Initial commit: Club DynaSQY

This commit is contained in:
2026-08-23 09:04:47 +00:00
commit 0875890dc7
145 changed files with 16947 additions and 0 deletions
+75
View File
@@ -0,0 +1,75 @@
import AddMember from './components/AddMember'
import { Routes, Route, Navigate } from 'react-router-dom'
import { useAuth } from './context/AuthContext'
import Login from './components/Login'
import Dashboard from './components/Dashboard'
import MembersList from './components/MembersList'
import MemberDetail from './components/MemberDetail'
import EditProfile from './components/EditProfile'
import GuestsList from './components/GuestsList'
import GuestDetail from './components/GuestDetail'
import AddGuestForm from './components/AddGuestForm'
import EditGuestForm from './components/EditGuestForm'
import RecommendationsList from './components/RecommendationsList'
import AddRecommendationForm from './components/AddRecommendationForm'
import EditRecommendationForm from './components/EditRecommendationForm'
import HonorsList from './components/HonorsList'
import Gallery from './components/Gallery'
import AdminHonors from './components/AdminHonors'
import AdminEvents from './components/AdminEvents'
import AdminUsers from './components/AdminUsers'
import AdminLogs from './components/AdminLogs'
import AdminPresentationEditor from './components/AdminPresentationEditor'
import Profile from './components/Profile'
import ChangePassword from './components/ChangePassword'
import Notifications from './components/Notifications'
import Presentation from './components/Presentation'
import Layout from './components/Layout'
function PrivateRoute({ children }) {
const { user, loading } = useAuth()
if (loading) return <div className="flex items-center justify-center min-h-screen"><p className="text-gray-500">Chargement...</p></div>
return user ? children : <Navigate to="/login" replace />
}
function AdminRoute({ children }) {
const { user, loading } = useAuth()
if (loading) return <div className="flex items-center justify-center min-h-screen"><p className="text-gray-500">Chargement...</p></div>
if (!user) return <Navigate to="/login" replace />
if (user.role !== 'admin') return <Navigate to="/" replace />
return children
}
export default function App() {
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/" element={<PrivateRoute><Layout /></PrivateRoute>}>
<Route index element={<Dashboard />} />
<Route path="members" element={<MembersList />} />
<Route path="members/:id" element={<MemberDetail />} />
<Route path="members/edit/:id" element={<EditProfile />} />
<Route path="guests" element={<GuestsList />} />
<Route path="guests/add" element={<AddGuestForm />} />
<Route path="guests/edit/:id" element={<EditGuestForm />} />
<Route path="guests/:id" element={<GuestDetail />} />
<Route path="recommendations" element={<RecommendationsList />} />
<Route path="recommendations/add" element={<AddRecommendationForm />} />
<Route path="recommendations/edit/:id" element={<EditRecommendationForm />} />
<Route path="honors" element={<HonorsList />} />
<Route path="admin/honors" element={<AdminRoute><AdminHonors /></AdminRoute>} />
<Route path="admin/events" element={<AdminRoute><AdminEvents /></AdminRoute>} />
<Route path="admin/users" element={<AdminRoute><AdminUsers /></AdminRoute>} />
<Route path="admin/logs" element={<AdminRoute><AdminLogs /></AdminRoute>} />
<Route path="admin/presentation-editor" element={<AdminRoute><AdminPresentationEditor /></AdminRoute>} />
<Route path="gallery" element={<Gallery />} />
<Route path="profile" element={<Profile />} />
<Route path="profile/password" element={<ChangePassword />} />
<Route path="notifications" element={<Notifications />} />
<Route path="members/add" element={<AdminRoute><AddMember /></AdminRoute>} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
<Route path="/presentation/:date" element={<PrivateRoute><Presentation /></PrivateRoute>} />
</Routes>
)
}
+206
View File
@@ -0,0 +1,206 @@
import { useState, useEffect, useRef } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
export default function AddGuestForm() {
const { authFetch, user } = useAuth()
const navigate = useNavigate()
const [form, setForm] = useState({
prenom: '', nom: '', societe: '', activite: '',
telephone: '', email: '', site_web: '', commentaires: '',
date_club: '',
})
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const [searchResults, setSearchResults] = useState([])
const [showSearch, setShowSearch] = useState(false)
const [createdGuestId, setCreatedGuestId] = useState(null)
const [sending, setSending] = useState(false)
const [showBlockedModal, setShowBlockedModal] = useState(false)
const searchRef = useRef(null)
useEffect(() => {
const handleClick = (e) => {
if (searchRef.current && !searchRef.current.contains(e.target)) setShowSearch(false)
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [])
const handleNameChange = async (field, value) => {
setForm((f) => ({ ...f, [field]: value }))
const nomVal = field === 'nom' ? value : form.nom
const prenomVal = field === 'prenom' ? value : form.prenom
const q = `${prenomVal} ${nomVal}`.trim()
if (q.length >= 2) {
const r = await authFetch(`/api/guests/search?q=${encodeURIComponent(q)}`)
if (r.ok) { const data = await r.json(); setSearchResults(data); setShowSearch(data.length > 0) }
} else { setShowSearch(false) }
}
const selectExisting = (g) => {
setForm((f) => ({
...f,
prenom: g.prenom,
nom: g.nom,
societe: g.societe || '',
activite: g.activite || '',
telephone: g.telephone || '',
email: g.email || '',
site_web: g.site_web || '',
}))
setShowSearch(false)
}
const handleChange = (e) => {
const { name, value } = e.target
if (name === 'prenom' || name === 'nom') {
handleNameChange(name, value)
} else {
setForm((f) => ({ ...f, [name]: value }))
}
}
const BLOCKED_DATES = ['2026-10-20', '2026-12-15']
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
if (BLOCKED_DATES.includes(form.date_club)) {
setShowBlockedModal(true)
return
}
setBusy(true)
try {
const r = await authFetch('/api/guests', {
method: 'POST',
body: JSON.stringify(form),
})
if (!r.ok) {
const err = await r.json()
throw new Error(err.error || 'Erreur')
}
const data = await r.json()
setCreatedGuestId(data.id)
} catch (err) {
setError(err.message)
} finally {
setBusy(false)
}
}
const handleSendInvitation = () => {
setSending(true)
if (createdGuestId && form.email) {
authFetch(`/api/guests/${createdGuestId}/send-invitation`, { method: 'POST' })
}
navigate('/guests')
}
const handleSkipInvitation = () => {
if (sending) return
navigate('/guests')
}
return (
<div className="space-y-4">
<Link to="/guests" className="text-blue-700 text-sm">&larr; Retour</Link>
<h2 className="text-xl font-bold text-gray-800">Ajouter un(e) invite(e)</h2>
{error && <div className="bg-red-100 text-red-700 rounded-lg p-3 text-sm">{error}</div>}
<form onSubmit={handleSubmit} className="bg-white rounded-xl p-5 shadow-sm space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Invite par</label>
<input type="text" value={`${user?.prenom} ${user?.nom}`} disabled className="w-full px-4 py-3 rounded-lg bg-gray-100 border border-gray-200 text-gray-500 text-base" />
</div>
<div className="grid grid-cols-2 gap-3 relative" ref={searchRef}>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Prenom *</label>
<input name="prenom" required value={form.prenom} onChange={handleChange} className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Nom *</label>
<input name="nom" required value={form.nom} onChange={handleChange} className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
{showSearch && (
<div className="absolute top-full left-0 right-0 z-10 mt-1 bg-white border border-gray-200 rounded-xl shadow-lg max-h-48 overflow-y-auto">
{searchResults.map((g, i) => (
<button key={i} type="button" onClick={() => selectExisting(g)} className="w-full text-left px-4 py-3 hover:bg-blue-50 border-b border-gray-100 last:border-0">
<p className="font-medium text-gray-800">{g.prenom} {g.nom}</p>
<p className="text-xs text-gray-500">{g.societe} — {g.total_visits} visite(s)</p>
</button>
))}
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Societe *</label>
<input name="societe" required value={form.societe} onChange={handleChange} className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Activite *</label>
<input name="activite" required value={form.activite} onChange={handleChange} className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Telephone *</label>
<input name="telephone" type="tel" required value={form.telephone} onChange={handleChange} className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email *</label>
<input name="email" type="email" required value={form.email} onChange={handleChange} className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Date de venue *</label>
<input name="date_club" type="date" required value={form.date_club} onChange={handleChange} className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Site internet</label>
<input name="site_web" type="url" value={form.site_web} onChange={handleChange} placeholder="https://..." className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Commentaires</label>
<textarea name="commentaires" rows={3} value={form.commentaires} onChange={handleChange} className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
<button type="submit" disabled={busy} className="w-full bg-blue-900 text-white py-3 rounded-xl font-semibold text-base hover:bg-blue-800 active:bg-blue-950 transition disabled:opacity-50">
{busy ? 'Enregistrement...' : "Ajouter l'invite(e)"}
</button>
</form>
{showBlockedModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={() => setShowBlockedModal(false)}>
<div className="bg-white rounded-2xl p-6 shadow-xl max-w-sm w-full mx-4 text-center" onClick={(e) => e.stopPropagation()}>
<div className="w-14 h-14 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
<span className="text-2xl">⛔</span>
</div>
<h3 className="text-lg font-bold text-gray-800 mb-2">Date non disponible</h3>
<p className="text-sm text-gray-500 mb-6">Cette date n'accepte pas de nouveaux invités.</p>
<button onClick={() => setShowBlockedModal(false)} className="w-full bg-blue-900 text-white py-3 rounded-xl font-semibold text-sm hover:bg-blue-800 active:bg-blue-950 transition">
OK
</button>
</div>
</div>
)}
{createdGuestId && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={handleSkipInvitation}>
<div className="bg-white rounded-2xl p-6 shadow-xl max-w-sm w-full mx-4 text-center" onClick={(e) => e.stopPropagation()}>
<div className="w-14 h-14 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-4">
<span className="text-2xl">✉</span>
</div>
<h3 className="text-lg font-bold text-gray-800 mb-2">Invité(e) ajouté(e) !</h3>
<p className="text-sm text-gray-500 mb-6">Voulez-vous envoyer l'email d'invitation à <strong>{form.prenom} {form.nom}</strong> maintenant ?</p>
<div className="flex gap-3">
<button onClick={handleSkipInvitation} disabled={sending} className="flex-1 bg-gray-100 text-gray-700 py-3 rounded-xl font-semibold text-sm hover:bg-gray-200 transition disabled:opacity-50">
Non
</button>
<button onClick={handleSendInvitation} disabled={sending} className="flex-1 bg-blue-900 text-white py-3 rounded-xl font-semibold text-sm hover:bg-blue-800 active:bg-blue-950 transition disabled:opacity-50">
{sending ? 'Envoi...' : 'Oui, envoyer'}
</button>
</div>
</div>
</div>
)}
</div>
)
}
+99
View File
@@ -0,0 +1,99 @@
import { useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
export default function AddMember() {
const { authFetch } = useAuth()
const navigate = useNavigate()
const [form, setForm] = useState({
prenom: '', nom: '', societe: '', activite: '',
email: '', telephone: '', role: 'member',
})
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const [busy, setBusy] = useState(false)
const handleChange = (e) => {
const { name, value } = e.target
setForm((f) => ({ ...f, [name]: value }))
}
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setSuccess('')
setBusy(true)
try {
const r = await authFetch('/api/members', {
method: 'POST',
body: JSON.stringify(form),
})
if (!r.ok) {
const err = await r.json()
throw new Error(err.error || 'Erreur')
}
const data = await r.json()
setSuccess(`Membre créé ! Mot de passe par défaut : ${data.defaultPassword}`)
setForm({ prenom: '', nom: '', societe: '', activite: '', email: '', telephone: '', role: 'member' })
} catch (err) {
setError(err.message)
} finally {
setBusy(false)
}
}
return (
<div className="space-y-4">
<Link to="/members" className="text-blue-700 text-sm">&larr; Retour</Link>
<h2 className="text-xl font-bold text-gray-800">Ajouter un membre</h2>
{error && <div className="bg-red-100 text-red-700 rounded-lg p-3 text-sm">{error}</div>}
{success && <div className="bg-green-100 text-green-700 rounded-lg p-3 text-sm">{success}</div>}
<form onSubmit={handleSubmit} className="bg-white rounded-xl p-5 shadow-sm space-y-4">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Prénom *</label>
<input name="prenom" required value={form.prenom} onChange={handleChange} className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Nom *</label>
<input name="nom" required value={form.nom} onChange={handleChange} className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email *</label>
<input name="email" type="email" required value={form.email} onChange={handleChange} className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Société</label>
<input name="societe" value={form.societe} onChange={handleChange} className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Téléphone</label>
<input name="telephone" type="tel" value={form.telephone} onChange={handleChange} className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Activité</label>
<input name="activite" value={form.activite} onChange={handleChange} className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Rôle</label>
<div className="flex gap-3">
<label className="flex items-center gap-2">
<input type="radio" name="role" value="member" checked={form.role === 'member'} onChange={handleChange} className="text-blue-900" />
<span className="text-sm">Membre</span>
</label>
<label className="flex items-center gap-2">
<input type="radio" name="role" value="admin" checked={form.role === 'admin'} onChange={handleChange} className="text-blue-900" />
<span className="text-sm">Admin</span>
</label>
</div>
</div>
<button type="submit" disabled={busy} className="w-full bg-blue-900 text-white py-3 rounded-xl font-semibold text-base hover:bg-blue-800 active:bg-blue-950 transition disabled:opacity-50">
{busy ? 'Création...' : 'Créer le membre'}
</button>
</form>
</div>
)
}
@@ -0,0 +1,121 @@
import { useState, useEffect } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
export default function AddRecommendationForm() {
const { authFetch, user } = useAuth()
const navigate = useNavigate()
const [members, setMembers] = useState([])
const [form, setForm] = useState({
date: new Date().toISOString().split('T')[0],
destinataire_id: '',
nom_prospect: '',
type: 'donnee',
transformee: false,
sans_suite: false,
ca_realise: '',
})
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
useEffect(() => {
authFetch('/api/members').then((r) => r.ok ? r.json() : []).then(setMembers)
}, [])
const handleChange = (e) => {
const { name, value, type, checked } = e.target
if (type === 'checkbox') {
setForm((f) => ({ ...f, [name]: checked, transformee: name === 'sans_suite' && checked ? false : name === 'transformee' ? checked : f.transformee, sans_suite: name === 'transformee' && checked ? false : name === 'sans_suite' ? checked : f.sans_suite }))
} else {
setForm((f) => ({ ...f, [name]: value }))
}
}
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setBusy(true)
try {
const payload = {
...form,
destinataire_id: form.destinataire_id ? Number(form.destinataire_id) : null,
ca_realise: form.ca_realise ? Number(form.ca_realise) : null,
}
const r = await authFetch('/api/recommendations', {
method: 'POST',
body: JSON.stringify(payload),
})
if (!r.ok) {
const err = await r.json()
throw new Error(err.error || 'Erreur')
}
navigate('/recommendations')
} catch (err) {
setError(err.message)
} finally {
setBusy(false)
}
}
return (
<div className="space-y-4">
<Link to="/recommendations" className="text-blue-700 text-sm">&larr; Retour</Link>
<h2 className="text-xl font-bold text-gray-800">Nouvelle recommandation</h2>
{error && <div className="bg-red-100 text-red-700 rounded-lg p-3 text-sm">{error}</div>}
<form onSubmit={handleSubmit} className="bg-white rounded-xl p-5 shadow-sm space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Membre</label>
<input type="text" value={`${user?.prenom} ${user?.nom}`} disabled className="w-full px-4 py-3 rounded-lg bg-gray-100 border border-gray-200 text-gray-500 text-base" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Date</label>
<input name="date" type="date" required value={form.date} onChange={handleChange} className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Type</label>
<div className="flex gap-3">
<label className="flex items-center gap-2">
<input type="radio" name="type" value="donnee" checked={form.type === 'donnee'} onChange={handleChange} className="text-blue-900" />
<span className="text-sm">Donnée</span>
</label>
<label className="flex items-center gap-2">
<input type="radio" name="type" value="recue" checked={form.type === 'recue'} onChange={handleChange} className="text-blue-900" />
<span className="text-sm">Reçue</span>
</label>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Destinataire / Source</label>
<select name="destinataire_id" value={form.destinataire_id} onChange={handleChange} className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base bg-white">
<option value="">— Sélectionner —</option>
{members.filter((m) => m.id !== user?.id).map((m) => (
<option key={m.id} value={m.id}>{m.prenom} {m.nom} {m.societe ? '- ' + m.societe : ''}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Nom du prospect *</label>
<input name="nom_prospect" required value={form.nom_prospect} onChange={handleChange} className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
<label className="flex items-center gap-3">
<input name="transformee" type="checkbox" checked={form.transformee} onChange={handleChange} className="w-5 h-5 rounded border-gray-300 text-blue-900 focus:ring-blue-500" />
<span className="text-sm text-gray-700">Transformée (conclue)</span>
</label>
<label className="flex items-center gap-3">
<input name="sans_suite" type="checkbox" checked={form.sans_suite} onChange={handleChange} className="w-5 h-5 rounded border-gray-300 text-gray-500 focus:ring-gray-400" />
<span className="text-sm text-gray-700">Sans suite</span>
</label>
{form.transformee && !form.sans_suite && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">CA HT réalisé (€)</label>
<input name="ca_realise" type="number" step="0.01" min="0" value={form.ca_realise} onChange={handleChange} className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
{form.ca_realise && <p className="text-sm text-green-600 mt-1">Soit {Number(form.ca_realise * 1.2).toLocaleString('fr-FR', {minimumFractionDigits: 2, maximumFractionDigits: 2})} € TTC</p>}
</div>
)}
<button type="submit" disabled={busy} className="w-full bg-blue-900 text-white py-3 rounded-xl font-semibold text-base hover:bg-blue-800 active:bg-blue-950 transition disabled:opacity-50">
{busy ? 'Enregistrement...' : 'Ajouter la recommandation'}
</button>
</form>
</div>
)
}
+108
View File
@@ -0,0 +1,108 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
export default function AdminEvents() {
const { authFetch } = useAuth()
const [events, setEvents] = useState([])
const [form, setForm] = useState({ date: '', nom_evenement: '', lieu: '', horaire: '' })
const [editing, setEditing] = useState(null)
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const [scraping, setScraping] = useState(false)
const load = () => { authFetch('/api/events').then((r) => r.ok ? r.json() : []).then(setEvents) }
useEffect(load, [])
const resetForm = () => { setForm({ date: '', nom_evenement: '', lieu: '', horaire: '' }); setEditing(null) }
const handleEdit = (e) => {
setForm({ date: e.date.split('T')[0] || e.date, nom_evenement: e.nom_evenement, lieu: e.lieu || '', horaire: e.horaire || '' })
setEditing(e.id)
}
const handleSubmit = async (e) => {
e.preventDefault(); setError(''); setBusy(true)
try {
const r = editing
? await authFetch(`/api/events/${editing}`, { method: 'PUT', body: JSON.stringify(form) })
: await authFetch('/api/events', { method: 'POST', body: JSON.stringify(form) })
if (!r.ok) { const err = await r.json(); throw new Error(err.error || 'Erreur') }
resetForm(); load()
} catch (err) { setError(err.message) } finally { setBusy(false) }
}
const handleDelete = async (id) => {
if (!confirm('Supprimer cet événement ?')) return
await authFetch(`/api/events/${id}`, { method: 'DELETE' }); load()
}
return (
<div className="space-y-4">
<Link to="/" className="text-blue-700 text-sm">&larr; Retour tableau de bord</Link>
<h2 className="text-xl font-bold text-gray-800">Gestion des événements</h2>
{error && <div className="bg-red-100 text-red-700 rounded-lg p-3 text-sm">{error}</div>}
<button onClick={async () => { setScraping(true); setError(''); try { const r = await authFetch('/api/events/scrape', { method: 'POST' }); const d = await r.json(); if (r.ok) { alert(d.message); load() } else setError(d.error || 'Erreur') } catch { setError('Erreur réseau') } finally { setScraping(false) } }}
disabled={scraping}
className="w-full bg-emerald-700 text-white py-3 px-4 rounded-xl font-medium text-center hover:bg-emerald-600 disabled:opacity-50">
{scraping ? 'Synchronisation...' : 'Synchroniser depuis rencontres-dirigeants.com'}
</button>
<button onClick={async () => {
if (!confirm('Supprimer tous les événements passés (avant aujourd\'hui) ?')) return
setError('')
try {
const r = await authFetch('/api/events/past', { method: 'DELETE' })
const d = await r.json()
if (r.ok) { alert(d.message); load() } else setError(d.error || 'Erreur')
} catch { setError('Erreur réseau') }
}} className="w-full bg-red-700 text-white py-3 px-4 rounded-xl font-medium text-center hover:bg-red-600">
Supprimer les événements passés
</button>
<form onSubmit={handleSubmit} className="bg-white rounded-xl p-5 shadow-sm space-y-4">
<h3 className="font-semibold text-gray-700">{editing ? 'Modifier' : 'Ajouter'} un événement</h3>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Nom *</label>
<input required value={form.nom_evenement} onChange={(e) => setForm({ ...form, nom_evenement: e.target.value })} className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Date *</label>
<input type="date" required value={form.date} onChange={(e) => setForm({ ...form, date: e.target.value })} className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Horaire</label>
<input value={form.horaire} onChange={(e) => setForm({ ...form, horaire: e.target.value })} placeholder="ex: 18h30" className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Lieu</label>
<input value={form.lieu} onChange={(e) => setForm({ ...form, lieu: e.target.value })} className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
<div className="flex gap-3">
<button type="submit" disabled={busy} className="flex-1 bg-blue-900 text-white py-3 rounded-xl font-semibold hover:bg-blue-800 disabled:opacity-50">
{busy ? 'Enregistrement...' : editing ? 'Modifier' : 'Ajouter'}
</button>
{editing && <button type="button" onClick={resetForm} className="px-4 py-3 rounded-xl border border-gray-300 text-gray-600 font-semibold">Annuler</button>}
</div>
</form>
<div className="space-y-2">
{events.map((e) => (
<div key={e.id} className="bg-white rounded-xl p-4 shadow-sm flex justify-between items-center">
<div>
<p className="font-semibold text-gray-800">{e.nom_evenement}</p>
<p className="text-sm text-gray-500">{new Date(e.date).toLocaleDateString('fr-FR')} {e.horaire && '- ' + e.horaire} {e.lieu && '| ' + e.lieu}</p>
</div>
<div className="flex gap-2 text-sm font-medium">
<button onClick={() => handleEdit(e)} className="text-blue-600">Modifier</button>
<button onClick={() => handleDelete(e.id)} className="text-red-600">Suppr.</button>
</div>
</div>
))}
</div>
</div>
)
}
+143
View File
@@ -0,0 +1,143 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
export default function AdminHonors() {
const { authFetch } = useAuth()
const [honors, setHonors] = useState([])
const [members, setMembers] = useState([])
const [form, setForm] = useState({ date: '', membre_id: '', societe: '', theme_sujet: '' })
const [editing, setEditing] = useState(null)
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const load = () => {
authFetch('/api/honors').then((r) => r.ok ? r.json() : []).then(setHonors)
authFetch('/api/members').then((r) => r.ok ? r.json() : []).then(setMembers)
}
useEffect(load, [])
const resetForm = () => {
setForm({ date: '', membre_id: '', societe: '', theme_sujet: '' })
setEditing(null)
}
const handleEdit = (h) => {
setForm({
date: h.date.split('T')[0] || h.date,
membre_id: h.membre_id || '',
societe: h.societe || '',
theme_sujet: h.theme_sujet,
})
setEditing(h.id)
}
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setBusy(true)
try {
const payload = {
...form,
membre_id: form.membre_id ? Number(form.membre_id) : null,
}
let r
if (editing) {
r = await authFetch(`/api/honors/${editing}`, { method: 'PUT', body: JSON.stringify(payload) })
} else {
r = await authFetch('/api/honors', { method: 'POST', body: JSON.stringify(payload) })
}
if (!r.ok) {
const err = await r.json()
throw new Error(err.error || 'Erreur')
}
resetForm()
load()
} catch (err) {
setError(err.message)
} finally {
setBusy(false)
}
}
const handleDelete = async (id) => {
if (!confirm('Supprimer cette mise à l\'honneur ?')) return
await authFetch(`/api/honors/${id}`, { method: 'DELETE' })
load()
}
return (
<div className="space-y-4">
<Link to="/" className="text-blue-700 text-sm">&larr; Retour au tableau de bord</Link>
<h2 className="text-xl font-bold text-gray-800">Gestion Admin - Mises à l'honneur</h2>
{error && <div className="bg-red-100 text-red-700 rounded-lg p-3 text-sm">{error}</div>}
<form onSubmit={handleSubmit} className="bg-white rounded-xl p-5 shadow-sm space-y-4">
<h3 className="font-semibold text-gray-700">{editing ? 'Modifier' : 'Ajouter'} une mise à l'honneur</h3>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Date *</label>
<input name="date" type="date" required value={form.date}
onChange={(e) => setForm({ ...form, date: e.target.value })}
className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Membre</label>
<select value={form.membre_id}
onChange={(e) => setForm({ ...form, membre_id: e.target.value })}
className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base bg-white">
<option value="">— Aucun —</option>
{members.map((m) => (
<option key={m.id} value={m.id}>{m.prenom} {m.nom}</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Société</label>
<input value={form.societe}
onChange={(e) => setForm({ ...form, societe: e.target.value })}
className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Thème / Sujet *</label>
<input required value={form.theme_sujet}
onChange={(e) => setForm({ ...form, theme_sujet: e.target.value })}
className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
<div className="flex gap-3">
<button type="submit" disabled={busy}
className="flex-1 bg-blue-900 text-white py-3 rounded-xl font-semibold hover:bg-blue-800 transition disabled:opacity-50">
{busy ? 'Enregistrement...' : editing ? 'Modifier' : 'Ajouter'}
</button>
{editing && (
<button type="button" onClick={resetForm}
className="px-4 py-3 rounded-xl border border-gray-300 text-gray-600 font-semibold">
Annuler
</button>
)}
</div>
</form>
<div className="space-y-2">
{honors.map((h) => (
<div key={h.id} className="bg-white rounded-xl p-4 shadow-sm flex justify-between items-center">
<div>
<p className="font-semibold text-gray-800">{h.theme_sujet}</p>
<p className="text-sm text-gray-500">
{new Date(h.date).toLocaleDateString('fr-FR')} {h.societe && `- ${h.societe}`}
</p>
</div>
<div className="flex gap-2">
<button onClick={() => handleEdit(h)}
className="text-blue-600 text-sm font-medium">Modifier</button>
<button onClick={() => handleDelete(h.id)}
className="text-red-600 text-sm font-medium">Suppr.</button>
</div>
</div>
))}
</div>
</div>
)
}
+91
View File
@@ -0,0 +1,91 @@
import { useState, useEffect } from 'react'
import { useAuth } from '../context/AuthContext'
const ACTION_LABELS = {
connexion: 'Connexion',
changement_mot_de_passe: 'Changement mot de passe',
recommandation_ajout: 'Recommandation ajoutée',
recommandation_modification: 'Recommandation modifiée',
recommandation_suppression: 'Recommandation supprimée',
invite_ajout: 'Invité ajouté',
invite_modification: 'Invité modifié',
invite_suppression: 'Invité supprimé',
invite_email_envoye: 'Email invitation envoyé',
candidature_demarrage: 'Candidature démarrée',
candidature_refus: 'Candidature refusée',
absence_ajout: 'Absence ajoutée',
absence_suppression: 'Absence supprimée',
}
export default function AdminLogs() {
const { authFetch } = useAuth()
const [logs, setLogs] = useState([])
const [page, setPage] = useState(1)
const [pages, setPages] = useState(1)
const [loading, setLoading] = useState(true)
const load = async (p) => {
setLoading(true)
setPage(p)
try {
const r = await authFetch(`/api/activity-logs?page=${p}&limit=50`)
if (r.ok) { const d = await r.json(); setLogs(d.logs); setPages(d.pages) }
} catch {} finally { setLoading(false) }
}
useEffect(() => { load(1) }, [])
return (
<div className="space-y-4">
<h2 className="text-xl font-bold text-gray-800">Journal d'activité</h2>
{loading ? (
<p className="text-center text-gray-400 py-8">Chargement...</p>
) : logs.length === 0 ? (
<p className="text-center text-gray-400 py-8">Aucune activité enregistrée</p>
) : (
<div className="bg-white rounded-xl shadow-sm overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50 text-left text-xs text-gray-500 uppercase">
<th className="px-4 py-3">Date</th>
<th className="px-4 py-3">Utilisateur</th>
<th className="px-4 py-3">Action</th>
<th className="px-4 py-3">Détails</th>
</tr>
</thead>
<tbody className="divide-y">
{logs.map((log) => (
<tr key={log.id} className="hover:bg-gray-50">
<td className="px-4 py-2.5 text-gray-500 whitespace-nowrap">
{new Date(log.created_at).toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })}
</td>
<td className="px-4 py-2.5 whitespace-nowrap font-medium">{log.user_prenom} {log.user_nom}</td>
<td className="px-4 py-2.5 whitespace-nowrap">
<span className="bg-blue-100 text-blue-800 text-xs font-medium px-2 py-0.5 rounded">
{ACTION_LABELS[log.action] || log.action}
</span>
</td>
<td className="px-4 py-2.5 text-gray-600 max-w-xs truncate">
{log.details ? (() => { try { return JSON.stringify(JSON.parse(log.details), null, 1).replace(/[{}"]/g, '').replace(/,/g, ', ').trim() } catch { return log.details } })() : '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{pages > 1 && (
<div className="flex justify-center gap-2">
{Array.from({ length: pages }, (_, i) => i + 1).map((p) => (
<button key={p} onClick={() => load(p)}
className={`w-8 h-8 rounded-lg text-sm font-medium ${p === page ? 'bg-blue-900 text-white' : 'bg-white text-gray-600 border hover:bg-gray-50'}`}>
{p}
</button>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,446 @@
import { useState, useEffect } from 'react'
import { useAuth } from '../context/AuthContext'
const SPHERE_COLORS = {
'Gestion humaine & social': { bg: 'bg-orange-500/20', border: 'border-orange-400', text: 'text-orange-300', hex: '#f97316' },
'Tech & Numérique': { bg: 'bg-blue-500/20', border: 'border-blue-400', text: 'text-blue-300', hex: '#3b82f6' },
'Immo & Espace de vie': { bg: 'bg-green-500/20', border: 'border-green-400', text: 'text-green-300', hex: '#22c55e' },
'Image & Média': { bg: 'bg-yellow-500/20', border: 'border-yellow-400', text: 'text-yellow-300', hex: '#eab308' },
}
const MEMBER_SPHERE = {
'Christelle': 'Gestion humaine & social', 'Estelle': 'Gestion humaine & social', 'Anaïs': 'Gestion humaine & social', 'Morgane': 'Gestion humaine & social', 'Amandine': 'Gestion humaine & social', 'Mathieu': 'Gestion humaine & social',
'Pierre': 'Tech & Numérique',
'Henrique': 'Immo & Espace de vie', 'Patricia': 'Immo & Espace de vie', 'Laetitia': 'Immo & Espace de vie', 'Jennifer': 'Immo & Espace de vie',
'Marie': 'Image & Média', 'Bastien': 'Image & Média', 'Miguel': 'Image & Média',
}
const PROGRAM = [
{ time: '11h00', label: 'Accueil des invités' },
{ time: '11h05', label: 'Rappel des fondamentaux du club d\'affaires' },
{ time: '11h10', label: 'Pitch des membres & des invités' },
{ time: '12h00', label: 'Coffee break' },
{ time: '12h15', label: 'Mise à l\'honneur d\'un membre' },
{ time: '12h25', label: 'Actualités du réseau Dynabuy' },
{ time: '12h30', label: 'Tour de table des recommandations & Clôture' },
]
const VALUES = [
{ title: 'Création du club', sub: 'Mars 2026' },
{ title: 'Nos valeurs', sub: 'Confiance, bienveillance, partage & bonne humeur' },
{ title: 'Fréquence', sub: 'Toutes les 2 semaines' },
{ title: 'Exclusivité', sub: 'Exclusivité des métiers' },
{ title: '1re marque française', sub: 'sur les Clubs d\'Affaires' },
{ title: 'Notre mission', sub: 'Dynamiser l\'économie du territoire & booster son CA' },
]
const LOOKING_FOR = ['Hôtellerie', 'Expert comptable', 'Sécurité Alarme', 'Centre de formation', 'Paysagiste', 'Téléphonie / Internet']
const PROSPECT_METHODS = ['Votre réseau', 'Vos clients', 'LinkedIn / Facebook', 'Événement Networking', 'Prospection téléphonique', 'Club de sport']
const EDITABLE_FIELDS = {
welcome: [
{ key: 'title', label: 'Titre', type: 'text' },
{ key: 'subtitle', label: 'Sous-titre', type: 'text' },
{ key: 'quote', label: 'Citation', type: 'text' },
],
thankyou: [
{ key: 'text', label: 'Texte de remerciement', type: 'text' },
{ key: 'subtext', label: 'Sous-texte', type: 'text' },
],
relax: [
{ key: 'title', label: 'Titre', type: 'text' },
{ key: 'body', label: 'Contenu', type: 'textarea' },
],
program: [
{ key: 'items', label: 'Programme (une ligne par étape : HHhMM | Texte)', type: 'textarea' },
],
values: [
{ key: 'items', label: 'Valeurs (une ligne : Titre | Sous-titre)', type: 'textarea' },
],
lookingfor: [
{ key: 'items', label: 'Recherches (une par ligne)', type: 'textarea' },
],
prospect: [
{ key: 'items', label: 'Méthodes de prospection (une par ligne)', type: 'textarea' },
],
play: [
{ key: 'title', label: 'Titre', type: 'text' },
{ key: 'body', label: 'Sous-titre', type: 'text' },
{ key: 'items', label: 'Étapes (une par ligne)', type: 'textarea' },
],
'pitch-member-intro': [
{ key: 'title', label: 'Titre', type: 'text' },
{ key: 'body', label: 'Sous-titre', type: 'text' },
],
coffee: [
{ key: 'title', label: 'Titre', type: 'text' },
{ key: 'body', label: 'Durée', type: 'text' },
{ key: 'sub', label: 'Texte supplémentaire', type: 'text' },
],
'how-to-recommend': [
{ key: 'title', label: 'Titre', type: 'text' },
{ key: 'body', label: 'Message personnalisé (remplace la liste)', type: 'textarea' },
],
feedback: [
{ key: 'title', label: 'Titre', type: 'text' },
{ key: 'body', label: 'Contenu', type: 'textarea' },
],
join: [
{ key: 'title', label: 'Titre', type: 'text' },
],
final: [
{ key: 'title', label: 'Titre', type: 'text' },
{ key: 'body', label: 'Contenu', type: 'textarea' },
],
}
const SLIDE_LABELS = {
welcome: 'Accueil', thankyou: 'Remerciements', relax: 'Détente',
dreamteam: 'Dream Team', program: 'Programme', values: 'Valeurs',
spheres: 'Sphères', lookingfor: 'Nous recherchons', prospect: 'Prospection',
play: 'À vous de jouer', 'pitch-member-intro': 'Intro pitch membres',
'member-card': 'Fiche membre', 'pitch-guest-intro': 'Intro pitch invités',
'guest-card': 'Fiche invité', 'member-custom': 'Slide personnalisée',
coffee: 'Pause café', honor: 'Mise à l\'honneur', 'next-honor': 'Prochaine mise à l\'honneur',
'club-news': 'Actualités du club', events: 'Événements',
'how-to-recommend': 'Comment recommander', recommendations: 'Recommandations',
feedback: 'Retour invités', join: 'Rejoindre', final: 'Mot de la fin',
}
const STATIC_TYPES = new Set(['welcome', 'thankyou', 'relax', 'program', 'values', 'lookingfor', 'prospect', 'play', 'pitch-member-intro', 'coffee', 'how-to-recommend', 'feedback', 'join', 'final'])
function getDefaultContent(type) {
switch (type) {
case 'welcome': return { title: 'Bienvenue au club d\'affaires', subtitle: 'Dyna Biz Coignières', quote: '"Mieux se connaître pour mieux se recommander"' }
case 'thankyou': return { text: 'Un grand merci à', subtext: 'pour son accueil' }
case 'relax': return { title: 'Un petit mot avant de commencer notre rendez-vous', body: 'Détendez-vous et ne prenez pas de notes.\nVous recevrez cette présentation au format PDF\navec toutes les informations.' }
case 'program': return { items: PROGRAM.map(i => `${i.time} | ${i.label}`).join('\n') }
case 'values': return { items: VALUES.map(i => `${i.title} | ${i.sub}`).join('\n') }
case 'lookingfor': return { items: LOOKING_FOR.join('\n') }
case 'prospect': return { items: PROSPECT_METHODS.join('\n') }
case 'play': return { title: 'À vous de jouer !', body: 'Mieux me connaître', items: ['Mon profil', 'Mon entreprise', 'Mes produits et services', 'Mes avantages concurrentiels', 'De quoi j\'ai besoin ?'].join('\n') }
case 'pitch-member-intro': return { title: 'À vous de jouer !', body: 'Pitch des membres' }
case 'coffee': return { title: 'Pause café', body: '15 minutes', sub: 'Profitez-en pour échanger vos cartes de visite' }
case 'how-to-recommend': return { title: 'Comment formuler une recommandation' }
case 'feedback': return { title: 'Chèr(e)s invité(e)s !', body: 'Dyna Biz Coignières, c\'est aussi votre regard.\n\nVos impressions comptent pour nous.\n\nQu\'est-ce qui vous a marqué ? Surpris ? Donné envie ?\n\nMerci pour votre retour, il compte pour nous\net pour l\'évolution du club.' }
case 'join': return { title: 'Rejoindre un Club d\'Affaires' }
case 'final': return { title: 'Le mot de la faim !', body: 'Place au networking et au buffet' }
default: return {}
}
}
function getSlideKey(slide) {
if (slide.type === 'member-card' && slide.person) return `member-${slide.person.id}`
if (slide.type === 'guest-card' && slide.person) return `guest-${slide.person.id}`
if (slide.type === 'member-custom' && slide.person) return `custom-${slide.person.id}-${slide.filename}`
return slide.type
}
export default function AdminPresentationEditor() {
const { authFetch } = useAuth()
const [dates, setDates] = useState([])
const [selectedDate, setSelectedDate] = useState('')
const [data, setData] = useState(null)
const [slides, setSlides] = useState([])
const [overrides, setOverrides] = useState({})
const [expanded, setExpanded] = useState({})
const [busy, setBusy] = useState(false)
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState('')
useEffect(() => {
authFetch('/api/events').then(r => r.ok ? r.json() : []).then(list => {
const clubDates = list.filter(e => e.nom_evenement === "Club d'Affaires").map(e => e.date.slice(0, 10)).sort()
setDates(clubDates)
if (clubDates.length > 0) {
const today = new Date().toISOString().slice(0, 10)
const next = clubDates.find(d => d >= today) || clubDates[clubDates.length - 1]
setSelectedDate(next)
}
})
}, [authFetch])
useEffect(() => {
if (!selectedDate) return
setBusy(true)
setMessage('')
Promise.all([
authFetch(`/api/presentation/${selectedDate}`).then(r => r.ok ? r.json() : null),
authFetch(`/api/admin/presentation-editor/${selectedDate}`).then(r => r.ok ? r.json() : []),
]).then(([presData, ov]) => {
if (!presData) { setBusy(false); return }
setData(presData)
const ovMap = {}
ov.forEach(o => { ovMap[o.slide_key] = { content: typeof o.content === 'string' ? JSON.parse(o.content) : o.content, hidden: !!o.hidden, sort_order: o.sort_order } })
setOverrides(ovMap)
setBusy(false)
}).catch(() => setBusy(false))
}, [selectedDate, authFetch])
useEffect(() => {
if (!data) return
const { guests: presentGuests } = data
const absenceMap = {}
if (data?.absences) data.absences.forEach(a => { absenceMap[a.membre_id] = a })
const memberSlideMap = {}
if (data?.memberSlides) data.memberSlides.forEach(ms => {
if (!memberSlideMap[ms.member_id]) memberSlideMap[ms.member_id] = []
memberSlideMap[ms.member_id].push(ms)
})
const rawSlides = [
{ type: 'welcome' },
{ type: 'thankyou' },
{ type: 'relax' },
{ type: 'dreamteam', members: data.members },
{ type: 'program' },
{ type: 'values' },
{ type: 'spheres' },
{ type: 'lookingfor' },
{ type: 'prospect' },
{ type: 'play' },
{ type: 'pitch-member-intro' },
...data.members.flatMap((m) => {
const sl = [{ type: 'member-card', person: m, absence: absenceMap[m.id] || null }]
const msList = memberSlideMap[m.id] || []
msList.forEach(ms => sl.push({ type: 'member-custom', person: m, filename: ms.filename }))
return sl
}),
{ type: 'pitch-guest-intro' },
...presentGuests.map(g => ({ type: 'guest-card', person: g })),
{ type: 'coffee' },
{ type: 'honor' },
{ type: 'next-honor' },
{ type: 'club-news' },
{ type: 'events' },
{ type: 'how-to-recommend' },
{ type: 'recommendations' },
{ type: 'feedback' },
{ type: 'join' },
{ type: 'final' },
]
const defaultExpanded = {}
rawSlides.forEach(s => { if (STATIC_TYPES.has(s.type)) defaultExpanded[getSlideKey(s)] = true })
setExpanded(defaultExpanded)
let slideList = rawSlides.map((s, i) => ({ ...s, slide_key: getSlideKey(s), default_index: i }))
const sortOverrides = {}
Object.entries(overrides).forEach(([k, v]) => { if (v.sort_order > 0 || v.sort_order === 0 && v.hidden === false) sortOverrides[k] = v.sort_order })
if (Object.keys(sortOverrides).length > 0) {
slideList.sort((a, b) => {
const oa = sortOverrides[a.slide_key]
const ob = sortOverrides[b.slide_key]
if (oa !== undefined && ob !== undefined) return oa - ob
if (oa !== undefined) return -1
if (ob !== undefined) return 1
return a.default_index - b.default_index
})
}
setSlides(slideList)
}, [data, overrides])
const handleMove = (index, dir) => {
const newSlides = [...slides]
const target = index + dir
if (target < 0 || target >= newSlides.length) return
;[newSlides[index], newSlides[target]] = [newSlides[target], newSlides[index]]
setSlides(newSlides)
}
const handleDragStart = (e, index) => {
e.dataTransfer.setData('text/plain', index)
e.dataTransfer.effectAllowed = 'move'
}
const handleDrop = (e, index) => {
e.preventDefault()
const from = parseInt(e.dataTransfer.getData('text/plain'))
if (isNaN(from) || from === index) return
const newSlides = [...slides]
const [moved] = newSlides.splice(from, 1)
newSlides.splice(index, 0, moved)
setSlides(newSlides)
}
const handleDragOver = (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move' }
const toggleHidden = (slideKey) => {
setOverrides(prev => ({
...prev,
[slideKey]: { ...(prev[slideKey] || {}), hidden: !(prev[slideKey]?.hidden) }
}))
}
const updateContent = (slideKey, fieldKey, value) => {
setOverrides(prev => ({
...prev,
[slideKey]: { ...(prev[slideKey] || {}), content: { ...((prev[slideKey]?.content) || {}), [fieldKey]: value } }
}))
}
const handleSave = async () => {
setSaving(true)
setMessage('')
try {
const list = []
slides.forEach((s, i) => {
const ov = overrides[s.slide_key] || {}
const hasContent = ov.content && Object.keys(ov.content).length > 0
const isHidden = ov.hidden
const hasOrder = ov.sort_order !== undefined
if (hasContent || isHidden || hasOrder) {
list.push({ slide_key: s.slide_key, content: ov.content || null, hidden: !!ov.hidden, sort_order: i })
} else if (i !== s.default_index) {
list.push({ slide_key: s.slide_key, content: null, hidden: false, sort_order: i })
} else {
list.push({ slide_key: s.slide_key, content: null, hidden: false, sort_order: i })
}
})
if (overrides.wheel_mode) {
list.push({ slide_key: 'wheel_mode', content: overrides.wheel_mode.content || { enabled: false }, hidden: false, sort_order: 0 })
}
const r = await authFetch(`/api/admin/presentation-editor/${selectedDate}`, {
method: 'PUT',
body: JSON.stringify({ overrides: list }),
})
if (!r.ok) throw new Error('Erreur lors de la sauvegarde')
setMessage('Modifications enregistrées ✓')
} catch (err) {
setMessage(err.message)
} finally {
setSaving(false)
}
}
const resetSlide = async (slideKey) => {
try {
const r = await authFetch(`/api/admin/presentation-editor/${selectedDate}`, {
method: 'PUT',
body: JSON.stringify({ overrides: [{ slide_key: slideKey, content: null, hidden: false, sort_order: 0 }] }),
})
if (!r.ok) throw new Error('Erreur')
setOverrides(prev => { const n = { ...prev }; delete n[slideKey]; return n })
setMessage('Slide réinitialisée ✓')
} catch (err) {
setMessage(err.message)
}
}
if (dates.length === 0) return <p className="text-center text-gray-400 py-8">Aucune date de présentation à venir</p>
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold text-gray-800">Éditeur de présentation</h2>
<select value={selectedDate} onChange={e => setSelectedDate(e.target.value)}
className="px-4 py-2 rounded-lg border border-gray-300 text-sm bg-white">
{dates.map(d => <option key={d} value={d}>{new Date(d).toLocaleDateString('fr-FR')}</option>)}
</select>
</div>
{message && <div className={`rounded-lg p-3 text-sm ${message.includes('✓') ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>{message}</div>}
{busy ? <p className="text-center text-gray-400 py-8">Chargement...</p> : (
<>
<div className="bg-white rounded-xl p-4 shadow-sm border border-gray-100 flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-lg">🎯</span>
<div>
<p className="text-sm font-semibold text-gray-800">Mode roue</p>
<p className="text-xs text-gray-500">Remplace les fiches membres/invités par une roue de la fortune</p>
</div>
</div>
<button
onClick={() => setOverrides(prev => ({
...prev,
wheel_mode: { content: { enabled: !(prev.wheel_mode?.content?.enabled) } }
}))}
className={`relative w-14 h-7 rounded-full transition-colors ${overrides.wheel_mode?.content?.enabled ? 'bg-blue-900' : 'bg-gray-300'}`}
>
<span className={`absolute top-0.5 left-0.5 w-6 h-6 bg-white rounded-full shadow transition-transform ${overrides.wheel_mode?.content?.enabled ? 'translate-x-7' : ''}`} />
</button>
</div>
<div className="space-y-2">
{slides.map((slide, i) => {
const ov = overrides[slide.slide_key] || {}
const isHidden = !!ov.hidden
const fields = EDITABLE_FIELDS[slide.type] || []
const isOpen = expanded[slide.slide_key] || false
const hasCustom = ov.content && Object.keys(ov.content).length > 0
const label = slide.type === 'member-card' ? `${SLIDE_LABELS[slide.type]} : ${slide.person?.prenom} ${slide.person?.nom}` :
slide.type === 'guest-card' ? `${SLIDE_LABELS[slide.type]} : ${slide.person?.prenom} ${slide.person?.nom}` :
slide.type === 'member-custom' ? `${SLIDE_LABELS[slide.type]} : ${slide.filename}` :
SLIDE_LABELS[slide.type] || slide.type
return (
<div key={slide.slide_key} draggable onDragStart={e => handleDragStart(e, i)} onDragOver={handleDragOver} onDrop={e => handleDrop(e, i)}
className={`bg-white rounded-xl shadow-sm border ${isHidden ? 'opacity-40 border-gray-200' : 'border-gray-100'}`}>
<div className="flex items-center gap-2 px-4 py-3 cursor-grab active:cursor-grabbing">
<span className="text-gray-300 cursor-grab text-sm">⠿</span>
<span className="text-xs text-gray-400 font-mono w-6">{i + 1}</span>
<span className={`text-xs font-bold px-2 py-0.5 rounded-full ${STATIC_TYPES.has(slide.type) ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'}`}>
{STATIC_TYPES.has(slide.type) ? 'Texte' : slide.type === 'member-card' || slide.type === 'guest-card' ? 'Membre' : 'Données'}
</span>
<span className="flex-1 text-sm font-medium text-gray-700 truncate">{label}</span>
<div className="flex items-center gap-1 shrink-0">
<button onClick={() => setExpanded(p => ({ ...p, [slide.slide_key]: !p[slide.slide_key] }))}
className={`text-xs px-2 py-1 rounded ${hasCustom ? 'bg-yellow-100 text-yellow-700' : 'text-gray-400 hover:text-gray-600'}`} title="Éditer">
{hasCustom ? '✎ Modifié' : '✎'}
</button>
<button onClick={() => toggleHidden(slide.slide_key)}
className={`text-xs px-2 py-1 rounded ${isHidden ? 'bg-green-100 text-green-600' : 'text-gray-400 hover:text-gray-600'}`}>
{isHidden ? '🗌 Afficher' : '👁 Cacher'}
</button>
<button onClick={() => handleMove(i, -1)} disabled={i === 0} className="text-gray-400 hover:text-gray-600 disabled:opacity-20 text-sm px-1">▲</button>
<button onClick={() => handleMove(i, 1)} disabled={i === slides.length - 1} className="text-gray-400 hover:text-gray-600 disabled:opacity-20 text-sm px-1">▼</button>
</div>
</div>
{isOpen && fields.length > 0 && (
<div className="px-4 pb-4 pt-1 border-t border-gray-100 space-y-3">
{fields.map(field => {
const defaultContent = getDefaultContent(slide.type)
const val = ov.content?.[field.key] ?? defaultContent[field.key] ?? ''
return (
<div key={field.key}>
<label className="block text-xs font-medium text-gray-600 mb-1">{field.label}</label>
{field.type === 'textarea' ? (
<textarea value={val} onChange={e => updateContent(slide.slide_key, field.key, e.target.value)}
rows={4} className="w-full px-3 py-2 rounded-lg border border-gray-300 text-sm outline-none focus:ring-2 focus:ring-blue-500" />
) : (
<input type="text" value={val} onChange={e => updateContent(slide.slide_key, field.key, e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-gray-300 text-sm outline-none focus:ring-2 focus:ring-blue-500" />
)}
</div>
)
})}
<button onClick={() => resetSlide(slide.slide_key)} className="text-xs text-red-500 hover:text-red-700 underline">Réinitialiser cette slide</button>
</div>
)}
{isOpen && fields.length === 0 && slide.type !== 'dreamteam' && slide.type !== 'spheres' && (
<div className="px-4 pb-4 pt-1 border-t border-gray-100">
<p className="text-xs text-gray-400">Cette slide est automatique et ne peut pas être éditée.</p>
</div>
)}
</div>
)
})}
</div>
<div className="bg-white rounded-xl p-4 shadow-sm border border-gray-100 text-center">
<button onClick={handleSave} disabled={saving}
className="bg-blue-900 text-white px-8 py-3 rounded-xl font-semibold text-sm hover:bg-blue-800 transition disabled:opacity-50">
{saving ? 'Enregistrement...' : 'Enregistrer les modifications'}
</button>
</div>
</>
)}
</div>
)
}
+43
View File
@@ -0,0 +1,43 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
export default function AdminUsers() {
const { authFetch } = useAuth()
const [users, setUsers] = useState([])
const load = () => { authFetch('/api/admin/users').then((r) => r.ok ? r.json() : []).then(setUsers) }
useEffect(load, [])
const toggleRole = async (u) => {
const newRole = u.role === 'admin' ? 'member' : 'admin'
if (!confirm(`Passer ${u.prenom} ${u.nom} en rôle "${newRole}" ?`)) return
const r = await authFetch(`/api/admin/users/${u.id}/role`, { method: 'PUT', body: JSON.stringify({ role: newRole }) })
if (r.ok) load()
}
return (
<div className="space-y-4">
<Link to="/" className="text-blue-700 text-sm">&larr; Retour</Link>
<h2 className="text-xl font-bold text-gray-800">Gestion des droits</h2>
<div className="space-y-2">
{users.map((u) => (
<div key={u.id} className="bg-white rounded-xl p-4 shadow-sm flex justify-between items-center">
<div>
<p className="font-semibold text-gray-800">{u.prenom} {u.nom}</p>
<p className="text-sm text-gray-500">{u.email}</p>
</div>
<div className="flex items-center gap-2">
<span className={`text-xs font-semibold px-2 py-1 rounded-full ${u.role === 'admin' ? 'bg-purple-100 text-purple-700' : 'bg-gray-100 text-gray-600'}`}>
{u.role}
</span>
<button onClick={() => toggleRole(u)} className="text-xs bg-blue-900 text-white px-3 py-1.5 rounded-lg">
Changer
</button>
</div>
</div>
))}
</div>
</div>
)
}
@@ -0,0 +1,48 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
export default function ChangePassword() {
const { authFetch } = useAuth()
const [form, setForm] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' })
const [error, setError] = useState('')
const [success, setSuccess] = useState(false)
const [busy, setBusy] = useState(false)
const handleSubmit = async (e) => {
e.preventDefault(); setError(''); setSuccess(false)
if (form.newPassword !== form.confirmPassword) { setError('Les mots de passe ne correspondent pas'); return }
setBusy(true)
try {
const r = await authFetch('/api/auth/password', { method: 'PUT', body: JSON.stringify({ currentPassword: form.currentPassword, newPassword: form.newPassword }) })
if (!r.ok) { const err = await r.json(); throw new Error(err.error || 'Erreur') }
setSuccess(true); setForm({ currentPassword: '', newPassword: '', confirmPassword: '' })
} catch (err) { setError(err.message) } finally { setBusy(false) }
}
return (
<div className="space-y-4">
<Link to="/" className="text-blue-700 text-sm">&larr; Retour</Link>
<h2 className="text-xl font-bold text-gray-800">Changer mon mot de passe</h2>
{error && <div className="bg-red-100 text-red-700 rounded-lg p-3 text-sm">{error}</div>}
{success && <div className="bg-green-100 text-green-700 rounded-lg p-3 text-sm">Mot de passe modifié avec succès</div>}
<form onSubmit={handleSubmit} className="bg-white rounded-xl p-5 shadow-sm space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Mot de passe actuel</label>
<input type="password" required value={form.currentPassword} onChange={(e) => setForm({ ...form, currentPassword: e.target.value })} className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Nouveau mot de passe</label>
<input type="password" required minLength={6} value={form.newPassword} onChange={(e) => setForm({ ...form, newPassword: e.target.value })} className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Confirmer le nouveau mot de passe</label>
<input type="password" required value={form.confirmPassword} onChange={(e) => setForm({ ...form, confirmPassword: e.target.value })} className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
<button type="submit" disabled={busy} className="w-full bg-blue-900 text-white py-3 rounded-xl font-semibold hover:bg-blue-800 disabled:opacity-50">
{busy ? 'Enregistrement...' : 'Changer le mot de passe'}
</button>
</form>
</div>
)
}
+112
View File
@@ -0,0 +1,112 @@
import { useRef, useEffect, useState, useMemo, useCallback } from 'react'
export default function CropModal({ file, onCrop, onCancel }) {
const containerRef = useRef(null)
const cropRef = useRef(null)
const [ready, setReady] = useState(false)
const [cropSize, setCropSize] = useState(200)
const [cropLeft, setCropLeft] = useState(0)
const [cropTop, setCropTop] = useState(0)
const [displayW, setDisplayW] = useState(0)
const [displayH, setDisplayH] = useState(0)
const [naturalW, setNaturalW] = useState(0)
const [naturalH, setNaturalH] = useState(0)
const drag = useRef(null)
const src = useMemo(() => URL.createObjectURL(file), [file])
const onImgLoad = useCallback((e) => {
const img = e.target
const nw = img.naturalWidth
const nh = img.naturalHeight
setNaturalW(nw)
setNaturalH(nh)
const cw = containerRef.current?.clientWidth || 400
const ch = containerRef.current?.clientHeight || 400
const scale = Math.min(cw / nw, ch / nh, 1)
const dw = Math.round(nw * scale)
const dh = Math.round(nh * scale)
setDisplayW(dw)
setDisplayH(dh)
const sz = Math.round(Math.min(dw, dh) * 0.8)
setCropSize(sz)
setCropLeft(Math.round((dw - sz) / 2))
setCropTop(Math.round((dh - sz) / 2))
setReady(true)
}, [])
const handlePointerDown = (e) => {
e.preventDefault()
const p = e.touches ? e.touches[0] : e
drag.current = { x: p.clientX, y: p.clientY, left: cropLeft, top: cropTop }
}
const handlePointerMove = (e) => {
if (!drag.current) return
const p = e.touches ? e.touches[0] : e
const dx = p.clientX - drag.current.x
const dy = p.clientY - drag.current.y
let nl = drag.current.left + dx
let nt = drag.current.top + dy
const maxL = displayW - cropSize
const maxT = displayH - cropSize
nl = Math.max(0, Math.min(nl, maxL))
nt = Math.max(0, Math.min(nt, maxT))
setCropLeft(nl)
setCropTop(nt)
}
const handlePointerUp = () => { drag.current = null }
const handleConfirm = () => {
const img = cropRef.current
if (!img || !ready) return
const sx = naturalW / displayW
const sy = naturalH / displayH
const c = document.createElement('canvas')
c.width = 400
c.height = 400
const ctx = c.getContext('2d')
ctx.drawImage(img, cropLeft * sx, cropTop * sy, cropSize * sx, cropSize * sy, 0, 0, 400, 400)
const dataUrl = c.toDataURL('image/jpeg', 0.9)
const bin = atob(dataUrl.split(',')[1])
const len = bin.length
const arr = new Uint8Array(len)
for (let i = 0; i < len; i++) arr[i] = bin.charCodeAt(i)
const blob = new Blob([arr], { type: 'image/jpeg' })
if (blob) onCrop(blob)
}
useEffect(() => () => URL.revokeObjectURL(src), [src])
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onCancel}>
<div className="bg-white rounded-2xl p-4 shadow-xl max-w-lg w-full mx-4" onClick={(e) => e.stopPropagation()}>
<div ref={containerRef} className="relative max-h-[60vh] overflow-hidden rounded-lg bg-gray-100 flex items-center justify-center select-none touch-none"
onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} onPointerLeave={handlePointerUp}>
<img ref={cropRef} src={src} onLoad={onImgLoad} alt=""
className="max-w-full max-h-[55vh] block"
style={ready ? { width: displayW, height: displayH } : {}} draggable={false} />
{ready && (
<div className="absolute" style={{ left: cropLeft, top: cropTop, width: cropSize, height: cropSize }}>
<div className="absolute inset-0 shadow-[0_0_0_9999px_rgba(0,0,0,0.45)] rounded-sm pointer-events-none" />
<div className="absolute inset-0 border-2 border-white rounded-sm" onPointerDown={handlePointerDown} style={{ touchAction: 'none', cursor: 'move' }} />
<div className="absolute -top-0.5 -left-0.5 w-1.5 h-1.5 bg-white rounded-sm" />
<div className="absolute -top-0.5 -right-0.5 w-1.5 h-1.5 bg-white rounded-sm" />
<div className="absolute -bottom-0.5 -left-0.5 w-1.5 h-1.5 bg-white rounded-sm" />
<div className="absolute -bottom-0.5 -right-0.5 w-1.5 h-1.5 bg-white rounded-sm" />
</div>
)}
</div>
{!ready && <p className="text-center text-gray-400 py-4">Chargement...</p>}
<div className="flex gap-3 mt-4">
<button onClick={onCancel} className="flex-1 bg-gray-100 text-gray-700 py-3 rounded-xl font-semibold text-sm hover:bg-gray-200 transition">
Annuler
</button>
<button onClick={handleConfirm} className="flex-1 bg-blue-900 text-white py-3 rounded-xl font-semibold text-sm hover:bg-blue-800 transition disabled:opacity-50" disabled={!ready}>
Confirmer
</button>
</div>
</div>
</div>
)
}
+245
View File
@@ -0,0 +1,245 @@
import { useState, useEffect } from 'react'
import { useAuth } from '../context/AuthContext'
export default function Dashboard() {
const { authFetch, user } = useAuth()
const [data, setData] = useState(null)
const [filter, setFilter] = useState('rolling')
const [loading, setLoading] = useState(true)
const load = async (f) => {
setLoading(true)
try {
const r = await authFetch(`/api/stats/dashboard?filter=${f}`)
if (r.ok) setData(await r.json())
} catch {} finally {
setLoading(false)
}
}
useEffect(() => {
load(filter)
}, [filter])
const filters = [
{ key: 'month', label: 'Ce mois' },
{ key: 'rolling', label: '12 mois' },
{ key: 'year', label: 'Année' },
]
if (loading && !data) {
return <div className="text-center text-gray-500 mt-8">Chargement...</div>
}
const { counts, prochaine_seance, top_donnees, top_invites, top_absences, top_conversion, candidatures, prochaines_honors, prochains_evenements, alertes_echeance } = data || {}
const gm = data?.guest_metrics || {}
const maxRecs = Math.max(...(top_donnees || []).map(m => m.total_recs), 1)
const maxInvites = Math.max(...(top_invites || []).map(m => m.total_invites), 1)
const maxAbsences = Math.max(...(top_absences || []).map(m => m.total_absences), 1)
const maxConversion = Math.max(...(top_conversion || []).map(m => m.total_convertis), 1)
return (
<div className="space-y-4">
<h2 className="text-xl font-bold text-gray-800">Tableau de bord</h2>
<div className="flex gap-2">
{filters.map((f) => (
<button
key={f.key}
onClick={() => setFilter(f.key)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition ${
filter === f.key ? 'bg-blue-900 text-white' : 'bg-white text-gray-600 border'
}`}
>
{f.label}
</button>
))}
</div>
{prochaine_seance && (
<div className="bg-indigo-50 border border-indigo-200 rounded-xl p-4 text-center">
<p className="text-xs text-indigo-600 font-medium">Prochain rendez-vous</p>
<p className="text-lg font-bold text-indigo-900">Club d'Affaires</p>
<p className="text-sm text-indigo-700">{new Date(prochaine_seance.date).toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}</p>
<div className="grid grid-cols-2 gap-2 mt-3">
<div className="bg-white/60 rounded-lg p-2">
<p className="text-2xl font-bold text-indigo-900">{prochaine_seance.invites}</p>
<p className="text-xs text-indigo-600">invité(s) prévu(s)</p>
</div>
<div className="bg-white/60 rounded-lg p-2">
<p className="text-2xl font-bold text-green-600">{prochaine_seance.acceptees || 0}</p>
<p className="text-xs text-green-600">Acceptées</p>
</div>
<div className="bg-white/60 rounded-lg p-2">
<p className="text-2xl font-bold text-red-600">{prochaine_seance.refusees || 0}</p>
<p className="text-xs text-red-600">Refusées</p>
</div>
<div className="bg-white/60 rounded-lg p-2">
<p className="text-2xl font-bold text-blue-600">{prochaine_seance.secondes_visites || 0}</p>
<p className="text-xs text-blue-600">2e visite(s)</p>
</div>
</div>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div className="bg-indigo-50 rounded-xl p-3 shadow-sm text-center">
<p className="text-2xl font-bold text-indigo-900">{gm.total_invites || 0}</p>
<p className="text-xs text-indigo-600 font-medium">Total invités depuis la création du Club</p>
</div>
<div className="bg-blue-50 rounded-xl p-3 shadow-sm text-center">
<p className="text-2xl font-bold text-blue-900">{gm.secondes_visites || 0}</p>
<p className="text-xs text-blue-600 font-medium">2e visite depuis la création du Club</p>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="bg-white rounded-xl p-4 shadow-sm">
<p className="text-xs text-gray-500">Recommandations</p>
<p className="text-2xl font-bold text-blue-900">{counts?.total || 0}</p>
</div>
<div className="bg-white rounded-xl p-4 shadow-sm">
<p className="text-xs text-gray-500">CA estimé</p>
<p className="text-2xl font-bold text-green-700">{Number(counts?.ca_total || 0).toLocaleString()} €</p>
</div>
<div className="bg-white rounded-xl p-4 shadow-sm">
<p className="text-xs text-gray-500">Transformées</p>
<p className="text-2xl font-bold text-orange-600">{counts?.transformees || 0}</p>
</div>
<div className="bg-white rounded-xl p-4 shadow-sm">
<p className="text-xs text-gray-500">Taux trans.</p>
<p className="text-2xl font-bold text-purple-700">{counts?.taux_transformation || 0}%</p>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="bg-white rounded-xl p-3 shadow-sm">
<h3 className="font-semibold text-gray-700 mb-2 text-sm">Top 5 Recos</h3>
{top_donnees?.length > 0 ? (
<div className="space-y-1.5">
{top_donnees.map((m, i) => (
<div key={m.id} className="relative">
<div className="absolute inset-y-0 left-0 bg-blue-100 rounded" style={{ width: `${(m.total_recs / maxRecs) * 100}%` }}></div>
<div className="relative flex justify-between text-xs px-1.5 py-1">
<span className="truncate">{i + 1}. {m.prenom} {m.nom}</span>
<span className="font-bold text-blue-900 ml-1">{m.total_recs}</span>
</div>
</div>
))}
</div>
) : (
<p className="text-gray-400 text-xs">Aucune donnée</p>
)}
</div>
<div className="bg-white rounded-xl p-3 shadow-sm">
<h3 className="font-semibold text-gray-700 mb-2 text-sm">Top 5 Invités</h3>
{top_invites?.length > 0 ? (
<div className="space-y-1.5">
{top_invites.map((m, i) => (
<div key={m.invite_par_id} className="relative">
<div className="absolute inset-y-0 left-0 bg-green-100 rounded" style={{ width: `${(m.total_invites / maxInvites) * 100}%` }}></div>
<div className="relative flex justify-between text-xs px-1.5 py-1">
<span className="truncate">{i + 1}. {m.prenom} {m.nom}</span>
<span className="font-bold text-green-800 ml-1">{m.total_invites}</span>
</div>
</div>
))}
</div>
) : (
<p className="text-gray-400 text-xs">Aucune donnée</p>
)}
</div>
</div>
{user?.role === 'admin' && top_absences?.length > 0 && (
<div className="bg-white rounded-xl p-3 shadow-sm">
<h3 className="font-semibold text-gray-700 mb-2 text-sm">Top Absences</h3>
<div className="space-y-1.5">
{top_absences.map((m, i) => (
<div key={m.id} className="relative">
<div className="absolute inset-y-0 left-0 bg-red-100 rounded" style={{ width: `${(m.total_absences / maxAbsences) * 100}%` }}></div>
<div className="relative flex justify-between text-xs px-1.5 py-1">
<span className="truncate">{i + 1}. {m.prenom} {m.nom}</span>
<span className="font-bold text-red-700 ml-1">{m.total_absences}</span>
</div>
</div>
))}
</div>
</div>
)}
{top_conversion?.length > 0 && (
<div className="bg-white rounded-xl p-3 shadow-sm">
<h3 className="font-semibold text-gray-700 mb-2 text-sm">Top Conversion (invité → membre)</h3>
<div className="space-y-1.5">
{top_conversion.map((m, i) => (
<div key={m.id} className="relative">
<div className="absolute inset-y-0 left-0 bg-yellow-100 rounded" style={{ width: `${(m.total_convertis / maxConversion) * 100}%` }}></div>
<div className="relative flex justify-between text-xs px-1.5 py-1">
<span className="truncate">{i + 1}. {m.prenom} {m.nom}</span>
<span className="font-bold text-yellow-800 ml-1">{m.total_convertis}</span>
</div>
</div>
))}
</div>
</div>
)}
{candidatures?.length > 0 && (
<div className="bg-white rounded-xl p-3 shadow-sm">
<h3 className="font-semibold text-gray-700 mb-2 text-sm">Candidatures en cours</h3>
<div className="space-y-1.5">
{candidatures.map((g) => (
<div key={g.id} className="flex justify-between text-xs px-1.5 py-1">
<span className="truncate">{g.prenom} {g.nom}{g.societe ? ` (${g.societe})` : ''}</span>
<span className="text-yellow-700 font-medium shrink-0 ml-2">{new Date(g.created_at).toLocaleDateString('fr-FR')}</span>
</div>
))}
</div>
</div>
)}
<div className="bg-orange-50 rounded-xl p-4 shadow-sm">
<h3 className="font-semibold text-orange-800 mb-2">Prochaines mises à l'honneur</h3>
{prochaines_honors?.length > 0 ? (
<div className="space-y-2">
{prochaines_honors.map((h) => (
<p key={h.id} className="text-sm text-orange-700">
{new Date(h.date).toLocaleDateString('fr-FR')} - {h.theme_sujet}
</p>
))}
</div>
) : (
<p className="text-gray-400 text-sm">Aucune programmation</p>
)}
</div>
<div className="bg-green-50 rounded-xl p-4 shadow-sm">
<h3 className="font-semibold text-green-800 mb-2">Prochains événements</h3>
{prochains_evenements?.length > 0 ? (
<div className="space-y-2">
{prochains_evenements.map((e) => (
<p key={e.id} className="text-sm text-green-700">
{new Date(e.date).toLocaleDateString('fr-FR')} - {e.nom_evenement}
</p>
))}
</div>
) : (
<p className="text-gray-400 text-sm">Aucun événement</p>
)}
</div>
{alertes_echeance?.length > 0 && user?.role === 'admin' && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4">
<h3 className="font-semibold text-red-800 mb-2">Renouvellements urgents</h3>
{alertes_echeance.map((m) => (
<p key={m.id} className="text-sm text-red-700">
{m.prenom} - {m.societe} : échéance le {new Date(m.date_echeance).toLocaleDateString('fr-FR')}
</p>
))}
</div>
)}
</div>
)
}
+138
View File
@@ -0,0 +1,138 @@
import { useState, useEffect } from 'react'
import { useNavigate, useParams, Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
export default function EditGuestForm() {
const { id } = useParams()
const { authFetch, user } = useAuth()
const navigate = useNavigate()
const [form, setForm] = useState({
prenom: '', nom: '', societe: '', activite: '',
telephone: '', email: '', site_web: '', commentaires: '',
date_club: '',
})
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const [showBlockedModal, setShowBlockedModal] = useState(false)
useEffect(() => {
authFetch(`/api/guests/${id}`)
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
if (data) setForm({
prenom: data.prenom || '',
nom: data.nom || '',
societe: data.societe || '',
activite: data.activite || '',
telephone: data.telephone || '',
email: data.email || '',
site_web: data.site_web || '',
commentaires: data.commentaires || '',
date_club: data.date_club ? data.date_club.split('T')[0] : '',
})
})
}, [id])
const handleChange = (e) => {
const { name, value } = e.target
setForm((f) => ({ ...f, [name]: value }))
}
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
const BLOCKED_DATES = ['2026-10-20', '2026-12-15']
if (BLOCKED_DATES.includes(form.date_club)) {
setShowBlockedModal(true)
return
}
setBusy(true)
try {
const r = await authFetch(`/api/guests/${id}`, {
method: 'PUT',
body: JSON.stringify(form),
})
if (!r.ok) {
const err = await r.json()
throw new Error(err.error || 'Erreur')
}
navigate('/guests')
} catch (err) {
setError(err.message)
} finally {
setBusy(false)
}
}
return (
<div className="space-y-4">
<Link to="/guests" className="text-blue-700 text-sm">&larr; Retour</Link>
<h2 className="text-xl font-bold text-gray-800">Modifier l'invite(e)</h2>
{error && <div className="bg-red-100 text-red-700 rounded-lg p-3 text-sm">{error}</div>}
<form onSubmit={handleSubmit} className="bg-white rounded-xl p-5 shadow-sm space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Invite par</label>
<input type="text" value={`${user?.prenom} ${user?.nom}`} disabled className="w-full px-4 py-3 rounded-lg bg-gray-100 border border-gray-200 text-gray-500 text-base" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Prenom *</label>
<input name="prenom" required value={form.prenom} onChange={handleChange} className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Nom *</label>
<input name="nom" required value={form.nom} onChange={handleChange} className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Societe *</label>
<input name="societe" required value={form.societe} onChange={handleChange} className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Activite *</label>
<input name="activite" required value={form.activite} onChange={handleChange} className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Telephone *</label>
<input name="telephone" type="tel" required value={form.telephone} onChange={handleChange} className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email *</label>
<input name="email" type="email" required value={form.email} onChange={handleChange} className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Date de venue *</label>
<input name="date_club" type="date" required value={form.date_club} onChange={handleChange} className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Site internet</label>
<input name="site_web" type="url" value={form.site_web} onChange={handleChange} placeholder="https://..." className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Commentaires</label>
<textarea name="commentaires" rows={3} value={form.commentaires} onChange={handleChange} className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
<button type="submit" disabled={busy} className="w-full bg-blue-900 text-white py-3 rounded-xl font-semibold text-base hover:bg-blue-800 active:bg-blue-950 transition disabled:opacity-50">
{busy ? 'Enregistrement...' : 'Enregistrer les modifications'}
</button>
</form>
{showBlockedModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={() => setShowBlockedModal(false)}>
<div className="bg-white rounded-2xl p-6 shadow-xl max-w-sm w-full mx-4 text-center" onClick={(e) => e.stopPropagation()}>
<div className="w-14 h-14 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
<span className="text-2xl">⛔</span>
</div>
<h3 className="text-lg font-bold text-gray-800 mb-2">Date non disponible</h3>
<p className="text-sm text-gray-500 mb-6">Cette date n'accepte pas de nouveaux invités.</p>
<button onClick={() => setShowBlockedModal(false)} className="w-full bg-blue-900 text-white py-3 rounded-xl font-semibold text-sm hover:bg-blue-800 active:bg-blue-950 transition">
OK
</button>
</div>
</div>
)}
</div>
)
}
+246
View File
@@ -0,0 +1,246 @@
import { useState, useEffect } from 'react'
import { useNavigate, useParams, Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import CropModal from './CropModal'
export default function EditProfile() {
const { id } = useParams()
const { authFetch, user, login } = useAuth()
const navigate = useNavigate()
const [members, setMembers] = useState([])
const [form, setForm] = useState({
prenom: '', nom: '', societe: '', activite: '',
email: '', telephone: '', site_web: '',
date_adhesion: '', date_echeance: '',
invite_par_id: '', date_postulation: '', reception_connu: '',
reception_inconnu: '', date_entretien_postulation: '',
date_demande_whatsapp: '', fonction: '', fonction_emoji: '', kdrive_link: '',
merged_display_name: '',
})
const [photo, setPhoto] = useState(null)
const [photoPreview, setPhotoPreview] = useState('')
const [cropFile, setCropFile] = useState(null)
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const isOwner = user?.id === Number(id) || user?.role === 'admin'
if (!isOwner) { navigate('/members/' + id, { replace: true }); return null }
useEffect(() => {
authFetch('/api/members').then((r) => (r.ok ? r.json() : [])).then((list) => setMembers(list.filter(m => m.id !== Number(id))))
}, [id])
useEffect(() => {
authFetch(`/api/members/${id}`).then((r) => (r.ok ? r.json() : null)).then((data) => {
if (data) setForm({
prenom: data.prenom || '', nom: data.nom || '',
societe: data.societe || '', activite: data.activite || '',
email: data.email || '', telephone: data.telephone || '',
site_web: data.site_web || '',
date_adhesion: data.date_adhesion ? data.date_adhesion.slice(0, 10) : '',
date_echeance: data.date_echeance ? data.date_echeance.slice(0, 10) : '',
invite_par_id: data.invite_par_id || '',
date_postulation: data.date_postulation ? data.date_postulation.slice(0, 10) : '',
reception_connu: data.reception_connu || '',
reception_inconnu: data.reception_inconnu || '',
date_entretien_postulation: data.date_entretien_postulation ? data.date_entretien_postulation.slice(0, 10) : '',
date_demande_whatsapp: data.date_demande_whatsapp ? data.date_demande_whatsapp.slice(0, 10) : '',
fonction: data.fonction || '', fonction_emoji: data.fonction_emoji || '', kdrive_link: data.kdrive_link || '',
merged_display_name: data.merged_display_name || '',
})
})
}, [id])
const handleChange = (e) => {
const { name, value, type, checked } = e.target
setForm((f) => ({ ...f, [name]: type === 'checkbox' ? checked : value }))
}
const handlePhoto = (e) => {
const file = e.target.files[0]
if (file) setCropFile(file)
}
const handleCrop = (blob) => {
const croppedFile = new File([blob], 'photo.jpg', { type: 'image/jpeg' })
setPhoto(croppedFile)
setPhotoPreview(URL.createObjectURL(blob))
setCropFile(null)
}
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setBusy(true)
try {
const r = await authFetch(`/api/members/${id}`, {
method: 'PUT',
body: JSON.stringify(form),
})
if (!r.ok) { const err = await r.json(); throw new Error(err.error || 'Erreur') }
if (photo) {
const fd = new FormData()
fd.append('photo', photo)
const r2 = await fetch('/api/members/upload-photo', {
method: 'POST',
headers: { Authorization: 'Bearer ' + localStorage.getItem('token') },
body: fd,
})
if (!r2.ok) throw new Error('Erreur upload photo')
}
navigate('/members/' + id)
} catch (err) {
setError(err.message)
} finally {
setBusy(false)
}
}
return (
<div className="space-y-4">
<Link to={'/members/' + id} className="text-blue-700 text-sm">&larr; Retour</Link>
<h2 className="text-xl font-bold text-gray-800">Modifier mon profil</h2>
{error && <div className="bg-red-100 text-red-700 rounded-lg p-3 text-sm">{error}</div>}
<form onSubmit={handleSubmit} className="bg-white rounded-xl p-5 shadow-sm space-y-4">
<div className="text-center">
{(photoPreview || form.photo_url) ? (
<img src={photoPreview || form.photo_url} alt="" className="w-20 h-20 rounded-full object-cover mx-auto" />
) : (
<div className="w-20 h-20 bg-blue-900 rounded-full flex items-center justify-center text-white text-2xl font-bold mx-auto">
{form.prenom[0]}{form.nom[0]}
</div>
)}
<label className="block text-sm text-blue-600 underline cursor-pointer mt-2">
Changer la photo
<input type="file" accept="image/*" onChange={handlePhoto} className="hidden" />
</label>
</div>
{cropFile && <CropModal file={cropFile} onCrop={handleCrop} onCancel={() => setCropFile(null)} />}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Prénom *</label>
<input name="prenom" required value={form.prenom} onChange={handleChange} className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Nom *</label>
<input name="nom" required value={form.nom} onChange={handleChange} className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Société</label>
<input name="societe" value={form.societe} onChange={handleChange} className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Activité</label>
<input name="activite" value={form.activite} onChange={handleChange} className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input name="email" type="email" value={form.email} onChange={handleChange} className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Téléphone</label>
<input name="telephone" type="tel" value={form.telephone} onChange={handleChange} className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Site web</label>
<input name="site_web" value={form.site_web} onChange={handleChange} placeholder="https://..." className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
{user?.role === 'admin' && (
<>
<div className="border-t pt-4">
<h3 className="font-semibold text-gray-700 mb-3">Administration</h3>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Date d'adhésion</label>
<input name="date_adhesion" type="date" value={form.date_adhesion} onChange={handleChange} className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Date d'échéance</label>
<input name="date_echeance" type="date" value={form.date_echeance} onChange={handleChange} className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
</div>
<div className="mt-3">
<label className="block text-sm font-medium text-gray-700 mb-1">Invité par</label>
<select name="invite_par_id" value={form.invite_par_id} onChange={handleChange} className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base bg-white">
<option value="">Sélectionner un membre</option>
{members.map((m) => (
<option key={m.id} value={m.id}>{m.prenom} {m.nom}</option>
))}
</select>
</div>
<div className="mt-3">
<label className="block text-sm font-medium text-gray-700 mb-1">Date de postulation</label>
<input name="date_postulation" type="date" value={form.date_postulation} onChange={handleChange} className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
<div className="mt-3">
<label className="block text-sm font-medium text-gray-700 mb-1">Réception par (connaît le métier)</label>
<select name="reception_connu" value={form.reception_connu} onChange={handleChange} className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base bg-white">
<option value="">Sélectionner un membre</option>
{members.map((m) => (
<option key={m.id} value={m.id}>{m.prenom} {m.nom}</option>
))}
</select>
</div>
<div className="mt-3">
<label className="block text-sm font-medium text-gray-700 mb-1">Réception par (ne connaît pas le métier)</label>
<select name="reception_inconnu" value={form.reception_inconnu} onChange={handleChange} className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base bg-white">
<option value="">Sélectionner un membre</option>
{members.map((m) => (
<option key={m.id} value={m.id}>{m.prenom} {m.nom}</option>
))}
</select>
</div>
<div className="mt-3">
<label className="block text-sm font-medium text-gray-700 mb-1">Date de l'entretien de postulation</label>
<input name="date_entretien_postulation" type="date" value={form.date_entretien_postulation} onChange={handleChange} className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
<div className="mt-3">
<label className="block text-sm font-medium text-gray-700 mb-1">Date de demande de validation aux membres via WhatsApp</label>
<input name="date_demande_whatsapp" type="date" value={form.date_demande_whatsapp} onChange={handleChange} className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
<div className="border-t border-gray-200 my-4"></div>
<h3 className="font-semibold text-gray-700 mb-3">Rôle clé & Documents</h3>
<div className="mt-3">
<label className="block text-sm font-medium text-gray-700 mb-1">Fonction / Rôle clé</label>
<select name="fonction" value={form.fonction} onChange={handleChange} className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base bg-white">
<option value="">Aucun</option>
{['Le sage du Collectif','Ambassadeur de l\'image','La tisseuse des liens','La clé de l\'accueil','Capitaines des adhésions','Co-présidence'].map((r) => (
<option key={r} value={r}>{r}</option>
))}
</select>
</div>
<div className="mt-3">
<label className="block text-sm font-medium text-gray-700 mb-1">Emoji du rôle</label>
<select name="fonction_emoji" value={form.fonction_emoji} onChange={handleChange} className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base bg-white">
<option value="">Aucun</option>
{['🌟','👑','🎯','🤝','🗝️','🎨','💡','🏆','🛡️','🌐','📢','👥','⚡','💎','📋','🔑','🕊️','⭐','🎵','🎭'].map((e) => (
<option key={e} value={e}>{e}</option>
))}
</select>
</div>
<div className="mt-3">
<label className="block text-sm font-medium text-gray-700 mb-1">Nom d'affichage fusionné (ex: "Amandine & Matthieu")</label>
<input name="merged_display_name" value={form.merged_display_name} onChange={handleChange} placeholder="Laisser vide pour utiliser le prénom seul" className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
</div>
</>
)}
<div className="border-t border-gray-200 my-4"></div>
<h3 className="font-semibold text-gray-700 mb-3">Documents membre</h3>
<div className="mt-3">
<label className="block text-sm font-medium text-gray-700 mb-1">Lien kDrive (documents membre)</label>
<input name="kdrive_link" value={form.kdrive_link} onChange={handleChange} placeholder="https://kdrive.secourit.fr/..." className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
<button type="submit" disabled={busy} className="w-full bg-blue-900 text-white py-3 rounded-xl font-semibold text-base hover:bg-blue-800 active:bg-blue-950 transition disabled:opacity-50">
{busy ? 'Enregistrement...' : 'Enregistrer les modifications'}
</button>
</form>
</div>
)
}
@@ -0,0 +1,133 @@
import { useState, useEffect } from 'react'
import { useNavigate, useParams, Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
export default function EditRecommendationForm() {
const { id } = useParams()
const { authFetch } = useAuth()
const navigate = useNavigate()
const [members, setMembers] = useState([])
const [form, setForm] = useState({
date: '', destinataire_id: '', nom_prospect: '',
type: 'donnee', transformee: false, sans_suite: false, ca_realise: '',
})
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
useEffect(() => {
authFetch('/api/members').then((r) => r.ok ? r.json() : []).then(setMembers)
authFetch(`/api/recommendations/${id}`)
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
if (data) setForm({
date: data.date ? data.date.split('T')[0] : '',
destinataire_id: data.destinataire_id ? String(data.destinataire_id) : '',
nom_prospect: data.nom_prospect || '',
type: data.type || 'donnee',
transformee: Boolean(data.transformee),
sans_suite: Boolean(data.sans_suite),
ca_realise: data.ca_realise ? String(data.ca_realise) : '',
})
})
}, [id])
const handleChange = (e) => {
const { name, value, type, checked } = e.target
if (type === 'checkbox') {
setForm((f) => ({ ...f, [name]: checked, transformee: name === 'sans_suite' && checked ? false : name === 'transformee' ? checked : f.transformee, sans_suite: name === 'transformee' && checked ? false : name === 'sans_suite' ? checked : f.sans_suite }))
} else {
setForm((f) => ({ ...f, [name]: value }))
}
}
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setBusy(true)
try {
const payload = {
...form,
destinataire_id: form.destinataire_id ? Number(form.destinataire_id) : null,
ca_realise: form.ca_realise ? Number(form.ca_realise) : null,
}
const r = await authFetch(`/api/recommendations/${id}`, {
method: 'PUT',
body: JSON.stringify(payload),
})
if (!r.ok) {
const err = await r.json()
throw new Error(err.error || 'Erreur')
}
navigate('/recommendations')
} catch (err) {
setError(err.message)
} finally {
setBusy(false)
}
}
return (
<div className="space-y-4">
<Link to="/recommendations" className="text-blue-700 text-sm">&larr; Retour</Link>
<h2 className="text-xl font-bold text-gray-800">Modifier la recommandation</h2>
{error && <div className="bg-red-100 text-red-700 rounded-lg p-3 text-sm">{error}</div>}
<form onSubmit={handleSubmit} className="bg-white rounded-xl p-5 shadow-sm space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Date</label>
<input name="date" type="date" required value={form.date} onChange={handleChange}
className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Type</label>
<div className="flex gap-3">
<label className="flex items-center gap-2">
<input type="radio" name="type" value="donnee" checked={form.type === 'donnee'} onChange={handleChange} className="text-blue-900" />
<span className="text-sm">Donnée</span>
</label>
<label className="flex items-center gap-2">
<input type="radio" name="type" value="recue" checked={form.type === 'recue'} onChange={handleChange} className="text-blue-900" />
<span className="text-sm">Reçue</span>
</label>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Destinataire / Source</label>
<select name="destinataire_id" value={form.destinataire_id} onChange={handleChange}
className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base bg-white">
<option value="">— Sélectionner —</option>
{members.map((m) => (
<option key={m.id} value={m.id}>{m.prenom} {m.nom} - {m.societe || ''}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Nom du prospect *</label>
<input name="nom_prospect" required value={form.nom_prospect} onChange={handleChange}
className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
</div>
<label className="flex items-center gap-3">
<input name="transformee" type="checkbox" checked={form.transformee} onChange={handleChange}
className="w-5 h-5 rounded border-gray-300 text-blue-900 focus:ring-blue-500" />
<span className="text-sm text-gray-700">Transformée (conclue)</span>
</label>
<label className="flex items-center gap-3">
<input name="sans_suite" type="checkbox" checked={form.sans_suite} onChange={handleChange}
className="w-5 h-5 rounded border-gray-300 text-gray-500 focus:ring-gray-400" />
<span className="text-sm text-gray-700">Sans suite</span>
</label>
{form.transformee && !form.sans_suite && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">CA HT réalisé (€)</label>
<input name="ca_realise" type="number" step="0.01" min="0" value={form.ca_realise} onChange={handleChange}
className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
{form.ca_realise && <p className="text-sm text-green-600 mt-1">Soit {Number(form.ca_realise * 1.2).toLocaleString('fr-FR', {minimumFractionDigits: 2, maximumFractionDigits: 2})} € TTC</p>}
</div>
)}
<button type="submit" disabled={busy}
className="w-full bg-blue-900 text-white py-3 rounded-xl font-semibold text-base hover:bg-blue-800 active:bg-blue-950 transition disabled:opacity-50">
{busy ? 'Enregistrement...' : 'Enregistrer les modifications'}
</button>
</form>
</div>
)
}
+155
View File
@@ -0,0 +1,155 @@
import { useState, useEffect, useRef } from 'react'
import { useAuth } from '../context/AuthContext'
export default function Gallery() {
const { authFetch, user } = useAuth()
const [dates, setDates] = useState([])
const [openDate, setOpenDate] = useState(null)
const [uploading, setUploading] = useState(false)
const [dateClub, setDateClub] = useState('')
const [files, setFiles] = useState([])
const [lightbox, setLightbox] = useState(null)
const fileRef = useRef()
const load = () => {
authFetch('/api/gallery').then((r) => (r.ok ? r.json() : [])).then((data) => {
setDates(data)
if (data.length > 0 && !openDate) setOpenDate(data[0].date)
})
}
useEffect(load, [])
useEffect(() => {
const handleKey = (e) => {
if (!lightbox) return
if (e.key === 'Escape') setLightbox(null)
if (e.key === 'ArrowLeft') navigateLightbox(-1)
if (e.key === 'ArrowRight') navigateLightbox(1)
}
window.addEventListener('keydown', handleKey)
return () => window.removeEventListener('keydown', handleKey)
}, [lightbox, dates, openDate])
const currentFiles = dates.find((d) => d.date === openDate)?.files || []
const navigateLightbox = (dir) => {
const idx = currentFiles.findIndex((f) => f.url === lightbox)
if (idx === -1) return
const next = idx + dir
if (next >= 0 && next < currentFiles.length) setLightbox(currentFiles[next].url)
}
const handleUpload = async (e) => {
e.preventDefault()
if (!dateClub || files.length === 0) { alert('Sélectionne une date et des photos'); return }
const fd = new FormData()
fd.append('date_club', dateClub)
Array.from(files).forEach((f) => fd.append('photos', f))
setUploading(true)
const r = await authFetch('/api/gallery/upload', { method: 'POST', body: fd })
setUploading(false)
if (r.ok) {
alert('Photos ajoutées')
setDateClub('')
setFiles([])
fileRef.current.value = ''
load()
} else {
const err = await r.json()
alert(err.error || 'Erreur')
}
}
const handleDelete = async (date, filename) => {
if (!confirm(`Supprimer ${filename} ?`)) return
const r = await authFetch(`/api/gallery/${date}/${filename}`, { method: 'DELETE' })
if (r.ok) {
if (lightbox) setLightbox(null)
load()
}
}
const thumbUrl = (f) => `/api/gallery/thumbnail/${f.url.split('/')[2]}/${encodeURIComponent(f.filename)}`
const formatDateLabel = (d) => {
const y = d.slice(0, 4), m = d.slice(4, 6), day = d.slice(6, 8)
return `${day}/${m}/${y}`
}
return (
<div className="space-y-4">
<h2 className="text-xl font-bold text-gray-800">Galerie photos</h2>
<form onSubmit={handleUpload} className="bg-white rounded-xl p-4 shadow-sm space-y-3">
<h3 className="font-semibold text-gray-700 text-sm">Ajouter des photos</h3>
<div>
<label className="block text-xs text-gray-600 mb-1">Sélectionner les photos (max 20, 20 Mo chacune)</label>
<div className="flex items-center gap-2">
<button type="button" onClick={() => fileRef.current?.click()} className="bg-blue-900 text-white px-4 py-2 rounded-lg text-sm font-semibold">Sélect. fichiers</button>
<span className="text-sm text-gray-500">{files.length > 0 ? `${files.length} fichier(s) sélectionné(s)` : 'Aucun fichier'}</span>
</div>
<input type="file" multiple accept="image/*" ref={fileRef} onChange={(e) => setFiles(e.target.files)} className="w-full text-sm hidden" />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Date du rendez-vous</label>
<input type="date" required value={dateClub} onChange={(e) => setDateClub(e.target.value)} className="w-full px-3 py-2 rounded-lg border text-sm" />
</div>
<button type="submit" disabled={uploading} className="w-full bg-blue-900 text-white py-2 rounded-lg text-sm font-semibold disabled:opacity-50">
{uploading ? 'Envoi...' : 'Envoyer mes photos'}
</button>
</form>
{dates.length === 0 && <p className="text-center text-gray-400 py-8">Aucune photo pour le moment</p>}
<div className="space-y-3">
{dates.map((d) => (
<div key={d.date} className="bg-white rounded-xl shadow-sm overflow-hidden">
<button onClick={() => setOpenDate(openDate === d.date ? null : d.date)}
className="w-full flex items-center justify-between px-4 py-3 text-left font-semibold text-gray-700 active:bg-gray-50">
<span>{formatDateLabel(d.date)} — {d.files.length} photo{d.files.length > 1 ? 's' : ''}</span>
<span className="text-lg">{openDate === d.date ? '▲' : '▼'}</span>
</button>
{openDate === d.date && (
<div className="px-4 pb-4">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{d.files.map((f) => (
<div key={f.filename} className="relative group">
<button onClick={() => setLightbox(f.url)} className="w-full">
<img src={thumbUrl(f)} alt={f.filename} className="w-full h-32 object-cover rounded-lg" loading="lazy" />
</button>
{user?.role === 'admin' && (
<button onClick={() => handleDelete(d.date, f.filename)} className="absolute top-1 right-1 bg-red-600 text-white w-6 h-6 rounded-full text-xs flex items-center justify-center opacity-0 group-hover:opacity-100 transition">
✕
</button>
)}
</div>
))}
</div>
</div>
)}
</div>
))}
</div>
{lightbox && (
<div className="fixed inset-0 bg-black/80 z-50 flex items-center justify-center" onClick={() => setLightbox(null)}>
<div className="relative max-w-full max-h-full p-4" onClick={(e) => e.stopPropagation()}>
<button onClick={() => setLightbox(null)} className="absolute -top-2 -right-2 bg-white text-gray-800 w-8 h-8 rounded-full text-lg flex items-center justify-center shadow z-10">✕</button>
{currentFiles.length > 1 && (
<>
<button onClick={() => navigateLightbox(-1)} className="absolute left-2 top-1/2 -translate-y-1/2 bg-white/80 text-gray-800 w-10 h-10 rounded-full text-xl flex items-center justify-center shadow">‹</button>
<button onClick={() => navigateLightbox(1)} className="absolute right-2 top-1/2 -translate-y-1/2 bg-white/80 text-gray-800 w-10 h-10 rounded-full text-xl flex items-center justify-center shadow">›</button>
</>
)}
<img src={lightbox} alt="" className="max-w-full max-h-[85vh] rounded-lg object-contain" />
<div className="flex justify-center gap-3 mt-3">
<a href={lightbox} download className="bg-white text-gray-800 px-4 py-2 rounded-lg text-sm font-semibold shadow">Télécharger</a>
<a href={lightbox} target="_blank" rel="noopener noreferrer" className="bg-blue-900 text-white px-4 py-2 rounded-lg text-sm font-semibold shadow">Ouvrir dans un nouvel onglet</a>
</div>
</div>
</div>
)}
</div>
)
}
+134
View File
@@ -0,0 +1,134 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
export default function GuestDetail() {
const { id } = useParams()
const navigate = useNavigate()
const { authFetch, user } = useAuth()
const [guest, setGuest] = useState(null)
useEffect(() => {
authFetch(`/api/guests/${id}`).then((r) => (r.ok ? r.json() : null)).then(setGuest)
}, [id])
const handleCandidature = async (action) => {
const confirmMsg = action === 'start' ? 'Proposer la candidature de cet invité ?' :
action === 'accept' ? 'Accepter → créer un compte membre et supprimer cet invité ?' :
'Refuser cette candidature ?'
if (!confirm(confirmMsg)) return
const r = await authFetch(`/api/guests/${id}/candidature`, {
method: 'POST',
body: JSON.stringify({ action }),
})
if (r.ok) {
if (action === 'accept') {
alert('Membre créé ! Mot de passe par défaut : Bienvenue2026')
navigate('/members')
} else {
const data = await r.json()
alert(data.message)
setGuest((g) => ({ ...g, statut_candidature: data.statut_candidature }))
}
} else {
const err = await r.json()
alert(err.error || 'Erreur')
}
}
if (!guest) return <p className="text-center text-gray-400 py-8">Chargement...</p>
const isOwner = guest.invite_par_id === user?.id || user?.role === 'admin'
const statutBadge = (s) => {
if (s === 'acceptee') return <span className="text-green-600 text-sm font-medium">✓ Acceptée</span>
if (s === 'refusee') return <span className="text-red-600 text-sm font-medium">✗ Refusée</span>
return <span className="text-orange-400 text-sm font-medium">En attente</span>
}
const candidatureBadge = (s) => {
if (s === 'candidature') return <span className="inline-block text-yellow-800 bg-yellow-100 text-xs font-bold px-2 py-0.5 rounded-full">Candidature en cours</span>
if (s === 'refuse') return <span className="inline-block text-red-800 bg-red-100 text-xs font-bold px-2 py-0.5 rounded-full">Candidature refusée</span>
return null
}
return (
<div className="space-y-4">
<Link to="/guests" className="text-blue-700 text-sm">&larr; Retour</Link>
<div className="bg-white rounded-xl p-6 shadow-sm text-center">
{guest.photo_url ? (
<img src={guest.photo_url} alt="" className="w-20 h-20 rounded-full object-cover mx-auto mb-3" />
) : (
<div className="w-20 h-20 bg-blue-900 rounded-full flex items-center justify-center text-white text-2xl font-bold mx-auto mb-3">
{guest.prenom[0]}{guest.nom[0]}
</div>
)}
<h2 className="text-xl font-bold text-gray-800">{guest.prenom} {guest.nom}</h2>
{guest.societe && <p className="text-gray-500">{guest.societe}</p>}
{guest.activite && <p className="text-gray-400 text-sm">{guest.activite}</p>}
{guest.visit_number === 2 && <span className="inline-block mt-1 text-xs font-bold text-red-600 bg-red-100 px-2 py-0.5 rounded-full">2</span>}
{guest.visit_number >= 3 && <span className="inline-block mt-1 text-xs font-bold text-purple-600 bg-purple-100 px-2 py-0.5 rounded-full">{guest.visit_number}</span>}
<div className="mt-2">{statutBadge(guest.statut)}</div>
<div className="mt-1">{candidatureBadge(guest.statut_candidature)}</div>
{isOwner && (
<Link to={`/guests/edit/${guest.id}`} className="inline-block mt-3 bg-blue-900 text-white px-4 py-2 rounded-lg text-sm font-semibold">Modifier</Link>
)}
</div>
{user?.role === 'admin' && (
<div className="bg-white rounded-xl p-4 shadow-sm space-y-2">
<h3 className="font-semibold text-gray-700 text-sm">Candidature membre</h3>
{!guest.statut_candidature && (
<button onClick={() => handleCandidature('start')} className="w-full bg-yellow-500 text-white py-2 rounded-lg text-sm font-semibold active:bg-yellow-600">
Proposer candidature
</button>
)}
{guest.statut_candidature === 'candidature' && (
<div className="flex gap-2">
<button onClick={() => handleCandidature('accept')} className="flex-1 bg-green-600 text-white py-2 rounded-lg text-sm font-semibold active:bg-green-700">
Accepter → Membre
</button>
<button onClick={() => handleCandidature('reject')} className="flex-1 bg-red-500 text-white py-2 rounded-lg text-sm font-semibold active:bg-red-600">
Refuser
</button>
</div>
)}
{guest.statut_candidature === 'refuse' && (
<p className="text-red-600 text-sm text-center">Candidature refusée</p>
)}
</div>
)}
<div className="bg-white rounded-xl p-4 shadow-sm space-y-3">
{guest.telephone && (
<a href={`tel:${guest.telephone}`} className="flex items-center gap-3 text-green-600">
<span className="text-lg">📞</span>
<span className="font-medium">{guest.telephone}</span>
</a>
)}
{guest.email && (
<a href={`mailto:${guest.email}`} className="flex items-center gap-3 text-blue-600">
<span className="text-lg">✉</span>
<span className="font-medium">{guest.email}</span>
</a>
)}
{guest.site_web && (
<a href={guest.site_web.startsWith('http') ? guest.site_web : 'https://' + guest.site_web} target="_blank" rel="noopener noreferrer" className="flex items-center gap-3 text-purple-600">
<span className="text-lg">🌐</span>
<span className="font-medium">{guest.site_web}</span>
</a>
)}
{guest.commentaires && <p className="text-sm text-gray-600">Note : {guest.commentaires}</p>}
{guest.date_club && (
<p className="text-sm text-gray-500">{new Date(guest.date_club) > new Date() ? 'Vient le' : 'Venue le'} {new Date(guest.date_club).toLocaleDateString('fr-FR', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}</p>
)}
{guest.invite_par_prenom && guest.invite_par_nom && (
<p className="text-xs text-gray-400">Invité par {guest.invite_par_prenom} {guest.invite_par_nom}</p>
)}
{guest.mail_envoye && guest.invitation_sent_at && (
<p className="text-xs text-gray-400">Email envoyé le {new Date(guest.invitation_sent_at).toLocaleDateString('fr-FR')}</p>
)}
</div>
</div>
)
}
+180
View File
@@ -0,0 +1,180 @@
import { useState, useEffect } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
export default function GuestsList() {
const { authFetch, user } = useAuth()
const navigate = useNavigate()
const [guests, setGuests] = useState([])
const [filterDate, setFilterDate] = useState('all')
const [search, setSearch] = useState('')
const [myOnly, setMyOnly] = useState(false)
const load = () => {
authFetch('/api/guests').then((r) => (r.ok ? r.json() : [])).then(setGuests)
}
useEffect(load, [])
const handleDelete = async (id) => {
if (!confirm('Supprimer cet invite ?')) return
const r = await authFetch(`/api/guests/${id}`, { method: 'DELETE' })
if (r.ok) load()
}
const handleSendInvitation = async (id) => {
const r = await authFetch(`/api/guests/${id}/send-invitation`, { method: 'POST' })
if (r.ok) {
alert('Email envoyé avec succès !')
load()
} else {
const err = await r.json()
alert(err.error || "Erreur lors de l'envoi")
}
}
const statutBadge = (s) => {
if (s === 'acceptee') return <span className="text-green-600 text-xs font-medium">✓ Acceptee</span>
if (s === 'refusee') return <span className="text-red-600 text-xs font-medium">✗ Refusee</span>
return <span className="text-orange-400 text-xs font-medium">En attente</span>
}
const candidatureBadge = (s) => {
if (s === 'candidature') return <span className="text-yellow-800 bg-yellow-100 text-xs font-bold px-2 py-0.5 rounded-full">Candidature</span>
if (s === 'refuse') return <span className="text-red-800 bg-red-100 text-xs font-bold px-2 py-0.5 rounded-full">Refusée</span>
return null
}
const visitBadge = (v) => {
if (v === 1) return <span className="text-xs font-bold text-blue-600 bg-blue-100 px-2 py-0.5 rounded-full">1</span>
if (v === 2) return <span className="text-xs font-bold text-red-600 bg-red-100 px-2 py-0.5 rounded-full">2</span>
return <span className="text-xs font-bold text-purple-600 bg-purple-100 px-2 py-0.5 rounded-full">{v}</span>
}
const dateKeys = [...new Set(guests.map((g) => g.date_club).filter(Boolean))].sort((a, b) => new Date(b) - new Date(a))
const q = search.toLowerCase().trim()
const filtered = guests.filter((g) => {
if (myOnly && g.invite_par_id !== user?.id) return false
if (!g.date_club) return false
if (q && !`${g.prenom} ${g.nom} ${g.societe} ${g.activite} ${g.telephone} ${g.email}`.toLowerCase().includes(q)) return false
return filterDate === 'all' || g.date_club === filterDate
})
const grouped = {}
filtered.forEach((g) => {
const key = g.date_club
if (!grouped[key]) grouped[key] = []
grouped[key].push(g)
})
const sortedDates = Object.keys(grouped).sort((a, b) => new Date(b) - new Date(a))
const formatDateLabel = (d) => {
const dt = new Date(d)
const now = new Date()
const isNext = dt >= now && (sortedDates.length === 0 || dt <= new Date(sortedDates[0]))
const label = dt.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: 'numeric' })
return isNext ? label + ' ★' : label
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold text-gray-800">Invite(e)s</h2>
<div className="flex gap-2">
<button onClick={() => setMyOnly(!myOnly)} className={`px-3 py-1.5 rounded-lg text-sm font-medium ${myOnly ? 'bg-blue-900 text-white' : 'bg-white border text-gray-600'}`}>
{myOnly ? 'Tous' : 'Les miens'}
</button>
<Link to="/guests/add" className="bg-blue-900 text-white px-4 py-2 rounded-xl text-sm font-semibold active:bg-blue-950">+ Ajouter</Link>
</div>
</div>
<input
type="text" placeholder="Rechercher..." value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full px-4 py-3 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base"
/>
{dateKeys.length > 0 && (
<div className="flex gap-2 flex-wrap">
<button onClick={() => setFilterDate('all')} className={`px-3 py-1.5 rounded-lg text-sm font-medium ${filterDate === 'all' ? 'bg-blue-900 text-white' : 'bg-white border text-gray-600'}`}>Toutes</button>
{dateKeys.map((dk) => {
const count = guests.filter((g) => g.date_club === dk).length
return (
<button key={dk} onClick={() => setFilterDate(dk)} className={`px-3 py-1.5 rounded-lg text-sm font-medium whitespace-nowrap ${filterDate === dk ? 'bg-blue-900 text-white' : 'bg-white border text-gray-600'}`}>
{formatDateLabel(dk)} ({count})
</button>
)
})}
</div>
)}
<div className="space-y-6">
{sortedDates.map((dateKey) => (
<div key={dateKey}>
<h3 className="font-bold text-gray-700 mb-2 text-sm bg-gray-100 rounded-lg px-3 py-1.5 inline-block">
{new Date(dateKey).toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
</h3>
<div className="space-y-2 mt-2">
{grouped[dateKey].map((g) => (
<div key={g.id} onClick={() => navigate(`/guests/${g.id}`)} className="bg-white rounded-xl p-4 shadow-sm active:bg-gray-50 transition cursor-pointer">
<div className="flex gap-3">
{g.photo_url ? (
<img src={g.photo_url} alt="" className="w-12 h-12 rounded-full object-cover shrink-0" />
) : (
<div className="w-12 h-12 bg-blue-900 rounded-full flex items-center justify-center text-white font-bold shrink-0 text-sm">
{g.prenom[0]}{g.nom[0]}
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex justify-between">
<div className="truncate">
<p className="font-semibold text-gray-800 truncate">{g.prenom} {g.nom}</p>
<p className="text-sm text-gray-500 truncate">{g.societe || '-'}</p>
</div>
<div className="text-right shrink-0 ml-2">
<span className="text-xs text-gray-400">par {g.invite_par_prenom} {g.invite_par_nom}</span>
<div className="mt-1">{statutBadge(g.statut)}</div>
</div>
</div>
<div className="mt-1 flex items-center gap-1">
{visitBadge(g.visit_number)}
{candidatureBadge(g.statut_candidature)}
</div>
<div className="mt-1 flex flex-wrap gap-2 items-center text-xs">
{g.telephone && <span className="text-green-600">📞 {g.telephone}</span>}
{g.email && <span className="text-blue-600">✉ {g.email}</span>}
{g.site_web && <span className="text-purple-600">🌐</span>}
<div className="flex gap-2 ml-auto">
<span onClick={(e) => { e.stopPropagation(); navigate(`/guests/edit/${g.id}`) }} className="text-blue-600 underline cursor-pointer">Modifier</span>
{(user?.role === 'admin' || g.invite_par_id === user?.id) && (
<span onClick={(e) => { e.stopPropagation(); handleDelete(g.id) }} className="text-red-600 underline cursor-pointer">Suppr.</span>
)}
</div>
</div>
{g.email && !g.mail_envoye && (user?.role === 'admin' || g.invite_par_id === user?.id) && (
<div className="mt-1">
<span onClick={(e) => { e.stopPropagation(); handleSendInvitation(g.id) }} className="text-indigo-600 underline text-xs font-medium cursor-pointer">Envoyer invitation</span>
</div>
)}
{g.mail_envoye && (
<div className="mt-1">
<span className="text-xs text-green-500">✓ Envoye</span>
{(user?.role === 'admin' || g.invite_par_id === user?.id) && (
<span onClick={(e) => { e.stopPropagation(); handleSendInvitation(g.id) }} className="text-indigo-600 underline text-xs font-medium cursor-pointer ml-2">Renvoyer</span>
)}
</div>
)}
</div>
</div>
</div>
))}
</div>
</div>
))}
{sortedDates.length === 0 && <p className="text-center text-gray-400 py-8">{myOnly ? 'Aucun invité de votre part' : 'Aucun invite pour cette periode'}</p>}
</div>
</div>
)
}
+42
View File
@@ -0,0 +1,42 @@
import { useState, useEffect } from 'react'
import { useAuth } from '../context/AuthContext'
export default function HonorsList() {
const { authFetch, user } = useAuth()
const [honors, setHonors] = useState([])
const load = () => { authFetch('/api/honors').then((r) => (r.ok ? r.json() : [])).then(setHonors) }
useEffect(load, [])
const handleDelete = async (id) => {
if (!confirm('Supprimer cette mise a l\'honneur ?')) return
const r = await authFetch(`/api/honors/${id}`, { method: 'DELETE' })
if (r.ok) load()
}
return (
<div className="space-y-4">
<h2 className="text-xl font-bold text-gray-800">Mises a l'honneur</h2>
<div className="space-y-3">
{honors.map((h) => (
<div key={h.id} className="bg-white rounded-xl p-4 shadow-sm">
<div className="flex justify-between items-start">
<div>
<p className="font-semibold text-gray-800">{h.theme_sujet}</p>
<p className="text-sm text-gray-500">
{new Date(h.date).toLocaleDateString('fr-FR')}
{h.prenom && ` - ${h.prenom} ${h.nom || ''}`}
{h.societe && ` (${h.societe})`}
</p>
</div>
{user?.role === 'admin' && (
<button onClick={() => handleDelete(h.id)} className="text-xs text-red-600 font-medium underline">Suppr.</button>
)}
</div>
</div>
))}
{honors.length === 0 && <p className="text-center text-gray-400 py-8">Aucune mise a l'honneur</p>}
</div>
</div>
)
}
+77
View File
@@ -0,0 +1,77 @@
import { useState, useEffect } from 'react'
import { Outlet, NavLink, useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import { setDeferredPrompt } from '../utils/installPrompt'
const navItems = [
{ to: '/', label: 'Tableau de bord', icon: '\u{1F4CA}' },
{ to: '/members', label: 'Membres', icon: '\u{1F465}' },
{ to: '/guests', label: 'Invit\u00e9(e)s', icon: '\u{2795}' },
{ to: '/recommendations', label: 'Recommandations', icon: '\u{1F4CB}' },
{ to: '/gallery', label: 'Photos', icon: '\u{1F5BC}' },
]
export default function Layout() {
const { user, authFetch, logout } = useAuth()
const navigate = useNavigate()
const [unreadCount, setUnreadCount] = useState(0)
useEffect(() => {
const handler = (e) => { e.preventDefault(); setDeferredPrompt(e) }
window.addEventListener('beforeinstallprompt', handler)
return () => window.removeEventListener('beforeinstallprompt', handler)
}, [])
useEffect(() => {
if (!user) return
const fetchCount = () => {
let dismissed = []
try { dismissed = JSON.parse(localStorage.getItem('dismissedInvites') || '[]') } catch {}
authFetch('/api/notifications/unread-count').then((r) => r.ok ? r.json() : { count: 0 }).then((d) => {
setUnreadCount(Math.max(0, d.count - dismissed.filter(id => id < 0).length))
}).catch(() => {})
}
fetchCount()
window.addEventListener('notifications-updated', fetchCount)
const interval = setInterval(fetchCount, 30000)
return () => { clearInterval(interval); window.removeEventListener('notifications-updated', fetchCount) }
}, [user, authFetch])
const handleLogout = () => { logout(); navigate('/login') }
return (
<div className="min-h-screen bg-gray-50 flex flex-col">
<header className="bg-blue-900 text-white px-2 sm:px-4 py-2 flex items-center justify-between shadow-md gap-1">
<div className="flex items-center gap-2 min-w-0">
<img src="/logo.svg" alt="Dyna Biz" className="h-9 w-auto shrink-0" />
<div className="min-w-0">
<h1 className="font-bold text-sm sm:text-lg truncate">Dyna Biz Coignieres</h1>
<p className="text-xs text-blue-200 truncate">{user?.prenom} {user?.nom}</p>
</div>
</div>
<div className="flex items-center gap-1 sm:gap-2 shrink-0">
<NavLink to="/notifications" className="relative text-xs sm:text-sm bg-blue-800 px-2 sm:px-3 py-1.5 rounded-lg active:bg-blue-950">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="w-4 h-4 inline"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
{unreadCount > 0 && (
<span className="absolute -top-1.5 -right-1.5 bg-red-500 text-white text-[10px] w-4 h-4 rounded-full flex items-center justify-center font-bold">{unreadCount > 9 ? '9+' : unreadCount}</span>
)}
</NavLink>
<NavLink to="/profile" className="text-xs sm:text-sm bg-blue-800 px-2 sm:px-3 py-1.5 rounded-lg active:bg-blue-950">Profil</NavLink>
<button onClick={handleLogout} className="text-xs sm:text-sm bg-blue-800 px-2 sm:px-3 py-1.5 rounded-lg active:bg-blue-950">Déconnexion</button>
</div>
</header>
<main className="flex-1 p-4 pb-24 overflow-y-auto"><Outlet /></main>
<nav className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 shadow-lg z-50 overflow-x-auto scrollbar-hide">
<div className="flex items-center min-w-max mx-auto px-1" style={{ justifyContent: 'center' }}>
{navItems.map((item) => (
<NavLink key={item.to} to={item.to} end={item.to === '/'}
className={({ isActive }) => `flex flex-col items-center py-2 px-2.5 text-[10px] leading-tight transition-colors shrink-0 ${isActive ? 'text-blue-900 font-semibold' : 'text-gray-500'}`}>
<span className="text-lg mb-0.5">{item.icon}</span>
<span className="whitespace-nowrap">{item.label}</span>
</NavLink>
))}
</div>
</nav>
</div>
)
}
+55
View File
@@ -0,0 +1,55 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
export default function Login() {
const [identifier, setIdentifier] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const { login, user } = useAuth()
const navigate = useNavigate()
if (user) {
navigate('/', { replace: true })
return null
}
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setBusy(true)
try {
await login(identifier, password)
navigate('/', { replace: true })
} catch (err) {
setError(err.message)
} finally {
setBusy(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-900 to-blue-700 p-4">
<div className="bg-white rounded-2xl shadow-2xl p-8 w-full max-w-sm">
<div className="text-center mb-6">
<img src="/logo.png" alt="Logo" className="w-20 h-20 object-contain mx-auto mb-3" />
<h1 className="text-2xl font-bold text-gray-800">Dyna Biz Coignières</h1>
<p className="text-gray-500 text-sm">Espace membres</p>
</div>
{error && <div className="bg-red-100 text-red-700 rounded-lg p-3 mb-4 text-sm">{error}</div>}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Identifiant</label>
<input type="text" required value={identifier} onChange={(e) => setIdentifier(e.target.value)} className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none text-base" placeholder="Votre prénom" autoComplete="username" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Mot de passe</label>
<input type="password" required value={password} onChange={(e) => setPassword(e.target.value)} className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none text-base" placeholder="••••••••" autoComplete="current-password" />
</div>
<button type="submit" disabled={busy} className="w-full bg-blue-900 text-white py-3 rounded-lg font-semibold text-base hover:bg-blue-800 active:bg-blue-950 transition disabled:opacity-50">{busy ? 'Connexion...' : 'Se connecter'}</button>
</form>
</div>
</div>
)
}
+309
View File
@@ -0,0 +1,309 @@
import { useState, useEffect, useRef } from 'react'
import { useParams, Link, useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
export default function MemberDetail() {
const { id } = useParams()
const { authFetch, user } = useAuth()
const navigate = useNavigate()
const [member, setMember] = useState(null)
const [absences, setAbsences] = useState([])
const [members, setMembers] = useState([])
const [guests, setGuests] = useState([])
const [showForm, setShowForm] = useState(false)
const [form, setForm] = useState({ date_absence: '', motif: '', prevenu: null, remplace_par_type: 'member', remplace_par_id: '', remplace_par_guest_id: '' })
const [error, setError] = useState('')
const [submitting, setSubmitting] = useState(false)
const [slides, setSlides] = useState([])
const [slideFiles, setSlideFiles] = useState([])
const [slideDate, setSlideDate] = useState('')
const [uploadBusy, setUploadBusy] = useState(false)
const fileInputRef = useRef(null)
useEffect(() => {
authFetch(`/api/members/${id}`).then((r) => (r.ok ? r.json() : null)).then(setMember)
}, [id])
useEffect(() => {
authFetch(`/api/member-slides/${id}`).then((r) => (r.ok ? r.json() : [])).then(setSlides)
}, [id])
useEffect(() => {
if (user?.role === 'admin') {
authFetch(`/api/absences/member/${id}`).then((r) => (r.ok ? r.json() : [])).then(setAbsences)
authFetch('/api/members').then((r) => (r.ok ? r.json() : [])).then((list) => setMembers(list.filter(m => m.id !== Number(id))))
authFetch('/api/guests').then((r) => (r.ok ? r.json() : [])).then(setGuests)
}
}, [id])
const handleAdd = async (e) => {
e.preventDefault(); setError('')
if (!form.date_absence) { setError('Veuillez sélectionner une date'); return }
if (form.prevenu === null) { setError('Veuillez indiquer si le membre a prévenu'); return }
setSubmitting(true)
try {
const r = await authFetch('/api/absences', {
method: 'POST',
body: JSON.stringify({
membre_id: Number(id),
date_absence: form.date_absence,
motif: form.motif,
prevenu: form.prevenu,
remplace_par_type: form.remplace_par_type,
remplace_par_id: form.remplace_par_type === 'member' ? (form.remplace_par_id || null) : null,
remplace_par_guest_id: form.remplace_par_type === 'guest' ? (form.remplace_par_guest_id || null) : null,
})
})
if (!r.ok) { setError('Erreur lors de l\'enregistrement'); return }
setForm({ date_absence: '', motif: '', prevenu: null, remplace_par_type: 'member', remplace_par_id: '', remplace_par_guest_id: '' })
setShowForm(false)
const updated = await authFetch(`/api/absences/member/${id}`)
if (updated.ok) setAbsences(await updated.json())
} finally {
setSubmitting(false)
}
}
const handleDelete = async (absenceId) => {
const r = await authFetch(`/api/absences/${absenceId}`, { method: 'DELETE' })
if (r.ok) setAbsences(absences.filter(a => a.id !== absenceId))
}
const handleUploadSlide = async () => {
if (slideFiles.length === 0 || !slideDate) return
setUploadBusy(true)
try {
for (const file of slideFiles) {
const fd = new FormData()
fd.append('file', file)
fd.append('date_key', slideDate.replace(/-/g, '').slice(0, 8))
const r = await authFetch('/api/member-slides/upload', { method: 'POST', body: fd })
if (!r.ok) {
let msg = `Erreur pour ${file.name}`
try { const err = await r.json(); msg = err.error || msg } catch {}
throw new Error(msg)
}
}
setSlideFiles([])
setSlideDate('')
if (fileInputRef.current) fileInputRef.current.value = ''
const list = await authFetch(`/api/member-slides/${id}`).then((r) => (r.ok ? r.json() : []))
setSlides(list)
} catch (err) {
alert(err.message)
} finally {
setUploadBusy(false)
}
}
const handleDeleteSlide = async (slideId) => {
if (!confirm('Supprimer cette slide ?')) return
try {
await authFetch(`/api/member-slides/${slideId}`, { method: 'DELETE' })
setSlides((prev) => prev.filter((s) => s.id !== slideId))
} catch (err) {
alert(err.message)
}
}
const formatDateKey = (key) => {
if (key.length === 8) return `${key.slice(0, 2)}/${key.slice(2, 4)}/${key.slice(4, 8)}`
return key
}
const isVideoFile = (filename) => {
const ext = filename?.toLowerCase().split('.').pop()
return ['mp4', 'webm', 'ogg', 'mov', 'avi', 'mkv'].includes(ext)
}
if (!member) return <p className="text-center text-gray-400 py-8">Chargement...</p>
const isOwner = user?.id === member.id || user?.role === 'admin'
return (
<div className="space-y-4">
<Link to="/members" className="text-blue-700 text-sm">&larr; Retour</Link>
<div className="bg-white rounded-xl p-6 shadow-sm text-center">
{member.photo_url ? (
<img src={member.photo_url} alt="" className="w-20 h-20 rounded-full object-cover mx-auto mb-3" />
) : (
<div className="w-20 h-20 bg-blue-900 rounded-full flex items-center justify-center text-white text-2xl font-bold mx-auto mb-3">
{member.prenom[0]}{member.nom[0]}
</div>
)}
<h2 className="text-xl font-bold text-gray-800">{member.prenom} {member.nom}</h2>
{member.fonction && <p className="text-lg">{member.fonction_emoji || ''} {member.fonction}</p>}
{member.societe && <p className="text-gray-500">{member.societe}</p>}
{member.activite && <p className="text-gray-400 text-sm">{member.activite}</p>}
{isOwner && (
<button onClick={() => navigate(`/members/edit/${member.id}`)} className="mt-3 bg-blue-900 text-white px-4 py-2 rounded-lg text-sm font-semibold">
Modifier mon profil
</button>
)}
</div>
<div className="bg-white rounded-xl p-4 shadow-sm space-y-3">
{member.telephone && (
<a href={`tel:${member.telephone}`} className="flex items-center gap-3 text-green-600">
<span className="text-lg">📞</span>
<span className="font-medium">{member.telephone}</span>
</a>
)}
{member.email && (
<a href={`mailto:${member.email}`} className="flex items-center gap-3 text-blue-600">
<span className="text-lg">✉</span>
<span className="font-medium">{member.email}</span>
</a>
)}
{member.site_web && (
<a href={member.site_web.startsWith('http') ? member.site_web : 'https://' + member.site_web} target="_blank" rel="noopener noreferrer" className="flex items-center gap-3 text-purple-600">
<span className="text-lg">🌐</span>
<span className="font-medium">{member.site_web}</span>
</a>
)}
{member.date_adhesion && (
<p className="text-sm text-gray-500">Adhésion : {new Date(member.date_adhesion).toLocaleDateString('fr-FR')}</p>
)}
{member.date_echeance && (
<p className="text-sm text-gray-500">Échéance : {new Date(member.date_echeance).toLocaleDateString('fr-FR')}</p>
)}
{member.kdrive_link && (
<a href={member.kdrive_link} target="_blank" rel="noopener noreferrer" className="flex items-center gap-3 text-indigo-600">
<span className="text-lg">📁</span>
<span className="font-medium">Documents membre</span>
</a>
)}
</div>
{isOwner && (
<div className="bg-white rounded-xl p-5 shadow-sm space-y-4">
<h3 className="font-semibold text-gray-800 text-lg">Mes présentations</h3>
<p className="text-sm text-gray-500">Importe une image, vidéo ou SVG qui sera affiché après ta fiche membre lors de la présentation. La date clé doit correspondre au jour de la présentation (format <strong>DDMMYYYY</strong>).</p>
<div className="flex flex-col sm:flex-row sm:items-end gap-3">
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 mb-1">Fichiers (image, vidéo ou SVG)</label>
<input ref={fileInputRef} type="file" accept="image/*,video/*,.svg" multiple onChange={(e) => setSlideFiles([...e.target.files])}
className="w-full text-sm text-gray-500 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-blue-900 file:text-white hover:file:bg-blue-800" />
{slideFiles.length > 0 && <p className="text-xs text-gray-500 mt-1">{slideFiles.length} fichier(s) sélectionné(s) : {slideFiles.map(f => f.name).join(', ')}</p>}
</div>
<div className="w-full sm:w-40">
<label className="block text-sm font-medium text-gray-700 mb-1">Date clé (DDMMYYYY)</label>
<input type="text" value={slideDate} onChange={(e) => setSlideDate(e.target.value.replace(/[^0-9]/g, '').slice(0, 8))}
placeholder="16062026" className="w-full px-3 py-2 rounded-lg border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-sm" />
</div>
<button onClick={handleUploadSlide} disabled={uploadBusy || slideFiles.length === 0 || slideDate.length !== 8}
className="w-full sm:w-auto bg-blue-900 text-white px-5 py-2 rounded-lg font-medium text-sm hover:bg-blue-800 active:bg-blue-950 transition disabled:opacity-50 shrink-0">
{uploadBusy ? 'Envoi...' : 'Envoyer ma présentation'}
</button>
</div>
{slides.length > 0 ? (
<div className="space-y-2">
{slides.map((s) => (
<div key={s.id} className="flex items-center justify-between bg-gray-50 rounded-lg px-4 py-2">
<div className="flex items-center gap-3">
<div className="w-12 h-8 rounded bg-gray-200 flex items-center justify-center text-xs text-gray-500 overflow-hidden">
{s.filename?.toLowerCase().endsWith('.pdf') ? (
<span className="font-bold text-red-500">PDF</span>
) : isVideoFile(s.filename) ? (
<span className="text-purple-600 font-bold text-lg">▶</span>
) : (
<img src={`/member-slides/${s.filename}`} alt="" className="w-full h-full object-cover" />
)}
</div>
<div>
<p className="text-sm font-medium text-gray-700">{s.original_name}</p>
<p className="text-xs text-gray-400">Date : {formatDateKey(s.date_key)}</p>
</div>
</div>
<button onClick={() => handleDeleteSlide(s.id)} className="text-red-500 hover:text-red-700 text-sm font-medium">Supprimer</button>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-400 italic">Aucune slide uploadée</p>
)}
</div>
)}
{user?.role === 'admin' && (
<div className="bg-white rounded-xl p-4 shadow-sm">
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-gray-700">Absences ({absences.length})</h3>
<button onClick={() => setShowForm(!showForm)} className="text-sm bg-red-100 text-red-700 px-3 py-1 rounded-lg font-medium">
{showForm ? 'Annuler' : '+ Ajouter'}
</button>
</div>
{showForm && (
<form onSubmit={handleAdd} className="space-y-2 mb-3 p-3 bg-gray-50 rounded-lg">
{error && <p className="text-red-600 text-sm">{error}</p>}
<div>
<label className="block text-xs text-gray-600 mb-1">Date de l'absence</label>
<input type="date" required value={form.date_absence} onChange={(e) => setForm({ ...form, date_absence: e.target.value })} className="w-full px-3 py-2 rounded-lg border text-sm" />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Motif (optionnel)</label>
<input type="text" value={form.motif} onChange={(e) => setForm({ ...form, motif: e.target.value })} className="w-full px-3 py-2 rounded-lg border text-sm" placeholder="Ex : Maladie, déplacement..." />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1.5">Prévenu ?</label>
<div className="flex gap-2">
<button type="button" onClick={() => setForm({ ...form, prevenu: true })} className={`flex-1 py-2 rounded-lg text-sm font-medium ${form.prevenu === true ? 'bg-green-600 text-white' : 'bg-white border text-gray-600'}`}>Oui</button>
<button type="button" onClick={() => setForm({ ...form, prevenu: false })} className={`flex-1 py-2 rounded-lg text-sm font-medium ${form.prevenu === false ? 'bg-red-600 text-white' : 'bg-white border text-gray-600'}`}>Non</button>
</div>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Remplacé par</label>
<div className="flex gap-2 mb-2">
<button type="button" onClick={() => setForm({ ...form, remplace_par_type: 'member', remplace_par_guest_id: '' })}
className={`flex-1 py-1.5 rounded-lg text-xs font-medium ${form.remplace_par_type === 'member' ? 'bg-blue-600 text-white' : 'bg-white border text-gray-600'}`}>Membre</button>
<button type="button" onClick={() => setForm({ ...form, remplace_par_type: 'guest', remplace_par_id: '' })}
className={`flex-1 py-1.5 rounded-lg text-xs font-medium ${form.remplace_par_type === 'guest' ? 'bg-blue-600 text-white' : 'bg-white border text-gray-600'}`}>Invité</button>
</div>
{form.remplace_par_type === 'member' ? (
<select value={form.remplace_par_id} onChange={(e) => setForm({ ...form, remplace_par_id: e.target.value })} className="w-full px-3 py-2 rounded-lg border text-sm bg-white">
<option value="">Non remplacé</option>
{members.map((m) => (
<option key={m.id} value={m.id}>{m.prenom} {m.nom}</option>
))}
</select>
) : (
<select value={form.remplace_par_guest_id} onChange={(e) => setForm({ ...form, remplace_par_guest_id: e.target.value })} className="w-full px-3 py-2 rounded-lg border text-sm bg-white">
<option value="">Non remplacé</option>
{guests.map((g) => (
<option key={g.id} value={g.id}>{g.prenom} {g.nom}{g.societe ? ` (${g.societe})` : ''}</option>
))}
</select>
)}
</div>
<button type="submit" disabled={submitting} className="w-full bg-red-600 text-white py-2 rounded-lg text-sm font-semibold disabled:opacity-50">Enregistrer l'absence</button>
</form>
)}
{absences.length > 0 ? (
<div className="space-y-2">
{absences.map((a) => (
<div key={a.id} className="flex items-center justify-between text-sm border-b border-gray-100 pb-2">
<div>
<span className="font-medium">{new Date(a.date_absence).toLocaleDateString('fr-FR')}</span>
{a.motif && <span className="text-gray-400 ml-2">- {a.motif}</span>}
<div className="flex gap-2 mt-0.5">
<span className={`text-xs px-1.5 py-0.5 rounded ${a.prevenu ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>{a.prevenu ? 'Prévenu' : 'Non prévenu'}</span>
{a.remplace_par_id || a.remplace_par_guest_id ? (
<span className="text-xs bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded">Remplacé par {a.remplace_par_type === 'guest' ? `${a.remplace_guest_prenom} ${a.remplace_guest_nom}` : `${a.remplace_prenom} ${a.remplace_nom}`}</span>
) : a.prevenu === 0 && (
<span className="text-xs bg-gray-100 text-gray-500 px-1.5 py-0.5 rounded">Non remplacé</span>
)}
</div>
</div>
<button onClick={() => handleDelete(a.id)} className="text-red-500 text-xs font-medium">Suppr.</button>
</div>
))}
</div>
) : (
<p className="text-gray-400 text-sm">Aucune absence enregistrée</p>
)}
</div>
)}
</div>
)
}
+58
View File
@@ -0,0 +1,58 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
export default function MembersList() {
const { authFetch, user } = useAuth()
const [members, setMembers] = useState([])
const [search, setSearch] = useState('')
useEffect(() => {
authFetch('/api/members').then((r) => (r.ok ? r.json() : [])).then(setMembers)
}, [])
const handleDelete = async (id) => {
if (!confirm('D\u00e9sactiver ce membre ?')) return
const r = await authFetch(`/api/members/${id}`, { method: 'DELETE' })
if (r.ok) { authFetch('/api/members').then((r) => (r.ok ? r.json() : [])).then(setMembers) }
}
const filtered = members.filter((m) => {
const q = search.toLowerCase()
return m.prenom.toLowerCase().includes(q) || m.nom.toLowerCase().includes(q) || (m.societe || '').toLowerCase().includes(q) || (m.activite || '').toLowerCase().includes(q)
})
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold text-gray-800">Membres</h2>
{user?.role === 'admin' && (
<Link to="/members/add" className="bg-blue-900 text-white px-4 py-2 rounded-xl text-sm font-semibold active:bg-blue-950">+ Ajouter</Link>
)}
</div>
<input type="text" placeholder="Rechercher..." value={search} onChange={(e) => setSearch(e.target.value)} className="w-full px-4 py-3 rounded-xl border border-gray-300 outline-none focus:ring-2 focus:ring-blue-500 text-base" />
<div className="space-y-3">
{filtered.map((m) => (
<Link key={m.id} to={`/members/${m.id}`} className="block bg-white rounded-xl p-4 shadow-sm active:bg-gray-50">
<div className="flex items-center gap-3">
{m.photo_url ? (
<img src={m.photo_url} alt="" className="w-10 h-10 rounded-full object-cover" />
) : (
<div className="w-10 h-10 bg-blue-900 rounded-full flex items-center justify-center text-white font-bold text-sm shrink-0">{m.prenom[0]}{m.nom[0]}</div>
)}
<div className="flex-1 min-w-0">
<p className="font-semibold text-gray-800">{m.prenom} {m.nom}</p>
{m.fonction && <p className="text-sm text-gray-500 truncate">{m.fonction_emoji || ''} {m.fonction}</p>}
<p className="text-xs text-gray-400 truncate">{m.societe || m.activite || ''}</p>
</div>
{user?.role === 'admin' && (
<button onClick={(e) => { e.preventDefault(); handleDelete(m.id) }} className="text-xs text-red-500 font-medium shrink-0">Suppr.</button>
)}
</div>
</Link>
))}
{filtered.length === 0 && <p className="text-center text-gray-400 py-8">Aucun membre trouv\u00e9</p>}
</div>
</div>
)
}
+140
View File
@@ -0,0 +1,140 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
const notifyCountChanged = () => window.dispatchEvent(new Event('notifications-updated'))
export default function Notifications() {
const { authFetch } = useAuth()
const [notifs, setNotifs] = useState([])
const [loading, setLoading] = useState(true)
const [dismissedIds, setDismissedIds] = useState(() => {
try { return JSON.parse(localStorage.getItem('dismissedInvites') || '[]') } catch { return [] }
})
const [inviteModal, setInviteModal] = useState(null)
const [sending, setSending] = useState(false)
const saveDismissed = (ids) => {
setDismissedIds(ids)
localStorage.setItem('dismissedInvites', JSON.stringify(ids))
}
const load = () => {
setLoading(true)
authFetch('/api/notifications').then((r) => (r.ok ? r.json() : [])).then((data) => {
setNotifs(data)
setLoading(false)
})
}
useEffect(load, [])
const markRead = async (n) => {
if (n.source === 'pending_invite') {
setNotifs((prev) => prev.map((x) => (x.id === n.id ? { ...x, read_at: new Date().toISOString() } : x)))
notifyCountChanged()
setInviteModal(n)
return
}
await authFetch(`/api/notifications/${n.id}/read`, { method: 'POST' })
setNotifs((prev) => prev.map((x) => (x.id === n.id ? { ...x, read_at: new Date().toISOString() } : x)))
notifyCountChanged()
}
const dismissNotif = async (n, e) => {
e.stopPropagation()
if (n.source === 'pending_invite') {
saveDismissed([...dismissedIds, n.id])
} else {
await authFetch(`/api/notifications/${n.id}/recipient`, { method: 'DELETE' })
}
setNotifs((prev) => prev.filter((x) => x.id !== n.id))
notifyCountChanged()
}
const markAllRead = async () => {
await authFetch('/api/notifications/read-all', { method: 'POST' })
setNotifs((prev) => prev.map((n) => (n.source !== 'pending_invite' ? { ...n, read_at: new Date().toISOString() } : n)))
notifyCountChanged()
}
const sendInvitation = async () => {
if (!inviteModal) return
setSending(true)
try {
const r = await authFetch(`/api/guests/${inviteModal.invite_id}/send-invitation`, { method: 'POST' })
if (r.ok) {
saveDismissed([...dismissedIds, inviteModal.id])
setNotifs((prev) => prev.filter((x) => x.id !== inviteModal.id))
setInviteModal(null)
notifyCountChanged()
}
} finally {
setSending(false)
}
}
const visibleNotifs = notifs.filter((n) => n.source !== 'pending_invite' || !dismissedIds.includes(n.id))
const formatDate = (d) => {
const date = new Date(d)
return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Link to="/" className="text-blue-700 text-sm">&larr; Retour</Link>
{notifs.some((n) => n.source !== 'pending_invite' && !n.read_at) && (
<button onClick={markAllRead} className="text-sm text-blue-700 font-medium">Tout marquer comme lu</button>
)}
</div>
<h2 className="text-xl font-bold text-gray-800">Notifications</h2>
{loading && <p className="text-center text-gray-400 py-8">Chargement...</p>}
{!loading && visibleNotifs.length === 0 && <p className="text-center text-gray-400 py-8">Aucune notification</p>}
<div className="space-y-2">
{visibleNotifs.map((n) => (
<div key={n.id}
onClick={() => { if (!n.read_at) markRead(n) }}
className={`bg-white rounded-xl p-4 shadow-sm cursor-pointer transition relative group ${n.read_at ? 'opacity-70' : ''} ${!n.read_at && n.source !== 'pending_invite' ? 'border-l-4 border-blue-900' : ''} ${n.source === 'pending_invite' && !dismissedIds.includes(n.id) ? 'border-l-4 border-yellow-500' : ''}`}>
<div className="flex items-start justify-between gap-2">
<h3 className={`font-semibold text-sm ${n.source === 'pending_invite' ? 'text-yellow-800' : 'text-gray-800'}`}>{n.title}</h3>
<div className="flex items-center gap-2 shrink-0">
{!n.read_at && <span className="bg-blue-900 text-white text-[10px] px-2 py-0.5 rounded-full">Nouveau</span>}
<button onClick={(e) => dismissNotif(n, e)}
className="text-gray-400 hover:text-red-600 transition opacity-0 group-hover:opacity-100 text-lg leading-none">&times;</button>
</div>
</div>
<p className="text-gray-600 text-sm mt-1">{n.body}</p>
<p className="text-xs text-gray-400 mt-2">{formatDate(n.created_at)}</p>
</div>
))}
</div>
{inviteModal && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50"
onClick={() => { if (!sending) setInviteModal(null) }}>
<div className="bg-white rounded-2xl p-6 max-w-sm w-full mx-4 shadow-xl"
onClick={(e) => e.stopPropagation()}>
<h3 className="font-bold text-lg text-gray-800 mb-2">Envoyer l'invitation ?</h3>
<p className="text-gray-600 text-sm mb-6">
Voulez-vous envoyer l'invitation à <strong>{inviteModal.guest_name}</strong> ?
</p>
<div className="flex gap-3">
<button onClick={sendInvitation} disabled={sending}
className="flex-1 bg-blue-900 hover:bg-blue-800 disabled:opacity-50 text-white py-2.5 rounded-xl font-medium text-sm transition">
{sending ? 'Envoi...' : 'Oui, envoyer'}
</button>
<button onClick={() => setInviteModal(null)} disabled={sending}
className="flex-1 bg-gray-100 hover:bg-gray-200 text-gray-700 py-2.5 rounded-xl font-medium text-sm transition">
Non, merci
</button>
</div>
</div>
</div>
)}
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,765 @@
import { useState, useEffect, useRef } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
const SPHERE_COLORS = {
'Gestion humaine & social': { bg: 'bg-orange-500/20', border: 'border-orange-400', text: 'text-orange-300', hex: '#f97316' },
'Tech & Numérique': { bg: 'bg-blue-500/20', border: 'border-blue-400', text: 'text-blue-300', hex: '#3b82f6' },
'Immo & Espace de vie': { bg: 'bg-green-500/20', border: 'border-green-400', text: 'text-green-300', hex: '#22c55e' },
'Image & Média': { bg: 'bg-yellow-500/20', border: 'border-yellow-400', text: 'text-yellow-300', hex: '#eab308' },
}
const SPHERES = [
{ icon: '👥', title: 'Gestion humaine & social', color: SPHERE_COLORS['Gestion humaine & social'] },
{ icon: '💻', title: 'Tech & Numérique', color: SPHERE_COLORS['Tech & Numérique'] },
{ icon: '🏠', title: 'Immo & Espace de vie', color: SPHERE_COLORS['Immo & Espace de vie'] },
{ icon: '📸', title: 'Image & Média', color: SPHERE_COLORS['Image & Média'] },
]
const MEMBER_SPHERE = {
'Christelle': 'Gestion humaine & social', 'Estelle': 'Gestion humaine & social', 'Anaïs': 'Gestion humaine & social', 'Morgane': 'Gestion humaine & social', 'Amandine': 'Gestion humaine & social', 'Mathieu': 'Gestion humaine & social',
'Pierre': 'Tech & Numérique',
'Henrique': 'Immo & Espace de vie', 'Patricia': 'Immo & Espace de vie', 'Laetitia': 'Immo & Espace de vie', 'Jennifer': 'Immo & Espace de vie',
'Marie': 'Image & Média', 'Bastien': 'Image & Média', 'Miguel': 'Image & Média',
}
function getMemberSphere(prenom) {
const name = Object.keys(MEMBER_SPHERE).find(k => k.toLowerCase() === prenom.toLowerCase())
return name ? SPHERE_COLORS[MEMBER_SPHERE[name]] : null
}
const VALUES = [
{ title: 'Création du club', sub: 'Mars 2026' },
{ title: 'Nos valeurs', sub: 'Confiance, bienveillance, partage & bonne humeur' },
{ title: 'Fréquence', sub: 'Toutes les 2 semaines' },
{ title: 'Exclusivité', sub: 'Exclusivité des métiers' },
{ title: '1re marque française', sub: 'sur les Clubs d\'Affaires' },
{ title: 'Notre mission', sub: 'Dynamiser l\'économie du territoire & booster son CA' },
]
const LOOKING_FOR = [
'Hôtellerie', 'Expert comptable', 'Sécurité Alarme',
'Concessionnaire Auto', 'Paysagiste', 'Téléphonie / Internet',
]
const PROSPECT_METHODS = [
'Votre réseau', 'Vos clients', 'LinkedIn / Facebook',
'Événement Networking', 'Prospection téléphonique', 'Club de sport',
]
const PROGRAM = [
{ time: '11h00', label: 'Accueil des invités' },
{ time: '11h05', label: 'Rappel des fondamentaux du club d\'affaires' },
{ time: '11h10', label: 'Pitch des membres & des invités' },
{ time: '12h00', label: 'Coffee break' },
{ time: '12h15', label: 'Mise à l\'honneur d\'un membre' },
{ time: '12h25', label: 'Actualités du réseau Dynabuy' },
{ time: '12h30', label: 'Tour de table des recommandations & Clôture' },
]
function playGong() {
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)()
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.connect(gain); gain.connect(ctx.destination)
osc.type = 'sine'
osc.frequency.setValueAtTime(440, ctx.currentTime)
osc.frequency.exponentialRampToValueAtTime(220, ctx.currentTime + 0.8)
gain.gain.setValueAtTime(0.5, ctx.currentTime)
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 1.5)
osc.start(ctx.currentTime); osc.stop(ctx.currentTime + 1.5)
} catch {}
}
function playBeep() {
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)()
const osc = ctx.createOscillator(); const gain = ctx.createGain()
osc.connect(gain); gain.connect(ctx.destination)
osc.type = 'square'; osc.frequency.setValueAtTime(880, ctx.currentTime)
gain.gain.setValueAtTime(0.3, ctx.currentTime)
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.2)
osc.start(ctx.currentTime); osc.stop(ctx.currentTime + 0.2)
} catch {}
}
function PitchTimer({ duration = 90, autoStart = false } = {}) {
const [remaining, setRemaining] = useState(duration)
const [running, setRunning] = useState(false)
const intervalRef = useRef(null)
const countRef = useRef(duration)
const clear = () => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
}
const startTimer = () => {
clear()
setRunning(true)
countRef.current = duration
setRemaining(duration)
intervalRef.current = setInterval(() => {
countRef.current--
setRemaining(countRef.current)
if (countRef.current <= 0) {
clear()
setRunning(false)
playGong()
} else if (countRef.current <= 5) {
playBeep()
}
}, 1000)
}
const stopTimer = () => {
clear()
setRunning(false)
}
const resetTimer = () => {
clear()
setRunning(false)
setRemaining(duration)
}
useEffect(() => {
if (autoStart) startTimer()
else resetTimer()
}, [autoStart])
useEffect(() => clear, [])
const mins = Math.floor(remaining / 60)
const secs = remaining % 60
const pct = remaining / duration
const color = pct > 0.5 ? 'text-green-400' : pct > 0.2 ? 'text-yellow-400' : 'text-red-400'
return (
<div className="flex flex-col items-center gap-2">
<div className="text-6xl sm:text-7xl font-mono font-bold tracking-wider tabular-nums">
<span className={color}>{String(mins).padStart(2, '0')}:{String(secs).padStart(2, '0')}</span>
</div>
<div className="w-40 sm:w-56 h-1.5 bg-white/20 rounded-full overflow-hidden">
<div className={`h-full rounded-full transition-all ${pct > 0.5 ? 'bg-green-400' : pct > 0.2 ? 'bg-yellow-400' : 'bg-red-400'}`}
style={{ width: `${pct * 100}%` }} />
</div>
<div className="flex gap-2">
{!running ? (
<button onClick={startTimer} className="bg-white/20 hover:bg-white/30 text-white px-4 py-1.5 rounded-full text-xs font-medium">
{remaining === duration ? 'Démarrer' : 'Reprendre'}
</button>
) : (
<button onClick={stopTimer} className="bg-white/20 hover:bg-white/30 text-yellow-300 px-4 py-1.5 rounded-full text-xs font-medium">Pause</button>
)}
{remaining < duration && !running && (
<button onClick={resetTimer} className="bg-white/10 hover:bg-white/20 text-white/80 px-4 py-1.5 rounded-full text-xs">Reset</button>
)}
</div>
</div>
)
}
function Slide({ children, className = '' }) {
return <div className={`text-white w-full h-full flex flex-col items-center justify-center ${className}`}>{children}</div>
}
function parsePhoto(path) {
if (!path) return null
if (path.startsWith('http')) return path
if (path.startsWith('/uploads')) return path
return `/uploads/${path.replace(/^\/?/, '')}`
}
export default function Presentation() {
const { date } = useParams()
const navigate = useNavigate()
const { authFetch } = useAuth()
const [data, setData] = useState(null)
const [slideIndex, setSlideIndex] = useState(0)
const [paused, setPaused] = useState(false)
const [started, setStarted] = useState(false)
const autoTimerRef = useRef(null)
const [loaded, setLoaded] = useState(false)
const [error, setError] = useState('')
const [pdfReady, setPdfReady] = useState(false)
const [timerStarted, setTimerStarted] = useState(false)
const pdfRef = useRef(null)
const audioRef = useRef(null)
const isPdf = typeof window !== 'undefined' && window.location.search.includes('pdf=1')
useEffect(() => {
authFetch(`/api/presentation/${date}`).then((r) => {
if (!r.ok) { setError('Erreur de chargement'); return }
return r.json()
}).then((d) => {
if (!d) return
if (d.members.length === 0 && d.guests.length === 0) { setError('Aucune donnée pour cette date'); return }
setData(d); setLoaded(true)
}).catch(() => setError('Erreur réseau'))
}, [date, authFetch])
useEffect(() => {
if (!isPdf || !loaded || !data) return
const timer = setTimeout(() => setPdfReady(true), 1000)
return () => clearTimeout(timer)
}, [isPdf, loaded, data])
useEffect(() => {
if (!pdfReady || !pdfRef.current) return
const script = document.createElement('script')
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js'
script.onload = () => {
window.html2pdf().set({
margin: [0.5, 0.5, 0.5, 0.5],
image: { type: 'jpeg', quality: 0.95 },
html2canvas: { scale: 2, useCORS: true, allowTaint: false },
jsPDF: { unit: 'in', format: 'a4', orientation: 'landscape' },
pagebreak: { mode: ['avoid-all', 'css', 'legacy'] }
}).from(pdfRef.current).save(`presentation-${date}.pdf`).then(() => {
setTimeout(() => window.close(), 1000)
})
}
script.onerror = () => { window.print() }
document.body.appendChild(script)
return () => { if (script.parentNode) script.parentNode.removeChild(script) }
}, [pdfReady, date])
useEffect(() => {
const audio = new Audio('/pupm-it_30sec.mp3')
audio.volume = 0.5
audioRef.current = audio
return () => { audio.pause(); audioRef.current = null }
}, [])
useEffect(() => {
const audio = audioRef.current
if (!audio || !slides.length) return
const type = slides[slideIndex]?.type
if (type === 'member-card' || type === 'guest-card') {
audio.currentTime = 0
audio.play().catch(() => {})
} else {
audio.pause()
audio.currentTime = 0
}
}, [slideIndex, slides])
const presentGuests = data ? data.guests : []
const absenceMap = {}
if (data?.absences) {
data.absences.forEach((a) => { absenceMap[a.membre_id] = a })
}
const memberSlideMap = {}
if (data?.memberSlides) {
data.memberSlides.forEach((ms) => {
if (!memberSlideMap[ms.member_id]) memberSlideMap[ms.member_id] = []
memberSlideMap[ms.member_id].push(ms)
})
}
const slides = data ? [
{ type: 'welcome' },
{ type: 'thankyou' },
{ type: 'relax' },
{ type: 'dreamteam', members: data.members },
{ type: 'program' },
{ type: 'values' },
{ type: 'spheres' },
{ type: 'lookingfor' },
{ type: 'prospect' },
{ type: 'play' },
{ type: 'pitch-member-intro' },
...data.members.flatMap((m) => {
const slides = [{ type: 'member-card', person: m, absence: absenceMap[m.id] || null }]
const msList = memberSlideMap[m.id] || []
msList.forEach((ms) => slides.push({ type: 'member-custom', person: m, filename: ms.filename }))
return slides
}),
{ type: 'pitch-guest-intro' },
...presentGuests.map((g) => ({ type: 'guest-card', person: g })),
{ type: 'coffee' },
{ type: 'honor' },
{ type: 'next-honor' },
{ type: 'club-news' },
{ type: 'events' },
{ type: 'recommendations' },
{ type: 'feedback' },
{ type: 'join' },
{ type: 'final' },
] : []
const totalSlides = slides.length
useEffect(() => {
if (!loaded || !started || paused) return
autoTimerRef.current = setInterval(() => {
setSlideIndex((prev) => Math.min(prev + 1, totalSlides - 1))
}, 6000)
return () => clearInterval(autoTimerRef.current)
}, [loaded, started, paused, totalSlides])
useEffect(() => {
const handler = (e) => {
if (e.key === 'Escape') { navigate(-1); return }
if (e.key === 'p' || e.key === 'P') { setPaused((p) => !p); return }
if (e.key === 'ArrowRight') { clearInterval(autoTimerRef.current); setSlideIndex((prev) => Math.min(prev + 1, totalSlides - 1)); setPaused(true) }
if (e.key === 'ArrowLeft') { clearInterval(autoTimerRef.current); setSlideIndex((prev) => Math.max(prev - 1, 0)); setPaused(true) }
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [navigate, totalSlides])
useEffect(() => { setTimerStarted(false) }, [slideIndex])
const handleSlideClick = (e) => {
if (e.target.tagName === 'BUTTON' || e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return
const type = slides[slideIndex]?.type
if ((type === 'member-card' || type === 'guest-card') && !timerStarted) {
setTimerStarted(true)
return
}
goNext()
}
const goNext = () => { clearInterval(autoTimerRef.current); setSlideIndex((prev) => Math.min(prev + 1, totalSlides - 1)); setPaused(true); setTimerStarted(false) }
const goPrev = () => { clearInterval(autoTimerRef.current); setSlideIndex((prev) => Math.max(prev - 1, 0)); setPaused(true); setTimerStarted(false) }
const formatDate = (d) => {
const parts = d.split('-')
const names = ['janvier', 'février', 'mars', 'avril', 'mai', 'juin', 'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre']
return `${parts[2]} ${names[parseInt(parts[1]) - 1]} ${parts[0]}`
}
const slide = slides[slideIndex]
if (!slide || !loaded) return null
function personCard(person, isGuest, absence) {
const sphere = isGuest ? null : getMemberSphere(person.prenom)
return (
<Slide key={isGuest ? `guest-${person.id}` : `member-${person.id}`}>
<div className="flex-1 flex items-center justify-center w-full max-w-4xl">
<div className="flex flex-col sm:flex-row items-center sm:items-stretch gap-6 sm:gap-8 w-full px-4">
<div className="shrink-0 flex flex-col items-center justify-center">
<div className={`w-40 h-40 sm:w-56 sm:h-56 rounded-full overflow-hidden border-4 shadow-xl bg-white/10 flex items-center justify-center ${sphere ? sphere.border : 'border-white/20'}`}>
{person.photo_url ? (
<img src={parsePhoto(person.photo_url)} alt="" className="w-full h-full object-cover"
onError={(e) => { e.target.style.display = 'none'; e.target.parentElement.textContent = person.prenom[0] + person.nom[0] }} />
) : (
<span className="text-5xl font-bold text-white/40">{person.prenom[0]}{person.nom[0]}</span>
)}
</div>
{sphere && (
<div className={`mt-2 px-3 py-0.5 rounded-full text-xs font-bold ${sphere.bg} ${sphere.text} border ${sphere.border} whitespace-nowrap`}>
{Object.keys(SPHERE_COLORS).find(k => SPHERE_COLORS[k] === sphere)}
</div>
)}
</div>
<div className="hidden sm:block w-px bg-white/20 self-stretch" />
<div className="flex flex-col justify-center text-center sm:text-left gap-2">
<h2 className="text-3xl sm:text-5xl font-bold">
{person.prenom} {person.nom}
</h2>
<p className="text-xl sm:text-3xl text-blue-200 font-medium">{person.societe || person.activite || ''}</p>
{isGuest ? (
<p className="text-base sm:text-lg text-white/80">
Invité(e) par <span className="text-yellow-300 font-semibold">{person.invite_par_prenom} {person.invite_par_nom}</span>
{person.statut === 'acceptee' && <span className="text-green-300 ml-2">✓</span>}
</p>
) : person.fonction ? (
<p className="text-base sm:text-lg text-white/70">{person.fonction_emoji} {person.fonction}</p>
) : null}
<div className="flex flex-col justify-center sm:justify-start text-base sm:text-lg text-white/80 mt-1 gap-1">
{person.telephone && <span className="flex items-center gap-2"><svg viewBox="0 0 24 24" fill="none" stroke="#60a5fa" strokeWidth="2" className="w-4 h-4 shrink-0"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg><span className="text-white/40">Tél :</span> {person.telephone}</span>}
{person.email && <span className="flex items-center gap-2"><svg viewBox="0 0 24 24" fill="none" stroke="#60a5fa" strokeWidth="2" className="w-4 h-4 shrink-0"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg><span className="text-white/40">Mail :</span> {person.email}</span>}
{person.site_web && <span className="flex items-center gap-2"><svg viewBox="0 0 24 24" fill="none" stroke="#60a5fa" strokeWidth="2" className="w-4 h-4 shrink-0"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg><span className="text-white/40">Site internet :</span> {person.site_web}</span>}
</div>
{absence && (
<p className="text-base sm:text-lg font-semibold mt-2">
{absence.remplace_par_id || absence.remplace_par_guest_id
? `Remplacé(e) par ${absence.remplace_par_type === 'guest' ? `${absence.remplace_guest_prenom} ${absence.remplace_guest_nom}` : `${absence.remplace_prenom} ${absence.remplace_nom}`}`
: 'Absent(e)'}
</p>
)}
</div>
</div>
</div>
<div className="shrink-0 -mt-8 sm:-mt-16 pb-2">
<PitchTimer duration={90} autoStart={timerStarted} />
</div>
</Slide>
)
}
function renderSlide(slide) {
switch (slide.type) {
case 'welcome':
return (
<Slide key="welcome" className="text-center">
<img src="/logo.png" alt="Dyna Biz" className="h-24 sm:h-32 mb-6 drop-shadow-lg" />
<h1 className="text-4xl sm:text-6xl font-bold mb-4">Bienvenue au club d'affaires</h1>
<p className="text-2xl sm:text-4xl text-blue-200 font-semibold">Dyna Biz Coignières</p>
<p className="text-lg sm:text-xl text-blue-300/80 mt-6 italic">"Mieux se connaître pour mieux se recommander"</p>
</Slide>
)
case 'thankyou':
return (
<Slide key="thankyou" className="text-center">
<p className="text-2xl sm:text-4xl font-semibold mb-6">Un grand merci à</p>
<div className="bg-white/10 rounded-2xl p-8 sm:p-12 inline-block">
<img src="/padel_logo.png"
alt="Padelshot" className="h-24 sm:h-32 mx-auto" />
</div>
<p className="mt-4 text-xl sm:text-2xl text-blue-200">pour son accueil</p>
</Slide>
)
case 'relax':
return (
<Slide key="relax" className="text-center max-w-3xl mx-auto">
<p className="text-3xl sm:text-5xl font-bold mb-8 leading-relaxed">Un petit mot avant de commencer notre rendez-vous</p>
<p className="text-2xl sm:text-3xl text-blue-200 leading-relaxed">
Détendez-vous et ne prenez pas de notes.<br />Vous recevrez cette présentation au format PDF<br />avec toutes les informations.
</p>
</Slide>
)
case 'dreamteam':
return (
<Slide key="dreamteam" className="text-center">
<h2 className="text-2xl sm:text-4xl font-bold mb-6">Présentation de la Dream Team</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3 max-w-4xl mx-auto">
{slide.members.filter((m) => m.fonction && m.fonction.trim()).map((m) => (
<div key={m.id} className="bg-white/10 rounded-xl p-3 flex flex-col items-center gap-1.5">
<div className="w-12 h-12 sm:w-14 sm:h-14 rounded-full overflow-hidden bg-white/10 flex items-center justify-center shrink-0">
{m.photo_url ? (
<img src={parsePhoto(m.photo_url)} alt="" className="w-full h-full object-cover" onError={(e) => { e.target.style.display = 'none'; e.target.parentElement.textContent = m.prenom[0] + m.nom[0] }} />
) : (
<span className="text-lg font-bold text-white/60">{m.prenom[0]}{m.nom[0]}</span>
)}
</div>
<p className="text-sm sm:text-base font-semibold text-white leading-tight">{m.prenom} {m.nom}</p>
<p className="text-xs sm:text-sm text-blue-300 leading-tight">{m.fonction_emoji} {m.fonction}</p>
</div>
))}
</div>
</Slide>
)
case 'program':
return (
<Slide key="program" className="text-center">
<img src="/banniere.png" alt="" className="h-28 sm:h-40 mb-4 rounded-xl object-cover" />
<h2 className="text-2xl sm:text-4xl font-bold mb-4">Programme</h2>
<div className="max-w-xl mx-auto space-y-1.5 sm:space-y-2">
{PROGRAM.map((item, i) => (
<div key={i} className="flex items-center gap-3 sm:gap-4 bg-white/10 rounded-lg px-4 sm:px-6 py-2 sm:py-2.5">
<span className="text-blue-300 font-mono font-bold text-base sm:text-lg shrink-0 w-16 sm:w-20 text-right">{item.time}</span>
<span className="text-base sm:text-xl text-left">{item.label}</span>
</div>
))}
</div>
</Slide>
)
case 'values':
return (
<Slide key="values" className="text-center">
<h2 className="text-2xl sm:text-4xl font-bold mb-2">Les clubs d'affaires</h2>
<p className="text-lg sm:text-2xl text-blue-200 mb-6">Dynabuy en local</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 max-w-3xl mx-auto">
{VALUES.map((v, i) => (
<div key={i} className="bg-white/10 rounded-xl p-4 sm:p-5 flex flex-col items-center justify-center text-center min-h-[100px]">
<p className="text-base sm:text-lg font-semibold">{v.title}</p>
<p className="text-sm sm:text-base text-blue-200/90 mt-1">{v.sub}</p>
</div>
))}
</div>
</Slide>
)
case 'spheres':
return (
<Slide key="spheres" className="text-center">
<h2 className="text-2xl sm:text-4xl font-bold mb-6">Les sphères du Dyna Biz Coignières</h2>
<div className="flex flex-wrap justify-center gap-6 max-w-2xl mx-auto">
{SPHERES.map((s, i) => (
<div key={i} className={`w-36 h-36 sm:w-44 sm:h-44 rounded-full flex flex-col items-center justify-center p-4 ${s.color.bg} border-2 ${s.color.border}`}>
<span className="text-3xl sm:text-4xl block mb-1">{s.icon}</span>
<p className={`text-sm sm:text-base font-semibold ${s.color.text} leading-tight text-center`}>{s.title}</p>
</div>
))}
</div>
</Slide>
)
case 'lookingfor':
return (
<Slide key="lookingfor" className="text-center">
<h2 className="text-2xl sm:text-4xl font-bold mb-2">Nous recherchons</h2>
<p className="text-lg text-blue-200 mb-6">Des professionnels pour compléter notre réseau</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 max-w-2xl mx-auto">
{LOOKING_FOR.map((item, i) => (
<div key={i} className="bg-white/10 rounded-xl p-5 sm:p-6 flex items-center justify-center text-center min-h-[90px]">
<p className="text-lg sm:text-xl font-semibold">{item}</p>
</div>
))}
</div>
</Slide>
)
case 'prospect':
return (
<Slide key="prospect" className="text-center">
<h2 className="text-2xl sm:text-4xl font-bold mb-2">Comment prospecter ?</h2>
<p className="text-lg text-blue-200 mb-6">Les meilleures façons de développer votre réseau</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 max-w-2xl mx-auto">
{PROSPECT_METHODS.map((item, i) => (
<div key={i} className="bg-white/10 rounded-xl p-5 sm:p-6 flex items-center justify-center text-center min-h-[80px]">
<p className="text-sm sm:text-lg font-semibold">{item}</p>
</div>
))}
</div>
</Slide>
)
case 'play':
return (
<Slide key="play" className="text-center">
<h2 className="text-3xl sm:text-5xl font-bold mb-4">À vous de jouer !</h2>
<p className="text-xl sm:text-3xl text-blue-200 mb-8">Mieux me connaître</p>
<div className="max-w-xl mx-auto text-left space-y-4 bg-white/10 rounded-xl p-8">
{['Mon profil', 'Mon entreprise', 'Mes produits et services', 'Mes avantages concurrentiels', 'De quoi j\'ai besoin ?'].map((item, i) => (
<p key={i} className="text-lg sm:text-xl flex items-center gap-3"><span className="text-blue-300 font-bold">{i + 1}.</span> {item}</p>
))}
</div>
</Slide>
)
case 'pitch-member-intro':
return (
<Slide key="pitch-member-intro" className="text-center">
<h2 className="text-2xl sm:text-4xl font-bold mb-4">À vous de jouer !</h2>
<p className="text-xl sm:text-3xl text-blue-200 mb-6">Pitch des membres</p>
<p className="text-xl sm:text-2xl mb-8">1 minute 30 par personne</p>
</Slide>
)
case 'member-card':
return personCard(slide.person, false, slide.absence)
case 'member-custom':
const isPdf = slide.filename?.toLowerCase().endsWith('.pdf')
return (
<Slide key={`custom-${slide.person.id}-${slide.filename}`} className="text-center">
<div className="flex-1 flex flex-row items-center w-full min-h-0 px-2 sm:px-4 gap-4 sm:gap-6">
<div className="shrink-0">
<PitchTimer duration={90} />
</div>
<div className="flex-1 flex flex-col items-center justify-start min-h-0 self-stretch pt-6">
{isPdf ? (
<object data={`/member-slides/${slide.filename}`} type="application/pdf" className="w-full max-h-[75vh] rounded-xl shadow-lg">
<p className="text-white/70">Impossible d'afficher ce PDF</p>
</object>
) : (
<img src={`/member-slides/${slide.filename}`} alt="" className="max-w-full max-h-[75vh] rounded-xl object-contain shadow-lg" />
)}
</div>
</div>
</Slide>
)
case 'pitch-guest-intro':
return (
<Slide key="pitch-guest-intro" className="text-center">
<h2 className="text-2xl sm:text-4xl font-bold mb-4">À vous de jouer !</h2>
<p className="text-xl sm:text-3xl text-blue-200 mb-6">Pitch des invités</p>
<p className="text-lg sm:text-xl mb-8">1 minute 30 par personne</p>
</Slide>
)
case 'guest-card':
return personCard(slide.person, true)
case 'coffee':
return (
<Slide key="coffee" className="text-center">
<span className="text-6xl sm:text-8xl mb-4 block">☕</span>
<h2 className="text-4xl sm:text-6xl font-bold mb-4">Pause café</h2>
<p className="text-2xl sm:text-3xl text-blue-200">15 minutes</p>
<p className="text-lg sm:text-xl text-blue-300/90 mt-4">Profitez-en pour échanger vos cartes de visite</p>
</Slide>
)
case 'honor':
return (
<Slide key="honor" className="text-center">
<span className="text-5xl sm:text-7xl mb-4 block">🏆</span>
<h2 className="text-3xl sm:text-5xl font-bold mb-4">Mise à l'honneur</h2>
{data?.currentHonor ? (
<p className="text-3xl sm:text-5xl text-yellow-300 font-bold">{data.currentHonor}</p>
) : (
<p className="text-2xl sm:text-3xl text-blue-200">À définir</p>
)}
<p className="text-lg sm:text-xl text-blue-300/90 mt-4">10 minutes</p>
</Slide>
)
case 'next-honor':
return (
<Slide key="next-honor" className="text-center">
<span className="text-5xl sm:text-7xl mb-4 block">📅</span>
<h2 className="text-2xl sm:text-4xl font-bold mb-4">Prochaine mise à l'honneur</h2>
{data?.nextHonor ? (
<><p className="text-2xl sm:text-3xl text-blue-200 font-semibold">{data.nextHonor}</p><p className="text-base sm:text-lg text-blue-300/90 mt-4">Merci de faire tenir votre présentation en 10 minutes svp</p></>
) : <p className="text-lg text-blue-200">À définir</p>}
</Slide>
)
case 'club-news':
return (
<Slide key="club-news" className="text-center">
<h2 className="text-2xl sm:text-4xl font-bold mb-4">Actualités du club</h2>
<p className="text-xl sm:text-2xl text-blue-200 mb-6">Les dernières nouvelles du Dyna Biz Coignières</p>
<div className="max-w-lg mx-auto bg-white/10 rounded-xl p-8 text-left space-y-3">
<p className="text-lg">📢 À venir avec Estelle...</p>
<p className="text-base text-blue-200/90">Actualités du réseau Dynabuy et du club.</p>
</div>
</Slide>
)
case 'events':
return (
<Slide key="events" className="text-center">
<h2 className="text-2xl sm:text-4xl font-bold mb-4">Les prochaines rencontres</h2>
<div className="max-w-lg mx-auto space-y-2">
{data?.events && data.events.length > 0 ? data.events.map((ev, i) => (
<div key={i} className="bg-white/10 rounded-lg p-4 text-left flex items-start gap-3">
<span className="text-blue-300 font-mono text-sm shrink-0 mt-0.5">{new Date(ev.date_event).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })}</span>
<div><p className="text-base sm:text-lg font-semibold">{ev.titre}</p>{ev.heure_debut && <p className="text-sm text-blue-200/90">{ev.heure_debut}{ev.lieu ? ` — ${ev.lieu}` : ''}</p>}</div>
</div>
)) : (
<div className="bg-white/10 rounded-xl p-6"> <p className="text-xl text-blue-200">Aucun événement programmé pour le moment</p></div>
)}
</div>
</Slide>
)
case 'recommendations':
return (
<Slide key="recommendations" className="text-center">
<h2 className="text-2xl sm:text-4xl font-bold mb-4">Tour de table</h2>
<p className="text-lg sm:text-xl text-blue-200 mb-6">Recommandations reçues & succès</p>
<div className="max-w-lg mx-auto space-y-2 max-h-[50vh] overflow-y-auto px-2">
{data?.recommendations && data.recommendations.length > 0 ? data.recommendations.map((r) => (
<div key={r.id} className="bg-white/10 rounded-lg p-4 text-left text-base">
<p className="font-semibold text-blue-200">{r.prenom} {r.nom}{r.societe ? ` (${r.societe})` : ''}</p>
<p className="text-white/80 mt-1">{r.nom_prospect}</p>
<p className="text-xs text-white/50 mt-1">{r.type === 'donnee' ? '📤 Donnée' : '📥 Reçue'}{r.ca_realise ? ` — ${r.ca_realise}€` : ''}</p>
</div>
)) : <p className="text-base text-blue-200">Aucune recommandation pour le moment</p>}
</div>
</Slide>
)
case 'feedback':
return (
<Slide key="feedback" className="text-center max-w-3xl mx-auto">
<p className="text-2xl sm:text-4xl font-bold mb-4">Chèr(e)s invité(e)s !</p>
<p className="text-xl sm:text-2xl text-blue-200 leading-relaxed">Dyna Biz Coignières, c'est aussi votre regard.</p>
<p className="text-lg sm:text-xl text-white/80 mt-4 leading-relaxed">
Vos impressions comptent pour nous.<br /><br />Qu'est-ce qui vous a marqué ? Surpris ? Donné envie ?<br /><br />Merci pour votre retour, il compte pour nous<br />et pour l'évolution du club.
</p>
</Slide>
)
case 'join':
return (
<Slide key="join" className="text-center">
<h2 className="text-2xl sm:text-4xl font-bold mb-6">Rejoindre un Club d'Affaires</h2>
<div className="max-w-lg mx-auto space-y-4">
<div className="bg-white/10 rounded-xl p-6 sm:p-8"><p className="text-lg sm:text-xl font-semibold mb-3">Comment postuler ?</p><p className="text-base sm:text-lg text-blue-200">2 visites obligatoires et ensuite entretien de postulation</p></div>
<div className="bg-white/10 rounded-xl p-6 sm:p-8"><p className="text-lg sm:text-xl font-semibold mb-3">Formule membre classique</p><p className="text-base sm:text-lg text-blue-200">Adhésion 1 an</p><p className="text-2xl sm:text-3xl font-bold mt-2">800€ HT / an</p><p className="text-base sm:text-lg text-blue-200">ou 80€ HT / mois par société</p></div>
</div>
</Slide>
)
case 'final':
return (
<Slide key="final" className="text-center">
<img src="/logo.png" alt="Dyna Biz" className="h-16 sm:h-24 mb-6 mx-auto drop-shadow-lg" />
<p className="text-2xl sm:text-4xl font-bold mb-4">Le mot de la faim !</p>
<p className="text-xl sm:text-2xl text-blue-200">Place au networking et au buffet</p>
<div className="mt-8 text-white/50 text-base"><p>Merci à toutes et à tous pour votre présence</p><p className="mt-1">Dyna Biz Coignières</p></div>
</Slide>
)
default:
return <Slide key="empty"><p className="text-white">Slide inconnue</p></Slide>
}
}
if (error) {
return (
<div className="fixed inset-0 flex items-center justify-center z-50" style={{ background: 'linear-gradient(135deg, #0B2D5E 0%, #1a4a8a 50%, #0B2D5E 100%)' }}>
<div className="bg-white/10 backdrop-blur rounded-xl p-8 text-center max-w-sm mx-4">
<p className="text-white font-medium mb-4">{error}</p>
<button onClick={() => navigate(-1)} className="bg-white/20 hover:bg-white/30 text-white px-6 py-2 rounded-lg">Fermer</button>
</div>
</div>
)
}
if (!loaded) {
return (
<div className="fixed inset-0 flex items-center justify-center z-50" style={{ background: 'linear-gradient(135deg, #0B2D5E 0%, #1a4a8a 50%, #0B2D5E 100%)' }}>
<p className="text-white text-lg">Chargement...</p>
</div>
)
}
if (isPdf) {
return (
<div ref={pdfRef} style={{ background: 'linear-gradient(135deg, #0B2D5E 0%, #1a4a8a 50%, #0B2D5E 100%)' }}>
<div className="text-white" style={{ pageBreakAfter: 'always', pageBreakInside: 'avoid' }}>
{slides.map((s, i) => (
<div key={i} className="w-full h-screen flex items-center justify-center p-8" style={{ pageBreakAfter: 'always' }}>
{renderSlide(s)}
</div>
))}
</div>
</div>
)
}
return (
<div className="fixed inset-0 flex flex-col z-50 select-none" onClick={handleSlideClick} style={{ background: 'linear-gradient(135deg, #0B2D5E 0%, #1a4a8a 50%, #0B2D5E 100%)' }}>
{!started && (
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-black/40 backdrop-blur-sm">
<img src="/logo.png" alt="Dyna Biz" className="h-24 sm:h-32 mb-8 drop-shadow-lg" />
<button onClick={() => setStarted(true)}
className="bg-white/20 hover:bg-white/30 text-white text-xl sm:text-2xl px-10 py-4 rounded-full font-semibold transition-all hover:scale-105 active:scale-95">
Démarrer la présentation
</button>
</div>
)}
<div className="relative flex-1 flex flex-col overflow-hidden">
<div className="absolute inset-0 opacity-5 pointer-events-none" style={{ backgroundImage: 'radial-gradient(circle at 25% 50%, white 0%, transparent 50%), radial-gradient(circle at 75% 50%, white 0%, transparent 50%)' }} />
<div className="absolute top-0 left-0 right-0 z-10 flex items-center justify-between px-4 py-2">
<div className="flex items-center gap-2">
<img src="/logo.png" alt="" className="h-9 sm:h-12" />
<span className="text-white/50 text-sm">{formatDate(date)}</span>
</div>
<div className="flex items-center gap-2">
{paused && <span className="text-yellow-400 text-xs font-medium bg-yellow-400/10 px-2 py-0.5 rounded">PAUSE</span>}
<button onClick={() => navigate(-1)} className="text-white/40 hover:text-white text-xs bg-white/10 px-2 py-1 rounded">Quitter (Esc)</button>
</div>
</div>
<div className="flex-1 flex items-center justify-center p-4 sm:p-8 pt-14 pb-16">
<div className="w-full h-full flex items-center justify-center animate-fadeIn">
{renderSlide(slides[slideIndex])}
</div>
</div>
<div className="absolute bottom-0 left-0 right-0 flex items-center justify-between px-4 py-3 bg-black/10">
<button onClick={goPrev} disabled={slideIndex === 0} className="text-white/60 hover:text-white disabled:opacity-20 text-lg px-2">&larr;</button>
<div className="flex items-center gap-1 max-w-[60%] overflow-x-auto scrollbar-hide px-2">
{slides.map((s, i) => (
<button key={i} onClick={() => { clearInterval(autoTimerRef.current); setSlideIndex(i); setPaused(true) }}
className={`shrink-0 w-2 h-2 rounded-full transition-all ${i === slideIndex ? 'bg-white w-4' : 'bg-white/30 hover:bg-white/50'}`}
title={`Slide ${i + 1}`} />
))}
</div>
<div className="flex items-center gap-2">
<span className="text-white/30 text-xs">{slideIndex + 1}/{totalSlides}</span>
<button onClick={goNext} disabled={slideIndex === totalSlides - 1} className="text-white/60 hover:text-white disabled:opacity-20 text-lg px-2">&rarr;</button>
</div>
</div>
</div>
</div>
)
}
+292
View File
@@ -0,0 +1,292 @@
import { useState, useEffect, useCallback } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
function urlBase64ToUint8Array(base64) {
const padding = '='.repeat((4 - (base64.length % 4)) % 4)
const b64 = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/')
const raw = atob(b64)
return Uint8Array.from([...raw].map((c) => c.charCodeAt(0)))
}
export default function Profile() {
const { user, authFetch } = useAuth()
const navigate = useNavigate()
const [notifEnabled, setNotifEnabled] = useState(false)
const [reminderDays, setReminderDays] = useState(3)
const [subscribed, setSubscribed] = useState(false)
const [notifSupported, setNotifSupported] = useState(false)
const [sendingTest, setSendingTest] = useState(false)
const [broadcastTitle, setBroadcastTitle] = useState('')
const [broadcastBody, setBroadcastBody] = useState('')
const [sendingBroadcast, setSendingBroadcast] = useState(false)
const [members, setMembers] = useState([])
const [selectedMembers, setSelectedMembers] = useState([])
const [showMemberPicker, setShowMemberPicker] = useState(false)
const [loadingMembers, setLoadingMembers] = useState(false)
const [presDate, setPresDate] = useState('')
useEffect(() => {
setNotifSupported('Notification' in window && 'serviceWorker' in navigator && 'PushManager' in window)
}, [])
useEffect(() => {
if (!notifSupported) return
navigator.serviceWorker.ready.then((reg) => reg.pushManager.getSubscription()).then((sub) => {
setSubscribed(!!sub)
setNotifEnabled(!!sub)
})
}, [notifSupported])
const handleToggleNotifications = useCallback(async () => {
if (subscribed) {
const reg = await navigator.serviceWorker.ready
const sub = await reg.pushManager.getSubscription()
if (sub) {
await authFetch('/api/push/unsubscribe', { method: 'DELETE', body: JSON.stringify({ endpoint: sub.endpoint }) })
await sub.unsubscribe()
}
setSubscribed(false)
setNotifEnabled(false)
return
}
const perm = await Notification.requestPermission()
if (perm !== 'granted') { alert('Notifications refusées'); return }
const r = await authFetch('/api/push/vapid-public-key')
const { publicKey } = await r.json()
if (!publicKey) { alert('Erreur de configuration des notifications'); return }
const reg = await navigator.serviceWorker.ready
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
})
await authFetch('/api/push/subscribe', {
method: 'POST',
body: JSON.stringify({
endpoint: sub.endpoint,
keys: { p256dh: btoa(String.fromCharCode(...new Uint8Array(sub.getKey('p256dh')))), auth: btoa(String.fromCharCode(...new Uint8Array(sub.getKey('auth')))) },
reminderDays,
}),
})
setSubscribed(true)
setNotifEnabled(true)
alert('Notifications activées !')
}, [subscribed, reminderDays, authFetch])
const handleUpdateReminderDays = async (days) => {
setReminderDays(days)
if (subscribed) {
const reg = await navigator.serviceWorker.ready
const sub = await reg.pushManager.getSubscription()
if (sub) {
await authFetch('/api/push/subscribe', {
method: 'POST',
body: JSON.stringify({
endpoint: sub.endpoint,
keys: { p256dh: btoa(String.fromCharCode(...new Uint8Array(sub.getKey('p256dh')))), auth: btoa(String.fromCharCode(...new Uint8Array(sub.getKey('auth')))) },
reminderDays: days,
}),
})
}
}
}
const handleTestNotification = async () => {
if (!subscribed) { alert('Active d\'abord les notifications'); return }
setSendingTest(true)
try {
const r = await authFetch('/api/push/test', { method: 'POST' })
if (r.ok) alert('Notification de test envoyée !')
else { const e = await r.json(); alert(e.error || 'Erreur') }
} catch { alert('Erreur réseau') }
finally { setSendingTest(false) }
}
const loadMembers = async () => {
setLoadingMembers(true)
try {
const r = await authFetch('/api/push/members-with-push')
if (r.ok) setMembers(await r.json())
} catch {}
setLoadingMembers(false)
}
const toggleMember = (id) => {
setSelectedMembers((prev) => prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])
}
const selectAll = () => {
setSelectedMembers(members.filter((m) => m.has_push).map((m) => m.id))
}
const handleBroadcast = async () => {
setSendingBroadcast(true)
try {
const r = await authFetch('/api/push/broadcast', {
method: 'POST',
body: JSON.stringify({ title: broadcastTitle, body: broadcastBody, userIds: selectedMembers.length > 0 ? selectedMembers : undefined }),
})
const data = await r.json()
if (r.ok) { alert(data.message); setBroadcastTitle(''); setBroadcastBody(''); setSelectedMembers([]); setShowMemberPicker(false) }
else alert(data.error || 'Erreur')
} catch { alert('Erreur réseau') }
finally { setSendingBroadcast(false) }
}
return (
<div className="space-y-4">
<Link to="/" className="text-blue-700 text-sm">&larr; Retour</Link>
<h2 className="text-xl font-bold text-gray-800">Mon profil</h2>
<div className="bg-white rounded-xl p-5 shadow-sm space-y-2">
<p className="text-gray-700"><span className="font-semibold">Prénom :</span> {user?.prenom}</p>
<p className="text-gray-700"><span className="font-semibold">Nom :</span> {user?.nom}</p>
<p className="text-gray-700"><span className="font-semibold">Société :</span> {user?.societe || '-'}</p>
<p className="text-gray-700"><span className="font-semibold">Rôle :</span> {user?.role === 'admin' ? 'Administrateur' : 'Membre'}</p>
</div>
{notifSupported && (
<div className="bg-white rounded-xl p-5 shadow-sm space-y-3">
<h3 className="font-semibold text-gray-700">Notifications</h3>
<label className="flex items-center justify-between">
<span className="text-sm text-gray-600">Rappels automatiques</span>
<button onClick={handleToggleNotifications}
className={`relative w-11 h-6 rounded-full transition-colors ${notifEnabled ? 'bg-blue-900' : 'bg-gray-300'}`}>
<span className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform ${notifEnabled ? 'translate-x-5' : ''}`} />
</button>
</label>
{notifEnabled && (
<>
<div>
<label className="block text-xs text-gray-600 mb-1">Rappeler tous les</label>
<select value={reminderDays} onChange={(e) => handleUpdateReminderDays(Number(e.target.value))}
className="w-full px-3 py-2 rounded-lg border text-sm">
<option value={1}>1 jour</option>
<option value={3}>3 jours</option>
<option value={7}>7 jours</option>
<option value={14}>14 jours</option>
<option value={30}>30 jours</option>
</select>
</div>
<button onClick={handleTestNotification} disabled={sendingTest}
className="block w-full bg-blue-900 text-white py-2 px-4 rounded-lg text-sm font-medium text-center disabled:opacity-50">
{sendingTest ? 'Envoi...' : 'Tester la notification'}
</button>
</>
)}
</div>
)}
<Link to="/profile/password" className="block w-full bg-white border border-gray-300 text-gray-800 py-3 px-4 rounded-xl font-medium text-center hover:bg-gray-50">
Changer mon mot de passe
</Link>
{user?.role === 'admin' && (
<>
<div className="bg-white rounded-xl p-5 shadow-sm space-y-3">
<h3 className="font-semibold text-gray-700">Envoyer une notification</h3>
<input type="text" value={broadcastTitle} onChange={(e) => setBroadcastTitle(e.target.value)}
placeholder="Titre" className="w-full px-3 py-2 rounded-lg border text-sm" />
<textarea value={broadcastBody} onChange={(e) => setBroadcastBody(e.target.value)}
placeholder="Votre message..." rows={3} className="w-full px-3 py-2 rounded-lg border text-sm resize-none" />
<div>
<button type="button" onClick={() => { if (!showMemberPicker) loadMembers(); setShowMemberPicker(!showMemberPicker) }}
className="text-sm text-blue-700 font-medium">
{showMemberPicker ? 'Masquer la sélection' : 'Sélectionner des membres cibles'}
</button>
{selectedMembers.length > 0 && (
<span className="text-xs text-gray-500 ml-2">{selectedMembers.length} sélectionné(s)</span>
)}
</div>
{showMemberPicker && (
<div className="border rounded-lg max-h-48 overflow-y-auto p-2 space-y-1">
{loadingMembers && <p className="text-xs text-gray-400 text-center py-2">Chargement...</p>}
{!loadingMembers && (
<>
<label className="flex items-center gap-2 text-xs text-blue-700 font-medium pb-1 border-b cursor-pointer">
<input type="checkbox" checked={selectedMembers.length === members.filter((m) => m.has_push).length && members.some((m) => m.has_push)}
onChange={(e) => e.target.checked ? selectAll() : setSelectedMembers([])} />
Tout sélectionner
</label>
{members.map((m) => (
<label key={m.id} className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={selectedMembers.includes(m.id)} onChange={() => toggleMember(m.id)} />
<span className={!m.has_push ? 'text-gray-400' : ''}>{m.prenom} {m.nom}{m.societe ? ` (${m.societe})` : ''}</span>
{!m.has_push && <span className="text-[10px] text-gray-400">pas de push</span>}
</label>
))}
</>
)}
</div>
)}
<button onClick={handleBroadcast} disabled={sendingBroadcast || !broadcastTitle || !broadcastBody}
className="block w-full bg-blue-900 text-white py-2 px-4 rounded-lg text-sm font-medium text-center disabled:opacity-50">
{sendingBroadcast ? 'Envoi...' : selectedMembers.length > 0 ? `Envoyer à ${selectedMembers.length} membre(s)` : 'Envoyer à tous les membres'}
</button>
</div>
<div className="bg-white rounded-xl p-5 shadow-sm space-y-3">
<h3 className="font-semibold text-gray-700">Présentation</h3>
<input type="date" value={presDate} onChange={(e) => setPresDate(e.target.value)}
className="w-full px-3 py-2 rounded-lg border text-sm" />
<button onClick={() => { if (presDate) navigate(`/presentation/${presDate}`) }}
disabled={!presDate}
className="block w-full bg-blue-900 text-white py-2 px-4 rounded-lg text-sm font-medium text-center disabled:opacity-50">
Démarrer la présentation
</button>
<button onClick={() => { if (presDate) window.open(`/presentation/${presDate}?pdf=1`, '_blank') }}
disabled={!presDate}
className="block w-full bg-emerald-700 text-white py-2 px-4 rounded-lg text-sm font-medium text-center disabled:opacity-50">
Télécharger en PDF
</button>
</div>
<button onClick={async () => {
try {
const r = await authFetch('/api/export/csv')
if (!r.ok) { alert('Erreur lors de l\'export'); return }
const blob = await r.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = r.headers.get('content-disposition')?.match(/filename="?(.+?)"?$/)?.[1] || 'export.csv'
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
} catch { alert('Erreur réseau') }
}} className="block w-full bg-emerald-700 text-white py-3 px-4 rounded-xl font-medium text-center hover:bg-emerald-800">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="w-5 h-5 inline mr-2 -mt-0.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Exporter les données (CSV)
</button>
<div className="space-y-3">
<h3 className="font-semibold text-gray-700">Administration</h3>
<Link to="/admin/users" className="block w-full bg-blue-900 text-white py-3 px-4 rounded-xl font-medium text-center hover:bg-blue-800">
Gestion des droits utilisateurs
</Link>
<Link to="/admin/events" className="block w-full bg-blue-900 text-white py-3 px-4 rounded-xl font-medium text-center hover:bg-blue-800">
Gérer les événements
</Link>
<Link to="/admin/honors" className="block w-full bg-blue-900 text-white py-3 px-4 rounded-xl font-medium text-center hover:bg-blue-800">
Gérer les mises à l'honneur
</Link>
<Link to="/admin/logs" className="block w-full bg-blue-900 text-white py-3 px-4 rounded-xl font-medium text-center hover:bg-blue-800">
Journal d'activité
</Link>
<Link to="/admin/presentation-editor" className="block w-full bg-blue-900 text-white py-3 px-4 rounded-xl font-medium text-center hover:bg-blue-800">
Éditeur de présentation
</Link>
</div>
</>
)}
</div>
)
}
@@ -0,0 +1,66 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
export default function RecommendationsList() {
const { authFetch, user } = useAuth()
const [recs, setRecs] = useState([])
const [myOnly, setMyOnly] = useState(false)
const load = () => { authFetch('/api/recommendations').then((r) => r.ok ? r.json() : []).then(setRecs) }
useEffect(load, [])
const handleDelete = async (id) => {
if (!confirm('Supprimer cette recommandation ?')) return
const r = await authFetch(`/api/recommendations/${id}`, { method: 'DELETE' })
if (r.ok) load()
}
const statusLabel = (r) => {
if (r.transformee) return { label: 'Transformée', color: 'text-green-600', ca: r.ca_realise }
if (r.sans_suite) return { label: 'Sans suite', color: 'text-gray-400', ca: null }
return { label: 'En cours', color: 'text-orange-400', ca: null }
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-2 flex-wrap">
<h2 className="text-xl font-bold text-gray-800">Recommandations</h2>
<div className="flex gap-2">
<button onClick={() => setMyOnly(!myOnly)} className={`px-3 py-1.5 rounded-lg text-sm font-medium ${myOnly ? 'bg-blue-900 text-white' : 'bg-white border text-gray-600'}`}>
{myOnly ? 'Toutes' : 'Les miennes'}
</button>
<Link to="/recommendations/add" className="bg-blue-900 text-white px-4 py-2 rounded-xl text-sm font-semibold active:bg-blue-950">+ Nouvelle</Link>
</div>
</div>
<div className="space-y-3">
{(myOnly ? recs.filter((r) => r.membre_id === user?.id) : recs).map((r) => {
const st = statusLabel(r)
return (
<div key={r.id} className="bg-white rounded-xl p-4 shadow-sm">
<div className="flex justify-between">
<div>
<p className="font-semibold text-gray-800">{r.nom_prospect}</p>
<p className="text-sm text-gray-500">{r.type === 'donnee' ? 'Donnée à' : 'Reçue de'} <span className="font-medium">{r.dest_prenom || '-'} {r.dest_nom || ''}</span></p>
</div>
<div className="text-right">
<span className="text-xs text-gray-400">{new Date(r.date).toLocaleDateString('fr-FR')}</span>
<Link to={`/recommendations/edit/${r.id}`} className="block text-xs text-blue-600 mt-1 underline">Modifier</Link>
{(user?.role === 'admin' || r.membre_id === user?.id) && (
<button onClick={() => handleDelete(r.id)} className="block text-xs text-red-600 mt-1 underline">Suppr.</button>
)}
</div>
</div>
<p className={`text-sm mt-1 ${st.color}`}>
{st.label}{st.ca !== null ? ` - CA HT : ${Number(st.ca).toLocaleString('fr-FR')} € (TTC : ${Number(st.ca * 1.2).toLocaleString('fr-FR', {minimumFractionDigits: 2, maximumFractionDigits: 2})} €)` : ''}
</p>
<p className="text-xs text-gray-400 mt-1">par {r.membre_prenom} {r.membre_nom}</p>
</div>
)
})}
{recs.length === 0 && <p className="text-center text-gray-400 py-8">Aucune recommandation</p>}
{myOnly && recs.length > 0 && recs.filter((r) => r.membre_id === user?.id).length === 0 && <p className="text-center text-gray-400 py-8">Aucune recommandation de votre part</p>}
</div>
</div>
)
}
+109
View File
@@ -0,0 +1,109 @@
import { useState, useEffect, useRef } from 'react'
const COLORS = [
'#FFD700', '#2ECC71', '#3498DB', '#E91E63',
'#9B59B6', '#F39C12', '#1ABC9C', '#e74c3c',
'#1abc9c', '#e67e22', '#2ecc71', '#f1c40f',
]
export default function SlotMachine({ items, phase, onSpinEnd, visibleCount = 5, riggedIndex }) {
const [currentIndex, setCurrentIndex] = useState(0)
const rafRef = useRef(null)
const timeoutRef = useRef(null)
const indexRef = useRef(0)
const startTimeRef = useRef(0)
const targetIndexRef = useRef(0)
const durationRef = useRef(0)
const onSpinEndRef = useRef(onSpinEnd)
onSpinEndRef.current = onSpinEnd
indexRef.current = currentIndex
useEffect(() => {
if (phase === 'idle') {
if (rafRef.current) { cancelAnimationFrame(rafRef.current); rafRef.current = null }
setCurrentIndex(0)
}
}, [phase])
useEffect(() => {
if (phase !== 'spinning' || items.length === 0) return
const spinDuration = 3000 + Math.random() * 3000
const targetItem = riggedIndex !== undefined ? riggedIndex : Math.floor(Math.random() * items.length)
targetIndexRef.current = targetItem
const totalSteps = 6 * items.length + targetItem
startTimeRef.current = performance.now()
durationRef.current = spinDuration
const animate = (now) => {
const elapsed = now - startTimeRef.current
const t = Math.min(elapsed / durationRef.current, 1)
const eased = 1 - Math.pow(1 - t, 3)
const current = (totalSteps * eased) % items.length
indexRef.current = current
setCurrentIndex(Math.floor(current))
if (t < 1) {
rafRef.current = requestAnimationFrame(animate)
} else {
indexRef.current = targetItem
setCurrentIndex(targetItem)
timeoutRef.current = setTimeout(() => onSpinEndRef.current?.(items[targetItem], targetItem), 400)
}
}
rafRef.current = requestAnimationFrame(animate)
return () => {
if (rafRef.current) { cancelAnimationFrame(rafRef.current); rafRef.current = null }
if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null }
}
}, [phase, items])
if (items.length === 0) return null
const half = Math.floor(visibleCount / 2)
const visible = []
for (let i = -half; i <= half; i++) {
const idx = ((currentIndex + i) % items.length + items.length) % items.length
const item = items[idx]
const dist = Math.abs(i)
const opacity = Math.max(0.3, 1 - dist / (half + 1))
const scale = Math.max(0.7, 1 - dist * 0.1)
const isCenter = i === 0
visible.push(
<div
key={`${idx}-${i}`}
className={`flex items-center gap-3 px-4 sm:px-6 py-2 sm:py-3 rounded-xl transition-all text-white ${isCenter ? 'font-bold shadow-lg' : ''}`}
style={{
opacity,
transform: `scale(${scale})`,
backgroundColor: isCenter ? (COLORS[idx % COLORS.length] + 'cc') : 'rgba(255,255,255,0.08)',
border: isCenter ? `2px solid ${COLORS[idx % COLORS.length]}` : '2px solid transparent',
margin: '2px 0',
transition: 'all 0.05s linear',
}}
>
<span className="text-sm sm:text-lg truncate">{item.label}</span>
</div>
)
}
return (
<div className="relative" style={{ width: 360 }}>
<div className="relative rounded-2xl overflow-hidden border-2 border-white/20 bg-black/30 backdrop-blur-sm" style={{ minHeight: visibleCount * 44 }}>
<div className="absolute left-0 top-1/2 -translate-y-1/2 z-10">
<div style={{
width: 0, height: 0,
borderTop: '14px solid transparent',
borderBottom: '14px solid transparent',
borderRight: '14px solid #fff',
filter: 'drop-shadow(0 0 6px rgba(255,255,255,0.5))',
}} />
</div>
<div className="flex flex-col items-stretch py-2 px-4">
{visible}
</div>
</div>
</div>
)
}
+188
View File
@@ -0,0 +1,188 @@
import { useRef, useEffect, useCallback } from 'react'
const COLORS = [
'#FFD700', '#2ECC71', '#3498DB', '#E91E63',
'#9B59B6', '#F39C12', '#1ABC9C', '#e74c3c',
'#1abc9c', '#e67e22', '#2ecc71', '#f1c40f',
]
export default function SpinWheel({ items, phase, onSpinEnd, size = 400 }) {
const canvasRef = useRef(null)
const rotationRef = useRef(0)
const rafRef = useRef(null)
const spinSpeedRef = useRef(0)
const onSpinEndRef = useRef(onSpinEnd)
onSpinEndRef.current = onSpinEnd
const itemsRef = useRef(items)
itemsRef.current = items
const drawWheel = useCallback((rotation) => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
const w = canvas.width
const h = canvas.height
const cx = w / 2
const cy = h / 2
const r = Math.min(cx, cy) - 10
const count = items.length || 1
ctx.clearRect(0, 0, w, h)
const arc = (2 * Math.PI) / count
for (let i = 0; i < count; i++) {
const startAngle = rotation + i * arc - Math.PI / 2
const endAngle = startAngle + arc
ctx.beginPath()
ctx.moveTo(cx, cy)
ctx.arc(cx, cy, r, startAngle, endAngle)
ctx.closePath()
ctx.fillStyle = COLORS[i % COLORS.length]
ctx.fill()
ctx.strokeStyle = '#fff'
ctx.lineWidth = 2
ctx.stroke()
const midAngle = startAngle + arc / 2
const textR = r * 0.65
const tx = cx + Math.cos(midAngle) * textR
const ty = cy + Math.sin(midAngle) * textR
ctx.save()
ctx.translate(tx, ty)
ctx.rotate(midAngle + Math.PI / 2)
const label = items[i]?.label || items[i]?.name || `#${i + 1}`
const fontSize = Math.min(14, (r / count) * 0.6)
ctx.font = `bold ${fontSize}px sans-serif`
ctx.fillStyle = '#fff'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const maxWidth = r * 0.5
if (ctx.measureText(label).width > maxWidth) {
const words = label.split(' ')
let line = ''
const lines = []
for (const word of words) {
const test = line ? line + ' ' + word : word
if (ctx.measureText(test).width > maxWidth && line) {
lines.push(line)
line = word
} else {
line = test
}
}
if (line) lines.push(line)
const lineHeight = fontSize * 1.2
const totalH = lines.length * lineHeight
lines.forEach((l, li) => {
ctx.fillText(l, 0, -totalH / 2 + lineHeight / 2 + li * lineHeight)
})
} else {
ctx.fillText(label, 0, 0)
}
ctx.restore()
}
ctx.beginPath()
ctx.arc(cx, cy, r, 0, 2 * Math.PI)
ctx.strokeStyle = '#fff'
ctx.lineWidth = 4
ctx.stroke()
ctx.beginPath()
ctx.arc(cx, cy, 8, 0, 2 * Math.PI)
ctx.fillStyle = '#fff'
ctx.fill()
}, [items])
useEffect(() => {
if (items.length === 0) return
if (phase === 'idle') {
if (rafRef.current) { cancelAnimationFrame(rafRef.current); rafRef.current = null }
drawWheel(rotationRef.current)
}
}, [items, phase, drawWheel])
useEffect(() => {
if (items.length === 0) return
if (phase === 'spinning') {
spinSpeedRef.current = 0.4 + Math.random() * 0.2
const animate = () => {
rotationRef.current += spinSpeedRef.current
drawWheel(rotationRef.current)
rafRef.current = requestAnimationFrame(animate)
}
rafRef.current = requestAnimationFrame(animate)
return () => {
if (rafRef.current) { cancelAnimationFrame(rafRef.current); rafRef.current = null }
}
}
}, [phase, items, drawWheel])
useEffect(() => {
if (items.length === 0) return
if (phase === 'stopping') {
if (rafRef.current) { cancelAnimationFrame(rafRef.current); rafRef.current = null }
const targetItem = Math.floor(Math.random() * items.length)
const targetArc = (2 * Math.PI) / items.length
const targetAngle = targetItem * targetArc + Math.PI / 2
const extraSpins = 3 + Math.random() * 2
const startRotation = rotationRef.current
const totalSpin = extraSpins * 2 * Math.PI
const endRotation = Math.ceil(startRotation / (2 * Math.PI)) * (2 * Math.PI) + totalSpin + targetAngle
const startTime = performance.now()
const duration = 3000 + Math.random() * 1000
const animate = (now) => {
const elapsed = now - startTime
const progress = Math.min(elapsed / duration, 1)
const eased = 1 - Math.pow(1 - progress, 4)
rotationRef.current = startRotation + (endRotation - startRotation) * eased
drawWheel(rotationRef.current)
if (progress < 1) {
rafRef.current = requestAnimationFrame(animate)
} else {
rotationRef.current = endRotation
drawWheel(endRotation)
const normalized = ((endRotation % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI)
const winningIdx = Math.floor(((2 * Math.PI) - normalized + Math.PI / 2 + targetArc / 2) / targetArc) % items.length
setTimeout(() => onSpinEndRef.current?.(items[winningIdx], winningIdx), 500)
}
}
rafRef.current = requestAnimationFrame(animate)
return () => {
if (rafRef.current) { cancelAnimationFrame(rafRef.current); rafRef.current = null }
}
}
}, [phase, items, drawWheel])
const cx = (size + 40) / 2
return (
<div className="relative inline-block" style={{ width: size + 40, height: size + 40 }}>
<canvas
ref={canvasRef}
width={size}
height={size}
className="rounded-full"
style={{ position: 'absolute', left: 20, top: 20 }}
/>
<div
className="absolute z-10"
style={{
left: cx - 12,
top: 20 - 8,
width: 0,
height: 0,
borderLeft: '12px solid transparent',
borderRight: '12px solid transparent',
borderTop: '18px solid #fff',
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.5))',
}}
/>
</div>
)
}
+362
View File
@@ -0,0 +1,362 @@
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import SlotMachine from './SlotMachine'
import { THEMES, getRandomQuestion } from '../utils/wheelQuestions'
import { startDrumRoll, stopDrumRoll, playDing } from '../utils/soundUtils'
function formatPhone(num) {
if (!num) return ''
const cleaned = String(num).replace(/\s+/g, '')
return cleaned.replace(/(\d{2})(?=\d)/g, '$1 ').trim()
}
const peopleToItems = (people) => people.map((p) => ({
id: p.id,
label: p.merged_display_name || `${p.prenom} ${p.nom}`,
isGuest: !!p.invite_par_prenom,
person: p,
}))
const themeItems = THEMES.map((t) => ({
id: t.id,
label: `${t.emoji} ${t.name}`,
color: t.color,
theme: t,
}))
function Timer({ duration = 90, onEnd }) {
const [remaining, setRemaining] = useState(duration)
const [running, setRunning] = useState(true)
const intervalRef = useRef(null)
const onEndRef = useRef(onEnd)
onEndRef.current = onEnd
useEffect(() => {
setRemaining(duration)
setRunning(true)
let count = duration
intervalRef.current = setInterval(() => {
count--
setRemaining(count)
if (count <= 0) {
clearInterval(intervalRef.current)
intervalRef.current = null
setRunning(false)
onEndRef.current?.()
}
}, 1000)
return () => { if (intervalRef.current) clearInterval(intervalRef.current) }
}, [duration])
useEffect(() => {
return () => { if (intervalRef.current) clearInterval(intervalRef.current) }
}, [])
const mins = Math.floor(remaining / 60)
const secs = remaining % 60
const pct = remaining / duration
const color = pct > 0.5 ? 'text-green-400' : pct > 0.2 ? 'text-yellow-400' : 'text-red-400'
return (
<div className="flex flex-col items-center gap-2">
<div className="text-5xl sm:text-6xl font-mono font-bold tracking-wider tabular-nums">
<span className={color}>{String(mins).padStart(2, '0')}:{String(secs).padStart(2, '0')}</span>
</div>
<div className="w-40 sm:w-56 h-1.5 bg-white/20 rounded-full overflow-hidden">
<div className={`h-full rounded-full transition-all ${pct > 0.5 ? 'bg-green-400' : pct > 0.2 ? 'bg-yellow-400' : 'bg-red-400'}`}
style={{ width: `${pct * 100}%` }} />
</div>
{running ? (
<button onClick={() => { clearInterval(intervalRef.current); intervalRef.current = null; setRunning(false) }}
className="bg-white/20 hover:bg-white/30 text-yellow-300 px-4 py-1.5 rounded-full text-xs font-medium">
Pause
</button>
) : remaining > 0 ? (
<span className="text-white/40 text-xs">En pause</span>
) : null}
</div>
)
}
function parsePhoto(path) {
if (!path) return null
if (path.startsWith('http')) return path
if (path.startsWith('/uploads')) return path
return `/uploads/${path.replace(/^\/?/, '')}`
}
export default function WheelFlow({ members, guests, onComplete }) {
const allPeople = useMemo(() => [...members.filter((m) => !m.absent), ...guests], [members, guests])
const [phase, setPhase] = useState('theme-idle')
const [themePhase, setThemePhase] = useState('idle')
const [personPhase, setPersonPhase] = useState('idle')
const [selectedTheme, setSelectedTheme] = useState(null)
const [selectedPerson, setSelectedPerson] = useState(null)
const [currentQuestion, setCurrentQuestion] = useState(null)
const [questionCount, setQuestionCount] = useState(0)
const usedQuestionsRef = useRef([])
const [remainingPeople, setRemainingPeople] = useState(() => peopleToItems(allPeople))
const remainingRef = useRef(remainingPeople)
remainingRef.current = remainingPeople
useEffect(() => {
const onKey = (e) => {
if (e.key === 'ArrowRight' || e.key === 'ArrowUp' || e.key === 'PageUp' || e.key === 'ArrowLeft' || e.key === 'ArrowDown' || e.key === 'PageDown' || e.key === ' ') {
e.preventDefault()
e.stopPropagation()
const target = document.querySelector('[class*="cursor-pointer"][class*="w-full"][class*="h-full"]')
if (target) target.click()
}
}
window.addEventListener('keydown', onKey, true)
return () => window.removeEventListener('keydown', onKey, true)
}, [])
const [riggedPersonIdx, setRiggedPersonIdx] = useState(() => {
const items = peopleToItems(allPeople)
const idx = items.findIndex(p => p.person?.prenom?.toLowerCase() === 'estelle')
return idx >= 0 ? idx : undefined
})
useEffect(() => {
const items = peopleToItems(allPeople)
setRemainingPeople(items)
remainingRef.current = items
}, [allPeople])
useEffect(() => {
if (remainingPeople.length === 0 && (phase === 'theme-idle' || phase === 'person-idle')) {
setPhase('done')
}
}, [remainingPeople, phase])
const handleNextRound = useCallback(() => {
setSelectedTheme(null)
setSelectedPerson(null)
setCurrentQuestion(null)
setThemePhase('idle')
setPersonPhase('idle')
if (remainingRef.current.length === 0) {
setPhase('done')
} else {
setPhase('theme-idle')
}
}, [])
const resetRound = useCallback(() => {
setRemainingPeople(peopleToItems(allPeople))
remainingRef.current = peopleToItems(allPeople)
setSelectedTheme(null)
setSelectedPerson(null)
setCurrentQuestion(null)
setThemePhase('idle')
setPersonPhase('idle')
setPhase('theme-idle')
usedQuestionsRef.current = []
}, [allPeople])
const handleThemeSpinEnd = useCallback((item) => {
setSelectedTheme(item.theme)
setPhase('theme-result')
stopDrumRoll()
playDing()
}, [])
const handlePersonSpinEnd = useCallback((item) => {
setSelectedPerson(item.person)
setRiggedPersonIdx(undefined)
const next = remainingRef.current.filter(p => p.id !== item.id)
setRemainingPeople(next)
remainingRef.current = next
stopDrumRoll()
playDing()
let question = getRandomQuestion(selectedTheme?.id, usedQuestionsRef.current)
if (!question) {
const fallbackTheme = THEMES[Math.floor(Math.random() * THEMES.length)]
question = getRandomQuestion(fallbackTheme.id, usedQuestionsRef.current) || {
question: 'Racontez une anecdote sur votre parcours entrepreneurial.',
questionIdx: 0,
themeId: 'fallback',
}
}
if (question) usedQuestionsRef.current.push(`${question.themeId}-${question.questionIdx}`)
setCurrentQuestion(question)
setPhase('person-result')
}, [selectedTheme])
const handleSpinClick = (e) => {
e.stopPropagation()
const startSpin = (setMachinePhase) => {
setMachinePhase('spinning')
startDrumRoll()
}
if (phase === 'theme-idle') startSpin(setThemePhase)
else if (phase === 'person-idle') startSpin(setPersonPhase)
}
const handleResultClick = useCallback((e) => {
e?.stopPropagation()
const nextPhase = phase === 'theme-result' ? 'person-idle' : 'question'
if (phase === 'theme-result') {
setThemePhase('idle')
setPersonPhase('idle')
}
setPhase(nextPhase)
if (nextPhase === 'question') setQuestionCount((c) => c + 1)
}, [phase])
const handleTimerEnd = useCallback(() => {
playDing()
handleNextRound()
}, [handleNextRound])
const themeWheelPhase = themePhase === 'spinning' ? 'spinning' : 'idle'
const personWheelPhase = personPhase === 'spinning' ? 'spinning' : 'idle'
if (phase === 'done') {
return (
<div
className="flex flex-col items-center justify-center w-full h-full text-white px-4 relative cursor-pointer"
onClick={(e) => { e.stopPropagation(); onComplete?.() }}
>
<div className="text-center animate-fadeIn">
<span className="text-7xl block mb-4">{'\uD83C\uDF89'}</span>
<h2 className="text-4xl sm:text-5xl font-bold mb-4">{'Tout le monde est pass\u00e9 !'}</h2>
<p className="text-xl text-blue-200">{'Bravo \u00e0 tous les participants !'}</p>
<p className="text-base text-blue-300 mt-6">{'Cliquez pour continuer la pr\u00e9sentation'}</p>
</div>
<button onClick={(e) => { e.stopPropagation(); resetRound() }} className="absolute bottom-4 right-4 bg-white/10 hover:bg-white/20 text-white/60 hover:text-white text-xs px-3 py-1.5 rounded-full">
{'\u21BA'} Recommencer
</button>
</div>
)
}
if (phase === 'question' && selectedTheme && selectedPerson) {
const q = currentQuestion || { question: 'Pas de question disponible pour ce th\u00e8me.' }
return (
<div
className="flex flex-col sm:flex-row items-center justify-center w-full h-full gap-6 text-white px-4 py-2 relative cursor-pointer"
onClick={(e) => { e.stopPropagation(); handleNextRound() }}
>
<div className="flex flex-col items-center justify-center shrink-0">
<div className="w-28 h-28 sm:w-36 sm:h-36 rounded-full overflow-hidden border-4 border-white/20 shadow-xl bg-white/10 flex items-center justify-center">
{selectedPerson.photo_url ? (
<img src={parsePhoto(selectedPerson.photo_url)} alt="" className="w-full h-full object-cover"
onError={(e) => { e.target.style.display = 'none'; e.target.parentElement.textContent = selectedPerson.prenom[0] + selectedPerson.nom[0] }} />
) : (
<span className="text-3xl font-bold text-white/40">{selectedPerson.prenom[0]}{selectedPerson.nom[0]}</span>
)}
</div>
<h2 className="text-xl sm:text-2xl font-bold mt-3 text-center">
{selectedPerson.merged_display_name || `${selectedPerson.prenom} ${selectedPerson.nom}`}
</h2>
<p className="text-base sm:text-lg text-blue-200 text-center">{selectedPerson.societe || selectedPerson.activite || ''}</p>
{selectedPerson.invite_par_prenom && (
<p className="text-sm text-white/70 mt-1">
Invité(e) par {selectedPerson.invite_par_prenom} {selectedPerson.invite_par_nom}
</p>
)}
<div className="flex flex-col items-center text-sm text-white/60 mt-2 gap-0.5">
{selectedPerson.telephone && <span className="flex items-center gap-1"><svg viewBox="0 0 24 24" fill="none" stroke="#60a5fa" strokeWidth="2" className="w-3.5 h-3.5 shrink-0"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>{formatPhone(selectedPerson.telephone)}</span>}
{selectedPerson.email && <span className="flex items-center gap-1"><svg viewBox="0 0 24 24" fill="none" stroke="#60a5fa" strokeWidth="2" className="w-3.5 h-3.5 shrink-0"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>{selectedPerson.email}</span>}
</div>
<div className="mt-2 text-sm text-white/40">
Plus que {remainingPeople.length} participant{remainingPeople.length > 1 ? 's' : ''}
</div>
</div>
<div className="hidden sm:block w-px bg-white/20 self-stretch" />
<div className="flex flex-col items-center justify-center gap-4 max-w-xl">
<div className="flex items-center gap-2">
<span className="text-2xl">{selectedTheme?.emoji || '\uD83D\uDCCC'}</span>
<span className="text-base sm:text-lg font-bold text-blue-200">{selectedTheme?.name || 'Question'}</span>
</div>
<div className="bg-white/10 rounded-2xl p-5 sm:p-6 w-full">
<p className="text-lg sm:text-xl font-semibold leading-relaxed">{q.question}</p>
</div>
<Timer key={questionCount} duration={90} onEnd={handleTimerEnd} />
</div>
<button onClick={(e) => { e.stopPropagation(); resetRound() }} className="absolute bottom-4 right-4 bg-white/10 hover:bg-white/20 text-white/60 hover:text-white text-xs px-3 py-1.5 rounded-full z-10">
{'\u21BA'} Recommencer
</button>
</div>
)
}
if (phase === 'theme-result' && selectedTheme) {
return (
<div
className="flex flex-col items-center justify-center w-full h-full cursor-pointer text-white px-4 relative"
onClick={handleResultClick}
>
<div className="text-center animate-fadeIn">
<span className="text-6xl sm:text-7xl block mb-4">{selectedTheme.emoji}</span>
<h2 className="text-3xl sm:text-5xl font-bold mb-2">{selectedTheme.name}</h2>
<p className="text-xl text-blue-200 mt-4">Cliquez pour continuer</p>
</div>
<button onClick={(e) => { e.stopPropagation(); resetRound() }} className="absolute bottom-4 right-4 bg-white/10 hover:bg-white/20 text-white/60 hover:text-white text-xs px-3 py-1.5 rounded-full">
{'\u21BA'} Recommencer
</button>
</div>
)
}
if (phase === 'person-result' && selectedPerson) {
return (
<div
className="flex flex-col items-center justify-center w-full h-full cursor-pointer text-white px-4 relative"
onClick={handleResultClick}
>
<div className="text-center animate-fadeIn">
<p className="text-2xl sm:text-3xl text-blue-200 mb-2">La personne d{'\u00e9'}sign{'\u00e9'}e est...</p>
<h2 className="text-4xl sm:text-6xl font-bold text-yellow-300 mb-2">
{selectedPerson.merged_display_name || `${selectedPerson.prenom} ${selectedPerson.nom}`}
</h2>
<p className="text-xl sm:text-2xl text-blue-200">{selectedPerson.societe || selectedPerson.activite || ''}</p>
{selectedPerson.invite_par_prenom && (
<p className="text-lg text-white/70 mt-2">
{'Invité(e) par'} {selectedPerson.invite_par_prenom} {selectedPerson.invite_par_nom}
</p>
)}
<div className="flex flex-wrap items-center justify-center gap-3 text-sm text-white/60 mt-3">
{selectedPerson.telephone && <span>{'\uD83D\uDCDE'} {formatPhone(selectedPerson.telephone)}</span>}
{selectedPerson.email && <span>{'\u2709'} {selectedPerson.email}</span>}
</div>
<p className="text-lg text-blue-200 mt-4">{'Cliquez pour découvrir la question'}</p>
</div>
<button onClick={(e) => { e.stopPropagation(); resetRound() }} className="absolute bottom-4 right-4 bg-white/10 hover:bg-white/20 text-white/60 hover:text-white text-xs px-3 py-1.5 rounded-full">
{'\u21BA'} Recommencer
</button>
</div>
)
}
return (
<div
className="flex flex-col items-center justify-center w-full h-full cursor-pointer text-white px-4 relative"
onClick={handleSpinClick}
>
{phase.startsWith('theme') ? (
<>
<SlotMachine items={themeItems} phase={themeWheelPhase} onSpinEnd={handleThemeSpinEnd} />
<p className="text-lg sm:text-xl text-blue-200 mt-4">
{themePhase === 'idle' && "Cliquez pour lancer la machine \u00e0 sous des th\u00e8mes"}
{themePhase === 'spinning' && 'S\u00e9lection en cours...'}
</p>
</>
) : (
<>
<SlotMachine items={remainingPeople} phase={personWheelPhase} onSpinEnd={handlePersonSpinEnd} riggedIndex={riggedPersonIdx} />
<p className="text-lg sm:text-xl text-blue-200 mt-4">
{personPhase === 'idle' && `Cliquez pour lancer la machine (${remainingPeople.length} restant${remainingPeople.length > 1 ? 's' : ''})`}
{personPhase === 'spinning' && 'S\u00e9lection en cours...'}
</p>
</>
)}
<button onClick={(e) => { e.stopPropagation(); resetRound() }} className="absolute bottom-4 right-4 bg-white/10 hover:bg-white/20 text-white/60 hover:text-white text-xs px-3 py-1.5 rounded-full z-10">
{'\u21BA'} Recommencer
</button>
</div>
)
}
Binary file not shown.
+58
View File
@@ -0,0 +1,58 @@
let audioCtx = null
let drumRollAudio = null
function getCtx() {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)()
if (audioCtx.state === 'suspended') audioCtx.resume()
return audioCtx
}
function createDrumRollAudio() {
const audio = new Audio('/drum-roll.mp3')
audio.loop = true
audio.volume = 0.5
return audio
}
export function startDrumRoll() {
try {
getCtx()
if (drumRollAudio) { drumRollAudio.pause(); drumRollAudio.currentTime = 0 }
drumRollAudio = createDrumRollAudio()
drumRollAudio.play().catch(() => {})
} catch {}
}
export function stopDrumRoll() {
try {
if (drumRollAudio) {
const vol = drumRollAudio.volume
const fade = setInterval(() => {
if (drumRollAudio.volume > 0.05) {
drumRollAudio.volume = Math.max(0, drumRollAudio.volume - 0.05)
} else {
clearInterval(fade)
drumRollAudio.pause()
drumRollAudio.currentTime = 0
drumRollAudio.volume = vol
drumRollAudio = null
}
}, 50)
}
} catch {}
}
export function playDing() {
try {
const ctx = getCtx()
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.connect(gain); gain.connect(ctx.destination)
osc.type = 'sine'
osc.frequency.setValueAtTime(880, ctx.currentTime)
osc.frequency.exponentialRampToValueAtTime(1320, ctx.currentTime + 0.15)
gain.gain.setValueAtTime(0.3, ctx.currentTime)
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.5)
osc.start(ctx.currentTime); osc.stop(ctx.currentTime + 0.5)
} catch {}
}
+52
View File
@@ -0,0 +1,52 @@
import { createContext, useContext, useState, useEffect } from 'react'
const API = '/api'
const AuthContext = createContext(null)
export function AuthProvider({ children }) {
const [user, setUser] = useState(null)
const [token, setToken] = useState(localStorage.getItem('token'))
const [loading, setLoading] = useState(true)
useEffect(() => {
if (token) {
fetch(`${API}/auth/me`, { headers: { Authorization: `Bearer ${token}` } })
.then((r) => (r.ok ? r.json() : Promise.reject()))
.then((u) => setUser(u))
.catch(() => { localStorage.removeItem('token'); setToken(null); setUser(null) })
.finally(() => setLoading(false))
} else { setLoading(false) }
}, [token])
const login = async (identifier, password) => {
const r = await fetch(`${API}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier, password }),
})
if (!r.ok) { const err = await r.json(); throw new Error(err.error || 'Login failed') }
const data = await r.json()
localStorage.setItem('token', data.token)
setToken(data.token)
setUser(data.user)
return data
}
const logout = () => { localStorage.removeItem('token'); setToken(null); setUser(null) }
const authFetch = (url, options = {}) => {
const isFormData = options.body instanceof FormData
return fetch(url, { ...options, headers: { ...options.headers, Authorization: `Bearer ${token}`, ...(isFormData ? {} : { 'Content-Type': 'application/json' }) } })
}
return (
<AuthContext.Provider value={{ user, token, loading, login, logout, authFetch }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be used inside AuthProvider')
return ctx
}
+10
View File
@@ -0,0 +1,10 @@
@import "tailwindcss";
.scrollbar-hide::-webkit-scrollbar { display: none }
.scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none }
@keyframes fadeIn {
from { opacity: 0; transform: scale(0.96); }
to { opacity: 1; transform: scale(1); }
}
.animate-fadeIn { animation: fadeIn 0.4s ease-out; }
+16
View File
@@ -0,0 +1,16 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import { AuthProvider } from './context/AuthContext'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</React.StrictMode>
)
+23
View File
@@ -0,0 +1,23 @@
import { precacheAndRoute } from 'workbox-precaching'
precacheAndRoute(self.__WB_MANIFEST)
self.addEventListener('push', (event) => {
let data = { title: 'Dyna Biz Coignières', body: '' }
try {
if (event.data) data = event.data.json()
} catch {}
const options = {
body: data.body,
icon: data.icon || '/logo.svg',
badge: data.badge || '/favicon.svg',
data: { url: data.data?.url || '/' },
}
event.waitUntil(self.registration.showNotification(data.title, options))
})
self.addEventListener('notificationclick', (event) => {
event.notification.close()
const url = event.notification.data?.url || '/'
event.waitUntil(clients.openWindow(url))
})
+5
View File
@@ -0,0 +1,5 @@
let deferredPrompt = null
export const setDeferredPrompt = (e) => { deferredPrompt = e }
export const getDeferredPrompt = () => deferredPrompt
export const clearDeferredPrompt = () => { deferredPrompt = null }
+58
View File
@@ -0,0 +1,58 @@
let audioCtx = null
let drumRollAudio = null
function getCtx() {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)()
if (audioCtx.state === 'suspended') audioCtx.resume()
return audioCtx
}
function createDrumRollAudio() {
const audio = new Audio('/drum-roll.mp3')
audio.loop = true
audio.volume = 0.5
return audio
}
export function startDrumRoll() {
try {
getCtx()
if (drumRollAudio) { drumRollAudio.pause(); drumRollAudio.currentTime = 0 }
drumRollAudio = createDrumRollAudio()
drumRollAudio.play().catch(() => {})
} catch {}
}
export function stopDrumRoll() {
try {
if (drumRollAudio) {
const vol = drumRollAudio.volume
const fade = setInterval(() => {
if (drumRollAudio.volume > 0.05) {
drumRollAudio.volume = Math.max(0, drumRollAudio.volume - 0.05)
} else {
clearInterval(fade)
drumRollAudio.pause()
drumRollAudio.currentTime = 0
drumRollAudio.volume = vol
drumRollAudio = null
}
}, 50)
}
} catch {}
}
export function playDing() {
try {
const ctx = getCtx()
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.connect(gain); gain.connect(ctx.destination)
osc.type = 'sine'
osc.frequency.setValueAtTime(880, ctx.currentTime)
osc.frequency.exponentialRampToValueAtTime(1320, ctx.currentTime + 0.15)
gain.gain.setValueAtTime(0.3, ctx.currentTime)
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.5)
osc.start(ctx.currentTime); osc.stop(ctx.currentTime + 0.5)
} catch {}
}
+104
View File
@@ -0,0 +1,104 @@
export const THEMES = [
{
id: 'grand-oral',
emoji: '\uD83C\uDFA4',
name: 'LE GRAND ORAL',
color: '#FFD700',
questions: [
"Quel est le jour o\u00f9 vous vous \u00eates dit : \u00ab \u00c7a y est, je suis vraiment entrepreneur \u00bb ?",
"Quelle valeur d\u00e9fendez-vous tellement fort que vous pourriez presque l'imprimer sur un t-shirt ?",
"Si vos clients entraient dans vos coulisses pendant une journ\u00e9e, quelle chose les surprendrait le plus ?",
"Si votre entreprise devait passer un casting, quels seraient ses 3 meilleurs arguments pour d\u00e9crocher le r\u00f4le ?",
"Quelle v\u00e9rit\u00e9 sur votre m\u00e9tier m\u00e9riterait d'\u00eatre davantage connue ?",
],
},
{
id: 'aventure',
emoji: '\uD83C\uDFAC',
name: "L'AVENTURE DE VOTRE ENTREPRISE",
color: '#2ECC71',
questions: [
"Si votre aventure entrepreneuriale \u00e9tait un film, quel serait son titre ?",
"Qui jouerait votre r\u00f4le dans le film de votre aventure\u2026 et pourquoi ?",
"Quelle sc\u00e8ne de votre parcours m\u00e9riterait absolument d'\u00eatre dans la bande-annonce ?",
"Si votre aventure entrepreneuriale devenait une s\u00e9rie Netflix, quel en serait le genre (com\u00e9die, thriller, aventure, drame\u2026) et le pitch ?",
"Quel chapitre de votre histoire entrepreneuriale n'avez-vous encore jamais racont\u00e9 ?",
],
},
{
id: 'masque',
emoji: '\uD83C\uDFAD',
name: 'LE MASQUE TOMBE',
color: '#3498DB',
questions: [
"Quelle est l'histoire la plus improbable que vous pourriez raconter \u00e0 un d\u00eener sans que personne ne vous croie ?",
"Quelle est la demande client la plus \u00e9trange qui vous a laiss\u00e9 quelques secondes en mode \u00ab pardon\u2026 vous avez dit quoi ? \u00bb ?",
"Quelle rencontre professionnelle improbable a chang\u00e9 quelque chose dans votre parcours ?",
"Quel clich\u00e9 sur votre m\u00e9tier m\u00e9riterait de dispara\u00eetre d\u00e9finitivement ?",
"Quelle question auriez-vous aim\u00e9 qu'on vous pose et \u00e0 laquelle personne ne pense jamais ?",
],
},
{
id: 'humain',
emoji: '\u2764\uFE0F',
name: "C\u00d4T\u00c9 HUMAIN",
color: '#E91E63',
questions: [
"Quel geste de solidarit\u00e9 vous a marqu\u00e9 dans votre aventure entrepreneuriale ?",
"Si vous deviez remettre un troph\u00e9e \u00ab Merci d'avoir \u00e9t\u00e9 l\u00e0 \u00bb aujourd'hui, \u00e0 qui le donneriez-vous ?",
"Quel moment vous a fait regarder votre \u00e9quipe en vous disant : \u00ab OK, l\u00e0, je suis fier \u00bb ?",
"Quelle petite attention a eu un impact beaucoup plus grand que pr\u00e9vu ?",
],
},
{
id: 'coulisses',
emoji: '\uD83D\uDD25',
name: 'LES COULISSES DE L\'ENTREPRENEUR',
color: '#9B59B6',
questions: [
"Quel a \u00e9t\u00e9 votre plus grand d\u00e9fi depuis la cr\u00e9ation de votre entreprise ?",
"Quel moment vous a donn\u00e9 le plus de doutes en tant qu'entrepreneur et vous a donn\u00e9 envie de rendre votre tablier ?",
"Quelle erreur vous a co\u00fbt\u00e9 cher\u2026 mais vous a offert une sacr\u00e9e le\u00e7on ?",
"Quelle est la chose la plus folle que vous ayez faite pour votre entreprise ?",
"Quelle d\u00e9cision prise sur un coup de poker vous a finalement r\u00e9ussi ?",
"Quelle comp\u00e9tence que vous n'auriez jamais imagin\u00e9 d\u00e9velopper avez-vous finalement d\u00fb apprendre en devenant entrepreneur ?",
"Quel r\u00f4le jouez-vous dans votre entreprise que personne n'imagine ?",
"Quelle phrase avez-vous d\u00e9j\u00e0 prononc\u00e9e en entrepreneur que vous n'auriez jamais imagin\u00e9 dire un jour ?",
],
},
{
id: 'anecdotes',
emoji: '\uD83D\uDE02',
name: 'LES ANECDOTES IMPROBABLES',
color: '#F39C12',
questions: [
"Quelle histoire avec un animal pourrait figurer dans les \u00ab perles de votre entreprise \u00bb ?",
"Quel est votre moment professionnel le plus g\u00eanant ou votre plus grand moment de solitude professionnelle ?",
"Avez-vous d\u00e9j\u00e0 \u00e9t\u00e9 menac\u00e9, soumis \u00e0 une pression ou confront\u00e9 \u00e0 une situation intimidante dans votre activit\u00e9 ?",
"Quelle situation vous semblait catastrophique sur le moment mais vous fait sourire aujourd'hui ?",
],
},
{
id: 'demain',
emoji: '\uD83D\uDE80',
name: 'ET DEMAIN\u2026',
color: '#1ABC9C',
questions: [
"Quelle est la prochaine grande \u00e9tape que vous aimeriez franchir avec votre entreprise ?",
"De quelle r\u00e9alisation \u00eates-vous le plus fier aujourd'hui ?",
"Si vous aviez une baguette magique pour changer une chose dans votre secteur, que changeriez-vous ?",
],
},
]
export function getRandomQuestion(themeId, usedIds = []) {
const theme = THEMES.find((t) => t.id === themeId)
if (!theme) return null
const available = theme.questions.map((q, i) => ({ idx: i, q })).filter(({ idx }) => !usedIds.includes(`${themeId}-${idx}`))
if (available.length === 0) {
const r = Math.floor(Math.random() * theme.questions.length)
return { question: theme.questions[r], questionIdx: r, themeId }
}
const pick = available[Math.floor(Math.random() * available.length)]
return { question: pick.q, questionIdx: pick.idx, themeId }
}