mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-24 07:38:04 +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
@@ -137,6 +137,14 @@ site sans traitement supplémentaire.
|
|||||||
`app/manifest.ts` et `app/opengraph-image.tsx` (vignette 1200×630 générée).
|
`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
|
- `/backend`, `/login`, `/inscription/*` et les fiches non actives sont en
|
||||||
`NOINDEX` ; les pages publiques utilisent `<main>` et un seul `<h1>`.
|
`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
|
## Roles
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound, permanentRedirect } from "next/navigation";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Sparkle } from "@/components/Sparkle";
|
import { Sparkle } from "@/components/Sparkle";
|
||||||
import { SiteHeader } from "@/components/SiteHeader";
|
import { SiteHeader } from "@/components/SiteHeader";
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
memberPath,
|
memberPath,
|
||||||
metaDescription,
|
metaDescription,
|
||||||
pageMetadata,
|
pageMetadata,
|
||||||
|
parseMemberParam,
|
||||||
} from "@/lib/seo";
|
} from "@/lib/seo";
|
||||||
import { publicBaseUrl } from "@/lib/seo-server";
|
import { publicBaseUrl } from "@/lib/seo-server";
|
||||||
import {
|
import {
|
||||||
@@ -108,7 +109,7 @@ export async function generateMetadata({
|
|||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
}): Promise<Metadata> {
|
}): Promise<Metadata> {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const memberId = Number(id);
|
const memberId = parseMemberParam(id);
|
||||||
const member = memberId ? await getPublicMember(memberId) : null;
|
const member = memberId ? await getPublicMember(memberId) : null;
|
||||||
if (!member || member.status !== "active") {
|
if (!member || member.status !== "active") {
|
||||||
return { title: "Adhérent introuvable", robots: NOINDEX };
|
return { title: "Adhérent introuvable", robots: NOINDEX };
|
||||||
@@ -119,7 +120,7 @@ export async function generateMetadata({
|
|||||||
return pageMetadata({
|
return pageMetadata({
|
||||||
title: memberTitle(member),
|
title: memberTitle(member),
|
||||||
description: metaDescription(member.description, fallback),
|
description: metaDescription(member.description, fallback),
|
||||||
path: memberPath(member.id),
|
path: memberPath(member),
|
||||||
ogType: "profile",
|
ogType: "profile",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -130,12 +131,17 @@ export default async function FicheAdherentPage({
|
|||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const memberId = Number(id);
|
const memberId = parseMemberParam(id);
|
||||||
if (!memberId) notFound();
|
if (!memberId) notFound();
|
||||||
|
|
||||||
const member = await getPublicMember(memberId);
|
const member = await getPublicMember(memberId);
|
||||||
if (!member || member.status !== "active") notFound();
|
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 [promos, baseUrl] = await Promise.all([getMemberLivePromotions(memberId), publicBaseUrl()]);
|
||||||
const accent = member.accent ?? "#E0A63C";
|
const accent = member.accent ?? "#E0A63C";
|
||||||
|
|
||||||
@@ -174,7 +180,7 @@ export default async function FicheAdherentPage({
|
|||||||
breadcrumbJsonLd(baseUrl, [
|
breadcrumbJsonLd(baseUrl, [
|
||||||
{ name: "Accueil", path: "/" },
|
{ name: "Accueil", path: "/" },
|
||||||
{ name: "Annuaire", path: "/annuaire" },
|
{ name: "Annuaire", path: "/annuaire" },
|
||||||
{ name: member.name, path: memberPath(member.id) },
|
{ name: member.name, path: canonical },
|
||||||
]),
|
]),
|
||||||
localBusinessJsonLd(baseUrl, member, hours, normalizedWebsite),
|
localBusinessJsonLd(baseUrl, member, hours, normalizedWebsite),
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -1,28 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import Link from "next/link";
|
import { MemberCard, type MemberCardData } from "@/components/MemberCard";
|
||||||
import { VitrineImage } from "@/components/VitrineImage";
|
|
||||||
|
|
||||||
export type DirectoryMember = {
|
export type DirectoryMember = 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;
|
hasPromo: boolean;
|
||||||
promoBadge: string | null;
|
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({
|
export function AnnuaireClient({
|
||||||
members,
|
members,
|
||||||
categories,
|
categories,
|
||||||
@@ -190,46 +175,9 @@ export function AnnuaireClient({
|
|||||||
{/* grid / empty */}
|
{/* grid / empty */}
|
||||||
{list.length > 0 ? (
|
{list.length > 0 ? (
|
||||||
<div className="grid grid-3" style={{ gap: 20 }}>
|
<div className="grid grid-3" style={{ gap: 20 }}>
|
||||||
{list.map((m, i) => {
|
{list.map((m, i) => (
|
||||||
return (
|
<MemberCard key={m.id} m={m} index={i} />
|
||||||
<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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ background: "#fff", border: "1px dashed #d8cdb4", borderRadius: 18, padding: 54, textAlign: "center" }}>
|
<div style={{ background: "#fff", border: "1px dashed #d8cdb4", borderRadius: 18, padding: 54, textAlign: "center" }}>
|
||||||
|
|||||||
@@ -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'annuaire
|
||||||
|
</h2>
|
||||||
|
<CategoryLinks categories={categoriesInUse} currentSlug={category.slug} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<SiteFooter />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,8 +6,10 @@ import { MembershipModalButton } from "@/components/MembershipModalButton";
|
|||||||
import {
|
import {
|
||||||
getActiveMembersWithCategory,
|
getActiveMembersWithCategory,
|
||||||
getAllCategories,
|
getAllCategories,
|
||||||
|
getCategoriesInUse,
|
||||||
getLiveBadgesByMember,
|
getLiveBadgesByMember,
|
||||||
} from "@/lib/queries";
|
} from "@/lib/queries";
|
||||||
|
import { CategoryLinks } from "@/components/CategoryLinks";
|
||||||
import { JsonLd } from "@/components/JsonLd";
|
import { JsonLd } from "@/components/JsonLd";
|
||||||
import { breadcrumbJsonLd, memberListJsonLd, pageMetadata } from "@/lib/seo";
|
import { breadcrumbJsonLd, memberListJsonLd, pageMetadata } from "@/lib/seo";
|
||||||
import { publicBaseUrl } from "@/lib/seo-server";
|
import { publicBaseUrl } from "@/lib/seo-server";
|
||||||
@@ -30,11 +32,12 @@ export default async function AnnuairePage({
|
|||||||
searchParams: Promise<{ q?: string }>;
|
searchParams: Promise<{ q?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { q } = await searchParams;
|
const { q } = await searchParams;
|
||||||
const [rawMembers, categories, badges, baseUrl] = await Promise.all([
|
const [rawMembers, categories, badges, baseUrl, categoriesInUse] = await Promise.all([
|
||||||
getActiveMembersWithCategory(),
|
getActiveMembersWithCategory(),
|
||||||
getAllCategories(),
|
getAllCategories(),
|
||||||
getLiveBadgesByMember(),
|
getLiveBadgesByMember(),
|
||||||
publicBaseUrl(),
|
publicBaseUrl(),
|
||||||
|
getCategoriesInUse(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const badgeByMember = new Map<number, string | null>();
|
const badgeByMember = new Map<number, string | null>();
|
||||||
@@ -98,6 +101,19 @@ export default async function AnnuairePage({
|
|||||||
|
|
||||||
<AnnuaireClient members={members} categories={categories} initialQuery={q ?? ""} />
|
<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 */}
|
{/* join CTA */}
|
||||||
<section
|
<section
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { signOut } from "@/auth";
|
|||||||
import { getSession } from "@/lib/session";
|
import { getSession } from "@/lib/session";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { CATEGORY_PALETTE } from "@/db/categories";
|
import { CATEGORY_PALETTE } from "@/db/categories";
|
||||||
|
import { slugify } from "@/lib/slug";
|
||||||
import {
|
import {
|
||||||
activityLog,
|
activityLog,
|
||||||
categories,
|
categories,
|
||||||
@@ -45,15 +46,6 @@ import { SITE_SETTING_DEFAULTS } from "@/lib/site-settings";
|
|||||||
import type { AppRole } from "@/types/next-auth";
|
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") {
|
async function logActivity(message: string, dot = "#2C6FB3") {
|
||||||
// Le message agrège des saisies de tiers : on ne laisse passer que <strong>.
|
// 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("/backend");
|
||||||
revalidatePath("/");
|
revalidatePath("/");
|
||||||
revalidatePath("/annuaire");
|
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. */
|
/** Réseaux demandés pour une promo, dans l'ordre d'affichage. */
|
||||||
@@ -168,6 +160,7 @@ async function publishPromoShares(
|
|||||||
imageUrl: promotions.imageUrl,
|
imageUrl: promotions.imageUrl,
|
||||||
memberId: promotions.memberId,
|
memberId: promotions.memberId,
|
||||||
memberName: members.name,
|
memberName: members.name,
|
||||||
|
memberCity: members.city,
|
||||||
})
|
})
|
||||||
.from(promotions)
|
.from(promotions)
|
||||||
.leftJoin(members, eq(promotions.memberId, members.id))
|
.leftJoin(members, eq(promotions.memberId, members.id))
|
||||||
@@ -550,7 +543,7 @@ export async function updateMember(formData: FormData) {
|
|||||||
.where(eq(members.id, id));
|
.where(eq(members.id, id));
|
||||||
|
|
||||||
revalidatePath("/backend/adherents");
|
revalidatePath("/backend/adherents");
|
||||||
revalidatePath(`/adherents/${id}`);
|
revalidatePath("/adherents/[id]", "page");
|
||||||
revalidatePath("/");
|
revalidatePath("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1104,7 +1097,7 @@ export async function updateOwnProfile(formData: FormData) {
|
|||||||
.where(eq(members.id, memberId));
|
.where(eq(members.id, memberId));
|
||||||
|
|
||||||
revalidatePath("/backend/espace");
|
revalidatePath("/backend/espace");
|
||||||
revalidatePath(`/adherents/${memberId}`);
|
revalidatePath("/adherents/[id]", "page");
|
||||||
revalidatePath("/annuaire");
|
revalidatePath("/annuaire");
|
||||||
revalidatePath("/");
|
revalidatePath("/");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,11 +134,19 @@ export default async function EditMemberPage({
|
|||||||
<div style={{ marginTop: 16 }}>
|
<div style={{ marginTop: 16 }}>
|
||||||
<label className="field-label">Description (une ligne vide sépare les paragraphes)</label>
|
<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" }} />
|
<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>
|
||||||
|
|
||||||
<div style={{ marginTop: 16 }}>
|
<div style={{ marginTop: 16 }}>
|
||||||
<label className="field-label">Tags / spécialités (séparés par des virgules)</label>
|
<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" />
|
<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'affichent sur
|
||||||
|
votre fiche et sont transmis aux moteurs de recherche.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: 16 }}>
|
<div style={{ marginTop: 16 }}>
|
||||||
|
|||||||
@@ -231,11 +231,19 @@ export default async function EspacePage() {
|
|||||||
<div style={{ marginTop: 16 }}>
|
<div style={{ marginTop: 16 }}>
|
||||||
<label className="field-label">Description (une ligne vide sépare les paragraphes)</label>
|
<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" }} />
|
<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>
|
||||||
|
|
||||||
<div style={{ marginTop: 16 }}>
|
<div style={{ marginTop: 16 }}>
|
||||||
<label className="field-label">Tags / spécialités (séparés par des virgules)</label>
|
<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'affichent sur
|
||||||
|
votre fiche et sont transmis aux moteurs de recherche.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: 16 }}>
|
<div style={{ marginTop: 16 }}>
|
||||||
|
|||||||
@@ -948,6 +948,13 @@ img {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
margin-bottom: 6px;
|
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 ---- */
|
/* ---- Studio e-mail Plein R ---- */
|
||||||
.email-workbench {
|
.email-workbench {
|
||||||
|
|||||||
+2
-2
@@ -11,7 +11,7 @@ import { PromoImage } from "@/components/PromoImage";
|
|||||||
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
|
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
|
||||||
import { getActiveMemberCount, getLivePromotions, getRotatingActiveMembers } from "@/lib/queries";
|
import { getActiveMemberCount, getLivePromotions, getRotatingActiveMembers } from "@/lib/queries";
|
||||||
import { getSiteSettings, socialLinks } from "@/lib/site-settings";
|
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";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -235,7 +235,7 @@ export default async function AccueilPage() {
|
|||||||
{promos.map((p) => (
|
{promos.map((p) => (
|
||||||
<Link
|
<Link
|
||||||
key={p.id}
|
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"
|
className="lift-card"
|
||||||
style={{
|
style={{
|
||||||
textDecoration: "none",
|
textDecoration: "none",
|
||||||
|
|||||||
+22
-9
@@ -2,7 +2,8 @@ import type { MetadataRoute } from "next";
|
|||||||
import { asc, eq } from "drizzle-orm";
|
import { asc, eq } from "drizzle-orm";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { members } from "@/db/schema";
|
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";
|
import { publicBaseUrl } from "@/lib/seo-server";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -19,22 +20,34 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||||||
priority: route.priority,
|
priority: route.priority,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let rows: { id: number }[] = [];
|
let rows: { id: number; name: string; city: string | null }[] = [];
|
||||||
|
let categoriesInUse: { slug: string }[] = [];
|
||||||
try {
|
try {
|
||||||
rows = await db
|
[rows, categoriesInUse] = await Promise.all([
|
||||||
.select({ id: members.id })
|
db
|
||||||
.from(members)
|
.select({ id: members.id, name: members.name, city: members.city })
|
||||||
.where(eq(members.status, "active"))
|
.from(members)
|
||||||
.orderBy(asc(members.id));
|
.where(eq(members.status, "active"))
|
||||||
|
.orderBy(asc(members.id)),
|
||||||
|
getCategoriesInUse(),
|
||||||
|
]);
|
||||||
} catch {
|
} catch {
|
||||||
// Base indisponible : on livre au moins les pages statiques.
|
// 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) => ({
|
const fiches: MetadataRoute.Sitemap = rows.map((m) => ({
|
||||||
url: absoluteUrl(base, memberPath(m.id)),
|
url: absoluteUrl(base, memberPath(m)),
|
||||||
changeFrequency: "weekly",
|
changeFrequency: "weekly",
|
||||||
priority: 0.7,
|
priority: 0.7,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return [...statics, ...fiches];
|
return [...statics, ...metiers, ...fiches];
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { VitrineImage } from "./VitrineImage";
|
import { VitrineImage } from "./VitrineImage";
|
||||||
|
import { memberPath } from "@/lib/seo";
|
||||||
|
|
||||||
export type CarouselMember = {
|
export type CarouselMember = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -24,7 +25,7 @@ const FADE_MS = 450;
|
|||||||
function Card({ m, visible, index }: { m: CarouselMember; visible: boolean; index: number }) {
|
function Card({ m, visible, index }: { m: CarouselMember; visible: boolean; index: number }) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/adherents/${m.id}`}
|
href={memberPath(m)}
|
||||||
className="lift-card"
|
className="lift-card"
|
||||||
style={{
|
style={{
|
||||||
background: "#fff",
|
background: "#fff",
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export async function getLivePromotions(limit = 6) {
|
|||||||
validUntil: promotions.validUntil,
|
validUntil: promotions.validUntil,
|
||||||
memberId: promotions.memberId,
|
memberId: promotions.memberId,
|
||||||
memberName: members.name,
|
memberName: members.name,
|
||||||
|
memberCity: members.city,
|
||||||
})
|
})
|
||||||
.from(promotions)
|
.from(promotions)
|
||||||
.leftJoin(members, eq(promotions.memberId, members.id))
|
.leftJoin(members, eq(promotions.memberId, members.id))
|
||||||
@@ -174,3 +175,44 @@ export async function getActiveMemberCount(): Promise<number> {
|
|||||||
.where(eq(members.status, "active"));
|
.where(eq(members.status, "active"));
|
||||||
return Number(row?.total ?? 0);
|
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 { Metadata } from "next";
|
||||||
import type { DaySchedule, WeekDayKey } from "@/lib/member-profile";
|
import type { DaySchedule, WeekDayKey } from "@/lib/member-profile";
|
||||||
import type { SiteSettings, SocialLink } from "@/lib/site-settings";
|
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).
|
* 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}`}`;
|
return `${base}${path.startsWith("/") ? path : `/${path}`}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Chemin canonique d'une fiche adhérent. */
|
export type MemberPathInput = { id: number; name: string; city?: string | null };
|
||||||
export function memberPath(id: number): string {
|
|
||||||
return `/adherents/${id}`;
|
/**
|
||||||
|
* 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(",")
|
.split(",")
|
||||||
.map((s) => s.trim())
|
.map((s) => s.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
const url = absoluteUrl(baseUrl, memberPath(member.id));
|
const url = absoluteUrl(baseUrl, memberPath(member));
|
||||||
return compact({
|
return compact({
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "LocalBusiness",
|
"@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. */
|
/** Liste d'adhérents (annuaire) : chaque entrée pointe vers sa fiche. */
|
||||||
export function memberListJsonLd(baseUrl: string, name: string, members: ItemListEntry[]) {
|
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) => ({
|
itemListElement: members.map((m, index) => ({
|
||||||
"@type": "ListItem",
|
"@type": "ListItem",
|
||||||
position: index + 1,
|
position: index + 1,
|
||||||
url: absoluteUrl(baseUrl, memberPath(m.id)),
|
url: absoluteUrl(baseUrl, memberPath(m)),
|
||||||
name: m.name,
|
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,
|
siteUrl,
|
||||||
type SocialNetwork,
|
type SocialNetwork,
|
||||||
} from "./social-accounts";
|
} from "./social-accounts";
|
||||||
|
import { memberPath } from "./seo";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
configuredNetworks,
|
configuredNetworks,
|
||||||
@@ -37,12 +38,15 @@ export type PromoForSharing = {
|
|||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
memberId: number | null;
|
memberId: number | null;
|
||||||
memberName: string | null;
|
memberName: string | null;
|
||||||
|
memberCity?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function promoLink(promo: PromoForSharing): Promise<string | null> {
|
export async function promoLink(promo: PromoForSharing): Promise<string | null> {
|
||||||
const base = await siteUrl();
|
const base = await siteUrl();
|
||||||
if (!base) return null;
|
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. */
|
/** Texte du post, commun aux deux réseaux. */
|
||||||
|
|||||||
+20
-4
@@ -8,6 +8,9 @@ import {
|
|||||||
STATIC_ROUTES,
|
STATIC_ROUTES,
|
||||||
absoluteUrl,
|
absoluteUrl,
|
||||||
breadcrumbJsonLd,
|
breadcrumbJsonLd,
|
||||||
|
categoryPath,
|
||||||
|
memberPath,
|
||||||
|
parseMemberParam,
|
||||||
eventJsonLd,
|
eventJsonLd,
|
||||||
localBusinessJsonLd,
|
localBusinessJsonLd,
|
||||||
memberListJsonLd,
|
memberListJsonLd,
|
||||||
@@ -30,6 +33,19 @@ describe("SEO — URLs et descriptions", () => {
|
|||||||
assert.equal(absoluteUrl(BASE, "adherents/3"), `${BASE}/adherents/3`);
|
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", () => {
|
it("borne la meta description sur un mot entier", () => {
|
||||||
const long = "Boulangerie artisanale ".repeat(20);
|
const long = "Boulangerie artisanale ".repeat(20);
|
||||||
const out = metaDescription(long);
|
const out = metaDescription(long);
|
||||||
@@ -116,7 +132,7 @@ describe("SEO — données structurées", () => {
|
|||||||
};
|
};
|
||||||
const biz = localBusinessJsonLd(BASE, member, [], null) as Record<string, unknown>;
|
const biz = localBusinessJsonLd(BASE, member, [], null) as Record<string, unknown>;
|
||||||
assert.equal(biz["@type"], "LocalBusiness");
|
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.address as { addressLocality: string }).addressLocality, "Pompey");
|
||||||
assert.equal(biz.keywords, "pain, viennoiseries");
|
assert.equal(biz.keywords, "pain, viennoiseries");
|
||||||
assert.ok(!("email" in biz));
|
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>;
|
const withSite = localBusinessJsonLd(BASE, member, [], "https://aubonpain.fr") as Record<string, unknown>;
|
||||||
assert.equal(withSite.url, "https://aubonpain.fr");
|
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", () => {
|
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", () => {
|
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.numberOfItems, 2);
|
||||||
assert.equal(list.itemListElement[1].url, `${BASE}/adherents/2`);
|
assert.equal(list.itemListElement[1].url, `${BASE}/adherents/2-bar-du-port-pompey`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user