diff --git a/CLAUDE.md b/CLAUDE.md index b705d24..6bd4e42 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `
` et un seul `

`. +- **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 diff --git a/src/app/adherents/[id]/page.tsx b/src/app/adherents/[id]/page.tsx index d3fa2c9..e562905 100644 --- a/src/app/adherents/[id]/page.tsx +++ b/src/app/adherents/[id]/page.tsx @@ -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 { 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), ]} diff --git a/src/app/annuaire/AnnuaireClient.tsx b/src/app/annuaire/AnnuaireClient.tsx index 17a03e1..12251ed 100644 --- a/src/app/annuaire/AnnuaireClient.tsx +++ b/src/app/annuaire/AnnuaireClient.tsx @@ -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 ? (
- {list.map((m, i) => { - return ( - - - {m.categoryLabel && ( - - {m.categoryLabel} - - )} - {m.hasPromo && m.promoBadge && ( - - {m.promoBadge} - - )} - -
-

- {m.name} -

-

- {m.description} -

-
- - {[m.address, m.city].filter(Boolean).join(" · ")} -
-
- - ); - })} + {list.map((m, i) => ( + + ))}
) : (
diff --git a/src/app/annuaire/[categorie]/page.tsx b/src/app/annuaire/[categorie]/page.tsx new file mode 100644 index 0000000..0391705 --- /dev/null +++ b/src/app/annuaire/[categorie]/page.tsx @@ -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(); + 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 { + 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(); + 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 ( +
+ + + +
+ + +
+ + +
+ + {members.length} {members.length > 1 ? "adhérents" : "adhérent"} +
+

+ {title} +

+

{intro}

+
+ + {members.length > 0 ? ( +
+ {members.map((m, i) => ( + + ))} +
+ ) : ( +
+
+ Soyez le premier référencé dans cette catégorie +
+ +
+ )} + + {categoriesInUse.length > 0 && ( +
+

+ Autres métiers de l'annuaire +

+ +
+ )} +
+ + +
+ ); +} diff --git a/src/app/annuaire/page.tsx b/src/app/annuaire/page.tsx index 950df72..538038f 100644 --- a/src/app/annuaire/page.tsx +++ b/src/app/annuaire/page.tsx @@ -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(); @@ -98,6 +101,19 @@ export default async function AnnuairePage({ + {/* Pages métier : une URL indexable par catégorie, en plus du filtre. */} + {categoriesInUse.length > 0 && ( +
+

+ Parcourir par métier +

+

+ Tous les commerçants, artisans et entreprises du Bassin de Pompey, classés par activité. +

+ +
+ )} + {/* join CTA */}
. @@ -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("/"); } diff --git a/src/app/backend/adherents/[id]/page.tsx b/src/app/backend/adherents/[id]/page.tsx index b5e8ec4..2ff64b8 100644 --- a/src/app/backend/adherents/[id]/page.tsx +++ b/src/app/backend/adherents/[id]/page.tsx @@ -134,11 +134,19 @@ export default async function EditMemberPage({