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>
This commit is contained in:
@@ -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 { 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 BreathingField from "@/components/visuals/BreathingField";
|
||||||
import AutoPlayVideo from "@/components/AutoPlayVideo";
|
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 🔥
|
// 🔥 EL TRUCO DEFINITIVO PARA TYPESCRIPT Y WEB COMPONENTS 🔥
|
||||||
// Al asignar el string a una variable con 'as any', TypeScript deja de
|
// Al asignar el string a una variable con 'as any', TypeScript deja de
|
||||||
@@ -999,7 +1001,7 @@ function ExpandedCaseStudy({ node }: { node: any }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- COMPONENTE PRINCIPAL ---
|
// --- 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 [expandedCase, setExpandedCase] = useState<string | null>(null);
|
||||||
|
|
||||||
const [mainLightboxOpen, setMainLightboxOpen] = useState(false);
|
const [mainLightboxOpen, setMainLightboxOpen] = useState(false);
|
||||||
@@ -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">
|
<div className="relative z-10 max-w-4xl mx-auto px-6 pb-12 md:pb-16 w-full">
|
||||||
<header>
|
<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">
|
<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} />
|
<LayoutDashboard size={14} />
|
||||||
<span className="text-[10px] md:text-xs font-semibold uppercase tracking-widest">{data.category}</span>
|
<span className="text-[10px] md:text-xs font-semibold uppercase tracking-widest">{data.category}</span>
|
||||||
|
|||||||
@@ -158,24 +158,28 @@ export default async function ApplicationPage({ params }: { params: Promise<{ sl
|
|||||||
const images = getApplicationImages(slug);
|
const images = getApplicationImages(slug);
|
||||||
|
|
||||||
// 4. JSON-LD structured data — wrapped to never break the render.
|
// 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[] = [];
|
let jsonLd: object[] = [];
|
||||||
try {
|
try {
|
||||||
const url = `${baseUrl()}/${locale}/applications/${slug}`;
|
|
||||||
const title = data?.title || "FLUX Application";
|
|
||||||
const description = data?.shortDescription || data?.subtitle || "";
|
const description = data?.shortDescription || data?.subtitle || "";
|
||||||
jsonLd = [
|
jsonLd = [
|
||||||
productSchema({
|
productSchema({
|
||||||
name: title,
|
name: appTitle,
|
||||||
description,
|
description,
|
||||||
imageUrl: images.heroImage || undefined,
|
imageUrl: images.heroImage || undefined,
|
||||||
category: data?.category || "RF Industrial",
|
category: data?.category || "RF Industrial",
|
||||||
url,
|
url: appUrl,
|
||||||
}),
|
}),
|
||||||
breadcrumbSchema([
|
breadcrumbSchema(
|
||||||
{ name: "Home", url: `${baseUrl()}/${locale}` },
|
crumbs.map((c) => ({ name: c.name, url: `${baseUrl()}${c.url}` }))
|
||||||
{ name: "Applications", url: `${baseUrl()}/${locale}#applications-deep` },
|
),
|
||||||
{ name: title, url },
|
|
||||||
]),
|
|
||||||
];
|
];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[applications/${slug}] JSON-LD build failed:`, error);
|
console.error(`[applications/${slug}] JSON-LD build failed:`, error);
|
||||||
@@ -184,7 +188,7 @@ export default async function ApplicationPage({ params }: { params: Promise<{ sl
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{jsonLd.length > 0 && <JsonLd data={jsonLd} />}
|
{jsonLd.length > 0 && <JsonLd data={jsonLd} />}
|
||||||
<ApplicationClient data={data} realCases={realCases} images={images} />
|
<ApplicationClient data={data} realCases={realCases} images={images} breadcrumbs={crumbs} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import BreathingField from "@/components/visuals/BreathingField";
|
|||||||
|
|
||||||
import { setRequestLocale } from "next-intl/server";
|
import { setRequestLocale } from "next-intl/server";
|
||||||
import { getLocalizedData } from "@/lib/i18nHelper";
|
import { getLocalizedData } from "@/lib/i18nHelper";
|
||||||
|
import Breadcrumbs from "@/components/seo/Breadcrumbs";
|
||||||
import {
|
import {
|
||||||
buildPageMetadata,
|
buildPageMetadata,
|
||||||
articleSchema,
|
articleSchema,
|
||||||
@@ -259,6 +260,12 @@ export default async function ArticlePage({ params }: { params: Promise<{ slug:
|
|||||||
const articleUrl = `${baseUrl()}/${locale}/news/${slug}`;
|
const articleUrl = `${baseUrl()}/${locale}/news/${slug}`;
|
||||||
const cover = article.coverImage ? `/news/${slug}/${article.coverImage}` : undefined;
|
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[] = [];
|
let jsonLd: object[] = [];
|
||||||
try {
|
try {
|
||||||
jsonLd = [
|
jsonLd = [
|
||||||
@@ -270,11 +277,9 @@ export default async function ArticlePage({ params }: { params: Promise<{ slug:
|
|||||||
publishedAt: article?.publishedAt || new Date(),
|
publishedAt: article?.publishedAt || new Date(),
|
||||||
updatedAt: article?.updatedAt || new Date(),
|
updatedAt: article?.updatedAt || new Date(),
|
||||||
}),
|
}),
|
||||||
breadcrumbSchema([
|
breadcrumbSchema(
|
||||||
{ name: "Home", url: `${baseUrl()}/${locale}` },
|
crumbs.map((c) => ({ name: c.name, url: `${baseUrl()}${c.url}` }))
|
||||||
{ name: "News", url: `${baseUrl()}/${locale}/news` },
|
),
|
||||||
{ name: article?.title || "Article", url: articleUrl },
|
|
||||||
]),
|
|
||||||
];
|
];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[news/${slug}] JSON-LD build failed:`, error);
|
console.error(`[news/${slug}] JSON-LD build failed:`, error);
|
||||||
@@ -299,6 +304,9 @@ export default async function ArticlePage({ params }: { params: Promise<{ slug:
|
|||||||
<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="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="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">
|
<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 bg-[#0066CC]/10 dark:bg-[#4DA6FF]/10 px-3 py-1.5 rounded-full"><Tag size={14}/> {article.category}</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>
|
<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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user