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

+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;