From f9d0d6c825cb6ca3684e92b9ca4589a7aa963a8e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 06:18:37 +0000 Subject: [PATCH] =?UTF-8?q?Accueil,=20pied=20de=20page=20et=20cat=C3=A9gor?= =?UTF-8?q?ies=20:=20compteur=20r=C3=A9el,=20boutons=20align=C3=A9s,=20r?= =?UTF-8?q?=C3=A9f=C3=A9rentiel=20compl=C3=A9t=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Accueil : le bandeau « Faites partie de l'aventure » affiche le nombre d'adhérents validés (statut active) au lieu de « plus de 120 » codé en dur. - Pied de page : les boutons Facebook / LinkedIn ont la même largeur (grille à une colonne dimensionnée sur le plus long) et une icône à largeur fixe. - Catégories : référentiel unique dans src/db/categories.ts (104 métiers, dont assurance, bazar/discount, grande distribution, librairie-presse, tabac, vétérinaire, auto-école, etc.), inséré par la migration 0012 avec ON CONFLICT (slug) : les catégories manquantes apparaissent au prochain démarrage, sans écraser un libellé renommé depuis le backend. Le seed et l'action « Ajouter une catégorie » réutilisent ce référentiel et sa palette. - tests/categories.test.ts vérifie unicité des slugs, contraintes de la table et cohérence entre le référentiel et la migration. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HDbKbJrPWweXXYSxA8MXW7 --- CLAUDE.md | 6 + drizzle/0012_referentiel_categories.sql | 109 ++ drizzle/meta/0012_snapshot.json | 1371 +++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/app/backend/actions.ts | 11 +- src/app/globals.css | 12 +- src/app/page.tsx | 10 +- src/components/SiteFooter.tsx | 2 +- src/db/categories.ts | 151 +++ src/db/seed.ts | 92 +- src/lib/queries.ts | 9 + tests/categories.test.ts | 46 + 12 files changed, 1718 insertions(+), 108 deletions(-) create mode 100644 drizzle/0012_referentiel_categories.sql create mode 100644 drizzle/meta/0012_snapshot.json create mode 100644 src/db/categories.ts create mode 100644 tests/categories.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 15efe12..b705d24 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,6 +41,12 @@ docker compose up --build # full stack - **Data reads** for the public site live in `src/lib/queries.ts`. - After changing `src/db/schema.ts`, run `npm run db:generate` and commit the new file under `drizzle/`. +- **Catégories** : le référentiel vit dans `src/db/categories.ts` (slugs stables, + jamais renommés). Il est inséré par la migration `0012_referentiel_categories` + (`ON CONFLICT (slug)` : seul `sort` est réaligné, un libellé renommé depuis le + backend est conservé) et rejoué par le seed. Pour ajouter une catégorie, on + l'ajoute au référentiel **et** on génère une nouvelle migration `--custom` + du même modèle ; `tests/categories.test.ts` vérifie la cohérence. ## Promotions diff --git a/drizzle/0012_referentiel_categories.sql b/drizzle/0012_referentiel_categories.sql new file mode 100644 index 0000000..93236fe --- /dev/null +++ b/drizzle/0012_referentiel_categories.sql @@ -0,0 +1,109 @@ +-- Référentiel des catégories (source : src/db/categories.ts). +-- Insère les catégories manquantes ; pour celles qui existent déjà, seul l'ordre +-- d'affichage est réaligné : un libellé renommé depuis le backend est conservé. +INSERT INTO "categories" ("slug", "label", "accent", "tint", "sort") VALUES + ('alimentation', 'Alimentation / Épicerie', '#E0A63C', '#f6efdc', 1), + ('epicerie-fine', 'Épicerie fine – Produits du terroir', '#6FB0C6', '#e7f0f3', 2), + ('supermarche', 'Supermarché – Grande distribution', '#9a6638', '#f4ebda', 3), + ('boulangerie', 'Boulangerie – Pâtisserie', '#2C6FB3', '#eaf0f6', 4), + ('boucherie-charcuterie', 'Boucherie – Charcuterie', '#5a7a5a', '#eef0ec', 5), + ('poissonnerie', 'Poissonnerie', '#7a6f9c', '#efe9f3', 6), + ('primeur', 'Primeur – Fruits & Légumes', '#c98a2e', '#f7efe0', 7), + ('fromagerie', 'Fromagerie – Crèmerie', '#3f8aa3', '#e6eff2', 8), + ('cave', 'Cave – Vins & Spiritueux', '#E0A63C', '#f6efdc', 9), + ('brasserie-artisanale', 'Brasserie artisanale – Boissons', '#6FB0C6', '#e7f0f3', 10), + ('chocolaterie', 'Chocolaterie – Confiserie', '#9a6638', '#f4ebda', 11), + ('glacier-salon-de-the', 'Glacier – Salon de thé', '#2C6FB3', '#eaf0f6', 12), + ('traiteur', 'Traiteur', '#5a7a5a', '#eef0ec', 13), + ('restauration', 'Restauration', '#7a6f9c', '#efe9f3', 14), + ('pizzeria', 'Pizzeria', '#c98a2e', '#f7efe0', 15), + ('restauration-rapide', 'Restauration rapide – Food truck', '#3f8aa3', '#e6eff2', 16), + ('cafe-bar', 'Café – Bar – Brasserie', '#E0A63C', '#f6efdc', 17), + ('mode-beaute', 'Mode & Beauté', '#6FB0C6', '#e7f0f3', 18), + ('mode-vetements', 'Mode & Vêtements', '#9a6638', '#f4ebda', 19), + ('lingerie', 'Lingerie', '#2C6FB3', '#eaf0f6', 20), + ('vetements-enfants', 'Vêtements enfants', '#5a7a5a', '#eef0ec', 21), + ('chaussures-maroquinerie', 'Chaussures – Maroquinerie', '#7a6f9c', '#efe9f3', 22), + ('bijouterie', 'Bijouterie – Horlogerie', '#c98a2e', '#f7efe0', 23), + ('coiffure', 'Coiffure', '#3f8aa3', '#e6eff2', 24), + ('barbier', 'Barbier', '#E0A63C', '#f6efdc', 25), + ('esthetique', 'Esthétique – Institut', '#6FB0C6', '#e7f0f3', 26), + ('onglerie', 'Onglerie – Prothésie ongulaire', '#9a6638', '#f4ebda', 27), + ('parfumerie', 'Parfumerie', '#2C6FB3', '#eaf0f6', 28), + ('bien-etre', 'Bien-être – Spa – Massage', '#5a7a5a', '#eef0ec', 29), + ('optique', 'Optique', '#7a6f9c', '#efe9f3', 30), + ('tatouage', 'Tatouage – Piercing', '#c98a2e', '#f7efe0', 31), + ('bazar-discount', 'Bazar – Discount – Équipement de la maison', '#3f8aa3', '#e6eff2', 32), + ('decoration', 'Décoration – Ameublement', '#E0A63C', '#f6efdc', 33), + ('cuisiniste', 'Cuisines – Salles de bain', '#6FB0C6', '#e7f0f3', 34), + ('literie', 'Literie', '#9a6638', '#f4ebda', 35), + ('arts-de-la-table', 'Arts de la table – Cadeaux', '#2C6FB3', '#eaf0f6', 36), + ('electromenager-multimedia', 'Électroménager – Multimédia', '#5a7a5a', '#eef0ec', 37), + ('fleuriste', 'Fleuriste', '#7a6f9c', '#efe9f3', 38), + ('jardinerie', 'Jardinerie – Motoculture', '#c98a2e', '#f7efe0', 39), + ('bricolage-jardinage', 'Bricolage – Jardinage', '#3f8aa3', '#e6eff2', 40), + ('animalerie', 'Animalerie – Services animaliers', '#E0A63C', '#f6efdc', 41), + ('artisanat', 'Artisanat', '#6FB0C6', '#e7f0f3', 42), + ('batiment', 'Bâtiment – Rénovation', '#9a6638', '#f4ebda', 43), + ('architecte', 'Architecte – Maître d''œuvre', '#2C6FB3', '#eaf0f6', 44), + ('maconnerie', 'Maçonnerie – Gros œuvre', '#5a7a5a', '#eef0ec', 45), + ('couverture-toiture', 'Couverture – Toiture – Zinguerie', '#7a6f9c', '#efe9f3', 46), + ('plomberie', 'Plomberie – Chauffage', '#c98a2e', '#f7efe0', 47), + ('electricite', 'Électricité', '#3f8aa3', '#e6eff2', 48), + ('isolation-energie', 'Isolation – Énergies renouvelables', '#E0A63C', '#f6efdc', 49), + ('menuiserie', 'Menuiserie – Ébénisterie', '#6FB0C6', '#e7f0f3', 50), + ('serrurerie-metallerie', 'Serrurerie – Métallerie', '#9a6638', '#f4ebda', 51), + ('carrelage-sols', 'Carrelage – Revêtements de sols', '#2C6FB3', '#eaf0f6', 52), + ('peinture', 'Peinture – Décoration intérieure', '#5a7a5a', '#eef0ec', 53), + ('paysagiste', 'Paysagiste – Espaces verts', '#7a6f9c', '#efe9f3', 54), + ('depannage-multiservices', 'Dépannage – Multiservices', '#c98a2e', '#f7efe0', 55), + ('automobile', 'Automobile – Garage', '#3f8aa3', '#e6eff2', 56), + ('carrosserie', 'Carrosserie', '#E0A63C', '#f6efdc', 57), + ('controle-technique', 'Contrôle technique', '#6FB0C6', '#e7f0f3', 58), + ('station-service-lavage', 'Station-service – Lavage auto', '#9a6638', '#f4ebda', 59), + ('location-vehicules', 'Location de véhicules', '#2C6FB3', '#eaf0f6', 60), + ('cycles-motos', 'Cycles – Motos', '#5a7a5a', '#eef0ec', 61), + ('auto-ecole', 'Auto-école', '#7a6f9c', '#efe9f3', 62), + ('taxi-vtc', 'Taxi – VTC', '#c98a2e', '#f7efe0', 63), + ('sante', 'Santé – Pharmacie', '#3f8aa3', '#e6eff2', 64), + ('medical', 'Médical – Paramédical', '#E0A63C', '#f6efdc', 65), + ('audioprothese', 'Audioprothèse', '#6FB0C6', '#e7f0f3', 66), + ('veterinaire', 'Vétérinaire', '#9a6638', '#f4ebda', 67), + ('banque-assurance', 'Banque – Assurance', '#2C6FB3', '#eaf0f6', 68), + ('assurance', 'Assurance – Mutuelle – Prévoyance', '#5a7a5a', '#eef0ec', 69), + ('courtier-credit', 'Courtier – Crédit – Financement', '#7a6f9c', '#efe9f3', 70), + ('immobilier', 'Immobilier', '#c98a2e', '#f7efe0', 71), + ('diagnostic-immobilier', 'Diagnostic immobilier – Expertise', '#3f8aa3', '#e6eff2', 72), + ('services', 'Services', '#E0A63C', '#f6efdc', 73), + ('services-a-la-personne', 'Services à la personne – Aide à domicile', '#6FB0C6', '#e7f0f3', 74), + ('pressing-retouches', 'Pressing – Retouches – Cordonnerie', '#9a6638', '#f4ebda', 75), + ('photographe', 'Photographe – Vidéo', '#2C6FB3', '#eaf0f6', 76), + ('pompes-funebres', 'Pompes funèbres', '#5a7a5a', '#eef0ec', 77), + ('agence-voyages', 'Agence de voyages', '#7a6f9c', '#efe9f3', 78), + ('demenagement', 'Déménagement – Garde-meubles', '#c98a2e', '#f7efe0', 79), + ('comptabilite', 'Comptabilité – Gestion', '#3f8aa3', '#e6eff2', 80), + ('juridique', 'Juridique – Notaire', '#E0A63C', '#f6efdc', 81), + ('conseil', 'Conseil aux entreprises', '#6FB0C6', '#e7f0f3', 82), + ('communication', 'Communication – Web – Marketing', '#9a6638', '#f4ebda', 83), + ('imprimerie-enseignes', 'Imprimerie – Enseignes – Signalétique', '#2C6FB3', '#eaf0f6', 84), + ('informatique', 'Informatique – Téléphonie', '#5a7a5a', '#eef0ec', 85), + ('nettoyage', 'Nettoyage – Entretien', '#7a6f9c', '#efe9f3', 86), + ('transport', 'Transport – Logistique', '#c98a2e', '#f7efe0', 87), + ('securite', 'Sécurité', '#3f8aa3', '#e6eff2', 88), + ('coworking', 'Coworking – Bureaux', '#E0A63C', '#f6efdc', 89), + ('interim-rh', 'Intérim – Ressources humaines', '#6FB0C6', '#e7f0f3', 90), + ('recyclage-dechets', 'Recyclage – Gestion des déchets', '#9a6638', '#f4ebda', 91), + ('librairie-papeterie', 'Librairie – Papeterie – Presse', '#2C6FB3', '#eaf0f6', 92), + ('tabac-presse', 'Tabac – Presse – Loto', '#5a7a5a', '#eef0ec', 93), + ('jouets-puericulture', 'Jouets – Puériculture', '#7a6f9c', '#efe9f3', 94), + ('sport-loisirs', 'Sport – Loisirs', '#c98a2e', '#f7efe0', 95), + ('articles-de-sport', 'Articles de sport', '#3f8aa3', '#e6eff2', 96), + ('fitness', 'Salle de sport – Fitness', '#E0A63C', '#f6efdc', 97), + ('culture-arts', 'Culture – Arts', '#6FB0C6', '#e7f0f3', 98), + ('education-formation', 'Éducation – Formation', '#9a6638', '#f4ebda', 99), + ('tourisme-hotellerie', 'Tourisme – Hôtellerie', '#2C6FB3', '#eaf0f6', 100), + ('evenementiel', 'Événementiel', '#5a7a5a', '#eef0ec', 101), + ('industrie', 'Industrie – Production', '#7a6f9c', '#efe9f3', 102), + ('agriculture', 'Agriculture – Producteur local', '#c98a2e', '#f7efe0', 103), + ('partenaire-institutionnel', 'Partenaire institutionnel – Collectivité', '#3f8aa3', '#e6eff2', 104) +ON CONFLICT ("slug") DO UPDATE SET "sort" = EXCLUDED."sort"; diff --git a/drizzle/meta/0012_snapshot.json b/drizzle/meta/0012_snapshot.json new file mode 100644 index 0000000..217dd75 --- /dev/null +++ b/drizzle/meta/0012_snapshot.json @@ -0,0 +1,1371 @@ +{ + "id": "eff0d401-f29b-4ea3-8c1e-edfdab3b556e", + "prevId": "af4fa895-0708-4071-9426-b2f04bf62dff", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.activity_log": { + "name": "activity_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "dot": { + "name": "dot", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'#2C6FB3'" + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "accent": { + "name": "accent", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'#E0A63C'" + }, + "tint": { + "name": "tint", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'#f6efdc'" + }, + "sort": { + "name": "sort", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "categories_slug_unique": { + "name": "categories_slug_unique", + "columns": [ + "slug" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contact_messages": { + "name": "contact_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "contact_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'new'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.image_consents": { + "name": "image_consents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "decision": { + "name": "decision", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signatory_name": { + "name": "signatory_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "signature_png": { + "name": "signature_png", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consent_version": { + "name": "consent_version", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "image_consents_member_id_members_id_fk": { + "name": "image_consents_member_id_members_id_fk", + "tableFrom": "image_consents", + "columnsFrom": [ + "member_id" + ], + "tableTo": "members", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meeting_registrations": { + "name": "meeting_registrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "meeting_id": { + "name": "meeting_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "attendee_name": { + "name": "attendee_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "attendee_company": { + "name": "attendee_company", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "attendee_email": { + "name": "attendee_email", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "attendee_phone": { + "name": "attendee_phone", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "image_consent": { + "name": "image_consent", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "meeting_registrations_meeting_idx": { + "name": "meeting_registrations_meeting_idx", + "columns": [ + { + "expression": "meeting_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "meeting_registrations_member_meeting_idx": { + "name": "meeting_registrations_member_meeting_idx", + "columns": [ + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "meeting_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "meeting_registrations_meeting_id_meetings_id_fk": { + "name": "meeting_registrations_meeting_id_meetings_id_fk", + "tableFrom": "meeting_registrations", + "columnsFrom": [ + "meeting_id" + ], + "tableTo": "meetings", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "meeting_registrations_member_id_members_id_fk": { + "name": "meeting_registrations_member_id_members_id_fk", + "tableFrom": "meeting_registrations", + "columnsFrom": [ + "member_id" + ], + "tableTo": "members", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meetings": { + "name": "meetings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "varchar(240)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capacity": { + "name": "capacity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 30 + }, + "participants_per_account": { + "name": "participants_per_account", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.members": { + "name": "members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "varchar(240)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "highlighted": { + "name": "highlighted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false + }, + "website": { + "name": "website", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "postal_code": { + "name": "postal_code", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "member_since": { + "name": "member_since", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "hours": { + "name": "hours", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "members_category_id_categories_id_fk": { + "name": "members_category_id_categories_id_fk", + "tableFrom": "members", + "columnsFrom": [ + "category_id" + ], + "tableTo": "categories", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.membership_requests": { + "name": "membership_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "request_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'new'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.past_meeting_photos": { + "name": "past_meeting_photos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "past_meeting_id": { + "name": "past_meeting_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "caption": { + "name": "caption", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "past_meeting_photos_past_meeting_id_past_meetings_id_fk": { + "name": "past_meeting_photos_past_meeting_id_past_meetings_id_fk", + "tableFrom": "past_meeting_photos", + "columnsFrom": [ + "past_meeting_id" + ], + "tableTo": "past_meetings", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.past_meetings": { + "name": "past_meetings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "event_date": { + "name": "event_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "varchar(240)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "participants": { + "name": "participants", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meeting_id": { + "name": "meeting_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "past_meetings_meeting_id_meetings_id_fk": { + "name": "past_meetings_meeting_id_meetings_id_fk", + "tableFrom": "past_meetings", + "columnsFrom": [ + "meeting_id" + ], + "tableTo": "meetings", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.promotions": { + "name": "promotions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "badge": { + "name": "badge", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_id": { + "name": "member_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "promo_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "valid_until": { + "name": "valid_until", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "share_facebook": { + "name": "share_facebook", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "share_linkedin": { + "name": "share_linkedin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "suspended_by": { + "name": "suspended_by", + "type": "promo_suspended_by", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "suspended_by_id": { + "name": "suspended_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "promotions_member_id_members_id_fk": { + "name": "promotions_member_id_members_id_fk", + "tableFrom": "promotions", + "columnsFrom": [ + "member_id" + ], + "tableTo": "members", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "promotions_suspended_by_id_users_id_fk": { + "name": "promotions_suspended_by_id_users_id_fk", + "tableFrom": "promotions", + "columnsFrom": [ + "suspended_by_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.site_settings": { + "name": "site_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "varchar(120)", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.social_accounts": { + "name": "social_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "network": { + "name": "network", + "type": "social_network", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "app_secret": { + "name": "app_secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "target_name": { + "name": "target_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "connected_by_id": { + "name": "connected_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "social_accounts_connected_by_id_users_id_fk": { + "name": "social_accounts_connected_by_id_users_id_fk", + "tableFrom": "social_accounts", + "columnsFrom": [ + "connected_by_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "social_accounts_network_unique": { + "name": "social_accounts_network_unique", + "columns": [ + "network" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.social_posts": { + "name": "social_posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "promotion_id": { + "name": "promotion_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "network": { + "name": "network", + "type": "social_network", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "social_post_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "posted_by_id": { + "name": "posted_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "social_posts_promotion_idx": { + "name": "social_posts_promotion_idx", + "columns": [ + { + "expression": "promotion_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "social_posts_promotion_id_promotions_id_fk": { + "name": "social_posts_promotion_id_promotions_id_fk", + "tableFrom": "social_posts", + "columnsFrom": [ + "promotion_id" + ], + "tableTo": "promotions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "social_posts_posted_by_id_users_id_fk": { + "name": "social_posts_posted_by_id_users_id_fk", + "tableFrom": "social_posts", + "columnsFrom": [ + "posted_by_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "member_id": { + "name": "member_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "must_change_password": { + "name": "must_change_password", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "session_version": { + "name": "session_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "users_member_id_members_id_fk": { + "name": "users_member_id_members_id_fk", + "tableFrom": "users", + "columnsFrom": [ + "member_id" + ], + "tableTo": "members", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.contact_status": { + "name": "contact_status", + "schema": "public", + "values": [ + "new", + "read", + "archived" + ] + }, + "public.member_status": { + "name": "member_status", + "schema": "public", + "values": [ + "active", + "pending" + ] + }, + "public.promo_status": { + "name": "promo_status", + "schema": "public", + "values": [ + "pending", + "live", + "expired", + "rejected", + "suspended" + ] + }, + "public.promo_suspended_by": { + "name": "promo_suspended_by", + "schema": "public", + "values": [ + "member", + "staff" + ] + }, + "public.request_status": { + "name": "request_status", + "schema": "public", + "values": [ + "new", + "approved", + "rejected" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "admin", + "moderator", + "editor", + "member" + ] + }, + "public.social_network": { + "name": "social_network", + "schema": "public", + "values": [ + "facebook", + "linkedin" + ] + }, + "public.social_post_status": { + "name": "social_post_status", + "schema": "public", + "values": [ + "posted", + "failed" + ] + } + }, + "schemas": {}, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 6a3687d..bbfb540 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1788512273181, "tag": "0011_drop_temp_password", "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1788675288708, + "tag": "0012_referentiel_categories", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/backend/actions.ts b/src/app/backend/actions.ts index 5222754..9c54390 100644 --- a/src/app/backend/actions.ts +++ b/src/app/backend/actions.ts @@ -9,6 +9,7 @@ import bcrypt from "bcryptjs"; import { signOut } from "@/auth"; import { getSession } from "@/lib/session"; import { db } from "@/db"; +import { CATEGORY_PALETTE } from "@/db/categories"; import { activityLog, categories, @@ -43,16 +44,6 @@ import { normalizeWebsite } from "@/lib/member-profile"; import { SITE_SETTING_DEFAULTS } from "@/lib/site-settings"; import type { AppRole } from "@/types/next-auth"; -const CATEGORY_PALETTE = [ - { accent: "#E0A63C", tint: "#f6efdc" }, - { accent: "#6FB0C6", tint: "#e7f0f3" }, - { accent: "#9a6638", tint: "#f4ebda" }, - { accent: "#2C6FB3", tint: "#eaf0f6" }, - { accent: "#5a7a5a", tint: "#eef0ec" }, - { accent: "#7a6f9c", tint: "#efe9f3" }, - { accent: "#c98a2e", tint: "#f7efe0" }, - { accent: "#3f8aa3", tint: "#e6eff2" }, -]; function slugify(input: string): string { return input diff --git a/src/app/globals.css b/src/app/globals.css index 42cd737..c649265 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -379,15 +379,15 @@ button { /* Boutons sociaux libellés : plus lisibles que deux icônes isolées, et ils donnent enfin du contenu à la quatrième colonne. */ .footer-social { - display: flex; - flex-direction: column; - /* Sans cela les boutons s'étirent sur toute la colonne et deviennent des - pavés vides. */ - align-items: flex-start; + /* Grille à une colonne dimensionnée sur son contenu : tous les boutons + prennent la largeur du plus long au lieu de s'étirer sur la colonne ou de + former un escalier. */ + display: inline-grid; + grid-template-columns: minmax(150px, max-content); gap: 9px; } .footer-social a { - display: inline-flex; + display: flex; align-items: center; gap: 10px; padding: 9px 13px; diff --git a/src/app/page.tsx b/src/app/page.tsx index 6bfbf4b..66a6883 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -9,7 +9,7 @@ import { MembersCarousel } from "@/components/MembersCarousel"; import { StarField } from "@/components/StarField"; import { PromoImage } from "@/components/PromoImage"; import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons"; -import { getLivePromotions, getRotatingActiveMembers } from "@/lib/queries"; +import { getActiveMemberCount, getLivePromotions, getRotatingActiveMembers } from "@/lib/queries"; import { getSiteSettings, socialLinks } from "@/lib/site-settings"; import { SITE_DESCRIPTION, SITE_TITLE, pageMetadata } from "@/lib/seo"; @@ -35,10 +35,11 @@ function badgeColor(badge: string | null) { } export default async function AccueilPage() { - const [promos, rotatingMembers, settings] = await Promise.all([ + const [promos, rotatingMembers, settings, memberCount] = await Promise.all([ getLivePromotions(6), getRotatingActiveMembers(), getSiteSettings(), + getActiveMemberCount(), ]); const socials = socialLinks(settings); @@ -450,8 +451,9 @@ export default async function AccueilPage() { Faites partie de l'aventure Plein R

- Rejoignez plus de 120 commerçants et entreprises qui font réseau, se rencontrent et réussissent - ensemble. + {memberCount > 0 + ? `Rejoignez les ${memberCount} commerçants et entreprises adhérents qui font réseau, se rencontrent et réussissent ensemble.` + : "Rejoignez les commerçants et entreprises qui font réseau, se rencontrent et réussissent ensemble."}

- + {s.label} diff --git a/src/db/categories.ts b/src/db/categories.ts new file mode 100644 index 0000000..d3ea0b9 --- /dev/null +++ b/src/db/categories.ts @@ -0,0 +1,151 @@ +/** + * Référentiel des catégories de métiers. + * + * Source unique pour le seed, la migration d'insertion (drizzle/0012_*) et le + * bouton « Ajouter » du backend. Les slugs sont stables : ils servent de clé + * d'unicité (`ON CONFLICT (slug)`) et ne doivent jamais être renommés, sinon la + * catégorie serait dupliquée. Un libellé renommé depuis le backend est conservé : + * la migration ne touche qu'à l'ordre d'affichage (`sort`). + */ + +export const CATEGORY_PALETTE = [ + { accent: "#E0A63C", tint: "#f6efdc" }, + { accent: "#6FB0C6", tint: "#e7f0f3" }, + { accent: "#9a6638", tint: "#f4ebda" }, + { accent: "#2C6FB3", tint: "#eaf0f6" }, + { accent: "#5a7a5a", tint: "#eef0ec" }, + { accent: "#7a6f9c", tint: "#efe9f3" }, + { accent: "#c98a2e", tint: "#f7efe0" }, + { accent: "#3f8aa3", tint: "#e6eff2" }, +] as const; + +export type CategoryDefinition = { slug: string; label: string }; + +// NB : les slugs historiques (alimentation, boulangerie, restauration, mode-beaute, +// artisanat, services, sante) sont conservés pour ne pas casser les liens existants. +export const CATEGORY_REFERENTIAL: CategoryDefinition[] = [ + // ---- Bouche & alimentation ---- + { slug: "alimentation", label: "Alimentation / Épicerie" }, + { slug: "epicerie-fine", label: "Épicerie fine – Produits du terroir" }, + { slug: "supermarche", label: "Supermarché – Grande distribution" }, + { slug: "boulangerie", label: "Boulangerie – Pâtisserie" }, + { slug: "boucherie-charcuterie", label: "Boucherie – Charcuterie" }, + { slug: "poissonnerie", label: "Poissonnerie" }, + { slug: "primeur", label: "Primeur – Fruits & Légumes" }, + { slug: "fromagerie", label: "Fromagerie – Crèmerie" }, + { slug: "cave", label: "Cave – Vins & Spiritueux" }, + { slug: "brasserie-artisanale", label: "Brasserie artisanale – Boissons" }, + { slug: "chocolaterie", label: "Chocolaterie – Confiserie" }, + { slug: "glacier-salon-de-the", label: "Glacier – Salon de thé" }, + { slug: "traiteur", label: "Traiteur" }, + { slug: "restauration", label: "Restauration" }, + { slug: "pizzeria", label: "Pizzeria" }, + { slug: "restauration-rapide", label: "Restauration rapide – Food truck" }, + { slug: "cafe-bar", label: "Café – Bar – Brasserie" }, + // ---- Mode, beauté & soin ---- + { slug: "mode-beaute", label: "Mode & Beauté" }, + { slug: "mode-vetements", label: "Mode & Vêtements" }, + { slug: "lingerie", label: "Lingerie" }, + { slug: "vetements-enfants", label: "Vêtements enfants" }, + { slug: "chaussures-maroquinerie", label: "Chaussures – Maroquinerie" }, + { slug: "bijouterie", label: "Bijouterie – Horlogerie" }, + { slug: "coiffure", label: "Coiffure" }, + { slug: "barbier", label: "Barbier" }, + { slug: "esthetique", label: "Esthétique – Institut" }, + { slug: "onglerie", label: "Onglerie – Prothésie ongulaire" }, + { slug: "parfumerie", label: "Parfumerie" }, + { slug: "bien-etre", label: "Bien-être – Spa – Massage" }, + { slug: "optique", label: "Optique" }, + { slug: "tatouage", label: "Tatouage – Piercing" }, + // ---- Maison, équipement & décoration ---- + { slug: "bazar-discount", label: "Bazar – Discount – Équipement de la maison" }, + { slug: "decoration", label: "Décoration – Ameublement" }, + { slug: "cuisiniste", label: "Cuisines – Salles de bain" }, + { slug: "literie", label: "Literie" }, + { slug: "arts-de-la-table", label: "Arts de la table – Cadeaux" }, + { slug: "electromenager-multimedia", label: "Électroménager – Multimédia" }, + { slug: "fleuriste", label: "Fleuriste" }, + { slug: "jardinerie", label: "Jardinerie – Motoculture" }, + { slug: "bricolage-jardinage", label: "Bricolage – Jardinage" }, + { slug: "animalerie", label: "Animalerie – Services animaliers" }, + // ---- Artisanat & bâtiment ---- + { slug: "artisanat", label: "Artisanat" }, + { slug: "batiment", label: "Bâtiment – Rénovation" }, + { slug: "architecte", label: "Architecte – Maître d'œuvre" }, + { slug: "maconnerie", label: "Maçonnerie – Gros œuvre" }, + { slug: "couverture-toiture", label: "Couverture – Toiture – Zinguerie" }, + { slug: "plomberie", label: "Plomberie – Chauffage" }, + { slug: "electricite", label: "Électricité" }, + { slug: "isolation-energie", label: "Isolation – Énergies renouvelables" }, + { slug: "menuiserie", label: "Menuiserie – Ébénisterie" }, + { slug: "serrurerie-metallerie", label: "Serrurerie – Métallerie" }, + { slug: "carrelage-sols", label: "Carrelage – Revêtements de sols" }, + { slug: "peinture", label: "Peinture – Décoration intérieure" }, + { slug: "paysagiste", label: "Paysagiste – Espaces verts" }, + { slug: "depannage-multiservices", label: "Dépannage – Multiservices" }, + // ---- Auto & mobilité ---- + { slug: "automobile", label: "Automobile – Garage" }, + { slug: "carrosserie", label: "Carrosserie" }, + { slug: "controle-technique", label: "Contrôle technique" }, + { slug: "station-service-lavage", label: "Station-service – Lavage auto" }, + { slug: "location-vehicules", label: "Location de véhicules" }, + { slug: "cycles-motos", label: "Cycles – Motos" }, + { slug: "auto-ecole", label: "Auto-école" }, + { slug: "taxi-vtc", label: "Taxi – VTC" }, + // ---- Santé ---- + { slug: "sante", label: "Santé – Pharmacie" }, + { slug: "medical", label: "Médical – Paramédical" }, + { slug: "audioprothese", label: "Audioprothèse" }, + { slug: "veterinaire", label: "Vétérinaire" }, + // ---- Banque, assurance & immobilier ---- + { slug: "banque-assurance", label: "Banque – Assurance" }, + { slug: "assurance", label: "Assurance – Mutuelle – Prévoyance" }, + { slug: "courtier-credit", label: "Courtier – Crédit – Financement" }, + { slug: "immobilier", label: "Immobilier" }, + { slug: "diagnostic-immobilier", label: "Diagnostic immobilier – Expertise" }, + // ---- Services aux particuliers ---- + { slug: "services", label: "Services" }, + { slug: "services-a-la-personne", label: "Services à la personne – Aide à domicile" }, + { slug: "pressing-retouches", label: "Pressing – Retouches – Cordonnerie" }, + { slug: "photographe", label: "Photographe – Vidéo" }, + { slug: "pompes-funebres", label: "Pompes funèbres" }, + { slug: "agence-voyages", label: "Agence de voyages" }, + { slug: "demenagement", label: "Déménagement – Garde-meubles" }, + // ---- Services aux entreprises ---- + { slug: "comptabilite", label: "Comptabilité – Gestion" }, + { slug: "juridique", label: "Juridique – Notaire" }, + { slug: "conseil", label: "Conseil aux entreprises" }, + { slug: "communication", label: "Communication – Web – Marketing" }, + { slug: "imprimerie-enseignes", label: "Imprimerie – Enseignes – Signalétique" }, + { slug: "informatique", label: "Informatique – Téléphonie" }, + { slug: "nettoyage", label: "Nettoyage – Entretien" }, + { slug: "transport", label: "Transport – Logistique" }, + { slug: "securite", label: "Sécurité" }, + { slug: "coworking", label: "Coworking – Bureaux" }, + { slug: "interim-rh", label: "Intérim – Ressources humaines" }, + { slug: "recyclage-dechets", label: "Recyclage – Gestion des déchets" }, + // ---- Culture, loisirs & formation ---- + { slug: "librairie-papeterie", label: "Librairie – Papeterie – Presse" }, + { slug: "tabac-presse", label: "Tabac – Presse – Loto" }, + { slug: "jouets-puericulture", label: "Jouets – Puériculture" }, + { slug: "sport-loisirs", label: "Sport – Loisirs" }, + { slug: "articles-de-sport", label: "Articles de sport" }, + { slug: "fitness", label: "Salle de sport – Fitness" }, + { slug: "culture-arts", label: "Culture – Arts" }, + { slug: "education-formation", label: "Éducation – Formation" }, + { slug: "tourisme-hotellerie", label: "Tourisme – Hôtellerie" }, + { slug: "evenementiel", label: "Événementiel" }, + // ---- Entreprise, production & institutions ---- + { slug: "industrie", label: "Industrie – Production" }, + { slug: "agriculture", label: "Agriculture – Producteur local" }, + { slug: "partenaire-institutionnel", label: "Partenaire institutionnel – Collectivité" }, +]; + +/** Lignes prêtes à insérer : couleur tournante et ordre d'affichage. */ +export function categoryRows() { + return CATEGORY_REFERENTIAL.map((c, i) => ({ + ...c, + ...CATEGORY_PALETTE[i % CATEGORY_PALETTE.length], + sort: i + 1, + })); +} diff --git a/src/db/seed.ts b/src/db/seed.ts index 78d5dcd..660decd 100644 --- a/src/db/seed.ts +++ b/src/db/seed.ts @@ -5,6 +5,7 @@ import bcrypt from "bcryptjs"; import { randomInt } from "node:crypto"; import { eq } from "drizzle-orm"; import * as schema from "./schema"; +import { categoryRows } from "./categories"; import { DEMO_PASSWORD, demoActivity, @@ -40,93 +41,10 @@ async function main() { console.log("Seeding database…"); - // ---- Categories (métiers) — liste complète ---- - const PALETTE = [ - { accent: "#E0A63C", tint: "#f6efdc" }, - { accent: "#6FB0C6", tint: "#e7f0f3" }, - { accent: "#9a6638", tint: "#f4ebda" }, - { accent: "#2C6FB3", tint: "#eaf0f6" }, - { accent: "#5a7a5a", tint: "#eef0ec" }, - { accent: "#7a6f9c", tint: "#efe9f3" }, - { accent: "#c98a2e", tint: "#f7efe0" }, - { accent: "#3f8aa3", tint: "#e6eff2" }, - ]; - // NB: les slugs existants (alimentation, boulangerie, restauration, mode-beaute, - // artisanat, services, sante) sont conservés pour ne pas casser les liens existants. - const categoryList: { slug: string; label: string }[] = [ - // Bouche & alimentation - { slug: "alimentation", label: "Alimentation / Épicerie" }, - { slug: "boulangerie", label: "Boulangerie – Pâtisserie" }, - { slug: "boucherie-charcuterie", label: "Boucherie – Charcuterie" }, - { slug: "poissonnerie", label: "Poissonnerie" }, - { slug: "primeur", label: "Primeur – Fruits & Légumes" }, - { slug: "fromagerie", label: "Fromagerie – Crèmerie" }, - { slug: "cave", label: "Cave – Vins & Spiritueux" }, - { slug: "chocolaterie", label: "Chocolaterie – Confiserie" }, - { slug: "traiteur", label: "Traiteur" }, - { slug: "restauration", label: "Restauration" }, - { slug: "restauration-rapide", label: "Restauration rapide" }, - { slug: "cafe-bar", label: "Café – Bar – Brasserie" }, - // Mode, beauté & soin - { slug: "mode-beaute", label: "Mode & Beauté" }, - { slug: "mode-vetements", label: "Mode & Vêtements" }, - { slug: "chaussures-maroquinerie", label: "Chaussures – Maroquinerie" }, - { slug: "bijouterie", label: "Bijouterie – Horlogerie" }, - { slug: "coiffure", label: "Coiffure" }, - { slug: "esthetique", label: "Esthétique – Institut" }, - { slug: "parfumerie", label: "Parfumerie" }, - { slug: "bien-etre", label: "Bien-être – Spa – Massage" }, - { slug: "optique", label: "Optique" }, - { slug: "tatouage", label: "Tatouage – Piercing" }, - // Maison, déco & artisanat / bâtiment - { slug: "artisanat", label: "Artisanat" }, - { slug: "decoration", label: "Décoration – Ameublement" }, - { slug: "fleuriste", label: "Fleuriste" }, - { slug: "bricolage-jardinage", label: "Bricolage – Jardinage" }, - { slug: "batiment", label: "Bâtiment – Rénovation" }, - { slug: "plomberie", label: "Plomberie – Chauffage" }, - { slug: "electricite", label: "Électricité" }, - { slug: "menuiserie", label: "Menuiserie – Ébénisterie" }, - { slug: "peinture", label: "Peinture – Décoration intérieure" }, - { slug: "paysagiste", label: "Paysagiste – Espaces verts" }, - // Auto & mobilité - { slug: "automobile", label: "Automobile – Garage" }, - { slug: "carrosserie", label: "Carrosserie" }, - { slug: "cycles-motos", label: "Cycles – Motos" }, - // Santé - { slug: "sante", label: "Santé – Pharmacie" }, - { slug: "medical", label: "Médical – Paramédical" }, - { slug: "audioprothese", label: "Audioprothèse" }, - // Services aux particuliers & entreprises - { slug: "services", label: "Services" }, - { slug: "banque-assurance", label: "Banque – Assurance" }, - { slug: "immobilier", label: "Immobilier" }, - { slug: "comptabilite", label: "Comptabilité – Gestion" }, - { slug: "juridique", label: "Juridique – Notaire" }, - { slug: "communication", label: "Communication – Web – Marketing" }, - { slug: "informatique", label: "Informatique – Téléphonie" }, - { slug: "nettoyage", label: "Nettoyage – Entretien" }, - { slug: "transport", label: "Transport – Logistique" }, - { slug: "securite", label: "Sécurité" }, - { slug: "coworking", label: "Coworking – Bureaux" }, - { slug: "interim-rh", label: "Intérim – Ressources humaines" }, - // Loisirs, culture & formation - { slug: "sport-loisirs", label: "Sport – Loisirs" }, - { slug: "fitness", label: "Salle de sport – Fitness" }, - { slug: "culture-arts", label: "Culture – Arts" }, - { slug: "education-formation", label: "Éducation – Formation" }, - { slug: "tourisme-hotellerie", label: "Tourisme – Hôtellerie" }, - { slug: "evenementiel", label: "Événementiel" }, - { slug: "animalerie", label: "Animalerie – Services animaliers" }, - // Entreprise & production - { slug: "industrie", label: "Industrie – Production" }, - { slug: "agriculture", label: "Agriculture – Producteur local" }, - ]; - const categoryData = categoryList.map((c, i) => ({ - ...c, - ...PALETTE[i % PALETTE.length], - sort: i + 1, - })); + // ---- Categories (métiers) : référentiel partagé (src/db/categories.ts) ---- + // La migration 0012 insère déjà ce référentiel ; le seed le rejoue par sécurité + // pour une base créée sans migration, sans jamais écraser un libellé existant. + const categoryData = categoryRows(); for (const c of categoryData) { await db.insert(categories).values(c).onConflictDoNothing({ target: categories.slug }); diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 45947d2..efe2ac2 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -165,3 +165,12 @@ export async function getHighlightedMembers(limit = 3) { .limit(limit); return rows; } + +/** Nombre d'adhérents validés (statut `active`) : affiché sur l'accueil. */ +export async function getActiveMemberCount(): Promise { + const [row] = await db + .select({ total: count(members.id) }) + .from(members) + .where(eq(members.status, "active")); + return Number(row?.total ?? 0); +} diff --git a/tests/categories.test.ts b/tests/categories.test.ts new file mode 100644 index 0000000..4e1b7a7 --- /dev/null +++ b/tests/categories.test.ts @@ -0,0 +1,46 @@ +import { strict as assert } from "node:assert"; +import { readFileSync } from "node:fs"; +import { describe, it } from "node:test"; + +import { CATEGORY_PALETTE, CATEGORY_REFERENTIAL, categoryRows } from "../src/db/categories"; + +describe("Référentiel des catégories", () => { + it("garde des slugs uniques et stables", () => { + const slugs = CATEGORY_REFERENTIAL.map((c) => c.slug); + assert.equal(new Set(slugs).size, slugs.length, "slug en double"); + for (const slug of slugs) { + assert.match(slug, /^[a-z0-9]+(?:-[a-z0-9]+)*$/, `slug invalide : ${slug}`); + } + // Slugs historiques : des fiches adhérents y sont rattachées. + for (const legacy of ["alimentation", "boulangerie", "restauration", "mode-beaute", "artisanat", "services", "sante"]) { + assert.ok(slugs.includes(legacy), `${legacy} doit rester présent`); + } + }); + + it("couvre les métiers demandés (assurance, bazar/discount, grande distribution…)", () => { + const slugs = new Set(CATEGORY_REFERENTIAL.map((c) => c.slug)); + for (const expected of ["assurance", "bazar-discount", "supermarche", "librairie-papeterie", "tabac-presse", "veterinaire", "auto-ecole"]) { + assert.ok(slugs.has(expected), `${expected} manquant`); + } + }); + + it("respecte les contraintes de la table", () => { + for (const row of categoryRows()) { + assert.ok(row.label.length <= 120, `libellé trop long : ${row.label}`); + assert.ok(row.slug.length <= 80); + assert.ok(CATEGORY_PALETTE.some((p) => p.accent === row.accent && p.tint === row.tint)); + assert.ok(row.sort >= 1); + } + }); + + it("est intégralement repris par la migration 0012", () => { + const sql = readFileSync(new URL("../drizzle/0012_referentiel_categories.sql", import.meta.url), "utf8"); + for (const row of categoryRows()) { + assert.ok(sql.includes(`('${row.slug}', '${row.label.replace(/'/g, "''")}', '${row.accent}', '${row.tint}', ${row.sort})`), + `${row.slug} absent ou différent dans la migration`); + } + // Ne jamais écraser un libellé renommé depuis le backend. + assert.ok(sql.includes('ON CONFLICT ("slug") DO UPDATE SET "sort" = EXCLUDED."sort";')); + assert.ok(!/DO UPDATE SET[^;]*"label"/.test(sql)); + }); +});