mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-17 12:17:20 +02:00
Implémente le site Plein R (Accueil + Backend) sur Next.js + Postgres + Docker
Application unique Next.js (App Router, TypeScript) servant : - Accueil public : héro, recherche, métiers, promotions, mises à l'honneur (données issues de Postgres) - Backend authentifié avec RBAC : tableau de bord, adhérents (CRUD), modération des promotions, administrateurs, espace adhérent (publication avec aperçu) Technique : - Drizzle ORM + drizzle-kit (migrations versionnées) sur driver pg - Auth.js v5 (credentials, sessions JWT), rôles admin/moderator/editor/member - Middleware de protection de /backend + gardes par rôle dans chaque vue - Server actions pour toutes les mutations (vérif auth + capacité + revalidate) - Design fidèle aux maquettes (design-system CSS, animations, responsive) Conteneurisation : - 1 conteneur applicatif (sortie standalone) + 1 conteneur Postgres externe - docker-compose avec healthcheck ; migrations + seed au démarrage - scripts migrate/seed bundlés (esbuild) pour un runtime sans devDeps Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XwfhYHzC9hQWPDnQ2p53GL
This commit is contained in:
45 files changed
+12013
No files matched your search
@@ -0,0 +1,241 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { can, ROLE_LABELS } from "@/lib/rbac";
|
||||
import type { AppRole } from "@/types/next-auth";
|
||||
import { doSignOut } from "./actions";
|
||||
|
||||
const TITLES: Record<string, [string, string]> = {
|
||||
"/backend": ["Tableau de bord", "Vue d'ensemble de l'association"],
|
||||
"/backend/adherents": ["Adhérents", "Gérer les commerçants et entreprises"],
|
||||
"/backend/promotions": [
|
||||
"Modération des promotions",
|
||||
"Validez les offres soumises par les adhérents",
|
||||
],
|
||||
"/backend/administrateurs": ["Administrateurs", "Gérer les accès à l'administration"],
|
||||
"/backend/espace": ["Mon espace adhérent", "Publiez et suivez vos promotions"],
|
||||
};
|
||||
|
||||
function initialsOf(name: string) {
|
||||
return name
|
||||
.split(/\s+/)
|
||||
.map((w) => w[0])
|
||||
.join("")
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
function NavLink({
|
||||
href,
|
||||
active,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
active: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Link href={href} className={`nav-btn${active ? " active" : ""}`}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function BackendShell({
|
||||
user,
|
||||
pendingCount,
|
||||
children,
|
||||
}: {
|
||||
user: { name: string; role: AppRole };
|
||||
pendingCount: number;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const [title, sub] =
|
||||
TITLES[pathname] ??
|
||||
(pathname.startsWith("/backend/adherents")
|
||||
? TITLES["/backend/adherents"]
|
||||
: ["Administration", "Plein R"]);
|
||||
|
||||
const dot = (
|
||||
<span style={{ width: 18, height: 18, border: "2px solid currentColor", borderRadius: 5, display: "inline-block" }} />
|
||||
);
|
||||
const dotRound = (
|
||||
<span style={{ width: 18, height: 18, border: "2px solid currentColor", borderRadius: "50%", display: "inline-block" }} />
|
||||
);
|
||||
const dotDiamond = (
|
||||
<span style={{ width: 18, height: 18, border: "2px solid currentColor", borderRadius: 5, display: "inline-block", transform: "rotate(45deg)" }} />
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="backend" style={{ fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
|
||||
{/* sidebar */}
|
||||
<aside className="sidebar">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 11, padding: "6px 8px 22px" }}>
|
||||
<span style={{ display: "inline-flex", background: "#F6F2E8", borderRadius: 11, padding: 6 }}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src="/assets/logo.svg" alt="Plein R" style={{ height: 34, width: "auto", display: "block" }} />
|
||||
</span>
|
||||
<div>
|
||||
<div className="font-display" style={{ fontWeight: 800, fontSize: 17, color: "#fff", lineHeight: 1 }}>
|
||||
Plein R
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "#7f9bb4", marginTop: 3 }}>Administration</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: "1 1 auto", display: "flex", flexDirection: "column", minWidth: 200 }}>
|
||||
{can(user.role, "viewDashboard") && (
|
||||
<>
|
||||
<div style={sectionLabel}>Pilotage</div>
|
||||
<NavLink href="/backend" active={pathname === "/backend"}>
|
||||
{dot}Tableau de bord
|
||||
</NavLink>
|
||||
{can(user.role, "manageMembers") && (
|
||||
<NavLink href="/backend/adherents" active={pathname.startsWith("/backend/adherents")}>
|
||||
{dotRound}Adhérents
|
||||
</NavLink>
|
||||
)}
|
||||
{can(user.role, "moderatePromos") && (
|
||||
<NavLink href="/backend/promotions" active={pathname === "/backend/promotions"}>
|
||||
{dotDiamond}Promotions
|
||||
<span
|
||||
style={{
|
||||
marginLeft: "auto",
|
||||
background: "#E0A63C",
|
||||
color: "#33291D",
|
||||
fontSize: 11,
|
||||
fontWeight: 800,
|
||||
borderRadius: 999,
|
||||
padding: "1px 8px",
|
||||
}}
|
||||
>
|
||||
{pendingCount}
|
||||
</span>
|
||||
</NavLink>
|
||||
)}
|
||||
{can(user.role, "manageAdmins") && (
|
||||
<NavLink href="/backend/administrateurs" active={pathname === "/backend/administrateurs"}>
|
||||
{dot}Administrateurs
|
||||
</NavLink>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div style={sectionLabel}>Côté adhérent</div>
|
||||
<NavLink href="/backend/espace" active={pathname === "/backend/espace"}>
|
||||
{dotRound}Mon espace
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: "auto",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 11,
|
||||
background: "rgba(255,255,255,0.06)",
|
||||
borderRadius: 12,
|
||||
padding: "11px 12px",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="font-display"
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: "50%",
|
||||
background: "#E0A63C",
|
||||
color: "#33291D",
|
||||
fontWeight: 800,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 14,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{initialsOf(user.name)}
|
||||
</span>
|
||||
<div style={{ lineHeight: 1.2, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: "#fff", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{user.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "#7f9bb4" }}>{ROLE_LABELS[user.role]}</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* main */}
|
||||
<main style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column" }}>
|
||||
<header
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "18px 32px",
|
||||
background: "#F6F2E8",
|
||||
borderBottom: "1px solid #e2d6bd",
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: 5,
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h1 className="font-display" style={{ fontWeight: 800, fontSize: 22, margin: 0, color: "#26201a" }}>
|
||||
{title}
|
||||
</h1>
|
||||
<div style={{ fontSize: 13, color: "#9a8d72", marginTop: 2 }}>{sub}</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<Link
|
||||
href="/"
|
||||
style={{
|
||||
textDecoration: "none",
|
||||
fontSize: 13.5,
|
||||
fontWeight: 600,
|
||||
color: "#6f6450",
|
||||
border: "1px solid #d8cdb4",
|
||||
padding: "9px 15px",
|
||||
borderRadius: 10,
|
||||
}}
|
||||
>
|
||||
← Voir le site
|
||||
</Link>
|
||||
<form action={doSignOut}>
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
background: "#fff",
|
||||
border: "1px solid #d8cdb4",
|
||||
color: "#9a6638",
|
||||
fontSize: 13.5,
|
||||
fontWeight: 700,
|
||||
padding: "9px 15px",
|
||||
borderRadius: 10,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Déconnexion
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div style={{ padding: "28px 32px 44px", flex: 1 }}>{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sectionLabel = {
|
||||
fontSize: 10.5,
|
||||
letterSpacing: "0.14em",
|
||||
textTransform: "uppercase" as const,
|
||||
color: "#5f7d97",
|
||||
fontWeight: 700,
|
||||
padding: "20px 10px 8px",
|
||||
};
|
||||
@@ -0,0 +1,177 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { eq } from "drizzle-orm";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { auth, signOut } from "@/auth";
|
||||
import { db } from "@/db";
|
||||
import {
|
||||
activityLog,
|
||||
members,
|
||||
promotions,
|
||||
users,
|
||||
} from "@/db/schema";
|
||||
import { can, LABEL_TO_ROLE } from "@/lib/rbac";
|
||||
import type { AppRole } from "@/types/next-auth";
|
||||
|
||||
async function logActivity(message: string, dot = "#2C6FB3") {
|
||||
await db.insert(activityLog).values({ message, dot });
|
||||
}
|
||||
|
||||
async function requireRole(): Promise<{ role: AppRole; memberId: number | null; name: string }> {
|
||||
const session = await auth();
|
||||
if (!session?.user) throw new Error("Non authentifié");
|
||||
return {
|
||||
role: session.user.role,
|
||||
memberId: session.user.memberId,
|
||||
name: session.user.name ?? "Adhérent",
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Promotion moderation ----
|
||||
export async function moderatePromo(formData: FormData) {
|
||||
const { role } = await requireRole();
|
||||
if (!can(role, "moderatePromos")) throw new Error("Accès refusé");
|
||||
|
||||
const id = Number(formData.get("id"));
|
||||
const action = String(formData.get("action"));
|
||||
if (!id) return;
|
||||
|
||||
if (action === "approve") {
|
||||
await db.update(promotions).set({ status: "live" }).where(eq(promotions.id, id));
|
||||
const [p] = await db.select().from(promotions).where(eq(promotions.id, id));
|
||||
if (p) await logActivity(`Promotion « ${p.title} » validée et mise en ligne`, "#1f8a5b");
|
||||
} else if (action === "reject" || action === "remove") {
|
||||
await db.delete(promotions).where(eq(promotions.id, id));
|
||||
}
|
||||
|
||||
revalidatePath("/backend/promotions");
|
||||
revalidatePath("/backend");
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
// ---- Member space: publish a promotion ----
|
||||
export async function publishPromo(formData: FormData) {
|
||||
const { memberId, name } = await requireRole();
|
||||
|
||||
const title = String(formData.get("title") ?? "").trim();
|
||||
if (!title) return;
|
||||
|
||||
const text = String(formData.get("text") ?? "").slice(0, 240);
|
||||
const category = String(formData.get("category") ?? "");
|
||||
const badge = String(formData.get("badge") ?? "").trim() || null;
|
||||
const imageUrl = String(formData.get("imageUrl") ?? "") || null;
|
||||
|
||||
await db.insert(promotions).values({
|
||||
title,
|
||||
text,
|
||||
category,
|
||||
badge,
|
||||
imageUrl,
|
||||
memberId: memberId ?? null,
|
||||
status: "pending",
|
||||
});
|
||||
|
||||
await logActivity(`<strong>${name}</strong> a soumis une promotion « ${title} »`, "#E0A63C");
|
||||
|
||||
revalidatePath("/backend/espace");
|
||||
revalidatePath("/backend");
|
||||
revalidatePath("/backend/promotions");
|
||||
}
|
||||
|
||||
// ---- Members CRUD ----
|
||||
export async function addMember(formData: FormData) {
|
||||
const { role } = await requireRole();
|
||||
if (!can(role, "manageMembers")) throw new Error("Accès refusé");
|
||||
|
||||
const name = String(formData.get("name") ?? "").trim();
|
||||
if (!name) return;
|
||||
const email = String(formData.get("email") ?? "").trim();
|
||||
const categoryId = formData.get("categoryId") ? Number(formData.get("categoryId")) : null;
|
||||
const city = String(formData.get("city") ?? "").trim() || null;
|
||||
const status = (String(formData.get("status") ?? "pending") as "active" | "pending");
|
||||
|
||||
await db.insert(members).values({ name, email, categoryId, city, status });
|
||||
await logActivity(`Nouvel adhérent ajouté : <strong>${name}</strong>`, "#2C6FB3");
|
||||
|
||||
revalidatePath("/backend/adherents");
|
||||
revalidatePath("/backend");
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
export async function updateMember(formData: FormData) {
|
||||
const { role } = await requireRole();
|
||||
if (!can(role, "manageMembers")) throw new Error("Accès refusé");
|
||||
|
||||
const id = Number(formData.get("id"));
|
||||
if (!id) return;
|
||||
|
||||
await db
|
||||
.update(members)
|
||||
.set({
|
||||
name: String(formData.get("name") ?? "").trim(),
|
||||
email: String(formData.get("email") ?? "").trim(),
|
||||
categoryId: formData.get("categoryId") ? Number(formData.get("categoryId")) : null,
|
||||
city: String(formData.get("city") ?? "").trim() || null,
|
||||
address: String(formData.get("address") ?? "").trim() || null,
|
||||
description: String(formData.get("description") ?? "").trim() || null,
|
||||
status: String(formData.get("status") ?? "pending") as "active" | "pending",
|
||||
highlighted: formData.get("highlighted") === "on",
|
||||
})
|
||||
.where(eq(members.id, id));
|
||||
|
||||
revalidatePath("/backend/adherents");
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
export async function deleteMember(formData: FormData) {
|
||||
const { role } = await requireRole();
|
||||
if (!can(role, "manageMembers")) throw new Error("Accès refusé");
|
||||
const id = Number(formData.get("id"));
|
||||
if (!id) return;
|
||||
await db.delete(members).where(eq(members.id, id));
|
||||
revalidatePath("/backend/adherents");
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
// ---- Admins ----
|
||||
export async function inviteAdmin(formData: FormData) {
|
||||
const { role } = await requireRole();
|
||||
if (!can(role, "manageAdmins")) throw new Error("Accès refusé");
|
||||
|
||||
const name = String(formData.get("name") ?? "").trim();
|
||||
if (!name) return;
|
||||
const email = String(formData.get("email") ?? "").trim().toLowerCase();
|
||||
if (!email) return;
|
||||
const roleLabel = String(formData.get("role") ?? "Administrateur");
|
||||
const newRole: AppRole = LABEL_TO_ROLE[roleLabel] ?? "editor";
|
||||
|
||||
const existing = await db.select().from(users).where(eq(users.email, email));
|
||||
if (existing.length > 0) return;
|
||||
|
||||
// Temporary password — the invitee resets it on first login (out of scope here).
|
||||
const tempPassword = Math.random().toString(36).slice(2, 12);
|
||||
await db.insert(users).values({
|
||||
name,
|
||||
email,
|
||||
role: newRole,
|
||||
passwordHash: await bcrypt.hash(tempPassword, 10),
|
||||
});
|
||||
|
||||
await logActivity(`<strong>${name}</strong> a été invité comme ${roleLabel}`, "#2C6FB3");
|
||||
revalidatePath("/backend/administrateurs");
|
||||
}
|
||||
|
||||
export async function removeAdmin(formData: FormData) {
|
||||
const { role } = await requireRole();
|
||||
if (!can(role, "manageAdmins")) throw new Error("Accès refusé");
|
||||
const id = Number(formData.get("id"));
|
||||
if (!id) return;
|
||||
await db.delete(users).where(eq(users.id, id));
|
||||
revalidatePath("/backend/administrateurs");
|
||||
}
|
||||
|
||||
// ---- Sign out ----
|
||||
export async function doSignOut() {
|
||||
await signOut({ redirectTo: "/" });
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { addMember } from "../actions";
|
||||
|
||||
export function AddMemberPanel({
|
||||
categories,
|
||||
}: {
|
||||
categories: { id: number; label: string }[];
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 0 }}>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="font-display"
|
||||
style={{ border: "none", background: "#13324F", color: "#fff", fontWeight: 700, fontSize: 14, padding: "11px 18px", borderRadius: 10, cursor: "pointer" }}
|
||||
>
|
||||
{open ? "× Fermer" : "+ Ajouter un adhérent"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<form
|
||||
action={async (fd) => {
|
||||
await addMember(fd);
|
||||
setOpen(false);
|
||||
}}
|
||||
style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, padding: 22, marginTop: 14 }}
|
||||
>
|
||||
<div className="grid grid-2" style={{ gap: 16 }}>
|
||||
<div>
|
||||
<label className="field-label">Nom de l'adhérent</label>
|
||||
<input name="name" required placeholder="ex : Au Bon Pain" className="field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">E-mail</label>
|
||||
<input name="email" type="email" placeholder="contact@exemple.fr" className="field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Catégorie</label>
|
||||
<select name="categoryId" className="field" defaultValue="">
|
||||
<option value="">—</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Commune</label>
|
||||
<input name="city" placeholder="ex : Pompey" className="field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Statut</label>
|
||||
<select name="status" className="field" defaultValue="pending">
|
||||
<option value="pending">En attente</option>
|
||||
<option value="active">Actif</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="font-display"
|
||||
style={{ marginTop: 18, border: "none", background: "#9a6638", color: "#fff", fontWeight: 700, fontSize: 14.5, padding: "12px 22px", borderRadius: 11, cursor: "pointer" }}
|
||||
>
|
||||
Enregistrer l'adhérent
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/db";
|
||||
import { categories, members } from "@/db/schema";
|
||||
import { can } from "@/lib/rbac";
|
||||
import { deleteMember, updateMember } from "../../actions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function EditMemberPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!can(session?.user.role, "manageMembers")) {
|
||||
redirect("/backend");
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const memberId = Number(id);
|
||||
if (!memberId) notFound();
|
||||
|
||||
const [member] = await db.select().from(members).where(eq(members.id, memberId));
|
||||
if (!member) notFound();
|
||||
|
||||
const cats = await db
|
||||
.select({ id: categories.id, label: categories.label })
|
||||
.from(categories)
|
||||
.orderBy(asc(categories.sort));
|
||||
|
||||
async function handleDelete() {
|
||||
"use server";
|
||||
const fd = new FormData();
|
||||
fd.set("id", String(memberId));
|
||||
await deleteMember(fd);
|
||||
redirect("/backend/adherents");
|
||||
}
|
||||
|
||||
async function handleUpdate(fd: FormData) {
|
||||
"use server";
|
||||
await updateMember(fd);
|
||||
redirect("/backend/adherents");
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 720 }}>
|
||||
<Link href="/backend/adherents" style={{ textDecoration: "none", color: "#6f6450", fontSize: 13.5, fontWeight: 600 }}>
|
||||
← Retour aux adhérents
|
||||
</Link>
|
||||
|
||||
<form action={handleUpdate} style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, padding: 24, marginTop: 14 }}>
|
||||
<input type="hidden" name="id" value={member.id} />
|
||||
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 18, margin: "0 0 18px", color: "#26201a" }}>
|
||||
Modifier l'adhérent
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-2" style={{ gap: 16 }}>
|
||||
<div>
|
||||
<label className="field-label">Nom</label>
|
||||
<input name="name" required defaultValue={member.name} className="field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">E-mail</label>
|
||||
<input name="email" type="email" defaultValue={member.email} className="field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Catégorie</label>
|
||||
<select name="categoryId" className="field" defaultValue={member.categoryId ?? ""}>
|
||||
<option value="">—</option>
|
||||
{cats.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Commune</label>
|
||||
<input name="city" defaultValue={member.city ?? ""} className="field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Adresse</label>
|
||||
<input name="address" defaultValue={member.address ?? ""} className="field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Statut</label>
|
||||
<select name="status" className="field" defaultValue={member.status}>
|
||||
<option value="pending">En attente</option>
|
||||
<option value="active">Actif</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<label className="field-label">Description</label>
|
||||
<textarea name="description" rows={3} defaultValue={member.description ?? ""} className="field" style={{ resize: "vertical" }} />
|
||||
</div>
|
||||
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 9, marginTop: 16, fontSize: 14, color: "#3c3322", cursor: "pointer" }}>
|
||||
<input type="checkbox" name="highlighted" defaultChecked={member.highlighted} />
|
||||
Mettre à l'honneur sur la page d'accueil
|
||||
</label>
|
||||
|
||||
<div style={{ display: "flex", gap: 10, marginTop: 22 }}>
|
||||
<button
|
||||
type="submit"
|
||||
className="font-display"
|
||||
style={{ border: "none", background: "#13324F", color: "#fff", fontWeight: 700, fontSize: 14.5, padding: "13px 24px", borderRadius: 11, cursor: "pointer" }}
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form action={handleDelete} style={{ marginTop: 16 }}>
|
||||
<button
|
||||
type="submit"
|
||||
style={{ border: "1px solid #e0c3bb", background: "#fff", color: "#d8472b", fontWeight: 700, fontSize: 13.5, padding: "11px 18px", borderRadius: 10, cursor: "pointer" }}
|
||||
>
|
||||
Supprimer cet adhérent
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { asc, eq, ilike } from "drizzle-orm";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/db";
|
||||
import { categories, members } from "@/db/schema";
|
||||
import { can } from "@/lib/rbac";
|
||||
import { AddMemberPanel } from "./AddMemberPanel";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function initialsOf(name: string) {
|
||||
return name.split(/\s+/).map((w) => w[0]).join("").slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
export default async function AdherentsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ q?: string }>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!can(session?.user.role, "manageMembers")) {
|
||||
redirect("/backend");
|
||||
}
|
||||
|
||||
const { q } = await searchParams;
|
||||
const search = (q ?? "").trim();
|
||||
|
||||
const cats = await db
|
||||
.select({ id: categories.id, label: categories.label })
|
||||
.from(categories)
|
||||
.orderBy(asc(categories.sort));
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: members.id,
|
||||
name: members.name,
|
||||
email: members.email,
|
||||
city: members.city,
|
||||
status: members.status,
|
||||
categoryLabel: categories.label,
|
||||
})
|
||||
.from(members)
|
||||
.leftJoin(categories, eq(members.categoryId, categories.id))
|
||||
.where(search ? ilike(members.name, `%${search}%`) : undefined)
|
||||
.orderBy(asc(members.name));
|
||||
|
||||
const COLS = "1.6fr 1fr 1.2fr 0.9fr 0.8fr";
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
|
||||
<AddMemberPanel categories={cats} />
|
||||
|
||||
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, overflow: "hidden" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "18px 22px", borderBottom: "1px solid #f0e8d6", gap: 12, flexWrap: "wrap" }}>
|
||||
<form style={{ display: "flex", alignItems: "center", gap: 10, background: "#faf7ef", border: "1px solid #e6dcc6", borderRadius: 10, padding: "8px 14px" }}>
|
||||
<span style={{ width: 9, height: 9, border: "2px solid #9a8d72", borderRadius: "50%" }} />
|
||||
<input
|
||||
name="q"
|
||||
defaultValue={search}
|
||||
placeholder="Rechercher un adhérent…"
|
||||
style={{ border: "none", outline: "none", background: "transparent", fontSize: 14, width: 240, color: "#3c3322" }}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: COLS,
|
||||
padding: "13px 22px",
|
||||
background: "#faf7ef",
|
||||
borderBottom: "1px solid #f0e8d6",
|
||||
fontSize: 12,
|
||||
letterSpacing: "0.05em",
|
||||
textTransform: "uppercase",
|
||||
color: "#9a8d72",
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
<div>Adhérent</div>
|
||||
<div>Catégorie</div>
|
||||
<div>Commune</div>
|
||||
<div>Statut</div>
|
||||
<div style={{ textAlign: "right" }}>Action</div>
|
||||
</div>
|
||||
|
||||
{rows.length === 0 && (
|
||||
<div style={{ padding: "22px", fontSize: 14, color: "#a99c82" }}>Aucun adhérent trouvé.</div>
|
||||
)}
|
||||
|
||||
{rows.map((m) => {
|
||||
const active = m.status === "active";
|
||||
return (
|
||||
<div key={m.id} style={{ display: "grid", gridTemplateColumns: COLS, alignItems: "center", padding: "15px 22px", borderBottom: "1px solid #f4eede" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<span className="font-display" style={{ width: 38, height: 38, borderRadius: 10, background: "#f0e8d6", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 800, color: "#9a8d72", fontSize: 14, flexShrink: 0 }}>
|
||||
{initialsOf(m.name)}
|
||||
</span>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 14.5, fontWeight: 700, color: "#26201a" }}>{m.name}</div>
|
||||
<div style={{ fontSize: 12, color: "#a99c82" }}>{m.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 13.5, color: "#6c6150" }}>{m.categoryLabel ?? "—"}</div>
|
||||
<div style={{ fontSize: 13.5, color: "#6c6150" }}>{m.city ?? "—"}</div>
|
||||
<div>
|
||||
<span style={{ display: "inline-block", fontSize: 11.5, fontWeight: 700, padding: "4px 11px", borderRadius: 999, background: active ? "#e6f4ec" : "#fbeede", color: active ? "#1f8a5b" : "#9a6638" }}>
|
||||
{active ? "Actif" : "En attente"}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ textAlign: "right" }}>
|
||||
<Link href={`/backend/adherents/${m.id}`} style={{ textDecoration: "none", color: "#2C6FB3", fontWeight: 700, fontSize: 13 }}>
|
||||
Modifier
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { inArray } from "drizzle-orm";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/db";
|
||||
import { users } from "@/db/schema";
|
||||
import { can, ROLE_LABELS, STAFF_ROLES } from "@/lib/rbac";
|
||||
import { inviteAdmin, removeAdmin } from "../actions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function initialsOf(name: string) {
|
||||
return name.split(/\s+/).map((w) => w[0]).join("").slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
export default async function AdminsPage() {
|
||||
const session = await auth();
|
||||
if (!can(session?.user.role, "manageAdmins")) {
|
||||
redirect("/backend");
|
||||
}
|
||||
|
||||
const admins = await db
|
||||
.select({ id: users.id, name: users.name, email: users.email, role: users.role })
|
||||
.from(users)
|
||||
.where(inArray(users.role, STAFF_ROLES));
|
||||
|
||||
return (
|
||||
<div className="grid admins-split">
|
||||
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, overflow: "hidden" }}>
|
||||
<div className="font-display" style={{ padding: "16px 22px", borderBottom: "1px solid #f0e8d6", fontWeight: 700, fontSize: 16, color: "#26201a" }}>
|
||||
Administrateurs ({admins.length})
|
||||
</div>
|
||||
{admins.map((a) => {
|
||||
const isAdmin = a.role === "admin";
|
||||
return (
|
||||
<div key={a.id} style={{ display: "flex", alignItems: "center", gap: 13, padding: "15px 22px", borderBottom: "1px solid #f4eede" }}>
|
||||
<span className="font-display" style={{ width: 40, height: 40, borderRadius: "50%", background: "#13324F", color: "#fff", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 800, fontSize: 14, flexShrink: 0 }}>
|
||||
{initialsOf(a.name)}
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 14.5, fontWeight: 700, color: "#26201a" }}>{a.name}</div>
|
||||
<div style={{ fontSize: 12.5, color: "#a99c82" }}>{a.email}</div>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
fontSize: 11.5,
|
||||
fontWeight: 700,
|
||||
padding: "4px 11px",
|
||||
borderRadius: 999,
|
||||
background: isAdmin ? "#e7eef6" : "#f1efe7",
|
||||
color: isAdmin ? "#2C6FB3" : "#6c6150",
|
||||
}}
|
||||
>
|
||||
{ROLE_LABELS[a.role]}
|
||||
</span>
|
||||
{a.id !== Number(session?.user.id) && (
|
||||
<form action={removeAdmin}>
|
||||
<input type="hidden" name="id" value={a.id} />
|
||||
<button type="submit" style={{ border: "none", background: "transparent", color: "#d8472b", fontWeight: 700, fontSize: 13, cursor: "pointer", marginLeft: 8 }}>
|
||||
Retirer
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, padding: 22 }}>
|
||||
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 16, margin: "0 0 16px", color: "#26201a" }}>
|
||||
Inviter un administrateur
|
||||
</h3>
|
||||
<form action={inviteAdmin}>
|
||||
<label className="field-label">Nom complet</label>
|
||||
<input name="name" required placeholder="ex : Julie Bernard" className="field" style={{ marginBottom: 14 }} />
|
||||
|
||||
<label className="field-label">E-mail</label>
|
||||
<input name="email" type="email" required placeholder="prenom@plein-r.fr" className="field" style={{ marginBottom: 14 }} />
|
||||
|
||||
<label className="field-label">Rôle</label>
|
||||
<select name="role" defaultValue="Administrateur" className="field" style={{ marginBottom: 18 }}>
|
||||
<option>Administrateur</option>
|
||||
<option>Modérateur</option>
|
||||
<option>Éditeur</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="font-display"
|
||||
style={{ width: "100%", border: "none", background: "#13324F", color: "#fff", fontWeight: 700, fontSize: 14.5, padding: 13, borderRadius: 11, cursor: "pointer" }}
|
||||
>
|
||||
Envoyer l'invitation
|
||||
</button>
|
||||
</form>
|
||||
<p style={{ fontSize: 11.5, color: "#a99c82", marginTop: 12, lineHeight: 1.5 }}>
|
||||
Un mot de passe temporaire est généré. L'invité pourra le réinitialiser à la première
|
||||
connexion.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { publishPromo } from "../actions";
|
||||
|
||||
const CATEGORIES = [
|
||||
"Boulangerie",
|
||||
"Restauration",
|
||||
"Mode & Beauté",
|
||||
"Artisanat",
|
||||
"Services",
|
||||
"Santé",
|
||||
"Alimentation",
|
||||
];
|
||||
|
||||
const STRIPE_WARM =
|
||||
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
|
||||
|
||||
export function MemberSpaceForm({ memberName }: { memberName: string }) {
|
||||
const router = useRouter();
|
||||
const [title, setTitle] = useState("");
|
||||
const [text, setText] = useState("");
|
||||
const [cat, setCat] = useState("Boulangerie");
|
||||
const [badge, setBadge] = useState("");
|
||||
const [imgData, setImgData] = useState("");
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [pending, setPending] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
function onFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const f = e.target.files?.[0];
|
||||
if (!f) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setImgData(String(reader.result));
|
||||
setSubmitted(false);
|
||||
};
|
||||
reader.readAsDataURL(f);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
setTitle("");
|
||||
setText("");
|
||||
setBadge("");
|
||||
setImgData("");
|
||||
setSubmitted(false);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!title.trim()) return;
|
||||
setPending(true);
|
||||
const fd = new FormData();
|
||||
fd.set("title", title);
|
||||
fd.set("text", text);
|
||||
fd.set("category", cat);
|
||||
fd.set("badge", badge);
|
||||
fd.set("imageUrl", imgData);
|
||||
await publishPromo(fd);
|
||||
setPending(false);
|
||||
setSubmitted(true);
|
||||
reset();
|
||||
setSubmitted(true);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
const hasImg = !!imgData;
|
||||
const hasBadge = !!badge.trim();
|
||||
|
||||
return (
|
||||
<div className="grid grid-2" style={{ alignItems: "start" }}>
|
||||
{/* FORM */}
|
||||
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, padding: 24 }}>
|
||||
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 18, margin: "0 0 4px", color: "#26201a" }}>
|
||||
Publier une promotion
|
||||
</h3>
|
||||
<p style={{ margin: "0 0 20px", fontSize: 13.5, color: "#9a8d72" }}>
|
||||
Ajoutez une image et un texte. Votre promotion sera visible après validation par
|
||||
l'association.
|
||||
</p>
|
||||
|
||||
<label className="field-label">Image de l'offre</label>
|
||||
<label style={{ display: "block", cursor: "pointer", marginBottom: 18 }}>
|
||||
<div
|
||||
style={{
|
||||
border: "2px dashed #d8cdb4",
|
||||
borderRadius: 14,
|
||||
background: hasImg ? `url(${imgData})` : "#faf7ef",
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
height: 170,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
textAlign: "center",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{!hasImg && (
|
||||
<>
|
||||
<span style={{ width: 38, height: 38, borderRadius: 10, background: "#f0e8d6", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<span style={{ width: 15, height: 15, border: "2px solid #b3a888", borderRadius: 4 }} />
|
||||
</span>
|
||||
<div style={{ fontSize: 13.5, fontWeight: 700, color: "#6c6150" }}>
|
||||
Cliquez pour choisir une image
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#a99c82" }}>JPG ou PNG · 1200×800 conseillé</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<input ref={fileRef} type="file" accept="image/*" onChange={onFile} style={{ display: "none" }} />
|
||||
</label>
|
||||
|
||||
<label className="field-label">Titre de la promotion</label>
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => {
|
||||
setTitle(e.target.value);
|
||||
setSubmitted(false);
|
||||
}}
|
||||
placeholder="ex : Le 13e pain offert"
|
||||
className="field"
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
<div style={{ display: "flex", gap: 12, marginBottom: 16 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label className="field-label">Catégorie</label>
|
||||
<select value={cat} onChange={(e) => setCat(e.target.value)} className="field">
|
||||
{CATEGORIES.map((c) => (
|
||||
<option key={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label className="field-label">Réduction (badge)</label>
|
||||
<input value={badge} onChange={(e) => setBadge(e.target.value)} placeholder="ex : -20%" className="field" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="field-label">Texte de la promotion</label>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value.slice(0, 240));
|
||||
setSubmitted(false);
|
||||
}}
|
||||
placeholder="Décrivez votre offre, les conditions, la durée…"
|
||||
rows={4}
|
||||
className="field"
|
||||
style={{ resize: "vertical", marginBottom: 6 }}
|
||||
/>
|
||||
<div style={{ fontSize: 12, color: "#a99c82", marginBottom: 18 }}>{text.length} / 240 caractères</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 10 }}>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={pending}
|
||||
className="font-display"
|
||||
style={{ flex: 1, border: "none", background: "#9a6638", color: "#fff", fontWeight: 700, fontSize: 15, padding: 14, borderRadius: 11, cursor: pending ? "default" : "pointer", opacity: pending ? 0.7 : 1 }}
|
||||
>
|
||||
{pending ? "Envoi…" : "Soumettre pour validation"}
|
||||
</button>
|
||||
<button
|
||||
onClick={reset}
|
||||
type="button"
|
||||
className="font-display"
|
||||
style={{ border: "1px solid #d8cdb4", background: "#fff", color: "#6c6150", fontWeight: 700, fontSize: 15, padding: "14px 18px", borderRadius: 11, cursor: "pointer" }}
|
||||
>
|
||||
Effacer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{submitted && (
|
||||
<div style={{ marginTop: 14, background: "#e6f4ec", border: "1px solid #b6e0c8", color: "#1f8a5b", borderRadius: 10, padding: "12px 14px", fontSize: 13.5, fontWeight: 600 }}>
|
||||
✓ Promotion envoyée ! Elle apparaîtra sur le site une fois validée par l'association.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* LIVE PREVIEW */}
|
||||
<div>
|
||||
<div style={{ fontSize: 12.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 700, marginBottom: 12 }}>
|
||||
Aperçu en direct
|
||||
</div>
|
||||
<article style={{ background: "#fff", border: "1px solid #ece3d0", borderRadius: 18, overflow: "hidden", boxShadow: "0 18px 40px -26px rgba(40,30,15,0.5)" }}>
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
height: 175,
|
||||
background: hasImg ? `url(${imgData})` : STRIPE_WARM,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{!hasImg && (
|
||||
<span style={{ fontSize: 10.5, letterSpacing: "0.12em", color: "#a99c82", textTransform: "uppercase" }}>
|
||||
photo de l'offre
|
||||
</span>
|
||||
)}
|
||||
{hasBadge && (
|
||||
<span className="font-display" style={{ position: "absolute", top: 0, right: 0, background: "#d8472b", color: "#fff", fontWeight: 800, fontSize: 18, padding: "8px 15px", borderRadius: "0 0 0 16px" }}>
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ position: "absolute", top: 12, left: 12, background: "#9a6638", color: "#fff", borderRadius: 999, padding: "5px 12px", fontSize: 11, fontWeight: 700 }}>
|
||||
{cat}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ padding: "17px 18px 18px" }}>
|
||||
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 18, margin: "0 0 5px", color: "#26201a" }}>
|
||||
{title.trim() || "Titre de votre promotion"}
|
||||
</h3>
|
||||
<p style={{ margin: "0 0 14px", fontSize: 13.5, color: "#8c8068", lineHeight: 1.5 }}>
|
||||
{text.trim() ||
|
||||
"Le texte de votre offre apparaîtra ici au fur et à mesure que vous le rédigez."}
|
||||
</p>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid #f0e8d6", paddingTop: 12 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: "#3c3322" }}>{memberName}</div>
|
||||
<span style={{ color: "#2C6FB3", fontWeight: 700, fontSize: 13 }}>Voir l'offre →</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/db";
|
||||
import { categories, members, promotions } from "@/db/schema";
|
||||
import { MemberSpaceForm } from "./MemberSpaceForm";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const PROMO_STATUS: Record<string, { label: string; bg: string; color: string }> = {
|
||||
live: { label: "En ligne", bg: "#e6f4ec", color: "#1f8a5b" },
|
||||
pending: { label: "En attente", bg: "#fbeede", color: "#9a6638" },
|
||||
rejected: { label: "Refusée", bg: "#fbe9e6", color: "#d8472b" },
|
||||
expired: { label: "Expirée", bg: "#f1efe7", color: "#a99c82" },
|
||||
};
|
||||
|
||||
function initialsOf(name: string) {
|
||||
return name.split(/\s+/).map((w) => w[0]).join("").slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
export default async function EspacePage() {
|
||||
const session = await auth();
|
||||
const memberId = session?.user.memberId ?? null;
|
||||
const fallbackName = session?.user.name ?? "Adhérent";
|
||||
|
||||
let memberName = fallbackName;
|
||||
let subtitle = "Espace adhérent";
|
||||
let myPromos: { id: number; title: string; status: string; createdAt: Date; imageUrl: string | null }[] = [];
|
||||
|
||||
if (memberId) {
|
||||
const [m] = await db
|
||||
.select({ name: members.name, city: members.city, categoryLabel: categories.label })
|
||||
.from(members)
|
||||
.leftJoin(categories, eq(members.categoryId, categories.id))
|
||||
.where(eq(members.id, memberId));
|
||||
if (m) {
|
||||
memberName = m.name;
|
||||
subtitle = ["Espace adhérent", m.categoryLabel, m.city].filter(Boolean).join(" · ");
|
||||
}
|
||||
myPromos = await db
|
||||
.select({
|
||||
id: promotions.id,
|
||||
title: promotions.title,
|
||||
status: promotions.status,
|
||||
createdAt: promotions.createdAt,
|
||||
imageUrl: promotions.imageUrl,
|
||||
})
|
||||
.from(promotions)
|
||||
.where(eq(promotions.memberId, memberId))
|
||||
.orderBy(desc(promotions.createdAt));
|
||||
}
|
||||
|
||||
function fmtDate(d: Date) {
|
||||
return new Date(d).toLocaleDateString("fr-FR", { day: "numeric", month: "long" });
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!memberId && (
|
||||
<div style={{ background: "#fbeede", border: "1px solid #ecd8b8", color: "#9a6638", borderRadius: 12, padding: "12px 16px", marginBottom: 18, fontSize: 13.5 }}>
|
||||
Votre compte n'est pas encore relié à une fiche adhérent. Les promotions publiées ne
|
||||
seront pas rattachées à un commerce.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ background: "#13324F", borderRadius: 16, padding: "20px 24px", marginBottom: 22, display: "flex", alignItems: "center", gap: 15 }}>
|
||||
<span className="font-display" style={{ width: 46, height: 46, borderRadius: 12, background: "#E0A63C", color: "#33291D", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 800, fontSize: 16, flexShrink: 0 }}>
|
||||
{initialsOf(memberName)}
|
||||
</span>
|
||||
<div>
|
||||
<div className="font-display" style={{ fontWeight: 700, fontSize: 18, color: "#fff" }}>
|
||||
{memberName}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "#9bb6cd" }}>{subtitle}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MemberSpaceForm memberName={memberName} />
|
||||
|
||||
<div style={{ marginTop: 28 }}>
|
||||
<div style={{ fontSize: 12.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 700, marginBottom: 12 }}>
|
||||
Mes promotions
|
||||
</div>
|
||||
{myPromos.length === 0 && (
|
||||
<div style={{ fontSize: 13.5, color: "#a99c82" }}>Vous n'avez pas encore publié de promotion.</div>
|
||||
)}
|
||||
{myPromos.map((mp) => {
|
||||
const st = PROMO_STATUS[mp.status] ?? PROMO_STATUS.expired;
|
||||
return (
|
||||
<div key={mp.id} style={{ display: "flex", alignItems: "center", gap: 13, background: "#fff", border: "1px solid #e6dcc6", borderRadius: 12, padding: "12px 14px", marginBottom: 10 }}>
|
||||
<span
|
||||
style={{
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: 9,
|
||||
background: mp.imageUrl
|
||||
? `url(${mp.imageUrl})`
|
||||
: "repeating-linear-gradient(45deg,#efe9da,#efe9da 7px,#e6ddc9 7px,#e6ddc9 14px)",
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: "#26201a", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{mp.title}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#a99c82" }}>Publié le {fmtDate(mp.createdAt)}</div>
|
||||
</div>
|
||||
<span style={{ display: "inline-block", fontSize: 11.5, fontWeight: 700, padding: "4px 11px", borderRadius: 999, background: st.bg, color: st.color }}>
|
||||
{st.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/db";
|
||||
import { promotions } from "@/db/schema";
|
||||
import { BackendShell } from "./BackendShell";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function BackendLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const pendingRows = await db
|
||||
.select({ id: promotions.id })
|
||||
.from(promotions)
|
||||
.where(eq(promotions.status, "pending"));
|
||||
|
||||
return (
|
||||
<BackendShell
|
||||
user={{ name: session.user.name ?? "Adhérent", role: session.user.role }}
|
||||
pendingCount={pendingRows.length}
|
||||
>
|
||||
{children}
|
||||
</BackendShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/db";
|
||||
import { activityLog, members, membershipRequests, promotions } from "@/db/schema";
|
||||
import { isStaff } from "@/lib/rbac";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
valueColor = "#13324F",
|
||||
hintColor = "#9a8d72",
|
||||
}: {
|
||||
label: string;
|
||||
value: number | string;
|
||||
hint: string;
|
||||
valueColor?: string;
|
||||
hintColor?: string;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, padding: 20 }}>
|
||||
<div style={{ fontSize: 13, color: "#9a8d72", marginBottom: 8 }}>{label}</div>
|
||||
<div className="font-display" style={{ fontWeight: 800, fontSize: 32, color: valueColor }}>
|
||||
{value}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: hintColor, marginTop: 6, fontWeight: 600 }}>{hint}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await auth();
|
||||
if (!isStaff(session?.user.role)) {
|
||||
redirect("/backend/espace");
|
||||
}
|
||||
|
||||
const [activeMembers, livePromos, pendingPromos, requests, activity] = await Promise.all([
|
||||
db.select({ id: members.id }).from(members).where(eq(members.status, "active")),
|
||||
db.select({ id: promotions.id }).from(promotions).where(eq(promotions.status, "live")),
|
||||
db.select({ id: promotions.id }).from(promotions).where(eq(promotions.status, "pending")),
|
||||
db.select({ id: membershipRequests.id }).from(membershipRequests).where(eq(membershipRequests.status, "new")),
|
||||
db.select().from(activityLog).orderBy(desc(activityLog.createdAt)).limit(6),
|
||||
]);
|
||||
|
||||
const pendingCount = pendingPromos.length;
|
||||
|
||||
function timeAgo(date: Date) {
|
||||
const diff = Date.now() - new Date(date).getTime();
|
||||
const h = Math.floor(diff / 3_600_000);
|
||||
if (h < 1) return "à l'instant";
|
||||
if (h < 24) return `il y a ${h} h`;
|
||||
const d = Math.floor(h / 24);
|
||||
return d === 1 ? "hier" : `il y a ${d} j`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="grid grid-4" style={{ marginBottom: 26 }}>
|
||||
<StatCard label="Adhérents actifs" value={activeMembers.length} hint="sur le réseau" valueColor="#13324F" hintColor="#1f8a5b" />
|
||||
<StatCard label="Promotions en ligne" value={livePromos.length} hint="visibles sur le site" />
|
||||
<StatCard label="À modérer" value={pendingCount} hint="en attente de validation" valueColor="#9a6638" hintColor="#9a6638" />
|
||||
<StatCard label="Demandes d'adhésion" value={requests.length} hint="à traiter" valueColor="#13324F" hintColor="#9a6638" />
|
||||
</div>
|
||||
|
||||
<div className="grid dash-split">
|
||||
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, padding: 22 }}>
|
||||
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 17, margin: "0 0 16px", color: "#26201a" }}>
|
||||
Activité récente
|
||||
</h3>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
|
||||
{activity.length === 0 && (
|
||||
<div style={{ fontSize: 13.5, color: "#a99c82" }}>Aucune activité récente.</div>
|
||||
)}
|
||||
{activity.map((a) => (
|
||||
<div key={a.id} style={{ display: "flex", alignItems: "center", gap: 13 }}>
|
||||
<span style={{ width: 9, height: 9, borderRadius: "50%", background: a.dot, flexShrink: 0 }} />
|
||||
<div
|
||||
style={{ flex: 1, fontSize: 13.5, color: "#3c3322" }}
|
||||
dangerouslySetInnerHTML={{ __html: a.message }}
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: "#a99c82", whiteSpace: "nowrap" }}>
|
||||
{timeAgo(a.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ background: "#13324F", borderRadius: 16, padding: 22, color: "#cfe0ee" }}>
|
||||
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 17, margin: "0 0 6px", color: "#fff" }}>
|
||||
Promotions à modérer
|
||||
</h3>
|
||||
<p style={{ margin: "0 0 16px", fontSize: 13, color: "#9bb6cd" }}>
|
||||
{pendingCount} en attente de validation.
|
||||
</p>
|
||||
<Link
|
||||
href="/backend/promotions"
|
||||
className="font-display"
|
||||
style={{
|
||||
display: "block",
|
||||
textAlign: "center",
|
||||
width: "100%",
|
||||
border: "none",
|
||||
background: "#E0A63C",
|
||||
color: "#33291D",
|
||||
fontWeight: 700,
|
||||
fontSize: 14.5,
|
||||
padding: 13,
|
||||
borderRadius: 11,
|
||||
cursor: "pointer",
|
||||
textDecoration: "none",
|
||||
}}
|
||||
>
|
||||
Ouvrir la modération
|
||||
</Link>
|
||||
<Link
|
||||
href="/backend/espace"
|
||||
className="font-display"
|
||||
style={{
|
||||
display: "block",
|
||||
textAlign: "center",
|
||||
width: "100%",
|
||||
marginTop: 10,
|
||||
border: "1px solid rgba(255,255,255,0.25)",
|
||||
background: "transparent",
|
||||
color: "#fff",
|
||||
fontWeight: 700,
|
||||
fontSize: 14.5,
|
||||
padding: 13,
|
||||
borderRadius: 11,
|
||||
cursor: "pointer",
|
||||
textDecoration: "none",
|
||||
}}
|
||||
>
|
||||
Espace adhérent
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { desc, eq, inArray } from "drizzle-orm";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/db";
|
||||
import { members, promotions } from "@/db/schema";
|
||||
import { can } from "@/lib/rbac";
|
||||
import { moderatePromo } from "../actions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const STRIPE_WARM =
|
||||
"repeating-linear-gradient(45deg,#efe9da,#efe9da 11px,#e6ddc9 11px,#e6ddc9 22px)";
|
||||
const STRIPE_COOL =
|
||||
"repeating-linear-gradient(45deg,#eef0ec,#eef0ec 11px,#e2e8e6 11px,#e2e8e6 22px)";
|
||||
|
||||
export default async function PromotionsPage() {
|
||||
const session = await auth();
|
||||
if (!can(session?.user.role, "moderatePromos")) {
|
||||
redirect("/backend");
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: promotions.id,
|
||||
title: promotions.title,
|
||||
text: promotions.text,
|
||||
category: promotions.category,
|
||||
status: promotions.status,
|
||||
imageUrl: promotions.imageUrl,
|
||||
memberName: members.name,
|
||||
})
|
||||
.from(promotions)
|
||||
.leftJoin(members, eq(promotions.memberId, members.id))
|
||||
.where(inArray(promotions.status, ["pending", "live"]))
|
||||
.orderBy(desc(promotions.status), desc(promotions.createdAt));
|
||||
|
||||
const pending = rows.filter((r) => r.status === "pending");
|
||||
const live = rows.filter((r) => r.status === "live");
|
||||
// pending first, then live
|
||||
const ordered = [...pending, ...live];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: "flex", gap: 10, marginBottom: 18, flexWrap: "wrap" }}>
|
||||
<span style={{ background: "#13324F", color: "#fff", fontSize: 13, fontWeight: 600, padding: "8px 16px", borderRadius: 999 }}>
|
||||
En attente · {pending.length}
|
||||
</span>
|
||||
<span style={{ background: "#fff", border: "1px solid #e6dcc6", color: "#6c6150", fontSize: 13, fontWeight: 600, padding: "8px 16px", borderRadius: 999 }}>
|
||||
En ligne · {live.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{ordered.length === 0 && (
|
||||
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, padding: 28, color: "#a99c82", fontSize: 14 }}>
|
||||
Aucune promotion pour le moment.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-3" style={{ gap: 18 }}>
|
||||
{ordered.map((p) => {
|
||||
const isPending = p.status === "pending";
|
||||
return (
|
||||
<article
|
||||
key={p.id}
|
||||
style={{
|
||||
background: "#fff",
|
||||
border: "1px solid #e6dcc6",
|
||||
borderRadius: 16,
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
height: 150,
|
||||
background: p.imageUrl
|
||||
? `url(${p.imageUrl})`
|
||||
: isPending
|
||||
? STRIPE_COOL
|
||||
: STRIPE_WARM,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{p.category && (
|
||||
<span style={{ position: "absolute", top: 10, left: 10, background: "#9a6638", color: "#fff", borderRadius: 999, padding: "4px 11px", fontSize: 11, fontWeight: 700 }}>
|
||||
{p.category}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 10,
|
||||
right: 10,
|
||||
borderRadius: 999,
|
||||
padding: "4px 11px",
|
||||
fontSize: 11,
|
||||
fontWeight: 800,
|
||||
background: isPending ? "#fbeede" : "#e6f4ec",
|
||||
color: isPending ? "#9a6638" : "#1f8a5b",
|
||||
}}
|
||||
>
|
||||
{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" }}>
|
||||
{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.text}
|
||||
</p>
|
||||
|
||||
{isPending ? (
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<form action={moderatePromo} style={{ flex: 1 }}>
|
||||
<input type="hidden" name="id" value={p.id} />
|
||||
<input type="hidden" name="action" value="approve" />
|
||||
<button type="submit" style={{ width: "100%", border: "none", background: "#1f8a5b", color: "#fff", fontWeight: 700, fontSize: 13, padding: 10, borderRadius: 9, cursor: "pointer" }}>
|
||||
Valider
|
||||
</button>
|
||||
</form>
|
||||
<form action={moderatePromo} style={{ flex: 1 }}>
|
||||
<input type="hidden" name="id" value={p.id} />
|
||||
<input type="hidden" name="action" value="reject" />
|
||||
<button type="submit" style={{ width: "100%", border: "1px solid #e0c3bb", background: "#fff", color: "#d8472b", fontWeight: 700, fontSize: 13, padding: 10, borderRadius: 9, cursor: "pointer" }}>
|
||||
Refuser
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid #f0e8d6", paddingTop: 11 }}>
|
||||
<span style={{ fontSize: 12.5, color: "#1f8a5b", fontWeight: 700 }}>● En ligne</span>
|
||||
<form action={moderatePromo}>
|
||||
<input type="hidden" name="id" value={p.id} />
|
||||
<input type="hidden" name="action" value="remove" />
|
||||
<button type="submit" style={{ border: "none", background: "transparent", color: "#a99c82", fontWeight: 700, fontSize: 12.5, cursor: "pointer" }}>
|
||||
Retirer
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user