Compare commits

...

1 Commits

Author SHA1 Message Date
Flatlogic Bot
2914397636 Auto commit: 2026-04-22T14:26:14.520Z 2026-04-22 14:26:14 +00:00
14 changed files with 1662 additions and 508 deletions

View File

@ -1,403 +1,362 @@
body {
background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab);
background-size: 400% 400%;
animation: gradient 15s ease infinite;
color: #212529;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 14px;
margin: 0;
min-height: 100vh;
:root {
--bg: #f5f7fa;
--surface: #ffffff;
--surface-muted: #f8fafc;
--border: #dbe1ea;
--border-strong: #c5ced9;
--text: #111827;
--text-muted: #667085;
--primary: #111827;
--secondary: #4b5563;
--accent: #0f172a;
--success: #166534;
--warning: #9a6700;
--danger: #b42318;
--shadow-sm: 0 1px 2px rgba(16, 24, 40, 0.04);
--shadow-md: 0 12px 30px rgba(15, 23, 42, 0.06);
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--spacing: 1rem;
}
.main-wrapper {
display: flex;
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
}
body.app-shell {
background: linear-gradient(to bottom, #f8fafc 0, #f8fafc 220px, #f5f7fa 220px, #f5f7fa 100%);
}
a {
color: inherit;
}
.app-navbar {
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(10px);
}
.navbar-brand {
display: inline-flex;
align-items: center;
gap: 0.75rem;
color: var(--primary);
}
.brand-mark {
width: 2rem;
height: 2rem;
border-radius: var(--radius-sm);
background: var(--primary);
color: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 100vh;
width: 100%;
padding: 20px;
box-sizing: border-box;
position: relative;
z-index: 1;
font-size: 0.75rem;
letter-spacing: 0.12em;
}
@keyframes gradient {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
.nav-link {
color: var(--secondary);
font-weight: 500;
}
.chat-container {
width: 100%;
max-width: 600px;
background: rgba(255, 255, 255, 0.85);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 20px;
display: flex;
flex-direction: column;
height: 85vh;
box-shadow: 0 20px 40px rgba(0,0,0,0.2);
backdrop-filter: blur(15px);
-webkit-backdrop-filter: blur(15px);
overflow: hidden;
.nav-link:hover,
.nav-link:focus,
.link-dark:hover,
.link-dark:focus {
color: #000 !important;
}
.chat-header {
padding: 1.5rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
background: rgba(255, 255, 255, 0.5);
font-weight: 700;
font-size: 1.1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
.hero-section {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 10px;
.eyebrow,
.section-kicker {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: var(--text-muted);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.5);
.display-title {
font-size: clamp(2.1rem, 4vw, 3.4rem);
line-height: 1.05;
letter-spacing: -0.03em;
max-width: 12ch;
}
.message {
max-width: 85%;
padding: 0.85rem 1.1rem;
border-radius: 16px;
line-height: 1.5;
font-size: 0.95rem;
box-shadow: 0 4px 15px rgba(0,0,0,0.05);
animation: fadeIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
.hero-copy {
max-width: 62ch;
color: var(--text-muted);
font-size: 1rem;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px) scale(0.95); }
to { opacity: 1; transform: translateY(0) scale(1); }
.panel-card,
.empty-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
}
.message.visitor {
align-self: flex-end;
background: linear-gradient(135deg, #212529 0%, #343a40 100%);
color: #fff;
border-bottom-right-radius: 4px;
.panel-card {
box-shadow: var(--shadow-md);
}
.message.bot {
align-self: flex-start;
background: #ffffff;
color: #212529;
border-bottom-left-radius: 4px;
.metric-card {
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 1rem;
background: var(--surface-muted);
min-height: 100%;
}
.chat-input-area {
padding: 1.25rem;
background: rgba(255, 255, 255, 0.5);
border-top: 1px solid rgba(0, 0, 0, 0.05);
.metric-card span {
display: block;
color: var(--text-muted);
font-size: 0.82rem;
margin-bottom: 0.35rem;
}
.chat-input-area form {
display: flex;
gap: 0.75rem;
.metric-card strong {
font-size: 1.6rem;
line-height: 1;
letter-spacing: -0.03em;
}
.chat-input-area input {
flex: 1;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 12px;
padding: 0.75rem 1rem;
outline: none;
background: rgba(255, 255, 255, 0.9);
transition: all 0.3s ease;
}
.chat-input-area input:focus {
border-color: #23a6d5;
box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.2);
}
.chat-input-area button {
background: #212529;
color: #fff;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 12px;
cursor: pointer;
font-weight: 600;
transition: all 0.3s ease;
}
.chat-input-area button:hover {
background: #000;
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0,0,0,0.2);
}
/* Background Animations */
.bg-animations {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
overflow: hidden;
pointer-events: none;
}
.blob {
position: absolute;
width: 500px;
height: 500px;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
filter: blur(80px);
animation: move 20s infinite alternate cubic-bezier(0.45, 0, 0.55, 1);
}
.blob-1 {
top: -10%;
left: -10%;
background: rgba(238, 119, 82, 0.4);
}
.blob-2 {
bottom: -10%;
right: -10%;
background: rgba(35, 166, 213, 0.4);
animation-delay: -7s;
width: 600px;
height: 600px;
}
.blob-3 {
top: 40%;
left: 30%;
background: rgba(231, 60, 126, 0.3);
animation-delay: -14s;
width: 450px;
height: 450px;
}
@keyframes move {
0% { transform: translate(0, 0) rotate(0deg) scale(1); }
33% { transform: translate(150px, 100px) rotate(120deg) scale(1.1); }
66% { transform: translate(-50px, 200px) rotate(240deg) scale(0.9); }
100% { transform: translate(0, 0) rotate(360deg) scale(1); }
}
.header-link {
font-size: 14px;
color: #fff;
text-decoration: none;
background: rgba(0, 0, 0, 0.2);
padding: 0.5rem 1rem;
border-radius: 8px;
transition: all 0.3s ease;
}
.header-link:hover {
background: rgba(0, 0, 0, 0.4);
text-decoration: none;
}
/* Admin Styles */
.admin-container {
max-width: 900px;
margin: 3rem auto;
padding: 2.5rem;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-radius: 24px;
box-shadow: 0 20px 50px rgba(0,0,0,0.15);
border: 1px solid rgba(255, 255, 255, 0.4);
position: relative;
z-index: 1;
}
.admin-container h1 {
margin-top: 0;
color: #212529;
font-weight: 800;
}
.table {
width: 100%;
border-collapse: separate;
border-spacing: 0 8px;
.workflow-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem;
margin-top: 1.5rem;
}
.table th {
background: transparent;
border: none;
.workflow-grid article {
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 1rem;
color: #6c757d;
font-weight: 600;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 1px;
background: var(--surface-muted);
}
.table td {
background: #fff;
padding: 1rem;
border: none;
.workflow-grid span {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 999px;
border: 1px solid var(--border-strong);
font-size: 0.82rem;
font-weight: 700;
margin-bottom: 0.75rem;
}
.table tr td:first-child { border-radius: 12px 0 0 12px; }
.table tr td:last-child { border-radius: 0 12px 12px 0; }
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
.workflow-grid h3 {
font-size: 1rem;
margin-bottom: 0.5rem;
font-weight: 600;
font-size: 0.9rem;
}
.form-control {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 12px;
background: #fff;
transition: all 0.3s ease;
box-sizing: border-box;
.workflow-grid p,
.empty-card p,
.quick-links .list-group-item,
.stack-item p,
.detail-note {
color: var(--text-muted);
}
.form-control:focus {
outline: none;
border-color: #23a6d5;
box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.1);
.quick-links .list-group-item {
padding-inline: 0;
border-color: var(--border);
background: transparent;
}
.header-container {
.stack-list {
display: grid;
gap: 0.9rem;
}
.stack-item {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 0.95rem 1rem;
background: var(--surface-muted);
}
.header-links {
.app-table {
--bs-table-bg: transparent;
--bs-table-striped-bg: #f8fafc;
margin-bottom: 0;
}
.app-table thead th {
background: #f8fafc;
color: var(--text-muted);
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.04em;
border-bottom-width: 1px;
}
.app-table td,
.app-table th {
padding: 1rem;
border-color: var(--border);
}
.auth-wrap {
min-height: calc(100vh - 180px);
display: flex;
align-items: center;
}
.form-control,
.form-select {
min-height: 44px;
border-color: var(--border-strong);
border-radius: var(--radius-sm);
padding-inline: 0.9rem;
background-color: #fff;
}
.form-control:focus,
.form-select:focus,
.btn:focus,
.nav-link:focus {
box-shadow: 0 0 0 0.2rem rgba(17, 24, 39, 0.12);
border-color: var(--primary);
}
.btn {
border-radius: var(--radius-sm);
padding: 0.7rem 1rem;
font-weight: 600;
}
.btn-lg {
padding: 0.85rem 1.25rem;
}
.btn-dark {
background: var(--primary);
border-color: var(--primary);
}
.btn-outline-secondary {
color: var(--secondary);
border-color: var(--border-strong);
}
.detail-list {
display: grid;
gap: 1rem;
}
.admin-card {
background: rgba(255, 255, 255, 0.6);
padding: 2rem;
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.5);
margin-bottom: 2.5rem;
box-shadow: 0 10px 30px rgba(0,0,0,0.05);
.detail-list div {
display: grid;
gap: 0.25rem;
padding-bottom: 0.9rem;
border-bottom: 1px solid var(--border);
}
.admin-card h3 {
margin-top: 0;
margin-bottom: 1.5rem;
.detail-list div:last-child {
border-bottom: 0;
padding-bottom: 0;
}
.detail-list dt {
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
}
.btn-delete {
background: #dc3545;
color: white;
border: none;
padding: 0.25rem 0.5rem;
border-radius: 4px;
cursor: pointer;
.detail-list dd {
margin: 0;
font-size: 1rem;
}
.btn-add {
background: #212529;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
margin-top: 1rem;
.toast {
border-radius: var(--radius-sm);
box-shadow: var(--shadow-md);
}
.btn-save {
background: #0088cc;
color: white;
border: none;
padding: 0.8rem 1.5rem;
border-radius: 12px;
cursor: pointer;
font-weight: 600;
width: 100%;
transition: all 0.3s ease;
.text-bg-success {
background-color: var(--success) !important;
}
.webhook-url {
font-size: 0.85em;
color: #555;
margin-top: 0.5rem;
.text-bg-warning {
background-color: var(--warning) !important;
color: #fff !important;
}
.history-table-container {
overflow-x: auto;
background: rgba(255, 255, 255, 0.4);
padding: 1rem;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.3);
.text-bg-danger {
background-color: var(--danger) !important;
}
.history-table {
width: 100%;
.text-bg-info {
background-color: #344054 !important;
}
.history-table-time {
width: 15%;
white-space: nowrap;
font-size: 0.85em;
color: #555;
.badge.text-bg-light {
background: #f8fafc !important;
color: var(--secondary) !important;
}
.history-table-user {
width: 35%;
background: rgba(255, 255, 255, 0.3);
border-radius: 8px;
padding: 8px;
.sticky-form-card {
position: sticky;
top: 5.5rem;
}
.history-table-ai {
width: 50%;
background: rgba(255, 255, 255, 0.5);
border-radius: 8px;
padding: 8px;
.app-footer {
background: transparent;
}
.no-messages {
text-align: center;
color: #777;
}
@media (max-width: 991.98px) {
.workflow-grid {
grid-template-columns: 1fr;
}
.sticky-form-card {
position: static;
}
}
@media (max-width: 575.98px) {
.display-title {
max-width: none;
}
.panel-card,
.empty-card {
border-radius: var(--radius-md);
}
.app-table td,
.app-table th {
padding: 0.8rem;
}
}

View File

@ -1,39 +1,12 @@
document.addEventListener('DOMContentLoaded', () => {
const chatForm = document.getElementById('chat-form');
const chatInput = document.getElementById('chat-input');
const chatMessages = document.getElementById('chat-messages');
document.querySelectorAll('.toast').forEach((toastEl) => {
const toast = new bootstrap.Toast(toastEl, { delay: 4500 });
toast.show();
});
const appendMessage = (text, sender) => {
const msgDiv = document.createElement('div');
msgDiv.classList.add('message', sender);
msgDiv.textContent = text;
chatMessages.appendChild(msgDiv);
chatMessages.scrollTop = chatMessages.scrollHeight;
};
chatForm.addEventListener('submit', async (e) => {
e.preventDefault();
const message = chatInput.value.trim();
if (!message) return;
appendMessage(message, 'visitor');
chatInput.value = '';
try {
const response = await fetch('api/chat.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message })
});
const data = await response.json();
// Artificial delay for realism
setTimeout(() => {
appendMessage(data.reply, 'bot');
}, 500);
} catch (error) {
console.error('Error:', error);
appendMessage("Sorry, something went wrong. Please try again.", 'bot');
}
document.querySelectorAll('input[data-uppercase="true"]').forEach((input) => {
input.addEventListener('input', () => {
input.value = input.value.toUpperCase();
});
});
});

83
athlete.php Normal file
View File

@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/app.php';
app_boot();
require_login();
$user = current_user();
$id = (int) ($_GET['id'] ?? 0);
$stmt = db()->prepare('SELECT * FROM athletes WHERE id = :id AND user_id = :user_id LIMIT 1');
$stmt->execute(['id' => $id, 'user_id' => (int) $user['id']]);
$athlete = $stmt->fetch();
if (!$athlete) {
http_response_code(404);
render_header('Fiche introuvable', ['description' => 'La fiche sportif demandée est introuvable.']);
?>
<main class="container py-5">
<div class="empty-card">
<h1 class="h4">Fiche introuvable</h1>
<p>Le sportif demandé nexiste pas ou nest pas accessible avec votre compte.</p>
<a href="athletes.php" class="btn btn-dark">Retour à la liste</a>
</div>
</main>
<?php
render_footer();
exit;
}
render_header('Fiche sportif', ['description' => 'Consulter le détail complet dun sportif dans RJLRESAKA.']);
?>
<main class="container-xxl py-5">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<p class="section-kicker mb-1">Fiche détaillée</p>
<h1 class="h2 mb-1"><?= e($athlete['first_name'] . ' ' . $athlete['last_name']) ?></h1>
<p class="text-secondary mb-0"><?= e((string) ($athlete['position_name'] ?: 'Poste non renseigné')) ?> • <?= e((string) $athlete['sport_name']) ?></p>
</div>
<div class="d-flex gap-2 flex-wrap">
<span class="badge text-bg-<?= e(stat_badge_class((string) $athlete['status'])) ?> px-3 py-2"><?= e(ucfirst((string) $athlete['status'])) ?></span>
<a href="athletes.php" class="btn btn-outline-secondary">Retour à la liste</a>
</div>
</div>
<div class="row g-4">
<div class="col-lg-4">
<div class="panel-card p-4 h-100">
<h2 class="h5 mb-3">Résumé</h2>
<dl class="detail-list mb-0">
<div><dt>Club actuel</dt><dd><?= e((string) $athlete['club_name']) ?></dd></div>
<div><dt>Nationalité</dt><dd><?= e((string) ($athlete['nationality'] ?: 'Non renseignée')) ?></dd></div>
<div><dt>Numéro</dt><dd><?= e((string) ($athlete['jersey_number'] ?: '—')) ?></dd></div>
<div><dt>Date darrivée</dt><dd><?= e(format_date($athlete['joined_on'])) ?></dd></div>
<div><dt>Créé le</dt><dd><?= e(format_datetime($athlete['created_at'])) ?></dd></div>
</dl>
</div>
</div>
<div class="col-lg-8">
<div class="panel-card p-4 mb-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="h5 mb-0">Performance actuelle</h2>
<span class="text-secondary small">Parcours professionnel instantané</span>
</div>
<div class="row g-3">
<div class="col-md-4"><div class="metric-card"><span>Matchs joués</span><strong><?= e((string) $athlete['matches_played']) ?></strong></div></div>
<div class="col-md-4"><div class="metric-card"><span>Buts / points</span><strong><?= e((string) $athlete['goals_scored']) ?></strong></div></div>
<div class="col-md-4"><div class="metric-card"><span>Passes décisives</span><strong><?= e((string) $athlete['assists_count']) ?></strong></div></div>
</div>
</div>
<div class="panel-card p-4 h-100">
<h2 class="h5 mb-3">Parcours et distinctions</h2>
<div class="mb-4">
<p class="text-secondary small text-uppercase mb-2">Distinctions</p>
<p class="mb-0"><?= e((string) ($athlete['awards'] ?: 'Aucune distinction renseignée.')) ?></p>
</div>
<div>
<p class="text-secondary small text-uppercase mb-2">Note de parcours</p>
<p class="mb-0 detail-note"><?= nl2br(e((string) ($athlete['career_note'] ?: 'Aucune note de parcours pour le moment.'))) ?></p>
</div>
</div>
</div>
</div>
</main>
<?php render_footer(); ?>

180
athlete_new.php Normal file
View File

@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/app.php';
app_boot();
require_login();
$user = current_user();
$errors = [];
if (is_post()) {
verify_csrf();
$firstName = trim((string) ($_POST['first_name'] ?? ''));
$lastName = trim((string) ($_POST['last_name'] ?? ''));
$sport = trim((string) ($_POST['sport_name'] ?? ''));
$club = trim((string) ($_POST['club_name'] ?? ''));
$nationality = trim((string) ($_POST['nationality'] ?? ''));
$position = trim((string) ($_POST['position_name'] ?? ''));
$jerseyNumber = trim((string) ($_POST['jersey_number'] ?? ''));
$status = trim((string) ($_POST['status'] ?? 'actif'));
$joinedOn = trim((string) ($_POST['joined_on'] ?? ''));
$matches = (int) ($_POST['matches_played'] ?? 0);
$goals = (int) ($_POST['goals_scored'] ?? 0);
$assists = (int) ($_POST['assists_count'] ?? 0);
$awards = trim((string) ($_POST['awards'] ?? ''));
$careerNote = trim((string) ($_POST['career_note'] ?? ''));
if ($firstName === '' || $lastName === '') {
$errors[] = 'Le prénom et le nom du sportif sont obligatoires.';
}
if ($sport === '') {
$errors[] = 'Le type de sport est obligatoire.';
}
if ($club === '') {
$errors[] = 'Le club actuel est obligatoire.';
}
if (!in_array($status, ['actif', 'blesse', 'suspendu', 'retraite'], true)) {
$errors[] = 'Le statut est invalide.';
}
if ($jerseyNumber !== '' && !ctype_digit($jerseyNumber)) {
$errors[] = 'Le numéro de maillot doit être numérique.';
}
if (!$errors) {
$stmt = db()->prepare(
'INSERT INTO athletes (user_id, first_name, last_name, sport_name, club_name, nationality, position_name, jersey_number, status, joined_on, matches_played, goals_scored, assists_count, awards, career_note)
VALUES (:user_id, :first_name, :last_name, :sport_name, :club_name, :nationality, :position_name, :jersey_number, :status, :joined_on, :matches_played, :goals_scored, :assists_count, :awards, :career_note)'
);
$stmt->bindValue(':user_id', (int) $user['id'], PDO::PARAM_INT);
$stmt->bindValue(':first_name', $firstName);
$stmt->bindValue(':last_name', $lastName);
$stmt->bindValue(':sport_name', $sport);
$stmt->bindValue(':club_name', $club);
$stmt->bindValue(':nationality', $nationality !== '' ? $nationality : null);
$stmt->bindValue(':position_name', $position !== '' ? $position : null);
$stmt->bindValue(':jersey_number', $jerseyNumber !== '' ? (int) $jerseyNumber : null, $jerseyNumber !== '' ? PDO::PARAM_INT : PDO::PARAM_NULL);
$stmt->bindValue(':status', $status);
$stmt->bindValue(':joined_on', $joinedOn !== '' ? $joinedOn : null);
$stmt->bindValue(':matches_played', max(0, $matches), PDO::PARAM_INT);
$stmt->bindValue(':goals_scored', max(0, $goals), PDO::PARAM_INT);
$stmt->bindValue(':assists_count', max(0, $assists), PDO::PARAM_INT);
$stmt->bindValue(':awards', $awards !== '' ? $awards : null);
$stmt->bindValue(':career_note', $careerNote !== '' ? $careerNote : null);
$stmt->execute();
$id = (int) db()->lastInsertId();
set_flash('success', 'Sportif créé avec succès.');
redirect('athlete.php?id=' . $id);
}
}
render_header('Ajouter un sportif', ['description' => 'Créer une nouvelle fiche sportive dans le registre RJLRESAKA.']);
?>
<main class="container-xxl py-5">
<div class="row g-4 align-items-start">
<div class="col-lg-4">
<div class="panel-card p-4 sticky-form-card">
<p class="section-kicker mb-1">Nouveau profil</p>
<h1 class="h3 mb-3">Ajouter un sportif</h1>
<p class="text-secondary mb-0">Créez une fiche complète avec club actuel, indicateurs de performance et note de parcours.</p>
</div>
</div>
<div class="col-lg-8">
<div class="panel-card p-4 p-lg-5">
<?php if ($errors): ?>
<div class="alert alert-danger" role="alert">
<ul class="mb-0 ps-3">
<?php foreach ($errors as $error): ?>
<li><?= e($error) ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<form method="post" class="vstack gap-4">
<input type="hidden" name="csrf_token" value="<?= e(csrf_token()) ?>">
<section>
<h2 class="h5 mb-3">Identité</h2>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label" for="first_name">Prénom</label>
<input class="form-control" id="first_name" name="first_name" value="<?= old('first_name') ?>" required>
</div>
<div class="col-md-6">
<label class="form-label" for="last_name">Nom</label>
<input class="form-control" id="last_name" name="last_name" value="<?= old('last_name') ?>" required>
</div>
<div class="col-md-6">
<label class="form-label" for="nationality">Nationalité</label>
<input class="form-control" id="nationality" name="nationality" value="<?= old('nationality') ?>">
</div>
<div class="col-md-6">
<label class="form-label" for="position_name">Poste / spécialité</label>
<input class="form-control" id="position_name" name="position_name" value="<?= old('position_name') ?>">
</div>
</div>
</section>
<section>
<h2 class="h5 mb-3">Affectation sportive</h2>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label" for="sport_name">Sport</label>
<input class="form-control" id="sport_name" name="sport_name" value="<?= old('sport_name') ?>" placeholder="Football, Basketball..." required>
</div>
<div class="col-md-6">
<label class="form-label" for="club_name">Club actuel</label>
<input class="form-control" id="club_name" name="club_name" value="<?= old('club_name') ?>" required>
</div>
<div class="col-md-4">
<label class="form-label" for="jersey_number"> maillot</label>
<input class="form-control" id="jersey_number" name="jersey_number" value="<?= old('jersey_number') ?>" inputmode="numeric">
</div>
<div class="col-md-4">
<label class="form-label" for="status">Statut</label>
<select class="form-select" id="status" name="status">
<?php foreach (['actif' => 'Actif', 'blesse' => 'Blessé', 'suspendu' => 'Suspendu', 'retraite' => 'Retraité'] as $value => $label): ?>
<option value="<?= e($value) ?>" <?= (($_POST['status'] ?? 'actif') === $value) ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-4">
<label class="form-label" for="joined_on">Date darrivée</label>
<input class="form-control" id="joined_on" type="date" name="joined_on" value="<?= old('joined_on') ?>">
</div>
</div>
</section>
<section>
<h2 class="h5 mb-3">Performance actuelle</h2>
<div class="row g-3">
<div class="col-md-4">
<label class="form-label" for="matches_played">Matchs joués</label>
<input class="form-control" id="matches_played" type="number" min="0" name="matches_played" value="<?= old('matches_played', '0') ?>">
</div>
<div class="col-md-4">
<label class="form-label" for="goals_scored">Buts / points</label>
<input class="form-control" id="goals_scored" type="number" min="0" name="goals_scored" value="<?= old('goals_scored', '0') ?>">
</div>
<div class="col-md-4">
<label class="form-label" for="assists_count">Passes décisives</label>
<input class="form-control" id="assists_count" type="number" min="0" name="assists_count" value="<?= old('assists_count', '0') ?>">
</div>
<div class="col-12">
<label class="form-label" for="awards">Distinctions</label>
<input class="form-control" id="awards" name="awards" value="<?= old('awards') ?>" placeholder="Capitaine, MVP, record personnel...">
</div>
<div class="col-12">
<label class="form-label" for="career_note">Note de parcours</label>
<textarea class="form-control" id="career_note" name="career_note" rows="5" placeholder="Résumé du parcours professionnel, transferts, progression..."><?= e($_POST['career_note'] ?? '') ?></textarea>
</div>
</div>
</section>
<div class="d-flex flex-wrap gap-2 justify-content-between align-items-center border-top pt-4">
<p class="text-secondary small mb-0">Les données sont enregistrées en base MySQL via PDO et requêtes préparées.</p>
<button class="btn btn-dark" type="submit">Enregistrer la fiche</button>
</div>
</form>
</div>
</div>
</div>
</main>
<?php render_footer(); ?>

134
athletes.php Normal file
View File

@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/app.php';
app_boot();
require_login();
$user = current_user();
$q = trim((string) ($_GET['q'] ?? ''));
$sport = trim((string) ($_GET['sport'] ?? ''));
$club = trim((string) ($_GET['club'] ?? ''));
$status = trim((string) ($_GET['status'] ?? ''));
$sql = 'SELECT * FROM athletes WHERE user_id = :user_id';
$params = ['user_id' => (int) $user['id']];
if ($q !== '') {
$sql .= ' AND (first_name LIKE :q OR last_name LIKE :q OR nationality LIKE :q OR position_name LIKE :q)';
$params['q'] = '%' . $q . '%';
}
if ($sport !== '') {
$sql .= ' AND sport_name = :sport';
$params['sport'] = $sport;
}
if ($club !== '') {
$sql .= ' AND club_name = :club';
$params['club'] = $club;
}
if ($status !== '') {
$sql .= ' AND status = :status';
$params['status'] = $status;
}
$sql .= ' ORDER BY created_at DESC';
$stmt = db()->prepare($sql);
$stmt->execute($params);
$athletes = $stmt->fetchAll();
$sports = db()->prepare('SELECT DISTINCT sport_name FROM athletes WHERE user_id = :user_id ORDER BY sport_name ASC');
$sports->execute(['user_id' => (int) $user['id']]);
$clubs = db()->prepare('SELECT DISTINCT club_name FROM athletes WHERE user_id = :user_id ORDER BY club_name ASC');
$clubs->execute(['user_id' => (int) $user['id']]);
render_header('Liste des sportifs', ['description' => 'Filtrer, parcourir et consulter les fiches sportifs enregistrées.']);
?>
<main class="container-xxl py-5">
<div class="d-flex flex-wrap justify-content-between align-items-end gap-3 mb-4">
<div>
<p class="section-kicker mb-1">Registre principal</p>
<h1 class="h3 mb-0">Liste des sportifs</h1>
</div>
<a href="athlete_new.php" class="btn btn-dark">Ajouter un sportif</a>
</div>
<div class="panel-card p-4 mb-4">
<form class="row g-3 align-items-end" method="get">
<div class="col-md-4">
<label class="form-label" for="q">Recherche</label>
<input class="form-control" id="q" name="q" value="<?= e($q) ?>" placeholder="Nom, poste, nationalité">
</div>
<div class="col-md-3">
<label class="form-label" for="sport">Sport</label>
<select class="form-select" id="sport" name="sport">
<option value="">Tous</option>
<?php foreach ($sports->fetchAll() as $item): ?>
<option value="<?= e((string) $item['sport_name']) ?>" <?= $sport === $item['sport_name'] ? 'selected' : '' ?>><?= e((string) $item['sport_name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-3">
<label class="form-label" for="club">Club</label>
<select class="form-select" id="club" name="club">
<option value="">Tous</option>
<?php foreach ($clubs->fetchAll() as $item): ?>
<option value="<?= e((string) $item['club_name']) ?>" <?= $club === $item['club_name'] ? 'selected' : '' ?>><?= e((string) $item['club_name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-2">
<label class="form-label" for="status">Statut</label>
<select class="form-select" id="status" name="status">
<option value="">Tous</option>
<?php foreach (['actif' => 'Actif', 'blesse' => 'Blessé', 'suspendu' => 'Suspendu', 'retraite' => 'Retraité'] as $value => $label): ?>
<option value="<?= e($value) ?>" <?= $status === $value ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12 d-flex gap-2">
<button class="btn btn-dark" type="submit">Filtrer</button>
<a href="athletes.php" class="btn btn-outline-secondary">Réinitialiser</a>
</div>
</form>
</div>
<div class="panel-card p-0 overflow-hidden">
<?php if ($athletes): ?>
<div class="table-responsive">
<table class="table align-middle app-table mb-0">
<thead>
<tr>
<th>Sportif</th>
<th>Sport</th>
<th>Club actuel</th>
<th>Statut</th>
<th>Stats</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($athletes as $athlete): ?>
<tr>
<td>
<strong><?= e($athlete['first_name'] . ' ' . $athlete['last_name']) ?></strong>
<div class="text-secondary small"><?= e((string) ($athlete['position_name'] ?: 'Poste non renseigné')) ?></div>
</td>
<td><?= e((string) $athlete['sport_name']) ?></td>
<td><?= e((string) $athlete['club_name']) ?></td>
<td><span class="badge text-bg-<?= e(stat_badge_class((string) $athlete['status'])) ?>"><?= e(ucfirst((string) $athlete['status'])) ?></span></td>
<td class="small text-secondary"><?= e((string) $athlete['matches_played']) ?> MJ · <?= e((string) $athlete['goals_scored']) ?> PTS · <?= e((string) $athlete['assists_count']) ?> AST</td>
<td class="text-end"><a href="athlete.php?id=<?= e((string) $athlete['id']) ?>" class="btn btn-sm btn-outline-secondary">Voir la fiche</a></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php else: ?>
<div class="empty-card m-4">
<h2 class="h5">Aucun résultat</h2>
<p class="mb-3">Aucune fiche ne correspond à vos filtres actuels.</p>
<a href="athlete_new.php" class="btn btn-dark">Créer la première fiche</a>
</div>
<?php endif; ?>
</div>
</main>
<?php render_footer(); ?>

77
change_password.php Normal file
View File

@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/app.php';
app_boot();
require_login();
$user = current_user();
$errors = [];
if (is_post()) {
verify_csrf();
$current = (string) ($_POST['current_password'] ?? '');
$password = (string) ($_POST['password'] ?? '');
$confirm = (string) ($_POST['password_confirm'] ?? '');
$stmt = db()->prepare('SELECT password_hash FROM users WHERE id = :id LIMIT 1');
$stmt->execute(['id' => (int) $user['id']]);
$fresh = $stmt->fetch();
if (!$fresh || !password_verify($current, (string) $fresh['password_hash'])) {
$errors[] = 'Le mot de passe actuel est incorrect.';
}
if (!password_rules_ok($password)) {
$errors[] = 'Le nouveau mot de passe doit contenir au moins 8 caractères.';
}
if ($password !== $confirm) {
$errors[] = 'La confirmation ne correspond pas.';
}
if (!$errors) {
$update = db()->prepare('UPDATE users SET password_hash = :password_hash WHERE id = :id');
$update->execute([
'password_hash' => password_hash($password, PASSWORD_BCRYPT),
'id' => (int) $user['id'],
]);
set_flash('success', 'Mot de passe modifié avec succès.');
redirect('index.php');
}
}
render_header('Modifier mot de passe', ['description' => 'Modifier le mot de passe du compte utilisateur RJLRESAKA.']);
?>
<main class="container py-5 auth-wrap">
<div class="row justify-content-center">
<div class="col-lg-5">
<div class="panel-card p-4 p-lg-5">
<p class="section-kicker mb-1">Sécurité</p>
<h1 class="h3 mb-3">Modifier le mot de passe</h1>
<?php if ($errors): ?>
<div class="alert alert-danger" role="alert">
<ul class="mb-0 ps-3">
<?php foreach ($errors as $error): ?>
<li><?= e($error) ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<form method="post" class="vstack gap-3">
<input type="hidden" name="csrf_token" value="<?= e(csrf_token()) ?>">
<div>
<label class="form-label" for="current_password">Mot de passe actuel</label>
<input class="form-control" id="current_password" type="password" name="current_password" required>
</div>
<div>
<label class="form-label" for="password">Nouveau mot de passe</label>
<input class="form-control" id="password" type="password" name="password" minlength="8" required>
</div>
<div>
<label class="form-label" for="password_confirm">Confirmation</label>
<input class="form-control" id="password_confirm" type="password" name="password_confirm" minlength="8" required>
</div>
<button class="btn btn-dark" type="submit">Enregistrer</button>
</form>
</div>
</div>
</div>
</main>
<?php render_footer(); ?>

58
forgot_password.php Normal file
View File

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/app.php';
app_boot();
if (is_post()) {
verify_csrf();
$email = strtolower(trim((string) ($_POST['email'] ?? '')));
if (filter_var($email, FILTER_VALIDATE_EMAIL)) {
$stmt = db()->prepare('SELECT id, email FROM users WHERE email = :email LIMIT 1');
$stmt->execute(['email' => $email]);
$user = $stmt->fetch();
if ($user) {
$token = strtoupper(substr(bin2hex(random_bytes(8)), 0, 12));
$hash = hash('sha256', $token);
$expiresAt = date('Y-m-d H:i:s', time() + 900);
$update = db()->prepare('UPDATE users SET reset_token_hash = :hash, reset_expires_at = :expires_at WHERE id = :id');
$update->execute([
'hash' => $hash,
'expires_at' => $expiresAt,
'id' => (int) $user['id'],
]);
error_log('RJLRESAKA reset token for ' . $email . ': ' . $token);
set_flash('info', 'Token de démonstration généré : ' . $token . ' (également journalisé côté serveur, valide 15 min).');
} else {
set_flash('info', 'Si ce compte existe, un token de réinitialisation a été généré.');
}
} else {
set_flash('warning', 'Veuillez saisir un email valide.');
}
redirect('reset_password.php');
}
render_header('Mot de passe oublié', ['description' => 'Générer un token de réinitialisation pour laccès à RJLRESAKA.']);
?>
<main class="container py-5 auth-wrap">
<div class="row justify-content-center">
<div class="col-lg-5">
<div class="panel-card p-4 p-lg-5">
<p class="section-kicker mb-1">Réinitialisation</p>
<h1 class="h3 mb-3">Mot de passe oublié</h1>
<p class="text-secondary mb-4">Saisissez votre email. Pour ce MVP, le token apparaît en notification et dans les logs serveur.</p>
<form method="post" class="vstack gap-3">
<input type="hidden" name="csrf_token" value="<?= e(csrf_token()) ?>">
<div>
<label class="form-label" for="email">Email du compte</label>
<input class="form-control" id="email" type="email" name="email" required>
</div>
<button class="btn btn-dark" type="submit">Générer un token</button>
</form>
</div>
</div>
</div>
</main>
<?php render_footer(); ?>

10
healthz.php Normal file
View File

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/app.php';
app_boot();
header('Content-Type: application/json');
echo json_encode([
'status' => 'ok',
'time' => gmdate('c'),
'php' => PHP_VERSION,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);

342
includes/app.php Normal file
View File

@ -0,0 +1,342 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../db/config.php';
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
function app_boot(): void
{
static $booted = false;
if ($booted) {
return;
}
date_default_timezone_set('UTC');
ensure_schema();
$booted = true;
}
function ensure_schema(): void
{
$pdo = db();
$pdo->exec(
"CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
first_name VARCHAR(80) NOT NULL,
last_name VARCHAR(80) NOT NULL,
email VARCHAR(150) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
role ENUM('admin','manager','user') NOT NULL DEFAULT 'manager',
failed_attempts INT NOT NULL DEFAULT 0,
locked_until DATETIME NULL,
reset_token_hash CHAR(64) NULL,
reset_expires_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_users_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS athletes (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
first_name VARCHAR(80) NOT NULL,
last_name VARCHAR(80) NOT NULL,
sport_name VARCHAR(100) NOT NULL,
club_name VARCHAR(120) NOT NULL,
nationality VARCHAR(80) DEFAULT NULL,
position_name VARCHAR(80) DEFAULT NULL,
jersey_number INT DEFAULT NULL,
status ENUM('actif','blesse','suspendu','retraite') NOT NULL DEFAULT 'actif',
joined_on DATE DEFAULT NULL,
matches_played INT NOT NULL DEFAULT 0,
goals_scored INT NOT NULL DEFAULT 0,
assists_count INT NOT NULL DEFAULT 0,
awards VARCHAR(255) DEFAULT NULL,
career_note TEXT DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_athletes_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_athletes_sport (sport_name),
INDEX idx_athletes_club (club_name),
INDEX idx_athletes_status (status),
INDEX idx_athletes_name (last_name, first_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
}
function e(?string $value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
function env_value(string $key, string $fallback = ''): string
{
$value = $_SERVER[$key] ?? getenv($key) ?: $fallback;
return is_string($value) ? $value : $fallback;
}
function app_name(): string
{
return env_value('PROJECT_NAME', 'RJLRESAKA');
}
function page_title(string $title): string
{
return $title . ' • ' . app_name();
}
function set_flash(string $type, string $message): void
{
$_SESSION['flash'][] = ['type' => $type, 'message' => $message];
}
function get_flashes(): array
{
$flashes = $_SESSION['flash'] ?? [];
unset($_SESSION['flash']);
return is_array($flashes) ? $flashes : [];
}
function current_user(): ?array
{
if (empty($_SESSION['user_id'])) {
return null;
}
static $user = null;
if ($user !== null) {
return $user;
}
$stmt = db()->prepare('SELECT id, first_name, last_name, email, role, created_at FROM users WHERE id = :id LIMIT 1');
$stmt->execute(['id' => (int) $_SESSION['user_id']]);
$user = $stmt->fetch() ?: null;
if (!$user) {
unset($_SESSION['user_id']);
}
return $user;
}
function require_login(): void
{
if (!current_user()) {
set_flash('warning', 'Veuillez vous connecter pour accéder à cette section.');
redirect('login.php');
}
}
function redirect(string $path): void
{
header('Location: ' . $path);
exit;
}
function csrf_token(): string
{
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(16));
}
return $_SESSION['csrf_token'];
}
function verify_csrf(): void
{
$token = $_POST['csrf_token'] ?? '';
if (!is_string($token) || !hash_equals($_SESSION['csrf_token'] ?? '', $token)) {
http_response_code(422);
exit('Jeton de formulaire invalide.');
}
}
function request_method(): string
{
return strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
}
function is_post(): bool
{
return request_method() === 'POST';
}
function old(string $key, string $fallback = ''): string
{
return e($_POST[$key] ?? $fallback);
}
function password_rules_ok(string $password): bool
{
return strlen($password) >= 8;
}
function login_user(array $user): void
{
session_regenerate_id(true);
$_SESSION['user_id'] = (int) $user['id'];
}
function logout_user(): void
{
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$params = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'], (bool) $params['secure'], (bool) $params['httponly']);
}
session_destroy();
}
function format_datetime(?string $value): string
{
if (!$value) {
return '—';
}
return date('d/m/Y H:i', strtotime($value));
}
function format_date(?string $value): string
{
if (!$value) {
return '—';
}
return date('d/m/Y', strtotime($value));
}
function stat_badge_class(string $status): string
{
return match ($status) {
'actif' => 'success',
'blesse' => 'warning',
'suspendu' => 'danger',
'retraite' => 'secondary',
default => 'secondary',
};
}
function fetch_dashboard_stats(?int $userId = null): array
{
$pdo = db();
$where = $userId ? 'WHERE user_id = :user_id' : '';
$stmt = $pdo->prepare("SELECT COUNT(*) AS total, COUNT(DISTINCT sport_name) AS sports, COUNT(DISTINCT club_name) AS clubs FROM athletes $where");
$stmt->execute($userId ? ['user_id' => $userId] : []);
$base = $stmt->fetch() ?: ['total' => 0, 'sports' => 0, 'clubs' => 0];
$statusStmt = $pdo->prepare("SELECT status, COUNT(*) AS total FROM athletes $where GROUP BY status ORDER BY total DESC");
$statusStmt->execute($userId ? ['user_id' => $userId] : []);
$recentStmt = $pdo->prepare("SELECT id, first_name, last_name, sport_name, club_name, status, created_at FROM athletes $where ORDER BY created_at DESC LIMIT 5");
$recentStmt->execute($userId ? ['user_id' => $userId] : []);
return [
'total' => (int) ($base['total'] ?? 0),
'sports' => (int) ($base['sports'] ?? 0),
'clubs' => (int) ($base['clubs'] ?? 0),
'status_breakdown' => $statusStmt->fetchAll(),
'recent' => $recentStmt->fetchAll(),
];
}
function top_values(string $column, int $limit = 6): array
{
$allowed = ['sport_name', 'club_name'];
if (!in_array($column, $allowed, true)) {
return [];
}
$stmt = db()->query("SELECT {$column} AS label, COUNT(*) AS total FROM athletes GROUP BY {$column} ORDER BY total DESC, {$column} ASC LIMIT {$limit}");
return $stmt->fetchAll();
}
function render_header(string $title, array $options = []): void
{
$pageDescription = $options['description'] ?? env_value('PROJECT_DESCRIPTION', 'Gestion professionnelle des sportifs, clubs et sports.');
$projectImageUrl = env_value('PROJECT_IMAGE_URL', '');
$bodyClass = $options['body_class'] ?? '';
$user = current_user();
$flashes = get_flashes();
$assetVersion = (string) @filemtime(__DIR__ . '/../assets/css/custom.css');
?>
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= e(page_title($title)) ?></title>
<meta name="description" content="<?= e($pageDescription) ?>">
<meta name="theme-color" content="#111827">
<meta property="og:title" content="<?= e(page_title($title)) ?>">
<meta property="og:description" content="<?= e($pageDescription) ?>">
<?php if ($projectImageUrl): ?>
<meta property="og:image" content="<?= e($projectImageUrl) ?>">
<meta property="twitter:image" content="<?= e($projectImageUrl) ?>">
<?php endif; ?>
<meta property="twitter:description" content="<?= e($pageDescription) ?>">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<link rel="stylesheet" href="assets/css/custom.css?v=<?= e($assetVersion) ?>">
</head>
<body class="<?= e($bodyClass) ?>">
<nav class="navbar navbar-expand-lg app-navbar sticky-top border-bottom">
<div class="container-xxl">
<a class="navbar-brand fw-semibold" href="index.php">
<span class="brand-mark">RJL</span>
<span>RJLRESAKA</span>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNav" aria-controls="mainNav" aria-expanded="false" aria-label="Basculer la navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="mainNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item"><a class="nav-link" href="index.php">Tableau de bord</a></li>
<li class="nav-item"><a class="nav-link" href="athletes.php">Sportifs</a></li>
<li class="nav-item"><a class="nav-link" href="athlete_new.php">Ajouter</a></li>
</ul>
<div class="d-flex align-items-center gap-2 flex-wrap">
<?php if ($user): ?>
<span class="text-secondary small">Connecté : <?= e($user['first_name'] . ' ' . $user['last_name']) ?></span>
<a class="btn btn-sm btn-outline-secondary" href="change_password.php">Mot de passe</a>
<a class="btn btn-sm btn-dark" href="logout.php">Déconnexion</a>
<?php else: ?>
<a class="btn btn-sm btn-outline-secondary" href="login.php">Connexion</a>
<a class="btn btn-sm btn-dark" href="register.php">Inscription</a>
<?php endif; ?>
</div>
</div>
</div>
</nav>
<?php if ($flashes): ?>
<div class="toast-container position-fixed top-0 end-0 p-3">
<?php foreach ($flashes as $flash): ?>
<div class="toast align-items-center text-bg-<?= e($flash['type']) ?> border-0 show mb-2" role="alert" aria-live="assertive" aria-atomic="true" data-autohide="true">
<div class="d-flex">
<div class="toast-body"><?= e($flash['message']) ?></div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Fermer"></button>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php
}
function render_footer(): void
{
$assetVersion = (string) @filemtime(__DIR__ . '/../assets/js/main.js');
?>
<footer class="border-top app-footer py-4 mt-5">
<div class="container-xxl d-flex flex-column flex-md-row justify-content-between gap-2 text-secondary small">
<span>RJLRESAKA MVP initial pour la gestion des sportifs</span>
<span>PHP <?= e(PHP_VERSION) ?> • <a href="healthz.php" class="link-secondary">Health check</a></span>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
<script src="assets/js/main.js?v=<?= e($assetVersion) ?>" defer></script>
</body>
</html>
<?php
}

338
index.php
View File

@ -1,150 +1,196 @@
<?php
declare(strict_types=1);
@ini_set('display_errors', '1');
@error_reporting(E_ALL);
@date_default_timezone_set('UTC');
require_once __DIR__ . '/includes/app.php';
app_boot();
$phpVersion = PHP_VERSION;
$now = date('Y-m-d H:i:s');
$user = current_user();
$stats = fetch_dashboard_stats($user['id'] ?? null);
$topSports = top_values('sport_name');
$topClubs = top_values('club_name');
render_header('Tableau de bord', [
'description' => 'RJLRESAKA centralise les sportifs, clubs et disciplines dans une interface nette et sécurisée.',
'body_class' => 'app-shell',
]);
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>New Style</title>
<?php
// Read project preview data from environment
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
?>
<?php if ($projectDescription): ?>
<!-- Meta description -->
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' />
<!-- Open Graph meta tags -->
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<!-- Twitter meta tags -->
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<?php endif; ?>
<?php if ($projectImageUrl): ?>
<!-- Open Graph image -->
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<!-- Twitter image -->
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<?php endif; ?>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-color-start: #6a11cb;
--bg-color-end: #2575fc;
--text-color: #ffffff;
--card-bg-color: rgba(255, 255, 255, 0.01);
--card-border-color: rgba(255, 255, 255, 0.1);
}
body {
margin: 0;
font-family: 'Inter', sans-serif;
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
color: var(--text-color);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
text-align: center;
overflow: hidden;
position: relative;
}
body::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><path d="M-10 10L110 10M10 -10L10 110" stroke-width="1" stroke="rgba(255,255,255,0.05)"/></svg>');
animation: bg-pan 20s linear infinite;
z-index: -1;
}
@keyframes bg-pan {
0% { background-position: 0% 0%; }
100% { background-position: 100% 100%; }
}
main {
padding: 2rem;
}
.card {
background: var(--card-bg-color);
border: 1px solid var(--card-border-color);
border-radius: 16px;
padding: 2rem;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.1);
}
.loader {
margin: 1.25rem auto 1.25rem;
width: 48px;
height: 48px;
border: 3px solid rgba(255, 255, 255, 0.25);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.hint {
opacity: 0.9;
}
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap; border: 0;
}
h1 {
font-size: 3rem;
font-weight: 700;
margin: 0 0 1rem;
letter-spacing: -1px;
}
p {
margin: 0.5rem 0;
font-size: 1.1rem;
}
code {
background: rgba(0,0,0,0.2);
padding: 2px 6px;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
footer {
position: absolute;
bottom: 1rem;
font-size: 0.8rem;
opacity: 0.7;
}
</style>
</head>
<body>
<main>
<div class="card">
<h1>Analyzing your requirements and generating your website…</h1>
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
<span class="sr-only">Loading…</span>
</div>
<p class="hint"><?= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.</p>
<p class="hint">This page will update automatically as the plan is implemented.</p>
<p>Runtime: PHP <code><?= htmlspecialchars($phpVersion) ?></code> — UTC <code><?= htmlspecialchars($now) ?></code></p>
</div>
</main>
<footer>
Page updated: <?= htmlspecialchars($now) ?> (UTC)
</footer>
</body>
</html>
<main>
<section class="hero-section border-bottom">
<div class="container-xxl py-5">
<div class="row g-4 align-items-center">
<div class="col-lg-7">
<span class="eyebrow">Gestion sportive sécurisée</span>
<h1 class="display-title mt-3 mb-3">Pilotez vos sportifs, clubs et disciplines dans un espace unique.</h1>
<p class="hero-copy mb-4">Ce premier MVP livre une base réellement exploitable&nbsp;: authentification sécurisée, verrouillage après 5 échecs, réinitialisation par token, création de fiches sportifs, tableau de bord, liste filtrable et fiche détaillée.</p>
<div class="d-flex flex-wrap gap-2">
<?php if ($user): ?>
<a href="athlete_new.php" class="btn btn-dark btn-lg">Ajouter un sportif</a>
<a href="athletes.php" class="btn btn-outline-secondary btn-lg">Voir le registre</a>
<?php else: ?>
<a href="register.php" class="btn btn-dark btn-lg">Créer un compte</a>
<a href="login.php" class="btn btn-outline-secondary btn-lg">Se connecter</a>
<?php endif; ?>
</div>
</div>
<div class="col-lg-5">
<div class="panel-card p-4">
<div class="d-flex justify-content-between align-items-start mb-4">
<div>
<p class="text-secondary text-uppercase small mb-1">Vue synthèse</p>
<h2 class="h5 mb-0">Indicateurs clés</h2>
</div>
<span class="badge text-bg-light border">MVP initial</span>
</div>
<div class="row g-3">
<div class="col-6">
<div class="metric-card">
<span>Total sportifs</span>
<strong><?= e((string) $stats['total']) ?></strong>
</div>
</div>
<div class="col-6">
<div class="metric-card">
<span>Sports suivis</span>
<strong><?= e((string) $stats['sports']) ?></strong>
</div>
</div>
<div class="col-6">
<div class="metric-card">
<span>Clubs actifs</span>
<strong><?= e((string) $stats['clubs']) ?></strong>
</div>
</div>
<div class="col-6">
<div class="metric-card">
<span>Statuts suivis</span>
<strong><?= e((string) count($stats['status_breakdown'])) ?></strong>
</div>
</div>
</div>
<hr class="my-4">
<div class="d-flex flex-wrap gap-2">
<?php if (!empty($stats['status_breakdown'])): ?>
<?php foreach ($stats['status_breakdown'] as $row): ?>
<span class="badge rounded-pill text-bg-<?= e(stat_badge_class((string) $row['status'])) ?>"><?= e(ucfirst((string) $row['status'])) ?> · <?= e((string) $row['total']) ?></span>
<?php endforeach; ?>
<?php else: ?>
<span class="text-secondary small">Aucun sportif enregistré pour le moment.</span>
<?php endif; ?>
</div>
</div>
</div>
</div>
</div>
</section>
<section class="container-xxl py-5">
<div class="row g-4">
<div class="col-lg-8">
<div class="panel-card p-4 h-100">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<p class="section-kicker mb-1">Workflow livré</p>
<h2 class="h4 mb-0">Parcours utilisateur disponible maintenant</h2>
</div>
<a href="athletes.php" class="link-dark text-decoration-none">Ouvrir le registre</a>
</div>
<div class="workflow-grid">
<article>
<span>01</span>
<h3>Accès sécurisé</h3>
<p>Inscription, connexion, verrouillage temporaire après 5 tentatives et réinitialisation via token.</p>
</article>
<article>
<span>02</span>
<h3>Création guidée</h3>
<p>Ajoutez un sportif avec discipline, club, statut, statistiques clés et note de parcours.</p>
</article>
<article>
<span>03</span>
<h3>Suivi exploitable</h3>
<p>Filtrez le registre, consultez la fiche détaillée et surveillez la répartition par sport et par club.</p>
</article>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="panel-card p-4 h-100">
<p class="section-kicker mb-1">Navigation rapide</p>
<h2 class="h4 mb-3">Actions recommandées</h2>
<div class="list-group list-group-flush quick-links">
<a class="list-group-item list-group-item-action" href="register.php">Créer un espace gestionnaire</a>
<a class="list-group-item list-group-item-action" href="athlete_new.php">Ajouter une fiche sportif</a>
<a class="list-group-item list-group-item-action" href="athletes.php">Consulter et filtrer la base</a>
<a class="list-group-item list-group-item-action" href="change_password.php">Sécuriser le mot de passe</a>
</div>
</div>
</div>
</div>
</section>
<section class="container-xxl pb-5">
<div class="row g-4">
<div class="col-lg-6">
<div class="panel-card p-4 h-100">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="h5 mb-0">Sports les plus représentés</h2>
<span class="text-secondary small">Aperçu agrégé</span>
</div>
<?php if ($topSports): ?>
<div class="stack-list">
<?php foreach ($topSports as $item): ?>
<div class="stack-item">
<div>
<strong><?= e((string) $item['label']) ?></strong>
<p class="mb-0 text-secondary small">Discipline suivie</p>
</div>
<span class="badge text-bg-light border"><?= e((string) $item['total']) ?> sportifs</span>
</div>
<?php endforeach; ?>
</div>
<?php else: ?>
<div class="empty-card">
<h3 class="h6">Aucun sport disponible</h3>
<p class="mb-0">Ajoutez votre premier sportif pour faire apparaître les disciplines suivies.</p>
</div>
<?php endif; ?>
</div>
</div>
<div class="col-lg-6">
<div class="panel-card p-4 h-100">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="h5 mb-0">Clubs récents</h2>
<span class="text-secondary small">Vue opérationnelle</span>
</div>
<?php if (!empty($stats['recent'])): ?>
<div class="table-responsive">
<table class="table align-middle app-table mb-0">
<thead>
<tr>
<th>Sportif</th>
<th>Club</th>
<th>Statut</th>
</tr>
</thead>
<tbody>
<?php foreach ($stats['recent'] as $athlete): ?>
<tr>
<td><a href="athlete.php?id=<?= e((string) $athlete['id']) ?>" class="link-dark fw-semibold text-decoration-none"><?= e($athlete['first_name'] . ' ' . $athlete['last_name']) ?></a></td>
<td><?= e((string) $athlete['club_name']) ?></td>
<td><span class="badge text-bg-<?= e(stat_badge_class((string) $athlete['status'])) ?>"><?= e(ucfirst((string) $athlete['status'])) ?></span></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php else: ?>
<div class="empty-card">
<h3 class="h6">Registre vide</h3>
<p class="mb-0">Le tableau de bord senrichira dès la première fiche créée.</p>
</div>
<?php endif; ?>
</div>
</div>
</div>
</section>
</main>
<?php render_footer(); ?>

80
login.php Normal file
View File

@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/app.php';
app_boot();
if (current_user()) {
redirect('index.php');
}
$error = null;
if (is_post()) {
verify_csrf();
$email = strtolower(trim((string) ($_POST['email'] ?? '')));
$password = (string) ($_POST['password'] ?? '');
$stmt = db()->prepare('SELECT * FROM users WHERE email = :email LIMIT 1');
$stmt->execute(['email' => $email]);
$user = $stmt->fetch();
if (!$user) {
$error = 'Identifiants invalides.';
} else {
$lockedUntil = $user['locked_until'] ?? null;
if ($lockedUntil && strtotime((string) $lockedUntil) > time()) {
$seconds = max(1, strtotime((string) $lockedUntil) - time());
$error = 'Compte temporairement bloqué. Réessayez dans ' . $seconds . ' secondes.';
} elseif (password_verify($password, (string) $user['password_hash'])) {
$reset = db()->prepare('UPDATE users SET failed_attempts = 0, locked_until = NULL WHERE id = :id');
$reset->execute(['id' => (int) $user['id']]);
login_user($user);
set_flash('success', 'Connexion réussie.');
redirect('index.php');
} else {
$attempts = (int) $user['failed_attempts'] + 1;
$locked = $attempts >= 5 ? date('Y-m-d H:i:s', time() + 30) : null;
$update = db()->prepare('UPDATE users SET failed_attempts = :failed_attempts, locked_until = :locked_until WHERE id = :id');
$update->bindValue(':failed_attempts', $attempts, PDO::PARAM_INT);
$update->bindValue(':locked_until', $locked);
$update->bindValue(':id', (int) $user['id'], PDO::PARAM_INT);
$update->execute();
$error = $attempts >= 5
? '5 tentatives atteintes. Compte bloqué pendant 30 secondes.'
: 'Identifiants invalides. Tentative ' . $attempts . '/5.';
}
}
}
render_header('Connexion', ['description' => 'Se connecter à RJLRESAKA pour accéder au registre des sportifs.']);
?>
<main class="container py-5 auth-wrap">
<div class="row justify-content-center">
<div class="col-lg-5 col-xl-4">
<div class="panel-card p-4 p-lg-5">
<p class="section-kicker mb-1">Authentification</p>
<h1 class="h3 mb-3">Connexion sécurisée</h1>
<p class="text-secondary mb-4">5 tentatives maximum, puis pause automatique de 30 secondes.</p>
<?php if ($error): ?>
<div class="alert alert-danger" role="alert"><?= e($error) ?></div>
<?php endif; ?>
<form method="post" class="vstack gap-3">
<input type="hidden" name="csrf_token" value="<?= e(csrf_token()) ?>">
<div>
<label class="form-label" for="email">Email</label>
<input class="form-control" id="email" type="email" name="email" value="<?= old('email') ?>" required>
</div>
<div>
<label class="form-label" for="password">Mot de passe</label>
<input class="form-control" id="password" type="password" name="password" required>
</div>
<button class="btn btn-dark w-100" type="submit">Se connecter</button>
</form>
<div class="d-flex justify-content-between flex-wrap gap-2 mt-3 small">
<a href="forgot_password.php" class="link-dark">Mot de passe oublié ?</a>
<a href="register.php" class="link-dark">Créer un compte</a>
</div>
</div>
</div>
</div>
</main>
<?php render_footer(); ?>

8
logout.php Normal file
View File

@ -0,0 +1,8 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/app.php';
app_boot();
logout_user();
session_start();
set_flash('success', 'Vous avez été déconnecté avec succès.');
redirect('login.php');

109
register.php Normal file
View File

@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/app.php';
app_boot();
if (current_user()) {
redirect('index.php');
}
$errors = [];
if (is_post()) {
verify_csrf();
$firstName = trim((string) ($_POST['first_name'] ?? ''));
$lastName = trim((string) ($_POST['last_name'] ?? ''));
$email = strtolower(trim((string) ($_POST['email'] ?? '')));
$password = (string) ($_POST['password'] ?? '');
$confirm = (string) ($_POST['password_confirm'] ?? '');
if ($firstName === '') {
$errors[] = 'Le prénom est obligatoire.';
}
if ($lastName === '') {
$errors[] = 'Le nom est obligatoire.';
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$errors[] = 'Veuillez saisir une adresse email valide.';
}
if (!password_rules_ok($password)) {
$errors[] = 'Le mot de passe doit contenir au moins 8 caractères.';
}
if ($password !== $confirm) {
$errors[] = 'La confirmation du mot de passe ne correspond pas.';
}
if (!$errors) {
$check = db()->prepare('SELECT id FROM users WHERE email = :email LIMIT 1');
$check->execute(['email' => $email]);
if ($check->fetch()) {
$errors[] = 'Un compte existe déjà avec cet email.';
}
}
if (!$errors) {
$stmt = db()->prepare('INSERT INTO users (first_name, last_name, email, password_hash) VALUES (:first_name, :last_name, :email, :password_hash)');
$stmt->execute([
'first_name' => $firstName,
'last_name' => $lastName,
'email' => $email,
'password_hash' => password_hash($password, PASSWORD_BCRYPT),
]);
$userId = (int) db()->lastInsertId();
login_user(['id' => $userId]);
set_flash('success', 'Compte créé avec succès. Bienvenue sur RJLRESAKA.');
redirect('index.php');
}
}
render_header('Inscription', ['description' => 'Créer un compte pour gérer les sportifs et clubs dans RJLRESAKA.']);
?>
<main class="container py-5 auth-wrap">
<div class="row justify-content-center">
<div class="col-lg-6 col-xl-5">
<div class="panel-card p-4 p-lg-5">
<p class="section-kicker mb-1">Créer un compte</p>
<h1 class="h3 mb-3">Ouvrir lespace de gestion</h1>
<p class="text-secondary mb-4">Commencez avec un compte sécurisé pour enregistrer vos premiers sportifs.</p>
<?php if ($errors): ?>
<div class="alert alert-danger" role="alert">
<ul class="mb-0 ps-3">
<?php foreach ($errors as $error): ?>
<li><?= e($error) ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<form method="post" class="vstack gap-3">
<input type="hidden" name="csrf_token" value="<?= e(csrf_token()) ?>">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label" for="first_name">Prénom</label>
<input class="form-control" id="first_name" name="first_name" value="<?= old('first_name') ?>" required>
</div>
<div class="col-md-6">
<label class="form-label" for="last_name">Nom</label>
<input class="form-control" id="last_name" name="last_name" value="<?= old('last_name') ?>" required>
</div>
</div>
<div>
<label class="form-label" for="email">Email</label>
<input class="form-control" id="email" type="email" name="email" value="<?= old('email') ?>" required>
</div>
<div>
<label class="form-label" for="password">Mot de passe</label>
<input class="form-control" id="password" type="password" name="password" minlength="8" required>
</div>
<div>
<label class="form-label" for="password_confirm">Confirmation du mot de passe</label>
<input class="form-control" id="password_confirm" type="password" name="password_confirm" minlength="8" required>
</div>
<button class="btn btn-dark w-100" type="submit">Créer mon compte</button>
</form>
<p class="text-secondary small mt-3 mb-0">Déjà inscrit ? <a href="login.php" class="link-dark">Connectez-vous</a>.</p>
</div>
</div>
</div>
</main>
<?php render_footer(); ?>

95
reset_password.php Normal file
View File

@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/app.php';
app_boot();
$errors = [];
if (is_post()) {
verify_csrf();
$email = strtolower(trim((string) ($_POST['email'] ?? '')));
$token = strtoupper(trim((string) ($_POST['token'] ?? '')));
$password = (string) ($_POST['password'] ?? '');
$confirm = (string) ($_POST['password_confirm'] ?? '');
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$errors[] = 'Adresse email invalide.';
}
if ($token === '') {
$errors[] = 'Le token est obligatoire.';
}
if (!password_rules_ok($password)) {
$errors[] = 'Le nouveau mot de passe doit contenir au moins 8 caractères.';
}
if ($password !== $confirm) {
$errors[] = 'La confirmation du mot de passe est incorrecte.';
}
if (!$errors) {
$stmt = db()->prepare('SELECT id, reset_token_hash, reset_expires_at FROM users WHERE email = :email LIMIT 1');
$stmt->execute(['email' => $email]);
$user = $stmt->fetch();
$hash = hash('sha256', $token);
$valid = $user
&& !empty($user['reset_token_hash'])
&& hash_equals((string) $user['reset_token_hash'], $hash)
&& !empty($user['reset_expires_at'])
&& strtotime((string) $user['reset_expires_at']) >= time();
if (!$valid) {
$errors[] = 'Token invalide ou expiré.';
} else {
$update = db()->prepare('UPDATE users SET password_hash = :password_hash, reset_token_hash = NULL, reset_expires_at = NULL, failed_attempts = 0, locked_until = NULL WHERE id = :id');
$update->execute([
'password_hash' => password_hash($password, PASSWORD_BCRYPT),
'id' => (int) $user['id'],
]);
set_flash('success', 'Mot de passe réinitialisé. Vous pouvez maintenant vous connecter.');
redirect('login.php');
}
}
}
render_header('Reset mot de passe', ['description' => 'Valider un token et définir un nouveau mot de passe sur RJLRESAKA.']);
?>
<main class="container py-5 auth-wrap">
<div class="row justify-content-center">
<div class="col-lg-6 col-xl-5">
<div class="panel-card p-4 p-lg-5">
<p class="section-kicker mb-1">Validation du token</p>
<h1 class="h3 mb-3">Définir un nouveau mot de passe</h1>
<p class="text-secondary mb-4">Copiez le token reçu puis confirmez un nouveau mot de passe sécurisé.</p>
<?php if ($errors): ?>
<div class="alert alert-danger" role="alert">
<ul class="mb-0 ps-3">
<?php foreach ($errors as $error): ?>
<li><?= e($error) ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<form method="post" class="vstack gap-3">
<input type="hidden" name="csrf_token" value="<?= e(csrf_token()) ?>">
<div>
<label class="form-label" for="email">Email</label>
<input class="form-control" id="email" type="email" name="email" value="<?= old('email') ?>" required>
</div>
<div>
<label class="form-label" for="token">Token</label>
<input class="form-control text-uppercase" id="token" name="token" value="<?= old('token', (string) ($_GET['token'] ?? '')) ?>" required>
</div>
<div>
<label class="form-label" for="password">Nouveau mot de passe</label>
<input class="form-control" id="password" type="password" name="password" minlength="8" required>
</div>
<div>
<label class="form-label" for="password_confirm">Confirmer</label>
<input class="form-control" id="password_confirm" type="password" name="password_confirm" minlength="8" required>
</div>
<button class="btn btn-dark" type="submit">Mettre à jour le mot de passe</button>
</form>
</div>
</div>
</div>
</main>
<?php render_footer(); ?>