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
MichaelandClaude Opus 5 7d72016bfb Prochaines dates : cartes de hauteur uniforme, détail en fenêtre
Une description longue étirait sa carte, donc toute la rangée de la grille,
et laissait les voisines avec un grand vide sous leur bouton — même défaut
que les cartes de l'annuaire avant leur uniformisation.

Le titre (2 lignes), le lieu (1 ligne) et la description (3 lignes) ont
désormais une hauteur figée et sont tronqués en fin de ligne ; un espaceur
pousse les compteurs et les boutons en bas de carte. La ligne de lieu reste
réservée quand la rencontre n'en a pas, sinon sa carte remonterait tout son
bas.

Le texte complet n'est plus perdu : « Voir le détail » ouvre une fenêtre
avec l'image, la date, le lieu, la description entière, les places et le
bouton d'inscription. Le texte tronqué reste dans le HTML (troncature CSS),
l'indexation n'y perd rien.

Les dates sont formatées côté serveur et passées déjà rendues : les formater
dans le composant client les aurait rendues dans le fuseau du visiteur et
provoqué une erreur d'hydratation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MS61rkgTUGgPYH88tuVx9U
2026-09-14 06:01:57 +00:00
MichaelandClaude Opus 5 e59ae3a908 Espace adhérent : taille et format de l'image annoncés au dépôt
L'application publie le fichier tel quel sur Facebook et LinkedIn : aucun
recadrage, aucune marge. Un visuel qui n'est pas au format attendu est donc
recadré — ou entouré de bandes de couleur prises dans l'image — par la
plateforme, ce qui ne se voit nulle part côté site puisque les cartes
recadrent en `cover`.

Le formulaire de dépôt affiche maintenant les dimensions, le format et le
poids du fichier choisi, et dit ce que les réseaux en feront : format non
carré, image trop petite pour un fil d'actualité, fichier trop lourd pour
l'envoi. Purement informatif, rien n'est bloqué.

`src/lib/image-info.ts` est pur et verrouillé par `tests/image-info.test.ts`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MS61rkgTUGgPYH88tuVx9U
2026-09-14 05:52:13 +00:00
MichaelandClaude Opus 5 146250f973 Modération : la diffusion réseaux suit la date de publication
Le bloc « Diffusion demandée » du formulaire « Valider » annonçait une
publication « dès la validation », alors qu'une promotion validée avec une
date future passe en `scheduled` et n'est diffusée qu'à l'échéance, par
`releaseDuePromotions()`. Le texte décrit maintenant les deux cas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MS61rkgTUGgPYH88tuVx9U
2026-09-14 05:44:01 +00:00
34 changed files with 2667 additions and 50 deletions

No files matched your search

+36
View File
@@ -139,6 +139,12 @@ même raison.
- Les images de promo sont stockées en data-URI : l'upload se fait donc en
binaire (multipart pour Facebook, Images API en 3 étapes pour LinkedIn), pas
par URL.
- L'image part **telle quelle** : aucun recadrage ni redimensionnement côté
application. Un visuel non carré est donc recadré — ou entouré de bandes de
couleur — par la plateforme. `src/lib/image-info.ts` (pur, verrouillé par
`tests/image-info.test.ts`) décrit le fichier déposé (dimensions, format,
poids) et `MemberSpaceForm` l'affiche sous l'aperçu : on informe, on ne
bloque pas.
- La diffusion est déclenchée **par la validation** (ou par l'échéance d'une
publication programmée), pas par un bouton :
`promotions.share_facebook` / `share_linkedin` sont choisis par l'adhérent,
@@ -215,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
@@ -242,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 ?? ""} />
+26 -34
View File
@@ -5,6 +5,7 @@ import { auth } from "@/auth";
import { db } from "@/db";
import { meetingRegistrations, meetings, pastMeetingPhotos, pastMeetings } from "@/db/schema";
import { JsonLd } from "@/components/JsonLd";
import { MeetingCard } from "@/components/MeetingCard";
import { PastMeetingGallery } from "@/components/PastMeetingGallery";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
@@ -232,40 +233,31 @@ export default async function AssociationPage() {
const remaining = Math.max(0, meeting.capacity - registered);
const isRegistered = myMeetingIds.has(meeting.id);
return (
<article id={`rencontre-${meeting.id}`} key={meeting.id} className="lift" style={{ background: "#fff", border: isRegistered ? "2px solid #1f8a5b" : "1px solid #e6dcc6", borderRadius: 12, overflow: "hidden", scrollMarginTop: 100 }}>
<div style={{ position: "relative", aspectRatio: "16 / 10", background: meeting.imageUrl ? `center/cover no-repeat url(${meeting.imageUrl})` : "repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)" }}>
{isRegistered && (
<span style={{ position: "absolute", top: 12, right: 12, display: "inline-flex", alignItems: "center", gap: 6, background: "#1f8a5b", color: "#fff", borderRadius: 999, padding: "7px 12px", fontSize: 12, fontWeight: 800, boxShadow: "0 4px 14px rgba(22,72,49,0.28)" }}>
✓ Inscrit
</span>
)}
</div>
<div style={{ padding: 18 }}>
<div style={{ color: "#9a6638", fontSize: 12.5, fontWeight: 800 }}>
{formatDate(meeting.startsAt)} · {formatTime(meeting.startsAt)}
</div>
<h3 className="font-display" style={{ margin: "7px 0 7px", color: "#26201a", fontSize: 21 }}>
{meeting.title}
</h3>
{meeting.location && <div style={{ fontSize: 13, color: "#6c6150", fontWeight: 700 }}>{meeting.location}</div>}
<p style={{ color: "#8c8068", fontSize: 13.5, lineHeight: 1.6, minHeight: 64 }}>
{meeting.description}
</p>
<div style={{ display: "flex", justifyContent: "space-between", color: "#6c6150", fontSize: 12.5, fontWeight: 800, marginBottom: 12 }}>
<span>{registered}/{meeting.capacity} inscrits</span>
<span>{remaining} place(s)</span>
</div>
<div style={{ color: "#8c8068", fontSize: 12.5, marginBottom: 10 }}>
Jusqu'à {meeting.participantsPerAccount} participant{meeting.participantsPerAccount > 1 ? "s" : ""} par compte
</div>
<Link
href={`/inscription/${meeting.id}`}
style={{ display: "block", textAlign: "center", textDecoration: "none", background: remaining <= 0 && !isRegistered ? "#a99c82" : "#13324F", color: "#fff", borderRadius: 8, padding: 11, fontWeight: 800 }}
>
{isRegistered ? "Modifier mon inscription" : remaining <= 0 ? "Complet" : memberId ? "S'inscrire" : "Connexion et inscription"}
</Link>
</div>
</article>
<MeetingCard
key={meeting.id}
meeting={{
id: meeting.id,
title: meeting.title,
description: meeting.description,
location: meeting.location,
imageUrl: meeting.imageUrl,
// Formatées ici : côté client, le fuseau du visiteur
// donnerait une autre heure et casserait l'hydratation.
dateLabel: formatDate(meeting.startsAt),
timeLabel: formatTime(meeting.startsAt),
capacity: meeting.capacity,
registered,
participantsPerAccount: meeting.participantsPerAccount,
isRegistered,
ctaLabel: isRegistered
? "Modifier mon inscription"
: remaining <= 0
? "Complet"
: memberId
? "S'inscrire"
: "Connexion et inscription",
}}
/>
);
})}
</div>
+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" />
+54 -1
View File
@@ -10,6 +10,7 @@ import type { SocialNetwork } from "@/lib/social";
import type { PromoCategoryGroup } from "@/lib/promo-categories";
import { formatValidity, isRangeInvalid } from "@/lib/promo-validity";
import { formatSchedule, parseScheduleInput } from "@/lib/promo-schedule";
import { imageNotes, imageSummary, type ImageInfo } from "@/lib/image-info";
const STRIPE_WARM =
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
@@ -51,6 +52,41 @@ function SummaryRow({
);
}
/**
* Taille, format et poids de l'image déposée, avec ce que les réseaux en
* feront. Purement informatif : aucune remarque n'empêche l'envoi.
*/
function ImageReport({ info }: { info: ImageInfo }) {
const notes = imageNotes(info);
return (
<div
style={{
marginTop: -8,
marginBottom: 18,
background: "#faf7ef",
border: "1px solid #f0e8d6",
borderRadius: 12,
padding: "10px 12px",
}}
>
<div style={{ fontSize: 12.5, fontWeight: 700, color: "#6c6150" }}>{imageSummary(info)}</div>
{notes.map((note, i) => (
<div
key={i}
style={{
fontSize: 11.5,
lineHeight: 1.45,
marginTop: 5,
color: note.tone === "warn" ? "#a8503c" : "#1f8a5b",
}}
>
{note.text}
</div>
))}
</div>
);
}
export function MemberSpaceForm({
memberName,
memberLogoUrl,
@@ -78,6 +114,10 @@ export function MemberSpaceForm({
const [endsOn, setEndsOn] = useState("");
const [publishAt, setPublishAt] = useState("");
const [imgData, setImgData] = useState("");
// Dimensions et poids du fichier déposé : rien n'est retouché à l'envoi, donc
// c'est ici — dernier moment où l'adhérent peut changer de visuel — qu'on lui
// dit ce que les réseaux feront d'un format inattendu.
const [imgInfo, setImgInfo] = useState<ImageInfo | null>(null);
const [shares, setShares] = useState<SocialNetwork[]>([]);
const [submitted, setSubmitted] = useState(false);
const [pending, setPending] = useState(false);
@@ -91,10 +131,20 @@ export function MemberSpaceForm({
function onFile(e: React.ChangeEvent<HTMLInputElement>) {
const f = e.target.files?.[0];
if (!f) return;
const bytes = f.size;
setImgInfo(null);
const reader = new FileReader();
reader.onload = () => {
setImgData(String(reader.result));
const dataUrl = String(reader.result);
setImgData(dataUrl);
setSubmitted(false);
// Les dimensions ne sont lisibles qu'une fois l'image décodée par le
// navigateur. Un échec de décodage laisse simplement le résumé vide :
// l'information est un confort, elle ne conditionne pas le dépôt.
const probe = new Image();
probe.onload = () => setImgInfo({ width: probe.naturalWidth, height: probe.naturalHeight, bytes });
probe.onerror = () => setImgInfo(null);
probe.src = dataUrl;
};
reader.readAsDataURL(f);
}
@@ -107,6 +157,7 @@ export function MemberSpaceForm({
setEndsOn("");
setPublishAt("");
setImgData("");
setImgInfo(null);
setShares([]);
setSubmitted(false);
if (fileRef.current) fileRef.current.value = "";
@@ -194,6 +245,8 @@ export function MemberSpaceForm({
<input ref={fileRef} type="file" accept="image/*" onChange={onFile} style={{ display: "none" }} />
</label>
{imgInfo && <ImageReport info={imgInfo} />}
<label className="field-label">Titre de la promotion</label>
<input
value={title}
+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>
);
+3 -1
View File
@@ -246,7 +246,9 @@ export default async function PromotionsPage() {
))}
</div>
<div style={{ fontSize: 11, color: "#a99c82", marginTop: 7, lineHeight: 1.45 }}>
Publié dès la validation. Ce choix n&apos;est plus modifiable ensuite.
Diffusé en même temps que la mise en ligne : tout de suite si le
champ Publication est vide, à la date indiquée sinon. Ce choix
n&apos;est plus modifiable ensuite.
</div>
</div>
)}
+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) }} />
</>
);
}
+200
View File
@@ -0,0 +1,200 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { Modal } from "@/components/Modal";
/**
* Carte d'une rencontre à venir, de hauteur identique à ses voisines.
*
* Le texte d'une rencontre est libre : une description longue étirait sa carte,
* donc toute la rangée de la grille, et laissait les autres avec un grand vide
* sous leur bouton. Titre et description occupent désormais une hauteur figée
* et sont tronqués en fin de ligne ; le texte complet reste lisible dans une
* fenêtre ouverte par « Voir le détail ».
*
* Les dates arrivent **déjà formatées** depuis la page serveur : les formater
* ici les rendrait dans le fuseau du visiteur, différent de celui du serveur,
* et provoquerait une erreur d'hydratation.
*/
export type UpcomingMeetingCardData = {
id: number;
title: string;
description: string | null;
location: string | null;
imageUrl: string | null;
/** « vendredi 3 octobre 2026 ». */
dateLabel: string;
/** « 18:30 ». */
timeLabel: string;
capacity: number;
registered: number;
participantsPerAccount: number;
isRegistered: boolean;
/** Libellé du bouton d'inscription, décidé côté serveur (session, places). */
ctaLabel: string;
};
const STRIPE =
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
/**
* Hauteurs figées des deux blocs de texte, même principe que `MemberCard` :
* elles alignent les cartes quel que soit le texte saisi par le staff.
*/
const TITLE_FONT = 21;
const TITLE_LINE = 1.25;
const TITLE_LINES = 2;
const DESC_FONT = 13.5;
const DESC_LINE = 1.6;
const DESC_LINES = 3;
const clampLines = (lines: number) =>
({
display: "-webkit-box",
WebkitLineClamp: lines,
WebkitBoxOrient: "vertical" as const,
overflow: "hidden",
}) as const;
export function MeetingCard({ meeting }: { meeting: UpcomingMeetingCardData }) {
const [open, setOpen] = useState(false);
const remaining = Math.max(0, meeting.capacity - meeting.registered);
const full = remaining <= 0 && !meeting.isRegistered;
const perAccount = `Jusqu'à ${meeting.participantsPerAccount} participant${meeting.participantsPerAccount > 1 ? "s" : ""} par compte`;
const cover = meeting.imageUrl ? `center/cover no-repeat url(${meeting.imageUrl})` : STRIPE;
return (
<article
id={`rencontre-${meeting.id}`}
className="lift"
style={{
background: "#fff",
border: meeting.isRegistered ? "2px solid #1f8a5b" : "1px solid #e6dcc6",
borderRadius: 12,
overflow: "hidden",
scrollMarginTop: 100,
display: "flex",
flexDirection: "column",
height: "100%",
}}
>
<div style={{ position: "relative", aspectRatio: "16 / 10", background: cover, flexShrink: 0 }}>
{meeting.isRegistered && (
<span style={{ position: "absolute", top: 12, right: 12, display: "inline-flex", alignItems: "center", gap: 6, background: "#1f8a5b", color: "#fff", borderRadius: 999, padding: "7px 12px", fontSize: 12, fontWeight: 800, boxShadow: "0 4px 14px rgba(22,72,49,0.28)" }}>
✓ Inscrit
</span>
)}
</div>
<div style={{ padding: 18, display: "flex", flexDirection: "column", flex: 1 }}>
<div style={{ color: "#9a6638", fontSize: 12.5, fontWeight: 800 }}>
{meeting.dateLabel} · {meeting.timeLabel}
</div>
<h3
className="font-display"
style={{
margin: "7px 0 7px",
color: "#26201a",
fontSize: TITLE_FONT,
lineHeight: TITLE_LINE,
height: TITLE_FONT * TITLE_LINE * TITLE_LINES,
...clampLines(TITLE_LINES),
}}
>
{meeting.title}
</h3>
{/* Le lieu est facultatif : sa ligne est réservée dans tous les cas,
sinon une carte sans lieu remonterait tout son bas. */}
<div
style={{
fontSize: 13,
color: "#6c6150",
fontWeight: 700,
height: 13 * 1.4,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{meeting.location}
</div>
<p
style={{
color: "#8c8068",
fontSize: DESC_FONT,
lineHeight: DESC_LINE,
height: DESC_FONT * DESC_LINE * DESC_LINES,
margin: "8px 0 0",
...clampLines(DESC_LINES),
}}
>
{meeting.description}
</p>
{/* Espaceur : colle le bas de carte au bas du cadre, quelle que soit la
hauteur imposée par la plus haute carte de la rangée. */}
<div style={{ flex: 1, minHeight: 12 }} />
<div style={{ display: "flex", justifyContent: "space-between", color: "#6c6150", fontSize: 12.5, fontWeight: 800, marginBottom: 8 }}>
<span>{meeting.registered}/{meeting.capacity} inscrits</span>
<span>{remaining} place(s)</span>
</div>
<div style={{ color: "#8c8068", fontSize: 12.5, marginBottom: 10 }}>{perAccount}</div>
<div style={{ display: "flex", gap: 8 }}>
<button
type="button"
onClick={() => setOpen(true)}
style={{ flex: "0 0 auto", border: "1px solid #d6c9ad", background: "#fff", color: "#6f6450", borderRadius: 8, padding: "11px 14px", fontWeight: 800, fontSize: 13.5, cursor: "pointer", fontFamily: "inherit" }}
>
Voir le détail
</button>
<Link
href={`/inscription/${meeting.id}`}
style={{ flex: 1, textAlign: "center", textDecoration: "none", background: full ? "#a99c82" : "#13324F", color: "#fff", borderRadius: 8, padding: 11, fontWeight: 800 }}
>
{meeting.ctaLabel}
</Link>
</div>
</div>
<Modal open={open} onClose={() => setOpen(false)} title={meeting.title} maxWidth={560}>
{meeting.imageUrl && (
<div style={{ aspectRatio: "16 / 10", background: cover, borderRadius: 12, marginBottom: 16 }} />
)}
<div style={{ color: "#9a6638", fontSize: 13, fontWeight: 800 }}>
{meeting.dateLabel} · {meeting.timeLabel}
</div>
{meeting.location && (
<div style={{ fontSize: 13.5, color: "#6c6150", fontWeight: 700, marginTop: 4 }}>{meeting.location}</div>
)}
{meeting.description && (
<p style={{ color: "#6c6150", fontSize: 14, lineHeight: 1.7, whiteSpace: "pre-line", margin: "14px 0 0" }}>
{meeting.description}
</p>
)}
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, background: "#faf7ef", border: "1px solid #f0e8d6", borderRadius: 12, padding: "12px 14px", margin: "18px 0 6px", fontSize: 13, fontWeight: 800, color: "#6c6150" }}>
<span>{meeting.registered}/{meeting.capacity} inscrits</span>
<span>{remaining} place(s) restante(s)</span>
</div>
<div style={{ color: "#8c8068", fontSize: 12.5, marginBottom: 16 }}>{perAccount}</div>
<Link
href={`/inscription/${meeting.id}`}
style={{ display: "block", textAlign: "center", textDecoration: "none", background: full ? "#a99c82" : "#13324F", color: "#fff", borderRadius: 8, padding: 12, fontWeight: 800 }}
>
{meeting.ctaLabel}
</Link>
</Modal>
</article>
);
}
+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 : "";
}
+156
View File
@@ -0,0 +1,156 @@
/**
* Description d'une image déposée dans un formulaire : dimensions, format et
* poids, plus les remarques à montrer à l'adhérent avant l'envoi.
*
* Module **pur** (aucun accès DOM, base ou réseau) : il est appelé depuis un
* composant client, et verrouillé par `tests/image-info.test.ts`.
*
* Le besoin vient des réseaux sociaux. L'application publie le fichier tel
* quel — `loadPromoImage()` décode la data-URI et envoie les octets sans y
* toucher — donc un visuel qui n'est pas au format attendu par Facebook ou
* LinkedIn est recadré ou entouré de bandes de couleur *par la plateforme*,
* une couleur prise dans l'image. Rien ne se voit côté site, qui recadre en
* `cover` : d'où l'information au moment du dépôt, seul endroit où l'adhérent
* peut encore changer de fichier.
*/
export type ImageInfo = {
/** Largeur en pixels. */
width: number;
/** Hauteur en pixels. */
height: number;
/** Poids du fichier d'origine, en octets. */
bytes: number;
};
/** Écart toléré autour du carré parfait (2 %) : 1080×1078 reste « carré ». */
export const SQUARE_TOLERANCE = 0.02;
/** En dessous, l'image sera floue une fois agrandie dans un fil d'actualité. */
export const MIN_SIDE = 800;
/** Côté conseillé, celui qu'attendent les deux réseaux. */
export const IDEAL_SIDE = 1080;
/**
* Au delà, la data-URI (base64, ~+33 %) approche la limite de 4 Mo des server
* actions : l'envoi peut être refusé.
*/
export const MAX_BYTES = 2_500_000;
/** Formats courants reconnus, du plus au moins fréquent. */
const KNOWN_RATIOS: { ratio: number; label: string }[] = [
{ ratio: 1, label: "1:1" },
{ ratio: 4 / 5, label: "4:5" },
{ ratio: 5 / 4, label: "5:4" },
{ ratio: 3 / 4, label: "3:4" },
{ ratio: 4 / 3, label: "4:3" },
{ ratio: 2 / 3, label: "2:3" },
{ ratio: 3 / 2, label: "3:2" },
{ ratio: 9 / 16, label: "9:16" },
{ ratio: 16 / 9, label: "16:9" },
{ ratio: 1.91, label: "1,91:1" },
];
function isValid(info: ImageInfo): boolean {
return (
Number.isFinite(info.width) &&
Number.isFinite(info.height) &&
info.width > 0 &&
info.height > 0
);
}
/** Poids lisible : « 840 Ko », « 1,2 Mo ». */
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return "";
if (bytes < 1000) return `${Math.round(bytes)} o`;
if (bytes < 1_000_000) return `${Math.round(bytes / 1000)} Ko`;
const mo = bytes / 1_000_000;
// Une décimale jusqu'à 10 Mo, virgule française.
const value = mo < 10 ? mo.toFixed(1).replace(".", ",") : String(Math.round(mo));
return `${value} Mo`;
}
/**
* Libellé du rapport largeur/hauteur : un format courant quand il en approche
* un à 2 % près, sinon une approximation décimale plutôt qu'un « 1083:721 »
* illisible.
*/
export function aspectLabel(width: number, height: number): string {
if (!isValid({ width, height, bytes: 0 })) return "";
const ratio = width / height;
for (const known of KNOWN_RATIOS) {
if (Math.abs(ratio - known.ratio) / known.ratio <= SQUARE_TOLERANCE) return known.label;
}
return ratio >= 1
? `${ratio.toFixed(2).replace(".", ",")}:1`
: `1:${(1 / ratio).toFixed(2).replace(".", ",")}`;
}
/** `true` si l'image est carrée à la tolérance près. */
export function isSquare(info: ImageInfo): boolean {
if (!isValid(info)) return false;
return Math.abs(info.width / info.height - 1) <= SQUARE_TOLERANCE;
}
/** Orientation en toutes lettres, pour accompagner le ratio. */
export function orientationLabel(info: ImageInfo): string {
if (!isValid(info)) return "";
if (isSquare(info)) return "carré";
return info.width > info.height ? "paysage" : "portrait";
}
/** « 1080 × 1350 px · portrait 4:5 · 420 Ko ». */
export function imageSummary(info: ImageInfo): string {
if (!isValid(info)) return "";
const parts = [
`${Math.round(info.width)} × ${Math.round(info.height)} px`,
`${orientationLabel(info)} ${aspectLabel(info.width, info.height)}`.trim(),
];
const weight = formatBytes(info.bytes);
if (weight) parts.push(weight);
return parts.join(" · ");
}
export type ImageNote = { tone: "warn" | "info"; text: string };
/**
* Remarques à afficher sous l'aperçu. Elles **informent**, elles ne bloquent
* pas : un visuel non carré reste publiable, l'adhérent doit seulement savoir
* ce que les réseaux en feront.
*/
export function imageNotes(info: ImageInfo): ImageNote[] {
if (!isValid(info)) return [];
const notes: ImageNote[] = [];
if (!isSquare(info)) {
notes.push({
tone: "warn",
text:
`Format ${aspectLabel(info.width, info.height)} : sur Facebook et LinkedIn, l'image sera ` +
`recadrée ou entourée de bandes de couleur. Une image carrée (${IDEAL_SIDE} × ${IDEAL_SIDE} px) ` +
`s'affiche entière.`,
});
}
if (Math.min(info.width, info.height) < MIN_SIDE) {
notes.push({
tone: "warn",
text: `Image de petite taille : elle paraîtra floue une fois agrandie. Visez ${IDEAL_SIDE} px de côté.`,
});
}
if (info.bytes > MAX_BYTES) {
notes.push({
tone: "warn",
text: `Fichier lourd (${formatBytes(info.bytes)}) : l'envoi peut être refusé. Compressez l'image avant de la déposer.`,
});
}
if (notes.length === 0) {
notes.push({ tone: "info", text: "Format idéal : l'image s'affichera entière sur le site et sur les réseaux." });
}
return notes;
}
+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}`);
}
});
});
+91
View File
@@ -0,0 +1,91 @@
import { strict as assert } from "node:assert";
import { describe, it } from "node:test";
import {
aspectLabel,
formatBytes,
imageNotes,
imageSummary,
isSquare,
orientationLabel,
IDEAL_SIDE,
} from "../src/lib/image-info";
describe("Poids d'un fichier", () => {
it("passe des octets aux kilo-octets puis aux méga-octets", () => {
assert.equal(formatBytes(640), "640 o");
assert.equal(formatBytes(420_000), "420 Ko");
assert.equal(formatBytes(1_200_000), "1,2 Mo");
});
it("ne rend rien pour une valeur absurde", () => {
assert.equal(formatBytes(-1), "");
assert.equal(formatBytes(Number.NaN), "");
});
});
describe("Format d'une image", () => {
it("reconnaît les formats courants", () => {
assert.equal(aspectLabel(1080, 1080), "1:1");
assert.equal(aspectLabel(1080, 1350), "4:5");
assert.equal(aspectLabel(1920, 1080), "16:9");
});
it("tolère un écart de 2 % autour d'un format connu", () => {
assert.equal(aspectLabel(1080, 1078), "1:1");
assert.ok(isSquare({ width: 1080, height: 1078, bytes: 0 }));
});
it("approche les formats inhabituels plutôt que de rendre une fraction illisible", () => {
assert.equal(aspectLabel(1000, 730), "1,37:1");
assert.equal(aspectLabel(730, 1000), "1:1,37");
});
it("nomme l'orientation", () => {
assert.equal(orientationLabel({ width: 1080, height: 1080, bytes: 0 }), "carré");
assert.equal(orientationLabel({ width: 1080, height: 1350, bytes: 0 }), "portrait");
assert.equal(orientationLabel({ width: 1350, height: 1080, bytes: 0 }), "paysage");
});
it("résume dimensions, format et poids", () => {
assert.equal(
imageSummary({ width: 1080, height: 1350, bytes: 420_000 }),
"1080 × 1350 px · portrait 4:5 · 420 Ko"
);
});
it("ne résume rien sans dimensions exploitables", () => {
assert.equal(imageSummary({ width: 0, height: 0, bytes: 1000 }), "");
assert.deepEqual(imageNotes({ width: 0, height: 0, bytes: 1000 }), []);
});
});
describe("Remarques sur l'image déposée", () => {
it("félicite une image carrée assez grande et assez légère", () => {
const notes = imageNotes({ width: IDEAL_SIDE, height: IDEAL_SIDE, bytes: 300_000 });
assert.equal(notes.length, 1);
assert.equal(notes[0].tone, "info");
});
it("prévient des bandes de couleur ajoutées par les réseaux hors du carré", () => {
const notes = imageNotes({ width: 1080, height: 1350, bytes: 300_000 });
assert.ok(notes.some((n) => n.tone === "warn" && /bandes de couleur/.test(n.text)));
assert.ok(notes.some((n) => /4:5/.test(n.text)));
});
it("signale une image trop petite", () => {
const notes = imageNotes({ width: 400, height: 400, bytes: 60_000 });
assert.ok(notes.some((n) => /floue/.test(n.text)));
});
it("signale un fichier trop lourd", () => {
const notes = imageNotes({ width: 1080, height: 1080, bytes: 3_000_000 });
assert.ok(notes.some((n) => /Fichier lourd/.test(n.text)));
});
it("cumule les remarques quand tout se ligue", () => {
const notes = imageNotes({ width: 600, height: 900, bytes: 3_000_000 });
assert.equal(notes.length, 3);
assert.ok(notes.every((n) => n.tone === "warn"));
});
});
+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`);
}
});
});