Application complète SLUC Business Club (React + Express + PostgreSQL, Docker)
Implémente l'intégralité de la maquette « SLUC Business Club.dc.html » : - Site public : accueil (héro administrable, carrousel membres, agenda avec inscription en ligne, rencontres passées, demande d'adhésion), annuaire avec recherche/filtres/fiche détaillée, page association - Espace membre : connexion, édition de fiche avec aperçu direct, upload logo/photo, statut d'adhésion par saison, changement de mot de passe - Espace admin : tableau de bord, membres (validation par saison), rencontres (CRUD + inscrits + impression + export Excel), inscriptions, catégories, contenu du site Architecture : 3 conteneurs Docker Compose — PostgreSQL 16 (réseau interne, rôle applicatif restreint), API Express (non-root, read-only fs), nginx non privilégié (frontend React + reverse-proxy + CSP stricte). Sécurité : requêtes 100 % paramétrées, bcrypt + JWT httpOnly SameSite=Strict, vérification d'Origin (CSRF), validation zod, rate limiting, uploads vérifiés par octets magiques avec noms aléatoires, aucun secret committé. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fkg15RCxNgUys4ru73He2o
This commit is contained in:
50 files changed
+6887
No files matched your search
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
*.log
|
||||
@@ -0,0 +1,13 @@
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci 2>/dev/null || npm install
|
||||
COPY index.html vite.config.js ./
|
||||
COPY public ./public
|
||||
COPY src ./src
|
||||
RUN npm run build
|
||||
|
||||
FROM nginxinc/nginx-unprivileged:1.27-alpine
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
EXPOSE 8080
|
||||
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" type="image/jpeg" href="/assets/logo.jpg" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;1,6..72,400;1,6..72,500&family=Archivo:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>Business Club SLUC Nancy</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,55 @@
|
||||
server {
|
||||
listen 8080;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
server_tokens off;
|
||||
client_max_body_size 3m;
|
||||
|
||||
# Security headers
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
|
||||
|
||||
gzip on;
|
||||
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||
|
||||
# API proxied to the backend container
|
||||
location /api/ {
|
||||
proxy_pass http://api:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Uploaded images: served directly from the shared volume, never executed
|
||||
location /uploads/ {
|
||||
alias /var/www/uploads/;
|
||||
types { image/jpeg jpg; image/png png; image/webp webp; }
|
||||
default_type application/octet-stream;
|
||||
# user-uploaded content: never allow it to script or be framed
|
||||
add_header Content-Security-Policy "default-src 'none'; frame-ancestors 'none'" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Content-Disposition "inline" always;
|
||||
add_header Cache-Control "public, max-age=86400" always;
|
||||
}
|
||||
|
||||
# Static assets (immutable hashed filenames from Vite)
|
||||
location /assets/ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
}
|
||||
Generated
+1722
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "sbc-web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
@@ -0,0 +1,35 @@
|
||||
import { Routes, Route, useLocation } from 'react-router-dom';
|
||||
import { useEffect } from 'react';
|
||||
import Header from './components/Header.jsx';
|
||||
import Footer from './components/Footer.jsx';
|
||||
import Home from './pages/Home.jsx';
|
||||
import Annuaire from './pages/Annuaire.jsx';
|
||||
import Association from './pages/Association.jsx';
|
||||
import Espace from './pages/Espace.jsx';
|
||||
import Admin from './pages/Admin.jsx';
|
||||
|
||||
export default function App() {
|
||||
const location = useLocation();
|
||||
const isAdmin = location.pathname.startsWith('/admin');
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
}, [location.pathname]);
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||
{!isAdmin && <Header />}
|
||||
<div style={{ flex: 1 }}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/annuaire" element={<Annuaire />} />
|
||||
<Route path="/association" element={<Association />} />
|
||||
<Route path="/espace-membre" element={<Espace />} />
|
||||
<Route path="/admin/*" element={<Admin />} />
|
||||
<Route path="*" element={<Home />} />
|
||||
</Routes>
|
||||
</div>
|
||||
{!isAdmin && <Footer />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const linkStyle = { color: '#B7AFA6', fontSize: 14, textDecoration: 'none' };
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer style={{ background: 'var(--footer)', color: '#B7AFA6', padding: '64px 0 34px' }}>
|
||||
<div className="container grid-4" style={{ gridTemplateColumns: '1.6fr 1fr 1fr 1fr', gap: 40 }}>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 18 }}>
|
||||
<img
|
||||
src="/assets/logo.jpg"
|
||||
alt="SLUC Business Club"
|
||||
style={{ height: 48, width: 48, objectFit: 'cover', borderRadius: 4, background: '#fff' }}
|
||||
/>
|
||||
<span className="serif" style={{ fontSize: 18, fontWeight: 600, color: '#fff' }}>
|
||||
Business Club SLUC Nancy
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ fontSize: 14, lineHeight: 1.65, maxWidth: 320 }}>
|
||||
Le réseau d'affaires des partenaires du SLUC Nancy Basket. Le business se joue en équipe.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, letterSpacing: '.14em', textTransform: 'uppercase', color: '#6E675F', fontWeight: 600, marginBottom: 16 }}>
|
||||
Navigation
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, alignItems: 'flex-start' }}>
|
||||
<Link to="/" style={linkStyle}>Accueil</Link>
|
||||
<Link to="/annuaire" style={linkStyle}>Annuaire</Link>
|
||||
<Link to="/association" style={linkStyle}>L'association</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, letterSpacing: '.14em', textTransform: 'uppercase', color: '#6E675F', fontWeight: 600, marginBottom: 16 }}>
|
||||
Contact
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, fontSize: 14, lineHeight: 1.5 }}>
|
||||
<span>Palais des Sports J. Weille<br />Nancy (54)</span>
|
||||
<span>contact@sluc-businessclub.fr</span>
|
||||
<span>+33 3 83 00 00 00</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, letterSpacing: '.14em', textTransform: 'uppercase', color: '#6E675F', fontWeight: 600, marginBottom: 16 }}>
|
||||
Suivez-nous
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, fontSize: 14 }}>
|
||||
<span>LinkedIn</span>
|
||||
<span>Instagram</span>
|
||||
<span>Facebook</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="container"
|
||||
style={{
|
||||
marginTop: 40, paddingTop: 24, borderTop: '1px solid rgba(255,255,255,.08)',
|
||||
display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12,
|
||||
fontSize: 12.5, color: '#6E675F',
|
||||
}}
|
||||
>
|
||||
<span>© {new Date().getFullYear()} Business Club SLUC Nancy</span>
|
||||
<span>Mentions légales · Confidentialité</span>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Link, NavLink } from 'react-router-dom';
|
||||
|
||||
const navLinkClass = ({ isActive }) => `nav-btn${isActive ? ' active' : ''}`;
|
||||
|
||||
export default function Header() {
|
||||
return (
|
||||
<header
|
||||
style={{
|
||||
position: 'sticky', top: 0, zIndex: 50,
|
||||
background: 'rgba(250,248,245,.86)', backdropFilter: 'blur(12px)',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="container"
|
||||
style={{ height: 76, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 24 }}
|
||||
>
|
||||
<Link to="/" style={{ display: 'flex', alignItems: 'center', gap: 14, textDecoration: 'none' }}>
|
||||
<img src="/assets/logo.jpg" alt="SLUC Business Club Nancy" style={{ height: 50, width: 'auto', display: 'block' }} />
|
||||
<span style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', lineHeight: 1.05 }}>
|
||||
<span className="serif" style={{ fontSize: 19, fontWeight: 600, color: 'var(--ink)' }}>Business Club</span>
|
||||
<span style={{ fontSize: 10, letterSpacing: '.22em', textTransform: 'uppercase', color: 'var(--red-dark)', fontWeight: 600 }}>
|
||||
SLUC Nancy
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
<nav style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||
<NavLink to="/" end className={navLinkClass}>Accueil</NavLink>
|
||||
<NavLink to="/annuaire" className={navLinkClass}>Annuaire</NavLink>
|
||||
<NavLink to="/association" className={navLinkClass}>L'association</NavLink>
|
||||
<NavLink to="/espace-membre" className={navLinkClass}>Espace membre</NavLink>
|
||||
<NavLink
|
||||
to="/admin"
|
||||
style={{ fontSize: 13, color: 'var(--gray-light)', display: 'flex', alignItems: 'center', gap: 6 }}
|
||||
className="nav-btn"
|
||||
>
|
||||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: 'var(--red)', display: 'inline-block' }} />
|
||||
Espace admin
|
||||
</NavLink>
|
||||
<Link to="/" style={{ marginLeft: 10, textDecoration: 'none' }}>
|
||||
<button className="btn btn-red" style={{ padding: '12px 22px', fontSize: 14 }}>Devenir membre</button>
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { api } from '../lib/api.js';
|
||||
|
||||
// Real replacement for the mock's <image-slot>: click or drop an image,
|
||||
// it is uploaded to `endpoint` and the stored path is displayed.
|
||||
export default function ImageSlot({ endpoint, value, onUploaded, placeholder, circle }) {
|
||||
const inputRef = useRef(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const send = async (file) => {
|
||||
if (!file) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const d = await api.upload(endpoint, file);
|
||||
onUploaded?.(d.path);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`image-slot${circle ? ' circle' : ''}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
send(e.dataTransfer.files?.[0]);
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{value && <img src={value} alt="" />}
|
||||
{!value && <span>{busy ? 'Envoi…' : error || placeholder}</span>}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => send(e.target.files?.[0])}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import Modal from './Modal.jsx';
|
||||
|
||||
export default function MemberModal({ member, onClose }) {
|
||||
if (!member) return null;
|
||||
return (
|
||||
<Modal onClose={onClose} maxWidth={760}>
|
||||
<div style={{ position: 'relative', background: 'var(--dark)', color: '#fff', padding: '32px 36px', display: 'flex', alignItems: 'center', gap: 22 }}>
|
||||
<button className="modal-close" onClick={onClose} style={{ top: 18, right: 18, width: 32, height: 32 }} aria-label="Fermer">×</button>
|
||||
<div style={{ width: 76, height: 76, flexShrink: 0, borderRadius: 8, background: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden', fontFamily: 'monospace', fontSize: 10, color: '#a8a099' }}>
|
||||
{member.logo_path ? (
|
||||
<img src={member.logo_path} alt="" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
|
||||
) : ('logo')}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, letterSpacing: '.12em', textTransform: 'uppercase', color: 'var(--red-soft)', fontWeight: 600, marginBottom: 6 }}>
|
||||
{member.secteur}
|
||||
</div>
|
||||
<h2 className="serif" style={{ fontSize: 30, fontWeight: 600, lineHeight: 1.05 }}>{member.nom}</h2>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<span style={{ display: 'inline-block', fontSize: 11.5, fontWeight: 600, color: '#E7E1D9', background: 'rgba(255,255,255,.12)', borderRadius: 20, padding: '5px 12px' }}>
|
||||
{member.categorie || 'Non classée'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '32px 36px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 20, background: 'var(--beige)', borderRadius: 8, padding: '20px 22px', marginBottom: 26 }}>
|
||||
<div className={member.photo_path ? '' : 'placeholder-pattern'} style={{ width: 88, height: 88, flexShrink: 0, borderRadius: '50%', overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: 'monospace', fontSize: 9, color: '#a8a099' }}>
|
||||
{member.photo_path ? (
|
||||
<img src={member.photo_path} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : ('photo')}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, letterSpacing: '.1em', textTransform: 'uppercase', color: 'var(--gray-light)', fontWeight: 600, marginBottom: 5 }}>
|
||||
Dirigeant·e
|
||||
</div>
|
||||
<div className="serif" style={{ fontSize: 22, fontWeight: 600, lineHeight: 1.1 }}>{member.dirigeant}</div>
|
||||
{member.adhesion && (
|
||||
<div style={{ fontSize: 13, color: 'var(--gray)', marginTop: 4 }}>Membre depuis {member.adhesion}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: 15, lineHeight: 1.7, color: 'var(--gray)', marginBottom: 26 }}>{member.presentation}</p>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, borderTop: '1px solid var(--border-soft)', paddingTop: 22 }}>
|
||||
{[['Email', member.email], ['Tél', member.tel], ['Site', member.site]].map(([label, value]) =>
|
||||
value ? (
|
||||
<div key={label} style={{ display: 'flex', gap: 12, alignItems: 'center', fontSize: 14 }}>
|
||||
<span style={{ width: 64, fontSize: 11, textTransform: 'uppercase', letterSpacing: '.08em', color: 'var(--gray-light)', fontWeight: 600 }}>
|
||||
{label}
|
||||
</span>
|
||||
<span style={{ color: label === 'Site' ? 'var(--red)' : 'var(--ink)' }}>{value}</span>
|
||||
</div>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
{member.email && (
|
||||
<a href={`mailto:${member.email}`} style={{ textDecoration: 'none' }}>
|
||||
<button className="btn btn-dark btn-sm" style={{ marginTop: 26, padding: '13px 24px', fontSize: 14 }}>
|
||||
Contacter cette entreprise
|
||||
</button>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export default function Modal({ onClose, maxWidth = 560, children, header }) {
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal" style={{ maxWidth }} onClick={(e) => e.stopPropagation()}>
|
||||
{header && (
|
||||
<div className="modal-header">
|
||||
<button className="modal-close" onClick={onClose} aria-label="Fermer">×</button>
|
||||
{header.kicker && (
|
||||
<div className="kicker on-dark" style={{ fontSize: 11, letterSpacing: '.12em', marginBottom: 6 }}>
|
||||
{header.kicker}
|
||||
</div>
|
||||
)}
|
||||
{header.title && (
|
||||
<h3 className="serif" style={{ fontSize: 23, fontWeight: 600, lineHeight: 1.15 }}>{header.title}</h3>
|
||||
)}
|
||||
{header.meta && <div style={{ fontSize: 13, color: '#B7AFA6', marginTop: 8 }}>{header.meta}</div>}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../../lib/api.js';
|
||||
import ImageSlot from '../ImageSlot.jsx';
|
||||
|
||||
export function CategoriesTab() {
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [editId, setEditId] = useState(null);
|
||||
const [draft, setDraft] = useState('');
|
||||
const [newCat, setNewCat] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const reload = () =>
|
||||
api.get('/api/admin/categories').then((d) => setCategories(d.categories)).catch(() => {});
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
}, []);
|
||||
|
||||
const save = async (id) => {
|
||||
if (!draft.trim()) return setEditId(null);
|
||||
try {
|
||||
await api.put(`/api/admin/categories/${id}`, { name: draft.trim() });
|
||||
setEditId(null);
|
||||
setDraft('');
|
||||
reload();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (c) => {
|
||||
if (!window.confirm(`Supprimer la catégorie « ${c.name} » ? Les entreprises concernées deviendront « Non classée ».`)) return;
|
||||
try {
|
||||
await api.del(`/api/admin/categories/${c.id}`);
|
||||
reload();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const add = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!newCat.trim()) return;
|
||||
setError('');
|
||||
try {
|
||||
await api.post('/api/admin/categories', { name: newCat.trim() });
|
||||
setNewCat('');
|
||||
reload();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid-2" style={{ gridTemplateColumns: '1.4fr 1fr', gap: 20, alignItems: 'start' }}>
|
||||
<div className="card" style={{ borderRadius: 6, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '20px 24px', borderBottom: '1px solid var(--border)' }}>
|
||||
<h3 className="serif" style={{ fontSize: 19, fontWeight: 600 }}>Catégories d'entreprises</h3>
|
||||
<span style={{ fontSize: 13, color: 'var(--gray-light)' }}>{categories.length} catégories</span>
|
||||
</div>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr><th>Catégorie</th><th>Entreprises</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{categories.map((c) => (
|
||||
<tr key={c.id}>
|
||||
<td style={{ padding: '12px 24px' }}>
|
||||
{editId === c.id ? (
|
||||
<input
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
maxLength={80}
|
||||
autoFocus
|
||||
style={{ fontSize: 14, padding: '8px 12px', border: '1px solid var(--red)', borderRadius: 3, background: 'var(--bg)', width: '100%', maxWidth: 260 }}
|
||||
/>
|
||||
) : (
|
||||
<span style={{ fontSize: 14, fontWeight: 600 }}>{c.name}</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '12px 24px' }}>{c.count}</td>
|
||||
<td style={{ padding: '12px 24px', textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
{editId === c.id ? (
|
||||
<>
|
||||
<button className="btn btn-red btn-sm" style={{ fontSize: 12.5, padding: '8px 14px', marginRight: 6 }} onClick={() => save(c.id)}>
|
||||
Enregistrer
|
||||
</button>
|
||||
<button className="btn-link-gray" style={{ fontSize: 12.5, padding: 8 }} onClick={() => { setEditId(null); setDraft(''); }}>
|
||||
Annuler
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button className="btn-link" style={{ fontSize: 13, marginRight: 14 }} onClick={() => { setEditId(c.id); setDraft(c.name); }}>
|
||||
Renommer
|
||||
</button>
|
||||
<button className="btn-link-gray" onClick={() => remove(c)}>Supprimer</button>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{error && <p className="error-text" style={{ padding: '12px 24px' }}>{error}</p>}
|
||||
</div>
|
||||
<div className="card" style={{ borderRadius: 6, padding: 26 }}>
|
||||
<h3 className="serif" style={{ fontSize: 19, fontWeight: 600, marginBottom: 8 }}>Nouvelle catégorie</h3>
|
||||
<p style={{ fontSize: 13.5, color: 'var(--gray-light)', lineHeight: 1.55, marginBottom: 18 }}>
|
||||
Ajoutez une catégorie pour classer les entreprises de l'annuaire.
|
||||
</p>
|
||||
<form onSubmit={add}>
|
||||
<input
|
||||
value={newCat}
|
||||
onChange={(e) => setNewCat(e.target.value)}
|
||||
placeholder="Ex. Restauration & Hôtellerie"
|
||||
maxLength={80}
|
||||
style={{ width: '100%', padding: '12px 14px', border: '1px solid var(--input-border)', borderRadius: 3, background: 'var(--bg)', marginBottom: 14 }}
|
||||
/>
|
||||
<button type="submit" className="btn btn-red" style={{ width: '100%', fontSize: 14.5, padding: 13, borderRadius: 3 }}>
|
||||
+ Ajouter la catégorie
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContenuTab() {
|
||||
const [content, setContent] = useState({ hero_quote_text: '', hero_quote_author: '', hero_photo: '' });
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/api/public/bootstrap').then((d) => setContent({ hero_photo: '', ...d.content })).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const save = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
try {
|
||||
await api.put('/api/admin/content', {
|
||||
hero_quote_text: content.hero_quote_text,
|
||||
hero_quote_author: content.hero_quote_author,
|
||||
});
|
||||
setSaved(true);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid-2" style={{ gap: 20, alignItems: 'start', maxWidth: 900 }}>
|
||||
<div className="card" style={{ borderRadius: 6, padding: 26 }}>
|
||||
<h3 className="serif" style={{ fontSize: 19, fontWeight: 600, marginBottom: 8 }}>Section « L'esprit du Club »</h3>
|
||||
<p style={{ fontSize: 13.5, color: 'var(--gray-light)', lineHeight: 1.55, marginBottom: 20 }}>
|
||||
Photo, citation et signature affichées sur la page d'accueil.
|
||||
</p>
|
||||
<form onSubmit={save}>
|
||||
<label className="field" style={{ marginBottom: 16 }}>Citation
|
||||
<textarea
|
||||
rows={2}
|
||||
value={content.hero_quote_text}
|
||||
maxLength={300}
|
||||
onChange={(e) => { setContent({ ...content, hero_quote_text: e.target.value }); setSaved(false); }}
|
||||
style={{ fontFamily: 'var(--serif)', fontSize: 16, fontWeight: 400 }}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">Signature
|
||||
<input
|
||||
value={content.hero_quote_author}
|
||||
maxLength={120}
|
||||
onChange={(e) => { setContent({ ...content, hero_quote_author: e.target.value }); setSaved(false); }}
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="error-text" style={{ marginTop: 12 }}>{error}</p>}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginTop: 20 }}>
|
||||
<button type="submit" className="btn btn-red btn-sm" style={{ fontSize: 14, padding: '12px 22px' }}>Enregistrer</button>
|
||||
{saved && <span className="success-text">✓ Contenu enregistré</span>}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className="card" style={{ borderRadius: 6, padding: 26 }}>
|
||||
<h3 className="serif" style={{ fontSize: 19, fontWeight: 600, marginBottom: 8 }}>Photo</h3>
|
||||
<p style={{ fontSize: 13.5, color: 'var(--gray-light)', lineHeight: 1.55, marginBottom: 18 }}>
|
||||
Déposez une image ou cliquez pour en choisir une — elle remplace celle affichée sur l'accueil.
|
||||
</p>
|
||||
<div style={{ aspectRatio: '4/5', maxWidth: 260 }}>
|
||||
<ImageSlot
|
||||
endpoint="/api/admin/content/hero-photo"
|
||||
value={content.hero_photo}
|
||||
placeholder="Photo — networking en tribune VIP"
|
||||
onUploaded={(path) => setContent({ ...content, hero_photo: path })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,608 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api, dateParts, seasonLabel, statutLabel } from '../../lib/api.js';
|
||||
import Modal from '../Modal.jsx';
|
||||
|
||||
/* ---------------- Members ---------------- */
|
||||
|
||||
function MemberFormModal({ member, categories, onClose, onSaved }) {
|
||||
const [form, setForm] = useState(
|
||||
member
|
||||
? { ...member, email: member.email || '', tel: member.tel || '', site: member.site || '', presentation: member.presentation || '' }
|
||||
: { nom: '', secteur: '', categorie_id: categories[0]?.id ?? null, dirigeant: '', email: '', tel: '', site: '', presentation: '' }
|
||||
);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const onChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setForm({ ...form, [name]: name === 'categorie_id' ? (value ? Number(value) : null) : value });
|
||||
};
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
const body = {
|
||||
nom: form.nom, secteur: form.secteur, categorie_id: form.categorie_id,
|
||||
dirigeant: form.dirigeant, email: form.email, tel: form.tel,
|
||||
site: form.site, presentation: form.presentation,
|
||||
};
|
||||
try {
|
||||
if (member) await api.put(`/api/admin/members/${member.id}`, body);
|
||||
else await api.post('/api/admin/members', body);
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal onClose={onClose} maxWidth={600} header={{ kicker: 'Membres', title: member ? 'Modifier le membre' : 'Nouveau membre' }}>
|
||||
<form onSubmit={submit} style={{ padding: '26px 30px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<label className="field">Entreprise
|
||||
<input name="nom" value={form.nom} onChange={onChange} required maxLength={120} />
|
||||
</label>
|
||||
<label className="field">Catégorie
|
||||
<select name="categorie_id" value={form.categorie_id || ''} onChange={onChange}>
|
||||
<option value="">Non classée</option>
|
||||
{categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label className="field">Secteur d'activité
|
||||
<input name="secteur" value={form.secteur} onChange={onChange} required maxLength={120} />
|
||||
</label>
|
||||
<label className="field">Dirigeant·e
|
||||
<input name="dirigeant" value={form.dirigeant} onChange={onChange} required maxLength={120} />
|
||||
</label>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<label className="field">Email
|
||||
<input name="email" type="email" value={form.email} onChange={onChange} maxLength={254} />
|
||||
</label>
|
||||
<label className="field">Téléphone
|
||||
<input name="tel" value={form.tel} onChange={onChange} maxLength={30} />
|
||||
</label>
|
||||
</div>
|
||||
<label className="field">Site web
|
||||
<input name="site" value={form.site} onChange={onChange} maxLength={200} />
|
||||
</label>
|
||||
<label className="field">Présentation
|
||||
<textarea name="presentation" value={form.presentation} onChange={onChange} rows={3} maxLength={2000} />
|
||||
</label>
|
||||
</div>
|
||||
{error && <p className="error-text" style={{ marginTop: 12 }}>{error}</p>}
|
||||
<div style={{ display: 'flex', gap: 12, marginTop: 24 }}>
|
||||
<button type="submit" className="btn btn-red btn-sm" style={{ flex: 1, fontSize: 14.5, padding: 13 }}>
|
||||
{member ? 'Enregistrer' : 'Ajouter le membre'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-outline-soft btn-sm" style={{ fontSize: 14.5, padding: '13px 22px' }} onClick={onClose}>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function MembersTab() {
|
||||
const [members, setMembers] = useState([]);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [modal, setModal] = useState(null); // null | 'new' | member
|
||||
const season = seasonLabel();
|
||||
|
||||
const reload = () =>
|
||||
Promise.all([api.get('/api/admin/members'), api.get('/api/admin/categories')])
|
||||
.then(([m, c]) => {
|
||||
setMembers(m.members);
|
||||
setCategories(c.categories);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
}, []);
|
||||
|
||||
const toggle = async (m) => {
|
||||
await api.post(`/api/admin/members/${m.id}/toggle-valide`);
|
||||
reload();
|
||||
};
|
||||
|
||||
const actifs = members.filter((m) => m.valide).length;
|
||||
|
||||
return (
|
||||
<div className="card" style={{ borderRadius: 6, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '20px 24px', borderBottom: '1px solid var(--border)' }}>
|
||||
<div>
|
||||
<h3 className="serif" style={{ fontSize: 19, fontWeight: 600 }}>Membres · {actifs} validés</h3>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--gray-light)', marginTop: 3 }}>
|
||||
Saison {season} · {members.length - actifs} à renouveler
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-red btn-sm" style={{ fontSize: 13, padding: '10px 18px' }} onClick={() => setModal('new')}>
|
||||
+ Ajouter un membre
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-start', padding: '14px 24px', background: '#FBF6EE', borderBottom: '1px solid var(--border-soft)', fontSize: 12.5, color: '#8A6D3B', lineHeight: 1.5 }}>
|
||||
<span>ⓘ</span>
|
||||
<span>
|
||||
Un membre non validé pour la saison en cours n'apparaît pas dans l'annuaire public et ne peut pas
|
||||
être inscrit aux rencontres. La saison court du 1<sup>er</sup> septembre au 31 août.
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Entreprise</th><th>Secteur</th><th>Dirigeant</th><th>Saison</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{members.map((m) => (
|
||||
<tr key={m.id}>
|
||||
<td>{m.nom}</td>
|
||||
<td>{m.secteur}</td>
|
||||
<td>{m.dirigeant}</td>
|
||||
<td>
|
||||
<span className={`badge ${m.valide ? 'badge-green' : 'badge-red'}`}>
|
||||
{m.valide ? `Validé ${season}` : 'Non validé'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
<button className="btn-link-gray" style={{ color: 'var(--gray)', marginRight: 14 }} onClick={() => toggle(m)}>
|
||||
{m.valide ? 'Suspendre' : 'Valider'}
|
||||
</button>
|
||||
<button className="btn-link" style={{ fontSize: 13 }} onClick={() => setModal(m)}>Modifier</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{modal && (
|
||||
<MemberFormModal
|
||||
member={modal === 'new' ? null : modal}
|
||||
categories={categories}
|
||||
onClose={() => setModal(null)}
|
||||
onSaved={() => { setModal(null); reload(); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- Rencontres ---------------- */
|
||||
|
||||
function RencFormModal({ renc, onClose, onSaved }) {
|
||||
const [form, setForm] = useState(
|
||||
renc
|
||||
? { titre: renc.titre, date_renc: String(renc.date_renc).slice(0, 10), heure: renc.heure, lieu: renc.lieu, description: renc.description, places: renc.places }
|
||||
: { titre: '', date_renc: '', heure: '', lieu: '', description: '', places: 30 }
|
||||
);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const onChange = (e) => setForm({ ...form, [e.target.name]: e.target.value });
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
try {
|
||||
if (renc) await api.put(`/api/admin/rencontres/${renc.id}`, form);
|
||||
else await api.post('/api/admin/rencontres', form);
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
if (!window.confirm('Supprimer cette rencontre et toutes ses inscriptions ?')) return;
|
||||
try {
|
||||
await api.del(`/api/admin/rencontres/${renc.id}`);
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal onClose={onClose} maxWidth={560} header={{ kicker: 'Rencontres', title: renc ? 'Gérer la rencontre' : 'Nouvelle rencontre' }}>
|
||||
<form onSubmit={submit} style={{ padding: '26px 30px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<label className="field">Titre de la rencontre
|
||||
<input name="titre" value={form.titre} onChange={onChange} required maxLength={200} />
|
||||
</label>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
|
||||
<label className="field" style={{ gridColumn: 'span 2' }}>Date
|
||||
<input name="date_renc" type="date" value={form.date_renc} onChange={onChange} required />
|
||||
</label>
|
||||
<label className="field">Heure
|
||||
<input name="heure" value={form.heure} onChange={onChange} placeholder="18h30" maxLength={20} />
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 12 }}>
|
||||
<label className="field">Lieu
|
||||
<input name="lieu" value={form.lieu} onChange={onChange} required maxLength={200} />
|
||||
</label>
|
||||
<label className="field">Places
|
||||
<input name="places" type="number" min={0} max={100000} value={form.places} onChange={onChange} required />
|
||||
</label>
|
||||
</div>
|
||||
<label className="field">Description
|
||||
<textarea name="description" value={form.description} onChange={onChange} rows={3} maxLength={2000} />
|
||||
</label>
|
||||
</div>
|
||||
{error && <p className="error-text" style={{ marginTop: 12 }}>{error}</p>}
|
||||
<div style={{ display: 'flex', gap: 12, marginTop: 24, alignItems: 'center' }}>
|
||||
<button type="submit" className="btn btn-red btn-sm" style={{ flex: 1, fontSize: 14.5, padding: 13 }}>
|
||||
{renc ? 'Enregistrer' : 'Créer la rencontre'}
|
||||
</button>
|
||||
{renc && (
|
||||
<button type="button" className="btn-link-gray" style={{ fontSize: 13.5, padding: '13px 10px' }} onClick={remove}>
|
||||
Supprimer
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn btn-outline-soft btn-sm" style={{ fontSize: 14.5, padding: '13px 22px' }} onClick={onClose}>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function escHtml(v) {
|
||||
return String(v ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function ParticipantsModal({ renc, onClose, onEdit, onCancel, refreshKey }) {
|
||||
const [rows, setRows] = useState([]);
|
||||
const { jour, mois } = dateParts(renc.date_renc);
|
||||
const meta = `${jour} ${mois} · ${renc.heure} · ${renc.lieu}`;
|
||||
|
||||
useEffect(() => {
|
||||
api.get(`/api/admin/rencontres/${renc.id}/inscriptions`).then((d) => setRows(d.inscriptions)).catch(() => {});
|
||||
}, [renc.id, refreshKey]);
|
||||
|
||||
const exportExcel = () => {
|
||||
let html = '<html xmlns:x="urn:schemas-microsoft-com:office:excel"><head><meta charset="utf-8"></head><body><table border="1"><thead>';
|
||||
html += '<tr><th>Participant</th><th>Entreprise</th><th>Email</th><th>Téléphone</th><th>Rencontre</th><th>Statut</th></tr></thead><tbody>';
|
||||
rows.forEach((p) => {
|
||||
html += `<tr><td>${escHtml(p.nom)}</td><td>${escHtml(p.entreprise)}</td><td>${escHtml(p.email)}</td><td>${escHtml(p.tel)}</td><td>${escHtml(renc.titre)}</td><td>${escHtml(statutLabel(p.statut))}</td></tr>`;
|
||||
});
|
||||
html += '</tbody></table></body></html>';
|
||||
const blob = new Blob(['' + html], { type: 'application/vnd.ms-excel' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
const slug = renc.titre.normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^a-zA-Z0-9]+/g, '-').toLowerCase();
|
||||
a.href = url;
|
||||
a.download = `inscrits-${slug}.xls`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 2000);
|
||||
};
|
||||
|
||||
const print = () => {
|
||||
let body = '';
|
||||
rows.forEach((p, i) => {
|
||||
body += `<tr><td>${i + 1}</td><td>${escHtml(p.nom)}</td><td>${escHtml(p.entreprise)}</td><td>${escHtml(p.email)}</td><td>${escHtml(p.tel)}</td><td>${escHtml(statutLabel(p.statut))}</td></tr>`;
|
||||
});
|
||||
const doc = `<!doctype html><html><head><meta charset="utf-8"><title>Inscrits — ${escHtml(renc.titre)}</title>`
|
||||
+ '<style>body{font-family:Arial,Helvetica,sans-serif;color:#1B1B1B;padding:32px}h1{font-size:22px;margin:0 0 4px}.meta{color:#666;font-size:13px;margin-bottom:20px}'
|
||||
+ 'table{width:100%;border-collapse:collapse;font-size:13px}th,td{text-align:left;padding:9px 10px;border-bottom:1px solid #ddd}th{background:#f4f1ec;text-transform:uppercase;font-size:11px;letter-spacing:.05em}'
|
||||
+ '.brand{color:#C1272D;font-weight:700;font-size:12px;letter-spacing:.1em;text-transform:uppercase;margin-bottom:16px}</style></head><body>'
|
||||
+ '<div class="brand">Business Club SLUC Nancy</div>'
|
||||
+ `<h1>${escHtml(renc.titre)}</h1><div class="meta">${escHtml(meta)} — ${rows.length} inscrits</div>`
|
||||
+ '<table><thead><tr><th>#</th><th>Participant</th><th>Entreprise</th><th>Email</th><th>Téléphone</th><th>Statut</th></tr></thead><tbody>'
|
||||
+ body + '</tbody></table></body></html>';
|
||||
const w = window.open('', '_blank');
|
||||
if (!w) return;
|
||||
w.document.open();
|
||||
w.document.write(doc);
|
||||
w.document.close();
|
||||
setTimeout(() => { w.focus(); w.print(); }, 300);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal onClose={onClose} maxWidth={720} header={{ kicker: 'Liste des inscrits', title: renc.titre, meta: `${meta} · ${rows.length} inscrits` }}>
|
||||
<div style={{ padding: '22px 32px', display: 'flex', gap: 12, borderBottom: '1px solid var(--border-soft)' }}>
|
||||
<button className="btn btn-sm" style={{ background: 'var(--admin-bg)', color: 'var(--ink)', fontSize: 13.5, padding: '11px 18px' }} onClick={print}>
|
||||
⎙ Imprimer
|
||||
</button>
|
||||
<button className="btn btn-sm" style={{ background: '#1E7B45', color: '#fff', fontSize: 13.5, padding: '11px 18px' }} onClick={exportExcel}>
|
||||
↓ Exporter Excel
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ padding: '6px 0 12px' }}>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr><th>Participant</th><th>Entreprise</th><th>Email</th><th>Statut</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((p) => (
|
||||
<tr key={p.id}>
|
||||
<td>{p.nom}</td>
|
||||
<td>{p.entreprise}</td>
|
||||
<td>{p.email}</td>
|
||||
<td><span className={`badge ${p.statut === 'confirmee' ? 'badge-green' : 'badge-amber'}`}>{statutLabel(p.statut)}</span></td>
|
||||
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
<button className="btn-link" style={{ fontSize: 12.5, marginRight: 12 }} onClick={() => onEdit(p)}>Modifier</button>
|
||||
<button className="btn-link-gray" style={{ fontSize: 12.5 }} onClick={() => onCancel(p)}>Annuler</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{rows.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '40px 20px', color: 'var(--gray-light)', fontSize: 14 }}>
|
||||
Aucun inscrit pour cette rencontre.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function RencontresTab() {
|
||||
const [rencontres, setRencontres] = useState([]);
|
||||
const [formModal, setFormModal] = useState(null); // null | 'new' | renc
|
||||
const [participantsRenc, setParticipantsRenc] = useState(null);
|
||||
const [editInscr, setEditInscr] = useState(null);
|
||||
const [cancelInscr, setCancelInscr] = useState(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
const reload = () => {
|
||||
api.get('/api/admin/rencontres').then((d) => setRencontres(d.rencontres)).catch(() => {});
|
||||
setRefreshKey((k) => k + 1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 22 }}>
|
||||
<h3 className="serif" style={{ fontSize: 20, fontWeight: 600 }}>Gestion des rencontres</h3>
|
||||
<button className="btn btn-red btn-sm" style={{ fontSize: 13, padding: '10px 18px' }} onClick={() => setFormModal('new')}>
|
||||
+ Créer une rencontre
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
{rencontres.map((e) => {
|
||||
const { jour, mois } = dateParts(e.date_renc);
|
||||
return (
|
||||
<div key={e.id} className="card" style={{ borderRadius: 6, padding: '22px 24px', display: 'grid', gridTemplateColumns: 'auto 1fr auto auto', gap: 24, alignItems: 'center' }}>
|
||||
<div style={{ width: 56, textAlign: 'center', background: 'var(--admin-bg)', borderRadius: 5, padding: '10px 0', lineHeight: 1 }}>
|
||||
<div className="serif" style={{ fontSize: 24, fontWeight: 600 }}>{jour}</div>
|
||||
<div style={{ fontSize: 10, textTransform: 'uppercase', color: 'var(--gray-light)', letterSpacing: '.1em' }}>{mois}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 16, fontWeight: 600 }}>{e.titre}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--gray-light)', marginTop: 4 }}>{e.heure} · {e.lieu}</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div className="serif" style={{ fontSize: 22, fontWeight: 600, color: 'var(--red)' }}>{e.inscrits}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--gray-light)' }}>inscrits / {e.places}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="btn btn-dark btn-sm" style={{ fontSize: 13, padding: '9px 16px' }} onClick={() => setParticipantsRenc(e)}>
|
||||
Inscrits ({e.inscrits})
|
||||
</button>
|
||||
<button className="btn btn-sm" style={{ background: 'var(--admin-bg)', fontSize: 13, padding: '9px 16px' }} onClick={() => setFormModal(e)}>
|
||||
Gérer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{formModal && (
|
||||
<RencFormModal
|
||||
renc={formModal === 'new' ? null : formModal}
|
||||
onClose={() => setFormModal(null)}
|
||||
onSaved={() => { setFormModal(null); reload(); }}
|
||||
/>
|
||||
)}
|
||||
{participantsRenc && (
|
||||
<ParticipantsModal
|
||||
renc={participantsRenc}
|
||||
refreshKey={refreshKey}
|
||||
onClose={() => setParticipantsRenc(null)}
|
||||
onEdit={setEditInscr}
|
||||
onCancel={setCancelInscr}
|
||||
/>
|
||||
)}
|
||||
{editInscr && (
|
||||
<InscriptionEditModal
|
||||
inscription={editInscr}
|
||||
rencontres={rencontres}
|
||||
onClose={() => setEditInscr(null)}
|
||||
onSaved={() => { setEditInscr(null); reload(); }}
|
||||
onRequestCancel={(p) => { setEditInscr(null); setCancelInscr(p); }}
|
||||
/>
|
||||
)}
|
||||
{cancelInscr && (
|
||||
<CancelConfirmModal
|
||||
inscription={cancelInscr}
|
||||
onClose={() => setCancelInscr(null)}
|
||||
onDone={() => { setCancelInscr(null); reload(); }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- Inscriptions ---------------- */
|
||||
|
||||
function InscriptionEditModal({ inscription, rencontres, onClose, onSaved, onRequestCancel }) {
|
||||
const [form, setForm] = useState({
|
||||
nom: inscription.nom,
|
||||
entreprise: inscription.entreprise,
|
||||
email: inscription.email || '',
|
||||
tel: inscription.tel || '',
|
||||
rencontre_id: inscription.rencontre_id,
|
||||
statut: inscription.statut,
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const onChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setForm({ ...form, [name]: name === 'rencontre_id' ? Number(value) : value });
|
||||
};
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
try {
|
||||
await api.put(`/api/admin/inscriptions/${inscription.id}`, form);
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal onClose={onClose} maxWidth={520} header={{ kicker: 'Inscription', title: "Modifier l'inscription" }}>
|
||||
<form onSubmit={submit} style={{ padding: '26px 30px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<label className="field">Participant
|
||||
<input name="nom" value={form.nom} onChange={onChange} required maxLength={120} />
|
||||
</label>
|
||||
<label className="field">Entreprise
|
||||
<input name="entreprise" value={form.entreprise} onChange={onChange} required maxLength={120} />
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<label className="field">Email
|
||||
<input name="email" type="email" value={form.email} onChange={onChange} maxLength={254} />
|
||||
</label>
|
||||
<label className="field">Téléphone
|
||||
<input name="tel" value={form.tel} onChange={onChange} maxLength={30} />
|
||||
</label>
|
||||
</div>
|
||||
<label className="field">Rencontre
|
||||
<select name="rencontre_id" value={form.rencontre_id} onChange={onChange}>
|
||||
{rencontres.map((r) => <option key={r.id} value={r.id}>{r.titre}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">Statut
|
||||
<select name="statut" value={form.statut} onChange={onChange}>
|
||||
<option value="confirmee">Confirmée</option>
|
||||
<option value="en_attente">En attente</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{error && <p className="error-text" style={{ marginTop: 12 }}>{error}</p>}
|
||||
<div style={{ display: 'flex', gap: 12, marginTop: 24, alignItems: 'center' }}>
|
||||
<button type="submit" className="btn btn-red btn-sm" style={{ flex: 1, fontSize: 14.5, padding: 13 }}>Enregistrer</button>
|
||||
<button type="button" className="btn-link-gray" style={{ fontSize: 13.5, padding: '13px 10px' }} onClick={() => onRequestCancel(inscription)}>
|
||||
Annuler l'inscription
|
||||
</button>
|
||||
<button type="button" className="btn btn-outline-soft btn-sm" style={{ fontSize: 14.5, padding: '13px 22px' }} onClick={onClose}>
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function CancelConfirmModal({ inscription, onClose, onDone }) {
|
||||
const [error, setError] = useState('');
|
||||
const confirm = async () => {
|
||||
try {
|
||||
await api.del(`/api/admin/inscriptions/${inscription.id}`);
|
||||
onDone();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Modal onClose={onClose} maxWidth={440}>
|
||||
<div style={{ padding: '36px 34px', textAlign: 'center' }}>
|
||||
<div style={{ width: 56, height: 56, borderRadius: '50%', background: '#F6E4E4', color: 'var(--red)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 26, margin: '0 auto 20px' }}>⚠</div>
|
||||
<h3 className="serif" style={{ fontSize: 24, fontWeight: 600, marginBottom: 12 }}>Annuler cette inscription ?</h3>
|
||||
<p style={{ fontSize: 15, color: 'var(--gray)', lineHeight: 1.6, marginBottom: 6 }}>
|
||||
Retirer <strong>{inscription.nom}</strong> de la liste
|
||||
</p>
|
||||
<p style={{ fontSize: 14, color: 'var(--gray-light)', marginBottom: 28 }}>{inscription.rencontre}</p>
|
||||
{error && <p className="error-text" style={{ marginBottom: 12 }}>{error}</p>}
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<button className="btn btn-red btn-sm" style={{ flex: 1, fontSize: 14.5, padding: 13 }} onClick={confirm}>
|
||||
Annuler l'inscription
|
||||
</button>
|
||||
<button className="btn btn-outline-soft btn-sm" style={{ flex: 1, fontSize: 14.5, padding: 13 }} onClick={onClose}>
|
||||
Retour
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function InscriptionsTab() {
|
||||
const [inscriptions, setInscriptions] = useState([]);
|
||||
const [rencontres, setRencontres] = useState([]);
|
||||
const [edit, setEdit] = useState(null);
|
||||
const [cancel, setCancel] = useState(null);
|
||||
|
||||
const reload = () =>
|
||||
Promise.all([api.get('/api/admin/inscriptions'), api.get('/api/admin/rencontres')])
|
||||
.then(([i, r]) => {
|
||||
setInscriptions(i.inscriptions);
|
||||
setRencontres(r.rencontres);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="card" style={{ borderRadius: 6, overflow: 'hidden' }}>
|
||||
<div style={{ padding: '20px 24px', borderBottom: '1px solid var(--border)' }}>
|
||||
<h3 className="serif" style={{ fontSize: 19, fontWeight: 600 }}>Inscriptions aux rencontres</h3>
|
||||
</div>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr><th>Participant</th><th>Entreprise</th><th>Rencontre</th><th>Statut</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{inscriptions.map((i) => (
|
||||
<tr key={i.id}>
|
||||
<td>{i.nom}</td>
|
||||
<td>{i.entreprise}</td>
|
||||
<td>{i.rencontre}</td>
|
||||
<td><span className={`badge ${i.statut === 'confirmee' ? 'badge-green' : 'badge-amber'}`}>{statutLabel(i.statut)}</span></td>
|
||||
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
<button className="btn-link" style={{ fontSize: 13, marginRight: 14 }} onClick={() => setEdit(i)}>Modifier</button>
|
||||
<button className="btn-link-gray" onClick={() => setCancel(i)}>Annuler</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{edit && (
|
||||
<InscriptionEditModal
|
||||
inscription={edit}
|
||||
rencontres={rencontres}
|
||||
onClose={() => setEdit(null)}
|
||||
onSaved={() => { setEdit(null); reload(); }}
|
||||
onRequestCancel={(p) => { setEdit(null); setCancel(p); }}
|
||||
/>
|
||||
)}
|
||||
{cancel && (
|
||||
<CancelConfirmModal
|
||||
inscription={cancel}
|
||||
onClose={() => setCancel(null)}
|
||||
onDone={() => { setCancel(null); reload(); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { api } from './api.js';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.get('/api/auth/me')
|
||||
.then((d) => setUser(d.user))
|
||||
.catch(() => setUser(null))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const login = async (email, password) => {
|
||||
const d = await api.post('/api/auth/login', { email, password });
|
||||
setUser(d.user);
|
||||
return d.user;
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await api.post('/api/auth/logout');
|
||||
} finally {
|
||||
setUser(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useAuth = () => useContext(AuthContext);
|
||||
@@ -0,0 +1,52 @@
|
||||
async function request(path, options = {}) {
|
||||
const res = await fetch(path, {
|
||||
headers: options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
...options,
|
||||
body:
|
||||
options.body instanceof FormData
|
||||
? options.body
|
||||
: options.body
|
||||
? JSON.stringify(options.body)
|
||||
: undefined,
|
||||
});
|
||||
let data = null;
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch {
|
||||
/* non-JSON response */
|
||||
}
|
||||
if (!res.ok) {
|
||||
const err = new Error(data?.error || `Erreur ${res.status}`);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: (path) => request(path),
|
||||
post: (path, body) => request(path, { method: 'POST', body }),
|
||||
put: (path, body) => request(path, { method: 'PUT', body }),
|
||||
del: (path) => request(path, { method: 'DELETE' }),
|
||||
upload: (path, file) => {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
return request(path, { method: 'POST', body: fd });
|
||||
},
|
||||
};
|
||||
|
||||
const MOIS = ['Jan', 'Fév', 'Mars', 'Avr', 'Mai', 'Juin', 'Juil', 'Août', 'Sept', 'Oct', 'Nov', 'Déc'];
|
||||
|
||||
export function dateParts(dateStr) {
|
||||
const d = new Date(dateStr);
|
||||
if (Number.isNaN(d.getTime())) return { jour: '--', mois: '' };
|
||||
return { jour: String(d.getUTCDate()).padStart(2, '0'), mois: MOIS[d.getUTCMonth()] };
|
||||
}
|
||||
|
||||
export function seasonLabel(d = new Date()) {
|
||||
const y = d.getFullYear();
|
||||
return d.getMonth() >= 8 ? `${y}–${y + 1}` : `${y - 1}–${y}`;
|
||||
}
|
||||
|
||||
export const statutLabel = (s) => (s === 'confirmee' ? 'Confirmée' : 'En attente');
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from './api.js';
|
||||
|
||||
export function usePublicData() {
|
||||
const [data, setData] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const reload = () =>
|
||||
api
|
||||
.get('/api/public/bootstrap')
|
||||
.then(setData)
|
||||
.catch((e) => setError(e.message));
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
}, []);
|
||||
|
||||
return { data, error, reload };
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App.jsx';
|
||||
import { AuthProvider } from './lib/AuthContext.jsx';
|
||||
import './styles.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '../lib/AuthContext.jsx';
|
||||
import { api, dateParts, statutLabel } from '../lib/api.js';
|
||||
import { LoginSection } from './Espace.jsx';
|
||||
import { MembersTab, RencontresTab, InscriptionsTab } from '../components/admin/AdminTabs.jsx';
|
||||
import { CategoriesTab, ContenuTab } from '../components/admin/AdminContent.jsx';
|
||||
|
||||
const TABS = [
|
||||
{ key: 'dashboard', icon: '◧', label: 'Tableau de bord', title: 'Tableau de bord' },
|
||||
{ key: 'membres', icon: '▤', label: 'Membres', title: 'Gestion des membres' },
|
||||
{ key: 'rencontres', icon: '◈', label: 'Rencontres', title: 'Rencontres' },
|
||||
{ key: 'inscriptions', icon: '✎', label: 'Inscriptions', title: 'Inscriptions' },
|
||||
{ key: 'categories', icon: '☲', label: 'Catégories', title: 'Catégories' },
|
||||
{ key: 'contenu', icon: '▧', label: 'Contenu du site', title: 'Contenu du site' },
|
||||
];
|
||||
|
||||
function Dashboard() {
|
||||
const [data, setData] = useState(null);
|
||||
useEffect(() => {
|
||||
api.get('/api/admin/dashboard').then(setData).catch(() => {});
|
||||
}, []);
|
||||
if (!data) return <p style={{ color: 'var(--gray-light)' }}>Chargement…</p>;
|
||||
|
||||
const k = data.kpis;
|
||||
const daysToNext = k.prochaine_date
|
||||
? Math.max(0, Math.round((new Date(k.prochaine_date) - Date.now()) / 86400000))
|
||||
: null;
|
||||
const kpis = [
|
||||
{ n: k.membres_valides, l: 'Membres validés', d: 'saison en cours' },
|
||||
{ n: k.a_renouveler, l: 'À renouveler', d: 'non validés' },
|
||||
{ n: k.rencontres_a_venir, l: 'Rencontres à venir', d: daysToNext != null ? `prochaine dans ${daysToNext} j` : '—' },
|
||||
{ n: k.inscriptions_attente, l: 'Inscriptions en attente', d: 'à valider' },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid-4" style={{ marginBottom: 32 }}>
|
||||
{kpis.map((x) => (
|
||||
<div key={x.l} className="card" style={{ borderRadius: 6, padding: 24 }}>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--gray-light)', marginBottom: 12 }}>{x.l}</div>
|
||||
<div className="serif" style={{ fontSize: 38, fontWeight: 600, lineHeight: 1 }}>{x.n}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--red)', fontWeight: 600, marginTop: 10 }}>{x.d}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid-2" style={{ gridTemplateColumns: '1.5fr 1fr', gap: 20 }}>
|
||||
<div className="card" style={{ borderRadius: 6, padding: 26 }}>
|
||||
<h3 className="serif" style={{ fontSize: 19, fontWeight: 600, marginBottom: 18 }}>Dernières inscriptions</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{data.latestInscriptions.map((i) => (
|
||||
<div key={i.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 0', borderBottom: '1px solid var(--border-soft)' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600 }}>{i.nom}</div>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--gray-light)' }}>{i.entreprise} · {i.rencontre}</div>
|
||||
</div>
|
||||
<span className={`badge ${i.statut === 'confirmee' ? 'badge-green' : 'badge-amber'}`}>{statutLabel(i.statut)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card" style={{ borderRadius: 6, padding: 26 }}>
|
||||
<h3 className="serif" style={{ fontSize: 19, fontWeight: 600, marginBottom: 18 }}>Prochaines rencontres</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
{data.upcoming.map((e) => {
|
||||
const { jour, mois } = dateParts(e.date_renc);
|
||||
return (
|
||||
<div key={e.id} style={{ display: 'flex', gap: 14, alignItems: 'center' }}>
|
||||
<div style={{ width: 46, flexShrink: 0, textAlign: 'center', background: 'var(--admin-bg)', borderRadius: 4, padding: '8px 0', lineHeight: 1 }}>
|
||||
<div className="serif" style={{ fontSize: 20, fontWeight: 600 }}>{jour}</div>
|
||||
<div style={{ fontSize: 9, textTransform: 'uppercase', color: 'var(--gray-light)', letterSpacing: '.1em' }}>{mois}</div>
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, lineHeight: 1.2 }}>{e.titre}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--gray-light)', marginTop: 3 }}>
|
||||
{Math.max(0, e.places - e.inscrits)} places restantes
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Admin() {
|
||||
const { user, loading, logout } = useAuth();
|
||||
const [tab, setTab] = useState('dashboard');
|
||||
|
||||
if (loading) return <main style={{ minHeight: '60vh' }} />;
|
||||
if (!user || user.role !== 'admin') {
|
||||
return (
|
||||
<main>
|
||||
<LoginSection kicker="Espace admin" title="Administration du Business Club" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const current = TABS.find((t) => t.key === tab);
|
||||
const today = new Date().toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', minHeight: '100vh', background: 'var(--admin-bg)' }}>
|
||||
<aside style={{ width: 250, flexShrink: 0, background: 'var(--dark)', color: '#fff', display: 'flex', flexDirection: 'column', padding: '26px 0', position: 'sticky', top: 0, height: '100vh' }}>
|
||||
<div style={{ padding: '0 24px 26px', borderBottom: '1px solid rgba(255,255,255,.1)', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<img src="/assets/logo.jpg" alt="SLUC" style={{ height: 40, width: 40, objectFit: 'cover', borderRadius: 4, background: '#fff' }} />
|
||||
<div style={{ lineHeight: 1.1 }}>
|
||||
<div className="serif" style={{ fontSize: 16, fontWeight: 600 }}>Admin</div>
|
||||
<div style={{ fontSize: 10, letterSpacing: '.14em', textTransform: 'uppercase', color: '#8A8279' }}>Business Club</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav style={{ padding: '20px 14px', display: 'flex', flexDirection: 'column', gap: 4, flex: 1 }}>
|
||||
{TABS.map((t) => (
|
||||
<button key={t.key} className={`admin-nav-btn${tab === t.key ? ' active' : ''}`} onClick={() => setTab(t.key)}>
|
||||
{t.icon} {t.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div style={{ padding: '14px 24px 0', borderTop: '1px solid rgba(255,255,255,.1)', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<Link to="/" style={{ color: '#B7AFA6', fontSize: 13.5, fontWeight: 500, padding: '6px 0', textDecoration: 'none' }}>← Retour au site</Link>
|
||||
<button onClick={logout} style={{ background: 'none', border: 'none', color: '#B7AFA6', fontSize: 13.5, fontWeight: 500, padding: '6px 0', textAlign: 'left' }}>
|
||||
Se déconnecter
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ background: '#fff', borderBottom: '1px solid var(--border)', padding: '20px 40px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<h1 className="serif" style={{ fontSize: 24, fontWeight: 600 }}>{current.title}</h1>
|
||||
<div style={{ fontSize: 13, color: 'var(--gray-light)', marginTop: 2, textTransform: 'capitalize' }}>{today}</div>
|
||||
</div>
|
||||
<div style={{ width: 38, height: 38, borderRadius: '50%', background: '#FBEDEC', color: 'var(--red)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 700, fontSize: 14 }}>
|
||||
{(user.email || 'AD').slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '36px 40px' }}>
|
||||
{tab === 'dashboard' && <Dashboard />}
|
||||
{tab === 'membres' && <MembersTab />}
|
||||
{tab === 'rencontres' && <RencontresTab />}
|
||||
{tab === 'inscriptions' && <InscriptionsTab />}
|
||||
{tab === 'categories' && <CategoriesTab />}
|
||||
{tab === 'contenu' && <ContenuTab />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { usePublicData } from '../lib/usePublic.js';
|
||||
import MemberModal from '../components/MemberModal.jsx';
|
||||
|
||||
export default function Annuaire() {
|
||||
const { data } = usePublicData();
|
||||
const [search, setSearch] = useState('');
|
||||
const [category, setCategory] = useState('Toutes');
|
||||
const [detail, setDetail] = useState(null);
|
||||
|
||||
const members = data?.members ?? [];
|
||||
const categories = data?.categories ?? [];
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return members.filter((m) => {
|
||||
const matchCat = category === 'Toutes' || (m.categorie || 'Non classée') === category;
|
||||
const matchText = !q || `${m.nom} ${m.dirigeant} ${m.secteur} ${m.categorie || ''}`.toLowerCase().includes(q);
|
||||
return matchCat && matchText;
|
||||
});
|
||||
}, [members, search, category]);
|
||||
|
||||
const chips = ['Toutes', ...categories.map((c) => c.name)];
|
||||
|
||||
return (
|
||||
<main>
|
||||
<section style={{ background: '#fff', borderBottom: '1px solid var(--border)' }}>
|
||||
<div className="container" style={{ padding: '70px 32px 60px' }}>
|
||||
<div className="kicker" style={{ marginBottom: 16 }}>Annuaire des membres</div>
|
||||
<h1 className="serif" style={{ fontWeight: 500, fontSize: 'clamp(36px, 5vw, 54px)', lineHeight: 1.05, marginBottom: 18 }}>
|
||||
{members.length} entreprises, un réseau.
|
||||
</h1>
|
||||
<p style={{ fontSize: 17, lineHeight: 1.6, color: 'var(--gray)', maxWidth: 600 }}>
|
||||
Découvrez les entreprises membres du Business Club SLUC Nancy et prenez contact directement avec leurs dirigeants.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="container" style={{ padding: '44px 32px 100px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap', marginBottom: 14 }}>
|
||||
<div style={{ position: 'relative', flex: 1, minWidth: 260 }}>
|
||||
<span style={{ position: 'absolute', left: 16, top: '50%', transform: 'translateY(-50%)', color: 'var(--gray-light)', fontSize: 15 }}>⌕</span>
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Rechercher une entreprise, un dirigeant, un secteur…"
|
||||
maxLength={120}
|
||||
style={{ width: '100%', padding: '14px 16px 14px 42px', border: '1px solid var(--input-border)', borderRadius: 3, background: '#fff' }}
|
||||
/>
|
||||
</div>
|
||||
<span style={{ fontSize: 13.5, color: 'var(--gray-light)', whiteSpace: 'nowrap' }}>
|
||||
{filtered.length} entreprise{filtered.length > 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginBottom: 36 }}>
|
||||
{chips.map((c) => (
|
||||
<button key={c} className={`chip${category === c ? ' active' : ''}`} onClick={() => setCategory(c)}>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid-3">
|
||||
{filtered.map((m) => (
|
||||
<div key={m.id} className="card" style={{ padding: 26, display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 16 }}>
|
||||
<div className={m.logo_path ? '' : 'placeholder-pattern'} style={{ width: 64, height: 64, flexShrink: 0, borderRadius: 4, display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: 'monospace', fontSize: 9, color: '#a8a099', overflow: 'hidden', background: m.logo_path ? '#fff' : undefined }}>
|
||||
{m.logo_path ? <img src={m.logo_path} alt="" style={{ width: '100%', height: '100%', objectFit: 'contain' }} /> : 'logo'}
|
||||
</div>
|
||||
<div>
|
||||
<div className="serif" style={{ fontSize: 20, fontWeight: 600, lineHeight: 1.15 }}>{m.nom}</div>
|
||||
<div style={{ fontSize: 11, letterSpacing: '.08em', textTransform: 'uppercase', color: 'var(--red)', fontWeight: 600, marginTop: 5 }}>{m.secteur}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<span className="badge-cat">{m.categorie || 'Non classée'}</span>
|
||||
</div>
|
||||
<p style={{ fontSize: 14, lineHeight: 1.6, color: 'var(--gray)', flex: 1, marginBottom: 20 }}>{m.presentation}</p>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: 18, borderTop: '1px solid var(--border-soft)' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--gray-light)' }}>{m.dirigeant}</span>
|
||||
<button className="btn-link" onClick={() => setDetail(m)}>Voir la fiche →</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '80px 20px', color: 'var(--gray-light)' }}>
|
||||
<div className="serif" style={{ fontSize: 26, color: 'var(--ink)', marginBottom: 10 }}>Aucune entreprise trouvée</div>
|
||||
<p style={{ fontSize: 15, marginBottom: 24 }}>Essayez d'autres mots-clés ou réinitialisez les filtres.</p>
|
||||
<button className="btn btn-dark btn-sm" style={{ fontSize: 14, padding: '12px 24px' }} onClick={() => { setSearch(''); setCategory('Toutes'); }}>
|
||||
Réinitialiser
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<MemberModal member={detail} onClose={() => setDetail(null)} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
const VALUES = [
|
||||
{ t: 'Réseau', d: "Un carnet d'adresses vivant, nourri par des rencontres régulières et de vraies affinités." },
|
||||
{ t: 'Convivialité', d: "Des moments d'échange chaleureux, où les relations se nouent avant les contrats." },
|
||||
{ t: 'Performance', d: "L'exigence du haut niveau appliquée au monde de l'entreprise." },
|
||||
{ t: 'Territoire', d: "Un engagement fort pour l'économie et le rayonnement du Grand Est." },
|
||||
];
|
||||
|
||||
const TIMELINE = [
|
||||
{ y: '1996', t: 'Création du Club', d: "Une poignée d'entreprises se rassemblent autour du SLUC Nancy." },
|
||||
{ y: '2005', t: '50 membres', d: "Le réseau s'impose comme un acteur économique local." },
|
||||
{ y: '2015', t: 'Premier gala', d: 'La soirée annuelle devient le rendez-vous phare du Club.' },
|
||||
{ y: '2026', t: '85 membres actifs', d: "Un réseau d'affaires de référence dans la région." },
|
||||
];
|
||||
|
||||
const STATS = [
|
||||
{ n: '85', l: 'entreprises membres' },
|
||||
{ n: '12', l: 'rencontres par an' },
|
||||
{ n: '30 ans', l: 'de partenariat' },
|
||||
{ n: '200+', l: 'invités au gala annuel' },
|
||||
];
|
||||
|
||||
export default function Association() {
|
||||
return (
|
||||
<main>
|
||||
<section style={{ background: 'var(--dark)', color: '#fff' }}>
|
||||
<div style={{ maxWidth: 900, margin: '0 auto', padding: '110px 32px', textAlign: 'center' }}>
|
||||
<div className="kicker on-dark" style={{ letterSpacing: '.22em', marginBottom: 26 }}>L'association</div>
|
||||
<h1 className="serif" style={{ fontWeight: 400, fontSize: 'clamp(38px, 5vw, 56px)', lineHeight: 1.08, marginBottom: 26 }}>
|
||||
Trente ans à réunir le sport<br />et l'entreprise.
|
||||
</h1>
|
||||
<p style={{ fontSize: 18, lineHeight: 1.65, color: '#B7AFA6', maxWidth: 620, margin: '0 auto' }}>
|
||||
Le Business Club est l'association des partenaires économiques du SLUC Nancy Basket.
|
||||
Une communauté de dirigeants qui partagent les valeurs du haut niveau.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="hero-grid" style={{ maxWidth: 1080, margin: '0 auto', padding: '96px 32px 40px', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 64, alignItems: 'center' }}>
|
||||
<div>
|
||||
<div className="kicker" style={{ marginBottom: 16 }}>Notre mission</div>
|
||||
<h2 className="serif" style={{ fontWeight: 500, fontSize: 38, lineHeight: 1.12, marginBottom: 22 }}>
|
||||
Créer des liens qui font grandir les entreprises.
|
||||
</h2>
|
||||
<p style={{ fontSize: 16, lineHeight: 1.7, color: 'var(--gray)', marginBottom: 16 }}>
|
||||
Nous rassemblons chaque année des dirigeants du Grand Est autour de rencontres d'affaires,
|
||||
d'événements exclusifs et de moments de convivialité, dans le cadre unique de la vie d'un
|
||||
club de basket professionnel.
|
||||
</p>
|
||||
<p style={{ fontSize: 16, lineHeight: 1.7, color: 'var(--gray)' }}>
|
||||
Adhérer, c'est développer son réseau, gagner en visibilité et soutenir le sport de haut
|
||||
niveau sur son territoire.
|
||||
</p>
|
||||
</div>
|
||||
<div className="placeholder-pattern" style={{ aspectRatio: '4/3', borderRadius: 5, display: 'flex', alignItems: 'flex-end', padding: 22 }}>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: 11, color: '#a8a099', background: 'var(--bg)', padding: '5px 9px', borderRadius: 2 }}>
|
||||
photo — assemblée des membres
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={{ maxWidth: 1080, margin: '0 auto', padding: '56px 32px' }}>
|
||||
<div className="grid-4">
|
||||
{VALUES.map((v) => (
|
||||
<div key={v.t} className="card" style={{ padding: 26 }}>
|
||||
<div style={{ width: 34, height: 3, background: 'var(--red)', marginBottom: 18 }} />
|
||||
<h3 className="serif" style={{ fontSize: 21, fontWeight: 600, marginBottom: 10 }}>{v.t}</h3>
|
||||
<p style={{ fontSize: 14, lineHeight: 1.6, color: 'var(--gray)' }}>{v.d}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={{ background: 'var(--beige)', marginTop: 56, padding: '90px 0' }}>
|
||||
<div className="hero-grid" style={{ maxWidth: 980, margin: '0 auto', padding: '0 32px', display: 'grid', gridTemplateColumns: '.8fr 1.2fr', gap: 56, alignItems: 'center' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ aspectRatio: '1/1', borderRadius: 6, background: 'repeating-linear-gradient(135deg,#e4dfd8 0 12px,#eeeae3 12px 24px)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: 'monospace', fontSize: 11, color: '#a8a099', marginBottom: 16 }}>
|
||||
portrait
|
||||
</div>
|
||||
<div className="serif" style={{ fontSize: 20, fontWeight: 600 }}>Jean-Marc Lefèvre</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--gray-light)', marginTop: 2 }}>Président du Business Club</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="serif" style={{ fontSize: 60, color: 'var(--red)', lineHeight: 0.5, height: 32 }}>“</div>
|
||||
<p className="serif" style={{ fontSize: 26, lineHeight: 1.45, fontWeight: 400, marginBottom: 22 }}>
|
||||
Notre force, c'est le collectif. Comme sur le terrain, nos membres avancent ensemble,
|
||||
se soutiennent et bâtissent des réussites communes.
|
||||
</p>
|
||||
<p style={{ fontSize: 15, lineHeight: 1.7, color: 'var(--gray)' }}>
|
||||
Depuis sa création, le Club n'a cessé de grandir grâce à l'engagement de dirigeants qui
|
||||
croient à la puissance du réseau. Je vous invite à nous rejoindre.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={{ maxWidth: 1080, margin: '0 auto', padding: '96px 32px' }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 52 }}>
|
||||
<div className="kicker" style={{ marginBottom: 14 }}>Notre histoire</div>
|
||||
<h2 className="serif" style={{ fontWeight: 500, fontSize: 40 }}>30 ans de croissance</h2>
|
||||
</div>
|
||||
<div className="grid-4" style={{ gap: 0 }}>
|
||||
{TIMELINE.map((t) => (
|
||||
<div key={t.y} style={{ padding: '0 20px', borderLeft: '1px solid var(--border)', position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', left: -6, top: 6, width: 11, height: 11, borderRadius: '50%', background: 'var(--red)' }} />
|
||||
<div className="serif" style={{ fontSize: 30, fontWeight: 600, color: 'var(--red)', marginBottom: 8 }}>{t.y}</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 6 }}>{t.t}</div>
|
||||
<div style={{ fontSize: 13.5, lineHeight: 1.55, color: 'var(--gray)' }}>{t.d}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={{ background: 'var(--dark)', color: '#fff', padding: '80px 0' }}>
|
||||
<div className="grid-4" style={{ maxWidth: 1080, margin: '0 auto', padding: '0 32px', gap: 40, textAlign: 'center' }}>
|
||||
{STATS.map((s) => (
|
||||
<div key={s.l}>
|
||||
<div className="serif" style={{ fontSize: 46, fontWeight: 600 }}>{s.n}</div>
|
||||
<div style={{ fontSize: 13.5, color: '#B7AFA6', marginTop: 6 }}>{s.l}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAuth } from '../lib/AuthContext.jsx';
|
||||
import { api, seasonLabel } from '../lib/api.js';
|
||||
import ImageSlot from '../components/ImageSlot.jsx';
|
||||
|
||||
function LoginSection({ title = 'Gérez votre présence au Club', kicker = 'Espace membre' }) {
|
||||
const { login } = useAuth();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setBusy(true);
|
||||
try {
|
||||
await login(email, password);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section style={{ minHeight: 'calc(100vh - 76px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '60px 24px', background: 'var(--dark)' }}>
|
||||
<div style={{ width: '100%', maxWidth: 440 }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 28 }}>
|
||||
<div className="kicker on-dark" style={{ marginBottom: 14 }}>{kicker}</div>
|
||||
<h1 className="serif" style={{ fontWeight: 500, fontSize: 34, color: '#fff', lineHeight: 1.1 }}>{title}</h1>
|
||||
</div>
|
||||
<form onSubmit={submit} style={{ background: '#fff', borderRadius: 8, padding: '32px 30px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<label className="field">Email professionnel
|
||||
<input name="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="vous@entreprise.fr" required maxLength={254} autoComplete="username" />
|
||||
</label>
|
||||
<label className="field">Mot de passe
|
||||
<input name="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="••••••••" required maxLength={200} autoComplete="current-password" />
|
||||
</label>
|
||||
</div>
|
||||
{error && <p className="error-text" style={{ marginTop: 14 }}>{error}</p>}
|
||||
<button type="submit" className="btn btn-red" style={{ width: '100%', marginTop: 22, padding: 14, borderRadius: 3 }} disabled={busy}>
|
||||
{busy ? 'Connexion…' : 'Se connecter'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordCard() {
|
||||
const [form, setForm] = useState({ currentPassword: '', newPassword: '' });
|
||||
const [msg, setMsg] = useState(null);
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
setMsg(null);
|
||||
try {
|
||||
await api.post('/api/auth/change-password', form);
|
||||
setMsg({ ok: true, text: '✓ Mot de passe modifié' });
|
||||
setForm({ currentPassword: '', newPassword: '' });
|
||||
} catch (err) {
|
||||
setMsg({ ok: false, text: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card" style={{ borderRadius: 8, padding: 30, marginTop: 24 }}>
|
||||
<h2 className="serif" style={{ fontSize: 22, fontWeight: 600, marginBottom: 6 }}>Mot de passe</h2>
|
||||
<p style={{ fontSize: 13.5, color: 'var(--gray-light)', marginBottom: 20, lineHeight: 1.55 }}>
|
||||
10 caractères minimum, avec au moins une lettre et un chiffre.
|
||||
</p>
|
||||
<form onSubmit={submit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<label className="field">Mot de passe actuel
|
||||
<input type="password" value={form.currentPassword} onChange={(e) => setForm({ ...form, currentPassword: e.target.value })} required autoComplete="current-password" />
|
||||
</label>
|
||||
<label className="field">Nouveau mot de passe
|
||||
<input type="password" value={form.newPassword} onChange={(e) => setForm({ ...form, newPassword: e.target.value })} required minLength={10} autoComplete="new-password" />
|
||||
</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<button type="submit" className="btn btn-red btn-sm" style={{ fontSize: 14.5, padding: '13px 26px' }}>Changer le mot de passe</button>
|
||||
{msg && <span className={msg.ok ? 'success-text' : 'error-text'}>{msg.text}</span>}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Portal() {
|
||||
const { logout } = useAuth();
|
||||
const [member, setMember] = useState(null);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [form, setForm] = useState(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const season = seasonLabel();
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([api.get('/api/member/profile'), api.get('/api/public/bootstrap')])
|
||||
.then(([p, b]) => {
|
||||
setMember(p.member);
|
||||
setForm(p.member);
|
||||
setCategories(b.categories);
|
||||
})
|
||||
.catch((e) => setError(e.message));
|
||||
}, []);
|
||||
|
||||
if (!form) {
|
||||
return (
|
||||
<section className="container" style={{ padding: '60px 32px' }}>
|
||||
<p style={{ color: 'var(--gray-light)' }}>{error || 'Chargement…'}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const onChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setForm({ ...form, [name]: name === 'categorie_id' ? (value ? Number(value) : null) : value });
|
||||
setSaved(false);
|
||||
};
|
||||
|
||||
const save = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
try {
|
||||
const d = await api.put('/api/member/profile', {
|
||||
nom: form.nom, secteur: form.secteur, categorie_id: form.categorie_id,
|
||||
dirigeant: form.dirigeant, email: form.email || '', tel: form.tel || '',
|
||||
site: form.site || '', presentation: form.presentation || '',
|
||||
});
|
||||
setMember(d.member);
|
||||
setForm(d.member);
|
||||
setSaved(true);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const valide = member?.valide;
|
||||
const catName = categories.find((c) => c.id === form.categorie_id)?.name || 'Non classée';
|
||||
|
||||
return (
|
||||
<section style={{ maxWidth: 1100, margin: '0 auto', padding: '44px 32px 90px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 20, flexWrap: 'wrap', marginBottom: 26 }}>
|
||||
<div>
|
||||
<div className="kicker" style={{ letterSpacing: '.16em', marginBottom: 8 }}>Espace membre</div>
|
||||
<h1 className="serif" style={{ fontWeight: 500, fontSize: 34, lineHeight: 1.1 }}>Bonjour {member.dirigeant}</h1>
|
||||
<div style={{ fontSize: 14, color: 'var(--gray-light)', marginTop: 4 }}>{member.nom}</div>
|
||||
</div>
|
||||
<button className="btn btn-outline-soft btn-sm" onClick={logout}>Se déconnecter</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, background: valide ? '#E8F3EC' : '#FBF0E6', border: `1px solid ${valide ? '#BFE0CB' : '#F0D6BC'}`, color: valide ? '#22623E' : '#8A5A22', borderRadius: 8, padding: '18px 22px' }}>
|
||||
<span style={{ fontSize: 22 }}>{valide ? '✓' : '⏳'}</span>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 15 }}>
|
||||
{valide ? `Adhésion validée pour la saison ${season}` : 'Adhésion à renouveler'}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, opacity: 0.85, marginTop: 2 }}>
|
||||
{valide
|
||||
? "Votre fiche est visible dans l'annuaire et vous pouvez vous inscrire aux rencontres."
|
||||
: "Tant que votre adhésion n'est pas validée par le Club, votre fiche n'apparaît pas dans l'annuaire et vous ne pouvez pas vous inscrire aux rencontres."}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hero-grid" style={{ display: 'grid', gridTemplateColumns: '1.35fr .9fr', gap: 32, marginTop: 30, alignItems: 'start' }}>
|
||||
<div>
|
||||
<div className="card" style={{ borderRadius: 8, padding: 30 }}>
|
||||
<h2 className="serif" style={{ fontSize: 22, fontWeight: 600, marginBottom: 6 }}>Mon profil</h2>
|
||||
<p style={{ fontSize: 13.5, color: 'var(--gray-light)', marginBottom: 24, lineHeight: 1.55 }}>
|
||||
Ces informations alimentent votre fiche dans l'annuaire des membres.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: 22, marginBottom: 24 }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: '.08em', color: 'var(--gray-light)', fontWeight: 600, marginBottom: 8 }}>Logo</div>
|
||||
<div style={{ width: 96, height: 96 }}>
|
||||
<ImageSlot endpoint="/api/member/profile/logo" value={form.logo_path} placeholder="Déposez votre logo" onUploaded={(path) => { setForm((f) => ({ ...f, logo_path: path })); setMember((m) => ({ ...m, logo_path: path })); }} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: '.08em', color: 'var(--gray-light)', fontWeight: 600, marginBottom: 8 }}>Photo dirigeant·e</div>
|
||||
<div style={{ width: 96, height: 96 }}>
|
||||
<ImageSlot circle endpoint="/api/member/profile/photo" value={form.photo_path} placeholder="Déposez votre photo" onUploaded={(path) => { setForm((f) => ({ ...f, photo_path: path })); setMember((m) => ({ ...m, photo_path: path })); }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={save}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<label className="field">Entreprise
|
||||
<input name="nom" value={form.nom} onChange={onChange} required maxLength={120} />
|
||||
</label>
|
||||
<label className="field">Catégorie
|
||||
<select name="categorie_id" value={form.categorie_id || ''} onChange={onChange}>
|
||||
<option value="">Non classée</option>
|
||||
{categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label className="field">Secteur d'activité
|
||||
<input name="secteur" value={form.secteur} onChange={onChange} required maxLength={120} />
|
||||
</label>
|
||||
<label className="field">Dirigeant·e
|
||||
<input name="dirigeant" value={form.dirigeant} onChange={onChange} required maxLength={120} />
|
||||
</label>
|
||||
<label className="field">Présentation
|
||||
<textarea name="presentation" value={form.presentation || ''} onChange={onChange} rows={3} maxLength={2000} />
|
||||
</label>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<label className="field">Email
|
||||
<input name="email" type="email" value={form.email || ''} onChange={onChange} maxLength={254} />
|
||||
</label>
|
||||
<label className="field">Téléphone
|
||||
<input name="tel" value={form.tel || ''} onChange={onChange} maxLength={30} />
|
||||
</label>
|
||||
</div>
|
||||
<label className="field">Site web
|
||||
<input name="site" value={form.site || ''} onChange={onChange} maxLength={200} />
|
||||
</label>
|
||||
</div>
|
||||
{error && <p className="error-text" style={{ marginTop: 12 }}>{error}</p>}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginTop: 24 }}>
|
||||
<button type="submit" className="btn btn-red btn-sm" style={{ fontSize: 14.5, padding: '13px 26px' }}>Enregistrer mon profil</button>
|
||||
{saved && <span className="success-text">✓ Profil enregistré</span>}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<PasswordCard />
|
||||
</div>
|
||||
|
||||
<div style={{ position: 'sticky', top: 96 }}>
|
||||
<div style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: '.1em', color: 'var(--gray-light)', fontWeight: 600, marginBottom: 12 }}>
|
||||
Aperçu de votre fiche annuaire
|
||||
</div>
|
||||
<div className="card" style={{ padding: 26 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 16 }}>
|
||||
<div className={form.logo_path ? '' : 'placeholder-pattern'} style={{ width: 64, height: 64, flexShrink: 0, borderRadius: 4, overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: 'monospace', fontSize: 9, color: '#a8a099', background: form.logo_path ? '#fff' : undefined }}>
|
||||
{form.logo_path ? <img src={form.logo_path} alt="" style={{ width: '100%', height: '100%', objectFit: 'contain' }} /> : 'logo'}
|
||||
</div>
|
||||
<div>
|
||||
<div className="serif" style={{ fontSize: 20, fontWeight: 600, lineHeight: 1.15 }}>{form.nom}</div>
|
||||
<div style={{ fontSize: 11, letterSpacing: '.08em', textTransform: 'uppercase', color: 'var(--red)', fontWeight: 600, marginTop: 5 }}>{form.secteur}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<span className="badge-cat">{catName}</span>
|
||||
</div>
|
||||
<p style={{ fontSize: 14, lineHeight: 1.6, color: 'var(--gray)', marginBottom: 20 }}>{form.presentation}</p>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: 18, borderTop: '1px solid var(--border-soft)' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--gray-light)' }}>{form.dirigeant}</span>
|
||||
<span className="btn-link">Voir la fiche →</span>
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ fontSize: 12.5, color: 'var(--gray-light)', lineHeight: 1.55, marginTop: 14 }}>
|
||||
{valide
|
||||
? "Votre fiche est publiée dans l'annuaire des membres."
|
||||
: "Cette fiche ne sera publiée qu'une fois votre adhésion validée pour la saison en cours."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Espace() {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
if (loading) return <main style={{ minHeight: '60vh' }} />;
|
||||
if (!user || user.role !== 'member') return <main><LoginSection /></main>;
|
||||
return <main><Portal /></main>;
|
||||
}
|
||||
|
||||
export { LoginSection };
|
||||
@@ -0,0 +1,340 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { usePublicData } from '../lib/usePublic.js';
|
||||
import { api, dateParts } from '../lib/api.js';
|
||||
import Modal from '../components/Modal.jsx';
|
||||
|
||||
function InscriptionModal({ rencontre, onClose, onDone }) {
|
||||
const [form, setForm] = useState({ nom: '', entreprise: '', email: '', tel: '' });
|
||||
const [done, setDone] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const { jour, mois } = dateParts(rencontre.date_renc);
|
||||
|
||||
const onChange = (e) => setForm({ ...form, [e.target.name]: e.target.value });
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
try {
|
||||
await api.post(`/api/public/rencontres/${rencontre.id}/inscriptions`, form);
|
||||
setDone(true);
|
||||
onDone?.();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<Modal onClose={onClose} maxWidth={460}>
|
||||
<div style={{ textAlign: 'center', padding: '50px 36px' }}>
|
||||
<div style={{ width: 60, height: 60, borderRadius: '50%', background: '#FBEDEC', color: 'var(--red)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 30, margin: '0 auto 22px' }}>✓</div>
|
||||
<h3 className="serif" style={{ fontSize: 26, fontWeight: 600, marginBottom: 10 }}>Inscription enregistrée</h3>
|
||||
<p style={{ fontSize: 15, color: 'var(--gray)', lineHeight: 1.6, marginBottom: 8 }}>
|
||||
Votre demande pour <strong>{rencontre.titre}</strong> est bien enregistrée.
|
||||
</p>
|
||||
<p style={{ fontSize: 13.5, color: 'var(--gray-light)', marginBottom: 26 }}>
|
||||
Elle sera confirmée par l'équipe du Club.
|
||||
</p>
|
||||
<button className="btn btn-dark btn-sm" style={{ padding: '13px 28px', fontSize: 14 }} onClick={onClose}>Fermer</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
onClose={onClose}
|
||||
maxWidth={460}
|
||||
header={{ kicker: 'Inscription', title: rencontre.titre, meta: `${jour} ${mois} · ${rencontre.heure} · ${rencontre.lieu}` }}
|
||||
>
|
||||
<form onSubmit={submit} style={{ padding: '28px 30px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<label className="field-plain">Nom & prénom
|
||||
<input name="nom" value={form.nom} onChange={onChange} required maxLength={120} />
|
||||
</label>
|
||||
<label className="field-plain">Entreprise
|
||||
<input name="entreprise" value={form.entreprise} onChange={onChange} required maxLength={120} />
|
||||
</label>
|
||||
<label className="field-plain">Email
|
||||
<input name="email" type="email" value={form.email} onChange={onChange} required maxLength={254} />
|
||||
</label>
|
||||
</div>
|
||||
{error && <p className="error-text" style={{ marginTop: 12 }}>{error}</p>}
|
||||
<button type="submit" className="btn btn-red" style={{ width: '100%', marginTop: 22, padding: 14 }}>
|
||||
Confirmer mon inscription
|
||||
</button>
|
||||
<p style={{ fontSize: 12, color: 'var(--gray-light)', textAlign: 'center', marginTop: 12, lineHeight: 1.5 }}>
|
||||
Réservé aux membres à jour de leur adhésion pour la saison en cours.
|
||||
</p>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const { data, reload } = usePublicData();
|
||||
const [inscrRenc, setInscrRenc] = useState(null);
|
||||
const [homeDone, setHomeDone] = useState(false);
|
||||
const [homeError, setHomeError] = useState('');
|
||||
const [form, setForm] = useState({ nom: '', fonction: '', entreprise: '', email: '' });
|
||||
|
||||
const onChange = (e) => setForm({ ...form, [e.target.name]: e.target.value });
|
||||
|
||||
const submitHome = async (e) => {
|
||||
e.preventDefault();
|
||||
setHomeError('');
|
||||
try {
|
||||
await api.post('/api/public/demandes-adhesion', form);
|
||||
setHomeDone(true);
|
||||
} catch (err) {
|
||||
setHomeError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const members = data?.members ?? [];
|
||||
const rencontres = data?.rencontres ?? [];
|
||||
const passees = data?.rencontresPassees ?? [];
|
||||
const content = data?.content ?? {};
|
||||
const marquee = members.concat(members);
|
||||
|
||||
return (
|
||||
<main>
|
||||
{/* HERO */}
|
||||
<section className="container hero-grid" style={{ padding: '64px 32px 40px', display: 'grid', gridTemplateColumns: '1.05fr .95fr', gap: 64, alignItems: 'center' }}>
|
||||
<div>
|
||||
<div className="kicker" style={{ marginBottom: 26 }}>Réseau d'affaires · SLUC Nancy Basket</div>
|
||||
<h1 className="serif" style={{ fontWeight: 500, fontSize: 'clamp(40px, 6vw, 66px)', lineHeight: 1.02, letterSpacing: '-.01em', marginBottom: 26 }}>
|
||||
Le business se joue en <em style={{ fontStyle: 'italic', color: 'var(--red)' }}>équipe</em>.
|
||||
</h1>
|
||||
<p style={{ fontSize: 18, lineHeight: 1.65, color: 'var(--gray)', maxWidth: 480, marginBottom: 38 }}>
|
||||
Depuis 30 ans, le Business Club rassemble les dirigeants du Grand Est autour des valeurs du sport
|
||||
de haut niveau : performance, engagement et esprit collectif.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 14, flexWrap: 'wrap' }}>
|
||||
<a href="#adhesion" style={{ textDecoration: 'none' }}>
|
||||
<button className="btn btn-red">Rejoindre le club</button>
|
||||
</a>
|
||||
<Link to="/association" style={{ textDecoration: 'none' }}>
|
||||
<button className="btn btn-outline">Découvrir l'association</button>
|
||||
</Link>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 42, marginTop: 54, paddingTop: 34, borderTop: '1px solid var(--border)' }}>
|
||||
{[['85', 'entreprises membres'], ['12', 'rencontres / an'], ['30 ans', 'de partenariat']].map(([n, l]) => (
|
||||
<div key={l}>
|
||||
<div className="serif" style={{ fontSize: 34, fontWeight: 600 }}>{n}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--gray-light)', marginTop: 2 }}>{l}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div className={content.hero_photo ? '' : 'placeholder-pattern'} style={{ aspectRatio: '4/5', borderRadius: 4, overflow: 'hidden', boxShadow: '0 30px 60px -30px rgba(27,27,27,.4)', display: 'flex', alignItems: 'flex-end', padding: content.hero_photo ? 0 : 22 }}>
|
||||
{content.hero_photo ? (
|
||||
<img src={content.hero_photo} alt="L'esprit du Club" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: 11, color: '#a8a099', background: 'var(--bg)', padding: '5px 9px', borderRadius: 2 }}>
|
||||
photo — networking en tribune VIP
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ position: 'absolute', left: -30, bottom: 40, background: 'var(--ink)', color: '#fff', padding: '22px 26px', borderRadius: 4, maxWidth: 230, boxShadow: '0 20px 40px -20px rgba(0,0,0,.5)' }}>
|
||||
<div className="serif" style={{ fontSize: 19, lineHeight: 1.3, marginBottom: 6 }}>
|
||||
« {content.hero_quote_text || 'On ne réussit jamais seul.'} »
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#B7AFA6' }}>{content.hero_quote_author || "L'esprit du Club"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* SLUC LINK */}
|
||||
<section className="container" style={{ paddingBottom: 56 }}>
|
||||
<a
|
||||
href="https://www.sluc-nancy-basket.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ display: 'grid', gridTemplateColumns: '110px 1fr auto', alignItems: 'center', gap: 28, background: 'var(--dark)', borderRadius: 6, padding: '28px 32px', textDecoration: 'none', color: 'inherit' }}
|
||||
>
|
||||
<div style={{ aspectRatio: '1/1', width: 110, borderRadius: 8, overflow: 'hidden', background: '#fff', padding: 14 }}>
|
||||
<img src="/assets/logo.jpg" alt="Logo SLUC Nancy Basket" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 11.5, letterSpacing: '.18em', textTransform: 'uppercase', color: 'var(--red-soft)', fontWeight: 600, marginBottom: 8 }}>
|
||||
Partenaire officiel
|
||||
</div>
|
||||
<div className="serif" style={{ fontSize: 22, fontWeight: 600, color: '#fff', marginBottom: 6 }}>SLUC Nancy Basket</div>
|
||||
<div style={{ fontSize: 14, lineHeight: 1.5, color: '#B7AFA6' }}>
|
||||
Retrouvez l'actualité, le calendrier et la billetterie du club sur son site officiel.
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: '#fff', whiteSpace: 'nowrap', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
Voir le site <span style={{ fontSize: 16 }}>↗</span>
|
||||
</div>
|
||||
</a>
|
||||
</section>
|
||||
|
||||
{/* MEMBERS MARQUEE */}
|
||||
<section style={{ padding: '72px 0', background: '#fff', borderTop: '1px solid var(--border)', borderBottom: '1px solid var(--border)' }}>
|
||||
<div className="container" style={{ paddingBottom: 34, display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 24 }}>
|
||||
<div>
|
||||
<div className="kicker" style={{ marginBottom: 12 }}>Ils nous font confiance</div>
|
||||
<h2 className="serif" style={{ fontWeight: 500, fontSize: 36, lineHeight: 1.1 }}>Nos entreprises membres</h2>
|
||||
</div>
|
||||
<Link to="/annuaire" style={{ textDecoration: 'none' }}>
|
||||
<span className="btn-link" style={{ fontSize: 14, whiteSpace: 'nowrap' }}>Voir l'annuaire complet →</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="marquee-mask">
|
||||
<div className="marquee-track">
|
||||
{marquee.map((m, i) => (
|
||||
<div key={`${m.id}-${i}`} style={{ width: 210, flexShrink: 0, background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '22px 20px', display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div className={m.logo_path ? '' : 'placeholder-pattern'} style={{ height: 56, borderRadius: 3, display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: 'monospace', fontSize: 10, color: '#a8a099', overflow: 'hidden', background: m.logo_path ? '#fff' : undefined }}>
|
||||
{m.logo_path ? <img src={m.logo_path} alt="" style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }} /> : 'logo'}
|
||||
</div>
|
||||
<div>
|
||||
<div className="serif" style={{ fontSize: 17, fontWeight: 600, lineHeight: 1.2 }}>{m.nom}</div>
|
||||
<div style={{ fontSize: 11, letterSpacing: '.08em', textTransform: 'uppercase', color: 'var(--red)', fontWeight: 600, marginTop: 6 }}>{m.secteur}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* UPCOMING */}
|
||||
<section className="container" style={{ padding: '96px 32px 40px' }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 52 }}>
|
||||
<div className="kicker" style={{ marginBottom: 14 }}>Agenda</div>
|
||||
<h2 className="serif" style={{ fontWeight: 500, fontSize: 44, lineHeight: 1.08 }}>Prochaines rencontres</h2>
|
||||
</div>
|
||||
<div className="grid-3">
|
||||
{rencontres.map((e) => {
|
||||
const { jour, mois } = dateParts(e.date_renc);
|
||||
const restantes = Math.max(0, e.places - e.inscrits);
|
||||
return (
|
||||
<div key={e.id} className="card" style={{ overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<div className="placeholder-pattern" style={{ height: 170, position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', top: 16, left: 16, background: 'var(--dark)', color: '#fff', borderRadius: 3, padding: '8px 12px', textAlign: 'center', lineHeight: 1.05 }}>
|
||||
<div className="serif" style={{ fontSize: 24, fontWeight: 600 }}>{jour}</div>
|
||||
<div style={{ fontSize: 10, letterSpacing: '.12em', textTransform: 'uppercase', color: '#B7AFA6' }}>{mois}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', flex: 1 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--gray-light)', marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span>{e.heure}</span><span>·</span><span>{e.lieu}</span>
|
||||
</div>
|
||||
<h3 className="serif" style={{ fontSize: 22, fontWeight: 600, lineHeight: 1.2, marginBottom: 10 }}>{e.titre}</h3>
|
||||
<p style={{ fontSize: 14.5, lineHeight: 1.6, color: 'var(--gray)', flex: 1 }}>{e.description}</p>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 22, paddingTop: 18, borderTop: '1px solid var(--border-soft)' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--red)', fontWeight: 600 }}>
|
||||
{restantes > 0 ? `${restantes} places restantes` : 'Complet'}
|
||||
</span>
|
||||
<button className="btn btn-dark btn-sm" style={{ fontSize: 13.5 }} disabled={restantes === 0} onClick={() => setInscrRenc(e)}>
|
||||
S'inscrire
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* PAST */}
|
||||
<section style={{ background: 'var(--beige)', marginTop: 56, padding: '96px 0' }}>
|
||||
<div className="container">
|
||||
<div style={{ marginBottom: 52 }}>
|
||||
<div className="kicker" style={{ marginBottom: 14 }}>Ils y étaient</div>
|
||||
<h2 className="serif" style={{ fontWeight: 500, fontSize: 44, lineHeight: 1.08 }}>Rencontres passées</h2>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 34 }}>
|
||||
{passees.map((p) => (
|
||||
<article key={p.id} className="card hero-grid" style={{ display: 'grid', gridTemplateColumns: '1.15fr .85fr', gap: 40, alignItems: 'center', padding: 36 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, letterSpacing: '.14em', textTransform: 'uppercase', color: 'var(--gray-light)', fontWeight: 600, marginBottom: 12 }}>
|
||||
{p.date_label} · {p.lieu}
|
||||
</div>
|
||||
<h3 className="serif" style={{ fontSize: 30, fontWeight: 600, lineHeight: 1.12, marginBottom: 16 }}>{p.titre}</h3>
|
||||
<p style={{ fontSize: 15.5, lineHeight: 1.7, color: 'var(--gray)', marginBottom: 20 }}>{p.texte}</p>
|
||||
<div style={{ display: 'flex', gap: 20, fontSize: 13, color: 'var(--gray-light)' }}>
|
||||
<span>👥 {p.participants} participants</span>
|
||||
<span>📷 {p.nb_photos} photos</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gridTemplateRows: '1fr 1fr', gap: 10, height: 240 }}>
|
||||
<div className="placeholder-pattern" style={{ gridRow: 'span 2', borderRadius: 3 }} />
|
||||
<div className="placeholder-pattern" style={{ borderRadius: 3 }} />
|
||||
<div className="placeholder-pattern" style={{ borderRadius: 3 }} />
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ADHESION */}
|
||||
<section id="adhesion" style={{ background: 'var(--dark)', color: '#fff', padding: '100px 0' }}>
|
||||
<div className="hero-grid" style={{ maxWidth: 1080, margin: '0 auto', padding: '0 32px', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 70, alignItems: 'center' }}>
|
||||
<div>
|
||||
<div className="kicker on-dark" style={{ marginBottom: 20 }}>Nous rejoindre</div>
|
||||
<h2 className="serif" style={{ fontWeight: 500, fontSize: 46, lineHeight: 1.08, marginBottom: 22 }}>
|
||||
Faites entrer votre entreprise dans le Club.
|
||||
</h2>
|
||||
<p style={{ fontSize: 16.5, lineHeight: 1.65, color: '#B7AFA6', marginBottom: 32 }}>
|
||||
Rejoignez un réseau de 85 dirigeants, accédez à toutes nos rencontres et bénéficiez d'une visibilité au cœur du SLUC Nancy.
|
||||
</p>
|
||||
<ul style={{ listStyle: 'none', display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
{['Accès à toutes les rencontres business', 'Places privilégiées aux matchs', "Présence dans l'annuaire des membres"].map((t) => (
|
||||
<li key={t} style={{ display: 'flex', gap: 12, alignItems: 'flex-start', fontSize: 15, color: '#E7E1D9' }}>
|
||||
<span style={{ color: 'var(--red-soft)', fontWeight: 700 }}>—</span>{t}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div style={{ background: '#fff', color: 'var(--ink)', borderRadius: 6, padding: 38 }}>
|
||||
{homeDone ? (
|
||||
<div style={{ textAlign: 'center', padding: '30px 10px' }}>
|
||||
<div style={{ width: 58, height: 58, borderRadius: '50%', background: '#FBEDEC', color: 'var(--red)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 28, margin: '0 auto 20px' }}>✓</div>
|
||||
<h3 className="serif" style={{ fontSize: 26, fontWeight: 600, marginBottom: 10 }}>Demande envoyée !</h3>
|
||||
<p style={{ fontSize: 15, color: 'var(--gray)', lineHeight: 1.6, marginBottom: 24 }}>
|
||||
Merci pour votre intérêt. Notre équipe vous recontacte sous 48h.
|
||||
</p>
|
||||
<button className="btn btn-outline btn-sm" style={{ fontSize: 14, padding: '12px 24px' }} onClick={() => { setHomeDone(false); setForm({ nom: '', fonction: '', entreprise: '', email: '' }); }}>
|
||||
Nouvelle demande
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={submitHome}>
|
||||
<h3 className="serif" style={{ fontSize: 24, fontWeight: 600, marginBottom: 22 }}>Demande d'adhésion</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<label className="field-plain">Nom & prénom
|
||||
<input name="nom" value={form.nom} onChange={onChange} required maxLength={120} />
|
||||
</label>
|
||||
<label className="field-plain">Fonction
|
||||
<input name="fonction" value={form.fonction} onChange={onChange} maxLength={120} />
|
||||
</label>
|
||||
</div>
|
||||
<label className="field-plain">Entreprise
|
||||
<input name="entreprise" value={form.entreprise} onChange={onChange} required maxLength={120} />
|
||||
</label>
|
||||
<label className="field-plain">Email professionnel
|
||||
<input name="email" type="email" value={form.email} onChange={onChange} required maxLength={254} />
|
||||
</label>
|
||||
</div>
|
||||
{homeError && <p className="error-text" style={{ marginTop: 12 }}>{homeError}</p>}
|
||||
<button type="submit" className="btn btn-red" style={{ width: '100%', marginTop: 24, padding: 15 }}>
|
||||
Envoyer ma demande
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{inscrRenc && (
|
||||
<InscriptionModal rencontre={inscrRenc} onClose={() => setInscrRenc(null)} onDone={reload} />
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/* SLUC Business Club — design system from the Claude Design mock */
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #FAF8F5;
|
||||
--ink: #1B1B1B;
|
||||
--red: #C1272D;
|
||||
--red-dark: #8E1B1F;
|
||||
--red-soft: #E0777A;
|
||||
--gray: #5A544E;
|
||||
--gray-mid: #4A443E;
|
||||
--gray-light: #9A938B;
|
||||
--border: #E7E2DB;
|
||||
--border-soft: #F0EBE4;
|
||||
--input-border: #DDD6CD;
|
||||
--dark: #161514;
|
||||
--footer: #0F0E0D;
|
||||
--beige: #F2EEE8;
|
||||
--admin-bg: #F4F1EC;
|
||||
--green: #2C7A4B;
|
||||
--serif: 'Newsreader', serif;
|
||||
--sans: 'Archivo', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
body { background: var(--bg); font-family: var(--sans); color: var(--ink); overflow-x: hidden; }
|
||||
::selection { background: var(--red); color: #fff; }
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-thumb { background: #d8d2c9; border-radius: 8px; }
|
||||
button { font-family: var(--sans); cursor: pointer; }
|
||||
input, textarea, select { font-family: var(--sans); font-size: 15px; }
|
||||
input:focus, textarea:focus, select:focus { outline: none; border-color: var(--red) !important; }
|
||||
|
||||
@keyframes marquee { from { transform: translateX(0); } to { transform: translateX(-50%); } }
|
||||
.marquee-track { display: flex; gap: 20px; width: max-content; animation: marquee 46s linear infinite; padding: 0 10px; }
|
||||
.marquee-track:hover { animation-play-state: paused; }
|
||||
.marquee-mask {
|
||||
position: relative;
|
||||
-webkit-mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
|
||||
mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
|
||||
}
|
||||
|
||||
.container { max-width: 1180px; margin: 0 auto; padding-left: 32px; padding-right: 32px; }
|
||||
.serif { font-family: var(--serif); }
|
||||
|
||||
.kicker {
|
||||
font-size: 12px; letter-spacing: .2em; text-transform: uppercase;
|
||||
color: var(--red); font-weight: 600;
|
||||
}
|
||||
.kicker.on-dark { color: var(--red-soft); }
|
||||
|
||||
/* Buttons */
|
||||
.btn { border: none; border-radius: 2px; font-weight: 600; font-size: 15px; padding: 16px 30px; transition: background .15s, color .15s, border-color .15s; }
|
||||
.btn-red { background: var(--red); color: #fff; }
|
||||
.btn-red:hover { background: var(--red-dark); }
|
||||
.btn-dark { background: var(--dark); color: #fff; }
|
||||
.btn-dark:hover { background: var(--red); }
|
||||
.btn-outline { background: none; color: var(--ink); border: 1px solid var(--ink); }
|
||||
.btn-outline:hover { background: var(--ink); color: #fff; }
|
||||
.btn-outline-soft { background: none; color: var(--gray); border: 1px solid var(--input-border); }
|
||||
.btn-outline-soft:hover { border-color: var(--dark); }
|
||||
.btn-sm { padding: 11px 20px; font-size: 13.5px; border-radius: 3px; }
|
||||
.btn-link { background: none; border: none; color: var(--red); font-weight: 600; font-size: 13.5px; padding: 0; }
|
||||
.btn-link:hover { color: var(--red-dark); }
|
||||
.btn-link-gray { background: none; border: none; color: var(--gray-light); font-weight: 600; font-size: 13px; padding: 0; }
|
||||
.btn-link-gray:hover { color: var(--red); }
|
||||
|
||||
/* Forms */
|
||||
.field { display: flex; flex-direction: column; gap: 6px; font-size: 12px; font-weight: 600; color: var(--gray-mid); text-transform: uppercase; letter-spacing: .06em; }
|
||||
.field input, .field textarea, .field select {
|
||||
padding: 11px 13px; border: 1px solid var(--input-border); border-radius: 3px;
|
||||
background: var(--bg); text-transform: none; letter-spacing: normal; resize: vertical;
|
||||
}
|
||||
.field-plain { display: flex; flex-direction: column; gap: 6px; font-size: 12.5px; font-weight: 600; color: var(--gray-mid); }
|
||||
.field-plain input {
|
||||
padding: 12px 14px; border: 1px solid var(--input-border); border-radius: 2px; background: var(--bg);
|
||||
}
|
||||
|
||||
/* Cards & badges */
|
||||
.card { background: #fff; border: 1px solid var(--border); border-radius: 5px; }
|
||||
.badge { display: inline-block; font-size: 11.5px; font-weight: 600; padding: 4px 10px; border-radius: 20px; white-space: nowrap; }
|
||||
.badge-green { background: #E8F3EC; color: var(--green); }
|
||||
.badge-amber { background: #FBEFD9; color: #B07D18; }
|
||||
.badge-red { background: #F6E4E4; color: #B0322E; }
|
||||
.badge-cat { font-size: 11.5px; font-weight: 600; color: var(--gray); background: var(--beige); border-radius: 20px; padding: 5px 12px; display: inline-block; }
|
||||
|
||||
.chip { border-radius: 20px; font-size: 13.5px; padding: 9px 18px; border: 1px solid var(--input-border); background: #fff; color: var(--gray); font-weight: 500; }
|
||||
.chip.active { background: var(--red); color: #fff; border-color: var(--red); font-weight: 600; }
|
||||
|
||||
.placeholder-pattern { background: repeating-linear-gradient(135deg, #e9e5df 0 12px, #f2efe9 12px 24px); }
|
||||
|
||||
/* Tables */
|
||||
.table { width: 100%; border-collapse: collapse; }
|
||||
.table th {
|
||||
text-align: left; font-size: 11px; letter-spacing: .08em; text-transform: uppercase;
|
||||
color: var(--gray-light); font-weight: 600; padding: 14px 24px; background: var(--bg);
|
||||
}
|
||||
.table td { padding: 14px 24px; font-size: 13.5px; color: var(--gray); border-top: 1px solid var(--border-soft); }
|
||||
.table td:first-child { font-size: 14px; font-weight: 600; color: var(--ink); }
|
||||
|
||||
/* Modals */
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0; z-index: 100; background: rgba(15, 14, 13, .55);
|
||||
backdrop-filter: blur(3px); display: flex; align-items: center; justify-content: center; padding: 24px;
|
||||
}
|
||||
.modal {
|
||||
background: #fff; border-radius: 8px; width: 100%; max-height: 90vh; overflow: auto;
|
||||
box-shadow: 0 40px 80px -30px rgba(0, 0, 0, .6);
|
||||
}
|
||||
.modal-header { position: relative; background: var(--dark); color: #fff; padding: 24px 30px; }
|
||||
.modal-close {
|
||||
position: absolute; top: 16px; right: 16px; background: rgba(255, 255, 255, .14);
|
||||
border: none; width: 30px; height: 30px; border-radius: 50%; font-size: 16px; color: #fff;
|
||||
}
|
||||
|
||||
/* Admin sidebar */
|
||||
.admin-nav-btn {
|
||||
display: block; width: 100%; text-align: left; background: none; color: #B7AFA6;
|
||||
border: none; border-left: 3px solid transparent; font-size: 14px; font-weight: 500;
|
||||
padding: 12px 14px; border-radius: 3px;
|
||||
}
|
||||
.admin-nav-btn.active { background: rgba(193, 39, 45, .18); color: #fff; border-left-color: var(--red); font-weight: 600; }
|
||||
|
||||
/* Header nav */
|
||||
.nav-btn { background: none; border: none; font-size: 14.5px; font-weight: 500; color: var(--gray-mid); padding: 10px 14px; border-radius: 2px; }
|
||||
.nav-btn:hover, .nav-btn.active { color: var(--red); }
|
||||
|
||||
/* Image slot (upload) */
|
||||
.image-slot {
|
||||
width: 100%; height: 100%; border: 1.5px dashed var(--input-border); border-radius: 8px;
|
||||
display: flex; align-items: center; justify-content: center; text-align: center;
|
||||
font-size: 11px; color: var(--gray-light); background: var(--bg); overflow: hidden;
|
||||
padding: 6px; position: relative;
|
||||
}
|
||||
.image-slot:hover { border-color: var(--red); }
|
||||
.image-slot img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
||||
.image-slot.circle, .image-slot.circle img { border-radius: 50%; }
|
||||
|
||||
.error-text { color: #B0322E; font-size: 13.5px; font-weight: 600; }
|
||||
.success-text { color: var(--green); font-size: 13.5px; font-weight: 600; }
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.grid-2, .grid-3, .grid-4 { grid-template-columns: 1fr !important; }
|
||||
.hero-grid { grid-template-columns: 1fr !important; }
|
||||
}
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
|
||||
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 24px; }
|
||||
.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; }
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3000',
|
||||
'/uploads': 'http://localhost:3000',
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: false,
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user