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. +

+ +
+ ) : ( +
+

+ Une question, une suggestion ? Écrivez-nous. +

+ + + + + + + + + + + +