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:
+201
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user