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
+201
View File
@@ -0,0 +1,201 @@
// src/lib/seo.ts
// ─────────────────────────────────────────────────────────────────────────────
// SEO helpers — base URL, alternate-language tags, JSON-LD structured data
// schemas (Organization, Article, Product, BreadcrumbList).
//
// Used by per-page generateMetadata functions and rendered as <script
// type="application/ld+json"> tags so Google can parse the structured data.
// ─────────────────────────────────────────────────────────────────────────────
import type { Metadata } from "next";
export const LOCALES = ["en", "it", "vec", "es", "de"] as const;
export type Locale = (typeof LOCALES)[number];
export function baseUrl() {
return (process.env.NEXT_PUBLIC_APP_URL || "https://rf-flux.com").replace(/\/$/, "");
}
export function absoluteUrl(path: string) {
if (path.startsWith("http")) return path;
return `${baseUrl()}${path.startsWith("/") ? "" : "/"}${path}`;
}
// ── Build hreflang alternates for a given path (without locale prefix) ──────
export function alternateLanguages(pathWithoutLocale: string) {
const base = baseUrl();
const clean = pathWithoutLocale.replace(/^\/+/, "");
return Object.fromEntries(
LOCALES.map((locale) => [locale, `${base}/${locale}${clean ? `/${clean}` : ""}`])
);
}
export interface PageSeoInput {
locale: Locale | string;
pathWithoutLocale: string; // e.g. "" for home, "applications/textile-drying" for an app
title: string;
description: string;
ogImageUrl?: string;
type?: "website" | "article" | "product";
publishedAt?: Date | string | null;
updatedAt?: Date | string | null;
}
export function buildPageMetadata(input: PageSeoInput): Metadata {
const base = baseUrl();
const path = input.pathWithoutLocale.replace(/^\/+/, "");
const canonical = `${base}/${input.locale}${path ? `/${path}` : ""}`;
return {
title: input.title,
description: input.description,
metadataBase: new URL(base),
alternates: {
canonical,
languages: alternateLanguages(input.pathWithoutLocale),
},
openGraph: {
title: input.title,
description: input.description,
url: canonical,
siteName: "FLUX Srl",
locale: String(input.locale),
type: input.type === "article" ? "article" : "website",
images: input.ogImageUrl ? [{ url: absoluteUrl(input.ogImageUrl) }] : undefined,
...(input.publishedAt
? { publishedTime: new Date(input.publishedAt).toISOString() }
: {}),
...(input.updatedAt
? { modifiedTime: new Date(input.updatedAt).toISOString() }
: {}),
},
twitter: {
card: "summary_large_image",
title: input.title,
description: input.description,
images: input.ogImageUrl ? [absoluteUrl(input.ogImageUrl)] : undefined,
},
};
}
// ── JSON-LD schemas ─────────────────────────────────────────────────────────
export function organizationSchema(opts?: { logoUrl?: string; sameAs?: string[] }) {
const base = baseUrl();
return {
"@context": "https://schema.org",
"@type": "Organization",
name: "FLUX Srl",
legalName: "FLUX Srl",
url: base,
logo: opts?.logoUrl ? absoluteUrl(opts.logoUrl) : `${base}/flux-logo.png`,
description:
"Leading manufacturer of solid-state Radio Frequency (RF), Microwave, and Infrared industrial equipment. Founded by Patrizio Grando — 40+ years of legacy.",
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",
},
contactPoint: {
"@type": "ContactPoint",
contactType: "Sales",
email: "info@rf-flux.com",
availableLanguage: ["English", "Italian", "German", "Spanish"],
},
...(opts?.sameAs ? { sameAs: opts.sameAs } : {}),
};
}
export function websiteSchema() {
const base = baseUrl();
return {
"@context": "https://schema.org",
"@type": "WebSite",
name: "FLUX Srl",
url: base,
inLanguage: ["en", "it", "vec", "es", "de"],
};
}
export function articleSchema(opts: {
headline: string;
description: string;
imageUrl?: string;
url: string;
publishedAt: Date | string;
updatedAt: Date | string;
author?: string;
}) {
return {
"@context": "https://schema.org",
"@type": "Article",
headline: opts.headline,
description: opts.description,
image: opts.imageUrl ? absoluteUrl(opts.imageUrl) : undefined,
datePublished: new Date(opts.publishedAt).toISOString(),
dateModified: new Date(opts.updatedAt).toISOString(),
author: {
"@type": "Organization",
name: opts.author || "FLUX Srl",
},
publisher: {
"@type": "Organization",
name: "FLUX Srl",
logo: {
"@type": "ImageObject",
url: `${baseUrl()}/flux-logo.png`,
},
},
mainEntityOfPage: {
"@type": "WebPage",
"@id": opts.url,
},
};
}
export function productSchema(opts: {
name: string;
description: string;
imageUrl?: string;
category: string;
url: string;
}) {
return {
"@context": "https://schema.org",
"@type": "Product",
name: opts.name,
description: opts.description,
image: opts.imageUrl ? absoluteUrl(opts.imageUrl) : undefined,
category: opts.category,
brand: {
"@type": "Brand",
name: "FLUX",
},
manufacturer: {
"@type": "Organization",
name: "FLUX Srl",
},
url: opts.url,
};
}
export function breadcrumbSchema(items: { name: string; url: string }[]) {
return {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: items.map((item, idx) => ({
"@type": "ListItem",
position: idx + 1,
name: item.name,
item: item.url,
})),
};
}