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

This commit is contained in:
DO Server Backup
2026-07-07 15:58:55 +00:00
parent 5fda5a1536
commit fe18800d18
318 changed files with 48597 additions and 7 deletions
Regular → Executable
+36
View File
@@ -116,6 +116,42 @@ log "Backing up MySQL credentials"
# Document all databases
mysql -e "SHOW DATABASES;" 2>/dev/null | grep -v "^Database\|information_schema\|performance_schema\|sys" > mysql/databases.txt || true
# ---------------------------------------------------------------------------
# 9b. Actual database dumps — added 2026-07-07. Previously this only wrote a
# list of database NAMES (mysql/databases.txt above), never the data
# itself. If this server were lost, every site's real data — orders,
# accounts, bookings — would be gone even though the backup "succeeded"
# every week. Dumps every real business DB (skips cyberpanel's own
# internal DB and MySQL system schemas).
# ---------------------------------------------------------------------------
log "Dumping site databases"
mkdir -p databases
for db in $(mysql -N -e "SHOW DATABASES;" 2>/dev/null | grep -vE "^(information_schema|performance_schema|mysql|sys|cyberpanel)$"); do
if mysqldump --single-transaction --routines --triggers "$db" 2>/dev/null | gzip > "databases/${db}.sql.gz"; then
SIZE=$(du -sh "databases/${db}.sql.gz" | cut -f1)
log " $db -> ${db}.sql.gz ($SIZE)"
else
log " ERROR dumping $db"
rm -f "databases/${db}.sql.gz"
fi
done
# ---------------------------------------------------------------------------
# 9c. Actual website files — added 2026-07-07. Same gap as above: the config
# backup only ever documented site names + disk usage (site-list.txt
# below), never copied the files themselves. .git is excluded per site
# since each site's own repo (on GitHub, mirrored to Gitea) already
# covers code history — this is a content/uploads/config safety net for
# whatever isn't (or hasn't yet been) committed there.
# ---------------------------------------------------------------------------
log "Backing up website files"
mkdir -p sites
for d in /home/*/public_html; do
site=$(basename "$(dirname "$d")")
mkdir -p "sites/$site"
rsync -a --delete --exclude='.git' "$d/" "sites/$site/public_html/"
done
# ---------------------------------------------------------------------------
# 10. /opt/infra snapshot (already a separate git repo — copy contents)
# ---------------------------------------------------------------------------
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env bash
# =============================================================================
# DO Server Config Backup — runs on orbis (165.22.1.228)
# Backs up all critical configs/scripts to GitHub weekly
# Install: /usr/local/bin/do-server-backup
# Cron: 0 4 * * 0 /usr/local/bin/do-server-backup >> /var/log/do-server-backup.log 2>&1
# =============================================================================
set -euo pipefail
PAT="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"
REPO_URL="https://${PAT}@github.com/myronblair/do-server-config.git"
REPO_DIR="/opt/do-server-config"
LOG_PREFIX="[$(date '+%Y-%m-%d %H:%M:%S')] [orbis]"
log() { echo "$LOG_PREFIX $*"; }
# ---------------------------------------------------------------------------
# 1. Clone or update repo
# ---------------------------------------------------------------------------
if [[ -d "$REPO_DIR/.git" ]]; then
log "Pulling latest from GitHub"
cd "$REPO_DIR"
git config user.email "backup@orbishosting.com"
git config user.name "DO Server Backup"
git pull --rebase origin main -q || true
else
log "Cloning repo to $REPO_DIR"
git clone "$REPO_URL" "$REPO_DIR"
cd "$REPO_DIR"
git config user.email "backup@orbishosting.com"
git config user.name "DO Server Backup"
fi
cd "$REPO_DIR"
mkdir -p scripts systemd wireguard network cron ssh ols-vhosts mysql infra
# ---------------------------------------------------------------------------
# 2. Custom scripts from /usr/local/bin (text only — skip large binaries)
# ---------------------------------------------------------------------------
log "Backing up custom scripts"
for f in /usr/local/bin/jarvis-*.sh \
/usr/local/bin/jarvis-*.py \
/usr/local/bin/ttg-backup.sh \
/usr/local/bin/do-server-backup; do
[[ -f "$f" ]] || continue
size=$(stat -c%s "$f" 2>/dev/null || echo 0)
[[ $size -lt 524288 ]] && cp "$f" scripts/ || log " SKIP (too large): $f"
done
# composer is a stock PHP tool — skip it
# ---------------------------------------------------------------------------
# 3. Custom systemd service units (skip stock DO/system units)
# ---------------------------------------------------------------------------
log "Backing up custom systemd units"
CUSTOM_UNITS="jarvis-agent.service fastapi_ssh_server.service"
for unit in $CUSTOM_UNITS; do
src="/etc/systemd/system/$unit"
[[ -f "$src" ]] && cp "$src" systemd/ || true
done
# ---------------------------------------------------------------------------
# 4. WireGuard configs (includes private keys — repo is private)
# ---------------------------------------------------------------------------
log "Backing up WireGuard configs"
for f in /etc/wireguard/*.conf; do
[[ -f "$f" ]] && cp "$f" wireguard/ || true
done
# ---------------------------------------------------------------------------
# 5. Network / netplan
# ---------------------------------------------------------------------------
log "Backing up netplan"
for f in /etc/netplan/*.yaml; do
[[ -f "$f" ]] && cp "$f" network/ || true
done
cp /etc/hosts network/hosts 2>/dev/null || true
cp /etc/hostname network/hostname 2>/dev/null || true
# ---------------------------------------------------------------------------
# 6. Root crontab — custom entries only (strip CyberPanel boilerplate)
# ---------------------------------------------------------------------------
log "Backing up crontab"
crontab -l 2>/dev/null | grep -v "^#\|CyberCP\|acme.sh\|cleansessions\|run_scheduled_scans\|pdnsHealthCheck\|findBWUsage\|postfixSenderPolicy\|upgradeCritical\|renew\.py\|IncScheduler\|e2scrub\|imunify\|sessionclean\|lsws\b" \
| sed '/^[[:space:]]*$/d' > cron/root_custom
# Also keep the full crontab for reference
crontab -l 2>/dev/null > cron/root_full || echo "# no crontab" > cron/root_full
# ---------------------------------------------------------------------------
# 7. SSH authorized_keys
# ---------------------------------------------------------------------------
log "Backing up SSH keys"
[[ -f /root/.ssh/authorized_keys ]] && cp /root/.ssh/authorized_keys ssh/ || true
[[ -f /root/.ssh/id_rsa.pub ]] && cp /root/.ssh/id_rsa.pub ssh/ || true
# ---------------------------------------------------------------------------
# 8. OpenLiteSpeed vhost configs (CyberPanel-managed)
# ---------------------------------------------------------------------------
log "Backing up OLS vhost configs"
for vdir in /usr/local/lsws/conf/vhosts/*/; do
vname=$(basename "$vdir")
[[ "$vname" == "Example" ]] && continue
mkdir -p "ols-vhosts/$vname"
for conf in "$vdir"*.conf; do
[[ -f "$conf" ]] && cp "$conf" "ols-vhosts/$vname/" || true
done
done
# OLS main listener/vhost mapping
grep -E "^\s*(listener|virtualHost|address |map |vhRoot|vhDomain|configFile)" \
/usr/local/lsws/conf/httpd_config.conf 2>/dev/null > ols-vhosts/httpd_vhosts_summary.txt || true
# ---------------------------------------------------------------------------
# 9. MySQL root credentials file
# ---------------------------------------------------------------------------
log "Backing up MySQL credentials"
[[ -f /root/.my.cnf ]] && cp /root/.my.cnf mysql/my.cnf || true
# Document all databases
mysql -e "SHOW DATABASES;" 2>/dev/null | grep -v "^Database\|information_schema\|performance_schema\|sys" > mysql/databases.txt || true
# ---------------------------------------------------------------------------
# 10. /opt/infra snapshot (already a separate git repo — copy contents)
# ---------------------------------------------------------------------------
log "Backing up /opt/infra snapshot"
if [[ -d /opt/infra ]]; then
rsync -a --exclude='.git' /opt/infra/ infra/
fi
# SMTP config docs
if [[ -d /opt/smtp-for-websites ]]; then
mkdir -p smtp-docs
rsync -a --exclude='.git' /opt/smtp-for-websites/ smtp-docs/
fi
# ---------------------------------------------------------------------------
# 11. CyberPanel website list (for documentation)
# ---------------------------------------------------------------------------
log "Documenting website list"
{
echo "# Websites on DO server — $(date '+%Y-%m-%d')"
echo ""
for d in /home/*/public_html; do
site=$(echo "$d" | sed 's|/home/||;s|/public_html||')
diskuse=$(du -sh "$d" 2>/dev/null | cut -f1)
echo "- $site ($diskuse)"
done
} > ols-vhosts/site-list.txt
# ---------------------------------------------------------------------------
# 12. Commit and push
# ---------------------------------------------------------------------------
log "Committing changes"
git add -A
if git diff --cached --quiet; then
log "No changes to commit"
else
CHANGES=$(git diff --cached --stat | tail -1)
git commit -m "[orbis] Weekly backup $(date '+%Y-%m-%d') — $CHANGES"
log "Pushing to GitHub"
git push origin main
log "Backup complete"
fi
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -5,3 +5,4 @@ mysql
park_slingshot
toms_tjj_db
tomt_ttg_db
workt_track_db
+6
View File
@@ -3,6 +3,7 @@ virtualHost Example{
vhRoot Example/
configFile conf/vhosts/Example/vhconf.conf
listener Default{
map worktracking.orbishosting.com worktracking.orbishosting.com
map orbis.orbishosting.com orbis.orbishosting.com
map mail.parkerslingshotrentals.com mail.parkerslingshotrentals.com
map parkerslingshotrentals.com parkerslingshotrentals.com
@@ -21,6 +22,7 @@ virtualHost tomtomgames.com {
vhRoot /home/$VH_NAME
configFile $SERVER_ROOT/conf/vhosts/$VH_NAME/vhost.conf
listener SSL {
map worktracking.orbishosting.com worktracking.orbishosting.com
map mail.tomtomgames.com mail.tomtomgames.com
map orbis.orbishosting.com orbis.orbishosting.com
map mail.parkerslingshotrentals.com mail.parkerslingshotrentals.com
@@ -37,6 +39,7 @@ virtualHost mail.tomtomgames.com {
vhRoot /home/tomtomgames.com
configFile $SERVER_ROOT/conf/vhosts/$VH_NAME/vhost.conf
listener SSL IPv6 {
map worktracking.orbishosting.com worktracking.orbishosting.com
map mail.tomtomgames.com mail.tomtomgames.com
map orbis.orbishosting.com orbis.orbishosting.com
map mail.parkerslingshotrentals.com mail.parkerslingshotrentals.com
@@ -76,3 +79,6 @@ virtualHost mail.parkerslingshotrentals.com {
virtualHost orbis.orbishosting.com {
vhRoot /home/$VH_NAME
configFile $SERVER_ROOT/conf/vhosts/$VH_NAME/vhost.conf
virtualHost worktracking.orbishosting.com {
vhRoot /home/$VH_NAME
configFile $SERVER_ROOT/conf/vhosts/$VH_NAME/vhost.conf
+5 -4
View File
@@ -1,8 +1,9 @@
# Websites on DO server — 2026-07-05
# Websites on DO server — 2026-07-07
- epictravelexpeditions.com (3.8M)
- orbishosting.com (113M)
- orbis.orbishosting.com (40K)
- parkerslingshotrentals.com (308K)
- tomsjavajive.com (3.8M)
- tomtomgames.com (872K)
- parkerslingshotrentals.com (300K)
- tomsjavajive.com (3.9M)
- tomtomgames.com (868K)
- worktracking.orbishosting.com (124K)
+91
View File
@@ -0,0 +1,91 @@
docRoot $VH_ROOT/public_html
vhDomain $VH_NAME
vhAliases www.$VH_NAME
adminEmails admin@worktracking.orbishosting.com
enableGzip 1
enableIpGeo 1
index {
useServer 0
indexFiles index.php, index.html
}
errorlog $VH_ROOT/logs/$VH_NAME.error_log {
useServer 0
logLevel WARN
rollingSize 10M
}
accesslog $VH_ROOT/logs/$VH_NAME.access_log {
useServer 0
logFormat "%h %l %u %t "%r" %>s %b "%{Referer}i" "%{User-Agent}i""
logHeaders 5
rollingSize 10M
keepDays 10
compressArchive 1
}
scripthandler {
add lsapi:workt7085 php
}
extprocessor workt7085 {
type lsapi
address UDS://tmp/lshttpd/workt7085.sock
maxConns 10
env LSAPI_CHILDREN=10
initTimeout 600
retryTimeout 0
persistConn 1
pcKeepAliveTimeout 1
respBuffer 0
autoStart 1
path /usr/local/lsws/lsphp85/bin/lsphp
extUser workt7085
extGroup workt7085
memSoftLimit 1024M
memHardLimit 1024M
procSoftLimit 400
procHardLimit 500
}
phpIniOverride {
php_admin_value open_basedir "/tmp:$VH_ROOT"
}
module cache {
storagePath /usr/local/lsws/cachedata/$VH_NAME
}
rewrite {
enable 1
autoLoadHtaccess 1
}
context /.well-known/acme-challenge {
location /usr/local/lsws/Example/html/.well-known/acme-challenge
allowBrowse 1
rewrite {
enable 0
}
addDefaultCharset off
phpIniOverride {
}
}
vhssl {
keyFile /etc/letsencrypt/live/worktracking.orbishosting.com/privkey.pem
certFile /etc/letsencrypt/live/worktracking.orbishosting.com/fullchain.pem
certChain 1
sslProtocol 24
enableECDHE 1
renegProtection 1
sslSessionCache 1
enableSpdy 15
enableStapling 1
ocspRespMaxAge 86400
}
+1 -1
View File
@@ -281,7 +281,7 @@ def get_uptime() -> dict:
return {}
def get_services(cfg: dict) -> list:
watch = cfg.get("watch_services", ["ollama", "homeassistant", "mysql", "nginx", "apache2"])
watch = cfg.get("watch_services", [])
statuses = []
for svc in watch:
try:
+1 -2
View File
@@ -10,12 +10,11 @@ KEEP_DAYS=7
DB_USER="root"
DB_PASS="b71e5c1a8c7457541b9c1db822de37adfa271926a38b6c20"
DATABASES="jarvis_db toms_tjj_db tomt_ttg_db epic_parkersling epic_epic_db parker_db"
DATABASES="toms_tjj_db tomt_ttg_db epic_parkersling epic_epic_db park_slingshot"
SITES=(
"/home/epictravelexpeditions.com/public_html"
"/home/epictravelexpeditions.com/parkerslingshot"
"/home/jarvis.orbishosting.com/public_html"
"/home/orbishosting.com/public_html"
"/home/orbis.orbishosting.com/public_html"
"/home/parkerslingshotrentals.com/public_html"
@@ -0,0 +1,6 @@
*.log
.DS_Store
*.swp
api/config.php
uploads/
@@ -0,0 +1,66 @@
### Rewrite Rules Added by CyberPanel Rewrite Rule Generator
RewriteEngine On
RewriteCond %{HTTPS} !=on
RewriteRule ^/?(.*) https://%{SERVER_NAME}/$1 [R,L]
### End CyberPanel Generated Rules.
# Epic Travel & Expeditions - CyberPanel LiteSpeed Configuration
DirectoryIndex index.html index.php
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
# Block access to version control metadata and the raw DB schema dump.
# These were previously reachable directly over HTTP (e.g. /.git/config,
# /db/schema.sql) and leaked repo/DB internals - deny them outright.
# Note: a direct RewriteRule pattern (no leading slash) was found NOT to be
# honored consistently on this LiteSpeed setup for root-level paths; matching
# on REQUEST_URI via RewriteCond is what's confirmed to work.
RewriteCond %{REQUEST_URI} ^/(\.git|db/)
RewriteRule .* - [F,L]
# API Routes - Direct to PHP backend
RewriteCond %{REQUEST_URI} ^/api/
RewriteRule ^api/(.*)$ api/api/$1 [L,QSA]
# Static files and directories - serve directly
RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]
# React Router - All other routes to index.html
RewriteRule ^ index.html [L]
</IfModule>
# Security Headers
<IfModule mod_headers.c>
Header set X-Content-Type-Options "nosniff"
Header set X-Frame-Options "SAMEORIGIN"
Header set X-XSS-Protection "1; mode=block"
</IfModule>
# Enable CORS for API
<FilesMatch "\.(php)$">
Header set Access-Control-Allow-Origin "*"
Header set Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
Header set Access-Control-Allow-Headers "Content-Type, Authorization"
</FilesMatch>
# Disable directory browsing
Options -Indexes +FollowSymLinks
# PHP Settings
<IfModule mod_php.c>
php_value upload_max_filesize 10M
php_value post_max_size 10M
php_value memory_limit 256M
php_value max_execution_time 300
</IfModule>
# Force use of index.html
<IfModule mod_dir.c>
DirectoryIndex index.html index.php
</IfModule>
@@ -0,0 +1,60 @@
# Epic Travel & Expeditions - LiteSpeed .htaccess for CyberPanel
# Optimized for LiteSpeed Web Server
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /api/
# Block direct access to config.php / .env - this was reachable at
# /api/config.php in production (200 OK) despite the FilesMatch/Require
# rule below, which LiteSpeed appears not to honor from .htaccess here.
# Matching on REQUEST_URI (not a direct RewriteRule pattern) is what's
# confirmed to actually work on this LiteSpeed setup.
RewriteCond %{REQUEST_URI} /(config\.php|\.env)$
RewriteRule .* - [F,L]
# Route all requests to index.php
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php/$1 [L,QSA]
</IfModule>
# LiteSpeed Cache Control
<IfModule LiteSpeed>
# Disable caching for API
CacheLookup off
</IfModule>
# Security Headers
<IfModule mod_headers.c>
Header set X-Content-Type-Options "nosniff"
Header set X-Frame-Options "SAMEORIGIN"
Header set X-XSS-Protection "1; mode=block"
Header set X-Powered-By "Epic Travel API"
</IfModule>
# Protect sensitive files
<FilesMatch "^(config\.php|\.env)">
Require all denied
</FilesMatch>
# PHP Settings (LiteSpeed compatible)
<IfModule mod_php7.c>
php_value upload_max_filesize 10M
php_value post_max_size 10M
php_value max_execution_time 300
php_value max_input_time 300
php_value memory_limit 256M
</IfModule>
# Compression
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE application/json
AddOutputFilterByType DEFLATE text/plain
AddOutputFilterByType DEFLATE text/html
</IfModule>
# Browser Caching
<IfModule mod_expires.c>
ExpiresActive Off
</IfModule>
@@ -0,0 +1,55 @@
<?php
/**
* Authentication Endpoints
*/
$db = Database::getInstance()->getConnection();
// Login endpoint
if ($method === 'POST' && $id === 'login') {
$input = getJsonInput();
// Validate input
$errors = validateRequired($input, ['email', 'password']);
if (!empty($errors)) {
jsonResponse(['error' => implode(', ', $errors)], 400);
}
$email = sanitizeString($input['email']);
$password = $input['password'];
// Find admin user
$stmt = $db->prepare("SELECT * FROM admin_users WHERE email = ?");
$stmt->execute([$email]);
$admin = $stmt->fetch();
if (!$admin) {
jsonResponse(['error' => 'Invalid email or password'], 401);
}
// Verify password
if (!password_verify($password, $admin['password_hash'])) {
jsonResponse(['error' => 'Invalid email or password'], 401);
}
// Create token
$token = JWT::createToken($email);
jsonResponse([
'access_token' => $token,
'token_type' => 'bearer',
'email' => $email
]);
}
// Verify token endpoint
if ($method === 'POST' && $id === 'verify') {
$payload = requireAuth();
jsonResponse([
'valid' => true,
'email' => $payload['sub']
]);
}
jsonResponse(['error' => 'Invalid auth endpoint'], 404);
@@ -0,0 +1,64 @@
<?php
/**
* Destination Categories Endpoints
*/
$db = Database::getInstance()->getConnection();
// GET all categories (public - needed for destination forms)
if ($method === 'GET' && !$id) {
$stmt = $db->query('SELECT id, name FROM destination_categories ORDER BY name');
jsonResponse($stmt->fetchAll());
}
// POST add category (admin)
if ($method === 'POST') {
requireAuth();
$input = getJsonInput();
$errors = validateRequired($input, ['name']);
if (!empty($errors)) jsonResponse(['error' => implode(', ', $errors)], 400);
$name = sanitizeString($input['name']);
try {
$stmt = $db->prepare('INSERT INTO destination_categories (name) VALUES (?)');
$stmt->execute([$name]);
$newId = $db->lastInsertId();
jsonResponse(['id' => $newId, 'name' => $name], 201);
} catch (Exception $e) {
jsonResponse(['error' => 'Category already exists'], 409);
}
}
// PUT rename category (admin) - also updates all destinations using it
if ($method === 'PUT' && $id) {
requireAuth();
$input = getJsonInput();
$errors = validateRequired($input, ['name']);
if (!empty($errors)) jsonResponse(['error' => implode(', ', $errors)], 400);
$newName = sanitizeString($input['name']);
$stmt = $db->prepare('SELECT name FROM destination_categories WHERE id = ?');
$stmt->execute([$id]);
$old = $stmt->fetch();
if (!$old) jsonResponse(['error' => 'Category not found'], 404);
try {
$db->prepare('UPDATE destination_categories SET name = ? WHERE id = ?')->execute([$newName, $id]);
$db->prepare('UPDATE destinations SET category = ? WHERE category = ?')->execute([$newName, $old['name']]);
jsonResponse(['id' => $id, 'name' => $newName]);
} catch (Exception $e) {
jsonResponse(['error' => 'Category name already exists'], 409);
}
}
// DELETE category (admin) - only if no destinations use it
if ($method === 'DELETE' && $id) {
requireAuth();
$stmt = $db->prepare('SELECT name FROM destination_categories WHERE id = ?');
$stmt->execute([$id]);
$cat = $stmt->fetch();
if (!$cat) jsonResponse(['error' => 'Category not found'], 404);
$stmt = $db->prepare('SELECT COUNT(*) FROM destinations WHERE category = ?');
$stmt->execute([$cat['name']]);
if ($stmt->fetchColumn() > 0) jsonResponse(['error' => 'Cannot delete — destinations are using this category'], 400);
$db->prepare('DELETE FROM destination_categories WHERE id = ?')->execute([$id]);
jsonResponse(['message' => 'Category deleted']);
}
jsonResponse(['error' => 'Invalid categories endpoint'], 404);
@@ -0,0 +1,38 @@
<?php
/**
* Contact Form Endpoint
*/
$db = Database::getInstance()->getConnection();
if ($method === 'POST') {
$input = getJsonInput();
$errors = validateRequired($input, ['name', 'email', 'message']);
if (!empty($errors)) {
jsonResponse(['error' => implode(', ', $errors)], 400);
}
if (!isValidEmail($input['email'])) {
jsonResponse(['error' => 'Invalid email address'], 400);
}
$name = sanitizeString($input['name']);
$email = sanitizeString($input['email']);
$message = $input['message'];
$id = generateUuid();
$stmt = $db->prepare("
INSERT INTO contacts (id, name, email, message, created_at)
VALUES (?, ?, ?, ?, NOW())
");
$stmt->execute([$id, $name, $email, $message]);
// Send admin alert and customer confirmation via SendGrid
sendContactAlert($name, $email, $message);
sendContactConfirmation($email, $name);
jsonResponse(['message' => 'Contact form submitted successfully']);
}
jsonResponse(['error' => 'Method not allowed'], 405);
@@ -0,0 +1,139 @@
<?php
/**
* Destinations CRUD Endpoints
*/
$db = Database::getInstance()->getConnection();
// GET all destinations or single destination
if ($method === 'GET') {
if ($id) {
// Get single destination
$stmt = $db->prepare("SELECT * FROM destinations WHERE id = ?");
$stmt->execute([$id]);
$destination = $stmt->fetch();
if (!$destination) {
jsonResponse(['error' => 'Destination not found'], 404);
}
jsonResponse($destination);
} else {
// Get all destinations with optional filtering
$category = isset($_GET['category']) ? sanitizeString($_GET['category']) : null;
$search = isset($_GET['search']) ? sanitizeString($_GET['search']) : null;
$sql = "SELECT * FROM destinations WHERE 1=1";
$params = [];
if ($category && $category !== 'All') {
$sql .= " AND category = ?";
$params[] = $category;
}
if ($search) {
$sql .= " AND (name LIKE ? OR location LIKE ?)";
$params[] = "%$search%";
$params[] = "%$search%";
}
$sql .= " LIMIT 100";
$stmt = $db->prepare($sql);
$stmt->execute($params);
$destinations = $stmt->fetchAll();
jsonResponse($destinations);
}
}
// POST create new destination (admin only)
if ($method === 'POST') {
requireAuth();
$input = getJsonInput();
$errors = validateRequired($input, ['name', 'location', 'description', 'image', 'category', 'rating', 'price']);
if (!empty($errors)) {
jsonResponse(['error' => implode(', ', $errors)], 400);
}
$id = generateUuid();
$stmt = $db->prepare("
INSERT INTO destinations (id, name, location, description, image, category, rating, price, currency, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
");
$stmt->execute([
$id,
sanitizeString($input['name']),
sanitizeString($input['location']),
$input['description'],
$input['image'],
$input['category'],
$input['rating'],
$input['price'],
isset($input['currency']) ? $input['currency'] : 'USD'
]);
// Fetch created destination
$stmt = $db->prepare("SELECT * FROM destinations WHERE id = ?");
$stmt->execute([$id]);
$destination = $stmt->fetch();
jsonResponse($destination, 201);
}
// PUT update destination (admin only)
if ($method === 'PUT' && $id) {
requireAuth();
$input = getJsonInput();
// Build update query dynamically
$updates = [];
$params = [];
$allowedFields = ['name', 'location', 'description', 'image', 'category', 'rating', 'price', 'currency'];
foreach ($allowedFields as $field) {
if (isset($input[$field])) {
$updates[] = "$field = ?";
$params[] = $field === 'description' ? $input[$field] : sanitizeString($input[$field]);
}
}
if (empty($updates)) {
jsonResponse(['error' => 'No fields to update'], 400);
}
$params[] = $id;
$sql = "UPDATE destinations SET " . implode(', ', $updates) . " WHERE id = ?";
$stmt = $db->prepare($sql);
$stmt->execute($params);
// Fetch updated destination
$stmt = $db->prepare("SELECT * FROM destinations WHERE id = ?");
$stmt->execute([$id]);
$destination = $stmt->fetch();
jsonResponse($destination);
}
// DELETE destination (admin only)
if ($method === 'DELETE' && $id) {
requireAuth();
// Delete destination (cascades to specials)
$stmt = $db->prepare("DELETE FROM destinations WHERE id = ?");
$stmt->execute([$id]);
if ($stmt->rowCount() === 0) {
jsonResponse(['error' => 'Destination not found'], 404);
}
jsonResponse(['message' => 'Destination deleted successfully']);
}
jsonResponse(['error' => 'Invalid destinations endpoint'], 404);
@@ -0,0 +1,37 @@
<?php
/**
* Newsletter Subscription Endpoint
*/
$db = Database::getInstance()->getConnection();
if ($method === 'POST' && $id === 'subscribe') {
$input = getJsonInput();
if (!isset($input['email']) || !isValidEmail($input['email'])) {
jsonResponse(['error' => 'Valid email address is required'], 400);
}
$email = sanitizeString($input['email']);
// Check if already subscribed
$stmt = $db->prepare("SELECT id FROM newsletter_subscribers WHERE email = ?");
$stmt->execute([$email]);
if ($stmt->fetch()) {
jsonResponse(['message' => 'Email already subscribed']);
}
$id = generateUuid();
$stmt = $db->prepare("
INSERT INTO newsletter_subscribers (id, email, subscribed_at)
VALUES (?, ?, NOW())
");
$stmt->execute([$id, $email]);
jsonResponse(['message' => 'Successfully subscribed to newsletter']);
}
jsonResponse(['error' => 'Invalid newsletter endpoint'], 404);
@@ -0,0 +1,131 @@
<?php
/**
* Weekly Specials CRUD Endpoints
*/
$db = Database::getInstance()->getConnection();
// GET all specials
if ($method === 'GET' && !$id) {
$stmt = $db->query("SELECT * FROM specials LIMIT 100");
$specials = $stmt->fetchAll();
// Parse JSON highlights
foreach ($specials as &$special) {
$special['highlights'] = json_decode($special['highlights'], true);
}
jsonResponse($specials);
}
// POST create special (admin only)
if ($method === 'POST') {
requireAuth();
$input = getJsonInput();
$errors = validateRequired($input, ['destination_id', 'discount', 'end_date', 'highlights']);
if (!empty($errors)) {
jsonResponse(['error' => implode(', ', $errors)], 400);
}
// Check if destination exists
$stmt = $db->prepare("SELECT id FROM destinations WHERE id = ?");
$stmt->execute([$input['destination_id']]);
if (!$stmt->fetch()) {
jsonResponse(['error' => 'Destination not found'], 404);
}
// Check if special already exists for this destination
$stmt = $db->prepare("SELECT id FROM specials WHERE destination_id = ?");
$stmt->execute([$input['destination_id']]);
if ($stmt->fetch()) {
jsonResponse(['error' => 'Special already exists for this destination'], 400);
}
$id = generateUuid();
$highlights = json_encode($input['highlights']);
$stmt = $db->prepare("
INSERT INTO specials (id, destination_id, discount, end_date, highlights, image_path, created_at)
VALUES (?, ?, ?, ?, ?, ?, NOW())
");
$stmt->execute([
$id,
$input['destination_id'],
$input['discount'],
$input['end_date'],
$highlights,
isset($input['image_path']) ? $input['image_path'] : null
]);
// Fetch created special
$stmt = $db->prepare("SELECT * FROM specials WHERE id = ?");
$stmt->execute([$id]);
$special = $stmt->fetch();
$special['highlights'] = json_decode($special['highlights'], true);
jsonResponse($special, 201);
}
// PUT update special (admin only)
if ($method === 'PUT' && $id) {
requireAuth();
$input = getJsonInput();
$updates = [];
$params = [];
if (isset($input['discount'])) {
$updates[] = "discount = ?";
$params[] = $input['discount'];
}
if (isset($input['end_date'])) {
$updates[] = "end_date = ?";
$params[] = $input['end_date'];
}
if (isset($input['highlights'])) {
$updates[] = "highlights = ?";
$params[] = json_encode($input['highlights']);
}
if (empty($updates)) {
jsonResponse(['error' => 'No fields to update'], 400);
}
$params[] = $id;
$sql = "UPDATE specials SET " . implode(', ', $updates) . " WHERE id = ?";
$stmt = $db->prepare($sql);
$stmt->execute($params);
// Fetch updated special
$stmt = $db->prepare("SELECT * FROM specials WHERE id = ?");
$stmt->execute([$id]);
$special = $stmt->fetch();
$special['highlights'] = json_decode($special['highlights'], true);
jsonResponse($special);
}
// DELETE special by destination_id (admin only)
if ($method === 'DELETE' && isset($pathParts[1]) && $pathParts[1] === 'destination' && isset($pathParts[2])) {
requireAuth();
$destinationId = $pathParts[2];
$stmt = $db->prepare("DELETE FROM specials WHERE destination_id = ?");
$stmt->execute([$destinationId]);
if ($stmt->rowCount() === 0) {
jsonResponse(['error' => 'Special not found for this destination'], 404);
}
jsonResponse(['message' => 'Special removed successfully']);
}
jsonResponse(['error' => 'Invalid specials endpoint'], 404);
@@ -0,0 +1,103 @@
<?php
/**
* Testimonials Endpoints
*/
$db = Database::getInstance()->getConnection();
// GET approved testimonials (public)
if ($method === 'GET' && !$id) {
$stmt = $db->query("SELECT id, full_name, location, message, image_path, created_at FROM testimonials WHERE status = 'approved' ORDER BY created_at DESC");
jsonResponse($stmt->fetchAll());
}
// GET all testimonials (admin)
if ($method === 'GET' && $id === 'all') {
requireAuth();
$stmt = $db->query("SELECT * FROM testimonials ORDER BY created_at DESC");
jsonResponse($stmt->fetchAll());
}
// POST submit testimonial (public)
if ($method === 'POST' && !$id) {
$input = getJsonInput();
$errors = validateRequired($input, ['full_name', 'location', 'message']);
if (!empty($errors)) {
jsonResponse(['error' => implode(', ', $errors)], 400);
}
$testimonialId = generateUuid();
$stmt = $db->prepare("INSERT INTO testimonials (id, full_name, location, message, image_path, status, created_at) VALUES (?, ?, ?, ?, ?, 'pending', NOW())");
$stmt->execute([
$testimonialId,
sanitizeString($input['full_name']),
sanitizeString($input['location']),
sanitizeString($input['message']),
isset($input['image_path']) ? $input['image_path'] : null
]);
jsonResponse(['message' => 'Testimonial submitted successfully', 'id' => $testimonialId], 201);
}
// PUT approve/deny/edit testimonial (admin)
if ($method === 'PUT' && $id) {
requireAuth();
$input = getJsonInput();
$updates = [];
$params = [];
if (isset($input['status']) && in_array($input['status'], ['pending', 'approved', 'denied'])) {
$updates[] = 'status = ?';
$params[] = $input['status'];
}
if (isset($input['full_name'])) { $updates[] = 'full_name = ?'; $params[] = sanitizeString($input['full_name']); }
if (isset($input['location'])) { $updates[] = 'location = ?'; $params[] = sanitizeString($input['location']); }
if (isset($input['message'])) { $updates[] = 'message = ?'; $params[] = sanitizeString($input['message']); }
if (empty($updates)) jsonResponse(['error' => 'No fields to update'], 400);
$params[] = $id;
$stmt = $db->prepare('UPDATE testimonials SET ' . implode(', ', $updates) . ' WHERE id = ?');
$stmt->execute($params);
$stmt = $db->prepare('SELECT * FROM testimonials WHERE id = ?');
$stmt->execute([$id]);
jsonResponse($stmt->fetch());
}
// DELETE testimonial (admin)
if ($method === 'DELETE' && $id) {
requireAuth();
$stmt = $db->prepare('DELETE FROM testimonials WHERE id = ?');
$stmt->execute([$id]);
if ($stmt->rowCount() === 0) jsonResponse(['error' => 'Testimonial not found'], 404);
jsonResponse(['message' => 'Testimonial deleted']);
}
// POST upload testimonial image (public) - rate limited per IP to prevent
// unauthenticated storage/bandwidth abuse (this endpoint has no login requirement).
if ($method === "POST" && $id === "upload") {
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$limitRow = $db->prepare("SELECT upload_count, window_start FROM upload_rate_limits WHERE ip_address = ?");
$limitRow->execute([$ip]);
$limit = $limitRow->fetch();
$windowFresh = $limit && (strtotime($limit['window_start']) > time() - 3600);
if ($windowFresh && (int)$limit['upload_count'] >= 5) {
jsonResponse(["error" => "Too many uploads. Please try again later."], 429);
}
if (!isset($_FILES["file"])) jsonResponse(["error" => "No file uploaded"], 400);
$result = handleImageUpload($_FILES["file"]);
if (isset($result['error'])) jsonResponse(["error" => $result['error']], $result['status']);
if ($windowFresh) {
$db->prepare("UPDATE upload_rate_limits SET upload_count = upload_count + 1 WHERE ip_address = ?")->execute([$ip]);
} else {
$db->prepare("REPLACE INTO upload_rate_limits (ip_address, upload_count, window_start) VALUES (?, 1, NOW())")->execute([$ip]);
}
jsonResponse(["url" => $result['url']]);
}
jsonResponse(['error' => 'Invalid testimonials endpoint'], 404);
@@ -0,0 +1,43 @@
<?php
/**
* Image Upload Endpoint
*/
requireAuth(); // Only authenticated users can upload
if ($method === 'POST' && $id === 'image') {
if (!isset($_FILES['file'])) {
jsonResponse(['error' => 'No file uploaded'], 400);
}
$result = handleImageUpload($_FILES['file']);
if (isset($result['error'])) {
jsonResponse(['error' => $result['error']], $result['status']);
}
jsonResponse([
'url' => $result['url'],
'filename' => $result['filename']
]);
}
// Serve uploaded images
if ($method === 'GET' && isset($pathParts[1]) && $pathParts[1] === 'uploads' && isset($pathParts[2])) {
$filename = basename($pathParts[2]);
$filepath = UPLOAD_DIR . $filename;
if (!file_exists($filepath)) {
jsonResponse(['error' => 'Image not found'], 404);
}
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_file($finfo, $filepath);
finfo_close($finfo);
header('Content-Type: ' . $mimeType);
header('Content-Length: ' . filesize($filepath));
readfile($filepath);
exit;
}
jsonResponse(['error' => 'Invalid upload endpoint'], 404);
@@ -0,0 +1,52 @@
<?php
/**
* Database Connection Class
* Uses PDO for secure MySQL connections
*/
class Database {
private static $instance = null;
private $conn;
private function __construct() {
try {
$dsn = "mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=" . DB_CHARSET;
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
$this->conn = new PDO($dsn, DB_USER, DB_PASS, $options);
} catch (PDOException $e) {
$this->handleError($e->getMessage());
}
}
public static function getInstance() {
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
public function getConnection() {
return $this->conn;
}
private function handleError($message) {
if (DEBUG_MODE) {
die(json_encode(['error' => 'Database Error: ' . $message]));
} else {
die(json_encode(['error' => 'Database connection failed']));
}
}
// Prevent cloning
private function __clone() {}
// Prevent unserialization
public function __wakeup() {
throw new Exception("Cannot unserialize singleton");
}
}
@@ -0,0 +1,124 @@
<?php
/**
* Helper Functions
*/
/**
* Set CORS headers
*/
function setCorsHeaders() {
$origin = isset($_SERVER['HTTP_ORIGIN']) ? $_SERVER['HTTP_ORIGIN'] : '';
if ($origin && (ALLOWED_ORIGINS === '*' || strpos(ALLOWED_ORIGINS, $origin) !== false)) {
header("Access-Control-Allow-Origin: $origin");
} else {
header("Access-Control-Allow-Origin: " . ALLOWED_ORIGINS);
}
header("Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type, Authorization");
header("Access-Control-Allow-Credentials: true");
// Handle preflight requests
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}
}
/**
* Send JSON response
*/
function jsonResponse($data, $statusCode = 200) {
http_response_code($statusCode);
header('Content-Type: application/json');
echo json_encode($data);
exit;
}
/**
* Get JSON input
*/
function getJsonInput() {
$input = file_get_contents('php://input');
return json_decode($input, true);
}
/**
* Validate email
*/
function isValidEmail($email) {
return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
}
/**
* Generate UUID v4
*/
function generateUuid() {
return sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0x0fff) | 0x4000,
mt_rand(0, 0x3fff) | 0x8000,
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
);
}
/**
* Sanitize string
*/
function sanitizeString($string) {
return htmlspecialchars(strip_tags(trim($string)), ENT_QUOTES, 'UTF-8');
}
/**
* Validate required fields
*/
function validateRequired($data, $requiredFields) {
$errors = [];
foreach ($requiredFields as $field) {
if (!isset($data[$field]) || empty(trim($data[$field]))) {
$errors[] = "$field is required";
}
}
return $errors;
}
/**
* Validate an uploaded image file ($_FILES['file']) and move it into
* UPLOAD_DIR under a fresh UUID filename. Shared by all endpoints that
* accept image uploads (was previously duplicated per-endpoint).
*
* Returns ['url' => ..., 'filename' => ...] on success, or
* ['error' => ..., 'status' => ...] on failure.
*/
function handleImageUpload($file) {
if (!$file || $file['error'] !== UPLOAD_ERR_OK) {
return ['error' => 'File upload failed', 'status' => 400];
}
if ($file['size'] > MAX_UPLOAD_SIZE) {
return ['error' => 'File too large. Maximum size is 5MB', 'status' => 400];
}
$allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if (!in_array($mimeType, $allowedTypes)) {
return ['error' => 'Invalid file type. Only JPG, PNG, and WebP allowed', 'status' => 400];
}
$extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
$filename = generateUuid() . '.' . $extension;
$filepath = UPLOAD_DIR . $filename;
if (!move_uploaded_file($file['tmp_name'], $filepath)) {
return ['error' => 'Failed to save file', 'status' => 500];
}
return ['url' => '/api/uploads/' . $filename, 'filename' => $filename];
}
@@ -0,0 +1,117 @@
<?php
/**
* JWT Authentication Functions
* Simple JWT implementation for PHP
*/
class JWT {
/**
* Create a JWT token
*/
public static function encode($payload, $secret) {
$header = json_encode(['typ' => 'JWT', 'alg' => 'HS256']);
$payload = json_encode($payload);
$base64UrlHeader = self::base64UrlEncode($header);
$base64UrlPayload = self::base64UrlEncode($payload);
$signature = hash_hmac('sha256', $base64UrlHeader . "." . $base64UrlPayload, $secret, true);
$base64UrlSignature = self::base64UrlEncode($signature);
return $base64UrlHeader . "." . $base64UrlPayload . "." . $base64UrlSignature;
}
/**
* Decode and verify a JWT token
*/
public static function decode($jwt, $secret) {
$parts = explode('.', $jwt);
if (count($parts) !== 3) {
return false;
}
list($base64UrlHeader, $base64UrlPayload, $base64UrlSignature) = $parts;
$signature = self::base64UrlDecode($base64UrlSignature);
$expectedSignature = hash_hmac('sha256', $base64UrlHeader . "." . $base64UrlPayload, $secret, true);
if (!hash_equals($signature, $expectedSignature)) {
return false;
}
$payload = json_decode(self::base64UrlDecode($base64UrlPayload), true);
// Check expiration
if (isset($payload['exp']) && $payload['exp'] < time()) {
return false;
}
return $payload;
}
/**
* Create authentication token
*/
public static function createToken($email) {
$payload = [
'sub' => $email,
'iat' => time(),
'exp' => time() + JWT_EXPIRY
];
return self::encode($payload, JWT_SECRET_KEY);
}
/**
* Verify token from Authorization header
*/
public static function verifyToken() {
$headers = getallheaders();
if (!isset($headers['Authorization'])) {
return false;
}
$authHeader = $headers['Authorization'];
if (!preg_match('/Bearer\s+(.*)$/i', $authHeader, $matches)) {
return false;
}
$token = $matches[1];
$payload = self::decode($token, JWT_SECRET_KEY);
return $payload;
}
/**
* Base64 URL encode
*/
private static function base64UrlEncode($data) {
return str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($data));
}
/**
* Base64 URL decode
*/
private static function base64UrlDecode($data) {
return base64_decode(str_replace(['-', '_'], ['+', '/'], $data));
}
}
/**
* Require authentication middleware
*/
function requireAuth() {
$payload = JWT::verifyToken();
if (!$payload) {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized']);
exit;
}
return $payload;
}
@@ -0,0 +1,97 @@
<?php
/**
* Epic Travel Expeditions — CyberMail Mailer
*/
function sendgridSend(string $toEmail, string $toName, string $subject, string $htmlBody, string $textBody = ''): bool {
$apiKey = defined('CYBERMAIL_API_KEY') ? CYBERMAIL_API_KEY : '';
if (!$apiKey || strpos($apiKey, 'YOUR_KEY') !== false) {
error_log('[EpicTravel mailer] CYBERMAIL_API_KEY not configured');
return false;
}
$payload = [
'from' => defined('MAIL_FROM') ? MAIL_FROM : 'noreply@epictravelexpeditions.com',
'to' => $toEmail,
'subject' => $subject,
'html' => $htmlBody,
];
if ($textBody) $payload['text'] = $textBody;
$ch = curl_init('https://platform.cyberpersons.com/email/v1/send');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $apiKey,
'Content-Type: application/json',
],
CURLOPT_TIMEOUT => 20,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 202) return true;
error_log('[EpicTravel mailer] CyberMail HTTP ' . $httpCode . ' — ' . $response);
return false;
}
function sendContactAlert(string $name, string $email, string $message): bool {
$adminEmail = defined('ADMIN_EMAIL') ? ADMIN_EMAIL : 'admin@epictravelexpeditions.com';
$subject = "New Contact Form Submission from {$name}";
$html = '
<div style="max-width:600px;margin:0 auto;font-family:Arial,sans-serif;">
<div style="background:#1a3a6b;padding:24px;text-align:center;">
<h1 style="color:#fff;margin:0;font-size:22px;">Epic Travel Expeditions</h1>
<p style="color:rgba(255,255,255,.7);margin:4px 0 0;font-size:14px;">New Contact Form Message</p>
</div>
<div style="padding:28px;background:#fff;border:1px solid #e5e7eb;">
<table style="width:100%;border-collapse:collapse;">
<tr><td style="padding:8px 0;color:#6b7280;font-size:13px;width:80px;">Name</td>
<td style="padding:8px 0;font-weight:600;">' . htmlspecialchars($name) . '</td></tr>
<tr><td style="padding:8px 0;color:#6b7280;font-size:13px;">Email</td>
<td style="padding:8px 0;"><a href="mailto:' . htmlspecialchars($email) . '" style="color:#1a3a6b;">' . htmlspecialchars($email) . '</a></td></tr>
</table>
<div style="margin-top:20px;padding:16px;background:#f9fafb;border-radius:8px;border-left:4px solid #1a3a6b;">
<p style="margin:0;font-size:14px;color:#374151;line-height:1.6;">' . nl2br(htmlspecialchars($message)) . '</p>
</div>
<p style="margin-top:20px;font-size:13px;color:#9ca3af;">Submitted ' . date('F j, Y \a\t g:i A T') . '</p>
</div>
<div style="background:#f3f4f6;padding:16px;text-align:center;">
<p style="margin:0;font-size:12px;color:#9ca3af;">&copy; ' . date('Y') . ' Epic Travel Expeditions</p>
</div>
</div>';
return sendgridSend($adminEmail, 'Epic Travel Admin', $subject, $html,
"New contact from {$name} ({$email}):\n\n{$message}");
}
function sendContactConfirmation(string $toEmail, string $toName): bool {
$subject = "We received your message — Epic Travel Expeditions";
$html = '
<div style="max-width:600px;margin:0 auto;font-family:Arial,sans-serif;">
<div style="background:#1a3a6b;padding:24px;text-align:center;">
<h1 style="color:#fff;margin:0;font-size:22px;">Epic Travel Expeditions</h1>
</div>
<div style="padding:32px;background:#fff;">
<h2 style="margin-top:0;color:#1a3a6b;">Thanks for reaching out, ' . htmlspecialchars($toName) . '!</h2>
<p style="color:#374151;line-height:1.6;">We received your message and our team will get back to you within 12 business days.</p>
<p style="color:#374151;line-height:1.6;">In the meantime, feel free to browse our destinations and current travel specials:</p>
<div style="text-align:center;margin:28px 0;">
<a href="https://epictravelexpeditions.com" style="display:inline-block;background:#1a3a6b;color:#fff;padding:14px 28px;border-radius:8px;text-decoration:none;font-weight:bold;">Explore Destinations</a>
</div>
<p style="color:#374151;line-height:1.6;">Adventure awaits,<br><strong>The Epic Travel Team</strong></p>
</div>
<div style="background:#f3f4f6;padding:16px;text-align:center;">
<p style="margin:0;font-size:12px;color:#9ca3af;">&copy; ' . date('Y') . ' Epic Travel Expeditions</p>
</div>
</div>';
return sendgridSend($toEmail, $toName, $subject, $html,
"Hi {$toName},\n\nThanks for contacting Epic Travel Expeditions! We'll get back to you within 1-2 business days.\n\nAdventure awaits,\nThe Epic Travel Team");
}
@@ -0,0 +1,79 @@
<?php
/**
* Epic Travel & Expeditions - Main API Entry Point
* This file routes all API requests to appropriate handlers
*/
// Load configuration and dependencies
require_once '/home/epictravelexpeditions.com/api-secrets.php';
require_once __DIR__ . '/includes/database.php';
require_once __DIR__ . '/includes/jwt.php';
require_once __DIR__ . '/includes/functions.php';
require_once __DIR__ . '/includes/mailer.php';
// Set CORS headers (handles OPTIONS preflight too)
setCorsHeaders();
// Get request method and path
$method = $_SERVER['REQUEST_METHOD'];
$path = isset($_SERVER['PATH_INFO']) ? $_SERVER['PATH_INFO'] : '/';
$path = trim($path, '/');
// Parse path
$pathParts = explode('/', $path);
$resource = isset($pathParts[0]) ? $pathParts[0] : '';
$id = isset($pathParts[1]) ? $pathParts[1] : null;
// Health check endpoint
if ($path === '' || $path === 'api') {
jsonResponse([
'message' => 'Epic Travel API is running',
'status' => 'healthy'
]);
}
// Route to appropriate handler
try {
switch ($resource) {
case 'auth':
require __DIR__ . '/api/auth.php';
break;
case 'destinations':
require __DIR__ . '/api/destinations.php';
break;
case 'specials':
require __DIR__ . '/api/specials.php';
break;
case 'contact':
require __DIR__ . '/api/contact.php';
break;
case 'newsletter':
require __DIR__ . '/api/newsletter.php';
break;
case 'upload':
require __DIR__ . '/api/upload.php';
break;
case 'testimonials':
require __DIR__ . '/api/testimonials.php';
break;
case 'categories':
require __DIR__ . '/api/categories.php';
break;
default:
jsonResponse(['error' => 'Endpoint not found'], 404);
}
} catch (Exception $e) {
if (DEBUG_MODE) {
jsonResponse(['error' => $e->getMessage()], 500);
} else {
jsonResponse(['error' => 'Internal server error'], 500);
}
}
@@ -0,0 +1,13 @@
{
"files": {
"main.css": "/static/css/main.7791f349.css",
"main.js": "/static/js/main.b20df6de.js",
"index.html": "/index.html",
"main.7791f349.css.map": "/static/css/main.7791f349.css.map",
"main.b20df6de.js.map": "/static/js/main.b20df6de.js.map"
},
"entrypoints": [
"static/css/main.7791f349.css",
"static/js/main.b20df6de.js"
]
}
@@ -0,0 +1,21 @@
<svg xmlns='http://www.w3.org/2000/svg' width='1200' height='630' viewBox='0 0 1200 630'>
<rect width='1200' height='630' fill='#0a1628'/>
<defs>
<linearGradient id='g2' x1='0' y1='0' x2='1' y2='1'>
<stop offset='0%' stop-color='#0a1628'/>
<stop offset='100%' stop-color='#1a2a4a'/>
</linearGradient>
</defs>
<rect width='1200' height='630' fill='url(#g2)'/>
<circle cx='600' cy='180' r='3' fill='white' opacity='0.6'/>
<circle cx='200' cy='120' r='2' fill='white' opacity='0.4'/>
<circle cx='1000' cy='80' r='2' fill='white' opacity='0.5'/>
<circle cx='150' cy='300' r='1.5' fill='white' opacity='0.3'/>
<circle cx='1050' cy='250' r='2' fill='white' opacity='0.4'/>
<text x='600' y='230' font-family='Arial,sans-serif' font-size='80' text-anchor='middle'>&#127758;</text>
<text x='600' y='330' font-family='Georgia,serif' font-size='62' font-weight='bold' fill='#ffffff' text-anchor='middle'>Epic Travel Expeditions</text>
<text x='600' y='390' font-family='Arial,sans-serif' font-size='26' fill='#60a5fa' text-anchor='middle'>Adventure Awaits &bull; Curated World Expeditions</text>
<text x='600' y='445' font-family='Arial,sans-serif' font-size='22' fill='rgba(255,255,255,0.5)' text-anchor='middle'>Exclusive Destinations &bull; Guided Tours &bull; Unforgettable Journeys</text>
<rect x='430' y='490' width='340' height='50' rx='8' fill='#2563eb'/>
<text x='600' y='522' font-family='Arial,sans-serif' font-size='22' font-weight='bold' fill='white' text-anchor='middle'>epictravelexpeditions.com</text>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,14 @@
{
"name": "epic-travel/expeditions",
"description": "Epic Travel & Expeditions Website",
"type": "project",
"require": {
"php": ">=7.4.0"
},
"config": {
"platform": {
"php": "8.0"
},
"optimize-autoloader": true
}
}
@@ -0,0 +1,91 @@
docRoot /home/epictravelexpeditions.com/parkerslingshot
vhDomain $VH_NAME
vhAliases www.$VH_NAME
adminEmails admin@epictravelexpeditions.com
enableGzip 1
enableIpGeo 1
index {
useServer 0
indexFiles index.php, index.html
}
errorlog $VH_ROOT/logs/epictravelexpeditions.com.error_log {
useServer 0
logLevel WARN
rollingSize 10M
}
accesslog $VH_ROOT/logs/epictravelexpeditions.com.access_log {
useServer 0
logFormat "%h %l %u %t "%r" %>s %b "%{Referer}i" "%{User-Agent}i""
logHeaders 5
rollingSize 10M
keepDays 10
compressArchive 1
}
phpIniOverride {
}
module cache {
storagePath /usr/local/lsws/cachedata/$VH_NAME
}
scripthandler {
add lsapi:epict63871534 php
}
extprocessor epict63871534 {
type lsapi
address UDS://tmp/lshttpd/epict63871534.sock
maxConns 10
env LSAPI_CHILDREN=10
initTimeout 60
retryTimeout 0
persistConn 1
pcKeepAliveTimeout 1
respBuffer 0
autoStart 1
path /usr/local/lsws/lsphp85/bin/lsphp
extUser epict6387
extGroup epict6387
memSoftLimit 1024M
memHardLimit 1024M
procSoftLimit 400
procHardLimit 500
}
rewrite {
enable 1
autoLoadHtaccess 1
}
context /.well-known/acme-challenge {
location /usr/local/lsws/Example/html/.well-known/acme-challenge
allowBrowse 1
rewrite {
enable 0
}
addDefaultCharset off
phpIniOverride {
}
}
vhssl {
keyFile /etc/letsencrypt/live/parkerslingshot.epictravelexpeditions.com/privkey.pem
certFile /etc/letsencrypt/live/parkerslingshot.epictravelexpeditions.com/fullchain.pem
certChain 1
sslProtocol 24
enableECDHE 1
renegProtection 1
sslSessionCache 1
enableSpdy 15
enableStapling 1
ocspRespMaxAge 86400
}
@@ -0,0 +1,85 @@
enablegzip 1
docroot /home/epictravelexpeditions.com/parkerslingshot
vhdomain $VH_NAME
phpinioverride
vhaliases www.$VH_NAME
enableipgeo 1
adminemails admin@epictravelexpeditions.com
errorlog $VH_ROOT/logs/epictravelexpeditions.com.error_log {
rollingsize 10M
loglevel WARN
useserver 0
}
rewrite {
autoloadhtaccess 1
enable 1
}
scripthandler {
add lsapi:epict63871534 php
}
extprocessor epict63871534 {
extgroup epict6387
memsoftlimit 1024M
autostart 1
prochardlimit 500
address UDS://tmp/lshttpd/epict63871534.sock
maxconns 10
memhardlimit 1024M
procsoftlimit 400
inittimeout 60
retrytimeout 0
persistconn 1
pckeepalivetimeout 1
respbuffer 0
path /usr/local/lsws/lsphp85/bin/lsphp
extuser epict6387
type lsapi
env LSAPI_CHILDREN=10
}
context /.well-known/acme-challenge {
location /usr/local/lsws/Example/html/.well-known/acme-challenge
adddefaultcharset off
phpinioverride
allowbrowse 1
rewrite {
enable 0
}
}
vhssl {
certchain 1
sslprotocol 24
enableecdhe 1
keyfile /etc/letsencrypt/live/parkerslingshot.epictravelexpeditions.com/privkey.pem
certfile /etc/letsencrypt/live/parkerslingshot.epictravelexpeditions.com/fullchain.pem
enablestapling 1
ocsprespmaxage 86400
renegprotection 1
sslsessioncache 1
enablespdy 15
}
module cache {
unknownkeywords storagepath /usr/local/lsws/cachedata/$VH_NAME
param storagepath /usr/local/lsws/cachedata/$VH_NAME
}
index {
indexfiles index.php, index.html
useserver 0
}
accesslog $VH_ROOT/logs/epictravelexpeditions.com.access_log {
compressarchive 1
logformat "%h %l %u %t "%r" %>s %b "%{Referer}i" "%{User-Agent}i""
rollingsize 10M
keepdays 10
useserver 0
logheaders 5
}
@@ -0,0 +1,91 @@
docRoot /home/epictravelexpeditions.com/parkerslingshot
vhDomain $VH_NAME
vhAliases www.$VH_NAME
adminEmails admin@epictravelexpeditions.com
enableGzip 1
enableIpGeo 1
index {
useServer 0
indexFiles index.php, index.html
}
errorlog $VH_ROOT/logs/epictravelexpeditions.com.error_log {
useServer 0
logLevel WARN
rollingSize 10M
}
accesslog $VH_ROOT/logs/epictravelexpeditions.com.access_log {
useServer 0
logFormat "%h %l %u %t "%r" %>s %b "%{Referer}i" "%{User-Agent}i""
logHeaders 5
rollingSize 10M
keepDays 10
compressArchive 1
}
phpIniOverride {
}
module cache {
storagePath /usr/local/lsws/cachedata/$VH_NAME
}
scripthandler {
add lsapi:epict63871534 php
}
extprocessor epict63871534 {
type lsapi
address UDS://tmp/lshttpd/epict63871534.sock
maxConns 10
env LSAPI_CHILDREN=10
initTimeout 60
retryTimeout 0
persistConn 1
pcKeepAliveTimeout 1
respBuffer 0
autoStart 1
path /usr/local/lsws/lsphp85/bin/lsphp
extUser epict6387
extGroup epict6387
memSoftLimit 1024M
memHardLimit 1024M
procSoftLimit 400
procHardLimit 500
}
rewrite {
enable 1
autoLoadHtaccess 1
}
context /.well-known/acme-challenge {
location /usr/local/lsws/Example/html/.well-known/acme-challenge
allowBrowse 1
rewrite {
enable 0
}
addDefaultCharset off
phpIniOverride {
}
}
vhssl {
keyFile /etc/letsencrypt/live/parkerslingshot.epictravelexpeditions.com/privkey.pem
certFile /etc/letsencrypt/live/parkerslingshot.epictravelexpeditions.com/fullchain.pem
certChain 1
sslProtocol 24
enableECDHE 1
renegProtection 1
sslSessionCache 1
enableSpdy 15
enableStapling 1
ocspRespMaxAge 86400
}
@@ -0,0 +1,130 @@
head 1.2;
access;
symbols;
locks
root:1.2; strict;
comment @# @;
1.2
date 2026.05.17.04.47.56; author root; state Exp;
branches;
next 1.1;
1.1
date 2026.05.17.04.47.43; author root; state Exp;
branches;
next ;
desc
@/usr/local/lsws/conf/vhosts/parkerslingshot.epictravelexpeditions.com/vhost.conf0
@
1.2
log
@Update
@
text
@docRoot /home/epictravelexpeditions.com/parkerslingshot
vhDomain $VH_NAME
vhAliases www.$VH_NAME
adminEmails admin@@epictravelexpeditions.com
enableGzip 1
enableIpGeo 1
index {
useServer 0
indexFiles index.php, index.html
}
errorlog $VH_ROOT/logs/epictravelexpeditions.com.error_log {
useServer 0
logLevel WARN
rollingSize 10M
}
accesslog $VH_ROOT/logs/epictravelexpeditions.com.access_log {
useServer 0
logFormat "%h %l %u %t "%r" %>s %b "%{Referer}i" "%{User-Agent}i""
logHeaders 5
rollingSize 10M
keepDays 10
compressArchive 1
}
phpIniOverride {
}
module cache {
storagePath /usr/local/lsws/cachedata/$VH_NAME
}
scripthandler {
add lsapi:epict63871534 php
}
extprocessor epict63871534 {
type lsapi
address UDS://tmp/lshttpd/epict63871534.sock
maxConns 10
env LSAPI_CHILDREN=10
initTimeout 60
retryTimeout 0
persistConn 1
pcKeepAliveTimeout 1
respBuffer 0
autoStart 1
path /usr/local/lsws/lsphp85/bin/lsphp
extUser epict6387
extGroup epict6387
memSoftLimit 1024M
memHardLimit 1024M
procSoftLimit 400
procHardLimit 500
}
rewrite {
enable 1
autoLoadHtaccess 1
}
context /.well-known/acme-challenge {
location /usr/local/lsws/Example/html/.well-known/acme-challenge
allowBrowse 1
rewrite {
enable 0
}
addDefaultCharset off
phpIniOverride {
}
}
vhssl {
keyFile /etc/letsencrypt/live/parkerslingshot.epictravelexpeditions.com/privkey.pem
certFile /etc/letsencrypt/live/parkerslingshot.epictravelexpeditions.com/fullchain.pem
certChain 1
sslProtocol 24
enableECDHE 1
renegProtection 1
sslSessionCache 1
enableSpdy 15
enableStapling 1
ocspRespMaxAge 86400
}
@
1.1
log
@Update
@
text
@d79 13
@
@@ -0,0 +1,95 @@
docRoot $VH_ROOT/public_html
vhDomain $VH_NAME
vhAliases www.$VH_NAME
adminEmails admin@epictravelexpeditions.com
enableGzip 1
enableIpGeo 1
index {
useServer 0
indexFiles index.php, index.html
}
errorlog $VH_ROOT/logs/$VH_NAME.error_log {
useServer 0
logLevel WARN
rollingSize 10M
}
accesslog $VH_ROOT/logs/$VH_NAME.access_log {
useServer 0
logFormat "%h %l %u %t "%r" %>s %b "%{Referer}i" "%{User-Agent}i""
logHeaders 5
rollingSize 10M
keepDays 10
compressArchive 1
}
scripthandler {
add lsapi:epict6387 php
}
extprocessor epict6387 {
type lsapi
address UDS://tmp/lshttpd/epict6387.sock
maxConns 10
env LSAPI_CHILDREN=10
initTimeout 600
retryTimeout 0
persistConn 1
pcKeepAliveTimeout 1
respBuffer 0
autoStart 1
path /usr/local/lsws/lsphp85/bin/lsphp
extUser epict6387
extGroup epict6387
memSoftLimit 1024M
memHardLimit 1024M
procSoftLimit 400
procHardLimit 500
}
phpIniOverride {
}
module cache {
storagePath /usr/local/lsws/cachedata/$VH_NAME
}
rewrite {
enable 1
autoLoadHtaccess 1
}
context /.well-known/acme-challenge {
location /usr/local/lsws/Example/html/.well-known/acme-challenge
allowBrowse 1
rewrite {
enable 0
}
addDefaultCharset off
phpIniOverride {
}
}
vhssl {
keyFile /etc/letsencrypt/live/epictravelexpeditions.com/privkey.pem
certFile /etc/letsencrypt/live/epictravelexpeditions.com/fullchain.pem
certChain 1
sslProtocol 24
enableECDHE 1
renegProtection 1
sslSessionCache 1
enableSpdy 15
enableStapling 1
ocspRespMaxAge 86400
}
<Directory /home/admin_epicftp/public_html>
AllowOverride All
Require all granted
</Directory>
@@ -0,0 +1,86 @@
vhdomain $VH_NAME
vhaliases www.$VH_NAME
enableipgeo 1
enablegzip 1
docroot $VH_ROOT/public_html
phpinioverride
allowoverride All
adminemails admin@epictravelexpeditions.com
extprocessor epict6387 {
pckeepalivetimeout 1
respbuffer 0
memsoftlimit 1024M
memhardlimit 1024M
env LSAPI_CHILDREN=10
inittimeout 600
extuser epict6387
extgroup epict6387
type lsapi
address UDS://tmp/lshttpd/epict6387.sock
maxconns 10
prochardlimit 500
retrytimeout 0
persistconn 1
autostart 1
path /usr/local/lsws/lsphp85/bin/lsphp
procsoftlimit 400
}
context /.well-known/acme-challenge {
adddefaultcharset off
phpinioverride
location /usr/local/lsws/Example/html/.well-known/acme-challenge
allowbrowse 1
rewrite {
enable 0
}
}
rewrite {
enable 1
autoloadhtaccess 1
}
module cache {
unknownkeywords storagepath /usr/local/lsws/cachedata/$VH_NAME
param storagepath /usr/local/lsws/cachedata/$VH_NAME
}
accesslog $VH_ROOT/logs/$VH_NAME.access_log {
rollingsize 10M
keepdays 10
compressarchive 1
logformat "%h %l %u %t "%r" %>s %b "%{Referer}i" "%{User-Agent}i""
useserver 0
logheaders 5
}
errorlog $VH_ROOT/logs/$VH_NAME.error_log {
useserver 0
rollingsize 10M
loglevel WARN
}
scripthandler {
add lsapi:epict6387 php
}
index {
indexfiles index.php, index.html
useserver 0
}
vhssl {
certchain 1
sslprotocol 24
enableecdhe 1
renegprotection 1
sslsessioncache 1
enablespdy 15
enablestapling 1
ocsprespmaxage 86400
keyfile /etc/letsencrypt/live/epictravelexpeditions.com/privkey.pem
certfile /etc/letsencrypt/live/epictravelexpeditions.com/fullchain.pem
}
@@ -0,0 +1,95 @@
docRoot $VH_ROOT/public_html
vhDomain $VH_NAME
vhAliases www.$VH_NAME
adminEmails admin@epictravelexpeditions.com
enableGzip 1
enableIpGeo 1
index {
useServer 0
indexFiles index.php, index.html
}
errorlog $VH_ROOT/logs/$VH_NAME.error_log {
useServer 0
logLevel WARN
rollingSize 10M
}
accesslog $VH_ROOT/logs/$VH_NAME.access_log {
useServer 0
logFormat "%h %l %u %t "%r" %>s %b "%{Referer}i" "%{User-Agent}i""
logHeaders 5
rollingSize 10M
keepDays 10
compressArchive 1
}
scripthandler {
add lsapi:epict6387 php
}
extprocessor epict6387 {
type lsapi
address UDS://tmp/lshttpd/epict6387.sock
maxConns 10
env LSAPI_CHILDREN=10
initTimeout 600
retryTimeout 0
persistConn 1
pcKeepAliveTimeout 1
respBuffer 0
autoStart 1
path /usr/local/lsws/lsphp85/bin/lsphp
extUser epict6387
extGroup epict6387
memSoftLimit 1024M
memHardLimit 1024M
procSoftLimit 400
procHardLimit 500
}
phpIniOverride {
}
module cache {
storagePath /usr/local/lsws/cachedata/$VH_NAME
}
rewrite {
enable 1
autoLoadHtaccess 1
}
context /.well-known/acme-challenge {
location /usr/local/lsws/Example/html/.well-known/acme-challenge
allowBrowse 1
rewrite {
enable 0
}
addDefaultCharset off
phpIniOverride {
}
}
vhssl {
keyFile /etc/letsencrypt/live/epictravelexpeditions.com/privkey.pem
certFile /etc/letsencrypt/live/epictravelexpeditions.com/fullchain.pem
certChain 1
sslProtocol 24
enableECDHE 1
renegProtection 1
sslSessionCache 1
enableSpdy 15
enableStapling 1
ocspRespMaxAge 86400
}
<Directory /home/admin_epicftp/public_html>
AllowOverride All
Require all granted
</Directory>
@@ -0,0 +1,147 @@
head 1.3;
access;
symbols;
locks
root:1.3; strict;
comment @# @;
1.3
date 2026.05.15.22.32.23; author root; state Exp;
branches;
next 1.2;
1.2
date 2026.05.15.20.10.31; author root; state Exp;
branches;
next 1.1;
1.1
date 2026.05.15.20.10.01; author root; state Exp;
branches;
next ;
desc
@/usr/local/lsws/conf/vhosts/epictravelexpeditions.com/vhost.conf0
@
1.3
log
@Update
@
text
@docRoot $VH_ROOT/public_html
vhDomain $VH_NAME
vhAliases www.$VH_NAME
adminEmails admin@@epictravelexpeditions.com
enableGzip 1
enableIpGeo 1
index {
useServer 0
indexFiles index.php, index.html
}
errorlog $VH_ROOT/logs/$VH_NAME.error_log {
useServer 0
logLevel WARN
rollingSize 10M
}
accesslog $VH_ROOT/logs/$VH_NAME.access_log {
useServer 0
logFormat "%h %l %u %t "%r" %>s %b "%{Referer}i" "%{User-Agent}i""
logHeaders 5
rollingSize 10M
keepDays 10
compressArchive 1
}
scripthandler {
add lsapi:epict6387 php
}
extprocessor epict6387 {
type lsapi
address UDS://tmp/lshttpd/epict6387.sock
maxConns 10
env LSAPI_CHILDREN=10
initTimeout 600
retryTimeout 0
persistConn 1
pcKeepAliveTimeout 1
respBuffer 0
autoStart 1
path /usr/local/lsws/lsphp85/bin/lsphp
extUser epict6387
extGroup epict6387
memSoftLimit 1024M
memHardLimit 1024M
procSoftLimit 400
procHardLimit 500
}
phpIniOverride {
}
module cache {
storagePath /usr/local/lsws/cachedata/$VH_NAME
}
rewrite {
enable 1
autoLoadHtaccess 1
}
context /.well-known/acme-challenge {
location /usr/local/lsws/Example/html/.well-known/acme-challenge
allowBrowse 1
rewrite {
enable 0
}
addDefaultCharset off
phpIniOverride {
}
}
vhssl {
keyFile /etc/letsencrypt/live/epictravelexpeditions.com/privkey.pem
certFile /etc/letsencrypt/live/epictravelexpeditions.com/fullchain.pem
certChain 1
sslProtocol 24
enableECDHE 1
renegProtection 1
sslSessionCache 1
enableSpdy 15
enableStapling 1
ocspRespMaxAge 86400
}
<Directory /home/admin_epicftp/public_html>
AllowOverride All
Require all granted
</Directory>@
1.2
log
@Update
@
text
@d92 4
@
1.1
log
@Update
@
text
@d79 13
@
@@ -0,0 +1,123 @@
/*M!999999\- enable the sandbox mode */
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!40101 SET NAMES utf8mb4 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
DROP TABLE IF EXISTS `admin_users`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `admin_users` (
`id` varchar(36) NOT NULL,
`email` varchar(255) NOT NULL,
`password_hash` varchar(255) NOT NULL,
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `email` (`email`),
KEY `idx_email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `contacts`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `contacts` (
`id` varchar(36) NOT NULL,
`name` varchar(255) NOT NULL,
`email` varchar(255) NOT NULL,
`message` text NOT NULL,
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `destination_categories`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `destination_categories` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(100) NOT NULL,
`created_at` timestamp NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `name` (`name`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `destinations`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `destinations` (
`id` varchar(36) NOT NULL,
`name` varchar(255) NOT NULL,
`location` varchar(255) NOT NULL,
`description` text NOT NULL,
`image` varchar(500) NOT NULL,
`category` varchar(50) NOT NULL,
`rating` decimal(2,1) NOT NULL DEFAULT 4.5,
`price` decimal(10,2) NOT NULL,
`currency` varchar(3) NOT NULL DEFAULT 'USD',
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
KEY `idx_category` (`category`),
KEY `idx_name` (`name`),
KEY `idx_location` (`location`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `newsletter_subscribers`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `newsletter_subscribers` (
`id` varchar(36) NOT NULL,
`email` varchar(255) NOT NULL,
`subscribed_at` datetime NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `email` (`email`),
KEY `idx_email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `specials`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `specials` (
`id` varchar(36) NOT NULL,
`destination_id` varchar(36) NOT NULL,
`discount` decimal(5,2) NOT NULL,
`price` decimal(10,2) DEFAULT NULL,
`end_date` date NOT NULL,
`highlights` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL CHECK (json_valid(`highlights`)),
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
`image_path` varchar(500) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_destination` (`destination_id`),
KEY `idx_end_date` (`end_date`),
CONSTRAINT `specials_ibfk_1` FOREIGN KEY (`destination_id`) REFERENCES `destinations` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `testimonials`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `testimonials` (
`id` varchar(36) NOT NULL,
`full_name` varchar(255) NOT NULL,
`location` varchar(255) NOT NULL,
`message` text NOT NULL,
`image_path` varchar(500) DEFAULT NULL,
`status` enum('pending','approved','denied') NOT NULL DEFAULT 'pending',
`created_at` timestamp NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
@@ -0,0 +1,18 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="Epic Travel Expeditions - Curated adventures to the world's most breathtaking destinations."/><link rel="preconnect" href="https://fonts.googleapis.com"/><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/><link href="https://fonts.googleapis.com/css2?family=Inter:wght@600&display=swap" rel="stylesheet"/><title>Epic Travel Expeditions | Adventure Awaits</title><script>window.addEventListener("error",function(e){e.error instanceof DOMException&&"DataCloneError"===e.error.name&&e.message&&e.message.includes("PerformanceServerTiming")&&(e.stopImmediatePropagation(),e.preventDefault())},!0)</script><script defer="defer" src="/static/js/main.b9c030e8v2.js"></script><link href="/static/css/main.7791f349.css" rel="stylesheet"><link rel="canonical" href="https://epictravelexpeditions.com/" />
<meta name="keywords" content="adventure travel, guided tours, travel expeditions, exotic destinations, bucket list travel, international travel packages, adventure vacations, world travel" />
<meta name="robots" content="index, follow" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Epic Travel Expeditions" />
<meta property="og:title" content="Epic Travel Expeditions | Adventure Awaits" />
<meta property="og:description" content="Epic Travel Expeditions - Curated adventures to the world&#39;s most breathtaking destinations. Explore our guided tours, exclusive specials, and dream destinations." />
<meta property="og:url" content="https://epictravelexpeditions.com/" />
<meta property="og:image" content="https://epictravelexpeditions.com/assets/og-image.jpg" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:locale" content="en_US" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Epic Travel Expeditions | Adventure Awaits" />
<meta name="twitter:description" content="Curated adventures to the world&#39;s most breathtaking destinations. Guided tours, exclusive travel specials, and unforgettable expeditions." />
<meta name="twitter:image" content="https://epictravelexpeditions.com/assets/og-image.jpg" />
<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"Organization","@id":"https://epictravelexpeditions.com/#org","name":"Epic Travel Expeditions","url":"https://epictravelexpeditions.com","description":"Curated adventure travel expeditions to the world's most breathtaking destinations.","sameAs":[]},{"@type":"WebSite","@id":"https://epictravelexpeditions.com/#website","url":"https://epictravelexpeditions.com","name":"Epic Travel Expeditions","publisher":{"@id":"https://epictravelexpeditions.com/#org"},"potentialAction":{"@type":"SearchAction","target":"https://epictravelexpeditions.com/destinations?q={search_term_string}","query-input":"required name=search_term_string"}},{"@type":"TravelAgency","@id":"https://epictravelexpeditions.com/#agency","name":"Epic Travel Expeditions","url":"https://epictravelexpeditions.com","description":"Specializing in curated adventure travel, guided expeditions, and exclusive destination packages worldwide.","priceRange":"$$$","currenciesAccepted":"USD","paymentAccepted":"Credit Card","areaServed":"Worldwide"}]}</script>
</head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div><script>!function(e,t){var r,s,o,i;t.__SV||(window.posthog=t,t._i=[],t.init=function(n,a,p){function c(e,t){var r=t.split(".");2==r.length&&(e=e[r[0]],t=r[1]),e[t]=function(){e.push([t].concat(Array.prototype.slice.call(arguments,0)))}}(o=e.createElement("script")).type="text/javascript",o.crossOrigin="anonymous",o.async=!0,o.src=a.api_host.replace(".i.posthog.com","-assets.i.posthog.com")+"/static/array.js",(i=e.getElementsByTagName("script")[0]).parentNode.insertBefore(o,i);var g=t;for(void 0!==p?g=t[p]=[]:p="posthog",g.people=g.people||[],g.toString=function(e){var t="posthog";return"posthog"!==p&&(t+="."+p),e||(t+=" (stub)"),t},g.people.toString=function(){return g.toString(1)+".people (stub)"},r="init me ws ys ps bs capture je Di ks register register_once register_for_session unregister unregister_for_session Ps getFeatureFlag getFeatureFlagPayload isFeatureEnabled reloadFeatureFlags updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures on onFeatureFlags onSurveysLoaded onSessionId getSurveys getActiveMatchingSurveys renderSurvey canRenderSurvey canRenderSurveyAsync identify setPersonProperties group resetGroups setPersonPropertiesForFlags resetPersonPropertiesForFlags setGroupPropertiesForFlags resetGroupPropertiesForFlags reset get_distinct_id getGroups get_session_id get_session_replay_url alias set_config startSessionRecording stopSessionRecording sessionRecordingStarted captureException loadToolbar get_property getSessionProperty Es $s createPersonProfile Is opt_in_capturing opt_out_capturing has_opted_in_capturing has_opted_out_capturing clear_opt_in_out_capturing Ss debug xs getPageViewId captureTraceFeedback captureTraceMetric".split(" "),s=0;s<r.length;s++)c(g,r[s]);t._i.push([n,a,p])},t.__SV=1)}(document,window.posthog||[]),posthog.init("phc_xAvL2Iq4tFmANRE7kzbKwaSqp1HJjN7x48s3vr0CMjs",{api_host:"https://us.i.posthog.com",person_profiles:"identified_only",session_recording:{recordCrossOriginIframes:!0,capturePerformance:!1}})</script><script src="/static/js/testimonials.js"></script><script src="/static/js/admin-portal-v2.js"></script></body></html>
@@ -0,0 +1,6 @@
User-agent: *
Allow: /
Disallow: /admin
Disallow: /api/
Sitemap: https://epictravelexpeditions.com/sitemap.xml
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
<url>
<loc>https://epictravelexpeditions.com/</loc>
<lastmod>2026-05-19</lastmod>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://epictravelexpeditions.com/destinations</loc>
<lastmod>2026-05-19</lastmod>
<changefreq>weekly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://epictravelexpeditions.com/specials</loc>
<lastmod>2026-05-19</lastmod>
<changefreq>daily</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://epictravelexpeditions.com/contact</loc>
<lastmod>2026-05-19</lastmod>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
</urlset>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,541 @@
(function(){
'use strict';
const API='https://epictravelexpeditions.com/api';
function authHdr(){return{'Content-Type':'application/json',Authorization:`Bearer ${localStorage.getItem('auth_token')}`};}
async function api(path,opts){
const res=await fetch(API+path,{headers:authHdr(),...opts});
const d=await res.json();
if(!res.ok)throw new Error(d.error||res.statusText);
return d;
}
async function uploadImg(file){
const fd=new FormData();fd.append('file',file);
const res=await fetch(API+'/upload/image',{method:'POST',headers:{Authorization:`Bearer ${localStorage.getItem('auth_token')}`},body:fd});
const d=await res.json();
if(!res.ok)throw new Error(d.error||'Upload failed');
return d.url;
}
async function getImg(inputId,fallback){
const el=document.getElementById(inputId);
if(el&&el.files&&el.files[0]){
const st=document.getElementById(inputId+'-st');
if(st)st.textContent='Uploading…';
try{const url=await uploadImg(el.files[0]);if(st)st.textContent='';return url;}
catch(e){if(st){st.textContent='Upload failed: '+e.message;st.style.color='#dc2626';}throw e;}
}
return fallback!=null?fallback:null;
}
function imgPicker(id,src){
const pr=src
?`<img id="${id}-pr" src="${src}" style="width:52px;height:52px;border-radius:8px;object-fit:cover;border:1px solid #e5e7eb">`
:`<div id="${id}-pr" style="width:52px;height:52px;border-radius:8px;background:#f3f4f6;border:1px solid #e5e7eb;display:flex;align-items:center;justify-content:center;font-size:20px">🖼</div>`;
return `<div style="display:flex;align-items:center;gap:10px">${pr}<label style="display:inline-flex;align-items:center;gap:6px;padding:7px 13px;border:1px dashed #d1d5db;border-radius:7px;cursor:pointer;font-size:12px;color:#6b7280;background:#fff">📁 Choose Image<input type="file" accept="image/jpeg,image/png,image/webp" id="${id}" style="display:none" onchange="(function(el){const f=el.files[0];if(!f)return;const u=URL.createObjectURL(f);const p=document.getElementById('${id}-pr');if(p.tagName==='IMG'){p.src=u;}else{p.outerHTML='<img id=${id}-pr src='+u+' style=width:52px;height:52px;border-radius:8px;object-fit:cover;border:1px solid #e5e7eb>';};})(this)"></label><span id="${id}-st" style="font-size:11px;color:#6b7280"></span></div>`;
}
function daysAgo(s){return Math.floor((Date.now()-new Date(s))/86400000);}
function daysLeft(s){return Math.ceil((new Date(s)-Date.now())/86400000);}
function ageBdg(d){const c=d<30?'bg':d<90?'by':'br';return`<span class="ep-b ${c}">${d}d ago</span>`;}
function expBdg(d){if(d<0)return`<span class="ep-b br">Expired</span>`;const c=d>14?'bg':d>7?'by':'br';return`<span class="ep-b ${c}">${d}d left</span>`;}
function esc(s){const e=document.createElement('div');e.textContent=s||'';return e.innerHTML;}
function catOpts(cats,sel){return cats.map(c=>`<option value="${esc(c.name)}"${c.name===sel?' selected':''}>${esc(c.name)}</option>`).join('');}
function fmtPrice(p){return p!=null?'$'+parseFloat(p).toLocaleString(undefined,{minimumFractionDigits:0,maximumFractionDigits:2}):'';}
/* CSS */
const css=`
#ep-portal{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#111827;min-height:100vh;background:#f3f4f6}
#ep-portal *,#ep-portal *::before,#ep-portal *::after{box-sizing:border-box}
#ep-hdr{background:#fff;border-bottom:1px solid #e5e7eb;padding:0 28px;height:60px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:100;box-shadow:0 1px 3px rgba(0,0,0,.06)}
.ep-logo{font-size:17px;font-weight:800;color:#1d4ed8;letter-spacing:-.4px}.ep-logo span{color:#6b7280;font-weight:400}
.ep-hdr-acts{display:flex;gap:8px}
.ep-hbtn{padding:6px 14px;border-radius:7px;font-size:13px;font-weight:600;border:1px solid #d1d5db;background:#fff;color:#374151;cursor:pointer;transition:all .15s;text-decoration:none;display:inline-flex;align-items:center;gap:5px}
.ep-hbtn:hover{background:#f9fafb}.ep-hbtn.red{border-color:#fca5a5;color:#dc2626}.ep-hbtn.red:hover{background:#fef2f2}
#ep-tabs{background:#fff;border-bottom:1px solid #e5e7eb;padding:0 28px;display:flex;gap:2px}
.ep-tab{padding:13px 18px;font-size:13px;font-weight:600;color:#6b7280;border:none;background:none;cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px;transition:all .15s;display:inline-flex;align-items:center;gap:7px}
.ep-tab:hover{color:#1d4ed8}.ep-tab.active{color:#1d4ed8;border-bottom-color:#1d4ed8}
.ep-tbadge{background:#fee2e2;color:#dc2626;border-radius:999px;font-size:11px;padding:1px 6px;font-weight:700}
#ep-body{padding:28px;max-width:1180px;margin:0 auto}
.ep-g4{display:grid;grid-template-columns:repeat(4,1fr);gap:18px;margin-bottom:28px}
@media(max-width:880px){.ep-g4{grid-template-columns:repeat(2,1fr)}}
.ep-stat{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:20px 22px;position:relative;overflow:hidden}
.ep-si{font-size:26px;margin-bottom:6px}.ep-sv{font-size:30px;font-weight:800;color:#111827}.ep-sl{font-size:12px;color:#6b7280;margin-top:1px}
.ep-acc{position:absolute;right:0;top:0;bottom:0;width:4px;border-radius:0 12px 12px 0}
.acb{background:#3b82f6}.acg{background:#10b981}.aca{background:#f59e0b}.acp{background:#8b5cf6}
.ep-card{background:#fff;border:1px solid #e5e7eb;border-radius:12px;margin-bottom:22px;overflow:hidden}
.ep-ch{padding:16px 22px;border-bottom:1px solid #e5e7eb;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px}
.ep-ct{font-size:14px;font-weight:700;color:#111827}.ep-cs{font-size:12px;color:#6b7280;margin-top:1px}
.ep-cb{padding:22px}
.ep-tbl{width:100%;border-collapse:collapse;font-size:13px}
.ep-tbl th{text-align:left;font-size:11px;font-weight:700;color:#6b7280;text-transform:uppercase;letter-spacing:.05em;padding:9px 12px;border-bottom:1px solid #e5e7eb;white-space:nowrap}
.ep-tbl td{padding:11px 12px;border-bottom:1px solid #f3f4f6;vertical-align:middle}
.ep-tbl tr:last-child td{border-bottom:none}.ep-tbl tr:hover td{background:#fafafa}
.ep-b{display:inline-block;padding:2px 9px;border-radius:999px;font-size:11px;font-weight:700}
.bg{background:#d1fae5;color:#065f46}.by{background:#fef3c7;color:#92400e}.br{background:#fee2e2;color:#991b1b}.bb{background:#dbeafe;color:#1e40af}.bgr{background:#f3f4f6;color:#374151}
.ep-btn{padding:6px 13px;border-radius:7px;font-size:12px;font-weight:600;border:none;cursor:pointer;transition:opacity .15s;display:inline-flex;align-items:center;gap:4px;white-space:nowrap}
.ep-btn:hover{opacity:.82}.ep-btn:disabled{opacity:.4;cursor:not-allowed}
.ep-pri{background:#2563eb;color:#fff}.ep-suc{background:#16a34a;color:#fff}.ep-dan{background:#dc2626;color:#fff}.ep-gho{background:#f3f4f6;color:#374151}
.ep-form{display:grid;grid-template-columns:1fr 1fr;gap:14px}
@media(max-width:640px){.ep-form{grid-template-columns:1fr}}
.ep-full{grid-column:1/-1}.ep-fld{display:flex;flex-direction:column;gap:4px}
.ep-lbl{font-size:11px;font-weight:700;color:#374151;text-transform:uppercase;letter-spacing:.04em}
.ep-inp,.ep-sel,.ep-ta{padding:8px 11px;border:1px solid #d1d5db;border-radius:7px;font-size:13px;color:#111827;background:#fff;outline:none;transition:border-color .15s;width:100%}
.ep-inp:focus,.ep-sel:focus,.ep-ta:focus{border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.1)}
.ep-ta{resize:vertical;min-height:72px}
.ep-frow{grid-column:1/-1;display:flex;gap:10px;justify-content:flex-end;padding-top:4px;align-items:center}
.ep-addbox{border:1px dashed #d1d5db;border-radius:10px;padding:18px;margin-bottom:20px;background:#fafafa;display:none}
.ep-addbox.open{display:block}
.ep-thumb{width:40px;height:40px;border-radius:7px;object-fit:cover;background:#e5e7eb}
.ep-smsg{font-size:12px}.ep-smsg.ok{color:#16a34a}.ep-smsg.err{color:#dc2626}
.ep-alert{padding:10px 15px;border-radius:8px;font-size:13px;margin-bottom:16px;border-left:3px solid}
.ep-alert.warn{background:#fffbeb;color:#92400e;border-color:#f59e0b}
.ep-inline{display:grid;grid-template-columns:1fr 1fr;gap:10px;padding:14px}
@media(max-width:640px){.ep-inline{grid-column:1fr}}
.ep-cat-row{display:flex;align-items:center;gap:10px;padding:9px 12px;border-radius:8px;border:1px solid #e5e7eb;background:#fff;margin-bottom:8px}
.ep-cat-nm{flex:1;font-size:13px;font-weight:600}.ep-cat-ct{font-size:11px;color:#9ca3af}
.ep-cat-ei{flex:1;padding:5px 9px;border:1px solid #3b82f6;border-radius:6px;font-size:13px;outline:none}
.ep-tc{border:1px solid #e5e7eb;border-radius:10px;padding:16px;margin-bottom:12px;display:flex;gap:12px;background:#fff}
.ep-tav{width:42px;height:42px;border-radius:50%;object-fit:cover;flex-shrink:0;background:#e5e7eb}
.ep-tavph{width:42px;height:42px;border-radius:50%;background:#2563eb;color:#fff;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:16px;flex-shrink:0}
.ep-tb{flex:1;min-width:0}.ep-tn{font-weight:700;font-size:13px}.ep-tl{font-size:11px;color:#6b7280;margin-bottom:5px}
.ep-tm{font-size:13px;color:#374151;margin-bottom:8px;line-height:1.5}
.ep-te{width:100%;border:1px solid #d1d5db;border-radius:6px;padding:6px 9px;font-size:12px;margin-bottom:7px;resize:vertical;min-height:52px}
.ep-tacts{display:flex;gap:7px;flex-wrap:wrap}
.ep-ftabs{display:flex;gap:7px;margin-bottom:18px;flex-wrap:wrap}
.ep-ft{padding:6px 15px;border-radius:7px;font-size:12px;font-weight:600;cursor:pointer;border:1px solid #d1d5db;background:#fff;color:#374151;transition:all .15s}
.ep-ft.active{background:#2563eb;color:#fff;border-color:#2563eb}
.ep-empty{text-align:center;padding:36px;color:#9ca3af;font-size:13px}
.ep-spec-img{width:52px;height:52px;border-radius:8px;object-fit:cover;background:#e5e7eb;flex-shrink:0}
.ep-price-old{text-decoration:line-through;color:#9ca3af;font-size:11px}
.ep-price-new{color:#16a34a;font-weight:700}
`;
const sel=document.createElement('style');sel.textContent=css;document.head.appendChild(sel);
/* State */
let activeTab='dashboard',tFilter='pending';
let destinations=[],specials=[],testimonials=[],categories=[];
async function loadAll(){
const r=await Promise.allSettled([
fetch(API+'/destinations',{headers:authHdr()}).then(r=>r.json()),
fetch(API+'/specials', {headers:authHdr()}).then(r=>r.json()),
fetch(API+'/testimonials/all',{headers:authHdr()}).then(r=>r.json()),
fetch(API+'/categories', {headers:authHdr()}).then(r=>r.json())
]);
if(r[0].status==='fulfilled'&&Array.isArray(r[0].value))destinations=r[0].value;
if(r[1].status==='fulfilled'&&Array.isArray(r[1].value))specials=r[1].value;
if(r[2].status==='fulfilled'&&Array.isArray(r[2].value))testimonials=r[2].value;
if(r[3].status==='fulfilled'&&Array.isArray(r[3].value))categories=r[3].value;
}
/* Shell */
function buildShell(){
const p=document.createElement('div');p.id='ep-portal';
p.innerHTML=`
<div id="ep-hdr">
<div class="ep-logo">Epic Travel <span>Admin</span></div>
<div class="ep-hdr-acts">
<a href="/" class="ep-hbtn" target="_blank">🌐 View Site</a>
<button class="ep-hbtn red" id="ep-logout"> Logout</button>
</div>
</div>
<div id="ep-tabs">
<button class="ep-tab active" data-tab="dashboard">📊 Dashboard</button>
<button class="ep-tab" data-tab="destinations">🗺 Destinations</button>
<button class="ep-tab" data-tab="specials"> Specials</button>
<button class="ep-tab" data-tab="testimonials">💬 Testimonials <span class="ep-tbadge" id="ep-pb" style="display:none"></span></button>
</div>
<div id="ep-body"></div>`;
p.querySelector('#ep-logout').onclick=()=>{localStorage.removeItem('isAdminAuthenticated');localStorage.removeItem('auth_token');window.location.href='/admin';};
p.querySelectorAll('.ep-tab[data-tab]').forEach(b=>{b.onclick=()=>{activeTab=b.dataset.tab;p.querySelectorAll('.ep-tab').forEach(t=>t.classList.remove('active'));b.classList.add('active');render();};});
return p;
}
/* Dashboard */
function rDash(){
const dm=Object.fromEntries(destinations.map(d=>[d.id,d]));
const pen=testimonials.filter(t=>t.status==='pending').length;
const app=testimonials.filter(t=>t.status==='approved').length;
let h=`<div class="ep-g4">
<div class="ep-stat"><div class="ep-acc acb"></div><div class="ep-si">🗺</div><div class="ep-sv">${destinations.length}</div><div class="ep-sl">Destinations</div></div>
<div class="ep-stat"><div class="ep-acc acg"></div><div class="ep-si"></div><div class="ep-sv">${specials.length}</div><div class="ep-sl">Active Specials</div></div>
<div class="ep-stat"><div class="ep-acc aca"></div><div class="ep-si"></div><div class="ep-sv">${pen}</div><div class="ep-sl">Pending Reviews</div></div>
<div class="ep-stat"><div class="ep-acc acp"></div><div class="ep-si"></div><div class="ep-sv">${app}</div><div class="ep-sl">Approved Testimonials</div></div>
</div>`;
if(pen>0)h+=`<div class="ep-alert warn">⚠️ <strong>${pen}</strong> testimonial${pen>1?'s':''} awaiting review.</div>`;
/* Destinations age */
const sd=[...destinations].sort((a,b)=>new Date(a.created_at)-new Date(b.created_at));
h+=`<div class="ep-card"><div class="ep-ch"><div><div class="ep-ct">🗺 Destinations</div><div class="ep-cs">Oldest entries first</div></div></div><table class="ep-tbl"><thead><tr><th>Destination</th><th>Category</th><th>Price</th><th>In System Since</th></tr></thead><tbody>`;
sd.forEach(d=>{h+=`<tr><td><strong>${esc(d.name)}</strong> <span style="color:#9ca3af;font-size:11px">${esc(d.location)}</span></td><td><span class="ep-b bb">${esc(d.category)}</span></td><td>${fmtPrice(d.price)}</td><td>${ageBdg(daysAgo(d.created_at))}</td></tr>`;});
h+=`</tbody></table></div>`;
/* Specials expiry */
const ss=[...specials].sort((a,b)=>new Date(a.end_date)-new Date(b.end_date));
h+=`<div class="ep-card"><div class="ep-ch"><div><div class="ep-ct">⭐ Weekly Specials</div><div class="ep-cs">Expiring soonest first</div></div></div><table class="ep-tbl"><thead><tr><th>Image</th><th>Destination</th><th>Original Price</th><th>Discount</th><th>Sale Price</th><th>Expires</th><th>Status</th></tr></thead><tbody>`;
ss.forEach(s=>{
const dest=dm[s.destination_id];
const basePrice=s.price!=null?parseFloat(s.price):(dest?parseFloat(dest.price):0);
const salePrice=basePrice*(1-parseFloat(s.discount)/100);
const imgSrc=s.image_path||(dest?dest.image:'');
h+=`<tr>
<td>${imgSrc?`<img src="${esc(imgSrc)}" class="ep-thumb" onerror="this.style.opacity='.2'">`:''}</td>
<td><strong>${esc(dest?dest.name:s.destination_id)}</strong></td>
<td class="ep-price-old">${fmtPrice(basePrice)}</td>
<td><span class="ep-b bg">${parseFloat(s.discount)}% OFF</span></td>
<td class="ep-price-new">${fmtPrice(salePrice)}</td>
<td>${new Date(s.end_date).toLocaleDateString()}</td>
<td>${expBdg(daysLeft(s.end_date))}</td>
</tr>`;
});
h+=`</tbody></table></div>`;
/* Pending testimonials */
const pl=testimonials.filter(t=>t.status==='pending');
if(pl.length){
h+=`<div class="ep-card"><div class="ep-ch"><div class="ep-ct">⏳ Pending Testimonials</div></div><div class="ep-cb">`;
pl.forEach(t=>{
const av=t.image_path?`<img class="ep-tav" src="${esc(t.image_path)}" alt="">`:`<div class="ep-tavph">${esc(t.full_name.charAt(0))}</div>`;
h+=`<div class="ep-tc" data-id="${esc(t.id)}">${av}<div class="ep-tb"><div class="ep-tn">${esc(t.full_name)}</div><div class="ep-tl">${esc(t.location)}</div><div class="ep-tm">${esc(t.message)}</div><div class="ep-tacts"><button class="ep-btn ep-suc" data-qa="${esc(t.id)}">✓ Approve</button><button class="ep-btn ep-dan" data-qd="${esc(t.id)}">✗ Deny</button></div></div></div>`;
});
h+=`</div></div>`;
}
return h;
}
/* Destinations */
function destRow(d){
return`<tr data-did="${esc(d.id)}">
<td><img class="ep-thumb" src="${esc(d.image)}" alt="" onerror="this.style.opacity='.2'"></td>
<td><strong>${esc(d.name)}</strong><br><span style="color:#9ca3af;font-size:11px">${esc(d.location)}</span></td>
<td><span class="ep-b bb">${esc(d.category)}</span></td>
<td>${fmtPrice(d.price)}</td>
<td> ${parseFloat(d.rating).toFixed(1)}</td>
<td>${ageBdg(daysAgo(d.created_at))}</td>
<td style="white-space:nowrap"><button class="ep-btn ep-gho" data-ed="${esc(d.id)}"> Edit</button> <button class="ep-btn ep-dan" data-dd="${esc(d.id)}">🗑</button></td>
</tr>`;
}
function rDest(){
const cc={};destinations.forEach(d=>{cc[d.category]=(cc[d.category]||0)+1;});
return`<div class="ep-card">
<div class="ep-ch"><div><div class="ep-ct">🏷 Categories</div><div class="ep-cs">Add, rename, or remove destination categories</div></div><button class="ep-btn ep-pri" id="ep-cat-tog">+ Add Category</button></div>
<div class="ep-cb">
<div class="ep-addbox" id="ep-cat-box">
<div style="display:flex;gap:10px;align-items:center">
<input class="ep-inp" id="ep-cat-new" placeholder="New category name" style="max-width:260px">
<button class="ep-btn ep-pri" id="ep-cat-save">Save</button>
<button class="ep-btn ep-gho" id="ep-cat-cancel">Cancel</button>
<span class="ep-smsg" id="ep-cat-msg"></span>
</div>
</div>
<div id="ep-cat-list">${categories.map(c=>{const n=cc[c.name]||0;return`<div class="ep-cat-row" data-cid="${c.id}"><span class="ep-cat-nm">${esc(c.name)}</span><span class="ep-cat-ct">${n} destination${n!==1?'s':''}</span><button class="ep-btn ep-gho" data-ren="${c.id}" data-cv="${esc(c.name)}" style="padding:4px 10px;font-size:11px">Rename</button><button class="ep-btn ep-dan" data-dc="${c.id}" data-dn="${esc(c.name)}" style="padding:4px 10px;font-size:11px"${n>0?' disabled title="In use"':''}>Delete</button></div>`;}).join('')}</div>
</div>
</div>
<div class="ep-card">
<div class="ep-ch"><div><div class="ep-ct">🗺 All Destinations (${destinations.length})</div></div><button class="ep-btn ep-pri" id="ep-dest-tog">+ Add Destination</button></div>
<div class="ep-cb">
<div class="ep-addbox" id="ep-dest-box">
<div style="font-weight:700;font-size:13px;margin-bottom:14px">New Destination</div>
<div class="ep-form">
<div class="ep-fld"><span class="ep-lbl">Name *</span><input class="ep-inp" id="ep-dn-name" placeholder="e.g. Paris"></div>
<div class="ep-fld"><span class="ep-lbl">Location *</span><input class="ep-inp" id="ep-dn-loc" placeholder="e.g. France"></div>
<div class="ep-fld"><span class="ep-lbl">Category *</span><select class="ep-sel" id="ep-dn-cat">${catOpts(categories,'')}</select></div>
<div class="ep-fld"><span class="ep-lbl">Price (USD) *</span><input class="ep-inp" id="ep-dn-price" type="number" placeholder="1299"></div>
<div class="ep-fld"><span class="ep-lbl">Rating (15)</span><input class="ep-inp" id="ep-dn-rating" type="number" step=".1" min="1" max="5" value="4.5"></div>
<div class="ep-fld ep-full"><span class="ep-lbl">Image *</span>${imgPicker('ep-dn-img','')}</div>
<div class="ep-fld ep-full"><span class="ep-lbl">Description *</span><textarea class="ep-ta" id="ep-dn-desc"></textarea></div>
<div class="ep-frow"><span class="ep-smsg" id="ep-dn-msg"></span><button class="ep-btn ep-gho" id="ep-dn-cancel">Cancel</button><button class="ep-btn ep-pri" id="ep-dn-save">Save Destination</button></div>
</div>
</div>
<table class="ep-tbl"><thead><tr><th>Photo</th><th>Name & Location</th><th>Category</th><th>Price</th><th>Rating</th><th>In System</th><th>Actions</th></tr></thead>
<tbody id="ep-dest-tbody">${destinations.map(destRow).join('')}</tbody></table>
</div>
</div>`;
}
/* Specials */
function specImg(s){
const dm=Object.fromEntries(destinations.map(d=>[d.id,d]));
const dest=dm[s.destination_id];
return s.image_path||(dest?dest.image:'')||'';
}
function specRow(s){
const dm=Object.fromEntries(destinations.map(d=>[d.id,d]));
const dest=dm[s.destination_id];
const base=s.price!=null?parseFloat(s.price):(dest?parseFloat(dest.price):0);
const sale=base*(1-parseFloat(s.discount)/100);
const img=specImg(s);
return`<tr data-sid="${esc(s.id)}">
<td>${img?`<img src="${esc(img)}" class="ep-thumb" onerror="this.style.opacity='.2'">`:''}</td>
<td><strong>${esc(dest?dest.name:s.destination_id)}</strong></td>
<td><span class="ep-b bg">${parseFloat(s.discount)}% OFF</span></td>
<td class="ep-price-old">${fmtPrice(base)}</td>
<td class="ep-price-new">${fmtPrice(sale)}</td>
<td>${new Date(s.end_date).toLocaleDateString()}</td>
<td>${expBdg(daysLeft(s.end_date))}</td>
<td style="white-space:nowrap"><button class="ep-btn ep-gho" data-es="${esc(s.id)}"> Edit</button> <button class="ep-btn ep-dan" data-ds="${esc(s.id)}">🗑</button></td>
</tr>`;
}
function rSpec(){
const destOpts=destinations.map(d=>`<option value="${esc(d.id)}">${esc(d.name)}</option>`).join('');
return`<div class="ep-card">
<div class="ep-ch"><div><div class="ep-ct"> Weekly Specials (${specials.length})</div></div><button class="ep-btn ep-pri" id="ep-spec-tog">+ Add Special</button></div>
<div class="ep-cb">
<div class="ep-addbox" id="ep-spec-box">
<div style="font-weight:700;font-size:13px;margin-bottom:14px">New Special</div>
<div class="ep-form">
<div class="ep-fld"><span class="ep-lbl">Destination *</span><select class="ep-sel" id="ep-sn-dest">${destOpts}</select></div>
<div class="ep-fld"><span class="ep-lbl">Original Price (USD) *</span><input class="ep-inp" id="ep-sn-price" type="number" placeholder="1299" title="Base price before discount"></div>
<div class="ep-fld"><span class="ep-lbl">Discount % *</span><input class="ep-inp" id="ep-sn-disc" type="number" min="1" max="99" placeholder="25"></div>
<div class="ep-fld"><span class="ep-lbl">End Date *</span><input class="ep-inp" id="ep-sn-end" type="date"></div>
<div class="ep-fld ep-full"><span class="ep-lbl">Special Image (optional defaults to destination photo)</span>${imgPicker('ep-sn-img','')}</div>
<div class="ep-fld ep-full"><span class="ep-lbl">Highlights (one per line)</span><textarea class="ep-ta" id="ep-sn-hls" placeholder="Free spa treatment&#10;Complimentary airport transfer"></textarea></div>
<div class="ep-frow"><span class="ep-smsg" id="ep-sn-msg"></span><button class="ep-btn ep-gho" id="ep-sn-cancel">Cancel</button><button class="ep-btn ep-pri" id="ep-sn-save">Save Special</button></div>
</div>
</div>
<table class="ep-tbl"><thead><tr><th>Image</th><th>Destination</th><th>Discount</th><th>Original Price</th><th>Sale Price</th><th>End Date</th><th>Status</th><th>Actions</th></tr></thead>
<tbody id="ep-spec-tbody">${specials.map(specRow).join('')}</tbody></table>
</div>
</div>`;
}
/* Testimonials */
function rTest(){
const cn={pending:0,approved:0,denied:0};testimonials.forEach(t=>cn[t.status]++);
const list=tFilter==='all'?testimonials:testimonials.filter(t=>t.status===tFilter);
let h=`<div class="ep-ftabs">
<button class="ep-ft ${tFilter==='pending'?'active':''}" data-tf="pending"> Pending (${cn.pending})</button>
<button class="ep-ft ${tFilter==='approved'?'active':''}" data-tf="approved"> Approved (${cn.approved})</button>
<button class="ep-ft ${tFilter==='denied'?'active':''}" data-tf="denied"> Denied (${cn.denied})</button>
<button class="ep-ft ${tFilter==='all'?'active':''}" data-tf="all">All (${testimonials.length})</button>
</div>`;
if(!list.length)return h+`<div class="ep-empty">💬 No testimonials here.</div>`;
list.forEach(t=>{
const av=t.image_path?`<img class="ep-tav" src="${esc(t.image_path)}" alt="">`:`<div class="ep-tavph">${esc(t.full_name.charAt(0))}</div>`;
const sb=`<span class="ep-b ${t.status==='approved'?'bg':t.status==='denied'?'br':'by'}">${t.status}</span>`;
h+=`<div class="ep-tc" data-tid="${esc(t.id)}">${av}<div class="ep-tb">
<div class="ep-tn">${esc(t.full_name)} ${sb}</div><div class="ep-tl">${esc(t.location)}</div>
<div class="ep-tm">${esc(t.message)}</div>
<textarea class="ep-te">${esc(t.message)}</textarea>
<div class="ep-tacts">
${t.status!=='approved'?`<button class="ep-btn ep-suc" data-ta="approve" data-tid="${esc(t.id)}">✓ Approve</button>`:''}
${t.status!=='denied'?`<button class="ep-btn ep-dan" data-ta="deny" data-tid="${esc(t.id)}">✗ Deny</button>`:''}
<button class="ep-btn ep-gho" data-ta="save" data-tid="${esc(t.id)}">💾 Save Edit</button>
<button class="ep-btn ep-dan" data-ta="delete" data-tid="${esc(t.id)}">🗑 Delete</button>
</div></div></div>`;
});
return h;
}
/* Render */
function render(){
const body=document.getElementById('ep-body');if(!body)return;
switch(activeTab){
case'dashboard': body.innerHTML=rDash(); wireDash(); break;
case'destinations':body.innerHTML=rDest(); wireDest(); break;
case'specials': body.innerHTML=rSpec(); wireSpec(); break;
case'testimonials':body.innerHTML=rTest(); wireTest(); break;
}
const pb=document.getElementById('ep-pb');
if(pb){const n=testimonials.filter(t=>t.status==='pending').length;pb.textContent=n||'';pb.style.display=n?'':'none';}
}
/* Wire: Dashboard */
function wireDash(){
document.querySelectorAll('[data-qa]').forEach(b=>b.onclick=async()=>{await api(`/testimonials/${b.dataset.qa}`,{method:'PUT',body:JSON.stringify({status:'approved'})});await loadAll();render();});
document.querySelectorAll('[data-qd]').forEach(b=>b.onclick=async()=>{await api(`/testimonials/${b.dataset.qd}`,{method:'PUT',body:JSON.stringify({status:'denied'})});await loadAll();render();});
}
/* Wire: Destinations */
function wireDest(){
/* Categories */
const ct=document.getElementById('ep-cat-tog'),cb=document.getElementById('ep-cat-box');
if(ct)ct.onclick=()=>cb.classList.toggle('open');
const cc=document.getElementById('ep-cat-cancel');if(cc)cc.onclick=()=>cb.classList.remove('open');
const cs=document.getElementById('ep-cat-save');
if(cs)cs.onclick=async()=>{
const m=document.getElementById('ep-cat-msg'),nm=document.getElementById('ep-cat-new').value.trim();
if(!nm){m.textContent='Name required';m.className='ep-smsg err';return;}
try{await api('/categories',{method:'POST',body:JSON.stringify({name:nm})});m.textContent='Added!';m.className='ep-smsg ok';await loadAll();activeTab='destinations';render();}
catch(e){m.textContent=e.message;m.className='ep-smsg err';}
};
document.querySelectorAll('[data-ren]').forEach(b=>b.onclick=()=>{
const id=b.dataset.ren,cur=b.dataset.cv,row=b.closest('.ep-cat-row');
row.innerHTML=`<input class="ep-cat-ei" id="ep-ren-${id}" value="${esc(cur)}"><button class="ep-btn ep-pri" data-rs="${id}" style="padding:5px 10px;font-size:11px">Save</button><button class="ep-btn ep-gho" data-rc style="padding:5px 10px;font-size:11px">Cancel</button><span class="ep-smsg" id="ep-rm-${id}"></span>`;
row.querySelector('[data-rc]').onclick=()=>{activeTab='destinations';render();};
row.querySelector(`[data-rs="${id}"]`).onclick=async()=>{
const nm=document.getElementById(`ep-ren-${id}`).value.trim(),m=document.getElementById(`ep-rm-${id}`);
if(!nm){m.textContent='Required';m.className='ep-smsg err';return;}
try{await api(`/categories/${id}`,{method:'PUT',body:JSON.stringify({name:nm})});await loadAll();activeTab='destinations';render();}
catch(e){m.textContent=e.message;m.className='ep-smsg err';}
};
});
document.querySelectorAll('[data-dc]').forEach(b=>b.onclick=async()=>{
if(!confirm(`Delete category "${b.dataset.dn}"?`))return;
try{await api(`/categories/${b.dataset.dc}`,{method:'DELETE'});await loadAll();activeTab='destinations';render();}catch(e){alert(e.message);}
});
/* Destination add */
const dt=document.getElementById('ep-dest-tog'),db=document.getElementById('ep-dest-box');
if(dt)dt.onclick=()=>db.classList.toggle('open');
const dn=document.getElementById('ep-dn-cancel');if(dn)dn.onclick=()=>db.classList.remove('open');
const ds=document.getElementById('ep-dn-save');
if(ds)ds.onclick=async()=>{
const m=document.getElementById('ep-dn-msg');
try{
const img=await getImg('ep-dn-img','');
await api('/destinations',{method:'POST',body:JSON.stringify({
name:document.getElementById('ep-dn-name').value.trim(),
location:document.getElementById('ep-dn-loc').value.trim(),
category:document.getElementById('ep-dn-cat').value,
price:document.getElementById('ep-dn-price').value,
rating:document.getElementById('ep-dn-rating').value||'4.5',
image:img,
description:document.getElementById('ep-dn-desc').value.trim()
})});
m.textContent='Saved!';m.className='ep-smsg ok';await loadAll();activeTab='destinations';render();
}catch(e){m.textContent=e.message;m.className='ep-smsg err';}
};
/* Destination edit */
document.querySelectorAll('[data-ed]').forEach(b=>b.onclick=()=>{
const id=b.dataset.ed,d=destinations.find(x=>x.id==id);if(!d)return;
const row=document.querySelector(`tr[data-did="${id}"]`);
row.innerHTML=`<td colspan="7"><div class="ep-inline ep-form">
<div class="ep-fld"><span class="ep-lbl">Name</span><input class="ep-inp" id="ei-n-${id}" value="${esc(d.name)}"></div>
<div class="ep-fld"><span class="ep-lbl">Location</span><input class="ep-inp" id="ei-l-${id}" value="${esc(d.location)}"></div>
<div class="ep-fld"><span class="ep-lbl">Category</span><select class="ep-sel" id="ei-c-${id}">${catOpts(categories,d.category)}</select></div>
<div class="ep-fld"><span class="ep-lbl">Price</span><input class="ep-inp" type="number" id="ei-p-${id}" value="${esc(d.price)}"></div>
<div class="ep-fld"><span class="ep-lbl">Rating</span><input class="ep-inp" type="number" step=".1" id="ei-r-${id}" value="${esc(d.rating)}"></div>
<div class="ep-fld ep-full"><span class="ep-lbl">Image (upload new to replace)</span>${imgPicker('ei-i-'+id,d.image)}</div>
<div class="ep-fld ep-full"><span class="ep-lbl">Description</span><textarea class="ep-ta" id="ei-d-${id}">${esc(d.description)}</textarea></div>
<div class="ep-frow">
<button class="ep-btn ep-gho" data-ec="${id}">Cancel</button>
<button class="ep-btn ep-pri" data-es="${id}">Save Changes</button>
</div>
</div></td>`;
row.querySelector(`[data-ec="${id}"]`).onclick=()=>{row.outerHTML=destRow(d);wireDest();};
row.querySelector(`[data-es="${id}"]`).onclick=async()=>{
try{
const img=await getImg('ei-i-'+id,d.image);
await api(`/destinations/${id}`,{method:'PUT',body:JSON.stringify({
name:document.getElementById(`ei-n-${id}`).value,
location:document.getElementById(`ei-l-${id}`).value,
category:document.getElementById(`ei-c-${id}`).value,
price:document.getElementById(`ei-p-${id}`).value,
rating:document.getElementById(`ei-r-${id}`).value,
image:img,
description:document.getElementById(`ei-d-${id}`).value
})});
await loadAll();activeTab='destinations';render();
}catch(e){alert(e.message);}
};
});
/* Destination delete */
document.querySelectorAll('[data-dd]').forEach(b=>b.onclick=async()=>{
if(!confirm('Delete this destination? This cannot be undone.'))return;
try{await api(`/destinations/${b.dataset.dd}`,{method:'DELETE'});await loadAll();activeTab='destinations';render();}catch(e){alert(e.message);}
});
}
/* Wire: Specials */
function wireSpec(){
const st=document.getElementById('ep-spec-tog'),sb=document.getElementById('ep-spec-box');
if(st)st.onclick=()=>sb.classList.toggle('open');
const sc=document.getElementById('ep-sn-cancel');if(sc)sc.onclick=()=>sb.classList.remove('open');
const ss=document.getElementById('ep-sn-save');
if(ss)ss.onclick=async()=>{
const m=document.getElementById('ep-sn-msg');
try{
const img=await getImg('ep-sn-img',null);
const hls=document.getElementById('ep-sn-hls').value.split('\n').map(l=>l.trim()).filter(Boolean);
await api('/specials',{method:'POST',body:JSON.stringify({
destination_id:document.getElementById('ep-sn-dest').value,
price:document.getElementById('ep-sn-price').value,
discount:document.getElementById('ep-sn-disc').value,
end_date:document.getElementById('ep-sn-end').value,
image_path:img,
highlights:hls
})});
m.textContent='Saved!';m.className='ep-smsg ok';await loadAll();activeTab='specials';render();
}catch(e){m.textContent=e.message;m.className='ep-smsg err';}
};
/* Specials edit */
document.querySelectorAll('[data-es]').forEach(b=>b.onclick=()=>{
const id=b.dataset.es,s=specials.find(x=>x.id==id);if(!s)return;
const dm=Object.fromEntries(destinations.map(d=>[d.id,d]));
const dest=dm[s.destination_id];
const hls=Array.isArray(s.highlights)?s.highlights.join('\n'):'';
const curImg=specImg(s);
const row=document.querySelector(`tr[data-sid="${id}"]`);
row.innerHTML=`<td colspan="8"><div class="ep-inline ep-form">
<div class="ep-fld"><span class="ep-lbl">Original Price</span><input class="ep-inp" type="number" id="si-p-${id}" value="${esc(s.price!=null?s.price:(dest?dest.price:''))}"></div>
<div class="ep-fld"><span class="ep-lbl">Discount %</span><input class="ep-inp" type="number" id="si-d-${id}" value="${esc(s.discount)}"></div>
<div class="ep-fld"><span class="ep-lbl">End Date</span><input class="ep-inp" type="date" id="si-e-${id}" value="${esc(s.end_date)}"></div>
<div class="ep-fld ep-full"><span class="ep-lbl">Image (upload to replace; leave blank to use destination photo)</span>${imgPicker('si-i-'+id,curImg)}</div>
<div class="ep-fld ep-full"><span class="ep-lbl">Highlights (one per line)</span><textarea class="ep-ta" id="si-h-${id}">${esc(hls)}</textarea></div>
<div class="ep-frow">
<button class="ep-btn ep-gho" data-sc="${id}">Cancel</button>
<button class="ep-btn ep-pri" data-sv="${id}">Save</button>
</div>
</div></td>`;
row.querySelector(`[data-sc="${id}"]`).onclick=()=>{row.outerHTML=specRow(s);wireSpec();};
row.querySelector(`[data-sv="${id}"]`).onclick=async()=>{
try{
const newImg=await getImg('si-i-'+id,s.image_path||null);
const hls2=document.getElementById(`si-h-${id}`).value.split('\n').map(l=>l.trim()).filter(Boolean);
await api(`/specials/${id}`,{method:'PUT',body:JSON.stringify({
price:document.getElementById(`si-p-${id}`).value,
discount:document.getElementById(`si-d-${id}`).value,
end_date:document.getElementById(`si-e-${id}`).value,
image_path:newImg,
highlights:hls2
})});
await loadAll();activeTab='specials';render();
}catch(e){alert(e.message);}
};
});
/* Specials delete */
document.querySelectorAll('[data-ds]').forEach(b=>b.onclick=async()=>{
if(!confirm('Delete this special?'))return;
try{await api(`/specials/${b.dataset.ds}`,{method:'DELETE'});await loadAll();activeTab='specials';render();}catch(e){alert(e.message);}
});
}
/* Wire: Testimonials */
function wireTest(){
document.querySelectorAll('[data-tf]').forEach(b=>b.onclick=()=>{tFilter=b.dataset.tf;render();});
document.querySelectorAll('[data-ta]').forEach(b=>b.onclick=async()=>{
const id=b.dataset.tid,action=b.dataset.ta;
const card=document.querySelector(`.ep-tc[data-tid="${id}"]`);
try{
if(action==='approve')await api(`/testimonials/${id}`,{method:'PUT',body:JSON.stringify({status:'approved'})});
else if(action==='deny')await api(`/testimonials/${id}`,{method:'PUT',body:JSON.stringify({status:'denied'})});
else if(action==='save'){const ta=card.querySelector('.ep-te');await api(`/testimonials/${id}`,{method:'PUT',body:JSON.stringify({message:ta.value})});}
else if(action==='delete'){if(!confirm('Delete?'))return;await api(`/testimonials/${id}`,{method:'DELETE'});}
await loadAll();render();
}catch(e){alert(e.message);}
});
}
/* Init */
let injected=false;
function tryInject(){
if(injected)return;
if(!window.location.pathname.startsWith('/admin/dashboard'))return;
if(!localStorage.getItem('isAdminAuthenticated'))return;
const root=document.getElementById('root');
if(!root||!root.children.length)return;
injected=true;obs.disconnect();
Array.from(root.children).forEach(c=>c.style.display='none');
const portal=buildShell();root.insertBefore(portal,root.firstChild);
loadAll().then(render);
}
const obs=new MutationObserver(tryInject);
obs.observe(document.body,{childList:true,subtree:true});
tryInject();
})();
@@ -0,0 +1,541 @@
(function(){
'use strict';
const API='https://epictravelexpeditions.com/api';
function authHdr(){return{'Content-Type':'application/json',Authorization:`Bearer ${localStorage.getItem('auth_token')}`};}
async function api(path,opts){
const res=await fetch(API+path,{headers:authHdr(),...opts});
const d=await res.json();
if(!res.ok)throw new Error(d.error||res.statusText);
return d;
}
async function uploadImg(file){
const fd=new FormData();fd.append('file',file);
const res=await fetch(API+'/upload/image',{method:'POST',headers:{Authorization:`Bearer ${localStorage.getItem('auth_token')}`},body:fd});
const d=await res.json();
if(!res.ok)throw new Error(d.error||'Upload failed');
return d.url;
}
async function getImg(inputId,fallback){
const el=document.getElementById(inputId);
if(el&&el.files&&el.files[0]){
const st=document.getElementById(inputId+'-st');
if(st)st.textContent='Uploading…';
try{const url=await uploadImg(el.files[0]);if(st)st.textContent='';return url;}
catch(e){if(st){st.textContent='Upload failed: '+e.message;st.style.color='#dc2626';}throw e;}
}
return fallback!=null?fallback:null;
}
function imgPicker(id,src){
const pr=src
?`<img id="${id}-pr" src="${src}" style="width:52px;height:52px;border-radius:8px;object-fit:cover;border:1px solid #e5e7eb">`
:`<div id="${id}-pr" style="width:52px;height:52px;border-radius:8px;background:#f3f4f6;border:1px solid #e5e7eb;display:flex;align-items:center;justify-content:center;font-size:20px">🖼</div>`;
return `<div style="display:flex;align-items:center;gap:10px">${pr}<label style="display:inline-flex;align-items:center;gap:6px;padding:7px 13px;border:1px dashed #d1d5db;border-radius:7px;cursor:pointer;font-size:12px;color:#6b7280;background:#fff">📁 Choose Image<input type="file" accept="image/jpeg,image/png,image/webp" id="${id}" style="display:none" onchange="(function(el){const f=el.files[0];if(!f)return;const u=URL.createObjectURL(f);const p=document.getElementById('${id}-pr');if(p.tagName==='IMG'){p.src=u;}else{p.outerHTML='<img id=${id}-pr src='+u+' style=width:52px;height:52px;border-radius:8px;object-fit:cover;border:1px solid #e5e7eb>';};})(this)"></label><span id="${id}-st" style="font-size:11px;color:#6b7280"></span></div>`;
}
function daysAgo(s){return Math.floor((Date.now()-new Date(s))/86400000);}
function daysLeft(s){return Math.ceil((new Date(s)-Date.now())/86400000);}
function ageBdg(d){const c=d<30?'bg':d<90?'by':'br';return`<span class="ep-b ${c}">${d}d ago</span>`;}
function expBdg(d){if(d<0)return`<span class="ep-b br">Expired</span>`;const c=d>14?'bg':d>7?'by':'br';return`<span class="ep-b ${c}">${d}d left</span>`;}
function esc(s){const e=document.createElement('div');e.textContent=s||'';return e.innerHTML;}
function catOpts(cats,sel){return cats.map(c=>`<option value="${esc(c.name)}"${c.name===sel?' selected':''}>${esc(c.name)}</option>`).join('');}
function fmtPrice(p){return p!=null?'$'+parseFloat(p).toLocaleString(undefined,{minimumFractionDigits:0,maximumFractionDigits:2}):'';}
/* CSS */
const css=`
#ep-portal{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#111827;min-height:100vh;background:#f3f4f6}
#ep-portal *,#ep-portal *::before,#ep-portal *::after{box-sizing:border-box}
#ep-hdr{background:#fff;border-bottom:1px solid #e5e7eb;padding:0 28px;height:60px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:100;box-shadow:0 1px 3px rgba(0,0,0,.06)}
.ep-logo{font-size:17px;font-weight:800;color:#1d4ed8;letter-spacing:-.4px}.ep-logo span{color:#6b7280;font-weight:400}
.ep-hdr-acts{display:flex;gap:8px}
.ep-hbtn{padding:6px 14px;border-radius:7px;font-size:13px;font-weight:600;border:1px solid #d1d5db;background:#fff;color:#374151;cursor:pointer;transition:all .15s;text-decoration:none;display:inline-flex;align-items:center;gap:5px}
.ep-hbtn:hover{background:#f9fafb}.ep-hbtn.red{border-color:#fca5a5;color:#dc2626}.ep-hbtn.red:hover{background:#fef2f2}
#ep-tabs{background:#fff;border-bottom:1px solid #e5e7eb;padding:0 28px;display:flex;gap:2px}
.ep-tab{padding:13px 18px;font-size:13px;font-weight:600;color:#6b7280;border:none;background:none;cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px;transition:all .15s;display:inline-flex;align-items:center;gap:7px}
.ep-tab:hover{color:#1d4ed8}.ep-tab.active{color:#1d4ed8;border-bottom-color:#1d4ed8}
.ep-tbadge{background:#fee2e2;color:#dc2626;border-radius:999px;font-size:11px;padding:1px 6px;font-weight:700}
#ep-body{padding:28px;max-width:1180px;margin:0 auto}
.ep-g4{display:grid;grid-template-columns:repeat(4,1fr);gap:18px;margin-bottom:28px}
@media(max-width:880px){.ep-g4{grid-template-columns:repeat(2,1fr)}}
.ep-stat{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:20px 22px;position:relative;overflow:hidden}
.ep-si{font-size:26px;margin-bottom:6px}.ep-sv{font-size:30px;font-weight:800;color:#111827}.ep-sl{font-size:12px;color:#6b7280;margin-top:1px}
.ep-acc{position:absolute;right:0;top:0;bottom:0;width:4px;border-radius:0 12px 12px 0}
.acb{background:#3b82f6}.acg{background:#10b981}.aca{background:#f59e0b}.acp{background:#8b5cf6}
.ep-card{background:#fff;border:1px solid #e5e7eb;border-radius:12px;margin-bottom:22px;overflow:hidden}
.ep-ch{padding:16px 22px;border-bottom:1px solid #e5e7eb;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px}
.ep-ct{font-size:14px;font-weight:700;color:#111827}.ep-cs{font-size:12px;color:#6b7280;margin-top:1px}
.ep-cb{padding:22px}
.ep-tbl{width:100%;border-collapse:collapse;font-size:13px}
.ep-tbl th{text-align:left;font-size:11px;font-weight:700;color:#6b7280;text-transform:uppercase;letter-spacing:.05em;padding:9px 12px;border-bottom:1px solid #e5e7eb;white-space:nowrap}
.ep-tbl td{padding:11px 12px;border-bottom:1px solid #f3f4f6;vertical-align:middle}
.ep-tbl tr:last-child td{border-bottom:none}.ep-tbl tr:hover td{background:#fafafa}
.ep-b{display:inline-block;padding:2px 9px;border-radius:999px;font-size:11px;font-weight:700}
.bg{background:#d1fae5;color:#065f46}.by{background:#fef3c7;color:#92400e}.br{background:#fee2e2;color:#991b1b}.bb{background:#dbeafe;color:#1e40af}.bgr{background:#f3f4f6;color:#374151}
.ep-btn{padding:6px 13px;border-radius:7px;font-size:12px;font-weight:600;border:none;cursor:pointer;transition:opacity .15s;display:inline-flex;align-items:center;gap:4px;white-space:nowrap}
.ep-btn:hover{opacity:.82}.ep-btn:disabled{opacity:.4;cursor:not-allowed}
.ep-pri{background:#2563eb;color:#fff}.ep-suc{background:#16a34a;color:#fff}.ep-dan{background:#dc2626;color:#fff}.ep-gho{background:#f3f4f6;color:#374151}
.ep-form{display:grid;grid-template-columns:1fr 1fr;gap:14px}
@media(max-width:640px){.ep-form{grid-template-columns:1fr}}
.ep-full{grid-column:1/-1}.ep-fld{display:flex;flex-direction:column;gap:4px}
.ep-lbl{font-size:11px;font-weight:700;color:#374151;text-transform:uppercase;letter-spacing:.04em}
.ep-inp,.ep-sel,.ep-ta{padding:8px 11px;border:1px solid #d1d5db;border-radius:7px;font-size:13px;color:#111827;background:#fff;outline:none;transition:border-color .15s;width:100%}
.ep-inp:focus,.ep-sel:focus,.ep-ta:focus{border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.1)}
.ep-ta{resize:vertical;min-height:72px}
.ep-frow{grid-column:1/-1;display:flex;gap:10px;justify-content:flex-end;padding-top:4px;align-items:center}
.ep-addbox{border:1px dashed #d1d5db;border-radius:10px;padding:18px;margin-bottom:20px;background:#fafafa;display:none}
.ep-addbox.open{display:block}
.ep-thumb{width:40px;height:40px;border-radius:7px;object-fit:cover;background:#e5e7eb}
.ep-smsg{font-size:12px}.ep-smsg.ok{color:#16a34a}.ep-smsg.err{color:#dc2626}
.ep-alert{padding:10px 15px;border-radius:8px;font-size:13px;margin-bottom:16px;border-left:3px solid}
.ep-alert.warn{background:#fffbeb;color:#92400e;border-color:#f59e0b}
.ep-inline{display:grid;grid-template-columns:1fr 1fr;gap:10px;padding:14px}
@media(max-width:640px){.ep-inline{grid-column:1fr}}
.ep-cat-row{display:flex;align-items:center;gap:10px;padding:9px 12px;border-radius:8px;border:1px solid #e5e7eb;background:#fff;margin-bottom:8px}
.ep-cat-nm{flex:1;font-size:13px;font-weight:600}.ep-cat-ct{font-size:11px;color:#9ca3af}
.ep-cat-ei{flex:1;padding:5px 9px;border:1px solid #3b82f6;border-radius:6px;font-size:13px;outline:none}
.ep-tc{border:1px solid #e5e7eb;border-radius:10px;padding:16px;margin-bottom:12px;display:flex;gap:12px;background:#fff}
.ep-tav{width:42px;height:42px;border-radius:50%;object-fit:cover;flex-shrink:0;background:#e5e7eb}
.ep-tavph{width:42px;height:42px;border-radius:50%;background:#2563eb;color:#fff;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:16px;flex-shrink:0}
.ep-tb{flex:1;min-width:0}.ep-tn{font-weight:700;font-size:13px}.ep-tl{font-size:11px;color:#6b7280;margin-bottom:5px}
.ep-tm{font-size:13px;color:#374151;margin-bottom:8px;line-height:1.5}
.ep-te{width:100%;border:1px solid #d1d5db;border-radius:6px;padding:6px 9px;font-size:12px;margin-bottom:7px;resize:vertical;min-height:52px}
.ep-tacts{display:flex;gap:7px;flex-wrap:wrap}
.ep-ftabs{display:flex;gap:7px;margin-bottom:18px;flex-wrap:wrap}
.ep-ft{padding:6px 15px;border-radius:7px;font-size:12px;font-weight:600;cursor:pointer;border:1px solid #d1d5db;background:#fff;color:#374151;transition:all .15s}
.ep-ft.active{background:#2563eb;color:#fff;border-color:#2563eb}
.ep-empty{text-align:center;padding:36px;color:#9ca3af;font-size:13px}
.ep-spec-img{width:52px;height:52px;border-radius:8px;object-fit:cover;background:#e5e7eb;flex-shrink:0}
.ep-price-old{text-decoration:line-through;color:#9ca3af;font-size:11px}
.ep-price-new{color:#16a34a;font-weight:700}
`;
const sel=document.createElement('style');sel.textContent=css;document.head.appendChild(sel);
/* State */
let activeTab='dashboard',tFilter='pending';
let destinations=[],specials=[],testimonials=[],categories=[];
async function loadAll(){
const r=await Promise.allSettled([
fetch(API+'/destinations',{headers:authHdr()}).then(r=>r.json()),
fetch(API+'/specials', {headers:authHdr()}).then(r=>r.json()),
fetch(API+'/testimonials/all',{headers:authHdr()}).then(r=>r.json()),
fetch(API+'/categories', {headers:authHdr()}).then(r=>r.json())
]);
if(r[0].status==='fulfilled'&&Array.isArray(r[0].value))destinations=r[0].value;
if(r[1].status==='fulfilled'&&Array.isArray(r[1].value))specials=r[1].value;
if(r[2].status==='fulfilled'&&Array.isArray(r[2].value))testimonials=r[2].value;
if(r[3].status==='fulfilled'&&Array.isArray(r[3].value))categories=r[3].value;
}
/* Shell */
function buildShell(){
const p=document.createElement('div');p.id='ep-portal';
p.innerHTML=`
<div id="ep-hdr">
<div class="ep-logo">Epic Travel <span>Admin</span></div>
<div class="ep-hdr-acts">
<a href="/" class="ep-hbtn" target="_blank">🌐 View Site</a>
<button class="ep-hbtn red" id="ep-logout"> Logout</button>
</div>
</div>
<div id="ep-tabs">
<button class="ep-tab active" data-tab="dashboard">📊 Dashboard</button>
<button class="ep-tab" data-tab="destinations">🗺 Destinations</button>
<button class="ep-tab" data-tab="specials"> Specials</button>
<button class="ep-tab" data-tab="testimonials">💬 Testimonials <span class="ep-tbadge" id="ep-pb" style="display:none"></span></button>
</div>
<div id="ep-body"></div>`;
p.querySelector('#ep-logout').onclick=()=>{localStorage.removeItem('isAdminAuthenticated');localStorage.removeItem('auth_token');window.location.href='/admin';};
p.querySelectorAll('.ep-tab[data-tab]').forEach(b=>{b.onclick=()=>{activeTab=b.dataset.tab;p.querySelectorAll('.ep-tab').forEach(t=>t.classList.remove('active'));b.classList.add('active');render();};});
return p;
}
/* Dashboard */
function rDash(){
const dm=Object.fromEntries(destinations.map(d=>[d.id,d]));
const pen=testimonials.filter(t=>t.status==='pending').length;
const app=testimonials.filter(t=>t.status==='approved').length;
let h=`<div class="ep-g4">
<div class="ep-stat"><div class="ep-acc acb"></div><div class="ep-si">🗺</div><div class="ep-sv">${destinations.length}</div><div class="ep-sl">Destinations</div></div>
<div class="ep-stat"><div class="ep-acc acg"></div><div class="ep-si"></div><div class="ep-sv">${specials.length}</div><div class="ep-sl">Active Specials</div></div>
<div class="ep-stat"><div class="ep-acc aca"></div><div class="ep-si"></div><div class="ep-sv">${pen}</div><div class="ep-sl">Pending Reviews</div></div>
<div class="ep-stat"><div class="ep-acc acp"></div><div class="ep-si"></div><div class="ep-sv">${app}</div><div class="ep-sl">Approved Testimonials</div></div>
</div>`;
if(pen>0)h+=`<div class="ep-alert warn">⚠️ <strong>${pen}</strong> testimonial${pen>1?'s':''} awaiting review.</div>`;
/* Destinations age */
const sd=[...destinations].sort((a,b)=>new Date(a.created_at)-new Date(b.created_at));
h+=`<div class="ep-card"><div class="ep-ch"><div><div class="ep-ct">🗺 Destinations</div><div class="ep-cs">Oldest entries first</div></div></div><table class="ep-tbl"><thead><tr><th>Destination</th><th>Category</th><th>Price</th><th>In System Since</th></tr></thead><tbody>`;
sd.forEach(d=>{h+=`<tr><td><strong>${esc(d.name)}</strong> <span style="color:#9ca3af;font-size:11px">${esc(d.location)}</span></td><td><span class="ep-b bb">${esc(d.category)}</span></td><td>${fmtPrice(d.price)}</td><td>${ageBdg(daysAgo(d.created_at))}</td></tr>`;});
h+=`</tbody></table></div>`;
/* Specials expiry */
const ss=[...specials].sort((a,b)=>new Date(a.end_date)-new Date(b.end_date));
h+=`<div class="ep-card"><div class="ep-ch"><div><div class="ep-ct">⭐ Weekly Specials</div><div class="ep-cs">Expiring soonest first</div></div></div><table class="ep-tbl"><thead><tr><th>Image</th><th>Destination</th><th>Original Price</th><th>Discount</th><th>Sale Price</th><th>Expires</th><th>Status</th></tr></thead><tbody>`;
ss.forEach(s=>{
const dest=dm[s.destination_id];
const basePrice=s.price!=null?parseFloat(s.price):(dest?parseFloat(dest.price):0);
const salePrice=basePrice*(1-parseFloat(s.discount)/100);
const imgSrc=s.image_path||(dest?dest.image:'');
h+=`<tr>
<td>${imgSrc?`<img src="${esc(imgSrc)}" class="ep-thumb" onerror="this.style.opacity='.2'">`:''}</td>
<td><strong>${esc(dest?dest.name:s.destination_id)}</strong></td>
<td class="ep-price-old">${fmtPrice(basePrice)}</td>
<td><span class="ep-b bg">${parseFloat(s.discount)}% OFF</span></td>
<td class="ep-price-new">${fmtPrice(salePrice)}</td>
<td>${new Date(s.end_date).toLocaleDateString()}</td>
<td>${expBdg(daysLeft(s.end_date))}</td>
</tr>`;
});
h+=`</tbody></table></div>`;
/* Pending testimonials */
const pl=testimonials.filter(t=>t.status==='pending');
if(pl.length){
h+=`<div class="ep-card"><div class="ep-ch"><div class="ep-ct">⏳ Pending Testimonials</div></div><div class="ep-cb">`;
pl.forEach(t=>{
const av=t.image_path?`<img class="ep-tav" src="${esc(t.image_path)}" alt="">`:`<div class="ep-tavph">${esc(t.full_name.charAt(0))}</div>`;
h+=`<div class="ep-tc" data-id="${esc(t.id)}">${av}<div class="ep-tb"><div class="ep-tn">${esc(t.full_name)}</div><div class="ep-tl">${esc(t.location)}</div><div class="ep-tm">${esc(t.message)}</div><div class="ep-tacts"><button class="ep-btn ep-suc" data-qa="${esc(t.id)}">✓ Approve</button><button class="ep-btn ep-dan" data-qd="${esc(t.id)}">✗ Deny</button></div></div></div>`;
});
h+=`</div></div>`;
}
return h;
}
/* Destinations */
function destRow(d){
return`<tr data-did="${esc(d.id)}">
<td><img class="ep-thumb" src="${esc(d.image)}" alt="" onerror="this.style.opacity='.2'"></td>
<td><strong>${esc(d.name)}</strong><br><span style="color:#9ca3af;font-size:11px">${esc(d.location)}</span></td>
<td><span class="ep-b bb">${esc(d.category)}</span></td>
<td>${fmtPrice(d.price)}</td>
<td> ${parseFloat(d.rating).toFixed(1)}</td>
<td>${ageBdg(daysAgo(d.created_at))}</td>
<td style="white-space:nowrap"><button class="ep-btn ep-gho" data-ed="${esc(d.id)}"> Edit</button> <button class="ep-btn ep-dan" data-dd="${esc(d.id)}">🗑</button></td>
</tr>`;
}
function rDest(){
const cc={};destinations.forEach(d=>{cc[d.category]=(cc[d.category]||0)+1;});
return`<div class="ep-card">
<div class="ep-ch"><div><div class="ep-ct">🏷 Categories</div><div class="ep-cs">Add, rename, or remove destination categories</div></div><button class="ep-btn ep-pri" id="ep-cat-tog">+ Add Category</button></div>
<div class="ep-cb">
<div class="ep-addbox" id="ep-cat-box">
<div style="display:flex;gap:10px;align-items:center">
<input class="ep-inp" id="ep-cat-new" placeholder="New category name" style="max-width:260px">
<button class="ep-btn ep-pri" id="ep-cat-save">Save</button>
<button class="ep-btn ep-gho" id="ep-cat-cancel">Cancel</button>
<span class="ep-smsg" id="ep-cat-msg"></span>
</div>
</div>
<div id="ep-cat-list">${categories.map(c=>{const n=cc[c.name]||0;return`<div class="ep-cat-row" data-cid="${c.id}"><span class="ep-cat-nm">${esc(c.name)}</span><span class="ep-cat-ct">${n} destination${n!==1?'s':''}</span><button class="ep-btn ep-gho" data-ren="${c.id}" data-cv="${esc(c.name)}" style="padding:4px 10px;font-size:11px">Rename</button><button class="ep-btn ep-dan" data-dc="${c.id}" data-dn="${esc(c.name)}" style="padding:4px 10px;font-size:11px"${n>0?' disabled title="In use"':''}>Delete</button></div>`;}).join('')}</div>
</div>
</div>
<div class="ep-card">
<div class="ep-ch"><div><div class="ep-ct">🗺 All Destinations (${destinations.length})</div></div><button class="ep-btn ep-pri" id="ep-dest-tog">+ Add Destination</button></div>
<div class="ep-cb">
<div class="ep-addbox" id="ep-dest-box">
<div style="font-weight:700;font-size:13px;margin-bottom:14px">New Destination</div>
<div class="ep-form">
<div class="ep-fld"><span class="ep-lbl">Name *</span><input class="ep-inp" id="ep-dn-name" placeholder="e.g. Paris"></div>
<div class="ep-fld"><span class="ep-lbl">Location *</span><input class="ep-inp" id="ep-dn-loc" placeholder="e.g. France"></div>
<div class="ep-fld"><span class="ep-lbl">Category *</span><select class="ep-sel" id="ep-dn-cat">${catOpts(categories,'')}</select></div>
<div class="ep-fld"><span class="ep-lbl">Price (USD) *</span><input class="ep-inp" id="ep-dn-price" type="number" placeholder="1299"></div>
<div class="ep-fld"><span class="ep-lbl">Rating (15)</span><input class="ep-inp" id="ep-dn-rating" type="number" step=".1" min="1" max="5" value="4.5"></div>
<div class="ep-fld ep-full"><span class="ep-lbl">Image *</span>${imgPicker('ep-dn-img','')}</div>
<div class="ep-fld ep-full"><span class="ep-lbl">Description *</span><textarea class="ep-ta" id="ep-dn-desc"></textarea></div>
<div class="ep-frow"><span class="ep-smsg" id="ep-dn-msg"></span><button class="ep-btn ep-gho" id="ep-dn-cancel">Cancel</button><button class="ep-btn ep-pri" id="ep-dn-save">Save Destination</button></div>
</div>
</div>
<table class="ep-tbl"><thead><tr><th>Photo</th><th>Name & Location</th><th>Category</th><th>Price</th><th>Rating</th><th>In System</th><th>Actions</th></tr></thead>
<tbody id="ep-dest-tbody">${destinations.map(destRow).join('')}</tbody></table>
</div>
</div>`;
}
/* Specials */
function specImg(s){
const dm=Object.fromEntries(destinations.map(d=>[d.id,d]));
const dest=dm[s.destination_id];
return s.image_path||(dest?dest.image:'')||'';
}
function specRow(s){
const dm=Object.fromEntries(destinations.map(d=>[d.id,d]));
const dest=dm[s.destination_id];
const base=s.price!=null?parseFloat(s.price):(dest?parseFloat(dest.price):0);
const sale=base*(1-parseFloat(s.discount)/100);
const img=specImg(s);
return`<tr data-sid="${esc(s.id)}">
<td>${img?`<img src="${esc(img)}" class="ep-thumb" onerror="this.style.opacity='.2'">`:''}</td>
<td><strong>${esc(dest?dest.name:s.destination_id)}</strong></td>
<td><span class="ep-b bg">${parseFloat(s.discount)}% OFF</span></td>
<td class="ep-price-old">${fmtPrice(base)}</td>
<td class="ep-price-new">${fmtPrice(sale)}</td>
<td>${new Date(s.end_date).toLocaleDateString()}</td>
<td>${expBdg(daysLeft(s.end_date))}</td>
<td style="white-space:nowrap"><button class="ep-btn ep-gho" data-es="${esc(s.id)}"> Edit</button> <button class="ep-btn ep-dan" data-ds="${esc(s.id)}">🗑</button></td>
</tr>`;
}
function rSpec(){
const destOpts=destinations.map(d=>`<option value="${esc(d.id)}">${esc(d.name)}</option>`).join('');
return`<div class="ep-card">
<div class="ep-ch"><div><div class="ep-ct"> Weekly Specials (${specials.length})</div></div><button class="ep-btn ep-pri" id="ep-spec-tog">+ Add Special</button></div>
<div class="ep-cb">
<div class="ep-addbox" id="ep-spec-box">
<div style="font-weight:700;font-size:13px;margin-bottom:14px">New Special</div>
<div class="ep-form">
<div class="ep-fld"><span class="ep-lbl">Destination *</span><select class="ep-sel" id="ep-sn-dest">${destOpts}</select></div>
<div class="ep-fld"><span class="ep-lbl">Original Price (USD) *</span><input class="ep-inp" id="ep-sn-price" type="number" placeholder="1299" title="Base price before discount"></div>
<div class="ep-fld"><span class="ep-lbl">Discount % *</span><input class="ep-inp" id="ep-sn-disc" type="number" min="1" max="99" placeholder="25"></div>
<div class="ep-fld"><span class="ep-lbl">End Date *</span><input class="ep-inp" id="ep-sn-end" type="date"></div>
<div class="ep-fld ep-full"><span class="ep-lbl">Special Image (optional defaults to destination photo)</span>${imgPicker('ep-sn-img','')}</div>
<div class="ep-fld ep-full"><span class="ep-lbl">Highlights (one per line)</span><textarea class="ep-ta" id="ep-sn-hls" placeholder="Free spa treatment&#10;Complimentary airport transfer"></textarea></div>
<div class="ep-frow"><span class="ep-smsg" id="ep-sn-msg"></span><button class="ep-btn ep-gho" id="ep-sn-cancel">Cancel</button><button class="ep-btn ep-pri" id="ep-sn-save">Save Special</button></div>
</div>
</div>
<table class="ep-tbl"><thead><tr><th>Image</th><th>Destination</th><th>Discount</th><th>Original Price</th><th>Sale Price</th><th>End Date</th><th>Status</th><th>Actions</th></tr></thead>
<tbody id="ep-spec-tbody">${specials.map(specRow).join('')}</tbody></table>
</div>
</div>`;
}
/* Testimonials */
function rTest(){
const cn={pending:0,approved:0,denied:0};testimonials.forEach(t=>cn[t.status]++);
const list=tFilter==='all'?testimonials:testimonials.filter(t=>t.status===tFilter);
let h=`<div class="ep-ftabs">
<button class="ep-ft ${tFilter==='pending'?'active':''}" data-tf="pending"> Pending (${cn.pending})</button>
<button class="ep-ft ${tFilter==='approved'?'active':''}" data-tf="approved"> Approved (${cn.approved})</button>
<button class="ep-ft ${tFilter==='denied'?'active':''}" data-tf="denied"> Denied (${cn.denied})</button>
<button class="ep-ft ${tFilter==='all'?'active':''}" data-tf="all">All (${testimonials.length})</button>
</div>`;
if(!list.length)return h+`<div class="ep-empty">💬 No testimonials here.</div>`;
list.forEach(t=>{
const av=t.image_path?`<img class="ep-tav" src="${esc(t.image_path)}" alt="">`:`<div class="ep-tavph">${esc(t.full_name.charAt(0))}</div>`;
const sb=`<span class="ep-b ${t.status==='approved'?'bg':t.status==='denied'?'br':'by'}">${t.status}</span>`;
h+=`<div class="ep-tc" data-tid="${esc(t.id)}">${av}<div class="ep-tb">
<div class="ep-tn">${esc(t.full_name)} ${sb}</div><div class="ep-tl">${esc(t.location)}</div>
<div class="ep-tm">${esc(t.message)}</div>
<textarea class="ep-te">${esc(t.message)}</textarea>
<div class="ep-tacts">
${t.status!=='approved'?`<button class="ep-btn ep-suc" data-ta="approve" data-tid="${esc(t.id)}">✓ Approve</button>`:''}
${t.status!=='denied'?`<button class="ep-btn ep-dan" data-ta="deny" data-tid="${esc(t.id)}">✗ Deny</button>`:''}
<button class="ep-btn ep-gho" data-ta="save" data-tid="${esc(t.id)}">💾 Save Edit</button>
<button class="ep-btn ep-dan" data-ta="delete" data-tid="${esc(t.id)}">🗑 Delete</button>
</div></div></div>`;
});
return h;
}
/* Render */
function render(){
const body=document.getElementById('ep-body');if(!body)return;
switch(activeTab){
case'dashboard': body.innerHTML=rDash(); wireDash(); break;
case'destinations':body.innerHTML=rDest(); wireDest(); break;
case'specials': body.innerHTML=rSpec(); wireSpec(); break;
case'testimonials':body.innerHTML=rTest(); wireTest(); break;
}
const pb=document.getElementById('ep-pb');
if(pb){const n=testimonials.filter(t=>t.status==='pending').length;pb.textContent=n||'';pb.style.display=n?'':'none';}
}
/* Wire: Dashboard */
function wireDash(){
document.querySelectorAll('[data-qa]').forEach(b=>b.onclick=async()=>{await api(`/testimonials/${b.dataset.qa}`,{method:'PUT',body:JSON.stringify({status:'approved'})});await loadAll();render();});
document.querySelectorAll('[data-qd]').forEach(b=>b.onclick=async()=>{await api(`/testimonials/${b.dataset.qd}`,{method:'PUT',body:JSON.stringify({status:'denied'})});await loadAll();render();});
}
/* Wire: Destinations */
function wireDest(){
/* Categories */
const ct=document.getElementById('ep-cat-tog'),cb=document.getElementById('ep-cat-box');
if(ct)ct.onclick=()=>cb.classList.toggle('open');
const cc=document.getElementById('ep-cat-cancel');if(cc)cc.onclick=()=>cb.classList.remove('open');
const cs=document.getElementById('ep-cat-save');
if(cs)cs.onclick=async()=>{
const m=document.getElementById('ep-cat-msg'),nm=document.getElementById('ep-cat-new').value.trim();
if(!nm){m.textContent='Name required';m.className='ep-smsg err';return;}
try{await api('/categories',{method:'POST',body:JSON.stringify({name:nm})});m.textContent='Added!';m.className='ep-smsg ok';await loadAll();activeTab='destinations';render();}
catch(e){m.textContent=e.message;m.className='ep-smsg err';}
};
document.querySelectorAll('[data-ren]').forEach(b=>b.onclick=()=>{
const id=b.dataset.ren,cur=b.dataset.cv,row=b.closest('.ep-cat-row');
row.innerHTML=`<input class="ep-cat-ei" id="ep-ren-${id}" value="${esc(cur)}"><button class="ep-btn ep-pri" data-rs="${id}" style="padding:5px 10px;font-size:11px">Save</button><button class="ep-btn ep-gho" data-rc style="padding:5px 10px;font-size:11px">Cancel</button><span class="ep-smsg" id="ep-rm-${id}"></span>`;
row.querySelector('[data-rc]').onclick=()=>{activeTab='destinations';render();};
row.querySelector(`[data-rs="${id}"]`).onclick=async()=>{
const nm=document.getElementById(`ep-ren-${id}`).value.trim(),m=document.getElementById(`ep-rm-${id}`);
if(!nm){m.textContent='Required';m.className='ep-smsg err';return;}
try{await api(`/categories/${id}`,{method:'PUT',body:JSON.stringify({name:nm})});await loadAll();activeTab='destinations';render();}
catch(e){m.textContent=e.message;m.className='ep-smsg err';}
};
});
document.querySelectorAll('[data-dc]').forEach(b=>b.onclick=async()=>{
if(!confirm(`Delete category "${b.dataset.dn}"?`))return;
try{await api(`/categories/${b.dataset.dc}`,{method:'DELETE'});await loadAll();activeTab='destinations';render();}catch(e){alert(e.message);}
});
/* Destination add */
const dt=document.getElementById('ep-dest-tog'),db=document.getElementById('ep-dest-box');
if(dt)dt.onclick=()=>db.classList.toggle('open');
const dn=document.getElementById('ep-dn-cancel');if(dn)dn.onclick=()=>db.classList.remove('open');
const ds=document.getElementById('ep-dn-save');
if(ds)ds.onclick=async()=>{
const m=document.getElementById('ep-dn-msg');
try{
const img=await getImg('ep-dn-img','');
await api('/destinations',{method:'POST',body:JSON.stringify({
name:document.getElementById('ep-dn-name').value.trim(),
location:document.getElementById('ep-dn-loc').value.trim(),
category:document.getElementById('ep-dn-cat').value,
price:document.getElementById('ep-dn-price').value,
rating:document.getElementById('ep-dn-rating').value||'4.5',
image:img,
description:document.getElementById('ep-dn-desc').value.trim()
})});
m.textContent='Saved!';m.className='ep-smsg ok';await loadAll();activeTab='destinations';render();
}catch(e){m.textContent=e.message;m.className='ep-smsg err';}
};
/* Destination edit */
document.querySelectorAll('[data-ed]').forEach(b=>b.onclick=()=>{
const id=b.dataset.ed,d=destinations.find(x=>x.id==id);if(!d)return;
const row=document.querySelector(`tr[data-did="${id}"]`);
row.innerHTML=`<td colspan="7"><div class="ep-inline ep-form">
<div class="ep-fld"><span class="ep-lbl">Name</span><input class="ep-inp" id="ei-n-${id}" value="${esc(d.name)}"></div>
<div class="ep-fld"><span class="ep-lbl">Location</span><input class="ep-inp" id="ei-l-${id}" value="${esc(d.location)}"></div>
<div class="ep-fld"><span class="ep-lbl">Category</span><select class="ep-sel" id="ei-c-${id}">${catOpts(categories,d.category)}</select></div>
<div class="ep-fld"><span class="ep-lbl">Price</span><input class="ep-inp" type="number" id="ei-p-${id}" value="${esc(d.price)}"></div>
<div class="ep-fld"><span class="ep-lbl">Rating</span><input class="ep-inp" type="number" step=".1" id="ei-r-${id}" value="${esc(d.rating)}"></div>
<div class="ep-fld ep-full"><span class="ep-lbl">Image (upload new to replace)</span>${imgPicker('ei-i-'+id,d.image)}</div>
<div class="ep-fld ep-full"><span class="ep-lbl">Description</span><textarea class="ep-ta" id="ei-d-${id}">${esc(d.description)}</textarea></div>
<div class="ep-frow">
<button class="ep-btn ep-gho" data-ec="${id}">Cancel</button>
<button class="ep-btn ep-pri" data-es="${id}">Save Changes</button>
</div>
</div></td>`;
row.querySelector(`[data-ec="${id}"]`).onclick=()=>{row.outerHTML=destRow(d);wireDest();};
row.querySelector(`[data-es="${id}"]`).onclick=async()=>{
try{
const img=await getImg('ei-i-'+id,d.image);
await api(`/destinations/${id}`,{method:'PUT',body:JSON.stringify({
name:document.getElementById(`ei-n-${id}`).value,
location:document.getElementById(`ei-l-${id}`).value,
category:document.getElementById(`ei-c-${id}`).value,
price:document.getElementById(`ei-p-${id}`).value,
rating:document.getElementById(`ei-r-${id}`).value,
image:img,
description:document.getElementById(`ei-d-${id}`).value
})});
await loadAll();activeTab='destinations';render();
}catch(e){alert(e.message);}
};
});
/* Destination delete */
document.querySelectorAll('[data-dd]').forEach(b=>b.onclick=async()=>{
if(!confirm('Delete this destination? This cannot be undone.'))return;
try{await api(`/destinations/${b.dataset.dd}`,{method:'DELETE'});await loadAll();activeTab='destinations';render();}catch(e){alert(e.message);}
});
}
/* Wire: Specials */
function wireSpec(){
const st=document.getElementById('ep-spec-tog'),sb=document.getElementById('ep-spec-box');
if(st)st.onclick=()=>sb.classList.toggle('open');
const sc=document.getElementById('ep-sn-cancel');if(sc)sc.onclick=()=>sb.classList.remove('open');
const ss=document.getElementById('ep-sn-save');
if(ss)ss.onclick=async()=>{
const m=document.getElementById('ep-sn-msg');
try{
const img=await getImg('ep-sn-img',null);
const hls=document.getElementById('ep-sn-hls').value.split('\n').map(l=>l.trim()).filter(Boolean);
await api('/specials',{method:'POST',body:JSON.stringify({
destination_id:document.getElementById('ep-sn-dest').value,
price:document.getElementById('ep-sn-price').value,
discount:document.getElementById('ep-sn-disc').value,
end_date:document.getElementById('ep-sn-end').value,
image_path:img,
highlights:hls
})});
m.textContent='Saved!';m.className='ep-smsg ok';await loadAll();activeTab='specials';render();
}catch(e){m.textContent=e.message;m.className='ep-smsg err';}
};
/* Specials edit */
document.querySelectorAll('[data-es]').forEach(b=>b.onclick=()=>{
const id=b.dataset.es,s=specials.find(x=>x.id==id);if(!s)return;
const dm=Object.fromEntries(destinations.map(d=>[d.id,d]));
const dest=dm[s.destination_id];
const hls=Array.isArray(s.highlights)?s.highlights.join('\n'):'';
const curImg=specImg(s);
const row=document.querySelector(`tr[data-sid="${id}"]`);
row.innerHTML=`<td colspan="8"><div class="ep-inline ep-form">
<div class="ep-fld"><span class="ep-lbl">Original Price</span><input class="ep-inp" type="number" id="si-p-${id}" value="${esc(s.price!=null?s.price:(dest?dest.price:''))}"></div>
<div class="ep-fld"><span class="ep-lbl">Discount %</span><input class="ep-inp" type="number" id="si-d-${id}" value="${esc(s.discount)}"></div>
<div class="ep-fld"><span class="ep-lbl">End Date</span><input class="ep-inp" type="date" id="si-e-${id}" value="${esc(s.end_date)}"></div>
<div class="ep-fld ep-full"><span class="ep-lbl">Image (upload to replace; leave blank to use destination photo)</span>${imgPicker('si-i-'+id,curImg)}</div>
<div class="ep-fld ep-full"><span class="ep-lbl">Highlights (one per line)</span><textarea class="ep-ta" id="si-h-${id}">${esc(hls)}</textarea></div>
<div class="ep-frow">
<button class="ep-btn ep-gho" data-sc="${id}">Cancel</button>
<button class="ep-btn ep-pri" data-sv="${id}">Save</button>
</div>
</div></td>`;
row.querySelector(`[data-sc="${id}"]`).onclick=()=>{row.outerHTML=specRow(s);wireSpec();};
row.querySelector(`[data-sv="${id}"]`).onclick=async()=>{
try{
const newImg=await getImg('si-i-'+id,s.image_path||null);
const hls2=document.getElementById(`si-h-${id}`).value.split('\n').map(l=>l.trim()).filter(Boolean);
await api(`/specials/${id}`,{method:'PUT',body:JSON.stringify({
price:document.getElementById(`si-p-${id}`).value,
discount:document.getElementById(`si-d-${id}`).value,
end_date:document.getElementById(`si-e-${id}`).value,
image_path:newImg,
highlights:hls2
})});
await loadAll();activeTab='specials';render();
}catch(e){alert(e.message);}
};
});
/* Specials delete */
document.querySelectorAll('[data-ds]').forEach(b=>b.onclick=async()=>{
if(!confirm('Delete this special?'))return;
try{await api(`/specials/${b.dataset.ds}`,{method:'DELETE'});await loadAll();activeTab='specials';render();}catch(e){alert(e.message);}
});
}
/* Wire: Testimonials */
function wireTest(){
document.querySelectorAll('[data-tf]').forEach(b=>b.onclick=()=>{tFilter=b.dataset.tf;render();});
document.querySelectorAll('[data-ta]').forEach(b=>b.onclick=async()=>{
const id=b.dataset.tid,action=b.dataset.ta;
const card=document.querySelector(`.ep-tc[data-tid="${id}"]`);
try{
if(action==='approve')await api(`/testimonials/${id}`,{method:'PUT',body:JSON.stringify({status:'approved'})});
else if(action==='deny')await api(`/testimonials/${id}`,{method:'PUT',body:JSON.stringify({status:'denied'})});
else if(action==='save'){const ta=card.querySelector('.ep-te');await api(`/testimonials/${id}`,{method:'PUT',body:JSON.stringify({message:ta.value})});}
else if(action==='delete'){if(!confirm('Delete?'))return;await api(`/testimonials/${id}`,{method:'DELETE'});}
await loadAll();render();
}catch(e){alert(e.message);}
});
}
/* Init */
let injected=false;
function tryInject(){
if(injected)return;
if(!window.location.pathname.startsWith('/admin/dashboard'))return;
if(!localStorage.getItem('isAdminAuthenticated'))return;
const root=document.getElementById('root');
if(!root||!root.children.length)return;
injected=true;obs.disconnect();
Array.from(root.children).forEach(c=>c.style.display='none');
const portal=buildShell();root.insertBefore(portal,root.firstChild);
loadAll().then(render);
}
const obs=new MutationObserver(tryInject);
obs.observe(document.body,{childList:true,subtree:true});
tryInject();
})();
@@ -0,0 +1,208 @@
(function () {
const API = 'https://epictravelexpeditions.com/api';
const style = document.createElement('style');
style.textContent = `
#et-admin-section { padding: 32px 0; }
#et-admin-section h3 { font-size: 1.4rem; font-weight: 700; color: #111827; margin-bottom: 16px; }
.et-admin-card {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 10px;
padding: 20px;
margin-bottom: 16px;
display: flex;
gap: 16px;
align-items: flex-start;
}
.et-admin-avatar {
width: 48px; height: 48px; border-radius: 50%;
object-fit: cover; flex-shrink: 0; background: #e5e7eb;
}
.et-admin-avatar-ph {
width: 48px; height: 48px; border-radius: 50%;
background: #2563eb; color: #fff;
display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 18px; flex-shrink: 0;
}
.et-admin-body { flex: 1; min-width: 0; }
.et-admin-name { font-weight: 700; font-size: 14px; }
.et-admin-loc { font-size: 12px; color: #6b7280; margin-bottom: 6px; }
.et-admin-msg { font-size: 13px; color: #374151; margin-bottom: 10px; }
.et-admin-status {
display: inline-block;
padding: 2px 10px; border-radius: 999px;
font-size: 11px; font-weight: 600; text-transform: uppercase;
margin-bottom: 10px;
}
.et-status-pending { background: #fef3c7; color: #92400e; }
.et-status-approved { background: #d1fae5; color: #065f46; }
.et-status-denied { background: #fee2e2; color: #991b1b; }
.et-admin-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.et-btn {
padding: 6px 14px; border-radius: 6px; font-size: 12px; font-weight: 600;
border: none; cursor: pointer; transition: opacity .15s;
}
.et-btn:hover { opacity: .8; }
.et-btn-approve { background: #16a34a; color: #fff; }
.et-btn-deny { background: #dc2626; color: #fff; }
.et-btn-delete { background: #6b7280; color: #fff; }
.et-btn-save { background: #2563eb; color: #fff; }
.et-edit-area {
width: 100%; box-sizing: border-box;
border: 1px solid #d1d5db; border-radius: 6px;
padding: 8px 10px; font-size: 13px;
resize: vertical; min-height: 60px; margin-bottom: 6px;
}
.et-tabs { display: flex; gap: 4px; margin-bottom: 20px; }
.et-tab {
padding: 6px 16px; border-radius: 6px; font-size: 13px; font-weight: 600;
cursor: pointer; border: 1px solid #d1d5db; background: #fff; color: #374151;
}
.et-tab.active { background: #2563eb; color: #fff; border-color: #2563eb; }
.et-empty { color: #9ca3af; font-size: 14px; text-align: center; padding: 32px 0; }
`;
document.head.appendChild(style);
function statusBadge(s) {
return `<span class="et-admin-status et-status-${s}">${s}</span>`;
}
function avatarHtml(t) {
if (t.image_path) return `<img class="et-admin-avatar" src="${t.image_path}" alt="">`;
return `<div class="et-admin-avatar-ph">${t.full_name.charAt(0).toUpperCase()}</div>`;
}
async function applyAction(id, payload) {
const token = localStorage.getItem('auth_token');
const res = await fetch(`${API}/testimonials/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify(payload)
});
return res.json();
}
async function deleteTestimonial(id) {
const token = localStorage.getItem('auth_token');
await fetch(`${API}/testimonials/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }
});
}
function renderCards(list, container, reload) {
container.innerHTML = '';
if (!list.length) {
container.innerHTML = '<p class="et-empty">No testimonials in this category.</p>';
return;
}
list.forEach(t => {
const card = document.createElement('div');
card.className = 'et-admin-card';
card.dataset.id = t.id;
card.innerHTML = `
${avatarHtml(t)}
<div class="et-admin-body">
<div class="et-admin-name">${t.full_name}</div>
<div class="et-admin-loc">${t.location}</div>
${statusBadge(t.status)}
<div class="et-admin-msg">${t.message}</div>
<textarea class="et-edit-area" data-orig="${t.message.replace(/"/g,'&quot;')}">${t.message}</textarea>
<div class="et-admin-actions">
${t.status !== 'approved' ? '<button class="et-btn et-btn-approve" data-action="approve">Approve</button>' : ''}
${t.status !== 'denied' ? '<button class="et-btn et-btn-deny" data-action="deny">Deny</button>' : ''}
<button class="et-btn et-btn-save" data-action="save">Save Edit</button>
<button class="et-btn et-btn-delete" data-action="delete">Delete</button>
</div>
</div>
`;
card.querySelectorAll('[data-action]').forEach(btn => {
btn.addEventListener('click', async () => {
const action = btn.dataset.action;
const textarea = card.querySelector('.et-edit-area');
try {
if (action === 'approve') await applyAction(t.id, { status: 'approved' });
else if (action === 'deny') await applyAction(t.id, { status: 'denied' });
else if (action === 'save') await applyAction(t.id, { message: textarea.value });
else if (action === 'delete') { if (!confirm('Delete this testimonial?')) return; await deleteTestimonial(t.id); }
reload();
} catch (e) { alert('Error: ' + e.message); }
});
});
container.appendChild(card);
});
}
async function buildAdminPanel(mountEl) {
const token = localStorage.getItem('auth_token');
const res = await fetch(`${API}/testimonials/all`, {
headers: { Authorization: `Bearer ${token}` }
});
const all = await res.json();
const section = document.createElement('div');
section.id = 'et-admin-section';
section.innerHTML = `
<h3>Testimonials</h3>
<div class="et-tabs">
<button class="et-tab active" data-filter="pending">Pending (${all.filter(t=>t.status==='pending').length})</button>
<button class="et-tab" data-filter="approved">Approved (${all.filter(t=>t.status==='approved').length})</button>
<button class="et-tab" data-filter="denied">Denied (${all.filter(t=>t.status==='denied').length})</button>
<button class="et-tab" data-filter="all">All (${all.length})</button>
</div>
<div id="et-admin-cards"></div>
`;
let currentFilter = 'pending';
const cardsEl = section.querySelector('#et-admin-cards');
async function reload() {
const res = await fetch(`${API}/testimonials/all`, {
headers: { Authorization: `Bearer ${token}` }
});
const fresh = await res.json();
const filtered = currentFilter === 'all' ? fresh : fresh.filter(t => t.status === currentFilter);
renderCards(filtered, cardsEl, reload);
// Update counts
section.querySelectorAll('.et-tab').forEach(tab => {
const f = tab.dataset.filter;
const count = f === 'all' ? fresh.length : fresh.filter(t => t.status === f).length;
tab.textContent = `${f.charAt(0).toUpperCase()+f.slice(1)} (${count})`;
if (f === currentFilter) tab.classList.add('active');
else tab.classList.remove('active');
});
}
section.querySelectorAll('.et-tab').forEach(tab => {
tab.addEventListener('click', () => {
currentFilter = tab.dataset.filter;
reload();
});
});
const initialFiltered = all.filter(t => t.status === 'pending');
renderCards(initialFiltered, cardsEl, reload);
mountEl.appendChild(section);
}
// Watch for admin dashboard
let adminDone = false;
const observer = new MutationObserver(() => {
if (adminDone) return;
if (!window.location.pathname.startsWith('/admin/dashboard')) return;
if (!localStorage.getItem('isAdminAuthenticated')) return;
// Find the main content area of the admin panel
const main = document.querySelector('[class*="max-w-7xl"]');
if (!main) return;
if (document.getElementById('et-admin-section')) return;
adminDone = true;
observer.disconnect();
buildAdminPanel(main);
});
observer.observe(document.body, { childList: true, subtree: true });
})();
File diff suppressed because one or more lines are too long
@@ -0,0 +1,67 @@
/**
* @license React
* react-dom-client.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* react-dom.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* react-jsx-runtime.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* react.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* scheduler.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license lucide-react v0.507.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/
/**
* react-router v7.11.0
*
* Copyright (c) Remix Software Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
*/
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,313 @@
(function () {
const API = 'https://epictravelexpeditions.com/api';
/* ── Styles ── */
const style = document.createElement('style');
style.textContent = `
.et-scroll-track { overflow: hidden; position: relative; }
.et-scroll-track::before,
.et-scroll-track::after {
content: '';
position: absolute;
top: 0; bottom: 0;
width: 80px;
z-index: 2;
pointer-events: none;
}
.et-scroll-track::before { left: 0; background: linear-gradient(to right, white, transparent); }
.et-scroll-track::after { right: 0; background: linear-gradient(to left, white, transparent); }
.et-scroll-inner {
display: flex;
gap: 24px;
width: max-content;
animation: et-slide 40s linear infinite;
}
.et-scroll-inner:hover { animation-play-state: paused; }
@keyframes et-slide {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
}
.et-card {
width: 300px;
flex-shrink: 0;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0,0,0,.06);
transition: box-shadow .2s;
}
.et-card:hover { box-shadow: 0 6px 20px rgba(0,0,0,.12); }
.et-avatar {
width: 56px; height: 56px;
border-radius: 50%;
object-fit: cover;
background: #e5e7eb;
}
.et-avatar-placeholder {
width: 56px; height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, #3b82f6, #2563eb);
display: flex; align-items: center; justify-content: center;
color: #fff; font-size: 22px; font-weight: 700;
}
.et-name { font-weight: 700; font-size: 15px; color: #111827; margin: 0; }
.et-loc { font-size: 13px; color: #6b7280; margin: 2px 0 12px; }
.et-msg { font-size: 14px; color: #374151; line-height: 1.6; font-style: italic; }
/* ── Form ── */
#et-form-section {
background: #f9fafb;
padding: 64px 24px;
text-align: center;
}
#et-form-section h2 {
font-size: 2rem; font-weight: 700; color: #111827; margin-bottom: 8px;
}
#et-form-section p {
color: #6b7280; margin-bottom: 32px;
}
#et-form {
max-width: 560px;
margin: 0 auto;
text-align: left;
display: flex;
flex-direction: column;
gap: 16px;
}
#et-form input, #et-form textarea {
width: 100%; box-sizing: border-box;
padding: 10px 14px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 14px;
color: #111827;
background: #fff;
outline: none;
transition: border-color .15s;
}
#et-form input:focus, #et-form textarea:focus { border-color: #3b82f6; }
#et-form textarea { resize: vertical; min-height: 100px; }
.et-file-label {
display: flex; align-items: center; gap: 10px;
padding: 10px 14px;
border: 1px dashed #d1d5db;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
color: #6b7280;
background: #fff;
transition: border-color .15s;
}
.et-file-label:hover { border-color: #3b82f6; color: #3b82f6; }
#et-file-input { display: none; }
#et-preview { width: 64px; height: 64px; border-radius: 50%; object-fit: cover; display: none; }
#et-submit {
padding: 12px 24px;
background: #2563eb;
color: #fff;
border: none;
border-radius: 8px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: background .15s;
align-self: flex-end;
}
#et-submit:hover { background: #1d4ed8; }
#et-submit:disabled { background: #93c5fd; cursor: not-allowed; }
#et-msg { font-size: 14px; text-align: center; }
.et-success { color: #16a34a; }
.et-error { color: #dc2626; }
#et-img-row { display: flex; align-items: center; gap: 12px; }
`;
document.head.appendChild(style);
/* ── Build a testimonial card ── */
function buildCard(t) {
const card = document.createElement('div');
card.className = 'et-card';
let avatarHtml;
if (t.image_path) {
avatarHtml = `<img class="et-avatar" src="${t.image_path}" alt="${t.full_name}" onerror="this.style.display='none'">`;
} else {
const initial = t.full_name.charAt(0).toUpperCase();
avatarHtml = `<div class="et-avatar-placeholder">${initial}</div>`;
}
card.innerHTML = `
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px">
${avatarHtml}
<div>
<p class="et-name">${t.full_name}</p>
<p class="et-loc">${t.location}</p>
</div>
</div>
<p class="et-msg">&ldquo;${t.message}&rdquo;</p>
`;
return card;
}
/* ── Replace static testimonials section ── */
function replaceTestimonials(section, testimonials) {
const inner = document.createElement('div');
inner.className = 'et-scroll-inner';
const cards = testimonials.map(buildCard);
cards.forEach(c => inner.appendChild(c));
// Duplicate for seamless loop
cards.forEach(c => inner.appendChild(c.cloneNode(true)));
const track = document.createElement('div');
track.className = 'et-scroll-track';
track.appendChild(inner);
// Keep the heading, replace the grid
const heading = section.querySelector('[class*="grid"]') || section.lastElementChild;
if (heading) heading.replaceWith(track);
else section.appendChild(track);
}
/* ── Upload image (no auth required for testimonials) ── */
async function uploadImage(file) {
const fd = new FormData();
fd.append('file', file);
const res = await fetch(`${API}/testimonials/upload`, { method: 'POST', body: fd });
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Upload failed');
return data.url;
}
/* ── Submission form ── */
function buildForm() {
const section = document.createElement('section');
section.id = 'et-form-section';
section.innerHTML = `
<h2>Share Your Experience</h2>
<p>Traveled with us? We'd love to hear about it.</p>
<form id="et-form" novalidate>
<input id="et-name" type="text" placeholder="Full Name *" required>
<input id="et-location" type="text" placeholder="City, State / Country *" required>
<textarea id="et-msg-input" placeholder="Tell us about your trip *" required></textarea>
<div id="et-img-row">
<label class="et-file-label" for="et-file-input">
📷 Add a photo (optional)
</label>
<input id="et-file-input" type="file" accept="image/jpeg,image/png,image/webp">
<img id="et-preview" src="" alt="preview">
</div>
<div id="et-msg"></div>
<button id="et-submit" type="submit">Submit Testimonial</button>
</form>
`;
setTimeout(() => {
const form = document.getElementById('et-form');
const nameEl = document.getElementById('et-name');
const locEl = document.getElementById('et-location');
const msgEl = document.getElementById('et-msg-input');
const fileEl = document.getElementById('et-file-input');
const preview = document.getElementById('et-preview');
const statusEl = document.getElementById('et-msg');
const submitBtn = document.getElementById('et-submit');
fileEl.addEventListener('change', () => {
const f = fileEl.files[0];
if (f) {
preview.src = URL.createObjectURL(f);
preview.style.display = 'block';
}
});
form.addEventListener('submit', async (e) => {
e.preventDefault();
statusEl.textContent = '';
const name = nameEl.value.trim();
const loc = locEl.value.trim();
const msg = msgEl.value.trim();
if (!name || !loc || !msg) {
statusEl.textContent = 'Please fill in all required fields.';
statusEl.className = 'et-error';
return;
}
submitBtn.disabled = true;
submitBtn.textContent = 'Submitting…';
try {
let imagePath = null;
if (fileEl.files[0]) {
imagePath = await uploadImage(fileEl.files[0]);
}
const res = await fetch(`${API}/testimonials`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ full_name: name, location: loc, message: msg, image_path: imagePath })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Submission failed');
statusEl.textContent = 'Thank you! Your testimonial has been submitted for review.';
statusEl.className = 'et-success';
form.reset();
preview.style.display = 'none';
} catch (err) {
statusEl.textContent = err.message;
statusEl.className = 'et-error';
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Submit Testimonial';
}
});
}, 0);
return section;
}
/* ── Main: observe DOM until static testimonials appear ── */
async function init() {
let testimonials = [];
try {
const res = await fetch(`${API}/testimonials`);
testimonials = await res.json();
} catch (_) {}
let done = false;
const observer = new MutationObserver(() => {
if (done) return;
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
let node;
while ((node = walker.nextNode())) {
if (node.nodeValue && node.nodeValue.includes('What Our Travelers Say')) {
// Walk up to the section/div container
let el = node.parentElement;
for (let i = 0; i < 6; i++) {
if (el && (el.tagName === 'SECTION' || (el.className && el.className.includes('py-')))) break;
el = el ? el.parentElement : null;
}
if (!el) break;
done = true;
observer.disconnect();
if (testimonials.length > 0) {
replaceTestimonials(el, testimonials);
}
// Inject form after the section
const formSection = buildForm();
el.after(formSection);
break;
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
@@ -0,0 +1,5 @@
*.log
.DS_Store
*.swp
uploads/
@@ -0,0 +1,10 @@
# Block direct web access to the .git directory and other dotfiles.
# The .git folder lives inside public_html (docRoot) and was found to be
# directly downloadable over HTTPS (e.g. https://orbis.orbishosting.com/.git/config),
# which leaked a live GitHub personal access token embedded in the remote URL.
# Note: standard Apache access-control directives (RedirectMatch, FilesMatch/
# Require all denied, Order/Deny) are silently ignored on this LiteSpeed vhost.
# Only mod_rewrite rules are honored (confirmed via testing), hence this form.
RewriteEngine On
RewriteCond %{REQUEST_URI} ^/\.git
RewriteRule .* - [F,L]
@@ -0,0 +1,96 @@
docRoot $VH_ROOT/public_html
vhDomain $VH_NAME
vhAliases www.$VH_NAME
adminEmails admin@cyberpanel.net
enableGzip 1
enableIpGeo 1
index {
useServer 0
indexFiles index.php, index.html
}
errorlog $VH_ROOT/logs/$VH_NAME.error_log {
useServer 0
logLevel WARN
rollingSize 10M
}
accesslog $VH_ROOT/logs/$VH_NAME.access_log {
useServer 0
logFormat "%h %l %u %t "%r" %>s %b "%{Referer}i" "%{User-Agent}i""
logHeaders 5
rollingSize 10M
keepDays 10
compressArchive 1
}
scripthandler {
add lsapi:orbis1968 php
}
extprocessor orbis1968 {
type lsapi
address UDS://tmp/lshttpd/orbis1968.sock
maxConns 10
env LSAPI_CHILDREN=10
initTimeout 600
retryTimeout 0
persistConn 1
pcKeepAliveTimeout 1
respBuffer 0
autoStart 1
path /usr/local/lsws/lsphp80/bin/lsphp
extUser orbis1968
extGroup orbis1968
memSoftLimit 1024M
memHardLimit 1024M
procSoftLimit 400
procHardLimit 500
}
phpIniOverride {
}
module cache {
storagePath /usr/local/lsws/cachedata/$VH_NAME
}
rewrite {
enable 1
autoLoadHtaccess 1
}
context /.git {
location /dev/null
allowBrowse 0
}
context /.well-known/acme-challenge {
location /usr/local/lsws/Example/html/.well-known/acme-challenge
allowBrowse 1
rewrite {
enable 0
}
addDefaultCharset off
phpIniOverride {
}
}
vhssl {
keyFile /etc/letsencrypt/live/orbis.orbishosting.com/privkey.pem
certFile /etc/letsencrypt/live/orbis.orbishosting.com/fullchain.pem
certChain 1
sslProtocol 24
enableECDHE 1
renegProtection 1
sslSessionCache 1
enableSpdy 15
enableStapling 1
ocspRespMaxAge 86400
}
@@ -0,0 +1,91 @@
docRoot $VH_ROOT/public_html
vhDomain $VH_NAME
vhAliases www.$VH_NAME
adminEmails admin@cyberpanel.net
enableGzip 1
enableIpGeo 1
index {
useServer 0
indexFiles index.php, index.html
}
errorlog $VH_ROOT/logs/$VH_NAME.error_log {
useServer 0
logLevel WARN
rollingSize 10M
}
accesslog $VH_ROOT/logs/$VH_NAME.access_log {
useServer 0
logFormat "%h %l %u %t "%r" %>s %b "%{Referer}i" "%{User-Agent}i""
logHeaders 5
rollingSize 10M
keepDays 10
compressArchive 1
}
scripthandler {
add lsapi:orbis1968 php
}
extprocessor orbis1968 {
type lsapi
address UDS://tmp/lshttpd/orbis1968.sock
maxConns 10
env LSAPI_CHILDREN=10
initTimeout 600
retryTimeout 0
persistConn 1
pcKeepAliveTimeout 1
respBuffer 0
autoStart 1
path /usr/local/lsws/lsphp80/bin/lsphp
extUser orbis1968
extGroup orbis1968
memSoftLimit 1024M
memHardLimit 1024M
procSoftLimit 400
procHardLimit 500
}
phpIniOverride {
}
module cache {
storagePath /usr/local/lsws/cachedata/$VH_NAME
}
rewrite {
enable 1
autoLoadHtaccess 1
}
context /.well-known/acme-challenge {
location /usr/local/lsws/Example/html/.well-known/acme-challenge
allowBrowse 1
rewrite {
enable 0
}
addDefaultCharset off
phpIniOverride {
}
}
vhssl {
keyFile /etc/letsencrypt/live/orbis.orbishosting.com/privkey.pem
certFile /etc/letsencrypt/live/orbis.orbishosting.com/fullchain.pem
certChain 1
sslProtocol 24
enableECDHE 1
renegProtection 1
sslSessionCache 1
enableSpdy 15
enableStapling 1
ocspRespMaxAge 86400
}
@@ -0,0 +1,130 @@
head 1.2;
access;
symbols;
locks
root:1.2; strict;
comment @# @;
1.2
date 2026.05.19.22.13.53; author root; state Exp;
branches;
next 1.1;
1.1
date 2026.05.19.22.13.41; author root; state Exp;
branches;
next ;
desc
@/usr/local/lsws/conf/vhosts/orbis.orbishosting.com/vhost.conf0
@
1.2
log
@Update
@
text
@docRoot $VH_ROOT/public_html
vhDomain $VH_NAME
vhAliases www.$VH_NAME
adminEmails admin@@cyberpanel.net
enableGzip 1
enableIpGeo 1
index {
useServer 0
indexFiles index.php, index.html
}
errorlog $VH_ROOT/logs/$VH_NAME.error_log {
useServer 0
logLevel WARN
rollingSize 10M
}
accesslog $VH_ROOT/logs/$VH_NAME.access_log {
useServer 0
logFormat "%h %l %u %t "%r" %>s %b "%{Referer}i" "%{User-Agent}i""
logHeaders 5
rollingSize 10M
keepDays 10
compressArchive 1
}
scripthandler {
add lsapi:orbis1968 php
}
extprocessor orbis1968 {
type lsapi
address UDS://tmp/lshttpd/orbis1968.sock
maxConns 10
env LSAPI_CHILDREN=10
initTimeout 600
retryTimeout 0
persistConn 1
pcKeepAliveTimeout 1
respBuffer 0
autoStart 1
path /usr/local/lsws/lsphp80/bin/lsphp
extUser orbis1968
extGroup orbis1968
memSoftLimit 1024M
memHardLimit 1024M
procSoftLimit 400
procHardLimit 500
}
phpIniOverride {
}
module cache {
storagePath /usr/local/lsws/cachedata/$VH_NAME
}
rewrite {
enable 1
autoLoadHtaccess 1
}
context /.well-known/acme-challenge {
location /usr/local/lsws/Example/html/.well-known/acme-challenge
allowBrowse 1
rewrite {
enable 0
}
addDefaultCharset off
phpIniOverride {
}
}
vhssl {
keyFile /etc/letsencrypt/live/orbis.orbishosting.com/privkey.pem
certFile /etc/letsencrypt/live/orbis.orbishosting.com/fullchain.pem
certChain 1
sslProtocol 24
enableECDHE 1
renegProtection 1
sslSessionCache 1
enableSpdy 15
enableStapling 1
ocspRespMaxAge 86400
}
@
1.1
log
@Update
@
text
@d79 13
@
@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Orbis Hosting — Client Portal</title>
<meta name="description" content="Sign in to the Orbis Hosting client portal to manage your web hosting, domains, email accounts, databases, and billing — all from one dashboard.">
<meta name="keywords" content="Orbis Hosting portal, hosting control panel, manage hosting account, domain management, hosting dashboard, client login, web hosting management">
<meta name="robots" content="index, follow">
<link rel="canonical" href="https://orbis.orbishosting.com/">
<meta property="og:type" content="website">
<meta property="og:title" content="Orbis Hosting Client Portal">
<meta property="og:description" content="Manage your Orbis Hosting account — domains, email, databases, and billing in one place.">
<meta property="og:url" content="https://orbis.orbishosting.com/">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Orbis Hosting Client Portal">
<meta name="twitter:description" content="Manage your hosting account, domains, email, and billing from the Orbis Hosting client portal.">
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0f1117;color:#e2e8f0;min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:2rem}
.logo{font-size:2.2rem;font-weight:700;background:linear-gradient(135deg,#6366f1,#0ea5e9);-webkit-background-clip:text;-webkit-text-fill-color:transparent;margin-bottom:.4rem}
.sub{font-size:.95rem;color:#64748b;text-transform:uppercase;letter-spacing:.1em;margin-bottom:2rem}
.card{background:#1e2535;border:1px solid #2d3748;border-radius:12px;padding:2rem 2.5rem;max-width:380px;width:100%}
.card p{color:#94a3b8;margin-bottom:1.5rem;font-size:.9rem}
.cta{display:block;background:linear-gradient(135deg,#6366f1,#0ea5e9);color:#fff;text-decoration:none;padding:.85rem;border-radius:8px;font-weight:600;font-size:1rem;transition:opacity .2s}
.cta:hover{opacity:.85}
.back{margin-top:1.25rem;font-size:.82rem;color:#64748b}
.back a{color:#6366f1;text-decoration:none}
.back a:hover{text-decoration:underline}
</style>
</head>
<body>
<div class="logo">Orbis Hosting</div>
<div class="sub">Client Portal</div>
<div class="card">
<p>Manage your hosting accounts, domains, email, databases, and billing.</p>
<a href="#" class="cta">Sign In</a>
</div>
<p class="back"><a href="https://orbishosting.com">← Back to orbishosting.com</a></p>
</body>
</html>
@@ -0,0 +1,7 @@
*.log
.DS_Store
*.swp
uploads/
legacy/
dist/
@@ -0,0 +1,14 @@
# Deny public access to the git working directory and legacy/unused
# server directories that live inside the web docroot but were never
# meant to be reachable over HTTP.
#
# Found during security review (2026-07): https://orbishosting.com/.git/config
# was directly downloadable and exposed a live GitHub PAT in the remote URL.
# That token must also be revoked/rotated on GitHub — this file only closes
# the HTTP exposure, it does not invalidate the leaked credential.
RewriteEngine On
RewriteRule ^\.git(/|$) - [F,L]
RewriteRule ^legacy(/|$) - [F,L]
RewriteRule ^dist(/|$) - [F,L]
RewriteRule ^extensions(/|$) - [F,L]
RewriteRule ^config(/|$) - [F,L]
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

+91
View File
@@ -0,0 +1,91 @@
docRoot $VH_ROOT/public_html
vhDomain $VH_NAME
vhAliases www.$VH_NAME
adminEmails admin@orbishosting.com
enableGzip 1
enableIpGeo 1
index {
useServer 0
indexFiles index.php, index.html
}
errorlog $VH_ROOT/logs/$VH_NAME.error_log {
useServer 0
logLevel WARN
rollingSize 10M
}
accesslog $VH_ROOT/logs/$VH_NAME.access_log {
useServer 0
logFormat "%h %l %u %t "%r" %>s %b "%{Referer}i" "%{User-Agent}i""
logHeaders 5
rollingSize 10M
keepDays 10
compressArchive 1
}
scripthandler {
add lsapi:orbis2682 php
}
extprocessor orbis2682 {
type lsapi
address UDS://tmp/lshttpd/orbis2682.sock
maxConns 10
env LSAPI_CHILDREN=10
initTimeout 600
retryTimeout 0
persistConn 1
pcKeepAliveTimeout 1
respBuffer 0
autoStart 1
path /usr/local/lsws/lsphp85/bin/lsphp
extUser orbis2682
extGroup orbis2682
memSoftLimit 1024M
memHardLimit 1024M
procSoftLimit 400
procHardLimit 500
}
phpIniOverride {
}
module cache {
storagePath /usr/local/lsws/cachedata/$VH_NAME
}
rewrite {
enable 1
autoLoadHtaccess 1
}
context /.well-known/acme-challenge {
location /usr/local/lsws/Example/html/.well-known/acme-challenge
allowBrowse 1
rewrite {
enable 0
}
addDefaultCharset off
phpIniOverride {
}
}
vhssl {
keyFile /etc/letsencrypt/live/orbishosting.com/privkey.pem
certFile /etc/letsencrypt/live/orbishosting.com/fullchain.pem
certChain 1
sslProtocol 24
enableECDHE 1
renegProtection 1
sslSessionCache 1
enableSpdy 15
enableStapling 1
ocspRespMaxAge 86400
}
@@ -0,0 +1,85 @@
docroot $VH_ROOT/public_html
enableipgeo 1
vhdomain $VH_NAME
phpinioverride
enablegzip 1
vhaliases www.$VH_NAME
adminemails admin@orbishosting.com
context /.well-known/acme-challenge {
location /usr/local/lsws/Example/html/.well-known/acme-challenge
allowbrowse 1
adddefaultcharset off
phpinioverride
rewrite {
enable 0
}
}
rewrite {
enable 1
autoloadhtaccess 1
}
index {
indexfiles index.php, index.html
useserver 0
}
extprocessor orbis2682 {
memhardlimit 1024M
procsoftlimit 400
retrytimeout 0
persistconn 1
extuser orbis2682
extgroup orbis2682
memsoftlimit 1024M
type lsapi
address UDS://tmp/lshttpd/orbis2682.sock
respbuffer 0
autostart 1
path /usr/local/lsws/lsphp85/bin/lsphp
prochardlimit 500
pckeepalivetimeout 1
maxconns 10
env LSAPI_CHILDREN=10
inittimeout 600
}
module cache {
unknownkeywords storagepath /usr/local/lsws/cachedata/$VH_NAME
param storagepath /usr/local/lsws/cachedata/$VH_NAME
}
errorlog $VH_ROOT/logs/$VH_NAME.error_log {
loglevel WARN
useserver 0
rollingsize 10M
}
accesslog $VH_ROOT/logs/$VH_NAME.access_log {
logheaders 5
rollingsize 10M
logformat "%h %l %u %t "%r" %>s %b "%{Referer}i" "%{User-Agent}i""
useserver 0
keepdays 10
compressarchive 1
}
scripthandler {
add lsapi:orbis2682 php
}
vhssl {
keyfile /etc/letsencrypt/live/orbishosting.com/privkey.pem
certfile /etc/letsencrypt/live/orbishosting.com/fullchain.pem
certchain 1
sslprotocol 24
enableecdhe 1
renegprotection 1
sslsessioncache 1
enablespdy 15
enablestapling 1
ocsprespmaxage 86400
}
@@ -0,0 +1,91 @@
docRoot $VH_ROOT/public_html
vhDomain $VH_NAME
vhAliases www.$VH_NAME
adminEmails admin@orbishosting.com
enableGzip 1
enableIpGeo 1
index {
useServer 0
indexFiles index.php, index.html
}
errorlog $VH_ROOT/logs/$VH_NAME.error_log {
useServer 0
logLevel WARN
rollingSize 10M
}
accesslog $VH_ROOT/logs/$VH_NAME.access_log {
useServer 0
logFormat "%h %l %u %t "%r" %>s %b "%{Referer}i" "%{User-Agent}i""
logHeaders 5
rollingSize 10M
keepDays 10
compressArchive 1
}
scripthandler {
add lsapi:orbis2682 php
}
extprocessor orbis2682 {
type lsapi
address UDS://tmp/lshttpd/orbis2682.sock
maxConns 10
env LSAPI_CHILDREN=10
initTimeout 600
retryTimeout 0
persistConn 1
pcKeepAliveTimeout 1
respBuffer 0
autoStart 1
path /usr/local/lsws/lsphp85/bin/lsphp
extUser orbis2682
extGroup orbis2682
memSoftLimit 1024M
memHardLimit 1024M
procSoftLimit 400
procHardLimit 500
}
phpIniOverride {
}
module cache {
storagePath /usr/local/lsws/cachedata/$VH_NAME
}
rewrite {
enable 1
autoLoadHtaccess 1
}
context /.well-known/acme-challenge {
location /usr/local/lsws/Example/html/.well-known/acme-challenge
allowBrowse 1
rewrite {
enable 0
}
addDefaultCharset off
phpIniOverride {
}
}
vhssl {
keyFile /etc/letsencrypt/live/orbishosting.com/privkey.pem
certFile /etc/letsencrypt/live/orbishosting.com/fullchain.pem
certChain 1
sslProtocol 24
enableECDHE 1
renegProtection 1
sslSessionCache 1
enableSpdy 15
enableStapling 1
ocspRespMaxAge 86400
}
@@ -0,0 +1,130 @@
head 1.2;
access;
symbols;
locks
root:1.2; strict;
comment @# @;
1.2
date 2026.05.15.20.05.14; author root; state Exp;
branches;
next 1.1;
1.1
date 2026.05.15.20.04.10; author root; state Exp;
branches;
next ;
desc
@/usr/local/lsws/conf/vhosts/orbishosting.com/vhost.conf0
@
1.2
log
@Update
@
text
@docRoot $VH_ROOT/public_html
vhDomain $VH_NAME
vhAliases www.$VH_NAME
adminEmails admin@@orbishosting.com
enableGzip 1
enableIpGeo 1
index {
useServer 0
indexFiles index.php, index.html
}
errorlog $VH_ROOT/logs/$VH_NAME.error_log {
useServer 0
logLevel WARN
rollingSize 10M
}
accesslog $VH_ROOT/logs/$VH_NAME.access_log {
useServer 0
logFormat "%h %l %u %t "%r" %>s %b "%{Referer}i" "%{User-Agent}i""
logHeaders 5
rollingSize 10M
keepDays 10
compressArchive 1
}
scripthandler {
add lsapi:orbis2682 php
}
extprocessor orbis2682 {
type lsapi
address UDS://tmp/lshttpd/orbis2682.sock
maxConns 10
env LSAPI_CHILDREN=10
initTimeout 600
retryTimeout 0
persistConn 1
pcKeepAliveTimeout 1
respBuffer 0
autoStart 1
path /usr/local/lsws/lsphp85/bin/lsphp
extUser orbis2682
extGroup orbis2682
memSoftLimit 1024M
memHardLimit 1024M
procSoftLimit 400
procHardLimit 500
}
phpIniOverride {
}
module cache {
storagePath /usr/local/lsws/cachedata/$VH_NAME
}
rewrite {
enable 1
autoLoadHtaccess 1
}
context /.well-known/acme-challenge {
location /usr/local/lsws/Example/html/.well-known/acme-challenge
allowBrowse 1
rewrite {
enable 0
}
addDefaultCharset off
phpIniOverride {
}
}
vhssl {
keyFile /etc/letsencrypt/live/orbishosting.com/privkey.pem
certFile /etc/letsencrypt/live/orbishosting.com/fullchain.pem
certChain 1
sslProtocol 24
enableECDHE 1
renegProtection 1
sslSessionCache 1
enableSpdy 15
enableStapling 1
ocspRespMaxAge 86400
}
@
1.1
log
@Update
@
text
@d79 13
@
@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Orbis Hosting — Reliable Web Hosting, VPS & Managed Servers</title>
<meta name="description" content="Orbis Hosting delivers fast, reliable web hosting, VPS, and managed server solutions. SSD-powered infrastructure, 99.9% uptime guarantee, and expert support for businesses and developers.">
<meta name="keywords" content="web hosting, VPS hosting, managed hosting, SSD web hosting, cloud hosting, domain registration, affordable web hosting, Orbis Hosting, business hosting, developer hosting">
<meta name="robots" content="index, follow">
<link rel="canonical" href="https://orbishosting.com/">
<meta property="og:type" content="website">
<meta property="og:title" content="Orbis Hosting — Reliable Web Hosting, VPS & Managed Servers">
<meta property="og:description" content="Fast, reliable web hosting and VPS solutions. SSD storage, 99.9% uptime, and expert 24/7 support.">
<meta property="og:url" content="https://orbishosting.com/">
<meta property="og:image" content="https://orbishosting.com/assets/og-image.jpg" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content="https://orbishosting.com/assets/og-image.jpg" />
<meta name="twitter:title" content="Orbis Hosting — Web Hosting & VPS">
<meta name="twitter:description" content="Fast, reliable web hosting and VPS solutions. SSD storage, 99.9% uptime, expert support.">
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0f1117;color:#e2e8f0;min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:2rem}
.logo{font-size:2.5rem;font-weight:700;background:linear-gradient(135deg,#6366f1,#0ea5e9);-webkit-background-clip:text;-webkit-text-fill-color:transparent;margin-bottom:.5rem}
.tagline{font-size:1.1rem;color:#94a3b8;margin-bottom:2.5rem}
.badges{display:flex;gap:1rem;justify-content:center;flex-wrap:wrap;margin-bottom:2.5rem}
.badge{background:#1e2535;border:1px solid #2d3748;border-radius:8px;padding:.6rem 1.2rem;font-size:.85rem;color:#cbd5e1}
.badge strong{color:#6366f1}
.cta{display:inline-block;background:linear-gradient(135deg,#6366f1,#0ea5e9);color:#fff;text-decoration:none;padding:.85rem 2rem;border-radius:8px;font-weight:600;font-size:1rem;transition:opacity .2s}
.cta:hover{opacity:.85}
.portal-link{margin-top:1.25rem;font-size:.85rem;color:#64748b}
.portal-link a{color:#6366f1;text-decoration:none}
.portal-link a:hover{text-decoration:underline}
</style>
</head>
<body>
<div class="logo">Orbis Hosting</div>
<p class="tagline">Reliable Web Hosting, VPS &amp; Managed Servers</p>
<div class="badges">
<div class="badge"><strong></strong> SSD-Powered</div>
<div class="badge"><strong>99.9%</strong> Uptime SLA</div>
<div class="badge"><strong>24/7</strong> Expert Support</div>
<div class="badge"><strong>🔒</strong> Free SSL</div>
</div>
<a href="mailto:support@orbishosting.com" class="cta">Get Started</a>
<p class="portal-link">Existing customer? <a href="https://orbis.orbishosting.com">Sign in to your portal →</a></p>
</body>
</html>
@@ -0,0 +1,6 @@
User-agent: *
Allow: /
Disallow: /admin/
Disallow: /api/
Disallow: /config/
Sitemap: https://orbishosting.com/sitemap.xml
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>https://orbishosting.com/</loc><lastmod>2026-06-14</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>
</urlset>
@@ -0,0 +1,59 @@
<?php
/**
* GitHub Auto-Deploy Webhook
* Verifies GitHub HMAC signature, then queues the repo for git pull.
* A root cron job (/usr/local/bin/jarvis-deploy.sh) processes the queue every minute.
*
* WEBHOOK_SECRET is loaded from api/config.php (gitignored) never hardcoded here.
*/
require_once __DIR__ . '/../api/config.php';
if (!defined('WEBHOOK_SECRET')) {
http_response_code(500);
echo json_encode(['error' => 'Webhook not configured']);
exit;
}
define('DEPLOY_QUEUE', '/tmp/jarvis-deploy-queue.txt');
define('DEPLOY_LOG', '/home/orbishosting.com/logs/deploy.log');
header('Content-Type: application/json');
$payload = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $payload, WEBHOOK_SECRET);
if (!hash_equals($expected, $sig)) {
http_response_code(403);
echo json_encode(['error' => 'Invalid signature']);
exit;
}
$data = json_decode($payload, true);
$repo = $data['repository']['name'] ?? '';
$ref = $data['ref'] ?? '';
$pusher = $data['pusher']['name'] ?? 'unknown';
// Only deploy on pushes to main
if ($ref !== 'refs/heads/main') {
echo json_encode(['ok' => true, 'skipped' => "ref $ref is not main"]);
exit;
}
$repoMap = [
'orbishosting' => '/home/orbishosting.com/public_html',
'orbis-hosting-portal' => '/home/orbis.orbishosting.com/public_html',
];
if (!isset($repoMap[$repo])) {
http_response_code(404);
echo json_encode(['error' => "Unknown repo: $repo"]);
exit;
}
$path = $repoMap[$repo];
$ts = date('Y-m-d H:i:s');
file_put_contents(DEPLOY_QUEUE, $path . "\n", FILE_APPEND | LOCK_EX);
file_put_contents(DEPLOY_LOG, "[$ts] Queued deploy: $repo by $pusher -> $path\n", FILE_APPEND | LOCK_EX);
echo json_encode(['ok' => true, 'queued' => $repo, 'path' => $path]);
@@ -0,0 +1,6 @@
*.log
.DS_Store
*.swp
uploads/*
!uploads/.htaccess
@@ -0,0 +1,21 @@
<Files db.php>
Order deny,allow
Deny from all
</Files>
RewriteEngine On
RewriteRule ^db\.php$ - [F,L]
RewriteCond %{REQUEST_URI} ^/db\.php$
RewriteRule .* - [F,L]
# .git/ was found directly downloadable (leaked GitHub PAT) - same class of
# issue found and fixed on sibling sites this session.
RewriteCond %{REQUEST_URI} ^/\.git
RewriteRule .* - [F,L]
# uploads/ (customer license/insurance docs) must never be directly downloadable -
# same Order/Deny-doesn't-work-on-OpenLiteSpeed gotcha as .git above. Docs are
# served only through view-doc.php / admin/view-doc.php, which read by filesystem
# path via readfile() - blocking direct URL access here doesn't break that flow.
RewriteCond %{REQUEST_URI} ^/uploads/
RewriteRule .* - [F,L]
@@ -0,0 +1,5 @@
<IfModule LiteSpeed>
CacheEnable off
</IfModule>
Header always set Cache-Control "no-store, no-cache, must-revalidate"
Header always set Pragma "no-cache"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,49 @@
<?php
require_once dirname(__DIR__) . '/db.php';
$token = preg_replace('/[^a-f0-9]/', '', $_GET['_t'] ?? '');
if (!verifyAdminToken($token)) {
http_response_code(403);
header('Content-Type: text/plain');
exit('Unauthorized — please log in to the admin panel first.');
}
$ref = strtoupper(preg_replace('/[^A-Z0-9\-]/', '', $_GET['ref'] ?? ''));
$type = in_array($_GET['type'] ?? '', ['license','insurance']) ? $_GET['type'] : '';
if (!$ref || !$type) {
http_response_code(400);
header('Content-Type: text/plain');
exit('Missing parameters.');
}
$col = $type === 'license' ? 'license_file' : 'insurance_file';
$stmt = db()->prepare("SELECT {$col} AS file_path FROM bookings WHERE booking_ref=?");
$stmt->execute([$ref]);
$row = $stmt->fetch();
if (!$row || !$row['file_path']) {
http_response_code(404);
header('Content-Type: text/plain');
exit('No document on file.');
}
$path = resolveUploadPath(dirname(__DIR__), $row['file_path']);
if (!$path) {
http_response_code(404);
header('Content-Type: text/plain');
exit('File not found.');
}
$mime = detectAllowedMime($path);
if (!$mime) {
http_response_code(403);
header('Content-Type: text/plain');
exit('Invalid file type.');
}
$fname = $type . '-' . $ref . '.' . ALLOWED_DOC_MIMES[$mime];
header('Content-Type: ' . $mime);
header('Content-Disposition: inline; filename="' . $fname . '"');
header('Content-Length: ' . filesize($path));
header('Cache-Control: no-store, no-cache');
readfile($path);
@@ -0,0 +1,47 @@
<?php
require_once __DIR__ . '/db.php';
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: ' . SITE_URL);
header('Cache-Control: no-store, no-cache, must-revalidate');
$month = (int)($_GET['month'] ?? date('n'));
$year = (int)($_GET['year'] ?? date('Y'));
$month = max(1, min(12, $month));
$year = max(date('Y'), min(date('Y') + 2, $year));
$start = sprintf('%04d-%02d-01', $year, $month);
$end = date('Y-m-t', strtotime($start));
// Bookings that overlap this month — including those that start last month and end this month
$booked = db()->prepare(
"SELECT rental_date, end_date FROM bookings
WHERE status IN ('pending','confirmed')
AND rental_date <= ? AND end_date >= ?"
);
$booked->execute([$end, $start]);
$bookedDays = [];
foreach ($booked->fetchAll() as $row) {
$d = new DateTime($row['rental_date']);
$e = new DateTime($row['end_date']);
while ($d <= $e) {
$bookedDays[] = $d->format('Y-m-d');
$d->modify('+1 day');
}
}
// Admin-blocked dates for this month
$blocked = db()->prepare(
"SELECT block_date FROM blocked_dates WHERE block_date BETWEEN ? AND ?"
);
$blocked->execute([$start, $end]);
foreach ($blocked->fetchAll() as $row) {
$bookedDays[] = $row['block_date'];
}
echo json_encode([
'month' => $month,
'year' => $year,
'booked_dates' => array_values(array_unique($bookedDays)),
]);
@@ -0,0 +1,2 @@
RewriteEngine On
RewriteRule .* - [F,L]
@@ -0,0 +1,91 @@
docRoot $VH_ROOT/public_html
vhDomain $VH_NAME
vhAliases www.$VH_NAME
adminEmails admin@parkerslingshotrentals.com
enableGzip 1
enableIpGeo 1
index {
useServer 0
indexFiles index.php, index.html
}
errorlog $VH_ROOT/logs/$VH_NAME.error_log {
useServer 0
logLevel WARN
rollingSize 10M
}
accesslog $VH_ROOT/logs/$VH_NAME.access_log {
useServer 0
logFormat "%h %l %u %t "%r" %>s %b "%{Referer}i" "%{User-Agent}i""
logHeaders 5
rollingSize 10M
keepDays 10
compressArchive 1
}
scripthandler {
add lsapi:parke1909 php
}
extprocessor parke1909 {
type lsapi
address UDS://tmp/lshttpd/parke1909.sock
maxConns 10
env LSAPI_CHILDREN=10
initTimeout 600
retryTimeout 0
persistConn 1
pcKeepAliveTimeout 1
respBuffer 0
autoStart 1
path /usr/local/lsws/lsphp85/bin/lsphp
extUser parke1909
extGroup parke1909
memSoftLimit 1024M
memHardLimit 1024M
procSoftLimit 400
procHardLimit 500
}
phpIniOverride {
}
module cache {
storagePath /usr/local/lsws/cachedata/$VH_NAME
}
rewrite {
enable 1
autoLoadHtaccess 1
}
context /.well-known/acme-challenge {
location /usr/local/lsws/Example/html/.well-known/acme-challenge
allowBrowse 1
rewrite {
enable 0
}
addDefaultCharset off
phpIniOverride {
}
}
vhssl {
keyFile /etc/letsencrypt/live/parkerslingshotrentals.com/privkey.pem
certFile /etc/letsencrypt/live/parkerslingshotrentals.com/fullchain.pem
certChain 1
sslProtocol 24
enableECDHE 1
renegProtection 1
sslSessionCache 1
enableSpdy 15
enableStapling 1
ocspRespMaxAge 86400
}
@@ -0,0 +1,91 @@
docRoot $VH_ROOT/public_html
vhDomain $VH_NAME
vhAliases www.$VH_NAME
adminEmails admin@parkerslingshotrentals.com
enableGzip 1
enableIpGeo 1
index {
useServer 0
indexFiles index.php, index.html
}
errorlog $VH_ROOT/logs/$VH_NAME.error_log {
useServer 0
logLevel WARN
rollingSize 10M
}
accesslog $VH_ROOT/logs/$VH_NAME.access_log {
useServer 0
logFormat "%h %l %u %t "%r" %>s %b "%{Referer}i" "%{User-Agent}i""
logHeaders 5
rollingSize 10M
keepDays 10
compressArchive 1
}
scripthandler {
add lsapi:parke1909 php
}
extprocessor parke1909 {
type lsapi
address UDS://tmp/lshttpd/parke1909.sock
maxConns 10
env LSAPI_CHILDREN=10
initTimeout 600
retryTimeout 0
persistConn 1
pcKeepAliveTimeout 1
respBuffer 0
autoStart 1
path /usr/local/lsws/lsphp85/bin/lsphp
extUser parke1909
extGroup parke1909
memSoftLimit 1024M
memHardLimit 1024M
procSoftLimit 400
procHardLimit 500
}
phpIniOverride {
}
module cache {
storagePath /usr/local/lsws/cachedata/$VH_NAME
}
rewrite {
enable 1
autoLoadHtaccess 1
}
context /.well-known/acme-challenge {
location /usr/local/lsws/Example/html/.well-known/acme-challenge
allowBrowse 1
rewrite {
enable 0
}
addDefaultCharset off
phpIniOverride {
}
}
vhssl {
keyFile /etc/letsencrypt/live/parkerslingshotrentals.com/privkey.pem
certFile /etc/letsencrypt/live/parkerslingshotrentals.com/fullchain.pem
certChain 1
sslProtocol 24
enableECDHE 1
renegProtection 1
sslSessionCache 1
enableSpdy 15
enableStapling 1
ocspRespMaxAge 86400
}
@@ -0,0 +1,130 @@
head 1.2;
access;
symbols;
locks
root:1.2; strict;
comment @# @;
1.2
date 2026.05.17.23.58.24; author root; state Exp;
branches;
next 1.1;
1.1
date 2026.05.17.23.58.09; author root; state Exp;
branches;
next ;
desc
@/usr/local/lsws/conf/vhosts/parkerslingshotrentals.com/vhost.conf0
@
1.2
log
@Update
@
text
@docRoot $VH_ROOT/public_html
vhDomain $VH_NAME
vhAliases www.$VH_NAME
adminEmails admin@@parkerslingshotrentals.com
enableGzip 1
enableIpGeo 1
index {
useServer 0
indexFiles index.php, index.html
}
errorlog $VH_ROOT/logs/$VH_NAME.error_log {
useServer 0
logLevel WARN
rollingSize 10M
}
accesslog $VH_ROOT/logs/$VH_NAME.access_log {
useServer 0
logFormat "%h %l %u %t "%r" %>s %b "%{Referer}i" "%{User-Agent}i""
logHeaders 5
rollingSize 10M
keepDays 10
compressArchive 1
}
scripthandler {
add lsapi:parke1909 php
}
extprocessor parke1909 {
type lsapi
address UDS://tmp/lshttpd/parke1909.sock
maxConns 10
env LSAPI_CHILDREN=10
initTimeout 600
retryTimeout 0
persistConn 1
pcKeepAliveTimeout 1
respBuffer 0
autoStart 1
path /usr/local/lsws/lsphp85/bin/lsphp
extUser parke1909
extGroup parke1909
memSoftLimit 1024M
memHardLimit 1024M
procSoftLimit 400
procHardLimit 500
}
phpIniOverride {
}
module cache {
storagePath /usr/local/lsws/cachedata/$VH_NAME
}
rewrite {
enable 1
autoLoadHtaccess 1
}
context /.well-known/acme-challenge {
location /usr/local/lsws/Example/html/.well-known/acme-challenge
allowBrowse 1
rewrite {
enable 0
}
addDefaultCharset off
phpIniOverride {
}
}
vhssl {
keyFile /etc/letsencrypt/live/parkerslingshotrentals.com/privkey.pem
certFile /etc/letsencrypt/live/parkerslingshotrentals.com/fullchain.pem
certChain 1
sslProtocol 24
enableECDHE 1
renegProtection 1
sslSessionCache 1
enableSpdy 15
enableStapling 1
ocspRespMaxAge 86400
}
@
1.1
log
@Update
@
text
@d79 13
@
@@ -0,0 +1,187 @@
<?php
require_once __DIR__ . '/db.php';
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: ' . SITE_URL . '');
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; }
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); echo json_encode(['success'=>false,'error'=>'Method not allowed']); exit; }
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
$name = trim(strip_tags($input['name'] ?? ''));
$email = trim(strip_tags($input['email'] ?? ''));
$phone = trim(strip_tags($input['phone'] ?? ''));
$package = trim(strip_tags($input['package'] ?? ''));
$date = trim(strip_tags($input['date'] ?? ''));
$message = trim(strip_tags($input['message'] ?? ''));
$squareToken = trim($input['square_token'] ?? '');
if (!$name || !$email || !$package || !$date) {
http_response_code(400);
echo json_encode(['success'=>false,'error'=>'Name, email, package, and date are required.']); exit;
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
http_response_code(400);
echo json_encode(['success'=>false,'error'=>'Invalid email address.']); exit;
}
if (!isset(PACKAGES[$package])) {
http_response_code(400);
echo json_encode(['success'=>false,'error'=>'Invalid package.']); exit;
}
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) || strtotime($date) < strtotime('today')) {
http_response_code(400);
echo json_encode(['success'=>false,'error'=>'Invalid or past date.']); exit;
}
$pkg = PACKAGES[$package];
$rentalDate = $date;
$endDate = date('Y-m-d', strtotime($date . ' +' . $pkg['days'] . ' days'));
// Check availability + create booking atomically. A named advisory lock (scoped to the
// requested date range) prevents two concurrent submissions from both passing the
// conflict check before either has inserted, which would otherwise double-book the
// vehicle for overlapping dates.
$lockName = 'psr_booking_' . $rentalDate . '_' . $endDate;
$gotLock = (int) db()->query("SELECT GET_LOCK('" . addslashes($lockName) . "', 5)")->fetchColumn();
if (!$gotLock) {
http_response_code(503);
echo json_encode(['success'=>false,'error'=>'Please try again in a moment.']); exit;
}
try {
$conflict = db()->prepare(
"SELECT id FROM bookings
WHERE status IN ('pending','confirmed')
AND rental_date <= ? AND end_date >= ?"
);
$conflict->execute([$endDate, $rentalDate]);
if ($conflict->fetch()) {
echo json_encode(['success'=>false,'error'=>'Sorry, that date is already booked. Please choose another date.']); exit;
}
$blockedCheck = db()->prepare("SELECT id FROM blocked_dates WHERE block_date BETWEEN ? AND ?");
$blockedCheck->execute([$rentalDate, $endDate]);
if ($blockedCheck->fetch()) {
echo json_encode(['success'=>false,'error'=>'That date is unavailable. Please choose another date.']); exit;
}
// Create booking
$ref = generateRef();
$stmt = db()->prepare(
"INSERT INTO bookings (booking_ref, name, email, phone, package, rental_date, end_date, amount, notes)
VALUES (?,?,?,?,?,?,?,?,?)"
);
$stmt->execute([$ref, $name, $email, $phone, $package, $rentalDate, $endDate, $pkg['amount'], $message]);
} finally {
db()->query("SELECT RELEASE_LOCK('" . addslashes($lockName) . "')");
}
$dateLabel = date('F j, Y', strtotime($rentalDate));
$pkgLabel = $pkg['label'];
$amountLabel = '$' . number_format($pkg['amount'], 2);
$depositLabel = '$' . number_format(DEPOSIT_AMOUNT, 2);
$balance = $pkg['amount'] - DEPOSIT_AMOUNT;
$balanceLabel = '$' . number_format($balance, 2);
// Admin email
$adminHtml = "<div style='max-width:600px;margin:0 auto;font-family:Arial,sans-serif'>
<div style='background:#f97316;padding:20px;text-align:center'>
<h1 style='color:#fff;margin:0;font-size:20px'>New Booking Request {$ref}</h1>
</div>
<div style='padding:24px;background:#fff;border:1px solid #e5e7eb'>
<table style='width:100%;font-size:15px'>
<tr><td style='color:#6b7280;padding:8px 0;width:110px'>Ref</td><td style='padding:8px 0;font-weight:700'>{$ref}</td></tr>
<tr><td style='color:#6b7280;padding:8px 0'>Name</td><td style='padding:8px 0'>" . htmlspecialchars($name) . "</td></tr>
<tr><td style='color:#6b7280;padding:8px 0'>Email</td><td style='padding:8px 0'>" . htmlspecialchars($email) . "</td></tr>
<tr><td style='color:#6b7280;padding:8px 0'>Phone</td><td style='padding:8px 0'>" . htmlspecialchars($phone ?: '—') . "</td></tr>
<tr><td style='color:#6b7280;padding:8px 0'>Package</td><td style='padding:8px 0;font-weight:700;color:#f97316'>{$pkgLabel} {$amountLabel}</td></tr>
<tr><td style='color:#6b7280;padding:8px 0'>Date</td><td style='padding:8px 0;font-weight:700'>{$dateLabel}</td></tr>
<tr><td style='color:#6b7280;padding:8px 0'>Deposit Hold</td><td style='padding:8px 0'>{$depositLabel} (card held not charged yet)</td></tr>
<tr><td style='color:#6b7280;padding:8px 0'>Balance Due</td><td style='padding:8px 0;font-weight:700;color:#16a34a'>{$balanceLabel} at pickup</td></tr>
</table>
" . ($message ? "<div style='margin-top:12px;padding:12px;background:#fff7ed;border-left:4px solid #f97316'>" . nl2br(htmlspecialchars($message)) . "</div>" : "") . "
<p style='margin-top:16px;font-size:13px;color:#9ca3af'>Submitted " . date('F j, Y g:i A') . " CT</p>
</div>
</div>";
// Customer confirmation email
$confirmHtml = "<div style='max-width:600px;margin:0 auto;font-family:Arial,sans-serif'>
<div style='background:#0d0d0d;padding:24px;text-align:center'>
<h1 style='color:#f97316;margin:0;font-size:20px'>Parker County Slingshot Rentals</h1>
</div>
<div style='padding:32px;background:#fff'>
<h2 style='margin-top:0;color:#0d0d0d'>Booking Request Received!</h2>
<p style='color:#374151'>Hey " . htmlspecialchars($name) . ", your request is in. We'll confirm availability and reach out within a few hours.</p>
<div style='background:#fff7ed;border:1px solid #fed7aa;border-radius:10px;padding:20px;margin:20px 0'>
<p style='margin:0 0 6px;font-size:13px;color:#9ca3af;text-transform:uppercase;letter-spacing:1px'>Booking Reference</p>
<p style='margin:0 0 16px;font-size:22px;font-weight:700;color:#f97316'>{$ref}</p>
<p style='margin:4px 0;font-size:14px;color:#374151'><strong>Package:</strong> {$pkgLabel}</p>
<p style='margin:4px 0;font-size:14px;color:#374151'><strong>Requested Date:</strong> {$dateLabel}</p>
<p style='margin:4px 0;font-size:14px;color:#374151'><strong>Total:</strong> {$amountLabel}</p>
<p style='margin:4px 0;font-size:14px;color:#374151'><strong>Deposit (card hold today):</strong> {$depositLabel} <span style='font-size:12px;color:#9ca3af'> not charged until confirmed</span></p>
<p style='margin:4px 0;font-size:14px;color:#374151'><strong>Balance due at pickup:</strong> <span style='font-weight:700;color:#16a34a'>{$balanceLabel}</span></p>
</div>
<div style='margin:20px 0;padding:16px;background:#fff7ed;border:1px solid #fed7aa;border-radius:10px;text-align:center'>
<p style='margin:0 0 10px;font-size:14px;font-weight:700;color:#111'>Next Step: Sign Your Rental Agreement</p>
<p style='margin:0 0 14px;font-size:13px;color:#6b7280'>Once your booking is confirmed you'll sign our digital waiver online no printer needed. Your link:</p>
<a href='" . SITE_URL . "/waiver.php?ref={$ref}' style='display:inline-block;background:#f97316;color:#fff;text-decoration:none;padding:10px 24px;border-radius:6px;font-weight:700;font-size:14px'>Sign Rental Agreement &rarr;</a>
</div>
<p style='color:#374151'>Questions? Call or text <strong>(817) 266-2022</strong> or reply to this email.</p>
<p style='color:#374151'>Ride on,<br><strong>The Parker County Slingshot 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') . " Parker County Slingshot Rentals &mdash; Weatherford, TX</p>
</div>
</div>";
// Square deposit authorization (delayed capture — hold only, not charged yet)
$depositStatus = null;
$sqId = null;
if ($squareToken) {
$sqResp = squareApi('POST', '/payments', [
'source_id' => $squareToken,
// Stable per booking ref (not time()-suffixed) so a network retry or
// accidental double-submit of this same deposit hold is recognized by
// Square as a duplicate instead of placing a second hold on the card.
'idempotency_key' => $ref . '-dep',
'amount_money' => ['amount' => (int)(DEPOSIT_AMOUNT * 100), 'currency' => 'USD'],
'autocomplete' => false, // hold only — capture when confirmed
'location_id' => SQUARE_LOCATION_ID,
'note' => "Deposit hold — booking {$ref}",
'reference_id' => $ref,
'buyer_email_address' => $email,
]);
if (!empty($sqResp['payment']['id'])) {
$sqId = $sqResp['payment']['id'];
$sqSts = $sqResp['payment']['status']; // APPROVED
db()->prepare("UPDATE bookings SET square_payment_id=?, square_payment_status=? WHERE booking_ref=?")
->execute([$sqId, $sqSts, $ref]);
$depositStatus = $sqSts;
}
}
// Add Square authorization badge to customer email if hold was placed
if ($depositStatus) {
$confirmHtml = str_replace(
"<p style='margin:4px 0;font-size:14px;color:#374151'><strong>Deposit (card hold today):</strong> {$depositLabel}",
"<p style='margin:4px 0;font-size:14px;color:#374151'><strong>Deposit (card hold today):</strong> {$depositLabel} <span style='font-size:12px;color:#16a34a;font-weight:700'>✓ Authorized</span>",
$confirmHtml
);
}
// Add deposit note to admin email if applicable
if ($depositStatus) {
$adminHtml = str_replace(
"<p style='margin-top:16px;font-size:13px;color:#9ca3af'>",
"<p style='margin-top:8px;font-size:13px;color:#16a34a;font-weight:700'>✓ \$" . number_format(DEPOSIT_AMOUNT, 2) . " deposit hold authorized (Square — not yet captured)</p><p style='margin-top:8px;font-size:13px;color:#9ca3af'>",
$adminHtml
);
}
sendEmail(ADMIN_EMAIL, 'Parker Slingshot Admin', "New Booking {$ref}: {$name}{$pkgLabel} on {$dateLabel}", $adminHtml);
sendEmail($email, $name, "Booking Request {$ref} — Parker County Slingshot Rentals", $confirmHtml);
$msg = "Booking request received! Your reference is {$ref}. We'll be in touch shortly.";
if ($depositStatus) $msg .= " A \$" . number_format(DEPOSIT_AMOUNT, 2) . " refundable deposit hold has been placed on your card.";
echo json_encode(['success'=>true,'ref'=>$ref,'deposit_held'=>(bool)$depositStatus,'square_payment_id'=>$sqId,'message'=>$msg]);
@@ -0,0 +1,2 @@
<?php
require_once dirname(__DIR__) . "/db-secrets.php";
@@ -0,0 +1,2 @@
RewriteEngine On
RewriteRule .* - [F,L]
@@ -0,0 +1,208 @@
/*M!999999\- enable the sandbox mode */
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!40101 SET NAMES utf8mb4 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
DROP TABLE IF EXISTS `admin_tokens`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `admin_tokens` (
`token` varchar(64) NOT NULL,
`expires_at` datetime NOT NULL,
`created_at` datetime DEFAULT current_timestamp(),
PRIMARY KEY (`token`),
KEY `idx_expires` (`expires_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `blocked_dates`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `blocked_dates` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`block_date` date NOT NULL,
`reason` varchar(200) DEFAULT NULL,
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `block_date` (`block_date`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `bookings`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `bookings` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`booking_ref` varchar(12) NOT NULL,
`name` varchar(120) NOT NULL,
`email` varchar(180) NOT NULL,
`phone` varchar(30) DEFAULT NULL,
`package` enum('half-day','full-day','weekend') NOT NULL,
`rental_date` date NOT NULL,
`end_date` date NOT NULL,
`status` enum('pending','confirmed','completed','cancelled') NOT NULL DEFAULT 'pending',
`amount` decimal(8,2) NOT NULL,
`deposit_paid` decimal(8,2) NOT NULL DEFAULT 0.00,
`notes` text DEFAULT NULL,
`admin_notes` text DEFAULT NULL,
`waiver_signed` tinyint(1) NOT NULL DEFAULT 0,
`waiver_signed_at` datetime DEFAULT NULL,
`waiver_ip` varchar(45) DEFAULT NULL,
`waiver_name` varchar(160) DEFAULT NULL,
`waiver_sig` mediumtext DEFAULT NULL,
`insurance_verified` tinyint(1) NOT NULL DEFAULT 0,
`insurance_file` varchar(255) DEFAULT NULL,
`deposit_received` tinyint(1) NOT NULL DEFAULT 0,
`license_verified` tinyint(1) NOT NULL DEFAULT 0,
`license_file` varchar(255) DEFAULT NULL,
`helmet_provided` tinyint(1) NOT NULL DEFAULT 0,
`safety_course` tinyint(1) NOT NULL DEFAULT 0,
`operational_course` tinyint(1) NOT NULL DEFAULT 0,
`slingshot_returned` tinyint(1) NOT NULL DEFAULT 0,
`returned_at` datetime DEFAULT NULL,
`square_payment_id` varchar(64) DEFAULT NULL,
`square_payment_status` varchar(20) DEFAULT NULL,
`square_refund_id` varchar(64) DEFAULT NULL,
`square_customer_id` varchar(64) DEFAULT NULL,
`square_card_id` varchar(64) DEFAULT NULL,
`card_last4` varchar(4) DEFAULT NULL,
`card_brand` varchar(20) DEFAULT NULL,
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
`updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `booking_ref` (`booking_ref`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `pcs_admins`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `pcs_admins` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`email` varchar(255) NOT NULL,
`password_hash` varchar(255) NOT NULL,
`name` varchar(100) DEFAULT NULL,
`created_at` datetime DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `email` (`email`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `pcs_auth_tokens`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `pcs_auth_tokens` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`admin_id` int(11) NOT NULL,
`token` varchar(64) NOT NULL,
`created_at` datetime DEFAULT current_timestamp(),
`expires_at` datetime NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `token` (`token`),
KEY `idx_token` (`token`),
KEY `idx_expires` (`expires_at`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `pcs_blocked_dates`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `pcs_blocked_dates` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`blocked_date` date NOT NULL,
`reason` varchar(255) DEFAULT NULL,
`created_at` datetime DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `blocked_date` (`blocked_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `pcs_bookings`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `pcs_bookings` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`booking_ref` varchar(20) NOT NULL,
`customer_id` int(11) NOT NULL,
`fleet_id` int(11) DEFAULT NULL,
`start_date` date NOT NULL,
`end_date` date NOT NULL,
`days` int(11) NOT NULL,
`price_per_day` decimal(10,2) NOT NULL,
`total_amount` decimal(10,2) NOT NULL,
`deposit_amount` decimal(10,2) DEFAULT 0.00,
`status` enum('pending','approved','denied','active','completed','cancelled') DEFAULT 'pending',
`payment_status` enum('unpaid','deposit_paid','paid','refunded') DEFAULT 'unpaid',
`payment_method` enum('cash','debit','credit') DEFAULT 'cash',
`license_file` varchar(255) DEFAULT NULL,
`insurance_file` varchar(255) DEFAULT NULL,
`docs_approved` tinyint(1) DEFAULT 0,
`customer_notes` text DEFAULT NULL,
`admin_notes` text DEFAULT NULL,
`created_at` datetime DEFAULT current_timestamp(),
`updated_at` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(),
`square_payment_id` varchar(100) DEFAULT NULL,
`square_terminal_id` varchar(100) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `booking_ref` (`booking_ref`),
KEY `idx_dates` (`start_date`,`end_date`),
KEY `idx_status` (`status`),
KEY `idx_customer` (`customer_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `pcs_customers`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `pcs_customers` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`email` varchar(255) NOT NULL,
`password_hash` varchar(255) DEFAULT NULL,
`name` varchar(150) NOT NULL,
`phone` varchar(30) DEFAULT NULL,
`dob` date DEFAULT NULL,
`address` text DEFAULT NULL,
`created_at` datetime DEFAULT current_timestamp(),
`is_active` tinyint(1) DEFAULT 1,
`notes` text DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `email` (`email`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `pcs_fleet`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `pcs_fleet` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(100) NOT NULL,
`color` varchar(50) DEFAULT NULL,
`year` int(11) DEFAULT NULL,
`model` varchar(100) DEFAULT NULL,
`is_available` tinyint(1) DEFAULT 1,
`notes` text DEFAULT NULL,
`created_at` datetime DEFAULT current_timestamp(),
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `pcs_settings`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `pcs_settings` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`setting_key` varchar(100) NOT NULL,
`setting_value` text DEFAULT NULL,
`updated_at` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `setting_key` (`setting_key`)
) ENGINE=InnoDB AUTO_INCREMENT=32 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
@@ -0,0 +1,79 @@
/*M!999999\- enable the sandbox mode */
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!40101 SET NAMES utf8mb4 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
DROP TABLE IF EXISTS `admin_tokens`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `admin_tokens` (
`token` varchar(64) NOT NULL,
`expires_at` datetime NOT NULL,
`created_at` datetime DEFAULT current_timestamp(),
PRIMARY KEY (`token`),
KEY `idx_expires` (`expires_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `blocked_dates`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `blocked_dates` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`block_date` date NOT NULL,
`reason` varchar(200) DEFAULT NULL,
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `block_date` (`block_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `bookings`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `bookings` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`booking_ref` varchar(12) NOT NULL,
`name` varchar(120) NOT NULL,
`email` varchar(180) NOT NULL,
`phone` varchar(30) DEFAULT NULL,
`package` enum('half-day','full-day','weekend') NOT NULL,
`rental_date` date NOT NULL,
`end_date` date NOT NULL,
`status` enum('pending','confirmed','completed','cancelled') NOT NULL DEFAULT 'pending',
`amount` decimal(8,2) NOT NULL,
`deposit_paid` decimal(8,2) NOT NULL DEFAULT 0.00,
`notes` text DEFAULT NULL,
`admin_notes` text DEFAULT NULL,
`waiver_signed` tinyint(1) NOT NULL DEFAULT 0,
`waiver_signed_at` datetime DEFAULT NULL,
`waiver_ip` varchar(45) DEFAULT NULL,
`waiver_name` varchar(160) DEFAULT NULL,
`waiver_sig` mediumtext DEFAULT NULL,
`insurance_verified` tinyint(1) NOT NULL DEFAULT 0,
`deposit_received` tinyint(1) NOT NULL DEFAULT 0,
`license_verified` tinyint(1) NOT NULL DEFAULT 0,
`square_payment_id` varchar(64) DEFAULT NULL,
`square_payment_status` varchar(20) DEFAULT NULL,
`square_refund_id` varchar(64) DEFAULT NULL,
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
`updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `booking_ref` (`booking_ref`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://parkerslingshotrentals.com/sitemap.xml
@@ -0,0 +1,12 @@
<?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://parkerslingshotrentals.com/</loc>
<lastmod>2026-05-19</lastmod>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
</urlset>
@@ -0,0 +1,187 @@
<?php
require_once __DIR__ . '/db.php';
header('X-Frame-Options: SAMEORIGIN');
header('X-Content-Type-Options: nosniff');
$ref = strtoupper(trim($_GET['ref'] ?? ''));
$type = in_array($_GET['type'] ?? '', ['license','insurance']) ? $_GET['type'] : '';
$error = '';
$done = false;
$booking = null;
if ($ref && $type) {
$stmt = db()->prepare("SELECT id, name, email, booking_ref, rental_date, status FROM bookings WHERE booking_ref=?");
$stmt->execute([$ref]);
$booking = $stmt->fetch();
if (!$booking) $error = 'Booking not found. Please check your confirmation email.';
elseif ($booking['status'] === 'cancelled') $error = 'This booking has been cancelled.';
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $booking && !$error) {
$file = $_FILES['doc'] ?? null;
if (!$file || $file['error'] !== UPLOAD_ERR_OK) {
$error = 'Upload failed — please try again or check file size.';
} else {
$mime = detectAllowedMime($file['tmp_name']);
if (!$mime) {
$error = 'Only JPG, PNG, or PDF files are accepted.';
} elseif ($file['size'] > 10 * 1024 * 1024) {
$error = 'File must be under 10 MB.';
} else {
$ext = ALLOWED_DOC_MIMES[$mime];
$dir = __DIR__ . '/uploads/' . $ref;
if (!is_dir($dir)) mkdir($dir, 0750, true);
$fname = $type . '_' . date('YmdHis') . '.' . $ext;
$dest = $dir . '/' . $fname;
if (move_uploaded_file($file['tmp_name'], $dest)) {
$col = $type === 'license' ? 'license_file' : 'insurance_file';
$rel = 'uploads/' . $ref . '/' . $fname;
db()->prepare("UPDATE bookings SET {$col}=? WHERE booking_ref=?")->execute([$rel, $ref]);
$typeLabel = $type === 'license' ? "Driver's License" : 'Proof of Insurance';
$dateLabel = date('F j, Y', strtotime($booking['rental_date']));
$adminHtml = "<div style='font-family:Arial,sans-serif;max-width:560px;margin:0 auto'>
<div style='background:#f97316;padding:18px;text-align:center'>
<h1 style='color:#fff;margin:0;font-size:18px'>{$typeLabel} Uploaded {$booking['booking_ref']}</h1>
</div>
<div style='padding:24px;background:#fff;border:1px solid #e5e7eb'>
<p><strong>" . htmlspecialchars($booking['name']) . "</strong> uploaded their <strong>{$typeLabel}</strong> for booking <strong>{$booking['booking_ref']}</strong> (rental: {$dateLabel}).</p>
<p style='margin-top:12px;font-size:13px;color:#6b7280'>View it in the admin panel under their booking detail.</p>
<div style='margin-top:16px'><a href='" . SITE_URL . "/admin/' style='display:inline-block;background:#f97316;color:#fff;text-decoration:none;padding:10px 22px;border-radius:6px;font-weight:700;font-size:13px'>Open Admin Panel &rarr;</a></div>
</div>
</div>";
sendEmail(ADMIN_EMAIL, 'Parker Slingshot Admin', "{$typeLabel} Uploaded — {$booking['booking_ref']}: " . $booking['name'], $adminHtml);
$done = true;
} else {
$error = 'Could not save file. Please try again.';
}
}
}
}
$typeLabel = $type === 'license' ? "Driver's License" : ($type === 'insurance' ? 'Proof of Insurance' : '');
$dateLabel = $booking ? date('F j, Y', strtotime($booking['rental_date'])) : '';
?><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Upload Document Parker County Slingshot Rentals</title>
<meta name="robots" content="noindex,nofollow" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Barlow+Condensed:wght@700;800&display=swap" rel="stylesheet" />
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root { --orange: #f97316; --black: #0d0d0d; }
body { font-family: 'Inter', sans-serif; background: #f3f4f6; color: #111; }
header { background: var(--black); padding: 1.25rem 2rem; display: flex; align-items: center; justify-content: space-between; }
header a { font-family: 'Barlow Condensed', sans-serif; font-size: 1.3rem; font-weight: 800; color: var(--orange); text-decoration: none; }
header span { font-size: 0.85rem; color: rgba(255,255,255,0.4); }
.wrap { max-width: 560px; margin: 2.5rem auto; padding: 0 1rem 4rem; }
.card { background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); padding: 2rem 2.5rem; }
@media (max-width: 560px) { .card { padding: 1.5rem; } }
h1 { font-family: 'Barlow Condensed', sans-serif; font-size: 1.9rem; font-weight: 800; margin-bottom: 0.25rem; }
.booking-badge { display: inline-block; background: #fff7ed; border: 1px solid #fed7aa; border-radius: 8px; padding: 0.6rem 1rem; margin: 1rem 0 1.5rem; }
.booking-badge .ref { font-size: 1.1rem; font-weight: 700; color: var(--orange); }
.booking-badge .meta { font-size: 0.82rem; color: #6b7280; margin-top: 2px; }
.upload-area { border: 2px dashed #d1d5db; border-radius: 10px; padding: 2rem; text-align: center; cursor: pointer; transition: border-color .2s, background .2s; background: #fafafa; position: relative; margin: 1rem 0; }
.upload-area:hover, .upload-area.drag { border-color: var(--orange); background: #fff7ed; }
.upload-area input[type=file] { position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; height: 100%; }
.upload-icon { font-size: 2.5rem; margin-bottom: 0.5rem; }
.upload-area p { font-size: 0.9rem; color: #6b7280; margin: 0; }
.upload-area .file-name { font-size: 0.88rem; color: var(--orange); font-weight: 600; margin-top: 0.5rem; display: none; }
.btn { display: block; width: 100%; background: var(--orange); color: #fff; border: none; border-radius: 8px; padding: 0.9rem; font-size: 1rem; font-weight: 700; cursor: pointer; transition: background .2s; margin-top: 1rem; }
.btn:hover { background: #ea580c; }
.btn:disabled { background: #d1d5db; cursor: not-allowed; }
.alert { padding: 0.85rem 1rem; border-radius: 8px; font-size: 0.9rem; margin-bottom: 1.25rem; }
.alert-error { background: rgba(239,68,68,.08); border: 1px solid rgba(239,68,68,.25); color: #dc2626; }
.success-icon { font-size: 3rem; text-align: center; margin-bottom: 1rem; }
.success-box { text-align: center; padding: .5rem 0; }
.success-box h1 { color: #16a34a; margin-bottom: 0.5rem; }
.success-box p { color: #374151; font-size: 0.95rem; max-width: 400px; margin: 0 auto .75rem; }
.hint { font-size: 0.8rem; color: #9ca3af; margin-top: 0.5rem; text-align: center; }
</style>
</head>
<body>
<header>
<a href="/">Parker County Slingshot Rentals</a>
<span>Document Upload</span>
</header>
<div class="wrap">
<?php if (!$ref || !$type || (!$booking && !$error)): ?>
<div class="card">
<h1>Upload Document</h1>
<p style="color:#6b7280;margin-top:.5rem">Invalid or missing upload link. Please use the link from your email or contact us.</p>
</div>
<?php elseif ($error && !$booking): ?>
<div class="card">
<div class="alert alert-error"><?= htmlspecialchars($error) ?></div>
<p style="color:#6b7280;font-size:.9rem">Need help? Call or text <strong>(817) 266-2022</strong>.</p>
</div>
<?php elseif ($done): ?>
<div class="card">
<div class="success-icon"></div>
<div class="success-box">
<h1>Upload Received!</h1>
<p>Thanks, <?= htmlspecialchars($booking['name']) ?>! Your <strong><?= htmlspecialchars($typeLabel) ?></strong> has been submitted for booking <strong><?= htmlspecialchars($booking['booking_ref']) ?></strong>.</p>
<p style="color:#6b7280;font-size:.85rem">We'll review it and still do a quick visual check at pickup. See you on <?= htmlspecialchars($dateLabel) ?>!</p>
</div>
</div>
<?php else: ?>
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
<div class="card">
<h1>Upload <?= htmlspecialchars($typeLabel) ?></h1>
<div class="booking-badge">
<div class="ref"><?= htmlspecialchars($booking['booking_ref']) ?></div>
<div class="meta"><?= htmlspecialchars($booking['name']) ?> &mdash; <?= htmlspecialchars($dateLabel) ?></div>
</div>
<p style="color:#374151;font-size:.9rem;margin-bottom:.25rem">
<?php if ($type === 'insurance'): ?>
Please upload a photo or scan of your current auto insurance card. JPG, PNG, or PDF accepted (max 10 MB).
<?php else: ?>
Please upload a photo or scan of the front of your driver's license. JPG, PNG, or PDF accepted (max 10 MB).
<?php endif; ?>
</p>
<p style="color:#9ca3af;font-size:.8rem;margin-bottom:1rem">We'll still do a visual check at pickup this is just for our records.</p>
<form method="post" enctype="multipart/form-data" id="uploadForm">
<div class="upload-area" id="dropZone">
<input type="file" name="doc" id="docInput" accept=".jpg,.jpeg,.png,.pdf" required />
<div class="upload-icon">📎</div>
<p>Tap or drag your file here</p>
<p style="font-size:.78rem;margin-top:4px">JPG &bull; PNG &bull; PDF &bull; max 10 MB</p>
<div class="file-name" id="fileName"></div>
</div>
<button type="submit" class="btn" id="submitBtn">Upload <?= htmlspecialchars($typeLabel) ?></button>
</form>
<p class="hint">Your document is stored securely and only visible to Parker County Slingshot Rentals staff.</p>
</div>
<?php endif; ?>
</div>
<script>
(function(){
const input = document.getElementById('docInput');
const label = document.getElementById('fileName');
const zone = document.getElementById('dropZone');
const btn = document.getElementById('submitBtn');
if (!input) return;
input.addEventListener('change', function() {
if (this.files[0]) {
label.textContent = this.files[0].name;
label.style.display = 'block';
}
});
['dragover','dragenter'].forEach(e => zone.addEventListener(e, ev => { ev.preventDefault(); zone.classList.add('drag'); }));
['dragleave','drop'].forEach(e => zone.addEventListener(e, ev => zone.classList.remove('drag')));
document.getElementById('uploadForm')?.addEventListener('submit', function() {
if (btn) { btn.disabled = true; btn.textContent = 'Uploading…'; }
});
})();
</script>
</body>
</html>
@@ -0,0 +1,5 @@
# Block direct web access - documents served only through view-doc.php / admin/view-doc.php.
# Require all denied / Order deny,allow do NOT work on this OpenLiteSpeed setup -
# only RewriteRule [F,L] actually blocks requests here (confirmed this session).
RewriteEngine On
RewriteRule .* - [F,L]
@@ -0,0 +1,50 @@
<?php
require_once __DIR__ . '/db.php';
$token = preg_replace('/[^a-f0-9]/', '', $_GET['_t'] ?? '');
if (!verifyAdminToken($token)) {
http_response_code(403);
header('Content-Type: text/plain');
exit('Unauthorized — please log in to the admin panel first.');
}
$ref = strtoupper(preg_replace('/[^A-Z0-9\-]/', '', $_GET['ref'] ?? ''));
$type = in_array($_GET['type'] ?? '', ['license','insurance']) ? $_GET['type'] : '';
if (!$ref || !$type) {
http_response_code(400);
header('Content-Type: text/plain');
exit('Missing parameters.');
}
$col = $type === 'license' ? 'license_file' : 'insurance_file';
$stmt = db()->prepare("SELECT {$col} AS file_path FROM bookings WHERE booking_ref=?");
$stmt->execute([$ref]);
$row = $stmt->fetch();
if (!$row || !$row['file_path']) {
http_response_code(404);
header('Content-Type: text/plain');
exit('Document not found.');
}
$path = resolveUploadPath(__DIR__, $row['file_path']);
if (!$path) {
http_response_code(404);
header('Content-Type: text/plain');
exit('File not found.');
}
$mime = detectAllowedMime($path);
if (!$mime) {
http_response_code(403);
header('Content-Type: text/plain');
exit('Invalid file type.');
}
$fname = $type . '-' . $ref . '.' . ALLOWED_DOC_MIMES[$mime];
header('Content-Type: ' . $mime);
header('Content-Disposition: inline; filename="' . $fname . '"');
header('Content-Length: ' . filesize($path));
header('Cache-Control: private, max-age=3600');
readfile($path);
exit;
@@ -0,0 +1,365 @@
<?php
require_once __DIR__ . '/db.php';
header('X-Frame-Options: DENY');
header('X-Content-Type-Options: nosniff');
$ref = strtoupper(trim($_GET['ref'] ?? ''));
$error = '';
$booking = null;
$signed = false;
if ($ref) {
$stmt = db()->prepare("SELECT * FROM bookings WHERE booking_ref = ?");
$stmt->execute([$ref]);
$booking = $stmt->fetch();
if (!$booking) {
$error = 'Booking reference not found. Please check your confirmation email.';
} elseif ($booking['status'] === 'cancelled') {
$error = 'This booking has been cancelled. Please contact us if you have questions.';
} elseif ($booking['waiver_signed']) {
$signed = true;
}
}
// Handle submission
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $booking && !$signed) {
$sigName = trim(strip_tags($_POST['sig_name'] ?? ''));
$sigData = $_POST['sig_data'] ?? ''; // base64 canvas PNG
$checks = (array)($_POST['checks'] ?? []);
$required = ['age','license','insurance','rules','damage','waiver'];
$missing = array_diff($required, $checks);
if (!$sigName) {
$error = 'Please type your full name to sign.';
} elseif ($missing) {
$error = 'Please check all required boxes before signing.';
} elseif (!$sigData || !preg_match('#^data:image/png;base64,[A-Za-z0-9+/]+={0,2}$#', $sigData) || strlen($sigData) > 300000) {
$error = 'Please draw your signature in the box above.';
} else {
$ip = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '';
$ip = explode(',', $ip)[0];
db()->prepare(
"UPDATE bookings SET waiver_signed=1, waiver_signed_at=NOW(), waiver_ip=?, waiver_name=?, waiver_sig=? WHERE booking_ref=?"
)->execute([trim($ip), $sigName, $sigData, $ref]);
$pkg = PACKAGES[$booking['package']] ?? ['label'=>$booking['package']];
$dateLabel = date('F j, Y', strtotime($booking['rental_date']));
$adminHtml = "<div style='font-family:Arial,sans-serif;max-width:600px;margin:0 auto'>
<div style='background:#f97316;padding:20px;text-align:center'>
<h1 style='color:#fff;margin:0;font-size:18px'>Waiver Signed {$ref}</h1>
</div>
<div style='padding:24px;background:#fff;border:1px solid #e5e7eb'>
<p><strong>" . htmlspecialchars($booking['name']) . "</strong> signed the rental waiver for booking <strong>{$ref}</strong>.</p>
<table style='width:100%;font-size:14px'>
<tr><td style='color:#6b7280;padding:6px 0;width:110px'>Package</td><td style='padding:6px 0'>" . htmlspecialchars($pkg['label']) . "</td></tr>
<tr><td style='color:#6b7280;padding:6px 0'>Date</td><td style='padding:6px 0'>{$dateLabel}</td></tr>
<tr><td style='color:#6b7280;padding:6px 0'>Signed by</td><td style='padding:6px 0'>" . htmlspecialchars($sigName) . "</td></tr>
<tr><td style='color:#6b7280;padding:6px 0'>IP</td><td style='padding:6px 0'>" . htmlspecialchars($ip) . "</td></tr>
<tr><td style='color:#6b7280;padding:6px 0'>Timestamp</td><td style='padding:6px 0'>" . date('F j, Y g:i A') . " CT</td></tr>
</table>
<img src='" . htmlspecialchars($sigData) . "' style='margin-top:16px;border:1px solid #e5e7eb;border-radius:6px;max-width:100%;height:auto' alt='Signature' />
</div>
</div>";
$custHtml = "<div style='font-family:Arial,sans-serif;max-width:600px;margin:0 auto'>
<div style='background:#0d0d0d;padding:24px;text-align:center'>
<h1 style='color:#f97316;margin:0;font-size:20px'>Parker County Slingshot Rentals</h1>
</div>
<div style='padding:32px;background:#fff'>
<h2 style='margin-top:0'>Waiver Signed You're All Set!</h2>
<p style='color:#374151'>Hey " . htmlspecialchars($booking['name']) . ", your rental agreement for booking <strong>{$ref}</strong> is signed and on file. See you on <strong>{$dateLabel}</strong>!</p>
<p style='color:#374151'>Remember to bring:</p>
<ul style='color:#374151'>
<li>Valid driver's license</li>
<li>Proof of personal auto insurance</li>
</ul>
<p style='color:#374151'>Questions? Call or text <strong>" . ADMIN_PHONE . "</strong>.</p>
<p style='color:#374151'>Ride on,<br><strong>The Parker County Slingshot 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') . " Parker County Slingshot Rentals &mdash; Weatherford, TX</p>
</div>
</div>";
sendEmail(ADMIN_EMAIL, 'Parker Slingshot Admin', "Waiver Signed: {$ref}" . $booking['name'], $adminHtml);
sendEmail($booking['email'], $booking['name'], "Rental Agreement Signed — {$ref}", $custHtml);
$signed = true;
}
}
$pkgLabel = $booking ? (PACKAGES[$booking['package']]['label'] ?? $booking['package']) : '';
$dateLabel = $booking ? date('F j, Y', strtotime($booking['rental_date'])) : '';
?><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Rental Agreement Parker County Slingshot Rentals</title>
<meta name="robots" content="noindex,nofollow" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Barlow+Condensed:wght@700;800&display=swap" rel="stylesheet" />
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root { --orange: #f97316; --orange-dark: #ea580c; --black: #0d0d0d; }
body { font-family: 'Inter', sans-serif; background: #f3f4f6; color: #111; line-height: 1.6; }
header { background: var(--black); padding: 1.25rem 2rem; display: flex; align-items: center; justify-content: space-between; }
header a { font-family: 'Barlow Condensed', sans-serif; font-size: 1.3rem; font-weight: 800; color: var(--orange); text-decoration: none; }
header span { font-size: 0.85rem; color: rgba(255,255,255,0.4); }
.wrap { max-width: 760px; margin: 2.5rem auto; padding: 0 1rem 4rem; }
.card { background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); padding: 2rem 2.5rem; margin-bottom: 1.5rem; }
@media (max-width: 600px) { .card { padding: 1.5rem; } }
h1 { font-family: 'Barlow Condensed', sans-serif; font-size: 2rem; font-weight: 800; margin-bottom: 0.25rem; }
h2 { font-size: 1rem; font-weight: 700; color: var(--orange); text-transform: uppercase; letter-spacing: 1px; margin: 1.75rem 0 0.75rem; }
p { color: #374151; font-size: 0.95rem; margin-bottom: 0.75rem; }
.booking-banner { background: #fff7ed; border: 1px solid #fed7aa; border-radius: 10px; padding: 1.25rem 1.5rem; margin-bottom: 1.75rem; display: flex; gap: 2rem; flex-wrap: wrap; }
.bb-item { font-size: 0.9rem; }
.bb-label { color: #9ca3af; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 0.2rem; }
.bb-value { font-weight: 700; color: #111; }
.bb-ref { color: var(--orange); font-size: 1.1rem; }
.clause { display: flex; gap: 0.85rem; align-items: flex-start; padding: 0.85rem 0; border-bottom: 1px solid #f3f4f6; }
.clause:last-child { border-bottom: none; }
.clause input[type=checkbox] { margin-top: 3px; width: 18px; height: 18px; accent-color: var(--orange); flex-shrink: 0; cursor: pointer; }
.clause label { font-size: 0.92rem; color: #374151; cursor: pointer; }
.clause label strong { color: #111; }
.sig-wrap { border: 2px dashed #d1d5db; border-radius: 8px; overflow: hidden; position: relative; background: #fafafa; margin-top: 0.5rem; }
#sigCanvas { display: block; width: 100%; height: 160px; cursor: crosshair; touch-action: none; }
.sig-clear { position: absolute; top: 8px; right: 8px; background: rgba(0,0,0,0.06); border: none; border-radius: 6px; padding: 4px 10px; font-size: 0.78rem; cursor: pointer; color: #6b7280; }
.sig-clear:hover { background: rgba(239,68,68,0.1); color: #ef4444; }
.sig-hint { font-size: 0.78rem; color: #9ca3af; margin-top: 0.4rem; }
input[type=text] { width: 100%; border: 1px solid #d1d5db; border-radius: 8px; padding: 0.75rem 1rem; font-size: 1rem; font-family: inherit; outline: none; transition: border-color 0.2s; margin-top: 0.4rem; }
input[type=text]:focus { border-color: var(--orange); }
.btn-sign { display: block; width: 100%; background: var(--orange); color: white; border: none; border-radius: 8px; padding: 1rem; font-size: 1.05rem; font-weight: 700; cursor: pointer; transition: background 0.2s; margin-top: 1.5rem; }
.btn-sign:hover { background: var(--orange-dark); }
.btn-sign:disabled { background: #d1d5db; cursor: not-allowed; }
.alert { padding: 0.9rem 1.1rem; border-radius: 8px; font-size: 0.9rem; margin-bottom: 1rem; }
.alert-error { background: rgba(239,68,68,0.08); border: 1px solid rgba(239,68,68,0.25); color: #dc2626; }
.alert-success { background: rgba(34,197,94,0.08); border: 1px solid rgba(34,197,94,0.25); color: #16a34a; }
.success-icon { font-size: 3rem; text-align: center; margin-bottom: 1rem; }
.success-box { text-align: center; padding: 1rem 0; }
.success-box h1 { color: #16a34a; margin-bottom: 0.5rem; }
.success-box p { color: #374151; max-width: 480px; margin: 0 auto 1rem; }
.checklist { text-align: left; display: inline-block; margin: 1rem auto; }
.checklist li { padding: 0.35rem 0; color: #374151; font-size: 0.95rem; list-style: none; }
.checklist li::before { content: '✓ '; color: #16a34a; font-weight: 700; }
.back-link { text-align: center; margin-top: 1.5rem; }
.back-link a { color: var(--orange); text-decoration: none; font-weight: 600; font-size: 0.9rem; }
</style>
</head>
<body>
<header>
<a href="/">Parker County Slingshot Rentals</a>
<span>Rental Agreement</span>
</header>
<div class="wrap">
<?php if (!$ref || $error === 'Booking reference not found. Please check your confirmation email.'): ?>
<!-- No valid ref show lookup form -->
<div class="card">
<h1>Rental Agreement</h1>
<p style="margin:0.75rem 0 1.5rem;color:#6b7280;">Enter your booking reference from your confirmation email to access your rental agreement.</p>
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
<form method="get" action="/waiver.php">
<label style="font-weight:600;font-size:0.9rem;display:block;margin-bottom:0.4rem;">Booking Reference</label>
<input type="text" name="ref" placeholder="PSR-XXXXXX" value="<?= htmlspecialchars($ref) ?>" style="text-transform:uppercase" maxlength="12" required />
<button type="submit" class="btn-sign" style="margin-top:1rem;">Look Up My Booking</button>
</form>
</div>
<?php elseif ($signed): ?>
<!-- Already signed or just signed -->
<div class="card">
<div class="success-icon"></div>
<div class="success-box">
<h1>You're All Set!</h1>
<p>Your rental agreement for booking <strong><?= htmlspecialchars($ref) ?></strong> is signed and on file. We'll see you on <strong><?= htmlspecialchars($dateLabel) ?></strong>!</p>
<p style="color:#6b7280;font-size:0.88rem;">A confirmation was sent to <?= htmlspecialchars($booking['email']) ?>.</p>
<p style="font-weight:700;margin-top:1rem;margin-bottom:0.25rem;">Remember to bring:</p>
<ul class="checklist">
<li>Valid driver's license</li>
<li>Proof of personal auto insurance</li>
</ul>
</div>
<div class="back-link"><a href="/"> Back to parkerslingshotrentals.com</a></div>
</div>
<?php elseif ($booking): ?>
<!-- Waiver form -->
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
<div class="card">
<h1>Rental Agreement</h1>
<p style="color:#6b7280;margin-top:0.25rem;margin-bottom:1.5rem;">Please read and sign the agreement below for your upcoming rental.</p>
<div class="booking-banner">
<div class="bb-item"><div class="bb-label">Booking Ref</div><div class="bb-value bb-ref"><?= htmlspecialchars($booking['booking_ref']) ?></div></div>
<div class="bb-item"><div class="bb-label">Name</div><div class="bb-value"><?= htmlspecialchars($booking['name']) ?></div></div>
<div class="bb-item"><div class="bb-label">Package</div><div class="bb-value"><?= htmlspecialchars($pkgLabel) ?></div></div>
<div class="bb-item"><div class="bb-label">Rental Date</div><div class="bb-value"><?= htmlspecialchars($dateLabel) ?></div></div>
</div>
<h2>Rental Terms & Conditions</h2>
<p>This Rental Agreement ("Agreement") is entered into between Parker County Slingshot Rentals ("Company") and the renter identified above ("Renter"). By signing below, Renter agrees to all terms stated herein.</p>
<h2>Eligibility Requirements</h2>
<p>Renter must be at least 25 years of age and hold a valid Class C driver's license (or equivalent) issued by a U.S. state or territory. Renter must not have any DUI/DWI convictions within the past 5 years.</p>
<h2>Insurance Requirement</h2>
<p>Renter is required to carry and provide proof of valid personal auto insurance at the time of vehicle pickup. The Company maintains a fleet insurance policy covering the vehicle; however, Renter's personal insurance is primary for liability arising from Renter's operation of the vehicle. Renter accepts financial responsibility for any deductible, damages, or losses not covered by either policy.</p>
<h2>Vehicle Use & Rules</h2>
<p>Renter agrees to operate the Polaris Slingshot in a safe, lawful manner and specifically agrees to:</p>
<ul style="color:#374151;font-size:0.92rem;padding-left:1.25rem;margin-bottom:0.75rem;">
<li style="margin-bottom:0.35rem;">Obey all applicable traffic laws and speed limits</li>
<li style="margin-bottom:0.35rem;">Never operate the vehicle under the influence of alcohol, drugs, or any impairing substance</li>
<li style="margin-bottom:0.35rem;">Never allow an unauthorized third party to operate the vehicle</li>
<li style="margin-bottom:0.35rem;">Wear the provided DOT-approved helmet at all times while operating the vehicle</li>
<li style="margin-bottom:0.35rem;">Not take the vehicle off paved roads or outside the approved driving area</li>
<li>Return the vehicle at the agreed-upon time and location in the same condition it was received</li>
</ul>
<h2>Damage & Security Deposit</h2>
<p>A refundable security deposit is required at pickup. Renter is financially responsible for any damage to the vehicle, including but not limited to collision damage, tire damage, interior damage, and any fines or citations incurred during the rental period. The Company reserves the right to apply the security deposit toward any such costs.</p>
<h2>Assumption of Risk & Release of Liability</h2>
<p>Renter acknowledges that operating a Polaris Slingshot involves inherent risks including, but not limited to, physical injury or death. Renter voluntarily assumes all such risks and, to the fullest extent permitted by Texas law, releases and holds harmless Parker County Slingshot Rentals, its owners, employees, and agents from any and all claims, damages, or liability arising out of Renter's use of the vehicle.</p>
<h2>Cancellation Policy</h2>
<p>Cancellations made more than 24 hours before the rental start time are fully refunded. Cancellations within 24 hours of the rental start time are subject to a 50% cancellation fee.</p>
</div>
<form method="post" action="/waiver.php?ref=<?= urlencode($ref) ?>" id="waiverForm">
<input type="hidden" name="sig_data" id="sigDataInput" />
<div class="card">
<h2 style="margin-top:0">Acknowledgments</h2>
<p style="margin-bottom:1rem;color:#6b7280;font-size:0.88rem;">Check each box to confirm you have read and agree to that section.</p>
<div class="clause">
<input type="checkbox" name="checks[]" value="age" id="ck_age" required />
<label for="ck_age"><strong>Age & License:</strong> I confirm I am 25 years of age or older and hold a valid driver's license.</label>
</div>
<div class="clause">
<input type="checkbox" name="checks[]" value="insurance" id="ck_ins" required />
<label for="ck_ins"><strong>Insurance:</strong> I will provide proof of valid personal auto insurance at pickup and understand it is required.</label>
</div>
<div class="clause">
<input type="checkbox" name="checks[]" value="rules" id="ck_rules" required />
<label for="ck_rules"><strong>Vehicle Rules:</strong> I agree to operate the Slingshot safely, lawfully, and sober, and to wear the provided helmet at all times.</label>
</div>
<div class="clause">
<input type="checkbox" name="checks[]" value="damage" id="ck_dmg" required />
<label for="ck_dmg"><strong>Damage Responsibility:</strong> I understand I am financially responsible for any damage or fines incurred during my rental period.</label>
</div>
<div class="clause">
<input type="checkbox" name="checks[]" value="license" id="ck_lic" required />
<label for="ck_lic"><strong>License Verification:</strong> I consent to Parker County Slingshot Rentals verifying my driver's license at pickup.</label>
</div>
<div class="clause">
<input type="checkbox" name="checks[]" value="waiver" id="ck_waiver" required />
<label for="ck_waiver"><strong>Assumption of Risk:</strong> I have read and understand the Assumption of Risk and Release of Liability section and agree to its terms.</label>
</div>
</div>
<div class="card">
<h2 style="margin-top:0">Your Signature</h2>
<label style="font-weight:600;font-size:0.9rem;display:block;margin-bottom:0.2rem;">Full Legal Name</label>
<input type="text" name="sig_name" id="sigName" placeholder="Type your full name as it appears on your license" required />
<p style="margin:1.25rem 0 0.4rem;font-weight:600;font-size:0.9rem;">Draw Your Signature</p>
<div class="sig-wrap">
<canvas id="sigCanvas"></canvas>
<button type="button" class="sig-clear" id="clearBtn">Clear</button>
</div>
<p class="sig-hint">Use your mouse or finger to sign in the box above.</p>
<p style="margin-top:1.5rem;font-size:0.82rem;color:#9ca3af;">
By clicking "Sign &amp; Submit" below, you confirm that you have read this entire Rental Agreement, that all acknowledgments above are checked, and that your typed name and drawn signature constitute a legally binding electronic signature under the ESIGN Act and Texas law. Signed: <span id="sigDateDisplay"><?= date('F j, Y') ?></span>.
</p>
<button type="submit" class="btn-sign" id="submitBtn">Sign &amp; Submit Rental Agreement</button>
</div>
</form>
<?php endif; ?>
</div><!-- /.wrap -->
<script>
(function() {
const canvas = document.getElementById('sigCanvas');
if (!canvas) return;
// Size canvas to its CSS width
function resizeCanvas() {
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * window.devicePixelRatio;
canvas.height = rect.height * window.devicePixelRatio;
canvas.getContext('2d').scale(window.devicePixelRatio, window.devicePixelRatio);
}
resizeCanvas();
const ctx = canvas.getContext('2d');
let drawing = false;
let hasDrawn = false;
ctx.strokeStyle = '#111';
ctx.lineWidth = 2.2;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
function getPos(e) {
const r = canvas.getBoundingClientRect();
const src = e.touches ? e.touches[0] : e;
return { x: src.clientX - r.left, y: src.clientY - r.top };
}
function startDraw(e) {
e.preventDefault();
drawing = true;
const p = getPos(e);
ctx.beginPath();
ctx.moveTo(p.x, p.y);
}
function draw(e) {
if (!drawing) return;
e.preventDefault();
const p = getPos(e);
ctx.lineTo(p.x, p.y);
ctx.stroke();
hasDrawn = true;
}
function endDraw() { drawing = false; }
canvas.addEventListener('mousedown', startDraw);
canvas.addEventListener('mousemove', draw);
canvas.addEventListener('mouseup', endDraw);
canvas.addEventListener('mouseleave', endDraw);
canvas.addEventListener('touchstart', startDraw, { passive: false });
canvas.addEventListener('touchmove', draw, { passive: false });
canvas.addEventListener('touchend', endDraw);
document.getElementById('clearBtn').addEventListener('click', () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
hasDrawn = false;
document.getElementById('sigDataInput').value = '';
});
document.getElementById('waiverForm').addEventListener('submit', function(e) {
if (!hasDrawn) {
e.preventDefault();
alert('Please draw your signature before submitting.');
canvas.style.borderColor = '#ef4444';
return;
}
// Export canvas to base64 PNG
document.getElementById('sigDataInput').value = canvas.toDataURL('image/png');
});
})();
</script>
</body>
</html>
@@ -0,0 +1,7 @@
*.log
.DS_Store
*.swp
config/database.php
uploads/
vendor/
@@ -0,0 +1,105 @@
# Tom's Java Jive - Apache Configuration
# Enable URL rewriting
RewriteEngine On
# Block sensitive paths outright. RedirectMatch/FilesMatch/Require/Order
# directives were confirmed NOT to be honored on this LiteSpeed setup (config/,
# includes/*.php, and .git/ were all still reachable live despite the
# RedirectMatch rules further down) — only mod_rewrite matching on REQUEST_URI
# is confirmed to actually work here. This must come before other rewrites.
RewriteCond %{REQUEST_URI} ^/(\.git|config/|includes/.*\.php|install/|!install!!/|db/)
RewriteRule .* - [F,L]
RewriteCond %{REQUEST_URI} \.sql$ [OR]
RewriteCond %{REQUEST_URI} \.py$
RewriteRule .* - [F,L]
# Force HTTPS (uncomment in production)
RewriteCond %{HTTPS} off
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
# Remove trailing slashes
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} (.+)/$
RewriteRule ^ %1 [L,R=301]
# Protect sensitive directories
RedirectMatch 403 /config/.*$
RedirectMatch 403 /includes/.*\.php$
RedirectMatch 403 /install/.*$
RedirectMatch 403 /db/.*$
# Block .git (full source + commit history, including any credentials ever
# committed to it) from ever being served. NOTE: on the live server this
# repo's .git directory lives inside the docroot itself (deployed via
# `git clone` directly into public_html) and was confirmed reachable over
# HTTP (.git/config, .git/logs/HEAD, etc. all returned real content) even
# though the RedirectMatch rules above were also confirmed NOT blocking
# /config/*.php or /includes/*.php live — meaning .htaccess directives are
# not being fully honored by the current web server. This needs a server
# config fix (vhost-level deny, or moving .git outside the docroot) in
# addition to this file — see review notes.
RedirectMatch 403 /\.git/.*$
RedirectMatch 403 /\.git$
<FilesMatch "\.sql$">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order allow,deny
Deny from all
</IfModule>
</FilesMatch>
# Set default charset
AddDefaultCharset UTF-8
# Disable directory listing
Options -Indexes
# Set timezone (optional)
# php_value date.timezone "America/New_York"
# Increase upload limits (adjust as needed)
php_value upload_max_filesize 10M
php_value post_max_size 10M
# Enable compression (optional)
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/plain text/css text/javascript application/javascript application/json
</IfModule>
# Browser caching (optional)
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType image/jpg "access plus 1 year"
ExpiresByType image/jpeg "access plus 1 year"
ExpiresByType image/png "access plus 1 year"
ExpiresByType image/webp "access plus 1 year"
ExpiresByType text/css "access plus 1 month"
ExpiresByType application/javascript "access plus 1 month"
</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>
# Custom error pages (optional)
# ErrorDocument 404 /404.php
# ErrorDocument 500 /500.php
# SEO ADDITIONS
RewriteCond %{HTTP_HOST} ^www\.tomsjavajive\.com [NC]
RewriteRule ^(.*)$ https://tomsjavajive.com/$1 [R=301,L]
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType image/jpg "access plus 1 year"
ExpiresByType image/jpeg "access plus 1 year"
ExpiresByType image/png "access plus 1 year"
ExpiresByType image/webp "access plus 1 year"
ExpiresByType text/css "access plus 1 month"
ExpiresByType application/javascript "access plus 1 month"
</IfModule>
@@ -0,0 +1,208 @@
# Tom's Java Jive - E-commerce Coffee Shop
A complete e-commerce platform built with **PHP 8.4** and **MySQL 8.0** for cPanel hosting.
## Quick Download
**ZIP File:** [tomsjavajive-php.zip](https://tomsjavajive.com/tomsjavajive-php.zip)
## Features
### Storefront
- 🛒 Shopping cart with session management
- 📦 Product catalog with categories, search, and filtering
- 💳 Checkout with multiple payment options
- 📱 PWA support (installable, offline capable)
- 👤 Customer accounts with order history
### Admin Panel
- 📊 Dashboard with sales overview
- 📈 Advanced analytics with charts
- 🛍️ Product management (CRUD)
- 📋 Order management
- 💰 POS (Point of Sale) system
- 👥 Customer management with wallet
- ⭐ Review moderation
- 🎁 Gift cards & coupons
- ✉️ Email campaigns
- 📦 Inventory tracking
- 🚚 Shipping configuration
- 💳 Payment settings
- 👤 Admin user management
### Integrations (Placeholder Keys - Configure in Admin)
- 📧 **SendGrid** - Transactional emails
- 📱 **Twilio** - SMS notifications
- 🔔 **Push Notifications** - Web push
- 🏆 **Loyalty Program** - 4-tier rewards system
- 💳 **Stripe Payments** - cURL-based (no Composer needed)
## Installation
### Requirements
- PHP 8.0+ (tested on 8.4.19)
- MySQL 8.0+
- Apache with mod_rewrite
### Steps
1. **Upload Files**
- Extract the ZIP to your `public_html` folder
- Or upload via FTP
2. **Create Database**
```
Log into phpMyAdmin
Create a new database (e.g., `tomsjavajive`)
```
3. **Import Schema**
- Go to phpMyAdmin > Import
- Select `install/schema.sql`
- Click "Go"
4. **Run Migrations** (for full features)
- Import `install/migration_v2.sql`
- Import `install/migration_v3.sql`
5. **Configure Database**
- Edit `config/database.php`:
```php
define('DB_HOST', 'localhost');
define('DB_NAME', 'your_database_name');
define('DB_USER', 'your_username');
define('DB_PASS', 'your_password');
```
6. **Create Admin User**
- `create-admin.php` and the seeded default admin account have been removed —
insert your own row into `admin_users` directly (via phpMyAdmin/CLI) with a
bcrypt hash from `password_hash($password, PASSWORD_BCRYPT, ['cost' => 12])`.
Never leave a well-known default password (e.g. `admin123!`) in place.
7. **Configure Site URL**
- Edit `config/config.php`:
```php
define('SITE_URL', 'https://yoursite.com');
define('SITE_NAME', "Tom's Java Jive");
```
8. **Delete Installation Files** (Security)
```
Delete: create-admin.php
Delete: install/ folder (optional, keep for reference)
```
## Directory Structure
```
/
├── admin/ # Admin panel pages
│ ├── assets/ # Admin CSS/JS
│ ├── includes/ # Admin header/footer
│ └── *.php # Admin pages
├── account/ # Customer portal
│ └── includes/ # Account layout
├── api/ # AJAX endpoints
├── assets/ # Frontend assets
│ ├── css/
│ ├── js/
│ ├── images/
│ └── icons/
├── config/ # Configuration files
├── includes/ # Core PHP files
│ ├── auth.php # Authentication
│ ├── db.php # Database connection
│ ├── email.php # SendGrid integration
│ ├── sms.php # Twilio integration
│ ├── push.php # Push notifications
│ ├── loyalty.php # Loyalty program
│ └── functions.php # Helper functions
├── install/ # Installation files
│ ├── schema.sql # Main database schema
│ ├── migration_v2.sql
│ └── migration_v3.sql
├── manifest.json # PWA manifest
├── sw.js # Service worker
└── *.php # Storefront pages
```
## Configuring Integrations
### SendGrid (Email)
1. Get API key from https://app.sendgrid.com/settings/api_keys
2. Admin > Settings > Integrations
3. Enter API key, from email, and from name
### Twilio (SMS)
1. Get credentials from https://console.twilio.com/
2. Admin > Settings > Integrations
3. Enter Account SID, Auth Token, and Phone Number
### Push Notifications
1. Generate VAPID keys at https://web-push-codelab.glitch.me/
2. Admin > Settings > Integrations
3. Enter Public and Private keys
## Loyalty Program Tiers
| Tier | Points Required | Multiplier | Key Benefits |
|------|----------------|------------|--------------|
| Bronze Bean | 0 | 1x | Birthday reward |
| Silver Roast | 500 | 1.25x | Free shipping $25+ |
| Gold Blend | 1,500 | 1.5x | Free all shipping |
| Platinum Reserve | 5,000 | 2x | VIP benefits |
**Redemption:** 100 points = $1 wallet credit
## Security Notes
- All passwords are hashed with `password_hash()`
- PDO prepared statements prevent SQL injection
- CSRF protection on forms
- XSS prevention via `htmlspecialchars()`
- Session-based authentication
## Stripe Payment Integration
The app includes a **cURL-based Stripe integration** that works without Composer:
### Features
- **PaymentIntent API** - Inline card element payments
- **Checkout Sessions** - Hosted Stripe payment page (redirect)
- **Webhooks** - Payment confirmation handlers
- **Demo Mode** - Works without API keys for testing
### Setup
1. Get your API keys from https://dashboard.stripe.com/apikeys
2. Edit `config/config.php`:
```php
define('STRIPE_SECRET_KEY', 'sk_live_your_key');
define('STRIPE_PUBLISHABLE_KEY', 'pk_live_your_key');
define('STRIPE_WEBHOOK_SECRET', 'whsec_your_secret');
```
### Webhook Setup
1. Stripe Dashboard > Developers > Webhooks
2. Add endpoint: `https://yoursite.com/api/webhook.php`
3. Select events: `payment_intent.succeeded`, `checkout.session.completed`
### Files
- `includes/stripe.php` - Core Stripe API class (cURL-based)
- `api/create-payment-intent.php` - Create PaymentIntent
- `api/create-checkout-session.php` - Create Checkout Session
- `api/payment-status.php` - Poll payment status
- `api/webhook.php` - Handle Stripe webhooks
## Support
For issues or feature requests, contact your developer.
## License
Proprietary - All rights reserved.
---
**Version:** 2.0
**Last Updated:** December 2025
@@ -0,0 +1,290 @@
<?php
/**
* Tom's Java Jive - Customer Addresses
*/
$pageTitle = "My Addresses - Tom's Java Jive";
require_once __DIR__ . '/../includes/functions.php';
require_once __DIR__ . '/../includes/auth.php';
CustomerAuth::require();
$customer = CustomerAuth::getFullUser();
$currentPage = 'addresses';
// Get addresses from customer record
$addresses = json_decode($customer['addresses'] ?? '[]', true);
if (!is_array($addresses)) $addresses = [];
// Handle form submissions
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
if ($action === 'add' || $action === 'edit') {
$index = isset($_POST['index']) ? intval($_POST['index']) : -1;
$address = [
'id' => $index >= 0 && isset($addresses[$index]['id']) ? $addresses[$index]['id'] : uniqid('addr_'),
'name' => trim($_POST['name'] ?? ''),
'phone' => trim($_POST['phone'] ?? ''),
'address' => trim($_POST['address'] ?? ''),
'address2' => trim($_POST['address2'] ?? ''),
'city' => trim($_POST['city'] ?? ''),
'state' => trim($_POST['state'] ?? ''),
'zip' => trim($_POST['zip'] ?? ''),
'country' => trim($_POST['country'] ?? 'USA'),
'is_default' => isset($_POST['is_default']),
];
// Validate
if (empty($address['name']) || empty($address['address']) || empty($address['city']) || empty($address['zip'])) {
setFlash('error', 'Please fill in all required fields');
} else {
// Handle default
if ($address['is_default']) {
foreach ($addresses as &$a) {
$a['is_default'] = false;
}
}
if ($index >= 0 && isset($addresses[$index])) {
$addresses[$index] = $address;
} else {
$addresses[] = $address;
}
// Save
db()->query(
"UPDATE customers SET addresses = :addresses WHERE customer_id = :id",
['addresses' => json_encode($addresses), 'id' => $customer['customer_id']]
);
setFlash('success', $action === 'add' ? 'Address added' : 'Address updated');
redirect('/account/addresses.php');
}
}
if ($action === 'delete') {
$index = intval($_POST['index'] ?? -1);
if ($index >= 0 && isset($addresses[$index])) {
array_splice($addresses, $index, 1);
db()->query(
"UPDATE customers SET addresses = :addresses WHERE customer_id = :id",
['addresses' => json_encode($addresses), 'id' => $customer['customer_id']]
);
setFlash('success', 'Address deleted');
}
redirect('/account/addresses.php');
}
if ($action === 'set_default') {
$index = intval($_POST['index'] ?? -1);
if ($index >= 0 && isset($addresses[$index])) {
foreach ($addresses as $i => &$a) {
$a['is_default'] = ($i === $index);
}
db()->query(
"UPDATE customers SET addresses = :addresses WHERE customer_id = :id",
['addresses' => json_encode($addresses), 'id' => $customer['customer_id']]
);
setFlash('success', 'Default address updated');
}
redirect('/account/addresses.php');
}
}
$extraHead = '<link rel="stylesheet" href="/assets/css/account.css?v='. filemtime(__DIR__ . '/../assets/css/account.css') .'">';
require_once __DIR__ . '/../includes/header.php';
require_once __DIR__ . '/includes/sidebar.php';
?>
<div class="account-header" style="display: flex; justify-content: space-between; align-items: center;">
<div>
<h1>My Addresses</h1>
<p class="text-muted">Manage your shipping and billing addresses</p>
</div>
<button class="btn btn-primary" onclick="openAddressModal()">
<i class="fas fa-plus"></i> Add Address
</button>
</div>
<?php if (hasFlash('success')): ?>
<div class="alert alert-success mb-2">
<i class="fas fa-check-circle"></i> <?= getFlash('success') ?>
</div>
<?php endif; ?>
<?php if (hasFlash('error')): ?>
<div class="alert alert-error mb-2">
<i class="fas fa-exclamation-circle"></i> <?= getFlash('error') ?>
</div>
<?php endif; ?>
<?php if (empty($addresses)): ?>
<div class="section-card">
<div class="section-card-body text-center" style="padding: 3rem;">
<i class="fas fa-map-marker-alt" style="font-size: 3rem; color: var(--color-text-muted); margin-bottom: 1rem;"></i>
<h3>No addresses saved</h3>
<p class="text-muted">Add an address for faster checkout.</p>
<button class="btn btn-primary mt-1" onclick="openAddressModal()">
<i class="fas fa-plus"></i> Add Address
</button>
</div>
</div>
<?php else: ?>
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1.5rem;">
<?php foreach ($addresses as $index => $addr): ?>
<div class="section-card">
<div class="section-card-body">
<?php if (!empty($addr['is_default'])): ?>
<span class="badge badge-primary" style="margin-bottom: 0.75rem;">Default</span>
<?php endif; ?>
<h4 style="margin: 0 0 0.5rem;"><?= htmlspecialchars($addr['name']) ?></h4>
<p style="margin: 0 0 0.25rem;"><?= htmlspecialchars($addr['address']) ?></p>
<?php if (!empty($addr['address2'])): ?>
<p style="margin: 0 0 0.25rem;"><?= htmlspecialchars($addr['address2']) ?></p>
<?php endif; ?>
<p style="margin: 0 0 0.25rem;">
<?= htmlspecialchars($addr['city']) ?>, <?= htmlspecialchars($addr['state']) ?> <?= htmlspecialchars($addr['zip']) ?>
</p>
<p style="margin: 0 0 0.75rem;"><?= htmlspecialchars($addr['country'] ?? 'USA') ?></p>
<?php if (!empty($addr['phone'])): ?>
<p class="text-muted" style="margin: 0 0 1rem;">
<i class="fas fa-phone"></i> <?= htmlspecialchars($addr['phone']) ?>
</p>
<?php endif; ?>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
<button class="btn btn-sm btn-secondary" onclick="editAddress(<?= $index ?>)">
<i class="fas fa-edit"></i> Edit
</button>
<?php if (empty($addr['is_default'])): ?>
<form method="POST" style="display: inline;">
<input type="hidden" name="action" value="set_default">
<input type="hidden" name="index" value="<?= $index ?>">
<button type="submit" class="btn btn-sm btn-secondary">
<i class="fas fa-check"></i> Make Default
</button>
</form>
<?php endif; ?>
<form method="POST" style="display: inline;" onsubmit="return confirm('Delete this address?')">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="index" value="<?= $index ?>">
<button type="submit" class="btn btn-sm btn-danger">
<i class="fas fa-trash"></i>
</button>
</form>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<!-- Address Modal -->
<div class="modal-overlay" id="addressModal">
<div class="modal">
<div class="modal-header">
<h3 class="modal-title" id="addressModalTitle">Add Address</h3>
<button type="button" class="modal-close" onclick="Modal.close('addressModal')">&times;</button>
</div>
<form method="POST" id="addressForm">
<input type="hidden" name="action" id="addressAction" value="add">
<input type="hidden" name="index" id="addressIndex" value="-1">
<div class="modal-body">
<div class="form-group">
<label class="form-label">Full Name *</label>
<input type="text" name="name" id="addr_name" class="form-input" required>
</div>
<div class="form-group">
<label class="form-label">Phone Number</label>
<input type="tel" name="phone" id="addr_phone" class="form-input">
</div>
<div class="form-group">
<label class="form-label">Street Address *</label>
<input type="text" name="address" id="addr_address" class="form-input" required>
</div>
<div class="form-group">
<label class="form-label">Apartment, suite, etc.</label>
<input type="text" name="address2" id="addr_address2" class="form-input">
</div>
<div style="display: grid; grid-template-columns: 2fr 1fr 1fr; gap: 1rem;">
<div class="form-group">
<label class="form-label">City *</label>
<input type="text" name="city" id="addr_city" class="form-input" required>
</div>
<div class="form-group">
<label class="form-label">State *</label>
<input type="text" name="state" id="addr_state" class="form-input" required>
</div>
<div class="form-group">
<label class="form-label">ZIP *</label>
<input type="text" name="zip" id="addr_zip" class="form-input" required>
</div>
</div>
<div class="form-group">
<label class="form-label">Country</label>
<select name="country" id="addr_country" class="form-select">
<option value="USA">United States</option>
<option value="Canada">Canada</option>
</select>
</div>
<div class="form-group">
<label class="form-checkbox">
<input type="checkbox" name="is_default" id="addr_default">
Set as default address
</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="Modal.close('addressModal')">Cancel</button>
<button type="submit" class="btn btn-primary">Save Address</button>
</div>
</form>
</div>
</div>
<script>
const addresses = <?= json_encode($addresses) ?>;
function openAddressModal() {
document.getElementById('addressModalTitle').textContent = 'Add Address';
document.getElementById('addressAction').value = 'add';
document.getElementById('addressIndex').value = '-1';
document.getElementById('addressForm').reset();
Modal.open('addressModal');
}
function editAddress(index) {
const addr = addresses[index];
if (!addr) return;
document.getElementById('addressModalTitle').textContent = 'Edit Address';
document.getElementById('addressAction').value = 'edit';
document.getElementById('addressIndex').value = index;
document.getElementById('addr_name').value = addr.name || '';
document.getElementById('addr_phone').value = addr.phone || '';
document.getElementById('addr_address').value = addr.address || '';
document.getElementById('addr_address2').value = addr.address2 || '';
document.getElementById('addr_city').value = addr.city || '';
document.getElementById('addr_state').value = addr.state || '';
document.getElementById('addr_zip').value = addr.zip || '';
document.getElementById('addr_country').value = addr.country || 'USA';
document.getElementById('addr_default').checked = !!addr.is_default;
Modal.open('addressModal');
}
</script>
<?php require_once __DIR__ . '/includes/footer.php'; ?>
@@ -0,0 +1,6 @@
</div>
</div>
</div>
</section>
<?php require_once __DIR__ . '/../../includes/footer.php'; ?>
@@ -0,0 +1,36 @@
<?php
/**
* Account Page Sidebar Include
*/
$customer = CustomerAuth::getFullUser();
?>
<section class="section" style="padding-top: 2rem;">
<div class="container">
<div class="account-layout">
<!-- Sidebar -->
<aside class="account-sidebar">
<div class="account-sidebar-user">
<div class="account-avatar">
<?= strtoupper(substr($customer['name'] ?? $customer['email'], 0, 1)) ?>
</div>
<h3><?= htmlspecialchars($customer['name'] ?? 'Customer') ?></h3>
<p><?= htmlspecialchars($customer['email']) ?></p>
</div>
<ul class="account-nav">
<li><a href="/account/" class="<?= ($currentPage ?? '') === 'dashboard' ? 'active' : '' ?>"><i class="fas fa-tachometer-alt"></i> Dashboard</a></li>
<li><a href="/account/orders.php" class="<?= ($currentPage ?? '') === 'orders' ? 'active' : '' ?>"><i class="fas fa-shopping-bag"></i> My Orders</a></li>
<li><a href="/account/wishlist.php" class="<?= ($currentPage ?? '') === 'wishlist' ? 'active' : '' ?>"><i class="fas fa-heart"></i> Wishlist</a></li>
<li><a href="/account/wallet.php" class="<?= ($currentPage ?? '') === 'wallet' ? 'active' : '' ?>"><i class="fas fa-wallet"></i> Wallet</a></li>
<li><a href="/account/rewards.php" class="<?= ($currentPage ?? '') === 'rewards' ? 'active' : '' ?>"><i class="fas fa-crown"></i> Rewards</a></li>
<li><a href="/account/addresses.php" class="<?= ($currentPage ?? '') === 'addresses' ? 'active' : '' ?>"><i class="fas fa-map-marker-alt"></i> Addresses</a></li>
<li><a href="/account/profile.php" class="<?= ($currentPage ?? '') === 'profile' ? 'active' : '' ?>"><i class="fas fa-user"></i> Profile</a></li>
<li><a href="/account/reviews.php" class="<?= ($currentPage ?? '') === 'reviews' ? 'active' : '' ?>"><i class="fas fa-star"></i> My Reviews</a></li>
<li><a href="/logout.php" class="danger"><i class="fas fa-sign-out-alt"></i> Logout</a></li>
</ul>
</aside>
<!-- Main Content -->
<div class="account-content">

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