mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-22 14:48:04 +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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user