Author SHA1 Message Date
Claude 86e7e28020 Demandes d'adhésion : actions selon le statut, double approbation impossible
Une demande approuvée gardait ses boutons « Approuver & créer » et « Rejeter »,
alors que l'adhérent et son compte existaient déjà. La ligne n'affiche plus
que le lien vers la fiche créée (retrouvée par l'e-mail) ; une demande
nouvelle garde ses deux actions, une demande rejetée peut encore être
approuvée.

Côté serveur, approveMembershipRequest refuse une demande déjà approuvée et
setRequestStatus ne réécrit plus le statut d'une demande approuvée : un
double clic ou un onglet resté ouvert ne peut plus créer un second compte.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDbKbJrPWweXXYSxA8MXW7
2026-09-06 09:22:25 +00:00
Claude 23d1d14a49 Espace adhérent en deux pages, e-mail de contact, logo sans débordement, types de produits pour les promos
- Espace adhérent : /backend/espace (profil, inscriptions, droit à l'image)
  et /backend/espace/promotions (dépôt et suivi). Bandeau et onglets communs
  (EspaceHeader) avec le nombre d'offres en ligne ; la page promotions ouvre
  sur « Mes promotions en cours », puis le formulaire, puis les offres en
  attente et l'historique. Deux entrées dans le menu, titres de page dédiés,
  revalidation des deux chemins.
- Fiche adhérent : colonne members.contact_email (migration 0013), saisie
  dans l'espace adhérent et la fiche staff ; la fiche publique et le JSON-LD
  affichent contact_email, sinon l'e-mail du compte.
- VitrineImage : sans photo de couverture, le logo est contenu à ~60 % d'un
  cadre à hauteur fixe sur un fond du logo flouté ; le conteneur sans hauteur
  qui laissait le logo déborder est supprimé.
- Promotions : la catégorie devient un type de produit ou de service
  (src/lib/promo-categories.ts, 10 groupes, ~85 entrées) pré-sélectionné
  depuis le métier de l'adhérent, à la place de la liste des métiers.
- Tests : catégories de promo (largeur, unicité, pré-sélection pour chaque
  métier du référentiel).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDbKbJrPWweXXYSxA8MXW7
2026-09-06 09:02:27 +00:00
Claude 8b46cd6316 Merge remote-tracking branch 'origin/main' into claude/seo-improvement-eilfsl 2026-09-06 06:56:00 +00:00
Claude bfc82b3b3b Tags adhérents automatiques : vocabulaire par métier, suggestions et repli
- src/lib/tags.ts (pur) : mots-clés par métier pour les 104 catégories,
  vocabulaire transversal détecté dans la description (fait maison, bio,
  livraison, sur rendez-vous, devis gratuit…), suggestTags() qui combine
  métier, commune et description, autoTags() qui n'intervient qu'à vide.
- Enregistrement : addMember, updateMember et updateOwnProfile passent par
  resolveMemberTags() ; un champ laissé vide reçoit les suggestions, une
  saisie n'est jamais remplacée.
- Formulaires : composant TagsField (champ non contrôlé + pastilles de
  suggestions recalculées à chaque frappe depuis catégorie, commune et
  description ; « Tout ajouter »), dans l'espace adhérent et la fiche staff.
- Fiche publique : les fiches jamais ré-enregistrées affichent les tags
  suggérés et les transmettent dans LocalBusiness.keywords.
- tests/tags.test.ts : couverture de chaque métier, nettoyage, détection,
  ordre des suggestions, non-écrasement.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDbKbJrPWweXXYSxA8MXW7
2026-09-06 06:51:25 +00:00
Claude c8417d8560 SEO : URLs de fiche parlantes, pages métier indexables, aides de rédaction
- Fiches adhérents : URL canonique /adherents/12-au-bon-pain-frouard
  (memberPath). L'identifiant en tête suffit ; /adherents/12 et tout slug
  périmé sont redirigés de façon permanente vers la forme canonique, donc
  les anciens liens et les partages sociaux restent valides. Tous les liens
  (accueil, carrousel, annuaire, JSON-LD, sitemap, publications Facebook /
  LinkedIn) passent par memberPath.
- Pages métier /annuaire/[categorie] : une page indexable par catégorie
  avec titre, introduction (effectif, communes), grille d'adhérents, fil
  d'Ariane et ItemList. Sans adhérent : noindex et hors sitemap. Section
  « Parcourir par métier » sur l'annuaire et « Autres métiers » sur chaque
  page pour le maillage interne.
- Composants partagés MemberCard et CategoryLinks ; slugify déplacé dans
  src/lib/slug.ts.
- Backend : conseils de rédaction sous les champs description et tags des
  formulaires adhérent (espace adhérent et fiche staff).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDbKbJrPWweXXYSxA8MXW7
2026-09-06 06:29:28 +00:00
Claude f9d0d6c825 Accueil, pied de page et catégories : compteur réel, boutons alignés, référentiel complété
- Accueil : le bandeau « Faites partie de l'aventure » affiche le nombre
  d'adhérents validés (statut active) au lieu de « plus de 120 » codé en dur.
- Pied de page : les boutons Facebook / LinkedIn ont la même largeur (grille à
  une colonne dimensionnée sur le plus long) et une icône à largeur fixe.
- Catégories : référentiel unique dans src/db/categories.ts (104 métiers, dont
  assurance, bazar/discount, grande distribution, librairie-presse, tabac,
  vétérinaire, auto-école, etc.), inséré par la migration 0012 avec
  ON CONFLICT (slug) : les catégories manquantes apparaissent au prochain
  démarrage, sans écraser un libellé renommé depuis le backend. Le seed et
  l'action « Ajouter une catégorie » réutilisent ce référentiel et sa palette.
- tests/categories.test.ts vérifie unicité des slugs, contraintes de la table
  et cohérence entre le référentiel et la migration.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDbKbJrPWweXXYSxA8MXW7
2026-09-06 06:18:37 +00:00
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
59 changed files with 6284 additions and 520 deletions

No files matched your search

+8 -3
View File
@@ -39,9 +39,14 @@ SEED_DEMO=false
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
+59
View File
@@ -41,6 +41,12 @@ docker compose up --build # full stack
- **Data reads** for the public site live in `src/lib/queries.ts`.
- After changing `src/db/schema.ts`, run `npm run db:generate` and commit the new
file under `drizzle/`.
- **Catégories** : le référentiel vit dans `src/db/categories.ts` (slugs stables,
jamais renommés). Il est inséré par la migration `0012_referentiel_categories`
(`ON CONFLICT (slug)` : seul `sort` est réaligné, un libellé renommé depuis le
backend est conservé) et rejoué par le seed. Pour ajouter une catégorie, on
l'ajoute au référentiel **et** on génère une nouvelle migration `--custom`
du même modèle ; `tests/categories.test.ts` vérifie la cohérence.
## Promotions
@@ -114,6 +120,59 @@ site sans traitement supplémentaire.
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>`.
- **Tags adhérents** : `src/lib/tags.ts` (pur) porte un vocabulaire par métier
(`CATEGORY_TAGS`, un par slug du référentiel, vérifié par `tests/tags.test.ts`)
et un vocabulaire transversal détecté dans la description. `autoTags()` ne
remplit le champ qu'à vide : à l'enregistrement (`resolveMemberTags` dans
`backend/actions.ts`) et à l'affichage de la fiche publique. Le composant
`TagsField` propose les suggestions en pastilles cliquables dans les deux
formulaires. Un nouveau métier dans `categories.ts` exige son entrée dans
`CATEGORY_TAGS`.
- **URLs de fiche** : `memberPath({ id, name, city })` donne
`/adherents/12-au-bon-pain-frouard`. L'identifiant en tête suffit
(`parseMemberParam`), toute autre écriture est redirigée en 301 vers la forme
canonique par la page : ne construisez jamais `/adherents/${id}` à la main.
- **Pages métier** : `/annuaire/[categorie]` (slug de `categories`) rend une page
indexable par activité (titre, intro, grille `MemberCard`, `ItemList`) ;
sans adhérent elle passe en `NOINDEX` et sort du sitemap. L'annuaire et ces
pages listent les métiers via `CategoryLinks` pour le maillage interne.
## Adhérents
- L'espace adhérent est en deux pages : `/backend/espace` (profil, inscriptions,
droit à l'image) et `/backend/espace/promotions` (dépôt et suivi des promos,
les offres **en ligne** en tête). `EspaceHeader` porte le bandeau et les
onglets ; toute action qui touche l'espace revalide les deux chemins.
- La catégorie d'une promotion est un **type de produit ou de service**
(`src/lib/promo-categories.ts`, groupes pour `<optgroup>`), pas le métier de
l'adhérent ; `defaultPromoCategory(slug)` pré-sélectionne depuis le métier.
- `members.email` est l'e-mail **administratif** (identifiant de connexion à la
création, échanges avec l'association) ; `members.contact_email` est l'e-mail
**public** de la fiche, saisi par l'adhérent ou le staff. La fiche et le
JSON-LD affichent `contact_email || email` : ne montrez jamais `email` seul.
- `VitrineImage` rend la couverture en `cover`, sinon le logo en `contain`
(~60 % d'un cadre à hauteur fixe) sur un fond du logo flouté, sinon un
placeholder rayé. Ne pas l'entourer d'un conteneur sans hauteur : les
`max-height` en % ne seraient plus résolus et le logo déborderait.
## 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
}
+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
+1
View File
@@ -93,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}
+109
View File
@@ -0,0 +1,109 @@
-- Référentiel des catégories (source : src/db/categories.ts).
-- Insère les catégories manquantes ; pour celles qui existent déjà, seul l'ordre
-- d'affichage est réaligné : un libellé renommé depuis le backend est conservé.
INSERT INTO "categories" ("slug", "label", "accent", "tint", "sort") VALUES
('alimentation', 'Alimentation / Épicerie', '#E0A63C', '#f6efdc', 1),
('epicerie-fine', 'Épicerie fine – Produits du terroir', '#6FB0C6', '#e7f0f3', 2),
('supermarche', 'Supermarché – Grande distribution', '#9a6638', '#f4ebda', 3),
('boulangerie', 'Boulangerie – Pâtisserie', '#2C6FB3', '#eaf0f6', 4),
('boucherie-charcuterie', 'Boucherie – Charcuterie', '#5a7a5a', '#eef0ec', 5),
('poissonnerie', 'Poissonnerie', '#7a6f9c', '#efe9f3', 6),
('primeur', 'Primeur – Fruits & Légumes', '#c98a2e', '#f7efe0', 7),
('fromagerie', 'Fromagerie – Crèmerie', '#3f8aa3', '#e6eff2', 8),
('cave', 'Cave – Vins & Spiritueux', '#E0A63C', '#f6efdc', 9),
('brasserie-artisanale', 'Brasserie artisanale – Boissons', '#6FB0C6', '#e7f0f3', 10),
('chocolaterie', 'Chocolaterie – Confiserie', '#9a6638', '#f4ebda', 11),
('glacier-salon-de-the', 'Glacier – Salon de thé', '#2C6FB3', '#eaf0f6', 12),
('traiteur', 'Traiteur', '#5a7a5a', '#eef0ec', 13),
('restauration', 'Restauration', '#7a6f9c', '#efe9f3', 14),
('pizzeria', 'Pizzeria', '#c98a2e', '#f7efe0', 15),
('restauration-rapide', 'Restauration rapide – Food truck', '#3f8aa3', '#e6eff2', 16),
('cafe-bar', 'Café – Bar – Brasserie', '#E0A63C', '#f6efdc', 17),
('mode-beaute', 'Mode & Beauté', '#6FB0C6', '#e7f0f3', 18),
('mode-vetements', 'Mode & Vêtements', '#9a6638', '#f4ebda', 19),
('lingerie', 'Lingerie', '#2C6FB3', '#eaf0f6', 20),
('vetements-enfants', 'Vêtements enfants', '#5a7a5a', '#eef0ec', 21),
('chaussures-maroquinerie', 'Chaussures – Maroquinerie', '#7a6f9c', '#efe9f3', 22),
('bijouterie', 'Bijouterie – Horlogerie', '#c98a2e', '#f7efe0', 23),
('coiffure', 'Coiffure', '#3f8aa3', '#e6eff2', 24),
('barbier', 'Barbier', '#E0A63C', '#f6efdc', 25),
('esthetique', 'Esthétique – Institut', '#6FB0C6', '#e7f0f3', 26),
('onglerie', 'Onglerie – Prothésie ongulaire', '#9a6638', '#f4ebda', 27),
('parfumerie', 'Parfumerie', '#2C6FB3', '#eaf0f6', 28),
('bien-etre', 'Bien-être – Spa – Massage', '#5a7a5a', '#eef0ec', 29),
('optique', 'Optique', '#7a6f9c', '#efe9f3', 30),
('tatouage', 'Tatouage – Piercing', '#c98a2e', '#f7efe0', 31),
('bazar-discount', 'Bazar – Discount – Équipement de la maison', '#3f8aa3', '#e6eff2', 32),
('decoration', 'Décoration – Ameublement', '#E0A63C', '#f6efdc', 33),
('cuisiniste', 'Cuisines – Salles de bain', '#6FB0C6', '#e7f0f3', 34),
('literie', 'Literie', '#9a6638', '#f4ebda', 35),
('arts-de-la-table', 'Arts de la table – Cadeaux', '#2C6FB3', '#eaf0f6', 36),
('electromenager-multimedia', 'Électroménager – Multimédia', '#5a7a5a', '#eef0ec', 37),
('fleuriste', 'Fleuriste', '#7a6f9c', '#efe9f3', 38),
('jardinerie', 'Jardinerie – Motoculture', '#c98a2e', '#f7efe0', 39),
('bricolage-jardinage', 'Bricolage – Jardinage', '#3f8aa3', '#e6eff2', 40),
('animalerie', 'Animalerie – Services animaliers', '#E0A63C', '#f6efdc', 41),
('artisanat', 'Artisanat', '#6FB0C6', '#e7f0f3', 42),
('batiment', 'Bâtiment – Rénovation', '#9a6638', '#f4ebda', 43),
('architecte', 'Architecte – Maître d''œuvre', '#2C6FB3', '#eaf0f6', 44),
('maconnerie', 'Maçonnerie – Gros œuvre', '#5a7a5a', '#eef0ec', 45),
('couverture-toiture', 'Couverture – Toiture – Zinguerie', '#7a6f9c', '#efe9f3', 46),
('plomberie', 'Plomberie – Chauffage', '#c98a2e', '#f7efe0', 47),
('electricite', 'Électricité', '#3f8aa3', '#e6eff2', 48),
('isolation-energie', 'Isolation – Énergies renouvelables', '#E0A63C', '#f6efdc', 49),
('menuiserie', 'Menuiserie – Ébénisterie', '#6FB0C6', '#e7f0f3', 50),
('serrurerie-metallerie', 'Serrurerie – Métallerie', '#9a6638', '#f4ebda', 51),
('carrelage-sols', 'Carrelage – Revêtements de sols', '#2C6FB3', '#eaf0f6', 52),
('peinture', 'Peinture – Décoration intérieure', '#5a7a5a', '#eef0ec', 53),
('paysagiste', 'Paysagiste – Espaces verts', '#7a6f9c', '#efe9f3', 54),
('depannage-multiservices', 'Dépannage – Multiservices', '#c98a2e', '#f7efe0', 55),
('automobile', 'Automobile – Garage', '#3f8aa3', '#e6eff2', 56),
('carrosserie', 'Carrosserie', '#E0A63C', '#f6efdc', 57),
('controle-technique', 'Contrôle technique', '#6FB0C6', '#e7f0f3', 58),
('station-service-lavage', 'Station-service – Lavage auto', '#9a6638', '#f4ebda', 59),
('location-vehicules', 'Location de véhicules', '#2C6FB3', '#eaf0f6', 60),
('cycles-motos', 'Cycles – Motos', '#5a7a5a', '#eef0ec', 61),
('auto-ecole', 'Auto-école', '#7a6f9c', '#efe9f3', 62),
('taxi-vtc', 'Taxi – VTC', '#c98a2e', '#f7efe0', 63),
('sante', 'Santé – Pharmacie', '#3f8aa3', '#e6eff2', 64),
('medical', 'Médical – Paramédical', '#E0A63C', '#f6efdc', 65),
('audioprothese', 'Audioprothèse', '#6FB0C6', '#e7f0f3', 66),
('veterinaire', 'Vétérinaire', '#9a6638', '#f4ebda', 67),
('banque-assurance', 'Banque – Assurance', '#2C6FB3', '#eaf0f6', 68),
('assurance', 'Assurance – Mutuelle – Prévoyance', '#5a7a5a', '#eef0ec', 69),
('courtier-credit', 'Courtier – Crédit – Financement', '#7a6f9c', '#efe9f3', 70),
('immobilier', 'Immobilier', '#c98a2e', '#f7efe0', 71),
('diagnostic-immobilier', 'Diagnostic immobilier – Expertise', '#3f8aa3', '#e6eff2', 72),
('services', 'Services', '#E0A63C', '#f6efdc', 73),
('services-a-la-personne', 'Services à la personne – Aide à domicile', '#6FB0C6', '#e7f0f3', 74),
('pressing-retouches', 'Pressing – Retouches – Cordonnerie', '#9a6638', '#f4ebda', 75),
('photographe', 'Photographe – Vidéo', '#2C6FB3', '#eaf0f6', 76),
('pompes-funebres', 'Pompes funèbres', '#5a7a5a', '#eef0ec', 77),
('agence-voyages', 'Agence de voyages', '#7a6f9c', '#efe9f3', 78),
('demenagement', 'Déménagement – Garde-meubles', '#c98a2e', '#f7efe0', 79),
('comptabilite', 'Comptabilité – Gestion', '#3f8aa3', '#e6eff2', 80),
('juridique', 'Juridique – Notaire', '#E0A63C', '#f6efdc', 81),
('conseil', 'Conseil aux entreprises', '#6FB0C6', '#e7f0f3', 82),
('communication', 'Communication – Web – Marketing', '#9a6638', '#f4ebda', 83),
('imprimerie-enseignes', 'Imprimerie – Enseignes – Signalétique', '#2C6FB3', '#eaf0f6', 84),
('informatique', 'Informatique – Téléphonie', '#5a7a5a', '#eef0ec', 85),
('nettoyage', 'Nettoyage – Entretien', '#7a6f9c', '#efe9f3', 86),
('transport', 'Transport – Logistique', '#c98a2e', '#f7efe0', 87),
('securite', 'Sécurité', '#3f8aa3', '#e6eff2', 88),
('coworking', 'Coworking – Bureaux', '#E0A63C', '#f6efdc', 89),
('interim-rh', 'Intérim – Ressources humaines', '#6FB0C6', '#e7f0f3', 90),
('recyclage-dechets', 'Recyclage – Gestion des déchets', '#9a6638', '#f4ebda', 91),
('librairie-papeterie', 'Librairie – Papeterie – Presse', '#2C6FB3', '#eaf0f6', 92),
('tabac-presse', 'Tabac – Presse – Loto', '#5a7a5a', '#eef0ec', 93),
('jouets-puericulture', 'Jouets – Puériculture', '#7a6f9c', '#efe9f3', 94),
('sport-loisirs', 'Sport – Loisirs', '#c98a2e', '#f7efe0', 95),
('articles-de-sport', 'Articles de sport', '#3f8aa3', '#e6eff2', 96),
('fitness', 'Salle de sport – Fitness', '#E0A63C', '#f6efdc', 97),
('culture-arts', 'Culture – Arts', '#6FB0C6', '#e7f0f3', 98),
('education-formation', 'Éducation – Formation', '#9a6638', '#f4ebda', 99),
('tourisme-hotellerie', 'Tourisme – Hôtellerie', '#2C6FB3', '#eaf0f6', 100),
('evenementiel', 'Événementiel', '#5a7a5a', '#eef0ec', 101),
('industrie', 'Industrie – Production', '#7a6f9c', '#efe9f3', 102),
('agriculture', 'Agriculture – Producteur local', '#c98a2e', '#f7efe0', 103),
('partenaire-institutionnel', 'Partenaire institutionnel – Collectivité', '#3f8aa3', '#e6eff2', 104)
ON CONFLICT ("slug") DO UPDATE SET "sort" = EXCLUDED."sort";
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "members" ADD COLUMN "contact_email" varchar(200);
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+14
View File
@@ -85,6 +85,20 @@
"when": 1788512273181,
"tag": "0011_drop_temp_password",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1788675288708,
"tag": "0012_referentiel_categories",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1788680191881,
"tag": "0013_contact_email",
"breakpoints": true
}
]
}
+75 -20
View File
@@ -1,12 +1,24 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { notFound, permanentRedirect } from "next/navigation";
import type { Metadata } from "next";
import { Sparkle } from "@/components/Sparkle";
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,
parseMemberParam,
} from "@/lib/seo";
import { publicBaseUrl } from "@/lib/seo-server";
import { autoTags, formatTags, parseTags } from "@/lib/tags";
import {
formatSlots,
getOpenStatus,
@@ -86,15 +98,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 = parseMemberParam(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),
ogType: "profile",
});
}
export default async function FicheAdherentPage({
@@ -103,13 +132,18 @@ export default async function FicheAdherentPage({
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const memberId = Number(id);
const memberId = parseMemberParam(id);
if (!memberId) notFound();
const member = await getPublicMember(memberId);
if (!member || member.status !== "active") notFound();
const promos = await getMemberLivePromotions(memberId);
// Une seule URL par fiche : l'ancienne forme `/adherents/12` et tout slug
// périmé (renommage, changement de commune) sont redirigés définitivement.
const canonical = memberPath(member);
if (`/adherents/${id}` !== canonical) permanentRedirect(canonical);
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(" ")]
@@ -119,11 +153,22 @@ export default async function FicheAdherentPage({
[member.address, member.postalCode, member.city].filter(Boolean).join(", ")
);
const paragraphs = (member.description ?? "").split("\n").map((s) => s.trim()).filter(Boolean);
const tags = (member.tags ?? "").split(",").map((s) => s.trim()).filter(Boolean);
// Fiche jamais ré-enregistrée depuis l'arrivée des tags automatiques : on
// affiche les suggestions du métier plutôt qu'une rubrique vide.
const tags = parseTags(
autoTags(member.tags, {
categorySlug: member.categorySlug,
categoryLabel: member.categoryLabel,
city: member.city,
description: member.description,
})
);
const hours = parseMemberHours(member.hours);
const openStatus = getOpenStatus(hours);
const normalizedWebsite = normalizeWebsite(member.website);
const shortWebsite = websiteLabel(member.website);
// E-mail public : celui saisi pour la fiche, sinon l'e-mail du compte.
const contactEmail = (member.contactEmail ?? "").trim() || member.email;
const phoneHref = member.phone ? `tel:${member.phone.replace(/[^\d+]/g, "")}` : null;
const sectionCard: React.CSSProperties = {
@@ -142,15 +187,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: canonical },
]),
localBusinessJsonLd(baseUrl, { ...member, email: contactEmail, tags: formatTags(tags) }, 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" }}>
@@ -309,8 +364,8 @@ export default async function FicheAdherentPage({
{phoneHref && member.phone && (
<ContactAction href={phoneHref} icon="phone" label="Appeler" detail={member.phone} />
)}
{member.email && (
<ContactAction href={`mailto:${member.email}`} icon="mail" label="Envoyer un e-mail" detail={member.email} />
{contactEmail && (
<ContactAction href={`mailto:${contactEmail}`} icon="mail" label="Envoyer un e-mail" detail={contactEmail} />
)}
{normalizedWebsite && shortWebsite && (
<ContactAction href={normalizedWebsite} icon="website" label="Visiter le site" detail={shortWebsite} external />
@@ -324,7 +379,7 @@ export default async function FicheAdherentPage({
external
/>
)}
{!phoneHref && !member.email && !normalizedWebsite && !fullAddress && (
{!phoneHref && !contactEmail && !normalizedWebsite && !fullAddress && (
<div className="practical-empty">Coordonnées non communiquées.</div>
)}
</div>
@@ -392,24 +447,24 @@ export default async function FicheAdherentPage({
</section>
{/* contact CTA */}
{(member.email || phoneHref || normalizedWebsite) && (
{(contactEmail || phoneHref || normalizedWebsite) && (
<section style={{ background: "linear-gradient(120deg,#13324F,#1d4a72)", borderRadius: 18, padding: 22, color: "#fff" }}>
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 17, margin: "0 0 6px" }}>Une question pour ce commerçant ?</h3>
<p style={{ margin: "0 0 14px", fontSize: 13.5, color: "#bcd3e6" }}>Contactez {member.name} directement.</p>
<a
href={member.email ? `mailto:${member.email}` : phoneHref ?? normalizedWebsite ?? undefined}
target={!member.email && !phoneHref ? "_blank" : undefined}
rel={!member.email && !phoneHref ? "noopener noreferrer" : undefined}
href={contactEmail ? `mailto:${contactEmail}` : phoneHref ?? normalizedWebsite ?? undefined}
target={!contactEmail && !phoneHref ? "_blank" : undefined}
rel={!contactEmail && !phoneHref ? "noopener noreferrer" : undefined}
className="font-display"
style={{ textDecoration: "none", display: "block", textAlign: "center", background: "#E0A63C", color: "#33291D", fontWeight: 700, fontSize: 14.5, padding: 12, borderRadius: 11 }}
>
{member.email ? "Envoyer un message" : phoneHref ? "Appeler maintenant" : "Visiter le site"}
{contactEmail ? "Envoyer un message" : phoneHref ? "Appeler maintenant" : "Visiter le site"}
</a>
</section>
)}
</aside>
</div>
</div>
</main>
<SiteFooter />
</div>
+5 -57
View File
@@ -1,28 +1,13 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import { VitrineImage } from "@/components/VitrineImage";
import { MemberCard, type MemberCardData } from "@/components/MemberCard";
export type DirectoryMember = {
id: number;
name: string;
description: string | null;
city: string | null;
address: string | null;
coverUrl: string | null;
logoUrl: string | null;
categoryLabel: string | null;
accent: string | null;
export type DirectoryMember = MemberCardData & {
hasPromo: boolean;
promoBadge: string | null;
};
const STRIPE_WARM =
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
const STRIPE_COOL =
"repeating-linear-gradient(45deg,#eef0ec,#eef0ec 12px,#e2e8e6 12px,#e2e8e6 24px)";
export function AnnuaireClient({
members,
categories,
@@ -190,46 +175,9 @@ export function AnnuaireClient({
{/* grid / empty */}
{list.length > 0 ? (
<div className="grid grid-3" style={{ gap: 20 }}>
{list.map((m, i) => {
return (
<Link
key={m.id}
href={`/adherents/${m.id}`}
className="lift-card"
style={{ textDecoration: "none", color: "inherit", background: "#fff", border: "1px solid #e6dcc6", borderRadius: 18, overflow: "hidden", display: "flex", flexDirection: "column" }}
>
<VitrineImage
coverUrl={m.coverUrl}
logoUrl={m.logoUrl}
height={150}
stripe={i % 2 === 0 ? STRIPE_WARM : STRIPE_COOL}
>
{m.categoryLabel && (
<span style={{ position: "absolute", top: 12, left: 12, background: m.accent ?? "#9a6638", color: "#fff", borderRadius: 999, padding: "5px 12px", fontSize: 11, fontWeight: 700 }}>
{m.categoryLabel}
</span>
)}
{m.hasPromo && m.promoBadge && (
<span className="font-display" style={{ position: "absolute", top: 0, right: 0, background: "#d8472b", color: "#fff", fontWeight: 800, fontSize: 12.5, padding: "6px 12px", borderRadius: "0 0 0 14px" }}>
{m.promoBadge}
</span>
)}
</VitrineImage>
<div style={{ padding: "16px 18px 18px", display: "flex", flexDirection: "column", flex: 1 }}>
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 18, margin: 0, color: "#26201a" }}>
{m.name}
</h3>
<p style={{ margin: "6px 0 12px", fontSize: 13, color: "#8c8068", lineHeight: 1.5, flex: 1 }}>
{m.description}
</p>
<div style={{ display: "flex", alignItems: "center", gap: 7, fontSize: 12.5, color: "#6c6150", borderTop: "1px solid #f0e8d6", paddingTop: 11 }}>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: m.accent ?? "#E0A63C" }} />
{[m.address, m.city].filter(Boolean).join(" · ")}
</div>
</div>
</Link>
);
})}
{list.map((m, i) => (
<MemberCard key={m.id} m={m} index={i} />
))}
</div>
) : (
<div style={{ background: "#fff", border: "1px dashed #d8cdb4", borderRadius: 18, padding: 54, textAlign: "center" }}>
+180
View File
@@ -0,0 +1,180 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { CategoryLinks } from "@/components/CategoryLinks";
import { JsonLd } from "@/components/JsonLd";
import { MemberCard } from "@/components/MemberCard";
import { MembershipModalButton } from "@/components/MembershipModalButton";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
import { Sparkle } from "@/components/Sparkle";
import {
getActiveMembersByCategory,
getCategoriesInUse,
getCategoryBySlug,
getLiveBadgesByMember,
} from "@/lib/queries";
import {
NOINDEX,
breadcrumbJsonLd,
categoryPath,
memberListJsonLd,
metaDescription,
pageMetadata,
} from "@/lib/seo";
import { publicBaseUrl } from "@/lib/seo-server";
export const dynamic = "force-dynamic";
type Params = Promise<{ categorie: string }>;
/** Communes représentées, dans l'ordre d'effectif, pour le texte d'introduction. */
function listCities(members: { city: string | null }[], max = 4): string[] {
const counts = new Map<string, number>();
for (const m of members) if (m.city) counts.set(m.city, (counts.get(m.city) ?? 0) + 1);
return [...counts.entries()]
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0], "fr"))
.slice(0, max)
.map(([city]) => city);
}
function joinFr(items: string[]): string {
if (items.length <= 1) return items.join("");
return `${items.slice(0, -1).join(", ")} et ${items[items.length - 1]}`;
}
/** Titre et introduction d'une page métier, réutilisés par les métadonnées et le rendu. */
function categoryCopy(label: string, members: { city: string | null }[]) {
const n = members.length;
const cities = listCities(members);
const where = cities.length > 0 ? ` à ${joinFr(cities)}` : " sur le Bassin de Pompey";
const title = `${label} sur le Bassin de Pompey`;
const intro =
n === 0
? `Aucun adhérent Plein R n'est encore référencé dans la catégorie ${label}. Vous exercez ce métier sur le Bassin de Pompey ? Rejoignez l'association pour apparaître ici.`
: `${n} ${n > 1 ? "professionnels adhérents" : "professionnel adhérent"} Plein R dans la catégorie ${label}${where} : coordonnées, horaires d'ouverture et bons plans du moment. Des commerçants et entreprises de proximité, membres du réseau économique du Bassin de Pompey.`;
return { title, intro };
}
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
const { categorie } = await params;
const category = await getCategoryBySlug(categorie);
if (!category) return { title: "Catégorie introuvable", robots: NOINDEX };
const members = await getActiveMembersByCategory(category.id);
const { title, intro } = categoryCopy(category.label, members);
return pageMetadata({
title,
description: metaDescription(intro),
path: categoryPath(category.slug),
// Une catégorie sans adhérent n'a rien à offrir aux moteurs : on la laisse
// visible pour les visiteurs, mais hors index.
robots: members.length === 0 ? NOINDEX : undefined,
});
}
export default async function CategoriePage({ params }: { params: Params }) {
const { categorie } = await params;
const category = await getCategoryBySlug(categorie);
if (!category) notFound();
const [rawMembers, badges, categoriesInUse, baseUrl] = await Promise.all([
getActiveMembersByCategory(category.id),
getLiveBadgesByMember(),
getCategoriesInUse(),
publicBaseUrl(),
]);
const badgeByMember = new Map<number, string | null>();
for (const b of badges) {
if (b.memberId != null && !badgeByMember.has(b.memberId)) badgeByMember.set(b.memberId, b.badge);
}
const members = rawMembers.map((m) => ({
...m,
hasPromo: badgeByMember.has(m.id),
promoBadge: badgeByMember.get(m.id) ?? null,
}));
const { title, intro } = categoryCopy(category.label, members);
const accent = category.accent || "#E0A63C";
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" },
{ name: category.label, path: categoryPath(category.slug) },
]),
memberListJsonLd(baseUrl, title, members),
]}
/>
<main className="container" style={{ paddingBottom: 56 }}>
<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>
<span>›</span>
<span style={{ color: "#3c3322", fontWeight: 600 }} aria-current="page">{category.label}</span>
</nav>
<section style={{ position: "relative", padding: "6px 0 22px", overflow: "hidden" }}>
<Sparkle color={accent} size={18} style={{ top: 12, right: 30 }} duration={3.2} />
<Sparkle color="#6FB0C6" size={12} style={{ top: 52, right: 90 }} duration={2.7} delay={0.5} />
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: 9,
background: category.tint || "#fff",
border: "1px solid #e6dcc6",
color: "#9a6638",
fontSize: 12,
letterSpacing: "0.12em",
textTransform: "uppercase",
fontWeight: 800,
padding: "6px 13px",
borderRadius: 999,
marginBottom: 14,
}}
>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: accent }} />
{members.length} {members.length > 1 ? "adhérents" : "adhérent"}
</div>
<h1 className="font-display" style={{ fontWeight: 800, fontSize: "clamp(26px, 6vw, 40px)", letterSpacing: "-0.02em", margin: "0 0 10px", color: "#26201a" }}>
{title}
</h1>
<p style={{ margin: 0, fontSize: 16, color: "#6c6150", maxWidth: 680, lineHeight: 1.6 }}>{intro}</p>
</section>
{members.length > 0 ? (
<div className="grid grid-3" style={{ gap: 20 }}>
{members.map((m, i) => (
<MemberCard key={m.id} m={m} index={i} />
))}
</div>
) : (
<div style={{ background: "#fff", border: "1px dashed #d8cdb4", borderRadius: 18, padding: 44, textAlign: "center" }}>
<div className="font-display" style={{ fontWeight: 700, fontSize: 19, color: "#26201a", marginBottom: 14 }}>
Soyez le premier référencé dans cette catégorie
</div>
<MembershipModalButton
label="Adhérer à l'association"
className="font-display"
style={{ border: "none", background: "#E0A63C", color: "#33291D", fontWeight: 700, fontSize: 15, padding: "12px 22px", borderRadius: 12 }}
/>
</div>
)}
{categoriesInUse.length > 0 && (
<section style={{ marginTop: 34, background: "#fff", border: "1px solid #e6dcc6", borderRadius: 18, padding: "22px 24px" }}>
<h2 className="font-display" style={{ fontWeight: 700, fontSize: 19, margin: "0 0 14px", color: "#26201a" }}>
Autres métiers de l&apos;annuaire
</h2>
<CategoryLinks categories={categoriesInUse} currentSlug={category.slug} />
</section>
)}
</main>
<SiteFooter />
</div>
);
}
+39 -7
View File
@@ -6,17 +6,25 @@ import { MembershipModalButton } from "@/components/MembershipModalButton";
import {
getActiveMembersWithCategory,
getAllCategories,
getCategoriesInUse,
getLiveBadgesByMember,
} from "@/lib/queries";
import { CategoryLinks } from "@/components/CategoryLinks";
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 +32,12 @@ 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, categoriesInUse] = await Promise.all([
getActiveMembersWithCategory(),
getAllCategories(),
getLiveBadgesByMember(),
publicBaseUrl(),
getCategoriesInUse(),
]);
const badgeByMember = new Map<number, string | null>();
@@ -46,8 +56,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} />
@@ -82,6 +101,19 @@ export default async function AnnuairePage({
<AnnuaireClient members={members} categories={categories} initialQuery={q ?? ""} />
{/* Pages métier : une URL indexable par catégorie, en plus du filtre. */}
{categoriesInUse.length > 0 && (
<section style={{ marginTop: 34, background: "#fff", border: "1px solid #e6dcc6", borderRadius: 18, padding: "22px 24px" }}>
<h2 className="font-display" style={{ fontWeight: 700, fontSize: 19, margin: "0 0 6px", color: "#26201a" }}>
Parcourir par métier
</h2>
<p style={{ margin: "0 0 14px", fontSize: 14, color: "#6c6150" }}>
Tous les commerçants, artisans et entreprises du Bassin de Pompey, classés par activité.
</p>
<CategoryLinks categories={categoriesInUse} />
</section>
)}
{/* join CTA */}
<section
style={{
@@ -113,7 +145,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 }}>
+6 -2
View File
@@ -24,7 +24,8 @@ const TITLES: Record<string, [string, string]> = {
"/backend/administrateurs": ["Administrateurs", "Gérer les accès à l'administration"],
"/backend/categories": ["Catégories", "Gérer les métiers de l'annuaire"],
"/backend/parametres": ["Paramètres du site", "Configurer l'association et les mentions légales"],
"/backend/espace": ["Mon espace adhérent", "Publiez et suivez vos promotions"],
"/backend/espace": ["Mon profil", "Votre fiche publique, vos inscriptions et votre droit à l'image"],
"/backend/espace/promotions": ["Mes promotions", "Proposez une offre et suivez celles en ligne"],
"/backend/changer-mot-de-passe": ["Mot de passe", "Sécurisez votre compte"],
};
@@ -85,7 +86,10 @@ const SECTIONS: NavSection[] = [
},
{
label: "Côté adhérent",
items: [{ href: "/backend/espace", label: "Mon espace", icon: "space" }],
items: [
{ href: "/backend/espace", label: "Mon profil", icon: "space" },
{ href: "/backend/espace/promotions", label: "Mes promotions", icon: "promos" },
],
},
];
+70 -30
View File
@@ -9,6 +9,9 @@ import bcrypt from "bcryptjs";
import { signOut } from "@/auth";
import { getSession } from "@/lib/session";
import { db } from "@/db";
import { CATEGORY_PALETTE } from "@/db/categories";
import { slugify } from "@/lib/slug";
import { autoTags } from "@/lib/tags";
import {
activityLog,
categories,
@@ -43,26 +46,7 @@ import { normalizeWebsite } from "@/lib/member-profile";
import { SITE_SETTING_DEFAULTS } from "@/lib/site-settings";
import type { AppRole } from "@/types/next-auth";
const CATEGORY_PALETTE = [
{ accent: "#E0A63C", tint: "#f6efdc" },
{ accent: "#6FB0C6", tint: "#e7f0f3" },
{ accent: "#9a6638", tint: "#f4ebda" },
{ accent: "#2C6FB3", tint: "#eaf0f6" },
{ accent: "#5a7a5a", tint: "#eef0ec" },
{ accent: "#7a6f9c", tint: "#efe9f3" },
{ accent: "#c98a2e", tint: "#f7efe0" },
{ accent: "#3f8aa3", tint: "#e6eff2" },
];
function slugify(input: string): string {
return input
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 70);
}
async function logActivity(message: string, dot = "#2C6FB3") {
// Le message agrège des saisies de tiers : on ne laisse passer que <strong>.
@@ -133,10 +117,11 @@ function asImageDataUri(formData: FormData, key: string): string | null {
function revalidatePromoPaths(memberId?: number | null) {
revalidatePath("/backend/promotions");
revalidatePath("/backend/espace");
revalidatePath("/backend/espace/promotions");
revalidatePath("/backend");
revalidatePath("/");
revalidatePath("/annuaire");
if (memberId) revalidatePath(`/adherents/${memberId}`);
if (memberId) revalidatePath("/adherents/[id]", "page");
}
/** Réseaux demandés pour une promo, dans l'ordre d'affichage. */
@@ -177,6 +162,7 @@ async function publishPromoShares(
imageUrl: promotions.imageUrl,
memberId: promotions.memberId,
memberName: members.name,
memberCity: members.city,
})
.from(promotions)
.leftJoin(members, eq(promotions.memberId, members.id))
@@ -349,6 +335,7 @@ export async function retryPromoShare(formData: FormData) {
await publishPromoShares(id, [network], userId);
revalidatePath("/backend/promotions");
revalidatePath("/backend/espace");
revalidatePath("/backend/espace/promotions");
}
// ---- Member space: choisir les réseaux tant que la promo est en attente ----
@@ -370,6 +357,7 @@ export async function setOwnPromoShareTargets(formData: FormData) {
await db.update(promotions).set(readShareTargets(formData)).where(eq(promotions.id, id));
revalidatePath("/backend/espace");
revalidatePath("/backend/espace/promotions");
revalidatePath("/backend/promotions");
}
@@ -400,6 +388,7 @@ export async function publishPromo(formData: FormData) {
await logActivity(`<strong>${name}</strong> a soumis une promotion « ${title} »`, "#E0A63C");
revalidatePath("/backend/espace");
revalidatePath("/backend/espace/promotions");
revalidatePath("/backend");
revalidatePath("/backend/promotions");
}
@@ -417,6 +406,28 @@ export type IssuedCredentials = { email: string; tempPassword: string };
export type CreatedMemberAccount = IssuedCredentials & { memberId: number };
/**
* Tags à enregistrer : la saisie si elle existe, sinon les suggestions déduites
* du métier, de la commune et de la description (`autoTags`).
*/
async function resolveMemberTags(
formTags: FormDataEntryValue | null,
input: { categoryId: number | null; city: string | null; description: string | null }
): Promise<string | null> {
const [category] = input.categoryId
? await db
.select({ slug: categories.slug, label: categories.label })
.from(categories)
.where(eq(categories.id, input.categoryId))
: [];
return autoTags(String(formTags ?? ""), {
categorySlug: category?.slug,
categoryLabel: category?.label,
city: input.city,
description: input.description,
});
}
export async function addMember(formData: FormData): Promise<CreatedMemberAccount | undefined> {
const { role } = await requireRole();
if (!can(role, "manageMembers")) throw new Error("Accès refusé");
@@ -435,9 +446,10 @@ export async function addMember(formData: FormData): Promise<CreatedMemberAccoun
throw new Error("Un compte existe déjà avec cet e-mail.");
}
const tags = await resolveMemberTags(null, { categoryId, city, description: null });
const [newMember] = await db
.insert(members)
.values({ name, email, categoryId, city, status })
.values({ name, email, categoryId, city, status, tags })
.returning({ id: members.id });
// Compte de connexion adhérent avec mot de passe temporaire à changer.
@@ -537,29 +549,35 @@ export async function updateMember(formData: FormData) {
const id = Number(formData.get("id"));
if (!id) return;
const categoryId = formData.get("categoryId") ? Number(formData.get("categoryId")) : null;
const city = String(formData.get("city") ?? "").trim() || null;
const description = String(formData.get("description") ?? "").trim() || null;
const tags = await resolveMemberTags(formData.get("tags"), { categoryId, city, description });
await db
.update(members)
.set({
name: String(formData.get("name") ?? "").trim(),
email: String(formData.get("email") ?? "").trim(),
categoryId: formData.get("categoryId") ? Number(formData.get("categoryId")) : null,
city: String(formData.get("city") ?? "").trim() || null,
contactEmail: String(formData.get("contactEmail") ?? "").trim().toLowerCase() || null,
categoryId,
city,
address: String(formData.get("address") ?? "").trim() || null,
description: String(formData.get("description") ?? "").trim() || null,
description,
postalCode: String(formData.get("postalCode") ?? "").trim() || null,
phone: String(formData.get("phone") ?? "").trim() || null,
website: normalizeWebsite(String(formData.get("website") ?? "")),
memberSince: formData.get("memberSince") ? Number(formData.get("memberSince")) : null,
coverUrl: String(formData.get("coverUrl") ?? "").trim() || null,
logoUrl: String(formData.get("logoUrl") ?? "").trim() || null,
tags: String(formData.get("tags") ?? "").trim() || null,
tags,
hours: String(formData.get("hours") ?? "").trim() || null,
status: String(formData.get("status") ?? "pending") as "active" | "pending",
})
.where(eq(members.id, id));
revalidatePath("/backend/adherents");
revalidatePath(`/adherents/${id}`);
revalidatePath("/adherents/[id]", "page");
revalidatePath("/");
}
@@ -847,6 +865,7 @@ function revalidateMeetingPaths(meetingId?: number) {
revalidatePath("/backend/inscriptions");
revalidatePath("/backend/rencontres-passees");
revalidatePath("/backend/espace");
revalidatePath("/backend/espace/promotions");
revalidatePath("/rencontres-passees");
if (meetingId) revalidatePath(`/inscription/${meetingId}`);
}
@@ -1039,6 +1058,7 @@ export async function saveImageConsent(formData: FormData) {
revalidatePath("/backend");
revalidatePath("/backend/espace");
revalidatePath("/backend/espace/promotions");
redirect("/backend/espace");
}
@@ -1092,18 +1112,30 @@ export async function updateOwnProfile(formData: FormData) {
const name = String(formData.get("name") ?? "").trim();
if (!name) return;
// La catégorie n'est pas modifiable par l'adhérent : on la relit en base pour
// en déduire les tags si le champ est laissé vide.
const [current] = await db.select({ categoryId: members.categoryId }).from(members).where(eq(members.id, memberId));
const city = String(formData.get("city") ?? "").trim() || null;
const description = String(formData.get("description") ?? "").trim() || null;
const tags = await resolveMemberTags(formData.get("tags"), {
categoryId: current?.categoryId ?? null,
city,
description,
});
await db
.update(members)
.set({
name,
description: String(formData.get("description") ?? "").trim() || null,
contactEmail: String(formData.get("contactEmail") ?? "").trim().toLowerCase() || null,
description,
address: String(formData.get("address") ?? "").trim() || null,
postalCode: String(formData.get("postalCode") ?? "").trim() || null,
city: String(formData.get("city") ?? "").trim() || null,
city,
phone: String(formData.get("phone") ?? "").trim() || null,
website: normalizeWebsite(String(formData.get("website") ?? "")),
hours: String(formData.get("hours") ?? "").trim() || null,
tags: String(formData.get("tags") ?? "").trim() || null,
tags,
// Images restreintes aux data-URI : un adhérent ne peut pas faire pointer
// sa fiche publique vers une ressource externe qu'il contrôle.
coverUrl: asImageDataUri(formData, "coverUrl"),
@@ -1113,7 +1145,8 @@ export async function updateOwnProfile(formData: FormData) {
.where(eq(members.id, memberId));
revalidatePath("/backend/espace");
revalidatePath(`/adherents/${memberId}`);
revalidatePath("/backend/espace/promotions");
revalidatePath("/adherents/[id]", "page");
revalidatePath("/annuaire");
revalidatePath("/");
}
@@ -1130,6 +1163,9 @@ export async function approveMembershipRequest(formData: FormData): Promise<Crea
const [req] = await db.select().from(membershipRequests).where(eq(membershipRequests.id, id));
if (!req) throw new Error("Demande introuvable.");
// Garde serveur : un double clic ou un onglet resté ouvert ne doit jamais
// créer un second compte pour la même demande.
if (req.status === "approved") throw new Error("Cette demande a déjà été approuvée : l'adhérent existe.");
const email = (req.email ?? "").trim().toLowerCase();
if (!email) {
@@ -1173,6 +1209,9 @@ export async function setRequestStatus(formData: FormData) {
const id = Number(formData.get("id"));
const status = String(formData.get("status") ?? "");
if (!id || !["new", "approved", "rejected"].includes(status)) return;
// Une demande approuvée a produit un adhérent : son statut ne se réécrit plus.
const [current] = await db.select({ status: membershipRequests.status }).from(membershipRequests).where(eq(membershipRequests.id, id));
if (!current || current.status === "approved") return;
await db
.update(membershipRequests)
.set({ status: status as "new" | "approved" | "rejected" })
@@ -1280,6 +1319,7 @@ export async function disconnectSocial(formData: FormData) {
revalidatePath("/backend/reseaux");
revalidatePath("/backend/promotions");
revalidatePath("/backend/espace");
revalidatePath("/backend/espace/promotions");
}
// ---- Sign out ----
+13 -3
View File
@@ -7,6 +7,7 @@ import { categories, members, users } from "@/db/schema";
import { can } from "@/lib/rbac";
import { ImageField } from "@/components/ImageField";
import { HoursEditor } from "@/components/HoursEditor";
import { TagsField } from "@/components/TagsField";
import { communeOptions } from "@/lib/communes";
import { deleteMember, updateMember } from "../../actions";
import { ResetPasswordButton } from "../ResetPasswordButton";
@@ -31,7 +32,7 @@ export default async function EditMemberPage({
if (!member) notFound();
const cats = await db
.select({ id: categories.id, label: categories.label })
.select({ id: categories.id, slug: categories.slug, label: categories.label })
.from(categories)
.orderBy(asc(categories.sort));
@@ -72,9 +73,14 @@ export default async function EditMemberPage({
<input name="name" required defaultValue={member.name} className="field" />
</div>
<div>
<label className="field-label">E-mail</label>
<label className="field-label">E-mail du compte (identifiant, échanges avec l&apos;association)</label>
<input name="email" type="email" defaultValue={member.email} className="field" />
</div>
<div>
<label className="field-label">E-mail de contact (affiché sur la fiche)</label>
<input name="contactEmail" type="email" defaultValue={member.contactEmail ?? ""} className="field" placeholder={member.email} />
<p className="field-hint">Laissé vide, la fiche publique affiche l&apos;e-mail du compte.</p>
</div>
<div>
<label className="field-label">Catégorie</label>
<select name="categoryId" className="field" defaultValue={member.categoryId ?? ""}>
@@ -134,11 +140,15 @@ export default async function EditMemberPage({
<div style={{ marginTop: 16 }}>
<label className="field-label">Description (une ligne vide sépare les paragraphes)</label>
<textarea name="description" rows={4} defaultValue={member.description ?? ""} className="field" style={{ resize: "vertical" }} />
<p className="field-hint">
Ce texte est lu par Google : indiquez votre métier, votre commune et vos spécialités en trois à cinq
phrases (ex. « Boulangerie artisanale à Frouard : pain au levain, viennoiseries, pâtisseries maison »).
</p>
</div>
<div style={{ marginTop: 16 }}>
<label className="field-label">Tags / spécialités (séparés par des virgules)</label>
<input name="tags" defaultValue={member.tags ?? ""} className="field" placeholder="Levain naturel, Produits locaux, Fait maison" />
<TagsField defaultValue={member.tags} categories={cats} />
</div>
<div style={{ marginTop: 16 }}>
+44 -9
View File
@@ -1,8 +1,9 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { desc } from "drizzle-orm";
import { desc, inArray } from "drizzle-orm";
import { getSession } from "@/lib/session";
import { db } from "@/db";
import { contactMessages, membershipRequests } from "@/db/schema";
import { contactMessages, members, membershipRequests } from "@/db/schema";
import { can } from "@/lib/rbac";
import { setContactStatus, setRequestStatus } from "../actions";
import { ApproveRequestForm } from "./ApproveRequestForm";
@@ -52,6 +53,21 @@ export default async function DemandesPage() {
db.select().from(contactMessages).orderBy(desc(contactMessages.createdAt)),
]);
// Demandes approuvées : on retrouve la fiche créée par son e-mail, pour
// proposer un lien vers l'adhérent plutôt que des boutons déjà consommés.
const approvedEmails = requests
.filter((r) => r.status === "approved" && r.email)
.map((r) => (r.email ?? "").trim().toLowerCase())
.filter(Boolean);
const memberByEmail = new Map<string, number>();
if (approvedEmails.length > 0) {
const rows = await db
.select({ id: members.id, email: members.email })
.from(members)
.where(inArray(members.email, approvedEmails));
for (const row of rows) memberByEmail.set(row.email.trim().toLowerCase(), row.id);
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 26 }}>
{/* ---- Demandes d'adhésion ---- */}
@@ -72,13 +88,32 @@ export default async function DemandesPage() {
</div>
</div>
<StatusPill map={REQ_STATUS} status={r.status} />
<div style={{ display: "flex", gap: 8 }}>
<ApproveRequestForm requestId={r.id} />
<form action={setRequestStatus}>
<input type="hidden" name="id" value={r.id} />
<input type="hidden" name="status" value="rejected" />
<ActionBtn color="#d8472b">Rejeter</ActionBtn>
</form>
{/* Les actions dépendent du statut : une demande approuvée a déjà
produit son adhérent, on ne propose plus que d'ouvrir sa fiche. */}
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
{r.status === "approved" ? (
(() => {
const memberId = memberByEmail.get((r.email ?? "").trim().toLowerCase());
return memberId ? (
<Link href={`/backend/adherents/${memberId}`} style={{ color: "#2C6FB3", fontWeight: 700, fontSize: 12.5, textDecoration: "none", whiteSpace: "nowrap" }}>
Ouvrir la fiche adhérent →
</Link>
) : (
<span style={{ fontSize: 12.5, color: "#a99c82" }}>Adhérent créé</span>
);
})()
) : (
<>
<ApproveRequestForm requestId={r.id} />
{r.status === "new" && (
<form action={setRequestStatus}>
<input type="hidden" name="id" value={r.id} />
<input type="hidden" name="status" value="rejected" />
<ActionBtn color="#d8472b">Rejeter</ActionBtn>
</form>
)}
</>
)}
</div>
</div>
{r.message && (
+62
View File
@@ -0,0 +1,62 @@
import Link from "next/link";
function initialsOf(name: string) {
return name.split(/\s+/).map((w) => w[0]).join("").slice(0, 2).toUpperCase();
}
const TABS = [
{ key: "profil", href: "/backend/espace", label: "Mon profil" },
{ key: "promotions", href: "/backend/espace/promotions", label: "Mes promotions" },
] as const;
export type EspaceTab = (typeof TABS)[number]["key"];
/**
* Bandeau de l'espace adhérent et onglets Profil / Promotions : deux pages
* distinctes pour que la fiche publique et la vie des promotions ne se
* mélangent pas dans une seule longue page.
*/
export function EspaceHeader({
memberName,
subtitle,
active,
publicPath,
promoBadge,
}: {
memberName: string;
subtitle: string;
active: EspaceTab;
publicPath?: string | null;
/** Nombre de promotions en ligne, affiché sur l'onglet. */
promoBadge?: number;
}) {
return (
<div style={{ marginBottom: 22 }}>
<div style={{ background: "#13324F", borderRadius: "16px 16px 0 0", padding: "20px 24px", display: "flex", alignItems: "center", gap: 15, flexWrap: "wrap" }}>
<span className="font-display" style={{ width: 46, height: 46, borderRadius: 12, background: "#E0A63C", color: "#33291D", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 800, fontSize: 16, flexShrink: 0 }}>
{initialsOf(memberName)}
</span>
<div style={{ flex: 1, minWidth: 200 }}>
<div className="font-display" style={{ fontWeight: 700, fontSize: 18, color: "#fff" }}>{memberName}</div>
<div style={{ fontSize: 13, color: "#9bb6cd" }}>{subtitle}</div>
</div>
{publicPath && (
<Link href={publicPath} target="_blank" style={{ color: "#fff", border: "1px solid rgba(255,255,255,0.35)", borderRadius: 10, padding: "8px 14px", fontSize: 13, fontWeight: 700, textDecoration: "none", whiteSpace: "nowrap" }}>
Voir ma fiche publique ↗
</Link>
)}
</div>
<nav className="espace-tabs" aria-label="Sections de l'espace adhérent">
{TABS.map((tab) => {
const isActive = tab.key === active;
return (
<Link key={tab.key} href={tab.href} className={`espace-tab${isActive ? " active" : ""}`} aria-current={isActive ? "page" : undefined}>
{tab.label}
{tab.key === "promotions" && promoBadge ? <span className="espace-tab__badge">{promoBadge}</span> : null}
</Link>
);
})}
</nav>
</div>
);
}
+19 -7
View File
@@ -6,6 +6,7 @@ import { publishPromo } from "../actions";
import { PromoImage } from "@/components/PromoImage";
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
import type { SocialNetwork } from "@/lib/social";
import type { PromoCategoryGroup } from "@/lib/promo-categories";
const STRIPE_WARM =
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
@@ -17,18 +18,23 @@ const NETWORK_LABELS: Record<SocialNetwork, string> = {
export function MemberSpaceForm({
memberName,
categories,
categoryGroups,
defaultCategory,
networks = [],
}: {
memberName: string;
categories: string[];
/** Types de produits / services, groupés pour le `<select>`. */
categoryGroups: PromoCategoryGroup[];
/** Pré-sélection déduite du métier de l'adhérent. */
defaultCategory?: string;
networks?: SocialNetwork[];
}) {
const CATEGORIES = categories.length > 0 ? categories : ["Autre"];
const allCategories = categoryGroups.flatMap((g) => g.items);
const initialCategory = defaultCategory && allCategories.includes(defaultCategory) ? defaultCategory : allCategories[0] ?? "Autre";
const router = useRouter();
const [title, setTitle] = useState("");
const [text, setText] = useState("");
const [cat, setCat] = useState(CATEGORIES[0]);
const [cat, setCat] = useState(initialCategory);
const [badge, setBadge] = useState("");
const [imgData, setImgData] = useState("");
const [shares, setShares] = useState<SocialNetwork[]>([]);
@@ -144,10 +150,16 @@ export function MemberSpaceForm({
<div style={{ display: "flex", gap: 12, marginBottom: 16 }}>
<div style={{ flex: 1 }}>
<label className="field-label">Catégorie</label>
<label className="field-label">Type de produit ou de service</label>
<select value={cat} onChange={(e) => setCat(e.target.value)} className="field">
{CATEGORIES.map((c) => (
<option key={c}>{c}</option>
{categoryGroups.map((group) => (
<optgroup key={group.label} label={group.label}>
{group.items.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</optgroup>
))}
</select>
</div>
+35 -240
View File
@@ -1,36 +1,21 @@
import Link from "next/link";
import { and, asc, desc, eq, gte, inArray, sql } from "drizzle-orm";
import { and, asc, desc, eq, gte, sql } from "drizzle-orm";
import { getSession } from "@/lib/session";
import { db } from "@/db";
import { categories, imageConsents, meetingRegistrations, meetings, members, promotions, socialPosts, type Member } from "@/db/schema";
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
import { configuredNetworks, SOCIAL_LABELS, type SocialNetwork } from "@/lib/social";
import { categories, imageConsents, meetingRegistrations, meetings, members, promotions } from "@/db/schema";
import type { Member } from "@/db/schema";
import { memberPath } from "@/lib/seo";
import { ImageField } from "@/components/ImageField";
import { ImageConsentForm } from "@/components/ImageConsentForm";
import { HoursEditor } from "@/components/HoursEditor";
import { TagsField } from "@/components/TagsField";
import { communeOptions } from "@/lib/communes";
import {
saveImageConsent,
setOwnPromoShareTargets,
setOwnPromoSuspension,
updateOwnProfile,
} from "../actions";
import { MemberSpaceForm } from "./MemberSpaceForm";
import { saveImageConsent, updateOwnProfile } from "../actions";
import { EspaceHeader } from "./EspaceHeader";
export const dynamic = "force-dynamic";
const PROMO_STATUS: Record<string, { label: string; bg: string; color: string }> = {
live: { label: "En ligne", bg: "#e6f4ec", color: "#1f8a5b" },
pending: { label: "En attente", bg: "#fbeede", color: "#9a6638" },
rejected: { label: "Refusée", bg: "#fbe9e6", color: "#d8472b" },
suspended: { label: "Suspendue", bg: "#fbe9e6", color: "#d8472b" },
expired: { label: "Expirée", bg: "#f1efe7", color: "#a99c82" },
};
function initialsOf(name: string) {
return name.split(/\s+/).map((w) => w[0]).join("").slice(0, 2).toUpperCase();
}
/** Espace adhérent, onglet Profil : fiche publique, inscriptions, droit à l'image. */
export default async function EspacePage() {
const session = await getSession();
const memberId = session?.user.memberId ?? null;
@@ -39,20 +24,10 @@ export default async function EspacePage() {
let memberName = fallbackName;
let subtitle = "Espace adhérent";
let profile: Member | null = null;
let myPromos: {
id: number;
title: string;
status: string;
createdAt: Date;
imageUrl: string | null;
suspendedBy: "member" | "staff" | null;
shareFacebook: boolean;
shareLinkedin: boolean;
}[] = [];
// Dernière tentative de publication par (promo, réseau).
let lastShare = new Map<string, { status: string; url: string | null; error: string | null }>();
let livePromoCount = 0;
let myRegistrations: { meetingId: number; title: string; startsAt: Date; location: string | null; participants: number; confirmed: boolean }[] = [];
let latestConsent: { decision: string; createdAt: Date } | null = null;
let profileCategory: { slug: string; label: string } | null = null;
if (memberId) {
const [m] = await db.select().from(members).where(eq(members.id, memberId));
@@ -60,43 +35,16 @@ export default async function EspacePage() {
profile = m;
memberName = m.name;
const [cat] = m.categoryId
? await db.select({ label: categories.label }).from(categories).where(eq(categories.id, m.categoryId))
? await db.select({ slug: categories.slug, label: categories.label }).from(categories).where(eq(categories.id, m.categoryId))
: [];
profileCategory = cat ?? null;
subtitle = ["Espace adhérent", cat?.label, m.city].filter(Boolean).join(" · ");
}
myPromos = await db
.select({
id: promotions.id,
title: promotions.title,
status: promotions.status,
createdAt: promotions.createdAt,
imageUrl: promotions.imageUrl,
suspendedBy: promotions.suspendedBy,
shareFacebook: promotions.shareFacebook,
shareLinkedin: promotions.shareLinkedin,
})
const [live] = await db
.select({ total: sql<number>`count(*)` })
.from(promotions)
.where(eq(promotions.memberId, memberId))
.orderBy(desc(promotions.createdAt));
if (myPromos.length > 0) {
const rows = await db
.select({
promotionId: socialPosts.promotionId,
network: socialPosts.network,
status: socialPosts.status,
url: socialPosts.url,
error: socialPosts.error,
})
.from(socialPosts)
.where(inArray(socialPosts.promotionId, myPromos.map((p) => p.id)))
.orderBy(desc(socialPosts.createdAt));
// Trié du plus récent au plus ancien : la première occurrence gagne.
lastShare = new Map();
for (const row of rows) {
const key = `${row.promotionId}:${row.network}`;
if (!lastShare.has(key)) lastShare.set(key, row);
}
}
.where(and(eq(promotions.memberId, memberId), eq(promotions.status, "live")));
livePromoCount = Number(live?.total ?? 0);
myRegistrations = await db
.select({
meetingId: meetings.id,
@@ -120,13 +68,6 @@ export default async function EspacePage() {
latestConsent = consent ?? null;
}
const catRows = await db
.select({ label: categories.label })
.from(categories)
.orderBy(asc(categories.sort));
const categoryLabels = catRows.map((c) => c.label);
const networks = await configuredNetworks();
function fmtDate(d: Date) {
return new Date(d).toLocaleDateString("fr-FR", { day: "numeric", month: "long" });
}
@@ -140,17 +81,13 @@ export default async function EspacePage() {
</div>
)}
<div style={{ background: "#13324F", borderRadius: 16, padding: "20px 24px", marginBottom: 22, display: "flex", alignItems: "center", gap: 15 }}>
<span className="font-display" style={{ width: 46, height: 46, borderRadius: 12, background: "#E0A63C", color: "#33291D", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 800, fontSize: 16, flexShrink: 0 }}>
{initialsOf(memberName)}
</span>
<div>
<div className="font-display" style={{ fontWeight: 700, fontSize: 18, color: "#fff" }}>
{memberName}
</div>
<div style={{ fontSize: 13, color: "#9bb6cd" }}>{subtitle}</div>
</div>
</div>
<EspaceHeader
memberName={memberName}
subtitle={subtitle}
active="profil"
publicPath={profile && profile.status === "active" ? memberPath(profile) : null}
promoBadge={livePromoCount}
/>
{memberId && (
<section style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 24, marginBottom: 28 }}>
@@ -194,6 +131,13 @@ export default async function EspacePage() {
<label className="field-label">Nom</label>
<input name="name" required defaultValue={profile.name} className="field" />
</div>
<div>
<label className="field-label">E-mail de contact (affiché sur la fiche)</label>
<input name="contactEmail" type="email" defaultValue={profile.contactEmail ?? ""} className="field" placeholder={profile.email} />
<p className="field-hint">
Indépendant de votre identifiant de connexion. Laissé vide, la fiche affiche l&apos;e-mail de votre compte.
</p>
</div>
<div>
<label className="field-label">Commune</label>
<select name="city" className="field" defaultValue={profile.city ?? ""}>
@@ -231,11 +175,15 @@ export default async function EspacePage() {
<div style={{ marginTop: 16 }}>
<label className="field-label">Description (une ligne vide sépare les paragraphes)</label>
<textarea name="description" rows={4} defaultValue={profile.description ?? ""} className="field" style={{ resize: "vertical" }} />
<p className="field-hint">
Ce texte est lu par Google : indiquez votre métier, votre commune et vos spécialités en trois à cinq
phrases (ex. « Boulangerie artisanale à Frouard : pain au levain, viennoiseries, pâtisseries maison »).
</p>
</div>
<div style={{ marginTop: 16 }}>
<label className="field-label">Tags / spécialités (séparés par des virgules)</label>
<input name="tags" defaultValue={profile.tags ?? ""} className="field" />
<TagsField defaultValue={profile.tags} fixedCategory={profileCategory} />
</div>
<div style={{ marginTop: 16 }}>
@@ -280,159 +228,6 @@ export default async function EspacePage() {
</section>
)}
<MemberSpaceForm memberName={memberName} categories={categoryLabels} networks={networks} />
<div style={{ marginTop: 28 }}>
<div style={{ fontSize: 12.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 700, marginBottom: 4 }}>
Mes promotions
</div>
<div style={{ fontSize: 13, color: "#8c8068", marginBottom: 12 }}>
Vous pouvez suspendre une promotion en ligne à tout moment : elle disparaît du site et
vous la remettez en ligne quand vous voulez.
</div>
{myPromos.length === 0 && (
<div style={{ fontSize: 13.5, color: "#a99c82" }}>Vous n&apos;avez pas encore publié de promotion.</div>
)}
{myPromos.map((mp) => {
const st = PROMO_STATUS[mp.status] ?? PROMO_STATUS.expired;
const suspendedByStaff = mp.status === "suspended" && mp.suspendedBy === "staff";
return (
<div key={mp.id} style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 12, padding: "12px 14px", marginBottom: 10 }}>
<div style={{ display: "flex", alignItems: "center", gap: 13 }}>
<span
style={{
width: 42,
height: 42,
borderRadius: 9,
background: mp.imageUrl
? `url(${mp.imageUrl})`
: "repeating-linear-gradient(45deg,#efe9da,#efe9da 7px,#e6ddc9 7px,#e6ddc9 14px)",
backgroundSize: "cover",
backgroundPosition: "center",
flexShrink: 0,
opacity: mp.status === "suspended" ? 0.5 : 1,
}}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: "#26201a", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{mp.title}
</div>
<div style={{ fontSize: 12, color: "#a99c82" }}>Publié le {fmtDate(mp.createdAt)}</div>
</div>
<span style={{ display: "inline-block", fontSize: 11.5, fontWeight: 700, padding: "4px 11px", borderRadius: 999, background: st.bg, color: st.color, flexShrink: 0 }}>
{st.label}
</span>
{mp.status === "live" && (
<form action={setOwnPromoSuspension}>
<input type="hidden" name="id" value={mp.id} />
<input type="hidden" name="action" value="suspend" />
<button type="submit" style={{ border: "1px solid #e0c3bb", background: "#fff", color: "#d8472b", fontWeight: 700, fontSize: 12.5, padding: "7px 13px", borderRadius: 9, cursor: "pointer", whiteSpace: "nowrap" }}>
Suspendre
</button>
</form>
)}
{mp.status === "suspended" && !suspendedByStaff && (
<form action={setOwnPromoSuspension}>
<input type="hidden" name="id" value={mp.id} />
<input type="hidden" name="action" value="restore" />
<button type="submit" style={{ border: "none", background: "#1f8a5b", color: "#fff", fontWeight: 700, fontSize: 12.5, padding: "8px 14px", borderRadius: 9, cursor: "pointer", whiteSpace: "nowrap" }}>
Remettre en ligne
</button>
</form>
)}
</div>
{suspendedByStaff && (
<div style={{ marginTop: 10, background: "#fbe9e6", border: "1px solid #f2d5cf", color: "#a8503c", borderRadius: 9, padding: "9px 11px", fontSize: 12.5, lineHeight: 1.5 }}>
Cette promotion a été suspendue par l&apos;association. Contactez-la pour la
remettre en ligne.
</div>
)}
{networks.length > 0 && (
<PromoShareState
promo={mp}
networks={networks}
lastShare={lastShare}
editable={mp.status === "pending"}
/>
)}
</div>
);
})}
</div>
</div>
);
}
/**
* État de diffusion d'une promo côté adhérent : modifiable tant qu'elle est en
* attente, purement informatif dès qu'elle est validée.
*/
function PromoShareState({
promo,
networks,
lastShare,
editable,
}: {
promo: { id: number; shareFacebook: boolean; shareLinkedin: boolean };
networks: SocialNetwork[];
lastShare: Map<string, { status: string; url: string | null; error: string | null }>;
editable: boolean;
}) {
const wanted = (n: SocialNetwork) => (n === "facebook" ? promo.shareFacebook : promo.shareLinkedin);
const chosen = networks.filter(wanted);
if (editable) {
return (
<form action={setOwnPromoShareTargets} style={{ marginTop: 11, borderTop: "1px solid #f0e8d6", paddingTop: 11 }}>
<input type="hidden" name="id" value={promo.id} />
<div style={{ fontSize: 11.5, color: "#8c8068", marginBottom: 8, lineHeight: 1.5 }}>
Diffusion prévue après validation — modifiable tant que la promotion est en attente.
</div>
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", alignItems: "center" }}>
{networks.map((network) => (
<label key={network} style={{ display: "inline-flex", alignItems: "center", gap: 7, fontSize: 12.5, fontWeight: 700, color: SOCIAL_BRAND[network], cursor: "pointer" }}>
<input
type="checkbox"
name={network === "facebook" ? "shareFacebook" : "shareLinkedin"}
defaultChecked={wanted(network)}
style={{ accentColor: SOCIAL_BRAND[network], margin: 0 }}
/>
<SocialIcon network={network} size={15} />
{SOCIAL_LABELS[network]}
</label>
))}
<button type="submit" style={{ border: "1px solid #d8cdb4", background: "#fff", color: "#6c6150", fontWeight: 700, fontSize: 12, padding: "6px 12px", borderRadius: 8, cursor: "pointer" }}>
Mettre à jour
</button>
</div>
</form>
);
}
if (chosen.length === 0) return null;
return (
<div style={{ marginTop: 11, borderTop: "1px solid #f0e8d6", paddingTop: 11, display: "flex", gap: 8, flexWrap: "wrap" }}>
{chosen.map((network) => {
const last = lastShare.get(`${promo.id}:${network}`);
const posted = last?.status === "posted";
const failed = last?.status === "failed";
const detail = posted ? "publié" : failed ? "échec de publication" : "en attente";
const color = posted ? "#1f8a5b" : failed ? "#d8472b" : "#9a6638";
const chip = (
<span style={{ display: "inline-flex", alignItems: "center", gap: 7, background: "#faf7ef", border: "1px solid #f0e8d6", borderRadius: 999, padding: "5px 12px", fontSize: 11.5, fontWeight: 700, color }}>
<SocialIcon network={network} size={13} />
{SOCIAL_LABELS[network]} · {detail}
</span>
);
return posted && last?.url ? (
<a key={network} href={last.url} target="_blank" rel="noopener noreferrer" style={{ textDecoration: "none" }}>
{chip}
</a>
) : (
<span key={network}>{chip}</span>
);
})}
</div>
);
}
+366
View File
@@ -0,0 +1,366 @@
import Link from "next/link";
import { and, desc, eq, inArray } from "drizzle-orm";
import { getSession } from "@/lib/session";
import { db } from "@/db";
import { categories, members, promotions, socialPosts } from "@/db/schema";
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
import { configuredNetworks, SOCIAL_LABELS, type SocialNetwork } from "@/lib/social";
import { PROMO_CATEGORY_GROUPS, defaultPromoCategory } from "@/lib/promo-categories";
import { memberPath } from "@/lib/seo";
import { setOwnPromoShareTargets, setOwnPromoSuspension } from "../../actions";
import { EspaceHeader } from "../EspaceHeader";
import { MemberSpaceForm } from "../MemberSpaceForm";
export const dynamic = "force-dynamic";
const PROMO_STATUS: Record<string, { label: string; bg: string; color: string }> = {
live: { label: "En ligne", bg: "#e6f4ec", color: "#1f8a5b" },
pending: { label: "En attente de validation", bg: "#fbeede", color: "#9a6638" },
rejected: { label: "Refusée", bg: "#fbe9e6", color: "#d8472b" },
suspended: { label: "Suspendue", bg: "#fbe9e6", color: "#d8472b" },
expired: { label: "Expirée", bg: "#f1efe7", color: "#a99c82" },
};
type MyPromo = {
id: number;
title: string;
status: string;
category: string | null;
badge: string | null;
validUntil: string | null;
createdAt: Date;
imageUrl: string | null;
suspendedBy: "member" | "staff" | null;
shareFacebook: boolean;
shareLinkedin: boolean;
};
type ShareState = Map<string, { status: string; url: string | null; error: string | null }>;
function fmtDate(d: Date) {
return new Date(d).toLocaleDateString("fr-FR", { day: "numeric", month: "long", year: "numeric" });
}
/** Espace adhérent, onglet Promotions : proposer une offre et suivre les siennes. */
export default async function EspacePromotionsPage() {
const session = await getSession();
const memberId = session?.user.memberId ?? null;
let memberName = session?.user.name ?? "Adhérent";
let subtitle = "Espace adhérent · Promotions";
let publicPath: string | null = null;
let categorySlug: string | null = null;
let myPromos: MyPromo[] = [];
let lastShare: ShareState = new Map();
if (memberId) {
const [m] = await db
.select({
id: members.id,
name: members.name,
city: members.city,
status: members.status,
categorySlug: categories.slug,
categoryLabel: categories.label,
})
.from(members)
.leftJoin(categories, eq(members.categoryId, categories.id))
.where(eq(members.id, memberId));
if (m) {
memberName = m.name;
categorySlug = m.categorySlug;
subtitle = ["Espace adhérent", m.categoryLabel, m.city].filter(Boolean).join(" · ");
publicPath = m.status === "active" ? memberPath(m) : null;
}
myPromos = await db
.select({
id: promotions.id,
title: promotions.title,
status: promotions.status,
category: promotions.category,
badge: promotions.badge,
validUntil: promotions.validUntil,
createdAt: promotions.createdAt,
imageUrl: promotions.imageUrl,
suspendedBy: promotions.suspendedBy,
shareFacebook: promotions.shareFacebook,
shareLinkedin: promotions.shareLinkedin,
})
.from(promotions)
.where(eq(promotions.memberId, memberId))
.orderBy(desc(promotions.createdAt));
if (myPromos.length > 0) {
const rows = await db
.select({
promotionId: socialPosts.promotionId,
network: socialPosts.network,
status: socialPosts.status,
url: socialPosts.url,
error: socialPosts.error,
})
.from(socialPosts)
.where(inArray(socialPosts.promotionId, myPromos.map((p) => p.id)))
.orderBy(desc(socialPosts.createdAt));
// Trié du plus récent au plus ancien : la première occurrence gagne.
for (const row of rows) {
const key = `${row.promotionId}:${row.network}`;
if (!lastShare.has(key)) lastShare.set(key, row);
}
}
}
const networks = await configuredNetworks();
const live = myPromos.filter((p) => p.status === "live");
const pending = myPromos.filter((p) => p.status === "pending");
const others = myPromos.filter((p) => p.status !== "live" && p.status !== "pending");
return (
<div>
{!memberId && (
<div style={{ background: "#fbeede", border: "1px solid #ecd8b8", color: "#9a6638", borderRadius: 12, padding: "12px 16px", marginBottom: 18, fontSize: 13.5 }}>
Votre compte n&apos;est pas encore relié à une fiche adhérent. Les promotions publiées ne
seront pas rattachées à un commerce.
</div>
)}
<EspaceHeader memberName={memberName} subtitle={subtitle} active="promotions" publicPath={publicPath} promoBadge={live.length} />
{/* Résumé : ce qui est visible par le public aujourd'hui */}
<div className="grid grid-3" style={{ gap: 12, marginBottom: 24 }}>
<StatCard label="En ligne sur le site" value={live.length} color="#1f8a5b" bg="#e6f4ec" />
<StatCard label="En attente de validation" value={pending.length} color="#9a6638" bg="#fbeede" />
<StatCard label="Terminées ou suspendues" value={others.length} color="#8c8068" bg="#f1efe7" />
</div>
<PromoSection
title="Mes promotions en cours"
intro={
live.length > 0
? "Ces offres sont visibles sur le site et sur votre fiche. Vous pouvez en suspendre une à tout moment : elle disparaît aussitôt, et vous la remettez en ligne quand vous voulez."
: "Aucune promotion en ligne pour le moment. Proposez une offre ci-dessous : elle apparaîtra ici dès sa validation par l'association."
}
promos={live}
networks={networks}
lastShare={lastShare}
highlight
/>
<MemberSpaceForm
memberName={memberName}
categoryGroups={PROMO_CATEGORY_GROUPS}
defaultCategory={defaultPromoCategory(categorySlug)}
networks={networks}
/>
{pending.length > 0 && (
<PromoSection
title="En attente de validation"
intro="L'association relit chaque offre avant sa mise en ligne. Vous pouvez encore ajuster la diffusion sur les réseaux sociaux."
promos={pending}
networks={networks}
lastShare={lastShare}
/>
)}
{others.length > 0 && (
<PromoSection
title="Suspendues, refusées ou expirées"
intro="Historique de vos offres. Une promotion suspendue par vous peut être remise en ligne."
promos={others}
networks={networks}
lastShare={lastShare}
/>
)}
{myPromos.length > 0 && (
<p style={{ fontSize: 13, color: "#8c8068", marginTop: 18 }}>
Les offres en ligne apparaissent sur <Link href="/#promotions" style={{ color: "#9a6638", fontWeight: 700 }}>la page d&apos;accueil</Link>
{publicPath ? (
<>
{" "}et sur <Link href={publicPath} style={{ color: "#9a6638", fontWeight: 700 }}>votre fiche</Link>
</>
) : null}
.
</p>
)}
</div>
);
}
function StatCard({ label, value, color, bg }: { label: string; value: number; color: string; bg: string }) {
return (
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 14, padding: "14px 16px", display: "flex", alignItems: "center", gap: 14 }}>
<span className="font-display" style={{ width: 44, height: 44, borderRadius: 12, background: bg, color, display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 800, fontSize: 20, flexShrink: 0 }}>
{value}
</span>
<span style={{ fontSize: 13, color: "#6c6150", fontWeight: 600, lineHeight: 1.35 }}>{label}</span>
</div>
);
}
function PromoSection({
title,
intro,
promos,
networks,
lastShare,
highlight = false,
}: {
title: string;
intro: string;
promos: MyPromo[];
networks: SocialNetwork[];
lastShare: ShareState;
highlight?: boolean;
}) {
return (
<section
style={{
background: highlight ? "#fff" : "transparent",
border: highlight ? "1px solid #e6dcc6" : "none",
borderRadius: 16,
padding: highlight ? 22 : "0",
marginBottom: 28,
}}
>
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 18, margin: 0, color: "#26201a" }}>
{title}
<span style={{ marginLeft: 8, fontSize: 13, color: "#a99c82", fontWeight: 600 }}>{promos.length}</span>
</h3>
<p style={{ fontSize: 13, color: "#8c8068", margin: "4px 0 14px", lineHeight: 1.5 }}>{intro}</p>
{promos.map((mp) => (
<PromoRow key={mp.id} promo={mp} networks={networks} lastShare={lastShare} />
))}
</section>
);
}
function PromoRow({ promo: mp, networks, lastShare }: { promo: MyPromo; networks: SocialNetwork[]; lastShare: ShareState }) {
const st = PROMO_STATUS[mp.status] ?? PROMO_STATUS.expired;
const suspendedByStaff = mp.status === "suspended" && mp.suspendedBy === "staff";
const meta = [mp.category, mp.badge, mp.validUntil].filter(Boolean).join(" · ");
return (
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 12, padding: "12px 14px", marginBottom: 10 }}>
<div style={{ display: "flex", alignItems: "center", gap: 13, flexWrap: "wrap" }}>
<span
style={{
width: 54,
height: 54,
borderRadius: 10,
background: mp.imageUrl ? `url(${mp.imageUrl})` : "repeating-linear-gradient(45deg,#efe9da,#efe9da 7px,#e6ddc9 7px,#e6ddc9 14px)",
backgroundSize: "cover",
backgroundPosition: "center",
flexShrink: 0,
opacity: mp.status === "suspended" ? 0.5 : 1,
}}
/>
<div style={{ flex: 1, minWidth: 180 }}>
<div style={{ fontSize: 14.5, fontWeight: 700, color: "#26201a" }}>{mp.title}</div>
<div style={{ fontSize: 12, color: "#a99c82", marginTop: 2 }}>
{meta ? `${meta} · ` : ""}Déposée le {fmtDate(mp.createdAt)}
</div>
</div>
<span style={{ display: "inline-block", fontSize: 11.5, fontWeight: 700, padding: "4px 11px", borderRadius: 999, background: st.bg, color: st.color, flexShrink: 0 }}>
{st.label}
</span>
{mp.status === "live" && (
<form action={setOwnPromoSuspension}>
<input type="hidden" name="id" value={mp.id} />
<input type="hidden" name="action" value="suspend" />
<button type="submit" style={{ border: "1px solid #e0c3bb", background: "#fff", color: "#d8472b", fontWeight: 700, fontSize: 12.5, padding: "7px 13px", borderRadius: 9, cursor: "pointer", whiteSpace: "nowrap" }}>
Suspendre
</button>
</form>
)}
{mp.status === "suspended" && !suspendedByStaff && (
<form action={setOwnPromoSuspension}>
<input type="hidden" name="id" value={mp.id} />
<input type="hidden" name="action" value="restore" />
<button type="submit" style={{ border: "none", background: "#1f8a5b", color: "#fff", fontWeight: 700, fontSize: 12.5, padding: "8px 14px", borderRadius: 9, cursor: "pointer", whiteSpace: "nowrap" }}>
Remettre en ligne
</button>
</form>
)}
</div>
{suspendedByStaff && (
<div style={{ marginTop: 10, background: "#fbe9e6", border: "1px solid #f2d5cf", color: "#a8503c", borderRadius: 9, padding: "9px 11px", fontSize: 12.5, lineHeight: 1.5 }}>
Cette promotion a été suspendue par l&apos;association. Contactez-la pour la remettre en ligne.
</div>
)}
{networks.length > 0 && <PromoShareState promo={mp} networks={networks} lastShare={lastShare} editable={mp.status === "pending"} />}
</div>
);
}
/**
* État de diffusion d'une promo côté adhérent : modifiable tant qu'elle est en
* attente, purement informatif dès qu'elle est validée.
*/
function PromoShareState({
promo,
networks,
lastShare,
editable,
}: {
promo: { id: number; shareFacebook: boolean; shareLinkedin: boolean };
networks: SocialNetwork[];
lastShare: ShareState;
editable: boolean;
}) {
const wanted = (n: SocialNetwork) => (n === "facebook" ? promo.shareFacebook : promo.shareLinkedin);
const chosen = networks.filter(wanted);
if (editable) {
return (
<form action={setOwnPromoShareTargets} style={{ marginTop: 11, borderTop: "1px solid #f0e8d6", paddingTop: 11 }}>
<input type="hidden" name="id" value={promo.id} />
<div style={{ fontSize: 11.5, color: "#8c8068", marginBottom: 8, lineHeight: 1.5 }}>
Diffusion prévue après validation — modifiable tant que la promotion est en attente.
</div>
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", alignItems: "center" }}>
{networks.map((network) => (
<label key={network} style={{ display: "inline-flex", alignItems: "center", gap: 7, fontSize: 12.5, fontWeight: 700, color: SOCIAL_BRAND[network], cursor: "pointer" }}>
<input
type="checkbox"
name={network === "facebook" ? "shareFacebook" : "shareLinkedin"}
defaultChecked={wanted(network)}
style={{ accentColor: SOCIAL_BRAND[network], margin: 0 }}
/>
<SocialIcon network={network} size={15} />
{SOCIAL_LABELS[network]}
</label>
))}
<button type="submit" style={{ border: "1px solid #d8cdb4", background: "#fff", color: "#6c6150", fontWeight: 700, fontSize: 12, padding: "6px 12px", borderRadius: 8, cursor: "pointer" }}>
Mettre à jour
</button>
</div>
</form>
);
}
if (chosen.length === 0) return null;
return (
<div style={{ marginTop: 11, borderTop: "1px solid #f0e8d6", paddingTop: 11, display: "flex", gap: 8, flexWrap: "wrap" }}>
{chosen.map((network) => {
const last = lastShare.get(`${promo.id}:${network}`);
const posted = last?.status === "posted";
const failed = last?.status === "failed";
const detail = posted ? "publié" : failed ? "échec de publication" : "en attente";
const color = posted ? "#1f8a5b" : failed ? "#d8472b" : "#9a6638";
const chip = (
<span style={{ display: "inline-flex", alignItems: "center", gap: 7, background: "#faf7ef", border: "1px solid #f0e8d6", borderRadius: 999, padding: "5px 12px", fontSize: 11.5, fontWeight: 700, color }}>
<SocialIcon network={network} size={13} />
{SOCIAL_LABELS[network]} · {detail}
</span>
);
return posted && last?.url ? (
<a key={network} href={last.url} target="_blank" rel="noopener noreferrer" style={{ textDecoration: "none" }}>
{chip}
</a>
) : (
<span key={network}>{chip}</span>
);
})}
</div>
);
}
+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";
+97 -6
View File
@@ -379,15 +379,15 @@ button {
/* Boutons sociaux libellés : plus lisibles que deux icônes isolées, et ils
donnent enfin du contenu à la quatrième colonne. */
.footer-social {
display: flex;
flex-direction: column;
/* Sans cela les boutons s'étirent sur toute la colonne et deviennent des
pavés vides. */
align-items: flex-start;
/* Grille à une colonne dimensionnée sur son contenu : tous les boutons
prennent la largeur du plus long au lieu de s'étirer sur la colonne ou de
former un escalier. */
display: inline-grid;
grid-template-columns: minmax(150px, max-content);
gap: 9px;
}
.footer-social a {
display: inline-flex;
display: flex;
align-items: center;
gap: 10px;
padding: 9px 13px;
@@ -948,6 +948,97 @@ img {
color: var(--muted);
margin-bottom: 6px;
}
/* Onglets de l'espace adhérent (Profil / Promotions), sous le bandeau. */
.espace-tabs {
display: flex;
gap: 4px;
background: #fff;
border: 1px solid #e6dcc6;
border-top: none;
border-radius: 0 0 16px 16px;
padding: 6px;
}
.espace-tab {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 11px 14px;
border-radius: 11px;
font-family: "Bricolage Grotesque", "Public Sans", system-ui, sans-serif;
font-weight: 700;
font-size: 14.5px;
color: var(--muted);
text-decoration: none;
transition: background 0.15s ease, color 0.15s ease;
}
.espace-tab:hover {
background: #faf7ef;
color: var(--brown);
}
.espace-tab.active {
background: var(--gold);
color: #33291D;
}
.espace-tab__badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 22px;
padding: 0 7px;
border-radius: 999px;
background: #1f8a5b;
color: #fff;
font-size: 12px;
font-weight: 800;
}
.espace-tab.active .espace-tab__badge {
background: #33291D;
color: #fff;
}
/* Suggestions de tags : pastilles cliquables sous le champ. */
.tag-suggestions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin-top: 8px;
}
.tag-suggestions__label {
font-size: 12px;
font-weight: 700;
color: var(--muted-2);
margin-right: 2px;
}
.tag-suggestion {
cursor: pointer;
font: inherit;
font-size: 12.5px;
font-weight: 600;
padding: 5px 11px;
border-radius: 999px;
border: 1px dashed #d8cdb4;
background: #faf7ef;
color: var(--ink-soft);
transition: background 0.15s ease, border-color 0.15s ease;
}
.tag-suggestion:hover {
background: #f6efdc;
border-color: var(--gold);
}
.tag-suggestion--all {
border-style: solid;
color: var(--brown);
}
/* Conseil de rédaction sous un champ : discret, mais lu avant de saisir. */
.field-hint {
margin: 6px 0 0;
font-size: 12.5px;
line-height: 1.5;
color: var(--muted-2);
}
/* ---- Studio e-mail Plein R ---- */
.email-workbench {
+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 }
);
}
+22 -7
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";
@@ -8,11 +9,23 @@ import { MembersCarousel } from "@/components/MembersCarousel";
import { StarField } from "@/components/StarField";
import { PromoImage } from "@/components/PromoImage";
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
import { getLivePromotions, getRotatingActiveMembers } from "@/lib/queries";
import { getActiveMemberCount, getLivePromotions, getRotatingActiveMembers } from "@/lib/queries";
import { getSiteSettings, socialLinks } from "@/lib/site-settings";
import { SITE_DESCRIPTION, SITE_TITLE, memberPath, 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)";
@@ -22,10 +35,11 @@ function badgeColor(badge: string | null) {
}
export default async function AccueilPage() {
const [promos, rotatingMembers, settings] = await Promise.all([
const [promos, rotatingMembers, settings, memberCount] = await Promise.all([
getLivePromotions(6),
getRotatingActiveMembers(),
getSiteSettings(),
getActiveMemberCount(),
]);
const socials = socialLinks(settings);
@@ -40,7 +54,7 @@ export default async function AccueilPage() {
>
<SiteHeader active="accueil" />
<div className="container">
<main className="container">
{/* hero */}
<section
className="grid hero-grid"
@@ -221,7 +235,7 @@ export default async function AccueilPage() {
{promos.map((p) => (
<Link
key={p.id}
href={p.memberId ? `/adherents/${p.memberId}` : "#"}
href={p.memberId ? memberPath({ id: p.memberId, name: p.memberName ?? "", city: p.memberCity }) : "#"}
className="lift-card"
style={{
textDecoration: "none",
@@ -437,8 +451,9 @@ export default async function AccueilPage() {
Faites partie de l&apos;aventure Plein R
</h2>
<p style={{ margin: "0 auto 22px", fontSize: 15.5, color: "#f4e6d3", maxWidth: 480 }}>
Rejoignez plus de 120 commerçants et entreprises qui font réseau, se rencontrent et réussissent
ensemble.
{memberCount > 0
? `Rejoignez les ${memberCount} commerçants et entreprises adhérents qui font réseau, se rencontrent et réussissent ensemble.`
: "Rejoignez les commerçants et entreprises qui font réseau, se rencontrent et réussissent ensemble."}
</p>
<MembershipModalButton
label="Devenir adhérent"
@@ -456,7 +471,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,
};
}
+53
View File
@@ -0,0 +1,53 @@
import type { MetadataRoute } from "next";
import { asc, eq } from "drizzle-orm";
import { db } from "@/db";
import { members } from "@/db/schema";
import { getCategoriesInUse } from "@/lib/queries";
import { STATIC_ROUTES, absoluteUrl, categoryPath, 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; name: string; city: string | null }[] = [];
let categoriesInUse: { slug: string }[] = [];
try {
[rows, categoriesInUse] = await Promise.all([
db
.select({ id: members.id, name: members.name, city: members.city })
.from(members)
.where(eq(members.status, "active"))
.orderBy(asc(members.id)),
getCategoriesInUse(),
]);
} catch {
// Base indisponible : on livre au moins les pages statiques.
}
// Pages métier : seulement celles qui ont au moins un adhérent (les autres
// sont en noindex, une page vide n'apporte rien aux moteurs).
const metiers: MetadataRoute.Sitemap = categoriesInUse.map((c) => ({
url: absoluteUrl(base, categoryPath(c.slug)),
changeFrequency: "weekly",
priority: 0.8,
}));
const fiches: MetadataRoute.Sitemap = rows.map((m) => ({
url: absoluteUrl(base, memberPath(m)),
changeFrequency: "weekly",
priority: 0.7,
}));
return [...statics, ...metiers, ...fiches];
}
+39
View File
@@ -0,0 +1,39 @@
import Link from "next/link";
import { categoryPath } from "@/lib/seo";
export type CategoryLink = { slug: string; label: string; accent: string; memberCount: number };
/** Pastilles-liens vers les pages métier : contenu statique, lisible sans JavaScript. */
export function CategoryLinks({ categories, currentSlug }: { categories: CategoryLink[]; currentSlug?: string }) {
return (
<nav aria-label="Parcourir par métier" style={{ display: "flex", flexWrap: "wrap", gap: 9 }}>
{categories.map((c) => {
const active = c.slug === currentSlug;
return (
<Link
key={c.slug}
href={categoryPath(c.slug)}
aria-current={active ? "page" : undefined}
style={{
textDecoration: "none",
fontSize: 13.5,
fontWeight: 600,
padding: "8px 14px",
borderRadius: 999,
border: `1px solid ${active ? "#13324F" : "#e6dcc6"}`,
background: active ? "#13324F" : "#fff",
color: active ? "#fff" : "#3c3322",
display: "inline-flex",
alignItems: "center",
gap: 8,
}}
>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: active ? "#E0A63C" : c.accent, flexShrink: 0 }} />
{c.label}
<span style={{ opacity: 0.6 }}>{c.memberCount}</span>
</Link>
);
})}
</nav>
);
}
+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) }}
/>
))}
</>
);
}
+66
View File
@@ -0,0 +1,66 @@
import Link from "next/link";
import { VitrineImage } from "@/components/VitrineImage";
import { memberPath } from "@/lib/seo";
export type MemberCardData = {
id: number;
name: string;
description: string | null;
city: string | null;
address: string | null;
coverUrl: string | null;
logoUrl: string | null;
categoryLabel: string | null;
accent: string | null;
hasPromo?: boolean;
promoBadge?: string | null;
};
const STRIPE_WARM =
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
const STRIPE_COOL =
"repeating-linear-gradient(45deg,#eef0ec,#eef0ec 12px,#e2e8e6 12px,#e2e8e6 24px)";
/**
* Carte d'un adhérent dans une grille (annuaire, page métier). Composant sans
* état : il se rend aussi bien côté serveur que dans le filtre client.
*/
export function MemberCard({ m, index = 0 }: { m: MemberCardData; index?: number }) {
return (
<Link
href={memberPath(m)}
className="lift-card"
style={{ textDecoration: "none", color: "inherit", background: "#fff", border: "1px solid #e6dcc6", borderRadius: 18, overflow: "hidden", display: "flex", flexDirection: "column" }}
>
<VitrineImage
coverUrl={m.coverUrl}
logoUrl={m.logoUrl}
height={150}
stripe={index % 2 === 0 ? STRIPE_WARM : STRIPE_COOL}
>
{m.categoryLabel && (
<span style={{ position: "absolute", top: 12, left: 12, background: m.accent ?? "#9a6638", color: "#fff", borderRadius: 999, padding: "5px 12px", fontSize: 11, fontWeight: 700 }}>
{m.categoryLabel}
</span>
)}
{m.hasPromo && m.promoBadge && (
<span className="font-display" style={{ position: "absolute", top: 0, right: 0, background: "#d8472b", color: "#fff", fontWeight: 800, fontSize: 12.5, padding: "6px 12px", borderRadius: "0 0 0 14px" }}>
{m.promoBadge}
</span>
)}
</VitrineImage>
<div style={{ padding: "16px 18px 18px", display: "flex", flexDirection: "column", flex: 1 }}>
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 18, margin: 0, color: "#26201a" }}>
{m.name}
</h3>
<p style={{ margin: "6px 0 12px", fontSize: 13, color: "#8c8068", lineHeight: 1.5, flex: 1 }}>
{m.description}
</p>
<div style={{ display: "flex", alignItems: "center", gap: 7, fontSize: 12.5, color: "#6c6150", borderTop: "1px solid #f0e8d6", paddingTop: 11 }}>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: m.accent ?? "#E0A63C" }} />
{[m.address, m.city].filter(Boolean).join(" · ")}
</div>
</div>
</Link>
);
}
+2 -1
View File
@@ -3,6 +3,7 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import { VitrineImage } from "./VitrineImage";
import { memberPath } from "@/lib/seo";
export type CarouselMember = {
id: number;
@@ -24,7 +25,7 @@ const FADE_MS = 450;
function Card({ m, visible, index }: { m: CarouselMember; visible: boolean; index: number }) {
return (
<Link
href={`/adherents/${m.id}`}
href={memberPath(m)}
className="lift-card"
style={{
background: "#fff",
+1 -1
View File
@@ -61,7 +61,7 @@ export async function SiteFooter() {
rel="noopener noreferrer"
aria-label={`Plein R sur ${s.label}`}
>
<span style={{ color: SOCIAL_BRAND[s.network], display: "inline-flex" }}>
<span style={{ color: SOCIAL_BRAND[s.network], display: "inline-flex", width: 17, flexShrink: 0 }}>
<SocialIcon network={s.network} size={17} />
</span>
{s.label}
+111
View File
@@ -0,0 +1,111 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { formatTags, parseTags, suggestTags } from "@/lib/tags";
type CategoryOption = { id: number; slug: string; label: string };
/**
* Champ « tags » avec suggestions automatiques.
*
* Les suggestions se recalculent à chaque saisie à partir de la catégorie (le
* `<select name="categoryId">` du formulaire, ou une catégorie fixe), de la
* commune et de la description du même formulaire. Un clic ajoute le mot ;
* champ laissé vide = les suggestions sont appliquées à l'enregistrement
* (`autoTags` côté serveur), l'adhérent garde donc toujours la main.
*
* Le champ est volontairement **non contrôlé** : l'écouteur natif posé sur le
* formulaire provoque un rendu React au milieu de l'événement de saisie, et un
* champ contrôlé y perdrait la frappe en cours.
*/
export function TagsField({
name = "tags",
defaultValue = "",
placeholder = "Levain naturel, Produits locaux, Fait maison",
categories,
fixedCategory,
}: {
name?: string;
defaultValue?: string | null;
placeholder?: string;
/** Formulaire staff : catégories sélectionnables (le select s'appelle `categoryId`). */
categories?: CategoryOption[];
/** Espace adhérent : catégorie non modifiable. */
fixedCategory?: { slug: string | null; label: string | null } | null;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const [current, setCurrent] = useState<string[]>(() => parseTags(defaultValue));
const [context, setContext] = useState({ categoryId: "", city: "", description: "" });
// Lit les autres champs du formulaire et se tient à jour à chaque frappe.
useEffect(() => {
const form = inputRef.current?.form;
if (!form) return;
const read = () => {
const data = new FormData(form);
setContext({
categoryId: String(data.get("categoryId") ?? ""),
city: String(data.get("city") ?? ""),
description: String(data.get("description") ?? ""),
});
setCurrent(parseTags(inputRef.current?.value));
};
read();
form.addEventListener("input", read);
form.addEventListener("change", read);
return () => {
form.removeEventListener("input", read);
form.removeEventListener("change", read);
};
}, []);
const suggestions = useMemo(() => {
const category = fixedCategory ?? categories?.find((c) => String(c.id) === context.categoryId) ?? null;
const all = suggestTags({
categorySlug: category?.slug,
categoryLabel: category?.label,
city: context.city,
description: context.description,
});
const have = new Set(current.map((t) => t.toLowerCase()));
return all.filter((t) => !have.has(t.toLowerCase()));
}, [fixedCategory, categories, context, current]);
function setTags(tags: string[]) {
const input = inputRef.current;
if (!input) return;
input.value = formatTags(tags);
setCurrent(parseTags(input.value));
}
return (
<div>
<input
ref={inputRef}
name={name}
defaultValue={defaultValue ?? ""}
className="field"
placeholder={placeholder}
autoComplete="off"
/>
{suggestions.length > 0 && (
<div className="tag-suggestions" aria-label="Suggestions de tags">
<span className="tag-suggestions__label">Suggestions :</span>
{suggestions.map((tag) => (
<button key={tag} type="button" className="tag-suggestion" onClick={() => setTags([...current, tag])} title={`Ajouter « ${tag} »`}>
+ {tag}
</button>
))}
<button type="button" className="tag-suggestion tag-suggestion--all" onClick={() => setTags([...current, ...suggestions])}>
Tout ajouter
</button>
</div>
)}
<p className="field-hint">
Mots-clés que vos clients recherchent : produits, services, marques, quartier. Laissez vide pour que les
suggestions soient appliquées automatiquement à l&apos;enregistrement. Ils s&apos;affichent sur votre fiche et
sont transmis aux moteurs de recherche.
</p>
</div>
);
}
+36 -21
View File
@@ -6,7 +6,9 @@ const STRIPE_WARM =
/**
* Zone d'image "vitrine" d'un adhérent.
* - Photo de couverture présente -> affichée en plein cadre (cover).
* - Sinon, le logo est affiché centré et réduit (contain ~80%), pas étiré.
* - Sinon, le logo est affiché centré et réduit (contain, ~60 % du cadre) sur
* un fond fait du logo lui-même flouté : le cadre prend les couleurs de la
* marque sans que le logo soit étiré ni ne déborde.
* - Sinon, fond rayé + texte placeholder.
*/
export function VitrineImage({
@@ -43,30 +45,43 @@ export function VitrineImage({
}
if (logoOnly) {
// `height` est fixe sur le cadre : les max-height en % du logo sont donc
// résolus, là où un conteneur intermédiaire sans hauteur les laissait sans
// effet et le logo débordait.
return (
<div style={{ ...base, background: "linear-gradient(160deg,#fffdf8 0%,#f3ecdc 100%)", padding: "min(8%, 22px)" }}>
{/* Cadre thème autour du logo (évite le bloc blanc nu) */}
<div style={{ ...base, background: "linear-gradient(160deg,#fffdf8 0%,#f3ecdc 100%)" }}>
<div
aria-hidden
style={{
background: "#fff",
border: "1px solid #e6dcc6",
borderRadius: 14,
boxShadow: "0 14px 30px -20px rgba(40,30,15,0.35)",
padding: "min(6%, 18px) min(9%, 28px)",
maxWidth: "84%",
maxHeight: "84%",
display: "flex",
alignItems: "center",
justifyContent: "center",
position: "absolute",
inset: 0,
backgroundImage: `url(${logoOnly})`,
backgroundSize: "cover",
backgroundPosition: "center",
filter: "blur(28px) saturate(1.25)",
transform: "scale(1.25)",
opacity: 0.32,
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={logoOnly}
alt=""
style={{ maxWidth: "100%", maxHeight: "100%", objectFit: "contain", display: "block" }}
/>
</div>
/>
<div
aria-hidden
style={{ position: "absolute", inset: 0, background: "linear-gradient(180deg, rgba(255,253,248,0.35) 0%, rgba(246,242,232,0.6) 100%)" }}
/>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={logoOnly}
alt=""
style={{
position: "relative",
maxWidth: "62%",
maxHeight: "62%",
width: "auto",
height: "auto",
objectFit: "contain",
display: "block",
filter: "drop-shadow(0 12px 24px rgba(40,30,15,0.18))",
}}
/>
{children}
</div>
);
+151
View File
@@ -0,0 +1,151 @@
/**
* Référentiel des catégories de métiers.
*
* Source unique pour le seed, la migration d'insertion (drizzle/0012_*) et le
* bouton « Ajouter » du backend. Les slugs sont stables : ils servent de clé
* d'unicité (`ON CONFLICT (slug)`) et ne doivent jamais être renommés, sinon la
* catégorie serait dupliquée. Un libellé renommé depuis le backend est conservé :
* la migration ne touche qu'à l'ordre d'affichage (`sort`).
*/
export const CATEGORY_PALETTE = [
{ accent: "#E0A63C", tint: "#f6efdc" },
{ accent: "#6FB0C6", tint: "#e7f0f3" },
{ accent: "#9a6638", tint: "#f4ebda" },
{ accent: "#2C6FB3", tint: "#eaf0f6" },
{ accent: "#5a7a5a", tint: "#eef0ec" },
{ accent: "#7a6f9c", tint: "#efe9f3" },
{ accent: "#c98a2e", tint: "#f7efe0" },
{ accent: "#3f8aa3", tint: "#e6eff2" },
] as const;
export type CategoryDefinition = { slug: string; label: string };
// NB : les slugs historiques (alimentation, boulangerie, restauration, mode-beaute,
// artisanat, services, sante) sont conservés pour ne pas casser les liens existants.
export const CATEGORY_REFERENTIAL: CategoryDefinition[] = [
// ---- Bouche & alimentation ----
{ slug: "alimentation", label: "Alimentation / Épicerie" },
{ slug: "epicerie-fine", label: "Épicerie fine – Produits du terroir" },
{ slug: "supermarche", label: "Supermarché – Grande distribution" },
{ slug: "boulangerie", label: "Boulangerie – Pâtisserie" },
{ slug: "boucherie-charcuterie", label: "Boucherie – Charcuterie" },
{ slug: "poissonnerie", label: "Poissonnerie" },
{ slug: "primeur", label: "Primeur – Fruits & Légumes" },
{ slug: "fromagerie", label: "Fromagerie – Crèmerie" },
{ slug: "cave", label: "Cave – Vins & Spiritueux" },
{ slug: "brasserie-artisanale", label: "Brasserie artisanale – Boissons" },
{ slug: "chocolaterie", label: "Chocolaterie – Confiserie" },
{ slug: "glacier-salon-de-the", label: "Glacier – Salon de thé" },
{ slug: "traiteur", label: "Traiteur" },
{ slug: "restauration", label: "Restauration" },
{ slug: "pizzeria", label: "Pizzeria" },
{ slug: "restauration-rapide", label: "Restauration rapide – Food truck" },
{ slug: "cafe-bar", label: "Café – Bar – Brasserie" },
// ---- Mode, beauté & soin ----
{ slug: "mode-beaute", label: "Mode & Beauté" },
{ slug: "mode-vetements", label: "Mode & Vêtements" },
{ slug: "lingerie", label: "Lingerie" },
{ slug: "vetements-enfants", label: "Vêtements enfants" },
{ slug: "chaussures-maroquinerie", label: "Chaussures – Maroquinerie" },
{ slug: "bijouterie", label: "Bijouterie – Horlogerie" },
{ slug: "coiffure", label: "Coiffure" },
{ slug: "barbier", label: "Barbier" },
{ slug: "esthetique", label: "Esthétique – Institut" },
{ slug: "onglerie", label: "Onglerie – Prothésie ongulaire" },
{ slug: "parfumerie", label: "Parfumerie" },
{ slug: "bien-etre", label: "Bien-être – Spa – Massage" },
{ slug: "optique", label: "Optique" },
{ slug: "tatouage", label: "Tatouage – Piercing" },
// ---- Maison, équipement & décoration ----
{ slug: "bazar-discount", label: "Bazar – Discount – Équipement de la maison" },
{ slug: "decoration", label: "Décoration – Ameublement" },
{ slug: "cuisiniste", label: "Cuisines – Salles de bain" },
{ slug: "literie", label: "Literie" },
{ slug: "arts-de-la-table", label: "Arts de la table – Cadeaux" },
{ slug: "electromenager-multimedia", label: "Électroménager – Multimédia" },
{ slug: "fleuriste", label: "Fleuriste" },
{ slug: "jardinerie", label: "Jardinerie – Motoculture" },
{ slug: "bricolage-jardinage", label: "Bricolage – Jardinage" },
{ slug: "animalerie", label: "Animalerie – Services animaliers" },
// ---- Artisanat & bâtiment ----
{ slug: "artisanat", label: "Artisanat" },
{ slug: "batiment", label: "Bâtiment – Rénovation" },
{ slug: "architecte", label: "Architecte – Maître d'œuvre" },
{ slug: "maconnerie", label: "Maçonnerie – Gros œuvre" },
{ slug: "couverture-toiture", label: "Couverture – Toiture – Zinguerie" },
{ slug: "plomberie", label: "Plomberie – Chauffage" },
{ slug: "electricite", label: "Électricité" },
{ slug: "isolation-energie", label: "Isolation – Énergies renouvelables" },
{ slug: "menuiserie", label: "Menuiserie – Ébénisterie" },
{ slug: "serrurerie-metallerie", label: "Serrurerie – Métallerie" },
{ slug: "carrelage-sols", label: "Carrelage – Revêtements de sols" },
{ slug: "peinture", label: "Peinture – Décoration intérieure" },
{ slug: "paysagiste", label: "Paysagiste – Espaces verts" },
{ slug: "depannage-multiservices", label: "Dépannage – Multiservices" },
// ---- Auto & mobilité ----
{ slug: "automobile", label: "Automobile – Garage" },
{ slug: "carrosserie", label: "Carrosserie" },
{ slug: "controle-technique", label: "Contrôle technique" },
{ slug: "station-service-lavage", label: "Station-service – Lavage auto" },
{ slug: "location-vehicules", label: "Location de véhicules" },
{ slug: "cycles-motos", label: "Cycles – Motos" },
{ slug: "auto-ecole", label: "Auto-école" },
{ slug: "taxi-vtc", label: "Taxi – VTC" },
// ---- Santé ----
{ slug: "sante", label: "Santé – Pharmacie" },
{ slug: "medical", label: "Médical – Paramédical" },
{ slug: "audioprothese", label: "Audioprothèse" },
{ slug: "veterinaire", label: "Vétérinaire" },
// ---- Banque, assurance & immobilier ----
{ slug: "banque-assurance", label: "Banque – Assurance" },
{ slug: "assurance", label: "Assurance – Mutuelle – Prévoyance" },
{ slug: "courtier-credit", label: "Courtier – Crédit – Financement" },
{ slug: "immobilier", label: "Immobilier" },
{ slug: "diagnostic-immobilier", label: "Diagnostic immobilier – Expertise" },
// ---- Services aux particuliers ----
{ slug: "services", label: "Services" },
{ slug: "services-a-la-personne", label: "Services à la personne – Aide à domicile" },
{ slug: "pressing-retouches", label: "Pressing – Retouches – Cordonnerie" },
{ slug: "photographe", label: "Photographe – Vidéo" },
{ slug: "pompes-funebres", label: "Pompes funèbres" },
{ slug: "agence-voyages", label: "Agence de voyages" },
{ slug: "demenagement", label: "Déménagement – Garde-meubles" },
// ---- Services aux entreprises ----
{ slug: "comptabilite", label: "Comptabilité – Gestion" },
{ slug: "juridique", label: "Juridique – Notaire" },
{ slug: "conseil", label: "Conseil aux entreprises" },
{ slug: "communication", label: "Communication – Web – Marketing" },
{ slug: "imprimerie-enseignes", label: "Imprimerie – Enseignes – Signalétique" },
{ slug: "informatique", label: "Informatique – Téléphonie" },
{ slug: "nettoyage", label: "Nettoyage – Entretien" },
{ slug: "transport", label: "Transport – Logistique" },
{ slug: "securite", label: "Sécurité" },
{ slug: "coworking", label: "Coworking – Bureaux" },
{ slug: "interim-rh", label: "Intérim – Ressources humaines" },
{ slug: "recyclage-dechets", label: "Recyclage – Gestion des déchets" },
// ---- Culture, loisirs & formation ----
{ slug: "librairie-papeterie", label: "Librairie – Papeterie – Presse" },
{ slug: "tabac-presse", label: "Tabac – Presse – Loto" },
{ slug: "jouets-puericulture", label: "Jouets – Puériculture" },
{ slug: "sport-loisirs", label: "Sport – Loisirs" },
{ slug: "articles-de-sport", label: "Articles de sport" },
{ slug: "fitness", label: "Salle de sport – Fitness" },
{ slug: "culture-arts", label: "Culture – Arts" },
{ slug: "education-formation", label: "Éducation – Formation" },
{ slug: "tourisme-hotellerie", label: "Tourisme – Hôtellerie" },
{ slug: "evenementiel", label: "Événementiel" },
// ---- Entreprise, production & institutions ----
{ slug: "industrie", label: "Industrie – Production" },
{ slug: "agriculture", label: "Agriculture – Producteur local" },
{ slug: "partenaire-institutionnel", label: "Partenaire institutionnel – Collectivité" },
];
/** Lignes prêtes à insérer : couleur tournante et ordre d'affichage. */
export function categoryRows() {
return CATEGORY_REFERENTIAL.map((c, i) => ({
...c,
...CATEGORY_PALETTE[i % CATEGORY_PALETTE.length],
sort: i + 1,
}));
}
+4
View File
@@ -52,7 +52,11 @@ export const categories = pgTable("categories", {
export const members = pgTable("members", {
id: serial("id").primaryKey(),
name: varchar("name", { length: 200 }).notNull(),
// E-mail administratif : identifiant de connexion à la création, échanges
// avec l'association. Jamais forcément celui que le commerce veut afficher.
email: varchar("email", { length: 200 }).notNull(),
// E-mail public de la fiche ; à vide, la fiche retombe sur `email`.
contactEmail: varchar("contact_email", { length: 200 }),
categoryId: integer("category_id").references(() => categories.id),
city: varchar("city", { length: 120 }),
address: varchar("address", { length: 240 }),
+5 -87
View File
@@ -5,6 +5,7 @@ import bcrypt from "bcryptjs";
import { randomInt } from "node:crypto";
import { eq } from "drizzle-orm";
import * as schema from "./schema";
import { categoryRows } from "./categories";
import {
DEMO_PASSWORD,
demoActivity,
@@ -40,93 +41,10 @@ async function main() {
console.log("Seeding database…");
// ---- Categories (métiers) — liste complète ----
const PALETTE = [
{ accent: "#E0A63C", tint: "#f6efdc" },
{ accent: "#6FB0C6", tint: "#e7f0f3" },
{ accent: "#9a6638", tint: "#f4ebda" },
{ accent: "#2C6FB3", tint: "#eaf0f6" },
{ accent: "#5a7a5a", tint: "#eef0ec" },
{ accent: "#7a6f9c", tint: "#efe9f3" },
{ accent: "#c98a2e", tint: "#f7efe0" },
{ accent: "#3f8aa3", tint: "#e6eff2" },
];
// NB: les slugs existants (alimentation, boulangerie, restauration, mode-beaute,
// artisanat, services, sante) sont conservés pour ne pas casser les liens existants.
const categoryList: { slug: string; label: string }[] = [
// Bouche & alimentation
{ slug: "alimentation", label: "Alimentation / Épicerie" },
{ slug: "boulangerie", label: "Boulangerie – Pâtisserie" },
{ slug: "boucherie-charcuterie", label: "Boucherie – Charcuterie" },
{ slug: "poissonnerie", label: "Poissonnerie" },
{ slug: "primeur", label: "Primeur – Fruits & Légumes" },
{ slug: "fromagerie", label: "Fromagerie – Crèmerie" },
{ slug: "cave", label: "Cave – Vins & Spiritueux" },
{ slug: "chocolaterie", label: "Chocolaterie – Confiserie" },
{ slug: "traiteur", label: "Traiteur" },
{ slug: "restauration", label: "Restauration" },
{ slug: "restauration-rapide", label: "Restauration rapide" },
{ slug: "cafe-bar", label: "Café – Bar – Brasserie" },
// Mode, beauté & soin
{ slug: "mode-beaute", label: "Mode & Beauté" },
{ slug: "mode-vetements", label: "Mode & Vêtements" },
{ slug: "chaussures-maroquinerie", label: "Chaussures – Maroquinerie" },
{ slug: "bijouterie", label: "Bijouterie – Horlogerie" },
{ slug: "coiffure", label: "Coiffure" },
{ slug: "esthetique", label: "Esthétique – Institut" },
{ slug: "parfumerie", label: "Parfumerie" },
{ slug: "bien-etre", label: "Bien-être – Spa – Massage" },
{ slug: "optique", label: "Optique" },
{ slug: "tatouage", label: "Tatouage – Piercing" },
// Maison, déco & artisanat / bâtiment
{ slug: "artisanat", label: "Artisanat" },
{ slug: "decoration", label: "Décoration – Ameublement" },
{ slug: "fleuriste", label: "Fleuriste" },
{ slug: "bricolage-jardinage", label: "Bricolage – Jardinage" },
{ slug: "batiment", label: "Bâtiment – Rénovation" },
{ slug: "plomberie", label: "Plomberie – Chauffage" },
{ slug: "electricite", label: "Électricité" },
{ slug: "menuiserie", label: "Menuiserie – Ébénisterie" },
{ slug: "peinture", label: "Peinture – Décoration intérieure" },
{ slug: "paysagiste", label: "Paysagiste – Espaces verts" },
// Auto & mobilité
{ slug: "automobile", label: "Automobile – Garage" },
{ slug: "carrosserie", label: "Carrosserie" },
{ slug: "cycles-motos", label: "Cycles – Motos" },
// Santé
{ slug: "sante", label: "Santé – Pharmacie" },
{ slug: "medical", label: "Médical – Paramédical" },
{ slug: "audioprothese", label: "Audioprothèse" },
// Services aux particuliers & entreprises
{ slug: "services", label: "Services" },
{ slug: "banque-assurance", label: "Banque – Assurance" },
{ slug: "immobilier", label: "Immobilier" },
{ slug: "comptabilite", label: "Comptabilité – Gestion" },
{ slug: "juridique", label: "Juridique – Notaire" },
{ slug: "communication", label: "Communication – Web – Marketing" },
{ slug: "informatique", label: "Informatique – Téléphonie" },
{ slug: "nettoyage", label: "Nettoyage – Entretien" },
{ slug: "transport", label: "Transport – Logistique" },
{ slug: "securite", label: "Sécurité" },
{ slug: "coworking", label: "Coworking – Bureaux" },
{ slug: "interim-rh", label: "Intérim – Ressources humaines" },
// Loisirs, culture & formation
{ slug: "sport-loisirs", label: "Sport – Loisirs" },
{ slug: "fitness", label: "Salle de sport – Fitness" },
{ slug: "culture-arts", label: "Culture – Arts" },
{ slug: "education-formation", label: "Éducation – Formation" },
{ slug: "tourisme-hotellerie", label: "Tourisme – Hôtellerie" },
{ slug: "evenementiel", label: "Événementiel" },
{ slug: "animalerie", label: "Animalerie – Services animaliers" },
// Entreprise & production
{ slug: "industrie", label: "Industrie – Production" },
{ slug: "agriculture", label: "Agriculture – Producteur local" },
];
const categoryData = categoryList.map((c, i) => ({
...c,
...PALETTE[i % PALETTE.length],
sort: i + 1,
}));
// ---- Categories (métiers) : référentiel partagé (src/db/categories.ts) ----
// La migration 0012 insère déjà ce référentiel ; le seed le rejoue par sécurité
// pour une base créée sans migration, sans jamais écraser un libellé existant.
const categoryData = categoryRows();
for (const c of categoryData) {
await db.insert(categories).values(c).onConflictDoNothing({ target: categories.slug });
+262
View File
@@ -0,0 +1,262 @@
/**
* Catégories de promotion : le **type de produit ou de service** mis en avant,
* indépendant du métier de l'adhérent (une boulangerie peut promouvoir un
* goûter, un garage des pneus). Affichées en pastille sur la carte de la promo.
*
* Module pur, groupé pour un `<select>` à `<optgroup>`.
*/
export type PromoCategoryGroup = { label: string; items: string[] };
export const PROMO_CATEGORY_GROUPS: PromoCategoryGroup[] = [
{
label: "Alimentation",
items: [
"Pain & viennoiseries",
"Pâtisserie & desserts",
"Chocolats & confiserie",
"Viandes & charcuterie",
"Poissons & fruits de mer",
"Fruits & légumes",
"Fromages & produits laitiers",
"Épicerie & produits du terroir",
"Produits bio & locaux",
"Vins, bières & spiritueux",
"Café, thé & boissons",
"Plats préparés & traiteur",
],
},
{
label: "Restauration & sorties",
items: [
"Menu & formule du midi",
"Repas & dîner",
"Pizzas & restauration rapide",
"Brunch, goûter & glaces",
"Apéritif & afterwork",
"Soirée & animation",
],
},
{
label: "Mode & accessoires",
items: [
"Vêtements femme",
"Vêtements homme",
"Vêtements enfant",
"Lingerie & maillots",
"Chaussures",
"Sacs & maroquinerie",
"Bijoux & montres",
"Lunettes & solaires",
],
},
{
label: "Beauté & bien-être",
items: [
"Coiffure",
"Barbier",
"Soins visage & corps",
"Épilation",
"Ongles & manucure",
"Maquillage & parfums",
"Massage & spa",
"Tatouage & piercing",
],
},
{
label: "Maison & équipement",
items: [
"Meubles & décoration",
"Literie",
"Cuisine & salle de bain",
"Électroménager",
"TV, high-tech & téléphonie",
"Linge de maison & arts de la table",
"Luminaires",
"Bazar & petit équipement",
"Fleurs & plantes",
"Jardin & extérieur",
"Bricolage & outillage",
],
},
{
label: "Auto & mobilité",
items: [
"Entretien & révision",
"Pneus",
"Carrosserie & pare-brise",
"Contrôle technique",
"Carburant & lavage",
"Vélos & motos",
"Location de véhicule",
"Auto-école & permis",
],
},
{
label: "Santé, famille & animaux",
items: [
"Pharmacie & parapharmacie",
"Optique & audition",
"Puériculture & bébé",
"Jouets & jeux",
"Animalerie & alimentation animale",
"Vétérinaire & toilettage",
],
},
{
label: "Services",
items: [
"Travaux & rénovation",
"Plomberie, chauffage & électricité",
"Nettoyage & entretien",
"Aide à domicile",
"Pressing, retouches & cordonnerie",
"Photographe & vidéo",
"Informatique & réparation",
"Communication, web & impression",
"Comptabilité, juridique & conseil",
"Banque, assurance & crédit",
"Immobilier",
"Déménagement & transport",
"Événementiel & location de matériel",
],
},
{
label: "Loisirs, culture & formation",
items: [
"Sport & fitness",
"Articles de sport",
"Livres, presse & papeterie",
"Culture, arts & spectacles",
"Cours & formation",
"Voyages & séjours",
"Hôtel & hébergement",
],
},
{
label: "Offres spéciales",
items: [
"Offre de bienvenue",
"Carte de fidélité",
"Parrainage",
"Déstockage & fin de série",
"Nouveauté",
"Autre",
],
},
];
export const PROMO_CATEGORIES: string[] = PROMO_CATEGORY_GROUPS.flatMap((g) => g.items);
/** Catégorie de promotion proposée par défaut selon le métier de l'adhérent. */
const DEFAULT_BY_MEMBER_CATEGORY: Record<string, string> = {
alimentation: "Épicerie & produits du terroir",
"epicerie-fine": "Épicerie & produits du terroir",
supermarche: "Épicerie & produits du terroir",
boulangerie: "Pain & viennoiseries",
"boucherie-charcuterie": "Viandes & charcuterie",
poissonnerie: "Poissons & fruits de mer",
primeur: "Fruits & légumes",
fromagerie: "Fromages & produits laitiers",
cave: "Vins, bières & spiritueux",
"brasserie-artisanale": "Vins, bières & spiritueux",
chocolaterie: "Chocolats & confiserie",
"glacier-salon-de-the": "Brunch, goûter & glaces",
traiteur: "Plats préparés & traiteur",
restauration: "Menu & formule du midi",
pizzeria: "Pizzas & restauration rapide",
"restauration-rapide": "Pizzas & restauration rapide",
"cafe-bar": "Apéritif & afterwork",
"mode-beaute": "Vêtements femme",
"mode-vetements": "Vêtements femme",
lingerie: "Lingerie & maillots",
"vetements-enfants": "Vêtements enfant",
"chaussures-maroquinerie": "Chaussures",
bijouterie: "Bijoux & montres",
coiffure: "Coiffure",
barbier: "Barbier",
esthetique: "Soins visage & corps",
onglerie: "Ongles & manucure",
parfumerie: "Maquillage & parfums",
"bien-etre": "Massage & spa",
optique: "Lunettes & solaires",
tatouage: "Tatouage & piercing",
"bazar-discount": "Bazar & petit équipement",
decoration: "Meubles & décoration",
cuisiniste: "Cuisine & salle de bain",
literie: "Literie",
"arts-de-la-table": "Linge de maison & arts de la table",
"electromenager-multimedia": "Électroménager",
fleuriste: "Fleurs & plantes",
jardinerie: "Jardin & extérieur",
"bricolage-jardinage": "Bricolage & outillage",
animalerie: "Animalerie & alimentation animale",
artisanat: "Meubles & décoration",
batiment: "Travaux & rénovation",
architecte: "Travaux & rénovation",
maconnerie: "Travaux & rénovation",
"couverture-toiture": "Travaux & rénovation",
plomberie: "Plomberie, chauffage & électricité",
electricite: "Plomberie, chauffage & électricité",
"isolation-energie": "Travaux & rénovation",
menuiserie: "Travaux & rénovation",
"serrurerie-metallerie": "Travaux & rénovation",
"carrelage-sols": "Travaux & rénovation",
peinture: "Travaux & rénovation",
paysagiste: "Jardin & extérieur",
"depannage-multiservices": "Travaux & rénovation",
automobile: "Entretien & révision",
carrosserie: "Carrosserie & pare-brise",
"controle-technique": "Contrôle technique",
"station-service-lavage": "Carburant & lavage",
"location-vehicules": "Location de véhicule",
"cycles-motos": "Vélos & motos",
"auto-ecole": "Auto-école & permis",
"taxi-vtc": "Déménagement & transport",
sante: "Pharmacie & parapharmacie",
medical: "Pharmacie & parapharmacie",
audioprothese: "Optique & audition",
veterinaire: "Vétérinaire & toilettage",
"banque-assurance": "Banque, assurance & crédit",
assurance: "Banque, assurance & crédit",
"courtier-credit": "Banque, assurance & crédit",
immobilier: "Immobilier",
"diagnostic-immobilier": "Immobilier",
services: "Autre",
"services-a-la-personne": "Aide à domicile",
"pressing-retouches": "Pressing, retouches & cordonnerie",
photographe: "Photographe & vidéo",
"pompes-funebres": "Autre",
"agence-voyages": "Voyages & séjours",
demenagement: "Déménagement & transport",
comptabilite: "Comptabilité, juridique & conseil",
juridique: "Comptabilité, juridique & conseil",
conseil: "Comptabilité, juridique & conseil",
communication: "Communication, web & impression",
"imprimerie-enseignes": "Communication, web & impression",
informatique: "Informatique & réparation",
nettoyage: "Nettoyage & entretien",
transport: "Déménagement & transport",
securite: "Autre",
coworking: "Autre",
"interim-rh": "Autre",
"recyclage-dechets": "Autre",
"librairie-papeterie": "Livres, presse & papeterie",
"tabac-presse": "Livres, presse & papeterie",
"jouets-puericulture": "Jouets & jeux",
"sport-loisirs": "Sport & fitness",
"articles-de-sport": "Articles de sport",
fitness: "Sport & fitness",
"culture-arts": "Culture, arts & spectacles",
"education-formation": "Cours & formation",
"tourisme-hotellerie": "Hôtel & hébergement",
evenementiel: "Événementiel & location de matériel",
industrie: "Autre",
agriculture: "Produits bio & locaux",
"partenaire-institutionnel": "Autre",
};
export function defaultPromoCategory(memberCategorySlug: string | null | undefined): string {
const found = memberCategorySlug ? DEFAULT_BY_MEMBER_CATEGORY[memberCategorySlug] : undefined;
return found && PROMO_CATEGORIES.includes(found) ? found : "Autre";
}
+53
View File
@@ -69,6 +69,7 @@ export async function getLivePromotions(limit = 6) {
validUntil: promotions.validUntil,
memberId: promotions.memberId,
memberName: members.name,
memberCity: members.city,
})
.from(promotions)
.leftJoin(members, eq(promotions.memberId, members.id))
@@ -84,6 +85,7 @@ export async function getPublicMember(id: number) {
id: members.id,
name: members.name,
email: members.email,
contactEmail: members.contactEmail,
description: members.description,
city: members.city,
address: members.address,
@@ -97,6 +99,7 @@ export async function getPublicMember(id: number) {
hours: members.hours,
tags: members.tags,
categoryLabel: categories.label,
categorySlug: categories.slug,
accent: categories.accent,
tint: categories.tint,
})
@@ -165,3 +168,53 @@ export async function getHighlightedMembers(limit = 3) {
.limit(limit);
return rows;
}
/** Nombre d'adhérents validés (statut `active`) : affiché sur l'accueil. */
export async function getActiveMemberCount(): Promise<number> {
const [row] = await db
.select({ total: count(members.id) })
.from(members)
.where(eq(members.status, "active"));
return Number(row?.total ?? 0);
}
/** Une catégorie par son slug d'URL, ou `null`. */
export async function getCategoryBySlug(slug: string) {
const [row] = await db
.select({
id: categories.id,
slug: categories.slug,
label: categories.label,
accent: categories.accent,
tint: categories.tint,
})
.from(categories)
.where(eq(categories.slug, slug));
return row ?? null;
}
/** Adhérents actifs d'une catégorie, pour la page métier de l'annuaire. */
export async function getActiveMembersByCategory(categoryId: number) {
return db
.select({
id: members.id,
name: members.name,
description: members.description,
city: members.city,
address: members.address,
coverUrl: members.coverUrl,
logoUrl: members.logoUrl,
categoryLabel: categories.label,
accent: categories.accent,
})
.from(members)
.leftJoin(categories, eq(members.categoryId, categories.id))
.where(and(eq(members.status, "active"), eq(members.categoryId, categoryId)))
.orderBy(asc(members.name));
}
/** Catégories ayant au moins un adhérent actif, avec leur effectif. */
export async function getCategoriesInUse() {
const rows = await getCategoriesWithCounts(1000);
return rows.filter((c) => Number(c.memberCount) > 0);
}
+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;
}
}
+415
View File
@@ -0,0 +1,415 @@
import type { Metadata } from "next";
import type { DaySchedule, WeekDayKey } from "@/lib/member-profile";
import type { SiteSettings, SocialLink } from "@/lib/site-settings";
import { slugify } from "@/lib/slug";
/**
* 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}`}`;
}
export type MemberPathInput = { id: number; name: string; city?: string | null };
/**
* Chemin canonique d'une fiche adhérent : `/adherents/12-au-bon-pain-frouard`.
*
* L'identifiant reste en tête : il suffit à retrouver la fiche, et le nom qui
* suit n'est là que pour les moteurs et les lecteurs. Toute autre écriture
* (`/adherents/12`, ancien nom…) est redirigée en 301 vers cette forme par la
* page, donc un renommage ne casse aucun lien.
*/
export function memberPath(member: MemberPathInput): string {
const slug = slugify([member.name, member.city ?? ""].filter(Boolean).join(" "));
return slug ? `/adherents/${member.id}-${slug}` : `/adherents/${member.id}`;
}
/** Identifiant numérique en tête du paramètre d'URL (`12-au-bon-pain` → 12), sinon `null`. */
export function parseMemberParam(param: string): number | null {
const match = /^(\d+)(?:-|$)/.exec(param);
if (!match) return null;
const id = Number(match[1]);
return Number.isSafeInteger(id) && id > 0 ? id : null;
}
/** Chemin d'une page métier de l'annuaire. */
export function categoryPath(slug: string): string {
return `/annuaire/${slug}`;
}
/**
* 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));
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 = MemberPathInput;
/** 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)),
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,
};
}
+11
View File
@@ -0,0 +1,11 @@
/** Slug ASCII minuscule : accents retirés, tout le reste devient un tiret. */
export function slugify(input: string, maxLength = 70): string {
return input
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, maxLength)
.replace(/-+$/g, "");
}
+5 -1
View File
@@ -15,6 +15,7 @@ import {
siteUrl,
type SocialNetwork,
} from "./social-accounts";
import { memberPath } from "./seo";
export {
configuredNetworks,
@@ -37,12 +38,15 @@ export type PromoForSharing = {
imageUrl: string | null;
memberId: number | null;
memberName: string | null;
memberCity?: string | null;
};
export async function promoLink(promo: PromoForSharing): Promise<string | null> {
const base = await siteUrl();
if (!base) return null;
return promo.memberId ? `${base}/adherents/${promo.memberId}` : `${base}/#promotions`;
return promo.memberId
? `${base}${memberPath({ id: promo.memberId, name: promo.memberName ?? "", city: promo.memberCity })}`
: `${base}/#promotions`;
}
/** Texte du post, commun aux deux réseaux. */
+244
View File
@@ -0,0 +1,244 @@
/**
* Tags automatiques des fiches adhérents.
*
* Module **pur** (aucun accès base, utilisable côté client comme côté serveur) :
* - un vocabulaire de mots-clés par métier (slug de catégorie) ;
* - un vocabulaire transversal détecté dans la description (fait maison, bio,
* livraison, sur rendez-vous…) ;
* - `suggestTags()` qui combine métier, commune et description ;
* - `autoTags()` qui n'intervient que si l'adhérent n'a rien saisi.
*
* Les tags s'affichent sur la fiche publique et partent dans les données
* structurées (`LocalBusiness.keywords`) : ce sont des mots que les clients
* tapent dans Google, pas des étiquettes internes.
*/
export const MAX_TAGS = 10;
/** Mots-clés par métier. Les slugs sont ceux de `src/db/categories.ts`. */
export const CATEGORY_TAGS: Record<string, string[]> = {
// Bouche & alimentation
alimentation: ["Épicerie", "Produits frais", "Produits locaux", "Dépannage"],
"epicerie-fine": ["Épicerie fine", "Produits du terroir", "Cadeaux gourmands", "Vins"],
supermarche: ["Supermarché", "Courses", "Drive", "Produits frais"],
boulangerie: ["Boulangerie", "Pain", "Viennoiseries", "Pâtisserie", "Sandwichs"],
"boucherie-charcuterie": ["Boucherie", "Charcuterie", "Viande locale", "Plats cuisinés"],
poissonnerie: ["Poissonnerie", "Poisson frais", "Fruits de mer", "Plateaux"],
primeur: ["Primeur", "Fruits et légumes", "Produits de saison", "Producteurs locaux"],
fromagerie: ["Fromagerie", "Fromages", "Crèmerie", "Plateaux de fromages"],
cave: ["Cave à vins", "Vins", "Spiritueux", "Bières", "Conseils"],
"brasserie-artisanale": ["Brasserie artisanale", "Bières locales", "Boissons", "Coffrets"],
chocolaterie: ["Chocolaterie", "Chocolats", "Confiserie", "Cadeaux"],
"glacier-salon-de-the": ["Glacier", "Salon de thé", "Glaces artisanales", "Goûter"],
traiteur: ["Traiteur", "Plats à emporter", "Buffets", "Événements", "Repas d'entreprise"],
restauration: ["Restaurant", "Cuisine maison", "Menu du jour", "Repas de groupe"],
pizzeria: ["Pizzeria", "Pizzas", "À emporter", "Livraison"],
"restauration-rapide": ["Restauration rapide", "Snack", "Burgers", "À emporter", "Food truck"],
"cafe-bar": ["Café", "Bar", "Brasserie", "Terrasse", "Afterwork"],
// Mode, beauté & soin
"mode-beaute": ["Mode", "Beauté", "Prêt-à-porter", "Accessoires"],
"mode-vetements": ["Vêtements", "Prêt-à-porter", "Mode femme", "Mode homme", "Accessoires"],
lingerie: ["Lingerie", "Sous-vêtements", "Maillots de bain", "Conseil"],
"vetements-enfants": ["Vêtements enfants", "Mode enfant", "Puériculture", "Cadeaux naissance"],
"chaussures-maroquinerie": ["Chaussures", "Maroquinerie", "Sacs", "Cordonnerie"],
bijouterie: ["Bijouterie", "Bijoux", "Montres", "Réparation", "Gravure"],
coiffure: ["Coiffeur", "Coupe", "Couleur", "Brushing", "Coiffure mariage"],
barbier: ["Barbier", "Barbe", "Coupe homme", "Rasage"],
esthetique: ["Institut de beauté", "Soins du visage", "Épilation", "Manucure", "Maquillage"],
onglerie: ["Onglerie", "Pose d'ongles", "Manucure", "Nail art"],
parfumerie: ["Parfumerie", "Parfums", "Cosmétiques", "Coffrets cadeaux"],
"bien-etre": ["Bien-être", "Massage", "Spa", "Relaxation", "Soins"],
optique: ["Opticien", "Lunettes", "Lentilles", "Examen de vue", "Lunettes de soleil"],
tatouage: ["Tatoueur", "Tatouage", "Piercing", "Sur rendez-vous"],
// Maison, équipement & décoration
"bazar-discount": ["Bazar", "Discount", "Équipement de la maison", "Décoration", "Jouets", "Petits prix"],
decoration: ["Décoration", "Meubles", "Ameublement", "Luminaires", "Idées cadeaux"],
cuisiniste: ["Cuisiniste", "Cuisine équipée", "Salle de bain", "Dressing", "Sur mesure"],
literie: ["Literie", "Matelas", "Sommiers", "Linge de lit"],
"arts-de-la-table": ["Arts de la table", "Vaisselle", "Cadeaux", "Liste de mariage"],
"electromenager-multimedia": ["Électroménager", "Multimédia", "TV", "Réparation", "Livraison"],
fleuriste: ["Fleuriste", "Bouquets", "Plantes", "Mariage", "Deuil", "Livraison de fleurs"],
jardinerie: ["Jardinerie", "Plantes", "Motoculture", "Terreau", "Conseils jardin"],
"bricolage-jardinage": ["Bricolage", "Jardinage", "Outillage", "Quincaillerie"],
animalerie: ["Animalerie", "Alimentation animale", "Toilettage", "Accessoires"],
// Artisanat & bâtiment
artisanat: ["Artisan", "Fait main", "Création", "Sur mesure"],
batiment: ["Bâtiment", "Rénovation", "Travaux", "Devis gratuit"],
architecte: ["Architecte", "Maître d'œuvre", "Permis de construire", "Rénovation"],
maconnerie: ["Maçonnerie", "Gros œuvre", "Terrassement", "Rénovation"],
"couverture-toiture": ["Couvreur", "Toiture", "Zinguerie", "Isolation de toiture", "Urgence"],
plomberie: ["Plombier", "Chauffagiste", "Chaudière", "Pompe à chaleur", "Dépannage"],
electricite: ["Électricien", "Installation électrique", "Mise aux normes", "Domotique", "Dépannage"],
"isolation-energie": ["Isolation", "Énergies renouvelables", "Panneaux solaires", "Rénovation énergétique"],
menuiserie: ["Menuisier", "Menuiserie", "Ébénisterie", "Sur mesure", "Fenêtres"],
"serrurerie-metallerie": ["Serrurier", "Métallerie", "Portails", "Garde-corps", "Urgence"],
"carrelage-sols": ["Carreleur", "Carrelage", "Parquet", "Revêtements de sols"],
peinture: ["Peintre", "Peinture", "Décoration intérieure", "Papier peint", "Ravalement"],
paysagiste: ["Paysagiste", "Espaces verts", "Entretien de jardin", "Clôtures", "Élagage"],
"depannage-multiservices": ["Dépannage", "Multiservices", "Petits travaux", "Homme toutes mains"],
// Auto & mobilité
automobile: ["Garage", "Entretien auto", "Réparation", "Pneus", "Contrôle technique"],
carrosserie: ["Carrosserie", "Peinture auto", "Pare-brise", "Débosselage"],
"controle-technique": ["Contrôle technique", "Contre-visite", "Sans rendez-vous"],
"station-service-lavage": ["Station-service", "Carburant", "Lavage auto", "Boutique"],
"location-vehicules": ["Location de voitures", "Location utilitaire", "Location camionnette"],
"cycles-motos": ["Vélos", "Motos", "Vélo électrique", "Réparation", "Accessoires"],
"auto-ecole": ["Auto-école", "Permis B", "Code de la route", "Conduite accompagnée"],
"taxi-vtc": ["Taxi", "VTC", "Transport de personnes", "Gare", "Aéroport"],
// Santé
sante: ["Pharmacie", "Santé", "Parapharmacie", "Orthopédie", "Conseils"],
medical: ["Médical", "Paramédical", "Soins", "Sur rendez-vous"],
audioprothese: ["Audioprothésiste", "Appareils auditifs", "Bilan auditif", "Piles"],
veterinaire: ["Vétérinaire", "Soins animaux", "Vaccination", "Urgences vétérinaires"],
// Banque, assurance & immobilier
"banque-assurance": ["Banque", "Assurance", "Crédit", "Épargne", "Professionnels"],
assurance: ["Assurance", "Assurance auto", "Assurance habitation", "Mutuelle santé", "Prévoyance", "Assurance professionnelle"],
"courtier-credit": ["Courtier", "Crédit immobilier", "Rachat de crédit", "Financement", "Assurance emprunteur"],
immobilier: ["Agence immobilière", "Vente", "Location", "Estimation gratuite", "Gestion locative"],
"diagnostic-immobilier": ["Diagnostic immobilier", "DPE", "Expertise", "Amiante", "Plomb"],
// Services aux particuliers
services: ["Services", "Proximité", "Sur devis"],
"services-a-la-personne": ["Aide à domicile", "Ménage", "Garde d'enfants", "Services à la personne", "Crédit d'impôt"],
"pressing-retouches": ["Pressing", "Retouches", "Cordonnerie", "Nettoyage à sec", "Clés"],
photographe: ["Photographe", "Portrait", "Mariage", "Photo d'identité", "Vidéo"],
"pompes-funebres": ["Pompes funèbres", "Obsèques", "Marbrerie", "Prévoyance obsèques"],
"agence-voyages": ["Agence de voyages", "Séjours", "Billets", "Croisières", "Voyages de groupe"],
demenagement: ["Déménagement", "Garde-meubles", "Cartons", "Devis gratuit"],
// Services aux entreprises
comptabilite: ["Expert-comptable", "Comptabilité", "Gestion", "Création d'entreprise", "Paie"],
juridique: ["Notaire", "Juridique", "Avocat", "Conseil", "Succession"],
conseil: ["Conseil", "Consultant", "Stratégie", "Accompagnement", "Formation"],
communication: ["Communication", "Site web", "Marketing", "Réseaux sociaux", "Graphisme"],
"imprimerie-enseignes": ["Imprimerie", "Enseignes", "Signalétique", "Flocage", "Cartes de visite"],
informatique: ["Informatique", "Dépannage informatique", "Téléphonie", "Réparation", "Réseau"],
nettoyage: ["Nettoyage", "Entretien de locaux", "Vitres", "Fin de chantier"],
transport: ["Transport", "Logistique", "Livraison", "Messagerie"],
securite: ["Sécurité", "Alarme", "Vidéosurveillance", "Gardiennage"],
coworking: ["Coworking", "Bureaux", "Salle de réunion", "Domiciliation"],
"interim-rh": ["Intérim", "Recrutement", "Ressources humaines", "Emploi"],
"recyclage-dechets": ["Recyclage", "Déchets", "Débarras", "Ferraille", "Location de bennes"],
// Culture, loisirs & formation
"librairie-papeterie": ["Librairie", "Papeterie", "Presse", "Livres", "Fournitures scolaires"],
"tabac-presse": ["Tabac", "Presse", "Loto", "FDJ", "Relais colis"],
"jouets-puericulture": ["Jouets", "Puériculture", "Jeux", "Cadeaux enfants", "Liste de naissance"],
"sport-loisirs": ["Sport", "Loisirs", "Activités", "Cours", "Stages"],
"articles-de-sport": ["Articles de sport", "Équipement sportif", "Chaussures de sport", "Textile"],
fitness: ["Salle de sport", "Fitness", "Musculation", "Cours collectifs", "Coaching"],
"culture-arts": ["Culture", "Arts", "Galerie", "Cours", "Spectacles"],
"education-formation": ["Formation", "Cours", "Soutien scolaire", "Formation professionnelle"],
"tourisme-hotellerie": ["Hôtel", "Hébergement", "Chambres d'hôtes", "Gîte", "Séminaires"],
evenementiel: ["Événementiel", "Organisation d'événements", "Mariage", "Location de matériel", "Animation"],
// Entreprise, production & institutions
industrie: ["Industrie", "Production", "Fabrication", "Sous-traitance"],
agriculture: ["Producteur local", "Ferme", "Vente directe", "Produits fermiers", "Circuit court"],
"partenaire-institutionnel": ["Partenaire", "Collectivité", "Institution", "Service public"],
};
/**
* Vocabulaire transversal détecté dans la description. Chaque motif est testé
* sur le texte normalisé (minuscules, sans accents).
*/
export const DESCRIPTION_VOCABULARY: { tag: string; patterns: RegExp[] }[] = [
{ tag: "Fait maison", patterns: [/\bfaite?s? maison\b/, /\bmaison\b.*\b(pain|plat|patisserie|gateau|cuisine)/] },
{ tag: "Artisanal", patterns: [/\bartisanal(e|es|aux)?\b/, /\bartisan\b/] },
{ tag: "Bio", patterns: [/\bbio(logique|logiques)?\b/] },
{ tag: "Produits locaux", patterns: [/\blocal(e|es)?\b/, /\blocaux\b/, /\bterroir\b/, /\bcircuit court\b/] },
{ tag: "Sur commande", patterns: [/\bsur commande\b/, /\bcommandes?\b/] },
{ tag: "Livraison", patterns: [/\blivraisons?\b/, /\blivr(e|ons|ez)\b/] },
{ tag: "À emporter", patterns: [/\ba emporter\b/, /\bvente a emporter\b/] },
{ tag: "Click & collect", patterns: [/\bclick\s*(&|and|et)\s*collect\b/, /\bretrait en magasin\b/] },
{ tag: "Sur rendez-vous", patterns: [/\bsur rendez[- ]vous\b/, /\brdv\b/] },
{ tag: "Sans rendez-vous", patterns: [/\bsans rendez[- ]vous\b/] },
{ tag: "Devis gratuit", patterns: [/\bdevis gratuits?\b/, /\bdevis\b/] },
{ tag: "Dépannage", patterns: [/\bdepannages?\b/, /\burgences?\b/, /\b7j\s*\/\s*7\b/, /\b24h\s*\/\s*24\b/] },
{ tag: "Sur mesure", patterns: [/\bsur[- ]mesure\b/, /\bpersonnalis(e|es|ee|ees|ation)\b/] },
{ tag: "Terrasse", patterns: [/\bterrasses?\b/] },
{ tag: "Parking", patterns: [/\bparking\b/, /\bstationnement\b/] },
{ tag: "Ouvert le dimanche", patterns: [/\bdimanche\b/] },
{ tag: "Accessible PMR", patterns: [/\bpmr\b/, /\bhandicap/, /\baccessible\b/] },
{ tag: "Événements", patterns: [/\bevenements?\b/, /\bmariages?\b/, /\bseminaires?\b/, /\banniversaires?\b/] },
{ tag: "Professionnels", patterns: [/\bprofessionnels\b/, /\bentreprises\b/, /\bb ?to ?b\b/, /\bb2b\b/] },
{ tag: "Cadeaux", patterns: [/\bcadeaux?\b/, /\bcoffrets?\b/, /\bcartes? cadeau\b/] },
{ tag: "Conseil", patterns: [/\bconseils?\b/, /\baccompagnement\b/] },
{ tag: "Formation", patterns: [/\bformations?\b/, /\bcours\b/, /\bateliers?\b/, /\bstages?\b/] },
{ tag: "Nouveau", patterns: [/\bnouveau\b/, /\bnouvelle\b/, /\bouverture\b/] },
];
export function normalizeText(value: string): string {
return value
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.toLowerCase()
.replace(/[’']/g, " ")
.replace(/\s+/g, " ")
.trim();
}
/** « a, b ,, c » → ["a", "b", "c"], sans doublons (accents et casse ignorés). */
export function parseTags(value: string | null | undefined): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const raw of (value ?? "").split(/[,;\n]/)) {
const tag = raw.replace(/\s+/g, " ").trim();
if (!tag) continue;
const key = normalizeText(tag);
if (seen.has(key)) continue;
seen.add(key);
out.push(tag);
}
return out;
}
export function formatTags(tags: string[]): string {
return parseTags(tags.join(", ")).join(", ");
}
/** Tags du métier : depuis le vocabulaire, sinon dérivés du libellé de la catégorie. */
export function categoryTags(categorySlug: string | null | undefined, categoryLabel: string | null | undefined): string[] {
if (categorySlug && CATEGORY_TAGS[categorySlug]) return [...CATEGORY_TAGS[categorySlug]];
if (!categoryLabel) return [];
return categoryLabel
.split(/\s*(?:–|—|-|\/|&|,)\s*/)
.map((part) => part.trim())
.filter((part) => part.length > 1);
}
/** Termes du vocabulaire transversal présents dans la description. */
export function tagsFromDescription(description: string | null | undefined): string[] {
const text = normalizeText(description ?? "");
if (!text) return [];
return DESCRIPTION_VOCABULARY.filter((entry) => entry.patterns.some((p) => p.test(text))).map((e) => e.tag);
}
export type TagSuggestionInput = {
categorySlug?: string | null;
categoryLabel?: string | null;
city?: string | null;
description?: string | null;
};
/**
* Suggestions ordonnées : le métier d'abord (c'est ce que l'on cherche le plus),
* puis la commune, puis ce que la description révèle. Plafonné à `MAX_TAGS`.
*/
export function suggestTags(input: TagSuggestionInput, max = MAX_TAGS): string[] {
const fromCategory = categoryTags(input.categorySlug, input.categoryLabel);
const city = (input.city ?? "").trim();
const fromDescription = tagsFromDescription(input.description);
// Un mot-clé métier qui figure aussi dans la description remonte en tête : il
// décrit vraiment l'activité, pas seulement la famille de métier.
const text = normalizeText(input.description ?? "");
const ranked = [...fromCategory].sort((a, b) => Number(text.includes(normalizeText(b))) - Number(text.includes(normalizeText(a))));
return parseTags([...ranked, city, ...fromDescription].join(", ")).slice(0, max);
}
/**
* Tags à enregistrer : la saisie de l'adhérent si elle existe, sinon les
* suggestions. Ne modifie jamais des tags renseignés.
*/
export function autoTags(existing: string | null | undefined, input: TagSuggestionInput): string | null {
const own = parseTags(existing);
if (own.length > 0) return formatTags(own);
const suggested = suggestTags(input);
return suggested.length > 0 ? formatTags(suggested) : null;
}
+46
View File
@@ -0,0 +1,46 @@
import { strict as assert } from "node:assert";
import { readFileSync } from "node:fs";
import { describe, it } from "node:test";
import { CATEGORY_PALETTE, CATEGORY_REFERENTIAL, categoryRows } from "../src/db/categories";
describe("Référentiel des catégories", () => {
it("garde des slugs uniques et stables", () => {
const slugs = CATEGORY_REFERENTIAL.map((c) => c.slug);
assert.equal(new Set(slugs).size, slugs.length, "slug en double");
for (const slug of slugs) {
assert.match(slug, /^[a-z0-9]+(?:-[a-z0-9]+)*$/, `slug invalide : ${slug}`);
}
// Slugs historiques : des fiches adhérents y sont rattachées.
for (const legacy of ["alimentation", "boulangerie", "restauration", "mode-beaute", "artisanat", "services", "sante"]) {
assert.ok(slugs.includes(legacy), `${legacy} doit rester présent`);
}
});
it("couvre les métiers demandés (assurance, bazar/discount, grande distribution…)", () => {
const slugs = new Set(CATEGORY_REFERENTIAL.map((c) => c.slug));
for (const expected of ["assurance", "bazar-discount", "supermarche", "librairie-papeterie", "tabac-presse", "veterinaire", "auto-ecole"]) {
assert.ok(slugs.has(expected), `${expected} manquant`);
}
});
it("respecte les contraintes de la table", () => {
for (const row of categoryRows()) {
assert.ok(row.label.length <= 120, `libellé trop long : ${row.label}`);
assert.ok(row.slug.length <= 80);
assert.ok(CATEGORY_PALETTE.some((p) => p.accent === row.accent && p.tint === row.tint));
assert.ok(row.sort >= 1);
}
});
it("est intégralement repris par la migration 0012", () => {
const sql = readFileSync(new URL("../drizzle/0012_referentiel_categories.sql", import.meta.url), "utf8");
for (const row of categoryRows()) {
assert.ok(sql.includes(`('${row.slug}', '${row.label.replace(/'/g, "''")}', '${row.accent}', '${row.tint}', ${row.sort})`),
`${row.slug} absent ou différent dans la migration`);
}
// Ne jamais écraser un libellé renommé depuis le backend.
assert.ok(sql.includes('ON CONFLICT ("slug") DO UPDATE SET "sort" = EXCLUDED."sort";'));
assert.ok(!/DO UPDATE SET[^;]*"label"/.test(sql));
});
});
+26
View File
@@ -0,0 +1,26 @@
import { strict as assert } from "node:assert";
import { describe, it } from "node:test";
import { CATEGORY_REFERENTIAL } from "../src/db/categories";
import { PROMO_CATEGORIES, PROMO_CATEGORY_GROUPS, defaultPromoCategory } from "../src/lib/promo-categories";
describe("Catégories de promotion (types de produits)", () => {
it("propose une liste large, sans doublon, groupée", () => {
assert.ok(PROMO_CATEGORIES.length >= 70, `liste trop courte : ${PROMO_CATEGORIES.length}`);
assert.equal(new Set(PROMO_CATEGORIES).size, PROMO_CATEGORIES.length, "doublon");
assert.ok(PROMO_CATEGORY_GROUPS.every((g) => g.items.length > 0 && g.label));
assert.ok(PROMO_CATEGORIES.includes("Autre"));
for (const c of PROMO_CATEGORIES) assert.ok(c.length <= 120, `trop long pour la colonne : ${c}`);
});
it("pré-sélectionne un type cohérent pour chaque métier du référentiel", () => {
for (const c of CATEGORY_REFERENTIAL) {
const d = defaultPromoCategory(c.slug);
assert.ok(PROMO_CATEGORIES.includes(d), `${c.slug} → ${d} inconnu`);
}
assert.equal(defaultPromoCategory("boulangerie"), "Pain & viennoiseries");
assert.equal(defaultPromoCategory("bazar-discount"), "Bazar & petit équipement");
assert.equal(defaultPromoCategory("inconnu"), "Autre");
assert.equal(defaultPromoCategory(null), "Autre");
});
});
+190
View File
@@ -0,0 +1,190 @@
import { strict as assert } from "node:assert";
import { describe, it } from "node:test";
import {
DESCRIPTION_MAX_LENGTH,
NOINDEX,
PRIVATE_PATHS,
STATIC_ROUTES,
absoluteUrl,
breadcrumbJsonLd,
categoryPath,
memberPath,
parseMemberParam,
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("construit des URLs de fiche parlantes et les relit", () => {
assert.equal(memberPath({ id: 12, name: "Au Bon Pain", city: "Frouard" }), "/adherents/12-au-bon-pain-frouard");
assert.equal(memberPath({ id: 3, name: "Épicerie « Chez Léa »", city: null }), "/adherents/3-epicerie-chez-lea");
assert.equal(memberPath({ id: 7, name: "???" }), "/adherents/7");
for (const param of ["12-au-bon-pain-frouard", "12-ancien-nom", "12"]) {
assert.equal(parseMemberParam(param), 12, param);
}
for (const param of ["abc", "-12", "12abc", "0", ""]) {
assert.equal(parseMemberParam(param), null, param);
}
assert.equal(categoryPath("boulangerie"), "/annuaire/boulangerie");
});
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-au-bon-pain-pompey`);
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-au-bon-pain-pompey`);
});
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: "Bar du Port", city: "Pompey" }]);
assert.equal(list.numberOfItems, 2);
assert.equal(list.itemListElement[1].url, `${BASE}/adherents/2-bar-du-port-pompey`);
});
});
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);
});
});
+55
View File
@@ -0,0 +1,55 @@
import { strict as assert } from "node:assert";
import { describe, it } from "node:test";
import { CATEGORY_REFERENTIAL } from "../src/db/categories";
import { CATEGORY_TAGS, MAX_TAGS, autoTags, formatTags, parseTags, suggestTags, tagsFromDescription } from "../src/lib/tags";
describe("Tags automatiques", () => {
it("couvre chaque métier du référentiel", () => {
for (const c of CATEGORY_REFERENTIAL) {
assert.ok((CATEGORY_TAGS[c.slug] ?? []).length >= 3, `vocabulaire manquant ou trop court : ${c.slug}`);
}
});
it("nettoie et dédoublonne la saisie", () => {
assert.deepEqual(parseTags(" Pain ,pain, PAIN ;Levain,, Fait maison "), ["Pain", "Levain", "Fait maison"]);
assert.deepEqual(parseTags("Épicerie, epicerie"), ["Épicerie"]);
assert.equal(formatTags(["a", "b", "a"]), "a, b");
});
it("lit la description sans accents ni casse", () => {
const tags = tagsFromDescription("Pains au LEVAIN faits maison, produits LOCAUX, livraison le dimanche. Devis gratuit.");
for (const expected of ["Fait maison", "Produits locaux", "Livraison", "Ouvert le dimanche", "Devis gratuit"]) {
assert.ok(tags.includes(expected), `${expected} attendu dans ${tags.join(", ")}`);
}
assert.deepEqual(tagsFromDescription(""), []);
});
it("suggère métier, commune puis description, dans la limite", () => {
const tags = suggestTags({
categorySlug: "boulangerie",
city: "Frouard",
description: "Boulangerie artisanale : pain au levain et viennoiseries faites maison.",
});
assert.equal(tags[0], "Boulangerie");
assert.ok(tags.includes("Frouard"));
assert.ok(tags.includes("Fait maison"));
assert.ok(tags.includes("Artisanal"));
assert.ok(tags.length <= MAX_TAGS);
// Les mots du métier cités dans la description passent devant les autres.
assert.ok(tags.indexOf("Pain") < tags.indexOf("Sandwichs"));
});
it("dérive du libellé quand le slug est inconnu (catégorie créée dans le backend)", () => {
assert.deepEqual(suggestTags({ categorySlug: "ma-categorie", categoryLabel: "Toilettage – Pension canine" }), [
"Toilettage",
"Pension canine",
]);
});
it("ne remplace jamais une saisie existante", () => {
assert.equal(autoTags("Kouglof, Brioche", { categorySlug: "boulangerie", city: "Frouard" }), "Kouglof, Brioche");
assert.equal(autoTags(" ", { categorySlug: "assurance", city: "Pompey" }), "Assurance, Assurance auto, Assurance habitation, Mutuelle santé, Prévoyance, Assurance professionnelle, Pompey");
assert.equal(autoTags("", {}), null);
});
});