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).
|
`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
|
- `/backend`, `/login`, `/inscription/*` et les fiches non actives sont en
|
||||||
`NOINDEX` ; les pages publiques utilisent `<main>` et un seul `<h1>`.
|
`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
|
- **URLs de fiche** : `memberPath({ id, name, city })` donne
|
||||||
`/adherents/12-au-bon-pain-frouard`. L'identifiant en tête suffit
|
`/adherents/12-au-bon-pain-frouard`. L'identifiant en tête suffit
|
||||||
(`parseMemberParam`), toute autre écriture est redirigée en 301 vers la forme
|
(`parseMemberParam`), toute autre écriture est redirigée en 301 vers la forme
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
parseMemberParam,
|
parseMemberParam,
|
||||||
} from "@/lib/seo";
|
} from "@/lib/seo";
|
||||||
import { publicBaseUrl } from "@/lib/seo-server";
|
import { publicBaseUrl } from "@/lib/seo-server";
|
||||||
|
import { autoTags, formatTags, parseTags } from "@/lib/tags";
|
||||||
import {
|
import {
|
||||||
formatSlots,
|
formatSlots,
|
||||||
getOpenStatus,
|
getOpenStatus,
|
||||||
@@ -152,7 +153,16 @@ export default async function FicheAdherentPage({
|
|||||||
[member.address, member.postalCode, member.city].filter(Boolean).join(", ")
|
[member.address, member.postalCode, member.city].filter(Boolean).join(", ")
|
||||||
);
|
);
|
||||||
const paragraphs = (member.description ?? "").split("\n").map((s) => s.trim()).filter(Boolean);
|
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 hours = parseMemberHours(member.hours);
|
||||||
const openStatus = getOpenStatus(hours);
|
const openStatus = getOpenStatus(hours);
|
||||||
const normalizedWebsite = normalizeWebsite(member.website);
|
const normalizedWebsite = normalizeWebsite(member.website);
|
||||||
@@ -182,7 +192,7 @@ export default async function FicheAdherentPage({
|
|||||||
{ name: "Annuaire", path: "/annuaire" },
|
{ name: "Annuaire", path: "/annuaire" },
|
||||||
{ name: member.name, path: canonical },
|
{ 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 { db } from "@/db";
|
||||||
import { CATEGORY_PALETTE } from "@/db/categories";
|
import { CATEGORY_PALETTE } from "@/db/categories";
|
||||||
import { slugify } from "@/lib/slug";
|
import { slugify } from "@/lib/slug";
|
||||||
|
import { autoTags } from "@/lib/tags";
|
||||||
import {
|
import {
|
||||||
activityLog,
|
activityLog,
|
||||||
categories,
|
categories,
|
||||||
@@ -401,6 +402,28 @@ export type IssuedCredentials = { email: string; tempPassword: string };
|
|||||||
|
|
||||||
export type CreatedMemberAccount = IssuedCredentials & { memberId: number };
|
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> {
|
export async function addMember(formData: FormData): Promise<CreatedMemberAccount | undefined> {
|
||||||
const { role } = await requireRole();
|
const { role } = await requireRole();
|
||||||
if (!can(role, "manageMembers")) throw new Error("Accès refusé");
|
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.");
|
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
|
const [newMember] = await db
|
||||||
.insert(members)
|
.insert(members)
|
||||||
.values({ name, email, categoryId, city, status })
|
.values({ name, email, categoryId, city, status, tags })
|
||||||
.returning({ id: members.id });
|
.returning({ id: members.id });
|
||||||
|
|
||||||
// Compte de connexion adhérent avec mot de passe temporaire à changer.
|
// 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"));
|
const id = Number(formData.get("id"));
|
||||||
if (!id) return;
|
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
|
await db
|
||||||
.update(members)
|
.update(members)
|
||||||
.set({
|
.set({
|
||||||
name: String(formData.get("name") ?? "").trim(),
|
name: String(formData.get("name") ?? "").trim(),
|
||||||
email: String(formData.get("email") ?? "").trim(),
|
email: String(formData.get("email") ?? "").trim(),
|
||||||
categoryId: formData.get("categoryId") ? Number(formData.get("categoryId")) : null,
|
categoryId,
|
||||||
city: String(formData.get("city") ?? "").trim() || null,
|
city,
|
||||||
address: String(formData.get("address") ?? "").trim() || null,
|
address: String(formData.get("address") ?? "").trim() || null,
|
||||||
description: String(formData.get("description") ?? "").trim() || null,
|
description,
|
||||||
postalCode: String(formData.get("postalCode") ?? "").trim() || null,
|
postalCode: String(formData.get("postalCode") ?? "").trim() || null,
|
||||||
phone: String(formData.get("phone") ?? "").trim() || null,
|
phone: String(formData.get("phone") ?? "").trim() || null,
|
||||||
website: normalizeWebsite(String(formData.get("website") ?? "")),
|
website: normalizeWebsite(String(formData.get("website") ?? "")),
|
||||||
memberSince: formData.get("memberSince") ? Number(formData.get("memberSince")) : null,
|
memberSince: formData.get("memberSince") ? Number(formData.get("memberSince")) : null,
|
||||||
coverUrl: String(formData.get("coverUrl") ?? "").trim() || null,
|
coverUrl: String(formData.get("coverUrl") ?? "").trim() || null,
|
||||||
logoUrl: String(formData.get("logoUrl") ?? "").trim() || null,
|
logoUrl: String(formData.get("logoUrl") ?? "").trim() || null,
|
||||||
tags: String(formData.get("tags") ?? "").trim() || null,
|
tags,
|
||||||
hours: String(formData.get("hours") ?? "").trim() || null,
|
hours: String(formData.get("hours") ?? "").trim() || null,
|
||||||
status: String(formData.get("status") ?? "pending") as "active" | "pending",
|
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();
|
const name = String(formData.get("name") ?? "").trim();
|
||||||
if (!name) return;
|
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
|
await db
|
||||||
.update(members)
|
.update(members)
|
||||||
.set({
|
.set({
|
||||||
name,
|
name,
|
||||||
description: String(formData.get("description") ?? "").trim() || null,
|
description,
|
||||||
address: String(formData.get("address") ?? "").trim() || null,
|
address: String(formData.get("address") ?? "").trim() || null,
|
||||||
postalCode: String(formData.get("postalCode") ?? "").trim() || null,
|
postalCode: String(formData.get("postalCode") ?? "").trim() || null,
|
||||||
city: String(formData.get("city") ?? "").trim() || null,
|
city,
|
||||||
phone: String(formData.get("phone") ?? "").trim() || null,
|
phone: String(formData.get("phone") ?? "").trim() || null,
|
||||||
website: normalizeWebsite(String(formData.get("website") ?? "")),
|
website: normalizeWebsite(String(formData.get("website") ?? "")),
|
||||||
hours: String(formData.get("hours") ?? "").trim() || null,
|
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
|
// Images restreintes aux data-URI : un adhérent ne peut pas faire pointer
|
||||||
// sa fiche publique vers une ressource externe qu'il contrôle.
|
// sa fiche publique vers une ressource externe qu'il contrôle.
|
||||||
coverUrl: asImageDataUri(formData, "coverUrl"),
|
coverUrl: asImageDataUri(formData, "coverUrl"),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { categories, members, users } from "@/db/schema";
|
|||||||
import { can } from "@/lib/rbac";
|
import { can } from "@/lib/rbac";
|
||||||
import { ImageField } from "@/components/ImageField";
|
import { ImageField } from "@/components/ImageField";
|
||||||
import { HoursEditor } from "@/components/HoursEditor";
|
import { HoursEditor } from "@/components/HoursEditor";
|
||||||
|
import { TagsField } from "@/components/TagsField";
|
||||||
import { communeOptions } from "@/lib/communes";
|
import { communeOptions } from "@/lib/communes";
|
||||||
import { deleteMember, updateMember } from "../../actions";
|
import { deleteMember, updateMember } from "../../actions";
|
||||||
import { ResetPasswordButton } from "../ResetPasswordButton";
|
import { ResetPasswordButton } from "../ResetPasswordButton";
|
||||||
@@ -31,7 +32,7 @@ export default async function EditMemberPage({
|
|||||||
if (!member) notFound();
|
if (!member) notFound();
|
||||||
|
|
||||||
const cats = await db
|
const cats = await db
|
||||||
.select({ id: categories.id, label: categories.label })
|
.select({ id: categories.id, slug: categories.slug, label: categories.label })
|
||||||
.from(categories)
|
.from(categories)
|
||||||
.orderBy(asc(categories.sort));
|
.orderBy(asc(categories.sort));
|
||||||
|
|
||||||
@@ -142,11 +143,7 @@ export default async function EditMemberPage({
|
|||||||
|
|
||||||
<div style={{ marginTop: 16 }}>
|
<div style={{ marginTop: 16 }}>
|
||||||
<label className="field-label">Tags / spécialités (séparés par des virgules)</label>
|
<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} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: 16 }}>
|
<div style={{ marginTop: 16 }}>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { configuredNetworks, SOCIAL_LABELS, type SocialNetwork } from "@/lib/soc
|
|||||||
import { ImageField } from "@/components/ImageField";
|
import { ImageField } from "@/components/ImageField";
|
||||||
import { ImageConsentForm } from "@/components/ImageConsentForm";
|
import { ImageConsentForm } from "@/components/ImageConsentForm";
|
||||||
import { HoursEditor } from "@/components/HoursEditor";
|
import { HoursEditor } from "@/components/HoursEditor";
|
||||||
|
import { TagsField } from "@/components/TagsField";
|
||||||
import { communeOptions } from "@/lib/communes";
|
import { communeOptions } from "@/lib/communes";
|
||||||
import {
|
import {
|
||||||
saveImageConsent,
|
saveImageConsent,
|
||||||
@@ -53,6 +54,7 @@ export default async function EspacePage() {
|
|||||||
let lastShare = new Map<string, { status: string; url: string | null; error: string | null }>();
|
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 myRegistrations: { meetingId: number; title: string; startsAt: Date; location: string | null; participants: number; confirmed: boolean }[] = [];
|
||||||
let latestConsent: { decision: string; createdAt: Date } | null = null;
|
let latestConsent: { decision: string; createdAt: Date } | null = null;
|
||||||
|
let profileCategory: { slug: string; label: string } | null = null;
|
||||||
|
|
||||||
if (memberId) {
|
if (memberId) {
|
||||||
const [m] = await db.select().from(members).where(eq(members.id, memberId));
|
const [m] = await db.select().from(members).where(eq(members.id, memberId));
|
||||||
@@ -60,8 +62,9 @@ export default async function EspacePage() {
|
|||||||
profile = m;
|
profile = m;
|
||||||
memberName = m.name;
|
memberName = m.name;
|
||||||
const [cat] = m.categoryId
|
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(" · ");
|
subtitle = ["Espace adhérent", cat?.label, m.city].filter(Boolean).join(" · ");
|
||||||
}
|
}
|
||||||
myPromos = await db
|
myPromos = await db
|
||||||
@@ -239,11 +242,7 @@ export default async function EspacePage() {
|
|||||||
|
|
||||||
<div style={{ marginTop: 16 }}>
|
<div style={{ marginTop: 16 }}>
|
||||||
<label className="field-label">Tags / spécialités (séparés par des virgules)</label>
|
<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" />
|
<TagsField defaultValue={profile.tags} fixedCategory={profileCategory} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: 16 }}>
|
<div style={{ marginTop: 16 }}>
|
||||||
|
|||||||
@@ -948,6 +948,40 @@ img {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
margin-bottom: 6px;
|
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. */
|
/* Conseil de rédaction sous un champ : discret, mais lu avant de saisir. */
|
||||||
.field-hint {
|
.field-hint {
|
||||||
margin: 6px 0 0;
|
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,
|
hours: members.hours,
|
||||||
tags: members.tags,
|
tags: members.tags,
|
||||||
categoryLabel: categories.label,
|
categoryLabel: categories.label,
|
||||||
|
categorySlug: categories.slug,
|
||||||
accent: categories.accent,
|
accent: categories.accent,
|
||||||
tint: categories.tint,
|
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