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:
Claude committed 2026-07-10 19:43:07 +00:00
commit 96dca2a802
50 files changed
+6887

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
node_modules
dist
*.log
+13
View File
@@ -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
+19
View File
@@ -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>
+55
View File
@@ -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;
}
}
+1722
View File
File diff suppressed because it is too large. Load diff
+20
View File
@@ -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

+35
View File
@@ -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>
);
}
+68
View File
@@ -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>
);
}
+47
View File
@@ -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>
);
}
+49
View File
@@ -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>
);
}
+69
View File
@@ -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>
);
}
+23
View File
@@ -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>
);
}
+199
View File
@@ -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>
);
}
+608
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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>
);
}
+39
View File
@@ -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);
+52
View File
@@ -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');
+19
View File
@@ -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 };
}
+16
View File
@@ -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>
);
+152
View File
@@ -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>
);
}
+101
View File
@@ -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>
);
}
+125
View File
@@ -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>
);
}
+277
View File
@@ -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 };
+340
View File
@@ -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>
);
}
+147
View File
@@ -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; }
+16
View File
@@ -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,
},
});