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:
@@ -1,6 +1,7 @@
|
||||
// ISR: revalidates every 60s + on-demand via revalidatePath after CMS uploads.
|
||||
export const revalidate = 60;
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
@@ -8,8 +9,14 @@ import { prisma } from "@/lib/prisma";
|
||||
import { notFound } from "next/navigation";
|
||||
import ApplicationClient from "./ApplicationClient";
|
||||
|
||||
// 🔥 IMPORTAMOS LA TUBERÍA MÁGICA
|
||||
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 ---
|
||||
function getApplicationImages(slug: string) {
|
||||
@@ -29,6 +36,39 @@ function getApplicationImages(slug: string) {
|
||||
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
|
||||
export async function generateStaticParams() {
|
||||
// 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
|
||||
const images = getApplicationImages(slug);
|
||||
|
||||
// Pasamos TODO al componente cliente interactivo (que ya viene traducido)
|
||||
return <ApplicationClient data={data} realCases={realCases} images={images} />;
|
||||
const url = `${baseUrl()}/${locale}/applications/${slug}`;
|
||||
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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// ISR: revalidates every 60s + on-demand via revalidatePath after CMS uploads.
|
||||
export const revalidate = 60;
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
@@ -11,6 +12,22 @@ import AutoPlayVideo from "@/components/AutoPlayVideo";
|
||||
// 🔥 IMPORTACIONES DE IDIOMAS
|
||||
import { getLocalizedData } from "@/lib/i18nHelper";
|
||||
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) ──
|
||||
const renderMarkdown = (text: string) => {
|
||||
|
||||
@@ -12,7 +12,9 @@ import { NextIntlClientProvider } from 'next-intl';
|
||||
import { getMessages } from 'next-intl/server';
|
||||
import { notFound } from 'next/navigation';
|
||||
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"] });
|
||||
|
||||
@@ -69,7 +71,13 @@ export default async function RootLayout({
|
||||
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 (
|
||||
<html lang={locale} className="scroll-smooth bg-[#F5F5F7]">
|
||||
@@ -82,14 +90,24 @@ export default async function RootLayout({
|
||||
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}>
|
||||
|
||||
<NavBar />
|
||||
|
||||
{/* 🔥 Panel del Carrito de Repuestos y Soporte Técnico 🔥 */}
|
||||
<CartDrawer />
|
||||
|
||||
{/* Inyectamos el manejador de transiciones aquí */}
|
||||
<NavigationManager />
|
||||
|
||||
<div className="flex-grow w-full flex flex-col relative">
|
||||
|
||||
@@ -1,14 +1,51 @@
|
||||
// ISR: revalidates every 60s + on-demand via revalidatePath after CMS uploads.
|
||||
export const revalidate = 60;
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ArrowLeft, Calendar, Tag, Linkedin } from "lucide-react";
|
||||
import BreathingField from "@/components/visuals/BreathingField";
|
||||
|
||||
// 🔥 IMPORTACIÓN DEL MOTOR DE TRADUCCIÓN 🔥
|
||||
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() {
|
||||
// In production Docker build, DB is not available.
|
||||
@@ -210,8 +247,27 @@ export default async function ArticlePage({ params }: { params: Promise<{ slug:
|
||||
let gallery: string[] = [];
|
||||
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 (
|
||||
<main className="relative min-h-screen pb-24">
|
||||
<JsonLd data={jsonLd} />
|
||||
<BreathingField />
|
||||
|
||||
<div className="fixed top-24 left-6 z-50 hidden md:block">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
@@ -6,10 +7,26 @@ import BreathingField from "@/components/visuals/BreathingField";
|
||||
|
||||
import { getLocalizedData } from "@/lib/i18nHelper";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { buildPageMetadata } from "@/lib/seo";
|
||||
|
||||
// ISR: revalidates every 60s + on-demand via revalidatePath after CMS uploads.
|
||||
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 }> }) {
|
||||
const resolvedParams = await params;
|
||||
const locale = resolvedParams.locale;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 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 path from "path";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
@@ -15,10 +15,46 @@ import ApplicationsDeep from "@/components/sections/ApplicationsDeep";
|
||||
import HeroReel from "@/components/sections/HeroReel";
|
||||
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
|
||||
// revalidatePath() after CMS uploads, plus a 60s safety window.
|
||||
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
|
||||
export default async function Home({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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