Accueil, pied de page et catégories : compteur réel, boutons alignés, référentiel complété

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDbKbJrPWweXXYSxA8MXW7
This commit is contained in:
Claude committed 2026-09-06 06:18:37 +00:00
1 parent a8adb9ae6a
commit f9d0d6c825
12 files changed
+1718 -108

No files matched your search

+6
View File
@@ -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
+109
View File
@@ -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";
File diff suppressed because it is too large. Load diff
+7
View File
@@ -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
}
]
}
+1 -10
View File
@@ -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
+6 -6
View File
@@ -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;
+6 -4
View File
@@ -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&apos;aventure Plein R
</h2>
<p style={{ margin: "0 auto 22px", fontSize: 15.5, color: "#f4e6d3", maxWidth: 480 }}>
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."}
</p>
<MembershipModalButton
label="Devenir adhérent"
+1 -1
View File
@@ -61,7 +61,7 @@ export async function SiteFooter() {
rel="noopener noreferrer"
aria-label={`Plein R sur ${s.label}`}
>
<span style={{ color: SOCIAL_BRAND[s.network], display: "inline-flex" }}>
<span style={{ color: SOCIAL_BRAND[s.network], display: "inline-flex", width: 17, flexShrink: 0 }}>
<SocialIcon network={s.network} size={17} />
</span>
{s.label}
+151
View File
@@ -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,
}));
}
+5 -87
View File
@@ -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 });
+9
View File
@@ -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<number> {
const [row] = await db
.select({ total: count(members.id) })
.from(members)
.where(eq(members.status, "active"));
return Number(row?.total ?? 0);
}
+46
View File
@@ -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));
});
});