Compare commits

..

4 Commits

Author SHA1 Message Date
davidherran c45a5be99e feat(i18n): translate hardcoded article page strings to 5 locales
Deploy to VPS / deploy (push) Has been cancelled
- Add ArticlePage namespace (backToNewsHub, backToNews, mediaGallery,
  joinLinkedIn, internalRelease) to all 5 locale message files
  (en, it, es, de, vec)
- Replace 5 hardcoded English strings in news/[slug]/page.tsx with
  getTranslations() calls

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-06 18:14:46 -05:00
davidherran cb7458cded feat(seo): visual breadcrumb navigation on article + application pages
- Create Breadcrumbs.tsx server component — semantic <nav> + <ol>/<li>
  with aria-current, ChevronRight separators, Apple-clean styling
- Add breadcrumbs to news article hero overlay (reuses JSON-LD crumbs)
- Add breadcrumbs to application detail hero (passed as prop to client
  component)
- Refactor breadcrumb data into shared array for JSON-LD + visual nav

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-06 18:10:49 -05:00
davidherran 7ec99734c5 feat(seo): LocalBusiness + CollectionPage structured data schemas
- Add localBusinessSchema() with geo coords, phone, opening hours for
  Google Local Pack and Knowledge Panel visibility
- Add collectionPageSchema() with ItemList for article listing pages
- Inject LocalBusiness alongside Organization+WebSite in root layout
- Inject CollectionPage in /news hub page with article items

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-06 18:06:12 -05:00
davidherran 8d80cbbc27 perf(seo): image sizes, semantic HTML, X-Robots-Tag headers
- Add `sizes` prop to 8 <Image> components across news, heritage, and
  application pages — tells the browser which srcset variant to download,
  improving LCP and reducing bandwidth
- Replace date <span> with <time dateTime={ISO}> on news pages —
  Google uses datetime for article freshness signals
- Wrap news cards and article content in <article> tags — semantic
  boundary for crawlers
- Add X-Robots-Tag: noindex, nofollow header to all /hq-command
  responses in proxy.ts — defense-in-depth alongside meta robots

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-06 18:04:40 -05:00
14 changed files with 240 additions and 46 deletions
+7
View File
@@ -184,6 +184,13 @@
"page": "Seite",
"of": "von"
},
"ArticlePage": {
"backToNewsHub": "Zurück zum News Hub",
"backToNews": "Zurück zu Nachrichten",
"mediaGallery": "Mediengalerie",
"joinLinkedIn": "Diskussion auf LinkedIn beitreten",
"internalRelease": "Interne Unternehmensmitteilung"
},
"AuthModal": {
"b2bPortal": "B2B-Portal",
"signIn": "Anmelden",
+7
View File
@@ -184,6 +184,13 @@
"page": "Page",
"of": "of"
},
"ArticlePage": {
"backToNewsHub": "Back to News Hub",
"backToNews": "Back to News",
"mediaGallery": "Media Gallery",
"joinLinkedIn": "Join the conversation on LinkedIn",
"internalRelease": "Internal Corporate Release"
},
"AuthModal": {
"b2bPortal": "B2B Portal",
"signIn": "Sign In",
+7
View File
@@ -184,6 +184,13 @@
"page": "Página",
"of": "de"
},
"ArticlePage": {
"backToNewsHub": "Volver al News Hub",
"backToNews": "Volver a Noticias",
"mediaGallery": "Galería de Medios",
"joinLinkedIn": "Únete a la conversación en LinkedIn",
"internalRelease": "Comunicado Corporativo Interno"
},
"AuthModal": {
"b2bPortal": "Portal B2B",
"signIn": "Iniciar Sesión",
+7
View File
@@ -184,6 +184,13 @@
"page": "Pagina",
"of": "di"
},
"ArticlePage": {
"backToNewsHub": "Torna al News Hub",
"backToNews": "Torna alle Notizie",
"mediaGallery": "Galleria Media",
"joinLinkedIn": "Partecipa alla conversazione su LinkedIn",
"internalRelease": "Comunicato Aziendale Interno"
},
"AuthModal": {
"b2bPortal": "Portale B2B",
"signIn": "Accedi",
+7
View File
@@ -184,6 +184,13 @@
"page": "Pagina",
"of": "de"
},
"ArticlePage": {
"backToNewsHub": "Torna al News Hub",
"backToNews": "Torna a łe Notissie",
"mediaGallery": "Gałeria Media",
"joinLinkedIn": "Parteçipa a ła conversassion su LinkedIn",
"internalRelease": "Comunicato Aziendałe Interno"
},
"AuthModal": {
"b2bPortal": "Portal par ditte",
"signIn": "Entra chive",
@@ -9,6 +9,8 @@ import Script from "next/script";
import { ArrowLeft, CheckCircle2, Zap, LayoutDashboard, Cpu, PencilRuler, Factory, MapPin, ChevronDown, Play, FileText, Box, Loader2, Maximize, X, ChevronLeft, ChevronRight } from "lucide-react";
import BreathingField from "@/components/visuals/BreathingField";
import AutoPlayVideo from "@/components/AutoPlayVideo";
import Breadcrumbs from "@/components/seo/Breadcrumbs";
import type { BreadcrumbItem } from "@/components/seo/Breadcrumbs";
// 🔥 EL TRUCO DEFINITIVO PARA TYPESCRIPT Y WEB COMPONENTS 🔥
// Al asignar el string a una variable con 'as any', TypeScript deja de
@@ -904,7 +906,7 @@ function ExpandedCaseStudy({ node }: { node: any }) {
const fullImgSrc = `/cases/${nodeSlug}/${img}`;
return (
<div key={`g-${idx}`} className="relative w-full aspect-video rounded-2xl md:rounded-3xl overflow-hidden border border-black/10 dark:border-white/10 shadow-lg group bg-white">
<Image src={fullImgSrc} alt="Installation" fill className="object-cover group-hover:scale-105 transition-transform duration-700" />
<Image src={fullImgSrc} alt="Installation" fill sizes="(max-width: 768px) 100vw, 50vw" className="object-cover group-hover:scale-105 transition-transform duration-700" />
<button
onClick={() => openLightbox(fullImgSrc)}
className="absolute inset-0 w-full h-full bg-black/0 hover:bg-black/30 active:bg-black/40 transition-all flex items-center justify-center opacity-0 group-hover:opacity-100 backdrop-blur-[0px] hover:backdrop-blur-sm touch-manipulation"
@@ -999,7 +1001,7 @@ function ExpandedCaseStudy({ node }: { node: any }) {
}
// --- COMPONENTE PRINCIPAL ---
export default function ApplicationClient({ data, realCases, images }: { data: any, realCases: any[], images: any }) {
export default function ApplicationClient({ data, realCases, images, breadcrumbs }: { data: any, realCases: any[], images: any, breadcrumbs?: BreadcrumbItem[] }) {
const [expandedCase, setExpandedCase] = useState<string | null>(null);
const [mainLightboxOpen, setMainLightboxOpen] = useState(false);
@@ -1045,7 +1047,7 @@ export default function ApplicationClient({ data, realCases, images }: { data: a
<section className="relative w-full h-[50vh] md:h-[70vh] flex items-end justify-start overflow-hidden">
{heroImage ? (
<Image src={heroImage} alt={data.title} fill className="object-cover object-center scale-105 animate-slow-zoom" priority />
<Image src={heroImage} alt={data.title} fill sizes="100vw" className="object-cover object-center scale-105 animate-slow-zoom" priority />
) : (
<div className="absolute inset-0 bg-gradient-to-br from-[#0066CC]/20 to-black/80" />
)}
@@ -1053,6 +1055,7 @@ export default function ApplicationClient({ data, realCases, images }: { data: a
<div className="relative z-10 max-w-4xl mx-auto px-6 pb-12 md:pb-16 w-full">
<header>
{breadcrumbs && <Breadcrumbs items={breadcrumbs} />}
<div className="inline-flex items-center gap-2 text-[#0066CC] dark:text-[#00F0FF] mb-3 bg-white/80 dark:bg-black/80 backdrop-blur-sm px-3 py-1.5 rounded-full border border-black/5 dark:border-white/10">
<LayoutDashboard size={14} />
<span className="text-[10px] md:text-xs font-semibold uppercase tracking-widest">{data.category}</span>
@@ -1149,7 +1152,7 @@ export default function ApplicationClient({ data, realCases, images }: { data: a
<div className="flex items-center gap-5 flex-1">
<div className="w-16 h-16 md:w-20 md:h-20 rounded-2xl md:rounded-3xl bg-black/5 dark:bg-black/50 border border-black/10 dark:border-white/5 flex items-center justify-center shrink-0 overflow-hidden relative shadow-inner">
{node.mediaFileName ? (
<Image src={`/cases/${nodeToSlug(node.title)}/${node.mediaFileName}`} alt={node.title} fill className="object-cover opacity-90 group-hover:scale-110 transition-transform duration-700" />
<Image src={`/cases/${nodeToSlug(node.title)}/${node.mediaFileName}`} alt={node.title} fill sizes="100px" className="object-cover opacity-90 group-hover:scale-110 transition-transform duration-700" />
) : (
<Cpu size={28} className="text-[#0066CC]/50 dark:text-[#00F0FF]/50" />
)}
+14 -10
View File
@@ -158,24 +158,28 @@ export default async function ApplicationPage({ params }: { params: Promise<{ sl
const images = getApplicationImages(slug);
// 4. JSON-LD structured data — wrapped to never break the render.
const appTitle = data?.title || "FLUX Application";
const appUrl = `${baseUrl()}/${locale}/applications/${slug}`;
const crumbs = [
{ name: "Home", url: `/${locale}` },
{ name: "Applications", url: `/${locale}#applications-deep` },
{ name: appTitle, url: `/${locale}/applications/${slug}` },
];
let jsonLd: object[] = [];
try {
const url = `${baseUrl()}/${locale}/applications/${slug}`;
const title = data?.title || "FLUX Application";
const description = data?.shortDescription || data?.subtitle || "";
jsonLd = [
productSchema({
name: title,
name: appTitle,
description,
imageUrl: images.heroImage || undefined,
category: data?.category || "RF Industrial",
url,
url: appUrl,
}),
breadcrumbSchema([
{ name: "Home", url: `${baseUrl()}/${locale}` },
{ name: "Applications", url: `${baseUrl()}/${locale}#applications-deep` },
{ name: title, url },
]),
breadcrumbSchema(
crumbs.map((c) => ({ name: c.name, url: `${baseUrl()}${c.url}` }))
),
];
} catch (error) {
console.error(`[applications/${slug}] JSON-LD build failed:`, error);
@@ -184,7 +188,7 @@ export default async function ApplicationPage({ params }: { params: Promise<{ sl
return (
<>
{jsonLd.length > 0 && <JsonLd data={jsonLd} />}
<ApplicationClient data={data} realCases={realCases} images={images} />
<ApplicationClient data={data} realCases={realCases} images={images} breadcrumbs={crumbs} />
</>
);
}
+1 -1
View File
@@ -256,7 +256,7 @@ export default async function HeritagePage({ params }: { params: Promise<{ local
{sec.type === 'image' && sec.mediaUrl && (
<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 sizes="100vw" className="object-cover grayscale hover:grayscale-0 transition-all duration-1000" />
</div>
)}
+5 -1
View File
@@ -20,7 +20,7 @@ export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
import { getBranding, getSocialLinks } from '@/lib/siteSettings';
import { organizationSchema, websiteSchema } from '@/lib/seo';
import { organizationSchema, websiteSchema, localBusinessSchema } from '@/lib/seo';
import JsonLd from '@/components/seo/JsonLd';
const inter = Inter({ subsets: ["latin"] });
@@ -154,6 +154,10 @@ export default async function RootLayout({
logoUrl: branding.logoUrl,
sameAs: sameAs.length ? sameAs : undefined,
}),
localBusinessSchema({
logoUrl: branding.logoUrl,
sameAs: sameAs.length ? sameAs : undefined,
}),
websiteSchema(),
]}
/>
+31 -22
View File
@@ -8,8 +8,9 @@ import { prisma } from "@/lib/prisma";
import { ArrowLeft, Calendar, Tag, Linkedin } from "lucide-react";
import BreathingField from "@/components/visuals/BreathingField";
import { setRequestLocale } from "next-intl/server";
import { getTranslations, setRequestLocale } from "next-intl/server";
import { getLocalizedData } from "@/lib/i18nHelper";
import Breadcrumbs from "@/components/seo/Breadcrumbs";
import {
buildPageMetadata,
articleSchema,
@@ -227,6 +228,7 @@ export default async function ArticlePage({ params }: { params: Promise<{ slug:
const resolvedParams = await params;
const { slug, locale } = resolvedParams;
setRequestLocale(locale);
const t = await getTranslations({ locale, namespace: "ArticlePage" });
let rawArticle: any = null;
try {
@@ -259,6 +261,12 @@ export default async function ArticlePage({ params }: { params: Promise<{ slug:
const articleUrl = `${baseUrl()}/${locale}/news/${slug}`;
const cover = article.coverImage ? `/news/${slug}/${article.coverImage}` : undefined;
const crumbs = [
{ name: "Home", url: `/${locale}` },
{ name: "News", url: `/${locale}/news` },
{ name: article?.title || "Article", url: `/${locale}/news/${slug}` },
];
let jsonLd: object[] = [];
try {
jsonLd = [
@@ -270,11 +278,9 @@ export default async function ArticlePage({ params }: { params: Promise<{ slug:
publishedAt: article?.publishedAt || new Date(),
updatedAt: article?.updatedAt || new Date(),
}),
breadcrumbSchema([
{ name: "Home", url: `${baseUrl()}/${locale}` },
{ name: "News", url: `${baseUrl()}/${locale}/news` },
{ name: article?.title || "Article", url: articleUrl },
]),
breadcrumbSchema(
crumbs.map((c) => ({ name: c.name, url: `${baseUrl()}${c.url}` }))
),
];
} catch (error) {
console.error(`[news/${slug}] JSON-LD build failed:`, error);
@@ -288,20 +294,23 @@ export default async function ArticlePage({ params }: { params: Promise<{ slug:
<div className="fixed top-24 left-6 z-50 hidden md:block">
{/* EL BOTÓN DE VOLVER AHORA RESPETA EL IDIOMA */}
<Link href={`/${locale}/news`} className="inline-flex items-center gap-2 text-sm font-medium text-[#86868B] hover:text-[#0066CC] dark:hover:text-[#4DA6FF] transition-colors py-2 px-4 bg-white/50 dark:bg-black/50 backdrop-blur-md rounded-full group shadow-sm border border-black/5 dark:border-white/10">
<ArrowLeft size={16} className="group-hover:-translate-x-1 transition-transform" /> Back to News Hub
<ArrowLeft size={16} className="group-hover:-translate-x-1 transition-transform" /> {t("backToNewsHub")}
</Link>
</div>
<section className="relative w-full h-[50vh] md:h-[70vh] flex items-end justify-center overflow-hidden bg-[#1D1D1F]">
{article.coverImage && (
<Image src={`/news/${slug}/${article.coverImage}`} alt={article.title} fill className="object-cover object-center opacity-60" priority />
<Image src={`/news/${slug}/${article.coverImage}`} alt={article.title} fill sizes="100vw" className="object-cover object-center opacity-60" priority />
)}
<div className="absolute inset-0 bg-gradient-to-t from-white via-white/80 dark:from-[#0A0A0C] dark:via-[#0A0A0C]/80 to-transparent" />
<div className="relative z-10 max-w-4xl w-full mx-auto px-6 pb-12 md:pb-20 text-center">
<div className="flex justify-center mb-4">
<Breadcrumbs items={crumbs} />
</div>
<div className="flex flex-wrap items-center justify-center gap-4 text-xs font-semibold uppercase tracking-widest text-[#0066CC] dark:text-[#4DA6FF] mb-6">
<span className="flex items-center gap-1.5 bg-[#0066CC]/10 dark:bg-[#4DA6FF]/10 px-3 py-1.5 rounded-full"><Tag size={14}/> {article.category}</span>
<span className="flex items-center gap-1.5 text-[#86868B]"><Calendar size={14}/> {new Date(article.publishedAt).toLocaleDateString(locale === 'en' ? 'en-US' : locale, { month: 'long', day: 'numeric', year: 'numeric' })}</span>
<time dateTime={new Date(article.publishedAt).toISOString()} className="flex items-center gap-1.5 text-[#86868B]"><Calendar size={14}/> {new Date(article.publishedAt).toLocaleDateString(locale === 'en' ? 'en-US' : locale, { month: 'long', day: 'numeric', year: 'numeric' })}</time>
</div>
<h1 className="text-4xl md:text-6xl font-medium text-[#1D1D1F] dark:text-[#F5F5F7] tracking-tight mb-6 leading-tight">
{article.title}
@@ -312,8 +321,8 @@ export default async function ArticlePage({ params }: { params: Promise<{ slug:
</div>
</section>
<div className="relative z-10 max-w-3xl mx-auto px-6 mt-12 md:mt-20">
<article className="relative z-10 max-w-3xl mx-auto px-6 mt-12 md:mt-20">
{/* 🔥 EL MARKDOWN TRADUCIDO AL VUELO 🔥 */}
<div className="max-w-none mb-16">
{renderMarkdown(article.content)}
@@ -321,11 +330,11 @@ export default async function ArticlePage({ params }: { params: Promise<{ slug:
{gallery.length > 0 && (
<div className="mt-16 pt-16 border-t border-black/5 dark:border-white/5">
<h3 className="text-xl font-medium mb-8 text-[#1D1D1F] dark:text-white">Media Gallery</h3>
<h3 className="text-xl font-medium mb-8 text-[#1D1D1F] dark:text-white">{t("mediaGallery")}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{gallery.map((imgSrc: string, idx: number) => (
<div key={idx} className={`relative rounded-3xl overflow-hidden bg-[#1D1D1F] border border-black/10 dark:border-white/10 ${idx === 0 && gallery.length % 2 !== 0 ? 'sm:col-span-2 h-64 md:h-96' : 'h-48 md:h-64'}`}>
<Image src={`/news/${slug}/gallery/${imgSrc}`} alt={`Gallery image ${idx + 1}`} fill className="object-cover hover:scale-105 transition-transform duration-700" />
<Image src={`/news/${slug}/gallery/${imgSrc}`} alt={`Gallery image ${idx + 1}`} fill sizes="(max-width: 640px) 100vw, 50vw" className="object-cover hover:scale-105 transition-transform duration-700" />
</div>
))}
</div>
@@ -334,25 +343,25 @@ export default async function ArticlePage({ params }: { params: Promise<{ slug:
<div className="mt-16 pt-8 border-t border-black/10 dark:border-white/10 flex justify-between items-center">
<Link href={`/${locale}/news`} className="text-[#0066CC] dark:text-[#4DA6FF] font-medium flex items-center gap-2 hover:underline md:hidden">
<ArrowLeft size={16} /> Back to News
<ArrowLeft size={16} /> {t("backToNews")}
</Link>
{article.linkedinUrl ? (
<a
href={article.linkedinUrl}
target="_blank"
rel="noopener noreferrer"
<a
href={article.linkedinUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm font-semibold text-white bg-[#0A66C2] px-6 py-3 rounded-full hover:bg-[#004182] transition-all shadow-lg shadow-[#0A66C2]/20 ml-auto md:ml-0"
>
<Linkedin size={16} /> Join the conversation on LinkedIn
<Linkedin size={16} /> {t("joinLinkedIn")}
</a>
) : (
<div className="text-xs text-[#86868B] italic hidden md:block">
Internal Corporate Release
{t("internalRelease")}
</div>
)}
</div>
</div>
</article>
</main>
);
}
+25 -6
View File
@@ -7,7 +7,8 @@ import BreathingField from "@/components/visuals/BreathingField";
import { getLocalizedData } from "@/lib/i18nHelper";
import { getTranslations, setRequestLocale } from "next-intl/server";
import { buildPageMetadata } from "@/lib/seo";
import { buildPageMetadata, collectionPageSchema, baseUrl } from "@/lib/seo";
import JsonLd from "@/components/seo/JsonLd";
// ISR: revalidate every 60s — see /[locale]/page.tsx for the full rationale.
export const revalidate = 60;
@@ -49,8 +50,22 @@ export default async function NewsHub({ params }: { params: Promise<{ locale: st
const heroArticle = articles.length > 0 ? articles[0] : null;
const gridArticles = articles.length > 1 ? articles.slice(1) : [];
const collectionSchema = articles.length > 0
? collectionPageSchema({
name: `${t("title1")} ${t("title2")} — FLUX`,
description: t("description"),
url: `${baseUrl()}/${locale}/news`,
items: articles.map((a: any, idx: number) => ({
name: a.title,
url: `${baseUrl()}/${locale}/news/${a.slug}`,
position: idx + 1,
})),
})
: null;
return (
<main className="relative min-h-screen pt-32 pb-24">
{collectionSchema && <JsonLd data={collectionSchema} />}
<BreathingField />
<div className="relative z-10 max-w-7xl mx-auto px-6">
@@ -76,10 +91,11 @@ export default async function NewsHub({ params }: { params: Promise<{ locale: st
{/* HERO ARTICLE */}
{heroArticle && (
<article>
<Link href={`/${locale}/news/${heroArticle.slug}`} className="group relative w-full rounded-[2rem] overflow-hidden bg-white/60 dark:bg-[#1D1D1F]/40 backdrop-blur-2xl border border-black/5 dark:border-white/10 flex flex-col md:flex-row shadow-lg hover:shadow-xl transition-all duration-500 hover:-translate-y-1">
<div className="w-full md:w-1/2 h-56 md:h-[400px] relative overflow-hidden bg-[#1D1D1F]">
{heroArticle.coverImage ? (
<Image src={`/news/${heroArticle.slug}/${heroArticle.coverImage}`} alt={heroArticle.title} fill className="object-cover transition-transform duration-700 group-hover:scale-105" />
<Image src={`/news/${heroArticle.slug}/${heroArticle.coverImage}`} alt={heroArticle.title} fill sizes="(max-width: 768px) 100vw, 50vw" className="object-cover transition-transform duration-700 group-hover:scale-105" />
) : (
<div className="absolute inset-0 bg-gradient-to-br from-[#0066CC]/20 to-[#0A0A0C] flex items-center justify-center"><Newspaper size={64} className="text-white/10" /></div>
)}
@@ -87,7 +103,7 @@ export default async function NewsHub({ params }: { params: Promise<{ locale: st
<div className="w-full md:w-1/2 p-6 md:p-10 flex flex-col justify-center">
<div className="flex items-center gap-3 mb-4">
<span className="text-[9px] font-bold uppercase tracking-widest text-[#0066CC] dark:text-[#4DA6FF] bg-[#0066CC]/10 dark:bg-[#4DA6FF]/10 px-2.5 py-1 rounded-full">{heroArticle.category}</span>
<span className="text-[10px] font-medium text-[#86868B] flex items-center gap-1"><Calendar size={12}/> {new Date(heroArticle.publishedAt).toLocaleDateString(locale === 'en' ? 'en-US' : locale, { month: 'short', day: 'numeric', year: 'numeric' })}</span>
<time dateTime={new Date(heroArticle.publishedAt).toISOString()} className="text-[10px] font-medium text-[#86868B] flex items-center gap-1"><Calendar size={12}/> {new Date(heroArticle.publishedAt).toLocaleDateString(locale === 'en' ? 'en-US' : locale, { month: 'short', day: 'numeric', year: 'numeric' })}</time>
</div>
<h2 className="text-2xl md:text-3xl font-medium text-[#1D1D1F] dark:text-white mb-3 leading-tight group-hover:text-[#0066CC] dark:group-hover:text-[#4DA6FF] transition-colors">{heroArticle.title}</h2>
<p className="text-sm text-[#86868B] dark:text-[#A1A1A6] leading-relaxed mb-6 line-clamp-3">{heroArticle.excerpt}</p>
@@ -96,16 +112,18 @@ export default async function NewsHub({ params }: { params: Promise<{ locale: st
</span>
</div>
</Link>
</article>
)}
{/* GRID COLUMNAS */}
{gridArticles.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6 mt-4">
{gridArticles.map((article) => (
<Link key={article.id} href={`/${locale}/news/${article.slug}`} className="group flex flex-col bg-white/60 dark:bg-[#1D1D1F]/40 backdrop-blur-md border border-black/5 dark:border-white/10 rounded-3xl overflow-hidden shadow-sm hover:shadow-md transition-all duration-500 hover:-translate-y-1">
<article key={article.id} className="flex flex-col">
<Link href={`/${locale}/news/${article.slug}`} className="group flex flex-col flex-1 bg-white/60 dark:bg-[#1D1D1F]/40 backdrop-blur-md border border-black/5 dark:border-white/10 rounded-3xl overflow-hidden shadow-sm hover:shadow-md transition-all duration-500 hover:-translate-y-1">
<div className="w-full h-40 relative overflow-hidden bg-[#1D1D1F]">
{article.coverImage ? (
<Image src={`/news/${article.slug}/${article.coverImage}`} alt={article.title} fill className="object-cover transition-transform duration-700 group-hover:scale-105" />
<Image src={`/news/${article.slug}/${article.coverImage}`} alt={article.title} fill sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" className="object-cover transition-transform duration-700 group-hover:scale-105" />
) : (
<div className="absolute inset-0 bg-gradient-to-br from-[#0066CC]/10 to-[#0A0A0C]" />
)}
@@ -113,7 +131,7 @@ export default async function NewsHub({ params }: { params: Promise<{ locale: st
<div className="p-5 flex flex-col flex-1">
<div className="flex justify-between items-center mb-3">
<span className="text-[8px] font-bold uppercase tracking-widest text-[#0066CC] dark:text-[#4DA6FF]">{article.category}</span>
<span className="text-[9px] text-[#86868B]">{new Date(article.publishedAt).toLocaleDateString(locale === 'en' ? 'en-US' : locale, { month: 'short', day: 'numeric' })}</span>
<time dateTime={new Date(article.publishedAt).toISOString()} className="text-[9px] text-[#86868B]">{new Date(article.publishedAt).toLocaleDateString(locale === 'en' ? 'en-US' : locale, { month: 'short', day: 'numeric' })}</time>
</div>
<h3 className="text-base font-medium text-[#1D1D1F] dark:text-white mb-2 leading-snug group-hover:text-[#0066CC] dark:group-hover:text-[#4DA6FF] transition-colors line-clamp-2">{article.title}</h3>
<p className="text-xs text-[#86868B] dark:text-[#A1A1A6] line-clamp-2 mb-4">{article.excerpt}</p>
@@ -122,6 +140,7 @@ export default async function NewsHub({ params }: { params: Promise<{ locale: st
</span>
</div>
</Link>
</article>
))}
</div>
)}
+52
View File
@@ -0,0 +1,52 @@
// src/components/seo/Breadcrumbs.tsx
// ─────────────────────────────────────────────────────────────────────────────
// Visible breadcrumb navigation trail — complements the JSON-LD BreadcrumbList
// already rendered by individual pages.
//
// Design: Apple-clean, muted, small text — blends with the hero overlays
// on article and application detail pages.
// ─────────────────────────────────────────────────────────────────────────────
import Link from "next/link";
import { ChevronRight } from "lucide-react";
export interface BreadcrumbItem {
name: string;
url: string;
}
export default function Breadcrumbs({ items }: { items: BreadcrumbItem[] }) {
if (items.length < 2) return null;
return (
<nav aria-label="Breadcrumb" className="mb-4">
<ol className="flex items-center gap-1 flex-wrap text-xs">
{items.map((item, idx) => {
const isLast = idx === items.length - 1;
return (
<li key={item.url} className="flex items-center gap-1">
{idx > 0 && (
<ChevronRight size={11} className="text-[#86868B]/40 shrink-0" />
)}
{isLast ? (
<span
aria-current="page"
className="text-[#1D1D1F] dark:text-white/90 font-medium truncate max-w-[220px]"
>
{item.name}
</span>
) : (
<Link
href={item.url}
className="text-[#86868B] hover:text-[#0066CC] dark:hover:text-[#4DA6FF] transition-colors"
>
{item.name}
</Link>
)}
</li>
);
})}
</ol>
</nav>
);
}
+64
View File
@@ -114,6 +114,70 @@ export function organizationSchema(opts?: { logoUrl?: string; sameAs?: string[]
};
}
export function localBusinessSchema(opts?: { logoUrl?: string; sameAs?: string[] }) {
const base = baseUrl();
return {
"@context": "https://schema.org",
"@type": "LocalBusiness",
"@id": `${base}/#local-business`,
name: "FLUX Srl",
legalName: "FLUX Srl",
url: base,
logo: opts?.logoUrl ? absoluteUrl(opts.logoUrl) : `${base}/flux-logo.png`,
description:
"Manufacturer of solid-state Radio Frequency (RF), Microwave, and Infrared industrial equipment since 1978.",
foundingDate: "1978",
founder: { "@type": "Person", name: "Patrizio Grando" },
address: {
"@type": "PostalAddress",
streetAddress: "Via Benedetto Marcello 32",
addressLocality: "Romano d'Ezzelino",
addressRegion: "Vicenza",
postalCode: "36060",
addressCountry: "IT",
},
geo: {
"@type": "GeoCoordinates",
latitude: 45.7836,
longitude: 11.7677,
},
telephone: "+39 0424 287 492",
email: "info@rf-flux.com",
openingHoursSpecification: {
"@type": "OpeningHoursSpecification",
dayOfWeek: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
opens: "08:00",
closes: "17:00",
},
areaServed: { "@type": "Place", name: "Worldwide" },
...(opts?.sameAs ? { sameAs: opts.sameAs } : {}),
};
}
export function collectionPageSchema(opts: {
name: string;
description: string;
url: string;
items: { name: string; url: string; position: number }[];
}) {
return {
"@context": "https://schema.org",
"@type": "CollectionPage",
name: opts.name,
description: opts.description,
url: opts.url,
mainEntity: {
"@type": "ItemList",
itemListElement: opts.items.map((item) => ({
"@type": "ListItem",
position: item.position,
url: item.url,
name: item.name,
})),
},
};
}
export function websiteSchema() {
const base = baseUrl();
return {
+6 -2
View File
@@ -37,7 +37,9 @@ export async function proxy(request: NextRequest) {
if (isPublicHQRoute) {
return NextResponse.redirect(new URL('/hq-command/dashboard', request.url));
}
return NextResponse.next(); // Pasa al CMS tranquilamente
const authedRes = NextResponse.next();
authedRes.headers.set('X-Robots-Tag', 'noindex, nofollow');
return authedRes;
} catch (error) {
// Pase falso o expirado
if (!isPublicHQRoute) {
@@ -45,7 +47,9 @@ export async function proxy(request: NextRequest) {
}
}
}
return NextResponse.next(); // Pasa a login/setup si no hay cookie
const hqRes = NextResponse.next();
hqRes.headers.set('X-Robots-Tag', 'noindex, nofollow');
return hqRes;
}
// --------------------------------------------------------