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. // ISR: revalidates every 60s + on-demand via revalidatePath after CMS uploads.
export const revalidate = 60; export const revalidate = 60;
import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
@@ -8,8 +9,14 @@ import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import ApplicationClient from "./ApplicationClient"; import ApplicationClient from "./ApplicationClient";
// 🔥 IMPORTAMOS LA TUBERÍA MÁGICA
import { getLocalizedData } from "@/lib/i18nHelper"; 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 --- // --- FUNCIÓN ORIGINAL PARA LEER IMÁGENES LOCALES ---
function getApplicationImages(slug: string) { function getApplicationImages(slug: string) {
@@ -29,6 +36,39 @@ function getApplicationImages(slug: string) {
return { heroImage, blueprints, machines }; 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 // GENERACIÓN DE RUTAS ESTÁTICAS DESDE LA BD
export async function generateStaticParams() { export async function generateStaticParams() {
// In production Docker build, DB is not available. // 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 // 3. Leemos las imágenes de la carpeta original
const images = getApplicationImages(slug); const images = getApplicationImages(slug);
// Pasamos TODO al componente cliente interactivo (que ya viene traducido) const url = `${baseUrl()}/${locale}/applications/${slug}`;
return <ApplicationClient data={data} realCases={realCases} images={images} />; 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} />
</>
);
} }
+17
View File
@@ -1,6 +1,7 @@
// ISR: revalidates every 60s + on-demand via revalidatePath after CMS uploads. // ISR: revalidates every 60s + on-demand via revalidatePath after CMS uploads.
export const revalidate = 60; export const revalidate = 60;
import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -11,6 +12,22 @@ import AutoPlayVideo from "@/components/AutoPlayVideo";
// 🔥 IMPORTACIONES DE IDIOMAS // 🔥 IMPORTACIONES DE IDIOMAS
import { getLocalizedData } from "@/lib/i18nHelper"; import { getLocalizedData } from "@/lib/i18nHelper";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { buildPageMetadata } from "@/lib/seo";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "HeritagePage" });
return buildPageMetadata({
locale,
pathWithoutLocale: "heritage",
title: `${t("subtitle")}${t("title1").trim()} ${t("title2").trim()} | FLUX`,
description: `${t("title1")} ${t("title2")} — Discover Patrizio Grando's 40-year legacy in Solid-State RF technology.`,
});
}
// ── SÚPER PARSER MARKDOWN (Con Tablas, Imágenes y Dark Mode puro) ── // ── SÚPER PARSER MARKDOWN (Con Tablas, Imágenes y Dark Mode puro) ──
const renderMarkdown = (text: string) => { const renderMarkdown = (text: string) => {
+22 -4
View File
@@ -12,7 +12,9 @@ import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server'; import { getMessages } from 'next-intl/server';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { routing } from '@/i18n/routing'; import { routing } from '@/i18n/routing';
import { getBranding } from '@/lib/siteSettings'; import { getBranding, getSocialLinks } from '@/lib/siteSettings';
import { organizationSchema, websiteSchema } from '@/lib/seo';
import JsonLd from '@/components/seo/JsonLd';
const inter = Inter({ subsets: ["latin"] }); const inter = Inter({ subsets: ["latin"] });
@@ -69,7 +71,13 @@ export default async function RootLayout({
notFound(); notFound();
} }
const messages = await getMessages(); const [messages, branding, social] = await Promise.all([
getMessages(),
getBranding(),
getSocialLinks(),
]);
const sameAs = [social.linkedin, social.instagram, social.youtube].filter(Boolean);
return ( return (
<html lang={locale} className="scroll-smooth bg-[#F5F5F7]"> <html lang={locale} className="scroll-smooth bg-[#F5F5F7]">
@@ -82,14 +90,24 @@ export default async function RootLayout({
position: "relative", position: "relative",
}} }}
> >
{/* Site-wide JSON-LD: Organization + WebSite — picked up by Google
knowledge panel and rich snippets. */}
<JsonLd
data={[
organizationSchema({
logoUrl: branding.logoUrl,
sameAs: sameAs.length ? sameAs : undefined,
}),
websiteSchema(),
]}
/>
<NextIntlClientProvider messages={messages}> <NextIntlClientProvider messages={messages}>
<NavBar /> <NavBar />
{/* 🔥 Panel del Carrito de Repuestos y Soporte Técnico 🔥 */}
<CartDrawer /> <CartDrawer />
{/* Inyectamos el manejador de transiciones aquí */}
<NavigationManager /> <NavigationManager />
<div className="flex-grow w-full flex flex-col relative"> <div className="flex-grow w-full flex flex-col relative">
+57 -1
View File
@@ -1,14 +1,51 @@
// ISR: revalidates every 60s + on-demand via revalidatePath after CMS uploads. // ISR: revalidates every 60s + on-demand via revalidatePath after CMS uploads.
export const revalidate = 60; export const revalidate = 60;
import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { ArrowLeft, Calendar, Tag, Linkedin } from "lucide-react"; import { ArrowLeft, Calendar, Tag, Linkedin } from "lucide-react";
import BreathingField from "@/components/visuals/BreathingField"; import BreathingField from "@/components/visuals/BreathingField";
// 🔥 IMPORTACIÓN DEL MOTOR DE TRADUCCIÓN 🔥
import { getLocalizedData } from "@/lib/i18nHelper"; import { getLocalizedData } from "@/lib/i18nHelper";
import {
buildPageMetadata,
articleSchema,
breadcrumbSchema,
baseUrl,
} from "@/lib/seo";
import JsonLd from "@/components/seo/JsonLd";
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string; locale: string }>;
}): Promise<Metadata> {
const { slug, locale } = await params;
try {
const raw = await prisma.newsArticle.findUnique({ where: { slug } });
if (!raw || !raw.isActive) {
return { title: "Article not found | FLUX", robots: { index: false, follow: false } };
}
const article = getLocalizedData(raw, locale);
const cover = article.coverImage ? `/news/${slug}/${article.coverImage}` : undefined;
return buildPageMetadata({
locale,
pathWithoutLocale: `news/${slug}`,
title: `${article.title} | FLUX Inside`,
description: article.excerpt,
ogImageUrl: cover,
type: "article",
publishedAt: article.publishedAt,
updatedAt: article.updatedAt,
});
} catch {
return { title: "FLUX | Energy, Directed." };
}
}
export async function generateStaticParams() { export async function generateStaticParams() {
// In production Docker build, DB is not available. // In production Docker build, DB is not available.
@@ -210,8 +247,27 @@ export default async function ArticlePage({ params }: { params: Promise<{ slug:
let gallery: string[] = []; let gallery: string[] = [];
try { gallery = JSON.parse(article.galleryJson || "[]"); } catch (e) {} try { gallery = JSON.parse(article.galleryJson || "[]"); } catch (e) {}
const articleUrl = `${baseUrl()}/${locale}/news/${slug}`;
const cover = article.coverImage ? `/news/${slug}/${article.coverImage}` : undefined;
const jsonLd = [
articleSchema({
headline: article.title,
description: article.excerpt,
imageUrl: cover,
url: articleUrl,
publishedAt: article.publishedAt,
updatedAt: article.updatedAt,
}),
breadcrumbSchema([
{ name: "Home", url: `${baseUrl()}/${locale}` },
{ name: "News", url: `${baseUrl()}/${locale}/news` },
{ name: article.title, url: articleUrl },
]),
];
return ( return (
<main className="relative min-h-screen pb-24"> <main className="relative min-h-screen pb-24">
<JsonLd data={jsonLd} />
<BreathingField /> <BreathingField />
<div className="fixed top-24 left-6 z-50 hidden md:block"> <div className="fixed top-24 left-6 z-50 hidden md:block">
+17
View File
@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -6,10 +7,26 @@ import BreathingField from "@/components/visuals/BreathingField";
import { getLocalizedData } from "@/lib/i18nHelper"; import { getLocalizedData } from "@/lib/i18nHelper";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { buildPageMetadata } from "@/lib/seo";
// ISR: revalidates every 60s + on-demand via revalidatePath after CMS uploads. // ISR: revalidates every 60s + on-demand via revalidatePath after CMS uploads.
export const revalidate = 60; export const revalidate = 60;
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "NewsHub" });
return buildPageMetadata({
locale,
pathWithoutLocale: "news",
title: `${t("subtitle")}${t("title1").trim()} ${t("title2").trim()} | FLUX`,
description: t("description"),
});
}
export default async function NewsHub({ params }: { params: Promise<{ locale: string }> }) { export default async function NewsHub({ params }: { params: Promise<{ locale: string }> }) {
const resolvedParams = await params; const resolvedParams = await params;
const locale = resolvedParams.locale; const locale = resolvedParams.locale;
+37 -1
View File
@@ -1,6 +1,6 @@
// src/app/[locale]/page.tsx // src/app/[locale]/page.tsx
// ✅ CORRECCIÓN: dynamic ya estaba, pero reforzamos el patrón de params
import type { Metadata } from "next";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -15,10 +15,46 @@ import ApplicationsDeep from "@/components/sections/ApplicationsDeep";
import HeroReel from "@/components/sections/HeroReel"; import HeroReel from "@/components/sections/HeroReel";
import WhatWeDo from "@/components/sections/WhatWeDo"; import WhatWeDo from "@/components/sections/WhatWeDo";
import { buildPageMetadata } from "@/lib/seo";
import { getBranding } from "@/lib/siteSettings";
// ISR: page is statically generated, but revalidates on demand via // ISR: page is statically generated, but revalidates on demand via
// revalidatePath() after CMS uploads, plus a 60s safety window. // revalidatePath() after CMS uploads, plus a 60s safety window.
export const revalidate = 60; export const revalidate = 60;
const TITLES: Record<string, string> = {
en: "FLUX | Solid-State RF Industrial Solutions",
it: "FLUX | Soluzioni Industriali Solid-State RF",
vec: "FLUX | Solusion Industriali Solid-State RF",
es: "FLUX | Soluciones Industriales Solid-State RF",
de: "FLUX | Solid-State RF Industrielle Lösungen",
};
const DESCRIPTIONS: Record<string, string> = {
en: "World-leading Solid-State RF, Microwave and Infrared industrial equipment. Drying, vulcanization, defrosting and more — 95% efficiency, 40+ years of legacy by Patrizio Grando.",
it: "Leader mondiale in apparecchiature industriali Solid-State RF, Microwave e Infrarossi. Essiccazione, vulcanizzazione, scongelamento — 95% di efficienza, 40+ anni di eredità di Patrizio Grando.",
vec: "Lìder nel mondo par machinari industriali Solid-State RF, Microwave e Infrarossi. Sugar, vulcanizar, descongelar — 95% de eficiensa, 40+ ani de eredità de Patrizio Grando.",
es: "Líder mundial en equipos industriales Solid-State RF, Microondas e Infrarrojos. Secado, vulcanización, descongelación — 95% de eficiencia, 40+ años de legado de Patrizio Grando.",
de: "Weltweit führend bei industriellen Solid-State RF-, Mikrowellen- und Infrarot-Anlagen. Trocknung, Vulkanisation, Auftauen — 95% Effizienz, 40+ Jahre Erbe von Patrizio Grando.",
};
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const branding = await getBranding();
return buildPageMetadata({
locale,
pathWithoutLocale: "",
title: TITLES[locale] || TITLES.en,
description: DESCRIPTIONS[locale] || DESCRIPTIONS.en,
ogImageUrl: branding.ogImageUrl,
type: "website",
});
}
// ✅ Next.js 16: params es Promise y DEBE ser awaiteado // ✅ Next.js 16: params es Promise y DEBE ser awaiteado
export default async function Home({ params }: { params: Promise<{ locale: string }> }) { export default async function Home({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params; const { locale } = await params;
+30
View File
@@ -0,0 +1,30 @@
// src/app/robots.ts
// ─────────────────────────────────────────────────────────────────────────────
// robots.txt — tells search crawlers what to index and where the sitemap lives.
// Auto-served at /robots.txt, no Nginx config needed.
// ─────────────────────────────────────────────────────────────────────────────
import type { MetadataRoute } from "next";
function baseUrl() {
return (process.env.NEXT_PUBLIC_APP_URL || "https://rf-flux.com").replace(/\/$/, "");
}
export default function robots(): MetadataRoute.Robots {
const base = baseUrl();
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: [
"/hq-command/", // Admin CMS — never index
"/api/", // Server endpoints — never index
"/parts", // B2B portal, auth-gated (also has noindex meta)
],
},
],
sitemap: `${base}/sitemap.xml`,
host: base,
};
}
+104
View File
@@ -0,0 +1,104 @@
// src/app/sitemap.ts
// ─────────────────────────────────────────────────────────────────────────────
// Dynamic sitemap generated from Prisma data — emits one entry per locale per
// active page (home, applications, news articles, heritage, news hub).
//
// Auto-discoverable at /sitemap.xml, no Nginx config needed.
// Search engines re-crawl this on each visit; Next.js caches it for `revalidate`.
// ─────────────────────────────────────────────────────────────────────────────
import type { MetadataRoute } from "next";
import { prisma } from "@/lib/prisma";
const LOCALES = ["en", "it", "vec", "es", "de"] as const;
function baseUrl() {
return (process.env.NEXT_PUBLIC_APP_URL || "https://rf-flux.com").replace(/\/$/, "");
}
export const revalidate = 3600; // Re-generate sitemap once per hour
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const base = baseUrl();
const now = new Date();
const entries: MetadataRoute.Sitemap = [];
// ── Static routes ─────────────────────────────────────────────
const staticPaths = [
{ path: "", priority: 1.0, changeFrequency: "weekly" as const },
{ path: "/news", priority: 0.7, changeFrequency: "daily" as const },
{ path: "/heritage", priority: 0.6, changeFrequency: "monthly" as const },
];
for (const locale of LOCALES) {
for (const { path, priority, changeFrequency } of staticPaths) {
entries.push({
url: `${base}/${locale}${path}`,
lastModified: now,
changeFrequency,
priority,
alternates: {
languages: Object.fromEntries(
LOCALES.map((alt) => [alt, `${base}/${alt}${path}`])
),
},
});
}
}
// ── Application pages ─────────────────────────────────────────
try {
const applications = await prisma.application.findMany({
where: { isActive: true },
select: { slug: true, updatedAt: true },
});
for (const app of applications) {
for (const locale of LOCALES) {
entries.push({
url: `${base}/${locale}/applications/${app.slug}`,
lastModified: app.updatedAt,
changeFrequency: "weekly",
priority: 0.9,
alternates: {
languages: Object.fromEntries(
LOCALES.map((alt) => [alt, `${base}/${alt}/applications/${app.slug}`])
),
},
});
}
}
} catch (error) {
console.error("[sitemap] Failed to load applications:", error);
}
// ── News articles ─────────────────────────────────────────────
try {
const articles = await prisma.newsArticle.findMany({
where: { isActive: true },
select: { slug: true, updatedAt: true, publishedAt: true },
orderBy: { publishedAt: "desc" },
});
for (const article of articles) {
for (const locale of LOCALES) {
entries.push({
url: `${base}/${locale}/news/${article.slug}`,
lastModified: article.updatedAt,
changeFrequency: "monthly",
priority: 0.7,
alternates: {
languages: Object.fromEntries(
LOCALES.map((alt) => [alt, `${base}/${alt}/news/${article.slug}`])
),
},
});
}
}
} catch (error) {
console.error("[sitemap] Failed to load news articles:", error);
}
return entries;
}
+22
View File
@@ -0,0 +1,22 @@
// src/components/seo/JsonLd.tsx
// ─────────────────────────────────────────────────────────────────────────────
// Server component that emits a single JSON-LD <script> tag.
// Pass either one schema or an array — they're merged into one script.
// ─────────────────────────────────────────────────────────────────────────────
interface JsonLdProps {
data: object | object[];
}
export default function JsonLd({ data }: JsonLdProps) {
const payload = Array.isArray(data) ? data : [data];
return (
<script
type="application/ld+json"
// We trust our own server-side payload — no user content reaches here.
dangerouslySetInnerHTML={{
__html: JSON.stringify(payload.length === 1 ? payload[0] : payload),
}}
/>
);
}
+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,
})),
};
}