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

+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",