From 7d72016bfb7a7f9debef2a167604ff57520c872b Mon Sep 17 00:00:00 2001 From: Michael Date: Mon, 14 Sep 2026 06:01:57 +0000 Subject: [PATCH] =?UTF-8?q?Prochaines=20dates=20:=20cartes=20de=20hauteur?= =?UTF-8?q?=20uniforme,=20d=C3=A9tail=20en=20fen=C3=AAtre?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01MS61rkgTUGgPYH88tuVx9U --- src/app/association/page.tsx | 60 +++++----- src/components/MeetingCard.tsx | 200 +++++++++++++++++++++++++++++++++ 2 files changed, 226 insertions(+), 34 deletions(-) create mode 100644 src/components/MeetingCard.tsx diff --git a/src/app/association/page.tsx b/src/app/association/page.tsx index 65a925d..8c9d2fd 100644 --- a/src/app/association/page.tsx +++ b/src/app/association/page.tsx @@ -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 ( -
-
- {isRegistered && ( - - ✓ Inscrit - - )} -
-
-
- {formatDate(meeting.startsAt)} · {formatTime(meeting.startsAt)} -
-

- {meeting.title} -

- {meeting.location &&
{meeting.location}
} -

- {meeting.description} -

-
- {registered}/{meeting.capacity} inscrits - {remaining} place(s) -
-
- Jusqu'à {meeting.participantsPerAccount} participant{meeting.participantsPerAccount > 1 ? "s" : ""} par compte -
- - {isRegistered ? "Modifier mon inscription" : remaining <= 0 ? "Complet" : memberId ? "S'inscrire" : "Connexion et inscription"} - -
-
+ ); })} diff --git a/src/components/MeetingCard.tsx b/src/components/MeetingCard.tsx new file mode 100644 index 0000000..c2627ec --- /dev/null +++ b/src/components/MeetingCard.tsx @@ -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 ( +
+
+ {meeting.isRegistered && ( + + ✓ Inscrit + + )} +
+ +
+
+ {meeting.dateLabel} · {meeting.timeLabel} +
+ +

+ {meeting.title} +

+ + {/* Le lieu est facultatif : sa ligne est réservée dans tous les cas, + sinon une carte sans lieu remonterait tout son bas. */} +
+ {meeting.location} +
+ +

+ {meeting.description} +

+ + {/* 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. */} +
+ +
+ {meeting.registered}/{meeting.capacity} inscrits + {remaining} place(s) +
+
{perAccount}
+ +
+ + + {meeting.ctaLabel} + +
+
+ + setOpen(false)} title={meeting.title} maxWidth={560}> + {meeting.imageUrl && ( +
+ )} + +
+ {meeting.dateLabel} · {meeting.timeLabel} +
+ {meeting.location && ( +
{meeting.location}
+ )} + + {meeting.description && ( +

+ {meeting.description} +

+ )} + +
+ {meeting.registered}/{meeting.capacity} inscrits + {remaining} place(s) restante(s) +
+
{perAccount}
+ + + {meeting.ctaLabel} + + +
+ ); +}