Commit f548e948 authored by AGLANPC\aglan's avatar AGLANPC\aglan

phase0CodePlaced

parents
RewriteEngine On
RewriteBase /
# Force HTTPS (uncomment in production)
# RewriteCond %{HTTPS} off
# RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
# Security headers
<IfModule mod_headers.c>
Header set X-Content-Type-Options "nosniff"
Header set X-Frame-Options "SAMEORIGIN"
Header set X-XSS-Protection "1; mode=block"
Header set Referrer-Policy "strict-origin-when-cross-origin"
Header set Permissions-Policy "camera=(), microphone=(), geolocation=()"
</IfModule>
# Block access to sensitive files
<FilesMatch "\.(env|log|ini|conf|sql|md|json|lock|yml|yaml)$">
Require all denied
</FilesMatch>
# Block access to hidden files
<FilesMatch "^\.">
Require all denied
</FilesMatch>
# Pass API requests directly to API files
RewriteCond %{REQUEST_URI} ^/api/ [NC]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^api/(.+)$ api/$1.php [L,QSA]
# If not a real file or directory, route through index.php
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} !^/assets/ [NC]
RewriteRule ^(.*)$ index.php?route=$1 [L,QSA]
# Disable directory listing
Options -Indexes
# PHP settings
<IfModule mod_php.c>
php_value upload_max_filesize 10M
php_value post_max_size 12M
php_value max_execution_time 60
php_value session.cookie_httponly 1
php_value session.cookie_samesite "Strict"
php_value session.use_strict_mode 1
</IfModule>
\ No newline at end of file
# Allow direct access to API PHP files
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /api/
# If request doesn't end in .php, add it
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME}.php -f
RewriteRule ^(.+)$ $1.php [L]
</IfModule>
# Security headers for API responses
<IfModule mod_headers.c>
Header set X-Content-Type-Options "nosniff"
Header set X-Frame-Options "DENY"
</IfModule>
\ No newline at end of file
<?php
/**
* API: Login
* POST /api/auth/login
*/
define('ROOT_PATH', dirname(__DIR__, 2));
require_once ROOT_PATH . '/core/bootstrap.php';
if (!is_post()) {
json_response(['success' => false, 'message' => 'Method not allowed'], 405);
}
csrf_require();
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
$username = trim($input['username'] ?? '');
$password = $input['password'] ?? '';
if (empty($username) || empty($password)) {
json_response(['success' => false, 'message' => 'Username and password required'], 422);
}
$result = Auth::attempt($username, $password, client_ip(), $_SERVER['HTTP_USER_AGENT'] ?? '');
if ($result['success']) {
$user = $result['user'];
json_response([
'success' => true,
'message' => $result['message'],
'user' => [
'id' => $user['id'],
'username' => $user['username'],
'name' => $user['full_name_ar'] ?? $user['username'],
'role' => $user['role_name'] ?? '',
],
]);
} else {
json_response([
'success' => false,
'message' => $result['message'],
], 401);
}
\ No newline at end of file
<?php
/**
* API: Logout
* POST /api/auth/logout
*/
define('ROOT_PATH', dirname(__DIR__, 2));
require_once ROOT_PATH . '/core/bootstrap.php';
Auth::logout();
json_response(['success' => true, 'message' => 'Logged out']);
\ No newline at end of file
<?php
/**
* API: Confirm Password Reset
* POST /api/auth/password-reset-confirm
*/
define('ROOT_PATH', dirname(__DIR__, 2));
require_once ROOT_PATH . '/core/bootstrap.php';
if (!is_post()) json_response(['success' => false, 'message' => 'Method not allowed'], 405);
csrf_require();
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
$token = $input['token'] ?? '';
$password = $input['password'] ?? '';
$confirm = $input['password_confirm'] ?? '';
if (empty($token)) {
json_response(['success' => false, 'message' => 'Token required'], 422);
}
if (strlen($password) < app_config('security.password_min_length')) {
json_response(['success' => false, 'message' => 'Password too short'], 422);
}
if ($password !== $confirm) {
json_response(['success' => false, 'message' => 'Passwords do not match'], 422);
}
$result = Auth::resetPassword($token, $password);
if ($result) {
json_response(['success' => true, 'message' => 'Password reset successfully']);
} else {
json_response(['success' => false, 'message' => 'Invalid or expired token'], 400);
}
\ No newline at end of file
<?php
/**
* API: Request Password Reset
* POST /api/auth/password-reset
*/
define('ROOT_PATH', dirname(__DIR__, 2));
require_once ROOT_PATH . '/core/bootstrap.php';
if (!is_post()) json_response(['success' => false, 'message' => 'Method not allowed'], 405);
csrf_require();
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
$email = trim($input['email'] ?? '');
if (!validate_email($email)) {
json_response(['success' => false, 'message' => 'Invalid email'], 422);
}
Auth::generatePasswordResetToken($email);
// Always return success to prevent email enumeration
json_response(['success' => true, 'message' => 'If the email exists, a reset link has been sent']);
\ No newline at end of file
<?php
/**
* API: System Config / Preferences
* GET /api/config/get — get config values
* POST /api/config/get?action=toggle_sidebar — toggle sidebar state
*/
define('ROOT_PATH', dirname(__DIR__, 2));
require_once ROOT_PATH . '/core/bootstrap.php';
if (!Auth::check()) {
json_response(['success' => false, 'message' => 'Unauthorized'], 401);
}
$action = input('action', '', 'REQUEST');
if ($action === 'toggle_sidebar' && is_post()) {
$current = Session::get('sidebar_collapsed', false);
Session::set('sidebar_collapsed', !$current);
json_response(['success' => true, 'collapsed' => !$current]);
}
// Return specific config values (non-sensitive)
$keys = input('keys', '', 'GET');
if ($keys) {
$keyList = explode(',', $keys);
$result = [];
foreach ($keyList as $key) {
$key = trim($key);
// Only return non-sensitive keys
if (!str_contains($key, 'password') && !str_contains($key, 'secret') && !str_contains($key, 'key')) {
$result[$key] = SystemConfig::get($key);
}
}
json_response(['success' => true, 'data' => $result]);
}
json_response(['success' => true, 'data' => []]);
\ No newline at end of file
<?php
/**
* API: System Config / Preferences
* GET /api/config/get — get config values
* POST /api/config/get?action=toggle_sidebar — toggle sidebar state
*/
define('ROOT_PATH', dirname(__DIR__, 2));
require_once ROOT_PATH . '/core/bootstrap.php';
if (!Auth::check()) {
json_response(['success' => false, 'message' => 'Unauthorized'], 401);
}
$action = input('action', '', 'REQUEST');
if ($action === 'toggle_sidebar' && is_post()) {
csrf_require();
$current = Session::get('sidebar_collapsed', false);
Session::set('sidebar_collapsed', !$current);
json_response(['success' => true, 'collapsed' => !$current]);
}
// Return specific config values (non-sensitive)
$keys = input('keys', '', 'GET');
if ($keys) {
$keyList = explode(',', $keys);
$result = [];
foreach ($keyList as $key) {
$key = trim($key);
// Block sensitive keys
if (!str_contains($key, 'password') && !str_contains($key, 'secret') && !str_contains($key, 'key') && !str_contains($key, 'token')) {
$result[$key] = SystemConfig::get($key);
}
}
json_response(['success' => true, 'data' => $result]);
}
json_response(['success' => true, 'data' => []]);
\ No newline at end of file
<?php
/**
* API: Mark Notifications as Read
* POST /api/notifications/mark-read
*/
define('ROOT_PATH', dirname(__DIR__, 2));
require_once ROOT_PATH . '/core/bootstrap.php';
if (!Auth::check()) {
json_response(['success' => false, 'message' => 'Unauthorized'], 401);
}
if (!is_post()) {
json_response(['success' => false, 'message' => 'Method not allowed'], 405);
}
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
if (!empty($input['all'])) {
$count = Notification::markAllAsRead(Auth::id());
json_response(['success' => true, 'marked' => $count]);
}
$id = isset($input['id']) ? (int)$input['id'] : 0;
if ($id > 0) {
$result = Notification::markAsRead($id, Auth::id());
json_response(['success' => $result]);
}
json_response(['success' => false, 'message' => 'No notification specified'], 422);
\ No newline at end of file
<?php
/**
* API: Mark Notifications as Read
* POST /api/notifications/mark-read
*/
define('ROOT_PATH', dirname(__DIR__, 2));
require_once ROOT_PATH . '/core/bootstrap.php';
if (!Auth::check()) {
json_response(['success' => false, 'message' => 'Unauthorized'], 401);
}
if (!is_post()) {
json_response(['success' => false, 'message' => 'Method not allowed'], 405);
}
csrf_require();
$input = all_input();
if (!empty($input['all'])) {
$count = Notification::markAllAsRead(Auth::id());
json_response(['success' => true, 'marked' => $count]);
}
$id = isset($input['id']) ? (int)$input['id'] : 0;
if ($id > 0) {
$result = Notification::markAsRead($id, Auth::id());
json_response(['success' => $result]);
}
json_response(['success' => false, 'message' => 'No notification specified'], 422);
\ No newline at end of file
<?php
/**
* API: Poll Notifications
* GET /api/notifications/poll
*/
define('ROOT_PATH', dirname(__DIR__, 2));
require_once ROOT_PATH . '/core/bootstrap.php';
if (!Auth::check()) {
json_response(['success' => false, 'message' => 'Unauthorized'], 401);
}
$notifications = Notification::getUnread(Auth::id(), 15);
$count = Notification::countUnread(Auth::id());
// Add time_ago to each notification
foreach ($notifications as &$n) {
$n['time_ago'] = time_ago($n['created_at']);
}
unset($n);
json_response([
'success' => true,
'notifications' => $notifications,
'count' => $count,
]);
\ No newline at end of file
<?php
/**
* API: Translations
* GET /api/translations/get?group=... — get translations by group
* POST /api/translations/get?action=set_locale — change locale
*/
define('ROOT_PATH', dirname(__DIR__, 2));
require_once ROOT_PATH . '/core/bootstrap.php';
if (!Auth::check()) {
json_response(['success' => false, 'message' => 'Unauthorized'], 401);
}
$action = input('action', '', 'REQUEST');
if ($action === 'set_locale' && is_post()) {
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
$locale = $input['locale'] ?? '';
if (in_array($locale, Translation::getAvailableLocales())) {
Translation::setLocale($locale);
json_response(['success' => true, 'locale' => $locale]);
}
json_response(['success' => false, 'message' => 'Invalid locale'], 422);
}
// Return translations for a group
$group = input('group', '', 'GET');
if ($group) {
$translations = Translation::getGroup($group);
json_response(['success' => true, 'data' => $translations]);
}
// Return all translations
json_response(['success' => true, 'data' => Translation::getAll()]);
\ No newline at end of file
<?php
/**
* API: Translations
* GET /api/translations/get?group=... — get translations by group
* POST /api/translations/get?action=set_locale — change locale
*/
define('ROOT_PATH', dirname(__DIR__, 2));
require_once ROOT_PATH . '/core/bootstrap.php';
if (!Auth::check()) {
json_response(['success' => false, 'message' => 'Unauthorized'], 401);
}
$action = input('action', '', 'REQUEST');
if ($action === 'set_locale' && is_post()) {
csrf_require();
$input = all_input();
$locale = $input['locale'] ?? '';
if (in_array($locale, Translation::getAvailableLocales())) {
Translation::setLocale($locale);
json_response(['success' => true, 'locale' => $locale]);
}
json_response(['success' => false, 'message' => 'Invalid locale'], 422);
}
// Return translations for a group
$group = input('group', '', 'GET');
if ($group) {
$translations = Translation::getGroup($group);
json_response(['success' => true, 'data' => $translations]);
}
// Return all translations
json_response(['success' => true, 'data' => Translation::getAll()]);
\ No newline at end of file
<?php
/**
* API: Serve Uploaded Files Securely
* GET /api/uploads/serve?path=members/photos/abc123.jpg
*
* This is the ONLY way to access uploaded files.
* Validates authentication, permission, and file existence.
*/
define('ROOT_PATH', dirname(__DIR__, 2));
require_once ROOT_PATH . '/core/bootstrap.php';
if (!Auth::check()) {
http_response_code(401);
exit('Unauthorized');
}
$relativePath = input('path', '', 'GET');
if (empty($relativePath)) {
http_response_code(400);
exit('Missing path');
}
// Sanitize path — prevent directory traversal
$relativePath = str_replace(['..', "\0"], '', $relativePath);
$relativePath = ltrim($relativePath, '/');
// Validate path doesn't escape uploads directory
$fullPath = realpath(app_config('upload.path') . '/' . $relativePath);
$uploadsBase = realpath(app_config('upload.path'));
if (!$fullPath || !$uploadsBase || !str_starts_with($fullPath, $uploadsBase)) {
http_response_code(404);
exit('File not found');
}
if (!is_file($fullPath) || !is_readable($fullPath)) {
http_response_code(404);
exit('File not found');
}
// Determine MIME type
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($fullPath);
// Whitelist safe MIME types
$allowedMimes = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
];
if (!in_array($mimeType, $allowedMimes)) {
http_response_code(403);
exit('File type not allowed');
}
// Cache headers (1 hour for images, no cache for documents)
$isImage = str_starts_with($mimeType, 'image/');
if ($isImage) {
header('Cache-Control: private, max-age=3600');
} else {
header('Cache-Control: no-store, no-cache, must-revalidate');
}
// Serve the file
header('Content-Type: ' . $mimeType);
header('Content-Length: ' . filesize($fullPath));
header('Content-Disposition: inline; filename="' . basename($fullPath) . '"');
header('X-Content-Type-Options: nosniff');
readfile($fullPath);
exit;
\ No newline at end of file
/* ============================================================
Login Page Styles
============================================================ */
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1a5276 0%, #2c3e50 50%, #1e3a52 100%);
padding: 20px;
}
.login-card {
width: 100%;
max-width: 420px;
background: #fff;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
overflow: hidden;
}
.login-header {
text-align: center;
padding: 32px 32px 20px;
background: linear-gradient(135deg, #1a5276, #2c3e50);
color: #fff;
}
.login-header-icon {
font-size: 48px;
margin-bottom: 12px;
color: #27ae60;
}
.login-header h1 {
font-size: 20px;
font-weight: 700;
margin: 0;
}
.login-header p {
font-size: 13px;
opacity: 0.8;
margin: 4px 0 0;
}
.login-body {
padding: 32px;
}
.login-body .form-control {
padding: 12px 16px;
font-size: 14px;
border-radius: 8px;
}
.login-body .form-label {
font-weight: 600;
font-size: 13px;
}
.login-body .btn-primary {
width: 100%;
padding: 12px;
font-size: 15px;
font-weight: 600;
border-radius: 8px;
background: #1a5276;
border: none;
margin-top: 8px;
}
.login-body .btn-primary:hover {
background: #154360;
}
.login-footer {
text-align: center;
padding: 0 32px 24px;
}
.login-footer a {
font-size: 13px;
color: #1a5276;
}
.login-error {
background: #fce4ec;
color: #c0392b;
padding: 10px 16px;
border-radius: 8px;
font-size: 13px;
margin-bottom: 16px;
text-align: center;
}
.login-success {
background: #e8f5e9;
color: #27ae60;
padding: 10px 16px;
border-radius: 8px;
font-size: 13px;
margin-bottom: 16px;
text-align: center;
}
@media (max-width: 480px) {
.login-card {
border-radius: 12px;
}
.login-body {
padding: 24px;
}
}
\ No newline at end of file
/* ============================================================
AL-ARCADE Club Management ERP — Main Stylesheet
============================================================ */
:root {
--sidebar-width: 260px;
--sidebar-collapsed-width: 70px;
--navbar-height: 60px;
--footer-height: 50px;
--color-primary: #1a5276;
--color-primary-light: #2471a3;
--color-primary-dark: #154360;
--color-secondary: #2c3e50;
--color-accent: #27ae60;
--color-accent-light: #2ecc71;
--color-warning: #f39c12;
--color-danger: #e74c3c;
--color-info: #3498db;
--color-sidebar-bg: #1e293b;
--color-sidebar-hover: #334155;
--color-sidebar-active: #0f172a;
--color-sidebar-text: #cbd5e1;
--color-sidebar-text-active: #ffffff;
--color-body-bg: #f1f5f9;
--color-card-bg: #ffffff;
--color-border: #e2e8f0;
--color-text: #1e293b;
--color-text-muted: #64748b;
--font-family-ar: 'Cairo', 'Segoe UI', Tahoma, sans-serif;
--font-family-en: 'Inter', 'Segoe UI', sans-serif;
--shadow-sm: 0 1px 2px rgba(0,0,0,0.05);
--shadow-md: 0 4px 6px -1px rgba(0,0,0,0.1);
--shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.1);
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--transition-fast: 150ms ease;
--transition-normal: 250ms ease;
--transition-slow: 350ms ease;
}
/* ── RESET & BASE ───────────────────────── */
*, *::before, *::after {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
font-family: var(--font-family-ar);
background-color: var(--color-body-bg);
color: var(--color-text);
font-size: 14px;
line-height: 1.6;
margin: 0;
padding: 0;
overflow-x: hidden;
}
html[lang="en"] body,
html[dir="ltr"] body {
font-family: var(--font-family-en);
}
a {
text-decoration: none;
color: var(--color-primary);
}
a:hover {
color: var(--color-primary-light);
}
/* ── APP WRAPPER ────────────────────────── */
.app-wrapper {
display: flex;
min-height: 100vh;
}
.app-main {
flex: 1;
display: flex;
flex-direction: column;
min-height: 100vh;
transition: margin var(--transition-normal);
}
[dir="rtl"] .app-main {
margin-right: var(--sidebar-width);
}
[dir="ltr"] .app-main {
margin-left: var(--sidebar-width);
}
.sidebar-collapsed .app-main {
margin-right: var(--sidebar-collapsed-width) !important;
margin-left: var(--sidebar-collapsed-width) !important;
}
/* ── SIDEBAR ────────────────────────────── */
.app-sidebar {
position: fixed;
top: 0;
height: 100vh;
width: var(--sidebar-width);
background: var(--color-sidebar-bg);
color: var(--color-sidebar-text);
z-index: 1040;
display: flex;
flex-direction: column;
transition: width var(--transition-normal);
overflow: hidden;
}
[dir="rtl"] .app-sidebar { right: 0; }
[dir="ltr"] .app-sidebar { left: 0; }
.sidebar-collapsed .app-sidebar {
width: var(--sidebar-collapsed-width);
}
/* Brand */
.sidebar-brand {
height: var(--navbar-height);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
border-bottom: 1px solid rgba(255,255,255,0.08);
flex-shrink: 0;
}
.sidebar-brand-link {
display: flex;
align-items: center;
gap: 10px;
color: #fff;
font-weight: 700;
font-size: 16px;
}
.sidebar-brand-link:hover {
color: #fff;
}
.sidebar-brand-icon {
font-size: 24px;
color: var(--color-accent);
}
.sidebar-collapsed .sidebar-brand-text {
display: none;
}
.sidebar-toggle-btn {
background: none;
border: none;
color: var(--color-sidebar-text);
font-size: 18px;
cursor: pointer;
}
/* User */
.sidebar-user {
display: flex;
align-items: center;
gap: 10px;
padding: 16px;
border-bottom: 1px solid rgba(255,255,255,0.08);
flex-shrink: 0;
}
.sidebar-user-avatar {
font-size: 32px;
color: var(--color-accent);
flex-shrink: 0;
}
.sidebar-user-info {
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-user-name {
font-weight: 600;
font-size: 13px;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-user-role {
font-size: 11px;
color: var(--color-sidebar-text);
}
.sidebar-collapsed .sidebar-user-info {
display: none;
}
/* Nav */
.sidebar-nav {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 8px 0;
}
.sidebar-nav::-webkit-scrollbar {
width: 4px;
}
.sidebar-nav::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.15);
border-radius: 2px;
}
.sidebar-menu {
list-style: none;
padding: 0;
margin: 0;
}
.sidebar-menu-link {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
color: var(--color-sidebar-text);
transition: all var(--transition-fast);
cursor: pointer;
border: none;
background: none;
width: 100%;
text-align: inherit;
font-size: 13.5px;
position: relative;
}
.sidebar-menu-link:hover {
background: var(--color-sidebar-hover);
color: var(--color-sidebar-text-active);
}
.sidebar-menu-item.active > .sidebar-menu-link {
background: var(--color-sidebar-active);
color: var(--color-sidebar-text-active);
}
.sidebar-menu-item.active > .sidebar-menu-link::before {
content: '';
position: absolute;
top: 0;
width: 3px;
height: 100%;
background: var(--color-accent);
}
[dir="rtl"] .sidebar-menu-item.active > .sidebar-menu-link::before { right: 0; }
[dir="ltr"] .sidebar-menu-item.active > .sidebar-menu-link::before { left: 0; }
.sidebar-menu-icon {
width: 20px;
text-align: center;
font-size: 15px;
flex-shrink: 0;
}
.sidebar-menu-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-collapsed .sidebar-menu-text,
.sidebar-collapsed .sidebar-menu-arrow,
.sidebar-collapsed .sidebar-badge {
display: none;
}
.sidebar-menu-arrow {
font-size: 10px;
transition: transform var(--transition-fast);
}
[dir="rtl"] .sidebar-menu-arrow {
margin-right: auto;
}
[dir="ltr"] .sidebar-menu-arrow {
margin-left: auto;
}
.sidebar-menu-link[aria-expanded="true"] .sidebar-menu-arrow {
transform: rotate(180deg);
}
.sidebar-badge {
font-size: 10px;
padding: 2px 6px;
}
/* Submenu */
.sidebar-submenu {
list-style: none;
padding: 0;
margin: 0;
background: rgba(0,0,0,0.15);
}
.sidebar-submenu-link {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 16px 8px 40px;
color: var(--color-sidebar-text);
font-size: 13px;
transition: all var(--transition-fast);
}
[dir="rtl"] .sidebar-submenu-link {
padding: 8px 40px 8px 16px;
}
.sidebar-submenu-link:hover {
color: var(--color-sidebar-text-active);
background: rgba(255,255,255,0.05);
}
.sidebar-submenu-item.active .sidebar-submenu-link {
color: var(--color-accent);
font-weight: 600;
}
.sidebar-submenu-icon {
font-size: 8px;
width: 16px;
text-align: center;
}
/* Sidebar Footer */
.sidebar-footer {
border-top: 1px solid rgba(255,255,255,0.08);
flex-shrink: 0;
}
.sidebar-footer-link {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
color: var(--color-sidebar-text);
transition: all var(--transition-fast);
}
.sidebar-footer-link:hover {
background: rgba(231,76,60,0.15);
color: var(--color-danger);
}
.sidebar-collapsed .sidebar-footer-link span {
display: none;
}
/* Sidebar Overlay (mobile) */
.sidebar-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
z-index: 1035;
display: none;
opacity: 0;
transition: opacity var(--transition-normal);
}
.sidebar-overlay.show {
display: block;
opacity: 1;
}
/* ── NAVBAR ─────────────────────────────── */
.app-navbar {
height: var(--navbar-height);
background: var(--color-card-bg);
border-bottom: 1px solid var(--color-border);
display: flex;
align-items: center;
padding: 0 20px;
position: sticky;
top: 0;
z-index: 1020;
box-shadow: var(--shadow-sm);
flex-shrink: 0;
}
.navbar-container {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.navbar-start {
display: flex;
align-items: center;
gap: 12px;
}
.navbar-end {
display: flex;
align-items: center;
gap: 8px;
}
.navbar-toggle-btn {
background: none;
border: none;
font-size: 18px;
color: var(--color-text-muted);
cursor: pointer;
padding: 8px;
border-radius: var(--radius-sm);
transition: all var(--transition-fast);
}
.navbar-toggle-btn:hover {
background: var(--color-body-bg);
color: var(--color-text);
}
.navbar-icon-btn {
display: flex;
align-items: center;
gap: 4px;
background: none;
border: none;
font-size: 16px;
color: var(--color-text-muted);
cursor: pointer;
padding: 8px;
border-radius: var(--radius-sm);
transition: all var(--transition-fast);
}
.navbar-icon-btn:hover {
background: var(--color-body-bg);
color: var(--color-primary);
}
.navbar-icon-label {
font-size: 12px;
font-weight: 700;
}
.navbar-user-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: var(--radius-md);
color: var(--color-text);
transition: all var(--transition-fast);
}
.navbar-user-btn:hover {
background: var(--color-body-bg);
color: var(--color-text);
}
.navbar-user-avatar {
font-size: 28px;
color: var(--color-primary);
}
.navbar-user-name {
font-size: 13px;
font-weight: 500;
max-width: 140px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── NOTIFICATION DROPDOWN ──────────────── */
.notification-dropdown {
width: 360px;
max-height: 480px;
padding: 0;
border: 1px solid var(--color-border);
box-shadow: var(--shadow-lg);
}
.notification-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--color-border);
}
.notification-body {
max-height: 360px;
overflow-y: auto;
}
.notification-item {
display: flex;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid var(--color-border);
transition: background var(--transition-fast);
cursor: pointer;
}
.notification-item:hover {
background: var(--color-body-bg);
}
.notification-item.unread {
background: #f0f9ff;
}
.notification-item-icon {
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
background: var(--color-body-bg);
color: var(--color-primary);
}
.notification-item-content {
flex: 1;
min-width: 0;
}
.notification-item-title {
font-size: 13px;
font-weight: 500;
margin-bottom: 2px;
}
.notification-item-time {
font-size: 11px;
color: var(--color-text-muted);
}
.notification-footer {
text-align: center;
padding: 10px;
border-top: 1px solid var(--color-border);
}
.notification-footer a {
font-size: 13px;
font-weight: 500;
}
/* ── CONTENT AREA ───────────────────────── */
.app-content {
flex: 1;
padding: 20px;
}
/* ── BREADCRUMB ─────────────────────────── */
.app-breadcrumb {
margin-bottom: 16px;
}
.app-breadcrumb .breadcrumb {
background: none;
padding: 0;
margin: 0;
font-size: 13px;
}
/* ── FOOTER ─────────────────────────────── */
.app-footer {
height: var(--footer-height);
display: flex;
align-items: center;
padding: 0 20px;
border-top: 1px solid var(--color-border);
background: var(--color-card-bg);
font-size: 12px;
color: var(--color-text-muted);
flex-shrink: 0;
}
/* ── CARDS ──────────────────────────────── */
.card {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
background: var(--color-card-bg);
margin-bottom: 20px;
}
.card-header {
font-weight: 600;
font-size: 15px;
background: transparent;
border-bottom: 1px solid var(--color-border);
padding: 14px 20px;
}
.card-body {
padding: 20px;
}
/* ── STAT WIDGETS ───────────────────────── */
.stat-card {
border-radius: var(--radius-md);
padding: 20px;
color: #fff;
position: relative;
overflow: hidden;
margin-bottom: 20px;
}
.stat-card-icon {
position: absolute;
top: 12px;
font-size: 48px;
opacity: 0.2;
}
[dir="rtl"] .stat-card-icon { left: 16px; }
[dir="ltr"] .stat-card-icon { right: 16px; }
.stat-card-value {
font-size: 28px;
font-weight: 700;
line-height: 1.2;
}
.stat-card-label {
font-size: 13px;
opacity: 0.85;
margin-top: 4px;
}
.stat-card.bg-primary-gradient {
background: linear-gradient(135deg, var(--color-primary), var(--color-primary-light));
}
.stat-card.bg-success-gradient {
background: linear-gradient(135deg, #1e8449, var(--color-accent));
}
.stat-card.bg-warning-gradient {
background: linear-gradient(135deg, #d68910, var(--color-warning));
}
.stat-card.bg-danger-gradient {
background: linear-gradient(135deg, #c0392b, var(--color-danger));
}
.stat-card.bg-info-gradient {
background: linear-gradient(135deg, #2471a3, var(--color-info));
}
/* ── TABLES ─────────────────────────────── */
.table-responsive {
border-radius: var(--radius-md);
}
.table {
margin-bottom: 0;
}
.table thead th {
background: var(--color-body-bg);
font-weight: 600;
font-size: 13px;
color: var(--color-text-muted);
border-bottom: 2px solid var(--color-border);
white-space: nowrap;
padding: 10px 12px;
}
.table tbody td {
padding: 10px 12px;
vertical-align: middle;
font-size: 13.5px;
}
.table tbody tr:hover {
background: #f8fafc;
}
/* ── FORMS ──────────────────────────────── */
.form-label {
font-weight: 500;
font-size: 13px;
margin-bottom: 4px;
color: var(--color-text);
}
.form-label .required {
color: var(--color-danger);
}
.form-control,
.form-select {
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 14px;
padding: 8px 12px;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.form-control:focus,
.form-select:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(26,82,118,0.15);
}
.form-control.is-invalid {
border-color: var(--color-danger);
}
.invalid-feedback {
font-size: 12px;
}
/* ── BUTTONS ────────────────────────────── */
.btn {
font-size: 14px;
font-weight: 500;
padding: 8px 18px;
border-radius: var(--radius-sm);
transition: all var(--transition-fast);
}
.btn-primary {
background: var(--color-primary);
border-color: var(--color-primary);
}
.btn-primary:hover {
background: var(--color-primary-dark);
border-color: var(--color-primary-dark);
}
.btn-success {
background: var(--color-accent);
border-color: var(--color-accent);
}
.btn-sm {
font-size: 12px;
padding: 5px 12px;
}
.btn-icon {
display: inline-flex;
align-items: center;
gap: 6px;
}
/* ── BADGES ─────────────────────────────── */
.badge {
font-weight: 500;
font-size: 11px;
padding: 4px 8px;
border-radius: var(--radius-sm);
}
/* ── PAGE HEADER ────────────────────────── */
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 12px;
}
.page-header-title {
font-size: 22px;
font-weight: 700;
margin: 0;
color: var(--color-text);
}
.page-header-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
/* ── EMPTY STATE ────────────────────────── */
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--color-text-muted);
}
.empty-state-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.4;
}
.empty-state-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 8px;
}
/* ── RESPONSIVE ─────────────────────────── */
@media (max-width: 991.98px) {
.app-sidebar {
transform: translateX(100%);
width: var(--sidebar-width) !important;
}
[dir="ltr"] .app-sidebar {
transform: translateX(-100%);
}
.app-sidebar.mobile-open {
transform: translateX(0) !important;
}
.app-main {
margin-right: 0 !important;
margin-left: 0 !important;
}
.notification-dropdown {
width: 300px;
}
}
@media (max-width: 575.98px) {
.app-content {
padding: 12px;
}
.page-header {
flex-direction: column;
align-items: flex-start;
}
}
\ No newline at end of file
/* ============================================================
Print Stylesheet — hides all chrome, shows only content
============================================================ */
@media print {
.app-sidebar,
.app-navbar,
.app-footer,
.app-breadcrumb,
.sidebar-overlay,
.page-header-actions,
.btn,
.alert,
.notification-dropdown,
.dataTables_filter,
.dataTables_length,
.dataTables_paginate,
.dataTables_info,
.no-print {
display: none !important;
}
.app-main {
margin: 0 !important;
}
.app-content {
padding: 0 !important;
}
body {
background: #fff !important;
color: #000 !important;
font-size: 12pt;
}
.card {
border: none !important;
box-shadow: none !important;
}
.table {
border: 1px solid #000;
}
.table th,
.table td {
border: 1px solid #333 !important;
padding: 4px 6px;
}
/* Receipt print styling */
.receipt-print {
width: 80mm;
margin: 0 auto;
font-family: 'Courier New', monospace;
font-size: 10pt;
}
.receipt-print .receipt-header {
text-align: center;
border-bottom: 1px dashed #000;
padding-bottom: 8px;
margin-bottom: 8px;
}
.receipt-print .receipt-line {
display: flex;
justify-content: space-between;
margin: 4px 0;
}
.receipt-print .receipt-total {
border-top: 1px dashed #000;
padding-top: 8px;
font-weight: bold;
font-size: 12pt;
}
.receipt-print .receipt-footer {
text-align: center;
margin-top: 12px;
border-top: 1px dashed #000;
padding-top: 8px;
font-size: 9pt;
}
a[href]::after {
content: none !important;
}
}
\ No newline at end of file
/* ============================================================
RTL-specific overrides
============================================================ */
[dir="rtl"] .breadcrumb-item + .breadcrumb-item::before {
content: "\\";
}
[dir="rtl"] .sidebar-submenu-link {
padding-right: 44px;
padding-left: 16px;
}
[dir="rtl"] .dropdown-menu-end {
--bs-position: end;
}
[dir="rtl"] .me-2 { margin-right: 0 !important; margin-left: 0.5rem !important; }
[dir="rtl"] .ms-1 { margin-left: 0 !important; margin-right: 0.25rem !important; }
[dir="rtl"] .ms-2 { margin-left: 0 !important; margin-right: 0.5rem !important; }
[dir="rtl"] .text-start { text-align: right !important; }
[dir="rtl"] .text-end { text-align: left !important; }
[dir="rtl"] .float-start { float: right !important; }
[dir="rtl"] .float-end { float: left !important; }
[dir="rtl"] .btn-close {
margin-left: 0;
margin-right: auto;
}
[dir="rtl"] .form-check {
padding-right: 1.5em;
padding-left: 0;
}
[dir="rtl"] .form-check-input {
float: right;
margin-right: -1.5em;
margin-left: 0;
}
[dir="rtl"] table.dataTable thead .sorting::after,
[dir="rtl"] table.dataTable thead .sorting_asc::after,
[dir="rtl"] table.dataTable thead .sorting_desc::after {
left: 8px;
right: auto;
}
\ No newline at end of file
/**
* AL-ARCADE Club Management ERP — Main Application JS
*/
(function() {
'use strict';
const App = {
init() {
this.initSidebar();
this.initLanguageToggle();
this.initTooltips();
this.initConfirmDialogs();
this.initAutoHideAlerts();
NotificationManager.init();
},
// ── SIDEBAR ────────────────────────────
initSidebar() {
const toggleBtn = document.getElementById('sidebarToggleBtn');
const closeBtn = document.getElementById('sidebarCloseBtn');
const sidebar = document.getElementById('appSidebar');
const overlay = document.getElementById('sidebarOverlay');
const body = document.body;
if (toggleBtn) {
toggleBtn.addEventListener('click', () => {
if (window.innerWidth < 992) {
sidebar.classList.toggle('mobile-open');
overlay.classList.toggle('show');
} else {
body.classList.toggle('sidebar-collapsed');
// Persist preference
fetch('/api/config/get.php?action=toggle_sidebar', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': AppConfig.csrfToken,
'X-Requested-With': 'XMLHttpRequest'
}
}).catch(() => {});
}
});
}
if (closeBtn) {
closeBtn.addEventListener('click', () => {
sidebar.classList.remove('mobile-open');
overlay.classList.remove('show');
});
}
if (overlay) {
overlay.addEventListener('click', () => {
sidebar.classList.remove('mobile-open');
overlay.classList.remove('show');
});
}
},
// ── LANGUAGE TOGGLE ────────────────────
initLanguageToggle() {
const btn = document.getElementById('langToggleBtn');
if (!btn) return;
btn.addEventListener('click', (e) => {
e.preventDefault();
const current = btn.dataset.current;
const newLang = current === 'ar' ? 'en' : 'ar';
fetch('/api/translations/get.php?action=set_locale', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': AppConfig.csrfToken,
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({ locale: newLang })
}).then(() => {
window.location.reload();
}).catch(() => {
window.location.reload();
});
});
},
// ── TOOLTIPS ───────────────────────────
initTooltips() {
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
tooltipTriggerList.forEach(el => new bootstrap.Tooltip(el));
},
// ── CONFIRM DIALOGS ────────────────────
initConfirmDialogs() {
document.addEventListener('click', (e) => {
const target = e.target.closest('[data-confirm]');
if (!target) return;
e.preventDefault();
const message = target.dataset.confirm;
const href = target.getAttribute('href') || target.dataset.href;
Swal.fire({
title: AppConfig.isRtl ? 'تأكيد' : 'Confirm',
text: message,
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#1a5276',
cancelButtonColor: '#6c757d',
confirmButtonText: AppConfig.isRtl ? 'نعم' : 'Yes',
cancelButtonText: AppConfig.isRtl ? 'إلغاء' : 'Cancel',
}).then((result) => {
if (result.isConfirmed && href) {
window.location.href = href;
}
});
});
},
// ── AUTO-HIDE ALERTS ───────────────────
initAutoHideAlerts() {
document.querySelectorAll('.alert-dismissible').forEach(alert => {
setTimeout(() => {
const bsAlert = bootstrap.Alert.getOrCreateInstance(alert);
bsAlert.close();
}, 5000);
});
}
};
// ── TOAST UTILITY ──────────────────────
window.Toast = {
show(message, type = 'info') {
const icons = { success: 'check-circle', error: 'times-circle', warning: 'exclamation-triangle', info: 'info-circle' };
const colors = { success: '#27ae60', error: '#e74c3c', warning: '#f39c12', info: '#3498db' };
Swal.fire({
toast: true,
position: AppConfig.isRtl ? 'top-start' : 'top-end',
icon: type === 'error' ? 'error' : type,
title: message,
showConfirmButton: false,
timer: 3000,
timerProgressBar: true,
});
},
success(msg) { this.show(msg, 'success'); },
error(msg) { this.show(msg, 'error'); },
warning(msg) { this.show(msg, 'warning'); },
info(msg) { this.show(msg, 'info'); },
};
// Init on DOM ready
document.addEventListener('DOMContentLoaded', () => App.init());
window.App = App;
})();
\ No newline at end of file
/**
* DataTables default configuration.
*/
(function() {
'use strict';
const isRtl = document.documentElement.dir === 'rtl';
// Arabic language for DataTables
const arLanguage = {
"processing": "جاري المعالجة...",
"lengthMenu": "عرض _MENU_ سجلات",
"zeroRecords": "لم يتم العثور على نتائج",
"emptyTable": "لا توجد بيانات",
"info": "عرض _START_ إلى _END_ من _TOTAL_ سجل",
"infoEmpty": "عرض 0 إلى 0 من 0 سجل",
"infoFiltered": "(تصفية من _MAX_ سجل)",
"search": "بحث:",
"paginate": {
"first": "الأول",
"last": "الأخير",
"next": "التالي",
"previous": "السابق"
}
};
const enLanguage = {
"processing": "Processing...",
"lengthMenu": "Show _MENU_ entries",
"zeroRecords": "No matching records found",
"emptyTable": "No data available",
"info": "Showing _START_ to _END_ of _TOTAL_ entries",
"infoEmpty": "Showing 0 to 0 of 0 entries",
"infoFiltered": "(filtered from _MAX_ total entries)",
"search": "Search:",
"paginate": {
"first": "First",
"last": "Last",
"next": "Next",
"previous": "Previous"
}
};
// Set DataTables defaults
if ($.fn.dataTable) {
$.extend(true, $.fn.dataTable.defaults, {
language: isRtl ? arLanguage : enLanguage,
pageLength: 25,
lengthMenu: [[10, 25, 50, 100], [10, 25, 50, 100]],
processing: true,
responsive: true,
stateSave: true,
stateDuration: 3600,
dom: '<"row"<"col-sm-12 col-md-6"l><"col-sm-12 col-md-6"f>>rtip',
order: [],
drawCallback: function() {
// Re-init tooltips after draw
this.api().columns.adjust();
}
});
}
/**
* Initialize a DataTable with standard config.
* Usage: AppDT.init('#myTable', { ...overrides })
*/
window.AppDT = {
init(selector, options = {}) {
const $table = $(selector);
if (!$table.length) return null;
const defaults = {
autoWidth: false,
columnDefs: [
{ targets: 'no-sort', orderable: false },
{ targets: 'text-center', className: 'text-center' }
]
};
return $table.DataTable({ ...defaults, ...options });
}
};
})();
\ No newline at end of file
/**
* Notification Polling & Display Manager.
*/
const NotificationManager = {
pollInterval: null,
pollDelay: 30000, // 30 seconds
init() {
this.loadNotifications();
this.startPolling();
this.bindEvents();
},
async loadNotifications() {
try {
const data = await Utils.ajax('/api/notifications/poll.php');
if (data.success) {
this.renderNotifications(data.notifications || []);
this.updateBadge(data.count || 0);
}
} catch (e) {
// Silently fail — notification polling shouldn't break the app
}
},
renderNotifications(notifications) {
const container = document.getElementById('notifList');
if (!container) return;
if (notifications.length === 0) {
const isRtl = window.AppConfig?.isRtl;
container.innerHTML = `
<div class="text-center py-3 text-muted">
<i class="fas fa-bell-slash"></i>
<p class="mb-0 small">${isRtl ? 'لا توجد إشعارات' : 'No notifications'}</p>
</div>`;
return;
}
const isRtl = window.AppConfig?.isRtl;
container.innerHTML = notifications.map(n => {
const title = isRtl ? (n.title_ar || n.title_en || '') : (n.title_en || n.title_ar || '');
const body = isRtl ? (n.body_ar || n.body_en || '') : (n.body_en || n.body_ar || '');
const timeAgo = n.time_ago || '';
return `
<div class="notification-item ${n.read_at ? '' : 'unread'}" data-id="${n.id}">
<div class="notification-item-icon">
<i class="fas fa-bell"></i>
</div>
<div class="notification-item-content">
<div class="notification-item-title">${this.escapeHtml(title)}</div>
<div class="small text-muted">${this.escapeHtml(body).substring(0, 80)}</div>
<div class="notification-item-time">${this.escapeHtml(timeAgo)}</div>
</div>
</div>`;
}).join('');
},
updateBadge(count) {
const badge = document.getElementById('notifBadge');
if (count > 0) {
if (badge) {
badge.textContent = count > 99 ? '99+' : count;
badge.style.display = '';
} else {
const btn = document.getElementById('notifDropdownBtn');
if (btn) {
const span = document.createElement('span');
span.id = 'notifBadge';
span.className = 'position-absolute top-0 start-100 translate-middle badge rounded-pill bg-danger notif-badge';
span.textContent = count > 99 ? '99+' : count;
btn.appendChild(span);
}
}
} else if (badge) {
badge.style.display = 'none';
}
},
startPolling() {
this.pollInterval = setInterval(() => this.loadNotifications(), this.pollDelay);
},
stopPolling() {
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
},
bindEvents() {
// Mark all as read
const markAllBtn = document.getElementById('markAllReadBtn');
if (markAllBtn) {
markAllBtn.addEventListener('click', async (e) => {
e.preventDefault();
try {
await Utils.post('/api/notifications/mark-read.php', { all: true });
this.loadNotifications();
} catch (err) {
// Ignore
}
});
}
// Mark individual as read
document.addEventListener('click', async (e) => {
const item = e.target.closest('.notification-item[data-id]');
if (!item) return;
const id = item.dataset.id;
try {
await Utils.post('/api/notifications/mark-read.php', { id: parseInt(id) });
item.classList.remove('unread');
this.loadNotifications();
} catch (err) {
// Ignore
}
});
},
escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
};
window.NotificationManager = NotificationManager;
\ No newline at end of file
/**
* Utility functions for the application.
*/
const Utils = {
/**
* Make an AJAX request with CSRF token.
*/
async ajax(url, options = {}) {
const defaults = {
method: 'GET',
headers: {
'X-CSRF-TOKEN': window.AppConfig?.csrfToken || '',
'X-Requested-With': 'XMLHttpRequest',
},
};
if (options.body && typeof options.body === 'object' && !(options.body instanceof FormData)) {
defaults.headers['Content-Type'] = 'application/json';
options.body = JSON.stringify(options.body);
}
const config = { ...defaults, ...options, headers: { ...defaults.headers, ...(options.headers || {}) } };
const response = await fetch(url, config);
const data = await response.json();
if (!response.ok) {
throw { status: response.status, data };
}
return data;
},
/**
* POST helper.
*/
async post(url, body = {}) {
return this.ajax(url, { method: 'POST', body });
},
/**
* Format money.
*/
formatMoney(amount) {
const cfg = window.AppConfig?.currency || {};
const num = parseFloat(amount) || 0;
const formatted = num.toFixed(cfg.decimals || 2)
.replace(/\B(?=(\d{3})+(?!\d))/g, cfg.thousands_sep || ',');
const symbol = window.AppConfig?.isRtl ? (cfg.symbol || 'ج.م') : (cfg.symbol_en || 'LE');
return formatted + ' ' + symbol;
},
/**
* Format date from ISO string.
*/
formatDate(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr);
const day = String(d.getDate()).padStart(2, '0');
const month = String(d.getMonth() + 1).padStart(2, '0');
const year = d.getFullYear();
return `${day}/${month}/${year}`;
},
/**
* Debounce function.
*/
debounce(func, wait = 300) {
let timeout;
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
},
/**
* Copy text to clipboard.
*/
async copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
Toast.success(window.AppConfig?.isRtl ? 'تم النسخ' : 'Copied!');
} catch {
Toast.error(window.AppConfig?.isRtl ? 'فشل النسخ' : 'Copy failed');
}
},
/**
* Print a specific element.
*/
printElement(selector) {
const content = document.querySelector(selector);
if (!content) return;
const win = window.open('', '_blank');
win.document.write(`
<html dir="${window.AppConfig?.isRtl ? 'rtl' : 'ltr'}">
<head><title>Print</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap${window.AppConfig?.isRtl ? '.rtl' : ''}.min.css" rel="stylesheet">
<link href="${window.AppConfig?.baseUrl}/assets/css/print.css" rel="stylesheet">
<style>body{padding:20px;font-family:'Cairo',sans-serif;}</style>
</head><body>${content.innerHTML}</body></html>
`);
win.document.close();
win.onload = () => { win.print(); win.close(); };
},
/**
* Validate Egyptian National ID on client side.
*/
validateNationalId(id) {
id = id.trim();
if (!/^[23]\d{13}$/.test(id)) return { valid: false, error: 'Invalid format' };
const century = id[0] === '2' ? 1900 : 2000;
const year = century + parseInt(id.substring(1, 3));
const month = parseInt(id.substring(3, 5));
const day = parseInt(id.substring(5, 7));
const testDate = new Date(year, month - 1, day);
if (testDate.getFullYear() !== year || testDate.getMonth() !== month - 1 || testDate.getDate() !== day) {
return { valid: false, error: 'Invalid date in ID' };
}
const gender = parseInt(id[12]) % 2 === 1 ? 'male' : 'female';
const dob = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
return { valid: true, dob, gender, govCode: id.substring(7, 9) };
},
/**
* Validate Egyptian mobile number.
*/
validateMobile(phone) {
return /^(?:\+?20)?0?1[0125]\d{8}$/.test(phone.replace(/[\s-]/g, ''));
},
/**
* Show loading overlay on a container.
*/
showLoading(selector) {
const el = typeof selector === 'string' ? document.querySelector(selector) : selector;
if (!el) return;
el.style.position = 'relative';
const overlay = document.createElement('div');
overlay.className = 'loading-overlay';
overlay.innerHTML = '<div class="spinner-border text-primary" role="status"><span class="visually-hidden">Loading...</span></div>';
overlay.style.cssText = 'position:absolute;inset:0;background:rgba(255,255,255,0.7);display:flex;align-items:center;justify-content:center;z-index:10;border-radius:inherit;';
el.appendChild(overlay);
},
/**
* Remove loading overlay.
*/
hideLoading(selector) {
const el = typeof selector === 'string' ? document.querySelector(selector) : selector;
if (!el) return;
const overlay = el.querySelector('.loading-overlay');
if (overlay) overlay.remove();
},
/**
* Serialize form to object.
*/
formToObject(formElement) {
const formData = new FormData(formElement);
const obj = {};
for (const [key, value] of formData.entries()) {
if (obj[key]) {
if (!Array.isArray(obj[key])) obj[key] = [obj[key]];
obj[key].push(value);
} else {
obj[key] = value;
}
}
return obj;
}
};
window.Utils = Utils;
\ No newline at end of file
<#
.SYNOPSIS
Codebase Collector — Single-file PowerShell + embedded Python.
Generates one massive text file containing your entire project's codebase.
.EXAMPLE
.\collect_codebase.ps1
.\collect_codebase.ps1 -ProjectPath "C:\xampp\htdocs\my-app"
.\collect_codebase.ps1 -ProjectPath "." -OutputFile "C:\dump.txt" -MaxFileSize 1024
#>
param(
[Parameter(Position = 0)]
[string]$ProjectPath = ".",
[Parameter()]
[string]$OutputFile = "",
[Parameter()]
[string]$ProjectName = "",
[Parameter()]
[int]$MaxFileSize = 512
)
$ErrorActionPreference = "Stop"
# ── Resolve project path ──
$ResolvedProject = (Resolve-Path -Path $ProjectPath -ErrorAction Stop).Path
if (-not (Test-Path $ResolvedProject -PathType Container)) {
Write-Error "Not a valid directory: $ResolvedProject"
exit 1
}
$DirName = Split-Path $ResolvedProject -Leaf
$FinalProjectName = if ($ProjectName) { $ProjectName } else { $DirName }
if (-not $OutputFile) {
$Desktop = [Environment]::GetFolderPath("Desktop")
$Timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
$OutputFile = Join-Path $Desktop "${FinalProjectName}_codebase_${Timestamp}.txt"
}
# ── Find Python 3 ──
Write-Host ""
Write-Host "================================================================" -ForegroundColor Cyan
Write-Host " CODEBASE COLLECTOR" -ForegroundColor Yellow
Write-Host "================================================================" -ForegroundColor Cyan
Write-Host ""
$PythonCmd = $null
foreach ($candidate in @("python", "python3", "py")) {
try {
$ver = & $candidate --version 2>&1
if ($ver -match "Python 3") {
$PythonCmd = $candidate
Write-Host " [OK] $ver ($candidate)" -ForegroundColor Green
break
}
}
catch { continue }
}
if (-not $PythonCmd) {
Write-Host " [FATAL] Python 3 not found. Install it and add to PATH." -ForegroundColor Red
exit 1
}
Write-Host " Project : $ResolvedProject" -ForegroundColor White
Write-Host " Output : $OutputFile" -ForegroundColor White
Write-Host " Max Size: ${MaxFileSize} KB per file" -ForegroundColor White
Write-Host ""
Write-Host "----------------------------------------------------------------" -ForegroundColor DarkGray
# ══════════════════════════════════════════════════════════════════════════════
# EMBEDDED PYTHON SCRIPT
# ══════════════════════════════════════════════════════════════════════════════
$PythonCode = @'
import os, sys, datetime, argparse
from pathlib import Path
EXCLUDED_DIRS = {
'.git', '.svn', '.hg', 'node_modules', 'vendor', 'bower_components',
'__pycache__', '.idea', '.vscode', '.cache', '.tmp', 'tmp', 'temp',
'storage/framework', 'storage/logs', 'dist', 'build', '.next', '.nuxt',
'.terraform', '.vagrant', '.sass-cache',
}
EXCLUDED_FILES = {
'.DS_Store', 'Thumbs.db', 'desktop.ini',
'.env', '.env.local', '.env.production', '.env.staging',
'package-lock.json', 'composer.lock', 'yarn.lock', 'pnpm-lock.yaml',
}
EXCLUDED_EXTENSIONS = {
'.exe', '.dll', '.so', '.dylib', '.bin', '.o', '.obj',
'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.ico', '.svg', '.webp', '.avif',
'.mp3', '.mp4', '.avi', '.mov', '.wav', '.flac', '.ogg', '.webm',
'.zip', '.tar', '.gz', '.rar', '.7z', '.bz2',
'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
'.ttf', '.otf', '.woff', '.woff2', '.eot',
'.pyc', '.pyo', '.class', '.jar',
'.sqlite', '.db', '.mdb',
'.map', '.min.js', '.min.css',
}
INCLUDE_EXTENSIONS = {
'.php', '.html', '.htm', '.css', '.scss', '.sass', '.less',
'.js', '.jsx', '.ts', '.tsx', '.vue', '.svelte',
'.json', '.xml', '.yaml', '.yml', '.toml', '.ini', '.cfg',
'.sql', '.graphql', '.gql',
'.py', '.rb', '.go', '.rs', '.java', '.kt', '.cs', '.c', '.cpp', '.h',
'.sh', '.bash', '.zsh', '.ps1', '.bat', '.cmd',
'.md', '.txt', '.rst', '.log', '.csv',
'.env.example', '.gitignore', '.htaccess', '.editorconfig',
'.twig', '.blade.php', '.phtml',
'.conf', '.nginx', '.apache',
'.dockerfile', '.dockerignore',
}
SEPARATOR = "=" * 100
THIN_SEP = "-" * 100
HDR = "#"
EXT_LANG = {
'.php': 'php', '.html': 'html', '.htm': 'html',
'.css': 'css', '.scss': 'scss', '.sass': 'sass', '.less': 'less',
'.js': 'javascript', '.jsx': 'jsx', '.ts': 'typescript', '.tsx': 'tsx',
'.json': 'json', '.xml': 'xml', '.yaml': 'yaml', '.yml': 'yaml',
'.sql': 'sql', '.graphql': 'graphql',
'.py': 'python', '.rb': 'ruby', '.go': 'go', '.rs': 'rust',
'.java': 'java', '.kt': 'kotlin', '.cs': 'csharp',
'.c': 'c', '.cpp': 'cpp', '.h': 'c',
'.sh': 'bash', '.bash': 'bash', '.zsh': 'zsh',
'.ps1': 'powershell', '.bat': 'batch', '.cmd': 'batch',
'.md': 'markdown', '.txt': 'text', '.ini': 'ini',
'.toml': 'toml', '.cfg': 'ini', '.conf': 'nginx',
'.vue': 'vue', '.svelte': 'svelte', '.twig': 'twig',
}
def should_exclude_dir(name, rel):
if name in EXCLUDED_DIRS:
return True
if rel.replace("\\", "/") in EXCLUDED_DIRS:
return True
return False
def should_include_file(name, ext):
if name in EXCLUDED_FILES:
return False
if ext.lower() in EXCLUDED_EXTENSIONS:
return False
if INCLUDE_EXTENSIONS:
for compound in ('.blade.php', '.env.example'):
if name.endswith(compound):
return True
if ext.lower() in INCLUDE_EXTENSIONS:
return True
if name in {'.gitignore', '.htaccess', '.editorconfig', '.dockerignore',
'Dockerfile', 'Makefile', 'Vagrantfile', 'Procfile', 'Caddyfile'}:
return True
return False
return True
def collect_files(root, max_size_kb):
files = []
root_str = str(root)
for dirpath, dirnames, filenames in os.walk(root):
rel_dir = os.path.relpath(dirpath, root_str)
dirnames[:] = [
d for d in sorted(dirnames)
if not should_exclude_dir(d, os.path.join(rel_dir, d) if rel_dir != '.' else d)
]
for fname in sorted(filenames):
_, ext = os.path.splitext(fname)
if not should_include_file(fname, ext):
continue
full = Path(dirpath) / fname
try:
size_kb = full.stat().st_size / 1024
if size_kb > max_size_kb:
continue
except OSError:
continue
files.append({
'full': full,
'rel': full.relative_to(root),
'size': size_kb,
'ext': ext,
})
return files
def build_tree(files, root_name):
tree = {}
for f in files:
parts = f['rel'].parts
cur = tree
for p in parts:
if p not in cur:
cur[p] = {}
cur = cur[p]
lines = [f"[DIR] {root_name}/"]
_render(tree, lines, "")
return "\n".join(lines)
def _render(node, lines, prefix):
entries = list(node.items())
for i, (name, children) in enumerate(entries):
last = i == len(entries) - 1
conn = " \\-- " if last else " |-- "
if children:
lines.append(f"{prefix}{conn}[DIR] {name}/")
ext = " " if last else " | "
_render(children, lines, prefix + ext)
else:
lines.append(f"{prefix}{conn}{name}")
def read_safe(path):
for enc in ('utf-8', 'utf-8-sig', 'latin-1', 'cp1252'):
try:
with open(path, 'r', encoding=enc, errors='strict') as f:
content = f.read()
if '\x00' in content:
return None
return content
except (UnicodeDecodeError, UnicodeError):
continue
except (IOError, OSError):
return None
return None
def generate(root, output, name, max_size_kb):
if not root.exists() or not root.is_dir():
print(f"[ERROR] Invalid directory: {root}")
sys.exit(1)
abs_root = root.resolve()
print(f"[*] Scanning: {abs_root}")
files = collect_files(root, max_size_kb)
print(f"[*] Found {len(files)} files")
if not files:
print("[!] No files found. Check path / exclusion rules.")
sys.exit(1)
ts = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
total_kb = sum(f['size'] for f in files)
ext_counts = {}
for f in files:
e = f['ext'] if f['ext'] else '(none)'
ext_counts[e] = ext_counts.get(e, 0) + 1
with open(output, 'w', encoding='utf-8') as out:
# Header
out.write(f"{SEPARATOR}\n")
out.write(f" CODEBASE DUMP: {name}\n")
out.write(f" Generated : {ts}\n")
out.write(f" Root Path : {abs_root}\n")
out.write(f" Total Files : {len(files)}\n")
out.write(f" Total Size : {total_kb:.1f} KB\n")
out.write(f"{SEPARATOR}\n\n")
# Extension breakdown
out.write(f"{THIN_SEP}\n")
out.write(" FILE TYPE BREAKDOWN\n")
out.write(f"{THIN_SEP}\n")
for e, c in sorted(ext_counts.items(), key=lambda x: -x[1]):
out.write(f" {e:<20s} {c:>5d} file(s)\n")
out.write(f"{THIN_SEP}\n\n")
# Directory tree
out.write(f"{SEPARATOR}\n")
out.write(" DIRECTORY TREE / FILE MAP\n")
out.write(f"{SEPARATOR}\n\n")
out.write(build_tree(files, name))
out.write(f"\n\n{SEPARATOR}\n")
out.write(" END OF FILE MAP\n")
out.write(f"{SEPARATOR}\n\n\n")
# File manifest
out.write(f"{SEPARATOR}\n")
out.write(" FILE MANIFEST\n")
out.write(f"{SEPARATOR}\n\n")
for i, f in enumerate(files, 1):
out.write(f" [{i:04d}] ./{f['rel']} ({f['size']:.1f} KB)\n")
out.write(f"\n{SEPARATOR}\n\n\n")
# Code dump
out.write(f"{SEPARATOR}\n")
out.write(" BEGIN CODE DUMP\n")
out.write(f"{SEPARATOR}\n\n")
ok = 0
skip = 0
for i, f in enumerate(files, 1):
rel = f['rel']
full = f['full'].resolve()
ext = f['ext'] if f['ext'] else '(none)'
content = read_safe(f['full'])
if content is None:
skip += 1
out.write(f"\n{'#' * 100}\n")
out.write(f" [{i:04d}] SKIPPED (binary/unreadable): ./{rel}\n")
out.write(f"{'#' * 100}\n\n")
continue
ok += 1
line_count = content.count('\n') + (1 if content else 0)
lang = EXT_LANG.get(ext.lower(), 'text')
out.write(f"\n{'=' * 100}\n")
out.write(f"{HDR} FILE [{i:04d}/{len(files):04d}]\n")
out.write(f"{HDR}\n")
out.write(f"{HDR} File Path : ./{rel}\n")
out.write(f"{HDR} Extension : {ext}\n")
out.write(f"{HDR} Size : {f['size']:.1f} KB\n")
out.write(f"{HDR} Lines : {line_count}\n")
out.write(f"{'=' * 100}\n\n")
out.write(f"```{lang}\n")
out.write(content)
if content and not content.endswith('\n'):
out.write('\n')
out.write("```\n")
out.write(f"\n{THIN_SEP}\n")
# Footer
out.write(f"\n\n{SEPARATOR}\n")
out.write(f" END OF CODEBASE DUMP\n")
out.write(f" Files Dumped : {ok}\n")
out.write(f" Files Skipped : {skip}\n")
out.write(f" Generated : {ts}\n")
out.write(f"{SEPARATOR}\n")
print(f"[+] Done! {output}")
print(f" {ok} dumped, {skip} skipped")
mb = output.stat().st_size / (1024 * 1024)
print(f" Output size: {mb:.2f} MB")
def main():
p = argparse.ArgumentParser()
p.add_argument("project_path")
p.add_argument("-o", "--output", default=None)
p.add_argument("-n", "--name", default=None)
p.add_argument("--max-size", type=int, default=512)
args = p.parse_args()
root = Path(args.project_path).resolve()
nm = args.name or root.name
out = Path(args.output) if args.output else Path.cwd() / f"{nm}_codebase.txt"
generate(root, out, nm, args.max_size)
if __name__ == "__main__":
main()
'@
# ══════════════════════════════════════════════════════════════════════════════
# WRITE TEMP PYTHON FILE, EXECUTE, CLEANUP
# ══════════════════════════════════════════════════════════════════════════════
$TempPy = Join-Path $env:TEMP "codebase_collector_$([guid]::NewGuid().ToString('N').Substring(0,8)).py"
try {
[System.IO.File]::WriteAllText($TempPy, $PythonCode, [System.Text.Encoding]::UTF8)
$StartTime = Get-Date
$process = Start-Process -FilePath $PythonCmd `
-ArgumentList "`"$TempPy`" `"$ResolvedProject`" -o `"$OutputFile`" -n `"$FinalProjectName`" --max-size $MaxFileSize" `
-NoNewWindow -Wait -PassThru
$Elapsed = (Get-Date) - $StartTime
Write-Host ""
Write-Host "----------------------------------------------------------------" -ForegroundColor DarkGray
if ($process.ExitCode -eq 0) {
$FileSize = if (Test-Path $OutputFile) {
$s = (Get-Item $OutputFile).Length
if ($s -gt 1MB) { "{0:N2} MB" -f ($s / 1MB) } else { "{0:N2} KB" -f ($s / 1KB) }
}
else { "Unknown" }
Write-Host ""
Write-Host " COLLECTION COMPLETE" -ForegroundColor Green
Write-Host " Output : $OutputFile" -ForegroundColor White
Write-Host " Size : $FileSize" -ForegroundColor White
Write-Host " Time : $($Elapsed.TotalSeconds.ToString('F2'))s" -ForegroundColor White
Write-Host ""
$open = Read-Host " Open output file? (y/N)"
if ($open -eq 'y' -or $open -eq 'Y') {
Start-Process notepad.exe $OutputFile
}
}
else {
Write-Host " FAILED (exit code: $($process.ExitCode))" -ForegroundColor Red
exit $process.ExitCode
}
}
finally {
if (Test-Path $TempPy) { Remove-Item $TempPy -Force -ErrorAction SilentlyContinue }
}
Write-Host ""
Write-Host "================================================================" -ForegroundColor Cyan
\ No newline at end of file
Require all denied
\ No newline at end of file
<?php
/**
* Application Configuration
*/
return [
'name' => 'AL-ARCADE Club Management',
'name_ar' => 'نظام إدارة نادي الأركيد',
'version' => '1.0.0',
'env' => 'production', // production | development
'debug' => false,
'timezone' => 'Africa/Cairo',
'locale' => 'ar', // default locale
'fallback_locale' => 'ar',
'url' => 'https://club.al-arcade.com',
'charset' => 'UTF-8',
'session' => [
'name' => 'ALARCADE_SESS',
'lifetime' => 7200, // 2 hours in seconds
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Strict',
],
'security' => [
'max_login_attempts' => 5,
'lockout_duration' => 900, // 15 minutes
'password_min_length' => 8,
'csrf_token_lifetime' => 3600,
'password_reset_expiry' => 3600, // 1 hour
'session_regenerate' => 300, // Regenerate session ID every 5 min
],
'upload' => [
'max_size' => 10485760, // 10MB
'allowed_images' => ['jpg', 'jpeg', 'png', 'gif', 'webp'],
'allowed_docs' => ['pdf', 'doc', 'docx', 'xls', 'xlsx'],
'path' => ROOT_PATH . '/uploads',
],
'logging' => [
'enabled' => true,
'path' => ROOT_PATH . '/logs/app.log',
'level' => 'error', // debug | info | warning | error
],
'pagination' => [
'per_page' => 25,
'max_per_page' => 100,
],
'currency' => [
'code' => 'EGP',
'symbol' => 'ج.م',
'symbol_en' => 'LE',
'decimals' => 2,
'dec_point' => '.',
'thousands_sep' => ',',
],
'date_format' => [
'display' => 'd/m/Y',
'display_time' => 'd/m/Y H:i',
'db' => 'Y-m-d',
'db_time' => 'Y-m-d H:i:s',
],
];
\ No newline at end of file
<?php
/**
* Database Configuration
*/
return [
'host' => '127.0.0.1',
'port' => 3306,
'database' => 'alarcade_club',
'username' => 'alarcade_user',
'password' => 'CHANGE_THIS_IN_PRODUCTION',
'charset' => 'utf8mb4',
'collation'=> 'utf8mb4_unicode_ci',
'options' => [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_STRINGIFY_FETCHES => false,
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci",
],
];
\ No newline at end of file
<?php
/**
* Sidebar Navigation Menu Configuration
* Defines ALL menu items for ALL phases.
* Visibility is controlled by permissions — items without
* required permission are hidden from unauthorized users.
* Phase 0 defines ALL menus. Future phases NEVER edit this file.
*/
return [
[
'id' => 'dashboard',
'title_ar' => 'لوحة التحكم',
'title_en' => 'Dashboard',
'icon' => 'fas fa-tachometer-alt',
'url' => '/dashboard',
'permission' => null,
'badge' => null,
'children' => [],
],
[
'id' => 'members',
'title_ar' => 'الأعضاء',
'title_en' => 'Members',
'icon' => 'fas fa-users',
'url' => '#',
'permission' => 'members.view',
'badge' => null,
'children' => [
['title_ar' => 'تسجيل عضو جديد', 'title_en' => 'New Registration', 'url' => '/members/registration', 'permission' => 'members.register', 'icon' => 'fas fa-user-plus'],
['title_ar' => 'قائمة الأعضاء', 'title_en' => 'Member List', 'url' => '/members/list', 'permission' => 'members.view', 'icon' => 'fas fa-list'],
['title_ar' => 'بحث متقدم', 'title_en' => 'Advanced Search', 'url' => '/members/search', 'permission' => 'members.view', 'icon' => 'fas fa-search'],
['title_ar' => 'التابعين', 'title_en' => 'Dependents', 'url' => '/members/dependents', 'permission' => 'members.dependents.view', 'icon' => 'fas fa-people-arrows'],
['title_ar' => 'طلبات العضوية', 'title_en' => 'Applications', 'url' => '/members/applications', 'permission' => 'members.applications.view', 'icon' => 'fas fa-file-alt'],
],
],
[
'id' => 'finance',
'title_ar' => 'الشؤون المالية',
'title_en' => 'Finance',
'icon' => 'fas fa-money-bill-wave',
'url' => '#',
'permission' => 'finance.view',
'badge' => null,
'children' => [
['title_ar' => 'الفواتير', 'title_en' => 'Invoices', 'url' => '/invoices/list', 'permission' => 'invoices.view', 'icon' => 'fas fa-file-invoice-dollar'],
['title_ar' => 'المدفوعات', 'title_en' => 'Payments', 'url' => '/payments/list', 'permission' => 'payments.view', 'icon' => 'fas fa-credit-card'],
['title_ar' => 'الإيصالات', 'title_en' => 'Receipts', 'url' => '/receipts/list', 'permission' => 'receipts.view', 'icon' => 'fas fa-receipt'],
['title_ar' => 'الأقساط', 'title_en' => 'Installments', 'url' => '/installments/list', 'permission' => 'installments.view', 'icon' => 'fas fa-calendar-check'],
['title_ar' => 'الخزينة', 'title_en' => 'Cashier', 'url' => '/cashier/index', 'permission' => 'cashier.view', 'icon' => 'fas fa-cash-register'],
],
],
[
'id' => 'cards',
'title_ar' => 'البطاقات',
'title_en' => 'Cards',
'icon' => 'fas fa-id-card',
'url' => '#',
'permission' => 'cards.view',
'badge' => null,
'children' => [
['title_ar' => 'إصدار بطاقة', 'title_en' => 'Issue Card', 'url' => '/cards/issue', 'permission' => 'cards.issue', 'icon' => 'fas fa-plus-circle'],
['title_ar' => 'قائمة البطاقات', 'title_en' => 'Card List', 'url' => '/cards/list', 'permission' => 'cards.view', 'icon' => 'fas fa-list'],
['title_ar' => 'الكارنيهات', 'title_en' => 'Carnets', 'url' => '/carnets/list', 'permission' => 'carnets.view', 'icon' => 'fas fa-address-card'],
],
],
[
'id' => 'renewals',
'title_ar' => 'التجديدات',
'title_en' => 'Renewals',
'icon' => 'fas fa-sync-alt',
'url' => '/renewals/list',
'permission' => 'renewals.view',
'badge' => null,
'children' => [],
],
[
'id' => 'violations',
'title_ar' => 'المخالفات',
'title_en' => 'Violations',
'icon' => 'fas fa-exclamation-triangle',
'url' => '/violations/list',
'permission' => 'violations.view',
'badge' => null,
'children' => [],
],
[
'id' => 'waiting_list',
'title_ar' => 'قوائم الانتظار',
'title_en' => 'Waiting List',
'icon' => 'fas fa-hourglass-half',
'url' => '/waiting-list/list',
'permission' => 'waiting_list.view',
'badge' => null,
'children' => [],
],
[
'id' => 'reports',
'title_ar' => 'التقارير',
'title_en' => 'Reports',
'icon' => 'fas fa-chart-bar',
'url' => '/reports/index',
'permission' => 'reports.view',
'badge' => null,
'children' => [],
],
[
'id' => 'notifications_mgmt',
'title_ar' => 'الإشعارات',
'title_en' => 'Notifications',
'icon' => 'fas fa-bell',
'url' => '/notifications/list',
'permission' => 'notifications.manage',
'badge' => null,
'children' => [],
],
[
'id' => 'gate',
'title_ar' => 'البوابة',
'title_en' => 'Gate Control',
'icon' => 'fas fa-door-open',
'url' => '/gate/index',
'permission' => 'gate.view',
'badge' => null,
'children' => [],
],
[
'id' => 'sports',
'title_ar' => 'الأنشطة الرياضية',
'title_en' => 'Sports',
'icon' => 'fas fa-futbol',
'url' => '/sports/list',
'permission' => 'sports.view',
'badge' => null,
'children' => [],
],
[
'id' => 'guests',
'title_ar' => 'الضيوف',
'title_en' => 'Guests',
'icon' => 'fas fa-user-friends',
'url' => '/guests/list',
'permission' => 'guests.view',
'badge' => null,
'children' => [],
],
// ── SYSTEM ADMIN ──
[
'id' => 'admin',
'title_ar' => 'إدارة النظام',
'title_en' => 'System Admin',
'icon' => 'fas fa-cogs',
'url' => '#',
'permission' => 'admin.access',
'badge' => null,
'children' => [
['title_ar' => 'المستخدمين', 'title_en' => 'Users', 'url' => '/admin/users', 'permission' => 'admin.users.view', 'icon' => 'fas fa-user-shield'],
['title_ar' => 'الأدوار والصلاحيات', 'title_en' => 'Roles & Permissions', 'url' => '/admin/roles', 'permission' => 'admin.roles.view', 'icon' => 'fas fa-user-lock'],
['title_ar' => 'إعدادات النظام', 'title_en' => 'System Config', 'url' => '/admin/config', 'permission' => 'admin.config.view', 'icon' => 'fas fa-sliders-h'],
['title_ar' => 'سجل المراجعة', 'title_en' => 'Audit Trail', 'url' => '/admin/audit', 'permission' => 'admin.audit.view', 'icon' => 'fas fa-history'],
['title_ar' => 'سلاسل الترقيم', 'title_en' => 'Number Series', 'url' => '/admin/number-series', 'permission' => 'admin.config.view', 'icon' => 'fas fa-sort-numeric-down'],
],
],
];
\ No newline at end of file
Require all denied
\ No newline at end of file
<?php
/**
* Audit Trail — logs every significant action into audit_trail table.
*/
class AuditTrail
{
/**
* Log an action.
*
* @param string $action e.g., INSERT, UPDATE, DELETE, LOGIN, LOGOUT, VIEW, EXPORT, PRINT
* @param string $tableName The table affected
* @param int|null $recordId The primary key of the record affected
* @param mixed $oldData Previous state (will be JSON-encoded)
* @param mixed $newData New state (will be JSON-encoded)
* @param string|null $description Human-readable description
*/
public static function log(
string $action,
string $tableName,
?int $recordId = null,
$oldData = null,
$newData = null,
?string $description = null
): void {
try {
$db = Database::getInstance();
$db->insert('audit_trail', [
'user_id' => Auth::id(),
'action' => $action,
'table_name' => $tableName,
'record_id' => $recordId,
'old_data' => $oldData !== null ? json_encode($oldData, JSON_UNESCAPED_UNICODE) : null,
'new_data' => $newData !== null ? json_encode($newData, JSON_UNESCAPED_UNICODE) : null,
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? null,
'user_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? mb_substr($_SERVER['HTTP_USER_AGENT'], 0, 500) : null,
'description' => $description,
'created_at' => date('Y-m-d H:i:s'),
]);
} catch (\Throwable $e) {
// Audit logging should NEVER crash the application
app_log('error', 'AuditTrail::log failed: ' . $e->getMessage());
}
}
}
\ No newline at end of file
<?php
/**
* Authentication & Authorization — login, sessions, permissions, RBAC.
*/
class Auth
{
private static ?array $currentUser = null;
private static ?array $currentPermissions = null;
/**
* Attempt login with username and password.
* Returns ['success' => bool, 'message' => string, 'user' => ?array]
*/
public static function attempt(string $username, string $password, string $ip, string $userAgent): array
{
$db = Database::getInstance();
$cfg = app_config('security');
// Check lockout
$recentAttempts = $db->scalar(
"SELECT COUNT(*) FROM login_attempts WHERE username = ? AND attempted_at > DATE_SUB(NOW(), INTERVAL ? SECOND) AND success = 0",
[$username, $cfg['lockout_duration']]
);
if ((int)$recentAttempts >= $cfg['max_login_attempts']) {
return [
'success' => false,
'message' => 'تم تجاوز الحد الأقصى لمحاولات الدخول. يرجى المحاولة لاحقاً.',
'message_en' => 'Too many login attempts. Please try again later.',
'user' => null,
];
}
// Find user
$user = $db->selectOne(
"SELECT u.*, r.name as role_name, r.name_ar as role_name_ar
FROM users u
LEFT JOIN roles r ON u.role_id = r.id
WHERE u.username = ? AND u.deleted_at IS NULL
LIMIT 1",
[$username]
);
if (!$user || !password_verify($password, $user['password_hash'])) {
// Record failed attempt
$db->insert('login_attempts', [
'username' => $username,
'ip_address' => $ip,
'user_agent' => mb_substr($userAgent, 0, 500),
'success' => 0,
'attempted_at' => date('Y-m-d H:i:s'),
]);
return [
'success' => false,
'message' => 'اسم المستخدم أو كلمة المرور غير صحيحة.',
'message_en' => 'Invalid username or password.',
'user' => null,
];
}
if ($user['is_active'] != 1) {
return [
'success' => false,
'message' => 'هذا الحساب معطل. تواصل مع المسؤول.',
'message_en' => 'This account is disabled. Contact your administrator.',
'user' => null,
];
}
// Record successful attempt
$db->insert('login_attempts', [
'username' => $username,
'ip_address' => $ip,
'user_agent' => mb_substr($userAgent, 0, 500),
'success' => 1,
'attempted_at' => date('Y-m-d H:i:s'),
]);
// Clear old failed attempts
$db->delete(
'login_attempts',
'username = ? AND success = 0',
[$username]
);
// Update last login
$db->update('users', [
'last_login_at' => date('Y-m-d H:i:s'),
'last_login_ip' => $ip,
], 'id = ?', [$user['id']]);
// Destroy any existing session for this user (single session enforcement)
$db->delete('user_sessions', 'user_id = ?', [$user['id']]);
// Create new session record
Session::regenerate();
$db->insert('user_sessions', [
'user_id' => $user['id'],
'session_id' => Session::id(),
'ip_address' => $ip,
'user_agent' => mb_substr($userAgent, 0, 500),
'created_at' => date('Y-m-d H:i:s'),
'expires_at' => date('Y-m-d H:i:s', time() + app_config('session.lifetime')),
]);
// Store in session
unset($user['password_hash']);
Session::set('user_id', $user['id']);
Session::set('user', $user);
Session::set('logged_in', true);
Session::set('login_time', time());
self::$currentUser = $user;
// Audit
AuditTrail::log('LOGIN', 'users', $user['id'], null, null, 'User logged in');
return [
'success' => true,
'message' => 'تم تسجيل الدخول بنجاح.',
'message_en' => 'Login successful.',
'user' => $user,
];
}
/**
* Logout the current user.
*/
public static function logout(): void
{
$userId = Session::get('user_id');
if ($userId) {
$db = Database::getInstance();
$db->delete('user_sessions', 'user_id = ?', [$userId]);
AuditTrail::log('LOGOUT', 'users', $userId, null, null, 'User logged out');
}
self::$currentUser = null;
self::$currentPermissions = null;
Session::destroy();
}
/**
* Check if the current request is authenticated.
*/
public static function check(): bool
{
if (!Session::get('logged_in')) {
return false;
}
$userId = Session::get('user_id');
$sessionId = Session::id();
if (!$userId || !$sessionId) {
return false;
}
// Validate session exists in DB and hasn't expired
$db = Database::getInstance();
$sess = $db->selectOne(
"SELECT * FROM user_sessions WHERE user_id = ? AND session_id = ? AND expires_at > NOW()",
[$userId, $sessionId]
);
if (!$sess) {
self::logout();
return false;
}
// Extend session expiry on activity
$db->update('user_sessions', [
'expires_at' => date('Y-m-d H:i:s', time() + app_config('session.lifetime')),
], 'user_id = ? AND session_id = ?', [$userId, $sessionId]);
return true;
}
/**
* Get the current authenticated user.
*/
public static function user(): ?array
{
if (self::$currentUser !== null) {
return self::$currentUser;
}
self::$currentUser = Session::get('user');
return self::$currentUser;
}
/**
* Get the current user's ID.
*/
public static function id(): ?int
{
$user = self::user();
return $user ? (int)$user['id'] : null;
}
/**
* Load all permissions for the current user (role + overrides).
*/
public static function loadPermissions(): array
{
if (self::$currentPermissions !== null) {
return self::$currentPermissions;
}
$user = self::user();
if (!$user) {
return [];
}
$db = Database::getInstance();
// Get role permissions
$rolePerms = $db->select(
"SELECT p.code
FROM permissions p
INNER JOIN role_permissions rp ON rp.permission_id = p.id
WHERE rp.role_id = ?",
[$user['role_id']]
);
$perms = array_column($rolePerms, 'code');
// Apply user-level overrides
$overrides = $db->select(
"SELECT p.code, upo.type
FROM user_permission_overrides upo
INNER JOIN permissions p ON p.id = upo.permission_id
WHERE upo.user_id = ?",
[$user['id']]
);
foreach ($overrides as $ov) {
if ($ov['type'] === 'grant' && !in_array($ov['code'], $perms)) {
$perms[] = $ov['code'];
} elseif ($ov['type'] === 'revoke') {
$perms = array_filter($perms, fn($p) => $p !== $ov['code']);
}
}
self::$currentPermissions = array_values($perms);
Session::set('permissions', self::$currentPermissions);
return self::$currentPermissions;
}
/**
* Check if current user has a specific permission.
*/
public static function hasPermission(string $permission): bool
{
$user = self::user();
if (!$user) {
return false;
}
// Super admin bypass
if (isset($user['is_super_admin']) && $user['is_super_admin'] == 1) {
return true;
}
$perms = self::loadPermissions();
return in_array($permission, $perms);
}
/**
* Check if current user has ANY of the given permissions.
*/
public static function hasAnyPermission(array $permissions): bool
{
foreach ($permissions as $perm) {
if (self::hasPermission($perm)) {
return true;
}
}
return false;
}
/**
* Check if current user has ALL of the given permissions.
*/
public static function hasAllPermissions(array $permissions): bool
{
foreach ($permissions as $perm) {
if (!self::hasPermission($perm)) {
return false;
}
}
return true;
}
/**
* Require a permission — abort with 403 if not held.
*/
public static function requirePermission(string $permission): void
{
if (!self::hasPermission($permission)) {
http_response_code(403);
include ROOT_PATH . '/pages/errors/403.php';
exit;
}
}
/**
* Require authentication — redirect to login if not authenticated.
*/
public static function requireLogin(): void
{
if (!self::check()) {
Session::flash('error', 'يرجى تسجيل الدخول أولاً');
redirect('/login');
}
}
/**
* Generate a password reset token.
*/
public static function generatePasswordResetToken(string $email): ?string
{
$db = Database::getInstance();
$user = $db->selectOne(
"SELECT id, email FROM users WHERE email = ? AND is_active = 1 AND deleted_at IS NULL",
[$email]
);
if (!$user) {
return null;
}
// Delete existing tokens for this user
$db->delete('password_reset_tokens', 'user_id = ?', [$user['id']]);
$token = bin2hex(random_bytes(32));
$db->insert('password_reset_tokens', [
'user_id' => $user['id'],
'token' => hash('sha256', $token),
'expires_at' => date('Y-m-d H:i:s', time() + app_config('security.password_reset_expiry')),
'created_at' => date('Y-m-d H:i:s'),
]);
return $token;
}
/**
* Validate a password reset token.
*/
public static function validatePasswordResetToken(string $token): ?array
{
$db = Database::getInstance();
$record = $db->selectOne(
"SELECT prt.*, u.username, u.email
FROM password_reset_tokens prt
INNER JOIN users u ON u.id = prt.user_id
WHERE prt.token = ? AND prt.expires_at > NOW()
LIMIT 1",
[hash('sha256', $token)]
);
return $record;
}
/**
* Reset a user's password via token.
*/
public static function resetPassword(string $token, string $newPassword): bool
{
$record = self::validatePasswordResetToken($token);
if (!$record) {
return false;
}
$db = Database::getInstance();
$db->update('users', [
'password_hash' => password_hash($newPassword, PASSWORD_ARGON2ID),
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$record['user_id']]);
$db->delete('password_reset_tokens', 'user_id = ?', [$record['user_id']]);
AuditTrail::log('PASSWORD_RESET', 'users', $record['user_id'], null, null, 'Password reset via token');
return true;
}
/**
* Refresh cached user data from DB.
*/
public static function refreshUser(): void
{
$userId = self::id();
if (!$userId) return;
$db = Database::getInstance();
$user = $db->selectOne(
"SELECT u.*, r.name as role_name, r.name_ar as role_name_ar
FROM users u
LEFT JOIN roles r ON u.role_id = r.id
WHERE u.id = ? AND u.deleted_at IS NULL",
[$userId]
);
if ($user) {
unset($user['password_hash']);
Session::set('user', $user);
self::$currentUser = $user;
self::$currentPermissions = null; // Force reload
}
}
}
\ No newline at end of file
<?php
/**
* Database Singleton — PDO wrapper.
* The ONE AND ONLY way to talk to the database.
*/
class Database
{
private static ?Database $instance = null;
private PDO $pdo;
private function __construct()
{
$cfg = require ROOT_PATH . '/config/database.php';
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
$cfg['host'],
$cfg['port'],
$cfg['database'],
$cfg['charset']
);
$this->pdo = new PDO($dsn, $cfg['username'], $cfg['password'], $cfg['options']);
}
private function __clone() {}
public function __wakeup() { throw new \RuntimeException('Cannot unserialize singleton'); }
public static function getInstance(): self
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
public function getPdo(): PDO
{
return $this->pdo;
}
/**
* Execute a raw query with bound parameters.
*/
public function query(string $sql, array $params = []): PDOStatement
{
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt;
}
/**
* SELECT multiple rows.
*/
public function select(string $sql, array $params = []): array
{
return $this->query($sql, $params)->fetchAll();
}
/**
* SELECT single row.
*/
public function selectOne(string $sql, array $params = []): ?array
{
$row = $this->query($sql, $params)->fetch();
return $row !== false ? $row : null;
}
/**
* SELECT single scalar value.
*/
public function scalar(string $sql, array $params = [])
{
return $this->query($sql, $params)->fetchColumn();
}
/**
* INSERT into a table. Returns last insert ID.
*/
public function insert(string $table, array $data): int
{
$columns = implode(', ', array_map(fn($c) => "`$c`", array_keys($data)));
$placeholders = implode(', ', array_fill(0, count($data), '?'));
$sql = "INSERT INTO `{$table}` ({$columns}) VALUES ({$placeholders})";
$this->query($sql, array_values($data));
return (int) $this->pdo->lastInsertId();
}
/**
* UPDATE rows. Returns affected row count.
*/
public function update(string $table, array $data, string $where, array $whereParams = []): int
{
$setParts = [];
$values = [];
foreach ($data as $col => $val) {
$setParts[] = "`{$col}` = ?";
$values[] = $val;
}
$sql = "UPDATE `{$table}` SET " . implode(', ', $setParts) . " WHERE {$where}";
$stmt = $this->query($sql, array_merge($values, $whereParams));
return $stmt->rowCount();
}
/**
* DELETE rows. Returns affected row count.
*/
public function delete(string $table, string $where, array $whereParams = []): int
{
$sql = "DELETE FROM `{$table}` WHERE {$where}";
return $this->query($sql, $whereParams)->rowCount();
}
/**
* Soft-delete rows by setting deleted_at.
*/
public function softDelete(string $table, string $where, array $whereParams = [], ?int $userId = null): int
{
$data = ['deleted_at' => date('Y-m-d H:i:s')];
if ($userId !== null) {
$data['updated_by'] = $userId;
}
return $this->update($table, $data, $where, $whereParams);
}
/**
* COUNT rows.
*/
public function count(string $table, string $where = '1=1', array $params = []): int
{
return (int) $this->scalar("SELECT COUNT(*) FROM `{$table}` WHERE {$where}", $params);
}
/**
* Check if a row exists.
*/
public function exists(string $table, string $where, array $params = []): bool
{
return $this->count($table, $where, $params) > 0;
}
public function beginTransaction(): bool
{
return $this->pdo->beginTransaction();
}
public function commit(): bool
{
return $this->pdo->commit();
}
public function rollback(): bool
{
if ($this->pdo->inTransaction()) {
return $this->pdo->rollBack();
}
return false;
}
public function lastInsertId(): string
{
return $this->pdo->lastInsertId();
}
}
\ No newline at end of file
<?php
/**
* Database Singleton — PDO wrapper.
* The ONE AND ONLY way to talk to the database.
*/
class Database
{
private static ?Database $instance = null;
private PDO $pdo;
private int $transactionDepth = 0;
private function __construct()
{
$cfg = require ROOT_PATH . '/config/database.php';
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
$cfg['host'],
$cfg['port'],
$cfg['database'],
$cfg['charset']
);
$this->pdo = new PDO($dsn, $cfg['username'], $cfg['password'], $cfg['options']);
}
private function __clone() {}
public function __wakeup() { throw new \RuntimeException('Cannot unserialize singleton'); }
public static function getInstance(): self
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
public function getPdo(): PDO
{
return $this->pdo;
}
/**
* Execute a raw query with bound parameters.
*/
public function query(string $sql, array $params = []): PDOStatement
{
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt;
}
/**
* SELECT multiple rows.
*/
public function select(string $sql, array $params = []): array
{
return $this->query($sql, $params)->fetchAll();
}
/**
* SELECT single row.
*/
public function selectOne(string $sql, array $params = []): ?array
{
$row = $this->query($sql, $params)->fetch();
return $row !== false ? $row : null;
}
/**
* SELECT single scalar value.
*/
public function scalar(string $sql, array $params = [])
{
return $this->query($sql, $params)->fetchColumn();
}
/**
* INSERT into a table. Returns last insert ID.
*/
public function insert(string $table, array $data): int
{
$columns = implode(', ', array_map(fn($c) => "`$c`", array_keys($data)));
$placeholders = implode(', ', array_fill(0, count($data), '?'));
$sql = "INSERT INTO `{$table}` ({$columns}) VALUES ({$placeholders})";
$this->query($sql, array_values($data));
return (int) $this->pdo->lastInsertId();
}
/**
* UPDATE rows. Returns affected row count.
*/
public function update(string $table, array $data, string $where, array $whereParams = []): int
{
$setParts = [];
$values = [];
foreach ($data as $col => $val) {
$setParts[] = "`{$col}` = ?";
$values[] = $val;
}
$sql = "UPDATE `{$table}` SET " . implode(', ', $setParts) . " WHERE {$where}";
$stmt = $this->query($sql, array_merge($values, $whereParams));
return $stmt->rowCount();
}
/**
* DELETE rows. Returns affected row count.
*/
public function delete(string $table, string $where, array $whereParams = []): int
{
$sql = "DELETE FROM `{$table}` WHERE {$where}";
return $this->query($sql, $whereParams)->rowCount();
}
/**
* Soft-delete rows by setting deleted_at.
*/
public function softDelete(string $table, string $where, array $whereParams = [], ?int $userId = null): int
{
$data = ['deleted_at' => date('Y-m-d H:i:s')];
if ($userId !== null) {
$data['updated_by'] = $userId;
}
return $this->update($table, $data, $where, $whereParams);
}
/**
* COUNT rows.
*/
public function count(string $table, string $where = '1=1', array $params = []): int
{
return (int) $this->scalar("SELECT COUNT(*) FROM `{$table}` WHERE {$where}", $params);
}
/**
* Check if a row exists.
*/
public function exists(string $table, string $where, array $params = []): bool
{
return $this->count($table, $where, $params) > 0;
}
/**
* Begin a transaction with SAVEPOINT support for nesting.
*/
public function beginTransaction(): bool
{
if ($this->transactionDepth === 0) {
$result = $this->pdo->beginTransaction();
} else {
$this->pdo->exec("SAVEPOINT TRANS_{$this->transactionDepth}");
$result = true;
}
$this->transactionDepth++;
return $result;
}
/**
* Commit a transaction (or release savepoint if nested).
*/
public function commit(): bool
{
$this->transactionDepth--;
if ($this->transactionDepth === 0) {
return $this->pdo->commit();
}
$this->pdo->exec("RELEASE SAVEPOINT TRANS_{$this->transactionDepth}");
return true;
}
/**
* Rollback a transaction (or rollback to savepoint if nested).
*/
public function rollback(): bool
{
$this->transactionDepth--;
if ($this->transactionDepth === 0) {
if ($this->pdo->inTransaction()) {
return $this->pdo->rollBack();
}
return false;
}
$this->pdo->exec("ROLLBACK TO SAVEPOINT TRANS_{$this->transactionDepth}");
return true;
}
/**
* Check if currently in a transaction.
*/
public function inTransaction(): bool
{
return $this->transactionDepth > 0;
}
/**
* Get the current transaction depth.
*/
public function getTransactionDepth(): int
{
return $this->transactionDepth;
}
public function lastInsertId(): string
{
return $this->pdo->lastInsertId();
}
}
\ No newline at end of file
<?php
/**
* Notification System — queues and retrieves notifications.
*/
class Notification
{
/**
* Queue a notification from a template.
*/
public static function queue(
string $templateCode,
string $recipientType,
int $recipientId,
array $data = [],
?int $createdBy = null
): ?int {
$db = Database::getInstance();
$template = $db->selectOne(
"SELECT * FROM notification_templates WHERE code = ? AND is_active = 1",
[$templateCode]
);
if (!$template) {
app_log('warning', "Notification template not found: {$templateCode}");
return null;
}
// Replace placeholders in title and body
$title = self::replacePlaceholders($template['title_ar'], $data);
$titleEn = self::replacePlaceholders($template['title_en'] ?? '', $data);
$body = self::replacePlaceholders($template['body_ar'], $data);
$bodyEn = self::replacePlaceholders($template['body_en'] ?? '', $data);
return $db->insert('notification_queue', [
'template_id' => $template['id'],
'recipient_type' => $recipientType,
'recipient_id' => $recipientId,
'title_ar' => $title,
'title_en' => $titleEn,
'body_ar' => $body,
'body_en' => $bodyEn,
'data' => !empty($data) ? json_encode($data, JSON_UNESCAPED_UNICODE) : null,
'channel' => $template['channel'] ?? 'system',
'priority' => $template['priority'] ?? 'normal',
'status' => 'pending',
'created_by' => $createdBy ?? Auth::id(),
'created_at' => date('Y-m-d H:i:s'),
]);
}
/**
* Get unread notifications for the current user.
*/
public static function getUnread(?int $userId = null, int $limit = 20): array
{
$userId = $userId ?? Auth::id();
if (!$userId) return [];
$db = Database::getInstance();
return $db->select(
"SELECT * FROM notification_queue
WHERE recipient_type = 'user' AND recipient_id = ? AND read_at IS NULL AND channel = 'system'
ORDER BY created_at DESC
LIMIT ?",
[$userId, $limit]
);
}
/**
* Count unread notifications.
*/
public static function countUnread(?int $userId = null): int
{
$userId = $userId ?? Auth::id();
if (!$userId) return 0;
$db = Database::getInstance();
return (int) $db->scalar(
"SELECT COUNT(*) FROM notification_queue
WHERE recipient_type = 'user' AND recipient_id = ? AND read_at IS NULL AND channel = 'system'",
[$userId]
);
}
/**
* Mark a notification as read.
*/
public static function markAsRead(int $notificationId, ?int $userId = null): bool
{
$userId = $userId ?? Auth::id();
$db = Database::getInstance();
return $db->update('notification_queue', [
'read_at' => date('Y-m-d H:i:s'),
], 'id = ? AND recipient_id = ?', [$notificationId, $userId]) > 0;
}
/**
* Mark all notifications as read for a user.
*/
public static function markAllAsRead(?int $userId = null): int
{
$userId = $userId ?? Auth::id();
$db = Database::getInstance();
return $db->update('notification_queue', [
'read_at' => date('Y-m-d H:i:s'),
], "recipient_type = 'user' AND recipient_id = ? AND read_at IS NULL", [$userId]);
}
/**
* Replace :placeholders in a template string.
*/
private static function replacePlaceholders(string $text, array $data): string
{
foreach ($data as $key => $value) {
$text = str_replace(':' . $key, (string)$value, $text);
}
return $text;
}
}
\ No newline at end of file
<?php
/**
* Receipt Generation — creates and retrieves receipts.
*/
class Receipt
{
/**
* Create a receipt with items.
*
* @param array $data Receipt header data
* @param array $items Array of line items [['description'=>..., 'amount'=>...], ...]
* @return int Receipt ID
*/
public static function create(array $data, array $items = []): int
{
$db = Database::getInstance();
$db->beginTransaction();
try {
$receiptNumber = self::generateNumber();
$receiptId = $db->insert('receipts', [
'receipt_number' => $receiptNumber,
'member_id' => $data['member_id'] ?? null,
'receipt_type' => $data['receipt_type'] ?? 'payment',
'payment_method' => $data['payment_method'] ?? 'cash',
'total_amount' => $data['total_amount'],
'currency' => $data['currency'] ?? 'EGP',
'notes' => $data['notes'] ?? null,
'notes_ar' => $data['notes_ar'] ?? null,
'issued_by' => Auth::id(),
'issued_at' => date('Y-m-d H:i:s'),
'status' => 'issued',
'created_by' => Auth::id(),
'created_at' => date('Y-m-d H:i:s'),
]);
foreach ($items as $index => $item) {
$db->insert('receipt_items', [
'receipt_id' => $receiptId,
'line_number' => $index + 1,
'description' => $item['description'] ?? '',
'description_ar' => $item['description_ar'] ?? $item['description'] ?? '',
'amount' => $item['amount'],
'fee_type' => $item['fee_type'] ?? null,
'reference_id' => $item['reference_id'] ?? null,
'created_at' => date('Y-m-d H:i:s'),
]);
}
$db->commit();
AuditTrail::log('INSERT', 'receipts', $receiptId, null,
['receipt_number' => $receiptNumber, 'total' => $data['total_amount']],
"Receipt created: {$receiptNumber}"
);
return $receiptId;
} catch (\Throwable $e) {
$db->rollback();
throw $e;
}
}
/**
* Get receipt by ID with items.
*/
public static function getById(int $id): ?array
{
$db = Database::getInstance();
$receipt = $db->selectOne("SELECT * FROM receipts WHERE id = ?", [$id]);
if (!$receipt) return null;
$receipt['items'] = $db->select(
"SELECT * FROM receipt_items WHERE receipt_id = ? ORDER BY line_number",
[$id]
);
return $receipt;
}
/**
* Get receipt by number.
*/
public static function getByNumber(string $number): ?array
{
$db = Database::getInstance();
$receipt = $db->selectOne("SELECT * FROM receipts WHERE receipt_number = ?", [$number]);
if (!$receipt) return null;
$receipt['items'] = $db->select(
"SELECT * FROM receipt_items WHERE receipt_id = ? ORDER BY line_number",
[$receipt['id']]
);
return $receipt;
}
/**
* Generate next receipt number from number_series.
*/
public static function generateNumber(): string
{
return generate_number_series('receipt');
}
/**
* Cancel a receipt.
*/
public static function cancel(int $receiptId, string $reason): bool
{
$db = Database::getInstance();
$old = $db->selectOne("SELECT * FROM receipts WHERE id = ?", [$receiptId]);
if (!$old || $old['status'] === 'cancelled') return false;
$db->update('receipts', [
'status' => 'cancelled',
'cancellation_reason' => $reason,
'cancelled_by' => Auth::id(),
'cancelled_at' => date('Y-m-d H:i:s'),
'updated_by' => Auth::id(),
], 'id = ?', [$receiptId]);
AuditTrail::log('UPDATE', 'receipts', $receiptId,
['status' => $old['status']],
['status' => 'cancelled', 'reason' => $reason],
"Receipt cancelled: {$old['receipt_number']}"
);
return true;
}
}
\ No newline at end of file
<?php
/**
* Router — maps URLs to page files.
*/
class Router
{
private string $basePath;
private array $publicRoutes = ['login', 'password-reset', 'password-reset-confirm'];
public function __construct()
{
$this->basePath = ROOT_PATH . '/pages';
}
/**
* Dispatch the current request to the correct page file.
*/
public function dispatch(): void
{
$route = $this->getRoute();
// Empty route = dashboard
if ($route === '' || $route === '/') {
$route = 'dashboard';
}
$route = trim($route, '/');
$segments = explode('/', $route);
// Check if this is a public route
$isPublic = in_array($segments[0], $this->publicRoutes);
// Require authentication for non-public routes
if (!$isPublic) {
Auth::requireLogin();
}
// Resolve the page file
$file = $this->resolveFile($segments);
if ($file && file_exists($file)) {
// Extract route parameters
$GLOBALS['_route_segments'] = $segments;
$GLOBALS['_route_id'] = isset($segments[2]) && is_numeric($segments[2]) ? (int)$segments[2] : null;
$GLOBALS['_route_action'] = $segments[1] ?? 'index';
$GLOBALS['_route_module'] = $segments[0];
include $file;
} else {
http_response_code(404);
include $this->basePath . '/errors/404.php';
}
}
/**
* Resolve file path from URL segments.
*/
private function resolveFile(array $segments): ?string
{
$count = count($segments);
// Try exact match first: /module/action → /pages/module/action.php
if ($count >= 2) {
$candidate = $this->basePath . '/' . $segments[0] . '/' . $segments[1] . '.php';
if (file_exists($candidate)) return $candidate;
}
// 3 segments: /module/action/id → /pages/module/action.php (id as param)
if ($count >= 3) {
$candidate = $this->basePath . '/' . $segments[0] . '/' . $segments[1] . '.php';
if (file_exists($candidate)) return $candidate;
}
// 1 segment: /module → /pages/module/index.php
if ($count === 1) {
$candidate = $this->basePath . '/' . $segments[0] . '/index.php';
if (file_exists($candidate)) return $candidate;
// Or a direct file: /pages/module.php (for login, logout, etc.)
$candidate = $this->basePath . '/' . $segments[0] . '.php';
if (file_exists($candidate)) return $candidate;
}
// Dashboard widgets: /dashboard/widgets/name → /pages/dashboard/widgets/name.php
if ($count >= 3 && $segments[0] === 'dashboard' && $segments[1] === 'widgets') {
$candidate = $this->basePath . '/dashboard/widgets/' . $segments[2] . '.php';
if (file_exists($candidate)) return $candidate;
}
return null;
}
/**
* Extract the route from the request.
*/
private function getRoute(): string
{
if (isset($_GET['route'])) {
return $_GET['route'];
}
$uri = $_SERVER['REQUEST_URI'] ?? '/';
$uri = parse_url($uri, PHP_URL_PATH);
$uri = rawurldecode($uri);
// Remove base directory if app isn't at document root
$scriptDir = dirname($_SERVER['SCRIPT_NAME']);
if ($scriptDir !== '/' && $scriptDir !== '\\') {
$uri = substr($uri, strlen($scriptDir));
}
return $uri ?: '/';
}
}
\ No newline at end of file
<?php
/**
* Session Manager — wraps PHP sessions with security hardening.
*/
class Session
{
private static bool $started = false;
public static function start(): void
{
if (self::$started) {
return;
}
$cfg = app_config('session');
session_name($cfg['name']);
session_set_cookie_params([
'lifetime' => $cfg['lifetime'],
'path' => $cfg['path'],
'secure' => $cfg['secure'],
'httponly' => $cfg['httponly'],
'samesite' => $cfg['samesite'],
]);
session_start();
self::$started = true;
// Regenerate session ID periodically to prevent fixation
$now = time();
if (!isset($_SESSION['_last_regenerate'])) {
$_SESSION['_last_regenerate'] = $now;
} elseif ($now - $_SESSION['_last_regenerate'] > app_config('security.session_regenerate')) {
session_regenerate_id(true);
$_SESSION['_last_regenerate'] = $now;
}
}
public static function set(string $key, $value): void
{
$_SESSION[$key] = $value;
}
public static function get(string $key, $default = null)
{
return $_SESSION[$key] ?? $default;
}
public static function has(string $key): bool
{
return isset($_SESSION[$key]);
}
public static function remove(string $key): void
{
unset($_SESSION[$key]);
}
public static function destroy(): void
{
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$p = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000,
$p['path'], $p['domain'], $p['secure'], $p['httponly']
);
}
session_destroy();
self::$started = false;
}
public static function regenerate(): void
{
session_regenerate_id(true);
$_SESSION['_last_regenerate'] = time();
}
/**
* Set a flash message (available for ONE subsequent request).
*/
public static function flash(string $key, $value): void
{
$_SESSION['_flash'][$key] = $value;
}
/**
* Get and clear a flash message.
*/
public static function getFlash(string $key, $default = null)
{
$value = $_SESSION['_flash'][$key] ?? $default;
unset($_SESSION['_flash'][$key]);
return $value;
}
/**
* Get all flash messages and clear them.
*/
public static function getAllFlash(): array
{
$flash = $_SESSION['_flash'] ?? [];
unset($_SESSION['_flash']);
return $flash;
}
public static function id(): string
{
return session_id();
}
}
\ No newline at end of file
<?php
/**
* Session Manager — wraps PHP sessions with security hardening.
*/
class Session
{
private static bool $started = false;
public static function start(): void
{
if (self::$started) {
return;
}
$cfg = app_config('session');
session_name($cfg['name']);
session_set_cookie_params([
'lifetime' => $cfg['lifetime'],
'path' => $cfg['path'],
'secure' => $cfg['secure'],
'httponly' => $cfg['httponly'],
'samesite' => $cfg['samesite'],
]);
session_start();
self::$started = true;
// Regenerate session ID periodically to prevent fixation
// BUT we must update the user_sessions table if the user is logged in
$now = time();
if (!isset($_SESSION['_last_regenerate'])) {
$_SESSION['_last_regenerate'] = $now;
} elseif ($now - $_SESSION['_last_regenerate'] > app_config('security.session_regenerate')) {
$oldSessionId = session_id();
session_regenerate_id(true);
$newSessionId = session_id();
$_SESSION['_last_regenerate'] = $now;
// Update user_sessions table if user is logged in
if (isset($_SESSION['user_id']) && $_SESSION['user_id']) {
try {
$db = Database::getInstance();
$db->update('user_sessions', [
'session_id' => $newSessionId,
], 'user_id = ? AND session_id = ?', [
$_SESSION['user_id'],
$oldSessionId
]);
} catch (\Throwable $e) {
// Don't crash on session update failure
}
}
}
}
public static function set(string $key, $value): void
{
$_SESSION[$key] = $value;
}
public static function get(string $key, $default = null)
{
return $_SESSION[$key] ?? $default;
}
public static function has(string $key): bool
{
return isset($_SESSION[$key]);
}
public static function remove(string $key): void
{
unset($_SESSION[$key]);
}
public static function destroy(): void
{
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$p = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000,
$p['path'], $p['domain'], $p['secure'], $p['httponly']
);
}
session_destroy();
self::$started = false;
}
public static function regenerate(): void
{
$oldSessionId = session_id();
session_regenerate_id(true);
$newSessionId = session_id();
$_SESSION['_last_regenerate'] = time();
// Update user_sessions table
if (isset($_SESSION['user_id']) && $_SESSION['user_id']) {
try {
$db = Database::getInstance();
$db->update('user_sessions', [
'session_id' => $newSessionId,
], 'user_id = ? AND session_id = ?', [
$_SESSION['user_id'],
$oldSessionId
]);
} catch (\Throwable $e) {
// Don't crash — session might not be in DB yet (login flow)
}
}
}
/**
* Set a flash message (available for ONE subsequent request).
*/
public static function flash(string $key, $value): void
{
$_SESSION['_flash'][$key] = $value;
}
/**
* Get and clear a flash message.
*/
public static function getFlash(string $key, $default = null)
{
$value = $_SESSION['_flash'][$key] ?? $default;
unset($_SESSION['_flash'][$key]);
return $value;
}
/**
* Get all flash messages and clear them.
*/
public static function getAllFlash(): array
{
$flash = $_SESSION['_flash'] ?? [];
unset($_SESSION['_flash']);
return $flash;
}
public static function id(): string
{
return session_id();
}
}
\ No newline at end of file
<?php
/**
* System Configuration — reads/writes from sys_config table.
*/
class SystemConfig
{
private static array $cache = [];
private static bool $loaded = false;
/**
* Load all config into memory.
*/
private static function loadAll(): void
{
if (self::$loaded) return;
$db = Database::getInstance();
$rows = $db->select("SELECT config_key, config_value, value_type FROM sys_config");
foreach ($rows as $row) {
self::$cache[$row['config_key']] = self::cast($row['config_value'], $row['value_type']);
}
self::$loaded = true;
}
/**
* Cast a string value to its appropriate PHP type.
*/
private static function cast(?string $value, ?string $type)
{
if ($value === null) return null;
return match ($type) {
'integer', 'int' => (int)$value,
'float', 'decimal' => (float)$value,
'boolean', 'bool' => in_array(strtolower($value), ['1', 'true', 'yes']),
'json' => json_decode($value, true),
default => $value,
};
}
/**
* Get a config value.
*/
public static function get(string $key, $default = null)
{
self::loadAll();
return self::$cache[$key] ?? $default;
}
/**
* Set a config value (persists to DB).
*/
public static function set(string $key, $value, ?int $userId = null): void
{
$db = Database::getInstance();
$strValue = is_bool($value) ? ($value ? '1' : '0') : (string)$value;
$exists = $db->exists('sys_config', 'config_key = ?', [$key]);
if ($exists) {
$old = self::get($key);
$db->update('sys_config', [
'config_value' => $strValue,
'updated_by' => $userId,
'updated_at' => date('Y-m-d H:i:s'),
], 'config_key = ?', [$key]);
AuditTrail::log('UPDATE', 'sys_config', null,
['key' => $key, 'value' => $old],
['key' => $key, 'value' => $strValue],
"Config changed: {$key}"
);
}
self::$cache[$key] = $value;
}
/**
* Get all config values (optionally filtered by prefix).
*/
public static function getAll(?string $prefix = null): array
{
self::loadAll();
if ($prefix === null) return self::$cache;
return array_filter(self::$cache, fn($k) => str_starts_with($k, $prefix), ARRAY_FILTER_USE_KEY);
}
/**
* Reload config from DB.
*/
public static function reload(): void
{
self::$loaded = false;
self::$cache = [];
self::loadAll();
}
}
\ No newline at end of file
<?php
/**
* Translation / i18n — pulls from sys_translations table.
*/
class Translation
{
private static array $strings = [];
private static string $locale = 'ar';
private static bool $loaded = false;
private static array $availableLocales = [];
/**
* Set the current locale.
*/
public static function setLocale(string $locale): void
{
$allowed = self::getAvailableLocales();
if (in_array($locale, $allowed)) {
self::$locale = $locale;
self::$loaded = false;
self::$strings = [];
Session::set('locale', $locale);
}
}
/**
* Get the current locale.
*/
public static function getLocale(): string
{
return self::$locale;
}
/**
* Is the current locale RTL?
*/
public static function isRtl(): bool
{
return self::$locale === 'ar';
}
/**
* Get available locales from DB.
*/
public static function getAvailableLocales(): array
{
if (!empty(self::$availableLocales)) {
return self::$availableLocales;
}
$db = Database::getInstance();
$rows = $db->select("SELECT code FROM sys_languages WHERE is_active = 1");
self::$availableLocales = array_column($rows, 'code');
if (empty(self::$availableLocales)) {
self::$availableLocales = ['ar', 'en'];
}
return self::$availableLocales;
}
/**
* Load translations for the current locale.
*/
private static function load(): void
{
if (self::$loaded) return;
$db = Database::getInstance();
$rows = $db->select(
"SELECT translation_key, translation_value FROM sys_translations WHERE lang_code = ?",
[self::$locale]
);
foreach ($rows as $row) {
self::$strings[$row['translation_key']] = $row['translation_value'];
}
self::$loaded = true;
}
/**
* Get a translated string, with optional replacements.
* Usage: __('welcome_message', ['name' => 'أحمد'])
*/
public static function get(string $key, array $replacements = []): string
{
self::load();
$str = self::$strings[$key] ?? $key;
foreach ($replacements as $placeholder => $value) {
$str = str_replace(':' . $placeholder, (string)$value, $str);
}
return $str;
}
/**
* Get all translations for a group prefix.
*/
public static function getGroup(string $prefix): array
{
self::load();
return array_filter(self::$strings, fn($k) => str_starts_with($k, $prefix), ARRAY_FILTER_USE_KEY);
}
/**
* Get all loaded translations.
*/
public static function getAll(): array
{
self::load();
return self::$strings;
}
}
\ No newline at end of file
<?php
/**
* Bootstrap — the ONE file that initializes everything.
* Included by index.php and every API endpoint.
*/
// Define root if not already defined
if (!defined('ROOT_PATH')) {
define('ROOT_PATH', dirname(__DIR__));
}
// Error handling
error_reporting(E_ALL);
ini_set('display_errors', '0');
ini_set('log_errors', '1');
// Load core classes
require_once ROOT_PATH . '/core/helpers.php';
require_once ROOT_PATH . '/core/Database.php';
require_once ROOT_PATH . '/core/Session.php';
require_once ROOT_PATH . '/core/Auth.php';
require_once ROOT_PATH . '/core/SystemConfig.php';
require_once ROOT_PATH . '/core/Translation.php';
require_once ROOT_PATH . '/core/AuditTrail.php';
require_once ROOT_PATH . '/core/Notification.php';
require_once ROOT_PATH . '/core/Receipt.php';
require_once ROOT_PATH . '/core/Router.php';
// Timezone
date_default_timezone_set(app_config('timezone', 'Africa/Cairo'));
// Set custom error/exception handlers
set_error_handler(function (int $errno, string $errstr, string $errfile, int $errline) {
app_log('error', "{$errstr} in {$errfile}:{$errline}", ['errno' => $errno]);
if (app_config('debug')) {
throw new \ErrorException($errstr, 0, $errno, $errfile, $errline);
}
return true;
});
set_exception_handler(function (\Throwable $e) {
app_log('error', $e->getMessage(), [
'file' => $e->getFile(),
'line' => $e->getLine(),
'trace' => $e->getTraceAsString(),
]);
if (is_ajax()) {
json_response(['success' => false, 'message' => 'Internal server error'], 500);
}
http_response_code(500);
if (file_exists(ROOT_PATH . '/pages/errors/500.php')) {
include ROOT_PATH . '/pages/errors/500.php';
} else {
echo '<h1>500 — Internal Server Error</h1>';
}
exit;
});
// Start session
Session::start();
// Restore locale from session
$sessionLocale = Session::get('locale', app_config('locale', 'ar'));
Translation::setLocale($sessionLocale);
// Set character encoding
mb_internal_encoding('UTF-8');
header('Content-Type: text/html; charset=UTF-8');
\ No newline at end of file
<?php
/**
* Global Helper Functions — available everywhere after bootstrap.
*/
// ──────────────────────────────────────────────
// CONFIG
// ──────────────────────────────────────────────
/**
* Get an app config value using dot notation.
* Example: app_config('session.lifetime')
*/
function app_config(string $key, $default = null)
{
static $config = null;
if ($config === null) {
$config = require ROOT_PATH . '/config/app.php';
}
$keys = explode('.', $key);
$value = $config;
foreach ($keys as $k) {
if (!is_array($value) || !array_key_exists($k, $value)) {
return $default;
}
$value = $value[$k];
}
return $value;
}
/**
* Get a system config value from the database.
*/
function sys_config(string $key, $default = null)
{
return SystemConfig::get($key, $default);
}
// ──────────────────────────────────────────────
// DATABASE SHORTCUTS
// ──────────────────────────────────────────────
function db(): Database
{
return Database::getInstance();
}
function db_select(string $sql, array $params = []): array
{
return Database::getInstance()->select($sql, $params);
}
function db_select_one(string $sql, array $params = []): ?array
{
return Database::getInstance()->selectOne($sql, $params);
}
function db_insert(string $table, array $data): int
{
return Database::getInstance()->insert($table, $data);
}
function db_update(string $table, array $data, string $where, array $whereParams = []): int
{
return Database::getInstance()->update($table, $data, $where, $whereParams);
}
function db_delete(string $table, string $where, array $whereParams = []): int
{
return Database::getInstance()->delete($table, $where, $whereParams);
}
function db_count(string $table, string $where = '1=1', array $params = []): int
{
return Database::getInstance()->count($table, $where, $params);
}
function db_exists(string $table, string $where, array $params = []): bool
{
return Database::getInstance()->exists($table, $where, $params);
}
function db_scalar(string $sql, array $params = [])
{
return Database::getInstance()->scalar($sql, $params);
}
// ──────────────────────────────────────────────
// AUTH SHORTCUTS
// ──────────────────────────────────────────────
function current_user(): ?array
{
return Auth::user();
}
function current_user_id(): ?int
{
return Auth::id();
}
function has_permission(string $permission): bool
{
return Auth::hasPermission($permission);
}
function require_permission(string $permission): void
{
Auth::requirePermission($permission);
}
// ──────────────────────────────────────────────
// TRANSLATION
// ──────────────────────────────────────────────
/**
* Translate a key. The single most-used function in the app.
*/
function __(?string $key, array $replacements = []): string
{
if ($key === null) return '';
return Translation::get($key, $replacements);
}
function current_locale(): string
{
return Translation::getLocale();
}
function is_rtl(): bool
{
return Translation::isRtl();
}
function dir_attr(): string
{
return is_rtl() ? 'rtl' : 'ltr';
}
function lang_attr(): string
{
return current_locale();
}
/**
* Get a value by locale: returns Arabic or English based on current locale.
*/
function localized(?string $ar, ?string $en): string
{
if (current_locale() === 'ar') {
return $ar ?? $en ?? '';
}
return $en ?? $ar ?? '';
}
// ──────────────────────────────────────────────
// SECURITY
// ──────────────────────────────────────────────
/**
* Generate a CSRF token and store it in session.
*/
function csrf_token(): string
{
$token = Session::get('csrf_token');
$time = Session::get('csrf_token_time', 0);
if (!$token || (time() - $time) > app_config('security.csrf_token_lifetime')) {
$token = bin2hex(random_bytes(32));
Session::set('csrf_token', $token);
Session::set('csrf_token_time', time());
}
return $token;
}
/**
* Output a hidden CSRF input field.
*/
function csrf_field(): string
{
return '<input type="hidden" name="_token" value="' . e(csrf_token()) . '">';
}
/**
* Verify a CSRF token.
*/
function csrf_verify(?string $token = null): bool
{
$token = $token ?? ($_POST['_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '');
$sessionToken = Session::get('csrf_token', '');
return $token !== '' && hash_equals($sessionToken, $token);
}
/**
* Require CSRF token — abort with 403 if invalid.
*/
function csrf_require(): void
{
if (!csrf_verify()) {
http_response_code(403);
if (is_ajax()) {
json_response(['success' => false, 'message' => 'Invalid CSRF token'], 403);
}
die('CSRF token mismatch');
}
}
// ──────────────────────────────────────────────
// OUTPUT / ESCAPING
// ──────────────────────────────────────────────
/**
* HTML-escape a string.
*/
function e(?string $value): string
{
if ($value === null) return '';
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
/**
* Output an HTML-escaped string.
*/
function ee(?string $value): void
{
echo e($value);
}
/**
* Sanitize input — trim and basic clean.
*/
function sanitize_input($input)
{
if (is_array($input)) {
return array_map('sanitize_input', $input);
}
if (!is_string($input)) return $input;
return trim(strip_tags($input));
}
/**
* Get a sanitized input value from POST/GET.
*/
function input(string $key, $default = null, string $method = 'REQUEST')
{
$source = match (strtoupper($method)) {
'POST' => $_POST,
'GET' => $_GET,
default => $_REQUEST,
};
$value = $source[$key] ?? $default;
return is_string($value) ? trim($value) : $value;
}
/**
* Get sanitized integer input.
*/
function input_int(string $key, ?int $default = null, string $method = 'REQUEST'): ?int
{
$value = input($key, null, $method);
return $value !== null ? (int)$value : $default;
}
// ──────────────────────────────────────────────
// RESPONSE
// ──────────────────────────────────────────────
/**
* Redirect to a URL.
*/
function redirect(string $url, int $code = 302): void
{
header("Location: {$url}", true, $code);
exit;
}
/**
* Send a JSON response and exit.
*/
function json_response(array $data, int $code = 200): void
{
http_response_code($code);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
/**
* Set a flash message.
*/
function flash(string $type, string $message): void
{
$existing = Session::get('_flash_messages', []);
$existing[] = ['type' => $type, 'message' => $message];
Session::set('_flash_messages', $existing);
}
/**
* Get and clear flash messages.
*/
function get_flash_messages(): array
{
$messages = Session::get('_flash_messages', []);
Session::remove('_flash_messages');
return $messages;
}
// ──────────────────────────────────────────────
// FORMATTING
// ──────────────────────────────────────────────
/**
* Format a monetary amount for display.
*/
function format_money($amount, bool $withSymbol = true): string
{
$cfg = app_config('currency');
$formatted = number_format(
(float)$amount,
$cfg['decimals'],
$cfg['dec_point'],
$cfg['thousands_sep']
);
if ($withSymbol) {
$symbol = current_locale() === 'ar' ? $cfg['symbol'] : $cfg['symbol_en'];
return $formatted . ' ' . $symbol;
}
return $formatted;
}
/**
* Format a date for display.
*/
function format_date(?string $date): string
{
if (!$date) return '';
$ts = strtotime($date);
return $ts ? date(app_config('date_format.display'), $ts) : $date;
}
/**
* Format a datetime for display.
*/
function format_datetime(?string $datetime): string
{
if (!$datetime) return '';
$ts = strtotime($datetime);
return $ts ? date(app_config('date_format.display_time'), $ts) : $datetime;
}
/**
* Format a date for database storage.
*/
function format_date_db(?string $date): ?string
{
if (!$date) return null;
$ts = strtotime($date);
return $ts ? date('Y-m-d', $ts) : null;
}
/**
* Time ago string (e.g., "3 minutes ago" / "منذ 3 دقائق").
*/
function time_ago(string $datetime): string
{
$diff = time() - strtotime($datetime);
if ($diff < 60) return is_rtl() ? 'الآن' : 'just now';
if ($diff < 3600) {
$m = floor($diff / 60);
return is_rtl() ? "منذ {$m} دقيقة" : "{$m} min ago";
}
if ($diff < 86400) {
$h = floor($diff / 3600);
return is_rtl() ? "منذ {$h} ساعة" : "{$h} hr ago";
}
$d = floor($diff / 86400);
return is_rtl() ? "منذ {$d} يوم" : "{$d} days ago";
}
// ──────────────────────────────────────────────
// VALIDATION
// ──────────────────────────────────────────────
/**
* Validate Egyptian National ID (14 digits).
* Returns extracted data or false.
*/
function validate_national_id(string $id): array|false
{
$id = trim($id);
if (!preg_match('/^[23]\d{13}$/', $id)) {
return false;
}
$century = $id[0] === '2' ? 1900 : 2000;
$year = $century + (int)substr($id, 1, 2);
$month = (int)substr($id, 3, 2);
$day = (int)substr($id, 5, 2);
if (!checkdate($month, $day, $year)) {
return false;
}
$govCode = substr($id, 7, 2);
$validGovCodes = [
'01','02','03','04','11','12','13','14','15','16','17','18','19',
'21','22','23','24','25','26','27','28','29','31','32','33','34','35','88'
];
if (!in_array($govCode, $validGovCodes)) {
return false;
}
$gender = ((int)$id[12] % 2 === 1) ? 'male' : 'female';
return [
'valid' => true,
'date_of_birth' => sprintf('%04d-%02d-%02d', $year, $month, $day),
'gender' => $gender,
'governorate_code'=> $govCode,
];
}
/**
* Validate an email address.
*/
function validate_email(?string $email): bool
{
return $email && filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
}
/**
* Validate a mobile phone (Egyptian format).
*/
function validate_mobile(?string $phone): bool
{
if (!$phone) return false;
$phone = preg_replace('/[\s\-]/', '', $phone);
return (bool)preg_match('/^(?:\+?20)?0?1[0125]\d{8}$/', $phone);
}
// ──────────────────────────────────────────────
// NUMBER SERIES
// ──────────────────────────────────────────────
/**
* Generate the next number in a series.
*/
function generate_number_series(string $seriesCode): string
{
$db = Database::getInstance();
// Lock the row for update
$pdo = $db->getPdo();
$pdo->beginTransaction();
try {
$series = $db->selectOne(
"SELECT * FROM number_series WHERE code = ? FOR UPDATE",
[$seriesCode]
);
if (!$series) {
$pdo->rollBack();
throw new \RuntimeException("Number series not found: {$seriesCode}");
}
$nextVal = (int)$series['current_value'] + 1;
$db->update('number_series', [
'current_value' => $nextVal,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$series['id']]);
$pdo->commit();
// Format the number
$prefix = $series['prefix'] ?? '';
$suffix = $series['suffix'] ?? '';
$padLength = (int)($series['pad_length'] ?? 6);
$separator = $series['separator'] ?? '-';
$includeYear = (int)($series['include_year'] ?? 1);
$parts = [];
if ($prefix) $parts[] = $prefix;
if ($includeYear) $parts[] = date('Y');
$parts[] = str_pad((string)$nextVal, $padLength, '0', STR_PAD_LEFT);
if ($suffix) $parts[] = $suffix;
return implode($separator, $parts);
} catch (\Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
throw $e;
}
}
// ──────────────────────────────────────────────
// FILE UPLOAD
// ──────────────────────────────────────────────
/**
* Handle a file upload.
* Returns the stored path relative to uploads/ or false on failure.
*/
function upload_file(array $file, string $subDir, ?array $allowedExt = null): string|false
{
if ($file['error'] !== UPLOAD_ERR_OK) {
return false;
}
$maxSize = app_config('upload.max_size');
if ($file['size'] > $maxSize) {
return false;
}
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if ($allowedExt === null) {
$allowedExt = array_merge(
app_config('upload.allowed_images'),
app_config('upload.allowed_docs')
);
}
if (!in_array($ext, $allowedExt)) {
return false;
}
$uploadBase = app_config('upload.path');
$targetDir = $uploadBase . '/' . trim($subDir, '/');
if (!is_dir($targetDir)) {
mkdir($targetDir, 0755, true);
}
$filename = bin2hex(random_bytes(16)) . '.' . $ext;
$targetPath = $targetDir . '/' . $filename;
if (move_uploaded_file($file['tmp_name'], $targetPath)) {
return trim($subDir, '/') . '/' . $filename;
}
return false;
}
/**
* Delete an uploaded file.
*/
function delete_upload(string $relativePath): bool
{
$fullPath = app_config('upload.path') . '/' . $relativePath;
if (file_exists($fullPath)) {
return unlink($fullPath);
}
return false;
}
// ──────────────────────────────────────────────
// AUDIT SHORTCUT
// ──────────────────────────────────────────────
function audit_log(string $action, string $table, ?int $recordId = null, $old = null, $new = null, ?string $desc = null): void
{
AuditTrail::log($action, $table, $recordId, $old, $new, $desc);
}
// ──────────────────────────────────────────────
// REQUEST HELPERS
// ──────────────────────────────────────────────
function is_ajax(): bool
{
return (
(isset($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest')
|| (isset($_SERVER['HTTP_ACCEPT']) && str_contains($_SERVER['HTTP_ACCEPT'], 'application/json'))
);
}
function is_post(): bool
{
return $_SERVER['REQUEST_METHOD'] === 'POST';
}
function is_get(): bool
{
return $_SERVER['REQUEST_METHOD'] === 'GET';
}
function base_url(string $path = ''): string
{
$url = rtrim(app_config('url', ''), '/');
return $url . '/' . ltrim($path, '/');
}
function asset_url(string $path): string
{
return base_url('assets/' . ltrim($path, '/'));
}
/**
* Check if the current URL path matches (for active menu highlighting).
*/
function is_active_path(string $path): bool
{
$current = trim(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH), '/');
$check = trim($path, '/');
return $current === $check || str_starts_with($current, $check . '/');
}
function active_class(string $path): string
{
return is_active_path($path) ? 'active' : '';
}
// ──────────────────────────────────────────────
// LOGGING
// ──────────────────────────────────────────────
function app_log(string $level, string $message, array $context = []): void
{
if (!app_config('logging.enabled')) return;
$levels = ['debug' => 0, 'info' => 1, 'warning' => 2, 'error' => 3];
$configLevel = $levels[app_config('logging.level', 'error')] ?? 3;
$msgLevel = $levels[$level] ?? 3;
if ($msgLevel < $configLevel) return;
$logFile = app_config('logging.path');
$dir = dirname($logFile);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
$timestamp = date('Y-m-d H:i:s');
$contextStr = !empty($context) ? ' ' . json_encode($context, JSON_UNESCAPED_UNICODE) : '';
$line = "[{$timestamp}] [{$level}] {$message}{$contextStr}" . PHP_EOL;
file_put_contents($logFile, $line, FILE_APPEND | LOCK_EX);
}
// ──────────────────────────────────────────────
// PAGINATION HELPER
// ──────────────────────────────────────────────
/**
* Build pagination data.
*/
function paginate(int $totalItems, int $currentPage = 1, ?int $perPage = null): array
{
$perPage = $perPage ?? app_config('pagination.per_page');
$perPage = min($perPage, app_config('pagination.max_per_page'));
$totalPages = max(1, (int)ceil($totalItems / $perPage));
$currentPage = max(1, min($currentPage, $totalPages));
$offset = ($currentPage - 1) * $perPage;
return [
'total' => $totalItems,
'per_page' => $perPage,
'current_page' => $currentPage,
'total_pages' => $totalPages,
'offset' => $offset,
'has_prev' => $currentPage > 1,
'has_next' => $currentPage < $totalPages,
];
}
/**
* Render pagination HTML.
*/
function render_pagination(array $pagination, string $baseUrl): string
{
if ($pagination['total_pages'] <= 1) return '';
$html = '<nav aria-label="pagination"><ul class="pagination justify-content-center">';
// Previous
$prevDisabled = $pagination['has_prev'] ? '' : ' disabled';
$prevUrl = $pagination['has_prev'] ? $baseUrl . '?page=' . ($pagination['current_page'] - 1) : '#';
$prevText = is_rtl() ? 'السابق' : 'Previous';
$html .= "<li class='page-item{$prevDisabled}'><a class='page-link' href='{$prevUrl}'>{$prevText}</a></li>";
// Page numbers
$start = max(1, $pagination['current_page'] - 3);
$end = min($pagination['total_pages'], $pagination['current_page'] + 3);
if ($start > 1) {
$html .= "<li class='page-item'><a class='page-link' href='{$baseUrl}?page=1'>1</a></li>";
if ($start > 2) $html .= "<li class='page-item disabled'><span class='page-link'>...</span></li>";
}
for ($i = $start; $i <= $end; $i++) {
$activeClass = $i === $pagination['current_page'] ? ' active' : '';
$html .= "<li class='page-item{$activeClass}'><a class='page-link' href='{$baseUrl}?page={$i}'>{$i}</a></li>";
}
if ($end < $pagination['total_pages']) {
if ($end < $pagination['total_pages'] - 1) $html .= "<li class='page-item disabled'><span class='page-link'>...</span></li>";
$t = $pagination['total_pages'];
$html .= "<li class='page-item'><a class='page-link' href='{$baseUrl}?page={$t}'>{$t}</a></li>";
}
// Next
$nextDisabled = $pagination['has_next'] ? '' : ' disabled';
$nextUrl = $pagination['has_next'] ? $baseUrl . '?page=' . ($pagination['current_page'] + 1) : '#';
$nextText = is_rtl() ? 'التالي' : 'Next';
$html .= "<li class='page-item{$nextDisabled}'><a class='page-link' href='{$nextUrl}'>{$nextText}</a></li>";
$html .= '</ul></nav>';
return $html;
}
// ──────────────────────────────────────────────
// MISCELLANEOUS
// ──────────────────────────────────────────────
/**
* Generate a random token.
*/
function random_token(int $length = 32): string
{
return bin2hex(random_bytes($length));
}
/**
* Get client IP address.
*/
function client_ip(): string
{
return $_SERVER['HTTP_X_FORWARDED_FOR']
?? $_SERVER['HTTP_X_REAL_IP']
?? $_SERVER['REMOTE_ADDR']
?? '0.0.0.0';
}
/**
* Truncate text.
*/
function str_truncate(string $text, int $length = 100, string $suffix = '...'): string
{
return mb_strlen($text) > $length ? mb_substr($text, 0, $length) . $suffix : $text;
}
/**
* Convert status code to badge HTML.
*/
function status_badge(string $status, ?string $label = null): string
{
$colorMap = [
'active' => 'success',
'activated' => 'success',
'approved' => 'success',
'paid' => 'success',
'issued' => 'success',
'pending' => 'warning',
'draft' => 'secondary',
'submitted' => 'info',
'under_review' => 'info',
'suspended' => 'danger',
'rejected' => 'danger',
'cancelled' => 'dark',
'expired' => 'dark',
'frozen' => 'primary',
'terminated' => 'danger',
'archived' => 'secondary',
];
$color = $colorMap[strtolower($status)] ?? 'secondary';
$displayLabel = $label ?? $status;
return "<span class='badge bg-{$color}'>{$displayLabel}</span>";
}
\ No newline at end of file
<?php
/**
* Global Helper Functions — available everywhere after bootstrap.
*/
// ──────────────────────────────────────────────
// CONFIG
// ──────────────────────────────────────────────
/**
* Get an app config value using dot notation.
* Example: app_config('session.lifetime')
*/
function app_config(string $key, $default = null)
{
static $config = null;
if ($config === null) {
$config = require ROOT_PATH . '/config/app.php';
}
$keys = explode('.', $key);
$value = $config;
foreach ($keys as $k) {
if (!is_array($value) || !array_key_exists($k, $value)) {
return $default;
}
$value = $value[$k];
}
return $value;
}
/**
* Get a system config value from the database.
*/
function sys_config(string $key, $default = null)
{
return SystemConfig::get($key, $default);
}
// ──────────────────────────────────────────────
// DATABASE SHORTCUTS
// ──────────────────────────────────────────────
function db(): Database
{
return Database::getInstance();
}
function db_select(string $sql, array $params = []): array
{
return Database::getInstance()->select($sql, $params);
}
function db_select_one(string $sql, array $params = []): ?array
{
return Database::getInstance()->selectOne($sql, $params);
}
function db_insert(string $table, array $data): int
{
return Database::getInstance()->insert($table, $data);
}
function db_update(string $table, array $data, string $where, array $whereParams = []): int
{
return Database::getInstance()->update($table, $data, $where, $whereParams);
}
function db_delete(string $table, string $where, array $whereParams = []): int
{
return Database::getInstance()->delete($table, $where, $whereParams);
}
function db_count(string $table, string $where = '1=1', array $params = []): int
{
return Database::getInstance()->count($table, $where, $params);
}
function db_exists(string $table, string $where, array $params = []): bool
{
return Database::getInstance()->exists($table, $where, $params);
}
function db_scalar(string $sql, array $params = [])
{
return Database::getInstance()->scalar($sql, $params);
}
// ──────────────────────────────────────────────
// AUTH SHORTCUTS
// ──────────────────────────────────────────────
function current_user(): ?array
{
return Auth::user();
}
function current_user_id(): ?int
{
return Auth::id();
}
function has_permission(string $permission): bool
{
return Auth::hasPermission($permission);
}
function require_permission(string $permission): void
{
Auth::requirePermission($permission);
}
// ──────────────────────────────────────────────
// TRANSLATION
// ──────────────────────────────────────────────
/**
* Translate a key. The single most-used function in the app.
*/
function __(?string $key, array $replacements = []): string
{
if ($key === null) return '';
return Translation::get($key, $replacements);
}
function current_locale(): string
{
return Translation::getLocale();
}
function is_rtl(): bool
{
return Translation::isRtl();
}
function dir_attr(): string
{
return is_rtl() ? 'rtl' : 'ltr';
}
function lang_attr(): string
{
return current_locale();
}
/**
* Get a value by locale: returns Arabic or English based on current locale.
*/
function localized(?string $ar, ?string $en): string
{
if (current_locale() === 'ar') {
return $ar ?? $en ?? '';
}
return $en ?? $ar ?? '';
}
// ──────────────────────────────────────────────
// SECURITY
// ──────────────────────────────────────────────
/**
* Generate a CSRF token and store it in session.
*/
function csrf_token(): string
{
$token = Session::get('csrf_token');
$time = Session::get('csrf_token_time', 0);
if (!$token || (time() - $time) > app_config('security.csrf_token_lifetime')) {
$token = bin2hex(random_bytes(32));
Session::set('csrf_token', $token);
Session::set('csrf_token_time', time());
}
return $token;
}
/**
* Output a hidden CSRF input field.
*/
function csrf_field(): string
{
return '<input type="hidden" name="_token" value="' . e(csrf_token()) . '">';
}
/**
* Verify a CSRF token.
*/
function csrf_verify(?string $token = null): bool
{
$token = $token ?? ($_POST['_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '');
$sessionToken = Session::get('csrf_token', '');
return $token !== '' && hash_equals($sessionToken, $token);
}
/**
* Require CSRF token — abort with 403 if invalid.
*/
function csrf_require(): void
{
if (!csrf_verify()) {
http_response_code(403);
if (is_ajax()) {
json_response(['success' => false, 'message' => 'Invalid CSRF token'], 403);
}
die('CSRF token mismatch');
}
}
// ──────────────────────────────────────────────
// OUTPUT / ESCAPING
// ──────────────────────────────────────────────
/**
* HTML-escape a string.
*/
function e(?string $value): string
{
if ($value === null) return '';
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
/**
* Output an HTML-escaped string.
*/
function ee(?string $value): void
{
echo e($value);
}
/**
* Sanitize input — trim and basic clean.
*/
function sanitize_input($input)
{
if (is_array($input)) {
return array_map('sanitize_input', $input);
}
if (!is_string($input)) return $input;
return trim(strip_tags($input));
}
/**
* Get a sanitized input value from POST/GET.
*/
function input(string $key, $default = null, string $method = 'REQUEST')
{
$source = match (strtoupper($method)) {
'POST' => $_POST,
'GET' => $_GET,
default => $_REQUEST,
};
$value = $source[$key] ?? $default;
return is_string($value) ? trim($value) : $value;
}
/**
* Get sanitized integer input.
*/
function input_int(string $key, ?int $default = null, string $method = 'REQUEST'): ?int
{
$value = input($key, null, $method);
return $value !== null && $value !== '' ? (int)$value : $default;
}
// ──────────────────────────────────────────────
// RESPONSE
// ──────────────────────────────────────────────
/**
* Redirect to a URL.
*/
function redirect(string $url, int $code = 302): void
{
header("Location: {$url}", true, $code);
exit;
}
/**
* Send a JSON response and exit.
*/
function json_response(array $data, int $code = 200): void
{
http_response_code($code);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
/**
* Set a flash message.
*/
function flash(string $type, string $message): void
{
$existing = Session::get('_flash_messages', []);
$existing[] = ['type' => $type, 'message' => $message];
Session::set('_flash_messages', $existing);
}
/**
* Get and clear flash messages.
*/
function get_flash_messages(): array
{
$messages = Session::get('_flash_messages', []);
Session::remove('_flash_messages');
return $messages;
}
// ──────────────────────────────────────────────
// FORMATTING
// ──────────────────────────────────────────────
/**
* Format a monetary amount for display.
*/
function format_money($amount, bool $withSymbol = true): string
{
$cfg = app_config('currency');
$formatted = number_format(
(float)$amount,
$cfg['decimals'],
$cfg['dec_point'],
$cfg['thousands_sep']
);
if ($withSymbol) {
$symbol = current_locale() === 'ar' ? $cfg['symbol'] : $cfg['symbol_en'];
return $formatted . ' ' . $symbol;
}
return $formatted;
}
/**
* Format a date for display.
*/
function format_date(?string $date): string
{
if (!$date) return '';
$ts = strtotime($date);
return $ts ? date(app_config('date_format.display'), $ts) : $date;
}
/**
* Format a datetime for display.
*/
function format_datetime(?string $datetime): string
{
if (!$datetime) return '';
$ts = strtotime($datetime);
return $ts ? date(app_config('date_format.display_time'), $ts) : $datetime;
}
/**
* Format a date for database storage.
*/
function format_date_db(?string $date): ?string
{
if (!$date) return null;
$ts = strtotime($date);
return $ts ? date('Y-m-d', $ts) : null;
}
/**
* Time ago string (e.g., "3 minutes ago" / "منذ 3 دقائق").
*/
function time_ago(string $datetime): string
{
$diff = time() - strtotime($datetime);
if ($diff < 60) return is_rtl() ? 'الآن' : 'just now';
if ($diff < 3600) {
$m = floor($diff / 60);
return is_rtl() ? "منذ {$m} دقيقة" : "{$m} min ago";
}
if ($diff < 86400) {
$h = floor($diff / 3600);
return is_rtl() ? "منذ {$h} ساعة" : "{$h} hr ago";
}
$d = floor($diff / 86400);
return is_rtl() ? "منذ {$d} يوم" : "{$d} days ago";
}
// ──────────────────────────────────────────────
// VALIDATION
// ──────────────────────────────────────────────
/**
* Validate Egyptian National ID (14 digits).
* Returns extracted data or false.
*/
function validate_national_id(string $id): array|false
{
$id = trim($id);
if (!preg_match('/^[23]\d{13}$/', $id)) {
return false;
}
$century = $id[0] === '2' ? 1900 : 2000;
$year = $century + (int)substr($id, 1, 2);
$month = (int)substr($id, 3, 2);
$day = (int)substr($id, 5, 2);
if (!checkdate($month, $day, $year)) {
return false;
}
$govCode = substr($id, 7, 2);
$validGovCodes = [
'01','02','03','04','11','12','13','14','15','16','17','18','19',
'21','22','23','24','25','26','27','28','29','31','32','33','34','35','88'
];
if (!in_array($govCode, $validGovCodes)) {
return false;
}
$gender = ((int)$id[12] % 2 === 1) ? 'male' : 'female';
return [
'valid' => true,
'date_of_birth' => sprintf('%04d-%02d-%02d', $year, $month, $day),
'gender' => $gender,
'governorate_code'=> $govCode,
];
}
/**
* Validate an email address.
*/
function validate_email(?string $email): bool
{
return $email && filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
}
/**
* Validate a mobile phone (Egyptian format).
*/
function validate_mobile(?string $phone): bool
{
if (!$phone) return false;
$phone = preg_replace('/[\s\-]/', '', $phone);
return (bool)preg_match('/^(?:\+?20)?0?1[0125]\d{8}$/', $phone);
}
// ──────────────────────────────────────────────
// NUMBER SERIES (FIXED — uses Database savepoints, no raw PDO)
// ──────────────────────────────────────────────
/**
* Generate the next number in a series.
* Safe to call inside or outside an existing transaction.
*/
function generate_number_series(string $seriesCode): string
{
$db = Database::getInstance();
$db->beginTransaction();
try {
$series = $db->selectOne(
"SELECT * FROM number_series WHERE code = ? FOR UPDATE",
[$seriesCode]
);
if (!$series) {
$db->rollback();
throw new \RuntimeException("Number series not found: {$seriesCode}");
}
$nextVal = (int)$series['current_value'] + 1;
$db->update('number_series', [
'current_value' => $nextVal,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$series['id']]);
$db->commit();
// Format the number
$prefix = $series['prefix'] ?? '';
$suffix = $series['suffix'] ?? '';
$padLength = (int)($series['pad_length'] ?? 6);
$separator = $series['separator'] ?? '-';
$includeYear = (int)($series['include_year'] ?? 1);
$parts = [];
if ($prefix) $parts[] = $prefix;
if ($includeYear) $parts[] = date('Y');
$parts[] = str_pad((string)$nextVal, $padLength, '0', STR_PAD_LEFT);
if ($suffix) $parts[] = $suffix;
return implode($separator, $parts);
} catch (\Throwable $e) {
$db->rollback();
throw $e;
}
}
// ──────────────────────────────────────────────
// FILE UPLOAD
// ──────────────────────────────────────────────
/**
* Handle a file upload.
* Returns the stored path relative to uploads/ or false on failure.
*/
function upload_file(array $file, string $subDir, ?array $allowedExt = null): string|false
{
if ($file['error'] !== UPLOAD_ERR_OK) {
return false;
}
$maxSize = app_config('upload.max_size');
if ($file['size'] > $maxSize) {
return false;
}
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if ($allowedExt === null) {
$allowedExt = array_merge(
app_config('upload.allowed_images'),
app_config('upload.allowed_docs')
);
}
if (!in_array($ext, $allowedExt)) {
return false;
}
// Verify MIME type matches extension for images
$imageExts = app_config('upload.allowed_images');
if (in_array($ext, $imageExts)) {
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($file['tmp_name']);
$allowedMimes = [
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'webp' => 'image/webp',
];
if (!isset($allowedMimes[$ext]) || $allowedMimes[$ext] !== $mimeType) {
return false;
}
}
$uploadBase = app_config('upload.path');
$targetDir = $uploadBase . '/' . trim($subDir, '/');
if (!is_dir($targetDir)) {
mkdir($targetDir, 0755, true);
}
$filename = bin2hex(random_bytes(16)) . '.' . $ext;
$targetPath = $targetDir . '/' . $filename;
if (move_uploaded_file($file['tmp_name'], $targetPath)) {
return trim($subDir, '/') . '/' . $filename;
}
return false;
}
/**
* Delete an uploaded file.
*/
function delete_upload(string $relativePath): bool
{
$fullPath = app_config('upload.path') . '/' . $relativePath;
if (file_exists($fullPath)) {
return unlink($fullPath);
}
return false;
}
/**
* Get the full filesystem path of an uploaded file (for serving).
*/
function get_upload_path(string $relativePath): ?string
{
$fullPath = app_config('upload.path') . '/' . $relativePath;
if (file_exists($fullPath) && is_file($fullPath)) {
return $fullPath;
}
return null;
}
// ──────────────────────────────────────────────
// AUDIT SHORTCUT
// ──────────────────────────────────────────────
function audit_log(string $action, string $table, ?int $recordId = null, $old = null, $new = null, ?string $desc = null): void
{
AuditTrail::log($action, $table, $recordId, $old, $new, $desc);
}
// ──────────────────────────────────────────────
// REQUEST HELPERS
// ──────────────────────────────────────────────
function is_ajax(): bool
{
return (
(isset($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest')
|| (isset($_SERVER['HTTP_ACCEPT']) && str_contains($_SERVER['HTTP_ACCEPT'], 'application/json'))
);
}
function is_post(): bool
{
return $_SERVER['REQUEST_METHOD'] === 'POST';
}
function is_get(): bool
{
return $_SERVER['REQUEST_METHOD'] === 'GET';
}
function is_delete(): bool
{
return $_SERVER['REQUEST_METHOD'] === 'DELETE';
}
function is_put(): bool
{
return $_SERVER['REQUEST_METHOD'] === 'PUT';
}
function base_url(string $path = ''): string
{
$url = rtrim(app_config('url', ''), '/');
return $url . '/' . ltrim($path, '/');
}
function asset_url(string $path): string
{
return base_url('assets/' . ltrim($path, '/'));
}
/**
* Check if the current URL path matches (for active menu highlighting).
*/
function is_active_path(string $path): bool
{
$current = trim(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH), '/');
$check = trim($path, '/');
return $current === $check || str_starts_with($current, $check . '/');
}
function active_class(string $path): string
{
return is_active_path($path) ? 'active' : '';
}
// ──────────────────────────────────────────────
// LOGGING
// ──────────────────────────────────────────────
function app_log(string $level, string $message, array $context = []): void
{
if (!app_config('logging.enabled')) return;
$levels = ['debug' => 0, 'info' => 1, 'warning' => 2, 'error' => 3];
$configLevel = $levels[app_config('logging.level', 'error')] ?? 3;
$msgLevel = $levels[$level] ?? 3;
if ($msgLevel < $configLevel) return;
$logFile = app_config('logging.path');
$dir = dirname($logFile);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
$timestamp = date('Y-m-d H:i:s');
$contextStr = !empty($context) ? ' ' . json_encode($context, JSON_UNESCAPED_UNICODE) : '';
$line = "[{$timestamp}] [{$level}] {$message}{$contextStr}" . PHP_EOL;
file_put_contents($logFile, $line, FILE_APPEND | LOCK_EX);
}
// ──────────────────────────────────────────────
// PAGINATION HELPER
// ──────────────────────────────────────────────
/**
* Build pagination data.
*/
function paginate(int $totalItems, int $currentPage = 1, ?int $perPage = null): array
{
$perPage = $perPage ?? app_config('pagination.per_page');
$perPage = min($perPage, app_config('pagination.max_per_page'));
$totalPages = max(1, (int)ceil($totalItems / $perPage));
$currentPage = max(1, min($currentPage, $totalPages));
$offset = ($currentPage - 1) * $perPage;
return [
'total' => $totalItems,
'per_page' => $perPage,
'current_page' => $currentPage,
'total_pages' => $totalPages,
'offset' => $offset,
'has_prev' => $currentPage > 1,
'has_next' => $currentPage < $totalPages,
];
}
/**
* Render pagination HTML.
*/
function render_pagination(array $pagination, string $baseUrl): string
{
if ($pagination['total_pages'] <= 1) return '';
// Preserve existing query string parameters
$parsedUrl = parse_url($baseUrl);
$basePath = $parsedUrl['path'] ?? $baseUrl;
parse_str($parsedUrl['query'] ?? '', $existingParams);
$html = '<nav aria-label="pagination"><ul class="pagination justify-content-center">';
// Previous
$prevDisabled = $pagination['has_prev'] ? '' : ' disabled';
$prevParams = array_merge($existingParams, ['page' => $pagination['current_page'] - 1]);
$prevUrl = $pagination['has_prev'] ? $basePath . '?' . http_build_query($prevParams) : '#';
$prevText = is_rtl() ? 'السابق' : 'Previous';
$html .= "<li class='page-item{$prevDisabled}'><a class='page-link' href='" . e($prevUrl) . "'>{$prevText}</a></li>";
// Page numbers
$start = max(1, $pagination['current_page'] - 3);
$end = min($pagination['total_pages'], $pagination['current_page'] + 3);
if ($start > 1) {
$p = array_merge($existingParams, ['page' => 1]);
$html .= "<li class='page-item'><a class='page-link' href='" . e($basePath . '?' . http_build_query($p)) . "'>1</a></li>";
if ($start > 2) $html .= "<li class='page-item disabled'><span class='page-link'>...</span></li>";
}
for ($i = $start; $i <= $end; $i++) {
$activeClass = $i === $pagination['current_page'] ? ' active' : '';
$p = array_merge($existingParams, ['page' => $i]);
$html .= "<li class='page-item{$activeClass}'><a class='page-link' href='" . e($basePath . '?' . http_build_query($p)) . "'>{$i}</a></li>";
}
if ($end < $pagination['total_pages']) {
if ($end < $pagination['total_pages'] - 1) $html .= "<li class='page-item disabled'><span class='page-link'>...</span></li>";
$t = $pagination['total_pages'];
$p = array_merge($existingParams, ['page' => $t]);
$html .= "<li class='page-item'><a class='page-link' href='" . e($basePath . '?' . http_build_query($p)) . "'>{$t}</a></li>";
}
// Next
$nextDisabled = $pagination['has_next'] ? '' : ' disabled';
$nextParams = array_merge($existingParams, ['page' => $pagination['current_page'] + 1]);
$nextUrl = $pagination['has_next'] ? $basePath . '?' . http_build_query($nextParams) : '#';
$nextText = is_rtl() ? 'التالي' : 'Next';
$html .= "<li class='page-item{$nextDisabled}'><a class='page-link' href='" . e($nextUrl) . "'>{$nextText}</a></li>";
$html .= '</ul></nav>';
return $html;
}
// ──────────────────────────────────────────────
// MISCELLANEOUS
// ──────────────────────────────────────────────
/**
* Generate a random token.
*/
function random_token(int $length = 32): string
{
return bin2hex(random_bytes($length));
}
/**
* Get client IP address.
*/
function client_ip(): string
{
return $_SERVER['HTTP_X_FORWARDED_FOR']
?? $_SERVER['HTTP_X_REAL_IP']
?? $_SERVER['REMOTE_ADDR']
?? '0.0.0.0';
}
/**
* Truncate text.
*/
function str_truncate(string $text, int $length = 100, string $suffix = '...'): string
{
return mb_strlen($text) > $length ? mb_substr($text, 0, $length) . $suffix : $text;
}
/**
* Convert status code to badge HTML.
*/
function status_badge(string $status, ?string $label = null): string
{
$colorMap = [
'active' => 'success',
'activated' => 'success',
'approved' => 'success',
'paid' => 'success',
'issued' => 'success',
'completed' => 'success',
'pending' => 'warning',
'in_progress' => 'warning',
'draft' => 'secondary',
'submitted' => 'info',
'under_review' => 'info',
'processing' => 'info',
'suspended' => 'danger',
'rejected' => 'danger',
'overdue' => 'danger',
'cancelled' => 'dark',
'expired' => 'dark',
'voided' => 'dark',
'frozen' => 'primary',
'terminated' => 'danger',
'archived' => 'secondary',
'inactive' => 'secondary',
];
$color = $colorMap[strtolower($status)] ?? 'secondary';
$displayLabel = e($label ?? $status);
return "<span class='badge bg-{$color}'>{$displayLabel}</span>";
}
/**
* Build a "data-attributes" string from an array.
* Usage: data_attrs(['id' => 5, 'name' => 'test']) => 'data-id="5" data-name="test"'
*/
function data_attrs(array $attrs): string
{
$parts = [];
foreach ($attrs as $key => $val) {
$parts[] = 'data-' . e($key) . '="' . e((string)$val) . '"';
}
return implode(' ', $parts);
}
/**
* Get the HTTP JSON request body as array.
*/
function json_input(): array
{
$raw = file_get_contents('php://input');
if (empty($raw)) return [];
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : [];
}
/**
* Get JSON input merged with POST.
*/
function all_input(): array
{
return array_merge($_POST, json_input());
}
\ No newline at end of file
<?php
// ADD THIS FUNCTION to core/helpers.php inside the FILE UPLOAD section,
// right after the get_upload_path() function:
/**
* Generate a URL to serve an uploaded file through the secure endpoint.
* Usage in templates: <img src="<?= upload_url('members/photos/abc.jpg') ?>">
*/
function upload_url(?string $relativePath): string
{
if (!$relativePath) return '';
return '/api/uploads/serve?path=' . urlencode($relativePath);
}
\ No newline at end of file
<?php
// THIS IS NOT A SEPARATE FILE — these lines go into core/helpers.php
// in the FILE UPLOAD section. They are already included in the
// replacement helpers.php above. Shown separately for clarity.
/**
* Generate a URL to serve an uploaded file through the secure endpoint.
* Usage in templates: <img src="<?= upload_url('members/photos/abc.jpg') ?>">
*/
function upload_url(?string $relativePath): string
{
if (!$relativePath) return '';
return '/api/uploads/serve?path=' . urlencode($relativePath);
}
\ No newline at end of file
<?php
/**
* AL-ARCADE Club Management ERP — Main Entry Point
* ALL page requests funnel through here.
*/
define('ROOT_PATH', __DIR__);
require_once ROOT_PATH . '/core/bootstrap.php';
$router = new Router();
$router->dispatch();
\ No newline at end of file
Deny from all
\ No newline at end of file
<?php
/**
* Dashboard — main landing page after login.
* Loads widgets from /pages/dashboard/widgets/*.php
*/
$pageTitle = is_rtl() ? 'لوحة التحكم' : 'Dashboard';
$breadcrumbs = [
['label' => is_rtl() ? 'الرئيسية' : 'Home', 'url' => '/dashboard'],
];
include ROOT_PATH . '/templates/header.php';
$user = current_user();
?>
<div class="container-fluid">
<div class="page-header">
<h1 class="page-header-title">
<?= e(is_rtl() ? 'مرحباً، ' . ($user['full_name_ar'] ?? $user['username']) : 'Welcome, ' . ($user['full_name'] ?? $user['username'])) ?>
</h1>
<div class="page-header-actions">
<span class="text-muted"><?= e(format_datetime(date('Y-m-d H:i:s'))) ?></span>
</div>
</div>
<!-- Dashboard Widgets Area -->
<div class="row" id="dashboardWidgets">
<?php
// Load all widget files from the widgets directory
$widgetsDir = ROOT_PATH . '/pages/dashboard/widgets';
if (is_dir($widgetsDir)) {
$widgetFiles = glob($widgetsDir . '/*.php');
sort($widgetFiles);
foreach ($widgetFiles as $widgetFile) {
include $widgetFile;
}
}
?>
</div>
</div>
<?php include ROOT_PATH . '/templates/footer.php'; ?>
\ No newline at end of file
<?php
/**
* Dashboard Widget: System Info
* Phase 0 built-in widget — shows PHP version, DB status, etc.
*/
if (!has_permission('admin.access')) return;
$dbOk = true;
try {
db_scalar("SELECT 1");
} catch (\Throwable $e) {
$dbOk = false;
}
?>
<div class="col-lg-3 col-md-6">
<div class="stat-card bg-info-gradient">
<div class="stat-card-icon"><i class="fas fa-server"></i></div>
<div class="stat-card-value"><?= e(app_config('version')) ?></div>
<div class="stat-card-label"><?= e(is_rtl() ? 'إصدار النظام' : 'System Version') ?></div>
</div>
</div>
<div class="col-lg-3 col-md-6">
<div class="stat-card <?= $dbOk ? 'bg-success-gradient' : 'bg-danger-gradient' ?>">
<div class="stat-card-icon"><i class="fas fa-database"></i></div>
<div class="stat-card-value"><?= $dbOk ? (is_rtl() ? 'متصل' : 'Online') : (is_rtl() ? 'منقطع' : 'Offline') ?></div>
<div class="stat-card-label"><?= e(is_rtl() ? 'قاعدة البيانات' : 'Database') ?></div>
</div>
</div>
\ No newline at end of file
<?php
/**
* Dashboard Widget: Welcome / Quick Stats
* Phase 0 built-in widget.
*/
$totalUsers = db_count('users', 'deleted_at IS NULL AND is_active = 1');
?>
<!-- Welcome Widget -->
<div class="col-lg-3 col-md-6">
<div class="stat-card bg-primary-gradient">
<div class="stat-card-icon"><i class="fas fa-users"></i></div>
<div class="stat-card-value"><?= number_format($totalUsers) ?></div>
<div class="stat-card-label"><?= e(is_rtl() ? 'المستخدمين النشطين' : 'Active Users') ?></div>
</div>
</div>
\ No newline at end of file
<?php
/**
* 403 Forbidden
*/
http_response_code(403);
$pageTitle = is_rtl() ? 'غير مصرح' : 'Forbidden';
if (Auth::check()) {
include ROOT_PATH . '/templates/header.php';
}
?>
<?php if (!Auth::check()): ?>
<!DOCTYPE html>
<html lang="<?= lang_attr() ?>" dir="<?= dir_attr() ?>">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>403</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Cairo:wght@400;700&display=swap" rel="stylesheet">
<style>body{font-family:'Cairo',sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;background:#f1f5f9;}</style>
</head><body>
<?php endif; ?>
<div class="container-fluid">
<div class="empty-state">
<div class="empty-state-icon"><i class="fas fa-lock"></i></div>
<h1 style="font-size:64px;font-weight:700;color:#e74c3c;">403</h1>
<div class="empty-state-title"><?= e(is_rtl() ? 'غير مصرح لك بالوصول' : 'Access Denied') ?></div>
<p class="text-muted"><?= e(is_rtl() ? 'ليس لديك الصلاحية للوصول إلى هذه الصفحة.' : 'You do not have permission to access this page.') ?></p>
<a href="/dashboard" class="btn btn-primary btn-icon mt-3">
<i class="fas fa-home"></i> <?= e(is_rtl() ? 'العودة للرئيسية' : 'Back to Dashboard') ?>
</a>
</div>
</div>
<?php if (Auth::check()): ?>
<?php include ROOT_PATH . '/templates/footer.php'; ?>
<?php else: ?>
</body></html>
<?php endif; ?>
\ No newline at end of file
<?php
/**
* 404 Not Found
*/
http_response_code(404);
$pageTitle = is_rtl() ? 'الصفحة غير موجودة' : 'Page Not Found';
if (Auth::check()) {
include ROOT_PATH . '/templates/header.php';
}
?>
<?php if (!Auth::check()): ?>
<!DOCTYPE html>
<html lang="<?= lang_attr() ?>" dir="<?= dir_attr() ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>404</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Cairo:wght@400;700&display=swap" rel="stylesheet">
<style>body{font-family:'Cairo',sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;background:#f1f5f9;}</style>
</head>
<body>
<?php endif; ?>
<div class="container-fluid">
<div class="empty-state">
<div class="empty-state-icon"><i class="fas fa-search"></i></div>
<h1 style="font-size:64px;font-weight:700;color:#1a5276;">404</h1>
<div class="empty-state-title"><?= e(is_rtl() ? 'الصفحة غير موجودة' : 'Page Not Found') ?></div>
<p class="text-muted"><?= e(is_rtl() ? 'الصفحة التي تبحث عنها غير موجودة أو تم نقلها.' : 'The page you are looking for does not exist or has been moved.') ?></p>
<a href="/dashboard" class="btn btn-primary btn-icon mt-3">
<i class="fas fa-home"></i> <?= e(is_rtl() ? 'العودة للرئيسية' : 'Back to Dashboard') ?>
</a>
</div>
</div>
<?php if (Auth::check()): ?>
<?php include ROOT_PATH . '/templates/footer.php'; ?>
<?php else: ?>
</body></html>
<?php endif; ?>
\ No newline at end of file
<?php
/**
* 500 Internal Server Error
*/
if (!http_response_code()) {
http_response_code(500);
}
?>
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>500 — Server Error</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.rtl.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Cairo:wght@400;700&display=swap" rel="stylesheet">
<style>
body { font-family: 'Cairo', sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; background: #f1f5f9; text-align: center; }
</style>
</head>
<body>
<div>
<div style="font-size:48px;color:#64748b;margin-bottom:16px;"><i class="fas fa-exclamation-triangle"></i></div>
<h1 style="font-size:64px;font-weight:700;color:#e74c3c;">500</h1>
<h3>حدث خطأ في الخادم</h3>
<p class="text-muted">نعتذر عن هذا الخطأ. يرجى المحاولة لاحقاً.</p>
<p class="text-muted">An internal server error occurred. Please try again later.</p>
<a href="/dashboard" class="btn btn-primary mt-3"><i class="fas fa-home"></i> العودة للرئيسية</a>
</div>
</body>
</html>
\ No newline at end of file
<?php
/**
* Login Page — standalone (no sidebar/navbar).
*/
// If already logged in, redirect to dashboard
if (Auth::check()) {
redirect('/dashboard');
}
$error = Session::getFlash('error');
$success = Session::getFlash('success');
// Handle POST login
if (is_post()) {
csrf_require();
$username = input('username', '', 'POST');
$password = $_POST['password'] ?? '';
if (empty($username) || empty($password)) {
$error = is_rtl() ? 'يرجى إدخال اسم المستخدم وكلمة المرور' : 'Please enter username and password';
} else {
$result = Auth::attempt($username, $password, client_ip(), $_SERVER['HTTP_USER_AGENT'] ?? '');
if ($result['success']) {
redirect('/dashboard');
} else {
$error = is_rtl() ? $result['message'] : $result['message_en'];
}
}
}
$direction = dir_attr();
$lang = lang_attr();
?>
<!DOCTYPE html>
<html lang="<?= $lang ?>" dir="<?= $direction ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= e(is_rtl() ? 'تسجيل الدخول' : 'Login') ?><?= e(is_rtl() ? app_config('name_ar') : app_config('name')) ?></title>
<?php if (is_rtl()): ?>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.rtl.min.css" rel="stylesheet">
<?php else: ?>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<?php endif; ?>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Cairo:wght@400;600;700&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
<link href="<?= asset_url('css/login.css') ?>" rel="stylesheet">
</head>
<body class="login-page">
<div class="login-card">
<div class="login-header">
<div class="login-header-icon">
<i class="fas fa-trophy"></i>
</div>
<h1><?= e(is_rtl() ? app_config('name_ar') : app_config('name')) ?></h1>
<p><?= e(is_rtl() ? 'تسجيل الدخول إلى لوحة التحكم' : 'Sign in to your dashboard') ?></p>
</div>
<div class="login-body">
<?php if ($error): ?>
<div class="login-error">
<i class="fas fa-exclamation-circle"></i> <?= e($error) ?>
</div>
<?php endif; ?>
<?php if ($success): ?>
<div class="login-success">
<i class="fas fa-check-circle"></i> <?= e($success) ?>
</div>
<?php endif; ?>
<form method="POST" action="/login" autocomplete="on">
<?= csrf_field() ?>
<div class="mb-3">
<label class="form-label" for="username"><?= e(is_rtl() ? 'اسم المستخدم' : 'Username') ?></label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-user"></i></span>
<input type="text" class="form-control" id="username" name="username"
value="<?= e(input('username', '', 'POST')) ?>"
placeholder="<?= e(is_rtl() ? 'أدخل اسم المستخدم' : 'Enter your username') ?>"
required autofocus autocomplete="username">
</div>
</div>
<div class="mb-3">
<label class="form-label" for="password"><?= e(is_rtl() ? 'كلمة المرور' : 'Password') ?></label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-lock"></i></span>
<input type="password" class="form-control" id="password" name="password"
placeholder="<?= e(is_rtl() ? 'أدخل كلمة المرور' : 'Enter your password') ?>"
required autocomplete="current-password">
</div>
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-sign-in-alt"></i>
<?= e(is_rtl() ? 'تسجيل الدخول' : 'Sign In') ?>
</button>
</form>
</div>
<div class="login-footer">
<a href="/password-reset"><?= e(is_rtl() ? 'نسيت كلمة المرور؟' : 'Forgot your password?') ?></a>
</div>
</div>
</body>
</html>
\ No newline at end of file
<?php
/**
* Logout handler.
*/
Auth::logout();
Session::start();
flash('success', is_rtl() ? 'تم تسجيل الخروج بنجاح' : 'Logged out successfully');
redirect('/login');
\ No newline at end of file
<?php
/**
* Password Reset — Confirm (set new password using token).
*/
if (Auth::check()) {
redirect('/dashboard');
}
$token = input('token', '', 'GET');
$error = null;
$success = null;
if (empty($token)) {
$error = is_rtl() ? 'رابط غير صالح' : 'Invalid link';
} else {
$record = Auth::validatePasswordResetToken($token);
if (!$record) {
$error = is_rtl() ? 'رابط غير صالح أو منتهي الصلاحية' : 'Invalid or expired link';
}
}
if (is_post() && !$error) {
csrf_require();
$password = $_POST['password'] ?? '';
$confirm = $_POST['password_confirm'] ?? '';
if (strlen($password) < app_config('security.password_min_length')) {
$error = is_rtl()
? 'كلمة المرور يجب أن تكون ' . app_config('security.password_min_length') . ' أحرف على الأقل'
: 'Password must be at least ' . app_config('security.password_min_length') . ' characters';
} elseif ($password !== $confirm) {
$error = is_rtl() ? 'كلمة المرور غير متطابقة' : 'Passwords do not match';
} else {
$reset = Auth::resetPassword($token, $password);
if ($reset) {
Session::start();
flash('success', is_rtl() ? 'تم تغيير كلمة المرور بنجاح' : 'Password changed successfully');
redirect('/login');
} else {
$error = is_rtl() ? 'فشل في تغيير كلمة المرور' : 'Failed to reset password';
}
}
}
$direction = dir_attr();
$lang = lang_attr();
?>
<!DOCTYPE html>
<html lang="<?= $lang ?>" dir="<?= $direction ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= e(is_rtl() ? 'كلمة مرور جديدة' : 'New Password') ?></title>
<?php if (is_rtl()): ?>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.rtl.min.css" rel="stylesheet">
<?php else: ?>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<?php endif; ?>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Cairo:wght@400;600;700&display=swap" rel="stylesheet">
<link href="<?= asset_url('css/login.css') ?>" rel="stylesheet">
</head>
<body class="login-page">
<div class="login-card">
<div class="login-header">
<div class="login-header-icon"><i class="fas fa-lock-open"></i></div>
<h1><?= e(is_rtl() ? 'كلمة مرور جديدة' : 'Set New Password') ?></h1>
</div>
<div class="login-body">
<?php if ($error): ?>
<div class="login-error"><i class="fas fa-exclamation-circle"></i> <?= e($error) ?></div>
<?php endif; ?>
<?php if (!$error || is_post()): ?>
<form method="POST" action="/password-reset-confirm?token=<?= e($token) ?>">
<?= csrf_field() ?>
<div class="mb-3">
<label class="form-label" for="password"><?= e(is_rtl() ? 'كلمة المرور الجديدة' : 'New Password') ?></label>
<input type="password" class="form-control" id="password" name="password" required minlength="<?= app_config('security.password_min_length') ?>">
</div>
<div class="mb-3">
<label class="form-label" for="password_confirm"><?= e(is_rtl() ? 'تأكيد كلمة المرور' : 'Confirm Password') ?></label>
<input type="password" class="form-control" id="password_confirm" name="password_confirm" required>
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> <?= e(is_rtl() ? 'حفظ كلمة المرور' : 'Save Password') ?>
</button>
</form>
<?php endif; ?>
</div>
<div class="login-footer">
<a href="/login"><?= e(is_rtl() ? 'العودة لتسجيل الدخول' : 'Back to Login') ?></a>
</div>
</div>
</body>
</html>
\ No newline at end of file
<?php
/**
* Password Reset — Request Form.
*/
if (Auth::check()) {
redirect('/dashboard');
}
$error = null;
$success = null;
if (is_post()) {
csrf_require();
$email = input('email', '', 'POST');
if (!validate_email($email)) {
$error = is_rtl() ? 'يرجى إدخال بريد إلكتروني صحيح' : 'Please enter a valid email address';
} else {
$token = Auth::generatePasswordResetToken($email);
// Always show success (to prevent email enumeration)
$success = is_rtl()
? 'إذا كان البريد مسجلاً، ستتلقى رابط إعادة تعيين كلمة المرور'
: 'If this email is registered, you will receive a password reset link';
if ($token) {
// In production, send email with the token link
// For now, log it
app_log('info', "Password reset token generated for: {$email}. Token: {$token}");
}
}
}
$direction = dir_attr();
$lang = lang_attr();
?>
<!DOCTYPE html>
<html lang="<?= $lang ?>" dir="<?= $direction ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= e(is_rtl() ? 'استعادة كلمة المرور' : 'Reset Password') ?></title>
<?php if (is_rtl()): ?>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.rtl.min.css" rel="stylesheet">
<?php else: ?>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<?php endif; ?>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Cairo:wght@400;600;700&display=swap" rel="stylesheet">
<link href="<?= asset_url('css/login.css') ?>" rel="stylesheet">
</head>
<body class="login-page">
<div class="login-card">
<div class="login-header">
<div class="login-header-icon"><i class="fas fa-key"></i></div>
<h1><?= e(is_rtl() ? 'استعادة كلمة المرور' : 'Reset Password') ?></h1>
<p><?= e(is_rtl() ? 'أدخل بريدك الإلكتروني لتلقي رابط الاستعادة' : 'Enter your email to receive a reset link') ?></p>
</div>
<div class="login-body">
<?php if ($error): ?>
<div class="login-error"><i class="fas fa-exclamation-circle"></i> <?= e($error) ?></div>
<?php endif; ?>
<?php if ($success): ?>
<div class="login-success"><i class="fas fa-check-circle"></i> <?= e($success) ?></div>
<?php endif; ?>
<form method="POST" action="/password-reset">
<?= csrf_field() ?>
<div class="mb-3">
<label class="form-label" for="email"><?= e(is_rtl() ? 'البريد الإلكتروني' : 'Email Address') ?></label>
<input type="email" class="form-control" id="email" name="email" required autofocus
placeholder="<?= e(is_rtl() ? 'أدخل بريدك الإلكتروني' : 'Enter your email') ?>">
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-paper-plane"></i>
<?= e(is_rtl() ? 'إرسال رابط الاستعادة' : 'Send Reset Link') ?>
</button>
</form>
</div>
<div class="login-footer">
<a href="/login"><i class="fas fa-arrow-right"></i> <?= e(is_rtl() ? 'العودة لتسجيل الدخول' : 'Back to Login') ?></a>
</div>
</div>
</body>
</html>
\ No newline at end of file
<?php
/**
* Password Reset — Request Form.
* Uses the Notification system so Phase 4 (email) can process these
* without ever editing this file.
*/
if (Auth::check()) {
redirect('/dashboard');
}
$error = null;
$success = null;
if (is_post()) {
csrf_require();
$email = input('email', '', 'POST');
if (!validate_email($email)) {
$error = is_rtl() ? 'يرجى إدخال بريد إلكتروني صحيح' : 'Please enter a valid email address';
} else {
$token = Auth::generatePasswordResetToken($email);
// Always show success to prevent email enumeration
$success = is_rtl()
? 'إذا كان البريد مسجلاً، ستتلقى رابط إعادة تعيين كلمة المرور'
: 'If this email is registered, you will receive a password reset link';
if ($token) {
// Find the user to get their ID
$targetUser = db_select_one(
"SELECT id, email FROM users WHERE email = ? AND is_active = 1 AND deleted_at IS NULL",
[$email]
);
if ($targetUser) {
$resetUrl = base_url('password-reset-confirm?token=' . $token);
// Queue notification — channel 'email' will be processed by Phase 4.
// Channel 'system' creates an in-app notification immediately.
// If the template doesn't exist yet, Notification::queue returns null (safe).
Notification::queue('PASSWORD_RESET', 'user', $targetUser['id'], [
'reset_url' => $resetUrl,
'email' => $email,
'token' => $token,
'expires' => date('Y-m-d H:i:s', time() + app_config('security.password_reset_expiry')),
]);
// Also log for development/debugging — always available
app_log('info', "Password reset requested for: {$email}. URL: {$resetUrl}");
}
}
}
}
$direction = dir_attr();
$lang = lang_attr();
?>
<!DOCTYPE html>
<html lang="<?= $lang ?>" dir="<?= $direction ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= e(is_rtl() ? 'استعادة كلمة المرور' : 'Reset Password') ?></title>
<?php if (is_rtl()): ?>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.rtl.min.css" rel="stylesheet">
<?php else: ?>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<?php endif; ?>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Cairo:wght@400;600;700&display=swap" rel="stylesheet">
<link href="<?= asset_url('css/login.css') ?>" rel="stylesheet">
</head>
<body class="login-page">
<div class="login-card">
<div class="login-header">
<div class="login-header-icon"><i class="fas fa-key"></i></div>
<h1><?= e(is_rtl() ? 'استعادة كلمة المرور' : 'Reset Password') ?></h1>
<p><?= e(is_rtl() ? 'أدخل بريدك الإلكتروني لتلقي رابط الاستعادة' : 'Enter your email to receive a reset link') ?></p>
</div>
<div class="login-body">
<?php if ($error): ?>
<div class="login-error"><i class="fas fa-exclamation-circle"></i> <?= e($error) ?></div>
<?php endif; ?>
<?php if ($success): ?>
<div class="login-success"><i class="fas fa-check-circle"></i> <?= e($success) ?></div>
<?php endif; ?>
<form method="POST" action="/password-reset">
<?= csrf_field() ?>
<div class="mb-3">
<label class="form-label" for="email"><?= e(is_rtl() ? 'البريد الإلكتروني' : 'Email Address') ?></label>
<input type="email" class="form-control" id="email" name="email" required autofocus
placeholder="<?= e(is_rtl() ? 'أدخل بريدك الإلكتروني' : 'Enter your email') ?>">
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-paper-plane"></i>
<?= e(is_rtl() ? 'إرسال رابط الاستعادة' : 'Send Reset Link') ?>
</button>
</form>
</div>
<div class="login-footer">
<a href="/login"><i class="fas fa-arrow-<?= is_rtl() ? 'left' : 'left' ?>"></i> <?= e(is_rtl() ? 'العودة لتسجيل الدخول' : 'Back to Login') ?></a>
</div>
</div>
</body>
</html>
\ No newline at end of file
<?php
/**
* User Profile Page — view and change password.
*/
$pageTitle = is_rtl() ? 'الملف الشخصي' : 'My Profile';
$breadcrumbs = [
['label' => is_rtl() ? 'الرئيسية' : 'Home', 'url' => '/dashboard'],
['label' => $pageTitle, 'url' => '/profile'],
];
$user = current_user();
$error = null;
$success = null;
if (is_post()) {
csrf_require();
$action = input('action', '', 'POST');
if ($action === 'change_password') {
$currentPassword = $_POST['current_password'] ?? '';
$newPassword = $_POST['new_password'] ?? '';
$confirmPassword = $_POST['confirm_password'] ?? '';
// Verify current password
$dbUser = db_select_one("SELECT password_hash FROM users WHERE id = ?", [current_user_id()]);
if (!$dbUser || !password_verify($currentPassword, $dbUser['password_hash'])) {
$error = is_rtl() ? 'كلمة المرور الحالية غير صحيحة' : 'Current password is incorrect';
} elseif (strlen($newPassword) < app_config('security.password_min_length')) {
$error = is_rtl()
? 'كلمة المرور الجديدة يجب أن تكون ' . app_config('security.password_min_length') . ' أحرف على الأقل'
: 'New password must be at least ' . app_config('security.password_min_length') . ' characters';
} elseif ($newPassword !== $confirmPassword) {
$error = is_rtl() ? 'كلمة المرور غير متطابقة' : 'Passwords do not match';
} else {
db_update('users', [
'password_hash' => password_hash($newPassword, PASSWORD_ARGON2ID),
'updated_at' => date('Y-m-d H:i:s'),
'updated_by' => current_user_id(),
], 'id = ?', [current_user_id()]);
audit_log('PASSWORD_CHANGE', 'users', current_user_id(), null, null, 'User changed own password');
$success = is_rtl() ? 'تم تغيير كلمة المرور بنجاح' : 'Password changed successfully';
}
}
}
include ROOT_PATH . '/templates/header.php';
?>
<div class="container-fluid">
<div class="page-header">
<h1 class="page-header-title"><i class="fas fa-user-circle"></i> <?= e($pageTitle) ?></h1>
</div>
<div class="row">
<!-- Profile Info -->
<div class="col-lg-4">
<div class="card">
<div class="card-body text-center">
<div style="font-size:72px;color:var(--color-primary);margin-bottom:16px;">
<i class="fas fa-user-circle"></i>
</div>
<h4><?= e($user['full_name_ar'] ?? $user['full_name'] ?? $user['username']) ?></h4>
<p class="text-muted"><?= e(is_rtl() ? ($user['role_name_ar'] ?? '') : ($user['role_name'] ?? '')) ?></p>
<hr>
<table class="table table-borderless text-start small">
<tr><td class="text-muted"><?= e(is_rtl() ? 'اسم المستخدم' : 'Username') ?></td><td><?= e($user['username']) ?></td></tr>
<tr><td class="text-muted"><?= e(is_rtl() ? 'البريد الإلكتروني' : 'Email') ?></td><td><?= e($user['email'] ?? '-') ?></td></tr>
<tr><td class="text-muted"><?= e(is_rtl() ? 'آخر دخول' : 'Last Login') ?></td><td><?= format_datetime($user['last_login_at'] ?? '') ?></td></tr>
</table>
</div>
</div>
</div>
<!-- Change Password -->
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<i class="fas fa-key"></i> <?= e(is_rtl() ? 'تغيير كلمة المرور' : 'Change Password') ?>
</div>
<div class="card-body">
<?php if ($error): ?>
<div class="alert alert-danger"><i class="fas fa-exclamation-circle"></i> <?= e($error) ?></div>
<?php endif; ?>
<?php if ($success): ?>
<div class="alert alert-success"><i class="fas fa-check-circle"></i> <?= e($success) ?></div>
<?php endif; ?>
<form method="POST" action="/profile">
<?= csrf_field() ?>
<input type="hidden" name="action" value="change_password">
<div class="mb-3">
<label class="form-label"><?= e(is_rtl() ? 'كلمة المرور الحالية' : 'Current Password') ?> <span class="required">*</span></label>
<input type="password" class="form-control" name="current_password" required>
</div>
<div class="mb-3">
<label class="form-label"><?= e(is_rtl() ? 'كلمة المرور الجديدة' : 'New Password') ?> <span class="required">*</span></label>
<input type="password" class="form-control" name="new_password" required minlength="<?= app_config('security.password_min_length') ?>">
</div>
<div class="mb-3">
<label class="form-label"><?= e(is_rtl() ? 'تأكيد كلمة المرور' : 'Confirm New Password') ?> <span class="required">*</span></label>
<input type="password" class="form-control" name="confirm_password" required>
</div>
<button type="submit" class="btn btn-primary btn-icon">
<i class="fas fa-save"></i> <?= e(is_rtl() ? 'حفظ' : 'Save') ?>
</button>
</form>
</div>
</div>
</div>
</div>
</div>
<?php include ROOT_PATH . '/templates/footer.php'; ?>
\ No newline at end of file
Require all denied
\ No newline at end of file
<?php
/**
* Breadcrumb — auto-generated from URL segments, with overrides.
* Pages can set: $breadcrumbs = [['label' => '...', 'url' => '...'], ...]
*/
if (!isset($breadcrumbs) || empty($breadcrumbs)) {
// Auto-generate from URL
$segments = $GLOBALS['_route_segments'] ?? [];
$breadcrumbs = [['label' => is_rtl() ? 'الرئيسية' : 'Home', 'url' => '/dashboard']];
$path = '';
foreach ($segments as $seg) {
$path .= '/' . $seg;
$breadcrumbs[] = ['label' => ucfirst(str_replace('-', ' ', $seg)), 'url' => $path];
}
}
if (!empty($breadcrumbs)):
?>
<div class="container-fluid">
<nav aria-label="breadcrumb" class="app-breadcrumb">
<ol class="breadcrumb">
<?php foreach ($breadcrumbs as $i => $crumb): ?>
<?php $isLast = ($i === count($breadcrumbs) - 1); ?>
<?php if ($isLast): ?>
<li class="breadcrumb-item active" aria-current="page"><?= e($crumb['label']) ?></li>
<?php else: ?>
<li class="breadcrumb-item"><a href="<?= e($crumb['url']) ?>"><?= e($crumb['label']) ?></a></li>
<?php endif; ?>
<?php endforeach; ?>
</ol>
</nav>
</div>
<?php endif; ?>
\ No newline at end of file
<?php
/**
* Main Page Footer — closes the HTML.
*/
?>
</main>
<!-- Footer -->
<footer class="app-footer">
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center">
<span>&copy; <?= date('Y') ?> <?= e(is_rtl() ? app_config('name_ar') : app_config('name')) ?></span>
<span class="text-muted"><?= e(is_rtl() ? 'الإصدار' : 'Version') ?> <?= e(app_config('version')) ?></span>
</div>
</div>
</footer>
</div>
</div>
<!-- Scripts -->
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.datatables.net/1.13.11/js/jquery.dataTables.min.js"></script>
<script src="https://cdn.datatables.net/1.13.11/js/dataTables.bootstrap5.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<!-- App Scripts -->
<script src="<?= asset_url('js/utils.js') ?>"></script>
<script src="<?= asset_url('js/datatables-config.js') ?>"></script>
<script src="<?= asset_url('js/notifications.js') ?>"></script>
<script src="<?= asset_url('js/app.js') ?>"></script>
<script>
window.AppConfig = {
locale: '<?= current_locale() ?>',
isRtl: <?= is_rtl() ? 'true' : 'false' ?>,
csrfToken: '<?= csrf_token() ?>',
baseUrl: '<?= rtrim(app_config('url', ''), '/') ?>',
currency: <?= json_encode(app_config('currency')) ?>,
};
</script>
<?php if (isset($extraJs) && is_array($extraJs)): ?>
<?php foreach ($extraJs as $js): ?>
<script src="<?= e($js) ?>"></script>
<?php endforeach; ?>
<?php endif; ?>
<?php if (isset($inlineJs)): ?>
<script><?= $inlineJs ?></script>
<?php endif; ?>
</body>
</html>
\ No newline at end of file
<?php
/**
* Main Page Header — includes <html>, <head>, and opens <body>.
* Every authenticated page includes this.
*
* Expected variables:
* $pageTitle (string) — The page title
* $bodyClass (string, optional) — Additional body classes
*/
$pageTitle = $pageTitle ?? (is_rtl() ? 'نظام إدارة النادي' : 'Club Management System');
$bodyClass = $bodyClass ?? '';
$direction = dir_attr();
$lang = lang_attr();
$sidebarCollapsed = Session::get('sidebar_collapsed', false);
$sidebarClass = $sidebarCollapsed ? 'sidebar-collapsed' : '';
?>
<!DOCTYPE html>
<html lang="<?= $lang ?>" dir="<?= $direction ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="<?= e(csrf_token()) ?>">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title><?= e($pageTitle) ?><?= e(is_rtl() ? app_config('name_ar') : app_config('name')) ?></title>
<!-- Bootstrap 5.3 RTL/LTR -->
<?php if (is_rtl()): ?>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.rtl.min.css" rel="stylesheet">
<?php else: ?>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<?php endif; ?>
<!-- Font Awesome 6 -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" rel="stylesheet">
<!-- DataTables -->
<link href="https://cdn.datatables.net/1.13.11/css/dataTables.bootstrap5.min.css" rel="stylesheet">
<!-- Google Fonts — Cairo for Arabic, Inter for English -->
<link href="https://fonts.googleapis.com/css2?family=Cairo:wght@300;400;500;600;700&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<!-- App Styles -->
<link href="<?= asset_url('css/main.css') ?>" rel="stylesheet">
<?php if (is_rtl()): ?>
<link href="<?= asset_url('css/rtl.css') ?>" rel="stylesheet">
<?php endif; ?>
<link href="<?= asset_url('css/print.css') ?>" rel="stylesheet" media="print">
<?php if (isset($extraCss) && is_array($extraCss)): ?>
<?php foreach ($extraCss as $css): ?>
<link href="<?= e($css) ?>" rel="stylesheet">
<?php endforeach; ?>
<?php endif; ?>
</head>
<body class="app-body <?= e($sidebarClass) ?> <?= e($bodyClass) ?>">
<div class="app-wrapper">
<?php include ROOT_PATH . '/templates/sidebar.php'; ?>
<div class="app-main">
<?php include ROOT_PATH . '/templates/navbar.php'; ?>
<main class="app-content">
<?php include ROOT_PATH . '/templates/breadcrumb.php'; ?>
<!-- Flash Messages -->
<?php $flashMessages = get_flash_messages(); ?>
<?php if (!empty($flashMessages)): ?>
<div class="container-fluid">
<?php foreach ($flashMessages as $fm): ?>
<div class="alert alert-<?= e($fm['type']) ?> alert-dismissible fade show" role="alert">
<?= e($fm['message']) ?>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
\ No newline at end of file
<?php
/**
* Top Navbar — search, language toggle, notifications, user menu.
*/
$user = current_user();
$notifCount = Notification::countUnread();
?>
<header class="app-navbar">
<div class="navbar-container">
<!-- Left: Sidebar Toggle + Search -->
<div class="navbar-start">
<button class="navbar-toggle-btn" id="sidebarToggleBtn" type="button" aria-label="Toggle sidebar">
<i class="fas fa-bars"></i>
</button>
</div>
<!-- Right: Actions -->
<div class="navbar-end">
<!-- Language Toggle -->
<div class="navbar-item">
<a href="#" class="navbar-icon-btn" id="langToggleBtn"
data-current="<?= e(current_locale()) ?>"
title="<?= is_rtl() ? 'Switch to English' : 'التبديل للعربية' ?>">
<i class="fas fa-globe"></i>
<span class="navbar-icon-label"><?= current_locale() === 'ar' ? 'EN' : 'ع' ?></span>
</a>
</div>
<!-- Notifications -->
<div class="navbar-item dropdown">
<a href="#" class="navbar-icon-btn position-relative" id="notifDropdownBtn"
data-bs-toggle="dropdown" aria-expanded="false">
<i class="fas fa-bell"></i>
<?php if ($notifCount > 0): ?>
<span class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-danger notif-badge" id="notifBadge">
<?= $notifCount > 99 ? '99+' : $notifCount ?>
</span>
<?php endif; ?>
</a>
<div class="dropdown-menu dropdown-menu-end notification-dropdown" id="notifDropdown">
<div class="notification-header">
<h6 class="mb-0"><?= is_rtl() ? 'الإشعارات' : 'Notifications' ?></h6>
<a href="#" id="markAllReadBtn" class="text-muted small"><?= is_rtl() ? 'قراءة الكل' : 'Mark all read' ?></a>
</div>
<div class="notification-body" id="notifList">
<div class="text-center py-3 text-muted">
<i class="fas fa-bell-slash"></i>
<p class="mb-0 small"><?= is_rtl() ? 'لا توجد إشعارات' : 'No notifications' ?></p>
</div>
</div>
<div class="notification-footer">
<a href="/notifications/list"><?= is_rtl() ? 'عرض الكل' : 'View All' ?></a>
</div>
</div>
</div>
<!-- User Menu -->
<div class="navbar-item dropdown">
<a href="#" class="navbar-user-btn" data-bs-toggle="dropdown" aria-expanded="false">
<div class="navbar-user-avatar">
<i class="fas fa-user-circle"></i>
</div>
<span class="navbar-user-name d-none d-md-inline">
<?= e($user['full_name_ar'] ?? $user['username'] ?? '') ?>
</span>
<i class="fas fa-chevron-down ms-1 small"></i>
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="/profile"><i class="fas fa-user me-2"></i><?= is_rtl() ? 'الملف الشخصي' : 'Profile' ?></a></li>
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item text-danger" href="/logout"
onclick="return confirm('<?= is_rtl() ? 'تسجيل الخروج؟' : 'Logout?' ?>')">
<i class="fas fa-sign-out-alt me-2"></i><?= is_rtl() ? 'خروج' : 'Logout' ?>
</a>
</li>
</ul>
</div>
</div>
</div>
</header>
\ No newline at end of file
<?php
/**
* Sidebar Navigation — rendered from config/menu.php.
* Permission-aware: hides items the user can't access.
*/
$menuItems = require ROOT_PATH . '/config/menu.php';
$user = current_user();
$locale = current_locale();
?>
<aside class="app-sidebar" id="appSidebar">
<!-- Brand -->
<div class="sidebar-brand">
<a href="/dashboard" class="sidebar-brand-link">
<i class="fas fa-trophy sidebar-brand-icon"></i>
<span class="sidebar-brand-text">
<?= e(is_rtl() ? 'إدارة النادي' : 'Club ERP') ?>
</span>
</a>
<button class="sidebar-toggle-btn d-lg-none" id="sidebarCloseBtn" type="button" aria-label="Close sidebar">
<i class="fas fa-times"></i>
</button>
</div>
<!-- User Mini Profile -->
<?php if ($user): ?>
<div class="sidebar-user">
<div class="sidebar-user-avatar">
<i class="fas fa-user-circle"></i>
</div>
<div class="sidebar-user-info">
<span class="sidebar-user-name"><?= e($user['full_name_ar'] ?? $user['full_name'] ?? $user['username']) ?></span>
<span class="sidebar-user-role"><?= e(is_rtl() ? ($user['role_name_ar'] ?? '') : ($user['role_name'] ?? '')) ?></span>
</div>
</div>
<?php endif; ?>
<!-- Navigation -->
<nav class="sidebar-nav">
<ul class="sidebar-menu" id="sidebarMenu">
<?php foreach ($menuItems as $item): ?>
<?php
// Check permission
if ($item['permission'] && !has_permission($item['permission'])) continue;
$hasChildren = !empty($item['children']);
$isActive = is_active_path($item['url']);
$childActive = false;
if ($hasChildren) {
foreach ($item['children'] as $child) {
if (is_active_path($child['url'])) {
$childActive = true;
break;
}
}
}
$itemClass = ($isActive || $childActive) ? 'active' : '';
$expandedClass = $childActive ? 'show' : '';
$title = $locale === 'ar' ? $item['title_ar'] : $item['title_en'];
?>
<li class="sidebar-menu-item <?= $itemClass ?> <?= $hasChildren ? 'has-submenu' : '' ?>">
<?php if ($hasChildren): ?>
<a class="sidebar-menu-link" href="#submenu-<?= e($item['id']) ?>"
data-bs-toggle="collapse" role="button"
aria-expanded="<?= $childActive ? 'true' : 'false' ?>">
<i class="<?= e($item['icon']) ?> sidebar-menu-icon"></i>
<span class="sidebar-menu-text"><?= e($title) ?></span>
<i class="fas fa-chevron-down sidebar-menu-arrow"></i>
</a>
<ul class="sidebar-submenu collapse <?= $expandedClass ?>" id="submenu-<?= e($item['id']) ?>">
<?php foreach ($item['children'] as $child): ?>
<?php if (isset($child['permission']) && !has_permission($child['permission'])) continue; ?>
<li class="sidebar-submenu-item <?= active_class($child['url']) ?>">
<a class="sidebar-submenu-link" href="<?= e($child['url']) ?>">
<i class="<?= e($child['icon'] ?? 'fas fa-circle-dot') ?> sidebar-submenu-icon"></i>
<span><?= e($locale === 'ar' ? $child['title_ar'] : $child['title_en']) ?></span>
</a>
</li>
<?php endforeach; ?>
</ul>
<?php else: ?>
<a class="sidebar-menu-link" href="<?= e($item['url']) ?>">
<i class="<?= e($item['icon']) ?> sidebar-menu-icon"></i>
<span class="sidebar-menu-text"><?= e($title) ?></span>
<?php if ($item['badge']): ?>
<span class="badge bg-danger sidebar-badge"><?= e($item['badge']) ?></span>
<?php endif; ?>
</a>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
</nav>
<!-- Sidebar Footer -->
<div class="sidebar-footer">
<a href="/logout" class="sidebar-footer-link" onclick="return confirm('<?= is_rtl() ? 'هل أنت متأكد من تسجيل الخروج؟' : 'Are you sure you want to logout?' ?>')">
<i class="fas fa-sign-out-alt"></i>
<span><?= is_rtl() ? 'تسجيل الخروج' : 'Logout' ?></span>
</a>
</div>
</aside>
<!-- Sidebar Overlay for Mobile -->
<div class="sidebar-overlay" id="sidebarOverlay"></div>
\ No newline at end of file
# Deny all direct access to uploaded files
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule .* - [F,L]
</IfModule>
# Fallback
<FilesMatch ".*">
Require all denied
</FilesMatch>
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment