Add association meetings and GDPR settings

This commit is contained in:
Michael committed 2026-07-17 17:47:24 +02:00
1 parent b4c8967a3c
commit 28ada88155
20 files changed
+2663 -27

No files matched your search

+66
View File
@@ -0,0 +1,66 @@
CREATE TABLE "image_consents" (
"id" serial PRIMARY KEY NOT NULL,
"member_id" integer NOT NULL,
"decision" varchar(20) NOT NULL,
"scopes" text,
"signatory_name" varchar(200),
"signature_png" text,
"consent_version" varchar(40) NOT NULL,
"ip" varchar(120),
"user_agent" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "meeting_registrations" (
"id" serial PRIMARY KEY NOT NULL,
"meeting_id" integer NOT NULL,
"member_id" integer,
"attendee_name" varchar(200) NOT NULL,
"attendee_email" varchar(200),
"image_consent" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "meetings" (
"id" serial PRIMARY KEY NOT NULL,
"title" varchar(200) NOT NULL,
"starts_at" timestamp with time zone NOT NULL,
"location" varchar(240),
"description" text,
"capacity" integer DEFAULT 30 NOT NULL,
"image_url" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "past_meeting_photos" (
"id" serial PRIMARY KEY NOT NULL,
"past_meeting_id" integer NOT NULL,
"image_url" text NOT NULL,
"caption" varchar(200),
"position" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "past_meetings" (
"id" serial PRIMARY KEY NOT NULL,
"title" varchar(200) NOT NULL,
"event_date" timestamp with time zone NOT NULL,
"location" varchar(240),
"description" text,
"participants" text,
"meeting_id" integer,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "site_settings" (
"key" varchar(120) PRIMARY KEY NOT NULL,
"value" text,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "image_consents" ADD CONSTRAINT "image_consents_member_id_members_id_fk" FOREIGN KEY ("member_id") REFERENCES "public"."members"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "meeting_registrations" ADD CONSTRAINT "meeting_registrations_meeting_id_meetings_id_fk" FOREIGN KEY ("meeting_id") REFERENCES "public"."meetings"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "meeting_registrations" ADD CONSTRAINT "meeting_registrations_member_id_members_id_fk" FOREIGN KEY ("member_id") REFERENCES "public"."members"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "past_meeting_photos" ADD CONSTRAINT "past_meeting_photos_past_meeting_id_past_meetings_id_fk" FOREIGN KEY ("past_meeting_id") REFERENCES "public"."past_meetings"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "past_meetings" ADD CONSTRAINT "past_meetings_meeting_id_meetings_id_fk" FOREIGN KEY ("meeting_id") REFERENCES "public"."meetings"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "meeting_registrations_meeting_member_idx" ON "meeting_registrations" USING btree ("meeting_id","member_id");
File diff suppressed because it is too large. Load diff
+7
View File
@@ -29,6 +29,13 @@
"when": 1782545608919, "when": 1782545608919,
"tag": "0003_outstanding_jimmy_woo", "tag": "0003_outstanding_jimmy_woo",
"breakpoints": true "breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1784303079565,
"tag": "0004_tiny_vanisher",
"breakpoints": true
} }
] ]
} }
+311
View File
@@ -0,0 +1,311 @@
import Link from "next/link";
import { and, asc, desc, 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 { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
import { getSiteSettings, parseBoardMembers, splitLines } from "@/lib/site-settings";
import { cancelMeetingRegistration, registerForMeeting } from "../backend/actions";
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 AssociationPage() {
const [settings, session] = await Promise.all([getSiteSettings(), auth()]);
const now = new Date();
const memberId = session?.user.memberId ?? null;
const upcoming = 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))
.where(gte(meetings.startsAt, now))
.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
.select({ meetingId: meetingRegistrations.meetingId })
.from(meetingRegistrations)
.where(
and(
eq(meetingRegistrations.memberId, memberId),
inArray(
meetingRegistrations.meetingId,
upcoming.map((meeting) => meeting.id)
)
)
)
: [];
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],
["Secrétariat", settings.association_secretary],
].filter(([, name]) => name);
const board = parseBoardMembers(settings.association_board_members);
return (
<div style={{ background: "#F6F2E8", minHeight: "100vh", fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
<SiteHeader active="association" logo />
<main>
<section className="container" style={{ paddingTop: 34, paddingBottom: 52 }}>
<div style={{ maxWidth: 900 }}>
<div style={{ color: "#9a6638", fontSize: 12, letterSpacing: "0.14em", textTransform: "uppercase", fontWeight: 800, marginBottom: 12 }}>
Association
</div>
<h1 className="font-display" style={{ margin: 0, color: "#26201a", fontSize: "clamp(42px, 8vw, 76px)", lineHeight: 0.94, letterSpacing: "-0.03em" }}>
{settings.association_name}
</h1>
<p style={{ margin: "18px 0 0", maxWidth: 760, color: "#6c6150", fontSize: "clamp(17px, 2vw, 21px)", lineHeight: 1.65 }}>
{settings.association_intro}
</p>
</div>
</section>
<section style={{ background: "#13324F", color: "#fff" }}>
<div className="container grid grid-2" style={{ paddingTop: 44, paddingBottom: 44, alignItems: "start" }}>
<div>
<div style={{ color: "#E0A63C", fontSize: 12, letterSpacing: "0.14em", textTransform: "uppercase", fontWeight: 800, marginBottom: 10 }}>
Mission
</div>
<h2 className="font-display" style={{ margin: 0, fontSize: "clamp(28px, 4vw, 44px)" }}>
Réseau, rencontres, réussite locale.
</h2>
</div>
<p style={{ margin: 0, color: "#cfe0ee", fontSize: 16, lineHeight: 1.75 }}>
{settings.association_mission}
</p>
</div>
</section>
<section className="container" style={{ paddingTop: 54, paddingBottom: 54 }}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 18, alignItems: "end", flexWrap: "wrap", marginBottom: 20 }}>
<div>
<div style={{ color: "#9a6638", fontSize: 12, letterSpacing: "0.14em", textTransform: "uppercase", fontWeight: 800 }}>
Rencontres
</div>
<h2 className="font-display" style={{ margin: "8px 0 0", color: "#26201a", fontSize: 34 }}>
Prochaines dates
</h2>
</div>
{!session?.user && (
<Link href="/login" style={{ textDecoration: "none", background: "#9a6638", color: "#fff", borderRadius: 999, padding: "11px 18px", fontWeight: 800 }}>
Se connecter pour s'inscrire
</Link>
)}
</div>
{upcoming.length === 0 && (
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 14, padding: 24, color: "#8c8068" }}>
Aucune rencontre programmée pour le moment.
</div>
)}
<div className="grid grid-3" style={{ gap: 18 }}>
{upcoming.map((meeting) => {
const registered = Number(meeting.registered);
const remaining = Math.max(0, meeting.capacity - registered);
const isRegistered = myMeetingIds.has(meeting.id);
return (
<article key={meeting.id} className="lift" style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 12, overflow: "hidden" }}>
<div style={{ aspectRatio: "16 / 10", background: meeting.imageUrl ? `center/cover no-repeat url(${meeting.imageUrl})` : "repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)" }} />
<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>
{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>
</article>
);
})}
</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
</div>
<h2 className="font-display" style={{ margin: "8px 0 20px", color: "#26201a", fontSize: 34 }}>
Directoire
</h2>
<div className="grid grid-2" style={{ gap: 18 }}>
<article style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 12, padding: 22 }}>
{settings.association_president_photo && (
// eslint-disable-next-line @next/next/no-img-element
<img src={settings.association_president_photo} alt="" style={{ width: 86, height: 86, objectFit: "cover", borderRadius: "50%", marginBottom: 14 }} />
)}
<div style={{ color: "#9a6638", fontSize: 12, fontWeight: 800 }}>{settings.association_president_role}</div>
<h3 className="font-display" style={{ color: "#26201a", fontSize: 24, margin: "6px 0" }}>
{settings.association_president || "Présidence à renseigner"}
</h3>
{settings.association_president_message && (
<p style={{ color: "#6c6150", lineHeight: 1.7 }}>{settings.association_president_message}</p>
)}
</article>
<div className="grid" style={{ gap: 12 }}>
{[...officers.map(([role, name]) => ({ role, name })), ...board].map((person, index) => (
<article key={`${person.role}-${person.name}-${index}`} style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 12, padding: "16px 18px" }}>
<div style={{ color: "#9a6638", fontSize: 12, fontWeight: 800 }}>{person.role}</div>
<div className="font-display" style={{ color: "#26201a", fontSize: 19, fontWeight: 800, marginTop: 3 }}>
{person.name}
</div>
</article>
))}
{officers.length === 0 && board.length === 0 && (
<article style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 12, padding: 18, color: "#8c8068" }}>
Les membres du directoire seront affichés ici après configuration.
</article>
)}
</div>
</div>
</section>
</main>
<SiteFooter />
</div>
);
}
+12
View File
@@ -15,8 +15,10 @@ const TITLES: Record<string, [string, string]> = {
"Modération des promotions", "Modération des promotions",
"Validez les offres soumises par les adhérents", "Validez les offres soumises par les adhérents",
], ],
"/backend/rencontres": ["Rencontres", "Gérer les prochaines dates et les archives"],
"/backend/administrateurs": ["Administrateurs", "Gérer les accès à l'administration"], "/backend/administrateurs": ["Administrateurs", "Gérer les accès à l'administration"],
"/backend/categories": ["Catégories", "Gérer les métiers de l'annuaire"], "/backend/categories": ["Catégories", "Gérer les métiers de l'annuaire"],
"/backend/parametres": ["Paramètres du site", "Configurer l'association et les mentions légales"],
"/backend/espace": ["Mon espace adhérent", "Publiez et suivez vos promotions"], "/backend/espace": ["Mon espace adhérent", "Publiez et suivez vos promotions"],
"/backend/changer-mot-de-passe": ["Mot de passe", "Sécurisez votre compte"], "/backend/changer-mot-de-passe": ["Mot de passe", "Sécurisez votre compte"],
}; };
@@ -141,11 +143,21 @@ export function BackendShell({
</span> </span>
</NavLink> </NavLink>
)} )}
{can(user.role, "manageMeetings") && (
<NavLink href="/backend/rencontres" active={pathname === "/backend/rencontres"}>
{dotRound}Rencontres
</NavLink>
)}
{can(user.role, "manageCategories") && ( {can(user.role, "manageCategories") && (
<NavLink href="/backend/categories" active={pathname === "/backend/categories"}> <NavLink href="/backend/categories" active={pathname === "/backend/categories"}>
{dotDiamond}Catégories {dotDiamond}Catégories
</NavLink> </NavLink>
)} )}
{can(user.role, "manageSettings") && (
<NavLink href="/backend/parametres" active={pathname === "/backend/parametres"}>
{dot}Paramètres
</NavLink>
)}
{can(user.role, "manageAdmins") && ( {can(user.role, "manageAdmins") && (
<NavLink href="/backend/administrateurs" active={pathname === "/backend/administrateurs"}> <NavLink href="/backend/administrateurs" active={pathname === "/backend/administrateurs"}>
{dot}Administrateurs {dot}Administrateurs
+266 -1
View File
@@ -1,7 +1,9 @@
"use server"; "use server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { eq, sql } from "drizzle-orm"; import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { and, eq, sql } from "drizzle-orm";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { auth, signOut } from "@/auth"; import { auth, signOut } from "@/auth";
import { db } from "@/db"; import { db } from "@/db";
@@ -9,12 +11,19 @@ import {
activityLog, activityLog,
categories, categories,
contactMessages, contactMessages,
imageConsents,
meetingRegistrations,
meetings,
members, members,
membershipRequests, membershipRequests,
pastMeetingPhotos,
pastMeetings,
promotions, promotions,
siteSettings,
users, users,
} from "@/db/schema"; } from "@/db/schema";
import { can, LABEL_TO_ROLE } from "@/lib/rbac"; import { can, LABEL_TO_ROLE } from "@/lib/rbac";
import { SITE_SETTING_DEFAULTS } from "@/lib/site-settings";
import type { AppRole } from "@/types/next-auth"; import type { AppRole } from "@/types/next-auth";
const CATEGORY_PALETTE = [ const CATEGORY_PALETTE = [
@@ -62,6 +71,21 @@ async function requireRole(): Promise<{ role: AppRole; memberId: number | null;
}; };
} }
function asString(formData: FormData, key: string) {
return String(formData.get(key) ?? "").trim();
}
function asDate(formData: FormData, key: string) {
const raw = asString(formData, key);
const date = raw ? new Date(raw) : null;
if (!date || Number.isNaN(date.getTime())) throw new Error("Date invalide");
return date;
}
function asNullableString(formData: FormData, key: string) {
return asString(formData, key) || null;
}
// ---- Promotion moderation ---- // ---- Promotion moderation ----
export async function moderatePromo(formData: FormData) { export async function moderatePromo(formData: FormData) {
const { role } = await requireRole(); const { role } = await requireRole();
@@ -370,6 +394,247 @@ export async function deleteCategory(formData: FormData) {
revalidatePath("/annuaire"); revalidatePath("/annuaire");
} }
// ---- Rencontres ----
export async function createMeeting(formData: FormData) {
const { role } = await requireRole();
if (!can(role, "manageMeetings")) throw new Error("Accès refusé");
const title = asString(formData, "title");
if (!title) return;
await db.insert(meetings).values({
title,
startsAt: asDate(formData, "startsAt"),
location: asNullableString(formData, "location"),
description: asNullableString(formData, "description"),
capacity: Number(formData.get("capacity") ?? 30) || 30,
imageUrl: asNullableString(formData, "imageUrl"),
});
await logActivity(`Rencontre ajoutée : <strong>${title}</strong>`, "#2C6FB3");
revalidatePath("/backend/rencontres");
revalidatePath("/association");
}
export async function updateMeeting(formData: FormData) {
const { role } = await requireRole();
if (!can(role, "manageMeetings")) throw new Error("Accès refusé");
const id = Number(formData.get("id"));
const title = asString(formData, "title");
if (!id || !title) return;
await db
.update(meetings)
.set({
title,
startsAt: asDate(formData, "startsAt"),
location: asNullableString(formData, "location"),
description: asNullableString(formData, "description"),
capacity: Number(formData.get("capacity") ?? 30) || 30,
imageUrl: asNullableString(formData, "imageUrl"),
})
.where(eq(meetings.id, id));
revalidatePath("/backend/rencontres");
revalidatePath("/association");
}
export async function deleteMeeting(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;
await db.delete(meetings).where(eq(meetings.id, id));
revalidatePath("/backend/rencontres");
revalidatePath("/association");
}
export async function registerForMeeting(formData: FormData) {
const session = await auth();
if (!session?.user?.memberId) throw new Error("Compte adhérent requis");
const memberId = session.user.memberId;
const meetingId = Number(formData.get("meetingId"));
if (!meetingId) return;
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");
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 [{ 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 [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",
});
});
revalidatePath("/association");
revalidatePath("/backend/espace");
}
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)
)
);
revalidatePath("/association");
revalidatePath("/backend/espace");
}
export async function createPastMeeting(formData: FormData) {
const { role } = await requireRole();
if (!can(role, "manageMeetings")) throw new Error("Accès refusé");
const title = asString(formData, "title");
if (!title) return;
await db.insert(pastMeetings).values({
title,
eventDate: asDate(formData, "eventDate"),
location: asNullableString(formData, "location"),
description: asNullableString(formData, "description"),
participants: asNullableString(formData, "participants"),
meetingId: formData.get("meetingId") ? Number(formData.get("meetingId")) : null,
});
revalidatePath("/backend/rencontres");
revalidatePath("/association");
}
export async function updatePastMeeting(formData: FormData) {
const { role } = await requireRole();
if (!can(role, "manageMeetings")) throw new Error("Accès refusé");
const id = Number(formData.get("id"));
const title = asString(formData, "title");
if (!id || !title) return;
await db
.update(pastMeetings)
.set({
title,
eventDate: asDate(formData, "eventDate"),
location: asNullableString(formData, "location"),
description: asNullableString(formData, "description"),
participants: asNullableString(formData, "participants"),
meetingId: formData.get("meetingId") ? Number(formData.get("meetingId")) : null,
})
.where(eq(pastMeetings.id, id));
revalidatePath("/backend/rencontres");
revalidatePath("/association");
}
export async function deletePastMeeting(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;
await db.delete(pastMeetings).where(eq(pastMeetings.id, id));
revalidatePath("/backend/rencontres");
revalidatePath("/association");
}
export async function addPastMeetingPhoto(formData: FormData) {
const { role } = await requireRole();
if (!can(role, "manageMeetings")) throw new Error("Accès refusé");
const pastMeetingId = Number(formData.get("pastMeetingId"));
const imageUrl = asString(formData, "imageUrl");
if (!pastMeetingId || !imageUrl) return;
const [{ max } = { max: 0 }] = await db
.select({ max: sql<number>`coalesce(max(${pastMeetingPhotos.position}), 0)` })
.from(pastMeetingPhotos)
.where(eq(pastMeetingPhotos.pastMeetingId, pastMeetingId));
await db.insert(pastMeetingPhotos).values({
pastMeetingId,
imageUrl,
caption: asNullableString(formData, "caption"),
position: Number(max) + 1,
});
revalidatePath("/backend/rencontres");
revalidatePath("/association");
}
export async function deletePastMeetingPhoto(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;
await db.delete(pastMeetingPhotos).where(eq(pastMeetingPhotos.id, id));
revalidatePath("/backend/rencontres");
revalidatePath("/association");
}
// ---- Paramètres du site ----
export async function saveSiteSettings(formData: FormData) {
const { role } = await requireRole();
if (!can(role, "manageSettings")) throw new Error("Accès refusé");
const updatedAt = new Date();
for (const key of Object.keys(SITE_SETTING_DEFAULTS)) {
await db
.insert(siteSettings)
.values({ key, value: asString(formData, key), updatedAt })
.onConflictDoUpdate({
target: siteSettings.key,
set: { value: asString(formData, key), updatedAt },
});
}
revalidatePath("/backend/parametres");
revalidatePath("/association");
revalidatePath("/mentions-legales");
revalidatePath("/confidentialite");
revalidatePath("/");
}
// ---- RGPD / droit à l'image ----
export async function saveImageConsent(formData: FormData) {
const session = await auth();
if (!session?.user?.memberId) throw new Error("Compte adhérent requis");
const decision = asString(formData, "decision");
if (!["accepted", "refused"].includes(decision)) throw new Error("Décision invalide");
const signatoryName = asString(formData, "signatoryName") || session.user.name || "Adhérent";
const signaturePng = asString(formData, "signaturePng");
if (decision === "accepted" && !signaturePng) {
throw new Error("Signature requise pour autoriser le droit à l'image");
}
const h = await headers();
await db.insert(imageConsents).values({
memberId: session.user.memberId,
decision,
scopes: decision === "accepted" ? "site,social,print" : "",
signatoryName,
signaturePng: decision === "accepted" ? signaturePng : null,
consentVersion: "2026-07-v1",
ip: h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? null,
userAgent: h.get("user-agent") ?? null,
});
revalidatePath("/backend");
revalidatePath("/backend/espace");
redirect("/backend/espace");
}
// ---- Self password change (forced at first login) ---- // ---- Self password change (forced at first login) ----
export async function changeOwnPassword(formData: FormData) { export async function changeOwnPassword(formData: FormData) {
const session = await auth(); const session = await auth();
+32 -2
View File
@@ -1,10 +1,11 @@
import { asc, desc, eq } from "drizzle-orm"; import { asc, desc, eq } from "drizzle-orm";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { db } from "@/db"; import { db } from "@/db";
import { categories, members, promotions, type Member } from "@/db/schema"; import { categories, imageConsents, members, promotions, type Member } from "@/db/schema";
import { ImageField } from "@/components/ImageField"; import { ImageField } from "@/components/ImageField";
import { ImageConsentForm } from "@/components/ImageConsentForm";
import { communeOptions } from "@/lib/communes"; import { communeOptions } from "@/lib/communes";
import { updateOwnProfile } from "../actions"; import { saveImageConsent, updateOwnProfile } from "../actions";
import { MemberSpaceForm } from "./MemberSpaceForm"; import { MemberSpaceForm } from "./MemberSpaceForm";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -29,6 +30,7 @@ export default async function EspacePage() {
let subtitle = "Espace adhérent"; let subtitle = "Espace adhérent";
let profile: Member | null = null; let profile: Member | null = null;
let myPromos: { id: number; title: string; status: string; createdAt: Date; imageUrl: string | null }[] = []; let myPromos: { id: number; title: string; status: string; createdAt: Date; imageUrl: string | null }[] = [];
let latestConsent: { decision: string; createdAt: Date } | null = null;
if (memberId) { if (memberId) {
const [m] = await db.select().from(members).where(eq(members.id, memberId)); const [m] = await db.select().from(members).where(eq(members.id, memberId));
@@ -51,6 +53,13 @@ export default async function EspacePage() {
.from(promotions) .from(promotions)
.where(eq(promotions.memberId, memberId)) .where(eq(promotions.memberId, memberId))
.orderBy(desc(promotions.createdAt)); .orderBy(desc(promotions.createdAt));
const [consent] = await db
.select({ decision: imageConsents.decision, createdAt: imageConsents.createdAt })
.from(imageConsents)
.where(eq(imageConsents.memberId, memberId))
.orderBy(desc(imageConsents.createdAt))
.limit(1);
latestConsent = consent ?? null;
} }
const catRows = await db const catRows = await db
@@ -154,6 +163,27 @@ export default async function EspacePage() {
</form> </form>
)} )}
{memberId && (
<section style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, padding: 24, marginBottom: 28 }}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, alignItems: "start", flexWrap: "wrap", marginBottom: 14 }}>
<div>
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 18, margin: 0, color: "#26201a" }}>
Droit à l'image
</h3>
<div style={{ color: "#8c8068", fontSize: 13, marginTop: 4 }}>
Vous pouvez modifier votre décision à tout moment.
</div>
</div>
{latestConsent && (
<span style={{ background: latestConsent.decision === "accepted" ? "#e6f4ec" : "#fbe9e6", color: latestConsent.decision === "accepted" ? "#1f8a5b" : "#d8472b", borderRadius: 999, padding: "6px 12px", fontSize: 12, fontWeight: 800 }}>
{latestConsent.decision === "accepted" ? "Autorisé" : "Refusé"} · {fmtDate(latestConsent.createdAt)}
</span>
)}
</div>
<ImageConsentForm saveAction={saveImageConsent} defaultName={memberName} compact />
</section>
)}
<MemberSpaceForm memberName={memberName} categories={categoryLabels} /> <MemberSpaceForm memberName={memberName} categories={categoryLabels} />
<div style={{ marginTop: 28 }}> <div style={{ marginTop: 28 }}>
+46 -2
View File
@@ -1,9 +1,11 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { eq } from "drizzle-orm"; import { desc, eq } from "drizzle-orm";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { db } from "@/db"; import { db } from "@/db";
import { contactMessages, membershipRequests, promotions } from "@/db/schema"; import { contactMessages, imageConsents, membershipRequests, promotions } from "@/db/schema";
import { ImageConsentForm } from "@/components/ImageConsentForm";
import { isStaff } from "@/lib/rbac"; import { isStaff } from "@/lib/rbac";
import { saveImageConsent } from "./actions";
import { BackendShell } from "./BackendShell"; import { BackendShell } from "./BackendShell";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -18,6 +20,48 @@ export default async function BackendLayout({
redirect("/login"); redirect("/login");
} }
if (
session.user.role === "member" &&
session.user.memberId &&
!session.user.mustChangePassword
) {
const latestConsent = await db
.select({ id: imageConsents.id })
.from(imageConsents)
.where(eq(imageConsents.memberId, session.user.memberId))
.orderBy(desc(imageConsents.createdAt))
.limit(1);
if (latestConsent.length === 0) {
return (
<div style={{ minHeight: "100vh", background: "#F6F2E8", color: "#33291D", fontFamily: "'Public Sans',sans-serif", padding: "clamp(22px, 5vw, 54px)" }}>
<main style={{ maxWidth: 860, margin: "0 auto" }}>
<div style={{ display: "flex", alignItems: "center", gap: 14, marginBottom: 24 }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/assets/logo.png" alt="Plein R" style={{ height: 50, width: "auto" }} />
<div>
<div className="font-display" style={{ fontSize: 24, fontWeight: 800, color: "#26201a" }}>
Consentement RGPD
</div>
<div style={{ color: "#8c8068", fontSize: 14 }}>Droit à l'image - première connexion adhérent</div>
</div>
</div>
<section style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 20, padding: "clamp(22px, 5vw, 36px)", boxShadow: "0 24px 55px -42px rgba(40,30,15,0.55)" }}>
<h1 className="font-display" style={{ margin: "0 0 10px", color: "#26201a", fontSize: "clamp(28px, 5vw, 40px)" }}>
Votre choix sur le droit à l'image
</h1>
<p style={{ margin: "0 0 20px", color: "#6c6150", lineHeight: 1.7 }}>
Avant d'accéder à votre espace, indiquez si Plein R peut utiliser votre image dans
les supports liés aux rencontres et à la vie de l'association.
</p>
<ImageConsentForm saveAction={saveImageConsent} defaultName={session.user.name ?? "Adhérent"} />
</section>
</main>
</div>
);
}
}
let pendingCount = 0; let pendingCount = 0;
let inboxCount = 0; let inboxCount = 0;
if (isStaff(session.user.role)) { if (isStaff(session.user.role)) {
+130
View File
@@ -0,0 +1,130 @@
import { redirect } from "next/navigation";
import type { CSSProperties } from "react";
import { auth } from "@/auth";
import { ImageField } from "@/components/ImageField";
import { can } from "@/lib/rbac";
import { getSiteSettings } from "@/lib/site-settings";
import { saveSiteSettings } from "../actions";
export const dynamic = "force-dynamic";
export default async function ParametresPage() {
const session = await auth();
if (!can(session?.user.role, "manageSettings")) redirect("/backend");
const settings = await getSiteSettings();
return (
<form action={saveSiteSettings} style={{ display: "grid", gap: 22, maxWidth: 1080 }}>
<section style={panel}>
<h2 className="font-display" style={title}>Identité du site</h2>
<div className="grid grid-2" style={{ gap: 16 }}>
<Field label="Nom association" name="association_name" value={settings.association_name} />
<Field label="Slogan" name="association_tagline" value={settings.association_tagline} />
<Field label="E-mail" name="association_email" value={settings.association_email} />
<Field label="Téléphone" name="association_phone" value={settings.association_phone} />
<Field label="Site web" name="association_website" value={settings.association_website} />
<Field label="Contact principal" name="association_contact" value={settings.association_contact} />
<div style={{ gridColumn: "1 / -1" }}>
<Field label="Adresse" name="association_address" value={settings.association_address} />
</div>
<Area label="Texte d'introduction Association" name="association_intro" value={settings.association_intro} />
<Area label="Mission" name="association_mission" value={settings.association_mission} />
</div>
</section>
<section style={panel}>
<h2 className="font-display" style={title}>Directoire</h2>
<div className="grid grid-2" style={{ gap: 16 }}>
<Field label="Président(e)" name="association_president" value={settings.association_president} />
<Field label="Intitulé du rôle" name="association_president_role" value={settings.association_president_role} />
<Field label="Vice-président(e)" name="association_vice_president" value={settings.association_vice_president} />
<Field label="Trésorier(ère)" name="association_treasurer" value={settings.association_treasurer} />
<Field label="Secrétaire" name="association_secretary" value={settings.association_secretary} />
<ImageField name="association_president_photo" label="Photo président(e)" defaultValue={settings.association_president_photo} height={130} />
<Area label="Message président(e)" name="association_president_message" value={settings.association_president_message} />
<Area
label="Autres membres du directoire (Nom|Fonction, une ligne par personne)"
name="association_board_members"
value={settings.association_board_members}
/>
</div>
</section>
<section style={panel}>
<h2 className="font-display" style={title}>Mentions légales & confidentialité</h2>
<div className="grid grid-2" style={{ gap: 16 }}>
<Field label="SIRET" name="association_siret" value={settings.association_siret} />
<Field label="RNA" name="association_rna" value={settings.association_rna} />
<Field label="Directeur de publication" name="association_publication_director" value={settings.association_publication_director} />
<Field label="Contact confidentialité/RGPD" name="association_privacy_contact" value={settings.association_privacy_contact} />
<Field label="Hébergeur" name="association_host_name" value={settings.association_host_name} />
<Field label="Téléphone hébergeur" name="association_host_phone" value={settings.association_host_phone} />
<div style={{ gridColumn: "1 / -1" }}>
<Field label="Adresse hébergeur" name="association_host_address" value={settings.association_host_address} />
</div>
<Field label="Dernière mise à jour légale" name="legal_updated" value={settings.legal_updated} placeholder="17 juillet 2026" />
</div>
<div style={{ marginTop: 14, color: "#9a6638", fontSize: 13, lineHeight: 1.6 }}>
Remplissez ces champs avant mise en production: ils alimentent les pages publiques
Mentions légales et Confidentialité.
</div>
</section>
<button type="submit" style={submitButton}>Enregistrer les paramètres</button>
</form>
);
}
function Field({
label,
name,
value,
placeholder,
}: {
label: string;
name: string;
value: string;
placeholder?: string;
}) {
return (
<div>
<label className="field-label">{label}</label>
<input name={name} defaultValue={value} placeholder={placeholder} className="field" />
</div>
);
}
function Area({ label, name, value }: { label: string; name: string; value: string }) {
return (
<div style={{ gridColumn: "1 / -1" }}>
<label className="field-label">{label}</label>
<textarea name={name} rows={4} defaultValue={value} className="field" />
</div>
);
}
const panel: CSSProperties = {
background: "#fff",
border: "1px solid #e6dcc6",
borderRadius: 16,
padding: 22,
};
const title: CSSProperties = {
margin: "0 0 16px",
fontSize: 20,
color: "#26201a",
};
const submitButton: CSSProperties = {
justifySelf: "start",
border: "none",
background: "#13324F",
color: "#fff",
fontWeight: 800,
fontSize: 14.5,
padding: "13px 22px",
borderRadius: 10,
cursor: "pointer",
};
+5 -5
View File
@@ -57,7 +57,7 @@ export default async function PromotionsPage() {
</div> </div>
)} )}
<div className="grid grid-3" style={{ gap: 18, width: "100%", maxWidth: 1096 }}> <div className="grid grid-3" style={{ gap: 16, width: "100%", maxWidth: 960 }}>
{ordered.map((p) => { {ordered.map((p) => {
const isPending = p.status === "pending"; const isPending = p.status === "pending";
return ( return (
@@ -75,7 +75,7 @@ export default async function PromotionsPage() {
<div <div
style={{ style={{
position: "relative", position: "relative",
aspectRatio: "1 / 1", aspectRatio: "4 / 3",
background: isPending ? STRIPE_COOL : STRIPE_WARM, background: isPending ? STRIPE_COOL : STRIPE_WARM,
overflow: "hidden", overflow: "hidden",
display: "flex", display: "flex",
@@ -105,14 +105,14 @@ export default async function PromotionsPage() {
{isPending ? "En attente" : "En ligne"} {isPending ? "En attente" : "En ligne"}
</span> </span>
</div> </div>
<div style={{ padding: "15px 16px 16px", display: "flex", flexDirection: "column", flex: 1 }}> <div style={{ padding: "13px 14px 14px", display: "flex", flexDirection: "column", flex: 1 }}>
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 16.5, margin: "0 0 4px", color: "#26201a" }}> <h3 className="font-display" style={{ fontWeight: 700, fontSize: 15.5, margin: "0 0 4px", color: "#26201a" }}>
{p.title} {p.title}
</h3> </h3>
<div style={{ fontSize: 12.5, color: "#9a6638", fontWeight: 600, marginBottom: 8 }}> <div style={{ fontSize: 12.5, color: "#9a6638", fontWeight: 600, marginBottom: 8 }}>
{p.memberName} {p.memberName}
</div> </div>
<p style={{ margin: "0 0 14px", fontSize: 13, color: "#8c8068", lineHeight: 1.5, flex: 1 }}> <p style={{ margin: "0 0 12px", fontSize: 12.5, color: "#8c8068", lineHeight: 1.45, flex: 1 }}>
{p.text} {p.text}
</p> </p>
+290
View File
@@ -0,0 +1,290 @@
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,
createMeeting,
createPastMeeting,
deleteMeeting,
deletePastMeeting,
deletePastMeetingPhoto,
updateMeeting,
updatePastMeeting,
} from "../actions";
export const dynamic = "force-dynamic";
function dateTimeValue(date: Date) {
const local = new Date(date);
local.setMinutes(local.getMinutes() - local.getTimezoneOffset());
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 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>
<form action={createMeeting} 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 et heure</label>
<input name="startsAt" type="datetime-local" className="field" required />
</div>
<div>
<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>
<div style={{ gridColumn: "1 / -1" }}>
<label className="field-label">Description</label>
<textarea name="description" rows={3} className="field" />
</div>
<div style={{ gridColumn: "1 / -1" }}>
<ImageField name="imageUrl" label="Image" height={160} />
</div>
<button type="submit" style={primaryButton}>
Ajouter la rencontre
</button>
</form>
</section>
<section>
<div style={sectionTitle}>Rencontres à venir / publiées</div>
<div className="grid grid-3" style={{ gap: 18 }}>
{rows.map((meeting) => (
<article key={meeting.id} style={cardStyle}>
<form action={updateMeeting} style={{ display: "grid", gap: 12 }}>
<input type="hidden" name="id" value={meeting.id} />
<ImageField name="imageUrl" label="Image" defaultValue={meeting.imageUrl ?? ""} height={120} />
<div>
<label className="field-label">Titre</label>
<input name="title" defaultValue={meeting.title} className="field" required />
</div>
<div>
<label className="field-label">Date et heure</label>
<input name="startsAt" type="datetime-local" defaultValue={dateTimeValue(meeting.startsAt)} className="field" required />
</div>
<div>
<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>
<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>
<form action={deleteMeeting} style={{ marginTop: 8 }}>
<input type="hidden" name="id" value={meeting.id} />
<button type="submit" style={dangerButton}>Supprimer</button>
</form>
</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>
</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",
};
+11 -4
View File
@@ -1,14 +1,20 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { LegalPage } from "@/components/LegalPage"; import { LegalPage } from "@/components/LegalPage";
import { getSiteSettings } from "@/lib/site-settings";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Confidentialité — Plein R", title: "Confidentialité — Plein R",
description: "Politique de confidentialité et traitement des données personnelles par Plein R.", description: "Politique de confidentialité et traitement des données personnelles par Plein R.",
}; };
export default function ConfidentialitePage() { export const dynamic = "force-dynamic";
export default async function ConfidentialitePage() {
const settings = await getSiteSettings();
const contact = settings.association_privacy_contact || settings.association_email || "le formulaire « Nous contacter »";
return ( return (
<LegalPage title="Confidentialité" intro="Cette page explique quelles données sont recueillies et comment Plein R les utilise."> <LegalPage title="Confidentialité" intro={`Cette page explique quelles données sont recueillies et comment ${settings.association_name} les utilise.`}>
<h2>Données recueillies</h2> <h2>Données recueillies</h2>
<p>Selon votre utilisation du site, Plein R peut recevoir :</p> <p>Selon votre utilisation du site, Plein R peut recevoir :</p>
<ul> <ul>
@@ -25,7 +31,7 @@ export default function ConfidentialitePage() {
<h2>Responsable, bases légales et destinataires</h2> <h2>Responsable, bases légales et destinataires</h2>
<p> <p>
Plein R est responsable des traitements réalisés par ce site. Les traitements reposent, selon le cas, {settings.association_name} est responsable des traitements réalisés par ce site. Les traitements reposent, selon le cas,
sur les démarches précontractuelles liées à l’adhésion, l’exécution de la relation avec les adhérents, sur les démarches précontractuelles liées à l’adhésion, l’exécution de la relation avec les adhérents,
l’intérêt légitime de l’association à répondre aux demandes et administrer son réseau, ou une obligation légale. l’intérêt légitime de l’association à répondre aux demandes et administrer son réseau, ou une obligation légale.
</p> </p>
@@ -50,9 +56,10 @@ export default function ConfidentialitePage() {
<p> <p>
Vous pouvez demander accès, rectification, effacement, limitation, opposition ou portabilité concernant vos données, Vous pouvez demander accès, rectification, effacement, limitation, opposition ou portabilité concernant vos données,
selon les conditions prévues par la réglementation. selon les conditions prévues par la réglementation.
Envoyez votre demande via le formulaire « Nous contacter » en précisant votre identité et l’objet de votre demande. Envoyez votre demande via {contact} en précisant votre identité et l’objet de votre demande.
</p> </p>
<p>Vous pouvez également saisir la CNIL si vous estimez que vos droits ne sont pas respectés.</p> <p>Vous pouvez également saisir la CNIL si vous estimez que vos droits ne sont pas respectés.</p>
{settings.legal_updated && <p>Dernière mise à jour : {settings.legal_updated}</p>}
</LegalPage> </LegalPage>
); );
} }
+26 -11
View File
@@ -1,30 +1,44 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { LegalPage } from "@/components/LegalPage"; import { LegalPage } from "@/components/LegalPage";
import { getSiteSettings } from "@/lib/site-settings";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Mentions légales — Plein R", title: "Mentions légales — Plein R",
description: "Mentions légales du site de l'association Plein R.", description: "Mentions légales du site de l'association Plein R.",
}; };
export default function MentionsLegalesPage() { export const dynamic = "force-dynamic";
function value(input: string, fallback = "à renseigner") {
return input || fallback;
}
export default async function MentionsLegalesPage() {
const settings = await getSiteSettings();
return ( return (
<LegalPage title="Mentions légales" intro="Informations relatives à l’édition et à l’utilisation du site Plein R."> <LegalPage title="Mentions légales" intro={`Informations relatives à l’édition et à l’utilisation du site ${settings.association_name}.`}>
<h2>Éditeur du site</h2> <h2>Éditeur du site</h2>
<p> <p>
Ce site est édité par Plein R, association des commerçants et entreprises du Bassin de Pompey. Ce site est édité par {settings.association_name}, association des commerçants et entreprises du Bassin de Pompey.
</p> </p>
<ul> <ul>
<li>Siège social : à compléter par l’association</li> <li>Siège social : {value(settings.association_address)}</li>
<li>Numéro RNA et, le cas échéant, SIREN : à compléter par l’association</li> <li>RNA : {value(settings.association_rna)}</li>
<li>Direction de la publication : à compléter par l’association</li> <li>SIRET : {value(settings.association_siret)}</li>
<li>Direction de la publication : {value(settings.association_publication_director || settings.association_president)}</li>
</ul> </ul>
<p>Pour contacter Plein R, utilisez le formulaire « Nous contacter » disponible en pied de page.</p> <p>
Contact : {value(settings.association_email || settings.association_contact)}{" "}
{settings.association_phone ? `- ${settings.association_phone}` : ""}
</p>
<h2>Hébergement</h2> <h2>Hébergement</h2>
<p> <ul>
Nom, raison sociale, adresse et numéro de téléphone de l’hébergeur : à compléter par l’association <li>Hébergeur : {value(settings.association_host_name)}</li>
dès que l’hébergement de production est choisi. <li>Adresse : {value(settings.association_host_address)}</li>
</p> <li>Téléphone : {value(settings.association_host_phone)}</li>
</ul>
<h2>Propriété intellectuelle</h2> <h2>Propriété intellectuelle</h2>
<p> <p>
@@ -38,6 +52,7 @@ export default function MentionsLegalesPage() {
Plein R veille à la qualité des informations publiées, sans pouvoir garantir leur exhaustivité permanente. Plein R veille à la qualité des informations publiées, sans pouvoir garantir leur exhaustivité permanente.
Les offres, coordonnées et contenus des adhérents relèvent de la responsabilité de leurs auteurs. Les offres, coordonnées et contenus des adhérents relèvent de la responsabilité de leurs auteurs.
</p> </p>
{settings.legal_updated && <p>Dernière mise à jour : {settings.legal_updated}</p>}
</LegalPage> </LegalPage>
); );
} }
+167
View File
@@ -0,0 +1,167 @@
"use client";
import { useRef, useState } from "react";
export function ImageConsentForm({
saveAction,
defaultName,
compact = false,
}: {
saveAction: (formData: FormData) => Promise<void>;
defaultName: string;
compact?: boolean;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [drawing, setDrawing] = useState(false);
const [signature, setSignature] = useState("");
const [error, setError] = useState("");
function point(event: React.PointerEvent<HTMLCanvasElement>) {
const canvas = canvasRef.current;
if (!canvas) return null;
const rect = canvas.getBoundingClientRect();
return {
x: ((event.clientX - rect.left) / rect.width) * canvas.width,
y: ((event.clientY - rect.top) / rect.height) * canvas.height,
};
}
function draw(event: React.PointerEvent<HTMLCanvasElement>) {
if (!drawing) return;
const p = point(event);
const ctx = canvasRef.current?.getContext("2d");
if (!p || !ctx) return;
ctx.lineTo(p.x, p.y);
ctx.strokeStyle = "#13324F";
ctx.lineWidth = 3;
ctx.lineCap = "round";
ctx.stroke();
setSignature(canvasRef.current?.toDataURL("image/png") ?? "");
}
function start(event: React.PointerEvent<HTMLCanvasElement>) {
const p = point(event);
const ctx = canvasRef.current?.getContext("2d");
if (!p || !ctx) return;
event.currentTarget.setPointerCapture(event.pointerId);
ctx.beginPath();
ctx.moveTo(p.x, p.y);
setDrawing(true);
}
function clear() {
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (canvas && ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);
setSignature("");
setError("");
}
function onSubmit(event: React.FormEvent<HTMLFormElement>) {
const submitter = (event.nativeEvent as SubmitEvent).submitter as HTMLButtonElement | null;
if (submitter?.value === "accepted" && !signature) {
event.preventDefault();
setError("Signez dans le cadre pour valider l'autorisation.");
}
}
return (
<form
action={saveAction}
onSubmit={onSubmit}
style={{
background: compact ? "#fff" : "#F6F2E8",
border: "1px solid #e6dcc6",
borderRadius: 16,
padding: compact ? 18 : 24,
}}
>
<input type="hidden" name="signaturePng" value={signature} />
<div style={{ display: "grid", gap: 14 }}>
<div>
<label className="field-label">Nom du signataire</label>
<input name="signatoryName" defaultValue={defaultName} className="field" required />
</div>
<div style={{ color: "#6c6150", fontSize: 13.5, lineHeight: 1.65 }}>
J'autorise l'association Plein R à utiliser mon image dans les supports liés à la vie
associative: site internet, réseaux sociaux et supports imprimés. Autorisation gratuite,
valable 5 ans, sans vente à des tiers. Je peux retirer mon accord à tout moment.
</div>
<div>
<label className="field-label">Signature</label>
<canvas
ref={canvasRef}
width={760}
height={190}
onPointerDown={start}
onPointerMove={draw}
onPointerUp={() => setDrawing(false)}
onPointerCancel={() => setDrawing(false)}
style={{
width: "100%",
height: compact ? 120 : 150,
display: "block",
touchAction: "none",
background: "#fff",
border: "1px dashed #d6c9ad",
borderRadius: 12,
}}
/>
<button
type="button"
onClick={clear}
style={{
marginTop: 8,
border: "none",
background: "transparent",
color: "#9a6638",
fontSize: 12.5,
fontWeight: 700,
cursor: "pointer",
}}
>
Effacer la signature
</button>
{error && <div style={{ color: "#d8472b", fontSize: 12.5, marginTop: 4 }}>{error}</div>}
</div>
<div style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
<button
type="submit"
name="decision"
value="accepted"
style={{
border: "none",
background: "#13324F",
color: "#fff",
fontWeight: 800,
borderRadius: 10,
padding: "11px 17px",
cursor: "pointer",
}}
>
J'autorise et je signe
</button>
<button
type="submit"
name="decision"
value="refused"
style={{
border: "1px solid #e0c3bb",
background: "#fff",
color: "#d8472b",
fontWeight: 800,
borderRadius: 10,
padding: "11px 17px",
cursor: "pointer",
}}
>
Je refuse
</button>
</div>
</div>
</form>
);
}
+67
View File
@@ -0,0 +1,67 @@
"use client";
import { useState } from "react";
export function PastMeetingGallery({
title,
photos,
}: {
title: string;
photos: { id: number; imageUrl: string; caption: string | null }[];
}) {
const [open, setOpen] = useState<number | null>(null);
if (photos.length === 0) return null;
const current = open == null ? null : photos.find((photo) => photo.id === open);
return (
<>
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 8, marginTop: 14 }}>
{photos.slice(0, 4).map((photo, index) => (
<button
key={photo.id}
type="button"
onClick={() => setOpen(photo.id)}
aria-label={`Ouvrir photo ${index + 1}`}
style={{
position: "relative",
border: "none",
padding: 0,
aspectRatio: "1 / 1",
borderRadius: 8,
overflow: "hidden",
cursor: "pointer",
background: `center/cover no-repeat url(${photo.imageUrl})`,
}}
>
{index === 3 && photos.length > 4 && (
<span style={{ position: "absolute", inset: 0, background: "rgba(19,50,79,0.62)", color: "#fff", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 800 }}>
+{photos.length - 3}
</span>
)}
</button>
))}
</div>
{current && (
<div
role="dialog"
aria-modal="true"
aria-label={`Galerie ${title}`}
onClick={() => setOpen(null)}
style={{ position: "fixed", inset: 0, background: "rgba(19,50,79,0.72)", zIndex: 80, padding: 24, display: "flex", alignItems: "center", justifyContent: "center" }}
>
<div onClick={(event) => event.stopPropagation()} style={{ maxWidth: 980, width: "100%" }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={current.imageUrl} alt={current.caption ?? title} style={{ width: "100%", maxHeight: "78vh", objectFit: "contain", background: "#fff", borderRadius: 12 }} />
<div style={{ marginTop: 12, display: "flex", justifyContent: "space-between", gap: 12, alignItems: "center" }}>
<div style={{ color: "#fff", fontWeight: 700 }}>{current.caption ?? title}</div>
<button type="button" onClick={() => setOpen(null)} style={{ border: "none", background: "#fff", color: "#13324F", borderRadius: 999, padding: "9px 16px", fontWeight: 800, cursor: "pointer" }}>
Fermer
</button>
</div>
</div>
</div>
)}
</>
);
}
+1
View File
@@ -24,6 +24,7 @@ export function SiteFooter() {
<div className="footer-links"> <div className="footer-links">
<Link href="/annuaire">Annuaire</Link> <Link href="/annuaire">Annuaire</Link>
<Link href="/#promotions">Promotions</Link> <Link href="/#promotions">Promotions</Link>
<Link href="/association">Association</Link>
<Link href="/login">Connexion</Link> <Link href="/login">Connexion</Link>
</div> </div>
</div> </div>
+1 -1
View File
@@ -4,7 +4,7 @@ const NAV = [
{ key: "accueil", label: "Accueil", href: "/" }, { key: "accueil", label: "Accueil", href: "/" },
{ key: "annuaire", label: "Annuaire", href: "/annuaire" }, { key: "annuaire", label: "Annuaire", href: "/annuaire" },
{ key: "promotions", label: "Promotions", href: "/#promotions" }, { key: "promotions", label: "Promotions", href: "/#promotions" },
{ key: "association", label: "L'association", href: "#" }, { key: "association", label: "L'association", href: "/association" },
]; ];
export function SiteHeader({ active, logo = false }: { active?: string; logo?: boolean }) { export function SiteHeader({ active, logo = false }: { active?: string; logo?: boolean }) {
+124
View File
@@ -125,6 +125,85 @@ export const activityLog = pgTable("activity_log", {
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}); });
// ---- Site settings (contenus configurables) ----
export const siteSettings = pgTable("site_settings", {
key: varchar("key", { length: 120 }).primaryKey(),
value: text("value"),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
});
// ---- Rencontres à venir ----
export const meetings = pgTable("meetings", {
id: serial("id").primaryKey(),
title: varchar("title", { length: 200 }).notNull(),
startsAt: timestamp("starts_at", { withTimezone: true }).notNull(),
location: varchar("location", { length: 240 }),
description: text("description"),
capacity: integer("capacity").notNull().default(30),
imageUrl: text("image_url"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
export const meetingRegistrations = pgTable(
"meeting_registrations",
{
id: serial("id").primaryKey(),
meetingId: integer("meeting_id")
.notNull()
.references(() => meetings.id, { onDelete: "cascade" }),
memberId: integer("member_id").references(() => members.id, { onDelete: "set null" }),
attendeeName: varchar("attendee_name", { length: 200 }).notNull(),
attendeeEmail: varchar("attendee_email", { length: 200 }),
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
),
})
);
// ---- Rencontres passées + galerie ----
export const pastMeetings = pgTable("past_meetings", {
id: serial("id").primaryKey(),
title: varchar("title", { length: 200 }).notNull(),
eventDate: timestamp("event_date", { withTimezone: true }).notNull(),
location: varchar("location", { length: 240 }),
description: text("description"),
participants: text("participants"),
meetingId: integer("meeting_id").references(() => meetings.id, { onDelete: "set null" }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
export const pastMeetingPhotos = pgTable("past_meeting_photos", {
id: serial("id").primaryKey(),
pastMeetingId: integer("past_meeting_id")
.notNull()
.references(() => pastMeetings.id, { onDelete: "cascade" }),
imageUrl: text("image_url").notNull(),
caption: varchar("caption", { length: 200 }),
position: integer("position").notNull().default(0),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
// ---- RGPD / droit à l'image : historique append-only ----
export const imageConsents = pgTable("image_consents", {
id: serial("id").primaryKey(),
memberId: integer("member_id")
.notNull()
.references(() => members.id, { onDelete: "cascade" }),
decision: varchar("decision", { length: 20 }).notNull(),
scopes: text("scopes"),
signatoryName: varchar("signatory_name", { length: 200 }),
signaturePng: text("signature_png"),
consentVersion: varchar("consent_version", { length: 40 }).notNull(),
ip: varchar("ip", { length: 120 }),
userAgent: text("user_agent"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
// ---- Relations ---- // ---- Relations ----
export const membersRelations = relations(members, ({ one, many }) => ({ export const membersRelations = relations(members, ({ one, many }) => ({
category: one(categories, { category: one(categories, {
@@ -132,6 +211,8 @@ export const membersRelations = relations(members, ({ one, many }) => ({
references: [categories.id], references: [categories.id],
}), }),
promotions: many(promotions), promotions: many(promotions),
meetingRegistrations: many(meetingRegistrations),
imageConsents: many(imageConsents),
})); }));
export const promotionsRelations = relations(promotions, ({ one }) => ({ export const promotionsRelations = relations(promotions, ({ one }) => ({
@@ -148,6 +229,43 @@ export const usersRelations = relations(users, ({ one }) => ({
}), }),
})); }));
export const meetingsRelations = relations(meetings, ({ many }) => ({
registrations: many(meetingRegistrations),
}));
export const meetingRegistrationsRelations = relations(meetingRegistrations, ({ one }) => ({
meeting: one(meetings, {
fields: [meetingRegistrations.meetingId],
references: [meetings.id],
}),
member: one(members, {
fields: [meetingRegistrations.memberId],
references: [members.id],
}),
}));
export const pastMeetingsRelations = relations(pastMeetings, ({ one, many }) => ({
linkedMeeting: one(meetings, {
fields: [pastMeetings.meetingId],
references: [meetings.id],
}),
photos: many(pastMeetingPhotos),
}));
export const pastMeetingPhotosRelations = relations(pastMeetingPhotos, ({ one }) => ({
pastMeeting: one(pastMeetings, {
fields: [pastMeetingPhotos.pastMeetingId],
references: [pastMeetings.id],
}),
}));
export const imageConsentsRelations = relations(imageConsents, ({ one }) => ({
member: one(members, {
fields: [imageConsents.memberId],
references: [members.id],
}),
}));
export type Category = typeof categories.$inferSelect; export type Category = typeof categories.$inferSelect;
export type Member = typeof members.$inferSelect; export type Member = typeof members.$inferSelect;
export type User = typeof users.$inferSelect; export type User = typeof users.$inferSelect;
@@ -155,3 +273,9 @@ export type Promotion = typeof promotions.$inferSelect;
export type MembershipRequest = typeof membershipRequests.$inferSelect; export type MembershipRequest = typeof membershipRequests.$inferSelect;
export type ContactMessage = typeof contactMessages.$inferSelect; export type ContactMessage = typeof contactMessages.$inferSelect;
export type ActivityEntry = typeof activityLog.$inferSelect; export type ActivityEntry = typeof activityLog.$inferSelect;
export type SiteSetting = typeof siteSettings.$inferSelect;
export type Meeting = typeof meetings.$inferSelect;
export type MeetingRegistration = typeof meetingRegistrations.$inferSelect;
export type PastMeeting = typeof pastMeetings.$inferSelect;
export type PastMeetingPhoto = typeof pastMeetingPhotos.$inferSelect;
export type ImageConsent = typeof imageConsents.$inferSelect;
+5 -1
View File
@@ -20,6 +20,8 @@ type Capability =
| "moderatePromos" | "moderatePromos"
| "manageAdmins" | "manageAdmins"
| "manageCategories" | "manageCategories"
| "manageMeetings"
| "manageSettings"
| "memberSpace"; | "memberSpace";
const CAPABILITIES: Record<AppRole, Capability[]> = { const CAPABILITIES: Record<AppRole, Capability[]> = {
@@ -29,9 +31,11 @@ const CAPABILITIES: Record<AppRole, Capability[]> = {
"moderatePromos", "moderatePromos",
"manageAdmins", "manageAdmins",
"manageCategories", "manageCategories",
"manageMeetings",
"manageSettings",
"memberSpace", "memberSpace",
], ],
moderator: ["viewDashboard", "manageMembers", "moderatePromos"], moderator: ["viewDashboard", "manageMembers", "moderatePromos", "manageMeetings"],
editor: ["viewDashboard", "manageMembers"], editor: ["viewDashboard", "manageMembers"],
member: ["memberSpace"], member: ["memberSpace"],
}; };
+61
View File
@@ -0,0 +1,61 @@
import { asc } from "drizzle-orm";
import { db } from "@/db";
import { siteSettings } from "@/db/schema";
export const SITE_SETTING_DEFAULTS = {
association_name: "Plein R",
association_tagline: "Association des commerçants et entreprises du Bassin de Pompey.",
association_intro:
"Plein R rassemble les acteurs économiques locaux autour du réseau, des rencontres et de la mise en valeur du territoire.",
association_mission:
"Créer du lien entre adhérents, encourager les échanges de proximité et porter une dynamique collective au service du Bassin de Pompey.",
association_address: "",
association_email: "",
association_phone: "",
association_website: "",
association_siret: "",
association_rna: "",
association_contact: "",
association_president: "",
association_president_role: "Présidence",
association_president_message: "",
association_president_photo: "",
association_vice_president: "",
association_treasurer: "",
association_secretary: "",
association_board_members: "",
association_host_name: "",
association_host_address: "",
association_host_phone: "",
association_publication_director: "",
association_privacy_contact: "",
legal_updated: "",
};
export type SiteSettings = typeof SITE_SETTING_DEFAULTS;
export type SiteSettingKey = keyof SiteSettings;
export async function getSiteSettings(): Promise<SiteSettings> {
const rows = await db.select().from(siteSettings).orderBy(asc(siteSettings.key));
const merged = { ...SITE_SETTING_DEFAULTS };
for (const row of rows) {
if (row.key in merged) {
merged[row.key as SiteSettingKey] = row.value ?? "";
}
}
return merged;
}
export function splitLines(value: string) {
return value
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
}
export function parseBoardMembers(value: string) {
return splitLines(value).map((line) => {
const [name, role] = line.split("|").map((part) => part.trim());
return { name, role: role || "Membre du directoire" };
});
}