mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-11 17:27:54 +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,11 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
.dockerignore
|
||||||
|
.env
|
||||||
|
.env*.local
|
||||||
|
*.md
|
||||||
|
npm-debug.log*
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# ---- Database (external Postgres) ----
|
||||||
|
# When running the app via docker-compose, host is the compose service name "postgres".
|
||||||
|
# For local dev outside Docker, use localhost.
|
||||||
|
DATABASE_URL=postgres://pleinr:pleinr@postgres:5432/pleinr
|
||||||
|
|
||||||
|
# Individual vars consumed by the postgres container in docker-compose.yml
|
||||||
|
POSTGRES_USER=pleinr
|
||||||
|
POSTGRES_PASSWORD=pleinr
|
||||||
|
POSTGRES_DB=pleinr
|
||||||
|
|
||||||
|
# ---- Auth.js (NextAuth v5) ----
|
||||||
|
# Generate with: openssl rand -base64 32
|
||||||
|
AUTH_SECRET=change-me-to-a-long-random-string
|
||||||
|
# Public URL of the app (used by Auth.js for callbacks)
|
||||||
|
AUTH_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# ---- First admin user (created by the seed script) ----
|
||||||
|
SEED_ADMIN_EMAIL=admin@plein-r.fr
|
||||||
|
SEED_ADMIN_PASSWORD=changeme123
|
||||||
|
SEED_ADMIN_NAME=Administrateur Plein R
|
||||||
|
|
||||||
|
# ---- App ----
|
||||||
|
NODE_ENV=production
|
||||||
|
PORT=3000
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# bundled db scripts (built in Docker)
|
||||||
|
/dist
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
Guidance for working in this repository.
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
**Plein R** — website for an association of merchants/businesses in the Bassin de
|
||||||
|
Pompey. A single Next.js application serving a public **Accueil** page and an
|
||||||
|
authenticated, role-based **Backend** (admin + member space). Postgres is an
|
||||||
|
external container.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- Next.js 15 (App Router, TypeScript, `output: "standalone"`)
|
||||||
|
- PostgreSQL 16 + Drizzle ORM (`src/db/schema.ts`) with `drizzle-kit` migrations
|
||||||
|
- Auth.js v5 (credentials provider, JWT sessions) — RBAC in `src/lib/rbac.ts`
|
||||||
|
- One app container + one Postgres container (`docker-compose.yml`)
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # dev server
|
||||||
|
npm run build # production build
|
||||||
|
npm run db:generate # regenerate SQL after editing src/db/schema.ts
|
||||||
|
npm run db:migrate # apply migrations
|
||||||
|
npm run db:seed # seed demo data
|
||||||
|
docker compose up --build # full stack
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **Styling**: faithful port of the original design as inline styles + a small
|
||||||
|
design-system in `src/app/globals.css` (palette as CSS vars, fonts, twinkle/float
|
||||||
|
animations, hover lifts, responsive grid helpers). No Tailwind.
|
||||||
|
- **Mutations**: server actions in `src/app/backend/actions.ts`. Each action
|
||||||
|
re-checks auth + capability via `auth()` and `can()` before writing, then
|
||||||
|
`revalidatePath()`.
|
||||||
|
- **Access control**: `src/middleware.ts` gates `/backend/*`; each page further
|
||||||
|
guards by role (`isStaff`, `can`) and redirects.
|
||||||
|
- **Data reads** for the public site live in `src/lib/queries.ts`.
|
||||||
|
- After changing `src/db/schema.ts`, run `npm run db:generate` and commit the new
|
||||||
|
file under `drizzle/`.
|
||||||
|
|
||||||
|
## Roles
|
||||||
|
|
||||||
|
`admin` > `moderator` > `editor` are staff; `member` is an adhérent linked to a
|
||||||
|
`members` row via `users.memberId`. Capability matrix is in `src/lib/rbac.ts`.
|
||||||
|
|
||||||
|
## Docker notes
|
||||||
|
|
||||||
|
- Migrations + optional seed run on container start via `docker-entrypoint.sh`.
|
||||||
|
- `npm run build:scripts` bundles `migrate`/`seed` into `dist/*.cjs` so the runtime
|
||||||
|
image needs no dev dependencies.
|
||||||
|
- The standalone server binds `HOSTNAME=0.0.0.0`, `PORT=3000`.
|
||||||
|
|
||||||
|
## Logo
|
||||||
|
|
||||||
|
`public/assets/logo.svg` is a brand-colour recreation; swap in the official asset
|
||||||
|
when available (referenced as `/assets/logo.svg`).
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# ---- deps: install all dependencies ----
|
||||||
|
FROM node:20-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# ---- builder: build Next.js (standalone) + bundle db scripts ----
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
RUN npm run build
|
||||||
|
RUN npm run build:scripts
|
||||||
|
|
||||||
|
# ---- runner: minimal production image ----
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production \
|
||||||
|
NEXT_TELEMETRY_DISABLED=1 \
|
||||||
|
PORT=3000 \
|
||||||
|
HOSTNAME=0.0.0.0
|
||||||
|
|
||||||
|
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
|
||||||
|
|
||||||
|
# Next.js standalone output
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
# Bundled migration + seed scripts and the SQL migrations
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
COPY --from=builder /app/drizzle ./drizzle
|
||||||
|
|
||||||
|
COPY docker-entrypoint.sh /app/docker-entrypoint.sh
|
||||||
|
RUN chmod +x /app/docker-entrypoint.sh && chown -R nextjs:nodejs /app
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# Plein R
|
||||||
|
|
||||||
|
Site de l'association **Plein R** — les commerçants et entreprises du Bassin de Pompey.
|
||||||
|
*Réseau · Rencontre · Réussite.*
|
||||||
|
|
||||||
|
Deux surfaces, **une seule application** :
|
||||||
|
|
||||||
|
- **Accueil** (`/`) — vitrine publique : héro + recherche, métiers, promotions des adhérents, mises à l'honneur.
|
||||||
|
- **Backend** (`/backend`) — back-office authentifié avec rôles : tableau de bord, adhérents, modération des promotions, administrateurs, et l'espace adhérent.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
| Couche | Choix |
|
||||||
|
|---|---|
|
||||||
|
| Framework | **Next.js 15** (App Router, TypeScript, sortie `standalone`) |
|
||||||
|
| Base de données | **PostgreSQL 16** (conteneur externe) |
|
||||||
|
| Accès données | **Drizzle ORM** + `drizzle-kit` (migrations versionnées) sur le driver `pg` |
|
||||||
|
| Authentification | **Auth.js v5** (NextAuth) — provider *credentials*, sessions JWT, RBAC |
|
||||||
|
| Conteneurisation | **1 conteneur applicatif** + **1 conteneur Postgres** (docker-compose) |
|
||||||
|
|
||||||
|
L'application tourne dans **un seul conteneur Docker**. Postgres est un **conteneur séparé** (externe).
|
||||||
|
|
||||||
|
## Démarrage rapide (Docker)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# éditez .env : au minimum, définissez AUTH_SECRET
|
||||||
|
# openssl rand -base64 32
|
||||||
|
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Au démarrage, le conteneur applique les migrations puis (si `SEED_ON_START=true`)
|
||||||
|
charge des données de démonstration. Ensuite :
|
||||||
|
|
||||||
|
- Site public : http://localhost:3000
|
||||||
|
- Espace adhérent / admin : http://localhost:3000/backend
|
||||||
|
- Connexion admin par défaut : `admin@plein-r.fr` / `changeme123`
|
||||||
|
|
||||||
|
> **Postgres déjà existant ?** Supprimez le service `postgres` de `docker-compose.yml`
|
||||||
|
> et pointez `DATABASE_URL` vers votre instance.
|
||||||
|
|
||||||
|
## Comptes de démonstration (seed)
|
||||||
|
|
||||||
|
| E-mail | Mot de passe | Rôle |
|
||||||
|
|---|---|---|
|
||||||
|
| admin@plein-r.fr | changeme123 | Administrateur |
|
||||||
|
| claire@plein-r.fr | changeme123 | Administrateur |
|
||||||
|
| thomas@plein-r.fr | changeme123 | Modérateur |
|
||||||
|
| sophie@plein-r.fr | changeme123 | Éditeur |
|
||||||
|
| contact@aubonpain.fr | changeme123 | Adhérent (Au Bon Pain) |
|
||||||
|
|
||||||
|
## Rôles & permissions
|
||||||
|
|
||||||
|
| Capacité | admin | moderator | editor | member |
|
||||||
|
|---|:--:|:--:|:--:|:--:|
|
||||||
|
| Tableau de bord | ✅ | ✅ | ✅ | — |
|
||||||
|
| Adhérents (CRUD) | ✅ | ✅ | ✅ | — |
|
||||||
|
| Modération des promotions | ✅ | ✅ | — | — |
|
||||||
|
| Administrateurs | ✅ | — | — | — |
|
||||||
|
| Mon espace (publier une promo) | — | — | — | ✅ |
|
||||||
|
|
||||||
|
`/backend` est protégé par le middleware ; chaque vue affine l'accès selon le rôle.
|
||||||
|
|
||||||
|
## Développement local (sans Docker)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
# Postgres accessible via DATABASE_URL (voir .env.example)
|
||||||
|
npm run db:generate # (re)génère le SQL de migration depuis le schéma
|
||||||
|
npm run db:migrate # applique les migrations
|
||||||
|
npm run db:seed # données de démonstration
|
||||||
|
npm run dev # http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
| Script | Rôle |
|
||||||
|
|---|---|
|
||||||
|
| `npm run dev` | Serveur de développement |
|
||||||
|
| `npm run build` | Build de production (standalone) |
|
||||||
|
| `npm run build:scripts` | Bundle des scripts migrate/seed (utilisé par Docker) |
|
||||||
|
| `npm run db:generate` | Génère les migrations Drizzle |
|
||||||
|
| `npm run db:migrate` | Applique les migrations |
|
||||||
|
| `npm run db:seed` | Insère les données de démonstration |
|
||||||
|
|
||||||
|
## Variables d'environnement
|
||||||
|
|
||||||
|
Voir [`.env.example`](./.env.example). Les principales :
|
||||||
|
|
||||||
|
- `DATABASE_URL` — chaîne de connexion Postgres
|
||||||
|
- `AUTH_SECRET` — secret de signature des sessions (**obligatoire**)
|
||||||
|
- `AUTH_URL` — URL publique de l'application
|
||||||
|
- `SEED_ON_START` — `true` pour seeder au démarrage du conteneur
|
||||||
|
- `SEED_ADMIN_EMAIL` / `SEED_ADMIN_PASSWORD` / `SEED_ADMIN_NAME` — premier admin
|
||||||
|
|
||||||
|
## Note sur le logo
|
||||||
|
|
||||||
|
Le logo (`public/assets/logo.svg`) est une recréation vectorielle aux couleurs de
|
||||||
|
la marque. Remplacez ce fichier par le logo officiel quand vous le souhaitez
|
||||||
|
(les pages le référencent via `/assets/logo.svg`).
|
||||||
|
|
||||||
|
## Architecture du dépôt
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app/
|
||||||
|
│ ├── page.tsx # Accueil (public)
|
||||||
|
│ ├── login/ # connexion
|
||||||
|
│ ├── api/auth/[...nextauth]/ # routes Auth.js
|
||||||
|
│ └── backend/ # back-office
|
||||||
|
│ ├── layout.tsx # garde d'auth + shell
|
||||||
|
│ ├── page.tsx # tableau de bord
|
||||||
|
│ ├── adherents/ # adhérents (liste, ajout, édition)
|
||||||
|
│ ├── promotions/ # modération
|
||||||
|
│ ├── administrateurs/ # gestion des accès
|
||||||
|
│ ├── espace/ # espace adhérent (publication)
|
||||||
|
│ └── actions.ts # server actions (mutations)
|
||||||
|
├── db/ # schéma Drizzle, client, migrate, seed
|
||||||
|
├── lib/ # requêtes, RBAC
|
||||||
|
└── types/ # augmentation des types Auth.js
|
||||||
|
```
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Plein R — single application container + external Postgres container.
|
||||||
|
#
|
||||||
|
# The app (Next.js) runs in ONE container. Postgres runs as a SEPARATE
|
||||||
|
# (external) container. For a managed/already-existing Postgres, delete the
|
||||||
|
# `postgres` service below and point DATABASE_URL at your instance.
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: pleinr-postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-pleinr}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-pleinr}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-pleinr}
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-pleinr} -d ${POSTGRES_DB:-pleinr}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
container_name: pleinr-app
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgres://${POSTGRES_USER:-pleinr}:${POSTGRES_PASSWORD:-pleinr}@postgres:5432/${POSTGRES_DB:-pleinr}
|
||||||
|
AUTH_SECRET: ${AUTH_SECRET:?set AUTH_SECRET in your .env}
|
||||||
|
AUTH_URL: ${AUTH_URL:-http://localhost:3000}
|
||||||
|
SEED_ON_START: ${SEED_ON_START:-true}
|
||||||
|
SEED_ADMIN_EMAIL: ${SEED_ADMIN_EMAIL:-admin@plein-r.fr}
|
||||||
|
SEED_ADMIN_PASSWORD: ${SEED_ADMIN_PASSWORD:-changeme123}
|
||||||
|
SEED_ADMIN_NAME: ${SEED_ADMIN_NAME:-Administrateur Plein R}
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "→ Applying database migrations…"
|
||||||
|
node dist/migrate.cjs
|
||||||
|
|
||||||
|
if [ "$SEED_ON_START" = "true" ]; then
|
||||||
|
echo "→ Seeding database (SEED_ON_START=true)…"
|
||||||
|
node dist/seed.cjs || echo " seed step reported an issue, continuing"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "→ Starting Plein R on port ${PORT:-3000}…"
|
||||||
|
exec node server.js
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { defineConfig } from "drizzle-kit";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "./src/db/schema.ts",
|
||||||
|
out: "./drizzle",
|
||||||
|
dialect: "postgresql",
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.DATABASE_URL!,
|
||||||
|
},
|
||||||
|
verbose: true,
|
||||||
|
strict: true,
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
CREATE TYPE "public"."member_status" AS ENUM('active', 'pending');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."promo_status" AS ENUM('pending', 'live', 'expired', 'rejected');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."request_status" AS ENUM('new', 'approved', 'rejected');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."role" AS ENUM('admin', 'moderator', 'editor', 'member');--> statement-breakpoint
|
||||||
|
CREATE TABLE "activity_log" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"dot" varchar(16) DEFAULT '#2C6FB3' NOT NULL,
|
||||||
|
"message" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "categories" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"slug" varchar(80) NOT NULL,
|
||||||
|
"label" varchar(120) NOT NULL,
|
||||||
|
"accent" varchar(16) DEFAULT '#E0A63C' NOT NULL,
|
||||||
|
"tint" varchar(16) DEFAULT '#f6efdc' NOT NULL,
|
||||||
|
"sort" integer DEFAULT 0 NOT NULL,
|
||||||
|
CONSTRAINT "categories_slug_unique" UNIQUE("slug")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "members" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"name" varchar(200) NOT NULL,
|
||||||
|
"email" varchar(200) NOT NULL,
|
||||||
|
"category_id" integer,
|
||||||
|
"city" varchar(120),
|
||||||
|
"address" varchar(240),
|
||||||
|
"description" text,
|
||||||
|
"status" "member_status" DEFAULT 'pending' NOT NULL,
|
||||||
|
"highlighted" boolean DEFAULT false NOT NULL,
|
||||||
|
"logo_url" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "membership_requests" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"name" varchar(200) NOT NULL,
|
||||||
|
"email" varchar(200),
|
||||||
|
"message" text,
|
||||||
|
"status" "request_status" DEFAULT 'new' NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "promotions" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"title" varchar(200) NOT NULL,
|
||||||
|
"text" text,
|
||||||
|
"category" varchar(120),
|
||||||
|
"badge" varchar(40),
|
||||||
|
"image_url" text,
|
||||||
|
"member_id" integer,
|
||||||
|
"status" "promo_status" DEFAULT 'pending' NOT NULL,
|
||||||
|
"valid_until" varchar(120),
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "users" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"email" varchar(200) NOT NULL,
|
||||||
|
"name" varchar(200) NOT NULL,
|
||||||
|
"password_hash" varchar(255) NOT NULL,
|
||||||
|
"role" "role" DEFAULT 'member' NOT NULL,
|
||||||
|
"member_id" integer,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "members" ADD CONSTRAINT "members_category_id_categories_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."categories"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "promotions" ADD CONSTRAINT "promotions_member_id_members_id_fk" FOREIGN KEY ("member_id") REFERENCES "public"."members"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "users" ADD CONSTRAINT "users_member_id_members_id_fk" FOREIGN KEY ("member_id") REFERENCES "public"."members"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "users_email_idx" ON "users" USING btree ("email");
|
||||||
@@ -0,0 +1,483 @@
|
|||||||
|
{
|
||||||
|
"id": "bf4dd99e-65a5-4e31-9d25-5fce8e585460",
|
||||||
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"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.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
|
||||||
|
},
|
||||||
|
"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.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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1782475322634,
|
||||||
|
"tag": "0000_numerous_roughhouse",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
output: "standalone",
|
||||||
|
reactStrictMode: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+7745
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "plein-r",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint",
|
||||||
|
"db:generate": "drizzle-kit generate",
|
||||||
|
"db:migrate": "tsx src/db/migrate.ts",
|
||||||
|
"db:seed": "tsx src/db/seed.ts",
|
||||||
|
"build:scripts": "esbuild src/db/migrate.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/migrate.cjs --external:pg-native && esbuild src/db/seed.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/seed.cjs --external:pg-native"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"drizzle-orm": "^0.38.3",
|
||||||
|
"eslint-config-next": "^15.5.19",
|
||||||
|
"next": "^15.5.19",
|
||||||
|
"next-auth": "5.0.0-beta.25",
|
||||||
|
"pg": "^8.13.1",
|
||||||
|
"react": "19.0.0",
|
||||||
|
"react-dom": "19.0.0",
|
||||||
|
"zod": "^3.24.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/node": "^22.10.5",
|
||||||
|
"@types/pg": "^8.11.10",
|
||||||
|
"@types/react": "^19.0.7",
|
||||||
|
"@types/react-dom": "^19.0.3",
|
||||||
|
"drizzle-kit": "^0.30.1",
|
||||||
|
"esbuild": "^0.24.2",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.7.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 480" fill="none" role="img" aria-label="Plein R — Association des commerçants et entreprises du Bassin de Pompey">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="plr-tower" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#a9743f"/>
|
||||||
|
<stop offset="1" stop-color="#8a5a30"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- sparkles -->
|
||||||
|
<g>
|
||||||
|
<path d="M96 150 l9 24 24 9 -24 9 -9 24 -9 -24 -24 -9 24 -9 z" fill="#9a6638"/>
|
||||||
|
<path d="M70 250 l7 18 18 7 -18 7 -7 18 -7 -18 -18 -7 18 -7 z" fill="#6FB0C6"/>
|
||||||
|
<path d="M120 95 l6 16 16 6 -16 6 -6 16 -6 -16 -16 -6 16 -6 z" fill="#E0A63C"/>
|
||||||
|
<path d="M300 110 l8 21 21 8 -21 8 -8 21 -8 -21 -21 -8 21 -8 z" fill="#9a6638"/>
|
||||||
|
<path d="M330 215 l7 18 18 7 -18 7 -7 18 -7 -18 -18 -7 18 -7 z" fill="#6FB0C6"/>
|
||||||
|
<path d="M280 250 l6 15 15 6 -15 6 -6 15 -6 -15 -15 -6 15 -6 z" fill="#2C6FB3"/>
|
||||||
|
<circle cx="160" cy="120" r="6" fill="#6FB0C6"/>
|
||||||
|
<circle cx="250" cy="150" r="5" fill="#E0A63C"/>
|
||||||
|
<circle cx="78" cy="190" r="5" fill="#E0A63C"/>
|
||||||
|
<circle cx="318" cy="160" r="5" fill="#9a6638"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Eiffel tower -->
|
||||||
|
<g stroke="#4a3a24" stroke-width="4" stroke-linejoin="round">
|
||||||
|
<!-- antenna -->
|
||||||
|
<rect x="196" y="60" width="8" height="34" rx="3" fill="url(#plr-tower)"/>
|
||||||
|
<!-- top -->
|
||||||
|
<path d="M188 94 h24 l-3 40 h-18 z" fill="url(#plr-tower)"/>
|
||||||
|
<!-- upper section -->
|
||||||
|
<path d="M183 134 h34 l-7 70 h-20 z" fill="url(#plr-tower)"/>
|
||||||
|
<!-- mid platform -->
|
||||||
|
<rect x="171" y="204" width="58" height="14" rx="3" fill="#6FB0C6"/>
|
||||||
|
<!-- middle section -->
|
||||||
|
<path d="M174 218 h52 l-12 86 h-28 z" fill="url(#plr-tower)"/>
|
||||||
|
<!-- lower platform -->
|
||||||
|
<rect x="156" y="304" width="88" height="16" rx="3" fill="#E0A63C"/>
|
||||||
|
<!-- legs with the iconic arch -->
|
||||||
|
<path d="M160 320 h80 l26 86 h-30 c-6 -34 -22 -44 -36 -44 -14 0 -30 10 -36 44 h-30 z" fill="url(#plr-tower)"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- ribbon banner -->
|
||||||
|
<path d="M40 360 q160 96 320 0 l-12 40 q-148 84 -296 0 z" fill="#9a6638"/>
|
||||||
|
<text x="200" y="402" text-anchor="middle" font-family="'Public Sans',Arial,sans-serif" font-size="20" font-weight="800" letter-spacing="1.5" fill="#f6f2e8">RÉSEAU · RENCONTRE · RÉUSSITE</text>
|
||||||
|
|
||||||
|
<!-- wordmark -->
|
||||||
|
<text x="200" y="352" text-anchor="middle" font-family="'Bricolage Grotesque','Georgia',serif" font-style="italic" font-size="78" font-weight="800" fill="#1E588F" letter-spacing="-1">Plein R</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
import { handlers } from "@/auth";
|
||||||
|
|
||||||
|
export const { GET, POST } = handlers;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
:root {
|
||||||
|
--cream: #f6f2e8;
|
||||||
|
--cream-2: #efe9da;
|
||||||
|
--cream-3: #ede7d7;
|
||||||
|
--ink: #33291d;
|
||||||
|
--ink-strong: #26201a;
|
||||||
|
--ink-soft: #3c3322;
|
||||||
|
--muted: #6c6150;
|
||||||
|
--muted-2: #8c8068;
|
||||||
|
--muted-3: #9a8d72;
|
||||||
|
--muted-4: #a99c82;
|
||||||
|
--brown: #9a6638;
|
||||||
|
--blue: #2c6fb3;
|
||||||
|
--blue-deep: #1e588f;
|
||||||
|
--navy: #13324f;
|
||||||
|
--gold: #e0a63c;
|
||||||
|
--teal: #6fb0c6;
|
||||||
|
--green: #1f8a5b;
|
||||||
|
--red: #d8472b;
|
||||||
|
--border: #e6dcc6;
|
||||||
|
--border-2: #f0e8d6;
|
||||||
|
--field-border: #ddd2bb;
|
||||||
|
--field-bg: #faf7ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--cream);
|
||||||
|
font-family: "Public Sans", system-ui, sans-serif;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-display {
|
||||||
|
font-family: "Bricolage Grotesque", "Public Sans", system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
input::placeholder,
|
||||||
|
textarea::placeholder {
|
||||||
|
color: var(--muted-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Animations ---- */
|
||||||
|
@keyframes plr-twinkle {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: scale(0.85) rotate(0deg);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.3) rotate(25deg);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes plr-floatlogo {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0) rotate(-0.6deg);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-14px) rotate(0.6deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
* {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sparkle {
|
||||||
|
clip-path: polygon(50% 0, 60% 40%, 100% 50%, 60% 60%, 50% 100%, 40% 60%, 0 50%, 40% 40%);
|
||||||
|
}
|
||||||
|
.twinkle {
|
||||||
|
animation: plr-twinkle 3.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.floatlogo {
|
||||||
|
animation: plr-floatlogo 6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Hover lifts ---- */
|
||||||
|
.lift {
|
||||||
|
transition: transform 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease;
|
||||||
|
}
|
||||||
|
.lift:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 16px 30px -18px rgba(40, 30, 15, 0.45);
|
||||||
|
border-color: #d8cdb4;
|
||||||
|
}
|
||||||
|
.lift-card {
|
||||||
|
transition: transform 0.28s ease, box-shadow 0.28s ease;
|
||||||
|
}
|
||||||
|
.lift-card:hover {
|
||||||
|
transform: translateY(-6px);
|
||||||
|
box-shadow: 0 24px 44px -24px rgba(40, 30, 15, 0.5);
|
||||||
|
}
|
||||||
|
.lift-cta {
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
.lift-cta:hover {
|
||||||
|
transform: translateY(-3px) scale(1.03);
|
||||||
|
box-shadow: 0 16px 30px -14px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
.link-arrow:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Layout helpers ---- */
|
||||||
|
.container {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
.grid-6 {
|
||||||
|
grid-template-columns: repeat(6, 1fr);
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.grid-4 {
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
.grid-3 {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
.grid-2 {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 22px;
|
||||||
|
}
|
||||||
|
.hero-grid {
|
||||||
|
grid-template-columns: 0.92fr 1.08fr;
|
||||||
|
gap: 48px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.dash-split {
|
||||||
|
grid-template-columns: 1.4fr 1fr;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
.admins-split {
|
||||||
|
grid-template-columns: 1.5fr 1fr;
|
||||||
|
gap: 18px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.grid-6 {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
.grid-4 {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
.grid-3 {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
.dash-split,
|
||||||
|
.admins-split {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.container {
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
.hero-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.grid-2 {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.grid-6,
|
||||||
|
.grid-4,
|
||||||
|
.grid-3 {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Backend layout ---- */
|
||||||
|
.backend {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--cream-3);
|
||||||
|
}
|
||||||
|
.sidebar {
|
||||||
|
width: 248px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--navy);
|
||||||
|
color: #cfe0ee;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 22px 16px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
.nav-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 11px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: "Public Sans", sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 11px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
text-decoration: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #9bb6cd;
|
||||||
|
}
|
||||||
|
.nav-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: #cfe0ee;
|
||||||
|
}
|
||||||
|
.nav-btn.active {
|
||||||
|
background: rgba(224, 166, 60, 0.22);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: inset 3px 0 0 var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #d8cdb4;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.backend {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.sidebar {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
position: static;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Forms ---- */
|
||||||
|
.field {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--field-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 11px 13px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: "Public Sans", sans-serif;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
background: var(--field-bg);
|
||||||
|
}
|
||||||
|
.field:focus {
|
||||||
|
outline: 2px solid rgba(44, 111, 179, 0.35);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
.field-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Plein R — Commerçants & entreprises du Bassin de Pompey",
|
||||||
|
description:
|
||||||
|
"Association des commerçants et entreprises du Bassin de Pompey. Réseau · Rencontre · Réussite.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link
|
||||||
|
rel="preconnect"
|
||||||
|
href="https://fonts.gstatic.com"
|
||||||
|
crossOrigin="anonymous"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,400;12..96,600;12..96,700;12..96,800&family=Public+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<link rel="icon" href="/assets/logo.svg" type="image/svg+xml" />
|
||||||
|
</head>
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { AuthError } from "next-auth";
|
||||||
|
import { signIn } from "@/auth";
|
||||||
|
|
||||||
|
export async function authenticate(
|
||||||
|
_prevState: string | undefined,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
await signIn("credentials", {
|
||||||
|
email: formData.get("email"),
|
||||||
|
password: formData.get("password"),
|
||||||
|
redirectTo: "/backend",
|
||||||
|
});
|
||||||
|
return undefined;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return "E-mail ou mot de passe incorrect.";
|
||||||
|
}
|
||||||
|
// Re-throw redirect (and any other) errors so navigation works.
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { authenticate } from "./actions";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [errorMessage, formAction, isPending] = useActionState(
|
||||||
|
authenticate,
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minHeight: "100vh",
|
||||||
|
background: "#EDE7D7",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: 24,
|
||||||
|
fontFamily: "'Public Sans',sans-serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ width: "100%", maxWidth: 420 }}>
|
||||||
|
<div style={{ textAlign: "center", marginBottom: 22 }}>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src="/assets/logo.svg" alt="Plein R" style={{ height: 92, width: "auto" }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "#fff",
|
||||||
|
border: "1px solid #e6dcc6",
|
||||||
|
borderRadius: 18,
|
||||||
|
padding: 30,
|
||||||
|
boxShadow: "0 26px 60px -40px rgba(40,30,15,0.5)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1
|
||||||
|
className="font-display"
|
||||||
|
style={{ fontWeight: 800, fontSize: 24, margin: "0 0 4px", color: "#26201a" }}
|
||||||
|
>
|
||||||
|
Espace adhérent
|
||||||
|
</h1>
|
||||||
|
<p style={{ margin: "0 0 22px", fontSize: 14, color: "#9a8d72" }}>
|
||||||
|
Connectez-vous pour accéder à votre espace.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form action={formAction}>
|
||||||
|
<label className="field-label" htmlFor="email">
|
||||||
|
E-mail
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
placeholder="prenom@plein-r.fr"
|
||||||
|
className="field"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label className="field-label" htmlFor="password">
|
||||||
|
Mot de passe
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
className="field"
|
||||||
|
style={{ marginBottom: 18 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{errorMessage && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "#fbe9e6",
|
||||||
|
border: "1px solid #f0c4bb",
|
||||||
|
color: "#d8472b",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "10px 13px",
|
||||||
|
fontSize: 13.5,
|
||||||
|
fontWeight: 600,
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending}
|
||||||
|
className="font-display"
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
border: "none",
|
||||||
|
background: "#13324F",
|
||||||
|
color: "#fff",
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: 15,
|
||||||
|
padding: 14,
|
||||||
|
borderRadius: 11,
|
||||||
|
cursor: isPending ? "default" : "pointer",
|
||||||
|
opacity: isPending ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPending ? "Connexion…" : "Se connecter"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ textAlign: "center", marginTop: 18 }}>
|
||||||
|
<Link href="/" style={{ fontSize: 13.5, color: "#6f6450", textDecoration: "none" }}>
|
||||||
|
← Retour au site
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,588 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Sparkle } from "@/components/Sparkle";
|
||||||
|
import {
|
||||||
|
getCategoriesWithCounts,
|
||||||
|
getHighlightedMembers,
|
||||||
|
getLivePromotions,
|
||||||
|
} from "@/lib/queries";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const STRIPE_WARM =
|
||||||
|
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
|
||||||
|
const STRIPE_COOL =
|
||||||
|
"repeating-linear-gradient(45deg,#eef0ec,#eef0ec 12px,#e2e8e6 12px,#e2e8e6 24px)";
|
||||||
|
|
||||||
|
function badgeColor(badge: string | null) {
|
||||||
|
if (!badge) return "#1f8a5b";
|
||||||
|
return badge.includes("%") ? "#d8472b" : "#1f8a5b";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AccueilPage() {
|
||||||
|
const [cats, promos, highlighted] = await Promise.all([
|
||||||
|
getCategoriesWithCounts(6),
|
||||||
|
getLivePromotions(6),
|
||||||
|
getHighlightedMembers(3),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "#F6F2E8",
|
||||||
|
minHeight: "100vh",
|
||||||
|
fontFamily: "'Public Sans',sans-serif",
|
||||||
|
color: "#33291D",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* header */}
|
||||||
|
<header
|
||||||
|
className="container"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "18px 56px",
|
||||||
|
gap: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Link href="/" style={{ display: "flex", alignItems: "center", gap: 10, textDecoration: "none" }}>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src="/assets/logo.svg" alt="Plein R" style={{ height: 44, width: "auto" }} />
|
||||||
|
<span className="font-display" style={{ fontWeight: 800, fontSize: 20, color: "#13324F" }}>
|
||||||
|
Plein R
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<nav style={{ display: "flex", alignItems: "center", gap: 30, flexWrap: "wrap" }}>
|
||||||
|
<a href="#annuaire" style={{ textDecoration: "none", color: "#3c3322", fontWeight: 600, fontSize: 15 }}>
|
||||||
|
Annuaire
|
||||||
|
</a>
|
||||||
|
<a href="#promotions" style={{ textDecoration: "none", color: "#6f6450", fontWeight: 500, fontSize: 15 }}>
|
||||||
|
Promotions
|
||||||
|
</a>
|
||||||
|
<a href="#" style={{ textDecoration: "none", color: "#6f6450", fontWeight: 500, fontSize: 15 }}>
|
||||||
|
L'association
|
||||||
|
</a>
|
||||||
|
<a href="#" style={{ textDecoration: "none", color: "#6f6450", fontWeight: 500, fontSize: 15 }}>
|
||||||
|
Adhérer
|
||||||
|
</a>
|
||||||
|
<Link
|
||||||
|
href="/backend"
|
||||||
|
style={{
|
||||||
|
textDecoration: "none",
|
||||||
|
color: "#fff",
|
||||||
|
background: "#9a6638",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 14.5,
|
||||||
|
padding: "10px 18px",
|
||||||
|
borderRadius: 999,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Espace adhérent
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="container">
|
||||||
|
{/* hero */}
|
||||||
|
<section
|
||||||
|
className="grid hero-grid"
|
||||||
|
style={{ position: "relative", padding: "30px 0 50px", overflow: "hidden" }}
|
||||||
|
>
|
||||||
|
<Sparkle color="#E0A63C" size={22} style={{ top: 30, left: 20 }} duration={3.2} />
|
||||||
|
<Sparkle color="#6FB0C6" size={15} style={{ bottom: 64, left: 120 }} duration={2.6} delay={0.4} />
|
||||||
|
<Sparkle color="#9a6638" size={18} style={{ top: 40, right: 90 }} duration={3.8} delay={0.9} />
|
||||||
|
<Sparkle color="#2C6FB3" size={24} style={{ bottom: 80, right: 180 }} duration={3} delay={1.3} />
|
||||||
|
|
||||||
|
<div style={{ display: "flex", justifyContent: "center", alignItems: "center" }}>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src="/assets/logo.svg"
|
||||||
|
alt="Plein R — Association des commerçants et entreprises du Bassin de Pompey"
|
||||||
|
className="floatlogo"
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 380,
|
||||||
|
height: "auto",
|
||||||
|
display: "block",
|
||||||
|
filter: "drop-shadow(0 18px 34px rgba(40,30,15,0.16))",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 9,
|
||||||
|
background: "#fff",
|
||||||
|
border: "1px solid #e6dcc6",
|
||||||
|
color: "#9a6638",
|
||||||
|
fontSize: 12.5,
|
||||||
|
letterSpacing: "0.14em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
fontWeight: 700,
|
||||||
|
padding: "8px 16px",
|
||||||
|
borderRadius: 999,
|
||||||
|
marginBottom: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Réseau · Rencontre · Réussite
|
||||||
|
</div>
|
||||||
|
<h1
|
||||||
|
className="font-display"
|
||||||
|
style={{
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: 52,
|
||||||
|
lineHeight: 1.03,
|
||||||
|
letterSpacing: "-0.02em",
|
||||||
|
margin: "0 0 8px",
|
||||||
|
color: "#26201a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Le cœur commerçant du <span style={{ color: "#2C6FB3" }}>Bassin de Pompey</span>
|
||||||
|
</h1>
|
||||||
|
<div
|
||||||
|
className="font-display"
|
||||||
|
style={{ fontWeight: 600, fontSize: 17, color: "#9a6638", margin: "0 0 16px" }}
|
||||||
|
>
|
||||||
|
Association des commerçants & entreprises du Bassin de Pompey
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 17, lineHeight: 1.6, color: "#6c6150", margin: "0 0 24px", maxWidth: 520 }}>
|
||||||
|
Boulangers, artisans, restaurateurs, entreprises… retrouvez tous nos adhérents, profitez de
|
||||||
|
leurs bons plans et faites vivre le commerce de proximité.
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
action="#annuaire"
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
gap: 10,
|
||||||
|
background: "#fff",
|
||||||
|
border: "1px solid #e6dcc6",
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: "9px 9px 9px 18px",
|
||||||
|
boxShadow: "0 16px 36px -24px rgba(40,30,15,0.4)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
name="q"
|
||||||
|
placeholder="Que cherchez-vous ?"
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
outline: "none",
|
||||||
|
background: "transparent",
|
||||||
|
fontSize: 15,
|
||||||
|
fontFamily: "'Public Sans'",
|
||||||
|
width: 240,
|
||||||
|
color: "#3c3322",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="font-display"
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
background: "#2C6FB3",
|
||||||
|
color: "#fff",
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: 15,
|
||||||
|
padding: "12px 24px",
|
||||||
|
borderRadius: 10,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Rechercher
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* category tiles */}
|
||||||
|
<section id="annuaire" style={{ padding: "6px 0 8px" }}>
|
||||||
|
<h2
|
||||||
|
className="font-display"
|
||||||
|
style={{ fontWeight: 700, fontSize: 18, textAlign: "center", margin: "0 0 18px", color: "#3c3322" }}
|
||||||
|
>
|
||||||
|
Parcourir par métier
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-6">
|
||||||
|
{cats.map((c) => (
|
||||||
|
<div
|
||||||
|
key={c.id}
|
||||||
|
className="lift"
|
||||||
|
style={{
|
||||||
|
background: "#fff",
|
||||||
|
border: "1px solid #e6dcc6",
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: "18px 12px",
|
||||||
|
textAlign: "center",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
width: 46,
|
||||||
|
height: 46,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: c.tint,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="sparkle" style={{ width: 18, height: 18, background: c.accent }} />
|
||||||
|
</span>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: "#3c3322" }}>{c.label}</div>
|
||||||
|
<div style={{ fontSize: 11.5, color: "#a99c82", marginTop: 2 }}>
|
||||||
|
{c.memberCount} adhérent{c.memberCount > 1 ? "s" : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* promotions */}
|
||||||
|
<section
|
||||||
|
id="promotions"
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
margin: "46px 0 10px",
|
||||||
|
background: "#fff",
|
||||||
|
border: "1px solid #e6dcc6",
|
||||||
|
borderRadius: 24,
|
||||||
|
padding: "38px 36px 42px",
|
||||||
|
overflow: "hidden",
|
||||||
|
boxShadow: "0 26px 60px -40px rgba(40,30,15,0.5)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Sparkle color="#E0A63C" size={20} style={{ top: 28, right: 44 }} duration={3.4} />
|
||||||
|
<Sparkle color="#6FB0C6" size={13} style={{ top: 64, right: 96 }} duration={2.7} delay={0.5} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "flex-end",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 20,
|
||||||
|
marginBottom: 6,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
background: "#fdeccd",
|
||||||
|
color: "#9a6638",
|
||||||
|
fontSize: 12,
|
||||||
|
letterSpacing: "0.12em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
fontWeight: 800,
|
||||||
|
padding: "6px 13px",
|
||||||
|
borderRadius: 999,
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: "50%", background: "#E0A63C" }} />
|
||||||
|
Bons plans
|
||||||
|
</div>
|
||||||
|
<h2
|
||||||
|
className="font-display"
|
||||||
|
style={{ fontWeight: 800, fontSize: 32, letterSpacing: "-0.02em", margin: 0, color: "#26201a" }}
|
||||||
|
>
|
||||||
|
Les promotions de nos adhérents
|
||||||
|
</h2>
|
||||||
|
<p style={{ margin: "7px 0 0", color: "#8c8068", fontSize: 15 }}>
|
||||||
|
Offres, réductions et nouveautés publiées par les commerçants du réseau.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a href="#promotions" className="link-arrow" style={{ textDecoration: "none", color: "#9a6638", fontWeight: 700, fontSize: 14.5, whiteSpace: "nowrap" }}>
|
||||||
|
Toutes les promos →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-3" style={{ marginTop: 26 }}>
|
||||||
|
{promos.map((p) => (
|
||||||
|
<article
|
||||||
|
key={p.id}
|
||||||
|
className="lift-card"
|
||||||
|
style={{
|
||||||
|
background: "#fff",
|
||||||
|
border: "1px solid #ece3d0",
|
||||||
|
borderRadius: 18,
|
||||||
|
overflow: "hidden",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
height: 168,
|
||||||
|
background: p.imageUrl ? `url(${p.imageUrl})` : STRIPE_WARM,
|
||||||
|
backgroundSize: "cover",
|
||||||
|
backgroundPosition: "center",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!p.imageUrl && (
|
||||||
|
<span style={{ fontSize: 10.5, letterSpacing: "0.12em", color: "#a99c82", textTransform: "uppercase" }}>
|
||||||
|
photo de l'offre
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{p.badge && (
|
||||||
|
<span
|
||||||
|
className="font-display"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
background: badgeColor(p.badge),
|
||||||
|
color: "#fff",
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: 18,
|
||||||
|
padding: "8px 16px",
|
||||||
|
borderRadius: "0 0 0 16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{p.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{p.category && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 12,
|
||||||
|
left: 12,
|
||||||
|
background: "#9a6638",
|
||||||
|
color: "#fff",
|
||||||
|
borderRadius: 999,
|
||||||
|
padding: "5px 12px",
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{p.category}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: "17px 18px 18px", display: "flex", flexDirection: "column", flex: 1 }}>
|
||||||
|
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 18, margin: "0 0 5px", color: "#26201a" }}>
|
||||||
|
{p.title}
|
||||||
|
</h3>
|
||||||
|
<p style={{ margin: "0 0 14px", fontSize: 13.5, color: "#8c8068", lineHeight: 1.5, flex: 1 }}>
|
||||||
|
{p.text}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
borderTop: "1px solid #f0e8d6",
|
||||||
|
paddingTop: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700, color: "#3c3322" }}>{p.memberName}</div>
|
||||||
|
<div style={{ fontSize: 11.5, color: "#a99c82" }}>{p.validUntil}</div>
|
||||||
|
</div>
|
||||||
|
<a href="#" className="link-arrow" style={{ textDecoration: "none", color: "#2C6FB3", fontWeight: 700, fontSize: 13 }}>
|
||||||
|
Voir l'offre →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* member CTA */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 28,
|
||||||
|
background: "#f9f4e7",
|
||||||
|
border: "1px dashed #d8cdb4",
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: "20px 24px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 20,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 12,
|
||||||
|
background: "#fff",
|
||||||
|
border: "1px solid #e6dcc6",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="sparkle" style={{ width: 18, height: 18, background: "#E0A63C" }} />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div className="font-display" style={{ fontWeight: 700, fontSize: 16, color: "#26201a" }}>
|
||||||
|
Vous êtes adhérent ?
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13.5, color: "#8c8068" }}>
|
||||||
|
Publiez votre promotion en quelques clics depuis votre espace.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/backend/espace"
|
||||||
|
className="font-display"
|
||||||
|
style={{
|
||||||
|
textDecoration: "none",
|
||||||
|
background: "#9a6638",
|
||||||
|
color: "#fff",
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: 14.5,
|
||||||
|
padding: "12px 22px",
|
||||||
|
borderRadius: 11,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Publier une promo
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* highlighted members */}
|
||||||
|
<section style={{ padding: "40px 0 44px" }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 20 }}>
|
||||||
|
<h2 className="font-display" style={{ fontWeight: 800, fontSize: 26, margin: 0, color: "#26201a" }}>
|
||||||
|
À l'honneur ce mois-ci
|
||||||
|
</h2>
|
||||||
|
<a href="#annuaire" className="link-arrow" style={{ textDecoration: "none", color: "#9a6638", fontWeight: 700, fontSize: 14.5 }}>
|
||||||
|
Tout l'annuaire →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-3" style={{ gap: 18 }}>
|
||||||
|
{highlighted.map((m, i) => (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
className="lift-card"
|
||||||
|
style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 18, overflow: "hidden" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
height: 150,
|
||||||
|
background: i % 2 === 1 ? STRIPE_COOL : STRIPE_WARM,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 10.5, letterSpacing: "0.12em", color: "#a99c82", textTransform: "uppercase" }}>
|
||||||
|
photo vitrine
|
||||||
|
</span>
|
||||||
|
{m.categoryLabel && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 12,
|
||||||
|
left: 12,
|
||||||
|
background: "#9a6638",
|
||||||
|
color: "#fff",
|
||||||
|
borderRadius: 999,
|
||||||
|
padding: "5px 12px",
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{m.categoryLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: "16px 18px 18px" }}>
|
||||||
|
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 18, margin: "0 0 5px", color: "#26201a" }}>
|
||||||
|
{m.name}
|
||||||
|
</h3>
|
||||||
|
<p style={{ margin: "0 0 12px", fontSize: 13.5, color: "#8c8068", lineHeight: 1.5 }}>
|
||||||
|
{m.description}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 7, fontSize: 12.5, color: "#6c6150" }}>
|
||||||
|
<span style={{ width: 7, height: 7, borderRadius: "50%", background: m.accent ?? "#E0A63C" }} />
|
||||||
|
{[m.address, m.city].filter(Boolean).join(" · ")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* warm CTA */}
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
margin: "0 0 50px",
|
||||||
|
position: "relative",
|
||||||
|
background: "linear-gradient(120deg,#9a6638,#b97f48)",
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: 44,
|
||||||
|
textAlign: "center",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Sparkle color="#E0A63C" size={18} style={{ top: 24, left: 60 }} duration={3.4} />
|
||||||
|
<Sparkle color="#6FB0C6" size={22} style={{ bottom: 26, right: 80 }} duration={2.8} delay={0.7} />
|
||||||
|
<h2 className="font-display" style={{ fontWeight: 800, fontSize: 30, margin: "0 0 10px", color: "#fff" }}>
|
||||||
|
Faites partie de l'aventure Plein R
|
||||||
|
</h2>
|
||||||
|
<p style={{ margin: "0 auto 22px", fontSize: 15.5, color: "#f4e6d3", maxWidth: 480 }}>
|
||||||
|
Rejoignez plus de 120 commerçants et entreprises qui font réseau, se rencontrent et réussissent
|
||||||
|
ensemble.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="font-display lift-cta"
|
||||||
|
style={{
|
||||||
|
textDecoration: "none",
|
||||||
|
background: "#fff",
|
||||||
|
color: "#9a6638",
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: 16,
|
||||||
|
padding: "15px 30px",
|
||||||
|
borderRadius: 12,
|
||||||
|
display: "inline-block",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Devenir adhérent
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* footer */}
|
||||||
|
<footer style={{ background: "#EFE9DA", borderTop: "1px solid #e2d6bd", padding: "28px 56px" }}>
|
||||||
|
<div
|
||||||
|
className="container"
|
||||||
|
style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 16, flexWrap: "wrap" }}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src="/assets/logo.svg" alt="Plein R" style={{ height: 36, width: "auto" }} />
|
||||||
|
<span className="font-display" style={{ fontWeight: 800, fontSize: 16, color: "#13324F" }}>
|
||||||
|
Plein R
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: "#8c8068", textAlign: "right", lineHeight: 1.7 }}>
|
||||||
|
contact@plein-r.fr · Bassin de Pompey
|
||||||
|
<br />
|
||||||
|
Réseau · Rencontre · Réussite
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { NextAuthConfig } from "next-auth";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edge-safe Auth.js configuration (no database / bcrypt imports here).
|
||||||
|
* Shared between the middleware (edge runtime) and the full auth instance.
|
||||||
|
*/
|
||||||
|
export const authConfig = {
|
||||||
|
pages: {
|
||||||
|
signIn: "/login",
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
strategy: "jwt",
|
||||||
|
},
|
||||||
|
trustHost: true,
|
||||||
|
callbacks: {
|
||||||
|
jwt({ token, user }) {
|
||||||
|
if (user) {
|
||||||
|
token.uid = (user as { id?: string }).id ?? token.sub;
|
||||||
|
token.role = (user as { role?: string }).role;
|
||||||
|
token.memberId = (user as { memberId?: number | null }).memberId ?? null;
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
session({ session, token }) {
|
||||||
|
if (session.user) {
|
||||||
|
session.user.id = (token.uid as string) ?? "";
|
||||||
|
session.user.role = token.role as
|
||||||
|
| "admin"
|
||||||
|
| "moderator"
|
||||||
|
| "editor"
|
||||||
|
| "member";
|
||||||
|
session.user.memberId = (token.memberId as number | null) ?? null;
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
authorized({ auth, request }) {
|
||||||
|
const isLoggedIn = !!auth?.user;
|
||||||
|
const { pathname } = request.nextUrl;
|
||||||
|
if (pathname.startsWith("/backend")) {
|
||||||
|
return isLoggedIn;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
providers: [],
|
||||||
|
} satisfies NextAuthConfig;
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
import NextAuth from "next-auth";
|
||||||
|
import Credentials from "next-auth/providers/credentials";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { users } from "@/db/schema";
|
||||||
|
import { authConfig } from "@/auth.config";
|
||||||
|
|
||||||
|
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
|
...authConfig,
|
||||||
|
providers: [
|
||||||
|
Credentials({
|
||||||
|
credentials: {
|
||||||
|
email: { label: "E-mail", type: "email" },
|
||||||
|
password: { label: "Mot de passe", type: "password" },
|
||||||
|
},
|
||||||
|
async authorize(credentials) {
|
||||||
|
const email = String(credentials?.email ?? "")
|
||||||
|
.toLowerCase()
|
||||||
|
.trim();
|
||||||
|
const password = String(credentials?.password ?? "");
|
||||||
|
if (!email || !password) return null;
|
||||||
|
|
||||||
|
const rows = await db.select().from(users).where(eq(users.email, email));
|
||||||
|
const user = rows[0];
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
const valid = await bcrypt.compare(password, user.passwordHash);
|
||||||
|
if (!valid) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(user.id),
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
memberId: user.memberId,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { CSSProperties } from "react";
|
||||||
|
|
||||||
|
export function Sparkle({
|
||||||
|
color,
|
||||||
|
size,
|
||||||
|
style,
|
||||||
|
duration = 3.2,
|
||||||
|
delay = 0,
|
||||||
|
}: {
|
||||||
|
color: string;
|
||||||
|
size: number;
|
||||||
|
style?: CSSProperties;
|
||||||
|
duration?: number;
|
||||||
|
delay?: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="sparkle twinkle"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
background: color,
|
||||||
|
animationDuration: `${duration}s`,
|
||||||
|
animationDelay: `${delay}s`,
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { drizzle } from "drizzle-orm/node-postgres";
|
||||||
|
import { Pool } from "pg";
|
||||||
|
import * as schema from "./schema";
|
||||||
|
|
||||||
|
const connectionString = process.env.DATABASE_URL;
|
||||||
|
if (!connectionString) {
|
||||||
|
throw new Error("DATABASE_URL is not set");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reuse the pool across hot reloads in dev to avoid exhausting connections.
|
||||||
|
const globalForDb = globalThis as unknown as { __pleinrPool?: Pool };
|
||||||
|
|
||||||
|
export const pool =
|
||||||
|
globalForDb.__pleinrPool ??
|
||||||
|
new Pool({
|
||||||
|
connectionString,
|
||||||
|
max: Number(process.env.PG_POOL_MAX ?? 10),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
globalForDb.__pleinrPool = pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const db = drizzle(pool, { schema });
|
||||||
|
export { schema };
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { drizzle } from "drizzle-orm/node-postgres";
|
||||||
|
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
||||||
|
import { Pool } from "pg";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const connectionString = process.env.DATABASE_URL;
|
||||||
|
if (!connectionString) {
|
||||||
|
throw new Error("DATABASE_URL is not set");
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = new Pool({ connectionString, max: 1 });
|
||||||
|
const db = drizzle(pool);
|
||||||
|
|
||||||
|
console.log("Running migrations…");
|
||||||
|
await migrate(db, { migrationsFolder: "./drizzle" });
|
||||||
|
console.log("Migrations complete.");
|
||||||
|
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("Migration failed:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
pgEnum,
|
||||||
|
serial,
|
||||||
|
integer,
|
||||||
|
text,
|
||||||
|
varchar,
|
||||||
|
boolean,
|
||||||
|
timestamp,
|
||||||
|
uniqueIndex,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
import { relations } from "drizzle-orm";
|
||||||
|
|
||||||
|
// ---- Enums ----
|
||||||
|
export const roleEnum = pgEnum("role", ["admin", "moderator", "editor", "member"]);
|
||||||
|
export const memberStatusEnum = pgEnum("member_status", ["active", "pending"]);
|
||||||
|
export const promoStatusEnum = pgEnum("promo_status", [
|
||||||
|
"pending",
|
||||||
|
"live",
|
||||||
|
"expired",
|
||||||
|
"rejected",
|
||||||
|
]);
|
||||||
|
export const requestStatusEnum = pgEnum("request_status", [
|
||||||
|
"new",
|
||||||
|
"approved",
|
||||||
|
"rejected",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ---- Categories (métiers) ----
|
||||||
|
export const categories = pgTable("categories", {
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
slug: varchar("slug", { length: 80 }).notNull().unique(),
|
||||||
|
label: varchar("label", { length: 120 }).notNull(),
|
||||||
|
accent: varchar("accent", { length: 16 }).notNull().default("#E0A63C"),
|
||||||
|
tint: varchar("tint", { length: 16 }).notNull().default("#f6efdc"),
|
||||||
|
sort: integer("sort").notNull().default(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Members (adhérents / businesses) ----
|
||||||
|
export const members = pgTable("members", {
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
name: varchar("name", { length: 200 }).notNull(),
|
||||||
|
email: varchar("email", { length: 200 }).notNull(),
|
||||||
|
categoryId: integer("category_id").references(() => categories.id),
|
||||||
|
city: varchar("city", { length: 120 }),
|
||||||
|
address: varchar("address", { length: 240 }),
|
||||||
|
description: text("description"),
|
||||||
|
status: memberStatusEnum("status").notNull().default("pending"),
|
||||||
|
highlighted: boolean("highlighted").notNull().default(false),
|
||||||
|
logoUrl: text("logo_url"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Users (admins + member accounts) ----
|
||||||
|
export const users = pgTable(
|
||||||
|
"users",
|
||||||
|
{
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
email: varchar("email", { length: 200 }).notNull(),
|
||||||
|
name: varchar("name", { length: 200 }).notNull(),
|
||||||
|
passwordHash: varchar("password_hash", { length: 255 }).notNull(),
|
||||||
|
role: roleEnum("role").notNull().default("member"),
|
||||||
|
memberId: integer("member_id").references(() => members.id),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
emailIdx: uniqueIndex("users_email_idx").on(t.email),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- Promotions ----
|
||||||
|
export const promotions = pgTable("promotions", {
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
title: varchar("title", { length: 200 }).notNull(),
|
||||||
|
text: text("text"),
|
||||||
|
category: varchar("category", { length: 120 }),
|
||||||
|
badge: varchar("badge", { length: 40 }),
|
||||||
|
imageUrl: text("image_url"),
|
||||||
|
memberId: integer("member_id").references(() => members.id),
|
||||||
|
status: promoStatusEnum("status").notNull().default("pending"),
|
||||||
|
validUntil: varchar("valid_until", { length: 120 }),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Membership requests (demandes d'adhésion) ----
|
||||||
|
export const membershipRequests = pgTable("membership_requests", {
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
name: varchar("name", { length: 200 }).notNull(),
|
||||||
|
email: varchar("email", { length: 200 }),
|
||||||
|
message: text("message"),
|
||||||
|
status: requestStatusEnum("status").notNull().default("new"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Activity log (activité récente) ----
|
||||||
|
export const activityLog = pgTable("activity_log", {
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
dot: varchar("dot", { length: 16 }).notNull().default("#2C6FB3"),
|
||||||
|
message: text("message").notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Relations ----
|
||||||
|
export const membersRelations = relations(members, ({ one, many }) => ({
|
||||||
|
category: one(categories, {
|
||||||
|
fields: [members.categoryId],
|
||||||
|
references: [categories.id],
|
||||||
|
}),
|
||||||
|
promotions: many(promotions),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const promotionsRelations = relations(promotions, ({ one }) => ({
|
||||||
|
member: one(members, {
|
||||||
|
fields: [promotions.memberId],
|
||||||
|
references: [members.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const usersRelations = relations(users, ({ one }) => ({
|
||||||
|
member: one(members, {
|
||||||
|
fields: [users.memberId],
|
||||||
|
references: [members.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export type Category = typeof categories.$inferSelect;
|
||||||
|
export type Member = typeof members.$inferSelect;
|
||||||
|
export type User = typeof users.$inferSelect;
|
||||||
|
export type Promotion = typeof promotions.$inferSelect;
|
||||||
|
export type MembershipRequest = typeof membershipRequests.$inferSelect;
|
||||||
|
export type ActivityEntry = typeof activityLog.$inferSelect;
|
||||||
+188
@@ -0,0 +1,188 @@
|
|||||||
|
import "dotenv/config";
|
||||||
|
import { Pool } from "pg";
|
||||||
|
import { drizzle } from "drizzle-orm/node-postgres";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import * as schema from "./schema";
|
||||||
|
|
||||||
|
const {
|
||||||
|
categories,
|
||||||
|
members,
|
||||||
|
users,
|
||||||
|
promotions,
|
||||||
|
membershipRequests,
|
||||||
|
activityLog,
|
||||||
|
} = schema;
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const connectionString = process.env.DATABASE_URL;
|
||||||
|
if (!connectionString) throw new Error("DATABASE_URL is not set");
|
||||||
|
|
||||||
|
const pool = new Pool({ connectionString, max: 1 });
|
||||||
|
const db = drizzle(pool, { schema });
|
||||||
|
|
||||||
|
console.log("Seeding database…");
|
||||||
|
|
||||||
|
// ---- Categories (métiers) ----
|
||||||
|
const categoryData = [
|
||||||
|
{ slug: "restauration", label: "Restauration", accent: "#E0A63C", tint: "#f6efdc", sort: 1 },
|
||||||
|
{ slug: "mode-beaute", label: "Mode & Beauté", accent: "#6FB0C6", tint: "#e7f0f3", sort: 2 },
|
||||||
|
{ slug: "artisanat", label: "Artisanat", accent: "#9a6638", tint: "#f4ebda", sort: 3 },
|
||||||
|
{ slug: "services", label: "Services", accent: "#2C6FB3", tint: "#eaf0f6", sort: 4 },
|
||||||
|
{ slug: "sante", label: "Santé", accent: "#E0A63C", tint: "#f6efdc", sort: 5 },
|
||||||
|
{ slug: "alimentation", label: "Alimentation", accent: "#6FB0C6", tint: "#e7f0f3", sort: 6 },
|
||||||
|
{ slug: "boulangerie", label: "Boulangerie", accent: "#9a6638", tint: "#f4ebda", sort: 7 },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const c of categoryData) {
|
||||||
|
await db.insert(categories).values(c).onConflictDoNothing({ target: categories.slug });
|
||||||
|
}
|
||||||
|
const cats = await db.select().from(categories);
|
||||||
|
const catId = (slug: string) => cats.find((c) => c.slug === slug)?.id ?? null;
|
||||||
|
|
||||||
|
// ---- Members (adhérents) ----
|
||||||
|
const memberData = [
|
||||||
|
{
|
||||||
|
name: "Au Bon Pain",
|
||||||
|
email: "contact@aubonpain.fr",
|
||||||
|
categoryId: catId("boulangerie"),
|
||||||
|
city: "Frouard",
|
||||||
|
address: "12 rue de la République",
|
||||||
|
description: "Pains au levain & viennoiseries maison, tous les matins.",
|
||||||
|
status: "active" as const,
|
||||||
|
highlighted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Café des Arts",
|
||||||
|
email: "hello@cafedesarts.fr",
|
||||||
|
categoryId: catId("restauration"),
|
||||||
|
city: "Pompey",
|
||||||
|
address: "3 place Stanislas",
|
||||||
|
description: "Cuisine de saison, brunch le dimanche, terrasse au cœur du bourg.",
|
||||||
|
status: "active" as const,
|
||||||
|
highlighted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Atelier Émilie",
|
||||||
|
email: "emilie@atelier.fr",
|
||||||
|
categoryId: catId("mode-beaute"),
|
||||||
|
city: "Champigneulles",
|
||||||
|
address: "7 av. des Tilleuls",
|
||||||
|
description: "Salon de coiffure & soins, sur rendez-vous du mardi au samedi.",
|
||||||
|
status: "active" as const,
|
||||||
|
highlighted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Garage Moderne",
|
||||||
|
email: "contact@garagemoderne.fr",
|
||||||
|
categoryId: catId("services"),
|
||||||
|
city: "Custines",
|
||||||
|
description: "Entretien et réparation toutes marques.",
|
||||||
|
status: "active" as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Boutique Indigo",
|
||||||
|
email: "indigo@boutique.fr",
|
||||||
|
categoryId: catId("mode-beaute"),
|
||||||
|
city: "Liverdun",
|
||||||
|
description: "Prêt-à-porter et accessoires.",
|
||||||
|
status: "pending" as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Fleurs & Sens",
|
||||||
|
email: "contact@fleursetsens.fr",
|
||||||
|
categoryId: catId("artisanat"),
|
||||||
|
city: "Pompey",
|
||||||
|
description: "Compositions florales, fleurs de saison cueillies localement.",
|
||||||
|
status: "active" as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "La Cave Gourmande",
|
||||||
|
email: "bonjour@cavegourmande.fr",
|
||||||
|
categoryId: catId("alimentation"),
|
||||||
|
city: "Frouard",
|
||||||
|
description: "Fromages fermiers, vins et produits du terroir.",
|
||||||
|
status: "active" as const,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const m of memberData) {
|
||||||
|
const existing = await db.select().from(members).where(eq(members.email, m.email));
|
||||||
|
if (existing.length === 0) await db.insert(members).values(m);
|
||||||
|
}
|
||||||
|
const allMembers = await db.select().from(members);
|
||||||
|
const memberId = (name: string) => allMembers.find((m) => m.name === name)?.id ?? null;
|
||||||
|
|
||||||
|
// ---- Promotions ----
|
||||||
|
const promoData = [
|
||||||
|
{ title: "Le 13e pain offert", text: "Sur présentation de votre carte adhérent, profitez d'une réduction sur nos pains au levain.", category: "Boulangerie", badge: "-20%", memberId: memberId("Au Bon Pain"), status: "live" as const, validUntil: "Valable jusqu'au 30 juin" },
|
||||||
|
{ title: "Formule midi du marché", text: "Entrée + plat + café à 15€ tous les midis de semaine. Produits frais et locaux.", category: "Restauration", badge: "Menu 15€", memberId: memberId("Café des Arts"), status: "live" as const, validUntil: "Du lundi au vendredi" },
|
||||||
|
{ title: "Soin offert dès 2 prestations", text: "Profitez de -30% sur le 2e soin réservé dans le mois. Sur rendez-vous.", category: "Beauté", badge: "-30%", memberId: memberId("Atelier Émilie"), status: "pending" as const, validUntil: "Jusqu'au 15 juillet" },
|
||||||
|
{ title: "Révision auto à prix réseau", text: "15€ de remise sur votre forfait révision pour tous les adhérents Plein R.", category: "Services", badge: "-15€", memberId: memberId("Garage Moderne"), status: "pending" as const, validUntil: "Toute l'année" },
|
||||||
|
{ title: "Bouquet du mois en promo", text: "-25% sur la composition florale du mois. Fleurs de saison, cueillies localement.", category: "Artisanat", badge: "-25%", memberId: memberId("Fleurs & Sens"), status: "live" as const, validUntil: "Jusqu'au 30 juin" },
|
||||||
|
{ title: "2 fromages achetés, 1 offert", text: "Sur une sélection de fromages fermiers. L'occasion de découvrir nos producteurs.", category: "Alimentation", badge: "2+1", memberId: memberId("La Cave Gourmande"), status: "live" as const, validUntil: "Ce week-end" },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const p of promoData) {
|
||||||
|
const existing = await db.select().from(promotions).where(eq(promotions.title, p.title));
|
||||||
|
if (existing.length === 0) await db.insert(promotions).values(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Membership requests ----
|
||||||
|
const requestData = [
|
||||||
|
{ name: "Boutique Indigo", email: "indigo@boutique.fr", message: "Souhaite rejoindre le réseau." },
|
||||||
|
{ name: "Pizzeria Bella", email: "contact@bella.fr", message: "Demande d'adhésion." },
|
||||||
|
{ name: "Coworking La Ruche", email: "hello@laruche.fr", message: "Espace de travail partagé." },
|
||||||
|
];
|
||||||
|
for (const r of requestData) {
|
||||||
|
const existing = await db.select().from(membershipRequests).where(eq(membershipRequests.name, r.name));
|
||||||
|
if (existing.length === 0) await db.insert(membershipRequests).values(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Activity log ----
|
||||||
|
const activityData = [
|
||||||
|
{ dot: "#1f8a5b", message: "<strong>Atelier Émilie</strong> a publié une promotion « Soin offert dès 2 prestations »" },
|
||||||
|
{ dot: "#E0A63C", message: "<strong>Garage Moderne</strong> a soumis une promotion en attente de validation" },
|
||||||
|
{ dot: "#2C6FB3", message: "Nouvelle demande d'adhésion : <strong>Boutique Indigo</strong>" },
|
||||||
|
{ dot: "#9a6638", message: "<strong>Café des Arts</strong> a mis à jour sa fiche annuaire" },
|
||||||
|
];
|
||||||
|
const existingActivity = await db.select().from(activityLog);
|
||||||
|
if (existingActivity.length === 0) {
|
||||||
|
for (const a of activityData) await db.insert(activityLog).values(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Admin + sample staff/member users ----
|
||||||
|
const adminEmail = process.env.SEED_ADMIN_EMAIL ?? "admin@plein-r.fr";
|
||||||
|
const adminPassword = process.env.SEED_ADMIN_PASSWORD ?? "changeme123";
|
||||||
|
const adminName = process.env.SEED_ADMIN_NAME ?? "Administrateur Plein R";
|
||||||
|
|
||||||
|
const seedUsers = [
|
||||||
|
{ email: adminEmail, name: adminName, password: adminPassword, role: "admin" as const, memberId: null },
|
||||||
|
{ email: "claire@plein-r.fr", name: "Claire Martin", password: "changeme123", role: "admin" as const, memberId: null },
|
||||||
|
{ email: "thomas@plein-r.fr", name: "Thomas Petit", password: "changeme123", role: "moderator" as const, memberId: null },
|
||||||
|
{ email: "sophie@plein-r.fr", name: "Sophie Aubert", password: "changeme123", role: "editor" as const, memberId: null },
|
||||||
|
{ email: "contact@aubonpain.fr", name: "Au Bon Pain", password: "changeme123", role: "member" as const, memberId: memberId("Au Bon Pain") },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const u of seedUsers) {
|
||||||
|
const existing = await db.select().from(users).where(eq(users.email, u.email));
|
||||||
|
if (existing.length === 0) {
|
||||||
|
await db.insert(users).values({
|
||||||
|
email: u.email,
|
||||||
|
name: u.name,
|
||||||
|
passwordHash: await bcrypt.hash(u.password, 10),
|
||||||
|
role: u.role,
|
||||||
|
memberId: u.memberId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Seed complete.");
|
||||||
|
console.log(` Admin login: ${adminEmail} / ${adminPassword}`);
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("Seed failed:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { and, count, desc, eq } from "drizzle-orm";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { categories, members, promotions } from "@/db/schema";
|
||||||
|
|
||||||
|
export async function getCategoriesWithCounts(limit = 6) {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: categories.id,
|
||||||
|
slug: categories.slug,
|
||||||
|
label: categories.label,
|
||||||
|
accent: categories.accent,
|
||||||
|
tint: categories.tint,
|
||||||
|
sort: categories.sort,
|
||||||
|
memberCount: count(members.id),
|
||||||
|
})
|
||||||
|
.from(categories)
|
||||||
|
.leftJoin(
|
||||||
|
members,
|
||||||
|
and(eq(members.categoryId, categories.id), eq(members.status, "active"))
|
||||||
|
)
|
||||||
|
.groupBy(categories.id)
|
||||||
|
.orderBy(categories.sort)
|
||||||
|
.limit(limit);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLivePromotions(limit = 6) {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: promotions.id,
|
||||||
|
title: promotions.title,
|
||||||
|
text: promotions.text,
|
||||||
|
category: promotions.category,
|
||||||
|
badge: promotions.badge,
|
||||||
|
imageUrl: promotions.imageUrl,
|
||||||
|
validUntil: promotions.validUntil,
|
||||||
|
memberName: members.name,
|
||||||
|
})
|
||||||
|
.from(promotions)
|
||||||
|
.leftJoin(members, eq(promotions.memberId, members.id))
|
||||||
|
.where(eq(promotions.status, "live"))
|
||||||
|
.orderBy(desc(promotions.createdAt))
|
||||||
|
.limit(limit);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getHighlightedMembers(limit = 3) {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: members.id,
|
||||||
|
name: members.name,
|
||||||
|
description: members.description,
|
||||||
|
city: members.city,
|
||||||
|
address: members.address,
|
||||||
|
categoryLabel: categories.label,
|
||||||
|
accent: categories.accent,
|
||||||
|
})
|
||||||
|
.from(members)
|
||||||
|
.leftJoin(categories, eq(members.categoryId, categories.id))
|
||||||
|
.where(and(eq(members.highlighted, true), eq(members.status, "active")))
|
||||||
|
.limit(limit);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { AppRole } from "@/types/next-auth";
|
||||||
|
|
||||||
|
export const ROLE_LABELS: Record<AppRole, string> = {
|
||||||
|
admin: "Administrateur",
|
||||||
|
moderator: "Modérateur",
|
||||||
|
editor: "Éditeur",
|
||||||
|
member: "Adhérent",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LABEL_TO_ROLE: Record<string, AppRole> = {
|
||||||
|
Administrateur: "admin",
|
||||||
|
Modérateur: "moderator",
|
||||||
|
Éditeur: "editor",
|
||||||
|
Adhérent: "member",
|
||||||
|
};
|
||||||
|
|
||||||
|
type Capability =
|
||||||
|
| "viewDashboard"
|
||||||
|
| "manageMembers"
|
||||||
|
| "moderatePromos"
|
||||||
|
| "manageAdmins"
|
||||||
|
| "memberSpace";
|
||||||
|
|
||||||
|
const CAPABILITIES: Record<AppRole, Capability[]> = {
|
||||||
|
admin: ["viewDashboard", "manageMembers", "moderatePromos", "manageAdmins", "memberSpace"],
|
||||||
|
moderator: ["viewDashboard", "manageMembers", "moderatePromos"],
|
||||||
|
editor: ["viewDashboard", "manageMembers"],
|
||||||
|
member: ["memberSpace"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function can(role: AppRole | undefined, capability: Capability): boolean {
|
||||||
|
if (!role) return false;
|
||||||
|
return CAPABILITIES[role]?.includes(capability) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STAFF_ROLES: AppRole[] = ["admin", "moderator", "editor"];
|
||||||
|
|
||||||
|
export function isStaff(role: AppRole | undefined): boolean {
|
||||||
|
return !!role && STAFF_ROLES.includes(role);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import NextAuth from "next-auth";
|
||||||
|
import { authConfig } from "@/auth.config";
|
||||||
|
|
||||||
|
// Edge-safe auth instance (no providers / db) used only to gate routes.
|
||||||
|
export const { auth: middleware } = NextAuth(authConfig);
|
||||||
|
|
||||||
|
export default middleware;
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ["/backend/:path*"],
|
||||||
|
};
|
||||||
Vendored
+26
@@ -0,0 +1,26 @@
|
|||||||
|
import type { DefaultSession } from "next-auth";
|
||||||
|
|
||||||
|
export type AppRole = "admin" | "moderator" | "editor" | "member";
|
||||||
|
|
||||||
|
declare module "next-auth" {
|
||||||
|
interface Session {
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
role: AppRole;
|
||||||
|
memberId: number | null;
|
||||||
|
} & DefaultSession["user"];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
role: AppRole;
|
||||||
|
memberId: number | null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "next-auth/jwt" {
|
||||||
|
interface JWT {
|
||||||
|
uid?: string;
|
||||||
|
role?: AppRole;
|
||||||
|
memberId?: number | null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [{ "name": "next" }],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user