Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f931ae281c | |||
| b9a744bdbc | |||
| b9201a437c | |||
| 6e46808c27 |
@@ -50,3 +50,7 @@ public/news/
|
||||
public/parts/
|
||||
public/operations-inbox/
|
||||
public/footage/
|
||||
|
||||
# Local Claude Code / MCP config — agent-specific, not project
|
||||
.mcp.json
|
||||
.claude/
|
||||
|
||||
+4
-4
@@ -60,9 +60,9 @@
|
||||
},
|
||||
"WhatWeDo": {
|
||||
"subtitle": "Was wir tun",
|
||||
"title": "Wir entwickeln fortschrittliche Hochfrequenztechnologien für die Industrien von morgen.",
|
||||
"title": "Wir entwickeln fortschrittliche Radio Frequency (RF) Technologien für die Industrien von morgen.",
|
||||
"desc": "Innovation, Effizienz und Nachhaltigkeit in jeder Lösung.",
|
||||
"tech": "Unsere Pulswellen-Technologie nutzt hochfrequente elektromagnetische Felder, um die Wassermoleküle in einem Produkt zu erhitzen.",
|
||||
"tech": "Unsere Pulse Wave Technologie nutzt hochfrequente elektromagnetische Felder, um die Wassermoleküle in einem Produkt zu erhitzen.",
|
||||
"process": "Wenn sie diesen Wellen ausgesetzt werden, vibrieren und rotieren die Moleküle Millionen Mal pro Sekunde.",
|
||||
"efficiency": "Diese intrinsische und unmittelbare Wärmeübertragung erfolgt gleichmäßig, was den Prozess deutlich schneller und energieeffizienter macht als herkömmliche Methoden.",
|
||||
"servicesSubtitle": "Unsere Dienstleistungen",
|
||||
@@ -125,7 +125,7 @@
|
||||
"title2": "Neu gedacht für 2026.",
|
||||
"p1_1": "Gegründet und geführt von Ingenieur ",
|
||||
"p1_2": "Patrizio Grando",
|
||||
"p1_3": ", der 2023 wieder in das Geschäft eintrat, um sein Erbe der Innovation fortzusetzen. FLUX Srl führt vier Jahrzehnte beispielloser Expertise in der Hochfrequenztechnologie fort.",
|
||||
"p1_3": ", der 2023 wieder in das Geschäft eintrat, um sein Erbe der Innovation fortzusetzen. FLUX Srl führt vier Jahrzehnte beispielloser Expertise in der Radio Frequency (RF) Technologie fort.",
|
||||
"p2": "Wir konzentrieren uns auf die Entwicklung modernster Solid-State-RF-Lösungen für innovative Märkte, in denen Wettbewerber nicht über die erforderliche Erfahrung und das detaillierte technische Fachwissen verfügen.",
|
||||
"button": "Lesen Sie den Deep Dive in Patrizios Erbe"
|
||||
},
|
||||
@@ -154,7 +154,7 @@
|
||||
"companyTitle": "Unternehmen",
|
||||
"hqTitle": "Hauptsitz",
|
||||
"techSolidState": "Solid-State RF",
|
||||
"techMicrowave": "Mikrowellensysteme",
|
||||
"techMicrowave": "Microwave Systems",
|
||||
"techEfficiency": "Energieeffizienz",
|
||||
"companyStory": "Unsere Geschichte",
|
||||
"companyMap": "Globales Netzwerk",
|
||||
|
||||
+7
-7
@@ -60,9 +60,9 @@
|
||||
},
|
||||
"WhatWeDo": {
|
||||
"subtitle": "Qué Hacemos",
|
||||
"title": "Desarrollamos tecnologías avanzadas de radiofrecuencia para impulsar las industrias del mañana.",
|
||||
"title": "Desarrollamos tecnologías avanzadas Radio Frequency (RF) para impulsar las industrias del mañana.",
|
||||
"desc": "Entregando innovación, eficiencia y sostenibilidad en cada solución.",
|
||||
"tech": "Nuestra tecnología de ondas pulsadas utiliza campos electromagnéticos de alta frecuencia para calentar las moléculas de agua dentro de un producto.",
|
||||
"tech": "Nuestra tecnología Pulse Wave utiliza campos electromagnéticos de alta frecuencia para calentar las moléculas de agua dentro de un producto.",
|
||||
"process": "Al exponerse a estas ondas, las moléculas vibran y giran millones de veces por segundo.",
|
||||
"efficiency": "Esta transferencia de calor intrínseca e inmediata ocurre de manera uniforme, haciendo que el proceso sea significativamente más rápido y eficiente energéticamente que los métodos convencionales.",
|
||||
"servicesSubtitle": "Nuestros Servicios",
|
||||
@@ -101,7 +101,7 @@
|
||||
"subtitle": "Aplicaciones de RF",
|
||||
"title1": "Diseñado para su industria.",
|
||||
"title2": "Optimizado para la eficiencia.",
|
||||
"desc": "Nuestra tecnología de RF de estado sólido es altamente flexible para la programación de producción. Seleccione una aplicación a continuación para ver el impacto en sus métricas operativas.",
|
||||
"desc": "Nuestra tecnología Solid-State RF es altamente flexible para la programación de producción. Seleccione una aplicación a continuación para ver el impacto en sus métricas operativas.",
|
||||
"calcROI": "Calcular ROI",
|
||||
"compareTech": "Comparar Tecnologías",
|
||||
"viewSpecs": "Ver Especificaciones"
|
||||
@@ -125,8 +125,8 @@
|
||||
"title2": "Reimaginado para 2026.",
|
||||
"p1_1": "Fundada y dirigida por el Ingeniero ",
|
||||
"p1_2": "Patrizio Grando",
|
||||
"p1_3": ", quien se reintegró a la empresa en 2023 para continuar su legado de innovación. FLUX Srl lleva adelante cuatro décadas de experiencia inigualable en tecnología de Radiofrecuencia.",
|
||||
"p2": "Nos enfocamos en desarrollar soluciones de RF de estado sólido de vanguardia para mercados innovadores, donde los competidores carecen de la experiencia necesaria y el conocimiento detallado de ingeniería.",
|
||||
"p1_3": ", quien se reintegró a la empresa en 2023 para continuar su legado de innovación. FLUX Srl lleva adelante cuatro décadas de experiencia inigualable en tecnología Radio Frequency (RF).",
|
||||
"p2": "Nos enfocamos en desarrollar soluciones Solid-State RF de vanguardia para mercados innovadores, donde los competidores carecen de la experiencia necesaria y el conocimiento detallado de ingeniería.",
|
||||
"button": "Leer más sobre el legado de Patrizio"
|
||||
},
|
||||
"CaseStudyModal": {
|
||||
@@ -153,8 +153,8 @@
|
||||
"appsTitle": "Aplicaciones",
|
||||
"companyTitle": "Empresa",
|
||||
"hqTitle": "Sede Central",
|
||||
"techSolidState": "RF de Estado Sólido",
|
||||
"techMicrowave": "Sistemas de Microondas",
|
||||
"techSolidState": "Solid-State RF",
|
||||
"techMicrowave": "Microwave Systems",
|
||||
"techEfficiency": "Eficiencia Energética",
|
||||
"companyStory": "Nuestra Historia",
|
||||
"companyMap": "Red Global",
|
||||
|
||||
+7
-7
@@ -60,9 +60,9 @@
|
||||
},
|
||||
"WhatWeDo": {
|
||||
"subtitle": "Cosa Facciamo",
|
||||
"title": "Sviluppiamo tecnologie avanzate a radiofrequenza per guidare le industrie di domani.",
|
||||
"title": "Sviluppiamo tecnologie avanzate Radio Frequency (RF) per guidare le industrie di domani.",
|
||||
"desc": "Innovazione, efficienza e sostenibilità in ogni soluzione.",
|
||||
"tech": "La nostra tecnologia a onde pulsate utilizza campi elettromagnetici ad alta frequenza per riscaldare le molecole d'acqua all'interno di un prodotto.",
|
||||
"tech": "La nostra tecnologia Pulse Wave utilizza campi elettromagnetici ad alta frequenza per riscaldare le molecole d'acqua all'interno di un prodotto.",
|
||||
"process": "Quando esposte a queste onde, le molecole vibrano e ruotano milioni di volte al secondo.",
|
||||
"efficiency": "Questo trasferimento di calore intrinseco e immediato avviene in modo uniforme, rendendo il processo significativamente più veloce ed efficiente dal punto di vista energetico rispetto ai metodi convenzionali.",
|
||||
"servicesSubtitle": "I Nostri Servizi",
|
||||
@@ -101,7 +101,7 @@
|
||||
"subtitle": "Applicazioni RF",
|
||||
"title1": "Progettato per la tua industria.",
|
||||
"title2": "Ottimizzato per l'efficienza.",
|
||||
"desc": "La nostra tecnologia RF a stato solido è altamente flessibile per la programmazione della produzione. Seleziona un'applicazione qui sotto per vedere l'impatto sulle tue metriche operative.",
|
||||
"desc": "La nostra tecnologia Solid-State RF è altamente flessibile per la programmazione della produzione. Seleziona un'applicazione qui sotto per vedere l'impatto sulle tue metriche operative.",
|
||||
"calcROI": "Calcola ROI",
|
||||
"compareTech": "Confronta Tecnologie",
|
||||
"viewSpecs": "Specifiche Complete"
|
||||
@@ -125,8 +125,8 @@
|
||||
"title2": "Reimmaginata per il 2026.",
|
||||
"p1_1": "Fondata e guidata dall'Ingegnere ",
|
||||
"p1_2": "Patrizio Grando",
|
||||
"p1_3": ", rientrato nel business nel 2023 per continuare il suo percorso di innovazione. FLUX Srl porta avanti quattro decenni di competenza senza pari nella tecnologia a Radiofrequenza.",
|
||||
"p2": "Ci concentriamo sullo sviluppo di soluzioni RF allo stato solido all'avanguardia per mercati innovativi, dove i competitor mancano dell'esperienza e della precisione ingegneristica necessaria.",
|
||||
"p1_3": ", rientrato nel business nel 2023 per continuare il suo percorso di innovazione. FLUX Srl porta avanti quattro decenni di competenza senza pari nella tecnologia Radio Frequency (RF).",
|
||||
"p2": "Ci concentriamo sullo sviluppo di soluzioni Solid-State RF all'avanguardia per mercati innovativi, dove i competitor mancano dell'esperienza e della precisione ingegneristica necessaria.",
|
||||
"button": "Leggi l'approfondimento sull'eredità di Patrizio"
|
||||
},
|
||||
"CaseStudyModal": {
|
||||
@@ -153,8 +153,8 @@
|
||||
"appsTitle": "Applicazioni",
|
||||
"companyTitle": "Azienda",
|
||||
"hqTitle": "Sede Centrale",
|
||||
"techSolidState": "RF a Stato Solido",
|
||||
"techMicrowave": "Sistemi a Microonde",
|
||||
"techSolidState": "Solid-State RF",
|
||||
"techMicrowave": "Microwave Systems",
|
||||
"techEfficiency": "Efficienza Energetica",
|
||||
"companyStory": "La nostra Storia",
|
||||
"companyMap": "Rete Globale",
|
||||
|
||||
+7
-7
@@ -60,9 +60,9 @@
|
||||
},
|
||||
"WhatWeDo": {
|
||||
"subtitle": "Cossa femo",
|
||||
"title": "Svilupemo tecnołogie de radiofrequensa par far ndar forte le industrie de doman.",
|
||||
"title": "Svilupemo tecnołogie Radio Frequency (RF) par far ndar forte le industrie de doman.",
|
||||
"desc": "Inovaçion, rendimento e rispeto par l'ambiente in ogni laoro.",
|
||||
"tech": "La nostra tecnołogia a onde pulsà la dopara i campi eletromagnetici par scaldar l'acqua drento i prodoti.",
|
||||
"tech": "La nostra tecnołogia Pulse Wave la dopara i campi eletromagnetici par scaldar l'acqua drento i prodoti.",
|
||||
"process": "Co le ciapa ste onde, le molecole le taca vibrar milioni de volte al secondo.",
|
||||
"efficiency": "Sto całore el vien drento subito e in modo conpagno dapartuto, fasendo el laoro tanto più de corsa dei sistemi de na volta.",
|
||||
"servicesSubtitle": "I nostri servisi",
|
||||
@@ -101,7 +101,7 @@
|
||||
"subtitle": "Applicaçion RF",
|
||||
"title1": "Fato aposta par el to laoro.",
|
||||
"title2": "Sempre al masimo de l'eficiensa.",
|
||||
"desc": "La nostra tecnołogia RF a stato sołido la xe flessibiłe par organizar el laoro. Sielsi n'applicaçion de soto par vedar come che la cambia i to numari.",
|
||||
"desc": "La nostra tecnołogia Solid-State RF la xe flessibiłe par organizar el laoro. Sielsi n'applicaçion de soto par vedar come che la cambia i to numari.",
|
||||
"calcROI": "Calcoła el guadagno",
|
||||
"compareTech": "Confronta tecnołogie",
|
||||
"viewSpecs": "Varda i detaji tecnici"
|
||||
@@ -125,8 +125,8 @@
|
||||
"title2": "Pensà de novo pal 2026.",
|
||||
"p1_1": "Fondà e guidà da l'Ingegnere ",
|
||||
"p1_2": "Patrizio Grando",
|
||||
"p1_3": ", che el xe tornà drento nel 2023 par ndar vanti con le so idee nove. FLUX Srl la va vanti con quaranta ani de esperiensa che no i ga nesun altro ne la Radiofrequensa.",
|
||||
"p2": "Semo concentrà nel far solusion RF a stato sołido moderne par mercà che i ga voja de inovaçion, dove i altri no i ga l'esperiensa e la preciçion de ingegneria che serve.",
|
||||
"p1_3": ", che el xe tornà drento nel 2023 par ndar vanti con le so idee nove. FLUX Srl la va vanti con quaranta ani de esperiensa che no i ga nesun altro ne la Radio Frequency (RF).",
|
||||
"p2": "Semo concentrà nel far solusion Solid-State RF moderne par mercà che i ga voja de inovaçion, dove i altri no i ga l'esperiensa e la preciçion de ingegneria che serve.",
|
||||
"button": "Lèzi l'aprofondimento su l'eredità de Patrizio"
|
||||
},
|
||||
"CaseStudyModal": {
|
||||
@@ -153,8 +153,8 @@
|
||||
"appsTitle": "Applicaçion",
|
||||
"companyTitle": "Dita",
|
||||
"hqTitle": "Sede Prinsipałe",
|
||||
"techSolidState": "RF a Stato Sołido",
|
||||
"techMicrowave": "Sistemi Microwave",
|
||||
"techSolidState": "Solid-State RF",
|
||||
"techMicrowave": "Microwave Systems",
|
||||
"techEfficiency": "Risparmio Energia",
|
||||
"companyStory": "La nostra Storia",
|
||||
"companyMap": "Rede Global",
|
||||
|
||||
+7
-1
@@ -6,12 +6,18 @@ const nextConfig = {
|
||||
output: "standalone" as const,
|
||||
images: {
|
||||
qualities: [75, 90, 100],
|
||||
// Image Optimizer cache TTL — keeps optimized variants for 5 min,
|
||||
// matching Nginx max-age. Picks up replaced source files quickly.
|
||||
minimumCacheTTL: 300,
|
||||
formats: ['image/avif', 'image/webp'] as ('image/avif' | 'image/webp')[],
|
||||
},
|
||||
reactStrictMode: true,
|
||||
serverExternalPackages: ['nodemailer'],
|
||||
experimental: {
|
||||
serverActions: {
|
||||
bodySizeLimit: '500mb' as const,
|
||||
// 50MB cap — large enough for hero images and CMS uploads,
|
||||
// small enough to limit DoS surface. Use /api/assets for big files.
|
||||
bodySizeLimit: '50mb' as const,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
+28
-12
@@ -36,6 +36,7 @@ server {
|
||||
|
||||
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
|
||||
|
||||
# Next.js bundles use content hashing — safe to cache forever
|
||||
location /_next/static/ {
|
||||
proxy_pass http://nextjs;
|
||||
expires 365d;
|
||||
@@ -43,6 +44,17 @@ server {
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# Next.js image optimizer — short cache, browser revalidates
|
||||
location /_next/image {
|
||||
proxy_pass http://nextjs;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
add_header Cache-Control "public, max-age=300, must-revalidate" always;
|
||||
}
|
||||
|
||||
location /hq-command/login {
|
||||
limit_req zone=login burst=10 nodelay;
|
||||
proxy_pass http://nextjs;
|
||||
@@ -104,46 +116,50 @@ server {
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# Serve uploaded assets directly from disk (bypass Next.js)
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# User-uploaded assets — served directly from disk (bypass Next.js)
|
||||
#
|
||||
# Cache strategy: short max-age + must-revalidate.
|
||||
# Browser caches for 5 minutes, then asks Nginx "did this change?"
|
||||
# via If-Modified-Since. Nginx auto-replies 304 (Not Modified) if the
|
||||
# file's mtime is unchanged, or serves the new file if it changed.
|
||||
# This means new CMS uploads appear within ~5 min without rebuild
|
||||
# AND saved bandwidth on unchanged files.
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
location /cases/ {
|
||||
alias /srv/cases/;
|
||||
expires 30d;
|
||||
add_header Cache-Control "public";
|
||||
add_header Cache-Control "public, max-age=300, must-revalidate" always;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
location /applications/ {
|
||||
alias /srv/applications/;
|
||||
expires 30d;
|
||||
add_header Cache-Control "public";
|
||||
add_header Cache-Control "public, max-age=300, must-revalidate" always;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
location /news/ {
|
||||
alias /srv/news/;
|
||||
expires 30d;
|
||||
add_header Cache-Control "public";
|
||||
add_header Cache-Control "public, max-age=300, must-revalidate" always;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
location /parts/ {
|
||||
alias /srv/parts/;
|
||||
expires 30d;
|
||||
add_header Cache-Control "public";
|
||||
add_header Cache-Control "public, max-age=300, must-revalidate" always;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
location /operations-inbox/ {
|
||||
alias /srv/operations-inbox/;
|
||||
expires 7d;
|
||||
add_header Cache-Control "private, max-age=60, must-revalidate" always;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
location /footage/ {
|
||||
alias /srv/footage/;
|
||||
expires 30d;
|
||||
add_header Cache-Control "public";
|
||||
add_header Cache-Control "public, max-age=300, must-revalidate" always;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- ADDITIVE MIGRATION — only adds new tables, never modifies or drops.
|
||||
-- Existing data (AdminUser, ClientUser w/ 2FA, GlobalNode, etc.) untouched.
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
-- HeroSlide: carousel slides shown on the home hero section.
|
||||
-- Replaces filesystem-scan of /public/footage/main with CMS control.
|
||||
CREATE TABLE IF NOT EXISTS "HeroSlide" (
|
||||
"id" TEXT NOT NULL,
|
||||
"mediaUrl" TEXT NOT NULL,
|
||||
"mediaType" TEXT NOT NULL DEFAULT 'image',
|
||||
"altText" TEXT,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"focalPointX" DOUBLE PRECISION NOT NULL DEFAULT 0.5,
|
||||
"focalPointY" DOUBLE PRECISION NOT NULL DEFAULT 0.5,
|
||||
"translationsJson" TEXT DEFAULT '{}',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "HeroSlide_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- SiteSetting: key-value config for favicon, logo, footer, OG image, etc.
|
||||
CREATE TABLE IF NOT EXISTS "SiteSetting" (
|
||||
"id" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"valueJson" TEXT NOT NULL DEFAULT '{}',
|
||||
"translationsJson" TEXT DEFAULT '{}',
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "SiteSetting_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "SiteSetting_key_key" ON "SiteSetting"("key");
|
||||
+47
-1
@@ -248,7 +248,53 @@ model PageContent {
|
||||
}
|
||||
|
||||
// ------------------------------------------------------
|
||||
// 11. CLIENT PORTAL (Usuarios B2B Aprobados) 🔥 NUEVO
|
||||
// 11. HERO REEL (Carrusel principal del Home)
|
||||
// ------------------------------------------------------
|
||||
// Manages the rotating images/videos shown in the home hero section.
|
||||
// Replaces the previous filesystem-scan approach (fs.readdirSync of
|
||||
// /public/footage/main) with full CMS control: ordering, on/off toggle,
|
||||
// focal-point per slide for proper responsive cropping on mobile/tablet,
|
||||
// and per-slide alt text for SEO.
|
||||
model HeroSlide {
|
||||
id String @id @default(cuid())
|
||||
mediaUrl String // Public path, e.g. "/footage/main/01_tifas.png"
|
||||
mediaType String @default("image") // "image" | "video"
|
||||
altText String? // For accessibility + SEO; falls back to title if null
|
||||
|
||||
order Int @default(0)
|
||||
isActive Boolean @default(true)
|
||||
|
||||
// Focal point for object-position on mobile/tablet crops (0–1 range).
|
||||
// Lets the editor pick "what should stay visible when the image is cropped".
|
||||
focalPointX Float @default(0.5)
|
||||
focalPointY Float @default(0.5)
|
||||
|
||||
// Optional per-slide caption that overrides the global Hero text.
|
||||
// Stored as JSON keyed by locale: {"en":{"title":"...","subtitle":"..."}}
|
||||
translationsJson String? @default("{}")
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// ------------------------------------------------------
|
||||
// 12. SITE SETTINGS (Favicon, Footer, Branding global)
|
||||
// ------------------------------------------------------
|
||||
// Single-row pattern (key-value) for global site config that doesn't
|
||||
// fit any other model: favicon, logos, footer, OG image, social links.
|
||||
model SiteSetting {
|
||||
id String @id @default(cuid())
|
||||
key String @unique // e.g. "favicon", "footer", "logo", "og_image", "hero_text"
|
||||
valueJson String @default("{}") // Flexible JSON payload per key
|
||||
|
||||
// 🌍 Translation engine (used for things like footer link labels)
|
||||
translationsJson String? @default("{}")
|
||||
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// ------------------------------------------------------
|
||||
// 13. CLIENT PORTAL (Usuarios B2B Aprobados)
|
||||
// ------------------------------------------------------
|
||||
model ClientUser {
|
||||
id String @id @default(cuid())
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
// ISR: revalidates every 60s + on-demand via revalidatePath after CMS uploads.
|
||||
export const revalidate = 60;
|
||||
|
||||
import Link from "next/link";
|
||||
import fs from "fs";
|
||||
@@ -46,8 +47,6 @@ export async function generateStaticParams() {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
// ISR: revalidates every 60s + on-demand via revalidatePath after CMS uploads.
|
||||
export const revalidate = 60;
|
||||
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
@@ -28,21 +29,21 @@ const renderMarkdown = (text: string) => {
|
||||
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">
|
||||
<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-[#111]">
|
||||
<tr className="bg-[#F5F5F7] dark: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'}`}>
|
||||
<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-[#1D1D1F] dark:text-white'}`}>
|
||||
{parseInline(th)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-black/40 backdrop-blur-md">
|
||||
<tbody className="bg-white/40 dark:bg-black/40 backdrop-blur-md">
|
||||
{tableRows.map((row, rIdx) => (
|
||||
<tr key={rIdx} className="hover:bg-white/[0.02] transition-colors group">
|
||||
<tr key={rIdx} className="hover:bg-black/[0.02] dark: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'}`}>
|
||||
<td key={cIdx} className={`p-5 border-b border-black/5 dark:border-white/5 text-sm ${cIdx === 0 ? 'text-[#6E6E73] dark:text-[#A1A1A6] 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>
|
||||
))}
|
||||
@@ -62,11 +63,11 @@ const renderMarkdown = (text: string) => {
|
||||
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]">
|
||||
<ol key={`ol-${elements.length}`} className="list-decimal ml-6 mb-8 text-[#6E6E73] 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-[#A1A1A6] space-y-2 text-lg font-light marker:text-[#00F0FF]">
|
||||
<ul key={`ul-${elements.length}`} className="list-disc ml-6 mb-8 text-[#6E6E73] dark:text-[#A1A1A6] space-y-2 text-lg font-light marker:text-[#0066CC] dark:marker:text-[#00F0FF]">
|
||||
{listItems}
|
||||
</ul>
|
||||
)
|
||||
@@ -81,11 +82,11 @@ const renderMarkdown = (text: string) => {
|
||||
|
||||
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>;
|
||||
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-white/90">{subPart}</em>;
|
||||
if (j % 2 === 1) return <em key={`${i}-${j}`} className="italic text-[#1D1D1F]/90 dark:text-white/90">{subPart}</em>;
|
||||
return subPart;
|
||||
});
|
||||
});
|
||||
@@ -119,7 +120,7 @@ const renderMarkdown = (text: string) => {
|
||||
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]">
|
||||
<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 grayscale hover:grayscale-0" loading="lazy" />
|
||||
</div>
|
||||
);
|
||||
@@ -127,19 +128,19 @@ const renderMarkdown = (text: string) => {
|
||||
}
|
||||
|
||||
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; }
|
||||
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-white tracking-tight">{parseInline(h2Match[1])}</h2>); return; }
|
||||
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-white tracking-tight">{parseInline(h1Match[1])}</h1>); return; }
|
||||
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-[#00F0FF] pl-5 py-2 my-8 text-xl font-light italic text-[#A1A1A6] bg-[#00F0FF]/5 rounded-r-xl">
|
||||
<blockquote key={idx} className="border-l-4 border-[#0066CC] dark:border-[#00F0FF] pl-5 py-2 my-8 text-xl font-light italic text-[#6E6E73] dark:text-[#A1A1A6] bg-[#0066CC]/5 dark:bg-[#00F0FF]/5 rounded-r-xl">
|
||||
{parseInline(quoteMatch[1])}
|
||||
</blockquote>
|
||||
);
|
||||
@@ -162,7 +163,7 @@ const renderMarkdown = (text: string) => {
|
||||
|
||||
pushList();
|
||||
elements.push(
|
||||
<p key={idx} className="text-[#A1A1A6] font-light leading-relaxed mb-6 text-lg">
|
||||
<p key={idx} className="text-[#6E6E73] dark:text-[#A1A1A6] font-light leading-relaxed mb-6 text-lg">
|
||||
{parseInline(trimmed)}
|
||||
</p>
|
||||
);
|
||||
@@ -195,12 +196,12 @@ export default async function HeritagePage({ params }: { params: Promise<{ local
|
||||
const sections = rawSections.map(sec => getLocalizedData(sec, locale));
|
||||
|
||||
return (
|
||||
<main className="relative min-h-screen pb-24 bg-[#0A0A0C]">
|
||||
<main className="relative min-h-screen pb-24 bg-[#F5F5F7] dark: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">
|
||||
<Link href={`/${locale}/#our-story`} className="inline-flex items-center gap-2 text-sm font-medium text-[#6E6E73] dark:text-[#86868B] hover:text-[#1D1D1F] dark:hover:text-white transition-colors py-2 px-4 bg-white/60 dark:bg-white/5 backdrop-blur-md rounded-full border border-black/10 dark:border-white/10">
|
||||
<ArrowLeft size={16} /> {t("backToOverview")}
|
||||
</Link>
|
||||
</div>
|
||||
@@ -209,10 +210,10 @@ export default async function HeritagePage({ params }: { params: Promise<{ local
|
||||
<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">
|
||||
<span className="text-[#0066CC] dark: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">
|
||||
<h1 className="text-5xl md:text-7xl font-light text-[#1D1D1F] dark:text-white tracking-tight leading-tight">
|
||||
{t("title1")} <br/> <span className="text-[#86868B]">{t("title2")}</span>
|
||||
</h1>
|
||||
</div>
|
||||
@@ -227,25 +228,22 @@ export default async function HeritagePage({ params }: { params: Promise<{ local
|
||||
<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>}
|
||||
{sec.title && <h2 className="text-3xl font-medium text-[#1D1D1F] dark: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">
|
||||
<div className="relative w-full h-80 md:h-[500px] rounded-3xl overflow-hidden border border-black/10 dark: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) — AUTOPLAY ON SCROLL 🔥 */}
|
||||
{sec.type === 'video' && sec.mediaUrl && (
|
||||
<div className="relative w-full aspect-video rounded-3xl overflow-hidden border border-white/10 shadow-2xl bg-black">
|
||||
<div className="relative w-full aspect-video rounded-3xl overflow-hidden border border-black/10 dark:border-white/10 shadow-2xl bg-black">
|
||||
<AutoPlayVideo
|
||||
src={`/heritage/videos/${sec.mediaUrl}`}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
|
||||
@@ -6,28 +6,54 @@ 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';
|
||||
import { getBranding } from '@/lib/siteSettings';
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
// Dynamic metadata pulls favicon, logos, OG image and theme color from the
|
||||
// SiteSetting CMS. Falls back to defaults when the table is empty.
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const branding = await getBranding();
|
||||
return {
|
||||
title: "FLUX | Energy, Directed.",
|
||||
description: "Advanced Radio Frequency Solutions by Patrizio Grando.",
|
||||
};
|
||||
icons: {
|
||||
icon: branding.faviconUrl,
|
||||
shortcut: branding.faviconUrl,
|
||||
apple: branding.appleTouchIconUrl,
|
||||
},
|
||||
openGraph: {
|
||||
title: "FLUX | Energy, Directed.",
|
||||
description: "Advanced Radio Frequency Solutions by Patrizio Grando.",
|
||||
images: branding.ogImageUrl ? [{ url: branding.ogImageUrl }] : undefined,
|
||||
type: "website",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "FLUX | Energy, Directed.",
|
||||
description: "Advanced Radio Frequency Solutions by Patrizio Grando.",
|
||||
images: branding.ogImageUrl ? [branding.ogImageUrl] : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
export async function generateViewport(): Promise<Viewport> {
|
||||
const branding = await getBranding();
|
||||
return {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
viewportFit: "cover",
|
||||
};
|
||||
themeColor: branding.themeColor,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
// ISR: revalidates every 60s + on-demand via revalidatePath after CMS uploads.
|
||||
export const revalidate = 60;
|
||||
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
@@ -7,8 +5,9 @@ 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
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
// ISR: revalidates every 60s + on-demand via revalidatePath after CMS uploads.
|
||||
export const revalidate = 60;
|
||||
|
||||
export default async function NewsHub({ params }: { params: Promise<{ locale: string }> }) {
|
||||
|
||||
@@ -15,25 +15,66 @@ import ApplicationsDeep from "@/components/sections/ApplicationsDeep";
|
||||
import HeroReel from "@/components/sections/HeroReel";
|
||||
import WhatWeDo from "@/components/sections/WhatWeDo";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
// ISR: page is statically generated, but revalidates on demand via
|
||||
// revalidatePath() after CMS uploads, plus a 60s safety window.
|
||||
export const revalidate = 60;
|
||||
|
||||
// ✅ 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[] = [];
|
||||
// --- 1. SLIDES DEL HERO (CMS-managed via HeroSlide model, fallback to filesystem) ---
|
||||
let heroSlides: Array<{
|
||||
mediaUrl: string;
|
||||
mediaType: string;
|
||||
altText: string | null;
|
||||
focalPointX: number;
|
||||
focalPointY: number;
|
||||
translationsJson: string | null;
|
||||
}> = [];
|
||||
|
||||
try {
|
||||
const dbSlides = await prisma.heroSlide.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: [{ order: "asc" }, { createdAt: "asc" }],
|
||||
select: {
|
||||
mediaUrl: true,
|
||||
mediaType: true,
|
||||
altText: true,
|
||||
focalPointX: true,
|
||||
focalPointY: true,
|
||||
translationsJson: true,
|
||||
},
|
||||
});
|
||||
heroSlides = dbSlides.map((s: any) => getLocalizedData(s, locale));
|
||||
} catch (error) {
|
||||
console.error("Error fetching hero slides from DB:", error);
|
||||
}
|
||||
|
||||
// Fallback: scan /public/footage/main when CMS has no active slides yet.
|
||||
// Lets the site keep working immediately after the migration runs but
|
||||
// before the editor populates HeroSlide rows.
|
||||
if (heroSlides.length === 0) {
|
||||
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}`);
|
||||
heroSlides = files
|
||||
.filter((file) => /\.(png|jpe?g|webp)$/i.test(file))
|
||||
.sort()
|
||||
.map((file) => ({
|
||||
mediaUrl: `/footage/main/${file}`,
|
||||
mediaType: "image",
|
||||
altText: null,
|
||||
focalPointX: 0.5,
|
||||
focalPointY: 0.5,
|
||||
translationsJson: null,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error reading footage directory:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 2. NODOS DEL GLOBO ---
|
||||
let mapNodes: any[] = [];
|
||||
@@ -91,7 +132,7 @@ export default async function Home({ params }: { params: Promise<{ locale: strin
|
||||
<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} />
|
||||
<HeroReel slides={heroSlides} />
|
||||
</div>
|
||||
<WhatWeDo />
|
||||
<div className="w-full overflow-hidden flex flex-col items-center">
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getLocalizedData } from "@/lib/i18nHelper";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
@@ -8,6 +6,7 @@ import ComponentGrid from "./_components/ComponentGrid";
|
||||
import { Metadata } from "next";
|
||||
import { getClientSession } from "@/app/actions/clientAuth";
|
||||
|
||||
// B2B portal — auth-gated, never cached.
|
||||
export const revalidate = 0;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
|
||||
+46
-11
@@ -30,6 +30,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { revalidateContent, type RevalidateScope } from "@/lib/revalidate";
|
||||
|
||||
const SCOPE_ROOTS: Record<string, string> = {
|
||||
applications: path.join(process.cwd(), "public", "applications"),
|
||||
@@ -37,8 +38,15 @@ const SCOPE_ROOTS: Record<string, string> = {
|
||||
news: path.join(process.cwd(), "public", "news"),
|
||||
// 🔥 NUEVO: Scope para el Component Matrix
|
||||
parts: path.join(process.cwd(), "public", "parts"),
|
||||
// 🔥 NUEVO: Hero carousel media (flat folder, slug ignored)
|
||||
footage: path.join(process.cwd(), "public", "footage", "main"),
|
||||
// 🔥 NUEVO: Site-wide brand assets (favicon, logo, OG image)
|
||||
branding: path.join(process.cwd(), "public", "branding"),
|
||||
};
|
||||
|
||||
// Scopes that ignore the `slug` parameter and write directly under their root.
|
||||
const FLAT_SCOPES = new Set(["footage", "branding"]);
|
||||
|
||||
const MEDIA_TYPES: Record<string, string[]> = {
|
||||
image: [".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg", ".avif"],
|
||||
video: [".mp4", ".webm", ".mov"],
|
||||
@@ -72,7 +80,18 @@ function sanitizePath(input: string): string {
|
||||
|
||||
function buildSafePath(scope: string, slug: string, subPath?: string): string | null {
|
||||
const root = SCOPE_ROOTS[scope];
|
||||
if (!root || !slug) return null;
|
||||
if (!root) return null;
|
||||
|
||||
// Flat scopes (footage, branding) ignore slug and operate directly on root.
|
||||
if (FLAT_SCOPES.has(scope)) {
|
||||
if (!subPath || subPath === "" || subPath === "/") return root;
|
||||
const cleaned = sanitizePath(subPath);
|
||||
const fullPath = path.join(root, cleaned);
|
||||
if (!path.resolve(fullPath).startsWith(path.resolve(root))) return null;
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
if (!slug) return null;
|
||||
const appDir = path.join(root, slug);
|
||||
if (!subPath || subPath === "" || subPath === "/") return appDir;
|
||||
const cleaned = sanitizePath(subPath);
|
||||
@@ -81,6 +100,12 @@ function buildSafePath(scope: string, slug: string, subPath?: string): string |
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
function buildPublicUrl(scope: string, slug: string, rel: string): string {
|
||||
if (scope === "footage") return `/footage/main/${rel}`;
|
||||
if (scope === "branding") return `/branding/${rel}`;
|
||||
return `/${scope}/${slug}/${rel}`;
|
||||
}
|
||||
|
||||
function buildBreadcrumbs(subPath: string) {
|
||||
const parts = subPath ? subPath.split("/").filter(Boolean) : [];
|
||||
const crumbs = [{ name: "Root", path: "" }];
|
||||
@@ -97,11 +122,11 @@ 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 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 });
|
||||
if (!FLAT_SCOPES.has(scope) && !slug) return NextResponse.json({ error: "Missing slug" }, { status: 400 });
|
||||
|
||||
const dirPath = buildSafePath(scope, slug, subPath);
|
||||
if (!dirPath) return NextResponse.json({ error: "Invalid path" }, { status: 400 });
|
||||
@@ -136,7 +161,7 @@ export async function GET(request: NextRequest) {
|
||||
mediaType: getFileType(entry.name),
|
||||
extension: path.extname(entry.name).toLowerCase(),
|
||||
path: rel,
|
||||
publicUrl: `/${scope}/${slug}/${rel}`,
|
||||
publicUrl: buildPublicUrl(scope, slug, rel),
|
||||
size: getFileSize(stats.size),
|
||||
sizeBytes: stats.size,
|
||||
modifiedAt: stats.mtime.toISOString(),
|
||||
@@ -165,12 +190,13 @@ 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 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 (!file) return NextResponse.json({ error: "Missing file" }, { status: 400 });
|
||||
if (!SCOPE_ROOTS[scope]) return NextResponse.json({ error: "Invalid scope" }, { status: 400 });
|
||||
if (!FLAT_SCOPES.has(scope) && !slug) return NextResponse.json({ error: "Missing slug" }, { status: 400 });
|
||||
|
||||
const ext = path.extname(file.name).toLowerCase();
|
||||
if (!ALL_EXTENSIONS.includes(ext)) {
|
||||
@@ -193,11 +219,14 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const rel = subPath ? `${subPath}/${safeName}` : safeName;
|
||||
|
||||
// 🔥 Invalida caché para que la imagen aparezca sin recompilar
|
||||
revalidateContent({ scope: scope as RevalidateScope, slug });
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
file: {
|
||||
name: safeName,
|
||||
publicUrl: `/${scope}/${slug}/${rel}`,
|
||||
publicUrl: buildPublicUrl(scope, slug, rel),
|
||||
path: rel,
|
||||
mediaType: getFileType(safeName),
|
||||
size: getFileSize(file.size),
|
||||
@@ -214,10 +243,11 @@ export async function POST(request: NextRequest) {
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { scope = "applications", slug, folderName, parentPath = "" } = body;
|
||||
const { scope = "applications", slug = "", folderName, parentPath = "" } = body;
|
||||
|
||||
if (!slug || !folderName) return NextResponse.json({ error: "Missing slug or folderName" }, { status: 400 });
|
||||
if (!folderName) return NextResponse.json({ error: "Missing folderName" }, { status: 400 });
|
||||
if (!SCOPE_ROOTS[scope]) return NextResponse.json({ error: "Invalid scope" }, { status: 400 });
|
||||
if (!FLAT_SCOPES.has(scope) && !slug) return NextResponse.json({ error: "Missing slug" }, { 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 });
|
||||
@@ -229,6 +259,8 @@ export async function PUT(request: NextRequest) {
|
||||
|
||||
fs.mkdirSync(targetPath, { recursive: true });
|
||||
|
||||
revalidateContent({ scope: scope as RevalidateScope, slug });
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
folder: { name: safe, path: parentPath ? `${parentPath}/${safe}` : safe }
|
||||
@@ -243,10 +275,11 @@ export async function PUT(request: NextRequest) {
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { scope = "applications", slug, filePath } = body;
|
||||
const { scope = "applications", slug = "", filePath } = body;
|
||||
|
||||
if (!slug || !filePath) return NextResponse.json({ error: "Missing params" }, { status: 400 });
|
||||
if (!filePath) return NextResponse.json({ error: "Missing filePath" }, { status: 400 });
|
||||
if (!SCOPE_ROOTS[scope]) return NextResponse.json({ error: "Invalid scope" }, { status: 400 });
|
||||
if (!FLAT_SCOPES.has(scope) && !slug) return NextResponse.json({ error: "Missing slug" }, { status: 400 });
|
||||
|
||||
const targetPath = buildSafePath(scope, slug, filePath);
|
||||
if (!targetPath) return NextResponse.json({ error: "Invalid path" }, { status: 400 });
|
||||
@@ -256,6 +289,8 @@ export async function DELETE(request: NextRequest) {
|
||||
|
||||
fs.unlinkSync(targetPath);
|
||||
|
||||
revalidateContent({ scope: scope as RevalidateScope, slug });
|
||||
|
||||
return NextResponse.json({ success: true, deleted: filePath });
|
||||
} catch (error) {
|
||||
console.error("Asset DELETE error:", error);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { revalidateContent } from "@/lib/revalidate";
|
||||
|
||||
// 1. REGLAS DE SEGURIDAD ESTRICTAS
|
||||
const ALLOWED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp', '.mp4', '.mov'];
|
||||
@@ -56,6 +57,9 @@ export async function POST(request: NextRequest) {
|
||||
// 6. DEVOLVER LA URL PÚBLICA PARA GUARDARLA EN POSTGRES
|
||||
const publicUrl = `/operations-inbox/${folderName}/${safeFileName}`;
|
||||
|
||||
// Invalida caché del operations-inbox / dashboard
|
||||
revalidateContent({ scope: "operations-inbox", slug: folderName });
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
url: publicUrl,
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { revalidateContent } from "@/lib/revalidate";
|
||||
import { translateContentForCMS } from "@/lib/aiTranslator";
|
||||
|
||||
export async function getHeroSlides() {
|
||||
try {
|
||||
const slides = await prisma.heroSlide.findMany({
|
||||
orderBy: [{ order: "asc" }, { createdAt: "asc" }],
|
||||
});
|
||||
return { success: true, slides };
|
||||
} catch (error: any) {
|
||||
return { error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function createHeroSlide(input: {
|
||||
mediaUrl: string;
|
||||
mediaType?: string;
|
||||
altText?: string;
|
||||
order?: number;
|
||||
}) {
|
||||
try {
|
||||
const last = await prisma.heroSlide.findFirst({
|
||||
orderBy: { order: "desc" },
|
||||
select: { order: true },
|
||||
});
|
||||
const nextOrder = input.order ?? (last ? last.order + 1 : 0);
|
||||
|
||||
const slide = await prisma.heroSlide.create({
|
||||
data: {
|
||||
mediaUrl: input.mediaUrl,
|
||||
mediaType: input.mediaType || "image",
|
||||
altText: input.altText || null,
|
||||
order: nextOrder,
|
||||
},
|
||||
});
|
||||
|
||||
revalidateContent({ scope: "hero" });
|
||||
return { success: true, slide };
|
||||
} catch (error: any) {
|
||||
return { error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateHeroSlide(
|
||||
id: string,
|
||||
patch: {
|
||||
mediaUrl?: string;
|
||||
mediaType?: string;
|
||||
altText?: string | null;
|
||||
isActive?: boolean;
|
||||
focalPointX?: number;
|
||||
focalPointY?: number;
|
||||
order?: number;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
description1?: string;
|
||||
description2?: string;
|
||||
autoTranslate?: boolean;
|
||||
}
|
||||
) {
|
||||
try {
|
||||
const data: any = {};
|
||||
if (patch.mediaUrl !== undefined) data.mediaUrl = patch.mediaUrl;
|
||||
if (patch.mediaType !== undefined) data.mediaType = patch.mediaType;
|
||||
if (patch.altText !== undefined) data.altText = patch.altText;
|
||||
if (patch.isActive !== undefined) data.isActive = patch.isActive;
|
||||
if (patch.focalPointX !== undefined) data.focalPointX = patch.focalPointX;
|
||||
if (patch.focalPointY !== undefined) data.focalPointY = patch.focalPointY;
|
||||
if (patch.order !== undefined) data.order = patch.order;
|
||||
|
||||
// Per-slide caption overrides + AI translation
|
||||
if (
|
||||
patch.title !== undefined ||
|
||||
patch.subtitle !== undefined ||
|
||||
patch.description1 !== undefined ||
|
||||
patch.description2 !== undefined
|
||||
) {
|
||||
const existing = await prisma.heroSlide.findUnique({ where: { id } });
|
||||
const baseTranslations = existing?.translationsJson
|
||||
? safeParse(existing.translationsJson, {})
|
||||
: {};
|
||||
|
||||
const englishOverrides: Record<string, string> = {};
|
||||
if (patch.title !== undefined) englishOverrides.title = patch.title;
|
||||
if (patch.subtitle !== undefined) englishOverrides.subtitle = patch.subtitle;
|
||||
if (patch.description1 !== undefined) englishOverrides.description1 = patch.description1;
|
||||
if (patch.description2 !== undefined) englishOverrides.description2 = patch.description2;
|
||||
|
||||
const merged: Record<string, any> = { ...baseTranslations, en: { ...baseTranslations.en, ...englishOverrides } };
|
||||
|
||||
if (patch.autoTranslate) {
|
||||
const aiResult = await translateContentForCMS(englishOverrides);
|
||||
if (aiResult) {
|
||||
for (const [locale, fields] of Object.entries(aiResult)) {
|
||||
merged[locale] = { ...merged[locale], ...(fields as Record<string, string>) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data.translationsJson = JSON.stringify(merged);
|
||||
}
|
||||
|
||||
const slide = await prisma.heroSlide.update({ where: { id }, data });
|
||||
revalidateContent({ scope: "hero" });
|
||||
return { success: true, slide };
|
||||
} catch (error: any) {
|
||||
return { error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteHeroSlide(id: string) {
|
||||
try {
|
||||
await prisma.heroSlide.delete({ where: { id } });
|
||||
revalidateContent({ scope: "hero" });
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
return { error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function reorderHeroSlides(orderedIds: string[]) {
|
||||
try {
|
||||
await prisma.$transaction(
|
||||
orderedIds.map((id, idx) =>
|
||||
prisma.heroSlide.update({ where: { id }, data: { order: idx } })
|
||||
)
|
||||
);
|
||||
revalidateContent({ scope: "hero" });
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
return { error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
function safeParse<T>(json: string | null | undefined, fallback: T): any {
|
||||
if (!json) return fallback;
|
||||
try {
|
||||
return JSON.parse(json);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
"use client";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ArrowLeft, Image as ImageIcon, Plus, Trash2, Loader2, GripVertical,
|
||||
Eye, EyeOff, Crosshair, Sparkles, Upload, Check, X, ChevronDown,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
getHeroSlides,
|
||||
createHeroSlide,
|
||||
updateHeroSlide,
|
||||
deleteHeroSlide,
|
||||
reorderHeroSlides,
|
||||
} from "./actions";
|
||||
|
||||
interface SlideRow {
|
||||
id: string;
|
||||
mediaUrl: string;
|
||||
mediaType: string;
|
||||
altText: string | null;
|
||||
isActive: boolean;
|
||||
focalPointX: number;
|
||||
focalPointY: number;
|
||||
order: number;
|
||||
translationsJson: string | null;
|
||||
}
|
||||
|
||||
function safeParseJson<T>(json: string | null | undefined, fallback: T): any {
|
||||
if (!json) return fallback;
|
||||
try { return JSON.parse(json); } catch { return fallback; }
|
||||
}
|
||||
|
||||
export default function HeroDashboard() {
|
||||
const [slides, setSlides] = useState<SlideRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [savingId, setSavingId] = useState<string | null>(null);
|
||||
const [savedFlash, setSavedFlash] = useState<string | null>(null);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [uploadHover, setUploadHover] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState("");
|
||||
const [draggedId, setDraggedId] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const loadSlides = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const res = await getHeroSlides();
|
||||
if (res.success && res.slides) setSlides(res.slides as SlideRow[]);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadSlides(); }, [loadSlides]);
|
||||
|
||||
const flashSaved = (id: string) => {
|
||||
setSavedFlash(id);
|
||||
setTimeout(() => setSavedFlash(null), 1500);
|
||||
};
|
||||
|
||||
// ─── Upload ─────────────────────────────────────────────────────
|
||||
const uploadFile = async (file: File) => {
|
||||
setIsUploading(true);
|
||||
setUploadProgress(`Uploading ${file.name}...`);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("scope", "footage");
|
||||
fd.append("file", file);
|
||||
const res = await fetch("/api/assets", { method: "POST", body: fd });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
await createHeroSlide({
|
||||
mediaUrl: data.file.publicUrl,
|
||||
mediaType: data.file.mediaType === "video" ? "video" : "image",
|
||||
altText: file.name.replace(/\.[^.]+$/, "").replace(/[-_]/g, " "),
|
||||
});
|
||||
setUploadProgress(`✓ ${data.file.name}`);
|
||||
setTimeout(() => setUploadProgress(""), 1800);
|
||||
await loadSlides();
|
||||
} else {
|
||||
setUploadProgress(`✗ ${data.error}`);
|
||||
setTimeout(() => setUploadProgress(""), 4000);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setUploadProgress(`✗ ${err.message}`);
|
||||
setTimeout(() => setUploadProgress(""), 4000);
|
||||
}
|
||||
setIsUploading(false);
|
||||
};
|
||||
|
||||
const handleFiles = (files: FileList | null) => {
|
||||
if (!files) return;
|
||||
Array.from(files).forEach(uploadFile);
|
||||
};
|
||||
|
||||
// ─── Updates with auto-save ─────────────────────────────────────
|
||||
const patchSlide = async (id: string, patch: Partial<SlideRow>) => {
|
||||
setSlides((prev) => prev.map((s) => (s.id === id ? { ...s, ...patch } : s)));
|
||||
setSavingId(id);
|
||||
const res = await updateHeroSlide(id, patch as any);
|
||||
setSavingId(null);
|
||||
if (res.success) flashSaved(id);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm("Delete this slide? The image file stays on disk and can be re-added.")) return;
|
||||
await deleteHeroSlide(id);
|
||||
await loadSlides();
|
||||
};
|
||||
|
||||
// ─── Drag and drop reorder ──────────────────────────────────────
|
||||
const onDragStart = (id: string) => setDraggedId(id);
|
||||
const onDragOver = (e: React.DragEvent) => e.preventDefault();
|
||||
const onDrop = async (targetId: string) => {
|
||||
if (!draggedId || draggedId === targetId) return;
|
||||
const ids = slides.map((s) => s.id);
|
||||
const fromIdx = ids.indexOf(draggedId);
|
||||
const toIdx = ids.indexOf(targetId);
|
||||
if (fromIdx < 0 || toIdx < 0) return;
|
||||
const reordered = [...ids];
|
||||
reordered.splice(fromIdx, 1);
|
||||
reordered.splice(toIdx, 0, draggedId);
|
||||
setSlides((prev) => reordered.map((id, i) => ({ ...prev.find((s) => s.id === id)!, order: i })));
|
||||
setDraggedId(null);
|
||||
await reorderHeroSlides(reordered);
|
||||
};
|
||||
|
||||
// ─── Focal point picker ─────────────────────────────────────────
|
||||
const onFocalClick = (id: string, e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) / rect.width;
|
||||
const y = (e.clientY - rect.top) / rect.height;
|
||||
const clamp = (v: number) => Math.max(0, Math.min(1, v));
|
||||
patchSlide(id, { focalPointX: clamp(x), focalPointY: clamp(y) });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-6 md:p-12 max-w-6xl mx-auto">
|
||||
{/* Header */}
|
||||
<Link
|
||||
href="/hq-command/dashboard"
|
||||
className="inline-flex items-center gap-2 text-sm text-[#86868B] hover:text-white mb-6 transition-colors"
|
||||
>
|
||||
<ArrowLeft size={14} /> Back to Dashboard
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-[#00F0FF] mb-2">
|
||||
<ImageIcon size={16} />
|
||||
<span className="text-[10px] uppercase tracking-widest font-bold">Home Hero Carousel</span>
|
||||
</div>
|
||||
<h1 className="text-3xl md:text-4xl font-light text-white">
|
||||
Hero <span className="font-medium">Slides.</span>
|
||||
</h1>
|
||||
<p className="text-[#86868B] mt-2 text-sm">
|
||||
Drag to reorder. Click an image to set its focal point. Auto-saves on every change.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
onDragEnter={(e) => { e.preventDefault(); setUploadHover(true); }}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDragLeave={() => setUploadHover(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setUploadHover(false);
|
||||
handleFiles(e.dataTransfer.files);
|
||||
}}
|
||||
className={`mb-8 border-2 border-dashed rounded-3xl p-10 text-center transition-all cursor-pointer ${
|
||||
uploadHover
|
||||
? "border-[#00F0FF] bg-[#00F0FF]/5"
|
||||
: "border-white/10 bg-white/[0.02] hover:bg-white/[0.04]"
|
||||
}`}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => { handleFiles(e.target.files); e.target.value = ""; }}
|
||||
/>
|
||||
<Upload size={32} className="mx-auto text-[#00F0FF] mb-3 opacity-60" />
|
||||
<div className="text-white font-medium mb-1">
|
||||
{isUploading ? uploadProgress : "Drop images or videos here"}
|
||||
</div>
|
||||
<div className="text-xs text-[#86868B]">
|
||||
PNG, JPG, WebP, MP4 — recommended 2560×1440 landscape, under 8MB
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Slides list */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20 text-[#86868B]">
|
||||
<Loader2 className="animate-spin mr-2" size={16} /> Loading slides…
|
||||
</div>
|
||||
) : slides.length === 0 ? (
|
||||
<div className="text-center py-20 text-[#86868B] bg-white/[0.02] rounded-3xl border border-white/5">
|
||||
<ImageIcon size={32} className="mx-auto mb-3 opacity-40" />
|
||||
<p>No hero slides yet.</p>
|
||||
<p className="text-xs mt-1">The home page will fall back to /public/footage/main until you add some.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{slides.map((slide) => {
|
||||
const isExpanded = expandedId === slide.id;
|
||||
const isSaving = savingId === slide.id;
|
||||
const justSaved = savedFlash === slide.id;
|
||||
const en = safeParseJson(slide.translationsJson, {})?.en || {};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={slide.id}
|
||||
draggable
|
||||
onDragStart={() => onDragStart(slide.id)}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={() => onDrop(slide.id)}
|
||||
className={`bg-[#111] border rounded-2xl overflow-hidden transition-all ${
|
||||
draggedId === slide.id ? "opacity-50" : ""
|
||||
} ${slide.isActive ? "border-white/10" : "border-white/5 opacity-60"}`}
|
||||
>
|
||||
{/* Row */}
|
||||
<div className="flex items-center gap-3 p-3">
|
||||
<button className="cursor-grab text-[#86868B] hover:text-white p-1" title="Drag to reorder">
|
||||
<GripVertical size={16} />
|
||||
</button>
|
||||
|
||||
{/* Thumbnail with focal-point picker */}
|
||||
<div
|
||||
onClick={(e) => onFocalClick(slide.id, e)}
|
||||
className="relative w-32 h-20 rounded-lg overflow-hidden bg-black flex-shrink-0 cursor-crosshair group"
|
||||
title="Click to set focal point"
|
||||
>
|
||||
{slide.mediaType === "video" ? (
|
||||
<video
|
||||
src={slide.mediaUrl}
|
||||
muted
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
style={{
|
||||
objectPosition: `${slide.focalPointX * 100}% ${slide.focalPointY * 100}%`,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={slide.mediaUrl}
|
||||
alt={slide.altText || ""}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
style={{
|
||||
objectPosition: `${slide.focalPointX * 100}% ${slide.focalPointY * 100}%`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Focal point indicator */}
|
||||
<div
|
||||
className="absolute w-4 h-4 -ml-2 -mt-2 border-2 border-white rounded-full pointer-events-none shadow-lg"
|
||||
style={{
|
||||
left: `${slide.focalPointX * 100}%`,
|
||||
top: `${slide.focalPointY * 100}%`,
|
||||
boxShadow: "0 0 0 2px rgba(0,0,0,0.5)",
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Crosshair size={20} className="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alt text + URL */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<input
|
||||
type="text"
|
||||
value={slide.altText || ""}
|
||||
onChange={(e) => patchSlide(slide.id, { altText: e.target.value })}
|
||||
placeholder="Alt text (for SEO + accessibility)"
|
||||
className="w-full bg-transparent border-0 outline-none text-white text-sm placeholder:text-[#86868B] focus:bg-white/[0.04] rounded px-2 py-1 -mx-2 -my-1"
|
||||
/>
|
||||
<div className="text-[10px] text-[#86868B] truncate font-mono mt-1">
|
||||
{slide.mediaUrl}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status indicators */}
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
{isSaving && <Loader2 size={12} className="animate-spin text-[#00F0FF]" />}
|
||||
{justSaved && (
|
||||
<span className="text-emerald-400 flex items-center gap-1">
|
||||
<Check size={12} /> Saved
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<button
|
||||
onClick={() => patchSlide(slide.id, { isActive: !slide.isActive })}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
slide.isActive
|
||||
? "text-emerald-400 hover:bg-emerald-500/10"
|
||||
: "text-[#86868B] hover:bg-white/5"
|
||||
}`}
|
||||
title={slide.isActive ? "Hide from carousel" : "Show in carousel"}
|
||||
>
|
||||
{slide.isActive ? <Eye size={16} /> : <EyeOff size={16} />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setExpandedId(isExpanded ? null : slide.id)}
|
||||
className="p-2 rounded-lg text-[#86868B] hover:bg-white/5 hover:text-white"
|
||||
title="Caption overrides"
|
||||
>
|
||||
<Sparkles size={16} className={isExpanded ? "text-[#00F0FF]" : ""} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleDelete(slide.id)}
|
||||
className="p-2 rounded-lg text-rose-400 hover:bg-rose-500/10"
|
||||
title="Delete slide"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Expanded — caption overrides */}
|
||||
{isExpanded && (
|
||||
<CaptionEditor
|
||||
initial={{
|
||||
title: en.title || "",
|
||||
subtitle: en.subtitle || "",
|
||||
description1: en.description1 || "",
|
||||
description2: en.description2 || "",
|
||||
}}
|
||||
onSave={async (vals, autoTranslate) => {
|
||||
setSavingId(slide.id);
|
||||
await updateHeroSlide(slide.id, { ...vals, autoTranslate });
|
||||
setSavingId(null);
|
||||
flashSaved(slide.id);
|
||||
await loadSlides();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Caption editor ───────────────────────────────────────────────
|
||||
function CaptionEditor({
|
||||
initial,
|
||||
onSave,
|
||||
}: {
|
||||
initial: { title: string; subtitle: string; description1: string; description2: string };
|
||||
onSave: (vals: typeof initial, autoTranslate: boolean) => Promise<void>;
|
||||
}) {
|
||||
const [vals, setVals] = useState(initial);
|
||||
const [autoTranslate, setAutoTranslate] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="border-t border-white/5 bg-white/[0.02] p-5 space-y-3">
|
||||
<div className="text-[10px] uppercase tracking-widest text-[#86868B] font-bold mb-3">
|
||||
Caption overrides (English — leave empty to use site defaults)
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<input
|
||||
value={vals.title}
|
||||
onChange={(e) => setVals({ ...vals, title: e.target.value })}
|
||||
placeholder="Title (e.g. LET THE POWER FLUX)"
|
||||
className="bg-black/40 border border-white/10 text-white text-sm rounded-lg px-3 py-2 outline-none focus:border-[#00F0FF]/40"
|
||||
/>
|
||||
<input
|
||||
value={vals.subtitle}
|
||||
onChange={(e) => setVals({ ...vals, subtitle: e.target.value })}
|
||||
placeholder="Subtitle (e.g. INNOVATION NOT IMITATION)"
|
||||
className="bg-black/40 border border-white/10 text-white text-sm rounded-lg px-3 py-2 outline-none focus:border-[#00F0FF]/40"
|
||||
/>
|
||||
<input
|
||||
value={vals.description1}
|
||||
onChange={(e) => setVals({ ...vals, description1: e.target.value })}
|
||||
placeholder="Description line 1"
|
||||
className="bg-black/40 border border-white/10 text-white text-sm rounded-lg px-3 py-2 outline-none focus:border-[#00F0FF]/40 md:col-span-2"
|
||||
/>
|
||||
<input
|
||||
value={vals.description2}
|
||||
onChange={(e) => setVals({ ...vals, description2: e.target.value })}
|
||||
placeholder="Description line 2"
|
||||
className="bg-black/40 border border-white/10 text-white text-sm rounded-lg px-3 py-2 outline-none focus:border-[#00F0FF]/40 md:col-span-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-[#86868B] cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoTranslate}
|
||||
onChange={(e) => setAutoTranslate(e.target.checked)}
|
||||
className="accent-[#00F0FF]"
|
||||
/>
|
||||
Auto-translate to IT, VEC, ES, DE with AI
|
||||
</label>
|
||||
|
||||
<button
|
||||
onClick={async () => {
|
||||
setSaving(true);
|
||||
await onSave(vals, autoTranslate);
|
||||
setSaving(false);
|
||||
}}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 bg-[#00F0FF] text-black text-sm font-medium rounded-lg hover:bg-[#00F0FF]/80 transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
|
||||
Save captions
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,9 @@ import {
|
||||
LogOut,
|
||||
Radar,
|
||||
Wrench,
|
||||
Server
|
||||
Server,
|
||||
Image as ImageIcon,
|
||||
Settings as SettingsIcon,
|
||||
} from "lucide-react";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logoutAdmin } from "@/app/hq-command/login/actions";
|
||||
@@ -27,6 +29,15 @@ export default async function DashboardPage() {
|
||||
const appsCount = await prisma.application.count({ where: { isActive: true } });
|
||||
|
||||
const modules = [
|
||||
{
|
||||
title: "Hero Carousel",
|
||||
description: "Manage the rotating images and videos on the home page hero section.",
|
||||
icon: ImageIcon,
|
||||
href: "/hq-command/dashboard/hero",
|
||||
color: "text-[#FF6B9D]",
|
||||
bg: "bg-[#FF6B9D]/10",
|
||||
border: "hover:border-[#FF6B9D]/50"
|
||||
},
|
||||
{
|
||||
title: "Global Network",
|
||||
description: "Manage physical installations, nodes, and events on the 3D Holographic Map.",
|
||||
@@ -107,6 +118,15 @@ export default async function DashboardPage() {
|
||||
color: "text-blue-400",
|
||||
bg: "bg-blue-400/10",
|
||||
border: "hover:border-blue-400/50"
|
||||
},
|
||||
{
|
||||
title: "Site Settings",
|
||||
description: "Branding, favicon, footer, social links — global config across the site.",
|
||||
icon: SettingsIcon,
|
||||
href: "/hq-command/dashboard/settings",
|
||||
color: "text-fuchsia-400",
|
||||
bg: "bg-fuchsia-500/10",
|
||||
border: "hover:border-fuchsia-500/50"
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { revalidateContent } from "@/lib/revalidate";
|
||||
import {
|
||||
DEFAULT_BRANDING,
|
||||
DEFAULT_FOOTER,
|
||||
DEFAULT_SOCIAL,
|
||||
type BrandingSettings,
|
||||
type FooterSettings,
|
||||
type SocialSettings,
|
||||
} from "@/lib/siteSettingsTypes";
|
||||
import { translateContentForCMS } from "@/lib/aiTranslator";
|
||||
|
||||
function safeParse<T>(json: string | null | undefined, fallback: T): T {
|
||||
if (!json) return fallback;
|
||||
try {
|
||||
return JSON.parse(json) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAllSettingsForEditor() {
|
||||
try {
|
||||
const rows = await prisma.siteSetting.findMany();
|
||||
const map = Object.fromEntries(rows.map((r: any) => [r.key, r]));
|
||||
|
||||
const brandingValue = safeParse<Partial<BrandingSettings>>(
|
||||
map.branding?.valueJson,
|
||||
{} as Partial<BrandingSettings>
|
||||
);
|
||||
const footerValue = safeParse<Partial<FooterSettings>>(
|
||||
map.footer?.valueJson,
|
||||
{} as Partial<FooterSettings>
|
||||
);
|
||||
const socialValue = safeParse<Partial<SocialSettings>>(
|
||||
map.social?.valueJson,
|
||||
{} as Partial<SocialSettings>
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
branding: { ...DEFAULT_BRANDING, ...brandingValue },
|
||||
footer: { ...DEFAULT_FOOTER, ...footerValue },
|
||||
social: { ...DEFAULT_SOCIAL, ...socialValue },
|
||||
footerTranslations: safeParse<Record<string, Partial<FooterSettings>>>(
|
||||
map.footer?.translationsJson,
|
||||
{}
|
||||
),
|
||||
};
|
||||
} catch (error: any) {
|
||||
return { error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertSetting(
|
||||
key: string,
|
||||
valueJson: string,
|
||||
translationsJson?: string
|
||||
) {
|
||||
await prisma.siteSetting.upsert({
|
||||
where: { key },
|
||||
update: {
|
||||
valueJson,
|
||||
...(translationsJson !== undefined ? { translationsJson } : {}),
|
||||
},
|
||||
create: {
|
||||
key,
|
||||
valueJson,
|
||||
translationsJson: translationsJson ?? "{}",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveBranding(branding: BrandingSettings) {
|
||||
try {
|
||||
await upsertSetting("branding", JSON.stringify(branding));
|
||||
revalidateContent({ scope: "branding" });
|
||||
revalidateContent({ scope: "settings" });
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
return { error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveFooter(footer: FooterSettings, autoTranslate: boolean) {
|
||||
try {
|
||||
let translationsJson: string | undefined;
|
||||
|
||||
if (autoTranslate) {
|
||||
const translatable: Record<string, string> = {
|
||||
ctaTitle1: footer.ctaTitle1,
|
||||
ctaTitle2: footer.ctaTitle2,
|
||||
ctaSubtitle: footer.ctaSubtitle,
|
||||
hqRegion: footer.hqRegion,
|
||||
hqCountry: footer.hqCountry,
|
||||
};
|
||||
const aiResult = await translateContentForCMS(translatable);
|
||||
if (aiResult) {
|
||||
const merged: Record<string, any> = {};
|
||||
for (const [locale, fields] of Object.entries(aiResult)) {
|
||||
merged[locale] = { valueJson: JSON.stringify({ ...footer, ...(fields as any) }) };
|
||||
// We store the per-locale partial under translationsJson, with each
|
||||
// locale containing only the fields that should be overridden.
|
||||
merged[locale] = fields;
|
||||
}
|
||||
translationsJson = JSON.stringify(merged);
|
||||
}
|
||||
}
|
||||
|
||||
await upsertSetting("footer", JSON.stringify(footer), translationsJson);
|
||||
revalidateContent({ scope: "settings" });
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
return { error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveSocial(social: SocialSettings) {
|
||||
try {
|
||||
await upsertSetting("social", JSON.stringify(social));
|
||||
revalidateContent({ scope: "settings" });
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
return { error: error.message };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
"use client";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ArrowLeft, Settings as SettingsIcon, Loader2, Check, Upload,
|
||||
Image as ImageIcon, Type, Share2, Sparkles, Info,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
getAllSettingsForEditor,
|
||||
saveBranding,
|
||||
saveFooter,
|
||||
saveSocial,
|
||||
} from "./actions";
|
||||
import {
|
||||
DEFAULT_BRANDING,
|
||||
DEFAULT_FOOTER,
|
||||
DEFAULT_SOCIAL,
|
||||
type BrandingSettings,
|
||||
type FooterSettings,
|
||||
type SocialSettings,
|
||||
} from "@/lib/siteSettingsTypes";
|
||||
|
||||
type Tab = "branding" | "footer" | "social";
|
||||
|
||||
export default function SiteSettingsPage() {
|
||||
const [tab, setTab] = useState<Tab>("branding");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [savedFlash, setSavedFlash] = useState<string | null>(null);
|
||||
|
||||
const [branding, setBranding] = useState<BrandingSettings>(DEFAULT_BRANDING);
|
||||
const [footer, setFooter] = useState<FooterSettings>(DEFAULT_FOOTER);
|
||||
const [social, setSocial] = useState<SocialSettings>(DEFAULT_SOCIAL);
|
||||
const [autoTranslateFooter, setAutoTranslateFooter] = useState(false);
|
||||
|
||||
const flash = (id: string) => {
|
||||
setSavedFlash(id);
|
||||
setTimeout(() => setSavedFlash(null), 1500);
|
||||
};
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const res = await getAllSettingsForEditor();
|
||||
if (res.success) {
|
||||
if (res.branding) setBranding(res.branding);
|
||||
if (res.footer) setFooter(res.footer);
|
||||
if (res.social) setSocial(res.social);
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const onSaveBranding = async () => {
|
||||
setSaving(true);
|
||||
await saveBranding(branding);
|
||||
setSaving(false);
|
||||
flash("branding");
|
||||
};
|
||||
|
||||
const onSaveFooter = async () => {
|
||||
setSaving(true);
|
||||
await saveFooter(footer, autoTranslateFooter);
|
||||
setSaving(false);
|
||||
flash("footer");
|
||||
};
|
||||
|
||||
const onSaveSocial = async () => {
|
||||
setSaving(true);
|
||||
await saveSocial(social);
|
||||
setSaving(false);
|
||||
flash("social");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-6 md:p-12 max-w-5xl mx-auto">
|
||||
<Link
|
||||
href="/hq-command/dashboard"
|
||||
className="inline-flex items-center gap-2 text-sm text-[#86868B] hover:text-white mb-6 transition-colors"
|
||||
>
|
||||
<ArrowLeft size={14} /> Back to Dashboard
|
||||
</Link>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-2 text-[#00F0FF] mb-2">
|
||||
<SettingsIcon size={16} />
|
||||
<span className="text-[10px] uppercase tracking-widest font-bold">Site Settings</span>
|
||||
</div>
|
||||
<h1 className="text-3xl md:text-4xl font-light text-white">
|
||||
Branding & <span className="font-medium">Global Content.</span>
|
||||
</h1>
|
||||
<p className="text-[#86868B] mt-2 text-sm">
|
||||
Favicon, logo, footer, social links — applies site-wide across all locales.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 mb-8 border-b border-white/10">
|
||||
{[
|
||||
{ id: "branding", label: "Branding", icon: ImageIcon },
|
||||
{ id: "footer", label: "Footer", icon: Type },
|
||||
{ id: "social", label: "Social", icon: Share2 },
|
||||
].map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setTab(t.id as Tab)}
|
||||
className={`flex items-center gap-2 px-5 py-3 text-sm font-medium border-b-2 transition-all ${
|
||||
tab === t.id
|
||||
? "border-[#00F0FF] text-[#00F0FF]"
|
||||
: "border-transparent text-[#86868B] hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<t.icon size={14} /> {t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20 text-[#86868B]">
|
||||
<Loader2 className="animate-spin mr-2" size={16} /> Loading settings…
|
||||
</div>
|
||||
) : tab === "branding" ? (
|
||||
<BrandingTab
|
||||
value={branding}
|
||||
onChange={setBranding}
|
||||
onSave={onSaveBranding}
|
||||
saving={saving}
|
||||
justSaved={savedFlash === "branding"}
|
||||
/>
|
||||
) : tab === "footer" ? (
|
||||
<FooterTab
|
||||
value={footer}
|
||||
onChange={setFooter}
|
||||
autoTranslate={autoTranslateFooter}
|
||||
onAutoTranslateChange={setAutoTranslateFooter}
|
||||
onSave={onSaveFooter}
|
||||
saving={saving}
|
||||
justSaved={savedFlash === "footer"}
|
||||
/>
|
||||
) : (
|
||||
<SocialTab
|
||||
value={social}
|
||||
onChange={setSocial}
|
||||
onSave={onSaveSocial}
|
||||
saving={saving}
|
||||
justSaved={savedFlash === "social"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Branding tab ────────────────────────────────────────────────
|
||||
function BrandingTab({
|
||||
value, onChange, onSave, saving, justSaved,
|
||||
}: {
|
||||
value: BrandingSettings;
|
||||
onChange: (v: BrandingSettings) => void;
|
||||
onSave: () => void;
|
||||
saving: boolean;
|
||||
justSaved: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<Tip>
|
||||
Upload images to set the site favicon and logo. Recommended sizes are shown next to each
|
||||
field. Changes appear on the live site within 60 seconds, no rebuild needed.
|
||||
</Tip>
|
||||
|
||||
<ImageField
|
||||
label="Favicon"
|
||||
helper="PNG, square, transparent background. Minimum 512×512. Auto-resized for tabs and bookmarks."
|
||||
value={value.faviconUrl}
|
||||
onChange={(url) => onChange({ ...value, faviconUrl: url })}
|
||||
/>
|
||||
|
||||
<ImageField
|
||||
label="Apple Touch Icon"
|
||||
helper="PNG, 180×180. Shown when users add the site to their iPhone home screen."
|
||||
value={value.appleTouchIconUrl}
|
||||
onChange={(url) => onChange({ ...value, appleTouchIconUrl: url })}
|
||||
/>
|
||||
|
||||
<ImageField
|
||||
label="Main Logo"
|
||||
helper="SVG preferred (scales to any size). Or PNG at 800×200 with transparent background."
|
||||
value={value.logoUrl}
|
||||
onChange={(url) => onChange({ ...value, logoUrl: url })}
|
||||
/>
|
||||
|
||||
<ImageField
|
||||
label="Email Logo"
|
||||
helper="PNG, 600×200. Used in transactional emails sent to clients."
|
||||
value={value.logoEmailUrl}
|
||||
onChange={(url) => onChange({ ...value, logoEmailUrl: url })}
|
||||
/>
|
||||
|
||||
<ImageField
|
||||
label="OpenGraph / Social Share Image"
|
||||
helper="PNG or JPG, 1200×630. Shown when the site is shared on LinkedIn / WhatsApp / Twitter."
|
||||
value={value.ogImageUrl}
|
||||
onChange={(url) => onChange({ ...value, ogImageUrl: url })}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-widest text-[#86868B] font-bold mb-2">
|
||||
Theme color
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
value={value.themeColor}
|
||||
onChange={(e) => onChange({ ...value, themeColor: e.target.value })}
|
||||
className="w-14 h-10 rounded-lg cursor-pointer bg-transparent"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={value.themeColor}
|
||||
onChange={(e) => onChange({ ...value, themeColor: e.target.value })}
|
||||
className="bg-black/40 border border-white/10 text-white text-sm rounded-lg px-3 py-2 outline-none focus:border-[#00F0FF]/40 font-mono"
|
||||
/>
|
||||
<span className="text-xs text-[#86868B]">
|
||||
Used for browser address bar tint on iOS / Android
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SaveButton onClick={onSave} saving={saving} justSaved={justSaved} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Footer tab ──────────────────────────────────────────────────
|
||||
function FooterTab({
|
||||
value, onChange, autoTranslate, onAutoTranslateChange, onSave, saving, justSaved,
|
||||
}: {
|
||||
value: FooterSettings;
|
||||
onChange: (v: FooterSettings) => void;
|
||||
autoTranslate: boolean;
|
||||
onAutoTranslateChange: (v: boolean) => void;
|
||||
onSave: () => void;
|
||||
saving: boolean;
|
||||
justSaved: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Tip>Footer text appears at the bottom of every page. Auto-translate sends the text to AI for IT, VEC, ES, DE versions.</Tip>
|
||||
|
||||
<FieldGroup title="Call-to-action banner">
|
||||
<TextField
|
||||
label="First line"
|
||||
value={value.ctaTitle1}
|
||||
onChange={(v) => onChange({ ...value, ctaTitle1: v })}
|
||||
/>
|
||||
<TextField
|
||||
label="Second line (highlighted)"
|
||||
value={value.ctaTitle2}
|
||||
onChange={(v) => onChange({ ...value, ctaTitle2: v })}
|
||||
/>
|
||||
<TextField
|
||||
label="Subtitle paragraph"
|
||||
value={value.ctaSubtitle}
|
||||
onChange={(v) => onChange({ ...value, ctaSubtitle: v })}
|
||||
multiline
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup title="Headquarters address">
|
||||
<TextField label="Street" value={value.hqAddress} onChange={(v) => onChange({ ...value, hqAddress: v })} />
|
||||
<TextField label="City + ZIP" value={value.hqCity} onChange={(v) => onChange({ ...value, hqCity: v })} />
|
||||
<TextField label="Region" value={value.hqRegion} onChange={(v) => onChange({ ...value, hqRegion: v })} />
|
||||
<TextField label="Country" value={value.hqCountry} onChange={(v) => onChange({ ...value, hqCountry: v })} />
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup title="Legal">
|
||||
<TextField label="Copyright holder" value={value.copyrightHolder} onChange={(v) => onChange({ ...value, copyrightHolder: v })} />
|
||||
<TextField label="Privacy policy URL" value={value.privacyUrl} onChange={(v) => onChange({ ...value, privacyUrl: v })} />
|
||||
<TextField label="Terms of service URL" value={value.termsUrl} onChange={(v) => onChange({ ...value, termsUrl: v })} />
|
||||
</FieldGroup>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-[#86868B] cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoTranslate}
|
||||
onChange={(e) => onAutoTranslateChange(e.target.checked)}
|
||||
className="accent-[#00F0FF]"
|
||||
/>
|
||||
<Sparkles size={12} className="text-[#00F0FF]" />
|
||||
Auto-translate to IT, VEC, ES, DE on save
|
||||
</label>
|
||||
|
||||
<SaveButton onClick={onSave} saving={saving} justSaved={justSaved} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Social tab ──────────────────────────────────────────────────
|
||||
function SocialTab({
|
||||
value, onChange, onSave, saving, justSaved,
|
||||
}: {
|
||||
value: SocialSettings;
|
||||
onChange: (v: SocialSettings) => void;
|
||||
onSave: () => void;
|
||||
saving: boolean;
|
||||
justSaved: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Tip>Add the URL of each profile. Leave blank to hide. Email is shown as a mailto link.</Tip>
|
||||
<TextField label="LinkedIn URL" value={value.linkedin} onChange={(v) => onChange({ ...value, linkedin: v })} placeholder="https://linkedin.com/company/flux-srl" />
|
||||
<TextField label="Instagram URL" value={value.instagram} onChange={(v) => onChange({ ...value, instagram: v })} placeholder="https://instagram.com/..." />
|
||||
<TextField label="YouTube URL" value={value.youtube} onChange={(v) => onChange({ ...value, youtube: v })} placeholder="https://youtube.com/@..." />
|
||||
<TextField label="Contact email" value={value.email} onChange={(v) => onChange({ ...value, email: v })} placeholder="info@rf-flux.com" />
|
||||
<SaveButton onClick={onSave} saving={saving} justSaved={justSaved} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Reusable bits ──────────────────────────────────────────────
|
||||
function Tip({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 bg-[#00F0FF]/5 border border-[#00F0FF]/15 rounded-2xl p-4 text-xs text-[#86868B]">
|
||||
<Info size={14} className="text-[#00F0FF] mt-0.5 flex-shrink-0" />
|
||||
<div className="leading-relaxed">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldGroup({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="text-[10px] uppercase tracking-widest text-[#86868B] font-bold">{title}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TextField({
|
||||
label, value, onChange, placeholder, multiline,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
multiline?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-xs text-[#86868B] mb-1.5">{label}</label>
|
||||
{multiline ? (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
rows={3}
|
||||
className="w-full bg-black/40 border border-white/10 text-white text-sm rounded-lg px-3 py-2 outline-none focus:border-[#00F0FF]/40 resize-y"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full bg-black/40 border border-white/10 text-white text-sm rounded-lg px-3 py-2 outline-none focus:border-[#00F0FF]/40"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageField({
|
||||
label, helper, value, onChange,
|
||||
}: {
|
||||
label: string;
|
||||
helper: string;
|
||||
value: string;
|
||||
onChange: (url: string) => void;
|
||||
}) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const upload = async (file: File) => {
|
||||
setIsUploading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("scope", "branding");
|
||||
fd.append("file", file);
|
||||
const res = await fetch("/api/assets", { method: "POST", body: fd });
|
||||
const data = await res.json();
|
||||
if (data.success) onChange(data.file.publicUrl);
|
||||
else setError(data.error || "Upload failed");
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Upload failed");
|
||||
}
|
||||
setIsUploading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white/[0.02] border border-white/10 rounded-2xl p-5">
|
||||
<div className="flex flex-col md:flex-row md:items-start gap-4">
|
||||
{/* Preview */}
|
||||
<div className="w-32 h-32 bg-black rounded-xl overflow-hidden flex items-center justify-center flex-shrink-0 border border-white/5">
|
||||
{value ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={value} alt={label} className="max-w-full max-h-full object-contain" />
|
||||
) : (
|
||||
<ImageIcon size={32} className="text-[#86868B]/40" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-white mb-1">{label}</div>
|
||||
<div className="text-xs text-[#86868B] mb-3 leading-relaxed">{helper}</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) upload(file);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
className="inline-flex items-center gap-2 bg-[#00F0FF]/10 text-[#00F0FF] hover:bg-[#00F0FF]/20 px-3 py-2 rounded-lg text-xs font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isUploading ? <Loader2 size={12} className="animate-spin" /> : <Upload size={12} />}
|
||||
{isUploading ? "Uploading…" : "Upload new"}
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="…or paste URL"
|
||||
className="flex-1 min-w-[180px] bg-black/40 border border-white/10 text-white text-xs rounded-lg px-3 py-2 outline-none focus:border-[#00F0FF]/40 font-mono"
|
||||
/>
|
||||
</div>
|
||||
{error && <div className="text-rose-400 text-xs mt-2">{error}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SaveButton({
|
||||
onClick, saving, justSaved,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
saving: boolean;
|
||||
justSaved: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 pt-4">
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={saving}
|
||||
className="px-5 py-3 bg-[#00F0FF] text-black text-sm font-medium rounded-lg hover:bg-[#00F0FF]/80 transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
|
||||
Save changes
|
||||
</button>
|
||||
{justSaved && (
|
||||
<span className="text-emerald-400 text-sm flex items-center gap-1">
|
||||
<Check size={14} /> Saved — live in 60 seconds
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +1,40 @@
|
||||
import { Link } from "@/i18n/routing";
|
||||
import { Linkedin, Instagram, Youtube, Mail } from "lucide-react";
|
||||
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";
|
||||
import { getFooterSettings, getSocialLinks } from "@/lib/siteSettings";
|
||||
|
||||
// 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");
|
||||
|
||||
const [footer, social] = await Promise.all([
|
||||
getFooterSettings(locale),
|
||||
getSocialLinks(locale),
|
||||
]);
|
||||
|
||||
let activeApps: any[] = [];
|
||||
try {
|
||||
const rawApps = await prisma.application.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
take: 4
|
||||
take: 4,
|
||||
});
|
||||
activeApps = rawApps.map((app: any) => getLocalizedData(app, locale));
|
||||
} catch (error) {
|
||||
console.error("Error loading apps in footer", error);
|
||||
}
|
||||
|
||||
const socialLinks = [
|
||||
{ url: social.linkedin, icon: Linkedin, label: "LinkedIn" },
|
||||
{ url: social.instagram, icon: Instagram, label: "Instagram" },
|
||||
{ url: social.youtube, icon: Youtube, label: "YouTube" },
|
||||
{ url: social.email ? `mailto:${social.email}` : "", icon: Mail, label: "Email" },
|
||||
].filter((s) => s.url);
|
||||
|
||||
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">
|
||||
@@ -33,11 +42,13 @@ export default async function Footer() {
|
||||
<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>
|
||||
{footer.ctaTitle1} <br />
|
||||
<span className="font-medium text-[#0066CC] dark:text-[#00F0FF] transition-colors">
|
||||
{footer.ctaTitle2}
|
||||
</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.
|
||||
{footer.ctaSubtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -46,7 +57,6 @@ export default async function Footer() {
|
||||
|
||||
<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
|
||||
@@ -63,10 +73,9 @@ export default async function Footer() {
|
||||
/>
|
||||
</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 => (
|
||||
{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>
|
||||
@@ -83,20 +92,37 @@ export default async function Footer() {
|
||||
<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
|
||||
{footer.hqAddress} <br />
|
||||
{footer.hqCity} <br />
|
||||
{footer.hqRegion}, {footer.hqCountry}
|
||||
</p>
|
||||
|
||||
{socialLinks.length > 0 && (
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
{socialLinks.map(({ url, icon: Icon, label }) => (
|
||||
<a
|
||||
key={label}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={label}
|
||||
className="w-9 h-9 rounded-full bg-white/5 hover:bg-[#0066CC]/20 dark:hover:bg-[#00F0FF]/20 flex items-center justify-center transition-colors"
|
||||
>
|
||||
<Icon size={15} className="text-[#86868B]" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
<p>© {new Date().getFullYear()} {footer.copyrightHolder}. {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>
|
||||
<Link href={footer.privacyUrl as any} className="hover:text-white transition-colors">Privacy Policy</Link>
|
||||
<Link href={footer.termsUrl as any} 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>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[#0066CC] dark:bg-[#00F0FF] animate-pulse" />
|
||||
{t("madeInItaly")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,38 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useMemo } 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[];
|
||||
const syncopate = Syncopate({ weight: ["400", "700"], subsets: ["latin"] });
|
||||
|
||||
export interface HeroSlideData {
|
||||
mediaUrl: string;
|
||||
mediaType: string; // "image" | "video"
|
||||
altText: string | null;
|
||||
focalPointX: number; // 0–1
|
||||
focalPointY: number; // 0–1
|
||||
translationsJson?: string | null;
|
||||
// Optional per-slide overrides (already merged via getLocalizedData server-side)
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
description1?: string;
|
||||
description2?: string;
|
||||
}
|
||||
|
||||
export default function HeroReel({ images }: HeroReelProps) {
|
||||
interface HeroReelProps {
|
||||
slides: HeroSlideData[];
|
||||
}
|
||||
|
||||
// ── Backwards-compat wrapper: the old API used `images: string[]` ─────────────
|
||||
// (Server pages should pass `slides` going forward; legacy callers still work.)
|
||||
export default function HeroReel(props: HeroReelProps | { images: string[] }) {
|
||||
const slides = useMemo<HeroSlideData[]>(() => {
|
||||
if ("slides" in props) return props.slides;
|
||||
return (props.images || []).map((src) => ({
|
||||
mediaUrl: src,
|
||||
mediaType: "image",
|
||||
altText: null,
|
||||
focalPointX: 0.5,
|
||||
focalPointY: 0.5,
|
||||
}));
|
||||
}, [props]);
|
||||
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const t = useTranslations("HeroReel");
|
||||
|
||||
useEffect(() => {
|
||||
if (!images || images.length <= 1) return;
|
||||
if (slides.length <= 1) return;
|
||||
const timer = setInterval(() => {
|
||||
setCurrentIndex((prevIndex) => (prevIndex + 1) % images.length);
|
||||
setCurrentIndex((prev) => (prev + 1) % slides.length);
|
||||
}, 3600);
|
||||
return () => clearInterval(timer);
|
||||
}, [images]);
|
||||
}, [slides.length]);
|
||||
|
||||
const current = slides[currentIndex];
|
||||
|
||||
return (
|
||||
<div
|
||||
id="technology"
|
||||
className="relative w-screen h-[100vh] mt-0 mb-0 overflow-hidden z-0 bg-[#050505]"
|
||||
className="relative w-full max-w-[100vw] h-[100svh] mt-0 mb-0 overflow-hidden z-0 bg-[#050505]"
|
||||
>
|
||||
|
||||
<AnimatePresence mode="popLayout">
|
||||
{images.length > 0 ? (
|
||||
{current ? (
|
||||
<motion.div
|
||||
key={currentIndex}
|
||||
initial={{ opacity: 0, scale: 1.05 }}
|
||||
@@ -41,62 +68,73 @@ export default function HeroReel({ images }: HeroReelProps) {
|
||||
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}
|
||||
{current.mediaType === "video" ? (
|
||||
<video
|
||||
key={current.mediaUrl}
|
||||
src={current.mediaUrl}
|
||||
autoPlay
|
||||
muted
|
||||
playsInline
|
||||
loop
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
style={{
|
||||
objectPosition: `${current.focalPointX * 100}% ${current.focalPointY * 100}%`,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={current.mediaUrl}
|
||||
alt={current.altText || `FLUX Vision ${currentIndex + 1}`}
|
||||
fill
|
||||
quality={90}
|
||||
sizes="100vw"
|
||||
priority={currentIndex === 0}
|
||||
className="object-cover"
|
||||
style={{
|
||||
objectPosition: `${current.focalPointX * 100}% ${current.focalPointY * 100}%`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</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 */}
|
||||
{/* Subtle edge gradients to keep text legible regardless of photo content */}
|
||||
<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 🔥 */}
|
||||
{/* Overlay text */}
|
||||
<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
|
||||
className={`${syncopate.className} text-3xl sm:text-4xl md:text-5xl lg:text-[5.5rem] font-bold text-white uppercase tracking-widest leading-[1.1] drop-shadow-2xl`}
|
||||
>
|
||||
{current?.title || "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 className="text-base sm:text-lg md:text-2xl lg:text-[2rem] font-light text-white uppercase tracking-[0.2em] drop-shadow-lg">
|
||||
{current?.subtitle || "INNOVATION NOT IMITATION"}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* ESPACIADOR INVISIBLE */}
|
||||
<div className="h-2 md:h-4"></div>
|
||||
<div className="h-2 md:h-4" />
|
||||
|
||||
{/* 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")}
|
||||
{current?.description1 || 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")}
|
||||
{current?.description2 || t("description2")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
// src/lib/revalidate.ts
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Cache invalidation helper.
|
||||
// Called after every CMS mutation (upload, edit, delete) so newly uploaded
|
||||
// images/text appear without rebuilding Docker.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
const LOCALES = ["en", "it", "vec", "es", "de"] as const;
|
||||
|
||||
export type RevalidateScope =
|
||||
| "applications"
|
||||
| "cases"
|
||||
| "news"
|
||||
| "parts"
|
||||
| "heritage"
|
||||
| "operations-inbox"
|
||||
| "footage"
|
||||
| "branding"
|
||||
| "hero"
|
||||
| "timeline"
|
||||
| "settings"
|
||||
| "all";
|
||||
|
||||
export interface RevalidateOptions {
|
||||
scope: RevalidateScope;
|
||||
slug?: string;
|
||||
}
|
||||
|
||||
function safeRevalidate(path: string, type: "page" | "layout" = "page") {
|
||||
try {
|
||||
revalidatePath(path, type);
|
||||
} catch (err) {
|
||||
console.warn(`[revalidate] Failed to revalidate path "${path}":`, err);
|
||||
}
|
||||
}
|
||||
|
||||
export function revalidateContent({ scope, slug }: RevalidateOptions) {
|
||||
safeRevalidate("/", "layout");
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
safeRevalidate(`/${locale}`);
|
||||
|
||||
switch (scope) {
|
||||
case "applications":
|
||||
safeRevalidate(`/${locale}/applications`);
|
||||
if (slug) safeRevalidate(`/${locale}/applications/${slug}`);
|
||||
break;
|
||||
case "news":
|
||||
safeRevalidate(`/${locale}/news`);
|
||||
if (slug) safeRevalidate(`/${locale}/news/${slug}`);
|
||||
break;
|
||||
case "parts":
|
||||
safeRevalidate(`/${locale}/parts`);
|
||||
break;
|
||||
case "heritage":
|
||||
safeRevalidate(`/${locale}/heritage`);
|
||||
break;
|
||||
case "cases":
|
||||
case "timeline":
|
||||
case "hero":
|
||||
case "footage":
|
||||
safeRevalidate(`/${locale}`);
|
||||
break;
|
||||
case "branding":
|
||||
case "settings":
|
||||
// Brand assets and global settings affect every page (header/footer/favicon).
|
||||
safeRevalidate("/", "layout");
|
||||
safeRevalidate(`/${locale}`);
|
||||
break;
|
||||
case "operations-inbox":
|
||||
break;
|
||||
case "all":
|
||||
safeRevalidate(`/${locale}/applications`);
|
||||
safeRevalidate(`/${locale}/news`);
|
||||
safeRevalidate(`/${locale}/parts`);
|
||||
safeRevalidate(`/${locale}/heritage`);
|
||||
if (slug) {
|
||||
safeRevalidate(`/${locale}/applications/${slug}`);
|
||||
safeRevalidate(`/${locale}/news/${slug}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const SCOPE_FROM_PATH: Record<string, RevalidateScope> = {
|
||||
applications: "applications",
|
||||
cases: "cases",
|
||||
news: "news",
|
||||
parts: "parts",
|
||||
heritage: "heritage",
|
||||
"operations-inbox": "operations-inbox",
|
||||
footage: "footage",
|
||||
};
|
||||
|
||||
export function revalidateFromPublicPath(publicUrl: string) {
|
||||
const segments = publicUrl.replace(/^\/+/, "").split("/");
|
||||
const top = segments[0];
|
||||
const slug = segments[1];
|
||||
|
||||
const scope = SCOPE_FROM_PATH[top];
|
||||
if (!scope) {
|
||||
safeRevalidate("/", "layout");
|
||||
return;
|
||||
}
|
||||
|
||||
revalidateContent({ scope, slug });
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// src/lib/siteSettings.ts
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Server-only loader for site-wide settings (branding, footer, social).
|
||||
// Reads the SiteSetting key-value table and merges with defaults.
|
||||
//
|
||||
// For pure types and defaults safe to import from client components,
|
||||
// use src/lib/siteSettingsTypes.ts instead.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import "server-only";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getLocalizedData } from "@/lib/i18nHelper";
|
||||
import {
|
||||
DEFAULT_BRANDING,
|
||||
DEFAULT_FOOTER,
|
||||
DEFAULT_SOCIAL,
|
||||
type BrandingSettings,
|
||||
type FooterSettings,
|
||||
type SocialSettings,
|
||||
} from "@/lib/siteSettingsTypes";
|
||||
|
||||
export {
|
||||
DEFAULT_BRANDING,
|
||||
DEFAULT_FOOTER,
|
||||
DEFAULT_SOCIAL,
|
||||
type BrandingSettings,
|
||||
type FooterSettings,
|
||||
type SocialSettings,
|
||||
};
|
||||
|
||||
function safeParse<T>(json: string | null | undefined, fallback: T): T {
|
||||
if (!json) return fallback;
|
||||
try {
|
||||
return JSON.parse(json) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
async function readSetting<T>(key: string, defaults: T, locale?: string): Promise<T> {
|
||||
try {
|
||||
const row = await prisma.siteSetting.findUnique({ where: { key } });
|
||||
if (!row) return defaults;
|
||||
|
||||
const localized = locale ? getLocalizedData(row, locale) : row;
|
||||
const value = safeParse<Partial<T>>(localized.valueJson, {} as Partial<T>);
|
||||
return { ...defaults, ...value };
|
||||
} catch (error) {
|
||||
console.error(`[siteSettings] Failed to read "${key}":`, error);
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
|
||||
export const getBranding = (locale?: string) =>
|
||||
readSetting<BrandingSettings>("branding", DEFAULT_BRANDING, locale);
|
||||
|
||||
export const getFooterSettings = (locale?: string) =>
|
||||
readSetting<FooterSettings>("footer", DEFAULT_FOOTER, locale);
|
||||
|
||||
export const getSocialLinks = (locale?: string) =>
|
||||
readSetting<SocialSettings>("social", DEFAULT_SOCIAL, locale);
|
||||
|
||||
export async function getAllSettings() {
|
||||
const [branding, footer, social] = await Promise.all([
|
||||
getBranding(),
|
||||
getFooterSettings(),
|
||||
getSocialLinks(),
|
||||
]);
|
||||
return { branding, footer, social };
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// src/lib/siteSettingsTypes.ts
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Pure types + defaults — no Prisma, no server-only deps.
|
||||
// Safe to import from client components (the HQ Command settings page uses this).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface BrandingSettings {
|
||||
logoUrl: string;
|
||||
logoEmailUrl: string;
|
||||
faviconUrl: string;
|
||||
appleTouchIconUrl: string;
|
||||
ogImageUrl: string;
|
||||
themeColor: string;
|
||||
}
|
||||
|
||||
export interface FooterSettings {
|
||||
ctaTitle1: string;
|
||||
ctaTitle2: string;
|
||||
ctaSubtitle: string;
|
||||
hqAddress: string;
|
||||
hqCity: string;
|
||||
hqRegion: string;
|
||||
hqCountry: string;
|
||||
copyrightHolder: string;
|
||||
privacyUrl: string;
|
||||
termsUrl: string;
|
||||
}
|
||||
|
||||
export interface SocialSettings {
|
||||
linkedin: string;
|
||||
instagram: string;
|
||||
youtube: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_BRANDING: BrandingSettings = {
|
||||
logoUrl: "/flux-logo.svg",
|
||||
logoEmailUrl: "/logoEmail.png",
|
||||
faviconUrl: "/flux-logo.png",
|
||||
appleTouchIconUrl: "/flux-logo.png",
|
||||
ogImageUrl: "/flux-logo.png",
|
||||
themeColor: "#0066CC",
|
||||
};
|
||||
|
||||
export const DEFAULT_FOOTER: FooterSettings = {
|
||||
ctaTitle1: "Ready to optimize",
|
||||
ctaTitle2: "your production?",
|
||||
ctaSubtitle:
|
||||
"Connect with our engineering team to calculate your ROI and explore custom RF solutions.",
|
||||
hqAddress: "Via Benedetto Marcello 32",
|
||||
hqCity: "36060 Romano d'Ezzelino",
|
||||
hqRegion: "Vicenza",
|
||||
hqCountry: "Italy",
|
||||
copyrightHolder: "FLUX Srl",
|
||||
privacyUrl: "/privacy",
|
||||
termsUrl: "/terms",
|
||||
};
|
||||
|
||||
export const DEFAULT_SOCIAL: SocialSettings = {
|
||||
linkedin: "",
|
||||
instagram: "",
|
||||
youtube: "",
|
||||
email: "info@rf-flux.com",
|
||||
};
|
||||
Reference in New Issue
Block a user