commit 7a90e6330160b15fc4f1053b14a0212c6312165e Author: Krikorios <99836218+Krikorios@users.noreply.github.com> Date: Sun Feb 22 02:24:15 2026 +0200 Initial commit: MSPE website - full site with admin panel, API, and public pages diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..49b547f --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e04c930 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..cbf167e --- /dev/null +++ b/.htaccess @@ -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 + + + Require all denied + + + Order deny,allow + Deny from all + + + +# Protect .env files + + + Require all denied + + + Order deny,allow + Deny from all + + + +# Prevent access to hidden files + + Order allow,deny + Deny from all + + +# Prevent access to JSON data files from web + + + Require all denied + + + Order deny,allow + Deny from all + + + +# Security headers + + 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'" + + +# Enable compression + + AddOutputFilterByType DEFLATE text/html text/plain text/css application/json application/javascript text/xml + + +# Browser caching + + 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" + diff --git a/Public HTML/.env.example b/Public HTML/.env.example new file mode 100644 index 0000000..2260f94 --- /dev/null +++ b/Public HTML/.env.example @@ -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= diff --git a/Public HTML/.htaccess b/Public HTML/.htaccess new file mode 100644 index 0000000..b5844a2 --- /dev/null +++ b/Public HTML/.htaccess @@ -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 + + + Require all denied + + + Order deny,allow + Deny from all + + + +# Protect .env files + + + Require all denied + + + Order deny,allow + Deny from all + + + +# Prevent access to hidden files + + Order allow,deny + Deny from all + + +# Prevent access to JSON data files from web + + + Require all denied + + + Order deny,allow + Deny from all + + + +# Security headers + + 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'" + + +# Enable compression + + AddOutputFilterByType DEFLATE text/html text/plain text/css application/json application/javascript text/xml + + +# Browser caching + + 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" + diff --git a/Public HTML/404.html b/Public HTML/404.html new file mode 100755 index 0000000..0b0d978 --- /dev/null +++ b/Public HTML/404.html @@ -0,0 +1,104 @@ + + + + + + Page Not Found | MSPE - Architects of Digital Resilience + + + + + + + + + + + + + + +
+
404
+

Page Not Found

+

+ Oops! The page you're looking for doesn't exist or has been moved. + Let's get you back on track. +

+ + Back to Homepage + + +
+ + diff --git a/Public HTML/about.html b/Public HTML/about.html new file mode 100755 index 0000000..56bf5cc --- /dev/null +++ b/Public HTML/about.html @@ -0,0 +1,492 @@ + + + + + + + About Us | MSPE - Architects of Digital Resilience + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Skip to main content + + + + + +
+
+
+
+ Who We Are +

About MSPE

+

We're architects of digital resilience - building the technological foundations that empower businesses to thrive in an ever-evolving digital landscape.

+
+
+
+ + +
+
+
+
+ +

Born From a Bold Belief

+

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.

+

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.

+
+
+
+
+

Our Mission

+

To transform complexity into competitive advantage. 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.

+
+
+
+
+
+

Our Vision

+

A world where every organization, regardless of size, operates with the digital resilience of a Fortune 500 company. 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.

+
+
+
+
+
+
+
+ MSPE Team Collaboration +
+
+ MSPE Data Center Operations +
+
+ MSPE Client Success +
+
+
+
+
+
+ + +
+
+
+ +

The Six Pillars of MSPE

+

These aren't just values - they're non-negotiable commitments that define who we are

+
+ +
+
+
+

Anticipatory Intelligence

+

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.

+
+
+
+

Radical Transparency

+

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.

+
+
+
+

Strategic Courage

+

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.

+
+
+
+

Boundless Curiosity

+

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.

+
+
+
+

Bespoke Precision

+

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.

+
+
+
+

Uncompromising Security

+

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.

+
+
+
+
+ + +
+
+
+ +

The MSPE Approach

+

A methodology built for results, not billable hours

+
+ +
+
+
+ 01 +
+

Deep Discovery

+

We don't start with solutions - we start with questions. Understanding your business, challenges, and aspirations before touching any technology.

+
+ +
+
+ 02 +
+

Strategic Architecture

+

We design solutions that work today and scale tomorrow. Every recommendation is mapped to your goals, budget, and growth trajectory.

+
+ +
+
+ 03 +
+

Precision Execution

+

Implementation with minimal disruption and maximum impact. We move fast, but never at the expense of quality or security.

+
+ +
+
+ 04 +
+

Continuous Evolution

+

Our relationship doesn't end at deployment. We monitor, optimize, and evolve your systems as your business grows and threats change.

+
+
+
+
+ + +
+
+
+ +

Meet the Team

+

Experts dedicated to building resilient, future-ready systems

+
+ +
+
+
+ + Founder & CEO + +
+
+

Kareem Mansour

+

Founder & CEO

+ Network & infrastructure specialist · 12+ yrs across enterprise and SMB environments. +
+
+
+
+ + CTO + +
+
+

Nour Haddad

+

Chief Technology Officer

+ Systems architect with deep expertise in cloud platforms and enterprise IT design. +
+
+
+
+ + Security Lead + +
+
+

Ranya Aziz

+

Head of Cybersecurity

+ Penetration tester and SIEM specialist · CEH certified and ISO 27001 lead auditor. +
+
+
+
+ + Cloud Architect + +
+
+

Elie Khoury

+

Cloud Solutions Architect

+ Azure & AWS certified · specializes in cloud migration, hybrid infrastructure, and DevOps. +
+
+
+
+
+ + +
+
+
+ +

What Clients Say

+

Real feedback from teams we support and protect

+
+ +
+
+
+
+

"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."

+
+
RH
+
+

Rami Haddad

+

Operations Manager, Levant Trading Co.

+
+
+
+
+
+
+
+

"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."

+
+
NK
+
+

Nadia Khoury

+

IT Director, Cedar Financial Group

+
+
+
+
+
+
+
+

"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."

+
+
MS
+
+

Marc Sfeir

+

CEO, BlueLine Digital Agency

+
+
+
+
+
+ +
+ +
+ +
+
+
+ + +
+
+
+ +

Technologies We Master

+

We stay at the forefront of technology to deliver cutting-edge solutions

+
+ +
+
+ Microsoft Azure + Microsoft Azure +
+
+ Amazon Web Services + Amazon Web Services +
+
+ Google Cloud + Google Cloud +
+
+ Linux + Linux & Open Source +
+
+ Kubernetes + Kubernetes +
+
+ Docker + Docker +
+
+
+
+ + +
+
+
+
+

Ready to Work Together?

+

Let's discuss how MSPE can help transform your business with our expert services.

+ +
+
+
+ + + + + + + + + + + diff --git a/Public HTML/admin/availability.html b/Public HTML/admin/availability.html new file mode 100755 index 0000000..8a7dc4a --- /dev/null +++ b/Public HTML/admin/availability.html @@ -0,0 +1,930 @@ + + + + + + + Availability - MSPE Admin + + + + + + + +
+ + +
+
+
+ +

Manage Availability

+
+ +
+
+
+ Admin +
+
+
+
+ +
+
+
+

Add Time Slot

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + + + + + + + + +
+
+ +
+ + +
+
+ + +
+

Block Time

+

Block a time slot to make it unavailable for public booking (vacation, personal time, etc.)

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+ + +
+ +
+
+

Availability Slots

+
+
+ + +
+
+ + +
+
+
+ + + + +
+ + + + + + + + + + + + + + + + +
DateTimeCapacityStatusNotesActions
+
+ +

Loading availability slots...

+
+
+
+
+
+
+
+
+ + + + + diff --git a/Public HTML/admin/bookings.html b/Public HTML/admin/bookings.html new file mode 100755 index 0000000..da45d6e --- /dev/null +++ b/Public HTML/admin/bookings.html @@ -0,0 +1,732 @@ + + + + + + + Bookings - MSPE Admin + + + + + + + +
+ + +
+
+
+ +

Calendar Bookings

+
+ +
+ + + + + View Public Calendar + + +
+
+ Admin +
+ +
+ + +
+
+
+
+ +
+
+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + + + + + + + + + + + + + + + + +
DateTimeClientEmailServiceStatusActions
+
+ +

Loading bookings...

+
+
+
+
+ +
+
+ +

Select a booking to view details

+
+
+
+
+
+
+ + + + + diff --git a/Public HTML/admin/css/admin.css b/Public HTML/admin/css/admin.css new file mode 100755 index 0000000..5fc22b7 --- /dev/null +++ b/Public HTML/admin/css/admin.css @@ -0,0 +1,3334 @@ +/* ========================================= + MSPE Admin Panel Stylesheet + ========================================= */ + +:root { + --primary: #0ea5e9; + --primary-dark: #0284c7; + --secondary: #0d9488; + --success: #10b981; + --warning: #f59e0b; + --danger: #ef4444; + --dark: #0a1628; + --dark-light: #1e293b; + --gray-100: #f1f5f9; + --gray-200: #e2e8f0; + --gray-300: #cbd5e1; + --gray-400: #94a3b8; + --gray-500: #64748b; + --gray-600: #475569; + --gray-700: #334155; + --white: #ffffff; + --sidebar-width: 260px; + --header-height: 70px; + --font-main: 'DM Sans', sans-serif; + --shadow-sm: 0 1px 2px rgba(0,0,0,0.05); + --shadow-md: 0 4px 6px -1px rgba(0,0,0,0.1); + --shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.1); + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 16px; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: var(--font-main); + background: var(--gray-100); + color: var(--gray-700); + line-height: 1.6; +} + +a { + text-decoration: none; + color: inherit; +} + +/* ======================== + Login Page + ======================== */ +.login-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: var(--dark); + position: relative; + overflow: hidden; +} + +.login-container { + width: 100%; + max-width: 450px; + padding: 2rem; + position: relative; + z-index: 1; +} + +.login-card { + background: var(--white); + border-radius: var(--radius-lg); + padding: 3rem; + box-shadow: var(--shadow-lg); +} + +.login-header { + text-align: center; + margin-bottom: 2rem; +} + +.login-logo { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + margin-bottom: 1.5rem; +} + +.login-logo i { + font-size: 2.5rem; + color: var(--primary); +} + +.login-logo span { + font-size: 2rem; + font-weight: 700; + color: var(--dark); +} + +.login-header h1 { + font-size: 1.5rem; + color: var(--dark); + margin-bottom: 0.5rem; +} + +.login-header p { + color: var(--gray-500); +} + +.login-form .form-group { + margin-bottom: 1.25rem; +} + +.login-form label { + display: block; + font-weight: 500; + color: var(--gray-700); + margin-bottom: 0.5rem; +} + +.input-icon { + position: relative; +} + +.input-icon i { + position: absolute; + left: 1rem; + top: 50%; + transform: translateY(-50%); + color: var(--gray-400); +} + +.input-icon input { + width: 100%; + padding: 0.875rem 1rem 0.875rem 2.75rem; + border: 2px solid var(--gray-200); + border-radius: var(--radius-md); + font-size: 1rem; + transition: all 0.3s ease; +} + +.input-icon input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 4px rgba(14, 165, 233, 0.1); +} + +.toggle-password { + position: absolute; + right: 1rem; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + color: var(--gray-400); + cursor: pointer; +} + +.form-options { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; +} + +.checkbox-label { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + font-size: 0.9rem; + color: var(--gray-600); +} + +.forgot-link { + color: var(--primary); + font-size: 0.9rem; + font-weight: 500; +} + +.forgot-link:hover { + text-decoration: underline; +} + +.login-message { + margin-top: 1rem; + padding: 0.75rem 1rem; + border-radius: var(--radius-sm); + font-size: 0.9rem; + display: none; +} + +.login-message.success { + background: rgba(16, 185, 129, 0.1); + color: var(--success); + border: 1px solid rgba(16, 185, 129, 0.3); +} + +.login-message.error { + background: rgba(239, 68, 68, 0.1); + color: var(--danger); + border: 1px solid rgba(239, 68, 68, 0.3); +} + +.login-footer { + margin-top: 2rem; + padding-top: 1.5rem; + border-top: 1px solid var(--gray-200); + text-align: center; +} + +.login-footer p { + font-size: 0.85rem; + color: var(--gray-500); + margin-bottom: 0.25rem; +} + +.login-bg { + position: fixed; + inset: 0; + z-index: 0; + overflow: hidden; + pointer-events: none; +} + +.bg-shape { + position: absolute; + border-radius: 50%; + opacity: 0.1; +} + +.bg-shape-1 { + width: 600px; + height: 600px; + background: var(--primary); + top: -200px; + right: -200px; +} + +.bg-shape-2 { + width: 400px; + height: 400px; + background: var(--secondary); + bottom: -100px; + left: -100px; +} + +.bg-shape-3 { + width: 300px; + height: 300px; + background: var(--primary); + bottom: 20%; + right: 10%; +} + +/* ======================== + Buttons + ======================== */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.75rem 1.5rem; + border: none; + border-radius: var(--radius-md); + font-family: var(--font-main); + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; +} + +.btn-primary { + background: linear-gradient(135deg, var(--primary), var(--secondary)); + color: var(--white); +} + +.btn-primary:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(14, 165, 233, 0.4); +} + +.btn-secondary { + background: var(--gray-200); + color: var(--gray-700); +} + +.btn-secondary:hover { + background: var(--gray-300); +} + +.btn-danger { + background: var(--danger); + color: var(--white); +} + +.btn-danger:hover { + background: #dc2626; +} + +.btn-block { + width: 100%; +} + +.btn-sm { + padding: 0.5rem 1rem; + font-size: 0.85rem; +} + +/* ======================== + Admin Layout + ======================== */ +.admin-wrapper { + display: flex; + min-height: 100vh; +} + +/* Sidebar */ +.sidebar { + width: var(--sidebar-width); + background: var(--dark); + position: fixed; + top: 0; + left: 0; + bottom: 0; + display: flex; + flex-direction: column; + z-index: 100; + transition: transform 0.3s ease; +} + +.sidebar-header { + padding: 1.5rem; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid rgba(255,255,255,0.1); +} + +.sidebar-logo { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.sidebar-logo i { + font-size: 1.75rem; + color: var(--primary); +} + +.sidebar-logo span { + font-size: 1.5rem; + font-weight: 700; + color: var(--white); +} + +.sidebar-toggle { + display: none; + background: none; + border: none; + color: var(--gray-400); + font-size: 1.25rem; + cursor: pointer; +} + +.sidebar-nav { + flex: 1; + padding: 1.5rem 0; + overflow-y: auto; +} + +.nav-section { + margin-bottom: 1.5rem; +} + +.nav-section-title { + display: block; + padding: 0 1.5rem; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--gray-500); + margin-bottom: 0.75rem; +} + +.nav-menu { + list-style: none; +} + +.nav-item { + margin-bottom: 0.25rem; +} + +.nav-link { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1.5rem; + color: var(--gray-400); + font-weight: 500; + transition: all 0.3s ease; +} + +.nav-link:hover { + color: var(--white); + background: rgba(255,255,255,0.05); +} + +.nav-item.active .nav-link { + color: var(--white); + background: linear-gradient(90deg, rgba(14, 165, 233, 0.2), transparent); + border-left: 3px solid var(--primary); +} + +.nav-link i { + width: 20px; + text-align: center; +} + +.nav-link .badge { + margin-left: auto; + background: var(--danger); + color: var(--white); + padding: 0.2rem 0.5rem; + border-radius: 10px; + font-size: 0.7rem; + font-weight: 600; +} + +.sidebar-footer { + padding: 1.5rem; + border-top: 1px solid rgba(255,255,255,0.1); +} + +.view-site-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.75rem; + background: rgba(255,255,255,0.05); + border-radius: var(--radius-md); + color: var(--gray-400); + font-weight: 500; + transition: all 0.3s ease; +} + +.view-site-btn:hover { + background: rgba(255,255,255,0.1); + color: var(--white); +} + +/* Main Content */ +.main-content { + flex: 1; + margin-left: var(--sidebar-width); + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* Header */ +.admin-header { + height: var(--header-height); + background: var(--white); + padding: 0 2rem; + display: flex; + align-items: center; + justify-content: space-between; + box-shadow: var(--shadow-sm); + position: sticky; + top: 0; + z-index: 50; +} + +.header-left { + display: flex; + align-items: center; + gap: 1rem; +} + +.mobile-toggle { + display: none; + background: none; + border: none; + font-size: 1.25rem; + color: var(--gray-600); + cursor: pointer; +} + +.page-title { + font-size: 1.5rem; + font-weight: 700; + color: var(--dark); +} + +.header-right { + display: flex; + align-items: center; + gap: 1.5rem; +} + +.header-search { + position: relative; +} + +.header-search i { + position: absolute; + left: 1rem; + top: 50%; + transform: translateY(-50%); + color: var(--gray-400); +} + +.header-search input { + padding: 0.625rem 1rem 0.625rem 2.5rem; + border: 2px solid var(--gray-200); + border-radius: 30px; + width: 250px; + font-size: 0.9rem; + transition: all 0.3s ease; +} + +.header-search input:focus { + outline: none; + border-color: var(--primary); + width: 300px; +} + +.notification-btn { + position: relative; + background: none; + border: none; + font-size: 1.25rem; + color: var(--gray-500); + cursor: pointer; + padding: 0.5rem; +} + +.notification-badge { + position: absolute; + top: 0; + right: 0; + background: var(--danger); + color: var(--white); + font-size: 0.65rem; + font-weight: 600; + padding: 0.15rem 0.4rem; + border-radius: 10px; +} + +.header-user { + display: flex; + align-items: center; + gap: 0.75rem; + position: relative; +} + +.user-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + overflow: hidden; +} + +.user-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.user-info { + display: flex; + flex-direction: column; +} + +.user-name { + font-weight: 600; + color: var(--dark); + font-size: 0.9rem; +} + +.user-role { + font-size: 0.75rem; + color: var(--gray-500); +} + +.user-dropdown { + position: relative; +} + +.dropdown-toggle { + background: none; + border: none; + color: var(--gray-400); + cursor: pointer; + padding: 0.25rem; +} + +.user-dropdown .dropdown-menu { + position: absolute; + top: 100%; + right: 0; + background: var(--white); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + min-width: 180px; + padding: 0.5rem 0; + opacity: 0; + visibility: hidden; + transform: translateY(10px); + transition: all 0.3s ease; + list-style: none; +} + +.user-dropdown:hover .dropdown-menu { + opacity: 1; + visibility: visible; + transform: translateY(0); +} + +.user-dropdown .dropdown-menu li a { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + color: var(--gray-600); + transition: all 0.3s ease; +} + +.user-dropdown .dropdown-menu li a:hover { + background: var(--gray-100); + color: var(--primary); +} + +.user-dropdown .dropdown-menu .divider { + border-top: 1px solid var(--gray-200); + margin: 0.5rem 0; +} + +/* Content Wrapper */ +.content-wrapper { + padding: 2rem; + flex: 1; +} + +/* ======================== + Dashboard + ======================== */ +.stats-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 1.5rem; + margin-bottom: 2rem; +} + +.stat-card { + background: var(--white); + padding: 1.5rem; + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + display: flex; + align-items: center; + gap: 1rem; +} + +.stat-icon { + width: 60px; + height: 60px; + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; + color: var(--white); +} + +.stat-icon.bg-blue { background: linear-gradient(135deg, #3b82f6, #1d4ed8); } +.stat-icon.bg-green { background: linear-gradient(135deg, #10b981, #059669); } +.stat-icon.bg-purple { background: linear-gradient(135deg, #8b5cf6, #6d28d9); } +.stat-icon.bg-orange { background: linear-gradient(135deg, #f59e0b, #d97706); } + +.stat-info h3 { + font-size: 1.75rem; + font-weight: 700; + color: var(--dark); + line-height: 1; +} + +.stat-info p { + color: var(--gray-500); + font-size: 0.9rem; + margin-top: 0.25rem; +} + +.stat-trend { + margin-left: auto; + font-size: 0.85rem; + font-weight: 600; + display: flex; + align-items: center; + gap: 0.25rem; +} + +.stat-trend.up { color: var(--success); } +.stat-trend.down { color: var(--danger); } + +/* Cards */ +.card { + background: var(--white); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + overflow: hidden; +} + +.card-header { + padding: 1.25rem 1.5rem; + border-bottom: 1px solid var(--gray-200); + display: flex; + align-items: center; + justify-content: space-between; +} + +.card-header h2, .card-header h3 { + font-size: 1.1rem; + font-weight: 600; + color: var(--dark); +} + +.card-body { + padding: 1.5rem; +} + +.view-all { + color: var(--primary); + font-weight: 500; + font-size: 0.9rem; +} + +.view-all:hover { + text-decoration: underline; +} + +/* Dashboard Rows */ +.dashboard-row { + margin-bottom: 2rem; +} + +.dashboard-row.two-col { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.5rem; +} + +/* Quick Actions */ +.quick-actions { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 1rem; +} + +.quick-action-btn { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + padding: 1.5rem; + background: var(--gray-100); + border-radius: var(--radius-md); + transition: all 0.3s ease; +} + +.quick-action-btn:hover { + background: var(--primary); + color: var(--white); + transform: translateY(-3px); +} + +.quick-action-btn i { + font-size: 1.5rem; +} + +.quick-action-btn span { + font-weight: 500; + font-size: 0.9rem; +} + +/* Content Lists */ +.content-list { + display: flex; + flex-direction: column; +} + +.content-item { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem 0; + border-bottom: 1px solid var(--gray-200); +} + +.content-item:last-child { + border-bottom: none; +} + +.item-image { + width: 70px; + height: 50px; + border-radius: var(--radius-sm); + overflow: hidden; + flex-shrink: 0; +} + +.item-image img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.item-info { + flex: 1; + min-width: 0; +} + +.item-info h4 { + font-size: 0.95rem; + font-weight: 600; + color: var(--dark); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.item-meta { + font-size: 0.8rem; + color: var(--gray-500); + margin-top: 0.25rem; +} + +.item-actions { + display: flex; + gap: 0.5rem; +} + +.action-btn { + width: 32px; + height: 32px; + border: none; + border-radius: var(--radius-sm); + cursor: pointer; + transition: all 0.3s ease; + display: flex; + align-items: center; + justify-content: center; +} + +.action-btn.edit { + background: rgba(59, 130, 246, 0.1); + color: #3b82f6; +} + +.action-btn.edit:hover { + background: #3b82f6; + color: var(--white); +} + +.action-btn.view { + background: rgba(16, 185, 129, 0.1); + color: #10b981; +} + +.action-btn.view:hover { + background: #10b981; + color: var(--white); +} + +.action-btn.delete { + background: rgba(239, 68, 68, 0.1); + color: #ef4444; +} + +.action-btn.delete:hover { + background: #ef4444; + color: var(--white); +} + +/* Messages List */ +.message-item { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem 0; + border-bottom: 1px solid var(--gray-200); +} + +.message-item:last-child { + border-bottom: none; +} + +.message-avatar { + width: 45px; + height: 45px; + border-radius: 50%; + overflow: hidden; + flex-shrink: 0; +} + +.message-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.message-info { + flex: 1; + min-width: 0; +} + +.message-info h4 { + font-size: 0.95rem; + font-weight: 600; + color: var(--dark); +} + +.message-info p { + font-size: 0.85rem; + color: var(--gray-500); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 0.25rem; +} + +.message-time { + font-size: 0.75rem; + color: var(--gray-400); + margin-top: 0.25rem; +} + +.message-status { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; +} + +.message-status.unread { background: var(--primary); } +.message-status.read { background: var(--gray-300); } + +/* ======================== + Content Management Pages + ======================== */ +.content-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; +} + +.content-filters { + display: flex; + gap: 1rem; + align-items: center; +} + +.filter-group select { + padding: 0.625rem 2rem 0.625rem 1rem; + border: 2px solid var(--gray-200); + border-radius: var(--radius-md); + font-size: 0.9rem; + color: var(--gray-700); + background: var(--white); + cursor: pointer; +} + +.filter-group select:focus { + outline: none; + border-color: var(--primary); +} + +.search-box { + position: relative; +} + +.search-box i { + position: absolute; + left: 1rem; + top: 50%; + transform: translateY(-50%); + color: var(--gray-400); +} + +.search-box input { + padding: 0.625rem 1rem 0.625rem 2.5rem; + border: 2px solid var(--gray-200); + border-radius: var(--radius-md); + width: 250px; + font-size: 0.9rem; +} + +.search-box input:focus { + outline: none; + border-color: var(--primary); +} + +/* Data Table */ +.table-responsive { + overflow-x: auto; +} + +.data-table { + width: 100%; + border-collapse: collapse; +} + +.data-table th, +.data-table td { + padding: 1rem 1.25rem; + text-align: left; + border-bottom: 1px solid var(--gray-200); +} + +.data-table th { + background: var(--gray-100); + font-weight: 600; + color: var(--gray-600); + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.data-table tbody tr:hover { + background: var(--gray-100); +} + +.table-item { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.table-item img { + width: 60px; + height: 40px; + border-radius: var(--radius-sm); + object-fit: cover; +} + +.category-badge, .status-badge { + display: inline-block; + padding: 0.35rem 0.75rem; + border-radius: 20px; + font-size: 0.75rem; + font-weight: 600; +} + +.category-badge.news { background: rgba(59, 130, 246, 0.1); color: #3b82f6; } +.category-badge.events { background: rgba(139, 92, 246, 0.1); color: #8b5cf6; } +.category-badge.insights { background: rgba(245, 158, 11, 0.1); color: #f59e0b; } +.category-badge.updates { background: rgba(16, 185, 129, 0.1); color: #10b981; } + +.status-badge.published { background: rgba(16, 185, 129, 0.1); color: #10b981; } +.status-badge.draft { background: rgba(245, 158, 11, 0.1); color: #f59e0b; } + +.table-actions { + display: flex; + gap: 0.5rem; +} + +.table-footer { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.25rem; + border-top: 1px solid var(--gray-200); +} + +.bulk-actions { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.bulk-actions select { + padding: 0.5rem 1rem; + border: 2px solid var(--gray-200); + border-radius: var(--radius-sm); + font-size: 0.9rem; +} + +.pagination { + display: flex; + align-items: center; + gap: 1rem; +} + +.pagination-info { + font-size: 0.9rem; + color: var(--gray-500); +} + +.pagination-controls { + display: flex; + gap: 0.25rem; +} + +.pagination-btn { + min-width: 36px; + height: 36px; + border: none; + background: var(--gray-100); + border-radius: var(--radius-sm); + cursor: pointer; + transition: all 0.3s ease; + font-weight: 500; + color: var(--gray-600); +} + +.pagination-btn:hover:not(:disabled) { + background: var(--primary); + color: var(--white); +} + +.pagination-btn.active { + background: var(--primary); + color: var(--white); +} + +.pagination-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ======================== + Form Styles + ======================== */ +.form-grid { + display: grid; + grid-template-columns: 1fr 350px; + gap: 2rem; +} + +.form-main, .form-sidebar { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.edit-form .form-group { + margin-bottom: 1.25rem; +} + +.edit-form .form-group:last-child { + margin-bottom: 0; +} + +.edit-form label { + display: block; + font-weight: 500; + color: var(--gray-700); + margin-bottom: 0.5rem; +} + +.edit-form input[type="text"], +.edit-form input[type="email"], +.edit-form input[type="datetime-local"], +.edit-form select, +.edit-form textarea { + width: 100%; + padding: 0.75rem 1rem; + border: 2px solid var(--gray-200); + border-radius: var(--radius-md); + font-size: 0.95rem; + font-family: var(--font-main); + transition: all 0.3s ease; +} + +.edit-form input:focus, +.edit-form select:focus, +.edit-form textarea:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 4px rgba(14, 165, 233, 0.1); +} + +.edit-form textarea { + resize: vertical; + min-height: 150px; +} + +.form-actions { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-top: 1rem; +} + +/* Image Upload */ +.image-upload { + border: 2px dashed var(--gray-300); + border-radius: var(--radius-md); + padding: 2rem; + text-align: center; + cursor: pointer; + transition: all 0.3s ease; + position: relative; +} + +.image-upload:hover { + border-color: var(--primary); + background: rgba(14, 165, 233, 0.02); +} + +.upload-placeholder { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; +} + +.upload-placeholder i { + font-size: 2.5rem; + color: var(--gray-400); +} + +.upload-placeholder p { + color: var(--gray-600); + font-weight: 500; +} + +.upload-placeholder span { + font-size: 0.8rem; + color: var(--gray-400); +} + +.image-upload img { + max-width: 100%; + max-height: 200px; + border-radius: var(--radius-sm); +} + +.remove-image { + position: absolute; + top: 0.5rem; + right: 0.5rem; +} + +/* ======================== + Modal + ======================== */ +.modal { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + visibility: hidden; + transition: all 0.3s ease; +} + +.modal.active { + opacity: 1; + visibility: visible; +} + +.modal-overlay { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.5); +} + +.modal-content { + position: relative; + background: var(--white); + border-radius: var(--radius-lg); + width: 90%; + max-width: 500px; + box-shadow: var(--shadow-lg); + transform: scale(0.9); + transition: transform 0.3s ease; +} + +.modal.active .modal-content { + transform: scale(1); +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.25rem 1.5rem; + border-bottom: 1px solid var(--gray-200); +} + +.modal-header h3 { + font-size: 1.25rem; + color: var(--dark); +} + +.modal-close { + background: none; + border: none; + font-size: 1.5rem; + color: var(--gray-400); + cursor: pointer; +} + +.modal-body { + padding: 1.5rem; +} + +.modal-footer { + padding: 1rem 1.5rem; + border-top: 1px solid var(--gray-200); + display: flex; + justify-content: flex-end; + gap: 0.75rem; +} + +/* ======================== + Responsive + ======================== */ +@media (max-width: 1200px) { + .stats-grid { + grid-template-columns: repeat(2, 1fr); + } + + .quick-actions { + grid-template-columns: repeat(2, 1fr); + } + + .form-grid { + grid-template-columns: 1fr; + } + + .form-sidebar { + order: -1; + } +} + +@media (max-width: 1024px) { + .sidebar { + transform: translateX(-100%); + } + + .sidebar.active { + transform: translateX(0); + } + + .main-content { + margin-left: 0; + } + + .mobile-toggle { + display: block; + } + + .sidebar-toggle { + display: block; + } + + .dashboard-row.two-col { + grid-template-columns: 1fr; + } + + .header-search { + display: none; + } +} + +@media (max-width: 768px) { + .content-wrapper { + padding: 1rem; + } + + .stats-grid { + grid-template-columns: 1fr; + } + + .content-header { + flex-direction: column; + gap: 1rem; + align-items: stretch; + } + + .content-filters { + flex-wrap: wrap; + } + + .search-box input { + width: 100%; + } + + .admin-header { + padding: 0 1rem; + } + + .user-info { + display: none; + } + + .table-footer { + flex-direction: column; + gap: 1rem; + } +} + +@media (max-width: 480px) { + .login-card { + padding: 2rem 1.5rem; + } + + .quick-actions { + grid-template-columns: 1fr; + } +} + +/* ========================================= + CRM Extended Styles + ========================================= */ + +/* Pages Management Grid */ +.pages-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 1.5rem; +} + +.page-admin-card { + background: var(--white); + border-radius: var(--radius-md); + padding: 1.5rem; + box-shadow: var(--shadow-sm); + border: 1px solid var(--gray-200); + transition: all 0.3s ease; +} + +.page-admin-card:hover { + box-shadow: var(--shadow-md); + border-color: var(--primary); +} + +.page-admin-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 1rem; +} + +.page-icon { + width: 48px; + height: 48px; + background: linear-gradient(135deg, var(--primary), var(--secondary)); + border-radius: var(--radius-sm); + display: flex; + align-items: center; + justify-content: center; + color: var(--white); + font-size: 1.25rem; +} + +.page-status { + font-size: 0.75rem; + font-weight: 500; + padding: 0.25rem 0.75rem; + border-radius: 20px; + display: flex; + align-items: center; + gap: 0.35rem; +} + +.page-status.published { + background: rgba(16, 185, 129, 0.1); + color: var(--success); +} + +.page-status.published i { + font-size: 0.5rem; +} + +.page-status.draft { + background: rgba(245, 158, 11, 0.1); + color: var(--warning); +} + +.page-admin-card h3 { + font-size: 1.125rem; + color: var(--dark); + margin-bottom: 0.5rem; +} + +.page-admin-card p { + font-size: 0.875rem; + color: var(--gray-500); + margin-bottom: 1rem; +} + +.page-sections { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.section-tag { + background: var(--gray-100); + color: var(--gray-600); + padding: 0.25rem 0.625rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; +} + +.page-admin-actions { + display: flex; + gap: 0.5rem; + padding-top: 1rem; + border-top: 1px solid var(--gray-200); +} + +/* Section collapse in page editor */ +.sect-body.collapsed { + display: none; +} + +/* Global Sections */ +.global-sections-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1rem; +} + +.global-section-item { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem; + background: var(--gray-100); + border-radius: var(--radius-sm); +} + +.global-section-item i { + font-size: 1.5rem; + color: var(--primary); + width: 40px; + text-align: center; +} + +.global-section-item div { + flex: 1; +} + +.global-section-item h4 { + font-size: 0.875rem; + font-weight: 600; + color: var(--dark); +} + +.global-section-item p { + font-size: 0.75rem; + color: var(--gray-500); + margin: 0; +} + +/* Team Management Grid */ +.team-admin-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1.5rem; +} + +.team-admin-card { + background: var(--white); + border-radius: var(--radius-md); + padding: 1.5rem; + text-align: center; + box-shadow: var(--shadow-sm); + border: 1px solid var(--gray-200); +} + +.team-member-photo { + position: relative; + width: 120px; + height: 120px; + margin: 0 auto 1rem; + border-radius: 50%; + overflow: hidden; +} + +.team-member-photo img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.photo-overlay { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.3s ease; +} + +.team-member-photo:hover .photo-overlay { + opacity: 1; +} + +.team-member-info h3 { + font-size: 1.125rem; + color: var(--dark); + margin-bottom: 0.25rem; +} + +.team-role { + display: block; + font-size: 0.875rem; + color: var(--primary); + font-weight: 500; +} + +.team-dept { + display: block; + font-size: 0.75rem; + color: var(--gray-500); + margin-top: 0.25rem; +} + +.team-socials { + display: flex; + justify-content: center; + gap: 0.75rem; + margin: 1rem 0; +} + +.social-icon { + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + background: var(--gray-100); + color: var(--gray-500); + font-size: 0.875rem; + transition: all 0.3s ease; +} + +.social-icon:hover { + background: var(--primary); + color: var(--white); +} + +.team-admin-actions { + display: flex; + justify-content: center; + gap: 0.5rem; + padding-top: 1rem; + border-top: 1px solid var(--gray-200); +} + +.team-order { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--gray-200); + font-size: 0.75rem; + color: var(--gray-500); +} + +.order-controls { + display: flex; + gap: 0.25rem; +} + +.order-controls button { + background: var(--gray-100); + border: none; + width: 24px; + height: 24px; + border-radius: 4px; + cursor: pointer; + color: var(--gray-600); +} + +.order-controls button:hover:not(:disabled) { + background: var(--primary); + color: var(--white); +} + +.order-controls button:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +/* Testimonials Admin Grid */ +.testimonials-admin-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); + gap: 1.5rem; +} + +.testimonial-admin-card { + background: var(--white); + border-radius: var(--radius-md); + padding: 1.5rem; + box-shadow: var(--shadow-sm); + border: 1px solid var(--gray-200); +} + +.testimonial-admin-card.pending { + border-left: 4px solid var(--warning); +} + +.testimonial-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 1rem; +} + +.testimonial-author { + display: flex; + gap: 0.75rem; + align-items: center; +} + +.testimonial-author img { + width: 48px; + height: 48px; + border-radius: 50%; + object-fit: cover; +} + +.testimonial-author h4 { + font-size: 0.9375rem; + color: var(--dark); + margin-bottom: 0.125rem; +} + +.testimonial-author span { + font-size: 0.75rem; + color: var(--gray-500); +} + +.testimonial-status { + font-size: 0.75rem; + font-weight: 500; + padding: 0.25rem 0.625rem; + border-radius: 20px; + display: flex; + align-items: center; + gap: 0.35rem; +} + +.testimonial-status.published { + background: rgba(16, 185, 129, 0.1); + color: var(--success); +} + +.testimonial-status.pending { + background: rgba(245, 158, 11, 0.1); + color: var(--warning); +} + +.testimonial-rating { + color: #fbbf24; + font-size: 0.875rem; + margin-bottom: 0.75rem; +} + +.testimonial-rating i.far { + color: var(--gray-300); +} + +.testimonial-text { + font-size: 0.875rem; + color: var(--gray-600); + line-height: 1.6; + margin-bottom: 1rem; + font-style: italic; +} + +.testimonial-meta { + display: flex; + gap: 1rem; + font-size: 0.75rem; + color: var(--gray-400); + margin-bottom: 1rem; +} + +.testimonial-meta i { + margin-right: 0.35rem; +} + +.testimonial-admin-actions { + display: flex; + gap: 0.5rem; + padding-top: 1rem; + border-top: 1px solid var(--gray-200); +} + +/* Media Library Styles */ +.media-stats { + display: flex; + justify-content: space-between; + align-items: center; + background: var(--white); + padding: 1.25rem 1.5rem; + border-radius: var(--radius-md); + margin-bottom: 1.5rem; + box-shadow: var(--shadow-sm); +} + +.storage-overview { + flex: 1; + max-width: 300px; +} + +.storage-used { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.5rem; +} + +.storage-used i { + color: var(--primary); +} + +.storage-value { + font-weight: 600; + color: var(--dark); +} + +.storage-label { + font-size: 0.75rem; + color: var(--gray-500); + margin-left: 0.25rem; +} + +.storage-bar { + height: 6px; + background: var(--gray-200); + border-radius: 3px; + overflow: hidden; +} + +.storage-progress { + height: 100%; + background: linear-gradient(90deg, var(--primary), var(--secondary)); + border-radius: 3px; +} + +.media-counts { + display: flex; + gap: 2rem; +} + +.media-count-item { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + color: var(--gray-600); +} + +.media-count-item i { + color: var(--primary); +} + +.media-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + flex-wrap: wrap; + gap: 1rem; +} + +.toolbar-left, +.toolbar-right { + display: flex; + gap: 0.75rem; + align-items: center; +} + +.media-folders { + display: flex; + gap: 1rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; +} + +.folder-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + background: var(--white); + border-radius: var(--radius-sm); + cursor: pointer; + border: 1px solid var(--gray-200); + transition: all 0.3s ease; +} + +.folder-item:hover { + border-color: var(--primary); + background: rgba(14, 165, 233, 0.05); +} + +.folder-item i { + color: #fbbf24; + font-size: 1.25rem; +} + +.folder-item span { + font-weight: 500; + color: var(--dark); +} + +.folder-item small { + font-size: 0.75rem; + color: var(--gray-400); +} + +.media-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 1rem; +} + +.media-item { + position: relative; + background: var(--white); + border-radius: var(--radius-sm); + overflow: hidden; + box-shadow: var(--shadow-sm); + border: 1px solid var(--gray-200); +} + +.media-preview { + position: relative; + height: 140px; + background: var(--gray-100); + overflow: hidden; +} + +.media-preview img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.media-preview.document, +.media-preview.video { + display: flex; + align-items: center; + justify-content: center; +} + +.media-preview.document i { + font-size: 3rem; + color: #ef4444; +} + +.media-preview.video i { + font-size: 3rem; + color: var(--primary); +} + +.media-overlay { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + opacity: 0; + transition: opacity 0.3s ease; +} + +.media-item:hover .media-overlay { + opacity: 1; +} + +.media-overlay .btn { + background: var(--white); + color: var(--dark); + border: none; + padding: 0.5rem; + border-radius: 4px; +} + +.media-info { + padding: 0.75rem; +} + +.media-name { + display: block; + font-size: 0.8125rem; + font-weight: 500; + color: var(--dark); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.media-size { + font-size: 0.75rem; + color: var(--gray-400); +} + +.media-checkbox { + position: absolute; + top: 0.5rem; + left: 0.5rem; + z-index: 2; +} + +.media-checkbox input { + width: 18px; + height: 18px; + cursor: pointer; +} + +.bulk-actions { + position: fixed; + bottom: 2rem; + left: 50%; + transform: translateX(-50%); + background: var(--dark); + padding: 1rem 1.5rem; + border-radius: var(--radius-md); + display: flex; + align-items: center; + gap: 1rem; + box-shadow: var(--shadow-lg); + z-index: 100; +} + +.bulk-actions .selected-count { + color: var(--white); + font-weight: 500; +} + +.upload-dropzone { + border: 2px dashed var(--gray-300); + border-radius: var(--radius-md); + padding: 3rem 2rem; + text-align: center; + cursor: pointer; + transition: all 0.3s ease; +} + +.upload-dropzone:hover, +.upload-dropzone.dragover { + border-color: var(--primary); + background: rgba(14, 165, 233, 0.05); +} + +.upload-dropzone i { + font-size: 3rem; + color: var(--gray-400); + margin-bottom: 1rem; +} + +.upload-dropzone h3 { + color: var(--dark); + margin-bottom: 0.5rem; +} + +.upload-dropzone p { + color: var(--gray-500); +} + +.upload-hint { + display: block; + font-size: 0.75rem; + color: var(--gray-400); + margin-top: 0.5rem; +} + +/* Users Table Styles */ +.user-info { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.user-info img { + width: 40px; + height: 40px; + border-radius: 50%; +} + +.user-info .name { + display: block; + font-weight: 500; + color: var(--dark); +} + +.user-info .username { + display: block; + font-size: 0.75rem; + color: var(--gray-400); +} + +.role-badge { + display: inline-block; + padding: 0.25rem 0.625rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; +} + +.role-badge.super-admin { + background: rgba(139, 92, 246, 0.1); + color: #8b5cf6; +} + +.role-badge.editor { + background: rgba(16, 185, 129, 0.1); + color: var(--success); +} + +.role-badge.contributor { + background: rgba(245, 158, 11, 0.1); + color: var(--warning); +} + +.status-badge.active { + background: rgba(16, 185, 129, 0.1); + color: var(--success); +} + +.status-badge.inactive { + background: rgba(107, 114, 128, 0.1); + color: var(--gray-500); +} + +.status-badge.pending { + background: rgba(245, 158, 11, 0.1); + color: var(--warning); +} + +.status-badge.confirmed { + background: rgba(16, 185, 129, 0.1); + color: var(--success); +} + +.status-badge.completed { + background: rgba(14, 165, 233, 0.1); + color: var(--primary); +} + +.status-badge.cancelled { + background: rgba(239, 68, 68, 0.1); + color: var(--danger); +} + +/* Roles Grid */ +.roles-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1.5rem; +} + +.role-card { + background: var(--gray-100); + border-radius: var(--radius-md); + padding: 1.25rem; +} + +.role-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.role-count { + font-size: 0.75rem; + color: var(--gray-500); +} + +.permissions-list { + list-style: none; +} + +.permissions-list li { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0; + font-size: 0.875rem; + color: var(--gray-600); + border-bottom: 1px solid var(--gray-200); +} + +.permissions-list li:last-child { + border-bottom: none; +} + +.permissions-list li i.fa-check { + color: var(--success); +} + +.permissions-list li i.fa-times { + color: var(--gray-400); +} + +/* Activity Timeline */ +.activity-timeline { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.activity-item { + display: flex; + gap: 1rem; + align-items: flex-start; +} + +.activity-icon { + width: 36px; + height: 36px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.875rem; + flex-shrink: 0; +} + +.activity-icon.login { + background: rgba(14, 165, 233, 0.1); + color: var(--primary); +} + +.activity-icon.edit { + background: rgba(245, 158, 11, 0.1); + color: var(--warning); +} + +.activity-icon.create { + background: rgba(16, 185, 129, 0.1); + color: var(--success); +} + +.activity-icon.settings { + background: rgba(139, 92, 246, 0.1); + color: #8b5cf6; +} + +.activity-content p { + font-size: 0.875rem; + color: var(--gray-700); +} + +.activity-time { + font-size: 0.75rem; + color: var(--gray-400); +} + +/* Filter Tabs */ +.filter-tabs { + display: flex; + gap: 0.5rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; +} + +.filter-tab { + padding: 0.625rem 1rem; + background: var(--white); + border: 1px solid var(--gray-200); + border-radius: var(--radius-sm); + font-size: 0.875rem; + font-weight: 500; + color: var(--gray-600); + cursor: pointer; + transition: all 0.3s ease; +} + +.filter-tab:hover { + border-color: var(--primary); + color: var(--primary); +} + +.filter-tab.active { + background: var(--primary); + border-color: var(--primary); + color: var(--white); +} + +.filter-tab .badge { + display: inline-block; + background: rgba(255, 255, 255, 0.2); + padding: 0.125rem 0.5rem; + border-radius: 10px; + font-size: 0.75rem; + margin-left: 0.5rem; +} + +/* Client Info in Tables */ +.client-info { + display: flex; + flex-direction: column; +} + +.client-info .name { + font-weight: 500; + color: var(--dark); +} + +.client-info .email { + font-size: 0.75rem; + color: var(--gray-400); +} + +.service-badge { + display: inline-block; + background: var(--gray-100); + padding: 0.25rem 0.625rem; + border-radius: 4px; + font-size: 0.8125rem; + font-weight: 500; + color: var(--gray-700); +} + +.datetime { + font-size: 0.8125rem; + color: var(--gray-600); + line-height: 1.8; +} + +.datetime i { + color: var(--gray-400); + margin-right: 0.35rem; + width: 14px; +} + +.completed-row { + opacity: 0.7; +} + +/* Stats Row */ +.stats-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; + margin-bottom: 1.5rem; +} + +.stat-card { + background: var(--white); + padding: 1.25rem; + border-radius: var(--radius-md); + display: flex; + align-items: center; + gap: 1rem; + box-shadow: var(--shadow-sm); +} + +.stat-icon { + width: 48px; + height: 48px; + border-radius: var(--radius-sm); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.25rem; +} + +.stat-details { + display: flex; + flex-direction: column; +} + +.stat-value { + font-size: 1.5rem; + font-weight: 700; + color: var(--dark); + line-height: 1.2; +} + +.stat-label { + font-size: 0.8125rem; + color: var(--gray-500); +} + +/* View Toggle */ +.view-toggle { + display: flex; + border: 1px solid var(--gray-200); + border-radius: var(--radius-sm); + overflow: hidden; +} + +.view-btn { + padding: 0.5rem 0.75rem; + background: var(--white); + border: none; + cursor: pointer; + color: var(--gray-500); + transition: all 0.3s ease; +} + +.view-btn:hover { + color: var(--primary); +} + +.view-btn.active { + background: var(--primary); + color: var(--white); +} + +/* Filter Bar */ +.filter-bar { + display: flex; + gap: 1rem; + align-items: center; +} + +/* Social Inputs */ +.social-inputs { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.input-icon-group { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.input-icon-group i { + width: 24px; + color: var(--gray-400); + font-size: 1.125rem; +} + +.input-icon-group input { + flex: 1; +} + +/* Checkbox Group */ +.checkbox-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.checkbox-label { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + color: var(--gray-600); + cursor: pointer; +} + +.checkbox-label input[type="checkbox"] { + width: 16px; + height: 16px; + cursor: pointer; +} + +/* Action Buttons */ +.action-buttons { + display: flex; + gap: 0.35rem; +} + +/* Header Actions */ +.header-actions { + display: flex; + gap: 1rem; + align-items: center; +} + +/* Media View Modal */ +.media-view-content { + display: grid; + grid-template-columns: 1fr 300px; + gap: 2rem; +} + +.media-view-preview { + background: var(--gray-100); + border-radius: var(--radius-sm); + display: flex; + align-items: center; + justify-content: center; + min-height: 300px; +} + +.media-view-preview img { + max-width: 100%; + max-height: 400px; + object-fit: contain; +} + +.media-view-details { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.detail-row { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.detail-row label { + font-size: 0.75rem; + font-weight: 600; + color: var(--gray-500); + text-transform: uppercase; +} + +.detail-row span { + color: var(--gray-700); + font-size: 0.875rem; +} + +.url-copy { + display: flex; + gap: 0.5rem; +} + +.url-copy input { + flex: 1; + font-size: 0.75rem; +} + +/* Button Variations */ +.btn-success { + background: var(--success); + color: var(--white); + border: none; +} + +.btn-success:hover { + background: #059669; +} + +/* Responsive Adjustments for New Components */ +@media (max-width: 768px) { + .pages-grid, + .team-admin-grid, + .testimonials-admin-grid, + .roles-grid { + grid-template-columns: 1fr; + } + + .media-stats { + flex-direction: column; + gap: 1rem; + align-items: stretch; + } + + .storage-overview { + max-width: 100%; + } + + .media-counts { + justify-content: space-between; + } + + .media-view-content { + grid-template-columns: 1fr; + } + + .bulk-actions { + left: 1rem; + right: 1rem; + transform: none; + flex-wrap: wrap; + justify-content: center; + } +} + +/* ============================================================ + Settings Tabs + ============================================================ */ +.settings-tabs { + display: flex; + gap: 0.25rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; + background: var(--gray-100, #f1f5f9); + padding: 0.25rem; + border-radius: 0.75rem; +} + +.tab-btn { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.625rem 1rem; + border: none; + background: transparent; + color: var(--gray-500, #64748b); + font-size: 0.875rem; + font-weight: 500; + border-radius: 0.5rem; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; +} + +.tab-btn:hover { + color: var(--gray-700, #334155); + background: var(--gray-200, #e2e8f0); +} + +.tab-btn.active { + background: #fff; + color: var(--primary, #0ea5e9); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.tab-btn i { + font-size: 0.8rem; +} + +.tab-content { + display: none; +} + +.tab-content.active { + display: block; +} + +/* Settings Form Layout */ +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + margin-bottom: 0; +} + +.form-hint { + display: block; + font-size: 0.75rem; + color: var(--gray-400, #94a3b8); + margin-top: 0.25rem; +} + +.code-textarea { + font-family: 'SF Mono', 'Fira Code', monospace; + font-size: 0.8125rem; +} + +/* Color Picker */ +.color-picker { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.color-picker input[type="color"] { + -webkit-appearance: none; + appearance: none; + width: 40px; + height: 40px; + border: 2px solid var(--gray-200, #e2e8f0); + border-radius: 0.5rem; + cursor: pointer; + padding: 2px; + flex-shrink: 0; +} + +.color-picker input[type="color"]::-webkit-color-swatch-wrapper { + padding: 0; +} + +.color-picker input[type="color"]::-webkit-color-swatch { + border: none; + border-radius: 0.35rem; +} + +.color-picker input[type="text"] { + width: 120px; + font-family: 'SF Mono', 'Fira Code', monospace; + font-size: 0.8125rem; +} + +/* Logo Upload */ +.logo-upload { + display: flex; + align-items: center; + gap: 1rem; +} + +.logo-upload .current-logo, +.logo-upload .current-favicon { + width: 60px; + height: 60px; + object-fit: contain; + border: 1px solid var(--gray-200, #e2e8f0); + border-radius: 0.5rem; + padding: 4px; + background: #fff; +} + +/* Social Input */ +.social-input label { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.social-input label i { + width: 20px; + text-align: center; + font-size: 1.1rem; +} + +/* Integration Items */ +.integration-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 0; + border-bottom: 1px solid var(--gray-100, #f1f5f9); +} + +.integration-item:last-of-type { + border-bottom: none; +} + +.integration-info { + display: flex; + align-items: center; + gap: 1rem; +} + +.integration-info i { + font-size: 1.5rem; + width: 32px; + text-align: center; +} + +.integration-info h4 { + margin: 0; + font-size: 0.9375rem; + font-weight: 600; +} + +.integration-info p { + margin: 0; + font-size: 0.8125rem; + color: var(--gray-400, #94a3b8); +} + +/* Toggle Switch */ +.toggle-switch { + position: relative; + display: inline-block; + width: 44px; + height: 24px; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.toggle-slider { + position: absolute; + cursor: pointer; + top: 0; left: 0; right: 0; bottom: 0; + background: var(--gray-300, #cbd5e1); + border-radius: 24px; + transition: 0.3s; +} + +.toggle-slider::before { + content: ''; + position: absolute; + height: 18px; + width: 18px; + left: 3px; + bottom: 3px; + background: #fff; + border-radius: 50%; + transition: 0.3s; +} + +.toggle-switch input:checked + .toggle-slider { + background: var(--primary, #0ea5e9); +} + +.toggle-switch input:checked + .toggle-slider::before { + transform: translateX(20px); +} + +/* Settings responsive */ +@media (max-width: 768px) { + .settings-tabs { + gap: 0.125rem; + } + + .tab-btn { + padding: 0.5rem 0.625rem; + font-size: 0.75rem; + } + + .tab-btn span { + display: none; + } + + .form-row { + grid-template-columns: 1fr; + } +} + +/* ============================================================ + Missing Classes & Fixes — safe additions only + ============================================================ */ + +/* Extended CSS Variables */ +:root { + --gray-50: #f8fafc; + --gray-900: #0f172a; +} + +/* ── btn-outline ─────────────────────────────────────────── */ +.btn-outline { + background: transparent; + border: 2px solid var(--gray-300); + color: var(--gray-700); +} +.btn-outline:hover { + border-color: var(--primary); + color: var(--primary); + background: rgba(14, 165, 233, 0.05); + transform: translateY(-1px); +} +.btn-outline.btn-danger, +.btn.btn-outline.btn-danger { + border-color: var(--danger); + color: var(--danger); + background: transparent; +} +.btn-outline.btn-danger:hover { + background: var(--danger); + color: var(--white); +} + +/* ── form-control & form-select ──────────────────────────── */ +.form-control, +.form-select { + width: 100%; + padding: 0.75rem 1rem; + border: 2px solid var(--gray-200); + border-radius: var(--radius-md); + font-size: 0.95rem; + font-family: var(--font-main); + color: var(--gray-700); + background: var(--white); + transition: border-color 0.3s ease, box-shadow 0.3s ease; +} +.form-control:focus, +.form-select:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 4px rgba(14, 165, 233, 0.1); +} +.form-control textarea, +textarea.form-control { + resize: vertical; + min-height: 100px; +} + +/* Standalone .form-group (outside .edit-form) */ +.form-group { + margin-bottom: 1.25rem; +} +.form-group > label { + display: block; + font-weight: 500; + color: var(--gray-700); + margin-bottom: 0.5rem; + font-size: 0.9rem; +} + +/* ── Utility classes ─────────────────────────────────────── */ +.opacity-50 { opacity: 0.5; } +.text-danger { color: var(--danger) !important; } +.text-center { text-align: center; } +.text-success { color: var(--success) !important; } + +/* ── btn-icon ────────────────────────────────────────────── */ +.btn.btn-icon, +.btn-icon { + width: 34px; + height: 34px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm); + font-size: 0.875rem; +} + +/* ── content-description ─────────────────────────────────── */ +.content-description { + color: var(--gray-500); + font-size: 0.9rem; + margin: 0; +} + +/* ── User dropdown active state (JS-toggled) ─────────────── */ +.user-dropdown.active .dropdown-menu { + opacity: 1; + visibility: visible; + transform: translateY(0); +} + +/* ── Modal aliases (modal-backdrop / modal-container) ────── */ +.modal-backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.5); + border-radius: inherit; +} +.modal-container { + position: relative; + background: var(--white); + border-radius: var(--radius-lg); + width: 90%; + max-width: 600px; + max-height: 90vh; + overflow-y: auto; + box-shadow: var(--shadow-lg); + transform: scale(0.9); + transition: transform 0.3s ease; +} +.modal.active .modal-container { + transform: scale(1); +} +.modal-lg, +.modal-content.modal-lg, +.modal-container.modal-lg { + max-width: 760px; +} +.modal-sm, +.modal-content.modal-sm, +.modal-container.modal-sm { + max-width: 440px; +} + +/* ── Services admin grid ─────────────────────────────────── */ +.services-admin-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1.5rem; + padding: 1.5rem 0; +} +.service-admin-card { + background: var(--white); + border-radius: var(--radius-md); + padding: 1.5rem; + box-shadow: var(--shadow-sm); + border: 1px solid var(--gray-200); + transition: all 0.3s ease; +} +.service-admin-card:hover { + box-shadow: var(--shadow-md); + border-color: var(--primary); +} +.service-admin-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 1rem; +} +.service-admin-card .service-icon { + width: 48px; + height: 48px; + border-radius: var(--radius-sm); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.25rem; +} +.service-admin-card .service-actions { + display: flex; + gap: 0.5rem; + align-items: center; +} +.service-admin-card h3 { + font-size: 1rem; + font-weight: 600; + color: var(--dark); + margin-bottom: 0.5rem; +} +.service-admin-card > p { + font-size: 0.875rem; + color: var(--gray-500); + margin-bottom: 1rem; +} +.service-meta { + font-size: 0.8rem; + color: var(--gray-400); + display: flex; + gap: 1rem; + align-items: center; +} + +/* ── Portfolio admin grid & cards ────────────────────────── */ +.portfolio-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1.5rem; + padding: 1.5rem; +} +.portfolio-admin-card { + background: var(--white); + border-radius: var(--radius-md); + overflow: hidden; + box-shadow: var(--shadow-sm); + border: 1px solid var(--gray-200); + transition: all 0.3s ease; +} +.portfolio-admin-card:hover { + box-shadow: var(--shadow-md); + transform: translateY(-3px); +} +.portfolio-admin-image { + position: relative; + height: 180px; + overflow: hidden; + background: var(--gray-100); +} +.portfolio-admin-image img { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.3s ease; +} +.portfolio-admin-card:hover .portfolio-admin-image img { + transform: scale(1.05); +} +.portfolio-status { + position: absolute; + top: 0.75rem; + left: 0.75rem; + padding: 0.25rem 0.625rem; + border-radius: 20px; + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} +.portfolio-status.published { background: rgba(16,185,129,0.9); color: #fff; } +.portfolio-status.draft { background: rgba(245,158,11,0.9); color: #fff; } +.portfolio-featured { + position: absolute; + top: 0.75rem; + right: 0.75rem; + background: #fbbf24; + color: #fff; + width: 28px; + height: 28px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; +} +.portfolio-admin-content { + padding: 1.25rem; +} +.portfolio-admin-content h3 { + font-size: 1rem; + font-weight: 600; + color: var(--dark); + margin-bottom: 0.25rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.portfolio-category { + font-size: 0.8rem; + color: var(--primary); + font-weight: 500; + text-transform: capitalize; + margin-bottom: 0.25rem; +} +.portfolio-client { + font-size: 0.8rem; + color: var(--gray-500); + margin-bottom: 1rem; +} +.portfolio-admin-actions { + display: flex; + gap: 0.5rem; + padding-top: 1rem; + border-top: 1px solid var(--gray-100); +} + +/* ── Portfolio / Content editor layout ───────────────────── */ +.editor-grid { + display: grid; + grid-template-columns: 1fr 320px; + gap: 2rem; +} +.editor-main, +.editor-sidebar { + display: flex; + flex-direction: column; + gap: 1.5rem; +} +.editor-actions { + display: flex; + gap: 0.75rem; +} +.editor-textarea { + min-height: 250px; + resize: vertical; + font-family: inherit; + line-height: 1.6; +} +@media (max-width: 1024px) { + .editor-grid { + grid-template-columns: 1fr; + } + .editor-sidebar { + order: -1; + } +} + +/* ── Image / file upload areas ───────────────────────────── */ +.image-upload-area { + border: 2px dashed var(--gray-300); + border-radius: var(--radius-md); + padding: 2rem 1.5rem; + text-align: center; + cursor: pointer; + transition: all 0.3s ease; + background-size: cover; + background-position: center; + min-height: 140px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; +} +.image-upload-area:hover { + border-color: var(--primary); + background-color: rgba(14, 165, 233, 0.02); +} +.image-upload-area i { font-size: 2rem; color: var(--gray-400); } +.image-upload-area p { color: var(--gray-600); font-weight: 500; margin: 0; } +.image-upload-area span { font-size: 0.8rem; color: var(--gray-400); } + +.file-upload-area { + border: 2px dashed var(--gray-300); + border-radius: var(--radius-md); + padding: 1.5rem; + text-align: center; + cursor: pointer; + transition: all 0.3s ease; + position: relative; +} +.file-upload-area:hover { border-color: var(--primary); } +.file-upload-area i { font-size: 2rem; color: var(--gray-400); display: block; margin-bottom: 0.5rem; } +.file-upload-area p { color: var(--gray-600); font-weight: 500; margin-bottom: 0.25rem; } +.file-upload-area span { font-size: 0.75rem; color: var(--gray-400); } +.file-upload-area input[type="file"] { + position: absolute; + inset: 0; + opacity: 0; + cursor: pointer; + width: 100%; + height: 100%; +} + +/* ── Gallery upload / preview ────────────────────────────── */ +.gallery-upload-area { + border: 2px dashed var(--gray-300); + border-radius: var(--radius-md); + padding: 1.5rem; + text-align: center; + cursor: pointer; + transition: all 0.3s ease; +} +.gallery-upload-area:hover { border-color: var(--primary); } +.gallery-upload-area i { font-size: 1.75rem; color: var(--gray-400); display: block; margin-bottom: 0.5rem; } +.gallery-upload-area p { color: var(--gray-600); margin: 0; } +.gallery-preview { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.5rem; + margin-top: 1rem; +} +.gallery-preview img { + width: 100%; + aspect-ratio: 1; + object-fit: cover; + border-radius: var(--radius-sm); +} +.gallery-preview-item { + position: relative; + border-radius: var(--radius-sm); + overflow: hidden; + aspect-ratio: 1; +} +.gallery-preview-item img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.gallery-remove-btn { + position: absolute; + top: 0.25rem; + right: 0.25rem; + width: 22px; + height: 22px; + border-radius: 50%; + background: rgba(0,0,0,0.6); + color: #fff; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.625rem; + padding: 0; + line-height: 1; +} +.gallery-remove-btn:hover { background: var(--danger, #ef4444); } + +/* ── Project results grid ────────────────────────────────── */ +.results-grid { + display: flex; + flex-direction: column; + gap: 0.75rem; +} +.result-item { + display: grid; + grid-template-columns: 1fr 2fr; + gap: 0.75rem; +} + +/* ── Checkbox wrapper ────────────────────────────────────── */ +.checkbox-wrapper { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + font-weight: 500; + color: var(--gray-700); + font-size: 0.9rem; +} +.checkbox-wrapper input[type="checkbox"] { + width: 16px; + height: 16px; + cursor: pointer; + accent-color: var(--primary); +} + +/* ── Subscribers stat cards small variant ────────────────── */ +.stats-row .stat-card h4 { + font-size: 0.85rem; + font-weight: 600; + color: var(--gray-600); + margin-bottom: 0.25rem; +} +.stats-row .stat-card h2 { + font-size: 1.75rem; + font-weight: 700; + color: var(--dark); + line-height: 1; +} + +/* ── Service card features list ─────────────────────────── */ +.service-features-list { + list-style: none; + padding: 0; + margin: 0.5rem 0 0; + display: flex; + flex-direction: column; + gap: 0.25rem; +} +.service-features-list li { + font-size: 0.8125rem; + color: var(--gray-600); + display: flex; + align-items: center; + gap: 0.375rem; +} +.service-features-list li i { + color: var(--success, #10b981); + font-size: 0.7rem; + flex-shrink: 0; +} + +/* ── Responsive additions ────────────────────────────────── */ +@media (max-width: 768px) { + .services-admin-grid, + .portfolio-grid { + grid-template-columns: 1fr; + } + .editor-grid { + grid-template-columns: 1fr; + } + .result-item { + grid-template-columns: 1fr; + } +} diff --git a/Public HTML/admin/dashboard.html b/Public HTML/admin/dashboard.html new file mode 100755 index 0000000..d1d6e0d --- /dev/null +++ b/Public HTML/admin/dashboard.html @@ -0,0 +1,429 @@ + + + + + + + Dashboard - MSPE Admin + + + + + + +
+ + + + +
+ +
+
+ +

Dashboard

+
+ +
+ + +
+ +
+ +
+
+ Admin +
+ +
+ + +
+
+
+
+ + +
+ +
+
+
+ +
+
+

0

+

Unread Messages

+
+
+ 0 total +
+
+ +
+
+ +
+
+

0

+

Pending Bookings

+
+
+ 0 this week +
+
+ +
+
+ +
+
+

0

+

Published Articles

+
+
+ 0 drafts +
+
+ +
+
+ +
+
+

0

+

Subscribers

+
+
+ 0 active +
+
+
+ + + + + +
+
+
+

Recent News

+ View All +
+
+
+ +
+ Loading... +
+
+
+
+ +
+
+

Recent Messages

+ View All +
+
+
+
+ Loading... +
+
+
+
+
+
+
+
+ + + + + diff --git a/Public HTML/admin/index.html b/Public HTML/admin/index.html new file mode 100755 index 0000000..14e8a6c --- /dev/null +++ b/Public HTML/admin/index.html @@ -0,0 +1,141 @@ + + + + + + + Admin Login - MSPE + + + + + + +
+
+ + + + + +
+ +
+
+
+
+
+
+ + + + diff --git a/Public HTML/admin/js/admin.js b/Public HTML/admin/js/admin.js new file mode 100755 index 0000000..123899e --- /dev/null +++ b/Public HTML/admin/js/admin.js @@ -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 = ` + + + `; + 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 +}; diff --git a/Public HTML/admin/media.html b/Public HTML/admin/media.html new file mode 100755 index 0000000..4826cfa --- /dev/null +++ b/Public HTML/admin/media.html @@ -0,0 +1,731 @@ + + + + + + + Media Library - MSPE Admin + + + + + + +
+ + + + +
+ +
+
+ +

Media Library

+
+ +
+ + +
+
+ Admin +
+ +
+ + +
+
+
+
+ + +
+
+
+
+ +
+ 0 Bytes + Used of 5 GB +
+
+
+
+
+
+
+
+ + 0 Images +
+
+ + 0 Documents +
+
+ + 0 Videos +
+
+
+ + +
+
+ + + +
+
+ +
+ + +
+
+
+ + +
+
+ + news + 0 files +
+
+ + portfolio + 0 files +
+
+ + team + 0 files +
+
+ + clients + 0 files +
+
+ + +
+
+ +

Loading media...

+
+
+ + + +
+
+
+ + + + + + + + + + + diff --git a/Public HTML/admin/messages.html b/Public HTML/admin/messages.html new file mode 100755 index 0000000..2407732 --- /dev/null +++ b/Public HTML/admin/messages.html @@ -0,0 +1,741 @@ + + + + + + + Messages - MSPE Admin + + + + + + + +
+ + +
+
+
+ +

Messages

+
+ +
+ + +
+
+ Admin +
+ +
+ + +
+
+
+
+ +
+
+
+
+

Inbox

+ 0 unread +
+ +
+ +
+ +
+
+ +

Loading messages...

+
+
+
+ +
+
+ +

Select a message to view details

+
+
+
+
+
+
+ + + + + diff --git a/Public HTML/admin/news.html b/Public HTML/admin/news.html new file mode 100755 index 0000000..ad4eb72 --- /dev/null +++ b/Public HTML/admin/news.html @@ -0,0 +1,708 @@ + + + + + + + News Management - MSPE Admin + + + + + + +
+ + + + +
+ +
+
+ +

News & Events

+
+ +
+
+
+ Admin +
+ +
+ + +
+
+
+
+ + +
+ +
+
+
+
+ +
+
+ +
+ +
+ +
+ +
+
+ + + + + + + + + + + + + + +
+ + TitleCategoryStatusDateActions
+
+ + +
+
+ + + +
+
+
+ + + + + + + + diff --git a/Public HTML/admin/pages.html b/Public HTML/admin/pages.html new file mode 100755 index 0000000..f94c4e4 --- /dev/null +++ b/Public HTML/admin/pages.html @@ -0,0 +1,512 @@ + + + + + + + Pages Management - MSPE Admin + + + + + + +
+ + + + +
+ +
+
+ +

Pages Management

+
+ +
+
+
+ Admin +
+ +
+ + +
+
+
+
+ + +
+
+

Manage your website pages and their content sections

+
+ + +
+
+ +

Loading pages...

+
+
+ + +
+
+

Global Sections

+

These sections appear across multiple pages

+
+
+
+
+ Loading... +
+
+
+
+
+
+
+ + + + + + + + + + + diff --git a/Public HTML/admin/portfolio.html b/Public HTML/admin/portfolio.html new file mode 100755 index 0000000..0f3125b --- /dev/null +++ b/Public HTML/admin/portfolio.html @@ -0,0 +1,708 @@ + + + + + + + Portfolio Management - MSPE Admin + + + + + + +
+ + + + +
+ +
+
+ +

Portfolio Management

+
+ +
+
+
+ Admin +
+ +
+ + +
+
+
+
+ + +
+ +
+
+
+
+ +
+
+ +
+ +
+ +
+ +
+
+
+ +

Loading projects...

+
+
+
+
+ + + +
+
+
+ + + + + diff --git a/Public HTML/admin/reset-password.html b/Public HTML/admin/reset-password.html new file mode 100755 index 0000000..a66916d --- /dev/null +++ b/Public HTML/admin/reset-password.html @@ -0,0 +1,147 @@ + + + + + + + Reset Password - MSPE Admin + + + + + + +
+
+ + + + + + + + + +
+
+ + + + diff --git a/Public HTML/admin/services.html b/Public HTML/admin/services.html new file mode 100755 index 0000000..d70328d --- /dev/null +++ b/Public HTML/admin/services.html @@ -0,0 +1,411 @@ + + + + + + + Services Management - MSPE Admin + + + + + + +
+ + + + +
+ +
+
+ +

Services Management

+
+ +
+
+
+ Admin +
+ +
+ + +
+
+
+
+ + +
+
+

Manage your service offerings displayed on the website

+ +
+ +
+
+ +

Loading services...

+
+
+
+
+
+ + + + + + + + diff --git a/Public HTML/admin/settings.html b/Public HTML/admin/settings.html new file mode 100755 index 0000000..ca01fc3 --- /dev/null +++ b/Public HTML/admin/settings.html @@ -0,0 +1,910 @@ + + + + + + + Site Settings - MSPE Admin + + + + + + +
+ + + + +
+ +
+
+ +

Site Settings

+
+ +
+
+
+ Admin +
+ +
+ + +
+
+
+
+ + +
+ +
+ + + + + + + +
+ + +
+
+
+

General Settings

+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+ + + +
+
+
+ +
+ Favicon + + +
+
+
+ +
+ + +
+ + +
+
+
+ + +
+
+
+

Contact Information

+
+
+
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ + +
+
+
+ + +
+
+
+

Social Media Links

+
+
+ + + + + + + + + + + + + +
+
+
+ + +
+
+
+

SEO Settings

+
+
+
+ + + Recommended: 50-60 characters +
+ +
+ + + Recommended: 150-160 characters +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+
+ + +
+
+
+

Theme & Colors

+
+
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+ + +
+
+
+ + +
+
+
+

Email Delivery & Password Reset

+
+
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + + Stored in site settings. Use a dedicated mailbox/app password. +
+ +
+ + + Reset tokens will be appended as ?token=... +
+ +
+
+ + +
+
+ +
+
+ + +
+
+
+ + +
+
+
+

Third-Party Integrations

+
+
+
+
+ +
+

Google reCAPTCHA

+

Protect forms from spam

+
+
+ +
+ +
+
+ +
+

Mailchimp

+

Email marketing integration

+
+
+ +
+ +
+
+ +
+

Slack Notifications

+

Get notified of new messages

+
+
+ +
+ +
+
+ +
+

Calendly

+

Booking integration

+
+
+ +
+ +
+

Calendly Settings

+
+ + +
+
+ + +
+
+
+
+
+
+ + + + + diff --git a/Public HTML/admin/subscribers.html b/Public HTML/admin/subscribers.html new file mode 100755 index 0000000..90e1dba --- /dev/null +++ b/Public HTML/admin/subscribers.html @@ -0,0 +1,531 @@ + + + + + + + Subscribers - MSPE Admin + + + + + + + +
+ + +
+
+
+ +

Newsletter Subscribers

+
+ +
+ + +
+
+ Admin +
+ +
+ + +
+
+
+
+ +
+
+
+

Total Subscribers

+
0
+
+
+

Active Subscribers

+
0
+
+
+

This Month

+
0
+
+
+ +
+
+

Subscriber List

+ +
+ + + + + + + + + + + + + + + + +
NameEmailSubscribedStatusActions
+
+ +

Loading subscribers...

+
+
+
+
+
+
+ + + + + diff --git a/Public HTML/admin/team.html b/Public HTML/admin/team.html new file mode 100755 index 0000000..23aab8a --- /dev/null +++ b/Public HTML/admin/team.html @@ -0,0 +1,508 @@ + + + + + + + Team Management - MSPE Admin + + + + + + +
+ + + + +
+ +
+
+ +

Team Management

+
+ +
+ + +
+
+ Admin +
+ +
+ + +
+
+
+
+ + +
+
+

Manage your team members displayed on the website

+
+ +
+ + +
+
+
+ + +
+
+ +

Loading team members...

+
+
+
+
+
+ + + + + + + + diff --git a/Public HTML/admin/testimonials.html b/Public HTML/admin/testimonials.html new file mode 100755 index 0000000..96a7980 --- /dev/null +++ b/Public HTML/admin/testimonials.html @@ -0,0 +1,546 @@ + + + + + + + Testimonials - MSPE Admin + + + + + + +
+ + + + +
+ +
+
+ +

Testimonials

+
+ +
+ + +
+
+ Admin +
+ +
+ + +
+
+
+
+ + +
+
+

Manage client testimonials and reviews displayed on the website

+
+ + +
+
+
+ +
+
+ 0 + Total Testimonials +
+
+
+
+ +
+
+ 0 + Published +
+
+
+
+ +
+
+ 0 + Avg Rating +
+
+
+
+ +
+
+ 0 + Pending Review +
+
+
+ + +
+
+ +

Loading testimonials...

+
+
+
+
+
+ + + + + + + + diff --git a/Public HTML/admin/users.html b/Public HTML/admin/users.html new file mode 100755 index 0000000..8b756ca --- /dev/null +++ b/Public HTML/admin/users.html @@ -0,0 +1,603 @@ + + + + + + + Admin Users - MSPE Admin + + + + + + +
+ + + + +
+ +
+
+ +

Admin Users

+
+ +
+ + +
+
+ Admin +
+ +
+ + +
+
+
+
+ + +
+
+

Manage admin user accounts and permissions

+
+ + +
+
+

User Accounts

+
+
+ + + + + + + + + + + + + + + + +
UserEmailRoleLast LoginStatusActions
+ Loading users... +
+
+
+ + +
+
+

Roles & Permissions

+
+
+
+
+
+ Super Admin + 1 user +
+
    +
  • Full system access
  • +
  • Manage all users
  • +
  • Site settings
  • +
  • Delete content
  • +
  • View analytics
  • +
+
+ +
+
+ Editor + 1 user +
+
    +
  • Create/edit content
  • +
  • Publish content
  • +
  • Manage media
  • +
  • View messages
  • +
  • Site settings
  • +
+
+ +
+
+ Contributor + 1 user +
+
    +
  • Create content
  • +
  • Publish content
  • +
  • Upload media
  • +
  • Delete content
  • +
  • Site settings
  • +
+
+
+
+
+ + +
+
+

Recent Activity

+ View All +
+
+
+
+ +
+

Super Admin logged in

+ 10 minutes ago +
+
+ +
+
+ +
+
+

Sarah Johnson edited "New IT Security Features"

+ 2 hours ago +
+
+ +
+
+ +
+
+

Sarah Johnson created new news article

+ Yesterday +
+
+ +
+
+ +
+
+

Super Admin updated site settings

+ 2 days ago +
+
+
+
+
+
+
+
+ + + + + + + + + + + diff --git a/Public HTML/api/.htaccess b/Public HTML/api/.htaccess new file mode 100755 index 0000000..152cde5 --- /dev/null +++ b/Public HTML/api/.htaccess @@ -0,0 +1,10 @@ +# Block dev/test/seed scripts in production + + + Require all denied + + + Order deny,allow + Deny from all + + diff --git a/Public HTML/api/auth.php b/Public HTML/api/auth.php new file mode 100755 index 0000000..b9d66cc --- /dev/null +++ b/Public HTML/api/auth.php @@ -0,0 +1,391 @@ + 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 = '

Password Reset

' + . '

A password reset was requested for your admin account.

' + . '

Reset Password

' + . '

This link is valid for 30 minutes.

'; + + $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']); + } + } + } +} diff --git a/Public HTML/api/bookings.php b/Public HTML/api/bookings.php new file mode 100755 index 0000000..e9dab51 --- /dev/null +++ b/Public HTML/api/bookings.php @@ -0,0 +1,605 @@ + 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 = '

Booking Received

' + . '

Thank you for booking a consultation with MSPE!

' + . '

Date: ' . $date . '

' + . '

Time: ' . $time . '

' + . '

Duration: ' . $booking['duration'] . ' minutes

' + . '

Your booking is currently pending confirmation.

'; + + $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 = '

Booking Confirmed!

' + . '

Your consultation with MSPE has been confirmed.

' + . '

Date: ' . $date . '

' + . '

Time: ' . $time . '

' + . '

Duration: ' . $booking['duration'] . ' minutes

' + . '

We look forward to speaking with you!

'; + + $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 = '

Booking Cancelled

' + . '

Your consultation with MSPE has been cancelled.

' + . '

If you would like to reschedule, please contact us at ' . ADMIN_EMAIL . '

'; + + $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 = '

New Booking Request

' + . '

Name: ' . htmlspecialchars($booking['first_name'] . ' ' . $booking['last_name']) . '

' + . '

Email: ' . htmlspecialchars($booking['email']) . '

' + . '

Phone: ' . htmlspecialchars($booking['phone']) . '

' + . '

Company: ' . htmlspecialchars($booking['company']) . '

' + . '

Date: ' . $booking['booking_date'] . '

' + . '

Time: ' . $booking['booking_time'] . '

' + . '

Duration: ' . $booking['duration'] . ' minutes

' + . '

Message:
' . nl2br(htmlspecialchars($booking['message'])) . '

'; + + $result = sendEmail($to, $subject, $html, $plain, $booking['email']); + if (!$result['success']) { + error_log('MSPE admin booking notification failed: ' . ($result['message'] ?? 'unknown')); + } +} diff --git a/Public HTML/api/config.php b/Public HTML/api/config.php new file mode 100755 index 0000000..8a7c913 --- /dev/null +++ b/Public HTML/api/config.php @@ -0,0 +1,773 @@ + 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 = '