Initial commit

This commit is contained in:
2026-05-22 12:52:45 +00:00
commit 0f11edc62e
34 changed files with 3095 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
*.log
.DS_Store
*.swp
api/config.php
uploads/
+60
View File
@@ -0,0 +1,60 @@
### Rewrite Rules Added by CyberPanel Rewrite Rule Generator
RewriteEngine On
RewriteCond %{HTTPS} !=on
RewriteRule ^/?(.*) https://%{SERVER_NAME}/$1 [R,L]
### End CyberPanel Generated Rules.
# Epic Travel & Expeditions - CyberPanel LiteSpeed Configuration
DirectoryIndex index.html index.php
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
# API Routes - Direct to PHP backend
RewriteCond %{REQUEST_URI} ^/api/
RewriteRule ^api/(.*)$ api/api/$1 [L,QSA]
# Admin setup page
RewriteRule ^setup/?$ setup_password.php [L]
# Static files and directories - serve directly
RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]
# React Router - All other routes to index.html
RewriteRule ^ index.html [L]
</IfModule>
# Security Headers
<IfModule mod_headers.c>
Header set X-Content-Type-Options "nosniff"
Header set X-Frame-Options "SAMEORIGIN"
Header set X-XSS-Protection "1; mode=block"
</IfModule>
# Enable CORS for API
<FilesMatch "\.(php)$">
Header set Access-Control-Allow-Origin "*"
Header set Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
Header set Access-Control-Allow-Headers "Content-Type, Authorization"
</FilesMatch>
# Disable directory browsing
Options -Indexes +FollowSymLinks
# PHP Settings
<IfModule mod_php.c>
php_value upload_max_filesize 10M
php_value post_max_size 10M
php_value memory_limit 256M
php_value max_execution_time 300
</IfModule>
# Force use of index.html
<IfModule mod_dir.c>
DirectoryIndex index.html index.php
</IfModule>
+52
View File
@@ -0,0 +1,52 @@
# Epic Travel & Expeditions - LiteSpeed .htaccess for CyberPanel
# Optimized for LiteSpeed Web Server
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /api/
# Route all requests to index.php
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php/$1 [L,QSA]
</IfModule>
# LiteSpeed Cache Control
<IfModule LiteSpeed>
# Disable caching for API
CacheLookup off
</IfModule>
# Security Headers
<IfModule mod_headers.c>
Header set X-Content-Type-Options "nosniff"
Header set X-Frame-Options "SAMEORIGIN"
Header set X-XSS-Protection "1; mode=block"
Header set X-Powered-By "Epic Travel API"
</IfModule>
# Protect sensitive files
<FilesMatch "^(config\.php|\.env)">
Require all denied
</FilesMatch>
# PHP Settings (LiteSpeed compatible)
<IfModule mod_php7.c>
php_value upload_max_filesize 10M
php_value post_max_size 10M
php_value max_execution_time 300
php_value max_input_time 300
php_value memory_limit 256M
</IfModule>
# Compression
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE application/json
AddOutputFilterByType DEFLATE text/plain
AddOutputFilterByType DEFLATE text/html
</IfModule>
# Browser Caching
<IfModule mod_expires.c>
ExpiresActive Off
</IfModule>
+55
View File
@@ -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);
+64
View File
@@ -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);
+38
View File
@@ -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);
+139
View File
@@ -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);
+37
View File
@@ -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);
+131
View File
@@ -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);
+95
View File
@@ -0,0 +1,95 @@
<?php
/**
* Testimonials Endpoints
*/
$db = Database::getInstance()->getConnection();
// GET approved testimonials (public)
if ($method === 'GET' && !$id) {
$stmt = $db->query("SELECT id, full_name, location, message, image_path, created_at FROM testimonials WHERE status = 'approved' ORDER BY created_at DESC");
jsonResponse($stmt->fetchAll());
}
// GET all testimonials (admin)
if ($method === 'GET' && $id === 'all') {
requireAuth();
$stmt = $db->query("SELECT * FROM testimonials ORDER BY created_at DESC");
jsonResponse($stmt->fetchAll());
}
// POST submit testimonial (public)
if ($method === 'POST' && !$id) {
$input = getJsonInput();
$errors = validateRequired($input, ['full_name', 'location', 'message']);
if (!empty($errors)) {
jsonResponse(['error' => implode(', ', $errors)], 400);
}
$testimonialId = generateUuid();
$stmt = $db->prepare("INSERT INTO testimonials (id, full_name, location, message, image_path, status, created_at) VALUES (?, ?, ?, ?, ?, 'pending', NOW())");
$stmt->execute([
$testimonialId,
sanitizeString($input['full_name']),
sanitizeString($input['location']),
sanitizeString($input['message']),
isset($input['image_path']) ? $input['image_path'] : null
]);
jsonResponse(['message' => 'Testimonial submitted successfully', 'id' => $testimonialId], 201);
}
// PUT approve/deny/edit testimonial (admin)
if ($method === 'PUT' && $id) {
requireAuth();
$input = getJsonInput();
$updates = [];
$params = [];
if (isset($input['status']) && in_array($input['status'], ['pending', 'approved', 'denied'])) {
$updates[] = 'status = ?';
$params[] = $input['status'];
}
if (isset($input['full_name'])) { $updates[] = 'full_name = ?'; $params[] = sanitizeString($input['full_name']); }
if (isset($input['location'])) { $updates[] = 'location = ?'; $params[] = sanitizeString($input['location']); }
if (isset($input['message'])) { $updates[] = 'message = ?'; $params[] = sanitizeString($input['message']); }
if (empty($updates)) jsonResponse(['error' => 'No fields to update'], 400);
$params[] = $id;
$stmt = $db->prepare('UPDATE testimonials SET ' . implode(', ', $updates) . ' WHERE id = ?');
$stmt->execute($params);
$stmt = $db->prepare('SELECT * FROM testimonials WHERE id = ?');
$stmt->execute([$id]);
jsonResponse($stmt->fetch());
}
// DELETE testimonial (admin)
if ($method === 'DELETE' && $id) {
requireAuth();
$stmt = $db->prepare('DELETE FROM testimonials WHERE id = ?');
$stmt->execute([$id]);
if ($stmt->rowCount() === 0) jsonResponse(['error' => 'Testimonial not found'], 404);
jsonResponse(['message' => 'Testimonial deleted']);
}
// POST upload testimonial image (public)
if ($method === "POST" && $id === "upload") {
if (!isset($_FILES["file"])) jsonResponse(["error" => "No file uploaded"], 400);
$file = $_FILES["file"];
if ($file["error"] !== UPLOAD_ERR_OK) jsonResponse(["error" => "Upload error"], 400);
if ($file["size"] > MAX_UPLOAD_SIZE) jsonResponse(["error" => "File too large (max 5MB)"], 400);
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $file["tmp_name"]);
finfo_close($finfo);
$allowed = ["image/jpeg","image/jpg","image/png","image/webp"];
if (!in_array($mime, $allowed)) jsonResponse(["error" => "Invalid file type"], 400);
$ext = strtolower(pathinfo($file["name"], PATHINFO_EXTENSION));
$name = generateUuid() . "." . $ext;
$dest = UPLOAD_DIR . $name;
if (!move_uploaded_file($file["tmp_name"], $dest)) jsonResponse(["error" => "Failed to save"], 500);
jsonResponse(["url" => "/api/uploads/" . $name]);
}
jsonResponse(['error' => 'Invalid testimonials endpoint'], 404);
+72
View File
@@ -0,0 +1,72 @@
<?php
/**
* Image Upload Endpoint
*/
requireAuth(); // Only authenticated users can upload
if ($method === 'POST' && $id === 'image') {
if (!isset($_FILES['file'])) {
jsonResponse(['error' => 'No file uploaded'], 400);
}
$file = $_FILES['file'];
// Validate file
if ($file['error'] !== UPLOAD_ERR_OK) {
jsonResponse(['error' => 'File upload failed'], 400);
}
// Check file size
if ($file['size'] > MAX_UPLOAD_SIZE) {
jsonResponse(['error' => 'File too large. Maximum size is 5MB'], 400);
}
// Check file type
$allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if (!in_array($mimeType, $allowedTypes)) {
jsonResponse(['error' => 'Invalid file type. Only JPG, PNG, and WebP allowed'], 400);
}
// Generate unique filename
$extension = pathinfo($file['name'], PATHINFO_EXTENSION);
$filename = generateUuid() . '.' . $extension;
$filepath = UPLOAD_DIR . $filename;
// Move uploaded file
if (!move_uploaded_file($file['tmp_name'], $filepath)) {
jsonResponse(['error' => 'Failed to save file'], 500);
}
$fileUrl = '/api/uploads/' . $filename;
jsonResponse([
'url' => $fileUrl,
'filename' => $filename
]);
}
// Serve uploaded images
if ($method === 'GET' && isset($pathParts[1]) && $pathParts[1] === 'uploads' && isset($pathParts[2])) {
$filename = basename($pathParts[2]);
$filepath = UPLOAD_DIR . $filename;
if (!file_exists($filepath)) {
jsonResponse(['error' => 'Image not found'], 404);
}
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_file($finfo, $filepath);
finfo_close($finfo);
header('Content-Type: ' . $mimeType);
header('Content-Length: ' . filesize($filepath));
readfile($filepath);
exit;
}
jsonResponse(['error' => 'Invalid upload endpoint'], 404);
+52
View File
@@ -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");
}
}
+87
View File
@@ -0,0 +1,87 @@
<?php
/**
* Helper Functions
*/
/**
* Set CORS headers
*/
function setCorsHeaders() {
$origin = isset($_SERVER['HTTP_ORIGIN']) ? $_SERVER['HTTP_ORIGIN'] : '';
if ($origin && (ALLOWED_ORIGINS === '*' || strpos(ALLOWED_ORIGINS, $origin) !== false)) {
header("Access-Control-Allow-Origin: $origin");
} else {
header("Access-Control-Allow-Origin: " . ALLOWED_ORIGINS);
}
header("Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type, Authorization");
header("Access-Control-Allow-Credentials: true");
// Handle preflight requests
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}
}
/**
* Send JSON response
*/
function jsonResponse($data, $statusCode = 200) {
http_response_code($statusCode);
header('Content-Type: application/json');
echo json_encode($data);
exit;
}
/**
* Get JSON input
*/
function getJsonInput() {
$input = file_get_contents('php://input');
return json_decode($input, true);
}
/**
* Validate email
*/
function isValidEmail($email) {
return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
}
/**
* Generate UUID v4
*/
function generateUuid() {
return sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0x0fff) | 0x4000,
mt_rand(0, 0x3fff) | 0x8000,
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
);
}
/**
* Sanitize string
*/
function sanitizeString($string) {
return htmlspecialchars(strip_tags(trim($string)), ENT_QUOTES, 'UTF-8');
}
/**
* Validate required fields
*/
function validateRequired($data, $requiredFields) {
$errors = [];
foreach ($requiredFields as $field) {
if (!isset($data[$field]) || empty(trim($data[$field]))) {
$errors[] = "$field is required";
}
}
return $errors;
}
+117
View File
@@ -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;
}
+101
View File
@@ -0,0 +1,101 @@
<?php
/**
* Epic Travel Expeditions — SendGrid Mailer
*/
function sendgridSend(string $toEmail, string $toName, string $subject, string $htmlBody, string $textBody = ''): bool {
$apiKey = defined('SENDGRID_API_KEY') ? SENDGRID_API_KEY : '';
if (!$apiKey || strpos($apiKey, 'YOUR_KEY') !== false) {
error_log('[EpicTravel mailer] SENDGRID_API_KEY not configured');
return false;
}
$payload = json_encode([
'personalizations' => [['to' => [['email' => $toEmail, 'name' => $toName]]]],
'from' => [
'email' => defined('MAIL_FROM') ? MAIL_FROM : 'noreply@epictravelexpeditions.com',
'name' => defined('MAIL_FROM_NAME') ? MAIL_FROM_NAME : 'Epic Travel Expeditions',
],
'subject' => $subject,
'content' => array_values(array_filter([
$textBody ? ['type' => 'text/plain', 'value' => $textBody] : null,
['type' => 'text/html', 'value' => $htmlBody],
])),
]);
$ch = curl_init('https://api.sendgrid.com/v3/mail/send');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $apiKey,
'Content-Type: application/json',
],
CURLOPT_TIMEOUT => 20,
CURLOPT_SSL_VERIFYPEER => false,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 202) return true;
error_log('[EpicTravel mailer] SendGrid HTTP ' . $httpCode . ' — ' . $response);
return false;
}
function sendContactAlert(string $name, string $email, string $message): bool {
$adminEmail = defined('ADMIN_EMAIL') ? ADMIN_EMAIL : 'admin@epictravelexpeditions.com';
$subject = "New Contact Form Submission from {$name}";
$html = '
<div style="max-width:600px;margin:0 auto;font-family:Arial,sans-serif;">
<div style="background:#1a3a6b;padding:24px;text-align:center;">
<h1 style="color:#fff;margin:0;font-size:22px;">Epic Travel Expeditions</h1>
<p style="color:rgba(255,255,255,.7);margin:4px 0 0;font-size:14px;">New Contact Form Message</p>
</div>
<div style="padding:28px;background:#fff;border:1px solid #e5e7eb;">
<table style="width:100%;border-collapse:collapse;">
<tr><td style="padding:8px 0;color:#6b7280;font-size:13px;width:80px;">Name</td>
<td style="padding:8px 0;font-weight:600;">' . htmlspecialchars($name) . '</td></tr>
<tr><td style="padding:8px 0;color:#6b7280;font-size:13px;">Email</td>
<td style="padding:8px 0;"><a href="mailto:' . htmlspecialchars($email) . '" style="color:#1a3a6b;">' . htmlspecialchars($email) . '</a></td></tr>
</table>
<div style="margin-top:20px;padding:16px;background:#f9fafb;border-radius:8px;border-left:4px solid #1a3a6b;">
<p style="margin:0;font-size:14px;color:#374151;line-height:1.6;">' . nl2br(htmlspecialchars($message)) . '</p>
</div>
<p style="margin-top:20px;font-size:13px;color:#9ca3af;">Submitted ' . date('F j, Y \a\t g:i A T') . '</p>
</div>
<div style="background:#f3f4f6;padding:16px;text-align:center;">
<p style="margin:0;font-size:12px;color:#9ca3af;">&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");
}
+83
View File
@@ -0,0 +1,83 @@
<?php
/**
* Epic Travel & Expeditions - Main API Entry Point
* This file routes all API requests to appropriate handlers
*/
// Load configuration and dependencies
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/includes/database.php';
require_once __DIR__ . '/includes/jwt.php';
require_once __DIR__ . '/includes/functions.php';
require_once __DIR__ . '/includes/mailer.php';
// Set CORS headers (handles OPTIONS preflight too)
setCorsHeaders();
// Get request method and path
$method = $_SERVER['REQUEST_METHOD'];
$path = isset($_SERVER['PATH_INFO']) ? $_SERVER['PATH_INFO'] : '/';
$path = trim($path, '/');
// Parse path
$pathParts = explode('/', $path);
$resource = isset($pathParts[0]) ? $pathParts[0] : '';
$id = isset($pathParts[1]) ? $pathParts[1] : null;
// Health check endpoint
if ($path === '' || $path === 'api') {
jsonResponse([
'message' => 'Epic Travel API is running',
'status' => 'healthy'
]);
}
// Route to appropriate handler
try {
switch ($resource) {
case 'auth':
require __DIR__ . '/api/auth.php';
break;
case 'destinations':
require __DIR__ . '/api/destinations.php';
break;
case 'specials':
require __DIR__ . '/api/specials.php';
break;
case 'contact':
require __DIR__ . '/api/contact.php';
break;
case 'newsletter':
require __DIR__ . '/api/newsletter.php';
break;
case 'upload':
require __DIR__ . '/api/upload.php';
break;
case 'download':
require __DIR__ . '/api/download.php';
break;
case 'testimonials':
require __DIR__ . '/api/testimonials.php';
break;
case 'categories':
require __DIR__ . '/api/categories.php';
break;
default:
jsonResponse(['error' => 'Endpoint not found'], 404);
}
} catch (Exception $e) {
if (DEBUG_MODE) {
jsonResponse(['error' => $e->getMessage()], 500);
} else {
jsonResponse(['error' => 'Internal server error'], 500);
}
}
+6
View File
@@ -0,0 +1,6 @@
<?php
// Simple PHP version checker
echo "PHP Version: " . phpversion() . "\n";
echo "Server Software: " . $_SERVER['SERVER_SOFTWARE'] . "\n";
phpinfo();
?>
+116
View File
@@ -0,0 +1,116 @@
<?php
/**
* Epic Travel - Admin Password Setup
* Visit: https://epictravelexpeditions.com/api/setup_password.php
* DELETE THIS FILE after use!
*/
$message = '';
$success = false;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$email = trim($_POST['email'] ?? '');
$password = trim($_POST['password'] ?? '');
$confirm = trim($_POST['confirm'] ?? '');
if (!$email || !$password) {
$message = 'Email and password are required.';
} elseif ($password !== $confirm) {
$message = 'Passwords do not match.';
} elseif (strlen($password) < 6) {
$message = 'Password must be at least 6 characters.';
} else {
try {
$pdo = new PDO(
'mysql:host=localhost;dbname=epic_epic_db;charset=utf8mb4',
'root',
'b71e5c1a8c7457541b9c1db822de37adfa271926a38b6c20',
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
$hash = password_hash($password, PASSWORD_BCRYPT);
// Check if user exists
$check = $pdo->prepare("SELECT COUNT(*) FROM admin_users WHERE email = ?");
$check->execute([$email]);
if ($check->fetchColumn() > 0) {
// Update existing
$s = $pdo->prepare("UPDATE admin_users SET password_hash = ? WHERE email = ?");
$s->execute([$hash, $email]);
$message = 'Password updated successfully!';
} else {
// Create new
$s = $pdo->prepare("INSERT INTO admin_users (id, email, password_hash, created_at) VALUES (?, ?, ?, NOW())");
$s->execute(['admin-1', $email, $hash]);
$message = 'Admin account created successfully!';
}
// Verify
if (password_verify($password, $hash)) {
$success = true;
} else {
$message = 'Error: Password verification failed.';
}
} catch (Exception $e) {
$message = 'Database error: ' . $e->getMessage();
}
}
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Setup — Epic Travel</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0 }
body { background: #0a0f1e; font-family: 'Segoe UI', sans-serif; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px }
.box { background: #111827; border: 1px solid rgba(59,130,246,.3); padding: 40px; width: 100%; max-width: 420px }
h1 { color: #3b82f6; font-size: 22px; margin-bottom: 6px }
.sub { color: #6b7280; font-size: 13px; margin-bottom: 28px }
label { display: block; color: #9ca3af; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 6px }
input { width: 100%; background: #1f2937; border: 1px solid rgba(255,255,255,.1); color: #f9fafb; padding: 11px 14px; font-size: 15px; outline: none; margin-bottom: 16px }
input:focus { border-color: #3b82f6 }
button { width: 100%; padding: 13px; background: #3b82f6; color: #fff; border: none; font-size: 15px; font-weight: 700; cursor: pointer }
button:hover { background: #2563eb }
.msg { padding: 12px 14px; font-size: 14px; font-weight: 600; margin-bottom: 20px }
.msg.error { background: rgba(239,68,68,.1); border: 1px solid rgba(239,68,68,.3); color: #f87171 }
.msg.success { background: rgba(34,197,94,.1); border: 1px solid rgba(34,197,94,.3); color: #4ade80 }
.warning { background: rgba(245,158,11,.1); border: 1px solid rgba(245,158,11,.3); color: #fbbf24; padding: 12px 14px; font-size: 13px; margin-top: 20px }
</style>
</head>
<body>
<div class="box">
<h1>Epic Travel Admin Setup</h1>
<div class="sub">Set your admin email and password</div>
<?php if ($message): ?>
<div class="msg <?= $success ? 'success' : 'error' ?>">
<?= $success ? '✓ ' : '⚠ ' ?><?= htmlspecialchars($message) ?>
</div>
<?php endif; ?>
<?php if ($success): ?>
<p style="color:#9ca3af;font-size:14px;margin-bottom:20px">
You can now <a href="/admin" style="color:#3b82f6">login to the admin panel</a>.<br><br>
<strong style="color:#f87171">⚠ Delete this file immediately!</strong><br>
Run in SSH: <code style="background:#1f2937;padding:2px 6px;color:#fbbf24">rm /home/epictravelexpeditions.com/public_html/api/setup_password.php</code>
</p>
<?php else: ?>
<form method="POST">
<label>Admin Email</label>
<input type="email" name="email" value="admin@epictravelexpeditions.com" required>
<label>New Password</label>
<input type="password" name="password" placeholder="Enter password" required>
<label>Confirm Password</label>
<input type="password" name="confirm" placeholder="Confirm password" required>
<button type="submit">Set Admin Password</button>
</form>
<div class="warning">⚠ Delete this file after use. It provides direct DB access.</div>
<?php endif; ?>
</div>
</body>
</html>
+13
View File
@@ -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"
]
}
+21
View File
@@ -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

+14
View File
@@ -0,0 +1,14 @@
{
"name": "epic-travel/expeditions",
"description": "Epic Travel & Expeditions Website",
"type": "project",
"require": {
"php": ">=7.4.0"
},
"config": {
"platform": {
"php": "8.0"
},
"optimize-autoloader": true
}
}
+18
View File
@@ -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>
+6
View File
@@ -0,0 +1,6 @@
User-agent: *
Allow: /
Disallow: /admin
Disallow: /api/
Sitemap: https://epictravelexpeditions.com/sitemap.xml
+30
View File
@@ -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
+541
View File
@@ -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();
})();
+541
View File
@@ -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();
})();
+208
View File
@@ -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
+67
View File
@@ -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
+313
View File
@@ -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();
}
})();