separate meetings and add member registrations

This commit is contained in:
Michael committed 2026-07-18 03:56:54 +02:00
1 parent b2d428b08b
commit afc805c96a
17 files changed
+2113 -372

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
DROP INDEX "meeting_registrations_meeting_member_idx";--> statement-breakpoint
ALTER TABLE "meeting_registrations" ADD COLUMN "attendee_company" varchar(200) DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE "meeting_registrations" ADD COLUMN "attendee_phone" varchar(40);--> statement-breakpoint
ALTER TABLE "meeting_registrations" ADD COLUMN "status" varchar(20) DEFAULT 'pending' NOT NULL;--> statement-breakpoint
UPDATE "meeting_registrations" AS r
SET "attendee_company" = m."name", "attendee_phone" = m."phone", "status" = 'confirmed'
FROM "members" AS m
WHERE r."member_id" = m."id";--> statement-breakpoint
ALTER TABLE "meetings" ADD COLUMN "participants_per_account" integer DEFAULT 1 NOT NULL;--> statement-breakpoint
CREATE INDEX "meeting_registrations_meeting_idx" ON "meeting_registrations" USING btree ("meeting_id");--> statement-breakpoint
CREATE INDEX "meeting_registrations_member_meeting_idx" ON "meeting_registrations" USING btree ("member_id","meeting_id");
File diff suppressed because it is too large. Load diff
+7
View File
@@ -36,6 +36,13 @@
"when": 1784303079565,
"tag": "0004_tiny_vanisher",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1784339279276,
"tag": "0005_round_exiles",
"breakpoints": true
}
]
}
+13 -112
View File
@@ -1,18 +1,11 @@
import Link from "next/link";
import { and, asc, desc, eq, gte, inArray, sql } from "drizzle-orm";
import { and, asc, eq, gte, inArray, sql } from "drizzle-orm";
import { auth } from "@/auth";
import { db } from "@/db";
import {
meetingRegistrations,
meetings,
pastMeetingPhotos,
pastMeetings,
} from "@/db/schema";
import { PastMeetingGallery } from "@/components/PastMeetingGallery";
import { meetingRegistrations, meetings } from "@/db/schema";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
import { getSiteSettings, parseBoardMembers, splitLines } from "@/lib/site-settings";
import { cancelMeetingRegistration, registerForMeeting } from "../backend/actions";
import { getSiteSettings, parseBoardMembers } from "@/lib/site-settings";
export const dynamic = "force-dynamic";
@@ -42,6 +35,7 @@ export default async function AssociationPage() {
location: meetings.location,
description: meetings.description,
capacity: meetings.capacity,
participantsPerAccount: meetings.participantsPerAccount,
imageUrl: meetings.imageUrl,
registered: sql<number>`count(${meetingRegistrations.id})`,
})
@@ -51,29 +45,6 @@ export default async function AssociationPage() {
.groupBy(meetings.id)
.orderBy(asc(meetings.startsAt));
const past = await db
.select()
.from(pastMeetings)
.orderBy(desc(pastMeetings.eventDate));
const photoRows = await db
.select()
.from(pastMeetingPhotos)
.orderBy(asc(pastMeetingPhotos.position), asc(pastMeetingPhotos.id));
const meetingIds = past.map((item) => item.meetingId).filter((id): id is number => id != null);
const linkedRegistrations =
meetingIds.length > 0
? await db
.select({
meetingId: meetingRegistrations.meetingId,
attendeeName: meetingRegistrations.attendeeName,
})
.from(meetingRegistrations)
.where(inArray(meetingRegistrations.meetingId, meetingIds))
.orderBy(asc(meetingRegistrations.attendeeName))
: [];
const myRegistrations =
memberId && upcoming.length > 0
? await db
@@ -91,18 +62,6 @@ export default async function AssociationPage() {
: [];
const myMeetingIds = new Set(myRegistrations.map((row) => row.meetingId));
const photosByPast = new Map<number, typeof photoRows>();
for (const photo of photoRows) {
photosByPast.set(photo.pastMeetingId, [...(photosByPast.get(photo.pastMeetingId) ?? []), photo]);
}
const registrationsByMeeting = new Map<number, string[]>();
for (const reg of linkedRegistrations) {
registrationsByMeeting.set(reg.meetingId, [
...(registrationsByMeeting.get(reg.meetingId) ?? []),
reg.attendeeName,
]);
}
const officers = [
["Vice-présidence", settings.association_vice_president],
["Trésorerie", settings.association_treasurer],
@@ -191,31 +150,15 @@ export default async function AssociationPage() {
<span>{registered}/{meeting.capacity} inscrits</span>
<span>{remaining} place(s)</span>
</div>
{memberId ? (
isRegistered ? (
<form action={cancelMeetingRegistration}>
<input type="hidden" name="meetingId" value={meeting.id} />
<button type="submit" style={{ width: "100%", border: "1px solid #e0c3bb", background: "#fff", color: "#d8472b", borderRadius: 10, padding: 11, fontWeight: 800, cursor: "pointer" }}>
Annuler mon inscription
</button>
</form>
) : (
<form action={registerForMeeting}>
<input type="hidden" name="meetingId" value={meeting.id} />
<label style={{ display: "flex", gap: 8, alignItems: "start", fontSize: 12.5, color: "#6c6150", marginBottom: 10 }}>
<input type="checkbox" name="imageConsent" />
J'accepte d'apparaître sur les photos de cette rencontre.
</label>
<button type="submit" disabled={remaining <= 0} style={{ width: "100%", border: "none", background: remaining <= 0 ? "#a99c82" : "#13324F", color: "#fff", borderRadius: 10, padding: 11, fontWeight: 800, cursor: remaining <= 0 ? "not-allowed" : "pointer" }}>
{remaining <= 0 ? "Complet" : "Je m'inscris"}
</button>
</form>
)
) : (
<Link href="/login" style={{ display: "block", textAlign: "center", textDecoration: "none", background: "#13324F", color: "#fff", borderRadius: 10, padding: 11, fontWeight: 800 }}>
Connexion adhérent
</Link>
)}
<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>
);
@@ -223,48 +166,6 @@ export default async function AssociationPage() {
</div>
</section>
<section style={{ background: "#EFE9DA" }}>
<div className="container" style={{ paddingTop: 54, paddingBottom: 54 }}>
<div style={{ color: "#9a6638", fontSize: 12, letterSpacing: "0.14em", textTransform: "uppercase", fontWeight: 800 }}>
Archives
</div>
<h2 className="font-display" style={{ margin: "8px 0 20px", color: "#26201a", fontSize: 34 }}>
Rencontres passées
</h2>
{past.length === 0 && (
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 14, padding: 24, color: "#8c8068" }}>
Aucune rencontre passée publiée pour le moment.
</div>
)}
<div className="grid grid-3" style={{ gap: 18 }}>
{past.map((item) => {
const linkedNames = item.meetingId ? registrationsByMeeting.get(item.meetingId) ?? [] : [];
const manualNames = splitLines(item.participants ?? "");
const names = linkedNames.length > 0 ? linkedNames : manualNames;
return (
<article key={item.id} style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 12, padding: 18 }}>
<div style={{ color: "#9a6638", fontSize: 12.5, fontWeight: 800 }}>
{formatDate(item.eventDate)}
</div>
<h3 className="font-display" style={{ color: "#26201a", fontSize: 21, margin: "7px 0" }}>
{item.title}
</h3>
{item.location && <div style={{ fontSize: 13, color: "#6c6150", fontWeight: 700 }}>{item.location}</div>}
<p style={{ color: "#8c8068", fontSize: 13.5, lineHeight: 1.6 }}>{item.description}</p>
{names.length > 0 && (
<div style={{ color: "#6c6150", fontSize: 12.5, lineHeight: 1.6 }}>
<strong>Participants:</strong> {names.slice(0, 6).join(", ")}
{names.length > 6 ? ` +${names.length - 6}` : ""}
</div>
)}
<PastMeetingGallery title={item.title} photos={photosByPast.get(item.id) ?? []} />
</article>
);
})}
</div>
</div>
</section>
<section className="container" style={{ paddingTop: 54, paddingBottom: 64 }}>
<div style={{ color: "#9a6638", fontSize: 12, letterSpacing: "0.14em", textTransform: "uppercase", fontWeight: 800 }}>
Gouvernance
+13 -1
View File
@@ -15,7 +15,9 @@ const TITLES: Record<string, [string, string]> = {
"Modération des promotions",
"Validez les offres soumises par les adhérents",
],
"/backend/rencontres": ["Rencontres", "Gérer les prochaines dates et les archives"],
"/backend/rencontres": ["Rencontres", "Gérer les prochaines dates"],
"/backend/inscriptions": ["Inscriptions", "Gérer les participants aux rencontres"],
"/backend/rencontres-passees": ["Rencontres passées", "Publier les archives et leurs photos"],
"/backend/emails": ["Création d'e-mails", "Composer des messages aux couleurs de Plein R"],
"/backend/administrateurs": ["Administrateurs", "Gérer les accès à l'administration"],
"/backend/categories": ["Catégories", "Gérer les métiers de l'annuaire"],
@@ -149,6 +151,16 @@ export function BackendShell({
{dotRound}Rencontres
</NavLink>
)}
{can(user.role, "manageMeetings") && (
<NavLink href="/backend/inscriptions" active={pathname === "/backend/inscriptions"}>
{dot}Inscriptions
</NavLink>
)}
{can(user.role, "manageMeetings") && (
<NavLink href="/backend/rencontres-passees" active={pathname === "/backend/rencontres-passees"}>
{dotDiamond}Rencontres passées
</NavLink>
)}
{can(user.role, "manageEmails") && (
<NavLink href="/backend/emails" active={pathname === "/backend/emails"}>
{dot}E-mails
+164 -45
View File
@@ -406,11 +406,13 @@ export async function createMeeting(formData: FormData) {
startsAt: asDate(formData, "startsAt"),
location: asNullableString(formData, "location"),
description: asNullableString(formData, "description"),
capacity: Number(formData.get("capacity") ?? 30) || 30,
capacity: Math.min(100000, Math.max(1, Number(formData.get("capacity") ?? 30) || 30)),
participantsPerAccount: Math.min(100, Math.max(1, Number(formData.get("participantsPerAccount") ?? 1) || 1)),
imageUrl: asNullableString(formData, "imageUrl"),
});
await logActivity(`Rencontre ajoutée : <strong>${title}</strong>`, "#2C6FB3");
revalidatePath("/backend/rencontres");
revalidatePath("/backend/inscriptions");
revalidatePath("/association");
}
@@ -428,11 +430,13 @@ export async function updateMeeting(formData: FormData) {
startsAt: asDate(formData, "startsAt"),
location: asNullableString(formData, "location"),
description: asNullableString(formData, "description"),
capacity: Number(formData.get("capacity") ?? 30) || 30,
capacity: Math.min(100000, Math.max(1, Number(formData.get("capacity") ?? 30) || 30)),
participantsPerAccount: Math.min(100, Math.max(1, Number(formData.get("participantsPerAccount") ?? 1) || 1)),
imageUrl: asNullableString(formData, "imageUrl"),
})
.where(eq(meetings.id, id));
revalidatePath("/backend/rencontres");
revalidatePath("/backend/inscriptions");
revalidatePath("/association");
}
@@ -443,65 +447,170 @@ export async function deleteMeeting(formData: FormData) {
if (!id) return;
await db.delete(meetings).where(eq(meetings.id, id));
revalidatePath("/backend/rencontres");
revalidatePath("/backend/inscriptions");
revalidatePath("/association");
}
export async function registerForMeeting(formData: FormData) {
export type MeetingRegistrationState = {
status: "idle" | "success" | "error";
message: string;
registrationCount?: number;
};
export async function saveMeetingRegistration(
_previousState: MeetingRegistrationState,
formData: FormData,
): Promise<MeetingRegistrationState> {
const session = await auth();
if (!session?.user?.memberId) throw new Error("Compte adhérent requis");
if (!session?.user?.memberId) {
return { status: "error", message: "Connectez-vous avec un compte adhérent pour vous inscrire." };
}
const memberId = session.user.memberId;
const meetingId = Number(formData.get("meetingId"));
if (!meetingId) return;
if (!meetingId) return { status: "error", message: "Rencontre introuvable." };
await db.transaction(async (tx) => {
const [meeting] = await tx.select().from(meetings).where(eq(meetings.id, meetingId));
if (!meeting) throw new Error("Rencontre introuvable");
if (meeting.startsAt.getTime() < Date.now()) throw new Error("Cette rencontre est passée");
try {
if (asString(formData, "intent") === "cancel") {
await db
.delete(meetingRegistrations)
.where(and(eq(meetingRegistrations.meetingId, meetingId), eq(meetingRegistrations.memberId, memberId)));
revalidateMeetingPaths(meetingId);
return { status: "success", message: "Votre inscription a été annulée.", registrationCount: 0 };
}
const duplicate = await tx
.select({ id: meetingRegistrations.id })
.from(meetingRegistrations)
.where(and(eq(meetingRegistrations.meetingId, meetingId), eq(meetingRegistrations.memberId, memberId)));
if (duplicate.length > 0) return;
const participantCount = Number(formData.get("participantCount"));
if (!Number.isInteger(participantCount) || participantCount < 1 || participantCount > 100) {
return { status: "error", message: "Ajoutez au moins un participant." };
}
const [{ total } = { total: 0 }] = await tx
.select({ total: sql<number>`count(*)` })
.from(meetingRegistrations)
.where(eq(meetingRegistrations.meetingId, meetingId));
if (Number(total) >= meeting.capacity) throw new Error("Rencontre complète");
const participants = Array.from({ length: participantCount }, (_, index) => ({
name: asString(formData, `participantName-${index}`),
imageConsent: formData.get(`imageConsent-${index}`) === "on",
}));
if (participants.some((participant) => !participant.name || participant.name.length > 200)) {
return { status: "error", message: "Renseignez le nom et le prénom de chaque participant." };
}
const normalizedNames = participants.map((participant) => participant.name.toLocaleLowerCase("fr-FR"));
if (new Set(normalizedNames).size !== normalizedNames.length) {
return { status: "error", message: "Chaque participant doit avoir un nom différent." };
}
if (formData.get("attestation") !== "on") {
return { status: "error", message: "Vous devez confirmer avoir recueilli le choix de chaque participant." };
}
const [member] = await tx
.select({ name: members.name, email: members.email })
.from(members)
.where(eq(members.id, memberId));
await tx.insert(meetingRegistrations).values({
meetingId,
memberId,
attendeeName: member?.name ?? session.user.name ?? "Adhérent",
attendeeEmail: member?.email ?? session.user.email ?? null,
imageConsent: formData.get("imageConsent") === "on",
await db.transaction(async (tx) => {
await tx.execute(sql`select ${meetings.id} from ${meetings} where ${meetings.id} = ${meetingId} for update`);
const [meeting] = await tx.select().from(meetings).where(eq(meetings.id, meetingId));
if (!meeting) throw new Error("Rencontre introuvable.");
if (meeting.startsAt.getTime() < Date.now()) throw new Error("Cette rencontre est passée.");
if (participants.length > meeting.participantsPerAccount) {
throw new Error(`Cette rencontre autorise ${meeting.participantsPerAccount} participant(s) maximum par compte.`);
}
const [member] = await tx
.select({ name: members.name, email: members.email, phone: members.phone, status: members.status })
.from(members)
.where(eq(members.id, memberId));
if (!member || member.status !== "active") {
throw new Error("Votre adhésion doit être active pour vous inscrire.");
}
const [{ total } = { total: 0 }] = await tx
.select({ total: sql<number>`count(*)` })
.from(meetingRegistrations)
.where(
and(
eq(meetingRegistrations.meetingId, meetingId),
sql`${meetingRegistrations.memberId} is distinct from ${memberId}`,
),
);
if (Number(total) + participants.length > meeting.capacity) {
throw new Error("Il ne reste pas assez de places pour tous les participants.");
}
await tx
.delete(meetingRegistrations)
.where(and(eq(meetingRegistrations.meetingId, meetingId), eq(meetingRegistrations.memberId, memberId)));
await tx.insert(meetingRegistrations).values(
participants.map((participant) => ({
meetingId,
memberId,
attendeeName: participant.name,
attendeeCompany: member.name,
attendeeEmail: member.email ?? session.user.email ?? null,
attendeePhone: member.phone,
status: "pending",
imageConsent: participant.imageConsent,
})),
);
});
});
revalidatePath("/association");
revalidatePath("/backend/espace");
revalidateMeetingPaths(meetingId);
return {
status: "success",
message: `Inscription enregistrée pour ${participants.length} participant${participants.length > 1 ? "s" : ""}.`,
registrationCount: participants.length,
};
} catch (error) {
return { status: "error", message: error instanceof Error ? error.message : "Inscription impossible." };
}
}
export async function cancelMeetingRegistration(formData: FormData) {
const session = await auth();
if (!session?.user?.memberId) throw new Error("Compte adhérent requis");
const meetingId = Number(formData.get("meetingId"));
if (!meetingId) return;
await db
.delete(meetingRegistrations)
.where(
and(
eq(meetingRegistrations.meetingId, meetingId),
eq(meetingRegistrations.memberId, session.user.memberId)
)
);
function revalidateMeetingPaths(meetingId?: number) {
revalidatePath("/association");
revalidatePath("/backend/rencontres");
revalidatePath("/backend/inscriptions");
revalidatePath("/backend/rencontres-passees");
revalidatePath("/backend/espace");
revalidatePath("/rencontres-passees");
if (meetingId) revalidatePath(`/inscription/${meetingId}`);
}
export async function updateMeetingRegistration(formData: FormData) {
const { role } = await requireRole();
if (!can(role, "manageMeetings")) throw new Error("Accès refusé");
const id = Number(formData.get("id"));
const attendeeName = asString(formData, "attendeeName");
const status = asString(formData, "status");
if (!id || !attendeeName || !["pending", "confirmed"].includes(status)) return;
const [registration] = await db
.update(meetingRegistrations)
.set({
attendeeName,
attendeeCompany: asString(formData, "attendeeCompany"),
attendeeEmail: asNullableString(formData, "attendeeEmail"),
attendeePhone: asNullableString(formData, "attendeePhone"),
status,
imageConsent: formData.get("imageConsent") === "on",
})
.where(eq(meetingRegistrations.id, id))
.returning({ meetingId: meetingRegistrations.meetingId });
revalidateMeetingPaths(registration?.meetingId);
}
export async function confirmMeetingRegistration(formData: FormData) {
const { role } = await requireRole();
if (!can(role, "manageMeetings")) throw new Error("Accès refusé");
const id = Number(formData.get("id"));
if (!id) return;
const [registration] = await db
.update(meetingRegistrations)
.set({ status: "confirmed" })
.where(eq(meetingRegistrations.id, id))
.returning({ meetingId: meetingRegistrations.meetingId });
revalidateMeetingPaths(registration?.meetingId);
}
export async function deleteMeetingRegistration(formData: FormData) {
const { role } = await requireRole();
if (!can(role, "manageMeetings")) throw new Error("Accès refusé");
const id = Number(formData.get("id"));
if (!id) return;
const [registration] = await db
.delete(meetingRegistrations)
.where(eq(meetingRegistrations.id, id))
.returning({ meetingId: meetingRegistrations.meetingId });
revalidateMeetingPaths(registration?.meetingId);
}
export async function createPastMeeting(formData: FormData) {
@@ -519,6 +628,8 @@ export async function createPastMeeting(formData: FormData) {
});
revalidatePath("/backend/rencontres");
revalidatePath("/association");
revalidatePath("/backend/rencontres-passees");
revalidatePath("/rencontres-passees");
}
export async function updatePastMeeting(formData: FormData) {
@@ -540,6 +651,8 @@ export async function updatePastMeeting(formData: FormData) {
.where(eq(pastMeetings.id, id));
revalidatePath("/backend/rencontres");
revalidatePath("/association");
revalidatePath("/backend/rencontres-passees");
revalidatePath("/rencontres-passees");
}
export async function deletePastMeeting(formData: FormData) {
@@ -550,6 +663,8 @@ export async function deletePastMeeting(formData: FormData) {
await db.delete(pastMeetings).where(eq(pastMeetings.id, id));
revalidatePath("/backend/rencontres");
revalidatePath("/association");
revalidatePath("/backend/rencontres-passees");
revalidatePath("/rencontres-passees");
}
export async function addPastMeetingPhoto(formData: FormData) {
@@ -570,6 +685,8 @@ export async function addPastMeetingPhoto(formData: FormData) {
});
revalidatePath("/backend/rencontres");
revalidatePath("/association");
revalidatePath("/backend/rencontres-passees");
revalidatePath("/rencontres-passees");
}
export async function deletePastMeetingPhoto(formData: FormData) {
@@ -580,6 +697,8 @@ export async function deletePastMeetingPhoto(formData: FormData) {
await db.delete(pastMeetingPhotos).where(eq(pastMeetingPhotos.id, id));
revalidatePath("/backend/rencontres");
revalidatePath("/association");
revalidatePath("/backend/rencontres-passees");
revalidatePath("/rencontres-passees");
}
// ---- Paramètres du site ----
+49 -2
View File
@@ -1,7 +1,8 @@
import { asc, desc, eq } from "drizzle-orm";
import Link from "next/link";
import { and, asc, desc, eq, gte, sql } from "drizzle-orm";
import { auth } from "@/auth";
import { db } from "@/db";
import { categories, imageConsents, members, promotions, type Member } from "@/db/schema";
import { categories, imageConsents, meetingRegistrations, meetings, members, promotions, type Member } from "@/db/schema";
import { ImageField } from "@/components/ImageField";
import { ImageConsentForm } from "@/components/ImageConsentForm";
import { communeOptions } from "@/lib/communes";
@@ -30,6 +31,7 @@ export default async function EspacePage() {
let subtitle = "Espace adhérent";
let profile: Member | null = null;
let myPromos: { id: number; title: string; status: string; createdAt: Date; imageUrl: string | null }[] = [];
let myRegistrations: { meetingId: number; title: string; startsAt: Date; location: string | null; participants: number; confirmed: boolean }[] = [];
let latestConsent: { decision: string; createdAt: Date } | null = null;
if (memberId) {
@@ -53,6 +55,20 @@ export default async function EspacePage() {
.from(promotions)
.where(eq(promotions.memberId, memberId))
.orderBy(desc(promotions.createdAt));
myRegistrations = await db
.select({
meetingId: meetings.id,
title: meetings.title,
startsAt: meetings.startsAt,
location: meetings.location,
participants: sql<number>`count(${meetingRegistrations.id})`,
confirmed: sql<boolean>`bool_and(${meetingRegistrations.status} = 'confirmed')`,
})
.from(meetingRegistrations)
.innerJoin(meetings, eq(meetings.id, meetingRegistrations.meetingId))
.where(and(eq(meetingRegistrations.memberId, memberId), gte(meetings.startsAt, new Date())))
.groupBy(meetings.id)
.orderBy(asc(meetings.startsAt));
const [consent] = await db
.select({ decision: imageConsents.decision, createdAt: imageConsents.createdAt })
.from(imageConsents)
@@ -93,6 +109,37 @@ export default async function EspacePage() {
</div>
</div>
{memberId && (
<section style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 24, marginBottom: 28 }}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, alignItems: "end", marginBottom: 15, flexWrap: "wrap" }}>
<div>
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 18, margin: 0, color: "#26201a" }}>Mes inscriptions</h3>
<div style={{ color: "#8c8068", fontSize: 13, marginTop: 4 }}>Participants inscrits aux prochaines rencontres.</div>
</div>
<Link href="/association" style={{ color: "#9a6638", fontSize: 13, fontWeight: 800, textDecoration: "none" }}>Voir les rencontres</Link>
</div>
{myRegistrations.length === 0 && <div style={{ color: "#a99c82", fontSize: 13.5 }}>Aucune inscription à venir.</div>}
<div style={{ display: "grid", gap: 10 }}>
{myRegistrations.map((registration) => (
<div key={registration.meetingId} style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 14, borderTop: "1px solid #f0e8d6", paddingTop: 12, flexWrap: "wrap" }}>
<div>
<div style={{ color: "#26201a", fontSize: 14, fontWeight: 800 }}>{registration.title}</div>
<div style={{ color: "#8c8068", fontSize: 12.5, marginTop: 3 }}>
{fmtDate(registration.startsAt)} · {registration.participants} participant{registration.participants > 1 ? "s" : ""}{registration.location ? ` · ${registration.location}` : ""}
</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<span style={{ background: registration.confirmed ? "#e6f4ec" : "#fbeede", color: registration.confirmed ? "#1f8a5b" : "#9a6638", borderRadius: 999, padding: "5px 10px", fontSize: 11.5, fontWeight: 800 }}>
{registration.confirmed ? "Confirmée" : "En attente"}
</span>
<Link href={`/inscription/${registration.meetingId}`} style={{ color: "#9a6638", fontSize: 12.5, fontWeight: 800, textDecoration: "none" }}>Modifier</Link>
</div>
</div>
))}
</div>
</section>
)}
{profile && (
<form action={updateOwnProfile} style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, padding: 24, marginBottom: 28 }}>
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 18, margin: "0 0 18px", color: "#26201a" }}>
+143
View File
@@ -0,0 +1,143 @@
import { redirect } from "next/navigation";
import type { CSSProperties } from "react";
import { and, desc, eq } from "drizzle-orm";
import { auth } from "@/auth";
import { db } from "@/db";
import { meetingRegistrations, meetings } from "@/db/schema";
import { can } from "@/lib/rbac";
import {
confirmMeetingRegistration,
deleteMeetingRegistration,
updateMeetingRegistration,
} from "../actions";
export const dynamic = "force-dynamic";
function formatDate(date: Date) {
return new Intl.DateTimeFormat("fr-FR", { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" }).format(date);
}
export default async function InscriptionsPage({ searchParams }: { searchParams: Promise<{ meeting?: string }> }) {
const session = await auth();
if (!can(session?.user.role, "manageMeetings")) redirect("/backend");
const { meeting: meetingParam } = await searchParams;
const selectedMeetingId = Number(meetingParam) || null;
const [meetingRows, registrationRows] = await Promise.all([
db.select({ id: meetings.id, title: meetings.title, startsAt: meetings.startsAt }).from(meetings).orderBy(desc(meetings.startsAt)),
db
.select({
id: meetingRegistrations.id,
meetingId: meetingRegistrations.meetingId,
meetingTitle: meetings.title,
meetingStartsAt: meetings.startsAt,
attendeeName: meetingRegistrations.attendeeName,
attendeeCompany: meetingRegistrations.attendeeCompany,
attendeeEmail: meetingRegistrations.attendeeEmail,
attendeePhone: meetingRegistrations.attendeePhone,
status: meetingRegistrations.status,
imageConsent: meetingRegistrations.imageConsent,
createdAt: meetingRegistrations.createdAt,
})
.from(meetingRegistrations)
.innerJoin(meetings, eq(meetings.id, meetingRegistrations.meetingId))
.where(selectedMeetingId ? and(eq(meetingRegistrations.meetingId, selectedMeetingId)) : undefined)
.orderBy(desc(meetings.startsAt), desc(meetingRegistrations.createdAt)),
]);
return (
<div style={{ display: "grid", gap: 22 }}>
<section style={{ display: "flex", justifyContent: "space-between", alignItems: "end", gap: 16, flexWrap: "wrap" }}>
<div>
<div style={sectionLabel}>Participants</div>
<div style={{ color: "#6c6150", fontSize: 13.5, marginTop: 5 }}>{registrationRows.length} inscription{registrationRows.length > 1 ? "s" : ""} affichée{registrationRows.length > 1 ? "s" : ""}</div>
</div>
<form method="get" style={{ display: "flex", gap: 9, alignItems: "end", flexWrap: "wrap" }}>
<div>
<label className="field-label" htmlFor="meeting-filter">Rencontre</label>
<select id="meeting-filter" name="meeting" className="field" defaultValue={selectedMeetingId ?? ""} style={{ minWidth: 260 }}>
<option value="">Toutes les rencontres</option>
{meetingRows.map((meeting) => <option key={meeting.id} value={meeting.id}>{meeting.title}</option>)}
</select>
</div>
<button type="submit" style={secondaryButton}>Filtrer</button>
</form>
</section>
{registrationRows.map((registration) => (
<article key={registration.id} style={cardStyle}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 14, alignItems: "start", flexWrap: "wrap", marginBottom: 16 }}>
<div>
<div className="font-display" style={{ color: "#26201a", fontSize: 19, fontWeight: 800 }}>{registration.attendeeName}</div>
<div style={{ color: "#8c8068", fontSize: 12.5, marginTop: 3 }}>{registration.meetingTitle} · {formatDate(registration.meetingStartsAt)}</div>
</div>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<span style={registration.status === "confirmed" ? confirmedBadge : pendingBadge}>{registration.status === "confirmed" ? "Confirmée" : "En attente"}</span>
<span style={registration.imageConsent ? consentBadge : refusalBadge}>{registration.imageConsent ? "Image autorisée" : "Image refusée"}</span>
</div>
</div>
<form action={updateMeetingRegistration} className="grid grid-2" style={{ gap: 12 }}>
<input type="hidden" name="id" value={registration.id} />
<div>
<label className="field-label">Participant</label>
<input name="attendeeName" className="field" defaultValue={registration.attendeeName} required />
</div>
<div>
<label className="field-label">Entreprise</label>
<input name="attendeeCompany" className="field" defaultValue={registration.attendeeCompany} />
</div>
<div>
<label className="field-label">E-mail</label>
<input name="attendeeEmail" type="email" className="field" defaultValue={registration.attendeeEmail ?? ""} />
</div>
<div>
<label className="field-label">Téléphone</label>
<input name="attendeePhone" className="field" defaultValue={registration.attendeePhone ?? ""} />
</div>
<div>
<label className="field-label">Statut</label>
<select name="status" className="field" defaultValue={registration.status}>
<option value="pending">En attente</option>
<option value="confirmed">Confirmée</option>
</select>
</div>
<label style={{ display: "flex", gap: 9, alignItems: "center", color: "#6c6150", fontSize: 13, paddingTop: 22 }}>
<input type="checkbox" name="imageConsent" defaultChecked={registration.imageConsent} />
Publication de l'image autorisée
</label>
<button type="submit" style={primaryButton}>Enregistrer</button>
</form>
<div style={{ display: "flex", gap: 10, marginTop: 12, paddingTop: 12, borderTop: "1px solid #f0e8d6", flexWrap: "wrap" }}>
{registration.status !== "confirmed" && (
<form action={confirmMeetingRegistration}>
<input type="hidden" name="id" value={registration.id} />
<button type="submit" style={confirmButton}>Confirmer</button>
</form>
)}
<form action={deleteMeetingRegistration}>
<input type="hidden" name="id" value={registration.id} />
<button type="submit" style={dangerButton}>Annuler l'inscription</button>
</form>
</div>
</article>
))}
{registrationRows.length === 0 && <div style={emptyStyle}>Aucune inscription pour cette sélection.</div>}
</div>
);
}
const sectionLabel: CSSProperties = { fontSize: 12.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800 };
const cardStyle: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 20 };
const badgeBase: CSSProperties = { display: "inline-block", borderRadius: 999, padding: "5px 10px", fontSize: 11.5, fontWeight: 800 };
const confirmedBadge: CSSProperties = { ...badgeBase, background: "#e6f4ec", color: "#1f8a5b" };
const pendingBadge: CSSProperties = { ...badgeBase, background: "#fbeede", color: "#9a6638" };
const consentBadge: CSSProperties = { ...badgeBase, background: "#eaf0f6", color: "#2C6FB3" };
const refusalBadge: CSSProperties = { ...badgeBase, background: "#fbe9e6", color: "#d8472b" };
const primaryButton: CSSProperties = { border: "none", background: "#13324F", color: "#fff", borderRadius: 8, padding: "11px 16px", fontWeight: 800, cursor: "pointer" };
const secondaryButton: CSSProperties = { border: "1px solid #d8cdb4", background: "#fff", color: "#6f6450", borderRadius: 8, padding: "11px 16px", fontWeight: 800, cursor: "pointer" };
const confirmButton: CSSProperties = { border: "none", background: "#1f8a5b", color: "#fff", borderRadius: 8, padding: "9px 14px", fontWeight: 800, cursor: "pointer" };
const dangerButton: CSSProperties = { border: "1px solid #e0c3bb", background: "#fff", color: "#d8472b", borderRadius: 8, padding: "8px 13px", fontWeight: 800, cursor: "pointer" };
const emptyStyle: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 28, color: "#8c8068", textAlign: "center" };
+172
View File
@@ -0,0 +1,172 @@
import { redirect } from "next/navigation";
import type { CSSProperties } from "react";
import { asc, desc, eq, sql } from "drizzle-orm";
import { auth } from "@/auth";
import { db } from "@/db";
import { meetingRegistrations, meetings, pastMeetingPhotos, pastMeetings } from "@/db/schema";
import { ImageField } from "@/components/ImageField";
import { can } from "@/lib/rbac";
import {
addPastMeetingPhoto,
createPastMeeting,
deletePastMeeting,
deletePastMeetingPhoto,
updatePastMeeting,
} from "../actions";
export const dynamic = "force-dynamic";
function dateValue(date: Date) {
const local = new Date(date);
local.setMinutes(local.getMinutes() - local.getTimezoneOffset());
return local.toISOString().slice(0, 10);
}
export default async function PastMeetingsAdminPage() {
const session = await auth();
if (!can(session?.user.role, "manageMeetings")) redirect("/backend");
const [meetingRows, archives, photos, refusals] = await Promise.all([
db
.select({
id: meetings.id,
title: meetings.title,
startsAt: meetings.startsAt,
registered: sql<number>`count(${meetingRegistrations.id})`,
})
.from(meetings)
.leftJoin(meetingRegistrations, eq(meetingRegistrations.meetingId, meetings.id))
.groupBy(meetings.id)
.orderBy(desc(meetings.startsAt)),
db.select().from(pastMeetings).orderBy(desc(pastMeetings.eventDate)),
db.select().from(pastMeetingPhotos).orderBy(asc(pastMeetingPhotos.position), asc(pastMeetingPhotos.id)),
db
.select({ meetingId: meetingRegistrations.meetingId, attendeeName: meetingRegistrations.attendeeName })
.from(meetingRegistrations)
.where(eq(meetingRegistrations.imageConsent, false))
.orderBy(asc(meetingRegistrations.attendeeName)),
]);
const photosByPast = new Map<number, typeof photos>();
for (const photo of photos) photosByPast.set(photo.pastMeetingId, [...(photosByPast.get(photo.pastMeetingId) ?? []), photo]);
const refusalsByMeeting = new Map<number, string[]>();
for (const refusal of refusals) refusalsByMeeting.set(refusal.meetingId, [...(refusalsByMeeting.get(refusal.meetingId) ?? []), refusal.attendeeName]);
return (
<div style={{ display: "grid", gap: 28 }}>
<section style={panelStyle}>
<h2 className="font-display" style={{ margin: "0 0 6px", fontSize: 20, color: "#26201a" }}>Nouvelle rencontre passée</h2>
<p style={{ color: "#8c8068", fontSize: 13, margin: "0 0 18px" }}>Les archives publiées apparaissent sur la page publique « Rencontres passées ».</p>
<form action={createPastMeeting} className="grid grid-2" style={{ gap: 16 }}>
<div>
<label className="field-label">Titre</label>
<input name="title" className="field" required />
</div>
<div>
<label className="field-label">Date</label>
<input name="eventDate" type="date" className="field" required />
</div>
<div>
<label className="field-label">Lieu</label>
<input name="location" className="field" />
</div>
<div>
<label className="field-label">Rencontre liée</label>
<select name="meetingId" className="field" defaultValue="">
<option value="">Aucune</option>
{meetingRows.map((meeting) => <option key={meeting.id} value={meeting.id}>{meeting.title} ({Number(meeting.registered)} participants)</option>)}
</select>
</div>
<div style={{ gridColumn: "1 / -1" }}>
<label className="field-label">Description</label>
<textarea name="description" rows={3} className="field" />
</div>
<div style={{ gridColumn: "1 / -1" }}>
<label className="field-label">Participants manuels (un par ligne, uniquement sans rencontre liée)</label>
<textarea name="participants" rows={3} className="field" />
</div>
<button type="submit" style={primaryButton}>Publier l'archive</button>
</form>
</section>
<section>
<div style={sectionTitle}>Archives publiées</div>
<div className="grid grid-3" style={{ gap: 18 }}>
{archives.map((archive) => {
const refused = archive.meetingId ? refusalsByMeeting.get(archive.meetingId) ?? [] : [];
return (
<article key={archive.id} style={cardStyle}>
{refused.length > 0 && (
<div style={{ background: "#fbe9e6", border: "1px solid #f0c4bb", color: "#b53a25", borderRadius: 8, padding: "11px 13px", fontSize: 12.5, lineHeight: 1.5, marginBottom: 13 }}>
<strong>Droit à l'image refusé :</strong> {refused.join(", ")}. Vérifiez que ces personnes ne figurent pas sur les photos publiées.
</div>
)}
<form action={updatePastMeeting} style={{ display: "grid", gap: 12 }}>
<input type="hidden" name="id" value={archive.id} />
<div>
<label className="field-label">Titre</label>
<input name="title" defaultValue={archive.title} className="field" required />
</div>
<div>
<label className="field-label">Date</label>
<input name="eventDate" type="date" defaultValue={dateValue(archive.eventDate)} className="field" required />
</div>
<div>
<label className="field-label">Lieu</label>
<input name="location" defaultValue={archive.location ?? ""} className="field" />
</div>
<div>
<label className="field-label">Rencontre liée</label>
<select name="meetingId" className="field" defaultValue={archive.meetingId ?? ""}>
<option value="">Aucune</option>
{meetingRows.map((meeting) => <option key={meeting.id} value={meeting.id}>{meeting.title} ({Number(meeting.registered)})</option>)}
</select>
</div>
<div>
<label className="field-label">Description</label>
<textarea name="description" rows={3} defaultValue={archive.description ?? ""} className="field" />
</div>
<div>
<label className="field-label">Participants manuels</label>
<textarea name="participants" rows={3} defaultValue={archive.participants ?? ""} className="field" />
</div>
<button type="submit" style={primaryButton}>Enregistrer</button>
</form>
<div style={{ marginTop: 14, borderTop: "1px solid #f0e8d6", paddingTop: 14 }}>
<form action={addPastMeetingPhoto} style={{ display: "grid", gap: 10 }}>
<input type="hidden" name="pastMeetingId" value={archive.id} />
<ImageField name="imageUrl" label="Ajouter une photo" height={100} />
<input name="caption" className="field" placeholder="Légende" />
<button type="submit" style={primaryButton}>Ajouter la photo</button>
</form>
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 8, marginTop: 12 }}>
{(photosByPast.get(archive.id) ?? []).map((photo) => (
<form key={photo.id} action={deletePastMeetingPhoto}>
<input type="hidden" name="id" value={photo.id} />
<button type="submit" title="Supprimer" aria-label={`Supprimer la photo ${photo.caption ?? ""}`} style={{ width: "100%", aspectRatio: "1 / 1", border: "1px solid #e6dcc6", borderRadius: 8, cursor: "pointer", background: `center/cover no-repeat url(${photo.imageUrl})` }} />
</form>
))}
</div>
</div>
<form action={deletePastMeeting} style={{ marginTop: 12 }}>
<input type="hidden" name="id" value={archive.id} />
<button type="submit" style={dangerButton}>Supprimer l'archive</button>
</form>
</article>
);
})}
</div>
{archives.length === 0 && <div style={emptyStyle}>Aucune rencontre passée publiée.</div>}
</section>
</div>
);
}
const panelStyle: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 22 };
const cardStyle: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 16 };
const sectionTitle: CSSProperties = { fontSize: 12.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, marginBottom: 12 };
const primaryButton: CSSProperties = { border: "none", background: "#13324F", color: "#fff", fontWeight: 800, fontSize: 13.5, padding: "11px 16px", borderRadius: 8, cursor: "pointer" };
const dangerButton: CSSProperties = { width: "100%", border: "1px solid #e0c3bb", background: "#fff", color: "#d8472b", fontWeight: 800, fontSize: 13, padding: "10px 14px", borderRadius: 8, cursor: "pointer" };
const emptyStyle: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 24, color: "#8c8068" };
+57 -207
View File
@@ -1,23 +1,15 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import type { CSSProperties } from "react";
import { asc, desc, eq, sql } from "drizzle-orm";
import { desc, eq, sql } from "drizzle-orm";
import { auth } from "@/auth";
import { db } from "@/db";
import { meetingRegistrations, meetings, pastMeetingPhotos, pastMeetings } from "@/db/schema";
import { meetingRegistrations, meetings } from "@/db/schema";
import { ImageField } from "@/components/ImageField";
import { MeetingEmailComposer } from "@/components/MeetingEmailComposer";
import { can } from "@/lib/rbac";
import { getSiteSettings } from "@/lib/site-settings";
import {
addPastMeetingPhoto,
createMeeting,
createPastMeeting,
deleteMeeting,
deletePastMeeting,
deletePastMeetingPhoto,
updateMeeting,
updatePastMeeting,
} from "../actions";
import { createMeeting, deleteMeeting, updateMeeting } from "../actions";
export const dynamic = "force-dynamic";
@@ -27,38 +19,29 @@ function dateTimeValue(date: Date) {
return local.toISOString().slice(0, 16);
}
function dateValue(date: Date) {
const local = new Date(date);
local.setMinutes(local.getMinutes() - local.getTimezoneOffset());
return local.toISOString().slice(0, 10);
}
export default async function RencontresAdminPage() {
const session = await auth();
if (!can(session?.user.role, "manageMeetings")) redirect("/backend");
const rows = await db
.select({
id: meetings.id,
title: meetings.title,
startsAt: meetings.startsAt,
location: meetings.location,
description: meetings.description,
capacity: meetings.capacity,
imageUrl: meetings.imageUrl,
registered: sql<number>`count(${meetingRegistrations.id})`,
})
.from(meetings)
.leftJoin(meetingRegistrations, eq(meetingRegistrations.meetingId, meetings.id))
.groupBy(meetings.id)
.orderBy(desc(meetings.startsAt));
const archives = await db.select().from(pastMeetings).orderBy(desc(pastMeetings.eventDate));
const photos = await db
.select()
.from(pastMeetingPhotos)
.orderBy(asc(pastMeetingPhotos.position), asc(pastMeetingPhotos.id));
const settings = await getSiteSettings();
const [rows, settings] = await Promise.all([
db
.select({
id: meetings.id,
title: meetings.title,
startsAt: meetings.startsAt,
location: meetings.location,
description: meetings.description,
capacity: meetings.capacity,
participantsPerAccount: meetings.participantsPerAccount,
imageUrl: meetings.imageUrl,
registered: sql<number>`count(${meetingRegistrations.id})`,
})
.from(meetings)
.leftJoin(meetingRegistrations, eq(meetingRegistrations.meetingId, meetings.id))
.groupBy(meetings.id)
.orderBy(desc(meetings.startsAt)),
getSiteSettings(),
]);
const emailBrand = {
associationName: settings.association_name,
address: settings.association_address,
@@ -67,17 +50,10 @@ export default async function RencontresAdminPage() {
siret: settings.association_siret,
};
const photosByPast = new Map<number, typeof photos>();
for (const photo of photos) {
photosByPast.set(photo.pastMeetingId, [...(photosByPast.get(photo.pastMeetingId) ?? []), photo]);
}
return (
<div style={{ display: "grid", gap: 28 }}>
<section style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, padding: 22 }}>
<h2 className="font-display" style={{ margin: "0 0 16px", fontSize: 20, color: "#26201a" }}>
Nouvelle rencontre
</h2>
<section style={panelStyle}>
<h2 className="font-display" style={{ margin: "0 0 16px", fontSize: 20, color: "#26201a" }}>Nouvelle rencontre</h2>
<form action={createMeeting} className="grid grid-2" style={{ gap: 16 }}>
<div>
<label className="field-label">Titre</label>
@@ -91,9 +67,15 @@ export default async function RencontresAdminPage() {
<label className="field-label">Lieu</label>
<input name="location" className="field" />
</div>
<div>
<label className="field-label">Places</label>
<input name="capacity" type="number" min="1" defaultValue={30} className="field" />
<div className="grid grid-2" style={{ gap: 12 }}>
<div>
<label className="field-label">Places</label>
<input name="capacity" type="number" min="1" defaultValue={30} className="field" />
</div>
<div>
<label className="field-label">Max. par compte</label>
<input name="participantsPerAccount" type="number" min="1" max="100" defaultValue={1} className="field" />
</div>
</div>
<div style={{ gridColumn: "1 / -1" }}>
<label className="field-label">Description</label>
@@ -102,14 +84,12 @@ export default async function RencontresAdminPage() {
<div style={{ gridColumn: "1 / -1" }}>
<ImageField name="imageUrl" label="Image" height={160} />
</div>
<button type="submit" style={primaryButton}>
Ajouter la rencontre
</button>
<button type="submit" style={primaryButton}>Ajouter la rencontre</button>
</form>
</section>
<section>
<div style={sectionTitle}>Rencontres à venir / publiées</div>
<div style={sectionTitle}>Rencontres publiées</div>
<div className="grid grid-3" style={{ gap: 18 }}>
{rows.map((meeting) => (
<article key={meeting.id} style={cardStyle}>
@@ -128,19 +108,27 @@ export default async function RencontresAdminPage() {
<label className="field-label">Lieu</label>
<input name="location" defaultValue={meeting.location ?? ""} className="field" />
</div>
<div>
<label className="field-label">Places</label>
<input name="capacity" type="number" min="1" defaultValue={meeting.capacity} className="field" />
<div className="grid grid-2" style={{ gap: 10 }}>
<div>
<label className="field-label">Places</label>
<input name="capacity" type="number" min="1" defaultValue={meeting.capacity} className="field" />
</div>
<div>
<label className="field-label">Max. / compte</label>
<input name="participantsPerAccount" type="number" min="1" max="100" defaultValue={meeting.participantsPerAccount} className="field" />
</div>
</div>
<div>
<label className="field-label">Description</label>
<textarea name="description" rows={3} defaultValue={meeting.description ?? ""} className="field" />
</div>
<div style={{ fontSize: 12.5, color: "#6c6150", fontWeight: 800 }}>
{Number(meeting.registered)} inscrit(s)
</div>
<button type="submit" style={primaryButton}>Enregistrer</button>
</form>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12, marginTop: 14, paddingTop: 14, borderTop: "1px solid #f0e8d6" }}>
<div style={{ fontSize: 12.5, color: "#6c6150", fontWeight: 800 }}>{Number(meeting.registered)} participant(s)</div>
<Link href={`/backend/inscriptions?meeting=${meeting.id}`} style={{ color: "#9a6638", fontSize: 12.5, fontWeight: 800, textDecoration: "none" }}>Voir les inscrits</Link>
</div>
<MeetingEmailComposer
meeting={{
id: meeting.id,
@@ -160,153 +148,15 @@ export default async function RencontresAdminPage() {
</article>
))}
</div>
</section>
<section style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, padding: 22 }}>
<h2 className="font-display" style={{ margin: "0 0 16px", fontSize: 20, color: "#26201a" }}>
Nouvelle rencontre passée
</h2>
<form action={createPastMeeting} className="grid grid-2" style={{ gap: 16 }}>
<div>
<label className="field-label">Titre</label>
<input name="title" className="field" required />
</div>
<div>
<label className="field-label">Date</label>
<input name="eventDate" type="date" className="field" required />
</div>
<div>
<label className="field-label">Lieu</label>
<input name="location" className="field" />
</div>
<div>
<label className="field-label">Lier une rencontre</label>
<select name="meetingId" className="field" defaultValue="">
<option value="">Aucune</option>
{rows.map((meeting) => (
<option key={meeting.id} value={meeting.id}>
{meeting.title}
</option>
))}
</select>
</div>
<div style={{ gridColumn: "1 / -1" }}>
<label className="field-label">Description</label>
<textarea name="description" rows={3} className="field" />
</div>
<div style={{ gridColumn: "1 / -1" }}>
<label className="field-label">Participants manuels (un par ligne, si non liée)</label>
<textarea name="participants" rows={3} className="field" />
</div>
<button type="submit" style={primaryButton}>Publier l'archive</button>
</form>
</section>
<section>
<div style={sectionTitle}>Rencontres passées</div>
<div className="grid grid-3" style={{ gap: 18 }}>
{archives.map((archive) => (
<article key={archive.id} style={cardStyle}>
<form action={updatePastMeeting} style={{ display: "grid", gap: 12 }}>
<input type="hidden" name="id" value={archive.id} />
<div>
<label className="field-label">Titre</label>
<input name="title" defaultValue={archive.title} className="field" required />
</div>
<div>
<label className="field-label">Date</label>
<input name="eventDate" type="date" defaultValue={dateValue(archive.eventDate)} className="field" required />
</div>
<div>
<label className="field-label">Lieu</label>
<input name="location" defaultValue={archive.location ?? ""} className="field" />
</div>
<div>
<label className="field-label">Rencontre liée</label>
<select name="meetingId" className="field" defaultValue={archive.meetingId ?? ""}>
<option value="">Aucune</option>
{rows.map((meeting) => (
<option key={meeting.id} value={meeting.id}>
{meeting.title}
</option>
))}
</select>
</div>
<div>
<label className="field-label">Description</label>
<textarea name="description" rows={3} defaultValue={archive.description ?? ""} className="field" />
</div>
<div>
<label className="field-label">Participants manuels</label>
<textarea name="participants" rows={3} defaultValue={archive.participants ?? ""} className="field" />
</div>
<button type="submit" style={primaryButton}>Enregistrer</button>
</form>
<div style={{ marginTop: 14, borderTop: "1px solid #f0e8d6", paddingTop: 14 }}>
<form action={addPastMeetingPhoto} style={{ display: "grid", gap: 10 }}>
<input type="hidden" name="pastMeetingId" value={archive.id} />
<ImageField name="imageUrl" label="Ajouter une photo" height={100} />
<input name="caption" className="field" placeholder="Légende" />
<button type="submit" style={primaryButton}>Ajouter la photo</button>
</form>
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 8, marginTop: 12 }}>
{(photosByPast.get(archive.id) ?? []).map((photo) => (
<form key={photo.id} action={deletePastMeetingPhoto}>
<input type="hidden" name="id" value={photo.id} />
<button type="submit" title="Supprimer" style={{ width: "100%", aspectRatio: "1 / 1", border: "1px solid #e6dcc6", borderRadius: 8, cursor: "pointer", background: `center/cover no-repeat url(${photo.imageUrl})` }} />
</form>
))}
</div>
</div>
<form action={deletePastMeeting} style={{ marginTop: 12 }}>
<input type="hidden" name="id" value={archive.id} />
<button type="submit" style={dangerButton}>Supprimer l'archive</button>
</form>
</article>
))}
</div>
{rows.length === 0 && <div style={emptyStyle}>Aucune rencontre programmée.</div>}
</section>
</div>
);
}
const cardStyle: CSSProperties = {
background: "#fff",
border: "1px solid #e6dcc6",
borderRadius: 12,
padding: 16,
};
const sectionTitle: CSSProperties = {
fontSize: 12.5,
letterSpacing: "0.08em",
textTransform: "uppercase" as const,
color: "#9a8d72",
fontWeight: 800,
marginBottom: 12,
};
const primaryButton: CSSProperties = {
border: "none",
background: "#13324F",
color: "#fff",
fontWeight: 800,
fontSize: 13.5,
padding: "11px 16px",
borderRadius: 10,
cursor: "pointer",
};
const dangerButton: CSSProperties = {
width: "100%",
border: "1px solid #e0c3bb",
background: "#fff",
color: "#d8472b",
fontWeight: 800,
fontSize: 13,
padding: "10px 14px",
borderRadius: 10,
cursor: "pointer",
};
const panelStyle: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 22 };
const cardStyle: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 16 };
const sectionTitle: CSSProperties = { fontSize: 12.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, marginBottom: 12 };
const primaryButton: CSSProperties = { border: "none", background: "#13324F", color: "#fff", fontWeight: 800, fontSize: 13.5, padding: "11px 16px", borderRadius: 8, cursor: "pointer" };
const dangerButton: CSSProperties = { width: "100%", border: "1px solid #e0c3bb", background: "#fff", color: "#d8472b", fontWeight: 800, fontSize: 13, padding: "10px 14px", borderRadius: 8, cursor: "pointer" };
const emptyStyle: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 24, color: "#8c8068" };
+11
View File
@@ -534,6 +534,17 @@ img {
align-items: start;
}
.meeting-registration-layout {
display: grid;
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
}
@media (max-width: 860px) {
.meeting-registration-layout {
grid-template-columns: 1fr;
}
}
@media (max-width: 1024px) {
.email-workbench,
.meeting-email-workbench { grid-template-columns: 1fr; }
+135
View File
@@ -0,0 +1,135 @@
import Link from "next/link";
import { and, asc, eq, gte, sql } from "drizzle-orm";
import { auth } from "@/auth";
import { MeetingRegistrationForm } from "@/components/MeetingRegistrationForm";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
import { db } from "@/db";
import { meetingRegistrations, meetings, members } from "@/db/schema";
export const dynamic = "force-dynamic";
function formatDate(date: Date) {
return new Intl.DateTimeFormat("fr-FR", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
}).format(date);
}
function formatTime(date: Date) {
return new Intl.DateTimeFormat("fr-FR", { hour: "2-digit", minute: "2-digit" }).format(date);
}
export default async function MeetingRegistrationPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const meetingId = Number(id);
const session = await auth();
const now = new Date();
const [meeting] = Number.isInteger(meetingId)
? await db
.select({
id: meetings.id,
title: meetings.title,
startsAt: meetings.startsAt,
location: meetings.location,
description: meetings.description,
capacity: meetings.capacity,
participantsPerAccount: meetings.participantsPerAccount,
imageUrl: meetings.imageUrl,
registered: sql<number>`count(${meetingRegistrations.id})`,
})
.from(meetings)
.leftJoin(meetingRegistrations, eq(meetingRegistrations.meetingId, meetings.id))
.where(and(eq(meetings.id, meetingId), gte(meetings.startsAt, now)))
.groupBy(meetings.id)
: [];
const memberId = session?.user.memberId ?? null;
const [member, existing] = memberId && meeting
? await Promise.all([
db
.select({ name: members.name, status: members.status })
.from(members)
.where(eq(members.id, memberId))
.then((rows) => rows[0] ?? null),
db
.select({
name: meetingRegistrations.attendeeName,
imageConsent: meetingRegistrations.imageConsent,
status: meetingRegistrations.status,
})
.from(meetingRegistrations)
.where(and(eq(meetingRegistrations.meetingId, meeting.id), eq(meetingRegistrations.memberId, memberId)))
.orderBy(asc(meetingRegistrations.createdAt), asc(meetingRegistrations.id)),
])
: [null, []];
const registered = meeting ? Number(meeting.registered) : 0;
const remaining = meeting ? Math.max(0, meeting.capacity - registered) : 0;
return (
<div style={{ background: "#F6F2E8", minHeight: "100vh", fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
<SiteHeader active="association" logo />
<main>
{!meeting ? (
<section className="container" style={{ paddingTop: 72, paddingBottom: 96, maxWidth: 760 }}>
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: "32px clamp(22px, 5vw, 38px)" }}>
<div style={{ color: "#9a6638", fontSize: 12, letterSpacing: "0.14em", textTransform: "uppercase", fontWeight: 800 }}>Rencontre</div>
<h1 className="font-display" style={{ color: "#26201a", fontSize: 34, margin: "8px 0 12px" }}>Inscription indisponible</h1>
<p style={{ color: "#6c6150", lineHeight: 1.7 }}>Cette rencontre n'existe pas ou les inscriptions sont terminées.</p>
<Link href="/association" style={{ display: "inline-block", marginTop: 12, color: "#9a6638", fontWeight: 800, textDecoration: "none" }}>Voir les prochaines rencontres</Link>
</div>
</section>
) : (
<section className="container" style={{ paddingTop: 40, paddingBottom: 80 }}>
<div style={{ gap: 28, alignItems: "start" }} className="meeting-registration-layout">
<article style={{ background: "#13324F", color: "#fff", borderRadius: 8, overflow: "hidden" }}>
<div style={{ aspectRatio: "16 / 9", background: meeting.imageUrl ? `center/cover no-repeat url(${meeting.imageUrl})` : "repeating-linear-gradient(45deg,#244965,#244965 12px,#2d5571 12px,#2d5571 24px)" }} />
<div style={{ padding: 24 }}>
<div style={{ color: "#E0A63C", fontSize: 12.5, fontWeight: 800 }}>{formatDate(meeting.startsAt)} · {formatTime(meeting.startsAt)}</div>
<h1 className="font-display" style={{ fontSize: 30, margin: "9px 0 8px" }}>{meeting.title}</h1>
{meeting.location && <div style={{ color: "#cfe0ee", fontSize: 14, fontWeight: 700 }}>{meeting.location}</div>}
{meeting.description && <p style={{ color: "#cfe0ee", lineHeight: 1.7, margin: "16px 0" }}>{meeting.description}</p>}
<div style={{ borderTop: "1px solid rgba(255,255,255,.16)", paddingTop: 15, color: "#fff", fontSize: 13.5, fontWeight: 800 }}>
{registered}/{meeting.capacity} participants · {remaining} place{remaining > 1 ? "s" : ""} restante{remaining > 1 ? "s" : ""}
</div>
</div>
</article>
<section style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: "clamp(22px, 4vw, 30px)" }}>
<div style={{ color: "#9a6638", fontSize: 12, letterSpacing: "0.14em", textTransform: "uppercase", fontWeight: 800 }}>Inscription membre</div>
<h2 className="font-display" style={{ color: "#26201a", fontSize: 28, margin: "8px 0 20px" }}>
{existing.length > 0 ? "Modifier les participants" : "Inscrire les participants"}
</h2>
{!session?.user || !memberId ? (
<div>
<p style={{ color: "#6c6150", lineHeight: 1.7 }}>Connectez-vous avec votre compte adhérent avant de choisir les participants.</p>
<Link href="/login" style={{ display: "inline-block", background: "#13324F", color: "#fff", borderRadius: 8, padding: "13px 20px", fontWeight: 800, textDecoration: "none" }}>Se connecter</Link>
</div>
) : member?.status !== "active" ? (
<div style={{ background: "#fbeede", border: "1px solid #ecd8b8", color: "#9a6638", borderRadius: 8, padding: "14px 16px", lineHeight: 1.6 }}>
Votre adhésion doit être active avant de pouvoir vous inscrire aux rencontres.
</div>
) : remaining === 0 && existing.length === 0 ? (
<div style={{ background: "#f1efe7", border: "1px solid #ded8c8", color: "#6c6150", borderRadius: 8, padding: "14px 16px" }}>Cette rencontre est complète.</div>
) : (
<MeetingRegistrationForm
meetingId={meeting.id}
defaultName={session.user.name ?? member.name}
existing={existing}
maxPerAccount={meeting.participantsPerAccount}
remaining={remaining}
/>
)}
</section>
</div>
</section>
)}
</main>
<SiteFooter />
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
import { asc, desc, inArray } from "drizzle-orm";
import { PastMeetingGallery } from "@/components/PastMeetingGallery";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
import { db } from "@/db";
import { meetingRegistrations, pastMeetingPhotos, pastMeetings } from "@/db/schema";
import { splitLines } from "@/lib/site-settings";
export const dynamic = "force-dynamic";
function formatDate(date: Date) {
return new Intl.DateTimeFormat("fr-FR", { day: "numeric", month: "long", year: "numeric" }).format(date);
}
export default async function PastMeetingsPage() {
const [archives, photos] = await Promise.all([
db.select().from(pastMeetings).orderBy(desc(pastMeetings.eventDate)),
db.select().from(pastMeetingPhotos).orderBy(asc(pastMeetingPhotos.position), asc(pastMeetingPhotos.id)),
]);
const linkedIds = archives.map((archive) => archive.meetingId).filter((id): id is number => id !== null);
const linkedRegistrations = linkedIds.length > 0
? await db
.select({ meetingId: meetingRegistrations.meetingId, id: meetingRegistrations.id })
.from(meetingRegistrations)
.where(inArray(meetingRegistrations.meetingId, linkedIds))
: [];
const photosByPast = new Map<number, typeof photos>();
for (const photo of photos) photosByPast.set(photo.pastMeetingId, [...(photosByPast.get(photo.pastMeetingId) ?? []), photo]);
const registrationCounts = new Map<number, number>();
for (const registration of linkedRegistrations) registrationCounts.set(registration.meetingId, (registrationCounts.get(registration.meetingId) ?? 0) + 1);
return (
<div style={{ background: "#F6F2E8", minHeight: "100vh", fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
<SiteHeader active="past" logo />
<main>
<section style={{ background: "#13324F", color: "#fff" }}>
<div className="container" style={{ paddingTop: 58, paddingBottom: 54 }}>
<div style={{ color: "#E0A63C", fontSize: 12, letterSpacing: "0.14em", textTransform: "uppercase", fontWeight: 800 }}>Ils y étaient</div>
<h1 className="font-display" style={{ margin: "10px 0 14px", fontSize: "clamp(38px, 7vw, 62px)", lineHeight: 1 }}>Nos rencontres passées</h1>
<p style={{ maxWidth: 680, color: "#cfe0ee", fontSize: 17, lineHeight: 1.7, margin: 0 }}>
Revivez en images les temps forts de Plein R et les moments partagés par les acteurs économiques du Bassin de Pompey.
</p>
</div>
</section>
<section className="container" style={{ paddingTop: 54, paddingBottom: 80 }}>
{archives.length === 0 ? (
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 34, color: "#8c8068", textAlign: "center" }}>Aucune rencontre passée publiée pour le moment.</div>
) : (
<div className="grid grid-3" style={{ gap: 20 }}>
{archives.map((archive) => {
const participantCount = archive.meetingId
? registrationCounts.get(archive.meetingId) ?? 0
: splitLines(archive.participants ?? "").length;
return (
<article key={archive.id} style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 18 }}>
<div style={{ color: "#9a6638", fontSize: 12.5, fontWeight: 800 }}>{formatDate(archive.eventDate)}</div>
<h2 className="font-display" style={{ color: "#26201a", fontSize: 23, margin: "7px 0" }}>{archive.title}</h2>
{archive.location && <div style={{ color: "#6c6150", fontSize: 13, fontWeight: 700 }}>{archive.location}</div>}
{archive.description && <p style={{ color: "#8c8068", fontSize: 13.5, lineHeight: 1.65 }}>{archive.description}</p>}
<div style={{ color: "#6c6150", fontSize: 12.5, fontWeight: 800, marginBottom: 12 }}>
{participantCount} participant{participantCount > 1 ? "s" : ""} · {(photosByPast.get(archive.id) ?? []).length} photo{(photosByPast.get(archive.id) ?? []).length > 1 ? "s" : ""}
</div>
<PastMeetingGallery title={archive.title} photos={photosByPast.get(archive.id) ?? []} />
</article>
);
})}
</div>
)}
</section>
</main>
<SiteFooter />
</div>
);
}
+176
View File
@@ -0,0 +1,176 @@
"use client";
import { useActionState, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import {
saveMeetingRegistration,
type MeetingRegistrationState,
} from "@/app/backend/actions";
type Participant = {
name: string;
imageConsent: boolean;
status?: string;
};
const INITIAL_STATE: MeetingRegistrationState = { status: "idle", message: "" };
export function MeetingRegistrationForm({
meetingId,
defaultName,
existing,
maxPerAccount,
remaining,
}: {
meetingId: number;
defaultName: string;
existing: Participant[];
maxPerAccount: number;
remaining: number;
}) {
const router = useRouter();
const [participants, setParticipants] = useState<Participant[]>(
existing.length > 0 ? existing : [{ name: defaultName, imageConsent: false }],
);
const [state, formAction, pending] = useActionState(saveMeetingRegistration, INITIAL_STATE);
const maxAllowed = Math.max(existing.length, Math.min(maxPerAccount, existing.length + remaining));
const registered = existing.length > 0;
useEffect(() => {
if (state.status !== "success") return;
if (state.registrationCount === 0) {
setParticipants([{ name: defaultName, imageConsent: false }]);
}
router.refresh();
}, [defaultName, router, state]);
return (
<form action={formAction} style={{ display: "grid", gap: 18 }}>
<input type="hidden" name="meetingId" value={meetingId} />
<input type="hidden" name="participantCount" value={participants.length} />
<div style={{ background: "#f6f2e8", border: "1px solid #e6dcc6", borderRadius: 8, padding: "13px 15px", color: "#6c6150", fontSize: 13.5, lineHeight: 1.6 }}>
Maximum {maxPerAccount} participant{maxPerAccount > 1 ? "s" : ""} par compte membre.
{registered && (
<span style={{ display: "block", color: "#9a6638", fontWeight: 800, marginTop: 3 }}>
{existing.every((participant) => participant.status === "confirmed")
? "Inscription confirmée"
: "Inscription en attente de validation"}
</span>
)}
</div>
<div style={{ display: "grid", gap: 12 }}>
{participants.map((participant, index) => (
<div key={index} style={{ border: "1px solid #e6dcc6", borderRadius: 8, padding: 16, background: "#fff" }}>
<div style={{ display: "flex", gap: 10, alignItems: "end" }}>
<div style={{ flex: 1 }}>
<label className="field-label" htmlFor={`participant-${index}`}>
Participant {index + 1} - nom et prénom
</label>
<input
id={`participant-${index}`}
name={`participantName-${index}`}
className="field"
required
maxLength={200}
value={participant.name}
onChange={(event) =>
setParticipants((current) =>
current.map((item, itemIndex) =>
itemIndex === index ? { ...item, name: event.target.value } : item,
),
)
}
/>
</div>
{participants.length > 1 && (
<button
type="button"
aria-label={`Retirer le participant ${index + 1}`}
title="Retirer"
onClick={() => setParticipants((current) => current.filter((_, itemIndex) => itemIndex !== index))}
style={{ width: 42, height: 42, border: "1px solid #e0c3bb", background: "#fff", color: "#d8472b", borderRadius: 8, cursor: "pointer", fontSize: 20 }}
>
×
</button>
)}
</div>
<label style={{ display: "flex", gap: 9, alignItems: "start", color: "#6c6150", fontSize: 12.5, lineHeight: 1.5, marginTop: 11 }}>
<input
type="checkbox"
name={`imageConsent-${index}`}
checked={participant.imageConsent}
onChange={() =>
setParticipants((current) =>
current.map((item, itemIndex) =>
itemIndex === index ? { ...item, imageConsent: !item.imageConsent } : item,
),
)
}
style={{ marginTop: 3 }}
/>
Cette personne autorise la publication de son image sur les supports de Plein R.
</label>
</div>
))}
</div>
{participants.length < maxAllowed && (
<button
type="button"
onClick={() => setParticipants((current) => [...current, { name: "", imageConsent: false }])}
style={{ justifySelf: "start", border: "none", background: "transparent", color: "#9a6638", fontWeight: 800, cursor: "pointer", padding: 0 }}
>
+ Ajouter un participant
</button>
)}
<label style={{ display: "flex", gap: 9, alignItems: "start", color: "#6c6150", fontSize: 12.5, lineHeight: 1.55 }}>
<input type="checkbox" name="attestation" required style={{ marginTop: 3 }} />
J'atteste avoir informé chaque personne inscrite que des photos peuvent être prises et avoir recueilli son accord ou son refus ci-dessus.
</label>
{state.message && (
<div
role="status"
style={{
background: state.status === "success" ? "#e6f4ec" : "#fbe9e6",
border: `1px solid ${state.status === "success" ? "#b9dfc8" : "#f0c4bb"}`,
color: state.status === "success" ? "#1f6f4a" : "#b53a25",
borderRadius: 8,
padding: "11px 14px",
fontSize: 13.5,
fontWeight: 700,
}}
>
{state.message}
</div>
)}
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
<button
type="submit"
name="intent"
value="save"
disabled={pending || maxAllowed < 1}
style={{ border: "none", background: "#13324F", color: "#fff", borderRadius: 8, padding: "13px 20px", fontWeight: 800, cursor: pending ? "default" : "pointer", opacity: pending ? 0.65 : 1 }}
>
{pending ? "Enregistrement..." : registered ? "Modifier mon inscription" : "Enregistrer mon inscription"}
</button>
{registered && (
<button
type="submit"
name="intent"
value="cancel"
formNoValidate
disabled={pending}
style={{ border: "1px solid #e0c3bb", background: "#fff", color: "#d8472b", borderRadius: 8, padding: "12px 18px", fontWeight: 800, cursor: pending ? "default" : "pointer" }}
>
Annuler mon inscription
</button>
)}
</div>
</form>
);
}
+1
View File
@@ -5,6 +5,7 @@ const NAV = [
{ key: "annuaire", label: "Annuaire", href: "/annuaire" },
{ key: "promotions", label: "Promotions", href: "/#promotions" },
{ key: "association", label: "L'association", href: "/association" },
{ key: "past", label: "Rencontres passées", href: "/rencontres-passees" },
];
export function SiteHeader({ active, logo = false }: { active?: string; logo?: boolean }) {
+7 -4
View File
@@ -7,6 +7,7 @@ import {
varchar,
boolean,
timestamp,
index,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
@@ -140,6 +141,7 @@ export const meetings = pgTable("meetings", {
location: varchar("location", { length: 240 }),
description: text("description"),
capacity: integer("capacity").notNull().default(30),
participantsPerAccount: integer("participants_per_account").notNull().default(1),
imageUrl: text("image_url"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
@@ -153,15 +155,16 @@ export const meetingRegistrations = pgTable(
.references(() => meetings.id, { onDelete: "cascade" }),
memberId: integer("member_id").references(() => members.id, { onDelete: "set null" }),
attendeeName: varchar("attendee_name", { length: 200 }).notNull(),
attendeeCompany: varchar("attendee_company", { length: 200 }).notNull().default(""),
attendeeEmail: varchar("attendee_email", { length: 200 }),
attendeePhone: varchar("attendee_phone", { length: 40 }),
status: varchar("status", { length: 20 }).notNull().default("pending"),
imageConsent: boolean("image_consent").notNull().default(false),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => ({
meetingMemberIdx: uniqueIndex("meeting_registrations_meeting_member_idx").on(
t.meetingId,
t.memberId
),
meetingIdx: index("meeting_registrations_meeting_idx").on(t.meetingId),
memberMeetingIdx: index("meeting_registrations_member_meeting_idx").on(t.memberId, t.meetingId),
})
);
+1 -1
View File
@@ -138,7 +138,7 @@ export function buildMeetingEmail(meeting: MeetingEmailData, texts: MeetingEmail
minute: "2-digit",
}).format(date);
const remaining = Math.max(0, meeting.capacity - meeting.registered);
const registrationUrl = `${baseUrl.replace(/\/$/, "")}/association#rencontre-${meeting.id}`;
const registrationUrl = `${baseUrl.replace(/\/$/, "")}/inscription/${meeting.id}`;
const subject = `Invitation Plein R — ${meeting.title} · ${dateLong}`;
const button = cta(registrationUrl, "Je m'inscris à la rencontre");
const body = `