Author SHA1 Message Date
Claude b860eeaf29 Référencement : Google Analytics et Search Console réglables en backoffice
Deux réglages dans Backend › Paramètres :

- `google_analytics_id` pose les balises gtag.js. Vide = rien n'est chargé.
  `src/lib/analytics.ts` (pur) n'accepte qu'un identifiant GA4 « G-… » : la
  saisie ne peut donc pas s'échapper du script en ligne. Les deux `<script>`
  portent le nonce de la CSP, et seules les pages publiques sont mesurées
  (`isTrackedPath` écarte `PRIVATE_PATHS`, le chemin venant du nouvel en-tête
  `x-pathname` du middleware — un layout ne connaît pas l'URL demandée).
- `google_site_verification` rend la balise de validation Search Console via
  `verification.google`, après contrôle du jeton.

La CSP gagne les hôtes Google Analytics en `connect-src` : elle est posée sur
le runtime Edge, qui ne peut pas lire la base pour savoir si la mesure est
active. `script-src` est inchangé, `'strict-dynamic'` couvrant les scripts que
gtag.js charge ensuite.

Le plan du site n'a besoin de rien : `/sitemap.xml` est déjà servi et annoncé
dans `/robots.txt`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7v5KcYUo2taDVDgehs7ZH
2026-09-15 05:12:21 +00:00
Claude 67cd9d6684 Affiche les coordonnées du référent aux visiteurs connectés
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
2026-09-14 11:31:59 +00:00
Claude fb09bf0e3a Accueil : préciser « entreprises de service »
Sur la page d'accueil, les mentions d'« entreprises » deviennent
« entreprises de service » : texte alternatif du logo, sous-titre du
hero, phrase d'accroche et appel à adhésion (les deux variantes,
avec et sans compteur d'adhérents).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkXREQkpPAKHFLgtnFFkCM
2026-09-14 11:04:53 +00:00
28 changed files with 2131 additions and 14 deletions

No files matched your search

+30
View File
@@ -221,6 +221,24 @@ même raison.
`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>`.
- **Mesure d'audience** : `src/lib/analytics.ts` est **pur**
(`tests/analytics.test.ts`). `normalizeMeasurementId()` n'accepte qu'un
identifiant GA4 (`G-…`, ni `UA-…` ni texte libre) : c'est la garde qui interdit
d'injecter une chaîne arbitraire dans le script en ligne. Le réglage
`google_analytics_id` (Paramètres) vide = **aucune balise posée**.
`<Analytics>` (`src/components/Analytics.tsx`) rend les deux `<script>` avec le
nonce de la CSP et n'agit que sur les pages publiques : `isTrackedPath()`
écarte `PRIVATE_PATHS`, le chemin venant de l'en-tête `x-pathname` posé par le
middleware (un layout ne connaît pas l'URL demandée). La CSP liste les hôtes
`*.google-analytics.com` / `*.analytics.google.com` / `*.googletagmanager.com`
en `connect-src` sans condition : elle est posée sur le runtime Edge, qui ne
peut pas interroger la base. `script-src` n'a pas besoin d'eux —
`'strict-dynamic'` couvre les scripts que gtag.js charge ensuite.
- **Search Console** : le réglage `google_site_verification` alimente
`verification.google` dans `app/layout.tsx` (balise `<meta>`).
`normalizeSiteVerification()` refuse la balise `<meta>` complète collée telle
quelle. Le plan du site est déjà servi sur `/sitemap.xml` et annoncé dans
`/robots.txt` : il n'y a aucun fichier à déposer, seulement l'URL à déclarer.
- **Tags adhérents** : `src/lib/tags.ts` (pur) porte un vocabulaire par métier
(`CATEGORY_TAGS`, un par slug du référentiel, vérifié par `tests/tags.test.ts`)
et un vocabulaire transversal détecté dans la description. `autoTags()` ne
@@ -248,6 +266,18 @@ même raison.
(`src/lib/promo-categories.ts`, groupes pour `<optgroup>`), pas le métier de
l'adhérent ; `defaultPromoCategory(slug)` pré-sélectionne depuis le métier.
- **Référent** : `members.contact_first_name` / `contact_last_name` /
`contact_phone` portent la personne à joindre et sa ligne directe. Ils sont
**réservés aux visiteurs connectés** : aucune requête publique ne les lit
(`tests/security.test.ts` le verrouille), seules `getMemberContacts()` /
`getMemberContact()` les rapatrient, et uniquement après un `getSession()`
positif. L'annuaire, les pages métier et la fiche affichent alors un bloc
« Contact adhérent » ; hors session la requête n'est pas faite, donc rien
n'est masqué en CSS, rien ne part dans le HTML ni dans le JSON-LD.
`src/lib/member-contact.ts` est **pur** (`tests/member-contact.test.ts`) :
`memberContact()` compose « Prénom Nom » et renvoie `null` s'il n'y a rien à
montrer, `telHref()` fabrique le lien `tel:`. Les deux formulaires de fiche
(espace adhérent et écran staff) partagent `MemberContactFields`.
- `members.email` est l'e-mail **administratif** (identifiant de connexion à la
création, échanges avec l'association) ; `members.contact_email` est l'e-mail
**public** de la fiche, saisi par l'adhérent ou le staff. La fiche et le
+3
View File
@@ -0,0 +1,3 @@
ALTER TABLE "members" ADD COLUMN "contact_first_name" varchar(120);--> statement-breakpoint
ALTER TABLE "members" ADD COLUMN "contact_last_name" varchar(120);--> statement-breakpoint
ALTER TABLE "members" ADD COLUMN "contact_phone" varchar(40);
File diff suppressed because it is too large. Load diff
+7
View File
@@ -120,6 +120,13 @@
"when": 1788692576315,
"tag": "0016_tiresome_korg",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1789385157231,
"tag": "0017_regular_devos",
"breakpoints": true
}
]
}
+34 -2
View File
@@ -7,7 +7,9 @@ import { SiteFooter } from "@/components/SiteFooter";
import { VitrineImage } from "@/components/VitrineImage";
import { PromoImage } from "@/components/PromoImage";
import { JsonLd } from "@/components/JsonLd";
import { getMemberLivePromotions, getPublicMember } from "@/lib/queries";
import { getMemberContact, getMemberLivePromotions, getPublicMember } from "@/lib/queries";
import { getSession } from "@/lib/session";
import { telHref } from "@/lib/member-contact";
import {
NOINDEX,
breadcrumbJsonLd,
@@ -144,7 +146,16 @@ export default async function FicheAdherentPage({
const canonical = memberPath(member);
if (`/adherents/${id}` !== canonical) permanentRedirect(canonical);
const [promos, baseUrl] = await Promise.all([getMemberLivePromotions(memberId), publicBaseUrl()]);
const [promos, baseUrl, session] = await Promise.all([
getMemberLivePromotions(memberId),
publicBaseUrl(),
getSession(),
]);
// Nom, prénom et ligne directe du référent : réservés aux visiteurs
// connectés. La requête n'est pas faite pour un visiteur anonyme, et ces
// champs restent hors du JSON-LD et des métadonnées de la fiche.
const contact = session?.user ? await getMemberContact(memberId) : null;
const contactTel = telHref(contact?.phone);
const accent = member.accent ?? "#E0A63C";
const fullAddress = [member.address, [member.postalCode, member.city].filter(Boolean).join(" ")]
@@ -384,6 +395,27 @@ export default async function FicheAdherentPage({
<div className="practical-empty">Coordonnées non communiquées.</div>
)}
</div>
{contact && (
<div className="contact-referent">
<div className="contact-referent__label">
<span style={{ width: 7, height: 7, borderRadius: "50%", background: accent }} />
Contact adhérent
</div>
{contact.name && <div className="contact-referent__name">{contact.name}</div>}
{contact.phone &&
(contactTel ? (
<a className="contact-referent__phone" href={contactTel}>
{contact.phone}
</a>
) : (
<div className="contact-referent__phone">{contact.phone}</div>
))}
<div className="contact-referent__hint">
Visible uniquement parce que vous êtes connecté : ces coordonnées ne sont pas
publiées sur le site.
</div>
</div>
)}
</section>
</div>
</section>
+4 -1
View File
@@ -2,10 +2,13 @@
import { useMemo, useState } from "react";
import { MemberCard, type MemberCardData } from "@/components/MemberCard";
import type { MemberContact } from "@/lib/member-contact";
export type DirectoryMember = MemberCardData & {
hasPromo: boolean;
promoBadge: string | null;
/** Référent de l'adhérent, `null` tant que le visiteur n'est pas connecté. */
contact: MemberContact | null;
};
export function AnnuaireClient({
@@ -176,7 +179,7 @@ export function AnnuaireClient({
{list.length > 0 ? (
<div className="grid grid-3" style={{ gap: 20 }}>
{list.map((m, i) => (
<MemberCard key={m.id} m={m} index={i} />
<MemberCard key={m.id} m={m} index={i} contact={m.contact} />
))}
</div>
) : (
+8 -2
View File
@@ -13,7 +13,9 @@ import {
getCategoriesInUse,
getCategoryBySlug,
getLiveBadgesByMember,
getMemberContacts,
} from "@/lib/queries";
import { getSession } from "@/lib/session";
import {
NOINDEX,
breadcrumbJsonLd,
@@ -77,12 +79,16 @@ export default async function CategoriePage({ params }: { params: Params }) {
const category = await getCategoryBySlug(categorie);
if (!category) notFound();
const [rawMembers, badges, categoriesInUse, baseUrl] = await Promise.all([
const [rawMembers, badges, categoriesInUse, baseUrl, session] = await Promise.all([
getActiveMembersByCategory(category.id),
getLiveBadgesByMember(),
getCategoriesInUse(),
publicBaseUrl(),
getSession(),
]);
// Même règle que sur l'annuaire : le référent n'est lu que pour un visiteur
// connecté, et reste hors du JSON-LD (construit sur `members`).
const contacts = session?.user ? 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);
@@ -148,7 +154,7 @@ export default async function CategoriePage({ params }: { params: Params }) {
{members.length > 0 ? (
<div className="grid grid-3" style={{ gap: 20 }}>
{members.map((m, i) => (
<MemberCard key={m.id} m={m} index={i} />
<MemberCard key={m.id} m={m} index={i} contact={contacts?.get(m.id) ?? null} />
))}
</div>
) : (
+17 -1
View File
@@ -8,7 +8,9 @@ import {
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";
@@ -32,14 +34,21 @@ export default async function AnnuairePage({
searchParams: Promise<{ q?: string }>;
}) {
const { q } = await searchParams;
const [rawMembers, categories, badges, baseUrl, categoriesInUse] = await Promise.all([
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)) {
@@ -51,6 +60,7 @@ export default async function AnnuairePage({
...m,
hasPromo: badgeByMember.has(m.id),
promoBadge: badgeByMember.get(m.id) ?? null,
contact: contacts?.get(m.id) ?? null,
}));
return (
@@ -97,6 +107,12 @@ export default async function AnnuairePage({
<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 ?? ""} />
+7
View File
@@ -525,6 +525,9 @@ export async function updateMember(formData: FormData) {
name: String(formData.get("name") ?? "").trim(),
email: String(formData.get("email") ?? "").trim(),
contactEmail: String(formData.get("contactEmail") ?? "").trim().toLowerCase() || null,
contactFirstName: String(formData.get("contactFirstName") ?? "").trim() || null,
contactLastName: String(formData.get("contactLastName") ?? "").trim() || null,
contactPhone: String(formData.get("contactPhone") ?? "").trim() || null,
categoryId,
city,
address: String(formData.get("address") ?? "").trim() || null,
@@ -543,6 +546,7 @@ export async function updateMember(formData: FormData) {
revalidatePath("/backend/adherents");
revalidatePath("/adherents/[id]", "page");
revalidatePath("/annuaire");
revalidatePath("/");
}
@@ -1095,6 +1099,9 @@ export async function updateOwnProfile(formData: FormData) {
.set({
name,
contactEmail: String(formData.get("contactEmail") ?? "").trim().toLowerCase() || null,
contactFirstName: String(formData.get("contactFirstName") ?? "").trim() || null,
contactLastName: String(formData.get("contactLastName") ?? "").trim() || null,
contactPhone: String(formData.get("contactPhone") ?? "").trim() || null,
description,
address: String(formData.get("address") ?? "").trim() || null,
postalCode: String(formData.get("postalCode") ?? "").trim() || null,
+7
View File
@@ -6,6 +6,7 @@ import { db } from "@/db";
import { categories, members, users } from "@/db/schema";
import { can } from "@/lib/rbac";
import { ImageField } from "@/components/ImageField";
import { MemberContactFields } from "@/components/MemberContactFields";
import { HoursEditor } from "@/components/HoursEditor";
import { TagsField } from "@/components/TagsField";
import { communeOptions } from "@/lib/communes";
@@ -132,6 +133,12 @@ export default async function EditMemberPage({
</div>
</div>
<MemberContactFields
firstName={member.contactFirstName}
lastName={member.contactLastName}
phone={member.contactPhone}
/>
<div className="grid grid-2" style={{ gap: 16, marginTop: 16 }}>
<ImageField name="coverUrl" label="Image d'entête (bannière)" defaultValue={member.coverUrl ?? ""} height={130} />
<ImageField name="logoUrl" label="Logo" defaultValue={member.logoUrl ?? ""} height={130} fit="contain" />
+7
View File
@@ -9,6 +9,7 @@ import { ImageField } from "@/components/ImageField";
import { ImageConsentForm } from "@/components/ImageConsentForm";
import { HoursEditor } from "@/components/HoursEditor";
import { TagsField } from "@/components/TagsField";
import { MemberContactFields } from "@/components/MemberContactFields";
import { communeOptions } from "@/lib/communes";
import { saveImageConsent, updateOwnProfile } from "../actions";
import { EspaceHeader } from "./EspaceHeader";
@@ -167,6 +168,12 @@ export default async function EspacePage() {
</div>
</div>
<MemberContactFields
firstName={profile.contactFirstName}
lastName={profile.contactLastName}
phone={profile.contactPhone}
/>
<div className="grid grid-2" style={{ gap: 16, marginTop: 16 }}>
<ImageField name="coverUrl" label="Image d'entête (bannière)" defaultValue={profile.coverUrl ?? ""} height={130} />
<ImageField name="logoUrl" label="Logo" defaultValue={profile.logoUrl ?? ""} height={130} fit="contain" />
+26
View File
@@ -98,6 +98,32 @@ export default async function ParametresPage() {
</div>
</section>
<section style={panel}>
<h2 className="font-display" style={title}>Référencement & mesure d&apos;audience</h2>
<div className="grid grid-2" style={{ gap: 16 }}>
<Field
label="Identifiant Google Analytics (GA4)"
name="google_analytics_id"
value={settings.google_analytics_id}
placeholder="G-XXXXXXXXXX"
/>
<Field
label="Jeton Search Console (balise HTML)"
name="google_site_verification"
value={settings.google_site_verification}
placeholder="Valeur de l'attribut content"
/>
</div>
<div style={{ marginTop: 14, color: "#9a6638", fontSize: 13, lineHeight: 1.6 }}>
Laissez vide pour ne rien charger : sans identifiant, aucune balise Google n&apos;est
posée sur le site. La mesure d&apos;audience ne couvre que les pages publiques — ni le
backoffice, ni l&apos;espace adhérent. Le plan du site est déjà publié sur{" "}
<code>/sitemap.xml</code> et annoncé dans <code>/robots.txt</code> : il suffit de le
déclarer dans Search Console. Pensez à mentionner Google Analytics dans la page
Confidentialité avant de l&apos;activer.
</div>
</section>
<button type="submit" style={submitButton}>Enregistrer les paramètres</button>
</form>
);
+42
View File
@@ -1403,6 +1403,48 @@ img {
color: var(--muted-4);
font-size: 13px;
}
/* Référent de l'adhérent : rendu seulement quand le visiteur est connecté —
le HTML public ne contient jamais ce bloc, rien n'est masqué en CSS. */
.contact-referent {
margin-top: 12px;
padding: 12px;
border: 1px dashed var(--border);
border-radius: 12px;
background: #fff;
}
.contact-referent__label {
display: flex;
align-items: center;
gap: 7px;
color: var(--muted-2);
font-size: 10.5px;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.contact-referent__name {
margin-top: 7px;
color: var(--ink-strong);
font-size: 14px;
font-weight: 700;
}
.contact-referent__phone {
display: inline-block;
margin-top: 3px;
color: var(--blue);
font-size: 13.5px;
font-weight: 700;
text-decoration: none;
}
.contact-referent__phone:hover {
text-decoration: underline;
}
.contact-referent__hint {
margin-top: 7px;
color: var(--muted-4);
font-size: 11.5px;
line-height: 1.45;
}
/* ---- Éditeur hebdomadaire des horaires ---- */
.hours-editor {
+11 -1
View File
@@ -1,4 +1,5 @@
import type { Metadata, Viewport } from "next";
import { Analytics } from "@/components/Analytics";
import { JsonLd } from "@/components/JsonLd";
import {
INDEX_FOLLOW,
@@ -11,6 +12,7 @@ import {
organizationJsonLd,
webSiteJsonLd,
} from "@/lib/seo";
import { normalizeSiteVerification } from "@/lib/analytics";
import { metadataBase, publicBaseUrl } from "@/lib/seo-server";
import { getSiteSettings, socialLinks } from "@/lib/site-settings";
import "./globals.css";
@@ -33,8 +35,14 @@ export const viewport: Viewport = {
* relatives (canonique, Open Graph, image de partage).
*/
export async function generateMetadata(): Promise<Metadata> {
const [base, settings] = await Promise.all([
metadataBase(),
getSiteSettings().catch(() => null),
]);
// Jeton « balise HTML » de Search Console, collé dans Backend › Paramètres.
const google = normalizeSiteVerification(settings?.google_site_verification);
return {
metadataBase: await metadataBase(),
metadataBase: base,
title: {
default: SITE_TITLE,
template: `%s · ${SITE_NAME}`,
@@ -63,6 +71,7 @@ export async function generateMetadata(): Promise<Metadata> {
description: SITE_DESCRIPTION,
},
robots: INDEX_FOLLOW,
verification: google ? { google } : undefined,
icons: {
icon: [{ url: "/assets/logo.png", type: "image/png" }],
apple: [{ url: "/assets/logo.png", type: "image/png" }],
@@ -108,6 +117,7 @@ export default async function RootLayout({
<body>
{children}
<JsonLd data={structuredData} />
<Analytics measurementId={settings?.google_analytics_id ?? ""} />
</body>
</html>
);
+5 -5
View File
@@ -58,7 +58,7 @@ export default async function AccueilPage() {
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="/assets/logo.png"
alt="Plein R — Association des commerçants et entreprises du Bassin de Pompey"
alt="Plein R — Association des commerçants et entreprises de service du Bassin de Pompey"
className="floatlogo"
style={{
width: "100%",
@@ -107,10 +107,10 @@ export default async function AccueilPage() {
className="font-display"
style={{ fontWeight: 600, fontSize: 17, color: "#9a6638", margin: "0 0 16px" }}
>
Association des commerçants &amp; entreprises du Bassin de Pompey
Association des commerçants &amp; entreprises de service du Bassin de Pompey
</div>
<p style={{ fontSize: 17, lineHeight: 1.6, color: "#6c6150", margin: "0 0 24px", maxWidth: 520 }}>
Boulangers, artisans, restaurateurs, entreprises… retrouvez tous nos adhérents, profitez de
Boulangers, artisans, restaurateurs, entreprises de service… retrouvez tous nos adhérents, profitez de
leurs bons plans et faites vivre le commerce de proximité.
</p>
<form
@@ -359,8 +359,8 @@ export default async function AccueilPage() {
</h2>
<p style={{ margin: "0 auto 22px", fontSize: 15.5, color: "#f4e6d3", maxWidth: 480 }}>
{memberCount > 0
? `Rejoignez les ${memberCount} commerçants et entreprises adhérents qui font réseau, se rencontrent et réussissent ensemble.`
: "Rejoignez les commerçants et entreprises qui font réseau, se rencontrent et réussissent ensemble."}
? `Rejoignez les ${memberCount} commerçants et entreprises de service adhérents qui font réseau, se rencontrent et réussissent ensemble.`
: "Rejoignez les commerçants et entreprises de service qui font réseau, se rencontrent et réussissent ensemble."}
</p>
<MembershipModalButton
label="Devenir adhérent"
+26
View File
@@ -0,0 +1,26 @@
import { headers } from "next/headers";
import { gtagConfigScript, gtagSrc, isTrackedPath, normalizeMeasurementId } from "@/lib/analytics";
/**
* Balises Google Analytics 4, posées uniquement si l'identifiant de mesure est
* renseigné dans Backend › Paramètres et si la page est publique.
*
* Les deux scripts portent le nonce de la CSP (`src/middleware.ts`) ; le
* chemin est lu dans l'en-tête `x-pathname` que le middleware recopie, un
* layout n'ayant pas accès à l'URL demandée.
*/
export async function Analytics({ measurementId }: { measurementId: string }) {
const id = normalizeMeasurementId(measurementId);
if (!id) return null;
const requestHeaders = await headers();
if (!isTrackedPath(requestHeaders.get("x-pathname") ?? "/")) return null;
const nonce = requestHeaders.get("x-nonce") ?? undefined;
return (
<>
<script async src={gtagSrc(id)} nonce={nonce} />
<script nonce={nonce} dangerouslySetInnerHTML={{ __html: gtagConfigScript(id) }} />
</>
);
}
+30 -1
View File
@@ -1,6 +1,7 @@
import Link from "next/link";
import { VitrineImage } from "@/components/VitrineImage";
import { memberPath } from "@/lib/seo";
import type { MemberContact } from "@/lib/member-contact";
export type MemberCardData = {
id: number;
@@ -44,8 +45,21 @@ const clampLines = (lines: number) =>
/**
* 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.
*
* `contact` (nom du référent, ligne directe) n'est passé qu'aux visiteurs
* connectés : la page ne le rapatrie pas du tout pour un visiteur anonyme, il
* n'y a donc rien à masquer en CSS. Le numéro reste en texte simple — un
* `tel:` imbriqué dans le lien de la carte serait un lien dans un lien.
*/
export function MemberCard({ m, index = 0 }: { m: MemberCardData; index?: number }) {
export function MemberCard({
m,
index = 0,
contact = null,
}: {
m: MemberCardData;
index?: number;
contact?: MemberContact | null;
}) {
const location = [m.address, m.city].filter(Boolean).join(" · ");
return (
<Link
@@ -87,6 +101,21 @@ export function MemberCard({ m, index = 0 }: { m: MemberCardData; index?: number
<span style={{ width: 7, height: 7, borderRadius: "50%", background: m.accent ?? "#E0A63C", flex: "0 0 auto" }} />
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{location}</span>
</div>
{contact && (
<div style={{ marginTop: 10, borderTop: "1px dashed #e6dcc6", paddingTop: 10 }}>
<div style={{ fontSize: 10.5, letterSpacing: "0.1em", textTransform: "uppercase", fontWeight: 800, color: "#9a8d72", marginBottom: 4 }}>
Contact adhérent
</div>
{contact.name && (
<div style={{ fontSize: 13, fontWeight: 700, color: "#3c3322", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{contact.name}
</div>
)}
{contact.phone && (
<div style={{ fontSize: 12.5, color: "#6c6150", marginTop: 2 }}>{contact.phone}</div>
)}
</div>
)}
</div>
</Link>
);
+52
View File
@@ -0,0 +1,52 @@
/**
* Coordonnées du référent de l'adhérent (nom, prénom, ligne directe), dans les
* deux formulaires de fiche : l'espace adhérent et l'écran staff.
*
* Ces champs ne sont jamais publiés : l'annuaire et la fiche publique ne les
* affichent qu'à un visiteur connecté (`src/lib/member-contact.ts`).
*/
export function MemberContactFields({
firstName,
lastName,
phone,
}: {
firstName: string | null;
lastName: string | null;
phone: string | null;
}) {
return (
<fieldset
style={{
marginTop: 16,
border: "1px solid #f0e8d6",
borderRadius: 12,
background: "#faf7ef",
padding: 16,
minWidth: 0,
}}
>
<legend className="field-label" style={{ padding: "0 6px" }}>
Contact adhérent
</legend>
<p className="field-hint" style={{ margin: "0 0 12px" }}>
La personne à joindre et sa ligne directe. Affiché sur l&apos;annuaire et sur la fiche
<strong> uniquement aux visiteurs connectés</strong> : rien de tout cela n&apos;apparaît sur
le site public ni dans les moteurs de recherche.
</p>
<div className="grid grid-2" style={{ gap: 16 }}>
<div>
<label className="field-label">Prénom</label>
<input name="contactFirstName" defaultValue={firstName ?? ""} className="field" />
</div>
<div>
<label className="field-label">Nom</label>
<input name="contactLastName" defaultValue={lastName ?? ""} className="field" />
</div>
<div>
<label className="field-label">Téléphone direct</label>
<input name="contactPhone" defaultValue={phone ?? ""} className="field" placeholder="06 12 ..." />
</div>
</div>
</fieldset>
);
}
+9
View File
@@ -19,6 +19,9 @@ export function demoMembers(catId: (slug: string) => number | null) {
address: "12 rue de la République",
postalCode: "54390",
phone: "03 83 49 00 00",
contactFirstName: "Julien",
contactLastName: "Marchal",
contactPhone: "06 12 34 56 78",
website: "https://www.aubonpain-frouard.fr",
memberSince: 2021,
tags: "Levain naturel, Produits locaux, Fait maison, Sans conservateur",
@@ -36,6 +39,9 @@ export function demoMembers(catId: (slug: string) => number | null) {
address: "3 place Stanislas",
postalCode: "54340",
phone: "03 83 24 00 00",
contactFirstName: "Sophie",
contactLastName: "Renard",
contactPhone: "06 98 76 54 32",
memberSince: 2019,
tags: "Cuisine de saison, Brunch, Terrasse, Produits frais",
hours: "Lundi – Vendredi|9h – 18h\nSamedi|9h – 19h\nDimanche|10h – 15h",
@@ -49,6 +55,9 @@ export function demoMembers(catId: (slug: string) => number | null) {
categoryId: catId("mode-beaute"),
city: "Champigneulles",
address: "7 av. des Tilleuls",
contactFirstName: "Émilie",
contactLastName: "Bertrand",
contactPhone: "06 45 67 89 01",
description: "Salon de coiffure & soins, sur rendez-vous du mardi au samedi.",
status: "active" as const,
highlighted: true,
+7
View File
@@ -62,6 +62,13 @@ export const members = pgTable("members", {
email: varchar("email", { length: 200 }).notNull(),
// E-mail public de la fiche ; à vide, la fiche retombe sur `email`.
contactEmail: varchar("contact_email", { length: 200 }),
// Référent de l'adhérent : la personne à joindre au sein du commerce, avec
// sa ligne directe. Contrairement aux coordonnées de l'établissement, ces
// trois champs ne sont montrés qu'aux visiteurs connectés (cf.
// `src/lib/member-contact.ts`), jamais dans le HTML public ni le JSON-LD.
contactFirstName: varchar("contact_first_name", { length: 120 }),
contactLastName: varchar("contact_last_name", { length: 120 }),
contactPhone: varchar("contact_phone", { length: 40 }),
categoryId: integer("category_id").references(() => categories.id),
city: varchar("city", { length: 120 }),
address: varchar("address", { length: 240 }),
+70
View File
@@ -0,0 +1,70 @@
import { PRIVATE_PATHS } from "@/lib/seo";
/**
* Mesure d'audience Google Analytics 4.
*
* Module **pur** (verrouillé par `tests/analytics.test.ts`) : il ne sait ni lire
* la base ni rendre du HTML, il décide seulement ce qui part dans la page.
* L'identifiant vient du réglage `google_analytics_id` (Backend › Paramètres) :
* tant qu'il est vide, aucune balise n'est posée et aucun appel n'est fait.
*/
/** Identifiant de mesure GA4 : « G- » suivi de l'identifiant du flux. */
const MEASUREMENT_ID = /^G-[A-Z0-9]{4,24}$/;
/**
* Valide la saisie de l'administrateur avant qu'elle n'atteigne la page.
*
* Renvoie `""` pour tout ce qui n'est pas un identifiant GA4 : c'est la garde
* qui interdit d'injecter une chaîne arbitraire dans le script en ligne — un
* identifiant validé ne contient ni guillemet, ni `<`, ni `</script>`.
* Les identifiants Universal Analytics (`UA-…`), arrêtés depuis 2023, sont
* refusés : ils ne collectent plus rien.
*/
export function normalizeMeasurementId(raw: string | null | undefined): string {
const value = (raw ?? "").trim().toUpperCase();
return MEASUREMENT_ID.test(value) ? value : "";
}
/** URL du chargeur gtag.js pour un identifiant **déjà normalisé**. */
export function gtagSrc(measurementId: string): string {
const id = normalizeMeasurementId(measurementId);
return id ? `https://www.googletagmanager.com/gtag/js?id=${id}` : "";
}
/**
* Script d'amorçage de gtag.js. Il porte le nonce de la CSP comme le reste des
* scripts du site ; les scripts que gtag.js charge ensuite passent grâce à
* `'strict-dynamic'`.
*/
export function gtagConfigScript(measurementId: string): string {
const id = normalizeMeasurementId(measurementId);
if (!id) return "";
return [
"window.dataLayer=window.dataLayer||[];",
"function gtag(){dataLayer.push(arguments);}",
"gtag('js',new Date());",
`gtag('config','${id}');`,
].join("");
}
/**
* Seules les pages publiques sont mesurées.
*
* L'espace adhérent et le backoffice portent des identifiants de membres et de
* promotions dans leurs URLs : les envoyer à un tiers n'apporterait rien et
* ferait sortir des données personnelles du site.
*/
export function isTrackedPath(pathname: string): boolean {
const path = pathname.split("?")[0] ?? "/";
return !PRIVATE_PATHS.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
}
/** Jeton de validation Search Console (balise `google-site-verification`). */
export function normalizeSiteVerification(raw: string | null | undefined): string {
const value = (raw ?? "").trim();
// Google livre un jeton alphanumérique d'une quarantaine de caractères ; on
// refuse le reste — dont la balise <meta> complète, collée telle quelle —
// plutôt que de recopier dans le <head> une chaîne venue de n'importe où.
return /^[A-Za-z0-9_-]{20,128}$/.test(value) ? value : "";
}
+56
View File
@@ -0,0 +1,56 @@
/**
* Référent d'un adhérent : la personne à joindre, sa ligne directe.
*
* Ces informations sont **réservées aux visiteurs connectés** : elles ne
* doivent jamais partir dans le HTML public, le JSON-LD ou les métadonnées.
* Le module est pur — le filtrage par session se fait chez l'appelant, qui
* n'appelle `memberContact()` qu'après avoir vérifié la session, et verrouillé
* par `tests/member-contact.test.ts`.
*/
export type MemberContactSource = {
contactFirstName?: string | null;
contactLastName?: string | null;
contactPhone?: string | null;
};
export type MemberContact = {
/** « Prénom Nom », ou l'un des deux seulement. */
name: string | null;
phone: string | null;
};
function clean(value: string | null | undefined) {
const trimmed = (value ?? "").replace(/\s+/g, " ").trim();
return trimmed || null;
}
/** « Prénom Nom » à partir des deux champs, `null` si les deux sont vides. */
export function contactName(source: MemberContactSource): string | null {
return clean([clean(source.contactFirstName), clean(source.contactLastName)].filter(Boolean).join(" "));
}
/**
* Le référent affichable, ou `null` s'il n'y a rien à montrer : le bloc
* « Contact adhérent » disparaît alors plutôt que d'afficher un cadre vide.
*/
export function memberContact(source: MemberContactSource): MemberContact | null {
const name = contactName(source);
const phone = clean(source.contactPhone);
if (!name && !phone) return null;
return { name, phone };
}
/**
* `tel:` à partir d'un numéro saisi librement (« 03 83 24 .. », « +33 3 83 … »).
* Seuls les chiffres sont conservés, avec l'indicatif international s'il ouvre
* le numéro ; une saisie sans aucun chiffre ne produit pas de lien.
*/
export function telHref(phone: string | null | undefined): string | null {
const raw = clean(phone);
if (!raw) return null;
const international = raw.startsWith("+");
const digits = raw.replace(/\D/g, "");
if (!digits) return null;
return `tel:${international ? "+" : ""}${digits}`;
}
+41
View File
@@ -1,6 +1,7 @@
import { and, asc, count, desc, eq, sql } from "drizzle-orm";
import { db } from "@/db";
import { categories, members, promotions } from "@/db/schema";
import { memberContact, type MemberContact } from "@/lib/member-contact";
/**
* Promotion réellement visible par le public : validée **et** dans sa période
@@ -135,6 +136,46 @@ export async function getPublicMember(id: number) {
return m ?? null;
}
/**
* Référents des adhérents actifs, indexés par fiche.
*
* Volontairement séparé des lectures publiques (`getActiveMembersWithCategory`,
* `getPublicMember`) : nom, prénom et ligne directe ne sont réservés aux
* visiteurs connectés que parce qu'aucune requête publique ne les rapatrie.
* L'appelant vérifie la session **avant** d'appeler.
*/
export async function getMemberContacts(): Promise<Map<number, MemberContact>> {
const rows = await db
.select({
id: members.id,
contactFirstName: members.contactFirstName,
contactLastName: members.contactLastName,
contactPhone: members.contactPhone,
})
.from(members)
.where(eq(members.status, "active"));
const byMember = new Map<number, MemberContact>();
for (const row of rows) {
const contact = memberContact(row);
if (contact) byMember.set(row.id, contact);
}
return byMember;
}
/** Référent d'une fiche. Même règle que `getMemberContacts()` : session vérifiée d'abord. */
export async function getMemberContact(id: number): Promise<MemberContact | null> {
const [row] = await db
.select({
contactFirstName: members.contactFirstName,
contactLastName: members.contactLastName,
contactPhone: members.contactPhone,
})
.from(members)
.where(eq(members.id, id));
return row ? memberContact(row) : null;
}
export async function getMemberLivePromotions(memberId: number) {
return db
.select({
+4
View File
@@ -44,6 +44,10 @@ export const SITE_SETTING_DEFAULTS = {
association_publication_director: "",
association_privacy_contact: "",
legal_updated: "",
// Référencement : identifiant de mesure GA4 (« G-… ») et jeton de
// validation Search Console. Vides = aucune balise posée sur le site.
google_analytics_id: "",
google_site_verification: "",
};
export type SiteSettings = typeof SITE_SETTING_DEFAULTS;
+8 -1
View File
@@ -32,7 +32,11 @@ function contentSecurityPolicy(nonce: string, isDev: boolean, isHttps: boolean):
"font-src 'self' https://fonts.gstatic.com",
// data: pour les images stockées en data-URI, https: pour les vignettes distantes.
"img-src 'self' data: https:",
"connect-src 'self'",
// Mesure d'audience GA4 : gtag.js renvoie ses relevés par `fetch`. Les
// hôtes sont listés même sans identifiant configuré — la CSP est posée
// sur le runtime Edge, qui ne peut pas interroger la base pour savoir si
// la mesure est active.
"connect-src 'self' https://*.google-analytics.com https://*.analytics.google.com https://*.googletagmanager.com",
// Carte « Nous situer » de la fiche adhérent.
"frame-src https://www.google.com https://maps.google.com",
// Uniquement quand la page est servie en HTTPS : sur une page HTTP (test
@@ -55,6 +59,9 @@ export default auth((request: NextRequest) => {
// ses balises <script>. Il est ensuite renvoyé sur la réponse au navigateur.
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-nonce", nonce);
// Un layout ne connaît pas l'URL demandée : `Analytics` s'en sert pour ne
// mesurer que les pages publiques.
requestHeaders.set("x-pathname", request.nextUrl.pathname);
requestHeaders.set("content-security-policy", csp);
const response = NextResponse.next({ request: { headers: requestHeaders } });
+109
View File
@@ -0,0 +1,109 @@
import { strict as assert } from "node:assert";
import { describe, it } from "node:test";
import {
gtagConfigScript,
gtagSrc,
isTrackedPath,
normalizeMeasurementId,
normalizeSiteVerification,
} from "../src/lib/analytics";
describe("Identifiant de mesure GA4", () => {
it("accepte un identifiant de flux", () => {
assert.equal(normalizeMeasurementId("G-ABC1234567"), "G-ABC1234567");
assert.equal(normalizeMeasurementId(" g-abc1234567 "), "G-ABC1234567");
});
it("refuse une saisie vide ou absente", () => {
assert.equal(normalizeMeasurementId(""), "");
assert.equal(normalizeMeasurementId(null), "");
assert.equal(normalizeMeasurementId(undefined), "");
assert.equal(normalizeMeasurementId(" "), "");
});
it("refuse Universal Analytics, arrêté depuis 2023", () => {
assert.equal(normalizeMeasurementId("UA-12345-1"), "");
});
it("refuse tout ce qui pourrait s'échapper du script en ligne", () => {
for (const payload of [
"G-ABC12345');alert(1);//",
"G-ABC12345</script><script>alert(1)</script>",
'G-ABC"12345',
"G-ABC 12345",
"<script>alert(1)</script>",
]) {
assert.equal(normalizeMeasurementId(payload), "", `accepté à tort : ${payload}`);
}
});
});
describe("Balises gtag.js", () => {
it("ne rend rien sans identifiant valide", () => {
assert.equal(gtagSrc(""), "");
assert.equal(gtagSrc("UA-12345-1"), "");
assert.equal(gtagConfigScript(""), "");
assert.equal(gtagConfigScript("pas-un-identifiant"), "");
});
it("charge gtag.js depuis Google avec l'identifiant configuré", () => {
assert.equal(
gtagSrc("G-ABC1234567"),
"https://www.googletagmanager.com/gtag/js?id=G-ABC1234567",
);
});
it("produit un script d'amorçage qui ne peut pas être refermé", () => {
const script = gtagConfigScript("G-ABC1234567");
assert.ok(script.includes("gtag('config','G-ABC1234567');"));
assert.ok(!script.includes("</script"));
assert.ok(!script.includes("<"));
});
});
describe("Pages mesurées", () => {
it("mesure les pages publiques", () => {
for (const path of ["/", "/annuaire", "/promotions", "/adherents/12-au-bon-pain", "/association"]) {
assert.equal(isTrackedPath(path), true, `devrait être mesurée : ${path}`);
}
});
it("ne mesure ni le backoffice, ni l'espace adhérent, ni les URLs privées", () => {
for (const path of [
"/backend",
"/backend/espace/promotions",
"/login",
"/inscription/adherent",
"/api/social/facebook/callback",
]) {
assert.equal(isTrackedPath(path), false, `ne devrait pas être mesurée : ${path}`);
}
});
it("ne confond pas un préfixe avec un début de mot", () => {
assert.equal(isTrackedPath("/backendeur"), true);
assert.equal(isTrackedPath("/apiculteurs"), true);
});
});
describe("Jeton Search Console", () => {
it("accepte le jeton livré par Google", () => {
assert.equal(
normalizeSiteVerification(" aBc123_def-456ghijkl "),
"aBc123_def-456ghijkl",
);
});
it("refuse une balise complète ou tout autre collage", () => {
for (const payload of [
'<meta name="google-site-verification" content="abc">',
"",
"trop-court",
"abc123 def456ghij",
"abc\"><script>alert(1)</script>",
]) {
assert.equal(normalizeSiteVerification(payload), "", `accepté à tort : ${payload}`);
}
});
});
+35
View File
@@ -0,0 +1,35 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { contactName, memberContact, telHref } from "../src/lib/member-contact";
test("le référent joint prénom et nom", () => {
assert.equal(contactName({ contactFirstName: "Marie", contactLastName: "Durand" }), "Marie Durand");
assert.equal(contactName({ contactFirstName: " Marie ", contactLastName: " Durand " }), "Marie Durand");
assert.equal(contactName({ contactFirstName: "Marie" }), "Marie");
assert.equal(contactName({ contactLastName: "Durand" }), "Durand");
assert.equal(contactName({}), null);
assert.equal(contactName({ contactFirstName: " ", contactLastName: null }), null);
});
test("un référent sans nom ni téléphone n'existe pas", () => {
assert.equal(memberContact({}), null);
assert.equal(memberContact({ contactFirstName: " ", contactPhone: "" }), null);
assert.deepEqual(memberContact({ contactPhone: "03 83 24 10 10" }), {
name: null,
phone: "03 83 24 10 10",
});
assert.deepEqual(memberContact({ contactLastName: "Durand", contactPhone: null }), {
name: "Durand",
phone: null,
});
});
test("le lien tel: ne garde que les chiffres et l'indicatif", () => {
assert.equal(telHref("03 83 24 10 10"), "tel:0383241010");
assert.equal(telHref("+33 3 83 24 10 10"), "tel:+33383241010");
assert.equal(telHref("03.83.24.10.10"), "tel:0383241010");
assert.equal(telHref("sur rendez-vous"), null);
assert.equal(telHref(null), null);
assert.equal(telHref(" "), null);
});
+44
View File
@@ -1,4 +1,5 @@
import { strict as assert } from "node:assert";
import { readFileSync } from "node:fs";
import { describe, it } from "node:test";
import { activityNodes, sanitizeActivityMessage } from "../src/lib/activity";
@@ -109,3 +110,46 @@ describe("Chiffrement des secrets réseaux", () => {
assert.equal(tryDecryptSecret("pas-un-secret-chiffré"), null);
});
});
describe("Coordonnées du référent — jamais dans les lectures publiques", () => {
const source = readFileSync(new URL("../src/lib/queries.ts", import.meta.url), "utf8");
/** Corps d'une fonction exportée de `queries.ts`, jusqu'à la suivante. */
function bodyOf(name: string): string {
const start = source.indexOf(`export async function ${name}(`);
assert.notEqual(start, -1, `${name} introuvable dans src/lib/queries.ts`);
const next = source.indexOf("\nexport ", start + 1);
return source.slice(start, next === -1 ? source.length : next);
}
// Nom, prénom et ligne directe de l'adhérent ne sont montrés qu'à un visiteur
// connecté. La garantie tient à une seule chose : aucune requête servant une
// page publique ne les rapatrie — sinon ils partiraient dans le HTML, le
// JSON-LD ou les props du composant client de l'annuaire.
const publicReads = [
"getActiveMembersWithCategory",
"getActiveMembersByCategory",
"getRotatingActiveMembers",
"getPublicMember",
"getLivePromotions",
];
for (const name of publicReads) {
it(`${name} ne lit pas le référent`, () => {
const body = bodyOf(name);
for (const column of ["contactFirstName", "contactLastName", "contactPhone"]) {
assert.ok(
!body.includes(column),
`${name} sélectionne ${column} : ces coordonnées deviendraient publiques.`
);
}
});
}
it("les lectures réservées existent et sont séparées", () => {
for (const name of ["getMemberContacts", "getMemberContact"]) {
const body = bodyOf(name);
assert.ok(body.includes("contactPhone"), `${name} devrait lire le référent`);
}
});
});