Files
SBC/web/src/pages/Home.jsx
T

464 lines
25 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { usePublicData } from '../lib/usePublic.js';
import { useAuth } from '../lib/AuthContext.jsx';
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();
const [context, setContext] = useState(null);
// Each participant: { nom, imageConsent } — image consent is recorded per
// person (an adult can't consent for another, art. 9 Code civil).
const [participants, setParticipants] = useState([{ nom: '', imageConsent: true }]);
const [attestation, setAttestation] = useState(false);
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);
useEffect(() => {
if (user?.role !== 'member' || user.mustChangePassword) return;
setError('');
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) => ({ nom: p.nom, imageConsent: p.image_consent !== false }))
: [{ nom: d.member.dirigeant || '', imageConsent: true }]
);
// A returning registration already carried the attestation.
setAttestation(d.participants.length > 0);
})
.catch((err) => setError(err.message));
}, [user, rencontre.id]);
const submitLogin = async (e) => {
e.preventDefault();
setError('');
setBusy(true);
try {
await login(credentials.email, credentials.password);
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
};
const changeParticipant = (index, value) => {
setParticipants((current) => current.map((p, i) => (i === index ? { ...p, nom: value } : p)));
};
const toggleParticipantImage = (index) => {
setParticipants((current) => current.map((p, i) => (i === index ? { ...p, imageConsent: !p.imageConsent } : p)));
};
const maxPerAccount = context?.rencontre.participants_par_compte || rencontre.participants_par_compte || 1;
const submit = async (e) => {
e.preventDefault();
setError('');
if (!attestation) {
setError("Merci de confirmer l'attestation sur le droit à l'image.");
return;
}
setBusy(true);
try {
await api.post(`/api/public/rencontres/${rencontre.id}/inscriptions`, { participants, attestation });
setDone(true);
onDone?.();
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
};
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 }}>
{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>
<p style={{ fontSize: 13.5, color: 'var(--gray-light)', marginBottom: 26 }}>
{participants.length} participant{participants.length > 1 ? 's' : ''} — confirmation 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: wasRegistered ? 'Modifier mon inscription' : 'Inscription', title: rencontre.titre, meta: `${jour} ${mois} · ${rencontre.heure} · ${rencontre.lieu}` }}
>
{!user && (
<form onSubmit={submitLogin} style={{ padding: '28px 30px' }}>
<p style={{ fontSize: 14, color: 'var(--gray)', lineHeight: 1.6, marginBottom: 18 }}>
Connectez-vous avec votre compte membre avant de choisir les participants.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<label className="field-plain">Email du compte
<input type="email" value={credentials.email} onChange={(e) => setCredentials({ ...credentials, email: e.target.value })} required autoComplete="username" />
</label>
<label className="field-plain">Mot de passe
<input type="password" value={credentials.password} onChange={(e) => setCredentials({ ...credentials, password: e.target.value })} required autoComplete="current-password" />
</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 }} disabled={busy}>
{busy ? 'Connexion…' : 'Se connecter et continuer'}
</button>
</form>
)}
{user && (user.role !== 'member' || user.mustChangePassword) && (
<div style={{ padding: '30px' }}>
<p className="error-text">
{user.role !== 'member'
? 'Les inscriptions doivent être effectuées avec un compte membre.'
: 'Vous devez d’abord remplacer votre mot de passe temporaire.'}
</p>
<Link to="/espace-membre" className="btn btn-dark" style={{ display: 'block', textAlign: 'center', marginTop: 20, textDecoration: 'none' }}>
Ouvrir mon espace membre
</Link>
</div>
)}
{user?.role === 'member' && !user.mustChangePassword && !context && (
<p style={{ padding: '30px', color: 'var(--gray-light)' }}>{error || 'Chargement de votre compte…'}</p>
)}
{user?.role === 'member' && !user.mustChangePassword && context && (
<form onSubmit={submit} style={{ padding: '28px 30px' }}>
<div style={{ background: 'var(--beige)', padding: '12px 14px', borderRadius: 4, fontSize: 13, color: 'var(--gray)', marginBottom: 18 }}>
Compte : <strong>{context.member.nom}</strong> · maximum {maxPerAccount} participant{maxPerAccount > 1 ? 's' : ''}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{participants.map((p, index) => (
<div key={index} style={{ border: '1px solid var(--border-soft)', borderRadius: 6, padding: '12px 14px' }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end' }}>
<label className="field-plain" style={{ flex: 1 }}>Participant {index + 1} — nom & prénom
<input value={p.nom} onChange={(e) => changeParticipant(index, e.target.value)} required maxLength={120} />
</label>
{participants.length > 1 && (
<button type="button" className="btn btn-outline-soft btn-sm" style={{ padding: '11px 13px' }} onClick={() => setParticipants((current) => current.filter((_, i) => i !== index))} aria-label={`Retirer le participant ${index + 1}`}>×</button>
)}
</div>
<label style={{ display: 'flex', gap: 8, alignItems: 'flex-start', marginTop: 10, fontSize: 12.5, color: 'var(--gray)', cursor: 'pointer' }}>
<input type="checkbox" checked={p.imageConsent} onChange={() => toggleParticipantImage(index)} style={{ marginTop: 2 }} />
<span>Cette personne autorise la publication de son image (photos de l'événement) sur les supports du club.</span>
</label>
</div>
))}
</div>
{participants.length < maxPerAccount && (
<button type="button" className="btn-link" style={{ marginTop: 14 }} onClick={() => setParticipants((current) => [...current, { nom: '', imageConsent: true }])}>
+ Ajouter un participant
</button>
)}
<label style={{ display: 'flex', gap: 8, alignItems: 'flex-start', marginTop: 16, fontSize: 12, color: 'var(--gray)', cursor: 'pointer', lineHeight: 1.5 }}>
<input type="checkbox" checked={attestation} onChange={(e) => setAttestation(e.target.checked)} style={{ marginTop: 2 }} />
<span>J'atteste avoir informé chaque personne inscrite que des photos peuvent être prises et publiées, et avoir recueilli son accord (ou son refus) ci-dessus. Pour un mineur, l'accord est donné par son représentant légal.</span>
</label>
{error && <p className="error-text" style={{ marginTop: 12 }}>{error}</p>}
<button type="submit" className="btn btn-red" style={{ width: '100%', marginTop: 18, padding: 14 }} disabled={busy}>
{busy ? 'Enregistrement…' : `Enregistrer ${participants.length} participant${participants.length > 1 ? 's' : ''}`}
</button>
<p style={{ fontSize: 11, color: 'var(--gray-light)', textAlign: 'center', marginTop: 10, lineHeight: 1.5 }}>
Vous pourrez rouvrir ce formulaire pour modifier les noms et les choix.{' '}
<Link to="/confidentialite" style={{ color: 'var(--red)' }}>Politique de confidentialité</Link>.
</p>
</form>
)}
</Modal>
);
}
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.
useEffect(() => {
const id = Number(searchParams.get('inscription'));
if (!id || !data) return;
const renc = (data.rencontres || []).find((r) => r.id === id);
if (renc) setInscrRenc(renc);
}, [data, searchParams]);
useEffect(() => {
if (searchParams.has('adhesion')) setAdhesionOpen(true);
}, [searchParams]);
const closeInscription = () => {
setInscrRenc(null);
if (searchParams.has('inscription')) setSearchParams({}, { replace: true });
};
const closeAdhesion = () => {
setAdhesionOpen(false);
if (searchParams.has('adhesion')) {
const next = new URLSearchParams(searchParams);
next.delete('adhesion');
setSearchParams(next, { replace: true });
}
};
const members = data?.members ?? [];
const rencontres = data?.rencontres ?? [];
const passees = data?.rencontresPassees ?? [];
const content = data?.content ?? {};
const settings = associationSettings(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, {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' }}>
<button className="btn btn-red" onClick={() => setAdhesionOpen(true)}>Rejoindre le club</button>
<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)' }}>
{[[String(members.length), 'entreprises membres'], [String(passees.length), 'rencontres organisées'], ['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>
{/* OFFICIAL SLUC PARTNER */}
<section className="container" style={{ paddingBottom: 56 }}>
<a
href="https://sluc-basket.fr/"
target="_blank"
rel="noopener noreferrer"
aria-label="Visiter le site officiel du SLUC Nancy Basket"
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: '#000' }}>
<img src="/assets/sluc-nancy-basket.png" alt="Logo officiel du SLUC Nancy Basket" style={{ display: 'block', 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 du
</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="container">
<div className="marquee-mask">
<div className="marquee-fade marquee-fade-left" />
<div className="marquee-fade marquee-fade-right" />
<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>
</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);
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' }}>
{e.image_path && (
<img src={e.image_path} alt="" style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover' }} />
)}
<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: 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 }}>
{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={!isRegistered && restantes === 0} onClick={() => setInscrRenc(e)}>
{isRegistered ? 'Modifier' : "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, display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 24, flexWrap: 'wrap' }}>
<div>
<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>
<Link to="/rencontres-passees" style={{ textDecoration: 'none' }}>
<span className="btn-link" style={{ fontSize: 14, whiteSpace: 'nowrap' }}>Voir toutes les rencontres →</span>
</Link>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 34 }}>
{passees.slice(0, 3).map((p) => (
<PastEventCard key={p.id} event={p} excerptLines={3} />
))}
</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 {members.length} dirigeant{members.length > 1 ? 's' : ''}, 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', "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 }}>
<AdhesionForm />
</div>
</div>
</section>
{inscrRenc && (
<InscriptionModal
key={inscrRenc.id}
rencontre={inscrRenc}
onClose={closeInscription}
onDone={() => {
reload();
refreshRegistrations();
}}
/>
)}
{adhesionOpen && <AdhesionModal onClose={closeAdhesion} />}
</main>
);
}