mirror of
https://github.com/myronblair/epictravelexpeditions
synced 2026-06-30 17:50:08 -05:00
Initial commit
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
*.log
|
||||
.DS_Store
|
||||
*.swp
|
||||
|
||||
api/config.php
|
||||
uploads/
|
||||
@@ -0,0 +1,60 @@
|
||||
### Rewrite Rules Added by CyberPanel Rewrite Rule Generator
|
||||
|
||||
RewriteEngine On
|
||||
RewriteCond %{HTTPS} !=on
|
||||
RewriteRule ^/?(.*) https://%{SERVER_NAME}/$1 [R,L]
|
||||
|
||||
### End CyberPanel Generated Rules.
|
||||
|
||||
# Epic Travel & Expeditions - CyberPanel LiteSpeed Configuration
|
||||
DirectoryIndex index.html index.php
|
||||
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
RewriteBase /
|
||||
|
||||
# API Routes - Direct to PHP backend
|
||||
RewriteCond %{REQUEST_URI} ^/api/
|
||||
RewriteRule ^api/(.*)$ api/api/$1 [L,QSA]
|
||||
|
||||
# Admin setup page
|
||||
RewriteRule ^setup/?$ setup_password.php [L]
|
||||
|
||||
# Static files and directories - serve directly
|
||||
RewriteCond %{REQUEST_FILENAME} -f [OR]
|
||||
RewriteCond %{REQUEST_FILENAME} -d
|
||||
RewriteRule ^ - [L]
|
||||
|
||||
# React Router - All other routes to index.html
|
||||
RewriteRule ^ index.html [L]
|
||||
</IfModule>
|
||||
|
||||
# Security Headers
|
||||
<IfModule mod_headers.c>
|
||||
Header set X-Content-Type-Options "nosniff"
|
||||
Header set X-Frame-Options "SAMEORIGIN"
|
||||
Header set X-XSS-Protection "1; mode=block"
|
||||
</IfModule>
|
||||
|
||||
# Enable CORS for API
|
||||
<FilesMatch "\.(php)$">
|
||||
Header set Access-Control-Allow-Origin "*"
|
||||
Header set Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
|
||||
Header set Access-Control-Allow-Headers "Content-Type, Authorization"
|
||||
</FilesMatch>
|
||||
|
||||
# Disable directory browsing
|
||||
Options -Indexes +FollowSymLinks
|
||||
|
||||
# PHP Settings
|
||||
<IfModule mod_php.c>
|
||||
php_value upload_max_filesize 10M
|
||||
php_value post_max_size 10M
|
||||
php_value memory_limit 256M
|
||||
php_value max_execution_time 300
|
||||
</IfModule>
|
||||
|
||||
# Force use of index.html
|
||||
<IfModule mod_dir.c>
|
||||
DirectoryIndex index.html index.php
|
||||
</IfModule>
|
||||
@@ -0,0 +1,52 @@
|
||||
# Epic Travel & Expeditions - LiteSpeed .htaccess for CyberPanel
|
||||
# Optimized for LiteSpeed Web Server
|
||||
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
RewriteBase /api/
|
||||
|
||||
# Route all requests to index.php
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^(.*)$ index.php/$1 [L,QSA]
|
||||
</IfModule>
|
||||
|
||||
# LiteSpeed Cache Control
|
||||
<IfModule LiteSpeed>
|
||||
# Disable caching for API
|
||||
CacheLookup off
|
||||
</IfModule>
|
||||
|
||||
# Security Headers
|
||||
<IfModule mod_headers.c>
|
||||
Header set X-Content-Type-Options "nosniff"
|
||||
Header set X-Frame-Options "SAMEORIGIN"
|
||||
Header set X-XSS-Protection "1; mode=block"
|
||||
Header set X-Powered-By "Epic Travel API"
|
||||
</IfModule>
|
||||
|
||||
# Protect sensitive files
|
||||
<FilesMatch "^(config\.php|\.env)">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
|
||||
# PHP Settings (LiteSpeed compatible)
|
||||
<IfModule mod_php7.c>
|
||||
php_value upload_max_filesize 10M
|
||||
php_value post_max_size 10M
|
||||
php_value max_execution_time 300
|
||||
php_value max_input_time 300
|
||||
php_value memory_limit 256M
|
||||
</IfModule>
|
||||
|
||||
# Compression
|
||||
<IfModule mod_deflate.c>
|
||||
AddOutputFilterByType DEFLATE application/json
|
||||
AddOutputFilterByType DEFLATE text/plain
|
||||
AddOutputFilterByType DEFLATE text/html
|
||||
</IfModule>
|
||||
|
||||
# Browser Caching
|
||||
<IfModule mod_expires.c>
|
||||
ExpiresActive Off
|
||||
</IfModule>
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
/**
|
||||
* Authentication Endpoints
|
||||
*/
|
||||
|
||||
$db = Database::getInstance()->getConnection();
|
||||
|
||||
// Login endpoint
|
||||
if ($method === 'POST' && $id === 'login') {
|
||||
$input = getJsonInput();
|
||||
|
||||
// Validate input
|
||||
$errors = validateRequired($input, ['email', 'password']);
|
||||
if (!empty($errors)) {
|
||||
jsonResponse(['error' => implode(', ', $errors)], 400);
|
||||
}
|
||||
|
||||
$email = sanitizeString($input['email']);
|
||||
$password = $input['password'];
|
||||
|
||||
// Find admin user
|
||||
$stmt = $db->prepare("SELECT * FROM admin_users WHERE email = ?");
|
||||
$stmt->execute([$email]);
|
||||
$admin = $stmt->fetch();
|
||||
|
||||
if (!$admin) {
|
||||
jsonResponse(['error' => 'Invalid email or password'], 401);
|
||||
}
|
||||
|
||||
// Verify password
|
||||
if (!password_verify($password, $admin['password_hash'])) {
|
||||
jsonResponse(['error' => 'Invalid email or password'], 401);
|
||||
}
|
||||
|
||||
// Create token
|
||||
$token = JWT::createToken($email);
|
||||
|
||||
jsonResponse([
|
||||
'access_token' => $token,
|
||||
'token_type' => 'bearer',
|
||||
'email' => $email
|
||||
]);
|
||||
}
|
||||
|
||||
// Verify token endpoint
|
||||
if ($method === 'POST' && $id === 'verify') {
|
||||
$payload = requireAuth();
|
||||
|
||||
jsonResponse([
|
||||
'valid' => true,
|
||||
'email' => $payload['sub']
|
||||
]);
|
||||
}
|
||||
|
||||
jsonResponse(['error' => 'Invalid auth endpoint'], 404);
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
/**
|
||||
* Destination Categories Endpoints
|
||||
*/
|
||||
$db = Database::getInstance()->getConnection();
|
||||
|
||||
// GET all categories (public - needed for destination forms)
|
||||
if ($method === 'GET' && !$id) {
|
||||
$stmt = $db->query('SELECT id, name FROM destination_categories ORDER BY name');
|
||||
jsonResponse($stmt->fetchAll());
|
||||
}
|
||||
|
||||
// POST add category (admin)
|
||||
if ($method === 'POST') {
|
||||
requireAuth();
|
||||
$input = getJsonInput();
|
||||
$errors = validateRequired($input, ['name']);
|
||||
if (!empty($errors)) jsonResponse(['error' => implode(', ', $errors)], 400);
|
||||
$name = sanitizeString($input['name']);
|
||||
try {
|
||||
$stmt = $db->prepare('INSERT INTO destination_categories (name) VALUES (?)');
|
||||
$stmt->execute([$name]);
|
||||
$newId = $db->lastInsertId();
|
||||
jsonResponse(['id' => $newId, 'name' => $name], 201);
|
||||
} catch (Exception $e) {
|
||||
jsonResponse(['error' => 'Category already exists'], 409);
|
||||
}
|
||||
}
|
||||
|
||||
// PUT rename category (admin) - also updates all destinations using it
|
||||
if ($method === 'PUT' && $id) {
|
||||
requireAuth();
|
||||
$input = getJsonInput();
|
||||
$errors = validateRequired($input, ['name']);
|
||||
if (!empty($errors)) jsonResponse(['error' => implode(', ', $errors)], 400);
|
||||
$newName = sanitizeString($input['name']);
|
||||
$stmt = $db->prepare('SELECT name FROM destination_categories WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$old = $stmt->fetch();
|
||||
if (!$old) jsonResponse(['error' => 'Category not found'], 404);
|
||||
try {
|
||||
$db->prepare('UPDATE destination_categories SET name = ? WHERE id = ?')->execute([$newName, $id]);
|
||||
$db->prepare('UPDATE destinations SET category = ? WHERE category = ?')->execute([$newName, $old['name']]);
|
||||
jsonResponse(['id' => $id, 'name' => $newName]);
|
||||
} catch (Exception $e) {
|
||||
jsonResponse(['error' => 'Category name already exists'], 409);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE category (admin) - only if no destinations use it
|
||||
if ($method === 'DELETE' && $id) {
|
||||
requireAuth();
|
||||
$stmt = $db->prepare('SELECT name FROM destination_categories WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$cat = $stmt->fetch();
|
||||
if (!$cat) jsonResponse(['error' => 'Category not found'], 404);
|
||||
$stmt = $db->prepare('SELECT COUNT(*) FROM destinations WHERE category = ?');
|
||||
$stmt->execute([$cat['name']]);
|
||||
if ($stmt->fetchColumn() > 0) jsonResponse(['error' => 'Cannot delete — destinations are using this category'], 400);
|
||||
$db->prepare('DELETE FROM destination_categories WHERE id = ?')->execute([$id]);
|
||||
jsonResponse(['message' => 'Category deleted']);
|
||||
}
|
||||
|
||||
jsonResponse(['error' => 'Invalid categories endpoint'], 404);
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
/**
|
||||
* Contact Form Endpoint
|
||||
*/
|
||||
|
||||
$db = Database::getInstance()->getConnection();
|
||||
|
||||
if ($method === 'POST') {
|
||||
$input = getJsonInput();
|
||||
|
||||
$errors = validateRequired($input, ['name', 'email', 'message']);
|
||||
if (!empty($errors)) {
|
||||
jsonResponse(['error' => implode(', ', $errors)], 400);
|
||||
}
|
||||
|
||||
if (!isValidEmail($input['email'])) {
|
||||
jsonResponse(['error' => 'Invalid email address'], 400);
|
||||
}
|
||||
|
||||
$name = sanitizeString($input['name']);
|
||||
$email = sanitizeString($input['email']);
|
||||
$message = $input['message'];
|
||||
$id = generateUuid();
|
||||
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO contacts (id, name, email, message, created_at)
|
||||
VALUES (?, ?, ?, ?, NOW())
|
||||
");
|
||||
$stmt->execute([$id, $name, $email, $message]);
|
||||
|
||||
// Send admin alert and customer confirmation via SendGrid
|
||||
sendContactAlert($name, $email, $message);
|
||||
sendContactConfirmation($email, $name);
|
||||
|
||||
jsonResponse(['message' => 'Contact form submitted successfully']);
|
||||
}
|
||||
|
||||
jsonResponse(['error' => 'Method not allowed'], 405);
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
/**
|
||||
* Destinations CRUD Endpoints
|
||||
*/
|
||||
|
||||
$db = Database::getInstance()->getConnection();
|
||||
|
||||
// GET all destinations or single destination
|
||||
if ($method === 'GET') {
|
||||
if ($id) {
|
||||
// Get single destination
|
||||
$stmt = $db->prepare("SELECT * FROM destinations WHERE id = ?");
|
||||
$stmt->execute([$id]);
|
||||
$destination = $stmt->fetch();
|
||||
|
||||
if (!$destination) {
|
||||
jsonResponse(['error' => 'Destination not found'], 404);
|
||||
}
|
||||
|
||||
jsonResponse($destination);
|
||||
} else {
|
||||
// Get all destinations with optional filtering
|
||||
$category = isset($_GET['category']) ? sanitizeString($_GET['category']) : null;
|
||||
$search = isset($_GET['search']) ? sanitizeString($_GET['search']) : null;
|
||||
|
||||
$sql = "SELECT * FROM destinations WHERE 1=1";
|
||||
$params = [];
|
||||
|
||||
if ($category && $category !== 'All') {
|
||||
$sql .= " AND category = ?";
|
||||
$params[] = $category;
|
||||
}
|
||||
|
||||
if ($search) {
|
||||
$sql .= " AND (name LIKE ? OR location LIKE ?)";
|
||||
$params[] = "%$search%";
|
||||
$params[] = "%$search%";
|
||||
}
|
||||
|
||||
$sql .= " LIMIT 100";
|
||||
|
||||
$stmt = $db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$destinations = $stmt->fetchAll();
|
||||
|
||||
jsonResponse($destinations);
|
||||
}
|
||||
}
|
||||
|
||||
// POST create new destination (admin only)
|
||||
if ($method === 'POST') {
|
||||
requireAuth();
|
||||
|
||||
$input = getJsonInput();
|
||||
|
||||
$errors = validateRequired($input, ['name', 'location', 'description', 'image', 'category', 'rating', 'price']);
|
||||
if (!empty($errors)) {
|
||||
jsonResponse(['error' => implode(', ', $errors)], 400);
|
||||
}
|
||||
|
||||
$id = generateUuid();
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO destinations (id, name, location, description, image, category, rating, price, currency, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
|
||||
");
|
||||
|
||||
$stmt->execute([
|
||||
$id,
|
||||
sanitizeString($input['name']),
|
||||
sanitizeString($input['location']),
|
||||
$input['description'],
|
||||
$input['image'],
|
||||
$input['category'],
|
||||
$input['rating'],
|
||||
$input['price'],
|
||||
isset($input['currency']) ? $input['currency'] : 'USD'
|
||||
]);
|
||||
|
||||
// Fetch created destination
|
||||
$stmt = $db->prepare("SELECT * FROM destinations WHERE id = ?");
|
||||
$stmt->execute([$id]);
|
||||
$destination = $stmt->fetch();
|
||||
|
||||
jsonResponse($destination, 201);
|
||||
}
|
||||
|
||||
// PUT update destination (admin only)
|
||||
if ($method === 'PUT' && $id) {
|
||||
requireAuth();
|
||||
|
||||
$input = getJsonInput();
|
||||
|
||||
// Build update query dynamically
|
||||
$updates = [];
|
||||
$params = [];
|
||||
|
||||
$allowedFields = ['name', 'location', 'description', 'image', 'category', 'rating', 'price', 'currency'];
|
||||
|
||||
foreach ($allowedFields as $field) {
|
||||
if (isset($input[$field])) {
|
||||
$updates[] = "$field = ?";
|
||||
$params[] = $field === 'description' ? $input[$field] : sanitizeString($input[$field]);
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($updates)) {
|
||||
jsonResponse(['error' => 'No fields to update'], 400);
|
||||
}
|
||||
|
||||
$params[] = $id;
|
||||
|
||||
$sql = "UPDATE destinations SET " . implode(', ', $updates) . " WHERE id = ?";
|
||||
$stmt = $db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
// Fetch updated destination
|
||||
$stmt = $db->prepare("SELECT * FROM destinations WHERE id = ?");
|
||||
$stmt->execute([$id]);
|
||||
$destination = $stmt->fetch();
|
||||
|
||||
jsonResponse($destination);
|
||||
}
|
||||
|
||||
// DELETE destination (admin only)
|
||||
if ($method === 'DELETE' && $id) {
|
||||
requireAuth();
|
||||
|
||||
// Delete destination (cascades to specials)
|
||||
$stmt = $db->prepare("DELETE FROM destinations WHERE id = ?");
|
||||
$stmt->execute([$id]);
|
||||
|
||||
if ($stmt->rowCount() === 0) {
|
||||
jsonResponse(['error' => 'Destination not found'], 404);
|
||||
}
|
||||
|
||||
jsonResponse(['message' => 'Destination deleted successfully']);
|
||||
}
|
||||
|
||||
jsonResponse(['error' => 'Invalid destinations endpoint'], 404);
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
/**
|
||||
* Newsletter Subscription Endpoint
|
||||
*/
|
||||
|
||||
$db = Database::getInstance()->getConnection();
|
||||
|
||||
if ($method === 'POST' && $id === 'subscribe') {
|
||||
$input = getJsonInput();
|
||||
|
||||
if (!isset($input['email']) || !isValidEmail($input['email'])) {
|
||||
jsonResponse(['error' => 'Valid email address is required'], 400);
|
||||
}
|
||||
|
||||
$email = sanitizeString($input['email']);
|
||||
|
||||
// Check if already subscribed
|
||||
$stmt = $db->prepare("SELECT id FROM newsletter_subscribers WHERE email = ?");
|
||||
$stmt->execute([$email]);
|
||||
|
||||
if ($stmt->fetch()) {
|
||||
jsonResponse(['message' => 'Email already subscribed']);
|
||||
}
|
||||
|
||||
$id = generateUuid();
|
||||
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO newsletter_subscribers (id, email, subscribed_at)
|
||||
VALUES (?, ?, NOW())
|
||||
");
|
||||
|
||||
$stmt->execute([$id, $email]);
|
||||
|
||||
jsonResponse(['message' => 'Successfully subscribed to newsletter']);
|
||||
}
|
||||
|
||||
jsonResponse(['error' => 'Invalid newsletter endpoint'], 404);
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
/**
|
||||
* Weekly Specials CRUD Endpoints
|
||||
*/
|
||||
|
||||
$db = Database::getInstance()->getConnection();
|
||||
|
||||
// GET all specials
|
||||
if ($method === 'GET' && !$id) {
|
||||
$stmt = $db->query("SELECT * FROM specials LIMIT 100");
|
||||
$specials = $stmt->fetchAll();
|
||||
|
||||
// Parse JSON highlights
|
||||
foreach ($specials as &$special) {
|
||||
$special['highlights'] = json_decode($special['highlights'], true);
|
||||
}
|
||||
|
||||
jsonResponse($specials);
|
||||
}
|
||||
|
||||
// POST create special (admin only)
|
||||
if ($method === 'POST') {
|
||||
requireAuth();
|
||||
|
||||
$input = getJsonInput();
|
||||
|
||||
$errors = validateRequired($input, ['destination_id', 'discount', 'end_date', 'highlights']);
|
||||
if (!empty($errors)) {
|
||||
jsonResponse(['error' => implode(', ', $errors)], 400);
|
||||
}
|
||||
|
||||
// Check if destination exists
|
||||
$stmt = $db->prepare("SELECT id FROM destinations WHERE id = ?");
|
||||
$stmt->execute([$input['destination_id']]);
|
||||
if (!$stmt->fetch()) {
|
||||
jsonResponse(['error' => 'Destination not found'], 404);
|
||||
}
|
||||
|
||||
// Check if special already exists for this destination
|
||||
$stmt = $db->prepare("SELECT id FROM specials WHERE destination_id = ?");
|
||||
$stmt->execute([$input['destination_id']]);
|
||||
if ($stmt->fetch()) {
|
||||
jsonResponse(['error' => 'Special already exists for this destination'], 400);
|
||||
}
|
||||
|
||||
$id = generateUuid();
|
||||
$highlights = json_encode($input['highlights']);
|
||||
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO specials (id, destination_id, discount, end_date, highlights, image_path, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, NOW())
|
||||
");
|
||||
|
||||
$stmt->execute([
|
||||
$id,
|
||||
$input['destination_id'],
|
||||
$input['discount'],
|
||||
$input['end_date'],
|
||||
$highlights,
|
||||
isset($input['image_path']) ? $input['image_path'] : null
|
||||
]);
|
||||
|
||||
// Fetch created special
|
||||
$stmt = $db->prepare("SELECT * FROM specials WHERE id = ?");
|
||||
$stmt->execute([$id]);
|
||||
$special = $stmt->fetch();
|
||||
$special['highlights'] = json_decode($special['highlights'], true);
|
||||
|
||||
jsonResponse($special, 201);
|
||||
}
|
||||
|
||||
// PUT update special (admin only)
|
||||
if ($method === 'PUT' && $id) {
|
||||
requireAuth();
|
||||
|
||||
$input = getJsonInput();
|
||||
|
||||
$updates = [];
|
||||
$params = [];
|
||||
|
||||
if (isset($input['discount'])) {
|
||||
$updates[] = "discount = ?";
|
||||
$params[] = $input['discount'];
|
||||
}
|
||||
|
||||
if (isset($input['end_date'])) {
|
||||
$updates[] = "end_date = ?";
|
||||
$params[] = $input['end_date'];
|
||||
}
|
||||
|
||||
if (isset($input['highlights'])) {
|
||||
$updates[] = "highlights = ?";
|
||||
$params[] = json_encode($input['highlights']);
|
||||
}
|
||||
|
||||
if (empty($updates)) {
|
||||
jsonResponse(['error' => 'No fields to update'], 400);
|
||||
}
|
||||
|
||||
$params[] = $id;
|
||||
|
||||
$sql = "UPDATE specials SET " . implode(', ', $updates) . " WHERE id = ?";
|
||||
$stmt = $db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
// Fetch updated special
|
||||
$stmt = $db->prepare("SELECT * FROM specials WHERE id = ?");
|
||||
$stmt->execute([$id]);
|
||||
$special = $stmt->fetch();
|
||||
$special['highlights'] = json_decode($special['highlights'], true);
|
||||
|
||||
jsonResponse($special);
|
||||
}
|
||||
|
||||
// DELETE special by destination_id (admin only)
|
||||
if ($method === 'DELETE' && isset($pathParts[1]) && $pathParts[1] === 'destination' && isset($pathParts[2])) {
|
||||
requireAuth();
|
||||
|
||||
$destinationId = $pathParts[2];
|
||||
|
||||
$stmt = $db->prepare("DELETE FROM specials WHERE destination_id = ?");
|
||||
$stmt->execute([$destinationId]);
|
||||
|
||||
if ($stmt->rowCount() === 0) {
|
||||
jsonResponse(['error' => 'Special not found for this destination'], 404);
|
||||
}
|
||||
|
||||
jsonResponse(['message' => 'Special removed successfully']);
|
||||
}
|
||||
|
||||
jsonResponse(['error' => 'Invalid specials endpoint'], 404);
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
/**
|
||||
* Testimonials Endpoints
|
||||
*/
|
||||
|
||||
$db = Database::getInstance()->getConnection();
|
||||
|
||||
// GET approved testimonials (public)
|
||||
if ($method === 'GET' && !$id) {
|
||||
$stmt = $db->query("SELECT id, full_name, location, message, image_path, created_at FROM testimonials WHERE status = 'approved' ORDER BY created_at DESC");
|
||||
jsonResponse($stmt->fetchAll());
|
||||
}
|
||||
|
||||
// GET all testimonials (admin)
|
||||
if ($method === 'GET' && $id === 'all') {
|
||||
requireAuth();
|
||||
$stmt = $db->query("SELECT * FROM testimonials ORDER BY created_at DESC");
|
||||
jsonResponse($stmt->fetchAll());
|
||||
}
|
||||
|
||||
// POST submit testimonial (public)
|
||||
if ($method === 'POST' && !$id) {
|
||||
$input = getJsonInput();
|
||||
$errors = validateRequired($input, ['full_name', 'location', 'message']);
|
||||
if (!empty($errors)) {
|
||||
jsonResponse(['error' => implode(', ', $errors)], 400);
|
||||
}
|
||||
|
||||
$testimonialId = generateUuid();
|
||||
$stmt = $db->prepare("INSERT INTO testimonials (id, full_name, location, message, image_path, status, created_at) VALUES (?, ?, ?, ?, ?, 'pending', NOW())");
|
||||
$stmt->execute([
|
||||
$testimonialId,
|
||||
sanitizeString($input['full_name']),
|
||||
sanitizeString($input['location']),
|
||||
sanitizeString($input['message']),
|
||||
isset($input['image_path']) ? $input['image_path'] : null
|
||||
]);
|
||||
jsonResponse(['message' => 'Testimonial submitted successfully', 'id' => $testimonialId], 201);
|
||||
}
|
||||
|
||||
// PUT approve/deny/edit testimonial (admin)
|
||||
if ($method === 'PUT' && $id) {
|
||||
requireAuth();
|
||||
$input = getJsonInput();
|
||||
$updates = [];
|
||||
$params = [];
|
||||
|
||||
if (isset($input['status']) && in_array($input['status'], ['pending', 'approved', 'denied'])) {
|
||||
$updates[] = 'status = ?';
|
||||
$params[] = $input['status'];
|
||||
}
|
||||
if (isset($input['full_name'])) { $updates[] = 'full_name = ?'; $params[] = sanitizeString($input['full_name']); }
|
||||
if (isset($input['location'])) { $updates[] = 'location = ?'; $params[] = sanitizeString($input['location']); }
|
||||
if (isset($input['message'])) { $updates[] = 'message = ?'; $params[] = sanitizeString($input['message']); }
|
||||
|
||||
if (empty($updates)) jsonResponse(['error' => 'No fields to update'], 400);
|
||||
|
||||
$params[] = $id;
|
||||
$stmt = $db->prepare('UPDATE testimonials SET ' . implode(', ', $updates) . ' WHERE id = ?');
|
||||
$stmt->execute($params);
|
||||
|
||||
$stmt = $db->prepare('SELECT * FROM testimonials WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
jsonResponse($stmt->fetch());
|
||||
}
|
||||
|
||||
// DELETE testimonial (admin)
|
||||
if ($method === 'DELETE' && $id) {
|
||||
requireAuth();
|
||||
$stmt = $db->prepare('DELETE FROM testimonials WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
if ($stmt->rowCount() === 0) jsonResponse(['error' => 'Testimonial not found'], 404);
|
||||
jsonResponse(['message' => 'Testimonial deleted']);
|
||||
}
|
||||
|
||||
|
||||
// POST upload testimonial image (public)
|
||||
if ($method === "POST" && $id === "upload") {
|
||||
if (!isset($_FILES["file"])) jsonResponse(["error" => "No file uploaded"], 400);
|
||||
$file = $_FILES["file"];
|
||||
if ($file["error"] !== UPLOAD_ERR_OK) jsonResponse(["error" => "Upload error"], 400);
|
||||
if ($file["size"] > MAX_UPLOAD_SIZE) jsonResponse(["error" => "File too large (max 5MB)"], 400);
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mime = finfo_file($finfo, $file["tmp_name"]);
|
||||
finfo_close($finfo);
|
||||
$allowed = ["image/jpeg","image/jpg","image/png","image/webp"];
|
||||
if (!in_array($mime, $allowed)) jsonResponse(["error" => "Invalid file type"], 400);
|
||||
$ext = strtolower(pathinfo($file["name"], PATHINFO_EXTENSION));
|
||||
$name = generateUuid() . "." . $ext;
|
||||
$dest = UPLOAD_DIR . $name;
|
||||
if (!move_uploaded_file($file["tmp_name"], $dest)) jsonResponse(["error" => "Failed to save"], 500);
|
||||
jsonResponse(["url" => "/api/uploads/" . $name]);
|
||||
}
|
||||
|
||||
jsonResponse(['error' => 'Invalid testimonials endpoint'], 404);
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
/**
|
||||
* Image Upload Endpoint
|
||||
*/
|
||||
|
||||
requireAuth(); // Only authenticated users can upload
|
||||
|
||||
if ($method === 'POST' && $id === 'image') {
|
||||
if (!isset($_FILES['file'])) {
|
||||
jsonResponse(['error' => 'No file uploaded'], 400);
|
||||
}
|
||||
|
||||
$file = $_FILES['file'];
|
||||
|
||||
// Validate file
|
||||
if ($file['error'] !== UPLOAD_ERR_OK) {
|
||||
jsonResponse(['error' => 'File upload failed'], 400);
|
||||
}
|
||||
|
||||
// Check file size
|
||||
if ($file['size'] > MAX_UPLOAD_SIZE) {
|
||||
jsonResponse(['error' => 'File too large. Maximum size is 5MB'], 400);
|
||||
}
|
||||
|
||||
// Check file type
|
||||
$allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mimeType = finfo_file($finfo, $file['tmp_name']);
|
||||
finfo_close($finfo);
|
||||
|
||||
if (!in_array($mimeType, $allowedTypes)) {
|
||||
jsonResponse(['error' => 'Invalid file type. Only JPG, PNG, and WebP allowed'], 400);
|
||||
}
|
||||
|
||||
// Generate unique filename
|
||||
$extension = pathinfo($file['name'], PATHINFO_EXTENSION);
|
||||
$filename = generateUuid() . '.' . $extension;
|
||||
$filepath = UPLOAD_DIR . $filename;
|
||||
|
||||
// Move uploaded file
|
||||
if (!move_uploaded_file($file['tmp_name'], $filepath)) {
|
||||
jsonResponse(['error' => 'Failed to save file'], 500);
|
||||
}
|
||||
|
||||
$fileUrl = '/api/uploads/' . $filename;
|
||||
|
||||
jsonResponse([
|
||||
'url' => $fileUrl,
|
||||
'filename' => $filename
|
||||
]);
|
||||
}
|
||||
|
||||
// Serve uploaded images
|
||||
if ($method === 'GET' && isset($pathParts[1]) && $pathParts[1] === 'uploads' && isset($pathParts[2])) {
|
||||
$filename = basename($pathParts[2]);
|
||||
$filepath = UPLOAD_DIR . $filename;
|
||||
|
||||
if (!file_exists($filepath)) {
|
||||
jsonResponse(['error' => 'Image not found'], 404);
|
||||
}
|
||||
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mimeType = finfo_file($finfo, $filepath);
|
||||
finfo_close($finfo);
|
||||
|
||||
header('Content-Type: ' . $mimeType);
|
||||
header('Content-Length: ' . filesize($filepath));
|
||||
readfile($filepath);
|
||||
exit;
|
||||
}
|
||||
|
||||
jsonResponse(['error' => 'Invalid upload endpoint'], 404);
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
/**
|
||||
* Database Connection Class
|
||||
* Uses PDO for secure MySQL connections
|
||||
*/
|
||||
|
||||
class Database {
|
||||
private static $instance = null;
|
||||
private $conn;
|
||||
|
||||
private function __construct() {
|
||||
try {
|
||||
$dsn = "mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=" . DB_CHARSET;
|
||||
$options = [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
];
|
||||
|
||||
$this->conn = new PDO($dsn, DB_USER, DB_PASS, $options);
|
||||
} catch (PDOException $e) {
|
||||
$this->handleError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public static function getInstance() {
|
||||
if (self::$instance === null) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function getConnection() {
|
||||
return $this->conn;
|
||||
}
|
||||
|
||||
private function handleError($message) {
|
||||
if (DEBUG_MODE) {
|
||||
die(json_encode(['error' => 'Database Error: ' . $message]));
|
||||
} else {
|
||||
die(json_encode(['error' => 'Database connection failed']));
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent cloning
|
||||
private function __clone() {}
|
||||
|
||||
// Prevent unserialization
|
||||
public function __wakeup() {
|
||||
throw new Exception("Cannot unserialize singleton");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
/**
|
||||
* Helper Functions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Set CORS headers
|
||||
*/
|
||||
function setCorsHeaders() {
|
||||
$origin = isset($_SERVER['HTTP_ORIGIN']) ? $_SERVER['HTTP_ORIGIN'] : '';
|
||||
|
||||
if ($origin && (ALLOWED_ORIGINS === '*' || strpos(ALLOWED_ORIGINS, $origin) !== false)) {
|
||||
header("Access-Control-Allow-Origin: $origin");
|
||||
} else {
|
||||
header("Access-Control-Allow-Origin: " . ALLOWED_ORIGINS);
|
||||
}
|
||||
|
||||
header("Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS");
|
||||
header("Access-Control-Allow-Headers: Content-Type, Authorization");
|
||||
header("Access-Control-Allow-Credentials: true");
|
||||
|
||||
// Handle preflight requests
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send JSON response
|
||||
*/
|
||||
function jsonResponse($data, $statusCode = 200) {
|
||||
http_response_code($statusCode);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode($data);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get JSON input
|
||||
*/
|
||||
function getJsonInput() {
|
||||
$input = file_get_contents('php://input');
|
||||
return json_decode($input, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate email
|
||||
*/
|
||||
function isValidEmail($email) {
|
||||
return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate UUID v4
|
||||
*/
|
||||
function generateUuid() {
|
||||
return sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
||||
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
|
||||
mt_rand(0, 0xffff),
|
||||
mt_rand(0, 0x0fff) | 0x4000,
|
||||
mt_rand(0, 0x3fff) | 0x8000,
|
||||
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize string
|
||||
*/
|
||||
function sanitizeString($string) {
|
||||
return htmlspecialchars(strip_tags(trim($string)), ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate required fields
|
||||
*/
|
||||
function validateRequired($data, $requiredFields) {
|
||||
$errors = [];
|
||||
|
||||
foreach ($requiredFields as $field) {
|
||||
if (!isset($data[$field]) || empty(trim($data[$field]))) {
|
||||
$errors[] = "$field is required";
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
/**
|
||||
* JWT Authentication Functions
|
||||
* Simple JWT implementation for PHP
|
||||
*/
|
||||
|
||||
class JWT {
|
||||
|
||||
/**
|
||||
* Create a JWT token
|
||||
*/
|
||||
public static function encode($payload, $secret) {
|
||||
$header = json_encode(['typ' => 'JWT', 'alg' => 'HS256']);
|
||||
$payload = json_encode($payload);
|
||||
|
||||
$base64UrlHeader = self::base64UrlEncode($header);
|
||||
$base64UrlPayload = self::base64UrlEncode($payload);
|
||||
|
||||
$signature = hash_hmac('sha256', $base64UrlHeader . "." . $base64UrlPayload, $secret, true);
|
||||
$base64UrlSignature = self::base64UrlEncode($signature);
|
||||
|
||||
return $base64UrlHeader . "." . $base64UrlPayload . "." . $base64UrlSignature;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode and verify a JWT token
|
||||
*/
|
||||
public static function decode($jwt, $secret) {
|
||||
$parts = explode('.', $jwt);
|
||||
|
||||
if (count($parts) !== 3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
list($base64UrlHeader, $base64UrlPayload, $base64UrlSignature) = $parts;
|
||||
|
||||
$signature = self::base64UrlDecode($base64UrlSignature);
|
||||
$expectedSignature = hash_hmac('sha256', $base64UrlHeader . "." . $base64UrlPayload, $secret, true);
|
||||
|
||||
if (!hash_equals($signature, $expectedSignature)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$payload = json_decode(self::base64UrlDecode($base64UrlPayload), true);
|
||||
|
||||
// Check expiration
|
||||
if (isset($payload['exp']) && $payload['exp'] < time()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create authentication token
|
||||
*/
|
||||
public static function createToken($email) {
|
||||
$payload = [
|
||||
'sub' => $email,
|
||||
'iat' => time(),
|
||||
'exp' => time() + JWT_EXPIRY
|
||||
];
|
||||
|
||||
return self::encode($payload, JWT_SECRET_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify token from Authorization header
|
||||
*/
|
||||
public static function verifyToken() {
|
||||
$headers = getallheaders();
|
||||
|
||||
if (!isset($headers['Authorization'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$authHeader = $headers['Authorization'];
|
||||
|
||||
if (!preg_match('/Bearer\s+(.*)$/i', $authHeader, $matches)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$token = $matches[1];
|
||||
$payload = self::decode($token, JWT_SECRET_KEY);
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 URL encode
|
||||
*/
|
||||
private static function base64UrlEncode($data) {
|
||||
return str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 URL decode
|
||||
*/
|
||||
private static function base64UrlDecode($data) {
|
||||
return base64_decode(str_replace(['-', '_'], ['+', '/'], $data));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Require authentication middleware
|
||||
*/
|
||||
function requireAuth() {
|
||||
$payload = JWT::verifyToken();
|
||||
|
||||
if (!$payload) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'Unauthorized']);
|
||||
exit;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
/**
|
||||
* Epic Travel Expeditions — SendGrid Mailer
|
||||
*/
|
||||
|
||||
function sendgridSend(string $toEmail, string $toName, string $subject, string $htmlBody, string $textBody = ''): bool {
|
||||
$apiKey = defined('SENDGRID_API_KEY') ? SENDGRID_API_KEY : '';
|
||||
if (!$apiKey || strpos($apiKey, 'YOUR_KEY') !== false) {
|
||||
error_log('[EpicTravel mailer] SENDGRID_API_KEY not configured');
|
||||
return false;
|
||||
}
|
||||
|
||||
$payload = json_encode([
|
||||
'personalizations' => [['to' => [['email' => $toEmail, 'name' => $toName]]]],
|
||||
'from' => [
|
||||
'email' => defined('MAIL_FROM') ? MAIL_FROM : 'noreply@epictravelexpeditions.com',
|
||||
'name' => defined('MAIL_FROM_NAME') ? MAIL_FROM_NAME : 'Epic Travel Expeditions',
|
||||
],
|
||||
'subject' => $subject,
|
||||
'content' => array_values(array_filter([
|
||||
$textBody ? ['type' => 'text/plain', 'value' => $textBody] : null,
|
||||
['type' => 'text/html', 'value' => $htmlBody],
|
||||
])),
|
||||
]);
|
||||
|
||||
$ch = curl_init('https://api.sendgrid.com/v3/mail/send');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Authorization: Bearer ' . $apiKey,
|
||||
'Content-Type: application/json',
|
||||
],
|
||||
CURLOPT_TIMEOUT => 20,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode === 202) return true;
|
||||
error_log('[EpicTravel mailer] SendGrid HTTP ' . $httpCode . ' — ' . $response);
|
||||
return false;
|
||||
}
|
||||
|
||||
function sendContactAlert(string $name, string $email, string $message): bool {
|
||||
$adminEmail = defined('ADMIN_EMAIL') ? ADMIN_EMAIL : 'admin@epictravelexpeditions.com';
|
||||
$subject = "New Contact Form Submission from {$name}";
|
||||
$html = '
|
||||
<div style="max-width:600px;margin:0 auto;font-family:Arial,sans-serif;">
|
||||
<div style="background:#1a3a6b;padding:24px;text-align:center;">
|
||||
<h1 style="color:#fff;margin:0;font-size:22px;">Epic Travel Expeditions</h1>
|
||||
<p style="color:rgba(255,255,255,.7);margin:4px 0 0;font-size:14px;">New Contact Form Message</p>
|
||||
</div>
|
||||
<div style="padding:28px;background:#fff;border:1px solid #e5e7eb;">
|
||||
<table style="width:100%;border-collapse:collapse;">
|
||||
<tr><td style="padding:8px 0;color:#6b7280;font-size:13px;width:80px;">Name</td>
|
||||
<td style="padding:8px 0;font-weight:600;">' . htmlspecialchars($name) . '</td></tr>
|
||||
<tr><td style="padding:8px 0;color:#6b7280;font-size:13px;">Email</td>
|
||||
<td style="padding:8px 0;"><a href="mailto:' . htmlspecialchars($email) . '" style="color:#1a3a6b;">' . htmlspecialchars($email) . '</a></td></tr>
|
||||
</table>
|
||||
<div style="margin-top:20px;padding:16px;background:#f9fafb;border-radius:8px;border-left:4px solid #1a3a6b;">
|
||||
<p style="margin:0;font-size:14px;color:#374151;line-height:1.6;">' . nl2br(htmlspecialchars($message)) . '</p>
|
||||
</div>
|
||||
<p style="margin-top:20px;font-size:13px;color:#9ca3af;">Submitted ' . date('F j, Y \a\t g:i A T') . '</p>
|
||||
</div>
|
||||
<div style="background:#f3f4f6;padding:16px;text-align:center;">
|
||||
<p style="margin:0;font-size:12px;color:#9ca3af;">© ' . date('Y') . ' Epic Travel Expeditions</p>
|
||||
</div>
|
||||
</div>';
|
||||
|
||||
return sendgridSend($adminEmail, 'Epic Travel Admin', $subject, $html,
|
||||
"New contact from {$name} ({$email}):\n\n{$message}");
|
||||
}
|
||||
|
||||
function sendContactConfirmation(string $toEmail, string $toName): bool {
|
||||
$subject = "We received your message — Epic Travel Expeditions";
|
||||
$html = '
|
||||
<div style="max-width:600px;margin:0 auto;font-family:Arial,sans-serif;">
|
||||
<div style="background:#1a3a6b;padding:24px;text-align:center;">
|
||||
<h1 style="color:#fff;margin:0;font-size:22px;">Epic Travel Expeditions</h1>
|
||||
</div>
|
||||
<div style="padding:32px;background:#fff;">
|
||||
<h2 style="margin-top:0;color:#1a3a6b;">Thanks for reaching out, ' . htmlspecialchars($toName) . '!</h2>
|
||||
<p style="color:#374151;line-height:1.6;">We received your message and our team will get back to you within 1–2 business days.</p>
|
||||
<p style="color:#374151;line-height:1.6;">In the meantime, feel free to browse our destinations and current travel specials:</p>
|
||||
<div style="text-align:center;margin:28px 0;">
|
||||
<a href="https://epictravelexpeditions.com" style="display:inline-block;background:#1a3a6b;color:#fff;padding:14px 28px;border-radius:8px;text-decoration:none;font-weight:bold;">Explore Destinations</a>
|
||||
</div>
|
||||
<p style="color:#374151;line-height:1.6;">Adventure awaits,<br><strong>The Epic Travel Team</strong></p>
|
||||
</div>
|
||||
<div style="background:#f3f4f6;padding:16px;text-align:center;">
|
||||
<p style="margin:0;font-size:12px;color:#9ca3af;">© ' . date('Y') . ' Epic Travel Expeditions</p>
|
||||
</div>
|
||||
</div>';
|
||||
|
||||
return sendgridSend($toEmail, $toName, $subject, $html,
|
||||
"Hi {$toName},\n\nThanks for contacting Epic Travel Expeditions! We'll get back to you within 1-2 business days.\n\nAdventure awaits,\nThe Epic Travel Team");
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
/**
|
||||
* Epic Travel & Expeditions - Main API Entry Point
|
||||
* This file routes all API requests to appropriate handlers
|
||||
*/
|
||||
|
||||
// Load configuration and dependencies
|
||||
require_once __DIR__ . '/config.php';
|
||||
require_once __DIR__ . '/includes/database.php';
|
||||
require_once __DIR__ . '/includes/jwt.php';
|
||||
require_once __DIR__ . '/includes/functions.php';
|
||||
require_once __DIR__ . '/includes/mailer.php';
|
||||
|
||||
// Set CORS headers (handles OPTIONS preflight too)
|
||||
setCorsHeaders();
|
||||
|
||||
// Get request method and path
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$path = isset($_SERVER['PATH_INFO']) ? $_SERVER['PATH_INFO'] : '/';
|
||||
$path = trim($path, '/');
|
||||
|
||||
// Parse path
|
||||
$pathParts = explode('/', $path);
|
||||
$resource = isset($pathParts[0]) ? $pathParts[0] : '';
|
||||
$id = isset($pathParts[1]) ? $pathParts[1] : null;
|
||||
|
||||
// Health check endpoint
|
||||
if ($path === '' || $path === 'api') {
|
||||
jsonResponse([
|
||||
'message' => 'Epic Travel API is running',
|
||||
'status' => 'healthy'
|
||||
]);
|
||||
}
|
||||
|
||||
// Route to appropriate handler
|
||||
try {
|
||||
switch ($resource) {
|
||||
case 'auth':
|
||||
require __DIR__ . '/api/auth.php';
|
||||
break;
|
||||
|
||||
case 'destinations':
|
||||
require __DIR__ . '/api/destinations.php';
|
||||
break;
|
||||
|
||||
case 'specials':
|
||||
require __DIR__ . '/api/specials.php';
|
||||
break;
|
||||
|
||||
case 'contact':
|
||||
require __DIR__ . '/api/contact.php';
|
||||
break;
|
||||
|
||||
case 'newsletter':
|
||||
require __DIR__ . '/api/newsletter.php';
|
||||
break;
|
||||
|
||||
case 'upload':
|
||||
require __DIR__ . '/api/upload.php';
|
||||
break;
|
||||
|
||||
case 'download':
|
||||
require __DIR__ . '/api/download.php';
|
||||
break;
|
||||
|
||||
case 'testimonials':
|
||||
require __DIR__ . '/api/testimonials.php';
|
||||
break;
|
||||
|
||||
case 'categories':
|
||||
require __DIR__ . '/api/categories.php';
|
||||
break;
|
||||
|
||||
default:
|
||||
jsonResponse(['error' => 'Endpoint not found'], 404);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
if (DEBUG_MODE) {
|
||||
jsonResponse(['error' => $e->getMessage()], 500);
|
||||
} else {
|
||||
jsonResponse(['error' => 'Internal server error'], 500);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
// Simple PHP version checker
|
||||
echo "PHP Version: " . phpversion() . "\n";
|
||||
echo "Server Software: " . $_SERVER['SERVER_SOFTWARE'] . "\n";
|
||||
phpinfo();
|
||||
?>
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
/**
|
||||
* Epic Travel - Admin Password Setup
|
||||
* Visit: https://epictravelexpeditions.com/api/setup_password.php
|
||||
* DELETE THIS FILE after use!
|
||||
*/
|
||||
|
||||
$message = '';
|
||||
$success = false;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$email = trim($_POST['email'] ?? '');
|
||||
$password = trim($_POST['password'] ?? '');
|
||||
$confirm = trim($_POST['confirm'] ?? '');
|
||||
|
||||
if (!$email || !$password) {
|
||||
$message = 'Email and password are required.';
|
||||
} elseif ($password !== $confirm) {
|
||||
$message = 'Passwords do not match.';
|
||||
} elseif (strlen($password) < 6) {
|
||||
$message = 'Password must be at least 6 characters.';
|
||||
} else {
|
||||
try {
|
||||
$pdo = new PDO(
|
||||
'mysql:host=localhost;dbname=epic_epic_db;charset=utf8mb4',
|
||||
'root',
|
||||
'b71e5c1a8c7457541b9c1db822de37adfa271926a38b6c20',
|
||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
|
||||
);
|
||||
|
||||
$hash = password_hash($password, PASSWORD_BCRYPT);
|
||||
|
||||
// Check if user exists
|
||||
$check = $pdo->prepare("SELECT COUNT(*) FROM admin_users WHERE email = ?");
|
||||
$check->execute([$email]);
|
||||
|
||||
if ($check->fetchColumn() > 0) {
|
||||
// Update existing
|
||||
$s = $pdo->prepare("UPDATE admin_users SET password_hash = ? WHERE email = ?");
|
||||
$s->execute([$hash, $email]);
|
||||
$message = 'Password updated successfully!';
|
||||
} else {
|
||||
// Create new
|
||||
$s = $pdo->prepare("INSERT INTO admin_users (id, email, password_hash, created_at) VALUES (?, ?, ?, NOW())");
|
||||
$s->execute(['admin-1', $email, $hash]);
|
||||
$message = 'Admin account created successfully!';
|
||||
}
|
||||
|
||||
// Verify
|
||||
if (password_verify($password, $hash)) {
|
||||
$success = true;
|
||||
} else {
|
||||
$message = 'Error: Password verification failed.';
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
$message = 'Database error: ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Admin Setup — Epic Travel</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0 }
|
||||
body { background: #0a0f1e; font-family: 'Segoe UI', sans-serif; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px }
|
||||
.box { background: #111827; border: 1px solid rgba(59,130,246,.3); padding: 40px; width: 100%; max-width: 420px }
|
||||
h1 { color: #3b82f6; font-size: 22px; margin-bottom: 6px }
|
||||
.sub { color: #6b7280; font-size: 13px; margin-bottom: 28px }
|
||||
label { display: block; color: #9ca3af; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 6px }
|
||||
input { width: 100%; background: #1f2937; border: 1px solid rgba(255,255,255,.1); color: #f9fafb; padding: 11px 14px; font-size: 15px; outline: none; margin-bottom: 16px }
|
||||
input:focus { border-color: #3b82f6 }
|
||||
button { width: 100%; padding: 13px; background: #3b82f6; color: #fff; border: none; font-size: 15px; font-weight: 700; cursor: pointer }
|
||||
button:hover { background: #2563eb }
|
||||
.msg { padding: 12px 14px; font-size: 14px; font-weight: 600; margin-bottom: 20px }
|
||||
.msg.error { background: rgba(239,68,68,.1); border: 1px solid rgba(239,68,68,.3); color: #f87171 }
|
||||
.msg.success { background: rgba(34,197,94,.1); border: 1px solid rgba(34,197,94,.3); color: #4ade80 }
|
||||
.warning { background: rgba(245,158,11,.1); border: 1px solid rgba(245,158,11,.3); color: #fbbf24; padding: 12px 14px; font-size: 13px; margin-top: 20px }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="box">
|
||||
<h1>Epic Travel Admin Setup</h1>
|
||||
<div class="sub">Set your admin email and password</div>
|
||||
|
||||
<?php if ($message): ?>
|
||||
<div class="msg <?= $success ? 'success' : 'error' ?>">
|
||||
<?= $success ? '✓ ' : '⚠ ' ?><?= htmlspecialchars($message) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($success): ?>
|
||||
<p style="color:#9ca3af;font-size:14px;margin-bottom:20px">
|
||||
You can now <a href="/admin" style="color:#3b82f6">login to the admin panel</a>.<br><br>
|
||||
<strong style="color:#f87171">⚠ Delete this file immediately!</strong><br>
|
||||
Run in SSH: <code style="background:#1f2937;padding:2px 6px;color:#fbbf24">rm /home/epictravelexpeditions.com/public_html/api/setup_password.php</code>
|
||||
</p>
|
||||
<?php else: ?>
|
||||
<form method="POST">
|
||||
<label>Admin Email</label>
|
||||
<input type="email" name="email" value="admin@epictravelexpeditions.com" required>
|
||||
<label>New Password</label>
|
||||
<input type="password" name="password" placeholder="Enter password" required>
|
||||
<label>Confirm Password</label>
|
||||
<input type="password" name="confirm" placeholder="Confirm password" required>
|
||||
<button type="submit">Set Admin Password</button>
|
||||
</form>
|
||||
<div class="warning">⚠ Delete this file after use. It provides direct DB access.</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"files": {
|
||||
"main.css": "/static/css/main.7791f349.css",
|
||||
"main.js": "/static/js/main.b20df6de.js",
|
||||
"index.html": "/index.html",
|
||||
"main.7791f349.css.map": "/static/css/main.7791f349.css.map",
|
||||
"main.b20df6de.js.map": "/static/js/main.b20df6de.js.map"
|
||||
},
|
||||
"entrypoints": [
|
||||
"static/css/main.7791f349.css",
|
||||
"static/js/main.b20df6de.js"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<svg xmlns='http://www.w3.org/2000/svg' width='1200' height='630' viewBox='0 0 1200 630'>
|
||||
<rect width='1200' height='630' fill='#0a1628'/>
|
||||
<defs>
|
||||
<linearGradient id='g2' x1='0' y1='0' x2='1' y2='1'>
|
||||
<stop offset='0%' stop-color='#0a1628'/>
|
||||
<stop offset='100%' stop-color='#1a2a4a'/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width='1200' height='630' fill='url(#g2)'/>
|
||||
<circle cx='600' cy='180' r='3' fill='white' opacity='0.6'/>
|
||||
<circle cx='200' cy='120' r='2' fill='white' opacity='0.4'/>
|
||||
<circle cx='1000' cy='80' r='2' fill='white' opacity='0.5'/>
|
||||
<circle cx='150' cy='300' r='1.5' fill='white' opacity='0.3'/>
|
||||
<circle cx='1050' cy='250' r='2' fill='white' opacity='0.4'/>
|
||||
<text x='600' y='230' font-family='Arial,sans-serif' font-size='80' text-anchor='middle'>🌎</text>
|
||||
<text x='600' y='330' font-family='Georgia,serif' font-size='62' font-weight='bold' fill='#ffffff' text-anchor='middle'>Epic Travel Expeditions</text>
|
||||
<text x='600' y='390' font-family='Arial,sans-serif' font-size='26' fill='#60a5fa' text-anchor='middle'>Adventure Awaits • Curated World Expeditions</text>
|
||||
<text x='600' y='445' font-family='Arial,sans-serif' font-size='22' fill='rgba(255,255,255,0.5)' text-anchor='middle'>Exclusive Destinations • Guided Tours • Unforgettable Journeys</text>
|
||||
<rect x='430' y='490' width='340' height='50' rx='8' fill='#2563eb'/>
|
||||
<text x='600' y='522' font-family='Arial,sans-serif' font-size='22' font-weight='bold' fill='white' text-anchor='middle'>epictravelexpeditions.com</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "epic-travel/expeditions",
|
||||
"description": "Epic Travel & Expeditions Website",
|
||||
"type": "project",
|
||||
"require": {
|
||||
"php": ">=7.4.0"
|
||||
},
|
||||
"config": {
|
||||
"platform": {
|
||||
"php": "8.0"
|
||||
},
|
||||
"optimize-autoloader": true
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="Epic Travel Expeditions - Curated adventures to the world's most breathtaking destinations."/><link rel="preconnect" href="https://fonts.googleapis.com"/><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/><link href="https://fonts.googleapis.com/css2?family=Inter:wght@600&display=swap" rel="stylesheet"/><title>Epic Travel Expeditions | Adventure Awaits</title><script>window.addEventListener("error",function(e){e.error instanceof DOMException&&"DataCloneError"===e.error.name&&e.message&&e.message.includes("PerformanceServerTiming")&&(e.stopImmediatePropagation(),e.preventDefault())},!0)</script><script defer="defer" src="/static/js/main.b9c030e8v2.js"></script><link href="/static/css/main.7791f349.css" rel="stylesheet"><link rel="canonical" href="https://epictravelexpeditions.com/" />
|
||||
<meta name="keywords" content="adventure travel, guided tours, travel expeditions, exotic destinations, bucket list travel, international travel packages, adventure vacations, world travel" />
|
||||
<meta name="robots" content="index, follow" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="Epic Travel Expeditions" />
|
||||
<meta property="og:title" content="Epic Travel Expeditions | Adventure Awaits" />
|
||||
<meta property="og:description" content="Epic Travel Expeditions - Curated adventures to the world's most breathtaking destinations. Explore our guided tours, exclusive specials, and dream destinations." />
|
||||
<meta property="og:url" content="https://epictravelexpeditions.com/" />
|
||||
<meta property="og:image" content="https://epictravelexpeditions.com/assets/og-image.jpg" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Epic Travel Expeditions | Adventure Awaits" />
|
||||
<meta name="twitter:description" content="Curated adventures to the world's most breathtaking destinations. Guided tours, exclusive travel specials, and unforgettable expeditions." />
|
||||
<meta name="twitter:image" content="https://epictravelexpeditions.com/assets/og-image.jpg" />
|
||||
<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"Organization","@id":"https://epictravelexpeditions.com/#org","name":"Epic Travel Expeditions","url":"https://epictravelexpeditions.com","description":"Curated adventure travel expeditions to the world's most breathtaking destinations.","sameAs":[]},{"@type":"WebSite","@id":"https://epictravelexpeditions.com/#website","url":"https://epictravelexpeditions.com","name":"Epic Travel Expeditions","publisher":{"@id":"https://epictravelexpeditions.com/#org"},"potentialAction":{"@type":"SearchAction","target":"https://epictravelexpeditions.com/destinations?q={search_term_string}","query-input":"required name=search_term_string"}},{"@type":"TravelAgency","@id":"https://epictravelexpeditions.com/#agency","name":"Epic Travel Expeditions","url":"https://epictravelexpeditions.com","description":"Specializing in curated adventure travel, guided expeditions, and exclusive destination packages worldwide.","priceRange":"$$$","currenciesAccepted":"USD","paymentAccepted":"Credit Card","areaServed":"Worldwide"}]}</script>
|
||||
</head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div><script>!function(e,t){var r,s,o,i;t.__SV||(window.posthog=t,t._i=[],t.init=function(n,a,p){function c(e,t){var r=t.split(".");2==r.length&&(e=e[r[0]],t=r[1]),e[t]=function(){e.push([t].concat(Array.prototype.slice.call(arguments,0)))}}(o=e.createElement("script")).type="text/javascript",o.crossOrigin="anonymous",o.async=!0,o.src=a.api_host.replace(".i.posthog.com","-assets.i.posthog.com")+"/static/array.js",(i=e.getElementsByTagName("script")[0]).parentNode.insertBefore(o,i);var g=t;for(void 0!==p?g=t[p]=[]:p="posthog",g.people=g.people||[],g.toString=function(e){var t="posthog";return"posthog"!==p&&(t+="."+p),e||(t+=" (stub)"),t},g.people.toString=function(){return g.toString(1)+".people (stub)"},r="init me ws ys ps bs capture je Di ks register register_once register_for_session unregister unregister_for_session Ps getFeatureFlag getFeatureFlagPayload isFeatureEnabled reloadFeatureFlags updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures on onFeatureFlags onSurveysLoaded onSessionId getSurveys getActiveMatchingSurveys renderSurvey canRenderSurvey canRenderSurveyAsync identify setPersonProperties group resetGroups setPersonPropertiesForFlags resetPersonPropertiesForFlags setGroupPropertiesForFlags resetGroupPropertiesForFlags reset get_distinct_id getGroups get_session_id get_session_replay_url alias set_config startSessionRecording stopSessionRecording sessionRecordingStarted captureException loadToolbar get_property getSessionProperty Es $s createPersonProfile Is opt_in_capturing opt_out_capturing has_opted_in_capturing has_opted_out_capturing clear_opt_in_out_capturing Ss debug xs getPageViewId captureTraceFeedback captureTraceMetric".split(" "),s=0;s<r.length;s++)c(g,r[s]);t._i.push([n,a,p])},t.__SV=1)}(document,window.posthog||[]),posthog.init("phc_xAvL2Iq4tFmANRE7kzbKwaSqp1HJjN7x48s3vr0CMjs",{api_host:"https://us.i.posthog.com",person_profiles:"identified_only",session_recording:{recordCrossOriginIframes:!0,capturePerformance:!1}})</script><script src="/static/js/testimonials.js"></script><script src="/static/js/admin-portal-v2.js"></script></body></html>
|
||||
@@ -0,0 +1,6 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Disallow: /admin
|
||||
Disallow: /api/
|
||||
|
||||
Sitemap: https://epictravelexpeditions.com/sitemap.xml
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
|
||||
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
|
||||
<url>
|
||||
<loc>https://epictravelexpeditions.com/</loc>
|
||||
<lastmod>2026-05-19</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://epictravelexpeditions.com/destinations</loc>
|
||||
<lastmod>2026-05-19</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.9</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://epictravelexpeditions.com/specials</loc>
|
||||
<lastmod>2026-05-19</lastmod>
|
||||
<changefreq>daily</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://epictravelexpeditions.com/contact</loc>
|
||||
<lastmod>2026-05-19</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,541 @@
|
||||
(function(){
|
||||
'use strict';
|
||||
const API='https://epictravelexpeditions.com/api';
|
||||
|
||||
function authHdr(){return{'Content-Type':'application/json',Authorization:`Bearer ${localStorage.getItem('auth_token')}`};}
|
||||
async function api(path,opts){
|
||||
const res=await fetch(API+path,{headers:authHdr(),...opts});
|
||||
const d=await res.json();
|
||||
if(!res.ok)throw new Error(d.error||res.statusText);
|
||||
return d;
|
||||
}
|
||||
async function uploadImg(file){
|
||||
const fd=new FormData();fd.append('file',file);
|
||||
const res=await fetch(API+'/upload/image',{method:'POST',headers:{Authorization:`Bearer ${localStorage.getItem('auth_token')}`},body:fd});
|
||||
const d=await res.json();
|
||||
if(!res.ok)throw new Error(d.error||'Upload failed');
|
||||
return d.url;
|
||||
}
|
||||
async function getImg(inputId,fallback){
|
||||
const el=document.getElementById(inputId);
|
||||
if(el&&el.files&&el.files[0]){
|
||||
const st=document.getElementById(inputId+'-st');
|
||||
if(st)st.textContent='Uploading…';
|
||||
try{const url=await uploadImg(el.files[0]);if(st)st.textContent='';return url;}
|
||||
catch(e){if(st){st.textContent='Upload failed: '+e.message;st.style.color='#dc2626';}throw e;}
|
||||
}
|
||||
return fallback!=null?fallback:null;
|
||||
}
|
||||
function imgPicker(id,src){
|
||||
const pr=src
|
||||
?`<img id="${id}-pr" src="${src}" style="width:52px;height:52px;border-radius:8px;object-fit:cover;border:1px solid #e5e7eb">`
|
||||
:`<div id="${id}-pr" style="width:52px;height:52px;border-radius:8px;background:#f3f4f6;border:1px solid #e5e7eb;display:flex;align-items:center;justify-content:center;font-size:20px">🖼</div>`;
|
||||
return `<div style="display:flex;align-items:center;gap:10px">${pr}<label style="display:inline-flex;align-items:center;gap:6px;padding:7px 13px;border:1px dashed #d1d5db;border-radius:7px;cursor:pointer;font-size:12px;color:#6b7280;background:#fff">📁 Choose Image<input type="file" accept="image/jpeg,image/png,image/webp" id="${id}" style="display:none" onchange="(function(el){const f=el.files[0];if(!f)return;const u=URL.createObjectURL(f);const p=document.getElementById('${id}-pr');if(p.tagName==='IMG'){p.src=u;}else{p.outerHTML='<img id=${id}-pr src='+u+' style=width:52px;height:52px;border-radius:8px;object-fit:cover;border:1px solid #e5e7eb>';};})(this)"></label><span id="${id}-st" style="font-size:11px;color:#6b7280"></span></div>`;
|
||||
}
|
||||
|
||||
function daysAgo(s){return Math.floor((Date.now()-new Date(s))/86400000);}
|
||||
function daysLeft(s){return Math.ceil((new Date(s)-Date.now())/86400000);}
|
||||
function ageBdg(d){const c=d<30?'bg':d<90?'by':'br';return`<span class="ep-b ${c}">${d}d ago</span>`;}
|
||||
function expBdg(d){if(d<0)return`<span class="ep-b br">Expired</span>`;const c=d>14?'bg':d>7?'by':'br';return`<span class="ep-b ${c}">${d}d left</span>`;}
|
||||
function esc(s){const e=document.createElement('div');e.textContent=s||'';return e.innerHTML;}
|
||||
function catOpts(cats,sel){return cats.map(c=>`<option value="${esc(c.name)}"${c.name===sel?' selected':''}>${esc(c.name)}</option>`).join('');}
|
||||
function fmtPrice(p){return p!=null?'$'+parseFloat(p).toLocaleString(undefined,{minimumFractionDigits:0,maximumFractionDigits:2}):'';}
|
||||
|
||||
/* CSS */
|
||||
const css=`
|
||||
#ep-portal{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#111827;min-height:100vh;background:#f3f4f6}
|
||||
#ep-portal *,#ep-portal *::before,#ep-portal *::after{box-sizing:border-box}
|
||||
#ep-hdr{background:#fff;border-bottom:1px solid #e5e7eb;padding:0 28px;height:60px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:100;box-shadow:0 1px 3px rgba(0,0,0,.06)}
|
||||
.ep-logo{font-size:17px;font-weight:800;color:#1d4ed8;letter-spacing:-.4px}.ep-logo span{color:#6b7280;font-weight:400}
|
||||
.ep-hdr-acts{display:flex;gap:8px}
|
||||
.ep-hbtn{padding:6px 14px;border-radius:7px;font-size:13px;font-weight:600;border:1px solid #d1d5db;background:#fff;color:#374151;cursor:pointer;transition:all .15s;text-decoration:none;display:inline-flex;align-items:center;gap:5px}
|
||||
.ep-hbtn:hover{background:#f9fafb}.ep-hbtn.red{border-color:#fca5a5;color:#dc2626}.ep-hbtn.red:hover{background:#fef2f2}
|
||||
#ep-tabs{background:#fff;border-bottom:1px solid #e5e7eb;padding:0 28px;display:flex;gap:2px}
|
||||
.ep-tab{padding:13px 18px;font-size:13px;font-weight:600;color:#6b7280;border:none;background:none;cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px;transition:all .15s;display:inline-flex;align-items:center;gap:7px}
|
||||
.ep-tab:hover{color:#1d4ed8}.ep-tab.active{color:#1d4ed8;border-bottom-color:#1d4ed8}
|
||||
.ep-tbadge{background:#fee2e2;color:#dc2626;border-radius:999px;font-size:11px;padding:1px 6px;font-weight:700}
|
||||
#ep-body{padding:28px;max-width:1180px;margin:0 auto}
|
||||
.ep-g4{display:grid;grid-template-columns:repeat(4,1fr);gap:18px;margin-bottom:28px}
|
||||
@media(max-width:880px){.ep-g4{grid-template-columns:repeat(2,1fr)}}
|
||||
.ep-stat{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:20px 22px;position:relative;overflow:hidden}
|
||||
.ep-si{font-size:26px;margin-bottom:6px}.ep-sv{font-size:30px;font-weight:800;color:#111827}.ep-sl{font-size:12px;color:#6b7280;margin-top:1px}
|
||||
.ep-acc{position:absolute;right:0;top:0;bottom:0;width:4px;border-radius:0 12px 12px 0}
|
||||
.acb{background:#3b82f6}.acg{background:#10b981}.aca{background:#f59e0b}.acp{background:#8b5cf6}
|
||||
.ep-card{background:#fff;border:1px solid #e5e7eb;border-radius:12px;margin-bottom:22px;overflow:hidden}
|
||||
.ep-ch{padding:16px 22px;border-bottom:1px solid #e5e7eb;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px}
|
||||
.ep-ct{font-size:14px;font-weight:700;color:#111827}.ep-cs{font-size:12px;color:#6b7280;margin-top:1px}
|
||||
.ep-cb{padding:22px}
|
||||
.ep-tbl{width:100%;border-collapse:collapse;font-size:13px}
|
||||
.ep-tbl th{text-align:left;font-size:11px;font-weight:700;color:#6b7280;text-transform:uppercase;letter-spacing:.05em;padding:9px 12px;border-bottom:1px solid #e5e7eb;white-space:nowrap}
|
||||
.ep-tbl td{padding:11px 12px;border-bottom:1px solid #f3f4f6;vertical-align:middle}
|
||||
.ep-tbl tr:last-child td{border-bottom:none}.ep-tbl tr:hover td{background:#fafafa}
|
||||
.ep-b{display:inline-block;padding:2px 9px;border-radius:999px;font-size:11px;font-weight:700}
|
||||
.bg{background:#d1fae5;color:#065f46}.by{background:#fef3c7;color:#92400e}.br{background:#fee2e2;color:#991b1b}.bb{background:#dbeafe;color:#1e40af}.bgr{background:#f3f4f6;color:#374151}
|
||||
.ep-btn{padding:6px 13px;border-radius:7px;font-size:12px;font-weight:600;border:none;cursor:pointer;transition:opacity .15s;display:inline-flex;align-items:center;gap:4px;white-space:nowrap}
|
||||
.ep-btn:hover{opacity:.82}.ep-btn:disabled{opacity:.4;cursor:not-allowed}
|
||||
.ep-pri{background:#2563eb;color:#fff}.ep-suc{background:#16a34a;color:#fff}.ep-dan{background:#dc2626;color:#fff}.ep-gho{background:#f3f4f6;color:#374151}
|
||||
.ep-form{display:grid;grid-template-columns:1fr 1fr;gap:14px}
|
||||
@media(max-width:640px){.ep-form{grid-template-columns:1fr}}
|
||||
.ep-full{grid-column:1/-1}.ep-fld{display:flex;flex-direction:column;gap:4px}
|
||||
.ep-lbl{font-size:11px;font-weight:700;color:#374151;text-transform:uppercase;letter-spacing:.04em}
|
||||
.ep-inp,.ep-sel,.ep-ta{padding:8px 11px;border:1px solid #d1d5db;border-radius:7px;font-size:13px;color:#111827;background:#fff;outline:none;transition:border-color .15s;width:100%}
|
||||
.ep-inp:focus,.ep-sel:focus,.ep-ta:focus{border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.1)}
|
||||
.ep-ta{resize:vertical;min-height:72px}
|
||||
.ep-frow{grid-column:1/-1;display:flex;gap:10px;justify-content:flex-end;padding-top:4px;align-items:center}
|
||||
.ep-addbox{border:1px dashed #d1d5db;border-radius:10px;padding:18px;margin-bottom:20px;background:#fafafa;display:none}
|
||||
.ep-addbox.open{display:block}
|
||||
.ep-thumb{width:40px;height:40px;border-radius:7px;object-fit:cover;background:#e5e7eb}
|
||||
.ep-smsg{font-size:12px}.ep-smsg.ok{color:#16a34a}.ep-smsg.err{color:#dc2626}
|
||||
.ep-alert{padding:10px 15px;border-radius:8px;font-size:13px;margin-bottom:16px;border-left:3px solid}
|
||||
.ep-alert.warn{background:#fffbeb;color:#92400e;border-color:#f59e0b}
|
||||
.ep-inline{display:grid;grid-template-columns:1fr 1fr;gap:10px;padding:14px}
|
||||
@media(max-width:640px){.ep-inline{grid-column:1fr}}
|
||||
.ep-cat-row{display:flex;align-items:center;gap:10px;padding:9px 12px;border-radius:8px;border:1px solid #e5e7eb;background:#fff;margin-bottom:8px}
|
||||
.ep-cat-nm{flex:1;font-size:13px;font-weight:600}.ep-cat-ct{font-size:11px;color:#9ca3af}
|
||||
.ep-cat-ei{flex:1;padding:5px 9px;border:1px solid #3b82f6;border-radius:6px;font-size:13px;outline:none}
|
||||
.ep-tc{border:1px solid #e5e7eb;border-radius:10px;padding:16px;margin-bottom:12px;display:flex;gap:12px;background:#fff}
|
||||
.ep-tav{width:42px;height:42px;border-radius:50%;object-fit:cover;flex-shrink:0;background:#e5e7eb}
|
||||
.ep-tavph{width:42px;height:42px;border-radius:50%;background:#2563eb;color:#fff;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:16px;flex-shrink:0}
|
||||
.ep-tb{flex:1;min-width:0}.ep-tn{font-weight:700;font-size:13px}.ep-tl{font-size:11px;color:#6b7280;margin-bottom:5px}
|
||||
.ep-tm{font-size:13px;color:#374151;margin-bottom:8px;line-height:1.5}
|
||||
.ep-te{width:100%;border:1px solid #d1d5db;border-radius:6px;padding:6px 9px;font-size:12px;margin-bottom:7px;resize:vertical;min-height:52px}
|
||||
.ep-tacts{display:flex;gap:7px;flex-wrap:wrap}
|
||||
.ep-ftabs{display:flex;gap:7px;margin-bottom:18px;flex-wrap:wrap}
|
||||
.ep-ft{padding:6px 15px;border-radius:7px;font-size:12px;font-weight:600;cursor:pointer;border:1px solid #d1d5db;background:#fff;color:#374151;transition:all .15s}
|
||||
.ep-ft.active{background:#2563eb;color:#fff;border-color:#2563eb}
|
||||
.ep-empty{text-align:center;padding:36px;color:#9ca3af;font-size:13px}
|
||||
.ep-spec-img{width:52px;height:52px;border-radius:8px;object-fit:cover;background:#e5e7eb;flex-shrink:0}
|
||||
.ep-price-old{text-decoration:line-through;color:#9ca3af;font-size:11px}
|
||||
.ep-price-new{color:#16a34a;font-weight:700}
|
||||
`;
|
||||
const sel=document.createElement('style');sel.textContent=css;document.head.appendChild(sel);
|
||||
|
||||
/* State */
|
||||
let activeTab='dashboard',tFilter='pending';
|
||||
let destinations=[],specials=[],testimonials=[],categories=[];
|
||||
|
||||
async function loadAll(){
|
||||
const r=await Promise.allSettled([
|
||||
fetch(API+'/destinations',{headers:authHdr()}).then(r=>r.json()),
|
||||
fetch(API+'/specials', {headers:authHdr()}).then(r=>r.json()),
|
||||
fetch(API+'/testimonials/all',{headers:authHdr()}).then(r=>r.json()),
|
||||
fetch(API+'/categories', {headers:authHdr()}).then(r=>r.json())
|
||||
]);
|
||||
if(r[0].status==='fulfilled'&&Array.isArray(r[0].value))destinations=r[0].value;
|
||||
if(r[1].status==='fulfilled'&&Array.isArray(r[1].value))specials=r[1].value;
|
||||
if(r[2].status==='fulfilled'&&Array.isArray(r[2].value))testimonials=r[2].value;
|
||||
if(r[3].status==='fulfilled'&&Array.isArray(r[3].value))categories=r[3].value;
|
||||
}
|
||||
|
||||
/* Shell */
|
||||
function buildShell(){
|
||||
const p=document.createElement('div');p.id='ep-portal';
|
||||
p.innerHTML=`
|
||||
<div id="ep-hdr">
|
||||
<div class="ep-logo">Epic Travel <span>Admin</span></div>
|
||||
<div class="ep-hdr-acts">
|
||||
<a href="/" class="ep-hbtn" target="_blank">🌐 View Site</a>
|
||||
<button class="ep-hbtn red" id="ep-logout">⏻ Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ep-tabs">
|
||||
<button class="ep-tab active" data-tab="dashboard">📊 Dashboard</button>
|
||||
<button class="ep-tab" data-tab="destinations">🗺 Destinations</button>
|
||||
<button class="ep-tab" data-tab="specials">⭐ Specials</button>
|
||||
<button class="ep-tab" data-tab="testimonials">💬 Testimonials <span class="ep-tbadge" id="ep-pb" style="display:none"></span></button>
|
||||
</div>
|
||||
<div id="ep-body"></div>`;
|
||||
p.querySelector('#ep-logout').onclick=()=>{localStorage.removeItem('isAdminAuthenticated');localStorage.removeItem('auth_token');window.location.href='/admin';};
|
||||
p.querySelectorAll('.ep-tab[data-tab]').forEach(b=>{b.onclick=()=>{activeTab=b.dataset.tab;p.querySelectorAll('.ep-tab').forEach(t=>t.classList.remove('active'));b.classList.add('active');render();};});
|
||||
return p;
|
||||
}
|
||||
|
||||
/* Dashboard */
|
||||
function rDash(){
|
||||
const dm=Object.fromEntries(destinations.map(d=>[d.id,d]));
|
||||
const pen=testimonials.filter(t=>t.status==='pending').length;
|
||||
const app=testimonials.filter(t=>t.status==='approved').length;
|
||||
let h=`<div class="ep-g4">
|
||||
<div class="ep-stat"><div class="ep-acc acb"></div><div class="ep-si">🗺</div><div class="ep-sv">${destinations.length}</div><div class="ep-sl">Destinations</div></div>
|
||||
<div class="ep-stat"><div class="ep-acc acg"></div><div class="ep-si">⭐</div><div class="ep-sv">${specials.length}</div><div class="ep-sl">Active Specials</div></div>
|
||||
<div class="ep-stat"><div class="ep-acc aca"></div><div class="ep-si">⏳</div><div class="ep-sv">${pen}</div><div class="ep-sl">Pending Reviews</div></div>
|
||||
<div class="ep-stat"><div class="ep-acc acp"></div><div class="ep-si">✅</div><div class="ep-sv">${app}</div><div class="ep-sl">Approved Testimonials</div></div>
|
||||
</div>`;
|
||||
if(pen>0)h+=`<div class="ep-alert warn">⚠️ <strong>${pen}</strong> testimonial${pen>1?'s':''} awaiting review.</div>`;
|
||||
|
||||
/* Destinations age */
|
||||
const sd=[...destinations].sort((a,b)=>new Date(a.created_at)-new Date(b.created_at));
|
||||
h+=`<div class="ep-card"><div class="ep-ch"><div><div class="ep-ct">🗺 Destinations</div><div class="ep-cs">Oldest entries first</div></div></div><table class="ep-tbl"><thead><tr><th>Destination</th><th>Category</th><th>Price</th><th>In System Since</th></tr></thead><tbody>`;
|
||||
sd.forEach(d=>{h+=`<tr><td><strong>${esc(d.name)}</strong> <span style="color:#9ca3af;font-size:11px">${esc(d.location)}</span></td><td><span class="ep-b bb">${esc(d.category)}</span></td><td>${fmtPrice(d.price)}</td><td>${ageBdg(daysAgo(d.created_at))}</td></tr>`;});
|
||||
h+=`</tbody></table></div>`;
|
||||
|
||||
/* Specials expiry */
|
||||
const ss=[...specials].sort((a,b)=>new Date(a.end_date)-new Date(b.end_date));
|
||||
h+=`<div class="ep-card"><div class="ep-ch"><div><div class="ep-ct">⭐ Weekly Specials</div><div class="ep-cs">Expiring soonest first</div></div></div><table class="ep-tbl"><thead><tr><th>Image</th><th>Destination</th><th>Original Price</th><th>Discount</th><th>Sale Price</th><th>Expires</th><th>Status</th></tr></thead><tbody>`;
|
||||
ss.forEach(s=>{
|
||||
const dest=dm[s.destination_id];
|
||||
const basePrice=s.price!=null?parseFloat(s.price):(dest?parseFloat(dest.price):0);
|
||||
const salePrice=basePrice*(1-parseFloat(s.discount)/100);
|
||||
const imgSrc=s.image_path||(dest?dest.image:'');
|
||||
h+=`<tr>
|
||||
<td>${imgSrc?`<img src="${esc(imgSrc)}" class="ep-thumb" onerror="this.style.opacity='.2'">`:''}</td>
|
||||
<td><strong>${esc(dest?dest.name:s.destination_id)}</strong></td>
|
||||
<td class="ep-price-old">${fmtPrice(basePrice)}</td>
|
||||
<td><span class="ep-b bg">${parseFloat(s.discount)}% OFF</span></td>
|
||||
<td class="ep-price-new">${fmtPrice(salePrice)}</td>
|
||||
<td>${new Date(s.end_date).toLocaleDateString()}</td>
|
||||
<td>${expBdg(daysLeft(s.end_date))}</td>
|
||||
</tr>`;
|
||||
});
|
||||
h+=`</tbody></table></div>`;
|
||||
|
||||
/* Pending testimonials */
|
||||
const pl=testimonials.filter(t=>t.status==='pending');
|
||||
if(pl.length){
|
||||
h+=`<div class="ep-card"><div class="ep-ch"><div class="ep-ct">⏳ Pending Testimonials</div></div><div class="ep-cb">`;
|
||||
pl.forEach(t=>{
|
||||
const av=t.image_path?`<img class="ep-tav" src="${esc(t.image_path)}" alt="">`:`<div class="ep-tavph">${esc(t.full_name.charAt(0))}</div>`;
|
||||
h+=`<div class="ep-tc" data-id="${esc(t.id)}">${av}<div class="ep-tb"><div class="ep-tn">${esc(t.full_name)}</div><div class="ep-tl">${esc(t.location)}</div><div class="ep-tm">${esc(t.message)}</div><div class="ep-tacts"><button class="ep-btn ep-suc" data-qa="${esc(t.id)}">✓ Approve</button><button class="ep-btn ep-dan" data-qd="${esc(t.id)}">✗ Deny</button></div></div></div>`;
|
||||
});
|
||||
h+=`</div></div>`;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
/* Destinations */
|
||||
function destRow(d){
|
||||
return`<tr data-did="${esc(d.id)}">
|
||||
<td><img class="ep-thumb" src="${esc(d.image)}" alt="" onerror="this.style.opacity='.2'"></td>
|
||||
<td><strong>${esc(d.name)}</strong><br><span style="color:#9ca3af;font-size:11px">${esc(d.location)}</span></td>
|
||||
<td><span class="ep-b bb">${esc(d.category)}</span></td>
|
||||
<td>${fmtPrice(d.price)}</td>
|
||||
<td>⭐ ${parseFloat(d.rating).toFixed(1)}</td>
|
||||
<td>${ageBdg(daysAgo(d.created_at))}</td>
|
||||
<td style="white-space:nowrap"><button class="ep-btn ep-gho" data-ed="${esc(d.id)}">✏ Edit</button> <button class="ep-btn ep-dan" data-dd="${esc(d.id)}">🗑</button></td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function rDest(){
|
||||
const cc={};destinations.forEach(d=>{cc[d.category]=(cc[d.category]||0)+1;});
|
||||
return`<div class="ep-card">
|
||||
<div class="ep-ch"><div><div class="ep-ct">🏷 Categories</div><div class="ep-cs">Add, rename, or remove destination categories</div></div><button class="ep-btn ep-pri" id="ep-cat-tog">+ Add Category</button></div>
|
||||
<div class="ep-cb">
|
||||
<div class="ep-addbox" id="ep-cat-box">
|
||||
<div style="display:flex;gap:10px;align-items:center">
|
||||
<input class="ep-inp" id="ep-cat-new" placeholder="New category name" style="max-width:260px">
|
||||
<button class="ep-btn ep-pri" id="ep-cat-save">Save</button>
|
||||
<button class="ep-btn ep-gho" id="ep-cat-cancel">Cancel</button>
|
||||
<span class="ep-smsg" id="ep-cat-msg"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ep-cat-list">${categories.map(c=>{const n=cc[c.name]||0;return`<div class="ep-cat-row" data-cid="${c.id}"><span class="ep-cat-nm">${esc(c.name)}</span><span class="ep-cat-ct">${n} destination${n!==1?'s':''}</span><button class="ep-btn ep-gho" data-ren="${c.id}" data-cv="${esc(c.name)}" style="padding:4px 10px;font-size:11px">Rename</button><button class="ep-btn ep-dan" data-dc="${c.id}" data-dn="${esc(c.name)}" style="padding:4px 10px;font-size:11px"${n>0?' disabled title="In use"':''}>Delete</button></div>`;}).join('')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ep-card">
|
||||
<div class="ep-ch"><div><div class="ep-ct">🗺 All Destinations (${destinations.length})</div></div><button class="ep-btn ep-pri" id="ep-dest-tog">+ Add Destination</button></div>
|
||||
<div class="ep-cb">
|
||||
<div class="ep-addbox" id="ep-dest-box">
|
||||
<div style="font-weight:700;font-size:13px;margin-bottom:14px">New Destination</div>
|
||||
<div class="ep-form">
|
||||
<div class="ep-fld"><span class="ep-lbl">Name *</span><input class="ep-inp" id="ep-dn-name" placeholder="e.g. Paris"></div>
|
||||
<div class="ep-fld"><span class="ep-lbl">Location *</span><input class="ep-inp" id="ep-dn-loc" placeholder="e.g. France"></div>
|
||||
<div class="ep-fld"><span class="ep-lbl">Category *</span><select class="ep-sel" id="ep-dn-cat">${catOpts(categories,'')}</select></div>
|
||||
<div class="ep-fld"><span class="ep-lbl">Price (USD) *</span><input class="ep-inp" id="ep-dn-price" type="number" placeholder="1299"></div>
|
||||
<div class="ep-fld"><span class="ep-lbl">Rating (1–5)</span><input class="ep-inp" id="ep-dn-rating" type="number" step=".1" min="1" max="5" value="4.5"></div>
|
||||
<div class="ep-fld ep-full"><span class="ep-lbl">Image *</span>${imgPicker('ep-dn-img','')}</div>
|
||||
<div class="ep-fld ep-full"><span class="ep-lbl">Description *</span><textarea class="ep-ta" id="ep-dn-desc"></textarea></div>
|
||||
<div class="ep-frow"><span class="ep-smsg" id="ep-dn-msg"></span><button class="ep-btn ep-gho" id="ep-dn-cancel">Cancel</button><button class="ep-btn ep-pri" id="ep-dn-save">Save Destination</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<table class="ep-tbl"><thead><tr><th>Photo</th><th>Name & Location</th><th>Category</th><th>Price</th><th>Rating</th><th>In System</th><th>Actions</th></tr></thead>
|
||||
<tbody id="ep-dest-tbody">${destinations.map(destRow).join('')}</tbody></table>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* Specials */
|
||||
function specImg(s){
|
||||
const dm=Object.fromEntries(destinations.map(d=>[d.id,d]));
|
||||
const dest=dm[s.destination_id];
|
||||
return s.image_path||(dest?dest.image:'')||'';
|
||||
}
|
||||
function specRow(s){
|
||||
const dm=Object.fromEntries(destinations.map(d=>[d.id,d]));
|
||||
const dest=dm[s.destination_id];
|
||||
const base=s.price!=null?parseFloat(s.price):(dest?parseFloat(dest.price):0);
|
||||
const sale=base*(1-parseFloat(s.discount)/100);
|
||||
const img=specImg(s);
|
||||
return`<tr data-sid="${esc(s.id)}">
|
||||
<td>${img?`<img src="${esc(img)}" class="ep-thumb" onerror="this.style.opacity='.2'">`:''}</td>
|
||||
<td><strong>${esc(dest?dest.name:s.destination_id)}</strong></td>
|
||||
<td><span class="ep-b bg">${parseFloat(s.discount)}% OFF</span></td>
|
||||
<td class="ep-price-old">${fmtPrice(base)}</td>
|
||||
<td class="ep-price-new">${fmtPrice(sale)}</td>
|
||||
<td>${new Date(s.end_date).toLocaleDateString()}</td>
|
||||
<td>${expBdg(daysLeft(s.end_date))}</td>
|
||||
<td style="white-space:nowrap"><button class="ep-btn ep-gho" data-es="${esc(s.id)}">✏ Edit</button> <button class="ep-btn ep-dan" data-ds="${esc(s.id)}">🗑</button></td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function rSpec(){
|
||||
const destOpts=destinations.map(d=>`<option value="${esc(d.id)}">${esc(d.name)}</option>`).join('');
|
||||
return`<div class="ep-card">
|
||||
<div class="ep-ch"><div><div class="ep-ct">⭐ Weekly Specials (${specials.length})</div></div><button class="ep-btn ep-pri" id="ep-spec-tog">+ Add Special</button></div>
|
||||
<div class="ep-cb">
|
||||
<div class="ep-addbox" id="ep-spec-box">
|
||||
<div style="font-weight:700;font-size:13px;margin-bottom:14px">New Special</div>
|
||||
<div class="ep-form">
|
||||
<div class="ep-fld"><span class="ep-lbl">Destination *</span><select class="ep-sel" id="ep-sn-dest">${destOpts}</select></div>
|
||||
<div class="ep-fld"><span class="ep-lbl">Original Price (USD) *</span><input class="ep-inp" id="ep-sn-price" type="number" placeholder="1299" title="Base price before discount"></div>
|
||||
<div class="ep-fld"><span class="ep-lbl">Discount % *</span><input class="ep-inp" id="ep-sn-disc" type="number" min="1" max="99" placeholder="25"></div>
|
||||
<div class="ep-fld"><span class="ep-lbl">End Date *</span><input class="ep-inp" id="ep-sn-end" type="date"></div>
|
||||
<div class="ep-fld ep-full"><span class="ep-lbl">Special Image (optional — defaults to destination photo)</span>${imgPicker('ep-sn-img','')}</div>
|
||||
<div class="ep-fld ep-full"><span class="ep-lbl">Highlights (one per line)</span><textarea class="ep-ta" id="ep-sn-hls" placeholder="Free spa treatment Complimentary airport transfer"></textarea></div>
|
||||
<div class="ep-frow"><span class="ep-smsg" id="ep-sn-msg"></span><button class="ep-btn ep-gho" id="ep-sn-cancel">Cancel</button><button class="ep-btn ep-pri" id="ep-sn-save">Save Special</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<table class="ep-tbl"><thead><tr><th>Image</th><th>Destination</th><th>Discount</th><th>Original Price</th><th>Sale Price</th><th>End Date</th><th>Status</th><th>Actions</th></tr></thead>
|
||||
<tbody id="ep-spec-tbody">${specials.map(specRow).join('')}</tbody></table>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* Testimonials */
|
||||
function rTest(){
|
||||
const cn={pending:0,approved:0,denied:0};testimonials.forEach(t=>cn[t.status]++);
|
||||
const list=tFilter==='all'?testimonials:testimonials.filter(t=>t.status===tFilter);
|
||||
let h=`<div class="ep-ftabs">
|
||||
<button class="ep-ft ${tFilter==='pending'?'active':''}" data-tf="pending">⏳ Pending (${cn.pending})</button>
|
||||
<button class="ep-ft ${tFilter==='approved'?'active':''}" data-tf="approved">✅ Approved (${cn.approved})</button>
|
||||
<button class="ep-ft ${tFilter==='denied'?'active':''}" data-tf="denied">❌ Denied (${cn.denied})</button>
|
||||
<button class="ep-ft ${tFilter==='all'?'active':''}" data-tf="all">All (${testimonials.length})</button>
|
||||
</div>`;
|
||||
if(!list.length)return h+`<div class="ep-empty">💬 No testimonials here.</div>`;
|
||||
list.forEach(t=>{
|
||||
const av=t.image_path?`<img class="ep-tav" src="${esc(t.image_path)}" alt="">`:`<div class="ep-tavph">${esc(t.full_name.charAt(0))}</div>`;
|
||||
const sb=`<span class="ep-b ${t.status==='approved'?'bg':t.status==='denied'?'br':'by'}">${t.status}</span>`;
|
||||
h+=`<div class="ep-tc" data-tid="${esc(t.id)}">${av}<div class="ep-tb">
|
||||
<div class="ep-tn">${esc(t.full_name)} ${sb}</div><div class="ep-tl">${esc(t.location)}</div>
|
||||
<div class="ep-tm">${esc(t.message)}</div>
|
||||
<textarea class="ep-te">${esc(t.message)}</textarea>
|
||||
<div class="ep-tacts">
|
||||
${t.status!=='approved'?`<button class="ep-btn ep-suc" data-ta="approve" data-tid="${esc(t.id)}">✓ Approve</button>`:''}
|
||||
${t.status!=='denied'?`<button class="ep-btn ep-dan" data-ta="deny" data-tid="${esc(t.id)}">✗ Deny</button>`:''}
|
||||
<button class="ep-btn ep-gho" data-ta="save" data-tid="${esc(t.id)}">💾 Save Edit</button>
|
||||
<button class="ep-btn ep-dan" data-ta="delete" data-tid="${esc(t.id)}">🗑 Delete</button>
|
||||
</div></div></div>`;
|
||||
});
|
||||
return h;
|
||||
}
|
||||
|
||||
/* Render */
|
||||
function render(){
|
||||
const body=document.getElementById('ep-body');if(!body)return;
|
||||
switch(activeTab){
|
||||
case'dashboard': body.innerHTML=rDash(); wireDash(); break;
|
||||
case'destinations':body.innerHTML=rDest(); wireDest(); break;
|
||||
case'specials': body.innerHTML=rSpec(); wireSpec(); break;
|
||||
case'testimonials':body.innerHTML=rTest(); wireTest(); break;
|
||||
}
|
||||
const pb=document.getElementById('ep-pb');
|
||||
if(pb){const n=testimonials.filter(t=>t.status==='pending').length;pb.textContent=n||'';pb.style.display=n?'':'none';}
|
||||
}
|
||||
|
||||
/* Wire: Dashboard */
|
||||
function wireDash(){
|
||||
document.querySelectorAll('[data-qa]').forEach(b=>b.onclick=async()=>{await api(`/testimonials/${b.dataset.qa}`,{method:'PUT',body:JSON.stringify({status:'approved'})});await loadAll();render();});
|
||||
document.querySelectorAll('[data-qd]').forEach(b=>b.onclick=async()=>{await api(`/testimonials/${b.dataset.qd}`,{method:'PUT',body:JSON.stringify({status:'denied'})});await loadAll();render();});
|
||||
}
|
||||
|
||||
/* Wire: Destinations */
|
||||
function wireDest(){
|
||||
/* Categories */
|
||||
const ct=document.getElementById('ep-cat-tog'),cb=document.getElementById('ep-cat-box');
|
||||
if(ct)ct.onclick=()=>cb.classList.toggle('open');
|
||||
const cc=document.getElementById('ep-cat-cancel');if(cc)cc.onclick=()=>cb.classList.remove('open');
|
||||
const cs=document.getElementById('ep-cat-save');
|
||||
if(cs)cs.onclick=async()=>{
|
||||
const m=document.getElementById('ep-cat-msg'),nm=document.getElementById('ep-cat-new').value.trim();
|
||||
if(!nm){m.textContent='Name required';m.className='ep-smsg err';return;}
|
||||
try{await api('/categories',{method:'POST',body:JSON.stringify({name:nm})});m.textContent='Added!';m.className='ep-smsg ok';await loadAll();activeTab='destinations';render();}
|
||||
catch(e){m.textContent=e.message;m.className='ep-smsg err';}
|
||||
};
|
||||
document.querySelectorAll('[data-ren]').forEach(b=>b.onclick=()=>{
|
||||
const id=b.dataset.ren,cur=b.dataset.cv,row=b.closest('.ep-cat-row');
|
||||
row.innerHTML=`<input class="ep-cat-ei" id="ep-ren-${id}" value="${esc(cur)}"><button class="ep-btn ep-pri" data-rs="${id}" style="padding:5px 10px;font-size:11px">Save</button><button class="ep-btn ep-gho" data-rc style="padding:5px 10px;font-size:11px">Cancel</button><span class="ep-smsg" id="ep-rm-${id}"></span>`;
|
||||
row.querySelector('[data-rc]').onclick=()=>{activeTab='destinations';render();};
|
||||
row.querySelector(`[data-rs="${id}"]`).onclick=async()=>{
|
||||
const nm=document.getElementById(`ep-ren-${id}`).value.trim(),m=document.getElementById(`ep-rm-${id}`);
|
||||
if(!nm){m.textContent='Required';m.className='ep-smsg err';return;}
|
||||
try{await api(`/categories/${id}`,{method:'PUT',body:JSON.stringify({name:nm})});await loadAll();activeTab='destinations';render();}
|
||||
catch(e){m.textContent=e.message;m.className='ep-smsg err';}
|
||||
};
|
||||
});
|
||||
document.querySelectorAll('[data-dc]').forEach(b=>b.onclick=async()=>{
|
||||
if(!confirm(`Delete category "${b.dataset.dn}"?`))return;
|
||||
try{await api(`/categories/${b.dataset.dc}`,{method:'DELETE'});await loadAll();activeTab='destinations';render();}catch(e){alert(e.message);}
|
||||
});
|
||||
|
||||
/* Destination add */
|
||||
const dt=document.getElementById('ep-dest-tog'),db=document.getElementById('ep-dest-box');
|
||||
if(dt)dt.onclick=()=>db.classList.toggle('open');
|
||||
const dn=document.getElementById('ep-dn-cancel');if(dn)dn.onclick=()=>db.classList.remove('open');
|
||||
const ds=document.getElementById('ep-dn-save');
|
||||
if(ds)ds.onclick=async()=>{
|
||||
const m=document.getElementById('ep-dn-msg');
|
||||
try{
|
||||
const img=await getImg('ep-dn-img','');
|
||||
await api('/destinations',{method:'POST',body:JSON.stringify({
|
||||
name:document.getElementById('ep-dn-name').value.trim(),
|
||||
location:document.getElementById('ep-dn-loc').value.trim(),
|
||||
category:document.getElementById('ep-dn-cat').value,
|
||||
price:document.getElementById('ep-dn-price').value,
|
||||
rating:document.getElementById('ep-dn-rating').value||'4.5',
|
||||
image:img,
|
||||
description:document.getElementById('ep-dn-desc').value.trim()
|
||||
})});
|
||||
m.textContent='Saved!';m.className='ep-smsg ok';await loadAll();activeTab='destinations';render();
|
||||
}catch(e){m.textContent=e.message;m.className='ep-smsg err';}
|
||||
};
|
||||
|
||||
/* Destination edit */
|
||||
document.querySelectorAll('[data-ed]').forEach(b=>b.onclick=()=>{
|
||||
const id=b.dataset.ed,d=destinations.find(x=>x.id==id);if(!d)return;
|
||||
const row=document.querySelector(`tr[data-did="${id}"]`);
|
||||
row.innerHTML=`<td colspan="7"><div class="ep-inline ep-form">
|
||||
<div class="ep-fld"><span class="ep-lbl">Name</span><input class="ep-inp" id="ei-n-${id}" value="${esc(d.name)}"></div>
|
||||
<div class="ep-fld"><span class="ep-lbl">Location</span><input class="ep-inp" id="ei-l-${id}" value="${esc(d.location)}"></div>
|
||||
<div class="ep-fld"><span class="ep-lbl">Category</span><select class="ep-sel" id="ei-c-${id}">${catOpts(categories,d.category)}</select></div>
|
||||
<div class="ep-fld"><span class="ep-lbl">Price</span><input class="ep-inp" type="number" id="ei-p-${id}" value="${esc(d.price)}"></div>
|
||||
<div class="ep-fld"><span class="ep-lbl">Rating</span><input class="ep-inp" type="number" step=".1" id="ei-r-${id}" value="${esc(d.rating)}"></div>
|
||||
<div class="ep-fld ep-full"><span class="ep-lbl">Image (upload new to replace)</span>${imgPicker('ei-i-'+id,d.image)}</div>
|
||||
<div class="ep-fld ep-full"><span class="ep-lbl">Description</span><textarea class="ep-ta" id="ei-d-${id}">${esc(d.description)}</textarea></div>
|
||||
<div class="ep-frow">
|
||||
<button class="ep-btn ep-gho" data-ec="${id}">Cancel</button>
|
||||
<button class="ep-btn ep-pri" data-es="${id}">Save Changes</button>
|
||||
</div>
|
||||
</div></td>`;
|
||||
row.querySelector(`[data-ec="${id}"]`).onclick=()=>{row.outerHTML=destRow(d);wireDest();};
|
||||
row.querySelector(`[data-es="${id}"]`).onclick=async()=>{
|
||||
try{
|
||||
const img=await getImg('ei-i-'+id,d.image);
|
||||
await api(`/destinations/${id}`,{method:'PUT',body:JSON.stringify({
|
||||
name:document.getElementById(`ei-n-${id}`).value,
|
||||
location:document.getElementById(`ei-l-${id}`).value,
|
||||
category:document.getElementById(`ei-c-${id}`).value,
|
||||
price:document.getElementById(`ei-p-${id}`).value,
|
||||
rating:document.getElementById(`ei-r-${id}`).value,
|
||||
image:img,
|
||||
description:document.getElementById(`ei-d-${id}`).value
|
||||
})});
|
||||
await loadAll();activeTab='destinations';render();
|
||||
}catch(e){alert(e.message);}
|
||||
};
|
||||
});
|
||||
|
||||
/* Destination delete */
|
||||
document.querySelectorAll('[data-dd]').forEach(b=>b.onclick=async()=>{
|
||||
if(!confirm('Delete this destination? This cannot be undone.'))return;
|
||||
try{await api(`/destinations/${b.dataset.dd}`,{method:'DELETE'});await loadAll();activeTab='destinations';render();}catch(e){alert(e.message);}
|
||||
});
|
||||
}
|
||||
|
||||
/* Wire: Specials */
|
||||
function wireSpec(){
|
||||
const st=document.getElementById('ep-spec-tog'),sb=document.getElementById('ep-spec-box');
|
||||
if(st)st.onclick=()=>sb.classList.toggle('open');
|
||||
const sc=document.getElementById('ep-sn-cancel');if(sc)sc.onclick=()=>sb.classList.remove('open');
|
||||
const ss=document.getElementById('ep-sn-save');
|
||||
if(ss)ss.onclick=async()=>{
|
||||
const m=document.getElementById('ep-sn-msg');
|
||||
try{
|
||||
const img=await getImg('ep-sn-img',null);
|
||||
const hls=document.getElementById('ep-sn-hls').value.split('\n').map(l=>l.trim()).filter(Boolean);
|
||||
await api('/specials',{method:'POST',body:JSON.stringify({
|
||||
destination_id:document.getElementById('ep-sn-dest').value,
|
||||
price:document.getElementById('ep-sn-price').value,
|
||||
discount:document.getElementById('ep-sn-disc').value,
|
||||
end_date:document.getElementById('ep-sn-end').value,
|
||||
image_path:img,
|
||||
highlights:hls
|
||||
})});
|
||||
m.textContent='Saved!';m.className='ep-smsg ok';await loadAll();activeTab='specials';render();
|
||||
}catch(e){m.textContent=e.message;m.className='ep-smsg err';}
|
||||
};
|
||||
|
||||
/* Specials edit */
|
||||
document.querySelectorAll('[data-es]').forEach(b=>b.onclick=()=>{
|
||||
const id=b.dataset.es,s=specials.find(x=>x.id==id);if(!s)return;
|
||||
const dm=Object.fromEntries(destinations.map(d=>[d.id,d]));
|
||||
const dest=dm[s.destination_id];
|
||||
const hls=Array.isArray(s.highlights)?s.highlights.join('\n'):'';
|
||||
const curImg=specImg(s);
|
||||
const row=document.querySelector(`tr[data-sid="${id}"]`);
|
||||
row.innerHTML=`<td colspan="8"><div class="ep-inline ep-form">
|
||||
<div class="ep-fld"><span class="ep-lbl">Original Price</span><input class="ep-inp" type="number" id="si-p-${id}" value="${esc(s.price!=null?s.price:(dest?dest.price:''))}"></div>
|
||||
<div class="ep-fld"><span class="ep-lbl">Discount %</span><input class="ep-inp" type="number" id="si-d-${id}" value="${esc(s.discount)}"></div>
|
||||
<div class="ep-fld"><span class="ep-lbl">End Date</span><input class="ep-inp" type="date" id="si-e-${id}" value="${esc(s.end_date)}"></div>
|
||||
<div class="ep-fld ep-full"><span class="ep-lbl">Image (upload to replace; leave blank to use destination photo)</span>${imgPicker('si-i-'+id,curImg)}</div>
|
||||
<div class="ep-fld ep-full"><span class="ep-lbl">Highlights (one per line)</span><textarea class="ep-ta" id="si-h-${id}">${esc(hls)}</textarea></div>
|
||||
<div class="ep-frow">
|
||||
<button class="ep-btn ep-gho" data-sc="${id}">Cancel</button>
|
||||
<button class="ep-btn ep-pri" data-sv="${id}">Save</button>
|
||||
</div>
|
||||
</div></td>`;
|
||||
row.querySelector(`[data-sc="${id}"]`).onclick=()=>{row.outerHTML=specRow(s);wireSpec();};
|
||||
row.querySelector(`[data-sv="${id}"]`).onclick=async()=>{
|
||||
try{
|
||||
const newImg=await getImg('si-i-'+id,s.image_path||null);
|
||||
const hls2=document.getElementById(`si-h-${id}`).value.split('\n').map(l=>l.trim()).filter(Boolean);
|
||||
await api(`/specials/${id}`,{method:'PUT',body:JSON.stringify({
|
||||
price:document.getElementById(`si-p-${id}`).value,
|
||||
discount:document.getElementById(`si-d-${id}`).value,
|
||||
end_date:document.getElementById(`si-e-${id}`).value,
|
||||
image_path:newImg,
|
||||
highlights:hls2
|
||||
})});
|
||||
await loadAll();activeTab='specials';render();
|
||||
}catch(e){alert(e.message);}
|
||||
};
|
||||
});
|
||||
|
||||
/* Specials delete */
|
||||
document.querySelectorAll('[data-ds]').forEach(b=>b.onclick=async()=>{
|
||||
if(!confirm('Delete this special?'))return;
|
||||
try{await api(`/specials/${b.dataset.ds}`,{method:'DELETE'});await loadAll();activeTab='specials';render();}catch(e){alert(e.message);}
|
||||
});
|
||||
}
|
||||
|
||||
/* Wire: Testimonials */
|
||||
function wireTest(){
|
||||
document.querySelectorAll('[data-tf]').forEach(b=>b.onclick=()=>{tFilter=b.dataset.tf;render();});
|
||||
document.querySelectorAll('[data-ta]').forEach(b=>b.onclick=async()=>{
|
||||
const id=b.dataset.tid,action=b.dataset.ta;
|
||||
const card=document.querySelector(`.ep-tc[data-tid="${id}"]`);
|
||||
try{
|
||||
if(action==='approve')await api(`/testimonials/${id}`,{method:'PUT',body:JSON.stringify({status:'approved'})});
|
||||
else if(action==='deny')await api(`/testimonials/${id}`,{method:'PUT',body:JSON.stringify({status:'denied'})});
|
||||
else if(action==='save'){const ta=card.querySelector('.ep-te');await api(`/testimonials/${id}`,{method:'PUT',body:JSON.stringify({message:ta.value})});}
|
||||
else if(action==='delete'){if(!confirm('Delete?'))return;await api(`/testimonials/${id}`,{method:'DELETE'});}
|
||||
await loadAll();render();
|
||||
}catch(e){alert(e.message);}
|
||||
});
|
||||
}
|
||||
|
||||
/* Init */
|
||||
let injected=false;
|
||||
function tryInject(){
|
||||
if(injected)return;
|
||||
if(!window.location.pathname.startsWith('/admin/dashboard'))return;
|
||||
if(!localStorage.getItem('isAdminAuthenticated'))return;
|
||||
const root=document.getElementById('root');
|
||||
if(!root||!root.children.length)return;
|
||||
injected=true;obs.disconnect();
|
||||
Array.from(root.children).forEach(c=>c.style.display='none');
|
||||
const portal=buildShell();root.insertBefore(portal,root.firstChild);
|
||||
loadAll().then(render);
|
||||
}
|
||||
const obs=new MutationObserver(tryInject);
|
||||
obs.observe(document.body,{childList:true,subtree:true});
|
||||
tryInject();
|
||||
})();
|
||||
@@ -0,0 +1,541 @@
|
||||
(function(){
|
||||
'use strict';
|
||||
const API='https://epictravelexpeditions.com/api';
|
||||
|
||||
function authHdr(){return{'Content-Type':'application/json',Authorization:`Bearer ${localStorage.getItem('auth_token')}`};}
|
||||
async function api(path,opts){
|
||||
const res=await fetch(API+path,{headers:authHdr(),...opts});
|
||||
const d=await res.json();
|
||||
if(!res.ok)throw new Error(d.error||res.statusText);
|
||||
return d;
|
||||
}
|
||||
async function uploadImg(file){
|
||||
const fd=new FormData();fd.append('file',file);
|
||||
const res=await fetch(API+'/upload/image',{method:'POST',headers:{Authorization:`Bearer ${localStorage.getItem('auth_token')}`},body:fd});
|
||||
const d=await res.json();
|
||||
if(!res.ok)throw new Error(d.error||'Upload failed');
|
||||
return d.url;
|
||||
}
|
||||
async function getImg(inputId,fallback){
|
||||
const el=document.getElementById(inputId);
|
||||
if(el&&el.files&&el.files[0]){
|
||||
const st=document.getElementById(inputId+'-st');
|
||||
if(st)st.textContent='Uploading…';
|
||||
try{const url=await uploadImg(el.files[0]);if(st)st.textContent='';return url;}
|
||||
catch(e){if(st){st.textContent='Upload failed: '+e.message;st.style.color='#dc2626';}throw e;}
|
||||
}
|
||||
return fallback!=null?fallback:null;
|
||||
}
|
||||
function imgPicker(id,src){
|
||||
const pr=src
|
||||
?`<img id="${id}-pr" src="${src}" style="width:52px;height:52px;border-radius:8px;object-fit:cover;border:1px solid #e5e7eb">`
|
||||
:`<div id="${id}-pr" style="width:52px;height:52px;border-radius:8px;background:#f3f4f6;border:1px solid #e5e7eb;display:flex;align-items:center;justify-content:center;font-size:20px">🖼</div>`;
|
||||
return `<div style="display:flex;align-items:center;gap:10px">${pr}<label style="display:inline-flex;align-items:center;gap:6px;padding:7px 13px;border:1px dashed #d1d5db;border-radius:7px;cursor:pointer;font-size:12px;color:#6b7280;background:#fff">📁 Choose Image<input type="file" accept="image/jpeg,image/png,image/webp" id="${id}" style="display:none" onchange="(function(el){const f=el.files[0];if(!f)return;const u=URL.createObjectURL(f);const p=document.getElementById('${id}-pr');if(p.tagName==='IMG'){p.src=u;}else{p.outerHTML='<img id=${id}-pr src='+u+' style=width:52px;height:52px;border-radius:8px;object-fit:cover;border:1px solid #e5e7eb>';};})(this)"></label><span id="${id}-st" style="font-size:11px;color:#6b7280"></span></div>`;
|
||||
}
|
||||
|
||||
function daysAgo(s){return Math.floor((Date.now()-new Date(s))/86400000);}
|
||||
function daysLeft(s){return Math.ceil((new Date(s)-Date.now())/86400000);}
|
||||
function ageBdg(d){const c=d<30?'bg':d<90?'by':'br';return`<span class="ep-b ${c}">${d}d ago</span>`;}
|
||||
function expBdg(d){if(d<0)return`<span class="ep-b br">Expired</span>`;const c=d>14?'bg':d>7?'by':'br';return`<span class="ep-b ${c}">${d}d left</span>`;}
|
||||
function esc(s){const e=document.createElement('div');e.textContent=s||'';return e.innerHTML;}
|
||||
function catOpts(cats,sel){return cats.map(c=>`<option value="${esc(c.name)}"${c.name===sel?' selected':''}>${esc(c.name)}</option>`).join('');}
|
||||
function fmtPrice(p){return p!=null?'$'+parseFloat(p).toLocaleString(undefined,{minimumFractionDigits:0,maximumFractionDigits:2}):'';}
|
||||
|
||||
/* CSS */
|
||||
const css=`
|
||||
#ep-portal{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#111827;min-height:100vh;background:#f3f4f6}
|
||||
#ep-portal *,#ep-portal *::before,#ep-portal *::after{box-sizing:border-box}
|
||||
#ep-hdr{background:#fff;border-bottom:1px solid #e5e7eb;padding:0 28px;height:60px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:100;box-shadow:0 1px 3px rgba(0,0,0,.06)}
|
||||
.ep-logo{font-size:17px;font-weight:800;color:#1d4ed8;letter-spacing:-.4px}.ep-logo span{color:#6b7280;font-weight:400}
|
||||
.ep-hdr-acts{display:flex;gap:8px}
|
||||
.ep-hbtn{padding:6px 14px;border-radius:7px;font-size:13px;font-weight:600;border:1px solid #d1d5db;background:#fff;color:#374151;cursor:pointer;transition:all .15s;text-decoration:none;display:inline-flex;align-items:center;gap:5px}
|
||||
.ep-hbtn:hover{background:#f9fafb}.ep-hbtn.red{border-color:#fca5a5;color:#dc2626}.ep-hbtn.red:hover{background:#fef2f2}
|
||||
#ep-tabs{background:#fff;border-bottom:1px solid #e5e7eb;padding:0 28px;display:flex;gap:2px}
|
||||
.ep-tab{padding:13px 18px;font-size:13px;font-weight:600;color:#6b7280;border:none;background:none;cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px;transition:all .15s;display:inline-flex;align-items:center;gap:7px}
|
||||
.ep-tab:hover{color:#1d4ed8}.ep-tab.active{color:#1d4ed8;border-bottom-color:#1d4ed8}
|
||||
.ep-tbadge{background:#fee2e2;color:#dc2626;border-radius:999px;font-size:11px;padding:1px 6px;font-weight:700}
|
||||
#ep-body{padding:28px;max-width:1180px;margin:0 auto}
|
||||
.ep-g4{display:grid;grid-template-columns:repeat(4,1fr);gap:18px;margin-bottom:28px}
|
||||
@media(max-width:880px){.ep-g4{grid-template-columns:repeat(2,1fr)}}
|
||||
.ep-stat{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:20px 22px;position:relative;overflow:hidden}
|
||||
.ep-si{font-size:26px;margin-bottom:6px}.ep-sv{font-size:30px;font-weight:800;color:#111827}.ep-sl{font-size:12px;color:#6b7280;margin-top:1px}
|
||||
.ep-acc{position:absolute;right:0;top:0;bottom:0;width:4px;border-radius:0 12px 12px 0}
|
||||
.acb{background:#3b82f6}.acg{background:#10b981}.aca{background:#f59e0b}.acp{background:#8b5cf6}
|
||||
.ep-card{background:#fff;border:1px solid #e5e7eb;border-radius:12px;margin-bottom:22px;overflow:hidden}
|
||||
.ep-ch{padding:16px 22px;border-bottom:1px solid #e5e7eb;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px}
|
||||
.ep-ct{font-size:14px;font-weight:700;color:#111827}.ep-cs{font-size:12px;color:#6b7280;margin-top:1px}
|
||||
.ep-cb{padding:22px}
|
||||
.ep-tbl{width:100%;border-collapse:collapse;font-size:13px}
|
||||
.ep-tbl th{text-align:left;font-size:11px;font-weight:700;color:#6b7280;text-transform:uppercase;letter-spacing:.05em;padding:9px 12px;border-bottom:1px solid #e5e7eb;white-space:nowrap}
|
||||
.ep-tbl td{padding:11px 12px;border-bottom:1px solid #f3f4f6;vertical-align:middle}
|
||||
.ep-tbl tr:last-child td{border-bottom:none}.ep-tbl tr:hover td{background:#fafafa}
|
||||
.ep-b{display:inline-block;padding:2px 9px;border-radius:999px;font-size:11px;font-weight:700}
|
||||
.bg{background:#d1fae5;color:#065f46}.by{background:#fef3c7;color:#92400e}.br{background:#fee2e2;color:#991b1b}.bb{background:#dbeafe;color:#1e40af}.bgr{background:#f3f4f6;color:#374151}
|
||||
.ep-btn{padding:6px 13px;border-radius:7px;font-size:12px;font-weight:600;border:none;cursor:pointer;transition:opacity .15s;display:inline-flex;align-items:center;gap:4px;white-space:nowrap}
|
||||
.ep-btn:hover{opacity:.82}.ep-btn:disabled{opacity:.4;cursor:not-allowed}
|
||||
.ep-pri{background:#2563eb;color:#fff}.ep-suc{background:#16a34a;color:#fff}.ep-dan{background:#dc2626;color:#fff}.ep-gho{background:#f3f4f6;color:#374151}
|
||||
.ep-form{display:grid;grid-template-columns:1fr 1fr;gap:14px}
|
||||
@media(max-width:640px){.ep-form{grid-template-columns:1fr}}
|
||||
.ep-full{grid-column:1/-1}.ep-fld{display:flex;flex-direction:column;gap:4px}
|
||||
.ep-lbl{font-size:11px;font-weight:700;color:#374151;text-transform:uppercase;letter-spacing:.04em}
|
||||
.ep-inp,.ep-sel,.ep-ta{padding:8px 11px;border:1px solid #d1d5db;border-radius:7px;font-size:13px;color:#111827;background:#fff;outline:none;transition:border-color .15s;width:100%}
|
||||
.ep-inp:focus,.ep-sel:focus,.ep-ta:focus{border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.1)}
|
||||
.ep-ta{resize:vertical;min-height:72px}
|
||||
.ep-frow{grid-column:1/-1;display:flex;gap:10px;justify-content:flex-end;padding-top:4px;align-items:center}
|
||||
.ep-addbox{border:1px dashed #d1d5db;border-radius:10px;padding:18px;margin-bottom:20px;background:#fafafa;display:none}
|
||||
.ep-addbox.open{display:block}
|
||||
.ep-thumb{width:40px;height:40px;border-radius:7px;object-fit:cover;background:#e5e7eb}
|
||||
.ep-smsg{font-size:12px}.ep-smsg.ok{color:#16a34a}.ep-smsg.err{color:#dc2626}
|
||||
.ep-alert{padding:10px 15px;border-radius:8px;font-size:13px;margin-bottom:16px;border-left:3px solid}
|
||||
.ep-alert.warn{background:#fffbeb;color:#92400e;border-color:#f59e0b}
|
||||
.ep-inline{display:grid;grid-template-columns:1fr 1fr;gap:10px;padding:14px}
|
||||
@media(max-width:640px){.ep-inline{grid-column:1fr}}
|
||||
.ep-cat-row{display:flex;align-items:center;gap:10px;padding:9px 12px;border-radius:8px;border:1px solid #e5e7eb;background:#fff;margin-bottom:8px}
|
||||
.ep-cat-nm{flex:1;font-size:13px;font-weight:600}.ep-cat-ct{font-size:11px;color:#9ca3af}
|
||||
.ep-cat-ei{flex:1;padding:5px 9px;border:1px solid #3b82f6;border-radius:6px;font-size:13px;outline:none}
|
||||
.ep-tc{border:1px solid #e5e7eb;border-radius:10px;padding:16px;margin-bottom:12px;display:flex;gap:12px;background:#fff}
|
||||
.ep-tav{width:42px;height:42px;border-radius:50%;object-fit:cover;flex-shrink:0;background:#e5e7eb}
|
||||
.ep-tavph{width:42px;height:42px;border-radius:50%;background:#2563eb;color:#fff;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:16px;flex-shrink:0}
|
||||
.ep-tb{flex:1;min-width:0}.ep-tn{font-weight:700;font-size:13px}.ep-tl{font-size:11px;color:#6b7280;margin-bottom:5px}
|
||||
.ep-tm{font-size:13px;color:#374151;margin-bottom:8px;line-height:1.5}
|
||||
.ep-te{width:100%;border:1px solid #d1d5db;border-radius:6px;padding:6px 9px;font-size:12px;margin-bottom:7px;resize:vertical;min-height:52px}
|
||||
.ep-tacts{display:flex;gap:7px;flex-wrap:wrap}
|
||||
.ep-ftabs{display:flex;gap:7px;margin-bottom:18px;flex-wrap:wrap}
|
||||
.ep-ft{padding:6px 15px;border-radius:7px;font-size:12px;font-weight:600;cursor:pointer;border:1px solid #d1d5db;background:#fff;color:#374151;transition:all .15s}
|
||||
.ep-ft.active{background:#2563eb;color:#fff;border-color:#2563eb}
|
||||
.ep-empty{text-align:center;padding:36px;color:#9ca3af;font-size:13px}
|
||||
.ep-spec-img{width:52px;height:52px;border-radius:8px;object-fit:cover;background:#e5e7eb;flex-shrink:0}
|
||||
.ep-price-old{text-decoration:line-through;color:#9ca3af;font-size:11px}
|
||||
.ep-price-new{color:#16a34a;font-weight:700}
|
||||
`;
|
||||
const sel=document.createElement('style');sel.textContent=css;document.head.appendChild(sel);
|
||||
|
||||
/* State */
|
||||
let activeTab='dashboard',tFilter='pending';
|
||||
let destinations=[],specials=[],testimonials=[],categories=[];
|
||||
|
||||
async function loadAll(){
|
||||
const r=await Promise.allSettled([
|
||||
fetch(API+'/destinations',{headers:authHdr()}).then(r=>r.json()),
|
||||
fetch(API+'/specials', {headers:authHdr()}).then(r=>r.json()),
|
||||
fetch(API+'/testimonials/all',{headers:authHdr()}).then(r=>r.json()),
|
||||
fetch(API+'/categories', {headers:authHdr()}).then(r=>r.json())
|
||||
]);
|
||||
if(r[0].status==='fulfilled'&&Array.isArray(r[0].value))destinations=r[0].value;
|
||||
if(r[1].status==='fulfilled'&&Array.isArray(r[1].value))specials=r[1].value;
|
||||
if(r[2].status==='fulfilled'&&Array.isArray(r[2].value))testimonials=r[2].value;
|
||||
if(r[3].status==='fulfilled'&&Array.isArray(r[3].value))categories=r[3].value;
|
||||
}
|
||||
|
||||
/* Shell */
|
||||
function buildShell(){
|
||||
const p=document.createElement('div');p.id='ep-portal';
|
||||
p.innerHTML=`
|
||||
<div id="ep-hdr">
|
||||
<div class="ep-logo">Epic Travel <span>Admin</span></div>
|
||||
<div class="ep-hdr-acts">
|
||||
<a href="/" class="ep-hbtn" target="_blank">🌐 View Site</a>
|
||||
<button class="ep-hbtn red" id="ep-logout">⏻ Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ep-tabs">
|
||||
<button class="ep-tab active" data-tab="dashboard">📊 Dashboard</button>
|
||||
<button class="ep-tab" data-tab="destinations">🗺 Destinations</button>
|
||||
<button class="ep-tab" data-tab="specials">⭐ Specials</button>
|
||||
<button class="ep-tab" data-tab="testimonials">💬 Testimonials <span class="ep-tbadge" id="ep-pb" style="display:none"></span></button>
|
||||
</div>
|
||||
<div id="ep-body"></div>`;
|
||||
p.querySelector('#ep-logout').onclick=()=>{localStorage.removeItem('isAdminAuthenticated');localStorage.removeItem('auth_token');window.location.href='/admin';};
|
||||
p.querySelectorAll('.ep-tab[data-tab]').forEach(b=>{b.onclick=()=>{activeTab=b.dataset.tab;p.querySelectorAll('.ep-tab').forEach(t=>t.classList.remove('active'));b.classList.add('active');render();};});
|
||||
return p;
|
||||
}
|
||||
|
||||
/* Dashboard */
|
||||
function rDash(){
|
||||
const dm=Object.fromEntries(destinations.map(d=>[d.id,d]));
|
||||
const pen=testimonials.filter(t=>t.status==='pending').length;
|
||||
const app=testimonials.filter(t=>t.status==='approved').length;
|
||||
let h=`<div class="ep-g4">
|
||||
<div class="ep-stat"><div class="ep-acc acb"></div><div class="ep-si">🗺</div><div class="ep-sv">${destinations.length}</div><div class="ep-sl">Destinations</div></div>
|
||||
<div class="ep-stat"><div class="ep-acc acg"></div><div class="ep-si">⭐</div><div class="ep-sv">${specials.length}</div><div class="ep-sl">Active Specials</div></div>
|
||||
<div class="ep-stat"><div class="ep-acc aca"></div><div class="ep-si">⏳</div><div class="ep-sv">${pen}</div><div class="ep-sl">Pending Reviews</div></div>
|
||||
<div class="ep-stat"><div class="ep-acc acp"></div><div class="ep-si">✅</div><div class="ep-sv">${app}</div><div class="ep-sl">Approved Testimonials</div></div>
|
||||
</div>`;
|
||||
if(pen>0)h+=`<div class="ep-alert warn">⚠️ <strong>${pen}</strong> testimonial${pen>1?'s':''} awaiting review.</div>`;
|
||||
|
||||
/* Destinations age */
|
||||
const sd=[...destinations].sort((a,b)=>new Date(a.created_at)-new Date(b.created_at));
|
||||
h+=`<div class="ep-card"><div class="ep-ch"><div><div class="ep-ct">🗺 Destinations</div><div class="ep-cs">Oldest entries first</div></div></div><table class="ep-tbl"><thead><tr><th>Destination</th><th>Category</th><th>Price</th><th>In System Since</th></tr></thead><tbody>`;
|
||||
sd.forEach(d=>{h+=`<tr><td><strong>${esc(d.name)}</strong> <span style="color:#9ca3af;font-size:11px">${esc(d.location)}</span></td><td><span class="ep-b bb">${esc(d.category)}</span></td><td>${fmtPrice(d.price)}</td><td>${ageBdg(daysAgo(d.created_at))}</td></tr>`;});
|
||||
h+=`</tbody></table></div>`;
|
||||
|
||||
/* Specials expiry */
|
||||
const ss=[...specials].sort((a,b)=>new Date(a.end_date)-new Date(b.end_date));
|
||||
h+=`<div class="ep-card"><div class="ep-ch"><div><div class="ep-ct">⭐ Weekly Specials</div><div class="ep-cs">Expiring soonest first</div></div></div><table class="ep-tbl"><thead><tr><th>Image</th><th>Destination</th><th>Original Price</th><th>Discount</th><th>Sale Price</th><th>Expires</th><th>Status</th></tr></thead><tbody>`;
|
||||
ss.forEach(s=>{
|
||||
const dest=dm[s.destination_id];
|
||||
const basePrice=s.price!=null?parseFloat(s.price):(dest?parseFloat(dest.price):0);
|
||||
const salePrice=basePrice*(1-parseFloat(s.discount)/100);
|
||||
const imgSrc=s.image_path||(dest?dest.image:'');
|
||||
h+=`<tr>
|
||||
<td>${imgSrc?`<img src="${esc(imgSrc)}" class="ep-thumb" onerror="this.style.opacity='.2'">`:''}</td>
|
||||
<td><strong>${esc(dest?dest.name:s.destination_id)}</strong></td>
|
||||
<td class="ep-price-old">${fmtPrice(basePrice)}</td>
|
||||
<td><span class="ep-b bg">${parseFloat(s.discount)}% OFF</span></td>
|
||||
<td class="ep-price-new">${fmtPrice(salePrice)}</td>
|
||||
<td>${new Date(s.end_date).toLocaleDateString()}</td>
|
||||
<td>${expBdg(daysLeft(s.end_date))}</td>
|
||||
</tr>`;
|
||||
});
|
||||
h+=`</tbody></table></div>`;
|
||||
|
||||
/* Pending testimonials */
|
||||
const pl=testimonials.filter(t=>t.status==='pending');
|
||||
if(pl.length){
|
||||
h+=`<div class="ep-card"><div class="ep-ch"><div class="ep-ct">⏳ Pending Testimonials</div></div><div class="ep-cb">`;
|
||||
pl.forEach(t=>{
|
||||
const av=t.image_path?`<img class="ep-tav" src="${esc(t.image_path)}" alt="">`:`<div class="ep-tavph">${esc(t.full_name.charAt(0))}</div>`;
|
||||
h+=`<div class="ep-tc" data-id="${esc(t.id)}">${av}<div class="ep-tb"><div class="ep-tn">${esc(t.full_name)}</div><div class="ep-tl">${esc(t.location)}</div><div class="ep-tm">${esc(t.message)}</div><div class="ep-tacts"><button class="ep-btn ep-suc" data-qa="${esc(t.id)}">✓ Approve</button><button class="ep-btn ep-dan" data-qd="${esc(t.id)}">✗ Deny</button></div></div></div>`;
|
||||
});
|
||||
h+=`</div></div>`;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
/* Destinations */
|
||||
function destRow(d){
|
||||
return`<tr data-did="${esc(d.id)}">
|
||||
<td><img class="ep-thumb" src="${esc(d.image)}" alt="" onerror="this.style.opacity='.2'"></td>
|
||||
<td><strong>${esc(d.name)}</strong><br><span style="color:#9ca3af;font-size:11px">${esc(d.location)}</span></td>
|
||||
<td><span class="ep-b bb">${esc(d.category)}</span></td>
|
||||
<td>${fmtPrice(d.price)}</td>
|
||||
<td>⭐ ${parseFloat(d.rating).toFixed(1)}</td>
|
||||
<td>${ageBdg(daysAgo(d.created_at))}</td>
|
||||
<td style="white-space:nowrap"><button class="ep-btn ep-gho" data-ed="${esc(d.id)}">✏ Edit</button> <button class="ep-btn ep-dan" data-dd="${esc(d.id)}">🗑</button></td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function rDest(){
|
||||
const cc={};destinations.forEach(d=>{cc[d.category]=(cc[d.category]||0)+1;});
|
||||
return`<div class="ep-card">
|
||||
<div class="ep-ch"><div><div class="ep-ct">🏷 Categories</div><div class="ep-cs">Add, rename, or remove destination categories</div></div><button class="ep-btn ep-pri" id="ep-cat-tog">+ Add Category</button></div>
|
||||
<div class="ep-cb">
|
||||
<div class="ep-addbox" id="ep-cat-box">
|
||||
<div style="display:flex;gap:10px;align-items:center">
|
||||
<input class="ep-inp" id="ep-cat-new" placeholder="New category name" style="max-width:260px">
|
||||
<button class="ep-btn ep-pri" id="ep-cat-save">Save</button>
|
||||
<button class="ep-btn ep-gho" id="ep-cat-cancel">Cancel</button>
|
||||
<span class="ep-smsg" id="ep-cat-msg"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ep-cat-list">${categories.map(c=>{const n=cc[c.name]||0;return`<div class="ep-cat-row" data-cid="${c.id}"><span class="ep-cat-nm">${esc(c.name)}</span><span class="ep-cat-ct">${n} destination${n!==1?'s':''}</span><button class="ep-btn ep-gho" data-ren="${c.id}" data-cv="${esc(c.name)}" style="padding:4px 10px;font-size:11px">Rename</button><button class="ep-btn ep-dan" data-dc="${c.id}" data-dn="${esc(c.name)}" style="padding:4px 10px;font-size:11px"${n>0?' disabled title="In use"':''}>Delete</button></div>`;}).join('')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ep-card">
|
||||
<div class="ep-ch"><div><div class="ep-ct">🗺 All Destinations (${destinations.length})</div></div><button class="ep-btn ep-pri" id="ep-dest-tog">+ Add Destination</button></div>
|
||||
<div class="ep-cb">
|
||||
<div class="ep-addbox" id="ep-dest-box">
|
||||
<div style="font-weight:700;font-size:13px;margin-bottom:14px">New Destination</div>
|
||||
<div class="ep-form">
|
||||
<div class="ep-fld"><span class="ep-lbl">Name *</span><input class="ep-inp" id="ep-dn-name" placeholder="e.g. Paris"></div>
|
||||
<div class="ep-fld"><span class="ep-lbl">Location *</span><input class="ep-inp" id="ep-dn-loc" placeholder="e.g. France"></div>
|
||||
<div class="ep-fld"><span class="ep-lbl">Category *</span><select class="ep-sel" id="ep-dn-cat">${catOpts(categories,'')}</select></div>
|
||||
<div class="ep-fld"><span class="ep-lbl">Price (USD) *</span><input class="ep-inp" id="ep-dn-price" type="number" placeholder="1299"></div>
|
||||
<div class="ep-fld"><span class="ep-lbl">Rating (1–5)</span><input class="ep-inp" id="ep-dn-rating" type="number" step=".1" min="1" max="5" value="4.5"></div>
|
||||
<div class="ep-fld ep-full"><span class="ep-lbl">Image *</span>${imgPicker('ep-dn-img','')}</div>
|
||||
<div class="ep-fld ep-full"><span class="ep-lbl">Description *</span><textarea class="ep-ta" id="ep-dn-desc"></textarea></div>
|
||||
<div class="ep-frow"><span class="ep-smsg" id="ep-dn-msg"></span><button class="ep-btn ep-gho" id="ep-dn-cancel">Cancel</button><button class="ep-btn ep-pri" id="ep-dn-save">Save Destination</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<table class="ep-tbl"><thead><tr><th>Photo</th><th>Name & Location</th><th>Category</th><th>Price</th><th>Rating</th><th>In System</th><th>Actions</th></tr></thead>
|
||||
<tbody id="ep-dest-tbody">${destinations.map(destRow).join('')}</tbody></table>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* Specials */
|
||||
function specImg(s){
|
||||
const dm=Object.fromEntries(destinations.map(d=>[d.id,d]));
|
||||
const dest=dm[s.destination_id];
|
||||
return s.image_path||(dest?dest.image:'')||'';
|
||||
}
|
||||
function specRow(s){
|
||||
const dm=Object.fromEntries(destinations.map(d=>[d.id,d]));
|
||||
const dest=dm[s.destination_id];
|
||||
const base=s.price!=null?parseFloat(s.price):(dest?parseFloat(dest.price):0);
|
||||
const sale=base*(1-parseFloat(s.discount)/100);
|
||||
const img=specImg(s);
|
||||
return`<tr data-sid="${esc(s.id)}">
|
||||
<td>${img?`<img src="${esc(img)}" class="ep-thumb" onerror="this.style.opacity='.2'">`:''}</td>
|
||||
<td><strong>${esc(dest?dest.name:s.destination_id)}</strong></td>
|
||||
<td><span class="ep-b bg">${parseFloat(s.discount)}% OFF</span></td>
|
||||
<td class="ep-price-old">${fmtPrice(base)}</td>
|
||||
<td class="ep-price-new">${fmtPrice(sale)}</td>
|
||||
<td>${new Date(s.end_date).toLocaleDateString()}</td>
|
||||
<td>${expBdg(daysLeft(s.end_date))}</td>
|
||||
<td style="white-space:nowrap"><button class="ep-btn ep-gho" data-es="${esc(s.id)}">✏ Edit</button> <button class="ep-btn ep-dan" data-ds="${esc(s.id)}">🗑</button></td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function rSpec(){
|
||||
const destOpts=destinations.map(d=>`<option value="${esc(d.id)}">${esc(d.name)}</option>`).join('');
|
||||
return`<div class="ep-card">
|
||||
<div class="ep-ch"><div><div class="ep-ct">⭐ Weekly Specials (${specials.length})</div></div><button class="ep-btn ep-pri" id="ep-spec-tog">+ Add Special</button></div>
|
||||
<div class="ep-cb">
|
||||
<div class="ep-addbox" id="ep-spec-box">
|
||||
<div style="font-weight:700;font-size:13px;margin-bottom:14px">New Special</div>
|
||||
<div class="ep-form">
|
||||
<div class="ep-fld"><span class="ep-lbl">Destination *</span><select class="ep-sel" id="ep-sn-dest">${destOpts}</select></div>
|
||||
<div class="ep-fld"><span class="ep-lbl">Original Price (USD) *</span><input class="ep-inp" id="ep-sn-price" type="number" placeholder="1299" title="Base price before discount"></div>
|
||||
<div class="ep-fld"><span class="ep-lbl">Discount % *</span><input class="ep-inp" id="ep-sn-disc" type="number" min="1" max="99" placeholder="25"></div>
|
||||
<div class="ep-fld"><span class="ep-lbl">End Date *</span><input class="ep-inp" id="ep-sn-end" type="date"></div>
|
||||
<div class="ep-fld ep-full"><span class="ep-lbl">Special Image (optional — defaults to destination photo)</span>${imgPicker('ep-sn-img','')}</div>
|
||||
<div class="ep-fld ep-full"><span class="ep-lbl">Highlights (one per line)</span><textarea class="ep-ta" id="ep-sn-hls" placeholder="Free spa treatment Complimentary airport transfer"></textarea></div>
|
||||
<div class="ep-frow"><span class="ep-smsg" id="ep-sn-msg"></span><button class="ep-btn ep-gho" id="ep-sn-cancel">Cancel</button><button class="ep-btn ep-pri" id="ep-sn-save">Save Special</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<table class="ep-tbl"><thead><tr><th>Image</th><th>Destination</th><th>Discount</th><th>Original Price</th><th>Sale Price</th><th>End Date</th><th>Status</th><th>Actions</th></tr></thead>
|
||||
<tbody id="ep-spec-tbody">${specials.map(specRow).join('')}</tbody></table>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* Testimonials */
|
||||
function rTest(){
|
||||
const cn={pending:0,approved:0,denied:0};testimonials.forEach(t=>cn[t.status]++);
|
||||
const list=tFilter==='all'?testimonials:testimonials.filter(t=>t.status===tFilter);
|
||||
let h=`<div class="ep-ftabs">
|
||||
<button class="ep-ft ${tFilter==='pending'?'active':''}" data-tf="pending">⏳ Pending (${cn.pending})</button>
|
||||
<button class="ep-ft ${tFilter==='approved'?'active':''}" data-tf="approved">✅ Approved (${cn.approved})</button>
|
||||
<button class="ep-ft ${tFilter==='denied'?'active':''}" data-tf="denied">❌ Denied (${cn.denied})</button>
|
||||
<button class="ep-ft ${tFilter==='all'?'active':''}" data-tf="all">All (${testimonials.length})</button>
|
||||
</div>`;
|
||||
if(!list.length)return h+`<div class="ep-empty">💬 No testimonials here.</div>`;
|
||||
list.forEach(t=>{
|
||||
const av=t.image_path?`<img class="ep-tav" src="${esc(t.image_path)}" alt="">`:`<div class="ep-tavph">${esc(t.full_name.charAt(0))}</div>`;
|
||||
const sb=`<span class="ep-b ${t.status==='approved'?'bg':t.status==='denied'?'br':'by'}">${t.status}</span>`;
|
||||
h+=`<div class="ep-tc" data-tid="${esc(t.id)}">${av}<div class="ep-tb">
|
||||
<div class="ep-tn">${esc(t.full_name)} ${sb}</div><div class="ep-tl">${esc(t.location)}</div>
|
||||
<div class="ep-tm">${esc(t.message)}</div>
|
||||
<textarea class="ep-te">${esc(t.message)}</textarea>
|
||||
<div class="ep-tacts">
|
||||
${t.status!=='approved'?`<button class="ep-btn ep-suc" data-ta="approve" data-tid="${esc(t.id)}">✓ Approve</button>`:''}
|
||||
${t.status!=='denied'?`<button class="ep-btn ep-dan" data-ta="deny" data-tid="${esc(t.id)}">✗ Deny</button>`:''}
|
||||
<button class="ep-btn ep-gho" data-ta="save" data-tid="${esc(t.id)}">💾 Save Edit</button>
|
||||
<button class="ep-btn ep-dan" data-ta="delete" data-tid="${esc(t.id)}">🗑 Delete</button>
|
||||
</div></div></div>`;
|
||||
});
|
||||
return h;
|
||||
}
|
||||
|
||||
/* Render */
|
||||
function render(){
|
||||
const body=document.getElementById('ep-body');if(!body)return;
|
||||
switch(activeTab){
|
||||
case'dashboard': body.innerHTML=rDash(); wireDash(); break;
|
||||
case'destinations':body.innerHTML=rDest(); wireDest(); break;
|
||||
case'specials': body.innerHTML=rSpec(); wireSpec(); break;
|
||||
case'testimonials':body.innerHTML=rTest(); wireTest(); break;
|
||||
}
|
||||
const pb=document.getElementById('ep-pb');
|
||||
if(pb){const n=testimonials.filter(t=>t.status==='pending').length;pb.textContent=n||'';pb.style.display=n?'':'none';}
|
||||
}
|
||||
|
||||
/* Wire: Dashboard */
|
||||
function wireDash(){
|
||||
document.querySelectorAll('[data-qa]').forEach(b=>b.onclick=async()=>{await api(`/testimonials/${b.dataset.qa}`,{method:'PUT',body:JSON.stringify({status:'approved'})});await loadAll();render();});
|
||||
document.querySelectorAll('[data-qd]').forEach(b=>b.onclick=async()=>{await api(`/testimonials/${b.dataset.qd}`,{method:'PUT',body:JSON.stringify({status:'denied'})});await loadAll();render();});
|
||||
}
|
||||
|
||||
/* Wire: Destinations */
|
||||
function wireDest(){
|
||||
/* Categories */
|
||||
const ct=document.getElementById('ep-cat-tog'),cb=document.getElementById('ep-cat-box');
|
||||
if(ct)ct.onclick=()=>cb.classList.toggle('open');
|
||||
const cc=document.getElementById('ep-cat-cancel');if(cc)cc.onclick=()=>cb.classList.remove('open');
|
||||
const cs=document.getElementById('ep-cat-save');
|
||||
if(cs)cs.onclick=async()=>{
|
||||
const m=document.getElementById('ep-cat-msg'),nm=document.getElementById('ep-cat-new').value.trim();
|
||||
if(!nm){m.textContent='Name required';m.className='ep-smsg err';return;}
|
||||
try{await api('/categories',{method:'POST',body:JSON.stringify({name:nm})});m.textContent='Added!';m.className='ep-smsg ok';await loadAll();activeTab='destinations';render();}
|
||||
catch(e){m.textContent=e.message;m.className='ep-smsg err';}
|
||||
};
|
||||
document.querySelectorAll('[data-ren]').forEach(b=>b.onclick=()=>{
|
||||
const id=b.dataset.ren,cur=b.dataset.cv,row=b.closest('.ep-cat-row');
|
||||
row.innerHTML=`<input class="ep-cat-ei" id="ep-ren-${id}" value="${esc(cur)}"><button class="ep-btn ep-pri" data-rs="${id}" style="padding:5px 10px;font-size:11px">Save</button><button class="ep-btn ep-gho" data-rc style="padding:5px 10px;font-size:11px">Cancel</button><span class="ep-smsg" id="ep-rm-${id}"></span>`;
|
||||
row.querySelector('[data-rc]').onclick=()=>{activeTab='destinations';render();};
|
||||
row.querySelector(`[data-rs="${id}"]`).onclick=async()=>{
|
||||
const nm=document.getElementById(`ep-ren-${id}`).value.trim(),m=document.getElementById(`ep-rm-${id}`);
|
||||
if(!nm){m.textContent='Required';m.className='ep-smsg err';return;}
|
||||
try{await api(`/categories/${id}`,{method:'PUT',body:JSON.stringify({name:nm})});await loadAll();activeTab='destinations';render();}
|
||||
catch(e){m.textContent=e.message;m.className='ep-smsg err';}
|
||||
};
|
||||
});
|
||||
document.querySelectorAll('[data-dc]').forEach(b=>b.onclick=async()=>{
|
||||
if(!confirm(`Delete category "${b.dataset.dn}"?`))return;
|
||||
try{await api(`/categories/${b.dataset.dc}`,{method:'DELETE'});await loadAll();activeTab='destinations';render();}catch(e){alert(e.message);}
|
||||
});
|
||||
|
||||
/* Destination add */
|
||||
const dt=document.getElementById('ep-dest-tog'),db=document.getElementById('ep-dest-box');
|
||||
if(dt)dt.onclick=()=>db.classList.toggle('open');
|
||||
const dn=document.getElementById('ep-dn-cancel');if(dn)dn.onclick=()=>db.classList.remove('open');
|
||||
const ds=document.getElementById('ep-dn-save');
|
||||
if(ds)ds.onclick=async()=>{
|
||||
const m=document.getElementById('ep-dn-msg');
|
||||
try{
|
||||
const img=await getImg('ep-dn-img','');
|
||||
await api('/destinations',{method:'POST',body:JSON.stringify({
|
||||
name:document.getElementById('ep-dn-name').value.trim(),
|
||||
location:document.getElementById('ep-dn-loc').value.trim(),
|
||||
category:document.getElementById('ep-dn-cat').value,
|
||||
price:document.getElementById('ep-dn-price').value,
|
||||
rating:document.getElementById('ep-dn-rating').value||'4.5',
|
||||
image:img,
|
||||
description:document.getElementById('ep-dn-desc').value.trim()
|
||||
})});
|
||||
m.textContent='Saved!';m.className='ep-smsg ok';await loadAll();activeTab='destinations';render();
|
||||
}catch(e){m.textContent=e.message;m.className='ep-smsg err';}
|
||||
};
|
||||
|
||||
/* Destination edit */
|
||||
document.querySelectorAll('[data-ed]').forEach(b=>b.onclick=()=>{
|
||||
const id=b.dataset.ed,d=destinations.find(x=>x.id==id);if(!d)return;
|
||||
const row=document.querySelector(`tr[data-did="${id}"]`);
|
||||
row.innerHTML=`<td colspan="7"><div class="ep-inline ep-form">
|
||||
<div class="ep-fld"><span class="ep-lbl">Name</span><input class="ep-inp" id="ei-n-${id}" value="${esc(d.name)}"></div>
|
||||
<div class="ep-fld"><span class="ep-lbl">Location</span><input class="ep-inp" id="ei-l-${id}" value="${esc(d.location)}"></div>
|
||||
<div class="ep-fld"><span class="ep-lbl">Category</span><select class="ep-sel" id="ei-c-${id}">${catOpts(categories,d.category)}</select></div>
|
||||
<div class="ep-fld"><span class="ep-lbl">Price</span><input class="ep-inp" type="number" id="ei-p-${id}" value="${esc(d.price)}"></div>
|
||||
<div class="ep-fld"><span class="ep-lbl">Rating</span><input class="ep-inp" type="number" step=".1" id="ei-r-${id}" value="${esc(d.rating)}"></div>
|
||||
<div class="ep-fld ep-full"><span class="ep-lbl">Image (upload new to replace)</span>${imgPicker('ei-i-'+id,d.image)}</div>
|
||||
<div class="ep-fld ep-full"><span class="ep-lbl">Description</span><textarea class="ep-ta" id="ei-d-${id}">${esc(d.description)}</textarea></div>
|
||||
<div class="ep-frow">
|
||||
<button class="ep-btn ep-gho" data-ec="${id}">Cancel</button>
|
||||
<button class="ep-btn ep-pri" data-es="${id}">Save Changes</button>
|
||||
</div>
|
||||
</div></td>`;
|
||||
row.querySelector(`[data-ec="${id}"]`).onclick=()=>{row.outerHTML=destRow(d);wireDest();};
|
||||
row.querySelector(`[data-es="${id}"]`).onclick=async()=>{
|
||||
try{
|
||||
const img=await getImg('ei-i-'+id,d.image);
|
||||
await api(`/destinations/${id}`,{method:'PUT',body:JSON.stringify({
|
||||
name:document.getElementById(`ei-n-${id}`).value,
|
||||
location:document.getElementById(`ei-l-${id}`).value,
|
||||
category:document.getElementById(`ei-c-${id}`).value,
|
||||
price:document.getElementById(`ei-p-${id}`).value,
|
||||
rating:document.getElementById(`ei-r-${id}`).value,
|
||||
image:img,
|
||||
description:document.getElementById(`ei-d-${id}`).value
|
||||
})});
|
||||
await loadAll();activeTab='destinations';render();
|
||||
}catch(e){alert(e.message);}
|
||||
};
|
||||
});
|
||||
|
||||
/* Destination delete */
|
||||
document.querySelectorAll('[data-dd]').forEach(b=>b.onclick=async()=>{
|
||||
if(!confirm('Delete this destination? This cannot be undone.'))return;
|
||||
try{await api(`/destinations/${b.dataset.dd}`,{method:'DELETE'});await loadAll();activeTab='destinations';render();}catch(e){alert(e.message);}
|
||||
});
|
||||
}
|
||||
|
||||
/* Wire: Specials */
|
||||
function wireSpec(){
|
||||
const st=document.getElementById('ep-spec-tog'),sb=document.getElementById('ep-spec-box');
|
||||
if(st)st.onclick=()=>sb.classList.toggle('open');
|
||||
const sc=document.getElementById('ep-sn-cancel');if(sc)sc.onclick=()=>sb.classList.remove('open');
|
||||
const ss=document.getElementById('ep-sn-save');
|
||||
if(ss)ss.onclick=async()=>{
|
||||
const m=document.getElementById('ep-sn-msg');
|
||||
try{
|
||||
const img=await getImg('ep-sn-img',null);
|
||||
const hls=document.getElementById('ep-sn-hls').value.split('\n').map(l=>l.trim()).filter(Boolean);
|
||||
await api('/specials',{method:'POST',body:JSON.stringify({
|
||||
destination_id:document.getElementById('ep-sn-dest').value,
|
||||
price:document.getElementById('ep-sn-price').value,
|
||||
discount:document.getElementById('ep-sn-disc').value,
|
||||
end_date:document.getElementById('ep-sn-end').value,
|
||||
image_path:img,
|
||||
highlights:hls
|
||||
})});
|
||||
m.textContent='Saved!';m.className='ep-smsg ok';await loadAll();activeTab='specials';render();
|
||||
}catch(e){m.textContent=e.message;m.className='ep-smsg err';}
|
||||
};
|
||||
|
||||
/* Specials edit */
|
||||
document.querySelectorAll('[data-es]').forEach(b=>b.onclick=()=>{
|
||||
const id=b.dataset.es,s=specials.find(x=>x.id==id);if(!s)return;
|
||||
const dm=Object.fromEntries(destinations.map(d=>[d.id,d]));
|
||||
const dest=dm[s.destination_id];
|
||||
const hls=Array.isArray(s.highlights)?s.highlights.join('\n'):'';
|
||||
const curImg=specImg(s);
|
||||
const row=document.querySelector(`tr[data-sid="${id}"]`);
|
||||
row.innerHTML=`<td colspan="8"><div class="ep-inline ep-form">
|
||||
<div class="ep-fld"><span class="ep-lbl">Original Price</span><input class="ep-inp" type="number" id="si-p-${id}" value="${esc(s.price!=null?s.price:(dest?dest.price:''))}"></div>
|
||||
<div class="ep-fld"><span class="ep-lbl">Discount %</span><input class="ep-inp" type="number" id="si-d-${id}" value="${esc(s.discount)}"></div>
|
||||
<div class="ep-fld"><span class="ep-lbl">End Date</span><input class="ep-inp" type="date" id="si-e-${id}" value="${esc(s.end_date)}"></div>
|
||||
<div class="ep-fld ep-full"><span class="ep-lbl">Image (upload to replace; leave blank to use destination photo)</span>${imgPicker('si-i-'+id,curImg)}</div>
|
||||
<div class="ep-fld ep-full"><span class="ep-lbl">Highlights (one per line)</span><textarea class="ep-ta" id="si-h-${id}">${esc(hls)}</textarea></div>
|
||||
<div class="ep-frow">
|
||||
<button class="ep-btn ep-gho" data-sc="${id}">Cancel</button>
|
||||
<button class="ep-btn ep-pri" data-sv="${id}">Save</button>
|
||||
</div>
|
||||
</div></td>`;
|
||||
row.querySelector(`[data-sc="${id}"]`).onclick=()=>{row.outerHTML=specRow(s);wireSpec();};
|
||||
row.querySelector(`[data-sv="${id}"]`).onclick=async()=>{
|
||||
try{
|
||||
const newImg=await getImg('si-i-'+id,s.image_path||null);
|
||||
const hls2=document.getElementById(`si-h-${id}`).value.split('\n').map(l=>l.trim()).filter(Boolean);
|
||||
await api(`/specials/${id}`,{method:'PUT',body:JSON.stringify({
|
||||
price:document.getElementById(`si-p-${id}`).value,
|
||||
discount:document.getElementById(`si-d-${id}`).value,
|
||||
end_date:document.getElementById(`si-e-${id}`).value,
|
||||
image_path:newImg,
|
||||
highlights:hls2
|
||||
})});
|
||||
await loadAll();activeTab='specials';render();
|
||||
}catch(e){alert(e.message);}
|
||||
};
|
||||
});
|
||||
|
||||
/* Specials delete */
|
||||
document.querySelectorAll('[data-ds]').forEach(b=>b.onclick=async()=>{
|
||||
if(!confirm('Delete this special?'))return;
|
||||
try{await api(`/specials/${b.dataset.ds}`,{method:'DELETE'});await loadAll();activeTab='specials';render();}catch(e){alert(e.message);}
|
||||
});
|
||||
}
|
||||
|
||||
/* Wire: Testimonials */
|
||||
function wireTest(){
|
||||
document.querySelectorAll('[data-tf]').forEach(b=>b.onclick=()=>{tFilter=b.dataset.tf;render();});
|
||||
document.querySelectorAll('[data-ta]').forEach(b=>b.onclick=async()=>{
|
||||
const id=b.dataset.tid,action=b.dataset.ta;
|
||||
const card=document.querySelector(`.ep-tc[data-tid="${id}"]`);
|
||||
try{
|
||||
if(action==='approve')await api(`/testimonials/${id}`,{method:'PUT',body:JSON.stringify({status:'approved'})});
|
||||
else if(action==='deny')await api(`/testimonials/${id}`,{method:'PUT',body:JSON.stringify({status:'denied'})});
|
||||
else if(action==='save'){const ta=card.querySelector('.ep-te');await api(`/testimonials/${id}`,{method:'PUT',body:JSON.stringify({message:ta.value})});}
|
||||
else if(action==='delete'){if(!confirm('Delete?'))return;await api(`/testimonials/${id}`,{method:'DELETE'});}
|
||||
await loadAll();render();
|
||||
}catch(e){alert(e.message);}
|
||||
});
|
||||
}
|
||||
|
||||
/* Init */
|
||||
let injected=false;
|
||||
function tryInject(){
|
||||
if(injected)return;
|
||||
if(!window.location.pathname.startsWith('/admin/dashboard'))return;
|
||||
if(!localStorage.getItem('isAdminAuthenticated'))return;
|
||||
const root=document.getElementById('root');
|
||||
if(!root||!root.children.length)return;
|
||||
injected=true;obs.disconnect();
|
||||
Array.from(root.children).forEach(c=>c.style.display='none');
|
||||
const portal=buildShell();root.insertBefore(portal,root.firstChild);
|
||||
loadAll().then(render);
|
||||
}
|
||||
const obs=new MutationObserver(tryInject);
|
||||
obs.observe(document.body,{childList:true,subtree:true});
|
||||
tryInject();
|
||||
})();
|
||||
@@ -0,0 +1,208 @@
|
||||
(function () {
|
||||
const API = 'https://epictravelexpeditions.com/api';
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
#et-admin-section { padding: 32px 0; }
|
||||
#et-admin-section h3 { font-size: 1.4rem; font-weight: 700; color: #111827; margin-bottom: 16px; }
|
||||
.et-admin-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.et-admin-avatar {
|
||||
width: 48px; height: 48px; border-radius: 50%;
|
||||
object-fit: cover; flex-shrink: 0; background: #e5e7eb;
|
||||
}
|
||||
.et-admin-avatar-ph {
|
||||
width: 48px; height: 48px; border-radius: 50%;
|
||||
background: #2563eb; color: #fff;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 700; font-size: 18px; flex-shrink: 0;
|
||||
}
|
||||
.et-admin-body { flex: 1; min-width: 0; }
|
||||
.et-admin-name { font-weight: 700; font-size: 14px; }
|
||||
.et-admin-loc { font-size: 12px; color: #6b7280; margin-bottom: 6px; }
|
||||
.et-admin-msg { font-size: 13px; color: #374151; margin-bottom: 10px; }
|
||||
.et-admin-status {
|
||||
display: inline-block;
|
||||
padding: 2px 10px; border-radius: 999px;
|
||||
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.et-status-pending { background: #fef3c7; color: #92400e; }
|
||||
.et-status-approved { background: #d1fae5; color: #065f46; }
|
||||
.et-status-denied { background: #fee2e2; color: #991b1b; }
|
||||
.et-admin-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.et-btn {
|
||||
padding: 6px 14px; border-radius: 6px; font-size: 12px; font-weight: 600;
|
||||
border: none; cursor: pointer; transition: opacity .15s;
|
||||
}
|
||||
.et-btn:hover { opacity: .8; }
|
||||
.et-btn-approve { background: #16a34a; color: #fff; }
|
||||
.et-btn-deny { background: #dc2626; color: #fff; }
|
||||
.et-btn-delete { background: #6b7280; color: #fff; }
|
||||
.et-btn-save { background: #2563eb; color: #fff; }
|
||||
.et-edit-area {
|
||||
width: 100%; box-sizing: border-box;
|
||||
border: 1px solid #d1d5db; border-radius: 6px;
|
||||
padding: 8px 10px; font-size: 13px;
|
||||
resize: vertical; min-height: 60px; margin-bottom: 6px;
|
||||
}
|
||||
.et-tabs { display: flex; gap: 4px; margin-bottom: 20px; }
|
||||
.et-tab {
|
||||
padding: 6px 16px; border-radius: 6px; font-size: 13px; font-weight: 600;
|
||||
cursor: pointer; border: 1px solid #d1d5db; background: #fff; color: #374151;
|
||||
}
|
||||
.et-tab.active { background: #2563eb; color: #fff; border-color: #2563eb; }
|
||||
.et-empty { color: #9ca3af; font-size: 14px; text-align: center; padding: 32px 0; }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
function statusBadge(s) {
|
||||
return `<span class="et-admin-status et-status-${s}">${s}</span>`;
|
||||
}
|
||||
|
||||
function avatarHtml(t) {
|
||||
if (t.image_path) return `<img class="et-admin-avatar" src="${t.image_path}" alt="">`;
|
||||
return `<div class="et-admin-avatar-ph">${t.full_name.charAt(0).toUpperCase()}</div>`;
|
||||
}
|
||||
|
||||
async function applyAction(id, payload) {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const res = await fetch(`${API}/testimonials/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function deleteTestimonial(id) {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
await fetch(`${API}/testimonials/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
}
|
||||
|
||||
function renderCards(list, container, reload) {
|
||||
container.innerHTML = '';
|
||||
if (!list.length) {
|
||||
container.innerHTML = '<p class="et-empty">No testimonials in this category.</p>';
|
||||
return;
|
||||
}
|
||||
list.forEach(t => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'et-admin-card';
|
||||
card.dataset.id = t.id;
|
||||
card.innerHTML = `
|
||||
${avatarHtml(t)}
|
||||
<div class="et-admin-body">
|
||||
<div class="et-admin-name">${t.full_name}</div>
|
||||
<div class="et-admin-loc">${t.location}</div>
|
||||
${statusBadge(t.status)}
|
||||
<div class="et-admin-msg">${t.message}</div>
|
||||
<textarea class="et-edit-area" data-orig="${t.message.replace(/"/g,'"')}">${t.message}</textarea>
|
||||
<div class="et-admin-actions">
|
||||
${t.status !== 'approved' ? '<button class="et-btn et-btn-approve" data-action="approve">Approve</button>' : ''}
|
||||
${t.status !== 'denied' ? '<button class="et-btn et-btn-deny" data-action="deny">Deny</button>' : ''}
|
||||
<button class="et-btn et-btn-save" data-action="save">Save Edit</button>
|
||||
<button class="et-btn et-btn-delete" data-action="delete">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
card.querySelectorAll('[data-action]').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const action = btn.dataset.action;
|
||||
const textarea = card.querySelector('.et-edit-area');
|
||||
try {
|
||||
if (action === 'approve') await applyAction(t.id, { status: 'approved' });
|
||||
else if (action === 'deny') await applyAction(t.id, { status: 'denied' });
|
||||
else if (action === 'save') await applyAction(t.id, { message: textarea.value });
|
||||
else if (action === 'delete') { if (!confirm('Delete this testimonial?')) return; await deleteTestimonial(t.id); }
|
||||
reload();
|
||||
} catch (e) { alert('Error: ' + e.message); }
|
||||
});
|
||||
});
|
||||
|
||||
container.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
async function buildAdminPanel(mountEl) {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const res = await fetch(`${API}/testimonials/all`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
const all = await res.json();
|
||||
|
||||
const section = document.createElement('div');
|
||||
section.id = 'et-admin-section';
|
||||
section.innerHTML = `
|
||||
<h3>Testimonials</h3>
|
||||
<div class="et-tabs">
|
||||
<button class="et-tab active" data-filter="pending">Pending (${all.filter(t=>t.status==='pending').length})</button>
|
||||
<button class="et-tab" data-filter="approved">Approved (${all.filter(t=>t.status==='approved').length})</button>
|
||||
<button class="et-tab" data-filter="denied">Denied (${all.filter(t=>t.status==='denied').length})</button>
|
||||
<button class="et-tab" data-filter="all">All (${all.length})</button>
|
||||
</div>
|
||||
<div id="et-admin-cards"></div>
|
||||
`;
|
||||
|
||||
let currentFilter = 'pending';
|
||||
const cardsEl = section.querySelector('#et-admin-cards');
|
||||
|
||||
async function reload() {
|
||||
const res = await fetch(`${API}/testimonials/all`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
const fresh = await res.json();
|
||||
const filtered = currentFilter === 'all' ? fresh : fresh.filter(t => t.status === currentFilter);
|
||||
renderCards(filtered, cardsEl, reload);
|
||||
// Update counts
|
||||
section.querySelectorAll('.et-tab').forEach(tab => {
|
||||
const f = tab.dataset.filter;
|
||||
const count = f === 'all' ? fresh.length : fresh.filter(t => t.status === f).length;
|
||||
tab.textContent = `${f.charAt(0).toUpperCase()+f.slice(1)} (${count})`;
|
||||
if (f === currentFilter) tab.classList.add('active');
|
||||
else tab.classList.remove('active');
|
||||
});
|
||||
}
|
||||
|
||||
section.querySelectorAll('.et-tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
currentFilter = tab.dataset.filter;
|
||||
reload();
|
||||
});
|
||||
});
|
||||
|
||||
const initialFiltered = all.filter(t => t.status === 'pending');
|
||||
renderCards(initialFiltered, cardsEl, reload);
|
||||
mountEl.appendChild(section);
|
||||
}
|
||||
|
||||
// Watch for admin dashboard
|
||||
let adminDone = false;
|
||||
const observer = new MutationObserver(() => {
|
||||
if (adminDone) return;
|
||||
if (!window.location.pathname.startsWith('/admin/dashboard')) return;
|
||||
if (!localStorage.getItem('isAdminAuthenticated')) return;
|
||||
|
||||
// Find the main content area of the admin panel
|
||||
const main = document.querySelector('[class*="max-w-7xl"]');
|
||||
if (!main) return;
|
||||
if (document.getElementById('et-admin-section')) return;
|
||||
|
||||
adminDone = true;
|
||||
observer.disconnect();
|
||||
buildAdminPanel(main);
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
})();
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* @license React
|
||||
* react-dom-client.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* react-dom.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* react-jsx-runtime.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* react.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* scheduler.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license lucide-react v0.507.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* react-router v7.11.0
|
||||
*
|
||||
* Copyright (c) Remix Software Inc.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE.md file in the root directory of this source tree.
|
||||
*
|
||||
* @license MIT
|
||||
*/
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,313 @@
|
||||
(function () {
|
||||
const API = 'https://epictravelexpeditions.com/api';
|
||||
|
||||
/* ── Styles ── */
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.et-scroll-track { overflow: hidden; position: relative; }
|
||||
.et-scroll-track::before,
|
||||
.et-scroll-track::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; bottom: 0;
|
||||
width: 80px;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
.et-scroll-track::before { left: 0; background: linear-gradient(to right, white, transparent); }
|
||||
.et-scroll-track::after { right: 0; background: linear-gradient(to left, white, transparent); }
|
||||
.et-scroll-inner {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
width: max-content;
|
||||
animation: et-slide 40s linear infinite;
|
||||
}
|
||||
.et-scroll-inner:hover { animation-play-state: paused; }
|
||||
@keyframes et-slide {
|
||||
0% { transform: translateX(0); }
|
||||
100% { transform: translateX(-50%); }
|
||||
}
|
||||
.et-card {
|
||||
width: 300px;
|
||||
flex-shrink: 0;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,.06);
|
||||
transition: box-shadow .2s;
|
||||
}
|
||||
.et-card:hover { box-shadow: 0 6px 20px rgba(0,0,0,.12); }
|
||||
.et-avatar {
|
||||
width: 56px; height: 56px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
background: #e5e7eb;
|
||||
}
|
||||
.et-avatar-placeholder {
|
||||
width: 56px; height: 56px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #3b82f6, #2563eb);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: #fff; font-size: 22px; font-weight: 700;
|
||||
}
|
||||
.et-name { font-weight: 700; font-size: 15px; color: #111827; margin: 0; }
|
||||
.et-loc { font-size: 13px; color: #6b7280; margin: 2px 0 12px; }
|
||||
.et-msg { font-size: 14px; color: #374151; line-height: 1.6; font-style: italic; }
|
||||
|
||||
/* ── Form ── */
|
||||
#et-form-section {
|
||||
background: #f9fafb;
|
||||
padding: 64px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
#et-form-section h2 {
|
||||
font-size: 2rem; font-weight: 700; color: #111827; margin-bottom: 8px;
|
||||
}
|
||||
#et-form-section p {
|
||||
color: #6b7280; margin-bottom: 32px;
|
||||
}
|
||||
#et-form {
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
#et-form input, #et-form textarea {
|
||||
width: 100%; box-sizing: border-box;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: #111827;
|
||||
background: #fff;
|
||||
outline: none;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
#et-form input:focus, #et-form textarea:focus { border-color: #3b82f6; }
|
||||
#et-form textarea { resize: vertical; min-height: 100px; }
|
||||
.et-file-label {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border: 1px dashed #d1d5db;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
background: #fff;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.et-file-label:hover { border-color: #3b82f6; color: #3b82f6; }
|
||||
#et-file-input { display: none; }
|
||||
#et-preview { width: 64px; height: 64px; border-radius: 50%; object-fit: cover; display: none; }
|
||||
#et-submit {
|
||||
padding: 12px 24px;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background .15s;
|
||||
align-self: flex-end;
|
||||
}
|
||||
#et-submit:hover { background: #1d4ed8; }
|
||||
#et-submit:disabled { background: #93c5fd; cursor: not-allowed; }
|
||||
#et-msg { font-size: 14px; text-align: center; }
|
||||
.et-success { color: #16a34a; }
|
||||
.et-error { color: #dc2626; }
|
||||
#et-img-row { display: flex; align-items: center; gap: 12px; }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
/* ── Build a testimonial card ── */
|
||||
function buildCard(t) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'et-card';
|
||||
|
||||
let avatarHtml;
|
||||
if (t.image_path) {
|
||||
avatarHtml = `<img class="et-avatar" src="${t.image_path}" alt="${t.full_name}" onerror="this.style.display='none'">`;
|
||||
} else {
|
||||
const initial = t.full_name.charAt(0).toUpperCase();
|
||||
avatarHtml = `<div class="et-avatar-placeholder">${initial}</div>`;
|
||||
}
|
||||
|
||||
card.innerHTML = `
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px">
|
||||
${avatarHtml}
|
||||
<div>
|
||||
<p class="et-name">${t.full_name}</p>
|
||||
<p class="et-loc">${t.location}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="et-msg">“${t.message}”</p>
|
||||
`;
|
||||
return card;
|
||||
}
|
||||
|
||||
/* ── Replace static testimonials section ── */
|
||||
function replaceTestimonials(section, testimonials) {
|
||||
const inner = document.createElement('div');
|
||||
inner.className = 'et-scroll-inner';
|
||||
|
||||
const cards = testimonials.map(buildCard);
|
||||
cards.forEach(c => inner.appendChild(c));
|
||||
// Duplicate for seamless loop
|
||||
cards.forEach(c => inner.appendChild(c.cloneNode(true)));
|
||||
|
||||
const track = document.createElement('div');
|
||||
track.className = 'et-scroll-track';
|
||||
track.appendChild(inner);
|
||||
|
||||
// Keep the heading, replace the grid
|
||||
const heading = section.querySelector('[class*="grid"]') || section.lastElementChild;
|
||||
if (heading) heading.replaceWith(track);
|
||||
else section.appendChild(track);
|
||||
}
|
||||
|
||||
/* ── Upload image (no auth required for testimonials) ── */
|
||||
async function uploadImage(file) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const res = await fetch(`${API}/testimonials/upload`, { method: 'POST', body: fd });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Upload failed');
|
||||
return data.url;
|
||||
}
|
||||
|
||||
/* ── Submission form ── */
|
||||
function buildForm() {
|
||||
const section = document.createElement('section');
|
||||
section.id = 'et-form-section';
|
||||
section.innerHTML = `
|
||||
<h2>Share Your Experience</h2>
|
||||
<p>Traveled with us? We'd love to hear about it.</p>
|
||||
<form id="et-form" novalidate>
|
||||
<input id="et-name" type="text" placeholder="Full Name *" required>
|
||||
<input id="et-location" type="text" placeholder="City, State / Country *" required>
|
||||
<textarea id="et-msg-input" placeholder="Tell us about your trip *" required></textarea>
|
||||
<div id="et-img-row">
|
||||
<label class="et-file-label" for="et-file-input">
|
||||
📷 Add a photo (optional)
|
||||
</label>
|
||||
<input id="et-file-input" type="file" accept="image/jpeg,image/png,image/webp">
|
||||
<img id="et-preview" src="" alt="preview">
|
||||
</div>
|
||||
<div id="et-msg"></div>
|
||||
<button id="et-submit" type="submit">Submit Testimonial</button>
|
||||
</form>
|
||||
`;
|
||||
|
||||
setTimeout(() => {
|
||||
const form = document.getElementById('et-form');
|
||||
const nameEl = document.getElementById('et-name');
|
||||
const locEl = document.getElementById('et-location');
|
||||
const msgEl = document.getElementById('et-msg-input');
|
||||
const fileEl = document.getElementById('et-file-input');
|
||||
const preview = document.getElementById('et-preview');
|
||||
const statusEl = document.getElementById('et-msg');
|
||||
const submitBtn = document.getElementById('et-submit');
|
||||
|
||||
fileEl.addEventListener('change', () => {
|
||||
const f = fileEl.files[0];
|
||||
if (f) {
|
||||
preview.src = URL.createObjectURL(f);
|
||||
preview.style.display = 'block';
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
statusEl.textContent = '';
|
||||
const name = nameEl.value.trim();
|
||||
const loc = locEl.value.trim();
|
||||
const msg = msgEl.value.trim();
|
||||
if (!name || !loc || !msg) {
|
||||
statusEl.textContent = 'Please fill in all required fields.';
|
||||
statusEl.className = 'et-error';
|
||||
return;
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Submitting…';
|
||||
|
||||
try {
|
||||
let imagePath = null;
|
||||
if (fileEl.files[0]) {
|
||||
imagePath = await uploadImage(fileEl.files[0]);
|
||||
}
|
||||
|
||||
const res = await fetch(`${API}/testimonials`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ full_name: name, location: loc, message: msg, image_path: imagePath })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Submission failed');
|
||||
|
||||
statusEl.textContent = 'Thank you! Your testimonial has been submitted for review.';
|
||||
statusEl.className = 'et-success';
|
||||
form.reset();
|
||||
preview.style.display = 'none';
|
||||
} catch (err) {
|
||||
statusEl.textContent = err.message;
|
||||
statusEl.className = 'et-error';
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Submit Testimonial';
|
||||
}
|
||||
});
|
||||
}, 0);
|
||||
|
||||
return section;
|
||||
}
|
||||
|
||||
/* ── Main: observe DOM until static testimonials appear ── */
|
||||
async function init() {
|
||||
let testimonials = [];
|
||||
try {
|
||||
const res = await fetch(`${API}/testimonials`);
|
||||
testimonials = await res.json();
|
||||
} catch (_) {}
|
||||
|
||||
let done = false;
|
||||
const observer = new MutationObserver(() => {
|
||||
if (done) return;
|
||||
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
||||
let node;
|
||||
while ((node = walker.nextNode())) {
|
||||
if (node.nodeValue && node.nodeValue.includes('What Our Travelers Say')) {
|
||||
// Walk up to the section/div container
|
||||
let el = node.parentElement;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
if (el && (el.tagName === 'SECTION' || (el.className && el.className.includes('py-')))) break;
|
||||
el = el ? el.parentElement : null;
|
||||
}
|
||||
if (!el) break;
|
||||
done = true;
|
||||
observer.disconnect();
|
||||
|
||||
if (testimonials.length > 0) {
|
||||
replaceTestimonials(el, testimonials);
|
||||
}
|
||||
|
||||
// Inject form after the section
|
||||
const formSection = buildForm();
|
||||
el.after(formSection);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user