Author SHA1 Message Date
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
Claude dd16e05b47 Demandes & messages : boutons d'action selon l'état de la ligne
Une demande approuvée gardait ses boutons « Approuver & créer » et
« Rejeter », un message lu son « Marquer lu », un message archivé son
« Archiver » : rien n'indiquait que l'action avait déjà été faite.

Chaque ligne ne propose plus que les transitions qui ont du sens :
- demande nouvelle → Approuver & créer / Rejeter
- demande rejetée  → Rouvrir
- demande approuvée → aucun bouton (le compte existe)
- message nouveau  → Marquer lu / Archiver
- message lu       → Archiver
- message archivé  → Désarchiver

Le masquage du bouton d'approbation vit dans `ApproveRequestForm` et non
dans la page : après l'approbation, `router.refresh()` repasse la demande
en « approuvée », et un test côté serveur démonterait le bloc
d'identifiants à usage unique avant qu'il ait été lu.

Deux gardes côté serveur couvrent l'onglet resté ouvert sur l'ancien
état : `approveMembershipRequest` refuse une demande déjà traitée, et
`setRequestStatus` ne touche plus une demande approuvée.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrfrukBXnHtMwq5iT7DwAM
2026-09-13 07:48:59 +00:00
Claude e9b6fdfd91 Annuaire : cartes adhérents de hauteur uniforme
Une description longue étirait sa carte, donc toute la rangée de la
grille, et laissait les voisines avec un grand vide sous le texte.

Le titre (2 lignes) et la description (4 lignes) ont désormais une
hauteur figée et sont tronqués en fin de ligne ; un espaceur pousse
la ligne d'adresse en bas de carte, et cette adresse tient sur une
seule ligne avec ellipsis. Toutes les cartes font la même hauteur,
quel que soit le texte saisi par l'adhérent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrfrukBXnHtMwq5iT7DwAM
2026-09-13 07:25:22 +00:00
11 changed files with 637 additions and 59 deletions

No files matched your search

+6
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,
+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>
+15 -2
View File
@@ -4,7 +4,7 @@ import { randomInt } from "node:crypto";
import { revalidatePath } from "next/cache";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { and, eq, sql } from "drizzle-orm";
import { and, eq, ne, sql } from "drizzle-orm";
import bcrypt from "bcryptjs";
import { signOut } from "@/auth";
import { getSession } from "@/lib/session";
@@ -1133,6 +1133,17 @@ export async function approveMembershipRequest(
const [req] = await db.select().from(membershipRequests).where(eq(membershipRequests.id, id));
if (!req) throw new Error("Demande introuvable.");
// Le bouton disparaît dès qu'une demande est traitée, mais un onglet resté
// ouvert peut encore poster : on refuse ici plutôt que de créer un doublon.
if (req.status !== "new") {
return {
error:
req.status === "approved"
? "Cette demande a déjà été approuvée."
: "Cette demande a été rejetée : rouvrez-la avant de l'approuver.",
};
}
const email = (req.email ?? "").trim().toLowerCase();
if (!email) {
return {
@@ -1178,10 +1189,12 @@ export async function setRequestStatus(formData: FormData) {
const id = Number(formData.get("id"));
const status = String(formData.get("status") ?? "");
if (!id || !["new", "approved", "rejected"].includes(status)) return;
// Une demande approuvée a créé un compte : elle ne se rejoue pas depuis un
// onglet resté ouvert sur l'ancien état.
await db
.update(membershipRequests)
.set({ status: status as "new" | "approved" | "rejected" })
.where(eq(membershipRequests.id, id));
.where(and(eq(membershipRequests.id, id), ne(membershipRequests.status, "approved")));
revalidatePath("/backend/demandes");
revalidatePath("/backend");
}
@@ -10,8 +10,19 @@ import { approveMembershipRequest, type CreatedMemberAccount } from "../actions"
* Approuve une demande et affiche une seule fois les identifiants du compte
* créé. On reste sur la page : une redirection ferait perdre le mot de passe,
* qui n'est conservé nulle part.
*
* Le bouton ne s'affiche que pour une demande encore à traiter, mais c'est ce
* composant — et non la page — qui en décide : après l'approbation,
* `router.refresh()` renvoie la demande en « approuvée », et un masquage côté
* serveur démonterait le bloc d'identifiants avant qu'il ait été lu.
*/
export function ApproveRequestForm({ requestId }: { requestId: number }) {
export function ApproveRequestForm({
requestId,
status,
}: {
requestId: number;
status: string;
}) {
const [created, setCreated] = useState<CreatedMemberAccount | null>(null);
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -28,6 +39,8 @@ export function ApproveRequestForm({ requestId }: { requestId: number }) {
);
}
if (status !== "new") return null;
return (
<form
action={async (fd) => {
+41 -16
View File
@@ -72,13 +72,25 @@ export default async function DemandesPage() {
</div>
</div>
<StatusPill map={REQ_STATUS} status={r.status} />
{/* Une demande traitée ne garde que la sortie qui a du sens :
« Rejeter » disparaît une fois l'adhérent créé, et un rejet
reste réversible tant qu'aucun compte n'existe. */}
<div style={{ display: "flex", gap: 8 }}>
<ApproveRequestForm requestId={r.id} />
<form action={setRequestStatus}>
<input type="hidden" name="id" value={r.id} />
<input type="hidden" name="status" value="rejected" />
<ActionBtn color="#d8472b">Rejeter</ActionBtn>
</form>
<ApproveRequestForm requestId={r.id} status={r.status} />
{r.status === "new" && (
<form action={setRequestStatus}>
<input type="hidden" name="id" value={r.id} />
<input type="hidden" name="status" value="rejected" />
<ActionBtn color="#d8472b">Rejeter</ActionBtn>
</form>
)}
{r.status === "rejected" && (
<form action={setRequestStatus}>
<input type="hidden" name="id" value={r.id} />
<input type="hidden" name="status" value="new" />
<ActionBtn color="#6c6150">Rouvrir</ActionBtn>
</form>
)}
</div>
</div>
{r.message && (
@@ -109,17 +121,30 @@ export default async function DemandesPage() {
</div>
</div>
<StatusPill map={MSG_STATUS} status={m.status} />
{/* Chaque bouton fait avancer le message d'un cran : on ne
propose plus « Marquer lu » sur un message déjà lu ni
« Archiver » sur un message archivé. */}
<div style={{ display: "flex", gap: 8 }}>
<form action={setContactStatus}>
<input type="hidden" name="id" value={m.id} />
<input type="hidden" name="status" value="read" />
<ActionBtn color="#2C6FB3">Marquer lu</ActionBtn>
</form>
<form action={setContactStatus}>
<input type="hidden" name="id" value={m.id} />
<input type="hidden" name="status" value="archived" />
<ActionBtn color="#a99c82">Archiver</ActionBtn>
</form>
{m.status === "new" && (
<form action={setContactStatus}>
<input type="hidden" name="id" value={m.id} />
<input type="hidden" name="status" value="read" />
<ActionBtn color="#2C6FB3">Marquer lu</ActionBtn>
</form>
)}
{m.status !== "archived" ? (
<form action={setContactStatus}>
<input type="hidden" name="id" value={m.id} />
<input type="hidden" name="status" value="archived" />
<ActionBtn color="#a99c82">Archiver</ActionBtn>
</form>
) : (
<form action={setContactStatus}>
<input type="hidden" name="id" value={m.id} />
<input type="hidden" name="status" value="read" />
<ActionBtn color="#6c6150">Désarchiver</ActionBtn>
</form>
)}
</div>
</div>
<p style={{ margin: "10px 0 0", fontSize: 13.5, color: "#5a5040", whiteSpace: "pre-wrap" }}>{m.message}</p>
+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}
+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>
)}
+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>
);
}
+31 -4
View File
@@ -21,11 +21,32 @@ const STRIPE_WARM =
const STRIPE_COOL =
"repeating-linear-gradient(45deg,#eef0ec,#eef0ec 12px,#e2e8e6 12px,#e2e8e6 24px)";
/**
* Hauteurs figées des deux blocs de texte : une description longue ne doit pas
* étirer la carte (donc toute la rangée de la grille). Titre et description
* sont tronqués en fin de ligne et occupent toujours la même place, ce qui
* aligne les cartes entre elles quel que soit le texte saisi par l'adhérent.
*/
const TITLE_LINE = 1.25;
const TITLE_LINES = 2;
const DESC_FONT = 13;
const DESC_LINE = 1.5;
const DESC_LINES = 4;
const clampLines = (lines: number) =>
({
display: "-webkit-box",
WebkitLineClamp: lines,
WebkitBoxOrient: "vertical" as const,
overflow: "hidden",
}) as const;
/**
* Carte d'un adhérent dans une grille (annuaire, page métier). Composant sans
* état : il se rend aussi bien côté serveur que dans le filtre client.
*/
export function MemberCard({ m, index = 0 }: { m: MemberCardData; index?: number }) {
const location = [m.address, m.city].filter(Boolean).join(" · ");
return (
<Link
href={memberPath(m)}
@@ -50,15 +71,21 @@ export function MemberCard({ m, index = 0 }: { m: MemberCardData; index?: number
)}
</VitrineImage>
<div style={{ padding: "16px 18px 18px", display: "flex", flexDirection: "column", flex: 1 }}>
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 18, margin: 0, color: "#26201a" }}>
<h3
className="font-display"
style={{ fontWeight: 700, fontSize: 18, lineHeight: TITLE_LINE, margin: 0, color: "#26201a", minHeight: 18 * TITLE_LINE * TITLE_LINES, ...clampLines(TITLE_LINES) }}
>
{m.name}
</h3>
<p style={{ margin: "6px 0 12px", fontSize: 13, color: "#8c8068", lineHeight: 1.5, flex: 1 }}>
<p
style={{ margin: "6px 0 12px", fontSize: DESC_FONT, color: "#8c8068", lineHeight: DESC_LINE, height: DESC_FONT * DESC_LINE * DESC_LINES, ...clampLines(DESC_LINES) }}
>
{m.description}
</p>
<div style={{ flex: 1 }} />
<div style={{ display: "flex", alignItems: "center", gap: 7, fontSize: 12.5, color: "#6c6150", borderTop: "1px solid #f0e8d6", paddingTop: 11 }}>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: m.accent ?? "#E0A63C" }} />
{[m.address, m.city].filter(Boolean).join(" · ")}
<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>
</div>
</Link>
+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;
}
+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"));
});
});