Application complète SLUC Business Club (React + Express + PostgreSQL, Docker)
Implémente l'intégralité de la maquette « SLUC Business Club.dc.html » : - Site public : accueil (héro administrable, carrousel membres, agenda avec inscription en ligne, rencontres passées, demande d'adhésion), annuaire avec recherche/filtres/fiche détaillée, page association - Espace membre : connexion, édition de fiche avec aperçu direct, upload logo/photo, statut d'adhésion par saison, changement de mot de passe - Espace admin : tableau de bord, membres (validation par saison), rencontres (CRUD + inscrits + impression + export Excel), inscriptions, catégories, contenu du site Architecture : 3 conteneurs Docker Compose — PostgreSQL 16 (réseau interne, rôle applicatif restreint), API Express (non-root, read-only fs), nginx non privilégié (frontend React + reverse-proxy + CSP stricte). Sécurité : requêtes 100 % paramétrées, bcrypt + JWT httpOnly SameSite=Strict, vérification d'Origin (CSRF), validation zod, rate limiting, uploads vérifiés par octets magiques avec noms aléatoires, aucun secret committé. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fkg15RCxNgUys4ru73He2o
This commit is contained in:
50 files changed
+6887
No files matched your search
@@ -0,0 +1,26 @@
|
|||||||
|
# Copy this file to .env and fill in strong secrets before running.
|
||||||
|
# cp .env.example .env
|
||||||
|
# openssl rand -hex 32 # use for JWT_SECRET
|
||||||
|
# openssl rand -hex 24 # use for each DB password
|
||||||
|
|
||||||
|
# PostgreSQL superuser password (used only inside the db container)
|
||||||
|
POSTGRES_PASSWORD=change-me-postgres-superuser
|
||||||
|
|
||||||
|
# Password of the restricted application role (sbc_app) the API connects with
|
||||||
|
APP_DB_PASSWORD=change-me-app-db-password
|
||||||
|
|
||||||
|
# Secret used to sign session tokens (JWT). MUST be long and random.
|
||||||
|
JWT_SECRET=change-me-64-hex-chars-min
|
||||||
|
|
||||||
|
# Initial password of the admin account (admin@sluc-businessclub.fr).
|
||||||
|
# Applied/updated at API startup. Change it after first login.
|
||||||
|
ADMIN_INITIAL_PASSWORD=ChangeMe-Admin-2026!
|
||||||
|
|
||||||
|
# Initial password given to every seeded member account (demo data only)
|
||||||
|
MEMBER_INITIAL_PASSWORD=ChangeMe-Membre-2026!
|
||||||
|
|
||||||
|
# Set to "true" when serving over HTTPS (adds Secure flag on cookies)
|
||||||
|
COOKIE_SECURE=false
|
||||||
|
|
||||||
|
# Public port of the web frontend
|
||||||
|
WEB_PORT=8080
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
uploads/
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# Business Club SLUC Nancy
|
||||||
|
|
||||||
|
Application web complète du réseau d'affaires des partenaires du SLUC Nancy Basket :
|
||||||
|
site public (accueil, annuaire des membres, association), espace membre et
|
||||||
|
back-office d'administration.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Trois conteneurs orchestrés par Docker Compose :
|
||||||
|
|
||||||
|
| Service | Rôle | Exposition |
|
||||||
|
|---------|------|------------|
|
||||||
|
| `db` | PostgreSQL 16 (schéma + données de démo au premier démarrage) | réseau interne uniquement, aucun port publié |
|
||||||
|
| `api` | API REST Node.js 22 / Express (auth, membres, rencontres, inscriptions, catégories, contenu, uploads) | réseau interne uniquement |
|
||||||
|
| `web` | nginx (non-root) : frontend React compilé, reverse-proxy `/api`, service des images `/uploads` | port `8080` |
|
||||||
|
|
||||||
|
```
|
||||||
|
Navigateur ──> web (nginx :8080) ──> api (Express :3000) ──> db (PostgreSQL :5432)
|
||||||
|
│ /uploads (volume partagé, lecture seule)
|
||||||
|
└─ fichiers statiques React
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Frontend** : React 18 + Vite + React Router — reproduction fidèle de la maquette
|
||||||
|
(`SLUC Business Club.dc.html`).
|
||||||
|
- **Backend** : Express, `pg` (requêtes paramétrées), `zod` (validation), `bcryptjs`
|
||||||
|
(hachage), JWT en cookie httpOnly, `helmet`, `express-rate-limit`, `multer` (uploads).
|
||||||
|
- **Base** : PostgreSQL 16, initialisée par `db/init/` (rôle applicatif restreint,
|
||||||
|
schéma, données de démonstration).
|
||||||
|
|
||||||
|
## Démarrage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Éditez .env : générez des secrets forts
|
||||||
|
# openssl rand -hex 32 → JWT_SECRET
|
||||||
|
# openssl rand -hex 24 → POSTGRES_PASSWORD et APP_DB_PASSWORD
|
||||||
|
# puis choisissez ADMIN_INITIAL_PASSWORD / MEMBER_INITIAL_PASSWORD
|
||||||
|
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
L'application est disponible sur <http://localhost:8080> (port configurable via `WEB_PORT`).
|
||||||
|
|
||||||
|
## Comptes
|
||||||
|
|
||||||
|
Les comptes de démonstration sont créés **verrouillés** (aucun mot de passe en dur
|
||||||
|
dans le dépôt). Au démarrage, l'API leur applique le mot de passe des variables
|
||||||
|
d'environnement — uniquement s'ils n'en ont pas déjà un :
|
||||||
|
|
||||||
|
| Rôle | Email | Mot de passe initial |
|
||||||
|
|------|-------|----------------------|
|
||||||
|
| Admin | `admin@sluc-businessclub.fr` | `ADMIN_INITIAL_PASSWORD` |
|
||||||
|
| Membre (×12) | email de contact de chaque entreprise de démo, ex. `contact@lorraine-assurances.fr` | `MEMBER_INITIAL_PASSWORD` |
|
||||||
|
|
||||||
|
Changez le mot de passe admin après la première connexion (Espace membre/admin →
|
||||||
|
formulaire « Mot de passe », endpoint `POST /api/auth/change-password`).
|
||||||
|
|
||||||
|
## Fonctionnalités
|
||||||
|
|
||||||
|
**Site public**
|
||||||
|
- Accueil : héro éditorial (citation et photo administrables), carrousel des membres,
|
||||||
|
prochaines rencontres avec inscription en ligne (contrôle de capacité et de doublon),
|
||||||
|
rencontres passées, formulaire de demande d'adhésion.
|
||||||
|
- Annuaire : recherche plein texte, filtres par catégorie, fiche détaillée par entreprise.
|
||||||
|
Seuls les membres **validés pour la saison** apparaissent.
|
||||||
|
- L'association : mission, valeurs, mot du président, chronologie, chiffres clés.
|
||||||
|
|
||||||
|
**Espace membre** (connexion email + mot de passe)
|
||||||
|
- Édition de sa fiche annuaire avec aperçu en direct.
|
||||||
|
- Upload du logo et de la photo dirigeant·e (JPEG/PNG/WebP, 2 Mo max, vérification
|
||||||
|
des octets magiques côté serveur).
|
||||||
|
- Bannière d'état d'adhésion (validée / à renouveler) et changement de mot de passe.
|
||||||
|
|
||||||
|
**Espace admin** (rôle `admin`)
|
||||||
|
- Tableau de bord : indicateurs temps réel, dernières inscriptions, prochaines rencontres.
|
||||||
|
- Membres : création, édition, validation/suspension par saison (1er sept. → 31 août).
|
||||||
|
- Rencontres : création, édition, suppression ; liste des inscrits avec impression
|
||||||
|
et export Excel.
|
||||||
|
- Inscriptions : modification, confirmation, annulation avec confirmation.
|
||||||
|
- Catégories : ajout, renommage, suppression (les entreprises deviennent « Non classée »).
|
||||||
|
- Contenu du site : citation, signature et photo de la page d'accueil.
|
||||||
|
|
||||||
|
## Sécurité
|
||||||
|
|
||||||
|
- **Injection SQL** : 100 % de requêtes paramétrées (`pg`), aucun SQL concaténé.
|
||||||
|
- **Authentification** : bcrypt (coût 12), comparaison à temps constant même si
|
||||||
|
l'email est inconnu, JWT HS256 signé (secret ≥ 32 caractères exigé au démarrage),
|
||||||
|
session de 12 h.
|
||||||
|
- **Cookies** : `httpOnly`, `SameSite=Strict`, `Secure` activable (`COOKIE_SECURE=true`
|
||||||
|
derrière HTTPS).
|
||||||
|
- **CSRF** : cookie SameSite=Strict + vérification de l'en-tête `Origin` sur toutes
|
||||||
|
les mutations.
|
||||||
|
- **Autorisation** : middleware de rôles (`member` / `admin`) sur chaque route protégée ;
|
||||||
|
un membre ne peut modifier que sa propre fiche.
|
||||||
|
- **Validation** : schémas `zod` sur toutes les entrées (types, longueurs, formats),
|
||||||
|
contraintes `CHECK` en base en seconde ligne.
|
||||||
|
- **Rate limiting** : global (300/min), connexion (10 / 15 min), formulaires publics
|
||||||
|
(20 / h).
|
||||||
|
- **Uploads** : taille ≤ 2 Mo, type vérifié par octets magiques (jamais le MIME client),
|
||||||
|
nom de fichier aléatoire généré côté serveur (aucune traversée de chemin possible),
|
||||||
|
servis par nginx avec `X-Content-Type-Options: nosniff` et types MIME forcés.
|
||||||
|
- **En-têtes** : CSP stricte, `X-Frame-Options: DENY`, `Referrer-Policy`,
|
||||||
|
`Permissions-Policy`, `helmet` côté API, `server_tokens off`.
|
||||||
|
- **Conteneurs** : API en utilisateur non-root avec système de fichiers en lecture
|
||||||
|
seule (`read_only` + tmpfs), nginx non privilégié, `no-new-privileges`, PostgreSQL
|
||||||
|
sans port publié sur un réseau interne (`internal: true`).
|
||||||
|
- **Base de données** : l'API se connecte avec un rôle dédié `sbc_app` limité au DML
|
||||||
|
(pas de DDL, pas de superuser).
|
||||||
|
- **Secrets** : uniquement via `.env` (ignoré par git) ; `docker compose` refuse de
|
||||||
|
démarrer sans eux ; aucun hash ni mot de passe committé.
|
||||||
|
- **Erreurs** : les détails restent dans les logs serveur, les clients reçoivent un
|
||||||
|
message générique.
|
||||||
|
|
||||||
|
### Pour la production
|
||||||
|
|
||||||
|
- Placez l'application derrière HTTPS (reverse-proxy TLS) et passez `COOKIE_SECURE=true`.
|
||||||
|
- Changez immédiatement les mots de passe initiaux.
|
||||||
|
- Sauvegardez les volumes `db_data` (base) et `uploads` (images).
|
||||||
|
|
||||||
|
## Développement local (sans Docker)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal 1 — base de données PostgreSQL locale + variables d'env, puis :
|
||||||
|
cd server && npm install && npm start
|
||||||
|
# Terminal 2
|
||||||
|
cd web && npm install && npm run dev # proxy /api → localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
├── docker-compose.yml
|
||||||
|
├── .env.example
|
||||||
|
├── db/init/ # 01 rôle applicatif · 02 schéma · 03 données de démo
|
||||||
|
├── server/ # API Express (src/routes, src/middleware, uploads)
|
||||||
|
└── web/ # React + Vite, nginx.conf, Dockerfile multi-étages
|
||||||
|
```
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Creates the restricted application role the API connects with.
|
||||||
|
# Runs once, on first initialization of the data volume.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Escape single quotes so an exotic password cannot break the SQL literal
|
||||||
|
ESCAPED_PASSWORD="${APP_DB_PASSWORD//\'/\'\'}"
|
||||||
|
|
||||||
|
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
|
||||||
|
CREATE ROLE sbc_app LOGIN PASSWORD '${ESCAPED_PASSWORD}' NOSUPERUSER NOCREATEDB NOCREATEROLE;
|
||||||
|
EOSQL
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
-- SLUC Business Club — schema
|
||||||
|
CREATE EXTENSION IF NOT EXISTS citext;
|
||||||
|
|
||||||
|
CREATE TABLE categories (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE CHECK (char_length(name) BETWEEN 1 AND 80)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE members (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
nom TEXT NOT NULL CHECK (char_length(nom) BETWEEN 1 AND 120),
|
||||||
|
secteur TEXT NOT NULL DEFAULT '' CHECK (char_length(secteur) <= 120),
|
||||||
|
categorie_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
|
||||||
|
dirigeant TEXT NOT NULL DEFAULT '' CHECK (char_length(dirigeant) <= 120),
|
||||||
|
adhesion SMALLINT,
|
||||||
|
email CITEXT CHECK (char_length(email) <= 254),
|
||||||
|
tel TEXT CHECK (char_length(tel) <= 30),
|
||||||
|
site TEXT CHECK (char_length(site) <= 200),
|
||||||
|
presentation TEXT NOT NULL DEFAULT '' CHECK (char_length(presentation) <= 2000),
|
||||||
|
valide BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
logo_path TEXT,
|
||||||
|
photo_path TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
email CITEXT NOT NULL UNIQUE CHECK (char_length(email) <= 254),
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('member', 'admin')),
|
||||||
|
member_id INTEGER UNIQUE REFERENCES members(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE rencontres (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
titre TEXT NOT NULL CHECK (char_length(titre) BETWEEN 1 AND 200),
|
||||||
|
date_renc DATE NOT NULL,
|
||||||
|
heure TEXT NOT NULL DEFAULT '' CHECK (char_length(heure) <= 20),
|
||||||
|
lieu TEXT NOT NULL DEFAULT '' CHECK (char_length(lieu) <= 200),
|
||||||
|
description TEXT NOT NULL DEFAULT '' CHECK (char_length(description) <= 2000),
|
||||||
|
places INTEGER NOT NULL CHECK (places >= 0 AND places <= 100000),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE inscriptions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
rencontre_id INTEGER NOT NULL REFERENCES rencontres(id) ON DELETE CASCADE,
|
||||||
|
nom TEXT NOT NULL CHECK (char_length(nom) BETWEEN 1 AND 120),
|
||||||
|
entreprise TEXT NOT NULL CHECK (char_length(entreprise) BETWEEN 1 AND 120),
|
||||||
|
email CITEXT CHECK (char_length(email) <= 254),
|
||||||
|
tel TEXT CHECK (char_length(tel) <= 30),
|
||||||
|
statut TEXT NOT NULL DEFAULT 'en_attente' CHECK (statut IN ('confirmee', 'en_attente')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_inscriptions_rencontre ON inscriptions(rencontre_id);
|
||||||
|
|
||||||
|
CREATE TABLE rencontres_passees (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
date_label TEXT NOT NULL,
|
||||||
|
lieu TEXT NOT NULL,
|
||||||
|
titre TEXT NOT NULL,
|
||||||
|
texte TEXT NOT NULL,
|
||||||
|
participants INTEGER NOT NULL DEFAULT 0,
|
||||||
|
nb_photos INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE demandes_adhesion (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
nom TEXT NOT NULL CHECK (char_length(nom) BETWEEN 1 AND 120),
|
||||||
|
fonction TEXT NOT NULL DEFAULT '' CHECK (char_length(fonction) <= 120),
|
||||||
|
entreprise TEXT NOT NULL CHECK (char_length(entreprise) BETWEEN 1 AND 120),
|
||||||
|
email CITEXT NOT NULL CHECK (char_length(email) <= 254),
|
||||||
|
statut TEXT NOT NULL DEFAULT 'nouvelle' CHECK (statut IN ('nouvelle', 'traitee')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE site_content (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Least-privilege grants for the application role (no DDL, no other schemas)
|
||||||
|
GRANT USAGE ON SCHEMA public TO sbc_app;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO sbc_app;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO sbc_app;
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
-- SLUC Business Club — demo seed data (content from the design mock)
|
||||||
|
-- User passwords are NOT stored here: accounts are created with the
|
||||||
|
-- unusable placeholder hash '*seed*' and receive their real (bcrypt)
|
||||||
|
-- password from the API at startup, based on ADMIN_INITIAL_PASSWORD /
|
||||||
|
-- MEMBER_INITIAL_PASSWORD environment variables.
|
||||||
|
|
||||||
|
INSERT INTO categories (name) VALUES
|
||||||
|
('Conseil & Finance'),
|
||||||
|
('Industrie & BTP'),
|
||||||
|
('Communication & Digital'),
|
||||||
|
('Services'),
|
||||||
|
('Santé');
|
||||||
|
|
||||||
|
INSERT INTO members (nom, secteur, categorie_id, dirigeant, adhesion, email, tel, site, presentation, valide) VALUES
|
||||||
|
('Lorraine Assurances', 'Assurance & Prévoyance', 1, 'Sophie Marchand', 2014, 'contact@lorraine-assurances.fr', '+33 3 83 12 45 60', 'lorraine-assurances.fr', 'Cabinet de courtage indépendant accompagnant les entreprises et dirigeants du Grand Est sur l''ensemble de leurs besoins en assurance et prévoyance.', true),
|
||||||
|
('Groupe Batigest', 'BTP & Construction', 2, 'Philippe Aubry', 2009, 'contact@batigest.fr', '+33 3 83 22 18 04', 'groupe-batigest.fr', 'Entreprise générale de construction et de rénovation, acteur majeur du bâtiment en Lorraine depuis plus de trente ans.', true),
|
||||||
|
('Moselle Digital', 'Agence digitale', 3, 'Karim Benali', 2019, 'hello@moselle-digital.fr', '+33 3 83 55 71 22', 'moselle-digital.fr', 'Agence de communication digitale spécialisée dans la création de sites, le référencement et les stratégies social media pour les PME.', true),
|
||||||
|
('Renard & Associés', 'Cabinet d''avocats', 1, 'Me Claire Renard', 2012, 'contact@renard-avocats.fr', '+33 3 83 30 09 15', 'renard-avocats.fr', 'Cabinet d''avocats d''affaires conseillant dirigeants et entreprises en droit des sociétés, droit social et droit commercial.', true),
|
||||||
|
('NancyTech Solutions', 'Solutions informatiques', 3, 'Thomas Villeret', 2020, 'contact@nancytech.fr', '+33 3 83 41 88 90', 'nancytech.fr', 'Infogérance, cybersécurité et déploiement d''outils métiers pour accompagner la transformation numérique des entreprises régionales.', true),
|
||||||
|
('Est Immobilier', 'Immobilier d''entreprise', 4, 'Nathalie Perrin', 2016, 'contact@est-immobilier.fr', '+33 3 83 17 62 30', 'est-immobilier.fr', 'Conseil en immobilier d''entreprise : bureaux, locaux commerciaux et investissement sur l''agglomération nancéienne.', true),
|
||||||
|
('Vosges Logistique', 'Transport & Logistique', 4, 'Bruno Kieffer', 2011, 'contact@vosges-logistique.fr', '+33 3 29 34 12 78', 'vosges-logistique.fr', 'Solutions de transport, entreposage et distribution au service des industriels et distributeurs du Grand Est.', false),
|
||||||
|
('Meurthe Industries', 'Industrie & Métallurgie', 2, 'Laurent Schmitt', 2008, 'contact@meurthe-industries.fr', '+33 3 83 49 05 11', 'meurthe-industries.fr', 'Sous-traitance industrielle de précision et travail des métaux pour les secteurs de l''automobile et de l''énergie.', true),
|
||||||
|
('Grand Est Finance', 'Conseil financier', 1, 'Isabelle Fontaine', 2017, 'contact@grandest-finance.fr', '+33 3 83 25 44 90', 'grandest-finance.fr', 'Conseil en gestion de patrimoine et financement d''entreprise, aux côtés des dirigeants dans leurs projets de croissance.', true),
|
||||||
|
('Atelier Lumière', 'Communication & Design', 3, 'Julie Mercier', 2021, 'bonjour@atelier-lumiere.fr', '+33 3 83 60 77 08', 'atelier-lumiere.fr', 'Studio de création graphique et d''identité de marque, du logo à la signalétique, pour donner du sens à votre image.', true),
|
||||||
|
('Pharma Lorraine', 'Laboratoire pharmaceutique', 5, 'Dr Antoine Rousseau', 2013, 'contact@pharma-lorraine.fr', '+33 3 83 90 21 47', 'pharma-lorraine.fr', 'Laboratoire de production et de distribution de solutions de santé, engagé dans l''innovation et la qualité.', false),
|
||||||
|
('Cristal Événements', 'Événementiel', 4, 'Émilie Colin', 2018, 'contact@cristal-events.fr', '+33 3 83 38 66 12', 'cristal-events.fr', 'Agence événementielle imaginant séminaires, soirées d''entreprise et événements sur-mesure clés en main.', true);
|
||||||
|
|
||||||
|
INSERT INTO rencontres (titre, date_renc, heure, lieu, description, places) VALUES
|
||||||
|
('Afterwork Business & Basket', '2026-09-17', '18h30', 'Palais des Sports J. Weille', 'Networking en tribune VIP autour d''un cocktail, suivi du match SLUC Nancy – Cholet. Le rendez-vous incontournable de la rentrée.', 40),
|
||||||
|
('Petit-déjeuner des Dirigeants', '2026-10-06', '08h00', 'Hôtel Mercure Nancy Centre', 'Échanges entre dirigeants autour d''un intervenant invité, dans un format intimiste propice aux affaires.', 25),
|
||||||
|
('Conférence — L''esprit d''équipe', '2026-11-19', '19h00', 'Grand Salon, Hôtel de Ville', 'Un coach de haut niveau partage les ressorts de la performance collective, du terrain à l''entreprise.', 80);
|
||||||
|
|
||||||
|
INSERT INTO inscriptions (rencontre_id, nom, entreprise, email, tel, statut) VALUES
|
||||||
|
(1, 'Sophie Marchand', 'Lorraine Assurances', 's.marchand@lorraine-assurances.fr', '+33 3 83 12 45 60', 'confirmee'),
|
||||||
|
(1, 'Karim Benali', 'Moselle Digital', 'k.benali@moselle-digital.fr', '+33 3 83 55 71 22', 'en_attente'),
|
||||||
|
(2, 'Thomas Villeret', 'NancyTech Solutions', 't.villeret@nancytech.fr', '+33 3 83 41 88 90', 'confirmee'),
|
||||||
|
(3, 'Nathalie Perrin', 'Est Immobilier', 'n.perrin@est-immobilier.fr', '+33 3 83 17 62 30', 'en_attente'),
|
||||||
|
(2, 'Bruno Kieffer', 'Vosges Logistique', 'b.kieffer@vosges-logistique.fr', '+33 3 29 34 12 78', 'confirmee'),
|
||||||
|
(1, 'Julie Mercier', 'Atelier Lumière', 'j.mercier@atelier-lumiere.fr', '+33 3 83 60 77 08', 'en_attente'),
|
||||||
|
(1, 'Laurent Schmitt', 'Meurthe Industries', 'l.schmitt@meurthe-industries.fr', '+33 3 83 49 05 11', 'confirmee'),
|
||||||
|
(3, 'Isabelle Fontaine', 'Grand Est Finance', 'i.fontaine@grandest-finance.fr', '+33 3 83 25 44 90', 'confirmee'),
|
||||||
|
(2, 'Me Claire Renard', 'Renard & Associés', 'c.renard@renard-avocats.fr', '+33 3 83 30 09 15', 'en_attente'),
|
||||||
|
(1, 'Émilie Colin', 'Cristal Événements', 'e.colin@cristal-events.fr', '+33 3 83 38 66 12', 'confirmee'),
|
||||||
|
(3, 'Philippe Aubry', 'Groupe Batigest', 'p.aubry@batigest.fr', '+33 3 83 22 18 04', 'confirmee');
|
||||||
|
|
||||||
|
INSERT INTO rencontres_passees (date_label, lieu, titre, texte, participants, nb_photos) VALUES
|
||||||
|
('Juin 2026', 'Château de Rémicourt', 'Soirée de Gala annuelle', 'Plus de 200 convives réunis pour célébrer une saison d''exception. Une soirée d''élégance mêlant remise de trophées, dîner gastronomique et rencontres privilégiées entre membres du Club.', 210, 48),
|
||||||
|
('Avril 2026', 'Site industriel, Florange', 'Visite privée — Usine ArcelorMittal', 'Immersion industrielle exclusive pour nos membres au cœur d''un fleuron de la métallurgie lorraine, suivie d''un échange avec la direction du site.', 34, 22),
|
||||||
|
('Février 2026', 'La Filature, Nancy', 'Table ronde — Digitaliser sa PME', 'Retours d''expérience et bonnes pratiques : trois dirigeants membres ont partagé leur parcours de transformation numérique devant une salle comble.', 56, 15);
|
||||||
|
|
||||||
|
INSERT INTO demandes_adhesion (nom, fonction, entreprise, email) VALUES
|
||||||
|
('Marc Dubois', 'Directeur général', 'Dubois Traiteur', 'm.dubois@dubois-traiteur.fr'),
|
||||||
|
('Léa Hoffmann', 'Présidente', 'Hoffmann Conseil', 'l.hoffmann@hoffmann-conseil.fr'),
|
||||||
|
('Julien Weber', 'Gérant', 'Weber Menuiserie', 'j.weber@weber-menuiserie.fr');
|
||||||
|
|
||||||
|
INSERT INTO site_content (key, value) VALUES
|
||||||
|
('hero_quote_text', 'On ne réussit jamais seul.'),
|
||||||
|
('hero_quote_author', 'L''esprit du Club'),
|
||||||
|
('hero_photo', '');
|
||||||
|
|
||||||
|
-- Accounts: one admin + one portal account per member company.
|
||||||
|
-- '*seed*' is an intentionally unusable hash; real passwords are applied
|
||||||
|
-- by the API at startup from environment variables.
|
||||||
|
INSERT INTO users (email, password_hash, role, member_id)
|
||||||
|
SELECT email, '*seed*', 'member', id FROM members WHERE email IS NOT NULL;
|
||||||
|
|
||||||
|
INSERT INTO users (email, password_hash, role) VALUES
|
||||||
|
('admin@sluc-businessclub.fr', '*seed*', 'admin');
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
name: sluc-business-club
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: sbc
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
|
||||||
|
APP_DB_PASSWORD: ${APP_DB_PASSWORD:?set APP_DB_PASSWORD in .env}
|
||||||
|
volumes:
|
||||||
|
- db_data:/var/lib/postgresql/data
|
||||||
|
- ./db/init:/docker-entrypoint-initdb.d:ro
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres -d sbc"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
# No published ports: the database is reachable only from the backend network.
|
||||||
|
|
||||||
|
api:
|
||||||
|
build: ./server
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
PORT: "3000"
|
||||||
|
DATABASE_URL: postgres://sbc_app:${APP_DB_PASSWORD}@db:5432/sbc
|
||||||
|
JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in .env}
|
||||||
|
ADMIN_INITIAL_PASSWORD: ${ADMIN_INITIAL_PASSWORD:-}
|
||||||
|
MEMBER_INITIAL_PASSWORD: ${MEMBER_INITIAL_PASSWORD:-}
|
||||||
|
COOKIE_SECURE: ${COOKIE_SECURE:-false}
|
||||||
|
UPLOAD_DIR: /data/uploads
|
||||||
|
volumes:
|
||||||
|
- uploads:/data/uploads
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
- frontend
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 6
|
||||||
|
|
||||||
|
web:
|
||||||
|
build: ./web
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${WEB_PORT:-8080}:8080"
|
||||||
|
volumes:
|
||||||
|
- uploads:/var/www/uploads:ro
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
networks:
|
||||||
|
- frontend
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
|
||||||
|
networks:
|
||||||
|
backend:
|
||||||
|
# internal: the db network never routes to the outside world
|
||||||
|
internal: true
|
||||||
|
frontend:
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db_data:
|
||||||
|
uploads:
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
FROM node:22-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci --omit=dev 2>/dev/null || npm install --omit=dev
|
||||||
|
|
||||||
|
FROM node:22-alpine
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY package.json ./
|
||||||
|
COPY src ./src
|
||||||
|
# Upload dir owned by the app user; a named volume mounted here inherits
|
||||||
|
# this ownership on first use
|
||||||
|
RUN mkdir -p /data/uploads && chown node:node /data/uploads
|
||||||
|
# Run as the unprivileged built-in user
|
||||||
|
USER node
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "src/index.js"]
|
||||||
Generated
+1313
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "sbc-api",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"cookie-parser": "^1.4.6",
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"express-rate-limit": "^7.4.0",
|
||||||
|
"helmet": "^7.1.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"pg": "^8.12.0",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+32
@@ -0,0 +1,32 @@
|
|||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { query } from './db.js';
|
||||||
|
import { config } from './config.js';
|
||||||
|
|
||||||
|
// Seeded accounts carry the unusable placeholder hash '*seed*'.
|
||||||
|
// On startup we give them a real bcrypt hash from environment variables —
|
||||||
|
// but never overwrite a password that has already been changed.
|
||||||
|
export async function applyInitialPasswords() {
|
||||||
|
if (config.adminInitialPassword) {
|
||||||
|
const hash = await bcrypt.hash(config.adminInitialPassword, 12);
|
||||||
|
const r = await query(
|
||||||
|
`UPDATE users SET password_hash = $1 WHERE role = 'admin' AND password_hash = '*seed*'`,
|
||||||
|
[hash]
|
||||||
|
);
|
||||||
|
if (r.rowCount > 0) console.log(`Initialized password for ${r.rowCount} admin account(s).`);
|
||||||
|
}
|
||||||
|
if (config.memberInitialPassword) {
|
||||||
|
const hash = await bcrypt.hash(config.memberInitialPassword, 12);
|
||||||
|
const r = await query(
|
||||||
|
`UPDATE users SET password_hash = $1 WHERE role = 'member' AND password_hash = '*seed*'`,
|
||||||
|
[hash]
|
||||||
|
);
|
||||||
|
if (r.rowCount > 0) console.log(`Initialized password for ${r.rowCount} member account(s).`);
|
||||||
|
}
|
||||||
|
const locked = await query(`SELECT COUNT(*)::int AS n FROM users WHERE password_hash = '*seed*'`);
|
||||||
|
if (locked.rows[0].n > 0) {
|
||||||
|
console.warn(
|
||||||
|
`${locked.rows[0].n} account(s) still locked (no initial password provided). ` +
|
||||||
|
'Set ADMIN_INITIAL_PASSWORD / MEMBER_INITIAL_PASSWORD to activate them.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
const required = (name) => {
|
||||||
|
const v = process.env[name];
|
||||||
|
if (!v) {
|
||||||
|
console.error(`Missing required environment variable: ${name}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
port: Number(process.env.PORT || 3000),
|
||||||
|
databaseUrl: required('DATABASE_URL'),
|
||||||
|
jwtSecret: required('JWT_SECRET'),
|
||||||
|
cookieSecure: process.env.COOKIE_SECURE === 'true',
|
||||||
|
uploadDir: process.env.UPLOAD_DIR || '/data/uploads',
|
||||||
|
adminInitialPassword: process.env.ADMIN_INITIAL_PASSWORD || '',
|
||||||
|
memberInitialPassword: process.env.MEMBER_INITIAL_PASSWORD || '',
|
||||||
|
sessionTtlSeconds: 12 * 60 * 60,
|
||||||
|
cookieName: 'sbc_session',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (config.jwtSecret.length < 32) {
|
||||||
|
console.error('JWT_SECRET must be at least 32 characters long.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import pg from 'pg';
|
||||||
|
import { config } from './config.js';
|
||||||
|
|
||||||
|
export const pool = new pg.Pool({
|
||||||
|
connectionString: config.databaseUrl,
|
||||||
|
max: 10,
|
||||||
|
idleTimeoutMillis: 30_000,
|
||||||
|
connectionTimeoutMillis: 5_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const query = (text, params) => pool.query(text, params);
|
||||||
|
|
||||||
|
export async function waitForDb(retries = 30, delayMs = 1000) {
|
||||||
|
for (let i = 1; i <= retries; i++) {
|
||||||
|
try {
|
||||||
|
await pool.query('SELECT 1');
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
if (i === retries) throw err;
|
||||||
|
await new Promise((r) => setTimeout(r, delayMs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
|
import { config } from './config.js';
|
||||||
|
import { waitForDb } from './db.js';
|
||||||
|
import { applyInitialPasswords } from './bootstrap.js';
|
||||||
|
import { attachUser } from './middleware/auth.js';
|
||||||
|
import { csrfOriginCheck, globalLimiter } from './middleware/security.js';
|
||||||
|
import { publicRouter } from './routes/public.js';
|
||||||
|
import { authRouter } from './routes/auth.js';
|
||||||
|
import { memberRouter } from './routes/member.js';
|
||||||
|
import { adminRouter } from './routes/admin.js';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.disable('x-powered-by');
|
||||||
|
app.set('trust proxy', 1); // behind nginx
|
||||||
|
app.use(helmet());
|
||||||
|
app.use(express.json({ limit: '64kb' }));
|
||||||
|
app.use(cookieParser());
|
||||||
|
app.use(globalLimiter);
|
||||||
|
app.use(csrfOriginCheck);
|
||||||
|
app.use(attachUser);
|
||||||
|
|
||||||
|
app.get('/api/health', (_req, res) => res.json({ ok: true }));
|
||||||
|
app.use('/api/public', publicRouter);
|
||||||
|
app.use('/api/auth', authRouter);
|
||||||
|
app.use('/api/member', memberRouter);
|
||||||
|
app.use('/api/admin', adminRouter);
|
||||||
|
|
||||||
|
app.use((_req, res) => res.status(404).json({ error: 'Introuvable' }));
|
||||||
|
|
||||||
|
// Central error handler: log details server-side, never leak them to clients
|
||||||
|
app.use((err, _req, res, _next) => {
|
||||||
|
if (err.type === 'entity.too.large' || err.type === 'entity.parse.failed') {
|
||||||
|
return res.status(400).json({ error: 'Requête invalide' });
|
||||||
|
}
|
||||||
|
if (err.code === '23503') return res.status(400).json({ error: 'Référence invalide' });
|
||||||
|
if (err.code === '23505') return res.status(409).json({ error: 'Cette valeur existe déjà.' });
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Erreur interne du serveur' });
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await waitForDb();
|
||||||
|
await applyInitialPasswords();
|
||||||
|
app.listen(config.port, () => {
|
||||||
|
console.log(`SBC API listening on :${config.port}`);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Startup failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { config } from '../config.js';
|
||||||
|
|
||||||
|
export function issueSession(res, payload) {
|
||||||
|
const token = jwt.sign(payload, config.jwtSecret, {
|
||||||
|
expiresIn: config.sessionTtlSeconds,
|
||||||
|
algorithm: 'HS256',
|
||||||
|
});
|
||||||
|
res.cookie(config.cookieName, token, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'strict',
|
||||||
|
secure: config.cookieSecure,
|
||||||
|
maxAge: config.sessionTtlSeconds * 1000,
|
||||||
|
path: '/',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSession(res) {
|
||||||
|
res.clearCookie(config.cookieName, { path: '/' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attachUser(req, _res, next) {
|
||||||
|
const token = req.cookies?.[config.cookieName];
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
req.user = jwt.verify(token, config.jwtSecret, { algorithms: ['HS256'] });
|
||||||
|
} catch {
|
||||||
|
req.user = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireAuth(role) {
|
||||||
|
return (req, res, next) => {
|
||||||
|
if (!req.user) return res.status(401).json({ error: 'Authentification requise' });
|
||||||
|
if (role && req.user.role !== role) return res.status(403).json({ error: 'Accès refusé' });
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import rateLimit from 'express-rate-limit';
|
||||||
|
|
||||||
|
// CSRF defense-in-depth: session cookie is SameSite=Strict, and every
|
||||||
|
// state-changing request must additionally come from our own origin.
|
||||||
|
export function csrfOriginCheck(req, res, next) {
|
||||||
|
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
|
||||||
|
const origin = req.headers.origin || '';
|
||||||
|
const host = req.headers['x-forwarded-host'] || req.headers.host || '';
|
||||||
|
if (origin) {
|
||||||
|
let originHost;
|
||||||
|
try {
|
||||||
|
originHost = new URL(origin).host;
|
||||||
|
} catch {
|
||||||
|
return res.status(403).json({ error: 'Origine invalide' });
|
||||||
|
}
|
||||||
|
if (originHost !== host) {
|
||||||
|
return res.status(403).json({ error: 'Origine non autorisée' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loginLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
limit: 10,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
message: { error: 'Trop de tentatives de connexion. Réessayez dans 15 minutes.' },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const publicFormLimiter = rateLimit({
|
||||||
|
windowMs: 60 * 60 * 1000,
|
||||||
|
limit: 20,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
message: { error: 'Trop de requêtes. Réessayez plus tard.' },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const globalLimiter = rateLimit({
|
||||||
|
windowMs: 60 * 1000,
|
||||||
|
limit: 300,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
message: { error: 'Trop de requêtes.' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export function validate(schema, source = 'body') {
|
||||||
|
return (req, res, next) => {
|
||||||
|
const result = schema.safeParse(req[source]);
|
||||||
|
if (!result.success) {
|
||||||
|
const first = result.error.issues[0];
|
||||||
|
return res.status(400).json({
|
||||||
|
error: `Donnée invalide : ${first.path.join('.')} — ${first.message}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
req[source === 'body' ? 'data' : 'params'] = result.data;
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { query } from '../db.js';
|
||||||
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
|
import { validate } from '../middleware/validate.js';
|
||||||
|
import {
|
||||||
|
adminMemberSchema,
|
||||||
|
rencontreSchema,
|
||||||
|
inscriptionAdminSchema,
|
||||||
|
categorySchema,
|
||||||
|
contentSchema,
|
||||||
|
idParam,
|
||||||
|
} from '../schemas.js';
|
||||||
|
import { imageUpload, saveImage, deleteImage } from '../uploads.js';
|
||||||
|
|
||||||
|
export const adminRouter = Router();
|
||||||
|
|
||||||
|
adminRouter.use(requireAuth('admin'));
|
||||||
|
|
||||||
|
const MEMBER_SQL = `
|
||||||
|
SELECT m.id, m.nom, m.secteur, m.categorie_id, c.name AS categorie, m.dirigeant,
|
||||||
|
m.adhesion, m.email, m.tel, m.site, m.presentation, m.valide,
|
||||||
|
m.logo_path, m.photo_path
|
||||||
|
FROM members m LEFT JOIN categories c ON c.id = m.categorie_id`;
|
||||||
|
|
||||||
|
const RENC_SQL = `
|
||||||
|
SELECT r.id, r.titre, r.date_renc, r.heure, r.lieu, r.description, r.places,
|
||||||
|
COUNT(i.id)::int AS inscrits
|
||||||
|
FROM rencontres r LEFT JOIN inscriptions i ON i.rencontre_id = r.id`;
|
||||||
|
|
||||||
|
const INSCR_SQL = `
|
||||||
|
SELECT i.id, i.nom, i.entreprise, i.email, i.tel, i.statut, i.created_at,
|
||||||
|
i.rencontre_id, r.titre AS rencontre
|
||||||
|
FROM inscriptions i JOIN rencontres r ON r.id = i.rencontre_id`;
|
||||||
|
|
||||||
|
// ---------- Dashboard ----------
|
||||||
|
adminRouter.get('/dashboard', async (_req, res, next) => {
|
||||||
|
try {
|
||||||
|
const [kpis, latest, upcoming] = await Promise.all([
|
||||||
|
query(`
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*)::int FROM members WHERE valide) AS membres_valides,
|
||||||
|
(SELECT COUNT(*)::int FROM members WHERE NOT valide) AS a_renouveler,
|
||||||
|
(SELECT COUNT(*)::int FROM rencontres WHERE date_renc >= CURRENT_DATE) AS rencontres_a_venir,
|
||||||
|
(SELECT COUNT(*)::int FROM inscriptions WHERE statut = 'en_attente') AS inscriptions_attente,
|
||||||
|
(SELECT COUNT(*)::int FROM demandes_adhesion WHERE statut = 'nouvelle') AS demandes_nouvelles,
|
||||||
|
(SELECT MIN(date_renc) FROM rencontres WHERE date_renc >= CURRENT_DATE) AS prochaine_date
|
||||||
|
`),
|
||||||
|
query(`${INSCR_SQL} ORDER BY i.created_at DESC, i.id DESC LIMIT 5`),
|
||||||
|
query(`${RENC_SQL} WHERE r.date_renc >= CURRENT_DATE GROUP BY r.id ORDER BY r.date_renc LIMIT 5`),
|
||||||
|
]);
|
||||||
|
res.json({ kpis: kpis.rows[0], latestInscriptions: latest.rows, upcoming: upcoming.rows });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Members ----------
|
||||||
|
adminRouter.get('/members', async (_req, res, next) => {
|
||||||
|
try {
|
||||||
|
const result = await query(`${MEMBER_SQL} ORDER BY m.nom`);
|
||||||
|
res.json({ members: result.rows });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRouter.post('/members', validate(adminMemberSchema), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const d = req.data;
|
||||||
|
const result = await query(
|
||||||
|
`INSERT INTO members (nom, secteur, categorie_id, dirigeant, adhesion, email, tel, site, presentation, valide)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`,
|
||||||
|
[d.nom, d.secteur, d.categorie_id ?? null, d.dirigeant, new Date().getFullYear(),
|
||||||
|
d.email || null, d.tel, d.site, d.presentation, d.valide ?? true]
|
||||||
|
);
|
||||||
|
res.status(201).json({ id: result.rows[0].id });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRouter.put('/members/:id', validate(idParam, 'params'), validate(adminMemberSchema), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const d = req.data;
|
||||||
|
const result = await query(
|
||||||
|
`UPDATE members SET nom=$1, secteur=$2, categorie_id=$3, dirigeant=$4, email=$5,
|
||||||
|
tel=$6, site=$7, presentation=$8, valide=COALESCE($9, valide), updated_at=now()
|
||||||
|
WHERE id=$10 RETURNING id`,
|
||||||
|
[d.nom, d.secteur, d.categorie_id ?? null, d.dirigeant, d.email || null,
|
||||||
|
d.tel, d.site, d.presentation, d.valide ?? null, req.params.id]
|
||||||
|
);
|
||||||
|
if (result.rowCount === 0) return res.status(404).json({ error: 'Membre introuvable' });
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRouter.post('/members/:id/toggle-valide', validate(idParam, 'params'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const result = await query(
|
||||||
|
'UPDATE members SET valide = NOT valide, updated_at = now() WHERE id = $1 RETURNING valide',
|
||||||
|
[req.params.id]
|
||||||
|
);
|
||||||
|
if (result.rowCount === 0) return res.status(404).json({ error: 'Membre introuvable' });
|
||||||
|
res.json({ valide: result.rows[0].valide });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Rencontres ----------
|
||||||
|
adminRouter.get('/rencontres', async (_req, res, next) => {
|
||||||
|
try {
|
||||||
|
const result = await query(`${RENC_SQL} GROUP BY r.id ORDER BY r.date_renc`);
|
||||||
|
res.json({ rencontres: result.rows });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRouter.post('/rencontres', validate(rencontreSchema), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const d = req.data;
|
||||||
|
const result = await query(
|
||||||
|
`INSERT INTO rencontres (titre, date_renc, heure, lieu, description, places)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
|
||||||
|
[d.titre, d.date_renc, d.heure, d.lieu, d.description, d.places]
|
||||||
|
);
|
||||||
|
res.status(201).json({ id: result.rows[0].id });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRouter.put('/rencontres/:id', validate(idParam, 'params'), validate(rencontreSchema), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const d = req.data;
|
||||||
|
const result = await query(
|
||||||
|
`UPDATE rencontres SET titre=$1, date_renc=$2, heure=$3, lieu=$4, description=$5, places=$6
|
||||||
|
WHERE id=$7 RETURNING id`,
|
||||||
|
[d.titre, d.date_renc, d.heure, d.lieu, d.description, d.places, req.params.id]
|
||||||
|
);
|
||||||
|
if (result.rowCount === 0) return res.status(404).json({ error: 'Rencontre introuvable' });
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRouter.delete('/rencontres/:id', validate(idParam, 'params'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await query('DELETE FROM rencontres WHERE id = $1', [req.params.id]);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRouter.get('/rencontres/:id/inscriptions', validate(idParam, 'params'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const result = await query(`${INSCR_SQL} WHERE i.rencontre_id = $1 ORDER BY i.nom`, [req.params.id]);
|
||||||
|
res.json({ inscriptions: result.rows });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Inscriptions ----------
|
||||||
|
adminRouter.get('/inscriptions', async (_req, res, next) => {
|
||||||
|
try {
|
||||||
|
const result = await query(`${INSCR_SQL} ORDER BY i.created_at DESC, i.id DESC`);
|
||||||
|
res.json({ inscriptions: result.rows });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRouter.put('/inscriptions/:id', validate(idParam, 'params'), validate(inscriptionAdminSchema), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const d = req.data;
|
||||||
|
const result = await query(
|
||||||
|
`UPDATE inscriptions SET nom=$1, entreprise=$2, email=$3, tel=$4, rencontre_id=$5, statut=$6
|
||||||
|
WHERE id=$7 RETURNING id`,
|
||||||
|
[d.nom, d.entreprise, d.email || null, d.tel, d.rencontre_id, d.statut, req.params.id]
|
||||||
|
);
|
||||||
|
if (result.rowCount === 0) return res.status(404).json({ error: 'Inscription introuvable' });
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRouter.post('/inscriptions/:id/confirm', validate(idParam, 'params'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const result = await query(
|
||||||
|
`UPDATE inscriptions SET statut = 'confirmee' WHERE id = $1 RETURNING id`,
|
||||||
|
[req.params.id]
|
||||||
|
);
|
||||||
|
if (result.rowCount === 0) return res.status(404).json({ error: 'Inscription introuvable' });
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRouter.delete('/inscriptions/:id', validate(idParam, 'params'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await query('DELETE FROM inscriptions WHERE id = $1', [req.params.id]);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Categories ----------
|
||||||
|
adminRouter.get('/categories', async (_req, res, next) => {
|
||||||
|
try {
|
||||||
|
const result = await query(`
|
||||||
|
SELECT c.id, c.name, COUNT(m.id)::int AS count
|
||||||
|
FROM categories c LEFT JOIN members m ON m.categorie_id = c.id
|
||||||
|
GROUP BY c.id ORDER BY c.id`);
|
||||||
|
res.json({ categories: result.rows });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRouter.post('/categories', validate(categorySchema), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const result = await query(
|
||||||
|
'INSERT INTO categories (name) VALUES ($1) ON CONFLICT (name) DO NOTHING RETURNING id',
|
||||||
|
[req.data.name]
|
||||||
|
);
|
||||||
|
if (result.rowCount === 0) return res.status(409).json({ error: 'Cette catégorie existe déjà.' });
|
||||||
|
res.status(201).json({ id: result.rows[0].id });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRouter.put('/categories/:id', validate(idParam, 'params'), validate(categorySchema), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const result = await query('UPDATE categories SET name = $1 WHERE id = $2 RETURNING id', [
|
||||||
|
req.data.name,
|
||||||
|
req.params.id,
|
||||||
|
]);
|
||||||
|
if (result.rowCount === 0) return res.status(404).json({ error: 'Catégorie introuvable' });
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === '23505') return res.status(409).json({ error: 'Ce nom existe déjà.' });
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRouter.delete('/categories/:id', validate(idParam, 'params'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
// members.categorie_id has ON DELETE SET NULL: they become "Non classée"
|
||||||
|
await query('DELETE FROM categories WHERE id = $1', [req.params.id]);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Site content ----------
|
||||||
|
adminRouter.put('/content', validate(contentSchema), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const entries = Object.entries(req.data).filter(([, v]) => v !== undefined);
|
||||||
|
for (const [key, value] of entries) {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO site_content (key, value) VALUES ($1, $2)
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
|
||||||
|
[key, value]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRouter.post('/content/hero-photo', (req, res, next) => {
|
||||||
|
imageUpload(req, res, async (err) => {
|
||||||
|
if (err) return res.status(400).json({ error: 'Fichier invalide (2 Mo max).' });
|
||||||
|
try {
|
||||||
|
if (!req.file) return res.status(400).json({ error: 'Aucun fichier reçu.' });
|
||||||
|
const publicPath = await saveImage(req.file.buffer);
|
||||||
|
if (!publicPath) return res.status(400).json({ error: 'Format accepté : JPEG, PNG ou WebP.' });
|
||||||
|
const prev = await query(`SELECT value FROM site_content WHERE key = 'hero_photo'`);
|
||||||
|
await query(
|
||||||
|
`INSERT INTO site_content (key, value) VALUES ('hero_photo', $1)
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
|
||||||
|
[publicPath]
|
||||||
|
);
|
||||||
|
await deleteImage(prev.rows[0]?.value);
|
||||||
|
res.json({ path: publicPath });
|
||||||
|
} catch (e) {
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Demandes d'adhésion ----------
|
||||||
|
adminRouter.get('/demandes', async (_req, res, next) => {
|
||||||
|
try {
|
||||||
|
const result = await query('SELECT * FROM demandes_adhesion ORDER BY created_at DESC');
|
||||||
|
res.json({ demandes: result.rows });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { query } from '../db.js';
|
||||||
|
import { issueSession, clearSession, requireAuth } from '../middleware/auth.js';
|
||||||
|
import { loginLimiter } from '../middleware/security.js';
|
||||||
|
import { validate } from '../middleware/validate.js';
|
||||||
|
import { loginSchema, changePasswordSchema } from '../schemas.js';
|
||||||
|
|
||||||
|
export const authRouter = Router();
|
||||||
|
|
||||||
|
const publicUser = (u) => ({ id: u.id, email: u.email, role: u.role, memberId: u.member_id });
|
||||||
|
|
||||||
|
authRouter.post('/login', loginLimiter, validate(loginSchema), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { email, password } = req.data;
|
||||||
|
const result = await query(
|
||||||
|
'SELECT id, email, password_hash, role, member_id FROM users WHERE email = $1',
|
||||||
|
[email]
|
||||||
|
);
|
||||||
|
// Always run a bcrypt comparison to keep timing uniform
|
||||||
|
const user = result.rows[0];
|
||||||
|
const hash = user?.password_hash?.startsWith('$2') ? user.password_hash : '$2a$12$invalidinvalidinvalidinvalidinvalidinvalidinvalidinva';
|
||||||
|
const ok = await bcrypt.compare(password, hash);
|
||||||
|
if (!user || !ok || !user.password_hash.startsWith('$2')) {
|
||||||
|
return res.status(401).json({ error: 'Email ou mot de passe incorrect.' });
|
||||||
|
}
|
||||||
|
issueSession(res, { sub: user.id, role: user.role, memberId: user.member_id });
|
||||||
|
res.json({ user: publicUser(user) });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
authRouter.post('/logout', (_req, res) => {
|
||||||
|
clearSession(res);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
authRouter.get('/me', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) return res.json({ user: null });
|
||||||
|
const result = await query(
|
||||||
|
'SELECT id, email, role, member_id FROM users WHERE id = $1',
|
||||||
|
[req.user.sub]
|
||||||
|
);
|
||||||
|
if (result.rowCount === 0) return res.json({ user: null });
|
||||||
|
res.json({ user: publicUser(result.rows[0]) });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
authRouter.post(
|
||||||
|
'/change-password',
|
||||||
|
requireAuth(),
|
||||||
|
validate(changePasswordSchema),
|
||||||
|
async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { currentPassword, newPassword } = req.data;
|
||||||
|
const result = await query('SELECT password_hash FROM users WHERE id = $1', [req.user.sub]);
|
||||||
|
if (result.rowCount === 0) return res.status(401).json({ error: 'Session invalide' });
|
||||||
|
const ok = await bcrypt.compare(currentPassword, result.rows[0].password_hash);
|
||||||
|
if (!ok) return res.status(401).json({ error: 'Mot de passe actuel incorrect.' });
|
||||||
|
const hash = await bcrypt.hash(newPassword, 12);
|
||||||
|
await query('UPDATE users SET password_hash = $1 WHERE id = $2', [hash, req.user.sub]);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { query } from '../db.js';
|
||||||
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
|
import { validate } from '../middleware/validate.js';
|
||||||
|
import { memberProfileSchema } from '../schemas.js';
|
||||||
|
import { imageUpload, saveImage, deleteImage } from '../uploads.js';
|
||||||
|
|
||||||
|
export const memberRouter = Router();
|
||||||
|
|
||||||
|
memberRouter.use(requireAuth('member'));
|
||||||
|
|
||||||
|
const PROFILE_SQL = `
|
||||||
|
SELECT m.id, m.nom, m.secteur, m.categorie_id, c.name AS categorie, m.dirigeant,
|
||||||
|
m.adhesion, m.email, m.tel, m.site, m.presentation, m.valide,
|
||||||
|
m.logo_path, m.photo_path
|
||||||
|
FROM members m LEFT JOIN categories c ON c.id = m.categorie_id
|
||||||
|
WHERE m.id = $1`;
|
||||||
|
|
||||||
|
memberRouter.get('/profile', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const result = await query(PROFILE_SQL, [req.user.memberId]);
|
||||||
|
if (result.rowCount === 0) return res.status(404).json({ error: 'Profil introuvable' });
|
||||||
|
res.json({ member: result.rows[0] });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
memberRouter.put('/profile', validate(memberProfileSchema), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { nom, secteur, categorie_id, dirigeant, email, tel, site, presentation } = req.data;
|
||||||
|
await query(
|
||||||
|
`UPDATE members SET nom=$1, secteur=$2, categorie_id=$3, dirigeant=$4, email=$5,
|
||||||
|
tel=$6, site=$7, presentation=$8, updated_at=now()
|
||||||
|
WHERE id=$9`,
|
||||||
|
[nom, secteur, categorie_id ?? null, dirigeant, email || null, tel, site, presentation, req.user.memberId]
|
||||||
|
);
|
||||||
|
const result = await query(PROFILE_SQL, [req.user.memberId]);
|
||||||
|
res.json({ member: result.rows[0] });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function imageRoute(column) {
|
||||||
|
return (req, res, next) => {
|
||||||
|
imageUpload(req, res, async (err) => {
|
||||||
|
if (err) return res.status(400).json({ error: 'Fichier invalide (2 Mo max).' });
|
||||||
|
try {
|
||||||
|
if (!req.file) return res.status(400).json({ error: 'Aucun fichier reçu.' });
|
||||||
|
const publicPath = await saveImage(req.file.buffer);
|
||||||
|
if (!publicPath) {
|
||||||
|
return res.status(400).json({ error: 'Format accepté : JPEG, PNG ou WebP.' });
|
||||||
|
}
|
||||||
|
const prev = await query(`SELECT ${column} FROM members WHERE id = $1`, [req.user.memberId]);
|
||||||
|
await query(`UPDATE members SET ${column} = $1, updated_at = now() WHERE id = $2`, [
|
||||||
|
publicPath,
|
||||||
|
req.user.memberId,
|
||||||
|
]);
|
||||||
|
await deleteImage(prev.rows[0]?.[column]);
|
||||||
|
res.json({ path: publicPath });
|
||||||
|
} catch (e) {
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
memberRouter.post('/profile/logo', imageRoute('logo_path'));
|
||||||
|
memberRouter.post('/profile/photo', imageRoute('photo_path'));
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { query } from '../db.js';
|
||||||
|
import { validate } from '../middleware/validate.js';
|
||||||
|
import { publicFormLimiter } from '../middleware/security.js';
|
||||||
|
import { demandeSchema, inscriptionPublicSchema, idParam } from '../schemas.js';
|
||||||
|
|
||||||
|
export const publicRouter = Router();
|
||||||
|
|
||||||
|
// Single bootstrap payload for the public site
|
||||||
|
publicRouter.get('/bootstrap', async (_req, res, next) => {
|
||||||
|
try {
|
||||||
|
const [content, categories, members, rencontres, passees] = await Promise.all([
|
||||||
|
query('SELECT key, value FROM site_content'),
|
||||||
|
query('SELECT id, name FROM categories ORDER BY id'),
|
||||||
|
query(
|
||||||
|
`SELECT m.id, m.nom, m.secteur, m.dirigeant, m.adhesion, m.email, m.tel, m.site,
|
||||||
|
m.presentation, m.logo_path, m.photo_path, c.name AS categorie
|
||||||
|
FROM members m LEFT JOIN categories c ON c.id = m.categorie_id
|
||||||
|
WHERE m.valide = true ORDER BY m.nom`
|
||||||
|
),
|
||||||
|
query(
|
||||||
|
`SELECT r.id, r.titre, r.date_renc, r.heure, r.lieu, r.description, r.places,
|
||||||
|
COUNT(i.id)::int AS inscrits
|
||||||
|
FROM rencontres r LEFT JOIN inscriptions i ON i.rencontre_id = r.id
|
||||||
|
WHERE r.date_renc >= CURRENT_DATE
|
||||||
|
GROUP BY r.id ORDER BY r.date_renc`
|
||||||
|
),
|
||||||
|
query('SELECT * FROM rencontres_passees ORDER BY id'),
|
||||||
|
]);
|
||||||
|
const contentMap = Object.fromEntries(content.rows.map((r) => [r.key, r.value]));
|
||||||
|
res.json({
|
||||||
|
content: contentMap,
|
||||||
|
categories: categories.rows,
|
||||||
|
members: members.rows,
|
||||||
|
rencontres: rencontres.rows,
|
||||||
|
rencontresPassees: passees.rows,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Membership request (home page form)
|
||||||
|
publicRouter.post(
|
||||||
|
'/demandes-adhesion',
|
||||||
|
publicFormLimiter,
|
||||||
|
validate(demandeSchema),
|
||||||
|
async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { nom, fonction, entreprise, email } = req.data;
|
||||||
|
await query(
|
||||||
|
'INSERT INTO demandes_adhesion (nom, fonction, entreprise, email) VALUES ($1, $2, $3, $4)',
|
||||||
|
[nom, fonction, entreprise, email]
|
||||||
|
);
|
||||||
|
res.status(201).json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Event registration (public modal)
|
||||||
|
publicRouter.post(
|
||||||
|
'/rencontres/:id/inscriptions',
|
||||||
|
publicFormLimiter,
|
||||||
|
validate(idParam, 'params'),
|
||||||
|
validate(inscriptionPublicSchema),
|
||||||
|
async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { nom, entreprise, email, tel } = req.data;
|
||||||
|
const renc = await query(
|
||||||
|
`SELECT r.places, COUNT(i.id)::int AS inscrits
|
||||||
|
FROM rencontres r LEFT JOIN inscriptions i ON i.rencontre_id = r.id
|
||||||
|
WHERE r.id = $1 AND r.date_renc >= CURRENT_DATE
|
||||||
|
GROUP BY r.id`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
if (renc.rowCount === 0) return res.status(404).json({ error: 'Rencontre introuvable' });
|
||||||
|
if (renc.rows[0].inscrits >= renc.rows[0].places) {
|
||||||
|
return res.status(409).json({ error: 'Cette rencontre est complète.' });
|
||||||
|
}
|
||||||
|
const dup = await query(
|
||||||
|
'SELECT 1 FROM inscriptions WHERE rencontre_id = $1 AND email = $2',
|
||||||
|
[id, email]
|
||||||
|
);
|
||||||
|
if (dup.rowCount > 0) {
|
||||||
|
return res.status(409).json({ error: 'Une inscription existe déjà avec cet email.' });
|
||||||
|
}
|
||||||
|
await query(
|
||||||
|
`INSERT INTO inscriptions (rencontre_id, nom, entreprise, email, tel, statut)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, 'en_attente')`,
|
||||||
|
[id, nom, entreprise, email, tel]
|
||||||
|
);
|
||||||
|
res.status(201).json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const trimmed = (max, min = 0) => z.string().trim().min(min).max(max);
|
||||||
|
|
||||||
|
export const idParam = z.object({ id: z.coerce.number().int().positive() });
|
||||||
|
|
||||||
|
export const loginSchema = z.object({
|
||||||
|
email: trimmed(254, 3).toLowerCase(),
|
||||||
|
password: z.string().min(1).max(200),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const changePasswordSchema = z.object({
|
||||||
|
currentPassword: z.string().min(1).max(200),
|
||||||
|
newPassword: z
|
||||||
|
.string()
|
||||||
|
.min(10, 'au moins 10 caractères')
|
||||||
|
.max(200)
|
||||||
|
.regex(/[a-zA-Z]/, 'doit contenir une lettre')
|
||||||
|
.regex(/[0-9]/, 'doit contenir un chiffre'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const demandeSchema = z.object({
|
||||||
|
nom: trimmed(120, 1),
|
||||||
|
fonction: trimmed(120).optional().default(''),
|
||||||
|
entreprise: trimmed(120, 1),
|
||||||
|
email: trimmed(254, 3).email(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const inscriptionPublicSchema = z.object({
|
||||||
|
nom: trimmed(120, 1),
|
||||||
|
entreprise: trimmed(120, 1),
|
||||||
|
email: trimmed(254, 3).email(),
|
||||||
|
tel: trimmed(30).optional().default(''),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const memberProfileSchema = z.object({
|
||||||
|
nom: trimmed(120, 1),
|
||||||
|
secteur: trimmed(120, 1),
|
||||||
|
categorie_id: z.coerce.number().int().positive().nullable().optional(),
|
||||||
|
dirigeant: trimmed(120, 1),
|
||||||
|
email: trimmed(254).email().or(z.literal('')).optional().default(''),
|
||||||
|
tel: trimmed(30).optional().default(''),
|
||||||
|
site: trimmed(200).optional().default(''),
|
||||||
|
presentation: trimmed(2000).optional().default(''),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const adminMemberSchema = memberProfileSchema.extend({
|
||||||
|
valide: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const rencontreSchema = z.object({
|
||||||
|
titre: trimmed(200, 1),
|
||||||
|
date_renc: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'format AAAA-MM-JJ'),
|
||||||
|
heure: trimmed(20).optional().default(''),
|
||||||
|
lieu: trimmed(200).optional().default(''),
|
||||||
|
description: trimmed(2000).optional().default(''),
|
||||||
|
places: z.coerce.number().int().min(0).max(100000),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const inscriptionAdminSchema = z.object({
|
||||||
|
nom: trimmed(120, 1),
|
||||||
|
entreprise: trimmed(120, 1),
|
||||||
|
email: trimmed(254).email().or(z.literal('')).optional().default(''),
|
||||||
|
tel: trimmed(30).optional().default(''),
|
||||||
|
rencontre_id: z.coerce.number().int().positive(),
|
||||||
|
statut: z.enum(['confirmee', 'en_attente']),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const categorySchema = z.object({ name: trimmed(80, 1) });
|
||||||
|
|
||||||
|
export const contentSchema = z.object({
|
||||||
|
hero_quote_text: trimmed(300).optional(),
|
||||||
|
hero_quote_author: trimmed(120).optional(),
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import multer from 'multer';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { config } from './config.js';
|
||||||
|
|
||||||
|
const MAX_SIZE = 2 * 1024 * 1024; // 2 MB
|
||||||
|
|
||||||
|
// magic-byte sniffing: never trust the client-provided MIME type
|
||||||
|
const SIGNATURES = [
|
||||||
|
{ ext: 'jpg', mime: 'image/jpeg', check: (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff },
|
||||||
|
{ ext: 'png', mime: 'image/png', check: (b) => b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47 },
|
||||||
|
{
|
||||||
|
ext: 'webp',
|
||||||
|
mime: 'image/webp',
|
||||||
|
check: (b) =>
|
||||||
|
b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 &&
|
||||||
|
b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const imageUpload = multer({
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
limits: { fileSize: MAX_SIZE, files: 1 },
|
||||||
|
}).single('file');
|
||||||
|
|
||||||
|
export function detectImageType(buffer) {
|
||||||
|
if (!buffer || buffer.length < 12) return null;
|
||||||
|
return SIGNATURES.find((s) => s.check(buffer)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Saves a validated image with a random, non-guessable filename and
|
||||||
|
// returns the public path. The filename is fully server-generated: no
|
||||||
|
// user input ever reaches the filesystem path.
|
||||||
|
export async function saveImage(buffer) {
|
||||||
|
const type = detectImageType(buffer);
|
||||||
|
if (!type) return null;
|
||||||
|
const name = `${crypto.randomBytes(16).toString('hex')}.${type.ext}`;
|
||||||
|
await fs.mkdir(config.uploadDir, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(config.uploadDir, name), buffer, { flag: 'wx' });
|
||||||
|
return `/uploads/${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteImage(publicPath) {
|
||||||
|
if (!publicPath || !publicPath.startsWith('/uploads/')) return;
|
||||||
|
const name = path.basename(publicPath);
|
||||||
|
// basename() strips any traversal; only delete inside the upload dir
|
||||||
|
await fs.rm(path.join(config.uploadDir, name), { force: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci 2>/dev/null || npm install
|
||||||
|
COPY index.html vite.config.js ./
|
||||||
|
COPY public ./public
|
||||||
|
COPY src ./src
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginxinc/nginx-unprivileged:1.27-alpine
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 8080
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="icon" type="image/jpeg" href="/assets/logo.jpg" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;1,6..72,400;1,6..72,500&family=Archivo:wght@400;500;600;700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<title>Business Club SLUC Nancy</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
server {
|
||||||
|
listen 8080;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
server_tokens off;
|
||||||
|
client_max_body_size 3m;
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||||
|
|
||||||
|
# API proxied to the backend container
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://api:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Forwarded-Host $http_host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Uploaded images: served directly from the shared volume, never executed
|
||||||
|
location /uploads/ {
|
||||||
|
alias /var/www/uploads/;
|
||||||
|
types { image/jpeg jpg; image/png png; image/webp webp; }
|
||||||
|
default_type application/octet-stream;
|
||||||
|
# user-uploaded content: never allow it to script or be framed
|
||||||
|
add_header Content-Security-Policy "default-src 'none'; frame-ancestors 'none'" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Content-Disposition "inline" always;
|
||||||
|
add_header Cache-Control "public, max-age=86400" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Static assets (immutable hashed filenames from Vite)
|
||||||
|
location /assets/ {
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback
|
||||||
|
location / {
|
||||||
|
try_files $uri /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1722
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "sbc-web",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.26.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"vite": "^5.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
@@ -0,0 +1,35 @@
|
|||||||
|
import { Routes, Route, useLocation } from 'react-router-dom';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import Header from './components/Header.jsx';
|
||||||
|
import Footer from './components/Footer.jsx';
|
||||||
|
import Home from './pages/Home.jsx';
|
||||||
|
import Annuaire from './pages/Annuaire.jsx';
|
||||||
|
import Association from './pages/Association.jsx';
|
||||||
|
import Espace from './pages/Espace.jsx';
|
||||||
|
import Admin from './pages/Admin.jsx';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const location = useLocation();
|
||||||
|
const isAdmin = location.pathname.startsWith('/admin');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{!isAdmin && <Header />}
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Home />} />
|
||||||
|
<Route path="/annuaire" element={<Annuaire />} />
|
||||||
|
<Route path="/association" element={<Association />} />
|
||||||
|
<Route path="/espace-membre" element={<Espace />} />
|
||||||
|
<Route path="/admin/*" element={<Admin />} />
|
||||||
|
<Route path="*" element={<Home />} />
|
||||||
|
</Routes>
|
||||||
|
</div>
|
||||||
|
{!isAdmin && <Footer />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
const linkStyle = { color: '#B7AFA6', fontSize: 14, textDecoration: 'none' };
|
||||||
|
|
||||||
|
export default function Footer() {
|
||||||
|
return (
|
||||||
|
<footer style={{ background: 'var(--footer)', color: '#B7AFA6', padding: '64px 0 34px' }}>
|
||||||
|
<div className="container grid-4" style={{ gridTemplateColumns: '1.6fr 1fr 1fr 1fr', gap: 40 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 18 }}>
|
||||||
|
<img
|
||||||
|
src="/assets/logo.jpg"
|
||||||
|
alt="SLUC Business Club"
|
||||||
|
style={{ height: 48, width: 48, objectFit: 'cover', borderRadius: 4, background: '#fff' }}
|
||||||
|
/>
|
||||||
|
<span className="serif" style={{ fontSize: 18, fontWeight: 600, color: '#fff' }}>
|
||||||
|
Business Club SLUC Nancy
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 14, lineHeight: 1.65, maxWidth: 320 }}>
|
||||||
|
Le réseau d'affaires des partenaires du SLUC Nancy Basket. Le business se joue en équipe.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 12, letterSpacing: '.14em', textTransform: 'uppercase', color: '#6E675F', fontWeight: 600, marginBottom: 16 }}>
|
||||||
|
Navigation
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, alignItems: 'flex-start' }}>
|
||||||
|
<Link to="/" style={linkStyle}>Accueil</Link>
|
||||||
|
<Link to="/annuaire" style={linkStyle}>Annuaire</Link>
|
||||||
|
<Link to="/association" style={linkStyle}>L'association</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 12, letterSpacing: '.14em', textTransform: 'uppercase', color: '#6E675F', fontWeight: 600, marginBottom: 16 }}>
|
||||||
|
Contact
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, fontSize: 14, lineHeight: 1.5 }}>
|
||||||
|
<span>Palais des Sports J. Weille<br />Nancy (54)</span>
|
||||||
|
<span>contact@sluc-businessclub.fr</span>
|
||||||
|
<span>+33 3 83 00 00 00</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 12, letterSpacing: '.14em', textTransform: 'uppercase', color: '#6E675F', fontWeight: 600, marginBottom: 16 }}>
|
||||||
|
Suivez-nous
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, fontSize: 14 }}>
|
||||||
|
<span>LinkedIn</span>
|
||||||
|
<span>Instagram</span>
|
||||||
|
<span>Facebook</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="container"
|
||||||
|
style={{
|
||||||
|
marginTop: 40, paddingTop: 24, borderTop: '1px solid rgba(255,255,255,.08)',
|
||||||
|
display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12,
|
||||||
|
fontSize: 12.5, color: '#6E675F',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>© {new Date().getFullYear()} Business Club SLUC Nancy</span>
|
||||||
|
<span>Mentions légales · Confidentialité</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { Link, NavLink } from 'react-router-dom';
|
||||||
|
|
||||||
|
const navLinkClass = ({ isActive }) => `nav-btn${isActive ? ' active' : ''}`;
|
||||||
|
|
||||||
|
export default function Header() {
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
style={{
|
||||||
|
position: 'sticky', top: 0, zIndex: 50,
|
||||||
|
background: 'rgba(250,248,245,.86)', backdropFilter: 'blur(12px)',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="container"
|
||||||
|
style={{ height: 76, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 24 }}
|
||||||
|
>
|
||||||
|
<Link to="/" style={{ display: 'flex', alignItems: 'center', gap: 14, textDecoration: 'none' }}>
|
||||||
|
<img src="/assets/logo.jpg" alt="SLUC Business Club Nancy" style={{ height: 50, width: 'auto', display: 'block' }} />
|
||||||
|
<span style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', lineHeight: 1.05 }}>
|
||||||
|
<span className="serif" style={{ fontSize: 19, fontWeight: 600, color: 'var(--ink)' }}>Business Club</span>
|
||||||
|
<span style={{ fontSize: 10, letterSpacing: '.22em', textTransform: 'uppercase', color: 'var(--red-dark)', fontWeight: 600 }}>
|
||||||
|
SLUC Nancy
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<nav style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||||
|
<NavLink to="/" end className={navLinkClass}>Accueil</NavLink>
|
||||||
|
<NavLink to="/annuaire" className={navLinkClass}>Annuaire</NavLink>
|
||||||
|
<NavLink to="/association" className={navLinkClass}>L'association</NavLink>
|
||||||
|
<NavLink to="/espace-membre" className={navLinkClass}>Espace membre</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to="/admin"
|
||||||
|
style={{ fontSize: 13, color: 'var(--gray-light)', display: 'flex', alignItems: 'center', gap: 6 }}
|
||||||
|
className="nav-btn"
|
||||||
|
>
|
||||||
|
<span style={{ width: 6, height: 6, borderRadius: '50%', background: 'var(--red)', display: 'inline-block' }} />
|
||||||
|
Espace admin
|
||||||
|
</NavLink>
|
||||||
|
<Link to="/" style={{ marginLeft: 10, textDecoration: 'none' }}>
|
||||||
|
<button className="btn btn-red" style={{ padding: '12px 22px', fontSize: 14 }}>Devenir membre</button>
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { api } from '../lib/api.js';
|
||||||
|
|
||||||
|
// Real replacement for the mock's <image-slot>: click or drop an image,
|
||||||
|
// it is uploaded to `endpoint` and the stored path is displayed.
|
||||||
|
export default function ImageSlot({ endpoint, value, onUploaded, placeholder, circle }) {
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const send = async (file) => {
|
||||||
|
if (!file) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const d = await api.upload(endpoint, file);
|
||||||
|
onUploaded?.(d.path);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`image-slot${circle ? ' circle' : ''}`}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
send(e.dataTransfer.files?.[0]);
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
{value && <img src={value} alt="" />}
|
||||||
|
{!value && <span>{busy ? 'Envoi…' : error || placeholder}</span>}
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={(e) => send(e.target.files?.[0])}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import Modal from './Modal.jsx';
|
||||||
|
|
||||||
|
export default function MemberModal({ member, onClose }) {
|
||||||
|
if (!member) return null;
|
||||||
|
return (
|
||||||
|
<Modal onClose={onClose} maxWidth={760}>
|
||||||
|
<div style={{ position: 'relative', background: 'var(--dark)', color: '#fff', padding: '32px 36px', display: 'flex', alignItems: 'center', gap: 22 }}>
|
||||||
|
<button className="modal-close" onClick={onClose} style={{ top: 18, right: 18, width: 32, height: 32 }} aria-label="Fermer">×</button>
|
||||||
|
<div style={{ width: 76, height: 76, flexShrink: 0, borderRadius: 8, background: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden', fontFamily: 'monospace', fontSize: 10, color: '#a8a099' }}>
|
||||||
|
{member.logo_path ? (
|
||||||
|
<img src={member.logo_path} alt="" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
|
||||||
|
) : ('logo')}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 11, letterSpacing: '.12em', textTransform: 'uppercase', color: 'var(--red-soft)', fontWeight: 600, marginBottom: 6 }}>
|
||||||
|
{member.secteur}
|
||||||
|
</div>
|
||||||
|
<h2 className="serif" style={{ fontSize: 30, fontWeight: 600, lineHeight: 1.05 }}>{member.nom}</h2>
|
||||||
|
<div style={{ marginTop: 10 }}>
|
||||||
|
<span style={{ display: 'inline-block', fontSize: 11.5, fontWeight: 600, color: '#E7E1D9', background: 'rgba(255,255,255,.12)', borderRadius: 20, padding: '5px 12px' }}>
|
||||||
|
{member.categorie || 'Non classée'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '32px 36px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 20, background: 'var(--beige)', borderRadius: 8, padding: '20px 22px', marginBottom: 26 }}>
|
||||||
|
<div className={member.photo_path ? '' : 'placeholder-pattern'} style={{ width: 88, height: 88, flexShrink: 0, borderRadius: '50%', overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: 'monospace', fontSize: 9, color: '#a8a099' }}>
|
||||||
|
{member.photo_path ? (
|
||||||
|
<img src={member.photo_path} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
|
) : ('photo')}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 11, letterSpacing: '.1em', textTransform: 'uppercase', color: 'var(--gray-light)', fontWeight: 600, marginBottom: 5 }}>
|
||||||
|
Dirigeant·e
|
||||||
|
</div>
|
||||||
|
<div className="serif" style={{ fontSize: 22, fontWeight: 600, lineHeight: 1.1 }}>{member.dirigeant}</div>
|
||||||
|
{member.adhesion && (
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--gray)', marginTop: 4 }}>Membre depuis {member.adhesion}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style={{ fontSize: 15, lineHeight: 1.7, color: 'var(--gray)', marginBottom: 26 }}>{member.presentation}</p>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, borderTop: '1px solid var(--border-soft)', paddingTop: 22 }}>
|
||||||
|
{[['Email', member.email], ['Tél', member.tel], ['Site', member.site]].map(([label, value]) =>
|
||||||
|
value ? (
|
||||||
|
<div key={label} style={{ display: 'flex', gap: 12, alignItems: 'center', fontSize: 14 }}>
|
||||||
|
<span style={{ width: 64, fontSize: 11, textTransform: 'uppercase', letterSpacing: '.08em', color: 'var(--gray-light)', fontWeight: 600 }}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: label === 'Site' ? 'var(--red)' : 'var(--ink)' }}>{value}</span>
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{member.email && (
|
||||||
|
<a href={`mailto:${member.email}`} style={{ textDecoration: 'none' }}>
|
||||||
|
<button className="btn btn-dark btn-sm" style={{ marginTop: 26, padding: '13px 24px', fontSize: 14 }}>
|
||||||
|
Contacter cette entreprise
|
||||||
|
</button>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export default function Modal({ onClose, maxWidth = 560, children, header }) {
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
|
<div className="modal" style={{ maxWidth }} onClick={(e) => e.stopPropagation()}>
|
||||||
|
{header && (
|
||||||
|
<div className="modal-header">
|
||||||
|
<button className="modal-close" onClick={onClose} aria-label="Fermer">×</button>
|
||||||
|
{header.kicker && (
|
||||||
|
<div className="kicker on-dark" style={{ fontSize: 11, letterSpacing: '.12em', marginBottom: 6 }}>
|
||||||
|
{header.kicker}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{header.title && (
|
||||||
|
<h3 className="serif" style={{ fontSize: 23, fontWeight: 600, lineHeight: 1.15 }}>{header.title}</h3>
|
||||||
|
)}
|
||||||
|
{header.meta && <div style={{ fontSize: 13, color: '#B7AFA6', marginTop: 8 }}>{header.meta}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { api } from '../../lib/api.js';
|
||||||
|
import ImageSlot from '../ImageSlot.jsx';
|
||||||
|
|
||||||
|
export function CategoriesTab() {
|
||||||
|
const [categories, setCategories] = useState([]);
|
||||||
|
const [editId, setEditId] = useState(null);
|
||||||
|
const [draft, setDraft] = useState('');
|
||||||
|
const [newCat, setNewCat] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const reload = () =>
|
||||||
|
api.get('/api/admin/categories').then((d) => setCategories(d.categories)).catch(() => {});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reload();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const save = async (id) => {
|
||||||
|
if (!draft.trim()) return setEditId(null);
|
||||||
|
try {
|
||||||
|
await api.put(`/api/admin/categories/${id}`, { name: draft.trim() });
|
||||||
|
setEditId(null);
|
||||||
|
setDraft('');
|
||||||
|
reload();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (c) => {
|
||||||
|
if (!window.confirm(`Supprimer la catégorie « ${c.name} » ? Les entreprises concernées deviendront « Non classée ».`)) return;
|
||||||
|
try {
|
||||||
|
await api.del(`/api/admin/categories/${c.id}`);
|
||||||
|
reload();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const add = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newCat.trim()) return;
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await api.post('/api/admin/categories', { name: newCat.trim() });
|
||||||
|
setNewCat('');
|
||||||
|
reload();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid-2" style={{ gridTemplateColumns: '1.4fr 1fr', gap: 20, alignItems: 'start' }}>
|
||||||
|
<div className="card" style={{ borderRadius: 6, overflow: 'hidden' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '20px 24px', borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<h3 className="serif" style={{ fontSize: 19, fontWeight: 600 }}>Catégories d'entreprises</h3>
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--gray-light)' }}>{categories.length} catégories</span>
|
||||||
|
</div>
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Catégorie</th><th>Entreprises</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{categories.map((c) => (
|
||||||
|
<tr key={c.id}>
|
||||||
|
<td style={{ padding: '12px 24px' }}>
|
||||||
|
{editId === c.id ? (
|
||||||
|
<input
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
maxLength={80}
|
||||||
|
autoFocus
|
||||||
|
style={{ fontSize: 14, padding: '8px 12px', border: '1px solid var(--red)', borderRadius: 3, background: 'var(--bg)', width: '100%', maxWidth: 260 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 600 }}>{c.name}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px 24px' }}>{c.count}</td>
|
||||||
|
<td style={{ padding: '12px 24px', textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||||
|
{editId === c.id ? (
|
||||||
|
<>
|
||||||
|
<button className="btn btn-red btn-sm" style={{ fontSize: 12.5, padding: '8px 14px', marginRight: 6 }} onClick={() => save(c.id)}>
|
||||||
|
Enregistrer
|
||||||
|
</button>
|
||||||
|
<button className="btn-link-gray" style={{ fontSize: 12.5, padding: 8 }} onClick={() => { setEditId(null); setDraft(''); }}>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button className="btn-link" style={{ fontSize: 13, marginRight: 14 }} onClick={() => { setEditId(c.id); setDraft(c.name); }}>
|
||||||
|
Renommer
|
||||||
|
</button>
|
||||||
|
<button className="btn-link-gray" onClick={() => remove(c)}>Supprimer</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{error && <p className="error-text" style={{ padding: '12px 24px' }}>{error}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="card" style={{ borderRadius: 6, padding: 26 }}>
|
||||||
|
<h3 className="serif" style={{ fontSize: 19, fontWeight: 600, marginBottom: 8 }}>Nouvelle catégorie</h3>
|
||||||
|
<p style={{ fontSize: 13.5, color: 'var(--gray-light)', lineHeight: 1.55, marginBottom: 18 }}>
|
||||||
|
Ajoutez une catégorie pour classer les entreprises de l'annuaire.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={add}>
|
||||||
|
<input
|
||||||
|
value={newCat}
|
||||||
|
onChange={(e) => setNewCat(e.target.value)}
|
||||||
|
placeholder="Ex. Restauration & Hôtellerie"
|
||||||
|
maxLength={80}
|
||||||
|
style={{ width: '100%', padding: '12px 14px', border: '1px solid var(--input-border)', borderRadius: 3, background: 'var(--bg)', marginBottom: 14 }}
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn btn-red" style={{ width: '100%', fontSize: 14.5, padding: 13, borderRadius: 3 }}>
|
||||||
|
+ Ajouter la catégorie
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContenuTab() {
|
||||||
|
const [content, setContent] = useState({ hero_quote_text: '', hero_quote_author: '', hero_photo: '' });
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get('/api/public/bootstrap').then((d) => setContent({ hero_photo: '', ...d.content })).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const save = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await api.put('/api/admin/content', {
|
||||||
|
hero_quote_text: content.hero_quote_text,
|
||||||
|
hero_quote_author: content.hero_quote_author,
|
||||||
|
});
|
||||||
|
setSaved(true);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid-2" style={{ gap: 20, alignItems: 'start', maxWidth: 900 }}>
|
||||||
|
<div className="card" style={{ borderRadius: 6, padding: 26 }}>
|
||||||
|
<h3 className="serif" style={{ fontSize: 19, fontWeight: 600, marginBottom: 8 }}>Section « L'esprit du Club »</h3>
|
||||||
|
<p style={{ fontSize: 13.5, color: 'var(--gray-light)', lineHeight: 1.55, marginBottom: 20 }}>
|
||||||
|
Photo, citation et signature affichées sur la page d'accueil.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={save}>
|
||||||
|
<label className="field" style={{ marginBottom: 16 }}>Citation
|
||||||
|
<textarea
|
||||||
|
rows={2}
|
||||||
|
value={content.hero_quote_text}
|
||||||
|
maxLength={300}
|
||||||
|
onChange={(e) => { setContent({ ...content, hero_quote_text: e.target.value }); setSaved(false); }}
|
||||||
|
style={{ fontFamily: 'var(--serif)', fontSize: 16, fontWeight: 400 }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">Signature
|
||||||
|
<input
|
||||||
|
value={content.hero_quote_author}
|
||||||
|
maxLength={120}
|
||||||
|
onChange={(e) => { setContent({ ...content, hero_quote_author: e.target.value }); setSaved(false); }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error && <p className="error-text" style={{ marginTop: 12 }}>{error}</p>}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginTop: 20 }}>
|
||||||
|
<button type="submit" className="btn btn-red btn-sm" style={{ fontSize: 14, padding: '12px 22px' }}>Enregistrer</button>
|
||||||
|
{saved && <span className="success-text">✓ Contenu enregistré</span>}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div className="card" style={{ borderRadius: 6, padding: 26 }}>
|
||||||
|
<h3 className="serif" style={{ fontSize: 19, fontWeight: 600, marginBottom: 8 }}>Photo</h3>
|
||||||
|
<p style={{ fontSize: 13.5, color: 'var(--gray-light)', lineHeight: 1.55, marginBottom: 18 }}>
|
||||||
|
Déposez une image ou cliquez pour en choisir une — elle remplace celle affichée sur l'accueil.
|
||||||
|
</p>
|
||||||
|
<div style={{ aspectRatio: '4/5', maxWidth: 260 }}>
|
||||||
|
<ImageSlot
|
||||||
|
endpoint="/api/admin/content/hero-photo"
|
||||||
|
value={content.hero_photo}
|
||||||
|
placeholder="Photo — networking en tribune VIP"
|
||||||
|
onUploaded={(path) => setContent({ ...content, hero_photo: path })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,608 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { api, dateParts, seasonLabel, statutLabel } from '../../lib/api.js';
|
||||||
|
import Modal from '../Modal.jsx';
|
||||||
|
|
||||||
|
/* ---------------- Members ---------------- */
|
||||||
|
|
||||||
|
function MemberFormModal({ member, categories, onClose, onSaved }) {
|
||||||
|
const [form, setForm] = useState(
|
||||||
|
member
|
||||||
|
? { ...member, email: member.email || '', tel: member.tel || '', site: member.site || '', presentation: member.presentation || '' }
|
||||||
|
: { nom: '', secteur: '', categorie_id: categories[0]?.id ?? null, dirigeant: '', email: '', tel: '', site: '', presentation: '' }
|
||||||
|
);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const onChange = (e) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setForm({ ...form, [name]: name === 'categorie_id' ? (value ? Number(value) : null) : value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
const body = {
|
||||||
|
nom: form.nom, secteur: form.secteur, categorie_id: form.categorie_id,
|
||||||
|
dirigeant: form.dirigeant, email: form.email, tel: form.tel,
|
||||||
|
site: form.site, presentation: form.presentation,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (member) await api.put(`/api/admin/members/${member.id}`, body);
|
||||||
|
else await api.post('/api/admin/members', body);
|
||||||
|
onSaved();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal onClose={onClose} maxWidth={600} header={{ kicker: 'Membres', title: member ? 'Modifier le membre' : 'Nouveau membre' }}>
|
||||||
|
<form onSubmit={submit} style={{ padding: '26px 30px' }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
|
<label className="field">Entreprise
|
||||||
|
<input name="nom" value={form.nom} onChange={onChange} required maxLength={120} />
|
||||||
|
</label>
|
||||||
|
<label className="field">Catégorie
|
||||||
|
<select name="categorie_id" value={form.categorie_id || ''} onChange={onChange}>
|
||||||
|
<option value="">Non classée</option>
|
||||||
|
{categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="field">Secteur d'activité
|
||||||
|
<input name="secteur" value={form.secteur} onChange={onChange} required maxLength={120} />
|
||||||
|
</label>
|
||||||
|
<label className="field">Dirigeant·e
|
||||||
|
<input name="dirigeant" value={form.dirigeant} onChange={onChange} required maxLength={120} />
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
|
<label className="field">Email
|
||||||
|
<input name="email" type="email" value={form.email} onChange={onChange} maxLength={254} />
|
||||||
|
</label>
|
||||||
|
<label className="field">Téléphone
|
||||||
|
<input name="tel" value={form.tel} onChange={onChange} maxLength={30} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="field">Site web
|
||||||
|
<input name="site" value={form.site} onChange={onChange} maxLength={200} />
|
||||||
|
</label>
|
||||||
|
<label className="field">Présentation
|
||||||
|
<textarea name="presentation" value={form.presentation} onChange={onChange} rows={3} maxLength={2000} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{error && <p className="error-text" style={{ marginTop: 12 }}>{error}</p>}
|
||||||
|
<div style={{ display: 'flex', gap: 12, marginTop: 24 }}>
|
||||||
|
<button type="submit" className="btn btn-red btn-sm" style={{ flex: 1, fontSize: 14.5, padding: 13 }}>
|
||||||
|
{member ? 'Enregistrer' : 'Ajouter le membre'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-outline-soft btn-sm" style={{ fontSize: 14.5, padding: '13px 22px' }} onClick={onClose}>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MembersTab() {
|
||||||
|
const [members, setMembers] = useState([]);
|
||||||
|
const [categories, setCategories] = useState([]);
|
||||||
|
const [modal, setModal] = useState(null); // null | 'new' | member
|
||||||
|
const season = seasonLabel();
|
||||||
|
|
||||||
|
const reload = () =>
|
||||||
|
Promise.all([api.get('/api/admin/members'), api.get('/api/admin/categories')])
|
||||||
|
.then(([m, c]) => {
|
||||||
|
setMembers(m.members);
|
||||||
|
setCategories(c.categories);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reload();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggle = async (m) => {
|
||||||
|
await api.post(`/api/admin/members/${m.id}/toggle-valide`);
|
||||||
|
reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
const actifs = members.filter((m) => m.valide).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ borderRadius: 6, overflow: 'hidden' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '20px 24px', borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<div>
|
||||||
|
<h3 className="serif" style={{ fontSize: 19, fontWeight: 600 }}>Membres · {actifs} validés</h3>
|
||||||
|
<div style={{ fontSize: 12.5, color: 'var(--gray-light)', marginTop: 3 }}>
|
||||||
|
Saison {season} · {members.length - actifs} à renouveler
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-red btn-sm" style={{ fontSize: 13, padding: '10px 18px' }} onClick={() => setModal('new')}>
|
||||||
|
+ Ajouter un membre
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-start', padding: '14px 24px', background: '#FBF6EE', borderBottom: '1px solid var(--border-soft)', fontSize: 12.5, color: '#8A6D3B', lineHeight: 1.5 }}>
|
||||||
|
<span>ⓘ</span>
|
||||||
|
<span>
|
||||||
|
Un membre non validé pour la saison en cours n'apparaît pas dans l'annuaire public et ne peut pas
|
||||||
|
être inscrit aux rencontres. La saison court du 1<sup>er</sup> septembre au 31 août.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Entreprise</th><th>Secteur</th><th>Dirigeant</th><th>Saison</th><th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{members.map((m) => (
|
||||||
|
<tr key={m.id}>
|
||||||
|
<td>{m.nom}</td>
|
||||||
|
<td>{m.secteur}</td>
|
||||||
|
<td>{m.dirigeant}</td>
|
||||||
|
<td>
|
||||||
|
<span className={`badge ${m.valide ? 'badge-green' : 'badge-red'}`}>
|
||||||
|
{m.valide ? `Validé ${season}` : 'Non validé'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||||
|
<button className="btn-link-gray" style={{ color: 'var(--gray)', marginRight: 14 }} onClick={() => toggle(m)}>
|
||||||
|
{m.valide ? 'Suspendre' : 'Valider'}
|
||||||
|
</button>
|
||||||
|
<button className="btn-link" style={{ fontSize: 13 }} onClick={() => setModal(m)}>Modifier</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{modal && (
|
||||||
|
<MemberFormModal
|
||||||
|
member={modal === 'new' ? null : modal}
|
||||||
|
categories={categories}
|
||||||
|
onClose={() => setModal(null)}
|
||||||
|
onSaved={() => { setModal(null); reload(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Rencontres ---------------- */
|
||||||
|
|
||||||
|
function RencFormModal({ renc, onClose, onSaved }) {
|
||||||
|
const [form, setForm] = useState(
|
||||||
|
renc
|
||||||
|
? { titre: renc.titre, date_renc: String(renc.date_renc).slice(0, 10), heure: renc.heure, lieu: renc.lieu, description: renc.description, places: renc.places }
|
||||||
|
: { titre: '', date_renc: '', heure: '', lieu: '', description: '', places: 30 }
|
||||||
|
);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const onChange = (e) => setForm({ ...form, [e.target.name]: e.target.value });
|
||||||
|
|
||||||
|
const submit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
if (renc) await api.put(`/api/admin/rencontres/${renc.id}`, form);
|
||||||
|
else await api.post('/api/admin/rencontres', form);
|
||||||
|
onSaved();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async () => {
|
||||||
|
if (!window.confirm('Supprimer cette rencontre et toutes ses inscriptions ?')) return;
|
||||||
|
try {
|
||||||
|
await api.del(`/api/admin/rencontres/${renc.id}`);
|
||||||
|
onSaved();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal onClose={onClose} maxWidth={560} header={{ kicker: 'Rencontres', title: renc ? 'Gérer la rencontre' : 'Nouvelle rencontre' }}>
|
||||||
|
<form onSubmit={submit} style={{ padding: '26px 30px' }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
<label className="field">Titre de la rencontre
|
||||||
|
<input name="titre" value={form.titre} onChange={onChange} required maxLength={200} />
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
|
||||||
|
<label className="field" style={{ gridColumn: 'span 2' }}>Date
|
||||||
|
<input name="date_renc" type="date" value={form.date_renc} onChange={onChange} required />
|
||||||
|
</label>
|
||||||
|
<label className="field">Heure
|
||||||
|
<input name="heure" value={form.heure} onChange={onChange} placeholder="18h30" maxLength={20} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 12 }}>
|
||||||
|
<label className="field">Lieu
|
||||||
|
<input name="lieu" value={form.lieu} onChange={onChange} required maxLength={200} />
|
||||||
|
</label>
|
||||||
|
<label className="field">Places
|
||||||
|
<input name="places" type="number" min={0} max={100000} value={form.places} onChange={onChange} required />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="field">Description
|
||||||
|
<textarea name="description" value={form.description} onChange={onChange} rows={3} maxLength={2000} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{error && <p className="error-text" style={{ marginTop: 12 }}>{error}</p>}
|
||||||
|
<div style={{ display: 'flex', gap: 12, marginTop: 24, alignItems: 'center' }}>
|
||||||
|
<button type="submit" className="btn btn-red btn-sm" style={{ flex: 1, fontSize: 14.5, padding: 13 }}>
|
||||||
|
{renc ? 'Enregistrer' : 'Créer la rencontre'}
|
||||||
|
</button>
|
||||||
|
{renc && (
|
||||||
|
<button type="button" className="btn-link-gray" style={{ fontSize: 13.5, padding: '13px 10px' }} onClick={remove}>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button type="button" className="btn btn-outline-soft btn-sm" style={{ fontSize: 14.5, padding: '13px 22px' }} onClick={onClose}>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(v) {
|
||||||
|
return String(v ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function ParticipantsModal({ renc, onClose, onEdit, onCancel, refreshKey }) {
|
||||||
|
const [rows, setRows] = useState([]);
|
||||||
|
const { jour, mois } = dateParts(renc.date_renc);
|
||||||
|
const meta = `${jour} ${mois} · ${renc.heure} · ${renc.lieu}`;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get(`/api/admin/rencontres/${renc.id}/inscriptions`).then((d) => setRows(d.inscriptions)).catch(() => {});
|
||||||
|
}, [renc.id, refreshKey]);
|
||||||
|
|
||||||
|
const exportExcel = () => {
|
||||||
|
let html = '<html xmlns:x="urn:schemas-microsoft-com:office:excel"><head><meta charset="utf-8"></head><body><table border="1"><thead>';
|
||||||
|
html += '<tr><th>Participant</th><th>Entreprise</th><th>Email</th><th>Téléphone</th><th>Rencontre</th><th>Statut</th></tr></thead><tbody>';
|
||||||
|
rows.forEach((p) => {
|
||||||
|
html += `<tr><td>${escHtml(p.nom)}</td><td>${escHtml(p.entreprise)}</td><td>${escHtml(p.email)}</td><td>${escHtml(p.tel)}</td><td>${escHtml(renc.titre)}</td><td>${escHtml(statutLabel(p.statut))}</td></tr>`;
|
||||||
|
});
|
||||||
|
html += '</tbody></table></body></html>';
|
||||||
|
const blob = new Blob(['' + html], { type: 'application/vnd.ms-excel' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
const slug = renc.titre.normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^a-zA-Z0-9]+/g, '-').toLowerCase();
|
||||||
|
a.href = url;
|
||||||
|
a.download = `inscrits-${slug}.xls`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const print = () => {
|
||||||
|
let body = '';
|
||||||
|
rows.forEach((p, i) => {
|
||||||
|
body += `<tr><td>${i + 1}</td><td>${escHtml(p.nom)}</td><td>${escHtml(p.entreprise)}</td><td>${escHtml(p.email)}</td><td>${escHtml(p.tel)}</td><td>${escHtml(statutLabel(p.statut))}</td></tr>`;
|
||||||
|
});
|
||||||
|
const doc = `<!doctype html><html><head><meta charset="utf-8"><title>Inscrits — ${escHtml(renc.titre)}</title>`
|
||||||
|
+ '<style>body{font-family:Arial,Helvetica,sans-serif;color:#1B1B1B;padding:32px}h1{font-size:22px;margin:0 0 4px}.meta{color:#666;font-size:13px;margin-bottom:20px}'
|
||||||
|
+ 'table{width:100%;border-collapse:collapse;font-size:13px}th,td{text-align:left;padding:9px 10px;border-bottom:1px solid #ddd}th{background:#f4f1ec;text-transform:uppercase;font-size:11px;letter-spacing:.05em}'
|
||||||
|
+ '.brand{color:#C1272D;font-weight:700;font-size:12px;letter-spacing:.1em;text-transform:uppercase;margin-bottom:16px}</style></head><body>'
|
||||||
|
+ '<div class="brand">Business Club SLUC Nancy</div>'
|
||||||
|
+ `<h1>${escHtml(renc.titre)}</h1><div class="meta">${escHtml(meta)} — ${rows.length} inscrits</div>`
|
||||||
|
+ '<table><thead><tr><th>#</th><th>Participant</th><th>Entreprise</th><th>Email</th><th>Téléphone</th><th>Statut</th></tr></thead><tbody>'
|
||||||
|
+ body + '</tbody></table></body></html>';
|
||||||
|
const w = window.open('', '_blank');
|
||||||
|
if (!w) return;
|
||||||
|
w.document.open();
|
||||||
|
w.document.write(doc);
|
||||||
|
w.document.close();
|
||||||
|
setTimeout(() => { w.focus(); w.print(); }, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal onClose={onClose} maxWidth={720} header={{ kicker: 'Liste des inscrits', title: renc.titre, meta: `${meta} · ${rows.length} inscrits` }}>
|
||||||
|
<div style={{ padding: '22px 32px', display: 'flex', gap: 12, borderBottom: '1px solid var(--border-soft)' }}>
|
||||||
|
<button className="btn btn-sm" style={{ background: 'var(--admin-bg)', color: 'var(--ink)', fontSize: 13.5, padding: '11px 18px' }} onClick={print}>
|
||||||
|
⎙ Imprimer
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-sm" style={{ background: '#1E7B45', color: '#fff', fontSize: 13.5, padding: '11px 18px' }} onClick={exportExcel}>
|
||||||
|
↓ Exporter Excel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '6px 0 12px' }}>
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Participant</th><th>Entreprise</th><th>Email</th><th>Statut</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((p) => (
|
||||||
|
<tr key={p.id}>
|
||||||
|
<td>{p.nom}</td>
|
||||||
|
<td>{p.entreprise}</td>
|
||||||
|
<td>{p.email}</td>
|
||||||
|
<td><span className={`badge ${p.statut === 'confirmee' ? 'badge-green' : 'badge-amber'}`}>{statutLabel(p.statut)}</span></td>
|
||||||
|
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||||
|
<button className="btn-link" style={{ fontSize: 12.5, marginRight: 12 }} onClick={() => onEdit(p)}>Modifier</button>
|
||||||
|
<button className="btn-link-gray" style={{ fontSize: 12.5 }} onClick={() => onCancel(p)}>Annuler</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{rows.length === 0 && (
|
||||||
|
<div style={{ textAlign: 'center', padding: '40px 20px', color: 'var(--gray-light)', fontSize: 14 }}>
|
||||||
|
Aucun inscrit pour cette rencontre.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RencontresTab() {
|
||||||
|
const [rencontres, setRencontres] = useState([]);
|
||||||
|
const [formModal, setFormModal] = useState(null); // null | 'new' | renc
|
||||||
|
const [participantsRenc, setParticipantsRenc] = useState(null);
|
||||||
|
const [editInscr, setEditInscr] = useState(null);
|
||||||
|
const [cancelInscr, setCancelInscr] = useState(null);
|
||||||
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
|
||||||
|
const reload = () => {
|
||||||
|
api.get('/api/admin/rencontres').then((d) => setRencontres(d.rencontres)).catch(() => {});
|
||||||
|
setRefreshKey((k) => k + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reload();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 22 }}>
|
||||||
|
<h3 className="serif" style={{ fontSize: 20, fontWeight: 600 }}>Gestion des rencontres</h3>
|
||||||
|
<button className="btn btn-red btn-sm" style={{ fontSize: 13, padding: '10px 18px' }} onClick={() => setFormModal('new')}>
|
||||||
|
+ Créer une rencontre
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
{rencontres.map((e) => {
|
||||||
|
const { jour, mois } = dateParts(e.date_renc);
|
||||||
|
return (
|
||||||
|
<div key={e.id} className="card" style={{ borderRadius: 6, padding: '22px 24px', display: 'grid', gridTemplateColumns: 'auto 1fr auto auto', gap: 24, alignItems: 'center' }}>
|
||||||
|
<div style={{ width: 56, textAlign: 'center', background: 'var(--admin-bg)', borderRadius: 5, padding: '10px 0', lineHeight: 1 }}>
|
||||||
|
<div className="serif" style={{ fontSize: 24, fontWeight: 600 }}>{jour}</div>
|
||||||
|
<div style={{ fontSize: 10, textTransform: 'uppercase', color: 'var(--gray-light)', letterSpacing: '.1em' }}>{mois}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 600 }}>{e.titre}</div>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--gray-light)', marginTop: 4 }}>{e.heure} · {e.lieu}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div className="serif" style={{ fontSize: 22, fontWeight: 600, color: 'var(--red)' }}>{e.inscrits}</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--gray-light)' }}>inscrits / {e.places}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button className="btn btn-dark btn-sm" style={{ fontSize: 13, padding: '9px 16px' }} onClick={() => setParticipantsRenc(e)}>
|
||||||
|
Inscrits ({e.inscrits})
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-sm" style={{ background: 'var(--admin-bg)', fontSize: 13, padding: '9px 16px' }} onClick={() => setFormModal(e)}>
|
||||||
|
Gérer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formModal && (
|
||||||
|
<RencFormModal
|
||||||
|
renc={formModal === 'new' ? null : formModal}
|
||||||
|
onClose={() => setFormModal(null)}
|
||||||
|
onSaved={() => { setFormModal(null); reload(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{participantsRenc && (
|
||||||
|
<ParticipantsModal
|
||||||
|
renc={participantsRenc}
|
||||||
|
refreshKey={refreshKey}
|
||||||
|
onClose={() => setParticipantsRenc(null)}
|
||||||
|
onEdit={setEditInscr}
|
||||||
|
onCancel={setCancelInscr}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{editInscr && (
|
||||||
|
<InscriptionEditModal
|
||||||
|
inscription={editInscr}
|
||||||
|
rencontres={rencontres}
|
||||||
|
onClose={() => setEditInscr(null)}
|
||||||
|
onSaved={() => { setEditInscr(null); reload(); }}
|
||||||
|
onRequestCancel={(p) => { setEditInscr(null); setCancelInscr(p); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{cancelInscr && (
|
||||||
|
<CancelConfirmModal
|
||||||
|
inscription={cancelInscr}
|
||||||
|
onClose={() => setCancelInscr(null)}
|
||||||
|
onDone={() => { setCancelInscr(null); reload(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Inscriptions ---------------- */
|
||||||
|
|
||||||
|
function InscriptionEditModal({ inscription, rencontres, onClose, onSaved, onRequestCancel }) {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
nom: inscription.nom,
|
||||||
|
entreprise: inscription.entreprise,
|
||||||
|
email: inscription.email || '',
|
||||||
|
tel: inscription.tel || '',
|
||||||
|
rencontre_id: inscription.rencontre_id,
|
||||||
|
statut: inscription.statut,
|
||||||
|
});
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const onChange = (e) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setForm({ ...form, [name]: name === 'rencontre_id' ? Number(value) : value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await api.put(`/api/admin/inscriptions/${inscription.id}`, form);
|
||||||
|
onSaved();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal onClose={onClose} maxWidth={520} header={{ kicker: 'Inscription', title: "Modifier l'inscription" }}>
|
||||||
|
<form onSubmit={submit} style={{ padding: '26px 30px' }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
|
<label className="field">Participant
|
||||||
|
<input name="nom" value={form.nom} onChange={onChange} required maxLength={120} />
|
||||||
|
</label>
|
||||||
|
<label className="field">Entreprise
|
||||||
|
<input name="entreprise" value={form.entreprise} onChange={onChange} required maxLength={120} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
|
<label className="field">Email
|
||||||
|
<input name="email" type="email" value={form.email} onChange={onChange} maxLength={254} />
|
||||||
|
</label>
|
||||||
|
<label className="field">Téléphone
|
||||||
|
<input name="tel" value={form.tel} onChange={onChange} maxLength={30} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="field">Rencontre
|
||||||
|
<select name="rencontre_id" value={form.rencontre_id} onChange={onChange}>
|
||||||
|
{rencontres.map((r) => <option key={r.id} value={r.id}>{r.titre}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="field">Statut
|
||||||
|
<select name="statut" value={form.statut} onChange={onChange}>
|
||||||
|
<option value="confirmee">Confirmée</option>
|
||||||
|
<option value="en_attente">En attente</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{error && <p className="error-text" style={{ marginTop: 12 }}>{error}</p>}
|
||||||
|
<div style={{ display: 'flex', gap: 12, marginTop: 24, alignItems: 'center' }}>
|
||||||
|
<button type="submit" className="btn btn-red btn-sm" style={{ flex: 1, fontSize: 14.5, padding: 13 }}>Enregistrer</button>
|
||||||
|
<button type="button" className="btn-link-gray" style={{ fontSize: 13.5, padding: '13px 10px' }} onClick={() => onRequestCancel(inscription)}>
|
||||||
|
Annuler l'inscription
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-outline-soft btn-sm" style={{ fontSize: 14.5, padding: '13px 22px' }} onClick={onClose}>
|
||||||
|
Fermer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CancelConfirmModal({ inscription, onClose, onDone }) {
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const confirm = async () => {
|
||||||
|
try {
|
||||||
|
await api.del(`/api/admin/inscriptions/${inscription.id}`);
|
||||||
|
onDone();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Modal onClose={onClose} maxWidth={440}>
|
||||||
|
<div style={{ padding: '36px 34px', textAlign: 'center' }}>
|
||||||
|
<div style={{ width: 56, height: 56, borderRadius: '50%', background: '#F6E4E4', color: 'var(--red)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 26, margin: '0 auto 20px' }}>⚠</div>
|
||||||
|
<h3 className="serif" style={{ fontSize: 24, fontWeight: 600, marginBottom: 12 }}>Annuler cette inscription ?</h3>
|
||||||
|
<p style={{ fontSize: 15, color: 'var(--gray)', lineHeight: 1.6, marginBottom: 6 }}>
|
||||||
|
Retirer <strong>{inscription.nom}</strong> de la liste
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: 14, color: 'var(--gray-light)', marginBottom: 28 }}>{inscription.rencontre}</p>
|
||||||
|
{error && <p className="error-text" style={{ marginBottom: 12 }}>{error}</p>}
|
||||||
|
<div style={{ display: 'flex', gap: 12 }}>
|
||||||
|
<button className="btn btn-red btn-sm" style={{ flex: 1, fontSize: 14.5, padding: 13 }} onClick={confirm}>
|
||||||
|
Annuler l'inscription
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-outline-soft btn-sm" style={{ flex: 1, fontSize: 14.5, padding: 13 }} onClick={onClose}>
|
||||||
|
Retour
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InscriptionsTab() {
|
||||||
|
const [inscriptions, setInscriptions] = useState([]);
|
||||||
|
const [rencontres, setRencontres] = useState([]);
|
||||||
|
const [edit, setEdit] = useState(null);
|
||||||
|
const [cancel, setCancel] = useState(null);
|
||||||
|
|
||||||
|
const reload = () =>
|
||||||
|
Promise.all([api.get('/api/admin/inscriptions'), api.get('/api/admin/rencontres')])
|
||||||
|
.then(([i, r]) => {
|
||||||
|
setInscriptions(i.inscriptions);
|
||||||
|
setRencontres(r.rencontres);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reload();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ borderRadius: 6, overflow: 'hidden' }}>
|
||||||
|
<div style={{ padding: '20px 24px', borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<h3 className="serif" style={{ fontSize: 19, fontWeight: 600 }}>Inscriptions aux rencontres</h3>
|
||||||
|
</div>
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Participant</th><th>Entreprise</th><th>Rencontre</th><th>Statut</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{inscriptions.map((i) => (
|
||||||
|
<tr key={i.id}>
|
||||||
|
<td>{i.nom}</td>
|
||||||
|
<td>{i.entreprise}</td>
|
||||||
|
<td>{i.rencontre}</td>
|
||||||
|
<td><span className={`badge ${i.statut === 'confirmee' ? 'badge-green' : 'badge-amber'}`}>{statutLabel(i.statut)}</span></td>
|
||||||
|
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||||
|
<button className="btn-link" style={{ fontSize: 13, marginRight: 14 }} onClick={() => setEdit(i)}>Modifier</button>
|
||||||
|
<button className="btn-link-gray" onClick={() => setCancel(i)}>Annuler</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{edit && (
|
||||||
|
<InscriptionEditModal
|
||||||
|
inscription={edit}
|
||||||
|
rencontres={rencontres}
|
||||||
|
onClose={() => setEdit(null)}
|
||||||
|
onSaved={() => { setEdit(null); reload(); }}
|
||||||
|
onRequestCancel={(p) => { setEdit(null); setCancel(p); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{cancel && (
|
||||||
|
<CancelConfirmModal
|
||||||
|
inscription={cancel}
|
||||||
|
onClose={() => setCancel(null)}
|
||||||
|
onDone={() => { setCancel(null); reload(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { createContext, useContext, useEffect, useState } from 'react';
|
||||||
|
import { api } from './api.js';
|
||||||
|
|
||||||
|
const AuthContext = createContext(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }) {
|
||||||
|
const [user, setUser] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api
|
||||||
|
.get('/api/auth/me')
|
||||||
|
.then((d) => setUser(d.user))
|
||||||
|
.catch(() => setUser(null))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = async (email, password) => {
|
||||||
|
const d = await api.post('/api/auth/login', { email, password });
|
||||||
|
setUser(d.user);
|
||||||
|
return d.user;
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
try {
|
||||||
|
await api.post('/api/auth/logout');
|
||||||
|
} finally {
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, loading, login, logout }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuth = () => useContext(AuthContext);
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
async function request(path, options = {}) {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
headers: options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
...options,
|
||||||
|
body:
|
||||||
|
options.body instanceof FormData
|
||||||
|
? options.body
|
||||||
|
: options.body
|
||||||
|
? JSON.stringify(options.body)
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
let data = null;
|
||||||
|
try {
|
||||||
|
data = await res.json();
|
||||||
|
} catch {
|
||||||
|
/* non-JSON response */
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = new Error(data?.error || `Erreur ${res.status}`);
|
||||||
|
err.status = res.status;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
get: (path) => request(path),
|
||||||
|
post: (path, body) => request(path, { method: 'POST', body }),
|
||||||
|
put: (path, body) => request(path, { method: 'PUT', body }),
|
||||||
|
del: (path) => request(path, { method: 'DELETE' }),
|
||||||
|
upload: (path, file) => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
return request(path, { method: 'POST', body: fd });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const MOIS = ['Jan', 'Fév', 'Mars', 'Avr', 'Mai', 'Juin', 'Juil', 'Août', 'Sept', 'Oct', 'Nov', 'Déc'];
|
||||||
|
|
||||||
|
export function dateParts(dateStr) {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
if (Number.isNaN(d.getTime())) return { jour: '--', mois: '' };
|
||||||
|
return { jour: String(d.getUTCDate()).padStart(2, '0'), mois: MOIS[d.getUTCMonth()] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function seasonLabel(d = new Date()) {
|
||||||
|
const y = d.getFullYear();
|
||||||
|
return d.getMonth() >= 8 ? `${y}–${y + 1}` : `${y - 1}–${y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const statutLabel = (s) => (s === 'confirmee' ? 'Confirmée' : 'En attente');
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { api } from './api.js';
|
||||||
|
|
||||||
|
export function usePublicData() {
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const reload = () =>
|
||||||
|
api
|
||||||
|
.get('/api/public/bootstrap')
|
||||||
|
.then(setData)
|
||||||
|
.catch((e) => setError(e.message));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reload();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { data, error, reload };
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import App from './App.jsx';
|
||||||
|
import { AuthProvider } from './lib/AuthContext.jsx';
|
||||||
|
import './styles.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../lib/AuthContext.jsx';
|
||||||
|
import { api, dateParts, statutLabel } from '../lib/api.js';
|
||||||
|
import { LoginSection } from './Espace.jsx';
|
||||||
|
import { MembersTab, RencontresTab, InscriptionsTab } from '../components/admin/AdminTabs.jsx';
|
||||||
|
import { CategoriesTab, ContenuTab } from '../components/admin/AdminContent.jsx';
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ key: 'dashboard', icon: '◧', label: 'Tableau de bord', title: 'Tableau de bord' },
|
||||||
|
{ key: 'membres', icon: '▤', label: 'Membres', title: 'Gestion des membres' },
|
||||||
|
{ key: 'rencontres', icon: '◈', label: 'Rencontres', title: 'Rencontres' },
|
||||||
|
{ key: 'inscriptions', icon: '✎', label: 'Inscriptions', title: 'Inscriptions' },
|
||||||
|
{ key: 'categories', icon: '☲', label: 'Catégories', title: 'Catégories' },
|
||||||
|
{ key: 'contenu', icon: '▧', label: 'Contenu du site', title: 'Contenu du site' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function Dashboard() {
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
useEffect(() => {
|
||||||
|
api.get('/api/admin/dashboard').then(setData).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
if (!data) return <p style={{ color: 'var(--gray-light)' }}>Chargement…</p>;
|
||||||
|
|
||||||
|
const k = data.kpis;
|
||||||
|
const daysToNext = k.prochaine_date
|
||||||
|
? Math.max(0, Math.round((new Date(k.prochaine_date) - Date.now()) / 86400000))
|
||||||
|
: null;
|
||||||
|
const kpis = [
|
||||||
|
{ n: k.membres_valides, l: 'Membres validés', d: 'saison en cours' },
|
||||||
|
{ n: k.a_renouveler, l: 'À renouveler', d: 'non validés' },
|
||||||
|
{ n: k.rencontres_a_venir, l: 'Rencontres à venir', d: daysToNext != null ? `prochaine dans ${daysToNext} j` : '—' },
|
||||||
|
{ n: k.inscriptions_attente, l: 'Inscriptions en attente', d: 'à valider' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="grid-4" style={{ marginBottom: 32 }}>
|
||||||
|
{kpis.map((x) => (
|
||||||
|
<div key={x.l} className="card" style={{ borderRadius: 6, padding: 24 }}>
|
||||||
|
<div style={{ fontSize: 12.5, color: 'var(--gray-light)', marginBottom: 12 }}>{x.l}</div>
|
||||||
|
<div className="serif" style={{ fontSize: 38, fontWeight: 600, lineHeight: 1 }}>{x.n}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--red)', fontWeight: 600, marginTop: 10 }}>{x.d}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid-2" style={{ gridTemplateColumns: '1.5fr 1fr', gap: 20 }}>
|
||||||
|
<div className="card" style={{ borderRadius: 6, padding: 26 }}>
|
||||||
|
<h3 className="serif" style={{ fontSize: 19, fontWeight: 600, marginBottom: 18 }}>Dernières inscriptions</h3>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
{data.latestInscriptions.map((i) => (
|
||||||
|
<div key={i.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 0', borderBottom: '1px solid var(--border-soft)' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 600 }}>{i.nom}</div>
|
||||||
|
<div style={{ fontSize: 12.5, color: 'var(--gray-light)' }}>{i.entreprise} · {i.rencontre}</div>
|
||||||
|
</div>
|
||||||
|
<span className={`badge ${i.statut === 'confirmee' ? 'badge-green' : 'badge-amber'}`}>{statutLabel(i.statut)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card" style={{ borderRadius: 6, padding: 26 }}>
|
||||||
|
<h3 className="serif" style={{ fontSize: 19, fontWeight: 600, marginBottom: 18 }}>Prochaines rencontres</h3>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
{data.upcoming.map((e) => {
|
||||||
|
const { jour, mois } = dateParts(e.date_renc);
|
||||||
|
return (
|
||||||
|
<div key={e.id} style={{ display: 'flex', gap: 14, alignItems: 'center' }}>
|
||||||
|
<div style={{ width: 46, flexShrink: 0, textAlign: 'center', background: 'var(--admin-bg)', borderRadius: 4, padding: '8px 0', lineHeight: 1 }}>
|
||||||
|
<div className="serif" style={{ fontSize: 20, fontWeight: 600 }}>{jour}</div>
|
||||||
|
<div style={{ fontSize: 9, textTransform: 'uppercase', color: 'var(--gray-light)', letterSpacing: '.1em' }}>{mois}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 600, lineHeight: 1.2 }}>{e.titre}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--gray-light)', marginTop: 3 }}>
|
||||||
|
{Math.max(0, e.places - e.inscrits)} places restantes
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Admin() {
|
||||||
|
const { user, loading, logout } = useAuth();
|
||||||
|
const [tab, setTab] = useState('dashboard');
|
||||||
|
|
||||||
|
if (loading) return <main style={{ minHeight: '60vh' }} />;
|
||||||
|
if (!user || user.role !== 'admin') {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<LoginSection kicker="Espace admin" title="Administration du Business Club" />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = TABS.find((t) => t.key === tab);
|
||||||
|
const today = new Date().toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', minHeight: '100vh', background: 'var(--admin-bg)' }}>
|
||||||
|
<aside style={{ width: 250, flexShrink: 0, background: 'var(--dark)', color: '#fff', display: 'flex', flexDirection: 'column', padding: '26px 0', position: 'sticky', top: 0, height: '100vh' }}>
|
||||||
|
<div style={{ padding: '0 24px 26px', borderBottom: '1px solid rgba(255,255,255,.1)', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<img src="/assets/logo.jpg" alt="SLUC" style={{ height: 40, width: 40, objectFit: 'cover', borderRadius: 4, background: '#fff' }} />
|
||||||
|
<div style={{ lineHeight: 1.1 }}>
|
||||||
|
<div className="serif" style={{ fontSize: 16, fontWeight: 600 }}>Admin</div>
|
||||||
|
<div style={{ fontSize: 10, letterSpacing: '.14em', textTransform: 'uppercase', color: '#8A8279' }}>Business Club</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<nav style={{ padding: '20px 14px', display: 'flex', flexDirection: 'column', gap: 4, flex: 1 }}>
|
||||||
|
{TABS.map((t) => (
|
||||||
|
<button key={t.key} className={`admin-nav-btn${tab === t.key ? ' active' : ''}`} onClick={() => setTab(t.key)}>
|
||||||
|
{t.icon} {t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div style={{ padding: '14px 24px 0', borderTop: '1px solid rgba(255,255,255,.1)', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
<Link to="/" style={{ color: '#B7AFA6', fontSize: 13.5, fontWeight: 500, padding: '6px 0', textDecoration: 'none' }}>← Retour au site</Link>
|
||||||
|
<button onClick={logout} style={{ background: 'none', border: 'none', color: '#B7AFA6', fontSize: 13.5, fontWeight: 500, padding: '6px 0', textAlign: 'left' }}>
|
||||||
|
Se déconnecter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ background: '#fff', borderBottom: '1px solid var(--border)', padding: '20px 40px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<div>
|
||||||
|
<h1 className="serif" style={{ fontSize: 24, fontWeight: 600 }}>{current.title}</h1>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--gray-light)', marginTop: 2, textTransform: 'capitalize' }}>{today}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ width: 38, height: 38, borderRadius: '50%', background: '#FBEDEC', color: 'var(--red)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 700, fontSize: 14 }}>
|
||||||
|
{(user.email || 'AD').slice(0, 2).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '36px 40px' }}>
|
||||||
|
{tab === 'dashboard' && <Dashboard />}
|
||||||
|
{tab === 'membres' && <MembersTab />}
|
||||||
|
{tab === 'rencontres' && <RencontresTab />}
|
||||||
|
{tab === 'inscriptions' && <InscriptionsTab />}
|
||||||
|
{tab === 'categories' && <CategoriesTab />}
|
||||||
|
{tab === 'contenu' && <ContenuTab />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { usePublicData } from '../lib/usePublic.js';
|
||||||
|
import MemberModal from '../components/MemberModal.jsx';
|
||||||
|
|
||||||
|
export default function Annuaire() {
|
||||||
|
const { data } = usePublicData();
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [category, setCategory] = useState('Toutes');
|
||||||
|
const [detail, setDetail] = useState(null);
|
||||||
|
|
||||||
|
const members = data?.members ?? [];
|
||||||
|
const categories = data?.categories ?? [];
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
return members.filter((m) => {
|
||||||
|
const matchCat = category === 'Toutes' || (m.categorie || 'Non classée') === category;
|
||||||
|
const matchText = !q || `${m.nom} ${m.dirigeant} ${m.secteur} ${m.categorie || ''}`.toLowerCase().includes(q);
|
||||||
|
return matchCat && matchText;
|
||||||
|
});
|
||||||
|
}, [members, search, category]);
|
||||||
|
|
||||||
|
const chips = ['Toutes', ...categories.map((c) => c.name)];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<section style={{ background: '#fff', borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<div className="container" style={{ padding: '70px 32px 60px' }}>
|
||||||
|
<div className="kicker" style={{ marginBottom: 16 }}>Annuaire des membres</div>
|
||||||
|
<h1 className="serif" style={{ fontWeight: 500, fontSize: 'clamp(36px, 5vw, 54px)', lineHeight: 1.05, marginBottom: 18 }}>
|
||||||
|
{members.length} entreprises, un réseau.
|
||||||
|
</h1>
|
||||||
|
<p style={{ fontSize: 17, lineHeight: 1.6, color: 'var(--gray)', maxWidth: 600 }}>
|
||||||
|
Découvrez les entreprises membres du Business Club SLUC Nancy et prenez contact directement avec leurs dirigeants.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="container" style={{ padding: '44px 32px 100px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap', marginBottom: 14 }}>
|
||||||
|
<div style={{ position: 'relative', flex: 1, minWidth: 260 }}>
|
||||||
|
<span style={{ position: 'absolute', left: 16, top: '50%', transform: 'translateY(-50%)', color: 'var(--gray-light)', fontSize: 15 }}>⌕</span>
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Rechercher une entreprise, un dirigeant, un secteur…"
|
||||||
|
maxLength={120}
|
||||||
|
style={{ width: '100%', padding: '14px 16px 14px 42px', border: '1px solid var(--input-border)', borderRadius: 3, background: '#fff' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 13.5, color: 'var(--gray-light)', whiteSpace: 'nowrap' }}>
|
||||||
|
{filtered.length} entreprise{filtered.length > 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginBottom: 36 }}>
|
||||||
|
{chips.map((c) => (
|
||||||
|
<button key={c} className={`chip${category === c ? ' active' : ''}`} onClick={() => setCategory(c)}>
|
||||||
|
{c}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid-3">
|
||||||
|
{filtered.map((m) => (
|
||||||
|
<div key={m.id} className="card" style={{ padding: 26, display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 16 }}>
|
||||||
|
<div className={m.logo_path ? '' : 'placeholder-pattern'} style={{ width: 64, height: 64, flexShrink: 0, borderRadius: 4, display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: 'monospace', fontSize: 9, color: '#a8a099', overflow: 'hidden', background: m.logo_path ? '#fff' : undefined }}>
|
||||||
|
{m.logo_path ? <img src={m.logo_path} alt="" style={{ width: '100%', height: '100%', objectFit: 'contain' }} /> : 'logo'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="serif" style={{ fontSize: 20, fontWeight: 600, lineHeight: 1.15 }}>{m.nom}</div>
|
||||||
|
<div style={{ fontSize: 11, letterSpacing: '.08em', textTransform: 'uppercase', color: 'var(--red)', fontWeight: 600, marginTop: 5 }}>{m.secteur}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<span className="badge-cat">{m.categorie || 'Non classée'}</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 14, lineHeight: 1.6, color: 'var(--gray)', flex: 1, marginBottom: 20 }}>{m.presentation}</p>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: 18, borderTop: '1px solid var(--border-soft)' }}>
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--gray-light)' }}>{m.dirigeant}</span>
|
||||||
|
<button className="btn-link" onClick={() => setDetail(m)}>Voir la fiche →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<div style={{ textAlign: 'center', padding: '80px 20px', color: 'var(--gray-light)' }}>
|
||||||
|
<div className="serif" style={{ fontSize: 26, color: 'var(--ink)', marginBottom: 10 }}>Aucune entreprise trouvée</div>
|
||||||
|
<p style={{ fontSize: 15, marginBottom: 24 }}>Essayez d'autres mots-clés ou réinitialisez les filtres.</p>
|
||||||
|
<button className="btn btn-dark btn-sm" style={{ fontSize: 14, padding: '12px 24px' }} onClick={() => { setSearch(''); setCategory('Toutes'); }}>
|
||||||
|
Réinitialiser
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<MemberModal member={detail} onClose={() => setDetail(null)} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
const VALUES = [
|
||||||
|
{ t: 'Réseau', d: "Un carnet d'adresses vivant, nourri par des rencontres régulières et de vraies affinités." },
|
||||||
|
{ t: 'Convivialité', d: "Des moments d'échange chaleureux, où les relations se nouent avant les contrats." },
|
||||||
|
{ t: 'Performance', d: "L'exigence du haut niveau appliquée au monde de l'entreprise." },
|
||||||
|
{ t: 'Territoire', d: "Un engagement fort pour l'économie et le rayonnement du Grand Est." },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TIMELINE = [
|
||||||
|
{ y: '1996', t: 'Création du Club', d: "Une poignée d'entreprises se rassemblent autour du SLUC Nancy." },
|
||||||
|
{ y: '2005', t: '50 membres', d: "Le réseau s'impose comme un acteur économique local." },
|
||||||
|
{ y: '2015', t: 'Premier gala', d: 'La soirée annuelle devient le rendez-vous phare du Club.' },
|
||||||
|
{ y: '2026', t: '85 membres actifs', d: "Un réseau d'affaires de référence dans la région." },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATS = [
|
||||||
|
{ n: '85', l: 'entreprises membres' },
|
||||||
|
{ n: '12', l: 'rencontres par an' },
|
||||||
|
{ n: '30 ans', l: 'de partenariat' },
|
||||||
|
{ n: '200+', l: 'invités au gala annuel' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Association() {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<section style={{ background: 'var(--dark)', color: '#fff' }}>
|
||||||
|
<div style={{ maxWidth: 900, margin: '0 auto', padding: '110px 32px', textAlign: 'center' }}>
|
||||||
|
<div className="kicker on-dark" style={{ letterSpacing: '.22em', marginBottom: 26 }}>L'association</div>
|
||||||
|
<h1 className="serif" style={{ fontWeight: 400, fontSize: 'clamp(38px, 5vw, 56px)', lineHeight: 1.08, marginBottom: 26 }}>
|
||||||
|
Trente ans à réunir le sport<br />et l'entreprise.
|
||||||
|
</h1>
|
||||||
|
<p style={{ fontSize: 18, lineHeight: 1.65, color: '#B7AFA6', maxWidth: 620, margin: '0 auto' }}>
|
||||||
|
Le Business Club est l'association des partenaires économiques du SLUC Nancy Basket.
|
||||||
|
Une communauté de dirigeants qui partagent les valeurs du haut niveau.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="hero-grid" style={{ maxWidth: 1080, margin: '0 auto', padding: '96px 32px 40px', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 64, alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<div className="kicker" style={{ marginBottom: 16 }}>Notre mission</div>
|
||||||
|
<h2 className="serif" style={{ fontWeight: 500, fontSize: 38, lineHeight: 1.12, marginBottom: 22 }}>
|
||||||
|
Créer des liens qui font grandir les entreprises.
|
||||||
|
</h2>
|
||||||
|
<p style={{ fontSize: 16, lineHeight: 1.7, color: 'var(--gray)', marginBottom: 16 }}>
|
||||||
|
Nous rassemblons chaque année des dirigeants du Grand Est autour de rencontres d'affaires,
|
||||||
|
d'événements exclusifs et de moments de convivialité, dans le cadre unique de la vie d'un
|
||||||
|
club de basket professionnel.
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: 16, lineHeight: 1.7, color: 'var(--gray)' }}>
|
||||||
|
Adhérer, c'est développer son réseau, gagner en visibilité et soutenir le sport de haut
|
||||||
|
niveau sur son territoire.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="placeholder-pattern" style={{ aspectRatio: '4/3', borderRadius: 5, display: 'flex', alignItems: 'flex-end', padding: 22 }}>
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: 11, color: '#a8a099', background: 'var(--bg)', padding: '5px 9px', borderRadius: 2 }}>
|
||||||
|
photo — assemblée des membres
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style={{ maxWidth: 1080, margin: '0 auto', padding: '56px 32px' }}>
|
||||||
|
<div className="grid-4">
|
||||||
|
{VALUES.map((v) => (
|
||||||
|
<div key={v.t} className="card" style={{ padding: 26 }}>
|
||||||
|
<div style={{ width: 34, height: 3, background: 'var(--red)', marginBottom: 18 }} />
|
||||||
|
<h3 className="serif" style={{ fontSize: 21, fontWeight: 600, marginBottom: 10 }}>{v.t}</h3>
|
||||||
|
<p style={{ fontSize: 14, lineHeight: 1.6, color: 'var(--gray)' }}>{v.d}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style={{ background: 'var(--beige)', marginTop: 56, padding: '90px 0' }}>
|
||||||
|
<div className="hero-grid" style={{ maxWidth: 980, margin: '0 auto', padding: '0 32px', display: 'grid', gridTemplateColumns: '.8fr 1.2fr', gap: 56, alignItems: 'center' }}>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ aspectRatio: '1/1', borderRadius: 6, background: 'repeating-linear-gradient(135deg,#e4dfd8 0 12px,#eeeae3 12px 24px)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: 'monospace', fontSize: 11, color: '#a8a099', marginBottom: 16 }}>
|
||||||
|
portrait
|
||||||
|
</div>
|
||||||
|
<div className="serif" style={{ fontSize: 20, fontWeight: 600 }}>Jean-Marc Lefèvre</div>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--gray-light)', marginTop: 2 }}>Président du Business Club</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="serif" style={{ fontSize: 60, color: 'var(--red)', lineHeight: 0.5, height: 32 }}>“</div>
|
||||||
|
<p className="serif" style={{ fontSize: 26, lineHeight: 1.45, fontWeight: 400, marginBottom: 22 }}>
|
||||||
|
Notre force, c'est le collectif. Comme sur le terrain, nos membres avancent ensemble,
|
||||||
|
se soutiennent et bâtissent des réussites communes.
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: 15, lineHeight: 1.7, color: 'var(--gray)' }}>
|
||||||
|
Depuis sa création, le Club n'a cessé de grandir grâce à l'engagement de dirigeants qui
|
||||||
|
croient à la puissance du réseau. Je vous invite à nous rejoindre.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style={{ maxWidth: 1080, margin: '0 auto', padding: '96px 32px' }}>
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: 52 }}>
|
||||||
|
<div className="kicker" style={{ marginBottom: 14 }}>Notre histoire</div>
|
||||||
|
<h2 className="serif" style={{ fontWeight: 500, fontSize: 40 }}>30 ans de croissance</h2>
|
||||||
|
</div>
|
||||||
|
<div className="grid-4" style={{ gap: 0 }}>
|
||||||
|
{TIMELINE.map((t) => (
|
||||||
|
<div key={t.y} style={{ padding: '0 20px', borderLeft: '1px solid var(--border)', position: 'relative' }}>
|
||||||
|
<div style={{ position: 'absolute', left: -6, top: 6, width: 11, height: 11, borderRadius: '50%', background: 'var(--red)' }} />
|
||||||
|
<div className="serif" style={{ fontSize: 30, fontWeight: 600, color: 'var(--red)', marginBottom: 8 }}>{t.y}</div>
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 6 }}>{t.t}</div>
|
||||||
|
<div style={{ fontSize: 13.5, lineHeight: 1.55, color: 'var(--gray)' }}>{t.d}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style={{ background: 'var(--dark)', color: '#fff', padding: '80px 0' }}>
|
||||||
|
<div className="grid-4" style={{ maxWidth: 1080, margin: '0 auto', padding: '0 32px', gap: 40, textAlign: 'center' }}>
|
||||||
|
{STATS.map((s) => (
|
||||||
|
<div key={s.l}>
|
||||||
|
<div className="serif" style={{ fontSize: 46, fontWeight: 600 }}>{s.n}</div>
|
||||||
|
<div style={{ fontSize: 13.5, color: '#B7AFA6', marginTop: 6 }}>{s.l}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useAuth } from '../lib/AuthContext.jsx';
|
||||||
|
import { api, seasonLabel } from '../lib/api.js';
|
||||||
|
import ImageSlot from '../components/ImageSlot.jsx';
|
||||||
|
|
||||||
|
function LoginSection({ title = 'Gérez votre présence au Club', kicker = 'Espace membre' }) {
|
||||||
|
const { login } = useAuth();
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const submit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await login(email, password);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ minHeight: 'calc(100vh - 76px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '60px 24px', background: 'var(--dark)' }}>
|
||||||
|
<div style={{ width: '100%', maxWidth: 440 }}>
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: 28 }}>
|
||||||
|
<div className="kicker on-dark" style={{ marginBottom: 14 }}>{kicker}</div>
|
||||||
|
<h1 className="serif" style={{ fontWeight: 500, fontSize: 34, color: '#fff', lineHeight: 1.1 }}>{title}</h1>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={submit} style={{ background: '#fff', borderRadius: 8, padding: '32px 30px' }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
<label className="field">Email professionnel
|
||||||
|
<input name="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="vous@entreprise.fr" required maxLength={254} autoComplete="username" />
|
||||||
|
</label>
|
||||||
|
<label className="field">Mot de passe
|
||||||
|
<input name="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="••••••••" required maxLength={200} autoComplete="current-password" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{error && <p className="error-text" style={{ marginTop: 14 }}>{error}</p>}
|
||||||
|
<button type="submit" className="btn btn-red" style={{ width: '100%', marginTop: 22, padding: 14, borderRadius: 3 }} disabled={busy}>
|
||||||
|
{busy ? 'Connexion…' : 'Se connecter'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PasswordCard() {
|
||||||
|
const [form, setForm] = useState({ currentPassword: '', newPassword: '' });
|
||||||
|
const [msg, setMsg] = useState(null);
|
||||||
|
|
||||||
|
const submit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setMsg(null);
|
||||||
|
try {
|
||||||
|
await api.post('/api/auth/change-password', form);
|
||||||
|
setMsg({ ok: true, text: '✓ Mot de passe modifié' });
|
||||||
|
setForm({ currentPassword: '', newPassword: '' });
|
||||||
|
} catch (err) {
|
||||||
|
setMsg({ ok: false, text: err.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ borderRadius: 8, padding: 30, marginTop: 24 }}>
|
||||||
|
<h2 className="serif" style={{ fontSize: 22, fontWeight: 600, marginBottom: 6 }}>Mot de passe</h2>
|
||||||
|
<p style={{ fontSize: 13.5, color: 'var(--gray-light)', marginBottom: 20, lineHeight: 1.55 }}>
|
||||||
|
10 caractères minimum, avec au moins une lettre et un chiffre.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={submit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
<label className="field">Mot de passe actuel
|
||||||
|
<input type="password" value={form.currentPassword} onChange={(e) => setForm({ ...form, currentPassword: e.target.value })} required autoComplete="current-password" />
|
||||||
|
</label>
|
||||||
|
<label className="field">Nouveau mot de passe
|
||||||
|
<input type="password" value={form.newPassword} onChange={(e) => setForm({ ...form, newPassword: e.target.value })} required minLength={10} autoComplete="new-password" />
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||||
|
<button type="submit" className="btn btn-red btn-sm" style={{ fontSize: 14.5, padding: '13px 26px' }}>Changer le mot de passe</button>
|
||||||
|
{msg && <span className={msg.ok ? 'success-text' : 'error-text'}>{msg.text}</span>}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Portal() {
|
||||||
|
const { logout } = useAuth();
|
||||||
|
const [member, setMember] = useState(null);
|
||||||
|
const [categories, setCategories] = useState([]);
|
||||||
|
const [form, setForm] = useState(null);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const season = seasonLabel();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([api.get('/api/member/profile'), api.get('/api/public/bootstrap')])
|
||||||
|
.then(([p, b]) => {
|
||||||
|
setMember(p.member);
|
||||||
|
setForm(p.member);
|
||||||
|
setCategories(b.categories);
|
||||||
|
})
|
||||||
|
.catch((e) => setError(e.message));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!form) {
|
||||||
|
return (
|
||||||
|
<section className="container" style={{ padding: '60px 32px' }}>
|
||||||
|
<p style={{ color: 'var(--gray-light)' }}>{error || 'Chargement…'}</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const onChange = (e) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setForm({ ...form, [name]: name === 'categorie_id' ? (value ? Number(value) : null) : value });
|
||||||
|
setSaved(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const d = await api.put('/api/member/profile', {
|
||||||
|
nom: form.nom, secteur: form.secteur, categorie_id: form.categorie_id,
|
||||||
|
dirigeant: form.dirigeant, email: form.email || '', tel: form.tel || '',
|
||||||
|
site: form.site || '', presentation: form.presentation || '',
|
||||||
|
});
|
||||||
|
setMember(d.member);
|
||||||
|
setForm(d.member);
|
||||||
|
setSaved(true);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const valide = member?.valide;
|
||||||
|
const catName = categories.find((c) => c.id === form.categorie_id)?.name || 'Non classée';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ maxWidth: 1100, margin: '0 auto', padding: '44px 32px 90px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 20, flexWrap: 'wrap', marginBottom: 26 }}>
|
||||||
|
<div>
|
||||||
|
<div className="kicker" style={{ letterSpacing: '.16em', marginBottom: 8 }}>Espace membre</div>
|
||||||
|
<h1 className="serif" style={{ fontWeight: 500, fontSize: 34, lineHeight: 1.1 }}>Bonjour {member.dirigeant}</h1>
|
||||||
|
<div style={{ fontSize: 14, color: 'var(--gray-light)', marginTop: 4 }}>{member.nom}</div>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-outline-soft btn-sm" onClick={logout}>Se déconnecter</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14, background: valide ? '#E8F3EC' : '#FBF0E6', border: `1px solid ${valide ? '#BFE0CB' : '#F0D6BC'}`, color: valide ? '#22623E' : '#8A5A22', borderRadius: 8, padding: '18px 22px' }}>
|
||||||
|
<span style={{ fontSize: 22 }}>{valide ? '✓' : '⏳'}</span>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 15 }}>
|
||||||
|
{valide ? `Adhésion validée pour la saison ${season}` : 'Adhésion à renouveler'}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, opacity: 0.85, marginTop: 2 }}>
|
||||||
|
{valide
|
||||||
|
? "Votre fiche est visible dans l'annuaire et vous pouvez vous inscrire aux rencontres."
|
||||||
|
: "Tant que votre adhésion n'est pas validée par le Club, votre fiche n'apparaît pas dans l'annuaire et vous ne pouvez pas vous inscrire aux rencontres."}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hero-grid" style={{ display: 'grid', gridTemplateColumns: '1.35fr .9fr', gap: 32, marginTop: 30, alignItems: 'start' }}>
|
||||||
|
<div>
|
||||||
|
<div className="card" style={{ borderRadius: 8, padding: 30 }}>
|
||||||
|
<h2 className="serif" style={{ fontSize: 22, fontWeight: 600, marginBottom: 6 }}>Mon profil</h2>
|
||||||
|
<p style={{ fontSize: 13.5, color: 'var(--gray-light)', marginBottom: 24, lineHeight: 1.55 }}>
|
||||||
|
Ces informations alimentent votre fiche dans l'annuaire des membres.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 22, marginBottom: 24 }}>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: '.08em', color: 'var(--gray-light)', fontWeight: 600, marginBottom: 8 }}>Logo</div>
|
||||||
|
<div style={{ width: 96, height: 96 }}>
|
||||||
|
<ImageSlot endpoint="/api/member/profile/logo" value={form.logo_path} placeholder="Déposez votre logo" onUploaded={(path) => { setForm((f) => ({ ...f, logo_path: path })); setMember((m) => ({ ...m, logo_path: path })); }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: '.08em', color: 'var(--gray-light)', fontWeight: 600, marginBottom: 8 }}>Photo dirigeant·e</div>
|
||||||
|
<div style={{ width: 96, height: 96 }}>
|
||||||
|
<ImageSlot circle endpoint="/api/member/profile/photo" value={form.photo_path} placeholder="Déposez votre photo" onUploaded={(path) => { setForm((f) => ({ ...f, photo_path: path })); setMember((m) => ({ ...m, photo_path: path })); }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={save}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
|
<label className="field">Entreprise
|
||||||
|
<input name="nom" value={form.nom} onChange={onChange} required maxLength={120} />
|
||||||
|
</label>
|
||||||
|
<label className="field">Catégorie
|
||||||
|
<select name="categorie_id" value={form.categorie_id || ''} onChange={onChange}>
|
||||||
|
<option value="">Non classée</option>
|
||||||
|
{categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="field">Secteur d'activité
|
||||||
|
<input name="secteur" value={form.secteur} onChange={onChange} required maxLength={120} />
|
||||||
|
</label>
|
||||||
|
<label className="field">Dirigeant·e
|
||||||
|
<input name="dirigeant" value={form.dirigeant} onChange={onChange} required maxLength={120} />
|
||||||
|
</label>
|
||||||
|
<label className="field">Présentation
|
||||||
|
<textarea name="presentation" value={form.presentation || ''} onChange={onChange} rows={3} maxLength={2000} />
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
|
<label className="field">Email
|
||||||
|
<input name="email" type="email" value={form.email || ''} onChange={onChange} maxLength={254} />
|
||||||
|
</label>
|
||||||
|
<label className="field">Téléphone
|
||||||
|
<input name="tel" value={form.tel || ''} onChange={onChange} maxLength={30} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="field">Site web
|
||||||
|
<input name="site" value={form.site || ''} onChange={onChange} maxLength={200} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{error && <p className="error-text" style={{ marginTop: 12 }}>{error}</p>}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginTop: 24 }}>
|
||||||
|
<button type="submit" className="btn btn-red btn-sm" style={{ fontSize: 14.5, padding: '13px 26px' }}>Enregistrer mon profil</button>
|
||||||
|
{saved && <span className="success-text">✓ Profil enregistré</span>}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<PasswordCard />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ position: 'sticky', top: 96 }}>
|
||||||
|
<div style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: '.1em', color: 'var(--gray-light)', fontWeight: 600, marginBottom: 12 }}>
|
||||||
|
Aperçu de votre fiche annuaire
|
||||||
|
</div>
|
||||||
|
<div className="card" style={{ padding: 26 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 16 }}>
|
||||||
|
<div className={form.logo_path ? '' : 'placeholder-pattern'} style={{ width: 64, height: 64, flexShrink: 0, borderRadius: 4, overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: 'monospace', fontSize: 9, color: '#a8a099', background: form.logo_path ? '#fff' : undefined }}>
|
||||||
|
{form.logo_path ? <img src={form.logo_path} alt="" style={{ width: '100%', height: '100%', objectFit: 'contain' }} /> : 'logo'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="serif" style={{ fontSize: 20, fontWeight: 600, lineHeight: 1.15 }}>{form.nom}</div>
|
||||||
|
<div style={{ fontSize: 11, letterSpacing: '.08em', textTransform: 'uppercase', color: 'var(--red)', fontWeight: 600, marginTop: 5 }}>{form.secteur}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<span className="badge-cat">{catName}</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 14, lineHeight: 1.6, color: 'var(--gray)', marginBottom: 20 }}>{form.presentation}</p>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: 18, borderTop: '1px solid var(--border-soft)' }}>
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--gray-light)' }}>{form.dirigeant}</span>
|
||||||
|
<span className="btn-link">Voir la fiche →</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 12.5, color: 'var(--gray-light)', lineHeight: 1.55, marginTop: 14 }}>
|
||||||
|
{valide
|
||||||
|
? "Votre fiche est publiée dans l'annuaire des membres."
|
||||||
|
: "Cette fiche ne sera publiée qu'une fois votre adhésion validée pour la saison en cours."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Espace() {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
|
||||||
|
if (loading) return <main style={{ minHeight: '60vh' }} />;
|
||||||
|
if (!user || user.role !== 'member') return <main><LoginSection /></main>;
|
||||||
|
return <main><Portal /></main>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { LoginSection };
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { usePublicData } from '../lib/usePublic.js';
|
||||||
|
import { api, dateParts } from '../lib/api.js';
|
||||||
|
import Modal from '../components/Modal.jsx';
|
||||||
|
|
||||||
|
function InscriptionModal({ rencontre, onClose, onDone }) {
|
||||||
|
const [form, setForm] = useState({ nom: '', entreprise: '', email: '', tel: '' });
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const { jour, mois } = dateParts(rencontre.date_renc);
|
||||||
|
|
||||||
|
const onChange = (e) => setForm({ ...form, [e.target.name]: e.target.value });
|
||||||
|
|
||||||
|
const submit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await api.post(`/api/public/rencontres/${rencontre.id}/inscriptions`, form);
|
||||||
|
setDone(true);
|
||||||
|
onDone?.();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
return (
|
||||||
|
<Modal onClose={onClose} maxWidth={460}>
|
||||||
|
<div style={{ textAlign: 'center', padding: '50px 36px' }}>
|
||||||
|
<div style={{ width: 60, height: 60, borderRadius: '50%', background: '#FBEDEC', color: 'var(--red)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 30, margin: '0 auto 22px' }}>✓</div>
|
||||||
|
<h3 className="serif" style={{ fontSize: 26, fontWeight: 600, marginBottom: 10 }}>Inscription enregistrée</h3>
|
||||||
|
<p style={{ fontSize: 15, color: 'var(--gray)', lineHeight: 1.6, marginBottom: 8 }}>
|
||||||
|
Votre demande pour <strong>{rencontre.titre}</strong> est bien enregistrée.
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: 13.5, color: 'var(--gray-light)', marginBottom: 26 }}>
|
||||||
|
Elle sera confirmée par l'équipe du Club.
|
||||||
|
</p>
|
||||||
|
<button className="btn btn-dark btn-sm" style={{ padding: '13px 28px', fontSize: 14 }} onClick={onClose}>Fermer</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
onClose={onClose}
|
||||||
|
maxWidth={460}
|
||||||
|
header={{ kicker: 'Inscription', title: rencontre.titre, meta: `${jour} ${mois} · ${rencontre.heure} · ${rencontre.lieu}` }}
|
||||||
|
>
|
||||||
|
<form onSubmit={submit} style={{ padding: '28px 30px' }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
<label className="field-plain">Nom & prénom
|
||||||
|
<input name="nom" value={form.nom} onChange={onChange} required maxLength={120} />
|
||||||
|
</label>
|
||||||
|
<label className="field-plain">Entreprise
|
||||||
|
<input name="entreprise" value={form.entreprise} onChange={onChange} required maxLength={120} />
|
||||||
|
</label>
|
||||||
|
<label className="field-plain">Email
|
||||||
|
<input name="email" type="email" value={form.email} onChange={onChange} required maxLength={254} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{error && <p className="error-text" style={{ marginTop: 12 }}>{error}</p>}
|
||||||
|
<button type="submit" className="btn btn-red" style={{ width: '100%', marginTop: 22, padding: 14 }}>
|
||||||
|
Confirmer mon inscription
|
||||||
|
</button>
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--gray-light)', textAlign: 'center', marginTop: 12, lineHeight: 1.5 }}>
|
||||||
|
Réservé aux membres à jour de leur adhésion pour la saison en cours.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const { data, reload } = usePublicData();
|
||||||
|
const [inscrRenc, setInscrRenc] = useState(null);
|
||||||
|
const [homeDone, setHomeDone] = useState(false);
|
||||||
|
const [homeError, setHomeError] = useState('');
|
||||||
|
const [form, setForm] = useState({ nom: '', fonction: '', entreprise: '', email: '' });
|
||||||
|
|
||||||
|
const onChange = (e) => setForm({ ...form, [e.target.name]: e.target.value });
|
||||||
|
|
||||||
|
const submitHome = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setHomeError('');
|
||||||
|
try {
|
||||||
|
await api.post('/api/public/demandes-adhesion', form);
|
||||||
|
setHomeDone(true);
|
||||||
|
} catch (err) {
|
||||||
|
setHomeError(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const members = data?.members ?? [];
|
||||||
|
const rencontres = data?.rencontres ?? [];
|
||||||
|
const passees = data?.rencontresPassees ?? [];
|
||||||
|
const content = data?.content ?? {};
|
||||||
|
const marquee = members.concat(members);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
{/* HERO */}
|
||||||
|
<section className="container hero-grid" style={{ padding: '64px 32px 40px', display: 'grid', gridTemplateColumns: '1.05fr .95fr', gap: 64, alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<div className="kicker" style={{ marginBottom: 26 }}>Réseau d'affaires · SLUC Nancy Basket</div>
|
||||||
|
<h1 className="serif" style={{ fontWeight: 500, fontSize: 'clamp(40px, 6vw, 66px)', lineHeight: 1.02, letterSpacing: '-.01em', marginBottom: 26 }}>
|
||||||
|
Le business se joue en <em style={{ fontStyle: 'italic', color: 'var(--red)' }}>équipe</em>.
|
||||||
|
</h1>
|
||||||
|
<p style={{ fontSize: 18, lineHeight: 1.65, color: 'var(--gray)', maxWidth: 480, marginBottom: 38 }}>
|
||||||
|
Depuis 30 ans, le Business Club rassemble les dirigeants du Grand Est autour des valeurs du sport
|
||||||
|
de haut niveau : performance, engagement et esprit collectif.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: 14, flexWrap: 'wrap' }}>
|
||||||
|
<a href="#adhesion" style={{ textDecoration: 'none' }}>
|
||||||
|
<button className="btn btn-red">Rejoindre le club</button>
|
||||||
|
</a>
|
||||||
|
<Link to="/association" style={{ textDecoration: 'none' }}>
|
||||||
|
<button className="btn btn-outline">Découvrir l'association</button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 42, marginTop: 54, paddingTop: 34, borderTop: '1px solid var(--border)' }}>
|
||||||
|
{[['85', 'entreprises membres'], ['12', 'rencontres / an'], ['30 ans', 'de partenariat']].map(([n, l]) => (
|
||||||
|
<div key={l}>
|
||||||
|
<div className="serif" style={{ fontSize: 34, fontWeight: 600 }}>{n}</div>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--gray-light)', marginTop: 2 }}>{l}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<div className={content.hero_photo ? '' : 'placeholder-pattern'} style={{ aspectRatio: '4/5', borderRadius: 4, overflow: 'hidden', boxShadow: '0 30px 60px -30px rgba(27,27,27,.4)', display: 'flex', alignItems: 'flex-end', padding: content.hero_photo ? 0 : 22 }}>
|
||||||
|
{content.hero_photo ? (
|
||||||
|
<img src={content.hero_photo} alt="L'esprit du Club" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
|
) : (
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: 11, color: '#a8a099', background: 'var(--bg)', padding: '5px 9px', borderRadius: 2 }}>
|
||||||
|
photo — networking en tribune VIP
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ position: 'absolute', left: -30, bottom: 40, background: 'var(--ink)', color: '#fff', padding: '22px 26px', borderRadius: 4, maxWidth: 230, boxShadow: '0 20px 40px -20px rgba(0,0,0,.5)' }}>
|
||||||
|
<div className="serif" style={{ fontSize: 19, lineHeight: 1.3, marginBottom: 6 }}>
|
||||||
|
« {content.hero_quote_text || 'On ne réussit jamais seul.'} »
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: '#B7AFA6' }}>{content.hero_quote_author || "L'esprit du Club"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* SLUC LINK */}
|
||||||
|
<section className="container" style={{ paddingBottom: 56 }}>
|
||||||
|
<a
|
||||||
|
href="https://www.sluc-nancy-basket.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ display: 'grid', gridTemplateColumns: '110px 1fr auto', alignItems: 'center', gap: 28, background: 'var(--dark)', borderRadius: 6, padding: '28px 32px', textDecoration: 'none', color: 'inherit' }}
|
||||||
|
>
|
||||||
|
<div style={{ aspectRatio: '1/1', width: 110, borderRadius: 8, overflow: 'hidden', background: '#fff', padding: 14 }}>
|
||||||
|
<img src="/assets/logo.jpg" alt="Logo SLUC Nancy Basket" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 11.5, letterSpacing: '.18em', textTransform: 'uppercase', color: 'var(--red-soft)', fontWeight: 600, marginBottom: 8 }}>
|
||||||
|
Partenaire officiel
|
||||||
|
</div>
|
||||||
|
<div className="serif" style={{ fontSize: 22, fontWeight: 600, color: '#fff', marginBottom: 6 }}>SLUC Nancy Basket</div>
|
||||||
|
<div style={{ fontSize: 14, lineHeight: 1.5, color: '#B7AFA6' }}>
|
||||||
|
Retrouvez l'actualité, le calendrier et la billetterie du club sur son site officiel.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: '#fff', whiteSpace: 'nowrap', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
Voir le site <span style={{ fontSize: 16 }}>↗</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* MEMBERS MARQUEE */}
|
||||||
|
<section style={{ padding: '72px 0', background: '#fff', borderTop: '1px solid var(--border)', borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<div className="container" style={{ paddingBottom: 34, display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 24 }}>
|
||||||
|
<div>
|
||||||
|
<div className="kicker" style={{ marginBottom: 12 }}>Ils nous font confiance</div>
|
||||||
|
<h2 className="serif" style={{ fontWeight: 500, fontSize: 36, lineHeight: 1.1 }}>Nos entreprises membres</h2>
|
||||||
|
</div>
|
||||||
|
<Link to="/annuaire" style={{ textDecoration: 'none' }}>
|
||||||
|
<span className="btn-link" style={{ fontSize: 14, whiteSpace: 'nowrap' }}>Voir l'annuaire complet →</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="marquee-mask">
|
||||||
|
<div className="marquee-track">
|
||||||
|
{marquee.map((m, i) => (
|
||||||
|
<div key={`${m.id}-${i}`} style={{ width: 210, flexShrink: 0, background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '22px 20px', display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
<div className={m.logo_path ? '' : 'placeholder-pattern'} style={{ height: 56, borderRadius: 3, display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: 'monospace', fontSize: 10, color: '#a8a099', overflow: 'hidden', background: m.logo_path ? '#fff' : undefined }}>
|
||||||
|
{m.logo_path ? <img src={m.logo_path} alt="" style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }} /> : 'logo'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="serif" style={{ fontSize: 17, fontWeight: 600, lineHeight: 1.2 }}>{m.nom}</div>
|
||||||
|
<div style={{ fontSize: 11, letterSpacing: '.08em', textTransform: 'uppercase', color: 'var(--red)', fontWeight: 600, marginTop: 6 }}>{m.secteur}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* UPCOMING */}
|
||||||
|
<section className="container" style={{ padding: '96px 32px 40px' }}>
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: 52 }}>
|
||||||
|
<div className="kicker" style={{ marginBottom: 14 }}>Agenda</div>
|
||||||
|
<h2 className="serif" style={{ fontWeight: 500, fontSize: 44, lineHeight: 1.08 }}>Prochaines rencontres</h2>
|
||||||
|
</div>
|
||||||
|
<div className="grid-3">
|
||||||
|
{rencontres.map((e) => {
|
||||||
|
const { jour, mois } = dateParts(e.date_renc);
|
||||||
|
const restantes = Math.max(0, e.places - e.inscrits);
|
||||||
|
return (
|
||||||
|
<div key={e.id} className="card" style={{ overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<div className="placeholder-pattern" style={{ height: 170, position: 'relative' }}>
|
||||||
|
<div style={{ position: 'absolute', top: 16, left: 16, background: 'var(--dark)', color: '#fff', borderRadius: 3, padding: '8px 12px', textAlign: 'center', lineHeight: 1.05 }}>
|
||||||
|
<div className="serif" style={{ fontSize: 24, fontWeight: 600 }}>{jour}</div>
|
||||||
|
<div style={{ fontSize: 10, letterSpacing: '.12em', textTransform: 'uppercase', color: '#B7AFA6' }}>{mois}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', flex: 1 }}>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--gray-light)', marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span>{e.heure}</span><span>·</span><span>{e.lieu}</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="serif" style={{ fontSize: 22, fontWeight: 600, lineHeight: 1.2, marginBottom: 10 }}>{e.titre}</h3>
|
||||||
|
<p style={{ fontSize: 14.5, lineHeight: 1.6, color: 'var(--gray)', flex: 1 }}>{e.description}</p>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 22, paddingTop: 18, borderTop: '1px solid var(--border-soft)' }}>
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--red)', fontWeight: 600 }}>
|
||||||
|
{restantes > 0 ? `${restantes} places restantes` : 'Complet'}
|
||||||
|
</span>
|
||||||
|
<button className="btn btn-dark btn-sm" style={{ fontSize: 13.5 }} disabled={restantes === 0} onClick={() => setInscrRenc(e)}>
|
||||||
|
S'inscrire
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* PAST */}
|
||||||
|
<section style={{ background: 'var(--beige)', marginTop: 56, padding: '96px 0' }}>
|
||||||
|
<div className="container">
|
||||||
|
<div style={{ marginBottom: 52 }}>
|
||||||
|
<div className="kicker" style={{ marginBottom: 14 }}>Ils y étaient</div>
|
||||||
|
<h2 className="serif" style={{ fontWeight: 500, fontSize: 44, lineHeight: 1.08 }}>Rencontres passées</h2>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 34 }}>
|
||||||
|
{passees.map((p) => (
|
||||||
|
<article key={p.id} className="card hero-grid" style={{ display: 'grid', gridTemplateColumns: '1.15fr .85fr', gap: 40, alignItems: 'center', padding: 36 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 12, letterSpacing: '.14em', textTransform: 'uppercase', color: 'var(--gray-light)', fontWeight: 600, marginBottom: 12 }}>
|
||||||
|
{p.date_label} · {p.lieu}
|
||||||
|
</div>
|
||||||
|
<h3 className="serif" style={{ fontSize: 30, fontWeight: 600, lineHeight: 1.12, marginBottom: 16 }}>{p.titre}</h3>
|
||||||
|
<p style={{ fontSize: 15.5, lineHeight: 1.7, color: 'var(--gray)', marginBottom: 20 }}>{p.texte}</p>
|
||||||
|
<div style={{ display: 'flex', gap: 20, fontSize: 13, color: 'var(--gray-light)' }}>
|
||||||
|
<span>👥 {p.participants} participants</span>
|
||||||
|
<span>📷 {p.nb_photos} photos</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gridTemplateRows: '1fr 1fr', gap: 10, height: 240 }}>
|
||||||
|
<div className="placeholder-pattern" style={{ gridRow: 'span 2', borderRadius: 3 }} />
|
||||||
|
<div className="placeholder-pattern" style={{ borderRadius: 3 }} />
|
||||||
|
<div className="placeholder-pattern" style={{ borderRadius: 3 }} />
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ADHESION */}
|
||||||
|
<section id="adhesion" style={{ background: 'var(--dark)', color: '#fff', padding: '100px 0' }}>
|
||||||
|
<div className="hero-grid" style={{ maxWidth: 1080, margin: '0 auto', padding: '0 32px', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 70, alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<div className="kicker on-dark" style={{ marginBottom: 20 }}>Nous rejoindre</div>
|
||||||
|
<h2 className="serif" style={{ fontWeight: 500, fontSize: 46, lineHeight: 1.08, marginBottom: 22 }}>
|
||||||
|
Faites entrer votre entreprise dans le Club.
|
||||||
|
</h2>
|
||||||
|
<p style={{ fontSize: 16.5, lineHeight: 1.65, color: '#B7AFA6', marginBottom: 32 }}>
|
||||||
|
Rejoignez un réseau de 85 dirigeants, accédez à toutes nos rencontres et bénéficiez d'une visibilité au cœur du SLUC Nancy.
|
||||||
|
</p>
|
||||||
|
<ul style={{ listStyle: 'none', display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
{['Accès à toutes les rencontres business', 'Places privilégiées aux matchs', "Présence dans l'annuaire des membres"].map((t) => (
|
||||||
|
<li key={t} style={{ display: 'flex', gap: 12, alignItems: 'flex-start', fontSize: 15, color: '#E7E1D9' }}>
|
||||||
|
<span style={{ color: 'var(--red-soft)', fontWeight: 700 }}>—</span>{t}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: '#fff', color: 'var(--ink)', borderRadius: 6, padding: 38 }}>
|
||||||
|
{homeDone ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '30px 10px' }}>
|
||||||
|
<div style={{ width: 58, height: 58, borderRadius: '50%', background: '#FBEDEC', color: 'var(--red)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 28, margin: '0 auto 20px' }}>✓</div>
|
||||||
|
<h3 className="serif" style={{ fontSize: 26, fontWeight: 600, marginBottom: 10 }}>Demande envoyée !</h3>
|
||||||
|
<p style={{ fontSize: 15, color: 'var(--gray)', lineHeight: 1.6, marginBottom: 24 }}>
|
||||||
|
Merci pour votre intérêt. Notre équipe vous recontacte sous 48h.
|
||||||
|
</p>
|
||||||
|
<button className="btn btn-outline btn-sm" style={{ fontSize: 14, padding: '12px 24px' }} onClick={() => { setHomeDone(false); setForm({ nom: '', fonction: '', entreprise: '', email: '' }); }}>
|
||||||
|
Nouvelle demande
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={submitHome}>
|
||||||
|
<h3 className="serif" style={{ fontSize: 24, fontWeight: 600, marginBottom: 22 }}>Demande d'adhésion</h3>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
|
<label className="field-plain">Nom & prénom
|
||||||
|
<input name="nom" value={form.nom} onChange={onChange} required maxLength={120} />
|
||||||
|
</label>
|
||||||
|
<label className="field-plain">Fonction
|
||||||
|
<input name="fonction" value={form.fonction} onChange={onChange} maxLength={120} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="field-plain">Entreprise
|
||||||
|
<input name="entreprise" value={form.entreprise} onChange={onChange} required maxLength={120} />
|
||||||
|
</label>
|
||||||
|
<label className="field-plain">Email professionnel
|
||||||
|
<input name="email" type="email" value={form.email} onChange={onChange} required maxLength={254} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{homeError && <p className="error-text" style={{ marginTop: 12 }}>{homeError}</p>}
|
||||||
|
<button type="submit" className="btn btn-red" style={{ width: '100%', marginTop: 24, padding: 15 }}>
|
||||||
|
Envoyer ma demande
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{inscrRenc && (
|
||||||
|
<InscriptionModal rencontre={inscrRenc} onClose={() => setInscrRenc(null)} onDone={reload} />
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
/* SLUC Business Club — design system from the Claude Design mock */
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #FAF8F5;
|
||||||
|
--ink: #1B1B1B;
|
||||||
|
--red: #C1272D;
|
||||||
|
--red-dark: #8E1B1F;
|
||||||
|
--red-soft: #E0777A;
|
||||||
|
--gray: #5A544E;
|
||||||
|
--gray-mid: #4A443E;
|
||||||
|
--gray-light: #9A938B;
|
||||||
|
--border: #E7E2DB;
|
||||||
|
--border-soft: #F0EBE4;
|
||||||
|
--input-border: #DDD6CD;
|
||||||
|
--dark: #161514;
|
||||||
|
--footer: #0F0E0D;
|
||||||
|
--beige: #F2EEE8;
|
||||||
|
--admin-bg: #F4F1EC;
|
||||||
|
--green: #2C7A4B;
|
||||||
|
--serif: 'Newsreader', serif;
|
||||||
|
--sans: 'Archivo', system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body { background: var(--bg); font-family: var(--sans); color: var(--ink); overflow-x: hidden; }
|
||||||
|
::selection { background: var(--red); color: #fff; }
|
||||||
|
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||||
|
::-webkit-scrollbar-thumb { background: #d8d2c9; border-radius: 8px; }
|
||||||
|
button { font-family: var(--sans); cursor: pointer; }
|
||||||
|
input, textarea, select { font-family: var(--sans); font-size: 15px; }
|
||||||
|
input:focus, textarea:focus, select:focus { outline: none; border-color: var(--red) !important; }
|
||||||
|
|
||||||
|
@keyframes marquee { from { transform: translateX(0); } to { transform: translateX(-50%); } }
|
||||||
|
.marquee-track { display: flex; gap: 20px; width: max-content; animation: marquee 46s linear infinite; padding: 0 10px; }
|
||||||
|
.marquee-track:hover { animation-play-state: paused; }
|
||||||
|
.marquee-mask {
|
||||||
|
position: relative;
|
||||||
|
-webkit-mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
|
||||||
|
mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container { max-width: 1180px; margin: 0 auto; padding-left: 32px; padding-right: 32px; }
|
||||||
|
.serif { font-family: var(--serif); }
|
||||||
|
|
||||||
|
.kicker {
|
||||||
|
font-size: 12px; letter-spacing: .2em; text-transform: uppercase;
|
||||||
|
color: var(--red); font-weight: 600;
|
||||||
|
}
|
||||||
|
.kicker.on-dark { color: var(--red-soft); }
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn { border: none; border-radius: 2px; font-weight: 600; font-size: 15px; padding: 16px 30px; transition: background .15s, color .15s, border-color .15s; }
|
||||||
|
.btn-red { background: var(--red); color: #fff; }
|
||||||
|
.btn-red:hover { background: var(--red-dark); }
|
||||||
|
.btn-dark { background: var(--dark); color: #fff; }
|
||||||
|
.btn-dark:hover { background: var(--red); }
|
||||||
|
.btn-outline { background: none; color: var(--ink); border: 1px solid var(--ink); }
|
||||||
|
.btn-outline:hover { background: var(--ink); color: #fff; }
|
||||||
|
.btn-outline-soft { background: none; color: var(--gray); border: 1px solid var(--input-border); }
|
||||||
|
.btn-outline-soft:hover { border-color: var(--dark); }
|
||||||
|
.btn-sm { padding: 11px 20px; font-size: 13.5px; border-radius: 3px; }
|
||||||
|
.btn-link { background: none; border: none; color: var(--red); font-weight: 600; font-size: 13.5px; padding: 0; }
|
||||||
|
.btn-link:hover { color: var(--red-dark); }
|
||||||
|
.btn-link-gray { background: none; border: none; color: var(--gray-light); font-weight: 600; font-size: 13px; padding: 0; }
|
||||||
|
.btn-link-gray:hover { color: var(--red); }
|
||||||
|
|
||||||
|
/* Forms */
|
||||||
|
.field { display: flex; flex-direction: column; gap: 6px; font-size: 12px; font-weight: 600; color: var(--gray-mid); text-transform: uppercase; letter-spacing: .06em; }
|
||||||
|
.field input, .field textarea, .field select {
|
||||||
|
padding: 11px 13px; border: 1px solid var(--input-border); border-radius: 3px;
|
||||||
|
background: var(--bg); text-transform: none; letter-spacing: normal; resize: vertical;
|
||||||
|
}
|
||||||
|
.field-plain { display: flex; flex-direction: column; gap: 6px; font-size: 12.5px; font-weight: 600; color: var(--gray-mid); }
|
||||||
|
.field-plain input {
|
||||||
|
padding: 12px 14px; border: 1px solid var(--input-border); border-radius: 2px; background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards & badges */
|
||||||
|
.card { background: #fff; border: 1px solid var(--border); border-radius: 5px; }
|
||||||
|
.badge { display: inline-block; font-size: 11.5px; font-weight: 600; padding: 4px 10px; border-radius: 20px; white-space: nowrap; }
|
||||||
|
.badge-green { background: #E8F3EC; color: var(--green); }
|
||||||
|
.badge-amber { background: #FBEFD9; color: #B07D18; }
|
||||||
|
.badge-red { background: #F6E4E4; color: #B0322E; }
|
||||||
|
.badge-cat { font-size: 11.5px; font-weight: 600; color: var(--gray); background: var(--beige); border-radius: 20px; padding: 5px 12px; display: inline-block; }
|
||||||
|
|
||||||
|
.chip { border-radius: 20px; font-size: 13.5px; padding: 9px 18px; border: 1px solid var(--input-border); background: #fff; color: var(--gray); font-weight: 500; }
|
||||||
|
.chip.active { background: var(--red); color: #fff; border-color: var(--red); font-weight: 600; }
|
||||||
|
|
||||||
|
.placeholder-pattern { background: repeating-linear-gradient(135deg, #e9e5df 0 12px, #f2efe9 12px 24px); }
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
.table { width: 100%; border-collapse: collapse; }
|
||||||
|
.table th {
|
||||||
|
text-align: left; font-size: 11px; letter-spacing: .08em; text-transform: uppercase;
|
||||||
|
color: var(--gray-light); font-weight: 600; padding: 14px 24px; background: var(--bg);
|
||||||
|
}
|
||||||
|
.table td { padding: 14px 24px; font-size: 13.5px; color: var(--gray); border-top: 1px solid var(--border-soft); }
|
||||||
|
.table td:first-child { font-size: 14px; font-weight: 600; color: var(--ink); }
|
||||||
|
|
||||||
|
/* Modals */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed; inset: 0; z-index: 100; background: rgba(15, 14, 13, .55);
|
||||||
|
backdrop-filter: blur(3px); display: flex; align-items: center; justify-content: center; padding: 24px;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
background: #fff; border-radius: 8px; width: 100%; max-height: 90vh; overflow: auto;
|
||||||
|
box-shadow: 0 40px 80px -30px rgba(0, 0, 0, .6);
|
||||||
|
}
|
||||||
|
.modal-header { position: relative; background: var(--dark); color: #fff; padding: 24px 30px; }
|
||||||
|
.modal-close {
|
||||||
|
position: absolute; top: 16px; right: 16px; background: rgba(255, 255, 255, .14);
|
||||||
|
border: none; width: 30px; height: 30px; border-radius: 50%; font-size: 16px; color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Admin sidebar */
|
||||||
|
.admin-nav-btn {
|
||||||
|
display: block; width: 100%; text-align: left; background: none; color: #B7AFA6;
|
||||||
|
border: none; border-left: 3px solid transparent; font-size: 14px; font-weight: 500;
|
||||||
|
padding: 12px 14px; border-radius: 3px;
|
||||||
|
}
|
||||||
|
.admin-nav-btn.active { background: rgba(193, 39, 45, .18); color: #fff; border-left-color: var(--red); font-weight: 600; }
|
||||||
|
|
||||||
|
/* Header nav */
|
||||||
|
.nav-btn { background: none; border: none; font-size: 14.5px; font-weight: 500; color: var(--gray-mid); padding: 10px 14px; border-radius: 2px; }
|
||||||
|
.nav-btn:hover, .nav-btn.active { color: var(--red); }
|
||||||
|
|
||||||
|
/* Image slot (upload) */
|
||||||
|
.image-slot {
|
||||||
|
width: 100%; height: 100%; border: 1.5px dashed var(--input-border); border-radius: 8px;
|
||||||
|
display: flex; align-items: center; justify-content: center; text-align: center;
|
||||||
|
font-size: 11px; color: var(--gray-light); background: var(--bg); overflow: hidden;
|
||||||
|
padding: 6px; position: relative;
|
||||||
|
}
|
||||||
|
.image-slot:hover { border-color: var(--red); }
|
||||||
|
.image-slot img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.image-slot.circle, .image-slot.circle img { border-radius: 50%; }
|
||||||
|
|
||||||
|
.error-text { color: #B0322E; font-size: 13.5px; font-weight: 600; }
|
||||||
|
.success-text { color: var(--green); font-size: 13.5px; font-weight: 600; }
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.grid-2, .grid-3, .grid-4 { grid-template-columns: 1fr !important; }
|
||||||
|
.hero-grid { grid-template-columns: 1fr !important; }
|
||||||
|
}
|
||||||
|
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
|
||||||
|
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 24px; }
|
||||||
|
.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; }
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:3000',
|
||||||
|
'/uploads': 'http://localhost:3000',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
sourcemap: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in new issue
Block a user