mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-20 21:58:09 +02:00
SEO : URLs de fiche parlantes, pages métier indexables, aides de rédaction
- Fiches adhérents : URL canonique /adherents/12-au-bon-pain-frouard (memberPath). L'identifiant en tête suffit ; /adherents/12 et tout slug périmé sont redirigés de façon permanente vers la forme canonique, donc les anciens liens et les partages sociaux restent valides. Tous les liens (accueil, carrousel, annuaire, JSON-LD, sitemap, publications Facebook / LinkedIn) passent par memberPath. - Pages métier /annuaire/[categorie] : une page indexable par catégorie avec titre, introduction (effectif, communes), grille d'adhérents, fil d'Ariane et ItemList. Sans adhérent : noindex et hors sitemap. Section « Parcourir par métier » sur l'annuaire et « Autres métiers » sur chaque page pour le maillage interne. - Composants partagés MemberCard et CategoryLinks ; slugify déplacé dans src/lib/slug.ts. - Backend : conseils de rédaction sous les champs description et tags des formulaires adhérent (espace adhérent et fiche staff). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDbKbJrPWweXXYSxA8MXW7
This commit is contained in:
19 files changed
+489
-99
No files matched your search
@@ -69,6 +69,7 @@ export async function getLivePromotions(limit = 6) {
|
||||
validUntil: promotions.validUntil,
|
||||
memberId: promotions.memberId,
|
||||
memberName: members.name,
|
||||
memberCity: members.city,
|
||||
})
|
||||
.from(promotions)
|
||||
.leftJoin(members, eq(promotions.memberId, members.id))
|
||||
@@ -174,3 +175,44 @@ export async function getActiveMemberCount(): Promise<number> {
|
||||
.where(eq(members.status, "active"));
|
||||
return Number(row?.total ?? 0);
|
||||
}
|
||||
|
||||
/** Une catégorie par son slug d'URL, ou `null`. */
|
||||
export async function getCategoryBySlug(slug: string) {
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: categories.id,
|
||||
slug: categories.slug,
|
||||
label: categories.label,
|
||||
accent: categories.accent,
|
||||
tint: categories.tint,
|
||||
})
|
||||
.from(categories)
|
||||
.where(eq(categories.slug, slug));
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
/** Adhérents actifs d'une catégorie, pour la page métier de l'annuaire. */
|
||||
export async function getActiveMembersByCategory(categoryId: number) {
|
||||
return db
|
||||
.select({
|
||||
id: members.id,
|
||||
name: members.name,
|
||||
description: members.description,
|
||||
city: members.city,
|
||||
address: members.address,
|
||||
coverUrl: members.coverUrl,
|
||||
logoUrl: members.logoUrl,
|
||||
categoryLabel: categories.label,
|
||||
accent: categories.accent,
|
||||
})
|
||||
.from(members)
|
||||
.leftJoin(categories, eq(members.categoryId, categories.id))
|
||||
.where(and(eq(members.status, "active"), eq(members.categoryId, categoryId)))
|
||||
.orderBy(asc(members.name));
|
||||
}
|
||||
|
||||
/** Catégories ayant au moins un adhérent actif, avec leur effectif. */
|
||||
export async function getCategoriesInUse() {
|
||||
const rows = await getCategoriesWithCounts(1000);
|
||||
return rows.filter((c) => Number(c.memberCount) > 0);
|
||||
}
|
||||
+30
-6
@@ -1,6 +1,7 @@
|
||||
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).
|
||||
@@ -83,9 +84,32 @@ export function absoluteUrl(baseUrl: string, path = "/"): string {
|
||||
return `${base}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
}
|
||||
|
||||
/** Chemin canonique d'une fiche adhérent. */
|
||||
export function memberPath(id: number): string {
|
||||
return `/adherents/${id}`;
|
||||
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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -259,7 +283,7 @@ export function localBusinessJsonLd(
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const url = absoluteUrl(baseUrl, memberPath(member.id));
|
||||
const url = absoluteUrl(baseUrl, memberPath(member));
|
||||
return compact({
|
||||
"@context": "https://schema.org",
|
||||
"@type": "LocalBusiness",
|
||||
@@ -325,7 +349,7 @@ export function eventJsonLd(baseUrl: string, meeting: EventInput) {
|
||||
});
|
||||
}
|
||||
|
||||
export type ItemListEntry = { id: number; name: string; description?: string | null };
|
||||
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[]) {
|
||||
@@ -337,7 +361,7 @@ export function memberListJsonLd(baseUrl: string, name: string, members: ItemLis
|
||||
itemListElement: members.map((m, index) => ({
|
||||
"@type": "ListItem",
|
||||
position: index + 1,
|
||||
url: absoluteUrl(baseUrl, memberPath(m.id)),
|
||||
url: absoluteUrl(baseUrl, memberPath(m)),
|
||||
name: m.name,
|
||||
})),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/** Slug ASCII minuscule : accents retirés, tout le reste devient un tiret. */
|
||||
export function slugify(input: string, maxLength = 70): string {
|
||||
return input
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, maxLength)
|
||||
.replace(/-+$/g, "");
|
||||
}
|
||||
+5
-1
@@ -15,6 +15,7 @@ import {
|
||||
siteUrl,
|
||||
type SocialNetwork,
|
||||
} from "./social-accounts";
|
||||
import { memberPath } from "./seo";
|
||||
|
||||
export {
|
||||
configuredNetworks,
|
||||
@@ -37,12 +38,15 @@ export type PromoForSharing = {
|
||||
imageUrl: string | null;
|
||||
memberId: number | null;
|
||||
memberName: string | null;
|
||||
memberCity?: string | null;
|
||||
};
|
||||
|
||||
export async function promoLink(promo: PromoForSharing): Promise<string | null> {
|
||||
const base = await siteUrl();
|
||||
if (!base) return null;
|
||||
return promo.memberId ? `${base}/adherents/${promo.memberId}` : `${base}/#promotions`;
|
||||
return promo.memberId
|
||||
? `${base}${memberPath({ id: promo.memberId, name: promo.memberName ?? "", city: promo.memberCity })}`
|
||||
: `${base}/#promotions`;
|
||||
}
|
||||
|
||||
/** Texte du post, commun aux deux réseaux. */
|
||||
|
||||
Reference in new issue
Block a user