Ajoute les inscriptions modifiables et les paramètres association
This commit is contained in:
1 parent
c49c45e65f
commit
cf67fdc9de
22 files changed
+442
-77
No files matched your search
@@ -3,7 +3,7 @@ import { Link, Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '../lib/AuthContext.jsx';
|
||||
import { api, dateParts, statutLabel } from '../lib/api.js';
|
||||
import { MembersTab, RencontresTab, InscriptionsTab } from '../components/admin/AdminTabs.jsx';
|
||||
import { CategoriesTab, ContenuTab } from '../components/admin/AdminContent.jsx';
|
||||
import { CategoriesTab, ContenuTab, ParametresTab } from '../components/admin/AdminContent.jsx';
|
||||
import { PastEventsTab } from '../components/admin/AdminPast.jsx';
|
||||
import { UsersTab } from '../components/admin/AdminUsers.jsx';
|
||||
import { EmailCreatorTab } from '../components/admin/AdminEmail.jsx';
|
||||
@@ -22,6 +22,7 @@ const TABS = [
|
||||
{ key: 'inscriptions', icon: '✎', label: 'Inscriptions', title: 'Inscriptions', roles: ['admin', 'moderator'] },
|
||||
{ key: 'categories', icon: '☲', label: 'Catégories', title: 'Catégories', roles: ['admin'] },
|
||||
{ key: 'contenu', icon: '▧', label: 'Contenu du site', title: 'Contenu du site', roles: ['admin'] },
|
||||
{ key: 'parametres', icon: '⚙', label: 'Paramètres', title: "Paramètres de l'association", roles: ['admin'] },
|
||||
{ key: 'emails', icon: '✉', label: "Créateur d'e-mail", title: "Créateur d'e-mail", roles: ['admin'] },
|
||||
{ key: 'utilisateurs', icon: '⚿', label: 'Administrateurs', title: 'Administrateurs & modérateurs', roles: ['admin'] },
|
||||
];
|
||||
@@ -155,6 +156,7 @@ export function AdminShell() {
|
||||
{tab === 'inscriptions' && <InscriptionsTab />}
|
||||
{tab === 'categories' && <CategoriesTab />}
|
||||
{tab === 'contenu' && <ContenuTab />}
|
||||
{tab === 'parametres' && <ParametresTab />}
|
||||
{tab === 'emails' && <EmailCreatorTab />}
|
||||
{tab === 'utilisateurs' && <UsersTab />}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { usePublicData } from '../lib/usePublic.js';
|
||||
import MemberModal from '../components/MemberModal.jsx';
|
||||
import { associationSettings } from '../lib/siteSettings.js';
|
||||
|
||||
export default function Annuaire() {
|
||||
const { data } = usePublicData();
|
||||
@@ -10,12 +11,13 @@ export default function Annuaire() {
|
||||
|
||||
const members = data?.members ?? [];
|
||||
const categories = data?.categories ?? [];
|
||||
const settings = associationSettings(data?.content);
|
||||
|
||||
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);
|
||||
const matchText = !q || `${m.nom} ${m.dirigeant} ${m.secteur} ${m.categorie || ''} ${m.adresse || ''}`.toLowerCase().includes(q);
|
||||
return matchCat && matchText;
|
||||
});
|
||||
}, [members, search, category]);
|
||||
@@ -31,7 +33,7 @@ export default function Annuaire() {
|
||||
{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.
|
||||
Découvrez les entreprises membres de {settings.association_name} et prenez contact directement avec leurs dirigeants.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { usePublicData } from '../lib/usePublic.js';
|
||||
import { associationSettings } from '../lib/siteSettings.js';
|
||||
|
||||
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." },
|
||||
@@ -20,6 +23,20 @@ const STATS = [
|
||||
];
|
||||
|
||||
export default function Association() {
|
||||
const { data } = usePublicData();
|
||||
const settings = associationSettings(data?.content);
|
||||
const officers = [
|
||||
['Vice-présidence', settings.association_vice_president],
|
||||
['Trésorerie', settings.association_treasurer],
|
||||
['Secrétariat', settings.association_secretary],
|
||||
].filter(([, name]) => name);
|
||||
const boardMembers = settings.association_board_members
|
||||
.split(/\r?\n/)
|
||||
.map((name) => name.trim())
|
||||
.filter(Boolean);
|
||||
const hasContact = settings.association_address || settings.association_email
|
||||
|| settings.association_phone || settings.association_contact;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<section style={{ background: 'var(--dark)', color: '#fff' }}>
|
||||
@@ -29,7 +46,7 @@ export default function Association() {
|
||||
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.
|
||||
{settings.association_name} est l'association des partenaires économiques du SLUC Nancy Basket.
|
||||
Une communauté de dirigeants qui partagent les valeurs du haut niveau.
|
||||
</p>
|
||||
</div>
|
||||
@@ -71,14 +88,14 @@ export default function Association() {
|
||||
</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 className="hero-grid" style={{ maxWidth: 980, margin: '0 auto', padding: '0 32px', display: 'grid', gridTemplateColumns: settings.association_president ? '.8fr 1.2fr' : '1fr', gap: 56, alignItems: 'center' }}>
|
||||
{settings.association_president && <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 className="serif" style={{ fontSize: 20, fontWeight: 600 }}>{settings.association_president}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--gray-light)', marginTop: 2 }}>Présidence de l'association</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 }}>
|
||||
@@ -93,6 +110,48 @@ export default function Association() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{(officers.length > 0 || boardMembers.length > 0) && (
|
||||
<section style={{ maxWidth: 1080, margin: '0 auto', padding: '90px 32px 30px' }}>
|
||||
<div className="kicker" style={{ marginBottom: 14 }}>Gouvernance</div>
|
||||
<h2 className="serif" style={{ fontWeight: 500, fontSize: 38, marginBottom: 34 }}>Le conseil d'administration</h2>
|
||||
{officers.length > 0 && (
|
||||
<div className="grid-3" style={{ marginBottom: boardMembers.length ? 38 : 0 }}>
|
||||
{officers.map(([role, name]) => (
|
||||
<div key={role} style={{ borderTop: '3px solid var(--red)', paddingTop: 18 }}>
|
||||
<div style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: '.1em', color: 'var(--gray-light)', fontWeight: 600, marginBottom: 8 }}>{role}</div>
|
||||
<div className="serif" style={{ fontSize: 21, fontWeight: 600 }}>{name}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{boardMembers.length > 0 && (
|
||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 24 }}>
|
||||
<div style={{ fontSize: 12, textTransform: 'uppercase', letterSpacing: '.1em', color: 'var(--gray-light)', fontWeight: 600, marginBottom: 16 }}>Membres du CA</div>
|
||||
<div className="grid-3" style={{ gap: 12 }}>
|
||||
{boardMembers.map((name, index) => <div key={`${name}-${index}`} style={{ fontSize: 15, fontWeight: 600 }}>{name}</div>)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{hasContact && (
|
||||
<section style={{ maxWidth: 1080, margin: '0 auto', padding: '80px 32px 30px' }}>
|
||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 44, display: 'grid', gridTemplateColumns: '1fr 1.4fr', gap: 50 }} className="hero-grid">
|
||||
<div>
|
||||
<div className="kicker" style={{ marginBottom: 14 }}>Contact</div>
|
||||
<h2 className="serif" style={{ fontWeight: 500, fontSize: 36 }}>Nous contacter</h2>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, fontSize: 15, lineHeight: 1.6 }}>
|
||||
{settings.association_contact && <strong>{settings.association_contact}</strong>}
|
||||
{settings.association_address && <span style={{ whiteSpace: 'pre-line', color: 'var(--gray)' }}>{settings.association_address}</span>}
|
||||
{settings.association_email && <a className="btn-link" href={`mailto:${settings.association_email}`}>{settings.association_email}</a>}
|
||||
{settings.association_phone && <a className="btn-link" href={`tel:${settings.association_phone.replace(/[^+\d]/g, '')}`}>{settings.association_phone}</a>}
|
||||
</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>
|
||||
|
||||
@@ -193,7 +193,7 @@ function Portal() {
|
||||
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 || '',
|
||||
site: form.site || '', adresse: form.adresse || '', presentation: form.presentation || '',
|
||||
});
|
||||
setMember(d.member);
|
||||
setForm(d.member);
|
||||
@@ -287,6 +287,9 @@ function Portal() {
|
||||
<label className="field">Site web
|
||||
<input name="site" value={form.site || ''} onChange={onChange} maxLength={200} />
|
||||
</label>
|
||||
<label className="field">Adresse de l'entreprise
|
||||
<input name="adresse" value={form.adresse || ''} onChange={onChange} maxLength={300} placeholder="Numéro, rue, code postal et ville" />
|
||||
</label>
|
||||
</div>
|
||||
{error && <p className="error-text" style={{ marginTop: 12 }}>{error}</p>}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginTop: 24 }}>
|
||||
@@ -316,6 +319,9 @@ function Portal() {
|
||||
<span className="badge-cat">{catName}</span>
|
||||
</div>
|
||||
<p style={{ fontSize: 14, lineHeight: 1.6, color: 'var(--gray)', marginBottom: 20 }}>{form.presentation}</p>
|
||||
{form.adresse && (
|
||||
<p style={{ fontSize: 13, lineHeight: 1.5, color: 'var(--gray-light)', marginBottom: 18 }}>{form.adresse}</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>
|
||||
|
||||
+43
-9
@@ -6,6 +6,7 @@ import { api, dateParts } from '../lib/api.js';
|
||||
import Modal from '../components/Modal.jsx';
|
||||
import PastEventCard from '../components/PastEventCard.jsx';
|
||||
import { AdhesionForm, AdhesionModal } from '../components/AdhesionForm.jsx';
|
||||
import { associationSettings } from '../lib/siteSettings.js';
|
||||
|
||||
export function InscriptionModal({ rencontre, onClose, onDone }) {
|
||||
const { user, login } = useAuth();
|
||||
@@ -13,6 +14,7 @@ export function InscriptionModal({ rencontre, onClose, onDone }) {
|
||||
const [participants, setParticipants] = useState(['']);
|
||||
const [credentials, setCredentials] = useState({ email: '', password: '' });
|
||||
const [done, setDone] = useState(false);
|
||||
const [wasRegistered, setWasRegistered] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const { jour, mois } = dateParts(rencontre.date_renc);
|
||||
@@ -23,6 +25,7 @@ export function InscriptionModal({ rencontre, onClose, onDone }) {
|
||||
api.get(`/api/public/rencontres/${rencontre.id}/inscription`)
|
||||
.then((d) => {
|
||||
setContext(d);
|
||||
setWasRegistered(d.participants.length > 0);
|
||||
setParticipants(d.participants.length ? d.participants.map((p) => p.nom) : [d.member.dirigeant || '']);
|
||||
})
|
||||
.catch((err) => setError(err.message));
|
||||
@@ -67,7 +70,9 @@ export function InscriptionModal({ rencontre, onClose, onDone }) {
|
||||
<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>
|
||||
<h3 className="serif" style={{ fontSize: 26, fontWeight: 600, marginBottom: 10 }}>
|
||||
{wasRegistered ? 'Inscription modifiée' : '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>
|
||||
@@ -84,7 +89,7 @@ export function InscriptionModal({ rencontre, onClose, onDone }) {
|
||||
<Modal
|
||||
onClose={onClose}
|
||||
maxWidth={460}
|
||||
header={{ kicker: 'Inscription', title: rencontre.titre, meta: `${jour} ${mois} · ${rencontre.heure} · ${rencontre.lieu}` }}
|
||||
header={{ kicker: wasRegistered ? 'Modifier mon inscription' : 'Inscription', title: rencontre.titre, meta: `${jour} ${mois} · ${rencontre.heure} · ${rencontre.lieu}` }}
|
||||
>
|
||||
{!user && (
|
||||
<form onSubmit={submitLogin} style={{ padding: '28px 30px' }}>
|
||||
@@ -160,9 +165,25 @@ export function InscriptionModal({ rencontre, onClose, onDone }) {
|
||||
|
||||
export default function Home() {
|
||||
const { data, reload } = usePublicData();
|
||||
const { user } = useAuth();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [inscrRenc, setInscrRenc] = useState(null);
|
||||
const [adhesionOpen, setAdhesionOpen] = useState(false);
|
||||
const [registrations, setRegistrations] = useState({});
|
||||
|
||||
const refreshRegistrations = () => {
|
||||
if (user?.role !== 'member' || user.mustChangePassword) {
|
||||
setRegistrations({});
|
||||
return Promise.resolve();
|
||||
}
|
||||
return api.get('/api/member/inscriptions').then((result) => {
|
||||
setRegistrations(Object.fromEntries(result.inscriptions.map((item) => [item.rencontre_id, item])));
|
||||
}).catch(() => setRegistrations({}));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refreshRegistrations();
|
||||
}, [user]);
|
||||
|
||||
// Registration links in invitation emails point to /?inscription=<id> :
|
||||
// once the public data is loaded, open the matching inscription modal.
|
||||
@@ -195,6 +216,7 @@ export default function Home() {
|
||||
const rencontres = data?.rencontres ?? [];
|
||||
const passees = data?.rencontresPassees ?? [];
|
||||
const content = data?.content ?? {};
|
||||
const settings = associationSettings(content);
|
||||
const marquee = members.concat(members);
|
||||
|
||||
return (
|
||||
@@ -207,7 +229,7 @@ export default function Home() {
|
||||
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
|
||||
Depuis 30 ans, {settings.association_name} 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' }}>
|
||||
@@ -313,6 +335,8 @@ export default function Home() {
|
||||
{rencontres.map((e) => {
|
||||
const { jour, mois } = dateParts(e.date_renc);
|
||||
const restantes = Math.max(0, e.places - e.inscrits);
|
||||
const registration = registrations[e.id];
|
||||
const isRegistered = Boolean(registration);
|
||||
return (
|
||||
<div key={e.id} className="card" style={{ overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<div className={e.image_path ? '' : 'placeholder-pattern'} style={{ height: 170, position: 'relative' }}>
|
||||
@@ -331,14 +355,16 @@ export default function Home() {
|
||||
<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 style={{ fontSize: 13, color: isRegistered ? 'var(--green)' : 'var(--red)', fontWeight: 600 }}>
|
||||
{isRegistered ? '✓ Inscrit' : (restantes > 0 ? `${restantes} places restantes` : 'Complet')}
|
||||
<small style={{ display: 'block', color: 'var(--gray-light)', fontWeight: 400, marginTop: 3 }}>
|
||||
Max. {e.participants_par_compte} par compte
|
||||
{isRegistered
|
||||
? `${registration.participants} participant${registration.participants > 1 ? 's' : ''} · ${registration.confirmee ? 'Confirmée' : 'En attente'}`
|
||||
: `Max. ${e.participants_par_compte} par compte`}
|
||||
</small>
|
||||
</span>
|
||||
<button className="btn btn-dark btn-sm" style={{ fontSize: 13.5 }} disabled={restantes === 0} onClick={() => setInscrRenc(e)}>
|
||||
S'inscrire
|
||||
<button className="btn btn-dark btn-sm" style={{ fontSize: 13.5 }} disabled={!isRegistered && restantes === 0} onClick={() => setInscrRenc(e)}>
|
||||
{isRegistered ? 'Modifier' : "S'inscrire"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -394,7 +420,15 @@ export default function Home() {
|
||||
</section>
|
||||
|
||||
{inscrRenc && (
|
||||
<InscriptionModal key={inscrRenc.id} rencontre={inscrRenc} onClose={closeInscription} onDone={reload} />
|
||||
<InscriptionModal
|
||||
key={inscrRenc.id}
|
||||
rencontre={inscrRenc}
|
||||
onClose={closeInscription}
|
||||
onDone={() => {
|
||||
reload();
|
||||
refreshRegistrations();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{adhesionOpen && <AdhesionModal onClose={closeAdhesion} />}
|
||||
</main>
|
||||
|
||||
Reference in new issue
Block a user