[orbis] Weekly backup 2026-07-07 — 318 files changed, 48597 insertions(+), 7 deletions(-)

This commit is contained in:
DO Server Backup
2026-07-07 15:58:55 +00:00
parent 5fda5a1536
commit fe18800d18
318 changed files with 48597 additions and 7 deletions
@@ -0,0 +1,6 @@
*.log
.DS_Store
*.swp
api/config.php
uploads/
@@ -0,0 +1,66 @@
### 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 /
# Block access to version control metadata and the raw DB schema dump.
# These were previously reachable directly over HTTP (e.g. /.git/config,
# /db/schema.sql) and leaked repo/DB internals - deny them outright.
# Note: a direct RewriteRule pattern (no leading slash) was found NOT to be
# honored consistently on this LiteSpeed setup for root-level paths; matching
# on REQUEST_URI via RewriteCond is what's confirmed to work.
RewriteCond %{REQUEST_URI} ^/(\.git|db/)
RewriteRule .* - [F,L]
# API Routes - Direct to PHP backend
RewriteCond %{REQUEST_URI} ^/api/
RewriteRule ^api/(.*)$ api/api/$1 [L,QSA]
# 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,60 @@
# Epic Travel & Expeditions - LiteSpeed .htaccess for CyberPanel
# Optimized for LiteSpeed Web Server
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /api/
# Block direct access to config.php / .env - this was reachable at
# /api/config.php in production (200 OK) despite the FilesMatch/Require
# rule below, which LiteSpeed appears not to honor from .htaccess here.
# Matching on REQUEST_URI (not a direct RewriteRule pattern) is what's
# confirmed to actually work on this LiteSpeed setup.
RewriteCond %{REQUEST_URI} /(config\.php|\.env)$
RewriteRule .* - [F,L]
# 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,103 @@
<?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) - rate limited per IP to prevent
// unauthenticated storage/bandwidth abuse (this endpoint has no login requirement).
if ($method === "POST" && $id === "upload") {
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$limitRow = $db->prepare("SELECT upload_count, window_start FROM upload_rate_limits WHERE ip_address = ?");
$limitRow->execute([$ip]);
$limit = $limitRow->fetch();
$windowFresh = $limit && (strtotime($limit['window_start']) > time() - 3600);
if ($windowFresh && (int)$limit['upload_count'] >= 5) {
jsonResponse(["error" => "Too many uploads. Please try again later."], 429);
}
if (!isset($_FILES["file"])) jsonResponse(["error" => "No file uploaded"], 400);
$result = handleImageUpload($_FILES["file"]);
if (isset($result['error'])) jsonResponse(["error" => $result['error']], $result['status']);
if ($windowFresh) {
$db->prepare("UPDATE upload_rate_limits SET upload_count = upload_count + 1 WHERE ip_address = ?")->execute([$ip]);
} else {
$db->prepare("REPLACE INTO upload_rate_limits (ip_address, upload_count, window_start) VALUES (?, 1, NOW())")->execute([$ip]);
}
jsonResponse(["url" => $result['url']]);
}
jsonResponse(['error' => 'Invalid testimonials endpoint'], 404);
@@ -0,0 +1,43 @@
<?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);
}
$result = handleImageUpload($_FILES['file']);
if (isset($result['error'])) {
jsonResponse(['error' => $result['error']], $result['status']);
}
jsonResponse([
'url' => $result['url'],
'filename' => $result['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,124 @@
<?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;
}
/**
* Validate an uploaded image file ($_FILES['file']) and move it into
* UPLOAD_DIR under a fresh UUID filename. Shared by all endpoints that
* accept image uploads (was previously duplicated per-endpoint).
*
* Returns ['url' => ..., 'filename' => ...] on success, or
* ['error' => ..., 'status' => ...] on failure.
*/
function handleImageUpload($file) {
if (!$file || $file['error'] !== UPLOAD_ERR_OK) {
return ['error' => 'File upload failed', 'status' => 400];
}
if ($file['size'] > MAX_UPLOAD_SIZE) {
return ['error' => 'File too large. Maximum size is 5MB', 'status' => 400];
}
$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)) {
return ['error' => 'Invalid file type. Only JPG, PNG, and WebP allowed', 'status' => 400];
}
$extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
$filename = generateUuid() . '.' . $extension;
$filepath = UPLOAD_DIR . $filename;
if (!move_uploaded_file($file['tmp_name'], $filepath)) {
return ['error' => 'Failed to save file', 'status' => 500];
}
return ['url' => '/api/uploads/' . $filename, 'filename' => $filename];
}
@@ -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,97 @@
<?php
/**
* Epic Travel Expeditions — CyberMail Mailer
*/
function sendgridSend(string $toEmail, string $toName, string $subject, string $htmlBody, string $textBody = ''): bool {
$apiKey = defined('CYBERMAIL_API_KEY') ? CYBERMAIL_API_KEY : '';
if (!$apiKey || strpos($apiKey, 'YOUR_KEY') !== false) {
error_log('[EpicTravel mailer] CYBERMAIL_API_KEY not configured');
return false;
}
$payload = [
'from' => defined('MAIL_FROM') ? MAIL_FROM : 'noreply@epictravelexpeditions.com',
'to' => $toEmail,
'subject' => $subject,
'html' => $htmlBody,
];
if ($textBody) $payload['text'] = $textBody;
$ch = curl_init('https://platform.cyberpersons.com/email/v1/send');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $apiKey,
'Content-Type: application/json',
],
CURLOPT_TIMEOUT => 20,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 202) return true;
error_log('[EpicTravel mailer] CyberMail 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;">&copy; ' . 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 12 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;">&copy; ' . 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,79 @@
<?php
/**
* Epic Travel & Expeditions - Main API Entry Point
* This file routes all API requests to appropriate handlers
*/
// Load configuration and dependencies
require_once '/home/epictravelexpeditions.com/api-secrets.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 '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,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'>&#127758;</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 &bull; 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 &bull; Guided Tours &bull; 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
}
}
@@ -0,0 +1,91 @@
docRoot /home/epictravelexpeditions.com/parkerslingshot
vhDomain $VH_NAME
vhAliases www.$VH_NAME
adminEmails admin@epictravelexpeditions.com
enableGzip 1
enableIpGeo 1
index {
useServer 0
indexFiles index.php, index.html
}
errorlog $VH_ROOT/logs/epictravelexpeditions.com.error_log {
useServer 0
logLevel WARN
rollingSize 10M
}
accesslog $VH_ROOT/logs/epictravelexpeditions.com.access_log {
useServer 0
logFormat "%h %l %u %t "%r" %>s %b "%{Referer}i" "%{User-Agent}i""
logHeaders 5
rollingSize 10M
keepDays 10
compressArchive 1
}
phpIniOverride {
}
module cache {
storagePath /usr/local/lsws/cachedata/$VH_NAME
}
scripthandler {
add lsapi:epict63871534 php
}
extprocessor epict63871534 {
type lsapi
address UDS://tmp/lshttpd/epict63871534.sock
maxConns 10
env LSAPI_CHILDREN=10
initTimeout 60
retryTimeout 0
persistConn 1
pcKeepAliveTimeout 1
respBuffer 0
autoStart 1
path /usr/local/lsws/lsphp85/bin/lsphp
extUser epict6387
extGroup epict6387
memSoftLimit 1024M
memHardLimit 1024M
procSoftLimit 400
procHardLimit 500
}
rewrite {
enable 1
autoLoadHtaccess 1
}
context /.well-known/acme-challenge {
location /usr/local/lsws/Example/html/.well-known/acme-challenge
allowBrowse 1
rewrite {
enable 0
}
addDefaultCharset off
phpIniOverride {
}
}
vhssl {
keyFile /etc/letsencrypt/live/parkerslingshot.epictravelexpeditions.com/privkey.pem
certFile /etc/letsencrypt/live/parkerslingshot.epictravelexpeditions.com/fullchain.pem
certChain 1
sslProtocol 24
enableECDHE 1
renegProtection 1
sslSessionCache 1
enableSpdy 15
enableStapling 1
ocspRespMaxAge 86400
}
@@ -0,0 +1,85 @@
enablegzip 1
docroot /home/epictravelexpeditions.com/parkerslingshot
vhdomain $VH_NAME
phpinioverride
vhaliases www.$VH_NAME
enableipgeo 1
adminemails admin@epictravelexpeditions.com
errorlog $VH_ROOT/logs/epictravelexpeditions.com.error_log {
rollingsize 10M
loglevel WARN
useserver 0
}
rewrite {
autoloadhtaccess 1
enable 1
}
scripthandler {
add lsapi:epict63871534 php
}
extprocessor epict63871534 {
extgroup epict6387
memsoftlimit 1024M
autostart 1
prochardlimit 500
address UDS://tmp/lshttpd/epict63871534.sock
maxconns 10
memhardlimit 1024M
procsoftlimit 400
inittimeout 60
retrytimeout 0
persistconn 1
pckeepalivetimeout 1
respbuffer 0
path /usr/local/lsws/lsphp85/bin/lsphp
extuser epict6387
type lsapi
env LSAPI_CHILDREN=10
}
context /.well-known/acme-challenge {
location /usr/local/lsws/Example/html/.well-known/acme-challenge
adddefaultcharset off
phpinioverride
allowbrowse 1
rewrite {
enable 0
}
}
vhssl {
certchain 1
sslprotocol 24
enableecdhe 1
keyfile /etc/letsencrypt/live/parkerslingshot.epictravelexpeditions.com/privkey.pem
certfile /etc/letsencrypt/live/parkerslingshot.epictravelexpeditions.com/fullchain.pem
enablestapling 1
ocsprespmaxage 86400
renegprotection 1
sslsessioncache 1
enablespdy 15
}
module cache {
unknownkeywords storagepath /usr/local/lsws/cachedata/$VH_NAME
param storagepath /usr/local/lsws/cachedata/$VH_NAME
}
index {
indexfiles index.php, index.html
useserver 0
}
accesslog $VH_ROOT/logs/epictravelexpeditions.com.access_log {
compressarchive 1
logformat "%h %l %u %t "%r" %>s %b "%{Referer}i" "%{User-Agent}i""
rollingsize 10M
keepdays 10
useserver 0
logheaders 5
}
@@ -0,0 +1,91 @@
docRoot /home/epictravelexpeditions.com/parkerslingshot
vhDomain $VH_NAME
vhAliases www.$VH_NAME
adminEmails admin@epictravelexpeditions.com
enableGzip 1
enableIpGeo 1
index {
useServer 0
indexFiles index.php, index.html
}
errorlog $VH_ROOT/logs/epictravelexpeditions.com.error_log {
useServer 0
logLevel WARN
rollingSize 10M
}
accesslog $VH_ROOT/logs/epictravelexpeditions.com.access_log {
useServer 0
logFormat "%h %l %u %t "%r" %>s %b "%{Referer}i" "%{User-Agent}i""
logHeaders 5
rollingSize 10M
keepDays 10
compressArchive 1
}
phpIniOverride {
}
module cache {
storagePath /usr/local/lsws/cachedata/$VH_NAME
}
scripthandler {
add lsapi:epict63871534 php
}
extprocessor epict63871534 {
type lsapi
address UDS://tmp/lshttpd/epict63871534.sock
maxConns 10
env LSAPI_CHILDREN=10
initTimeout 60
retryTimeout 0
persistConn 1
pcKeepAliveTimeout 1
respBuffer 0
autoStart 1
path /usr/local/lsws/lsphp85/bin/lsphp
extUser epict6387
extGroup epict6387
memSoftLimit 1024M
memHardLimit 1024M
procSoftLimit 400
procHardLimit 500
}
rewrite {
enable 1
autoLoadHtaccess 1
}
context /.well-known/acme-challenge {
location /usr/local/lsws/Example/html/.well-known/acme-challenge
allowBrowse 1
rewrite {
enable 0
}
addDefaultCharset off
phpIniOverride {
}
}
vhssl {
keyFile /etc/letsencrypt/live/parkerslingshot.epictravelexpeditions.com/privkey.pem
certFile /etc/letsencrypt/live/parkerslingshot.epictravelexpeditions.com/fullchain.pem
certChain 1
sslProtocol 24
enableECDHE 1
renegProtection 1
sslSessionCache 1
enableSpdy 15
enableStapling 1
ocspRespMaxAge 86400
}
@@ -0,0 +1,130 @@
head 1.2;
access;
symbols;
locks
root:1.2; strict;
comment @# @;
1.2
date 2026.05.17.04.47.56; author root; state Exp;
branches;
next 1.1;
1.1
date 2026.05.17.04.47.43; author root; state Exp;
branches;
next ;
desc
@/usr/local/lsws/conf/vhosts/parkerslingshot.epictravelexpeditions.com/vhost.conf0
@
1.2
log
@Update
@
text
@docRoot /home/epictravelexpeditions.com/parkerslingshot
vhDomain $VH_NAME
vhAliases www.$VH_NAME
adminEmails admin@@epictravelexpeditions.com
enableGzip 1
enableIpGeo 1
index {
useServer 0
indexFiles index.php, index.html
}
errorlog $VH_ROOT/logs/epictravelexpeditions.com.error_log {
useServer 0
logLevel WARN
rollingSize 10M
}
accesslog $VH_ROOT/logs/epictravelexpeditions.com.access_log {
useServer 0
logFormat "%h %l %u %t "%r" %>s %b "%{Referer}i" "%{User-Agent}i""
logHeaders 5
rollingSize 10M
keepDays 10
compressArchive 1
}
phpIniOverride {
}
module cache {
storagePath /usr/local/lsws/cachedata/$VH_NAME
}
scripthandler {
add lsapi:epict63871534 php
}
extprocessor epict63871534 {
type lsapi
address UDS://tmp/lshttpd/epict63871534.sock
maxConns 10
env LSAPI_CHILDREN=10
initTimeout 60
retryTimeout 0
persistConn 1
pcKeepAliveTimeout 1
respBuffer 0
autoStart 1
path /usr/local/lsws/lsphp85/bin/lsphp
extUser epict6387
extGroup epict6387
memSoftLimit 1024M
memHardLimit 1024M
procSoftLimit 400
procHardLimit 500
}
rewrite {
enable 1
autoLoadHtaccess 1
}
context /.well-known/acme-challenge {
location /usr/local/lsws/Example/html/.well-known/acme-challenge
allowBrowse 1
rewrite {
enable 0
}
addDefaultCharset off
phpIniOverride {
}
}
vhssl {
keyFile /etc/letsencrypt/live/parkerslingshot.epictravelexpeditions.com/privkey.pem
certFile /etc/letsencrypt/live/parkerslingshot.epictravelexpeditions.com/fullchain.pem
certChain 1
sslProtocol 24
enableECDHE 1
renegProtection 1
sslSessionCache 1
enableSpdy 15
enableStapling 1
ocspRespMaxAge 86400
}
@
1.1
log
@Update
@
text
@d79 13
@
@@ -0,0 +1,95 @@
docRoot $VH_ROOT/public_html
vhDomain $VH_NAME
vhAliases www.$VH_NAME
adminEmails admin@epictravelexpeditions.com
enableGzip 1
enableIpGeo 1
index {
useServer 0
indexFiles index.php, index.html
}
errorlog $VH_ROOT/logs/$VH_NAME.error_log {
useServer 0
logLevel WARN
rollingSize 10M
}
accesslog $VH_ROOT/logs/$VH_NAME.access_log {
useServer 0
logFormat "%h %l %u %t "%r" %>s %b "%{Referer}i" "%{User-Agent}i""
logHeaders 5
rollingSize 10M
keepDays 10
compressArchive 1
}
scripthandler {
add lsapi:epict6387 php
}
extprocessor epict6387 {
type lsapi
address UDS://tmp/lshttpd/epict6387.sock
maxConns 10
env LSAPI_CHILDREN=10
initTimeout 600
retryTimeout 0
persistConn 1
pcKeepAliveTimeout 1
respBuffer 0
autoStart 1
path /usr/local/lsws/lsphp85/bin/lsphp
extUser epict6387
extGroup epict6387
memSoftLimit 1024M
memHardLimit 1024M
procSoftLimit 400
procHardLimit 500
}
phpIniOverride {
}
module cache {
storagePath /usr/local/lsws/cachedata/$VH_NAME
}
rewrite {
enable 1
autoLoadHtaccess 1
}
context /.well-known/acme-challenge {
location /usr/local/lsws/Example/html/.well-known/acme-challenge
allowBrowse 1
rewrite {
enable 0
}
addDefaultCharset off
phpIniOverride {
}
}
vhssl {
keyFile /etc/letsencrypt/live/epictravelexpeditions.com/privkey.pem
certFile /etc/letsencrypt/live/epictravelexpeditions.com/fullchain.pem
certChain 1
sslProtocol 24
enableECDHE 1
renegProtection 1
sslSessionCache 1
enableSpdy 15
enableStapling 1
ocspRespMaxAge 86400
}
<Directory /home/admin_epicftp/public_html>
AllowOverride All
Require all granted
</Directory>
@@ -0,0 +1,86 @@
vhdomain $VH_NAME
vhaliases www.$VH_NAME
enableipgeo 1
enablegzip 1
docroot $VH_ROOT/public_html
phpinioverride
allowoverride All
adminemails admin@epictravelexpeditions.com
extprocessor epict6387 {
pckeepalivetimeout 1
respbuffer 0
memsoftlimit 1024M
memhardlimit 1024M
env LSAPI_CHILDREN=10
inittimeout 600
extuser epict6387
extgroup epict6387
type lsapi
address UDS://tmp/lshttpd/epict6387.sock
maxconns 10
prochardlimit 500
retrytimeout 0
persistconn 1
autostart 1
path /usr/local/lsws/lsphp85/bin/lsphp
procsoftlimit 400
}
context /.well-known/acme-challenge {
adddefaultcharset off
phpinioverride
location /usr/local/lsws/Example/html/.well-known/acme-challenge
allowbrowse 1
rewrite {
enable 0
}
}
rewrite {
enable 1
autoloadhtaccess 1
}
module cache {
unknownkeywords storagepath /usr/local/lsws/cachedata/$VH_NAME
param storagepath /usr/local/lsws/cachedata/$VH_NAME
}
accesslog $VH_ROOT/logs/$VH_NAME.access_log {
rollingsize 10M
keepdays 10
compressarchive 1
logformat "%h %l %u %t "%r" %>s %b "%{Referer}i" "%{User-Agent}i""
useserver 0
logheaders 5
}
errorlog $VH_ROOT/logs/$VH_NAME.error_log {
useserver 0
rollingsize 10M
loglevel WARN
}
scripthandler {
add lsapi:epict6387 php
}
index {
indexfiles index.php, index.html
useserver 0
}
vhssl {
certchain 1
sslprotocol 24
enableecdhe 1
renegprotection 1
sslsessioncache 1
enablespdy 15
enablestapling 1
ocsprespmaxage 86400
keyfile /etc/letsencrypt/live/epictravelexpeditions.com/privkey.pem
certfile /etc/letsencrypt/live/epictravelexpeditions.com/fullchain.pem
}
@@ -0,0 +1,95 @@
docRoot $VH_ROOT/public_html
vhDomain $VH_NAME
vhAliases www.$VH_NAME
adminEmails admin@epictravelexpeditions.com
enableGzip 1
enableIpGeo 1
index {
useServer 0
indexFiles index.php, index.html
}
errorlog $VH_ROOT/logs/$VH_NAME.error_log {
useServer 0
logLevel WARN
rollingSize 10M
}
accesslog $VH_ROOT/logs/$VH_NAME.access_log {
useServer 0
logFormat "%h %l %u %t "%r" %>s %b "%{Referer}i" "%{User-Agent}i""
logHeaders 5
rollingSize 10M
keepDays 10
compressArchive 1
}
scripthandler {
add lsapi:epict6387 php
}
extprocessor epict6387 {
type lsapi
address UDS://tmp/lshttpd/epict6387.sock
maxConns 10
env LSAPI_CHILDREN=10
initTimeout 600
retryTimeout 0
persistConn 1
pcKeepAliveTimeout 1
respBuffer 0
autoStart 1
path /usr/local/lsws/lsphp85/bin/lsphp
extUser epict6387
extGroup epict6387
memSoftLimit 1024M
memHardLimit 1024M
procSoftLimit 400
procHardLimit 500
}
phpIniOverride {
}
module cache {
storagePath /usr/local/lsws/cachedata/$VH_NAME
}
rewrite {
enable 1
autoLoadHtaccess 1
}
context /.well-known/acme-challenge {
location /usr/local/lsws/Example/html/.well-known/acme-challenge
allowBrowse 1
rewrite {
enable 0
}
addDefaultCharset off
phpIniOverride {
}
}
vhssl {
keyFile /etc/letsencrypt/live/epictravelexpeditions.com/privkey.pem
certFile /etc/letsencrypt/live/epictravelexpeditions.com/fullchain.pem
certChain 1
sslProtocol 24
enableECDHE 1
renegProtection 1
sslSessionCache 1
enableSpdy 15
enableStapling 1
ocspRespMaxAge 86400
}
<Directory /home/admin_epicftp/public_html>
AllowOverride All
Require all granted
</Directory>
@@ -0,0 +1,147 @@
head 1.3;
access;
symbols;
locks
root:1.3; strict;
comment @# @;
1.3
date 2026.05.15.22.32.23; author root; state Exp;
branches;
next 1.2;
1.2
date 2026.05.15.20.10.31; author root; state Exp;
branches;
next 1.1;
1.1
date 2026.05.15.20.10.01; author root; state Exp;
branches;
next ;
desc
@/usr/local/lsws/conf/vhosts/epictravelexpeditions.com/vhost.conf0
@
1.3
log
@Update
@
text
@docRoot $VH_ROOT/public_html
vhDomain $VH_NAME
vhAliases www.$VH_NAME
adminEmails admin@@epictravelexpeditions.com
enableGzip 1
enableIpGeo 1
index {
useServer 0
indexFiles index.php, index.html
}
errorlog $VH_ROOT/logs/$VH_NAME.error_log {
useServer 0
logLevel WARN
rollingSize 10M
}
accesslog $VH_ROOT/logs/$VH_NAME.access_log {
useServer 0
logFormat "%h %l %u %t "%r" %>s %b "%{Referer}i" "%{User-Agent}i""
logHeaders 5
rollingSize 10M
keepDays 10
compressArchive 1
}
scripthandler {
add lsapi:epict6387 php
}
extprocessor epict6387 {
type lsapi
address UDS://tmp/lshttpd/epict6387.sock
maxConns 10
env LSAPI_CHILDREN=10
initTimeout 600
retryTimeout 0
persistConn 1
pcKeepAliveTimeout 1
respBuffer 0
autoStart 1
path /usr/local/lsws/lsphp85/bin/lsphp
extUser epict6387
extGroup epict6387
memSoftLimit 1024M
memHardLimit 1024M
procSoftLimit 400
procHardLimit 500
}
phpIniOverride {
}
module cache {
storagePath /usr/local/lsws/cachedata/$VH_NAME
}
rewrite {
enable 1
autoLoadHtaccess 1
}
context /.well-known/acme-challenge {
location /usr/local/lsws/Example/html/.well-known/acme-challenge
allowBrowse 1
rewrite {
enable 0
}
addDefaultCharset off
phpIniOverride {
}
}
vhssl {
keyFile /etc/letsencrypt/live/epictravelexpeditions.com/privkey.pem
certFile /etc/letsencrypt/live/epictravelexpeditions.com/fullchain.pem
certChain 1
sslProtocol 24
enableECDHE 1
renegProtection 1
sslSessionCache 1
enableSpdy 15
enableStapling 1
ocspRespMaxAge 86400
}
<Directory /home/admin_epicftp/public_html>
AllowOverride All
Require all granted
</Directory>@
1.2
log
@Update
@
text
@d92 4
@
1.1
log
@Update
@
text
@d79 13
@
@@ -0,0 +1,123 @@
/*M!999999\- enable the sandbox mode */
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!40101 SET NAMES utf8mb4 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
DROP TABLE IF EXISTS `admin_users`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `admin_users` (
`id` varchar(36) NOT NULL,
`email` varchar(255) NOT NULL,
`password_hash` varchar(255) NOT NULL,
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `email` (`email`),
KEY `idx_email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `contacts`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `contacts` (
`id` varchar(36) NOT NULL,
`name` varchar(255) NOT NULL,
`email` varchar(255) NOT NULL,
`message` text NOT NULL,
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `destination_categories`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `destination_categories` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(100) NOT NULL,
`created_at` timestamp NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `name` (`name`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `destinations`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `destinations` (
`id` varchar(36) NOT NULL,
`name` varchar(255) NOT NULL,
`location` varchar(255) NOT NULL,
`description` text NOT NULL,
`image` varchar(500) NOT NULL,
`category` varchar(50) NOT NULL,
`rating` decimal(2,1) NOT NULL DEFAULT 4.5,
`price` decimal(10,2) NOT NULL,
`currency` varchar(3) NOT NULL DEFAULT 'USD',
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
KEY `idx_category` (`category`),
KEY `idx_name` (`name`),
KEY `idx_location` (`location`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `newsletter_subscribers`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `newsletter_subscribers` (
`id` varchar(36) NOT NULL,
`email` varchar(255) NOT NULL,
`subscribed_at` datetime NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `email` (`email`),
KEY `idx_email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `specials`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `specials` (
`id` varchar(36) NOT NULL,
`destination_id` varchar(36) NOT NULL,
`discount` decimal(5,2) NOT NULL,
`price` decimal(10,2) DEFAULT NULL,
`end_date` date NOT NULL,
`highlights` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL CHECK (json_valid(`highlights`)),
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
`image_path` varchar(500) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_destination` (`destination_id`),
KEY `idx_end_date` (`end_date`),
CONSTRAINT `specials_ibfk_1` FOREIGN KEY (`destination_id`) REFERENCES `destinations` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `testimonials`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `testimonials` (
`id` varchar(36) NOT NULL,
`full_name` varchar(255) NOT NULL,
`location` varchar(255) NOT NULL,
`message` text NOT NULL,
`image_path` varchar(500) DEFAULT NULL,
`status` enum('pending','approved','denied') NOT NULL DEFAULT 'pending',
`created_at` timestamp NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
@@ -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&#39;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&#39;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
@@ -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 (15)</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&#10;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 (15)</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&#10;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,'&quot;')}">${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">&ldquo;${t.message}&rdquo;</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();
}
})();