mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-22 06:38:05 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86e7e28020 | ||
|
|
23d1d14a49 | ||
|
|
8b46cd6316 | ||
|
|
bfc82b3b3b | ||
|
|
c8417d8560 | ||
|
|
f9d0d6c825 | ||
|
|
320e63b749 | ||
|
|
8414c7201e | ||
|
|
0562d07254 | ||
|
|
a8adb9ae6a | ||
|
|
dd350038f0 |
No files matched your search
+8
-3
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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";
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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" }}>
|
||||
|
||||
@@ -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'annuaire
|
||||
</h2>
|
||||
<CategoryLinks categories={categoriesInUse} currentSlug={category.slug} />
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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
@@ -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 ----
|
||||
|
||||
@@ -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'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'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 }}>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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'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'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'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>
|
||||
);
|
||||
}
|
||||
@@ -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'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'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'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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}: {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
import { NOINDEX } from "@/lib/seo";
|
||||
|
||||
// La page de connexion est un composant client : ses métadonnées vivent ici.
|
||||
export const metadata: Metadata = {
|
||||
title: "Connexion à l'espace adhérent",
|
||||
robots: NOINDEX,
|
||||
};
|
||||
|
||||
export default function LoginLayout({ children }: { children: React.ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { SITE_DESCRIPTION, SITE_NAME, SITE_TITLE, THEME_COLOR } from "@/lib/seo";
|
||||
|
||||
/** Manifeste web : nom, icône et couleurs, pour l'ajout à l'écran d'accueil. */
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: SITE_TITLE,
|
||||
short_name: SITE_NAME,
|
||||
description: SITE_DESCRIPTION,
|
||||
lang: "fr",
|
||||
start_url: "/",
|
||||
display: "standalone",
|
||||
background_color: "#F6F2E8",
|
||||
theme_color: THEME_COLOR,
|
||||
icons: [{ src: "/assets/logo.png", sizes: "any", type: "image/png" }],
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
import { SiteHeader } from "@/components/SiteHeader";
|
||||
import { NOINDEX } from "@/lib/seo";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Page introuvable",
|
||||
robots: NOINDEX,
|
||||
};
|
||||
|
||||
/** 404 publique : renvoie vers l'annuaire plutôt que vers une page blanche. */
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div style={{ background: "#F6F2E8", minHeight: "100vh", fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
|
||||
<SiteHeader logo />
|
||||
<main className="container" style={{ paddingTop: 48, paddingBottom: 72, maxWidth: 720 }}>
|
||||
<div style={{ color: "#9a6638", fontSize: 12, letterSpacing: "0.14em", textTransform: "uppercase", fontWeight: 800, marginBottom: 12 }}>
|
||||
Erreur 404
|
||||
</div>
|
||||
<h1 className="font-display" style={{ margin: "0 0 14px", color: "#26201a", fontSize: "clamp(30px, 6vw, 48px)", lineHeight: 1.05, letterSpacing: "-0.02em" }}>
|
||||
Cette page n'existe pas ou plus
|
||||
</h1>
|
||||
<p style={{ fontSize: 17, lineHeight: 1.6, color: "#6c6150", margin: "0 0 26px" }}>
|
||||
La fiche ou la page demandée a peut-être été retirée. Retrouvez tous les commerçants et
|
||||
entreprises du Bassin de Pompey dans l'annuaire.
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
|
||||
<Link
|
||||
href="/annuaire"
|
||||
className="font-display"
|
||||
style={{ textDecoration: "none", background: "#E0A63C", color: "#33291D", fontWeight: 700, fontSize: 15.5, padding: "13px 24px", borderRadius: 12 }}
|
||||
>
|
||||
Voir l'annuaire
|
||||
</Link>
|
||||
<Link
|
||||
href="/"
|
||||
className="font-display"
|
||||
style={{ textDecoration: "none", background: "#fff", border: "1px solid #e6dcc6", color: "#33291D", fontWeight: 700, fontSize: 15.5, padding: "13px 24px", borderRadius: 12 }}
|
||||
>
|
||||
Retour à l'accueil
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { ImageResponse } from "next/og";
|
||||
import { SITE_NAME } from "@/lib/seo";
|
||||
|
||||
/**
|
||||
* Image de partage (Open Graph / Twitter) générée à la volée, 1200×630.
|
||||
* Sert de vignette par défaut à toutes les pages ; les fiches adhérents la
|
||||
* reprennent, leurs logos étant stockés en data-URI (illisibles par les
|
||||
* robots de prévisualisation).
|
||||
*/
|
||||
export const alt = "Plein R — Commerçants & entreprises du Bassin de Pompey";
|
||||
export const size = { width: 1200, height: 630 };
|
||||
export const contentType = "image/png";
|
||||
|
||||
/** Logo servi depuis `public/` (copié dans l'image standalone), en data-URI pour Satori. */
|
||||
async function logoDataUri(): Promise<string | null> {
|
||||
try {
|
||||
const file = await readFile(path.join(process.cwd(), "public", "assets", "logo.png"));
|
||||
return `data:image/png;base64,${file.toString("base64")}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default async function OpenGraphImage() {
|
||||
const logo = await logoDataUri();
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "space-between",
|
||||
padding: 72,
|
||||
background: "linear-gradient(135deg, #F6F2E8 0%, #efe9da 100%)",
|
||||
color: "#26201a",
|
||||
fontFamily: "sans-serif",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 24 }}>
|
||||
{logo ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={logo} alt="" width={120} height={120} style={{ objectFit: "contain" }} />
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: 84,
|
||||
height: 84,
|
||||
borderRadius: 24,
|
||||
background: "#E0A63C",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "#26201a",
|
||||
fontSize: 48,
|
||||
fontWeight: 800,
|
||||
}}
|
||||
>
|
||||
R
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: 40, fontWeight: 800, letterSpacing: -1 }}>{SITE_NAME}</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
|
||||
<div style={{ fontSize: 26, letterSpacing: 6, textTransform: "uppercase", color: "#9a6638", fontWeight: 700 }}>
|
||||
Réseau · Rencontre · Réussite
|
||||
</div>
|
||||
<div style={{ fontSize: 72, fontWeight: 800, lineHeight: 1.02, letterSpacing: -2, display: "flex", flexWrap: "wrap" }}>
|
||||
Le cœur commerçant du
|
||||
<span style={{ color: "#2C6FB3" }}>Bassin de Pompey</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 30, color: "#6c6150", marginTop: 8 }}>
|
||||
Annuaire des commerçants, artisans et entreprises · Bons plans · Rencontres
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 14 }}>
|
||||
{["#E0A63C", "#2C6FB3", "#d8472b", "#1f8a5b"].map((c) => (
|
||||
<div key={c} style={{ width: 64, height: 10, borderRadius: 999, background: c }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{ ...size }
|
||||
);
|
||||
}
|
||||
+22
-7
@@ -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'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>
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { PRIVATE_PATHS, absoluteUrl } from "@/lib/seo";
|
||||
import { publicBaseUrl } from "@/lib/seo-server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** /robots.txt : tout le site public est explorable, l'espace privé exclu. */
|
||||
export default async function robots(): Promise<MetadataRoute.Robots> {
|
||||
const base = await publicBaseUrl();
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
disallow: PRIVATE_PATHS,
|
||||
},
|
||||
],
|
||||
sitemap: base ? absoluteUrl(base, "/sitemap.xml") : undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,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];
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { headers } from "next/headers";
|
||||
import { serializeJsonLd } from "@/lib/seo";
|
||||
|
||||
/**
|
||||
* Bloc de données structurées `application/ld+json`.
|
||||
*
|
||||
* Le contenu est sérialisé par `serializeJsonLd`, qui échappe `<`, `>` et `&` :
|
||||
* les descriptions saisies par les adhérents ne peuvent pas fermer la balise.
|
||||
* Le nonce de la CSP est recopié par cohérence, même si un bloc JSON-LD n'est
|
||||
* jamais exécuté par le navigateur.
|
||||
*/
|
||||
export async function JsonLd({ data }: { data: unknown | unknown[] }) {
|
||||
const nonce = (await headers()).get("x-nonce") ?? undefined;
|
||||
const items = Array.isArray(data) ? data : [data];
|
||||
return (
|
||||
<>
|
||||
{items.map((item, index) => (
|
||||
<script
|
||||
key={index}
|
||||
type="application/ld+json"
|
||||
nonce={nonce}
|
||||
dangerouslySetInnerHTML={{ __html: serializeJsonLd(item) }}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'enregistrement. Ils s'affichent sur votre fiche et
|
||||
sont transmis aux moteurs de recherche.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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
@@ -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 });
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user