7ec99734c5
- 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>
266 lines
8.0 KiB
TypeScript
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,
|
|
})),
|
|
};
|
|
}
|