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