Author SHA1 Message Date
Claude 320e63b749 Caddy : répondre aussi sur l'IP, pour installer avant que le DNS ne soit posé
Le Caddyfile gagne un bloc fourre-tout http:// à côté du bloc du nom de
domaine. L'installation complète, Caddy compris, devient donc utilisable
immédiatement sur http://IP_DU_SERVEUR, et bascule seule en HTTPS dès que le
DNS pointe sur la machine : Caddy réessaie la demande de certificat en
arrière-plan, sans intervention ni redémarrage.

Le préfixe http:// désactive le HTTPS automatique pour ce seul bloc, aucune
autorité ne délivrant de certificat pour une IP nue. Le bloc du domaine reste
prioritaire et sa redirection HTTP vers HTTPS est insérée avant le fourre-tout.
Vérifié avec le binaire Caddy 2.8.4 : configuration valide, et à l'exécution un
308 vers https:// pour l'en-tête Host du domaine contre un passage au proxy
pour l'accès par IP.

Rend inutile la surcouche docker-compose.http.yml, retirée, la surcouche Caddy
couvrant désormais les deux situations. README et .env.example décrivent ce
parcours unique.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8UUiQtBWYWNFcsea8spnX
2026-09-05 15:26:40 +00:00
Claude 8414c7201e Installation VPS : surcouche HTTP port 80 pour valider avant le domaine
Ajoute docker-compose.http.yml, qui publie l'application sur le port 80 et
laisse Compose créer le réseau proxy. Permet de valider une installation sur
l'IP du serveur avant que le DNS ne soit en place, sans HTTPS possible à ce
stade (pas de certificat pour une IP nue).

Documente les deux phases dans le README et .env.example : HTTP simple d'abord,
puis bascule vers la surcouche Caddy en changeant la seule ligne COMPOSE_FILE,
les volumes conservant base, compte admin et secret de session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8UUiQtBWYWNFcsea8spnX
2026-09-05 15:21:31 +00:00
Claude 0562d07254 Installation VPS sans reverse proxy : Caddy + Let's Encrypt en surcouche Compose
Ajoute docker-compose.caddy.yml et un Caddyfile pour servir le site sur un
nom de domaine en 80/443 avec certificat automatique, sans Nginx Proxy
Manager. Le port 8413 n'est plus publié dans ce mode et le réseau proxy est
créé par Compose. Procédure documentée dans le README et .env.example.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8UUiQtBWYWNFcsea8spnX
2026-09-05 15:08:45 +00:00
Claude a8adb9ae6a Référencement : métadonnées complètes, données structurées, sitemap et robots
- Layout : metadataBase, gabarit de titre « %s · Plein R », description,
  mots-clés, Open Graph / Twitter, robots, icônes, manifeste, viewport et
  couleur de thème ; JSON-LD Organization + WebSite (recherche annuaire).
- Chaque page publique pose titre, description (≤ 160 caractères) et
  canonique via pageMetadata() ; l'annuaire ignore ainsi ?q=. Fiche adhérent :
  titre « Nom · Catégorie à Ville », description dérivée, LocalBusiness avec
  adresse, contacts et horaires (OpeningHoursSpecification), BreadcrumbList.
  Annuaire : ItemList des adhérents. Association : BusinessEvent par
  rencontre à venir. Rencontres passées : fil d'Ariane.
- Nouvelles routes : robots.txt, sitemap.xml (pages statiques + adhérents
  actifs), manifest.webmanifest, image de partage 1200×630 générée avec le
  logo, page 404 dédiée (noindex).
- /backend, /login, /inscription/* et les fiches inactives en noindex.
- Landmarks <main> sur toutes les pages publiques, fil d'Ariane en <nav>.
- serializeJsonLd() échappe <, > et & : les saisies des adhérents ne peuvent
  pas fermer le bloc <script>. Tests unitaires dans tests/seo.test.ts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDbKbJrPWweXXYSxA8MXW7
2026-09-05 10:41:53 +00:00
LogiFlow dd350038f0 Durcissement (lot A) : seed sûr et vide par défaut, mots de passe temporaires affichés une seule fois (#4)
Durcissement (lot A) : seed sûr et vide par défaut, mots de passe temporaires affichés une seule fois
2026-09-04 12:38:50 +02:00
Claude 741dd53b40 Base vide par défaut : données de démonstration isolées et purgeables
Le seed ne crée plus que le référentiel des catégories et le compte
administrateur initial. Les adhérents, promotions, demandes d'adhésion,
entrées du journal et comptes de démonstration sont déplacés dans
src/db/demo-data.ts et ne sont insérés qu'avec SEED_DEMO=true.

Nouveau script `npm run db:purge-demo` (bundlé en dist/purge-demo.cjs pour le
conteneur) : retire exactement ce jeu de démonstration d'une base qui l'a reçu
lors des démarrages précédents, sans toucher aux saisies de l'association ni
aux catégories. Idempotent, en transaction ; un compte réel rattaché à une
fiche de démo est détaché et non supprimé.

Vérifié en local sur Postgres 16 : seed par défaut = 59 catégories + 1 admin
et rien d'autre ; seed avec SEED_DEMO=true = jeu complet ; purge = retour à
l'état vide ; seconde purge sans effet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RAQsCp4nnZbwexCg7NDBHE
2026-09-04 09:23:44 +00:00
Claude 3b17d7ff42 Durcissement (lot A) : seed sûr en production, mots de passe temporaires affichés une seule fois
Audit de sécurité complet de l'application, puis correctifs du lot A validés :

- Seed : plus aucun compte de démonstration (mot de passe « changeme123 »)
  sans SEED_DEMO=true. L'administrateur initial reçoit un mot de passe
  aléatoire affiché une fois dans les journaux (ou SEED_ADMIN_PASSWORD) et
  doit le changer à la première connexion. docker-compose ne fournit plus de
  mot de passe par défaut.
- Mots de passe temporaires : la colonne users.temp_password (en clair) est
  supprimée (migration 0011). Création d'adhérent, réinitialisation,
  rattrapage des comptes manquants, approbation de demande et invitation
  staff renvoient les identifiants, affichés une seule fois par le composant
  OneTimeCredentials, sans redirection. L'invitation staff, qui ne
  communiquait jamais le mot de passe, redevient utilisable et impose le
  changement à la première connexion.
- Sessions JWT limitées à 7 jours (30 auparavant).
- En-têtes : Strict-Transport-Security ajouté, X-Powered-By supprimé.
- docker-compose : port Postgres publié sur 127.0.0.1 uniquement.
- Image Docker sur node:22 (Node 20 en fin de vie).
- Dépendances : next 15.5.25, pg 8.23, nanoid 3.3.18 (avis GHSA-2v37-7h3g-55p8).

Vérifié en local : tests, typage, build de production, migration + seed sur
un Postgres 16, et parcours navigateur complet (première connexion, changement
forcé, création / réinitialisation / invitation avec affichage unique, cookie
de session à 7 jours).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RAQsCp4nnZbwexCg7NDBHE
2026-09-04 09:15:45 +00:00
49 changed files with 3406 additions and 366 deletions

No files matched your search

+18 -4
View File
@@ -23,16 +23,30 @@ 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
# URL publique du site. Se règle normalement dans Backend › Réseaux sociaux ;
# cette variable ne sert que de repli (puis AUTH_URL en dernier recours).
NEXT_PUBLIC_SITE_URL=
# ---- 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
+34 -1
View File
@@ -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
```
@@ -97,8 +98,40 @@ site sans traitement supplémentaire.
- 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
+29
View File
@@ -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
View File
@@ -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 \
+51
View File
@@ -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 |
+50
View File
@@ -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
+10 -3
View File
@@ -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
@@ -61,7 +62,12 @@ services:
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
@@ -87,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}
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "users" DROP COLUMN "temp_password";
File diff suppressed because it is too large. Load diff
+7
View File
@@ -78,6 +78,13 @@
"when": 1784972681534,
"tag": "0010_woozy_luminals",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1788512273181,
"tag": "0011_drop_temp_password",
"breakpoints": true
}
]
}
+8
View File
@@ -2,6 +2,8 @@
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" },
@@ -11,6 +13,12 @@ const nextConfig = {
{
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).
+52 -52
View File
@@ -11,9 +11,9 @@
"bcryptjs": "^2.4.3",
"dotenv": "^16.4.7",
"drizzle-orm": "^0.45.2",
"next": "^15.5.21",
"next": "^15.5.25",
"next-auth": "^5.0.0-beta.32",
"pg": "^8.13.1",
"pg": "^8.23.0",
"react": "19.0.0",
"react-dom": "19.0.0",
"zod": "^3.24.1"
@@ -1044,15 +1044,15 @@
}
},
"node_modules/@next/env": {
"version": "15.5.21",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.21.tgz",
"integrity": "sha512-hjJI/GfrjWHgNguRIBzItjRRu0m3Nrz17GhxsjuHfjIvg9hyg3239REd2dpI+bpMTFuVrVprHzEQ19m++cDtbw==",
"version": "15.5.25",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.25.tgz",
"integrity": "sha512-42h1lLr07vl4gawALP1hsgRZjHB1xYa58JfUfHwr0f7jG/zhPakh5GHkADHXOC9ZxUvlQFOPIrp7s6qX4DezPQ==",
"license": "MIT"
},
"node_modules/@next/swc-darwin-arm64": {
"version": "15.5.21",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.21.tgz",
"integrity": "sha512-ZfjqPEdi6TRC/fWx7UDbwb1fbVgyh2uD5tVTRKIDZDlYM+UNuE/LafDG2fwuAoZilADpABh46OY/F5qf9JjqLQ==",
"version": "15.5.25",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.25.tgz",
"integrity": "sha512-w+RR0v/QuApnWEjRGm1z6gcObKwGMb5YPA7V3bzBEVSBpMFUXprer0tS27UxjUcEnqbhL7Zuzohej79B6rYmBg==",
"cpu": [
"arm64"
],
@@ -1066,9 +1066,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "15.5.21",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.21.tgz",
"integrity": "sha512-TlCf1NpxgQLzTrexuev75xwmNCJMd1/qkJpTVP1GRRcih93hlIBn1P72hkh8T0gnRFr6BmWksQtbyG3jT6jnww==",
"version": "15.5.25",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.25.tgz",
"integrity": "sha512-QiGGBUSakt8S1H4Lt9Ehsh6Ja87axiBnQQgysOObvCbI7iUfJnRGntF1P64S4/ijuHFnSB8KLsEddkY3nN26uw==",
"cpu": [
"x64"
],
@@ -1082,9 +1082,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "15.5.21",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.21.tgz",
"integrity": "sha512-LXRsq1p+HvHSi7ygwNcSEEcK0zuo5jS75ZlqFHtOH+LF7qntXAJVJxah+1Pi/GyBm7EpkwU7m4EgbvIKrMqm9A==",
"version": "15.5.25",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.25.tgz",
"integrity": "sha512-ehLos/66zo0d/mJCU5u96a/VDcr01aaUrX0o/i16UdInxz8qPTCDSxGtjk/Lps1sIr1RJFdiX3hxc0fxJo+cPA==",
"cpu": [
"arm64"
],
@@ -1098,9 +1098,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "15.5.21",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.21.tgz",
"integrity": "sha512-hyGixhFxpDKjqoev6l4KlcRBlt9AXWrGhDZwmwg49sMJM5tnKQPSi+SEj9+e5n+l/bthRGZUdh59GKIs6lQPRw==",
"version": "15.5.25",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.25.tgz",
"integrity": "sha512-ZVMrqLiJ7DiChgmbkQwFtdhAnUkSH/4p7tB29QY+giATb0Q/XGHNRSKAb/B8XGDHRUaA67NepOW5W8u3ZRJBAA==",
"cpu": [
"arm64"
],
@@ -1114,9 +1114,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "15.5.21",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.21.tgz",
"integrity": "sha512-qfE+YfOba6S2+13e8qn1/UozDVNZ2clBlrs8UtDoax4s8ediu6sq93z66OEHUYlb69Tffh5JTNkgtsAKiSuugg==",
"version": "15.5.25",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.25.tgz",
"integrity": "sha512-UOewtDGkTMJTiODrEdeLZ50yGb59xCZSriNpXkfPMxRRgwDkGc7i8mLWqV5076wEdb+Ca/XN7MhJyM3CupyNyQ==",
"cpu": [
"x64"
],
@@ -1130,9 +1130,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "15.5.21",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.21.tgz",
"integrity": "sha512-BXLGG+EvIwp/Rrgl6HY8sqvD6BOUOIRz8/naDbeLNX7mlA5H2XRcL6MW/0IGnJISfj5BA9gNhFyJj5yOoiIDJQ==",
"version": "15.5.25",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.25.tgz",
"integrity": "sha512-UBHwA8AhkCZgtRfU1aJpunuAJe/6gZv6jDESQe4p5MjTb5V0YEeJBWCdNqx15Vj3x+5jmauRfeMJSjfQj9HGFQ==",
"cpu": [
"x64"
],
@@ -1146,9 +1146,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "15.5.21",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.21.tgz",
"integrity": "sha512-tNGNOlT0Wn7E4IMsSnufjXN/l2L2/AGdLLpa2vzS89SYCBuihgLn3ngLsIrvndAnWo9nAkus+4gZHTI/Ijx9HA==",
"version": "15.5.25",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.25.tgz",
"integrity": "sha512-QcFcPRr16djk5IqK5+e8O80eZfgWzIvVBXfitIq0tQ/uc+eyfdoZ0NmKc0cnbIJyfVwREapKuG97YcxWA9gcpA==",
"cpu": [
"arm64"
],
@@ -1162,9 +1162,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "15.5.21",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.21.tgz",
"integrity": "sha512-DmIdWmC9p4rdNIiQqo8ap0+Cnj6kKtTZnuSCxoYydSc8sgpDgAg9wFhxplunak9imLV0pTvc5WVCOHwm5eHLtQ==",
"version": "15.5.25",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.25.tgz",
"integrity": "sha512-zREeykps3ndWr9egJgvJKqVkkDuaw6Zrrg23cYBos0ygydFkAWYU4+PaPVwXzP1eAYQJe53ShSK45iDM529BOg==",
"cpu": [
"x64"
],
@@ -1615,9 +1615,9 @@
"peer": true
},
"node_modules/nanoid": {
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"funding": [
{
"type": "github",
@@ -1633,12 +1633,12 @@
}
},
"node_modules/next": {
"version": "15.5.21",
"resolved": "https://registry.npmjs.org/next/-/next-15.5.21.tgz",
"integrity": "sha512-/TsdBtkWLhkl+NVL3Uqws2UphNd6IPzOtzSk1fHaf+0P7GQKLZDUytyhns/Ykbzdy9+YRjwG7ONvrHaaTDdFqQ==",
"version": "15.5.25",
"resolved": "https://registry.npmjs.org/next/-/next-15.5.25.tgz",
"integrity": "sha512-OMWNulIIqKM2ykvC2qMjIt0IoavB4UB2SCs4iXJ6z6847FvyH8jBmBWcvrF5iuhTu8Przh20Fo/aoszIdqx4PA==",
"license": "MIT",
"dependencies": {
"@next/env": "15.5.21",
"@next/env": "15.5.25",
"@swc/helpers": "0.5.15",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
@@ -1651,15 +1651,15 @@
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "15.5.21",
"@next/swc-darwin-x64": "15.5.21",
"@next/swc-linux-arm64-gnu": "15.5.21",
"@next/swc-linux-arm64-musl": "15.5.21",
"@next/swc-linux-x64-gnu": "15.5.21",
"@next/swc-linux-x64-musl": "15.5.21",
"@next/swc-win32-arm64-msvc": "15.5.21",
"@next/swc-win32-x64-msvc": "15.5.21",
"sharp": "^0.34.3"
"@next/swc-darwin-arm64": "15.5.25",
"@next/swc-darwin-x64": "15.5.25",
"@next/swc-linux-arm64-gnu": "15.5.25",
"@next/swc-linux-arm64-musl": "15.5.25",
"@next/swc-linux-x64-gnu": "15.5.25",
"@next/swc-linux-x64-musl": "15.5.25",
"@next/swc-win32-arm64-msvc": "15.5.25",
"@next/swc-win32-x64-msvc": "15.5.25",
"sharp": "^0.34.3 || ^0.35.4"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
@@ -1721,14 +1721,14 @@
}
},
"node_modules/pg": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
"version": "8.23.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
"integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.14.0",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.15.0",
"pg-protocol": "^1.16.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
@@ -1779,9 +1779,9 @@
}
},
"node_modules/pg-protocol": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
"integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
"license": "MIT"
},
"node_modules/pg-types": {
+4 -3
View File
@@ -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.45.2",
"next": "^15.5.21",
"next": "^15.5.25",
"next-auth": "^5.0.0-beta.32",
"pg": "^8.13.1",
"pg": "^8.23.0",
"react": "19.0.0",
"react-dom": "19.0.0",
"zod": "^3.24.1"
+46 -9
View File
@@ -6,7 +6,17 @@ 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,
@@ -86,15 +96,32 @@ function ContactAction({
);
}
/** 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({
@@ -109,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(" ")]
@@ -142,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" }}>
@@ -409,7 +446,7 @@ export default async function FicheAdherentPage({
)}
</aside>
</div>
</div>
</main>
<SiteFooter />
</div>
+23 -7
View File
@@ -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>
+29 -1
View File
@@ -1,8 +1,10 @@
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";
@@ -13,9 +15,26 @@ import {
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",
@@ -30,7 +49,7 @@ function formatTime(date: Date) {
}
export default async function AssociationPage() {
const [settings, session] = await Promise.all([getSiteSettings(), auth()]);
const [settings, session, baseUrl] = await Promise.all([getSiteSettings(), auth(), publicBaseUrl()]);
const now = new Date();
const memberId = session?.user.memberId ?? null;
@@ -108,6 +127,15 @@ export default async function AssociationPage() {
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 }}>
+40 -25
View File
@@ -405,7 +405,19 @@ export async function publishPromo(formData: FormData) {
}
// ---- Members CRUD ----
export async function addMember(formData: FormData): Promise<number | undefined> {
/**
* Identifiants d'un compte qui vient d'être créé ou réinitialisé.
*
* Le mot de passe temporaire n'est **jamais stocké** : il n'existe qu'en
* mémoire le temps de la réponse et n'est montré qu'une seule fois, à l'écran
* qui a déclenché l'action. Un export de la base ne peut donc plus révéler de
* mot de passe utilisable.
*/
export type IssuedCredentials = { email: string; tempPassword: string };
export type CreatedMemberAccount = IssuedCredentials & { memberId: number };
export async function addMember(formData: FormData): Promise<CreatedMemberAccount | undefined> {
const { role } = await requireRole();
if (!can(role, "manageMembers")) throw new Error("Accès refusé");
@@ -436,7 +448,6 @@ export async function addMember(formData: FormData): Promise<number | undefined>
role: "member",
memberId: newMember.id,
passwordHash: await bcrypt.hash(tempPassword, 10),
tempPassword,
mustChangePassword: true,
});
@@ -445,13 +456,14 @@ export async function addMember(formData: FormData): Promise<number | undefined>
revalidatePath("/backend/adherents");
revalidatePath("/backend");
revalidatePath("/");
return newMember.id;
return { memberId: newMember.id, email, tempPassword };
}
// Crée des comptes de connexion pour les adhérents existants qui n'en ont pas
// encore (ceux ajoutés avant l'arrivée des comptes adhérent). Chaque compte
// reçoit un mot de passe temporaire à changer à la première connexion.
export async function createMissingMemberAccounts() {
// reçoit un mot de passe temporaire à changer à la première connexion. Les
// identifiants sont renvoyés pour un affichage unique : rien n'est conservé.
export async function createMissingMemberAccounts(): Promise<(IssuedCredentials & { name: string })[]> {
const { role } = await requireRole();
if (!can(role, "manageMembers")) throw new Error("Accès refusé");
@@ -465,7 +477,7 @@ export async function createMissingMemberAccounts() {
const takenEmails = new Set(allUsers.map((u) => u.email.toLowerCase()));
const linkedMemberIds = new Set(allUsers.map((u) => u.memberId).filter((x): x is number => x != null));
let created = 0;
const created: (IssuedCredentials & { name: string })[] = [];
for (const m of allMembers) {
if (linkedMemberIds.has(m.id)) continue;
const email = (m.email ?? "").trim().toLowerCase();
@@ -478,27 +490,27 @@ export async function createMissingMemberAccounts() {
role: "member",
memberId: m.id,
passwordHash: await bcrypt.hash(tempPassword, 10),
tempPassword,
mustChangePassword: true,
});
takenEmails.add(email);
created++;
created.push({ name: m.name, email, tempPassword });
}
if (created > 0) {
await logActivity(`${created} compte(s) adhérent créé(s) pour les fiches existantes`, "#2C6FB3");
if (created.length > 0) {
await logActivity(`${created.length} compte(s) adhérent créé(s) pour les fiches existantes`, "#2C6FB3");
}
revalidatePath("/backend/adherents");
return created;
}
// Réinitialise le mot de passe d'un adhérent : nouveau mot de passe temporaire
// visible par l'admin jusqu'à la prochaine connexion de l'adhérent.
export async function resetMemberPassword(formData: FormData) {
// Réinitialise le mot de passe d'un adhérent : le nouveau mot de passe
// temporaire est renvoyé pour un affichage unique, puis oublié.
export async function resetMemberPassword(formData: FormData): Promise<IssuedCredentials | undefined> {
const { role } = await requireRole();
if (!can(role, "manageMembers")) throw new Error("Accès refusé");
const memberId = Number(formData.get("memberId"));
if (!memberId) return;
if (!memberId) return undefined;
const [u] = await db.select().from(users).where(eq(users.memberId, memberId));
if (!u) throw new Error("Aucun compte de connexion lié à cet adhérent.");
@@ -508,7 +520,6 @@ export async function resetMemberPassword(formData: FormData) {
.update(users)
.set({
passwordHash: await bcrypt.hash(tempPassword, 10),
tempPassword,
mustChangePassword: true,
// Coupe les sessions ouvertes avec l'ancien mot de passe.
sessionVersion: (u.sessionVersion ?? 0) + 1,
@@ -516,6 +527,7 @@ export async function resetMemberPassword(formData: FormData) {
.where(eq(users.id, u.id));
revalidatePath(`/backend/adherents/${memberId}`);
return { email: u.email, tempPassword };
}
export async function updateMember(formData: FormData) {
@@ -568,31 +580,35 @@ export async function deleteMember(formData: FormData) {
}
// ---- Admins ----
export async function inviteAdmin(formData: FormData) {
export async function inviteAdmin(formData: FormData): Promise<IssuedCredentials | undefined> {
const { role } = await requireRole();
if (!can(role, "manageAdmins")) throw new Error("Accès refusé");
const name = String(formData.get("name") ?? "").trim();
if (!name) return;
if (!name) return undefined;
const email = String(formData.get("email") ?? "").trim().toLowerCase();
if (!email) return;
if (!email) return undefined;
const roleLabel = String(formData.get("role") ?? "Administrateur");
const newRole: AppRole = LABEL_TO_ROLE[roleLabel] ?? "editor";
const existing = await db.select().from(users).where(eq(users.email, email));
if (existing.length > 0) return;
if (existing.length > 0) throw new Error("Un compte existe déjà avec cet e-mail.");
// Temporary password — the invitee resets it on first login (out of scope here).
// Mot de passe temporaire montré une seule fois à l'inviteur, à changer à
// la première connexion. Auparavant il n'était ni conservé ni affiché :
// l'invité ne pouvait pas se connecter.
const tempPassword = generateTempPassword();
await db.insert(users).values({
name,
email,
role: newRole,
passwordHash: await bcrypt.hash(tempPassword, 10),
mustChangePassword: true,
});
await logActivity(`<strong>${name}</strong> a été invité comme ${roleLabel}`, "#2C6FB3");
revalidatePath("/backend/administrateurs");
return { email, tempPassword };
}
export async function removeAdmin(formData: FormData) {
@@ -1056,7 +1072,6 @@ export async function changeOwnPassword(formData: FormData) {
.update(users)
.set({
passwordHash: await bcrypt.hash(password, 10),
tempPassword: null,
mustChangePassword: false,
// Invalide toutes les autres sessions ouvertes sur ce compte.
sessionVersion: (user!.sessionVersion ?? 0) + 1,
@@ -1104,8 +1119,9 @@ export async function updateOwnProfile(formData: FormData) {
}
// Approuve une demande d'adhésion ET crée directement l'adhérent + son compte
// de connexion (mot de passe temporaire). Renvoie l'id du nouvel adhérent.
export async function approveMembershipRequest(formData: FormData): Promise<number | undefined> {
// de connexion. Renvoie l'id du nouvel adhérent et ses identifiants, à
// afficher une seule fois.
export async function approveMembershipRequest(formData: FormData): Promise<CreatedMemberAccount | undefined> {
const { role } = await requireRole();
if (!can(role, "manageMembers")) throw new Error("Accès refusé");
@@ -1137,7 +1153,6 @@ export async function approveMembershipRequest(formData: FormData): Promise<numb
role: "member",
memberId: newMember.id,
passwordHash: await bcrypt.hash(tempPassword, 10),
tempPassword,
mustChangePassword: true,
});
@@ -1148,7 +1163,7 @@ export async function approveMembershipRequest(formData: FormData): Promise<numb
revalidatePath("/backend/adherents");
revalidatePath("/backend");
revalidatePath("/");
return newMember.id;
return { memberId: newMember.id, email, tempPassword };
}
// ---- Inbox: membership requests + contact messages ----
+18 -5
View File
@@ -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&apos;affiche
sur la fiche de l&apos;adhérent jusqu&apos;à sa première connexion.
une seule fois, juste après l&apos;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&apos;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>
);
}
+7 -22
View File
@@ -8,7 +8,8 @@ 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";
@@ -35,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));
@@ -53,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 }}>
@@ -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&apos;adhérent devra changer son mot de passe temporaire.
<div style={{ fontSize: 12, marginTop: 4 }}>
Communiquez-le à l&apos;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&apos;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" }}>
+2 -24
View File
@@ -5,8 +5,8 @@ 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";
@@ -59,31 +59,9 @@ export default async function AdherentsPage({
return !!e && !takenEmails.has(e);
}).length;
async function handleBackfill() {
"use server";
await createMissingMemberAccounts();
redirect("/backend/adherents");
}
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&apos;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} />
@@ -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&apos;invité devra le
changer à sa première connexion.
</p>
</div>
);
}
+3 -27
View File
@@ -4,7 +4,8 @@ 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";
@@ -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&apos;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&apos;invité pourra le réinitialiser à la première
connexion.
</p>
<InviteAdminForm />
</div>
</div>
);
@@ -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>
);
}
+3 -13
View File
@@ -4,7 +4,8 @@ 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";
@@ -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 &amp; 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" />
+8
View File
@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { desc, eq } from "drizzle-orm";
import { getSession } from "@/lib/session";
@@ -7,9 +8,16 @@ 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,
}: {
+6 -4
View File
@@ -1,11 +1,13 @@
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 = {
title: "Confidentialité — Plein R",
description: "Politique de confidentialité et traitement des données personnelles par Plein R.",
};
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";
+8
View File
@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import Link from "next/link";
import { and, asc, eq, gte, sql } from "drizzle-orm";
import { getSession } from "@/lib/session";
@@ -6,9 +7,16 @@ 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",
+80 -8
View File
@@ -1,4 +1,18 @@
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";
// Rendu dynamique pour toutes les pages : la CSP à nonce du middleware ne peut
@@ -6,17 +20,73 @@ import "./globals.css";
// interrogent de toute façon la base.
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Plein R — Commerçants & entreprises du Bassin de Pompey",
description:
"Association des commerçants et entreprises du Bassin de Pompey. Réseau · Rencontre · Réussite.",
export 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>
@@ -30,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>
);
}
+12
View File
@@ -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;
}
+17
View File
@@ -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" }],
};
}
+6 -4
View File
@@ -1,11 +1,13 @@
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 = {
title: "Mentions légales — Plein R",
description: "Mentions légales du site de l'association Plein R.",
};
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";
+48
View File
@@ -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&apos;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&apos;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&apos;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&apos;accueil
</Link>
</div>
</main>
<SiteFooter />
</div>
);
}
+88
View File
@@ -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&nbsp;
<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 }
);
}
+15 -2
View File
@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Sparkle } from "@/components/Sparkle";
import { Reveal } from "@/components/Reveal";
@@ -10,9 +11,21 @@ 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)";
@@ -40,7 +53,7 @@ export default async function AccueilPage() {
>
<SiteHeader active="accueil" />
<div className="container">
<main className="container">
{/* hero */}
<section
className="grid hero-grid"
@@ -456,7 +469,7 @@ export default async function AccueilPage() {
/>
</Reveal>
</section>
</div>
</main>
<SiteFooter />
</div>
+21 -1
View File
@@ -1,21 +1,34 @@
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] = await Promise.all([
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
@@ -37,6 +50,13 @@ export default async function PastMeetingsPage() {
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 }}>
+20
View File
@@ -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,
};
}
+40
View File
@@ -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];
}
+4
View File
@@ -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: {
+27
View File
@@ -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) }}
/>
))}
</>
);
}
+49
View File
@@ -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&apos;est affiché qu&apos;une seule fois et n&apos;est conservé nulle part.
Un changement de mot de passe sera exigé à la première connexion.
</div>
</div>
);
}
+137
View File
@@ -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);
}
+110
View File
@@ -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);
});
+2 -1
View File
@@ -80,8 +80,9 @@ 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),
+92 -147
View File
@@ -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();
}
+36
View File
@@ -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;
}
}
+391
View File
@@ -0,0 +1,391 @@
import type { Metadata } from "next";
import type { DaySchedule, WeekDayKey } from "@/lib/member-profile";
import type { SiteSettings, SocialLink } from "@/lib/site-settings";
/**
* Référencement : constantes, métadonnées et données structurées (JSON-LD).
*
* Tout ce module est **pur** : aucune lecture en base, aucun accès aux en-têtes
* de requête. Les pages lui passent l'URL publique obtenue via
* `publicBaseUrl()` (src/lib/seo-server.ts). Cela permet de verrouiller le
* comportement par des tests unitaires (tests/seo.test.ts).
*/
export const SITE_NAME = "Plein R";
export const SITE_TITLE = "Plein R — Commerçants & entreprises du Bassin de Pompey";
export const SITE_DESCRIPTION =
"Plein R, association des commerçants, artisans et entreprises du Bassin de Pompey : " +
"annuaire des professionnels, bons plans et rencontres près de chez vous.";
export const SITE_LOCALE = "fr_FR";
export const THEME_COLOR = "#E0A63C";
/** Mots-clés de la thématique : commerce de proximité sur le Bassin de Pompey. */
export const SITE_KEYWORDS = [
"Plein R",
"Bassin de Pompey",
"commerçants Bassin de Pompey",
"association de commerçants",
"entreprises Pompey",
"artisans Pompey",
"commerces Frouard",
"commerces Liverdun",
"commerces Champigneulles",
"commerces Custines",
"annuaire des professionnels",
"bons plans",
"promotions locales",
"commerce de proximité",
"Meurthe-et-Moselle",
"Lorraine",
];
/** Longueur cible d'une meta description : Google tronque vers 155–160 caractères. */
export const DESCRIPTION_MAX_LENGTH = 155;
/** Pages transactionnelles ou privées : jamais indexées. */
export const NOINDEX: NonNullable<Metadata["robots"]> = {
index: false,
follow: false,
nocache: true,
googleBot: { index: false, follow: false, noimageindex: true },
};
/** Directives d'indexation généreuses pour les pages publiques. */
export const INDEX_FOLLOW: NonNullable<Metadata["robots"]> = {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-image-preview": "large",
"max-snippet": -1,
"max-video-preview": -1,
},
};
/** Chemins exclus des moteurs (robots.txt + sitemap). */
export const PRIVATE_PATHS = ["/backend", "/api", "/login", "/inscription"];
/** Pages publiques statiques, avec leur priorité relative dans le sitemap. */
export const STATIC_ROUTES: { path: string; priority: number; changeFrequency: "daily" | "weekly" | "monthly" | "yearly" }[] = [
{ path: "/", priority: 1, changeFrequency: "daily" },
{ path: "/annuaire", priority: 0.9, changeFrequency: "daily" },
{ path: "/association", priority: 0.8, changeFrequency: "weekly" },
{ path: "/rencontres-passees", priority: 0.6, changeFrequency: "monthly" },
{ path: "/mentions-legales", priority: 0.2, changeFrequency: "yearly" },
{ path: "/confidentialite", priority: 0.2, changeFrequency: "yearly" },
];
/** Construit une URL absolue à partir de l'URL publique et d'un chemin. */
export function absoluteUrl(baseUrl: string, path = "/"): string {
const base = baseUrl.replace(/\/+$/, "");
if (!path || path === "/") return `${base}/`;
return `${base}${path.startsWith("/") ? path : `/${path}`}`;
}
/** Chemin canonique d'une fiche adhérent. */
export function memberPath(id: number): string {
return `/adherents/${id}`;
}
/**
* Ramène un texte libre à une meta description propre : espaces normalisés,
* coupée sur un mot entier avec une ellipse si elle dépasse la longueur cible.
*/
export function metaDescription(text: string | null | undefined, fallback = SITE_DESCRIPTION): string {
const clean = (text ?? "").replace(/\s+/g, " ").trim();
if (!clean) return metaDescription(fallback, "");
if (clean.length <= DESCRIPTION_MAX_LENGTH) return clean;
const cut = clean.slice(0, DESCRIPTION_MAX_LENGTH - 1);
const lastSpace = cut.lastIndexOf(" ");
return `${(lastSpace > 60 ? cut.slice(0, lastSpace) : cut).replace(/[\s,;:.!?–—-]+$/, "")}…`;
}
/**
* Sérialise un objet JSON-LD pour l'insérer dans un `<script>`.
*
* Les données viennent en partie des adhérents (descriptions, noms) : on échappe
* `<`, `>` et `&` en séquences Unicode pour qu'aucune saisie ne puisse fermer la
* balise `</script>` et injecter du HTML. JSON reste strictement valide.
*/
export function serializeJsonLd(data: unknown): string {
return JSON.stringify(data)
.replace(/</g, "\\u003c")
.replace(/>/g, "\\u003e")
.replace(/&/g, "\\u0026")
.replace(/\u2028/g, "\\u2028")
.replace(/\u2029/g, "\\u2029");
}
/** Retire les clés dont la valeur est vide : JSON-LD n'aime pas les `null`. */
export function compact<T extends Record<string, unknown>>(obj: T): Partial<T> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
if (value === null || value === undefined || value === "") continue;
if (Array.isArray(value) && value.length === 0) continue;
out[key] = value;
}
return out as Partial<T>;
}
// ---------------------------------------------------------------------------
// Données structurées
// ---------------------------------------------------------------------------
export function organizationId(baseUrl: string): string {
return `${absoluteUrl(baseUrl)}#organization`;
}
export function webSiteId(baseUrl: string): string {
return `${absoluteUrl(baseUrl)}#website`;
}
/** L'association elle-même (utilisée sur toutes les pages via le layout). */
export function organizationJsonLd(baseUrl: string, settings: SiteSettings, socials: SocialLink[]) {
const address = settings.association_address.trim();
return compact({
"@context": "https://schema.org",
"@type": "Organization",
"@id": organizationId(baseUrl),
name: settings.association_name || SITE_NAME,
alternateName: "Association Plein R — Bassin de Pompey",
legalName: settings.association_name ? `Association ${settings.association_name}` : undefined,
url: absoluteUrl(baseUrl),
logo: {
"@type": "ImageObject",
url: absoluteUrl(baseUrl, "/assets/logo.png"),
},
image: absoluteUrl(baseUrl, "/opengraph-image"),
description: metaDescription(settings.association_intro || settings.association_tagline),
slogan: "Réseau · Rencontre · Réussite",
email: settings.association_email.trim() || undefined,
telephone: settings.association_phone.trim() || undefined,
address: address
? { "@type": "PostalAddress", streetAddress: address, addressCountry: "FR" }
: undefined,
areaServed: {
"@type": "AdministrativeArea",
name: "Bassin de Pompey, Meurthe-et-Moselle, France",
},
sameAs: socials.map((s) => s.href),
});
}
/** Le site et sa fonction de recherche (annuaire), pour la « sitelinks searchbox ». */
export function webSiteJsonLd(baseUrl: string) {
return {
"@context": "https://schema.org",
"@type": "WebSite",
"@id": webSiteId(baseUrl),
name: SITE_NAME,
alternateName: SITE_TITLE,
url: absoluteUrl(baseUrl),
inLanguage: "fr-FR",
publisher: { "@id": organizationId(baseUrl) },
potentialAction: {
"@type": "SearchAction",
target: {
"@type": "EntryPoint",
urlTemplate: `${absoluteUrl(baseUrl, "/annuaire")}?q={search_term_string}`,
},
"query-input": "required name=search_term_string",
},
};
}
export type BreadcrumbItem = { name: string; path: string };
/** Fil d'Ariane : améliore l'affichage du chemin dans les résultats. */
export function breadcrumbJsonLd(baseUrl: string, items: BreadcrumbItem[]) {
return {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: items.map((item, index) => ({
"@type": "ListItem",
position: index + 1,
name: item.name,
item: absoluteUrl(baseUrl, item.path),
})),
};
}
const SCHEMA_DAYS: Record<WeekDayKey, string> = {
monday: "https://schema.org/Monday",
tuesday: "https://schema.org/Tuesday",
wednesday: "https://schema.org/Wednesday",
thursday: "https://schema.org/Thursday",
friday: "https://schema.org/Friday",
saturday: "https://schema.org/Saturday",
sunday: "https://schema.org/Sunday",
};
/** Horaires d'un adhérent au format `OpeningHoursSpecification`. */
export function openingHoursJsonLd(hours: DaySchedule[]) {
return hours
.filter((day) => !day.closed && day.slots.length > 0)
.flatMap((day) =>
day.slots.map((slot) => ({
"@type": "OpeningHoursSpecification",
dayOfWeek: SCHEMA_DAYS[day.key],
opens: slot.start,
closes: slot.end,
}))
);
}
export type LocalBusinessInput = {
id: number;
name: string;
description: string | null;
address: string | null;
postalCode: string | null;
city: string | null;
phone: string | null;
email: string | null;
website: string | null;
categoryLabel: string | null;
tags: string | null;
};
/** Une fiche adhérent : commerce ou entreprise locale. */
export function localBusinessJsonLd(
baseUrl: string,
member: LocalBusinessInput,
hours: DaySchedule[],
website: string | null
) {
const hasAddress = Boolean(member.address || member.postalCode || member.city);
const keywords = (member.tags ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const url = absoluteUrl(baseUrl, memberPath(member.id));
return compact({
"@context": "https://schema.org",
"@type": "LocalBusiness",
"@id": `${url}#business`,
name: member.name,
description: member.description ? metaDescription(member.description, "") : undefined,
url: website ?? url,
mainEntityOfPage: url,
telephone: member.phone ?? undefined,
email: member.email ?? undefined,
address: hasAddress
? compact({
"@type": "PostalAddress",
streetAddress: member.address ?? undefined,
postalCode: member.postalCode ?? undefined,
addressLocality: member.city ?? undefined,
addressRegion: "Grand Est",
addressCountry: "FR",
})
: undefined,
openingHoursSpecification: openingHoursJsonLd(hours),
keywords: keywords.length > 0 ? keywords.join(", ") : undefined,
additionalType: member.categoryLabel ?? undefined,
memberOf: { "@id": organizationId(baseUrl) },
});
}
export type EventInput = {
id: number;
title: string;
startsAt: Date;
location: string | null;
description: string | null;
capacity: number;
registered: number;
};
/** Une rencontre à venir de l'association. */
export function eventJsonLd(baseUrl: string, meeting: EventInput) {
const remaining = Math.max(0, meeting.capacity - Number(meeting.registered));
return compact({
"@context": "https://schema.org",
"@type": "BusinessEvent",
name: meeting.title,
description: meeting.description ? metaDescription(meeting.description, "") : undefined,
startDate: meeting.startsAt.toISOString(),
eventStatus: "https://schema.org/EventScheduled",
eventAttendanceMode: "https://schema.org/OfflineEventAttendanceMode",
location: meeting.location
? { "@type": "Place", name: meeting.location, address: meeting.location }
: undefined,
organizer: { "@id": organizationId(baseUrl) },
url: absoluteUrl(baseUrl, "/association"),
maximumAttendeeCapacity: meeting.capacity,
remainingAttendeeCapacity: remaining,
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "EUR",
availability: remaining > 0 ? "https://schema.org/InStock" : "https://schema.org/SoldOut",
url: absoluteUrl(baseUrl, `/inscription/${meeting.id}`),
},
});
}
export type ItemListEntry = { id: number; name: string; description?: string | null };
/** Liste d'adhérents (annuaire) : chaque entrée pointe vers sa fiche. */
export function memberListJsonLd(baseUrl: string, name: string, members: ItemListEntry[]) {
return {
"@context": "https://schema.org",
"@type": "ItemList",
name,
numberOfItems: members.length,
itemListElement: members.map((m, index) => ({
"@type": "ListItem",
position: index + 1,
url: absoluteUrl(baseUrl, memberPath(m.id)),
name: m.name,
})),
};
}
/**
* Image de partage par défaut (générée par app/opengraph-image.tsx). Une page
* qui définit son propre `openGraph` remplace celui du layout en bloc : il faut
* donc redonner l'image explicitement, sinon elle disparaît des partages.
*/
export const SHARE_IMAGE = {
url: "/opengraph-image",
width: 1200,
height: 630,
alt: SITE_TITLE,
type: "image/png",
};
/** Métadonnées Open Graph / Twitter communes, complétées par chaque page. */
export function pageMetadata(input: {
title: string;
/** Titre complet pour Open Graph quand le titre de page est déjà « absolu ». */
ogTitle?: string;
description: string;
path: string;
robots?: Metadata["robots"];
ogType?: "website" | "profile" | "article";
}): Metadata {
const ogTitle = input.ogTitle ?? `${input.title} · ${SITE_NAME}`;
return {
title: input.title,
description: input.description,
alternates: { canonical: input.path },
openGraph: {
title: ogTitle,
description: input.description,
url: input.path,
type: input.ogType ?? "website",
locale: SITE_LOCALE,
siteName: SITE_NAME,
images: [SHARE_IMAGE],
},
twitter: {
card: "summary_large_image",
title: ogTitle,
description: input.description,
images: [SHARE_IMAGE],
},
robots: input.robots ?? INDEX_FOLLOW,
};
}
+174
View File
@@ -0,0 +1,174 @@
import { strict as assert } from "node:assert";
import { describe, it } from "node:test";
import {
DESCRIPTION_MAX_LENGTH,
NOINDEX,
PRIVATE_PATHS,
STATIC_ROUTES,
absoluteUrl,
breadcrumbJsonLd,
eventJsonLd,
localBusinessJsonLd,
memberListJsonLd,
metaDescription,
openingHoursJsonLd,
organizationJsonLd,
pageMetadata,
serializeJsonLd,
webSiteJsonLd,
} from "../src/lib/seo";
import { parseMemberHours } from "../src/lib/member-profile";
import { SITE_SETTING_DEFAULTS, socialLinks } from "../src/lib/site-settings";
const BASE = "https://pleinr.example.org";
describe("SEO — URLs et descriptions", () => {
it("construit des URLs absolues propres", () => {
assert.equal(absoluteUrl(BASE), `${BASE}/`);
assert.equal(absoluteUrl(`${BASE}//`, "/annuaire"), `${BASE}/annuaire`);
assert.equal(absoluteUrl(BASE, "adherents/3"), `${BASE}/adherents/3`);
});
it("borne la meta description sur un mot entier", () => {
const long = "Boulangerie artisanale ".repeat(20);
const out = metaDescription(long);
assert.ok(out.length <= DESCRIPTION_MAX_LENGTH, `trop long : ${out.length}`);
assert.ok(out.endsWith("…"));
assert.ok(!/ …$/.test(out), "pas d'espace avant l'ellipse");
assert.equal(metaDescription(" Deux lignes\n\nici "), "Deux lignes ici");
assert.equal(metaDescription(null, "repli"), "repli");
});
});
describe("SEO — JSON-LD sûr", () => {
// Les descriptions viennent des adhérents : un `</script>` ne doit jamais
// pouvoir sortir du bloc de données structurées.
it("échappe toute tentative de fermeture de balise", () => {
const out = serializeJsonLd({ description: `</script><script>alert(1)</script> & <img>` });
assert.ok(!out.includes("<"), out);
assert.ok(!out.includes(">"), out);
assert.ok(!out.includes("&"), out);
// Le JSON reste valide et fidèle une fois relu.
assert.equal(JSON.parse(out).description, `</script><script>alert(1)</script> & <img>`);
});
});
describe("SEO — données structurées", () => {
const settings = {
...SITE_SETTING_DEFAULTS,
association_email: "contact@pleinr.fr",
association_address: "1 place de la Mairie, 54340 Pompey",
};
it("décrit l'association avec ses réseaux et son logo", () => {
const org = organizationJsonLd(BASE, settings, socialLinks(settings)) as Record<string, unknown>;
assert.equal(org["@type"], "Organization");
assert.equal(org.url, `${BASE}/`);
assert.equal(org.email, "contact@pleinr.fr");
assert.deepEqual(org.sameAs, [settings.association_facebook, settings.association_linkedin]);
assert.equal((org.logo as { url: string }).url, `${BASE}/assets/logo.png`);
assert.ok(!("telephone" in org), "les champs vides sont retirés");
});
it("expose la recherche de l'annuaire au moteur", () => {
const site = webSiteJsonLd(BASE);
assert.equal(
site.potentialAction.target.urlTemplate,
`${BASE}/annuaire?q={search_term_string}`
);
assert.equal(site.publisher["@id"], `${BASE}/#organization`);
});
it("numérote le fil d'Ariane", () => {
const crumbs = breadcrumbJsonLd(BASE, [
{ name: "Accueil", path: "/" },
{ name: "Annuaire", path: "/annuaire" },
]);
assert.deepEqual(
crumbs.itemListElement.map((i) => [i.position, i.item]),
[[1, `${BASE}/`], [2, `${BASE}/annuaire`]]
);
});
it("traduit les horaires en OpeningHoursSpecification", () => {
const hours = parseMemberHours("Lundi – Vendredi|9h-12h / 14h-18h30\nSamedi|9h-12h\nDimanche|Fermé");
const spec = openingHoursJsonLd(hours);
const monday = spec.filter((s) => s.dayOfWeek === "https://schema.org/Monday");
assert.equal(monday.length, 2);
assert.deepEqual(monday.map((s) => [s.opens, s.closes]), [["09:00", "12:00"], ["14:00", "18:30"]]);
assert.ok(spec.every((s) => s.dayOfWeek !== "https://schema.org/Sunday"));
});
it("décrit une fiche adhérent en LocalBusiness", () => {
const member = {
id: 7,
name: "Au Bon Pain",
description: "Boulangerie artisanale.",
address: "12 rue du Four",
postalCode: "54340",
city: "Pompey",
phone: "03 83 00 00 00",
email: null,
website: null,
categoryLabel: "Boulangerie",
tags: "pain, viennoiseries",
};
const biz = localBusinessJsonLd(BASE, member, [], null) as Record<string, unknown>;
assert.equal(biz["@type"], "LocalBusiness");
assert.equal(biz.url, `${BASE}/adherents/7`);
assert.equal((biz.address as { addressLocality: string }).addressLocality, "Pompey");
assert.equal(biz.keywords, "pain, viennoiseries");
assert.ok(!("email" in biz));
assert.ok(!("openingHoursSpecification" in biz), "pas d'horaires vides");
const withSite = localBusinessJsonLd(BASE, member, [], "https://aubonpain.fr") as Record<string, unknown>;
assert.equal(withSite.url, "https://aubonpain.fr");
assert.equal(withSite.mainEntityOfPage, `${BASE}/adherents/7`);
});
it("décrit une rencontre à venir avec ses places restantes", () => {
const event = eventJsonLd(BASE, {
id: 3,
title: "Afterwork de rentrée",
startsAt: new Date("2026-09-24T17:30:00Z"),
location: "Salle des fêtes, Frouard",
description: null,
capacity: 30,
registered: 30,
}) as Record<string, unknown>;
assert.equal(event.startDate, "2026-09-24T17:30:00.000Z");
assert.equal(event.remainingAttendeeCapacity, 0);
assert.equal((event.offers as { availability: string }).availability, "https://schema.org/SoldOut");
assert.equal((event.offers as { url: string }).url, `${BASE}/inscription/3`);
});
it("liste les adhérents de l'annuaire", () => {
const list = memberListJsonLd(BASE, "Annuaire", [{ id: 1, name: "A" }, { id: 2, name: "B" }]);
assert.equal(list.numberOfItems, 2);
assert.equal(list.itemListElement[1].url, `${BASE}/adherents/2`);
});
});
describe("SEO — indexation", () => {
it("exclut l'espace privé et les pages transactionnelles", () => {
for (const path of ["/backend", "/api", "/login", "/inscription"]) {
assert.ok(PRIVATE_PATHS.includes(path), `${path} doit être exclu des robots`);
}
assert.ok(STATIC_ROUTES.every((r) => !PRIVATE_PATHS.some((p) => r.path.startsWith(p))));
assert.equal((NOINDEX as { index: boolean }).index, false);
});
it("pose canonique, Open Graph et Twitter sur chaque page", () => {
const meta = pageMetadata({ title: "Annuaire", description: "Desc", path: "/annuaire" });
assert.equal(meta.alternates?.canonical, "/annuaire");
assert.equal((meta.openGraph as { url: string }).url, "/annuaire");
assert.equal((meta.openGraph as { locale: string }).locale, "fr_FR");
assert.equal((meta.openGraph as { title: string }).title, "Annuaire · Plein R");
assert.equal((meta.twitter as { card: string }).card, "summary_large_image");
// L'image de partage doit survivre au remplacement du bloc openGraph du layout.
assert.equal((meta.openGraph as { images: { url: string }[] }).images[0].url, "/opengraph-image");
assert.equal((meta.twitter as { images: { url: string }[] }).images[0].url, "/opengraph-image");
assert.equal((meta.robots as { index: boolean }).index, true);
});
});