Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2914397636 |
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
83
athlete.php
Normal 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é n’existe pas ou n’est 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 d’un 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 d’arrivé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
180
athlete_new.php
Normal 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">N° 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 d’arrivé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
134
athletes.php
Normal 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
77
change_password.php
Normal 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
58
forgot_password.php
Normal 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 l’accè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
10
healthz.php
Normal 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
342
includes/app.php
Normal 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
338
index.php
@ -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 : 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 s’enrichira 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
80
login.php
Normal 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
8
logout.php
Normal 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
109
register.php
Normal 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 l’espace 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
95
reset_password.php
Normal 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(); ?>
|
||||
Loading…
x
Reference in New Issue
Block a user