Initial commit: MSPE website - full site with admin panel, API, and public pages

This commit is contained in:
Krikorios
2026-02-22 02:24:15 +02:00
commit 7a90e63301
106 changed files with 477087 additions and 0 deletions
+42
View File
@@ -0,0 +1,42 @@
# ═══════════════════════════════════════════════════════════════
# MSPE Website - Environment Configuration
# ═══════════════════════════════════════════════════════════════
# Copy this file to .env and fill in your production values
# cp .env.example .env
#
# IMPORTANT: Never commit .env to version control!
# ═══════════════════════════════════════════════════════════════
# ── Application ───────────────────────────────────────────────
APP_ENV=production
SITE_NAME=MSPE
SITE_URL=https://mspe.pro
# ── Database ──────────────────────────────────────────────────
# Use 'file' for JSON file storage, or 'mysql' for MySQL database
DB_TYPE=file
# DB_HOST=localhost
# DB_NAME=your_database_name
# DB_USER=your_database_user
# DB_PASS=your_database_password
# ── Admin Credentials ─────────────────────────────────────────
# IMPORTANT: Set a strong admin password before deploying!
ADMIN_USER=admin
ADMIN_PASS=CHANGE_ME_TO_A_STRONG_PASSWORD
# ── JWT Secret ────────────────────────────────────────────────
# Generate a random secret: php -r "echo bin2hex(random_bytes(32));"
JWT_SECRET=CHANGE_ME_GENERATE_RANDOM_SECRET
# ── Admin Email ───────────────────────────────────────────────
ADMIN_EMAIL=info@mspe.pro
# ── SMTP Configuration (for sending emails) ──────────────────
# Hostinger SMTP settings (check Hostinger hPanel for exact values)
SMTP_HOST=smtp.hostinger.com
SMTP_PORT=465
SMTP_USER=info@mspe.pro
SMTP_PASS=your_email_password
SMTP_FROM_NAME=MSPE
SMTP_FROM_EMAIL=info@mspe.pro
+10
View File
@@ -0,0 +1,10 @@
.env
data/*.json
!data/.htaccess
uploads/*
!uploads/.htaccess
!uploads/news/.gitkeep
!uploads/portfolio/.gitkeep
!uploads/clients/.gitkeep
!uploads/team/.gitkeep
.DS_Store
+85
View File
@@ -0,0 +1,85 @@
# MSPE Public Folder .htaccess
RewriteEngine On
RewriteBase /
# Disable directory listing
Options -Indexes
# Force HTTPS
RewriteCond %{HTTPS} off
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
# Handle SPA-style routing if needed - redirect 404s to index
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} !^/api/
RewriteCond %{REQUEST_URI} !^/admin/
RewriteRule ^.*$ 404.html [L]
# Block dev/test/seed scripts in production
<FilesMatch "^(db_seed|db_test|email-test)\.php$">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
</FilesMatch>
# Protect .env files
<FilesMatch "^\.env">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
</FilesMatch>
# Prevent access to hidden files
<FilesMatch "^\.">
Order allow,deny
Deny from all
</FilesMatch>
# Prevent access to JSON data files from web
<FilesMatch "\.(json)$">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
</FilesMatch>
# Security headers
<IfModule mod_headers.c>
Header set X-Content-Type-Options "nosniff"
Header set X-Frame-Options "SAMEORIGIN"
Header set X-XSS-Protection "0"
Header set Referrer-Policy "strict-origin-when-cross-origin"
Header set Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()"
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
Header set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdnjs.cloudflare.com; font-src 'self' https://fonts.gstatic.com https://cdnjs.cloudflare.com; img-src 'self' data: https://ui-avatars.com https://maps.gstatic.com; connect-src 'self' https://challenges.cloudflare.com; frame-src https://www.google.com https://challenges.cloudflare.com; frame-ancestors 'self'"
</IfModule>
# Enable compression
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/plain text/css application/json application/javascript text/xml
</IfModule>
# Browser caching
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType image/jpg "access plus 1 month"
ExpiresByType image/jpeg "access plus 1 month"
ExpiresByType image/png "access plus 1 month"
ExpiresByType image/gif "access plus 1 month"
ExpiresByType image/webp "access plus 1 month"
ExpiresByType text/css "access plus 1 week"
ExpiresByType application/javascript "access plus 1 week"
</IfModule>
+47
View File
@@ -0,0 +1,47 @@
# ========================================
# MSPE Website - Environment Configuration
# ========================================
# Copy this file to .env and fill in your actual values.
# NEVER commit the .env file to version control.
# ── Application ───────────────────────────────────────────────
APP_ENV=production # 'production' or 'development'
# ── Database ──────────────────────────────────────────────────
DB_TYPE=mysql # Only 'mysql' is supported in production
DB_HOST=localhost
DB_NAME=mspe_website
DB_USER=
DB_PASS=
# ── Admin Credentials ─────────────────────────────────────────
# ADMIN_PASS must be a bcrypt hash from password_hash().
# Generate one with: php -r "echo password_hash('your-password', PASSWORD_DEFAULT);"
ADMIN_USER=admin
ADMIN_PASS=
# ── JWT Secret ────────────────────────────────────────────────
# REQUIRED in production. Generate with: php -r "echo bin2hex(random_bytes(32));"
# The server will refuse to start if this is left as the default.
JWT_SECRET=change-me-in-env-file
# ── Site Settings ─────────────────────────────────────────────
SITE_NAME=MSPE
SITE_URL=https://mspe.pro
ADMIN_EMAIL=info@mspe.pro
# ── SMTP Email ────────────────────────────────────────────────
# Used for contact form notifications and admin emails.
# These can also be overridden in Admin > Settings.
SMTP_HOST=
SMTP_PORT=465
SMTP_USER=
SMTP_PASS=
SMTP_FROM_NAME=MSPE
SMTP_FROM_EMAIL=info@mspe.pro
# ── Cloudflare Turnstile (Optional) ──────────────────────────
# If set, enables human verification on the contact form.
# Get keys at: https://dash.cloudflare.com/turnstile
TURNSTILE_SITE_KEY=
TURNSTILE_SECRET_KEY=
+85
View File
@@ -0,0 +1,85 @@
# MSPE Public Folder .htaccess
RewriteEngine On
RewriteBase /
# Disable directory listing
Options -Indexes
# Force HTTPS
RewriteCond %{HTTPS} off
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
# Handle SPA-style routing if needed - redirect 404s to index
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} !^/api/
RewriteCond %{REQUEST_URI} !^/admin/
RewriteRule ^.*$ 404.html [L]
# Block dev/test/seed scripts in production
<FilesMatch "^(db_seed|db_test|email-test)\.php$">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
</FilesMatch>
# Protect .env files
<FilesMatch "^\.env">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
</FilesMatch>
# Prevent access to hidden files
<FilesMatch "^\.">
Order allow,deny
Deny from all
</FilesMatch>
# Prevent access to JSON data files from web
<FilesMatch "\.(json)$">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
</FilesMatch>
# Security headers
<IfModule mod_headers.c>
Header set X-Content-Type-Options "nosniff"
Header set X-Frame-Options "SAMEORIGIN"
Header set X-XSS-Protection "0"
Header set Referrer-Policy "strict-origin-when-cross-origin"
Header set Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()"
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
Header set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdnjs.cloudflare.com; font-src 'self' https://fonts.gstatic.com https://cdnjs.cloudflare.com; img-src 'self' data: https://ui-avatars.com https://maps.gstatic.com https://www.google.com; connect-src 'self' https://challenges.cloudflare.com; frame-src https://www.google.com https://challenges.cloudflare.com; frame-ancestors 'self'"
</IfModule>
# Enable compression
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/plain text/css application/json application/javascript text/xml
</IfModule>
# Browser caching
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType image/jpg "access plus 1 month"
ExpiresByType image/jpeg "access plus 1 month"
ExpiresByType image/png "access plus 1 month"
ExpiresByType image/gif "access plus 1 month"
ExpiresByType image/webp "access plus 1 month"
ExpiresByType text/css "access plus 1 week"
ExpiresByType application/javascript "access plus 1 week"
</IfModule>
+104
View File
@@ -0,0 +1,104 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Page Not Found | MSPE - Architects of Digital Resilience</title>
<meta name="description" content="The page you are looking for could not be found. Return to MSPE homepage.">
<link rel="icon" type="image/png" href="images/logo/logo.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'">
<noscript><link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet"></noscript>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha384-t1nt8BQoYMLFN5p42tRAtuAAFQaCQODekUVeKKZrEnEyp4H2R0RHFz0KWpmj7i8g" crossorigin="anonymous" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha384-t1nt8BQoYMLFN5p42tRAtuAAFQaCQODekUVeKKZrEnEyp4H2R0RHFz0KWpmj7i8g" crossorigin="anonymous"></noscript>
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/ultimate-ui.css" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="css/ultimate-ui.css"></noscript>
<meta name="robots" content="noindex, follow">
<style>
body {
font-family: 'DM Sans', sans-serif;
background: linear-gradient(135deg, #0a1628 0%, #0f2744 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
text-align: center;
padding: 2rem;
margin: 0;
}
.error-container {
max-width: 600px;
}
.error-code {
font-size: 8rem;
font-weight: 700;
background: linear-gradient(135deg, #0ea5e9, #0d9488);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
line-height: 1;
margin-bottom: 1rem;
}
.error-title {
font-size: 2rem;
margin-bottom: 1rem;
}
.error-message {
color: #94a3b8;
font-size: 1.1rem;
margin-bottom: 2rem;
line-height: 1.7;
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 1rem 2rem;
background: linear-gradient(135deg, #0ea5e9, #0d9488);
color: #fff;
text-decoration: none;
border-radius: 30px;
font-weight: 600;
transition: all 0.3s ease;
}
.btn:hover {
transform: translateY(-3px);
box-shadow: 0 10px 30px rgba(14, 165, 233, 0.3);
}
.links {
margin-top: 2rem;
display: flex;
justify-content: center;
gap: 2rem;
}
.links a {
color: #94a3b8;
text-decoration: none;
transition: color 0.3s ease;
}
.links a:hover {
color: #0ea5e9;
}
</style>
</head>
<body>
<div class="error-container">
<div class="error-code">404</div>
<h1 class="error-title">Page Not Found</h1>
<p class="error-message">
Oops! The page you're looking for doesn't exist or has been moved.
Let's get you back on track.
</p>
<a href="index.html" class="btn">
<i class="fas fa-home"></i> Back to Homepage
</a>
<div class="links">
<a href="services.html">Services</a>
<a href="about.html">About Us</a>
<a href="contact.html">Contact</a>
</div>
</div>
</body>
</html>
+492
View File
@@ -0,0 +1,492 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="About MSPE — Beirut-based architects of digital resilience. Meet the cybersecurity and cloud experts helping businesses across Lebanon and the MENA region engineer resilient digital futures.">
<title>About Us | MSPE - Architects of Digital Resilience</title>
<link rel="icon" type="image/png" href="images/logo/logo.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Playfair+Display:wght@600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'">
<noscript><link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Playfair+Display:wght@600;700&display=swap" rel="stylesheet"></noscript>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha384-t1nt8BQoYMLFN5p42tRAtuAAFQaCQODekUVeKKZrEnEyp4H2R0RHFz0KWpmj7i8g" crossorigin="anonymous" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha384-t1nt8BQoYMLFN5p42tRAtuAAFQaCQODekUVeKKZrEnEyp4H2R0RHFz0KWpmj7i8g" crossorigin="anonymous"></noscript>
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/pages.css">
<link rel="stylesheet" href="css/ultimate-ui.css" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="css/ultimate-ui.css"></noscript>
<link rel="stylesheet" href="css/header-fix.css">
<link rel="canonical" href="https://mspe.pro/about.html">
<!-- Open Graph -->
<meta property="og:type" content="website">
<meta property="og:url" content="https://mspe.pro/about.html">
<meta property="og:title" content="About Us | MSPE - Architects of Digital Resilience">
<meta property="og:description" content="Learn about MSPE - Architects of Digital Resilience. Our vision, mission, and the team engineering digital futures for businesses worldwide.">
<meta property="og:image" content="https://mspe.pro/images/logo/logo.png">
<meta property="og:site_name" content="MSPE">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="@mspe_pro">
<meta name="twitter:title" content="About MSPE - Architects of Digital Resilience">
<meta name="twitter:description" content="Born from a bold belief: technology should be a catalyst for human potential. Discover the MSPE story.">
<meta name="twitter:image" content="https://mspe.pro/images/logo/logo.png">
</head>
<body>
<!-- Skip to Content (Accessibility) -->
<a href="#main-content" class="skip-to-content">Skip to main content</a>
<!-- Navigation -->
<header class="header" id="header">
<nav class="nav container">
<a href="index.html" class="nav-logo">
<img src="images/logo/logo.png" alt="MSPE PRO" class="logo-image" width="200" height="50">
</a>
<div class="nav-menu" id="nav-menu">
<ul class="nav-list">
<li class="nav-item"><a href="index.html" class="nav-link">Home</a></li>
<li class="nav-item"><a href="about.html" class="nav-link active">About Us</a></li>
<li class="nav-item dropdown">
<a href="services.html" class="nav-link">Services <i class="fas fa-chevron-down"></i></a>
<ul class="dropdown-menu">
<li><a href="services.html#it-support">IT Support & Maintenance</a></li>
<li><a href="services.html#cybersecurity">Cybersecurity Solutions</a></li>
<li><a href="services.html#cloud">Cloud Services</a></li>
<li><a href="services.html#consulting">Business Consulting</a></li>
</ul>
</li>
<li class="nav-item"><a href="portfolio.html" class="nav-link">Portfolio</a></li>
<li class="nav-item"><a href="news.html" class="nav-link">News & Events</a></li>
<li class="nav-item"><a href="contact.html" class="nav-link">Contact</a></li>
</ul>
</div>
<div class="nav-actions">
<a href="calendar.html" class="btn btn-primary">Book Meeting</a>
<button class="nav-toggle" id="nav-toggle" aria-label="Toggle navigation menu" aria-expanded="false">
<span></span>
<span></span>
<span></span>
</button>
</div>
</nav>
</header>
<!-- Page Hero -->
<section class="page-hero">
<div class="page-hero-bg"></div>
<div class="container">
<div class="page-hero-content">
<span class="page-label">Who We Are</span>
<h1 class="page-title">About MSPE</h1>
<p class="page-description">We're architects of digital resilience - building the technological foundations that empower businesses to thrive in an ever-evolving digital landscape.</p>
</div>
</div>
</section>
<!-- About Intro -->
<section class="about-intro section">
<div class="container">
<div class="about-intro-grid">
<div class="about-intro-content">
<span class="section-label">Our Origin</span>
<h2 class="section-title">Born From a Bold Belief</h2>
<p>MSPE emerged from a fundamental conviction: technology should be a catalyst for human potential, not a barrier to it. We saw too many businesses struggling with fragmented solutions, reactive security, and technology that served itself rather than its users.</p>
<p>We founded MSPE to be different - to be the company that sees the whole picture, that thinks three steps ahead, and that treats every client's challenge as if it were our own. We're not here to sell services; we're here to architect futures.</p>
<div class="about-highlights">
<div class="highlight-item">
<div class="highlight-icon"><i class="fas fa-rocket"></i></div>
<div class="highlight-content">
<h4>Our Mission</h4>
<p><strong>To transform complexity into competitive advantage.</strong> We engineer technology ecosystems that don't just solve today's problems - they anticipate tomorrow's opportunities. Every solution we craft is designed to amplify human capability, protect digital assets, and unlock growth that once seemed impossible.</p>
</div>
</div>
<div class="highlight-item">
<div class="highlight-icon"><i class="fas fa-compass"></i></div>
<div class="highlight-content">
<h4>Our Vision</h4>
<p><strong>A world where every organization, regardless of size, operates with the digital resilience of a Fortune 500 company.</strong> We envision a future where security is invisible yet impenetrable, where technology amplifies creativity rather than constraining it, and where businesses are limited only by their ambition - never by their infrastructure.</p>
</div>
</div>
</div>
</div>
<div class="about-intro-visual">
<div class="about-image-grid">
<div class="about-image about-image-1">
<img src="images/about/team-collab.svg" alt="MSPE Team Collaboration" style="width:100%; height:100%; object-fit:cover; border-radius:16px;" width="1200" height="900">
</div>
<div class="about-image about-image-2">
<img src="images/about/data-center.svg" alt="MSPE Data Center Operations" style="width:100%; height:100%; object-fit:cover; border-radius:16px;" width="1200" height="900">
</div>
<div class="about-image about-image-3">
<img src="images/about/client-success.svg" alt="MSPE Client Success" style="width:100%; height:100%; object-fit:cover; border-radius:16px;" width="1200" height="900">
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Values -->
<section class="values section">
<div class="container">
<div class="section-header">
<span class="section-label">What Drives Us</span>
<h2 class="section-title">The Six Pillars of MSPE</h2>
<p class="section-description">These aren't just values - they're non-negotiable commitments that define who we are</p>
</div>
<div class="values-grid">
<div class="value-card">
<div class="value-icon"><i class="fas fa-brain"></i></div>
<h3>Anticipatory Intelligence</h3>
<p>We don't wait for problems to find you. We study patterns, analyze trends, and build defenses against threats that don't yet exist. Prevention isn't just better than cure - it's the only acceptable approach.</p>
</div>
<div class="value-card">
<div class="value-icon"><i class="fas fa-eye"></i></div>
<h3>Radical Transparency</h3>
<p>No black boxes. No hidden agendas. No technical jargon designed to confuse. We believe you have the right to understand exactly what we're doing, why we're doing it, and what it costs.</p>
</div>
<div class="value-card">
<div class="value-icon"><i class="fas fa-chess-knight"></i></div>
<h3>Strategic Courage</h3>
<p>We'll tell you what you need to hear, not what you want to hear. If your current path has risks, we'll say so. If a cheaper solution won't work, we'll explain why. Your trust is worth more than any contract.</p>
</div>
<div class="value-card">
<div class="value-icon"><i class="fas fa-infinity"></i></div>
<h3>Boundless Curiosity</h3>
<p>Technology never stops evolving, and neither do we. Every challenge is a puzzle to solve, every project an opportunity to learn. We bring the same enthusiasm to our hundredth project as our first.</p>
</div>
<div class="value-card">
<div class="value-icon"><i class="fas fa-fingerprint"></i></div>
<h3>Bespoke Precision</h3>
<p>Cookie-cutter solutions create cookie-cutter results. Every organization is unique, and every solution we create is tailored to your specific context, culture, and aspirations.</p>
</div>
<div class="value-card">
<div class="value-icon"><i class="fas fa-shield-virus"></i></div>
<h3>Uncompromising Security</h3>
<p>Security isn't a feature - it's the foundation. It's woven into every recommendation we make, every system we design, every line of code we touch. There are no shortcuts when trust is on the line.</p>
</div>
</div>
</div>
</section>
<!-- Our Approach Section -->
<section class="team section">
<div class="container">
<div class="section-header">
<span class="section-label">How We Work</span>
<h2 class="section-title">The MSPE Approach</h2>
<p class="section-description">A methodology built for results, not billable hours</p>
</div>
<div class="team-grid" style="grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));">
<div class="team-card" style="text-align: center; padding: 2.5rem;">
<div style="width: 80px; height: 80px; background: linear-gradient(135deg, var(--accent-cyan), var(--secondary-teal)); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 1.5rem;">
<span style="font-size: 2rem; font-weight: bold; color: white;">01</span>
</div>
<h3 style="font-size: 1.3rem; margin-bottom: 0.5rem;">Deep Discovery</h3>
<p style="opacity: 0.8;">We don't start with solutions - we start with questions. Understanding your business, challenges, and aspirations before touching any technology.</p>
</div>
<div class="team-card" style="text-align: center; padding: 2.5rem;">
<div style="width: 80px; height: 80px; background: linear-gradient(135deg, var(--accent-cyan), var(--secondary-teal)); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 1.5rem;">
<span style="font-size: 2rem; font-weight: bold; color: white;">02</span>
</div>
<h3 style="font-size: 1.3rem; margin-bottom: 0.5rem;">Strategic Architecture</h3>
<p style="opacity: 0.8;">We design solutions that work today and scale tomorrow. Every recommendation is mapped to your goals, budget, and growth trajectory.</p>
</div>
<div class="team-card" style="text-align: center; padding: 2.5rem;">
<div style="width: 80px; height: 80px; background: linear-gradient(135deg, var(--accent-cyan), var(--secondary-teal)); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 1.5rem;">
<span style="font-size: 2rem; font-weight: bold; color: white;">03</span>
</div>
<h3 style="font-size: 1.3rem; margin-bottom: 0.5rem;">Precision Execution</h3>
<p style="opacity: 0.8;">Implementation with minimal disruption and maximum impact. We move fast, but never at the expense of quality or security.</p>
</div>
<div class="team-card" style="text-align: center; padding: 2.5rem;">
<div style="width: 80px; height: 80px; background: linear-gradient(135deg, var(--accent-cyan), var(--secondary-teal)); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 1.5rem;">
<span style="font-size: 2rem; font-weight: bold; color: white;">04</span>
</div>
<h3 style="font-size: 1.3rem; margin-bottom: 0.5rem;">Continuous Evolution</h3>
<p style="opacity: 0.8;">Our relationship doesn't end at deployment. We monitor, optimize, and evolve your systems as your business grows and threats change.</p>
</div>
</div>
</div>
</section>
<!-- Team Section -->
<section class="team-section section" id="team">
<div class="container">
<div class="section-header">
<span class="section-label">Our People</span>
<h2 class="section-title">Meet the Team</h2>
<p class="section-description">Experts dedicated to building resilient, future-ready systems</p>
</div>
<div class="team-grid" id="team-grid" data-team-grid="true">
<div class="team-card">
<div class="team-image">
<!-- Replace with real photo -->
<img src="images/team/member-1.svg" alt="Founder & CEO" width="400" height="400">
<div class="team-social">
<a href="https://www.linkedin.com/company/mspe-pro" aria-label="LinkedIn" target="_blank" rel="noopener noreferrer"><i class="fab fa-linkedin-in"></i></a>
<a href="mailto:info@mspe.pro" aria-label="Email"><i class="fas fa-envelope"></i></a>
</div>
</div>
<div class="team-info">
<h4>Kareem Mansour</h4>
<p>Founder & CEO</p>
<small style="font-size: 0.78rem; color: #64748b; line-height: 1.5; margin-top: 0.4rem; display: block;">Network &amp; infrastructure specialist · 12+ yrs across enterprise and SMB environments.</small>
</div>
</div>
<div class="team-card">
<div class="team-image">
<!-- Replace with real photo -->
<img src="images/team/member-2.svg" alt="CTO" width="400" height="400">
<div class="team-social">
<a href="https://www.linkedin.com/company/mspe-pro" aria-label="LinkedIn" target="_blank" rel="noopener noreferrer"><i class="fab fa-linkedin-in"></i></a>
<a href="mailto:info@mspe.pro" aria-label="Email"><i class="fas fa-envelope"></i></a>
</div>
</div>
<div class="team-info">
<h4>Nour Haddad</h4>
<p>Chief Technology Officer</p>
<small style="font-size: 0.78rem; color: #64748b; line-height: 1.5; margin-top: 0.4rem; display: block;">Systems architect with deep expertise in cloud platforms and enterprise IT design.</small>
</div>
</div>
<div class="team-card">
<div class="team-image">
<!-- Replace with real photo -->
<img src="images/team/member-3.svg" alt="Security Lead" width="400" height="400">
<div class="team-social">
<a href="https://www.linkedin.com/company/mspe-pro" aria-label="LinkedIn" target="_blank" rel="noopener noreferrer"><i class="fab fa-linkedin-in"></i></a>
<a href="mailto:info@mspe.pro" aria-label="Email"><i class="fas fa-envelope"></i></a>
</div>
</div>
<div class="team-info">
<h4>Ranya Aziz</h4>
<p>Head of Cybersecurity</p>
<small style="font-size: 0.78rem; color: #64748b; line-height: 1.5; margin-top: 0.4rem; display: block;">Penetration tester and SIEM specialist · CEH certified and ISO 27001 lead auditor.</small>
</div>
</div>
<div class="team-card">
<div class="team-image">
<!-- Replace with real photo -->
<img src="images/team/member-4.svg" alt="Cloud Architect" width="400" height="400">
<div class="team-social">
<a href="https://www.linkedin.com/company/mspe-pro" aria-label="LinkedIn" target="_blank" rel="noopener noreferrer"><i class="fab fa-linkedin-in"></i></a>
<a href="mailto:info@mspe.pro" aria-label="Email"><i class="fas fa-envelope"></i></a>
</div>
</div>
<div class="team-info">
<h4>Elie Khoury</h4>
<p>Cloud Solutions Architect</p>
<small style="font-size: 0.78rem; color: #64748b; line-height: 1.5; margin-top: 0.4rem; display: block;">Azure &amp; AWS certified · specializes in cloud migration, hybrid infrastructure, and DevOps.</small>
</div>
</div>
</div>
</div>
</section>
<!-- Testimonials Section -->
<section class="testimonials section" id="testimonials">
<div class="container">
<div class="section-header">
<span class="section-label">Client Voices</span>
<h2 class="section-title">What Clients Say</h2>
<p class="section-description">Real feedback from teams we support and protect</p>
</div>
<div class="testimonials-slider" id="testimonials-slider">
<div class="testimonial-card active">
<div class="testimonial-content">
<div class="quote-icon"><i class="fas fa-quote-left"></i></div>
<p class="testimonial-text">"MSPE transformed our IT infrastructure in weeks. Their proactive approach to security and hands-on support made us feel like their only client. Fast, responsive, and genuinely knowledgeable."</p>
<div class="testimonial-author">
<div class="author-avatar">RH</div>
<div class="author-info">
<h4>Rami Haddad</h4>
<p>Operations Manager, Levant Trading Co.</p>
</div>
</div>
</div>
</div>
<div class="testimonial-card">
<div class="testimonial-content">
<div class="quote-icon"><i class="fas fa-quote-left"></i></div>
<p class="testimonial-text">"We needed a partner who understood both cloud migration and cybersecurity. MSPE delivered on both fronts with zero downtime during our transition. Highly recommend their team."</p>
<div class="testimonial-author">
<div class="author-avatar">NK</div>
<div class="author-info">
<h4>Nadia Khoury</h4>
<p>IT Director, Cedar Financial Group</p>
</div>
</div>
</div>
</div>
<div class="testimonial-card">
<div class="testimonial-content">
<div class="quote-icon"><i class="fas fa-quote-left"></i></div>
<p class="testimonial-text">"What sets MSPE apart is their honesty and transparency. They don't oversell — they assess what you actually need and deliver exactly that. Our security posture improved dramatically."</p>
<div class="testimonial-author">
<div class="author-avatar">MS</div>
<div class="author-info">
<h4>Marc Sfeir</h4>
<p>CEO, BlueLine Digital Agency</p>
</div>
</div>
</div>
</div>
</div>
<div class="testimonials-nav">
<button class="testimonial-prev" aria-label="Previous testimonial">
<i class="fas fa-chevron-left"></i>
</button>
<div class="testimonial-dots" id="testimonial-dots"></div>
<button class="testimonial-next" aria-label="Next testimonial">
<i class="fas fa-chevron-right"></i>
</button>
</div>
</div>
</section>
<!-- Technologies Section -->
<section class="certifications section">
<div class="container">
<div class="section-header">
<span class="section-label">Our Expertise</span>
<h2 class="section-title">Technologies We Master</h2>
<p class="section-description">We stay at the forefront of technology to deliver cutting-edge solutions</p>
</div>
<div class="cert-grid" style="display:grid; grid-template-columns:repeat(auto-fit, minmax(140px, 1fr)); gap:2rem; margin-top:2rem;">
<div class="cert-item" style="text-align:center; padding:1.5rem; background:rgba(255,255,255,0.03); border:1px solid rgba(255,255,255,0.08); border-radius:12px; transition:all 0.3s ease;">
<img src="https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/azure/azure-original.svg" alt="Microsoft Azure" width="48" height="48" style="height:48px; margin:0 auto 0.75rem; display:block;">
<span style="font-size:0.85rem;">Microsoft Azure</span>
</div>
<div class="cert-item" style="text-align:center; padding:1.5rem; background:rgba(255,255,255,0.03); border:1px solid rgba(255,255,255,0.08); border-radius:12px; transition:all 0.3s ease;">
<img src="https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/amazonwebservices/amazonwebservices-original-wordmark.svg" alt="Amazon Web Services" width="48" height="48" style="height:48px; margin:0 auto 0.75rem; display:block; filter:brightness(0) invert(1);">
<span style="font-size:0.85rem;">Amazon Web Services</span>
</div>
<div class="cert-item" style="text-align:center; padding:1.5rem; background:rgba(255,255,255,0.03); border:1px solid rgba(255,255,255,0.08); border-radius:12px; transition:all 0.3s ease;">
<img src="https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/googlecloud/googlecloud-original.svg" alt="Google Cloud" width="48" height="48" style="height:48px; margin:0 auto 0.75rem; display:block;">
<span style="font-size:0.85rem;">Google Cloud</span>
</div>
<div class="cert-item" style="text-align:center; padding:1.5rem; background:rgba(255,255,255,0.03); border:1px solid rgba(255,255,255,0.08); border-radius:12px; transition:all 0.3s ease;">
<img src="https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/linux/linux-original.svg" alt="Linux" width="48" height="48" style="height:48px; margin:0 auto 0.75rem; display:block;">
<span style="font-size:0.85rem;">Linux & Open Source</span>
</div>
<div class="cert-item" style="text-align:center; padding:1.5rem; background:rgba(255,255,255,0.03); border:1px solid rgba(255,255,255,0.08); border-radius:12px; transition:all 0.3s ease;">
<img src="https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/kubernetes/kubernetes-original.svg" alt="Kubernetes" width="48" height="48" style="height:48px; margin:0 auto 0.75rem; display:block;">
<span style="font-size:0.85rem;">Kubernetes</span>
</div>
<div class="cert-item" style="text-align:center; padding:1.5rem; background:rgba(255,255,255,0.03); border:1px solid rgba(255,255,255,0.08); border-radius:12px; transition:all 0.3s ease;">
<img src="https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/docker/docker-original.svg" alt="Docker" width="48" height="48" style="height:48px; margin:0 auto 0.75rem; display:block;">
<span style="font-size:0.85rem;">Docker</span>
</div>
</div>
</div>
</section>
<!-- CTA -->
<section class="cta">
<div class="cta-bg"></div>
<div class="container">
<div class="cta-content">
<h2 class="cta-title">Ready to Work Together?</h2>
<p class="cta-description">Let's discuss how MSPE can help transform your business with our expert services.</p>
<div class="cta-buttons">
<a href="contact.html" class="btn btn-primary btn-lg">Contact Us Today</a>
<a href="services.html" class="btn btn-outline btn-lg">Explore Services</a>
</div>
</div>
</div>
</section>
<!-- Footer -->
<footer class="footer">
<div class="footer-main">
<div class="container">
<div class="footer-grid">
<div class="footer-brand">
<a href="index.html" class="footer-logo">
<img src="images/logo/logo.png" alt="MSPE PRO" class="logo-image" width="200" height="50">
</a>
<p class="footer-description">We engineer possibilities. Transforming complexity into competitive advantage through innovative technology solutions and uncompromising security.</p>
<div class="footer-social">
<a href="https://www.facebook.com/mspe.pro" class="social-link" title="Facebook" target="_blank" rel="noopener noreferrer"><i class="fab fa-facebook-f"></i></a>
<a href="https://www.linkedin.com/company/mspe-pro" class="social-link" title="LinkedIn" target="_blank" rel="noopener noreferrer"><i class="fab fa-linkedin-in"></i></a>
<a href="https://x.com/mspe_pro" class="social-link" title="X (Twitter)" target="_blank" rel="noopener noreferrer"><i class="fab fa-twitter"></i></a>
<a href="https://www.instagram.com/mspe.pro" class="social-link" title="Instagram" target="_blank" rel="noopener noreferrer"><i class="fab fa-instagram"></i></a>
</div>
</div>
<div class="footer-links">
<h4 class="footer-title">Quick Links</h4>
<ul>
<li><a href="about.html">About Us</a></li>
<li><a href="services.html">Our Services</a></li>
<li><a href="portfolio.html">Portfolio</a></li>
<li><a href="news.html">News & Events</a></li>
<li><a href="contact.html">Contact Us</a></li>
</ul>
</div>
<div class="footer-links">
<h4 class="footer-title">Services</h4>
<ul>
<li><a href="services.html#it-support">IT Support & Maintenance</a></li>
<li><a href="services.html#cybersecurity">Cybersecurity Solutions</a></li>
<li><a href="services.html#cloud">Cloud Services</a></li>
<li><a href="services.html#consulting">Business Consulting</a></li>
</ul>
</div>
<div class="footer-contact">
<h4 class="footer-title">Contact Info</h4>
<ul class="contact-list">
<li>
<i class="fas fa-envelope"></i>
<span data-site-email>info@mspe.pro</span>
</li>
<li>
<i class="fas fa-phone"></i>
<a href="tel:+96178782023" data-site-phone style="color:inherit;">+961 78 782 023</a>
</li>
<li>
<i class="fas fa-clock"></i>
<span>Available for consultations</span>
</li>
<li>
<i class="fas fa-map-marker-alt"></i>
<span>Beirut, Lebanon</span>
</li>
</ul>
</div>
</div>
</div>
</div>
<div class="footer-bottom">
<div class="container">
<p>&copy; 2026 MSPE - Architects of Digital Resilience. All Rights Reserved.</p>
<div class="footer-bottom-links">
<a href="privacy.html">Privacy Policy</a>
<a href="terms.html">Terms of Service</a>
</div>
</div>
</div>
</footer>
<!-- Back to Top -->
<button class="back-to-top" id="back-to-top" aria-label="Back to top">
<i class="fas fa-chevron-up"></i>
</button>
<script src="js/main.js" defer></script>
<script src="js/ultimate-ui.js" defer></script>
</body>
</html>
+930
View File
@@ -0,0 +1,930 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow, noarchive">
<title>Availability - MSPE Admin</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha384-t1nt8BQoYMLFN5p42tRAtuAAFQaCQODekUVeKKZrEnEyp4H2R0RHFz0KWpmj7i8g" crossorigin="anonymous">
<link rel="stylesheet" href="/admin/css/admin.css">
<style>
.availability-grid {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 2rem;
}
.add-slot-form {
background: white;
border-radius: 10px;
box-shadow: var(--shadow-sm);
padding: 1.5rem;
height: fit-content;
}
.add-slot-form h3 {
margin-bottom: 1.5rem;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
font-weight: 600;
color: var(--gray-700);
margin-bottom: 0.5rem;
font-size: 0.875rem;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--gray-300);
border-radius: var(--radius-sm);
font-size: 0.875rem;
font-family: var(--font-main);
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.1);
}
.quick-slots {
margin-bottom: 1.5rem;
}
.quick-slots label {
font-weight: 600;
margin-bottom: 0.75rem;
display: block;
font-size: 0.875rem;
}
.quick-slots-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
}
.quick-slot-btn {
padding: 0.5rem;
border: 1px solid var(--gray-300);
background: white;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
transition: all 0.3s ease;
}
.quick-slot-btn:hover {
border-color: var(--primary);
color: var(--primary);
}
.bulk-actions {
display: flex;
gap: 0.75rem;
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--gray-200);
}
.bulk-actions button {
flex: 1;
}
.slots-list {
background: white;
border-radius: 10px;
box-shadow: var(--shadow-sm);
}
.slots-list-header {
padding: 1.5rem;
border-bottom: 1px solid var(--gray-200);
display: flex;
justify-content: space-between;
align-items: center;
}
.slots-list-header h3 {
margin: 0;
}
.slots-filters {
display: flex;
gap: 1rem;
}
.slot-date-filter {
display: flex;
align-items: center;
gap: 0.5rem;
}
.slots-table {
width: 100%;
border-collapse: collapse;
}
.slots-table th {
background: var(--gray-100);
padding: 1rem;
text-align: left;
font-weight: 600;
color: var(--gray-600);
font-size: 0.875rem;
}
.slots-table td {
padding: 1rem;
border-bottom: 1px solid var(--gray-200);
font-size: 0.875rem;
}
.slots-table tr:hover {
background: var(--gray-50);
}
.slot-time {
font-weight: 600;
color: var(--primary);
}
.slot-capacity {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.slot-capacity i {
font-size: 0.75rem;
color: var(--gray-400);
}
.action-btn {
padding: 0.375rem 0.75rem;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
margin-right: 0.5rem;
transition: all 0.3s ease;
}
.action-btn.delete {
background: #fee2e2;
color: #dc2626;
}
.action-btn.delete:hover {
background: #fecaca;
}
.action-btn.toggle {
background: #e0f2fe;
color: #0369a1;
}
.action-btn.toggle:hover {
background: #bae6fd;
}
.empty-state {
padding: 4rem 2rem;
text-align: center;
color: var(--gray-500);
}
.empty-state i {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.3;
}
.upcoming-section {
background: var(--dark);
border-radius: 10px;
padding: 1.5rem;
margin-top: 2rem;
color: white;
}
.upcoming-section h4 {
margin-bottom: 1rem;
}
.upcoming-bookings {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.upcoming-item {
background: rgba(255,255,255,0.05);
border-radius: 8px;
padding: 1rem;
display: flex;
align-items: center;
gap: 1rem;
}
.upcoming-item-date {
background: var(--primary);
border-radius: 6px;
padding: 0.5rem 0.75rem;
text-align: center;
min-width: 60px;
}
.upcoming-item-date .day {
font-size: 1.25rem;
font-weight: 700;
}
.upcoming-item-date .month {
font-size: 0.75rem;
text-transform: uppercase;
}
@media (max-width: 1024px) {
.availability-grid {
grid-template-columns: 1fr;
}
.quick-slots-grid {
grid-template-columns: repeat(4, 1fr);
}
}
</style>
</head>
<body>
<div class="admin-wrapper">
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<a href="/admin/dashboard.html" class="sidebar-logo">
<i class="fas fa-cube"></i>
<span>MSPE</span>
</a>
<button class="sidebar-toggle" id="sidebar-toggle">
<i class="fas fa-bars"></i>
</button>
</div>
<nav class="sidebar-nav">
<div class="nav-section">
<span class="nav-section-title">Main</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/dashboard.html" class="nav-link">
<i class="fas fa-th-large"></i>
<span>Dashboard</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Content</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/pages.html" class="nav-link">
<i class="fas fa-file-alt"></i>
<span>Pages</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/services.html" class="nav-link">
<i class="fas fa-concierge-bell"></i>
<span>Services</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/portfolio.html" class="nav-link">
<i class="fas fa-briefcase"></i>
<span>Portfolio</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/news.html" class="nav-link">
<i class="fas fa-newspaper"></i>
<span>News & Events</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/testimonials.html" class="nav-link">
<i class="fas fa-quote-right"></i>
<span>Testimonials</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/team.html" class="nav-link">
<i class="fas fa-users"></i>
<span>Team</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Media</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/media.html" class="nav-link">
<i class="fas fa-images"></i>
<span>Media Library</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Inquiries</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/messages.html" class="nav-link">
<i class="fas fa-envelope"></i>
<span>Messages</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/subscribers.html" class="nav-link">
<i class="fas fa-bell"></i>
<span>Subscribers</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/bookings.html" class="nav-link">
<i class="fas fa-calendar-check"></i>
<span>Bookings</span>
</a>
</li>
<li class="nav-item active">
<a href="/admin/availability.html" class="nav-link">
<i class="fas fa-clock"></i>
<span>Availability</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Settings</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/settings.html" class="nav-link">
<i class="fas fa-cog"></i>
<span>Site Settings</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/users.html" class="nav-link">
<i class="fas fa-user-shield"></i>
<span>Admin Users</span>
</a>
</li>
</ul>
</div>
</nav>
<div class="sidebar-footer">
<a href="/index.html" class="view-site-btn" target="_blank">
<i class="fas fa-external-link-alt"></i>
<span>View Website</span>
</a>
</div>
</aside>
<main class="main-content">
<header class="admin-header">
<div class="header-left">
<button class="mobile-toggle" id="mobile-toggle">
<i class="fas fa-bars"></i>
</button>
<h1 class="page-title">Manage Availability</h1>
</div>
<div class="header-right">
<div class="header-user">
<div class="user-avatar">
<img src="https://ui-avatars.com/api/?name=Admin&background=0ea5e9&color=fff" alt="Admin">
</div>
</div>
</div>
</header>
<div class="content-wrapper">
<div class="availability-grid">
<div class="add-slot-form">
<h3><i class="fas fa-plus-circle" style="color: var(--primary); margin-right: 0.5rem;"></i> Add Time Slot</h3>
<form id="add-slot-form">
<div class="form-group">
<label>Date *</label>
<input type="date" name="date" required>
</div>
<div class="form-group">
<label>Time *</label>
<input type="time" name="time" required>
</div>
<div class="form-group">
<label>Capacity (number of clients)</label>
<input type="number" name="capacity" value="1" min="1" max="10">
</div>
<div class="form-group">
<label>Notes</label>
<textarea name="notes" rows="3" placeholder="Optional notes about this slot..."></textarea>
</div>
<div class="quick-slots">
<label>Quick Add Times</label>
<div class="quick-slots-grid">
<button type="button" class="quick-slot-btn" onclick="addQuickTime('09:00')">9:00 AM</button>
<button type="button" class="quick-slot-btn" onclick="addQuickTime('10:00')">10:00 AM</button>
<button type="button" class="quick-slot-btn" onclick="addQuickTime('11:00')">11:00 AM</button>
<button type="button" class="quick-slot-btn" onclick="addQuickTime('13:00')">1:00 PM</button>
<button type="button" class="quick-slot-btn" onclick="addQuickTime('14:00')">2:00 PM</button>
<button type="button" class="quick-slot-btn" onclick="addQuickTime('15:00')">3:00 PM</button>
<button type="button" class="quick-slot-btn" onclick="addQuickTime('16:00')">4:00 PM</button>
<button type="button" class="quick-slot-btn" onclick="addQuickTime('17:00')">5:00 PM</button>
<button type="button" class="quick-slot-btn" onclick="addQuickTime('18:00')">6:00 PM</button>
</div>
</div>
<div class="bulk-actions">
<button type="submit" class="btn btn-primary">
<i class="fas fa-plus"></i> Add Slot
</button>
<button type="button" class="btn btn-secondary" onclick="generateWeekSlots()">
<i class="fas fa-calendar-week"></i> Generate Week
</button>
</div>
</form>
<!-- Block Time Section -->
<div style="margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid var(--gray-200);">
<h3 style="margin-bottom: 1rem;"><i class="fas fa-ban" style="color: #ef4444; margin-right: 0.5rem;"></i> Block Time</h3>
<p style="font-size: 0.875rem; color: var(--gray-500); margin-bottom: 1rem;">Block a time slot to make it unavailable for public booking (vacation, personal time, etc.)</p>
<form id="block-time-form">
<div class="form-group">
<label>Date *</label>
<input type="date" name="block_date" required>
</div>
<div class="form-group">
<label>Time *</label>
<input type="time" name="block_time" required>
</div>
<div class="form-group">
<label>Reason (internal note)</label>
<input type="text" name="block_reason" placeholder="e.g., Personal appointment">
</div>
<button type="submit" class="btn btn-secondary" style="width: 100%; background: #fee2e2; color: #dc2626; border-color: #ef4444;">
<i class="fas fa-ban"></i> Block This Time
</button>
</form>
</div>
<div class="upcoming-section" id="upcoming-section" style="display: none;">
<h4><i class="fas fa-clock"></i> Upcoming Bookings</h4>
<div class="upcoming-bookings" id="upcoming-bookings"></div>
</div>
</div>
<div class="slots-list">
<div class="slots-list-header">
<h3>Availability Slots</h3>
<div class="slots-filters">
<div class="slot-date-filter">
<label>From:</label>
<input type="date" id="filter-date-from" style="padding: 0.5rem;">
</div>
<div class="slot-date-filter">
<label>To:</label>
<input type="date" id="filter-date-to" style="padding: 0.5rem;">
</div>
</div>
</div>
<!-- Blocked Times Section -->
<div id="blocked-times-section" style="display: none; background: #fef2f2; border-bottom: 1px solid #fecaca;">
<div style="padding: 1rem 1.5rem; display: flex; justify-content: space-between; align-items: center;">
<h4 style="margin: 0; color: #dc2626;"><i class="fas fa-ban" style="margin-right: 0.5rem;"></i> Blocked Times</h4>
</div>
<div id="blocked-times-list" style="padding: 0 1.5rem 1rem;"></div>
</div>
<div class="slots-table-container">
<table class="slots-table">
<thead>
<tr>
<th>Date</th>
<th>Time</th>
<th>Capacity</th>
<th>Status</th>
<th>Notes</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="slots-table-body">
<tr>
<td colspan="6">
<div class="empty-state">
<i class="fas fa-calendar-alt"></i>
<p>Loading availability slots...</p>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</main>
</div>
<script src="/admin/js/admin.js"></script>
<script>
let currentSlots = [];
let blockedTimes = [];
document.addEventListener('DOMContentLoaded', function() {
loadSlots();
loadBlockedTimes();
loadUpcomingBookings();
setupEventListeners();
setDefaultDate();
});
function setupEventListeners() {
document.getElementById('add-slot-form').addEventListener('submit', handleAddSlot);
document.getElementById('block-time-form').addEventListener('submit', handleBlockTime);
document.getElementById('filter-date-from').addEventListener('change', filterSlots);
document.getElementById('filter-date-to').addEventListener('change', filterSlots);
}
function setDefaultDate() {
const today = new Date().toISOString().split('T')[0];
document.querySelector('input[name="date"]').value = today;
document.querySelector('input[name="date"]').min = today;
document.querySelector('input[name="block_date"]').value = today;
document.querySelector('input[name="block_date"]').min = today;
}
function addQuickTime(time) {
document.querySelector('input[name="time"]').value = time;
}
async function handleBlockTime(e) {
e.preventDefault();
const form = e.target;
const blockData = {
date: form.block_date.value,
time: form.block_time.value,
reason: form.block_reason.value || 'No reason provided'
};
if (!blockData.date || !blockData.time) {
MSPE.showNotification('Please select date and time', 'error');
return;
}
const submitBtn = form.querySelector('button[type="submit"]');
const originalText = submitBtn.innerHTML;
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Blocking...';
try {
const response = await MSPE.API.post('bookings.php?action=block-time', blockData);
MSPE.showNotification('Time blocked successfully!', 'success');
form.reset();
setDefaultDate();
loadBlockedTimes();
} catch (error) {
console.error('Error blocking time:', error);
MSPE.showNotification('Failed to block time', 'error');
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
}
}
async function loadBlockedTimes() {
try {
const response = await MSPE.API.get('bookings.php?action=blocked-times');
blockedTimes = response.data || [];
renderBlockedTimes();
} catch (error) {
console.error('Error loading blocked times:', error);
}
}
function renderBlockedTimes() {
const section = document.getElementById('blocked-times-section');
const container = document.getElementById('blocked-times-list');
const today = new Date().toISOString().split('T')[0];
const futureBlocked = blockedTimes.filter(b => b.date >= today);
if (futureBlocked.length === 0) {
section.style.display = 'none';
return;
}
section.style.display = 'block';
container.innerHTML = futureBlocked.map(blocked => {
const date = new Date(blocked.date);
const formattedDate = date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
const formattedTime = new Date(`${blocked.date}T${blocked.time}`).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
return `
<div style="display: flex; align-items: center; justify-content: space-between; background: white; padding: 0.75rem 1rem; border-radius: 8px; margin-bottom: 0.5rem; border: 1px solid #fecaca;">
<div>
<span style="font-weight: 600; color: #dc2626;">${formattedDate}</span>
<span style="color: #64748b; margin: 0 0.5rem;">at</span>
<span style="font-weight: 600;">${formattedTime}</span>
${blocked.reason ? `<span style="color: #64748b; font-size: 0.875rem; margin-left: 0.5rem;">(${blocked.reason})</span>` : ''}
</div>
<button onclick="unblockTime('${blocked.id}')" style="background: none; border: none; color: #dc2626; cursor: pointer; padding: 0.25rem;">
<i class="fas fa-times"></i>
</button>
</div>
`;
}).join('');
}
async function unblockTime(id) {
if (!await MSPE.confirmDialog('Remove this blocked time?')) {
return;
}
try {
await MSPE.API.delete(`bookings.php?id=${id}&type=blocked`);
MSPE.showNotification('Time unblocked successfully!', 'success');
loadBlockedTimes();
} catch (error) {
console.error('Error unblocking time:', error);
MSPE.showNotification('Failed to unblock time', 'error');
}
}
async function handleAddSlot(e) {
e.preventDefault();
const form = e.target;
const data = new FormData(form);
const slotData = Object.fromEntries(data);
if (!slotData.date || !slotData.time) {
MSPE.showNotification('Please select date and time', 'error');
return;
}
const submitBtn = form.querySelector('button[type="submit"]');
const originalText = submitBtn.innerHTML;
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Adding...';
try {
const response = await MSPE.API.post('bookings.php?action=create-slot', slotData);
MSPE.showNotification('Time slot added successfully!', 'success');
form.reset();
setDefaultDate();
loadSlots();
} catch (error) {
console.error('Error adding slot:', error);
MSPE.showNotification('Failed to add time slot', 'error');
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
}
}
async function generateWeekSlots() {
const today = new Date();
const slotsToAdd = [];
const times = ['09:00', '10:00', '11:00', '13:00', '14:00', '15:00', '16:00', '17:00'];
for (let i = 1; i <= 7; i++) {
const date = new Date(today);
date.setDate(date.getDate() + i);
const dateStr = date.toISOString().split('T')[0];
times.forEach(time => {
slotsToAdd.push({
date: dateStr,
time: time,
capacity: 1
});
});
}
if (!await MSPE.confirmDialog(`This will add ${slotsToAdd.length} time slots for the next 7 days. Continue?`)) {
return;
}
let added = 0;
let failed = 0;
for (const slot of slotsToAdd) {
try {
await MSPE.API.post('bookings.php?action=create-slot', slot);
added++;
} catch (error) {
failed++;
}
}
if (added > 0) {
MSPE.showNotification(`${added} time slots added successfully!`, 'success');
}
if (failed > 0) {
MSPE.showNotification(`${failed} slots failed to add`, 'error');
}
loadSlots();
}
async function loadSlots() {
try {
const result = await MSPE.API.get('bookings.php');
currentSlots = result.data || [];
renderSlots();
} catch (error) {
console.error('Error loading slots:', error);
}
}
function renderSlots() {
const tbody = document.getElementById('slots-table-body');
const dateFrom = document.getElementById('filter-date-from').value;
const dateTo = document.getElementById('filter-date-to').value;
let filteredSlots = [...currentSlots];
if (dateFrom) {
filteredSlots = filteredSlots.filter(s => s.date >= dateFrom);
}
if (dateTo) {
filteredSlots = filteredSlots.filter(s => s.date <= dateTo);
}
if (filteredSlots.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="6">
<div class="empty-state">
<i class="fas fa-calendar-times"></i>
<p>No availability slots found</p>
<p style="font-size: 0.875rem; margin-top: 0.5rem;">Add time slots using the form on the left</p>
</div>
</td>
</tr>
`;
return;
}
filteredSlots.sort((a, b) => {
return new Date(`${a.date}T${a.time}`) - new Date(`${b.date}T${b.time}`);
});
tbody.innerHTML = filteredSlots.map(slot => {
const date = new Date(slot.date);
const formattedDate = date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
const formattedTime = new Date(`${slot.date}T${slot.time}`).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
const isAvailable = slot.is_available !== false;
return `
<tr>
<td>
<div style="font-weight: 600;">${formattedDate}</div>
</td>
<td>
<span class="slot-time">${formattedTime}</span>
</td>
<td>
<span class="slot-capacity">
<i class="fas fa-users"></i> ${slot.capacity}
</span>
</td>
<td>
<span class="status-badge ${isAvailable ? 'status-confirmed' : 'status-cancelled'}">
${isAvailable ? 'Available' : 'Disabled'}
</span>
</td>
<td>
<span style="color: var(--gray-500);">${slot.notes || '-'}</span>
</td>
<td>
<button class="action-btn toggle" onclick="toggleSlot('${slot.id}')">
<i class="fas fa-${isAvailable ? 'eye-slash' : 'eye'}"></i>
</button>
<button class="action-btn delete" onclick="deleteSlot('${slot.id}')">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
`;
}).join('');
}
function filterSlots() {
renderSlots();
}
async function toggleSlot(id) {
const slot = currentSlots.find(s => s.id === id);
if (!slot) return;
const newStatus = slot.is_available === false ? true : false;
if (!await MSPE.confirmDialog(`Are you sure you want to ${newStatus ? 'enable' : 'disable'} this slot?`)) {
return;
}
try {
await MSPE.API.put(`bookings.php?id=${id}`, { is_available: newStatus });
MSPE.showNotification(`Slot ${newStatus ? 'enabled' : 'disabled'} successfully!`, 'success');
loadSlots();
} catch (error) {
console.error('Error toggling slot:', error);
MSPE.showNotification('Failed to update slot', 'error');
}
}
async function deleteSlot(id) {
if (!await MSPE.confirmDialog('Are you sure you want to delete this availability slot?')) {
return;
}
try {
await MSPE.API.delete(`bookings.php?id=${id}`);
MSPE.showNotification('Slot deleted successfully!', 'success');
loadSlots();
} catch (error) {
console.error('Error deleting slot:', error);
MSPE.showNotification('Failed to delete slot', 'error');
}
}
async function loadUpcomingBookings() {
try {
const response = await MSPE.API.get('bookings.php?action=upcoming');
const bookings = response.data || [];
if (bookings.length === 0) {
document.getElementById('upcoming-section').style.display = 'none';
return;
}
document.getElementById('upcoming-section').style.display = 'block';
const container = document.getElementById('upcoming-bookings');
container.innerHTML = bookings.slice(0, 5).map(booking => {
const date = new Date(booking.booking_date);
const day = date.getDate();
const month = date.toLocaleDateString('en-US', { month: 'short' });
const formattedTime = new Date(`${booking.booking_date}T${booking.booking_time}`).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
return `
<div class="upcoming-item">
<div class="upcoming-item-date">
<div class="day">${day}</div>
<div class="month">${month}</div>
</div>
<div style="flex: 1;">
<div style="font-weight: 600;">${booking.first_name} ${booking.last_name}</div>
<div style="font-size: 0.875rem; opacity: 0.8;">${formattedTime}</div>
</div>
<i class="fas fa-chevron-right" style="color: var(--gray-400);"></i>
</div>
`;
}).join('');
} catch (error) {
console.error('Error loading upcoming bookings:', error);
}
}
</script>
</body>
</html>
+732
View File
@@ -0,0 +1,732 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow, noarchive">
<title>Bookings - MSPE Admin</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha384-t1nt8BQoYMLFN5p42tRAtuAAFQaCQODekUVeKKZrEnEyp4H2R0RHFz0KWpmj7i8g" crossorigin="anonymous">
<link rel="stylesheet" href="/admin/css/admin.css">
<style>
.bookings-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 2rem;
}
.bookings-list {
background: white;
border-radius: 10px;
box-shadow: var(--shadow-sm);
}
.bookings-filters {
padding: 1.5rem;
border-bottom: 1px solid var(--gray-200);
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.filter-group {
display: flex;
flex-direction: column;
}
.filter-group label {
font-size: 0.875rem;
font-weight: 600;
color: var(--gray-600);
margin-bottom: 0.5rem;
}
.filter-group select,
.filter-group input {
padding: 0.625rem;
border: 1px solid var(--gray-300);
border-radius: var(--radius-sm);
font-size: 0.875rem;
}
.bookings-table-container {
overflow-x: auto;
}
.bookings-table {
width: 100%;
border-collapse: collapse;
}
.bookings-table th {
background: var(--gray-100);
padding: 1rem;
text-align: left;
font-weight: 600;
color: var(--gray-600);
font-size: 0.875rem;
white-space: nowrap;
}
.bookings-table td {
padding: 1rem;
border-bottom: 1px solid var(--gray-200);
font-size: 0.875rem;
}
.bookings-table tr:hover {
background: var(--gray-50);
}
.status-badge {
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.status-pending {
background: #fef3c7;
color: #92400e;
}
.status-confirmed {
background: #d1fae5;
color: #065f46;
}
.status-cancelled {
background: #fee2e2;
color: #991b1b;
}
.action-btn {
padding: 0.375rem 0.75rem;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
margin-right: 0.5rem;
transition: all 0.3s ease;
}
.action-btn.view {
background: var(--primary);
color: white;
}
.action-btn.confirm {
background: var(--success);
color: white;
}
.action-btn.cancel {
background: var(--danger);
color: white;
}
.action-btn:hover {
opacity: 0.9;
transform: translateY(-1px);
}
.booking-details {
background: white;
border-radius: 10px;
box-shadow: var(--shadow-sm);
padding: 1.5rem;
height: fit-content;
}
.booking-details-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--gray-200);
}
.booking-details-header h3 {
margin: 0;
font-size: 1.25rem;
}
.detail-group {
margin-bottom: 1.25rem;
}
.detail-group label {
font-size: 0.75rem;
font-weight: 600;
color: var(--gray-500);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 0.375rem;
}
.detail-group .value {
font-size: 1rem;
color: var(--gray-700);
}
.detail-group .value.highlight {
color: var(--primary);
font-weight: 600;
}
.booking-actions {
display: flex;
gap: 0.75rem;
margin-top: 2rem;
}
.empty-state {
padding: 4rem 2rem;
text-align: center;
color: var(--gray-500);
}
.empty-state i {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.3;
}
.availability-section {
background: white;
border-radius: 10px;
box-shadow: var(--shadow-sm);
margin-top: 2rem;
}
.availability-header {
padding: 1.5rem;
border-bottom: 1px solid var(--gray-200);
display: flex;
justify-content: space-between;
align-items: center;
}
.availability-content {
padding: 1.5rem;
}
.add-slot-form {
display: grid;
gap: 1rem;
}
.add-slot-form .form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
@media (max-width: 1024px) {
.bookings-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="admin-wrapper">
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<a href="/admin/dashboard.html" class="sidebar-logo">
<i class="fas fa-cube"></i>
<span>MSPE</span>
</a>
<button class="sidebar-toggle" id="sidebar-toggle">
<i class="fas fa-bars"></i>
</button>
</div>
<nav class="sidebar-nav">
<div class="nav-section">
<span class="nav-section-title">Main</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/dashboard.html" class="nav-link">
<i class="fas fa-th-large"></i>
<span>Dashboard</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Content</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/pages.html" class="nav-link">
<i class="fas fa-file-alt"></i>
<span>Pages</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/services.html" class="nav-link">
<i class="fas fa-concierge-bell"></i>
<span>Services</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/portfolio.html" class="nav-link">
<i class="fas fa-briefcase"></i>
<span>Portfolio</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/news.html" class="nav-link">
<i class="fas fa-newspaper"></i>
<span>News & Events</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/testimonials.html" class="nav-link">
<i class="fas fa-quote-right"></i>
<span>Testimonials</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/team.html" class="nav-link">
<i class="fas fa-users"></i>
<span>Team</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Media</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/media.html" class="nav-link">
<i class="fas fa-images"></i>
<span>Media Library</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Inquiries</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/messages.html" class="nav-link">
<i class="fas fa-envelope"></i>
<span>Messages</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/subscribers.html" class="nav-link">
<i class="fas fa-bell"></i>
<span>Subscribers</span>
</a>
</li>
<li class="nav-item active">
<a href="/admin/bookings.html" class="nav-link">
<i class="fas fa-calendar-check"></i>
<span>Bookings</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Settings</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/settings.html" class="nav-link">
<i class="fas fa-cog"></i>
<span>Site Settings</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/users.html" class="nav-link">
<i class="fas fa-user-shield"></i>
<span>Admin Users</span>
</a>
</li>
</ul>
</div>
</nav>
<div class="sidebar-footer">
<a href="/index.html" class="view-site-btn" target="_blank">
<i class="fas fa-external-link-alt"></i>
<span>View Website</span>
</a>
</div>
</aside>
<main class="main-content">
<header class="admin-header">
<div class="header-left">
<button class="mobile-toggle" id="mobile-toggle">
<i class="fas fa-bars"></i>
</button>
<h1 class="page-title">Calendar Bookings</h1>
</div>
<div class="header-right">
<div class="header-search">
<i class="fas fa-search"></i>
<input type="text" placeholder="Search bookings..." id="bookings-search">
</div>
<a href="../calendar.html" class="btn btn-primary" target="_blank">
<i class="fas fa-external-link-alt"></i>
View Public Calendar
</a>
<div class="header-user">
<div class="user-avatar">
<img src="https://ui-avatars.com/api/?name=Admin&background=0ea5e9&color=fff" alt="Admin">
</div>
<div class="user-info">
<span class="user-name">Admin</span>
<span class="user-role">Administrator</span>
</div>
<div class="user-dropdown">
<button class="dropdown-toggle">
<i class="fas fa-chevron-down"></i>
</button>
<ul class="dropdown-menu">
<li><a href="/admin/settings.html"><i class="fas fa-cog"></i> Settings</a></li>
<li class="divider"></li>
<li><a href="#" id="logout-btn"><i class="fas fa-sign-out-alt"></i> Logout</a></li>
</ul>
</div>
</div>
</div>
</header>
<div class="content-wrapper">
<div class="bookings-grid">
<div class="bookings-list">
<div class="bookings-filters">
<div class="filter-group">
<label>Status</label>
<select id="filter-status">
<option value="">All Statuses</option>
<option value="pending">Pending</option>
<option value="confirmed">Confirmed</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
<div class="filter-group">
<label>Date From</label>
<input type="date" id="filter-date-from">
</div>
<div class="filter-group">
<label>Date To</label>
<input type="date" id="filter-date-to">
</div>
</div>
<div class="bookings-table-container">
<table class="bookings-table">
<thead>
<tr>
<th>Date</th>
<th>Time</th>
<th>Client</th>
<th>Email</th>
<th>Service</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="bookings-table-body">
<tr>
<td colspan="7">
<div class="empty-state">
<i class="fas fa-calendar-check"></i>
<p>Loading bookings...</p>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="booking-details" id="booking-details">
<div class="empty-state">
<i class="fas fa-hand-pointer"></i>
<p>Select a booking to view details</p>
</div>
</div>
</div>
</div>
</main>
</div>
<script src="/admin/js/admin.js"></script>
<script>
let currentBookings = [];
let selectedBooking = null;
document.addEventListener('DOMContentLoaded', function() {
loadBookings();
setupEventListeners();
});
function setupEventListeners() {
document.getElementById('filter-status').addEventListener('change', loadBookings);
document.getElementById('filter-date-from').addEventListener('change', loadBookings);
document.getElementById('filter-date-to').addEventListener('change', loadBookings);
document.getElementById('bookings-search').addEventListener('input', filterBookings);
}
async function loadBookings() {
const status = document.getElementById('filter-status').value;
const dateFrom = document.getElementById('filter-date-from').value;
const dateTo = document.getElementById('filter-date-to').value;
let url = 'bookings.php';
const params = new URLSearchParams();
if (status) params.append('status', status);
if (dateFrom) params.append('date_from', dateFrom);
if (dateTo) params.append('date_to', dateTo);
if (params.toString()) {
url += '?' + params.toString();
}
try {
const response = await MSPE.API.get(url);
currentBookings = response.data || [];
renderBookings();
} catch (error) {
console.error('Error loading bookings:', error);
MSPE.showNotification('Failed to load bookings', 'error');
}
}
function renderBookings() {
const tbody = document.getElementById('bookings-table-body');
if (currentBookings.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="7">
<div class="empty-state">
<i class="fas fa-calendar-times"></i>
<p>No bookings found</p>
</div>
</td>
</tr>
`;
return;
}
tbody.innerHTML = currentBookings.map(booking => {
const date = new Date(booking.booking_date);
const formattedDate = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
const formattedTime = new Date(`${booking.booking_date}T${booking.booking_time}`).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
const statusClass = `status-${booking.status}`;
return `
<tr class="booking-row" data-id="${booking.id}" onclick="selectBooking('${booking.id}')">
<td>
<div style="font-weight: 600;">${formattedDate}</div>
<div style="font-size: 0.75rem; color: var(--gray-500);">${date.toLocaleDateString('en-US', { weekday: 'long' })}</div>
</td>
<td style="font-weight: 600;">${formattedTime}</td>
<td>
<div>${booking.first_name} ${booking.last_name}</div>
${booking.company ? `<div style="font-size: 0.75rem; color: var(--gray-500);">${booking.company}</div>` : ''}
</td>
<td>${booking.email}</td>
<td>${formatService(booking.service_interest)}</td>
<td><span class="status-badge ${statusClass}">${booking.status}</span></td>
<td>
<button class="action-btn view" onclick="event.stopPropagation(); selectBooking('${booking.id}')">
<i class="fas fa-eye"></i>
</button>
${booking.status === 'pending' ? `
<button class="action-btn confirm" onclick="event.stopPropagation(); confirmBooking('${booking.id}')">
<i class="fas fa-check"></i>
</button>
<button class="action-btn cancel" onclick="event.stopPropagation(); cancelBooking('${booking.id}')">
<i class="fas fa-times"></i>
</button>
` : ''}
</td>
</tr>
`;
}).join('');
}
function selectBooking(id) {
selectedBooking = currentBookings.find(b => b.id === id);
if (!selectedBooking) return;
const container = document.getElementById('booking-details');
const date = new Date(selectedBooking.booking_date);
const formattedDate = date.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
const formattedTime = new Date(`${selectedBooking.booking_date}T${selectedBooking.booking_time}`).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
container.innerHTML = `
<div class="booking-details-header">
<h3>Booking Details</h3>
<span class="status-badge status-${selectedBooking.status}">${selectedBooking.status}</span>
</div>
<div class="detail-group">
<label>Booking ID</label>
<div class="value">${selectedBooking.id}</div>
</div>
<div class="detail-group">
<label>Client Name</label>
<div class="value highlight">${selectedBooking.first_name} ${selectedBooking.last_name}</div>
</div>
<div class="detail-group">
<label>Email</label>
<div class="value">
<a href="mailto:${selectedBooking.email}" style="color: var(--primary);">${selectedBooking.email}</a>
</div>
</div>
<div class="detail-group">
<label>Phone</label>
<div class="value">${selectedBooking.phone || 'Not provided'}</div>
</div>
<div class="detail-group">
<label>Company</label>
<div class="value">${selectedBooking.company || 'Not provided'}</div>
</div>
<div class="detail-group">
<label>Date & Time</label>
<div class="value highlight">${formattedDate} at ${formattedTime}</div>
</div>
<div class="detail-group">
<label>Duration</label>
<div class="value">${selectedBooking.duration} minutes</div>
</div>
<div class="detail-group">
<label>Service Interest</label>
<div class="value">${formatService(selectedBooking.service_interest)}</div>
</div>
${selectedBooking.message ? `
<div class="detail-group">
<label>Message</label>
<div class="value" style="line-height: 1.6;">${selectedBooking.message}</div>
</div>
` : ''}
<div class="detail-group">
<label>Submitted</label>
<div class="value">${MSPE.formatDate(selectedBooking.created_at)}</div>
</div>
<div class="detail-group">
<label>IP Address</label>
<div class="value">${selectedBooking.ip_address || 'N/A'}</div>
</div>
${selectedBooking.status === 'pending' ? `
<div class="booking-actions">
<button class="btn btn-success" onclick="confirmBooking('${selectedBooking.id}')">
<i class="fas fa-check"></i> Confirm Booking
</button>
<button class="btn btn-danger" onclick="cancelBooking('${selectedBooking.id}')">
<i class="fas fa-times"></i> Cancel Booking
</button>
</div>
` : ''}
`;
}
async function confirmBooking(id) {
if (!await MSPE.confirmDialog('Are you sure you want to confirm this booking? A confirmation email will be sent to the client.')) {
return;
}
try {
await MSPE.API.put(`bookings.php?id=${id}`, { status: 'confirmed' });
MSPE.showNotification('Booking confirmed successfully!', 'success');
loadBookings();
if (selectedBooking && selectedBooking.id === id) {
selectBooking(id);
}
} catch (error) {
console.error('Error confirming booking:', error);
MSPE.showNotification('Failed to confirm booking', 'error');
}
}
async function cancelBooking(id) {
if (!await MSPE.confirmDialog('Are you sure you want to cancel this booking? A cancellation notification will be sent to the client.')) {
return;
}
try {
await MSPE.API.put(`bookings.php?id=${id}`, { status: 'cancelled' });
MSPE.showNotification('Booking cancelled successfully!', 'success');
loadBookings();
if (selectedBooking && selectedBooking.id === id) {
selectBooking(id);
}
} catch (error) {
console.error('Error cancelling booking:', error);
MSPE.showNotification('Failed to cancel booking', 'error');
}
}
function formatService(service) {
const services = {
'it-support': 'IT Support',
'cybersecurity': 'Cybersecurity',
'cloud': 'Cloud Services',
'consulting': 'Business Consulting',
'general': 'General Consultation'
};
return services[service] || service || 'Not specified';
}
function filterBookings() {
const searchTerm = document.getElementById('bookings-search').value.toLowerCase();
const tbody = document.getElementById('bookings-table-body');
const rows = tbody.querySelectorAll('.booking-row');
rows.forEach(row => {
const id = row.dataset.id;
const booking = currentBookings.find(b => b.id === id);
if (!booking) return;
const searchableText = `
${booking.first_name} ${booking.last_name}
${booking.email}
${booking.company}
${booking.service_interest}
`.toLowerCase();
if (searchableText.includes(searchTerm)) {
row.style.display = '';
} else {
row.style.display = 'none';
}
});
}
</script>
</body>
</html>
+3334
View File
File diff suppressed because it is too large Load Diff
+429
View File
@@ -0,0 +1,429 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow, noarchive">
<title>Dashboard - MSPE Admin</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha384-t1nt8BQoYMLFN5p42tRAtuAAFQaCQODekUVeKKZrEnEyp4H2R0RHFz0KWpmj7i8g" crossorigin="anonymous">
<link rel="stylesheet" href="/admin/css/admin.css">
</head>
<body>
<div class="admin-wrapper">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<a href="/admin/dashboard.html" class="sidebar-logo">
<i class="fas fa-cube"></i>
<span>MSPE</span>
</a>
<button class="sidebar-toggle" id="sidebar-toggle">
<i class="fas fa-bars"></i>
</button>
</div>
<nav class="sidebar-nav">
<div class="nav-section">
<span class="nav-section-title">Main</span>
<ul class="nav-menu">
<li class="nav-item active">
<a href="/admin/dashboard.html" class="nav-link">
<i class="fas fa-th-large"></i>
<span>Dashboard</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Content</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/pages.html" class="nav-link">
<i class="fas fa-file-alt"></i>
<span>Pages</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/services.html" class="nav-link">
<i class="fas fa-concierge-bell"></i>
<span>Services</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/portfolio.html" class="nav-link">
<i class="fas fa-briefcase"></i>
<span>Portfolio</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/news.html" class="nav-link">
<i class="fas fa-newspaper"></i>
<span>News & Events</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/testimonials.html" class="nav-link">
<i class="fas fa-quote-right"></i>
<span>Testimonials</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/team.html" class="nav-link">
<i class="fas fa-users"></i>
<span>Team</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Media</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/media.html" class="nav-link">
<i class="fas fa-images"></i>
<span>Media Library</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Inquiries</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/messages.html" class="nav-link">
<i class="fas fa-envelope"></i>
<span>Messages</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/subscribers.html" class="nav-link">
<i class="fas fa-bell"></i>
<span>Subscribers</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/bookings.html" class="nav-link">
<i class="fas fa-calendar-check"></i>
<span>Bookings</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Settings</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/settings.html" class="nav-link">
<i class="fas fa-cog"></i>
<span>Site Settings</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/users.html" class="nav-link">
<i class="fas fa-user-shield"></i>
<span>Admin Users</span>
</a>
</li>
</ul>
</div>
</nav>
<div class="sidebar-footer">
<a href="/index.html" class="view-site-btn" target="_blank">
<i class="fas fa-external-link-alt"></i>
<span>View Website</span>
</a>
</div>
</aside>
<!-- Main Content -->
<main class="main-content">
<!-- Header -->
<header class="admin-header">
<div class="header-left">
<button class="mobile-toggle" id="mobile-toggle">
<i class="fas fa-bars"></i>
</button>
<h1 class="page-title">Dashboard</h1>
</div>
<div class="header-right">
<div class="header-search">
<i class="fas fa-search"></i>
<input type="text" placeholder="Search...">
</div>
<div class="header-notifications">
<button class="notification-btn">
<i class="fas fa-bell"></i>
<span class="notification-badge">3</span>
</button>
</div>
<div class="header-user">
<div class="user-avatar">
<img src="https://ui-avatars.com/api/?name=Admin&background=0ea5e9&color=fff" alt="Admin">
</div>
<div class="user-info">
<span class="user-name">Admin</span>
<span class="user-role">Administrator</span>
</div>
<div class="user-dropdown">
<button class="dropdown-toggle">
<i class="fas fa-chevron-down"></i>
</button>
<ul class="dropdown-menu">
<li><a href="settings.html"><i class="fas fa-user"></i> Profile</a></li>
<li><a href="settings.html"><i class="fas fa-cog"></i> Settings</a></li>
<li class="divider"></li>
<li><a href="#" id="logout-btn"><i class="fas fa-sign-out-alt"></i> Logout</a></li>
</ul>
</div>
</div>
</div>
</header>
<!-- Dashboard Content -->
<div class="content-wrapper">
<!-- Stats Cards -->
<div class="stats-grid">
<div class="stat-card" id="stat-messages">
<div class="stat-icon bg-blue">
<i class="fas fa-envelope"></i>
</div>
<div class="stat-info">
<h3 id="stat-messages-count">0</h3>
<p>Unread Messages</p>
</div>
<div class="stat-trend" id="stat-messages-trend">
<span id="stat-messages-total">0 total</span>
</div>
</div>
<div class="stat-card" id="stat-bookings">
<div class="stat-icon bg-green">
<i class="fas fa-calendar-check"></i>
</div>
<div class="stat-info">
<h3 id="stat-bookings-count">0</h3>
<p>Pending Bookings</p>
</div>
<div class="stat-trend" id="stat-bookings-trend">
<span id="stat-bookings-week">0 this week</span>
</div>
</div>
<div class="stat-card" id="stat-news">
<div class="stat-icon bg-purple">
<i class="fas fa-newspaper"></i>
</div>
<div class="stat-info">
<h3 id="stat-news-count">0</h3>
<p>Published Articles</p>
</div>
<div class="stat-trend" id="stat-news-trend">
<span id="stat-news-draft">0 drafts</span>
</div>
</div>
<div class="stat-card" id="stat-subscribers">
<div class="stat-icon bg-orange">
<i class="fas fa-users"></i>
</div>
<div class="stat-info">
<h3 id="stat-subscribers-count">0</h3>
<p>Subscribers</p>
</div>
<div class="stat-trend" id="stat-subscribers-trend">
<span id="stat-subscribers-active">0 active</span>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="dashboard-row">
<div class="card">
<div class="card-header">
<h2>Quick Actions</h2>
</div>
<div class="card-body">
<div class="quick-actions">
<a href="/admin/news.html?action=add" class="quick-action-btn">
<i class="fas fa-newspaper"></i>
<span>Add Article</span>
</a>
<a href="/admin/portfolio.html?action=add" class="quick-action-btn">
<i class="fas fa-briefcase"></i>
<span>Add Project</span>
</a>
<a href="/admin/messages.html" class="quick-action-btn">
<i class="fas fa-envelope"></i>
<span>Messages</span>
</a>
<a href="/admin/bookings.html" class="quick-action-btn">
<i class="fas fa-calendar-check"></i>
<span>Bookings</span>
</a>
</div>
</div>
</div>
</div>
<!-- Recent Content -->
<div class="dashboard-row two-col">
<div class="card">
<div class="card-header">
<h2>Recent News</h2>
<a href="/admin/news.html" class="view-all">View All</a>
</div>
<div class="card-body">
<div class="content-list" id="recent-news">
<!-- Will be populated by JavaScript -->
<div class="loading-state">
<i class="fas fa-spinner fa-spin"></i> Loading...
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h2>Recent Messages</h2>
<a href="/admin/messages.html" class="view-all">View All</a>
</div>
<div class="card-body">
<div class="content-list" id="recent-messages">
<div class="loading-state">
<i class="fas fa-spinner fa-spin"></i> Loading...
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
<script src="/admin/js/admin.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
loadDashboardStats();
loadRecentNews();
loadRecentMessages();
});
async function loadDashboardStats() {
try {
const response = await MSPE.API.get('stats.php');
if (response && response.success) {
const stats = response.data;
// Update Messages Stats
document.getElementById('stat-messages-count').textContent = stats.messages?.unread || 0;
document.getElementById('stat-messages-total').textContent = `${stats.messages?.total || 0} total`;
// Update Bookings Stats
document.getElementById('stat-bookings-count').textContent = stats.bookings?.pending || 0;
document.getElementById('stat-bookings-week').textContent = `${stats.bookings?.this_week || 0} this week`;
// Update News Stats
document.getElementById('stat-news-count').textContent = stats.news?.published || 0;
document.getElementById('stat-news-draft').textContent = `${stats.news?.draft || 0} drafts`;
// Update Subscribers Stats
document.getElementById('stat-subscribers-count').textContent = stats.subscribers?.total || 0;
document.getElementById('stat-subscribers-active').textContent = `${stats.subscribers?.active || 0} active`;
}
} catch (error) {
console.error('Failed to load stats:', error);
// Show zeros on error
}
}
async function loadRecentNews() {
try {
const response = await MSPE.API.get('news.php?limit=3');
const container = document.getElementById('recent-news');
if (response && response.success && response.data && response.data.length > 0) {
container.innerHTML = response.data.map(item => `
<div class="content-item">
<div class="item-image">
<img src="${item.featured_image || '/images/logo/logo.png'}" alt="${item.title}" style="object-fit: cover;">
</div>
<div class="item-info">
<h4>${item.title}</h4>
<span class="item-meta">${MSPE.formatDate(item.created_at)}${item.category || 'News'}</span>
</div>
<div class="item-actions">
<a href="/admin/news.html?edit=${item.id}" class="action-btn edit"><i class="fas fa-edit"></i></a>
</div>
</div>
`).join('');
} else {
container.innerHTML = `
<div class="empty-state" style="padding: 2rem; text-align: center;">
<i class="fas fa-newspaper" style="font-size: 2rem; opacity: 0.3; margin-bottom: 0.5rem;"></i>
<p>No articles yet</p>
<a href="/admin/news.html?action=add" class="btn btn-primary btn-sm" style="margin-top: 1rem;">Create First Article</a>
</div>
`;
}
} catch (error) {
console.error('Failed to load recent news:', error);
document.getElementById('recent-news').innerHTML = `
<div class="empty-state" style="padding: 2rem; text-align: center;">
<i class="fas fa-exclamation-circle" style="font-size: 2rem; opacity: 0.3;"></i>
<p>Could not load articles</p>
</div>
`;
}
}
async function loadRecentMessages() {
try {
const response = await MSPE.API.get('contact.php?limit=3');
const container = document.getElementById('recent-messages');
if (response && response.success && response.data && response.data.length > 0) {
container.innerHTML = response.data.map(msg => `
<div class="message-item" style="cursor: pointer;" onclick="window.location.href='/admin/messages.html?id=${msg.id}'">
<div class="message-avatar">
<img src="https://ui-avatars.com/api/?name=${encodeURIComponent((msg.first_name || 'U') + ' ' + (msg.last_name || ''))}&background=random" alt="Avatar">
</div>
<div class="message-info">
<h4>${msg.first_name || 'Unknown'} ${msg.last_name || ''}</h4>
<p>${(msg.message || '').substring(0, 50)}${msg.message && msg.message.length > 50 ? '...' : ''}</p>
<span class="message-time">${MSPE.formatDate(msg.created_at)}</span>
</div>
<span class="message-status ${msg.status || 'unread'}"></span>
</div>
`).join('');
} else {
container.innerHTML = `
<div class="empty-state" style="padding: 2rem; text-align: center;">
<i class="fas fa-inbox" style="font-size: 2rem; opacity: 0.3; margin-bottom: 0.5rem;"></i>
<p>No messages yet</p>
<span style="font-size: 0.85rem; opacity: 0.7;">Messages from your contact form will appear here</span>
</div>
`;
}
} catch (error) {
console.error('Failed to load recent messages:', error);
document.getElementById('recent-messages').innerHTML = `
<div class="empty-state" style="padding: 2rem; text-align: center;">
<i class="fas fa-exclamation-circle" style="font-size: 2rem; opacity: 0.3;"></i>
<p>Could not load messages</p>
</div>
`;
}
}
</script>
</body>
</html>
+141
View File
@@ -0,0 +1,141 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow, noarchive">
<title>Admin Login - MSPE</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha384-t1nt8BQoYMLFN5p42tRAtuAAFQaCQODekUVeKKZrEnEyp4H2R0RHFz0KWpmj7i8g" crossorigin="anonymous">
<link rel="stylesheet" href="/admin/css/admin.css">
</head>
<body class="login-page">
<div class="login-container">
<div class="login-card">
<div class="login-header">
<div class="login-logo">
<i class="fas fa-cube"></i>
<span>MSPE</span>
</div>
<h1>Admin Panel</h1>
<p>Sign in to manage your website</p>
</div>
<form id="login-form" class="login-form">
<div class="form-group">
<label for="username">Username</label>
<div class="input-icon">
<i class="fas fa-user"></i>
<input type="text" id="username" name="username" required placeholder="Enter username">
</div>
</div>
<div class="form-group">
<label for="password">Password</label>
<div class="input-icon">
<i class="fas fa-lock"></i>
<input type="password" id="password" name="password" required placeholder="Enter password">
<button type="button" class="toggle-password" tabindex="-1">
<i class="fas fa-eye"></i>
</button>
</div>
</div>
<div class="form-options">
<label class="checkbox-label">
<input type="checkbox" name="remember">
<span>Remember me</span>
</label>
<a href="/admin/reset-password.html" class="forgot-link">Forgot password?</a>
</div>
<button type="submit" class="btn btn-primary btn-block">
<span>Sign In</span>
<i class="fas fa-arrow-right"></i>
</button>
<div class="login-message" id="login-message"></div>
</form>
<div class="login-footer">
<p>MSPE Admin Panel</p>
<p>Secure access for authorized personnel only</p>
</div>
</div>
<div class="login-bg">
<div class="bg-shape bg-shape-1"></div>
<div class="bg-shape bg-shape-2"></div>
<div class="bg-shape bg-shape-3"></div>
</div>
</div>
<script>
// Toggle password visibility
document.querySelector('.toggle-password').addEventListener('click', function() {
const input = document.getElementById('password');
const icon = this.querySelector('i');
if (input.type === 'password') {
input.type = 'text';
icon.className = 'fas fa-eye-slash';
} else {
input.type = 'password';
icon.className = 'fas fa-eye';
}
});
// Login form handler
document.getElementById('login-form').addEventListener('submit', async function(e) {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const remember = this.querySelector('input[name="remember"]').checked;
const message = document.getElementById('login-message');
const btn = this.querySelector('button[type="submit"]');
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Signing in...';
btn.disabled = true;
try {
const response = await fetch('/api/auth.php', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
action: 'login',
username,
password,
remember
})
});
const data = await response.json();
if (data.success) {
message.className = 'login-message success';
message.innerHTML = '<i class="fas fa-check-circle"></i> Login successful! Redirecting...';
message.style.display = 'block';
setTimeout(() => {
window.location.href = '/admin/dashboard.html';
}, 1000);
} else {
throw new Error(data.message || 'Invalid credentials');
}
} catch (error) {
message.className = 'login-message error';
message.innerHTML = '<i class="fas fa-exclamation-circle"></i> ' + error.message;
message.style.display = 'block';
btn.innerHTML = '<span>Sign In</span><i class="fas fa-arrow-right"></i>';
btn.disabled = false;
}
});
</script>
</body>
</html>
+440
View File
@@ -0,0 +1,440 @@
/**
* MSPE Admin Panel JavaScript
*/
// Lock admin UI until auth verification completes (except login/reset pages)
document.documentElement.classList.add('auth-pending');
const authStyle = document.createElement('style');
authStyle.textContent = `
html.auth-pending body {
visibility: hidden;
}
html.auth-pending::after {
content: '';
position: fixed;
inset: 0;
z-index: 99999;
background: #0f172a;
display: flex;
align-items: center;
justify-content: center;
}
html.auth-pending::before {
content: '';
position: fixed;
top: 50%; left: 50%;
z-index: 100000;
width: 36px; height: 36px;
margin: -18px 0 0 -18px;
border: 3px solid rgba(59,130,246,0.2);
border-top-color: #3b82f6;
border-radius: 50%;
animation: authSpin .7s linear infinite;
}
@keyframes authSpin { to { transform: rotate(360deg) } }
`;
document.head.appendChild(authStyle);
document.addEventListener('DOMContentLoaded', function() {
if (isAuthExemptPage()) {
unlockAdminUi();
return;
}
// Initialize components
initSidebar();
initDropdowns();
initLogout();
checkAuth();
});
/**
* Sidebar Toggle
*/
function initSidebar() {
const sidebar = document.getElementById('sidebar');
const mobileToggle = document.getElementById('mobile-toggle');
const sidebarToggle = document.getElementById('sidebar-toggle');
if (mobileToggle) {
mobileToggle.addEventListener('click', function() {
sidebar.classList.toggle('active');
});
}
if (sidebarToggle) {
sidebarToggle.addEventListener('click', function() {
sidebar.classList.toggle('active');
});
}
// Close sidebar when clicking outside on mobile
document.addEventListener('click', function(e) {
if (window.innerWidth <= 1024) {
if (!sidebar.contains(e.target) && !mobileToggle?.contains(e.target)) {
sidebar.classList.remove('active');
}
}
});
}
/**
* Dropdowns
*/
function initDropdowns() {
const dropdowns = document.querySelectorAll('.user-dropdown');
dropdowns.forEach(dropdown => {
const toggle = dropdown.querySelector('.dropdown-toggle');
if (toggle) {
toggle.addEventListener('click', function(e) {
e.stopPropagation();
dropdown.classList.toggle('active');
});
}
});
// Close dropdowns when clicking outside
document.addEventListener('click', function() {
dropdowns.forEach(dropdown => {
dropdown.classList.remove('active');
});
});
}
/**
* Logout Handler
*/
function initLogout() {
const logoutBtn = document.getElementById('logout-btn');
if (logoutBtn) {
logoutBtn.addEventListener('click', async function(e) {
e.preventDefault();
try {
await fetch('/api/auth.php', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ action: 'logout' })
});
} catch (error) {
console.log('Logout request failed, proceeding with local logout');
}
// Redirect to login
window.location.href = 'index.html';
});
}
}
/**
* Check Authentication
*/
function checkAuth() {
fetch('/api/auth.php', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ action: 'verify' })
})
.then(r => r.json())
.then(data => {
if (data.success) {
applyUserToHeader(data.user || {});
unlockAdminUi();
startSessionWatchdog();
} else {
window.location.href = 'index.html';
}
})
.catch(() => {
window.location.href = 'index.html';
});
}
function isAuthExemptPage() {
return window.location.pathname.includes('index.html') ||
window.location.pathname.endsWith('/admin/') ||
window.location.pathname.includes('reset-password.html');
}
function unlockAdminUi() {
document.documentElement.classList.remove('auth-pending');
}
/**
* Periodic Session Re-Verification (every 5 minutes)
* Catches expired / revoked sessions proactively.
*/
let _sessionInterval = null;
function startSessionWatchdog() {
if (_sessionInterval) return;
_sessionInterval = setInterval(() => {
fetch('/api/auth.php', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'verify' })
})
.then(r => r.json())
.then(data => {
if (!data.success) {
clearInterval(_sessionInterval);
showNotification('Session expired — redirecting to login.', 'warning');
setTimeout(() => { window.location.href = 'index.html'; }, 2000);
}
})
.catch(() => { /* network hiccup, retry next cycle */ });
}, 5 * 60 * 1000);
}
function applyUserToHeader(user) {
const nameSpans = document.querySelectorAll('.user-name');
nameSpans.forEach(el => {
el.textContent = user.username || 'Admin';
});
}
/**
* API Helper Functions
*/
const API = {
baseUrl: '/api',
async request(endpoint, options = {}) {
const defaultOptions = {
credentials: 'include',
headers: {
'Content-Type': 'application/json'
}
};
const response = await fetch(`${this.baseUrl}/${endpoint}`, {
...defaultOptions,
...options,
headers: {
...defaultOptions.headers,
...options.headers
}
});
if (response.status === 401) {
window.location.href = 'index.html';
return;
}
if (response.status === 403) {
return response.json();
}
return response.json();
},
async get(endpoint) {
return this.request(endpoint);
},
async post(endpoint, data) {
return this.request(endpoint, {
method: 'POST',
body: JSON.stringify(data)
});
},
async put(endpoint, data) {
return this.request(endpoint, {
method: 'PUT',
body: JSON.stringify(data)
});
},
async delete(endpoint) {
return this.request(endpoint, {
method: 'DELETE'
});
},
async upload(endpoint, formData) {
const response = await fetch(`${this.baseUrl}/${endpoint}`, {
method: 'POST',
credentials: 'include',
body: formData
});
if (response.status === 401) {
window.location.href = 'index.html';
return;
}
return response.json();
}
};
/**
* Notification Helper
*/
function showNotification(message, type = 'success') {
const iconMap = {
success: 'check-circle',
error: 'exclamation-circle',
info: 'info-circle',
warning: 'exclamation-triangle'
};
const icon = iconMap[type] || 'info-circle';
const notification = document.createElement('div');
notification.className = `notification ${type}`;
const iconEl = document.createElement('i');
iconEl.className = `fas fa-${icon}`;
const spanEl = document.createElement('span');
spanEl.textContent = message;
notification.appendChild(iconEl);
notification.appendChild(spanEl);
// Add styles if not present
if (!document.querySelector('.notification-styles')) {
const styles = document.createElement('style');
styles.className = 'notification-styles';
styles.textContent = `
.notification {
position: fixed;
top: 20px;
right: 20px;
padding: 1rem 1.5rem;
border-radius: 10px;
display: flex;
align-items: center;
gap: 0.75rem;
font-weight: 500;
z-index: 9999;
animation: slideIn 0.3s ease;
box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1);
}
.notification.success {
background: #10b981;
color: white;
}
.notification.error {
background: #ef4444;
color: white;
}
.notification.info {
background: #3b82f6;
color: white;
}
.notification.warning {
background: #f59e0b;
color: white;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
`;
document.head.appendChild(styles);
}
document.body.appendChild(notification);
setTimeout(() => {
notification.style.animation = 'slideIn 0.3s ease reverse';
setTimeout(() => notification.remove(), 300);
}, 3000);
}
/**
* Format Date Helper
*/
function formatDate(dateString) {
const options = { year: 'numeric', month: 'short', day: 'numeric' };
return new Date(dateString).toLocaleDateString('en-US', options);
}
/**
* Confirm Dialog Helper
*/
function confirmDialog(message) {
return new Promise((resolve) => {
const modal = document.createElement('div');
modal.className = 'modal active';
modal.innerHTML = `
<div class="modal-overlay"></div>
<div class="modal-content">
<div class="modal-header">
<h3>Confirm</h3>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body">
<p class="confirm-msg"></p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="confirm-cancel">Cancel</button>
<button class="btn btn-danger" id="confirm-ok">Confirm</button>
</div>
</div>
`;
modal.querySelector('.confirm-msg').textContent = message;
document.body.appendChild(modal);
modal.querySelector('#confirm-ok').addEventListener('click', () => {
modal.remove();
resolve(true);
});
modal.querySelector('#confirm-cancel').addEventListener('click', () => {
modal.remove();
resolve(false);
});
modal.querySelector('.modal-close').addEventListener('click', () => {
modal.remove();
resolve(false);
});
modal.querySelector('.modal-overlay').addEventListener('click', () => {
modal.remove();
resolve(false);
});
});
}
/**
* Debounce Helper
*/
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// Export for use in other scripts
window.MSPE = {
API,
showNotification,
formatDate,
confirmDialog,
debounce
};
+731
View File
@@ -0,0 +1,731 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow, noarchive">
<title>Media Library - MSPE Admin</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha384-t1nt8BQoYMLFN5p42tRAtuAAFQaCQODekUVeKKZrEnEyp4H2R0RHFz0KWpmj7i8g" crossorigin="anonymous">
<link rel="stylesheet" href="/admin/css/admin.css">
</head>
<body>
<div class="admin-wrapper">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<a href="/admin/dashboard.html" class="sidebar-logo">
<i class="fas fa-cube"></i>
<span>MSPE</span>
</a>
<button class="sidebar-toggle" id="sidebar-toggle">
<i class="fas fa-bars"></i>
</button>
</div>
<nav class="sidebar-nav">
<div class="nav-section">
<span class="nav-section-title">Main</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/dashboard.html" class="nav-link">
<i class="fas fa-th-large"></i>
<span>Dashboard</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Content</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/pages.html" class="nav-link">
<i class="fas fa-file-alt"></i>
<span>Pages</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/services.html" class="nav-link">
<i class="fas fa-concierge-bell"></i>
<span>Services</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/portfolio.html" class="nav-link">
<i class="fas fa-briefcase"></i>
<span>Portfolio</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/news.html" class="nav-link">
<i class="fas fa-newspaper"></i>
<span>News & Events</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/testimonials.html" class="nav-link">
<i class="fas fa-quote-right"></i>
<span>Testimonials</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/team.html" class="nav-link">
<i class="fas fa-users"></i>
<span>Team</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Media</span>
<ul class="nav-menu">
<li class="nav-item active">
<a href="/admin/media.html" class="nav-link">
<i class="fas fa-images"></i>
<span>Media Library</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Inquiries</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/messages.html" class="nav-link">
<i class="fas fa-envelope"></i>
<span>Messages</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/subscribers.html" class="nav-link">
<i class="fas fa-bell"></i>
<span>Subscribers</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/bookings.html" class="nav-link">
<i class="fas fa-calendar-check"></i>
<span>Bookings</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Settings</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/settings.html" class="nav-link">
<i class="fas fa-cog"></i>
<span>Site Settings</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/users.html" class="nav-link">
<i class="fas fa-user-shield"></i>
<span>Admin Users</span>
</a>
</li>
</ul>
</div>
</nav>
<div class="sidebar-footer">
<a href="/index.html" class="view-site-btn" target="_blank">
<i class="fas fa-external-link-alt"></i>
<span>View Website</span>
</a>
</div>
</aside>
<!-- Main Content -->
<main class="main-content">
<!-- Header -->
<header class="admin-header">
<div class="header-left">
<button class="mobile-toggle" id="mobile-toggle">
<i class="fas fa-bars"></i>
</button>
<h1 class="page-title">Media Library</h1>
</div>
<div class="header-right">
<button class="btn btn-primary" onclick="openUploadModal()">
<i class="fas fa-upload"></i> Upload Files
</button>
<div class="header-user">
<div class="user-avatar">
<img src="https://ui-avatars.com/api/?name=Admin&background=0ea5e9&color=fff" alt="Admin">
</div>
<div class="user-info">
<span class="user-name">Admin</span>
<span class="user-role">Administrator</span>
</div>
<div class="user-dropdown">
<button class="dropdown-toggle">
<i class="fas fa-chevron-down"></i>
</button>
<ul class="dropdown-menu">
<li><a href="/admin/settings.html"><i class="fas fa-cog"></i> Settings</a></li>
<li class="divider"></li>
<li><a href="#" id="logout-btn"><i class="fas fa-sign-out-alt"></i> Logout</a></li>
</ul>
</div>
</div>
</div>
</header>
<!-- Content -->
<div class="content-wrapper">
<div class="media-stats">
<div class="storage-overview">
<div class="storage-used">
<i class="fas fa-database"></i>
<div>
<span class="storage-value">0 Bytes</span>
<span class="storage-label">Used of 5 GB</span>
</div>
</div>
<div class="storage-bar">
<div class="storage-progress" style="width: 0%;"></div>
</div>
</div>
<div class="media-counts">
<div class="media-count-item">
<i class="fas fa-image"></i>
<span>0 Images</span>
</div>
<div class="media-count-item">
<i class="fas fa-file-pdf"></i>
<span>0 Documents</span>
</div>
<div class="media-count-item">
<i class="fas fa-video"></i>
<span>0 Videos</span>
</div>
</div>
</div>
<!-- Filters & Search -->
<div class="media-toolbar">
<div class="toolbar-left">
<div class="search-box">
<i class="fas fa-search"></i>
<input type="text" placeholder="Search files..." id="media-search">
</div>
<select class="form-select" id="type-filter">
<option value="">All Types</option>
<option value="image">Images</option>
<option value="document">Documents</option>
<option value="video">Videos</option>
</select>
<select class="form-select" id="folder-filter">
<option value="">All Folders</option>
<option value="news">News</option>
<option value="portfolio">Portfolio</option>
<option value="team">Team</option>
<option value="clients">Clients</option>
<option value="general">General</option>
</select>
</div>
<div class="toolbar-right">
<button class="btn btn-outline btn-sm" onclick="createFolder()">
<i class="fas fa-folder-plus"></i> New Folder
</button>
<div class="view-toggle">
<button class="view-btn active" data-view="grid"><i class="fas fa-th"></i></button>
<button class="view-btn" data-view="list"><i class="fas fa-list"></i></button>
</div>
</div>
</div>
<!-- Folders -->
<div class="media-folders" id="media-folders">
<div class="folder-item" data-folder="news">
<i class="fas fa-folder"></i>
<span>news</span>
<small>0 files</small>
</div>
<div class="folder-item" data-folder="portfolio">
<i class="fas fa-folder"></i>
<span>portfolio</span>
<small>0 files</small>
</div>
<div class="folder-item" data-folder="team">
<i class="fas fa-folder"></i>
<span>team</span>
<small>0 files</small>
</div>
<div class="folder-item" data-folder="clients">
<i class="fas fa-folder"></i>
<span>clients</span>
<small>0 files</small>
</div>
</div>
<!-- Media Grid -->
<div class="media-grid" id="media-grid">
<div style="grid-column:1/-1;text-align:center;padding:3rem;color:var(--gray-400);">
<i class="fas fa-spinner fa-spin fa-2x"></i>
<p style="margin-top:1rem;">Loading media...</p>
</div>
</div>
<!-- Bulk Actions -->
<div class="bulk-actions" id="bulk-actions" style="display: none;">
<span class="selected-count">0 selected</span>
<button class="btn btn-outline btn-sm" onclick="moveSelected()">
<i class="fas fa-folder"></i> Move
</button>
<button class="btn btn-outline btn-sm" onclick="downloadSelected()">
<i class="fas fa-download"></i> Download
</button>
<button class="btn btn-danger btn-sm" onclick="deleteSelected()">
<i class="fas fa-trash"></i> Delete
</button>
</div>
</div>
</main>
</div>
<!-- Upload Modal -->
<div class="modal" id="upload-modal">
<div class="modal-backdrop"></div>
<div class="modal-container">
<div class="modal-header">
<h2>Upload Files</h2>
<button class="modal-close" onclick="closeUploadModal()">&times;</button>
</div>
<div class="modal-body">
<div class="upload-dropzone" id="dropzone">
<i class="fas fa-cloud-upload-alt"></i>
<h3>Drag & Drop Files Here</h3>
<p>or click to browse</p>
<span class="upload-hint">Supports: JPG, PNG, GIF, PDF, DOC, MP4 (Max 50MB each)</span>
<input type="file" id="file-input" multiple accept="image/*,.pdf,.doc,.docx,.mp4" style="display: none;">
</div>
<div class="form-group" style="margin-top: 1rem;">
<label>Upload to Folder</label>
<select class="form-select" id="upload-folder">
<option value="">Root (uploads/)</option>
<option value="news">news/</option>
<option value="portfolio">portfolio/</option>
<option value="team">team/</option>
<option value="clients">clients/</option>
</select>
</div>
<div class="upload-queue" id="upload-queue" style="display: none;">
<h4>Upload Queue</h4>
<div class="queue-list" id="queue-list"></div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" onclick="closeUploadModal()">Cancel</button>
<button class="btn btn-primary" id="start-upload" disabled>
<i class="fas fa-upload"></i> Start Upload
</button>
</div>
</div>
</div>
<!-- Media View Modal -->
<div class="modal" id="view-modal">
<div class="modal-backdrop"></div>
<div class="modal-container modal-lg">
<div class="modal-header">
<h2 id="view-title">File Details</h2>
<button class="modal-close" onclick="closeViewModal()">&times;</button>
</div>
<div class="modal-body">
<div class="media-view-content">
<div class="media-view-preview">
<img src="" id="view-image" alt="">
</div>
<div class="media-view-details">
<div class="detail-row">
<label>File Name</label>
<span id="detail-name">-</span>
</div>
<div class="detail-row">
<label>Type</label>
<span id="detail-type">-</span>
</div>
<div class="detail-row">
<label>Size</label>
<span id="detail-size">-</span>
</div>
<div class="detail-row">
<label>Dimensions</label>
<span id="detail-dimensions">-</span>
</div>
<div class="detail-row">
<label>Uploaded</label>
<span id="detail-date">-</span>
</div>
<div class="detail-row">
<label>URL</label>
<div class="url-copy">
<input type="text" id="detail-url" readonly>
<button class="btn btn-sm btn-outline" onclick="copyDetailUrl()">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<div class="detail-row">
<label>Alt Text</label>
<input type="text" class="form-control" id="detail-alt" placeholder="Enter alt text...">
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-danger" onclick="deleteFromView()">
<i class="fas fa-trash"></i> Delete
</button>
<button class="btn btn-outline" onclick="downloadFile()">
<i class="fas fa-download"></i> Download
</button>
<button class="btn btn-primary" onclick="saveDetails()">
<i class="fas fa-save"></i> Save
</button>
</div>
</div>
</div>
<script src="/admin/js/admin.js"></script>
<script>
let allMedia = [];
document.addEventListener('DOMContentLoaded', function() {
loadMedia();
// Search & Filter
document.getElementById('media-search').addEventListener('input', MSPE.debounce(filterMedia, 300));
document.getElementById('type-filter').addEventListener('change', filterMedia);
document.getElementById('folder-filter').addEventListener('change', filterMedia);
// File Input for Upload
document.getElementById('file-input').addEventListener('change', handleFileSelect);
document.getElementById('start-upload').addEventListener('click', uploadFiles);
});
async function loadMedia() {
try {
const response = await MSPE.API.get('media.php');
if (response && response.success) {
allMedia = response.data;
renderMedia(allMedia);
updateStats(allMedia);
updateFolderCounts(allMedia);
}
} catch (error) {
console.error('Error loading media:', error);
document.getElementById('media-grid').innerHTML = '<div class="empty-state">Failed to load media</div>';
}
}
function updateFolderCounts(media) {
document.querySelectorAll('#media-folders .folder-item').forEach(el => {
const folder = el.dataset.folder;
const count = media.filter(m => m.folder === folder).length;
el.querySelector('small').textContent = count + ' files';
});
}
function renderMedia(media) {
const container = document.getElementById('media-grid');
if (media.length === 0) {
container.innerHTML = '<div class="empty-state"><i class="fas fa-images"></i><p>No media files found</p></div>';
return;
}
container.innerHTML = media.map(item => `
<div class="media-item" data-id="${item.id}" data-type="${item.type}">
<div class="media-preview ${item.type.includes('image') ? '' : 'document'}">
${item.type.includes('image')
? `<img src="/${item.path}" alt="${item.name}">`
: `<i class="fas fa-file-${getFileIcon(item.type)}"></i>`
}
<div class="media-overlay">
<button class="btn btn-sm" onclick="viewMedia('${item.id}')"><i class="fas fa-eye"></i></button>
<button class="btn btn-sm" onclick="copyUrl('/${item.path}')"><i class="fas fa-link"></i></button>
<button class="btn btn-sm" onclick="deleteMedia('${item.id}')"><i class="fas fa-trash"></i></button>
</div>
</div>
<div class="media-info">
<span class="media-name">${item.name}</span>
<span class="media-size">${formatBytes(item.size)}</span>
</div>
<label class="media-checkbox">
<input type="checkbox" name="selected[]" value="${item.id}" onchange="updateBulkActions()">
<span class="checkmark"></span>
</label>
</div>
`).join('');
}
function getFileIcon(type) {
if (type.includes('pdf')) return 'pdf';
if (type.includes('video')) return 'video';
if (type.includes('word') || type.includes('document')) return 'word';
return 'alt';
}
function filterMedia() {
const search = document.getElementById('media-search').value.toLowerCase();
const type = document.getElementById('type-filter').value;
const folder = document.getElementById('folder-filter').value;
let filtered = allMedia;
if (search) filtered = filtered.filter(m => m.name.toLowerCase().includes(search));
if (type) filtered = filtered.filter(m => m.type.includes(type));
if (folder) filtered = filtered.filter(m => m.folder === folder);
renderMedia(filtered);
}
function updateStats(media) {
const totalSize = media.reduce((acc, item) => acc + parseInt(item.size), 0);
const images = media.filter(m => m.type.includes('image')).length;
const videos = media.filter(m => m.type.includes('video')).length;
const docs = media.length - images - videos;
document.querySelector('.storage-value').textContent = formatBytes(totalSize);
document.querySelectorAll('.media-count-item span')[0].textContent = images + ' Images';
document.querySelectorAll('.media-count-item span')[1].textContent = docs + ' Documents';
document.querySelectorAll('.media-count-item span')[2].textContent = videos + ' Videos';
// Simple progress bar logic (assuming 500MB limit for demo)
const pct = Math.min((totalSize / (500 * 1024 * 1024)) * 100, 100);
document.querySelector('.storage-progress').style.width = pct + '%';
}
function formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
// Upload Logic
let uploadFilesList = [];
function handleFileSelect(e) {
const files = Array.from(e.target.files);
uploadFilesList = files;
const queueList = document.getElementById('queue-list');
document.getElementById('upload-queue').style.display = 'block';
queueList.innerHTML = files.map(f => `
<div class="queue-item">
<span>${f.name}</span>
<small>${formatBytes(f.size)}</small>
</div>
`).join('');
document.getElementById('start-upload').disabled = false;
}
async function uploadFiles() {
const folder = document.getElementById('upload-folder').value;
const formData = new FormData();
formData.append('folder', folder);
uploadFilesList.forEach(file => {
formData.append('files[]', file); // Use array name for multiple
});
const btn = document.getElementById('start-upload');
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Uploading...';
btn.disabled = true;
try {
const result = await MSPE.API.upload('media.php', formData);
if (result && result.success) {
MSPE.showNotification('Upload successful', 'success');
closeUploadModal();
loadMedia();
} else {
MSPE.showNotification(result?.message || 'Upload failed', 'error');
}
} catch (error) {
MSPE.showNotification('Error uploading files', 'error');
} finally {
btn.innerHTML = '<i class="fas fa-upload"></i> Start Upload';
btn.disabled = false;
}
}
async function deleteMedia(id) {
if (!await MSPE.confirmDialog('Delete this file?')) return;
try {
const response = await MSPE.API.delete(`media.php?id=${id}`);
if (response.success) {
MSPE.showNotification('File deleted', 'success');
loadMedia();
}
} catch (error) {
MSPE.showNotification('Error deleting file', 'error');
}
}
function copyUrl(path) {
const url = window.location.origin + path;
navigator.clipboard.writeText(url).then(() => {
MSPE.showNotification('URL copied to clipboard', 'success');
});
}
// --- Modals ---
function openUploadModal() {
document.getElementById('upload-modal').classList.add('active');
uploadFilesList = [];
document.getElementById('queue-list').innerHTML = '';
document.getElementById('upload-queue').style.display = 'none';
document.getElementById('start-upload').disabled = true;
}
function closeUploadModal() {
document.getElementById('upload-modal').classList.remove('active');
}
function viewMedia(id) {
const item = allMedia.find(m => m.id === id);
if (!item) return;
document.getElementById('view-modal').classList.add('active');
document.getElementById('view-image').src = '/' + item.path;
document.getElementById('detail-name').textContent = item.name;
document.getElementById('detail-type').textContent = item.type;
document.getElementById('detail-size').textContent = formatBytes(item.size);
document.getElementById('detail-date').textContent = MSPE.formatDate(item.created_at);
document.getElementById('detail-url').value = window.location.origin + '/' + item.path;
}
function closeViewModal() {
document.getElementById('view-modal').classList.remove('active');
}
// Drag & Drop
const dropzone = document.getElementById('dropzone');
const fileInput = document.getElementById('file-input');
dropzone.addEventListener('click', () => fileInput.click());
dropzone.addEventListener('dragover', (e) => {
e.preventDefault();
dropzone.classList.add('dragover');
});
dropzone.addEventListener('dragleave', () => {
dropzone.classList.remove('dragover');
});
dropzone.addEventListener('drop', (e) => {
e.preventDefault();
dropzone.classList.remove('dragover');
fileInput.files = e.dataTransfer.files;
handleFileSelect({ target: fileInput });
});
function updateBulkActions() {
const checked = document.querySelectorAll('.media-checkbox input:checked');
const bulkActions = document.getElementById('bulk-actions');
if (checked.length > 0) {
bulkActions.style.display = 'flex';
bulkActions.querySelector('.selected-count').textContent = checked.length + ' selected';
} else {
bulkActions.style.display = 'none';
}
}
function createFolder() {
const name = prompt('Enter folder name:');
if (name) {
MSPE.showNotification('Folder management is handled on the server file system', 'info');
}
}
function moveSelected() {
const checked = document.querySelectorAll('.media-checkbox input:checked');
if (checked.length === 0) return;
MSPE.showNotification('Move functionality coming soon', 'info');
}
async function downloadSelected() {
const checked = document.querySelectorAll('.media-checkbox input:checked');
if (checked.length === 0) return;
checked.forEach(cb => {
const item = allMedia.find(m => m.id === cb.value);
if (item) downloadFile('/' + item.path);
});
}
async function deleteSelected() {
const checked = document.querySelectorAll('.media-checkbox input:checked');
if (checked.length === 0) return;
if (!await MSPE.confirmDialog(`Delete ${checked.length} selected file(s)?`)) return;
let success = 0;
for (const cb of checked) {
try {
const response = await MSPE.API.delete(`media.php?id=${cb.value}`);
if (response && response.success) success++;
} catch (e) { /* continue */ }
}
MSPE.showNotification(`${success} file(s) deleted`, 'success');
loadMedia();
}
function downloadFile(path) {
const link = document.createElement('a');
link.href = path || '';
link.download = '';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function copyDetailUrl() {
const url = document.getElementById('detail-url').value;
navigator.clipboard.writeText(url).then(() => {
MSPE.showNotification('URL copied to clipboard', 'success');
});
}
async function deleteFromView() {
const img = document.getElementById('view-image');
const item = allMedia.find(m => img.src.includes(m.path));
if (!item) return;
if (!await MSPE.confirmDialog('Delete this file?')) return;
try {
const response = await MSPE.API.delete(`media.php?id=${item.id}`);
if (response && response.success) {
MSPE.showNotification('File deleted', 'success');
closeViewModal();
loadMedia();
}
} catch (error) {
MSPE.showNotification('Error deleting file', 'error');
}
}
function saveDetails() {
const alt = document.getElementById('detail-alt').value;
MSPE.showNotification('Details saved', 'success');
closeViewModal();
}
</script>
</body>
</html>
+741
View File
@@ -0,0 +1,741 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow, noarchive">
<title>Messages - MSPE Admin</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha384-t1nt8BQoYMLFN5p42tRAtuAAFQaCQODekUVeKKZrEnEyp4H2R0RHFz0KWpmj7i8g" crossorigin="anonymous">
<link rel="stylesheet" href="/admin/css/admin.css">
<style>
.messages-grid {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 2rem;
height: calc(100vh - 200px);
}
.messages-list {
background: white;
border-radius: 10px;
box-shadow: var(--shadow-sm);
overflow: hidden;
display: flex;
flex-direction: column;
}
.messages-list-header {
padding: 1.5rem;
border-bottom: 1px solid var(--gray-200);
display: flex;
justify-content: space-between;
align-items: center;
}
.messages-list-header h3 {
margin: 0;
}
.unread-count {
background: var(--primary);
color: white;
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.875rem;
font-weight: 600;
}
.messages-filters {
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--gray-200);
display: flex;
gap: 1rem;
}
.messages-filters select {
padding: 0.5rem 1rem;
border: 1px solid var(--gray-300);
border-radius: var(--radius-sm);
font-size: 0.875rem;
}
.messages-items {
flex: 1;
overflow-y: auto;
}
.message-item {
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--gray-200);
cursor: pointer;
transition: all 0.3s ease;
}
.message-item:hover {
background: var(--gray-50);
}
.message-item.active {
background: var(--primary);
color: white;
}
.message-item.active .message-meta,
.message-item.active .message-preview {
color: rgba(255,255,255,0.8);
}
.message-item.unread {
background: rgba(14, 165, 233, 0.05);
border-left: 3px solid var(--primary);
}
.message-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.5rem;
}
.message-sender {
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
}
.message-sender .unread-dot {
width: 8px;
height: 8px;
background: var(--primary);
border-radius: 50%;
}
.message-meta {
font-size: 0.75rem;
color: var(--gray-500);
}
.message-preview {
font-size: 0.875rem;
color: var(--gray-600);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.message-details {
background: white;
border-radius: 10px;
box-shadow: var(--shadow-sm);
overflow: hidden;
display: flex;
flex-direction: column;
}
.message-details-header {
padding: 1.5rem;
border-bottom: 1px solid var(--gray-200);
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.message-details-header h3 {
margin: 0 0 0.5rem 0;
}
.message-details-header .meta {
font-size: 0.875rem;
color: var(--gray-500);
}
.message-actions-header {
display: flex;
gap: 0.5rem;
}
.message-actions-header button {
padding: 0.5rem 1rem;
border: 1px solid var(--gray-300);
background: white;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
transition: all 0.3s ease;
}
.message-actions-header button:hover {
background: var(--gray-100);
}
.message-actions-header button.delete:hover {
background: #fee2e2;
color: #dc2626;
border-color: #dc2626;
}
.message-details-content {
flex: 1;
padding: 1.5rem;
overflow-y: auto;
}
.contact-info {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.5rem;
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--gray-200);
}
.contact-info-item {
display: flex;
flex-direction: column;
}
.contact-info-item label {
font-size: 0.75rem;
font-weight: 600;
color: var(--gray-500);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 0.375rem;
}
.contact-info-item .value {
font-size: 0.9375rem;
color: var(--gray-700);
}
.contact-info-item .value a {
color: var(--primary);
text-decoration: none;
}
.message-body {
line-height: 1.7;
color: var(--gray-700);
}
.message-body h4 {
margin-bottom: 1rem;
color: var(--gray-900);
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 4rem 2rem;
text-align: center;
color: var(--gray-500);
}
.empty-state i {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.3;
}
@media (max-width: 1024px) {
.messages-grid {
grid-template-columns: 1fr;
height: auto;
}
.messages-list {
max-height: 400px;
}
}
</style>
</head>
<body>
<div class="admin-wrapper">
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<a href="/admin/dashboard.html" class="sidebar-logo">
<i class="fas fa-cube"></i>
<span>MSPE</span>
</a>
<button class="sidebar-toggle" id="sidebar-toggle">
<i class="fas fa-bars"></i>
</button>
</div>
<nav class="sidebar-nav">
<div class="nav-section">
<span class="nav-section-title">Main</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/dashboard.html" class="nav-link">
<i class="fas fa-th-large"></i>
<span>Dashboard</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Content</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/pages.html" class="nav-link">
<i class="fas fa-file-alt"></i>
<span>Pages</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/services.html" class="nav-link">
<i class="fas fa-concierge-bell"></i>
<span>Services</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/portfolio.html" class="nav-link">
<i class="fas fa-briefcase"></i>
<span>Portfolio</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/news.html" class="nav-link">
<i class="fas fa-newspaper"></i>
<span>News & Events</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/testimonials.html" class="nav-link">
<i class="fas fa-quote-right"></i>
<span>Testimonials</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/team.html" class="nav-link">
<i class="fas fa-users"></i>
<span>Team</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Media</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/media.html" class="nav-link">
<i class="fas fa-images"></i>
<span>Media Library</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Inquiries</span>
<ul class="nav-menu">
<li class="nav-item active">
<a href="/admin/messages.html" class="nav-link">
<i class="fas fa-envelope"></i>
<span>Messages</span>
<span class="badge" id="sidebar-unread-count">0</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/subscribers.html" class="nav-link">
<i class="fas fa-bell"></i>
<span>Subscribers</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/bookings.html" class="nav-link">
<i class="fas fa-calendar-check"></i>
<span>Bookings</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Settings</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/settings.html" class="nav-link">
<i class="fas fa-cog"></i>
<span>Site Settings</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/users.html" class="nav-link">
<i class="fas fa-user-shield"></i>
<span>Admin Users</span>
</a>
</li>
</ul>
</div>
</nav>
<div class="sidebar-footer">
<a href="/index.html" class="view-site-btn" target="_blank">
<i class="fas fa-external-link-alt"></i>
<span>View Website</span>
</a>
</div>
</aside>
<main class="main-content">
<header class="admin-header">
<div class="header-left">
<button class="mobile-toggle" id="mobile-toggle">
<i class="fas fa-bars"></i>
</button>
<h1 class="page-title">Messages</h1>
</div>
<div class="header-right">
<div class="header-search">
<i class="fas fa-search"></i>
<input type="text" placeholder="Search messages..." id="search-input">
</div>
<div class="header-user">
<div class="user-avatar">
<img src="https://ui-avatars.com/api/?name=Admin&background=0ea5e9&color=fff" alt="Admin">
</div>
<div class="user-info">
<span class="user-name">Admin</span>
<span class="user-role">Administrator</span>
</div>
<div class="user-dropdown">
<button class="dropdown-toggle">
<i class="fas fa-chevron-down"></i>
</button>
<ul class="dropdown-menu">
<li><a href="/admin/settings.html"><i class="fas fa-cog"></i> Settings</a></li>
<li class="divider"></li>
<li><a href="#" id="logout-btn"><i class="fas fa-sign-out-alt"></i> Logout</a></li>
</ul>
</div>
</div>
</div>
</header>
<div class="content-wrapper">
<div class="messages-grid">
<div class="messages-list">
<div class="messages-list-header">
<h3>Inbox</h3>
<span class="unread-count" id="unread-count">0 unread</span>
</div>
<div class="messages-filters">
<select id="status-filter">
<option value="">All Messages</option>
<option value="unread">Unread</option>
<option value="read">Read</option>
</select>
</div>
<div class="messages-items" id="messages-list">
<div class="empty-state">
<i class="fas fa-spinner fa-spin"></i>
<p>Loading messages...</p>
</div>
</div>
</div>
<div class="message-details" id="message-details">
<div class="empty-state">
<i class="fas fa-envelope-open-text"></i>
<p>Select a message to view details</p>
</div>
</div>
</div>
</div>
</main>
</div>
<script src="/admin/js/admin.js"></script>
<script>
let allMessages = [];
let selectedMessageId = null;
document.addEventListener('DOMContentLoaded', function() {
loadMessages();
setupEventListeners();
checkUrlParams();
});
function setupEventListeners() {
document.getElementById('status-filter').addEventListener('change', filterMessages);
document.getElementById('search-input').addEventListener('input', MSPE.debounce(filterMessages, 300));
}
function checkUrlParams() {
const params = new URLSearchParams(window.location.search);
const messageId = params.get('id');
if (messageId) {
setTimeout(() => selectMessage(messageId), 500);
}
}
async function loadMessages() {
try {
const response = await MSPE.API.get('contact.php');
if (response && response.success) {
allMessages = response.data || [];
updateUnreadCount(response.unread || 0);
renderMessagesList(allMessages);
}
} catch (error) {
console.error('Failed to load messages:', error);
document.getElementById('messages-list').innerHTML = `
<div class="empty-state">
<i class="fas fa-exclamation-circle"></i>
<p>Failed to load messages</p>
</div>
`;
}
}
function updateUnreadCount(count) {
document.getElementById('unread-count').textContent = `${count} unread`;
document.getElementById('sidebar-unread-count').textContent = count;
if (count === 0) {
document.getElementById('sidebar-unread-count').style.display = 'none';
} else {
document.getElementById('sidebar-unread-count').style.display = 'inline';
}
}
function renderMessagesList(messages) {
const container = document.getElementById('messages-list');
if (!messages || messages.length === 0) {
container.innerHTML = `
<div class="empty-state">
<i class="fas fa-inbox"></i>
<p>No messages yet</p>
<span style="font-size: 0.875rem;">Messages from your contact form will appear here</span>
</div>
`;
return;
}
container.innerHTML = messages.map(message => {
const isUnread = (message.status === 'unread');
const isActive = (message.id === selectedMessageId);
const name = `${message.first_name} ${message.last_name}`;
const timeAgo = getTimeAgo(new Date(message.created_at));
const preview = message.message.length > 60
? message.message.substring(0, 60) + '...'
: message.message;
return `
<div class="message-item ${isUnread ? 'unread' : ''} ${isActive ? 'active' : ''}"
onclick="selectMessage('${message.id}')"
data-id="${message.id}">
<div class="message-header">
<span class="message-sender">
${isUnread ? '<span class="unread-dot"></span>' : ''}
${name}
</span>
<span class="message-meta">${timeAgo}</span>
</div>
<div class="message-preview">${preview}</div>
</div>
`;
}).join('');
}
async function selectMessage(id) {
selectedMessageId = id;
// Update list selection
document.querySelectorAll('.message-item').forEach(item => {
item.classList.remove('active');
if (item.dataset.id === id) {
item.classList.add('active');
item.classList.remove('unread');
}
});
try {
const response = await MSPE.API.get(`contact.php?id=${id}`);
if (response && response.success && response.data) {
const message = response.data;
renderMessageDetails(message);
// Update message in local array
const index = allMessages.findIndex(m => m.id === id);
if (index !== -1) {
allMessages[index].status = 'read';
}
// Update unread count
const unreadCount = allMessages.filter(m => m.status === 'unread').length;
updateUnreadCount(unreadCount);
}
} catch (error) {
console.error('Failed to load message:', error);
}
}
function renderMessageDetails(message) {
const container = document.getElementById('message-details');
const name = `${message.first_name} ${message.last_name}`;
const date = new Date(message.created_at);
const formattedDate = date.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
});
const serviceLabels = {
'it-support': 'IT Support',
'cybersecurity': 'Cybersecurity',
'cloud': 'Cloud Services',
'consulting': 'Business Consulting',
'general': 'General Inquiry'
};
container.innerHTML = `
<div class="message-details-header">
<div>
<h3>${name}</h3>
<div class="meta">${formattedDate}</div>
</div>
<div class="message-actions-header">
<button onclick="replyToMessage('${message.email}')">
<i class="fas fa-reply"></i> Reply
</button>
<button class="delete" onclick="deleteMessage('${message.id}')">
<i class="fas fa-trash"></i> Delete
</button>
</div>
</div>
<div class="message-details-content">
<div class="contact-info">
<div class="contact-info-item">
<label>Email</label>
<div class="value"><a href="mailto:${message.email}">${message.email}</a></div>
</div>
<div class="contact-info-item">
<label>Phone</label>
<div class="value">${message.phone || 'Not provided'}</div>
</div>
<div class="contact-info-item">
<label>Company</label>
<div class="value">${message.company || 'Not provided'}</div>
</div>
<div class="contact-info-item">
<label>Service Interest</label>
<div class="value">${serviceLabels[message.service] || message.service || 'Not specified'}</div>
</div>
${message.budget ? `
<div class="contact-info-item">
<label>Budget</label>
<div class="value">${message.budget}</div>
</div>
` : ''}
</div>
<div class="message-body">
<h4>Message</h4>
<p>${message.message.replace(/\n/g, '<br>')}</p>
</div>
</div>
`;
}
function replyToMessage(email) {
window.location.href = `mailto:${email}`;
}
async function deleteMessage(id) {
if (!await MSPE.confirmDialog('Are you sure you want to delete this message? This action cannot be undone.')) {
return;
}
try {
const response = await MSPE.API.delete(`contact.php?id=${id}`);
if (response && response.success) {
MSPE.showNotification('Message deleted successfully', 'success');
// Remove from local array
allMessages = allMessages.filter(m => m.id !== id);
// Clear details panel
selectedMessageId = null;
document.getElementById('message-details').innerHTML = `
<div class="empty-state">
<i class="fas fa-envelope-open-text"></i>
<p>Select a message to view details</p>
</div>
`;
// Re-render list
renderMessagesList(allMessages);
// Update unread count
const unreadCount = allMessages.filter(m => m.status === 'unread').length;
updateUnreadCount(unreadCount);
}
} catch (error) {
console.error('Failed to delete message:', error);
MSPE.showNotification('Failed to delete message', 'error');
}
}
function filterMessages() {
const status = document.getElementById('status-filter').value;
const search = document.getElementById('search-input').value.toLowerCase();
let filtered = allMessages;
if (status) {
filtered = filtered.filter(m => m.status === status);
}
if (search) {
filtered = filtered.filter(m =>
m.first_name.toLowerCase().includes(search) ||
m.last_name.toLowerCase().includes(search) ||
m.email.toLowerCase().includes(search) ||
m.message.toLowerCase().includes(search) ||
(m.company && m.company.toLowerCase().includes(search))
);
}
renderMessagesList(filtered);
}
function getTimeAgo(date) {
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
</script>
</body>
</html>
+708
View File
@@ -0,0 +1,708 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow, noarchive">
<title>News Management - MSPE Admin</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha384-t1nt8BQoYMLFN5p42tRAtuAAFQaCQODekUVeKKZrEnEyp4H2R0RHFz0KWpmj7i8g" crossorigin="anonymous">
<link rel="stylesheet" href="/admin/css/admin.css">
</head>
<body>
<div class="admin-wrapper">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<a href="/admin/dashboard.html" class="sidebar-logo">
<i class="fas fa-cube"></i>
<span>MSPE</span>
</a>
<button class="sidebar-toggle" id="sidebar-toggle">
<i class="fas fa-bars"></i>
</button>
</div>
<nav class="sidebar-nav">
<div class="nav-section">
<span class="nav-section-title">Main</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/dashboard.html" class="nav-link">
<i class="fas fa-th-large"></i>
<span>Dashboard</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Content</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/pages.html" class="nav-link">
<i class="fas fa-file-alt"></i>
<span>Pages</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/services.html" class="nav-link">
<i class="fas fa-concierge-bell"></i>
<span>Services</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/portfolio.html" class="nav-link">
<i class="fas fa-briefcase"></i>
<span>Portfolio</span>
</a>
</li>
<li class="nav-item active">
<a href="/admin/news.html" class="nav-link">
<i class="fas fa-newspaper"></i>
<span>News & Events</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/testimonials.html" class="nav-link">
<i class="fas fa-quote-right"></i>
<span>Testimonials</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/team.html" class="nav-link">
<i class="fas fa-users"></i>
<span>Team</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Media</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/media.html" class="nav-link">
<i class="fas fa-images"></i>
<span>Media Library</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Inquiries</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/messages.html" class="nav-link">
<i class="fas fa-envelope"></i>
<span>Messages</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/subscribers.html" class="nav-link">
<i class="fas fa-bell"></i>
<span>Subscribers</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/bookings.html" class="nav-link">
<i class="fas fa-calendar-check"></i>
<span>Bookings</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Settings</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/settings.html" class="nav-link">
<i class="fas fa-cog"></i>
<span>Site Settings</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/users.html" class="nav-link">
<i class="fas fa-user-shield"></i>
<span>Admin Users</span>
</a>
</li>
</ul>
</div>
</nav>
<div class="sidebar-footer">
<a href="/index.html" class="view-site-btn" target="_blank">
<i class="fas fa-external-link-alt"></i>
<span>View Website</span>
</a>
</div>
</aside>
<!-- Main Content -->
<main class="main-content">
<!-- Header -->
<header class="admin-header">
<div class="header-left">
<button class="mobile-toggle" id="mobile-toggle">
<i class="fas fa-bars"></i>
</button>
<h1 class="page-title">News & Events</h1>
</div>
<div class="header-right">
<div class="header-user">
<div class="user-avatar">
<img src="https://ui-avatars.com/api/?name=Admin&background=0ea5e9&color=fff" alt="Admin">
</div>
<div class="user-info">
<span class="user-name">Admin</span>
<span class="user-role">Administrator</span>
</div>
<div class="user-dropdown">
<button class="dropdown-toggle">
<i class="fas fa-chevron-down"></i>
</button>
<ul class="dropdown-menu">
<li><a href="/admin/settings.html"><i class="fas fa-cog"></i> Settings</a></li>
<li class="divider"></li>
<li><a href="#" id="logout-btn"><i class="fas fa-sign-out-alt"></i> Logout</a></li>
</ul>
</div>
</div>
</div>
</header>
<!-- Content -->
<div class="content-wrapper">
<!-- List View -->
<div id="list-view">
<div class="content-header">
<div class="content-filters">
<div class="filter-group">
<select id="category-filter">
<option value="">All Categories</option>
<option value="news">Company News</option>
<option value="events">Events</option>
<option value="insights">Industry Insights</option>
<option value="updates">Product Updates</option>
</select>
</div>
<div class="filter-group">
<select id="status-filter">
<option value="">All Status</option>
<option value="published">Published</option>
<option value="draft">Draft</option>
</select>
</div>
<div class="search-box">
<i class="fas fa-search"></i>
<input type="text" id="search-input" placeholder="Search news...">
</div>
</div>
<button class="btn btn-primary" id="add-new-btn">
<i class="fas fa-plus"></i> Add New
</button>
</div>
<div class="card">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th width="50">
<label class="checkbox-label">
<input type="checkbox" id="select-all">
<span class="checkmark"></span>
</label>
</th>
<th>Title</th>
<th width="120">Category</th>
<th width="100">Status</th>
<th width="120">Date</th>
<th width="120">Actions</th>
</tr>
</thead>
<tbody id="news-table-body">
<!-- Data will be loaded via JS -->
</tbody>
</table>
</div>
<div class="table-footer">
<div class="bulk-actions">
<select id="bulk-action">
<option value="">Bulk Actions</option>
<option value="publish">Publish</option>
<option value="draft">Set as Draft</option>
<option value="delete">Delete</option>
</select>
<button class="btn btn-secondary btn-sm" id="apply-bulk">Apply</button>
</div>
<div class="pagination">
<span class="pagination-info" id="pagination-info">Showing 0 articles</span>
<div class="pagination-controls" id="pagination-controls">
</div>
</div>
</div>
</div>
</div>
<!-- Edit/Add Form -->
<div id="form-view" style="display: none;">
<div class="content-header">
<button class="btn btn-secondary" id="back-to-list">
<i class="fas fa-arrow-left"></i> Back to List
</button>
</div>
<form id="news-form" class="edit-form">
<div class="form-grid">
<div class="form-main">
<div class="card">
<div class="card-header">
<h2 id="form-title">Add New Article</h2>
</div>
<div class="card-body">
<input type="hidden" id="article-id" name="id">
<div class="form-group">
<label for="title">Title *</label>
<input type="text" id="title" name="title" required placeholder="Enter article title">
</div>
<div class="form-group">
<label for="slug">Slug</label>
<input type="text" id="slug" name="slug" placeholder="auto-generated-from-title">
</div>
<div class="form-group">
<label for="excerpt">Excerpt *</label>
<textarea id="excerpt" name="excerpt" rows="3" required placeholder="Brief description of the article"></textarea>
</div>
<div class="form-group">
<label for="content">Content *</label>
<textarea id="content" name="content" rows="15" required placeholder="Write your article content here..."></textarea>
</div>
</div>
</div>
</div>
<div class="form-sidebar">
<div class="card">
<div class="card-header">
<h3>Publish</h3>
</div>
<div class="card-body">
<div class="form-group">
<label for="status">Status</label>
<select id="status" name="status">
<option value="draft">Draft</option>
<option value="published">Published</option>
</select>
</div>
<div class="form-group">
<label for="publish-date">Publish Date</label>
<input type="datetime-local" id="publish-date" name="publish_date">
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary btn-block" id="save-draft">
Save Draft
</button>
<button type="submit" class="btn btn-primary btn-block">
<i class="fas fa-check"></i> Publish
</button>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3>Category</h3>
</div>
<div class="card-body">
<div class="form-group">
<select id="category" name="category" required>
<option value="">Select Category</option>
<option value="news">Company News</option>
<option value="events">Events</option>
<option value="insights">Industry Insights</option>
<option value="updates">Product Updates</option>
</select>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3>Featured Image</h3>
</div>
<div class="card-body">
<div class="image-upload" id="image-upload">
<div class="upload-placeholder" id="upload-placeholder">
<i class="fas fa-cloud-upload-alt"></i>
<p>Drag & drop or click to upload</p>
<span>Recommended: 800x500px</span>
</div>
<img id="image-preview" src="" alt="" style="display: none;">
<input type="file" id="featured-image" name="featured_image" accept="image/*" hidden>
<button type="button" class="btn btn-secondary btn-sm remove-image" id="remove-image" style="display: none;">
<i class="fas fa-times"></i> Remove
</button>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3>Author</h3>
</div>
<div class="card-body">
<div class="form-group">
<input type="text" id="author" name="author" value="Admin" placeholder="Author name">
</div>
</div>
</div>
</div>
</div>
</form>
</div>
</div>
</main>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal" id="delete-modal">
<div class="modal-overlay"></div>
<div class="modal-content">
<div class="modal-header">
<h3>Confirm Delete</h3>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete this article? This action cannot be undone.</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary modal-cancel">Cancel</button>
<button class="btn btn-danger" id="confirm-delete">Delete</button>
</div>
</div>
</div>
<script src="/admin/js/admin.js"></script>
<script>
let allArticles = [];
document.addEventListener('DOMContentLoaded', function() {
loadArticles();
setupEventListeners();
// Check for edit param in URL
const urlParams = new URLSearchParams(window.location.search);
const editId = urlParams.get('edit');
const action = urlParams.get('action');
if (editId) {
editArticle(editId);
} else if (action === 'add') {
showForm();
}
});
function setupEventListeners() {
document.getElementById('add-new-btn').addEventListener('click', showForm);
document.getElementById('back-to-list').addEventListener('click', showList);
document.getElementById('news-form').addEventListener('submit', saveArticle);
// Save draft button
document.getElementById('save-draft').addEventListener('click', function() {
document.getElementById('status').value = 'draft';
document.getElementById('news-form').dispatchEvent(new Event('submit', { cancelable: true }));
});
// Bulk actions
document.getElementById('apply-bulk').addEventListener('click', applyBulkAction);
// Filters
document.getElementById('category-filter').addEventListener('change', filterArticles);
document.getElementById('status-filter').addEventListener('change', filterArticles);
document.getElementById('search-input').addEventListener('input', MSPE.debounce(filterArticles, 300));
// Select all
document.getElementById('select-all').addEventListener('change', function() {
document.querySelectorAll('.row-select').forEach(cb => cb.checked = this.checked);
});
// Image upload handling
const imageUpload = document.getElementById('image-upload');
const imageInput = document.getElementById('featured-image');
const removeImageBtn = document.getElementById('remove-image');
imageUpload.addEventListener('click', function(e) {
if (e.target !== removeImageBtn && !removeImageBtn.contains(e.target)) {
imageInput.click();
}
});
imageInput.addEventListener('change', function() {
if (this.files && this.files[0]) {
const reader = new FileReader();
reader.onload = function(e) {
document.getElementById('image-preview').src = e.target.result;
document.getElementById('image-preview').style.display = 'block';
document.getElementById('upload-placeholder').style.display = 'none';
removeImageBtn.style.display = 'block';
};
reader.readAsDataURL(this.files[0]);
}
});
removeImageBtn.addEventListener('click', function(e) {
e.stopPropagation();
imageInput.value = '';
document.getElementById('image-preview').style.display = 'none';
document.getElementById('image-preview').src = '';
document.getElementById('upload-placeholder').style.display = 'flex';
removeImageBtn.style.display = 'none';
});
// Auto-slug
document.getElementById('title').addEventListener('input', function() {
const slug = this.value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
document.getElementById('slug').value = slug;
});
}
async function loadArticles() {
try {
const response = await MSPE.API.get('news.php');
if (response && response.success) {
allArticles = response.data;
renderArticles(allArticles);
}
} catch (error) {
console.error('Failed to load articles:', error);
document.getElementById('news-table-body').innerHTML = `
<tr><td colspan="6" class="text-center">Failed to load articles</td></tr>
`;
}
}
function renderArticles(articles) {
const tbody = document.getElementById('news-table-body');
// Update pagination info
const infoEl = document.getElementById('pagination-info');
if (infoEl) {
infoEl.textContent = articles.length === 0
? 'No articles'
: `Showing ${articles.length} article${articles.length !== 1 ? 's' : ''}`;
}
if (articles.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="6">
<div class="empty-state">
<i class="fas fa-newspaper"></i>
<p>No articles found</p>
</div>
</td>
</tr>
`;
return;
}
tbody.innerHTML = articles.map(article => `
<tr>
<td>
<label class="checkbox-label">
<input type="checkbox" class="row-select" value="${article.id}">
<span class="checkmark"></span>
</label>
</td>
<td>
<div class="table-item">
<img src="${article.featured_image || 'https://via.placeholder.com/60x40'}" alt="" style="object-fit: cover; width: 60px; height: 40px;">
<span>${article.title}</span>
</div>
</td>
<td><span class="category-badge ${article.category}">${article.category}</span></td>
<td><span class="status-badge ${article.status}">${article.status}</span></td>
<td>${MSPE.formatDate(article.created_at)}</td>
<td>
<div class="table-actions">
<button class="action-btn edit" onclick="editArticle('${article.id}')" title="Edit"><i class="fas fa-edit"></i></button>
<button class="action-btn delete" onclick="deleteArticle('${article.id}')" title="Delete"><i class="fas fa-trash"></i></button>
</div>
</td>
</tr>
`).join('');
}
function filterArticles() {
const category = document.getElementById('category-filter').value;
const status = document.getElementById('status-filter').value;
const search = document.getElementById('search-input').value.toLowerCase();
let filtered = allArticles;
if (category) filtered = filtered.filter(a => a.category === category);
if (status) filtered = filtered.filter(a => a.status === status);
if (search) filtered = filtered.filter(a => a.title.toLowerCase().includes(search));
renderArticles(filtered);
}
function showForm() {
document.getElementById('list-view').style.display = 'none';
document.getElementById('form-view').style.display = 'block';
document.getElementById('form-title').textContent = 'Add New Article';
document.getElementById('news-form').reset();
document.getElementById('article-id').value = '';
// Reset image
document.getElementById('image-preview').style.display = 'none';
document.getElementById('upload-placeholder').style.display = 'flex';
document.getElementById('remove-image').style.display = 'none';
}
function showList() {
document.getElementById('form-view').style.display = 'none';
document.getElementById('list-view').style.display = 'block';
// Update URL without refresh
const url = new URL(window.location);
url.searchParams.delete('edit');
url.searchParams.delete('action');
window.history.pushState({}, '', url);
}
async function editArticle(id) {
try {
const response = await MSPE.API.get(`news.php?id=${id}`);
if (response && response.success) {
const article = response.data;
showForm();
document.getElementById('form-title').textContent = 'Edit Article';
document.getElementById('article-id').value = article.id;
document.getElementById('title').value = article.title;
document.getElementById('slug').value = article.slug || '';
document.getElementById('excerpt').value = article.excerpt || '';
document.getElementById('content').value = article.content || '';
document.getElementById('category').value = article.category;
document.getElementById('status').value = article.status;
document.getElementById('author').value = article.author;
if (article.featured_image) {
document.getElementById('image-preview').src = article.featured_image;
document.getElementById('image-preview').style.display = 'block';
document.getElementById('upload-placeholder').style.display = 'none';
document.getElementById('remove-image').style.display = 'block';
}
}
} catch (error) {
console.error('Failed to load article:', error);
MSPE.showNotification('Failed to load article details', 'error');
}
}
async function saveArticle(e) {
e.preventDefault();
const form = e.target;
const formData = new FormData(form);
const submitBtn = form.querySelector('button[type="submit"]');
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Saving...';
try {
const result = await MSPE.API.upload('news.php', formData);
if (result && result.success) {
MSPE.showNotification('Article saved successfully', 'success');
loadArticles();
showList();
} else {
MSPE.showNotification(result?.message || 'Failed to save article', 'error');
}
} catch (error) {
console.error('Save error:', error);
MSPE.showNotification('An error occurred while saving', 'error');
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = '<i class="fas fa-check"></i> Publish';
}
}
async function deleteArticle(id) {
if (!await MSPE.confirmDialog('Are you sure you want to delete this article?')) {
return;
}
try {
const response = await MSPE.API.delete(`news.php?id=${id}`);
if (response && response.success) {
MSPE.showNotification('Article deleted successfully', 'success');
loadArticles();
}
} catch (error) {
console.error('Delete error:', error);
MSPE.showNotification('Failed to delete article', 'error');
}
}
async function applyBulkAction() {
const action = document.getElementById('bulk-action').value;
if (!action) {
MSPE.showNotification('Please select a bulk action', 'warning');
return;
}
const selected = Array.from(document.querySelectorAll('.row-select:checked')).map(cb => cb.value);
if (selected.length === 0) {
MSPE.showNotification('No articles selected', 'warning');
return;
}
if (action === 'delete') {
if (!await MSPE.confirmDialog(`Delete ${selected.length} selected article(s)?`)) return;
let success = 0;
for (const id of selected) {
try {
const res = await MSPE.API.delete(`news.php?id=${id}`);
if (res && res.success) success++;
} catch (e) { /* continue */ }
}
MSPE.showNotification(`${success} article(s) deleted`, 'success');
} else {
// publish or draft
let success = 0;
for (const id of selected) {
try {
const formData = new FormData();
formData.append('id', id);
formData.append('status', action === 'publish' ? 'published' : 'draft');
const res = await MSPE.API.upload('news.php', formData);
if (res && res.success) success++;
} catch (e) { /* continue */ }
}
MSPE.showNotification(`${success} article(s) updated`, 'success');
}
document.getElementById('select-all').checked = false;
loadArticles();
}
</script>
</body>
</html>
+512
View File
@@ -0,0 +1,512 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow, noarchive">
<title>Pages Management - MSPE Admin</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha384-t1nt8BQoYMLFN5p42tRAtuAAFQaCQODekUVeKKZrEnEyp4H2R0RHFz0KWpmj7i8g" crossorigin="anonymous">
<link rel="stylesheet" href="/admin/css/admin.css">
</head>
<body>
<div class="admin-wrapper">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<a href="/admin/dashboard.html" class="sidebar-logo">
<i class="fas fa-cube"></i>
<span>MSPE</span>
</a>
<button class="sidebar-toggle" id="sidebar-toggle">
<i class="fas fa-bars"></i>
</button>
</div>
<nav class="sidebar-nav">
<div class="nav-section">
<span class="nav-section-title">Main</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/dashboard.html" class="nav-link">
<i class="fas fa-th-large"></i>
<span>Dashboard</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Content</span>
<ul class="nav-menu">
<li class="nav-item active">
<a href="/admin/pages.html" class="nav-link">
<i class="fas fa-file-alt"></i>
<span>Pages</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/services.html" class="nav-link">
<i class="fas fa-concierge-bell"></i>
<span>Services</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/portfolio.html" class="nav-link">
<i class="fas fa-briefcase"></i>
<span>Portfolio</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/news.html" class="nav-link">
<i class="fas fa-newspaper"></i>
<span>News & Events</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/testimonials.html" class="nav-link">
<i class="fas fa-quote-right"></i>
<span>Testimonials</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/team.html" class="nav-link">
<i class="fas fa-users"></i>
<span>Team</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Media</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/media.html" class="nav-link">
<i class="fas fa-images"></i>
<span>Media Library</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Inquiries</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/messages.html" class="nav-link">
<i class="fas fa-envelope"></i>
<span>Messages</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/subscribers.html" class="nav-link">
<i class="fas fa-bell"></i>
<span>Subscribers</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/bookings.html" class="nav-link">
<i class="fas fa-calendar-check"></i>
<span>Bookings</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Settings</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/settings.html" class="nav-link">
<i class="fas fa-cog"></i>
<span>Site Settings</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/users.html" class="nav-link">
<i class="fas fa-user-shield"></i>
<span>Admin Users</span>
</a>
</li>
</ul>
</div>
</nav>
<div class="sidebar-footer">
<a href="/index.html" class="view-site-btn" target="_blank">
<i class="fas fa-external-link-alt"></i>
<span>View Website</span>
</a>
</div>
</aside>
<!-- Main Content -->
<main class="main-content">
<!-- Header -->
<header class="admin-header">
<div class="header-left">
<button class="mobile-toggle" id="mobile-toggle">
<i class="fas fa-bars"></i>
</button>
<h1 class="page-title">Pages Management</h1>
</div>
<div class="header-right">
<div class="header-user">
<div class="user-avatar">
<img src="https://ui-avatars.com/api/?name=Admin&background=0ea5e9&color=fff" alt="Admin">
</div>
<div class="user-info">
<span class="user-name">Admin</span>
<span class="user-role">Administrator</span>
</div>
<div class="user-dropdown">
<button class="dropdown-toggle">
<i class="fas fa-chevron-down"></i>
</button>
<ul class="dropdown-menu">
<li><a href="/admin/settings.html"><i class="fas fa-cog"></i> Settings</a></li>
<li class="divider"></li>
<li><a href="#" id="logout-btn"><i class="fas fa-sign-out-alt"></i> Logout</a></li>
</ul>
</div>
</div>
</div>
</header>
<!-- Content -->
<div class="content-wrapper">
<div class="content-header">
<p class="content-description">Manage your website pages and their content sections</p>
</div>
<!-- Pages Grid (loaded dynamically) -->
<div class="pages-grid" id="pages-grid">
<div style="grid-column:1/-1;text-align:center;padding:3rem;color:var(--gray-400);">
<i class="fas fa-spinner fa-spin fa-2x"></i>
<p style="margin-top:1rem;">Loading pages...</p>
</div>
</div>
<!-- Global Sections -->
<div class="card" style="margin-top: 2rem;">
<div class="card-header">
<h3><i class="fas fa-layer-group"></i> Global Sections</h3>
<p class="card-subtitle">These sections appear across multiple pages</p>
</div>
<div class="card-body">
<div class="global-sections-grid" id="global-sections-grid">
<div style="text-align:center;padding:2rem;color:var(--gray-400);grid-column:1/-1;">
<i class="fas fa-spinner fa-spin"></i> Loading...
</div>
</div>
</div>
</div>
</div>
</main>
</div>
<!-- Page Editor Modal -->
<div class="modal" id="page-modal">
<div class="modal-overlay" onclick="closePageModal()"></div>
<div class="modal-content" style="max-width:700px;">
<div class="modal-header">
<h3 id="page-modal-title">Edit Page</h3>
<button class="modal-close" onclick="closePageModal()"><i class="fas fa-times"></i></button>
</div>
<div class="modal-body" style="max-height:70vh;overflow-y:auto;">
<form id="page-form">
<input type="hidden" id="page-slug">
<!-- SEO & Meta -->
<div style="margin-bottom:1.5rem;">
<h4 style="font-size:.875rem;font-weight:600;color:var(--dark);margin-bottom:.75rem;display:flex;align-items:center;gap:.5rem;">
<i class="fas fa-search" style="color:var(--primary);"></i> SEO & Metadata
</h4>
<div class="form-group" style="margin-bottom:.75rem;">
<label>Meta Title</label>
<input type="text" class="form-control" id="page-meta-title" placeholder="Page title for search engines">
<small style="color:var(--gray-400);font-size:.75rem;">Recommended: 5060 characters</small>
</div>
<div class="form-group" style="margin-bottom:.75rem;">
<label>Meta Description</label>
<textarea class="form-control" id="page-meta-desc" rows="2" placeholder="Brief description for search results"></textarea>
<small style="color:var(--gray-400);font-size:.75rem;">Recommended: 150160 characters</small>
</div>
<div class="form-group">
<label>Status</label>
<select class="form-control" id="page-status">
<option value="published">Published</option>
<option value="draft">Draft</option>
</select>
</div>
</div>
<!-- Sections -->
<div id="page-sections-editor"></div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-outline" onclick="closePageModal()">Cancel</button>
<button class="btn btn-primary" onclick="savePage()"><i class="fas fa-save"></i> Save Changes</button>
</div>
</div>
</div>
<!-- Global Section Editor Modal -->
<div class="modal" id="global-modal">
<div class="modal-overlay" onclick="closeGlobalModal()"></div>
<div class="modal-content" style="max-width:550px;">
<div class="modal-header">
<h3 id="global-modal-title">Edit Section</h3>
<button class="modal-close" onclick="closeGlobalModal()"><i class="fas fa-times"></i></button>
</div>
<div class="modal-body">
<form id="global-form">
<input type="hidden" id="global-slug">
<div id="global-fields-editor"></div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-outline" onclick="closeGlobalModal()">Cancel</button>
<button class="btn btn-primary" onclick="saveGlobalSection()"><i class="fas fa-save"></i> Save</button>
</div>
</div>
</div>
<script src="/admin/js/admin.js"></script>
<script>
let allPages = [];
let globalSections = [];
let currentPageData = null;
document.addEventListener('DOMContentLoaded', function() {
loadPages();
loadGlobalSections();
});
// ── Load & Render ────────────────────────────────────
async function loadPages() {
try {
const res = await MSPE.API.get('pages.php');
if (res && res.success) {
allPages = res.data;
renderPages(allPages);
}
} catch (err) {
console.error('Error loading pages:', err);
document.getElementById('pages-grid').innerHTML =
'<div class="empty-state" style="grid-column:1/-1;"><i class="fas fa-exclamation-circle"></i><p>Failed to load pages</p></div>';
}
}
function renderPages(pages) {
const grid = document.getElementById('pages-grid');
if (!pages.length) {
grid.innerHTML = '<div class="empty-state" style="grid-column:1/-1;"><i class="fas fa-file-alt"></i><p>No pages found</p></div>';
return;
}
grid.innerHTML = pages.map(page => {
const sectionTags = page.sections.map(s => `<span class="section-tag">${s.label || s}</span>`).join('');
const statusClass = page.status === 'draft' ? 'draft' : 'published';
const statusLabel = page.status === 'draft' ? 'Draft' : 'Published';
const lastSaved = page.updated_at ? `<span style="font-size:.7rem;color:var(--gray-400);display:block;margin-top:.25rem;">Last saved: ${MSPE.formatDate(page.updated_at)}</span>` : '';
return `
<div class="page-admin-card">
<div class="page-admin-header">
<div class="page-icon"><i class="fas ${page.icon || 'fa-file-alt'}"></i></div>
<div class="page-status ${statusClass}"><i class="fas fa-circle"></i> ${statusLabel}</div>
</div>
<h3>${page.title}</h3>
<p>${page.description || ''}${lastSaved}</p>
<div class="page-sections">${sectionTags}</div>
<div class="page-admin-actions">
<a href="${page.url || '#'}" target="_blank" class="btn btn-sm btn-outline">
<i class="fas fa-eye"></i> View
</a>
<button class="btn btn-sm btn-primary" onclick="editPage('${page.slug}')">
<i class="fas fa-edit"></i> Edit
</button>
</div>
</div>`;
}).join('');
}
async function loadGlobalSections() {
try {
const res = await MSPE.API.get('pages.php?type=global');
if (res && res.success) {
globalSections = res.data;
renderGlobalSections(globalSections);
}
} catch (err) {
console.error('Error loading global sections:', err);
}
}
function renderGlobalSections(sections) {
const grid = document.getElementById('global-sections-grid');
grid.innerHTML = sections.map(s => `
<div class="global-section-item">
<i class="fas ${s.icon}"></i>
<div>
<h4>${s.label}</h4>
<p>${s.description}</p>
</div>
<button class="btn btn-sm btn-outline" onclick="editGlobalSection('${s.slug}')">Edit</button>
</div>
`).join('');
}
// ── Page Editor ──────────────────────────────────────
async function editPage(slug) {
try {
const res = await MSPE.API.get('pages.php?id=' + slug);
if (!res || !res.success) {
MSPE.showNotification('Failed to load page data', 'error');
return;
}
currentPageData = res.data;
document.getElementById('page-modal-title').textContent = 'Edit: ' + currentPageData.title;
document.getElementById('page-slug').value = currentPageData.slug;
document.getElementById('page-meta-title').value = currentPageData.meta_title || '';
document.getElementById('page-meta-desc').value = currentPageData.meta_description || '';
document.getElementById('page-status').value = currentPageData.status || 'published';
// Build section editors
const container = document.getElementById('page-sections-editor');
container.innerHTML = currentPageData.sections.map((section, si) => {
const fields = section.fields.map((f, fi) => {
const inputId = 'sect-' + section.key + '-' + f.name;
if (f.type === 'textarea') {
return `<div class="form-group" style="margin-bottom:.75rem;">
<label>${f.label}</label>
<textarea class="form-control section-field" id="${inputId}" data-section="${section.key}" data-field="${f.name}" rows="2" placeholder="${f.label}">${f.value || ''}</textarea>
</div>`;
}
return `<div class="form-group" style="margin-bottom:.75rem;">
<label>${f.label}</label>
<input type="text" class="form-control section-field" id="${inputId}" data-section="${section.key}" data-field="${f.name}" value="${(f.value || '').replace(/"/g, '&quot;')}" placeholder="${f.label}">
</div>`;
}).join('');
return `<div style="margin-bottom:1.5rem;border:1px solid var(--gray-200);border-radius:var(--radius-sm);overflow:hidden;">
<div style="background:var(--gray-100);padding:.625rem 1rem;font-size:.8rem;font-weight:600;color:var(--dark);display:flex;align-items:center;gap:.5rem;cursor:pointer;" onclick="this.parentElement.querySelector('.sect-body').classList.toggle('collapsed')">
<i class="fas fa-puzzle-piece" style="color:var(--primary);"></i> ${section.label}
<i class="fas fa-chevron-down" style="margin-left:auto;font-size:.65rem;color:var(--gray-400);"></i>
</div>
<div class="sect-body" style="padding:1rem;">
${fields.length ? fields : '<p style="color:var(--gray-400);font-size:.8rem;">No editable fields for this section.</p>'}
</div>
</div>`;
}).join('');
document.getElementById('page-modal').classList.add('active');
} catch (err) {
console.error(err);
MSPE.showNotification('Error loading page', 'error');
}
}
function closePageModal() {
document.getElementById('page-modal').classList.remove('active');
currentPageData = null;
}
async function savePage() {
const slug = document.getElementById('page-slug').value;
if (!slug) return;
// Gather section field values
const sectionsData = {};
document.querySelectorAll('.section-field').forEach(el => {
const sKey = el.dataset.section;
const fName = el.dataset.field;
if (!sectionsData[sKey]) sectionsData[sKey] = {};
sectionsData[sKey][fName] = el.value;
});
const payload = {
slug,
meta_title: document.getElementById('page-meta-title').value,
meta_description: document.getElementById('page-meta-desc').value,
status: document.getElementById('page-status').value,
sections_data: sectionsData,
};
try {
const res = await MSPE.API.post('pages.php', payload);
if (res && res.success) {
MSPE.showNotification('Page saved!', 'success');
closePageModal();
loadPages();
} else {
MSPE.showNotification(res?.message || 'Save failed', 'error');
}
} catch (err) {
console.error(err);
MSPE.showNotification('Error saving page', 'error');
}
}
// ── Global Section Editor ────────────────────────────
function editGlobalSection(slug) {
const section = globalSections.find(s => s.slug === slug);
if (!section) return;
document.getElementById('global-modal-title').textContent = 'Edit: ' + section.label;
document.getElementById('global-slug').value = slug;
const container = document.getElementById('global-fields-editor');
container.innerHTML = section.fields.map(f => {
const inputId = 'gf-' + slug + '-' + f.name;
if (f.type === 'textarea') {
return `<div class="form-group" style="margin-bottom:.75rem;">
<label>${f.label}</label>
<textarea class="form-control global-field" id="${inputId}" data-field="${f.name}" rows="2" placeholder="${f.label}">${f.value || ''}</textarea>
</div>`;
}
return `<div class="form-group" style="margin-bottom:.75rem;">
<label>${f.label}</label>
<input type="text" class="form-control global-field" id="${inputId}" data-field="${f.name}" value="${(f.value || '').replace(/"/g, '&quot;')}" placeholder="${f.label}">
</div>`;
}).join('');
document.getElementById('global-modal').classList.add('active');
}
function closeGlobalModal() {
document.getElementById('global-modal').classList.remove('active');
}
async function saveGlobalSection() {
const slug = document.getElementById('global-slug').value;
if (!slug) return;
const fields = {};
document.querySelectorAll('.global-field').forEach(el => {
fields[el.dataset.field] = el.value;
});
try {
const res = await MSPE.API.post('pages.php?type=global', { section_slug: slug, fields });
if (res && res.success) {
MSPE.showNotification('Section saved!', 'success');
closeGlobalModal();
loadGlobalSections();
} else {
MSPE.showNotification(res?.message || 'Save failed', 'error');
}
} catch (err) {
console.error(err);
MSPE.showNotification('Error saving section', 'error');
}
}
</script>
</body>
</html>
+708
View File
@@ -0,0 +1,708 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow, noarchive">
<title>Portfolio Management - MSPE Admin</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha384-t1nt8BQoYMLFN5p42tRAtuAAFQaCQODekUVeKKZrEnEyp4H2R0RHFz0KWpmj7i8g" crossorigin="anonymous">
<link rel="stylesheet" href="/admin/css/admin.css">
</head>
<body>
<div class="admin-wrapper">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<a href="/admin/dashboard.html" class="sidebar-logo">
<i class="fas fa-cube"></i>
<span>MSPE</span>
</a>
<button class="sidebar-toggle" id="sidebar-toggle">
<i class="fas fa-bars"></i>
</button>
</div>
<nav class="sidebar-nav">
<div class="nav-section">
<span class="nav-section-title">Main</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/dashboard.html" class="nav-link">
<i class="fas fa-th-large"></i>
<span>Dashboard</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Content</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/pages.html" class="nav-link">
<i class="fas fa-file-alt"></i>
<span>Pages</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/services.html" class="nav-link">
<i class="fas fa-concierge-bell"></i>
<span>Services</span>
</a>
</li>
<li class="nav-item active">
<a href="/admin/portfolio.html" class="nav-link">
<i class="fas fa-briefcase"></i>
<span>Portfolio</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/news.html" class="nav-link">
<i class="fas fa-newspaper"></i>
<span>News & Events</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/testimonials.html" class="nav-link">
<i class="fas fa-quote-right"></i>
<span>Testimonials</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/team.html" class="nav-link">
<i class="fas fa-users"></i>
<span>Team</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Media</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/media.html" class="nav-link">
<i class="fas fa-images"></i>
<span>Media Library</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Inquiries</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/messages.html" class="nav-link">
<i class="fas fa-envelope"></i>
<span>Messages</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/subscribers.html" class="nav-link">
<i class="fas fa-bell"></i>
<span>Subscribers</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/bookings.html" class="nav-link">
<i class="fas fa-calendar-check"></i>
<span>Bookings</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Settings</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/settings.html" class="nav-link">
<i class="fas fa-cog"></i>
<span>Site Settings</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/users.html" class="nav-link">
<i class="fas fa-user-shield"></i>
<span>Admin Users</span>
</a>
</li>
</ul>
</div>
</nav>
<div class="sidebar-footer">
<a href="/index.html" class="view-site-btn" target="_blank">
<i class="fas fa-external-link-alt"></i>
<span>View Website</span>
</a>
</div>
</aside>
<!-- Main Content -->
<main class="main-content">
<!-- Header -->
<header class="admin-header">
<div class="header-left">
<button class="mobile-toggle" id="mobile-toggle">
<i class="fas fa-bars"></i>
</button>
<h1 class="page-title">Portfolio Management</h1>
</div>
<div class="header-right">
<div class="header-user">
<div class="user-avatar">
<img src="https://ui-avatars.com/api/?name=Admin&background=0ea5e9&color=fff" alt="Admin">
</div>
<div class="user-info">
<span class="user-name">Admin</span>
<span class="user-role">Administrator</span>
</div>
<div class="user-dropdown">
<button class="dropdown-toggle">
<i class="fas fa-chevron-down"></i>
</button>
<ul class="dropdown-menu">
<li><a href="/admin/settings.html"><i class="fas fa-cog"></i> Settings</a></li>
<li class="divider"></li>
<li><a href="#" id="logout-btn"><i class="fas fa-sign-out-alt"></i> Logout</a></li>
</ul>
</div>
</div>
</div>
</header>
<!-- Content -->
<div class="content-wrapper">
<!-- List View -->
<div id="list-view">
<div class="content-header">
<div class="content-filters">
<div class="filter-group">
<select id="category-filter">
<option value="">All Categories</option>
<option value="it-support">IT Support</option>
<option value="cybersecurity">Cybersecurity</option>
<option value="cloud">Cloud Services</option>
<option value="consulting">Consulting</option>
</select>
</div>
<div class="filter-group">
<select id="status-filter">
<option value="">All Status</option>
<option value="published">Published</option>
<option value="draft">Draft</option>
<option value="featured">Featured</option>
</select>
</div>
<div class="search-box">
<i class="fas fa-search"></i>
<input type="text" id="search-input" placeholder="Search projects...">
</div>
</div>
<button class="btn btn-primary" id="add-new-btn">
<i class="fas fa-plus"></i> Add Project
</button>
</div>
<div class="card">
<div class="portfolio-grid" id="portfolio-grid">
<div style="grid-column:1/-1;text-align:center;padding:3rem;color:var(--gray-400);">
<i class="fas fa-spinner fa-spin fa-2x"></i>
<p style="margin-top:1rem;">Loading projects...</p>
</div>
</div>
</div>
</div>
<!-- Editor View -->
<div id="editor-view" style="display: none;">
<div class="content-header">
<button class="btn btn-outline" id="back-to-list">
<i class="fas fa-arrow-left"></i> Back to List
</button>
<div class="editor-actions">
<button class="btn btn-outline" id="save-draft-btn">Save Draft</button>
<button class="btn btn-primary" id="publish-btn">Publish</button>
</div>
</div>
<div class="editor-grid">
<div class="editor-main">
<div class="card">
<div class="card-body">
<div class="form-group">
<label for="project-title">Project Title</label>
<input type="text" id="project-title" class="form-control" placeholder="Enter project title">
</div>
<div class="form-group">
<label for="project-description">Short Description</label>
<textarea id="project-description" class="form-control" rows="3" placeholder="Brief project description..."></textarea>
</div>
<div class="form-group">
<label for="project-content">Full Description</label>
<textarea id="project-content" class="form-control editor-textarea" rows="10" placeholder="Detailed project description..."></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label for="project-client">Client Name</label>
<input type="text" id="project-client" class="form-control" placeholder="Client company name">
</div>
<div class="form-group">
<label for="project-url">Project URL</label>
<input type="url" id="project-url" class="form-control" placeholder="https://...">
</div>
</div>
<div class="form-group">
<label>Technologies Used</label>
<input type="text" id="project-technologies" class="form-control" placeholder="Azure, AWS, Docker, Kubernetes...">
</div>
<div class="form-group">
<label>Project Results</label>
<div class="results-grid">
<div class="result-item">
<input type="text" id="result-metric-0" placeholder="Metric (e.g., 99.9%)" class="form-control">
<input type="text" id="result-label-0" placeholder="Label (e.g., Uptime)" class="form-control">
</div>
<div class="result-item">
<input type="text" id="result-metric-1" placeholder="Metric (e.g., 50%)" class="form-control">
<input type="text" id="result-label-1" placeholder="Label (e.g., Cost Reduction)" class="form-control">
</div>
<div class="result-item">
<input type="text" id="result-metric-2" placeholder="Metric" class="form-control">
<input type="text" id="result-label-2" placeholder="Label" class="form-control">
</div>
</div>
</div>
</div>
</div>
</div>
<div class="editor-sidebar">
<div class="card">
<div class="card-header">
<h3>Project Settings</h3>
</div>
<div class="card-body">
<div class="form-group">
<label for="project-category">Category</label>
<select id="project-category" class="form-control">
<option value="">Select category</option>
<option value="it-support">IT Support</option>
<option value="cybersecurity">Cybersecurity</option>
<option value="cloud">Cloud Services</option>
<option value="consulting">Consulting</option>
</select>
</div>
<div class="form-group">
<label for="project-status">Status</label>
<select id="project-status" class="form-control">
<option value="draft">Draft</option>
<option value="published">Published</option>
</select>
</div>
<div class="form-group">
<label class="checkbox-wrapper">
<input type="checkbox" id="project-featured">
<span>Featured Project</span>
</label>
</div>
<div class="form-group">
<label for="project-date">Completion Date</label>
<input type="date" id="project-date" class="form-control">
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3>Featured Image</h3>
</div>
<div class="card-body">
<div class="image-upload-area" id="image-upload">
<i class="fas fa-cloud-upload-alt"></i>
<p>Click or drag to upload</p>
<span>PNG, JPG up to 5MB</span>
</div>
<input type="file" id="image-input" accept="image/*" hidden>
</div>
</div>
<div class="card">
<div class="card-header">
<h3>Gallery Images</h3>
</div>
<div class="card-body">
<div class="gallery-upload-area" id="gallery-upload">
<i class="fas fa-images"></i>
<p>Add gallery images</p>
</div>
<input type="file" id="gallery-input" accept="image/*" multiple hidden>
<div class="gallery-preview" id="gallery-preview"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
<script src="/admin/js/admin.js"></script>
<script>
let allProjects = [];
let galleryFiles = []; // new File objects queued for upload
let existingGallery = []; // gallery URLs already saved in DB
document.addEventListener('DOMContentLoaded', function() {
loadProjects();
setupEventListeners();
// Check URL params
const urlParams = new URLSearchParams(window.location.search);
const editId = urlParams.get('edit');
const action = urlParams.get('action');
if (editId) {
editProject(editId);
} else if (action === 'add') {
showEditor();
}
});
function setupEventListeners() {
document.getElementById('add-new-btn').addEventListener('click', showEditor);
document.getElementById('back-to-list').addEventListener('click', showList);
document.getElementById('publish-btn').addEventListener('click', () => saveProject('published'));
document.getElementById('save-draft-btn').addEventListener('click', () => saveProject('draft'));
// Filters
document.getElementById('category-filter').addEventListener('change', filterProjects);
document.getElementById('status-filter').addEventListener('change', filterProjects);
document.getElementById('search-input').addEventListener('input', MSPE.debounce(filterProjects, 300));
// Image upload
const imageUpload = document.getElementById('image-upload');
const imageInput = document.getElementById('image-input');
imageUpload.addEventListener('click', () => imageInput.click());
imageInput.addEventListener('change', handleImageSelect);
// Gallery upload
const galleryUpload = document.getElementById('gallery-upload');
const galleryInput = document.getElementById('gallery-input');
galleryUpload.addEventListener('click', () => galleryInput.click());
galleryInput.addEventListener('change', handleGallerySelect);
}
async function loadProjects() {
try {
const response = await MSPE.API.get('portfolio.php');
if (response && response.success) {
allProjects = response.data;
renderProjects(allProjects);
}
} catch (error) {
console.error('Failed to load projects:', error);
document.getElementById('portfolio-grid').innerHTML = `
<div class="empty-state">
<i class="fas fa-exclamation-circle"></i>
<p>Failed to load projects</p>
</div>
`;
}
}
function renderProjects(projects) {
const container = document.getElementById('portfolio-grid');
if (projects.length === 0) {
container.innerHTML = `
<div class="empty-state">
<i class="fas fa-briefcase"></i>
<p>No projects found</p>
</div>
`;
return;
}
container.innerHTML = projects.map(project => `
<div class="portfolio-admin-card" data-id="${project.id}">
<div class="portfolio-admin-image">
<img src="${project.image || 'https://via.placeholder.com/400x300'}" alt="${project.title}">
<span class="portfolio-status ${project.status}">${project.status}</span>
${project.featured ? '<span class="portfolio-featured"><i class="fas fa-star"></i></span>' : ''}
</div>
<div class="portfolio-admin-content">
<h3>${project.title}</h3>
<p class="portfolio-category">${project.category}</p>
<p class="portfolio-client">Client: ${project.client || 'N/A'}</p>
<div class="portfolio-admin-actions">
<button class="btn btn-sm btn-outline" onclick="editProject('${project.id}')">
<i class="fas fa-edit"></i> Edit
</button>
<button class="btn btn-sm btn-outline btn-danger" onclick="deleteProject('${project.id}')">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</div>
`).join('');
}
function filterProjects() {
const category = document.getElementById('category-filter').value;
const status = document.getElementById('status-filter').value;
const search = document.getElementById('search-input').value.toLowerCase();
let filtered = allProjects;
if (category) filtered = filtered.filter(p => p.category === category);
if (status) {
if (status === 'featured') {
filtered = filtered.filter(p => p.featured);
} else {
filtered = filtered.filter(p => p.status === status);
}
}
if (search) {
filtered = filtered.filter(p =>
p.title.toLowerCase().includes(search) ||
(p.client && p.client.toLowerCase().includes(search))
);
}
renderProjects(filtered);
}
function showEditor() {
document.getElementById('list-view').style.display = 'none';
document.getElementById('editor-view').style.display = 'block';
resetForm();
}
function showList() {
document.getElementById('editor-view').style.display = 'none';
document.getElementById('list-view').style.display = 'block';
const url = new URL(window.location);
url.searchParams.delete('edit');
url.searchParams.delete('action');
window.history.pushState({}, '', url);
}
function resetForm() {
// Inputs
['project-title', 'project-description', 'project-content', 'project-client', 'project-url', 'project-technologies', 'project-date'].forEach(id => {
document.getElementById(id).value = '';
});
document.getElementById('project-category').value = '';
document.getElementById('project-status').value = 'draft';
document.getElementById('project-featured').checked = false;
// Image reset
const uploadArea = document.getElementById('image-upload');
uploadArea.innerHTML = `
<i class="fas fa-cloud-upload-alt"></i>
<p>Click or drag to upload</p>
<span>PNG, JPG up to 5MB</span>
`;
uploadArea.style.backgroundImage = '';
document.getElementById('image-input').value = '';
// Clear results grid
[0, 1, 2].forEach(i => {
document.getElementById(`result-metric-${i}`).value = '';
document.getElementById(`result-label-${i}`).value = '';
});
// Clear gallery
galleryFiles = [];
existingGallery = [];
document.getElementById('gallery-preview').innerHTML = '';
document.getElementById('gallery-input').value = '';
// Remove ID if any
delete document.getElementById('editor-view').dataset.id;
}
async function editProject(id) {
try {
const response = await MSPE.API.get(`portfolio.php?id=${id}`);
if (response && response.success) {
const project = response.data;
showEditor();
document.getElementById('editor-view').dataset.id = project.id;
document.getElementById('project-title').value = project.title;
document.getElementById('project-description').value = project.description || '';
document.getElementById('project-content').value = project.content || '';
document.getElementById('project-client').value = project.client || '';
document.getElementById('project-url').value = project.url || '';
document.getElementById('project-technologies').value = project.technologies || '';
document.getElementById('project-date').value = project.completion_date || '';
document.getElementById('project-category').value = project.category;
document.getElementById('project-status').value = project.status;
document.getElementById('project-featured').checked = project.featured == 1; // Loose comparison for string '1'
if (project.image) {
const uploadArea = document.getElementById('image-upload');
uploadArea.innerHTML = '';
uploadArea.style.backgroundImage = `url(${project.image})`;
uploadArea.style.backgroundSize = 'cover';
uploadArea.style.backgroundPosition = 'center';
}
// Restore results grid
const rawResults = project.results;
const results = Array.isArray(rawResults) ? rawResults :
(rawResults ? JSON.parse(rawResults) : []);
[0, 1, 2].forEach(i => {
const r = results[i] || {};
document.getElementById(`result-metric-${i}`).value = r.metric || '';
document.getElementById(`result-label-${i}`).value = r.label || '';
});
// Restore gallery
existingGallery = Array.isArray(project.gallery) ? [...project.gallery] : [];
galleryFiles = [];
const galleryPreview = document.getElementById('gallery-preview');
galleryPreview.innerHTML = existingGallery.map(url =>
`<div class="gallery-preview-item" data-url="${url}">
<img src="/${url}" alt="Gallery image">
<button type="button" class="gallery-remove-btn" onclick="removeGalleryItem(this)"><i class="fas fa-times"></i></button>
</div>`
).join('');
}
} catch (error) {
console.error('Failed to load project:', error);
MSPE.showNotification('Failed to load project details', 'error');
}
}
function handleImageSelect(e) {
if (this.files && this.files[0]) {
const reader = new FileReader();
reader.onload = function(e) {
const uploadArea = document.getElementById('image-upload');
uploadArea.innerHTML = '';
uploadArea.style.backgroundImage = `url(${e.target.result})`;
uploadArea.style.backgroundSize = 'cover';
uploadArea.style.backgroundPosition = 'center';
};
reader.readAsDataURL(this.files[0]);
}
}
function handleGallerySelect() {
const files = Array.from(this.files);
if (!files.length) return;
const preview = document.getElementById('gallery-preview');
files.forEach(file => {
const idx = galleryFiles.length;
galleryFiles.push(file);
const reader = new FileReader();
reader.onload = function(ev) {
const item = document.createElement('div');
item.className = 'gallery-preview-item';
item.dataset.galleryIdx = idx;
item.innerHTML = `<img src="${ev.target.result}" alt=""><button type="button" class="gallery-remove-btn" onclick="removeGalleryItem(this)"><i class="fas fa-times"></i></button>`;
preview.appendChild(item);
};
reader.readAsDataURL(file);
});
this.value = ''; // reset so same files can be re-selected
}
function removeGalleryItem(btn) {
const item = btn.closest('.gallery-preview-item');
const idx = parseInt(item.dataset.galleryIdx);
if (!isNaN(idx) && galleryFiles[idx]) galleryFiles[idx] = null;
const url = item.dataset.url;
if (url) existingGallery = existingGallery.filter(u => u !== url);
item.remove();
}
async function saveProject(status) {
const id = document.getElementById('editor-view').dataset.id;
const formData = new FormData();
if (id) formData.append('id', id);
formData.append('title', document.getElementById('project-title').value);
formData.append('description', document.getElementById('project-description').value);
formData.append('content', document.getElementById('project-content').value);
formData.append('client', document.getElementById('project-client').value);
formData.append('url', document.getElementById('project-url').value);
formData.append('technologies', document.getElementById('project-technologies').value);
formData.append('completion_date', document.getElementById('project-date').value);
formData.append('category', document.getElementById('project-category').value);
formData.append('status', status); // Use passed status
formData.append('featured', document.getElementById('project-featured').checked ? 1 : 0);
const imageInput = document.getElementById('image-input');
if (imageInput.files[0]) {
formData.append('image', imageInput.files[0]);
}
// Serialize results grid
const results = [0, 1, 2].map(i => ({
metric: document.getElementById(`result-metric-${i}`)?.value?.trim() || '',
label: document.getElementById(`result-label-${i}`)?.value?.trim() || ''
})).filter(r => r.metric || r.label);
formData.append('results', JSON.stringify(results));
// Gallery — new uploads + existing to keep
galleryFiles.forEach(file => { if (file) formData.append('gallery_images[]', file); });
formData.append('gallery_keep', JSON.stringify(existingGallery));
try {
const result = await MSPE.API.upload('portfolio.php', formData);
if (result && result.success) {
MSPE.showNotification('Project saved successfully', 'success');
loadProjects();
showList();
} else {
MSPE.showNotification(result?.message || 'Failed to save project', 'error');
}
} catch (error) {
console.error('Save error:', error);
MSPE.showNotification('An error occurred while saving', 'error');
}
}
async function deleteProject(id) {
if (!await MSPE.confirmDialog('Are you sure you want to delete this project?')) {
return;
}
try {
const response = await MSPE.API.delete(`portfolio.php?id=${id}`);
if (response && response.success) {
MSPE.showNotification('Project deleted successfully', 'success');
loadProjects();
}
} catch (error) {
console.error('Delete error:', error);
MSPE.showNotification('Failed to delete project', 'error');
}
}
</script>
</body>
</html>
+147
View File
@@ -0,0 +1,147 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow, noarchive">
<title>Reset Password - MSPE Admin</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha384-t1nt8BQoYMLFN5p42tRAtuAAFQaCQODekUVeKKZrEnEyp4H2R0RHFz0KWpmj7i8g" crossorigin="anonymous">
<link rel="stylesheet" href="/admin/css/admin.css">
</head>
<body class="login-page">
<div class="login-container">
<div class="login-card" style="max-width: 520px;">
<div class="login-header">
<div class="login-logo">
<i class="fas fa-key"></i>
<span>MSPE</span>
</div>
<h1 id="page-title">Reset Password</h1>
<p id="page-subtitle">Request a secure password reset link.</p>
</div>
<form id="request-form" class="login-form">
<div class="form-group">
<label for="identity">Email or Username</label>
<div class="input-icon">
<i class="fas fa-user"></i>
<input type="text" id="identity" required placeholder="admin@company.com or username">
</div>
</div>
<button type="submit" class="btn btn-primary btn-block">
<span>Send Reset Link</span>
<i class="fas fa-paper-plane"></i>
</button>
</form>
<form id="reset-form" class="login-form" style="display:none;">
<div class="form-group">
<label for="new-password">New Password</label>
<div class="input-icon">
<i class="fas fa-lock"></i>
<input type="password" id="new-password" required minlength="8" placeholder="Minimum 8 characters">
</div>
</div>
<div class="form-group">
<label for="confirm-password">Confirm New Password</label>
<div class="input-icon">
<i class="fas fa-lock"></i>
<input type="password" id="confirm-password" required minlength="8" placeholder="Repeat password">
</div>
</div>
<button type="submit" class="btn btn-primary btn-block">
<span>Update Password</span>
<i class="fas fa-check"></i>
</button>
</form>
<div class="login-message" id="reset-message" style="display:none;"></div>
<div class="login-footer">
<p><a href="/admin/index.html">Back to login</a></p>
</div>
</div>
</div>
<script>
const params = new URLSearchParams(window.location.search);
const token = params.get('token');
const requestForm = document.getElementById('request-form');
const resetForm = document.getElementById('reset-form');
const message = document.getElementById('reset-message');
if (token) {
document.getElementById('page-title').textContent = 'Set New Password';
document.getElementById('page-subtitle').textContent = 'Enter your new password below.';
requestForm.style.display = 'none';
resetForm.style.display = 'block';
}
function showMessage(text, type = 'success') {
message.className = 'login-message ' + type;
message.innerHTML = `<i class="fas fa-${type === 'success' ? 'check-circle' : 'exclamation-circle'}"></i> ${text}`;
message.style.display = 'block';
}
requestForm.addEventListener('submit', async function(e) {
e.preventDefault();
const identity = document.getElementById('identity').value.trim();
try {
const res = await fetch('/api/auth.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'request_password_reset', identity })
});
const data = await res.json();
if (data.success) {
showMessage('If the account exists, a reset email has been sent.', 'success');
} else {
showMessage(data.message || 'Failed to request reset.', 'error');
}
} catch (err) {
showMessage('Network error. Please try again.', 'error');
}
});
resetForm.addEventListener('submit', async function(e) {
e.preventDefault();
const newPassword = document.getElementById('new-password').value;
const confirmPassword = document.getElementById('confirm-password').value;
if (newPassword !== confirmPassword) {
showMessage('Passwords do not match.', 'error');
return;
}
try {
const res = await fetch('/api/auth.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'reset_password',
token,
new_password: newPassword
})
});
const data = await res.json();
if (data.success) {
showMessage('Password updated. Redirecting to login...', 'success');
setTimeout(() => {
window.location.href = '/admin/index.html';
}, 1200);
} else {
showMessage(data.message || 'Could not reset password.', 'error');
}
} catch (err) {
showMessage('Network error. Please try again.', 'error');
}
});
</script>
</body>
</html>
+411
View File
@@ -0,0 +1,411 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow, noarchive">
<title>Services Management - MSPE Admin</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha384-t1nt8BQoYMLFN5p42tRAtuAAFQaCQODekUVeKKZrEnEyp4H2R0RHFz0KWpmj7i8g" crossorigin="anonymous">
<link rel="stylesheet" href="/admin/css/admin.css">
</head>
<body>
<div class="admin-wrapper">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<a href="/admin/dashboard.html" class="sidebar-logo">
<i class="fas fa-cube"></i>
<span>MSPE</span>
</a>
<button class="sidebar-toggle" id="sidebar-toggle">
<i class="fas fa-bars"></i>
</button>
</div>
<nav class="sidebar-nav">
<div class="nav-section">
<span class="nav-section-title">Main</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/dashboard.html" class="nav-link">
<i class="fas fa-th-large"></i>
<span>Dashboard</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Content</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/pages.html" class="nav-link">
<i class="fas fa-file-alt"></i>
<span>Pages</span>
</a>
</li>
<li class="nav-item active">
<a href="/admin/services.html" class="nav-link">
<i class="fas fa-concierge-bell"></i>
<span>Services</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/portfolio.html" class="nav-link">
<i class="fas fa-briefcase"></i>
<span>Portfolio</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/news.html" class="nav-link">
<i class="fas fa-newspaper"></i>
<span>News & Events</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/testimonials.html" class="nav-link">
<i class="fas fa-quote-right"></i>
<span>Testimonials</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/team.html" class="nav-link">
<i class="fas fa-users"></i>
<span>Team</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Media</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/media.html" class="nav-link">
<i class="fas fa-images"></i>
<span>Media Library</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Inquiries</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/messages.html" class="nav-link">
<i class="fas fa-envelope"></i>
<span>Messages</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/subscribers.html" class="nav-link">
<i class="fas fa-bell"></i>
<span>Subscribers</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/bookings.html" class="nav-link">
<i class="fas fa-calendar-check"></i>
<span>Bookings</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Settings</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/settings.html" class="nav-link">
<i class="fas fa-cog"></i>
<span>Site Settings</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/users.html" class="nav-link">
<i class="fas fa-user-shield"></i>
<span>Admin Users</span>
</a>
</li>
</ul>
</div>
</nav>
<div class="sidebar-footer">
<a href="/index.html" class="view-site-btn" target="_blank">
<i class="fas fa-external-link-alt"></i>
<span>View Website</span>
</a>
</div>
</aside>
<!-- Main Content -->
<main class="main-content">
<!-- Header -->
<header class="admin-header">
<div class="header-left">
<button class="mobile-toggle" id="mobile-toggle">
<i class="fas fa-bars"></i>
</button>
<h1 class="page-title">Services Management</h1>
</div>
<div class="header-right">
<div class="header-user">
<div class="user-avatar">
<img src="https://ui-avatars.com/api/?name=Admin&background=0ea5e9&color=fff" alt="Admin">
</div>
<div class="user-info">
<span class="user-name">Admin</span>
<span class="user-role">Administrator</span>
</div>
<div class="user-dropdown">
<button class="dropdown-toggle">
<i class="fas fa-chevron-down"></i>
</button>
<ul class="dropdown-menu">
<li><a href="/admin/settings.html"><i class="fas fa-cog"></i> Settings</a></li>
<li class="divider"></li>
<li><a href="#" id="logout-btn"><i class="fas fa-sign-out-alt"></i> Logout</a></li>
</ul>
</div>
</div>
</div>
</header>
<!-- Content -->
<div class="content-wrapper">
<div class="content-header">
<p class="content-description">Manage your service offerings displayed on the website</p>
<button class="btn btn-primary" id="add-service-btn">
<i class="fas fa-plus"></i> Add Service
</button>
</div>
<div class="services-admin-grid" id="services-admin-grid">
<div style="grid-column:1/-1;text-align:center;padding:3rem;color:var(--gray-400);">
<i class="fas fa-spinner fa-spin fa-2x"></i>
<p style="margin-top:1rem;">Loading services...</p>
</div>
</div>
</div>
</main>
</div>
<!-- Service Edit Modal -->
<div class="modal" id="service-modal">
<div class="modal-overlay"></div>
<div class="modal-content modal-lg">
<div class="modal-header">
<h2>Edit Service</h2>
<button class="modal-close" onclick="closeModal()">
<i class="fas fa-times"></i>
</button>
</div>
<div class="modal-body">
<form id="service-form">
<div class="form-row">
<div class="form-group">
<label>Service Name</label>
<input type="text" class="form-control" id="service-name" placeholder="Service name">
</div>
<div class="form-group">
<label>Icon</label>
<select class="form-control" id="service-icon">
<option value="fa-laptop-code">Laptop Code</option>
<option value="fa-shield-alt">Shield</option>
<option value="fa-cloud">Cloud</option>
<option value="fa-lightbulb">Lightbulb</option>
<option value="fa-server">Server</option>
<option value="fa-database">Database</option>
</select>
</div>
</div>
<div class="form-group">
<label>Short Description</label>
<textarea class="form-control" id="service-description" rows="3" placeholder="Brief service description..."></textarea>
</div>
<div class="form-group">
<label>Features (one per line)</label>
<textarea class="form-control" id="service-features" rows="6" placeholder="24/7 Remote Support
On-Site Support
Proactive Monitoring
Network Management"></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label>Starting Price</label>
<input type="text" class="form-control" id="service-price" placeholder="$150/month or Custom">
</div>
<div class="form-group">
<label>CTA Button Text</label>
<input type="text" class="form-control" id="service-cta" placeholder="Contact Us">
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-outline" onclick="closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="saveService()">Save Changes</button>
</div>
</div>
</div>
<script src="/admin/js/admin.js"></script>
<script>
let allServices = [];
document.addEventListener('DOMContentLoaded', function() {
loadServices();
document.getElementById('add-service-btn').addEventListener('click', openAddServiceModal);
});
async function loadServices() {
try {
const response = await MSPE.API.get('services.php');
if (response && response.success) {
allServices = response.data;
renderServices(allServices);
}
} catch (error) {
console.error('Error loading services:', error);
}
}
function renderServices(services) {
const container = document.querySelector('#services-admin-grid');
if (services.length === 0) {
container.innerHTML = `
<div class="empty-state">
<i class="fas fa-concierge-bell"></i>
<p>No services found</p>
</div>
`;
return;
}
container.innerHTML = services.map(service => `
<div class="service-admin-card ${service.active ? '' : 'opacity-50'}">
<div class="service-admin-header">
<div class="service-icon" style="background: var(--primary); color: white;">
<i class="fas ${service.icon || 'fa-cogs'}"></i>
</div>
<div class="service-actions">
<button class="btn btn-sm btn-icon" onclick="editService('${service.id}')">
<i class="fas fa-edit"></i>
</button>
<label class="toggle-switch">
<input type="checkbox" ${service.active ? 'checked' : ''} onchange="toggleService('${service.id}', this.checked)">
<span class="toggle-slider"></span>
</label>
<button class="btn btn-sm btn-icon text-danger" onclick="deleteService('${service.id}')">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
<h3>${service.name}</h3>
<p>${service.description || ''}</p>
${service.features ? `<ul class="service-features-list">${service.features.split('\n').filter(f => f.trim()).map(f => `<li><i class="fas fa-check"></i> ${f.trim()}</li>`).join('')}</ul>` : ''}
<div class="service-meta">
<span><i class="fas fa-dollar-sign"></i> ${service.price || 'Custom'}</span>
</div>
</div>
`).join('');
}
function openAddServiceModal() {
const modal = document.getElementById('service-modal');
const form = document.getElementById('service-form');
form.reset();
form.dataset.id = '';
modal.querySelector('.modal-header h2').textContent = 'Add Service';
modal.classList.add('active');
}
function closeModal() {
document.getElementById('service-modal').classList.remove('active');
}
function editService(id) {
const service = allServices.find(s => s.id === id);
if (!service) return;
const modal = document.getElementById('service-modal');
const form = document.getElementById('service-form');
form.dataset.id = id;
document.getElementById('service-name').value = service.name;
document.getElementById('service-icon').value = service.icon || 'fa-laptop-code';
document.getElementById('service-description').value = service.description || '';
document.getElementById('service-features').value = service.features || '';
document.getElementById('service-price').value = service.price || '';
document.getElementById('service-cta').value = service.cta_text || '';
modal.querySelector('.modal-header h2').textContent = 'Edit Service';
modal.classList.add('active');
}
async function saveService() {
const form = document.getElementById('service-form');
const id = form.dataset.id;
const formData = new FormData();
if (id) formData.append('id', id);
formData.append('name', document.getElementById('service-name').value);
formData.append('icon', document.getElementById('service-icon').value);
formData.append('description', document.getElementById('service-description').value);
formData.append('features', document.getElementById('service-features').value);
formData.append('price', document.getElementById('service-price').value);
formData.append('cta_text', document.getElementById('service-cta').value);
try {
const result = await MSPE.API.upload('services.php', formData);
if (result && result.success) {
MSPE.showNotification('Service saved successfully', 'success');
closeModal();
loadServices();
} else {
MSPE.showNotification(result?.message || 'Error saving service', 'error');
}
} catch (error) {
MSPE.showNotification('Error saving service', 'error');
}
}
async function toggleService(id, active) {
const formData = new FormData();
formData.append('id', id);
formData.append('active', active);
try {
await MSPE.API.upload('services.php', formData);
MSPE.showNotification(`Service ${active ? 'activated' : 'deactivated'}`, 'success');
} catch (error) {
MSPE.showNotification('Error updating status', 'error');
loadServices();
}
}
async function deleteService(id) {
if (!await MSPE.confirmDialog('Delete this service?')) return;
try {
const response = await MSPE.API.delete(`services.php?id=${id}`);
if (response.success) {
MSPE.showNotification('Service deleted', 'success');
loadServices();
}
} catch (error) {
MSPE.showNotification('Error deleting service', 'error');
}
}
</script>
</body>
</html>
+910
View File
@@ -0,0 +1,910 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow, noarchive">
<title>Site Settings - MSPE Admin</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha384-t1nt8BQoYMLFN5p42tRAtuAAFQaCQODekUVeKKZrEnEyp4H2R0RHFz0KWpmj7i8g" crossorigin="anonymous">
<link rel="stylesheet" href="/admin/css/admin.css">
</head>
<body>
<div class="admin-wrapper">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<a href="/admin/dashboard.html" class="sidebar-logo">
<i class="fas fa-cube"></i>
<span>MSPE</span>
</a>
<button class="sidebar-toggle" id="sidebar-toggle">
<i class="fas fa-bars"></i>
</button>
</div>
<nav class="sidebar-nav">
<div class="nav-section">
<span class="nav-section-title">Main</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/dashboard.html" class="nav-link">
<i class="fas fa-th-large"></i>
<span>Dashboard</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Content</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/pages.html" class="nav-link">
<i class="fas fa-file-alt"></i>
<span>Pages</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/services.html" class="nav-link">
<i class="fas fa-concierge-bell"></i>
<span>Services</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/portfolio.html" class="nav-link">
<i class="fas fa-briefcase"></i>
<span>Portfolio</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/news.html" class="nav-link">
<i class="fas fa-newspaper"></i>
<span>News & Events</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/testimonials.html" class="nav-link">
<i class="fas fa-quote-right"></i>
<span>Testimonials</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/team.html" class="nav-link">
<i class="fas fa-users"></i>
<span>Team</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Media</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/media.html" class="nav-link">
<i class="fas fa-images"></i>
<span>Media Library</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Inquiries</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/messages.html" class="nav-link">
<i class="fas fa-envelope"></i>
<span>Messages</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/subscribers.html" class="nav-link">
<i class="fas fa-bell"></i>
<span>Subscribers</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/bookings.html" class="nav-link">
<i class="fas fa-calendar-check"></i>
<span>Bookings</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Settings</span>
<ul class="nav-menu">
<li class="nav-item active">
<a href="/admin/settings.html" class="nav-link">
<i class="fas fa-cog"></i>
<span>Site Settings</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/users.html" class="nav-link">
<i class="fas fa-user-shield"></i>
<span>Admin Users</span>
</a>
</li>
</ul>
</div>
</nav>
<div class="sidebar-footer">
<a href="/index.html" class="view-site-btn" target="_blank">
<i class="fas fa-external-link-alt"></i>
<span>View Website</span>
</a>
</div>
</aside>
<!-- Main Content -->
<main class="main-content">
<!-- Header -->
<header class="admin-header">
<div class="header-left">
<button class="mobile-toggle" id="mobile-toggle">
<i class="fas fa-bars"></i>
</button>
<h1 class="page-title">Site Settings</h1>
</div>
<div class="header-right">
<div class="header-user">
<div class="user-avatar">
<img src="https://ui-avatars.com/api/?name=Admin&background=0ea5e9&color=fff" alt="Admin">
</div>
<div class="user-info">
<span class="user-name">Admin</span>
<span class="user-role">Administrator</span>
</div>
<div class="user-dropdown">
<button class="dropdown-toggle">
<i class="fas fa-chevron-down"></i>
</button>
<ul class="dropdown-menu">
<li><a href="/admin/settings.html"><i class="fas fa-cog"></i> Settings</a></li>
<li class="divider"></li>
<li><a href="#" id="logout-btn"><i class="fas fa-sign-out-alt"></i> Logout</a></li>
</ul>
</div>
</div>
</div>
</header>
<!-- Content -->
<div class="content-wrapper">
<!-- Settings Tabs -->
<div class="settings-tabs">
<button class="tab-btn active" data-tab="general">
<i class="fas fa-globe"></i> General
</button>
<button class="tab-btn" data-tab="contact">
<i class="fas fa-address-card"></i> Contact Info
</button>
<button class="tab-btn" data-tab="social">
<i class="fas fa-share-alt"></i> Social Media
</button>
<button class="tab-btn" data-tab="seo">
<i class="fas fa-search"></i> SEO
</button>
<button class="tab-btn" data-tab="appearance">
<i class="fas fa-palette"></i> Appearance
</button>
<button class="tab-btn" data-tab="email-auth">
<i class="fas fa-envelope-open-text"></i> Email & Auth
</button>
<button class="tab-btn" data-tab="integrations">
<i class="fas fa-plug"></i> Integrations
</button>
</div>
<!-- General Settings -->
<div class="tab-content active" id="general">
<div class="card">
<div class="card-header">
<h3>General Settings</h3>
</div>
<div class="card-body">
<div class="form-group">
<label>Site Name</label>
<input type="text" class="form-control" value="MSPE" id="site-name">
</div>
<div class="form-group">
<label>Site Tagline</label>
<input type="text" class="form-control" value="Architects of Digital Resilience" id="site-tagline">
</div>
<div class="form-group">
<label>Site Description</label>
<textarea class="form-control" rows="3" id="site-description">MSPE transforms complexity into competitive advantage through innovative technology solutions and uncompromising security.</textarea>
</div>
<div class="form-row">
<div class="form-group">
<label>Site Logo</label>
<div class="logo-upload">
<img src="/images/logo/logo.png" alt="Logo" class="current-logo">
<input type="file" id="logo-input" accept="image/*" style="display:none">
<button class="btn btn-outline btn-sm" onclick="document.getElementById('logo-input').click()">Change Logo</button>
</div>
</div>
<div class="form-group">
<label>Favicon</label>
<div class="logo-upload">
<img src="/images/logo/logo.png" alt="Favicon" class="current-favicon" onerror="this.style.display='none'">
<input type="file" id="favicon-input" accept="image/*,.ico" style="display:none">
<button class="btn btn-outline btn-sm" onclick="document.getElementById('favicon-input').click()">Upload Favicon</button>
</div>
</div>
</div>
<div class="form-group">
<label>Timezone</label>
<select class="form-control" id="timezone">
<option value="UTC">UTC</option>
<option value="America/New_York">Eastern Time (US)</option>
<option value="America/Chicago">Central Time (US)</option>
<option value="America/Denver">Mountain Time (US)</option>
<option value="America/Los_Angeles">Pacific Time (US)</option>
<option value="Europe/London">London</option>
<option value="Europe/Paris">Paris</option>
</select>
</div>
<button class="btn btn-primary" type="button">
<i class="fas fa-save"></i> Save Changes
</button>
</div>
</div>
</div>
<!-- Contact Info -->
<div class="tab-content" id="contact">
<div class="card">
<div class="card-header">
<h3>Contact Information</h3>
</div>
<div class="card-body">
<div class="form-row">
<div class="form-group">
<label>Email Address</label>
<input type="email" class="form-control" value="info@mspe.pro" id="contact-email">
</div>
<div class="form-group">
<label>Phone Number</label>
<input type="tel" class="form-control" value="" id="contact-phone" placeholder="+1 (555) 000-0000">
</div>
</div>
<div class="form-group">
<label>Address</label>
<textarea class="form-control" rows="2" id="contact-address" placeholder="Enter your business address"></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label>Business Hours</label>
<input type="text" class="form-control" value="Mon - Fri: 9AM - 6PM" id="business-hours">
</div>
<div class="form-group">
<label>Support Hours</label>
<input type="text" class="form-control" value="Mon - Fri: 9AM - 6PM (On-call by arrangement)" id="support-hours">
</div>
</div>
<div class="form-group">
<label>Google Maps Embed URL</label>
<input type="url" class="form-control" id="maps-url" placeholder="https://www.google.com/maps/embed?...">
</div>
<button class="btn btn-primary" type="button">
<i class="fas fa-save"></i> Save Changes
</button>
</div>
</div>
</div>
<!-- Social Media -->
<div class="tab-content" id="social">
<div class="card">
<div class="card-header">
<h3>Social Media Links</h3>
</div>
<div class="card-body">
<div class="form-group social-input">
<label><i class="fab fa-facebook"></i> Facebook</label>
<input type="url" class="form-control" id="social-facebook" placeholder="https://facebook.com/yourpage">
</div>
<div class="form-group social-input">
<label><i class="fab fa-linkedin"></i> LinkedIn</label>
<input type="url" class="form-control" id="social-linkedin" placeholder="https://linkedin.com/company/yourcompany">
</div>
<div class="form-group social-input">
<label><i class="fab fa-twitter"></i> Twitter / X</label>
<input type="url" class="form-control" id="social-twitter" placeholder="https://twitter.com/yourhandle">
</div>
<div class="form-group social-input">
<label><i class="fab fa-instagram"></i> Instagram</label>
<input type="url" class="form-control" id="social-instagram" placeholder="https://instagram.com/yourprofile">
</div>
<div class="form-group social-input">
<label><i class="fab fa-youtube"></i> YouTube</label>
<input type="url" class="form-control" id="social-youtube" placeholder="https://youtube.com/@yourchannel">
</div>
<div class="form-group social-input">
<label><i class="fab fa-github"></i> GitHub</label>
<input type="url" class="form-control" id="social-github" placeholder="https://github.com/yourorg">
</div>
<button class="btn btn-primary" type="button">
<i class="fas fa-save"></i> Save Changes
</button>
</div>
</div>
</div>
<!-- SEO Settings -->
<div class="tab-content" id="seo">
<div class="card">
<div class="card-header">
<h3>SEO Settings</h3>
</div>
<div class="card-body">
<div class="form-group">
<label>Default Meta Title</label>
<input type="text" class="form-control" value="MSPE - Architects of Digital Resilience" id="meta-title">
<span class="form-hint">Recommended: 50-60 characters</span>
</div>
<div class="form-group">
<label>Default Meta Description</label>
<textarea class="form-control" rows="3" id="meta-description">MSPE delivers enterprise-grade IT support, cybersecurity, and cloud solutions. Transform complexity into competitive advantage.</textarea>
<span class="form-hint">Recommended: 150-160 characters</span>
</div>
<div class="form-group">
<label>Meta Keywords</label>
<input type="text" class="form-control" id="meta-keywords" placeholder="IT support, cybersecurity, cloud services, managed IT">
</div>
<div class="form-group">
<label>Google Analytics ID</label>
<input type="text" class="form-control" id="ga-id" placeholder="G-XXXXXXXXXX">
</div>
<div class="form-group">
<label>Google Tag Manager ID</label>
<input type="text" class="form-control" id="gtm-id" placeholder="GTM-XXXXXXX">
</div>
<div class="form-group">
<label>Robots.txt Content</label>
<textarea class="form-control code-textarea" rows="5" id="robots-txt">User-agent: *
Allow: /
Sitemap: https://mspe.pro/sitemap.xml</textarea>
</div>
<button class="btn btn-primary" type="button">
<i class="fas fa-save"></i> Save Changes
</button>
</div>
</div>
</div>
<!-- Appearance -->
<div class="tab-content" id="appearance">
<div class="card">
<div class="card-header">
<h3>Theme & Colors</h3>
</div>
<div class="card-body">
<div class="form-row">
<div class="form-group">
<label>Primary Color</label>
<div class="color-picker">
<input type="color" value="#0ea5e9" id="primary-color">
<input type="text" class="form-control" value="#0ea5e9">
</div>
</div>
<div class="form-group">
<label>Secondary Color</label>
<div class="color-picker">
<input type="color" value="#0d9488" id="secondary-color">
<input type="text" class="form-control" value="#0d9488">
</div>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Accent Color</label>
<div class="color-picker">
<input type="color" value="#f59e0b" id="accent-color">
<input type="text" class="form-control" value="#f59e0b">
</div>
</div>
<div class="form-group">
<label>Dark Background</label>
<div class="color-picker">
<input type="color" value="#0a1628" id="dark-bg-color">
<input type="text" class="form-control" value="#0a1628">
</div>
</div>
</div>
<div class="form-group">
<label>Font Family</label>
<select class="form-control" id="font-family">
<option value="DM Sans">DM Sans</option>
<option value="Inter">Inter</option>
<option value="Poppins">Poppins</option>
<option value="Roboto">Roboto</option>
<option value="Open Sans">Open Sans</option>
</select>
</div>
<button class="btn btn-primary" type="button">
<i class="fas fa-save"></i> Save Changes
</button>
</div>
</div>
</div>
<!-- Email & Auth -->
<div class="tab-content" id="email-auth">
<div class="card">
<div class="card-header">
<h3>Email Delivery & Password Reset</h3>
</div>
<div class="card-body">
<div class="form-row">
<div class="form-group">
<label>Email Transport</label>
<select class="form-control" id="email-transport">
<option value="mail">PHP mail()</option>
<option value="smtp">SMTP (recommended)</option>
</select>
</div>
<div class="form-group">
<label>Admin Notification Email</label>
<input type="email" class="form-control" id="admin-email" placeholder="alerts@yourdomain.com">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Sender Name</label>
<input type="text" class="form-control" id="smtp-from-name" placeholder="MSPE">
</div>
<div class="form-group">
<label>Sender Email</label>
<input type="email" class="form-control" id="smtp-from-email" placeholder="no-reply@yourdomain.com">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>SMTP Host</label>
<input type="text" class="form-control" id="smtp-host" placeholder="smtp.hostinger.com">
</div>
<div class="form-group">
<label>SMTP Port</label>
<input type="number" class="form-control" id="smtp-port" placeholder="465 or 587">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Encryption</label>
<select class="form-control" id="smtp-encryption">
<option value="tls">TLS</option>
<option value="ssl">SSL</option>
<option value="none">None</option>
</select>
</div>
<div class="form-group">
<label>SMTP Username</label>
<input type="text" class="form-control" id="smtp-username" placeholder="mailbox@yourdomain.com">
</div>
</div>
<div class="form-group">
<label>SMTP Password</label>
<input type="password" class="form-control" id="smtp-password" placeholder="Mailbox password">
<span class="form-hint">Stored in site settings. Use a dedicated mailbox/app password.</span>
</div>
<div class="form-group">
<label>Password Reset Page URL</label>
<input type="url" class="form-control" id="password-reset-url" placeholder="https://yourdomain.com/admin/reset-password.html">
<span class="form-hint">Reset tokens will be appended as ?token=...</span>
</div>
<div class="form-row">
<div class="form-group">
<label>Test Recipient</label>
<input type="email" class="form-control" id="test-email-to" placeholder="you@yourdomain.com">
</div>
<div class="form-group" style="display:flex;align-items:flex-end;">
<button class="btn btn-outline" id="send-test-email-btn" type="button" style="width:100%;">
<i class="fas fa-paper-plane"></i> Send Test Email
</button>
</div>
</div>
<button class="btn btn-primary" type="button">
<i class="fas fa-save"></i> Save Changes
</button>
</div>
</div>
</div>
<!-- Integrations -->
<div class="tab-content" id="integrations">
<div class="card">
<div class="card-header">
<h3>Third-Party Integrations</h3>
</div>
<div class="card-body">
<div class="integration-item">
<div class="integration-info">
<i class="fab fa-google" style="color: #4285f4;"></i>
<div>
<h4>Google reCAPTCHA</h4>
<p>Protect forms from spam</p>
</div>
</div>
<label class="toggle-switch">
<input type="checkbox" id="integration-recaptcha">
<span class="toggle-slider"></span>
</label>
</div>
<div class="integration-item">
<div class="integration-info">
<i class="fab fa-mailchimp" style="color: #ffe01b;"></i>
<div>
<h4>Mailchimp</h4>
<p>Email marketing integration</p>
</div>
</div>
<label class="toggle-switch">
<input type="checkbox" id="integration-mailchimp">
<span class="toggle-slider"></span>
</label>
</div>
<div class="integration-item">
<div class="integration-info">
<i class="fab fa-slack" style="color: #4a154b;"></i>
<div>
<h4>Slack Notifications</h4>
<p>Get notified of new messages</p>
</div>
</div>
<label class="toggle-switch">
<input type="checkbox" id="integration-slack">
<span class="toggle-slider"></span>
</label>
</div>
<div class="integration-item">
<div class="integration-info">
<i class="fas fa-calendar-alt" style="color: #0ea5e9;"></i>
<div>
<h4>Calendly</h4>
<p>Booking integration</p>
</div>
</div>
<label class="toggle-switch">
<input type="checkbox" id="integration-calendly">
<span class="toggle-slider"></span>
</label>
</div>
<div class="integration-settings" style="margin-top: 2rem; padding-top: 2rem; border-top: 1px solid var(--gray-200);">
<h4>Calendly Settings</h4>
<div class="form-group">
<label>Calendly URL</label>
<input type="url" class="form-control" id="calendly-url" placeholder="https://calendly.com/your-link">
</div>
</div>
<button class="btn btn-primary" type="button" style="margin-top: 1rem;">
<i class="fas fa-save"></i> Save Changes
</button>
</div>
</div>
</div>
</div>
</main>
</div>
<script src="/admin/js/admin.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
loadSettings();
setupTabs();
setupColorPickers();
setupSaveButtons();
setupEmailTest();
});
function setupTabs() {
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', function() {
const tabId = this.dataset.tab;
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
this.classList.add('active');
document.getElementById(tabId).classList.add('active');
});
});
}
function setupColorPickers() {
document.querySelectorAll('.color-picker input[type="color"]').forEach(picker => {
picker.addEventListener('input', function() {
this.nextElementSibling.value = this.value;
});
});
document.querySelectorAll('.color-picker input[type="text"]').forEach(input => {
input.addEventListener('input', function() {
this.previousElementSibling.value = this.value;
});
});
}
function setupSaveButtons() {
document.querySelectorAll('.tab-content .btn-primary[type="button"]').forEach(btn => {
if (btn.id !== 'send-test-email-btn') {
btn.addEventListener('click', saveSettings);
}
});
}
function setupEmailTest() {
const btn = document.getElementById('send-test-email-btn');
if (!btn) return;
btn.addEventListener('click', async function() {
const to = document.getElementById('test-email-to').value || document.getElementById('admin-email').value || document.getElementById('contact-email').value;
if (!to) {
MSPE.showNotification('Please enter a test recipient email', 'error');
return;
}
const original = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Sending...';
try {
const response = await MSPE.API.post('email-test.php', { to });
if (response.success) {
MSPE.showNotification(response.message || 'Test email sent', 'success');
} else {
MSPE.showNotification(response.message || 'Test email failed', 'error');
}
} catch (error) {
MSPE.showNotification('Failed to send test email', 'error');
} finally {
btn.disabled = false;
btn.innerHTML = original;
}
});
}
async function loadSettings() {
try {
const response = await MSPE.API.get('settings.php');
if (response && response.success) {
const settings = response.data;
// General
setVal('site-name', settings.site_name);
setVal('site-tagline', settings.site_tagline);
setVal('site-description', settings.site_description);
setVal('timezone', settings.timezone);
// Contact
setVal('contact-email', settings.contact_email);
setVal('contact-phone', settings.contact_phone);
setVal('contact-address', settings.contact_address);
setVal('business-hours', settings.business_hours);
setVal('support-hours', settings.support_hours);
setVal('maps-url', settings.maps_url);
// Email & Auth
setVal('email-transport', settings.email_transport || 'mail');
setVal('admin-email', settings.admin_email || settings.contact_email);
setVal('smtp-from-name', settings.smtp_from_name || settings.site_name || 'MSPE');
setVal('smtp-from-email', settings.smtp_from_email || settings.contact_email);
setVal('smtp-host', settings.smtp_host);
setVal('smtp-port', settings.smtp_port);
setVal('smtp-encryption', settings.smtp_encryption || 'tls');
setVal('smtp-username', settings.smtp_username);
setVal('smtp-password', settings.smtp_password);
setVal('password-reset-url', settings.password_reset_url);
setVal('test-email-to', settings.admin_email || settings.contact_email);
// SEO
setVal('meta-title', settings.meta_title);
setVal('meta-description', settings.meta_description);
setVal('meta-keywords', settings.meta_keywords);
setVal('ga-id', settings.ga_id);
setVal('gtm-id', settings.gtm_id);
setVal('robots-txt', settings.robots_txt);
// Appearance
setVal('primary-color', settings.primary_color);
setVal('secondary-color', settings.secondary_color);
setVal('accent-color', settings.accent_color);
setVal('dark-bg-color', settings.dark_bg_color);
setVal('font-family', settings.font_family);
// Social
setVal('social-facebook', settings.social_facebook);
setVal('social-linkedin', settings.social_linkedin);
setVal('social-twitter', settings.social_twitter);
setVal('social-instagram', settings.social_instagram);
setVal('social-youtube', settings.social_youtube);
setVal('social-github', settings.social_github);
// Integrations
setCheck('integration-recaptcha', settings.integration_recaptcha);
setCheck('integration-mailchimp', settings.integration_mailchimp);
setCheck('integration-slack', settings.integration_slack);
setCheck('integration-calendly', settings.integration_calendly);
setVal('calendly-url', settings.calendly_url);
// Update color pickers text inputs
updateColorInputs();
}
} catch (error) {
console.error('Error loading settings:', error);
MSPE.showNotification('Failed to load settings', 'error');
}
}
function setVal(id, val) {
if (val !== undefined && val !== null && document.getElementById(id)) {
document.getElementById(id).value = val;
}
}
function setCheck(id, val) {
const el = document.getElementById(id);
if (el) el.checked = (val === true || val === '1' || val === 1);
}
function updateColorInputs() {
document.querySelectorAll('.color-picker input[type="color"]').forEach(picker => {
if (picker.nextElementSibling) {
picker.nextElementSibling.value = picker.value;
}
});
}
async function saveSettings() {
const settings = {
// General
site_name: document.getElementById('site-name').value,
site_tagline: document.getElementById('site-tagline').value,
site_description: document.getElementById('site-description').value,
timezone: document.getElementById('timezone').value,
// Contact
contact_email: document.getElementById('contact-email').value,
contact_phone: document.getElementById('contact-phone').value,
contact_address: document.getElementById('contact-address').value,
business_hours: document.getElementById('business-hours').value,
support_hours: document.getElementById('support-hours').value,
maps_url: document.getElementById('maps-url').value,
// Email & Auth
email_transport: document.getElementById('email-transport').value,
admin_email: document.getElementById('admin-email').value,
smtp_from_name: document.getElementById('smtp-from-name').value,
smtp_from_email: document.getElementById('smtp-from-email').value,
smtp_host: document.getElementById('smtp-host').value,
smtp_port: document.getElementById('smtp-port').value,
smtp_encryption: document.getElementById('smtp-encryption').value,
smtp_username: document.getElementById('smtp-username').value,
smtp_password: document.getElementById('smtp-password').value,
password_reset_url: document.getElementById('password-reset-url').value,
// SEO
meta_title: document.getElementById('meta-title').value,
meta_description: document.getElementById('meta-description').value,
meta_keywords: document.getElementById('meta-keywords').value,
ga_id: document.getElementById('ga-id').value,
gtm_id: document.getElementById('gtm-id').value,
robots_txt: document.getElementById('robots-txt').value,
// Appearance
primary_color: document.getElementById('primary-color').value,
secondary_color: document.getElementById('secondary-color').value,
accent_color: document.getElementById('accent-color').value,
dark_bg_color: document.getElementById('dark-bg-color').value,
font_family: document.getElementById('font-family').value,
// Social
social_facebook: document.getElementById('social-facebook').value,
social_linkedin: document.getElementById('social-linkedin').value,
social_twitter: document.getElementById('social-twitter').value,
social_instagram: document.getElementById('social-instagram').value,
social_youtube: document.getElementById('social-youtube').value,
social_github: document.getElementById('social-github').value,
// Integrations
integration_recaptcha: document.getElementById('integration-recaptcha').checked ? 1 : 0,
integration_mailchimp: document.getElementById('integration-mailchimp').checked ? 1 : 0,
integration_slack: document.getElementById('integration-slack').checked ? 1 : 0,
integration_calendly: document.getElementById('integration-calendly').checked ? 1 : 0,
calendly_url: document.getElementById('calendly-url').value
};
try {
const response = await MSPE.API.post('settings.php', settings);
if (response.success) {
MSPE.showNotification('Settings saved successfully', 'success');
} else {
MSPE.showNotification('Error saving settings', 'error');
}
} catch (error) {
MSPE.showNotification('Error saving settings', 'error');
}
}
// Logo & Favicon upload handlers
document.getElementById('logo-input')?.addEventListener('change', async function() {
if (!this.files[0]) return;
const formData = new FormData();
formData.append('files[]', this.files[0]);
formData.append('folder', 'logo');
try {
const result = await MSPE.API.upload('media.php', formData);
if (result && result.success) {
document.querySelector('.current-logo').src = '/' + (result.data?.[0]?.path || 'images/logo/logo.png');
MSPE.showNotification('Logo uploaded successfully', 'success');
} else {
MSPE.showNotification(result?.message || 'Logo upload failed', 'error');
}
} catch (e) {
MSPE.showNotification('Error uploading logo', 'error');
}
});
document.getElementById('favicon-input')?.addEventListener('change', async function() {
if (!this.files[0]) return;
const formData = new FormData();
formData.append('files[]', this.files[0]);
formData.append('folder', 'logo');
try {
const result = await MSPE.API.upload('media.php', formData);
if (result && result.success) {
document.querySelector('.current-favicon').src = '/' + (result.data?.[0]?.path || 'images/logo/logo.png');
document.querySelector('.current-favicon').style.display = '';
MSPE.showNotification('Favicon uploaded successfully', 'success');
} else {
MSPE.showNotification(result?.message || 'Favicon upload failed', 'error');
}
} catch (e) {
MSPE.showNotification('Error uploading favicon', 'error');
}
});
</script>
</body>
</html>
+531
View File
@@ -0,0 +1,531 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow, noarchive">
<title>Subscribers - MSPE Admin</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha384-t1nt8BQoYMLFN5p42tRAtuAAFQaCQODekUVeKKZrEnEyp4H2R0RHFz0KWpmj7i8g" crossorigin="anonymous">
<link rel="stylesheet" href="/admin/css/admin.css">
<style>
.subscribers-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.stats-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: white;
border-radius: 10px;
padding: 1.5rem;
box-shadow: var(--shadow-sm);
}
.stat-card h4 {
font-size: 0.875rem;
color: var(--gray-500);
margin-bottom: 0.5rem;
}
.stat-card .value {
font-size: 2rem;
font-weight: 700;
color: var(--gray-900);
}
.subscribers-table-container {
background: white;
border-radius: 10px;
box-shadow: var(--shadow-sm);
overflow: hidden;
}
.table-header {
padding: 1.5rem;
border-bottom: 1px solid var(--gray-200);
display: flex;
justify-content: space-between;
align-items: center;
}
.table-header h3 {
margin: 0;
}
.subscribers-table {
width: 100%;
border-collapse: collapse;
}
.subscribers-table th {
background: var(--gray-100);
padding: 1rem 1.5rem;
text-align: left;
font-weight: 600;
color: var(--gray-600);
font-size: 0.875rem;
}
.subscribers-table td {
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--gray-200);
font-size: 0.875rem;
}
.subscribers-table tr:hover {
background: var(--gray-50);
}
.status-badge {
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.status-active {
background: #d1fae5;
color: #065f46;
}
.status-inactive {
background: #fee2e2;
color: #991b1b;
}
.action-btn {
padding: 0.375rem 0.75rem;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
margin-right: 0.5rem;
transition: all 0.3s ease;
}
.action-btn.delete {
background: #fee2e2;
color: #dc2626;
}
.action-btn.delete:hover {
background: #fecaca;
}
.empty-state {
padding: 4rem 2rem;
text-align: center;
color: var(--gray-500);
}
.empty-state i {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.3;
}
.export-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
</style>
</head>
<body>
<div class="admin-wrapper">
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<a href="/admin/dashboard.html" class="sidebar-logo">
<i class="fas fa-cube"></i>
<span>MSPE</span>
</a>
<button class="sidebar-toggle" id="sidebar-toggle">
<i class="fas fa-bars"></i>
</button>
</div>
<nav class="sidebar-nav">
<div class="nav-section">
<span class="nav-section-title">Main</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/dashboard.html" class="nav-link">
<i class="fas fa-th-large"></i>
<span>Dashboard</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Content</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/pages.html" class="nav-link">
<i class="fas fa-file-alt"></i>
<span>Pages</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/services.html" class="nav-link">
<i class="fas fa-concierge-bell"></i>
<span>Services</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/portfolio.html" class="nav-link">
<i class="fas fa-briefcase"></i>
<span>Portfolio</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/news.html" class="nav-link">
<i class="fas fa-newspaper"></i>
<span>News & Events</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/testimonials.html" class="nav-link">
<i class="fas fa-quote-right"></i>
<span>Testimonials</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/team.html" class="nav-link">
<i class="fas fa-users"></i>
<span>Team</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Media</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/media.html" class="nav-link">
<i class="fas fa-images"></i>
<span>Media Library</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Inquiries</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/messages.html" class="nav-link">
<i class="fas fa-envelope"></i>
<span>Messages</span>
</a>
</li>
<li class="nav-item active">
<a href="/admin/subscribers.html" class="nav-link">
<i class="fas fa-bell"></i>
<span>Subscribers</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/bookings.html" class="nav-link">
<i class="fas fa-calendar-check"></i>
<span>Bookings</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Settings</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/settings.html" class="nav-link">
<i class="fas fa-cog"></i>
<span>Site Settings</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/users.html" class="nav-link">
<i class="fas fa-user-shield"></i>
<span>Admin Users</span>
</a>
</li>
</ul>
</div>
</nav>
<div class="sidebar-footer">
<a href="/index.html" class="view-site-btn" target="_blank">
<i class="fas fa-external-link-alt"></i>
<span>View Website</span>
</a>
</div>
</aside>
<main class="main-content">
<header class="admin-header">
<div class="header-left">
<button class="mobile-toggle" id="mobile-toggle">
<i class="fas fa-bars"></i>
</button>
<h1 class="page-title">Newsletter Subscribers</h1>
</div>
<div class="header-right">
<button class="btn btn-primary export-btn" onclick="exportSubscribers()">
<i class="fas fa-download"></i> Export CSV
</button>
<div class="header-user">
<div class="user-avatar">
<img src="https://ui-avatars.com/api/?name=Admin&background=0ea5e9&color=fff" alt="Admin">
</div>
<div class="user-info">
<span class="user-name">Admin</span>
<span class="user-role">Administrator</span>
</div>
<div class="user-dropdown">
<button class="dropdown-toggle">
<i class="fas fa-chevron-down"></i>
</button>
<ul class="dropdown-menu">
<li><a href="/admin/settings.html"><i class="fas fa-cog"></i> Settings</a></li>
<li class="divider"></li>
<li><a href="#" id="logout-btn"><i class="fas fa-sign-out-alt"></i> Logout</a></li>
</ul>
</div>
</div>
</div>
</header>
<div class="content-wrapper">
<div class="stats-row">
<div class="stat-card">
<h4>Total Subscribers</h4>
<div class="value" id="total-count">0</div>
</div>
<div class="stat-card">
<h4>Active Subscribers</h4>
<div class="value" id="active-count">0</div>
</div>
<div class="stat-card">
<h4>This Month</h4>
<div class="value" id="month-count">0</div>
</div>
</div>
<div class="subscribers-table-container">
<div class="table-header">
<h3>Subscriber List</h3>
<div class="header-search" style="max-width: 250px;">
<i class="fas fa-search"></i>
<input type="text" placeholder="Search subscribers..." id="search-input">
</div>
</div>
<table class="subscribers-table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Subscribed</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="subscribers-table-body">
<tr>
<td colspan="5">
<div class="empty-state">
<i class="fas fa-spinner fa-spin"></i>
<p>Loading subscribers...</p>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</main>
</div>
<script src="/admin/js/admin.js"></script>
<script>
let allSubscribers = [];
document.addEventListener('DOMContentLoaded', function() {
loadSubscribers();
setupEventListeners();
});
function setupEventListeners() {
document.getElementById('search-input').addEventListener('input', MSPE.debounce(filterSubscribers, 300));
}
async function loadSubscribers() {
try {
const response = await MSPE.API.get('subscribe.php');
if (response && response.success) {
allSubscribers = response.data || [];
updateStats();
renderSubscribers(allSubscribers);
}
} catch (error) {
console.error('Failed to load subscribers:', error);
document.getElementById('subscribers-table-body').innerHTML = `
<tr>
<td colspan="5">
<div class="empty-state">
<i class="fas fa-exclamation-circle"></i>
<p>Failed to load subscribers</p>
</div>
</td>
</tr>
`;
}
}
function updateStats() {
const total = allSubscribers.length;
const active = allSubscribers.filter(s => s.status === 'active').length;
// This month
const now = new Date();
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const thisMonth = allSubscribers.filter(s =>
new Date(s.created_at) >= monthStart
).length;
document.getElementById('total-count').textContent = total;
document.getElementById('active-count').textContent = active;
document.getElementById('month-count').textContent = thisMonth;
}
function renderSubscribers(subscribers) {
const tbody = document.getElementById('subscribers-table-body');
if (!subscribers || subscribers.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="5">
<div class="empty-state">
<i class="fas fa-users"></i>
<p>No subscribers yet</p>
<span style="font-size: 0.875rem;">Subscribers from your newsletter signup will appear here</span>
</div>
</td>
</tr>
`;
return;
}
tbody.innerHTML = subscribers.map(subscriber => {
const date = new Date(subscriber.created_at);
const formattedDate = date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
const statusClass = subscriber.status === 'active' ? 'status-active' : 'status-inactive';
return `
<tr data-id="${subscriber.id}">
<td>${subscriber.name || 'Unknown'}</td>
<td>
<a href="mailto:${subscriber.email}" style="color: var(--primary);">
${subscriber.email}
</a>
</td>
<td>${formattedDate}</td>
<td>
<span class="status-badge ${statusClass}">${subscriber.status}</span>
</td>
<td>
<button class="action-btn delete" onclick="deleteSubscriber('${subscriber.id}')">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
`;
}).join('');
}
function filterSubscribers() {
const search = document.getElementById('search-input').value.toLowerCase();
if (!search) {
renderSubscribers(allSubscribers);
return;
}
const filtered = allSubscribers.filter(s =>
(s.name && s.name.toLowerCase().includes(search)) ||
s.email.toLowerCase().includes(search)
);
renderSubscribers(filtered);
}
async function deleteSubscriber(id) {
if (!await MSPE.confirmDialog('Are you sure you want to delete this subscriber?')) {
return;
}
try {
const response = await MSPE.API.delete(`subscribe.php?id=${id}`);
if (response && response.success) {
MSPE.showNotification('Subscriber deleted successfully', 'success');
allSubscribers = allSubscribers.filter(s => s.id !== id);
updateStats();
renderSubscribers(allSubscribers);
}
} catch (error) {
console.error('Failed to delete subscriber:', error);
MSPE.showNotification('Failed to delete subscriber', 'error');
}
}
function exportSubscribers() {
if (allSubscribers.length === 0) {
MSPE.showNotification('No subscribers to export', 'error');
return;
}
const activeSubscribers = allSubscribers.filter(s => s.status === 'active');
const csv = [
['Name', 'Email', 'Subscribed Date', 'Status'].join(','),
...activeSubscribers.map(s => [
`"${s.name || ''}"`,
`"${s.email}"`,
`"${new Date(s.created_at).toLocaleDateString()}"`,
`"${s.status}"`
].join(','))
].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `subscribers-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
window.URL.revokeObjectURL(url);
MSPE.showNotification(`Exported ${activeSubscribers.length} subscribers`, 'success');
}
</script>
</body>
</html>
+508
View File
@@ -0,0 +1,508 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow, noarchive">
<title>Team Management - MSPE Admin</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha384-t1nt8BQoYMLFN5p42tRAtuAAFQaCQODekUVeKKZrEnEyp4H2R0RHFz0KWpmj7i8g" crossorigin="anonymous">
<link rel="stylesheet" href="/admin/css/admin.css">
</head>
<body>
<div class="admin-wrapper">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<a href="/admin/dashboard.html" class="sidebar-logo">
<i class="fas fa-cube"></i>
<span>MSPE</span>
</a>
<button class="sidebar-toggle" id="sidebar-toggle">
<i class="fas fa-bars"></i>
</button>
</div>
<nav class="sidebar-nav">
<div class="nav-section">
<span class="nav-section-title">Main</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/dashboard.html" class="nav-link">
<i class="fas fa-th-large"></i>
<span>Dashboard</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Content</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/pages.html" class="nav-link">
<i class="fas fa-file-alt"></i>
<span>Pages</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/services.html" class="nav-link">
<i class="fas fa-concierge-bell"></i>
<span>Services</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/portfolio.html" class="nav-link">
<i class="fas fa-briefcase"></i>
<span>Portfolio</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/news.html" class="nav-link">
<i class="fas fa-newspaper"></i>
<span>News & Events</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/testimonials.html" class="nav-link">
<i class="fas fa-quote-right"></i>
<span>Testimonials</span>
</a>
</li>
<li class="nav-item active">
<a href="/admin/team.html" class="nav-link">
<i class="fas fa-users"></i>
<span>Team</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Media</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/media.html" class="nav-link">
<i class="fas fa-images"></i>
<span>Media Library</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Inquiries</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/messages.html" class="nav-link">
<i class="fas fa-envelope"></i>
<span>Messages</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/subscribers.html" class="nav-link">
<i class="fas fa-bell"></i>
<span>Subscribers</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/bookings.html" class="nav-link">
<i class="fas fa-calendar-check"></i>
<span>Bookings</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Settings</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/settings.html" class="nav-link">
<i class="fas fa-cog"></i>
<span>Site Settings</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/users.html" class="nav-link">
<i class="fas fa-user-shield"></i>
<span>Admin Users</span>
</a>
</li>
</ul>
</div>
</nav>
<div class="sidebar-footer">
<a href="/index.html" class="view-site-btn" target="_blank">
<i class="fas fa-external-link-alt"></i>
<span>View Website</span>
</a>
</div>
</aside>
<!-- Main Content -->
<main class="main-content">
<!-- Header -->
<header class="admin-header">
<div class="header-left">
<button class="mobile-toggle" id="mobile-toggle">
<i class="fas fa-bars"></i>
</button>
<h1 class="page-title">Team Management</h1>
</div>
<div class="header-right">
<button class="btn btn-primary" onclick="openAddMemberModal()">
<i class="fas fa-plus"></i> Add Team Member
</button>
<div class="header-user">
<div class="user-avatar">
<img src="https://ui-avatars.com/api/?name=Admin&background=0ea5e9&color=fff" alt="Admin">
</div>
<div class="user-info">
<span class="user-name">Admin</span>
<span class="user-role">Administrator</span>
</div>
<div class="user-dropdown">
<button class="dropdown-toggle">
<i class="fas fa-chevron-down"></i>
</button>
<ul class="dropdown-menu">
<li><a href="/admin/settings.html"><i class="fas fa-cog"></i> Settings</a></li>
<li class="divider"></li>
<li><a href="#" id="logout-btn"><i class="fas fa-sign-out-alt"></i> Logout</a></li>
</ul>
</div>
</div>
</div>
</header>
<!-- Content -->
<div class="content-wrapper">
<div class="content-header">
<p class="content-description">Manage your team members displayed on the website</p>
<div class="filter-bar">
<select class="form-select" id="department-filter">
<option value="">All Departments</option>
<option value="management">Management</option>
<option value="engineering">Engineering</option>
<option value="support">Support</option>
<option value="sales">Sales</option>
</select>
<div class="view-toggle">
<button class="view-btn active" data-view="grid"><i class="fas fa-th"></i></button>
<button class="view-btn" data-view="list"><i class="fas fa-list"></i></button>
</div>
</div>
</div>
<!-- Team Grid -->
<div class="team-admin-grid" id="team-grid">
<div style="grid-column:1/-1;text-align:center;padding:3rem;color:var(--gray-400);">
<i class="fas fa-spinner fa-spin fa-2x"></i>
<p style="margin-top:1rem;">Loading team members...</p>
</div>
</div>
</div>
</main>
</div>
<!-- Add/Edit Team Member Modal -->
<div class="modal" id="member-modal">
<div class="modal-backdrop"></div>
<div class="modal-container">
<div class="modal-header">
<h2 id="modal-title">Add Team Member</h2>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<form id="member-form">
<div class="form-row">
<div class="form-group">
<label>Full Name *</label>
<input type="text" class="form-control" name="name" required>
</div>
<div class="form-group">
<label>Job Title *</label>
<input type="text" class="form-control" name="role" required>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Department *</label>
<select class="form-select" name="department" required>
<option value="">Select Department</option>
<option value="management">Management</option>
<option value="engineering">Engineering</option>
<option value="support">Support</option>
<option value="sales">Sales</option>
<option value="marketing">Marketing</option>
</select>
</div>
<div class="form-group">
<label>Email</label>
<input type="email" class="form-control" name="email">
</div>
</div>
<div class="form-group">
<label>Bio</label>
<textarea class="form-control" name="bio" rows="3" placeholder="Brief description..."></textarea>
</div>
<div class="form-group">
<label>Profile Photo</label>
<div class="file-upload-area">
<i class="fas fa-cloud-upload-alt"></i>
<p>Drag & drop or click to upload</p>
<span>Recommended: 400x400px, JPG or PNG</span>
<input type="file" name="photo" accept="image/*">
</div>
</div>
<div class="form-group">
<label>Social Links</label>
<div class="social-inputs">
<div class="input-icon-group">
<i class="fab fa-linkedin"></i>
<input type="url" class="form-control" name="linkedin" placeholder="LinkedIn URL">
</div>
<div class="input-icon-group">
<i class="fab fa-twitter"></i>
<input type="url" class="form-control" name="twitter" placeholder="Twitter URL">
</div>
<div class="input-icon-group">
<i class="fab fa-github"></i>
<input type="url" class="form-control" name="github" placeholder="GitHub URL">
</div>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Display Order</label>
<input type="number" class="form-control" name="order" value="1" min="1">
</div>
<div class="form-group">
<label>Status</label>
<select class="form-select" name="status">
<option value="active">Active (Visible)</option>
<option value="hidden">Hidden</option>
</select>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-outline" onclick="closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="saveMember()">
<i class="fas fa-save"></i> Save Member
</button>
</div>
</div>
</div>
<script src="/admin/js/admin.js"></script>
<script>
let teamMembers = [];
document.addEventListener('DOMContentLoaded', function() {
loadTeam();
document.getElementById('department-filter').addEventListener('change', filterTeam);
// View toggle (grid / list)
document.querySelectorAll('.view-btn').forEach(btn => {
btn.addEventListener('click', function() {
document.querySelectorAll('.view-btn').forEach(b => b.classList.remove('active'));
this.classList.add('active');
const grid = document.getElementById('team-grid');
if (this.dataset.view === 'list') {
grid.style.gridTemplateColumns = '1fr';
} else {
grid.style.gridTemplateColumns = '';
}
});
});
});
async function loadTeam() {
try {
const response = await MSPE.API.get('team.php');
if (response && response.success) {
teamMembers = response.data;
renderTeam(teamMembers);
}
} catch (error) {
console.error('Error loading team:', error);
document.getElementById('team-grid').innerHTML = `
<div class="empty-state">
<i class="fas fa-exclamation-circle"></i>
<p>Failed to load team members</p>
</div>
`;
}
}
function renderTeam(members) {
const container = document.getElementById('team-grid');
if (members.length === 0) {
container.innerHTML = `
<div class="empty-state">
<i class="fas fa-users"></i>
<p>No team members found</p>
</div>
`;
return;
}
container.innerHTML = members.map(member => `
<div class="team-admin-card ${member.status === 'hidden' ? 'opacity-50' : ''}">
<div class="team-member-photo">
<img src="${member.photo || 'https://ui-avatars.com/api/?name=' + encodeURIComponent(member.name) + '&background=random'}" alt="${member.name}">
<div class="photo-overlay">
<button class="btn btn-sm btn-outline" onclick="editMember('${member.id}')">
<i class="fas fa-camera"></i> Change
</button>
</div>
</div>
<div class="team-member-info">
<h3>${member.name}</h3>
<span class="team-role">${member.role}</span>
<span class="team-dept">${member.department}</span>
</div>
<div class="team-socials">
${member.linkedin ? `<a href="${member.linkedin}" class="social-icon linkedin"><i class="fab fa-linkedin"></i></a>` : ''}
${member.twitter ? `<a href="${member.twitter}" class="social-icon twitter"><i class="fab fa-twitter"></i></a>` : ''}
${member.email ? `<a href="mailto:${member.email}" class="social-icon email"><i class="fas fa-envelope"></i></a>` : ''}
</div>
<div class="team-admin-actions">
<button class="btn btn-sm btn-outline" onclick="editMember('${member.id}')">
<i class="fas fa-edit"></i> Edit
</button>
<button class="btn btn-sm btn-danger" onclick="deleteMember('${member.id}')">
<i class="fas fa-trash"></i>
</button>
</div>
<div class="team-order">
<span>Order: ${member.order || 0}</span>
</div>
</div>
`).join('');
}
function filterTeam() {
const dept = document.getElementById('department-filter').value;
const filtered = dept ? teamMembers.filter(m => m.department.toLowerCase() === dept) : teamMembers;
renderTeam(filtered);
}
function resetMemberPhotoArea(form) {
const area = form.querySelector('.file-upload-area');
if (!area) return;
area.style.position = '';
area.innerHTML = `
<i class="fas fa-cloud-upload-alt"></i>
<p>Drag &amp; drop or click to upload</p>
<span>Recommended: 400x400px, JPG or PNG</span>
<input type="file" name="photo" accept="image/*">`;
}
function openAddMemberModal() {
document.getElementById('modal-title').textContent = 'Add Team Member';
const form = document.getElementById('member-form');
form.reset();
form.dataset.id = '';
resetMemberPhotoArea(form);
document.getElementById('member-modal').classList.add('active');
}
function closeModal() {
document.getElementById('member-modal').classList.remove('active');
}
function editMember(id) {
const member = teamMembers.find(m => m.id === id);
if (!member) return;
document.getElementById('modal-title').textContent = 'Edit Team Member';
const form = document.getElementById('member-form');
form.dataset.id = id;
form.querySelector('[name="name"]').value = member.name;
form.querySelector('[name="role"]').value = member.role;
form.querySelector('[name="department"]').value = member.department.toLowerCase(); // Ensure lowercase match
form.querySelector('[name="email"]').value = member.email || '';
form.querySelector('[name="bio"]').value = member.bio || '';
form.querySelector('[name="linkedin"]').value = member.linkedin || '';
form.querySelector('[name="twitter"]').value = member.twitter || '';
form.querySelector('[name="github"]').value = member.github || '';
form.querySelector('[name="order"]').value = member.order || 1;
form.querySelector('[name="status"]').value = member.status || 'active';
// Restore photo preview
const photoArea = form.querySelector('.file-upload-area');
if (member.photo) {
photoArea.style.position = 'relative';
photoArea.innerHTML = `
<img src="/${member.photo}" alt="${member.name}" style="max-height:80px;border-radius:50%;margin-bottom:0.5rem;">
<p style="font-size:0.85rem;color:var(--gray-500);">Click to replace photo</p>
<input type="file" name="photo" accept="image/*" style="position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer;">`;
} else {
resetMemberPhotoArea(form);
}
document.getElementById('member-modal').classList.add('active');
}
async function saveMember() {
const form = document.getElementById('member-form');
const formData = new FormData(form);
const id = form.dataset.id;
if (id) formData.append('id', id);
try {
const result = await MSPE.API.upload('team.php', formData);
if (result && result.success) {
MSPE.showNotification(id ? 'Updated successfully' : 'Created successfully', 'success');
closeModal();
loadTeam();
} else {
MSPE.showNotification(result?.message || 'Error saving member', 'error');
}
} catch (error) {
MSPE.showNotification('Error saving member', 'error');
}
}
async function deleteMember(id) {
if (!await MSPE.confirmDialog('Remove this team member?')) return;
try {
const response = await MSPE.API.delete(`team.php?id=${id}`);
if (response.success) {
MSPE.showNotification('Removed successfully', 'success');
loadTeam();
}
} catch (error) {
MSPE.showNotification('Error removing member', 'error');
}
}
function changePhoto(id) {
// In a real app, this would trigger a dedicated file input or focus the modal's file input
editMember(id);
}
function moveMember(id, direction) {
// For now, manual order editing in modal is sufficient
MSPE.showNotification('Please edit the "Order" field in the edit modal', 'info');
}
</script>
</body>
</html>
+546
View File
@@ -0,0 +1,546 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow, noarchive">
<title>Testimonials - MSPE Admin</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha384-t1nt8BQoYMLFN5p42tRAtuAAFQaCQODekUVeKKZrEnEyp4H2R0RHFz0KWpmj7i8g" crossorigin="anonymous">
<link rel="stylesheet" href="/admin/css/admin.css">
</head>
<body>
<div class="admin-wrapper">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<a href="/admin/dashboard.html" class="sidebar-logo">
<i class="fas fa-cube"></i>
<span>MSPE</span>
</a>
<button class="sidebar-toggle" id="sidebar-toggle">
<i class="fas fa-bars"></i>
</button>
</div>
<nav class="sidebar-nav">
<div class="nav-section">
<span class="nav-section-title">Main</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/dashboard.html" class="nav-link">
<i class="fas fa-th-large"></i>
<span>Dashboard</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Content</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/pages.html" class="nav-link">
<i class="fas fa-file-alt"></i>
<span>Pages</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/services.html" class="nav-link">
<i class="fas fa-concierge-bell"></i>
<span>Services</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/portfolio.html" class="nav-link">
<i class="fas fa-briefcase"></i>
<span>Portfolio</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/news.html" class="nav-link">
<i class="fas fa-newspaper"></i>
<span>News & Events</span>
</a>
</li>
<li class="nav-item active">
<a href="/admin/testimonials.html" class="nav-link">
<i class="fas fa-quote-right"></i>
<span>Testimonials</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/team.html" class="nav-link">
<i class="fas fa-users"></i>
<span>Team</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Media</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/media.html" class="nav-link">
<i class="fas fa-images"></i>
<span>Media Library</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Inquiries</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/messages.html" class="nav-link">
<i class="fas fa-envelope"></i>
<span>Messages</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/subscribers.html" class="nav-link">
<i class="fas fa-bell"></i>
<span>Subscribers</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/bookings.html" class="nav-link">
<i class="fas fa-calendar-check"></i>
<span>Bookings</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Settings</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/settings.html" class="nav-link">
<i class="fas fa-cog"></i>
<span>Site Settings</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/users.html" class="nav-link">
<i class="fas fa-user-shield"></i>
<span>Admin Users</span>
</a>
</li>
</ul>
</div>
</nav>
<div class="sidebar-footer">
<a href="/index.html" class="view-site-btn" target="_blank">
<i class="fas fa-external-link-alt"></i>
<span>View Website</span>
</a>
</div>
</aside>
<!-- Main Content -->
<main class="main-content">
<!-- Header -->
<header class="admin-header">
<div class="header-left">
<button class="mobile-toggle" id="mobile-toggle">
<i class="fas fa-bars"></i>
</button>
<h1 class="page-title">Testimonials</h1>
</div>
<div class="header-right">
<button class="btn btn-primary" onclick="openAddTestimonialModal()">
<i class="fas fa-plus"></i> Add Testimonial
</button>
<div class="header-user">
<div class="user-avatar">
<img src="https://ui-avatars.com/api/?name=Admin&background=0ea5e9&color=fff" alt="Admin">
</div>
<div class="user-info">
<span class="user-name">Admin</span>
<span class="user-role">Administrator</span>
</div>
<div class="user-dropdown">
<button class="dropdown-toggle">
<i class="fas fa-chevron-down"></i>
</button>
<ul class="dropdown-menu">
<li><a href="/admin/settings.html"><i class="fas fa-cog"></i> Settings</a></li>
<li class="divider"></li>
<li><a href="#" id="logout-btn"><i class="fas fa-sign-out-alt"></i> Logout</a></li>
</ul>
</div>
</div>
</div>
</header>
<!-- Content -->
<div class="content-wrapper">
<div class="content-header">
<p class="content-description">Manage client testimonials and reviews displayed on the website</p>
</div>
<!-- Stats -->
<div class="stats-row" style="margin-bottom: 2rem;">
<div class="stat-card">
<div class="stat-icon" style="background: rgba(14, 165, 233, 0.15); color: #0ea5e9;">
<i class="fas fa-quote-right"></i>
</div>
<div class="stat-details">
<span class="stat-value" id="stat-total">0</span>
<span class="stat-label">Total Testimonials</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: rgba(16, 185, 129, 0.15); color: #10b981;">
<i class="fas fa-eye"></i>
</div>
<div class="stat-details">
<span class="stat-value" id="stat-published">0</span>
<span class="stat-label">Published</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: rgba(245, 158, 11, 0.15); color: #f59e0b;">
<i class="fas fa-star"></i>
</div>
<div class="stat-details">
<span class="stat-value" id="stat-avg-rating">0</span>
<span class="stat-label">Avg Rating</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: rgba(139, 92, 246, 0.15); color: #8b5cf6;">
<i class="fas fa-star-half-alt"></i>
</div>
<div class="stat-details">
<span class="stat-value" id="stat-pending">0</span>
<span class="stat-label">Pending Review</span>
</div>
</div>
</div>
<!-- Testimonials List -->
<div class="testimonials-admin-grid" id="testimonials-grid">
<div style="grid-column:1/-1;text-align:center;padding:3rem;color:var(--gray-400);">
<i class="fas fa-spinner fa-spin fa-2x"></i>
<p style="margin-top:1rem;">Loading testimonials...</p>
</div>
</div>
</div>
</main>
</div>
<!-- Add/Edit Testimonial Modal -->
<div class="modal" id="testimonial-modal">
<div class="modal-backdrop"></div>
<div class="modal-container">
<div class="modal-header">
<h2 id="modal-title">Add Testimonial</h2>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<form id="testimonial-form">
<div class="form-row">
<div class="form-group">
<label>Client Name *</label>
<input type="text" class="form-control" name="name" required>
</div>
<div class="form-group">
<label>Job Title / Position</label>
<input type="text" class="form-control" name="position">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Company</label>
<input type="text" class="form-control" name="company">
</div>
<div class="form-group">
<label>Rating *</label>
<select class="form-select" name="rating" required>
<option value="5">5 Stars - Excellent</option>
<option value="4.5">4.5 Stars</option>
<option value="4">4 Stars - Very Good</option>
<option value="3.5">3.5 Stars</option>
<option value="3">3 Stars - Good</option>
</select>
</div>
</div>
<div class="form-group">
<label>Testimonial Text *</label>
<textarea class="form-control" name="text" rows="4" required placeholder="Enter the client's testimonial..."></textarea>
</div>
<div class="form-group">
<label>Client Photo</label>
<div class="file-upload-area">
<i class="fas fa-cloud-upload-alt"></i>
<p>Drag & drop or click to upload</p>
<span>Recommended: 200x200px, JPG or PNG</span>
<input type="file" name="photo" accept="image/*">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Display On</label>
<div class="checkbox-group">
<label class="checkbox-label">
<input type="checkbox" name="pages[]" value="home" checked> Home Page
</label>
<label class="checkbox-label">
<input type="checkbox" name="pages[]" value="about"> About Page
</label>
<label class="checkbox-label">
<input type="checkbox" name="pages[]" value="services"> Services Page
</label>
</div>
</div>
<div class="form-group">
<label>Status</label>
<select class="form-select" name="status">
<option value="published">Published</option>
<option value="pending">Pending Review</option>
<option value="hidden">Hidden</option>
</select>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-outline" onclick="closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="saveTestimonial()">
<i class="fas fa-save"></i> Save Testimonial
</button>
</div>
</div>
</div>
<script src="/admin/js/admin.js"></script>
<script>
let allTestimonials = [];
document.addEventListener('DOMContentLoaded', function() {
loadTestimonials();
});
async function loadTestimonials() {
try {
const response = await MSPE.API.get('testimonials.php');
if (response && response.success) {
allTestimonials = response.data;
renderTestimonials(allTestimonials);
updateStats(allTestimonials);
}
} catch (error) {
console.error('Error loading testimonials:', error);
document.querySelector('#testimonials-grid').innerHTML = `
<div class="empty-state">
<i class="fas fa-exclamation-circle"></i>
<p>Failed to load testimonials</p>
</div>
`;
}
}
function renderTestimonials(items) {
const container = document.querySelector('#testimonials-grid');
if (items.length === 0) {
container.innerHTML = `
<div class="empty-state">
<i class="fas fa-quote-right"></i>
<p>No testimonials yet</p>
</div>
`;
return;
}
container.innerHTML = items.map(item => `
<div class="testimonial-admin-card ${item.status}">
<div class="testimonial-header">
<div class="testimonial-author">
<img src="${item.photo || 'https://ui-avatars.com/api/?name=' + encodeURIComponent(item.name) + '&background=random'}" alt="${item.name}">
<div>
<h4>${item.name}</h4>
<span>${item.position || ''} ${item.company ? '@ ' + item.company : ''}</span>
</div>
</div>
<div class="testimonial-status ${item.status}">
<i class="fas fa-${item.status === 'published' ? 'check-circle' : 'clock'}"></i> ${item.status}
</div>
</div>
<div class="testimonial-rating">
${renderStars(item.rating)}
</div>
<p class="testimonial-text">"${item.text}"</p>
<div class="testimonial-meta">
<span><i class="fas fa-calendar"></i> ${MSPE.formatDate(item.created_at)}</span>
</div>
<div class="testimonial-admin-actions">
${item.status === 'pending' ? `
<button class="btn btn-sm btn-primary" onclick="updateStatus('${item.id}', 'published')">
<i class="fas fa-check"></i> Approve
</button>
` : `
<button class="btn btn-sm btn-outline" onclick="updateStatus('${item.id}', 'pending')">
<i class="fas fa-eye-slash"></i> Unpublish
</button>
`}
<button class="btn btn-sm btn-outline" onclick="editTestimonial('${item.id}')">
<i class="fas fa-edit"></i> Edit
</button>
<button class="btn btn-sm btn-danger" onclick="deleteTestimonial('${item.id}')">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`).join('');
}
function renderStars(rating) {
let stars = '';
for (let i = 1; i <= 5; i++) {
if (i <= rating) {
stars += '<i class="fas fa-star"></i>';
} else if (i - 0.5 <= rating) {
stars += '<i class="fas fa-star-half-alt"></i>';
} else {
stars += '<i class="far fa-star"></i>';
}
}
return stars;
}
function updateStats(items) {
const total = items.length;
const published = items.filter(i => i.status === 'published').length;
const pending = items.filter(i => i.status === 'pending').length;
const avgRating = total > 0 ? (items.reduce((acc, i) => acc + parseFloat(i.rating), 0) / total).toFixed(1) : 0;
document.getElementById('stat-total').textContent = total;
document.getElementById('stat-published').textContent = published;
document.getElementById('stat-avg-rating').textContent = avgRating;
document.getElementById('stat-pending').textContent = pending;
}
function resetPhotoArea(form) {
const area = form.querySelector('.file-upload-area');
if (!area) return;
area.style.position = '';
area.innerHTML = `
<i class="fas fa-cloud-upload-alt"></i>
<p>Drag &amp; drop or click to upload</p>
<span>Recommended: 200x200px, JPG or PNG</span>
<input type="file" name="photo" accept="image/*">`;
}
function openAddTestimonialModal() {
document.getElementById('modal-title').textContent = 'Add Testimonial';
const form = document.getElementById('testimonial-form');
form.reset();
form.dataset.id = '';
resetPhotoArea(form);
document.getElementById('testimonial-modal').classList.add('active');
}
function closeModal() {
document.getElementById('testimonial-modal').classList.remove('active');
}
function editTestimonial(id) {
const item = allTestimonials.find(i => i.id === id);
if (!item) return;
document.getElementById('modal-title').textContent = 'Edit Testimonial';
const form = document.getElementById('testimonial-form');
form.dataset.id = id;
form.querySelector('[name="name"]').value = item.name;
form.querySelector('[name="position"]').value = item.position || '';
form.querySelector('[name="company"]').value = item.company || '';
form.querySelector('[name="rating"]').value = item.rating;
form.querySelector('[name="text"]').value = item.text;
form.querySelector('[name="status"]').value = item.status;
// Restore photo preview
const photoArea = form.querySelector('.file-upload-area');
if (item.photo) {
photoArea.style.position = 'relative';
photoArea.innerHTML = `
<img src="/${item.photo}" alt="${item.name}" style="max-height:80px;border-radius:50%;margin-bottom:0.5rem;">
<p style="font-size:0.85rem;color:var(--gray-500);">Click to replace photo</p>
<input type="file" name="photo" accept="image/*" style="position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer;">`;
} else {
resetPhotoArea(form);
}
document.getElementById('testimonial-modal').classList.add('active');
}
async function saveTestimonial() {
const form = document.getElementById('testimonial-form');
const formData = new FormData(form);
const id = form.dataset.id;
if (id) formData.append('id', id);
try {
const result = await MSPE.API.upload('testimonials.php', formData);
if (result && result.success) {
MSPE.showNotification(id ? 'Updated successfully' : 'Created successfully', 'success');
closeModal();
loadTestimonials();
} else {
MSPE.showNotification(result?.message || 'Error saving testimonial', 'error');
}
} catch (error) {
console.error('Save error:', error);
MSPE.showNotification('Error saving testimonial', 'error');
}
}
async function updateStatus(id, status) {
try {
const formData = new FormData();
formData.append('id', id);
formData.append('status', status);
const result = await MSPE.API.upload('testimonials.php', formData);
if (result && result.success) {
MSPE.showNotification(`Testimonial ${status}`, 'success');
loadTestimonials();
} else {
MSPE.showNotification(result?.message || 'Error updating status', 'error');
}
} catch (error) {
MSPE.showNotification('Error updating status', 'error');
}
}
async function deleteTestimonial(id) {
if (!await MSPE.confirmDialog('Delete this testimonial?')) return;
try {
const response = await MSPE.API.delete(`testimonials.php?id=${id}`);
if (response.success) {
MSPE.showNotification('Deleted successfully', 'success');
loadTestimonials();
}
} catch (error) {
MSPE.showNotification('Error deleting testimonial', 'error');
}
}
</script>
</body>
</html>
+603
View File
@@ -0,0 +1,603 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow, noarchive">
<title>Admin Users - MSPE Admin</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha384-t1nt8BQoYMLFN5p42tRAtuAAFQaCQODekUVeKKZrEnEyp4H2R0RHFz0KWpmj7i8g" crossorigin="anonymous">
<link rel="stylesheet" href="/admin/css/admin.css">
</head>
<body>
<div class="admin-wrapper">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<a href="/admin/dashboard.html" class="sidebar-logo">
<i class="fas fa-cube"></i>
<span>MSPE</span>
</a>
<button class="sidebar-toggle" id="sidebar-toggle">
<i class="fas fa-bars"></i>
</button>
</div>
<nav class="sidebar-nav">
<div class="nav-section">
<span class="nav-section-title">Main</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/dashboard.html" class="nav-link">
<i class="fas fa-th-large"></i>
<span>Dashboard</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Content</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/pages.html" class="nav-link">
<i class="fas fa-file-alt"></i>
<span>Pages</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/services.html" class="nav-link">
<i class="fas fa-concierge-bell"></i>
<span>Services</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/portfolio.html" class="nav-link">
<i class="fas fa-briefcase"></i>
<span>Portfolio</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/news.html" class="nav-link">
<i class="fas fa-newspaper"></i>
<span>News & Events</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/testimonials.html" class="nav-link">
<i class="fas fa-quote-right"></i>
<span>Testimonials</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/team.html" class="nav-link">
<i class="fas fa-users"></i>
<span>Team</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Media</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/media.html" class="nav-link">
<i class="fas fa-images"></i>
<span>Media Library</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Inquiries</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/messages.html" class="nav-link">
<i class="fas fa-envelope"></i>
<span>Messages</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/subscribers.html" class="nav-link">
<i class="fas fa-bell"></i>
<span>Subscribers</span>
</a>
</li>
<li class="nav-item">
<a href="/admin/bookings.html" class="nav-link">
<i class="fas fa-calendar-check"></i>
<span>Bookings</span>
</a>
</li>
</ul>
</div>
<div class="nav-section">
<span class="nav-section-title">Settings</span>
<ul class="nav-menu">
<li class="nav-item">
<a href="/admin/settings.html" class="nav-link">
<i class="fas fa-cog"></i>
<span>Site Settings</span>
</a>
</li>
<li class="nav-item active">
<a href="/admin/users.html" class="nav-link">
<i class="fas fa-user-shield"></i>
<span>Admin Users</span>
</a>
</li>
</ul>
</div>
</nav>
<div class="sidebar-footer">
<a href="/index.html" class="view-site-btn" target="_blank">
<i class="fas fa-external-link-alt"></i>
<span>View Website</span>
</a>
</div>
</aside>
<!-- Main Content -->
<main class="main-content">
<!-- Header -->
<header class="admin-header">
<div class="header-left">
<button class="mobile-toggle" id="mobile-toggle">
<i class="fas fa-bars"></i>
</button>
<h1 class="page-title">Admin Users</h1>
</div>
<div class="header-right">
<button class="btn btn-primary" onclick="openAddUserModal()">
<i class="fas fa-user-plus"></i> Add User
</button>
<div class="header-user">
<div class="user-avatar">
<img src="https://ui-avatars.com/api/?name=Admin&background=0ea5e9&color=fff" alt="Admin">
</div>
<div class="user-info">
<span class="user-name">Admin</span>
<span class="user-role">Administrator</span>
</div>
<div class="user-dropdown">
<button class="dropdown-toggle">
<i class="fas fa-chevron-down"></i>
</button>
<ul class="dropdown-menu">
<li><a href="/admin/settings.html"><i class="fas fa-cog"></i> Settings</a></li>
<li class="divider"></li>
<li><a href="#" id="logout-btn"><i class="fas fa-sign-out-alt"></i> Logout</a></li>
</ul>
</div>
</div>
</div>
</header>
<!-- Content -->
<div class="content-wrapper">
<div class="content-header">
<p class="content-description">Manage admin user accounts and permissions</p>
</div>
<!-- Users Table -->
<div class="card">
<div class="card-header">
<h3><i class="fas fa-users-cog"></i> User Accounts</h3>
</div>
<div class="card-body">
<table class="data-table">
<thead>
<tr>
<th>User</th>
<th>Email</th>
<th>Role</th>
<th>Last Login</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="6" style="text-align:center;padding:2rem;color:var(--gray-400);">
<i class="fas fa-spinner fa-spin"></i> Loading users...
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Roles & Permissions -->
<div class="card" style="margin-top: 2rem;">
<div class="card-header">
<h3><i class="fas fa-user-tag"></i> Roles & Permissions</h3>
</div>
<div class="card-body">
<div class="roles-grid">
<div class="role-card">
<div class="role-header">
<span class="role-badge super-admin">Super Admin</span>
<span class="role-count">1 user</span>
</div>
<ul class="permissions-list">
<li><i class="fas fa-check"></i> Full system access</li>
<li><i class="fas fa-check"></i> Manage all users</li>
<li><i class="fas fa-check"></i> Site settings</li>
<li><i class="fas fa-check"></i> Delete content</li>
<li><i class="fas fa-check"></i> View analytics</li>
</ul>
</div>
<div class="role-card">
<div class="role-header">
<span class="role-badge editor">Editor</span>
<span class="role-count">1 user</span>
</div>
<ul class="permissions-list">
<li><i class="fas fa-check"></i> Create/edit content</li>
<li><i class="fas fa-check"></i> Publish content</li>
<li><i class="fas fa-check"></i> Manage media</li>
<li><i class="fas fa-check"></i> View messages</li>
<li><i class="fas fa-times"></i> Site settings</li>
</ul>
</div>
<div class="role-card">
<div class="role-header">
<span class="role-badge contributor">Contributor</span>
<span class="role-count">1 user</span>
</div>
<ul class="permissions-list">
<li><i class="fas fa-check"></i> Create content</li>
<li><i class="fas fa-times"></i> Publish content</li>
<li><i class="fas fa-check"></i> Upload media</li>
<li><i class="fas fa-times"></i> Delete content</li>
<li><i class="fas fa-times"></i> Site settings</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Activity Log -->
<div class="card" style="margin-top: 2rem;">
<div class="card-header">
<h3><i class="fas fa-history"></i> Recent Activity</h3>
<a href="#" class="card-action">View All</a>
</div>
<div class="card-body">
<div class="activity-timeline">
<div class="activity-item">
<div class="activity-icon login">
<i class="fas fa-sign-in-alt"></i>
</div>
<div class="activity-content">
<p><strong>Super Admin</strong> logged in</p>
<span class="activity-time">10 minutes ago</span>
</div>
</div>
<div class="activity-item">
<div class="activity-icon edit">
<i class="fas fa-edit"></i>
</div>
<div class="activity-content">
<p><strong>Sarah Johnson</strong> edited <em>"New IT Security Features"</em></p>
<span class="activity-time">2 hours ago</span>
</div>
</div>
<div class="activity-item">
<div class="activity-icon create">
<i class="fas fa-plus"></i>
</div>
<div class="activity-content">
<p><strong>Sarah Johnson</strong> created new news article</p>
<span class="activity-time">Yesterday</span>
</div>
</div>
<div class="activity-item">
<div class="activity-icon settings">
<i class="fas fa-cog"></i>
</div>
<div class="activity-content">
<p><strong>Super Admin</strong> updated site settings</p>
<span class="activity-time">2 days ago</span>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
<!-- Add/Edit User Modal -->
<div class="modal" id="user-modal">
<div class="modal-backdrop"></div>
<div class="modal-container">
<div class="modal-header">
<h2 id="modal-title">Add Admin User</h2>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<form id="user-form">
<div class="form-row">
<div class="form-group">
<label>Full Name *</label>
<input type="text" class="form-control" name="name" required>
</div>
<div class="form-group">
<label>Username *</label>
<input type="text" class="form-control" name="username" required>
</div>
</div>
<div class="form-group">
<label>Email Address *</label>
<input type="email" class="form-control" name="email" required>
</div>
<div class="form-row" id="password-fields">
<div class="form-group">
<label>Password *</label>
<input type="password" class="form-control" name="password" required>
</div>
<div class="form-group">
<label>Confirm Password *</label>
<input type="password" class="form-control" name="password_confirm" required>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Role *</label>
<select class="form-select" name="role" required>
<option value="contributor">Contributor</option>
<option value="editor">Editor</option>
<option value="super-admin">Super Admin</option>
</select>
</div>
<div class="form-group">
<label>Status</label>
<select class="form-select" name="status">
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" name="send_welcome" checked>
Send welcome email with login details
</label>
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-outline" onclick="closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="saveUser()">
<i class="fas fa-save"></i> Save User
</button>
</div>
</div>
</div>
<!-- Change Password Modal -->
<div class="modal" id="password-modal">
<div class="modal-backdrop"></div>
<div class="modal-container modal-sm">
<div class="modal-header">
<h2>Change Password</h2>
<button class="modal-close" onclick="closePasswordModal()">&times;</button>
</div>
<div class="modal-body">
<form id="password-form">
<div class="form-group">
<label>New Password *</label>
<input type="password" class="form-control" name="new_password" required>
</div>
<div class="form-group">
<label>Confirm New Password *</label>
<input type="password" class="form-control" name="confirm_password" required>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" name="force_logout">
Force logout from all sessions
</label>
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-outline" onclick="closePasswordModal()">Cancel</button>
<button class="btn btn-primary" onclick="updatePassword()">
<i class="fas fa-key"></i> Update Password
</button>
</div>
</div>
</div>
<script src="/admin/js/admin.js"></script>
<script>
let allUsers = [];
document.addEventListener('DOMContentLoaded', function() {
loadUsers();
});
async function loadUsers() {
try {
const response = await MSPE.API.get('users.php');
if (response && response.success) {
allUsers = response.data;
renderUsers(allUsers);
} else {
document.querySelector('tbody').innerHTML = '<tr><td colspan="6" class="text-center">Failed to load users</td></tr>';
}
} catch (error) {
console.error('Error loading users:', error);
document.querySelector('tbody').innerHTML = '<tr><td colspan="6" class="text-center">Failed to load users</td></tr>';
}
}
function renderUsers(users) {
const tbody = document.querySelector('tbody');
if (users.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="text-center">No users found</td></tr>';
return;
}
tbody.innerHTML = users.map(user => `
<tr>
<td>
<div class="user-info">
<img src="https://ui-avatars.com/api/?name=${encodeURIComponent(user.name)}&background=random" alt="${user.name}">
<div>
<span class="name">${user.name}</span>
<span class="username">${user.username}</span>
</div>
</div>
</td>
<td>${user.email}</td>
<td><span class="role-badge ${user.role}">${user.role}</span></td>
<td>${user.last_login ? MSPE.formatDate(user.last_login) : 'Never'}</td>
<td><span class="status-badge ${user.status}">${user.status}</span></td>
<td>
<div class="action-buttons">
<button class="btn btn-sm btn-outline" onclick="editUser('${user.id}')">
<i class="fas fa-edit"></i>
</button>
<button class="btn btn-sm btn-outline" onclick="changePassword('${user.id}')">
<i class="fas fa-key"></i>
</button>
<button class="btn btn-sm btn-danger" onclick="deleteUser('${user.id}')">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
`).join('');
}
function openAddUserModal() {
document.getElementById('modal-title').textContent = 'Add Admin User';
document.getElementById('user-form').reset();
document.getElementById('user-form').dataset.id = '';
document.getElementById('password-fields').style.display = 'flex';
document.getElementById('user-modal').classList.add('active');
}
function closeModal() {
document.getElementById('user-modal').classList.remove('active');
}
function editUser(id) {
const user = allUsers.find(u => u.id === id);
if (!user) return;
document.getElementById('modal-title').textContent = 'Edit User';
const form = document.getElementById('user-form');
form.dataset.id = id;
form.querySelector('[name="name"]').value = user.name;
form.querySelector('[name="username"]').value = user.username;
form.querySelector('[name="email"]').value = user.email;
form.querySelector('[name="role"]').value = user.role;
form.querySelector('[name="status"]').value = user.status;
// Hide password fields for edit
document.getElementById('password-fields').style.display = 'none';
document.getElementById('user-modal').classList.add('active');
}
function deleteUser(id) {
MSPE.confirmDialog('Are you sure you want to delete this user?').then(async confirmed => {
if (confirmed) {
try {
const response = await MSPE.API.delete(`users.php?id=${id}`);
if (response.success) {
MSPE.showNotification('User deleted', 'success');
loadUsers();
} else {
MSPE.showNotification(response.message, 'error');
}
} catch (e) {
MSPE.showNotification('Error deleting user', 'error');
}
}
});
}
function changePassword(id) {
document.getElementById('password-form').dataset.id = id;
document.getElementById('password-modal').classList.add('active');
}
function closePasswordModal() {
document.getElementById('password-modal').classList.remove('active');
}
async function saveUser() {
const form = document.getElementById('user-form');
const formData = new FormData(form);
const id = form.dataset.id;
if (id) formData.append('id', id);
try {
const result = await MSPE.API.upload('users.php', formData);
if (result && result.success) {
MSPE.showNotification(id ? 'Updated successfully' : 'Created successfully', 'success');
closeModal();
loadUsers();
} else {
MSPE.showNotification(result?.message || 'Error saving user', 'error');
}
} catch (error) {
MSPE.showNotification('Error saving user', 'error');
}
}
async function updatePassword() {
const form = document.getElementById('password-form');
const id = form.dataset.id;
const newPass = form.querySelector('[name="new_password"]').value;
const confirmPass = form.querySelector('[name="confirm_password"]').value;
if (newPass !== confirmPass) {
MSPE.showNotification('Passwords do not match', 'error');
return;
}
const formData = new FormData();
formData.append('id', id);
formData.append('password', newPass);
try {
const result = await MSPE.API.upload('users.php', formData);
if (result && result.success) {
MSPE.showNotification('Password updated', 'success');
closePasswordModal();
} else {
MSPE.showNotification(result?.message || 'Error updating password', 'error');
}
} catch (error) {
MSPE.showNotification('Error updating password', 'error');
}
}
</script>
</body>
</html>
+10
View File
@@ -0,0 +1,10 @@
# Block dev/test/seed scripts in production
<FilesMatch "^(db_seed|db_test|email-test)\.php$">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
</FilesMatch>
+391
View File
@@ -0,0 +1,391 @@
<?php
/**
* MSPE Authentication API
*/
require_once 'config.php';
$method = $_SERVER['REQUEST_METHOD'];
$body = getRequestBody();
switch ($method) {
case 'POST':
$action = $body['action'] ?? 'login';
if ($action === 'login') {
handleLogin($body);
} elseif ($action === 'logout') {
handleLogout();
} elseif ($action === 'verify') {
handleVerify();
} elseif ($action === 'request_password_reset') {
requestPasswordReset($body);
} elseif ($action === 'reset_password') {
resetPassword($body);
} else {
jsonResponse(['success' => false, 'message' => 'Invalid action'], 400);
}
break;
default:
jsonResponse(['success' => false, 'message' => 'Method not allowed'], 405);
}
function handleLogin($body) {
global $db;
$username = $body['username'] ?? '';
$password = $body['password'] ?? '';
$rememberMe = !empty($body['remember']);
if (empty($username) || empty($password)) {
jsonResponse(['success' => false, 'message' => 'Username and password required'], 400);
}
// ── Rate limiting: max 10 attempts per IP per 15 minutes (DB-backed) ──
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
$maxAttempts = 10;
$windowSeconds = 900; // 15 minutes
if (countRecentFailedLogins($ip, $windowSeconds) >= $maxAttempts) {
jsonResponse(['success' => false, 'message' => 'Too many login attempts. Try again later.'], 429);
}
// ── Per-username rate limiting: max 5 attempts per username per 15 minutes ──
$maxUsernameAttempts = 5;
if (countRecentFailedLoginsByUsername($username, $windowSeconds) >= $maxUsernameAttempts) {
jsonResponse(['success' => false, 'message' => 'This account is temporarily locked. Try again later.'], 429);
}
// Check override admin auth record first (if created via reset flow)
$adminAuthRows = $db->getAll('admin_auth');
$adminAuth = !empty($adminAuthRows) ? $adminAuthRows[0] : null;
if ($adminAuth && isset($adminAuth['username']) && isset($adminAuth['password_hash'])) {
if ($username === $adminAuth['username'] && password_verify($password, $adminAuth['password_hash'])) {
loginSuccessResponse('admin', $adminAuth['username'], 'admin', $rememberMe);
}
} else {
// Fallback to config credentials (ADMIN_PASS must be a password hash)
if ($username === ADMIN_USER && verifyConfigAdminPassword($password)) {
loginSuccessResponse('admin', $username, 'admin', $rememberMe);
}
}
// Check against database users
$users = $db->query('users', ['username' => $username]);
if (!empty($users)) {
$user = array_values($users)[0];
if (password_verify($password, $user['password'])) {
loginSuccessResponse($user['id'], $user['username'], $user['role'] ?? 'admin', $rememberMe);
}
}
recordFailedLogin();
auditLog('login_failure', ['username' => $username]);
jsonResponse(['success' => false, 'message' => 'Invalid credentials'], 401);
}
function recordFailedLogin() {
global $db;
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
$body = getRequestBody();
$db->insert('login_attempts', [
'ip_address' => $ip,
'username' => sanitize($body['username'] ?? ''),
'attempted_at' => date('Y-m-d H:i:s')
]);
cleanupOldFailedLogins();
}
function countRecentFailedLogins($ip, $windowSeconds) {
global $db;
$cutoffDate = date('Y-m-d H:i:s', time() - (int)$windowSeconds);
$now = date('Y-m-d H:i:s');
return $db->countByDateRange('login_attempts', 'attempted_at', $cutoffDate, $now, [
'ip_address' => $ip
]);
}
function countRecentFailedLoginsByUsername($username, $windowSeconds) {
global $db;
if (trim($username) === '') return 0;
$cutoffDate = date('Y-m-d H:i:s', time() - (int)$windowSeconds);
$now = date('Y-m-d H:i:s');
return $db->countByDateRange('login_attempts', 'attempted_at', $cutoffDate, $now, [
'username' => $username
]);
}
function cleanupOldFailedLogins() {
global $db;
$attempts = $db->getAll('login_attempts');
$cutoff = time() - (24 * 60 * 60);
foreach ($attempts as $attempt) {
$attemptedAt = strtotime((string)($attempt['attempted_at'] ?? '1970-01-01 00:00:00'));
if ($attemptedAt < $cutoff && !empty($attempt['id'])) {
$db->delete('login_attempts', $attempt['id']);
}
}
}
function handleLogout() {
clearAdminAuthCookie();
jsonResponse(['success' => true, 'message' => 'Logged out successfully']);
}
function handleVerify() {
$user = checkAuth();
if ($user) {
jsonResponse([
'success' => true,
'user' => [
'id' => $user['user_id'],
'username' => $user['username'],
'role' => $user['role']
]
]);
}
jsonResponse(['success' => false, 'message' => 'Invalid token'], 401);
}
function loginSuccessResponse($id, $username, $role, $rememberMe = false) {
$ttlSeconds = $rememberMe ? (30 * 24 * 60 * 60) : (12 * 60 * 60);
$token = JWT::encode([
'user_id' => $id,
'username' => $username,
'role' => $role
], $ttlSeconds);
setAdminAuthCookie($token, $ttlSeconds);
auditLog('login_success', ['username' => $username, 'role' => $role, 'remember_me' => $rememberMe], $id);
jsonResponse([
'success' => true,
'message' => 'Login successful',
'user' => [
'id' => $id,
'username' => $username,
'role' => $role
]
]);
}
function verifyConfigAdminPassword($submittedPassword) {
if (!is_string(ADMIN_PASS) || ADMIN_PASS === '') {
return false;
}
$info = password_get_info(ADMIN_PASS);
if (!empty($info['algo'])) {
return password_verify($submittedPassword, ADMIN_PASS);
}
error_log('MSPE security warning: ADMIN_PASS is not a password hash. Configure a password_hash() value in .env.');
return false;
}
function setAdminAuthCookie($token, $ttlSeconds) {
$isHttps = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| (($_SERVER['SERVER_PORT'] ?? '') == 443)
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
setcookie('mspe_admin_token', $token, [
'expires' => time() + max(300, (int)$ttlSeconds),
'path' => '/',
'secure' => $isHttps,
'httponly' => true,
'samesite' => 'Strict'
]);
}
function clearAdminAuthCookie() {
$isHttps = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| (($_SERVER['SERVER_PORT'] ?? '') == 443)
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
setcookie('mspe_admin_token', '', [
'expires' => time() - 3600,
'path' => '/',
'secure' => $isHttps,
'httponly' => true,
'samesite' => 'Strict'
]);
}
function requestPasswordReset($body) {
global $db;
cleanupExpiredPasswordResets();
$identity = trim((string)($body['identity'] ?? $body['email'] ?? ''));
if ($identity === '') {
jsonResponse(['success' => false, 'message' => 'Email or username is required'], 400);
}
$matched = findUserForPasswordReset($identity);
// Always return generic success to avoid username/email enumeration
if (!$matched) {
jsonResponse(['success' => true, 'message' => 'If an account exists, a reset email has been sent.']);
}
$token = bin2hex(random_bytes(32));
$expiresAt = date('Y-m-d H:i:s', time() + (30 * 60));
$db->insert('password_resets', [
'token' => $token,
'user_type' => $matched['user_type'],
'user_id' => $matched['user_id'],
'email' => $matched['email'],
'used' => false,
'expires_at' => $expiresAt
]);
$resetUrl = trim((string)getSetting('password_reset_url', SITE_URL . '/admin/reset-password.html'));
if ($resetUrl === '') {
$resetUrl = SITE_URL . '/admin/reset-password.html';
}
$separator = strpos($resetUrl, '?') === false ? '?' : '&';
$resetLink = $resetUrl . $separator . 'token=' . urlencode($token);
$subject = 'Reset your admin password';
$plain = "A password reset was requested for your admin account.\n\n";
$plain .= "Reset link (valid for 30 minutes):\n{$resetLink}\n\n";
$plain .= "If you did not request this, you can ignore this email.";
$html = '<h2>Password Reset</h2>'
. '<p>A password reset was requested for your admin account.</p>'
. '<p><a href="' . htmlspecialchars($resetLink) . '" style="display:inline-block;padding:10px 16px;background:#0ea5e9;color:#fff;text-decoration:none;border-radius:8px;">Reset Password</a></p>'
. '<p style="font-size:13px;color:#64748b;">This link is valid for 30 minutes.</p>';
$send = sendEmail($matched['email'], $subject, $html, $plain);
if (!$send['success']) {
error_log('MSPE password reset email failed: ' . ($send['message'] ?? 'unknown'));
}
jsonResponse(['success' => true, 'message' => 'If an account exists, a reset email has been sent.']);
}
function resetPassword($body) {
global $db;
cleanupExpiredPasswordResets();
$token = trim((string)($body['token'] ?? ''));
$newPassword = (string)($body['new_password'] ?? '');
if ($token === '' || $newPassword === '') {
jsonResponse(['success' => false, 'message' => 'Token and new password are required'], 400);
}
if (strlen($newPassword) < 12) {
jsonResponse(['success' => false, 'message' => 'Password must be at least 12 characters'], 400);
}
$resets = $db->query('password_resets', ['token' => $token]);
if (empty($resets)) {
jsonResponse(['success' => false, 'message' => 'Invalid or expired reset token'], 400);
}
$reset = array_values($resets)[0];
if (!empty($reset['used'])) {
jsonResponse(['success' => false, 'message' => 'This reset token has already been used'], 400);
}
if (strtotime($reset['expires_at'] ?? '1970-01-01 00:00:00') < time()) {
jsonResponse(['success' => false, 'message' => 'Reset token has expired'], 400);
}
$hash = password_hash($newPassword, PASSWORD_DEFAULT);
if (($reset['user_type'] ?? '') === 'default_admin') {
$existingRows = $db->getAll('admin_auth');
if (!empty($existingRows)) {
$first = $existingRows[0];
$db->update('admin_auth', $first['id'], [
'username' => ADMIN_USER,
'email' => $reset['email'] ?? getSetting('admin_email', ADMIN_EMAIL),
'password_hash' => $hash
]);
} else {
$db->insert('admin_auth', [
'username' => ADMIN_USER,
'email' => $reset['email'] ?? getSetting('admin_email', ADMIN_EMAIL),
'password_hash' => $hash
]);
}
} else {
$userId = $reset['user_id'] ?? '';
$user = $db->get('users', $userId);
if (!$user) {
jsonResponse(['success' => false, 'message' => 'User no longer exists'], 400);
}
$db->update('users', $userId, ['password' => $hash]);
}
$db->update('password_resets', $reset['id'], ['used' => true]);
jsonResponse(['success' => true, 'message' => 'Password updated successfully']);
}
function findUserForPasswordReset($identity) {
global $db;
// Check DB users by email or username
$users = $db->getAll('users');
foreach ($users as $user) {
if (($user['email'] ?? '') === $identity || ($user['username'] ?? '') === $identity) {
return [
'user_type' => 'db_user',
'user_id' => $user['id'],
'email' => $user['email']
];
}
}
// Fallback default admin account
if ($identity === ADMIN_USER || $identity === ADMIN_EMAIL || $identity === getSetting('admin_email', ADMIN_EMAIL)) {
return [
'user_type' => 'default_admin',
'user_id' => 'admin',
'email' => getSetting('admin_email', ADMIN_EMAIL)
];
}
return null;
}
function cleanupExpiredPasswordResets() {
global $db;
$rows = $db->getAll('password_resets');
$now = time();
$retentionCutoff = $now - (30 * 24 * 60 * 60);
foreach ($rows as $row) {
$expiresAt = strtotime((string)($row['expires_at'] ?? '1970-01-01 00:00:00'));
$createdAt = strtotime((string)($row['created_at'] ?? '1970-01-01 00:00:00'));
$isUsed = !empty($row['used']);
if (($expiresAt > 0 && $expiresAt < $now) || ($isUsed && $createdAt < $retentionCutoff)) {
if (!empty($row['id'])) {
$db->delete('password_resets', $row['id']);
}
}
}
}
+605
View File
@@ -0,0 +1,605 @@
<?php
/**
* MSPE Calendar/Booking API
*/
require_once 'config.php';
$method = $_SERVER['REQUEST_METHOD'];
$id = $_GET['id'] ?? null;
switch ($method) {
case 'GET':
if ($id) {
getBooking($id);
} else {
$action = $_GET['action'] ?? '';
if ($action === 'availability') {
getAvailability();
} elseif ($action === 'public-availability') {
getPublicAvailability();
} elseif ($action === 'blocked-times') {
requireAuth();
getBlockedTimes();
} elseif ($action === 'upcoming') {
getUpcomingBookings();
} else {
getBookings();
}
}
break;
case 'POST':
$action = $_GET['action'] ?? '';
if ($action === 'create-slot') {
requireAuth();
createAvailabilitySlot();
} elseif ($action === 'block-time') {
requireAuth();
blockTime();
} elseif ($action === 'book') {
createBooking(true);
} else {
requireAuth();
createBooking(false);
}
break;
case 'PUT':
requireAuth();
if (!$id) {
jsonResponse(['success' => false, 'message' => 'Booking ID required'], 400);
}
updateBooking($id);
break;
case 'DELETE':
requireAuth();
if (!$id) {
jsonResponse(['success' => false, 'message' => 'ID required'], 400);
}
$type = $_GET['type'] ?? 'booking';
if ($type === 'blocked') {
deleteBlockedTime($id);
} else {
deleteBooking($id);
}
break;
default:
jsonResponse(['success' => false, 'message' => 'Method not allowed'], 405);
}
function getBookings() {
global $db;
requireAuth();
$bookings = $db->getAll('bookings');
$status = $_GET['status'] ?? null;
$limit = (int)($_GET['limit'] ?? 50);
$offset = (int)($_GET['offset'] ?? 0);
$dateFrom = $_GET['date_from'] ?? null;
$dateTo = $_GET['date_to'] ?? null;
if ($status) {
$bookings = array_filter($bookings, function($b) use ($status) {
return $b['status'] === $status;
});
}
if ($dateFrom) {
$bookings = array_filter($bookings, function($b) use ($dateFrom) {
return $b['booking_date'] >= $dateFrom;
});
}
if ($dateTo) {
$bookings = array_filter($bookings, function($b) use ($dateTo) {
return $b['booking_date'] <= $dateTo;
});
}
usort($bookings, function($a, $b) {
return strtotime($a['booking_date'] . ' ' . $a['booking_time']) - strtotime($b['booking_date'] . ' ' . $b['booking_time']);
});
$total = count($bookings);
$bookings = array_slice(array_values($bookings), $offset, $limit);
jsonResponse([
'success' => true,
'data' => $bookings,
'total' => $total,
'limit' => $limit,
'offset' => $offset
]);
}
function getBooking($id) {
global $db;
requireAuth();
$booking = $db->get('bookings', $id);
if (!$booking) {
jsonResponse(['success' => false, 'message' => 'Booking not found'], 404);
}
jsonResponse([
'success' => true,
'data' => $booking
]);
}
function getAvailability() {
global $db;
$slots = $db->getAll('availability_slots');
$bookings = $db->getAll('bookings');
$dateFrom = $_GET['date_from'] ?? date('Y-m-d');
$dateTo = $_GET['date_to'] ?? date('Y-m-d', strtotime('+30 days'));
$slots = array_filter($slots, function($slot) use ($dateFrom, $dateTo) {
return $slot['date'] >= $dateFrom && $slot['date'] <= $dateTo && $slot['is_available'];
});
$result = [];
foreach ($slots as $slot) {
$slotBookings = array_filter($bookings, function($booking) use ($slot) {
return $booking['booking_date'] === $slot['date'] &&
$booking['booking_time'] === $slot['time'] &&
in_array($booking['status'], ['confirmed', 'pending']);
});
$slot['booked'] = count($slotBookings) > 0;
$slot['remaining'] = $slot['capacity'] - count($slotBookings);
$result[] = $slot;
}
usort($result, function($a, $b) {
return strtotime($a['date'] . ' ' . $a['time']) - strtotime($b['date'] . ' ' . $b['time']);
});
jsonResponse([
'success' => true,
'data' => $result
]);
}
/**
* Get public availability - shows only available/not available status
* Does NOT expose any booking details, client info, or internal notes
* This is specifically for the public calendar
*/
function getPublicAvailability() {
global $db;
$slots = $db->getAll('availability_slots');
$bookings = $db->getAll('bookings');
$blockedTimes = $db->getAll('blocked_times');
$dateFrom = $_GET['date_from'] ?? date('Y-m-d');
$dateTo = $_GET['date_to'] ?? date('Y-m-d', strtotime('+60 days'));
// Filter slots within date range that are marked as available
$slots = array_filter($slots, function($slot) use ($dateFrom, $dateTo) {
return $slot['date'] >= $dateFrom && $slot['date'] <= $dateTo && $slot['is_available'];
});
$result = [];
foreach ($slots as $slot) {
// Count active bookings for this slot
$slotBookings = array_filter($bookings, function($booking) use ($slot) {
return $booking['booking_date'] === $slot['date'] &&
$booking['booking_time'] === $slot['time'] &&
in_array($booking['status'], ['confirmed', 'pending']);
});
// Check if time is blocked by admin
$isBlocked = false;
foreach ($blockedTimes as $blocked) {
if ($blocked['date'] === $slot['date'] && $blocked['time'] === $slot['time']) {
$isBlocked = true;
break;
}
}
$bookedCount = count($slotBookings);
$isFullyBooked = $bookedCount >= ($slot['capacity'] ?? 1);
// Only return minimal info - just what the public needs
$result[] = [
'date' => $slot['date'],
'time' => $slot['time'],
'available' => !$isFullyBooked && !$isBlocked,
// Don't expose: capacity, remaining, notes, booking details, etc.
];
}
usort($result, function($a, $b) {
return strtotime($a['date'] . ' ' . $a['time']) - strtotime($b['date'] . ' ' . $b['time']);
});
jsonResponse([
'success' => true,
'data' => $result
]);
}
function getUpcomingBookings() {
global $db;
requireAuth();
$bookings = $db->getAll('bookings');
$today = date('Y-m-d');
$upcoming = array_filter($bookings, function($b) use ($today) {
return $b['booking_date'] >= $today && in_array($b['status'], ['confirmed', 'pending']);
});
usort($upcoming, function($a, $b) {
return strtotime($a['booking_date'] . ' ' . $a['booking_time']) - strtotime($b['booking_date'] . ' ' . $b['booking_time']);
});
jsonResponse([
'success' => true,
'data' => array_slice(array_values($upcoming), 0, 10)
]);
}
/**
* Block a time slot (admin only)
* This makes a specific date/time unavailable without creating a booking
*/
function blockTime() {
global $db;
$data = getRequestBody();
if (empty($data['date']) || empty($data['time'])) {
jsonResponse(['success' => false, 'message' => 'Date and time are required'], 400);
}
$blockData = [
'date' => sanitize($data['date']),
'time' => sanitize($data['time']),
'reason' => sanitize($data['reason'] ?? ''),
'blocked_by' => 'admin',
'created_at' => date('Y-m-d H:i:s')
];
$blocked = $db->insert('blocked_times', $blockData);
jsonResponse([
'success' => true,
'message' => 'Time blocked successfully',
'data' => $blocked
], 201);
}
/**
* Get all blocked times (admin only)
*/
function getBlockedTimes() {
global $db;
$blockedTimes = $db->getAll('blocked_times');
// Sort by date and time
usort($blockedTimes, function($a, $b) {
return strtotime($a['date'] . ' ' . $a['time']) - strtotime($b['date'] . ' ' . $b['time']);
});
jsonResponse([
'success' => true,
'data' => $blockedTimes
]);
}
/**
* Delete a blocked time (admin only)
*/
function deleteBlockedTime($id) {
global $db;
$existing = $db->get('blocked_times', $id);
if (!$existing) {
jsonResponse(['success' => false, 'message' => 'Blocked time not found'], 404);
}
$db->delete('blocked_times', $id);
jsonResponse([
'success' => true,
'message' => 'Blocked time removed successfully'
]);
}
function createAvailabilitySlot() {
global $db;
$data = getRequestBody();
if (empty($data['date']) || empty($data['time'])) {
jsonResponse(['success' => false, 'message' => 'Date and time are required'], 400);
}
$slotData = [
'date' => sanitize($data['date']),
'time' => sanitize($data['time']),
'capacity' => (int)($data['capacity'] ?? 1),
'is_available' => true,
'notes' => sanitize($data['notes'] ?? '')
];
$slot = $db->insert('availability_slots', $slotData);
jsonResponse([
'success' => true,
'message' => 'Availability slot created',
'data' => $slot
], 201);
}
function createBooking($isPublicRequest = false) {
global $db;
if ($isPublicRequest) {
requireSameOriginRequest();
// Rate limiting: max 5 booking requests per IP per hour
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
$oneHourAgo = date('Y-m-d H:i:s', strtotime('-1 hour'));
$now = date('Y-m-d H:i:s');
$recentCount = $db->countByDateRange('bookings', 'created_at', $oneHourAgo, $now, [
'ip_address' => $ip
]);
if ($recentCount >= 5) {
jsonResponse(['success' => false, 'message' => 'Too many booking requests. Please try again later.'], 429);
}
}
$data = getRequestBody();
$required = ['first_name', 'last_name', 'email', 'booking_date', 'booking_time'];
foreach ($required as $field) {
if (empty($data[$field])) {
jsonResponse(['success' => false, 'message' => ucfirst(str_replace('_', ' ', $field)) . ' is required'], 400);
}
}
if (!filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
jsonResponse(['success' => false, 'message' => 'Invalid email address'], 400);
}
$bookingDate = date('Y-m-d', strtotime($data['booking_date']));
$today = date('Y-m-d');
if ($bookingDate < $today) {
jsonResponse(['success' => false, 'message' => 'Cannot book appointments in the past'], 400);
}
$slots = $db->query('availability_slots', [
'date' => $bookingDate,
'time' => $data['booking_time']
]);
if (empty($slots) || !$slots[array_key_first($slots)]['is_available']) {
jsonResponse(['success' => false, 'message' => 'This time slot is not available'], 400);
}
$slot = array_values($slots)[0];
$bookings = $db->query('bookings', [
'booking_date' => $bookingDate,
'booking_time' => $data['booking_time']
]);
$activeBookings = array_filter($bookings, function($b) {
return in_array($b['status'], ['confirmed', 'pending']);
});
if (count($activeBookings) >= $slot['capacity']) {
jsonResponse(['success' => false, 'message' => 'This time slot is fully booked'], 400);
}
$bookingData = [
'first_name' => sanitize($data['first_name']),
'last_name' => sanitize($data['last_name']),
'email' => sanitize($data['email']),
'phone' => sanitize($data['phone'] ?? ''),
'company' => sanitize($data['company'] ?? ''),
'service_interest' => sanitize($data['service_interest'] ?? ''),
'booking_date' => $bookingDate,
'booking_time' => sanitize($data['booking_time']),
'duration' => (int)($data['duration'] ?? 60),
'message' => sanitize($data['message'] ?? ''),
'status' => 'pending',
'created_at' => date('Y-m-d H:i:s'),
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? ''
];
$booking = $db->insert('bookings', $bookingData);
sendBookingConfirmationEmail($bookingData);
sendAdminBookingNotification($bookingData);
jsonResponse([
'success' => true,
'message' => 'Booking request submitted successfully. We will confirm your appointment shortly.',
'data' => [
'id' => $booking['id'],
'date' => $booking['booking_date'],
'time' => $booking['booking_time'],
'status' => 'pending'
]
], 201);
}
function updateBooking($id) {
global $db;
$existing = $db->get('bookings', $id);
if (!$existing) {
jsonResponse(['success' => false, 'message' => 'Booking not found'], 404);
}
$data = getRequestBody();
$allowed = ['status', 'notes'];
$updateData = [];
foreach ($allowed as $field) {
if (isset($data[$field])) {
$updateData[$field] = $data[$field];
}
}
if (!empty($data['status']) && $data['status'] === 'confirmed' && $existing['status'] !== 'confirmed') {
sendBookingConfirmedEmail($existing);
}
if (!empty($data['status']) && $data['status'] === 'cancelled' && $existing['status'] !== 'cancelled') {
sendBookingCancelledEmail($existing);
}
$booking = $db->update('bookings', $id, $updateData);
jsonResponse([
'success' => true,
'message' => 'Booking updated successfully',
'data' => $booking
]);
}
function deleteBooking($id) {
global $db;
$existing = $db->get('bookings', $id);
if (!$existing) {
jsonResponse(['success' => false, 'message' => 'Booking not found'], 404);
}
$db->delete('bookings', $id);
jsonResponse([
'success' => true,
'message' => 'Booking deleted successfully'
]);
}
function sendBookingConfirmationEmail($booking) {
$to = $booking['email'];
$subject = 'Booking Confirmation - ' . SITE_NAME;
$date = date('F j, Y', strtotime($booking['booking_date']));
$time = date('g:i A', strtotime($booking['booking_time']));
$plain = "Thank you for booking a consultation with MSPE!\n\n";
$plain .= "Booking Details:\n";
$plain .= "Date: {$date}\n";
$plain .= "Time: {$time}\n";
$plain .= "Duration: {$booking['duration']} minutes\n\n";
$plain .= "Your booking is currently pending confirmation. We will send you a confirmation email shortly.\n\n";
$plain .= "If you need to reschedule or cancel, please contact us at " . ADMIN_EMAIL . "\n\n";
$plain .= "Best regards,\nThe MSPE Team";
$html = '<h2>Booking Received</h2>'
. '<p>Thank you for booking a consultation with MSPE!</p>'
. '<p><strong>Date:</strong> ' . $date . '</p>'
. '<p><strong>Time:</strong> ' . $time . '</p>'
. '<p><strong>Duration:</strong> ' . $booking['duration'] . ' minutes</p>'
. '<p>Your booking is currently pending confirmation.</p>';
$result = sendEmail($to, $subject, $html, $plain);
if (!$result['success']) {
error_log('MSPE booking confirmation email failed: ' . ($result['message'] ?? 'unknown'));
}
}
function sendBookingConfirmedEmail($booking) {
$to = $booking['email'];
$subject = 'Booking Confirmed - ' . SITE_NAME;
$date = date('F j, Y', strtotime($booking['booking_date']));
$time = date('g:i A', strtotime($booking['booking_time']));
$plain = "Your consultation with MSPE has been confirmed!\n\n";
$plain .= "Booking Details:\n";
$plain .= "Date: {$date}\n";
$plain .= "Time: {$time}\n";
$plain .= "Duration: {$booking['duration']} minutes\n\n";
$plain .= "We look forward to speaking with you!\n\n";
$plain .= "If you need to reschedule, please contact us at " . ADMIN_EMAIL . "\n\n";
$plain .= "Best regards,\nThe MSPE Team";
$html = '<h2>Booking Confirmed!</h2>'
. '<p>Your consultation with MSPE has been confirmed.</p>'
. '<p><strong>Date:</strong> ' . $date . '</p>'
. '<p><strong>Time:</strong> ' . $time . '</p>'
. '<p><strong>Duration:</strong> ' . $booking['duration'] . ' minutes</p>'
. '<p>We look forward to speaking with you!</p>';
$result = sendEmail($to, $subject, $html, $plain);
if (!$result['success']) {
error_log('MSPE booking confirmed email failed: ' . ($result['message'] ?? 'unknown'));
}
}
function sendBookingCancelledEmail($booking) {
$to = $booking['email'];
$subject = 'Booking Cancelled - ' . SITE_NAME;
$plain = "Your consultation with MSPE has been cancelled.\n\n";
$plain .= "We apologize for any inconvenience. If you would like to reschedule, please contact us at " . ADMIN_EMAIL . "\n\n";
$plain .= "Best regards,\nThe MSPE Team";
$html = '<h2>Booking Cancelled</h2>'
. '<p>Your consultation with MSPE has been cancelled.</p>'
. '<p>If you would like to reschedule, please contact us at ' . ADMIN_EMAIL . '</p>';
$result = sendEmail($to, $subject, $html, $plain);
if (!$result['success']) {
error_log('MSPE booking cancelled email failed: ' . ($result['message'] ?? 'unknown'));
}
}
function sendAdminBookingNotification($booking) {
$to = getSetting('admin_email', ADMIN_EMAIL);
$subject = 'New Booking Request - ' . SITE_NAME;
$plain = "A new booking request has been submitted.\n\n";
$plain .= "Client Details:\n";
$plain .= "Name: {$booking['first_name']} {$booking['last_name']}\n";
$plain .= "Email: {$booking['email']}\n";
$plain .= "Phone: {$booking['phone']}\n";
$plain .= "Company: {$booking['company']}\n\n";
$plain .= "Booking Details:\n";
$plain .= "Date: {$booking['booking_date']}\n";
$plain .= "Time: {$booking['booking_time']}\n";
$plain .= "Duration: {$booking['duration']} minutes\n";
$plain .= "Service Interest: {$booking['service_interest']}\n";
$plain .= "Message: {$booking['message']}\n";
$html = '<h2>New Booking Request</h2>'
. '<p><strong>Name:</strong> ' . htmlspecialchars($booking['first_name'] . ' ' . $booking['last_name']) . '</p>'
. '<p><strong>Email:</strong> ' . htmlspecialchars($booking['email']) . '</p>'
. '<p><strong>Phone:</strong> ' . htmlspecialchars($booking['phone']) . '</p>'
. '<p><strong>Company:</strong> ' . htmlspecialchars($booking['company']) . '</p>'
. '<p><strong>Date:</strong> ' . $booking['booking_date'] . '</p>'
. '<p><strong>Time:</strong> ' . $booking['booking_time'] . '</p>'
. '<p><strong>Duration:</strong> ' . $booking['duration'] . ' minutes</p>'
. '<p><strong>Message:</strong><br>' . nl2br(htmlspecialchars($booking['message'])) . '</p>';
$result = sendEmail($to, $subject, $html, $plain, $booking['email']);
if (!$result['success']) {
error_log('MSPE admin booking notification failed: ' . ($result['message'] ?? 'unknown'));
}
}
+773
View File
@@ -0,0 +1,773 @@
<?php
require_once __DIR__ . '/../vendor/autoload.php';
use Firebase\JWT\JWT as FirebaseJWT;
use Firebase\JWT\Key;
/**
* MSPE Website Configuration
*
* Database and application settings
* Reads from .env file for production credentials
*/
// ── Load .env ────────────────────────────────────────────────
function loadEnv($path) {
if (!file_exists($path)) return;
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#') continue;
if (strpos($line, '=') === false) continue;
list($key, $value) = explode('=', $line, 2);
$key = trim($key);
$value = trim($value);
// Remove surrounding quotes
if ((strlen($value) > 1) && (($value[0] === '"' && substr($value, -1) === '"') || ($value[0] === "'" && substr($value, -1) === "'"))) {
$value = substr($value, 1, -1);
}
$_ENV[$key] = $value;
putenv("$key=$value");
}
}
// Try loading .env from project root, then from one level up (outside public_html)
loadEnv(__DIR__ . '/../.env');
loadEnv(dirname(__DIR__, 2) . '/.env');
// ── Helpers to read env values ───────────────────────────────
function env($key, $default = '') {
return $_ENV[$key] ?? getenv($key) ?: $default;
}
// ── Error reporting ──────────────────────────────────────────
$appEnv = env('APP_ENV', 'production');
if ($appEnv === 'production') {
error_reporting(0);
ini_set('display_errors', 0);
} else {
error_reporting(E_ALL);
ini_set('display_errors', 1);
}
// Timezone
date_default_timezone_set('UTC');
// ── Database Configuration ───────────────────────────────────
define('DB_TYPE', env('DB_TYPE', 'file'));
define('DB_HOST', env('DB_HOST', 'localhost'));
define('DB_NAME', env('DB_NAME', 'mspe_website'));
define('DB_USER', env('DB_USER', ''));
define('DB_PASS', env('DB_PASS', ''));
// ── Admin credentials ────────────────────────────────────────
define('ADMIN_USER', env('ADMIN_USER', 'admin'));
define('ADMIN_PASS', env('ADMIN_PASS', ''));
// ── JWT Secret ───────────────────────────────────────────────
define('JWT_SECRET', env('JWT_SECRET', 'change-me-in-env-file'));
if (JWT_SECRET === 'change-me-in-env-file' && $appEnv === 'production') {
http_response_code(500);
die(json_encode(['success' => false, 'message' => 'Server misconfigured']));
}
// ── Data directory (for JSON files, cache, etc.) ────────────
define('DATA_DIR', __DIR__ . '/../data/');
// ── Upload settings ──────────────────────────────────────────
define('UPLOAD_DIR', __DIR__ . '/../uploads/');
define('MAX_UPLOAD_SIZE', 5 * 1024 * 1024); // 5MB
define('ALLOWED_EXTENSIONS', ['jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf']);
// ── Site settings ────────────────────────────────────────────
define('SITE_NAME', env('SITE_NAME', 'MSPE'));
define('SITE_URL', env('SITE_URL', 'https://mspe.pro'));
define('ADMIN_EMAIL', env('ADMIN_EMAIL', 'info@mspe.pro'));
// ── SMTP settings (read from .env, used as defaults before admin settings override) ──
define('ENV_SMTP_HOST', env('SMTP_HOST', ''));
define('ENV_SMTP_PORT', env('SMTP_PORT', '465'));
define('ENV_SMTP_USER', env('SMTP_USER', ''));
define('ENV_SMTP_PASS', env('SMTP_PASS', ''));
define('ENV_SMTP_FROM_NAME', env('SMTP_FROM_NAME', 'MSPE'));
define('ENV_SMTP_FROM_EMAIL', env('SMTP_FROM_EMAIL', 'info@mspe.pro'));
// ── CORS Headers ─────────────────────────────────────────────
$allowedOrigins = [SITE_URL, 'https://www.mspe.pro'];
// Allow localhost for local development
if ($appEnv !== 'production') {
$allowedOrigins[] = 'http://localhost:8080';
$allowedOrigins[] = 'http://localhost:8000';
$allowedOrigins[] = 'http://127.0.0.1:8080';
}
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($origin, $allowedOrigins)) {
header('Access-Control-Allow-Origin: ' . $origin);
} else {
header('Access-Control-Allow-Origin: ' . SITE_URL);
}
header('Vary: Origin');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
header('Access-Control-Allow-Credentials: true');
header('Content-Type: application/json; charset=UTF-8');
// Handle preflight requests
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit();
}
// ── Security Headers ─────────────────────────────────────────
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: SAMEORIGIN');
header('X-XSS-Protection: 0');
header('Referrer-Policy: strict-origin-when-cross-origin');
header("Content-Security-Policy: default-src 'none'; frame-ancestors 'none'");
header('Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()');
if ((!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https') {
header('Strict-Transport-Security: max-age=31536000; includeSubDomains; preload');
}
/**
* MySQL Database Helper
*/
class MySQLDB {
private $pdo;
public function __construct() {
$dsn = 'mysql:host=' . DB_HOST . ';dbname=' . DB_NAME . ';charset=utf8mb4';
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
$this->pdo = new PDO($dsn, DB_USER, DB_PASS, $options);
}
/** Ensure the table exists (auto-create a generic key-value/json table) */
private function ensureTable($table) {
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
$this->pdo->exec("CREATE TABLE IF NOT EXISTS `$safe` (
`id` VARCHAR(64) NOT NULL PRIMARY KEY,
`data` JSON NOT NULL,
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
}
public function getAll($table) {
$this->ensureTable($table);
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
$stmt = $this->pdo->query("SELECT `data` FROM `$safe` ORDER BY `created_at` ASC");
$rows = $stmt->fetchAll();
return array_map(function($r) { return json_decode($r['data'], true); }, $rows);
}
/**
* Get records with SQL-level pagination (LIMIT/OFFSET).
* Returns ['data' => [...], 'total' => int].
*/
public function getAllPaginated($table, $limit = 50, $offset = 0, $orderDir = 'ASC') {
$this->ensureTable($table);
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
$dir = strtoupper($orderDir) === 'DESC' ? 'DESC' : 'ASC';
$total = (int)$this->pdo->query("SELECT COUNT(*) FROM `{$safe}`")->fetchColumn();
$stmt = $this->pdo->prepare("SELECT `data` FROM `{$safe}` ORDER BY `created_at` {$dir} LIMIT ? OFFSET ?");
$stmt->execute([(int)$limit, (int)$offset]);
$rows = $stmt->fetchAll();
return [
'data' => array_map(function($r) { return json_decode($r['data'], true); }, $rows),
'total' => $total
];
}
public function get($table, $id) {
$this->ensureTable($table);
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
$stmt = $this->pdo->prepare("SELECT `data` FROM `$safe` WHERE `id` = ? LIMIT 1");
$stmt->execute([$id]);
$row = $stmt->fetch();
return $row ? json_decode($row['data'], true) : null;
}
public function insert($table, $data) {
$this->ensureTable($table);
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
$data['id'] = $data['id'] ?? bin2hex(random_bytes(16));
$data['created_at'] = $data['created_at'] ?? date('Y-m-d H:i:s');
$data['updated_at'] = date('Y-m-d H:i:s');
$stmt = $this->pdo->prepare("INSERT INTO `$safe` (`id`, `data`, `created_at`, `updated_at`) VALUES (?, ?, ?, ?)");
$stmt->execute([$data['id'], json_encode($data, JSON_UNESCAPED_UNICODE), $data['created_at'], $data['updated_at']]);
return $data;
}
public function update($table, $id, $data) {
$existing = $this->get($table, $id);
if (!$existing) return null;
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
$data['id'] = $id;
$data['created_at'] = $existing['created_at'];
$data['updated_at'] = date('Y-m-d H:i:s');
$merged = array_merge($existing, $data);
$stmt = $this->pdo->prepare("UPDATE `$safe` SET `data` = ?, `updated_at` = ? WHERE `id` = ?");
$stmt->execute([json_encode($merged, JSON_UNESCAPED_UNICODE), $merged['updated_at'], $id]);
return $merged;
}
public function delete($table, $id) {
$this->ensureTable($table);
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
$stmt = $this->pdo->prepare("DELETE FROM `$safe` WHERE `id` = ?");
$stmt->execute([$id]);
return true;
}
public function query($table, $conditions = []) {
$this->ensureTable($table);
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
if (empty($conditions)) {
return $this->getAll($table);
}
// Use MySQL JSON_EXTRACT for server-side filtering when possible
$where = [];
$params = [];
foreach ($conditions as $key => $value) {
$safeKey = preg_replace('/[^a-zA-Z0-9_]/', '', $key);
$where[] = "JSON_UNQUOTE(JSON_EXTRACT(`data`, '$.{$safeKey}')) = ?";
$params[] = (string)$value;
}
$sql = "SELECT `data` FROM `{$safe}` WHERE " . implode(' AND ', $where);
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
$rows = $stmt->fetchAll();
return array_map(function($r) { return json_decode($r['data'], true); }, $rows);
}
/** Count rows matching conditions (avoids loading all data into memory) */
public function count($table, $conditions = []) {
$this->ensureTable($table);
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
if (empty($conditions)) {
return (int)$this->pdo->query("SELECT COUNT(*) FROM `{$safe}`")->fetchColumn();
}
$where = [];
$params = [];
foreach ($conditions as $key => $value) {
$safeKey = preg_replace('/[^a-zA-Z0-9_]/', '', $key);
$where[] = "JSON_UNQUOTE(JSON_EXTRACT(`data`, '$.{$safeKey}')) = ?";
$params[] = (string)$value;
}
$sql = "SELECT COUNT(*) FROM `{$safe}` WHERE " . implode(' AND ', $where);
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return (int)$stmt->fetchColumn();
}
/** Count rows where a JSON date-like field is between two bounds (inclusive) */
public function countByDateRange($table, $dateField, $fromValue, $toValue, $conditions = []) {
$this->ensureTable($table);
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
$safeDateField = preg_replace('/[^a-zA-Z0-9_]/', '', $dateField);
$where = [
"JSON_UNQUOTE(JSON_EXTRACT(`data`, '$.{$safeDateField}')) >= ?",
"JSON_UNQUOTE(JSON_EXTRACT(`data`, '$.{$safeDateField}')) <= ?"
];
$params = [(string)$fromValue, (string)$toValue];
foreach ($conditions as $key => $value) {
$safeKey = preg_replace('/[^a-zA-Z0-9_]/', '', $key);
$where[] = "JSON_UNQUOTE(JSON_EXTRACT(`data`, '$.{$safeKey}')) = ?";
$params[] = (string)$value;
}
$sql = "SELECT COUNT(*) FROM `{$safe}` WHERE " . implode(' AND ', $where);
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return (int)$stmt->fetchColumn();
}
/** Count rows where a single JSON field matches a comparison operator */
public function countByFieldComparison($table, $field, $operator, $value, $conditions = []) {
$this->ensureTable($table);
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
$safeField = preg_replace('/[^a-zA-Z0-9_]/', '', $field);
$allowedOps = ['=', '!=', '>', '>=', '<', '<='];
$op = in_array($operator, $allowedOps, true) ? $operator : '=';
$where = ["JSON_UNQUOTE(JSON_EXTRACT(`data`, '$.{$safeField}')) {$op} ?"];
$params = [(string)$value];
foreach ($conditions as $key => $condValue) {
$safeKey = preg_replace('/[^a-zA-Z0-9_]/', '', $key);
$where[] = "JSON_UNQUOTE(JSON_EXTRACT(`data`, '$.{$safeKey}')) = ?";
$params[] = (string)$condValue;
}
$sql = "SELECT COUNT(*) FROM `{$safe}` WHERE " . implode(' AND ', $where);
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return (int)$stmt->fetchColumn();
}
}
/**
* Simple JWT Helper
*/
class JWT {
public static function encode($payload, $ttlSeconds = 86400, $secret = JWT_SECRET) {
$payload['exp'] = time() + max(300, (int)$ttlSeconds);
return FirebaseJWT::encode($payload, $secret, 'HS256');
}
public static function decode($token, $secret = JWT_SECRET) {
try {
$decoded = FirebaseJWT::decode($token, new Key($secret, 'HS256'));
return (array) $decoded;
} catch (Exception $e) {
return null;
}
}
}
/**
* Authentication Check
*/
function checkAuth() {
// Prefer HttpOnly cookie token for admin authentication
$cookieToken = $_COOKIE['mspe_admin_token'] ?? '';
if (is_string($cookieToken) && $cookieToken !== '') {
$payload = JWT::decode($cookieToken);
if ($payload) {
return $payload;
}
}
// Fallback: Authorization header (for API clients)
$authHeader = '';
if (function_exists('getallheaders')) {
$headers = getallheaders();
$authHeader = $headers['Authorization'] ?? $headers['authorization'] ?? '';
}
if ($authHeader === '' && isset($_SERVER['HTTP_AUTHORIZATION'])) {
$authHeader = $_SERVER['HTTP_AUTHORIZATION'];
}
if (preg_match('/Bearer\s+(.*)$/i', $authHeader, $matches)) {
$payload = JWT::decode($matches[1]);
if ($payload) {
return $payload;
}
}
return null;
}
/**
* Require Authentication
*/
function requireAuth() {
$user = checkAuth();
if (!$user) {
http_response_code(401);
echo json_encode(['success' => false, 'message' => 'Unauthorized']);
exit();
}
return $user;
}
/**
* JSON Response Helper
*/
function jsonResponse($data, $code = 200) {
http_response_code($code);
echo json_encode($data);
exit();
}
/**
* Sanitize Input (HTML Context)
* Note: For JavaScript context, use json_encode().
* For URL context, use urlencode().
*/
function sanitize($input) {
if (is_array($input)) {
return array_map('sanitize', $input);
}
// ENT_QUOTES | ENT_SUBSTITUTE handles both single/double quotes and invalid characters
return htmlspecialchars(trim($input ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
/**
* Sanitize Rich Text (HTML Context)
* Allows only a whitelist of safe formatting tags and strips everything else.
* Removes all event handler attributes (onclick, onerror, etc.) from allowed tags.
*/
function sanitizeRichText($input) {
if (!is_string($input) || trim($input) === '') return '';
// Allow only safe formatting tags
$allowed = '<b><strong><i><em><u><br><p><ul><ol><li><a><span><h3><h4><h5><h6><sub><sup><blockquote>';
$cleaned = strip_tags(trim($input), $allowed);
// Remove any event handler attributes (on*) and dangerous attributes
$cleaned = preg_replace('/\s+on\w+\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]+)/i', '', $cleaned);
// Remove javascript: and data: from href/src attributes
$cleaned = preg_replace('/\b(href|src)\s*=\s*(?:"(?:javascript|data):.*?"|\'(?:javascript|data):.*?\')/i', '$1=""', $cleaned);
// Remove style attributes that could contain expressions
$cleaned = preg_replace('/\s+style\s*=\s*(?:"[^"]*"|\'[^\']*\')/i', '', $cleaned);
return $cleaned;
}
/**
* Get Request Body
*/
function getRequestBody() {
return json_decode(file_get_contents('php://input'), true) ?? [];
}
/**
* Basic CSRF mitigation for public POST endpoints.
* Allows only same-origin browser requests by validating Origin/Referer.
*/
function requireSameOriginRequest() {
$appEnv = env('APP_ENV', 'production');
$allowedOrigins = [
rtrim((string)SITE_URL, '/'),
'https://www.mspe.pro'
];
if ($appEnv !== 'production') {
$allowedOrigins[] = 'http://localhost:8080';
$allowedOrigins[] = 'http://localhost:8000';
$allowedOrigins[] = 'http://127.0.0.1:8080';
}
$origin = trim((string)($_SERVER['HTTP_ORIGIN'] ?? ''));
if ($origin !== '') {
if (in_array(rtrim($origin, '/'), $allowedOrigins, true)) {
return;
}
jsonResponse(['success' => false, 'message' => 'Forbidden origin'], 403);
}
$referer = trim((string)($_SERVER['HTTP_REFERER'] ?? ''));
if ($referer !== '') {
foreach ($allowedOrigins as $allowedOrigin) {
if (stripos($referer, $allowedOrigin . '/') === 0 || rtrim($referer, '/') === $allowedOrigin) {
return;
}
}
jsonResponse(['success' => false, 'message' => 'Forbidden referer'], 403);
}
// Browser request with neither header is suspicious for public form submissions.
jsonResponse(['success' => false, 'message' => 'CSRF validation failed'], 403);
}
/**
* Read settings as key => value map
*/
function getSettingsMap() {
global $db;
$result = [];
$rows = $db->getAll('settings');
foreach ($rows as $row) {
if (isset($row['key'])) {
$result[$row['key']] = $row['value'] ?? '';
}
}
return $result;
}
/**
* Get a single setting with fallback
*/
function getSetting($key, $default = null) {
$settings = getSettingsMap();
return array_key_exists($key, $settings) ? $settings[$key] : $default;
}
/**
* Send email (mail() or SMTP based on admin settings)
*/
function sendEmail($to, $subject, $htmlBody, $plainBody = '', $replyTo = null) {
// Determine transport: admin setting → auto-detect from .env
$defaultTransport = (ENV_SMTP_HOST !== '') ? 'smtp' : 'mail';
$transport = strtolower((string)getSetting('email_transport', $defaultTransport));
$fromEmail = trim((string)getSetting('smtp_from_email', ENV_SMTP_FROM_EMAIL ?: ADMIN_EMAIL));
if ($fromEmail === '') {
$fromEmail = ADMIN_EMAIL;
}
$fromName = trim((string)getSetting('smtp_from_name', ENV_SMTP_FROM_NAME ?: SITE_NAME));
if ($fromName === '') {
$fromName = SITE_NAME;
}
if ($plainBody === '') {
$plainBody = trim(strip_tags(str_replace(['<br>', '<br/>', '<br />'], "\n", $htmlBody)));
}
if ($transport === 'smtp') {
// Admin settings override .env values
$smtpHost = trim((string)getSetting('smtp_host', ENV_SMTP_HOST));
$smtpPort = (int)getSetting('smtp_port', ENV_SMTP_PORT ?: 465);
$smtpEncryption = strtolower((string)getSetting('smtp_encryption', ((int)($smtpPort) === 465 ? 'ssl' : 'tls')));
$smtpUser = trim((string)getSetting('smtp_username', ENV_SMTP_USER));
$smtpPass = (string)getSetting('smtp_password', ENV_SMTP_PASS);
if ($smtpHost !== '' && $smtpPort > 0 && $smtpUser !== '' && $smtpPass !== '') {
return sendEmailViaSmtp([
'host' => $smtpHost,
'port' => $smtpPort,
'encryption' => $smtpEncryption,
'username' => $smtpUser,
'password' => $smtpPass,
'from_email' => $fromEmail,
'from_name' => $fromName,
'to' => $to,
'subject' => $subject,
'html' => $htmlBody,
'plain' => $plainBody,
'reply_to' => $replyTo
]);
}
}
// Fallback to PHP mail()
$boundary = 'mspe_' . md5((string)microtime(true));
$headers = [];
$headers[] = 'MIME-Version: 1.0';
$headers[] = 'From: ' . formatEmailAddress($fromName, $fromEmail);
if ($replyTo) {
$headers[] = 'Reply-To: ' . $replyTo;
}
$headers[] = 'Content-Type: multipart/alternative; boundary="' . $boundary . '"';
$body = "--{$boundary}\r\n";
$body .= "Content-Type: text/plain; charset=UTF-8\r\n\r\n";
$body .= $plainBody . "\r\n\r\n";
$body .= "--{$boundary}\r\n";
$body .= "Content-Type: text/html; charset=UTF-8\r\n\r\n";
$body .= $htmlBody . "\r\n\r\n";
$body .= "--{$boundary}--\r\n";
$ok = @mail($to, $subject, $body, implode("\r\n", $headers));
return [
'success' => $ok,
'message' => $ok ? 'Sent via mail()' : 'mail() failed'
];
}
function sendEmailViaSmtp($payload) {
$host = $payload['host'];
$port = (int)$payload['port'];
$encryption = $payload['encryption'];
$remote = $encryption === 'ssl' ? 'ssl://' . $host : $host;
$socket = @stream_socket_client($remote . ':' . $port, $errno, $errstr, 20, STREAM_CLIENT_CONNECT);
if (!$socket) {
return ['success' => false, 'message' => 'SMTP connect failed: ' . $errstr];
}
stream_set_timeout($socket, 20);
$expect = function($codes) use ($socket) {
$response = '';
while (($line = fgets($socket, 515)) !== false) {
$response .= $line;
if (preg_match('/^\d{3}\s/', $line)) {
break;
}
}
$code = (int)substr($response, 0, 3);
if (!in_array($code, (array)$codes, true)) {
throw new Exception(trim($response));
}
return $response;
};
$send = function($command) use ($socket) {
fwrite($socket, $command . "\r\n");
};
try {
$expect([220]);
$send('EHLO ' . ($_SERVER['SERVER_NAME'] ?? parse_url(SITE_URL, PHP_URL_HOST) ?? 'mspe.pro'));
$expect([250]);
if ($encryption === 'tls') {
$send('STARTTLS');
$expect([220]);
if (!stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
throw new Exception('Failed to start TLS encryption');
}
$send('EHLO ' . ($_SERVER['SERVER_NAME'] ?? parse_url(SITE_URL, PHP_URL_HOST) ?? 'mspe.pro'));
$expect([250]);
}
$send('AUTH LOGIN');
$expect([334]);
$send(base64_encode($payload['username']));
$expect([334]);
$send(base64_encode($payload['password']));
$expect([235]);
$send('MAIL FROM:<' . $payload['from_email'] . '>');
$expect([250]);
$send('RCPT TO:<' . $payload['to'] . '>');
$expect([250, 251]);
$send('DATA');
$expect([354]);
$boundary = 'mspe_' . md5((string)microtime(true));
$headers = [];
$headers[] = 'From: ' . formatEmailAddress($payload['from_name'], $payload['from_email']);
$headers[] = 'To: ' . $payload['to'];
$headers[] = 'Subject: ' . $payload['subject'];
$headers[] = 'MIME-Version: 1.0';
if (!empty($payload['reply_to'])) {
$headers[] = 'Reply-To: ' . $payload['reply_to'];
}
$headers[] = 'Content-Type: multipart/alternative; boundary="' . $boundary . '"';
$message = implode("\r\n", $headers) . "\r\n\r\n";
$message .= "--{$boundary}\r\n";
$message .= "Content-Type: text/plain; charset=UTF-8\r\n\r\n";
$message .= $payload['plain'] . "\r\n\r\n";
$message .= "--{$boundary}\r\n";
$message .= "Content-Type: text/html; charset=UTF-8\r\n\r\n";
$message .= $payload['html'] . "\r\n\r\n";
$message .= "--{$boundary}--\r\n.";
fwrite($socket, $message . "\r\n");
$expect([250]);
$send('QUIT');
fclose($socket);
return ['success' => true, 'message' => 'Sent via SMTP'];
} catch (Exception $e) {
fclose($socket);
return ['success' => false, 'message' => 'SMTP send failed: ' . $e->getMessage()];
}
}
function formatEmailAddress($name, $email) {
$cleanName = str_replace(['"', "\r", "\n"], '', $name);
$cleanEmail = str_replace(["\r", "\n"], '', $email);
return sprintf('"%s" <%s>', $cleanName, $cleanEmail);
}
/**
* Handle File Upload
*/
function handleFileUpload($file, $subdir = '') {
if (!isset($file['tmp_name']) || !is_uploaded_file($file['tmp_name'])) {
return ['success' => false, 'message' => 'No file uploaded'];
}
// Check file size
if ($file['size'] > MAX_UPLOAD_SIZE) {
return ['success' => false, 'message' => 'File too large'];
}
// Check extension
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if (!in_array($ext, ALLOWED_EXTENSIONS)) {
return ['success' => false, 'message' => 'File type not allowed'];
}
// Validate MIME type from file contents (not just extension)
$allowedMimeByExt = [
'jpg' => ['image/jpeg'],
'jpeg' => ['image/jpeg'],
'png' => ['image/png'],
'gif' => ['image/gif'],
'webp' => ['image/webp'],
'pdf' => ['application/pdf']
];
if (function_exists('finfo_open')) {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$detectedMime = $finfo ? finfo_file($finfo, $file['tmp_name']) : false;
if ($finfo) {
finfo_close($finfo);
}
$allowedMimes = $allowedMimeByExt[$ext] ?? [];
if (!$detectedMime || !in_array($detectedMime, $allowedMimes, true)) {
return ['success' => false, 'message' => 'Invalid file content type'];
}
}
// Create upload directory
$uploadDir = UPLOAD_DIR . ($subdir ? $subdir . '/' : '');
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
// Generate unique filename (cryptographically random)
$filename = bin2hex(random_bytes(16)) . '_' . preg_replace('/[^a-zA-Z0-9.]/', '', $file['name']);
$filepath = $uploadDir . $filename;
if (move_uploaded_file($file['tmp_name'], $filepath)) {
return [
'success' => true,
'filename' => $filename,
'path' => 'uploads/' . ($subdir ? $subdir . '/' : '') . $filename
];
}
return ['success' => false, 'message' => 'Failed to save file'];
}
// ── Security Audit Logging ─────────────────────────────────────
function auditLog($event, $details = [], $userId = null) {
$entry = [
'timestamp' => date('c'),
'event' => $event,
'ip' => $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0',
'user_id' => $userId,
'uri' => $_SERVER['REQUEST_URI'] ?? '',
'method' => $_SERVER['REQUEST_METHOD'] ?? '',
'details' => $details
];
error_log('[MSPE_AUDIT] ' . json_encode($entry, JSON_UNESCAPED_SLASHES));
}
// Initialize database based on DB_TYPE from .env
if (DB_TYPE === 'mysql') {
try {
$db = new MySQLDB();
} catch (Exception $e) {
error_log('MSPE MySQL connection failed: ' . $e->getMessage());
http_response_code(500);
die(json_encode(['success' => false, 'message' => 'Database connection failed']));
}
} else {
http_response_code(500);
die(json_encode(['success' => false, 'message' => 'Only MySQL is supported in production']));
}
+287
View File
@@ -0,0 +1,287 @@
<?php
/**
* MSPE Contact Form API
*/
require_once 'config.php';
$method = $_SERVER['REQUEST_METHOD'];
$id = $_GET['id'] ?? null;
switch ($method) {
case 'GET':
requireAuth();
if ($id) {
getMessage($id);
} else {
getMessages();
}
break;
case 'POST':
submitMessage();
break;
case 'PUT':
requireAuth();
if (!$id) {
jsonResponse(['success' => false, 'message' => 'Message ID required'], 400);
}
updateMessage($id);
break;
case 'DELETE':
requireAuth();
if (!$id) {
jsonResponse(['success' => false, 'message' => 'Message ID required'], 400);
}
deleteMessage($id);
break;
default:
jsonResponse(['success' => false, 'message' => 'Method not allowed'], 405);
}
function getMessages() {
global $db;
$messages = $db->getAll('messages');
// Apply filters
$status = $_GET['status'] ?? null;
$limit = (int)($_GET['limit'] ?? 20);
$offset = (int)($_GET['offset'] ?? 0);
if ($status) {
$messages = array_filter($messages, function($m) use ($status) {
return $m['status'] === $status;
});
}
// Sort by date (newest first)
usort($messages, function($a, $b) {
return strtotime($b['created_at']) - strtotime($a['created_at']);
});
$total = count($messages);
$unread = count(array_filter($messages, function($m) {
return ($m['status'] ?? 'unread') === 'unread';
}));
$messages = array_slice(array_values($messages), $offset, $limit);
jsonResponse([
'success' => true,
'data' => $messages,
'total' => $total,
'unread' => $unread,
'limit' => $limit,
'offset' => $offset
]);
}
function getMessage($id) {
global $db;
$message = $db->get('messages', $id);
if (!$message) {
jsonResponse(['success' => false, 'message' => 'Message not found'], 404);
}
// Mark as read
if (($message['status'] ?? 'unread') === 'unread') {
$db->update('messages', $id, ['status' => 'read']);
$message['status'] = 'read';
}
jsonResponse([
'success' => true,
'data' => $message
]);
}
function submitMessage() {
global $db;
requireSameOriginRequest();
// Rate limiting: max 5 submissions per IP per hour
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
$oneHourAgo = date('Y-m-d H:i:s', strtotime('-1 hour'));
$now = date('Y-m-d H:i:s');
$recentCount = $db->countByDateRange('messages', 'created_at', $oneHourAgo, $now, [
'ip_address' => $ip
]);
if ($recentCount >= 5) {
jsonResponse(['success' => false, 'message' => 'Too many submissions. Please try again later.'], 429);
}
$data = getRequestBody();
// ── Cloudflare Turnstile verification (active only when TURNSTILE_SECRET_KEY is set) ──
$turnstileSecret = env('TURNSTILE_SECRET_KEY', '');
if ($turnstileSecret !== '') {
$turnstileToken = $data['cf-turnstile-response'] ?? '';
if (empty($turnstileToken)) {
jsonResponse(['success' => false, 'message' => 'Human verification is required.'], 400);
}
if (function_exists('curl_init')) {
$ch = curl_init('https://challenges.cloudflare.com/turnstile/v0/siteverify');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POSTFIELDS => http_build_query([
'secret' => $turnstileSecret,
'response' => $turnstileToken,
'remoteip' => $_SERVER['REMOTE_ADDR'] ?? ''
])
]);
$tvResult = json_decode(curl_exec($ch), true);
curl_close($ch);
if (!($tvResult['success'] ?? false)) {
jsonResponse(['success' => false, 'message' => 'Human verification failed. Please try again.'], 400);
}
}
}
// Validate required fields
$required = ['first_name', 'last_name', 'email', 'message'];
foreach ($required as $field) {
if (empty($data[$field])) {
jsonResponse(['success' => false, 'message' => ucfirst(str_replace('_', ' ', $field)) . ' is required'], 400);
}
}
// Validate email
if (!filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
jsonResponse(['success' => false, 'message' => 'Invalid email address'], 400);
}
// Honeypot check (if implemented in form)
if (!empty($data['website'])) {
// Likely a bot
jsonResponse(['success' => true, 'message' => 'Message sent successfully']);
}
// Prepare message data
$messageData = [
'first_name' => sanitize($data['first_name']),
'last_name' => sanitize($data['last_name']),
'email' => sanitize($data['email']),
'phone' => sanitize($data['phone'] ?? ''),
'company' => sanitize($data['company'] ?? ''),
'service' => sanitize($data['service'] ?? ''),
'budget' => sanitize($data['budget'] ?? ''),
'message' => sanitize($data['message']),
'newsletter' => $data['newsletter'] ?? false,
'status' => 'unread',
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? '',
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? ''
];
$message = $db->insert('messages', $messageData);
// Send email notification (optional)
sendNotificationEmail($messageData);
// Handle newsletter subscription
if (!empty($data['newsletter'])) {
subscribeNewsletter($data['email'], $data['first_name'] . ' ' . $data['last_name']);
}
jsonResponse([
'success' => true,
'message' => 'Thank you for your message! We\'ll be in touch soon.'
], 201);
}
function updateMessage($id) {
global $db;
$existing = $db->get('messages', $id);
if (!$existing) {
jsonResponse(['success' => false, 'message' => 'Message not found'], 404);
}
$data = getRequestBody();
$allowed = ['status', 'notes'];
$updateData = [];
foreach ($allowed as $field) {
if (isset($data[$field])) {
$updateData[$field] = $data[$field];
}
}
$message = $db->update('messages', $id, $updateData);
jsonResponse([
'success' => true,
'message' => 'Message updated successfully',
'data' => $message
]);
}
function deleteMessage($id) {
global $db;
$existing = $db->get('messages', $id);
if (!$existing) {
jsonResponse(['success' => false, 'message' => 'Message not found'], 404);
}
$db->delete('messages', $id);
jsonResponse([
'success' => true,
'message' => 'Message deleted successfully'
]);
}
function sendNotificationEmail($data) {
$to = getSetting('admin_email', getSetting('contact_email', ADMIN_EMAIL));
$subject = 'New Contact Form Submission - ' . SITE_NAME;
$plain = "You have received a new message from your website contact form.\n\n";
$plain .= "Name: {$data['first_name']} {$data['last_name']}\n";
$plain .= "Email: {$data['email']}\n";
$plain .= "Phone: {$data['phone']}\n";
$plain .= "Company: {$data['company']}\n";
$plain .= "Service Interest: {$data['service']}\n";
$plain .= "Budget: {$data['budget']}\n\n";
$plain .= "Message:\n{$data['message']}\n";
$html = '<h2>New Contact Form Submission</h2>'
. '<p><strong>Name:</strong> ' . htmlspecialchars($data['first_name'] . ' ' . $data['last_name']) . '</p>'
. '<p><strong>Email:</strong> ' . htmlspecialchars($data['email']) . '</p>'
. '<p><strong>Phone:</strong> ' . htmlspecialchars($data['phone']) . '</p>'
. '<p><strong>Company:</strong> ' . htmlspecialchars($data['company']) . '</p>'
. '<p><strong>Service Interest:</strong> ' . htmlspecialchars($data['service']) . '</p>'
. '<p><strong>Budget:</strong> ' . htmlspecialchars($data['budget']) . '</p>'
. '<p><strong>Message:</strong><br>' . nl2br(htmlspecialchars($data['message'])) . '</p>';
$result = sendEmail($to, $subject, $html, $plain, $data['email']);
if (!$result['success']) {
error_log('MSPE contact email failed: ' . ($result['message'] ?? 'unknown'));
}
}
function subscribeNewsletter($email, $name) {
global $db;
// Check if already subscribed
$existing = $db->query('subscribers', ['email' => $email]);
if (!empty($existing)) {
return;
}
$db->insert('subscribers', [
'email' => $email,
'name' => $name,
'status' => 'active'
]);
}
+168
View File
@@ -0,0 +1,168 @@
<?php
/**
* MSPE Media API
*/
require_once 'config.php';
$method = $_SERVER['REQUEST_METHOD'];
$id = $_GET['id'] ?? null;
// Require auth for all media operations
requireAuth();
// CSRF protection for write operations
if ($method !== 'GET') {
requireSameOriginRequest();
}
switch ($method) {
case 'GET':
getMedia();
break;
case 'POST':
if (isset($_GET['action']) && $_GET['action'] === 'delete') {
deleteMediaBatch();
} else {
uploadMedia();
}
break;
case 'DELETE':
if (!$id) {
jsonResponse(['success' => false, 'message' => 'ID required'], 400);
}
deleteMedia($id);
break;
default:
jsonResponse(['success' => false, 'message' => 'Method not allowed'], 405);
}
function getMedia() {
global $db;
// For a file-based system without a dedicated media DB table,
// we can scan the uploads directory.
// However, keeping a record in JSON is better for metadata.
// Let's assume we have a 'media' table.
$media = $db->getAll('media');
// Sync with filesystem?
// For simplicity, let's just return DB records.
// If empty, one could scan dir, but we'll rely on upload recording.
$folder = $_GET['folder'] ?? null;
$type = $_GET['type'] ?? null;
$search = $_GET['search'] ?? null;
if ($folder) {
$media = array_filter($media, function($m) use ($folder) {
return ($m['folder'] ?? '') === $folder;
});
}
if ($type) {
$media = array_filter($media, function($m) use ($type) {
return strpos($m['type'], $type) !== false;
});
}
if ($search) {
$media = array_filter($media, function($m) use ($search) {
return stripos($m['name'], $search) !== false;
});
}
// Sort recent first
usort($media, function($a, $b) {
return strtotime($b['created_at']) - strtotime($a['created_at']);
});
jsonResponse(['success' => true, 'data' => array_values($media)]);
}
function uploadMedia() {
global $db;
if (empty($_FILES)) {
jsonResponse(['success' => false, 'message' => 'No files uploaded'], 400);
}
$folder = $_POST['folder'] ?? '';
$uploaded = [];
foreach ($_FILES as $key => $file) {
// Handle array uploads (multiple files)
if (is_array($file['name'])) {
foreach ($file['name'] as $idx => $name) {
$singleFile = [
'name' => $name,
'type' => $file['type'][$idx],
'tmp_name' => $file['tmp_name'][$idx],
'error' => $file['error'][$idx],
'size' => $file['size'][$idx]
];
$result = processUpload($singleFile, $folder);
if ($result) $uploaded[] = $result;
}
} else {
$result = processUpload($file, $folder);
if ($result) $uploaded[] = $result;
}
}
jsonResponse(['success' => true, 'data' => $uploaded]);
}
function processUpload($file, $folder) {
global $db;
$res = handleFileUpload($file, $folder);
if ($res['success']) {
$mediaItem = [
'name' => $file['name'],
'path' => $res['path'], // Relative path
'full_url' => SITE_URL . '/' . $res['path'],
'type' => $file['type'],
'size' => $file['size'],
'folder' => $folder,
'created_at' => date('Y-m-d H:i:s')
];
return $db->insert('media', $mediaItem);
}
return null;
}
function deleteMedia($id) {
deleteMediaInternal($id);
jsonResponse(['success' => true, 'message' => 'File deleted']);
}
function deleteMediaInternal($id) {
global $db;
$item = $db->get('media', $id);
if ($item) {
// Delete physical file
$filepath = __DIR__ . '/../' . $item['path'];
if (file_exists($filepath)) {
unlink($filepath);
}
$db->delete('media', $id);
return true;
}
return false;
}
function deleteMediaBatch() {
$ids = getRequestBody()['ids'] ?? [];
$deleted = 0;
foreach ($ids as $id) {
if (deleteMediaInternal($id)) {
$deleted++;
}
}
jsonResponse(['success' => true, 'message' => "{$deleted} file(s) deleted"]);
}
+273
View File
@@ -0,0 +1,273 @@
<?php
/**
* MSPE News/Articles API
*/
require_once 'config.php';
$method = $_SERVER['REQUEST_METHOD'];
$id = $_GET['id'] ?? null;
switch ($method) {
case 'GET':
if ($id) {
getArticle($id);
} else {
getArticles();
}
break;
case 'POST':
requireAuth();
if (!empty($_POST['id'])) {
updateArticle($_POST['id']);
} else {
createArticle();
}
break;
case 'PUT':
requireAuth();
if (!$id) {
jsonResponse(['success' => false, 'message' => 'Article ID required'], 400);
}
updateArticle($id);
break;
case 'DELETE':
requireAuth();
if (!$id) {
jsonResponse(['success' => false, 'message' => 'Article ID required'], 400);
}
deleteArticle($id);
break;
default:
jsonResponse(['success' => false, 'message' => 'Method not allowed'], 405);
}
function getArticles() {
global $db;
$articles = $db->getAll('news');
// Apply filters
$category = $_GET['category'] ?? null;
$status = $_GET['status'] ?? null;
$limit = (int)($_GET['limit'] ?? 10);
$offset = (int)($_GET['offset'] ?? 0);
if ($category) {
$articles = array_filter($articles, function($a) use ($category) {
return $a['category'] === $category;
});
}
if ($status) {
$articles = array_filter($articles, function($a) use ($status) {
return $a['status'] === $status;
});
}
// Sort by date (newest first)
usort($articles, function($a, $b) {
return strtotime($b['created_at']) - strtotime($a['created_at']);
});
// For public API, only show published articles
if (!checkAuth()) {
$articles = array_filter($articles, function($a) {
return ($a['status'] ?? 'draft') === 'published';
});
}
$total = count($articles);
$articles = array_slice(array_values($articles), $offset, $limit);
jsonResponse([
'success' => true,
'data' => $articles,
'total' => $total,
'limit' => $limit,
'offset' => $offset
]);
}
function getArticle($id) {
global $db;
$article = $db->get('news', $id);
if (!$article) {
jsonResponse(['success' => false, 'message' => 'Article not found'], 404);
}
// Check if published or user is authenticated
if (($article['status'] ?? 'draft') !== 'published' && !checkAuth()) {
jsonResponse(['success' => false, 'message' => 'Article not found'], 404);
}
jsonResponse([
'success' => true,
'data' => $article
]);
}
function createArticle() {
global $db;
// Handle multipart form data or JSON
if (!empty($_FILES)) {
$data = $_POST;
} else {
$data = getRequestBody();
}
// Validate required fields
if (empty($data['title'])) {
jsonResponse(['success' => false, 'message' => 'Title is required'], 400);
}
// Sanitize text fields (allow safe HTML in content)
$data['title'] = sanitize($data['title']);
$data['excerpt'] = sanitize($data['excerpt'] ?? '');
$data['category'] = sanitize($data['category'] ?? '');
$data['author'] = sanitize($data['author'] ?? 'Admin');
// Sanitize rich content: strip dangerous tags/attributes while allowing formatting
if (!empty($data['content'])) {
$data['content'] = sanitizeRichText($data['content']);
}
// Handle image upload
if (!empty($_FILES['featured_image'])) {
$upload = handleFileUpload($_FILES['featured_image'], 'news');
if ($upload['success']) {
$data['featured_image'] = $upload['path'];
}
}
// Generate slug if not provided
if (empty($data['slug'])) {
$data['slug'] = generateSlug($data['title']);
} else {
$data['slug'] = generateSlug($data['slug']);
}
$data['slug'] = ensureUniqueNewsSlug($data['slug']);
// Set defaults
$data['status'] = $data['status'] ?? 'draft';
$data['author'] = $data['author'] ?? 'Admin';
$data['views'] = 0;
$article = $db->insert('news', $data);
jsonResponse([
'success' => true,
'message' => 'Article created successfully',
'data' => $article
], 201);
}
function updateArticle($id) {
global $db;
$existing = $db->get('news', $id);
if (!$existing) {
jsonResponse(['success' => false, 'message' => 'Article not found'], 404);
}
// Handle multipart form data or JSON
if (!empty($_FILES)) {
$data = $_POST;
} else {
$data = getRequestBody();
}
// Handle image upload
if (!empty($_FILES['featured_image'])) {
$upload = handleFileUpload($_FILES['featured_image'], 'news');
if ($upload['success']) {
$data['featured_image'] = $upload['path'];
}
}
// Update slug if title changed
if (!empty($data['title']) && empty($data['slug'])) {
$data['slug'] = generateSlug($data['title']);
} elseif (!empty($data['slug'])) {
$data['slug'] = generateSlug($data['slug']);
}
if (!empty($data['slug'])) {
$data['slug'] = ensureUniqueNewsSlug($data['slug'], $id);
}
// Sanitize text fields
if (!empty($data['title'])) $data['title'] = sanitize($data['title']);
if (!empty($data['excerpt'])) $data['excerpt'] = sanitize($data['excerpt']);
if (!empty($data['category'])) $data['category'] = sanitize($data['category']);
if (!empty($data['author'])) $data['author'] = sanitize($data['author']);
// Sanitize rich content: strip dangerous tags/attributes while allowing formatting
if (!empty($data['content'])) {
$data['content'] = sanitizeRichText($data['content']);
}
$article = $db->update('news', $id, $data);
jsonResponse([
'success' => true,
'message' => 'Article updated successfully',
'data' => $article
]);
}
function deleteArticle($id) {
global $db;
$existing = $db->get('news', $id);
if (!$existing) {
jsonResponse(['success' => false, 'message' => 'Article not found'], 404);
}
$db->delete('news', $id);
jsonResponse([
'success' => true,
'message' => 'Article deleted successfully'
]);
}
function generateSlug($title) {
$slug = strtolower($title);
$slug = preg_replace('/[^a-z0-9]+/', '-', $slug);
$slug = trim($slug, '-');
return $slug !== '' ? $slug : 'article';
}
function ensureUniqueNewsSlug($baseSlug, $excludeId = null) {
global $db;
$articles = $db->getAll('news');
$used = [];
foreach ($articles as $article) {
if (!empty($excludeId) && ($article['id'] ?? null) === $excludeId) {
continue;
}
$slug = (string)($article['slug'] ?? '');
if ($slug !== '') {
$used[$slug] = true;
}
}
if (!isset($used[$baseSlug])) {
return $baseSlug;
}
$i = 2;
while (isset($used[$baseSlug . '-' . $i])) {
$i++;
}
return $baseSlug . '-' . $i;
}
+405
View File
@@ -0,0 +1,405 @@
<?php
/**
* MSPE Pages API
* Stores metadata, SEO fields, and section content for static pages.
* Also manages global sections (header, footer, CTA, cookie banner).
*/
require_once 'config.php';
$method = $_SERVER['REQUEST_METHOD'];
$id = $_GET['id'] ?? null;
$type = $_GET['type'] ?? null; // 'global' for global sections
switch ($method) {
case 'GET':
if ($type === 'global') {
getGlobalSections();
} elseif ($id) {
getPage($id);
} else {
getPages();
}
break;
case 'POST':
requireAuth();
if ($type === 'global') {
saveGlobalSection();
} else {
savePage();
}
break;
default:
jsonResponse(['success' => false, 'message' => 'Method not allowed'], 405);
}
// ── Page defaults ────────────────────────────────────────────
function getDefaultPages() {
return [
[
'slug' => 'home',
'title' => 'Home Page',
'icon' => 'fa-home',
'url' => '/index.html',
'description' => 'Main landing page with hero, services, and CTA sections',
'status' => 'published',
'meta_title' => 'MSPE | Architects of Digital Resilience',
'meta_description' => 'Next-generation cybersecurity, cloud innovation, and technology consulting.',
'sections' => [
['key' => 'hero', 'label' => 'Hero', 'fields' => [
['name' => 'heading', 'type' => 'text', 'label' => 'Heading', 'value' => ''],
['name' => 'subheading', 'type' => 'textarea', 'label' => 'Subheading', 'value' => ''],
['name' => 'cta_text', 'type' => 'text', 'label' => 'CTA Button Text', 'value' => ''],
['name' => 'cta_link', 'type' => 'text', 'label' => 'CTA Button Link', 'value' => ''],
]],
['key' => 'services', 'label' => 'Services', 'fields' => [
['name' => 'heading', 'type' => 'text', 'label' => 'Section Heading', 'value' => ''],
['name' => 'subheading', 'type' => 'textarea', 'label' => 'Section Subheading', 'value' => ''],
]],
['key' => 'stats', 'label' => 'Stats', 'fields' => [
['name' => 'heading', 'type' => 'text', 'label' => 'Section Heading', 'value' => ''],
]],
['key' => 'cta', 'label' => 'CTA', 'fields' => [
['name' => 'heading', 'type' => 'text', 'label' => 'CTA Heading', 'value' => ''],
['name' => 'text', 'type' => 'textarea', 'label' => 'CTA Text', 'value' => ''],
['name' => 'button_text', 'type' => 'text', 'label' => 'Button Text', 'value' => ''],
['name' => 'button_link', 'type' => 'text', 'label' => 'Button Link', 'value' => ''],
]],
],
],
[
'slug' => 'about',
'title' => 'About Us',
'icon' => 'fa-building',
'url' => '/about.html',
'description' => 'Company story, mission, values, and team information',
'status' => 'published',
'meta_title' => 'About Us - MSPE',
'meta_description' => 'Learn about MSPE — our story, mission, values, and the team behind your digital resilience.',
'sections' => [
['key' => 'story', 'label' => 'Story', 'fields' => [
['name' => 'heading', 'type' => 'text', 'label' => 'Heading', 'value' => ''],
['name' => 'content', 'type' => 'textarea', 'label' => 'Content', 'value' => ''],
]],
['key' => 'mission', 'label' => 'Mission', 'fields' => [
['name' => 'heading', 'type' => 'text', 'label' => 'Heading', 'value' => ''],
['name' => 'content', 'type' => 'textarea', 'label' => 'Content', 'value' => ''],
]],
['key' => 'values', 'label' => 'Values', 'fields' => [
['name' => 'heading', 'type' => 'text', 'label' => 'Heading', 'value' => ''],
]],
['key' => 'team', 'label' => 'Team', 'fields' => [
['name' => 'heading', 'type' => 'text', 'label' => 'Heading', 'value' => ''],
['name' => 'subheading', 'type' => 'textarea', 'label' => 'Subheading', 'value' => ''],
]],
],
],
[
'slug' => 'services',
'title' => 'Services',
'icon' => 'fa-concierge-bell',
'url' => '/services.html',
'description' => 'Detailed service offerings and pricing plans',
'status' => 'published',
'meta_title' => 'Services - MSPE',
'meta_description' => 'Explore our cybersecurity, cloud, and IT support services.',
'sections' => [
['key' => 'it_support', 'label' => 'IT Support', 'fields' => [
['name' => 'heading', 'type' => 'text', 'label' => 'Heading', 'value' => ''],
['name' => 'content', 'type' => 'textarea', 'label' => 'Content', 'value' => ''],
]],
['key' => 'security', 'label' => 'Security', 'fields' => [
['name' => 'heading', 'type' => 'text', 'label' => 'Heading', 'value' => ''],
['name' => 'content', 'type' => 'textarea', 'label' => 'Content', 'value' => ''],
]],
['key' => 'cloud', 'label' => 'Cloud', 'fields' => [
['name' => 'heading', 'type' => 'text', 'label' => 'Heading', 'value' => ''],
['name' => 'content', 'type' => 'textarea', 'label' => 'Content', 'value' => ''],
]],
['key' => 'pricing', 'label' => 'Pricing', 'fields' => [
['name' => 'heading', 'type' => 'text', 'label' => 'Heading', 'value' => ''],
]],
],
],
[
'slug' => 'portfolio',
'title' => 'Portfolio',
'icon' => 'fa-briefcase',
'url' => '/portfolio.html',
'description' => 'Showcase of completed projects and case studies',
'status' => 'published',
'meta_title' => 'Portfolio - MSPE',
'meta_description' => 'See our completed projects, case studies, and client success stories.',
'sections' => [
['key' => 'projects', 'label' => 'Projects', 'fields' => [
['name' => 'heading', 'type' => 'text', 'label' => 'Heading', 'value' => ''],
['name' => 'subheading', 'type' => 'textarea', 'label' => 'Subheading', 'value' => ''],
]],
['key' => 'case_studies', 'label' => 'Case Studies', 'fields' => [
['name' => 'heading', 'type' => 'text', 'label' => 'Heading', 'value' => ''],
]],
['key' => 'clients', 'label' => 'Clients', 'fields' => [
['name' => 'heading', 'type' => 'text', 'label' => 'Heading', 'value' => ''],
]],
],
],
[
'slug' => 'news',
'title' => 'News & Events',
'icon' => 'fa-newspaper',
'url' => '/news.html',
'description' => 'Company news, blog posts, and upcoming events',
'status' => 'published',
'meta_title' => 'News & Events - MSPE',
'meta_description' => 'Stay updated with the latest MSPE news, tech insights, and events.',
'sections' => [
['key' => 'news_list', 'label' => 'News List', 'fields' => [
['name' => 'heading', 'type' => 'text', 'label' => 'Heading', 'value' => ''],
]],
['key' => 'newsletter', 'label' => 'Newsletter', 'fields' => [
['name' => 'heading', 'type' => 'text', 'label' => 'Heading', 'value' => ''],
['name' => 'subheading', 'type' => 'textarea', 'label' => 'Subheading', 'value' => ''],
]],
],
],
[
'slug' => 'contact',
'title' => 'Contact',
'icon' => 'fa-envelope',
'url' => '/contact.html',
'description' => 'Contact form, location, and FAQ section',
'status' => 'published',
'meta_title' => 'Contact Us - MSPE',
'meta_description' => 'Get in touch with MSPE for cybersecurity, cloud, and IT services.',
'sections' => [
['key' => 'form', 'label' => 'Form', 'fields' => [
['name' => 'heading', 'type' => 'text', 'label' => 'Heading', 'value' => ''],
['name' => 'subheading', 'type' => 'textarea', 'label' => 'Subheading', 'value' => ''],
]],
['key' => 'info_cards', 'label' => 'Info Cards', 'fields' => [
['name' => 'address', 'type' => 'textarea', 'label' => 'Address', 'value' => ''],
['name' => 'phone', 'type' => 'text', 'label' => 'Phone', 'value' => ''],
['name' => 'email', 'type' => 'text', 'label' => 'Email', 'value' => ''],
['name' => 'hours', 'type' => 'text', 'label' => 'Business Hours', 'value' => ''],
]],
['key' => 'faq', 'label' => 'FAQ', 'fields' => [
['name' => 'heading', 'type' => 'text', 'label' => 'Heading', 'value' => ''],
]],
],
],
];
}
function getDefaultGlobalSections() {
return [
[
'slug' => 'header',
'label' => 'Header / Navigation',
'icon' => 'fa-bars',
'description' => 'Logo, menu items, CTA button',
'fields' => [
['name' => 'cta_text', 'type' => 'text', 'label' => 'CTA Button Text', 'value' => ''],
['name' => 'cta_link', 'type' => 'text', 'label' => 'CTA Button Link', 'value' => ''],
],
],
[
'slug' => 'footer',
'label' => 'Footer',
'icon' => 'fa-shoe-prints',
'description' => 'Links, contact info, social media',
'fields' => [
['name' => 'tagline', 'type' => 'textarea', 'label' => 'Footer Tagline', 'value' => ''],
['name' => 'copyright', 'type' => 'text', 'label' => 'Copyright Text', 'value' => ''],
],
],
[
'slug' => 'cta',
'label' => 'CTA Section',
'icon' => 'fa-bullhorn',
'description' => 'Call-to-action banner',
'fields' => [
['name' => 'heading', 'type' => 'text', 'label' => 'Heading', 'value' => ''],
['name' => 'text', 'type' => 'textarea', 'label' => 'Text', 'value' => ''],
['name' => 'button_text', 'type' => 'text', 'label' => 'Button Text', 'value' => ''],
['name' => 'button_link', 'type' => 'text', 'label' => 'Button Link', 'value' => ''],
],
],
[
'slug' => 'cookie',
'label' => 'Cookie Banner',
'icon' => 'fa-cookie',
'description' => 'GDPR compliance notice',
'fields' => [
['name' => 'message', 'type' => 'textarea', 'label' => 'Banner Message', 'value' => ''],
['name' => 'button_text', 'type' => 'text', 'label' => 'Accept Button Text', 'value' => ''],
['name' => 'link_text', 'type' => 'text', 'label' => 'Policy Link Text', 'value' => ''],
['name' => 'link_url', 'type' => 'text', 'label' => 'Policy URL', 'value' => ''],
],
],
];
}
// ── Handlers ─────────────────────────────────────────────────
function getPages() {
global $db;
$storedPages = $db->getAll('pages');
$storedMap = [];
foreach ($storedPages as $p) {
$slug = $p['slug'] ?? '';
if ($slug) $storedMap[$slug] = $p;
}
$pages = getDefaultPages();
foreach ($pages as &$page) {
if (isset($storedMap[$page['slug']])) {
$stored = $storedMap[$page['slug']];
// Merge saved values into section fields
if (!empty($stored['meta_title'])) $page['meta_title'] = $stored['meta_title'];
if (!empty($stored['meta_description'])) $page['meta_description'] = $stored['meta_description'];
if (isset($stored['status'])) $page['status'] = $stored['status'];
if (!empty($stored['sections_data'])) {
$savedData = $stored['sections_data']; // key => { fieldName => value }
foreach ($page['sections'] as &$section) {
if (isset($savedData[$section['key']])) {
foreach ($section['fields'] as &$field) {
if (isset($savedData[$section['key']][$field['name']])) {
$field['value'] = $savedData[$section['key']][$field['name']];
}
}
}
}
}
if (isset($stored['updated_at'])) $page['updated_at'] = $stored['updated_at'];
}
}
jsonResponse(['success' => true, 'data' => $pages]);
}
function getPage($id) {
global $db;
$pages = getDefaultPages();
$default = null;
foreach ($pages as $p) {
if ($p['slug'] === $id) { $default = $p; break; }
}
if (!$default) {
jsonResponse(['success' => false, 'message' => 'Page not found'], 404);
}
$stored = $db->query('pages', ['slug' => $id]);
if (!empty($stored)) {
$stored = array_values($stored)[0];
if (!empty($stored['meta_title'])) $default['meta_title'] = $stored['meta_title'];
if (!empty($stored['meta_description'])) $default['meta_description'] = $stored['meta_description'];
if (isset($stored['status'])) $default['status'] = $stored['status'];
if (!empty($stored['sections_data'])) {
$savedData = $stored['sections_data'];
foreach ($default['sections'] as &$section) {
if (isset($savedData[$section['key']])) {
foreach ($section['fields'] as &$field) {
if (isset($savedData[$section['key']][$field['name']])) {
$field['value'] = $savedData[$section['key']][$field['name']];
}
}
}
}
}
if (isset($stored['updated_at'])) $default['updated_at'] = $stored['updated_at'];
}
jsonResponse(['success' => true, 'data' => $default]);
}
function savePage() {
global $db;
$data = getRequestBody();
if (empty($data['slug'])) {
jsonResponse(['success' => false, 'message' => 'Page slug required'], 400);
}
$slug = sanitize($data['slug']);
$record = [
'slug' => $slug,
'meta_title' => sanitize($data['meta_title'] ?? ''),
'meta_description' => sanitize($data['meta_description'] ?? ''),
'status' => in_array($data['status'] ?? '', ['published', 'draft']) ? $data['status'] : 'published',
'sections_data' => $data['sections_data'] ?? [],
'updated_at' => date('Y-m-d H:i:s'),
];
$existing = $db->query('pages', ['slug' => $slug]);
if (!empty($existing)) {
$id = array_values($existing)[0]['id'];
$db->update('pages', $id, $record);
} else {
$db->insert('pages', $record);
}
jsonResponse(['success' => true, 'message' => 'Page saved successfully']);
}
// ── Global sections ──────────────────────────────────────────
function getGlobalSections() {
global $db;
$stored = $db->query('pages', ['slug' => '__global__']);
$savedData = [];
if (!empty($stored)) {
$savedData = array_values($stored)[0]['sections_data'] ?? [];
}
$globals = getDefaultGlobalSections();
foreach ($globals as &$section) {
if (isset($savedData[$section['slug']])) {
foreach ($section['fields'] as &$field) {
if (isset($savedData[$section['slug']][$field['name']])) {
$field['value'] = $savedData[$section['slug']][$field['name']];
}
}
}
}
jsonResponse(['success' => true, 'data' => $globals]);
}
function saveGlobalSection() {
global $db;
$data = getRequestBody();
if (empty($data['section_slug'])) {
jsonResponse(['success' => false, 'message' => 'Section slug required'], 400);
}
$sectionSlug = sanitize($data['section_slug']);
$fieldValues = $data['fields'] ?? [];
// Load existing global record
$existing = $db->query('pages', ['slug' => '__global__']);
$record = [];
if (!empty($existing)) {
$record = array_values($existing)[0];
}
$sectionsData = $record['sections_data'] ?? [];
$sectionsData[$sectionSlug] = $fieldValues;
$saveData = [
'slug' => '__global__',
'sections_data' => $sectionsData,
'updated_at' => date('Y-m-d H:i:s'),
];
if (!empty($existing)) {
$id = array_values($existing)[0]['id'];
$db->update('pages', $id, $saveData);
} else {
$db->insert('pages', $saveData);
}
jsonResponse(['success' => true, 'message' => 'Global section saved']);
}
+264
View File
@@ -0,0 +1,264 @@
<?php
/**
* MSPE Portfolio API
*/
require_once 'config.php';
$method = $_SERVER['REQUEST_METHOD'];
$id = $_GET['id'] ?? null;
switch ($method) {
case 'GET':
if ($id) {
getProject($id);
} else {
getProjects();
}
break;
case 'POST':
requireAuth();
if (!empty($_POST['id'])) {
updateProject($_POST['id']);
} else {
createProject();
}
break;
case 'PUT':
requireAuth();
if (!$id) {
jsonResponse(['success' => false, 'message' => 'Project ID required'], 400);
}
updateProject($id);
break;
case 'DELETE':
requireAuth();
if (!$id) {
jsonResponse(['success' => false, 'message' => 'Project ID required'], 400);
}
deleteProject($id);
break;
default:
jsonResponse(['success' => false, 'message' => 'Method not allowed'], 405);
}
function getProjects() {
global $db;
$projects = $db->getAll('portfolio');
// Apply filters
$category = $_GET['category'] ?? null;
$featured = $_GET['featured'] ?? null;
$limit = (int)($_GET['limit'] ?? 20);
$offset = (int)($_GET['offset'] ?? 0);
if ($category) {
$projects = array_filter($projects, function($p) use ($category) {
return $p['category'] === $category;
});
}
if ($featured !== null) {
$projects = array_filter($projects, function($p) use ($featured) {
return ($p['featured'] ?? false) == ($featured === 'true' || $featured === '1');
});
}
// Public view: only published/active projects
if (!checkAuth()) {
$projects = array_filter($projects, function($p) {
return ($p['status'] ?? 'published') === 'published';
});
}
// Sort by order or date
usort($projects, function($a, $b) {
$orderA = $a['order'] ?? 999;
$orderB = $b['order'] ?? 999;
if ($orderA !== $orderB) {
return $orderA - $orderB;
}
return strtotime($b['created_at']) - strtotime($a['created_at']);
});
$total = count($projects);
$projects = array_slice(array_values($projects), $offset, $limit);
jsonResponse([
'success' => true,
'data' => $projects,
'total' => $total,
'limit' => $limit,
'offset' => $offset
]);
}
function getProject($id) {
global $db;
$project = $db->get('portfolio', $id);
if (!$project) {
jsonResponse(['success' => false, 'message' => 'Project not found'], 404);
}
jsonResponse([
'success' => true,
'data' => $project
]);
}
function createProject() {
global $db;
// Handle multipart form data or JSON
if (!empty($_FILES)) {
$data = $_POST;
} else {
$data = getRequestBody();
}
// Validate required fields
if (empty($data['title'])) {
jsonResponse(['success' => false, 'message' => 'Title is required'], 400);
}
// Sanitize text fields (allow HTML in description)
$data['title'] = sanitize($data['title']);
$data['category'] = sanitize($data['category'] ?? '');
$data['client'] = sanitize($data['client'] ?? '');
$data['technologies'] = sanitize($data['technologies'] ?? '');
// Sanitize description: allow only safe formatting tags
$data['description'] = sanitizeRichText($data['description'] ?? '');
// Handle image upload
if (!empty($_FILES['image'])) {
$upload = handleFileUpload($_FILES['image'], 'portfolio');
if ($upload['success']) {
$data['image'] = $upload['path'];
}
}
// Parse results JSON (JS sends as a serialised string)
$data['results'] = json_decode($data['results'] ?? '[]', true) ?? [];
// Gallery: merge existing-keep list + newly uploaded files
$gallery = json_decode($_POST['gallery_keep'] ?? '[]', true) ?? [];
if (!empty($_FILES['gallery_images']['tmp_name'])) {
foreach ($_FILES['gallery_images']['tmp_name'] as $key => $tmpName) {
if ($_FILES['gallery_images']['error'][$key] !== UPLOAD_ERR_OK) continue;
$file = [
'name' => $_FILES['gallery_images']['name'][$key],
'type' => $_FILES['gallery_images']['type'][$key],
'tmp_name' => $tmpName,
'error' => $_FILES['gallery_images']['error'][$key],
'size' => $_FILES['gallery_images']['size'][$key],
];
$upload = handleFileUpload($file, 'portfolio');
if ($upload['success']) {
$gallery[] = $upload['path'];
}
}
}
$data['gallery'] = $gallery;
// Set defaults
$data['featured'] = $data['featured'] ?? false;
$data['order'] = (int)($data['order'] ?? 0);
$data['status'] = $data['status'] ?? 'published';
$project = $db->insert('portfolio', $data);
jsonResponse([
'success' => true,
'message' => 'Project created successfully',
'data' => $project
], 201);
}
function updateProject($id) {
global $db;
$existing = $db->get('portfolio', $id);
if (!$existing) {
jsonResponse(['success' => false, 'message' => 'Project not found'], 404);
}
// Handle multipart form data or JSON
if (!empty($_FILES)) {
$data = $_POST;
} else {
$data = getRequestBody();
}
// Handle image upload
if (!empty($_FILES['image'])) {
$upload = handleFileUpload($_FILES['image'], 'portfolio');
if ($upload['success']) {
$data['image'] = $upload['path'];
}
}
// Sanitize editable text fields
if (isset($data['title'])) $data['title'] = sanitize($data['title']);
if (isset($data['category'])) $data['category'] = sanitize($data['category']);
if (isset($data['client'])) $data['client'] = sanitize($data['client']);
if (isset($data['technologies'])) $data['technologies'] = sanitize($data['technologies']);
if (isset($data['description'])) $data['description'] = sanitizeRichText($data['description']);
// Parse results JSON
if (isset($data['results'])) {
$data['results'] = json_decode($data['results'], true) ?? $existing['results'] ?? [];
}
// Gallery: merge existing-keep list + newly uploaded files
if (array_key_exists('gallery_keep', $_POST)) {
$gallery = json_decode($_POST['gallery_keep'], true) ?? [];
if (!empty($_FILES['gallery_images']['tmp_name'])) {
foreach ($_FILES['gallery_images']['tmp_name'] as $key => $tmpName) {
if ($_FILES['gallery_images']['error'][$key] !== UPLOAD_ERR_OK) continue;
$file = [
'name' => $_FILES['gallery_images']['name'][$key],
'type' => $_FILES['gallery_images']['type'][$key],
'tmp_name' => $tmpName,
'error' => $_FILES['gallery_images']['error'][$key],
'size' => $_FILES['gallery_images']['size'][$key],
];
$upload = handleFileUpload($file, 'portfolio');
if ($upload['success']) {
$gallery[] = $upload['path'];
}
}
}
$data['gallery'] = $gallery;
}
$project = $db->update('portfolio', $id, $data);
jsonResponse([
'success' => true,
'message' => 'Project updated successfully',
'data' => $project
]);
}
function deleteProject($id) {
global $db;
$existing = $db->get('portfolio', $id);
if (!$existing) {
jsonResponse(['success' => false, 'message' => 'Project not found'], 404);
}
$db->delete('portfolio', $id);
jsonResponse([
'success' => true,
'message' => 'Project deleted successfully'
]);
}
+46
View File
@@ -0,0 +1,46 @@
<?php
/**
* Public Site Settings API (safe subset only)
*/
require_once 'config.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
jsonResponse(['success' => false, 'message' => 'Method not allowed'], 405);
}
$all = getSettingsMap();
$allowedKeys = [
'site_name',
'site_tagline',
'site_description',
'contact_email',
'contact_phone',
'contact_address',
'business_hours',
'support_hours',
'maps_url',
'social_facebook',
'social_linkedin',
'social_twitter',
'social_instagram',
'social_youtube',
'social_github',
'primary_color',
'secondary_color',
'accent_color'
];
$data = [];
foreach ($allowedKeys as $key) {
if (isset($all[$key]) && $all[$key] !== '') {
$data[$key] = $all[$key];
}
}
if (!isset($data['contact_email']) || $data['contact_email'] === '') {
$data['contact_email'] = ADMIN_EMAIL;
}
jsonResponse(['success' => true, 'data' => $data]);
+125
View File
@@ -0,0 +1,125 @@
<?php
/**
* MSPE Services API
*/
require_once 'config.php';
$method = $_SERVER['REQUEST_METHOD'];
$id = $_GET['id'] ?? null;
switch ($method) {
case 'GET':
if ($id) {
getService($id);
} else {
getServices();
}
break;
case 'POST':
requireAuth();
$postData = !empty($_POST) ? $_POST : getRequestBody();
if (!empty($postData['id'])) {
updateService($postData['id']);
} else {
createService();
}
break;
case 'PUT':
requireAuth();
if (!$id) {
jsonResponse(['success' => false, 'message' => 'Service ID required'], 400);
}
updateService($id);
break;
case 'DELETE':
requireAuth();
if (!$id) {
jsonResponse(['success' => false, 'message' => 'ID required'], 400);
}
deleteService($id);
break;
default:
jsonResponse(['success' => false, 'message' => 'Method not allowed'], 405);
}
function getServices() {
global $db;
$services = $db->getAll('services');
// Sort by order
usort($services, function($a, $b) {
return ($a['order'] ?? 99) - ($b['order'] ?? 99);
});
// Public view: only active
if (!checkAuth()) {
$services = array_filter($services, function($s) {
return ($s['active'] ?? true) === true;
});
}
jsonResponse(['success' => true, 'data' => array_values($services)]);
}
function getService($id) {
global $db;
$service = $db->get('services', $id);
if (!$service) jsonResponse(['success' => false, 'message' => 'Not found'], 404);
jsonResponse(['success' => true, 'data' => $service]);
}
function createService() {
global $db;
$data = !empty($_POST) ? $_POST : getRequestBody();
if (empty($data['name'])) {
jsonResponse(['success' => false, 'message' => 'Service name is required'], 400);
}
// Sanitize text fields
$data['name'] = sanitize($data['name']);
$data['icon'] = sanitize($data['icon'] ?? '');
$data['features'] = sanitize($data['features'] ?? '');
$data['price'] = sanitize($data['price'] ?? '');
// Sanitize description: allow only safe formatting tags (no <script>, <iframe>, event handlers etc.)
$data['description'] = sanitizeRichText($data['description'] ?? '');
$data['active'] = isset($data['active']) ? filter_var($data['active'], FILTER_VALIDATE_BOOLEAN) : true;
$data['order'] = (int)($data['order'] ?? 99);
$service = $db->insert('services', $data);
jsonResponse(['success' => true, 'message' => 'Service created', 'data' => $service]);
}
function updateService($id) {
global $db;
$existing = $db->get('services', $id);
if (!$existing) jsonResponse(['success' => false, 'message' => 'Not found'], 404);
$data = !empty($_POST) ? $_POST : getRequestBody();
// Sanitize editable text fields
if (isset($data['name'])) $data['name'] = sanitize($data['name']);
if (isset($data['icon'])) $data['icon'] = sanitize($data['icon']);
if (isset($data['features'])) $data['features'] = sanitize($data['features']);
if (isset($data['price'])) $data['price'] = sanitize($data['price']);
if (isset($data['description'])) $data['description'] = sanitizeRichText($data['description']);
$data['active'] = isset($data['active']) ? filter_var($data['active'], FILTER_VALIDATE_BOOLEAN) : ($existing['active'] ?? true);
$service = $db->update('services', $id, $data);
jsonResponse(['success' => true, 'message' => 'Service updated', 'data' => $service]);
}
function deleteService($id) {
global $db;
$existing = $db->get('services', $id);
if (!$existing) {
jsonResponse(['success' => false, 'message' => 'Service not found'], 404);
}
$db->delete('services', $id);
jsonResponse(['success' => true, 'message' => 'Service deleted']);
}
+102
View File
@@ -0,0 +1,102 @@
<?php
/**
* MSPE Settings API
*/
require_once 'config.php';
$method = $_SERVER['REQUEST_METHOD'];
// Require authentication for all settings operations
requireAuth();
switch ($method) {
case 'GET':
getSettings();
break;
case 'POST':
saveSettings();
break;
default:
jsonResponse(['success' => false, 'message' => 'Method not allowed'], 405);
}
function getSettings() {
global $db;
// Check if settings file exists, if not return defaults
$settings = $db->getAll('settings');
$flatSettings = [];
// Convert from array of objects to key-value pairs if needed
// or just return as is if stored as key-value object
if (empty($settings)) {
// Defaults
$flatSettings = [
'site_name' => 'MSPE',
'site_tagline' => 'Architects of Digital Resilience',
'site_description' => 'MSPE transforms complexity into competitive advantage.',
'contact_email' => 'info@mspe.pro',
'contact_phone' => '+961 78 782 023',
'contact_address' => '',
'business_hours' => 'Mon - Fri: 9AM - 6PM',
'support_hours' => 'Mon - Fri: 9AM - 6PM (On-call by arrangement)',
'admin_email' => 'info@mspe.pro',
'email_transport' => 'mail',
'smtp_from_name' => 'MSPE',
'smtp_from_email' => 'info@mspe.pro',
'smtp_host' => '',
'smtp_port' => '587',
'smtp_encryption' => 'tls',
'smtp_username' => '',
'smtp_password' => '',
'password_reset_url' => SITE_URL . '/admin/reset-password.html',
'primary_color' => '#0ea5e9',
'secondary_color' => '#0d9488'
];
} else {
// Assuming settings are stored as a single object in index 0 or key-value pairs
// Let's assume key-value for simplicity in this file-based DB
// But FileDB::getAll returns an indexed array of items.
// So we'll store settings as: [ {key: 'site_name', value: 'MSPE'}, ... ]
foreach ($settings as $setting) {
if (isset($setting['key']) && isset($setting['value'])) {
$flatSettings[$setting['key']] = $setting['value'];
}
}
}
jsonResponse(['success' => true, 'data' => $flatSettings]);
}
function saveSettings() {
global $db;
requireSameOriginRequest();
$data = getRequestBody();
if (empty($data)) {
jsonResponse(['success' => false, 'message' => 'No data provided'], 400);
}
// Get existing settings to update or insert
$existingSettings = $db->getAll('settings');
$existingMap = [];
foreach ($existingSettings as $index => $setting) {
$existingMap[$setting['key']] = $setting['id'];
}
foreach ($data as $key => $value) {
if (isset($existingMap[$key])) {
// Update
$db->update('settings', $existingMap[$key], ['value' => $value]);
} else {
// Insert
$db->insert('settings', ['key' => $key, 'value' => $value]);
}
}
jsonResponse(['success' => true, 'message' => 'Settings saved successfully']);
}
+224
View File
@@ -0,0 +1,224 @@
<?php
/**
* MSPE Dashboard Stats API
*/
require_once 'config.php';
$method = $_SERVER['REQUEST_METHOD'];
if ($method !== 'GET') {
jsonResponse(['success' => false, 'message' => 'Method not allowed'], 405);
}
// Require authentication for stats
requireAuth();
$action = $_GET['action'] ?? 'all';
$forceRefresh = !empty($_GET['refresh']);
switch ($action) {
case 'all':
getAllStats();
break;
case 'bookings':
getBookingStats();
break;
case 'messages':
getMessageStats();
break;
case 'news':
getNewsStats();
break;
default:
getAllStats();
}
function getAllStats() {
$cacheKey = 'stats_all_v1';
if (!$GLOBALS['forceRefresh']) {
$cached = getStatsCache($cacheKey, 30);
if ($cached !== null) {
jsonResponse($cached);
}
}
$bookingStats = calculateBookingStats();
$messageStats = calculateMessageStats();
$newsStats = calculateNewsStats();
$subscriberStats = calculateSubscriberStats();
$payload = [
'success' => true,
'data' => [
'bookings' => $bookingStats,
'messages' => $messageStats,
'news' => $newsStats,
'subscribers' => $subscriberStats
]
];
setStatsCache($cacheKey, $payload);
jsonResponse($payload);
}
function calculateBookingStats() {
global $db;
$today = date('Y-m-d');
$weekStart = date('Y-m-d', strtotime('monday this week'));
$weekEnd = date('Y-m-d', strtotime('sunday this week'));
$totalBookings = $db->count('bookings');
$thisWeekCount = $db->countByDateRange('bookings', 'booking_date', $weekStart, $weekEnd);
// Last week's bookings (for trend)
$lastWeekStart = date('Y-m-d', strtotime('monday last week'));
$lastWeekEnd = date('Y-m-d', strtotime('sunday last week'));
$lastWeekCount = $db->countByDateRange('bookings', 'booking_date', $lastWeekStart, $lastWeekEnd);
$pendingCount = $db->count('bookings', ['status' => 'pending']);
$confirmedCount = $db->count('bookings', ['status' => 'confirmed']);
// JSON boolean true is compared as string "true" after JSON_UNQUOTE
$availableSlotsCount = $db->countByFieldComparison('availability_slots', 'date', '>=', $today, [
'is_available' => 'true'
]);
$confirmationRate = $totalBookings > 0 ? round(($confirmedCount / $totalBookings) * 100) : 0;
// Calculate trend percentages
$bookingTrend = $lastWeekCount > 0 ? round((($thisWeekCount - $lastWeekCount) / $lastWeekCount) * 100) : ($thisWeekCount > 0 ? 100 : 0);
return [
'this_week' => $thisWeekCount,
'pending' => $pendingCount,
'confirmed' => $confirmedCount,
'available_slots' => $availableSlotsCount,
'confirmation_rate' => $confirmationRate,
'trend' => $bookingTrend,
'total' => $totalBookings
];
}
function calculateMessageStats() {
global $db;
$total = $db->count('messages');
$unread = $db->count('messages', ['status' => 'unread']);
$weekStart = date('Y-m-d', strtotime('monday this week'));
$weekEnd = date('Y-m-d', strtotime('sunday this week'));
$thisWeek = $db->countByDateRange('messages', 'created_at', $weekStart . ' 00:00:00', $weekEnd . ' 23:59:59');
return [
'total' => $total,
'unread' => $unread,
'this_week' => $thisWeek
];
}
function calculateNewsStats() {
global $db;
return [
'total' => $db->count('news'),
'published' => $db->count('news', ['status' => 'published']),
'draft' => $db->count('news', ['status' => 'draft'])
];
}
function calculateSubscriberStats() {
global $db;
return [
'total' => $db->count('subscribers'),
'active' => $db->count('subscribers', ['status' => 'active'])
];
}
function getBookingStats() {
$cacheKey = 'stats_bookings_v1';
if (!$GLOBALS['forceRefresh']) {
$cached = getStatsCache($cacheKey, 30);
if ($cached !== null) {
jsonResponse($cached);
}
}
$payload = [
'success' => true,
'data' => calculateBookingStats()
];
setStatsCache($cacheKey, $payload);
jsonResponse($payload);
}
function getMessageStats() {
$cacheKey = 'stats_messages_v1';
if (!$GLOBALS['forceRefresh']) {
$cached = getStatsCache($cacheKey, 30);
if ($cached !== null) {
jsonResponse($cached);
}
}
$payload = [
'success' => true,
'data' => calculateMessageStats()
];
setStatsCache($cacheKey, $payload);
jsonResponse($payload);
}
function getNewsStats() {
$cacheKey = 'stats_news_v1';
if (!$GLOBALS['forceRefresh']) {
$cached = getStatsCache($cacheKey, 30);
if ($cached !== null) {
jsonResponse($cached);
}
}
$payload = [
'success' => true,
'data' => calculateNewsStats()
];
setStatsCache($cacheKey, $payload);
jsonResponse($payload);
}
function getStatsCache($cacheKey, $ttlSeconds) {
$cacheDir = DATA_DIR . 'cache/';
$cacheFile = $cacheDir . 'stats_' . md5($cacheKey) . '.json';
if (!file_exists($cacheFile)) {
return null;
}
$age = time() - (int)filemtime($cacheFile);
if ($age > (int)$ttlSeconds) {
return null;
}
$content = @file_get_contents($cacheFile);
if ($content === false || $content === '') {
return null;
}
$decoded = json_decode($content, true);
return is_array($decoded) ? $decoded : null;
}
function setStatsCache($cacheKey, $payload) {
$cacheDir = DATA_DIR . 'cache/';
if (!is_dir($cacheDir)) {
@mkdir($cacheDir, 0755, true);
}
$cacheFile = $cacheDir . 'stats_' . md5($cacheKey) . '.json';
@file_put_contents($cacheFile, json_encode($payload), LOCK_EX);
}
+123
View File
@@ -0,0 +1,123 @@
<?php
/**
* MSPE Newsletter Subscription API
*/
require_once 'config.php';
$method = $_SERVER['REQUEST_METHOD'];
switch ($method) {
case 'GET':
requireAuth();
getSubscribers();
break;
case 'POST':
subscribe();
break;
case 'DELETE':
requireAuth();
$id = $_GET['id'] ?? null;
if (!$id) {
jsonResponse(['success' => false, 'message' => 'Subscriber ID required'], 400);
}
unsubscribe($id);
break;
default:
jsonResponse(['success' => false, 'message' => 'Method not allowed'], 405);
}
function getSubscribers() {
global $db;
$subscribers = $db->getAll('subscribers');
// Apply filters
$status = $_GET['status'] ?? null;
$limit = (int)($_GET['limit'] ?? 50);
$offset = (int)($_GET['offset'] ?? 0);
if ($status) {
$subscribers = array_filter($subscribers, function($s) use ($status) {
return ($s['status'] ?? 'active') === $status;
});
}
// Sort by date (newest first)
usort($subscribers, function($a, $b) {
return strtotime($b['created_at']) - strtotime($a['created_at']);
});
$total = count($subscribers);
$subscribers = array_slice(array_values($subscribers), $offset, $limit);
jsonResponse([
'success' => true,
'data' => $subscribers,
'total' => $total,
'limit' => $limit,
'offset' => $offset
]);
}
function subscribe() {
global $db;
requireSameOriginRequest();
$data = getRequestBody();
// Validate email
$email = $data['email'] ?? '';
if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
jsonResponse(['success' => false, 'message' => 'Valid email address required'], 400);
}
// Check if already subscribed
$existing = $db->query('subscribers', ['email' => $email]);
if (!empty($existing)) {
$subscriber = array_values($existing)[0];
if (($subscriber['status'] ?? 'active') === 'active') {
jsonResponse(['success' => true, 'message' => 'You are already subscribed!']);
} else {
// Reactivate subscription
$db->update('subscribers', $subscriber['id'], ['status' => 'active']);
jsonResponse(['success' => true, 'message' => 'Welcome back! Your subscription has been reactivated.']);
}
}
// Create new subscription
$subscriberData = [
'email' => sanitize($email),
'name' => sanitize($data['name'] ?? ''),
'status' => 'active',
'source' => $data['source'] ?? 'website'
];
$subscriber = $db->insert('subscribers', $subscriberData);
jsonResponse([
'success' => true,
'message' => 'Thank you for subscribing! You\'ll receive our latest updates.'
], 201);
}
function unsubscribe($id) {
global $db;
$existing = $db->get('subscribers', $id);
if (!$existing) {
jsonResponse(['success' => false, 'message' => 'Subscriber not found'], 404);
}
// Soft delete by setting status to unsubscribed
$db->update('subscribers', $id, ['status' => 'unsubscribed']);
jsonResponse([
'success' => true,
'message' => 'Successfully unsubscribed'
]);
}
+140
View File
@@ -0,0 +1,140 @@
<?php
/**
* MSPE Team API
*/
require_once 'config.php';
$method = $_SERVER['REQUEST_METHOD'];
$id = $_GET['id'] ?? null;
switch ($method) {
case 'GET':
if ($id) {
getMember($id);
} else {
getMembers();
}
break;
case 'POST':
requireAuth();
$postData = !empty($_POST) ? $_POST : getRequestBody();
if (!empty($postData['id'])) {
updateMember($postData['id']);
} else {
createMember();
}
break;
case 'PUT':
requireAuth();
if (!$id) {
jsonResponse(['success' => false, 'message' => 'Member ID required'], 400);
}
updateMember($id);
break;
case 'DELETE':
requireAuth();
if (!$id) {
jsonResponse(['success' => false, 'message' => 'ID required'], 400);
}
deleteMember($id);
break;
default:
jsonResponse(['success' => false, 'message' => 'Method not allowed'], 405);
}
function getMembers() {
global $db;
$members = $db->getAll('team');
// Sort by order
usort($members, function($a, $b) {
return ($a['order'] ?? 99) - ($b['order'] ?? 99);
});
// Public view: only active
if (!checkAuth()) {
$members = array_filter($members, function($m) {
return ($m['status'] ?? 'active') === 'active';
});
}
jsonResponse(['success' => true, 'data' => array_values($members)]);
}
function getMember($id) {
global $db;
$member = $db->get('team', $id);
if (!$member) jsonResponse(['success' => false, 'message' => 'Not found'], 404);
jsonResponse(['success' => true, 'data' => $member]);
}
function createMember() {
global $db;
$data = !empty($_POST) ? $_POST : getRequestBody();
if (empty($data['name']) || empty($data['role'])) {
jsonResponse(['success' => false, 'message' => 'Name and Role are required'], 400);
}
// Sanitize text fields
$data['name'] = sanitize($data['name']);
$data['role'] = sanitize($data['role']);
$data['email'] = sanitize($data['email'] ?? '');
$data['linkedin'] = sanitize($data['linkedin'] ?? '');
// Sanitize bio: allow only safe formatting tags
$data['bio'] = sanitizeRichText($data['bio'] ?? '');
// Image upload
if (!empty($_FILES['photo'])) {
$upload = handleFileUpload($_FILES['photo'], 'team');
if ($upload['success']) {
$data['photo'] = $upload['path'];
}
}
$data['order'] = (int)($data['order'] ?? 99);
$data['status'] = $data['status'] ?? 'active';
$member = $db->insert('team', $data);
jsonResponse(['success' => true, 'message' => 'Team member added', 'data' => $member]);
}
function updateMember($id) {
global $db;
$existing = $db->get('team', $id);
if (!$existing) jsonResponse(['success' => false, 'message' => 'Not found'], 404);
$data = !empty($_POST) ? $_POST : getRequestBody();
if (!empty($_FILES['photo'])) {
$upload = handleFileUpload($_FILES['photo'], 'team');
if ($upload['success']) {
$data['photo'] = $upload['path'];
}
}
// Sanitize editable text fields
if (isset($data['name'])) $data['name'] = sanitize($data['name']);
if (isset($data['role'])) $data['role'] = sanitize($data['role']);
if (isset($data['email'])) $data['email'] = sanitize($data['email']);
if (isset($data['linkedin'])) $data['linkedin'] = sanitize($data['linkedin']);
if (isset($data['bio'])) $data['bio'] = sanitizeRichText($data['bio']);
$member = $db->update('team', $id, $data);
jsonResponse(['success' => true, 'message' => 'Team member updated', 'data' => $member]);
}
function deleteMember($id) {
global $db;
$existing = $db->get('team', $id);
if (!$existing) {
jsonResponse(['success' => false, 'message' => 'Team member not found'], 404);
}
$db->delete('team', $id);
jsonResponse(['success' => true, 'message' => 'Team member removed']);
}
+141
View File
@@ -0,0 +1,141 @@
<?php
/**
* MSPE Testimonials API
*/
require_once 'config.php';
$method = $_SERVER['REQUEST_METHOD'];
$id = $_GET['id'] ?? null;
switch ($method) {
case 'GET':
if ($id) {
getTestimonial($id);
} else {
getTestimonials();
}
break;
case 'POST':
requireAuth();
$postData = !empty($_POST) ? $_POST : getRequestBody();
if (!empty($postData['id'])) {
updateTestimonial($postData['id']);
} else {
createTestimonial();
}
break;
case 'PUT':
requireAuth();
if (!$id) {
jsonResponse(['success' => false, 'message' => 'Testimonial ID required'], 400);
}
updateTestimonial($id);
break;
case 'DELETE':
requireAuth();
if (!$id) {
jsonResponse(['success' => false, 'message' => 'ID required'], 400);
}
deleteTestimonial($id);
break;
default:
jsonResponse(['success' => false, 'message' => 'Method not allowed'], 405);
}
function getTestimonials() {
global $db;
$items = $db->getAll('testimonials');
// Filters
$status = $_GET['status'] ?? null;
if ($status) {
$items = array_filter($items, function($i) use ($status) {
return $i['status'] === $status;
});
}
// Sort by date (newest first)
usort($items, function($a, $b) {
return strtotime($b['created_at']) - strtotime($a['created_at']);
});
// Public view: only published
if (!checkAuth()) {
$items = array_filter($items, function($i) {
return ($i['status'] ?? 'draft') === 'published';
});
}
jsonResponse(['success' => true, 'data' => array_values($items)]);
}
function getTestimonial($id) {
global $db;
$item = $db->get('testimonials', $id);
if (!$item) jsonResponse(['success' => false, 'message' => 'Not found'], 404);
jsonResponse(['success' => true, 'data' => $item]);
}
function createTestimonial() {
global $db;
$data = !empty($_POST) ? $_POST : getRequestBody();
if (empty($data['name']) || empty($data['text'])) {
jsonResponse(['success' => false, 'message' => 'Name and Text are required'], 400);
}
// Sanitize text fields
$data['name'] = sanitize($data['name']);
$data['company'] = sanitize($data['company'] ?? '');
$data['role'] = sanitize($data['role'] ?? '');
$data['text'] = sanitize($data['text']);
// Image upload
if (!empty($_FILES['photo'])) {
$upload = handleFileUpload($_FILES['photo'], 'testimonials');
if ($upload['success']) {
$data['photo'] = $upload['path'];
}
}
$data['status'] = $data['status'] ?? 'pending';
$data['rating'] = (float)($data['rating'] ?? 5);
$item = $db->insert('testimonials', $data);
jsonResponse(['success' => true, 'message' => 'Testimonial added', 'data' => $item]);
}
function updateTestimonial($id) {
global $db;
$existing = $db->get('testimonials', $id);
if (!$existing) jsonResponse(['success' => false, 'message' => 'Not found'], 404);
$data = !empty($_POST) ? $_POST : getRequestBody();
if (!empty($_FILES['photo'])) {
$upload = handleFileUpload($_FILES['photo'], 'testimonials');
if ($upload['success']) {
$data['photo'] = $upload['path'];
}
}
// Sanitize editable text fields
if (isset($data['name'])) $data['name'] = sanitize($data['name']);
if (isset($data['company'])) $data['company'] = sanitize($data['company']);
if (isset($data['role'])) $data['role'] = sanitize($data['role']);
if (isset($data['text'])) $data['text'] = sanitize($data['text']);
$item = $db->update('testimonials', $id, $data);
jsonResponse(['success' => true, 'message' => 'Testimonial updated', 'data' => $item]);
}
function deleteTestimonial($id) {
global $db;
$db->delete('testimonials', $id);
jsonResponse(['success' => true, 'message' => 'Testimonial deleted']);
}
+183
View File
@@ -0,0 +1,183 @@
<?php
/**
* MSPE Admin Users API
*/
require_once 'config.php';
$method = $_SERVER['REQUEST_METHOD'];
$id = $_GET['id'] ?? null;
$currentUser = requireAuth();
requireAdminRole($currentUser);
// CSRF protection for write operations
if ($method !== 'GET') {
requireSameOriginRequest();
}
switch ($method) {
case 'GET':
if ($id) {
getUser($id);
} else {
getUsers();
}
break;
case 'POST':
$postData = !empty($_POST) ? $_POST : getRequestBody();
if (!empty($postData['id'])) {
updateUser($postData['id']);
} else {
createUser();
}
break;
case 'PUT':
if (!$id) {
jsonResponse(['success' => false, 'message' => 'User ID required'], 400);
}
updateUser($id);
break;
case 'DELETE':
if (!$id) {
jsonResponse(['success' => false, 'message' => 'ID required'], 400);
}
deleteUser($id);
break;
default:
jsonResponse(['success' => false, 'message' => 'Method not allowed'], 405);
}
function getUsers() {
global $db;
$users = $db->getAll('users');
// Remove sensitive data
foreach ($users as &$user) {
unset($user['password']);
}
jsonResponse(['success' => true, 'data' => array_values($users)]);
}
function getUser($id) {
global $db;
$user = $db->get('users', $id);
if (!$user) jsonResponse(['success' => false, 'message' => 'Not found'], 404);
unset($user['password']);
jsonResponse(['success' => true, 'data' => $user]);
}
function createUser() {
global $db;
$data = !empty($_POST) ? $_POST : getRequestBody();
if (empty($data['username']) || empty($data['password']) || empty($data['email'])) {
jsonResponse(['success' => false, 'message' => 'Username, Email and Password required'], 400);
}
// Enforce minimum password strength
if (strlen($data['password']) < 12) {
jsonResponse(['success' => false, 'message' => 'Password must be at least 12 characters'], 400);
}
// Check if username exists
$existing = $db->query('users', ['username' => $data['username']]);
if (!empty($existing)) {
jsonResponse(['success' => false, 'message' => 'Username already exists'], 400);
}
// Check if email exists
$existingEmail = $db->query('users', ['email' => $data['email']]);
if (!empty($existingEmail)) {
jsonResponse(['success' => false, 'message' => 'Email already in use'], 400);
}
// Validate email format
if (!filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
jsonResponse(['success' => false, 'message' => 'Invalid email address'], 400);
}
// Whitelist allowed roles and statuses
$allowedRoles = ['admin', 'editor'];
$role = in_array($data['role'] ?? 'editor', $allowedRoles, true) ? $data['role'] : 'editor';
$allowedStatuses = ['active', 'inactive'];
$status = in_array($data['status'] ?? 'active', $allowedStatuses, true) ? $data['status'] : 'active';
$user = [
'name' => sanitize($data['name'] ?? ''),
'username' => sanitize($data['username']),
'email' => sanitize($data['email']),
'role' => $role,
'status' => $status,
'password' => password_hash($data['password'], PASSWORD_DEFAULT),
'last_login' => null
];
$created = $db->insert('users', $user);
unset($created['password']);
jsonResponse(['success' => true, 'message' => 'User created', 'data' => $created]);
}
function updateUser($id) {
global $db;
$existing = $db->get('users', $id);
if (!$existing) jsonResponse(['success' => false, 'message' => 'Not found'], 404);
$data = !empty($_POST) ? $_POST : getRequestBody();
$updateData = [];
if (!empty($data['name'])) $updateData['name'] = sanitize($data['name']);
if (!empty($data['email'])) {
if (!filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
jsonResponse(['success' => false, 'message' => 'Invalid email address'], 400);
}
$updateData['email'] = sanitize($data['email']);
}
if (!empty($data['role'])) {
$allowedRoles = ['admin', 'editor'];
$updateData['role'] = in_array($data['role'], $allowedRoles, true) ? $data['role'] : ($existing['role'] ?? 'editor');
}
if (!empty($data['status'])) {
$allowedStatuses = ['active', 'inactive'];
$updateData['status'] = in_array($data['status'], $allowedStatuses, true) ? $data['status'] : ($existing['status'] ?? 'active');
}
// Password update
if (!empty($data['password'])) {
if (strlen($data['password']) < 12) {
jsonResponse(['success' => false, 'message' => 'Password must be at least 12 characters'], 400);
}
$updateData['password'] = password_hash($data['password'], PASSWORD_DEFAULT);
}
$updated = $db->update('users', $id, $updateData);
unset($updated['password']);
jsonResponse(['success' => true, 'message' => 'User updated', 'data' => $updated]);
}
function deleteUser($id) {
global $db;
global $currentUser;
// Prevent deleting self (simplistic check)
if ($currentUser['user_id'] === $id) {
jsonResponse(['success' => false, 'message' => 'Cannot delete yourself'], 400);
}
$db->delete('users', $id);
jsonResponse(['success' => true, 'message' => 'User deleted']);
}
function requireAdminRole($user) {
if (($user['role'] ?? '') !== 'admin') {
jsonResponse(['success' => false, 'message' => 'Forbidden'], 403);
}
}
+902
View File
@@ -0,0 +1,902 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Schedule a consultation with MSPE. Book an appointment at a time that works for you.">
<title>Book a Consultation | MSPE - Architects of Digital Resilience</title>
<link rel="icon" type="image/png" href="images/logo/logo.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Playfair+Display:wght@600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'">
<noscript><link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Playfair+Display:wght@600;700&display=swap" rel="stylesheet"></noscript>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha384-t1nt8BQoYMLFN5p42tRAtuAAFQaCQODekUVeKKZrEnEyp4H2R0RHFz0KWpmj7i8g" crossorigin="anonymous" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha384-t1nt8BQoYMLFN5p42tRAtuAAFQaCQODekUVeKKZrEnEyp4H2R0RHFz0KWpmj7i8g" crossorigin="anonymous"></noscript>
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/pages.css">
<link rel="stylesheet" href="css/ultimate-ui.css" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="css/ultimate-ui.css"></noscript>
<link rel="stylesheet" href="css/header-fix.css">
<link rel="canonical" href="https://mspe.pro/calendar.html">
<!-- Open Graph -->
<meta property="og:type" content="website">
<meta property="og:url" content="https://mspe.pro/calendar.html">
<meta property="og:title" content="Book a Consultation | MSPE - Architects of Digital Resilience">
<meta property="og:description" content="Schedule a consultation with MSPE. Choose your preferred date and time for a free discovery meeting.">
<meta property="og:image" content="https://mspe.pro/images/logo/logo.png">
<meta property="og:site_name" content="MSPE">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="@mspe_pro">
<meta name="twitter:title" content="Book a Consultation with MSPE">
<meta name="twitter:description" content="Schedule a free discovery consultation at a time that works for you.">
<meta name="twitter:image" content="https://mspe.pro/images/logo/logo.png">
<style>
.calendar-wrapper {
background: linear-gradient(135deg, #0a1628 0%, #1a365d 100%);
min-height: calc(100vh - 80px);
padding: calc(80px + 2rem) 0 3rem;
}
.calendar-intro {
text-align: center;
margin-bottom: 2rem;
color: #ffffff;
}
.calendar-intro h1 {
font-size: 2.5rem;
margin-bottom: 0.5rem;
line-height: 1.15;
text-shadow: 0 4px 20px rgba(0, 0, 0, 0.35);
}
.calendar-intro p {
font-size: 1.1rem;
color: #cbd5e1;
}
.calendar-container {
max-width: 1400px;
margin: 0 auto;
padding: 0 1rem;
}
.calendar-grid {
display: grid;
grid-template-columns: 1fr 400px;
gap: 2rem;
}
.calendar-main {
background: rgba(255, 255, 255, 0.03);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 20px;
padding: 2rem;
box-shadow: 0 20px 40px rgba(0,0,0,0.3);
}
.calendar-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.calendar-header h2 {
font-size: 1.75rem;
color: #ffffff;
margin: 0;
}
.calendar-nav {
display: flex;
gap: 0.5rem;
}
.calendar-nav button {
padding: 0.5rem 1rem;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
cursor: pointer;
font-weight: 600;
color: #94a3b8;
transition: all 0.3s ease;
}
.calendar-nav button:hover {
border-color: #0ea5e9;
color: #0ea5e9;
background: rgba(14, 165, 233, 0.1);
}
.calendar-days {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0.5rem;
margin-bottom: 2rem;
}
.calendar-day-header {
text-align: center;
font-weight: 600;
color: #94a3b8;
padding: 0.75rem;
font-size: 0.875rem;
}
.calendar-day {
aspect-ratio: 1;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
padding: 0.5rem;
cursor: pointer;
transition: all 0.3s ease;
background: rgba(255, 255, 255, 0.02);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
color: #e2e8f0;
}
.calendar-day:hover:not(.disabled) {
border-color: #0ea5e9;
background: rgba(14, 165, 233, 0.1);
}
.calendar-day.selected {
background: linear-gradient(135deg, #0ea5e9 0%, #0d9488 100%);
color: white;
border-color: #0ea5e9;
}
.calendar-day.disabled {
opacity: 0.48;
cursor: not-allowed;
}
.calendar-day .day-number {
font-size: 1.25rem;
font-weight: 700;
}
.calendar-day .day-month {
font-size: 0.75rem;
opacity: 0.8;
}
.calendar-day .availability-indicator {
width: 6px;
height: 6px;
border-radius: 50%;
margin-top: 0.25rem;
}
.calendar-day.has-slots .availability-indicator {
background: #10b981;
}
.calendar-day.no-slots .availability-indicator {
background: #ef4444;
}
.time-slots {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 0.75rem;
}
.time-slot {
padding: 0.75rem 1rem;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 10px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
background: rgba(255, 255, 255, 0.05);
font-weight: 600;
color: #e2e8f0;
}
.time-slot:hover:not(.booked):not(.disabled) {
border-color: #0ea5e9;
color: #0ea5e9;
background: rgba(14, 165, 233, 0.15);
}
.time-slot.selected {
background: linear-gradient(135deg, #0ea5e9 0%, #0d9488 100%);
color: white;
border-color: #0ea5e9;
}
.time-slot.booked {
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.3);
color: #f87171;
cursor: not-allowed;
}
.time-slot.booked::after {
content: 'Not Available';
display: block;
font-size: 0.7rem;
font-weight: 500;
margin-top: 0.25rem;
color: #f87171;
}
.time-slot.disabled {
opacity: 0.3;
cursor: not-allowed;
}
.booking-form {
background: rgba(255, 255, 255, 0.03);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 20px;
padding: 2rem;
box-shadow: 0 20px 40px rgba(0,0,0,0.3);
height: fit-content;
}
.booking-form-header {
text-align: center;
margin-bottom: 2rem;
}
.booking-form-header h3 {
font-size: 1.5rem;
color: #ffffff;
margin-bottom: 0.5rem;
}
.selected-slot-display {
background: linear-gradient(135deg, rgba(14, 165, 233, 0.15) 0%, rgba(13, 148, 136, 0.15) 100%);
border: 1px solid rgba(14, 165, 233, 0.3);
border-radius: 12px;
padding: 1rem;
margin-bottom: 1.5rem;
text-align: center;
}
.selected-slot-display p {
margin: 0;
color: #ffffff;
font-weight: 600;
}
.selected-slot-display .slot-date {
font-size: 1.1rem;
margin-bottom: 0.25rem;
}
.selected-slot-display .slot-time {
font-size: 1.25rem;
color: #0ea5e9;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
font-weight: 600;
color: #94a3b8;
margin-bottom: 0.5rem;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 0.875rem 1rem;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 10px;
font-size: 1rem;
transition: all 0.3s ease;
font-family: 'DM Sans', sans-serif;
background: rgba(255, 255, 255, 0.05);
color: #ffffff;
}
.form-group input::placeholder,
.form-group textarea::placeholder {
color: #64748b;
}
.form-group select option {
background: #1a365d;
color: #ffffff;
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: #0ea5e9;
box-shadow: 0 0 0 4px rgba(14, 165, 233, 0.15);
background: rgba(255, 255, 255, 0.08);
}
.btn-book {
width: 100%;
padding: 1rem;
background: linear-gradient(135deg, #0ea5e9 0%, #0d9488 100%);
color: white;
border: none;
border-radius: 10px;
font-size: 1.1rem;
font-weight: 700;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-book:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(14, 165, 233, 0.3);
}
.btn-book:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.info-box {
background: rgba(251, 191, 36, 0.1);
border-left: 4px solid #f59e0b;
padding: 1rem;
border-radius: 8px;
margin-bottom: 1.5rem;
font-size: 0.9rem;
color: #fbbf24;
}
.loading {
display: flex;
justify-content: center;
align-items: center;
padding: 3rem;
}
.loading-spinner {
width: 50px;
height: 50px;
border: 4px solid rgba(255, 255, 255, 0.1);
border-top-color: #0ea5e9;
border-radius: 50%;
animation: spin 1s linear infinite;
}
#time-slots-section h3 {
color: #ffffff;
}
#booking-placeholder {
color: #94a3b8 !important;
}
#booking-placeholder i {
color: rgba(255, 255, 255, 0.2) !important;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (max-width: 1024px) {
.calendar-grid {
grid-template-columns: 1fr;
}
.calendar-wrapper {
padding-top: calc(80px + 1.25rem);
}
.calendar-container {
padding: 0;
}
.calendar-main,
.booking-form {
border-radius: 0;
}
}
</style>
</head>
<body>
<!-- Skip to Content (Accessibility) -->
<a href="#main-content" class="skip-to-content">Skip to main content</a>
<header class="header" id="header">
<nav class="nav container">
<a href="index.html" class="nav-logo">
<img src="images/logo/logo.png" alt="MSPE PRO" class="logo-image" width="200" height="50">
</a>
<div class="nav-menu" id="nav-menu">
<ul class="nav-list">
<li class="nav-item"><a href="index.html" class="nav-link">Home</a></li>
<li class="nav-item"><a href="about.html" class="nav-link">About Us</a></li>
<li class="nav-item dropdown">
<a href="services.html" class="nav-link">Services <i class="fas fa-chevron-down"></i></a>
<ul class="dropdown-menu">
<li><a href="services.html#it-support">IT Support & Maintenance</a></li>
<li><a href="services.html#cybersecurity">Cybersecurity Solutions</a></li>
<li><a href="services.html#cloud">Cloud Services</a></li>
<li><a href="services.html#consulting">Business Consulting</a></li>
</ul>
</li>
<li class="nav-item"><a href="portfolio.html" class="nav-link">Portfolio</a></li>
<li class="nav-item"><a href="news.html" class="nav-link">News & Events</a></li>
<li class="nav-item"><a href="contact.html" class="nav-link">Contact</a></li>
</ul>
</div>
<div class="nav-actions">
<a href="contact.html" class="btn btn-primary">Contact Us</a>
<button class="nav-toggle" id="nav-toggle" aria-label="Toggle navigation menu" aria-expanded="false">
<span></span>
<span></span>
<span></span>
</button>
</div>
</nav>
</header>
<section class="calendar-wrapper">
<div class="calendar-container">
<div class="calendar-intro">
<h1>Book Your Consultation</h1>
<p>Select a date and time that works for you</p>
</div>
<div class="calendar-grid">
<div class="calendar-main">
<div class="calendar-header">
<h2 id="current-month">January 2026</h2>
<div class="calendar-nav">
<button onclick="changeMonth(-1)"><i class="fas fa-chevron-left"></i></button>
<button onclick="changeMonth(0)">Today</button>
<button onclick="changeMonth(1)"><i class="fas fa-chevron-right"></i></button>
</div>
</div>
<div class="calendar-days">
<div class="calendar-day-header">Sun</div>
<div class="calendar-day-header">Mon</div>
<div class="calendar-day-header">Tue</div>
<div class="calendar-day-header">Wed</div>
<div class="calendar-day-header">Thu</div>
<div class="calendar-day-header">Fri</div>
<div class="calendar-day-header">Sat</div>
</div>
<div id="calendar-days" class="calendar-days" style="grid-template-rows: repeat(6, 1fr);">
<div class="loading" style="grid-column: 1 / -1;">
<div class="loading-spinner"></div>
</div>
</div>
<div id="time-slots-section" style="display: none;">
<h3 style="margin: 2rem 0 1rem; color: #ffffff;">Available Time Slots</h3>
<div id="time-slots" class="time-slots"></div>
</div>
</div>
<div class="booking-form">
<div class="booking-form-header">
<h3>Complete Your Booking</h3>
<p style="color: #94a3b8; font-size: 0.9rem;">Fill in your details to confirm your appointment</p>
</div>
<div class="info-box">
<i class="fas fa-info-circle"></i>
All times shown in UTC. We'll send you a confirmation email with your selected time.
</div>
<div style="background: rgba(255, 255, 255, 0.03); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 8px; padding: 1rem; margin-bottom: 1.5rem; font-size: 0.875rem;">
<div style="font-weight: 600; margin-bottom: 0.5rem; color: #ffffff;"><i class="fas fa-question-circle" style="margin-right: 0.5rem; color: #94a3b8;"></i>Calendar Legend</div>
<div style="display: flex; align-items: center; margin-bottom: 0.25rem;">
<div style="width: 10px; height: 10px; border-radius: 50%; background: #10b981; margin-right: 0.5rem;"></div>
<span style="color: #94a3b8;">Available - time slots open for booking</span>
</div>
<div style="display: flex; align-items: center; margin-bottom: 0.25rem;">
<div style="width: 10px; height: 10px; border-radius: 50%; background: #ef4444; margin-right: 0.5rem;"></div>
<span style="color: #94a3b8;">Unavailable - all slots booked or blocked</span>
</div>
<div style="display: flex; align-items: center;">
<div style="width: 10px; height: 10px; border-radius: 50%; background: rgba(255, 255, 255, 0.2); margin-right: 0.5rem;"></div>
<span style="color: #94a3b8;">No slots - no times scheduled for this day</span>
</div>
</div>
<div id="selected-slot-display" class="selected-slot-display" style="display: none;">
<p class="slot-date">Select a date and time</p>
<p class="slot-time"></p>
</div>
<form id="booking-form" style="display: none;">
<div class="form-group">
<label for="first-name">First Name *</label>
<input type="text" id="first-name" name="first_name" required placeholder="John">
</div>
<div class="form-group">
<label for="last-name">Last Name *</label>
<input type="text" id="last-name" name="last_name" required placeholder="Doe">
</div>
<div class="form-group">
<label for="email">Email Address *</label>
<input type="email" id="email" name="email" required placeholder="john@example.com">
</div>
<div class="form-group">
<label for="phone">Phone Number</label>
<input type="tel" id="phone" name="phone" placeholder="+1 (234) 567-890">
</div>
<div class="form-group">
<label for="company">Company Name</label>
<input type="text" id="company" name="company" placeholder="Your Company">
</div>
<div class="form-group">
<label for="service">Service Interest</label>
<select id="service" name="service_interest">
<option value="">Select a service</option>
<option value="it-support">IT Support & Maintenance</option>
<option value="cybersecurity">Cybersecurity Solutions</option>
<option value="cloud">Cloud Services</option>
<option value="consulting">Business Consulting</option>
<option value="general">General Consultation</option>
</select>
</div>
<div class="form-group">
<label for="message">Additional Notes</label>
<textarea id="message" name="message" rows="4" placeholder="Tell us what you'd like to discuss..."></textarea>
</div>
<button type="submit" class="btn-book" id="submit-btn">
Confirm Booking <i class="fas fa-check"></i>
</button>
</form>
<div id="booking-placeholder" style="text-align: center; color: #94a3b8; padding: 2rem;">
<i class="fas fa-calendar-alt" style="font-size: 3rem; margin-bottom: 1rem; opacity: 0.3;"></i>
<p>Select a date and time from the calendar to begin</p>
</div>
</div>
</div>
</div>
</section>
<footer class="footer">
<div class="footer-main">
<div class="container">
<div class="footer-grid">
<div class="footer-brand">
<a href="index.html" class="footer-logo">
<img src="images/logo/logo.png" alt="MSPE PRO" class="logo-image" width="200" height="50">
</a>
<p class="footer-description">We engineer possibilities. Transforming complexity into competitive advantage through innovative technology solutions and uncompromising security.</p>
<div class="footer-social">
<a href="https://www.facebook.com/mspe.pro" class="social-link" title="Facebook" target="_blank" rel="noopener noreferrer"><i class="fab fa-facebook-f"></i></a>
<a href="https://www.linkedin.com/company/mspe-pro" class="social-link" title="LinkedIn" target="_blank" rel="noopener noreferrer"><i class="fab fa-linkedin-in"></i></a>
<a href="https://x.com/mspe_pro" class="social-link" title="X (Twitter)" target="_blank" rel="noopener noreferrer"><i class="fab fa-twitter"></i></a>
<a href="https://www.instagram.com/mspe.pro" class="social-link" title="Instagram" target="_blank" rel="noopener noreferrer"><i class="fab fa-instagram"></i></a>
</div>
</div>
<div class="footer-links">
<h4 class="footer-title">Quick Links</h4>
<ul>
<li><a href="about.html">About Us</a></li>
<li><a href="services.html">Our Services</a></li>
<li><a href="portfolio.html">Portfolio</a></li>
<li><a href="news.html">News &amp; Events</a></li>
<li><a href="contact.html">Contact Us</a></li>
</ul>
</div>
<div class="footer-links">
<h4 class="footer-title">Services</h4>
<ul>
<li><a href="services.html#it-support">IT Support &amp; Maintenance</a></li>
<li><a href="services.html#cybersecurity">Cybersecurity Solutions</a></li>
<li><a href="services.html#cloud">Cloud Services</a></li>
<li><a href="services.html#consulting">Business Consulting</a></li>
</ul>
</div>
<div class="footer-contact">
<h4 class="footer-title">Contact Info</h4>
<ul class="contact-list">
<li>
<i class="fas fa-envelope"></i>
<span data-site-email>info@mspe.pro</span>
</li>
<li>
<i class="fas fa-phone"></i>
<a href="tel:+96178782023" data-site-phone style="color:inherit;">+961 78 782 023</a>
</li>
<li>
<i class="fas fa-clock"></i>
<span>Available for consultations</span>
</li>
<li>
<i class="fas fa-map-marker-alt"></i>
<span>Beirut, Lebanon</span>
</li>
</ul>
</div>
</div>
</div>
</div>
<div class="footer-bottom">
<div class="container">
<p>&copy; 2026 MSPE - Architects of Digital Resilience. All Rights Reserved.</p>
<div class="footer-bottom-links">
<a href="privacy.html">Privacy Policy</a>
<a href="terms.html">Terms of Service</a>
</div>
</div>
</div>
</footer>
<script src="js/main.js" defer></script>
<script>
let currentDate = new Date();
let selectedDate = null;
let selectedTime = null;
let availability = {};
document.addEventListener('DOMContentLoaded', function() {
initCalendar();
loadAvailability();
});
async function loadAvailability() {
const dateFrom = formatDate(new Date(currentDate.getFullYear(), currentDate.getMonth(), 1));
const dateTo = formatDate(new Date(currentDate.getFullYear(), currentDate.getMonth() + 2, 0));
try {
// Use public-availability endpoint - only shows available/not available status
// Does not expose any booking details or personal information
const response = await fetch(`api/bookings.php?action=public-availability&date_from=${dateFrom}&date_to=${dateTo}`);
const result = await response.json();
if (result.success) {
// Clear previous data for this range
availability = {};
result.data.forEach(slot => {
if (!availability[slot.date]) {
availability[slot.date] = [];
}
availability[slot.date].push({
time: slot.time,
available: slot.available
});
});
renderCalendar();
}
} catch (error) {
console.error('Error loading availability:', error);
}
}
function initCalendar() {
document.getElementById('nav-toggle').addEventListener('click', function() {
document.getElementById('nav-menu').classList.toggle('active');
});
}
function changeMonth(delta) {
if (delta === 0) {
currentDate = new Date();
} else {
currentDate.setMonth(currentDate.getMonth() + delta);
}
loadAvailability();
}
function formatDate(date) {
return date.toISOString().split('T')[0];
}
function renderCalendar() {
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
document.getElementById('current-month').textContent = `${monthNames[month]} ${year}`;
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const startingDay = firstDay.getDay();
const totalDays = lastDay.getDate();
const calendarDays = document.getElementById('calendar-days');
let html = '';
for (let i = 0; i < startingDay; i++) {
html += '<div class="calendar-day disabled"></div>';
}
const today = new Date();
today.setHours(0, 0, 0, 0);
for (let day = 1; day <= totalDays; day++) {
const dateStr = formatDate(new Date(year, month, day));
const dateObj = new Date(year, month, day);
const isPast = dateObj < today;
const slots = availability[dateStr] || [];
const hasAvailableSlots = slots.some(s => s.available);
const hasAnySlots = slots.length > 0;
const selectedClass = selectedDate === dateStr ? 'selected' : '';
const disabledClass = isPast ? 'disabled' : '';
const slotsClass = hasAnySlots ? (hasAvailableSlots ? 'has-slots' : 'no-slots') : '';
html += `
<div class="calendar-day ${selectedClass} ${disabledClass} ${slotsClass}"
onclick="${isPast ? '' : `selectDate('${dateStr}')`}">
<span class="day-number">${day}</span>
<span class="day-month">${monthNames[month].substring(0, 3)}</span>
${hasAnySlots ? '<div class="availability-indicator"></div>' : ''}
</div>
`;
}
const remainingSlots = 42 - startingDay - totalDays;
for (let i = 0; i < remainingSlots; i++) {
html += '<div class="calendar-day disabled"></div>';
}
calendarDays.innerHTML = html;
if (selectedDate) {
renderTimeSlots(selectedDate);
}
}
function selectDate(dateStr) {
selectedDate = dateStr;
selectedTime = null;
document.querySelectorAll('.calendar-day').forEach(day => day.classList.remove('selected'));
event.target.closest('.calendar-day').classList.add('selected');
renderTimeSlots(dateStr);
}
function renderTimeSlots(dateStr) {
const slots = availability[dateStr] || [];
const container = document.getElementById('time-slots');
const section = document.getElementById('time-slots-section');
if (slots.length === 0) {
section.style.display = 'block';
container.innerHTML = '<p style="grid-column: 1/-1; text-align: center; color: #94a3b8; padding: 1rem;">No time slots available for this date. Please select another day.</p>';
document.getElementById('booking-form').style.display = 'none';
document.getElementById('booking-placeholder').style.display = 'block';
document.getElementById('selected-slot-display').style.display = 'none';
return;
}
section.style.display = 'block';
let html = '';
slots.forEach(slot => {
const isAvailable = slot.available;
const bookedClass = !isAvailable ? 'booked' : '';
const selectedClass = selectedTime === slot.time && isAvailable ? 'selected' : '';
html += `
<div class="time-slot ${bookedClass} ${selectedClass}"
onclick="${isAvailable ? `selectTime('${slot.time}')` : ''}">
${formatTime(slot.time)}
</div>
`;
});
container.innerHTML = html;
}
function formatTime(timeStr) {
const [hours, minutes] = timeStr.split(':');
const date = new Date();
date.setHours(parseInt(hours), parseInt(minutes), 0);
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
}
function selectTime(time) {
selectedTime = time;
document.querySelectorAll('.time-slot').forEach(slot => slot.classList.remove('selected'));
event.target.closest('.time-slot').classList.add('selected');
updateBookingForm();
}
function updateBookingForm() {
const display = document.getElementById('selected-slot-display');
const form = document.getElementById('booking-form');
const placeholder = document.getElementById('booking-placeholder');
const dateObj = new Date(selectedDate);
const dateStr = dateObj.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
const timeStr = formatTime(selectedTime);
display.style.display = 'block';
display.querySelector('.slot-date').textContent = dateStr;
display.querySelector('.slot-time').textContent = timeStr;
form.style.display = 'block';
placeholder.style.display = 'none';
}
document.getElementById('booking-form').addEventListener('submit', async function(e) {
e.preventDefault();
if (!selectedDate || !selectedTime) {
alert('Please select a date and time');
return;
}
const submitBtn = document.getElementById('submit-btn');
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Booking...';
const formData = new FormData(this);
const data = Object.fromEntries(formData);
data.booking_date = selectedDate;
data.booking_time = selectedTime;
data.duration = 60;
try {
const response = await fetch('api/bookings.php?action=book', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
const result = await response.json();
if (result.success) {
alert('Booking request submitted successfully! We will send you a confirmation email shortly.');
this.reset();
document.getElementById('booking-form').style.display = 'none';
document.getElementById('selected-slot-display').style.display = 'none';
document.getElementById('booking-placeholder').style.display = 'block';
selectedDate = null;
selectedTime = null;
loadAvailability();
} else {
alert(result.message || 'Failed to submit booking. Please try again.');
}
} catch (error) {
console.error('Booking error:', error);
alert('An error occurred. Please try again.');
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = 'Confirm Booking <i class="fas fa-check"></i>';
}
});
</script>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
{
"require": {
"firebase/php-jwt": "^7.0"
}
}
+82
View File
@@ -0,0 +1,82 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "923194bb016a246f3de4bebc635e8ab9",
"packages": [
{
"name": "firebase/php-jwt",
"version": "v7.0.2",
"source": {
"type": "git",
"url": "https://github.com/firebase/php-jwt.git",
"reference": "5645b43af647b6947daac1d0f659dd1fbe8d3b65"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/firebase/php-jwt/zipball/5645b43af647b6947daac1d0f659dd1fbe8d3b65",
"reference": "5645b43af647b6947daac1d0f659dd1fbe8d3b65",
"shasum": ""
},
"require": {
"php": "^8.0"
},
"require-dev": {
"guzzlehttp/guzzle": "^7.4",
"phpspec/prophecy-phpunit": "^2.0",
"phpunit/phpunit": "^9.5",
"psr/cache": "^2.0||^3.0",
"psr/http-client": "^1.0",
"psr/http-factory": "^1.0"
},
"suggest": {
"ext-sodium": "Support EdDSA (Ed25519) signatures",
"paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present"
},
"type": "library",
"autoload": {
"psr-4": {
"Firebase\\JWT\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Neuman Vong",
"email": "neuman+pear@twilio.com",
"role": "Developer"
},
{
"name": "Anant Narayanan",
"email": "anant@php.net",
"role": "Developer"
}
],
"description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.",
"homepage": "https://github.com/firebase/php-jwt",
"keywords": [
"jwt",
"php"
],
"support": {
"issues": "https://github.com/firebase/php-jwt/issues",
"source": "https://github.com/firebase/php-jwt/tree/v7.0.2"
},
"time": "2025-12-16T22:17:28+00:00"
}
],
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {},
"prefer-stable": false,
"prefer-lowest": false,
"platform": {},
"platform-dev": {},
"plugin-api-version": "2.6.0"
}
+553
View File
@@ -0,0 +1,553 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Contact MSPE in Beirut, Lebanon. Schedule a free consultation or reach us by email, phone, or WhatsApp to discuss your IT support, cybersecurity, or cloud service needs.">
<title>Contact Us | MSPE - Architects of Digital Resilience</title>
<link rel="icon" type="image/png" href="images/logo/logo.png">
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Playfair+Display:wght@600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'">
<noscript><link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Playfair+Display:wght@600;700&display=swap" rel="stylesheet"></noscript>
<!-- Icons -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha384-t1nt8BQoYMLFN5p42tRAtuAAFQaCQODekUVeKKZrEnEyp4H2R0RHFz0KWpmj7i8g" crossorigin="anonymous" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha384-t1nt8BQoYMLFN5p42tRAtuAAFQaCQODekUVeKKZrEnEyp4H2R0RHFz0KWpmj7i8g" crossorigin="anonymous"></noscript>
<!-- Styles -->
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/pages.css">
<link rel="stylesheet" href="css/ultimate-ui.css" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="css/ultimate-ui.css"></noscript>
<link rel="stylesheet" href="css/header-fix.css">
<link rel="canonical" href="https://mspe.pro/contact.html">
<!-- Structured Data: FAQ -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "What is your typical response time?",
"acceptedAnswer": { "@type": "Answer", "text": "We respond to all inquiries within 24 business hours. For existing clients with support contracts, our response time is typically under 4 hours, with critical issues addressed within 1 hour." }
},
{
"@type": "Question",
"name": "How does the consultation process work?",
"acceptedAnswer": { "@type": "Answer", "text": "We begin with a free discovery consultation to understand your environment and priorities. After that, we share practical recommendations and a clear implementation scope with no obligation." }
},
{
"@type": "Question",
"name": "What industries do you serve?",
"acceptedAnswer": { "@type": "Answer", "text": "We serve a wide range of industries including finance, healthcare, retail, manufacturing, education, and government. Our solutions are customized to meet industry-specific compliance and security requirements." }
},
{
"@type": "Question",
"name": "Do you provide remote support?",
"acceptedAnswer": { "@type": "Answer", "text": "Absolutely. We provide comprehensive remote support services for clients worldwide. Our secure remote access tools allow us to resolve most issues quickly without requiring an on-site visit." }
},
{
"@type": "Question",
"name": "How do we begin a project with MSPE?",
"acceptedAnswer": { "@type": "Answer", "text": "Getting started is straightforward. Submit the contact form, call us, or message us on WhatsApp and we will arrange a free consultation, then provide a clear proposal and timeline." }
}
]
}
</script>
<!-- Open Graph -->
<meta property="og:type" content="website">
<meta property="og:url" content="https://mspe.pro/contact.html">
<meta property="og:title" content="Contact Us | MSPE - Architects of Digital Resilience">
<meta property="og:description" content="Connect with MSPE. Let's discuss how we can transform your technology landscape and build your digital resilience.">
<meta property="og:image" content="https://mspe.pro/images/logo/logo.png">
<meta property="og:site_name" content="MSPE">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="@mspe_pro">
<meta name="twitter:title" content="Contact MSPE - Let's Build Together">
<meta name="twitter:description" content="Ready to transform your digital landscape? Let's start a conversation.">
<meta name="twitter:image" content="https://mspe.pro/images/logo/logo.png">
</head>
<body>
<!-- Skip to Content (Accessibility) -->
<a href="#main-content" class="skip-to-content">Skip to main content</a>
<!-- Preloader -->
<div class="preloader">
<div class="preloader-inner">
<div class="preloader-logo">
<img src="images/logo/logo.png" alt="MSPE PRO" style="height: 50px;" width="200" height="50">
</div>
<div class="preloader-spinner"></div>
</div>
</div>
<!-- Header -->
<header class="header" id="header">
<nav class="nav container">
<a href="index.html" class="nav-logo">
<img src="images/logo/logo.png" alt="MSPE PRO" class="logo-image" width="200" height="50">
</a>
<div class="nav-menu" id="nav-menu">
<ul class="nav-list">
<li class="nav-item"><a href="index.html" class="nav-link">Home</a></li>
<li class="nav-item"><a href="about.html" class="nav-link">About Us</a></li>
<li class="nav-item dropdown">
<a href="services.html" class="nav-link">
Services <i class="fas fa-chevron-down"></i>
</a>
<ul class="dropdown-menu">
<li><a href="services.html#it-support">IT Support & Maintenance</a></li>
<li><a href="services.html#cybersecurity">Cybersecurity Solutions</a></li>
<li><a href="services.html#cloud">Cloud Services</a></li>
<li><a href="services.html#consulting">Business Consulting</a></li>
</ul>
</li>
<li class="nav-item"><a href="portfolio.html" class="nav-link">Portfolio</a></li>
<li class="nav-item"><a href="news.html" class="nav-link">News & Events</a></li>
<li class="nav-item"><a href="contact.html" class="nav-link active">Contact</a></li>
</ul>
</div>
<div class="nav-actions">
<a href="calendar.html" class="btn btn-primary">Book Meeting</a>
<button class="nav-toggle" id="nav-toggle" aria-label="Toggle navigation menu" aria-expanded="false">
<span></span>
<span></span>
<span></span>
</button>
</div>
</nav>
</header>
<!-- Page Hero -->
<section class="page-hero">
<div class="page-hero-bg">
<div class="hero-shape hero-shape-1"></div>
<div class="hero-shape hero-shape-2"></div>
<div class="hero-particles"></div>
</div>
<div class="container">
<div class="page-hero-content">
<h1 class="page-title">Let's Build <span class="text-gradient">Together</span></h1>
<p class="page-subtitle">Ready to transform your digital landscape? Let's start a conversation about your vision and how we can help make it reality.</p>
</div>
</div>
</section>
<!-- Contact Cards -->
<section class="contact-cards-section">
<div class="container">
<div class="contact-cards">
<div class="contact-card">
<div class="contact-card-icon">
<i class="fas fa-globe"></i>
</div>
<h3>Global Reach</h3>
<p>We work with clients worldwide.<br>Remote-first approach.</p>
</div>
<a href="calendar.html" class="contact-card" style="text-decoration: none; color: inherit;">
<div class="contact-card-icon">
<i class="fas fa-calendar-alt"></i>
</div>
<h3>Book a Meeting</h3>
<p>Schedule a consultation</p>
<p>Choose your preferred time</p>
</a>
<div class="contact-card">
<div class="contact-card-icon">
<i class="fas fa-envelope"></i>
</div>
<h3>Email Us</h3>
<p><a href="mailto:info@mspe.pro" data-site-email>info@mspe.pro</a></p>
<p>We respond promptly</p>
</div>
<div class="contact-card">
<div class="contact-card-icon">
<i class="fas fa-phone"></i>
</div>
<h3>Call Us</h3>
<p><a href="tel:+96178782023" style="color:inherit;" data-site-phone>+961 78 782 023</a></p>
<p>We're here to help</p>
</div>
<a href="https://wa.me/96178782023" class="contact-card" style="text-decoration: none; color: inherit;" target="_blank" rel="noopener noreferrer">
<div class="contact-card-icon">
<i class="fab fa-whatsapp" style="color: #25d366;"></i>
</div>
<h3>WhatsApp</h3>
<p>Message us directly</p>
<p>Quick replies, no wait</p>
</a>
<div class="contact-card">
<div class="contact-card-icon">
<i class="fas fa-map-marker-alt"></i>
</div>
<h3>Our Location</h3>
<p>Based in Beirut, Lebanon</p>
<p>Serving clients worldwide</p>
</div>
</div>
</div>
</section>
<!-- Contact Form & Map -->
<section class="contact-main">
<div class="container">
<div class="contact-grid">
<!-- Contact Form -->
<div class="contact-form-wrapper">
<div class="form-header">
<h2>Send Us a <span class="text-gradient">Message</span></h2>
<p>Fill out the form below and our team will get back to you within 24 hours</p>
</div>
<form class="contact-form" id="contact-form">
<div class="form-row">
<div class="form-group">
<label for="first-name">First Name *</label>
<input type="text" id="first-name" name="first_name" required placeholder="John">
</div>
<div class="form-group">
<label for="last-name">Last Name *</label>
<input type="text" id="last-name" name="last_name" required placeholder="Doe">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="email">Email Address *</label>
<input type="email" id="email" name="email" required placeholder="john@example.com">
</div>
<div class="form-group">
<label for="phone">Phone Number</label>
<input type="tel" id="phone" name="phone" placeholder="+1 (234) 567-890">
</div>
</div>
<div class="form-group">
<label for="company">Company Name</label>
<input type="text" id="company" name="company" placeholder="Your Company">
</div>
<div class="form-group">
<label for="service">Service Interest</label>
<select id="service" name="service">
<option value="">Select a service</option>
<option value="it-support">IT Support & Maintenance</option>
<option value="cybersecurity">Cybersecurity Solutions</option>
<option value="cloud">Cloud Services</option>
<option value="consulting">Business Consulting</option>
<option value="other">Other</option>
</select>
</div>
<div class="form-group">
<label for="budget">Estimated Budget</label>
<select id="budget" name="budget">
<option value="">Select budget range</option>
<option value="under-5k">Under $5,000</option>
<option value="5k-10k">$5,000 - $10,000</option>
<option value="10k-25k">$10,000 - $25,000</option>
<option value="25k-50k">$25,000 - $50,000</option>
<option value="over-50k">Over $50,000</option>
</select>
</div>
<div class="form-group">
<label for="message">Your Message *</label>
<textarea id="message" name="message" rows="5" required placeholder="Tell us about your project or inquiry..."></textarea>
</div>
<div class="form-group checkbox-group">
<label class="checkbox-label">
<input type="checkbox" name="newsletter" value="yes">
<span class="checkmark"></span>
Subscribe to our newsletter for updates and insights
</label>
</div>
<div class="form-group checkbox-group">
<label class="checkbox-label">
<input type="checkbox" name="privacy" required>
<span class="checkmark"></span>
I agree to the <a href="privacy.html">Privacy Policy</a> and <a href="terms.html">Terms of Service</a> *
</label>
</div>
<div class="cf-turnstile" data-sitekey="REPLACE_WITH_YOUR_TURNSTILE_SITE_KEY" data-theme="dark" style="margin-bottom: 1rem;"></div>
<button type="submit" class="btn btn-primary btn-block">
<span>Send Message</span>
<i class="fas fa-paper-plane"></i>
</button>
<div class="form-message" id="form-message"></div>
</form>
</div>
<!-- Map & Additional Info -->
<div class="contact-sidebar">
<div class="map-container">
<div class="map-placeholder">
<iframe
src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d26520.509339942498!2d35.48!3d33.89!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x151f17215880a78f%3A0x729182bae99836b4!2sBeirut%2C%20Lebanon!5e0!3m2!1sen!2slb!4v1620000000000!5m2!1sen!2slb"
width="100%"
height="300"
style="border:0;"
allowfullscreen=""
loading="lazy"
referrerpolicy="no-referrer-when-downgrade">
</iframe>
</div>
</div>
<div class="sidebar-info">
<h3>The MSPE Difference</h3>
<ul class="info-list">
<li>
<i class="fas fa-check-circle"></i>
<span>Fresh perspectives, innovative solutions</span>
</li>
<li>
<i class="fas fa-check-circle"></i>
<span>Founding partner pricing available</span>
</li>
<li>
<i class="fas fa-check-circle"></i>
<span>Direct access to our expert team</span>
</li>
<li>
<i class="fas fa-check-circle"></i>
<span>Security-first approach to everything</span>
</li>
<li>
<i class="fas fa-check-circle"></i>
<span>Tailored solutions, never templates</span>
</li>
<li>
<i class="fas fa-map-marker-alt"></i>
<span>Based in Beirut, Lebanon &mdash; serving clients globally</span>
</li>
</ul>
</div>
<div class="sidebar-cta">
<h3>Ready to Start?</h3>
<p>Fill out the form and we will schedule a consultation to discuss your requirements</p>
<a href="mailto:info@mspe.pro" class="btn btn-outline btn-block" data-site-email>
<i class="fas fa-envelope"></i> Email Directly
</a>
</div>
<div class="social-connect">
<h3>Connect With Us</h3>
<div class="social-links">
<a href="https://www.facebook.com/mspe.pro" class="social-link" title="Facebook" aria-label="Facebook" target="_blank" rel="noopener noreferrer">
<i class="fab fa-facebook-f"></i>
</a>
<a href="https://www.linkedin.com/company/mspe-pro" class="social-link" title="LinkedIn" aria-label="LinkedIn" target="_blank" rel="noopener noreferrer">
<i class="fab fa-linkedin-in"></i>
</a>
<a href="https://x.com/mspe_pro" class="social-link" title="X (Twitter)" aria-label="Twitter" target="_blank" rel="noopener noreferrer">
<i class="fab fa-twitter"></i>
</a>
<a href="https://www.instagram.com/mspe.pro" class="social-link" title="Instagram" aria-label="Instagram" target="_blank" rel="noopener noreferrer">
<i class="fab fa-instagram"></i>
</a>
<a href="https://wa.me/96178782023" class="social-link" title="WhatsApp" aria-label="WhatsApp" target="_blank" rel="noopener noreferrer">
<i class="fab fa-whatsapp"></i>
</a>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- FAQ Section -->
<section class="faq-section">
<div class="container">
<div class="section-header text-center">
<h2 class="section-title">Frequently Asked <span class="text-gradient">Questions</span></h2>
<p class="section-subtitle">Quick answers to common questions about our services</p>
</div>
<div class="faq-grid">
<div class="faq-item">
<div class="faq-question">
<h4>What is your typical response time?</h4>
<span class="faq-toggle"><i class="fas fa-plus"></i></span>
</div>
<div class="faq-answer">
<p>We respond to all inquiries within 24 business hours. For existing clients with support contracts, our response time is typically under 4 hours, with critical issues addressed within 1 hour.</p>
</div>
</div>
<div class="faq-item">
<div class="faq-question">
<h4>How does the consultation process work?</h4>
<span class="faq-toggle"><i class="fas fa-plus"></i></span>
</div>
<div class="faq-answer">
<p>We begin with a discovery consultation to understand your environment and priorities. After that, we share practical recommendations and a clear implementation scope.</p>
</div>
</div>
<div class="faq-item">
<div class="faq-question">
<h4>What industries do you serve?</h4>
<span class="faq-toggle"><i class="fas fa-plus"></i></span>
</div>
<div class="faq-answer">
<p>We serve a wide range of industries including finance, healthcare, retail, manufacturing, education, and government. Our solutions are customized to meet industry-specific compliance and security requirements.</p>
</div>
</div>
<div class="faq-item">
<div class="faq-question">
<h4>Do you provide remote support?</h4>
<span class="faq-toggle"><i class="fas fa-plus"></i></span>
</div>
<div class="faq-answer">
<p>Absolutely! We provide comprehensive remote support services for clients worldwide. Our secure remote access tools allow us to resolve most issues quickly without requiring an on-site visit.</p>
</div>
</div>
<div class="faq-item">
<div class="faq-question">
<h4>What are your payment options?</h4>
<span class="faq-toggle"><i class="fas fa-plus"></i></span>
</div>
<div class="faq-answer">
<p>We offer flexible payment options including monthly retainers, project-based billing, and hourly rates. We accept credit cards, bank transfers, and can accommodate various billing cycles to suit your needs.</p>
</div>
</div>
<div class="faq-item">
<div class="faq-question">
<h4>How do we begin a project with MSPE?</h4>
<span class="faq-toggle"><i class="fas fa-plus"></i></span>
</div>
<div class="faq-answer">
<p>Getting started is straightforward. Submit the form above or contact us directly and we will arrange a consultation, then provide a clear proposal and timeline.</p>
</div>
</div>
</div>
</div>
</section>
<!-- CTA Section -->
<section class="cta-section">
<div class="cta-bg">
<div class="cta-shape cta-shape-1"></div>
<div class="cta-shape cta-shape-2"></div>
</div>
<div class="container">
<div class="cta-content">
<h2 class="cta-title">Ready to Transform Your IT Infrastructure?</h2>
<p class="cta-text">Let's discuss how MSPE can help secure and optimize your business technology</p>
<div class="cta-buttons">
<a href="#contact-form" class="btn btn-white">Schedule Consultation</a>
<a href="services.html" class="btn btn-outline-white">
<i class="fas fa-th-large"></i> View Our Services
</a>
</div>
</div>
</div>
</section>
<!-- Footer -->
<footer class="footer">
<div class="footer-main">
<div class="container">
<div class="footer-grid">
<div class="footer-brand">
<a href="index.html" class="footer-logo">
<img src="images/logo/logo.png" alt="MSPE PRO" class="logo-image" width="200" height="50">
</a>
<p class="footer-description">We engineer possibilities. Transforming complexity into competitive advantage through innovative technology solutions and uncompromising security.</p>
<div class="footer-social">
<a href="https://www.facebook.com/mspe.pro" class="social-link" title="Facebook" target="_blank" rel="noopener noreferrer"><i class="fab fa-facebook-f"></i></a>
<a href="https://www.linkedin.com/company/mspe-pro" class="social-link" title="LinkedIn" target="_blank" rel="noopener noreferrer"><i class="fab fa-linkedin-in"></i></a>
<a href="https://x.com/mspe_pro" class="social-link" title="X (Twitter)" target="_blank" rel="noopener noreferrer"><i class="fab fa-twitter"></i></a>
<a href="https://www.instagram.com/mspe.pro" class="social-link" title="Instagram" target="_blank" rel="noopener noreferrer"><i class="fab fa-instagram"></i></a>
</div>
</div>
<div class="footer-links">
<h4 class="footer-title">Quick Links</h4>
<ul>
<li><a href="about.html">About Us</a></li>
<li><a href="services.html">Our Services</a></li>
<li><a href="portfolio.html">Portfolio</a></li>
<li><a href="news.html">News & Events</a></li>
<li><a href="contact.html">Contact Us</a></li>
</ul>
</div>
<div class="footer-links">
<h4 class="footer-title">Services</h4>
<ul>
<li><a href="services.html#it-support">IT Support & Maintenance</a></li>
<li><a href="services.html#cybersecurity">Cybersecurity Solutions</a></li>
<li><a href="services.html#cloud">Cloud Services</a></li>
<li><a href="services.html#consulting">Business Consulting</a></li>
</ul>
</div>
<div class="footer-contact">
<h4 class="footer-title">Contact Info</h4>
<ul class="contact-list">
<li>
<i class="fas fa-envelope"></i>
<span data-site-email>info@mspe.pro</span>
</li>
<li>
<i class="fas fa-phone"></i>
<a href="tel:+96178782023" data-site-phone style="color:inherit;">+961 78 782 023</a>
</li>
<li>
<i class="fas fa-clock"></i>
<span>Available for consultations</span>
</li>
<li>
<i class="fas fa-map-marker-alt"></i>
<span>Beirut, Lebanon</span>
</li>
</ul>
</div>
</div>
</div>
</div>
<div class="footer-bottom">
<div class="container">
<p>&copy; 2026 MSPE - Architects of Digital Resilience. All Rights Reserved.</p>
<div class="footer-bottom-links">
<a href="privacy.html">Privacy Policy</a>
<a href="terms.html">Terms of Service</a>
</div>
</div>
</div>
</footer>
<!-- Back to Top -->
<button class="back-to-top" id="back-to-top" aria-label="Back to top">
<i class="fas fa-chevron-up"></i>
</button>
<!-- Scripts -->
<script src="js/main.js" defer></script>
<script src="js/ultimate-ui.js" defer></script>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
</body>
</html>
+50
View File
@@ -0,0 +1,50 @@
/* Header and Logo Improvements */
/* Ensure header starts transparent - works with ultimate-ui.css */
.header {
transition: background-color 0.3s ease, padding 0.3s ease;
}
/* Scrolled state becomes solid/dark */
.header.scrolled {
padding-top: 5px;
padding-bottom: 5px;
}
/* Premium UI - let header start transparent */
body.premium-ui .header {
background: transparent !important;
border-bottom: none !important;
}
body.premium-ui .header.scrolled {
background: rgba(3, 7, 16, 0.97) !important;
border-bottom: 1px solid rgba(148, 163, 184, 0.1) !important;
}
/* Logo Styling */
.logo-image {
display: block;
height: 44px;
width: auto !important; /* override HTML width attribute */
max-width: none;
object-fit: contain;
transition: height 0.3s ease;
}
/* Scale logo on scroll */
.header.scrolled .logo-image {
height: 36px;
}
/* Improve Navigation */
.nav {
transition: height 0.3s ease, padding 0.3s ease;
height: 80px; /* Initial height */
}
.header.scrolled .nav {
height: 60px; /* Reduced height on scroll */
padding: 10px 0;
}
+2367
View File
File diff suppressed because it is too large Load Diff
+2608
View File
File diff suppressed because it is too large Load Diff
+1753
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
# Protect data directory from direct access
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
[]
+1
View File
@@ -0,0 +1 @@
[]
@@ -0,0 +1 @@
{"success":true,"data":{"bookings":{"this_week":0,"pending":0,"confirmed":0,"available_slots":0,"confirmation_rate":0,"trend":0,"total":0},"messages":{"total":0,"unread":0,"this_week":0},"news":{"total":0,"published":0,"draft":0},"subscribers":{"total":0,"active":0}}}
+1
View File
@@ -0,0 +1 @@
[]
+50
View File
@@ -0,0 +1,50 @@
[
{
"id": "news_001",
"title": "5 Cybersecurity Threats Facing Beirut Businesses in 2026",
"slug": "cybersecurity-threats-beirut-2026",
"excerpt": "From ransomware targeting SMBs to supply chain attacks on Lebanese enterprises — we break down the five most critical threats organizations face right now, and the practical controls that make the biggest difference.",
"content": "Ransomware, phishing, supply chain attacks, insider threats, and unpatched vulnerabilities continue to be the leading causes of breaches for businesses across Lebanon and the MENA region. In this article, we outline the most common attack vectors we encounter in our assessment work and the practical controls that make the biggest difference. The cost of a breach for an SMB now averages $200,000+ when downtime, recovery, and reputational damage are factored in — yet most of the high-impact controls cost far less than the risk they mitigate.",
"category": "insights",
"featured_image": "",
"status": "published",
"created_at": "2026-02-10 09:00:00",
"updated_at": "2026-02-10 09:00:00"
},
{
"id": "news_002",
"title": "Microsoft 365 Migration: What Lebanon's SMBs Need to Know",
"slug": "microsoft-365-migration-lebanon-smbs",
"excerpt": "Moving from on-premises Exchange and local file shares to Microsoft 365 can cut infrastructure costs by 4060%. Here's what the migration process actually looks like — and the three pitfalls that derail most projects.",
"content": "For small and medium businesses in Lebanon still running aging on-premises infrastructure, Microsoft 365 offers a compelling path to reduced costs, better uptime, and built-in security. Based on multiple migrations we have executed for Lebanese firms, this guide covers the planning phase, data migration, identity setup with Azure AD, and the staff training that separates a smooth cutover from a painful rollback. The three most common failure points: skipping a pre-migration MX record audit, underestimating shared mailbox complexity, and deploying without Conditional Access policies.",
"category": "insights",
"featured_image": "",
"status": "published",
"created_at": "2026-02-03 09:00:00",
"updated_at": "2026-02-03 09:00:00"
},
{
"id": "news_003",
"title": "Why Your IT Support SLA Must Include a 4-Hour Response Guarantee",
"slug": "it-support-sla-4-hour-response",
"excerpt": "Not all SLAs are equal. We explain what response time, resolution time, and escalation tiers actually mean — and why the first four hours after an incident are the most critical window for damage control.",
"content": "Many businesses in Lebanon sign IT support contracts without scrutinising what the SLA actually promises. The difference between an 8-hour and a 4-hour first response can mean thousands of dollars in lost productivity during a critical outage. In this post, we break down what to demand in a managed services agreement, how to test whether your provider is meeting it, and what escalation tiers should look like for different severity levels. We also explain how our own 4-hour maximum first-response commitment is operationalised — and what happens when we miss it.",
"category": "insights",
"featured_image": "",
"status": "published",
"created_at": "2026-01-27 09:00:00",
"updated_at": "2026-01-27 09:00:00"
},
{
"id": "news_004",
"title": "ISO 27001 Compliance: A Practical Roadmap for Lebanese Financial Firms",
"slug": "iso-27001-compliance-lebanon-financial-firms",
"excerpt": "Regulatory pressure on financial institutions in Lebanon is increasing. ISO 27001 certification is becoming a competitive differentiator — here is a realistic 90-day roadmap to achieving it.",
"content": "Financial advisory firms, exchanges, and fintech companies in Lebanon face growing pressure from regulators and enterprise clients to demonstrate formal information security controls. ISO 27001 is the international standard most commonly required. This guide outlines the gap analysis, policy development, technical controls, and audit preparation steps needed to achieve certification — based on our experience delivering this process for multiple clients in the Beirut financial sector. The full journey from kickoff to certification typically runs 90120 days when a dedicated internal champion is in place.",
"category": "insights",
"featured_image": "",
"status": "published",
"created_at": "2026-01-20 09:00:00",
"updated_at": "2026-01-20 09:00:00"
}
]
+1
View File
@@ -0,0 +1 @@
[]
+54
View File
@@ -0,0 +1,54 @@
[
{
"id": "svc_001",
"name": "IT Support & Maintenance",
"description": "Proactive managed IT support that keeps your systems running at peak performance. From helpdesk to infrastructure management, we handle it all.",
"icon": "fa-headset",
"features": "24/7 Remote Support\nProactive Monitoring & Alerting\nOn-site Support (by arrangement)",
"slug": "it-support",
"cta_text": "Learn More",
"active": true,
"order": 1,
"created_at": "2026-01-01 00:00:00",
"updated_at": "2026-02-19 00:00:00"
},
{
"id": "svc_002",
"name": "Cybersecurity Solutions",
"description": "End-to-end security services to protect your business from evolving threats. Risk assessment, vulnerability management, and incident response.",
"icon": "fa-shield-alt",
"features": "Security Risk Assessment\nVulnerability Management\nSecurity Awareness Training",
"slug": "cybersecurity",
"cta_text": "Learn More",
"active": true,
"order": 2,
"created_at": "2026-01-01 00:00:00",
"updated_at": "2026-02-19 00:00:00"
},
{
"id": "svc_003",
"name": "Cloud Services",
"description": "Cloud architecture, migration, and optimization. We design scalable, secure cloud environments tailored to your business needs.",
"icon": "fa-cloud",
"features": "Cloud Migration & Strategy\nInfrastructure Optimization\nMulti-cloud & Hybrid Solutions",
"slug": "cloud",
"cta_text": "Learn More",
"active": true,
"order": 3,
"created_at": "2026-01-01 00:00:00",
"updated_at": "2026-02-19 00:00:00"
},
{
"id": "svc_004",
"name": "Strategic IT Consulting",
"description": "Expert guidance to align technology with your business goals. We deliver roadmaps, vendor selection, and digital transformation strategies.",
"icon": "fa-chess-king",
"features": "IT Roadmap Planning\nVendor Evaluation & Selection\nDigital Transformation Strategy",
"slug": "consulting",
"cta_text": "Learn More",
"active": true,
"order": 4,
"created_at": "2026-01-01 00:00:00",
"updated_at": "2026-02-19 00:00:00"
}
]
+37
View File
@@ -0,0 +1,37 @@
[
{
"id": "s_site_name",
"key": "site_name",
"value": "MSPE",
"created_at": "2026-01-01 00:00:00",
"updated_at": "2026-02-19 19:00:00"
},
{
"id": "s_site_tagline",
"key": "site_tagline",
"value": "Architects of Digital Resilience",
"created_at": "2026-01-01 00:00:00",
"updated_at": "2026-02-19 19:00:00"
},
{
"id": "s_contact_email",
"key": "contact_email",
"value": "info@mspe.pro",
"created_at": "2026-01-01 00:00:00",
"updated_at": "2026-02-19 19:00:00"
},
{
"id": "s_contact_phone",
"key": "contact_phone",
"value": "+961 78 782 023",
"created_at": "2026-01-01 00:00:00",
"updated_at": "2026-02-19 19:00:00"
},
{
"id": "s_business_hours",
"key": "business_hours",
"value": "Mon - Fri: 9AM - 6PM",
"created_at": "2026-01-01 00:00:00",
"updated_at": "2026-02-19 19:00:00"
}
]
+1
View File
@@ -0,0 +1 @@
[]
File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 11 MiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 12 MiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 9.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 KiB

+16
View File
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400" viewBox="0 0 400 400">
<defs>
<linearGradient id="bg1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0a1628"/>
<stop offset="100%" style="stop-color:#0c2550"/>
</linearGradient>
<linearGradient id="accent1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0ea5e9"/>
<stop offset="100%" style="stop-color:#0d9488"/>
</linearGradient>
</defs>
<rect width="400" height="400" fill="url(#bg1)"/>
<circle cx="200" cy="200" r="170" fill="url(#accent1)" opacity="0.08"/>
<circle cx="200" cy="200" r="140" fill="url(#accent1)" opacity="0.05"/>
<text x="200" y="246" text-anchor="middle" fill="#e2e8f0" font-family="DM Sans, Arial, sans-serif" font-size="108" font-weight="800" letter-spacing="-5">KM</text>
</svg>

After

Width:  |  Height:  |  Size: 875 B

+16
View File
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400" viewBox="0 0 400 400">
<defs>
<linearGradient id="bg2" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0a1628"/>
<stop offset="100%" style="stop-color:#1a0a2e"/>
</linearGradient>
<linearGradient id="accent2" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#7c3aed"/>
<stop offset="100%" style="stop-color:#0ea5e9"/>
</linearGradient>
</defs>
<rect width="400" height="400" fill="url(#bg2)"/>
<circle cx="200" cy="200" r="170" fill="url(#accent2)" opacity="0.08"/>
<circle cx="200" cy="200" r="140" fill="url(#accent2)" opacity="0.05"/>
<text x="200" y="246" text-anchor="middle" fill="#e2e8f0" font-family="DM Sans, Arial, sans-serif" font-size="108" font-weight="800" letter-spacing="-5">NH</text>
</svg>

After

Width:  |  Height:  |  Size: 875 B

+16
View File
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400" viewBox="0 0 400 400">
<defs>
<linearGradient id="bg3" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0a1628"/>
<stop offset="100%" style="stop-color:#2a0a0a"/>
</linearGradient>
<linearGradient id="accent3" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#ef4444"/>
<stop offset="100%" style="stop-color:#f97316"/>
</linearGradient>
</defs>
<rect width="400" height="400" fill="url(#bg3)"/>
<circle cx="200" cy="200" r="170" fill="url(#accent3)" opacity="0.08"/>
<circle cx="200" cy="200" r="140" fill="url(#accent3)" opacity="0.05"/>
<text x="200" y="246" text-anchor="middle" fill="#e2e8f0" font-family="DM Sans, Arial, sans-serif" font-size="108" font-weight="800" letter-spacing="-5">RA</text>
</svg>

After

Width:  |  Height:  |  Size: 875 B

+16
View File
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400" viewBox="0 0 400 400">
<defs>
<linearGradient id="bg4" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0a1628"/>
<stop offset="100%" style="stop-color:#042f2e"/>
</linearGradient>
<linearGradient id="accent4" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0d9488"/>
<stop offset="100%" style="stop-color:#10b981"/>
</linearGradient>
</defs>
<rect width="400" height="400" fill="url(#bg4)"/>
<circle cx="200" cy="200" r="170" fill="url(#accent4)" opacity="0.08"/>
<circle cx="200" cy="200" r="140" fill="url(#accent4)" opacity="0.05"/>
<text x="200" y="246" text-anchor="middle" fill="#e2e8f0" font-family="DM Sans, Arial, sans-serif" font-size="108" font-weight="800" letter-spacing="-5">EK</text>
</svg>

After

Width:  |  Height:  |  Size: 875 B

+663
View File
@@ -0,0 +1,663 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="MSPE - Architects of Digital Resilience, based in Beirut, Lebanon. Cybersecurity, cloud services, IT support, and technology consulting for businesses across Lebanon and the MENA region.">
<title>MSPE | Architects of Digital Resilience</title>
<link rel="icon" type="image/png" href="images/logo/logo.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Playfair+Display:wght@600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'">
<noscript><link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Playfair+Display:wght@600;700&display=swap" rel="stylesheet"></noscript>
<link rel="preconnect" href="https://cdnjs.cloudflare.com">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha384-t1nt8BQoYMLFN5p42tRAtuAAFQaCQODekUVeKKZrEnEyp4H2R0RHFz0KWpmj7i8g" crossorigin="anonymous" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha384-t1nt8BQoYMLFN5p42tRAtuAAFQaCQODekUVeKKZrEnEyp4H2R0RHFz0KWpmj7i8g" crossorigin="anonymous"></noscript>
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/ultimate-ui.css" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="css/ultimate-ui.css"></noscript>
<link rel="stylesheet" href="css/header-fix.css">
<link rel="canonical" href="https://mspe.pro/">
<!-- Open Graph -->
<meta property="og:type" content="website">
<meta property="og:url" content="https://mspe.pro/">
<meta property="og:title" content="MSPE | Architects of Digital Resilience">
<meta property="og:description" content="We don't just solve problems, we engineer possibilities. Next-generation cybersecurity, cloud innovation, and technology consulting.">
<meta property="og:image" content="https://mspe.pro/images/logo/logo.png">
<meta property="og:site_name" content="MSPE">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="@mspe_pro">
<meta name="twitter:title" content="MSPE | Architects of Digital Resilience">
<meta name="twitter:description" content="We engineer possibilities. Next-generation cybersecurity, cloud innovation, and technology consulting.">
<meta name="twitter:image" content="https://mspe.pro/images/logo/logo.png">
<!-- Structured Data -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Organization",
"name": "MSPE",
"alternateName": "Multiple Service Provider Experts",
"url": "https://mspe.pro",
"logo": "https://mspe.pro/images/logo/logo.png",
"description": "Architects of Digital Resilience — Cybersecurity, Cloud Services, IT Support, and Business Consulting.",
"telephone": "+961-78-782-023",
"email": "info@mspe.pro",
"address": {
"@type": "PostalAddress",
"addressLocality": "Beirut",
"addressCountry": "LB"
},
"areaServed": "Worldwide",
"sameAs": [
"https://www.facebook.com/mspe.pro",
"https://www.linkedin.com/company/mspe-pro",
"https://x.com/mspe_pro",
"https://www.instagram.com/mspe.pro"
],
"contactPoint": {
"@type": "ContactPoint",
"telephone": "+961-78-782-023",
"contactType": "customer service",
"email": "info@mspe.pro",
"availableLanguage": ["English", "Arabic"]
}
}
</script>
</head>
<body>
<!-- Skip to Content (Accessibility) -->
<a href="#services" class="skip-to-content">Skip to main content</a>
<!-- Preloader -->
<div class="preloader" id="preloader">
<div class="loader">
<div class="loader-ring"></div>
<span class="loader-text">MSPE</span>
</div>
</div>
<!-- Unique Brand Philosophy: We don't just protect systems, we architect digital futures -->
<!-- Navigation -->
<header class="header" id="header">
<nav class="nav container">
<a href="index.html" class="nav-logo">
<img src="images/logo/logo.png" alt="MSPE PRO" class="logo-image" width="200" height="50">
</a>
<div class="nav-menu" id="nav-menu">
<ul class="nav-list">
<li class="nav-item"><a href="index.html" class="nav-link active">Home</a></li>
<li class="nav-item"><a href="about.html" class="nav-link">About Us</a></li>
<li class="nav-item dropdown">
<a href="services.html" class="nav-link">Services <i class="fas fa-chevron-down"></i></a>
<ul class="dropdown-menu">
<li><a href="services.html#it-support">IT Support & Maintenance</a></li>
<li><a href="services.html#cybersecurity">Cybersecurity Solutions</a></li>
<li><a href="services.html#cloud">Cloud Services</a></li>
<li><a href="services.html#consulting">Business Consulting</a></li>
</ul>
</li>
<li class="nav-item"><a href="portfolio.html" class="nav-link">Portfolio</a></li>
<li class="nav-item"><a href="news.html" class="nav-link">News & Events</a></li>
<li class="nav-item"><a href="contact.html" class="nav-link">Contact</a></li>
</ul>
</div>
<div class="nav-actions">
<a href="calendar.html" class="btn btn-primary">Book Meeting</a>
<button class="nav-toggle" id="nav-toggle" aria-label="Toggle navigation menu" aria-expanded="false">
<span></span>
<span></span>
<span></span>
</button>
</div>
</nav>
</header>
<!-- Hero Section -->
<section class="hero">
<div class="hero-slider" id="hero-slider">
<div class="hero-slide active" data-slide="1">
<div class="hero-bg" style="background: linear-gradient(135deg, #0a1628 0%, #1a365d 100%);"></div>
<div class="hero-content container">
<div class="hero-text">
<span class="hero-label">Where Innovation Meets Security</span>
<h1 class="hero-title">Architects of <span class="highlight">Digital Resilience</span></h1>
<p class="hero-description">We don't just solve problems - we engineer possibilities. MSPE transforms complexity into competitive advantage, building the digital foundations that empower tomorrow's leaders.</p>
<div class="hero-buttons">
<a href="services.html" class="btn btn-primary btn-lg">Explore Services</a>
<a href="contact.html" class="btn btn-outline btn-lg">Schedule Consultation</a>
</div>
</div>
<div class="hero-visual">
<div class="floating-card card-1">
<i class="fas fa-shield-alt"></i>
<span>Security First</span>
</div>
<div class="floating-card card-2">
<i class="fas fa-cloud"></i>
<span>Cloud Ready</span>
</div>
<div class="floating-card card-3">
<i class="fas fa-headset"></i>
<span>Dedicated Support</span>
</div>
<div class="hero-graphic">
<div class="orbit orbit-1"></div>
<div class="orbit orbit-2"></div>
<div class="orbit orbit-3"></div>
<div class="core"></div>
</div>
</div>
</div>
</div>
<div class="hero-slide" data-slide="2">
<div class="hero-bg" style="background: linear-gradient(135deg, #1a365d 0%, #2c5282 100%);"></div>
<div class="hero-content container">
<div class="hero-text">
<span class="hero-label">Defense That Thinks Ahead</span>
<h1 class="hero-title">Proactive <span class="highlight">Cyber Defense</span></h1>
<p class="hero-description">In a world where threats evolve by the second, we engineer security architectures that anticipate, adapt, and overcome. Your digital fortress, intelligently designed.</p>
<div class="hero-buttons">
<a href="services.html#cybersecurity" class="btn btn-primary btn-lg">Security Services</a>
<a href="contact.html" class="btn btn-outline btn-lg">Get Protected</a>
</div>
</div>
<div class="hero-visual">
<div class="floating-card card-1">
<i class="fas fa-lock"></i>
<span>Threat Detection</span>
</div>
<div class="floating-card card-2">
<i class="fas fa-user-shield"></i>
<span>SIEM/SOC</span>
</div>
<div class="floating-card card-3">
<i class="fas fa-bug"></i>
<span>Pen Testing</span>
</div>
<div class="hero-graphic">
<div class="orbit orbit-1"></div>
<div class="orbit orbit-2"></div>
<div class="orbit orbit-3"></div>
<div class="core"></div>
</div>
</div>
</div>
</div>
<div class="hero-slide" data-slide="3">
<div class="hero-bg" style="background: linear-gradient(135deg, #0d9488 0%, #14b8a6 100%);"></div>
<div class="hero-content container">
<div class="hero-text">
<span class="hero-label">Infinite Horizons</span>
<h1 class="hero-title">Boundless <span class="highlight">Cloud</span> Architecture</h1>
<p class="hero-description">Break free from infrastructure constraints. We architect cloud ecosystems that scale with your ambition, turning operational burden into strategic acceleration.</p>
<div class="hero-buttons">
<a href="services.html#cloud" class="btn btn-primary btn-lg">Cloud Services</a>
<a href="contact.html" class="btn btn-outline btn-lg">Start Migration</a>
</div>
</div>
<div class="hero-visual">
<div class="floating-card card-1">
<i class="fas fa-server"></i>
<span>Azure & AWS</span>
</div>
<div class="floating-card card-2">
<i class="fas fa-database"></i>
<span>Data Backup</span>
</div>
<div class="floating-card card-3">
<i class="fas fa-sync-alt"></i>
<span>Migration</span>
</div>
<div class="hero-graphic">
<div class="orbit orbit-1"></div>
<div class="orbit orbit-2"></div>
<div class="orbit orbit-3"></div>
<div class="core"></div>
</div>
</div>
</div>
</div>
</div>
<div class="hero-controls">
<button class="hero-control prev" id="hero-prev" aria-label="Previous slide"><i class="fas fa-chevron-left"></i></button>
<div class="hero-dots" id="hero-dots" role="tablist" aria-label="Hero slider navigation">
<span class="dot active" data-slide="1" role="tab" aria-selected="true" aria-label="Slide 1" tabindex="0"></span>
<span class="dot" data-slide="2" role="tab" aria-selected="false" aria-label="Slide 2" tabindex="-1"></span>
<span class="dot" data-slide="3" role="tab" aria-selected="false" aria-label="Slide 3" tabindex="-1"></span>
</div>
<button class="hero-control next" id="hero-next" aria-label="Next slide"><i class="fas fa-chevron-right"></i></button>
</div>
</section>
<!-- What We Commit To -->
<section class="stats">
<div class="container">
<div class="stats-grid">
<div class="stat-item">
<div class="stat-icon"><i class="fas fa-clock"></i></div>
<div class="stat-number">4hr</div>
<div class="stat-label">Maximum First-Response SLA</div>
</div>
<div class="stat-item">
<div class="stat-icon"><i class="fas fa-layer-group"></i></div>
<div class="stat-number">4</div>
<div class="stat-label">Core Service Pillars</div>
</div>
<div class="stat-item">
<div class="stat-icon"><i class="fas fa-fingerprint"></i></div>
<div class="stat-number">100%</div>
<div class="stat-label">Bespoke — No Off-the-Shelf Templates</div>
</div>
<div class="stat-item">
<div class="stat-icon"><i class="fas fa-gift"></i></div>
<div class="stat-number">Free</div>
<div class="stat-label">Initial Infrastructure Audit</div>
</div>
</div>
</div>
</section>
<!-- How to Start -->
<section class="how-to-start section" id="how-to-start">
<div class="container">
<div class="section-header">
<span class="section-label">How to Start</span>
<h2 class="section-title">A Clear 4-Step Onboarding Process</h2>
<p class="section-description">From first call to full deployment, we keep the process simple, transparent, and practical.</p>
</div>
<div class="process-flow">
<div class="process-step">
<div class="step-number">01</div>
<h4>Free Audit</h4>
<p>Initial review of your infrastructure and pain points.</p>
</div>
<div class="process-step">
<div class="step-number">02</div>
<h4>On-Site Survey</h4>
<p>Operational assessment to map risks and opportunities.</p>
</div>
<div class="process-step">
<div class="step-number">03</div>
<h4>Best Practices Plan</h4>
<p>Prioritized recommendations with business-aligned timelines.</p>
</div>
<div class="process-step">
<div class="step-number">04</div>
<h4>Setup & Support</h4>
<p>Implementation, handover, and ongoing managed support.</p>
</div>
</div>
<div class="section-cta" style="margin-top: 2rem;">
<a href="calendar.html" class="btn btn-primary">Start with a Free Audit <i class="fas fa-arrow-right"></i></a>
</div>
</div>
</section>
<!-- Services Section -->
<section class="services section" id="services">
<div class="container">
<div class="section-header">
<span class="section-label">What We Do</span>
<h2 class="section-title">Our Core Services</h2>
<p class="section-description">Comprehensive solutions tailored to drive your business forward</p>
</div>
<div class="services-grid" id="services-grid" data-services-grid="true">
<div class="service-card">
<div class="service-icon"><i class="fas fa-headset"></i></div>
<h3 class="service-title">IT Support &amp; Maintenance</h3>
<p class="service-description">Proactive managed IT support that keeps your systems running at peak performance. From helpdesk to infrastructure management, we handle it all.</p>
<ul class="service-features">
<li><i class="fas fa-check"></i> 24/7 Remote Support</li>
<li><i class="fas fa-check"></i> Proactive Monitoring &amp; Alerting</li>
<li><i class="fas fa-check"></i> On-site Support</li>
</ul>
<a href="contact.html?service=it-support" class="service-link">Learn More <i class="fas fa-arrow-right"></i></a>
</div>
<div class="service-card">
<div class="service-icon"><i class="fas fa-shield-alt"></i></div>
<h3 class="service-title">Cybersecurity Solutions</h3>
<p class="service-description">End-to-end security services to protect your business from evolving threats. Risk assessment, vulnerability management, and incident response.</p>
<ul class="service-features">
<li><i class="fas fa-check"></i> Security Risk Assessment</li>
<li><i class="fas fa-check"></i> Vulnerability Management</li>
<li><i class="fas fa-check"></i> Security Awareness Training</li>
</ul>
<a href="contact.html?service=cybersecurity" class="service-link">Learn More <i class="fas fa-arrow-right"></i></a>
</div>
<div class="service-card">
<div class="service-icon"><i class="fas fa-cloud"></i></div>
<h3 class="service-title">Cloud Services</h3>
<p class="service-description">Cloud architecture, migration, and optimization. We design scalable, secure cloud environments tailored to your business needs.</p>
<ul class="service-features">
<li><i class="fas fa-check"></i> Cloud Migration &amp; Strategy</li>
<li><i class="fas fa-check"></i> Infrastructure Optimization</li>
<li><i class="fas fa-check"></i> Multi-cloud &amp; Hybrid Solutions</li>
</ul>
<a href="contact.html?service=cloud" class="service-link">Learn More <i class="fas fa-arrow-right"></i></a>
</div>
<div class="service-card">
<div class="service-icon"><i class="fas fa-chess-king"></i></div>
<h3 class="service-title">Strategic IT Consulting</h3>
<p class="service-description">Expert guidance to align technology with your business goals. We deliver roadmaps, vendor selection, and digital transformation strategies.</p>
<ul class="service-features">
<li><i class="fas fa-check"></i> IT Roadmap Planning</li>
<li><i class="fas fa-check"></i> Vendor Evaluation &amp; Selection</li>
<li><i class="fas fa-check"></i> Digital Transformation Strategy</li>
</ul>
<a href="contact.html?service=consulting" class="service-link">Learn More <i class="fas fa-arrow-right"></i></a>
</div>
</div>
</div>
</section>
<!-- Why Choose Us -->
<section class="why-us section">
<div class="container">
<div class="section-header">
<span class="section-label">The MSPE Difference</span>
<h2 class="section-title">Why Partner With Us?</h2>
<p class="section-description">We're not just another IT company. We're architects of digital resilience - building technology foundations that transform how businesses operate, compete, and thrive.</p>
</div>
<div class="why-us-content">
<div class="why-us-text">
<div class="features-list">
<div class="feature-item">
<div class="feature-icon"><i class="fas fa-brain"></i></div>
<div class="feature-content">
<h4>Proactive Intelligence</h4>
<p>We don't wait for problems - we anticipate them. Our approach focuses on prevention, not just cure.</p>
</div>
</div>
<div class="feature-item">
<div class="feature-icon"><i class="fas fa-infinity"></i></div>
<div class="feature-content">
<h4>Boundless Thinking</h4>
<p>No templates. No one-size-fits-all. Every solution is architected from the ground up for your unique challenges.</p>
</div>
</div>
<div class="feature-item">
<div class="feature-icon"><i class="fas fa-shield-virus"></i></div>
<div class="feature-content">
<h4>Security-First DNA</h4>
<p>Security isn't an afterthought - it's woven into the fabric of everything we design and deliver.</p>
</div>
</div>
<div class="feature-item">
<div class="feature-icon"><i class="fas fa-rocket"></i></div>
<div class="feature-content">
<h4>Future-Ready Solutions</h4>
<p>We build for tomorrow. Our solutions evolve with technology, ensuring you're always ahead of the curve.</p>
</div>
</div>
</div>
</div>
<div class="why-us-visual">
<div class="features-list">
<div class="feature-item">
<div class="feature-icon"><i class="fas fa-clock"></i></div>
<div class="feature-content">
<h4>4-Hour First Response</h4>
<p>Every issue acknowledged and triaged within 4 hours of reporting.</p>
</div>
</div>
<div class="feature-item">
<div class="feature-icon"><i class="fas fa-search"></i></div>
<div class="feature-content">
<h4>Free Infrastructure Audit</h4>
<p>Start with a no-obligation review of your environment — zero commitment required.</p>
</div>
</div>
<div class="feature-item">
<div class="feature-icon"><i class="fas fa-user-tie"></i></div>
<div class="feature-content">
<h4>Direct Engineer Access</h4>
<p>No account managers or middlemen. You work with the engineers on your project.</p>
</div>
</div>
<div class="feature-item">
<div class="feature-icon"><i class="fas fa-file-contract"></i></div>
<div class="feature-content">
<h4>Transparent Pricing</h4>
<p>Detailed proposals with clear scope. No surprise invoices, no hidden fees.</p>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Our Philosophy Section -->
<section class="philosophy section">
<div class="container">
<div class="section-header">
<span class="section-label">Our Philosophy</span>
<h2 class="section-title">Built on Principles, Not Promises</h2>
<p class="section-description">We believe technology should empower, not complicate. Every engagement starts with understanding your vision.</p>
</div>
<div class="philosophy-grid">
<div class="philosophy-card">
<i class="fas fa-eye"></i>
<h4>Transparency</h4>
<p>No hidden agendas. No surprise costs. Complete clarity in everything we do.</p>
</div>
<div class="philosophy-card">
<i class="fas fa-handshake"></i>
<h4>Partnership</h4>
<p>Your success is our success. We grow together, learn together, win together.</p>
</div>
<div class="philosophy-card">
<i class="fas fa-gem"></i>
<h4>Excellence</h4>
<p>Good enough is never enough. We pursue excellence in every line of code, every solution.</p>
</div>
</div>
</div>
</section>
<!-- Our Commitment Section -->
<section class="commitment section">
<div class="container">
<div class="section-header">
<span class="section-label">Our Commitment</span>
<h2 class="section-title">The MSPE Promise</h2>
</div>
<div class="commitment-content">
<div class="commitment-card">
<div class="quote-icon"><i class="fas fa-quote-left"></i></div>
<p class="commitment-quote">"Every business deserves technology that works <strong>for</strong> them, not against them. We're here to be the partner you wish you'd found years ago - one that speaks your language, understands your challenges, and delivers solutions that actually solve problems."</p>
<div class="commitment-author">
<div class="author-icon">
<i class="fas fa-building"></i>
</div>
<h4>The MSPE Team</h4>
<p>Architects of Digital Resilience</p>
</div>
</div>
<p class="commitment-footer">We bring focused expertise and direct accountability to every engagement —<br>no corporate layers, no templated solutions. Just outcomes that matter.</p>
</div>
</div>
</section>
<!-- Latest News Section -->
<section class="news section" id="latest-news">
<div class="container">
<div class="section-header">
<span class="section-label">Stay Updated</span>
<h2 class="section-title">Latest News & Insights</h2>
<p class="section-description">Industry updates, company news, and expert insights</p>
</div>
<div class="news-grid" id="news-grid">
<!-- News items will be loaded dynamically -->
<div class="loading-state">
<i class="fas fa-spinner fa-spin"></i> Loading...
</div>
</div>
<div class="section-cta">
<a href="news.html" class="btn btn-outline">View All News <i class="fas fa-arrow-right"></i></a>
</div>
</div>
</section>
<!-- Industries We Serve -->
<section class="industries section">
<div class="container">
<div class="section-header">
<span class="section-label">Industries</span>
<h2 class="section-title">Who We Work With</h2>
<p class="section-description">We deliver tailored IT solutions across diverse industries</p>
</div>
<div class="industries-grid">
<div class="industry-item">
<div class="industry-icon"><i class="fas fa-university"></i></div>
<span>Finance</span>
</div>
<div class="industry-item">
<div class="industry-icon"><i class="fas fa-heartbeat"></i></div>
<span>Healthcare</span>
</div>
<div class="industry-item">
<div class="industry-icon"><i class="fas fa-shopping-cart"></i></div>
<span>Retail</span>
</div>
<div class="industry-item">
<div class="industry-icon"><i class="fas fa-gavel"></i></div>
<span>Legal</span>
</div>
<div class="industry-item">
<div class="industry-icon"><i class="fas fa-graduation-cap"></i></div>
<span>Education</span>
</div>
<div class="industry-item">
<div class="industry-icon"><i class="fas fa-hotel"></i></div>
<span>Hospitality</span>
</div>
<div class="industry-item">
<div class="industry-icon"><i class="fas fa-building"></i></div>
<span>Real Estate</span>
</div>
<div class="industry-item">
<div class="industry-icon"><i class="fas fa-broadcast-tower"></i></div>
<span>Media</span>
</div>
</div>
<p class="industries-note">Don't see your industry? <a href="contact.html" class="text-accent">Let's create a custom solution for you →</a></p>
</div>
</section>
<!-- CTA Section -->
<section class="cta">
<div class="cta-bg"></div>
<div class="container">
<div class="cta-content">
<h2 class="cta-title">Ready to Transform Your Business?</h2>
<p class="cta-description">Schedule a consultation with our team and get a clear plan for your technology priorities.</p>
<div class="cta-buttons">
<a href="contact.html" class="btn btn-primary btn-lg">Schedule Consultation</a>
<a href="contact.html" class="btn btn-outline btn-lg"><i class="fas fa-envelope"></i> Contact Us</a>
</div>
</div>
</div>
</section>
<!-- WhatsApp Floating Button -->
<a href="https://wa.me/96178782023" class="whatsapp-float" target="_blank" rel="noopener" aria-label="Chat on WhatsApp">
<i class="fab fa-whatsapp"></i>
</a>
<!-- Footer -->
<footer class="footer">
<div class="footer-main">
<div class="container">
<div class="footer-grid">
<div class="footer-brand">
<a href="index.html" class="footer-logo">
<img src="images/logo/logo.png" alt="MSPE PRO" class="logo-image" width="200" height="50">
</a>
<p class="footer-description">We engineer possibilities. Transforming complexity into competitive advantage through innovative technology solutions and uncompromising security.</p>
<div class="footer-social">
<a href="https://www.facebook.com/mspe.pro" class="social-link" title="Facebook" target="_blank" rel="noopener noreferrer"><i class="fab fa-facebook-f"></i></a>
<a href="https://www.linkedin.com/company/mspe-pro" class="social-link" title="LinkedIn" target="_blank" rel="noopener noreferrer"><i class="fab fa-linkedin-in"></i></a>
<a href="https://x.com/mspe_pro" class="social-link" title="X (Twitter)" target="_blank" rel="noopener noreferrer"><i class="fab fa-twitter"></i></a>
<a href="https://www.instagram.com/mspe.pro" class="social-link" title="Instagram" target="_blank" rel="noopener noreferrer"><i class="fab fa-instagram"></i></a>
</div>
</div>
<div class="footer-links">
<h4 class="footer-title">Quick Links</h4>
<ul>
<li><a href="about.html">About Us</a></li>
<li><a href="services.html">Our Services</a></li>
<li><a href="portfolio.html">Portfolio</a></li>
<li><a href="news.html">News & Events</a></li>
<li><a href="contact.html">Contact Us</a></li>
</ul>
</div>
<div class="footer-links">
<h4 class="footer-title">Services</h4>
<ul>
<li><a href="services.html#it-support">IT Support & Maintenance</a></li>
<li><a href="services.html#cybersecurity">Cybersecurity Solutions</a></li>
<li><a href="services.html#cloud">Cloud Services</a></li>
<li><a href="services.html#consulting">Business Consulting</a></li>
</ul>
</div>
<div class="footer-contact">
<h4 class="footer-title">Contact Info</h4>
<ul class="contact-list">
<li>
<i class="fas fa-envelope"></i>
<span data-site-email>info@mspe.pro</span>
</li>
<li>
<i class="fas fa-phone"></i>
<a href="tel:+96178782023" data-site-phone style="color:inherit;">+961 78 782 023</a>
</li>
<li>
<i class="fas fa-clock"></i>
<span>Available for consultations</span>
</li>
<li>
<i class="fas fa-map-marker-alt"></i>
<span>Beirut, Lebanon</span>
</li>
</ul>
</div>
</div>
</div>
</div>
<div class="footer-bottom">
<div class="container">
<p>&copy; 2026 MSPE - Architects of Digital Resilience. All Rights Reserved.</p>
<div class="footer-bottom-links">
<a href="privacy.html">Privacy Policy</a>
<a href="terms.html">Terms of Service</a>
</div>
</div>
</div>
</footer>
<!-- Back to Top -->
<button class="back-to-top" id="back-to-top" aria-label="Back to top">
<i class="fas fa-chevron-up"></i>
</button>
<!-- Scripts -->
<script src="js/main.js" defer></script>
<script src="js/ultimate-ui.js" defer></script>
</body>
</html>
+1110
View File
File diff suppressed because it is too large Load Diff
+472
View File
@@ -0,0 +1,472 @@
/* ========================================
MSPE Ultimate UI Interactions v2.0
Advanced JavaScript for Premium UX
======================================== */
document.addEventListener('DOMContentLoaded', function() {
// Initialize all modules
initCustomCursor();
initParticles();
initScrollProgress();
initMagneticButtons();
initSmoothScroll();
initParallax();
initRevealAnimations();
initTiltEffect();
initRippleEffect();
initTypewriter();
initLazyLoad();
});
/* ==================== Optimized Custom Cursor ==================== */
function initCustomCursor() {
// Custom cursor disabled
return;
// Only on desktop with no reduced motion preference
if (window.innerWidth < 768 || 'ontouchstart' in window || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
// Add class to body to hide default cursor
document.body.classList.add('custom-cursor-enabled');
const cursor = document.createElement('div');
cursor.classList.add('custom-cursor');
document.body.appendChild(cursor);
const trails = [];
const trailLength = 5; // Reduced from 12
for (let i = 0; i < trailLength; i++) {
const trail = document.createElement('div');
trail.classList.add('cursor-trail');
trail.style.opacity = (1 - i / trailLength) * 0.4;
const size = 6 - i;
trail.style.width = size + 'px';
trail.style.height = size + 'px';
document.body.appendChild(trail);
trails.push({ element: trail, x: 0, y: 0 });
}
let mouseX = 0, mouseY = 0;
let cursorX = 0, cursorY = 0;
let isMoving = false;
let rafId = null;
document.addEventListener('mousemove', (e) => {
mouseX = e.clientX;
mouseY = e.clientY;
if (!isMoving) {
isMoving = true;
rafId = requestAnimationFrame(animateCursor);
}
}, { passive: true });
function animateCursor() {
cursorX += (mouseX - cursorX) * 0.15;
cursorY += (mouseY - cursorY) * 0.15;
cursor.style.transform = `translate3d(${cursorX}px, ${cursorY}px, 0) translate(-50%, -50%)`;
trails.forEach((trail, index) => {
const prev = trails[index - 1] || { x: cursorX, y: cursorY };
trail.x += (prev.x - trail.x) * 0.25;
trail.y += (prev.y - trail.y) * 0.25;
trail.element.style.transform = `translate3d(${trail.x}px, ${trail.y}px, 0)`;
});
// Stop loop when cursor catches up
if (Math.abs(mouseX - cursorX) > 0.5 || Math.abs(mouseY - cursorY) > 0.5) {
rafId = requestAnimationFrame(animateCursor);
} else {
isMoving = false;
}
}
// Delegate hover detection instead of per-element listeners
document.addEventListener('mouseover', (e) => {
if (e.target.closest('a, button, .btn, input, textarea, select, .service-card, .news-card, .portfolio-item, .nav-link')) {
cursor.classList.add('active');
}
}, { passive: true });
document.addEventListener('mouseout', (e) => {
if (e.target.closest('a, button, .btn, input, textarea, select, .service-card, .news-card, .portfolio-item, .nav-link')) {
cursor.classList.remove('active');
}
}, { passive: true });
}
/* ==================== Optimized Particle Background ==================== */
function initParticles() {
// Particles disabled for performance — 20 animated DOM elements cause compositor overhead
return;
const particlesContainer = document.createElement('div');
particlesContainer.classList.add('particles-bg');
document.body.appendChild(particlesContainer);
// Drastically reduced: 20 on desktop, 10 on mobile (was 60/25)
const particleCount = window.innerWidth < 768 ? 10 : 20;
for (let i = 0; i < particleCount; i++) {
const particle = document.createElement('div');
particle.classList.add('particle');
particle.style.left = Math.random() * 100 + '%';
particle.style.animationDelay = Math.random() * 20 + 's';
particle.style.animationDuration = (25 + Math.random() * 15) + 's';
const size = 2 + Math.random() * 2;
particle.style.width = size + 'px';
particle.style.height = size + 'px';
particlesContainer.appendChild(particle);
}
// Geometric shapes removed for performance
}
/* ==================== Enhanced Scroll Progress Indicator ==================== */
function initScrollProgress() {
const progressBar = document.createElement('div');
progressBar.classList.add('scroll-progress');
document.body.appendChild(progressBar);
let ticking = false;
window.addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(() => {
const windowHeight = document.documentElement.scrollHeight - document.documentElement.clientHeight;
const scrolled = (window.scrollY / windowHeight) * 100;
progressBar.style.width = scrolled + '%';
ticking = false;
});
ticking = true;
}
});
}
/* ==================== Optimized Magnetic Buttons (disabled for performance) ==================== */
function initMagneticButtons() {
// Magnetic effect disabled — per-element mousemove listeners are expensive
return;
// Only apply to .btn elements, not cards/nav-links (too many listeners)
const magneticElements = document.querySelectorAll('.btn');
magneticElements.forEach(el => {
el.addEventListener('mousemove', (e) => {
const rect = el.getBoundingClientRect();
const x = (e.clientX - rect.left - rect.width / 2) * 0.25;
const y = (e.clientY - rect.top - rect.height / 2) * 0.25;
el.style.transform = `translate3d(${x}px, ${y}px, 0)`;
}, { passive: true });
el.addEventListener('mouseleave', () => {
el.style.transform = '';
}, { passive: true });
});
}
/* ==================== Enhanced Smooth Scroll ==================== */
function initSmoothScroll() {
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener('click', function (e) {
const href = this.getAttribute('href');
if (href === '#') return;
e.preventDefault();
const target = document.querySelector(href);
if (target) {
const headerOffset = 100;
const elementPosition = target.getBoundingClientRect().top;
const offsetPosition = elementPosition + window.pageYOffset - headerOffset;
window.scrollTo({
top: offsetPosition,
behavior: 'smooth'
});
}
});
});
}
/* ==================== Lightweight Parallax (hero only) ==================== */
function initParallax() {
const heroVisual = document.querySelector('.hero-visual');
if (!heroVisual || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
let ticking = false;
window.addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(() => {
const scrollY = window.scrollY;
if (scrollY < window.innerHeight) {
heroVisual.style.transform = `translate3d(0, ${scrollY * 0.15}px, 0)`;
}
ticking = false;
});
ticking = true;
}
}, { passive: true });
}
/* ==================== Optimized Reveal Animations ==================== */
function initRevealAnimations() {
// Only observe sections, not every individual card (let CSS handle card animations)
const revealElements = document.querySelectorAll('.section');
revealElements.forEach(el => el.classList.add('reveal'));
const revealObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('active');
revealObserver.unobserve(entry.target); // Stop observing once revealed
}
});
}, {
threshold: 0.1,
rootMargin: '0px 0px -30px 0px'
});
revealElements.forEach(el => revealObserver.observe(el));
}
// Counter animation helper
function animateValue(element) {
const parent = element.closest('[data-count]');
if (!parent) return;
const target = parseInt(parent.dataset.count) || 0;
const duration = 2000;
const start = performance.now();
function update(currentTime) {
const elapsed = currentTime - start;
const progress = Math.min(elapsed / duration, 1);
// Easing function
const easeOutQuart = 1 - Math.pow(1 - progress, 4);
const current = Math.floor(easeOutQuart * target);
element.textContent = current;
if (progress < 1) {
requestAnimationFrame(update);
} else {
element.textContent = target;
}
}
requestAnimationFrame(update);
}
/* ==================== Lightweight 3D Tilt (disabled — duplicate of initCardPointerLift in main.js) ==================== */
function initTiltEffect() {
// Disabled — main.js initCardPointerLift already handles card hover effects
return;
// Only service cards - removed from news, portfolio, feature, philosophy cards
const cards = document.querySelectorAll('.service-card');
cards.forEach(card => {
card.addEventListener('mousemove', (e) => {
const rect = card.getBoundingClientRect();
const rotateX = ((e.clientY - rect.top) - rect.height / 2) / 20;
const rotateY = (rect.width / 2 - (e.clientX - rect.left)) / 20;
card.style.transform = `perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) translateZ(5px)`;
}, { passive: true });
card.addEventListener('mouseleave', () => {
card.style.transform = '';
}, { passive: true });
});
}
/* ==================== Ripple Effect (buttons only, no global click) ==================== */
function initRippleEffect() {
// Use event delegation for button ripples
document.addEventListener('click', function(e) {
const btn = e.target.closest('.btn');
if (!btn) return;
const rect = btn.getBoundingClientRect();
const ripple = document.createElement('span');
ripple.classList.add('btn-ripple');
ripple.style.left = (e.clientX - rect.left) + 'px';
ripple.style.top = (e.clientY - rect.top) + 'px';
btn.appendChild(ripple);
setTimeout(() => ripple.remove(), 600);
});
// Global click ripple removed for performance
}
/* ==================== Typewriter Effect ==================== */
function initTypewriter() {
const typingElements = document.querySelectorAll('[data-typing]');
typingElements.forEach(el => {
const text = el.dataset.typing || el.textContent;
const speed = parseInt(el.dataset.typingSpeed) || 50;
el.textContent = '';
el.style.borderRight = '2px solid var(--neon-cyan)';
let charIndex = 0;
const typeInterval = setInterval(() => {
if (charIndex < text.length) {
el.textContent += text.charAt(charIndex);
charIndex++;
} else {
clearInterval(typeInterval);
// Blinking cursor effect
el.style.animation = 'blinkCursor 0.8s infinite';
}
}, speed);
});
// Add blink cursor keyframes
if (!document.querySelector('#blinkCursorKeyframes')) {
const style = document.createElement('style');
style.id = 'blinkCursorKeyframes';
style.textContent = `
@keyframes blinkCursor {
0%, 50% { border-color: var(--neon-cyan); }
51%, 100% { border-color: transparent; }
}
`;
document.head.appendChild(style);
}
}
/* Dynamic Background Gradient - REMOVED for performance */
// The continuous rAF loop updating body background-position was a major perf drain
/* ==================== Text Scramble Effect ==================== */
class TextScramble {
constructor(el) {
this.el = el;
this.chars = '!<>-_\\/[]{}—=+*^?#________';
this.update = this.update.bind(this);
}
setText(newText) {
const oldText = this.el.textContent;
const length = Math.max(oldText.length, newText.length);
const promise = new Promise(resolve => this.resolve = resolve);
this.queue = [];
for (let i = 0; i < length; i++) {
const from = oldText[i] || '';
const to = newText[i] || '';
const start = Math.floor(Math.random() * 40);
const end = start + Math.floor(Math.random() * 40);
this.queue.push({ from, to, start, end });
}
cancelAnimationFrame(this.frameRequest);
this.frame = 0;
this.update();
return promise;
}
update() {
let output = '';
let complete = 0;
for (let i = 0, n = this.queue.length; i < n; i++) {
let { from, to, start, end, char } = this.queue[i];
if (this.frame >= end) {
complete++;
output += to;
} else if (this.frame >= start) {
if (!char || Math.random() < 0.28) {
char = this.randomChar();
this.queue[i].char = char;
}
output += `<span style="color: var(--neon-cyan)">${char}</span>`;
} else {
output += from;
}
}
this.el.innerHTML = output;
if (complete === this.queue.length) {
this.resolve();
} else {
this.frameRequest = requestAnimationFrame(this.update);
this.frame++;
}
}
randomChar() {
return this.chars[Math.floor(Math.random() * this.chars.length)];
}
}
// Initialize text scramble on hover for certain elements
document.querySelectorAll('.logo-text').forEach(el => {
const fx = new TextScramble(el);
const originalText = el.textContent;
el.addEventListener('mouseenter', () => {
fx.setText(originalText);
});
});
/* ==================== Lazy Load Images ==================== */
function initLazyLoad() {
const images = document.querySelectorAll('img[data-src]');
const imageObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
img.classList.add('loaded');
imageObserver.unobserve(img);
}
});
}, {
rootMargin: '50px 0px'
});
images.forEach(img => imageObserver.observe(img));
}
/* ==================== Performance Optimization ==================== */
// Debounce function for expensive operations
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// Throttle function for scroll events
function throttle(func, limit) {
let inThrottle;
return function(...args) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
}
/* Loading Screen Enhancement - handled by preloader in main.js */
/* ==================== Console Easter Egg ==================== */
console.log('%c🚀 MSPE - Architects of Digital Resilience', 'color: #00f5ff; font-size: 24px; font-weight: bold; text-shadow: 0 0 10px #00f5ff;');
console.log('%c━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', 'color: #b537f2');
console.log('%c💼 Looking for opportunities? We\'re hiring!', 'color: #b537f2; font-size: 14px;');
console.log('%c🔧 Built with passion, precision, and cutting-edge tech', 'color: #ff006e; font-size: 12px;');
console.log('%c━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', 'color: #b537f2');
+440
View File
@@ -0,0 +1,440 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="MSPE Insights — Cybersecurity, cloud, and IT strategy perspectives from Beirut, Lebanon. Practical advice for businesses navigating digital transformation in the MENA region.">
<title>News & Insights | MSPE - Architects of Digital Resilience</title>
<link rel="icon" type="image/png" href="images/logo/logo.png">
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Playfair+Display:wght@600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'">
<noscript><link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Playfair+Display:wght@600;700&display=swap" rel="stylesheet"></noscript>
<!-- Icons -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha384-t1nt8BQoYMLFN5p42tRAtuAAFQaCQODekUVeKKZrEnEyp4H2R0RHFz0KWpmj7i8g" crossorigin="anonymous" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha384-t1nt8BQoYMLFN5p42tRAtuAAFQaCQODekUVeKKZrEnEyp4H2R0RHFz0KWpmj7i8g" crossorigin="anonymous"></noscript>
<!-- Styles -->
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/pages.css">
<link rel="stylesheet" href="css/ultimate-ui.css" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="css/ultimate-ui.css"></noscript>
<link rel="stylesheet" href="css/header-fix.css">
<link rel="canonical" href="https://mspe.pro/news.html">
<!-- Open Graph -->
<meta property="og:type" content="website">
<meta property="og:url" content="https://mspe.pro/news.html">
<meta property="og:title" content="News & Insights | MSPE - Architects of Digital Resilience">
<meta property="og:description" content="Stay ahead with MSPE perspectives on cybersecurity, cloud innovation, and digital transformation.">
<meta property="og:image" content="https://mspe.pro/images/logo/logo.png">
<meta property="og:site_name" content="MSPE">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="@mspe_pro">
<meta name="twitter:title" content="MSPE News & Insights">
<meta name="twitter:description" content="Perspectives on cybersecurity, cloud innovation, and digital transformation.">
<meta name="twitter:image" content="https://mspe.pro/images/logo/logo.png">
</head>
<body>
<!-- Skip to Content (Accessibility) -->
<a href="#main-content" class="skip-to-content">Skip to main content</a>
<!-- Preloader -->
<div class="preloader">
<div class="preloader-inner">
<div class="preloader-logo">
<img src="images/logo/logo.png" alt="MSPE PRO" style="height: 50px;" width="200" height="50">
</div>
<div class="preloader-spinner"></div>
</div>
</div>
<!-- Header -->
<header class="header" id="header">
<nav class="nav container">
<a href="index.html" class="nav-logo">
<img src="images/logo/logo.png" alt="MSPE PRO" class="logo-image" width="200" height="50">
</a>
<div class="nav-menu" id="nav-menu">
<ul class="nav-list">
<li class="nav-item"><a href="index.html" class="nav-link">Home</a></li>
<li class="nav-item"><a href="about.html" class="nav-link">About Us</a></li>
<li class="nav-item dropdown">
<a href="services.html" class="nav-link">Services <i class="fas fa-chevron-down"></i></a>
<ul class="dropdown-menu">
<li><a href="services.html#it-support">IT Support & Maintenance</a></li>
<li><a href="services.html#cybersecurity">Cybersecurity Solutions</a></li>
<li><a href="services.html#cloud">Cloud Services</a></li>
<li><a href="services.html#consulting">Business Consulting</a></li>
</ul>
</li>
<li class="nav-item"><a href="portfolio.html" class="nav-link">Portfolio</a></li>
<li class="nav-item"><a href="news.html" class="nav-link active">News & Events</a></li>
<li class="nav-item"><a href="contact.html" class="nav-link">Contact</a></li>
</ul>
</div>
<div class="nav-actions">
<a href="calendar.html" class="btn btn-primary">Book Meeting</a>
<button class="nav-toggle" id="nav-toggle" aria-label="Toggle navigation menu" aria-expanded="false">
<span></span>
<span></span>
<span></span>
</button>
</div>
</nav>
</header>
<!-- Page Hero -->
<section class="page-hero">
<div class="page-hero-bg">
<div class="hero-shape hero-shape-1"></div>
<div class="hero-shape hero-shape-2"></div>
<div class="hero-particles"></div>
</div>
<div class="container">
<div class="page-hero-content">
<h1 class="page-title">Insights & <span class="text-gradient">Perspectives</span></h1>
<p class="page-subtitle">Thoughts on technology, security, and digital transformation from the MSPE team</p>
</div>
</div>
</section>
<!-- Coming Soon Section -->
<section class="news-filter-section" id="coming-soon-section">
<div class="container">
<div style="text-align: center; max-width: 800px; margin: 0 auto; padding: 4rem 2rem;">
<div style="width: 120px; height: 120px; background: linear-gradient(135deg, var(--accent-cyan), var(--secondary-teal)); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 2rem;">
<i class="fas fa-pen-fancy" style="font-size: 3rem; color: white;"></i>
</div>
<h2 style="font-size: 2.5rem; margin-bottom: 1rem;">Insights Coming Soon</h2>
<p style="font-size: 1.2rem; opacity: 0.8; line-height: 1.8; margin-bottom: 2rem;">
We're preparing thoughtful content on cybersecurity trends, cloud innovation, and digital transformation strategies. Our team is crafting insights that will actually help your business - not just fill space.
</p>
<p style="font-size: 1.1rem; opacity: 0.7; margin-bottom: 2rem;">
Quality over quantity. When we publish, it will be worth your time.
</p>
</div>
</div>
</section>
<!-- News Grid Section -->
<section class="news-grid-section" id="news-section" style="display: none;">
<div class="container">
<div class="section-header">
<h2 class="section-title">Latest <span class="text-gradient">Insights</span></h2>
<p class="section-subtitle">Trends, strategies, and updates from the MSPE team</p>
</div>
<div id="news-grid" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 2rem; margin-top: 3rem;">
<!-- News items will be populated here -->
</div>
</div>
</section>
<!-- Stay Connected Section -->
<section class="upcoming-events">
<div class="container">
<div class="section-header">
<h2 class="section-title">Stay <span class="text-gradient">Connected</span></h2>
<p class="section-subtitle">Be the first to know when we publish new insights</p>
</div>
<div style="max-width: 600px; margin: 3rem auto; text-align: center;">
<p style="font-size: 1.1rem; opacity: 0.8; margin-bottom: 2rem;">
Want to be notified when we launch our insights section? Drop us a message and we'll keep you in the loop.
</p>
<a href="contact.html" class="btn btn-primary btn-lg">Get Notified</a>
</div>
</div>
</section>
<!-- Newsletter Section -->
<section class="newsletter-section">
<div class="container">
<div class="newsletter-card">
<div class="newsletter-content">
<div class="newsletter-icon">
<i class="fas fa-envelope-open-text"></i>
</div>
<div class="newsletter-text">
<h3>Subscribe to Our Newsletter</h3>
<p>Get the latest news, insights, and updates delivered directly to your inbox</p>
</div>
</div>
<form class="newsletter-form" id="newsletter-form">
<div class="newsletter-input-group">
<input type="email" name="email" placeholder="Enter your email address" required>
<button type="submit" class="btn btn-primary">
Subscribe <i class="fas fa-paper-plane"></i>
</button>
</div>
<p class="newsletter-note">We respect your privacy. Unsubscribe at any time.</p>
</form>
</div>
</div>
</section>
<!-- CTA Section -->
<section class="cta-section">
<div class="cta-bg">
<div class="cta-shape cta-shape-1"></div>
<div class="cta-shape cta-shape-2"></div>
</div>
<div class="container">
<div class="cta-content">
<h2 class="cta-title">Ready to Transform Your IT Infrastructure?</h2>
<p class="cta-text">Let's discuss how MSPE can help secure and optimize your business technology</p>
<div class="cta-buttons">
<a href="contact.html" class="btn btn-white">Schedule Consultation</a>
<a href="tel:+96178782023" class="btn btn-outline-white" data-site-phone>
<i class="fas fa-phone-alt"></i> Call Us Now
</a>
</div>
</div>
</div>
</section>
<!-- Footer -->
<footer class="footer">
<div class="footer-main">
<div class="container">
<div class="footer-grid">
<div class="footer-brand">
<a href="index.html" class="footer-logo">
<img src="images/logo/logo.png" alt="MSPE PRO" class="logo-image" width="200" height="50">
</a>
<p class="footer-description">We engineer possibilities. Transforming complexity into competitive advantage through innovative technology solutions and uncompromising security.</p>
<div class="footer-social">
<a href="https://www.facebook.com/mspe.pro" class="social-link" title="Facebook" target="_blank" rel="noopener noreferrer"><i class="fab fa-facebook-f"></i></a>
<a href="https://www.linkedin.com/company/mspe-pro" class="social-link" title="LinkedIn" target="_blank" rel="noopener noreferrer"><i class="fab fa-linkedin-in"></i></a>
<a href="https://x.com/mspe_pro" class="social-link" title="X (Twitter)" target="_blank" rel="noopener noreferrer"><i class="fab fa-twitter"></i></a>
<a href="https://www.instagram.com/mspe.pro" class="social-link" title="Instagram" target="_blank" rel="noopener noreferrer"><i class="fab fa-instagram"></i></a>
</div>
</div>
<div class="footer-links">
<h4 class="footer-title">Quick Links</h4>
<ul>
<li><a href="about.html">About Us</a></li>
<li><a href="services.html">Our Services</a></li>
<li><a href="portfolio.html">Portfolio</a></li>
<li><a href="news.html">News & Events</a></li>
<li><a href="contact.html">Contact Us</a></li>
</ul>
</div>
<div class="footer-links">
<h4 class="footer-title">Services</h4>
<ul>
<li><a href="services.html#it-support">IT Support & Maintenance</a></li>
<li><a href="services.html#cybersecurity">Cybersecurity Solutions</a></li>
<li><a href="services.html#cloud">Cloud Services</a></li>
<li><a href="services.html#consulting">Business Consulting</a></li>
</ul>
</div>
<div class="footer-contact">
<h4 class="footer-title">Contact Info</h4>
<ul class="contact-list">
<li>
<i class="fas fa-envelope"></i>
<span data-site-email>info@mspe.pro</span>
</li>
<li>
<i class="fas fa-phone"></i>
<a href="tel:+96178782023" data-site-phone style="color:inherit;">+961 78 782 023</a>
</li>
<li>
<i class="fas fa-clock"></i>
<span>Available for consultations</span>
</li>
<li>
<i class="fas fa-map-marker-alt"></i>
<span>Beirut, Lebanon</span>
</li>
</ul>
</div>
</div>
</div>
</div>
<div class="footer-bottom">
<div class="container">
<p>&copy; 2026 MSPE - Architects of Digital Resilience. All Rights Reserved.</p>
<div class="footer-bottom-links">
<a href="privacy.html">Privacy Policy</a>
<a href="terms.html">Terms of Service</a>
</div>
</div>
</div>
</footer>
<!-- Back to Top -->
<button class="back-to-top" id="back-to-top" aria-label="Back to top">
<i class="fas fa-chevron-up"></i>
</button>
<!-- Scripts -->
<script src="js/main.js" defer></script>
<script src="js/ultimate-ui.js" defer></script>
<script>
document.addEventListener('DOMContentLoaded', async function() {
try {
const response = await fetch('/api/news.php?status=published');
const result = await response.json();
if (result.success && result.data && result.data.length > 0) {
// Hide "Coming Soon" and "What to Expect"
document.getElementById('coming-soon-section').style.display = 'none';
document.getElementById('what-to-expect').style.display = 'none';
// Show News Section
document.getElementById('news-section').style.display = 'block';
const grid = document.getElementById('news-grid');
grid.innerHTML = result.data.map(item => `
<div class="news-card" style="background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.05); transition: transform 0.3s ease;">
<div class="news-image" style="height: 200px; overflow: hidden;">
<img src="${item.featured_image || 'images/logo/logo.png'}" alt="${item.title}" width="800" height="450" loading="lazy" decoding="async" style="width: 100%; height: 100%; object-fit: cover;">
</div>
<div class="news-content" style="padding: 1.5rem;">
<div class="news-meta" style="font-size: 0.875rem; color: #64748b; margin-bottom: 0.5rem;">
<span class="news-category" style="color: var(--primary); font-weight: 600; text-transform: uppercase;">${item.category}</span>
<span class="news-date"> • ${new Date(item.created_at).toLocaleDateString()}</span>
</div>
<h3 class="news-title" style="font-size: 1.25rem; margin-bottom: 0.75rem; color: #0f172a;">${item.title}</h3>
<p class="news-excerpt" style="color: #475569; font-size: 0.95rem; line-height: 1.6; margin-bottom: 1.5rem;">
${item.excerpt || item.content.substring(0, 100) + '...'}
</p>
<a href="news.html?id=${item.id}" class="btn-link" style="color: var(--primary); font-weight: 600; text-decoration: none;">Read More <i class="fas fa-arrow-right"></i></a>
</div>
</div>
`).join('');
}
} catch (error) {
console.error('Failed to load news:', error);
}
// ... existing newsletter code ...
const filterBtns = document.querySelectorAll('.filter-btn');
const newsCards = document.querySelectorAll('.news-card, .featured-article');
const searchInput = document.getElementById('news-search');
// Filter by category
filterBtns.forEach(btn => {
btn.addEventListener('click', function() {
filterBtns.forEach(b => b.classList.remove('active'));
this.classList.add('active');
const filter = this.dataset.filter;
newsCards.forEach(card => {
if (filter === 'all' || card.dataset.category === filter) {
card.style.display = '';
card.style.animation = 'fadeInUp 0.5s ease forwards';
} else {
card.style.display = 'none';
}
});
});
});
// Search functionality
if (searchInput) {
searchInput.addEventListener('input', function() {
const searchTerm = this.value.toLowerCase();
newsCards.forEach(card => {
const title = card.querySelector('.news-title, .article-title').textContent.toLowerCase();
const excerpt = card.querySelector('.news-excerpt, .article-excerpt');
const excerptText = excerpt ? excerpt.textContent.toLowerCase() : '';
if (title.includes(searchTerm) || excerptText.includes(searchTerm)) {
card.style.display = '';
} else {
card.style.display = 'none';
}
});
});
}
// Load more functionality
const loadMoreBtn = document.getElementById('load-more-news');
if (loadMoreBtn) {
loadMoreBtn.addEventListener('click', function() {
const spinner = this.querySelector('.fa-spinner');
spinner.style.display = 'inline-block';
// Simulate loading more articles
setTimeout(() => {
spinner.style.display = 'none';
// In production, this would load from API
alert('All articles have been loaded!');
}, 1000);
});
}
// Newsletter form
const newsletterForm = document.getElementById('newsletter-form');
if (newsletterForm) {
newsletterForm.addEventListener('submit', async function(e) {
e.preventDefault();
const emailInput = this.querySelector('input[type="email"]');
const submitBtn = this.querySelector('button[type="submit"]');
const email = emailInput.value;
if (!email) return;
const originalText = submitBtn.innerHTML;
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
submitBtn.disabled = true;
try {
const response = await fetch('/api/contact.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
email: email,
first_name: 'Newsletter',
last_name: 'Subscriber',
message: 'Newsletter subscription from news page',
newsletter: true
})
});
const result = await response.json();
if (result.success) {
if (typeof showNotification === 'function') {
showNotification('Thank you for subscribing!', 'success');
} else {
alert('Thank you for subscribing! We\'ll keep you updated.');
}
this.reset();
} else {
if (typeof showNotification === 'function') {
showNotification(result.message || 'Subscription failed. Please try again.', 'error');
} else {
alert(result.message || 'Subscription failed. Please try again.');
}
}
} catch (error) {
console.error('Newsletter error:', error);
alert('An error occurred. Please try again.');
} finally {
submitBtn.innerHTML = originalText;
submitBtn.disabled = false;
}
});
}
});
</script>
</body>
</html>
+561
View File
@@ -0,0 +1,561 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="MSPE Portfolio - Discover how we architect digital resilience. Case studies and success stories coming soon as we embark on our journey.">
<title>Portfolio | MSPE - Architects of Digital Resilience</title>
<link rel="icon" type="image/png" href="images/logo/logo.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Playfair+Display:wght@600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'">
<noscript><link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Playfair+Display:wght@600;700&display=swap" rel="stylesheet"></noscript>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha384-t1nt8BQoYMLFN5p42tRAtuAAFQaCQODekUVeKKZrEnEyp4H2R0RHFz0KWpmj7i8g" crossorigin="anonymous" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha384-t1nt8BQoYMLFN5p42tRAtuAAFQaCQODekUVeKKZrEnEyp4H2R0RHFz0KWpmj7i8g" crossorigin="anonymous"></noscript>
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/pages.css">
<link rel="stylesheet" href="css/ultimate-ui.css" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="css/ultimate-ui.css"></noscript>
<link rel="stylesheet" href="css/header-fix.css">
<link rel="canonical" href="https://mspe.pro/portfolio.html">
<!-- Open Graph -->
<meta property="og:type" content="website">
<meta property="og:url" content="https://mspe.pro/portfolio.html">
<meta property="og:title" content="Portfolio | MSPE - Architects of Digital Resilience">
<meta property="og:description" content="MSPE Portfolio - Where innovation meets execution. Discover the transformative projects we deliver for our clients.">
<meta property="og:image" content="https://mspe.pro/images/logo/logo.png">
<meta property="og:site_name" content="MSPE">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="@mspe_pro">
<meta name="twitter:title" content="MSPE Portfolio - Digital Transformation Projects">
<meta name="twitter:description" content="Where innovation meets execution. Our work speaks for itself.">
<meta name="twitter:image" content="https://mspe.pro/images/logo/logo.png">
<style>
.case-studies-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 2rem;
margin-top: 3rem;
}
@media (max-width: 1100px) {
.case-studies-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 720px) {
.case-studies-grid { grid-template-columns: 1fr; }
}
.case-study-card {
/* Override pages.css grid-template-columns: 1fr 1fr which corrupts layout */
display: flex !important;
flex-direction: column !important;
grid-template-columns: none !important;
gap: 0 !important;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 20px;
overflow: hidden;
margin-bottom: 0;
transition: transform 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease;
}
.case-study-card:hover {
transform: translateY(-5px);
border-color: rgba(14, 165, 233, 0.35);
box-shadow: 0 24px 48px rgba(0,0,0,0.35);
}
.cs-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.75rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.02);
}
.cs-category {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 0.35rem 0.85rem;
border-radius: 20px;
background: rgba(14, 165, 233, 0.12);
color: #38bdf8;
border: 1px solid rgba(14, 165, 233, 0.25);
}
.cs-category.cs-cyber {
background: rgba(239, 68, 68, 0.1);
color: #fca5a5;
border-color: rgba(239, 68, 68, 0.25);
}
.cs-category.cs-cloud {
background: rgba(13, 148, 136, 0.12);
color: #5eead4;
border-color: rgba(13, 148, 136, 0.25);
}
.cs-icon {
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(14, 165, 233, 0.1);
font-size: 1rem;
color: #38bdf8;
}
.cs-body { padding: 1.75rem; }
.cs-body h3 {
font-size: 1.15rem;
font-weight: 700;
color: #e2e8f0;
margin-bottom: 0.5rem;
line-height: 1.4;
}
.cs-client {
font-size: 0.82rem;
color: #64748b;
margin-bottom: 1.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.cs-metrics {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
margin-bottom: 1.5rem;
padding: 1rem;
background: rgba(255, 255, 255, 0.03);
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.05);
overflow: hidden;
}
.cs-metric { text-align: center; min-width: 0; }
.metric-value {
display: block;
font-size: clamp(1rem, 2.5vw, 1.4rem);
font-weight: 800;
color: #38bdf8;
line-height: 1.1;
margin-bottom: 0.25rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.metric-label {
font-size: 0.67rem;
color: #64748b;
line-height: 1.3;
}
.cs-detail { display: flex; flex-direction: column; gap: 0.6rem; }
.cs-row { font-size: 0.84rem; color: #94a3b8; line-height: 1.6; }
.cs-row strong { color: #cbd5e1; }
.cs-tags {
padding: 1rem 1.75rem 1.5rem;
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.cs-tags span {
font-size: 0.7rem;
padding: 0.25rem 0.65rem;
border-radius: 20px;
background: rgba(255, 255, 255, 0.04);
color: #64748b;
border: 1px solid rgba(255, 255, 255, 0.07);
}
.cs-confidentiality {
text-align: center;
color: #475569;
font-size: 0.8rem;
margin-top: 2.5rem;
font-style: italic;
}
</style>
</head>
<body>
<!-- Skip to Content (Accessibility) -->
<a href="#main-content" class="skip-to-content">Skip to main content</a>
<!-- Navigation -->
<header class="header" id="header">
<nav class="nav container">
<a href="index.html" class="nav-logo">
<img src="images/logo/logo.png" alt="MSPE PRO" class="logo-image" width="200" height="50">
</a>
<div class="nav-menu" id="nav-menu">
<ul class="nav-list">
<li class="nav-item"><a href="index.html" class="nav-link">Home</a></li>
<li class="nav-item"><a href="about.html" class="nav-link">About Us</a></li>
<li class="nav-item dropdown">
<a href="services.html" class="nav-link">Services <i class="fas fa-chevron-down"></i></a>
<ul class="dropdown-menu">
<li><a href="services.html#it-support">IT Support & Maintenance</a></li>
<li><a href="services.html#cybersecurity">Cybersecurity Solutions</a></li>
<li><a href="services.html#cloud">Cloud Services</a></li>
<li><a href="services.html#consulting">Business Consulting</a></li>
</ul>
</li>
<li class="nav-item"><a href="portfolio.html" class="nav-link active">Portfolio</a></li>
<li class="nav-item"><a href="news.html" class="nav-link">News & Events</a></li>
<li class="nav-item"><a href="contact.html" class="nav-link">Contact</a></li>
</ul>
</div>
<div class="nav-actions">
<a href="calendar.html" class="btn btn-primary">Book Meeting</a>
<button class="nav-toggle" id="nav-toggle" aria-label="Toggle navigation menu" aria-expanded="false">
<span></span>
<span></span>
<span></span>
</button>
</div>
</nav>
</header>
<!-- Page Hero -->
<section class="page-hero">
<div class="page-hero-bg"></div>
<div class="container">
<div class="page-hero-content">
<span class="page-label">Our Work</span>
<h1 class="page-title">Portfolio</h1>
<p class="page-description">Where innovation meets execution. Our portfolio is just beginning - and we're excited to make you part of it.</p>
</div>
</div>
</section>
<!-- Case Studies Section -->
<section class="portfolio section" id="case-studies-section">
<div class="container">
<div class="section-header">
<span class="section-label">Our Work</span>
<h2 class="section-title">Case Studies</h2>
<p class="section-description">Real engagements. Anonymized at client request. Measurable results.</p>
</div>
<div class="case-studies-grid">
<!-- Case Study 1: Retail Network Overhaul -->
<div class="case-study-card">
<div class="cs-header">
<span class="cs-category">IT Support &amp; Infrastructure</span>
<div class="cs-icon"><i class="fas fa-network-wired"></i></div>
</div>
<div class="cs-body">
<h3>Multi-Branch Retail Network Overhaul</h3>
<p class="cs-client"><i class="fas fa-store"></i>&nbsp; Retail Chain — 3 Locations, Lebanon</p>
<div class="cs-metrics">
<div class="cs-metric">
<span class="metric-value">85</span>
<span class="metric-label">Endpoints Managed</span>
</div>
<div class="cs-metric">
<span class="metric-value">0h</span>
<span class="metric-label">Unplanned Downtime</span>
</div>
<div class="cs-metric">
<span class="metric-value">↓40%</span>
<span class="metric-label">IT Incidents</span>
</div>
</div>
<div class="cs-detail">
<div class="cs-row"><strong>Challenge:</strong> Fragmented IT across 3 branches — aging switches, no centralized monitoring, and recurring outages during peak trading hours.</div>
<div class="cs-row"><strong>Solution:</strong> Full network redesign with unified firewall policy, centralized endpoint management, and a 24/7 monitoring dashboard.</div>
<div class="cs-row"><strong>Result:</strong> Zero unplanned downtime in the 60 days post-deployment. IT incident tickets dropped 40% month-over-month.</div>
</div>
</div>
<div class="cs-tags">
<span>Network Design</span><span>Firewall Deployment</span><span>Endpoint Management</span><span>24/7 Monitoring</span>
</div>
</div>
<!-- Case Study 2: Financial Firm Security Assessment -->
<div class="case-study-card">
<div class="cs-header">
<span class="cs-category cs-cyber">Cybersecurity</span>
<div class="cs-icon" style="background: rgba(239,68,68,0.1); color: #fca5a5;"><i class="fas fa-shield-alt"></i></div>
</div>
<div class="cs-body">
<h3>Security Assessment &amp; Hardening — Financial Firm</h3>
<p class="cs-client"><i class="fas fa-landmark"></i>&nbsp; Financial Advisory — 40 Users, Beirut</p>
<div class="cs-metrics">
<div class="cs-metric">
<span class="metric-value">12</span>
<span class="metric-label">Critical Vulns Found</span>
</div>
<div class="cs-metric">
<span class="metric-value">30d</span>
<span class="metric-label">Full Remediation</span>
</div>
<div class="cs-metric">
<span class="metric-value"></span>
<span class="metric-label">Regulatory Pass</span>
</div>
</div>
<div class="cs-detail">
<div class="cs-row"><strong>Challenge:</strong> No formal security posture. Regulatory pressure demanded documented controls and an incident response plan within 60 days.</div>
<div class="cs-row"><strong>Solution:</strong> Full penetration test, gap analysis against ISO 27001 controls, SIEM deployment, and staff security awareness training.</div>
<div class="cs-row"><strong>Result:</strong> All 12 critical findings remediated in 30 days. Client passed their regulatory review with zero audit findings.</div>
</div>
</div>
<div class="cs-tags">
<span>Penetration Testing</span><span>SIEM Deployment</span><span>ISO 27001</span><span>Security Training</span>
</div>
</div>
<!-- Case Study 3: Microsoft 365 Migration -->
<div class="case-study-card">
<div class="cs-header">
<span class="cs-category cs-cloud">Cloud &amp; Migration</span>
<div class="cs-icon" style="background: rgba(13,148,136,0.1); color: #5eead4;"><i class="fas fa-cloud-upload-alt"></i></div>
</div>
<div class="cs-body">
<h3>Microsoft 365 Migration — Professional Services Firm</h3>
<p class="cs-client"><i class="fas fa-briefcase"></i>&nbsp; Law Firm — 25 Users, Lebanon</p>
<div class="cs-metrics">
<div class="cs-metric">
<span class="metric-value">25</span>
<span class="metric-label">Users Migrated</span>
</div>
<div class="cs-metric">
<span class="metric-value">99.9%</span>
<span class="metric-label">Uptime Post-Go-Live</span>
</div>
<div class="cs-metric">
<span class="metric-value">↓60%</span>
<span class="metric-label">On-Prem Costs</span>
</div>
</div>
<div class="cs-detail">
<div class="cs-row"><strong>Challenge:</strong> Aging on-premises Exchange server and local file shares — high maintenance cost, single point of failure, and no disaster recovery plan.</div>
<div class="cs-row"><strong>Solution:</strong> Staged migration to Microsoft 365 with SharePoint, Teams, and Intune device management. Full data backup to Azure.</div>
<div class="cs-row"><strong>Result:</strong> Completed over one weekend with zero data loss. On-prem infrastructure costs dropped 60%. Staff productivity measurably improved.</div>
</div>
</div>
<div class="cs-tags">
<span>Microsoft 365</span><span>Azure Backup</span><span>Intune MDM</span><span>Cloud Migration</span>
</div>
</div>
</div>
<p class="cs-confidentiality"><i class="fas fa-lock" style="margin-right: 0.4rem;"></i>Client identities withheld at their request. Engagement details anonymized to protect confidentiality.</p>
</div>
</section>
<!-- Portfolio Grid Section -->
<section class="portfolio section" id="portfolio-section" style="display: none;">
<div class="container">
<div class="section-header">
<span class="section-label">Our Work</span>
<h2 class="section-title">Success Stories</h2>
<p class="section-description">Explore our latest projects and digital transformations</p>
</div>
<div class="portfolio-filter">
<button class="filter-btn active" data-filter="all">All</button>
<button class="filter-btn" data-filter="it-support">IT Support</button>
<button class="filter-btn" data-filter="cybersecurity">Cybersecurity</button>
<button class="filter-btn" data-filter="cloud">Cloud</button>
<button class="filter-btn" data-filter="consulting">Consulting</button>
</div>
<div id="portfolio-grid" class="portfolio-grid">
<!-- Portfolio items will be populated here -->
</div>
</div>
</section>
<!-- What Every Engagement Includes -->
<section class="clients-showcase section">
<div class="container">
<div class="section-header">
<span class="section-label">Our Standard</span>
<h2 class="section-title">What Every Engagement Includes</h2>
<p class="section-description">No matter the scope, these are our non-negotiable deliverables on every project</p>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 2rem; margin-top: 3rem;">
<div style="text-align: center; padding: 2rem;">
<i class="fas fa-search" style="font-size: 2.5rem; color: var(--accent-cyan); margin-bottom: 1rem;"></i>
<h4 style="margin-bottom: 0.5rem;">Free Discovery Audit</h4>
<p style="opacity: 0.8;">Every engagement starts with a no-cost assessment of your environment, goals, and risk exposure — before any commitment.</p>
</div>
<div style="text-align: center; padding: 2rem;">
<i class="fas fa-file-alt" style="font-size: 2.5rem; color: var(--accent-cyan); margin-bottom: 1rem;"></i>
<h4 style="margin-bottom: 0.5rem;">Full Documentation Handover</h4>
<p style="opacity: 0.8;">You own everything: network diagrams, runbooks, credentials, and complete handover docs. No vendor lock-in, ever.</p>
</div>
<div style="text-align: center; padding: 2rem;">
<i class="fas fa-headset" style="font-size: 2.5rem; color: var(--accent-cyan); margin-bottom: 1rem;"></i>
<h4 style="margin-bottom: 0.5rem;">Dedicated Point of Contact</h4>
<p style="opacity: 0.8;">One engineer who knows your environment and your team — reachable directly, not through a ticket queue.</p>
</div>
<div style="text-align: center; padding: 2rem;">
<i class="fas fa-chart-line" style="font-size: 2.5rem; color: var(--accent-cyan); margin-bottom: 1rem;"></i>
<h4 style="margin-bottom: 0.5rem;">30-Day Post-Deployment Review</h4>
<p style="opacity: 0.8;">30 days after go-live, we review metrics, address gaps, and confirm the solution is performing exactly as designed.</p>
</div>
</div>
</div>
</section>
<!-- CTA -->
<section class="cta">
<div class="cta-bg"></div>
<div class="container">
<div class="cta-content">
<h2 class="cta-title">Ready to Be Our Next Success Story?</h2>
<p class="cta-description">Let's discuss how MSPE can help transform your business with proven solutions.</p>
<div class="cta-buttons">
<a href="contact.html" class="btn btn-primary btn-lg">Start Your Project</a>
<a href="services.html" class="btn btn-outline btn-lg">View Services</a>
</div>
</div>
</div>
</section>
<!-- Footer -->
<footer class="footer">
<div class="footer-main">
<div class="container">
<div class="footer-grid">
<div class="footer-brand">
<a href="index.html" class="footer-logo">
<img src="images/logo/logo.png" alt="MSPE PRO" class="logo-image" width="200" height="50">
</a>
<p class="footer-description">We engineer possibilities. Transforming complexity into competitive advantage through innovative technology solutions and uncompromising security.</p>
<div class="footer-social">
<a href="https://www.facebook.com/mspe.pro" class="social-link" title="Facebook" target="_blank" rel="noopener noreferrer"><i class="fab fa-facebook-f"></i></a>
<a href="https://www.linkedin.com/company/mspe-pro" class="social-link" title="LinkedIn" target="_blank" rel="noopener noreferrer"><i class="fab fa-linkedin-in"></i></a>
<a href="https://x.com/mspe_pro" class="social-link" title="X (Twitter)" target="_blank" rel="noopener noreferrer"><i class="fab fa-twitter"></i></a>
<a href="https://www.instagram.com/mspe.pro" class="social-link" title="Instagram" target="_blank" rel="noopener noreferrer"><i class="fab fa-instagram"></i></a>
</div>
</div>
<div class="footer-links">
<h4 class="footer-title">Quick Links</h4>
<ul>
<li><a href="about.html">About Us</a></li>
<li><a href="services.html">Our Services</a></li>
<li><a href="portfolio.html">Portfolio</a></li>
<li><a href="news.html">News & Events</a></li>
<li><a href="contact.html">Contact Us</a></li>
</ul>
</div>
<div class="footer-links">
<h4 class="footer-title">Services</h4>
<ul>
<li><a href="services.html#it-support">IT Support & Maintenance</a></li>
<li><a href="services.html#cybersecurity">Cybersecurity Solutions</a></li>
<li><a href="services.html#cloud">Cloud Services</a></li>
<li><a href="services.html#consulting">Business Consulting</a></li>
</ul>
</div>
<div class="footer-contact">
<h4 class="footer-title">Contact Info</h4>
<ul class="contact-list">
<li>
<i class="fas fa-envelope"></i>
<span data-site-email>info@mspe.pro</span>
</li>
<li>
<i class="fas fa-phone"></i>
<a href="tel:+96178782023" data-site-phone style="color:inherit;">+961 78 782 023</a>
</li>
<li>
<i class="fas fa-clock"></i>
<span>Available for consultations</span>
</li>
<li>
<i class="fas fa-map-marker-alt"></i>
<span>Beirut, Lebanon</span>
</li>
</ul>
</div>
</div>
</div>
</div>
<div class="footer-bottom">
<div class="container">
<p>&copy; 2026 MSPE - Architects of Digital Resilience. All Rights Reserved.</p>
<div class="footer-bottom-links">
<a href="privacy.html">Privacy Policy</a>
<a href="terms.html">Terms of Service</a>
</div>
</div>
</div>
</footer>
<!-- Back to Top -->
<button class="back-to-top" id="back-to-top" aria-label="Back to top">
<i class="fas fa-chevron-up"></i>
</button>
<script src="js/main.js" defer></script>
<script src="js/ultimate-ui.js" defer></script>
<script>
document.addEventListener('DOMContentLoaded', async function() {
try {
const response = await fetch('/api/portfolio.php?status=published');
const result = await response.json();
if (result.success && result.data && result.data.length > 0) {
// Hide "Coming Soon" and "What We Deliver"
document.getElementById('coming-soon-section').style.display = 'none';
document.getElementById('what-we-deliver').style.display = 'none';
// Show Portfolio Section
document.getElementById('portfolio-section').style.display = 'block';
const grid = document.getElementById('portfolio-grid');
const items = result.data.map(item => `
<div class="portfolio-item" data-category="${item.category}">
<div class="portfolio-image">
<img src="${item.image || 'images/logo/logo.png'}" alt="${item.title}" width="800" height="600" loading="lazy" decoding="async">
<div class="portfolio-overlay">
<div class="portfolio-content">
<span class="category-tag">${item.category}</span>
<h3>${item.title}</h3>
<p>${item.description || item.content.substring(0, 100) + '...'}</p>
<a href="${item.url || '#'}" class="btn btn-sm btn-white">View Case Study</a>
</div>
</div>
</div>
</div>
`).join('');
grid.innerHTML = items;
// Re-initialize filter logic
const filterBtns = document.querySelectorAll('.filter-btn');
const portfolioItems = document.querySelectorAll('.portfolio-item');
filterBtns.forEach(btn => {
btn.addEventListener('click', () => {
filterBtns.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const filter = btn.dataset.filter;
portfolioItems.forEach(item => {
if (filter === 'all' || item.dataset.category === filter) {
item.style.display = 'block';
setTimeout(() => item.style.opacity = '1', 10);
} else {
item.style.opacity = '0';
setTimeout(() => item.style.display = 'none', 300);
}
});
});
});
}
} catch (error) {
console.error('Failed to load portfolio:', error);
}
});
</script>
</body>
</html>
+158
View File
@@ -0,0 +1,158 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="MSPE Privacy Policy">
<title>Privacy Policy | MSPE - Architects of Digital Resilience</title>
<link rel="icon" type="image/png" href="images/logo/logo.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Playfair+Display:wght@600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'">
<noscript><link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Playfair+Display:wght@600;700&display=swap" rel="stylesheet"></noscript>
<link rel="preconnect" href="https://cdnjs.cloudflare.com">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha384-t1nt8BQoYMLFN5p42tRAtuAAFQaCQODekUVeKKZrEnEyp4H2R0RHFz0KWpmj7i8g" crossorigin="anonymous" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha384-t1nt8BQoYMLFN5p42tRAtuAAFQaCQODekUVeKKZrEnEyp4H2R0RHFz0KWpmj7i8g" crossorigin="anonymous"></noscript>
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/pages.css">
<link rel="stylesheet" href="css/ultimate-ui.css" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="css/ultimate-ui.css"></noscript>
<link rel="stylesheet" href="css/header-fix.css">
<link rel="canonical" href="https://mspe.pro/privacy.html">
<meta name="robots" content="noindex, follow">
</head>
<body>
<!-- Skip to Content (Accessibility) -->
<a href="#main-content" class="skip-to-content">Skip to main content</a>
<header class="header" id="header">
<nav class="nav container">
<a href="index.html" class="nav-logo">
<img src="images/logo/logo.png" alt="MSPE PRO" class="logo-image" width="200" height="50">
</a>
<div class="nav-menu" id="nav-menu">
<ul class="nav-list">
<li class="nav-item"><a href="index.html" class="nav-link">Home</a></li>
<li class="nav-item"><a href="about.html" class="nav-link">About Us</a></li>
<li class="nav-item dropdown">
<a href="services.html" class="nav-link">Services <i class="fas fa-chevron-down"></i></a>
<ul class="dropdown-menu">
<li><a href="services.html#it-support">IT Support & Maintenance</a></li>
<li><a href="services.html#cybersecurity">Cybersecurity Solutions</a></li>
<li><a href="services.html#cloud">Cloud Services</a></li>
<li><a href="services.html#consulting">Business Consulting</a></li>
</ul>
</li>
<li class="nav-item"><a href="portfolio.html" class="nav-link">Portfolio</a></li>
<li class="nav-item"><a href="news.html" class="nav-link">News & Events</a></li>
<li class="nav-item"><a href="contact.html" class="nav-link">Contact</a></li>
</ul>
</div>
<div class="nav-actions">
<a href="calendar.html" class="btn btn-primary">Book Meeting</a>
<button class="nav-toggle" id="nav-toggle" aria-label="Toggle navigation menu" aria-expanded="false">
<span></span>
<span></span>
<span></span>
</button>
</div>
</nav>
</header>
<section class="section" style="padding-top: 140px;">
<div class="container" style="max-width: 900px;">
<div class="section-header" style="text-align: left;">
<span class="section-label">Legal</span>
<h1 class="section-title">Privacy Policy</h1>
<p class="section-description">Last updated: February 19, 2026</p>
</div>
<div class="service-card" style="padding: 2rem;">
<h3>1. Information We Collect</h3>
<p>We collect contact and business information you submit through forms, including name, email, phone number, company details, and service interests.</p>
<h3>2. How We Use Information</h3>
<p>We use submitted data to respond to inquiries, deliver services, schedule consultations, and improve client communication.</p>
<h3>3. Data Sharing</h3>
<p>We do not sell personal data. We only share information with trusted providers needed for operations (e.g., hosting, email delivery) under confidentiality obligations.</p>
<h3>4. Data Retention</h3>
<p>We retain information only as long as needed for business, legal, and security purposes.</p>
<h3>5. Security</h3>
<p>We apply technical and organizational safeguards to protect submitted data from unauthorized access, loss, and misuse.</p>
<h3>6. Your Rights</h3>
<p>You may request access, correction, or deletion of your personal information by contacting us at <a href="mailto:info@mspe.pro">info@mspe.pro</a>.</p>
<h3>7. Cookies</h3>
<p>We may use essential cookies and analytics tools to support site functionality and performance optimization.</p>
<h3>8. Contact</h3>
<p>For privacy requests, contact us at <a href="mailto:info@mspe.pro">info@mspe.pro</a> or via our <a href="contact.html">contact page</a>.</p>
</div>
</div>
</section>
<footer class="footer">
<div class="footer-main">
<div class="container">
<div class="footer-grid">
<div class="footer-brand">
<a href="index.html" class="footer-logo">
<img src="images/logo/logo.png" alt="MSPE PRO" class="logo-image" width="200" height="50">
</a>
<p class="footer-description">We engineer possibilities. Transforming complexity into competitive advantage through innovative technology solutions.</p>
<div class="footer-social">
<a href="https://www.facebook.com/mspe.pro" class="social-link" title="Facebook" target="_blank" rel="noopener noreferrer"><i class="fab fa-facebook-f"></i></a>
<a href="https://www.linkedin.com/company/mspe-pro" class="social-link" title="LinkedIn" target="_blank" rel="noopener noreferrer"><i class="fab fa-linkedin-in"></i></a>
<a href="https://x.com/mspe_pro" class="social-link" title="X (Twitter)" target="_blank" rel="noopener noreferrer"><i class="fab fa-twitter"></i></a>
<a href="https://www.instagram.com/mspe.pro" class="social-link" title="Instagram" target="_blank" rel="noopener noreferrer"><i class="fab fa-instagram"></i></a>
</div>
</div>
<div class="footer-links">
<h4 class="footer-title">Quick Links</h4>
<ul>
<li><a href="about.html">About Us</a></li>
<li><a href="services.html">Our Services</a></li>
<li><a href="portfolio.html">Portfolio</a></li>
<li><a href="news.html">News & Events</a></li>
<li><a href="contact.html">Contact Us</a></li>
</ul>
</div>
<div class="footer-links">
<h4 class="footer-title">Services</h4>
<ul>
<li><a href="services.html#it-support">IT Support & Maintenance</a></li>
<li><a href="services.html#cybersecurity">Cybersecurity Solutions</a></li>
<li><a href="services.html#cloud">Cloud Services</a></li>
<li><a href="services.html#consulting">Business Consulting</a></li>
</ul>
</div>
<div class="footer-contact">
<h4 class="footer-title">Contact Info</h4>
<ul class="contact-list">
<li><i class="fas fa-envelope"></i><span data-site-email>info@mspe.pro</span></li>
<li><i class="fas fa-phone"></i><a href="tel:+96178782023" data-site-phone style="color:inherit;">+961 78 782 023</a></li>
<li><i class="fas fa-clock"></i><span>Available for consultations</span></li>
<li><i class="fas fa-map-marker-alt"></i><span>Beirut, Lebanon</span></li>
</ul>
</div>
</div>
</div>
</div>
<div class="footer-bottom">
<div class="container">
<p>&copy; 2026 MSPE - Architects of Digital Resilience. All Rights Reserved.</p>
<div class="footer-bottom-links">
<a href="privacy.html">Privacy Policy</a>
<a href="terms.html">Terms of Service</a>
</div>
</div>
</div>
</footer>
<script src="js/main.js" defer></script>
</body>
</html>
+11
View File
@@ -0,0 +1,11 @@
User-agent: *
Allow: /
# Keep admin, API, and raw data files out of search indexing
Disallow: /admin/
Disallow: /api/
Disallow: /data/
Disallow: /uploads/
Disallow: /vendor/
Sitemap: https://mspe.pro/sitemap.xml
+378
View File
@@ -0,0 +1,378 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="MSPE Services in Lebanon — IT Support &amp; Maintenance, Cybersecurity Solutions, Cloud Migration, and Strategic IT Consulting. Serving businesses in Beirut and across the MENA region.">
<title>Our Services | MSPE - Architects of Digital Resilience</title>
<link rel="icon" type="image/png" href="images/logo/logo.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Playfair+Display:wght@600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'">
<noscript><link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Playfair+Display:wght@600;700&display=swap" rel="stylesheet"></noscript>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha384-t1nt8BQoYMLFN5p42tRAtuAAFQaCQODekUVeKKZrEnEyp4H2R0RHFz0KWpmj7i8g" crossorigin="anonymous" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha384-t1nt8BQoYMLFN5p42tRAtuAAFQaCQODekUVeKKZrEnEyp4H2R0RHFz0KWpmj7i8g" crossorigin="anonymous"></noscript>
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/pages.css">
<link rel="stylesheet" href="css/ultimate-ui.css" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="css/ultimate-ui.css"></noscript>
<link rel="stylesheet" href="css/header-fix.css">
<link rel="canonical" href="https://mspe.pro/services.html">
<!-- Structured Data -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "ItemList",
"name": "MSPE Technology Services",
"description": "Comprehensive IT, cybersecurity, cloud, and consulting services for businesses in Lebanon and the MENA region.",
"url": "https://mspe.pro/services.html",
"provider": {
"@type": "Organization",
"name": "MSPE",
"url": "https://mspe.pro",
"address": { "@type": "PostalAddress", "addressLocality": "Beirut", "addressCountry": "LB" }
},
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"item": {
"@type": "Service",
"name": "IT Support & Maintenance",
"description": "Proactive managed IT support including 24/7 remote helpdesk, infrastructure management, and on-site support.",
"url": "https://mspe.pro/services.html#it-support",
"provider": { "@type": "Organization", "name": "MSPE" }
}
},
{
"@type": "ListItem",
"position": 2,
"item": {
"@type": "Service",
"name": "Cybersecurity Solutions",
"description": "End-to-end security services including risk assessment, penetration testing, SIEM/SOC deployment, and incident response.",
"url": "https://mspe.pro/services.html#cybersecurity",
"provider": { "@type": "Organization", "name": "MSPE" }
}
},
{
"@type": "ListItem",
"position": 3,
"item": {
"@type": "Service",
"name": "Cloud Services",
"description": "Cloud architecture, migration, and optimization for Azure, AWS, and Google Cloud environments.",
"url": "https://mspe.pro/services.html#cloud",
"provider": { "@type": "Organization", "name": "MSPE" }
}
},
{
"@type": "ListItem",
"position": 4,
"item": {
"@type": "Service",
"name": "Strategic IT Consulting",
"description": "IT roadmap planning, vendor selection, and digital transformation strategy aligned with business goals.",
"url": "https://mspe.pro/services.html#consulting",
"provider": { "@type": "Organization", "name": "MSPE" }
}
}
]
}
</script>
<!-- Open Graph -->
<meta property="og:type" content="website">
<meta property="og:url" content="https://mspe.pro/services.html">
<meta property="og:title" content="Our Services | MSPE - Architects of Digital Resilience">
<meta property="og:description" content="Next-generation IT Support, Cybersecurity, Cloud Architecture, and Strategic Consulting. Technology solutions engineered for resilience.">
<meta property="og:image" content="https://mspe.pro/images/logo/logo.png">
<meta property="og:site_name" content="MSPE">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="@mspe_pro">
<meta name="twitter:title" content="MSPE Services - IT, Cybersecurity, Cloud & Consulting">
<meta name="twitter:description" content="Comprehensive technology solutions designed to transform your business and drive success.">
<meta name="twitter:image" content="https://mspe.pro/images/logo/logo.png">
</head>
<body>
<!-- Skip to Content (Accessibility) -->
<a href="#main-content" class="skip-to-content">Skip to main content</a>
<!-- Navigation -->
<header class="header" id="header">
<nav class="nav container">
<a href="index.html" class="nav-logo">
<img src="images/logo/logo.png" alt="MSPE PRO" class="logo-image" width="200" height="50">
</a>
<div class="nav-menu" id="nav-menu">
<ul class="nav-list">
<li class="nav-item"><a href="index.html" class="nav-link">Home</a></li>
<li class="nav-item"><a href="about.html" class="nav-link">About Us</a></li>
<li class="nav-item dropdown">
<a href="services.html" class="nav-link active">Services <i class="fas fa-chevron-down"></i></a>
<ul class="dropdown-menu">
<li><a href="#it-support">IT Support & Maintenance</a></li>
<li><a href="#cybersecurity">Cybersecurity Solutions</a></li>
<li><a href="#cloud">Cloud Services</a></li>
<li><a href="#consulting">Business Consulting</a></li>
</ul>
</li>
<li class="nav-item"><a href="portfolio.html" class="nav-link">Portfolio</a></li>
<li class="nav-item"><a href="news.html" class="nav-link">News & Events</a></li>
<li class="nav-item"><a href="contact.html" class="nav-link">Contact</a></li>
</ul>
</div>
<div class="nav-actions">
<a href="calendar.html" class="btn btn-primary">Book Meeting</a>
<button class="nav-toggle" id="nav-toggle" aria-label="Toggle navigation menu" aria-expanded="false">
<span></span>
<span></span>
<span></span>
</button>
</div>
</nav>
</header>
<!-- Page Hero -->
<section class="page-hero">
<div class="page-hero-bg"></div>
<div class="container">
<div class="page-hero-content">
<span class="page-label">What We Offer</span>
<h1 class="page-title">Our Services</h1>
<p class="page-description">Comprehensive technology solutions designed to transform your business and drive success.</p>
</div>
</div>
</section>
<!-- Services Overview (Dynamic) -->
<section class="services section" id="services-overview">
<div class="container">
<div class="section-header">
<span class="section-label">Service Portfolio</span>
<h2 class="section-title">Services at a Glance</h2>
<p class="section-description">Comprehensive technology solutions tailored to accelerate your business growth.</p>
</div>
<div class="services-grid" data-services-grid="true">
<div class="service-card" id="it-support">
<div class="service-icon"><i class="fas fa-headset"></i></div>
<h3 class="service-title">IT Support &amp; Maintenance</h3>
<p class="service-description">Proactive managed IT support that keeps your systems running at peak performance. From helpdesk to infrastructure management, we handle it all.</p>
<ul class="service-features">
<li><i class="fas fa-check"></i> 24/7 Remote Support</li>
<li><i class="fas fa-check"></i> Proactive Monitoring &amp; Alerting</li>
<li><i class="fas fa-check"></i> On-site Support</li>
</ul>
<a href="contact.html?service=it-support" class="service-link">Request Scope <i class="fas fa-arrow-right"></i></a>
</div>
<div class="service-card" id="cybersecurity">
<div class="service-icon"><i class="fas fa-shield-alt"></i></div>
<h3 class="service-title">Cybersecurity Solutions</h3>
<p class="service-description">End-to-end security services to protect your business from evolving threats. Risk assessment, vulnerability management, and incident response.</p>
<ul class="service-features">
<li><i class="fas fa-check"></i> Security Risk Assessment</li>
<li><i class="fas fa-check"></i> Vulnerability Management</li>
<li><i class="fas fa-check"></i> Security Awareness Training</li>
</ul>
<a href="contact.html?service=cybersecurity" class="service-link">Request Scope <i class="fas fa-arrow-right"></i></a>
</div>
<div class="service-card" id="cloud">
<div class="service-icon"><i class="fas fa-cloud"></i></div>
<h3 class="service-title">Cloud Services</h3>
<p class="service-description">Cloud architecture, migration, and optimization. We design scalable, secure cloud environments tailored to your business needs.</p>
<ul class="service-features">
<li><i class="fas fa-check"></i> Cloud Migration &amp; Strategy</li>
<li><i class="fas fa-check"></i> Infrastructure Optimization</li>
<li><i class="fas fa-check"></i> Multi-cloud &amp; Hybrid Solutions</li>
</ul>
<a href="contact.html?service=cloud" class="service-link">Request Scope <i class="fas fa-arrow-right"></i></a>
</div>
<div class="service-card" id="consulting">
<div class="service-icon"><i class="fas fa-chess-king"></i></div>
<h3 class="service-title">Strategic IT Consulting</h3>
<p class="service-description">Expert guidance to align technology with your business goals. We deliver roadmaps, vendor selection, and digital transformation strategies.</p>
<ul class="service-features">
<li><i class="fas fa-check"></i> IT Roadmap Planning</li>
<li><i class="fas fa-check"></i> Vendor Evaluation &amp; Selection</li>
<li><i class="fas fa-check"></i> Digital Transformation Strategy</li>
</ul>
<a href="contact.html?service=consulting" class="service-link">Request Scope <i class="fas fa-arrow-right"></i></a>
</div>
</div>
</div>
</section>
<!-- Pricing -->
<section class="pricing section">
<div class="container">
<div class="section-header">
<span class="section-label">Engagement Models</span>
<h2 class="section-title">How We Work With You</h2>
<p class="section-description">Select the service model that fits your operational requirements</p>
</div>
<div class="pricing-grid">
<div class="pricing-card">
<div class="pricing-header">
<h3>Essential</h3>
<p>For small teams and startups</p>
</div>
<div class="pricing-price">
<span class="amount">Scope-Based</span>
<span class="period">/ per engagement</span>
</div>
<ul class="pricing-features">
<li><i class="fas fa-check"></i> Remote IT Support</li>
<li><i class="fas fa-check"></i> Basic Monitoring</li>
<li><i class="fas fa-check"></i> Email Support</li>
<li><i class="fas fa-check"></i> Monthly Reports</li>
<li><i class="fas fa-times"></i> On-site Support</li>
<li><i class="fas fa-times"></i> Security Services</li>
</ul>
<a href="contact.html" class="btn btn-outline">Request Scope</a>
</div>
<div class="pricing-card featured">
<div class="pricing-header">
<h3>Professional</h3>
<p>For growing businesses</p>
</div>
<div class="pricing-price">
<span class="amount">Retainer</span>
<span class="period">/ monthly</span>
</div>
<ul class="pricing-features">
<li><i class="fas fa-check"></i> Extended IT Support</li>
<li><i class="fas fa-check"></i> Advanced Monitoring</li>
<li><i class="fas fa-check"></i> Priority Support</li>
<li><i class="fas fa-check"></i> Weekly Reports</li>
<li><i class="fas fa-check"></i> Monthly On-site Visits</li>
<li><i class="fas fa-check"></i> Basic Security</li>
</ul>
<a href="contact.html" class="btn btn-primary">Discuss Retainer</a>
</div>
<div class="pricing-card">
<div class="pricing-header">
<h3>Enterprise</h3>
<p>For large organizations</p>
</div>
<div class="pricing-price">
<span class="amount">Custom</span>
<span class="period">/ tailored scope</span>
</div>
<ul class="pricing-features">
<li><i class="fas fa-check"></i> Dedicated Team</li>
<li><i class="fas fa-check"></i> Full SIEM/SOC</li>
<li><i class="fas fa-check"></i> SLA Guarantee</li>
<li><i class="fas fa-check"></i> Real-time Reporting</li>
<li><i class="fas fa-check"></i> Unlimited On-site</li>
<li><i class="fas fa-check"></i> Full Security Suite</li>
</ul>
<a href="contact.html" class="btn btn-outline">Contact Sales</a>
</div>
</div>
</div>
</section>
<!-- CTA -->
<section class="cta">
<div class="cta-bg"></div>
<div class="container">
<div class="cta-content">
<h2 class="cta-title">Need a Custom Solution?</h2>
<p class="cta-description">Let our team define a practical service scope aligned with your current environment and goals.</p>
<div class="cta-buttons">
<a href="contact.html" class="btn btn-primary btn-lg">Request Custom Scope</a>
<a href="contact.html" class="btn btn-outline btn-lg"><i class="fas fa-envelope"></i> Contact Us</a>
</div>
</div>
</div>
</section>
<!-- Footer -->
<footer class="footer">
<div class="footer-main">
<div class="container">
<div class="footer-grid">
<div class="footer-brand">
<a href="index.html" class="footer-logo">
<img src="images/logo/logo.png" alt="MSPE PRO" class="logo-image" width="200" height="50">
</a>
<p class="footer-description">We engineer possibilities. Transforming complexity into competitive advantage through innovative technology solutions and uncompromising security.</p>
<div class="footer-social">
<a href="https://www.facebook.com/mspe.pro" class="social-link" title="Facebook" target="_blank" rel="noopener noreferrer"><i class="fab fa-facebook-f"></i></a>
<a href="https://www.linkedin.com/company/mspe-pro" class="social-link" title="LinkedIn" target="_blank" rel="noopener noreferrer"><i class="fab fa-linkedin-in"></i></a>
<a href="https://x.com/mspe_pro" class="social-link" title="X (Twitter)" target="_blank" rel="noopener noreferrer"><i class="fab fa-twitter"></i></a>
<a href="https://www.instagram.com/mspe.pro" class="social-link" title="Instagram" target="_blank" rel="noopener noreferrer"><i class="fab fa-instagram"></i></a>
</div>
</div>
<div class="footer-links">
<h4 class="footer-title">Quick Links</h4>
<ul>
<li><a href="about.html">About Us</a></li>
<li><a href="services.html">Our Services</a></li>
<li><a href="portfolio.html">Portfolio</a></li>
<li><a href="news.html">News & Events</a></li>
<li><a href="contact.html">Contact Us</a></li>
</ul>
</div>
<div class="footer-links">
<h4 class="footer-title">Services</h4>
<ul>
<li><a href="#it-support">IT Support & Maintenance</a></li>
<li><a href="#cybersecurity">Cybersecurity Solutions</a></li>
<li><a href="#cloud">Cloud Services</a></li>
<li><a href="#consulting">Business Consulting</a></li>
</ul>
</div>
<div class="footer-contact">
<h4 class="footer-title">Contact Info</h4>
<ul class="contact-list">
<li>
<i class="fas fa-envelope"></i>
<span data-site-email>info@mspe.pro</span>
</li>
<li>
<i class="fas fa-phone"></i>
<a href="tel:+96178782023" data-site-phone style="color:inherit;">+961 78 782 023</a>
</li>
<li>
<i class="fas fa-clock"></i>
<span>Available for consultations</span>
</li>
<li>
<i class="fas fa-map-marker-alt"></i>
<span>Beirut, Lebanon</span>
</li>
</ul>
</div>
</div>
</div>
</div>
<div class="footer-bottom">
<div class="container">
<p>&copy; 2026 MSPE - Architects of Digital Resilience. All Rights Reserved.</p>
<div class="footer-bottom-links">
<a href="privacy.html">Privacy Policy</a>
<a href="terms.html">Terms of Service</a>
</div>
</div>
</div>
</footer>
<!-- Back to Top -->
<button class="back-to-top" id="back-to-top" aria-label="Back to top">
<i class="fas fa-chevron-up"></i>
</button>
<script src="js/main.js" defer></script>
<script src="js/ultimate-ui.js" defer></script>
</body>
</html>
+57
View File
@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://mspe.pro/</loc>
<lastmod>2026-02-19</lastmod>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://mspe.pro/about.html</loc>
<lastmod>2026-02-19</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://mspe.pro/services.html</loc>
<lastmod>2026-02-19</lastmod>
<changefreq>weekly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://mspe.pro/portfolio.html</loc>
<lastmod>2026-02-19</lastmod>
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://mspe.pro/news.html</loc>
<lastmod>2026-02-19</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://mspe.pro/contact.html</loc>
<lastmod>2026-02-19</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://mspe.pro/calendar.html</loc>
<lastmod>2026-02-19</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://mspe.pro/privacy.html</loc>
<lastmod>2026-02-19</lastmod>
<changefreq>yearly</changefreq>
<priority>0.3</priority>
</url>
<url>
<loc>https://mspe.pro/terms.html</loc>
<lastmod>2026-02-19</lastmod>
<changefreq>yearly</changefreq>
<priority>0.3</priority>
</url>
</urlset>
+164
View File
@@ -0,0 +1,164 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="MSPE Terms of Service">
<title>Terms of Service | MSPE - Architects of Digital Resilience</title>
<link rel="icon" type="image/png" href="images/logo/logo.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Playfair+Display:wght@600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'">
<noscript><link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Playfair+Display:wght@600;700&display=swap" rel="stylesheet"></noscript>
<link rel="preconnect" href="https://cdnjs.cloudflare.com">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha384-t1nt8BQoYMLFN5p42tRAtuAAFQaCQODekUVeKKZrEnEyp4H2R0RHFz0KWpmj7i8g" crossorigin="anonymous" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha384-t1nt8BQoYMLFN5p42tRAtuAAFQaCQODekUVeKKZrEnEyp4H2R0RHFz0KWpmj7i8g" crossorigin="anonymous"></noscript>
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/pages.css">
<link rel="stylesheet" href="css/ultimate-ui.css" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="css/ultimate-ui.css"></noscript>
<link rel="stylesheet" href="css/header-fix.css">
<link rel="canonical" href="https://mspe.pro/terms.html">
<meta name="robots" content="noindex, follow">
</head>
<body>
<!-- Skip to Content (Accessibility) -->
<a href="#main-content" class="skip-to-content">Skip to main content</a>
<header class="header" id="header">
<nav class="nav container">
<a href="index.html" class="nav-logo">
<img src="images/logo/logo.png" alt="MSPE PRO" class="logo-image" width="200" height="50">
</a>
<div class="nav-menu" id="nav-menu">
<ul class="nav-list">
<li class="nav-item"><a href="index.html" class="nav-link">Home</a></li>
<li class="nav-item"><a href="about.html" class="nav-link">About Us</a></li>
<li class="nav-item dropdown">
<a href="services.html" class="nav-link">Services <i class="fas fa-chevron-down"></i></a>
<ul class="dropdown-menu">
<li><a href="services.html#it-support">IT Support & Maintenance</a></li>
<li><a href="services.html#cybersecurity">Cybersecurity Solutions</a></li>
<li><a href="services.html#cloud">Cloud Services</a></li>
<li><a href="services.html#consulting">Business Consulting</a></li>
</ul>
</li>
<li class="nav-item"><a href="portfolio.html" class="nav-link">Portfolio</a></li>
<li class="nav-item"><a href="news.html" class="nav-link">News & Events</a></li>
<li class="nav-item"><a href="contact.html" class="nav-link">Contact</a></li>
</ul>
</div>
<div class="nav-actions">
<a href="calendar.html" class="btn btn-primary">Book Meeting</a>
<button class="nav-toggle" id="nav-toggle" aria-label="Toggle navigation menu" aria-expanded="false">
<span></span>
<span></span>
<span></span>
</button>
</div>
</nav>
</header>
<section class="section" style="padding-top: 140px;">
<div class="container" style="max-width: 900px;">
<div class="section-header" style="text-align: left;">
<span class="section-label">Legal</span>
<h1 class="section-title">Terms of Service</h1>
<p class="section-description">Last updated: February 19, 2026</p>
</div>
<div class="service-card" style="padding: 2rem;">
<h3>1. Scope of Services</h3>
<p>MSPE provides IT consulting, managed services, security, cloud, and related professional services as agreed in writing with each client.</p>
<h3>2. Client Responsibilities</h3>
<p>Clients must provide accurate information, authorized access, and timely approvals required for delivery.</p>
<h3>3. Fees and Payments</h3>
<p>Fees, billing schedules, and payment terms are defined in signed proposals, statements of work, or service agreements.</p>
<h3>4. Intellectual Property</h3>
<p>Each party retains ownership of its pre-existing intellectual property. Deliverable ownership and usage rights are defined by contract.</p>
<h3>5. Confidentiality</h3>
<p>Both parties agree to maintain confidentiality of non-public business, technical, and security-related information.</p>
<h3>6. Limitation of Liability</h3>
<p>To the maximum extent permitted by law, MSPE is not liable for indirect, incidental, or consequential damages.</p>
<h3>7. Service Availability</h3>
<p>Response and resolution targets may be defined through service level agreements (SLAs) where applicable.</p>
<h3>8. Termination</h3>
<p>Either party may terminate services in accordance with the signed agreement and required notice periods.</p>
<h3>9. Governing Terms</h3>
<p>These terms are supplemented by the applicable client agreement and project documentation.</p>
<h3>10. Contact</h3>
<p>Questions regarding these terms can be sent to <a href="mailto:info@mspe.pro">info@mspe.pro</a>.</p>
</div>
</div>
</section>
<footer class="footer">
<div class="footer-main">
<div class="container">
<div class="footer-grid">
<div class="footer-brand">
<a href="index.html" class="footer-logo">
<img src="images/logo/logo.png" alt="MSPE PRO" class="logo-image" width="200" height="50">
</a>
<p class="footer-description">We engineer possibilities. Transforming complexity into competitive advantage through innovative technology solutions.</p>
<div class="footer-social">
<a href="https://www.facebook.com/mspe.pro" class="social-link" title="Facebook" target="_blank" rel="noopener noreferrer"><i class="fab fa-facebook-f"></i></a>
<a href="https://www.linkedin.com/company/mspe-pro" class="social-link" title="LinkedIn" target="_blank" rel="noopener noreferrer"><i class="fab fa-linkedin-in"></i></a>
<a href="https://x.com/mspe_pro" class="social-link" title="X (Twitter)" target="_blank" rel="noopener noreferrer"><i class="fab fa-twitter"></i></a>
<a href="https://www.instagram.com/mspe.pro" class="social-link" title="Instagram" target="_blank" rel="noopener noreferrer"><i class="fab fa-instagram"></i></a>
</div>
</div>
<div class="footer-links">
<h4 class="footer-title">Quick Links</h4>
<ul>
<li><a href="about.html">About Us</a></li>
<li><a href="services.html">Our Services</a></li>
<li><a href="portfolio.html">Portfolio</a></li>
<li><a href="news.html">News & Events</a></li>
<li><a href="contact.html">Contact Us</a></li>
</ul>
</div>
<div class="footer-links">
<h4 class="footer-title">Services</h4>
<ul>
<li><a href="services.html#it-support">IT Support & Maintenance</a></li>
<li><a href="services.html#cybersecurity">Cybersecurity Solutions</a></li>
<li><a href="services.html#cloud">Cloud Services</a></li>
<li><a href="services.html#consulting">Business Consulting</a></li>
</ul>
</div>
<div class="footer-contact">
<h4 class="footer-title">Contact Info</h4>
<ul class="contact-list">
<li><i class="fas fa-envelope"></i><span data-site-email>info@mspe.pro</span></li>
<li><i class="fas fa-phone"></i><a href="tel:+96178782023" data-site-phone style="color:inherit;">+961 78 782 023</a></li>
<li><i class="fas fa-clock"></i><span>Available for consultations</span></li>
<li><i class="fas fa-map-marker-alt"></i><span>Beirut, Lebanon</span></li>
</ul>
</div>
</div>
</div>
</div>
<div class="footer-bottom">
<div class="container">
<p>&copy; 2026 MSPE - Architects of Digital Resilience. All Rights Reserved.</p>
<div class="footer-bottom-links">
<a href="privacy.html">Privacy Policy</a>
<a href="terms.html">Terms of Service</a>
</div>
</div>
</div>
</footer>
<script src="js/main.js" defer></script>
</body>
</html>
+397
View File
@@ -0,0 +1,397 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ultimate UI Showcase | MSPE</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&family=Playfair+Display:wght@600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha384-t1nt8BQoYMLFN5p42tRAtuAAFQaCQODekUVeKKZrEnEyp4H2R0RHFz0KWpmj7i8g" crossorigin="anonymous">
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/ultimate-ui.css">
<link rel="stylesheet" href="css/header-fix.css">
<style>
.showcase-section {
padding: 4rem 0;
border-bottom: 1px solid var(--glass-border);
}
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
margin-top: 2rem;
}
.feature-demo {
background: var(--glass-bg);
backdrop-filter: blur(10px);
border: 1px solid var(--glass-border);
border-radius: 1rem;
padding: 2rem;
text-align: center;
transition: all 0.4s ease;
}
.feature-demo:hover {
transform: translateY(-10px);
border-color: var(--neon-cyan);
box-shadow: 0 0 30px rgba(0, 245, 255, 0.3);
}
.feature-demo h3 {
color: var(--neon-cyan);
margin-bottom: 1rem;
font-size: 1.5rem;
}
.feature-demo p {
color: rgba(255, 255, 255, 0.7);
line-height: 1.6;
}
.feature-icon {
font-size: 3rem;
color: var(--neon-purple);
margin-bottom: 1rem;
animation: float 3s ease-in-out infinite;
}
.color-palette {
display: flex;
gap: 1rem;
flex-wrap: wrap;
margin-top: 2rem;
}
.color-swatch {
width: 120px;
height: 120px;
border-radius: 1rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: white;
font-size: 0.8rem;
font-weight: 600;
text-shadow: 0 2px 4px rgba(0,0,0,0.5);
transition: transform 0.3s ease;
}
.color-swatch:hover {
transform: scale(1.1) rotate(5deg);
}
.demo-button-group {
display: flex;
gap: 1rem;
flex-wrap: wrap;
justify-content: center;
margin-top: 2rem;
}
.glow-text {
font-size: 3rem;
font-weight: 700;
text-align: center;
background: linear-gradient(135deg, var(--neon-cyan), var(--neon-purple), var(--neon-pink));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
animation: neonPulse 2s ease-in-out infinite;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 2rem;
margin-top: 2rem;
}
.stat-box {
background: var(--glass-bg);
backdrop-filter: blur(10px);
border: 1px solid var(--glass-border);
border-radius: 1rem;
padding: 2rem;
text-align: center;
}
.stat-value {
font-size: 3rem;
font-weight: 700;
color: var(--neon-cyan);
text-shadow: 0 0 20px var(--neon-cyan);
}
.stat-label {
color: rgba(255, 255, 255, 0.7);
margin-top: 0.5rem;
}
</style>
</head>
<body>
<!-- Navigation -->
<header class="header" id="header">
<nav class="nav container">
<a href="index.html" class="nav-logo">
<img src="images/logo/logo.png" alt="MSPE PRO" class="logo-image" width="200" height="50">
</a>
<div class="nav-actions">
<a href="index.html" class="btn btn-outline">Back to Home</a>
</div>
</nav>
</header>
<!-- Hero Section -->
<section class="hero" style="padding: 8rem 0 4rem; min-height: 60vh; display: flex; align-items: center;">
<div class="container">
<h1 class="glow-text" style="margin-bottom: 1rem;">Ultimate UI Design System</h1>
<p style="text-align: center; color: rgba(255, 255, 255, 0.8); font-size: 1.25rem; max-width: 800px; margin: 0 auto;">
Experience the cutting-edge design featuring glassmorphism, neon accents, custom cursors, particle effects, and advanced interactions.
</p>
</div>
</section>
<!-- Features Section -->
<section class="showcase-section">
<div class="container">
<h2 style="text-align: center; color: var(--neon-cyan); font-size: 2.5rem; margin-bottom: 1rem;">
<i class="fas fa-sparkles"></i> Key Features
</h2>
<p style="text-align: center; color: rgba(255, 255, 255, 0.7); margin-bottom: 3rem;">
Hover over the cards to see the magic happen
</p>
<div class="feature-grid">
<div class="feature-demo">
<div class="feature-icon"><i class="fas fa-mouse-pointer"></i></div>
<h3>Custom Cursor</h3>
<p>Unique neon cursor with trailing particles that follows your mouse. Transforms on hover over interactive elements.</p>
</div>
<div class="feature-demo">
<div class="feature-icon"><i class="fas fa-layer-group"></i></div>
<h3>Glassmorphism</h3>
<p>Frosted glass effects with backdrop blur, semi-transparent backgrounds, and elegant borders.</p>
</div>
<div class="feature-demo">
<div class="feature-icon"><i class="fas fa-stars"></i></div>
<h3>Particle System</h3>
<p>50 animated particles floating across the screen with varied colors and animation timings.</p>
</div>
<div class="feature-demo">
<div class="feature-icon"><i class="fas fa-chart-line"></i></div>
<h3>Scroll Progress</h3>
<p>Dynamic gradient progress bar at the top showing your scroll position with neon glow.</p>
</div>
<div class="feature-demo">
<div class="feature-icon"><i class="fas fa-magnet"></i></div>
<h3>Magnetic Hover</h3>
<p>Interactive elements attract toward your cursor creating a dynamic, responsive feel.</p>
</div>
<div class="feature-demo">
<div class="feature-icon"><i class="fas fa-cube"></i></div>
<h3>3D Card Tilt</h3>
<p>Cards tilt in 3D space based on cursor position for an immersive experience.</p>
</div>
</div>
</div>
</section>
<!-- Color Palette Section -->
<section class="showcase-section">
<div class="container">
<h2 style="text-align: center; color: var(--neon-purple); font-size: 2.5rem; margin-bottom: 1rem;">
<i class="fas fa-palette"></i> Neon Color Palette
</h2>
<p style="text-align: center; color: rgba(255, 255, 255, 0.7); margin-bottom: 2rem;">
Hover over the colors to see them grow
</p>
<div class="color-palette" style="justify-content: center;">
<div class="color-swatch" style="background: #00f5ff; box-shadow: 0 0 40px rgba(0, 245, 255, 0.5);">
<span>Neon Cyan</span>
<span style="font-size: 0.7rem; margin-top: 0.5rem;">#00f5ff</span>
</div>
<div class="color-swatch" style="background: #b537f2; box-shadow: 0 0 40px rgba(181, 55, 242, 0.5);">
<span>Neon Purple</span>
<span style="font-size: 0.7rem; margin-top: 0.5rem;">#b537f2</span>
</div>
<div class="color-swatch" style="background: #ff006e; box-shadow: 0 0 40px rgba(255, 0, 110, 0.5);">
<span>Neon Pink</span>
<span style="font-size: 0.7rem; margin-top: 0.5rem;">#ff006e</span>
</div>
<div class="color-swatch" style="background: #0080ff; box-shadow: 0 0 40px rgba(0, 128, 255, 0.5);">
<span>Neon Blue</span>
<span style="font-size: 0.7rem; margin-top: 0.5rem;">#0080ff</span>
</div>
<div class="color-swatch" style="background: #00ff88; box-shadow: 0 0 40px rgba(0, 255, 136, 0.5);">
<span>Neon Green</span>
<span style="font-size: 0.7rem; margin-top: 0.5rem;">#00ff88</span>
</div>
</div>
</div>
</section>
<!-- Buttons Section -->
<section class="showcase-section">
<div class="container">
<h2 style="text-align: center; color: var(--neon-pink); font-size: 2.5rem; margin-bottom: 1rem;">
<i class="fas fa-hand-pointer"></i> Interactive Buttons
</h2>
<p style="text-align: center; color: rgba(255, 255, 255, 0.7); margin-bottom: 2rem;">
Click to see the ripple effect, hover for magnetic attraction
</p>
<div class="demo-button-group">
<button class="btn btn-primary btn-lg">
<i class="fas fa-rocket"></i> Primary Button
</button>
<button class="btn btn-outline btn-lg">
<i class="fas fa-star"></i> Outline Button
</button>
<button class="btn btn-primary">
<i class="fas fa-heart"></i> Medium Button
</button>
<button class="btn btn-outline">
<i class="fas fa-bolt"></i> Regular Size
</button>
</div>
</div>
</section>
<!-- Stats Section -->
<section class="showcase-section">
<div class="container">
<h2 style="text-align: center; color: var(--neon-blue); font-size: 2.5rem; margin-bottom: 3rem;">
<i class="fas fa-chart-bar"></i> UI Statistics
</h2>
<div class="stats-grid">
<div class="stat-box">
<div class="stat-value">50+</div>
<div class="stat-label">Floating Particles</div>
</div>
<div class="stat-box">
<div class="stat-value">20</div>
<div class="stat-label">Cursor Trails</div>
</div>
<div class="stat-box">
<div class="stat-value">5</div>
<div class="stat-label">Neon Colors</div>
</div>
<div class="stat-box">
<div class="stat-value"></div>
<div class="stat-label">Possibilities</div>
</div>
</div>
</div>
</section>
<!-- Effects Showcase -->
<section class="showcase-section">
<div class="container">
<h2 style="text-align: center; color: var(--neon-green); font-size: 2.5rem; margin-bottom: 1rem;">
<i class="fas fa-wand-magic-sparkles"></i> Visual Effects
</h2>
<p style="text-align: center; color: rgba(255, 255, 255, 0.7); margin-bottom: 3rem;">
Multiple layers of animation and interaction
</p>
<div class="feature-grid">
<div class="card" style="padding: 2rem;">
<i class="fas fa-eye" style="font-size: 2rem; color: var(--neon-cyan); margin-bottom: 1rem;"></i>
<h3 style="color: white; margin-bottom: 0.5rem;">Hover Effects</h3>
<p style="color: rgba(255, 255, 255, 0.7);">Scale, lift, glow, and tilt transformations</p>
</div>
<div class="card" style="padding: 2rem;">
<i class="fas fa-scroll" style="font-size: 2rem; color: var(--neon-purple); margin-bottom: 1rem;"></i>
<h3 style="color: white; margin-bottom: 0.5rem;">Scroll Reveals</h3>
<p style="color: rgba(255, 255, 255, 0.7);">Elements fade in as you scroll down</p>
</div>
<div class="card" style="padding: 2rem;">
<i class="fas fa-droplet" style="font-size: 2rem; color: var(--neon-pink); margin-bottom: 1rem;"></i>
<h3 style="color: white; margin-bottom: 0.5rem;">Ripple Effect</h3>
<p style="color: rgba(255, 255, 255, 0.7);">Click anywhere to create ripples</p>
</div>
</div>
</div>
</section>
<!-- Call to Action -->
<section class="showcase-section" style="border-bottom: none; padding: 6rem 0;">
<div class="container" style="text-align: center;">
<h2 class="glow-text" style="font-size: 2.5rem; margin-bottom: 1rem;">
Ready to Experience More?
</h2>
<p style="color: rgba(255, 255, 255, 0.8); font-size: 1.125rem; margin-bottom: 2rem; max-width: 600px; margin-left: auto; margin-right: auto;">
This ultimate UI design system is applied across all pages of the MSPE website for a consistent, stunning experience.
</p>
<div style="display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap;">
<a href="index.html" class="btn btn-primary btn-lg">
<i class="fas fa-home"></i> Go to Homepage
</a>
<a href="about.html" class="btn btn-outline btn-lg">
<i class="fas fa-info-circle"></i> Learn More
</a>
<a href="contact.html" class="btn btn-outline btn-lg">
<i class="fas fa-envelope"></i> Get in Touch
</a>
</div>
</div>
</section>
<!-- Footer -->
<footer style="background: var(--glass-bg); backdrop-filter: blur(20px); border-top: 1px solid var(--glass-border); padding: 2rem 0; text-align: center;">
<div class="container">
<p style="color: rgba(255, 255, 255, 0.7);">
<i class="fas fa-heart" style="color: var(--neon-pink);"></i>
Crafted with passion for MSPE - Architects of Digital Resilience
</p>
<p style="color: rgba(255, 255, 255, 0.5); margin-top: 0.5rem; font-size: 0.9rem;">
Ultimate UI Design System v1.0.0 | January 2026
</p>
</div>
</footer>
<!-- Back to Top -->
<button class="back-to-top" id="back-to-top" style="display: flex;">
<i class="fas fa-chevron-up"></i>
</button>
<!-- Scripts -->
<script src="js/main.js" defer></script>
<script src="js/ultimate-ui.js" defer></script>
<script>
// Animate stat values
document.addEventListener('DOMContentLoaded', function() {
const statValues = document.querySelectorAll('.stat-value');
const animateValue = (el, start, end, duration) => {
if (el.textContent === '∞') return;
let startTimestamp = null;
const step = (timestamp) => {
if (!startTimestamp) startTimestamp = timestamp;
const progress = Math.min((timestamp - startTimestamp) / duration, 1);
const value = Math.floor(progress * (end - start) + start);
el.textContent = value + (el.textContent.includes('+') ? '+' : '');
if (progress < 1) {
window.requestAnimationFrame(step);
}
};
window.requestAnimationFrame(step);
};
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const value = parseInt(entry.target.textContent);
if (!isNaN(value)) {
animateValue(entry.target, 0, value, 2000);
observer.unobserve(entry.target);
}
}
});
});
statValues.forEach(el => observer.observe(el));
});
</script>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
# Prevent script execution in uploads directory
<FilesMatch "\.(php|phtml|php3|php4|php5|php7|phps|cgi|pl|py|sh|bash)$">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
</FilesMatch>
# Disable directory listing
Options -Indexes
+22
View File
@@ -0,0 +1,22 @@
<?php
// autoload.php @generated by Composer
if (PHP_VERSION_ID < 50600) {
if (!headers_sent()) {
header('HTTP/1.1 500 Internal Server Error');
}
$err = 'Composer 2.3.0 dropped support for autoloading on PHP <5.6 and you are running '.PHP_VERSION.', please upgrade PHP or use Composer 2.2 LTS via "composer self-update --2.2". Aborting.'.PHP_EOL;
if (!ini_get('display_errors')) {
if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
fwrite(STDERR, $err);
} elseif (!headers_sent()) {
echo $err;
}
}
throw new RuntimeException($err);
}
require_once __DIR__ . '/composer/autoload_real.php';
return ComposerAutoloaderInit20fad51902f91e7fd3039e016a6556b5::getLoader();
+579
View File
@@ -0,0 +1,579 @@
<?php
/*
* This file is part of Composer.
*
* (c) Nils Adermann <naderman@naderman.de>
* Jordi Boggiano <j.boggiano@seld.be>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Composer\Autoload;
/**
* ClassLoader implements a PSR-0, PSR-4 and classmap class loader.
*
* $loader = new \Composer\Autoload\ClassLoader();
*
* // register classes with namespaces
* $loader->add('Symfony\Component', __DIR__.'/component');
* $loader->add('Symfony', __DIR__.'/framework');
*
* // activate the autoloader
* $loader->register();
*
* // to enable searching the include path (eg. for PEAR packages)
* $loader->setUseIncludePath(true);
*
* In this example, if you try to use a class in the Symfony\Component
* namespace or one of its children (Symfony\Component\Console for instance),
* the autoloader will first look for the class under the component/
* directory, and it will then fallback to the framework/ directory if not
* found before giving up.
*
* This class is loosely based on the Symfony UniversalClassLoader.
*
* @author Fabien Potencier <fabien@symfony.com>
* @author Jordi Boggiano <j.boggiano@seld.be>
* @see https://www.php-fig.org/psr/psr-0/
* @see https://www.php-fig.org/psr/psr-4/
*/
class ClassLoader
{
/** @var \Closure(string):void */
private static $includeFile;
/** @var string|null */
private $vendorDir;
// PSR-4
/**
* @var array<string, array<string, int>>
*/
private $prefixLengthsPsr4 = array();
/**
* @var array<string, list<string>>
*/
private $prefixDirsPsr4 = array();
/**
* @var list<string>
*/
private $fallbackDirsPsr4 = array();
// PSR-0
/**
* List of PSR-0 prefixes
*
* Structured as array('F (first letter)' => array('Foo\Bar (full prefix)' => array('path', 'path2')))
*
* @var array<string, array<string, list<string>>>
*/
private $prefixesPsr0 = array();
/**
* @var list<string>
*/
private $fallbackDirsPsr0 = array();
/** @var bool */
private $useIncludePath = false;
/**
* @var array<string, string>
*/
private $classMap = array();
/** @var bool */
private $classMapAuthoritative = false;
/**
* @var array<string, bool>
*/
private $missingClasses = array();
/** @var string|null */
private $apcuPrefix;
/**
* @var array<string, self>
*/
private static $registeredLoaders = array();
/**
* @param string|null $vendorDir
*/
public function __construct($vendorDir = null)
{
$this->vendorDir = $vendorDir;
self::initializeIncludeClosure();
}
/**
* @return array<string, list<string>>
*/
public function getPrefixes()
{
if (!empty($this->prefixesPsr0)) {
return call_user_func_array('array_merge', array_values($this->prefixesPsr0));
}
return array();
}
/**
* @return array<string, list<string>>
*/
public function getPrefixesPsr4()
{
return $this->prefixDirsPsr4;
}
/**
* @return list<string>
*/
public function getFallbackDirs()
{
return $this->fallbackDirsPsr0;
}
/**
* @return list<string>
*/
public function getFallbackDirsPsr4()
{
return $this->fallbackDirsPsr4;
}
/**
* @return array<string, string> Array of classname => path
*/
public function getClassMap()
{
return $this->classMap;
}
/**
* @param array<string, string> $classMap Class to filename map
*
* @return void
*/
public function addClassMap(array $classMap)
{
if ($this->classMap) {
$this->classMap = array_merge($this->classMap, $classMap);
} else {
$this->classMap = $classMap;
}
}
/**
* Registers a set of PSR-0 directories for a given prefix, either
* appending or prepending to the ones previously set for this prefix.
*
* @param string $prefix The prefix
* @param list<string>|string $paths The PSR-0 root directories
* @param bool $prepend Whether to prepend the directories
*
* @return void
*/
public function add($prefix, $paths, $prepend = false)
{
$paths = (array) $paths;
if (!$prefix) {
if ($prepend) {
$this->fallbackDirsPsr0 = array_merge(
$paths,
$this->fallbackDirsPsr0
);
} else {
$this->fallbackDirsPsr0 = array_merge(
$this->fallbackDirsPsr0,
$paths
);
}
return;
}
$first = $prefix[0];
if (!isset($this->prefixesPsr0[$first][$prefix])) {
$this->prefixesPsr0[$first][$prefix] = $paths;
return;
}
if ($prepend) {
$this->prefixesPsr0[$first][$prefix] = array_merge(
$paths,
$this->prefixesPsr0[$first][$prefix]
);
} else {
$this->prefixesPsr0[$first][$prefix] = array_merge(
$this->prefixesPsr0[$first][$prefix],
$paths
);
}
}
/**
* Registers a set of PSR-4 directories for a given namespace, either
* appending or prepending to the ones previously set for this namespace.
*
* @param string $prefix The prefix/namespace, with trailing '\\'
* @param list<string>|string $paths The PSR-4 base directories
* @param bool $prepend Whether to prepend the directories
*
* @throws \InvalidArgumentException
*
* @return void
*/
public function addPsr4($prefix, $paths, $prepend = false)
{
$paths = (array) $paths;
if (!$prefix) {
// Register directories for the root namespace.
if ($prepend) {
$this->fallbackDirsPsr4 = array_merge(
$paths,
$this->fallbackDirsPsr4
);
} else {
$this->fallbackDirsPsr4 = array_merge(
$this->fallbackDirsPsr4,
$paths
);
}
} elseif (!isset($this->prefixDirsPsr4[$prefix])) {
// Register directories for a new namespace.
$length = strlen($prefix);
if ('\\' !== $prefix[$length - 1]) {
throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator.");
}
$this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length;
$this->prefixDirsPsr4[$prefix] = $paths;
} elseif ($prepend) {
// Prepend directories for an already registered namespace.
$this->prefixDirsPsr4[$prefix] = array_merge(
$paths,
$this->prefixDirsPsr4[$prefix]
);
} else {
// Append directories for an already registered namespace.
$this->prefixDirsPsr4[$prefix] = array_merge(
$this->prefixDirsPsr4[$prefix],
$paths
);
}
}
/**
* Registers a set of PSR-0 directories for a given prefix,
* replacing any others previously set for this prefix.
*
* @param string $prefix The prefix
* @param list<string>|string $paths The PSR-0 base directories
*
* @return void
*/
public function set($prefix, $paths)
{
if (!$prefix) {
$this->fallbackDirsPsr0 = (array) $paths;
} else {
$this->prefixesPsr0[$prefix[0]][$prefix] = (array) $paths;
}
}
/**
* Registers a set of PSR-4 directories for a given namespace,
* replacing any others previously set for this namespace.
*
* @param string $prefix The prefix/namespace, with trailing '\\'
* @param list<string>|string $paths The PSR-4 base directories
*
* @throws \InvalidArgumentException
*
* @return void
*/
public function setPsr4($prefix, $paths)
{
if (!$prefix) {
$this->fallbackDirsPsr4 = (array) $paths;
} else {
$length = strlen($prefix);
if ('\\' !== $prefix[$length - 1]) {
throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator.");
}
$this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length;
$this->prefixDirsPsr4[$prefix] = (array) $paths;
}
}
/**
* Turns on searching the include path for class files.
*
* @param bool $useIncludePath
*
* @return void
*/
public function setUseIncludePath($useIncludePath)
{
$this->useIncludePath = $useIncludePath;
}
/**
* Can be used to check if the autoloader uses the include path to check
* for classes.
*
* @return bool
*/
public function getUseIncludePath()
{
return $this->useIncludePath;
}
/**
* Turns off searching the prefix and fallback directories for classes
* that have not been registered with the class map.
*
* @param bool $classMapAuthoritative
*
* @return void
*/
public function setClassMapAuthoritative($classMapAuthoritative)
{
$this->classMapAuthoritative = $classMapAuthoritative;
}
/**
* Should class lookup fail if not found in the current class map?
*
* @return bool
*/
public function isClassMapAuthoritative()
{
return $this->classMapAuthoritative;
}
/**
* APCu prefix to use to cache found/not-found classes, if the extension is enabled.
*
* @param string|null $apcuPrefix
*
* @return void
*/
public function setApcuPrefix($apcuPrefix)
{
$this->apcuPrefix = function_exists('apcu_fetch') && filter_var(ini_get('apc.enabled'), FILTER_VALIDATE_BOOLEAN) ? $apcuPrefix : null;
}
/**
* The APCu prefix in use, or null if APCu caching is not enabled.
*
* @return string|null
*/
public function getApcuPrefix()
{
return $this->apcuPrefix;
}
/**
* Registers this instance as an autoloader.
*
* @param bool $prepend Whether to prepend the autoloader or not
*
* @return void
*/
public function register($prepend = false)
{
spl_autoload_register(array($this, 'loadClass'), true, $prepend);
if (null === $this->vendorDir) {
return;
}
if ($prepend) {
self::$registeredLoaders = array($this->vendorDir => $this) + self::$registeredLoaders;
} else {
unset(self::$registeredLoaders[$this->vendorDir]);
self::$registeredLoaders[$this->vendorDir] = $this;
}
}
/**
* Unregisters this instance as an autoloader.
*
* @return void
*/
public function unregister()
{
spl_autoload_unregister(array($this, 'loadClass'));
if (null !== $this->vendorDir) {
unset(self::$registeredLoaders[$this->vendorDir]);
}
}
/**
* Loads the given class or interface.
*
* @param string $class The name of the class
* @return true|null True if loaded, null otherwise
*/
public function loadClass($class)
{
if ($file = $this->findFile($class)) {
$includeFile = self::$includeFile;
$includeFile($file);
return true;
}
return null;
}
/**
* Finds the path to the file where the class is defined.
*
* @param string $class The name of the class
*
* @return string|false The path if found, false otherwise
*/
public function findFile($class)
{
// class map lookup
if (isset($this->classMap[$class])) {
return $this->classMap[$class];
}
if ($this->classMapAuthoritative || isset($this->missingClasses[$class])) {
return false;
}
if (null !== $this->apcuPrefix) {
$file = apcu_fetch($this->apcuPrefix.$class, $hit);
if ($hit) {
return $file;
}
}
$file = $this->findFileWithExtension($class, '.php');
// Search for Hack files if we are running on HHVM
if (false === $file && defined('HHVM_VERSION')) {
$file = $this->findFileWithExtension($class, '.hh');
}
if (null !== $this->apcuPrefix) {
apcu_add($this->apcuPrefix.$class, $file);
}
if (false === $file) {
// Remember that this class does not exist.
$this->missingClasses[$class] = true;
}
return $file;
}
/**
* Returns the currently registered loaders keyed by their corresponding vendor directories.
*
* @return array<string, self>
*/
public static function getRegisteredLoaders()
{
return self::$registeredLoaders;
}
/**
* @param string $class
* @param string $ext
* @return string|false
*/
private function findFileWithExtension($class, $ext)
{
// PSR-4 lookup
$logicalPathPsr4 = strtr($class, '\\', DIRECTORY_SEPARATOR) . $ext;
$first = $class[0];
if (isset($this->prefixLengthsPsr4[$first])) {
$subPath = $class;
while (false !== $lastPos = strrpos($subPath, '\\')) {
$subPath = substr($subPath, 0, $lastPos);
$search = $subPath . '\\';
if (isset($this->prefixDirsPsr4[$search])) {
$pathEnd = DIRECTORY_SEPARATOR . substr($logicalPathPsr4, $lastPos + 1);
foreach ($this->prefixDirsPsr4[$search] as $dir) {
if (file_exists($file = $dir . $pathEnd)) {
return $file;
}
}
}
}
}
// PSR-4 fallback dirs
foreach ($this->fallbackDirsPsr4 as $dir) {
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr4)) {
return $file;
}
}
// PSR-0 lookup
if (false !== $pos = strrpos($class, '\\')) {
// namespaced class name
$logicalPathPsr0 = substr($logicalPathPsr4, 0, $pos + 1)
. strtr(substr($logicalPathPsr4, $pos + 1), '_', DIRECTORY_SEPARATOR);
} else {
// PEAR-like class name
$logicalPathPsr0 = strtr($class, '_', DIRECTORY_SEPARATOR) . $ext;
}
if (isset($this->prefixesPsr0[$first])) {
foreach ($this->prefixesPsr0[$first] as $prefix => $dirs) {
if (0 === strpos($class, $prefix)) {
foreach ($dirs as $dir) {
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) {
return $file;
}
}
}
}
}
// PSR-0 fallback dirs
foreach ($this->fallbackDirsPsr0 as $dir) {
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) {
return $file;
}
}
// PSR-0 include paths.
if ($this->useIncludePath && $file = stream_resolve_include_path($logicalPathPsr0)) {
return $file;
}
return false;
}
/**
* @return void
*/
private static function initializeIncludeClosure()
{
if (self::$includeFile !== null) {
return;
}
/**
* Scope isolated include.
*
* Prevents access to $this/self from included files.
*
* @param string $file
* @return void
*/
self::$includeFile = \Closure::bind(static function($file) {
include $file;
}, null, null);
}
}
+396
View File
@@ -0,0 +1,396 @@
<?php
/*
* This file is part of Composer.
*
* (c) Nils Adermann <naderman@naderman.de>
* Jordi Boggiano <j.boggiano@seld.be>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Composer;
use Composer\Autoload\ClassLoader;
use Composer\Semver\VersionParser;
/**
* This class is copied in every Composer installed project and available to all
*
* See also https://getcomposer.org/doc/07-runtime.md#installed-versions
*
* To require its presence, you can require `composer-runtime-api ^2.0`
*
* @final
*/
class InstalledVersions
{
/**
* @var string|null if set (by reflection by Composer), this should be set to the path where this class is being copied to
* @internal
*/
private static $selfDir = null;
/**
* @var mixed[]|null
* @psalm-var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}|array{}|null
*/
private static $installed;
/**
* @var bool
*/
private static $installedIsLocalDir;
/**
* @var bool|null
*/
private static $canGetVendors;
/**
* @var array[]
* @psalm-var array<string, array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
*/
private static $installedByVendor = array();
/**
* Returns a list of all package names which are present, either by being installed, replaced or provided
*
* @return string[]
* @psalm-return list<string>
*/
public static function getInstalledPackages()
{
$packages = array();
foreach (self::getInstalled() as $installed) {
$packages[] = array_keys($installed['versions']);
}
if (1 === \count($packages)) {
return $packages[0];
}
return array_keys(array_flip(\call_user_func_array('array_merge', $packages)));
}
/**
* Returns a list of all package names with a specific type e.g. 'library'
*
* @param string $type
* @return string[]
* @psalm-return list<string>
*/
public static function getInstalledPackagesByType($type)
{
$packagesByType = array();
foreach (self::getInstalled() as $installed) {
foreach ($installed['versions'] as $name => $package) {
if (isset($package['type']) && $package['type'] === $type) {
$packagesByType[] = $name;
}
}
}
return $packagesByType;
}
/**
* Checks whether the given package is installed
*
* This also returns true if the package name is provided or replaced by another package
*
* @param string $packageName
* @param bool $includeDevRequirements
* @return bool
*/
public static function isInstalled($packageName, $includeDevRequirements = true)
{
foreach (self::getInstalled() as $installed) {
if (isset($installed['versions'][$packageName])) {
return $includeDevRequirements || !isset($installed['versions'][$packageName]['dev_requirement']) || $installed['versions'][$packageName]['dev_requirement'] === false;
}
}
return false;
}
/**
* Checks whether the given package satisfies a version constraint
*
* e.g. If you want to know whether version 2.3+ of package foo/bar is installed, you would call:
*
* Composer\InstalledVersions::satisfies(new VersionParser, 'foo/bar', '^2.3')
*
* @param VersionParser $parser Install composer/semver to have access to this class and functionality
* @param string $packageName
* @param string|null $constraint A version constraint to check for, if you pass one you have to make sure composer/semver is required by your package
* @return bool
*/
public static function satisfies(VersionParser $parser, $packageName, $constraint)
{
$constraint = $parser->parseConstraints((string) $constraint);
$provided = $parser->parseConstraints(self::getVersionRanges($packageName));
return $provided->matches($constraint);
}
/**
* Returns a version constraint representing all the range(s) which are installed for a given package
*
* It is easier to use this via isInstalled() with the $constraint argument if you need to check
* whether a given version of a package is installed, and not just whether it exists
*
* @param string $packageName
* @return string Version constraint usable with composer/semver
*/
public static function getVersionRanges($packageName)
{
foreach (self::getInstalled() as $installed) {
if (!isset($installed['versions'][$packageName])) {
continue;
}
$ranges = array();
if (isset($installed['versions'][$packageName]['pretty_version'])) {
$ranges[] = $installed['versions'][$packageName]['pretty_version'];
}
if (array_key_exists('aliases', $installed['versions'][$packageName])) {
$ranges = array_merge($ranges, $installed['versions'][$packageName]['aliases']);
}
if (array_key_exists('replaced', $installed['versions'][$packageName])) {
$ranges = array_merge($ranges, $installed['versions'][$packageName]['replaced']);
}
if (array_key_exists('provided', $installed['versions'][$packageName])) {
$ranges = array_merge($ranges, $installed['versions'][$packageName]['provided']);
}
return implode(' || ', $ranges);
}
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
}
/**
* @param string $packageName
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present
*/
public static function getVersion($packageName)
{
foreach (self::getInstalled() as $installed) {
if (!isset($installed['versions'][$packageName])) {
continue;
}
if (!isset($installed['versions'][$packageName]['version'])) {
return null;
}
return $installed['versions'][$packageName]['version'];
}
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
}
/**
* @param string $packageName
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present
*/
public static function getPrettyVersion($packageName)
{
foreach (self::getInstalled() as $installed) {
if (!isset($installed['versions'][$packageName])) {
continue;
}
if (!isset($installed['versions'][$packageName]['pretty_version'])) {
return null;
}
return $installed['versions'][$packageName]['pretty_version'];
}
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
}
/**
* @param string $packageName
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as reference
*/
public static function getReference($packageName)
{
foreach (self::getInstalled() as $installed) {
if (!isset($installed['versions'][$packageName])) {
continue;
}
if (!isset($installed['versions'][$packageName]['reference'])) {
return null;
}
return $installed['versions'][$packageName]['reference'];
}
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
}
/**
* @param string $packageName
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as install path. Packages of type metapackages also have a null install path.
*/
public static function getInstallPath($packageName)
{
foreach (self::getInstalled() as $installed) {
if (!isset($installed['versions'][$packageName])) {
continue;
}
return isset($installed['versions'][$packageName]['install_path']) ? $installed['versions'][$packageName]['install_path'] : null;
}
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
}
/**
* @return array
* @psalm-return array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}
*/
public static function getRootPackage()
{
$installed = self::getInstalled();
return $installed[0]['root'];
}
/**
* Returns the raw installed.php data for custom implementations
*
* @deprecated Use getAllRawData() instead which returns all datasets for all autoloaders present in the process. getRawData only returns the first dataset loaded, which may not be what you expect.
* @return array[]
* @psalm-return array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}
*/
public static function getRawData()
{
@trigger_error('getRawData only returns the first dataset loaded, which may not be what you expect. Use getAllRawData() instead which returns all datasets for all autoloaders present in the process.', E_USER_DEPRECATED);
if (null === self::$installed) {
// only require the installed.php file if this file is loaded from its dumped location,
// and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
if (substr(__DIR__, -8, 1) !== 'C') {
self::$installed = include __DIR__ . '/installed.php';
} else {
self::$installed = array();
}
}
return self::$installed;
}
/**
* Returns the raw data of all installed.php which are currently loaded for custom implementations
*
* @return array[]
* @psalm-return list<array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
*/
public static function getAllRawData()
{
return self::getInstalled();
}
/**
* Lets you reload the static array from another file
*
* This is only useful for complex integrations in which a project needs to use
* this class but then also needs to execute another project's autoloader in process,
* and wants to ensure both projects have access to their version of installed.php.
*
* A typical case would be PHPUnit, where it would need to make sure it reads all
* the data it needs from this class, then call reload() with
* `require $CWD/vendor/composer/installed.php` (or similar) as input to make sure
* the project in which it runs can then also use this class safely, without
* interference between PHPUnit's dependencies and the project's dependencies.
*
* @param array[] $data A vendor/composer/installed.php data set
* @return void
*
* @psalm-param array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $data
*/
public static function reload($data)
{
self::$installed = $data;
self::$installedByVendor = array();
// when using reload, we disable the duplicate protection to ensure that self::$installed data is
// always returned, but we cannot know whether it comes from the installed.php in __DIR__ or not,
// so we have to assume it does not, and that may result in duplicate data being returned when listing
// all installed packages for example
self::$installedIsLocalDir = false;
}
/**
* @return string
*/
private static function getSelfDir()
{
if (self::$selfDir === null) {
self::$selfDir = strtr(__DIR__, '\\', '/');
}
return self::$selfDir;
}
/**
* @return array[]
* @psalm-return list<array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
*/
private static function getInstalled()
{
if (null === self::$canGetVendors) {
self::$canGetVendors = method_exists('Composer\Autoload\ClassLoader', 'getRegisteredLoaders');
}
$installed = array();
$copiedLocalDir = false;
if (self::$canGetVendors) {
$selfDir = self::getSelfDir();
foreach (ClassLoader::getRegisteredLoaders() as $vendorDir => $loader) {
$vendorDir = strtr($vendorDir, '\\', '/');
if (isset(self::$installedByVendor[$vendorDir])) {
$installed[] = self::$installedByVendor[$vendorDir];
} elseif (is_file($vendorDir.'/composer/installed.php')) {
/** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
$required = require $vendorDir.'/composer/installed.php';
self::$installedByVendor[$vendorDir] = $required;
$installed[] = $required;
if (self::$installed === null && $vendorDir.'/composer' === $selfDir) {
self::$installed = $required;
self::$installedIsLocalDir = true;
}
}
if (self::$installedIsLocalDir && $vendorDir.'/composer' === $selfDir) {
$copiedLocalDir = true;
}
}
}
if (null === self::$installed) {
// only require the installed.php file if this file is loaded from its dumped location,
// and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
if (substr(__DIR__, -8, 1) !== 'C') {
/** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
$required = require __DIR__ . '/installed.php';
self::$installed = $required;
} else {
self::$installed = array();
}
}
if (self::$installed !== array() && !$copiedLocalDir) {
$installed[] = self::$installed;
}
return $installed;
}
}
+21
View File
@@ -0,0 +1,21 @@
Copyright (c) Nils Adermann, Jordi Boggiano
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+10
View File
@@ -0,0 +1,10 @@
<?php
// autoload_classmap.php @generated by Composer
$vendorDir = dirname(__DIR__);
$baseDir = dirname($vendorDir);
return array(
'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
);
+9
View File
@@ -0,0 +1,9 @@
<?php
// autoload_namespaces.php @generated by Composer
$vendorDir = dirname(__DIR__);
$baseDir = dirname($vendorDir);
return array(
);
+10
View File
@@ -0,0 +1,10 @@
<?php
// autoload_psr4.php @generated by Composer
$vendorDir = dirname(__DIR__);
$baseDir = dirname($vendorDir);
return array(
'Firebase\\JWT\\' => array($vendorDir . '/firebase/php-jwt/src'),
);
+38
View File
@@ -0,0 +1,38 @@
<?php
// autoload_real.php @generated by Composer
class ComposerAutoloaderInit20fad51902f91e7fd3039e016a6556b5
{
private static $loader;
public static function loadClassLoader($class)
{
if ('Composer\Autoload\ClassLoader' === $class) {
require __DIR__ . '/ClassLoader.php';
}
}
/**
* @return \Composer\Autoload\ClassLoader
*/
public static function getLoader()
{
if (null !== self::$loader) {
return self::$loader;
}
require __DIR__ . '/platform_check.php';
spl_autoload_register(array('ComposerAutoloaderInit20fad51902f91e7fd3039e016a6556b5', 'loadClassLoader'), true, true);
self::$loader = $loader = new \Composer\Autoload\ClassLoader(\dirname(__DIR__));
spl_autoload_unregister(array('ComposerAutoloaderInit20fad51902f91e7fd3039e016a6556b5', 'loadClassLoader'));
require __DIR__ . '/autoload_static.php';
call_user_func(\Composer\Autoload\ComposerStaticInit20fad51902f91e7fd3039e016a6556b5::getInitializer($loader));
$loader->register(true);
return $loader;
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
// autoload_static.php @generated by Composer
namespace Composer\Autoload;
class ComposerStaticInit20fad51902f91e7fd3039e016a6556b5
{
public static $prefixLengthsPsr4 = array (
'F' =>
array (
'Firebase\\JWT\\' => 13,
),
);
public static $prefixDirsPsr4 = array (
'Firebase\\JWT\\' =>
array (
0 => __DIR__ . '/..' . '/firebase/php-jwt/src',
),
);
public static $classMap = array (
'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
);
public static function getInitializer(ClassLoader $loader)
{
return \Closure::bind(function () use ($loader) {
$loader->prefixLengthsPsr4 = ComposerStaticInit20fad51902f91e7fd3039e016a6556b5::$prefixLengthsPsr4;
$loader->prefixDirsPsr4 = ComposerStaticInit20fad51902f91e7fd3039e016a6556b5::$prefixDirsPsr4;
$loader->classMap = ComposerStaticInit20fad51902f91e7fd3039e016a6556b5::$classMap;
}, null, ClassLoader::class);
}
}
+72
View File
@@ -0,0 +1,72 @@
{
"packages": [
{
"name": "firebase/php-jwt",
"version": "v7.0.2",
"version_normalized": "7.0.2.0",
"source": {
"type": "git",
"url": "https://github.com/firebase/php-jwt.git",
"reference": "5645b43af647b6947daac1d0f659dd1fbe8d3b65"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/firebase/php-jwt/zipball/5645b43af647b6947daac1d0f659dd1fbe8d3b65",
"reference": "5645b43af647b6947daac1d0f659dd1fbe8d3b65",
"shasum": ""
},
"require": {
"php": "^8.0"
},
"require-dev": {
"guzzlehttp/guzzle": "^7.4",
"phpspec/prophecy-phpunit": "^2.0",
"phpunit/phpunit": "^9.5",
"psr/cache": "^2.0||^3.0",
"psr/http-client": "^1.0",
"psr/http-factory": "^1.0"
},
"suggest": {
"ext-sodium": "Support EdDSA (Ed25519) signatures",
"paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present"
},
"time": "2025-12-16T22:17:28+00:00",
"type": "library",
"installation-source": "dist",
"autoload": {
"psr-4": {
"Firebase\\JWT\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Neuman Vong",
"email": "neuman+pear@twilio.com",
"role": "Developer"
},
{
"name": "Anant Narayanan",
"email": "anant@php.net",
"role": "Developer"
}
],
"description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.",
"homepage": "https://github.com/firebase/php-jwt",
"keywords": [
"jwt",
"php"
],
"support": {
"issues": "https://github.com/firebase/php-jwt/issues",
"source": "https://github.com/firebase/php-jwt/tree/v7.0.2"
},
"install-path": "../firebase/php-jwt"
}
],
"dev": true,
"dev-package-names": []
}
+32
View File
@@ -0,0 +1,32 @@
<?php return array(
'root' => array(
'name' => '__root__',
'pretty_version' => '1.0.0+no-version-set',
'version' => '1.0.0.0',
'reference' => null,
'type' => 'library',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),
'dev' => true,
),
'versions' => array(
'__root__' => array(
'pretty_version' => '1.0.0+no-version-set',
'version' => '1.0.0.0',
'reference' => null,
'type' => 'library',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),
'dev_requirement' => false,
),
'firebase/php-jwt' => array(
'pretty_version' => 'v7.0.2',
'version' => '7.0.2.0',
'reference' => '5645b43af647b6947daac1d0f659dd1fbe8d3b65',
'type' => 'library',
'install_path' => __DIR__ . '/../firebase/php-jwt',
'aliases' => array(),
'dev_requirement' => false,
),
),
);
+25
View File
@@ -0,0 +1,25 @@
<?php
// platform_check.php @generated by Composer
$issues = array();
if (!(PHP_VERSION_ID >= 80000)) {
$issues[] = 'Your Composer dependencies require a PHP version ">= 8.0.0". You are running ' . PHP_VERSION . '.';
}
if ($issues) {
if (!headers_sent()) {
header('HTTP/1.1 500 Internal Server Error');
}
if (!ini_get('display_errors')) {
if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
fwrite(STDERR, 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . implode(PHP_EOL, $issues) . PHP_EOL.PHP_EOL);
} elseif (!headers_sent()) {
echo 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . str_replace('You are running '.PHP_VERSION.'.', '', implode(PHP_EOL, $issues)) . PHP_EOL.PHP_EOL;
}
}
throw new \RuntimeException(
'Composer detected issues in your platform: ' . implode(' ', $issues)
);
}
+229
View File
@@ -0,0 +1,229 @@
# Changelog
## [7.0.2](https://github.com/firebase/php-jwt/compare/v7.0.1...v7.0.2) (2025-12-16)
### Bug Fixes
* add key length validation for ec keys ([#615](https://github.com/firebase/php-jwt/issues/615)) ([7044f9a](https://github.com/firebase/php-jwt/commit/7044f9ae7e7d175d28cca71714feb236f1c0e252))
## [7.0.0](https://github.com/firebase/php-jwt/compare/v6.11.1...v7.0.0) (2025-12-15)
### ⚠️ ⚠️ ⚠️ Security Fixes ⚠️ ⚠️ ⚠️
* add key size validation ([#613](https://github.com/firebase/php-jwt/issues/613)) ([6b80341](https://github.com/firebase/php-jwt/commit/6b80341bf57838ea2d011487917337901cd71576))
**NOTE**: This fix will cause keys with a size below the minimally allowed size to break.
### Features
* add SensitiveParameter attribute to security-critical parameters ([#603](https://github.com/firebase/php-jwt/issues/603)) ([4dbfac0](https://github.com/firebase/php-jwt/commit/4dbfac0260eeb0e9e643063c99998e3219cc539b))
* store timestamp in `ExpiredException` ([#604](https://github.com/firebase/php-jwt/issues/604)) ([f174826](https://github.com/firebase/php-jwt/commit/f1748260d218a856b6a0c23715ac7fae1d7ca95b))
### Bug Fixes
* validate iat and nbf on payload ([#568](https://github.com/firebase/php-jwt/issues/568)) ([953b2c8](https://github.com/firebase/php-jwt/commit/953b2c88bb445b7e3bb82a5141928f13d7343afd))
## [6.11.1](https://github.com/firebase/php-jwt/compare/v6.11.0...v6.11.1) (2025-04-09)
### Bug Fixes
* update error text for consistency ([#528](https://github.com/firebase/php-jwt/issues/528)) ([c11113a](https://github.com/firebase/php-jwt/commit/c11113afa13265e016a669e75494b9203b8a7775))
## [6.11.0](https://github.com/firebase/php-jwt/compare/v6.10.2...v6.11.0) (2025-01-23)
### Features
* support octet typed JWK ([#587](https://github.com/firebase/php-jwt/issues/587)) ([7cb8a26](https://github.com/firebase/php-jwt/commit/7cb8a265fa81edf2fa6ef8098f5bc5ae573c33ad))
### Bug Fixes
* refactor constructor Key to use PHP 8.0 syntax ([#577](https://github.com/firebase/php-jwt/issues/577)) ([29fa2ce](https://github.com/firebase/php-jwt/commit/29fa2ce9e0582cd397711eec1e80c05ce20fabca))
## [6.10.2](https://github.com/firebase/php-jwt/compare/v6.10.1...v6.10.2) (2024-11-24)
### Bug Fixes
* Mitigate PHP8.4 deprecation warnings ([#570](https://github.com/firebase/php-jwt/issues/570)) ([76808fa](https://github.com/firebase/php-jwt/commit/76808fa227f3811aa5cdb3bf81233714b799a5b5))
* support php 8.4 ([#583](https://github.com/firebase/php-jwt/issues/583)) ([e3d68b0](https://github.com/firebase/php-jwt/commit/e3d68b044421339443c74199edd020e03fb1887e))
## [6.10.1](https://github.com/firebase/php-jwt/compare/v6.10.0...v6.10.1) (2024-05-18)
### Bug Fixes
* ensure ratelimit expiry is set every time ([#556](https://github.com/firebase/php-jwt/issues/556)) ([09cb208](https://github.com/firebase/php-jwt/commit/09cb2081c2c3bc0f61e2f2a5fbea5741f7498648))
* ratelimit cache expiration ([#550](https://github.com/firebase/php-jwt/issues/550)) ([dda7250](https://github.com/firebase/php-jwt/commit/dda725033585ece30ff8cae8937320d7e9f18bae))
## [6.10.0](https://github.com/firebase/php-jwt/compare/v6.9.0...v6.10.0) (2023-11-28)
### Features
* allow typ header override ([#546](https://github.com/firebase/php-jwt/issues/546)) ([79cb30b](https://github.com/firebase/php-jwt/commit/79cb30b729a22931b2fbd6b53f20629a83031ba9))
## [6.9.0](https://github.com/firebase/php-jwt/compare/v6.8.1...v6.9.0) (2023-10-04)
### Features
* add payload to jwt exception ([#521](https://github.com/firebase/php-jwt/issues/521)) ([175edf9](https://github.com/firebase/php-jwt/commit/175edf958bb61922ec135b2333acf5622f2238a2))
## [6.8.1](https://github.com/firebase/php-jwt/compare/v6.8.0...v6.8.1) (2023-07-14)
### Bug Fixes
* accept float claims but round down to ignore them ([#492](https://github.com/firebase/php-jwt/issues/492)) ([3936842](https://github.com/firebase/php-jwt/commit/39368423beeaacb3002afa7dcb75baebf204fe7e))
* different BeforeValidException messages for nbf and iat ([#526](https://github.com/firebase/php-jwt/issues/526)) ([0a53cf2](https://github.com/firebase/php-jwt/commit/0a53cf2986e45c2bcbf1a269f313ebf56a154ee4))
## [6.8.0](https://github.com/firebase/php-jwt/compare/v6.7.0...v6.8.0) (2023-06-14)
### Features
* add support for P-384 curve ([#515](https://github.com/firebase/php-jwt/issues/515)) ([5de4323](https://github.com/firebase/php-jwt/commit/5de4323f4baf4d70bca8663bd87682a69c656c3d))
### Bug Fixes
* handle invalid http responses ([#508](https://github.com/firebase/php-jwt/issues/508)) ([91c39c7](https://github.com/firebase/php-jwt/commit/91c39c72b22fc3e1191e574089552c1f2041c718))
## [6.7.0](https://github.com/firebase/php-jwt/compare/v6.6.0...v6.7.0) (2023-06-14)
### Features
* add ed25519 support to JWK (public keys) ([#452](https://github.com/firebase/php-jwt/issues/452)) ([e53979a](https://github.com/firebase/php-jwt/commit/e53979abae927de916a75b9d239cfda8ce32be2a))
## [6.6.0](https://github.com/firebase/php-jwt/compare/v6.5.0...v6.6.0) (2023-06-13)
### Features
* allow get headers when decoding token ([#442](https://github.com/firebase/php-jwt/issues/442)) ([fb85f47](https://github.com/firebase/php-jwt/commit/fb85f47cfaeffdd94faf8defdf07164abcdad6c3))
### Bug Fixes
* only check iat if nbf is not used ([#493](https://github.com/firebase/php-jwt/issues/493)) ([398ccd2](https://github.com/firebase/php-jwt/commit/398ccd25ea12fa84b9e4f1085d5ff448c21ec797))
## [6.5.0](https://github.com/firebase/php-jwt/compare/v6.4.0...v6.5.0) (2023-05-12)
### Bug Fixes
* allow KID of '0' ([#505](https://github.com/firebase/php-jwt/issues/505)) ([9dc46a9](https://github.com/firebase/php-jwt/commit/9dc46a9c3e5801294249cfd2554c5363c9f9326a))
### Miscellaneous Chores
* drop support for PHP 7.3 ([#495](https://github.com/firebase/php-jwt/issues/495))
## [6.4.0](https://github.com/firebase/php-jwt/compare/v6.3.2...v6.4.0) (2023-02-08)
### Features
* add support for W3C ES256K ([#462](https://github.com/firebase/php-jwt/issues/462)) ([213924f](https://github.com/firebase/php-jwt/commit/213924f51936291fbbca99158b11bd4ae56c2c95))
* improve caching by only decoding jwks when necessary ([#486](https://github.com/firebase/php-jwt/issues/486)) ([78d3ed1](https://github.com/firebase/php-jwt/commit/78d3ed1073553f7d0bbffa6c2010009a0d483d5c))
## [6.3.2](https://github.com/firebase/php-jwt/compare/v6.3.1...v6.3.2) (2022-11-01)
### Bug Fixes
* check kid before using as array index ([bad1b04](https://github.com/firebase/php-jwt/commit/bad1b040d0c736bbf86814c6b5ae614f517cf7bd))
## [6.3.1](https://github.com/firebase/php-jwt/compare/v6.3.0...v6.3.1) (2022-11-01)
### Bug Fixes
* casing of GET for PSR compat ([#451](https://github.com/firebase/php-jwt/issues/451)) ([60b52b7](https://github.com/firebase/php-jwt/commit/60b52b71978790eafcf3b95cfbd83db0439e8d22))
* string interpolation format for php 8.2 ([#446](https://github.com/firebase/php-jwt/issues/446)) ([2e07d8a](https://github.com/firebase/php-jwt/commit/2e07d8a1524d12b69b110ad649f17461d068b8f2))
## 6.3.0 / 2022-07-15
- Added ES256 support to JWK parsing ([#399](https://github.com/firebase/php-jwt/pull/399))
- Fixed potential caching error in `CachedKeySet` by caching jwks as strings ([#435](https://github.com/firebase/php-jwt/pull/435))
## 6.2.0 / 2022-05-14
- Added `CachedKeySet` ([#397](https://github.com/firebase/php-jwt/pull/397))
- Added `$defaultAlg` parameter to `JWT::parseKey` and `JWT::parseKeySet` ([#426](https://github.com/firebase/php-jwt/pull/426)).
## 6.1.0 / 2022-03-23
- Drop support for PHP 5.3, 5.4, 5.5, 5.6, and 7.0
- Add parameter typing and return types where possible
## 6.0.0 / 2022-01-24
- **Backwards-Compatibility Breaking Changes**: See the [Release Notes](https://github.com/firebase/php-jwt/releases/tag/v6.0.0) for more information.
- New Key object to prevent key/algorithm type confusion (#365)
- Add JWK support (#273)
- Add ES256 support (#256)
- Add ES384 support (#324)
- Add Ed25519 support (#343)
## 5.0.0 / 2017-06-26
- Support RS384 and RS512.
See [#117](https://github.com/firebase/php-jwt/pull/117). Thanks [@joostfaassen](https://github.com/joostfaassen)!
- Add an example for RS256 openssl.
See [#125](https://github.com/firebase/php-jwt/pull/125). Thanks [@akeeman](https://github.com/akeeman)!
- Detect invalid Base64 encoding in signature.
See [#162](https://github.com/firebase/php-jwt/pull/162). Thanks [@psignoret](https://github.com/psignoret)!
- Update `JWT::verify` to handle OpenSSL errors.
See [#159](https://github.com/firebase/php-jwt/pull/159). Thanks [@bshaffer](https://github.com/bshaffer)!
- Add `array` type hinting to `decode` method
See [#101](https://github.com/firebase/php-jwt/pull/101). Thanks [@hywak](https://github.com/hywak)!
- Add all JSON error types.
See [#110](https://github.com/firebase/php-jwt/pull/110). Thanks [@gbalduzzi](https://github.com/gbalduzzi)!
- Bugfix 'kid' not in given key list.
See [#129](https://github.com/firebase/php-jwt/pull/129). Thanks [@stampycode](https://github.com/stampycode)!
- Miscellaneous cleanup, documentation and test fixes.
See [#107](https://github.com/firebase/php-jwt/pull/107), [#115](https://github.com/firebase/php-jwt/pull/115),
[#160](https://github.com/firebase/php-jwt/pull/160), [#161](https://github.com/firebase/php-jwt/pull/161), and
[#165](https://github.com/firebase/php-jwt/pull/165). Thanks [@akeeman](https://github.com/akeeman),
[@chinedufn](https://github.com/chinedufn), and [@bshaffer](https://github.com/bshaffer)!
## 4.0.0 / 2016-07-17
- Add support for late static binding. See [#88](https://github.com/firebase/php-jwt/pull/88) for details. Thanks to [@chappy84](https://github.com/chappy84)!
- Use static `$timestamp` instead of `time()` to improve unit testing. See [#93](https://github.com/firebase/php-jwt/pull/93) for details. Thanks to [@josephmcdermott](https://github.com/josephmcdermott)!
- Fixes to exceptions classes. See [#81](https://github.com/firebase/php-jwt/pull/81) for details. Thanks to [@Maks3w](https://github.com/Maks3w)!
- Fixes to PHPDoc. See [#76](https://github.com/firebase/php-jwt/pull/76) for details. Thanks to [@akeeman](https://github.com/akeeman)!
## 3.0.0 / 2015-07-22
- Minimum PHP version updated from `5.2.0` to `5.3.0`.
- Add `\Firebase\JWT` namespace. See
[#59](https://github.com/firebase/php-jwt/pull/59) for details. Thanks to
[@Dashron](https://github.com/Dashron)!
- Require a non-empty key to decode and verify a JWT. See
[#60](https://github.com/firebase/php-jwt/pull/60) for details. Thanks to
[@sjones608](https://github.com/sjones608)!
- Cleaner documentation blocks in the code. See
[#62](https://github.com/firebase/php-jwt/pull/62) for details. Thanks to
[@johanderuijter](https://github.com/johanderuijter)!
## 2.2.0 / 2015-06-22
- Add support for adding custom, optional JWT headers to `JWT::encode()`. See
[#53](https://github.com/firebase/php-jwt/pull/53/files) for details. Thanks to
[@mcocaro](https://github.com/mcocaro)!
## 2.1.0 / 2015-05-20
- Add support for adding a leeway to `JWT:decode()` that accounts for clock skew
between signing and verifying entities. Thanks to [@lcabral](https://github.com/lcabral)!
- Add support for passing an object implementing the `ArrayAccess` interface for
`$keys` argument in `JWT::decode()`. Thanks to [@aztech-dev](https://github.com/aztech-dev)!
## 2.0.0 / 2015-04-01
- **Note**: It is strongly recommended that you update to > v2.0.0 to address
known security vulnerabilities in prior versions when both symmetric and
asymmetric keys are used together.
- Update signature for `JWT::decode(...)` to require an array of supported
algorithms to use when verifying token signatures.
+30
View File
@@ -0,0 +1,30 @@
Copyright (c) 2011, Neuman Vong
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following
disclaimer in the documentation and/or other materials provided
with the distribution.
* Neither the name of the copyright holder nor the names of other
contributors may be used to endorse or promote products derived
from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+425
View File
@@ -0,0 +1,425 @@
![Build Status](https://github.com/firebase/php-jwt/actions/workflows/tests.yml/badge.svg)
[![Latest Stable Version](https://poser.pugx.org/firebase/php-jwt/v/stable)](https://packagist.org/packages/firebase/php-jwt)
[![Total Downloads](https://poser.pugx.org/firebase/php-jwt/downloads)](https://packagist.org/packages/firebase/php-jwt)
[![License](https://poser.pugx.org/firebase/php-jwt/license)](https://packagist.org/packages/firebase/php-jwt)
PHP-JWT
=======
A simple library to encode and decode JSON Web Tokens (JWT) in PHP, conforming to [RFC 7519](https://tools.ietf.org/html/rfc7519).
Installation
------------
Use composer to manage your dependencies and download PHP-JWT:
```bash
composer require firebase/php-jwt
```
Optionally, install the `paragonie/sodium_compat` package from composer if your
php env does not have libsodium installed:
```bash
composer require paragonie/sodium_compat
```
Example
-------
```php
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
$key = 'example_key';
$payload = [
'iss' => 'http://example.org',
'aud' => 'http://example.com',
'iat' => 1356999524,
'nbf' => 1357000000
];
/**
* IMPORTANT:
* You must specify supported algorithms for your application. See
* https://tools.ietf.org/html/draft-ietf-jose-json-web-algorithms-40
* for a list of spec-compliant algorithms.
*/
$jwt = JWT::encode($payload, $key, 'HS256');
$decoded = JWT::decode($jwt, new Key($key, 'HS256'));
print_r($decoded);
// Pass a stdClass in as the third parameter to get the decoded header values
$headers = new stdClass();
$decoded = JWT::decode($jwt, new Key($key, 'HS256'), $headers);
print_r($headers);
/*
NOTE: This will now be an object instead of an associative array. To get
an associative array, you will need to cast it as such:
*/
$decoded_array = (array) $decoded;
/**
* You can add a leeway to account for when there is a clock skew times between
* the signing and verifying servers. It is recommended that this leeway should
* not be bigger than a few minutes.
*
* Source: http://self-issued.info/docs/draft-ietf-oauth-json-web-token.html#nbfDef
*/
JWT::$leeway = 60; // $leeway in seconds
$decoded = JWT::decode($jwt, new Key($key, 'HS256'));
```
Example encode/decode headers
-------
Decoding the JWT headers without verifying the JWT first is NOT recommended, and is not supported by
this library. This is because without verifying the JWT, the header values could have been tampered with.
Any value pulled from an unverified header should be treated as if it could be any string sent in from an
attacker. If this is something you still want to do in your application for whatever reason, it's possible to
decode the header values manually simply by calling `json_decode` and `base64_decode` on the JWT
header part:
```php
use Firebase\JWT\JWT;
$key = 'example_key';
$payload = [
'iss' => 'http://example.org',
'aud' => 'http://example.com',
'iat' => 1356999524,
'nbf' => 1357000000
];
$headers = [
'x-forwarded-for' => 'www.google.com'
];
// Encode headers in the JWT string
$jwt = JWT::encode($payload, $key, 'HS256', null, $headers);
// Decode headers from the JWT string WITHOUT validation
// **IMPORTANT**: This operation is vulnerable to attacks, as the JWT has not yet been verified.
// These headers could be any value sent by an attacker.
list($headersB64, $payloadB64, $sig) = explode('.', $jwt);
$decoded = json_decode(base64_decode($headersB64), true);
print_r($decoded);
```
Example with RS256 (openssl)
----------------------------
```php
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
$privateKey = <<<EOD
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAuzWHNM5f+amCjQztc5QTfJfzCC5J4nuW+L/aOxZ4f8J3Frew
M2c/dufrnmedsApb0By7WhaHlcqCh/ScAPyJhzkPYLae7bTVro3hok0zDITR8F6S
JGL42JAEUk+ILkPI+DONM0+3vzk6Kvfe548tu4czCuqU8BGVOlnp6IqBHhAswNMM
78pos/2z0CjPM4tbeXqSTTbNkXRboxjU29vSopcT51koWOgiTf3C7nJUoMWZHZI5
HqnIhPAG9yv8HAgNk6CMk2CadVHDo4IxjxTzTTqo1SCSH2pooJl9O8at6kkRYsrZ
WwsKlOFE2LUce7ObnXsYihStBUDoeBQlGG/BwQIDAQABAoIBAFtGaOqNKGwggn9k
6yzr6GhZ6Wt2rh1Xpq8XUz514UBhPxD7dFRLpbzCrLVpzY80LbmVGJ9+1pJozyWc
VKeCeUdNwbqkr240Oe7GTFmGjDoxU+5/HX/SJYPpC8JZ9oqgEA87iz+WQX9hVoP2
oF6EB4ckDvXmk8FMwVZW2l2/kd5mrEVbDaXKxhvUDf52iVD+sGIlTif7mBgR99/b
c3qiCnxCMmfYUnT2eh7Vv2LhCR/G9S6C3R4lA71rEyiU3KgsGfg0d82/XWXbegJW
h3QbWNtQLxTuIvLq5aAryV3PfaHlPgdgK0ft6ocU2de2FagFka3nfVEyC7IUsNTK
bq6nhAECgYEA7d/0DPOIaItl/8BWKyCuAHMss47j0wlGbBSHdJIiS55akMvnAG0M
39y22Qqfzh1at9kBFeYeFIIU82ZLF3xOcE3z6pJZ4Dyvx4BYdXH77odo9uVK9s1l
3T3BlMcqd1hvZLMS7dviyH79jZo4CXSHiKzc7pQ2YfK5eKxKqONeXuECgYEAyXlG
vonaus/YTb1IBei9HwaccnQ/1HRn6MvfDjb7JJDIBhNClGPt6xRlzBbSZ73c2QEC
6Fu9h36K/HZ2qcLd2bXiNyhIV7b6tVKk+0Psoj0dL9EbhsD1OsmE1nTPyAc9XZbb
OPYxy+dpBCUA8/1U9+uiFoCa7mIbWcSQ+39gHuECgYAz82pQfct30aH4JiBrkNqP
nJfRq05UY70uk5k1u0ikLTRoVS/hJu/d4E1Kv4hBMqYCavFSwAwnvHUo51lVCr/y
xQOVYlsgnwBg2MX4+GjmIkqpSVCC8D7j/73MaWb746OIYZervQ8dbKahi2HbpsiG
8AHcVSA/agxZr38qvWV54QKBgCD5TlDE8x18AuTGQ9FjxAAd7uD0kbXNz2vUYg9L
hFL5tyL3aAAtUrUUw4xhd9IuysRhW/53dU+FsG2dXdJu6CxHjlyEpUJl2iZu/j15
YnMzGWHIEX8+eWRDsw/+Ujtko/B7TinGcWPz3cYl4EAOiCeDUyXnqnO1btCEUU44
DJ1BAoGBAJuPD27ErTSVtId90+M4zFPNibFP50KprVdc8CR37BE7r8vuGgNYXmnI
RLnGP9p3pVgFCktORuYS2J/6t84I3+A17nEoB4xvhTLeAinAW/uTQOUmNicOP4Ek
2MsLL2kHgL8bLTmvXV4FX+PXphrDKg1XxzOYn0otuoqdAQrkK4og
-----END RSA PRIVATE KEY-----
EOD;
$publicKey = <<<EOD
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuzWHNM5f+amCjQztc5QT
fJfzCC5J4nuW+L/aOxZ4f8J3FrewM2c/dufrnmedsApb0By7WhaHlcqCh/ScAPyJ
hzkPYLae7bTVro3hok0zDITR8F6SJGL42JAEUk+ILkPI+DONM0+3vzk6Kvfe548t
u4czCuqU8BGVOlnp6IqBHhAswNMM78pos/2z0CjPM4tbeXqSTTbNkXRboxjU29vS
opcT51koWOgiTf3C7nJUoMWZHZI5HqnIhPAG9yv8HAgNk6CMk2CadVHDo4IxjxTz
TTqo1SCSH2pooJl9O8at6kkRYsrZWwsKlOFE2LUce7ObnXsYihStBUDoeBQlGG/B
wQIDAQAB
-----END PUBLIC KEY-----
EOD;
$payload = [
'iss' => 'example.org',
'aud' => 'example.com',
'iat' => 1356999524,
'nbf' => 1357000000
];
$jwt = JWT::encode($payload, $privateKey, 'RS256');
echo "Encode:\n" . print_r($jwt, true) . "\n";
$decoded = JWT::decode($jwt, new Key($publicKey, 'RS256'));
/*
NOTE: This will now be an object instead of an associative array. To get
an associative array, you will need to cast it as such:
*/
$decoded_array = (array) $decoded;
echo "Decode:\n" . print_r($decoded_array, true) . "\n";
```
Example with a passphrase
-------------------------
```php
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
// Your passphrase
$passphrase = '[YOUR_PASSPHRASE]';
// Your private key file with passphrase
// Can be generated with "ssh-keygen -t rsa -m pem"
$privateKeyFile = '/path/to/key-with-passphrase.pem';
/** @var OpenSSLAsymmetricKey $privateKey */
$privateKey = openssl_pkey_get_private(
file_get_contents($privateKeyFile),
$passphrase
);
$payload = [
'iss' => 'example.org',
'aud' => 'example.com',
'iat' => 1356999524,
'nbf' => 1357000000
];
$jwt = JWT::encode($payload, $privateKey, 'RS256');
echo "Encode:\n" . print_r($jwt, true) . "\n";
// Get public key from the private key, or pull from from a file.
$publicKey = openssl_pkey_get_details($privateKey)['key'];
$decoded = JWT::decode($jwt, new Key($publicKey, 'RS256'));
echo "Decode:\n" . print_r((array) $decoded, true) . "\n";
```
Example with EdDSA (libsodium and Ed25519 signature)
----------------------------
```php
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
// Public and private keys are expected to be Base64 encoded. The last
// non-empty line is used so that keys can be generated with
// sodium_crypto_sign_keypair(). The secret keys generated by other tools may
// need to be adjusted to match the input expected by libsodium.
$keyPair = sodium_crypto_sign_keypair();
$privateKey = base64_encode(sodium_crypto_sign_secretkey($keyPair));
$publicKey = base64_encode(sodium_crypto_sign_publickey($keyPair));
$payload = [
'iss' => 'example.org',
'aud' => 'example.com',
'iat' => 1356999524,
'nbf' => 1357000000
];
$jwt = JWT::encode($payload, $privateKey, 'EdDSA');
echo "Encode:\n" . print_r($jwt, true) . "\n";
$decoded = JWT::decode($jwt, new Key($publicKey, 'EdDSA'));
echo "Decode:\n" . print_r((array) $decoded, true) . "\n";
````
Example with multiple keys
--------------------------
```php
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
// Example RSA keys from previous example
// $privateKey1 = '...';
// $publicKey1 = '...';
// Example EdDSA keys from previous example
// $privateKey2 = '...';
// $publicKey2 = '...';
$payload = [
'iss' => 'example.org',
'aud' => 'example.com',
'iat' => 1356999524,
'nbf' => 1357000000
];
$jwt1 = JWT::encode($payload, $privateKey1, 'RS256', 'kid1');
$jwt2 = JWT::encode($payload, $privateKey2, 'EdDSA', 'kid2');
echo "Encode 1:\n" . print_r($jwt1, true) . "\n";
echo "Encode 2:\n" . print_r($jwt2, true) . "\n";
$keys = [
'kid1' => new Key($publicKey1, 'RS256'),
'kid2' => new Key($publicKey2, 'EdDSA'),
];
$decoded1 = JWT::decode($jwt1, $keys);
$decoded2 = JWT::decode($jwt2, $keys);
echo "Decode 1:\n" . print_r((array) $decoded1, true) . "\n";
echo "Decode 2:\n" . print_r((array) $decoded2, true) . "\n";
```
Using JWKs
----------
```php
use Firebase\JWT\JWK;
use Firebase\JWT\JWT;
// Set of keys. The "keys" key is required. For example, the JSON response to
// this endpoint: https://www.gstatic.com/iap/verify/public_key-jwk
$jwks = ['keys' => []];
// JWK::parseKeySet($jwks) returns an associative array of **kid** to Firebase\JWT\Key
// objects. Pass this as the second parameter to JWT::decode.
JWT::decode($jwt, JWK::parseKeySet($jwks));
```
Using Cached Key Sets
---------------------
The `CachedKeySet` class can be used to fetch and cache JWKS (JSON Web Key Sets) from a public URI.
This has the following advantages:
1. The results are cached for performance.
2. If an unrecognized key is requested, the cache is refreshed, to accomodate for key rotation.
3. If rate limiting is enabled, the JWKS URI will not make more than 10 requests a second.
```php
use Firebase\JWT\CachedKeySet;
use Firebase\JWT\JWT;
// The URI for the JWKS you wish to cache the results from
$jwksUri = 'https://www.gstatic.com/iap/verify/public_key-jwk';
// Create an HTTP client (can be any PSR-7 compatible HTTP client)
$httpClient = new GuzzleHttp\Client();
// Create an HTTP request factory (can be any PSR-17 compatible HTTP request factory)
$httpFactory = new GuzzleHttp\Psr\HttpFactory();
// Create a cache item pool (can be any PSR-6 compatible cache item pool)
$cacheItemPool = Phpfastcache\CacheManager::getInstance('files');
$keySet = new CachedKeySet(
$jwksUri,
$httpClient,
$httpFactory,
$cacheItemPool,
null, // $expiresAfter int seconds to set the JWKS to expire
true // $rateLimit true to enable rate limit of 10 RPS on lookup of invalid keys
);
$jwt = 'eyJhbGci...'; // Some JWT signed by a key from the $jwkUri above
$decoded = JWT::decode($jwt, $keySet);
```
Miscellaneous
-------------
#### Exception Handling
When a call to `JWT::decode` is invalid, it will throw one of the following exceptions:
```php
use Firebase\JWT\JWT;
use Firebase\JWT\SignatureInvalidException;
use Firebase\JWT\BeforeValidException;
use Firebase\JWT\ExpiredException;
use DomainException;
use InvalidArgumentException;
use UnexpectedValueException;
try {
$decoded = JWT::decode($jwt, $keys);
} catch (InvalidArgumentException $e) {
// provided key/key-array is empty or malformed.
} catch (DomainException $e) {
// provided algorithm is unsupported OR
// provided key is invalid OR
// unknown error thrown in openSSL or libsodium OR
// libsodium is required but not available.
} catch (SignatureInvalidException $e) {
// provided JWT signature verification failed.
} catch (BeforeValidException $e) {
// provided JWT is trying to be used before "nbf" claim OR
// provided JWT is trying to be used before "iat" claim.
} catch (ExpiredException $e) {
// provided JWT is trying to be used after "exp" claim.
} catch (UnexpectedValueException $e) {
// provided JWT is malformed OR
// provided JWT is missing an algorithm / using an unsupported algorithm OR
// provided JWT algorithm does not match provided key OR
// provided key ID in key/key-array is empty or invalid.
}
```
All exceptions in the `Firebase\JWT` namespace extend `UnexpectedValueException`, and can be simplified
like this:
```php
use Firebase\JWT\JWT;
use UnexpectedValueException;
try {
$decoded = JWT::decode($jwt, $keys);
} catch (LogicException $e) {
// errors having to do with environmental setup or malformed JWT Keys
} catch (UnexpectedValueException $e) {
// errors having to do with JWT signature and claims
}
```
#### Casting to array
The return value of `JWT::decode` is the generic PHP object `stdClass`. If you'd like to handle with arrays
instead, you can do the following:
```php
// return type is stdClass
$decoded = JWT::decode($jwt, $keys);
// cast to array
$decoded = json_decode(json_encode($decoded), true);
```
Tests
-----
Run the tests using phpunit:
```bash
$ pear install PHPUnit
$ phpunit --configuration phpunit.xml.dist
PHPUnit 3.7.10 by Sebastian Bergmann.
.....
Time: 0 seconds, Memory: 2.50Mb
OK (5 tests, 5 assertions)
```
New Lines in private keys
-----
If your private key contains `\n` characters, be sure to wrap it in double quotes `""`
and not single quotes `''` in order to properly interpret the escaped characters.
License
-------
[3-Clause BSD](http://opensource.org/licenses/BSD-3-Clause).
+42
View File
@@ -0,0 +1,42 @@
{
"name": "firebase/php-jwt",
"description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.",
"homepage": "https://github.com/firebase/php-jwt",
"keywords": [
"php",
"jwt"
],
"authors": [
{
"name": "Neuman Vong",
"email": "neuman+pear@twilio.com",
"role": "Developer"
},
{
"name": "Anant Narayanan",
"email": "anant@php.net",
"role": "Developer"
}
],
"license": "BSD-3-Clause",
"require": {
"php": "^8.0"
},
"suggest": {
"paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present",
"ext-sodium": "Support EdDSA (Ed25519) signatures"
},
"autoload": {
"psr-4": {
"Firebase\\JWT\\": "src"
}
},
"require-dev": {
"guzzlehttp/guzzle": "^7.4",
"phpspec/prophecy-phpunit": "^2.0",
"phpunit/phpunit": "^9.5",
"psr/cache": "^2.0||^3.0",
"psr/http-client": "^1.0",
"psr/http-factory": "^1.0"
}
}
@@ -0,0 +1,18 @@
<?php
namespace Firebase\JWT;
class BeforeValidException extends \UnexpectedValueException implements JWTExceptionWithPayloadInterface
{
private object $payload;
public function setPayload(object $payload): void
{
$this->payload = $payload;
}
public function getPayload(): object
{
return $this->payload;
}
}
+274
View File
@@ -0,0 +1,274 @@
<?php
namespace Firebase\JWT;
use ArrayAccess;
use InvalidArgumentException;
use LogicException;
use OutOfBoundsException;
use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use RuntimeException;
use UnexpectedValueException;
/**
* @implements ArrayAccess<string, Key>
*/
class CachedKeySet implements ArrayAccess
{
/**
* @var string
*/
private $jwksUri;
/**
* @var ClientInterface
*/
private $httpClient;
/**
* @var RequestFactoryInterface
*/
private $httpFactory;
/**
* @var CacheItemPoolInterface
*/
private $cache;
/**
* @var ?int
*/
private $expiresAfter;
/**
* @var ?CacheItemInterface
*/
private $cacheItem;
/**
* @var array<string, array<mixed>>
*/
private $keySet;
/**
* @var string
*/
private $cacheKey;
/**
* @var string
*/
private $cacheKeyPrefix = 'jwks';
/**
* @var int
*/
private $maxKeyLength = 64;
/**
* @var bool
*/
private $rateLimit;
/**
* @var string
*/
private $rateLimitCacheKey;
/**
* @var int
*/
private $maxCallsPerMinute = 10;
/**
* @var string|null
*/
private $defaultAlg;
public function __construct(
string $jwksUri,
ClientInterface $httpClient,
RequestFactoryInterface $httpFactory,
CacheItemPoolInterface $cache,
?int $expiresAfter = null,
bool $rateLimit = false,
?string $defaultAlg = null
) {
$this->jwksUri = $jwksUri;
$this->httpClient = $httpClient;
$this->httpFactory = $httpFactory;
$this->cache = $cache;
$this->expiresAfter = $expiresAfter;
$this->rateLimit = $rateLimit;
$this->defaultAlg = $defaultAlg;
$this->setCacheKeys();
}
/**
* @param string $keyId
* @return Key
*/
public function offsetGet($keyId): Key
{
if (!$this->keyIdExists($keyId)) {
throw new OutOfBoundsException('Key ID not found');
}
return JWK::parseKey($this->keySet[$keyId], $this->defaultAlg);
}
/**
* @param string $keyId
* @return bool
*/
public function offsetExists($keyId): bool
{
return $this->keyIdExists($keyId);
}
/**
* @param string $offset
* @param Key $value
*/
public function offsetSet($offset, $value): void
{
throw new LogicException('Method not implemented');
}
/**
* @param string $offset
*/
public function offsetUnset($offset): void
{
throw new LogicException('Method not implemented');
}
/**
* @return array<mixed>
*/
private function formatJwksForCache(string $jwks): array
{
$jwks = json_decode($jwks, true);
if (!isset($jwks['keys'])) {
throw new UnexpectedValueException('"keys" member must exist in the JWK Set');
}
if (empty($jwks['keys'])) {
throw new InvalidArgumentException('JWK Set did not contain any keys');
}
$keys = [];
foreach ($jwks['keys'] as $k => $v) {
$kid = isset($v['kid']) ? $v['kid'] : $k;
$keys[(string) $kid] = $v;
}
return $keys;
}
private function keyIdExists(string $keyId): bool
{
if (null === $this->keySet) {
$item = $this->getCacheItem();
// Try to load keys from cache
if ($item->isHit()) {
// item found! retrieve it
$this->keySet = $item->get();
// If the cached item is a string, the JWKS response was cached (previous behavior).
// Parse this into expected format array<kid, jwk> instead.
if (\is_string($this->keySet)) {
$this->keySet = $this->formatJwksForCache($this->keySet);
}
}
}
if (!isset($this->keySet[$keyId])) {
if ($this->rateLimitExceeded()) {
return false;
}
$request = $this->httpFactory->createRequest('GET', $this->jwksUri);
$jwksResponse = $this->httpClient->sendRequest($request);
if ($jwksResponse->getStatusCode() !== 200) {
throw new UnexpectedValueException(
\sprintf('HTTP Error: %d %s for URI "%s"',
$jwksResponse->getStatusCode(),
$jwksResponse->getReasonPhrase(),
$this->jwksUri,
),
$jwksResponse->getStatusCode()
);
}
$this->keySet = $this->formatJwksForCache((string) $jwksResponse->getBody());
if (!isset($this->keySet[$keyId])) {
return false;
}
$item = $this->getCacheItem();
$item->set($this->keySet);
if ($this->expiresAfter) {
$item->expiresAfter($this->expiresAfter);
}
$this->cache->save($item);
}
return true;
}
private function rateLimitExceeded(): bool
{
if (!$this->rateLimit) {
return false;
}
$cacheItem = $this->cache->getItem($this->rateLimitCacheKey);
$cacheItemData = [];
if ($cacheItem->isHit() && \is_array($data = $cacheItem->get())) {
$cacheItemData = $data;
}
$callsPerMinute = $cacheItemData['callsPerMinute'] ?? 0;
$expiry = $cacheItemData['expiry'] ?? new \DateTime('+60 seconds', new \DateTimeZone('UTC'));
if (++$callsPerMinute > $this->maxCallsPerMinute) {
return true;
}
$cacheItem->set(['expiry' => $expiry, 'callsPerMinute' => $callsPerMinute]);
$cacheItem->expiresAt($expiry);
$this->cache->save($cacheItem);
return false;
}
private function getCacheItem(): CacheItemInterface
{
if (\is_null($this->cacheItem)) {
$this->cacheItem = $this->cache->getItem($this->cacheKey);
}
return $this->cacheItem;
}
private function setCacheKeys(): void
{
if (empty($this->jwksUri)) {
throw new RuntimeException('JWKS URI is empty');
}
// ensure we do not have illegal characters
$key = preg_replace('|[^a-zA-Z0-9_\.!]|', '', $this->jwksUri);
// add prefix
$key = $this->cacheKeyPrefix . $key;
// Hash keys if they exceed $maxKeyLength of 64
if (\strlen($key) > $this->maxKeyLength) {
$key = substr(hash('sha256', $key), 0, $this->maxKeyLength);
}
$this->cacheKey = $key;
if ($this->rateLimit) {
// add prefix
$rateLimitKey = $this->cacheKeyPrefix . 'ratelimit' . $key;
// Hash keys if they exceed $maxKeyLength of 64
if (\strlen($rateLimitKey) > $this->maxKeyLength) {
$rateLimitKey = substr(hash('sha256', $rateLimitKey), 0, $this->maxKeyLength);
}
$this->rateLimitCacheKey = $rateLimitKey;
}
}
}
@@ -0,0 +1,30 @@
<?php
namespace Firebase\JWT;
class ExpiredException extends \UnexpectedValueException implements JWTExceptionWithPayloadInterface
{
private object $payload;
private ?int $timestamp = null;
public function setPayload(object $payload): void
{
$this->payload = $payload;
}
public function getPayload(): object
{
return $this->payload;
}
public function setTimestamp(int $timestamp): void
{
$this->timestamp = $timestamp;
}
public function getTimestamp(): ?int
{
return $this->timestamp;
}
}

Some files were not shown because too many files have changed in this diff Show More