Compare commits

...

4 Commits

Author SHA1 Message Date
davidherran f931ae281c i18n: preserve technical industry terms across all locales
Deploy to VPS / deploy (push) Has been cancelled
Some technical terms were being translated literally and reading awkwardly
to industrial buyers — fixed across IT, ES, VEC, DE so they match the
English source and industry convention.

PRESERVED IN ENGLISH (industry-standard, never translate)
- "Solid-State RF" — was "RF a Stato Solido" / "RF de Estado Sólido" /
  "RF a Stato Sołido" / "Solid-State-RF"
- "Microwave Systems" — was "Sistemi a Microonde" / "Sistemas de
  Microondas" / "Mikrowellensysteme" / "Sistemi Microwave"
- "Radio Frequency (RF)" — was "Radiofrequenza" / "Radiofrecuencia" /
  "Radiofrequensa" / "Hochfrequenztechnologie" (kept as the technical
  proper noun, with the RF acronym in parentheses for first reference)
- "Pulse Wave" — was "onde pulsate" / "ondas pulsadas" / "onde pulsà" /
  "Pulswellen-Technologie"

Files: messages/it.json, messages/es.json, messages/vec.json,
messages/de.json. messages/en.json unchanged. JSON syntax validated.
2026-05-04 13:14:20 -05:00
davidherran b9a744bdbc feat: site settings CMS — favicon, logo, footer, social, OG image
Adds a full settings dashboard at /hq-command/dashboard/settings so the
client can update favicon, logos, footer text, social links and OG image
without code changes — wired into the SiteSetting model from the previous
commit.

NEW
- src/lib/siteSettingsTypes.ts: pure types + defaults (client-safe import)
- src/lib/siteSettings.ts: server-only loader using the SiteSetting model
- /api/assets gains a "branding" flat scope that writes to /public/branding
- /hq-command/dashboard/settings/{actions.ts, page.tsx} with three tabs:
    1. Branding — favicon, Apple touch icon, main logo, email logo, OG
       image, theme color. Each field has helper text with recommended
       size/format and live preview.
    2. Footer — CTA banner, HQ address, legal links. Optional one-click
       AI translation to IT, VEC, ES, DE.
    3. Social — LinkedIn, Instagram, YouTube, contact email.

WIRED INTO LAYOUT
- src/app/[locale]/layout.tsx now uses generateMetadata + generateViewport
  to pull favicon, OG image and theme color dynamically. Adds Twitter
  Card metadata. Falls back to the default flux-logo when SiteSetting
  table is empty.
- src/components/layout/Footer.tsx reads CTA/HQ/legal copy from DB,
  supports per-locale overrides via translationsJson, and renders social
  icons (LinkedIn / Instagram / YouTube / Mail) only for filled fields.

UX FOR THE EDITOR (David's "12-year-old test")
- Drop-zone uploaders next to URL inputs — paste-or-upload either way
- Live image previews next to every branding field
- "Saved — live in 60 seconds" inline confirmation, no extra modals
- Recommended sizes spelled out next to each field (e.g. "PNG, square,
  minimum 512×512" for favicon)
- Tooltip explaining why each image is needed

NO SCHEMA CHANGES — uses the SiteSetting table created in the previous
commit. Existing rows untouched.
2026-05-04 12:47:10 -05:00
davidherran b9201a437c feat: hero carousel CMS + responsive mobile/iPad fix + flat-scope assets
Replaces the filesystem-scan hero (fs.readdirSync of /public/footage/main)
with a fully CMS-driven HeroSlide model. Editors can now drag-drop reorder,
toggle slides on/off, set focal points for proper mobile cropping, and
auto-translate per-slide captions.

NEW SCHEMA (additive — does not touch existing tables)
- HeroSlide: mediaUrl, mediaType, altText, order, isActive, focalPointX,
  focalPointY, translationsJson, timestamps
- SiteSetting: key-value JSON store for site-wide config (favicon, logo,
  footer, OG image) — wired up in next commit
- Migration 20260504120000_add_hero_slides_and_site_settings/migration.sql
  uses CREATE TABLE IF NOT EXISTS, additive only

HERO REEL REFACTOR (Bug #4 — responsive mobile/iPad)
- Switches from `images: string[]` to `slides: HeroSlideData[]` while
  keeping a backwards-compat path so legacy callers still work
- w-screen → w-full max-w-[100vw] (no horizontal scroll on iOS)
- h-[100vh] → h-[100svh] so iOS Safari URL bar doesn't push content
- Reduces title font sizes on small viewports (text-3xl → text-4xl
  → text-5xl → text-[5.5rem]) so the headline stays inside the canvas
- objectPosition driven by focal-point fields per slide
- Native <video> support for video slides

HQ COMMAND — /hq-command/dashboard/hero
- Drag-drop reorder, click-to-set-focal-point, inline alt-text editing
- Auto-save with "Saving…" / "Saved ✓" indicators
- Per-slide caption overrides (title, subtitle, descriptions)
- Optional one-click AI translation to IT, VEC, ES, DE
- Drop-zone uploader → /api/assets (scope=footage, flat folder)

API — /api/assets
- New flat scopes: "footage" (writes to /public/footage/main) and
  "branding" (writes to /public/branding) — slug-less for site-wide assets
- New buildPublicUrl helper centralises the URL convention
- Revalidate helper expanded with branding + settings scopes

HOME PAGE
- Reads hero slides from DB first; falls back to filesystem scan when
  HeroSlide table is empty (so production keeps working immediately
  after migration runs but before the editor populates rows)

DEPLOY NOTES
- After git pull on VPS, run the migration ONCE:
    docker compose exec app npx prisma migrate deploy
  Then:
    docker compose up -d --build app
  Existing data (AdminUser w/ 2FA, ClientUser, GlobalNode, Application,
  TimelineEvent, NewsArticle, HeritageSection, SparePart, OperationsSignal,
  NotificationRoute, PageContent) is NOT touched. Migration only creates
  two new tables.
2026-05-04 09:34:49 -05:00
davidherran 6e46808c27 fix: instant CMS uploads + heritage dark/light + ISR caching
Eliminates the need to run "docker compose build" after uploading
images via HQ Command. Heritage page now respects light/dark mode.

CACHE INVALIDATION
- New helper src/lib/revalidate.ts called from /api/assets and
  /api/public-upload after every upload, delete, folder create
- Pages switch from force-dynamic to ISR with revalidate=60
  (regenerated on demand whenever content changes, plus 60s safety)
- Nginx now sends "max-age=300, must-revalidate" instead of "expires 30d"
  on /cases/, /applications/, /news/, /parts/, /footage/, /operations-inbox/
  so browsers revalidate via If-Modified-Since (304s on unchanged files)
- Next.js Image Optimizer aligned with same TTL via minimumCacheTTL=300
  and adds /_next/image location block in Nginx for correct headers

HERITAGE DARK/LIGHT FIX (Bug #8)
- Replaces hardcoded #0A0A0C / #00F0FF / text-white with proper
  light + dark variants throughout markdown renderer (tables, lists,
  headings, blockquotes, paragraphs, images)
- Hero section, navigation pill, and CMS-driven sections now switch
  with the global theme toggle

SECURITY HARDENING
- Server actions bodySizeLimit reduced from 500MB to 50MB
  (large uploads still go through /api/assets which uses Nginx 500MB cap)

DEPLOY NOTES
- Run on VPS:
    git pull
    docker compose up -d --build app
    docker compose exec nginx nginx -s reload
- No DB schema changes in this commit. Existing 2FA users / data untouched.
2026-05-04 09:27:46 -05:00
28 changed files with 1935 additions and 221 deletions
+4
View File
@@ -50,3 +50,7 @@ public/news/
public/parts/ public/parts/
public/operations-inbox/ public/operations-inbox/
public/footage/ public/footage/
# Local Claude Code / MCP config — agent-specific, not project
.mcp.json
.claude/
+4 -4
View File
@@ -60,9 +60,9 @@
}, },
"WhatWeDo": { "WhatWeDo": {
"subtitle": "Was wir tun", "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.", "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.", "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.", "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", "servicesSubtitle": "Unsere Dienstleistungen",
@@ -125,7 +125,7 @@
"title2": "Neu gedacht für 2026.", "title2": "Neu gedacht für 2026.",
"p1_1": "Gegründet und geführt von Ingenieur ", "p1_1": "Gegründet und geführt von Ingenieur ",
"p1_2": "Patrizio Grando", "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.", "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" "button": "Lesen Sie den Deep Dive in Patrizios Erbe"
}, },
@@ -154,7 +154,7 @@
"companyTitle": "Unternehmen", "companyTitle": "Unternehmen",
"hqTitle": "Hauptsitz", "hqTitle": "Hauptsitz",
"techSolidState": "Solid-State RF", "techSolidState": "Solid-State RF",
"techMicrowave": "Mikrowellensysteme", "techMicrowave": "Microwave Systems",
"techEfficiency": "Energieeffizienz", "techEfficiency": "Energieeffizienz",
"companyStory": "Unsere Geschichte", "companyStory": "Unsere Geschichte",
"companyMap": "Globales Netzwerk", "companyMap": "Globales Netzwerk",
+7 -7
View File
@@ -60,9 +60,9 @@
}, },
"WhatWeDo": { "WhatWeDo": {
"subtitle": "Qué Hacemos", "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.", "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.", "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.", "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", "servicesSubtitle": "Nuestros Servicios",
@@ -101,7 +101,7 @@
"subtitle": "Aplicaciones de RF", "subtitle": "Aplicaciones de RF",
"title1": "Diseñado para su industria.", "title1": "Diseñado para su industria.",
"title2": "Optimizado para la eficiencia.", "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", "calcROI": "Calcular ROI",
"compareTech": "Comparar Tecnologías", "compareTech": "Comparar Tecnologías",
"viewSpecs": "Ver Especificaciones" "viewSpecs": "Ver Especificaciones"
@@ -125,8 +125,8 @@
"title2": "Reimaginado para 2026.", "title2": "Reimaginado para 2026.",
"p1_1": "Fundada y dirigida por el Ingeniero ", "p1_1": "Fundada y dirigida por el Ingeniero ",
"p1_2": "Patrizio Grando", "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.", "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 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.", "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" "button": "Leer más sobre el legado de Patrizio"
}, },
"CaseStudyModal": { "CaseStudyModal": {
@@ -153,8 +153,8 @@
"appsTitle": "Aplicaciones", "appsTitle": "Aplicaciones",
"companyTitle": "Empresa", "companyTitle": "Empresa",
"hqTitle": "Sede Central", "hqTitle": "Sede Central",
"techSolidState": "RF de Estado Sólido", "techSolidState": "Solid-State RF",
"techMicrowave": "Sistemas de Microondas", "techMicrowave": "Microwave Systems",
"techEfficiency": "Eficiencia Energética", "techEfficiency": "Eficiencia Energética",
"companyStory": "Nuestra Historia", "companyStory": "Nuestra Historia",
"companyMap": "Red Global", "companyMap": "Red Global",
+7 -7
View File
@@ -60,9 +60,9 @@
}, },
"WhatWeDo": { "WhatWeDo": {
"subtitle": "Cosa Facciamo", "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.", "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.", "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.", "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", "servicesSubtitle": "I Nostri Servizi",
@@ -101,7 +101,7 @@
"subtitle": "Applicazioni RF", "subtitle": "Applicazioni RF",
"title1": "Progettato per la tua industria.", "title1": "Progettato per la tua industria.",
"title2": "Ottimizzato per l'efficienza.", "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", "calcROI": "Calcola ROI",
"compareTech": "Confronta Tecnologie", "compareTech": "Confronta Tecnologie",
"viewSpecs": "Specifiche Complete" "viewSpecs": "Specifiche Complete"
@@ -125,8 +125,8 @@
"title2": "Reimmaginata per il 2026.", "title2": "Reimmaginata per il 2026.",
"p1_1": "Fondata e guidata dall'Ingegnere ", "p1_1": "Fondata e guidata dall'Ingegnere ",
"p1_2": "Patrizio Grando", "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.", "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 RF allo stato solido all'avanguardia per mercati innovativi, dove i competitor mancano dell'esperienza e della precisione ingegneristica necessaria.", "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" "button": "Leggi l'approfondimento sull'eredità di Patrizio"
}, },
"CaseStudyModal": { "CaseStudyModal": {
@@ -153,8 +153,8 @@
"appsTitle": "Applicazioni", "appsTitle": "Applicazioni",
"companyTitle": "Azienda", "companyTitle": "Azienda",
"hqTitle": "Sede Centrale", "hqTitle": "Sede Centrale",
"techSolidState": "RF a Stato Solido", "techSolidState": "Solid-State RF",
"techMicrowave": "Sistemi a Microonde", "techMicrowave": "Microwave Systems",
"techEfficiency": "Efficienza Energetica", "techEfficiency": "Efficienza Energetica",
"companyStory": "La nostra Storia", "companyStory": "La nostra Storia",
"companyMap": "Rete Globale", "companyMap": "Rete Globale",
+7 -7
View File
@@ -60,9 +60,9 @@
}, },
"WhatWeDo": { "WhatWeDo": {
"subtitle": "Cossa femo", "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.", "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.", "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.", "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", "servicesSubtitle": "I nostri servisi",
@@ -101,7 +101,7 @@
"subtitle": "Applicaçion RF", "subtitle": "Applicaçion RF",
"title1": "Fato aposta par el to laoro.", "title1": "Fato aposta par el to laoro.",
"title2": "Sempre al masimo de l'eficiensa.", "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", "calcROI": "Calcoła el guadagno",
"compareTech": "Confronta tecnołogie", "compareTech": "Confronta tecnołogie",
"viewSpecs": "Varda i detaji tecnici" "viewSpecs": "Varda i detaji tecnici"
@@ -125,8 +125,8 @@
"title2": "Pensà de novo pal 2026.", "title2": "Pensà de novo pal 2026.",
"p1_1": "Fondà e guidà da l'Ingegnere ", "p1_1": "Fondà e guidà da l'Ingegnere ",
"p1_2": "Patrizio Grando", "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.", "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 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.", "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" "button": "Lèzi l'aprofondimento su l'eredità de Patrizio"
}, },
"CaseStudyModal": { "CaseStudyModal": {
@@ -153,8 +153,8 @@
"appsTitle": "Applicaçion", "appsTitle": "Applicaçion",
"companyTitle": "Dita", "companyTitle": "Dita",
"hqTitle": "Sede Prinsipałe", "hqTitle": "Sede Prinsipałe",
"techSolidState": "RF a Stato Sołido", "techSolidState": "Solid-State RF",
"techMicrowave": "Sistemi Microwave", "techMicrowave": "Microwave Systems",
"techEfficiency": "Risparmio Energia", "techEfficiency": "Risparmio Energia",
"companyStory": "La nostra Storia", "companyStory": "La nostra Storia",
"companyMap": "Rede Global", "companyMap": "Rede Global",
+7 -1
View File
@@ -6,12 +6,18 @@ const nextConfig = {
output: "standalone" as const, output: "standalone" as const,
images: { images: {
qualities: [75, 90, 100], 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, reactStrictMode: true,
serverExternalPackages: ['nodemailer'], serverExternalPackages: ['nodemailer'],
experimental: { experimental: {
serverActions: { 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
View File
@@ -36,6 +36,7 @@ server {
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
# Next.js bundles use content hashing — safe to cache forever
location /_next/static/ { location /_next/static/ {
proxy_pass http://nextjs; proxy_pass http://nextjs;
expires 365d; expires 365d;
@@ -43,6 +44,17 @@ server {
access_log off; 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 { location /hq-command/login {
limit_req zone=login burst=10 nodelay; limit_req zone=login burst=10 nodelay;
proxy_pass http://nextjs; proxy_pass http://nextjs;
@@ -104,46 +116,50 @@ server {
access_log off; 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/ { location /cases/ {
alias /srv/cases/; alias /srv/cases/;
expires 30d; add_header Cache-Control "public, max-age=300, must-revalidate" always;
add_header Cache-Control "public";
access_log off; access_log off;
} }
location /applications/ { location /applications/ {
alias /srv/applications/; alias /srv/applications/;
expires 30d; add_header Cache-Control "public, max-age=300, must-revalidate" always;
add_header Cache-Control "public";
access_log off; access_log off;
} }
location /news/ { location /news/ {
alias /srv/news/; alias /srv/news/;
expires 30d; add_header Cache-Control "public, max-age=300, must-revalidate" always;
add_header Cache-Control "public";
access_log off; access_log off;
} }
location /parts/ { location /parts/ {
alias /srv/parts/; alias /srv/parts/;
expires 30d; add_header Cache-Control "public, max-age=300, must-revalidate" always;
add_header Cache-Control "public";
access_log off; access_log off;
} }
location /operations-inbox/ { location /operations-inbox/ {
alias /srv/operations-inbox/; alias /srv/operations-inbox/;
expires 7d; add_header Cache-Control "private, max-age=60, must-revalidate" always;
access_log off; access_log off;
} }
location /footage/ { location /footage/ {
alias /srv/footage/; alias /srv/footage/;
expires 30d; add_header Cache-Control "public, max-age=300, must-revalidate" always;
add_header Cache-Control "public";
access_log off; 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
View File
@@ -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 (01 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 { model ClientUser {
id String @id @default(cuid()) 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 Link from "next/link";
import fs from "fs"; import fs from "fs";
@@ -46,8 +47,6 @@ export async function generateStaticParams() {
} }
} }
export const revalidate = 60;
// 🔥 AHORA RECIBIMOS EL LOCALE DESDE LA URL // 🔥 AHORA RECIBIMOS EL LOCALE DESDE LA URL
export default async function ApplicationPage({ params }: { params: Promise<{ slug: string, locale: string }> }) { export default async function ApplicationPage({ params }: { params: Promise<{ slug: string, locale: string }> }) {
const resolvedParams = await params; const resolvedParams = await params;
+25 -27
View File
@@ -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 Link from "next/link";
import Image from "next/image"; import Image from "next/image";
@@ -28,21 +29,21 @@ const renderMarkdown = (text: string) => {
if (inTable) { if (inTable) {
elements.push( elements.push(
<div key={`table-${elements.length}`} className="my-12 w-full overflow-x-auto pb-4 [scrollbar-width:none]"> <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> <thead>
<tr className="bg-[#111]"> <tr className="bg-[#F5F5F7] dark:bg-[#111]">
{tableHeaders.map((th, i) => ( {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)} {parseInline(th)}
</th> </th>
))} ))}
</tr> </tr>
</thead> </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) => ( {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) => ( {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)} {parseInline(cell)}
</td> </td>
))} ))}
@@ -62,11 +63,11 @@ const renderMarkdown = (text: string) => {
if (listItems.length > 0) { if (listItems.length > 0) {
elements.push( elements.push(
isOrderedList ? ( 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} {listItems}
</ol> </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} {listItems}
</ul> </ul>
) )
@@ -81,11 +82,11 @@ const renderMarkdown = (text: string) => {
let parts = str.split(boldRegex); let parts = str.split(boldRegex);
return parts.map((part, i) => { 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); let subParts = part.split(italicRegex);
return subParts.map((subPart, j) => { 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; return subPart;
}); });
}); });
@@ -119,7 +120,7 @@ const renderMarkdown = (text: string) => {
if (imgMatch) { if (imgMatch) {
pushList(); pushTable(); pushList(); pushTable();
elements.push( 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" /> <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> </div>
); );
@@ -127,19 +128,19 @@ const renderMarkdown = (text: string) => {
} }
const h3Match = trimmed.match(/^###\s*(.*)/); 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*(.*)/); 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*(.*)/); 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*(.*)/); const quoteMatch = trimmed.match(/^>\s*(.*)/);
if (quoteMatch) { if (quoteMatch) {
pushList(); pushTable(); pushList(); pushTable();
elements.push( 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])} {parseInline(quoteMatch[1])}
</blockquote> </blockquote>
); );
@@ -162,7 +163,7 @@ const renderMarkdown = (text: string) => {
pushList(); pushList();
elements.push( 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)} {parseInline(trimmed)}
</p> </p>
); );
@@ -195,12 +196,12 @@ export default async function HeritagePage({ params }: { params: Promise<{ local
const sections = rawSections.map(sec => getLocalizedData(sec, locale)); const sections = rawSections.map(sec => getLocalizedData(sec, locale));
return ( 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 /> <BreathingField />
{/* NAVEGACIÓN FLOTANTE */} {/* NAVEGACIÓN FLOTANTE */}
<div className="fixed top-24 left-6 z-50"> <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")} <ArrowLeft size={16} /> {t("backToOverview")}
</Link> </Link>
</div> </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"> <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="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"> <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")} {t("subtitle")}
</span> </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> {t("title1")} <br/> <span className="text-[#86868B]">{t("title2")}</span>
</h1> </h1>
</div> </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"> <div key={sec.id} className="animate-in fade-in slide-in-from-bottom-8 duration-1000">
{/* El título ya viene traducido */} {/* 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' && ( {sec.type === 'text' && (
<div className="max-w-none"> <div className="max-w-none">
{renderMarkdown(sec.content || "")} {renderMarkdown(sec.content || "")}
</div> </div>
)} )}
{/* 🔥 BLOQUE DE IMAGEN GIGANTE 🔥 */}
{sec.type === 'image' && sec.mediaUrl && ( {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" /> <Image src={`/heritage/${sec.mediaUrl}`} alt={sec.title || "Heritage Image"} fill className="object-cover grayscale hover:grayscale-0 transition-all duration-1000" />
</div> </div>
)} )}
{/* 🔥 BLOQUE DE VIDEO NATIVO (MP4 LOCAL) — AUTOPLAY ON SCROLL 🔥 */}
{sec.type === 'video' && sec.mediaUrl && ( {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 <AutoPlayVideo
src={`/heritage/videos/${sec.mediaUrl}`} src={`/heritage/videos/${sec.mediaUrl}`}
className="absolute inset-0 w-full h-full object-cover" className="absolute inset-0 w-full h-full object-cover"
+29 -3
View File
@@ -6,28 +6,54 @@ import NavBar from "@/components/layout/NavBar";
import NavigationManager from "@/components/layout/NavigationManager"; import NavigationManager from "@/components/layout/NavigationManager";
import SilentObserver from "@/components/ai/SilentObserver"; import SilentObserver from "@/components/ai/SilentObserver";
import Footer from "@/components/layout/Footer"; import Footer from "@/components/layout/Footer";
// 🔥 NUEVO: Importamos el Drawer del Carrito / Helpdesk
import CartDrawer from "@/components/layout/CartDrawer"; import CartDrawer from "@/components/layout/CartDrawer";
import { NextIntlClientProvider } from 'next-intl'; import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server'; import { getMessages } from 'next-intl/server';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { routing } from '@/i18n/routing'; import { routing } from '@/i18n/routing';
import { getBranding } from '@/lib/siteSettings';
const inter = Inter({ subsets: ["latin"] }); 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.", title: "FLUX | Energy, Directed.",
description: "Advanced Radio Frequency Solutions by Patrizio Grando.", 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", width: "device-width",
initialScale: 1, initialScale: 1,
maximumScale: 1, maximumScale: 1,
userScalable: false, userScalable: false,
viewportFit: "cover", viewportFit: "cover",
themeColor: branding.themeColor,
}; };
}
export default async function RootLayout({ export default async function RootLayout({
children, children,
+2 -1
View File
@@ -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 Link from "next/link";
import Image from "next/image"; import Image from "next/image";
+2 -3
View File
@@ -1,5 +1,3 @@
export const dynamic = "force-dynamic";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -7,8 +5,9 @@ import { Newspaper, ArrowRight, Calendar } from "lucide-react";
import BreathingField from "@/components/visuals/BreathingField"; import BreathingField from "@/components/visuals/BreathingField";
import { getLocalizedData } from "@/lib/i18nHelper"; 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 const revalidate = 60;
export default async function NewsHub({ params }: { params: Promise<{ locale: string }> }) { export default async function NewsHub({ params }: { params: Promise<{ locale: string }> }) {
+48 -7
View File
@@ -15,25 +15,66 @@ import ApplicationsDeep from "@/components/sections/ApplicationsDeep";
import HeroReel from "@/components/sections/HeroReel"; import HeroReel from "@/components/sections/HeroReel";
import WhatWeDo from "@/components/sections/WhatWeDo"; 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 // ✅ Next.js 16: params es Promise y DEBE ser awaiteado
export default async function Home({ params }: { params: Promise<{ locale: string }> }) { export default async function Home({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params; const { locale } = await params;
// --- 1. LECTURA DE IMÁGENES --- // --- 1. SLIDES DEL HERO (CMS-managed via HeroSlide model, fallback to filesystem) ---
let footageImages: string[] = []; 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 { try {
const footageDir = path.join(process.cwd(), "public", "footage", "main"); const footageDir = path.join(process.cwd(), "public", "footage", "main");
if (fs.existsSync(footageDir)) { if (fs.existsSync(footageDir)) {
const files = fs.readdirSync(footageDir); const files = fs.readdirSync(footageDir);
footageImages = files heroSlides = files
.filter(file => /\.(png|jpe?g|webp)$/i.test(file)) .filter((file) => /\.(png|jpe?g|webp)$/i.test(file))
.map(file => `/footage/main/${file}`); .sort()
.map((file) => ({
mediaUrl: `/footage/main/${file}`,
mediaType: "image",
altText: null,
focalPointX: 0.5,
focalPointY: 0.5,
translationsJson: null,
}));
} }
} catch (error) { } catch (error) {
console.error("Error reading footage directory:", error); console.error("Error reading footage directory:", error);
} }
}
// --- 2. NODOS DEL GLOBO --- // --- 2. NODOS DEL GLOBO ---
let mapNodes: any[] = []; 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"> <main className="relative min-h-screen flex flex-col items-center w-full">
<BreathingField /> <BreathingField />
<div className="w-full overflow-hidden flex flex-col items-center justify-center"> <div className="w-full overflow-hidden flex flex-col items-center justify-center">
<HeroReel images={footageImages} /> <HeroReel slides={heroSlides} />
</div> </div>
<WhatWeDo /> <WhatWeDo />
<div className="w-full overflow-hidden flex flex-col items-center"> <div className="w-full overflow-hidden flex flex-col items-center">
+1 -2
View File
@@ -1,5 +1,3 @@
export const dynamic = "force-dynamic";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { getLocalizedData } from "@/lib/i18nHelper"; import { getLocalizedData } from "@/lib/i18nHelper";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
@@ -8,6 +6,7 @@ import ComponentGrid from "./_components/ComponentGrid";
import { Metadata } from "next"; import { Metadata } from "next";
import { getClientSession } from "@/app/actions/clientAuth"; import { getClientSession } from "@/app/actions/clientAuth";
// B2B portal — auth-gated, never cached.
export const revalidate = 0; export const revalidate = 0;
export const metadata: Metadata = { export const metadata: Metadata = {
+46 -11
View File
@@ -30,6 +30,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import { revalidateContent, type RevalidateScope } from "@/lib/revalidate";
const SCOPE_ROOTS: Record<string, string> = { const SCOPE_ROOTS: Record<string, string> = {
applications: path.join(process.cwd(), "public", "applications"), applications: path.join(process.cwd(), "public", "applications"),
@@ -37,8 +38,15 @@ const SCOPE_ROOTS: Record<string, string> = {
news: path.join(process.cwd(), "public", "news"), news: path.join(process.cwd(), "public", "news"),
// 🔥 NUEVO: Scope para el Component Matrix // 🔥 NUEVO: Scope para el Component Matrix
parts: path.join(process.cwd(), "public", "parts"), 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[]> = { const MEDIA_TYPES: Record<string, string[]> = {
image: [".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg", ".avif"], image: [".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg", ".avif"],
video: [".mp4", ".webm", ".mov"], video: [".mp4", ".webm", ".mov"],
@@ -72,7 +80,18 @@ function sanitizePath(input: string): string {
function buildSafePath(scope: string, slug: string, subPath?: string): string | null { function buildSafePath(scope: string, slug: string, subPath?: string): string | null {
const root = SCOPE_ROOTS[scope]; 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); const appDir = path.join(root, slug);
if (!subPath || subPath === "" || subPath === "/") return appDir; if (!subPath || subPath === "" || subPath === "/") return appDir;
const cleaned = sanitizePath(subPath); const cleaned = sanitizePath(subPath);
@@ -81,6 +100,12 @@ function buildSafePath(scope: string, slug: string, subPath?: string): string |
return fullPath; 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) { function buildBreadcrumbs(subPath: string) {
const parts = subPath ? subPath.split("/").filter(Boolean) : []; const parts = subPath ? subPath.split("/").filter(Boolean) : [];
const crumbs = [{ name: "Root", path: "" }]; const crumbs = [{ name: "Root", path: "" }];
@@ -97,11 +122,11 @@ export async function GET(request: NextRequest) {
try { try {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const scope = searchParams.get("scope") || "applications"; const scope = searchParams.get("scope") || "applications";
const slug = searchParams.get("slug"); const slug = searchParams.get("slug") || "";
const subPath = searchParams.get("path") || ""; 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 (!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); const dirPath = buildSafePath(scope, slug, subPath);
if (!dirPath) return NextResponse.json({ error: "Invalid path" }, { status: 400 }); if (!dirPath) return NextResponse.json({ error: "Invalid path" }, { status: 400 });
@@ -136,7 +161,7 @@ export async function GET(request: NextRequest) {
mediaType: getFileType(entry.name), mediaType: getFileType(entry.name),
extension: path.extname(entry.name).toLowerCase(), extension: path.extname(entry.name).toLowerCase(),
path: rel, path: rel,
publicUrl: `/${scope}/${slug}/${rel}`, publicUrl: buildPublicUrl(scope, slug, rel),
size: getFileSize(stats.size), size: getFileSize(stats.size),
sizeBytes: stats.size, sizeBytes: stats.size,
modifiedAt: stats.mtime.toISOString(), modifiedAt: stats.mtime.toISOString(),
@@ -165,12 +190,13 @@ export async function POST(request: NextRequest) {
try { try {
const formData = await request.formData(); const formData = await request.formData();
const scope = (formData.get("scope") as string) || "applications"; 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 subPath = formData.get("path") as string || "";
const file = formData.get("file") as File; 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 (!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(); const ext = path.extname(file.name).toLowerCase();
if (!ALL_EXTENSIONS.includes(ext)) { if (!ALL_EXTENSIONS.includes(ext)) {
@@ -193,11 +219,14 @@ export async function POST(request: NextRequest) {
const rel = subPath ? `${subPath}/${safeName}` : safeName; const rel = subPath ? `${subPath}/${safeName}` : safeName;
// 🔥 Invalida caché para que la imagen aparezca sin recompilar
revalidateContent({ scope: scope as RevalidateScope, slug });
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
file: { file: {
name: safeName, name: safeName,
publicUrl: `/${scope}/${slug}/${rel}`, publicUrl: buildPublicUrl(scope, slug, rel),
path: rel, path: rel,
mediaType: getFileType(safeName), mediaType: getFileType(safeName),
size: getFileSize(file.size), size: getFileSize(file.size),
@@ -214,10 +243,11 @@ export async function POST(request: NextRequest) {
export async function PUT(request: NextRequest) { export async function PUT(request: NextRequest) {
try { try {
const body = await request.json(); 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 (!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, ""); const safe = folderName.toLowerCase().trim().replace(/\s+/g, "-").replace(/[^a-z0-9_-]/g, "");
if (!safe) return NextResponse.json({ error: "Invalid folder name" }, { status: 400 }); 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 }); fs.mkdirSync(targetPath, { recursive: true });
revalidateContent({ scope: scope as RevalidateScope, slug });
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
folder: { name: safe, path: parentPath ? `${parentPath}/${safe}` : safe } folder: { name: safe, path: parentPath ? `${parentPath}/${safe}` : safe }
@@ -243,10 +275,11 @@ export async function PUT(request: NextRequest) {
export async function DELETE(request: NextRequest) { export async function DELETE(request: NextRequest) {
try { try {
const body = await request.json(); 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 (!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); const targetPath = buildSafePath(scope, slug, filePath);
if (!targetPath) return NextResponse.json({ error: "Invalid path" }, { status: 400 }); if (!targetPath) return NextResponse.json({ error: "Invalid path" }, { status: 400 });
@@ -256,6 +289,8 @@ export async function DELETE(request: NextRequest) {
fs.unlinkSync(targetPath); fs.unlinkSync(targetPath);
revalidateContent({ scope: scope as RevalidateScope, slug });
return NextResponse.json({ success: true, deleted: filePath }); return NextResponse.json({ success: true, deleted: filePath });
} catch (error) { } catch (error) {
console.error("Asset DELETE error:", error); console.error("Asset DELETE error:", error);
+4
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import { revalidateContent } from "@/lib/revalidate";
// 1. REGLAS DE SEGURIDAD ESTRICTAS // 1. REGLAS DE SEGURIDAD ESTRICTAS
const ALLOWED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp', '.mp4', '.mov']; 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 // 6. DEVOLVER LA URL PÚBLICA PARA GUARDARLA EN POSTGRES
const publicUrl = `/operations-inbox/${folderName}/${safeFileName}`; const publicUrl = `/operations-inbox/${folderName}/${safeFileName}`;
// Invalida caché del operations-inbox / dashboard
revalidateContent({ scope: "operations-inbox", slug: folderName });
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
url: publicUrl, 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;
}
}
+423
View File
@@ -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>
);
}
+21 -1
View File
@@ -15,7 +15,9 @@ import {
LogOut, LogOut,
Radar, Radar,
Wrench, Wrench,
Server Server,
Image as ImageIcon,
Settings as SettingsIcon,
} from "lucide-react"; } from "lucide-react";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { logoutAdmin } from "@/app/hq-command/login/actions"; 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 appsCount = await prisma.application.count({ where: { isActive: true } });
const modules = [ 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", title: "Global Network",
description: "Manage physical installations, nodes, and events on the 3D Holographic Map.", 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", color: "text-blue-400",
bg: "bg-blue-400/10", bg: "bg-blue-400/10",
border: "hover:border-blue-400/50" 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 &amp; <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>
);
}
+45 -19
View File
@@ -1,31 +1,40 @@
import { Link } from "@/i18n/routing"; import { Link } from "@/i18n/routing";
import { Linkedin, Instagram, Youtube, Mail } from "lucide-react";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { getLocalizedData } from "@/lib/i18nHelper"; import { getLocalizedData } from "@/lib/i18nHelper";
import { getTranslations, getLocale } from "next-intl/server"; import { getTranslations, getLocale } from "next-intl/server";
// 🔥 IMPORTAMOS ESTO PARA ROMPER LA CACHÉ import { getFooterSettings, getSocialLinks } from "@/lib/siteSettings";
import { unstable_noStore as noStore } from "next/cache";
// Importamos nuestros componentes interactivos
import { AiContactButton, AiFooterLink } from "@/components/ui/AiTriggerButton"; import { AiContactButton, AiFooterLink } from "@/components/ui/AiTriggerButton";
export default async function Footer() { export default async function Footer() {
noStore(); // 🔥 Esta línea asegura que el Footer SIEMPRE consulte Prisma al recargar
const locale = await getLocale(); const locale = await getLocale();
const t = await getTranslations("Footer"); const t = await getTranslations("Footer");
const [footer, social] = await Promise.all([
getFooterSettings(locale),
getSocialLinks(locale),
]);
let activeApps: any[] = []; let activeApps: any[] = [];
try { try {
const rawApps = await prisma.application.findMany({ const rawApps = await prisma.application.findMany({
where: { isActive: true }, where: { isActive: true },
orderBy: { createdAt: "asc" }, orderBy: { createdAt: "asc" },
take: 4 take: 4,
}); });
activeApps = rawApps.map((app: any) => getLocalizedData(app, locale)); activeApps = rawApps.map((app: any) => getLocalizedData(app, locale));
} catch (error) { } catch (error) {
console.error("Error loading apps in footer", 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 ( return (
<footer className="bg-[#1D1D1F] text-[#F5F5F7] pt-24 pb-12 rounded-t-[40px] mt-20 relative z-20 shadow-2xl"> <footer className="bg-[#1D1D1F] text-[#F5F5F7] pt-24 pb-12 rounded-t-[40px] mt-20 relative z-20 shadow-2xl">
<div className="max-w-7xl mx-auto px-6"> <div className="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 className="flex flex-col md:flex-row justify-between items-start md:items-end mb-24 gap-12">
<div> <div>
<h2 className="text-4xl md:text-6xl font-light tracking-tight mb-6"> <h2 className="text-4xl md:text-6xl font-light tracking-tight mb-6">
Ready to optimize <br /> {footer.ctaTitle1} <br />
<span className="font-medium text-[#0066CC] dark:text-[#00F0FF] transition-colors">your production?</span> <span className="font-medium text-[#0066CC] dark:text-[#00F0FF] transition-colors">
{footer.ctaTitle2}
</span>
</h2> </h2>
<p className="text-[#86868B] text-lg max-w-md font-light"> <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> </p>
</div> </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"> <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"> <div className="flex flex-col gap-4">
<span className="text-sm font-semibold tracking-widest text-[#86868B] uppercase mb-2">{t("techTitle")}</span> <span className="text-sm font-semibold tracking-widest text-[#86868B] uppercase mb-2">{t("techTitle")}</span>
<AiFooterLink <AiFooterLink
@@ -63,10 +73,9 @@ export default async function Footer() {
/> />
</div> </div>
{/* 🔥 COLUMNA APLICACIONES: 100% DINÁMICA Y ANTI-CACHÉ 🔥 */}
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<span className="text-sm font-semibold tracking-widest text-[#86868B] uppercase mb-2">{t("appsTitle")}</span> <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"> <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} {app.title}
</Link> </Link>
@@ -83,20 +92,37 @@ export default async function Footer() {
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<span className="text-sm font-semibold tracking-widest text-[#86868B] uppercase mb-2">{t("hqTitle")}</span> <span className="text-sm font-semibold tracking-widest text-[#86868B] uppercase mb-2">{t("hqTitle")}</span>
<p className="text-[#86868B] font-light leading-relaxed"> <p className="text-[#86868B] font-light leading-relaxed">
Via Benedetto Marcello 32 <br /> {footer.hqAddress} <br />
36060 Romano d'Ezzelino <br /> {footer.hqCity} <br />
Vicenza, Italy {footer.hqRegion}, {footer.hqCountry}
</p> </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> </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"> <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"> <div className="flex gap-6 mt-4 md:mt-0">
<Link href="#" className="hover:text-white transition-colors">Privacy Policy</Link> <Link href={footer.privacyUrl as any} className="hover:text-white transition-colors">Privacy Policy</Link>
<Link href="#" className="hover:text-white transition-colors">Terms of Service</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="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")} {t("madeInItaly")}
</span> </span>
</div> </div>
+77 -39
View File
@@ -1,38 +1,65 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect, useMemo } from "react";
import Image from "next/image"; import Image from "next/image";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
// 🔥 IMPORTAMOS LA FUENTE FUTURISTA/EXTENDIDA SÓLO PARA EL HERO 🔥
import { Syncopate } from "next/font/google"; import { Syncopate } from "next/font/google";
const syncopate = Syncopate({ weight: ['400', '700'], subsets: ['latin'] });
interface HeroReelProps { const syncopate = Syncopate({ weight: ["400", "700"], subsets: ["latin"] });
images: string[];
export interface HeroSlideData {
mediaUrl: string;
mediaType: string; // "image" | "video"
altText: string | null;
focalPointX: number; // 01
focalPointY: number; // 01
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 [currentIndex, setCurrentIndex] = useState(0);
const t = useTranslations("HeroReel"); const t = useTranslations("HeroReel");
useEffect(() => { useEffect(() => {
if (!images || images.length <= 1) return; if (slides.length <= 1) return;
const timer = setInterval(() => { const timer = setInterval(() => {
setCurrentIndex((prevIndex) => (prevIndex + 1) % images.length); setCurrentIndex((prev) => (prev + 1) % slides.length);
}, 3600); }, 3600);
return () => clearInterval(timer); return () => clearInterval(timer);
}, [images]); }, [slides.length]);
const current = slides[currentIndex];
return ( return (
<div <div
id="technology" 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"> <AnimatePresence mode="popLayout">
{images.length > 0 ? ( {current ? (
<motion.div <motion.div
key={currentIndex} key={currentIndex}
initial={{ opacity: 0, scale: 1.05 }} 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] }} transition={{ duration: 1.5, ease: [0.25, 0.1, 0.25, 1] }}
className="absolute inset-0 w-full h-full" className="absolute inset-0 w-full h-full"
> >
<Image {current.mediaType === "video" ? (
src={images[currentIndex]} <video
alt={`FLUX Vision ${currentIndex}`} key={current.mediaUrl}
fill src={current.mediaUrl}
quality={100} autoPlay
sizes="100vw" muted
className="object-cover" playsInline
priority={currentIndex === 0} 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> </motion.div>
) : ( ) : (
<div className="absolute inset-0 bg-gradient-to-br from-[#111] to-[#0A0A0C]" /> <div className="absolute inset-0 bg-gradient-to-br from-[#111] to-[#0A0A0C]" />
)} )}
</AnimatePresence> </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 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" /> <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 ── */} {/* Overlay text */}
{/* 🔥 Usamos items-end y pb-24/pb-32 para tirarlo hacia la mitad inferior 🔥 */}
<div className="absolute inset-0 z-20 flex items-end justify-start px-6 md:px-12 lg:px-24 pb-24 md:pb-32 pointer-events-none"> <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 <motion.div
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4, duration: 1, ease: "easeOut" }} 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" 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"> <div className="flex flex-col gap-1 md:gap-3">
{/* LEMA PRINCIPAL (Fuente Syncopate) */} <h1
<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`}> 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`}
LET THE POWER FLUX >
{current?.title || "LET THE POWER FLUX"}
</h1> </h1>
{/* FRASE SECUNDARIA (Fuente limpia y elegante) */} <h2 className="text-base sm:text-lg md:text-2xl lg:text-[2rem] font-light text-white uppercase tracking-[0.2em] drop-shadow-lg">
<h2 className="text-lg md:text-2xl lg:text-[2rem] font-light text-white uppercase tracking-[0.2em] drop-shadow-lg"> {current?.subtitle || "INNOVATION NOT IMITATION"}
INNOVATION NOT IMITATION
</h2> </h2>
</div> </div>
{/* ESPACIADOR INVISIBLE */} <div className="h-2 md:h-4" />
<div className="h-2 md:h-4"></div>
{/* BLOQUE DE DESCRIPCIÓN TIPO "CONSOLA" */}
<div className="flex flex-col gap-2 md:gap-3"> <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"> <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>
<p className="font-mono text-[10px] md:text-xs lg:text-sm text-white/90 uppercase tracking-widest drop-shadow-md"> <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> </p>
</div> </div>
</motion.div> </motion.div>
</div> </div>
</div> </div>
+110
View File
@@ -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 });
}
+70
View File
@@ -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 };
}
+64
View File
@@ -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",
};