From 141acffc1c85ff9cb8d21900bb8daad902be8af3 Mon Sep 17 00:00:00 2001
From: Michael SCHAL
Date: Sat, 27 Jun 2026 09:16:59 +0200
Subject: [PATCH] Logo annuaire + modals devenir membre & contact
- Logo en haut a gauche de la page Annuaire (prop logo sur SiteHeader)
- Modal "Devenir adherent" sur les CTA accueil + annuaire : cree une
demande d'adhesion (membership_requests) et alerte les admins via le
journal d'activite (compteur tableau de bord deja cable)
- Formulaire de contact (modal depuis le pied de page) : nouvelle table
contact_messages + alerte admin
- Composant Modal partage, server actions publiques (sans auth)
- Migration drizzle 0002
Co-Authored-By: Claude Opus 4.8
---
drizzle/0002_puzzling_kabuki.sql | 10 +
drizzle/meta/0002_snapshot.json | 592 +++++++++++++++++++++++
drizzle/meta/_journal.json | 7 +
src/app/actions/public.ts | 73 +++
src/app/annuaire/page.tsx | 14 +-
src/app/page.tsx | 11 +-
src/components/ContactModalButton.tsx | 115 +++++
src/components/MembershipModalButton.tsx | 116 +++++
src/components/Modal.tsx | 93 ++++
src/components/SiteFooter.tsx | 16 +-
src/components/SiteHeader.tsx | 10 +-
src/db/schema.ts | 17 +
12 files changed, 1057 insertions(+), 17 deletions(-)
create mode 100644 drizzle/0002_puzzling_kabuki.sql
create mode 100644 drizzle/meta/0002_snapshot.json
create mode 100644 src/app/actions/public.ts
create mode 100644 src/components/ContactModalButton.tsx
create mode 100644 src/components/MembershipModalButton.tsx
create mode 100644 src/components/Modal.tsx
diff --git a/drizzle/0002_puzzling_kabuki.sql b/drizzle/0002_puzzling_kabuki.sql
new file mode 100644
index 0000000..f5d1e57
--- /dev/null
+++ b/drizzle/0002_puzzling_kabuki.sql
@@ -0,0 +1,10 @@
+CREATE TYPE "public"."contact_status" AS ENUM('new', 'read', 'archived');--> statement-breakpoint
+CREATE TABLE "contact_messages" (
+ "id" serial PRIMARY KEY NOT NULL,
+ "name" varchar(200) NOT NULL,
+ "email" varchar(200) NOT NULL,
+ "subject" varchar(200),
+ "message" text NOT NULL,
+ "status" "contact_status" DEFAULT 'new' NOT NULL,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL
+);
diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json
new file mode 100644
index 0000000..11bd272
--- /dev/null
+++ b/drizzle/meta/0002_snapshot.json
@@ -0,0 +1,592 @@
+{
+ "id": "0a329bb6-174e-4b49-b244-fe5be0324a99",
+ "prevId": "338e83f7-c966-41fc-a2c7-14a4147353c5",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.activity_log": {
+ "name": "activity_log",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "dot": {
+ "name": "dot",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'#2C6FB3'"
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.categories": {
+ "name": "categories",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "varchar(80)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "label": {
+ "name": "label",
+ "type": "varchar(120)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "accent": {
+ "name": "accent",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'#E0A63C'"
+ },
+ "tint": {
+ "name": "tint",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'#f6efdc'"
+ },
+ "sort": {
+ "name": "sort",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "categories_slug_unique": {
+ "name": "categories_slug_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "slug"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.contact_messages": {
+ "name": "contact_messages",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "subject": {
+ "name": "subject",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "contact_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'new'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.members": {
+ "name": "members",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "category_id": {
+ "name": "category_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "city": {
+ "name": "city",
+ "type": "varchar(120)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "address": {
+ "name": "address",
+ "type": "varchar(240)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "member_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "highlighted": {
+ "name": "highlighted",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "logo_url": {
+ "name": "logo_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cover_url": {
+ "name": "cover_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "phone": {
+ "name": "phone",
+ "type": "varchar(40)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "website": {
+ "name": "website",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "postal_code": {
+ "name": "postal_code",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "member_since": {
+ "name": "member_since",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "hours": {
+ "name": "hours",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tags": {
+ "name": "tags",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "members_category_id_categories_id_fk": {
+ "name": "members_category_id_categories_id_fk",
+ "tableFrom": "members",
+ "tableTo": "categories",
+ "columnsFrom": [
+ "category_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.membership_requests": {
+ "name": "membership_requests",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "request_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'new'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.promotions": {
+ "name": "promotions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "category": {
+ "name": "category",
+ "type": "varchar(120)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "badge": {
+ "name": "badge",
+ "type": "varchar(40)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "image_url": {
+ "name": "image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "member_id": {
+ "name": "member_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "promo_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "valid_until": {
+ "name": "valid_until",
+ "type": "varchar(120)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "promotions_member_id_members_id_fk": {
+ "name": "promotions_member_id_members_id_fk",
+ "tableFrom": "promotions",
+ "tableTo": "members",
+ "columnsFrom": [
+ "member_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.users": {
+ "name": "users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "password_hash": {
+ "name": "password_hash",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "role",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'member'"
+ },
+ "member_id": {
+ "name": "member_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "users_email_idx": {
+ "name": "users_email_idx",
+ "columns": [
+ {
+ "expression": "email",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "users_member_id_members_id_fk": {
+ "name": "users_member_id_members_id_fk",
+ "tableFrom": "users",
+ "tableTo": "members",
+ "columnsFrom": [
+ "member_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.contact_status": {
+ "name": "contact_status",
+ "schema": "public",
+ "values": [
+ "new",
+ "read",
+ "archived"
+ ]
+ },
+ "public.member_status": {
+ "name": "member_status",
+ "schema": "public",
+ "values": [
+ "active",
+ "pending"
+ ]
+ },
+ "public.promo_status": {
+ "name": "promo_status",
+ "schema": "public",
+ "values": [
+ "pending",
+ "live",
+ "expired",
+ "rejected"
+ ]
+ },
+ "public.request_status": {
+ "name": "request_status",
+ "schema": "public",
+ "values": [
+ "new",
+ "approved",
+ "rejected"
+ ]
+ },
+ "public.role": {
+ "name": "role",
+ "schema": "public",
+ "values": [
+ "admin",
+ "moderator",
+ "editor",
+ "member"
+ ]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index 6017429..7bb66d0 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -15,6 +15,13 @@
"when": 1782508812450,
"tag": "0001_dusty_cardiac",
"breakpoints": true
+ },
+ {
+ "idx": 2,
+ "version": "7",
+ "when": 1782544443365,
+ "tag": "0002_puzzling_kabuki",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/src/app/actions/public.ts b/src/app/actions/public.ts
new file mode 100644
index 0000000..edb0918
--- /dev/null
+++ b/src/app/actions/public.ts
@@ -0,0 +1,73 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { db } from "@/db";
+import { activityLog, contactMessages, membershipRequests } from "@/db/schema";
+
+export type SubmitResult = { ok: boolean; error?: string };
+
+const isEmail = (v: string) => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v);
+
+async function logActivity(message: string, dot: string) {
+ await db.insert(activityLog).values({ message, dot });
+}
+
+// ---- Demande pour devenir membre (public, sans authentification) ----
+export async function submitMembershipRequest(formData: FormData): Promise {
+ const name = String(formData.get("name") ?? "").trim();
+ const email = String(formData.get("email") ?? "").trim();
+ const phone = String(formData.get("phone") ?? "").trim();
+ const note = String(formData.get("message") ?? "").trim();
+
+ if (!name) return { ok: false, error: "Le nom est requis." };
+ if (email && !isEmail(email)) return { ok: false, error: "Adresse e-mail invalide." };
+
+ // Pas de colonne téléphone dédiée : on l'intègre au message.
+ const message = [phone ? `Téléphone : ${phone}` : null, note || null]
+ .filter(Boolean)
+ .join("\n")
+ .slice(0, 2000);
+
+ await db.insert(membershipRequests).values({
+ name,
+ email: email || null,
+ message: message || null,
+ status: "new",
+ });
+
+ await logActivity(
+ `${name} souhaite devenir adhérent`,
+ "#9a6638",
+ );
+
+ revalidatePath("/backend");
+ return { ok: true };
+}
+
+// ---- Message via le formulaire de contact (public) ----
+export async function submitContactMessage(formData: FormData): Promise {
+ const name = String(formData.get("name") ?? "").trim();
+ const email = String(formData.get("email") ?? "").trim();
+ const subject = String(formData.get("subject") ?? "").trim();
+ const message = String(formData.get("message") ?? "").trim();
+
+ if (!name) return { ok: false, error: "Le nom est requis." };
+ if (!isEmail(email)) return { ok: false, error: "Adresse e-mail invalide." };
+ if (!message) return { ok: false, error: "Le message est requis." };
+
+ await db.insert(contactMessages).values({
+ name,
+ email,
+ subject: subject ? subject.slice(0, 200) : null,
+ message: message.slice(0, 4000),
+ status: "new",
+ });
+
+ await logActivity(
+ `Nouveau message de contact de ${name}`,
+ "#2C6FB3",
+ );
+
+ revalidatePath("/backend");
+ return { ok: true };
+}
diff --git a/src/app/annuaire/page.tsx b/src/app/annuaire/page.tsx
index da66f5e..82036eb 100644
--- a/src/app/annuaire/page.tsx
+++ b/src/app/annuaire/page.tsx
@@ -1,8 +1,8 @@
-import Link from "next/link";
import type { Metadata } from "next";
import { Sparkle } from "@/components/Sparkle";
import { SiteHeader } from "@/components/SiteHeader";
import { SiteFooter } from "@/components/SiteFooter";
+import { MembershipModalButton } from "@/components/MembershipModalButton";
import {
getActiveMembersWithCategory,
getAllCategories,
@@ -45,7 +45,7 @@ export default async function AnnuairePage({
return (
-
+
{/* title block */}
@@ -107,13 +107,11 @@ export default async function AnnuairePage({
Rejoignez l'annuaire Plein R
-
- Adhérer à l'association
-
+ style={{ border: "none", background: "#E0A63C", color: "#33291D", fontWeight: 700, fontSize: 15.5, padding: "14px 26px", borderRadius: 12, whiteSpace: "nowrap" }}
+ />
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 509e3ca..d58341f 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -3,6 +3,7 @@ import { Sparkle } from "@/components/Sparkle";
import { Reveal } from "@/components/Reveal";
import { SiteHeader } from "@/components/SiteHeader";
import { SiteFooter } from "@/components/SiteFooter";
+import { MembershipModalButton } from "@/components/MembershipModalButton";
import { getHighlightedMembers, getLivePromotions } from "@/lib/queries";
export const dynamic = "force-dynamic";
@@ -456,11 +457,11 @@ export default async function AccueilPage() {
Rejoignez plus de 120 commerçants et entreprises qui font réseau, se rencontrent et réussissent
ensemble.
-
- Devenir adhérent
-
+ />
diff --git a/src/components/ContactModalButton.tsx b/src/components/ContactModalButton.tsx
new file mode 100644
index 0000000..f0081f7
--- /dev/null
+++ b/src/components/ContactModalButton.tsx
@@ -0,0 +1,115 @@
+"use client";
+
+import { useState } from "react";
+import { Modal } from "./Modal";
+import { submitContactMessage } from "@/app/actions/public";
+
+export function ContactModalButton({
+ label,
+ className,
+ style,
+}: {
+ label: string;
+ className?: string;
+ style?: React.CSSProperties;
+}) {
+ const [open, setOpen] = useState(false);
+ const [pending, setPending] = useState(false);
+ const [done, setDone] = useState(false);
+ const [error, setError] = useState(null);
+
+ function close() {
+ setOpen(false);
+ setTimeout(() => {
+ setDone(false);
+ setError(null);
+ }, 200);
+ }
+
+ async function onSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ setPending(true);
+ setError(null);
+ const res = await submitContactMessage(new FormData(e.currentTarget));
+ setPending(false);
+ if (res.ok) setDone(true);
+ else setError(res.error ?? "Une erreur est survenue.");
+ }
+
+ return (
+ <>
+
+
+
+ {done ? (
+
+
✓
+
+ Message envoyé ! Nous vous répondrons dès que possible.
+
+
+
+ ) : (
+
+ )}
+
+ >
+ );
+}
+
+function Field({ label, children }: { label: string; children: React.ReactNode }) {
+ return (
+
+
+ {children}
+
+ );
+}
+
+const primaryBtn: React.CSSProperties = {
+ width: "100%",
+ border: "none",
+ background: "#13324F",
+ color: "#fff",
+ fontWeight: 700,
+ fontSize: 15,
+ padding: "13px 18px",
+ borderRadius: 12,
+ cursor: "pointer",
+};
+
+const errorBox: React.CSSProperties = {
+ background: "#fbe8e6",
+ color: "#a3372e",
+ fontSize: 13,
+ padding: "10px 12px",
+ borderRadius: 10,
+ marginBottom: 14,
+};
diff --git a/src/components/MembershipModalButton.tsx b/src/components/MembershipModalButton.tsx
new file mode 100644
index 0000000..1db49a0
--- /dev/null
+++ b/src/components/MembershipModalButton.tsx
@@ -0,0 +1,116 @@
+"use client";
+
+import { useState } from "react";
+import { Modal } from "./Modal";
+import { submitMembershipRequest } from "@/app/actions/public";
+
+export function MembershipModalButton({
+ label,
+ className,
+ style,
+}: {
+ label: string;
+ className?: string;
+ style?: React.CSSProperties;
+}) {
+ const [open, setOpen] = useState(false);
+ const [pending, setPending] = useState(false);
+ const [done, setDone] = useState(false);
+ const [error, setError] = useState(null);
+
+ function close() {
+ setOpen(false);
+ // Réinitialise après la fermeture (laisse l'animation finir).
+ setTimeout(() => {
+ setDone(false);
+ setError(null);
+ }, 200);
+ }
+
+ async function onSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ setPending(true);
+ setError(null);
+ const res = await submitMembershipRequest(new FormData(e.currentTarget));
+ setPending(false);
+ if (res.ok) setDone(true);
+ else setError(res.error ?? "Une erreur est survenue.");
+ }
+
+ return (
+ <>
+
+
+
+ {done ? (
+
+
✓
+
+ Demande envoyée ! L'équipe Plein R vous recontactera rapidement.
+
+
+
+ ) : (
+
+ )}
+
+ >
+ );
+}
+
+function Field({ label, children }: { label: string; children: React.ReactNode }) {
+ return (
+
+
+ {children}
+
+ );
+}
+
+const primaryBtn: React.CSSProperties = {
+ width: "100%",
+ border: "none",
+ background: "#9a6638",
+ color: "#fff",
+ fontWeight: 700,
+ fontSize: 15,
+ padding: "13px 18px",
+ borderRadius: 12,
+ cursor: "pointer",
+};
+
+const errorBox: React.CSSProperties = {
+ background: "#fbe8e6",
+ color: "#a3372e",
+ fontSize: 13,
+ padding: "10px 12px",
+ borderRadius: 10,
+ marginBottom: 14,
+};
diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx
new file mode 100644
index 0000000..9f0b500
--- /dev/null
+++ b/src/components/Modal.tsx
@@ -0,0 +1,93 @@
+"use client";
+
+import { useEffect } from "react";
+
+export function Modal({
+ open,
+ onClose,
+ title,
+ children,
+ maxWidth = 480,
+}: {
+ open: boolean;
+ onClose: () => void;
+ title: string;
+ children: React.ReactNode;
+ maxWidth?: number;
+}) {
+ useEffect(() => {
+ if (!open) return;
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") onClose();
+ };
+ document.addEventListener("keydown", onKey);
+ const prev = document.body.style.overflow;
+ document.body.style.overflow = "hidden";
+ return () => {
+ document.removeEventListener("keydown", onKey);
+ document.body.style.overflow = prev;
+ };
+ }, [open, onClose]);
+
+ if (!open) return null;
+
+ return (
+
+
e.stopPropagation()}
+ style={{
+ background: "#fff",
+ borderRadius: 18,
+ border: "1px solid #e6dcc6",
+ width: "100%",
+ maxWidth,
+ maxHeight: "90vh",
+ overflowY: "auto",
+ boxShadow: "0 24px 60px rgba(25,20,12,0.28)",
+ padding: "26px 28px",
+ }}
+ >
+
+
+ {title}
+
+
+
+ {children}
+
+
+ );
+}
diff --git a/src/components/SiteFooter.tsx b/src/components/SiteFooter.tsx
index d105868..b45f01c 100644
--- a/src/components/SiteFooter.tsx
+++ b/src/components/SiteFooter.tsx
@@ -1,3 +1,5 @@
+import { ContactModalButton } from "./ContactModalButton";
+
export function SiteFooter() {
return (