mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-24 07:38:04 +02:00
L'annuaire, les pages métier et la fiche adhérent montrent désormais la personne à joindre chez l'adhérent — nom, prénom, ligne directe — mais uniquement à un visiteur connecté. Trois colonnes sur `members` (`contact_first_name`, `contact_last_name`, `contact_phone`), saisies dans les deux formulaires de fiche via le composant partagé `MemberContactFields`. Le filtrage ne repose pas sur du CSS ni sur un rendu conditionnel tardif : aucune requête publique ne lit ces colonnes. Seules `getMemberContacts()` et `getMemberContact()` les rapatrient, et les pages ne les appellent qu'après un `getSession()` positif. Un visiteur anonyme ne reçoit donc rien — ni dans le HTML, ni dans le JSON-LD, ni dans les props du composant client de l'annuaire. `tests/security.test.ts` verrouille cette séparation. `src/lib/member-contact.ts` reste pur (composition du nom, lien `tel:`) et est couvert par `tests/member-contact.test.ts`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012TAK3c4jAUqxVMzy746wWQ
170 lines
6.7 KiB
TypeScript
170 lines
6.7 KiB
TypeScript
import type { Metadata } from "next";
|
|
import { Sparkle } from "@/components/Sparkle";
|
|
import { SiteHeader } from "@/components/SiteHeader";
|
|
import { SiteFooter } from "@/components/SiteFooter";
|
|
import { MembershipModalButton } from "@/components/MembershipModalButton";
|
|
import {
|
|
getActiveMembersWithCategory,
|
|
getAllCategories,
|
|
getCategoriesInUse,
|
|
getLiveBadgesByMember,
|
|
getMemberContacts,
|
|
} from "@/lib/queries";
|
|
import { getSession } from "@/lib/session";
|
|
import { CategoryLinks } from "@/components/CategoryLinks";
|
|
import { JsonLd } from "@/components/JsonLd";
|
|
import { breadcrumbJsonLd, memberListJsonLd, pageMetadata } from "@/lib/seo";
|
|
import { publicBaseUrl } from "@/lib/seo-server";
|
|
import { AnnuaireClient, type DirectoryMember } from "./AnnuaireClient";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
// Canonique sans paramètre de recherche : /annuaire?q=… ne crée pas de doublon.
|
|
export const metadata: Metadata = pageMetadata({
|
|
title: "Annuaire des commerçants du Bassin de Pompey",
|
|
description:
|
|
"Trouvez un commerçant, artisan ou entreprise du Bassin de Pompey (Pompey, Frouard, Liverdun, " +
|
|
"Champigneulles…) : coordonnées, horaires et bons plans.",
|
|
path: "/annuaire",
|
|
});
|
|
|
|
export default async function AnnuairePage({
|
|
searchParams,
|
|
}: {
|
|
searchParams: Promise<{ q?: string }>;
|
|
}) {
|
|
const { q } = await searchParams;
|
|
const [rawMembers, categories, badges, baseUrl, categoriesInUse, session] = await Promise.all([
|
|
getActiveMembersWithCategory(),
|
|
getAllCategories(),
|
|
getLiveBadgesByMember(),
|
|
publicBaseUrl(),
|
|
getCategoriesInUse(),
|
|
getSession(),
|
|
]);
|
|
|
|
// Coordonnées du référent : réservées aux visiteurs connectés. Pour un
|
|
// visiteur anonyme la requête n'est pas faite du tout, donc rien ne part
|
|
// dans le HTML ni vers le composant client.
|
|
const signedIn = Boolean(session?.user);
|
|
const contacts = signedIn ? await getMemberContacts() : null;
|
|
|
|
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: DirectoryMember[] = rawMembers.map((m) => ({
|
|
...m,
|
|
hasPromo: badgeByMember.has(m.id),
|
|
promoBadge: badgeByMember.get(m.id) ?? null,
|
|
contact: contacts?.get(m.id) ?? null,
|
|
}));
|
|
|
|
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" },
|
|
]),
|
|
memberListJsonLd(baseUrl, "Annuaire des adhérents Plein R", rawMembers),
|
|
]}
|
|
/>
|
|
|
|
<main className="container" style={{ paddingBottom: 56 }}>
|
|
{/* title block */}
|
|
<section style={{ position: "relative", padding: "14px 0 22px", overflow: "hidden" }}>
|
|
<Sparkle color="#E0A63C" size={18} style={{ top: 22, right: 30 }} duration={3.2} />
|
|
<Sparkle color="#6FB0C6" size={12} style={{ top: 60, right: 90 }} duration={2.7} delay={0.5} />
|
|
<div
|
|
style={{
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: 9,
|
|
background: "#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: "#E0A63C" }} />
|
|
{members.length} adhérents
|
|
</div>
|
|
<h1 className="font-display" style={{ fontWeight: 800, fontSize: "clamp(26px, 6vw, 40px)", letterSpacing: "-0.02em", margin: "0 0 8px", color: "#26201a" }}>
|
|
L'annuaire des commerçants & entreprises
|
|
</h1>
|
|
<p style={{ margin: 0, fontSize: 16.5, color: "#6c6150", maxWidth: 560 }}>
|
|
Trouvez un professionnel du Bassin de Pompey, découvrez sa fiche et ses bons plans.
|
|
</p>
|
|
{signedIn && (
|
|
<p style={{ margin: "10px 0 0", fontSize: 13.5, color: "#9a8d72", maxWidth: 560 }}>
|
|
Vous êtes connecté : les coordonnées du référent de chaque adhérent (nom, prénom,
|
|
téléphone) sont affichées sur les fiches ci-dessous.
|
|
</p>
|
|
)}
|
|
</section>
|
|
|
|
<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={{
|
|
marginTop: 34,
|
|
position: "relative",
|
|
background: "linear-gradient(120deg,#13324F,#1d4a72)",
|
|
borderRadius: 20,
|
|
padding: "34px 40px",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
gap: 28,
|
|
overflow: "hidden",
|
|
flexWrap: "wrap",
|
|
}}
|
|
>
|
|
<Sparkle color="#E0A63C" size={18} style={{ top: 20, right: 40 }} duration={3.2} />
|
|
<div>
|
|
<div style={{ fontSize: 12.5, letterSpacing: "0.12em", textTransform: "uppercase", color: "#9fc6e6", fontWeight: 700, marginBottom: 8 }}>
|
|
Vous n'êtes pas encore référencé ?
|
|
</div>
|
|
<h2 className="font-display" style={{ fontWeight: 800, fontSize: 26, margin: 0, color: "#fff" }}>
|
|
Rejoignez l'annuaire Plein R
|
|
</h2>
|
|
</div>
|
|
<MembershipModalButton
|
|
label="Adhérer à l'association"
|
|
className="font-display"
|
|
style={{ border: "none", background: "#E0A63C", color: "#33291D", fontWeight: 700, fontSize: 15.5, padding: "14px 26px", borderRadius: 12, whiteSpace: "nowrap" }}
|
|
/>
|
|
</section>
|
|
</main>
|
|
|
|
<SiteFooter />
|
|
</div>
|
|
);
|
|
}
|