mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-11 17:27:54 +02:00
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:
10 files changed
+521
-22
No files matched your search
@@ -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
|
||||
|
||||
@@ -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),
|
||||
]}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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'affichent sur
|
||||
votre fiche et sont transmis aux moteurs de recherche.
|
||||
</p>
|
||||
<TagsField defaultValue={member.tags} categories={cats} />
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
|
||||
@@ -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'affichent sur
|
||||
votre fiche et sont transmis aux moteurs de recherche.
|
||||
</p>
|
||||
<TagsField defaultValue={profile.tags} fixedCategory={profileCategory} />
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { formatTags, parseTags, suggestTags } from "@/lib/tags";
|
||||
|
||||
type CategoryOption = { id: number; slug: string; label: string };
|
||||
|
||||
/**
|
||||
* Champ « tags » avec suggestions automatiques.
|
||||
*
|
||||
* Les suggestions se recalculent à chaque saisie à partir de la catégorie (le
|
||||
* `<select name="categoryId">` du formulaire, ou une catégorie fixe), de la
|
||||
* commune et de la description du même formulaire. Un clic ajoute le mot ;
|
||||
* champ laissé vide = les suggestions sont appliquées à l'enregistrement
|
||||
* (`autoTags` côté serveur), l'adhérent garde donc toujours la main.
|
||||
*
|
||||
* Le champ est volontairement **non contrôlé** : l'écouteur natif posé sur le
|
||||
* formulaire provoque un rendu React au milieu de l'événement de saisie, et un
|
||||
* champ contrôlé y perdrait la frappe en cours.
|
||||
*/
|
||||
export function TagsField({
|
||||
name = "tags",
|
||||
defaultValue = "",
|
||||
placeholder = "Levain naturel, Produits locaux, Fait maison",
|
||||
categories,
|
||||
fixedCategory,
|
||||
}: {
|
||||
name?: string;
|
||||
defaultValue?: string | null;
|
||||
placeholder?: string;
|
||||
/** Formulaire staff : catégories sélectionnables (le select s'appelle `categoryId`). */
|
||||
categories?: CategoryOption[];
|
||||
/** Espace adhérent : catégorie non modifiable. */
|
||||
fixedCategory?: { slug: string | null; label: string | null } | null;
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [current, setCurrent] = useState<string[]>(() => parseTags(defaultValue));
|
||||
const [context, setContext] = useState({ categoryId: "", city: "", description: "" });
|
||||
|
||||
// Lit les autres champs du formulaire et se tient à jour à chaque frappe.
|
||||
useEffect(() => {
|
||||
const form = inputRef.current?.form;
|
||||
if (!form) return;
|
||||
const read = () => {
|
||||
const data = new FormData(form);
|
||||
setContext({
|
||||
categoryId: String(data.get("categoryId") ?? ""),
|
||||
city: String(data.get("city") ?? ""),
|
||||
description: String(data.get("description") ?? ""),
|
||||
});
|
||||
setCurrent(parseTags(inputRef.current?.value));
|
||||
};
|
||||
read();
|
||||
form.addEventListener("input", read);
|
||||
form.addEventListener("change", read);
|
||||
return () => {
|
||||
form.removeEventListener("input", read);
|
||||
form.removeEventListener("change", read);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const suggestions = useMemo(() => {
|
||||
const category = fixedCategory ?? categories?.find((c) => String(c.id) === context.categoryId) ?? null;
|
||||
const all = suggestTags({
|
||||
categorySlug: category?.slug,
|
||||
categoryLabel: category?.label,
|
||||
city: context.city,
|
||||
description: context.description,
|
||||
});
|
||||
const have = new Set(current.map((t) => t.toLowerCase()));
|
||||
return all.filter((t) => !have.has(t.toLowerCase()));
|
||||
}, [fixedCategory, categories, context, current]);
|
||||
|
||||
function setTags(tags: string[]) {
|
||||
const input = inputRef.current;
|
||||
if (!input) return;
|
||||
input.value = formatTags(tags);
|
||||
setCurrent(parseTags(input.value));
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
name={name}
|
||||
defaultValue={defaultValue ?? ""}
|
||||
className="field"
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{suggestions.length > 0 && (
|
||||
<div className="tag-suggestions" aria-label="Suggestions de tags">
|
||||
<span className="tag-suggestions__label">Suggestions :</span>
|
||||
{suggestions.map((tag) => (
|
||||
<button key={tag} type="button" className="tag-suggestion" onClick={() => setTags([...current, tag])} title={`Ajouter « ${tag} »`}>
|
||||
+ {tag}
|
||||
</button>
|
||||
))}
|
||||
<button type="button" className="tag-suggestion tag-suggestion--all" onClick={() => setTags([...current, ...suggestions])}>
|
||||
Tout ajouter
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<p className="field-hint">
|
||||
Mots-clés que vos clients recherchent : produits, services, marques, quartier. Laissez vide pour que les
|
||||
suggestions soient appliquées automatiquement à l'enregistrement. Ils s'affichent sur votre fiche et
|
||||
sont transmis aux moteurs de recherche.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* Tags automatiques des fiches adhérents.
|
||||
*
|
||||
* Module **pur** (aucun accès base, utilisable côté client comme côté serveur) :
|
||||
* - un vocabulaire de mots-clés par métier (slug de catégorie) ;
|
||||
* - un vocabulaire transversal détecté dans la description (fait maison, bio,
|
||||
* livraison, sur rendez-vous…) ;
|
||||
* - `suggestTags()` qui combine métier, commune et description ;
|
||||
* - `autoTags()` qui n'intervient que si l'adhérent n'a rien saisi.
|
||||
*
|
||||
* Les tags s'affichent sur la fiche publique et partent dans les données
|
||||
* structurées (`LocalBusiness.keywords`) : ce sont des mots que les clients
|
||||
* tapent dans Google, pas des étiquettes internes.
|
||||
*/
|
||||
|
||||
export const MAX_TAGS = 10;
|
||||
|
||||
/** Mots-clés par métier. Les slugs sont ceux de `src/db/categories.ts`. */
|
||||
export const CATEGORY_TAGS: Record<string, string[]> = {
|
||||
// Bouche & alimentation
|
||||
alimentation: ["Épicerie", "Produits frais", "Produits locaux", "Dépannage"],
|
||||
"epicerie-fine": ["Épicerie fine", "Produits du terroir", "Cadeaux gourmands", "Vins"],
|
||||
supermarche: ["Supermarché", "Courses", "Drive", "Produits frais"],
|
||||
boulangerie: ["Boulangerie", "Pain", "Viennoiseries", "Pâtisserie", "Sandwichs"],
|
||||
"boucherie-charcuterie": ["Boucherie", "Charcuterie", "Viande locale", "Plats cuisinés"],
|
||||
poissonnerie: ["Poissonnerie", "Poisson frais", "Fruits de mer", "Plateaux"],
|
||||
primeur: ["Primeur", "Fruits et légumes", "Produits de saison", "Producteurs locaux"],
|
||||
fromagerie: ["Fromagerie", "Fromages", "Crèmerie", "Plateaux de fromages"],
|
||||
cave: ["Cave à vins", "Vins", "Spiritueux", "Bières", "Conseils"],
|
||||
"brasserie-artisanale": ["Brasserie artisanale", "Bières locales", "Boissons", "Coffrets"],
|
||||
chocolaterie: ["Chocolaterie", "Chocolats", "Confiserie", "Cadeaux"],
|
||||
"glacier-salon-de-the": ["Glacier", "Salon de thé", "Glaces artisanales", "Goûter"],
|
||||
traiteur: ["Traiteur", "Plats à emporter", "Buffets", "Événements", "Repas d'entreprise"],
|
||||
restauration: ["Restaurant", "Cuisine maison", "Menu du jour", "Repas de groupe"],
|
||||
pizzeria: ["Pizzeria", "Pizzas", "À emporter", "Livraison"],
|
||||
"restauration-rapide": ["Restauration rapide", "Snack", "Burgers", "À emporter", "Food truck"],
|
||||
"cafe-bar": ["Café", "Bar", "Brasserie", "Terrasse", "Afterwork"],
|
||||
// Mode, beauté & soin
|
||||
"mode-beaute": ["Mode", "Beauté", "Prêt-à-porter", "Accessoires"],
|
||||
"mode-vetements": ["Vêtements", "Prêt-à-porter", "Mode femme", "Mode homme", "Accessoires"],
|
||||
lingerie: ["Lingerie", "Sous-vêtements", "Maillots de bain", "Conseil"],
|
||||
"vetements-enfants": ["Vêtements enfants", "Mode enfant", "Puériculture", "Cadeaux naissance"],
|
||||
"chaussures-maroquinerie": ["Chaussures", "Maroquinerie", "Sacs", "Cordonnerie"],
|
||||
bijouterie: ["Bijouterie", "Bijoux", "Montres", "Réparation", "Gravure"],
|
||||
coiffure: ["Coiffeur", "Coupe", "Couleur", "Brushing", "Coiffure mariage"],
|
||||
barbier: ["Barbier", "Barbe", "Coupe homme", "Rasage"],
|
||||
esthetique: ["Institut de beauté", "Soins du visage", "Épilation", "Manucure", "Maquillage"],
|
||||
onglerie: ["Onglerie", "Pose d'ongles", "Manucure", "Nail art"],
|
||||
parfumerie: ["Parfumerie", "Parfums", "Cosmétiques", "Coffrets cadeaux"],
|
||||
"bien-etre": ["Bien-être", "Massage", "Spa", "Relaxation", "Soins"],
|
||||
optique: ["Opticien", "Lunettes", "Lentilles", "Examen de vue", "Lunettes de soleil"],
|
||||
tatouage: ["Tatoueur", "Tatouage", "Piercing", "Sur rendez-vous"],
|
||||
// Maison, équipement & décoration
|
||||
"bazar-discount": ["Bazar", "Discount", "Équipement de la maison", "Décoration", "Jouets", "Petits prix"],
|
||||
decoration: ["Décoration", "Meubles", "Ameublement", "Luminaires", "Idées cadeaux"],
|
||||
cuisiniste: ["Cuisiniste", "Cuisine équipée", "Salle de bain", "Dressing", "Sur mesure"],
|
||||
literie: ["Literie", "Matelas", "Sommiers", "Linge de lit"],
|
||||
"arts-de-la-table": ["Arts de la table", "Vaisselle", "Cadeaux", "Liste de mariage"],
|
||||
"electromenager-multimedia": ["Électroménager", "Multimédia", "TV", "Réparation", "Livraison"],
|
||||
fleuriste: ["Fleuriste", "Bouquets", "Plantes", "Mariage", "Deuil", "Livraison de fleurs"],
|
||||
jardinerie: ["Jardinerie", "Plantes", "Motoculture", "Terreau", "Conseils jardin"],
|
||||
"bricolage-jardinage": ["Bricolage", "Jardinage", "Outillage", "Quincaillerie"],
|
||||
animalerie: ["Animalerie", "Alimentation animale", "Toilettage", "Accessoires"],
|
||||
// Artisanat & bâtiment
|
||||
artisanat: ["Artisan", "Fait main", "Création", "Sur mesure"],
|
||||
batiment: ["Bâtiment", "Rénovation", "Travaux", "Devis gratuit"],
|
||||
architecte: ["Architecte", "Maître d'œuvre", "Permis de construire", "Rénovation"],
|
||||
maconnerie: ["Maçonnerie", "Gros œuvre", "Terrassement", "Rénovation"],
|
||||
"couverture-toiture": ["Couvreur", "Toiture", "Zinguerie", "Isolation de toiture", "Urgence"],
|
||||
plomberie: ["Plombier", "Chauffagiste", "Chaudière", "Pompe à chaleur", "Dépannage"],
|
||||
electricite: ["Électricien", "Installation électrique", "Mise aux normes", "Domotique", "Dépannage"],
|
||||
"isolation-energie": ["Isolation", "Énergies renouvelables", "Panneaux solaires", "Rénovation énergétique"],
|
||||
menuiserie: ["Menuisier", "Menuiserie", "Ébénisterie", "Sur mesure", "Fenêtres"],
|
||||
"serrurerie-metallerie": ["Serrurier", "Métallerie", "Portails", "Garde-corps", "Urgence"],
|
||||
"carrelage-sols": ["Carreleur", "Carrelage", "Parquet", "Revêtements de sols"],
|
||||
peinture: ["Peintre", "Peinture", "Décoration intérieure", "Papier peint", "Ravalement"],
|
||||
paysagiste: ["Paysagiste", "Espaces verts", "Entretien de jardin", "Clôtures", "Élagage"],
|
||||
"depannage-multiservices": ["Dépannage", "Multiservices", "Petits travaux", "Homme toutes mains"],
|
||||
// Auto & mobilité
|
||||
automobile: ["Garage", "Entretien auto", "Réparation", "Pneus", "Contrôle technique"],
|
||||
carrosserie: ["Carrosserie", "Peinture auto", "Pare-brise", "Débosselage"],
|
||||
"controle-technique": ["Contrôle technique", "Contre-visite", "Sans rendez-vous"],
|
||||
"station-service-lavage": ["Station-service", "Carburant", "Lavage auto", "Boutique"],
|
||||
"location-vehicules": ["Location de voitures", "Location utilitaire", "Location camionnette"],
|
||||
"cycles-motos": ["Vélos", "Motos", "Vélo électrique", "Réparation", "Accessoires"],
|
||||
"auto-ecole": ["Auto-école", "Permis B", "Code de la route", "Conduite accompagnée"],
|
||||
"taxi-vtc": ["Taxi", "VTC", "Transport de personnes", "Gare", "Aéroport"],
|
||||
// Santé
|
||||
sante: ["Pharmacie", "Santé", "Parapharmacie", "Orthopédie", "Conseils"],
|
||||
medical: ["Médical", "Paramédical", "Soins", "Sur rendez-vous"],
|
||||
audioprothese: ["Audioprothésiste", "Appareils auditifs", "Bilan auditif", "Piles"],
|
||||
veterinaire: ["Vétérinaire", "Soins animaux", "Vaccination", "Urgences vétérinaires"],
|
||||
// Banque, assurance & immobilier
|
||||
"banque-assurance": ["Banque", "Assurance", "Crédit", "Épargne", "Professionnels"],
|
||||
assurance: ["Assurance", "Assurance auto", "Assurance habitation", "Mutuelle santé", "Prévoyance", "Assurance professionnelle"],
|
||||
"courtier-credit": ["Courtier", "Crédit immobilier", "Rachat de crédit", "Financement", "Assurance emprunteur"],
|
||||
immobilier: ["Agence immobilière", "Vente", "Location", "Estimation gratuite", "Gestion locative"],
|
||||
"diagnostic-immobilier": ["Diagnostic immobilier", "DPE", "Expertise", "Amiante", "Plomb"],
|
||||
// Services aux particuliers
|
||||
services: ["Services", "Proximité", "Sur devis"],
|
||||
"services-a-la-personne": ["Aide à domicile", "Ménage", "Garde d'enfants", "Services à la personne", "Crédit d'impôt"],
|
||||
"pressing-retouches": ["Pressing", "Retouches", "Cordonnerie", "Nettoyage à sec", "Clés"],
|
||||
photographe: ["Photographe", "Portrait", "Mariage", "Photo d'identité", "Vidéo"],
|
||||
"pompes-funebres": ["Pompes funèbres", "Obsèques", "Marbrerie", "Prévoyance obsèques"],
|
||||
"agence-voyages": ["Agence de voyages", "Séjours", "Billets", "Croisières", "Voyages de groupe"],
|
||||
demenagement: ["Déménagement", "Garde-meubles", "Cartons", "Devis gratuit"],
|
||||
// Services aux entreprises
|
||||
comptabilite: ["Expert-comptable", "Comptabilité", "Gestion", "Création d'entreprise", "Paie"],
|
||||
juridique: ["Notaire", "Juridique", "Avocat", "Conseil", "Succession"],
|
||||
conseil: ["Conseil", "Consultant", "Stratégie", "Accompagnement", "Formation"],
|
||||
communication: ["Communication", "Site web", "Marketing", "Réseaux sociaux", "Graphisme"],
|
||||
"imprimerie-enseignes": ["Imprimerie", "Enseignes", "Signalétique", "Flocage", "Cartes de visite"],
|
||||
informatique: ["Informatique", "Dépannage informatique", "Téléphonie", "Réparation", "Réseau"],
|
||||
nettoyage: ["Nettoyage", "Entretien de locaux", "Vitres", "Fin de chantier"],
|
||||
transport: ["Transport", "Logistique", "Livraison", "Messagerie"],
|
||||
securite: ["Sécurité", "Alarme", "Vidéosurveillance", "Gardiennage"],
|
||||
coworking: ["Coworking", "Bureaux", "Salle de réunion", "Domiciliation"],
|
||||
"interim-rh": ["Intérim", "Recrutement", "Ressources humaines", "Emploi"],
|
||||
"recyclage-dechets": ["Recyclage", "Déchets", "Débarras", "Ferraille", "Location de bennes"],
|
||||
// Culture, loisirs & formation
|
||||
"librairie-papeterie": ["Librairie", "Papeterie", "Presse", "Livres", "Fournitures scolaires"],
|
||||
"tabac-presse": ["Tabac", "Presse", "Loto", "FDJ", "Relais colis"],
|
||||
"jouets-puericulture": ["Jouets", "Puériculture", "Jeux", "Cadeaux enfants", "Liste de naissance"],
|
||||
"sport-loisirs": ["Sport", "Loisirs", "Activités", "Cours", "Stages"],
|
||||
"articles-de-sport": ["Articles de sport", "Équipement sportif", "Chaussures de sport", "Textile"],
|
||||
fitness: ["Salle de sport", "Fitness", "Musculation", "Cours collectifs", "Coaching"],
|
||||
"culture-arts": ["Culture", "Arts", "Galerie", "Cours", "Spectacles"],
|
||||
"education-formation": ["Formation", "Cours", "Soutien scolaire", "Formation professionnelle"],
|
||||
"tourisme-hotellerie": ["Hôtel", "Hébergement", "Chambres d'hôtes", "Gîte", "Séminaires"],
|
||||
evenementiel: ["Événementiel", "Organisation d'événements", "Mariage", "Location de matériel", "Animation"],
|
||||
// Entreprise, production & institutions
|
||||
industrie: ["Industrie", "Production", "Fabrication", "Sous-traitance"],
|
||||
agriculture: ["Producteur local", "Ferme", "Vente directe", "Produits fermiers", "Circuit court"],
|
||||
"partenaire-institutionnel": ["Partenaire", "Collectivité", "Institution", "Service public"],
|
||||
};
|
||||
|
||||
/**
|
||||
* Vocabulaire transversal détecté dans la description. Chaque motif est testé
|
||||
* sur le texte normalisé (minuscules, sans accents).
|
||||
*/
|
||||
export const DESCRIPTION_VOCABULARY: { tag: string; patterns: RegExp[] }[] = [
|
||||
{ tag: "Fait maison", patterns: [/\bfaite?s? maison\b/, /\bmaison\b.*\b(pain|plat|patisserie|gateau|cuisine)/] },
|
||||
{ tag: "Artisanal", patterns: [/\bartisanal(e|es|aux)?\b/, /\bartisan\b/] },
|
||||
{ tag: "Bio", patterns: [/\bbio(logique|logiques)?\b/] },
|
||||
{ tag: "Produits locaux", patterns: [/\blocal(e|es)?\b/, /\blocaux\b/, /\bterroir\b/, /\bcircuit court\b/] },
|
||||
{ tag: "Sur commande", patterns: [/\bsur commande\b/, /\bcommandes?\b/] },
|
||||
{ tag: "Livraison", patterns: [/\blivraisons?\b/, /\blivr(e|ons|ez)\b/] },
|
||||
{ tag: "À emporter", patterns: [/\ba emporter\b/, /\bvente a emporter\b/] },
|
||||
{ tag: "Click & collect", patterns: [/\bclick\s*(&|and|et)\s*collect\b/, /\bretrait en magasin\b/] },
|
||||
{ tag: "Sur rendez-vous", patterns: [/\bsur rendez[- ]vous\b/, /\brdv\b/] },
|
||||
{ tag: "Sans rendez-vous", patterns: [/\bsans rendez[- ]vous\b/] },
|
||||
{ tag: "Devis gratuit", patterns: [/\bdevis gratuits?\b/, /\bdevis\b/] },
|
||||
{ tag: "Dépannage", patterns: [/\bdepannages?\b/, /\burgences?\b/, /\b7j\s*\/\s*7\b/, /\b24h\s*\/\s*24\b/] },
|
||||
{ tag: "Sur mesure", patterns: [/\bsur[- ]mesure\b/, /\bpersonnalis(e|es|ee|ees|ation)\b/] },
|
||||
{ tag: "Terrasse", patterns: [/\bterrasses?\b/] },
|
||||
{ tag: "Parking", patterns: [/\bparking\b/, /\bstationnement\b/] },
|
||||
{ tag: "Ouvert le dimanche", patterns: [/\bdimanche\b/] },
|
||||
{ tag: "Accessible PMR", patterns: [/\bpmr\b/, /\bhandicap/, /\baccessible\b/] },
|
||||
{ tag: "Événements", patterns: [/\bevenements?\b/, /\bmariages?\b/, /\bseminaires?\b/, /\banniversaires?\b/] },
|
||||
{ tag: "Professionnels", patterns: [/\bprofessionnels\b/, /\bentreprises\b/, /\bb ?to ?b\b/, /\bb2b\b/] },
|
||||
{ tag: "Cadeaux", patterns: [/\bcadeaux?\b/, /\bcoffrets?\b/, /\bcartes? cadeau\b/] },
|
||||
{ tag: "Conseil", patterns: [/\bconseils?\b/, /\baccompagnement\b/] },
|
||||
{ tag: "Formation", patterns: [/\bformations?\b/, /\bcours\b/, /\bateliers?\b/, /\bstages?\b/] },
|
||||
{ tag: "Nouveau", patterns: [/\bnouveau\b/, /\bnouvelle\b/, /\bouverture\b/] },
|
||||
];
|
||||
|
||||
export function normalizeText(value: string): string {
|
||||
return value
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[’']/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** « a, b ,, c » → ["a", "b", "c"], sans doublons (accents et casse ignorés). */
|
||||
export function parseTags(value: string | null | undefined): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const raw of (value ?? "").split(/[,;\n]/)) {
|
||||
const tag = raw.replace(/\s+/g, " ").trim();
|
||||
if (!tag) continue;
|
||||
const key = normalizeText(tag);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(tag);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function formatTags(tags: string[]): string {
|
||||
return parseTags(tags.join(", ")).join(", ");
|
||||
}
|
||||
|
||||
/** Tags du métier : depuis le vocabulaire, sinon dérivés du libellé de la catégorie. */
|
||||
export function categoryTags(categorySlug: string | null | undefined, categoryLabel: string | null | undefined): string[] {
|
||||
if (categorySlug && CATEGORY_TAGS[categorySlug]) return [...CATEGORY_TAGS[categorySlug]];
|
||||
if (!categoryLabel) return [];
|
||||
return categoryLabel
|
||||
.split(/\s*(?:–|—|-|\/|&|,)\s*/)
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part.length > 1);
|
||||
}
|
||||
|
||||
/** Termes du vocabulaire transversal présents dans la description. */
|
||||
export function tagsFromDescription(description: string | null | undefined): string[] {
|
||||
const text = normalizeText(description ?? "");
|
||||
if (!text) return [];
|
||||
return DESCRIPTION_VOCABULARY.filter((entry) => entry.patterns.some((p) => p.test(text))).map((e) => e.tag);
|
||||
}
|
||||
|
||||
export type TagSuggestionInput = {
|
||||
categorySlug?: string | null;
|
||||
categoryLabel?: string | null;
|
||||
city?: string | null;
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Suggestions ordonnées : le métier d'abord (c'est ce que l'on cherche le plus),
|
||||
* puis la commune, puis ce que la description révèle. Plafonné à `MAX_TAGS`.
|
||||
*/
|
||||
export function suggestTags(input: TagSuggestionInput, max = MAX_TAGS): string[] {
|
||||
const fromCategory = categoryTags(input.categorySlug, input.categoryLabel);
|
||||
const city = (input.city ?? "").trim();
|
||||
const fromDescription = tagsFromDescription(input.description);
|
||||
// Un mot-clé métier qui figure aussi dans la description remonte en tête : il
|
||||
// décrit vraiment l'activité, pas seulement la famille de métier.
|
||||
const text = normalizeText(input.description ?? "");
|
||||
const ranked = [...fromCategory].sort((a, b) => Number(text.includes(normalizeText(b))) - Number(text.includes(normalizeText(a))));
|
||||
return parseTags([...ranked, city, ...fromDescription].join(", ")).slice(0, max);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tags à enregistrer : la saisie de l'adhérent si elle existe, sinon les
|
||||
* suggestions. Ne modifie jamais des tags renseignés.
|
||||
*/
|
||||
export function autoTags(existing: string | null | undefined, input: TagSuggestionInput): string | null {
|
||||
const own = parseTags(existing);
|
||||
if (own.length > 0) return formatTags(own);
|
||||
const suggested = suggestTags(input);
|
||||
return suggested.length > 0 ? formatTags(suggested) : null;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import { CATEGORY_REFERENTIAL } from "../src/db/categories";
|
||||
import { CATEGORY_TAGS, MAX_TAGS, autoTags, formatTags, parseTags, suggestTags, tagsFromDescription } from "../src/lib/tags";
|
||||
|
||||
describe("Tags automatiques", () => {
|
||||
it("couvre chaque métier du référentiel", () => {
|
||||
for (const c of CATEGORY_REFERENTIAL) {
|
||||
assert.ok((CATEGORY_TAGS[c.slug] ?? []).length >= 3, `vocabulaire manquant ou trop court : ${c.slug}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("nettoie et dédoublonne la saisie", () => {
|
||||
assert.deepEqual(parseTags(" Pain ,pain, PAIN ;Levain,, Fait maison "), ["Pain", "Levain", "Fait maison"]);
|
||||
assert.deepEqual(parseTags("Épicerie, epicerie"), ["Épicerie"]);
|
||||
assert.equal(formatTags(["a", "b", "a"]), "a, b");
|
||||
});
|
||||
|
||||
it("lit la description sans accents ni casse", () => {
|
||||
const tags = tagsFromDescription("Pains au LEVAIN faits maison, produits LOCAUX, livraison le dimanche. Devis gratuit.");
|
||||
for (const expected of ["Fait maison", "Produits locaux", "Livraison", "Ouvert le dimanche", "Devis gratuit"]) {
|
||||
assert.ok(tags.includes(expected), `${expected} attendu dans ${tags.join(", ")}`);
|
||||
}
|
||||
assert.deepEqual(tagsFromDescription(""), []);
|
||||
});
|
||||
|
||||
it("suggère métier, commune puis description, dans la limite", () => {
|
||||
const tags = suggestTags({
|
||||
categorySlug: "boulangerie",
|
||||
city: "Frouard",
|
||||
description: "Boulangerie artisanale : pain au levain et viennoiseries faites maison.",
|
||||
});
|
||||
assert.equal(tags[0], "Boulangerie");
|
||||
assert.ok(tags.includes("Frouard"));
|
||||
assert.ok(tags.includes("Fait maison"));
|
||||
assert.ok(tags.includes("Artisanal"));
|
||||
assert.ok(tags.length <= MAX_TAGS);
|
||||
// Les mots du métier cités dans la description passent devant les autres.
|
||||
assert.ok(tags.indexOf("Pain") < tags.indexOf("Sandwichs"));
|
||||
});
|
||||
|
||||
it("dérive du libellé quand le slug est inconnu (catégorie créée dans le backend)", () => {
|
||||
assert.deepEqual(suggestTags({ categorySlug: "ma-categorie", categoryLabel: "Toilettage – Pension canine" }), [
|
||||
"Toilettage",
|
||||
"Pension canine",
|
||||
]);
|
||||
});
|
||||
|
||||
it("ne remplace jamais une saisie existante", () => {
|
||||
assert.equal(autoTags("Kouglof, Brioche", { categorySlug: "boulangerie", city: "Frouard" }), "Kouglof, Brioche");
|
||||
assert.equal(autoTags(" ", { categorySlug: "assurance", city: "Pompey" }), "Assurance, Assurance auto, Assurance habitation, Mutuelle santé, Prévoyance, Assurance professionnelle, Pompey");
|
||||
assert.equal(autoTags("", {}), null);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user