Files
PleinR/src/lib/seo.ts
T
Claude a52e13e623 Promotions : page publique dédiée, l'accueil garde les six dernières
`/promotions` liste toutes les offres en cours ; l'accueil en montre six et
renvoie vers elle. Les deux listes partagent `PromoCard`, extrait de la page
d'accueil : un seul rendu de carte à maintenir.

- `getLivePromotions(limit)` accepte `null` pour « toutes les offres », la
  limite restant appliquée en SQL et non après coup.
- Page complète : `<h1>` unique, `<main>`, métadonnées et canonique via
  `pageMetadata`, JSON-LD `BreadcrumbList` + `ItemList`, entrée au sitemap,
  état vide qui renvoie vers l'annuaire.
- Les liens « Promotions » de l'en-tête, du pied de page et de l'espace
  adhérent visent désormais cette page. Idem pour le lien d'une publication
  réseau sans adhérent rattaché, qui pointait sur l'ancre `/#promotions`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
2026-09-06 14:34:27 +00:00

417 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Metadata } from "next";
import type { DaySchedule, WeekDayKey } from "@/lib/member-profile";
import type { SiteSettings, SocialLink } from "@/lib/site-settings";
import { slugify } from "@/lib/slug";
/**
* Référencement : constantes, métadonnées et données structurées (JSON-LD).
*
* Tout ce module est **pur** : aucune lecture en base, aucun accès aux en-têtes
* de requête. Les pages lui passent l'URL publique obtenue via
* `publicBaseUrl()` (src/lib/seo-server.ts). Cela permet de verrouiller le
* comportement par des tests unitaires (tests/seo.test.ts).
*/
export const SITE_NAME = "Plein R";
export const SITE_TITLE = "Plein R — Commerçants & entreprises du Bassin de Pompey";
export const SITE_DESCRIPTION =
"Plein R, association des commerçants, artisans et entreprises du Bassin de Pompey : " +
"annuaire des professionnels, bons plans et rencontres près de chez vous.";
export const SITE_LOCALE = "fr_FR";
export const THEME_COLOR = "#E0A63C";
/** Mots-clés de la thématique : commerce de proximité sur le Bassin de Pompey. */
export const SITE_KEYWORDS = [
"Plein R",
"Bassin de Pompey",
"commerçants Bassin de Pompey",
"association de commerçants",
"entreprises Pompey",
"artisans Pompey",
"commerces Frouard",
"commerces Liverdun",
"commerces Champigneulles",
"commerces Custines",
"annuaire des professionnels",
"bons plans",
"promotions locales",
"commerce de proximité",
"Meurthe-et-Moselle",
"Lorraine",
];
/** Longueur cible d'une meta description : Google tronque vers 155–160 caractères. */
export const DESCRIPTION_MAX_LENGTH = 155;
/** Pages transactionnelles ou privées : jamais indexées. */
export const NOINDEX: NonNullable<Metadata["robots"]> = {
index: false,
follow: false,
nocache: true,
googleBot: { index: false, follow: false, noimageindex: true },
};
/** Directives d'indexation généreuses pour les pages publiques. */
export const INDEX_FOLLOW: NonNullable<Metadata["robots"]> = {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-image-preview": "large",
"max-snippet": -1,
"max-video-preview": -1,
},
};
/** Chemins exclus des moteurs (robots.txt + sitemap). */
export const PRIVATE_PATHS = ["/backend", "/api", "/login", "/inscription"];
/** Pages publiques statiques, avec leur priorité relative dans le sitemap. */
export const STATIC_ROUTES: { path: string; priority: number; changeFrequency: "daily" | "weekly" | "monthly" | "yearly" }[] = [
{ path: "/", priority: 1, changeFrequency: "daily" },
{ path: "/annuaire", priority: 0.9, changeFrequency: "daily" },
{ path: "/promotions", priority: 0.9, changeFrequency: "daily" },
{ path: "/association", priority: 0.8, changeFrequency: "weekly" },
{ path: "/rencontres-passees", priority: 0.6, changeFrequency: "monthly" },
{ path: "/mentions-legales", priority: 0.2, changeFrequency: "yearly" },
{ path: "/confidentialite", priority: 0.2, changeFrequency: "yearly" },
];
/** Construit une URL absolue à partir de l'URL publique et d'un chemin. */
export function absoluteUrl(baseUrl: string, path = "/"): string {
const base = baseUrl.replace(/\/+$/, "");
if (!path || path === "/") return `${base}/`;
return `${base}${path.startsWith("/") ? path : `/${path}`}`;
}
export type MemberPathInput = { id: number; name: string; city?: string | null };
/**
* Chemin canonique d'une fiche adhérent : `/adherents/12-au-bon-pain-frouard`.
*
* L'identifiant reste en tête : il suffit à retrouver la fiche, et le nom qui
* suit n'est là que pour les moteurs et les lecteurs. Toute autre écriture
* (`/adherents/12`, ancien nom…) est redirigée en 301 vers cette forme par la
* page, donc un renommage ne casse aucun lien.
*/
export function memberPath(member: MemberPathInput): string {
const slug = slugify([member.name, member.city ?? ""].filter(Boolean).join(" "));
return slug ? `/adherents/${member.id}-${slug}` : `/adherents/${member.id}`;
}
/** Identifiant numérique en tête du paramètre d'URL (`12-au-bon-pain` → 12), sinon `null`. */
export function parseMemberParam(param: string): number | null {
const match = /^(\d+)(?:-|$)/.exec(param);
if (!match) return null;
const id = Number(match[1]);
return Number.isSafeInteger(id) && id > 0 ? id : null;
}
/** Chemin d'une page métier de l'annuaire. */
export function categoryPath(slug: string): string {
return `/annuaire/${slug}`;
}
/**
* Ramène un texte libre à une meta description propre : espaces normalisés,
* coupée sur un mot entier avec une ellipse si elle dépasse la longueur cible.
*/
export function metaDescription(text: string | null | undefined, fallback = SITE_DESCRIPTION): string {
const clean = (text ?? "").replace(/\s+/g, " ").trim();
if (!clean) return metaDescription(fallback, "");
if (clean.length <= DESCRIPTION_MAX_LENGTH) return clean;
const cut = clean.slice(0, DESCRIPTION_MAX_LENGTH - 1);
const lastSpace = cut.lastIndexOf(" ");
return `${(lastSpace > 60 ? cut.slice(0, lastSpace) : cut).replace(/[\s,;:.!?–—-]+$/, "")}…`;
}
/**
* Sérialise un objet JSON-LD pour l'insérer dans un `<script>`.
*
* Les données viennent en partie des adhérents (descriptions, noms) : on échappe
* `<`, `>` et `&` en séquences Unicode pour qu'aucune saisie ne puisse fermer la
* balise `</script>` et injecter du HTML. JSON reste strictement valide.
*/
export function serializeJsonLd(data: unknown): string {
return JSON.stringify(data)
.replace(/</g, "\\u003c")
.replace(/>/g, "\\u003e")
.replace(/&/g, "\\u0026")
.replace(/\u2028/g, "\\u2028")
.replace(/\u2029/g, "\\u2029");
}
/** Retire les clés dont la valeur est vide : JSON-LD n'aime pas les `null`. */
export function compact<T extends Record<string, unknown>>(obj: T): Partial<T> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
if (value === null || value === undefined || value === "") continue;
if (Array.isArray(value) && value.length === 0) continue;
out[key] = value;
}
return out as Partial<T>;
}
// ---------------------------------------------------------------------------
// Données structurées
// ---------------------------------------------------------------------------
export function organizationId(baseUrl: string): string {
return `${absoluteUrl(baseUrl)}#organization`;
}
export function webSiteId(baseUrl: string): string {
return `${absoluteUrl(baseUrl)}#website`;
}
/** L'association elle-même (utilisée sur toutes les pages via le layout). */
export function organizationJsonLd(baseUrl: string, settings: SiteSettings, socials: SocialLink[]) {
const address = settings.association_address.trim();
return compact({
"@context": "https://schema.org",
"@type": "Organization",
"@id": organizationId(baseUrl),
name: settings.association_name || SITE_NAME,
alternateName: "Association Plein R — Bassin de Pompey",
legalName: settings.association_name ? `Association ${settings.association_name}` : undefined,
url: absoluteUrl(baseUrl),
logo: {
"@type": "ImageObject",
url: absoluteUrl(baseUrl, "/assets/logo.png"),
},
image: absoluteUrl(baseUrl, "/opengraph-image"),
description: metaDescription(settings.association_intro || settings.association_tagline),
slogan: "Réseau · Rencontre · Réussite",
email: settings.association_email.trim() || undefined,
telephone: settings.association_phone.trim() || undefined,
address: address
? { "@type": "PostalAddress", streetAddress: address, addressCountry: "FR" }
: undefined,
areaServed: {
"@type": "AdministrativeArea",
name: "Bassin de Pompey, Meurthe-et-Moselle, France",
},
sameAs: socials.map((s) => s.href),
});
}
/** Le site et sa fonction de recherche (annuaire), pour la « sitelinks searchbox ». */
export function webSiteJsonLd(baseUrl: string) {
return {
"@context": "https://schema.org",
"@type": "WebSite",
"@id": webSiteId(baseUrl),
name: SITE_NAME,
alternateName: SITE_TITLE,
url: absoluteUrl(baseUrl),
inLanguage: "fr-FR",
publisher: { "@id": organizationId(baseUrl) },
potentialAction: {
"@type": "SearchAction",
target: {
"@type": "EntryPoint",
urlTemplate: `${absoluteUrl(baseUrl, "/annuaire")}?q={search_term_string}`,
},
"query-input": "required name=search_term_string",
},
};
}
export type BreadcrumbItem = { name: string; path: string };
/** Fil d'Ariane : améliore l'affichage du chemin dans les résultats. */
export function breadcrumbJsonLd(baseUrl: string, items: BreadcrumbItem[]) {
return {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: items.map((item, index) => ({
"@type": "ListItem",
position: index + 1,
name: item.name,
item: absoluteUrl(baseUrl, item.path),
})),
};
}
const SCHEMA_DAYS: Record<WeekDayKey, string> = {
monday: "https://schema.org/Monday",
tuesday: "https://schema.org/Tuesday",
wednesday: "https://schema.org/Wednesday",
thursday: "https://schema.org/Thursday",
friday: "https://schema.org/Friday",
saturday: "https://schema.org/Saturday",
sunday: "https://schema.org/Sunday",
};
/** Horaires d'un adhérent au format `OpeningHoursSpecification`. */
export function openingHoursJsonLd(hours: DaySchedule[]) {
return hours
.filter((day) => !day.closed && day.slots.length > 0)
.flatMap((day) =>
day.slots.map((slot) => ({
"@type": "OpeningHoursSpecification",
dayOfWeek: SCHEMA_DAYS[day.key],
opens: slot.start,
closes: slot.end,
}))
);
}
export type LocalBusinessInput = {
id: number;
name: string;
description: string | null;
address: string | null;
postalCode: string | null;
city: string | null;
phone: string | null;
email: string | null;
website: string | null;
categoryLabel: string | null;
tags: string | null;
};
/** Une fiche adhérent : commerce ou entreprise locale. */
export function localBusinessJsonLd(
baseUrl: string,
member: LocalBusinessInput,
hours: DaySchedule[],
website: string | null
) {
const hasAddress = Boolean(member.address || member.postalCode || member.city);
const keywords = (member.tags ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const url = absoluteUrl(baseUrl, memberPath(member));
return compact({
"@context": "https://schema.org",
"@type": "LocalBusiness",
"@id": `${url}#business`,
name: member.name,
description: member.description ? metaDescription(member.description, "") : undefined,
url: website ?? url,
mainEntityOfPage: url,
telephone: member.phone ?? undefined,
email: member.email ?? undefined,
address: hasAddress
? compact({
"@type": "PostalAddress",
streetAddress: member.address ?? undefined,
postalCode: member.postalCode ?? undefined,
addressLocality: member.city ?? undefined,
addressRegion: "Grand Est",
addressCountry: "FR",
})
: undefined,
openingHoursSpecification: openingHoursJsonLd(hours),
keywords: keywords.length > 0 ? keywords.join(", ") : undefined,
additionalType: member.categoryLabel ?? undefined,
memberOf: { "@id": organizationId(baseUrl) },
});
}
export type EventInput = {
id: number;
title: string;
startsAt: Date;
location: string | null;
description: string | null;
capacity: number;
registered: number;
};
/** Une rencontre à venir de l'association. */
export function eventJsonLd(baseUrl: string, meeting: EventInput) {
const remaining = Math.max(0, meeting.capacity - Number(meeting.registered));
return compact({
"@context": "https://schema.org",
"@type": "BusinessEvent",
name: meeting.title,
description: meeting.description ? metaDescription(meeting.description, "") : undefined,
startDate: meeting.startsAt.toISOString(),
eventStatus: "https://schema.org/EventScheduled",
eventAttendanceMode: "https://schema.org/OfflineEventAttendanceMode",
location: meeting.location
? { "@type": "Place", name: meeting.location, address: meeting.location }
: undefined,
organizer: { "@id": organizationId(baseUrl) },
url: absoluteUrl(baseUrl, "/association"),
maximumAttendeeCapacity: meeting.capacity,
remainingAttendeeCapacity: remaining,
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "EUR",
availability: remaining > 0 ? "https://schema.org/InStock" : "https://schema.org/SoldOut",
url: absoluteUrl(baseUrl, `/inscription/${meeting.id}`),
},
});
}
export type ItemListEntry = MemberPathInput;
/** Liste d'adhérents (annuaire) : chaque entrée pointe vers sa fiche. */
export function memberListJsonLd(baseUrl: string, name: string, members: ItemListEntry[]) {
return {
"@context": "https://schema.org",
"@type": "ItemList",
name,
numberOfItems: members.length,
itemListElement: members.map((m, index) => ({
"@type": "ListItem",
position: index + 1,
url: absoluteUrl(baseUrl, memberPath(m)),
name: m.name,
})),
};
}
/**
* Image de partage par défaut (générée par app/opengraph-image.tsx). Une page
* qui définit son propre `openGraph` remplace celui du layout en bloc : il faut
* donc redonner l'image explicitement, sinon elle disparaît des partages.
*/
export const SHARE_IMAGE = {
url: "/opengraph-image",
width: 1200,
height: 630,
alt: SITE_TITLE,
type: "image/png",
};
/** Métadonnées Open Graph / Twitter communes, complétées par chaque page. */
export function pageMetadata(input: {
title: string;
/** Titre complet pour Open Graph quand le titre de page est déjà « absolu ». */
ogTitle?: string;
description: string;
path: string;
robots?: Metadata["robots"];
ogType?: "website" | "profile" | "article";
}): Metadata {
const ogTitle = input.ogTitle ?? `${input.title} · ${SITE_NAME}`;
return {
title: input.title,
description: input.description,
alternates: { canonical: input.path },
openGraph: {
title: ogTitle,
description: input.description,
url: input.path,
type: input.ogType ?? "website",
locale: SITE_LOCALE,
siteName: SITE_NAME,
images: [SHARE_IMAGE],
},
twitter: {
card: "summary_large_image",
title: ogTitle,
description: input.description,
images: [SHARE_IMAGE],
},
robots: input.robots ?? INDEX_FOLLOW,
};
}