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
This commit is contained in:
MichaelandClaude Opus 5 committed 2026-09-14 06:01:57 +00:00
1 parent e59ae3a908
commit 7d72016bfb
2 files changed
+226 -34

No files matched your search

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