mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-22 06:38:05 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
320e63b749 | ||
|
|
8414c7201e | ||
|
|
0562d07254 | ||
|
|
a8adb9ae6a | ||
|
|
dd350038f0 | ||
|
|
741dd53b40 | ||
|
|
3b17d7ff42 | ||
|
|
dd7bbfb777 | ||
|
|
b0788d3f20 | ||
|
|
2874d79c9e | ||
|
|
fb3d5e337e | ||
|
|
e86c67c17e | ||
|
|
db361051e6 | ||
|
|
19d1104ab0 | ||
|
|
8bc1b17fc5 | ||
|
|
e3a4771edf | ||
|
|
dd9697019c | ||
|
|
5e7233f98d | ||
|
|
afc805c96a | ||
|
|
b2d428b08b | ||
|
|
28ada88155 | ||
|
|
b4c8967a3c |
No files matched your search
+44
-1
@@ -23,9 +23,52 @@ AUTH_URL=
|
||||
|
||||
# ---- First admin user (created by the seed script) ----
|
||||
SEED_ADMIN_EMAIL=admin@plein-r.fr
|
||||
SEED_ADMIN_PASSWORD=changeme123
|
||||
# Laissez vide : un mot de passe aléatoire est généré au premier démarrage,
|
||||
# affiché UNE FOIS dans les journaux, et doit être changé à la première connexion.
|
||||
SEED_ADMIN_PASSWORD=
|
||||
SEED_ADMIN_NAME=Administrateur Plein R
|
||||
# Données de démonstration (adhérents, promotions, demandes, journal et
|
||||
# comptes au mot de passe connu de tous) : uniquement sur un poste de
|
||||
# développement, jamais en production. Par défaut la base reste vide, avec le
|
||||
# référentiel des catégories et le compte administrateur seulement.
|
||||
# Pour retirer des données de démo déjà en base : npm run db:purge-demo
|
||||
# (ou, dans le conteneur : node dist/purge-demo.cjs).
|
||||
SEED_DEMO=false
|
||||
|
||||
# ---- App ----
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
|
||||
# ---- VPS sans reverse proxy existant (voir README) ----
|
||||
# Caddy devant l'application : ports 80 et 443, certificat Let's Encrypt
|
||||
# automatique pour SITE_DOMAIN, et réponse immédiate sur http://IP_DU_SERVEUR
|
||||
# tant que le DNS n'est pas configuré. Renseignez SITE_DOMAIN même si le
|
||||
# domaine ne pointe pas encore : la bascule en HTTPS se fera toute seule.
|
||||
#COMPOSE_FILE=docker-compose.yml:docker-compose.caddy.yml
|
||||
#SITE_DOMAIN=pleinr.example.fr
|
||||
#CADDY_EMAIL=vous@example.fr
|
||||
|
||||
# ---- Publication des promotions sur les réseaux sociaux ----
|
||||
# La configuration se fait désormais dans Backend › Réseaux sociaux : on y colle
|
||||
# les identifiants de l'application, on clique Connecter, et les jetons sont
|
||||
# récupérés et stockés chiffrés automatiquement. Rien à mettre ici dans le cas
|
||||
# normal.
|
||||
#
|
||||
# Clé de chiffrement des jetons en base. Si elle est vide, AUTH_SECRET est
|
||||
# utilisé. La définir explicitement permet de faire tourner AUTH_SECRET sans
|
||||
# perdre les connexions réseaux.
|
||||
# openssl rand -base64 32
|
||||
SOCIAL_TOKEN_KEY=
|
||||
|
||||
# Versions d'API (facultatif)
|
||||
FACEBOOK_GRAPH_VERSION=v21.0
|
||||
LINKEDIN_API_VERSION=202506
|
||||
|
||||
# --- Repli historique ---
|
||||
# Ces variables restent lues si aucun compte n'est connecté depuis le
|
||||
# backoffice. Utile pour un jeton posé à la main ; sinon, laissez vide.
|
||||
FACEBOOK_PAGE_ID=
|
||||
FACEBOOK_PAGE_ACCESS_TOKEN=
|
||||
LINKEDIN_ORGANIZATION_URN=
|
||||
LINKEDIN_ORGANIZATION_ID=
|
||||
LINKEDIN_ACCESS_TOKEN=
|
||||
@@ -23,7 +23,8 @@ 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
|
||||
npm run db:seed # catégories + admin initial ; données de démo si SEED_DEMO=true
|
||||
npm run db:purge-demo # retire les données de démo d'une base qui les a reçues
|
||||
docker compose up --build # full stack
|
||||
```
|
||||
|
||||
@@ -33,7 +34,7 @@ docker compose up --build # full stack
|
||||
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
|
||||
re-checks auth + capability via `getSession()` and `can()` before writing, then
|
||||
`revalidatePath()`.
|
||||
- **Access control**: `src/middleware.ts` gates `/backend/*`; each page further
|
||||
guards by role (`isStaff`, `can`) and redirects.
|
||||
@@ -41,6 +42,96 @@ docker compose up --build # full stack
|
||||
- After changing `src/db/schema.ts`, run `npm run db:generate` and commit the new
|
||||
file under `drizzle/`.
|
||||
|
||||
## Promotions
|
||||
|
||||
Statuts : `pending` → `live` → `suspended` ⇄ `live` (+ `rejected` / `expired`).
|
||||
`promotions.suspended_by` retient qui a suspendu (`member` ou `staff`) : une
|
||||
suspension par le staff ne peut être levée que par le staff. Les lectures
|
||||
publiques filtrent sur `status = 'live'`, donc une promo suspendue disparaît du
|
||||
site sans traitement supplémentaire.
|
||||
|
||||
## Réseaux sociaux
|
||||
|
||||
- `src/lib/social.ts` publie une promo sur la page Facebook (Graph API) ou
|
||||
LinkedIn (Posts API). `src/lib/social-accounts.ts` gère la configuration : OAuth,
|
||||
jetons, cibles. Réseau non configuré = case masquée.
|
||||
- Les identifiants et jetons vivent en base (`social_accounts`), **chiffrés** via
|
||||
`src/lib/crypto.ts` (AES-256-GCM, clé `SOCIAL_TOKEN_KEY` ou `AUTH_SECRET`), posés
|
||||
depuis `/backend/reseaux`. Les variables d'environnement restent lues en repli.
|
||||
Aucun secret ne doit jamais repartir vers le navigateur.
|
||||
- `isNetworkConfigured()` / `configuredNetworks()` / `siteUrl()` / `redirectUri()`
|
||||
sont **asynchrones** (accès base).
|
||||
- L'URL publique est le réglage `site_public_url`, édité sur `/backend/reseaux`
|
||||
(pré-rempli depuis les en-têtes de la requête). `saveSiteSettings` **saute**
|
||||
cette clé : le formulaire Paramètres ne la contient pas et l'écraserait.
|
||||
- Routes OAuth : `src/app/api/social/[network]/{connect,callback}`. Le `state`
|
||||
anti-CSRF passe par un cookie httpOnly ; aucun jeton ne transite par une URL.
|
||||
- Facebook : le jeton de page n'expire pas. LinkedIn : 60 jours, rafraîchissement
|
||||
programmatique réservé à certains partenaires, d'où le bandeau de reconnexion.
|
||||
- Les images de promo sont stockées en data-URI : l'upload se fait donc en
|
||||
binaire (multipart pour Facebook, Images API en 3 étapes pour LinkedIn), pas
|
||||
par URL.
|
||||
- La diffusion est déclenchée **par la validation**, pas par un bouton :
|
||||
`promotions.share_facebook` / `share_linkedin` sont choisis par l'adhérent,
|
||||
ajustables par le modérateur dans le formulaire « Valider », puis figés
|
||||
(`status !== 'pending'`).
|
||||
- `publishPromoShares()` dans `backend/actions.ts` est le seul point de
|
||||
publication. Elle ne lève jamais et ignore tout réseau ayant déjà une ligne
|
||||
`social_posts` en `posted` : c'est la garde anti-republication, qui couvre
|
||||
aussi le cycle suspension → remise en ligne.
|
||||
- `retryPromoShare` ne sert qu'au rattrapage d'un échec sur un réseau déjà
|
||||
choisi ; il ne peut pas élargir la diffusion.
|
||||
- Les URLs publiques des pages FB/LinkedIn sont des `site_settings`
|
||||
(`association_facebook`, `association_linkedin`), éditables dans Paramètres.
|
||||
|
||||
## Sécurité
|
||||
|
||||
- Le journal d'activité agrège des saisies de tiers, dont le formulaire de
|
||||
contact **public** : il est filtré à l'écriture (`sanitizeActivityMessage`) et
|
||||
rendu en éléments React (`activityNodes`), jamais en HTML brut.
|
||||
- `getSession()` (`src/lib/session.ts`) remplace `auth()` partout : le rôle et le
|
||||
rattachement adhérent sont relus en base à chaque requête, et
|
||||
`users.session_version` invalide les jetons émis avant un changement de mot de
|
||||
passe. N'appelez plus `auth()` directement depuis une page ou une action.
|
||||
- Les images ne sont acceptées qu'en data-URI (`asImageDataUri`) : une URL ferait
|
||||
appeler par le serveur une cible choisie par l'utilisateur (SSRF).
|
||||
- La CSP à nonce est posée par `src/middleware.ts`. Elle impose un rendu
|
||||
dynamique : `export const dynamic = "force-dynamic"` est dans `app/layout.tsx`,
|
||||
un HTML pré-généré ne pouvant pas porter de nonce.
|
||||
- Les mots de passe temporaires (création d'adhérent, réinitialisation,
|
||||
invitation staff) ne sont **jamais stockés** : l'action les renvoie et le
|
||||
composant `OneTimeCredentials` les affiche une seule fois, sans redirection.
|
||||
`users.must_change_password` seul persiste.
|
||||
- Le seed ne crée en production que le référentiel des catégories et
|
||||
l'administrateur initial, avec un mot de passe aléatoire affiché une fois
|
||||
dans les journaux (ou `SEED_ADMIN_PASSWORD`) et un changement obligatoire à
|
||||
la première connexion. Toutes les données de démonstration (adhérents,
|
||||
promotions, demandes, journal, comptes `changeme123`) vivent dans
|
||||
`src/db/demo-data.ts`, exigent `SEED_DEMO=true`, et `npm run db:purge-demo`
|
||||
les retire d'une base existante.
|
||||
- Sessions JWT limitées à 7 jours (`auth.config.ts`), HSTS et suppression de
|
||||
`X-Powered-By` dans `next.config.mjs`. Le port Postgres de `docker-compose`
|
||||
n'est publié que sur `127.0.0.1`.
|
||||
- `npm test` verrouille ces protections (`tests/security.test.ts`).
|
||||
|
||||
## Référencement (SEO)
|
||||
|
||||
- `src/lib/seo.ts` est **pur** (constantes, `pageMetadata()`, générateurs JSON-LD,
|
||||
`serializeJsonLd()` qui échappe `<>&`) et verrouillé par `tests/seo.test.ts`.
|
||||
`src/lib/seo-server.ts` fournit `publicBaseUrl()` : réglage `site_public_url`,
|
||||
puis variables d'environnement, puis en-têtes de la requête.
|
||||
- `app/layout.tsx` pose `metadataBase`, le gabarit de titre `%s · Plein R`,
|
||||
Open Graph / Twitter, `robots`, le manifeste, et les JSON-LD `Organization` +
|
||||
`WebSite`. Chaque page publique appelle `pageMetadata({ title, description,
|
||||
path })` : le `path` sert de canonique (l'annuaire ignore ainsi `?q=`).
|
||||
- Données structurées par page via `<JsonLd data={…} />` : `BreadcrumbList`
|
||||
partout, `ItemList` sur l'annuaire, `LocalBusiness` (+ horaires) sur la fiche
|
||||
adhérent, `BusinessEvent` pour chaque rencontre à venir.
|
||||
- `app/robots.ts`, `app/sitemap.ts` (pages statiques + adhérents actifs),
|
||||
`app/manifest.ts` et `app/opengraph-image.tsx` (vignette 1200×630 générée).
|
||||
- `/backend`, `/login`, `/inscription/*` et les fiches non actives sont en
|
||||
`NOINDEX` ; les pages publiques utilisent `<main>` et un seul `<h1>`.
|
||||
|
||||
## Roles
|
||||
|
||||
`admin` > `moderator` > `editor` are staff; `member` is an adhérent linked to a
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Reverse proxy Caddy pour Plein R (voir docker-compose.caddy.yml).
|
||||
#
|
||||
# Deux entrées, pour que l'installation soit utilisable AVANT que le DNS ne
|
||||
# soit configuré, et bascule toute seule ensuite :
|
||||
#
|
||||
# 1. {$SITE_DOMAIN} — le nom de domaine. Caddy obtient et renouvelle seul le
|
||||
# certificat Let's Encrypt et redirige HTTP vers HTTPS. Tant que le DNS ne
|
||||
# pointe pas sur le serveur, la demande de certificat échoue et Caddy
|
||||
# réessaie en arrière-plan, avec un délai croissant : rien à relancer, le
|
||||
# site devient joignable en HTTPS peu après la mise en place du DNS.
|
||||
#
|
||||
# 2. http:// — fourre-tout HTTP, qui répond notamment sur http://IP_DU_SERVEUR.
|
||||
# Le préfixe http:// désactive explicitement le HTTPS automatique pour ce
|
||||
# bloc (aucun certificat n'existe pour une IP nue). Le bloc du domaine étant
|
||||
# plus spécifique, il reste prioritaire, et la redirection HTTP vers HTTPS
|
||||
# du domaine est insérée avant ce fourre-tout.
|
||||
{
|
||||
email {$CADDY_EMAIL}
|
||||
}
|
||||
|
||||
{$SITE_DOMAIN} {
|
||||
encode zstd gzip
|
||||
reverse_proxy pleinr-app:3000
|
||||
}
|
||||
|
||||
http:// {
|
||||
encode zstd gzip
|
||||
reverse_proxy pleinr-app:3000
|
||||
}
|
||||
+3
-3
@@ -1,13 +1,13 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ---- deps: install all dependencies ----
|
||||
FROM node:20-alpine AS deps
|
||||
FROM node:22-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
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
@@ -16,7 +16,7 @@ RUN npm run build
|
||||
RUN npm run build:scripts
|
||||
|
||||
# ---- runner: minimal production image ----
|
||||
FROM node:20-alpine AS runner
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production \
|
||||
NEXT_TELEMETRY_DISABLED=1 \
|
||||
|
||||
@@ -45,6 +45,57 @@ charge des données de démonstration. Ensuite :
|
||||
> **Postgres déjà existant ?** Supprimez le service `postgres` de `docker-compose.yml`
|
||||
> et pointez `DATABASE_URL` vers votre instance.
|
||||
|
||||
## Installation sur un VPS sans reverse proxy
|
||||
|
||||
Le `docker-compose.yml` suppose un Nginx Proxy Manager déjà en place (réseau
|
||||
externe `nginx_default`). Sur un serveur nu, `docker-compose.caddy.yml` ajoute
|
||||
**Caddy** devant l'application, sans modifier le fichier de base.
|
||||
|
||||
Caddy sert deux entrées à la fois (voir `Caddyfile`) :
|
||||
|
||||
| Adresse | Sert | Certificat |
|
||||
|---|---|---|
|
||||
| `http://IP_DU_SERVEUR` | tout de suite | aucun (impossible sur une IP nue) |
|
||||
| `https://votre-domaine.fr` | dès que le DNS pointe sur le serveur | Let's Encrypt, automatique |
|
||||
|
||||
On installe donc **tout d'un coup**, on valide par l'IP, et la bascule en HTTPS
|
||||
se fait ensuite d'elle-même : il n'y a **rien à relancer** au moment de
|
||||
configurer le DNS. Tant que le domaine ne résout pas, Caddy réessaie la demande
|
||||
de certificat en arrière-plan avec un délai croissant.
|
||||
|
||||
1. Installer Docker : `curl -fsSL https://get.docker.com | sh`
|
||||
2. Ouvrir les ports : `sudo ufw allow 80,443/tcp`
|
||||
3. Cloner le dépôt, copier `.env.example` en `.env` et renseigner au minimum
|
||||
`POSTGRES_PASSWORD`, `DATABASE_URL`, `SEED_ADMIN_EMAIL`, puis :
|
||||
|
||||
```dotenv
|
||||
COMPOSE_FILE=docker-compose.yml:docker-compose.caddy.yml
|
||||
SITE_DOMAIN=votre-domaine.fr
|
||||
CADDY_EMAIL=vous@example.fr
|
||||
```
|
||||
|
||||
Renseigner `SITE_DOMAIN` **même si le DNS n'est pas encore en place** : c'est
|
||||
ce qui rend la bascule automatique. Laisser `AUTH_URL` **vide** : Auth.js
|
||||
déduit alors l'URL des en-têtes de la requête, et suit donc l'IP puis le
|
||||
domaine sans reconfiguration.
|
||||
4. Lancer : `docker compose up -d --build`
|
||||
5. Relever le mot de passe administrateur, affiché une seule fois :
|
||||
`docker logs pleinr-app 2>&1 | grep -i -A2 "mot de passe"`
|
||||
|
||||
Aucun port applicatif n'est publié : tout passe par Caddy. Les certificats
|
||||
vivent dans le volume `caddy_data` et se renouvellent seuls. Une fois le DNS
|
||||
posé, vérifier la délivrance avec
|
||||
`docker logs pleinr-caddy 2>&1 | grep -i "certificate obtained"`.
|
||||
|
||||
> **Vérification par l'IP** : en HTTP sur une adresse IP, la directive
|
||||
> `upgrade-insecure-requests` de la CSP (`src/middleware.ts`) peut empêcher le
|
||||
> navigateur de charger styles et scripts. Pour un test fidèle avant le DNS,
|
||||
> passer par un tunnel SSH — `ssh -L 8080:localhost:80 utilisateur@IP` — puis
|
||||
> ouvrir `http://localhost:8080` : `localhost` est une origine de confiance, la
|
||||
> directive ne s'y applique pas.
|
||||
|
||||
Mise à jour : `git pull && docker compose up -d --build`.
|
||||
|
||||
## Comptes de démonstration (seed)
|
||||
|
||||
| E-mail | Mot de passe | Rôle |
|
||||
@@ -62,11 +113,89 @@ charge des données de démonstration. Ensuite :
|
||||
| Tableau de bord | ✅ | ✅ | ✅ | — |
|
||||
| Adhérents (CRUD) | ✅ | ✅ | ✅ | — |
|
||||
| Modération des promotions | ✅ | ✅ | — | — |
|
||||
| Publication réseaux sociaux | ✅ | ✅ | — | — |
|
||||
| Administrateurs | ✅ | — | — | — |
|
||||
| Mon espace (publier une promo) | — | — | — | ✅ |
|
||||
|
||||
`/backend` est protégé par le middleware ; chaque vue affine l'accès selon le rôle.
|
||||
|
||||
## Cycle de vie d'une promotion
|
||||
|
||||
`pending` → `live` → `suspended` ⇄ `live`, ou suppression.
|
||||
|
||||
- L'adhérent soumet la promotion depuis **Mon espace** : elle part en `pending`.
|
||||
- L'association la valide dans **Backend › Promotions** : elle passe `live` et
|
||||
s'affiche sur l'accueil, l'annuaire et la fiche de l'adhérent.
|
||||
- **Suspension** : l'adhérent peut suspendre *ses* promotions depuis Mon espace,
|
||||
l'association peut suspendre n'importe laquelle. Une promotion suspendue sort
|
||||
immédiatement du site public et peut être remise en ligne à tout moment.
|
||||
Nuance importante : une suspension décidée par l'association ne peut pas être
|
||||
levée par l'adhérent (`promotions.suspended_by` mémorise l'auteur).
|
||||
|
||||
## Publication sur Facebook / LinkedIn
|
||||
|
||||
La diffusion est **pilotée par la validation**, jamais déclenchée à la main :
|
||||
|
||||
1. L'adhérent coche Facebook et/ou LinkedIn en soumettant sa promotion. Rien
|
||||
n'est publié à ce stade ; il peut modifier son choix tant que la promo est
|
||||
`pending`.
|
||||
2. Le modérateur voit ces cases pré-cochées sur la carte en attente et peut les
|
||||
ajuster — c'est le dernier moment où le choix est modifiable.
|
||||
3. **Valider** met la promo en ligne *et* publie sur les réseaux retenus (image +
|
||||
texte + lien vers la fiche adhérent). Après validation, le choix est figé.
|
||||
|
||||
Chaque tentative est tracée dans `social_posts` : succès avec le lien du post, ou
|
||||
échec avec le message d'erreur affiché sur la carte. En cas d'échec, un bouton
|
||||
**Réessayer** apparaît, limité aux réseaux déjà choisis — il ne permet pas
|
||||
d'élargir la diffusion.
|
||||
|
||||
Une promotion n'est jamais publiée deux fois : un réseau ayant déjà une
|
||||
publication réussie est systématiquement ignoré, y compris sur un cycle
|
||||
suspension → remise en ligne.
|
||||
|
||||
### Connecter les comptes
|
||||
|
||||
Tout se passe dans **Backend › Réseaux sociaux** (administrateurs) : on colle les
|
||||
identifiants de l'application, on clique **Connecter**, on choisit la page. Les
|
||||
jetons sont récupérés par OAuth et stockés **chiffrés** (AES-256-GCM, clé
|
||||
`SOCIAL_TOKEN_KEY` ou à défaut `AUTH_SECRET`) ; ils ne ressortent jamais vers le
|
||||
navigateur. Un réseau non connecté voit simplement sa case disparaître du
|
||||
formulaire de promotion.
|
||||
|
||||
Le même écran porte l'**URL publique du site**, pré-remplie avec l'adresse par
|
||||
laquelle vous consultez le backoffice : elle sert à l'adresse de retour OAuth et
|
||||
au lien inséré dans les publications. Seule l'origine est conservée (le schéma
|
||||
est ajouté si vous l'omettez, un éventuel chemin est retiré).
|
||||
|
||||
L'écran affiche ensuite l'URL de redirection à déclarer sur le portail
|
||||
développeur — c'est l'erreur de configuration la plus fréquente.
|
||||
|
||||
**Facebook.** Créez une application « Business » sur
|
||||
[developers.facebook.com](https://developers.facebook.com/apps), ajoutez le
|
||||
produit Connexion Facebook, déclarez l'URL de redirection. Gardez l'application
|
||||
en **mode développement** avec le compte de l'association comme administrateur :
|
||||
publier sur votre propre page ne demande alors aucune revue Meta. Le jeton de
|
||||
page obtenu **n'expire pas** — une connexion suffit, définitivement.
|
||||
|
||||
**LinkedIn.** Créez une application sur
|
||||
[linkedin.com/developers](https://www.linkedin.com/developers/apps) rattachée à
|
||||
la page de l'association, puis demandez le produit **Community Management API**.
|
||||
Deux limites à connaître avant de vous lancer :
|
||||
|
||||
- l'accès est soumis à une revue (page vérifiée, nom légal, adresse, politique
|
||||
de confidentialité) ; ce n'est pas garanti ni immédiat ;
|
||||
- les jetons LinkedIn durent **60 jours** et le rafraîchissement programmatique
|
||||
est réservé à certains partenaires. En pratique il faut donc recliquer sur
|
||||
**Reconnecter** environ tous les deux mois. Le backoffice affiche la date
|
||||
d'expiration et un bandeau d'alerte 7 jours avant.
|
||||
|
||||
Les variables d'environnement (`FACEBOOK_PAGE_ACCESS_TOKEN`, etc.) restent lues
|
||||
en **repli** si aucun compte n'est connecté, pour ne pas casser une installation
|
||||
antérieure.
|
||||
|
||||
Les **liens publics** vers les deux pages (affichés sur l'accueil et dans le pied
|
||||
de page) se règlent, eux, dans **Backend › Paramètres**.
|
||||
|
||||
## Développement local (sans Docker)
|
||||
|
||||
```bash
|
||||
@@ -88,6 +217,7 @@ npm run dev # http://localhost:3000
|
||||
| `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 |
|
||||
| `npm test` | Tests de sécurité (filtre XSS, limitation de connexion, chiffrement) |
|
||||
|
||||
## Variables d'environnement
|
||||
|
||||
@@ -98,6 +228,11 @@ Voir [`.env.example`](./.env.example). Les principales :
|
||||
- `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
|
||||
- `NEXT_PUBLIC_SITE_URL` — repli pour l'URL publique du site ; elle se règle
|
||||
normalement dans **Backend › Réseaux sociaux**, aucune variable n'est requise.
|
||||
- `SOCIAL_TOKEN_KEY` — clé de chiffrement des jetons réseaux (défaut : `AUTH_SECRET`)
|
||||
- `FACEBOOK_PAGE_ID` / `FACEBOOK_PAGE_ACCESS_TOKEN`, `LINKEDIN_ORGANIZATION_URN` /
|
||||
`LINKEDIN_ACCESS_TOKEN` — repli si aucun compte n'est connecté via le backoffice
|
||||
|
||||
## Note sur le logo
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Plein R — variante « VPS sans reverse proxy existant ».
|
||||
#
|
||||
# Ajoute Caddy devant l'application : il écoute en 80 et 443, obtient et
|
||||
# renouvelle tout seul le certificat Let's Encrypt pour SITE_DOMAIN, et
|
||||
# redirige automatiquement HTTP vers HTTPS.
|
||||
#
|
||||
# Utilisation : dans .env, poser
|
||||
# COMPOSE_FILE=docker-compose.yml:docker-compose.caddy.yml
|
||||
# SITE_DOMAIN=pleinr.example.fr
|
||||
# CADDY_EMAIL=vous@example.fr
|
||||
# AUTH_URL=https://pleinr.example.fr
|
||||
# puis `docker compose up -d --build` comme d'habitude.
|
||||
#
|
||||
# Pré-requis : le DNS du domaine pointe vers le VPS et les ports 80/443 sont
|
||||
# ouverts dans le pare-feu.
|
||||
|
||||
services:
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
container_name: pleinr-caddy
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- app
|
||||
environment:
|
||||
SITE_DOMAIN: ${SITE_DOMAIN:?SITE_DOMAIN manquant dans .env}
|
||||
CADDY_EMAIL: ${CADDY_EMAIL:?CADDY_EMAIL manquant dans .env (alertes d expiration Let's Encrypt)}
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "443:443/udp"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
networks:
|
||||
- proxy
|
||||
|
||||
app:
|
||||
# L'application n'est plus publiée directement sur l'hôte : tout passe
|
||||
# par Caddy (sinon http://IP:8413 contournerait le HTTPS).
|
||||
ports: !reset []
|
||||
|
||||
volumes:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
|
||||
networks:
|
||||
# Sans Nginx Proxy Manager, personne ne crée ce réseau : Compose s'en charge.
|
||||
proxy:
|
||||
external: false
|
||||
+22
-3
@@ -16,8 +16,9 @@ services:
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
ports:
|
||||
# Host port rarely used (container stays on 5432). Change the left side if needed.
|
||||
- "54329:5432"
|
||||
# Lié à 127.0.0.1 : la base ne doit jamais être joignable depuis le réseau.
|
||||
# Retirez le préfixe uniquement pour un accès distant volontaire (et protégé).
|
||||
- "127.0.0.1:54329:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-pleinr} -d ${POSTGRES_DB:-pleinr}"]
|
||||
interval: 5s
|
||||
@@ -47,9 +48,26 @@ services:
|
||||
AUTH_URL: ${AUTH_URL:-}
|
||||
# Lets Auth.js trust the X-Forwarded-* headers from the proxy.
|
||||
AUTH_TRUST_HOST: "true"
|
||||
# URL publique, reprise dans le texte des posts Facebook / LinkedIn.
|
||||
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-}
|
||||
# Chiffrement des jetons réseaux stockés en base (à défaut : AUTH_SECRET).
|
||||
SOCIAL_TOKEN_KEY: ${SOCIAL_TOKEN_KEY:-}
|
||||
# Repli historique : la configuration normale se fait dans le backoffice.
|
||||
FACEBOOK_PAGE_ID: ${FACEBOOK_PAGE_ID:-}
|
||||
FACEBOOK_PAGE_ACCESS_TOKEN: ${FACEBOOK_PAGE_ACCESS_TOKEN:-}
|
||||
FACEBOOK_GRAPH_VERSION: ${FACEBOOK_GRAPH_VERSION:-}
|
||||
LINKEDIN_ORGANIZATION_URN: ${LINKEDIN_ORGANIZATION_URN:-}
|
||||
LINKEDIN_ORGANIZATION_ID: ${LINKEDIN_ORGANIZATION_ID:-}
|
||||
LINKEDIN_ACCESS_TOKEN: ${LINKEDIN_ACCESS_TOKEN:-}
|
||||
LINKEDIN_API_VERSION: ${LINKEDIN_API_VERSION:-}
|
||||
SEED_ON_START: ${SEED_ON_START:-true}
|
||||
SEED_ADMIN_EMAIL: ${SEED_ADMIN_EMAIL:-admin@plein-r.fr}
|
||||
SEED_ADMIN_PASSWORD: ${SEED_ADMIN_PASSWORD:-changeme123}
|
||||
# Vide = un mot de passe aléatoire est généré au premier démarrage et
|
||||
# affiché une seule fois dans les journaux du conteneur.
|
||||
SEED_ADMIN_PASSWORD: ${SEED_ADMIN_PASSWORD:-}
|
||||
# Données de démonstration (adhérents, promotions, comptes au mot de
|
||||
# passe connu) : jamais en production. Base vide par défaut.
|
||||
SEED_DEMO: ${SEED_DEMO:-false}
|
||||
SEED_ADMIN_NAME: ${SEED_ADMIN_NAME:-Administrateur Plein R}
|
||||
ports:
|
||||
# Host port rarely used (container stays on 3000). App reachable at http://HOST:8413
|
||||
@@ -75,5 +93,6 @@ networks:
|
||||
proxy:
|
||||
# The reverse proxy's existing network. Must already exist (created by the
|
||||
# NPM/nginx stack). Override the name with PROXY_NETWORK if it differs.
|
||||
# Sans proxy existant : voir docker-compose.caddy.yml (README).
|
||||
external: true
|
||||
name: ${PROXY_NETWORK:-nginx_default}
|
||||
@@ -0,0 +1,66 @@
|
||||
CREATE TABLE "image_consents" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"member_id" integer NOT NULL,
|
||||
"decision" varchar(20) NOT NULL,
|
||||
"scopes" text,
|
||||
"signatory_name" varchar(200),
|
||||
"signature_png" text,
|
||||
"consent_version" varchar(40) NOT NULL,
|
||||
"ip" varchar(120),
|
||||
"user_agent" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "meeting_registrations" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"meeting_id" integer NOT NULL,
|
||||
"member_id" integer,
|
||||
"attendee_name" varchar(200) NOT NULL,
|
||||
"attendee_email" varchar(200),
|
||||
"image_consent" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "meetings" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"title" varchar(200) NOT NULL,
|
||||
"starts_at" timestamp with time zone NOT NULL,
|
||||
"location" varchar(240),
|
||||
"description" text,
|
||||
"capacity" integer DEFAULT 30 NOT NULL,
|
||||
"image_url" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "past_meeting_photos" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"past_meeting_id" integer NOT NULL,
|
||||
"image_url" text NOT NULL,
|
||||
"caption" varchar(200),
|
||||
"position" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "past_meetings" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"title" varchar(200) NOT NULL,
|
||||
"event_date" timestamp with time zone NOT NULL,
|
||||
"location" varchar(240),
|
||||
"description" text,
|
||||
"participants" text,
|
||||
"meeting_id" integer,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "site_settings" (
|
||||
"key" varchar(120) PRIMARY KEY NOT NULL,
|
||||
"value" text,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "image_consents" ADD CONSTRAINT "image_consents_member_id_members_id_fk" FOREIGN KEY ("member_id") REFERENCES "public"."members"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "meeting_registrations" ADD CONSTRAINT "meeting_registrations_meeting_id_meetings_id_fk" FOREIGN KEY ("meeting_id") REFERENCES "public"."meetings"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "meeting_registrations" ADD CONSTRAINT "meeting_registrations_member_id_members_id_fk" FOREIGN KEY ("member_id") REFERENCES "public"."members"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "past_meeting_photos" ADD CONSTRAINT "past_meeting_photos_past_meeting_id_past_meetings_id_fk" FOREIGN KEY ("past_meeting_id") REFERENCES "public"."past_meetings"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "past_meetings" ADD CONSTRAINT "past_meetings_meeting_id_meetings_id_fk" FOREIGN KEY ("meeting_id") REFERENCES "public"."meetings"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "meeting_registrations_meeting_member_idx" ON "meeting_registrations" USING btree ("meeting_id","member_id");
|
||||
@@ -0,0 +1,11 @@
|
||||
DROP INDEX "meeting_registrations_meeting_member_idx";--> statement-breakpoint
|
||||
ALTER TABLE "meeting_registrations" ADD COLUMN "attendee_company" varchar(200) DEFAULT '' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "meeting_registrations" ADD COLUMN "attendee_phone" varchar(40);--> statement-breakpoint
|
||||
ALTER TABLE "meeting_registrations" ADD COLUMN "status" varchar(20) DEFAULT 'pending' NOT NULL;--> statement-breakpoint
|
||||
UPDATE "meeting_registrations" AS r
|
||||
SET "attendee_company" = m."name", "attendee_phone" = m."phone", "status" = 'confirmed'
|
||||
FROM "members" AS m
|
||||
WHERE r."member_id" = m."id";--> statement-breakpoint
|
||||
ALTER TABLE "meetings" ADD COLUMN "participants_per_account" integer DEFAULT 1 NOT NULL;--> statement-breakpoint
|
||||
CREATE INDEX "meeting_registrations_meeting_idx" ON "meeting_registrations" USING btree ("meeting_id");--> statement-breakpoint
|
||||
CREATE INDEX "meeting_registrations_member_meeting_idx" ON "meeting_registrations" USING btree ("member_id","meeting_id");
|
||||
@@ -0,0 +1,23 @@
|
||||
CREATE TYPE "public"."promo_suspended_by" AS ENUM('member', 'staff');--> statement-breakpoint
|
||||
CREATE TYPE "public"."social_network" AS ENUM('facebook', 'linkedin');--> statement-breakpoint
|
||||
CREATE TYPE "public"."social_post_status" AS ENUM('posted', 'failed');--> statement-breakpoint
|
||||
ALTER TYPE "public"."promo_status" ADD VALUE 'suspended';--> statement-breakpoint
|
||||
CREATE TABLE "social_posts" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"promotion_id" integer NOT NULL,
|
||||
"network" "social_network" NOT NULL,
|
||||
"status" "social_post_status" NOT NULL,
|
||||
"external_id" varchar(200),
|
||||
"url" text,
|
||||
"error" text,
|
||||
"posted_by_id" integer,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "promotions" ADD COLUMN "suspended_by" "promo_suspended_by";--> statement-breakpoint
|
||||
ALTER TABLE "promotions" ADD COLUMN "suspended_by_id" integer;--> statement-breakpoint
|
||||
ALTER TABLE "promotions" ADD COLUMN "suspended_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "social_posts" ADD CONSTRAINT "social_posts_promotion_id_promotions_id_fk" FOREIGN KEY ("promotion_id") REFERENCES "public"."promotions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "social_posts" ADD CONSTRAINT "social_posts_posted_by_id_users_id_fk" FOREIGN KEY ("posted_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "social_posts_promotion_idx" ON "social_posts" USING btree ("promotion_id");--> statement-breakpoint
|
||||
ALTER TABLE "promotions" ADD CONSTRAINT "promotions_suspended_by_id_users_id_fk" FOREIGN KEY ("suspended_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "promotions" ADD COLUMN "share_facebook" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "promotions" ADD COLUMN "share_linkedin" boolean DEFAULT false NOT NULL;
|
||||
@@ -0,0 +1,17 @@
|
||||
CREATE TABLE "social_accounts" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"network" "social_network" NOT NULL,
|
||||
"app_id" varchar(200) NOT NULL,
|
||||
"app_secret" text NOT NULL,
|
||||
"access_token" text,
|
||||
"refresh_token" text,
|
||||
"expires_at" timestamp with time zone,
|
||||
"target_id" varchar(200),
|
||||
"target_name" varchar(200),
|
||||
"connected_by_id" integer,
|
||||
"connected_at" timestamp with time zone,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "social_accounts_network_unique" UNIQUE("network")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "social_accounts" ADD CONSTRAINT "social_accounts_connected_by_id_users_id_fk" FOREIGN KEY ("connected_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Texte de mission enrichi sur la page Association.
|
||||
--
|
||||
-- La clause sur `value` est volontaire : le texte n'est remplacé que s'il est
|
||||
-- resté celui d'origine. Une mission déjà réécrite depuis Paramètres n'est
|
||||
-- jamais écrasée. Les « apports » n'ont pas de ligne en base tant qu'ils n'ont
|
||||
-- pas été édités : leur valeur par défaut s'applique d'elle-même.
|
||||
UPDATE "site_settings"
|
||||
SET "value" = 'Plein R fédère les commerçants, artisans et entreprises du Bassin de Pompey autour d''une conviction simple : on va plus loin ensemble. L''association crée les occasions de se rencontrer, de se connaître et de travailler les uns avec les autres.
|
||||
|
||||
Nous mettons chaque adhérent en avant auprès des habitants comme des autres professionnels du territoire : une fiche dans l''annuaire, des bons plans relayés sur le site et sur nos réseaux sociaux, une place dans nos rencontres et nos publications.
|
||||
|
||||
L''objectif est double : amener des clients aux commerces de proximité, et faire naître des courants d''affaires entre les entreprises du bassin.',
|
||||
"updated_at" = now()
|
||||
WHERE "key" = 'association_mission'
|
||||
AND "value" = 'Créer du lien entre adhérents, encourager les échanges de proximité et porter une dynamique collective au service du Bassin de Pompey.';
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "users" ADD COLUMN "session_version" integer DEFAULT 0 NOT NULL;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "users" DROP COLUMN "temp_password";
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -29,6 +29,62 @@
|
||||
"when": 1782545608919,
|
||||
"tag": "0003_outstanding_jimmy_woo",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1784303079565,
|
||||
"tag": "0004_tiny_vanisher",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1784339279276,
|
||||
"tag": "0005_round_exiles",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1784930674056,
|
||||
"tag": "0006_tiresome_clea",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1784931989372,
|
||||
"tag": "0007_foamy_vindicator",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1784933339220,
|
||||
"tag": "0008_neat_makkari",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1784968500537,
|
||||
"tag": "0009_texte_mission",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1784972681534,
|
||||
"tag": "0010_woozy_luminals",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1788512273181,
|
||||
"tag": "0011_drop_temp_password",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,10 +2,39 @@
|
||||
const nextConfig = {
|
||||
output: "standalone",
|
||||
reactStrictMode: true,
|
||||
// Pas d'en-tête X-Powered-By : inutile de renseigner un attaquant sur la pile.
|
||||
poweredByHeader: false,
|
||||
experimental: {
|
||||
// Les images d'entête/logo sont envoyées en data-URL via server action.
|
||||
serverActions: { bodySizeLimit: "4mb" },
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/:path*",
|
||||
headers: [
|
||||
// HSTS : une fois le site vu en HTTPS, le navigateur refuse le HTTP
|
||||
// clair pendant un an. Sans effet tant que le site est servi en HTTP.
|
||||
{
|
||||
key: "Strict-Transport-Security",
|
||||
value: "max-age=31536000; includeSubDomains",
|
||||
},
|
||||
// Empêche l'interprétation d'une réponse selon un type deviné.
|
||||
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||
// Le site ne doit pas être encadré par un tiers (clickjacking).
|
||||
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
|
||||
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||
{
|
||||
key: "Permissions-Policy",
|
||||
value: "camera=(), microphone=(), geolocation=(), interest-cohort=()",
|
||||
},
|
||||
// La CSP complète est posée par le middleware : elle a besoin d'un
|
||||
// nonce différent à chaque requête, ce qu'une valeur statique ne peut
|
||||
// pas fournir.
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+417
-6000
File diff suppressed because it is too large.
Load diff
+14
-8
@@ -10,16 +10,17 @@
|
||||
"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"
|
||||
"db:purge-demo": "tsx src/db/purge-demo.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 && esbuild src/db/purge-demo.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/purge-demo.cjs --external:pg-native",
|
||||
"test": "tsx --test tests/**/*.test.ts"
|
||||
},
|
||||
"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",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"next": "^15.5.25",
|
||||
"next-auth": "^5.0.0-beta.32",
|
||||
"pg": "^8.23.0",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"zod": "^3.24.1"
|
||||
@@ -30,9 +31,14 @@
|
||||
"@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",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"esbuild": "^0.28.1",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"overrides": {
|
||||
"postcss": "^8.5.23",
|
||||
"sharp": "^0.35.3",
|
||||
"esbuild": "^0.28.1"
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,15 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { db } from "@/db";
|
||||
import { activityLog, contactMessages, membershipRequests } from "@/db/schema";
|
||||
import { sanitizeActivityMessage } from "@/lib/activity";
|
||||
|
||||
export type SubmitResult = { ok: boolean; error?: string };
|
||||
|
||||
const isEmail = (v: string) => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v);
|
||||
|
||||
async function logActivity(message: string, dot: string) {
|
||||
await db.insert(activityLog).values({ message, dot });
|
||||
// Saisie publique non authentifiée : filtrage obligatoire.
|
||||
await db.insert(activityLog).values({ message: sanitizeActivityMessage(message), dot });
|
||||
}
|
||||
|
||||
// ---- Demande pour devenir membre (public, sans authentification) ----
|
||||
|
||||
+196
-44
@@ -6,7 +6,24 @@ import { SiteHeader } from "@/components/SiteHeader";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
import { VitrineImage } from "@/components/VitrineImage";
|
||||
import { PromoImage } from "@/components/PromoImage";
|
||||
import { JsonLd } from "@/components/JsonLd";
|
||||
import { getMemberLivePromotions, getPublicMember } from "@/lib/queries";
|
||||
import {
|
||||
NOINDEX,
|
||||
breadcrumbJsonLd,
|
||||
localBusinessJsonLd,
|
||||
memberPath,
|
||||
metaDescription,
|
||||
pageMetadata,
|
||||
} from "@/lib/seo";
|
||||
import { publicBaseUrl } from "@/lib/seo-server";
|
||||
import {
|
||||
formatSlots,
|
||||
getOpenStatus,
|
||||
normalizeWebsite,
|
||||
parseMemberHours,
|
||||
websiteLabel,
|
||||
} from "@/lib/member-profile";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -31,15 +48,80 @@ function initialsOf(name: string) {
|
||||
return name.split(/\s+/).map((w) => w[0]).join("").slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
type ContactIconName = "phone" | "mail" | "website" | "pin" | "clock" | "chat";
|
||||
|
||||
function ContactIcon({ name }: { name: ContactIconName }) {
|
||||
const paths: Record<ContactIconName, React.ReactNode> = {
|
||||
phone: <path d="M7.4 3.8 9 7.2 7.2 8.6a14.4 14.4 0 0 0 6.2 6.2L14.8 13l3.4 1.6-.8 3.6c-.2.9-1 1.5-1.9 1.5C8.2 19.7 2.3 13.8 2.3 6.5c0-.9.6-1.7 1.5-1.9l3.6-.8Z" />,
|
||||
mail: <><rect x="2.5" y="4.5" width="19" height="15" rx="2.5" /><path d="m4 6 8 6 8-6" /></>,
|
||||
website: <><circle cx="12" cy="12" r="9.5" /><path d="M2.8 12h18.4M12 2.5c2.4 2.6 3.6 5.8 3.6 9.5S14.4 18.9 12 21.5M12 2.5C9.6 5.1 8.4 8.3 8.4 12s1.2 6.9 3.6 9.5" /></>,
|
||||
pin: <><path d="M20 10c0 5.5-8 11.5-8 11.5S4 15.5 4 10a8 8 0 1 1 16 0Z" /><circle cx="12" cy="10" r="2.5" /></>,
|
||||
clock: <><circle cx="12" cy="12" r="9.5" /><path d="M12 6.6V12l3.6 2.4" /></>,
|
||||
chat: <><path d="M20.5 12.6c0 3.9-3.8 7.1-8.5 7.1-.9 0-1.8-.1-2.6-.3l-5.4 1.7 1.7-4.2a6.6 6.6 0 0 1-2.2-4.8c0-3.9 3.8-7.1 8.5-7.1s8.5 3.2 8.5 7.1Z" /><path d="M8.8 11.9h.01M12 11.9h.01M15.2 11.9h.01" /></>,
|
||||
};
|
||||
return (
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
{paths[name]}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ContactAction({
|
||||
href,
|
||||
icon,
|
||||
label,
|
||||
detail,
|
||||
external = false,
|
||||
}: {
|
||||
href: string;
|
||||
icon: ContactIconName;
|
||||
label: string;
|
||||
detail: string;
|
||||
external?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="contact-action"
|
||||
target={external ? "_blank" : undefined}
|
||||
rel={external ? "noopener noreferrer" : undefined}
|
||||
>
|
||||
<span className="contact-action__icon"><ContactIcon name={icon} /></span>
|
||||
<span className="contact-action__copy">
|
||||
<strong>{label}</strong>
|
||||
<span>{detail}</span>
|
||||
</span>
|
||||
<span className="contact-action__arrow" aria-hidden="true">{external ? "↗" : "→"}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/** Titre de la fiche : « Nom · Catégorie à Ville », le plus parlant en résultat de recherche. */
|
||||
function memberTitle(member: { name: string; categoryLabel: string | null; city: string | null }) {
|
||||
const where = member.city ? ` à ${member.city}` : " · Bassin de Pompey";
|
||||
return member.categoryLabel ? `${member.name} · ${member.categoryLabel}${where}` : `${member.name}${where}`;
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
const member = await getPublicMember(Number(id));
|
||||
if (!member) return { title: "Adhérent — Plein R" };
|
||||
return { title: `${member.name} — Plein R`, description: member.description ?? undefined };
|
||||
const memberId = Number(id);
|
||||
const member = memberId ? await getPublicMember(memberId) : null;
|
||||
if (!member || member.status !== "active") {
|
||||
return { title: "Adhérent introuvable", robots: NOINDEX };
|
||||
}
|
||||
const fallback =
|
||||
`${member.name}, ${member.categoryLabel ? `${member.categoryLabel.toLowerCase()} ` : ""}` +
|
||||
`adhérent Plein R${member.city ? ` à ${member.city}` : " sur le Bassin de Pompey"} : coordonnées, horaires et bons plans.`;
|
||||
return pageMetadata({
|
||||
title: memberTitle(member),
|
||||
description: metaDescription(member.description, fallback),
|
||||
path: memberPath(member.id),
|
||||
ogType: "profile",
|
||||
});
|
||||
}
|
||||
|
||||
export default async function FicheAdherentPage({
|
||||
@@ -54,7 +136,7 @@ export default async function FicheAdherentPage({
|
||||
const member = await getPublicMember(memberId);
|
||||
if (!member || member.status !== "active") notFound();
|
||||
|
||||
const promos = await getMemberLivePromotions(memberId);
|
||||
const [promos, baseUrl] = await Promise.all([getMemberLivePromotions(memberId), publicBaseUrl()]);
|
||||
const accent = member.accent ?? "#E0A63C";
|
||||
|
||||
const fullAddress = [member.address, [member.postalCode, member.city].filter(Boolean).join(" ")]
|
||||
@@ -65,10 +147,11 @@ export default async function FicheAdherentPage({
|
||||
);
|
||||
const paragraphs = (member.description ?? "").split("\n").map((s) => s.trim()).filter(Boolean);
|
||||
const tags = (member.tags ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
||||
const hours = (member.hours ?? "")
|
||||
.split("\n")
|
||||
.map((line) => line.split("|").map((s) => s.trim()))
|
||||
.filter((p) => p[0]);
|
||||
const hours = parseMemberHours(member.hours);
|
||||
const openStatus = getOpenStatus(hours);
|
||||
const normalizedWebsite = normalizeWebsite(member.website);
|
||||
const shortWebsite = websiteLabel(member.website);
|
||||
const phoneHref = member.phone ? `tel:${member.phone.replace(/[^\d+]/g, "")}` : null;
|
||||
|
||||
const sectionCard: React.CSSProperties = {
|
||||
background: "#fff",
|
||||
@@ -86,15 +169,25 @@ export default async function FicheAdherentPage({
|
||||
return (
|
||||
<div style={{ background: "#F6F2E8", minHeight: "100vh", fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
|
||||
<SiteHeader active="annuaire" />
|
||||
<JsonLd
|
||||
data={[
|
||||
breadcrumbJsonLd(baseUrl, [
|
||||
{ name: "Accueil", path: "/" },
|
||||
{ name: "Annuaire", path: "/annuaire" },
|
||||
{ name: member.name, path: memberPath(member.id) },
|
||||
]),
|
||||
localBusinessJsonLd(baseUrl, member, hours, normalizedWebsite),
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="container" style={{ paddingBottom: 56 }}>
|
||||
<main className="container" style={{ paddingBottom: 56 }}>
|
||||
{/* breadcrumb */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 13.5, color: "#9a8d72", padding: "6px 0 18px", flexWrap: "wrap" }}>
|
||||
<nav aria-label="Fil d'Ariane" style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 13.5, color: "#9a8d72", padding: "6px 0 18px", flexWrap: "wrap" }}>
|
||||
<Link href="/annuaire" style={{ textDecoration: "none", color: "#9a6638", fontWeight: 600 }}>Annuaire</Link>
|
||||
{member.categoryLabel && (<><span>›</span><span style={{ color: "#9a8d72" }}>{member.categoryLabel}</span></>)}
|
||||
<span>›</span>
|
||||
<span style={{ color: "#3c3322", fontWeight: 600 }}>{member.name}</span>
|
||||
</div>
|
||||
<span style={{ color: "#3c3322", fontWeight: 600 }} aria-current="page">{member.name}</span>
|
||||
</nav>
|
||||
|
||||
{/* cover + identity */}
|
||||
<section style={{ position: "relative", borderRadius: 22, overflow: "hidden", border: "1px solid #e6dcc6" }}>
|
||||
@@ -149,11 +242,14 @@ export default async function FicheAdherentPage({
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 10, paddingBottom: 4, flexWrap: "wrap" }}>
|
||||
{member.phone && (
|
||||
<a href={`tel:${member.phone.replace(/\s/g, "")}`} className="font-display" style={{ textDecoration: "none", background: "#2C6FB3", color: "#fff", fontWeight: 700, fontSize: 14, padding: "11px 18px", borderRadius: 11 }}>Appeler</a>
|
||||
{phoneHref && (
|
||||
<a href={phoneHref} className="font-display profile-hero-action profile-hero-action--primary">Appeler</a>
|
||||
)}
|
||||
{normalizedWebsite && (
|
||||
<a href={normalizedWebsite} target="_blank" rel="noopener noreferrer" className="font-display profile-hero-action">Site web ↗</a>
|
||||
)}
|
||||
{mapQuery && (
|
||||
<a href={`https://www.google.com/maps/dir/?api=1&destination=${mapQuery}`} target="_blank" rel="noopener" className="font-display" style={{ textDecoration: "none", background: "#fff", border: "1px solid #d8cdb4", color: "#3c3322", fontWeight: 700, fontSize: 14, padding: "11px 18px", borderRadius: 11 }}>Itinéraire</a>
|
||||
<a href={`https://www.google.com/maps/dir/?api=1&destination=${mapQuery}`} target="_blank" rel="noopener noreferrer" className="font-display profile-hero-action">Itinéraire</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -186,44 +282,90 @@ export default async function FicheAdherentPage({
|
||||
</section>
|
||||
|
||||
{/* practical info */}
|
||||
<section style={sectionCard}>
|
||||
<h2 className="font-display" style={{ ...h2, marginBottom: 16 }}>Informations pratiques</h2>
|
||||
<div className="grid grid-2" style={{ gap: 22 }}>
|
||||
<section className="practical-section">
|
||||
<div className="practical-section__heading">
|
||||
<div>
|
||||
<div style={{ fontSize: 12.5, letterSpacing: "0.06em", textTransform: "uppercase", color: "#a99c82", fontWeight: 700, marginBottom: 10 }}>Horaires</div>
|
||||
<span className="eyebrow">Préparer votre visite</span>
|
||||
<h2 className="font-display" style={{ ...h2, margin: "10px 0 0" }}>Informations pratiques</h2>
|
||||
</div>
|
||||
{openStatus && (
|
||||
<div className={`open-status${openStatus.isOpen ? " is-open" : ""}`}>
|
||||
<span />
|
||||
<div>
|
||||
<strong>{openStatus.label}</strong>
|
||||
{openStatus.detail && <small>{openStatus.detail}</small>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="practical-grid">
|
||||
<section className="schedule-card" aria-labelledby="schedule-title">
|
||||
<div className="practical-card-title">
|
||||
<span className="practical-card-title__icon" aria-hidden="true"><ContactIcon name="clock" /></span>
|
||||
<div>
|
||||
<span>Cette semaine</span>
|
||||
<h3 id="schedule-title" className="font-display">Horaires d'ouverture</h3>
|
||||
</div>
|
||||
</div>
|
||||
{hours.length > 0 ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 7, fontSize: 14 }}>
|
||||
{hours.map(([label, range], i) => {
|
||||
const closed = (range ?? "").toLowerCase().includes("ferm");
|
||||
<div className="schedule-list">
|
||||
{hours.map((day) => {
|
||||
const isToday = openStatus?.todayKey === day.key;
|
||||
return (
|
||||
<div key={i} style={{ display: "flex", justifyContent: "space-between", gap: 12 }}>
|
||||
<span style={{ color: "#6c6150" }}>{label}</span>
|
||||
<span style={{ color: closed ? "#c0392b" : "#3c3322", fontWeight: 600 }}>{range}</span>
|
||||
<div
|
||||
key={day.key}
|
||||
className={`schedule-row${isToday ? " is-today" : ""}`}
|
||||
aria-current={isToday ? "date" : undefined}
|
||||
>
|
||||
<span className="schedule-row__day">
|
||||
{day.label}
|
||||
{isToday && <small>Aujourd'hui</small>}
|
||||
</span>
|
||||
<strong className={day.closed ? "is-closed" : undefined}>
|
||||
{day.closed ? "Fermé" : formatSlots(day.slots)}
|
||||
</strong>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: 14, color: "#a99c82" }}>Horaires non communiqués.</div>
|
||||
<div className="practical-empty">Horaires non communiqués.</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12.5, letterSpacing: "0.06em", textTransform: "uppercase", color: "#a99c82", fontWeight: 700, marginBottom: 10 }}>Contact</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 9, fontSize: 14, color: "#5e5444" }}>
|
||||
{member.phone && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 9 }}><span style={{ width: 7, height: 7, borderRadius: "50%", background: "#E0A63C" }} />{member.phone}</div>
|
||||
</section>
|
||||
|
||||
<section className="contact-card" aria-labelledby="contact-title">
|
||||
<div className="practical-card-title">
|
||||
<span className="practical-card-title__icon practical-card-title__icon--blue" aria-hidden="true"><ContactIcon name="chat" /></span>
|
||||
<div>
|
||||
<span>Contact direct</span>
|
||||
<h3 id="contact-title" className="font-display">Joindre l'établissement</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="contact-actions">
|
||||
{phoneHref && member.phone && (
|
||||
<ContactAction href={phoneHref} icon="phone" label="Appeler" detail={member.phone} />
|
||||
)}
|
||||
{member.email && (
|
||||
<a href={`mailto:${member.email}`} style={{ display: "flex", alignItems: "center", gap: 9, color: "#5e5444", textDecoration: "none" }}><span style={{ width: 7, height: 7, borderRadius: "50%", background: "#6FB0C6" }} />{member.email}</a>
|
||||
<ContactAction href={`mailto:${member.email}`} icon="mail" label="Envoyer un e-mail" detail={member.email} />
|
||||
)}
|
||||
{member.website && (
|
||||
<a href={member.website} target="_blank" rel="noopener" style={{ display: "flex", alignItems: "center", gap: 9, color: "#2C6FB3", textDecoration: "none", fontWeight: 600 }}><span style={{ width: 7, height: 7, borderRadius: "50%", background: "#2C6FB3" }} />{member.website.replace(/^https?:\/\//, "")}</a>
|
||||
{normalizedWebsite && shortWebsite && (
|
||||
<ContactAction href={normalizedWebsite} icon="website" label="Visiter le site" detail={shortWebsite} external />
|
||||
)}
|
||||
{!member.phone && !member.email && !member.website && (
|
||||
<div style={{ color: "#a99c82" }}>Coordonnées non communiquées.</div>
|
||||
{mapQuery && fullAddress && (
|
||||
<ContactAction
|
||||
href={`https://www.google.com/maps/dir/?api=1&destination=${mapQuery}`}
|
||||
icon="pin"
|
||||
label="Itinéraire"
|
||||
detail={fullAddress}
|
||||
external
|
||||
/>
|
||||
)}
|
||||
{!phoneHref && !member.email && !normalizedWebsite && !fullAddress && (
|
||||
<div className="practical-empty">Coordonnées non communiquées.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -287,14 +429,24 @@ export default async function FicheAdherentPage({
|
||||
</section>
|
||||
|
||||
{/* contact CTA */}
|
||||
<section style={{ background: "linear-gradient(120deg,#13324F,#1d4a72)", borderRadius: 18, padding: 22, color: "#fff" }}>
|
||||
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 17, margin: "0 0 6px" }}>Une question pour ce commerçant ?</h3>
|
||||
<p style={{ margin: "0 0 14px", fontSize: 13.5, color: "#bcd3e6" }}>Contactez {member.name} directement.</p>
|
||||
<a href={member.email ? `mailto:${member.email}` : "#"} className="font-display" style={{ textDecoration: "none", display: "block", textAlign: "center", background: "#E0A63C", color: "#33291D", fontWeight: 700, fontSize: 14.5, padding: 12, borderRadius: 11 }}>Envoyer un message</a>
|
||||
</section>
|
||||
{(member.email || phoneHref || normalizedWebsite) && (
|
||||
<section style={{ background: "linear-gradient(120deg,#13324F,#1d4a72)", borderRadius: 18, padding: 22, color: "#fff" }}>
|
||||
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 17, margin: "0 0 6px" }}>Une question pour ce commerçant ?</h3>
|
||||
<p style={{ margin: "0 0 14px", fontSize: 13.5, color: "#bcd3e6" }}>Contactez {member.name} directement.</p>
|
||||
<a
|
||||
href={member.email ? `mailto:${member.email}` : phoneHref ?? normalizedWebsite ?? undefined}
|
||||
target={!member.email && !phoneHref ? "_blank" : undefined}
|
||||
rel={!member.email && !phoneHref ? "noopener noreferrer" : undefined}
|
||||
className="font-display"
|
||||
style={{ textDecoration: "none", display: "block", textAlign: "center", background: "#E0A63C", color: "#33291D", fontWeight: 700, fontSize: 14.5, padding: 12, borderRadius: 11 }}
|
||||
>
|
||||
{member.email ? "Envoyer un message" : phoneHref ? "Appeler maintenant" : "Visiter le site"}
|
||||
</a>
|
||||
</section>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<SiteFooter />
|
||||
</div>
|
||||
|
||||
@@ -8,15 +8,21 @@ import {
|
||||
getAllCategories,
|
||||
getLiveBadgesByMember,
|
||||
} from "@/lib/queries";
|
||||
import { JsonLd } from "@/components/JsonLd";
|
||||
import { breadcrumbJsonLd, memberListJsonLd, pageMetadata } from "@/lib/seo";
|
||||
import { publicBaseUrl } from "@/lib/seo-server";
|
||||
import { AnnuaireClient, type DirectoryMember } from "./AnnuaireClient";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Annuaire — Plein R",
|
||||
// Canonique sans paramètre de recherche : /annuaire?q=… ne crée pas de doublon.
|
||||
export const metadata: Metadata = pageMetadata({
|
||||
title: "Annuaire des commerçants du Bassin de Pompey",
|
||||
description:
|
||||
"L'annuaire des commerçants et entreprises du Bassin de Pompey. Trouvez un professionnel et ses bons plans.",
|
||||
};
|
||||
"Trouvez un commerçant, artisan ou entreprise du Bassin de Pompey (Pompey, Frouard, Liverdun, " +
|
||||
"Champigneulles…) : coordonnées, horaires et bons plans.",
|
||||
path: "/annuaire",
|
||||
});
|
||||
|
||||
export default async function AnnuairePage({
|
||||
searchParams,
|
||||
@@ -24,10 +30,11 @@ export default async function AnnuairePage({
|
||||
searchParams: Promise<{ q?: string }>;
|
||||
}) {
|
||||
const { q } = await searchParams;
|
||||
const [rawMembers, categories, badges] = await Promise.all([
|
||||
const [rawMembers, categories, badges, baseUrl] = await Promise.all([
|
||||
getActiveMembersWithCategory(),
|
||||
getAllCategories(),
|
||||
getLiveBadgesByMember(),
|
||||
publicBaseUrl(),
|
||||
]);
|
||||
|
||||
const badgeByMember = new Map<number, string | null>();
|
||||
@@ -46,8 +53,17 @@ export default async function AnnuairePage({
|
||||
return (
|
||||
<div style={{ background: "#F6F2E8", minHeight: "100vh", fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
|
||||
<SiteHeader active="annuaire" logo />
|
||||
<JsonLd
|
||||
data={[
|
||||
breadcrumbJsonLd(baseUrl, [
|
||||
{ name: "Accueil", path: "/" },
|
||||
{ name: "Annuaire", path: "/annuaire" },
|
||||
]),
|
||||
memberListJsonLd(baseUrl, "Annuaire des adhérents Plein R", rawMembers),
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="container" style={{ paddingBottom: 56 }}>
|
||||
<main className="container" style={{ paddingBottom: 56 }}>
|
||||
{/* title block */}
|
||||
<section style={{ position: "relative", padding: "14px 0 22px", overflow: "hidden" }}>
|
||||
<Sparkle color="#E0A63C" size={18} style={{ top: 22, right: 30 }} duration={3.2} />
|
||||
@@ -113,7 +129,7 @@ export default async function AnnuairePage({
|
||||
style={{ border: "none", background: "#E0A63C", color: "#33291D", fontWeight: 700, fontSize: 15.5, padding: "14px 26px", borderRadius: 12, whiteSpace: "nowrap" }}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<SiteFooter />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { can } from "@/lib/rbac";
|
||||
import {
|
||||
exchangeCode,
|
||||
getDecryptedAppSecret,
|
||||
getSocialAccount,
|
||||
saveConnection,
|
||||
SOCIAL_NETWORKS,
|
||||
type SocialNetwork,
|
||||
} from "@/lib/social-accounts";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const SETTINGS = "/backend/reseaux";
|
||||
|
||||
function back(request: Request, params: Record<string, string>) {
|
||||
const url = new URL(SETTINGS, request.url);
|
||||
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
|
||||
const response = NextResponse.redirect(url);
|
||||
// Le state a fait son office, quel que soit le résultat.
|
||||
for (const network of SOCIAL_NETWORKS) response.cookies.delete(`plr_oauth_${network}`);
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ network: string }> }
|
||||
) {
|
||||
const session = await getSession();
|
||||
if (!can(session?.user.role, "manageSettings")) {
|
||||
return NextResponse.redirect(new URL("/backend", request.url));
|
||||
}
|
||||
|
||||
const { network: raw } = await params;
|
||||
const network = raw as SocialNetwork;
|
||||
if (!SOCIAL_NETWORKS.includes(network)) {
|
||||
return back(request, { error: "Réseau inconnu." });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const error = url.searchParams.get("error_description") ?? url.searchParams.get("error");
|
||||
if (error) {
|
||||
return back(request, { error: `Autorisation refusée : ${error}` });
|
||||
}
|
||||
|
||||
const code = url.searchParams.get("code");
|
||||
const state = url.searchParams.get("state");
|
||||
const expected = request.headers
|
||||
.get("cookie")
|
||||
?.split(";")
|
||||
.map((c) => c.trim())
|
||||
.find((c) => c.startsWith(`plr_oauth_${network}=`))
|
||||
?.split("=")[1];
|
||||
|
||||
if (!code || !state || !expected || state !== expected) {
|
||||
return back(request, { error: "Requête de retour invalide (state). Relancez la connexion." });
|
||||
}
|
||||
|
||||
const account = await getSocialAccount(network);
|
||||
const appSecret = await getDecryptedAppSecret(network);
|
||||
if (!account?.appId || !appSecret) {
|
||||
return back(request, { error: "Identifiants d'application introuvables." });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await exchangeCode(network, account.appId, appSecret, code);
|
||||
const userId = Number(session?.user.id);
|
||||
|
||||
// Une seule page administrée : on la sélectionne d'office. Sinon on laisse
|
||||
// choisir, en conservant le jeton utilisateur le temps de la sélection.
|
||||
if (result.targets.length === 1) {
|
||||
await saveConnection(network, result, result.targets[0], Number.isFinite(userId) ? userId : null);
|
||||
return back(request, { connected: network });
|
||||
}
|
||||
|
||||
// Plusieurs pages : on garde le jeton utilisateur, l'écran relistera les
|
||||
// cibles côté serveur. Rien de sensible ne transite par l'URL.
|
||||
await saveConnection(network, result, null, Number.isFinite(userId) ? userId : null);
|
||||
return back(request, { choose: network });
|
||||
} catch (caught) {
|
||||
const message = caught instanceof Error ? caught.message : "Échec de la connexion.";
|
||||
return back(request, { error: message.slice(0, 400) });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { can } from "@/lib/rbac";
|
||||
import {
|
||||
authorizeUrl,
|
||||
getDecryptedAppSecret,
|
||||
getSocialAccount,
|
||||
siteUrl,
|
||||
SOCIAL_NETWORKS,
|
||||
type SocialNetwork,
|
||||
} from "@/lib/social-accounts";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const SETTINGS = "/backend/reseaux";
|
||||
|
||||
function back(request: Request, error: string) {
|
||||
return NextResponse.redirect(new URL(`${SETTINGS}?error=${encodeURIComponent(error)}`, request.url));
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ network: string }> }
|
||||
) {
|
||||
const session = await getSession();
|
||||
if (!can(session?.user.role, "manageSettings")) {
|
||||
return NextResponse.redirect(new URL("/backend", request.url));
|
||||
}
|
||||
|
||||
const { network: raw } = await params;
|
||||
const network = raw as SocialNetwork;
|
||||
if (!SOCIAL_NETWORKS.includes(network)) {
|
||||
return back(request, "Réseau inconnu.");
|
||||
}
|
||||
|
||||
const base = await siteUrl();
|
||||
if (!base) {
|
||||
return back(
|
||||
request,
|
||||
"Renseignez d'abord l'URL publique du site : elle sert à construire l'adresse de retour."
|
||||
);
|
||||
}
|
||||
|
||||
const account = await getSocialAccount(network);
|
||||
const appSecret = await getDecryptedAppSecret(network);
|
||||
if (!account?.appId || !appSecret) {
|
||||
return back(request, "Enregistrez d'abord l'identifiant et le secret de l'application.");
|
||||
}
|
||||
|
||||
// `state` en cookie httpOnly : vérifié au retour pour écarter toute requête
|
||||
// de rappel forgée.
|
||||
const state = randomBytes(24).toString("hex");
|
||||
const response = NextResponse.redirect(await authorizeUrl(network, account.appId, state));
|
||||
response.cookies.set(`plr_oauth_${network}`, state, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: new URL(base).protocol === "https:",
|
||||
path: "/",
|
||||
maxAge: 600,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { and, asc, desc, eq, gte, inArray, sql } from "drizzle-orm";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/db";
|
||||
import { meetingRegistrations, meetings, pastMeetingPhotos, pastMeetings } from "@/db/schema";
|
||||
import { JsonLd } from "@/components/JsonLd";
|
||||
import { PastMeetingGallery } from "@/components/PastMeetingGallery";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
import { SiteHeader } from "@/components/SiteHeader";
|
||||
import {
|
||||
getSiteSettings,
|
||||
parseBoardMembers,
|
||||
parseParagraphs,
|
||||
parsePillars,
|
||||
splitLines,
|
||||
} from "@/lib/site-settings";
|
||||
import { breadcrumbJsonLd, eventJsonLd, pageMetadata } from "@/lib/seo";
|
||||
import { publicBaseUrl } from "@/lib/seo-server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const ASSOCIATION_TITLE = "Plein R, l'association des entreprises du Bassin de Pompey";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
...pageMetadata({
|
||||
title: ASSOCIATION_TITLE,
|
||||
ogTitle: ASSOCIATION_TITLE,
|
||||
description:
|
||||
"Plein R fédère commerçants, artisans et entreprises du Bassin de Pompey : mission, équipe, " +
|
||||
"prochaines rencontres. Rejoignez le réseau économique local.",
|
||||
path: "/association",
|
||||
}),
|
||||
// Le nom de l'association est déjà dans le titre : pas de suffixe « · Plein R ».
|
||||
title: { absolute: ASSOCIATION_TITLE },
|
||||
};
|
||||
|
||||
function formatDate(date: Date) {
|
||||
return new Intl.DateTimeFormat("fr-FR", {
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function formatTime(date: Date) {
|
||||
return new Intl.DateTimeFormat("fr-FR", { hour: "2-digit", minute: "2-digit" }).format(date);
|
||||
}
|
||||
|
||||
export default async function AssociationPage() {
|
||||
const [settings, session, baseUrl] = await Promise.all([getSiteSettings(), auth(), publicBaseUrl()]);
|
||||
const now = new Date();
|
||||
const memberId = session?.user.memberId ?? null;
|
||||
|
||||
const upcoming = await db
|
||||
.select({
|
||||
id: meetings.id,
|
||||
title: meetings.title,
|
||||
startsAt: meetings.startsAt,
|
||||
location: meetings.location,
|
||||
description: meetings.description,
|
||||
capacity: meetings.capacity,
|
||||
participantsPerAccount: meetings.participantsPerAccount,
|
||||
imageUrl: meetings.imageUrl,
|
||||
registered: sql<number>`count(${meetingRegistrations.id})`,
|
||||
})
|
||||
.from(meetings)
|
||||
.leftJoin(meetingRegistrations, eq(meetingRegistrations.meetingId, meetings.id))
|
||||
.where(gte(meetings.startsAt, now))
|
||||
.groupBy(meetings.id)
|
||||
.orderBy(asc(meetings.startsAt));
|
||||
|
||||
const past = await db.select().from(pastMeetings).orderBy(desc(pastMeetings.eventDate)).limit(3);
|
||||
const pastIds = past.map((meeting) => meeting.id);
|
||||
const linkedMeetingIds = past.map((meeting) => meeting.meetingId).filter((id): id is number => id !== null);
|
||||
const [pastPhotos, linkedRegistrations] = await Promise.all([
|
||||
pastIds.length > 0
|
||||
? db
|
||||
.select()
|
||||
.from(pastMeetingPhotos)
|
||||
.where(inArray(pastMeetingPhotos.pastMeetingId, pastIds))
|
||||
.orderBy(asc(pastMeetingPhotos.position), asc(pastMeetingPhotos.id))
|
||||
: [],
|
||||
linkedMeetingIds.length > 0
|
||||
? db
|
||||
.select({ meetingId: meetingRegistrations.meetingId, id: meetingRegistrations.id })
|
||||
.from(meetingRegistrations)
|
||||
.where(inArray(meetingRegistrations.meetingId, linkedMeetingIds))
|
||||
: [],
|
||||
]);
|
||||
|
||||
const myRegistrations =
|
||||
memberId && upcoming.length > 0
|
||||
? await db
|
||||
.select({ meetingId: meetingRegistrations.meetingId })
|
||||
.from(meetingRegistrations)
|
||||
.where(
|
||||
and(
|
||||
eq(meetingRegistrations.memberId, memberId),
|
||||
inArray(
|
||||
meetingRegistrations.meetingId,
|
||||
upcoming.map((meeting) => meeting.id)
|
||||
)
|
||||
)
|
||||
)
|
||||
: [];
|
||||
const myMeetingIds = new Set(myRegistrations.map((row) => row.meetingId));
|
||||
const photosByPast = new Map<number, typeof pastPhotos>();
|
||||
for (const photo of pastPhotos) {
|
||||
photosByPast.set(photo.pastMeetingId, [...(photosByPast.get(photo.pastMeetingId) ?? []), photo]);
|
||||
}
|
||||
const registrationCounts = new Map<number, number>();
|
||||
for (const registration of linkedRegistrations) {
|
||||
registrationCounts.set(registration.meetingId, (registrationCounts.get(registration.meetingId) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const officers = [
|
||||
["Vice-présidence", settings.association_vice_president],
|
||||
["Trésorerie", settings.association_treasurer],
|
||||
["Secrétariat", settings.association_secretary],
|
||||
].filter(([, name]) => name);
|
||||
const board = parseBoardMembers(settings.association_board_members);
|
||||
const missionParagraphs = parseParagraphs(settings.association_mission);
|
||||
const pillars = parsePillars(settings.association_pillars);
|
||||
|
||||
return (
|
||||
<div style={{ background: "#F6F2E8", minHeight: "100vh", fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
|
||||
<SiteHeader active="association" logo />
|
||||
<JsonLd
|
||||
data={[
|
||||
breadcrumbJsonLd(baseUrl, [
|
||||
{ name: "Accueil", path: "/" },
|
||||
{ name: "L'association", path: "/association" },
|
||||
]),
|
||||
...upcoming.map((meeting) => eventJsonLd(baseUrl, meeting)),
|
||||
]}
|
||||
/>
|
||||
|
||||
<main>
|
||||
<section className="container" style={{ paddingTop: 34, paddingBottom: 52 }}>
|
||||
<div style={{ maxWidth: 900 }}>
|
||||
<div style={{ color: "#9a6638", fontSize: 12, letterSpacing: "0.14em", textTransform: "uppercase", fontWeight: 800, marginBottom: 12 }}>
|
||||
Association
|
||||
</div>
|
||||
<h1 className="font-display" style={{ margin: 0, color: "#26201a", fontSize: "clamp(42px, 8vw, 76px)", lineHeight: 0.94, letterSpacing: "-0.03em" }}>
|
||||
{settings.association_name}
|
||||
</h1>
|
||||
<p style={{ margin: "18px 0 0", maxWidth: 760, color: "#6c6150", fontSize: "clamp(17px, 2vw, 21px)", lineHeight: 1.65 }}>
|
||||
{settings.association_intro}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={{ background: "#13324F", color: "#fff" }}>
|
||||
<div className="container" style={{ paddingTop: 44, paddingBottom: 48 }}>
|
||||
<div className="grid grid-2" style={{ alignItems: "start" }}>
|
||||
<div>
|
||||
<div style={{ color: "#E0A63C", fontSize: 12, letterSpacing: "0.14em", textTransform: "uppercase", fontWeight: 800, marginBottom: 10 }}>
|
||||
Mission
|
||||
</div>
|
||||
<h2 className="font-display" style={{ margin: 0, fontSize: "clamp(28px, 4vw, 44px)" }}>
|
||||
Réseau, rencontres, réussite locale.
|
||||
</h2>
|
||||
</div>
|
||||
<div>
|
||||
{missionParagraphs.map((paragraph, index) => (
|
||||
<p
|
||||
key={index}
|
||||
style={{
|
||||
margin: index === 0 ? 0 : "14px 0 0",
|
||||
color: "#cfe0ee",
|
||||
fontSize: 16,
|
||||
lineHeight: 1.75,
|
||||
}}
|
||||
>
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pillars.length > 0 && (
|
||||
<>
|
||||
<div style={{ color: "#E0A63C", fontSize: 12, letterSpacing: "0.14em", textTransform: "uppercase", fontWeight: 800, margin: "44px 0 16px" }}>
|
||||
Ce que l'association vous apporte
|
||||
</div>
|
||||
<div className="pillars-grid">
|
||||
{pillars.map((pillar, index) => (
|
||||
<article key={pillar.title} className="pillar-card">
|
||||
<span className="font-display pillar-card__index">
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</span>
|
||||
<h3 className="font-display pillar-card__title">{pillar.title}</h3>
|
||||
{pillar.description && (
|
||||
<p className="pillar-card__text">{pillar.description}</p>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="container" style={{ paddingTop: 54, paddingBottom: 54 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: 18, alignItems: "end", flexWrap: "wrap", marginBottom: 20 }}>
|
||||
<div>
|
||||
<div style={{ color: "#9a6638", fontSize: 12, letterSpacing: "0.14em", textTransform: "uppercase", fontWeight: 800 }}>
|
||||
Rencontres
|
||||
</div>
|
||||
<h2 className="font-display" style={{ margin: "8px 0 0", color: "#26201a", fontSize: 34 }}>
|
||||
Prochaines dates
|
||||
</h2>
|
||||
</div>
|
||||
{!session?.user && (
|
||||
<Link href="/login" style={{ textDecoration: "none", background: "#9a6638", color: "#fff", borderRadius: 999, padding: "11px 18px", fontWeight: 800 }}>
|
||||
Se connecter pour s'inscrire
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{upcoming.length === 0 && (
|
||||
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 14, padding: 24, color: "#8c8068" }}>
|
||||
Aucune rencontre programmée pour le moment.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-3" style={{ gap: 18 }}>
|
||||
{upcoming.map((meeting) => {
|
||||
const registered = Number(meeting.registered);
|
||||
const remaining = Math.max(0, meeting.capacity - registered);
|
||||
const isRegistered = myMeetingIds.has(meeting.id);
|
||||
return (
|
||||
<article id={`rencontre-${meeting.id}`} key={meeting.id} className="lift" style={{ background: "#fff", border: isRegistered ? "2px solid #1f8a5b" : "1px solid #e6dcc6", borderRadius: 12, overflow: "hidden", scrollMarginTop: 100 }}>
|
||||
<div style={{ position: "relative", aspectRatio: "16 / 10", background: meeting.imageUrl ? `center/cover no-repeat url(${meeting.imageUrl})` : "repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)" }}>
|
||||
{isRegistered && (
|
||||
<span style={{ position: "absolute", top: 12, right: 12, display: "inline-flex", alignItems: "center", gap: 6, background: "#1f8a5b", color: "#fff", borderRadius: 999, padding: "7px 12px", fontSize: 12, fontWeight: 800, boxShadow: "0 4px 14px rgba(22,72,49,0.28)" }}>
|
||||
✓ Inscrit
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ padding: 18 }}>
|
||||
<div style={{ color: "#9a6638", fontSize: 12.5, fontWeight: 800 }}>
|
||||
{formatDate(meeting.startsAt)} · {formatTime(meeting.startsAt)}
|
||||
</div>
|
||||
<h3 className="font-display" style={{ margin: "7px 0 7px", color: "#26201a", fontSize: 21 }}>
|
||||
{meeting.title}
|
||||
</h3>
|
||||
{meeting.location && <div style={{ fontSize: 13, color: "#6c6150", fontWeight: 700 }}>{meeting.location}</div>}
|
||||
<p style={{ color: "#8c8068", fontSize: 13.5, lineHeight: 1.6, minHeight: 64 }}>
|
||||
{meeting.description}
|
||||
</p>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", color: "#6c6150", fontSize: 12.5, fontWeight: 800, marginBottom: 12 }}>
|
||||
<span>{registered}/{meeting.capacity} inscrits</span>
|
||||
<span>{remaining} place(s)</span>
|
||||
</div>
|
||||
<div style={{ color: "#8c8068", fontSize: 12.5, marginBottom: 10 }}>
|
||||
Jusqu'à {meeting.participantsPerAccount} participant{meeting.participantsPerAccount > 1 ? "s" : ""} par compte
|
||||
</div>
|
||||
<Link
|
||||
href={`/inscription/${meeting.id}`}
|
||||
style={{ display: "block", textAlign: "center", textDecoration: "none", background: remaining <= 0 && !isRegistered ? "#a99c82" : "#13324F", color: "#fff", borderRadius: 8, padding: 11, fontWeight: 800 }}
|
||||
>
|
||||
{isRegistered ? "Modifier mon inscription" : remaining <= 0 ? "Complet" : memberId ? "S'inscrire" : "Connexion et inscription"}
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={{ background: "#EFE9DA" }}>
|
||||
<div className="container" style={{ paddingTop: 54, paddingBottom: 54 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "end", gap: 18, flexWrap: "wrap", marginBottom: 20 }}>
|
||||
<div>
|
||||
<div style={{ color: "#9a6638", fontSize: 12, letterSpacing: "0.14em", textTransform: "uppercase", fontWeight: 800 }}>
|
||||
Ils y étaient
|
||||
</div>
|
||||
<h2 className="font-display" style={{ margin: "8px 0 0", color: "#26201a", fontSize: 34 }}>
|
||||
Nos dernières rencontres
|
||||
</h2>
|
||||
</div>
|
||||
<Link href="/rencontres-passees" style={{ display: "inline-block", background: "#13324F", color: "#fff", borderRadius: 8, padding: "11px 16px", fontSize: 13.5, fontWeight: 800, textDecoration: "none" }}>
|
||||
Voir toutes les rencontres passées
|
||||
</Link>
|
||||
</div>
|
||||
{past.length === 0 ? (
|
||||
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 24, color: "#8c8068" }}>
|
||||
Aucune rencontre passée publiée pour le moment.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-3" style={{ gap: 18 }}>
|
||||
{past.map((meeting) => {
|
||||
const photos = photosByPast.get(meeting.id) ?? [];
|
||||
const participantCount = meeting.meetingId
|
||||
? registrationCounts.get(meeting.meetingId) ?? 0
|
||||
: splitLines(meeting.participants ?? "").length;
|
||||
return (
|
||||
<article key={meeting.id} style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 18 }}>
|
||||
<div style={{ color: "#9a6638", fontSize: 12.5, fontWeight: 800 }}>{formatDate(meeting.eventDate)}</div>
|
||||
<h3 className="font-display" style={{ color: "#26201a", fontSize: 22, margin: "7px 0" }}>{meeting.title}</h3>
|
||||
{meeting.location && <div style={{ color: "#6c6150", fontSize: 13, fontWeight: 700 }}>{meeting.location}</div>}
|
||||
{meeting.description && <p style={{ color: "#8c8068", fontSize: 13.5, lineHeight: 1.65 }}>{meeting.description}</p>}
|
||||
<div style={{ color: "#6c6150", fontSize: 12.5, fontWeight: 800, marginBottom: 12 }}>
|
||||
{participantCount} participant{participantCount > 1 ? "s" : ""} · {photos.length} photo{photos.length > 1 ? "s" : ""}
|
||||
</div>
|
||||
<PastMeetingGallery title={meeting.title} photos={photos} />
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="container" style={{ paddingTop: 54, paddingBottom: 64 }}>
|
||||
<div style={{ color: "#9a6638", fontSize: 12, letterSpacing: "0.14em", textTransform: "uppercase", fontWeight: 800 }}>
|
||||
Gouvernance
|
||||
</div>
|
||||
<h2 className="font-display" style={{ margin: "8px 0 20px", color: "#26201a", fontSize: 34 }}>
|
||||
Directoire
|
||||
</h2>
|
||||
<div className="grid grid-2" style={{ gap: 18 }}>
|
||||
<article style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 12, padding: 22 }}>
|
||||
{settings.association_president_photo && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={settings.association_president_photo} alt="" style={{ width: 86, height: 86, objectFit: "cover", borderRadius: "50%", marginBottom: 14 }} />
|
||||
)}
|
||||
<div style={{ color: "#9a6638", fontSize: 12, fontWeight: 800 }}>{settings.association_president_role}</div>
|
||||
<h3 className="font-display" style={{ color: "#26201a", fontSize: 24, margin: "6px 0" }}>
|
||||
{settings.association_president || "Présidence à renseigner"}
|
||||
</h3>
|
||||
{settings.association_president_message && (
|
||||
<p style={{ color: "#6c6150", lineHeight: 1.7 }}>{settings.association_president_message}</p>
|
||||
)}
|
||||
</article>
|
||||
<div className="grid" style={{ gap: 12 }}>
|
||||
{[...officers.map(([role, name]) => ({ role, name })), ...board].map((person, index) => (
|
||||
<article key={`${person.role}-${person.name}-${index}`} style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 12, padding: "16px 18px" }}>
|
||||
<div style={{ color: "#9a6638", fontSize: 12, fontWeight: 800 }}>{person.role}</div>
|
||||
<div className="font-display" style={{ color: "#26201a", fontSize: 19, fontWeight: 800, marginTop: 3 }}>
|
||||
{person.name}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{officers.length === 0 && board.length === 0 && (
|
||||
<article style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 12, padding: 18, color: "#8c8068" }}>
|
||||
Les membres du directoire seront affichés ici après configuration.
|
||||
</article>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+193
-164
@@ -2,7 +2,8 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { BackendIcon, type BackendIconName } from "@/components/BackendIcons";
|
||||
import { can, ROLE_LABELS } from "@/lib/rbac";
|
||||
import type { AppRole } from "@/types/next-auth";
|
||||
import { doSignOut } from "./actions";
|
||||
@@ -15,12 +16,79 @@ const TITLES: Record<string, [string, string]> = {
|
||||
"Modération des promotions",
|
||||
"Validez les offres soumises par les adhérents",
|
||||
],
|
||||
"/backend/rencontres": ["Rencontres", "Gérer les prochaines dates"],
|
||||
"/backend/inscriptions": ["Inscriptions", "Gérer les participants aux rencontres"],
|
||||
"/backend/rencontres-passees": ["Rencontres passées", "Publier les archives et leurs photos"],
|
||||
"/backend/emails": ["Création d'e-mails", "Composer des messages aux couleurs de Plein R"],
|
||||
"/backend/reseaux": ["Réseaux sociaux", "Connecter les pages Facebook et LinkedIn"],
|
||||
"/backend/administrateurs": ["Administrateurs", "Gérer les accès à l'administration"],
|
||||
"/backend/categories": ["Catégories", "Gérer les métiers de l'annuaire"],
|
||||
"/backend/parametres": ["Paramètres du site", "Configurer l'association et les mentions légales"],
|
||||
"/backend/espace": ["Mon espace adhérent", "Publiez et suivez vos promotions"],
|
||||
"/backend/changer-mot-de-passe": ["Mot de passe", "Sécurisez votre compte"],
|
||||
};
|
||||
|
||||
type Capability = Parameters<typeof can>[1];
|
||||
type BadgeKey = "inbox" | "promos";
|
||||
|
||||
type NavItem = {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: BackendIconName;
|
||||
capability?: Capability;
|
||||
badge?: BadgeKey;
|
||||
/** Les fiches adhérent sont des sous-routes : l'entrée doit rester active. */
|
||||
prefix?: boolean;
|
||||
};
|
||||
|
||||
type NavSection = { label: string; items: NavItem[] };
|
||||
|
||||
/**
|
||||
* Menu groupé par usage plutôt qu'en une seule pile : on distingue le quotidien
|
||||
* (réseau, rencontres) de ce qu'on ne touche qu'occasionnellement (configuration).
|
||||
*/
|
||||
const SECTIONS: NavSection[] = [
|
||||
{
|
||||
label: "Pilotage",
|
||||
items: [{ href: "/backend", label: "Tableau de bord", icon: "dashboard", capability: "viewDashboard" }],
|
||||
},
|
||||
{
|
||||
label: "Vie du réseau",
|
||||
items: [
|
||||
{ href: "/backend/adherents", label: "Adhérents", icon: "members", capability: "manageMembers", prefix: true },
|
||||
{ href: "/backend/demandes", label: "Demandes & messages", icon: "inbox", capability: "manageMembers", badge: "inbox" },
|
||||
{ href: "/backend/promotions", label: "Promotions", icon: "promos", capability: "moderatePromos", badge: "promos" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Rencontres",
|
||||
items: [
|
||||
{ href: "/backend/rencontres", label: "Prochaines dates", icon: "meetings", capability: "manageMeetings" },
|
||||
{ href: "/backend/inscriptions", label: "Inscriptions", icon: "registrations", capability: "manageMeetings" },
|
||||
{ href: "/backend/rencontres-passees", label: "Archives & photos", icon: "gallery", capability: "manageMeetings" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Communication",
|
||||
items: [
|
||||
{ href: "/backend/emails", label: "E-mails", icon: "emails", capability: "manageEmails" },
|
||||
{ href: "/backend/reseaux", label: "Réseaux sociaux", icon: "social", capability: "manageSettings" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Configuration",
|
||||
items: [
|
||||
{ href: "/backend/categories", label: "Catégories", icon: "categories", capability: "manageCategories" },
|
||||
{ href: "/backend/parametres", label: "Paramètres du site", icon: "settings", capability: "manageSettings" },
|
||||
{ href: "/backend/administrateurs", label: "Administrateurs", icon: "admins", capability: "manageAdmins" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Côté adhérent",
|
||||
items: [{ href: "/backend/espace", label: "Mon espace", icon: "space" }],
|
||||
},
|
||||
];
|
||||
|
||||
function initialsOf(name: string) {
|
||||
return name
|
||||
.split(/\s+/)
|
||||
@@ -30,19 +98,21 @@ function initialsOf(name: string) {
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
function NavLink({
|
||||
href,
|
||||
active,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
active: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
function Badge({ value }: { value: number }) {
|
||||
return (
|
||||
<Link href={href} className={`nav-btn${active ? " active" : ""}`}>
|
||||
{children}
|
||||
</Link>
|
||||
<span
|
||||
style={{
|
||||
marginLeft: "auto",
|
||||
background: "#E0A63C",
|
||||
color: "#33291D",
|
||||
fontSize: 11,
|
||||
fontWeight: 800,
|
||||
borderRadius: 999,
|
||||
padding: "1px 8px",
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -58,119 +128,116 @@ export function BackendShell({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
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" }} />
|
||||
// Le tiroir se referme dès qu'on navigue.
|
||||
useEffect(() => {
|
||||
setMenuOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
// Pas de défilement de la page derrière le tiroir ouvert.
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
const previous = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = previous;
|
||||
};
|
||||
}, [menuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setMenuOpen(false);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [menuOpen]);
|
||||
|
||||
const badges: Record<BadgeKey, number> = { inbox: inboxCount, promos: pendingCount };
|
||||
|
||||
const sections = SECTIONS.map((section) => ({
|
||||
...section,
|
||||
items: section.items.filter((item) => !item.capability || can(user.role, item.capability)),
|
||||
})).filter((section) => section.items.length > 0);
|
||||
|
||||
const siteLink = (
|
||||
<Link href="/" className="backend-action">
|
||||
← Voir le site
|
||||
</Link>
|
||||
);
|
||||
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)" }} />
|
||||
const signOut = (
|
||||
<form action={doSignOut}>
|
||||
<button type="submit" className="backend-action backend-action--strong">
|
||||
Déconnexion
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
|
||||
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" }}>
|
||||
{menuOpen && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Fermer le menu"
|
||||
className="sidebar-backdrop"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside className={`sidebar${menuOpen ? " is-open" : ""}`} id="backend-nav">
|
||||
<div className="sidebar-brand">
|
||||
<span style={{ display: "inline-flex", background: "#F6F2E8", borderRadius: 11, padding: 6 }}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src="/assets/logo.png" alt="Plein R" style={{ height: 34, width: "auto", display: "block" }} />
|
||||
</span>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar-close"
|
||||
aria-label="Fermer le menu"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
>
|
||||
<BackendIcon name="close" size={20} />
|
||||
</button>
|
||||
</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, "manageMembers") && (
|
||||
<NavLink href="/backend/demandes" active={pathname === "/backend/demandes"}>
|
||||
{dot}Demandes & messages
|
||||
{inboxCount > 0 && (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: "auto",
|
||||
background: "#E0A63C",
|
||||
color: "#33291D",
|
||||
fontSize: 11,
|
||||
fontWeight: 800,
|
||||
borderRadius: 999,
|
||||
padding: "1px 8px",
|
||||
}}
|
||||
>
|
||||
{inboxCount}
|
||||
</span>
|
||||
)}
|
||||
</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",
|
||||
}}
|
||||
<nav className="sidebar-nav">
|
||||
{sections.map((section) => (
|
||||
<div key={section.label}>
|
||||
<div className="sidebar-section">{section.label}</div>
|
||||
{section.items.map((item) => {
|
||||
const active = item.prefix ? pathname.startsWith(item.href) : pathname === item.href;
|
||||
const badge = item.badge ? badges[item.badge] : 0;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`nav-btn${active ? " active" : ""}`}
|
||||
aria-current={active ? "page" : undefined}
|
||||
>
|
||||
{pendingCount}
|
||||
</span>
|
||||
</NavLink>
|
||||
)}
|
||||
{can(user.role, "manageCategories") && (
|
||||
<NavLink href="/backend/categories" active={pathname === "/backend/categories"}>
|
||||
{dotDiamond}Catégories
|
||||
</NavLink>
|
||||
)}
|
||||
{can(user.role, "manageAdmins") && (
|
||||
<NavLink href="/backend/administrateurs" active={pathname === "/backend/administrateurs"}>
|
||||
{dot}Administrateurs
|
||||
</NavLink>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<BackendIcon name={item.icon} />
|
||||
{item.label}
|
||||
{item.badge && badge > 0 && <Badge value={badge} />}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<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",
|
||||
}}
|
||||
>
|
||||
<div className="sidebar-user">
|
||||
<span
|
||||
className="font-display"
|
||||
style={{
|
||||
@@ -196,76 +263,38 @@ export function BackendShell({
|
||||
<div style={{ fontSize: 11, color: "#7f9bb4" }}>{ROLE_LABELS[user.role]}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sur téléphone, l'entête n'a pas la place : les actions vivent ici. */}
|
||||
<div className="sidebar-actions">
|
||||
{siteLink}
|
||||
{signOut}
|
||||
</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>
|
||||
<header className="backend-header">
|
||||
<button
|
||||
type="button"
|
||||
className="backend-burger"
|
||||
aria-label="Ouvrir le menu"
|
||||
aria-expanded={menuOpen}
|
||||
aria-controls="backend-nav"
|
||||
onClick={() => setMenuOpen(true)}
|
||||
>
|
||||
<BackendIcon name="menu" size={22} />
|
||||
</button>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<h1 className="font-display backend-header__title">{title}</h1>
|
||||
<div className="backend-header__sub">{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 className="backend-header__actions">
|
||||
{siteLink}
|
||||
{signOut}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div style={{ padding: "28px 32px 44px", flex: 1 }}>{children}</div>
|
||||
<div className="backend-content">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sectionLabel = {
|
||||
fontSize: 10.5,
|
||||
letterSpacing: "0.14em",
|
||||
textTransform: "uppercase" as const,
|
||||
color: "#5f7d97",
|
||||
fontWeight: 700,
|
||||
padding: "20px 10px 8px",
|
||||
};
|
||||
+825
-48
File diff suppressed because it is too large.
Load diff
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { BASSIN_POMPEY_COMMUNES } from "@/lib/communes";
|
||||
import { addMember } from "../actions";
|
||||
import { OneTimeCredentials } from "@/components/OneTimeCredentials";
|
||||
import { addMember, type CreatedMemberAccount } from "../actions";
|
||||
|
||||
export function AddMemberPanel({
|
||||
categories,
|
||||
@@ -11,10 +13,19 @@ export function AddMemberPanel({
|
||||
categories: { id: number; label: string }[];
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [created, setCreated] = useState<CreatedMemberAccount | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 0 }}>
|
||||
{created && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginBottom: 14 }}>
|
||||
<OneTimeCredentials items={[created]} title="Adhérent créé — identifiants à transmettre" />
|
||||
<Link href={`/backend/adherents/${created.memberId}`} style={{ color: "#2C6FB3", fontWeight: 700, fontSize: 13, textDecoration: "none" }}>
|
||||
Ouvrir la fiche adhérent →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
@@ -28,10 +39,12 @@ export function AddMemberPanel({
|
||||
{open && (
|
||||
<form
|
||||
action={async (fd) => {
|
||||
const id = await addMember(fd);
|
||||
const result = await addMember(fd);
|
||||
setOpen(false);
|
||||
// Redirige vers la fiche : le login + mot de passe temporaire y sont affichés.
|
||||
if (id) router.push(`/backend/adherents/${id}`);
|
||||
// On reste sur la page : le mot de passe temporaire n'est affiché
|
||||
// qu'ici, une seule fois, et n'est conservé nulle part.
|
||||
setCreated(result ?? null);
|
||||
router.refresh();
|
||||
}}
|
||||
style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, padding: 22, marginTop: 14 }}
|
||||
>
|
||||
@@ -76,7 +89,7 @@ export function AddMemberPanel({
|
||||
</div>
|
||||
<div style={{ marginTop: 14, fontSize: 12.5, color: "#9a8d72" }}>
|
||||
Un compte de connexion est créé automatiquement. Le mot de passe temporaire s'affiche
|
||||
sur la fiche de l'adhérent jusqu'à sa première connexion.
|
||||
une seule fois, juste après l'enregistrement : notez-le avant de quitter la page.
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { OneTimeCredentials, type IssuedCredentialsItem } from "@/components/OneTimeCredentials";
|
||||
import { createMissingMemberAccounts } from "../actions";
|
||||
|
||||
export function BackfillAccountsForm({ missingAccounts }: { missingAccounts: number }) {
|
||||
const [issued, setIssued] = useState<IssuedCredentialsItem[] | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
if (issued) {
|
||||
return (
|
||||
<OneTimeCredentials
|
||||
items={issued.map((i) => ({ label: i.label, email: i.email, tempPassword: i.tempPassword }))}
|
||||
title={`${issued.length} compte(s) créé(s) — identifiants à transmettre`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
action={async () => {
|
||||
setPending(true);
|
||||
try {
|
||||
const created = await createMissingMemberAccounts();
|
||||
setIssued(created.map((c) => ({ label: c.name, email: c.email, tempPassword: c.tempPassword })));
|
||||
router.refresh();
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}}
|
||||
style={{ background: "#fbeede", border: "1px solid #ecd8b8", borderRadius: 12, padding: "14px 18px", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 14, flexWrap: "wrap" }}
|
||||
>
|
||||
<div style={{ fontSize: 13.5, color: "#9a6638" }}>
|
||||
<strong>{missingAccounts}</strong> adhérent(s) avec un e-mail n'ont pas encore de compte de connexion.
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
className="font-display"
|
||||
style={{ border: "none", background: "#9a6638", color: "#fff", fontWeight: 700, fontSize: 13.5, padding: "10px 18px", borderRadius: 10, cursor: "pointer", whiteSpace: "nowrap" }}
|
||||
>
|
||||
{pending ? "Création…" : "Créer les comptes manquants"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { OneTimeCredentials } from "@/components/OneTimeCredentials";
|
||||
import { resetMemberPassword, type IssuedCredentials } from "../actions";
|
||||
|
||||
export function ResetPasswordButton({ memberId }: { memberId: number }) {
|
||||
const [issued, setIssued] = useState<IssuedCredentials | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 14, display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
{issued && <OneTimeCredentials items={[issued]} title="Nouveau mot de passe temporaire" />}
|
||||
<form
|
||||
action={async (fd) => {
|
||||
setPending(true);
|
||||
try {
|
||||
const result = await resetMemberPassword(fd);
|
||||
setIssued(result ?? null);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="memberId" value={memberId} />
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
style={{ border: "1px solid #d8cdb4", background: "#fff", color: "#6f6450", fontWeight: 600, fontSize: 13, padding: "9px 15px", borderRadius: 9, cursor: "pointer" }}
|
||||
>
|
||||
{pending ? "Réinitialisation…" : "Réinitialiser le mot de passe"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { auth } from "@/auth";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { db } from "@/db";
|
||||
import { categories, members, users } from "@/db/schema";
|
||||
import { can } from "@/lib/rbac";
|
||||
import { ImageField } from "@/components/ImageField";
|
||||
import { HoursEditor } from "@/components/HoursEditor";
|
||||
import { communeOptions } from "@/lib/communes";
|
||||
import { deleteMember, resetMemberPassword, updateMember } from "../../actions";
|
||||
import { deleteMember, updateMember } from "../../actions";
|
||||
import { ResetPasswordButton } from "../ResetPasswordButton";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -16,7 +18,7 @@ export default async function EditMemberPage({
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
const session = await getSession();
|
||||
if (!can(session?.user.role, "manageMembers")) {
|
||||
redirect("/backend");
|
||||
}
|
||||
@@ -34,7 +36,7 @@ export default async function EditMemberPage({
|
||||
.orderBy(asc(categories.sort));
|
||||
|
||||
const [account] = await db
|
||||
.select({ email: users.email, tempPassword: users.tempPassword, mustChange: users.mustChangePassword })
|
||||
.select({ email: users.email, mustChange: users.mustChangePassword })
|
||||
.from(users)
|
||||
.where(eq(users.memberId, memberId));
|
||||
|
||||
@@ -52,14 +54,6 @@ export default async function EditMemberPage({
|
||||
redirect("/backend/adherents");
|
||||
}
|
||||
|
||||
async function handleReset() {
|
||||
"use server";
|
||||
const fd = new FormData();
|
||||
fd.set("memberId", String(memberId));
|
||||
await resetMemberPassword(fd);
|
||||
redirect(`/backend/adherents/${memberId}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 720 }}>
|
||||
<Link href="/backend/adherents" style={{ textDecoration: "none", color: "#6f6450", fontSize: 13.5, fontWeight: 600 }}>
|
||||
@@ -148,8 +142,7 @@ export default async function EditMemberPage({
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<label className="field-label">Horaires (une ligne par jour : « Jour|plage », ex : Mardi – Vendredi|7h – 19h30)</label>
|
||||
<textarea name="hours" rows={4} defaultValue={member.hours ?? ""} className="field" style={{ resize: "vertical" }} placeholder={"Mardi – Vendredi|7h – 19h30\nSamedi|7h – 19h\nDimanche|7h – 13h\nLundi|Fermé"} />
|
||||
<HoursEditor defaultValue={member.hours} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 10, marginTop: 22 }}>
|
||||
@@ -172,12 +165,11 @@ export default async function EditMemberPage({
|
||||
<div style={{ fontSize: 13.5, color: "#5a5040", marginBottom: 10 }}>
|
||||
Identifiant : <strong>{account.email}</strong>
|
||||
</div>
|
||||
{account.tempPassword ? (
|
||||
{account.mustChange ? (
|
||||
<div style={{ background: "#fbeede", border: "1px solid #ecd8b8", borderRadius: 10, padding: "11px 14px", fontSize: 13.5, color: "#9a6638" }}>
|
||||
Mot de passe temporaire :{" "}
|
||||
<strong style={{ fontFamily: "monospace", fontSize: 15 }}>{account.tempPassword}</strong>
|
||||
En attente de première connexion : l'adhérent devra changer son mot de passe temporaire.
|
||||
<div style={{ fontSize: 12, marginTop: 4 }}>
|
||||
Communiquez-le à l'adhérent. Il disparaît dès sa première connexion (changement obligatoire).
|
||||
Mot de passe égaré ? Réinitialisez-le : un nouveau vous sera montré une seule fois.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -185,14 +177,7 @@ export default async function EditMemberPage({
|
||||
✓ L'adhérent a défini son propre mot de passe.
|
||||
</div>
|
||||
)}
|
||||
<form action={handleReset} style={{ marginTop: 14 }}>
|
||||
<button
|
||||
type="submit"
|
||||
style={{ border: "1px solid #d8cdb4", background: "#fff", color: "#6f6450", fontWeight: 600, fontSize: 13, padding: "9px 15px", borderRadius: 9, cursor: "pointer" }}
|
||||
>
|
||||
Réinitialiser le mot de passe
|
||||
</button>
|
||||
</form>
|
||||
<ResetPasswordButton memberId={memberId} />
|
||||
</>
|
||||
) : (
|
||||
<div style={{ fontSize: 13.5, color: "#a99c82" }}>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { asc, eq, ilike } from "drizzle-orm";
|
||||
import { auth } from "@/auth";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { db } from "@/db";
|
||||
import { categories, members, users } from "@/db/schema";
|
||||
import { can } from "@/lib/rbac";
|
||||
import { createMissingMemberAccounts } from "../actions";
|
||||
import { AddMemberPanel } from "./AddMemberPanel";
|
||||
import { BackfillAccountsForm } from "./BackfillAccountsForm";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -19,7 +19,7 @@ export default async function AdherentsPage({
|
||||
}: {
|
||||
searchParams: Promise<{ q?: string }>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
const session = await getSession();
|
||||
if (!can(session?.user.role, "manageMembers")) {
|
||||
redirect("/backend");
|
||||
}
|
||||
@@ -59,38 +59,13 @@ export default async function AdherentsPage({
|
||||
return !!e && !takenEmails.has(e);
|
||||
}).length;
|
||||
|
||||
async function handleBackfill() {
|
||||
"use server";
|
||||
await createMissingMemberAccounts();
|
||||
redirect("/backend/adherents");
|
||||
}
|
||||
|
||||
const COLS = "1.6fr 1fr 1.2fr 0.9fr 0.8fr";
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
|
||||
{missingAccounts > 0 && (
|
||||
<form
|
||||
action={handleBackfill}
|
||||
style={{ background: "#fbeede", border: "1px solid #ecd8b8", borderRadius: 12, padding: "14px 18px", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 14, flexWrap: "wrap" }}
|
||||
>
|
||||
<div style={{ fontSize: 13.5, color: "#9a6638" }}>
|
||||
<strong>{missingAccounts}</strong> adhérent(s) avec un e-mail n'ont pas encore de compte de connexion.
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="font-display"
|
||||
style={{ border: "none", background: "#9a6638", color: "#fff", fontWeight: 700, fontSize: 13.5, padding: "10px 18px", borderRadius: 10, cursor: "pointer", whiteSpace: "nowrap" }}
|
||||
>
|
||||
Créer les comptes manquants
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{missingAccounts > 0 && <BackfillAccountsForm missingAccounts={missingAccounts} />}
|
||||
|
||||
<AddMemberPanel categories={cats} />
|
||||
|
||||
<div className="table-scroll">
|
||||
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, overflow: "hidden", minWidth: 560 }}>
|
||||
<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%" }} />
|
||||
@@ -104,9 +79,8 @@ export default async function AdherentsPage({
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="member-row member-head"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: COLS,
|
||||
padding: "13px 22px",
|
||||
background: "#faf7ef",
|
||||
borderBottom: "1px solid #f0e8d6",
|
||||
@@ -131,24 +105,28 @@ export default async function AdherentsPage({
|
||||
{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 }}>
|
||||
<div key={m.id} className="member-row" style={{ padding: "15px 22px", borderBottom: "1px solid #f4eede" }}>
|
||||
<div className="member-row__identity">
|
||||
<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 className="member-row__mail" 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 className="member-row__meta" style={{ fontSize: 13.5, color: "#6c6150" }}>
|
||||
{m.categoryLabel ?? "—"}
|
||||
</div>
|
||||
<div className="member-row__meta" 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" }}>
|
||||
<div className="member-row__action">
|
||||
<Link href={`/backend/adherents/${m.id}`} style={{ textDecoration: "none", color: "#2C6FB3", fontWeight: 700, fontSize: 13 }}>
|
||||
Modifier
|
||||
</Link>
|
||||
@@ -157,7 +135,6 @@ export default async function AdherentsPage({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { OneTimeCredentials } from "@/components/OneTimeCredentials";
|
||||
import { inviteAdmin, type IssuedCredentials } from "../actions";
|
||||
|
||||
export function InviteAdminForm() {
|
||||
const [issued, setIssued] = useState<IssuedCredentials | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
|
||||
{issued && <OneTimeCredentials items={[issued]} title="Compte créé — identifiants à transmettre" />}
|
||||
{error && (
|
||||
<div role="alert" style={{ background: "#fdecea", border: "1px solid #f1c4bd", borderRadius: 10, padding: "10px 14px", fontSize: 13, color: "#a3372e" }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<form
|
||||
action={async (fd) => {
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await inviteAdmin(fd);
|
||||
setIssued(result ?? null);
|
||||
router.refresh();
|
||||
} catch {
|
||||
// Next masque le détail des erreurs serveur en production.
|
||||
setError("Impossible de créer ce compte : vérifiez que l'e-mail n'est pas déjà utilisé.");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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"
|
||||
disabled={pending}
|
||||
className="font-display"
|
||||
style={{ width: "100%", border: "none", background: "#13324F", color: "#fff", fontWeight: 700, fontSize: 14.5, padding: 13, borderRadius: 11, cursor: "pointer" }}
|
||||
>
|
||||
{pending ? "Création…" : "Créer le compte"}
|
||||
</button>
|
||||
</form>
|
||||
<p style={{ fontSize: 11.5, color: "#a99c82", margin: 0, lineHeight: 1.5 }}>
|
||||
Un mot de passe temporaire est généré et affiché une seule fois ici. L'invité devra le
|
||||
changer à sa première connexion.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { inArray } from "drizzle-orm";
|
||||
import { auth } from "@/auth";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { db } from "@/db";
|
||||
import { users } from "@/db/schema";
|
||||
import { can, ROLE_LABELS, STAFF_ROLES } from "@/lib/rbac";
|
||||
import { inviteAdmin, removeAdmin } from "../actions";
|
||||
import { removeAdmin } from "../actions";
|
||||
import { InviteAdminForm } from "./InviteAdminForm";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -13,7 +14,7 @@ function initialsOf(name: string) {
|
||||
}
|
||||
|
||||
export default async function AdminsPage() {
|
||||
const session = await auth();
|
||||
const session = await getSession();
|
||||
if (!can(session?.user.role, "manageAdmins")) {
|
||||
redirect("/backend");
|
||||
}
|
||||
@@ -70,32 +71,7 @@ export default async function AdminsPage() {
|
||||
<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>
|
||||
<InviteAdminForm />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { asc, count, eq } from "drizzle-orm";
|
||||
import { auth } from "@/auth";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { db } from "@/db";
|
||||
import { categories, members } from "@/db/schema";
|
||||
import { can } from "@/lib/rbac";
|
||||
@@ -9,7 +9,7 @@ import { addCategory, deleteCategory, renameCategory } from "../actions";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function CategoriesPage() {
|
||||
const session = await auth();
|
||||
const session = await getSession();
|
||||
if (!can(session?.user.role, "manageCategories")) {
|
||||
redirect("/backend");
|
||||
}
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { changeOwnPassword } from "../actions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ChangePasswordPage() {
|
||||
const session = await auth();
|
||||
export default async function ChangePasswordPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ error?: string }>;
|
||||
}) {
|
||||
const session = await getSession();
|
||||
if (!session?.user) redirect("/login");
|
||||
|
||||
const { error } = await searchParams;
|
||||
const forced = session.user.mustChangePassword;
|
||||
|
||||
return (
|
||||
@@ -23,7 +28,25 @@ export default async function ChangePasswordPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div style={{ background: "#fbe9e6", border: "1px solid #f2d5cf", color: "#a8503c", borderRadius: 10, padding: "11px 14px", fontSize: 13.5, marginBottom: 16 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form action={changeOwnPassword}>
|
||||
<label className="field-label">
|
||||
{forced ? "Mot de passe temporaire reçu" : "Mot de passe actuel"}
|
||||
</label>
|
||||
<input
|
||||
name="currentPassword"
|
||||
type="password"
|
||||
required
|
||||
className="field"
|
||||
style={{ marginBottom: 14 }}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
|
||||
<label className="field-label">Nouveau mot de passe (8 caractères min.)</label>
|
||||
<input name="password" type="password" required minLength={8} className="field" style={{ marginBottom: 14 }} autoComplete="new-password" />
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { OneTimeCredentials } from "@/components/OneTimeCredentials";
|
||||
import { approveMembershipRequest, type CreatedMemberAccount } from "../actions";
|
||||
|
||||
/**
|
||||
* Approuve une demande et affiche une seule fois les identifiants du compte
|
||||
* créé. On reste sur la page : une redirection ferait perdre le mot de passe,
|
||||
* qui n'est conservé nulle part.
|
||||
*/
|
||||
export function ApproveRequestForm({ requestId }: { requestId: number }) {
|
||||
const [created, setCreated] = useState<CreatedMemberAccount | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
if (created) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8, minWidth: 260 }}>
|
||||
<OneTimeCredentials items={[created]} title="Adhérent créé — identifiants à transmettre" />
|
||||
<Link href={`/backend/adherents/${created.memberId}`} style={{ color: "#2C6FB3", fontWeight: 700, fontSize: 13, textDecoration: "none" }}>
|
||||
Ouvrir la fiche adhérent →
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
action={async (fd) => {
|
||||
setPending(true);
|
||||
try {
|
||||
const result = await approveMembershipRequest(fd);
|
||||
if (result) {
|
||||
setCreated(result);
|
||||
router.refresh();
|
||||
}
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="id" value={requestId} />
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
style={{ border: "1px solid #1f8a5b", color: "#1f8a5b", background: "#fff", fontWeight: 700, fontSize: 12.5, padding: "7px 12px", borderRadius: 8, cursor: "pointer", whiteSpace: "nowrap" }}
|
||||
>
|
||||
{pending ? "Création…" : "Approuver & créer"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { desc } from "drizzle-orm";
|
||||
import { auth } from "@/auth";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { db } from "@/db";
|
||||
import { contactMessages, membershipRequests } from "@/db/schema";
|
||||
import { can } from "@/lib/rbac";
|
||||
import { approveMembershipRequest, setContactStatus, setRequestStatus } from "../actions";
|
||||
import { setContactStatus, setRequestStatus } from "../actions";
|
||||
import { ApproveRequestForm } from "./ApproveRequestForm";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -41,7 +42,7 @@ function ActionBtn({ children, color }: { children: React.ReactNode; color: stri
|
||||
}
|
||||
|
||||
export default async function DemandesPage() {
|
||||
const session = await auth();
|
||||
const session = await getSession();
|
||||
if (!can(session?.user.role, "manageMembers")) {
|
||||
redirect("/backend");
|
||||
}
|
||||
@@ -51,14 +52,6 @@ export default async function DemandesPage() {
|
||||
db.select().from(contactMessages).orderBy(desc(contactMessages.createdAt)),
|
||||
]);
|
||||
|
||||
// Approuver = créer l'adhérent + son compte, puis aller sur sa fiche
|
||||
// (login + mot de passe temporaire y sont affichés).
|
||||
async function handleApprove(fd: FormData) {
|
||||
"use server";
|
||||
const id = await approveMembershipRequest(fd);
|
||||
redirect(id ? `/backend/adherents/${id}` : "/backend/demandes");
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 26 }}>
|
||||
{/* ---- Demandes d'adhésion ---- */}
|
||||
@@ -80,10 +73,7 @@ export default async function DemandesPage() {
|
||||
</div>
|
||||
<StatusPill map={REQ_STATUS} status={r.status} />
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<form action={handleApprove}>
|
||||
<input type="hidden" name="id" value={r.id} />
|
||||
<ActionBtn color="#1f8a5b">Approuver & créer</ActionBtn>
|
||||
</form>
|
||||
<ApproveRequestForm requestId={r.id} />
|
||||
<form action={setRequestStatus}>
|
||||
<input type="hidden" name="id" value={r.id} />
|
||||
<input type="hidden" name="status" value="rejected" />
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { EmailCreator } from "@/components/EmailCreator";
|
||||
import { can } from "@/lib/rbac";
|
||||
import { getSiteSettings } from "@/lib/site-settings";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function EmailsPage() {
|
||||
const session = await getSession();
|
||||
if (!can(session?.user.role, "manageEmails")) redirect("/backend");
|
||||
const settings = await getSiteSettings();
|
||||
const brand = {
|
||||
associationName: settings.association_name,
|
||||
address: settings.association_address,
|
||||
email: settings.association_email,
|
||||
phone: settings.association_phone,
|
||||
siret: settings.association_siret,
|
||||
};
|
||||
|
||||
return <EmailCreator brand={brand} />;
|
||||
}
|
||||
@@ -4,16 +4,25 @@ import { useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { publishPromo } from "../actions";
|
||||
import { PromoImage } from "@/components/PromoImage";
|
||||
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
|
||||
import type { SocialNetwork } from "@/lib/social";
|
||||
|
||||
const STRIPE_WARM =
|
||||
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
|
||||
|
||||
const NETWORK_LABELS: Record<SocialNetwork, string> = {
|
||||
facebook: "Facebook",
|
||||
linkedin: "LinkedIn",
|
||||
};
|
||||
|
||||
export function MemberSpaceForm({
|
||||
memberName,
|
||||
categories,
|
||||
networks = [],
|
||||
}: {
|
||||
memberName: string;
|
||||
categories: string[];
|
||||
networks?: SocialNetwork[];
|
||||
}) {
|
||||
const CATEGORIES = categories.length > 0 ? categories : ["Autre"];
|
||||
const router = useRouter();
|
||||
@@ -22,10 +31,16 @@ export function MemberSpaceForm({
|
||||
const [cat, setCat] = useState(CATEGORIES[0]);
|
||||
const [badge, setBadge] = useState("");
|
||||
const [imgData, setImgData] = useState("");
|
||||
const [shares, setShares] = useState<SocialNetwork[]>([]);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [pending, setPending] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
function toggleShare(network: SocialNetwork) {
|
||||
setShares((s) => (s.includes(network) ? s.filter((n) => n !== network) : [...s, network]));
|
||||
setSubmitted(false);
|
||||
}
|
||||
|
||||
function onFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const f = e.target.files?.[0];
|
||||
if (!f) return;
|
||||
@@ -42,6 +57,7 @@ export function MemberSpaceForm({
|
||||
setText("");
|
||||
setBadge("");
|
||||
setImgData("");
|
||||
setShares([]);
|
||||
setSubmitted(false);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}
|
||||
@@ -55,6 +71,8 @@ export function MemberSpaceForm({
|
||||
fd.set("category", cat);
|
||||
fd.set("badge", badge);
|
||||
fd.set("imageUrl", imgData);
|
||||
if (shares.includes("facebook")) fd.set("shareFacebook", "on");
|
||||
if (shares.includes("linkedin")) fd.set("shareLinkedin", "on");
|
||||
await publishPromo(fd);
|
||||
setPending(false);
|
||||
setSubmitted(true);
|
||||
@@ -153,6 +171,50 @@ export function MemberSpaceForm({
|
||||
/>
|
||||
<div style={{ fontSize: 12, color: "#a99c82", marginBottom: 18 }}>{text.length} / 240 caractères</div>
|
||||
|
||||
{networks.length > 0 && (
|
||||
<div style={{ border: "1px solid #f0e8d6", background: "#faf7ef", borderRadius: 12, padding: "14px 15px", marginBottom: 18 }}>
|
||||
<div className="field-label" style={{ marginBottom: 4 }}>
|
||||
Diffusion sur les réseaux Plein R
|
||||
</div>
|
||||
<div style={{ fontSize: 12.5, color: "#8c8068", lineHeight: 1.5, marginBottom: 11 }}>
|
||||
Choisissez où l'association relaiera votre offre. La publication n'a lieu
|
||||
qu'une fois la promotion validée — et le choix n'est plus modifiable ensuite.
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 9, flexWrap: "wrap" }}>
|
||||
{networks.map((network) => {
|
||||
const active = shares.includes(network);
|
||||
return (
|
||||
<label
|
||||
key={network}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 9,
|
||||
cursor: "pointer",
|
||||
padding: "9px 14px",
|
||||
borderRadius: 11,
|
||||
border: `1px solid ${active ? SOCIAL_BRAND[network] : "#e6dcc6"}`,
|
||||
background: active ? "#fff" : "transparent",
|
||||
color: active ? SOCIAL_BRAND[network] : "#6c6150",
|
||||
fontSize: 13.5,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={active}
|
||||
onChange={() => toggleShare(network)}
|
||||
style={{ accentColor: SOCIAL_BRAND[network], margin: 0 }}
|
||||
/>
|
||||
<SocialIcon network={network} size={16} />
|
||||
{NETWORK_LABELS[network]}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", gap: 10 }}>
|
||||
<button
|
||||
onClick={submit}
|
||||
|
||||
+271
-31
@@ -1,10 +1,20 @@
|
||||
import { asc, desc, eq } from "drizzle-orm";
|
||||
import { auth } from "@/auth";
|
||||
import Link from "next/link";
|
||||
import { and, asc, desc, eq, gte, inArray, sql } from "drizzle-orm";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { db } from "@/db";
|
||||
import { categories, members, promotions, type Member } from "@/db/schema";
|
||||
import { categories, imageConsents, meetingRegistrations, meetings, members, promotions, socialPosts, type Member } from "@/db/schema";
|
||||
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
|
||||
import { configuredNetworks, SOCIAL_LABELS, type SocialNetwork } from "@/lib/social";
|
||||
import { ImageField } from "@/components/ImageField";
|
||||
import { ImageConsentForm } from "@/components/ImageConsentForm";
|
||||
import { HoursEditor } from "@/components/HoursEditor";
|
||||
import { communeOptions } from "@/lib/communes";
|
||||
import { updateOwnProfile } from "../actions";
|
||||
import {
|
||||
saveImageConsent,
|
||||
setOwnPromoShareTargets,
|
||||
setOwnPromoSuspension,
|
||||
updateOwnProfile,
|
||||
} from "../actions";
|
||||
import { MemberSpaceForm } from "./MemberSpaceForm";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -13,6 +23,7 @@ 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" },
|
||||
suspended: { label: "Suspendue", bg: "#fbe9e6", color: "#d8472b" },
|
||||
expired: { label: "Expirée", bg: "#f1efe7", color: "#a99c82" },
|
||||
};
|
||||
|
||||
@@ -21,14 +32,27 @@ function initialsOf(name: string) {
|
||||
}
|
||||
|
||||
export default async function EspacePage() {
|
||||
const session = await auth();
|
||||
const session = await getSession();
|
||||
const memberId = session?.user.memberId ?? null;
|
||||
const fallbackName = session?.user.name ?? "Adhérent";
|
||||
|
||||
let memberName = fallbackName;
|
||||
let subtitle = "Espace adhérent";
|
||||
let profile: Member | null = null;
|
||||
let myPromos: { id: number; title: string; status: string; createdAt: Date; imageUrl: string | null }[] = [];
|
||||
let myPromos: {
|
||||
id: number;
|
||||
title: string;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
imageUrl: string | null;
|
||||
suspendedBy: "member" | "staff" | null;
|
||||
shareFacebook: boolean;
|
||||
shareLinkedin: boolean;
|
||||
}[] = [];
|
||||
// Dernière tentative de publication par (promo, réseau).
|
||||
let lastShare = new Map<string, { status: string; url: string | null; error: string | null }>();
|
||||
let myRegistrations: { meetingId: number; title: string; startsAt: Date; location: string | null; participants: number; confirmed: boolean }[] = [];
|
||||
let latestConsent: { decision: string; createdAt: Date } | null = null;
|
||||
|
||||
if (memberId) {
|
||||
const [m] = await db.select().from(members).where(eq(members.id, memberId));
|
||||
@@ -47,10 +71,53 @@ export default async function EspacePage() {
|
||||
status: promotions.status,
|
||||
createdAt: promotions.createdAt,
|
||||
imageUrl: promotions.imageUrl,
|
||||
suspendedBy: promotions.suspendedBy,
|
||||
shareFacebook: promotions.shareFacebook,
|
||||
shareLinkedin: promotions.shareLinkedin,
|
||||
})
|
||||
.from(promotions)
|
||||
.where(eq(promotions.memberId, memberId))
|
||||
.orderBy(desc(promotions.createdAt));
|
||||
if (myPromos.length > 0) {
|
||||
const rows = await db
|
||||
.select({
|
||||
promotionId: socialPosts.promotionId,
|
||||
network: socialPosts.network,
|
||||
status: socialPosts.status,
|
||||
url: socialPosts.url,
|
||||
error: socialPosts.error,
|
||||
})
|
||||
.from(socialPosts)
|
||||
.where(inArray(socialPosts.promotionId, myPromos.map((p) => p.id)))
|
||||
.orderBy(desc(socialPosts.createdAt));
|
||||
// Trié du plus récent au plus ancien : la première occurrence gagne.
|
||||
lastShare = new Map();
|
||||
for (const row of rows) {
|
||||
const key = `${row.promotionId}:${row.network}`;
|
||||
if (!lastShare.has(key)) lastShare.set(key, row);
|
||||
}
|
||||
}
|
||||
myRegistrations = await db
|
||||
.select({
|
||||
meetingId: meetings.id,
|
||||
title: meetings.title,
|
||||
startsAt: meetings.startsAt,
|
||||
location: meetings.location,
|
||||
participants: sql<number>`count(${meetingRegistrations.id})`,
|
||||
confirmed: sql<boolean>`bool_and(${meetingRegistrations.status} = 'confirmed')`,
|
||||
})
|
||||
.from(meetingRegistrations)
|
||||
.innerJoin(meetings, eq(meetings.id, meetingRegistrations.meetingId))
|
||||
.where(and(eq(meetingRegistrations.memberId, memberId), gte(meetings.startsAt, new Date())))
|
||||
.groupBy(meetings.id)
|
||||
.orderBy(asc(meetings.startsAt));
|
||||
const [consent] = await db
|
||||
.select({ decision: imageConsents.decision, createdAt: imageConsents.createdAt })
|
||||
.from(imageConsents)
|
||||
.where(eq(imageConsents.memberId, memberId))
|
||||
.orderBy(desc(imageConsents.createdAt))
|
||||
.limit(1);
|
||||
latestConsent = consent ?? null;
|
||||
}
|
||||
|
||||
const catRows = await db
|
||||
@@ -58,6 +125,7 @@ export default async function EspacePage() {
|
||||
.from(categories)
|
||||
.orderBy(asc(categories.sort));
|
||||
const categoryLabels = catRows.map((c) => c.label);
|
||||
const networks = await configuredNetworks();
|
||||
|
||||
function fmtDate(d: Date) {
|
||||
return new Date(d).toLocaleDateString("fr-FR", { day: "numeric", month: "long" });
|
||||
@@ -84,6 +152,37 @@ export default async function EspacePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{memberId && (
|
||||
<section style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 24, marginBottom: 28 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, alignItems: "end", marginBottom: 15, flexWrap: "wrap" }}>
|
||||
<div>
|
||||
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 18, margin: 0, color: "#26201a" }}>Mes inscriptions</h3>
|
||||
<div style={{ color: "#8c8068", fontSize: 13, marginTop: 4 }}>Participants inscrits aux prochaines rencontres.</div>
|
||||
</div>
|
||||
<Link href="/association" style={{ color: "#9a6638", fontSize: 13, fontWeight: 800, textDecoration: "none" }}>Voir les rencontres</Link>
|
||||
</div>
|
||||
{myRegistrations.length === 0 && <div style={{ color: "#a99c82", fontSize: 13.5 }}>Aucune inscription à venir.</div>}
|
||||
<div style={{ display: "grid", gap: 10 }}>
|
||||
{myRegistrations.map((registration) => (
|
||||
<div key={registration.meetingId} style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 14, borderTop: "1px solid #f0e8d6", paddingTop: 12, flexWrap: "wrap" }}>
|
||||
<div>
|
||||
<div style={{ color: "#26201a", fontSize: 14, fontWeight: 800 }}>{registration.title}</div>
|
||||
<div style={{ color: "#8c8068", fontSize: 12.5, marginTop: 3 }}>
|
||||
{fmtDate(registration.startsAt)} · {registration.participants} participant{registration.participants > 1 ? "s" : ""}{registration.location ? ` · ${registration.location}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<span style={{ background: registration.confirmed ? "#e6f4ec" : "#fbeede", color: registration.confirmed ? "#1f8a5b" : "#9a6638", borderRadius: 999, padding: "5px 10px", fontSize: 11.5, fontWeight: 800 }}>
|
||||
{registration.confirmed ? "Confirmée" : "En attente"}
|
||||
</span>
|
||||
<Link href={`/inscription/${registration.meetingId}`} style={{ color: "#9a6638", fontSize: 12.5, fontWeight: 800, textDecoration: "none" }}>Modifier</Link>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{profile && (
|
||||
<form action={updateOwnProfile} style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, padding: 24, marginBottom: 28 }}>
|
||||
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 18, margin: "0 0 18px", color: "#26201a" }}>
|
||||
@@ -140,8 +239,7 @@ export default async function EspacePage() {
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<label className="field-label">Horaires (une ligne par jour : « Jour|plage »)</label>
|
||||
<textarea name="hours" rows={4} defaultValue={profile.hours ?? ""} className="field" style={{ resize: "vertical" }} placeholder={"Mardi – Vendredi|7h – 19h30\nSamedi|7h – 19h"} />
|
||||
<HoursEditor defaultValue={profile.hours} />
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -154,41 +252,109 @@ export default async function EspacePage() {
|
||||
</form>
|
||||
)}
|
||||
|
||||
<MemberSpaceForm memberName={memberName} categories={categoryLabels} />
|
||||
{memberId && (
|
||||
<section style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, padding: 24, marginBottom: 28 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, alignItems: "start", flexWrap: "wrap", marginBottom: 14 }}>
|
||||
<div>
|
||||
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 18, margin: 0, color: "#26201a" }}>
|
||||
Droit à l'image
|
||||
</h3>
|
||||
<div style={{ color: "#8c8068", fontSize: 13, marginTop: 4 }}>
|
||||
{latestConsent?.decision === "accepted"
|
||||
? "Autorisation signée et enregistrée. Vous pouvez la retirer à tout moment."
|
||||
: "Vous pouvez autoriser l'utilisation de votre image à tout moment."}
|
||||
</div>
|
||||
</div>
|
||||
{latestConsent && (
|
||||
<span style={{ background: latestConsent.decision === "accepted" ? "#e6f4ec" : "#fbe9e6", color: latestConsent.decision === "accepted" ? "#1f8a5b" : "#d8472b", borderRadius: 999, padding: "6px 12px", fontSize: 12, fontWeight: 800 }}>
|
||||
{latestConsent.decision === "accepted" ? "Autorisé" : "Refusé"} · {fmtDate(latestConsent.createdAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ImageConsentForm
|
||||
saveAction={saveImageConsent}
|
||||
defaultName={memberName}
|
||||
compact
|
||||
currentDecision={latestConsent?.decision === "accepted" ? "accepted" : latestConsent?.decision === "refused" ? "refused" : null}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<MemberSpaceForm memberName={memberName} categories={categoryLabels} networks={networks} />
|
||||
|
||||
<div style={{ marginTop: 28 }}>
|
||||
<div style={{ fontSize: 12.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 700, marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 12.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 700, marginBottom: 4 }}>
|
||||
Mes promotions
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "#8c8068", marginBottom: 12 }}>
|
||||
Vous pouvez suspendre une promotion en ligne à tout moment : elle disparaît du site et
|
||||
vous la remettez en ligne quand vous voulez.
|
||||
</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;
|
||||
const suspendedByStaff = mp.status === "suspended" && mp.suspendedBy === "staff";
|
||||
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 key={mp.id} style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 12, padding: "12px 14px", marginBottom: 10 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 13 }}>
|
||||
<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,
|
||||
opacity: mp.status === "suspended" ? 0.5 : 1,
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
<div style={{ fontSize: 12, color: "#a99c82" }}>Publié le {fmtDate(mp.createdAt)}</div>
|
||||
<span style={{ display: "inline-block", fontSize: 11.5, fontWeight: 700, padding: "4px 11px", borderRadius: 999, background: st.bg, color: st.color, flexShrink: 0 }}>
|
||||
{st.label}
|
||||
</span>
|
||||
{mp.status === "live" && (
|
||||
<form action={setOwnPromoSuspension}>
|
||||
<input type="hidden" name="id" value={mp.id} />
|
||||
<input type="hidden" name="action" value="suspend" />
|
||||
<button type="submit" style={{ border: "1px solid #e0c3bb", background: "#fff", color: "#d8472b", fontWeight: 700, fontSize: 12.5, padding: "7px 13px", borderRadius: 9, cursor: "pointer", whiteSpace: "nowrap" }}>
|
||||
Suspendre
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{mp.status === "suspended" && !suspendedByStaff && (
|
||||
<form action={setOwnPromoSuspension}>
|
||||
<input type="hidden" name="id" value={mp.id} />
|
||||
<input type="hidden" name="action" value="restore" />
|
||||
<button type="submit" style={{ border: "none", background: "#1f8a5b", color: "#fff", fontWeight: 700, fontSize: 12.5, padding: "8px 14px", borderRadius: 9, cursor: "pointer", whiteSpace: "nowrap" }}>
|
||||
Remettre en ligne
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</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>
|
||||
{suspendedByStaff && (
|
||||
<div style={{ marginTop: 10, background: "#fbe9e6", border: "1px solid #f2d5cf", color: "#a8503c", borderRadius: 9, padding: "9px 11px", fontSize: 12.5, lineHeight: 1.5 }}>
|
||||
Cette promotion a été suspendue par l'association. Contactez-la pour la
|
||||
remettre en ligne.
|
||||
</div>
|
||||
)}
|
||||
{networks.length > 0 && (
|
||||
<PromoShareState
|
||||
promo={mp}
|
||||
networks={networks}
|
||||
lastShare={lastShare}
|
||||
editable={mp.status === "pending"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -196,3 +362,77 @@ export default async function EspacePage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* État de diffusion d'une promo côté adhérent : modifiable tant qu'elle est en
|
||||
* attente, purement informatif dès qu'elle est validée.
|
||||
*/
|
||||
function PromoShareState({
|
||||
promo,
|
||||
networks,
|
||||
lastShare,
|
||||
editable,
|
||||
}: {
|
||||
promo: { id: number; shareFacebook: boolean; shareLinkedin: boolean };
|
||||
networks: SocialNetwork[];
|
||||
lastShare: Map<string, { status: string; url: string | null; error: string | null }>;
|
||||
editable: boolean;
|
||||
}) {
|
||||
const wanted = (n: SocialNetwork) => (n === "facebook" ? promo.shareFacebook : promo.shareLinkedin);
|
||||
const chosen = networks.filter(wanted);
|
||||
|
||||
if (editable) {
|
||||
return (
|
||||
<form action={setOwnPromoShareTargets} style={{ marginTop: 11, borderTop: "1px solid #f0e8d6", paddingTop: 11 }}>
|
||||
<input type="hidden" name="id" value={promo.id} />
|
||||
<div style={{ fontSize: 11.5, color: "#8c8068", marginBottom: 8, lineHeight: 1.5 }}>
|
||||
Diffusion prévue après validation — modifiable tant que la promotion est en attente.
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", alignItems: "center" }}>
|
||||
{networks.map((network) => (
|
||||
<label key={network} style={{ display: "inline-flex", alignItems: "center", gap: 7, fontSize: 12.5, fontWeight: 700, color: SOCIAL_BRAND[network], cursor: "pointer" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
name={network === "facebook" ? "shareFacebook" : "shareLinkedin"}
|
||||
defaultChecked={wanted(network)}
|
||||
style={{ accentColor: SOCIAL_BRAND[network], margin: 0 }}
|
||||
/>
|
||||
<SocialIcon network={network} size={15} />
|
||||
{SOCIAL_LABELS[network]}
|
||||
</label>
|
||||
))}
|
||||
<button type="submit" style={{ border: "1px solid #d8cdb4", background: "#fff", color: "#6c6150", fontWeight: 700, fontSize: 12, padding: "6px 12px", borderRadius: 8, cursor: "pointer" }}>
|
||||
Mettre à jour
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
if (chosen.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 11, borderTop: "1px solid #f0e8d6", paddingTop: 11, display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
{chosen.map((network) => {
|
||||
const last = lastShare.get(`${promo.id}:${network}`);
|
||||
const posted = last?.status === "posted";
|
||||
const failed = last?.status === "failed";
|
||||
const detail = posted ? "publié" : failed ? "échec de publication" : "en attente";
|
||||
const color = posted ? "#1f8a5b" : failed ? "#d8472b" : "#9a6638";
|
||||
const chip = (
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 7, background: "#faf7ef", border: "1px solid #f0e8d6", borderRadius: 999, padding: "5px 12px", fontSize: 11.5, fontWeight: 700, color }}>
|
||||
<SocialIcon network={network} size={13} />
|
||||
{SOCIAL_LABELS[network]} · {detail}
|
||||
</span>
|
||||
);
|
||||
return posted && last?.url ? (
|
||||
<a key={network} href={last.url} target="_blank" rel="noopener noreferrer" style={{ textDecoration: "none" }}>
|
||||
{chip}
|
||||
</a>
|
||||
) : (
|
||||
<span key={network}>{chip}</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import type { CSSProperties } from "react";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { db } from "@/db";
|
||||
import { meetingRegistrations, meetings } from "@/db/schema";
|
||||
import { can } from "@/lib/rbac";
|
||||
import {
|
||||
confirmMeetingRegistration,
|
||||
deleteMeetingRegistration,
|
||||
updateMeetingRegistration,
|
||||
} from "../actions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function formatDate(date: Date) {
|
||||
return new Intl.DateTimeFormat("fr-FR", { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" }).format(date);
|
||||
}
|
||||
|
||||
export default async function InscriptionsPage({ searchParams }: { searchParams: Promise<{ meeting?: string }> }) {
|
||||
const session = await getSession();
|
||||
if (!can(session?.user.role, "manageMeetings")) redirect("/backend");
|
||||
const { meeting: meetingParam } = await searchParams;
|
||||
const selectedMeetingId = Number(meetingParam) || null;
|
||||
|
||||
const [meetingRows, registrationRows] = await Promise.all([
|
||||
db.select({ id: meetings.id, title: meetings.title, startsAt: meetings.startsAt }).from(meetings).orderBy(desc(meetings.startsAt)),
|
||||
db
|
||||
.select({
|
||||
id: meetingRegistrations.id,
|
||||
meetingId: meetingRegistrations.meetingId,
|
||||
meetingTitle: meetings.title,
|
||||
meetingStartsAt: meetings.startsAt,
|
||||
attendeeName: meetingRegistrations.attendeeName,
|
||||
attendeeCompany: meetingRegistrations.attendeeCompany,
|
||||
attendeeEmail: meetingRegistrations.attendeeEmail,
|
||||
attendeePhone: meetingRegistrations.attendeePhone,
|
||||
status: meetingRegistrations.status,
|
||||
imageConsent: meetingRegistrations.imageConsent,
|
||||
createdAt: meetingRegistrations.createdAt,
|
||||
})
|
||||
.from(meetingRegistrations)
|
||||
.innerJoin(meetings, eq(meetings.id, meetingRegistrations.meetingId))
|
||||
.where(selectedMeetingId ? and(eq(meetingRegistrations.meetingId, selectedMeetingId)) : undefined)
|
||||
.orderBy(desc(meetings.startsAt), desc(meetingRegistrations.createdAt)),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gap: 22 }}>
|
||||
<section style={{ display: "flex", justifyContent: "space-between", alignItems: "end", gap: 16, flexWrap: "wrap" }}>
|
||||
<div>
|
||||
<div style={sectionLabel}>Participants</div>
|
||||
<div style={{ color: "#6c6150", fontSize: 13.5, marginTop: 5 }}>{registrationRows.length} inscription{registrationRows.length > 1 ? "s" : ""} affichée{registrationRows.length > 1 ? "s" : ""}</div>
|
||||
</div>
|
||||
<form method="get" style={{ display: "flex", gap: 9, alignItems: "end", flexWrap: "wrap" }}>
|
||||
<div>
|
||||
<label className="field-label" htmlFor="meeting-filter">Rencontre</label>
|
||||
<select id="meeting-filter" name="meeting" className="field" defaultValue={selectedMeetingId ?? ""} style={{ minWidth: 260 }}>
|
||||
<option value="">Toutes les rencontres</option>
|
||||
{meetingRows.map((meeting) => <option key={meeting.id} value={meeting.id}>{meeting.title}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" style={secondaryButton}>Filtrer</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{registrationRows.map((registration) => (
|
||||
<article key={registration.id} style={cardStyle}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: 14, alignItems: "start", flexWrap: "wrap", marginBottom: 16 }}>
|
||||
<div>
|
||||
<div className="font-display" style={{ color: "#26201a", fontSize: 19, fontWeight: 800 }}>{registration.attendeeName}</div>
|
||||
<div style={{ color: "#8c8068", fontSize: 12.5, marginTop: 3 }}>{registration.meetingTitle} · {formatDate(registration.meetingStartsAt)}</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<span style={registration.status === "confirmed" ? confirmedBadge : pendingBadge}>{registration.status === "confirmed" ? "Confirmée" : "En attente"}</span>
|
||||
<span style={registration.imageConsent ? consentBadge : refusalBadge}>{registration.imageConsent ? "Image autorisée" : "Image refusée"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form action={updateMeetingRegistration} className="grid grid-2" style={{ gap: 12 }}>
|
||||
<input type="hidden" name="id" value={registration.id} />
|
||||
<div>
|
||||
<label className="field-label">Participant</label>
|
||||
<input name="attendeeName" className="field" defaultValue={registration.attendeeName} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Entreprise</label>
|
||||
<input name="attendeeCompany" className="field" defaultValue={registration.attendeeCompany} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">E-mail</label>
|
||||
<input name="attendeeEmail" type="email" className="field" defaultValue={registration.attendeeEmail ?? ""} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Téléphone</label>
|
||||
<input name="attendeePhone" className="field" defaultValue={registration.attendeePhone ?? ""} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Statut</label>
|
||||
<select name="status" className="field" defaultValue={registration.status}>
|
||||
<option value="pending">En attente</option>
|
||||
<option value="confirmed">Confirmée</option>
|
||||
</select>
|
||||
</div>
|
||||
<label style={{ display: "flex", gap: 9, alignItems: "center", color: "#6c6150", fontSize: 13, paddingTop: 22 }}>
|
||||
<input type="checkbox" name="imageConsent" defaultChecked={registration.imageConsent} />
|
||||
Publication de l'image autorisée
|
||||
</label>
|
||||
<button type="submit" style={primaryButton}>Enregistrer</button>
|
||||
</form>
|
||||
|
||||
<div style={{ display: "flex", gap: 10, marginTop: 12, paddingTop: 12, borderTop: "1px solid #f0e8d6", flexWrap: "wrap" }}>
|
||||
{registration.status !== "confirmed" && (
|
||||
<form action={confirmMeetingRegistration}>
|
||||
<input type="hidden" name="id" value={registration.id} />
|
||||
<button type="submit" style={confirmButton}>Confirmer</button>
|
||||
</form>
|
||||
)}
|
||||
<form action={deleteMeetingRegistration}>
|
||||
<input type="hidden" name="id" value={registration.id} />
|
||||
<button type="submit" style={dangerButton}>Annuler l'inscription</button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
|
||||
{registrationRows.length === 0 && <div style={emptyStyle}>Aucune inscription pour cette sélection.</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sectionLabel: CSSProperties = { fontSize: 12.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800 };
|
||||
const cardStyle: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 20 };
|
||||
const badgeBase: CSSProperties = { display: "inline-block", borderRadius: 999, padding: "5px 10px", fontSize: 11.5, fontWeight: 800 };
|
||||
const confirmedBadge: CSSProperties = { ...badgeBase, background: "#e6f4ec", color: "#1f8a5b" };
|
||||
const pendingBadge: CSSProperties = { ...badgeBase, background: "#fbeede", color: "#9a6638" };
|
||||
const consentBadge: CSSProperties = { ...badgeBase, background: "#eaf0f6", color: "#2C6FB3" };
|
||||
const refusalBadge: CSSProperties = { ...badgeBase, background: "#fbe9e6", color: "#d8472b" };
|
||||
const primaryButton: CSSProperties = { border: "none", background: "#13324F", color: "#fff", borderRadius: 8, padding: "11px 16px", fontWeight: 800, cursor: "pointer" };
|
||||
const secondaryButton: CSSProperties = { border: "1px solid #d8cdb4", background: "#fff", color: "#6f6450", borderRadius: 8, padding: "11px 16px", fontWeight: 800, cursor: "pointer" };
|
||||
const confirmButton: CSSProperties = { border: "none", background: "#1f8a5b", color: "#fff", borderRadius: 8, padding: "9px 14px", fontWeight: 800, cursor: "pointer" };
|
||||
const dangerButton: CSSProperties = { border: "1px solid #e0c3bb", background: "#fff", color: "#d8472b", borderRadius: 8, padding: "8px 13px", fontWeight: 800, cursor: "pointer" };
|
||||
const emptyStyle: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 28, color: "#8c8068", textAlign: "center" };
|
||||
@@ -1,23 +1,75 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { auth } from "@/auth";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { db } from "@/db";
|
||||
import { contactMessages, membershipRequests, promotions } from "@/db/schema";
|
||||
import { contactMessages, imageConsents, membershipRequests, promotions } from "@/db/schema";
|
||||
import { ImageConsentForm } from "@/components/ImageConsentForm";
|
||||
import { isStaff } from "@/lib/rbac";
|
||||
import { saveImageConsent } from "./actions";
|
||||
import { BackendShell } from "./BackendShell";
|
||||
import { NOINDEX } from "@/lib/seo";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Espace privé : jamais indexé, même si une page fuyait hors du middleware.
|
||||
export const metadata: Metadata = {
|
||||
title: "Espace adhérent",
|
||||
robots: NOINDEX,
|
||||
};
|
||||
|
||||
export default async function BackendLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const session = await auth();
|
||||
const session = await getSession();
|
||||
if (!session?.user) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
if (
|
||||
session.user.role === "member" &&
|
||||
session.user.memberId &&
|
||||
!session.user.mustChangePassword
|
||||
) {
|
||||
const latestConsent = await db
|
||||
.select({ id: imageConsents.id })
|
||||
.from(imageConsents)
|
||||
.where(eq(imageConsents.memberId, session.user.memberId))
|
||||
.orderBy(desc(imageConsents.createdAt))
|
||||
.limit(1);
|
||||
|
||||
if (latestConsent.length === 0) {
|
||||
return (
|
||||
<div style={{ minHeight: "100vh", background: "#F6F2E8", color: "#33291D", fontFamily: "'Public Sans',sans-serif", padding: "clamp(22px, 5vw, 54px)" }}>
|
||||
<main style={{ maxWidth: 860, margin: "0 auto" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 14, marginBottom: 24 }}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src="/assets/logo.png" alt="Plein R" style={{ height: 50, width: "auto" }} />
|
||||
<div>
|
||||
<div className="font-display" style={{ fontSize: 24, fontWeight: 800, color: "#26201a" }}>
|
||||
Consentement RGPD
|
||||
</div>
|
||||
<div style={{ color: "#8c8068", fontSize: 14 }}>Droit à l'image - première connexion adhérent</div>
|
||||
</div>
|
||||
</div>
|
||||
<section style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 20, padding: "clamp(22px, 5vw, 36px)", boxShadow: "0 24px 55px -42px rgba(40,30,15,0.55)" }}>
|
||||
<h1 className="font-display" style={{ margin: "0 0 10px", color: "#26201a", fontSize: "clamp(28px, 5vw, 40px)" }}>
|
||||
Votre choix sur le droit à l'image
|
||||
</h1>
|
||||
<p style={{ margin: "0 0 20px", color: "#6c6150", lineHeight: 1.7 }}>
|
||||
Avant d'accéder à votre espace, indiquez si Plein R peut utiliser votre image dans
|
||||
les supports liés aux rencontres et à la vie de l'association.
|
||||
</p>
|
||||
<ImageConsentForm saveAction={saveImageConsent} defaultName={session.user.name ?? "Adhérent"} />
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let pendingCount = 0;
|
||||
let inboxCount = 0;
|
||||
if (isStaff(session.user.role)) {
|
||||
|
||||
+49
-11
@@ -1,10 +1,12 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { auth } from "@/auth";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { db } from "@/db";
|
||||
import { activityLog, contactMessages, members, membershipRequests, promotions } from "@/db/schema";
|
||||
import { isStaff } from "@/lib/rbac";
|
||||
import { activityNodes } from "@/lib/activity";
|
||||
import { can, isStaff } from "@/lib/rbac";
|
||||
import { expiryStatus, getSocialAccounts, SOCIAL_LABELS } from "@/lib/social-accounts";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -22,18 +24,18 @@ function StatCard({
|
||||
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 }}>
|
||||
<div className="stat-card" style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16 }}>
|
||||
<div className="stat-card__label">{label}</div>
|
||||
<div className="font-display stat-card__value" style={{ color: valueColor }}>
|
||||
{value}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: hintColor, marginTop: 6, fontWeight: 600 }}>{hint}</div>
|
||||
<div className="stat-card__hint" style={{ color: hintColor }}>{hint}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await auth();
|
||||
const session = await getSession();
|
||||
if (!isStaff(session?.user.role)) {
|
||||
redirect("/backend/espace");
|
||||
}
|
||||
@@ -49,6 +51,15 @@ export default async function DashboardPage() {
|
||||
|
||||
const pendingCount = pendingPromos.length;
|
||||
|
||||
// Jetons réseaux à renouveler : sans alerte, on ne découvre l'expiration
|
||||
// qu'au moment où une publication échoue.
|
||||
const expiringNetworks = can(session?.user.role, "manageSettings")
|
||||
? (await getSocialAccounts())
|
||||
.filter((a) => a.accessToken && a.targetId)
|
||||
.map((a) => ({ network: a.network, status: expiryStatus(a.expiresAt) }))
|
||||
.filter((a) => a.status === "soon" || a.status === "expired")
|
||||
: [];
|
||||
|
||||
function timeAgo(date: Date) {
|
||||
const diff = Date.now() - new Date(date).getTime();
|
||||
const h = Math.floor(diff / 3_600_000);
|
||||
@@ -60,6 +71,34 @@ export default async function DashboardPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{expiringNetworks.length > 0 && (
|
||||
<Link
|
||||
href="/backend/reseaux"
|
||||
style={{
|
||||
display: "block",
|
||||
textDecoration: "none",
|
||||
background: "#fbeede",
|
||||
border: "1px solid #ecd8b8",
|
||||
color: "#9a6638",
|
||||
borderRadius: 12,
|
||||
padding: "13px 16px",
|
||||
marginBottom: 20,
|
||||
fontSize: 13.5,
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
{expiringNetworks.map((n) => (
|
||||
<div key={n.network}>
|
||||
<strong>{SOCIAL_LABELS[n.network]}</strong>{" "}
|
||||
{n.status === "expired"
|
||||
? "— le jeton a expiré, les publications échoueront."
|
||||
: "— le jeton expire dans moins de 7 jours."}{" "}
|
||||
Reconnecter le compte →
|
||||
</div>
|
||||
))}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<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" />
|
||||
@@ -87,10 +126,9 @@ export default async function DashboardPage() {
|
||||
{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 }}
|
||||
/>
|
||||
<div style={{ flex: 1, fontSize: 13.5, color: "#3c3322" }}>
|
||||
{activityNodes(a.message)}
|
||||
</div>
|
||||
<span style={{ fontSize: 12, color: "#a99c82", whiteSpace: "nowrap" }}>
|
||||
{timeAgo(a.createdAt)}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import type { CSSProperties } from "react";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { ImageField } from "@/components/ImageField";
|
||||
import { can } from "@/lib/rbac";
|
||||
import { getSiteSettings } from "@/lib/site-settings";
|
||||
import { saveSiteSettings } from "../actions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ParametresPage() {
|
||||
const session = await getSession();
|
||||
if (!can(session?.user.role, "manageSettings")) redirect("/backend");
|
||||
|
||||
const settings = await getSiteSettings();
|
||||
|
||||
return (
|
||||
<form action={saveSiteSettings} style={{ display: "grid", gap: 22, maxWidth: 1080 }}>
|
||||
<section style={panel}>
|
||||
<h2 className="font-display" style={title}>Identité du site</h2>
|
||||
<div className="grid grid-2" style={{ gap: 16 }}>
|
||||
<Field label="Nom association" name="association_name" value={settings.association_name} />
|
||||
<Field label="Slogan" name="association_tagline" value={settings.association_tagline} />
|
||||
<Field label="E-mail" name="association_email" value={settings.association_email} />
|
||||
<Field label="Téléphone" name="association_phone" value={settings.association_phone} />
|
||||
<Field label="Site web" name="association_website" value={settings.association_website} />
|
||||
<Field label="Contact principal" name="association_contact" value={settings.association_contact} />
|
||||
<Field
|
||||
label="Page Facebook"
|
||||
name="association_facebook"
|
||||
value={settings.association_facebook}
|
||||
placeholder="https://www.facebook.com/..."
|
||||
/>
|
||||
<Field
|
||||
label="Page LinkedIn"
|
||||
name="association_linkedin"
|
||||
value={settings.association_linkedin}
|
||||
placeholder="https://www.linkedin.com/company/..."
|
||||
/>
|
||||
<div style={{ gridColumn: "1 / -1" }}>
|
||||
<Field label="Adresse" name="association_address" value={settings.association_address} />
|
||||
</div>
|
||||
<Area label="Texte d'introduction Association" name="association_intro" value={settings.association_intro} />
|
||||
<Area
|
||||
label="Mission (une ligne vide sépare les paragraphes)"
|
||||
name="association_mission"
|
||||
value={settings.association_mission}
|
||||
/>
|
||||
<Area
|
||||
label="Ce que l'association apporte (Titre|Description, une ligne par point)"
|
||||
name="association_pillars"
|
||||
value={settings.association_pillars}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginTop: 14, color: "#9a6638", fontSize: 13, lineHeight: 1.6 }}>
|
||||
Les pages Facebook et LinkedIn s'affichent sur la page d'accueil et dans le pied
|
||||
de page. Laissez un champ vide pour masquer le réseau correspondant. La publication
|
||||
automatique des promotions, elle, se configure côté serveur (jetons d'accès dans les
|
||||
variables d'environnement).
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={panel}>
|
||||
<h2 className="font-display" style={title}>Directoire</h2>
|
||||
<div className="grid grid-2" style={{ gap: 16 }}>
|
||||
<Field label="Président(e)" name="association_president" value={settings.association_president} />
|
||||
<Field label="Intitulé du rôle" name="association_president_role" value={settings.association_president_role} />
|
||||
<Field label="Vice-président(e)" name="association_vice_president" value={settings.association_vice_president} />
|
||||
<Field label="Trésorier(ère)" name="association_treasurer" value={settings.association_treasurer} />
|
||||
<Field label="Secrétaire" name="association_secretary" value={settings.association_secretary} />
|
||||
<ImageField name="association_president_photo" label="Photo président(e)" defaultValue={settings.association_president_photo} height={130} />
|
||||
<Area label="Message président(e)" name="association_president_message" value={settings.association_president_message} />
|
||||
<Area
|
||||
label="Autres membres du directoire (Nom|Fonction, une ligne par personne)"
|
||||
name="association_board_members"
|
||||
value={settings.association_board_members}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={panel}>
|
||||
<h2 className="font-display" style={title}>Mentions légales & confidentialité</h2>
|
||||
<div className="grid grid-2" style={{ gap: 16 }}>
|
||||
<Field label="SIRET" name="association_siret" value={settings.association_siret} />
|
||||
<Field label="RNA" name="association_rna" value={settings.association_rna} />
|
||||
<Field label="Directeur de publication" name="association_publication_director" value={settings.association_publication_director} />
|
||||
<Field label="Contact confidentialité/RGPD" name="association_privacy_contact" value={settings.association_privacy_contact} />
|
||||
<Field label="Hébergeur" name="association_host_name" value={settings.association_host_name} />
|
||||
<Field label="Téléphone hébergeur" name="association_host_phone" value={settings.association_host_phone} />
|
||||
<div style={{ gridColumn: "1 / -1" }}>
|
||||
<Field label="Adresse hébergeur" name="association_host_address" value={settings.association_host_address} />
|
||||
</div>
|
||||
<Field label="Dernière mise à jour légale" name="legal_updated" value={settings.legal_updated} placeholder="17 juillet 2026" />
|
||||
</div>
|
||||
<div style={{ marginTop: 14, color: "#9a6638", fontSize: 13, lineHeight: 1.6 }}>
|
||||
Remplissez ces champs avant mise en production: ils alimentent les pages publiques
|
||||
Mentions légales et Confidentialité.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button type="submit" style={submitButton}>Enregistrer les paramètres</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
name,
|
||||
value,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string;
|
||||
name: string;
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="field-label">{label}</label>
|
||||
<input name={name} defaultValue={value} placeholder={placeholder} className="field" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Area({ label, name, value }: { label: string; name: string; value: string }) {
|
||||
return (
|
||||
<div style={{ gridColumn: "1 / -1" }}>
|
||||
<label className="field-label">{label}</label>
|
||||
<textarea name={name} rows={4} defaultValue={value} className="field" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const panel: CSSProperties = {
|
||||
background: "#fff",
|
||||
border: "1px solid #e6dcc6",
|
||||
borderRadius: 16,
|
||||
padding: 22,
|
||||
};
|
||||
|
||||
const title: CSSProperties = {
|
||||
margin: "0 0 16px",
|
||||
fontSize: 20,
|
||||
color: "#26201a",
|
||||
};
|
||||
|
||||
const submitButton: CSSProperties = {
|
||||
justifySelf: "start",
|
||||
border: "none",
|
||||
background: "#13324F",
|
||||
color: "#fff",
|
||||
fontWeight: 800,
|
||||
fontSize: 14.5,
|
||||
padding: "13px 22px",
|
||||
borderRadius: 10,
|
||||
cursor: "pointer",
|
||||
};
|
||||
@@ -1,11 +1,13 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { desc, eq, inArray } from "drizzle-orm";
|
||||
import { auth } from "@/auth";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { db } from "@/db";
|
||||
import { members, promotions } from "@/db/schema";
|
||||
import { members, promotions, socialPosts, users } from "@/db/schema";
|
||||
import { can } from "@/lib/rbac";
|
||||
import { moderatePromo } from "../actions";
|
||||
import { configuredNetworks, SOCIAL_LABELS } from "@/lib/social";
|
||||
import { moderatePromo, retryPromoShare } from "../actions";
|
||||
import { PromoImage } from "@/components/PromoImage";
|
||||
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -14,11 +16,23 @@ const STRIPE_WARM =
|
||||
const STRIPE_COOL =
|
||||
"repeating-linear-gradient(45deg,#eef0ec,#eef0ec 11px,#e2e8e6 11px,#e2e8e6 22px)";
|
||||
|
||||
const STATUS_CHIP: Record<string, { label: string; bg: string; color: string }> = {
|
||||
pending: { label: "En attente", bg: "#fbeede", color: "#9a6638" },
|
||||
live: { label: "En ligne", bg: "#e6f4ec", color: "#1f8a5b" },
|
||||
suspended: { label: "Suspendue", bg: "#fbe9e6", color: "#d8472b" },
|
||||
};
|
||||
|
||||
function fmtDate(d: Date) {
|
||||
return new Date(d).toLocaleDateString("fr-FR", { day: "numeric", month: "long", year: "numeric" });
|
||||
}
|
||||
|
||||
export default async function PromotionsPage() {
|
||||
const session = await auth();
|
||||
const session = await getSession();
|
||||
if (!can(session?.user.role, "moderatePromos")) {
|
||||
redirect("/backend");
|
||||
}
|
||||
const canShare = can(session?.user.role, "publishSocial");
|
||||
const networks = canShare ? await configuredNetworks() : [];
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
@@ -28,17 +42,47 @@ export default async function PromotionsPage() {
|
||||
category: promotions.category,
|
||||
status: promotions.status,
|
||||
imageUrl: promotions.imageUrl,
|
||||
suspendedBy: promotions.suspendedBy,
|
||||
suspendedAt: promotions.suspendedAt,
|
||||
shareFacebook: promotions.shareFacebook,
|
||||
shareLinkedin: promotions.shareLinkedin,
|
||||
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));
|
||||
.where(inArray(promotions.status, ["pending", "live", "suspended"]))
|
||||
.orderBy(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];
|
||||
const suspended = rows.filter((r) => r.status === "suspended");
|
||||
// En attente d'abord, puis en ligne, puis suspendues.
|
||||
const ordered = [...pending, ...live, ...suspended];
|
||||
|
||||
const shares = ordered.length
|
||||
? await db
|
||||
.select({
|
||||
promotionId: socialPosts.promotionId,
|
||||
network: socialPosts.network,
|
||||
status: socialPosts.status,
|
||||
url: socialPosts.url,
|
||||
error: socialPosts.error,
|
||||
createdAt: socialPosts.createdAt,
|
||||
authorName: users.name,
|
||||
})
|
||||
.from(socialPosts)
|
||||
.leftJoin(users, eq(socialPosts.postedById, users.id))
|
||||
.where(inArray(socialPosts.promotionId, ordered.map((r) => r.id)))
|
||||
.orderBy(desc(socialPosts.createdAt))
|
||||
: [];
|
||||
|
||||
// La liste est triée du plus récent au plus ancien : la première occurrence
|
||||
// rencontrée pour un couple (promo, réseau) est donc la plus récente.
|
||||
const lastShare = new Map<string, (typeof shares)[number]>();
|
||||
for (const s of shares) {
|
||||
const key = `${s.promotionId}:${s.network}`;
|
||||
if (!lastShare.has(key)) lastShare.set(key, s);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -49,17 +93,32 @@ export default async function PromotionsPage() {
|
||||
<span style={{ background: "#fff", border: "1px solid #e6dcc6", color: "#6c6150", fontSize: 13, fontWeight: 600, padding: "8px 16px", borderRadius: 999 }}>
|
||||
En ligne · {live.length}
|
||||
</span>
|
||||
<span style={{ background: "#fff", border: "1px solid #e6dcc6", color: "#6c6150", fontSize: 13, fontWeight: 600, padding: "8px 16px", borderRadius: 999 }}>
|
||||
Suspendues · {suspended.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{canShare && networks.length === 0 && (
|
||||
<div style={{ background: "#fbeede", border: "1px solid #ecd8b8", color: "#9a6638", borderRadius: 12, padding: "12px 16px", marginBottom: 18, fontSize: 13.5, lineHeight: 1.55 }}>
|
||||
Publication sur les réseaux indisponible : aucun jeton d'accès Facebook ou LinkedIn
|
||||
n'est configuré sur le serveur (voir <code>.env.example</code>).
|
||||
</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 }}>
|
||||
<div className="grid grid-3" style={{ gap: 16, width: "100%", maxWidth: 960 }}>
|
||||
{ordered.map((p) => {
|
||||
const isPending = p.status === "pending";
|
||||
const isSuspended = p.status === "suspended";
|
||||
const chip = STATUS_CHIP[p.status] ?? STATUS_CHIP.pending;
|
||||
const chosenNetworks = networks.filter((n) =>
|
||||
n === "facebook" ? p.shareFacebook : p.shareLinkedin
|
||||
);
|
||||
return (
|
||||
<article
|
||||
key={p.id}
|
||||
@@ -75,12 +134,13 @@ export default async function PromotionsPage() {
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
aspectRatio: "1 / 1",
|
||||
aspectRatio: "4 / 3",
|
||||
background: isPending ? STRIPE_COOL : STRIPE_WARM,
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
opacity: isSuspended ? 0.55 : 1,
|
||||
}}
|
||||
>
|
||||
{p.imageUrl && <PromoImage src={p.imageUrl} alt={p.title ?? ""} />}
|
||||
@@ -98,34 +158,69 @@ export default async function PromotionsPage() {
|
||||
padding: "4px 11px",
|
||||
fontSize: 11,
|
||||
fontWeight: 800,
|
||||
background: isPending ? "#fbeede" : "#e6f4ec",
|
||||
color: isPending ? "#9a6638" : "#1f8a5b",
|
||||
background: chip.bg,
|
||||
color: chip.color,
|
||||
}}
|
||||
>
|
||||
{isPending ? "En attente" : "En ligne"}
|
||||
{chip.label}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ padding: "15px 16px 16px", display: "flex", flexDirection: "column", flex: 1 }}>
|
||||
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 16.5, margin: "0 0 4px", color: "#26201a" }}>
|
||||
<div style={{ padding: "13px 14px 14px", display: "flex", flexDirection: "column", flex: 1 }}>
|
||||
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 15.5, margin: "0 0 4px", color: "#26201a" }}>
|
||||
{p.title}
|
||||
</h3>
|
||||
<div style={{ fontSize: 12.5, color: "#9a6638", fontWeight: 600, marginBottom: 8 }}>
|
||||
{p.memberName}
|
||||
</div>
|
||||
<p style={{ margin: "0 0 14px", fontSize: 13, color: "#8c8068", lineHeight: 1.5, flex: 1 }}>
|
||||
<p style={{ margin: "0 0 12px", fontSize: 12.5, color: "#8c8068", lineHeight: 1.45, flex: 1 }}>
|
||||
{p.text}
|
||||
</p>
|
||||
|
||||
{isPending ? (
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
{isSuspended && (
|
||||
<div style={{ background: "#fbe9e6", border: "1px solid #f2d5cf", color: "#a8503c", borderRadius: 10, padding: "9px 11px", fontSize: 12, lineHeight: 1.45, marginBottom: 11 }}>
|
||||
Suspendue par {p.suspendedBy === "member" ? "l'adhérent" : "l'association"}
|
||||
{p.suspendedAt ? ` le ${fmtDate(p.suspendedAt)}` : ""}.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPending && (
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "stretch" }}>
|
||||
{/* Les cases vivent dans le formulaire « Valider » : c'est le
|
||||
seul et dernier moment où la diffusion est ajustable. */}
|
||||
<form action={moderatePromo} style={{ flex: 1 }}>
|
||||
<input type="hidden" name="id" value={p.id} />
|
||||
<input type="hidden" name="action" value="approve" />
|
||||
{networks.length > 0 && (
|
||||
<div style={{ background: "#faf7ef", border: "1px solid #f0e8d6", borderRadius: 10, padding: "10px 11px", marginBottom: 8 }}>
|
||||
<div style={{ fontSize: 10.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, marginBottom: 7 }}>
|
||||
Diffusion demandée
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{networks.map((network) => (
|
||||
<label key={network} style={{ display: "flex", alignItems: "center", gap: 7, fontSize: 12.5, fontWeight: 700, color: SOCIAL_BRAND[network], cursor: "pointer" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
name={network === "facebook" ? "shareFacebook" : "shareLinkedin"}
|
||||
defaultChecked={network === "facebook" ? p.shareFacebook : p.shareLinkedin}
|
||||
style={{ accentColor: SOCIAL_BRAND[network], margin: 0 }}
|
||||
/>
|
||||
<SocialIcon network={network} size={14} />
|
||||
{SOCIAL_LABELS[network]}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "#a99c82", marginTop: 7, lineHeight: 1.45 }}>
|
||||
Publié dès la validation. Ce choix n'est plus modifiable ensuite.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<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 }}>
|
||||
{/* Aligné en bas pour rester au niveau du bouton Valider,
|
||||
que le bloc diffusion rend plus haut. */}
|
||||
<form action={moderatePromo} style={{ flex: 1, display: "flex", flexDirection: "column", justifyContent: "flex-end" }}>
|
||||
<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" }}>
|
||||
@@ -133,18 +228,93 @@ export default async function PromotionsPage() {
|
||||
</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>
|
||||
)}
|
||||
|
||||
{!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={isSuspended ? "restore" : "suspend"} />
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
width: "100%",
|
||||
border: isSuspended ? "none" : "1px solid #e0c3bb",
|
||||
background: isSuspended ? "#1f8a5b" : "#fff",
|
||||
color: isSuspended ? "#fff" : "#d8472b",
|
||||
fontWeight: 700,
|
||||
fontSize: 13,
|
||||
padding: 10,
|
||||
borderRadius: 9,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{isSuspended ? "Remettre en ligne" : "Suspendre"}
|
||||
</button>
|
||||
</form>
|
||||
<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 type="submit" style={{ border: "1px solid #e6dcc6", background: "#fff", color: "#a99c82", fontWeight: 700, fontSize: 13, padding: "10px 12px", borderRadius: 9, cursor: "pointer" }}>
|
||||
Supprimer
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isPending && chosenNetworks.length > 0 && (
|
||||
<div style={{ borderTop: "1px solid #f0e8d6", marginTop: 12, paddingTop: 11 }}>
|
||||
<div style={{ fontSize: 10.5, letterSpacing: "0.1em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, marginBottom: 8 }}>
|
||||
Diffusion réseaux
|
||||
</div>
|
||||
{chosenNetworks.map((network) => {
|
||||
const last = lastShare.get(`${p.id}:${network}`);
|
||||
const posted = last?.status === "posted";
|
||||
return (
|
||||
<div key={network} style={{ marginBottom: 8 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 7, fontSize: 12, fontWeight: 700, color: posted ? "#1f8a5b" : "#d8472b" }}>
|
||||
<span style={{ color: SOCIAL_BRAND[network], display: "inline-flex" }}>
|
||||
<SocialIcon network={network} size={14} />
|
||||
</span>
|
||||
{SOCIAL_LABELS[network]} ·{" "}
|
||||
{posted ? `publié le ${fmtDate(last!.createdAt)}` : "non publié"}
|
||||
{posted && last?.url && (
|
||||
<a href={last.url} target="_blank" rel="noopener noreferrer" style={{ color: "#2C6FB3", fontWeight: 700 }}>
|
||||
voir
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{!posted && last?.error && (
|
||||
<div style={{ fontSize: 11, color: "#a8503c", lineHeight: 1.45, marginTop: 3 }}>
|
||||
{last.error.slice(0, 200)}
|
||||
</div>
|
||||
)}
|
||||
{!posted && p.status === "live" && (
|
||||
<form action={retryPromoShare} style={{ marginTop: 6 }}>
|
||||
<input type="hidden" name="id" value={p.id} />
|
||||
<input type="hidden" name="network" value={network} />
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
border: `1px solid ${SOCIAL_BRAND[network]}`,
|
||||
background: "#fff",
|
||||
color: SOCIAL_BRAND[network],
|
||||
fontWeight: 700,
|
||||
fontSize: 12,
|
||||
padding: "7px 12px",
|
||||
borderRadius: 8,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Réessayer sur {SOCIAL_LABELS[network]}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
@@ -153,3 +323,4 @@ export default async function PromotionsPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import type { CSSProperties } from "react";
|
||||
import { asc, desc, eq, sql } from "drizzle-orm";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { db } from "@/db";
|
||||
import { meetingRegistrations, meetings, pastMeetingPhotos, pastMeetings } from "@/db/schema";
|
||||
import { ImageField } from "@/components/ImageField";
|
||||
import { can } from "@/lib/rbac";
|
||||
import {
|
||||
addPastMeetingPhoto,
|
||||
createPastMeeting,
|
||||
deletePastMeeting,
|
||||
deletePastMeetingPhoto,
|
||||
updatePastMeeting,
|
||||
} from "../actions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function dateValue(date: Date) {
|
||||
const local = new Date(date);
|
||||
local.setMinutes(local.getMinutes() - local.getTimezoneOffset());
|
||||
return local.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export default async function PastMeetingsAdminPage() {
|
||||
const session = await getSession();
|
||||
if (!can(session?.user.role, "manageMeetings")) redirect("/backend");
|
||||
|
||||
const [meetingRows, archives, photos, refusals] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: meetings.id,
|
||||
title: meetings.title,
|
||||
startsAt: meetings.startsAt,
|
||||
registered: sql<number>`count(${meetingRegistrations.id})`,
|
||||
})
|
||||
.from(meetings)
|
||||
.leftJoin(meetingRegistrations, eq(meetingRegistrations.meetingId, meetings.id))
|
||||
.groupBy(meetings.id)
|
||||
.orderBy(desc(meetings.startsAt)),
|
||||
db.select().from(pastMeetings).orderBy(desc(pastMeetings.eventDate)),
|
||||
db.select().from(pastMeetingPhotos).orderBy(asc(pastMeetingPhotos.position), asc(pastMeetingPhotos.id)),
|
||||
db
|
||||
.select({ meetingId: meetingRegistrations.meetingId, attendeeName: meetingRegistrations.attendeeName })
|
||||
.from(meetingRegistrations)
|
||||
.where(eq(meetingRegistrations.imageConsent, false))
|
||||
.orderBy(asc(meetingRegistrations.attendeeName)),
|
||||
]);
|
||||
|
||||
const photosByPast = new Map<number, typeof photos>();
|
||||
for (const photo of photos) photosByPast.set(photo.pastMeetingId, [...(photosByPast.get(photo.pastMeetingId) ?? []), photo]);
|
||||
const refusalsByMeeting = new Map<number, string[]>();
|
||||
for (const refusal of refusals) refusalsByMeeting.set(refusal.meetingId, [...(refusalsByMeeting.get(refusal.meetingId) ?? []), refusal.attendeeName]);
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gap: 28 }}>
|
||||
<section style={panelStyle}>
|
||||
<h2 className="font-display" style={{ margin: "0 0 6px", fontSize: 20, color: "#26201a" }}>Nouvelle rencontre passée</h2>
|
||||
<p style={{ color: "#8c8068", fontSize: 13, margin: "0 0 18px" }}>Les trois archives les plus récentes apparaissent sur « L'association » et la page complète rassemble toutes les rencontres passées.</p>
|
||||
<form action={createPastMeeting} className="grid grid-2" style={{ gap: 16 }}>
|
||||
<div>
|
||||
<label className="field-label">Titre</label>
|
||||
<input name="title" className="field" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Date</label>
|
||||
<input name="eventDate" type="date" className="field" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Lieu</label>
|
||||
<input name="location" className="field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Rencontre liée</label>
|
||||
<select name="meetingId" className="field" defaultValue="">
|
||||
<option value="">Aucune</option>
|
||||
{meetingRows.map((meeting) => <option key={meeting.id} value={meeting.id}>{meeting.title} ({Number(meeting.registered)} participants)</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ gridColumn: "1 / -1" }}>
|
||||
<label className="field-label">Description</label>
|
||||
<textarea name="description" rows={3} className="field" />
|
||||
</div>
|
||||
<div style={{ gridColumn: "1 / -1" }}>
|
||||
<label className="field-label">Participants manuels (un par ligne, uniquement sans rencontre liée)</label>
|
||||
<textarea name="participants" rows={3} className="field" />
|
||||
</div>
|
||||
<button type="submit" style={primaryButton}>Publier l'archive</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div style={sectionTitle}>Archives publiées</div>
|
||||
<div className="grid grid-3" style={{ gap: 18 }}>
|
||||
{archives.map((archive) => {
|
||||
const refused = archive.meetingId ? refusalsByMeeting.get(archive.meetingId) ?? [] : [];
|
||||
return (
|
||||
<article key={archive.id} style={cardStyle}>
|
||||
{refused.length > 0 && (
|
||||
<div style={{ background: "#fbe9e6", border: "1px solid #f0c4bb", color: "#b53a25", borderRadius: 8, padding: "11px 13px", fontSize: 12.5, lineHeight: 1.5, marginBottom: 13 }}>
|
||||
<strong>Droit à l'image refusé :</strong> {refused.join(", ")}. Vérifiez que ces personnes ne figurent pas sur les photos publiées.
|
||||
</div>
|
||||
)}
|
||||
<form action={updatePastMeeting} style={{ display: "grid", gap: 12 }}>
|
||||
<input type="hidden" name="id" value={archive.id} />
|
||||
<div>
|
||||
<label className="field-label">Titre</label>
|
||||
<input name="title" defaultValue={archive.title} className="field" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Date</label>
|
||||
<input name="eventDate" type="date" defaultValue={dateValue(archive.eventDate)} className="field" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Lieu</label>
|
||||
<input name="location" defaultValue={archive.location ?? ""} className="field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Rencontre liée</label>
|
||||
<select name="meetingId" className="field" defaultValue={archive.meetingId ?? ""}>
|
||||
<option value="">Aucune</option>
|
||||
{meetingRows.map((meeting) => <option key={meeting.id} value={meeting.id}>{meeting.title} ({Number(meeting.registered)})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Description</label>
|
||||
<textarea name="description" rows={3} defaultValue={archive.description ?? ""} className="field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Participants manuels</label>
|
||||
<textarea name="participants" rows={3} defaultValue={archive.participants ?? ""} className="field" />
|
||||
</div>
|
||||
<button type="submit" style={primaryButton}>Enregistrer</button>
|
||||
</form>
|
||||
|
||||
<div style={{ marginTop: 14, borderTop: "1px solid #f0e8d6", paddingTop: 14 }}>
|
||||
<form action={addPastMeetingPhoto} style={{ display: "grid", gap: 10 }}>
|
||||
<input type="hidden" name="pastMeetingId" value={archive.id} />
|
||||
<ImageField name="imageUrl" label="Ajouter une photo" height={100} />
|
||||
<input name="caption" className="field" placeholder="Légende" />
|
||||
<button type="submit" style={primaryButton}>Ajouter la photo</button>
|
||||
</form>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 8, marginTop: 12 }}>
|
||||
{(photosByPast.get(archive.id) ?? []).map((photo) => (
|
||||
<form key={photo.id} action={deletePastMeetingPhoto}>
|
||||
<input type="hidden" name="id" value={photo.id} />
|
||||
<button type="submit" title="Supprimer" aria-label={`Supprimer la photo ${photo.caption ?? ""}`} style={{ width: "100%", aspectRatio: "1 / 1", border: "1px solid #e6dcc6", borderRadius: 8, cursor: "pointer", background: `center/cover no-repeat url(${photo.imageUrl})` }} />
|
||||
</form>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form action={deletePastMeeting} style={{ marginTop: 12 }}>
|
||||
<input type="hidden" name="id" value={archive.id} />
|
||||
<button type="submit" style={dangerButton}>Supprimer l'archive</button>
|
||||
</form>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{archives.length === 0 && <div style={emptyStyle}>Aucune rencontre passée publiée.</div>}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const panelStyle: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 22 };
|
||||
const cardStyle: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 16 };
|
||||
const sectionTitle: CSSProperties = { fontSize: 12.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, marginBottom: 12 };
|
||||
const primaryButton: CSSProperties = { border: "none", background: "#13324F", color: "#fff", fontWeight: 800, fontSize: 13.5, padding: "11px 16px", borderRadius: 8, cursor: "pointer" };
|
||||
const dangerButton: CSSProperties = { width: "100%", border: "1px solid #e0c3bb", background: "#fff", color: "#d8472b", fontWeight: 800, fontSize: 13, padding: "10px 14px", borderRadius: 8, cursor: "pointer" };
|
||||
const emptyStyle: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 24, color: "#8c8068" };
|
||||
@@ -0,0 +1,162 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import type { CSSProperties } from "react";
|
||||
import { desc, eq, sql } from "drizzle-orm";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { db } from "@/db";
|
||||
import { meetingRegistrations, meetings } from "@/db/schema";
|
||||
import { ImageField } from "@/components/ImageField";
|
||||
import { MeetingEmailComposer } from "@/components/MeetingEmailComposer";
|
||||
import { can } from "@/lib/rbac";
|
||||
import { getSiteSettings } from "@/lib/site-settings";
|
||||
import { createMeeting, deleteMeeting, updateMeeting } from "../actions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function dateTimeValue(date: Date) {
|
||||
const local = new Date(date);
|
||||
local.setMinutes(local.getMinutes() - local.getTimezoneOffset());
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
export default async function RencontresAdminPage() {
|
||||
const session = await getSession();
|
||||
if (!can(session?.user.role, "manageMeetings")) redirect("/backend");
|
||||
|
||||
const [rows, settings] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: meetings.id,
|
||||
title: meetings.title,
|
||||
startsAt: meetings.startsAt,
|
||||
location: meetings.location,
|
||||
description: meetings.description,
|
||||
capacity: meetings.capacity,
|
||||
participantsPerAccount: meetings.participantsPerAccount,
|
||||
imageUrl: meetings.imageUrl,
|
||||
registered: sql<number>`count(${meetingRegistrations.id})`,
|
||||
})
|
||||
.from(meetings)
|
||||
.leftJoin(meetingRegistrations, eq(meetingRegistrations.meetingId, meetings.id))
|
||||
.groupBy(meetings.id)
|
||||
.orderBy(desc(meetings.startsAt)),
|
||||
getSiteSettings(),
|
||||
]);
|
||||
const emailBrand = {
|
||||
associationName: settings.association_name,
|
||||
address: settings.association_address,
|
||||
email: settings.association_email,
|
||||
phone: settings.association_phone,
|
||||
siret: settings.association_siret,
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gap: 28 }}>
|
||||
<section style={panelStyle}>
|
||||
<h2 className="font-display" style={{ margin: "0 0 16px", fontSize: 20, color: "#26201a" }}>Nouvelle rencontre</h2>
|
||||
<form action={createMeeting} className="grid grid-2" style={{ gap: 16 }}>
|
||||
<div>
|
||||
<label className="field-label">Titre</label>
|
||||
<input name="title" className="field" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Date et heure</label>
|
||||
<input name="startsAt" type="datetime-local" className="field" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Lieu</label>
|
||||
<input name="location" className="field" />
|
||||
</div>
|
||||
<div className="grid grid-2" style={{ gap: 12 }}>
|
||||
<div>
|
||||
<label className="field-label">Places</label>
|
||||
<input name="capacity" type="number" min="1" defaultValue={30} className="field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Max. par compte</label>
|
||||
<input name="participantsPerAccount" type="number" min="1" max="100" defaultValue={1} className="field" />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ gridColumn: "1 / -1" }}>
|
||||
<label className="field-label">Description</label>
|
||||
<textarea name="description" rows={3} className="field" />
|
||||
</div>
|
||||
<div style={{ gridColumn: "1 / -1" }}>
|
||||
<ImageField name="imageUrl" label="Image" height={160} />
|
||||
</div>
|
||||
<button type="submit" style={primaryButton}>Ajouter la rencontre</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div style={sectionTitle}>Rencontres publiées</div>
|
||||
<div className="grid grid-3" style={{ gap: 18 }}>
|
||||
{rows.map((meeting) => (
|
||||
<article key={meeting.id} style={cardStyle}>
|
||||
<form action={updateMeeting} style={{ display: "grid", gap: 12 }}>
|
||||
<input type="hidden" name="id" value={meeting.id} />
|
||||
<ImageField name="imageUrl" label="Image" defaultValue={meeting.imageUrl ?? ""} height={120} />
|
||||
<div>
|
||||
<label className="field-label">Titre</label>
|
||||
<input name="title" defaultValue={meeting.title} className="field" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Date et heure</label>
|
||||
<input name="startsAt" type="datetime-local" defaultValue={dateTimeValue(meeting.startsAt)} className="field" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Lieu</label>
|
||||
<input name="location" defaultValue={meeting.location ?? ""} className="field" />
|
||||
</div>
|
||||
<div className="grid grid-2" style={{ gap: 10 }}>
|
||||
<div>
|
||||
<label className="field-label">Places</label>
|
||||
<input name="capacity" type="number" min="1" defaultValue={meeting.capacity} className="field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Max. / compte</label>
|
||||
<input name="participantsPerAccount" type="number" min="1" max="100" defaultValue={meeting.participantsPerAccount} className="field" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Description</label>
|
||||
<textarea name="description" rows={3} defaultValue={meeting.description ?? ""} className="field" />
|
||||
</div>
|
||||
<button type="submit" style={primaryButton}>Enregistrer</button>
|
||||
</form>
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12, marginTop: 14, paddingTop: 14, borderTop: "1px solid #f0e8d6" }}>
|
||||
<div style={{ fontSize: 12.5, color: "#6c6150", fontWeight: 800 }}>{Number(meeting.registered)} participant(s)</div>
|
||||
<Link href={`/backend/inscriptions?meeting=${meeting.id}`} style={{ color: "#9a6638", fontSize: 12.5, fontWeight: 800, textDecoration: "none" }}>Voir les inscrits</Link>
|
||||
</div>
|
||||
<MeetingEmailComposer
|
||||
meeting={{
|
||||
id: meeting.id,
|
||||
title: meeting.title,
|
||||
startsAt: meeting.startsAt.toISOString(),
|
||||
location: meeting.location,
|
||||
description: meeting.description,
|
||||
capacity: meeting.capacity,
|
||||
registered: Number(meeting.registered),
|
||||
}}
|
||||
brand={emailBrand}
|
||||
/>
|
||||
<form action={deleteMeeting} style={{ marginTop: 8 }}>
|
||||
<input type="hidden" name="id" value={meeting.id} />
|
||||
<button type="submit" style={dangerButton}>Supprimer</button>
|
||||
</form>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
{rows.length === 0 && <div style={emptyStyle}>Aucune rencontre programmée.</div>}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const panelStyle: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 22 };
|
||||
const cardStyle: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 16 };
|
||||
const sectionTitle: CSSProperties = { fontSize: 12.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, marginBottom: 12 };
|
||||
const primaryButton: CSSProperties = { border: "none", background: "#13324F", color: "#fff", fontWeight: 800, fontSize: 13.5, padding: "11px 16px", borderRadius: 8, cursor: "pointer" };
|
||||
const dangerButton: CSSProperties = { width: "100%", border: "1px solid #e0c3bb", background: "#fff", color: "#d8472b", fontWeight: 800, fontSize: 13, padding: "10px 14px", borderRadius: 8, cursor: "pointer" };
|
||||
const emptyStyle: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 24, color: "#8c8068" };
|
||||
@@ -0,0 +1,348 @@
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import type { CSSProperties } from "react";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { can } from "@/lib/rbac";
|
||||
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
|
||||
import {
|
||||
expiryStatus,
|
||||
getSocialAccounts,
|
||||
listStoredTargets,
|
||||
redirectUri,
|
||||
siteUrl,
|
||||
SOCIAL_LABELS,
|
||||
SOCIAL_NETWORKS,
|
||||
type SocialTarget,
|
||||
} from "@/lib/social-accounts";
|
||||
import { disconnectSocial, saveSitePublicUrl, saveSocialApp, selectSocialTarget } from "../actions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const HELP: Record<
|
||||
(typeof SOCIAL_NETWORKS)[number],
|
||||
{ portal: string; steps: string[]; caution?: string }
|
||||
> = {
|
||||
facebook: {
|
||||
portal: "https://developers.facebook.com/apps",
|
||||
steps: [
|
||||
"Créez une application de type « Business » et relevez l'identifiant et la clé secrète (Paramètres › Général).",
|
||||
"Ajoutez le produit « Connexion Facebook » puis collez l'URL de redirection ci-dessous dans « URI de redirection OAuth valides ».",
|
||||
"Laissez l'application en mode développement et ajoutez le compte de l'association comme administrateur : publier sur votre propre page ne demande alors aucune revue Meta.",
|
||||
],
|
||||
},
|
||||
linkedin: {
|
||||
portal: "https://www.linkedin.com/developers/apps",
|
||||
steps: [
|
||||
"Créez une application rattachée à la page LinkedIn de l'association et relevez le Client ID et le Client Secret (onglet Auth).",
|
||||
"Collez l'URL de redirection ci-dessous dans « Authorized redirect URLs ».",
|
||||
"Onglet Produits : demandez « Community Management API », indispensable pour publier au nom de la page.",
|
||||
],
|
||||
caution:
|
||||
"LinkedIn soumet cette demande à une revue (page vérifiée, nom légal, adresse, politique de confidentialité) et ses jetons expirent au bout de 60 jours : il faudra recliquer sur Reconnecter environ tous les deux mois.",
|
||||
},
|
||||
};
|
||||
|
||||
function fmtDate(d: Date) {
|
||||
return new Date(d).toLocaleDateString("fr-FR", { day: "numeric", month: "long", year: "numeric" });
|
||||
}
|
||||
|
||||
export default async function ReseauxPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ error?: string; connected?: string; choose?: string }>;
|
||||
}) {
|
||||
const session = await getSession();
|
||||
if (!can(session?.user.role, "manageSettings")) redirect("/backend");
|
||||
|
||||
const { error, connected, choose } = await searchParams;
|
||||
const accounts = await getSocialAccounts();
|
||||
const byNetwork = new Map(accounts.map((a) => [a.network, a]));
|
||||
const base = await siteUrl();
|
||||
|
||||
// Adresse par laquelle l'administrateur consulte cette page : proposée par
|
||||
// défaut pour lui éviter de la recopier à la main.
|
||||
const requestHeaders = await headers();
|
||||
const host = requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host") ?? "";
|
||||
const protocol = requestHeaders.get("x-forwarded-proto") ?? (host.startsWith("localhost") ? "http" : "https");
|
||||
const detected = host ? `${protocol}://${host}` : "";
|
||||
|
||||
// Calculées ici : le rendu des cartes n'est pas asynchrone.
|
||||
const redirectUris = new Map<string, string>();
|
||||
for (const network of SOCIAL_NETWORKS) {
|
||||
redirectUris.set(network, await redirectUri(network));
|
||||
}
|
||||
|
||||
// Cibles à proposer quand le compte administre plusieurs pages.
|
||||
const targets = new Map<string, SocialTarget[]>();
|
||||
let targetError: string | null = null;
|
||||
for (const network of SOCIAL_NETWORKS) {
|
||||
const account = byNetwork.get(network);
|
||||
if (account?.accessToken && !account.targetId) {
|
||||
try {
|
||||
targets.set(network, await listStoredTargets(network));
|
||||
} catch (caught) {
|
||||
targetError = caught instanceof Error ? caught.message : "Pages illisibles.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gap: 22, maxWidth: 1080 }}>
|
||||
{error && <Banner tone="error">{error}</Banner>}
|
||||
{targetError && <Banner tone="error">{targetError}</Banner>}
|
||||
{connected && (
|
||||
<Banner tone="ok">
|
||||
Compte {SOCIAL_LABELS[connected as "facebook"] ?? connected} connecté.
|
||||
</Banner>
|
||||
)}
|
||||
{choose && (
|
||||
<Banner tone="warn">
|
||||
Connexion réussie : choisissez la page à utiliser pour les publications.
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
<section style={panel}>
|
||||
<h2 className="font-display" style={{ ...title, margin: "0 0 4px" }}>
|
||||
URL publique du site
|
||||
</h2>
|
||||
<p style={{ margin: "0 0 14px", fontSize: 13.5, color: "#6c6150", lineHeight: 1.6 }}>
|
||||
Adresse à laquelle vos visiteurs accèdent au site. Elle sert à construire
|
||||
l'adresse de retour des connexions Facebook et LinkedIn, et le lien inséré dans
|
||||
les publications.
|
||||
</p>
|
||||
<form action={saveSitePublicUrl} style={{ display: "flex", gap: 10, flexWrap: "wrap", alignItems: "flex-end" }}>
|
||||
<div style={{ flex: "1 1 320px", minWidth: 0 }}>
|
||||
<label className="field-label">Adresse</label>
|
||||
<input
|
||||
name="sitePublicUrl"
|
||||
className="field"
|
||||
defaultValue={base || detected}
|
||||
placeholder="https://pleinr.example.fr"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" style={submitButton}>Enregistrer</button>
|
||||
</form>
|
||||
{!base && detected && (
|
||||
<div style={{ marginTop: 10, fontSize: 12.5, color: "#9a6638" }}>
|
||||
Adresse détectée depuis votre navigation : <strong>{detected}</strong>. Vérifiez-la puis
|
||||
enregistrez.
|
||||
</div>
|
||||
)}
|
||||
{!base && !detected && (
|
||||
<div style={{ marginTop: 10, fontSize: 12.5, color: "#a8503c" }}>
|
||||
Renseignez cette adresse avant de connecter un réseau social.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{SOCIAL_NETWORKS.map((network) => {
|
||||
const account = byNetwork.get(network);
|
||||
const help = HELP[network];
|
||||
const status = expiryStatus(account?.expiresAt);
|
||||
const isConnected = !!account?.accessToken && !!account.targetId;
|
||||
const candidates = targets.get(network) ?? [];
|
||||
|
||||
return (
|
||||
<section key={network} style={panel}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 6 }}>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 12,
|
||||
background: SOCIAL_BRAND[network],
|
||||
color: "#fff",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<SocialIcon network={network} size={20} />
|
||||
</span>
|
||||
<h2 className="font-display" style={{ ...title, margin: 0, flex: 1 }}>
|
||||
{SOCIAL_LABELS[network]}
|
||||
</h2>
|
||||
<StatusChip connected={isConnected} status={status} />
|
||||
</div>
|
||||
|
||||
{isConnected && (
|
||||
<div style={{ fontSize: 13.5, color: "#6c6150", marginBottom: 14, lineHeight: 1.6 }}>
|
||||
Publie sur <strong style={{ color: "#26201a" }}>{account?.targetName}</strong>.
|
||||
{status === "never" && " Ce jeton n'expire pas."}
|
||||
{account?.expiresAt && status !== "never" && ` Jeton valable jusqu'au ${fmtDate(account.expiresAt)}.`}
|
||||
{account?.connectedAt && ` Connecté le ${fmtDate(account.connectedAt)}.`}
|
||||
<br />
|
||||
Pour changer de page, relancez une connexion.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(status === "soon" || status === "expired") && isConnected && (
|
||||
<Banner tone={status === "expired" ? "error" : "warn"}>
|
||||
{status === "expired"
|
||||
? `Le jeton ${SOCIAL_LABELS[network]} a expiré : les publications échoueront tant que vous n'aurez pas reconnecté le compte.`
|
||||
: `Le jeton ${SOCIAL_LABELS[network]} expire bientôt. Un clic sur Reconnecter suffit à le renouveler.`}
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
{candidates.length > 0 && (
|
||||
<div style={{ background: "#faf7ef", border: "1px solid #f0e8d6", borderRadius: 12, padding: 15, marginBottom: 16 }}>
|
||||
<div className="field-label" style={{ marginBottom: 9 }}>
|
||||
Page à utiliser pour les publications
|
||||
</div>
|
||||
<div style={{ display: "grid", gap: 8 }}>
|
||||
{candidates.map((target) => (
|
||||
<form key={target.id} action={selectSocialTarget} style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<input type="hidden" name="network" value={network} />
|
||||
<input type="hidden" name="targetId" value={target.id} />
|
||||
<span style={{ flex: 1, fontSize: 13.5, color: "#3c3322", fontWeight: 600 }}>
|
||||
{target.name}
|
||||
</span>
|
||||
<button type="submit" style={{ border: "none", background: "#13324F", color: "#fff", fontWeight: 700, fontSize: 12.5, padding: "8px 14px", borderRadius: 9, cursor: "pointer" }}>
|
||||
Choisir
|
||||
</button>
|
||||
</form>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form action={saveSocialApp} style={{ marginBottom: 14 }}>
|
||||
<input type="hidden" name="network" value={network} />
|
||||
<div className="grid grid-2" style={{ gap: 16 }}>
|
||||
<div>
|
||||
<label className="field-label">
|
||||
{network === "facebook" ? "Identifiant de l'application" : "Client ID"}
|
||||
</label>
|
||||
<input name="appId" defaultValue={account?.appId ?? ""} className="field" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">
|
||||
{network === "facebook" ? "Clé secrète" : "Client Secret"}
|
||||
</label>
|
||||
<input
|
||||
name="appSecret"
|
||||
type="password"
|
||||
className="field"
|
||||
autoComplete="new-password"
|
||||
placeholder={account ? "Enregistré — laissez vide pour le conserver" : "Collez la clé secrète"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" style={{ ...submitButton, marginTop: 14 }}>
|
||||
Enregistrer les identifiants
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", borderTop: "1px solid #f0e8d6", paddingTop: 14 }}>
|
||||
<a
|
||||
href={`/api/social/${network}/connect`}
|
||||
className="font-display"
|
||||
style={{
|
||||
textDecoration: "none",
|
||||
border: "none",
|
||||
background: account?.appId ? SOCIAL_BRAND[network] : "#d8cdb4",
|
||||
color: "#fff",
|
||||
fontWeight: 700,
|
||||
fontSize: 14,
|
||||
padding: "12px 20px",
|
||||
borderRadius: 10,
|
||||
pointerEvents: account?.appId ? undefined : "none",
|
||||
}}
|
||||
>
|
||||
{isConnected ? "Reconnecter" : "Connecter"}
|
||||
</a>
|
||||
{isConnected && (
|
||||
<form action={disconnectSocial}>
|
||||
<input type="hidden" name="network" value={network} />
|
||||
<button type="submit" style={{ border: "1px solid #e0c3bb", background: "#fff", color: "#d8472b", fontWeight: 700, fontSize: 14, padding: "12px 18px", borderRadius: 10, cursor: "pointer" }}>
|
||||
Déconnecter
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<details style={{ marginTop: 16 }}>
|
||||
<summary style={{ cursor: "pointer", fontSize: 13.5, fontWeight: 700, color: "#9a6638" }}>
|
||||
Comment obtenir ces identifiants ?
|
||||
</summary>
|
||||
<ol style={{ margin: "12px 0 0", paddingLeft: 20, fontSize: 13.5, color: "#6c6150", lineHeight: 1.7 }}>
|
||||
{help.steps.map((step) => (
|
||||
<li key={step}>{step}</li>
|
||||
))}
|
||||
</ol>
|
||||
<div style={{ marginTop: 12, fontSize: 13, color: "#6c6150" }}>
|
||||
Portail :{" "}
|
||||
<a href={help.portal} target="_blank" rel="noopener noreferrer" style={{ color: "#2C6FB3", fontWeight: 700 }}>
|
||||
{help.portal}
|
||||
</a>
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<div className="field-label">URL de redirection à déclarer</div>
|
||||
<code style={{ display: "block", background: "#faf7ef", border: "1px solid #f0e8d6", borderRadius: 9, padding: "10px 12px", fontSize: 12.5, color: "#3c3322", overflowWrap: "anywhere" }}>
|
||||
{base ? redirectUris.get(network) : "— renseignez d'abord l'URL publique du site ci-dessus —"}
|
||||
</code>
|
||||
</div>
|
||||
{help.caution && (
|
||||
<div style={{ marginTop: 12, background: "#fbeede", border: "1px solid #ecd8b8", color: "#9a6638", borderRadius: 10, padding: "11px 13px", fontSize: 13, lineHeight: 1.6 }}>
|
||||
{help.caution}
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusChip({ connected, status }: { connected: boolean; status: string }) {
|
||||
const [label, bg, color] = !connected
|
||||
? ["Non connecté", "#f1efe7", "#a99c82"]
|
||||
: status === "expired"
|
||||
? ["Jeton expiré", "#fbe9e6", "#d8472b"]
|
||||
: status === "soon"
|
||||
? ["Expire bientôt", "#fbeede", "#9a6638"]
|
||||
: ["Connecté", "#e6f4ec", "#1f8a5b"];
|
||||
return (
|
||||
<span style={{ background: bg, color, borderRadius: 999, padding: "6px 13px", fontSize: 12, fontWeight: 800 }}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Banner({ tone, children }: { tone: "ok" | "warn" | "error"; children: React.ReactNode }) {
|
||||
const palette = {
|
||||
ok: { bg: "#e6f4ec", border: "#c7e6d5", color: "#1f8a5b" },
|
||||
warn: { bg: "#fbeede", border: "#ecd8b8", color: "#9a6638" },
|
||||
error: { bg: "#fbe9e6", border: "#f2d5cf", color: "#a8503c" },
|
||||
}[tone];
|
||||
return (
|
||||
<div style={{ background: palette.bg, border: `1px solid ${palette.border}`, color: palette.color, borderRadius: 12, padding: "12px 16px", fontSize: 13.5, lineHeight: 1.6 }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const panel: CSSProperties = {
|
||||
background: "#fff",
|
||||
border: "1px solid #e6dcc6",
|
||||
borderRadius: 16,
|
||||
padding: 22,
|
||||
};
|
||||
|
||||
const title: CSSProperties = {
|
||||
fontSize: 20,
|
||||
color: "#26201a",
|
||||
};
|
||||
|
||||
const submitButton: CSSProperties = {
|
||||
border: "none",
|
||||
background: "#13324F",
|
||||
color: "#fff",
|
||||
fontWeight: 800,
|
||||
fontSize: 14,
|
||||
padding: "11px 20px",
|
||||
borderRadius: 10,
|
||||
cursor: "pointer",
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Metadata } from "next";
|
||||
import { LegalPage } from "@/components/LegalPage";
|
||||
import { getSiteSettings } from "@/lib/site-settings";
|
||||
import { pageMetadata } from "@/lib/seo";
|
||||
|
||||
export const metadata: Metadata = pageMetadata({
|
||||
title: "Politique de confidentialité",
|
||||
description: "Politique de confidentialité et traitement des données personnelles par l'association Plein R.",
|
||||
path: "/confidentialite",
|
||||
});
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ConfidentialitePage() {
|
||||
const settings = await getSiteSettings();
|
||||
const contact = settings.association_privacy_contact || settings.association_email || "le formulaire « Nous contacter »";
|
||||
|
||||
return (
|
||||
<LegalPage title="Confidentialité" intro={`Cette page explique quelles données sont recueillies et comment ${settings.association_name} les utilise.`}>
|
||||
<h2>Données recueillies</h2>
|
||||
<p>Selon votre utilisation du site, Plein R peut recevoir :</p>
|
||||
<ul>
|
||||
<li>vos nom, e-mail, téléphone et message lors d’une prise de contact ou demande d’adhésion ;</li>
|
||||
<li>les informations professionnelles publiées dans l’annuaire et les promotions ;</li>
|
||||
<li>les données nécessaires à la connexion et à la sécurisation des espaces privés.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Finalités</h2>
|
||||
<p>
|
||||
Ces données servent à répondre aux demandes, gérer les adhésions, administrer l’annuaire,
|
||||
modérer les promotions et sécuriser l’accès aux comptes. Elles ne sont pas vendues à des tiers.
|
||||
</p>
|
||||
|
||||
<h2>Responsable, bases légales et destinataires</h2>
|
||||
<p>
|
||||
{settings.association_name} est responsable des traitements réalisés par ce site. Les traitements reposent, selon le cas,
|
||||
sur les démarches précontractuelles liées à l’adhésion, l’exécution de la relation avec les adhérents,
|
||||
l’intérêt légitime de l’association à répondre aux demandes et administrer son réseau, ou une obligation légale.
|
||||
</p>
|
||||
<p>
|
||||
Seules les personnes habilitées au sein de Plein R et les prestataires techniques nécessaires au fonctionnement
|
||||
du site peuvent accéder aux données, dans la limite de leurs missions.
|
||||
</p>
|
||||
|
||||
<h2>Durée et sécurité</h2>
|
||||
<p>
|
||||
Les données sont conservées pendant la durée nécessaire au traitement demandé, à la gestion de la relation
|
||||
avec l’association et au respect de ses obligations. Des mesures adaptées limitent accès, perte ou divulgation non autorisée.
|
||||
</p>
|
||||
|
||||
<h2>Cookies</h2>
|
||||
<p>
|
||||
Le site utilise uniquement les éléments techniques nécessaires à son fonctionnement, notamment pour maintenir
|
||||
une session sécurisée dans l’espace adhérent ou l’administration.
|
||||
</p>
|
||||
|
||||
<h2>Vos droits</h2>
|
||||
<p>
|
||||
Vous pouvez demander accès, rectification, effacement, limitation, opposition ou portabilité concernant vos données,
|
||||
selon les conditions prévues par la réglementation.
|
||||
Envoyez votre demande via {contact} en précisant votre identité et l’objet de votre demande.
|
||||
</p>
|
||||
<p>Vous pouvez également saisir la CNIL si vous estimez que vos droits ne sont pas respectés.</p>
|
||||
{settings.legal_updated && <p>Dernière mise à jour : {settings.legal_updated}</p>}
|
||||
</LegalPage>
|
||||
);
|
||||
}
|
||||
+1087
-7
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,143 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { and, asc, eq, gte, sql } from "drizzle-orm";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { MeetingRegistrationForm } from "@/components/MeetingRegistrationForm";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
import { SiteHeader } from "@/components/SiteHeader";
|
||||
import { db } from "@/db";
|
||||
import { meetingRegistrations, meetings, members } from "@/db/schema";
|
||||
import { NOINDEX } from "@/lib/seo";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Page transactionnelle : rien à indexer, la rencontre est décrite sur /association.
|
||||
export const metadata: Metadata = {
|
||||
title: "Inscription à une rencontre",
|
||||
robots: NOINDEX,
|
||||
};
|
||||
|
||||
function formatDate(date: Date) {
|
||||
return new Intl.DateTimeFormat("fr-FR", {
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function formatTime(date: Date) {
|
||||
return new Intl.DateTimeFormat("fr-FR", { hour: "2-digit", minute: "2-digit" }).format(date);
|
||||
}
|
||||
|
||||
export default async function MeetingRegistrationPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const meetingId = Number(id);
|
||||
const session = await getSession();
|
||||
const now = new Date();
|
||||
|
||||
const [meeting] = Number.isInteger(meetingId)
|
||||
? await db
|
||||
.select({
|
||||
id: meetings.id,
|
||||
title: meetings.title,
|
||||
startsAt: meetings.startsAt,
|
||||
location: meetings.location,
|
||||
description: meetings.description,
|
||||
capacity: meetings.capacity,
|
||||
participantsPerAccount: meetings.participantsPerAccount,
|
||||
imageUrl: meetings.imageUrl,
|
||||
registered: sql<number>`count(${meetingRegistrations.id})`,
|
||||
})
|
||||
.from(meetings)
|
||||
.leftJoin(meetingRegistrations, eq(meetingRegistrations.meetingId, meetings.id))
|
||||
.where(and(eq(meetings.id, meetingId), gte(meetings.startsAt, now)))
|
||||
.groupBy(meetings.id)
|
||||
: [];
|
||||
|
||||
const memberId = session?.user.memberId ?? null;
|
||||
const [member, existing] = memberId && meeting
|
||||
? await Promise.all([
|
||||
db
|
||||
.select({ name: members.name, status: members.status })
|
||||
.from(members)
|
||||
.where(eq(members.id, memberId))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
db
|
||||
.select({
|
||||
name: meetingRegistrations.attendeeName,
|
||||
imageConsent: meetingRegistrations.imageConsent,
|
||||
status: meetingRegistrations.status,
|
||||
})
|
||||
.from(meetingRegistrations)
|
||||
.where(and(eq(meetingRegistrations.meetingId, meeting.id), eq(meetingRegistrations.memberId, memberId)))
|
||||
.orderBy(asc(meetingRegistrations.createdAt), asc(meetingRegistrations.id)),
|
||||
])
|
||||
: [null, []];
|
||||
|
||||
const registered = meeting ? Number(meeting.registered) : 0;
|
||||
const remaining = meeting ? Math.max(0, meeting.capacity - registered) : 0;
|
||||
|
||||
return (
|
||||
<div style={{ background: "#F6F2E8", minHeight: "100vh", fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
|
||||
<SiteHeader active="association" logo />
|
||||
<main>
|
||||
{!meeting ? (
|
||||
<section className="container" style={{ paddingTop: 72, paddingBottom: 96, maxWidth: 760 }}>
|
||||
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: "32px clamp(22px, 5vw, 38px)" }}>
|
||||
<div style={{ color: "#9a6638", fontSize: 12, letterSpacing: "0.14em", textTransform: "uppercase", fontWeight: 800 }}>Rencontre</div>
|
||||
<h1 className="font-display" style={{ color: "#26201a", fontSize: 34, margin: "8px 0 12px" }}>Inscription indisponible</h1>
|
||||
<p style={{ color: "#6c6150", lineHeight: 1.7 }}>Cette rencontre n'existe pas ou les inscriptions sont terminées.</p>
|
||||
<Link href="/association" style={{ display: "inline-block", marginTop: 12, color: "#9a6638", fontWeight: 800, textDecoration: "none" }}>Voir les prochaines rencontres</Link>
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<section className="container" style={{ paddingTop: 40, paddingBottom: 80 }}>
|
||||
<div style={{ gap: 28, alignItems: "start" }} className="meeting-registration-layout">
|
||||
<article style={{ background: "#13324F", color: "#fff", borderRadius: 8, overflow: "hidden" }}>
|
||||
<div style={{ aspectRatio: "16 / 9", background: meeting.imageUrl ? `center/cover no-repeat url(${meeting.imageUrl})` : "repeating-linear-gradient(45deg,#244965,#244965 12px,#2d5571 12px,#2d5571 24px)" }} />
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ color: "#E0A63C", fontSize: 12.5, fontWeight: 800 }}>{formatDate(meeting.startsAt)} · {formatTime(meeting.startsAt)}</div>
|
||||
<h1 className="font-display" style={{ fontSize: 30, margin: "9px 0 8px" }}>{meeting.title}</h1>
|
||||
{meeting.location && <div style={{ color: "#cfe0ee", fontSize: 14, fontWeight: 700 }}>{meeting.location}</div>}
|
||||
{meeting.description && <p style={{ color: "#cfe0ee", lineHeight: 1.7, margin: "16px 0" }}>{meeting.description}</p>}
|
||||
<div style={{ borderTop: "1px solid rgba(255,255,255,.16)", paddingTop: 15, color: "#fff", fontSize: 13.5, fontWeight: 800 }}>
|
||||
{registered}/{meeting.capacity} participants · {remaining} place{remaining > 1 ? "s" : ""} restante{remaining > 1 ? "s" : ""}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<section style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: "clamp(22px, 4vw, 30px)" }}>
|
||||
<div style={{ color: "#9a6638", fontSize: 12, letterSpacing: "0.14em", textTransform: "uppercase", fontWeight: 800 }}>Inscription membre</div>
|
||||
<h2 className="font-display" style={{ color: "#26201a", fontSize: 28, margin: "8px 0 20px" }}>
|
||||
{existing.length > 0 ? "Modifier les participants" : "Inscrire les participants"}
|
||||
</h2>
|
||||
{!session?.user || !memberId ? (
|
||||
<div>
|
||||
<p style={{ color: "#6c6150", lineHeight: 1.7 }}>Connectez-vous avec votre compte adhérent avant de choisir les participants.</p>
|
||||
<Link href="/login" style={{ display: "inline-block", background: "#13324F", color: "#fff", borderRadius: 8, padding: "13px 20px", fontWeight: 800, textDecoration: "none" }}>Se connecter</Link>
|
||||
</div>
|
||||
) : member?.status !== "active" ? (
|
||||
<div style={{ background: "#fbeede", border: "1px solid #ecd8b8", color: "#9a6638", borderRadius: 8, padding: "14px 16px", lineHeight: 1.6 }}>
|
||||
Votre adhésion doit être active avant de pouvoir vous inscrire aux rencontres.
|
||||
</div>
|
||||
) : remaining === 0 && existing.length === 0 ? (
|
||||
<div style={{ background: "#f1efe7", border: "1px solid #ded8c8", color: "#6c6150", borderRadius: 8, padding: "14px 16px" }}>Cette rencontre est complète.</div>
|
||||
) : (
|
||||
<MeetingRegistrationForm
|
||||
meetingId={meeting.id}
|
||||
defaultName={session.user.name ?? member.name}
|
||||
existing={existing}
|
||||
maxPerAccount={meeting.participantsPerAccount}
|
||||
remaining={remaining}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+85
-8
@@ -1,17 +1,92 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { JsonLd } from "@/components/JsonLd";
|
||||
import {
|
||||
INDEX_FOLLOW,
|
||||
SITE_DESCRIPTION,
|
||||
SITE_KEYWORDS,
|
||||
SITE_LOCALE,
|
||||
SITE_NAME,
|
||||
SITE_TITLE,
|
||||
THEME_COLOR,
|
||||
organizationJsonLd,
|
||||
webSiteJsonLd,
|
||||
} from "@/lib/seo";
|
||||
import { metadataBase, publicBaseUrl } from "@/lib/seo-server";
|
||||
import { getSiteSettings, socialLinks } from "@/lib/site-settings";
|
||||
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.",
|
||||
// Rendu dynamique pour toutes les pages : la CSP à nonce du middleware ne peut
|
||||
// pas signer les scripts d'un HTML pré-généré au build. Toutes les pages
|
||||
// interrogent de toute façon la base.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
themeColor: THEME_COLOR,
|
||||
colorScheme: "light",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
/**
|
||||
* Métadonnées par défaut de tout le site. Chaque page complète ou remplace
|
||||
* titre, description et canonique ; `metadataBase` rend absolues les URLs
|
||||
* relatives (canonique, Open Graph, image de partage).
|
||||
*/
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return {
|
||||
metadataBase: await metadataBase(),
|
||||
title: {
|
||||
default: SITE_TITLE,
|
||||
template: `%s · ${SITE_NAME}`,
|
||||
},
|
||||
description: SITE_DESCRIPTION,
|
||||
applicationName: SITE_NAME,
|
||||
keywords: SITE_KEYWORDS,
|
||||
authors: [{ name: `Association ${SITE_NAME}` }],
|
||||
creator: `Association ${SITE_NAME}`,
|
||||
publisher: `Association ${SITE_NAME}`,
|
||||
category: "business",
|
||||
referrer: "strict-origin-when-cross-origin",
|
||||
// Pas de canonique ici : chaque page publique pose la sienne via
|
||||
// `pageMetadata()` ; les pages privées (noindex) n'en ont pas.
|
||||
openGraph: {
|
||||
type: "website",
|
||||
locale: SITE_LOCALE,
|
||||
siteName: SITE_NAME,
|
||||
title: SITE_TITLE,
|
||||
description: SITE_DESCRIPTION,
|
||||
url: "/",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: SITE_TITLE,
|
||||
description: SITE_DESCRIPTION,
|
||||
},
|
||||
robots: INDEX_FOLLOW,
|
||||
icons: {
|
||||
icon: [{ url: "/assets/logo.png", type: "image/png" }],
|
||||
apple: [{ url: "/assets/logo.png", type: "image/png" }],
|
||||
shortcut: ["/assets/logo.png"],
|
||||
},
|
||||
manifest: "/manifest.webmanifest",
|
||||
};
|
||||
}
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// Données structurées communes : l'association (Organization) et le site
|
||||
// (WebSite + recherche dans l'annuaire). Ne bloque jamais le rendu.
|
||||
const [baseUrl, settings] = await Promise.all([
|
||||
publicBaseUrl(),
|
||||
getSiteSettings().catch(() => null),
|
||||
]);
|
||||
const structuredData = settings
|
||||
? [organizationJsonLd(baseUrl, settings, socialLinks(settings)), webSiteJsonLd(baseUrl)]
|
||||
: [webSiteJsonLd(baseUrl)];
|
||||
|
||||
return (
|
||||
<html lang="fr">
|
||||
<head>
|
||||
@@ -25,13 +100,15 @@ export default function RootLayout({
|
||||
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.png" type="image/png" />
|
||||
{/* Fallback: if JS is disabled, never keep revealed content hidden. */}
|
||||
<noscript>
|
||||
<style>{`.reveal,.reveal-stagger>*{opacity:1!important}`}</style>
|
||||
</noscript>
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
<body>
|
||||
{children}
|
||||
<JsonLd data={structuredData} />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
import { NOINDEX } from "@/lib/seo";
|
||||
|
||||
// La page de connexion est un composant client : ses métadonnées vivent ici.
|
||||
export const metadata: Metadata = {
|
||||
title: "Connexion à l'espace adhérent",
|
||||
robots: NOINDEX,
|
||||
};
|
||||
|
||||
export default function LoginLayout({ children }: { children: React.ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { SITE_DESCRIPTION, SITE_NAME, SITE_TITLE, THEME_COLOR } from "@/lib/seo";
|
||||
|
||||
/** Manifeste web : nom, icône et couleurs, pour l'ajout à l'écran d'accueil. */
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: SITE_TITLE,
|
||||
short_name: SITE_NAME,
|
||||
description: SITE_DESCRIPTION,
|
||||
lang: "fr",
|
||||
start_url: "/",
|
||||
display: "standalone",
|
||||
background_color: "#F6F2E8",
|
||||
theme_color: THEME_COLOR,
|
||||
icons: [{ src: "/assets/logo.png", sizes: "any", type: "image/png" }],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { Metadata } from "next";
|
||||
import { LegalPage } from "@/components/LegalPage";
|
||||
import { getSiteSettings } from "@/lib/site-settings";
|
||||
import { pageMetadata } from "@/lib/seo";
|
||||
|
||||
export const metadata: Metadata = pageMetadata({
|
||||
title: "Mentions légales",
|
||||
description: "Mentions légales du site de l'association Plein R : éditeur, hébergeur, propriété intellectuelle.",
|
||||
path: "/mentions-legales",
|
||||
});
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function value(input: string, fallback = "à renseigner") {
|
||||
return input || fallback;
|
||||
}
|
||||
|
||||
export default async function MentionsLegalesPage() {
|
||||
const settings = await getSiteSettings();
|
||||
|
||||
return (
|
||||
<LegalPage title="Mentions légales" intro={`Informations relatives à l’édition et à l’utilisation du site ${settings.association_name}.`}>
|
||||
<h2>Éditeur du site</h2>
|
||||
<p>
|
||||
Ce site est édité par {settings.association_name}, association des commerçants et entreprises du Bassin de Pompey.
|
||||
</p>
|
||||
<ul>
|
||||
<li>Siège social : {value(settings.association_address)}</li>
|
||||
<li>RNA : {value(settings.association_rna)}</li>
|
||||
<li>SIRET : {value(settings.association_siret)}</li>
|
||||
<li>Direction de la publication : {value(settings.association_publication_director || settings.association_president)}</li>
|
||||
</ul>
|
||||
<p>
|
||||
Contact : {value(settings.association_email || settings.association_contact)}{" "}
|
||||
{settings.association_phone ? `- ${settings.association_phone}` : ""}
|
||||
</p>
|
||||
|
||||
<h2>Hébergement</h2>
|
||||
<ul>
|
||||
<li>Hébergeur : {value(settings.association_host_name)}</li>
|
||||
<li>Adresse : {value(settings.association_host_address)}</li>
|
||||
<li>Téléphone : {value(settings.association_host_phone)}</li>
|
||||
</ul>
|
||||
|
||||
<h2>Propriété intellectuelle</h2>
|
||||
<p>
|
||||
Structure, textes, éléments graphiques et identité visuelle du site sont protégés. Toute reproduction,
|
||||
adaptation ou diffusion sans autorisation préalable de Plein R est interdite, hors exceptions prévues par la loi.
|
||||
</p>
|
||||
<p>Logos, images et contenus publiés par les adhérents restent la propriété de leurs titulaires respectifs.</p>
|
||||
|
||||
<h2>Responsabilité</h2>
|
||||
<p>
|
||||
Plein R veille à la qualité des informations publiées, sans pouvoir garantir leur exhaustivité permanente.
|
||||
Les offres, coordonnées et contenus des adhérents relèvent de la responsabilité de leurs auteurs.
|
||||
</p>
|
||||
{settings.legal_updated && <p>Dernière mise à jour : {settings.legal_updated}</p>}
|
||||
</LegalPage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
import { SiteHeader } from "@/components/SiteHeader";
|
||||
import { NOINDEX } from "@/lib/seo";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Page introuvable",
|
||||
robots: NOINDEX,
|
||||
};
|
||||
|
||||
/** 404 publique : renvoie vers l'annuaire plutôt que vers une page blanche. */
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div style={{ background: "#F6F2E8", minHeight: "100vh", fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
|
||||
<SiteHeader logo />
|
||||
<main className="container" style={{ paddingTop: 48, paddingBottom: 72, maxWidth: 720 }}>
|
||||
<div style={{ color: "#9a6638", fontSize: 12, letterSpacing: "0.14em", textTransform: "uppercase", fontWeight: 800, marginBottom: 12 }}>
|
||||
Erreur 404
|
||||
</div>
|
||||
<h1 className="font-display" style={{ margin: "0 0 14px", color: "#26201a", fontSize: "clamp(30px, 6vw, 48px)", lineHeight: 1.05, letterSpacing: "-0.02em" }}>
|
||||
Cette page n'existe pas ou plus
|
||||
</h1>
|
||||
<p style={{ fontSize: 17, lineHeight: 1.6, color: "#6c6150", margin: "0 0 26px" }}>
|
||||
La fiche ou la page demandée a peut-être été retirée. Retrouvez tous les commerçants et
|
||||
entreprises du Bassin de Pompey dans l'annuaire.
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
|
||||
<Link
|
||||
href="/annuaire"
|
||||
className="font-display"
|
||||
style={{ textDecoration: "none", background: "#E0A63C", color: "#33291D", fontWeight: 700, fontSize: 15.5, padding: "13px 24px", borderRadius: 12 }}
|
||||
>
|
||||
Voir l'annuaire
|
||||
</Link>
|
||||
<Link
|
||||
href="/"
|
||||
className="font-display"
|
||||
style={{ textDecoration: "none", background: "#fff", border: "1px solid #e6dcc6", color: "#33291D", fontWeight: 700, fontSize: 15.5, padding: "13px 24px", borderRadius: 12 }}
|
||||
>
|
||||
Retour à l'accueil
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { ImageResponse } from "next/og";
|
||||
import { SITE_NAME } from "@/lib/seo";
|
||||
|
||||
/**
|
||||
* Image de partage (Open Graph / Twitter) générée à la volée, 1200×630.
|
||||
* Sert de vignette par défaut à toutes les pages ; les fiches adhérents la
|
||||
* reprennent, leurs logos étant stockés en data-URI (illisibles par les
|
||||
* robots de prévisualisation).
|
||||
*/
|
||||
export const alt = "Plein R — Commerçants & entreprises du Bassin de Pompey";
|
||||
export const size = { width: 1200, height: 630 };
|
||||
export const contentType = "image/png";
|
||||
|
||||
/** Logo servi depuis `public/` (copié dans l'image standalone), en data-URI pour Satori. */
|
||||
async function logoDataUri(): Promise<string | null> {
|
||||
try {
|
||||
const file = await readFile(path.join(process.cwd(), "public", "assets", "logo.png"));
|
||||
return `data:image/png;base64,${file.toString("base64")}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default async function OpenGraphImage() {
|
||||
const logo = await logoDataUri();
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "space-between",
|
||||
padding: 72,
|
||||
background: "linear-gradient(135deg, #F6F2E8 0%, #efe9da 100%)",
|
||||
color: "#26201a",
|
||||
fontFamily: "sans-serif",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 24 }}>
|
||||
{logo ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={logo} alt="" width={120} height={120} style={{ objectFit: "contain" }} />
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: 84,
|
||||
height: 84,
|
||||
borderRadius: 24,
|
||||
background: "#E0A63C",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "#26201a",
|
||||
fontSize: 48,
|
||||
fontWeight: 800,
|
||||
}}
|
||||
>
|
||||
R
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: 40, fontWeight: 800, letterSpacing: -1 }}>{SITE_NAME}</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
|
||||
<div style={{ fontSize: 26, letterSpacing: 6, textTransform: "uppercase", color: "#9a6638", fontWeight: 700 }}>
|
||||
Réseau · Rencontre · Réussite
|
||||
</div>
|
||||
<div style={{ fontSize: 72, fontWeight: 800, lineHeight: 1.02, letterSpacing: -2, display: "flex", flexWrap: "wrap" }}>
|
||||
Le cœur commerçant du
|
||||
<span style={{ color: "#2C6FB3" }}>Bassin de Pompey</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 30, color: "#6c6150", marginTop: 8 }}>
|
||||
Annuaire des commerçants, artisans et entreprises · Bons plans · Rencontres
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 14 }}>
|
||||
{["#E0A63C", "#2C6FB3", "#d8472b", "#1f8a5b"].map((c) => (
|
||||
<div key={c} style={{ width: 64, height: 10, borderRadius: 999, background: c }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{ ...size }
|
||||
);
|
||||
}
|
||||
+116
-3
@@ -1,3 +1,4 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Sparkle } from "@/components/Sparkle";
|
||||
import { Reveal } from "@/components/Reveal";
|
||||
@@ -7,10 +8,24 @@ import { MembershipModalButton } from "@/components/MembershipModalButton";
|
||||
import { MembersCarousel } from "@/components/MembersCarousel";
|
||||
import { StarField } from "@/components/StarField";
|
||||
import { PromoImage } from "@/components/PromoImage";
|
||||
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
|
||||
import { getLivePromotions, getRotatingActiveMembers } from "@/lib/queries";
|
||||
import { getSiteSettings, socialLinks } from "@/lib/site-settings";
|
||||
import { SITE_DESCRIPTION, SITE_TITLE, pageMetadata } from "@/lib/seo";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
...pageMetadata({
|
||||
title: SITE_TITLE,
|
||||
ogTitle: SITE_TITLE,
|
||||
description: SITE_DESCRIPTION,
|
||||
path: "/",
|
||||
}),
|
||||
// Titre absolu : la page d'accueil ne prend pas le suffixe « · Plein R ».
|
||||
title: { absolute: SITE_TITLE },
|
||||
};
|
||||
|
||||
const STRIPE_WARM =
|
||||
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
|
||||
|
||||
@@ -20,10 +35,12 @@ function badgeColor(badge: string | null) {
|
||||
}
|
||||
|
||||
export default async function AccueilPage() {
|
||||
const [promos, rotatingMembers] = await Promise.all([
|
||||
const [promos, rotatingMembers, settings] = await Promise.all([
|
||||
getLivePromotions(6),
|
||||
getRotatingActiveMembers(),
|
||||
getSiteSettings(),
|
||||
]);
|
||||
const socials = socialLinks(settings);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -36,7 +53,7 @@ export default async function AccueilPage() {
|
||||
>
|
||||
<SiteHeader active="accueil" />
|
||||
|
||||
<div className="container">
|
||||
<main className="container">
|
||||
{/* hero */}
|
||||
<section
|
||||
className="grid hero-grid"
|
||||
@@ -318,6 +335,102 @@ export default async function AccueilPage() {
|
||||
<MembersCarousel members={rotatingMembers} />
|
||||
</section>
|
||||
|
||||
{/* réseaux sociaux */}
|
||||
{socials.length > 0 && (
|
||||
<section
|
||||
style={{
|
||||
position: "relative",
|
||||
margin: "0 0 46px",
|
||||
background: "#fff",
|
||||
border: "1px solid #e6dcc6",
|
||||
borderRadius: 24,
|
||||
padding: "34px 36px 36px",
|
||||
overflow: "hidden",
|
||||
boxShadow: "0 26px 60px -40px rgba(40,30,15,0.5)",
|
||||
}}
|
||||
>
|
||||
<Sparkle color="#6FB0C6" size={18} style={{ top: 26, right: 42 }} duration={3.1} />
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
background: "#eaf0f6",
|
||||
color: "#2C6FB3",
|
||||
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: "#2C6FB3" }} />
|
||||
Suivez-nous
|
||||
</div>
|
||||
<h2
|
||||
className="font-display"
|
||||
style={{ fontWeight: 800, fontSize: "clamp(22px, 5vw, 30px)", letterSpacing: "-0.02em", margin: 0, color: "#26201a" }}
|
||||
>
|
||||
Plein R sur les réseaux
|
||||
</h2>
|
||||
<p style={{ margin: "7px 0 0", color: "#8c8068", fontSize: 15, maxWidth: 560 }}>
|
||||
Bons plans, rencontres et actualités des commerçants du Bassin de Pompey : retrouvez-nous
|
||||
sur Facebook et LinkedIn.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-2" style={{ gap: 16, marginTop: 24 }}>
|
||||
{socials.map((s) => (
|
||||
<a
|
||||
key={s.network}
|
||||
href={s.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="lift-card"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 14,
|
||||
textDecoration: "none",
|
||||
color: "inherit",
|
||||
background: "#faf7ef",
|
||||
border: "1px solid #f0e8d6",
|
||||
borderRadius: 16,
|
||||
padding: "16px 18px",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 13,
|
||||
background: SOCIAL_BRAND[s.network],
|
||||
color: "#fff",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<SocialIcon network={s.network} size={22} />
|
||||
</span>
|
||||
<span style={{ minWidth: 0, flex: 1 }}>
|
||||
<span className="font-display" style={{ display: "block", fontWeight: 700, fontSize: 16, color: "#26201a" }}>
|
||||
{s.label}
|
||||
</span>
|
||||
<span style={{ display: "block", fontSize: 12.5, color: "#8c8068", marginTop: 2, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{s.handle}
|
||||
</span>
|
||||
</span>
|
||||
<span style={{ color: "#2C6FB3", fontWeight: 700, fontSize: 14, flexShrink: 0 }}>↗</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* warm CTA */}
|
||||
<section
|
||||
style={{
|
||||
@@ -356,7 +469,7 @@ export default async function AccueilPage() {
|
||||
/>
|
||||
</Reveal>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<SiteFooter />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { Metadata } from "next";
|
||||
import { asc, desc, inArray } from "drizzle-orm";
|
||||
import { JsonLd } from "@/components/JsonLd";
|
||||
import { PastMeetingGallery } from "@/components/PastMeetingGallery";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
import { SiteHeader } from "@/components/SiteHeader";
|
||||
import { db } from "@/db";
|
||||
import { meetingRegistrations, pastMeetingPhotos, pastMeetings } from "@/db/schema";
|
||||
import { splitLines } from "@/lib/site-settings";
|
||||
import { breadcrumbJsonLd, pageMetadata } from "@/lib/seo";
|
||||
import { publicBaseUrl } from "@/lib/seo-server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata: Metadata = pageMetadata({
|
||||
title: "Rencontres passées de l'association",
|
||||
description:
|
||||
"Retour en images sur les rencontres Plein R : soirées réseau, visites d'entreprises et moments " +
|
||||
"partagés entre entrepreneurs du Bassin de Pompey.",
|
||||
path: "/rencontres-passees",
|
||||
});
|
||||
|
||||
function formatDate(date: Date) {
|
||||
return new Intl.DateTimeFormat("fr-FR", { day: "numeric", month: "long", year: "numeric" }).format(date);
|
||||
}
|
||||
|
||||
export default async function PastMeetingsPage() {
|
||||
const [archives, photos, baseUrl] = await Promise.all([
|
||||
db.select().from(pastMeetings).orderBy(desc(pastMeetings.eventDate)),
|
||||
db.select().from(pastMeetingPhotos).orderBy(asc(pastMeetingPhotos.position), asc(pastMeetingPhotos.id)),
|
||||
publicBaseUrl(),
|
||||
]);
|
||||
const linkedIds = archives.map((archive) => archive.meetingId).filter((id): id is number => id !== null);
|
||||
const linkedRegistrations = linkedIds.length > 0
|
||||
? await db
|
||||
.select({ meetingId: meetingRegistrations.meetingId, id: meetingRegistrations.id })
|
||||
.from(meetingRegistrations)
|
||||
.where(inArray(meetingRegistrations.meetingId, linkedIds))
|
||||
: [];
|
||||
|
||||
const photosByPast = new Map<number, typeof photos>();
|
||||
for (const photo of photos) {
|
||||
photosByPast.set(photo.pastMeetingId, [...(photosByPast.get(photo.pastMeetingId) ?? []), photo]);
|
||||
}
|
||||
const registrationCounts = new Map<number, number>();
|
||||
for (const registration of linkedRegistrations) {
|
||||
registrationCounts.set(registration.meetingId, (registrationCounts.get(registration.meetingId) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ background: "#F6F2E8", minHeight: "100vh", fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
|
||||
<SiteHeader active="association" logo />
|
||||
<JsonLd
|
||||
data={breadcrumbJsonLd(baseUrl, [
|
||||
{ name: "Accueil", path: "/" },
|
||||
{ name: "L'association", path: "/association" },
|
||||
{ name: "Rencontres passées", path: "/rencontres-passees" },
|
||||
])}
|
||||
/>
|
||||
<main>
|
||||
<section style={{ background: "#13324F", color: "#fff" }}>
|
||||
<div className="container" style={{ paddingTop: 58, paddingBottom: 54 }}>
|
||||
<div style={{ color: "#E0A63C", fontSize: 12, letterSpacing: "0.14em", textTransform: "uppercase", fontWeight: 800 }}>Ils y étaient</div>
|
||||
<h1 className="font-display" style={{ margin: "10px 0 14px", fontSize: "clamp(38px, 7vw, 62px)", lineHeight: 1 }}>Toutes nos rencontres passées</h1>
|
||||
<p style={{ maxWidth: 680, color: "#cfe0ee", fontSize: 17, lineHeight: 1.7, margin: 0 }}>
|
||||
Revivez en images les temps forts de Plein R et les moments partagés par les acteurs économiques du Bassin de Pompey.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="container" style={{ paddingTop: 54, paddingBottom: 80 }}>
|
||||
{archives.length === 0 ? (
|
||||
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 34, color: "#8c8068", textAlign: "center" }}>Aucune rencontre passée publiée pour le moment.</div>
|
||||
) : (
|
||||
<div className="grid grid-3" style={{ gap: 20 }}>
|
||||
{archives.map((archive) => {
|
||||
const archivePhotos = photosByPast.get(archive.id) ?? [];
|
||||
const participantCount = archive.meetingId
|
||||
? registrationCounts.get(archive.meetingId) ?? 0
|
||||
: splitLines(archive.participants ?? "").length;
|
||||
return (
|
||||
<article key={archive.id} style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 18 }}>
|
||||
<div style={{ color: "#9a6638", fontSize: 12.5, fontWeight: 800 }}>{formatDate(archive.eventDate)}</div>
|
||||
<h2 className="font-display" style={{ color: "#26201a", fontSize: 23, margin: "7px 0" }}>{archive.title}</h2>
|
||||
{archive.location && <div style={{ color: "#6c6150", fontSize: 13, fontWeight: 700 }}>{archive.location}</div>}
|
||||
{archive.description && <p style={{ color: "#8c8068", fontSize: 13.5, lineHeight: 1.65 }}>{archive.description}</p>}
|
||||
<div style={{ color: "#6c6150", fontSize: 12.5, fontWeight: 800, marginBottom: 12 }}>
|
||||
{participantCount} participant{participantCount > 1 ? "s" : ""} · {archivePhotos.length} photo{archivePhotos.length > 1 ? "s" : ""}
|
||||
</div>
|
||||
<PastMeetingGallery title={archive.title} photos={archivePhotos} />
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { PRIVATE_PATHS, absoluteUrl } from "@/lib/seo";
|
||||
import { publicBaseUrl } from "@/lib/seo-server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** /robots.txt : tout le site public est explorable, l'espace privé exclu. */
|
||||
export default async function robots(): Promise<MetadataRoute.Robots> {
|
||||
const base = await publicBaseUrl();
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
disallow: PRIVATE_PATHS,
|
||||
},
|
||||
],
|
||||
sitemap: base ? absoluteUrl(base, "/sitemap.xml") : undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { members } from "@/db/schema";
|
||||
import { STATIC_ROUTES, absoluteUrl, memberPath } from "@/lib/seo";
|
||||
import { publicBaseUrl } from "@/lib/seo-server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** /sitemap.xml : pages publiques + une entrée par adhérent actif. */
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const base = await publicBaseUrl();
|
||||
|
||||
// Pas de `lastModified` : sans date de mise à jour fiable en base, mieux vaut
|
||||
// l'omettre qu'annoncer une valeur que les moteurs finiraient par ignorer.
|
||||
const statics: MetadataRoute.Sitemap = STATIC_ROUTES.map((route) => ({
|
||||
url: absoluteUrl(base, route.path),
|
||||
changeFrequency: route.changeFrequency,
|
||||
priority: route.priority,
|
||||
}));
|
||||
|
||||
let rows: { id: number }[] = [];
|
||||
try {
|
||||
rows = await db
|
||||
.select({ id: members.id })
|
||||
.from(members)
|
||||
.where(eq(members.status, "active"))
|
||||
.orderBy(asc(members.id));
|
||||
} catch {
|
||||
// Base indisponible : on livre au moins les pages statiques.
|
||||
}
|
||||
|
||||
const fiches: MetadataRoute.Sitemap = rows.map((m) => ({
|
||||
url: absoluteUrl(base, memberPath(m.id)),
|
||||
changeFrequency: "weekly",
|
||||
priority: 0.7,
|
||||
}));
|
||||
|
||||
return [...statics, ...fiches];
|
||||
}
|
||||
@@ -13,6 +13,10 @@ export const authConfig = {
|
||||
},
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
// 7 jours et non 30 : un jeton dérobé sur un poste partagé vaut d'autant
|
||||
// moins longtemps. La révocation immédiate reste assurée par
|
||||
// `users.session_version` (src/lib/session.ts).
|
||||
maxAge: 7 * 24 * 60 * 60,
|
||||
},
|
||||
trustHost: true,
|
||||
callbacks: {
|
||||
@@ -23,6 +27,7 @@ export const authConfig = {
|
||||
token.memberId = (user as { memberId?: number | null }).memberId ?? null;
|
||||
token.mustChangePassword =
|
||||
(user as { mustChangePassword?: boolean }).mustChangePassword ?? false;
|
||||
token.sessionVersion = (user as { sessionVersion?: number }).sessionVersion ?? 0;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
@@ -36,6 +41,8 @@ export const authConfig = {
|
||||
| "member";
|
||||
session.user.memberId = (token.memberId as number | null) ?? null;
|
||||
session.user.mustChangePassword = Boolean(token.mustChangePassword);
|
||||
// Exposé pour la revalidation en base (src/lib/session.ts).
|
||||
session.sessionVersion = Number(token.sessionVersion ?? 0);
|
||||
}
|
||||
return session;
|
||||
},
|
||||
|
||||
+19
-3
@@ -5,6 +5,11 @@ import { eq } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { users } from "@/db/schema";
|
||||
import { authConfig } from "@/auth.config";
|
||||
import { clearLoginFailures, isLoginBlocked, recordLoginFailure } from "@/lib/login-throttle";
|
||||
|
||||
// Empreinte bcrypt d'une valeur quelconque : sert de comparaison factice quand
|
||||
// l'adresse n'existe pas, pour que le temps de réponse reste identique.
|
||||
const ABSENT_USER_HASH = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy";
|
||||
|
||||
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
...authConfig,
|
||||
@@ -21,12 +26,22 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
const password = String(credentials?.password ?? "");
|
||||
if (!email || !password) return null;
|
||||
|
||||
if (isLoginBlocked(email)) 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;
|
||||
// Comparaison menée même sans compte correspondant : sans cela, le
|
||||
// temps de réponse révélerait quelles adresses existent.
|
||||
const hash = user?.passwordHash ?? ABSENT_USER_HASH;
|
||||
const valid = await bcrypt.compare(password, hash);
|
||||
|
||||
if (!user || !valid) {
|
||||
recordLoginFailure(email);
|
||||
return null;
|
||||
}
|
||||
|
||||
clearLoginFailures(email);
|
||||
|
||||
return {
|
||||
id: String(user.id),
|
||||
@@ -35,6 +50,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
role: user.role,
|
||||
memberId: user.memberId,
|
||||
mustChangePassword: user.mustChangePassword,
|
||||
sessionVersion: user.sessionVersion ?? 0,
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Icônes de la navigation d'administration. Les anciens carrés / ronds /
|
||||
* losanges ne portaient aucun sens : une icône par rubrique rend le menu
|
||||
* lisible d'un coup d'œil.
|
||||
*/
|
||||
export type BackendIconName =
|
||||
| "dashboard"
|
||||
| "members"
|
||||
| "inbox"
|
||||
| "promos"
|
||||
| "meetings"
|
||||
| "registrations"
|
||||
| "gallery"
|
||||
| "emails"
|
||||
| "social"
|
||||
| "categories"
|
||||
| "settings"
|
||||
| "admins"
|
||||
| "space"
|
||||
| "menu"
|
||||
| "close";
|
||||
|
||||
const PATHS: Record<BackendIconName, ReactNode> = {
|
||||
dashboard: (
|
||||
<>
|
||||
<rect x="3" y="3" width="7.5" height="7.5" rx="2" />
|
||||
<rect x="13.5" y="3" width="7.5" height="7.5" rx="2" />
|
||||
<rect x="3" y="13.5" width="7.5" height="7.5" rx="2" />
|
||||
<rect x="13.5" y="13.5" width="7.5" height="7.5" rx="2" />
|
||||
</>
|
||||
),
|
||||
members: (
|
||||
<>
|
||||
<circle cx="9" cy="8" r="3.4" />
|
||||
<path d="M2.8 20c0-3.4 2.8-5.6 6.2-5.6s6.2 2.2 6.2 5.6" />
|
||||
<path d="M16.4 5.2a3.2 3.2 0 0 1 0 6M18 14.8c2 .7 3.2 2.4 3.2 5.2" />
|
||||
</>
|
||||
),
|
||||
inbox: (
|
||||
<>
|
||||
<rect x="2.5" y="4.5" width="19" height="15" rx="2.5" />
|
||||
<path d="m4 6.5 8 6 8-6" />
|
||||
</>
|
||||
),
|
||||
promos: (
|
||||
<>
|
||||
<path d="M11.6 2.8H20a1.2 1.2 0 0 1 1.2 1.2v8.4a1.6 1.6 0 0 1-.5 1.1l-7.4 7.4a1.6 1.6 0 0 1-2.3 0l-7-7a1.6 1.6 0 0 1 0-2.3l7.4-7.4a1.6 1.6 0 0 1 1.1-.5Z" />
|
||||
<circle cx="16.6" cy="7.4" r="1.4" />
|
||||
</>
|
||||
),
|
||||
meetings: (
|
||||
<>
|
||||
<rect x="3" y="5" width="18" height="16" rx="2.5" />
|
||||
<path d="M3 9.5h18M8 3v4M16 3v4" />
|
||||
</>
|
||||
),
|
||||
registrations: (
|
||||
<>
|
||||
<path d="M8 4.5H6.5A2.5 2.5 0 0 0 4 7v12a2.5 2.5 0 0 0 2.5 2.5h11A2.5 2.5 0 0 0 20 19V7a2.5 2.5 0 0 0-2.5-2.5H16" />
|
||||
<rect x="8" y="2.5" width="8" height="4" rx="1.4" />
|
||||
<path d="m8.6 12.4 1.7 1.7 3.4-3.4M8.6 17.2h6.8" />
|
||||
</>
|
||||
),
|
||||
gallery: (
|
||||
<>
|
||||
<rect x="3" y="4.5" width="18" height="15" rx="2.5" />
|
||||
<circle cx="8.6" cy="9.6" r="1.7" />
|
||||
<path d="m4 17 4.8-4.4a1.8 1.8 0 0 1 2.4 0L16 17M14 14.4l1.6-1.5a1.8 1.8 0 0 1 2.4 0L21 15.6" />
|
||||
</>
|
||||
),
|
||||
emails: (
|
||||
<>
|
||||
<path d="M20.5 3.5 10.8 13.2M20.5 3.5 14.3 21l-3.5-7.8L3 9.7l17.5-6.2Z" />
|
||||
</>
|
||||
),
|
||||
social: (
|
||||
<>
|
||||
<circle cx="18" cy="5.4" r="2.9" />
|
||||
<circle cx="6" cy="12" r="2.9" />
|
||||
<circle cx="18" cy="18.6" r="2.9" />
|
||||
<path d="m8.5 10.6 7-3.8M8.5 13.4l7 3.8" />
|
||||
</>
|
||||
),
|
||||
categories: (
|
||||
<>
|
||||
<rect x="3" y="3.5" width="7.5" height="7.5" rx="2" />
|
||||
<rect x="3" y="13" width="7.5" height="7.5" rx="2" />
|
||||
<rect x="13.5" y="13" width="7.5" height="7.5" rx="2" />
|
||||
<circle cx="17.2" cy="7.2" r="3.8" />
|
||||
</>
|
||||
),
|
||||
settings: (
|
||||
<>
|
||||
<path d="M4 7h10M18 7h2M4 17h2M10 17h10" />
|
||||
<circle cx="16" cy="7" r="2.4" />
|
||||
<circle cx="8" cy="17" r="2.4" />
|
||||
</>
|
||||
),
|
||||
admins: (
|
||||
<>
|
||||
<path d="M12 2.6 4.5 5.8V11c0 4.7 3.1 8.6 7.5 10.4 4.4-1.8 7.5-5.7 7.5-10.4V5.8L12 2.6Z" />
|
||||
<path d="m8.8 11.8 2.2 2.2 4.2-4.2" />
|
||||
</>
|
||||
),
|
||||
space: (
|
||||
<>
|
||||
<circle cx="12" cy="8.2" r="3.6" />
|
||||
<path d="M4.8 20.4c0-3.7 3.2-6 7.2-6s7.2 2.3 7.2 6" />
|
||||
</>
|
||||
),
|
||||
menu: <path d="M4 7h16M4 12h16M4 17h16" />,
|
||||
close: <path d="m6 6 12 12M18 6 6 18" />,
|
||||
};
|
||||
|
||||
export function BackendIcon({ name, size = 18 }: { name: BackendIconName; size?: number }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
height={size}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ display: "block", flexShrink: 0 }}
|
||||
>
|
||||
{PATHS[name]}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Modal } from "./Modal";
|
||||
import { submitContactMessage } from "@/app/actions/public";
|
||||
|
||||
@@ -74,6 +75,13 @@ export function ContactModalButton({
|
||||
|
||||
{error && <div style={errorBox}>{error}</div>}
|
||||
|
||||
<p style={{ margin: "0 0 12px", fontSize: 11.5, lineHeight: 1.5, color: "#8c8068" }}>
|
||||
Vos informations servent uniquement à répondre à votre message. Consultez notre{" "}
|
||||
<Link href="/confidentialite" target="_blank" rel="noopener noreferrer" style={{ color: "#9a6638", fontWeight: 700 }}>
|
||||
politique de confidentialité
|
||||
</Link>.
|
||||
</p>
|
||||
|
||||
<button type="submit" className="lift-cta" disabled={pending} style={{ ...primaryBtn, opacity: pending ? 0.6 : 1 }}>
|
||||
{pending ? "Envoi…" : "Envoyer le message"}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { copyRichEmail, downloadHtml, downloadOutlookDraft } from "@/lib/email-client";
|
||||
import { buildGeneralEmail, type EmailBrand, type GeneralEmailContent } from "@/lib/email-templates";
|
||||
|
||||
export function EmailCreator({ brand }: { brand: EmailBrand }) {
|
||||
const [baseUrl, setBaseUrl] = useState("");
|
||||
const [form, setForm] = useState<GeneralEmailContent>({
|
||||
subject: `Actualités de ${brand.associationName}`,
|
||||
kicker: brand.associationName,
|
||||
title: "Votre titre ici",
|
||||
body: "Bonjour,\n\nRédigez ici votre message. Créez plusieurs paragraphes en laissant une ligne vide.\n\nLe gabarit conserve automatiquement l'identité visuelle Plein R.",
|
||||
buttonLabel: "Découvrir",
|
||||
buttonUrl: "",
|
||||
signature: `À très bientôt,\nL'équipe de ${brand.associationName}`,
|
||||
});
|
||||
const [flash, setFlash] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setBaseUrl(window.location.origin);
|
||||
setForm((current) => ({ ...current, buttonUrl: `${window.location.origin}/association` }));
|
||||
}, []);
|
||||
|
||||
const email = useMemo(
|
||||
() => (baseUrl ? buildGeneralEmail(form, baseUrl, brand) : null),
|
||||
[baseUrl, brand, form],
|
||||
);
|
||||
|
||||
function change(name: keyof GeneralEmailContent, value: string) {
|
||||
setForm((current) => ({ ...current, [name]: value }));
|
||||
}
|
||||
|
||||
function success(key: string) {
|
||||
setFlash(key);
|
||||
setTimeout(() => setFlash(""), 1800);
|
||||
}
|
||||
|
||||
async function action(key: string, task: () => Promise<void> | void) {
|
||||
try {
|
||||
setError("");
|
||||
await task();
|
||||
success(key);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Action impossible dans ce navigateur.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="email-workbench">
|
||||
<section className="email-editor-card">
|
||||
<div className="email-editor-kicker">Studio de communication</div>
|
||||
<h2 className="font-display" style={{ margin: "6px 0 8px", fontSize: 23, color: "#26201a" }}>Composer un e-mail</h2>
|
||||
<p style={{ margin: "0 0 20px", color: "#8c8068", fontSize: 13, lineHeight: 1.6 }}>
|
||||
Logo, couleurs Plein R et coordonnées ajoutés automatiquement.
|
||||
</p>
|
||||
|
||||
<div style={{ display: "grid", gap: 13 }}>
|
||||
<EmailField label="Objet" value={form.subject} onChange={(value) => change("subject", value)} />
|
||||
<EmailField label="Surtitre" value={form.kicker} onChange={(value) => change("kicker", value)} />
|
||||
<EmailField label="Titre" value={form.title} onChange={(value) => change("title", value)} />
|
||||
<EmailArea label="Message" value={form.body} rows={9} onChange={(value) => change("body", value)} />
|
||||
<div style={{ borderTop: "1px solid #f0e8d6", paddingTop: 14, display: "grid", gap: 11 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 800, color: "#6c6150" }}>Bouton facultatif</div>
|
||||
<EmailField label="Texte du bouton" value={form.buttonLabel} onChange={(value) => change("buttonLabel", value)} />
|
||||
<EmailField label="Lien" type="url" value={form.buttonUrl} onChange={(value) => change("buttonUrl", value)} />
|
||||
</div>
|
||||
<EmailArea label="Signature" value={form.signature} rows={3} onChange={(value) => change("signature", value)} />
|
||||
</div>
|
||||
|
||||
{error && <div style={{ marginTop: 13, color: "#d8472b", fontSize: 13, fontWeight: 700 }}>{error}</div>}
|
||||
<div style={{ display: "grid", gap: 9, marginTop: 20 }}>
|
||||
<button type="button" className="email-primary-button" disabled={!email} onClick={() => email && action("outlook", () => downloadOutlookDraft(email.subject, email.html))}>
|
||||
{flash === "outlook" ? "✓ Brouillon téléchargé" : "Télécharger le brouillon Outlook"}
|
||||
</button>
|
||||
<button type="button" className="email-secondary-button" disabled={!email} onClick={() => email && action("copy", () => copyRichEmail(email.html, `${email.subject}\n\n${form.body}`))}>
|
||||
{flash === "copy" ? "✓ E-mail copié" : "Copier l'e-mail enrichi"}
|
||||
</button>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 9 }}>
|
||||
<button type="button" className="email-secondary-button" onClick={() => action("subject", () => navigator.clipboard.writeText(form.subject))}>
|
||||
{flash === "subject" ? "✓ Objet copié" : "Copier l'objet"}
|
||||
</button>
|
||||
<button type="button" className="email-secondary-button" disabled={!email} onClick={() => email && action("html", () => downloadHtml(email.subject, email.html))}>
|
||||
{flash === "html" ? "✓ HTML créé" : "Télécharger HTML"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ margin: "13px 0 0", color: "#9a8d72", fontSize: 11.5, lineHeight: 1.55 }}>
|
||||
Ouvrez fichier .eml pour obtenir un brouillon Outlook prêt à compléter avec destinataires.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section style={{ minWidth: 0 }}>
|
||||
<div style={{ marginBottom: 10, color: "#8c8068", fontSize: 12.5 }}><strong style={{ color: "#33291d" }}>Objet :</strong> {form.subject}</div>
|
||||
<div className="email-preview-shell">
|
||||
{email ? <iframe title="Aperçu de l'e-mail" srcDoc={email.html} sandbox="" /> : <div style={{ padding: 30 }}>Préparation aperçu…</div>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmailField({ label, value, onChange, type = "text" }: { label: string; value: string; onChange: (value: string) => void; type?: string }) {
|
||||
return <label className="field-label">{label}<input className="field" type={type} value={value} onChange={(event) => onChange(event.target.value)} style={{ marginTop: 6 }} /></label>;
|
||||
}
|
||||
|
||||
function EmailArea({ label, value, onChange, rows }: { label: string; value: string; onChange: (value: string) => void; rows: number }) {
|
||||
return <label className="field-label">{label}<textarea className="field" value={value} rows={rows} onChange={(event) => onChange(event.target.value)} style={{ marginTop: 6, resize: "vertical" }} /></label>;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
editorSchedule,
|
||||
serializeMemberHours,
|
||||
type DaySchedule,
|
||||
type HoursSlot,
|
||||
} from "@/lib/member-profile";
|
||||
|
||||
function emptySlot(): HoursSlot {
|
||||
return { start: "09:00", end: "18:00" };
|
||||
}
|
||||
|
||||
export function HoursEditor({ defaultValue }: { defaultValue?: string | null }) {
|
||||
const initialDays = useMemo(() => editorSchedule(defaultValue), [defaultValue]);
|
||||
const [days, setDays] = useState<DaySchedule[]>(initialDays);
|
||||
const [touched, setTouched] = useState(Boolean(defaultValue?.trim()));
|
||||
|
||||
function updateDay(index: number, update: (day: DaySchedule) => DaySchedule) {
|
||||
setTouched(true);
|
||||
setDays((current) => current.map((day, dayIndex) => (dayIndex === index ? update(day) : day)));
|
||||
}
|
||||
|
||||
function setOpen(index: number, open: boolean) {
|
||||
updateDay(index, (day) => ({
|
||||
...day,
|
||||
closed: !open,
|
||||
slots: open ? (day.slots.length > 0 ? day.slots : [emptySlot()]) : [],
|
||||
}));
|
||||
}
|
||||
|
||||
function updateSlot(index: number, slotIndex: number, key: keyof HoursSlot, value: string) {
|
||||
updateDay(index, (day) => ({
|
||||
...day,
|
||||
slots: day.slots.map((slot, currentSlot) =>
|
||||
currentSlot === slotIndex ? { ...slot, [key]: value } : slot
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
function addSlot(index: number) {
|
||||
updateDay(index, (day) => ({
|
||||
...day,
|
||||
closed: false,
|
||||
slots: [...day.slots, { start: "14:00", end: "18:00" }].slice(0, 2),
|
||||
}));
|
||||
}
|
||||
|
||||
function removeSlot(index: number, slotIndex: number) {
|
||||
updateDay(index, (day) => {
|
||||
const slots = day.slots.filter((_, currentSlot) => currentSlot !== slotIndex);
|
||||
return { ...day, closed: slots.length === 0, slots };
|
||||
});
|
||||
}
|
||||
|
||||
const serialized = touched ? serializeMemberHours(days) : defaultValue ?? "";
|
||||
|
||||
return (
|
||||
<fieldset className="hours-editor">
|
||||
<legend className="field-label">Horaires d'ouverture</legend>
|
||||
<p className="hours-editor__hint">
|
||||
Activez jours ouverts. Ajoutez seconde plage pour coupure déjeuner.
|
||||
</p>
|
||||
<input type="hidden" name="hours" value={serialized} />
|
||||
|
||||
<div className="hours-editor__days">
|
||||
{days.map((day, index) => {
|
||||
const open = !day.closed;
|
||||
return (
|
||||
<div className={`hours-editor__day${open ? " is-open" : ""}`} key={day.key}>
|
||||
<div className="hours-editor__day-head">
|
||||
<strong>{day.label}</strong>
|
||||
<label className="hours-editor__toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={open}
|
||||
onChange={(event) => setOpen(index, event.target.checked)}
|
||||
/>
|
||||
<span>{open ? "Ouvert" : "Fermé"}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="hours-editor__slots">
|
||||
{day.slots.map((slot, slotIndex) => (
|
||||
<div className="hours-editor__slot" key={`${day.key}-${slotIndex}`}>
|
||||
<label>
|
||||
<span>De</span>
|
||||
<input
|
||||
type="time"
|
||||
value={slot.start}
|
||||
onChange={(event) =>
|
||||
updateSlot(index, slotIndex, "start", event.target.value)
|
||||
}
|
||||
aria-label={`${day.label}, ouverture ${slotIndex + 1}`}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>À</span>
|
||||
<input
|
||||
type="time"
|
||||
value={slot.end}
|
||||
onChange={(event) =>
|
||||
updateSlot(index, slotIndex, "end", event.target.value)
|
||||
}
|
||||
aria-label={`${day.label}, fermeture ${slotIndex + 1}`}
|
||||
/>
|
||||
</label>
|
||||
{day.slots.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="hours-editor__remove"
|
||||
onClick={() => removeSlot(index, slotIndex)}
|
||||
aria-label={`Supprimer plage ${slotIndex + 1} du ${day.label.toLowerCase()}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{day.slots.length < 2 && (
|
||||
<button
|
||||
type="button"
|
||||
className="hours-editor__add"
|
||||
onClick={() => addSlot(index)}
|
||||
>
|
||||
+ Ajouter une seconde plage
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
export function ImageConsentForm({
|
||||
saveAction,
|
||||
defaultName,
|
||||
compact = false,
|
||||
currentDecision = null,
|
||||
}: {
|
||||
saveAction: (formData: FormData) => Promise<void>;
|
||||
defaultName: string;
|
||||
compact?: boolean;
|
||||
currentDecision?: "accepted" | "refused" | null;
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [drawing, setDrawing] = useState(false);
|
||||
const [signature, setSignature] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
function point(event: React.PointerEvent<HTMLCanvasElement>) {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return null;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return {
|
||||
x: ((event.clientX - rect.left) / rect.width) * canvas.width,
|
||||
y: ((event.clientY - rect.top) / rect.height) * canvas.height,
|
||||
};
|
||||
}
|
||||
|
||||
function draw(event: React.PointerEvent<HTMLCanvasElement>) {
|
||||
if (!drawing) return;
|
||||
const p = point(event);
|
||||
const ctx = canvasRef.current?.getContext("2d");
|
||||
if (!p || !ctx) return;
|
||||
ctx.lineTo(p.x, p.y);
|
||||
ctx.strokeStyle = "#13324F";
|
||||
ctx.lineWidth = 3;
|
||||
ctx.lineCap = "round";
|
||||
ctx.stroke();
|
||||
setSignature(canvasRef.current?.toDataURL("image/png") ?? "");
|
||||
}
|
||||
|
||||
function start(event: React.PointerEvent<HTMLCanvasElement>) {
|
||||
const p = point(event);
|
||||
const ctx = canvasRef.current?.getContext("2d");
|
||||
if (!p || !ctx) return;
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(p.x, p.y);
|
||||
setDrawing(true);
|
||||
}
|
||||
|
||||
function clear() {
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas?.getContext("2d");
|
||||
if (canvas && ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
setSignature("");
|
||||
setError("");
|
||||
}
|
||||
|
||||
function onSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
const submitter = (event.nativeEvent as SubmitEvent).submitter as HTMLButtonElement | null;
|
||||
if (submitter?.value === "accepted" && !signature) {
|
||||
event.preventDefault();
|
||||
setError("Signez dans le cadre pour valider l'autorisation.");
|
||||
}
|
||||
}
|
||||
|
||||
if (currentDecision === "accepted") {
|
||||
return (
|
||||
<form action={saveAction} style={{ marginTop: 12 }}>
|
||||
<input type="hidden" name="signatoryName" value={defaultName} />
|
||||
<input type="hidden" name="signaturePng" value="" />
|
||||
<button
|
||||
type="submit"
|
||||
name="decision"
|
||||
value="refused"
|
||||
style={{
|
||||
border: "1px solid #e0c3bb",
|
||||
background: "#fff",
|
||||
color: "#d8472b",
|
||||
fontWeight: 800,
|
||||
borderRadius: 8,
|
||||
padding: "9px 14px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Retirer mon autorisation
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
action={saveAction}
|
||||
onSubmit={onSubmit}
|
||||
style={{
|
||||
background: compact ? "#fff" : "#F6F2E8",
|
||||
border: "1px solid #e6dcc6",
|
||||
borderRadius: 16,
|
||||
padding: compact ? 18 : 24,
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="signaturePng" value={signature} />
|
||||
<div style={{ display: "grid", gap: 14 }}>
|
||||
<div>
|
||||
<label className="field-label">Nom du signataire</label>
|
||||
<input name="signatoryName" defaultValue={defaultName} className="field" required />
|
||||
</div>
|
||||
|
||||
<div style={{ color: "#6c6150", fontSize: 13.5, lineHeight: 1.65 }}>
|
||||
J'autorise l'association Plein R à utiliser mon image dans les supports liés à la vie
|
||||
associative: site internet, réseaux sociaux et supports imprimés. Autorisation gratuite,
|
||||
valable 5 ans, sans vente à des tiers. Je peux retirer mon accord à tout moment.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="field-label">Signature</label>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={760}
|
||||
height={190}
|
||||
onPointerDown={start}
|
||||
onPointerMove={draw}
|
||||
onPointerUp={() => setDrawing(false)}
|
||||
onPointerCancel={() => setDrawing(false)}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: compact ? 120 : 150,
|
||||
display: "block",
|
||||
touchAction: "none",
|
||||
background: "#fff",
|
||||
border: "1px dashed #d6c9ad",
|
||||
borderRadius: 12,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clear}
|
||||
style={{
|
||||
marginTop: 8,
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
color: "#9a6638",
|
||||
fontSize: 12.5,
|
||||
fontWeight: 700,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Effacer la signature
|
||||
</button>
|
||||
{error && <div style={{ color: "#d8472b", fontSize: 12.5, marginTop: 4 }}>{error}</div>}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
|
||||
<button
|
||||
type="submit"
|
||||
name="decision"
|
||||
value="accepted"
|
||||
style={{
|
||||
border: "none",
|
||||
background: "#13324F",
|
||||
color: "#fff",
|
||||
fontWeight: 800,
|
||||
borderRadius: 10,
|
||||
padding: "11px 17px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
J'autorise et je signe
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
name="decision"
|
||||
value="refused"
|
||||
style={{
|
||||
border: "1px solid #e0c3bb",
|
||||
background: "#fff",
|
||||
color: "#d8472b",
|
||||
fontWeight: 800,
|
||||
borderRadius: 10,
|
||||
padding: "11px 17px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Je refuse
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { headers } from "next/headers";
|
||||
import { serializeJsonLd } from "@/lib/seo";
|
||||
|
||||
/**
|
||||
* Bloc de données structurées `application/ld+json`.
|
||||
*
|
||||
* Le contenu est sérialisé par `serializeJsonLd`, qui échappe `<`, `>` et `&` :
|
||||
* les descriptions saisies par les adhérents ne peuvent pas fermer la balise.
|
||||
* Le nonce de la CSP est recopié par cohérence, même si un bloc JSON-LD n'est
|
||||
* jamais exécuté par le navigateur.
|
||||
*/
|
||||
export async function JsonLd({ data }: { data: unknown | unknown[] }) {
|
||||
const nonce = (await headers()).get("x-nonce") ?? undefined;
|
||||
const items = Array.isArray(data) ? data : [data];
|
||||
return (
|
||||
<>
|
||||
{items.map((item, index) => (
|
||||
<script
|
||||
key={index}
|
||||
type="application/ld+json"
|
||||
nonce={nonce}
|
||||
dangerouslySetInnerHTML={{ __html: serializeJsonLd(item) }}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { SiteFooter } from "./SiteFooter";
|
||||
import { SiteHeader } from "./SiteHeader";
|
||||
|
||||
export function LegalPage({ title, intro, children }: { title: string; intro: string; children: ReactNode }) {
|
||||
return (
|
||||
<div style={{ background: "#F6F2E8", minHeight: "100vh", fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
|
||||
<SiteHeader logo />
|
||||
<main className="container" style={{ paddingTop: 24, paddingBottom: 64 }}>
|
||||
<Link href="/" style={{ color: "#9a6638", fontSize: 13.5, fontWeight: 700, textDecoration: "none" }}>
|
||||
← Retour à l'accueil
|
||||
</Link>
|
||||
<article
|
||||
className="legal-content"
|
||||
style={{ maxWidth: 860, marginTop: 22, background: "#fff", border: "1px solid #e6dcc6", borderRadius: 22, padding: "clamp(24px, 5vw, 48px)", boxShadow: "0 24px 55px -42px rgba(40,30,15,0.55)" }}
|
||||
>
|
||||
<div style={{ color: "#9a6638", fontSize: 11.5, letterSpacing: "0.14em", textTransform: "uppercase", fontWeight: 800, marginBottom: 10 }}>
|
||||
Informations du site
|
||||
</div>
|
||||
<h1 className="font-display" style={{ fontSize: "clamp(30px, 6vw, 44px)", letterSpacing: "-0.03em", color: "#26201a", margin: "0 0 12px" }}>
|
||||
{title}
|
||||
</h1>
|
||||
<p style={{ fontSize: 16, maxWidth: 680 }}>{intro}</p>
|
||||
{children}
|
||||
</article>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Modal } from "@/components/Modal";
|
||||
import { copyRichEmail, downloadHtml, downloadOutlookDraft } from "@/lib/email-client";
|
||||
import { buildMeetingEmail, defaultMeetingEmailTexts, type EmailBrand, type MeetingEmailData, type MeetingEmailTexts } from "@/lib/email-templates";
|
||||
|
||||
export function MeetingEmailComposer({ meeting, brand }: { meeting: MeetingEmailData; brand: EmailBrand }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [baseUrl, setBaseUrl] = useState("");
|
||||
const [texts, setTexts] = useState<MeetingEmailTexts>(() => defaultMeetingEmailTexts(brand));
|
||||
const [flash, setFlash] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const email = useMemo(() => (baseUrl ? buildMeetingEmail(meeting, texts, baseUrl, brand) : null), [baseUrl, brand, meeting, texts]);
|
||||
|
||||
function show() {
|
||||
setBaseUrl(window.location.origin);
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
function change(name: keyof MeetingEmailTexts, value: string) {
|
||||
setTexts((current) => ({ ...current, [name]: value }));
|
||||
}
|
||||
|
||||
async function action(key: string, task: () => Promise<void> | void) {
|
||||
try {
|
||||
setError("");
|
||||
await task();
|
||||
setFlash(key);
|
||||
setTimeout(() => setFlash(""), 1800);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Action impossible dans ce navigateur.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={show} className="meeting-email-button">✦ Créer invitation e-mail</button>
|
||||
<Modal open={open} onClose={() => setOpen(false)} title={`Invitation — ${meeting.title}`} maxWidth={1100}>
|
||||
<div className="meeting-email-workbench">
|
||||
<div style={{ display: "grid", gap: 13 }}>
|
||||
<EmailArea label="Salutation" value={texts.greeting} rows={2} onChange={(value) => change("greeting", value)} />
|
||||
<EmailArea label="Introduction" value={texts.intro} rows={5} onChange={(value) => change("intro", value)} />
|
||||
<EmailArea label="Conclusion" value={texts.outro} rows={4} onChange={(value) => change("outro", value)} />
|
||||
<EmailArea label="Signature" value={texts.signature} rows={3} onChange={(value) => change("signature", value)} />
|
||||
<p style={{ margin: 0, color: "#8c8068", fontSize: 12, lineHeight: 1.55 }}>
|
||||
Date, lieu, description, places et lien d'inscription repris automatiquement.
|
||||
</p>
|
||||
{error && <div style={{ color: "#d8472b", fontSize: 13, fontWeight: 700 }}>{error}</div>}
|
||||
<button type="button" className="email-primary-button" disabled={!email} onClick={() => email && action("outlook", () => downloadOutlookDraft(email.subject, email.html, "invitation"))}>
|
||||
{flash === "outlook" ? "✓ Brouillon téléchargé" : "Télécharger brouillon Outlook"}
|
||||
</button>
|
||||
<button type="button" className="email-secondary-button" disabled={!email} onClick={() => email && action("copy", () => copyRichEmail(email.html, `${email.subject}\n${email.registrationUrl}`))}>
|
||||
{flash === "copy" ? "✓ Invitation copiée" : "Copier invitation enrichie"}
|
||||
</button>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||
<button type="button" className="email-secondary-button" disabled={!email} onClick={() => email && action("link", () => navigator.clipboard.writeText(email.registrationUrl))}>{flash === "link" ? "✓ Lien copié" : "Copier lien"}</button>
|
||||
<button type="button" className="email-secondary-button" disabled={!email} onClick={() => email && action("html", () => downloadHtml(email.subject, email.html, "invitation"))}>{flash === "html" ? "✓ HTML créé" : "HTML"}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ marginBottom: 9, color: "#8c8068", fontSize: 12 }}><strong style={{ color: "#33291d" }}>Objet :</strong> {email?.subject}</div>
|
||||
<div className="email-preview-shell compact">
|
||||
{email && <iframe title="Aperçu invitation" srcDoc={email.html} sandbox="" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EmailArea({ label, value, onChange, rows }: { label: string; value: string; onChange: (value: string) => void; rows: number }) {
|
||||
return <label className="field-label">{label}<textarea className="field" value={value} rows={rows} onChange={(event) => onChange(event.target.value)} style={{ marginTop: 6, resize: "vertical" }} /></label>;
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
saveMeetingRegistration,
|
||||
type MeetingRegistrationState,
|
||||
} from "@/app/backend/actions";
|
||||
|
||||
type Participant = {
|
||||
name: string;
|
||||
imageConsent: boolean;
|
||||
status?: string;
|
||||
};
|
||||
|
||||
const INITIAL_STATE: MeetingRegistrationState = { status: "idle", message: "" };
|
||||
|
||||
export function MeetingRegistrationForm({
|
||||
meetingId,
|
||||
defaultName,
|
||||
existing,
|
||||
maxPerAccount,
|
||||
remaining,
|
||||
}: {
|
||||
meetingId: number;
|
||||
defaultName: string;
|
||||
existing: Participant[];
|
||||
maxPerAccount: number;
|
||||
remaining: number;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [participants, setParticipants] = useState<Participant[]>(
|
||||
existing.length > 0 ? existing : [{ name: defaultName, imageConsent: false }],
|
||||
);
|
||||
const [state, formAction, pending] = useActionState(saveMeetingRegistration, INITIAL_STATE);
|
||||
const maxAllowed = Math.max(existing.length, Math.min(maxPerAccount, existing.length + remaining));
|
||||
const registered = existing.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status !== "success") return;
|
||||
if (state.registrationCount === 0) {
|
||||
setParticipants([{ name: defaultName, imageConsent: false }]);
|
||||
}
|
||||
router.refresh();
|
||||
}, [defaultName, router, state]);
|
||||
|
||||
return (
|
||||
<form action={formAction} style={{ display: "grid", gap: 18 }}>
|
||||
<input type="hidden" name="meetingId" value={meetingId} />
|
||||
<input type="hidden" name="participantCount" value={participants.length} />
|
||||
|
||||
<div style={{ background: "#f6f2e8", border: "1px solid #e6dcc6", borderRadius: 8, padding: "13px 15px", color: "#6c6150", fontSize: 13.5, lineHeight: 1.6 }}>
|
||||
Maximum {maxPerAccount} participant{maxPerAccount > 1 ? "s" : ""} par compte membre.
|
||||
{registered && (
|
||||
<span style={{ display: "block", color: "#9a6638", fontWeight: 800, marginTop: 3 }}>
|
||||
{existing.every((participant) => participant.status === "confirmed")
|
||||
? "Inscription confirmée"
|
||||
: "Inscription en attente de validation"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gap: 12 }}>
|
||||
{participants.map((participant, index) => (
|
||||
<div key={index} style={{ border: "1px solid #e6dcc6", borderRadius: 8, padding: 16, background: "#fff" }}>
|
||||
<div style={{ display: "flex", gap: 10, alignItems: "end" }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label className="field-label" htmlFor={`participant-${index}`}>
|
||||
Participant {index + 1} - nom et prénom
|
||||
</label>
|
||||
<input
|
||||
id={`participant-${index}`}
|
||||
name={`participantName-${index}`}
|
||||
className="field"
|
||||
required
|
||||
maxLength={200}
|
||||
value={participant.name}
|
||||
onChange={(event) =>
|
||||
setParticipants((current) =>
|
||||
current.map((item, itemIndex) =>
|
||||
itemIndex === index ? { ...item, name: event.target.value } : item,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{participants.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Retirer le participant ${index + 1}`}
|
||||
title="Retirer"
|
||||
onClick={() => setParticipants((current) => current.filter((_, itemIndex) => itemIndex !== index))}
|
||||
style={{ width: 42, height: 42, border: "1px solid #e0c3bb", background: "#fff", color: "#d8472b", borderRadius: 8, cursor: "pointer", fontSize: 20 }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<label style={{ display: "flex", gap: 9, alignItems: "start", color: "#6c6150", fontSize: 12.5, lineHeight: 1.5, marginTop: 11 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
name={`imageConsent-${index}`}
|
||||
checked={participant.imageConsent}
|
||||
onChange={() =>
|
||||
setParticipants((current) =>
|
||||
current.map((item, itemIndex) =>
|
||||
itemIndex === index ? { ...item, imageConsent: !item.imageConsent } : item,
|
||||
),
|
||||
)
|
||||
}
|
||||
style={{ marginTop: 3 }}
|
||||
/>
|
||||
Cette personne autorise la publication de son image sur les supports de Plein R.
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{participants.length < maxAllowed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setParticipants((current) => [...current, { name: "", imageConsent: false }])}
|
||||
style={{ justifySelf: "start", border: "none", background: "transparent", color: "#9a6638", fontWeight: 800, cursor: "pointer", padding: 0 }}
|
||||
>
|
||||
+ Ajouter un participant
|
||||
</button>
|
||||
)}
|
||||
|
||||
<label style={{ display: "flex", gap: 9, alignItems: "start", color: "#6c6150", fontSize: 12.5, lineHeight: 1.55 }}>
|
||||
<input type="checkbox" name="attestation" required style={{ marginTop: 3 }} />
|
||||
J'atteste avoir informé chaque personne inscrite que des photos peuvent être prises et avoir recueilli son accord ou son refus ci-dessus.
|
||||
</label>
|
||||
|
||||
{state.message && (
|
||||
<div
|
||||
role="status"
|
||||
style={{
|
||||
background: state.status === "success" ? "#e6f4ec" : "#fbe9e6",
|
||||
border: `1px solid ${state.status === "success" ? "#b9dfc8" : "#f0c4bb"}`,
|
||||
color: state.status === "success" ? "#1f6f4a" : "#b53a25",
|
||||
borderRadius: 8,
|
||||
padding: "11px 14px",
|
||||
fontSize: 13.5,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{state.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
|
||||
<button
|
||||
type="submit"
|
||||
name="intent"
|
||||
value="save"
|
||||
disabled={pending || maxAllowed < 1}
|
||||
style={{ border: "none", background: "#13324F", color: "#fff", borderRadius: 8, padding: "13px 20px", fontWeight: 800, cursor: pending ? "default" : "pointer", opacity: pending ? 0.65 : 1 }}
|
||||
>
|
||||
{pending ? "Enregistrement..." : registered ? "Modifier mon inscription" : "Enregistrer mon inscription"}
|
||||
</button>
|
||||
{registered && (
|
||||
<button
|
||||
type="submit"
|
||||
name="intent"
|
||||
value="cancel"
|
||||
formNoValidate
|
||||
disabled={pending}
|
||||
style={{ border: "1px solid #e0c3bb", background: "#fff", color: "#d8472b", borderRadius: 8, padding: "12px 18px", fontWeight: 800, cursor: pending ? "default" : "pointer" }}
|
||||
>
|
||||
Annuler mon inscription
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Modal } from "./Modal";
|
||||
import { submitMembershipRequest } from "@/app/actions/public";
|
||||
|
||||
@@ -75,6 +76,13 @@ export function MembershipModalButton({
|
||||
|
||||
{error && <div style={errorBox}>{error}</div>}
|
||||
|
||||
<p style={{ margin: "0 0 12px", fontSize: 11.5, lineHeight: 1.5, color: "#8c8068" }}>
|
||||
Vos informations servent uniquement à traiter votre demande. Consultez notre{" "}
|
||||
<Link href="/confidentialite" target="_blank" rel="noopener noreferrer" style={{ color: "#9a6638", fontWeight: 700 }}>
|
||||
politique de confidentialité
|
||||
</Link>.
|
||||
</p>
|
||||
|
||||
<button type="submit" className="lift-cta" disabled={pending} style={{ ...primaryBtn, opacity: pending ? 0.6 : 1 }}>
|
||||
{pending ? "Envoi…" : "Envoyer ma demande"}
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
export function Modal({
|
||||
open,
|
||||
@@ -31,7 +32,7 @@ export function Modal({
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@@ -88,6 +89,7 @@ export function Modal({
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Affichage unique d'identifiants fraîchement émis.
|
||||
*
|
||||
* Le mot de passe temporaire n'est stocké nulle part : il n'existe que dans
|
||||
* l'état de ce composant, le temps que le staff le relève. Un rechargement de
|
||||
* la page le fait disparaître définitivement (il reste possible de
|
||||
* réinitialiser le mot de passe pour en obtenir un nouveau).
|
||||
*/
|
||||
export type IssuedCredentialsItem = { label?: string; email: string; tempPassword: string };
|
||||
|
||||
export function OneTimeCredentials({
|
||||
items,
|
||||
title = "Identifiants à transmettre",
|
||||
}: {
|
||||
items: IssuedCredentialsItem[];
|
||||
title?: string;
|
||||
}) {
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
style={{ background: "#fbeede", border: "1px solid #ecd8b8", borderRadius: 12, padding: "14px 18px", fontSize: 13.5, color: "#9a6638" }}
|
||||
>
|
||||
<div className="font-display" style={{ fontWeight: 700, fontSize: 14.5, marginBottom: 8 }}>
|
||||
{title}
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{items.map((item) => (
|
||||
<div key={item.email} style={{ display: "flex", flexWrap: "wrap", gap: "4px 14px", alignItems: "baseline" }}>
|
||||
{item.label && <span style={{ fontWeight: 700, color: "#6f4b23" }}>{item.label}</span>}
|
||||
<span>
|
||||
Identifiant : <strong>{item.email}</strong>
|
||||
</span>
|
||||
<span>
|
||||
Mot de passe temporaire :{" "}
|
||||
<strong style={{ fontFamily: "monospace", fontSize: 15, userSelect: "all" }}>{item.tempPassword}</strong>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, marginTop: 10, lineHeight: 1.5 }}>
|
||||
Notez-le maintenant : il n'est affiché qu'une seule fois et n'est conservé nulle part.
|
||||
Un changement de mot de passe sera exigé à la première connexion.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
export function PastMeetingGallery({
|
||||
title,
|
||||
photos,
|
||||
}: {
|
||||
title: string;
|
||||
photos: { id: number; imageUrl: string; caption: string | null }[];
|
||||
}) {
|
||||
const [open, setOpen] = useState<number | null>(null);
|
||||
if (photos.length === 0) return null;
|
||||
const current = open == null ? null : photos.find((photo) => photo.id === open);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 8, marginTop: 14 }}>
|
||||
{photos.slice(0, 4).map((photo, index) => (
|
||||
<button
|
||||
key={photo.id}
|
||||
type="button"
|
||||
onClick={() => setOpen(photo.id)}
|
||||
aria-label={`Ouvrir photo ${index + 1}`}
|
||||
style={{
|
||||
position: "relative",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
aspectRatio: "1 / 1",
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
cursor: "pointer",
|
||||
background: `center/cover no-repeat url(${photo.imageUrl})`,
|
||||
}}
|
||||
>
|
||||
{index === 3 && photos.length > 4 && (
|
||||
<span style={{ position: "absolute", inset: 0, background: "rgba(19,50,79,0.62)", color: "#fff", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 800 }}>
|
||||
+{photos.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{current && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`Galerie ${title}`}
|
||||
onClick={() => setOpen(null)}
|
||||
style={{ position: "fixed", inset: 0, background: "rgba(19,50,79,0.72)", zIndex: 80, padding: 24, display: "flex", alignItems: "center", justifyContent: "center" }}
|
||||
>
|
||||
<div onClick={(event) => event.stopPropagation()} style={{ maxWidth: 980, width: "100%" }}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={current.imageUrl} alt={current.caption ?? title} style={{ width: "100%", maxHeight: "78vh", objectFit: "contain", background: "#fff", borderRadius: 12 }} />
|
||||
<div style={{ marginTop: 12, display: "flex", justifyContent: "space-between", gap: 12, alignItems: "center" }}>
|
||||
<div style={{ color: "#fff", fontWeight: 700 }}>{current.caption ?? title}</div>
|
||||
<button type="button" onClick={() => setOpen(null)} style={{ border: "none", background: "#fff", color: "#13324F", borderRadius: 999, padding: "9px 16px", fontWeight: 800, cursor: "pointer" }}>
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +1,89 @@
|
||||
import Link from "next/link";
|
||||
import { getSiteSettings, socialLinks } from "@/lib/site-settings";
|
||||
import { ContactModalButton } from "./ContactModalButton";
|
||||
import { SOCIAL_BRAND, SocialIcon } from "./SocialIcons";
|
||||
|
||||
export async function SiteFooter() {
|
||||
const year = new Date().getFullYear();
|
||||
const settings = await getSiteSettings();
|
||||
const socials = socialLinks(settings);
|
||||
|
||||
const email = settings.association_email.trim();
|
||||
const phone = settings.association_phone.trim();
|
||||
const address = settings.association_address.trim();
|
||||
const hasContact = !!(email || phone || address);
|
||||
|
||||
export function SiteFooter() {
|
||||
return (
|
||||
<footer style={{ background: "#EFE9DA", borderTop: "1px solid #e2d6bd", padding: "28px clamp(16px, 5vw, 56px)" }}>
|
||||
<div
|
||||
className="container"
|
||||
style={{ display: "flex", justifyContent: "flex-end", alignItems: "center" }}
|
||||
>
|
||||
<div style={{ fontSize: 13, color: "#8c8068", textAlign: "right", lineHeight: 1.7 }}>
|
||||
<ContactModalButton
|
||||
label="Nous contacter"
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
font: "inherit",
|
||||
color: "#9a6638",
|
||||
fontWeight: 700,
|
||||
textDecoration: "underline",
|
||||
}}
|
||||
/>
|
||||
{" · Bassin de Pompey"}
|
||||
<br />
|
||||
Réseau · Rencontre · Réussite
|
||||
<footer className="site-footer">
|
||||
<div className="container" style={{ paddingTop: 40, paddingBottom: 26 }}>
|
||||
<div className="footer-grid">
|
||||
<div className="footer-brand">
|
||||
<Link href="/" aria-label="Plein R — accueil" style={{ display: "inline-flex" }}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src="/assets/logo.png" alt="Plein R" />
|
||||
</Link>
|
||||
<p className="footer-tagline">
|
||||
Association des commerçants et entreprises du Bassin de Pompey.
|
||||
Réseau · Rencontre · Réussite.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="footer-title">Accès rapide</div>
|
||||
<div className="footer-links">
|
||||
<Link href="/annuaire">Annuaire</Link>
|
||||
<Link href="/#promotions">Promotions</Link>
|
||||
<Link href="/association">L'association</Link>
|
||||
<Link href="/rencontres-passees">Rencontres passées</Link>
|
||||
<Link href="/login">Espace adhérent</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="footer-title">Informations</div>
|
||||
<div className="footer-links">
|
||||
<ContactModalButton label="Nous contacter" />
|
||||
<Link href="/mentions-legales">Mentions légales</Link>
|
||||
<Link href="/confidentialite">Confidentialité</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{socials.length > 0 && (
|
||||
<>
|
||||
<div className="footer-title">Nous suivre</div>
|
||||
<div className="footer-social">
|
||||
{socials.map((s) => (
|
||||
<a
|
||||
key={s.network}
|
||||
href={s.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`Plein R sur ${s.label}`}
|
||||
>
|
||||
<span style={{ color: SOCIAL_BRAND[s.network], display: "inline-flex" }}>
|
||||
<SocialIcon network={s.network} size={17} />
|
||||
</span>
|
||||
{s.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasContact && (
|
||||
<div className="footer-contact">
|
||||
{email && <a href={`mailto:${email}`}>{email}</a>}
|
||||
{phone && <a href={`tel:${phone.replace(/[^\d+]/g, "")}`}>{phone}</a>}
|
||||
{address && <span>{address}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="footer-bottom">
|
||||
<span>© {year} Plein R. Tous droits réservés.</span>
|
||||
<span className="footer-bottom__place">Bassin de Pompey</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -4,61 +4,42 @@ const NAV = [
|
||||
{ key: "accueil", label: "Accueil", href: "/" },
|
||||
{ key: "annuaire", label: "Annuaire", href: "/annuaire" },
|
||||
{ key: "promotions", label: "Promotions", href: "/#promotions" },
|
||||
{ key: "association", label: "L'association", href: "#" },
|
||||
{ key: "association", label: "L'association", href: "/association" },
|
||||
];
|
||||
|
||||
export function SiteHeader({ active, logo = false }: { active?: string; logo?: boolean }) {
|
||||
return (
|
||||
<header
|
||||
className="container"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: logo ? "space-between" : "flex-end",
|
||||
padding: "18px clamp(16px, 5vw, 56px)",
|
||||
gap: 20,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
{logo && (
|
||||
<Link href="/" aria-label="Plein R — accueil" style={{ display: "inline-flex", alignItems: "center" }}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src="/assets/logo.png" alt="Plein R" style={{ height: 46, width: "auto", display: "block" }} />
|
||||
</Link>
|
||||
)}
|
||||
<nav style={{ display: "flex", alignItems: "center", gap: 30, flexWrap: "wrap" }}>
|
||||
<header className="container site-header">
|
||||
{/* Sur téléphone le logo est toujours visible : c'est le seul retour à
|
||||
l'accueil, la page d'accueil ne l'affichant qu'en grand dans le hero. */}
|
||||
<Link
|
||||
href="/"
|
||||
aria-label="Plein R — accueil"
|
||||
className={`site-header__brand${logo ? "" : " is-desktop-hidden"}`}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src="/assets/logo.png" alt="Plein R" />
|
||||
</Link>
|
||||
|
||||
<nav className="site-header__nav">
|
||||
{NAV.map((n) => {
|
||||
const isActive = active === n.key;
|
||||
return (
|
||||
<Link
|
||||
key={n.key}
|
||||
href={n.href}
|
||||
style={{
|
||||
textDecoration: "none",
|
||||
color: isActive ? "#3c3322" : "#6f6450",
|
||||
fontWeight: isActive ? 600 : 500,
|
||||
fontSize: 15,
|
||||
}}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={`site-header__link${isActive ? " is-active" : ""}`}
|
||||
>
|
||||
{n.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<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>
|
||||
|
||||
<Link href="/backend" className="site-header__cta">
|
||||
Espace adhérent
|
||||
</Link>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { SocialLink } from "@/lib/site-settings";
|
||||
|
||||
const PATHS: Record<SocialLink["network"], string> = {
|
||||
facebook:
|
||||
"M22 12.06C22 6.5 17.52 2 12 2S2 6.5 2 12.06c0 5.02 3.66 9.18 8.44 9.94v-7.03H7.9v-2.91h2.54V9.85c0-2.52 1.5-3.91 3.77-3.91 1.09 0 2.23.2 2.23.2v2.46h-1.26c-1.24 0-1.63.78-1.63 1.57v1.89h2.78l-.44 2.91h-2.34V22c4.78-.76 8.44-4.92 8.44-9.94Z",
|
||||
linkedin:
|
||||
"M6.94 5.5a2.06 2.06 0 1 1-4.12 0 2.06 2.06 0 0 1 4.12 0ZM6.6 9.05H3.15V21H6.6V9.05Zm5.52 0H8.8V21h3.32v-6.27c0-3.1 4-3.35 4-.01V21h3.33v-7.42c0-5.26-5.94-5.07-7.33-2.48V9.05Z",
|
||||
};
|
||||
|
||||
export const SOCIAL_BRAND: Record<SocialLink["network"], string> = {
|
||||
facebook: "#1877F2",
|
||||
linkedin: "#0A66C2",
|
||||
};
|
||||
|
||||
export function SocialIcon({ network, size = 18 }: { network: SocialLink["network"]; size?: number }) {
|
||||
return (
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor" width={size} height={size} style={{ display: "block" }}>
|
||||
<path d={PATHS[network]} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Données de démonstration.
|
||||
*
|
||||
* Elles ne sont insérées que par `npm run db:seed` avec `SEED_DEMO=true`, et
|
||||
* `npm run db:purge-demo` sait les retirer d'une base qui les a reçues. Tout
|
||||
* ce qui identifie ces enregistrements (e-mails, titres, noms, messages) vit
|
||||
* ici pour que les deux scripts restent alignés.
|
||||
*/
|
||||
|
||||
export const DEMO_PASSWORD = "changeme123";
|
||||
|
||||
export function demoMembers(catId: (slug: string) => number | null) {
|
||||
return [
|
||||
{
|
||||
name: "Au Bon Pain",
|
||||
email: "contact@aubonpain.fr",
|
||||
categoryId: catId("boulangerie"),
|
||||
city: "Frouard",
|
||||
address: "12 rue de la République",
|
||||
postalCode: "54390",
|
||||
phone: "03 83 49 00 00",
|
||||
website: "https://www.aubonpain-frouard.fr",
|
||||
memberSince: 2021,
|
||||
tags: "Levain naturel, Produits locaux, Fait maison, Sans conservateur",
|
||||
hours: "Mardi – Vendredi|7h – 19h30\nSamedi|7h – 19h\nDimanche|7h – 13h\nLundi|Fermé",
|
||||
description:
|
||||
"Boulangerie artisanale familiale installée au cœur de Frouard depuis trois générations. Nous travaillons chaque jour des farines locales et un levain naturel pour vous proposer pains, viennoiseries et pâtisseries faits maison.\nNotre équipe vous accueille du mardi au dimanche matin. Spécialités : pain au levain, baguette de tradition, kouglof et tartes aux fruits de saison.",
|
||||
status: "active" as const,
|
||||
highlighted: true,
|
||||
},
|
||||
{
|
||||
name: "Café des Arts",
|
||||
email: "hello@cafedesarts.fr",
|
||||
categoryId: catId("restauration"),
|
||||
city: "Pompey",
|
||||
address: "3 place Stanislas",
|
||||
postalCode: "54340",
|
||||
phone: "03 83 24 00 00",
|
||||
memberSince: 2019,
|
||||
tags: "Cuisine de saison, Brunch, Terrasse, Produits frais",
|
||||
hours: "Lundi – Vendredi|9h – 18h\nSamedi|9h – 19h\nDimanche|10h – 15h",
|
||||
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,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function demoPromotions(memberId: (name: string) => number | null) {
|
||||
return [
|
||||
{ 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" },
|
||||
];
|
||||
}
|
||||
|
||||
export const demoRequests = [
|
||||
{ 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é." },
|
||||
];
|
||||
|
||||
export const demoActivity = [
|
||||
{ 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" },
|
||||
];
|
||||
|
||||
export function demoUsers(memberId: (name: string) => number | null) {
|
||||
return [
|
||||
{ email: "claire@plein-r.fr", name: "Claire Martin", role: "admin" as const, memberId: null },
|
||||
{ email: "thomas@plein-r.fr", name: "Thomas Petit", role: "moderator" as const, memberId: null },
|
||||
{ email: "sophie@plein-r.fr", name: "Sophie Aubert", role: "editor" as const, memberId: null },
|
||||
{ email: "contact@aubonpain.fr", name: "Au Bon Pain", role: "member" as const, memberId: memberId("Au Bon Pain") },
|
||||
];
|
||||
}
|
||||
|
||||
/** E-mails des fiches adhérent de démonstration. */
|
||||
export function demoMemberEmails(): string[] {
|
||||
return demoMembers(() => null).map((m) => m.email);
|
||||
}
|
||||
|
||||
/** Titres des promotions de démonstration. */
|
||||
export function demoPromotionTitles(): string[] {
|
||||
return demoPromotions(() => null).map((p) => p.title);
|
||||
}
|
||||
|
||||
/** E-mails des comptes de démonstration. */
|
||||
export function demoUserEmails(): string[] {
|
||||
return demoUsers(() => null).map((u) => u.email);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import "dotenv/config";
|
||||
import { Pool } from "pg";
|
||||
import { drizzle } from "drizzle-orm/node-postgres";
|
||||
import { and, inArray, isNull } from "drizzle-orm";
|
||||
import * as schema from "./schema";
|
||||
import {
|
||||
demoActivity,
|
||||
demoMemberEmails,
|
||||
demoPromotionTitles,
|
||||
demoRequests,
|
||||
demoUserEmails,
|
||||
} from "./demo-data";
|
||||
|
||||
const { activityLog, imageConsents, meetingRegistrations, members, membershipRequests, promotions, users } = schema;
|
||||
|
||||
/**
|
||||
* Retire les données de démonstration d'une base qui les a reçues (anciens
|
||||
* déploiements où le seed les insérait à chaque démarrage).
|
||||
*
|
||||
* Seuls les enregistrements du jeu de démonstration sont touchés, reconnus par
|
||||
* leur e-mail, leur titre, leur nom ou leur message exacts. Tout ce qui a été
|
||||
* saisi par l'association reste en place, y compris les catégories et le
|
||||
* compte administrateur.
|
||||
*
|
||||
* npm run db:purge-demo (poste de développement)
|
||||
* docker compose exec app node dist/purge-demo.cjs (conteneur)
|
||||
*/
|
||||
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 });
|
||||
|
||||
const memberEmails = demoMemberEmails();
|
||||
const promoTitles = demoPromotionTitles();
|
||||
const userEmails = demoUserEmails();
|
||||
const requestNames = demoRequests.map((r) => r.name);
|
||||
const activityMessages = demoActivity.map((a) => a.message);
|
||||
|
||||
const removed = await db.transaction(async (tx) => {
|
||||
const demoMemberRows = await tx
|
||||
.select({ id: members.id })
|
||||
.from(members)
|
||||
.where(inArray(members.email, memberEmails));
|
||||
const memberIds = demoMemberRows.map((m) => m.id);
|
||||
|
||||
// Comptes de démonstration (staff et adhérent).
|
||||
const deletedUsers = await tx
|
||||
.delete(users)
|
||||
.where(inArray(users.email, userEmails))
|
||||
.returning({ id: users.id });
|
||||
|
||||
let deletedPromos: { id: number }[] = [];
|
||||
let deletedMembers: { id: number }[] = [];
|
||||
if (memberIds.length > 0) {
|
||||
// Un compte réel rattaché par erreur à une fiche de démo est détaché,
|
||||
// pas supprimé.
|
||||
await tx.update(users).set({ memberId: null }).where(inArray(users.memberId, memberIds));
|
||||
await tx.delete(imageConsents).where(inArray(imageConsents.memberId, memberIds));
|
||||
await tx
|
||||
.update(meetingRegistrations)
|
||||
.set({ memberId: null })
|
||||
.where(inArray(meetingRegistrations.memberId, memberIds));
|
||||
// Les publications réseaux liées suivent (clé étrangère en cascade).
|
||||
deletedPromos = await tx
|
||||
.delete(promotions)
|
||||
.where(inArray(promotions.memberId, memberIds))
|
||||
.returning({ id: promotions.id });
|
||||
deletedMembers = await tx
|
||||
.delete(members)
|
||||
.where(inArray(members.id, memberIds))
|
||||
.returning({ id: members.id });
|
||||
}
|
||||
|
||||
// Promotions de démo sans fiche (seed antérieur, fiche déjà retirée).
|
||||
const orphanPromos = await tx
|
||||
.delete(promotions)
|
||||
.where(and(inArray(promotions.title, promoTitles), isNull(promotions.memberId)))
|
||||
.returning({ id: promotions.id });
|
||||
|
||||
const deletedRequests = await tx
|
||||
.delete(membershipRequests)
|
||||
.where(inArray(membershipRequests.name, requestNames))
|
||||
.returning({ id: membershipRequests.id });
|
||||
|
||||
const deletedActivity = await tx
|
||||
.delete(activityLog)
|
||||
.where(inArray(activityLog.message, activityMessages))
|
||||
.returning({ id: activityLog.id });
|
||||
|
||||
return {
|
||||
comptes: deletedUsers.length,
|
||||
adherents: deletedMembers.length,
|
||||
promotions: deletedPromos.length + orphanPromos.length,
|
||||
demandes: deletedRequests.length,
|
||||
journal: deletedActivity.length,
|
||||
};
|
||||
});
|
||||
|
||||
console.log("Données de démonstration retirées :");
|
||||
for (const [key, count] of Object.entries(removed)) console.log(` ${key} : ${count}`);
|
||||
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Purge failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
+197
-2
@@ -7,6 +7,7 @@ import {
|
||||
varchar,
|
||||
boolean,
|
||||
timestamp,
|
||||
index,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { relations } from "drizzle-orm";
|
||||
@@ -19,7 +20,13 @@ export const promoStatusEnum = pgEnum("promo_status", [
|
||||
"live",
|
||||
"expired",
|
||||
"rejected",
|
||||
"suspended",
|
||||
]);
|
||||
// Qui a suspendu la promotion : son créateur (l'adhérent) ou l'association.
|
||||
// Une promotion suspendue par l'association ne peut être réactivée que par elle.
|
||||
export const promoSuspendedByEnum = pgEnum("promo_suspended_by", ["member", "staff"]);
|
||||
export const socialNetworkEnum = pgEnum("social_network", ["facebook", "linkedin"]);
|
||||
export const socialPostStatusEnum = pgEnum("social_post_status", ["posted", "failed"]);
|
||||
export const requestStatusEnum = pgEnum("request_status", [
|
||||
"new",
|
||||
"approved",
|
||||
@@ -73,8 +80,12 @@ export const users = pgTable(
|
||||
passwordHash: varchar("password_hash", { length: 255 }).notNull(),
|
||||
role: roleEnum("role").notNull().default("member"),
|
||||
memberId: integer("member_id").references(() => members.id),
|
||||
// Le mot de passe temporaire n'est plus stocké (ni en clair ni chiffré) :
|
||||
// il est montré une seule fois à la création / réinitialisation.
|
||||
mustChangePassword: boolean("must_change_password").notNull().default(false),
|
||||
tempPassword: varchar("temp_password", { length: 60 }),
|
||||
// Incrémenté pour invalider les jetons déjà émis (changement ou
|
||||
// réinitialisation de mot de passe).
|
||||
sessionVersion: integer("session_version").notNull().default(0),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
@@ -93,9 +104,56 @@ export const promotions = pgTable("promotions", {
|
||||
memberId: integer("member_id").references(() => members.id),
|
||||
status: promoStatusEnum("status").notNull().default("pending"),
|
||||
validUntil: varchar("valid_until", { length: 120 }),
|
||||
// Réseaux demandés à la soumission. Le choix reste modifiable tant que la
|
||||
// promo est « pending » ; la validation le fige ET déclenche la publication.
|
||||
shareFacebook: boolean("share_facebook").notNull().default(false),
|
||||
shareLinkedin: boolean("share_linkedin").notNull().default(false),
|
||||
suspendedBy: promoSuspendedByEnum("suspended_by"),
|
||||
suspendedById: integer("suspended_by_id").references(() => users.id, { onDelete: "set null" }),
|
||||
suspendedAt: timestamp("suspended_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
// ---- Publications réseaux sociaux (Facebook / LinkedIn) ----
|
||||
export const socialPosts = pgTable(
|
||||
"social_posts",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
promotionId: integer("promotion_id")
|
||||
.notNull()
|
||||
.references(() => promotions.id, { onDelete: "cascade" }),
|
||||
network: socialNetworkEnum("network").notNull(),
|
||||
status: socialPostStatusEnum("status").notNull(),
|
||||
externalId: varchar("external_id", { length: 200 }),
|
||||
url: text("url"),
|
||||
error: text("error"),
|
||||
postedById: integer("posted_by_id").references(() => users.id, { onDelete: "set null" }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
promoIdx: index("social_posts_promotion_idx").on(t.promotionId),
|
||||
})
|
||||
);
|
||||
|
||||
// ---- Comptes réseaux sociaux connectés (OAuth) ----
|
||||
// Une ligne par réseau. Les secrets sont chiffrés (src/lib/crypto.ts) et ne
|
||||
// ressortent jamais vers le navigateur.
|
||||
export const socialAccounts = pgTable("social_accounts", {
|
||||
id: serial("id").primaryKey(),
|
||||
network: socialNetworkEnum("network").notNull().unique(),
|
||||
appId: varchar("app_id", { length: 200 }).notNull(),
|
||||
appSecret: text("app_secret").notNull(),
|
||||
accessToken: text("access_token"),
|
||||
refreshToken: text("refresh_token"),
|
||||
// Nul = n'expire pas (jeton de page Facebook).
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
||||
targetId: varchar("target_id", { length: 200 }),
|
||||
targetName: varchar("target_name", { length: 200 }),
|
||||
connectedById: integer("connected_by_id").references(() => users.id, { onDelete: "set null" }),
|
||||
connectedAt: timestamp("connected_at", { withTimezone: true }),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
// ---- Membership requests (demandes d'adhésion) ----
|
||||
export const membershipRequests = pgTable("membership_requests", {
|
||||
id: serial("id").primaryKey(),
|
||||
@@ -125,6 +183,87 @@ export const activityLog = pgTable("activity_log", {
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
// ---- Site settings (contenus configurables) ----
|
||||
export const siteSettings = pgTable("site_settings", {
|
||||
key: varchar("key", { length: 120 }).primaryKey(),
|
||||
value: text("value"),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
// ---- Rencontres à venir ----
|
||||
export const meetings = pgTable("meetings", {
|
||||
id: serial("id").primaryKey(),
|
||||
title: varchar("title", { length: 200 }).notNull(),
|
||||
startsAt: timestamp("starts_at", { withTimezone: true }).notNull(),
|
||||
location: varchar("location", { length: 240 }),
|
||||
description: text("description"),
|
||||
capacity: integer("capacity").notNull().default(30),
|
||||
participantsPerAccount: integer("participants_per_account").notNull().default(1),
|
||||
imageUrl: text("image_url"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const meetingRegistrations = pgTable(
|
||||
"meeting_registrations",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
meetingId: integer("meeting_id")
|
||||
.notNull()
|
||||
.references(() => meetings.id, { onDelete: "cascade" }),
|
||||
memberId: integer("member_id").references(() => members.id, { onDelete: "set null" }),
|
||||
attendeeName: varchar("attendee_name", { length: 200 }).notNull(),
|
||||
attendeeCompany: varchar("attendee_company", { length: 200 }).notNull().default(""),
|
||||
attendeeEmail: varchar("attendee_email", { length: 200 }),
|
||||
attendeePhone: varchar("attendee_phone", { length: 40 }),
|
||||
status: varchar("status", { length: 20 }).notNull().default("pending"),
|
||||
imageConsent: boolean("image_consent").notNull().default(false),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
meetingIdx: index("meeting_registrations_meeting_idx").on(t.meetingId),
|
||||
memberMeetingIdx: index("meeting_registrations_member_meeting_idx").on(t.memberId, t.meetingId),
|
||||
})
|
||||
);
|
||||
|
||||
// ---- Rencontres passées + galerie ----
|
||||
export const pastMeetings = pgTable("past_meetings", {
|
||||
id: serial("id").primaryKey(),
|
||||
title: varchar("title", { length: 200 }).notNull(),
|
||||
eventDate: timestamp("event_date", { withTimezone: true }).notNull(),
|
||||
location: varchar("location", { length: 240 }),
|
||||
description: text("description"),
|
||||
participants: text("participants"),
|
||||
meetingId: integer("meeting_id").references(() => meetings.id, { onDelete: "set null" }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const pastMeetingPhotos = pgTable("past_meeting_photos", {
|
||||
id: serial("id").primaryKey(),
|
||||
pastMeetingId: integer("past_meeting_id")
|
||||
.notNull()
|
||||
.references(() => pastMeetings.id, { onDelete: "cascade" }),
|
||||
imageUrl: text("image_url").notNull(),
|
||||
caption: varchar("caption", { length: 200 }),
|
||||
position: integer("position").notNull().default(0),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
// ---- RGPD / droit à l'image : historique append-only ----
|
||||
export const imageConsents = pgTable("image_consents", {
|
||||
id: serial("id").primaryKey(),
|
||||
memberId: integer("member_id")
|
||||
.notNull()
|
||||
.references(() => members.id, { onDelete: "cascade" }),
|
||||
decision: varchar("decision", { length: 20 }).notNull(),
|
||||
scopes: text("scopes"),
|
||||
signatoryName: varchar("signatory_name", { length: 200 }),
|
||||
signaturePng: text("signature_png"),
|
||||
consentVersion: varchar("consent_version", { length: 40 }).notNull(),
|
||||
ip: varchar("ip", { length: 120 }),
|
||||
userAgent: text("user_agent"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
// ---- Relations ----
|
||||
export const membersRelations = relations(members, ({ one, many }) => ({
|
||||
category: one(categories, {
|
||||
@@ -132,13 +271,23 @@ export const membersRelations = relations(members, ({ one, many }) => ({
|
||||
references: [categories.id],
|
||||
}),
|
||||
promotions: many(promotions),
|
||||
meetingRegistrations: many(meetingRegistrations),
|
||||
imageConsents: many(imageConsents),
|
||||
}));
|
||||
|
||||
export const promotionsRelations = relations(promotions, ({ one }) => ({
|
||||
export const promotionsRelations = relations(promotions, ({ one, many }) => ({
|
||||
member: one(members, {
|
||||
fields: [promotions.memberId],
|
||||
references: [members.id],
|
||||
}),
|
||||
socialPosts: many(socialPosts),
|
||||
}));
|
||||
|
||||
export const socialPostsRelations = relations(socialPosts, ({ one }) => ({
|
||||
promotion: one(promotions, {
|
||||
fields: [socialPosts.promotionId],
|
||||
references: [promotions.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const usersRelations = relations(users, ({ one }) => ({
|
||||
@@ -148,10 +297,56 @@ export const usersRelations = relations(users, ({ one }) => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
export const meetingsRelations = relations(meetings, ({ many }) => ({
|
||||
registrations: many(meetingRegistrations),
|
||||
}));
|
||||
|
||||
export const meetingRegistrationsRelations = relations(meetingRegistrations, ({ one }) => ({
|
||||
meeting: one(meetings, {
|
||||
fields: [meetingRegistrations.meetingId],
|
||||
references: [meetings.id],
|
||||
}),
|
||||
member: one(members, {
|
||||
fields: [meetingRegistrations.memberId],
|
||||
references: [members.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const pastMeetingsRelations = relations(pastMeetings, ({ one, many }) => ({
|
||||
linkedMeeting: one(meetings, {
|
||||
fields: [pastMeetings.meetingId],
|
||||
references: [meetings.id],
|
||||
}),
|
||||
photos: many(pastMeetingPhotos),
|
||||
}));
|
||||
|
||||
export const pastMeetingPhotosRelations = relations(pastMeetingPhotos, ({ one }) => ({
|
||||
pastMeeting: one(pastMeetings, {
|
||||
fields: [pastMeetingPhotos.pastMeetingId],
|
||||
references: [pastMeetings.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const imageConsentsRelations = relations(imageConsents, ({ one }) => ({
|
||||
member: one(members, {
|
||||
fields: [imageConsents.memberId],
|
||||
references: [members.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export type Category = typeof categories.$inferSelect;
|
||||
export type Member = typeof members.$inferSelect;
|
||||
export type User = typeof users.$inferSelect;
|
||||
export type Promotion = typeof promotions.$inferSelect;
|
||||
export type SocialPost = typeof socialPosts.$inferSelect;
|
||||
export type SocialAccount = typeof socialAccounts.$inferSelect;
|
||||
export type SocialNetwork = (typeof socialNetworkEnum.enumValues)[number];
|
||||
export type MembershipRequest = typeof membershipRequests.$inferSelect;
|
||||
export type ContactMessage = typeof contactMessages.$inferSelect;
|
||||
export type ActivityEntry = typeof activityLog.$inferSelect;
|
||||
export type SiteSetting = typeof siteSettings.$inferSelect;
|
||||
export type Meeting = typeof meetings.$inferSelect;
|
||||
export type MeetingRegistration = typeof meetingRegistrations.$inferSelect;
|
||||
export type PastMeeting = typeof pastMeetings.$inferSelect;
|
||||
export type PastMeetingPhoto = typeof pastMeetingPhotos.$inferSelect;
|
||||
export type ImageConsent = typeof imageConsents.$inferSelect;
|
||||
+92
-147
@@ -2,8 +2,17 @@ import "dotenv/config";
|
||||
import { Pool } from "pg";
|
||||
import { drizzle } from "drizzle-orm/node-postgres";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { randomInt } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import * as schema from "./schema";
|
||||
import {
|
||||
DEMO_PASSWORD,
|
||||
demoActivity,
|
||||
demoMembers,
|
||||
demoPromotions,
|
||||
demoRequests,
|
||||
demoUsers,
|
||||
} from "./demo-data";
|
||||
|
||||
const {
|
||||
categories,
|
||||
@@ -14,6 +23,14 @@ const {
|
||||
activityLog,
|
||||
} = schema;
|
||||
|
||||
// Mot de passe initial lisible, tiré avec un aléa cryptographique.
|
||||
function randomPassword(length = 16): string {
|
||||
const alphabet = "ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789";
|
||||
let out = "";
|
||||
for (let i = 0; i < length; i++) out += alphabet[randomInt(alphabet.length)];
|
||||
return out;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
if (!connectionString) throw new Error("DATABASE_URL is not set");
|
||||
@@ -117,157 +134,85 @@ async function main() {
|
||||
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",
|
||||
postalCode: "54390",
|
||||
phone: "03 83 49 00 00",
|
||||
website: "https://www.aubonpain-frouard.fr",
|
||||
memberSince: 2021,
|
||||
tags: "Levain naturel, Produits locaux, Fait maison, Sans conservateur",
|
||||
hours: "Mardi – Vendredi|7h – 19h30\nSamedi|7h – 19h\nDimanche|7h – 13h\nLundi|Fermé",
|
||||
description:
|
||||
"Boulangerie artisanale familiale installée au cœur de Frouard depuis trois générations. Nous travaillons chaque jour des farines locales et un levain naturel pour vous proposer pains, viennoiseries et pâtisseries faits maison.\nNotre équipe vous accueille du mardi au dimanche matin. Spécialités : pain au levain, baguette de tradition, kouglof et tartes aux fruits de saison.",
|
||||
status: "active" as const,
|
||||
highlighted: true,
|
||||
},
|
||||
{
|
||||
name: "Café des Arts",
|
||||
email: "hello@cafedesarts.fr",
|
||||
categoryId: catId("restauration"),
|
||||
city: "Pompey",
|
||||
address: "3 place Stanislas",
|
||||
postalCode: "54340",
|
||||
phone: "03 83 24 00 00",
|
||||
memberSince: 2019,
|
||||
tags: "Cuisine de saison, Brunch, Terrasse, Produits frais",
|
||||
hours: "Lundi – Vendredi|9h – 18h\nSamedi|9h – 19h\nDimanche|10h – 15h",
|
||||
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,
|
||||
});
|
||||
// ---- Données de démonstration (SEED_DEMO=true uniquement) ----
|
||||
//
|
||||
// Par défaut la base reste vide : seuls le référentiel des catégories et le
|
||||
// compte administrateur initial sont créés. Les adhérents, promotions,
|
||||
// demandes, entrées de journal et comptes de démonstration ne servent qu'à
|
||||
// un poste de développement.
|
||||
const demo = (process.env.SEED_DEMO ?? "").trim().toLowerCase() === "true";
|
||||
if (demo) {
|
||||
for (const m of demoMembers(catId)) {
|
||||
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;
|
||||
|
||||
for (const p of demoPromotions(memberId)) {
|
||||
const existing = await db.select().from(promotions).where(eq(promotions.title, p.title));
|
||||
if (existing.length === 0) await db.insert(promotions).values(p);
|
||||
}
|
||||
|
||||
for (const r of demoRequests) {
|
||||
const existing = await db.select().from(membershipRequests).where(eq(membershipRequests.name, r.name));
|
||||
if (existing.length === 0) await db.insert(membershipRequests).values(r);
|
||||
}
|
||||
|
||||
const existingActivity = await db.select().from(activityLog);
|
||||
if (existingActivity.length === 0) {
|
||||
for (const a of demoActivity) await db.insert(activityLog).values(a);
|
||||
}
|
||||
|
||||
for (const u of demoUsers(memberId)) {
|
||||
const existing = await db.select({ id: users.id }).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(DEMO_PASSWORD, 10),
|
||||
role: u.role,
|
||||
memberId: u.memberId,
|
||||
});
|
||||
}
|
||||
}
|
||||
console.log(` Données de démonstration insérées (SEED_DEMO=true) ; comptes de démo : mot de passe « ${DEMO_PASSWORD} ».`);
|
||||
}
|
||||
|
||||
// ---- Comptes de connexion ----
|
||||
//
|
||||
// Le seed tourne à chaque démarrage du conteneur (SEED_ON_START). Il ne doit
|
||||
// donc jamais créer en production de compte dont le mot de passe est connu
|
||||
// de tous : les comptes de démonstration sont réservés à SEED_DEMO=true, et
|
||||
// l'administrateur initial reçoit un mot de passe aléatoire (affiché une
|
||||
// seule fois ici) à changer à la première connexion, sauf si
|
||||
// SEED_ADMIN_PASSWORD est fourni explicitement.
|
||||
const adminEmail = (process.env.SEED_ADMIN_EMAIL ?? "admin@plein-r.fr").trim().toLowerCase();
|
||||
const adminName = process.env.SEED_ADMIN_NAME ?? "Administrateur Plein R";
|
||||
const providedAdminPassword = (process.env.SEED_ADMIN_PASSWORD ?? "").trim();
|
||||
|
||||
const [existingAdmin] = await db.select({ id: users.id }).from(users).where(eq(users.email, adminEmail));
|
||||
if (!existingAdmin) {
|
||||
const generated = !providedAdminPassword;
|
||||
const adminPassword = providedAdminPassword || randomPassword();
|
||||
await db.insert(users).values({
|
||||
email: adminEmail,
|
||||
name: adminName,
|
||||
passwordHash: await bcrypt.hash(adminPassword, 10),
|
||||
role: "admin",
|
||||
memberId: null,
|
||||
mustChangePassword: true,
|
||||
});
|
||||
if (generated) {
|
||||
console.log(" Compte administrateur créé. Mot de passe initial (affiché une seule fois) :");
|
||||
console.log(` ${adminEmail} / ${adminPassword}`);
|
||||
} else {
|
||||
console.log(` Compte administrateur créé : ${adminEmail} (mot de passe fourni par SEED_ADMIN_PASSWORD).`);
|
||||
}
|
||||
console.log(" Un changement de mot de passe sera exigé à la première connexion.");
|
||||
}
|
||||
|
||||
console.log("Seed complete.");
|
||||
console.log(` Admin login: ${adminEmail} / ${adminPassword}`);
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,62 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Chiffrement des secrets stockés en base (jetons OAuth, secrets d'application).
|
||||
*
|
||||
* AES-256-GCM : le tag d'authentification garantit qu'une valeur altérée en base
|
||||
* est rejetée au lieu d'être déchiffrée en silence.
|
||||
*
|
||||
* La clé dérive de `SOCIAL_TOKEN_KEY`, avec repli sur `AUTH_SECRET` pour ne rien
|
||||
* imposer aux déploiements existants. Conséquence : changer `AUTH_SECRET` sans
|
||||
* avoir posé `SOCIAL_TOKEN_KEY` rend les secrets illisibles — il suffit alors de
|
||||
* reconnecter les comptes, aucune donnée métier n'est perdue.
|
||||
*/
|
||||
|
||||
const PREFIX = "v1";
|
||||
const SALT = "pleinr.social.v1";
|
||||
|
||||
function secretMaterial(): string {
|
||||
const key = (process.env.SOCIAL_TOKEN_KEY ?? "").trim() || (process.env.AUTH_SECRET ?? "").trim();
|
||||
if (!key) {
|
||||
throw new Error(
|
||||
"Chiffrement indisponible : définissez SOCIAL_TOKEN_KEY (ou AUTH_SECRET) sur le serveur."
|
||||
);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function key(): Buffer {
|
||||
return scryptSync(secretMaterial(), SALT, 32);
|
||||
}
|
||||
|
||||
export function encryptSecret(plain: string): string {
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv("aes-256-gcm", key(), iv);
|
||||
const encrypted = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return [PREFIX, iv.toString("base64"), tag.toString("base64"), encrypted.toString("base64")].join(":");
|
||||
}
|
||||
|
||||
export function decryptSecret(stored: string): string {
|
||||
const parts = stored.split(":");
|
||||
if (parts.length !== 4 || parts[0] !== PREFIX) {
|
||||
throw new Error("Secret chiffré illisible (format inattendu).");
|
||||
}
|
||||
const [, iv, tag, payload] = parts;
|
||||
const decipher = createDecipheriv("aes-256-gcm", key(), Buffer.from(iv, "base64"));
|
||||
decipher.setAuthTag(Buffer.from(tag, "base64"));
|
||||
return Buffer.concat([
|
||||
decipher.update(Buffer.from(payload, "base64")),
|
||||
decipher.final(),
|
||||
]).toString("utf8");
|
||||
}
|
||||
|
||||
/** Déchiffre sans lever : une clé changée ne doit pas casser l'affichage. */
|
||||
export function tryDecryptSecret(stored: string | null): string | null {
|
||||
if (!stored) return null;
|
||||
try {
|
||||
return decryptSecret(stored);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
function bytesBase64(bytes: Uint8Array) {
|
||||
let binary = "";
|
||||
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
|
||||
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
|
||||
}
|
||||
return globalThis.btoa(binary);
|
||||
}
|
||||
|
||||
function utf8Base64(value: string) {
|
||||
return bytesBase64(new TextEncoder().encode(value));
|
||||
}
|
||||
|
||||
function wrapBase64(value: string) {
|
||||
return value.match(/.{1,76}/g)?.join("\r\n") ?? "";
|
||||
}
|
||||
|
||||
function slug(value: string) {
|
||||
return value
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^a-zA-Z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
.toLowerCase() || "email-plein-r";
|
||||
}
|
||||
|
||||
function emailBody(html: string) {
|
||||
return new DOMParser().parseFromString(html, "text/html").body.innerHTML.trim();
|
||||
}
|
||||
|
||||
export async function copyRichEmail(html: string, plainText: string) {
|
||||
const holder = document.createElement("div");
|
||||
holder.contentEditable = "true";
|
||||
holder.setAttribute("aria-hidden", "true");
|
||||
holder.style.cssText = "position:fixed;left:-10000px;top:0;width:700px;background:#F6F2E8;pointer-events:none;z-index:-1";
|
||||
holder.innerHTML = emailBody(html);
|
||||
document.body.appendChild(holder);
|
||||
const selection = window.getSelection();
|
||||
const range = document.createRange();
|
||||
try {
|
||||
holder.focus();
|
||||
range.selectNodeContents(holder);
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
if (!document.execCommand("copy")) throw new Error("Copie refusée");
|
||||
} catch (error) {
|
||||
if (!navigator.clipboard?.write || typeof ClipboardItem === "undefined") throw error;
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
"text/html": new Blob([emailBody(html)], { type: "text/html" }),
|
||||
"text/plain": new Blob([plainText], { type: "text/plain" }),
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
selection?.removeAllRanges();
|
||||
holder.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadOutlookDraft(subject: string, html: string, prefix = "email") {
|
||||
const logoUrl = html.match(/\bsrc=["']([^"']*\/assets\/logo\.png(?:\?[^"']*)?)["']/i)?.[1];
|
||||
let renderedHtml = html;
|
||||
let logoPart = "";
|
||||
const boundary = "----=_PleinR_Outlook_Draft";
|
||||
|
||||
if (logoUrl) {
|
||||
const response = await fetch(logoUrl, { cache: "force-cache" });
|
||||
if (!response.ok) throw new Error("Logo Plein R introuvable.");
|
||||
const base64 = bytesBase64(new Uint8Array(await response.arrayBuffer()));
|
||||
renderedHtml = html.replace(/(\bsrc\s*=\s*)(["'])[^"']*\/assets\/logo\.png(?:\?[^"']*)?\2/gi, "$1$2cid:plein-r-logo$2");
|
||||
logoPart = [`--${boundary}`, "Content-Type: image/png; name=\"logo.png\"", "Content-Transfer-Encoding: base64", "Content-ID: <plein-r-logo>", "Content-Disposition: inline; filename=\"logo.png\"", "", wrapBase64(base64)].join("\r\n");
|
||||
}
|
||||
|
||||
const mime = logoPart
|
||||
? ["X-Unsent: 1", `Subject: =?UTF-8?B?${utf8Base64(subject)}?=`, "MIME-Version: 1.0", `Content-Type: multipart/related; type=\"text/html\"; boundary=\"${boundary}\"`, "", `--${boundary}`, "Content-Type: text/html; charset=UTF-8", "Content-Transfer-Encoding: base64", "", wrapBase64(utf8Base64(renderedHtml)), logoPart, `--${boundary}--`, ""].join("\r\n")
|
||||
: ["X-Unsent: 1", `Subject: =?UTF-8?B?${utf8Base64(subject)}?=`, "MIME-Version: 1.0", "Content-Type: text/html; charset=UTF-8", "Content-Transfer-Encoding: base64", "", wrapBase64(utf8Base64(html)), ""].join("\r\n");
|
||||
downloadBlob(new Blob([mime], { type: "message/rfc822" }), `${prefix}-${slug(subject)}-outlook.eml`);
|
||||
}
|
||||
|
||||
export function downloadHtml(subject: string, html: string, prefix = "email") {
|
||||
downloadBlob(new Blob([html], { type: "text/html;charset=utf-8" }), `${prefix}-${slug(subject)}.html`);
|
||||
}
|
||||
|
||||
function downloadBlob(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 2000);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
export type EmailBrand = {
|
||||
associationName: string;
|
||||
address?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
siret?: string;
|
||||
};
|
||||
|
||||
export type GeneralEmailContent = {
|
||||
subject: string;
|
||||
kicker: string;
|
||||
title: string;
|
||||
body: string;
|
||||
buttonLabel: string;
|
||||
buttonUrl: string;
|
||||
signature: string;
|
||||
};
|
||||
|
||||
export type MeetingEmailTexts = {
|
||||
greeting: string;
|
||||
intro: string;
|
||||
outro: string;
|
||||
signature: string;
|
||||
};
|
||||
|
||||
export type MeetingEmailData = {
|
||||
id: number;
|
||||
title: string;
|
||||
startsAt: string;
|
||||
location: string | null;
|
||||
description: string | null;
|
||||
capacity: number;
|
||||
registered: number;
|
||||
};
|
||||
|
||||
function esc(value: unknown) {
|
||||
return String(value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function lines(value: unknown) {
|
||||
return esc(value).replace(/\r?\n/g, "<br>");
|
||||
}
|
||||
|
||||
function webUrl(value: string) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "http:" || url.protocol === "https:" ? url.href : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function paragraphs(value: string) {
|
||||
return value
|
||||
.split(/\r?\n\s*\r?\n/)
|
||||
.filter(Boolean)
|
||||
.map(
|
||||
(paragraph, index, all) =>
|
||||
`<tr><td style="padding-bottom:${index === all.length - 1 ? 0 : 18}px;font-family:Arial,Helvetica,sans-serif;font-size:15px;line-height:25px;color:#5d5447;">${lines(paragraph)}</td></tr>`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function footer(brand: EmailBrand) {
|
||||
const contact = [brand.email, brand.phone].filter(Boolean).map(esc).join(" · ");
|
||||
return `<tr><td bgcolor="#13324F" style="background:#13324F;padding:25px 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:20px;color:#C9D8E5;">
|
||||
<strong style="color:#FFFFFF;">${esc(brand.associationName)}</strong>${brand.address ? ` · ${lines(brand.address)}` : ""}<br>
|
||||
${contact}${brand.siret ? `${contact ? "<br>" : ""}SIRET : ${esc(brand.siret)}` : ""}
|
||||
</td></tr>`;
|
||||
}
|
||||
|
||||
function emailShell({ title, content, baseUrl, brand }: { title: string; content: string; baseUrl: string; brand: EmailBrand }) {
|
||||
const logoUrl = `${baseUrl.replace(/\/$/, "")}/assets/logo.png`;
|
||||
return `<!doctype html>
|
||||
<html lang="fr" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${esc(title)}</title>
|
||||
<!--[if mso]><xml><o:OfficeDocumentSettings><o:AllowPNG/><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml><![endif]-->
|
||||
</head>
|
||||
<body bgcolor="#F6F2E8" style="margin:0;padding:0;background:#F6F2E8;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#F6F2E8" style="width:100%;background:#F6F2E8;border-collapse:collapse;">
|
||||
<tr><td align="center" style="padding:36px 14px;">
|
||||
<!--[if mso]><table role="presentation" width="620" cellpadding="0" cellspacing="0" border="0"><tr><td><![endif]-->
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#FFFFFF" style="width:100%;max-width:620px;background:#FFFFFF;border:1px solid #E6DCC6;border-collapse:collapse;">
|
||||
<tr><td bgcolor="#13324F" style="background:#13324F;padding:22px 40px;border-bottom:5px solid #E0A63C;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr>
|
||||
<td width="64" valign="middle"><span style="display:block;background:#F6F2E8;padding:6px;border-radius:10px;"><img src="${esc(logoUrl)}" width="52" alt="${esc(brand.associationName)}" style="display:block;width:52px;height:auto;"></span></td>
|
||||
<td valign="middle" style="padding-left:17px;font-family:Arial,Helvetica,sans-serif;font-size:20px;font-weight:bold;color:#FFFFFF;">${esc(brand.associationName)}</td>
|
||||
</tr></table>
|
||||
</td></tr>
|
||||
${content}
|
||||
${footer(brand)}
|
||||
</table>
|
||||
<!--[if mso]></td></tr></table><![endif]-->
|
||||
</td></tr></table>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
function cta(url: string, label: string) {
|
||||
const safe = webUrl(url);
|
||||
if (!safe || !label) return "";
|
||||
return `<table role="presentation" width="300" cellpadding="0" cellspacing="0" border="0" style="width:300px;border-collapse:collapse;"><tr><td align="center" bgcolor="#2C6FB3" style="background:#2C6FB3;padding:16px 0;border-radius:8px;font-family:Arial,Helvetica,sans-serif;font-size:17px;font-weight:bold;"><a href="${esc(safe)}" target="_blank" style="display:block;color:#FFFFFF;text-decoration:none;">${esc(label)}</a></td></tr></table>`;
|
||||
}
|
||||
|
||||
export function buildGeneralEmail(content: GeneralEmailContent, baseUrl: string, brand: EmailBrand) {
|
||||
const button = cta(content.buttonUrl, content.buttonLabel);
|
||||
const body = `
|
||||
<tr><td style="padding:42px 40px 16px;">
|
||||
${content.kicker ? `<div style="padding-bottom:15px;font-family:Arial,Helvetica,sans-serif;font-size:11px;line-height:14px;letter-spacing:2.2px;text-transform:uppercase;color:#9A6638;font-weight:bold;">${esc(content.kicker)}</div>` : ""}
|
||||
<div style="padding-bottom:23px;font-family:Georgia,'Times New Roman',serif;font-size:30px;line-height:36px;color:#26201A;">${esc(content.title)}</div>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">${paragraphs(content.body)}</table>
|
||||
</td></tr>
|
||||
${button ? `<tr><td align="center" style="padding:22px 40px 8px;">${button}</td></tr>` : ""}
|
||||
<tr><td style="padding:25px 40px 40px;font-family:Georgia,'Times New Roman',serif;font-size:16px;line-height:25px;color:#26201A;">${lines(content.signature)}</td></tr>`;
|
||||
return { subject: content.subject, html: emailShell({ title: content.subject, content: body, baseUrl, brand }) };
|
||||
}
|
||||
|
||||
export function defaultMeetingEmailTexts(brand: EmailBrand): MeetingEmailTexts {
|
||||
return {
|
||||
greeting: "Chère adhérente, cher adhérent,",
|
||||
intro: "Plein R a le plaisir de vous convier à sa prochaine rencontre. Un moment pour créer des liens, partager les actualités locales et faire grandir notre réseau.",
|
||||
outro: "Les places sont limitées : pensez à confirmer votre présence dès maintenant.",
|
||||
signature: `À très bientôt,\nL'équipe de ${brand.associationName}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMeetingEmail(meeting: MeetingEmailData, texts: MeetingEmailTexts, baseUrl: string, brand: EmailBrand) {
|
||||
const date = new Date(meeting.startsAt);
|
||||
const dateLong = new Intl.DateTimeFormat("fr-FR", {
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(date);
|
||||
const remaining = Math.max(0, meeting.capacity - meeting.registered);
|
||||
const registrationUrl = `${baseUrl.replace(/\/$/, "")}/inscription/${meeting.id}`;
|
||||
const subject = `Invitation Plein R — ${meeting.title} · ${dateLong}`;
|
||||
const button = cta(registrationUrl, "Je m'inscris à la rencontre");
|
||||
const body = `
|
||||
<tr><td style="padding:42px 40px 18px;">
|
||||
<div style="padding-bottom:16px;font-family:Arial,Helvetica,sans-serif;font-size:11px;letter-spacing:2.2px;text-transform:uppercase;color:#9A6638;font-weight:bold;">Invitation · Rencontre Plein R</div>
|
||||
${texts.greeting ? `<div style="padding-bottom:15px;font-family:Georgia,'Times New Roman',serif;font-size:19px;line-height:25px;color:#26201A;">${lines(texts.greeting)}</div>` : ""}
|
||||
${texts.intro ? `<div style="font-family:Arial,Helvetica,sans-serif;font-size:15px;line-height:25px;color:#5D5447;">${lines(texts.intro)}</div>` : ""}
|
||||
</td></tr>
|
||||
<tr><td style="padding:8px 40px 0;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#FAF7EF" style="width:100%;background:#FAF7EF;border:1px solid #E6DCC6;border-left:5px solid #E0A63C;border-collapse:collapse;">
|
||||
<tr><td style="padding:27px 28px;">
|
||||
<div style="padding-bottom:11px;font-family:Georgia,'Times New Roman',serif;font-size:25px;line-height:31px;color:#26201A;">${esc(meeting.title)}</div>
|
||||
${meeting.description ? `<div style="padding-bottom:17px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:23px;color:#5D5447;">${lines(meeting.description)}</div>` : ""}
|
||||
<div style="border-top:1px solid #E6DCC6;padding-top:16px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:25px;color:#5D5447;">
|
||||
<strong style="color:#26201A;">${esc(dateLong.charAt(0).toUpperCase() + dateLong.slice(1))}</strong><br>
|
||||
${meeting.location ? `${esc(meeting.location)}<br>` : ""}
|
||||
${remaining > 0 ? `${remaining} place${remaining > 1 ? "s" : ""} restante${remaining > 1 ? "s" : ""}` : "Rencontre complète"}
|
||||
</div>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
<tr><td align="center" style="padding:28px 40px 12px;">${button}</td></tr>
|
||||
<tr><td style="padding:14px 40px 40px;">
|
||||
${texts.outro ? `<div style="padding-bottom:22px;font-family:Arial,Helvetica,sans-serif;font-size:15px;line-height:25px;color:#5D5447;">${lines(texts.outro)}</div>` : ""}
|
||||
${texts.signature ? `<div style="font-family:Georgia,'Times New Roman',serif;font-size:16px;line-height:25px;color:#26201A;">${lines(texts.signature)}</div>` : ""}
|
||||
</td></tr>
|
||||
<tr><td style="padding:0 40px 24px;font-family:Arial,Helvetica,sans-serif;font-size:11px;line-height:17px;color:#8C8068;">Si bouton bloqué, copiez ce lien :<br><a href="${esc(registrationUrl)}" style="color:#2C6FB3;word-break:break-all;">${esc(registrationUrl)}</a></td></tr>`;
|
||||
return { subject, html: emailShell({ title: subject, content: body, baseUrl, brand }), registrationUrl };
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Limitation des tentatives de connexion.
|
||||
*
|
||||
* L'application n'avait aucun frein : un mot de passe d'administrateur pouvait
|
||||
* être cherché par force brute aussi vite que le serveur répond. Le compteur
|
||||
* vit en mémoire du processus, ce qui suffit ici (un seul conteneur applicatif)
|
||||
* et se réinitialise au redémarrage.
|
||||
*/
|
||||
|
||||
const WINDOW_MS = 15 * 60 * 1000;
|
||||
const MAX_FAILURES = 8;
|
||||
|
||||
type Entry = { failures: number; firstAt: number; blockedUntil: number };
|
||||
|
||||
const attempts = new Map<string, Entry>();
|
||||
|
||||
function prune(now: number) {
|
||||
if (attempts.size < 500) return;
|
||||
for (const [key, entry] of attempts) {
|
||||
if (now - entry.firstAt > WINDOW_MS && now > entry.blockedUntil) attempts.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** true si la clé est actuellement bloquée. */
|
||||
export function isLoginBlocked(key: string): boolean {
|
||||
const entry = attempts.get(key);
|
||||
return !!entry && Date.now() < entry.blockedUntil;
|
||||
}
|
||||
|
||||
export function recordLoginFailure(key: string): void {
|
||||
const now = Date.now();
|
||||
prune(now);
|
||||
const entry = attempts.get(key);
|
||||
|
||||
if (!entry || now - entry.firstAt > WINDOW_MS) {
|
||||
attempts.set(key, { failures: 1, firstAt: now, blockedUntil: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
entry.failures += 1;
|
||||
if (entry.failures >= MAX_FAILURES) {
|
||||
// Blocage progressif : chaque échec au-delà du seuil éloigne la prochaine
|
||||
// tentative, sans jamais verrouiller définitivement un compte légitime.
|
||||
const extra = entry.failures - MAX_FAILURES;
|
||||
entry.blockedUntil = now + Math.min(WINDOW_MS, 60_000 * 2 ** Math.min(extra, 4));
|
||||
}
|
||||
}
|
||||
|
||||
export function clearLoginFailures(key: string): void {
|
||||
attempts.delete(key);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
export const WEEK_DAYS = [
|
||||
{ key: "monday", label: "Lundi", shortLabel: "Lun." },
|
||||
{ key: "tuesday", label: "Mardi", shortLabel: "Mar." },
|
||||
{ key: "wednesday", label: "Mercredi", shortLabel: "Mer." },
|
||||
{ key: "thursday", label: "Jeudi", shortLabel: "Jeu." },
|
||||
{ key: "friday", label: "Vendredi", shortLabel: "Ven." },
|
||||
{ key: "saturday", label: "Samedi", shortLabel: "Sam." },
|
||||
{ key: "sunday", label: "Dimanche", shortLabel: "Dim." },
|
||||
] as const;
|
||||
|
||||
export type WeekDayKey = (typeof WEEK_DAYS)[number]["key"];
|
||||
|
||||
export type HoursSlot = {
|
||||
start: string;
|
||||
end: string;
|
||||
};
|
||||
|
||||
export type DaySchedule = {
|
||||
key: WeekDayKey;
|
||||
label: string;
|
||||
closed: boolean;
|
||||
slots: HoursSlot[];
|
||||
};
|
||||
|
||||
function normalizeText(value: string) {
|
||||
return value
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/\./g, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function dayIndex(value: string) {
|
||||
const normalized = normalizeText(value);
|
||||
return WEEK_DAYS.findIndex(({ label, shortLabel }) => {
|
||||
const full = normalizeText(label);
|
||||
const short = normalizeText(shortLabel);
|
||||
return normalized === full || normalized === short || normalized.startsWith(full);
|
||||
});
|
||||
}
|
||||
|
||||
function parseTime(value: string) {
|
||||
const match = value.trim().match(/^(\d{1,2})(?:\s*(?:h|:)\s*(\d{2})?)?$/i);
|
||||
if (!match) return null;
|
||||
const hour = Number(match[1]);
|
||||
const minute = Number(match[2] ?? 0);
|
||||
if (hour > 23 || minute > 59) return null;
|
||||
return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function parseSlots(value: string): HoursSlot[] {
|
||||
return value
|
||||
.split(/\s*(?:\/|,|;|\bet\b)\s*/i)
|
||||
.map((part) => {
|
||||
const match = part.trim().match(
|
||||
/^(\d{1,2}(?:\s*(?:h|:)\s*\d{0,2})?)\s*(?:–|—|-|à)\s*(\d{1,2}(?:\s*(?:h|:)\s*\d{0,2})?)$/i
|
||||
);
|
||||
if (!match) return null;
|
||||
const start = parseTime(match[1]);
|
||||
const end = parseTime(match[2]);
|
||||
return start && end ? { start, end } : null;
|
||||
})
|
||||
.filter((slot): slot is HoursSlot => Boolean(slot));
|
||||
}
|
||||
|
||||
function indicesFromLabel(label: string) {
|
||||
const parts = label.split(/\s+(?:–|—|-|à)\s+/i);
|
||||
if (parts.length === 1) {
|
||||
const index = dayIndex(parts[0]);
|
||||
return index >= 0 ? [index] : [];
|
||||
}
|
||||
|
||||
const start = dayIndex(parts[0]);
|
||||
const end = dayIndex(parts[parts.length - 1]);
|
||||
if (start < 0 || end < 0) return [];
|
||||
|
||||
const indices: number[] = [];
|
||||
let current = start;
|
||||
for (let guard = 0; guard < 7; guard += 1) {
|
||||
indices.push(current);
|
||||
if (current === end) break;
|
||||
current = (current + 1) % 7;
|
||||
}
|
||||
return indices;
|
||||
}
|
||||
|
||||
export function parseMemberHours(value: string | null | undefined): DaySchedule[] {
|
||||
if (!value?.trim()) return [];
|
||||
|
||||
const schedules = new Map<number, DaySchedule>();
|
||||
for (const line of value.split("\n")) {
|
||||
const separator = line.indexOf("|");
|
||||
if (separator < 0) continue;
|
||||
|
||||
const label = line.slice(0, separator).trim();
|
||||
const range = line.slice(separator + 1).trim();
|
||||
const indices = indicesFromLabel(label);
|
||||
const closed = normalizeText(range).includes("ferm");
|
||||
const slots = closed ? [] : parseSlots(range);
|
||||
if (!closed && slots.length === 0) continue;
|
||||
|
||||
for (const index of indices) {
|
||||
const day = WEEK_DAYS[index];
|
||||
schedules.set(index, {
|
||||
key: day.key,
|
||||
label: day.label,
|
||||
closed,
|
||||
slots: slots.map((slot) => ({ ...slot })),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(schedules.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([, schedule]) => schedule);
|
||||
}
|
||||
|
||||
export function editorSchedule(value: string | null | undefined): DaySchedule[] {
|
||||
const parsed = new Map(parseMemberHours(value).map((day) => [day.key, day]));
|
||||
return WEEK_DAYS.map((day) => {
|
||||
const existing = parsed.get(day.key);
|
||||
return (
|
||||
existing ?? {
|
||||
key: day.key,
|
||||
label: day.label,
|
||||
closed: true,
|
||||
slots: [],
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function serializeMemberHours(days: DaySchedule[]) {
|
||||
return WEEK_DAYS.map((day) => {
|
||||
const schedule = days.find((item) => item.key === day.key);
|
||||
if (!schedule || schedule.closed || schedule.slots.length === 0) {
|
||||
return `${day.label}|Fermé`;
|
||||
}
|
||||
const slots = schedule.slots
|
||||
.filter((slot) => slot.start && slot.end)
|
||||
.map((slot) => `${slot.start} – ${slot.end}`)
|
||||
.join(" / ");
|
||||
return `${day.label}|${slots || "Fermé"}`;
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
export function formatHour(value: string) {
|
||||
const [hour, minute] = value.split(":");
|
||||
return `${Number(hour)}h${minute === "00" ? "" : minute}`;
|
||||
}
|
||||
|
||||
export function formatSlots(slots: HoursSlot[]) {
|
||||
return slots.map((slot) => `${formatHour(slot.start)} – ${formatHour(slot.end)}`).join(" / ");
|
||||
}
|
||||
|
||||
function minutesOf(value: string) {
|
||||
const [hour, minute] = value.split(":").map(Number);
|
||||
return hour * 60 + minute;
|
||||
}
|
||||
|
||||
function parisNow(date: Date) {
|
||||
const parts = new Intl.DateTimeFormat("en-GB", {
|
||||
timeZone: "Europe/Paris",
|
||||
weekday: "long",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
}).formatToParts(date);
|
||||
const weekday = parts.find((part) => part.type === "weekday")?.value.toLowerCase();
|
||||
const hour = Number(parts.find((part) => part.type === "hour")?.value ?? 0);
|
||||
const minute = Number(parts.find((part) => part.type === "minute")?.value ?? 0);
|
||||
const dayIndex = WEEK_DAYS.findIndex((day) => day.key === weekday);
|
||||
return { dayIndex: dayIndex < 0 ? 0 : dayIndex, minutes: hour * 60 + minute };
|
||||
}
|
||||
|
||||
export type OpenStatus = {
|
||||
isOpen: boolean;
|
||||
label: string;
|
||||
detail: string | null;
|
||||
todayKey: WeekDayKey;
|
||||
};
|
||||
|
||||
export function getOpenStatus(
|
||||
schedules: DaySchedule[],
|
||||
date = new Date()
|
||||
): OpenStatus | null {
|
||||
if (schedules.length === 0) return null;
|
||||
|
||||
const byKey = new Map(schedules.map((schedule) => [schedule.key, schedule]));
|
||||
const now = parisNow(date);
|
||||
const today = WEEK_DAYS[now.dayIndex];
|
||||
const todaySchedule = byKey.get(today.key);
|
||||
|
||||
for (const slot of todaySchedule?.slots ?? []) {
|
||||
const start = minutesOf(slot.start);
|
||||
const end = minutesOf(slot.end);
|
||||
if (now.minutes >= start && now.minutes < end) {
|
||||
return {
|
||||
isOpen: true,
|
||||
label: "Ouvert maintenant",
|
||||
detail: `Ferme à ${formatHour(slot.end)}`,
|
||||
todayKey: today.key,
|
||||
};
|
||||
}
|
||||
if (now.minutes < start) {
|
||||
return {
|
||||
isOpen: false,
|
||||
label: "Fermé actuellement",
|
||||
detail: `Ouvre à ${formatHour(slot.start)}`,
|
||||
todayKey: today.key,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
for (let offset = 1; offset <= 7; offset += 1) {
|
||||
const index = (now.dayIndex + offset) % 7;
|
||||
const nextDay = WEEK_DAYS[index];
|
||||
const nextSlot = byKey.get(nextDay.key)?.slots[0];
|
||||
if (!nextSlot) continue;
|
||||
return {
|
||||
isOpen: false,
|
||||
label: "Fermé actuellement",
|
||||
detail:
|
||||
offset === 1
|
||||
? `Ouvre demain à ${formatHour(nextSlot.start)}`
|
||||
: `Ouvre ${nextDay.label.toLowerCase()} à ${formatHour(nextSlot.start)}`,
|
||||
todayKey: today.key,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isOpen: false,
|
||||
label: "Fermé actuellement",
|
||||
detail: null,
|
||||
todayKey: today.key,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeWebsite(value: string | null | undefined) {
|
||||
const raw = value?.trim();
|
||||
if (!raw) return null;
|
||||
const candidate = /^[a-z][a-z\d+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`;
|
||||
try {
|
||||
const url = new URL(candidate);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
||||
const normalized = url.toString();
|
||||
return normalized.length <= 200 ? normalized : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function websiteLabel(value: string | null | undefined) {
|
||||
const normalized = normalizeWebsite(value);
|
||||
if (!normalized) return null;
|
||||
const url = new URL(normalized);
|
||||
const path = url.pathname === "/" ? "" : url.pathname.replace(/\/$/, "");
|
||||
return `${url.hostname.replace(/^www\./i, "")}${path}`;
|
||||
}
|
||||
+15
-1
@@ -18,8 +18,12 @@ type Capability =
|
||||
| "viewDashboard"
|
||||
| "manageMembers"
|
||||
| "moderatePromos"
|
||||
| "publishSocial"
|
||||
| "manageAdmins"
|
||||
| "manageCategories"
|
||||
| "manageMeetings"
|
||||
| "manageEmails"
|
||||
| "manageSettings"
|
||||
| "memberSpace";
|
||||
|
||||
const CAPABILITIES: Record<AppRole, Capability[]> = {
|
||||
@@ -27,11 +31,21 @@ const CAPABILITIES: Record<AppRole, Capability[]> = {
|
||||
"viewDashboard",
|
||||
"manageMembers",
|
||||
"moderatePromos",
|
||||
"publishSocial",
|
||||
"manageAdmins",
|
||||
"manageCategories",
|
||||
"manageMeetings",
|
||||
"manageEmails",
|
||||
"manageSettings",
|
||||
"memberSpace",
|
||||
],
|
||||
moderator: ["viewDashboard", "manageMembers", "moderatePromos"],
|
||||
moderator: [
|
||||
"viewDashboard",
|
||||
"manageMembers",
|
||||
"moderatePromos",
|
||||
"publishSocial",
|
||||
"manageMeetings",
|
||||
],
|
||||
editor: ["viewDashboard", "manageMembers"],
|
||||
member: ["memberSpace"],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { headers } from "next/headers";
|
||||
import { siteUrl } from "@/lib/social-accounts";
|
||||
|
||||
/**
|
||||
* URL publique du site pour le référencement (canoniques, sitemap, JSON-LD).
|
||||
*
|
||||
* Priorité au réglage `site_public_url` (Backend › Réseaux sociaux) puis aux
|
||||
* variables d'environnement, via `siteUrl()`. En dernier recours, l'URL est
|
||||
* déduite des en-têtes de la requête (reverse proxy compris) : le site reste
|
||||
* ainsi correctement référencé tant que le réglage n'est pas renseigné.
|
||||
*/
|
||||
export async function publicBaseUrl(): Promise<string> {
|
||||
const configured = await siteUrl().catch(() => "");
|
||||
if (configured) return configured;
|
||||
try {
|
||||
const h = await headers();
|
||||
const host = h.get("x-forwarded-host") ?? h.get("host") ?? "";
|
||||
if (!host) return "";
|
||||
const protocol = h.get("x-forwarded-proto") ?? (host.startsWith("localhost") ? "http" : "https");
|
||||
return `${protocol}://${host}`.replace(/\/+$/, "");
|
||||
} catch {
|
||||
// Hors requête (génération de robots/sitemap au build par exemple).
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** `metadataBase` pour Next : indéfini si aucune URL n'est connue. */
|
||||
export async function metadataBase(): Promise<URL | undefined> {
|
||||
const base = await publicBaseUrl();
|
||||
if (!base) return undefined;
|
||||
try {
|
||||
return new URL(base);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user