Files
Siro/dashboard/siro-admin/js/app.js
T

664 lines
26 KiB
JavaScript

/* ==========================================================================
Siro Admin Web Portal - Interactive Application Engine
========================================================================== */
document.addEventListener('DOMContentLoaded', () => {
// DOM Elements
const authWrapper = document.getElementById('authWrapper');
const loginForm = document.getElementById('loginForm');
const navItems = document.querySelectorAll('.nav-item[data-view]');
const pageViews = document.querySelectorAll('.page-view');
const logoutBtn = document.getElementById('logoutBtn');
const sidebar = document.getElementById('sidebar');
const toggleSidebarBtn = document.getElementById('toggleSidebar');
// Sample State & Datasets
let currentUser = JSON.parse(localStorage.getItem('siro_admin_user')) || null;
const mockRides = [
{ id: 'SR-9842', rider: 'Ahmad Al-Mansoor', driver: 'Khalid Hassan', pickup: 'Abdoun, Amman', dropoff: '7th Circle', fare: '$14.50', status: 'Completed', time: '10 mins ago' },
{ id: 'SR-9843', rider: 'Sarah Mahmoud', driver: 'Omar Farooq', pickup: 'Jabal Amman', dropoff: 'Queen Alia Airport', fare: '$28.00', status: 'In Progress', time: 'Just now' },
{ id: 'SR-9844', rider: 'Tariq Ziyad', driver: 'Youssef Ali', pickup: 'Sweifieh Mall', dropoff: 'University of Jordan', fare: '$9.20', status: 'Completed', time: '25 mins ago' },
{ id: 'SR-9845', rider: 'Reem Kanaan', driver: 'Hassan Nabil', pickup: 'Mecca Street', dropoff: 'Tabarbour', fare: '$11.80', status: 'Cancelled', time: '40 mins ago' },
{ id: 'SR-9846', rider: 'Fadi Jarrah', driver: 'Bilal Mustafa', pickup: 'Khalda', dropoff: 'Shmeisani', fare: '$16.00', status: 'In Progress', time: '5 mins ago' },
];
const mockDrivers = [
{ id: 'DRV-101', name: 'Khalid Hassan', phone: '+962 7 9123 4567', vehicle: 'Toyota Camry 2022', rating: '4.95', trips: 1420, status: 'Online', verified: true },
{ id: 'DRV-102', name: 'Omar Farooq', phone: '+962 7 8876 5432', vehicle: 'Hyundai Elantra 2021', rating: '4.88', trips: 980, status: 'In Trip', verified: true },
{ id: 'DRV-103', name: 'Youssef Ali', phone: '+962 7 7654 3210', vehicle: 'Kia Optima 2023', rating: '4.90', trips: 1150, status: 'Online', verified: true },
{ id: 'DRV-104', name: 'Hassan Nabil', phone: '+962 7 9988 7766', vehicle: 'Nissan Sentra 2020', rating: '4.72', trips: 620, status: 'Offline', verified: false },
];
const mockPassengers = [
{ id: 'PSG-501', name: 'Ahmad Al-Mansoor', email: 'ahmad@example.com', trips: 45, rating: '4.9', joined: 'Jan 2025', status: 'Active' },
{ id: 'PSG-502', name: 'Sarah Mahmoud', email: 'sarah.m@example.com', trips: 89, rating: '5.0', joined: 'Nov 2024', status: 'Active' },
{ id: 'PSG-503', name: 'Reem Kanaan', email: 'reem.k@example.com', trips: 12, rating: '4.6', joined: 'Mar 2025', status: 'Suspended' },
];
// Initialize App
function initApp() {
checkAuth();
setupNavigation();
renderRidesTable();
renderDriversTable();
renderPassengersTable();
initCharts();
setupEvents();
}
// Web Device Fingerprint Generator
function getWebFingerprint() {
let fp = localStorage.getItem('siro_web_fp');
if (!fp) {
fp = 'web_' + Math.random().toString(36).substring(2) + Date.now().toString(36);
localStorage.setItem('siro_web_fp', fp);
}
return fp;
}
const deviceFingerprint = getWebFingerprint();
// Mode State (Live vs Demo)
let isLiveMode = true;
const modeLiveBtn = document.getElementById('modeLiveBtn');
const modeDemoBtn = document.getElementById('modeDemoBtn');
modeLiveBtn?.addEventListener('click', () => {
isLiveMode = true;
modeLiveBtn.style.background = 'var(--primary)';
modeLiveBtn.style.color = '#fff';
modeDemoBtn.style.background = 'transparent';
modeDemoBtn.style.color = 'var(--text-muted)';
});
modeDemoBtn?.addEventListener('click', () => {
isLiveMode = false;
modeDemoBtn.style.background = 'var(--primary)';
modeDemoBtn.style.color = '#fff';
modeLiveBtn.style.background = 'transparent';
modeLiveBtn.style.color = 'var(--text-muted)';
});
// Auth Functions
function checkAuth() {
if (currentUser) {
authWrapper.classList.add('hidden');
if (currentUser.isLive && currentUser.jwt) {
fetchLiveDashboardData();
}
} else {
authWrapper.classList.remove('hidden');
}
}
let pendingOtpPhone = '';
let pendingOtpPassword = '';
loginForm?.addEventListener('submit', async (e) => {
e.preventDefault();
const phone = document.getElementById('loginEmail').value.trim();
const password = document.getElementById('loginPass').value.trim();
if (isLiveMode) {
showNotification('Authenticating credentials with server...', 'info');
try {
const formData = new FormData();
formData.append('phone', phone);
formData.append('id', phone); // send both phone and id for maximum compatibility
formData.append('password', password);
formData.append('fingerprint', deviceFingerprint);
formData.append('aud', 'admin');
// Try Admin auth login endpoint
let response = await fetch('/backend/Admin/auth/login.php', {
method: 'POST',
body: formData
});
// Fallback to loginAdmin.php if 404
if (response.status === 404) {
response = await fetch('/backend/loginAdmin.php', {
method: 'POST',
body: formData
});
}
const resText = await response.text();
console.log('Server Raw Response:', resText);
let res;
try {
res = JSON.parse(resText);
} catch (jsonErr) {
showNotification(`Server Error (${response.status}): ${resText.substring(0, 100)}`, 'danger');
return;
}
console.log('Parsed API Response:', res);
// Check if login succeeded and JWT was returned
const jwtToken = res.jwt || res.data?.jwt || (typeof res.message === 'object' ? res.message?.jwt : null);
const adminInfo = res.admin || res.data?.admin || (typeof res.message === 'object' ? res.message?.admin : {}) || {};
if (response.ok && (res.status === 'success' || jwtToken)) {
if (jwtToken) {
currentUser = {
name: adminInfo.name || 'Admin',
email: adminInfo.email || phone,
role: adminInfo.role || 'Administrator',
jwt: jwtToken,
isLive: true
};
localStorage.setItem('siro_admin_user', JSON.stringify(currentUser));
authWrapper.classList.add('hidden');
showNotification('Access Granted! Welcome to Admin Portal.', 'success');
fetchLiveDashboardData();
} else if (res.status === 'otp_required' || (res.message && (res.message.status === 'otp_required' || res.message === 'otp_required'))) {
pendingOtpPhone = phone;
pendingOtpPassword = password;
const masked = res.phone || (typeof res.message === 'object' ? res.message.phone : null) || phone;
document.getElementById('otpPhoneText').textContent = `Verification code sent to WhatsApp (${masked})`;
document.getElementById('otpModal').classList.add('active');
} else {
showNotification('Login successful, loading dashboard...', 'success');
currentUser = { name: adminInfo.name || 'Admin', email: phone, role: 'Administrator', isLive: true };
localStorage.setItem('siro_admin_user', JSON.stringify(currentUser));
authWrapper.classList.add('hidden');
fetchLiveDashboardData();
}
} else if (res.status === 'otp_required' || (res.message && (res.message.status === 'otp_required' || res.message === 'otp_required'))) {
pendingOtpPhone = phone;
pendingOtpPassword = password;
const masked = res.phone || (typeof res.message === 'object' ? res.message.phone : null) || phone;
document.getElementById('otpPhoneText').textContent = `Verification code sent to WhatsApp (${masked})`;
document.getElementById('otpModal').classList.add('active');
} else {
// Show REAL backend API error message
const errorMsg = (typeof res.message === 'string' ? res.message : null) || res.error || 'Invalid credentials or user not found.';
showNotification(`Login Error: ${errorMsg}`, 'danger');
}
} catch (err) {
console.error('Login Exception:', err);
showNotification(`Network Error: ${err.message || 'Could not connect to backend'}`, 'danger');
}
} else {
currentUser = { name: 'Super Admin (Demo)', email: phone, role: 'Administrator', isLive: false };
localStorage.setItem('siro_admin_user', JSON.stringify(currentUser));
authWrapper.classList.add('hidden');
showNotification('Welcome to Interactive Demo Mode!', 'success');
}
});
// Submit OTP Code
document.getElementById('submitOtpBtn')?.addEventListener('click', async () => {
const otp = document.getElementById('otpInput').value.trim();
if (!otp || otp.length < 3) {
showNotification('Please enter the 3-digit OTP code', 'warning');
return;
}
try {
const formData = new FormData();
formData.append('otp', otp);
formData.append('fingerprint', deviceFingerprint);
formData.append('aud', 'admin');
const response = await fetch('/backend/Admin/auth/verify_login.php', {
method: 'POST',
body: formData
});
const res = await response.json();
if (response.ok && res.status === 'success') {
currentUser = {
name: res.admin?.name || 'Admin',
email: res.admin?.email || pendingOtpPhone,
role: res.admin?.role || 'Administrator',
jwt: res.jwt,
isLive: true
};
localStorage.setItem('siro_admin_user', JSON.stringify(currentUser));
closeOtpModal();
authWrapper.classList.add('hidden');
showNotification('OTP Verified! Welcome back, Admin.', 'success');
fetchLiveDashboardData();
} else {
showNotification(res.message || 'Invalid OTP code', 'danger');
}
} catch (err) {
showNotification('OTP verification error', 'danger');
}
});
window.closeOtpModal = function() {
document.getElementById('otpModal')?.classList.remove('active');
};
async function fetchLiveDashboardData() {
try {
showNotification('Fetching live fleet metrics from database...', 'info');
const response = await fetch('/backend/Admin/dashbord.php', {
headers: {
'Authorization': `Bearer ${currentUser?.jwt || ''}`
}
});
const data = await response.json();
let stats = null;
if (data && data.status === 'success') {
stats = Array.isArray(data.data) ? data.data[0] : (data.data?.data ? data.data.data[0] : data.data);
} else if (data && data[0]) {
stats = data[0];
}
if (stats) {
// Update Revenue & Stats Cards
const statCards = document.querySelectorAll('.stat-card');
statCards.forEach(card => {
const title = card.querySelector('.stat-title')?.textContent.trim();
const valEl = card.querySelector('.stat-value');
if (!valEl) return;
if (title?.includes('Revenue')) {
const rev = stats.total_driver_earnings || stats.total_revenue || '42,850';
valEl.textContent = typeof rev === 'number' || !isNaN(rev) ? `$${parseFloat(rev).toLocaleString()}` : rev;
} else if (title?.includes('Rides') || title?.includes('Trips')) {
valEl.textContent = (stats.countRide || stats.total_rides || '1,482').toLocaleString();
} else if (title?.includes('Captains') || title?.includes('Drivers')) {
valEl.textContent = (stats.countDriver || stats.num_Driver || '324').toLocaleString();
} else if (title?.includes('Passengers') || title?.includes('Satisfaction')) {
valEl.textContent = (stats.countPassengers || '8,920').toLocaleString();
}
});
// Update system health metrics if present
const totalDistanceEl = document.getElementById('totalDistanceMetric');
if (totalDistanceEl && stats.total_distance) {
totalDistanceEl.textContent = `${stats.total_distance} km`;
}
showNotification('Live Database Stats Synchronized!', 'success');
// Fetch Live Detailed Tables
fetchLiveRides();
fetchLivePassengers();
fetchLivePendingStaff();
}
} catch (err) {
console.log('Using local fallback metrics for display:', err);
}
}
async function fetchLiveRides() {
try {
const response = await fetch('/backend/Admin/rides/get_rides_by_status.php?status=All', {
headers: { 'Authorization': `Bearer ${currentUser?.jwt || ''}` }
});
const data = await response.json();
const rides = Array.isArray(data) ? data : (data.data || []);
if (rides.length > 0) {
const tbody = document.getElementById('ridesTableBody');
if (tbody) {
tbody.innerHTML = rides.slice(0, 15).map(r => `
<tr>
<td><strong>SR-${r.id}</strong></td>
<td>${r.p_fname || 'Passenger'} ${r.p_lname || ''}</td>
<td>${r.d_fname || 'Driver'} ${r.d_lname || ''}</td>
<td><span style="font-size:0.82rem; color: var(--text-muted);"><i class="ph ph-map-pin"></i> ${r.address_start || 'Pickup'} → ${r.address_end || 'Dropoff'}</span></td>
<td><strong>$${r.price || r.price_for_passenger || '0.00'}</strong></td>
<td><span class="badge ${getStatusBadge(r.status)}">${r.status || 'Active'}</span></td>
<td>${r.created_at ? r.created_at.substring(11, 16) : 'Just now'}</td>
<td>
<button class="btn btn-secondary btn-sm" onclick="viewDetails('SR-${r.id}')">
<i class="ph ph-eye"></i> View
</button>
</td>
</tr>
`).join('');
}
}
} catch (err) {
console.log('Error fetching live rides:', err);
}
}
async function fetchLivePassengers() {
try {
const response = await fetch('/backend/Admin/getPassengerDetails.php', {
headers: { 'Authorization': `Bearer ${currentUser?.jwt || ''}` }
});
const data = await response.json();
const passengers = Array.isArray(data) ? data : (data.data || []);
if (passengers.length > 0) {
const tbody = document.getElementById('passengersTableBody');
if (tbody) {
tbody.innerHTML = passengers.slice(0, 15).map(p => `
<tr>
<td><strong>PSG-${p.id}</strong></td>
<td>${p.first_name || 'Passenger'} ${p.last_name || ''}</td>
<td>${p.email || p.phone || 'N/A'}</td>
<td>${p.countPassengerRide || 0} trips</td>
<td><i class="ph-fill ph-star" style="color:var(--warning);"></i> ${p.passengerAverageRating || '5.0'}</td>
<td>${p.created_at ? p.created_at.substring(0, 10) : 'Jan 2025'}</td>
<td><span class="badge ${getStatusBadge(p.status || 'Active')}">${p.status || 'Active'}</span></td>
<td>
<button class="btn btn-secondary btn-sm"><i class="ph ph-user"></i> Profile</button>
</td>
</tr>
`).join('');
}
}
} catch (err) {
console.log('Error fetching live passengers:', err);
}
}
async function fetchLivePendingStaff() {
try {
const response = await fetch('/backend/Admin/Staff/pending.php', {
headers: { 'Authorization': `Bearer ${currentUser?.jwt || ''}` }
});
const data = await response.json();
const pending = data?.data || [];
if (pending.length > 0) {
showNotification(`Notice: You have ${pending.length} pending staff approval requests!`, 'info');
}
} catch (err) {
console.log('Pending staff lookup:', err);
}
}
const fingerprintLoginBtn = document.getElementById('fingerprintLoginBtn');
fingerprintLoginBtn?.addEventListener('click', async () => {
showNotification('Touch Fingerprint sensor on your device...', 'info');
// Check WebAuthn support
if (window.PublicKeyCredential) {
try {
// Biometric WebAuthn prompt simulation / API call
setTimeout(() => {
currentUser = { name: 'Super Admin (Biometric)', email: 'admin@siromove.com', role: 'Administrator', authMethod: 'Fingerprint/TouchID' };
localStorage.setItem('siro_admin_user', JSON.stringify(currentUser));
authWrapper.classList.add('hidden');
showNotification('Fingerprint Verified! Access Granted.', 'success');
}, 1200);
} catch (err) {
showNotification('Fingerprint verification failed', 'danger');
}
} else {
currentUser = { name: 'Super Admin', email: 'admin@siromove.com', role: 'Administrator', authMethod: 'Biometric' };
localStorage.setItem('siro_admin_user', JSON.stringify(currentUser));
authWrapper.classList.add('hidden');
showNotification('Biometric Auth Verified!', 'success');
}
});
logoutBtn?.addEventListener('click', () => {
localStorage.removeItem('siro_admin_user');
currentUser = null;
authWrapper.classList.remove('hidden');
});
// Sidebar & Navigation
function setupNavigation() {
navItems.forEach(item => {
item.addEventListener('click', (e) => {
e.preventDefault();
const targetView = item.getAttribute('data-view');
navItems.forEach(n => n.classList.remove('active'));
item.classList.add('active');
pageViews.forEach(view => {
if (view.id === targetView) {
view.classList.add('active');
} else {
view.classList.remove('active');
}
});
// Close sidebar on mobile
if (window.innerWidth <= 992) {
sidebar.classList.remove('open');
}
});
});
toggleSidebarBtn?.addEventListener('click', () => {
sidebar.classList.toggle('open');
});
}
// Render Data Tables
function renderRidesTable() {
const tbody = document.getElementById('ridesTableBody');
if (!tbody) return;
tbody.innerHTML = mockRides.map(ride => `
<tr>
<td><strong>${ride.id}</strong></td>
<td>${ride.rider}</td>
<td>${ride.driver}</td>
<td><span style="font-size:0.82rem; color: var(--text-muted);"><i class="ph ph-map-pin"></i> ${ride.pickup} → ${ride.dropoff}</span></td>
<td><strong>${ride.fare}</strong></td>
<td><span class="badge ${getStatusBadge(ride.status)}">${ride.status}</span></td>
<td>${ride.time}</td>
<td>
<button class="btn btn-secondary btn-sm" onclick="viewDetails('${ride.id}')">
<i class="ph ph-eye"></i> View
</button>
</td>
</tr>
`).join('');
}
function renderDriversTable() {
const tbody = document.getElementById('driversTableBody');
if (!tbody) return;
tbody.innerHTML = mockDrivers.map(drv => `
<tr>
<td><strong>${drv.id}</strong></td>
<td>
<div style="display:flex; align-items:center; gap:0.5rem;">
<span>${drv.name}</span>
${drv.verified ? '<i class="ph-fill ph-seal-check" style="color:var(--info);" title="Verified"></i>' : ''}
</div>
</td>
<td>${drv.phone}</td>
<td>${drv.vehicle}</td>
<td><i class="ph-fill ph-star" style="color:var(--warning);"></i> ${drv.rating}</td>
<td>${drv.trips}</td>
<td><span class="badge ${getStatusBadge(drv.status)}">${drv.status}</span></td>
<td>
<button class="btn btn-secondary btn-sm"><i class="ph ph-gear"></i> Manage</button>
</td>
</tr>
`).join('');
}
function renderPassengersTable() {
const tbody = document.getElementById('passengersTableBody');
if (!tbody) return;
tbody.innerHTML = mockPassengers.map(p => `
<tr>
<td><strong>${p.id}</strong></td>
<td>${p.name}</td>
<td>${p.email}</td>
<td>${p.trips} trips</td>
<td><i class="ph-fill ph-star" style="color:var(--warning);"></i> ${p.rating}</td>
<td>${p.joined}</td>
<td><span class="badge ${getStatusBadge(p.status)}">${p.status}</span></td>
<td>
<button class="btn btn-secondary btn-sm"><i class="ph ph-user"></i> Profile</button>
</td>
</tr>
`).join('');
}
function getStatusBadge(status) {
switch (status) {
case 'Completed':
case 'Online':
case 'Active':
return 'badge-success';
case 'In Progress':
case 'In Trip':
return 'badge-primary';
case 'Cancelled':
case 'Offline':
case 'Suspended':
return 'badge-danger';
default:
return 'badge-info';
}
}
// Dynamic Canvas Charts
function initCharts() {
const canvas = document.getElementById('revenueChart');
if (!canvas) return;
const ctx = canvas.getContext('2d');
// Resize canvas
canvas.width = canvas.parentElement.clientWidth;
canvas.height = canvas.parentElement.clientHeight;
const data = [12000, 18500, 15000, 24000, 29000, 34500, 42000];
const labels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const W = canvas.width;
const H = canvas.height;
const padding = 40;
// Draw Grid Lines
ctx.strokeStyle = 'rgba(255, 255, 255, 0.05)';
ctx.lineWidth = 1;
for (let i = 0; i <= 4; i++) {
const y = padding + (i * (H - padding * 2) / 4);
ctx.beginPath();
ctx.moveTo(padding, y);
ctx.lineTo(W - padding, y);
ctx.stroke();
}
// Draw Smooth Line
const max = 50000;
const points = data.map((val, idx) => {
const x = padding + (idx * (W - padding * 2) / (data.length - 1));
const y = H - padding - (val / max * (H - padding * 2));
return { x, y };
});
// Area Gradient
const gradient = ctx.createLinearGradient(0, 0, 0, H);
gradient.addColorStop(0, 'rgba(99, 102, 241, 0.4)');
gradient.addColorStop(1, 'rgba(99, 102, 241, 0.0)');
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) {
const xc = (points[i].x + points[i - 1].x) / 2;
const yc = (points[i].y + points[i - 1].y) / 2;
ctx.quadraticCurveTo(points[i - 1].x, points[i - 1].y, xc, yc);
}
ctx.lineTo(points[points.length - 1].x, points[points.length - 1].y);
ctx.lineTo(points[points.length - 1].x, H - padding);
ctx.lineTo(points[0].x, H - padding);
ctx.closePath();
ctx.fillStyle = gradient;
ctx.fill();
// Line Path
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) {
const xc = (points[i].x + points[i - 1].x) / 2;
const yc = (points[i].y + points[i - 1].y) / 2;
ctx.quadraticCurveTo(points[i - 1].x, points[i - 1].y, xc, yc);
}
ctx.lineTo(points[points.length - 1].x, points[points.length - 1].y);
ctx.strokeStyle = '#6366f1';
ctx.lineWidth = 3;
ctx.stroke();
// Draw Points
points.forEach((p, idx) => {
ctx.beginPath();
ctx.arc(p.x, p.y, 5, 0, Math.PI * 2);
ctx.fillStyle = '#6366f1';
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = '#fff';
ctx.stroke();
// Labels
ctx.fillStyle = '#94a3b8';
ctx.font = '12px Inter';
ctx.textAlign = 'center';
ctx.fillText(labels[idx], p.x, H - 15);
});
}
// Setup Event Listeners & Search Filters
function setupEvents() {
window.addEventListener('resize', () => {
initCharts();
});
const searchInput = document.getElementById('globalSearch');
searchInput?.addEventListener('input', (e) => {
const query = e.target.value.toLowerCase();
// Highlight matching items in tables if any
});
}
// Notifications Helper
function showNotification(msg, type = 'info') {
const toast = document.createElement('div');
toast.style.cssText = `
position: fixed;
bottom: 24px;
right: 24px;
background: var(--bg-elevated);
border: 1px solid var(--border-active);
color: #fff;
padding: 12px 20px;
border-radius: 12px;
box-shadow: var(--shadow-md);
z-index: 9999;
font-size: 0.9rem;
animation: fadeIn 0.3s ease;
`;
toast.innerHTML = `<i class="ph ph-check-circle" style="color:var(--success);"></i> ${msg}`;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3500);
}
window.viewDetails = function(id) {
const modal = document.getElementById('detailsModal');
const modalBody = document.getElementById('modalBodyContent');
if (!modal || !modalBody) return;
modalBody.innerHTML = `
<h3 style="margin-bottom:1rem; color:#fff;">Ride Details: ${id}</h3>
<p style="color:var(--text-muted); margin-bottom:0.5rem;"><strong>Pickup:</strong> Abdoun Circle, Amman</p>
<p style="color:var(--text-muted); margin-bottom:0.5rem;"><strong>Dropoff:</strong> Queen Alia International Airport</p>
<p style="color:var(--text-muted); margin-bottom:0.5rem;"><strong>Fare Breakdown:</strong> Base Fare ($4.00) + Distance ($20.00) + Peak Surge ($4.00)</p>
<p style="color:var(--text-muted); margin-bottom:1.5rem;"><strong>Payment Method:</strong> Credit Card (Visa **** 4921)</p>
<div style="text-align:right;">
<button class="btn btn-secondary" onclick="closeModal()">Close</button>
</div>
`;
modal.classList.add('active');
};
window.closeModal = function() {
const modal = document.getElementById('detailsModal');
modal?.classList.remove('active');
};
// Run initial state
initApp();
});