seo: dynamic sitemap + robots + per-page metadata + JSON-LD

Brings the site up to enterprise SEO standards. Google now gets a complete
machine-readable map of the content, with multilingual hreflang tags,
structured data for the knowledge panel, and rich Open Graph cards on
LinkedIn / WhatsApp / Twitter.

NEW
- src/app/sitemap.ts: dynamic sitemap.xml from Prisma. Emits 5 locales x
  every active application + every active news article, with hreflang
  alternates linking each translation. Hourly revalidation.
- src/app/robots.ts: robots.txt blocks /hq-command/, /api/, /parts (B2B
  auth-gated), points crawlers at the sitemap.
- src/lib/seo.ts: helpers for canonical URLs, hreflang alternates, and
  JSON-LD schemas (Organization, WebSite, Article, Product, BreadcrumbList).
- src/components/seo/JsonLd.tsx: server component that emits one
  application/ld+json script tag per page.

PER-PAGE generateMetadata
- Home: localized titles + descriptions in EN/IT/VEC/ES/DE
- News hub: title built from translations, hreflang tags
- News article: title/description from DB, OG image = cover, type=article,
  publishedTime + modifiedTime for date freshness signals
- Applications: title/description from DB, type=product, hero image
- Heritage: localized title/description

JSON-LD STRUCTURED DATA
- Site-wide (in root layout): Organization (with HQ address, founder,
  contact, social profiles) + WebSite — drives Google knowledge panel
- Article pages: Article schema with publisher/datePublished/dateModified
  — required for Google News / Discover eligibility
- Application pages: Product schema (FLUX brand, RF Industrial category)
  + BreadcrumbList — drives rich-snippet breadcrumb in search results

NOTES
- Open Graph metadataBase set from NEXT_PUBLIC_APP_URL so absolute URLs
  for OG images are correct (LinkedIn previews require absolute paths)
- All pages have canonical URLs to prevent duplicate-content penalties
- /parts already has noindex meta (B2B portal) — also blocked in robots
- No DB schema changes. Pure additions to /src/lib and /src/app.
This commit is contained in:
2026-05-04 14:42:43 -05:00
parent f931ae281c
commit 09e6d0c7cf
10 changed files with 574 additions and 13 deletions
+63 -3
View File
@@ -1,6 +1,7 @@
// ISR: revalidates every 60s + on-demand via revalidatePath after CMS uploads.
export const revalidate = 60;
import type { Metadata } from "next";
import Link from "next/link";
import fs from "fs";
import path from "path";
@@ -8,8 +9,14 @@ import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import ApplicationClient from "./ApplicationClient";
// 🔥 IMPORTAMOS LA TUBERÍA MÁGICA
import { getLocalizedData } from "@/lib/i18nHelper";
import {
buildPageMetadata,
productSchema,
breadcrumbSchema,
baseUrl,
} from "@/lib/seo";
import JsonLd from "@/components/seo/JsonLd";
// --- FUNCIÓN ORIGINAL PARA LEER IMÁGENES LOCALES ---
function getApplicationImages(slug: string) {
@@ -29,6 +36,39 @@ function getApplicationImages(slug: string) {
return { heroImage, blueprints, machines };
}
// ── Per-page metadata (Open Graph, Twitter, hreflang, canonical) ───────────
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string; locale: string }>;
}): Promise<Metadata> {
const { slug, locale } = await params;
try {
const raw = await prisma.application.findUnique({ where: { slug } });
if (!raw) {
return {
title: "Application not found | FLUX",
robots: { index: false, follow: false },
};
}
const data = getLocalizedData(raw, locale);
const heroImage = getApplicationImages(slug).heroImage;
return buildPageMetadata({
locale,
pathWithoutLocale: `applications/${slug}`,
title: `${data.title} — RF Industrial Solutions | FLUX`,
description: data.shortDescription || data.subtitle,
ogImageUrl: heroImage || undefined,
type: "product",
});
} catch {
return { title: "FLUX | Energy, Directed." };
}
}
// GENERACIÓN DE RUTAS ESTÁTICAS DESDE LA BD
export async function generateStaticParams() {
// In production Docker build, DB is not available.
@@ -85,6 +125,26 @@ export default async function ApplicationPage({ params }: { params: Promise<{ sl
// 3. Leemos las imágenes de la carpeta original
const images = getApplicationImages(slug);
// Pasamos TODO al componente cliente interactivo (que ya viene traducido)
return <ApplicationClient data={data} realCases={realCases} images={images} />;
const url = `${baseUrl()}/${locale}/applications/${slug}`;
const jsonLd = [
productSchema({
name: data.title,
description: data.shortDescription || data.subtitle,
imageUrl: images.heroImage || undefined,
category: data.category,
url,
}),
breadcrumbSchema([
{ name: "Home", url: `${baseUrl()}/${locale}` },
{ name: "Applications", url: `${baseUrl()}/${locale}#applications-deep` },
{ name: data.title, url },
]),
];
return (
<>
<JsonLd data={jsonLd} />
<ApplicationClient data={data} realCases={realCases} images={images} />
</>
);
}