mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-21 22:28:04 +02:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b860eeaf29 | ||
|
|
67cd9d6684 | ||
|
|
fb09bf0e3a | ||
|
|
7d72016bfb | ||
|
|
e59ae3a908 | ||
|
|
146250f973 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -120,6 +120,13 @@
|
||||
"when": 1788692576315,
|
||||
"tag": "0016_tiresome_korg",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"when": 1789385157231,
|
||||
"tag": "0017_regular_devos",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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 ?? ""} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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'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'est
|
||||
posée sur le site. La mesure d'audience ne couvre que les pages publiques — ni le
|
||||
backoffice, ni l'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'activer.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button type="submit" style={submitButton}>Enregistrer les paramètres</button>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -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'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'est plus modifiable ensuite.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 & entreprises du Bassin de Pompey
|
||||
Association des commerçants & 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"
|
||||
|
||||
@@ -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) }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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'annuaire et sur la fiche
|
||||
<strong> uniquement aux visiteurs connectés</strong> : rien de tout cela n'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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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 : "";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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 } });
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user