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:
Claude committed 2026-09-06 06:29:28 +00:00
1 parent f9d0d6c825
commit c8417d8560
19 files changed
+489 -99

No files matched your search

+8
View File
@@ -137,6 +137,14 @@ site sans traitement supplémentaire.
`app/manifest.ts` et `app/opengraph-image.tsx` (vignette 1200×630 générée).
- `/backend`, `/login`, `/inscription/*` et les fiches non actives sont en
`NOINDEX` ; les pages publiques utilisent `<main>` et un seul `<h1>`.
- **URLs de fiche** : `memberPath({ id, name, city })` donne
`/adherents/12-au-bon-pain-frouard`. L'identifiant en tête suffit
(`parseMemberParam`), toute autre écriture est redirigée en 301 vers la forme
canonique par la page : ne construisez jamais `/adherents/${id}` à la main.
- **Pages métier** : `/annuaire/[categorie]` (slug de `categories`) rend une page
indexable par activité (titre, intro, grille `MemberCard`, `ItemList`) ;
sans adhérent elle passe en `NOINDEX` et sort du sitemap. L'annuaire et ces
pages listent les métiers via `CategoryLinks` pour le maillage interne.
## Roles
+11 -5
View File
@@ -1,5 +1,5 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { notFound, permanentRedirect } from "next/navigation";
import type { Metadata } from "next";
import { Sparkle } from "@/components/Sparkle";
import { SiteHeader } from "@/components/SiteHeader";
@@ -15,6 +15,7 @@ import {
memberPath,
metaDescription,
pageMetadata,
parseMemberParam,
} from "@/lib/seo";
import { publicBaseUrl } from "@/lib/seo-server";
import {
@@ -108,7 +109,7 @@ export async function generateMetadata({
params: Promise<{ id: string }>;
}): Promise<Metadata> {
const { id } = await params;
const memberId = Number(id);
const memberId = parseMemberParam(id);
const member = memberId ? await getPublicMember(memberId) : null;
if (!member || member.status !== "active") {
return { title: "Adhérent introuvable", robots: NOINDEX };
@@ -119,7 +120,7 @@ export async function generateMetadata({
return pageMetadata({
title: memberTitle(member),
description: metaDescription(member.description, fallback),
path: memberPath(member.id),
path: memberPath(member),
ogType: "profile",
});
}
@@ -130,12 +131,17 @@ export default async function FicheAdherentPage({
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const memberId = Number(id);
const memberId = parseMemberParam(id);
if (!memberId) notFound();
const member = await getPublicMember(memberId);
if (!member || member.status !== "active") notFound();
// Une seule URL par fiche : l'ancienne forme `/adherents/12` et tout slug
// périmé (renommage, changement de commune) sont redirigés définitivement.
const canonical = memberPath(member);
if (`/adherents/${id}` !== canonical) permanentRedirect(canonical);
const [promos, baseUrl] = await Promise.all([getMemberLivePromotions(memberId), publicBaseUrl()]);
const accent = member.accent ?? "#E0A63C";
@@ -174,7 +180,7 @@ export default async function FicheAdherentPage({
breadcrumbJsonLd(baseUrl, [
{ name: "Accueil", path: "/" },
{ name: "Annuaire", path: "/annuaire" },
{ name: member.name, path: memberPath(member.id) },
{ name: member.name, path: canonical },
]),
localBusinessJsonLd(baseUrl, member, hours, normalizedWebsite),
]}
+5 -57
View File
@@ -1,28 +1,13 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import { VitrineImage } from "@/components/VitrineImage";
import { MemberCard, type MemberCardData } from "@/components/MemberCard";
export type DirectoryMember = {
id: number;
name: string;
description: string | null;
city: string | null;
address: string | null;
coverUrl: string | null;
logoUrl: string | null;
categoryLabel: string | null;
accent: string | null;
export type DirectoryMember = MemberCardData & {
hasPromo: boolean;
promoBadge: string | null;
};
const STRIPE_WARM =
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
const STRIPE_COOL =
"repeating-linear-gradient(45deg,#eef0ec,#eef0ec 12px,#e2e8e6 12px,#e2e8e6 24px)";
export function AnnuaireClient({
members,
categories,
@@ -190,46 +175,9 @@ export function AnnuaireClient({
{/* grid / empty */}
{list.length > 0 ? (
<div className="grid grid-3" style={{ gap: 20 }}>
{list.map((m, i) => {
return (
<Link
key={m.id}
href={`/adherents/${m.id}`}
className="lift-card"
style={{ textDecoration: "none", color: "inherit", background: "#fff", border: "1px solid #e6dcc6", borderRadius: 18, overflow: "hidden", display: "flex", flexDirection: "column" }}
>
<VitrineImage
coverUrl={m.coverUrl}
logoUrl={m.logoUrl}
height={150}
stripe={i % 2 === 0 ? STRIPE_WARM : STRIPE_COOL}
>
{m.categoryLabel && (
<span style={{ position: "absolute", top: 12, left: 12, background: m.accent ?? "#9a6638", color: "#fff", borderRadius: 999, padding: "5px 12px", fontSize: 11, fontWeight: 700 }}>
{m.categoryLabel}
</span>
)}
{m.hasPromo && m.promoBadge && (
<span className="font-display" style={{ position: "absolute", top: 0, right: 0, background: "#d8472b", color: "#fff", fontWeight: 800, fontSize: 12.5, padding: "6px 12px", borderRadius: "0 0 0 14px" }}>
{m.promoBadge}
</span>
)}
</VitrineImage>
<div style={{ padding: "16px 18px 18px", display: "flex", flexDirection: "column", flex: 1 }}>
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 18, margin: 0, color: "#26201a" }}>
{m.name}
</h3>
<p style={{ margin: "6px 0 12px", fontSize: 13, color: "#8c8068", lineHeight: 1.5, flex: 1 }}>
{m.description}
</p>
<div style={{ display: "flex", alignItems: "center", gap: 7, fontSize: 12.5, color: "#6c6150", borderTop: "1px solid #f0e8d6", paddingTop: 11 }}>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: m.accent ?? "#E0A63C" }} />
{[m.address, m.city].filter(Boolean).join(" · ")}
</div>
</div>
</Link>
);
})}
{list.map((m, i) => (
<MemberCard key={m.id} m={m} index={i} />
))}
</div>
) : (
<div style={{ background: "#fff", border: "1px dashed #d8cdb4", borderRadius: 18, padding: 54, textAlign: "center" }}>
+180
View File
@@ -0,0 +1,180 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { CategoryLinks } from "@/components/CategoryLinks";
import { JsonLd } from "@/components/JsonLd";
import { MemberCard } from "@/components/MemberCard";
import { MembershipModalButton } from "@/components/MembershipModalButton";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
import { Sparkle } from "@/components/Sparkle";
import {
getActiveMembersByCategory,
getCategoriesInUse,
getCategoryBySlug,
getLiveBadgesByMember,
} from "@/lib/queries";
import {
NOINDEX,
breadcrumbJsonLd,
categoryPath,
memberListJsonLd,
metaDescription,
pageMetadata,
} from "@/lib/seo";
import { publicBaseUrl } from "@/lib/seo-server";
export const dynamic = "force-dynamic";
type Params = Promise<{ categorie: string }>;
/** Communes représentées, dans l'ordre d'effectif, pour le texte d'introduction. */
function listCities(members: { city: string | null }[], max = 4): string[] {
const counts = new Map<string, number>();
for (const m of members) if (m.city) counts.set(m.city, (counts.get(m.city) ?? 0) + 1);
return [...counts.entries()]
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0], "fr"))
.slice(0, max)
.map(([city]) => city);
}
function joinFr(items: string[]): string {
if (items.length <= 1) return items.join("");
return `${items.slice(0, -1).join(", ")} et ${items[items.length - 1]}`;
}
/** Titre et introduction d'une page métier, réutilisés par les métadonnées et le rendu. */
function categoryCopy(label: string, members: { city: string | null }[]) {
const n = members.length;
const cities = listCities(members);
const where = cities.length > 0 ? ` à ${joinFr(cities)}` : " sur le Bassin de Pompey";
const title = `${label} sur le Bassin de Pompey`;
const intro =
n === 0
? `Aucun adhérent Plein R n'est encore référencé dans la catégorie ${label}. Vous exercez ce métier sur le Bassin de Pompey ? Rejoignez l'association pour apparaître ici.`
: `${n} ${n > 1 ? "professionnels adhérents" : "professionnel adhérent"} Plein R dans la catégorie ${label}${where} : coordonnées, horaires d'ouverture et bons plans du moment. Des commerçants et entreprises de proximité, membres du réseau économique du Bassin de Pompey.`;
return { title, intro };
}
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
const { categorie } = await params;
const category = await getCategoryBySlug(categorie);
if (!category) return { title: "Catégorie introuvable", robots: NOINDEX };
const members = await getActiveMembersByCategory(category.id);
const { title, intro } = categoryCopy(category.label, members);
return pageMetadata({
title,
description: metaDescription(intro),
path: categoryPath(category.slug),
// Une catégorie sans adhérent n'a rien à offrir aux moteurs : on la laisse
// visible pour les visiteurs, mais hors index.
robots: members.length === 0 ? NOINDEX : undefined,
});
}
export default async function CategoriePage({ params }: { params: Params }) {
const { categorie } = await params;
const category = await getCategoryBySlug(categorie);
if (!category) notFound();
const [rawMembers, badges, categoriesInUse, baseUrl] = await Promise.all([
getActiveMembersByCategory(category.id),
getLiveBadgesByMember(),
getCategoriesInUse(),
publicBaseUrl(),
]);
const badgeByMember = new Map<number, string | null>();
for (const b of badges) {
if (b.memberId != null && !badgeByMember.has(b.memberId)) badgeByMember.set(b.memberId, b.badge);
}
const members = rawMembers.map((m) => ({
...m,
hasPromo: badgeByMember.has(m.id),
promoBadge: badgeByMember.get(m.id) ?? null,
}));
const { title, intro } = categoryCopy(category.label, members);
const accent = category.accent || "#E0A63C";
return (
<div style={{ background: "#F6F2E8", minHeight: "100vh", fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
<SiteHeader active="annuaire" logo />
<JsonLd
data={[
breadcrumbJsonLd(baseUrl, [
{ name: "Accueil", path: "/" },
{ name: "Annuaire", path: "/annuaire" },
{ name: category.label, path: categoryPath(category.slug) },
]),
memberListJsonLd(baseUrl, title, members),
]}
/>
<main className="container" style={{ paddingBottom: 56 }}>
<nav aria-label="Fil d'Ariane" style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 13.5, color: "#9a8d72", padding: "6px 0 18px", flexWrap: "wrap" }}>
<Link href="/annuaire" style={{ textDecoration: "none", color: "#9a6638", fontWeight: 600 }}>Annuaire</Link>
<span>›</span>
<span style={{ color: "#3c3322", fontWeight: 600 }} aria-current="page">{category.label}</span>
</nav>
<section style={{ position: "relative", padding: "6px 0 22px", overflow: "hidden" }}>
<Sparkle color={accent} size={18} style={{ top: 12, right: 30 }} duration={3.2} />
<Sparkle color="#6FB0C6" size={12} style={{ top: 52, right: 90 }} duration={2.7} delay={0.5} />
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: 9,
background: category.tint || "#fff",
border: "1px solid #e6dcc6",
color: "#9a6638",
fontSize: 12,
letterSpacing: "0.12em",
textTransform: "uppercase",
fontWeight: 800,
padding: "6px 13px",
borderRadius: 999,
marginBottom: 14,
}}
>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: accent }} />
{members.length} {members.length > 1 ? "adhérents" : "adhérent"}
</div>
<h1 className="font-display" style={{ fontWeight: 800, fontSize: "clamp(26px, 6vw, 40px)", letterSpacing: "-0.02em", margin: "0 0 10px", color: "#26201a" }}>
{title}
</h1>
<p style={{ margin: 0, fontSize: 16, color: "#6c6150", maxWidth: 680, lineHeight: 1.6 }}>{intro}</p>
</section>
{members.length > 0 ? (
<div className="grid grid-3" style={{ gap: 20 }}>
{members.map((m, i) => (
<MemberCard key={m.id} m={m} index={i} />
))}
</div>
) : (
<div style={{ background: "#fff", border: "1px dashed #d8cdb4", borderRadius: 18, padding: 44, textAlign: "center" }}>
<div className="font-display" style={{ fontWeight: 700, fontSize: 19, color: "#26201a", marginBottom: 14 }}>
Soyez le premier référencé dans cette catégorie
</div>
<MembershipModalButton
label="Adhérer à l'association"
className="font-display"
style={{ border: "none", background: "#E0A63C", color: "#33291D", fontWeight: 700, fontSize: 15, padding: "12px 22px", borderRadius: 12 }}
/>
</div>
)}
{categoriesInUse.length > 0 && (
<section style={{ marginTop: 34, background: "#fff", border: "1px solid #e6dcc6", borderRadius: 18, padding: "22px 24px" }}>
<h2 className="font-display" style={{ fontWeight: 700, fontSize: 19, margin: "0 0 14px", color: "#26201a" }}>
Autres métiers de l&apos;annuaire
</h2>
<CategoryLinks categories={categoriesInUse} currentSlug={category.slug} />
</section>
)}
</main>
<SiteFooter />
</div>
);
}
+17 -1
View File
@@ -6,8 +6,10 @@ import { MembershipModalButton } from "@/components/MembershipModalButton";
import {
getActiveMembersWithCategory,
getAllCategories,
getCategoriesInUse,
getLiveBadgesByMember,
} from "@/lib/queries";
import { CategoryLinks } from "@/components/CategoryLinks";
import { JsonLd } from "@/components/JsonLd";
import { breadcrumbJsonLd, memberListJsonLd, pageMetadata } from "@/lib/seo";
import { publicBaseUrl } from "@/lib/seo-server";
@@ -30,11 +32,12 @@ export default async function AnnuairePage({
searchParams: Promise<{ q?: string }>;
}) {
const { q } = await searchParams;
const [rawMembers, categories, badges, baseUrl] = await Promise.all([
const [rawMembers, categories, badges, baseUrl, categoriesInUse] = await Promise.all([
getActiveMembersWithCategory(),
getAllCategories(),
getLiveBadgesByMember(),
publicBaseUrl(),
getCategoriesInUse(),
]);
const badgeByMember = new Map<number, string | null>();
@@ -98,6 +101,19 @@ export default async function AnnuairePage({
<AnnuaireClient members={members} categories={categories} initialQuery={q ?? ""} />
{/* Pages métier : une URL indexable par catégorie, en plus du filtre. */}
{categoriesInUse.length > 0 && (
<section style={{ marginTop: 34, background: "#fff", border: "1px solid #e6dcc6", borderRadius: 18, padding: "22px 24px" }}>
<h2 className="font-display" style={{ fontWeight: 700, fontSize: 19, margin: "0 0 6px", color: "#26201a" }}>
Parcourir par métier
</h2>
<p style={{ margin: "0 0 14px", fontSize: 14, color: "#6c6150" }}>
Tous les commerçants, artisans et entreprises du Bassin de Pompey, classés par activité.
</p>
<CategoryLinks categories={categoriesInUse} />
</section>
)}
{/* join CTA */}
<section
style={{
+5 -12
View File
@@ -10,6 +10,7 @@ import { signOut } from "@/auth";
import { getSession } from "@/lib/session";
import { db } from "@/db";
import { CATEGORY_PALETTE } from "@/db/categories";
import { slugify } from "@/lib/slug";
import {
activityLog,
categories,
@@ -45,15 +46,6 @@ import { SITE_SETTING_DEFAULTS } from "@/lib/site-settings";
import type { AppRole } from "@/types/next-auth";
function slugify(input: string): string {
return input
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 70);
}
async function logActivity(message: string, dot = "#2C6FB3") {
// Le message agrège des saisies de tiers : on ne laisse passer que <strong>.
@@ -127,7 +119,7 @@ function revalidatePromoPaths(memberId?: number | null) {
revalidatePath("/backend");
revalidatePath("/");
revalidatePath("/annuaire");
if (memberId) revalidatePath(`/adherents/${memberId}`);
if (memberId) revalidatePath("/adherents/[id]", "page");
}
/** Réseaux demandés pour une promo, dans l'ordre d'affichage. */
@@ -168,6 +160,7 @@ async function publishPromoShares(
imageUrl: promotions.imageUrl,
memberId: promotions.memberId,
memberName: members.name,
memberCity: members.city,
})
.from(promotions)
.leftJoin(members, eq(promotions.memberId, members.id))
@@ -550,7 +543,7 @@ export async function updateMember(formData: FormData) {
.where(eq(members.id, id));
revalidatePath("/backend/adherents");
revalidatePath(`/adherents/${id}`);
revalidatePath("/adherents/[id]", "page");
revalidatePath("/");
}
@@ -1104,7 +1097,7 @@ export async function updateOwnProfile(formData: FormData) {
.where(eq(members.id, memberId));
revalidatePath("/backend/espace");
revalidatePath(`/adherents/${memberId}`);
revalidatePath("/adherents/[id]", "page");
revalidatePath("/annuaire");
revalidatePath("/");
}
+8
View File
@@ -134,11 +134,19 @@ export default async function EditMemberPage({
<div style={{ marginTop: 16 }}>
<label className="field-label">Description (une ligne vide sépare les paragraphes)</label>
<textarea name="description" rows={4} defaultValue={member.description ?? ""} className="field" style={{ resize: "vertical" }} />
<p className="field-hint">
Ce texte est lu par Google : indiquez votre métier, votre commune et vos spécialités en trois à cinq
phrases (ex. « Boulangerie artisanale à Frouard : pain au levain, viennoiseries, pâtisseries maison »).
</p>
</div>
<div style={{ marginTop: 16 }}>
<label className="field-label">Tags / spécialités (séparés par des virgules)</label>
<input name="tags" defaultValue={member.tags ?? ""} className="field" placeholder="Levain naturel, Produits locaux, Fait maison" />
<p className="field-hint">
Mots-clés que vos clients recherchent : produits, services, marques, quartier. Ils s&apos;affichent sur
votre fiche et sont transmis aux moteurs de recherche.
</p>
</div>
<div style={{ marginTop: 16 }}>
+9 -1
View File
@@ -231,11 +231,19 @@ export default async function EspacePage() {
<div style={{ marginTop: 16 }}>
<label className="field-label">Description (une ligne vide sépare les paragraphes)</label>
<textarea name="description" rows={4} defaultValue={profile.description ?? ""} className="field" style={{ resize: "vertical" }} />
<p className="field-hint">
Ce texte est lu par Google : indiquez votre métier, votre commune et vos spécialités en trois à cinq
phrases (ex. « Boulangerie artisanale à Frouard : pain au levain, viennoiseries, pâtisseries maison »).
</p>
</div>
<div style={{ marginTop: 16 }}>
<label className="field-label">Tags / spécialités (séparés par des virgules)</label>
<input name="tags" defaultValue={profile.tags ?? ""} className="field" />
<input name="tags" defaultValue={profile.tags ?? ""} className="field" placeholder="Levain naturel, Produits locaux, Fait maison" />
<p className="field-hint">
Mots-clés que vos clients recherchent : produits, services, marques, quartier. Ils s&apos;affichent sur
votre fiche et sont transmis aux moteurs de recherche.
</p>
</div>
<div style={{ marginTop: 16 }}>
+7
View File
@@ -948,6 +948,13 @@ img {
color: var(--muted);
margin-bottom: 6px;
}
/* Conseil de rédaction sous un champ : discret, mais lu avant de saisir. */
.field-hint {
margin: 6px 0 0;
font-size: 12.5px;
line-height: 1.5;
color: var(--muted-2);
}
/* ---- Studio e-mail Plein R ---- */
.email-workbench {
+2 -2
View File
@@ -11,7 +11,7 @@ import { PromoImage } from "@/components/PromoImage";
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
import { getActiveMemberCount, getLivePromotions, getRotatingActiveMembers } from "@/lib/queries";
import { getSiteSettings, socialLinks } from "@/lib/site-settings";
import { SITE_DESCRIPTION, SITE_TITLE, pageMetadata } from "@/lib/seo";
import { SITE_DESCRIPTION, SITE_TITLE, memberPath, pageMetadata } from "@/lib/seo";
export const dynamic = "force-dynamic";
@@ -235,7 +235,7 @@ export default async function AccueilPage() {
{promos.map((p) => (
<Link
key={p.id}
href={p.memberId ? `/adherents/${p.memberId}` : "#"}
href={p.memberId ? memberPath({ id: p.memberId, name: p.memberName ?? "", city: p.memberCity }) : "#"}
className="lift-card"
style={{
textDecoration: "none",
+22 -9
View File
@@ -2,7 +2,8 @@ import type { MetadataRoute } from "next";
import { asc, eq } from "drizzle-orm";
import { db } from "@/db";
import { members } from "@/db/schema";
import { STATIC_ROUTES, absoluteUrl, memberPath } from "@/lib/seo";
import { getCategoriesInUse } from "@/lib/queries";
import { STATIC_ROUTES, absoluteUrl, categoryPath, memberPath } from "@/lib/seo";
import { publicBaseUrl } from "@/lib/seo-server";
export const dynamic = "force-dynamic";
@@ -19,22 +20,34 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
priority: route.priority,
}));
let rows: { id: number }[] = [];
let rows: { id: number; name: string; city: string | null }[] = [];
let categoriesInUse: { slug: string }[] = [];
try {
rows = await db
.select({ id: members.id })
.from(members)
.where(eq(members.status, "active"))
.orderBy(asc(members.id));
[rows, categoriesInUse] = await Promise.all([
db
.select({ id: members.id, name: members.name, city: members.city })
.from(members)
.where(eq(members.status, "active"))
.orderBy(asc(members.id)),
getCategoriesInUse(),
]);
} catch {
// Base indisponible : on livre au moins les pages statiques.
}
// Pages métier : seulement celles qui ont au moins un adhérent (les autres
// sont en noindex, une page vide n'apporte rien aux moteurs).
const metiers: MetadataRoute.Sitemap = categoriesInUse.map((c) => ({
url: absoluteUrl(base, categoryPath(c.slug)),
changeFrequency: "weekly",
priority: 0.8,
}));
const fiches: MetadataRoute.Sitemap = rows.map((m) => ({
url: absoluteUrl(base, memberPath(m.id)),
url: absoluteUrl(base, memberPath(m)),
changeFrequency: "weekly",
priority: 0.7,
}));
return [...statics, ...fiches];
return [...statics, ...metiers, ...fiches];
}
+39
View File
@@ -0,0 +1,39 @@
import Link from "next/link";
import { categoryPath } from "@/lib/seo";
export type CategoryLink = { slug: string; label: string; accent: string; memberCount: number };
/** Pastilles-liens vers les pages métier : contenu statique, lisible sans JavaScript. */
export function CategoryLinks({ categories, currentSlug }: { categories: CategoryLink[]; currentSlug?: string }) {
return (
<nav aria-label="Parcourir par métier" style={{ display: "flex", flexWrap: "wrap", gap: 9 }}>
{categories.map((c) => {
const active = c.slug === currentSlug;
return (
<Link
key={c.slug}
href={categoryPath(c.slug)}
aria-current={active ? "page" : undefined}
style={{
textDecoration: "none",
fontSize: 13.5,
fontWeight: 600,
padding: "8px 14px",
borderRadius: 999,
border: `1px solid ${active ? "#13324F" : "#e6dcc6"}`,
background: active ? "#13324F" : "#fff",
color: active ? "#fff" : "#3c3322",
display: "inline-flex",
alignItems: "center",
gap: 8,
}}
>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: active ? "#E0A63C" : c.accent, flexShrink: 0 }} />
{c.label}
<span style={{ opacity: 0.6 }}>{c.memberCount}</span>
</Link>
);
})}
</nav>
);
}
+66
View File
@@ -0,0 +1,66 @@
import Link from "next/link";
import { VitrineImage } from "@/components/VitrineImage";
import { memberPath } from "@/lib/seo";
export type MemberCardData = {
id: number;
name: string;
description: string | null;
city: string | null;
address: string | null;
coverUrl: string | null;
logoUrl: string | null;
categoryLabel: string | null;
accent: string | null;
hasPromo?: boolean;
promoBadge?: string | null;
};
const STRIPE_WARM =
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
const STRIPE_COOL =
"repeating-linear-gradient(45deg,#eef0ec,#eef0ec 12px,#e2e8e6 12px,#e2e8e6 24px)";
/**
* Carte d'un adhérent dans une grille (annuaire, page métier). Composant sans
* état : il se rend aussi bien côté serveur que dans le filtre client.
*/
export function MemberCard({ m, index = 0 }: { m: MemberCardData; index?: number }) {
return (
<Link
href={memberPath(m)}
className="lift-card"
style={{ textDecoration: "none", color: "inherit", background: "#fff", border: "1px solid #e6dcc6", borderRadius: 18, overflow: "hidden", display: "flex", flexDirection: "column" }}
>
<VitrineImage
coverUrl={m.coverUrl}
logoUrl={m.logoUrl}
height={150}
stripe={index % 2 === 0 ? STRIPE_WARM : STRIPE_COOL}
>
{m.categoryLabel && (
<span style={{ position: "absolute", top: 12, left: 12, background: m.accent ?? "#9a6638", color: "#fff", borderRadius: 999, padding: "5px 12px", fontSize: 11, fontWeight: 700 }}>
{m.categoryLabel}
</span>
)}
{m.hasPromo && m.promoBadge && (
<span className="font-display" style={{ position: "absolute", top: 0, right: 0, background: "#d8472b", color: "#fff", fontWeight: 800, fontSize: 12.5, padding: "6px 12px", borderRadius: "0 0 0 14px" }}>
{m.promoBadge}
</span>
)}
</VitrineImage>
<div style={{ padding: "16px 18px 18px", display: "flex", flexDirection: "column", flex: 1 }}>
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 18, margin: 0, color: "#26201a" }}>
{m.name}
</h3>
<p style={{ margin: "6px 0 12px", fontSize: 13, color: "#8c8068", lineHeight: 1.5, flex: 1 }}>
{m.description}
</p>
<div style={{ display: "flex", alignItems: "center", gap: 7, fontSize: 12.5, color: "#6c6150", borderTop: "1px solid #f0e8d6", paddingTop: 11 }}>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: m.accent ?? "#E0A63C" }} />
{[m.address, m.city].filter(Boolean).join(" · ")}
</div>
</div>
</Link>
);
}
+2 -1
View File
@@ -3,6 +3,7 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import { VitrineImage } from "./VitrineImage";
import { memberPath } from "@/lib/seo";
export type CarouselMember = {
id: number;
@@ -24,7 +25,7 @@ const FADE_MS = 450;
function Card({ m, visible, index }: { m: CarouselMember; visible: boolean; index: number }) {
return (
<Link
href={`/adherents/${m.id}`}
href={memberPath(m)}
className="lift-card"
style={{
background: "#fff",
+42
View File
@@ -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
View File
@@ -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,
})),
};
+11
View File
@@ -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
View File
@@ -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. */
+20 -4
View File
@@ -8,6 +8,9 @@ import {
STATIC_ROUTES,
absoluteUrl,
breadcrumbJsonLd,
categoryPath,
memberPath,
parseMemberParam,
eventJsonLd,
localBusinessJsonLd,
memberListJsonLd,
@@ -30,6 +33,19 @@ describe("SEO — URLs et descriptions", () => {
assert.equal(absoluteUrl(BASE, "adherents/3"), `${BASE}/adherents/3`);
});
it("construit des URLs de fiche parlantes et les relit", () => {
assert.equal(memberPath({ id: 12, name: "Au Bon Pain", city: "Frouard" }), "/adherents/12-au-bon-pain-frouard");
assert.equal(memberPath({ id: 3, name: "Épicerie « Chez Léa »", city: null }), "/adherents/3-epicerie-chez-lea");
assert.equal(memberPath({ id: 7, name: "???" }), "/adherents/7");
for (const param of ["12-au-bon-pain-frouard", "12-ancien-nom", "12"]) {
assert.equal(parseMemberParam(param), 12, param);
}
for (const param of ["abc", "-12", "12abc", "0", ""]) {
assert.equal(parseMemberParam(param), null, param);
}
assert.equal(categoryPath("boulangerie"), "/annuaire/boulangerie");
});
it("borne la meta description sur un mot entier", () => {
const long = "Boulangerie artisanale ".repeat(20);
const out = metaDescription(long);
@@ -116,7 +132,7 @@ describe("SEO — données structurées", () => {
};
const biz = localBusinessJsonLd(BASE, member, [], null) as Record<string, unknown>;
assert.equal(biz["@type"], "LocalBusiness");
assert.equal(biz.url, `${BASE}/adherents/7`);
assert.equal(biz.url, `${BASE}/adherents/7-au-bon-pain-pompey`);
assert.equal((biz.address as { addressLocality: string }).addressLocality, "Pompey");
assert.equal(biz.keywords, "pain, viennoiseries");
assert.ok(!("email" in biz));
@@ -124,7 +140,7 @@ describe("SEO — données structurées", () => {
const withSite = localBusinessJsonLd(BASE, member, [], "https://aubonpain.fr") as Record<string, unknown>;
assert.equal(withSite.url, "https://aubonpain.fr");
assert.equal(withSite.mainEntityOfPage, `${BASE}/adherents/7`);
assert.equal(withSite.mainEntityOfPage, `${BASE}/adherents/7-au-bon-pain-pompey`);
});
it("décrit une rencontre à venir avec ses places restantes", () => {
@@ -144,9 +160,9 @@ describe("SEO — données structurées", () => {
});
it("liste les adhérents de l'annuaire", () => {
const list = memberListJsonLd(BASE, "Annuaire", [{ id: 1, name: "A" }, { id: 2, name: "B" }]);
const list = memberListJsonLd(BASE, "Annuaire", [{ id: 1, name: "A" }, { id: 2, name: "Bar du Port", city: "Pompey" }]);
assert.equal(list.numberOfItems, 2);
assert.equal(list.itemListElement[1].url, `${BASE}/adherents/2`);
assert.equal(list.itemListElement[1].url, `${BASE}/adherents/2-bar-du-port-pompey`);
});
});