Initial commit: Club DynaSQY
@@ -0,0 +1,13 @@
|
||||
FROM node:20-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx-frontend.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
|
||||
<meta name="theme-color" content="#1e40af" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>Dyna Biz Coignières</title>
|
||||
</head>
|
||||
<body class="bg-gray-50">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 240 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,66 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /invitation/ {
|
||||
proxy_pass http://backend:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_request_buffering off;
|
||||
client_max_body_size 300M;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
|
||||
location /uploads/ {
|
||||
proxy_pass http://backend:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
client_max_body_size 10M;
|
||||
}
|
||||
|
||||
location = /gallery { try_files $uri /index.html; }
|
||||
location = /gallery/ { try_files $uri /index.html; }
|
||||
|
||||
location /gallery/ {
|
||||
proxy_pass http://backend:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
client_max_body_size 300M;
|
||||
}
|
||||
|
||||
location /member-slides/ {
|
||||
proxy_pass http://backend:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
client_max_body_size 300M;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "club-dynasqy-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"cropperjs": "^1.6.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.4",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"vite": "^5.4.0",
|
||||
"vite-plugin-pwa": "^0.20.1"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 578 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 578 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" rx="20" fill="#1e40af"/>
|
||||
<text x="50" y="65" font-size="40" font-family="Arial" fill="white" text-anchor="middle" font-weight="bold">DS</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 248 B |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 240 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 176 KiB |
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">← 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>
|
||||
)
|
||||
}
|
||||
@@ -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">← 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">← 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>
|
||||
)
|
||||
}
|
||||
@@ -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">← 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>
|
||||
)
|
||||
}
|
||||
@@ -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">← 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">← 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">← 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">← 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>
|
||||
)
|
||||
}
|
||||
@@ -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">← 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">← 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">← 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">← 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">← 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">×</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>
|
||||
)
|
||||
}
|
||||
@@ -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">←</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">→</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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">← 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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))
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
let deferredPrompt = null
|
||||
|
||||
export const setDeferredPrompt = (e) => { deferredPrompt = e }
|
||||
export const getDeferredPrompt = () => deferredPrompt
|
||||
export const clearDeferredPrompt = () => { deferredPrompt = null }
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
strategies: 'injectManifest',
|
||||
srcDir: 'src',
|
||||
filename: 'sw.js',
|
||||
includeAssets: ['logo.png'],
|
||||
manifest: {
|
||||
name: 'Dyna Biz Coignieres',
|
||||
short_name: 'DynaBiz',
|
||||
description: 'Application du Club d\'Affaires Dyna Biz Coignieres',
|
||||
theme_color: '#1e40af',
|
||||
background_color: '#ffffff',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait-primary',
|
||||
start_url: '/',
|
||||
icons: [
|
||||
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': { target: 'http://localhost:3000', changeOrigin: true },
|
||||
},
|
||||
},
|
||||
})
|
||||