diff --git a/CLAUDE.md b/CLAUDE.md index 6bd4e42..cb3c94a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `
` et un seul `

`. +- **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 diff --git a/src/app/adherents/[id]/page.tsx b/src/app/adherents/[id]/page.tsx index e562905..400cd07 100644 --- a/src/app/adherents/[id]/page.tsx +++ b/src/app/adherents/[id]/page.tsx @@ -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), ]} /> diff --git a/src/app/backend/actions.ts b/src/app/backend/actions.ts index 8265dba..bc78613 100644 --- a/src/app/backend/actions.ts +++ b/src/app/backend/actions.ts @@ -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 { + 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 { 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 - -

- Mots-clés que vos clients recherchent : produits, services, marques, quartier. Ils s'affichent sur - votre fiche et sont transmis aux moteurs de recherche. -

+
diff --git a/src/app/backend/espace/page.tsx b/src/app/backend/espace/page.tsx index e38c3bf..b857a82 100644 --- a/src/app/backend/espace/page.tsx +++ b/src/app/backend/espace/page.tsx @@ -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(); 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() {
- -

- Mots-clés que vos clients recherchent : produits, services, marques, quartier. Ils s'affichent sur - votre fiche et sont transmis aux moteurs de recherche. -

+
diff --git a/src/app/globals.css b/src/app/globals.css index 37bb2ce..8e03ad5 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; diff --git a/src/components/TagsField.tsx b/src/components/TagsField.tsx new file mode 100644 index 0000000..fc9d226 --- /dev/null +++ b/src/components/TagsField.tsx @@ -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 + * ` + {suggestions.length > 0 && ( +
+ Suggestions : + {suggestions.map((tag) => ( + + ))} + +
+ )} +

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

+
+ ); +} diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 8d12e9e..9a37aad 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -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, }) diff --git a/src/lib/tags.ts b/src/lib/tags.ts new file mode 100644 index 0000000..b1b4b57 --- /dev/null +++ b/src/lib/tags.ts @@ -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 = { + // 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(); + 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; +} diff --git a/tests/tags.test.ts b/tests/tags.test.ts new file mode 100644 index 0000000..912748f --- /dev/null +++ b/tests/tags.test.ts @@ -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); + }); +});