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} + + +
+ ); +}