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
This commit is contained in:
Claude committed 2026-09-06 06:51:25 +00:00
1 parent c8417d8560
commit bfc82b3b3b
10 files changed
+521 -22

No files matched your search

+8
View File
@@ -137,6 +137,14 @@ 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
+12 -2
View File
@@ -18,6 +18,7 @@ import {
parseMemberParam,
} from "@/lib/seo";
import { publicBaseUrl } from "@/lib/seo-server";
import { autoTags, formatTags, parseTags } from "@/lib/tags";
import {
formatSlots,
getOpenStatus,
@@ -152,7 +153,16 @@ 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);
@@ -182,7 +192,7 @@ export default async function FicheAdherentPage({
{ name: "Annuaire", path: "/annuaire" },
{ name: member.name, path: canonical },
]),
localBusinessJsonLd(baseUrl, member, hours, normalizedWebsite),
localBusinessJsonLd(baseUrl, { ...member, tags: formatTags(tags) }, hours, normalizedWebsite),
]}
/>
+48 -8
View File
@@ -11,6 +11,7 @@ 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,
@@ -401,6 +402,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é");
@@ -419,9 +442,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.
@@ -521,22 +545,27 @@ 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,
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",
})
@@ -1076,18 +1105,29 @@ 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,
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"),
+3 -6
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));
@@ -142,11 +143,7 @@ export default async function EditMemberPage({
<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" />
<p className="field-hint">
Mots-clés que vos clients recherchent : produits, services, marques, quartier. Ils s&apos;affichent sur
votre fiche et sont transmis aux moteurs de recherche.
</p>
<TagsField defaultValue={member.tags} categories={cats} />
</div>
<div style={{ marginTop: 16 }}>
+5 -6
View File
@@ -8,6 +8,7 @@ import { configuredNetworks, SOCIAL_LABELS, type SocialNetwork } from "@/lib/soc
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,
@@ -53,6 +54,7 @@ export default async function EspacePage() {
let lastShare = new Map<string, { status: string; url: string | null; error: string | null }>();
let myRegistrations: { meetingId: number; title: string; startsAt: Date; location: string | null; participants: number; confirmed: boolean }[] = [];
let latestConsent: { decision: string; createdAt: Date } | null = null;
let profileCategory: { slug: string; label: string } | null = null;
if (memberId) {
const [m] = await db.select().from(members).where(eq(members.id, memberId));
@@ -60,8 +62,9 @@ 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
@@ -239,11 +242,7 @@ export default async function EspacePage() {
<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" placeholder="Levain naturel, Produits locaux, Fait maison" />
<p className="field-hint">
Mots-clés que vos clients recherchent : produits, services, marques, quartier. Ils s&apos;affichent sur
votre fiche et sont transmis aux moteurs de recherche.
</p>
<TagsField defaultValue={profile.tags} fixedCategory={profileCategory} />
</div>
<div style={{ marginTop: 16 }}>
+34
View File
@@ -948,6 +948,40 @@ img {
color: var(--muted);
margin-bottom: 6px;
}
/* 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;
+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>
);
}
+1
View File
@@ -98,6 +98,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,
})
+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;
}
+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);
});
});