mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-11 17:27:54 +02:00
Add association meetings and GDPR settings
This commit is contained in:
1 parent
b4c8967a3c
commit
28ada88155
20 files changed
+2663
-27
No files matched your search
@@ -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
@@ -29,6 +29,13 @@
|
||||
"when": 1782545608919,
|
||||
"tag": "0003_outstanding_jimmy_woo",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1784303079565,
|
||||
"tag": "0004_tiny_vanisher",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -15,8 +15,10 @@ 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/administrateurs": ["Administrateurs", "Gérer les accès à l'administration"],
|
||||
"/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/changer-mot-de-passe": ["Mot de passe", "Sécurisez votre compte"],
|
||||
};
|
||||
@@ -141,11 +143,21 @@ export function BackendShell({
|
||||
</span>
|
||||
</NavLink>
|
||||
)}
|
||||
{can(user.role, "manageMeetings") && (
|
||||
<NavLink href="/backend/rencontres" active={pathname === "/backend/rencontres"}>
|
||||
{dotRound}Rencontres
|
||||
</NavLink>
|
||||
)}
|
||||
{can(user.role, "manageCategories") && (
|
||||
<NavLink href="/backend/categories" active={pathname === "/backend/categories"}>
|
||||
{dotDiamond}Catégories
|
||||
</NavLink>
|
||||
)}
|
||||
{can(user.role, "manageSettings") && (
|
||||
<NavLink href="/backend/parametres" active={pathname === "/backend/parametres"}>
|
||||
{dot}Paramètres
|
||||
</NavLink>
|
||||
)}
|
||||
{can(user.role, "manageAdmins") && (
|
||||
<NavLink href="/backend/administrateurs" active={pathname === "/backend/administrateurs"}>
|
||||
{dot}Administrateurs
|
||||
|
||||
+266
-1
@@ -1,7 +1,9 @@
|
||||
"use server";
|
||||
|
||||
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 { auth, signOut } from "@/auth";
|
||||
import { db } from "@/db";
|
||||
@@ -9,12 +11,19 @@ import {
|
||||
activityLog,
|
||||
categories,
|
||||
contactMessages,
|
||||
imageConsents,
|
||||
meetingRegistrations,
|
||||
meetings,
|
||||
members,
|
||||
membershipRequests,
|
||||
pastMeetingPhotos,
|
||||
pastMeetings,
|
||||
promotions,
|
||||
siteSettings,
|
||||
users,
|
||||
} from "@/db/schema";
|
||||
import { can, LABEL_TO_ROLE } from "@/lib/rbac";
|
||||
import { SITE_SETTING_DEFAULTS } from "@/lib/site-settings";
|
||||
import type { AppRole } from "@/types/next-auth";
|
||||
|
||||
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 ----
|
||||
export async function moderatePromo(formData: FormData) {
|
||||
const { role } = await requireRole();
|
||||
@@ -370,6 +394,247 @@ export async function deleteCategory(formData: FormData) {
|
||||
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) ----
|
||||
export async function changeOwnPassword(formData: FormData) {
|
||||
const session = await auth();
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { asc, desc, eq } from "drizzle-orm";
|
||||
import { auth } from "@/auth";
|
||||
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 { ImageConsentForm } from "@/components/ImageConsentForm";
|
||||
import { communeOptions } from "@/lib/communes";
|
||||
import { updateOwnProfile } from "../actions";
|
||||
import { saveImageConsent, updateOwnProfile } from "../actions";
|
||||
import { MemberSpaceForm } from "./MemberSpaceForm";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -29,6 +30,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 latestConsent: { decision: string; createdAt: Date } | null = null;
|
||||
|
||||
if (memberId) {
|
||||
const [m] = await db.select().from(members).where(eq(members.id, memberId));
|
||||
@@ -51,6 +53,13 @@ export default async function EspacePage() {
|
||||
.from(promotions)
|
||||
.where(eq(promotions.memberId, memberId))
|
||||
.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
|
||||
@@ -154,6 +163,27 @@ export default async function EspacePage() {
|
||||
</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} />
|
||||
|
||||
<div style={{ marginTop: 28 }}>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { auth } from "@/auth";
|
||||
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 { saveImageConsent } from "./actions";
|
||||
import { BackendShell } from "./BackendShell";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -18,6 +20,48 @@ export default async function BackendLayout({
|
||||
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 inboxCount = 0;
|
||||
if (isStaff(session.user.role)) {
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
@@ -57,7 +57,7 @@ export default async function PromotionsPage() {
|
||||
</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) => {
|
||||
const isPending = p.status === "pending";
|
||||
return (
|
||||
@@ -75,7 +75,7 @@ export default async function PromotionsPage() {
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
aspectRatio: "1 / 1",
|
||||
aspectRatio: "4 / 3",
|
||||
background: isPending ? STRIPE_COOL : STRIPE_WARM,
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
@@ -105,14 +105,14 @@ export default async function PromotionsPage() {
|
||||
{isPending ? "En attente" : "En ligne"}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ padding: "15px 16px 16px", display: "flex", flexDirection: "column", flex: 1 }}>
|
||||
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 16.5, margin: "0 0 4px", color: "#26201a" }}>
|
||||
<div style={{ padding: "13px 14px 14px", display: "flex", flexDirection: "column", flex: 1 }}>
|
||||
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 15.5, margin: "0 0 4px", color: "#26201a" }}>
|
||||
{p.title}
|
||||
</h3>
|
||||
<div style={{ fontSize: 12.5, color: "#9a6638", fontWeight: 600, marginBottom: 8 }}>
|
||||
{p.memberName}
|
||||
</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>
|
||||
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
@@ -1,14 +1,20 @@
|
||||
import type { Metadata } from "next";
|
||||
import { LegalPage } from "@/components/LegalPage";
|
||||
import { getSiteSettings } from "@/lib/site-settings";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Confidentialité — 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 (
|
||||
<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>
|
||||
<p>Selon votre utilisation du site, Plein R peut recevoir :</p>
|
||||
<ul>
|
||||
@@ -25,7 +31,7 @@ export default function ConfidentialitePage() {
|
||||
|
||||
<h2>Responsable, bases légales et destinataires</h2>
|
||||
<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,
|
||||
l’intérêt légitime de l’association à répondre aux demandes et administrer son réseau, ou une obligation légale.
|
||||
</p>
|
||||
@@ -50,9 +56,10 @@ export default function ConfidentialitePage() {
|
||||
<p>
|
||||
Vous pouvez demander accès, rectification, effacement, limitation, opposition ou portabilité concernant vos données,
|
||||
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>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>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +1,44 @@
|
||||
import type { Metadata } from "next";
|
||||
import { LegalPage } from "@/components/LegalPage";
|
||||
import { getSiteSettings } from "@/lib/site-settings";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Mentions légales — 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 (
|
||||
<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>
|
||||
<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>
|
||||
<ul>
|
||||
<li>Siège social : à compléter par l’association</li>
|
||||
<li>Numéro RNA et, le cas échéant, SIREN : à compléter par l’association</li>
|
||||
<li>Direction de la publication : à compléter par l’association</li>
|
||||
<li>Siège social : {value(settings.association_address)}</li>
|
||||
<li>RNA : {value(settings.association_rna)}</li>
|
||||
<li>SIRET : {value(settings.association_siret)}</li>
|
||||
<li>Direction de la publication : {value(settings.association_publication_director || settings.association_president)}</li>
|
||||
</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>
|
||||
<p>
|
||||
Nom, raison sociale, adresse et numéro de téléphone de l’hébergeur : à compléter par l’association
|
||||
dès que l’hébergement de production est choisi.
|
||||
</p>
|
||||
<ul>
|
||||
<li>Hébergeur : {value(settings.association_host_name)}</li>
|
||||
<li>Adresse : {value(settings.association_host_address)}</li>
|
||||
<li>Téléphone : {value(settings.association_host_phone)}</li>
|
||||
</ul>
|
||||
|
||||
<h2>Propriété intellectuelle</h2>
|
||||
<p>
|
||||
@@ -38,6 +52,7 @@ export default function MentionsLegalesPage() {
|
||||
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.
|
||||
</p>
|
||||
{settings.legal_updated && <p>Dernière mise à jour : {settings.legal_updated}</p>}
|
||||
</LegalPage>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ export function SiteFooter() {
|
||||
<div className="footer-links">
|
||||
<Link href="/annuaire">Annuaire</Link>
|
||||
<Link href="/#promotions">Promotions</Link>
|
||||
<Link href="/association">Association</Link>
|
||||
<Link href="/login">Connexion</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ const NAV = [
|
||||
{ key: "accueil", label: "Accueil", href: "/" },
|
||||
{ key: "annuaire", label: "Annuaire", href: "/annuaire" },
|
||||
{ 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 }) {
|
||||
|
||||
@@ -125,6 +125,85 @@ export const activityLog = pgTable("activity_log", {
|
||||
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 ----
|
||||
export const membersRelations = relations(members, ({ one, many }) => ({
|
||||
category: one(categories, {
|
||||
@@ -132,6 +211,8 @@ export const membersRelations = relations(members, ({ one, many }) => ({
|
||||
references: [categories.id],
|
||||
}),
|
||||
promotions: many(promotions),
|
||||
meetingRegistrations: many(meetingRegistrations),
|
||||
imageConsents: many(imageConsents),
|
||||
}));
|
||||
|
||||
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 Member = typeof members.$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 ContactMessage = typeof contactMessages.$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
@@ -20,6 +20,8 @@ type Capability =
|
||||
| "moderatePromos"
|
||||
| "manageAdmins"
|
||||
| "manageCategories"
|
||||
| "manageMeetings"
|
||||
| "manageSettings"
|
||||
| "memberSpace";
|
||||
|
||||
const CAPABILITIES: Record<AppRole, Capability[]> = {
|
||||
@@ -29,9 +31,11 @@ const CAPABILITIES: Record<AppRole, Capability[]> = {
|
||||
"moderatePromos",
|
||||
"manageAdmins",
|
||||
"manageCategories",
|
||||
"manageMeetings",
|
||||
"manageSettings",
|
||||
"memberSpace",
|
||||
],
|
||||
moderator: ["viewDashboard", "manageMembers", "moderatePromos"],
|
||||
moderator: ["viewDashboard", "manageMembers", "moderatePromos", "manageMeetings"],
|
||||
editor: ["viewDashboard", "manageMembers"],
|
||||
member: ["memberSpace"],
|
||||
};
|
||||
|
||||
@@ -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" };
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user