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

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

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

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

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

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDbKbJrPWweXXYSxA8MXW7
2026-09-06 06:18:37 +00:00
40 changed files with 5057 additions and 506 deletions

No files matched your search

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