Files
flux-srl/src/lib/seo.ts
T
davidherran 7ec99734c5 feat(seo): LocalBusiness + CollectionPage structured data schemas
- Add localBusinessSchema() with geo coords, phone, opening hours for
  Google Local Pack and Knowledge Panel visibility
- Add collectionPageSchema() with ItemList for article listing pages
- Inject LocalBusiness alongside Organization+WebSite in root layout
- Inject CollectionPage in /news hub page with article items

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-06 18:06:12 -05:00

266 lines
8.0 KiB
TypeScript

// 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 localBusinessSchema(opts?: { logoUrl?: string; sameAs?: string[] }) {
const base = baseUrl();
return {
"@context": "https://schema.org",
"@type": "LocalBusiness",
"@id": `${base}/#local-business`,
name: "FLUX Srl",
legalName: "FLUX Srl",
url: base,
logo: opts?.logoUrl ? absoluteUrl(opts.logoUrl) : `${base}/flux-logo.png`,
description:
"Manufacturer of solid-state Radio Frequency (RF), Microwave, and Infrared industrial equipment since 1978.",
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",
},
geo: {
"@type": "GeoCoordinates",
latitude: 45.7836,
longitude: 11.7677,
},
telephone: "+39 0424 287 492",
email: "info@rf-flux.com",
openingHoursSpecification: {
"@type": "OpeningHoursSpecification",
dayOfWeek: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
opens: "08:00",
closes: "17:00",
},
areaServed: { "@type": "Place", name: "Worldwide" },
...(opts?.sameAs ? { sameAs: opts.sameAs } : {}),
};
}
export function collectionPageSchema(opts: {
name: string;
description: string;
url: string;
items: { name: string; url: string; position: number }[];
}) {
return {
"@context": "https://schema.org",
"@type": "CollectionPage",
name: opts.name,
description: opts.description,
url: opts.url,
mainEntity: {
"@type": "ItemList",
itemListElement: opts.items.map((item) => ({
"@type": "ListItem",
position: item.position,
url: item.url,
name: item.name,
})),
},
};
}
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,
})),
};
}