diff --git a/backup.sh b/backup.sh old mode 100644 new mode 100755 index 26ef8f8..0d5f337 --- a/backup.sh +++ b/backup.sh @@ -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) # --------------------------------------------------------------------------- diff --git a/backup.sh.bak-2026-07-07 b/backup.sh.bak-2026-07-07 new file mode 100644 index 0000000..26ef8f8 --- /dev/null +++ b/backup.sh.bak-2026-07-07 @@ -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 diff --git a/databases/epic_epic_db.sql.gz b/databases/epic_epic_db.sql.gz new file mode 100644 index 0000000..0576ae2 Binary files /dev/null and b/databases/epic_epic_db.sql.gz differ diff --git a/databases/epic_parkersling.sql.gz b/databases/epic_parkersling.sql.gz new file mode 100644 index 0000000..fe3b41e Binary files /dev/null and b/databases/epic_parkersling.sql.gz differ diff --git a/databases/park_slingshot.sql.gz b/databases/park_slingshot.sql.gz new file mode 100644 index 0000000..37092e9 Binary files /dev/null and b/databases/park_slingshot.sql.gz differ diff --git a/databases/toms_tjj_db.sql.gz b/databases/toms_tjj_db.sql.gz new file mode 100644 index 0000000..aabda19 Binary files /dev/null and b/databases/toms_tjj_db.sql.gz differ diff --git a/databases/tomt_ttg_db.sql.gz b/databases/tomt_ttg_db.sql.gz new file mode 100644 index 0000000..11adc40 Binary files /dev/null and b/databases/tomt_ttg_db.sql.gz differ diff --git a/databases/workt_track_db.sql.gz b/databases/workt_track_db.sql.gz new file mode 100644 index 0000000..5605378 Binary files /dev/null and b/databases/workt_track_db.sql.gz differ diff --git a/mysql/databases.txt b/mysql/databases.txt index 069ed23..a883153 100644 --- a/mysql/databases.txt +++ b/mysql/databases.txt @@ -5,3 +5,4 @@ mysql park_slingshot toms_tjj_db tomt_ttg_db +workt_track_db diff --git a/ols-vhosts/httpd_vhosts_summary.txt b/ols-vhosts/httpd_vhosts_summary.txt index b22665c..4afe527 100644 --- a/ols-vhosts/httpd_vhosts_summary.txt +++ b/ols-vhosts/httpd_vhosts_summary.txt @@ -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 diff --git a/ols-vhosts/site-list.txt b/ols-vhosts/site-list.txt index 2b48571..4613438 100644 --- a/ols-vhosts/site-list.txt +++ b/ols-vhosts/site-list.txt @@ -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) diff --git a/ols-vhosts/worktracking.orbishosting.com/vhost.conf b/ols-vhosts/worktracking.orbishosting.com/vhost.conf new file mode 100755 index 0000000..258602e --- /dev/null +++ b/ols-vhosts/worktracking.orbishosting.com/vhost.conf @@ -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 +} diff --git a/scripts/jarvis-agent.py b/scripts/jarvis-agent.py index 1dc721a..d237438 100755 --- a/scripts/jarvis-agent.py +++ b/scripts/jarvis-agent.py @@ -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: diff --git a/scripts/jarvis-backup.sh b/scripts/jarvis-backup.sh index 3b0f7af..c71fea0 100755 --- a/scripts/jarvis-backup.sh +++ b/scripts/jarvis-backup.sh @@ -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" diff --git a/sites/epictravelexpeditions.com/public_html/.gitignore b/sites/epictravelexpeditions.com/public_html/.gitignore new file mode 100644 index 0000000..26add4a --- /dev/null +++ b/sites/epictravelexpeditions.com/public_html/.gitignore @@ -0,0 +1,6 @@ +*.log +.DS_Store +*.swp + +api/config.php +uploads/ diff --git a/sites/epictravelexpeditions.com/public_html/.htaccess b/sites/epictravelexpeditions.com/public_html/.htaccess new file mode 100644 index 0000000..ed30591 --- /dev/null +++ b/sites/epictravelexpeditions.com/public_html/.htaccess @@ -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 + + +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] + + +# Security Headers + +Header set X-Content-Type-Options "nosniff" +Header set X-Frame-Options "SAMEORIGIN" +Header set X-XSS-Protection "1; mode=block" + + +# Enable CORS for API + + 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" + + +# Disable directory browsing +Options -Indexes +FollowSymLinks + +# PHP Settings + +php_value upload_max_filesize 10M +php_value post_max_size 10M +php_value memory_limit 256M +php_value max_execution_time 300 + + +# Force use of index.html + +DirectoryIndex index.html index.php + diff --git a/sites/epictravelexpeditions.com/public_html/api/.htaccess b/sites/epictravelexpeditions.com/public_html/api/.htaccess new file mode 100644 index 0000000..a04bdce --- /dev/null +++ b/sites/epictravelexpeditions.com/public_html/api/.htaccess @@ -0,0 +1,60 @@ +# Epic Travel & Expeditions - LiteSpeed .htaccess for CyberPanel +# Optimized for LiteSpeed Web Server + + + 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] + + +# LiteSpeed Cache Control + + # Disable caching for API + CacheLookup off + + +# Security Headers + + 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" + + +# Protect sensitive files + + Require all denied + + +# PHP Settings (LiteSpeed compatible) + + 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 + + +# Compression + + AddOutputFilterByType DEFLATE application/json + AddOutputFilterByType DEFLATE text/plain + AddOutputFilterByType DEFLATE text/html + + +# Browser Caching + + ExpiresActive Off + diff --git a/sites/epictravelexpeditions.com/public_html/api/api/auth.php b/sites/epictravelexpeditions.com/public_html/api/api/auth.php new file mode 100644 index 0000000..293a04e --- /dev/null +++ b/sites/epictravelexpeditions.com/public_html/api/api/auth.php @@ -0,0 +1,55 @@ +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); diff --git a/sites/epictravelexpeditions.com/public_html/api/api/categories.php b/sites/epictravelexpeditions.com/public_html/api/api/categories.php new file mode 100644 index 0000000..cf6b4bd --- /dev/null +++ b/sites/epictravelexpeditions.com/public_html/api/api/categories.php @@ -0,0 +1,64 @@ +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); diff --git a/sites/epictravelexpeditions.com/public_html/api/api/contact.php b/sites/epictravelexpeditions.com/public_html/api/api/contact.php new file mode 100644 index 0000000..cdbe424 --- /dev/null +++ b/sites/epictravelexpeditions.com/public_html/api/api/contact.php @@ -0,0 +1,38 @@ +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); diff --git a/sites/epictravelexpeditions.com/public_html/api/api/destinations.php b/sites/epictravelexpeditions.com/public_html/api/api/destinations.php new file mode 100644 index 0000000..96c1e9c --- /dev/null +++ b/sites/epictravelexpeditions.com/public_html/api/api/destinations.php @@ -0,0 +1,139 @@ +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); diff --git a/sites/epictravelexpeditions.com/public_html/api/api/newsletter.php b/sites/epictravelexpeditions.com/public_html/api/api/newsletter.php new file mode 100644 index 0000000..c34d4d7 --- /dev/null +++ b/sites/epictravelexpeditions.com/public_html/api/api/newsletter.php @@ -0,0 +1,37 @@ +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); diff --git a/sites/epictravelexpeditions.com/public_html/api/api/specials.php b/sites/epictravelexpeditions.com/public_html/api/api/specials.php new file mode 100644 index 0000000..3ca92c1 --- /dev/null +++ b/sites/epictravelexpeditions.com/public_html/api/api/specials.php @@ -0,0 +1,131 @@ +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); diff --git a/sites/epictravelexpeditions.com/public_html/api/api/testimonials.php b/sites/epictravelexpeditions.com/public_html/api/api/testimonials.php new file mode 100644 index 0000000..37648f2 --- /dev/null +++ b/sites/epictravelexpeditions.com/public_html/api/api/testimonials.php @@ -0,0 +1,103 @@ +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); diff --git a/sites/epictravelexpeditions.com/public_html/api/api/upload.php b/sites/epictravelexpeditions.com/public_html/api/api/upload.php new file mode 100644 index 0000000..d2040c4 --- /dev/null +++ b/sites/epictravelexpeditions.com/public_html/api/api/upload.php @@ -0,0 +1,43 @@ + '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); diff --git a/sites/epictravelexpeditions.com/public_html/api/includes/database.php b/sites/epictravelexpeditions.com/public_html/api/includes/database.php new file mode 100644 index 0000000..4c6b7cf --- /dev/null +++ b/sites/epictravelexpeditions.com/public_html/api/includes/database.php @@ -0,0 +1,52 @@ + 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"); + } +} diff --git a/sites/epictravelexpeditions.com/public_html/api/includes/functions.php b/sites/epictravelexpeditions.com/public_html/api/includes/functions.php new file mode 100644 index 0000000..6573e1e --- /dev/null +++ b/sites/epictravelexpeditions.com/public_html/api/includes/functions.php @@ -0,0 +1,124 @@ + ..., '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]; +} diff --git a/sites/epictravelexpeditions.com/public_html/api/includes/jwt.php b/sites/epictravelexpeditions.com/public_html/api/includes/jwt.php new file mode 100644 index 0000000..dd8478c --- /dev/null +++ b/sites/epictravelexpeditions.com/public_html/api/includes/jwt.php @@ -0,0 +1,117 @@ + '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; +} diff --git a/sites/epictravelexpeditions.com/public_html/api/includes/mailer.php b/sites/epictravelexpeditions.com/public_html/api/includes/mailer.php new file mode 100644 index 0000000..8495a0a --- /dev/null +++ b/sites/epictravelexpeditions.com/public_html/api/includes/mailer.php @@ -0,0 +1,97 @@ + 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 = ' +
+
+

Epic Travel Expeditions

+

New Contact Form Message

+
+
+ + + + + +
Name' . htmlspecialchars($name) . '
Email' . htmlspecialchars($email) . '
+
+

' . nl2br(htmlspecialchars($message)) . '

+
+

Submitted ' . date('F j, Y \a\t g:i A T') . '

+
+
+

© ' . date('Y') . ' Epic Travel Expeditions

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

Epic Travel Expeditions

+
+
+

Thanks for reaching out, ' . htmlspecialchars($toName) . '!

+

We received your message and our team will get back to you within 1–2 business days.

+

In the meantime, feel free to browse our destinations and current travel specials:

+
+ Explore Destinations +
+

Adventure awaits,
The Epic Travel Team

+
+
+

© ' . date('Y') . ' Epic Travel Expeditions

+
+
'; + + 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"); +} diff --git a/sites/epictravelexpeditions.com/public_html/api/index.php b/sites/epictravelexpeditions.com/public_html/api/index.php new file mode 100644 index 0000000..a1c8e4d --- /dev/null +++ b/sites/epictravelexpeditions.com/public_html/api/index.php @@ -0,0 +1,79 @@ + '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); + } +} diff --git a/sites/epictravelexpeditions.com/public_html/asset-manifest.json b/sites/epictravelexpeditions.com/public_html/asset-manifest.json new file mode 100644 index 0000000..c2c6425 --- /dev/null +++ b/sites/epictravelexpeditions.com/public_html/asset-manifest.json @@ -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" + ] +} \ No newline at end of file diff --git a/sites/epictravelexpeditions.com/public_html/assets/og-image.jpg b/sites/epictravelexpeditions.com/public_html/assets/og-image.jpg new file mode 100644 index 0000000..2ed2b32 --- /dev/null +++ b/sites/epictravelexpeditions.com/public_html/assets/og-image.jpg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + 🌎 + Epic Travel Expeditions + Adventure Awaits • Curated World Expeditions + Exclusive Destinations • Guided Tours • Unforgettable Journeys + + epictravelexpeditions.com + diff --git a/sites/epictravelexpeditions.com/public_html/composer.json b/sites/epictravelexpeditions.com/public_html/composer.json new file mode 100644 index 0000000..72d8e3f --- /dev/null +++ b/sites/epictravelexpeditions.com/public_html/composer.json @@ -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 + } +} diff --git a/sites/epictravelexpeditions.com/public_html/config/vhost-parkerslingshot/vhost.conf b/sites/epictravelexpeditions.com/public_html/config/vhost-parkerslingshot/vhost.conf new file mode 100755 index 0000000..dcad4de --- /dev/null +++ b/sites/epictravelexpeditions.com/public_html/config/vhost-parkerslingshot/vhost.conf @@ -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 +} diff --git a/sites/epictravelexpeditions.com/public_html/config/vhost-parkerslingshot/vhost.conf.txt b/sites/epictravelexpeditions.com/public_html/config/vhost-parkerslingshot/vhost.conf.txt new file mode 100755 index 0000000..c85a3fe --- /dev/null +++ b/sites/epictravelexpeditions.com/public_html/config/vhost-parkerslingshot/vhost.conf.txt @@ -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 +} diff --git a/sites/epictravelexpeditions.com/public_html/config/vhost-parkerslingshot/vhost.conf0 b/sites/epictravelexpeditions.com/public_html/config/vhost-parkerslingshot/vhost.conf0 new file mode 100755 index 0000000..dcad4de --- /dev/null +++ b/sites/epictravelexpeditions.com/public_html/config/vhost-parkerslingshot/vhost.conf0 @@ -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 +} diff --git a/sites/epictravelexpeditions.com/public_html/config/vhost-parkerslingshot/vhost.conf0,v b/sites/epictravelexpeditions.com/public_html/config/vhost-parkerslingshot/vhost.conf0,v new file mode 100755 index 0000000..7f8900c --- /dev/null +++ b/sites/epictravelexpeditions.com/public_html/config/vhost-parkerslingshot/vhost.conf0,v @@ -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 +@ diff --git a/sites/epictravelexpeditions.com/public_html/config/vhost/vhost.conf b/sites/epictravelexpeditions.com/public_html/config/vhost/vhost.conf new file mode 100755 index 0000000..1e73e9d --- /dev/null +++ b/sites/epictravelexpeditions.com/public_html/config/vhost/vhost.conf @@ -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 +} + + AllowOverride All + Require all granted + \ No newline at end of file diff --git a/sites/epictravelexpeditions.com/public_html/config/vhost/vhost.conf.txt b/sites/epictravelexpeditions.com/public_html/config/vhost/vhost.conf.txt new file mode 100755 index 0000000..d7fe048 --- /dev/null +++ b/sites/epictravelexpeditions.com/public_html/config/vhost/vhost.conf.txt @@ -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 +} diff --git a/sites/epictravelexpeditions.com/public_html/config/vhost/vhost.conf0 b/sites/epictravelexpeditions.com/public_html/config/vhost/vhost.conf0 new file mode 100755 index 0000000..1e73e9d --- /dev/null +++ b/sites/epictravelexpeditions.com/public_html/config/vhost/vhost.conf0 @@ -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 +} + + AllowOverride All + Require all granted + \ No newline at end of file diff --git a/sites/epictravelexpeditions.com/public_html/config/vhost/vhost.conf0,v b/sites/epictravelexpeditions.com/public_html/config/vhost/vhost.conf0,v new file mode 100755 index 0000000..4c3bd44 --- /dev/null +++ b/sites/epictravelexpeditions.com/public_html/config/vhost/vhost.conf0,v @@ -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 +} + + AllowOverride All + Require all granted +@ + + +1.2 +log +@Update +@ +text +@d92 4 +@ + + +1.1 +log +@Update +@ +text +@d79 13 +@ diff --git a/sites/epictravelexpeditions.com/public_html/db/schema.sql b/sites/epictravelexpeditions.com/public_html/db/schema.sql new file mode 100644 index 0000000..062b909 --- /dev/null +++ b/sites/epictravelexpeditions.com/public_html/db/schema.sql @@ -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 */; + diff --git a/sites/epictravelexpeditions.com/public_html/index.html b/sites/epictravelexpeditions.com/public_html/index.html new file mode 100644 index 0000000..0f8c102 --- /dev/null +++ b/sites/epictravelexpeditions.com/public_html/index.html @@ -0,0 +1,18 @@ +Epic Travel Expeditions | Adventure Awaits + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/sites/epictravelexpeditions.com/public_html/robots.txt b/sites/epictravelexpeditions.com/public_html/robots.txt new file mode 100644 index 0000000..7014030 --- /dev/null +++ b/sites/epictravelexpeditions.com/public_html/robots.txt @@ -0,0 +1,6 @@ +User-agent: * +Allow: / +Disallow: /admin +Disallow: /api/ + +Sitemap: https://epictravelexpeditions.com/sitemap.xml diff --git a/sites/epictravelexpeditions.com/public_html/sitemap.xml b/sites/epictravelexpeditions.com/public_html/sitemap.xml new file mode 100644 index 0000000..e8c09b2 --- /dev/null +++ b/sites/epictravelexpeditions.com/public_html/sitemap.xml @@ -0,0 +1,30 @@ + + + + https://epictravelexpeditions.com/ + 2026-05-19 + weekly + 1.0 + + + https://epictravelexpeditions.com/destinations + 2026-05-19 + weekly + 0.9 + + + https://epictravelexpeditions.com/specials + 2026-05-19 + daily + 0.8 + + + https://epictravelexpeditions.com/contact + 2026-05-19 + monthly + 0.6 + + diff --git a/sites/epictravelexpeditions.com/public_html/static/css/main.7791f349.css b/sites/epictravelexpeditions.com/public_html/static/css/main.7791f349.css new file mode 100644 index 0000000..ec56952 --- /dev/null +++ b/sites/epictravelexpeditions.com/public_html/static/css/main.7791f349.css @@ -0,0 +1,4 @@ +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#3b82f680;--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#3b82f680;--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/* +! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com +*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{-webkit-text-size-adjust:100%;font-feature-settings:normal;-webkit-tap-highlight-color:transparent;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-variation-settings:normal;line-height:1.5;tab-size:4}body{line-height:inherit}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-feature-settings:normal;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:initial}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{font-feature-settings:inherit;color:inherit;font-family:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:initial;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:initial}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}:root{--background:0 0% 100%;--foreground:0 0% 3.9%;--card:0 0% 100%;--card-foreground:0 0% 3.9%;--popover:0 0% 100%;--popover-foreground:0 0% 3.9%;--primary:0 0% 9%;--primary-foreground:0 0% 98%;--secondary:0 0% 96.1%;--secondary-foreground:0 0% 9%;--muted:0 0% 96.1%;--muted-foreground:0 0% 45.1%;--accent:0 0% 96.1%;--accent-foreground:0 0% 9%;--destructive:0 84.2% 60.2%;--destructive-foreground:0 0% 98%;--border:0 0% 89.8%;--input:0 0% 89.8%;--ring:0 0% 3.9%;--chart-1:12 76% 61%;--chart-2:173 58% 39%;--chart-3:197 37% 24%;--chart-4:43 74% 66%;--chart-5:27 87% 67%;--radius:0.5rem}*{border-color:#e5e5e5;border-color:hsl(var(--border))}body{background-color:#fff;background-color:hsl(var(--background));color:#0a0a0a;color:hsl(var(--foreground))}[data-debug-wrapper=true]{display:contents!important}[data-debug-wrapper=true]>*{border:inherit;column-gap:inherit;gap:inherit;margin:inherit;padding:inherit;row-gap:inherit}.sr-only{clip:rect(0,0,0,0);border-width:0;height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;white-space:nowrap;width:1px}.pointer-events-none{pointer-events:none}.pointer-events-auto{pointer-events:auto}.visible{visibility:visible}.invisible{visibility:hidden}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.inset-x-0{left:0;right:0}.inset-y-0{bottom:0;top:0}.-bottom-12{bottom:-3rem}.-left-12{left:-3rem}.-right-12{right:-3rem}.-top-12{top:-3rem}.bottom-0{bottom:0}.bottom-4{bottom:1rem}.left-0{left:0}.left-1{left:.25rem}.left-1\/2{left:50%}.left-2{left:.5rem}.left-3{left:.75rem}.left-4{left:1rem}.left-\[50\%\]{left:50%}.right-0{right:0}.right-1{right:.25rem}.right-2{right:.5rem}.right-4{right:1rem}.top-0{top:0}.top-1{top:.25rem}.top-1\/2{top:50%}.top-2{top:.5rem}.top-4{top:1rem}.top-\[1px\]{top:1px}.top-\[50\%\]{top:50%}.top-\[60\%\]{top:60%}.top-full{top:100%}.z-0{z-index:0}.z-10{z-index:10}.z-50{z-index:50}.z-\[100\]{z-index:100}.z-\[1\]{z-index:1}.-mx-1{margin-left:-.25rem;margin-right:-.25rem}.mx-auto{margin-left:auto;margin-right:auto}.my-1{margin-bottom:.25rem;margin-top:.25rem}.-ml-4{margin-left:-1rem}.-mt-4{margin-top:-1rem}.mb-1{margin-bottom:.25rem}.mb-12{margin-bottom:3rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mr-2{margin-right:.5rem}.mr-4{margin-right:1rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-24{margin-top:6rem}.mt-4{margin-top:1rem}.mt-8{margin-top:2rem}.mt-auto{margin-top:auto}.line-clamp-2{-webkit-line-clamp:2}.line-clamp-2,.line-clamp-3{-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-3{-webkit-line-clamp:3}.block{display:block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.aspect-square{aspect-ratio:1/1}.h-1\.5{height:.375rem}.h-10{height:2.5rem}.h-16{height:4rem}.h-2{height:.5rem}.h-2\.5{height:.625rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-32{height:8rem}.h-4{height:1rem}.h-48{height:12rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-64{height:16rem}.h-7{height:1.75rem}.h-8{height:2rem}.h-9{height:2.25rem}.h-\[1px\]{height:1px}.h-\[var\(--radix-navigation-menu-viewport-height\)\]{height:var(--radix-navigation-menu-viewport-height)}.h-\[var\(--radix-select-trigger-height\)\]{height:var(--radix-select-trigger-height)}.h-auto{height:auto}.h-full{height:100%}.h-px{height:1px}.h-screen{height:100vh}.max-h-\[--radix-context-menu-content-available-height\]{max-height:var(--radix-context-menu-content-available-height)}.max-h-\[--radix-select-content-available-height\]{max-height:var(--radix-select-content-available-height)}.max-h-\[300px\]{max-height:300px}.max-h-\[90vh\]{max-height:90vh}.max-h-\[var\(--radix-dropdown-menu-content-available-height\)\]{max-height:var(--radix-dropdown-menu-content-available-height)}.max-h-screen{max-height:100vh}.min-h-\[60px\]{min-height:60px}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-16{width:4rem}.w-2{width:.5rem}.w-2\.5{width:.625rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-3\/4{width:75%}.w-32{width:8rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-64{width:16rem}.w-7{width:1.75rem}.w-72{width:18rem}.w-8{width:2rem}.w-9{width:2.25rem}.w-\[100px\]{width:100px}.w-\[1px\]{width:1px}.w-full{width:100%}.w-max{width:-webkit-max-content;width:max-content}.w-px{width:1px}.min-w-0{min-width:0}.min-w-10{min-width:2.5rem}.min-w-8{min-width:2rem}.min-w-9{min-width:2.25rem}.min-w-\[12rem\]{min-width:12rem}.min-w-\[8rem\]{min-width:8rem}.min-w-\[var\(--radix-select-trigger-width\)\]{min-width:var(--radix-select-trigger-width)}.max-w-2xl{max-width:42rem}.max-w-4xl{max-width:56rem}.max-w-7xl{max-width:80rem}.max-w-lg{max-width:32rem}.max-w-max{max-width:-webkit-max-content;max-width:max-content}.max-w-md{max-width:28rem}.flex-1{flex:1 1}.flex-shrink-0,.shrink-0{flex-shrink:0}.grow{flex-grow:1}.grow-0{flex-grow:0}.basis-full{flex-basis:100%}.caption-bottom{caption-side:bottom}.border-collapse{border-collapse:collapse}.origin-\[--radix-context-menu-content-transform-origin\]{transform-origin:var(--radix-context-menu-content-transform-origin)}.origin-\[--radix-dropdown-menu-content-transform-origin\]{transform-origin:var(--radix-dropdown-menu-content-transform-origin)}.origin-\[--radix-hover-card-content-transform-origin\]{transform-origin:var(--radix-hover-card-content-transform-origin)}.origin-\[--radix-menubar-content-transform-origin\]{transform-origin:var(--radix-menubar-content-transform-origin)}.origin-\[--radix-popover-content-transform-origin\]{transform-origin:var(--radix-popover-content-transform-origin)}.origin-\[--radix-select-content-transform-origin\]{transform-origin:var(--radix-select-content-transform-origin)}.origin-\[--radix-tooltip-content-transform-origin\]{transform-origin:var(--radix-tooltip-content-transform-origin)}.-translate-x-1\/2{--tw-translate-x:-50%}.-translate-x-1\/2,.-translate-y-1\/2{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y:-50%}.translate-x-\[-50\%\]{--tw-translate-x:-50%}.translate-x-\[-50\%\],.translate-y-\[-50\%\]{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-y-\[-50\%\]{--tw-translate-y:-50%}.rotate-45{--tw-rotate:45deg}.rotate-45,.rotate-90{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.rotate-90{--tw-rotate:90deg}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.touch-none{touch-action:none}.select-none{-webkit-user-select:none;user-select:none}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-12{gap:3rem}.gap-2{gap:.5rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.space-x-1>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.25rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.25rem*var(--tw-space-x-reverse))}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.5rem*var(--tw-space-x-reverse))}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.75rem*var(--tw-space-x-reverse))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.space-x-8>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(2rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(2rem*var(--tw-space-x-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.25rem*var(--tw-space-y-reverse));margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)))}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.375rem*var(--tw-space-y-reverse));margin-top:calc(.375rem*(1 - var(--tw-space-y-reverse)))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.75rem*var(--tw-space-y-reverse));margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1.5rem*var(--tw-space-y-reverse));margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.overflow-x-hidden{overflow-x:hidden}.whitespace-nowrap{white-space:nowrap}.break-words{overflow-wrap:break-word}.rounded-2xl{border-radius:1rem}.rounded-\[inherit\]{border-radius:inherit}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem;border-radius:var(--radius)}.rounded-md{border-radius:calc(.5rem - 2px);border-radius:calc(var(--radius) - 2px)}.rounded-sm{border-radius:calc(.5rem - 4px);border-radius:calc(var(--radius) - 4px)}.rounded-xl{border-radius:.75rem}.rounded-t-\[10px\]{border-top-left-radius:10px;border-top-right-radius:10px}.rounded-tl-sm{border-top-left-radius:calc(.5rem - 4px);border-top-left-radius:calc(var(--radius) - 4px)}.border{border-width:1px}.border-2{border-width:2px}.border-y{border-top-width:1px}.border-b,.border-y{border-bottom-width:1px}.border-l{border-left-width:1px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-destructive{border-color:#ef4444;border-color:hsl(var(--destructive))}.border-destructive\/50{border-color:#ef444480;border-color:hsl(var(--destructive)/.5)}.border-gray-700{--tw-border-opacity:1;border-color:#374151;border-color:rgb(55 65 81/var(--tw-border-opacity,1))}.border-input{border-color:#e5e5e5;border-color:hsl(var(--input))}.border-primary{border-color:#171717;border-color:hsl(var(--primary))}.border-primary\/50{border-color:#17171780;border-color:hsl(var(--primary)/.5)}.border-red-200{--tw-border-opacity:1;border-color:#fecaca;border-color:rgb(254 202 202/var(--tw-border-opacity,1))}.border-red-500{--tw-border-opacity:1;border-color:#ef4444;border-color:rgb(239 68 68/var(--tw-border-opacity,1))}.border-transparent{border-color:#0000}.border-white{--tw-border-opacity:1;border-color:#fff;border-color:rgb(255 255 255/var(--tw-border-opacity,1))}.border-white\/30{border-color:#ffffff4d}.border-l-transparent{border-left-color:#0000}.border-t-transparent{border-top-color:#0000}.bg-accent{background-color:#f5f5f5;background-color:hsl(var(--accent))}.bg-background{background-color:#fff;background-color:hsl(var(--background))}.bg-black\/80{background-color:#000c}.bg-border{background-color:#e5e5e5;background-color:hsl(var(--border))}.bg-card{background-color:#fff;background-color:hsl(var(--card))}.bg-cyan-600{--tw-bg-opacity:1;background-color:#0891b2;background-color:rgb(8 145 178/var(--tw-bg-opacity,1))}.bg-destructive{background-color:#ef4444;background-color:hsl(var(--destructive))}.bg-foreground{background-color:#0a0a0a;background-color:hsl(var(--foreground))}.bg-gray-50{--tw-bg-opacity:1;background-color:#f9fafb;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.bg-gray-700{--tw-bg-opacity:1;background-color:#374151;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.bg-muted{background-color:#f5f5f5;background-color:hsl(var(--muted))}.bg-muted\/50{background-color:#f5f5f580;background-color:hsl(var(--muted)/.5)}.bg-popover{background-color:#fff;background-color:hsl(var(--popover))}.bg-primary{background-color:#171717;background-color:hsl(var(--primary))}.bg-primary\/10{background-color:#1717171a;background-color:hsl(var(--primary)/.1)}.bg-primary\/20{background-color:#17171733;background-color:hsl(var(--primary)/.2)}.bg-red-500{--tw-bg-opacity:1;background-color:#ef4444;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-secondary{background-color:#f5f5f5;background-color:hsl(var(--secondary))}.bg-transparent{background-color:initial}.bg-white{--tw-bg-opacity:1;background-color:#fff;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-white\/20{background-color:#fff3}.bg-white\/95{background-color:#fffffff2}.bg-gradient-to-br{background-image:linear-gradient(to bottom right,var(--tw-gradient-stops))}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.from-blue-50{--tw-gradient-from:#eff6ff var(--tw-gradient-from-position);--tw-gradient-to:#eff6ff00 var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-cyan-50{--tw-gradient-from:#ecfeff var(--tw-gradient-from-position);--tw-gradient-to:#ecfeff00 var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-cyan-500{--tw-gradient-from:#06b6d4 var(--tw-gradient-from-position);--tw-gradient-to:#06b6d400 var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-cyan-600{--tw-gradient-from:#0891b2 var(--tw-gradient-from-position);--tw-gradient-to:#0891b200 var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-cyan-900\/80{--tw-gradient-from:#164e63cc var(--tw-gradient-from-position);--tw-gradient-to:#164e6300 var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-gray-900{--tw-gradient-from:#111827 var(--tw-gradient-from-position);--tw-gradient-to:#11182700 var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.to-blue-100{--tw-gradient-to:#dbeafe var(--tw-gradient-to-position)}.to-blue-50{--tw-gradient-to:#eff6ff var(--tw-gradient-to-position)}.to-blue-600{--tw-gradient-to:#2563eb var(--tw-gradient-to-position)}.to-blue-700{--tw-gradient-to:#1d4ed8 var(--tw-gradient-to-position)}.to-blue-900\/70{--tw-gradient-to:#1e3a8ab3 var(--tw-gradient-to-position)}.to-cyan-50{--tw-gradient-to:#ecfeff var(--tw-gradient-to-position)}.to-gray-800{--tw-gradient-to:#1f2937 var(--tw-gradient-to-position)}.bg-clip-text{-webkit-background-clip:text;background-clip:text}.fill-current{fill:currentColor}.fill-primary{fill:#171717;fill:hsl(var(--primary))}.fill-yellow-400{fill:#facc15}.object-cover{object-fit:cover}.p-0{padding:0}.p-1{padding:.25rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.p-\[1px\]{padding:1px}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-bottom:.125rem;padding-top:.125rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.py-1\.5{padding-bottom:.375rem;padding-top:.375rem}.py-12{padding-bottom:3rem;padding-top:3rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-20{padding-bottom:5rem;padding-top:5rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.py-8{padding-bottom:2rem;padding-top:2rem}.pb-4{padding-bottom:1rem}.pl-10{padding-left:2.5rem}.pl-2{padding-left:.5rem}.pl-2\.5{padding-left:.625rem}.pl-4{padding-left:1rem}.pl-8{padding-left:2rem}.pr-2{padding-right:.5rem}.pr-2\.5{padding-right:.625rem}.pr-6{padding-right:1.5rem}.pr-8{padding-right:2rem}.pt-0{padding-top:0}.pt-1{padding-top:.25rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.pt-8{padding-top:2rem}.text-left{text-align:left}.text-center{text-align:center}.align-middle{vertical-align:middle}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-\[0\.8rem\]{font-size:.8rem}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.italic{font-style:italic}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.tracking-tight{letter-spacing:-.025em}.tracking-widest{letter-spacing:.1em}.text-accent-foreground{color:#171717;color:hsl(var(--accent-foreground))}.text-card-foreground{color:#0a0a0a;color:hsl(var(--card-foreground))}.text-current{color:currentColor}.text-cyan-100{--tw-text-opacity:1;color:#cffafe;color:rgb(207 250 254/var(--tw-text-opacity,1))}.text-cyan-400{--tw-text-opacity:1;color:#22d3ee;color:rgb(34 211 238/var(--tw-text-opacity,1))}.text-cyan-50{--tw-text-opacity:1;color:#ecfeff;color:rgb(236 254 255/var(--tw-text-opacity,1))}.text-cyan-600{--tw-text-opacity:1;color:#0891b2;color:rgb(8 145 178/var(--tw-text-opacity,1))}.text-destructive{color:#ef4444;color:hsl(var(--destructive))}.text-destructive-foreground{color:#fafafa;color:hsl(var(--destructive-foreground))}.text-foreground{color:#0a0a0a;color:hsl(var(--foreground))}.text-foreground\/50{color:#0a0a0a80;color:hsl(var(--foreground)/.5)}.text-gray-300{--tw-text-opacity:1;color:#d1d5db;color:rgb(209 213 219/var(--tw-text-opacity,1))}.text-gray-400{--tw-text-opacity:1;color:#9ca3af;color:rgb(156 163 175/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:#6b7280;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-600{--tw-text-opacity:1;color:#4b5563;color:rgb(75 85 99/var(--tw-text-opacity,1))}.text-gray-700{--tw-text-opacity:1;color:#374151;color:rgb(55 65 81/var(--tw-text-opacity,1))}.text-gray-900{--tw-text-opacity:1;color:#111827;color:rgb(17 24 39/var(--tw-text-opacity,1))}.text-muted-foreground{color:#737373;color:hsl(var(--muted-foreground))}.text-popover-foreground{color:#0a0a0a;color:hsl(var(--popover-foreground))}.text-primary{color:#171717;color:hsl(var(--primary))}.text-primary-foreground{color:#fafafa;color:hsl(var(--primary-foreground))}.text-red-500{--tw-text-opacity:1;color:#ef4444;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:#dc2626;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-secondary-foreground{color:#171717;color:hsl(var(--secondary-foreground))}.text-transparent{color:#0000}.text-white{--tw-text-opacity:1;color:#fff;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-yellow-400{--tw-text-opacity:1;color:#facc15;color:rgb(250 204 21/var(--tw-text-opacity,1))}.line-through{-webkit-text-decoration-line:line-through;text-decoration-line:line-through}.underline-offset-4{text-underline-offset:4px}.opacity-0{opacity:0}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-90{opacity:.9}.shadow{--tw-shadow:0 1px 3px 0 #0000001a,0 1px 2px -1px #0000001a;--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-lg{box-shadow:0 0 #0000,0 0 #0000,var(--tw-shadow);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px #0000001a,0 4px 6px -4px #0000001a;--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-md{--tw-shadow:0 4px 6px -1px #0000001a,0 2px 4px -2px #0000001a;--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-md,.shadow-sm{box-shadow:0 0 #0000,0 0 #0000,var(--tw-shadow);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 2px 0 #0000000d;--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.outline-none{outline:2px solid #0000;outline-offset:2px}.outline{outline-style:solid}.ring-0{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring-0,.ring-1{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),0 0 #0000;box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-1{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring-ring{--tw-ring-color:hsl(var(--ring))}.ring-offset-background{--tw-ring-offset-color:hsl(var(--background))}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-md{--tw-backdrop-blur:blur(12px)}.backdrop-blur-md,.backdrop-blur-sm{-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.backdrop-blur-sm{--tw-backdrop-blur:blur(4px)}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-text-decoration-color,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-text-decoration-color,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-all{transition-duration:.15s;transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-colors{transition-duration:.15s;transition-property:color,background-color,border-color,fill,stroke,-webkit-text-decoration-color;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,-webkit-text-decoration-color;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-opacity{transition-duration:.15s;transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-shadow{transition-duration:.15s;transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-transform{transition-duration:.15s;transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1)}.delay-100{transition-delay:.1s}.delay-200{transition-delay:.2s}.duration-1000{transition-duration:1s}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.duration-500{transition-duration:.5s}.duration-700{transition-duration:.7s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}@keyframes enter{0%{opacity:1;opacity:var(--tw-enter-opacity,1);transform:translateZ(0) scaleX(1) rotate(0);transform:translate3d(var(--tw-enter-translate-x,0),var(--tw-enter-translate-y,0),0) scale3d(var(--tw-enter-scale,1),var(--tw-enter-scale,1),var(--tw-enter-scale,1)) rotate(var(--tw-enter-rotate,0))}}@keyframes exit{to{opacity:1;opacity:var(--tw-exit-opacity,1);transform:translateZ(0) scaleX(1) rotate(0);transform:translate3d(var(--tw-exit-translate-x,0),var(--tw-exit-translate-y,0),0) scale3d(var(--tw-exit-scale,1),var(--tw-exit-scale,1),var(--tw-exit-scale,1)) rotate(var(--tw-exit-rotate,0))}}.animate-in{--tw-enter-opacity:initial;--tw-enter-scale:initial;--tw-enter-rotate:initial;--tw-enter-translate-x:initial;--tw-enter-translate-y:initial;animation-duration:.15s;animation-name:enter}.fade-in,.fade-in-0{--tw-enter-opacity:0}.zoom-in-95{--tw-enter-scale:.95}.slide-in-from-bottom{--tw-enter-translate-y:100%}.slide-in-from-top{--tw-enter-translate-y:-100%}.duration-1000{animation-duration:1s}.duration-200{animation-duration:.2s}.duration-300{animation-duration:.3s}.duration-500{animation-duration:.5s}.duration-700{animation-duration:.7s}.delay-100{animation-delay:.1s}.delay-200{animation-delay:.2s}.ease-in-out{animation-timing-function:cubic-bezier(.4,0,.2,1)}.running{animation-play-state:running}body{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,Fira Sans,Droid Sans,Helvetica Neue,sans-serif;margin:0}code{font-family:source-code-pro,Menlo,Monaco,Consolas,Courier New,monospace}.file\:border-0::-webkit-file-upload-button{border-width:0}.file\:border-0::file-selector-button{border-width:0}.file\:bg-transparent::-webkit-file-upload-button{background-color:initial}.file\:bg-transparent::file-selector-button{background-color:initial}.file\:text-sm::-webkit-file-upload-button{font-size:.875rem;line-height:1.25rem}.file\:text-sm::file-selector-button{font-size:.875rem;line-height:1.25rem}.file\:font-medium::-webkit-file-upload-button{font-weight:500}.file\:font-medium::file-selector-button{font-weight:500}.file\:text-foreground::-webkit-file-upload-button{color:#0a0a0a;color:hsl(var(--foreground))}.file\:text-foreground::file-selector-button{color:#0a0a0a;color:hsl(var(--foreground))}.placeholder\:text-cyan-100::placeholder{--tw-text-opacity:1;color:#cffafe;color:rgb(207 250 254/var(--tw-text-opacity,1))}.placeholder\:text-muted-foreground::placeholder{color:#737373;color:hsl(var(--muted-foreground))}.after\:absolute:after{content:var(--tw-content);position:absolute}.after\:inset-y-0:after{bottom:0;content:var(--tw-content);top:0}.after\:left-1\/2:after{content:var(--tw-content);left:50%}.after\:w-1:after{content:var(--tw-content);width:.25rem}.after\:-translate-x-1\/2:after{--tw-translate-x:-50%;content:var(--tw-content);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.first\:rounded-l-md:first-child{border-bottom-left-radius:calc(.5rem - 2px);border-bottom-left-radius:calc(var(--radius) - 2px);border-top-left-radius:calc(.5rem - 2px);border-top-left-radius:calc(var(--radius) - 2px)}.first\:border-l:first-child{border-left-width:1px}.last\:rounded-r-md:last-child{border-bottom-right-radius:calc(.5rem - 2px);border-bottom-right-radius:calc(var(--radius) - 2px);border-top-right-radius:calc(.5rem - 2px);border-top-right-radius:calc(var(--radius) - 2px)}.focus-within\:relative:focus-within{position:relative}.focus-within\:z-20:focus-within{z-index:20}.hover\:bg-accent:hover{background-color:#f5f5f5;background-color:hsl(var(--accent))}.hover\:bg-cyan-50:hover{--tw-bg-opacity:1;background-color:#ecfeff;background-color:rgb(236 254 255/var(--tw-bg-opacity,1))}.hover\:bg-cyan-600:hover{--tw-bg-opacity:1;background-color:#0891b2;background-color:rgb(8 145 178/var(--tw-bg-opacity,1))}.hover\:bg-cyan-700:hover{--tw-bg-opacity:1;background-color:#0e7490;background-color:rgb(14 116 144/var(--tw-bg-opacity,1))}.hover\:bg-destructive\/80:hover{background-color:#ef4444cc;background-color:hsl(var(--destructive)/.8)}.hover\:bg-destructive\/90:hover{background-color:#ef4444e6;background-color:hsl(var(--destructive)/.9)}.hover\:bg-muted:hover{background-color:#f5f5f5;background-color:hsl(var(--muted))}.hover\:bg-muted\/50:hover{background-color:#f5f5f580;background-color:hsl(var(--muted)/.5)}.hover\:bg-primary:hover{background-color:#171717;background-color:hsl(var(--primary))}.hover\:bg-primary\/80:hover{background-color:#171717cc;background-color:hsl(var(--primary)/.8)}.hover\:bg-primary\/90:hover{background-color:#171717e6;background-color:hsl(var(--primary)/.9)}.hover\:bg-red-50:hover{--tw-bg-opacity:1;background-color:#fef2f2;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.hover\:bg-secondary:hover{background-color:#f5f5f5;background-color:hsl(var(--secondary))}.hover\:bg-secondary\/80:hover{background-color:#f5f5f5cc;background-color:hsl(var(--secondary)/.8)}.hover\:bg-white:hover{--tw-bg-opacity:1;background-color:#fff;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.hover\:text-accent-foreground:hover{color:#171717;color:hsl(var(--accent-foreground))}.hover\:text-cyan-400:hover{--tw-text-opacity:1;color:#22d3ee;color:rgb(34 211 238/var(--tw-text-opacity,1))}.hover\:text-cyan-600:hover{--tw-text-opacity:1;color:#0891b2;color:rgb(8 145 178/var(--tw-text-opacity,1))}.hover\:text-foreground:hover{color:#0a0a0a;color:hsl(var(--foreground))}.hover\:text-muted-foreground:hover{color:#737373;color:hsl(var(--muted-foreground))}.hover\:text-primary-foreground:hover{color:#fafafa;color:hsl(var(--primary-foreground))}.hover\:underline:hover{-webkit-text-decoration-line:underline;text-decoration-line:underline}.hover\:opacity-100:hover{opacity:1}.hover\:shadow-2xl:hover{--tw-shadow:0 25px 50px -12px #00000040;--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.hover\:shadow-2xl:hover,.hover\:shadow-xl:hover{box-shadow:0 0 #0000,0 0 #0000,var(--tw-shadow);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:shadow-xl:hover{--tw-shadow:0 20px 25px -5px #0000001a,0 8px 10px -6px #0000001a;--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color)}.focus\:bg-accent:focus{background-color:#f5f5f5;background-color:hsl(var(--accent))}.focus\:bg-primary:focus{background-color:#171717;background-color:hsl(var(--primary))}.focus\:text-accent-foreground:focus{color:#171717;color:hsl(var(--accent-foreground))}.focus\:text-primary-foreground:focus{color:#fafafa;color:hsl(var(--primary-foreground))}.focus\:opacity-100:focus{opacity:1}.focus\:outline-none:focus{outline:2px solid #0000;outline-offset:2px}.focus\:ring-1:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.focus\:ring-1:focus,.focus\:ring-2:focus{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),0 0 #0000;box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.focus\:ring-ring:focus{--tw-ring-color:hsl(var(--ring))}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px}.focus-visible\:outline-none:focus-visible{outline:2px solid #0000;outline-offset:2px}.focus-visible\:ring-1:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),0 0 #0000;box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus-visible\:ring-2:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),0 0 #0000;box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus-visible\:ring-ring:focus-visible{--tw-ring-color:hsl(var(--ring))}.focus-visible\:ring-offset-1:focus-visible{--tw-ring-offset-width:1px}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width:2px}.focus-visible\:ring-offset-background:focus-visible{--tw-ring-offset-color:hsl(var(--background))}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:scale-110{--tw-scale-x:1.1;--tw-scale-y:1.1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:opacity-100{opacity:1}.group.destructive .group-\[\.destructive\]\:border-muted\/40{border-color:#f5f5f566;border-color:hsl(var(--muted)/.4)}.group.toaster .group-\[\.toaster\]\:border-border{border-color:#e5e5e5;border-color:hsl(var(--border))}.group.toast .group-\[\.toast\]\:bg-muted{background-color:#f5f5f5;background-color:hsl(var(--muted))}.group.toast .group-\[\.toast\]\:bg-primary{background-color:#171717;background-color:hsl(var(--primary))}.group.toaster .group-\[\.toaster\]\:bg-background{background-color:#fff;background-color:hsl(var(--background))}.group.destructive .group-\[\.destructive\]\:text-red-300{--tw-text-opacity:1;color:#fca5a5;color:rgb(252 165 165/var(--tw-text-opacity,1))}.group.toast .group-\[\.toast\]\:text-muted-foreground{color:#737373;color:hsl(var(--muted-foreground))}.group.toast .group-\[\.toast\]\:text-primary-foreground{color:#fafafa;color:hsl(var(--primary-foreground))}.group.toaster .group-\[\.toaster\]\:text-foreground{color:#0a0a0a;color:hsl(var(--foreground))}.group.toaster .group-\[\.toaster\]\:shadow-lg{--tw-shadow:0 10px 15px -3px #0000001a,0 4px 6px -4px #0000001a;--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color);box-shadow:0 0 #0000,0 0 #0000,var(--tw-shadow);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.group.destructive .group-\[\.destructive\]\:hover\:border-destructive\/30:hover{border-color:#ef44444d;border-color:hsl(var(--destructive)/.3)}.group.destructive .group-\[\.destructive\]\:hover\:bg-destructive:hover{background-color:#ef4444;background-color:hsl(var(--destructive))}.group.destructive .group-\[\.destructive\]\:hover\:text-destructive-foreground:hover{color:#fafafa;color:hsl(var(--destructive-foreground))}.group.destructive .group-\[\.destructive\]\:hover\:text-red-50:hover{--tw-text-opacity:1;color:#fef2f2;color:rgb(254 242 242/var(--tw-text-opacity,1))}.group.destructive .group-\[\.destructive\]\:focus\:ring-destructive:focus{--tw-ring-color:hsl(var(--destructive))}.group.destructive .group-\[\.destructive\]\:focus\:ring-red-400:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(248 113 113/var(--tw-ring-opacity,1))}.group.destructive .group-\[\.destructive\]\:focus\:ring-offset-red-600:focus{--tw-ring-offset-color:#dc2626}.peer:disabled~.peer-disabled\:cursor-not-allowed{cursor:not-allowed}.peer:disabled~.peer-disabled\:opacity-70{opacity:.7}.has-\[\:disabled\]\:opacity-50:has(:disabled){opacity:.5}.aria-selected\:bg-accent[aria-selected=true]{background-color:#f5f5f5;background-color:hsl(var(--accent))}.aria-selected\:bg-accent\/50[aria-selected=true]{background-color:#f5f5f580;background-color:hsl(var(--accent)/.5)}.aria-selected\:text-accent-foreground[aria-selected=true]{color:#171717;color:hsl(var(--accent-foreground))}.aria-selected\:text-muted-foreground[aria-selected=true]{color:#737373;color:hsl(var(--muted-foreground))}.aria-selected\:opacity-100[aria-selected=true]{opacity:1}.data-\[disabled\=true\]\:pointer-events-none[data-disabled=true],.data-\[disabled\]\:pointer-events-none[data-disabled]{pointer-events:none}.data-\[panel-group-direction\=vertical\]\:h-px[data-panel-group-direction=vertical]{height:1px}.data-\[panel-group-direction\=vertical\]\:w-full[data-panel-group-direction=vertical]{width:100%}.data-\[side\=bottom\]\:translate-y-1[data-side=bottom]{--tw-translate-y:0.25rem}.data-\[side\=bottom\]\:translate-y-1[data-side=bottom],.data-\[side\=left\]\:-translate-x-1[data-side=left]{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.data-\[side\=left\]\:-translate-x-1[data-side=left]{--tw-translate-x:-0.25rem}.data-\[side\=right\]\:translate-x-1[data-side=right]{--tw-translate-x:0.25rem}.data-\[side\=right\]\:translate-x-1[data-side=right],.data-\[side\=top\]\:-translate-y-1[data-side=top]{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.data-\[side\=top\]\:-translate-y-1[data-side=top]{--tw-translate-y:-0.25rem}.data-\[state\=checked\]\:translate-x-4[data-state=checked]{--tw-translate-x:1rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.data-\[state\=unchecked\]\:translate-x-0[data-state=unchecked],.data-\[swipe\=cancel\]\:translate-x-0[data-swipe=cancel]{--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.data-\[swipe\=end\]\:translate-x-\[var\(--radix-toast-swipe-end-x\)\][data-swipe=end]{--tw-translate-x:var(--radix-toast-swipe-end-x)}.data-\[swipe\=end\]\:translate-x-\[var\(--radix-toast-swipe-end-x\)\][data-swipe=end],.data-\[swipe\=move\]\:translate-x-\[var\(--radix-toast-swipe-move-x\)\][data-swipe=move]{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.data-\[swipe\=move\]\:translate-x-\[var\(--radix-toast-swipe-move-x\)\][data-swipe=move]{--tw-translate-x:var(--radix-toast-swipe-move-x)}@keyframes accordion-up{0%{height:var(--radix-accordion-content-height)}to{height:0}}.data-\[state\=closed\]\:animate-accordion-up[data-state=closed]{animation:accordion-up .2s ease-out}@keyframes accordion-down{0%{height:0}to{height:var(--radix-accordion-content-height)}}.data-\[state\=open\]\:animate-accordion-down[data-state=open]{animation:accordion-down .2s ease-out}.data-\[panel-group-direction\=vertical\]\:flex-col[data-panel-group-direction=vertical]{flex-direction:column}.data-\[selected\=true\]\:bg-accent[data-selected=true]{background-color:#f5f5f5;background-color:hsl(var(--accent))}.data-\[state\=active\]\:bg-background[data-state=active]{background-color:#fff;background-color:hsl(var(--background))}.data-\[state\=checked\]\:bg-primary[data-state=checked]{background-color:#171717;background-color:hsl(var(--primary))}.data-\[state\=on\]\:bg-accent[data-state=on],.data-\[state\=open\]\:bg-accent[data-state=open]{background-color:#f5f5f5;background-color:hsl(var(--accent))}.data-\[state\=open\]\:bg-accent\/50[data-state=open]{background-color:#f5f5f580;background-color:hsl(var(--accent)/.5)}.data-\[state\=open\]\:bg-secondary[data-state=open]{background-color:#f5f5f5;background-color:hsl(var(--secondary))}.data-\[state\=selected\]\:bg-muted[data-state=selected]{background-color:#f5f5f5;background-color:hsl(var(--muted))}.data-\[state\=unchecked\]\:bg-input[data-state=unchecked]{background-color:#e5e5e5;background-color:hsl(var(--input))}.data-\[placeholder\]\:text-muted-foreground[data-placeholder]{color:#737373;color:hsl(var(--muted-foreground))}.data-\[selected\=true\]\:text-accent-foreground[data-selected=true]{color:#171717;color:hsl(var(--accent-foreground))}.data-\[state\=active\]\:text-foreground[data-state=active]{color:#0a0a0a;color:hsl(var(--foreground))}.data-\[state\=checked\]\:text-primary-foreground[data-state=checked]{color:#fafafa;color:hsl(var(--primary-foreground))}.data-\[state\=on\]\:text-accent-foreground[data-state=on],.data-\[state\=open\]\:text-accent-foreground[data-state=open]{color:#171717;color:hsl(var(--accent-foreground))}.data-\[state\=open\]\:text-muted-foreground[data-state=open]{color:#737373;color:hsl(var(--muted-foreground))}.data-\[disabled\=true\]\:opacity-50[data-disabled=true],.data-\[disabled\]\:opacity-50[data-disabled]{opacity:.5}.data-\[state\=active\]\:shadow[data-state=active]{--tw-shadow:0 1px 3px 0 #0000001a,0 1px 2px -1px #0000001a;--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color);box-shadow:0 0 #0000,0 0 #0000,var(--tw-shadow);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.data-\[swipe\=move\]\:transition-none[data-swipe=move]{transition-property:none}.data-\[state\=closed\]\:duration-300[data-state=closed]{transition-duration:.3s}.data-\[state\=open\]\:duration-500[data-state=open]{transition-duration:.5s}.data-\[motion\^\=from-\]\:animate-in[data-motion^=from-],.data-\[state\=open\]\:animate-in[data-state=open],.data-\[state\=visible\]\:animate-in[data-state=visible]{--tw-enter-opacity:initial;--tw-enter-scale:initial;--tw-enter-rotate:initial;--tw-enter-translate-x:initial;--tw-enter-translate-y:initial;animation-duration:.15s;animation-name:enter}.data-\[motion\^\=to-\]\:animate-out[data-motion^=to-],.data-\[state\=closed\]\:animate-out[data-state=closed],.data-\[state\=hidden\]\:animate-out[data-state=hidden],.data-\[swipe\=end\]\:animate-out[data-swipe=end]{--tw-exit-opacity:initial;--tw-exit-scale:initial;--tw-exit-rotate:initial;--tw-exit-translate-x:initial;--tw-exit-translate-y:initial;animation-duration:.15s;animation-name:exit}.data-\[motion\^\=from-\]\:fade-in[data-motion^=from-]{--tw-enter-opacity:0}.data-\[motion\^\=to-\]\:fade-out[data-motion^=to-],.data-\[state\=closed\]\:fade-out-0[data-state=closed]{--tw-exit-opacity:0}.data-\[state\=closed\]\:fade-out-80[data-state=closed]{--tw-exit-opacity:0.8}.data-\[state\=hidden\]\:fade-out[data-state=hidden]{--tw-exit-opacity:0}.data-\[state\=open\]\:fade-in-0[data-state=open],.data-\[state\=visible\]\:fade-in[data-state=visible]{--tw-enter-opacity:0}.data-\[state\=closed\]\:zoom-out-95[data-state=closed]{--tw-exit-scale:.95}.data-\[state\=open\]\:zoom-in-90[data-state=open]{--tw-enter-scale:.9}.data-\[state\=open\]\:zoom-in-95[data-state=open]{--tw-enter-scale:.95}.data-\[motion\=from-end\]\:slide-in-from-right-52[data-motion=from-end]{--tw-enter-translate-x:13rem}.data-\[motion\=from-start\]\:slide-in-from-left-52[data-motion=from-start]{--tw-enter-translate-x:-13rem}.data-\[motion\=to-end\]\:slide-out-to-right-52[data-motion=to-end]{--tw-exit-translate-x:13rem}.data-\[motion\=to-start\]\:slide-out-to-left-52[data-motion=to-start]{--tw-exit-translate-x:-13rem}.data-\[side\=bottom\]\:slide-in-from-top-2[data-side=bottom]{--tw-enter-translate-y:-0.5rem}.data-\[side\=left\]\:slide-in-from-right-2[data-side=left]{--tw-enter-translate-x:0.5rem}.data-\[side\=right\]\:slide-in-from-left-2[data-side=right]{--tw-enter-translate-x:-0.5rem}.data-\[side\=top\]\:slide-in-from-bottom-2[data-side=top]{--tw-enter-translate-y:0.5rem}.data-\[state\=closed\]\:slide-out-to-bottom[data-state=closed]{--tw-exit-translate-y:100%}.data-\[state\=closed\]\:slide-out-to-left[data-state=closed]{--tw-exit-translate-x:-100%}.data-\[state\=closed\]\:slide-out-to-left-1\/2[data-state=closed]{--tw-exit-translate-x:-50%}.data-\[state\=closed\]\:slide-out-to-right-full[data-state=closed],.data-\[state\=closed\]\:slide-out-to-right[data-state=closed]{--tw-exit-translate-x:100%}.data-\[state\=closed\]\:slide-out-to-top[data-state=closed]{--tw-exit-translate-y:-100%}.data-\[state\=closed\]\:slide-out-to-top-\[48\%\][data-state=closed]{--tw-exit-translate-y:-48%}.data-\[state\=open\]\:slide-in-from-bottom[data-state=open]{--tw-enter-translate-y:100%}.data-\[state\=open\]\:slide-in-from-left[data-state=open]{--tw-enter-translate-x:-100%}.data-\[state\=open\]\:slide-in-from-left-1\/2[data-state=open]{--tw-enter-translate-x:-50%}.data-\[state\=open\]\:slide-in-from-right[data-state=open]{--tw-enter-translate-x:100%}.data-\[state\=open\]\:slide-in-from-top[data-state=open]{--tw-enter-translate-y:-100%}.data-\[state\=open\]\:slide-in-from-top-\[48\%\][data-state=open]{--tw-enter-translate-y:-48%}.data-\[state\=open\]\:slide-in-from-top-full[data-state=open]{--tw-enter-translate-y:-100%}.data-\[state\=closed\]\:duration-300[data-state=closed]{animation-duration:.3s}.data-\[state\=open\]\:duration-500[data-state=open]{animation-duration:.5s}.data-\[panel-group-direction\=vertical\]\:after\:left-0[data-panel-group-direction=vertical]:after{content:var(--tw-content);left:0}.data-\[panel-group-direction\=vertical\]\:after\:h-1[data-panel-group-direction=vertical]:after{content:var(--tw-content);height:.25rem}.data-\[panel-group-direction\=vertical\]\:after\:w-full[data-panel-group-direction=vertical]:after{content:var(--tw-content);width:100%}.data-\[panel-group-direction\=vertical\]\:after\:-translate-y-1\/2[data-panel-group-direction=vertical]:after{--tw-translate-y:-50%;content:var(--tw-content);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.data-\[panel-group-direction\=vertical\]\:after\:translate-x-0[data-panel-group-direction=vertical]:after{--tw-translate-x:0px;content:var(--tw-content);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.data-\[state\=open\]\:focus\:bg-accent:focus[data-state=open],.data-\[state\=open\]\:hover\:bg-accent:hover[data-state=open]{background-color:#f5f5f5;background-color:hsl(var(--accent))}.group[data-state=open] .group-data-\[state\=open\]\:rotate-180{--tw-rotate:180deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.dark\:border-destructive:is(.dark *){border-color:#ef4444;border-color:hsl(var(--destructive))}@media (min-width:640px){.sm\:bottom-0{bottom:0}.sm\:right-0{right:0}.sm\:top-auto{top:auto}.sm\:mt-0{margin-top:0}.sm\:max-w-sm{max-width:24rem}.sm\:flex-row{flex-direction:row}.sm\:flex-col{flex-direction:column}.sm\:justify-end{justify-content:flex-end}.sm\:gap-2\.5{gap:.625rem}.sm\:space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.5rem*var(--tw-space-x-reverse))}.sm\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.sm\:space-y-0>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(0px*var(--tw-space-y-reverse));margin-top:calc(0px*(1 - var(--tw-space-y-reverse)))}.sm\:rounded-lg{border-radius:.5rem;border-radius:var(--radius)}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}.sm\:text-left{text-align:left}.data-\[state\=open\]\:sm\:slide-in-from-bottom-full[data-state=open]{--tw-enter-translate-y:100%}}@media (min-width:768px){.md\:absolute{position:absolute}.md\:flex{display:flex}.md\:hidden{display:none}.md\:w-96{width:24rem}.md\:w-\[var\(--radix-navigation-menu-viewport-width\)\]{width:var(--radix-navigation-menu-viewport-width)}.md\:w-auto{width:auto}.md\:max-w-\[420px\]{max-width:420px}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:p-12{padding:3rem}.md\:text-2xl{font-size:1.5rem;line-height:2rem}.md\:text-4xl{font-size:2.25rem;line-height:2.5rem}.md\:text-5xl{font-size:3rem;line-height:1}.md\:text-7xl{font-size:4.5rem;line-height:1}.md\:text-sm{font-size:.875rem;line-height:1.25rem}}@media (min-width:1024px){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:px-8{padding-left:2rem;padding-right:2rem}}.\[\&\+div\]\:text-xs+div{font-size:.75rem;line-height:1rem}.\[\&\:has\(\>\.day-range-end\)\]\:rounded-r-md:has(>.day-range-end){border-bottom-right-radius:calc(.5rem - 2px);border-bottom-right-radius:calc(var(--radius) - 2px);border-top-right-radius:calc(.5rem - 2px);border-top-right-radius:calc(var(--radius) - 2px)}.\[\&\:has\(\>\.day-range-start\)\]\:rounded-l-md:has(>.day-range-start){border-bottom-left-radius:calc(.5rem - 2px);border-bottom-left-radius:calc(var(--radius) - 2px);border-top-left-radius:calc(.5rem - 2px);border-top-left-radius:calc(var(--radius) - 2px)}.\[\&\:has\(\[aria-selected\]\)\]\:rounded-md:has([aria-selected]){border-radius:calc(.5rem - 2px);border-radius:calc(var(--radius) - 2px)}.\[\&\:has\(\[aria-selected\]\)\]\:bg-accent:has([aria-selected]){background-color:#f5f5f5;background-color:hsl(var(--accent))}.first\:\[\&\:has\(\[aria-selected\]\)\]\:rounded-l-md:has([aria-selected]):first-child{border-bottom-left-radius:calc(.5rem - 2px);border-bottom-left-radius:calc(var(--radius) - 2px);border-top-left-radius:calc(.5rem - 2px);border-top-left-radius:calc(var(--radius) - 2px)}.last\:\[\&\:has\(\[aria-selected\]\)\]\:rounded-r-md:has([aria-selected]):last-child{border-bottom-right-radius:calc(.5rem - 2px);border-bottom-right-radius:calc(var(--radius) - 2px);border-top-right-radius:calc(.5rem - 2px);border-top-right-radius:calc(var(--radius) - 2px)}.\[\&\:has\(\[aria-selected\]\.day-outside\)\]\:bg-accent\/50:has([aria-selected].day-outside){background-color:#f5f5f580;background-color:hsl(var(--accent)/.5)}.\[\&\:has\(\[aria-selected\]\.day-range-end\)\]\:rounded-r-md:has([aria-selected].day-range-end){border-bottom-right-radius:calc(.5rem - 2px);border-bottom-right-radius:calc(var(--radius) - 2px);border-top-right-radius:calc(.5rem - 2px);border-top-right-radius:calc(var(--radius) - 2px)}.\[\&\:has\(\[role\=checkbox\]\)\]\:pr-0:has([role=checkbox]){padding-right:0}.\[\&\>\[role\=checkbox\]\]\:translate-y-\[2px\]>[role=checkbox]{--tw-translate-y:2px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.\[\&\>span\]\:line-clamp-1>span{-webkit-box-orient:vertical;-webkit-line-clamp:1;display:-webkit-box;overflow:hidden}.\[\&\>svg\+div\]\:translate-y-\[-3px\]>svg+div{--tw-translate-y:-3px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.\[\&\>svg\]\:absolute>svg{position:absolute}.\[\&\>svg\]\:left-4>svg{left:1rem}.\[\&\>svg\]\:top-4>svg{top:1rem}.\[\&\>svg\]\:size-4>svg{height:1rem;width:1rem}.\[\&\>svg\]\:h-3\.5>svg{height:.875rem}.\[\&\>svg\]\:w-3\.5>svg{width:.875rem}.\[\&\>svg\]\:shrink-0>svg{flex-shrink:0}.\[\&\>svg\]\:text-destructive>svg{color:#ef4444;color:hsl(var(--destructive))}.\[\&\>svg\]\:text-foreground>svg{color:#0a0a0a;color:hsl(var(--foreground))}.\[\&\>svg\~\*\]\:pl-7>svg~*{padding-left:1.75rem}.\[\&\>tr\]\:last\:border-b-0:last-child>tr{border-bottom-width:0}.\[\&\[data-panel-group-direction\=vertical\]\>div\]\:rotate-90[data-panel-group-direction=vertical]>div{--tw-rotate:90deg}.\[\&\[data-panel-group-direction\=vertical\]\>div\]\:rotate-90[data-panel-group-direction=vertical]>div,.\[\&\[data-state\=open\]\>svg\]\:rotate-180[data-state=open]>svg{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.\[\&\[data-state\=open\]\>svg\]\:rotate-180[data-state=open]>svg{--tw-rotate:180deg}.\[\&_\[cmdk-group-heading\]\]\:px-2 [cmdk-group-heading]{padding-left:.5rem;padding-right:.5rem}.\[\&_\[cmdk-group-heading\]\]\:py-1\.5 [cmdk-group-heading]{padding-bottom:.375rem;padding-top:.375rem}.\[\&_\[cmdk-group-heading\]\]\:text-xs [cmdk-group-heading]{font-size:.75rem;line-height:1rem}.\[\&_\[cmdk-group-heading\]\]\:font-medium [cmdk-group-heading]{font-weight:500}.\[\&_\[cmdk-group-heading\]\]\:text-muted-foreground [cmdk-group-heading]{color:#737373;color:hsl(var(--muted-foreground))}.\[\&_\[cmdk-group\]\:not\(\[hidden\]\)_\~\[cmdk-group\]\]\:pt-0 [cmdk-group]:not([hidden])~[cmdk-group]{padding-top:0}.\[\&_\[cmdk-group\]\]\:px-2 [cmdk-group]{padding-left:.5rem;padding-right:.5rem}.\[\&_\[cmdk-input-wrapper\]_svg\]\:h-5 [cmdk-input-wrapper] svg{height:1.25rem}.\[\&_\[cmdk-input-wrapper\]_svg\]\:w-5 [cmdk-input-wrapper] svg{width:1.25rem}.\[\&_\[cmdk-input\]\]\:h-12 [cmdk-input]{height:3rem}.\[\&_\[cmdk-item\]\]\:px-2 [cmdk-item]{padding-left:.5rem;padding-right:.5rem}.\[\&_\[cmdk-item\]\]\:py-3 [cmdk-item]{padding-bottom:.75rem;padding-top:.75rem}.\[\&_\[cmdk-item\]_svg\]\:h-5 [cmdk-item] svg{height:1.25rem}.\[\&_\[cmdk-item\]_svg\]\:w-5 [cmdk-item] svg{width:1.25rem}.\[\&_p\]\:leading-relaxed p{line-height:1.625}.\[\&_svg\]\:pointer-events-none svg{pointer-events:none}.\[\&_svg\]\:size-4 svg{height:1rem;width:1rem}.\[\&_svg\]\:shrink-0 svg{flex-shrink:0}.\[\&_tr\:last-child\]\:border-0 tr:last-child{border-width:0}.\[\&_tr\]\:border-b tr{border-bottom-width:1px}.App-logo{height:40vmin;pointer-events:none}@media (prefers-reduced-motion:no-preference){.App-logo{animation:App-logo-spin 20s linear infinite}}.App-header{align-items:center;background-color:#0f0f10;color:#fff;display:flex;flex-direction:column;font-size:calc(10px + 2vmin);justify-content:center;min-height:100vh}.App-link{color:#61dafb}@keyframes App-logo-spin{0%{transform:rotate(0deg)}to{transform:rotate(1turn)}} +/*# sourceMappingURL=main.7791f349.css.map*/ \ No newline at end of file diff --git a/sites/epictravelexpeditions.com/public_html/static/css/main.7791f349.css.map b/sites/epictravelexpeditions.com/public_html/static/css/main.7791f349.css.map new file mode 100644 index 0000000..f554559 --- /dev/null +++ b/sites/epictravelexpeditions.com/public_html/static/css/main.7791f349.css.map @@ -0,0 +1 @@ +{"version":3,"file":"static/css/main.7791f349.css","mappings":"AAAA,wCAAc,CAAd,uBAAc,CAAd,kBAAc,CAAd,kBAAc,CAAd,aAAc,CAAd,aAAc,CAAd,aAAc,CAAd,cAAc,CAAd,cAAc,CAAd,YAAc,CAAd,YAAc,CAAd,iBAAc,CAAd,qCAAc,CAAd,6BAAc,CAAd,4BAAc,CAAd,2BAAc,CAAd,cAAc,CAAd,mBAAc,CAAd,qBAAc,CAAd,sBAAc,CAAd,uBAAc,CAAd,iBAAc,CAAd,0BAAc,CAAd,2BAAc,CAAd,yBAAc,CAAd,iCAAc,CAAd,0BAAc,CAAd,qBAAc,CAAd,6BAAc,CAAd,WAAc,CAAd,iBAAc,CAAd,eAAc,CAAd,gBAAc,CAAd,iBAAc,CAAd,aAAc,CAAd,eAAc,CAAd,YAAc,CAAd,kBAAc,CAAd,oBAAc,CAAd,0BAAc,CAAd,wBAAc,CAAd,yBAAc,CAAd,0BAAc,CAAd,sBAAc,CAAd,uBAAc,CAAd,wBAAc,CAAd,qBAAc,CAAd,mBAAc,CAAd,qBAAc,CAAd,oBAAc,CAAd,oBAAc,CAAd,kCAAc,CAAd,uBAAc,CAAd,kBAAc,CAAd,kBAAc,CAAd,aAAc,CAAd,aAAc,CAAd,aAAc,CAAd,cAAc,CAAd,cAAc,CAAd,YAAc,CAAd,YAAc,CAAd,iBAAc,CAAd,qCAAc,CAAd,6BAAc,CAAd,4BAAc,CAAd,2BAAc,CAAd,cAAc,CAAd,mBAAc,CAAd,qBAAc,CAAd,sBAAc,CAAd,uBAAc,CAAd,iBAAc,CAAd,0BAAc,CAAd,2BAAc,CAAd,yBAAc,CAAd,iCAAc,CAAd,0BAAc,CAAd,qBAAc,CAAd,6BAAc,CAAd,WAAc,CAAd,iBAAc,CAAd,eAAc,CAAd,gBAAc,CAAd,iBAAc,CAAd,aAAc,CAAd,eAAc,CAAd,YAAc,CAAd,kBAAc,CAAd,oBAAc,CAAd,0BAAc,CAAd,wBAAc,CAAd,yBAAc,CAAd,0BAAc,CAAd,sBAAc,CAAd,uBAAc,CAAd,wBAAc,CAAd,qBAAc,CAAd,mBAAc,CAAd,qBAAc,CAAd,oBAAc,CAAd,oBAAc,CAAd;;CAAc,CAAd,uCAAc,CAAd,qBAAc,CAAd,8BAAc,CAAd,wCAAc,CAAd,4BAAc,CAAd,uCAAc,CAAd,gHAAc,CAAd,8BAAc,CAAd,eAAc,CAAd,UAAc,CAAd,wBAAc,CAAd,uBAAc,CAAd,aAAc,CAAd,QAAc,CAAd,4DAAc,CAAd,gCAAc,CAAd,mCAAc,CAAd,mBAAc,CAAd,eAAc,CAAd,uBAAc,CAAd,2BAAc,CAAd,8CAAc,CAAd,mGAAc,CAAd,aAAc,CAAd,8BAAc,CAAd,mBAAc,CAAd,qBAAc,CAAd,aAAc,CAAd,iBAAc,CAAd,sBAAc,CAAd,iBAAc,CAAd,aAAc,CAAd,8BAAc,CAAd,oBAAc,CAAd,aAAc,CAAd,mEAAc,CAAd,aAAc,CAAd,mBAAc,CAAd,cAAc,CAAd,+BAAc,CAAd,mBAAc,CAAd,sBAAc,CAAd,mBAAc,CAAd,QAAc,CAAd,SAAc,CAAd,iCAAc,CAAd,gHAAc,CAAd,wBAAc,CAAd,qBAAc,CAAd,4BAAc,CAAd,gCAAc,CAAd,+BAAc,CAAd,mEAAc,CAAd,0CAAc,CAAd,mBAAc,CAAd,mDAAc,CAAd,sDAAc,CAAd,YAAc,CAAd,yBAAc,CAAd,2DAAc,CAAd,iBAAc,CAAd,yBAAc,CAAd,0BAAc,CAAd,QAAc,CAAd,SAAc,CAAd,gBAAc,CAAd,wBAAc,CAAd,sDAAc,CAAd,SAAc,CAAd,mCAAc,CAAd,wBAAc,CAAd,4DAAc,CAAd,qBAAc,CAAd,qBAAc,CAAd,cAAc,CAAd,uDAAc,CAAd,4BAAc,CAAd,sBAAc,CAAd,gBAAc,CAAd,2BAAc,CAAd,mBAAc,CAAd,8BAAc,CAAd,iBAAc,CAAd,6BAAc,CAAd,sBAAc,CAAd,8BAAc,CAAd,kBAAc,CAAd,6BAAc,CAAd,mBAAc,CAAd,2BAAc,CAAd,2BAAc,CAAd,iCAAc,CAAd,mBAAc,CAAd,kBAAc,CAAd,gBAAc,CAAd,oBAAc,CAAd,qBAAc,CAAd,qBAAc,CAAd,oBAAc,CAAd,oBAAc,CAAd,eAAc,CAAd,sBAAc,CAAd,+BAAc,CAAd,0BAAc,CAAd,uCAAc,CAAd,aAAc,CAAd,4BAAc,CAAd,oDAAc,CAAd,0CAAc,CAAd,kBAAc,CAAd,WAAc,CAAd,cAAc,CAAd,eAAc,CAAd,eAAc,CAEd,2BAAmB,CAAnB,yBAAmB,CAAnB,WAAmB,CAAnB,eAAmB,CAAnB,SAAmB,CAAnB,iBAAmB,CAAnB,kBAAmB,CAAnB,SAAmB,CAAnB,wCAAmB,CAAnB,wCAAmB,CAAnB,2BAAmB,CAAnB,4BAAmB,CAAnB,qBAAmB,CAAnB,2BAAmB,CAAnB,2BAAmB,CAAnB,gBAAmB,CAAnB,iBAAmB,CAAnB,OAAmB,CAAnB,yBAAmB,CAAnB,wBAAmB,CAAnB,oBAAmB,CAAnB,sBAAmB,CAAnB,kBAAmB,CAAnB,kBAAmB,CAAnB,qBAAmB,CAAnB,cAAmB,CAAnB,mBAAmB,CAAnB,mBAAmB,CAAnB,kBAAmB,CAAnB,mBAAmB,CAAnB,iBAAmB,CAAnB,uBAAmB,CAAnB,gBAAmB,CAAnB,qBAAmB,CAAnB,oBAAmB,CAAnB,mBAAmB,CAAnB,YAAmB,CAAnB,iBAAmB,CAAnB,iBAAmB,CAAnB,gBAAmB,CAAnB,eAAmB,CAAnB,oBAAmB,CAAnB,qBAAmB,CAAnB,qBAAmB,CAAnB,kBAAmB,CAAnB,cAAmB,CAAnB,gBAAmB,CAAnB,gBAAmB,CAAnB,sBAAmB,CAAnB,kBAAmB,CAAnB,0BAAmB,CAAnB,oBAAmB,CAAnB,yBAAmB,CAAnB,iBAAmB,CAAnB,4CAAmB,CAAnB,wBAAmB,CAAnB,uBAAmB,CAAnB,0BAAmB,CAAnB,yBAAmB,CAAnB,yBAAmB,CAAnB,0BAAmB,CAAnB,wBAAmB,CAAnB,0BAAmB,CAAnB,wBAAmB,CAAnB,wBAAmB,CAAnB,uBAAmB,CAAnB,yBAAmB,CAAnB,yBAAmB,CAAnB,wBAAmB,CAAnB,uBAAmB,CAAnB,2BAAmB,CAAnB,uBAAmB,CAAnB,2BAAmB,CAAnB,sBAAmB,CAAnB,sBAAmB,CAAnB,qBAAmB,CAAnB,qBAAmB,CAAnB,wBAAmB,CAAnB,kCAAmB,CAAnB,uDAAmB,CAAnB,mBAAmB,CAAnB,eAAmB,CAAnB,kCAAmB,CAAnB,oBAAmB,CAAnB,kBAAmB,CAAnB,gCAAmB,CAAnB,oBAAmB,CAAnB,kBAAmB,CAAnB,oBAAmB,CAAnB,+BAAmB,CAAnB,sBAAmB,CAAnB,mBAAmB,CAAnB,iBAAmB,CAAnB,iBAAmB,CAAnB,sBAAmB,CAAnB,kBAAmB,CAAnB,sBAAmB,CAAnB,iBAAmB,CAAnB,gBAAmB,CAAnB,kBAAmB,CAAnB,mBAAmB,CAAnB,kBAAmB,CAAnB,kBAAmB,CAAnB,mBAAmB,CAAnB,gBAAmB,CAAnB,mBAAmB,CAAnB,qBAAmB,CAAnB,yGAAmB,CAAnB,qFAAmB,CAAnB,mBAAmB,CAAnB,mBAAmB,CAAnB,gBAAmB,CAAnB,sBAAmB,CAAnB,sHAAmB,CAAnB,0GAAmB,CAAnB,iCAAmB,CAAnB,+BAAmB,CAAnB,+HAAmB,CAAnB,8BAAmB,CAAnB,+BAAmB,CAAnB,8BAAmB,CAAnB,kBAAmB,CAAnB,gBAAmB,CAAnB,gBAAmB,CAAnB,qBAAmB,CAAnB,iBAAmB,CAAnB,qBAAmB,CAAnB,iBAAmB,CAAnB,gBAAmB,CAAnB,eAAmB,CAAnB,kBAAmB,CAAnB,iBAAmB,CAAnB,iBAAmB,CAAnB,kBAAmB,CAAnB,iBAAmB,CAAnB,eAAmB,CAAnB,kBAAmB,CAAnB,wBAAmB,CAAnB,oBAAmB,CAAnB,kBAAmB,CAAnB,gCAAmB,CAAnB,iBAAmB,CAAnB,eAAmB,CAAnB,oBAAmB,CAAnB,0BAAmB,CAAnB,uBAAmB,CAAnB,0BAAmB,CAAnB,gCAAmB,CAAnB,8BAAmB,CAAnB,0FAAmB,CAAnB,0BAAmB,CAAnB,0BAAmB,CAAnB,0BAAmB,CAAnB,yBAAmB,CAAnB,wCAAmB,CAAnB,qBAAmB,CAAnB,yBAAmB,CAAnB,gBAAmB,CAAnB,sCAAmB,CAAnB,iBAAmB,CAAnB,mBAAmB,CAAnB,2BAAmB,CAAnB,mCAAmB,CAAnB,yCAAmB,CAAnB,6HAAmB,CAAnB,+HAAmB,CAAnB,yHAAmB,CAAnB,mHAAmB,CAAnB,mHAAmB,CAAnB,iHAAmB,CAAnB,mHAAmB,CAAnB,wCAAmB,CAAnB,mOAAmB,CAAnB,wCAAmB,CAAnB,4CAAmB,CAAnB,2OAAmB,CAAnB,4CAAmB,CAAnB,4BAAmB,CAAnB,mNAAmB,CAAnB,4BAAmB,CAAnB,wMAAmB,CAAnB,+BAAmB,EAAnB,kEAAmB,CAAnB,8BAAmB,CAAnB,8BAAmB,CAAnB,6BAAmB,CAAnB,qCAAmB,CAAnB,gBAAmB,CAAnB,+BAAmB,CAAnB,0DAAmB,CAAnB,0DAAmB,CAAnB,0DAAmB,CAAnB,4BAAmB,CAAnB,+BAAmB,CAAnB,+CAAmB,CAAnB,yBAAmB,CAAnB,mCAAmB,CAAnB,+BAAmB,CAAnB,gCAAmB,CAAnB,sCAAmB,CAAnB,8CAAmB,CAAnB,iBAAmB,CAAnB,qBAAmB,CAAnB,gBAAmB,CAAnB,gBAAmB,CAAnB,eAAmB,CAAnB,iBAAmB,CAAnB,eAAmB,CAAnB,+DAAmB,CAAnB,4GAAmB,CAAnB,+DAAmB,CAAnB,0GAAmB,CAAnB,+DAAmB,CAAnB,4GAAmB,CAAnB,+DAAmB,CAAnB,wGAAmB,CAAnB,+DAAmB,CAAnB,wGAAmB,CAAnB,+DAAmB,CAAnB,4GAAmB,CAAnB,kEAAmB,CAAnB,8GAAmB,CAAnB,+DAAmB,CAAnB,0GAAmB,CAAnB,+DAAmB,CAAnB,4GAAmB,CAAnB,+DAAmB,CAAnB,wGAAmB,CAAnB,+DAAmB,CAAnB,4GAAmB,CAAnB,4BAAmB,CAAnB,gCAAmB,CAAnB,gCAAmB,CAAnB,oCAAmB,CAAnB,qCAAmB,CAAnB,qCAAmB,CAAnB,+BAAmB,CAAnB,0CAAmB,CAAnB,kCAAmB,CAAnB,+BAAmB,CAAnB,2BAAmB,CAAnB,2CAAmB,CAAnB,uCAAmB,CAAnB,2CAAmB,CAAnB,uCAAmB,CAAnB,gCAAmB,CAAnB,+CAAmB,CAAnB,4BAAmB,CAAnB,uDAAmB,CAAnB,gDAAmB,CAAnB,wBAAmB,CAAnB,0BAAmB,CAAnB,8BAAmB,CAAnB,2CAAmB,CAAnB,+BAAmB,CAAnB,gCAAmB,CAAnB,8BAAmB,CAAnB,wCAAmB,CAAnB,oCAAmB,CAAnB,8CAAmB,CAAnB,uCAAmB,CAAnB,sCAAmB,CAAnB,oBAAmB,CAAnB,qDAAmB,CAAnB,kCAAmB,CAAnB,8BAAmB,CAAnB,oCAAmB,CAAnB,gCAAmB,CAAnB,0CAAmB,CAAnB,mCAAmB,CAAnB,qCAAmB,CAAnB,oBAAmB,CAAnB,wDAAmB,CAAnB,qCAAmB,CAAnB,oBAAmB,CAAnB,sDAAmB,CAAnB,sCAAmB,CAAnB,mCAAmB,CAAnB,iBAAmB,CAAnB,wDAAmB,CAAnB,wCAAmB,CAAnB,6CAAmB,CAAnB,4CAAmB,CAAnB,mCAAmB,CAAnB,mCAAmB,CAAnB,oCAAmB,CAAnB,uCAAmB,CAAnB,oCAAmB,CAAnB,mCAAmB,CAAnB,mCAAmB,CAAnB,8BAAmB,CAAnB,iCAAmB,CAAnB,8BAAmB,CAAnB,wBAAmB,CAAnB,sDAAmB,CAAnB,wCAAmB,CAAnB,wCAAmB,CAAnB,uCAAmB,CAAnB,uCAAmB,CAAnB,6BAAmB,CAAnB,wBAAmB,CAAnB,wDAAmB,CAAnB,8BAAmB,CAAnB,wBAAmB,CAAnB,qDAAmB,CAAnB,kCAAmB,CAAnB,kCAAmB,CAAnB,wCAAmB,CAAnB,qCAAmB,CAAnB,iCAAmB,CAAnB,oCAAmB,CAAnB,oCAAmB,CAAnB,oCAAmB,CAAnB,0CAAmB,CAAnB,uCAAmB,CAAnB,0CAAmB,CAAnB,uCAAmB,CAAnB,6BAAmB,CAAnB,wBAAmB,CAAnB,sDAAmB,CAAnB,sCAAmB,CAAnB,sCAAmB,CAAnB,wCAAmB,CAAnB,2BAAmB,CAAnB,qBAAmB,CAAnB,wDAAmB,CAAnB,oCAAmB,CAAnB,wCAAmB,CAAnB,6FAAmB,CAAnB,qFAAmB,CAAnB,yEAAmB,CAAnB,yDAAmB,CAAnB,iEAAmB,CAAnB,yEAAmB,CAAnB,yDAAmB,CAAnB,iEAAmB,CAAnB,0EAAmB,CAAnB,yDAAmB,CAAnB,iEAAmB,CAAnB,0EAAmB,CAAnB,yDAAmB,CAAnB,iEAAmB,CAAnB,gFAAmB,CAAnB,yDAAmB,CAAnB,iEAAmB,CAAnB,0EAAmB,CAAnB,yDAAmB,CAAnB,iEAAmB,CAAnB,oEAAmB,CAAnB,mEAAmB,CAAnB,oEAAmB,CAAnB,oEAAmB,CAAnB,0EAAmB,CAAnB,mEAAmB,CAAnB,oEAAmB,CAAnB,0CAAmB,CAAnB,oBAAmB,CAAnB,+BAAmB,CAAnB,0BAAmB,CAAnB,wBAAmB,CAAnB,6BAAmB,CAAnB,8BAAmB,CAAnB,cAAmB,CAAnB,mBAAmB,CAAnB,kBAAmB,CAAnB,mBAAmB,CAAnB,iBAAmB,CAAnB,mBAAmB,CAAnB,iBAAmB,CAAnB,sBAAmB,CAAnB,6BAAmB,CAAnB,qBAAmB,CAAnB,wBAAmB,CAAnB,mBAAmB,CAAnB,6BAAmB,CAAnB,qBAAmB,CAAnB,yBAAmB,CAAnB,oBAAmB,CAAnB,uBAAmB,CAAnB,kBAAmB,CAAnB,uBAAmB,CAAnB,kBAAmB,CAAnB,mDAAmB,CAAnB,8CAAmB,CAAnB,mDAAmB,CAAnB,2CAAmB,CAAnB,4CAAmB,CAAnB,2CAAmB,CAAnB,8CAAmB,CAAnB,0CAAmB,CAAnB,8CAAmB,CAAnB,0CAAmB,CAAnB,yBAAmB,CAAnB,0BAAmB,CAAnB,wBAAmB,CAAnB,6BAAmB,CAAnB,uBAAmB,CAAnB,uBAAmB,CAAnB,yBAAmB,CAAnB,8BAAmB,CAAnB,0BAAmB,CAAnB,wBAAmB,CAAnB,mBAAmB,CAAnB,wBAAmB,CAAnB,sBAAmB,CAAnB,wBAAmB,CAAnB,sBAAmB,CAAnB,0BAAmB,CAAnB,8BAAmB,CAAnB,mCAAmB,CAAnB,0BAAmB,CAAnB,gBAAmB,CAAnB,4BAAmB,CAAnB,mBAAmB,CAAnB,2BAAmB,CAAnB,kBAAmB,CAAnB,wBAAmB,CAAnB,aAAmB,CAAnB,iCAAmB,CAAnB,yBAAmB,CAAnB,kBAAmB,CAAnB,2BAAmB,CAAnB,mBAAmB,CAAnB,0BAAmB,CAAnB,mBAAmB,CAAnB,0BAAmB,CAAnB,mBAAmB,CAAnB,yBAAmB,CAAnB,gBAAmB,CAAnB,0BAAmB,CAAnB,4BAAmB,CAAnB,4BAAmB,CAAnB,8BAAmB,CAAnB,yBAAmB,CAAnB,2BAAmB,CAAnB,kCAAmB,CAAnB,sCAAmB,CAAnB,oCAAmB,CAAnB,qCAAmB,CAAnB,mCAAmB,CAAnB,mCAAmB,CAAnB,iCAAmB,CAAnB,gCAAmB,CAAnB,kCAAmB,CAAnB,aAAmB,CAAnB,+CAAmB,CAAnB,kCAAmB,CAAnB,aAAmB,CAAnB,8CAAmB,CAAnB,iCAAmB,CAAnB,aAAmB,CAAnB,+CAAmB,CAAnB,kCAAmB,CAAnB,aAAmB,CAAnB,6CAAmB,CAAnB,+BAAmB,CAAnB,6BAAmB,CAAnB,0CAAmB,CAAnB,wCAAmB,CAAnB,8BAAmB,CAAnB,4BAAmB,CAAnB,oCAAmB,CAAnB,+BAAmB,CAAnB,kCAAmB,CAAnB,aAAmB,CAAnB,+CAAmB,CAAnB,kCAAmB,CAAnB,aAAmB,CAAnB,+CAAmB,CAAnB,kCAAmB,CAAnB,aAAmB,CAAnB,+CAAmB,CAAnB,kCAAmB,CAAnB,aAAmB,CAAnB,4CAAmB,CAAnB,kCAAmB,CAAnB,aAAmB,CAAnB,4CAAmB,CAAnB,kCAAmB,CAAnB,aAAmB,CAAnB,4CAAmB,CAAnB,oCAAmB,CAAnB,kCAAmB,CAAnB,sCAAmB,CAAnB,oCAAmB,CAAnB,2BAAmB,CAAnB,yBAAmB,CAAnB,sCAAmB,CAAnB,oCAAmB,CAAnB,iCAAmB,CAAnB,aAAmB,CAAnB,6CAAmB,CAAnB,iCAAmB,CAAnB,aAAmB,CAAnB,6CAAmB,CAAnB,wCAAmB,CAAnB,sCAAmB,CAAnB,6BAAmB,CAAnB,+BAAmB,CAAnB,UAAmB,CAAnB,+CAAmB,CAAnB,oCAAmB,CAAnB,aAAmB,CAAnB,8CAAmB,CAAnB,uDAAmB,CAAnB,iCAAmB,CAAnB,6CAAmB,CAAnB,oBAAmB,CAAnB,sBAAmB,CAAnB,sBAAmB,CAAnB,sBAAmB,CAAnB,sBAAmB,CAAnB,kEAAmB,CAAnB,4FAAmB,CAAnB,kEAAmB,CAAnB,kGAAmB,CAAnB,0EAAmB,CAAnB,iGAAmB,CAAnB,wEAAmB,CAAnB,+FAAmB,CAAnB,qEAAmB,CAAnB,kGAAmB,CAAnB,4CAAmB,CAAnB,sDAAmB,CAAnB,qCAAmB,CAAnB,kBAAmB,CAAnB,4BAAmB,CAAnB,kHAAmB,CAAnB,kGAAmB,CAAnB,uFAAmB,CAAnB,wFAAmB,CAAnB,kHAAmB,CAAnB,wGAAmB,CAAnB,2CAAmB,CAAnB,qEAAmB,CAAnB,wLAAmB,CAAnB,+CAAmB,CAAnB,kTAAmB,CAAnB,sQAAmB,CAAnB,8CAAmB,CAAnB,kMAAmB,CAAnB,6IAAmB,CAAnB,mMAAmB,CAAnB,kDAAmB,CAAnB,gEAAmB,CAAnB,kDAAmB,CAAnB,6IAAmB,CAAnB,yFAAmB,CAAnB,uHAAmB,CAAnB,kDAAmB,CAAnB,wEAAmB,CAAnB,kDAAmB,CAAnB,0EAAmB,CAAnB,kDAAmB,CAAnB,4EAAmB,CAAnB,kDAAmB,CAAnB,+BAAmB,CAAnB,+BAAmB,CAAnB,qCAAmB,CAAnB,qCAAmB,CAAnB,qCAAmB,CAAnB,qCAAmB,CAAnB,qCAAmB,CAAnB,+DAAmB,CAAnB,6BAAmB,CAAnB,iCAAmB,CAAnB,2CAAmB,CAAnB,sMAAmB,EAAnB,4BAAmB,CAAnB,gCAAmB,CAAnB,2CAAmB,CAAnB,gMAAmB,EAAnB,sCAAmB,CAAnB,wBAAmB,CAAnB,yBAAmB,CAAnB,8BAAmB,CAAnB,sDAAmB,CAAnB,oBAAmB,CAAnB,wCAAmB,CAAnB,gCAAmB,CAAnB,iDAAmB,CAAnB,+CAAmB,CAAnB,oCAAmB,CAAnB,oCAAmB,CAAnB,oCAAmB,CAAnB,oCAAmB,CAAnB,oCAAmB,CAAnB,8BAAmB,CAAnB,8BAAmB,CAAnB,8DAAmB,CAAnB,qCAAmB,CAEnB,KAMI,kCAAmC,CACnC,iCAAkC,CALlC,mIAGc,CAJd,QAOJ,CAEA,KACI,uEAEJ,CAjBA,0DAmHA,CAnHA,oDAmHA,CAnHA,0EAmHA,CAnHA,oEAmHA,CAnHA,4DAmHA,CAnHA,mBAmHA,CAnHA,sDAmHA,CAnHA,mBAmHA,CAnHA,8DAmHA,CAnHA,wDAmHA,CAnHA,gEAmHA,CAnHA,4BAmHA,CAnHA,0DAmHA,CAnHA,4BAmHA,CAnHA,4DAmHA,CAnHA,aAmHA,CAnHA,+CAmHA,CAnHA,8DAmHA,CAnHA,kCAmHA,CAnHA,gDAmHA,CAnHA,iBAmHA,CAnHA,0DAmHA,CAnHA,KAmHA,CAnHA,iDAmHA,CAnHA,QAmHA,CAnHA,2CAmHA,CAnHA,YAmHA,CAnHA,qDAmHA,CAnHA,yBAmHA,CAnHA,6LAmHA,CAnHA,4EAmHA,CAnHA,4FAmHA,CAnHA,gDAmHA,CAnHA,kDAmHA,CAnHA,2EAmHA,CAnHA,8FAmHA,CAnHA,iDAmHA,CAnHA,sDAmHA,CAnHA,2CAmHA,CAnHA,gDAmHA,CAnHA,mCAmHA,CAnHA,0CAmHA,CAnHA,wBAmHA,CAnHA,wDAmHA,CAnHA,2CAmHA,CAnHA,wBAmHA,CAnHA,sDAmHA,CAnHA,2CAmHA,CAnHA,wBAmHA,CAnHA,uDAmHA,CAnHA,2DAmHA,CAnHA,2CAmHA,CAnHA,2DAmHA,CAnHA,2CAmHA,CAnHA,+CAmHA,CAnHA,kCAmHA,CAnHA,qDAmHA,CAnHA,qCAmHA,CAnHA,iDAmHA,CAnHA,oCAmHA,CAnHA,uDAmHA,CAnHA,uCAmHA,CAnHA,uDAmHA,CAnHA,uCAmHA,CAnHA,yCAmHA,CAnHA,wBAmHA,CAnHA,wDAmHA,CAnHA,mDAmHA,CAnHA,sCAmHA,CAnHA,yDAmHA,CAnHA,yCAmHA,CAnHA,wCAmHA,CAnHA,qBAmHA,CAnHA,wDAmHA,CAnHA,kDAmHA,CAnHA,mCAmHA,CAnHA,+CAmHA,CAnHA,aAmHA,CAnHA,8CAmHA,CAnHA,+CAmHA,CAnHA,aAmHA,CAnHA,6CAmHA,CAnHA,2CAmHA,CAnHA,4BAmHA,CAnHA,iDAmHA,CAnHA,kCAmHA,CAnHA,mDAmHA,CAnHA,oCAmHA,CAnHA,8DAmHA,CAnHA,8BAmHA,CAnHA,mCAmHA,CAnHA,gEAmHA,CAnHA,4DAmHA,CAnHA,gGAmHA,CAnHA,kGAmHA,CAnHA,wFAmHA,CAnHA,kGAmHA,CAnHA,gDAmHA,CAnHA,mCAmHA,CAnHA,iDAmHA,CAnHA,oCAmHA,CAnHA,kDAmHA,CAnHA,mCAmHA,CAnHA,mDAmHA,CAnHA,oCAmHA,CAnHA,mCAmHA,CAnHA,kDAmHA,CAnHA,kBAmHA,CAnHA,+HAmHA,CAnHA,wGAmHA,CAnHA,iHAmHA,CAnHA,wFAmHA,CAnHA,+HAmHA,CAnHA,wGAmHA,CAnHA,wDAmHA,CAnHA,sDAmHA,CAnHA,kEAmHA,CAnHA,kBAmHA,CAnHA,+IAmHA,CAnHA,wGAmHA,CAnHA,uEAmHA,CAnHA,wFAmHA,CAnHA,+IAmHA,CAnHA,wGAmHA,CAnHA,uEAmHA,CAnHA,wFAmHA,CAnHA,wEAmHA,CAnHA,sEAmHA,CAnHA,sEAmHA,CAnHA,kGAmHA,CAnHA,2DAmHA,CAnHA,yDAmHA,CAnHA,yCAmHA,CAnHA,qDAmHA,CAnHA,gBAmHA,CAnHA,6LAmHA,CAnHA,gDAmHA,CAnHA,oFAmHA,CAnHA,iCAmHA,CAnHA,uEAmHA,CAnHA,+BAmHA,CAnHA,kEAmHA,CAnHA,kCAmHA,CAnHA,oEAmHA,CAnHA,oCAmHA,CAnHA,wEAmHA,CAnHA,uCAmHA,CAnHA,6EAmHA,CAnHA,aAmHA,CAnHA,+CAmHA,CAnHA,oEAmHA,CAnHA,kCAmHA,CAnHA,sEAmHA,CAnHA,oCAmHA,CAnHA,kEAmHA,CAnHA,4BAmHA,CAnHA,8GAmHA,CAnHA,iGAmHA,CAnHA,+CAmHA,CAnHA,kGAmHA,CAnHA,uGAmHA,CAnHA,uCAmHA,CAnHA,iGAmHA,CAnHA,wCAmHA,CAnHA,mGAmHA,CAnHA,wCAmHA,CAnHA,yFAmHA,CAnHA,aAmHA,CAnHA,+CAmHA,CAnHA,kHAmHA,CAnHA,0FAmHA,CAnHA,yDAmHA,CAnHA,4GAmHA,CAnHA,oEAmHA,CAnHA,oDAmHA,CAnHA,yDAmHA,CAnHA,sEAmHA,CAnHA,mCAmHA,CAnHA,4EAmHA,CAnHA,sCAmHA,CAnHA,wEAmHA,CAnHA,mCAmHA,CAnHA,uEAmHA,CAnHA,kCAmHA,CAnHA,yDAmHA,CAnHA,4IAmHA,CAnHA,+FAmHA,CAnHA,iGAmHA,CAnHA,gFAmHA,CAnHA,0SAmHA,CAnHA,8EAmHA,CAnHA,8EAmHA,CAnHA,sSAmHA,CAnHA,4EAmHA,CAnHA,iFAmHA,CAnHA,6LAmHA,CAnHA,8IAmHA,CAnHA,6LAmHA,CAnHA,sIAmHA,CAnHA,8WAmHA,CAnHA,0IAmHA,CAnHA,uEAmHA,CAnHA,WAmHA,EAnHA,oGAmHA,CAnHA,qCAmHA,CAnHA,+CAmHA,EAnHA,oGAmHA,CAnHA,8GAmHA,CAnHA,gFAmHA,CAnHA,mCAmHA,CAnHA,+EAmHA,CAnHA,uCAmHA,CAnHA,iFAmHA,CAnHA,oCAmHA,CAnHA,wHAmHA,CAnHA,mCAmHA,CAnHA,gFAmHA,CAnHA,sCAmHA,CAnHA,6EAmHA,CAnHA,sCAmHA,CAnHA,iFAmHA,CAnHA,kCAmHA,CAnHA,mFAmHA,CAnHA,kCAmHA,CAnHA,4EAmHA,CAnHA,kCAmHA,CAnHA,kFAmHA,CAnHA,mCAmHA,CAnHA,yEAmHA,CAnHA,4BAmHA,CAnHA,mFAmHA,CAnHA,oCAmHA,CAnHA,uIAmHA,CAnHA,mCAmHA,CAnHA,2EAmHA,CAnHA,kCAmHA,CAnHA,iHAmHA,CAnHA,6GAmHA,CAnHA,4FAmHA,CAnHA,+CAmHA,CAnHA,kGAmHA,CAnHA,gFAmHA,CAnHA,gFAmHA,CAnHA,4EAmHA,CAnHA,gMAmHA,CAnHA,wBAmHA,CAnHA,yBAmHA,CAnHA,8BAmHA,CAnHA,sDAmHA,CAnHA,oBAmHA,CAnHA,kPAmHA,CAnHA,uBAmHA,CAnHA,wBAmHA,CAnHA,6BAmHA,CAnHA,qDAmHA,CAnHA,mBAmHA,CAnHA,2EAmHA,CAnHA,8HAmHA,CAnHA,6EAmHA,CAnHA,wEAmHA,CAnHA,4HAmHA,CAnHA,2EAmHA,CAnHA,sEAmHA,CAnHA,uEAmHA,CAnHA,qGAmHA,CAnHA,yGAmHA,CAnHA,+FAmHA,CAnHA,mGAmHA,CAnHA,4FAmHA,CAnHA,yFAmHA,CAnHA,2FAmHA,CAnHA,wFAmHA,CAnHA,0FAmHA,CAnHA,yFAmHA,CAnHA,6FAmHA,CAnHA,6JAmHA,CAnHA,wFAmHA,CAnHA,gGAmHA,CAnHA,wFAmHA,CAnHA,uFAmHA,CAnHA,2FAmHA,CAnHA,uFAmHA,CAnHA,sFAmHA,CAnHA,8FAmHA,CAnHA,2FAmHA,CAnHA,+EAmHA,CAnHA,2EAmHA,CAnHA,6HAmHA,CAnHA,MAmHA,CAnHA,0HAmHA,CAnHA,aAmHA,CAnHA,6HAmHA,CAnHA,UAmHA,CAnHA,oIAmHA,CAnHA,yBAmHA,CAnHA,6LAmHA,CAnHA,+HAmHA,CAnHA,yBAmHA,CAnHA,6LAmHA,CAnHA,sJAmHA,CAnHA,mCAmHA,CAnHA,kFAmHA,CAnHA,6LAmHA,CAnHA,0DAmHA,CAnHA,oCAmHA,CAnHA,+CAmHA,CAnHA,oBAmHA,CAnHA,sBAmHA,CAnHA,sBAmHA,CAnHA,6BAmHA,CAnHA,gCAmHA,CAnHA,mCAmHA,CAnHA,yCAmHA,CAnHA,yBAmHA,CAnHA,mEAmHA,CAnHA,0GAmHA,CAnHA,mEAmHA,CAnHA,wGAmHA,CAnHA,mEAmHA,CAnHA,sGAmHA,CAnHA,mCAmHA,CAnHA,2BAmHA,CAnHA,6BAmHA,CAnHA,oBAmHA,CAnHA,8BAmHA,CAnHA,iGAmHA,EAnHA,wDAmHA,CAnHA,sBAmHA,CAnHA,wBAmHA,CAnHA,qBAmHA,CAnHA,0GAmHA,CAnHA,sBAmHA,CAnHA,oCAmHA,CAnHA,8DAmHA,CAnHA,gCAmHA,CAnHA,sBAmHA,CAnHA,8BAmHA,CAnHA,gBAmHA,CAnHA,+BAmHA,CAnHA,kBAmHA,CAnHA,4BAmHA,CAnHA,aAmHA,CAnHA,8BAmHA,CAnHA,aAmHA,CAnHA,8BAmHA,CAnHA,mBAmHA,EAnHA,wFAmHA,CAnHA,8DAmHA,CAnHA,8DAmHA,CAnHA,2BAmHA,CAnHA,kBAmHA,EAnHA,0CAmHA,CAnHA,gBAmHA,CAnHA,iHAmHA,CAnHA,8FAmHA,CAnHA,iDAmHA,CAnHA,oHAmHA,CAnHA,4FAmHA,CAnHA,gDAmHA,CAnHA,kGAmHA,CAnHA,uCAmHA,CAnHA,0FAmHA,CAnHA,mCAmHA,CAnHA,mIAmHA,CAnHA,4FAmHA,CAnHA,gDAmHA,CAnHA,kIAmHA,CAnHA,8FAmHA,CAnHA,iDAmHA,CAnHA,yHAmHA,CAnHA,sCAmHA,CAnHA,8IAmHA,CAnHA,8FAmHA,CAnHA,iDAmHA,CAnHA,6EAmHA,CAnHA,qFAmHA,CAnHA,6LAmHA,CAnHA,4DAmHA,CAnHA,wCAmHA,CAnHA,eAmHA,CAnHA,qEAmHA,CAnHA,6LAmHA,CAnHA,4CAmHA,CAnHA,kCAmHA,CAnHA,gCAmHA,CAnHA,+CAmHA,CAnHA,uCAmHA,CAnHA,sCAmHA,CAnHA,wCAmHA,CAnHA,gDAmHA,CAnHA,6BAmHA,CAnHA,+CAmHA,CAnHA,4BAmHA,CAnHA,iDAmHA,CAnHA,iEAmHA,CAnHA,0HAmHA,CAnHA,wWAmHA,CAnHA,oFAmHA,CAnHA,4EAmHA,CAnHA,mBAmHA,CAnHA,uGAmHA,CAnHA,6EAmHA,CAnHA,gBAmHA,CAnHA,gFAmHA,CAnHA,wFAmHA,CAnHA,kCAmHA,CAnHA,sHAmHA,CAnHA,4DAmHA,CAnHA,mBAmHA,CAnHA,+EAmHA,CAnHA,8EAmHA,CAnHA,qDAmHA,CAnHA,0DAmHA,CAnHA,mBAmHA,CAnHA,gFAmHA,CAnHA,6DAmHA,CAnHA,4DAmHA,CAnHA,8CAmHA,CAnHA,wDAmHA,CAnHA,8CAmHA,CAnHA,uCAmHA,CAnHA,6DAmHA,CAnHA,+CAmHA,CCnHA,UACI,aAAc,CACd,mBACJ,CAEA,8CACI,UACI,2CACJ,CACJ,CAEA,YAKI,kBAAmB,CAJnB,wBAAyB,CAOzB,UAAY,CALZ,YAAa,CACb,qBAAsB,CAGtB,4BAA6B,CAD7B,sBAAuB,CAJvB,gBAOJ,CAEA,UACI,aACJ,CAEA,yBACI,GACI,sBACJ,CACA,GACI,uBACJ,CACJ","sources":["index.css","App.css"],"sourcesContent":["@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\nbody {\n margin: 0;\n font-family:\n -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Roboto\", \"Oxygen\",\n \"Ubuntu\", \"Cantarell\", \"Fira Sans\", \"Droid Sans\", \"Helvetica Neue\",\n sans-serif;\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n}\n\ncode {\n font-family:\n source-code-pro, Menlo, Monaco, Consolas, \"Courier New\", monospace;\n}\n\n@layer base {\n :root {\n --background: 0 0% 100%;\n --foreground: 0 0% 3.9%;\n --card: 0 0% 100%;\n --card-foreground: 0 0% 3.9%;\n --popover: 0 0% 100%;\n --popover-foreground: 0 0% 3.9%;\n --primary: 0 0% 9%;\n --primary-foreground: 0 0% 98%;\n --secondary: 0 0% 96.1%;\n --secondary-foreground: 0 0% 9%;\n --muted: 0 0% 96.1%;\n --muted-foreground: 0 0% 45.1%;\n --accent: 0 0% 96.1%;\n --accent-foreground: 0 0% 9%;\n --destructive: 0 84.2% 60.2%;\n --destructive-foreground: 0 0% 98%;\n --border: 0 0% 89.8%;\n --input: 0 0% 89.8%;\n --ring: 0 0% 3.9%;\n --chart-1: 12 76% 61%;\n --chart-2: 173 58% 39%;\n --chart-3: 197 37% 24%;\n --chart-4: 43 74% 66%;\n --chart-5: 27 87% 67%;\n --radius: 0.5rem;\n }\n .dark {\n --background: 0 0% 3.9%;\n --foreground: 0 0% 98%;\n --card: 0 0% 3.9%;\n --card-foreground: 0 0% 98%;\n --popover: 0 0% 3.9%;\n --popover-foreground: 0 0% 98%;\n --primary: 0 0% 98%;\n --primary-foreground: 0 0% 9%;\n --secondary: 0 0% 14.9%;\n --secondary-foreground: 0 0% 98%;\n --muted: 0 0% 14.9%;\n --muted-foreground: 0 0% 63.9%;\n --accent: 0 0% 14.9%;\n --accent-foreground: 0 0% 98%;\n --destructive: 0 62.8% 30.6%;\n --destructive-foreground: 0 0% 98%;\n --border: 0 0% 14.9%;\n --input: 0 0% 14.9%;\n --ring: 0 0% 83.1%;\n --chart-1: 220 70% 50%;\n --chart-2: 160 60% 45%;\n --chart-3: 30 80% 55%;\n --chart-4: 280 65% 60%;\n --chart-5: 340 75% 55%;\n }\n}\n\n@layer base {\n * {\n @apply border-border;\n }\n body {\n @apply bg-background text-foreground;\n }\n}\n\n@layer base {\n [data-debug-wrapper=\"true\"] {\n display: contents !important;\n }\n\n [data-debug-wrapper=\"true\"] > * {\n margin-left: inherit;\n margin-right: inherit;\n margin-top: inherit;\n margin-bottom: inherit;\n padding-left: inherit;\n padding-right: inherit;\n padding-top: inherit;\n padding-bottom: inherit;\n column-gap: inherit;\n row-gap: inherit;\n gap: inherit;\n border-left-width: inherit;\n border-right-width: inherit;\n border-top-width: inherit;\n border-bottom-width: inherit;\n border-left-style: inherit;\n border-right-style: inherit;\n border-top-style: inherit;\n border-bottom-style: inherit;\n border-left-color: inherit;\n border-right-color: inherit;\n border-top-color: inherit;\n border-bottom-color: inherit;\n }\n}\n",".App-logo {\n height: 40vmin;\n pointer-events: none;\n}\n\n@media (prefers-reduced-motion: no-preference) {\n .App-logo {\n animation: App-logo-spin infinite 20s linear;\n }\n}\n\n.App-header {\n background-color: #0f0f10;\n min-height: 100vh;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n font-size: calc(10px + 2vmin);\n color: white;\n}\n\n.App-link {\n color: #61dafb;\n}\n\n@keyframes App-logo-spin {\n from {\n transform: rotate(0deg);\n }\n to {\n transform: rotate(360deg);\n }\n}\n"],"names":[],"ignoreList":[],"sourceRoot":""} \ No newline at end of file diff --git a/sites/epictravelexpeditions.com/public_html/static/js/admin-portal-v2.js b/sites/epictravelexpeditions.com/public_html/static/js/admin-portal-v2.js new file mode 100644 index 0000000..d6961ba --- /dev/null +++ b/sites/epictravelexpeditions.com/public_html/static/js/admin-portal-v2.js @@ -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 + ?`` + :`
🖼
`; + return `
${pr}
`; +} + +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`${d}d ago`;} +function expBdg(d){if(d<0)return`Expired`;const c=d>14?'bg':d>7?'by':'br';return`${d}d left`;} +function esc(s){const e=document.createElement('div');e.textContent=s||'';return e.innerHTML;} +function catOpts(cats,sel){return cats.map(c=>``).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=` +
+ +
+ 🌐 View Site + +
+
+
+ + + + +
+
`; + 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=`
+
🗺
${destinations.length}
Destinations
+
${specials.length}
Active Specials
+
${pen}
Pending Reviews
+
${app}
Approved Testimonials
+
`; + if(pen>0)h+=`
⚠️ ${pen} testimonial${pen>1?'s':''} awaiting review.
`; + + /* Destinations age */ + const sd=[...destinations].sort((a,b)=>new Date(a.created_at)-new Date(b.created_at)); + h+=`
🗺 Destinations
Oldest entries first
`; + sd.forEach(d=>{h+=``;}); + h+=`
DestinationCategoryPriceIn System Since
${esc(d.name)} ${esc(d.location)}${esc(d.category)}${fmtPrice(d.price)}${ageBdg(daysAgo(d.created_at))}
`; + + /* Specials expiry */ + const ss=[...specials].sort((a,b)=>new Date(a.end_date)-new Date(b.end_date)); + h+=`
⭐ Weekly Specials
Expiring soonest first
`; + 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+=` + + + + + + + + `; + }); + h+=`
ImageDestinationOriginal PriceDiscountSale PriceExpiresStatus
${imgSrc?``:''}${esc(dest?dest.name:s.destination_id)}${fmtPrice(basePrice)}${parseFloat(s.discount)}% OFF${fmtPrice(salePrice)}${new Date(s.end_date).toLocaleDateString()}${expBdg(daysLeft(s.end_date))}
`; + + /* Pending testimonials */ + const pl=testimonials.filter(t=>t.status==='pending'); + if(pl.length){ + h+=`
⏳ Pending Testimonials
`; + pl.forEach(t=>{ + const av=t.image_path?``:`
${esc(t.full_name.charAt(0))}
`; + h+=`
${av}
${esc(t.full_name)}
${esc(t.location)}
${esc(t.message)}
`; + }); + h+=`
`; + } + return h; +} + +/* Destinations */ +function destRow(d){ + return` + + ${esc(d.name)}
${esc(d.location)} + ${esc(d.category)} + ${fmtPrice(d.price)} + ⭐ ${parseFloat(d.rating).toFixed(1)} + ${ageBdg(daysAgo(d.created_at))} + + `; +} + +function rDest(){ + const cc={};destinations.forEach(d=>{cc[d.category]=(cc[d.category]||0)+1;}); + return`
+
🏷 Categories
Add, rename, or remove destination categories
+
+
+
+ + + + +
+
+
${categories.map(c=>{const n=cc[c.name]||0;return`
${esc(c.name)}${n} destination${n!==1?'s':''}
`;}).join('')}
+
+
+
+
🗺 All Destinations (${destinations.length})
+
+
+
New Destination
+
+
Name *
+
Location *
+
Category *
+
Price (USD) *
+
Rating (1–5)
+
Image *${imgPicker('ep-dn-img','')}
+
Description *
+
+
+
+ + ${destinations.map(destRow).join('')}
PhotoName & LocationCategoryPriceRatingIn SystemActions
+
+
`; +} + +/* 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` + ${img?``:''} + ${esc(dest?dest.name:s.destination_id)} + ${parseFloat(s.discount)}% OFF + ${fmtPrice(base)} + ${fmtPrice(sale)} + ${new Date(s.end_date).toLocaleDateString()} + ${expBdg(daysLeft(s.end_date))} + + `; +} + +function rSpec(){ + const destOpts=destinations.map(d=>``).join(''); + return`
+
⭐ Weekly Specials (${specials.length})
+
+
+
New Special
+
+
Destination *
+
Original Price (USD) *
+
Discount % *
+
End Date *
+
Special Image (optional — defaults to destination photo)${imgPicker('ep-sn-img','')}
+
Highlights (one per line)
+
+
+
+ + ${specials.map(specRow).join('')}
ImageDestinationDiscountOriginal PriceSale PriceEnd DateStatusActions
+
+
`; +} + +/* 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=`
+ + + + +
`; + if(!list.length)return h+`
💬 No testimonials here.
`; + list.forEach(t=>{ + const av=t.image_path?``:`
${esc(t.full_name.charAt(0))}
`; + const sb=`${t.status}`; + h+=`
${av}
+
${esc(t.full_name)} ${sb}
${esc(t.location)}
+
${esc(t.message)}
+ +
+ ${t.status!=='approved'?``:''} + ${t.status!=='denied'?``:''} + + +
`; + }); + 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=``; + 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=`
+
Name
+
Location
+
Category
+
Price
+
Rating
+
Image (upload new to replace)${imgPicker('ei-i-'+id,d.image)}
+
Description
+
+ + +
+
`; + 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=`
+
Original Price
+
Discount %
+
End Date
+
Image (upload to replace; leave blank to use destination photo)${imgPicker('si-i-'+id,curImg)}
+
Highlights (one per line)
+
+ + +
+
`; + 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(); +})(); diff --git a/sites/epictravelexpeditions.com/public_html/static/js/admin-portal.js b/sites/epictravelexpeditions.com/public_html/static/js/admin-portal.js new file mode 100644 index 0000000..d6961ba --- /dev/null +++ b/sites/epictravelexpeditions.com/public_html/static/js/admin-portal.js @@ -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 + ?`` + :`
🖼
`; + return `
${pr}
`; +} + +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`${d}d ago`;} +function expBdg(d){if(d<0)return`Expired`;const c=d>14?'bg':d>7?'by':'br';return`${d}d left`;} +function esc(s){const e=document.createElement('div');e.textContent=s||'';return e.innerHTML;} +function catOpts(cats,sel){return cats.map(c=>``).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=` +
+ +
+ 🌐 View Site + +
+
+
+ + + + +
+
`; + 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=`
+
🗺
${destinations.length}
Destinations
+
${specials.length}
Active Specials
+
${pen}
Pending Reviews
+
${app}
Approved Testimonials
+
`; + if(pen>0)h+=`
⚠️ ${pen} testimonial${pen>1?'s':''} awaiting review.
`; + + /* Destinations age */ + const sd=[...destinations].sort((a,b)=>new Date(a.created_at)-new Date(b.created_at)); + h+=`
🗺 Destinations
Oldest entries first
`; + sd.forEach(d=>{h+=``;}); + h+=`
DestinationCategoryPriceIn System Since
${esc(d.name)} ${esc(d.location)}${esc(d.category)}${fmtPrice(d.price)}${ageBdg(daysAgo(d.created_at))}
`; + + /* Specials expiry */ + const ss=[...specials].sort((a,b)=>new Date(a.end_date)-new Date(b.end_date)); + h+=`
⭐ Weekly Specials
Expiring soonest first
`; + 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+=` + + + + + + + + `; + }); + h+=`
ImageDestinationOriginal PriceDiscountSale PriceExpiresStatus
${imgSrc?``:''}${esc(dest?dest.name:s.destination_id)}${fmtPrice(basePrice)}${parseFloat(s.discount)}% OFF${fmtPrice(salePrice)}${new Date(s.end_date).toLocaleDateString()}${expBdg(daysLeft(s.end_date))}
`; + + /* Pending testimonials */ + const pl=testimonials.filter(t=>t.status==='pending'); + if(pl.length){ + h+=`
⏳ Pending Testimonials
`; + pl.forEach(t=>{ + const av=t.image_path?``:`
${esc(t.full_name.charAt(0))}
`; + h+=`
${av}
${esc(t.full_name)}
${esc(t.location)}
${esc(t.message)}
`; + }); + h+=`
`; + } + return h; +} + +/* Destinations */ +function destRow(d){ + return` + + ${esc(d.name)}
${esc(d.location)} + ${esc(d.category)} + ${fmtPrice(d.price)} + ⭐ ${parseFloat(d.rating).toFixed(1)} + ${ageBdg(daysAgo(d.created_at))} + + `; +} + +function rDest(){ + const cc={};destinations.forEach(d=>{cc[d.category]=(cc[d.category]||0)+1;}); + return`
+
🏷 Categories
Add, rename, or remove destination categories
+
+
+
+ + + + +
+
+
${categories.map(c=>{const n=cc[c.name]||0;return`
${esc(c.name)}${n} destination${n!==1?'s':''}
`;}).join('')}
+
+
+
+
🗺 All Destinations (${destinations.length})
+
+
+
New Destination
+
+
Name *
+
Location *
+
Category *
+
Price (USD) *
+
Rating (1–5)
+
Image *${imgPicker('ep-dn-img','')}
+
Description *
+
+
+
+ + ${destinations.map(destRow).join('')}
PhotoName & LocationCategoryPriceRatingIn SystemActions
+
+
`; +} + +/* 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` + ${img?``:''} + ${esc(dest?dest.name:s.destination_id)} + ${parseFloat(s.discount)}% OFF + ${fmtPrice(base)} + ${fmtPrice(sale)} + ${new Date(s.end_date).toLocaleDateString()} + ${expBdg(daysLeft(s.end_date))} + + `; +} + +function rSpec(){ + const destOpts=destinations.map(d=>``).join(''); + return`
+
⭐ Weekly Specials (${specials.length})
+
+
+
New Special
+
+
Destination *
+
Original Price (USD) *
+
Discount % *
+
End Date *
+
Special Image (optional — defaults to destination photo)${imgPicker('ep-sn-img','')}
+
Highlights (one per line)
+
+
+
+ + ${specials.map(specRow).join('')}
ImageDestinationDiscountOriginal PriceSale PriceEnd DateStatusActions
+
+
`; +} + +/* 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=`
+ + + + +
`; + if(!list.length)return h+`
💬 No testimonials here.
`; + list.forEach(t=>{ + const av=t.image_path?``:`
${esc(t.full_name.charAt(0))}
`; + const sb=`${t.status}`; + h+=`
${av}
+
${esc(t.full_name)} ${sb}
${esc(t.location)}
+
${esc(t.message)}
+ +
+ ${t.status!=='approved'?``:''} + ${t.status!=='denied'?``:''} + + +
`; + }); + 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=``; + 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=`
+
Name
+
Location
+
Category
+
Price
+
Rating
+
Image (upload new to replace)${imgPicker('ei-i-'+id,d.image)}
+
Description
+
+ + +
+
`; + 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=`
+
Original Price
+
Discount %
+
End Date
+
Image (upload to replace; leave blank to use destination photo)${imgPicker('si-i-'+id,curImg)}
+
Highlights (one per line)
+
+ + +
+
`; + 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(); +})(); diff --git a/sites/epictravelexpeditions.com/public_html/static/js/admin-testimonials.js b/sites/epictravelexpeditions.com/public_html/static/js/admin-testimonials.js new file mode 100644 index 0000000..810be1d --- /dev/null +++ b/sites/epictravelexpeditions.com/public_html/static/js/admin-testimonials.js @@ -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 `${s}`; + } + + function avatarHtml(t) { + if (t.image_path) return ``; + return `
${t.full_name.charAt(0).toUpperCase()}
`; + } + + 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 = '

No testimonials in this category.

'; + return; + } + list.forEach(t => { + const card = document.createElement('div'); + card.className = 'et-admin-card'; + card.dataset.id = t.id; + card.innerHTML = ` + ${avatarHtml(t)} +
+
${t.full_name}
+
${t.location}
+ ${statusBadge(t.status)} +
${t.message}
+ +
+ ${t.status !== 'approved' ? '' : ''} + ${t.status !== 'denied' ? '' : ''} + + +
+
+ `; + + 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 = ` +

Testimonials

+
+ + + + +
+
+ `; + + 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 }); +})(); diff --git a/sites/epictravelexpeditions.com/public_html/static/js/main.b9c030e8.js b/sites/epictravelexpeditions.com/public_html/static/js/main.b9c030e8.js new file mode 100644 index 0000000..49d3a37 --- /dev/null +++ b/sites/epictravelexpeditions.com/public_html/static/js/main.b9c030e8.js @@ -0,0 +1,3 @@ +/*! For license information please see main.b9c030e8.js.LICENSE.txt */ +(()=>{"use strict";var e={4(e,t,n){var r=n(853),a=n(43),o=n(950);function l(e){var t="https://react.dev/errors/"+e;if(1F||(e.current=M[F],M[F]=null,F--)}function U(e,t){F++,M[F]=e.current,e.current=t}var H,W,V=I(null),$=I(null),q=I(null),K=I(null);function Y(e,t){switch(U(q,t),U($,e),U(V,null),t.nodeType){case 9:case 11:e=(e=t.documentElement)&&(e=e.namespaceURI)?vd(e):0;break;default:if(e=t.tagName,t=t.namespaceURI)e=yd(t=vd(t),e);else switch(e){case"svg":e=1;break;case"math":e=2;break;default:e=0}}B(V),U(V,e)}function Q(){B(V),B($),B(q)}function G(e){null!==e.memoizedState&&U(K,e);var t=V.current,n=yd(t,e.type);t!==n&&(U($,e),U(V,n))}function X(e){$.current===e&&(B(V),B($)),K.current===e&&(B(K),df._currentValue=z)}function J(e){if(void 0===H)try{throw Error()}catch(yg){var t=yg.stack.trim().match(/\n( *(at )?)/);H=t&&t[1]||"",W=-1)":-1--a||s[r]!==c[a]){var u="\n"+s[r].replace(" at new "," at ");return e.displayName&&u.includes("")&&(u=u.replace("",e.displayName)),u}}while(1<=r&&0<=a);break}}}finally{Z=!1,Error.prepareStackTrace=n}return(n=e?e.displayName||e.name:"")?J(n):""}function te(e,t){switch(e.tag){case 26:case 27:case 5:return J(e.type);case 16:return J("Lazy");case 13:return e.child!==t&&null!==t?J("Suspense Fallback"):J("Suspense");case 19:return J("SuspenseList");case 0:case 15:return ee(e.type,!1);case 11:return ee(e.type.render,!1);case 1:return ee(e.type,!0);case 31:return J("Activity");default:return""}}function ne(e){try{var t="",n=null;do{t+=te(e,n),n=e,e=e.return}while(e);return t}catch(yg){return"\nError generating stack: "+yg.message+"\n"+yg.stack}}var re=Object.prototype.hasOwnProperty,ae=r.unstable_scheduleCallback,oe=r.unstable_cancelCallback,le=r.unstable_shouldYield,ie=r.unstable_requestPaint,se=r.unstable_now,ce=r.unstable_getCurrentPriorityLevel,ue=r.unstable_ImmediatePriority,de=r.unstable_UserBlockingPriority,fe=r.unstable_NormalPriority,pe=r.unstable_LowPriority,me=r.unstable_IdlePriority,he=r.log,ge=r.unstable_setDisableYieldValue,ve=null,ye=null;function be(e){if("function"===typeof he&&ge(e),ye&&"function"===typeof ye.setStrictMode)try{ye.setStrictMode(ve,e)}catch(t){}}var we=Math.clz32?Math.clz32:function(e){return e>>>=0,0===e?32:31-(xe(e)/ke|0)|0},xe=Math.log,ke=Math.LN2;var Se=256,Ee=262144,Ne=4194304;function Ce(e){var t=42&e;if(0!==t)return t;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return 261888&e;case 262144:case 524288:case 1048576:case 2097152:return 3932160&e;case 4194304:case 8388608:case 16777216:case 33554432:return 62914560&e;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function je(e,t,n){var r=e.pendingLanes;if(0===r)return 0;var a=0,o=e.suspendedLanes,l=e.pingedLanes;e=e.warmLanes;var i=134217727&r;return 0!==i?0!==(r=i&~o)?a=Ce(r):0!==(l&=i)?a=Ce(l):n||0!==(n=i&~e)&&(a=Ce(n)):0!==(i=r&~o)?a=Ce(i):0!==l?a=Ce(l):n||0!==(n=r&~e)&&(a=Ce(n)),0===a?0:0!==t&&t!==a&&0===(t&o)&&((o=a&-a)>=(n=t&-t)||32===o&&0!==(4194048&n))?t:a}function Re(e,t){return 0===(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)}function Te(e,t){switch(e){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;default:return-1}}function Pe(){var e=Ne;return 0===(62914560&(Ne<<=1))&&(Ne=4194304),e}function _e(e){for(var t=[],n=0;31>n;n++)t.push(e);return t}function Oe(e,t){e.pendingLanes|=t,268435456!==t&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function Ae(e,t,n){e.pendingLanes|=t,e.suspendedLanes&=~t;var r=31-we(t);e.entangledLanes|=t,e.entanglements[r]=1073741824|e.entanglements[r]|261930&n}function Le(e,t){var n=e.entangledLanes|=t;for(e=e.entanglements;n;){var r=31-we(n),a=1<=Cn),Tn=String.fromCharCode(32),Pn=!1;function _n(e,t){switch(e){case"keyup":return-1!==En.indexOf(t.keyCode);case"keydown":return 229!==t.keyCode;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function On(e){return"object"===typeof(e=e.detail)&&"data"in e?e.data:null}var An=!1;var Ln={color:!0,date:!0,datetime:!0,"datetime-local":!0,email:!0,month:!0,number:!0,password:!0,range:!0,search:!0,tel:!0,text:!0,time:!0,url:!0,week:!0};function Dn(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return"input"===t?!!Ln[e.type]:"textarea"===t}function zn(e,t,n,r){Dt?zt?zt.push(r):zt=[r]:Dt=r,0<(t=rd(t,"onChange")).length&&(n=new nn("onChange","change",null,n,r),e.push({event:n,listeners:t}))}var Mn=null,Fn=null;function In(e){Qu(e,0)}function Bn(e){if(mt(Je(e)))return e}function Un(e,t){if("change"===e)return t}var Hn=!1;if(Ut){var Wn;if(Ut){var Vn="oninput"in document;if(!Vn){var $n=document.createElement("div");$n.setAttribute("oninput","return;"),Vn="function"===typeof $n.oninput}Wn=Vn}else Wn=!1;Hn=Wn&&(!document.documentMode||9=t)return{node:r,offset:t-e};e=n}e:{for(;r;){if(r.nextSibling){r=r.nextSibling;break e}r=r.parentNode}r=void 0}r=er(r)}}function nr(e,t){return!(!e||!t)&&(e===t||(!e||3!==e.nodeType)&&(t&&3===t.nodeType?nr(e,t.parentNode):"contains"in e?e.contains(t):!!e.compareDocumentPosition&&!!(16&e.compareDocumentPosition(t))))}function rr(e){for(var t=ht((e=null!=e&&null!=e.ownerDocument&&null!=e.ownerDocument.defaultView?e.ownerDocument.defaultView:window).document);t instanceof e.HTMLIFrameElement;){try{var n="string"===typeof t.contentWindow.location.href}catch(r){n=!1}if(!n)break;t=ht((e=t.contentWindow).document)}return t}function ar(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&("input"===t&&("text"===e.type||"search"===e.type||"tel"===e.type||"url"===e.type||"password"===e.type)||"textarea"===t||"true"===e.contentEditable)}var or=Ut&&"documentMode"in document&&11>=document.documentMode,lr=null,ir=null,sr=null,cr=!1;function ur(e,t,n){var r=n.window===n?n.document:9===n.nodeType?n:n.ownerDocument;cr||null==lr||lr!==ht(r)||("selectionStart"in(r=lr)&&ar(r)?r={start:r.selectionStart,end:r.selectionEnd}:r={anchorNode:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection()).anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset},sr&&Zn(sr,r)||(sr=r,0<(r=rd(ir,"onSelect")).length&&(t=new nn("onSelect","select",null,t,n),e.push({event:t,listeners:r}),t.target=lr)))}function dr(e,t){var n={};return n[e.toLowerCase()]=t.toLowerCase(),n["Webkit"+e]="webkit"+t,n["Moz"+e]="moz"+t,n}var fr={animationend:dr("Animation","AnimationEnd"),animationiteration:dr("Animation","AnimationIteration"),animationstart:dr("Animation","AnimationStart"),transitionrun:dr("Transition","TransitionRun"),transitionstart:dr("Transition","TransitionStart"),transitioncancel:dr("Transition","TransitionCancel"),transitionend:dr("Transition","TransitionEnd")},pr={},mr={};function hr(e){if(pr[e])return pr[e];if(!fr[e])return e;var t,n=fr[e];for(t in n)if(n.hasOwnProperty(t)&&t in mr)return pr[e]=n[t];return e}Ut&&(mr=document.createElement("div").style,"AnimationEvent"in window||(delete fr.animationend.animation,delete fr.animationiteration.animation,delete fr.animationstart.animation),"TransitionEvent"in window||delete fr.transitionend.transition);var gr=hr("animationend"),vr=hr("animationiteration"),yr=hr("animationstart"),br=hr("transitionrun"),wr=hr("transitionstart"),xr=hr("transitioncancel"),kr=hr("transitionend"),Sr=new Map,Er="abort auxClick beforeToggle cancel canPlay canPlayThrough click close contextMenu copy cut drag dragEnd dragEnter dragExit dragLeave dragOver dragStart drop durationChange emptied encrypted ended error gotPointerCapture input invalid keyDown keyPress keyUp load loadedData loadedMetadata loadStart lostPointerCapture mouseDown mouseMove mouseOut mouseOver mouseUp paste pause play playing pointerCancel pointerDown pointerMove pointerOut pointerOver pointerUp progress rateChange reset resize seeked seeking stalled submit suspend timeUpdate touchCancel touchEnd touchStart volumeChange scroll toggle touchMove waiting wheel".split(" ");function Nr(e,t){Sr.set(e,t),rt(t,[e])}Er.push("scrollEnd");var Cr="function"===typeof reportError?reportError:function(e){if("object"===typeof window&&"function"===typeof window.ErrorEvent){var t=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:"object"===typeof e&&null!==e&&"string"===typeof e.message?String(e.message):String(e),error:e});if(!window.dispatchEvent(t))return}else if("object"===typeof process&&"function"===typeof process.emit)return void process.emit("uncaughtException",e);console.error(e)},jr=[],Rr=0,Tr=0;function Pr(){for(var e=Rr,t=Tr=Rr=0;t>=l,a-=l,na=1<<32-we(t)+a|n<h?(g=d,d=null):g=d.sibling;var v=p(a,d,i[h],s);if(null===v){null===d&&(d=g);break}e&&d&&null===v.alternate&&t(a,d),l=o(v,l,h),null===u?c=v:u.sibling=v,u=v,d=g}if(h===i.length)return n(a,d),da&&aa(a,h),c;if(null===d){for(;hg?(v=h,h=null):v=h.sibling;var b=p(a,h,y.value,c);if(null===b){null===h&&(h=v);break}e&&h&&null===b.alternate&&t(a,h),i=o(b,i,g),null===d?u=b:d.sibling=b,d=b,h=v}if(y.done)return n(a,h),da&&aa(a,g),u;if(null===h){for(;!y.done;g++,y=s.next())null!==(y=f(a,y.value,c))&&(i=o(y,i,g),null===d?u=y:d.sibling=y,d=y);return da&&aa(a,g),u}for(h=r(h);!y.done;g++,y=s.next())null!==(y=m(h,a,g,y.value,c))&&(e&&null!==y.alternate&&h.delete(null===y.key?g:y.key),i=o(y,i,g),null===d?u=y:d.sibling=y,d=y);return e&&h.forEach(function(e){return t(a,e)}),da&&aa(a,g),u}(s,c,u=b.call(u),d)}if("function"===typeof u.then)return y(s,c,co(u),d);if(u.$$typeof===x)return y(s,c,Aa(s,u),d);fo(s,u)}return"string"===typeof u&&""!==u||"number"===typeof u||"bigint"===typeof u?(u=""+u,null!==c&&6===c.tag?(n(s,c.sibling),(d=a(c,u)).return=s,s=d):(n(s,c),(d=Vr(u,s.mode,d)).return=s,s=d),i(s)):n(s,c)}return function(e,t,n,r){try{so=0;var a=y(e,t,n,r);return io=null,a}catch(yg){if(yg===Xa||yg===Za)throw yg;var o=Fr(29,yg,null,e.mode);return o.lanes=r,o.return=e,o}}}var mo=po(!0),ho=po(!1),go=!1;function vo(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function yo(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,callbacks:null})}function bo(e){return{lane:e,tag:0,payload:null,callback:null,next:null}}function wo(e,t,n){var r=e.updateQueue;if(null===r)return null;if(r=r.shared,0!==(2&pc)){var a=r.pending;return null===a?t.next=t:(t.next=a.next,a.next=t),r.pending=t,t=Dr(e),Lr(e,null,n),t}return _r(e,r,t,n),Dr(e)}function xo(e,t,n){if(null!==(t=t.updateQueue)&&(t=t.shared,0!==(4194048&n))){var r=t.lanes;n|=r&=e.pendingLanes,t.lanes=n,Le(e,n)}}function ko(e,t){var n=e.updateQueue,r=e.alternate;if(null!==r&&n===(r=r.updateQueue)){var a=null,o=null;if(null!==(n=n.firstBaseUpdate)){do{var l={lane:n.lane,tag:n.tag,payload:n.payload,callback:null,next:null};null===o?a=o=l:o=o.next=l,n=n.next}while(null!==n);null===o?a=o=t:o=o.next=t}else a=o=t;return n={baseState:r.baseState,firstBaseUpdate:a,lastBaseUpdate:o,shared:r.shared,callbacks:r.callbacks},void(e.updateQueue=n)}null===(e=n.lastBaseUpdate)?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}var So=!1;function Eo(){if(So){if(null!==Va)throw Va}}function No(e,t,n,r){So=!1;var a=e.updateQueue;go=!1;var o=a.firstBaseUpdate,l=a.lastBaseUpdate,i=a.shared.pending;if(null!==i){a.shared.pending=null;var s=i,c=s.next;s.next=null,null===l?o=c:l.next=c,l=s;var u=e.alternate;null!==u&&((i=(u=u.updateQueue).lastBaseUpdate)!==l&&(null===i?u.firstBaseUpdate=c:i.next=c,u.lastBaseUpdate=s))}if(null!==o){var d=a.baseState;for(l=0,u=c=s=null,i=o;;){var f=-536870913&i.lane,m=f!==i.lane;if(m?(gc&f)===f:(r&f)===f){0!==f&&f===Wa&&(So=!0),null!==u&&(u=u.next={lane:0,tag:i.tag,payload:i.payload,callback:null,next:null});e:{var h=e,g=i;f=t;var v=n;switch(g.tag){case 1:if("function"===typeof(h=g.payload)){d=h.call(v,d,f);break e}d=h;break e;case 3:h.flags=-65537&h.flags|128;case 0:if(null===(f="function"===typeof(h=g.payload)?h.call(v,d,f):h)||void 0===f)break e;d=p({},d,f);break e;case 2:go=!0}}null!==(f=i.callback)&&(e.flags|=64,m&&(e.flags|=8192),null===(m=a.callbacks)?a.callbacks=[f]:m.push(f))}else m={lane:f,tag:i.tag,payload:i.payload,callback:i.callback,next:null},null===u?(c=u=m,s=d):u=u.next=m,l|=f;if(null===(i=i.next)){if(null===(i=a.shared.pending))break;i=(m=i).next,m.next=null,a.lastBaseUpdate=m,a.shared.pending=null}}null===u&&(s=d),a.baseState=s,a.firstBaseUpdate=c,a.lastBaseUpdate=u,null===o&&(a.shared.lanes=0),Ec|=l,e.lanes=l,e.memoizedState=d}}function Co(e,t){if("function"!==typeof e)throw Error(l(191,e));e.call(t)}function jo(e,t){var n=e.callbacks;if(null!==n)for(e.callbacks=null,e=0;eo?o:8;var l=L.T,i={};L.T=i,di(e,!1,t,n);try{var s=a(),c=L.S;if(null!==c&&c(i,s),null!==s&&"object"===typeof s&&"function"===typeof s.then)ui(e,t,function(e,t){var n=[],r={status:"pending",value:null,reason:null,then:function(e){n.push(e)}};return e.then(function(){r.status="fulfilled",r.value=t;for(var e=0;e<\/script>",o=o.removeChild(o.firstChild);break;case"select":o="string"===typeof r.is?i.createElement("select",{is:r.is}):i.createElement("select"),r.multiple?o.multiple=!0:r.size&&(o.size=r.size);break;default:o="string"===typeof r.is?i.createElement(a,{is:r.is}):i.createElement(a)}}o[Ue]=t,o[He]=r;e:for(i=t.child;null!==i;){if(5===i.tag||6===i.tag)o.appendChild(i.stateNode);else if(4!==i.tag&&27!==i.tag&&null!==i.child){i.child.return=i,i=i.child;continue}if(i===t)break e;for(;null===i.sibling;){if(null===i.return||i.return===t)break e;i=i.return}i.sibling.return=i.return,i=i.sibling}t.stateNode=o;e:switch(fd(o,a,r),a){case"button":case"input":case"select":case"textarea":r=!!r.autoFocus;break e;case"img":r=!0;break e;default:r=!1}r&&is(t)}}return fs(t),ss(t,t.type,null===e||e.memoizedProps,t.pendingProps,n),null;case 6:if(e&&null!=t.stateNode)e.memoizedProps!==r&&is(t);else{if("string"!==typeof r&&null===t.stateNode)throw Error(l(166));if(e=q.current,ya(t)){if(e=t.stateNode,n=t.memoizedProps,r=null,null!==(a=ca))switch(a.tag){case 27:case 5:r=a.memoizedProps}e[Ue]=t,(e=!!(e.nodeValue===n||null!==r&&!0===r.suppressHydrationWarning||cd(e.nodeValue,n)))||ha(t,!0)}else(e=gd(e).createTextNode(r))[Ue]=t,t.stateNode=e}return fs(t),null;case 31:if(n=t.memoizedState,null===e||null!==e.memoizedState){if(r=ya(t),null!==n){if(null===e){if(!r)throw Error(l(318));if(!(e=null!==(e=t.memoizedState)?e.dehydrated:null))throw Error(l(557));e[Ue]=t}else ba(),0===(128&t.flags)&&(t.memoizedState=null),t.flags|=4;fs(t),e=!1}else n=wa(),null!==e&&null!==e.memoizedState&&(e.memoizedState.hydrationErrors=n),e=!0;if(!e)return 256&t.flags?(Io(t),t):(Io(t),null);if(0!==(128&t.flags))throw Error(l(558))}return fs(t),null;case 13:if(r=t.memoizedState,null===e||null!==e.memoizedState&&null!==e.memoizedState.dehydrated){if(a=ya(t),null!==r&&null!==r.dehydrated){if(null===e){if(!a)throw Error(l(318));if(!(a=null!==(a=t.memoizedState)?a.dehydrated:null))throw Error(l(317));a[Ue]=t}else ba(),0===(128&t.flags)&&(t.memoizedState=null),t.flags|=4;fs(t),a=!1}else a=wa(),null!==e&&null!==e.memoizedState&&(e.memoizedState.hydrationErrors=a),a=!0;if(!a)return 256&t.flags?(Io(t),t):(Io(t),null)}return Io(t),0!==(128&t.flags)?(t.lanes=n,t):(n=null!==r,e=null!==e&&null!==e.memoizedState,n&&(a=null,null!==(r=t.child).alternate&&null!==r.alternate.memoizedState&&null!==r.alternate.memoizedState.cachePool&&(a=r.alternate.memoizedState.cachePool.pool),o=null,null!==r.memoizedState&&null!==r.memoizedState.cachePool&&(o=r.memoizedState.cachePool.pool),o!==a&&(r.flags|=2048)),n!==e&&n&&(t.child.flags|=8192),us(t,t.updateQueue),fs(t),null);case 4:return Q(),null===e&&Zu(t.stateNode.containerInfo),fs(t),null;case 10:return Ca(t.type),fs(t),null;case 19:if(B(Bo),null===(r=t.memoizedState))return fs(t),null;if(a=0!==(128&t.flags),null===(o=r.rendering))if(a)ds(r,!1);else{if(0!==Sc||null!==e&&0!==(128&e.flags))for(e=t.child;null!==e;){if(null!==(o=Uo(e))){for(t.flags|=128,ds(r,!1),e=o.updateQueue,t.updateQueue=e,us(t,e),t.subtreeFlags=0,e=n,n=t.child;null!==n;)Ur(n,e),n=n.sibling;return U(Bo,1&Bo.current|2),da&&aa(t,r.treeForkCount),t.child}e=e.sibling}null!==r.tail&&se()>Lc&&(t.flags|=128,a=!0,ds(r,!1),t.lanes=4194304)}else{if(!a)if(null!==(e=Uo(o))){if(t.flags|=128,a=!0,e=e.updateQueue,t.updateQueue=e,us(t,e),ds(r,!0),null===r.tail&&"hidden"===r.tailMode&&!o.alternate&&!da)return fs(t),null}else 2*se()-r.renderingStartTime>Lc&&536870912!==n&&(t.flags|=128,a=!0,ds(r,!1),t.lanes=4194304);r.isBackwards?(o.sibling=t.child,t.child=o):(null!==(e=r.last)?e.sibling=o:t.child=o,r.last=o)}return null!==r.tail?(e=r.tail,r.rendering=e,r.tail=e.sibling,r.renderingStartTime=se(),e.sibling=null,n=Bo.current,U(Bo,a?1&n|2:1&n),da&&aa(t,r.treeForkCount),e):(fs(t),null);case 22:case 23:return Io(t),Oo(),r=null!==t.memoizedState,null!==e?null!==e.memoizedState!==r&&(t.flags|=8192):r&&(t.flags|=8192),r?0!==(536870912&n)&&0===(128&t.flags)&&(fs(t),6&t.subtreeFlags&&(t.flags|=8192)):fs(t),null!==(n=t.updateQueue)&&us(t,n.retryQueue),n=null,null!==e&&null!==e.memoizedState&&null!==e.memoizedState.cachePool&&(n=e.memoizedState.cachePool.pool),r=null,null!==t.memoizedState&&null!==t.memoizedState.cachePool&&(r=t.memoizedState.cachePool.pool),r!==n&&(t.flags|=2048),null!==e&&B(Ka),null;case 24:return n=null,null!==e&&(n=e.memoizedState.cache),t.memoizedState.cache!==n&&(t.flags|=2048),Ca(Fa),fs(t),null;case 25:case 30:return null}throw Error(l(156,t.tag))}function ms(e,t){switch(ia(t),t.tag){case 1:return 65536&(e=t.flags)?(t.flags=-65537&e|128,t):null;case 3:return Ca(Fa),Q(),0!==(65536&(e=t.flags))&&0===(128&e)?(t.flags=-65537&e|128,t):null;case 26:case 27:case 5:return X(t),null;case 31:if(null!==t.memoizedState){if(Io(t),null===t.alternate)throw Error(l(340));ba()}return 65536&(e=t.flags)?(t.flags=-65537&e|128,t):null;case 13:if(Io(t),null!==(e=t.memoizedState)&&null!==e.dehydrated){if(null===t.alternate)throw Error(l(340));ba()}return 65536&(e=t.flags)?(t.flags=-65537&e|128,t):null;case 19:return B(Bo),null;case 4:return Q(),null;case 10:return Ca(t.type),null;case 22:case 23:return Io(t),Oo(),null!==e&&B(Ka),65536&(e=t.flags)?(t.flags=-65537&e|128,t):null;case 24:return Ca(Fa),null;default:return null}}function hs(e,t){switch(ia(t),t.tag){case 3:Ca(Fa),Q();break;case 26:case 27:case 5:X(t);break;case 4:Q();break;case 31:null!==t.memoizedState&&Io(t);break;case 13:Io(t);break;case 19:B(Bo);break;case 10:Ca(t.type);break;case 22:case 23:Io(t),Oo(),null!==e&&B(Ka);break;case 24:Ca(Fa)}}function gs(e,t){try{var n=t.updateQueue,r=null!==n?n.lastEffect:null;if(null!==r){var a=r.next;n=a;do{if((n.tag&e)===e){r=void 0;var o=n.create,l=n.inst;r=o(),l.destroy=r}n=n.next}while(n!==a)}}catch(i){Su(t,t.return,i)}}function vs(e,t,n){try{var r=t.updateQueue,a=null!==r?r.lastEffect:null;if(null!==a){var o=a.next;r=o;do{if((r.tag&e)===e){var l=r.inst,i=l.destroy;if(void 0!==i){l.destroy=void 0,a=t;var s=n,c=i;try{c()}catch(u){Su(a,s,u)}}}r=r.next}while(r!==o)}}catch(u){Su(t,t.return,u)}}function ys(e){var t=e.updateQueue;if(null!==t){var n=e.stateNode;try{jo(t,n)}catch(r){Su(e,e.return,r)}}}function bs(e,t,n){n.props=Si(e.type,e.memoizedProps),n.state=e.memoizedState;try{n.componentWillUnmount()}catch(r){Su(e,t,r)}}function ws(e,t){try{var n=e.ref;if(null!==n){switch(e.tag){case 26:case 27:case 5:var r=e.stateNode;break;default:r=e.stateNode}"function"===typeof n?e.refCleanup=n(r):n.current=r}}catch(a){Su(e,t,a)}}function xs(e,t){var n=e.ref,r=e.refCleanup;if(null!==n)if("function"===typeof r)try{r()}catch(a){Su(e,t,a)}finally{e.refCleanup=null,null!=(e=e.alternate)&&(e.refCleanup=null)}else if("function"===typeof n)try{n(null)}catch(o){Su(e,t,o)}else n.current=null}function ks(e){var t=e.type,n=e.memoizedProps,r=e.stateNode;try{e:switch(t){case"button":case"input":case"select":case"textarea":n.autoFocus&&r.focus();break e;case"img":n.src?r.src=n.src:n.srcSet&&(r.srcset=n.srcSet)}}catch(a){Su(e,e.return,a)}}function Ss(e,t,n){try{var r=e.stateNode;!function(e,t,n,r){switch(t){case"div":case"span":case"svg":case"path":case"a":case"g":case"p":case"li":break;case"input":var a=null,o=null,i=null,s=null,c=null,u=null,d=null;for(m in n){var f=n[m];if(n.hasOwnProperty(m)&&null!=f)switch(m){case"checked":case"value":break;case"defaultValue":c=f;default:r.hasOwnProperty(m)||ud(e,t,m,null,r,f)}}for(var p in r){var m=r[p];if(f=n[p],r.hasOwnProperty(p)&&(null!=m||null!=f))switch(p){case"type":o=m;break;case"name":a=m;break;case"checked":u=m;break;case"defaultChecked":d=m;break;case"value":i=m;break;case"defaultValue":s=m;break;case"children":case"dangerouslySetInnerHTML":if(null!=m)throw Error(l(137,t));break;default:m!==f&&ud(e,t,p,m,r,f)}}return void yt(e,i,s,c,u,d,o,a);case"select":for(o in m=i=s=p=null,n)if(c=n[o],n.hasOwnProperty(o)&&null!=c)switch(o){case"value":break;case"multiple":m=c;default:r.hasOwnProperty(o)||ud(e,t,o,null,r,c)}for(a in r)if(o=r[a],c=n[a],r.hasOwnProperty(a)&&(null!=o||null!=c))switch(a){case"value":p=o;break;case"defaultValue":s=o;break;case"multiple":i=o;default:o!==c&&ud(e,t,a,o,r,c)}return t=s,n=i,r=m,void(null!=p?xt(e,!!n,p,!1):!!r!==!!n&&(null!=t?xt(e,!!n,t,!0):xt(e,!!n,n?[]:"",!1)));case"textarea":for(s in m=p=null,n)if(a=n[s],n.hasOwnProperty(s)&&null!=a&&!r.hasOwnProperty(s))switch(s){case"value":case"children":break;default:ud(e,t,s,null,r,a)}for(i in r)if(a=r[i],o=n[i],r.hasOwnProperty(i)&&(null!=a||null!=o))switch(i){case"value":p=a;break;case"defaultValue":m=a;break;case"children":break;case"dangerouslySetInnerHTML":if(null!=a)throw Error(l(91));break;default:a!==o&&ud(e,t,i,a,r,o)}return void kt(e,p,m);case"option":for(var h in n)if(p=n[h],n.hasOwnProperty(h)&&null!=p&&!r.hasOwnProperty(h))if("selected"===h)e.selected=!1;else ud(e,t,h,null,r,p);for(c in r)if(p=r[c],m=n[c],r.hasOwnProperty(c)&&p!==m&&(null!=p||null!=m))if("selected"===c)e.selected=p&&"function"!==typeof p&&"symbol"!==typeof p;else ud(e,t,c,p,r,m);return;case"img":case"link":case"area":case"base":case"br":case"col":case"embed":case"hr":case"keygen":case"meta":case"param":case"source":case"track":case"wbr":case"menuitem":for(var g in n)p=n[g],n.hasOwnProperty(g)&&null!=p&&!r.hasOwnProperty(g)&&ud(e,t,g,null,r,p);for(u in r)if(p=r[u],m=n[u],r.hasOwnProperty(u)&&p!==m&&(null!=p||null!=m))switch(u){case"children":case"dangerouslySetInnerHTML":if(null!=p)throw Error(l(137,t));break;default:ud(e,t,u,p,r,m)}return;default:if(Rt(t)){for(var v in n)p=n[v],n.hasOwnProperty(v)&&void 0!==p&&!r.hasOwnProperty(v)&&dd(e,t,v,void 0,r,p);for(d in r)p=r[d],m=n[d],!r.hasOwnProperty(d)||p===m||void 0===p&&void 0===m||dd(e,t,d,p,r,m);return}}for(var y in n)p=n[y],n.hasOwnProperty(y)&&null!=p&&!r.hasOwnProperty(y)&&ud(e,t,y,null,r,p);for(f in r)p=r[f],m=n[f],!r.hasOwnProperty(f)||p===m||null==p&&null==m||ud(e,t,f,p,r,m)}(r,e.type,n,t),r[He]=t}catch(a){Su(e,e.return,a)}}function Es(e){return 5===e.tag||3===e.tag||26===e.tag||27===e.tag&&Cd(e.type)||4===e.tag}function Ns(e){e:for(;;){for(;null===e.sibling;){if(null===e.return||Es(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;5!==e.tag&&6!==e.tag&&18!==e.tag;){if(27===e.tag&&Cd(e.type))continue e;if(2&e.flags)continue e;if(null===e.child||4===e.tag)continue e;e.child.return=e,e=e.child}if(!(2&e.flags))return e.stateNode}}function Cs(e,t,n){var r=e.tag;if(5===r||6===r)e=e.stateNode,t?(9===n.nodeType?n.body:"HTML"===n.nodeName?n.ownerDocument.body:n).insertBefore(e,t):((t=9===n.nodeType?n.body:"HTML"===n.nodeName?n.ownerDocument.body:n).appendChild(e),null!==(n=n._reactRootContainer)&&void 0!==n||null!==t.onclick||(t.onclick=Ot));else if(4!==r&&(27===r&&Cd(e.type)&&(n=e.stateNode,t=null),null!==(e=e.child)))for(Cs(e,t,n),e=e.sibling;null!==e;)Cs(e,t,n),e=e.sibling}function js(e,t,n){var r=e.tag;if(5===r||6===r)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(4!==r&&(27===r&&Cd(e.type)&&(n=e.stateNode),null!==(e=e.child)))for(js(e,t,n),e=e.sibling;null!==e;)js(e,t,n),e=e.sibling}function Rs(e){var t=e.stateNode,n=e.memoizedProps;try{for(var r=e.type,a=t.attributes;a.length;)t.removeAttributeNode(a[0]);fd(t,r,n),t[Ue]=e,t[He]=n}catch(o){Su(e,e.return,o)}}var Ts=!1,Ps=!1,_s=!1,Os="function"===typeof WeakSet?WeakSet:Set,As=null;function Ls(e,t,n){var r=n.flags;switch(n.tag){case 0:case 11:case 15:Ys(e,n),4&r&&gs(5,n);break;case 1:if(Ys(e,n),4&r)if(e=n.stateNode,null===t)try{e.componentDidMount()}catch(l){Su(n,n.return,l)}else{var a=Si(n.type,t.memoizedProps);t=t.memoizedState;try{e.componentDidUpdate(a,t,e.__reactInternalSnapshotBeforeUpdate)}catch(i){Su(n,n.return,i)}}64&r&&ys(n),512&r&&ws(n,n.return);break;case 3:if(Ys(e,n),64&r&&null!==(e=n.updateQueue)){if(t=null,null!==n.child)switch(n.child.tag){case 27:case 5:case 1:t=n.child.stateNode}try{jo(e,t)}catch(l){Su(n,n.return,l)}}break;case 27:null===t&&4&r&&Rs(n);case 26:case 5:Ys(e,n),null===t&&4&r&&ks(n),512&r&&ws(n,n.return);break;case 12:Ys(e,n);break;case 31:Ys(e,n),4&r&&Bs(e,n);break;case 13:Ys(e,n),4&r&&Us(e,n),64&r&&(null!==(e=n.memoizedState)&&(null!==(e=e.dehydrated)&&function(e,t){var n=e.ownerDocument;if("$~"===e.data)e._reactRetry=t;else if("$?"!==e.data||"loading"!==n.readyState)t();else{var r=function(){t(),n.removeEventListener("DOMContentLoaded",r)};n.addEventListener("DOMContentLoaded",r),e._reactRetry=r}}(e,n=ju.bind(null,n))));break;case 22:if(!(r=null!==n.memoizedState||Ts)){t=null!==t&&null!==t.memoizedState||Ps,a=Ts;var o=Ps;Ts=r,(Ps=t)&&!o?Gs(e,n,0!==(8772&n.subtreeFlags)):Ys(e,n),Ts=a,Ps=o}break;case 30:break;default:Ys(e,n)}}function Ds(e){var t=e.alternate;null!==t&&(e.alternate=null,Ds(t)),e.child=null,e.deletions=null,e.sibling=null,5===e.tag&&(null!==(t=e.stateNode)&&Qe(t)),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}var zs=null,Ms=!1;function Fs(e,t,n){for(n=n.child;null!==n;)Is(e,t,n),n=n.sibling}function Is(e,t,n){if(ye&&"function"===typeof ye.onCommitFiberUnmount)try{ye.onCommitFiberUnmount(ve,n)}catch(o){}switch(n.tag){case 26:Ps||xs(n,t),Fs(e,t,n),n.memoizedState?n.memoizedState.count--:n.stateNode&&(n=n.stateNode).parentNode.removeChild(n);break;case 27:Ps||xs(n,t);var r=zs,a=Ms;Cd(n.type)&&(zs=n.stateNode,Ms=!1),Fs(e,t,n),Fd(n.stateNode),zs=r,Ms=a;break;case 5:Ps||xs(n,t);case 6:if(r=zs,a=Ms,zs=null,Fs(e,t,n),Ms=a,null!==(zs=r))if(Ms)try{(9===zs.nodeType?zs.body:"HTML"===zs.nodeName?zs.ownerDocument.body:zs).removeChild(n.stateNode)}catch(l){Su(n,t,l)}else try{zs.removeChild(n.stateNode)}catch(l){Su(n,t,l)}break;case 18:null!==zs&&(Ms?(jd(9===(e=zs).nodeType?e.body:"HTML"===e.nodeName?e.ownerDocument.body:e,n.stateNode),$f(e)):jd(zs,n.stateNode));break;case 4:r=zs,a=Ms,zs=n.stateNode.containerInfo,Ms=!0,Fs(e,t,n),zs=r,Ms=a;break;case 0:case 11:case 14:case 15:vs(2,n,t),Ps||vs(4,n,t),Fs(e,t,n);break;case 1:Ps||(xs(n,t),"function"===typeof(r=n.stateNode).componentWillUnmount&&bs(n,t,r)),Fs(e,t,n);break;case 21:Fs(e,t,n);break;case 22:Ps=(r=Ps)||null!==n.memoizedState,Fs(e,t,n),Ps=r;break;default:Fs(e,t,n)}}function Bs(e,t){if(null===t.memoizedState&&(null!==(e=t.alternate)&&null!==(e=e.memoizedState))){e=e.dehydrated;try{$f(e)}catch(n){Su(t,t.return,n)}}}function Us(e,t){if(null===t.memoizedState&&(null!==(e=t.alternate)&&(null!==(e=e.memoizedState)&&null!==(e=e.dehydrated))))try{$f(e)}catch(n){Su(t,t.return,n)}}function Hs(e,t){var n=function(e){switch(e.tag){case 31:case 13:case 19:var t=e.stateNode;return null===t&&(t=e.stateNode=new Os),t;case 22:return null===(t=(e=e.stateNode)._retryCache)&&(t=e._retryCache=new Os),t;default:throw Error(l(435,e.tag))}}(e);t.forEach(function(t){if(!n.has(t)){n.add(t);var r=Ru.bind(null,e,t);t.then(r,r)}})}function Ws(e,t){var n=t.deletions;if(null!==n)for(var r=0;r title"))),fd(o,r,n),o[Ue]=e,et(o),r=o;break e;case"link":var i=nf("link","href",a).get(r+(n.href||""));if(i)for(var s=0;si)break;var u=s.transferSize,d=s.initiatorType;u&&pd(d)&&(l+=u*((s=s.responseEnd)of?50:800)+t);return e.unsuspend=n,function(){e.unsuspend=null,clearTimeout(r),clearTimeout(a)}}:null}(d,m),null!==m)return Bc=o,e.cancelPendingCommit=m(hu.bind(null,e,t,o,n,r,a,l,i,s,u,d,null,f,p)),void Jc(e,o,l,!c)}hu(e,t,o,n,r,a,l,i,s)}function Xc(e){for(var t=e;;){var n=t.tag;if((0===n||11===n||15===n)&&16384&t.flags&&(null!==(n=t.updateQueue)&&null!==(n=n.stores)))for(var r=0;rg&&(l=g,g=h,h=l);var v=tr(i,h),y=tr(i,g);if(v&&y&&(1!==p.rangeCount||p.anchorNode!==v.node||p.anchorOffset!==v.offset||p.focusNode!==y.node||p.focusOffset!==y.offset)){var b=d.createRange();b.setStart(v.node,v.offset),p.removeAllRanges(),h>g?(p.addRange(b),p.extend(y.node,y.offset)):(b.setEnd(y.node,y.offset),p.addRange(b))}}}}for(d=[],p=i;p=p.parentNode;)1===p.nodeType&&d.push({element:p,left:p.scrollLeft,top:p.scrollTop});for("function"===typeof i.focus&&i.focus(),i=0;in?32:n,L.T=null,n=Hc,Hc=null;var o=Fc,i=Bc;if(Mc=0,Ic=Fc=null,Bc=0,0!==(6&pc))throw Error(l(331));var s=pc;if(pc|=4,sc(o.current),ec(o,o.current,i,n),pc=s,zu(0,!1),ye&&"function"===typeof ye.onPostCommitFiberRoot)try{ye.onPostCommitFiberRoot(ve,o)}catch(c){}return!0}finally{D.p=a,L.T=r,bu(e,t)}}function ku(e,t,n){t=Yr(n,t),null!==(e=wo(e,t=Ti(e.stateNode,t,2),2))&&(Oe(e,2),Du(e))}function Su(e,t,n){if(3===e.tag)ku(e,e,n);else for(;null!==t;){if(3===t.tag){ku(t,e,n);break}if(1===t.tag){var r=t.stateNode;if("function"===typeof t.type.getDerivedStateFromError||"function"===typeof r.componentDidCatch&&(null===zc||!zc.has(r))){e=Yr(n,e),null!==(r=wo(t,n=Pi(2),2))&&(_i(n,r,t,e),Oe(r,2),Du(r));break}}t=t.return}}function Eu(e,t,n){var r=e.pingCache;if(null===r){r=e.pingCache=new fc;var a=new Set;r.set(t,a)}else void 0===(a=r.get(t))&&(a=new Set,r.set(t,a));a.has(n)||(xc=!0,a.add(n),e=Nu.bind(null,e,t,n),t.then(e,e))}function Nu(e,t,n){var r=e.pingCache;null!==r&&r.delete(t),e.pingedLanes|=e.suspendedLanes&n,e.warmLanes&=~n,mc===e&&(gc&n)===n&&(4===Sc||3===Sc&&(62914560&gc)===gc&&300>se()-Oc?0===(2&pc)&&tu(e,0):Cc|=n,Rc===gc&&(Rc=0)),Du(e)}function Cu(e,t){0===t&&(t=Pe()),null!==(e=Ar(e,t))&&(Oe(e,t),Du(e))}function ju(e){var t=e.memoizedState,n=0;null!==t&&(n=t.retryLane),Cu(e,n)}function Ru(e,t){var n=0;switch(e.tag){case 31:case 13:var r=e.stateNode,a=e.memoizedState;null!==a&&(n=a.retryLane);break;case 19:r=e.stateNode;break;case 22:r=e.stateNode._retryCache;break;default:throw Error(l(314))}null!==r&&r.delete(t),Cu(e,n)}var Tu=null,Pu=null,_u=!1,Ou=!1,Au=!1,Lu=0;function Du(e){e!==Pu&&null===e.next&&(null===Pu?Tu=Pu=e:Pu=Pu.next=e),Ou=!0,_u||(_u=!0,Ed(function(){0!==(6&pc)?ae(ue,Mu):Fu()}))}function zu(e,t){if(!Au&&Ou){Au=!0;do{for(var n=!1,r=Tu;null!==r;){if(!t)if(0!==e){var a=r.pendingLanes;if(0===a)var o=0;else{var l=r.suspendedLanes,i=r.pingedLanes;o=(1<<31-we(42|e)+1)-1,o=201326741&(o&=a&~(l&~i))?201326741&o|1:o?2|o:0}0!==o&&(n=!0,Uu(r,o))}else o=gc,0===(3&(o=je(r,r===mc?o:0,null!==r.cancelPendingCommit||-1!==r.timeoutHandle)))||Re(r,o)||(n=!0,Uu(r,o));r=r.next}}while(n);Au=!1}}function Mu(){Fu()}function Fu(){Ou=_u=!1;var e=0;0!==Lu&&function(){var e=window.event;if(e&&"popstate"===e.type)return e!==wd&&(wd=e,!0);return wd=null,!1}()&&(e=Lu);for(var t=se(),n=null,r=Tu;null!==r;){var a=r.next,o=Iu(r,t);0===o?(r.next=null,null===n?Tu=a:n.next=a,null===a&&(Pu=n)):(n=r,(0!==e||0!==(3&o))&&(Ou=!0)),r=a}0!==Mc&&5!==Mc||zu(e,!1),0!==Lu&&(Lu=0)}function Iu(e,t){for(var n=e.suspendedLanes,r=e.pingedLanes,a=e.expirationTimes,o=-62914561&e.pendingLanes;0 title"):null)}function af(e){return"stylesheet"!==e.type||0!==(3&e.state.loading)}var of=0;function lf(){if(this.count--,0===this.count&&(0===this.imgCount||!this.waitingForImages))if(this.stylesheets)cf(this,this.stylesheets);else if(this.unsuspend){var e=this.unsuspend;this.unsuspend=null,e()}}var sf=null;function cf(e,t){e.stylesheets=null,null!==e.unsuspend&&(e.count++,sf=new Map,t.forEach(uf,e),sf=null,lf.call(e))}function uf(e,t){if(!(4&t.state.loading)){var n=sf.get(e);if(n)var r=n.get(null);else{n=new Map,sf.set(e,n);for(var a=e.querySelectorAll("link[data-precedence],style[data-precedence]"),o=0;o>>1,a=e[r];if(!(0>>1;ro(s,n))co(u,s)?(e[r]=u,e[c]=n,r=c):(e[r]=s,e[i]=n,r=i);else{if(!(co(u,n)))break e;e[r]=u,e[c]=n,r=c}}}return t}function o(e,t){var n=e.sortIndex-t.sortIndex;return 0!==n?n:e.id-t.id}if(t.unstable_now=void 0,"object"===typeof performance&&"function"===typeof performance.now){var l=performance;t.unstable_now=function(){return l.now()}}else{var i=Date,s=i.now();t.unstable_now=function(){return i.now()-s}}var c=[],u=[],d=1,f=null,p=3,m=!1,h=!1,g=!1,v=!1,y="function"===typeof setTimeout?setTimeout:null,b="function"===typeof clearTimeout?clearTimeout:null,w="undefined"!==typeof setImmediate?setImmediate:null;function x(e){for(var t=r(u);null!==t;){if(null===t.callback)a(u);else{if(!(t.startTime<=e))break;a(u),t.sortIndex=t.expirationTime,n(c,t)}t=r(u)}}function k(e){if(g=!1,x(e),!h)if(null!==r(c))h=!0,E||(E=!0,S());else{var t=r(u);null!==t&&O(k,t.startTime-e)}}var S,E=!1,N=-1,C=5,j=-1;function R(){return!!v||!(t.unstable_now()-je&&R());){var l=f.callback;if("function"===typeof l){f.callback=null,p=f.priorityLevel;var i=l(f.expirationTime<=e);if(e=t.unstable_now(),"function"===typeof i){f.callback=i,x(e),n=!0;break t}f===r(c)&&a(c),x(e)}else a(c);f=r(c)}if(null!==f)n=!0;else{var s=r(u);null!==s&&O(k,s.startTime-e),n=!1}}break e}finally{f=null,p=o,m=!1}n=void 0}}finally{n?S():E=!1}}}if("function"===typeof w)S=function(){w(T)};else if("undefined"!==typeof MessageChannel){var P=new MessageChannel,_=P.port2;P.port1.onmessage=T,S=function(){_.postMessage(null)}}else S=function(){y(T,0)};function O(e,n){N=y(function(){e(t.unstable_now())},n)}t.unstable_IdlePriority=5,t.unstable_ImmediatePriority=1,t.unstable_LowPriority=4,t.unstable_NormalPriority=3,t.unstable_Profiling=null,t.unstable_UserBlockingPriority=2,t.unstable_cancelCallback=function(e){e.callback=null},t.unstable_forceFrameRate=function(e){0>e||125l?(e.sortIndex=o,n(u,e),null===r(c)&&e===r(u)&&(g?(b(N),N=-1):g=!0,O(k,o-l))):(e.sortIndex=i,n(c,e),h||m||(h=!0,E||(E=!0,S()))),e},t.unstable_shouldYield=R,t.unstable_wrapCallback=function(e){var t=p;return function(){var n=p;p=t;try{return e.apply(this,arguments)}finally{p=n}}}},950(e,t,n){!function e(){if("undefined"!==typeof __REACT_DEVTOOLS_GLOBAL_HOOK__&&"function"===typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE)try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(e)}catch(t){console.error(t)}}(),e.exports=n(672)}},t={};function n(r){var a=t[r];if(void 0!==a)return a.exports;var o=t[r]={exports:{}};return e[r](o,o.exports,n),o.exports}(()=>{var e,t=Object.getPrototypeOf?e=>Object.getPrototypeOf(e):e=>e.__proto__;n.t=function(r,a){if(1&a&&(r=this(r)),8&a)return r;if("object"===typeof r&&r){if(4&a&&r.__esModule)return r;if(16&a&&"function"===typeof r.then)return r}var o=Object.create(null);n.r(o);var l={};e=e||[null,t({}),t([]),t(t)];for(var i=2&a&&r;("object"==typeof i||"function"==typeof i)&&!~e.indexOf(i);i=t(i))Object.getOwnPropertyNames(i).forEach(e=>l[e]=()=>r[e]);return l.default=()=>r,n.d(o,l),o}})(),n.d=(e,t)=>{for(var r in t)n.o(t,r)&&!n.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:t[r]})},n.g=function(){if("object"===typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"===typeof window)return window}}(),n.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),n.r=e=>{"undefined"!==typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.nc=void 0;var r={};n.r(r),n.d(r,{hasBrowserEnv:()=>Ja,hasStandardBrowserEnv:()=>eo,hasStandardBrowserWebWorkerEnv:()=>to,navigator:()=>Za,origin:()=>no});var a=n(43),o=n.t(a,2),l=n(391);function i(e,t){if(null==e)return{};var n,r,a=function(e,t){if(null==e)return{};var n={};for(var r in e)if({}.hasOwnProperty.call(e,r)){if(-1!==t.indexOf(r))continue;n[r]=e[r]}return n}(e,t);if(Object.getOwnPropertySymbols){var o=Object.getOwnPropertySymbols(e);for(r=0;r0&&void 0!==arguments[0]?arguments[0]:{})}function x(e,t){if(!1===e||null===e||"undefined"===typeof e)throw new Error(t)}function k(e,t){if(!e){"undefined"!==typeof console&&console.warn(t);try{throw new Error(t)}catch(n){}}}function S(e,t){return{usr:e.state,key:e.key,idx:t}}function E(e,t){let n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:null,r=arguments.length>3?arguments[3]:void 0;return f(f({pathname:"string"===typeof e?e:e.pathname,search:"",hash:""},"string"===typeof t?C(t):t),{},{state:n,key:t&&t.key||r||Math.random().toString(36).substring(2,10)})}function N(e){let{pathname:t="/",search:n="",hash:r=""}=e;return n&&"?"!==n&&(t+="?"===n.charAt(0)?n:"?"+n),r&&"#"!==r&&(t+="#"===r.charAt(0)?r:"#"+r),t}function C(e){let t={};if(e){let n=e.indexOf("#");n>=0&&(t.hash=e.substring(n),e=e.substring(0,n));let r=e.indexOf("?");r>=0&&(t.search=e.substring(r),e=e.substring(0,r)),e&&(t.pathname=e)}return t}function j(e,t,n){let r=arguments.length>3&&void 0!==arguments[3]?arguments[3]:{},{window:a=document.defaultView,v5Compat:o=!1}=r,l=a.history,i="POP",s=null,c=u();function u(){return(l.state||{idx:null}).idx}function d(){i="POP";let e=u(),t=null==e?null:e-c;c=e,s&&s({action:i,location:m.location,delta:t})}function p(e){return R(e)}null==c&&(c=0,l.replaceState(f(f({},l.state),{},{idx:c}),""));let m={get action(){return i},get location(){return e(a,l)},listen(e){if(s)throw new Error("A history only accepts one active listener");return a.addEventListener(b,d),s=e,()=>{a.removeEventListener(b,d),s=null}},createHref:e=>t(a,e),createURL:p,encodeLocation(e){let t=p(e);return{pathname:t.pathname,search:t.search,hash:t.hash}},push:function(e,t){i="PUSH";let r=E(m.location,e,t);n&&n(r,e),c=u()+1;let d=S(r,c),f=m.createHref(r);try{l.pushState(d,"",f)}catch(p){if(p instanceof DOMException&&"DataCloneError"===p.name)throw p;a.location.assign(f)}o&&s&&s({action:i,location:m.location,delta:1})},replace:function(e,t){i="REPLACE";let r=E(m.location,e,t);n&&n(r,e),c=u();let a=S(r,c),d=m.createHref(r);l.replaceState(a,"",d),o&&s&&s({action:i,location:m.location,delta:0})},go:e=>l.go(e)};return m}function R(e){let t=arguments.length>1&&void 0!==arguments[1]&&arguments[1],n="http://localhost";"undefined"!==typeof window&&(n="null"!==window.location.origin?window.location.origin:window.location.href),x(n,"No window.location.(origin|href) available to create URL");let r="string"===typeof e?e:N(e);return r=r.replace(/ $/,"%20"),!t&&r.startsWith("//")&&(r=n+r),new URL(r,n)}new WeakMap;function T(e,t){return P(e,t,arguments.length>2&&void 0!==arguments[2]?arguments[2]:"/",!1)}function P(e,t,n,r){let a=$(("string"===typeof t?C(t):t).pathname||"/",n);if(null==a)return null;let o=_(e);!function(e){e.sort((e,t)=>e.score!==t.score?t.score-e.score:function(e,t){let n=e.length===t.length&&e.slice(0,-1).every((e,n)=>e===t[n]);return n?e[e.length-1]-t[t.length-1]:0}(e.routesMeta.map(e=>e.childrenIndex),t.routesMeta.map(e=>e.childrenIndex)))}(o);let l=null;for(let i=0;null==l&&i1&&void 0!==arguments[1]?arguments[1]:[],n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:[],r=arguments.length>3&&void 0!==arguments[3]?arguments[3]:"",a=arguments.length>4&&void 0!==arguments[4]&&arguments[4],o=function(e,o){let l=arguments.length>2&&void 0!==arguments[2]?arguments[2]:a,i=arguments.length>3?arguments[3]:void 0,s={relativePath:void 0===i?e.path||"":i,caseSensitive:!0===e.caseSensitive,childrenIndex:o,route:e};if(s.relativePath.startsWith("/")){if(!s.relativePath.startsWith(r)&&l)return;x(s.relativePath.startsWith(r),'Absolute route path "'.concat(s.relativePath,'" nested under path "').concat(r,'" is not valid. An absolute child route path must start with the combined path of all its parent routes.')),s.relativePath=s.relativePath.slice(r.length)}let c=Z([r,s.relativePath]),u=n.concat(s);e.children&&e.children.length>0&&(x(!0!==e.index,'Index routes must not have child routes. Please remove all child routes from route path "'.concat(c,'".')),_(e.children,t,u,c,l)),(null!=e.path||e.index)&&t.push({path:c,score:B(c,e.index),routesMeta:u})};return e.forEach((e,t)=>{var n;if(""!==e.path&&null!==(n=e.path)&&void 0!==n&&n.includes("?"))for(let r of O(e.path))o(e,t,!0,r);else o(e,t)}),t}function O(e){let t=e.split("/");if(0===t.length)return[];let[n,...r]=t,a=n.endsWith("?"),o=n.replace(/\?$/,"");if(0===r.length)return a?[o,""]:[o];let l=O(r.join("/")),i=[];return i.push(...l.map(e=>""===e?o:[o,e].join("/"))),a&&i.push(...l),i.map(t=>e.startsWith("/")&&""===t?"/":t)}var A=/^:[\w-]+$/,L=3,D=2,z=1,M=10,F=-2,I=e=>"*"===e;function B(e,t){let n=e.split("/"),r=n.length;return n.some(I)&&(r+=F),t&&(r+=D),n.filter(e=>!I(e)).reduce((e,t)=>e+(A.test(t)?L:""===t?z:M),r)}function U(e,t){let n=arguments.length>2&&void 0!==arguments[2]&&arguments[2],{routesMeta:r}=e,a={},o="/",l=[];for(let i=0;i{let{paramName:r,isOptional:a}=t;if("*"===r){let e=i[n]||"";l=o.slice(0,o.length-e.length).replace(/(.)\/+$/,"$1")}const s=i[n];return e[r]=a&&!s?void 0:(s||"").replace(/%2F/g,"/"),e},{});return{params:s,pathname:o,pathnameBase:l,pattern:e}}function W(e){let t=arguments.length>1&&void 0!==arguments[1]&&arguments[1],n=!(arguments.length>2&&void 0!==arguments[2])||arguments[2];k("*"===e||!e.endsWith("*")||e.endsWith("/*"),'Route path "'.concat(e,'" will be treated as if it were "').concat(e.replace(/\*$/,"/*"),'" because the `*` character must always follow a `/` in the pattern. To get rid of this warning, please change the route path to "').concat(e.replace(/\*$/,"/*"),'".'));let r=[],a="^"+e.replace(/\/*\*?$/,"").replace(/^\/*/,"/").replace(/[\\.*+^${}|()[\]]/g,"\\$&").replace(/\/:([\w-]+)(\?)?/g,(e,t,n)=>(r.push({paramName:t,isOptional:null!=n}),n?"/?([^\\/]+)?":"/([^\\/]+)")).replace(/\/([\w-]+)\?(\/|$)/g,"(/$1)?$2");return e.endsWith("*")?(r.push({paramName:"*"}),a+="*"===e||"/*"===e?"(.*)$":"(?:\\/(.+)|\\/*)$"):n?a+="\\/*$":""!==e&&"/"!==e&&(a+="(?:(?=\\/|$))"),[new RegExp(a,t?void 0:"i"),r]}function V(e){try{return e.split("/").map(e=>decodeURIComponent(e).replace(/\//g,"%2F")).join("/")}catch(t){return k(!1,'The URL path "'.concat(e,'" could not be decoded because it is a malformed URL segment. This is probably due to a bad percent encoding (').concat(t,").")),e}}function $(e,t){if("/"===t)return e;if(!e.toLowerCase().startsWith(t.toLowerCase()))return null;let n=t.endsWith("/")?t.length-1:t.length,r=e.charAt(n);return r&&"/"!==r?null:e.slice(n)||"/"}var q=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i,K=e=>q.test(e);function Y(e,t){let n=t.replace(/\/+$/,"").split("/");return e.split("/").forEach(e=>{".."===e?n.length>1&&n.pop():"."!==e&&n.push(e)}),n.length>1?n.join("/"):"/"}function Q(e,t,n,r){return"Cannot include a '".concat(e,"' character in a manually specified `to.").concat(t,"` field [").concat(JSON.stringify(r),"]. Please separate it out to the `to.").concat(n,'` field. Alternatively you may provide the full path as a string in and the router will parse it for you.')}function G(e){return e.filter((e,t)=>0===t||e.route.path&&e.route.path.length>0)}function X(e){let t=G(e);return t.map((e,n)=>n===t.length-1?e.pathname:e.pathnameBase)}function J(e,t,n){let r,a=arguments.length>3&&void 0!==arguments[3]&&arguments[3];"string"===typeof e?r=C(e):(r=f({},e),x(!r.pathname||!r.pathname.includes("?"),Q("?","pathname","search",r)),x(!r.pathname||!r.pathname.includes("#"),Q("#","pathname","hash",r)),x(!r.search||!r.search.includes("#"),Q("#","search","hash",r)));let o,l=""===e||""===r.pathname,i=l?"/":r.pathname;if(null==i)o=n;else{let e=t.length-1;if(!a&&i.startsWith("..")){let t=i.split("/");for(;".."===t[0];)t.shift(),e-=1;r.pathname=t.join("/")}o=e>=0?t[e]:"/"}let s=function(e){let t,n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"/",{pathname:r,search:a="",hash:o=""}="string"===typeof e?C(e):e;if(r)if(K(r))t=r;else{if(r.includes("//")){let e=r;r=r.replace(/\/\/+/g,"/"),k(!1,"Pathnames cannot have embedded double slashes - normalizing ".concat(e," -> ").concat(r))}t=r.startsWith("/")?Y(r.substring(1),"/"):Y(r,n)}else t=n;return{pathname:t,search:te(a),hash:ne(o)}}(r,o),c=i&&"/"!==i&&i.endsWith("/"),u=(l||"."===i)&&n.endsWith("/");return s.pathname.endsWith("/")||!c&&!u||(s.pathname+="/"),s}var Z=e=>e.join("/").replace(/\/\/+/g,"/"),ee=e=>e.replace(/\/+$/,"").replace(/^\/*/,"/"),te=e=>e&&"?"!==e?e.startsWith("?")?e:"?"+e:"",ne=e=>e&&"#"!==e?e.startsWith("#")?e:"#"+e:"";var re=class{constructor(e,t,n){let r=arguments.length>3&&void 0!==arguments[3]&&arguments[3];this.status=e,this.statusText=t||"",this.internal=r,n instanceof Error?(this.data=n.toString(),this.error=n):this.data=n}};function ae(e){return null!=e&&"number"===typeof e.status&&"string"===typeof e.statusText&&"boolean"===typeof e.internal&&"data"in e}function oe(e){return e.map(e=>e.route.path).filter(Boolean).join("/").replace(/\/\/*/g,"/")||"/"}var le="undefined"!==typeof window&&"undefined"!==typeof window.document&&"undefined"!==typeof window.document.createElement;function ie(e,t){let n=e;if("string"!==typeof n||!q.test(n))return{absoluteURL:void 0,isExternal:!1,to:n};let r=n,a=!1;if(le)try{let e=new URL(window.location.href),r=n.startsWith("//")?new URL(e.protocol+n):new URL(n),o=$(r.pathname,t);r.origin===e.origin&&null!=o?n=o+r.search+r.hash:a=!0}catch(o){k(!1,' contains an invalid URL which will probably break when clicked - please update to a valid URL path.'))}return{absoluteURL:r,isExternal:a,to:n}}Symbol("Uninstrumented");Object.getOwnPropertyNames(Object.prototype).sort().join("\0");var se=["POST","PUT","PATCH","DELETE"],ce=(new Set(se),["GET",...se]);new Set(ce),Symbol("ResetLoaderData");var ue=a.createContext(null);ue.displayName="DataRouter";var de=a.createContext(null);de.displayName="DataRouterState";var fe=a.createContext(!1);function pe(){return a.useContext(fe)}var me=a.createContext({isTransitioning:!1});me.displayName="ViewTransition";var he=a.createContext(new Map);he.displayName="Fetchers";var ge=a.createContext(null);ge.displayName="Await";var ve=a.createContext(null);ve.displayName="Navigation";var ye=a.createContext(null);ye.displayName="Location";var be=a.createContext({outlet:null,matches:[],isDataRoute:!1});be.displayName="Route";var we=a.createContext(null);we.displayName="RouteError";var xe="REACT_ROUTER_ERROR";function ke(){return null!=a.useContext(ye)}function Se(){return x(ke(),"useLocation() may be used only in the context of a component."),a.useContext(ye).location}var Ee="You should call navigate() in a React.useEffect(), not when your component is first rendered.";function Ne(e){a.useContext(ve).static||a.useLayoutEffect(e)}function Ce(){let{isDataRoute:e}=a.useContext(be);return e?function(){let{router:e}=Me("useNavigate"),t=Ie("useNavigate"),n=a.useRef(!1);Ne(()=>{n.current=!0});let r=a.useCallback(async function(r){let a=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};k(n.current,Ee),n.current&&("number"===typeof r?await e.navigate(r):await e.navigate(r,f({fromRouteId:t},a)))},[e,t]);return r}():function(){x(ke(),"useNavigate() may be used only in the context of a component.");let e=a.useContext(ue),{basename:t,navigator:n}=a.useContext(ve),{matches:r}=a.useContext(be),{pathname:o}=Se(),l=JSON.stringify(X(r)),i=a.useRef(!1);Ne(()=>{i.current=!0});let s=a.useCallback(function(r){let a=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};if(k(i.current,Ee),!i.current)return;if("number"===typeof r)return void n.go(r);let s=J(r,JSON.parse(l),o,"path"===a.relative);null==e&&"/"!==t&&(s.pathname="/"===s.pathname?t:Z([t,s.pathname])),(a.replace?n.replace:n.push)(s,a.state,a)},[t,n,l,o,e]);return s}()}a.createContext(null);function je(e){let{relative:t}=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},{matches:n}=a.useContext(be),{pathname:r}=Se(),o=JSON.stringify(X(n));return a.useMemo(()=>J(e,JSON.parse(o),r,"path"===t),[e,o,r,t])}function Re(e,t,n,r,o){x(ke(),"useRoutes() may be used only in the context of a component.");let{navigator:l}=a.useContext(ve),{matches:i}=a.useContext(be),s=i[i.length-1],c=s?s.params:{},u=s?s.pathname:"/",d=s?s.pathnameBase:"/",p=s&&s.route;{let e=p&&p.path||"";He(u,!p||e.endsWith("*")||e.endsWith("*?"),'You rendered descendant (or called `useRoutes()`) at "'.concat(u,'" (under ) but the parent route path has no trailing "*". This means if you navigate deeper, the parent won\'t match anymore and therefore the child routes will never render.\n\nPlease change the parent to .'))}let m,h=Se();if(t){var g;let e="string"===typeof t?C(t):t;x("/"===d||(null===(g=e.pathname)||void 0===g?void 0:g.startsWith(d)),'When overriding the location using `` or `useRoutes(routes, location)`, the location pathname must begin with the portion of the URL pathname that was matched by all parent routes. The current pathname base is "'.concat(d,'" but pathname "').concat(e.pathname,'" was given in the `location` prop.')),m=e}else m=h;let v=m.pathname||"/",y=v;if("/"!==d){let e=d.replace(/^\//,"").split("/");y="/"+v.replace(/^\//,"").split("/").slice(e.length).join("/")}let b=T(e,{pathname:y});k(p||null!=b,'No routes matched location "'.concat(m.pathname).concat(m.search).concat(m.hash,'" ')),k(null==b||void 0!==b[b.length-1].route.element||void 0!==b[b.length-1].route.Component||void 0!==b[b.length-1].route.lazy,'Matched leaf route at location "'.concat(m.pathname).concat(m.search).concat(m.hash,'" does not have an element or Component. This means it will render an with a null value by default resulting in an "empty" page.'));let w=De(b&&b.map(e=>Object.assign({},e,{params:Object.assign({},c,e.params),pathname:Z([d,l.encodeLocation?l.encodeLocation(e.pathname.replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:e.pathname]),pathnameBase:"/"===e.pathnameBase?d:Z([d,l.encodeLocation?l.encodeLocation(e.pathnameBase.replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:e.pathnameBase])})),i,n,r,o);return t&&w?a.createElement(ye.Provider,{value:{location:f({pathname:"/",search:"",hash:"",state:null,key:"default"},m),navigationType:"POP"}},w):w}function Te(){let e=Be(),t=ae(e)?"".concat(e.status," ").concat(e.statusText):e instanceof Error?e.message:JSON.stringify(e),n=e instanceof Error?e.stack:null,r="rgba(200,200,200, 0.5)",o={padding:"0.5rem",backgroundColor:r},l={padding:"2px 4px",backgroundColor:r},i=null;return console.error("Error handled by React Router default ErrorBoundary:",e),i=a.createElement(a.Fragment,null,a.createElement("p",null,"\ud83d\udcbf Hey developer \ud83d\udc4b"),a.createElement("p",null,"You can provide a way better UX than this when your app throws errors by providing your own ",a.createElement("code",{style:l},"ErrorBoundary")," or"," ",a.createElement("code",{style:l},"errorElement")," prop on your route.")),a.createElement(a.Fragment,null,a.createElement("h2",null,"Unexpected Application Error!"),a.createElement("h3",{style:{fontStyle:"italic"}},t),n?a.createElement("pre",{style:o},n):null,i)}var Pe=a.createElement(Te,null),_e=class extends a.Component{constructor(e){super(e),this.state={location:e.location,revalidation:e.revalidation,error:e.error}}static getDerivedStateFromError(e){return{error:e}}static getDerivedStateFromProps(e,t){return t.location!==e.location||"idle"!==t.revalidation&&"idle"===e.revalidation?{error:e.error,location:e.location,revalidation:e.revalidation}:{error:void 0!==e.error?e.error:t.error,location:t.location,revalidation:e.revalidation||t.revalidation}}componentDidCatch(e,t){this.props.onError?this.props.onError(e,t):console.error("React Router caught the following error during render",e)}render(){let e=this.state.error;if(this.context&&"object"===typeof e&&e&&"digest"in e&&"string"===typeof e.digest){const t=function(e){if(e.startsWith("".concat(xe,":").concat("ROUTE_ERROR_RESPONSE",":{")))try{let t=JSON.parse(e.slice(40));if("object"===typeof t&&t&&"number"===typeof t.status&&"string"===typeof t.statusText)return new re(t.status,t.statusText,t.data)}catch(t){}}(e.digest);t&&(e=t)}let t=void 0!==e?a.createElement(be.Provider,{value:this.props.routeContext},a.createElement(we.Provider,{value:e,children:this.props.component})):this.props.children;return this.context?a.createElement(Ae,{error:e},t):t}};_e.contextType=fe;var Oe=new WeakMap;function Ae(e){let{children:t,error:n}=e,{basename:r}=a.useContext(ve);if("object"===typeof n&&n&&"digest"in n&&"string"===typeof n.digest){let e=function(e){if(e.startsWith("".concat(xe,":").concat("REDIRECT",":{")))try{let t=JSON.parse(e.slice(28));if("object"===typeof t&&t&&"number"===typeof t.status&&"string"===typeof t.statusText&&"string"===typeof t.location&&"boolean"===typeof t.reloadDocument&&"boolean"===typeof t.replace)return t}catch(t){}}(n.digest);if(e){let t=Oe.get(n);if(t)throw t;let o=ie(e.location,r);if(le&&!Oe.get(n)){if(!o.isExternal&&!e.reloadDocument){const t=Promise.resolve().then(()=>window.__reactRouterDataRouter.navigate(o.to,{replace:e.replace}));throw Oe.set(n,t),t}window.location.href=o.absoluteURL||o.to}return a.createElement("meta",{httpEquiv:"refresh",content:"0;url=".concat(o.absoluteURL||o.to)})}}return t}function Le(e){let{routeContext:t,match:n,children:r}=e,o=a.useContext(ue);return o&&o.static&&o.staticContext&&(n.route.errorElement||n.route.ErrorBoundary)&&(o.staticContext._deepestRenderedBoundaryId=n.route.id),a.createElement(be.Provider,{value:t},r)}function De(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:[],n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:null,r=arguments.length>3&&void 0!==arguments[3]?arguments[3]:null;if(null==e){if(!n)return null;if(n.errors)e=n.matches;else{if(0!==t.length||n.initialized||!(n.matches.length>0))return null;e=n.matches}}let o=e,l=null===n||void 0===n?void 0:n.errors;if(null!=l){let e=o.findIndex(e=>e.route.id&&void 0!==(null===l||void 0===l?void 0:l[e.route.id]));x(e>=0,"Could not find a matching route for errors on route IDs: ".concat(Object.keys(l).join(","))),o=o.slice(0,Math.min(o.length,e+1))}let i=!1,s=-1;if(n)for(let a=0;a=0?o.slice(0,s+1):[o[0]];break}}}let c=n&&r?(e,t)=>{var a,o;r(e,{location:n.location,params:null!==(a=null===(o=n.matches)||void 0===o||null===(o=o[0])||void 0===o?void 0:o.params)&&void 0!==a?a:{},unstable_pattern:oe(n.matches),errorInfo:t})}:void 0;return o.reduceRight((e,r,u)=>{let d,f=!1,p=null,m=null;n&&(d=l&&r.route.id?l[r.route.id]:void 0,p=r.route.errorElement||Pe,i&&(s<0&&0===u?(He("route-fallback",!1,"No `HydrateFallback` element provided to render during initial hydration"),f=!0,m=null):s===u&&(f=!0,m=r.route.hydrateFallbackElement||null)));let h=t.concat(o.slice(0,u+1)),g=()=>{let t;return t=d?p:f?m:r.route.Component?a.createElement(r.route.Component,null):r.route.element?r.route.element:e,a.createElement(Le,{match:r,routeContext:{outlet:e,matches:h,isDataRoute:null!=n},children:t})};return n&&(r.route.ErrorBoundary||r.route.errorElement||0===u)?a.createElement(_e,{location:n.location,revalidation:n.revalidation,component:p,error:d,children:g(),routeContext:{outlet:null,matches:h,isDataRoute:!0},onError:c}):g()},null)}function ze(e){return"".concat(e," must be used within a data router. See https://reactrouter.com/en/main/routers/picking-a-router.")}function Me(e){let t=a.useContext(ue);return x(t,ze(e)),t}function Fe(e){let t=a.useContext(de);return x(t,ze(e)),t}function Ie(e){let t=function(e){let t=a.useContext(be);return x(t,ze(e)),t}(e),n=t.matches[t.matches.length-1];return x(n.route.id,"".concat(e,' can only be used on routes that contain a unique "id"')),n.route.id}function Be(){var e;let t=a.useContext(we),n=Fe("useRouteError"),r=Ie("useRouteError");return void 0!==t?t:null===(e=n.errors)||void 0===e?void 0:e[r]}var Ue={};function He(e,t,n){t||Ue[e]||(Ue[e]=!0,k(!1,n))}var We={};function Ve(e,t){e||We[t]||(We[t]=!0,console.warn(t))}o.useOptimistic;a.memo(function(e){let{routes:t,future:n,state:r,onError:a}=e;return Re(t,void 0,r,a,n)});function $e(e){x(!1,"A is only ever to be used as the child of element, never rendered directly. Please wrap your in a .")}function qe(e){let{basename:t="/",children:n=null,location:r,navigationType:o="POP",navigator:l,static:i=!1,unstable_useTransitions:s}=e;x(!ke(),"You cannot render a inside another . You should never have more than one in your app.");let c=t.replace(/^\/*/,"/"),u=a.useMemo(()=>({basename:c,navigator:l,static:i,unstable_useTransitions:s,future:{}}),[c,l,i,s]);"string"===typeof r&&(r=C(r));let{pathname:d="/",search:f="",hash:p="",state:m=null,key:h="default"}=r,g=a.useMemo(()=>{let e=$(d,c);return null==e?null:{location:{pathname:e,search:f,hash:p,state:m,key:h},navigationType:o}},[c,d,f,p,m,h,o]);return k(null!=g,' is not able to match the URL "').concat(d).concat(f).concat(p,"\" because it does not start with the basename, so the won't render anything.")),null==g?null:a.createElement(ve.Provider,{value:u},a.createElement(ye.Provider,{children:n,value:g}))}function Ke(e){let{children:t,location:n}=e;return Re(Ye(t),n)}a.Component;function Ye(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:[],n=[];return a.Children.forEach(e,(e,r)=>{if(!a.isValidElement(e))return;let o=[...t,r];if(e.type===a.Fragment)return void n.push.apply(n,Ye(e.props.children,o));x(e.type===$e,"[".concat("string"===typeof e.type?e.type:e.type.name,"] is not a component. All component children of must be a or ")),x(!e.props.index||!e.props.children,"An index route cannot have child routes.");let l={id:e.props.id||o.join("-"),caseSensitive:e.props.caseSensitive,element:e.props.element,Component:e.props.Component,index:e.props.index,path:e.props.path,middleware:e.props.middleware,loader:e.props.loader,action:e.props.action,hydrateFallbackElement:e.props.hydrateFallbackElement,HydrateFallback:e.props.HydrateFallback,errorElement:e.props.errorElement,ErrorBoundary:e.props.ErrorBoundary,hasErrorBoundary:!0===e.props.hasErrorBoundary||null!=e.props.ErrorBoundary||null!=e.props.errorElement,shouldRevalidate:e.props.shouldRevalidate,handle:e.props.handle,lazy:e.props.lazy};e.props.children&&(l.children=Ye(e.props.children,o)),n.push(l)}),n}var Qe="get",Ge="application/x-www-form-urlencoded";function Xe(e){return"undefined"!==typeof HTMLElement&&e instanceof HTMLElement}var Je=null;var Ze=new Set(["application/x-www-form-urlencoded","multipart/form-data","text/plain"]);function et(e){return null==e||Ze.has(e)?e:(k(!1,'"'.concat(e,'" is not a valid `encType` for `
`/`` and will default to "').concat(Ge,'"')),null)}function tt(e,t){let n,r,a,o,l;if(Xe(i=e)&&"form"===i.tagName.toLowerCase()){let l=e.getAttribute("action");r=l?$(l,t):null,n=e.getAttribute("method")||Qe,a=et(e.getAttribute("enctype"))||Ge,o=new FormData(e)}else if(function(e){return Xe(e)&&"button"===e.tagName.toLowerCase()}(e)||function(e){return Xe(e)&&"input"===e.tagName.toLowerCase()}(e)&&("submit"===e.type||"image"===e.type)){let l=e.form;if(null==l)throw new Error('Cannot submit a + + + + + + + + + +
+
+ +

Built for the Thrill-Seeker

+

Everything you need for an unforgettable open-road experience — no hassle, just pure adrenaline.

+
+
+
🏎️
+

Polaris Slingshot SL

+

Our fleet features the latest Polaris Slingshot models — powerful, fast, and impossible to ignore on Texas roads.

+
+
+
🛡️
+

Full Coverage Insurance

+

Every rental includes comprehensive coverage. Drive with confidence knowing you're fully protected.

+
+
+
🗺️
+

Scenic Route Guides

+

We hand you a curated Texas route map — the best backroads, vistas, and stops around Parker County.

+
+
+
⛑️
+

Safety First

+

DOT-approved helmets included with every rental. Safety orientation for all drivers before you hit the road.

+
+
+
📞
+

Roadside Assistance

+

24/7 roadside support so you're never stranded. We've got your back from pickup to return.

+
+
+
+

Easy Booking

+

Simple online reservation, flexible pickup times, and transparent pricing. No hidden fees — ever.

+
+
+
+
+ + +
+
+ +

Pick Your Adventure

+

Transparent pricing. No surprise fees. Just you, the open road, and a machine that turns heads.

+
+
+

Half Day

+

$99

+

4 hours of freedom

+
    +
  • Polaris Slingshot SL
  • +
  • DOT helmets included
  • +
  • Safety orientation
  • +
  • Route map & guide
  • +
  • Proof of insurance required
  • +
  • Roadside assistance
  • +
+ Book Half Day +
+ +
+

Weekend

+

$299

+

48-hour getaway

+
    +
  • Polaris Slingshot SL
  • +
  • DOT helmets included
  • +
  • Safety orientation
  • +
  • Route map & guide
  • +
  • Proof of insurance required
  • +
  • 24/7 roadside assistance
  • +
+ Book Weekend +
+
+

Must be 25+ with valid driver's license. Security deposit required. Prices include tax.

+
+
+ + +
+
+ +

Ready in 5 Simple Steps

+

From booking to keys in hand — we make it effortless so you can focus on the fun.

+
+
+
1
+

Choose Your Package

+

Half day, full day, or weekend — pick the adventure that fits your schedule and budget.

+
+
+
2
+

Book & Submit

+

Fill out our simple booking form online or give us a call. We'll review your request right away.

+
+
+
3
+

Get Approved

+

We'll confirm availability and reach out within a few hours. Once approved, you're officially on the calendar.

+
+
+
4
+

Sign Your Waiver

+

You'll receive a link to our digital rental agreement. Sign it online in minutes — no printer needed.

+
+
+
5
+

Hit the Road

+

Arrive at pickup with your license and proof of insurance. Complete your safety briefing, grab your helmets, and go.

+
+
+
+
+ + +
+
+ +

Texas Roads Worth Driving

+

We know the best roads. Every rental comes with a recommended route guide — here's a taste.

+
+
+

The Parker County Loop

+

~45 miles • 1.5 hrs

+

Roll through Weatherford's historic downtown, Millsap, and back through the rolling Texas plains. The perfect intro ride.

+
+
+

Possum Kingdom Run

+

~80 miles • 2.5 hrs

+

Head northwest toward Mineral Wells and Possum Kingdom Lake. Hill Country scenery, lake views, and open highway.

+
+
+

Granbury & Glen Rose

+

~70 miles • 2 hrs

+

Cruise south to the charming Granbury square and dinosaur country in Glen Rose. Great for couples and history lovers.

+
+
+

DFW Sunset Cruise

+

~60 miles • 2 hrs

+

Head east toward the Fort Worth skyline at golden hour, loop through Azle and back. City lights in an open cockpit.

+
+
+
+
+ + +
+
+ +

Questions & Answers

+

Everything you need to know before you book.

+
+
+ Do I need a motorcycle license to rent a Polaris Slingshot in Texas? +

In Texas, a standard Class C driver's license is all you need. No motorcycle endorsement required. You must be 25 or older with a clean driving record to rent.

+
+
+ What's included in every rental? +

Every rental includes the Polaris Slingshot, DOT-approved helmets for driver and passenger, a safety orientation, a suggested scenic route map, and roadside assistance. Renters must provide proof of valid personal auto insurance. We carry a comprehensive fleet policy covering the vehicle.

+
+
+ How many people can ride in a Polaris Slingshot? +

The Polaris Slingshot is a two-seater — one driver and one passenger. Both experience the open-air thrill side by side in sports-car-style seats.

+
+
+ Is there a security deposit? +

Yes, a refundable security deposit is required at the time of pickup. The deposit is returned in full upon safe return of the vehicle with no damage.

+
+
+ Can I drive outside of Parker County? +

Yes — you can drive throughout the DFW area including Weatherford, Mineral Wells, Granbury, Azle, and Fort Worth. We'll note any restrictions in your rental agreement.

+
+
+ What if it rains? +

The Polaris Slingshot can be driven in light rain, but we recommend rescheduling in severe weather for your safety and comfort. We offer flexible rescheduling with 24-hour notice.

+
+
+ How do I cancel or reschedule? +

Cancel or reschedule free of charge up to 24 hours before your rental start time. Cancellations within 24 hours are subject to a 50% fee.

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

Ready to Ride?

+

Send us your preferred date and package and we'll confirm availability within a few hours. Can't wait? Give us a call.

+
+ 📍 + Weatherford, TX 76086
(Exact pickup address provided at booking)
+
+
+ 📞 + (817) 266-2022 +
+ +
+ 🕐 + Mon–Fri: 9am–6pm • Sat–Sun: 8am–8pm +
+
+
+ +
+
+ +

Loading…

+ +
+
+
Checking availability…
+
+
+
Available
+
Selected
+ +
Unavailable
+
+

Click a date to select it — click again to deselect. Weekend package shows both days automatically.

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

Deposit — $45 today

+ +
+

A $45 hold is placed on your card to secure your date — not charged until confirmed. Remaining balance is due at pickup.

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

© 2026 Parker County Slingshot Rentals — Weatherford, Texas. All rights reserved.

+

Polaris Slingshot® is a registered trademark of Polaris Inc. We are an independent rental operator.

+
+ + + + + diff --git a/sites/parkerslingshotrentals.com/public_html/robots.txt b/sites/parkerslingshotrentals.com/public_html/robots.txt new file mode 100644 index 0000000..e4f0436 --- /dev/null +++ b/sites/parkerslingshotrentals.com/public_html/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://parkerslingshotrentals.com/sitemap.xml diff --git a/sites/parkerslingshotrentals.com/public_html/sitemap.xml b/sites/parkerslingshotrentals.com/public_html/sitemap.xml new file mode 100644 index 0000000..c86c01b --- /dev/null +++ b/sites/parkerslingshotrentals.com/public_html/sitemap.xml @@ -0,0 +1,12 @@ + + + + https://parkerslingshotrentals.com/ + 2026-05-19 + weekly + 1.0 + + diff --git a/sites/parkerslingshotrentals.com/public_html/upload-docs.php b/sites/parkerslingshotrentals.com/public_html/upload-docs.php new file mode 100644 index 0000000..a275cb6 --- /dev/null +++ b/sites/parkerslingshotrentals.com/public_html/upload-docs.php @@ -0,0 +1,187 @@ +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 = "
+
+

{$typeLabel} Uploaded — {$booking['booking_ref']}

+
+
+

" . htmlspecialchars($booking['name']) . " uploaded their {$typeLabel} for booking {$booking['booking_ref']} (rental: {$dateLabel}).

+

View it in the admin panel under their booking detail.

+ +
+
"; + 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'])) : ''; +?> + + + + + Upload Document — Parker County Slingshot Rentals + + + + + + +
+ Parker County Slingshot Rentals + Document Upload +
+
+ + +
+

Upload Document

+

Invalid or missing upload link. Please use the link from your email or contact us.

+
+ + +
+
+

Need help? Call or text (817) 266-2022.

+
+ + +
+
+
+

Upload Received!

+

Thanks, ! Your has been submitted for booking .

+

We'll review it and still do a quick visual check at pickup. See you on !

+
+
+ + +
+
+

Upload

+
+
+
+
+

+ + Please upload a photo or scan of your current auto insurance card. JPG, PNG, or PDF accepted (max 10 MB). + + Please upload a photo or scan of the front of your driver's license. JPG, PNG, or PDF accepted (max 10 MB). + +

+

We'll still do a visual check at pickup — this is just for our records.

+
+
+ +
📎
+

Tap or drag your file here

+

JPG • PNG • PDF • max 10 MB

+
+
+ +
+

Your document is stored securely and only visible to Parker County Slingshot Rentals staff.

+
+ + +
+ + + diff --git a/sites/parkerslingshotrentals.com/public_html/uploads/.htaccess b/sites/parkerslingshotrentals.com/public_html/uploads/.htaccess new file mode 100644 index 0000000..23a9814 --- /dev/null +++ b/sites/parkerslingshotrentals.com/public_html/uploads/.htaccess @@ -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] diff --git a/sites/parkerslingshotrentals.com/public_html/view-doc.php b/sites/parkerslingshotrentals.com/public_html/view-doc.php new file mode 100644 index 0000000..1f7cfd1 --- /dev/null +++ b/sites/parkerslingshotrentals.com/public_html/view-doc.php @@ -0,0 +1,50 @@ +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; diff --git a/sites/parkerslingshotrentals.com/public_html/waiver.php b/sites/parkerslingshotrentals.com/public_html/waiver.php new file mode 100644 index 0000000..35126d7 --- /dev/null +++ b/sites/parkerslingshotrentals.com/public_html/waiver.php @@ -0,0 +1,365 @@ +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 = "
+
+

Waiver Signed — {$ref}

+
+
+

" . htmlspecialchars($booking['name']) . " signed the rental waiver for booking {$ref}.

+ + + + + + +
Package" . htmlspecialchars($pkg['label']) . "
Date{$dateLabel}
Signed by" . htmlspecialchars($sigName) . "
IP" . htmlspecialchars($ip) . "
Timestamp" . date('F j, Y g:i A') . " CT
+ Signature +
+
"; + + $custHtml = "
+
+

Parker County Slingshot Rentals

+
+
+

Waiver Signed — You're All Set!

+

Hey " . htmlspecialchars($booking['name']) . ", your rental agreement for booking {$ref} is signed and on file. See you on {$dateLabel}!

+

Remember to bring:

+
    +
  • Valid driver's license
  • +
  • Proof of personal auto insurance
  • +
+

Questions? Call or text " . ADMIN_PHONE . ".

+

Ride on,
The Parker County Slingshot Team

+
+
+

© " . date('Y') . " Parker County Slingshot Rentals — Weatherford, TX

+
+
"; + + 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'])) : ''; +?> + + + + + Rental Agreement — Parker County Slingshot Rentals + + + + + + + +
+ Parker County Slingshot Rentals + Rental Agreement +
+ +
+ + + +
+

Rental Agreement

+

Enter your booking reference from your confirmation email to access your rental agreement.

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

You're All Set!

+

Your rental agreement for booking is signed and on file. We'll see you on !

+

A confirmation was sent to .

+

Remember to bring:

+
    +
  • Valid driver's license
  • +
  • Proof of personal auto insurance
  • +
+
+ +
+ + + +
+ +
+

Rental Agreement

+

Please read and sign the agreement below for your upcoming rental.

+ +
+
Booking Ref
+
Name
+
Package
+
Rental Date
+
+ +

Rental Terms & Conditions

+

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.

+ +

Eligibility Requirements

+

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.

+ +

Insurance Requirement

+

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.

+ +

Vehicle Use & Rules

+

Renter agrees to operate the Polaris Slingshot in a safe, lawful manner and specifically agrees to:

+
    +
  • Obey all applicable traffic laws and speed limits
  • +
  • Never operate the vehicle under the influence of alcohol, drugs, or any impairing substance
  • +
  • Never allow an unauthorized third party to operate the vehicle
  • +
  • Wear the provided DOT-approved helmet at all times while operating the vehicle
  • +
  • Not take the vehicle off paved roads or outside the approved driving area
  • +
  • Return the vehicle at the agreed-upon time and location in the same condition it was received
  • +
+ +

Damage & Security Deposit

+

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.

+ +

Assumption of Risk & Release of Liability

+

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.

+ +

Cancellation Policy

+

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.

+
+ +
+ +
+

Acknowledgments

+

Check each box to confirm you have read and agree to that section.

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

Your Signature

+ + + + +

Draw Your Signature

+
+ + +
+

Use your mouse or finger to sign in the box above.

+ +

+ By clicking "Sign & 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: . +

+ + +
+
+ + + +
+ + + + diff --git a/sites/tomsjavajive.com/public_html/.gitignore b/sites/tomsjavajive.com/public_html/.gitignore new file mode 100644 index 0000000..707bf70 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/.gitignore @@ -0,0 +1,7 @@ +*.log +.DS_Store +*.swp + +config/database.php +uploads/ +vendor/ diff --git a/sites/tomsjavajive.com/public_html/.htaccess b/sites/tomsjavajive.com/public_html/.htaccess new file mode 100644 index 0000000..df1797e --- /dev/null +++ b/sites/tomsjavajive.com/public_html/.htaccess @@ -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$ + + + + Require all denied + + + Order allow,deny + Deny from all + + + +# 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) + + AddOutputFilterByType DEFLATE text/html text/plain text/css text/javascript application/javascript application/json + + +# Browser caching (optional) + + 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" + + +# Security headers + + Header set X-Content-Type-Options "nosniff" + Header set X-Frame-Options "SAMEORIGIN" + Header set X-XSS-Protection "1; mode=block" + + +# 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] + + 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" + diff --git a/sites/tomsjavajive.com/public_html/README.md b/sites/tomsjavajive.com/public_html/README.md new file mode 100644 index 0000000..4829f46 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/README.md @@ -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 diff --git a/sites/tomsjavajive.com/public_html/account/addresses.php b/sites/tomsjavajive.com/public_html/account/addresses.php new file mode 100644 index 0000000..e0aeda3 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/account/addresses.php @@ -0,0 +1,290 @@ + $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 = ''; +require_once __DIR__ . '/../includes/header.php'; +require_once __DIR__ . '/includes/sidebar.php'; +?> + + + + +
+ +
+ + + +
+ +
+ + + +
+
+ +

No addresses saved

+

Add an address for faster checkout.

+ +
+
+ +
+ $addr): ?> +
+
+ + Default + + +

+ +

+ +

+ +

+ , +

+

+ + +

+ +

+ + +
+ + + +
+ + + +
+ + +
+ + + +
+
+
+
+ +
+ + + + + + + + diff --git a/sites/tomsjavajive.com/public_html/account/includes/footer.php b/sites/tomsjavajive.com/public_html/account/includes/footer.php new file mode 100644 index 0000000..ea85462 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/account/includes/footer.php @@ -0,0 +1,6 @@ + + + + + + diff --git a/sites/tomsjavajive.com/public_html/account/includes/sidebar.php b/sites/tomsjavajive.com/public_html/account/includes/sidebar.php new file mode 100644 index 0000000..58cc711 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/account/includes/sidebar.php @@ -0,0 +1,36 @@ + + +
+
+ +
+ + + + + diff --git a/sites/tomsjavajive.com/public_html/admin/includes/header.php b/sites/tomsjavajive.com/public_html/admin/includes/header.php new file mode 100644 index 0000000..2cf6db7 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/admin/includes/header.php @@ -0,0 +1,182 @@ + + + + + + + <?= $pageTitle ?? 'Admin' ?> - Tom's Java Jive Admin + + + + + + + + + +
+ + + + +
+
+ + + + +
+
+ + + + +
+
+
+ +
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
diff --git a/sites/tomsjavajive.com/public_html/admin/index.php b/sites/tomsjavajive.com/public_html/admin/index.php new file mode 100644 index 0000000..0dcbaa6 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/admin/index.php @@ -0,0 +1,197 @@ +count('orders'); +$todayOrders = db()->count('orders', 'DATE(created_at) = CURDATE()'); +$totalRevenue = db()->fetch("SELECT COALESCE(SUM(total), 0) as total FROM orders WHERE payment_status = 'paid'")['total'] ?? 0; +$todayRevenue = db()->fetch("SELECT COALESCE(SUM(total), 0) as total FROM orders WHERE payment_status = 'paid' AND DATE(created_at) = CURDATE()")['total'] ?? 0; +$totalCustomers = db()->count('customers'); +$totalProducts = db()->count('products', 'is_active = 1'); +$lowStockProducts = db()->count('products', 'stock <= low_stock_threshold AND is_active = 1'); +$pendingOrders = db()->count('orders', "order_status = 'pending'"); + +// Recent orders +$recentOrders = db()->fetchAll( + "SELECT * FROM orders ORDER BY created_at DESC LIMIT 10" +); + +// Low stock products +$lowStockItems = db()->fetchAll( + "SELECT * FROM products WHERE stock <= low_stock_threshold AND is_active = 1 ORDER BY stock ASC LIMIT 5" +); +?> + + + + +
+
+
+ +
+
+
Today's Revenue
+
+ +
+
+ +
+
+
Today's Orders
+
+ +
+
+ +
+
+
Total Customers
+
+ +
+
+ +
+
+
Pending Orders
+
+
+ +
+ +
+
+

Recent Orders

+ View All +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
OrderCustomerTotalStatusDate
+ No orders yet +
+ + + + + 'warning', + 'confirmed', 'processing' => 'primary', + 'shipped', 'delivered' => 'success', + 'cancelled', 'refunded' => 'error', + default => 'primary' + }; + ?> + + + +
+
+
+ + +
+ +
+
+

Overview

+
+
+
+ Total Revenue + +
+
+ Total Orders + +
+
+ Active Products + +
+
+ Low Stock Items + +
+
+
+ + + +
+
+

+ Low Stock +

+
+
+ +
+ + left +
+ + + Manage Inventory + +
+
+ + + + +
+
+ + diff --git a/sites/tomsjavajive.com/public_html/admin/integrations.php b/sites/tomsjavajive.com/public_html/admin/integrations.php new file mode 100644 index 0000000..1d7ade8 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/admin/integrations.php @@ -0,0 +1,348 @@ + getSetting('cybermail_api_key', ''), + 'from' => getSetting('cybermail_from_email', 'noreply@tomsjavajive.com'), + 'from_name' => getSetting('cybermail_from_name', "Tom's Java Jive"), + 'enabled' => getSetting('email_notifications_enabled', '0'), +]; +$tw = [ + 'sid' => getSetting('twilio_account_sid', ''), + 'token' => getSetting('twilio_auth_token', ''), + 'phone' => getSetting('twilio_phone_number', ''), + 'enabled' => getSetting('sms_notifications_enabled', '0'), +]; +$push = [ + 'pub' => getSetting('vapid_public_key', ''), + 'priv' => getSetting('vapid_private_key', ''), + 'enabled' => getSetting('push_notifications_enabled', '0'), +]; +$loyaltyEnabled = getSetting('loyalty_enabled', '1') === '1'; +?> + + + + + + +
+ + + +
+
+
+ +
+

Email — CyberMail

+

Transactional email — order confirmations, shipping updates, password resets

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

Manage at CyberMail Dashboard

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

Twilio SMS

+

SMS notifications for orders, shipping, and promotions

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

Get credentials at Twilio Console

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

Push Notifications

+

Web push for order updates and promotions

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

Generate at Web Push Codelab

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

Loyalty Program

+

Reward customers with points and tiers

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

Tier Structure

+ + + + + + + + +
TierMin PointsMultiplierKey Benefits
Bronze Bean01x1 point/$1, Birthday reward
Silver Roast5001.25xFree shipping $25+, Double points weekends
Gold Blend1,5001.5xFree shipping all orders, Priority support
Platinum Reserve5,0002xExpress shipping, VIP events, Account manager
+

100 points = $1 credit • Points earned on every purchase

+
+ +
+
+
+ + + + + + + diff --git a/sites/tomsjavajive.com/public_html/admin/inventory.php b/sites/tomsjavajive.com/public_html/admin/inventory.php new file mode 100644 index 0000000..eef142d --- /dev/null +++ b/sites/tomsjavajive.com/public_html/admin/inventory.php @@ -0,0 +1,337 @@ +fetch("SELECT name, stock FROM products WHERE product_id = :id", ['id' => $_POST['product_id']]); + $newStock = max(0, ($product['stock'] ?? 0) + $adjustment); + + db()->update('products', ['stock' => $newStock], 'product_id = :id', ['id' => $_POST['product_id']]); + setFlash('success', $product['name'] . ' stock adjusted by ' . ($adjustment > 0 ? '+' : '') . $adjustment . '. New stock: ' . $newStock); + } + header('Location: /admin/inventory.php'); + exit; + } + + if ($action === 'update_threshold' && !empty($_POST['product_id'])) { + $threshold = intval($_POST['low_stock_threshold'] ?? 10); + db()->update('products', ['low_stock_threshold' => $threshold], 'product_id = :id', ['id' => $_POST['product_id']]); + setFlash('success', 'Low stock threshold updated'); + header('Location: /admin/inventory.php'); + exit; + } + + if ($action === 'bulk_adjust') { + $adjustments = $_POST['adjustments'] ?? []; + $count = 0; + foreach ($adjustments as $productId => $adj) { + $adj = intval($adj); + if ($adj != 0) { + db()->query( + "UPDATE products SET stock = GREATEST(0, stock + :adj) WHERE product_id = :id", + ['adj' => $adj, 'id' => $productId] + ); + $count++; + } + } + if ($count > 0) { + setFlash('success', "Adjusted stock for $count products"); + } + header('Location: /admin/inventory.php'); + exit; + } +} + +// Filters +$filter = $_GET['filter'] ?? ''; +$search = $_GET['search'] ?? ''; +$category = $_GET['category'] ?? ''; + +$where = ['1=1']; +$params = []; + +if ($search) { + $where[] = '(name LIKE :search OR sku LIKE :search OR barcode LIKE :search)'; + $params['search'] = '%' . $search . '%'; +} + +if ($category) { + $where[] = 'category = :category'; + $params['category'] = $category; +} + +if ($filter === 'low') { + $where[] = 'stock <= low_stock_threshold AND stock > 0'; +} elseif ($filter === 'out') { + $where[] = 'stock <= 0'; +} elseif ($filter === 'in') { + $where[] = 'stock > low_stock_threshold'; +} + +$whereClause = implode(' AND ', $where); + +$products = db()->fetchAll( + "SELECT product_id, name, sku, barcode, category, stock, low_stock_threshold, price, is_active + FROM products + WHERE {$whereClause} + ORDER BY stock ASC, name ASC", + $params +); + +// Get categories for filter +$categories = db()->fetchAll( + "SELECT DISTINCT category FROM products WHERE category IS NOT NULL AND category != '' ORDER BY category" +); + +// Stats +$totalProducts = db()->count('products', 'is_active = 1'); +$lowStockCount = db()->count('products', 'stock <= low_stock_threshold AND stock > 0 AND is_active = 1'); +$outOfStockCount = db()->count('products', 'stock <= 0 AND is_active = 1'); +$totalStock = db()->fetch("SELECT SUM(stock) as total FROM products WHERE is_active = 1")['total'] ?? 0; +$inventoryValue = db()->fetch("SELECT SUM(stock * price) as total FROM products WHERE is_active = 1")['total'] ?? 0; +?> + + + + +
+ + + +
+
+
+
+
+
Total Units
+
+
+
+
+
+
+
Inventory Value
+
+
+
+
+
+
+
Low Stock
+
+
+
+
+
+
+
Out of Stock
+
+
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+ + + Clear + +
+
+
+ + + + + +
+
+ products +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ProductSKU / BarcodeCategoryStockThresholdStatusActions
No products found
+ + + Inactive + + + + + + +
+ + + - + +
+ + + + + + + + + Out of Stock + + Low Stock + + In Stock + + + + + + +
+
+
+ + + + + + + diff --git a/sites/tomsjavajive.com/public_html/admin/login.php b/sites/tomsjavajive.com/public_html/admin/login.php new file mode 100644 index 0000000..3ade01b --- /dev/null +++ b/sites/tomsjavajive.com/public_html/admin/login.php @@ -0,0 +1,68 @@ + + + + + + +Admin Login — Tom's Java Jive + + + + +
+ + +
+ +
+ + + + + +
+ ← Back to Store +
+ + diff --git a/sites/tomsjavajive.com/public_html/admin/logout.php b/sites/tomsjavajive.com/public_html/admin/logout.php new file mode 100644 index 0000000..db5e9b3 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/admin/logout.php @@ -0,0 +1,10 @@ +fetch("SELECT * FROM orders WHERE order_id = :id", ['id' => $orderId]); + +if (!$order) { + setFlash('error', 'Order not found'); + header('Location: /admin/orders.php'); + exit; +} + +// Handle status update +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $action = $_POST['action'] ?? ''; + + if ($action === 'update_status') { + $status = $_POST['status'] ?? ''; + $trackingNumber = $_POST['tracking_number'] ?? ''; + + $updateData = ['order_status' => $status]; + if ($trackingNumber) { + $updateData['tracking_number'] = $trackingNumber; + } + + db()->update('orders', $updateData, 'order_id = :id', ['id' => $orderId]); + setFlash('success', 'Order status updated'); + header('Location: /admin/order.php?id=' . $orderId); + exit; + } + + if ($action === 'add_note') { + $note = trim($_POST['note'] ?? ''); + if ($note) { + $existingNotes = $order['notes'] ?? ''; + $newNote = '[' . date('M j, Y g:i A') . '] ' . $note; + $allNotes = $existingNotes ? $existingNotes . "\n" . $newNote : $newNote; + + db()->update('orders', ['notes' => $allNotes], 'order_id = :id', ['id' => $orderId]); + setFlash('success', 'Note added'); + header('Location: /admin/order.php?id=' . $orderId); + exit; + } + } + + if ($action === 'process_refund') { + $refundAmount = (float) ($_POST['refund_amount'] ?? 0); + $reason = trim($_POST['refund_reason'] ?? ''); + + if ($order['payment_method'] !== 'square' || empty($order['square_payment_id'])) { + setFlash('error', 'This order has no Square payment to refund.'); + header('Location: /admin/order.php?id=' . $orderId); + exit; + } + if ($order['payment_status'] !== 'paid' && $order['payment_status'] !== 'partially_refunded') { + setFlash('error', 'Only paid orders can be refunded.'); + header('Location: /admin/order.php?id=' . $orderId); + exit; + } + if ($refundAmount <= 0 || $refundAmount > (float) $order['total']) { + setFlash('error', 'Invalid refund amount.'); + header('Location: /admin/order.php?id=' . $orderId); + exit; + } + + try { + $result = squareRefundPayment($order['square_payment_id'], $refundAmount, $reason ?: ('Order #' . $order['order_number'])); + $refund = $result['refund'] ?? []; + $refundStatus = $refund['status'] ?? ''; + + if (in_array($refundStatus, ['PENDING', 'COMPLETED'], true)) { + $isFullRefund = $refundAmount >= (float) $order['total']; + $newPaymentStatus = $isFullRefund ? 'refunded' : 'partially_refunded'; + + db()->update('orders', + ['payment_status' => $newPaymentStatus, 'order_status' => $isFullRefund ? 'refunded' : $order['order_status']], + 'order_id = :id', + ['id' => $orderId] + ); + + // Restore any wallet credit that was applied, proportionally on a full refund + if ($isFullRefund && !empty($order['wallet_amount_used']) && (float) $order['wallet_amount_used'] > 0 && !empty($order['customer_id'])) { + $walletAmount = (float) $order['wallet_amount_used']; + $cust = db()->fetch("SELECT wallet_balance FROM customers WHERE customer_id = :id", ['id' => $order['customer_id']]); + if ($cust) { + $newBalance = (float) $cust['wallet_balance'] + $walletAmount; + db()->update('customers', ['wallet_balance' => $newBalance], 'customer_id = :id', ['id' => $order['customer_id']]); + db()->insert('wallet_transactions', [ + 'transaction_id' => generateId('wt_'), + 'customer_id' => $order['customer_id'], + 'amount' => $walletAmount, + 'balance_after' => $newBalance, + 'type' => 'refund', + 'description' => 'Refund for Order #' . $order['order_number'], + ]); + } + } + + $existingNotes = $order['notes'] ?? ''; + $newNote = '[' . date('M j, Y g:i A') . '] Refunded ' . formatCurrency($refundAmount) + . ' via Square (refund ID: ' . ($refund['id'] ?? 'unknown') . ')' + . ($reason ? ' — ' . $reason : ''); + $allNotes = $existingNotes ? $existingNotes . "\n" . $newNote : $newNote; + db()->update('orders', ['notes' => $allNotes], 'order_id = :id', ['id' => $orderId]); + + setFlash('success', formatCurrency($refundAmount) . ' refunded successfully.'); + } else { + setFlash('error', 'Refund did not complete (status: ' . $refundStatus . ').'); + } + } catch (Exception $e) { + setFlash('error', 'Refund failed: ' . $e->getMessage()); + } + + header('Location: /admin/order.php?id=' . $orderId); + exit; + } +} + +$items = json_decode($order['items'], true) ?? []; +$shippingAddress = json_decode($order['shipping_address'], true) ?? []; + +$statuses = ['pending', 'confirmed', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded']; +$canRefund = $order['payment_method'] === 'square' + && !empty($order['square_payment_id']) + && in_array($order['payment_status'], ['paid', 'partially_refunded'], true); +?> + + + + +
+ + +
+ + +
+ +
+ +
+
+

Order Items

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + 0): ?> + + + + + + 0): ?> + + + + + + 0): ?> + + + + + + 0): ?> + + + + + + + + + + +
ProductPriceQtyTotal
+ + + + + + + +
Subtotal
Shipping
Tax
Discount-
Wallet / Gift Card-
Total
+
+
+ + +
+
+

Order Notes

+
+
+ +
+ +

No notes yet.

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

Process Refund

+
+
+
+ +
+ + + Up to (full order total). Enter a lower amount for a partial refund. +
+
+ + +
+ +
+
+
+ +
+ + +
+ +
+
+

Order Status

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

Customer

+
+
+

+

+ +

+ + + + + View Customer + + +
+
+ + + +
+
+

Shipping Address

+
+
+

+
+ , + + +

+
+
+ + + +
+
+

Payment

+
+
+
+ Method + +
+
+ Status + 'success', + 'failed' => 'error', + 'refunded', 'partially_refunded' => 'warning', + default => 'primary' + }; + ?> + +
+ +
+ Square Payment ID + +
+ +
+ Stripe ID + ... +
+ +
+
+ + +
+
+

Timeline

+
+
+
+ Created + +
+ +
+ Updated + +
+ +
+
+
+
+ + diff --git a/sites/tomsjavajive.com/public_html/admin/orders.php b/sites/tomsjavajive.com/public_html/admin/orders.php new file mode 100644 index 0000000..d207205 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/admin/orders.php @@ -0,0 +1,281 @@ + $status]; + if ($trackingNumber) { + $updateData['tracking_number'] = $trackingNumber; + } + + db()->update('orders', $updateData, 'order_id = :id', ['id' => $orderId]); + setFlash('success', 'Order status updated'); + header('Location: /admin/orders.php'); + exit; + } +} + +// Filters +$status = $_GET['status'] ?? ''; +$search = $_GET['search'] ?? ''; +$dateFrom = $_GET['date_from'] ?? ''; +$dateTo = $_GET['date_to'] ?? ''; +$page = max(1, intval($_GET['page'] ?? 1)); + +// Build query +$where = ['1=1']; +$params = []; + +if ($status) { + $where[] = 'order_status = :status'; + $params['status'] = $status; +} + +if ($search) { + $where[] = '(order_number LIKE :search1 OR customer_name LIKE :search2 OR customer_email LIKE :search3)'; + $params['search1'] = '%' . $search . '%'; + $params['search2'] = '%' . $search . '%'; + $params['search3'] = '%' . $search . '%'; +} + +if ($dateFrom) { + $where[] = 'DATE(created_at) >= :date_from'; + $params['date_from'] = $dateFrom; +} + +if ($dateTo) { + $where[] = 'DATE(created_at) <= :date_to'; + $params['date_to'] = $dateTo; +} + +$whereClause = implode(' AND ', $where); + +// Get total and paginate +$totalOrders = db()->count('orders', $whereClause, $params); +$pagination = paginate($totalOrders, $page, ADMIN_ITEMS_PER_PAGE); + +// Get orders +$orders = db()->fetchAll( + "SELECT * FROM orders WHERE {$whereClause} ORDER BY created_at DESC LIMIT :limit OFFSET :offset", + array_merge($params, ['limit' => $pagination['per_page'], 'offset' => $pagination['offset']]) +); + +$statuses = ['pending', 'confirmed', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded']; +?> + + + + +
+ + +
+ + + +
+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + + + + Clear + +
+
+
+ + +
+
+ orders found +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OrderCustomerItemsTotalPaymentStatusDateActions
+ No orders found +
+ + + POS + + +
+ +
item + 'success', + 'failed' => 'error', + 'refunded' => 'warning', + default => 'primary' + }; + ?> + + + + + 'warning', + 'confirmed', 'processing' => 'primary', + 'shipped', 'delivered' => 'success', + 'cancelled', 'refunded' => 'error', + default => 'primary' + }; + ?> + + + + + + + + +
+
+
+ + + 1): ?> +
+ +
+ + + + + + + + diff --git a/sites/tomsjavajive.com/public_html/admin/payments.php b/sites/tomsjavajive.com/public_html/admin/payments.php new file mode 100644 index 0000000..81d77e1 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/admin/payments.php @@ -0,0 +1,246 @@ + isset($_POST['stripe_enabled']), + 'test_mode' => isset($_POST['stripe_test_mode']), + 'publishable_key' => trim($_POST['stripe_publishable_key'] ?? ''), + 'secret_key' => trim($_POST['stripe_secret_key'] ?? ''), + 'webhook_secret' => trim($_POST['stripe_webhook_secret'] ?? '') + ]); + setFlash('success', 'Stripe settings updated'); + } + + if ($section === 'square') { + setSetting('payment_square', [ + 'enabled' => isset($_POST['square_enabled']), + 'sandbox' => isset($_POST['square_sandbox']), + 'app_id' => trim($_POST['square_app_id'] ?? ''), + 'location_id' => trim($_POST['square_location_id'] ?? ''), + 'access_token' => trim($_POST['square_access_token'] ?? ''), + 'webhook_signature_key' => trim($_POST['square_webhook_signature_key'] ?? '') + ]); + setFlash('success', 'Square settings updated'); + } + + if ($section === 'methods') { + setSetting('payment_methods', [ + 'card' => isset($_POST['method_card']), + 'cash' => isset($_POST['method_cash']), + 'wallet' => isset($_POST['method_wallet']), + 'gift_card' => isset($_POST['method_gift_card']) + ]); + setFlash('success', 'Payment methods updated'); + } + + header('Location: /admin/payments.php'); + exit; +} + +$stripe = getSetting('payment_stripe', [ + 'enabled' => true, + 'test_mode' => true, + 'publishable_key' => '', + 'secret_key' => '', + 'webhook_secret' => '' +]); + +$square = getSetting('payment_square', [ + 'enabled' => defined('PAYMENT_PROCESSOR') && PAYMENT_PROCESSOR === 'square', + 'sandbox' => defined('SQUARE_ENV') && SQUARE_ENV === 'sandbox', + 'app_id' => defined('SQUARE_APP_ID') ? SQUARE_APP_ID : '', + 'location_id' => defined('SQUARE_LOCATION_ID') ? SQUARE_LOCATION_ID : '', + 'access_token' => defined('SQUARE_ACCESS_TOKEN') ? SQUARE_ACCESS_TOKEN : '', + 'webhook_signature_key' => defined('SQUARE_WEBHOOK_SIGNATURE_KEY') ? SQUARE_WEBHOOK_SIGNATURE_KEY : '' +]); + +$methods = getSetting('payment_methods', [ + 'card' => true, + 'cash' => true, + 'wallet' => true, + 'gift_card' => true +]); +?> + + + + +
+ + +
+ + +
+ +
+ +
+
+

Square

+
+
+
+ +
+ +
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + From the Square Developer Dashboard's webhook subscription for this site +
+ + +
+
+
+ + +
+ +
+
+

Stripe (legacy)

+
+
+
+ +
+ +
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + + Get this from your Stripe webhook settings +
+ + +
+
+
+ + +
+ +
+
+

POS Payment Methods

+
+
+

Select which payment methods are available in the Point of Sale system.

+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ + +
+
+
+
+
+ + diff --git a/sites/tomsjavajive.com/public_html/admin/pos.php b/sites/tomsjavajive.com/public_html/admin/pos.php new file mode 100644 index 0000000..44b018f --- /dev/null +++ b/sites/tomsjavajive.com/public_html/admin/pos.php @@ -0,0 +1,1386 @@ +fetchAll( + "SELECT product_id, name, price, sale_price, stock, images, category, barcode, sku + FROM products WHERE is_active = 1 ORDER BY category, name" +); + +// Group products by category +$productsByCategory = []; +foreach ($products as $product) { + $cat = $product['category'] ?? 'Other'; + if (!isset($productsByCategory[$cat])) { + $productsByCategory[$cat] = []; + } + $product['images'] = json_decode($product['images'] ?? '[]', true); + $product['display_price'] = $product['sale_price'] ?? $product['price']; + $productsByCategory[$cat][] = $product; +} + +// Get held orders +$heldOrders = $_SESSION['pos_held_orders'] ?? []; + +// Get tax rate from settings +$taxRate = getSetting('tax_rate', 0) / 100; +?> + + + +
+ +
+ + +
+ + + + +
+ +
+ +
+ +

No products available

+ Add Products +
+ + 0 && $product['stock'] <= 5; + ?> +
+ +
+
+
+ +
+
+ + +
+
+ + +
+
+

Current Sale

+
+ +
+
+ +
+
+ +

Add products to start a sale

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+ Subtotal + $0.00 +
+ +
+ Tax (%) + $0.00 +
+
+ Total + $0.00 +
+
+ +
+ +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + diff --git a/sites/tomsjavajive.com/public_html/admin/product-edit.php b/sites/tomsjavajive.com/public_html/admin/product-edit.php new file mode 100644 index 0000000..a907dda --- /dev/null +++ b/sites/tomsjavajive.com/public_html/admin/product-edit.php @@ -0,0 +1,393 @@ +fetchAll("SELECT category_id, name FROM categories WHERE is_active = 1 ORDER BY name ASC"); +$productTypesList = db()->fetchAll("SELECT type_id, name FROM product_types WHERE is_active = 1 ORDER BY sort_order ASC, name ASC"); + + +$productId = $_GET['id'] ?? ''; +$product = null; +$isEdit = false; +$errors = []; + +if ($productId) { + $product = db()->fetch("SELECT * FROM products WHERE product_id = :id", ['id' => $productId]); + if ($product) { + $isEdit = true; + $pageTitle = 'Edit Product'; + $product['images'] = json_decode($product['images'] ?? '[]', true); + $product['tags'] = json_decode($product['tags'] ?? '[]', true); + } +} + +if (!$product) { + $product = [ + 'product_id' => '', + 'name' => '', + 'description' => '', + 'price' => '', + 'sale_price' => '', + 'cost_price' => '', + 'sku' => '', + 'barcode' => '', + 'category' => '', + 'tags' => [], + 'images' => [], + 'stock' => 100, + 'low_stock_threshold' => 10, + 'weight' => '', + 'is_active' => 1, + 'is_featured' => 0 + ]; + $pageTitle = 'Add Product'; +} + +// Handle form submission +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $name = trim($_POST['name'] ?? ''); + $description = trim($_POST['description'] ?? ''); + $price = floatval($_POST['price'] ?? 0); + $salePrice = !empty($_POST['sale_price']) ? floatval($_POST['sale_price']) : null; + $costPrice = !empty($_POST['cost_price']) ? floatval($_POST['cost_price']) : null; + $sku = trim($_POST['sku'] ?? ''); + $barcode = trim($_POST['barcode'] ?? ''); + $category = trim($_POST['category'] ?? ''); + $stock = intval($_POST['stock'] ?? 0); + $lowStockThreshold = intval($_POST['low_stock_threshold'] ?? 10); + $weight = !empty($_POST['weight']) ? floatval($_POST['weight']) : null; + $isActive = isset($_POST['is_active']) ? 1 : 0; + $isFeatured = isset($_POST['is_featured']) ? 1 : 0; + $imageUrls = array_filter(array_map('trim', explode("\n", $_POST['image_urls'] ?? ''))); + + // Validate + if (empty($name)) $errors['name'] = 'Product name is required'; + if ($price <= 0) $errors['price'] = 'Price must be greater than 0'; + + if (empty($errors)) { + $data = [ + 'name' => $name, + 'description' => $description, + 'price' => $price, + 'sale_price' => $salePrice, + 'cost_price' => $costPrice, + 'sku' => $sku ?: null, + 'barcode' => $barcode ?: null, + 'category' => $category ?: null, + 'product_type_id' => trim($_POST['product_type_id'] ?? '') ?: null, + 'images' => json_encode($imageUrls), + 'stock' => $stock, + 'low_stock_threshold' => $lowStockThreshold, + 'weight' => $weight, + 'is_active' => $isActive, + 'is_featured' => $isFeatured + ]; + + if ($isEdit) { + db()->update('products', $data, 'product_id = :id', ['id' => $productId]); + setFlash('success', 'Product updated successfully'); + } else { + $data['product_id'] = generateId('prod_'); + db()->insert('products', $data); + setFlash('success', 'Product created successfully'); + } + + header('Location: /admin/products.php'); + exit; + } + + // Keep form values on error + $product = array_merge($product, $_POST); + $product['images'] = $imageUrls; +} + +// Get categories for dropdown +$categories = db()->fetchAll( + "SELECT DISTINCT category FROM products WHERE category IS NOT NULL AND category != '' ORDER BY category" +); +?> + + + + +
+ + Please fix the errors below +
+ + +
+
+ +
+
+
+

Basic Information

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

Pricing

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

Images

+
+
+
+ + +
+ +
Drag & drop images here
+
or click to browse — JPG, PNG, WebP, GIF up to 5MB each
+ +
+ +
+ +
+
+ or add image URLs +
+
+ + One URL per line +
+ + +
+
+
+ + +
+
+
+

Status

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

Inventory

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

Identifiers

+
+
+
+ + +
+ +
+ + +
+
+
+ + +
+
+
+ + diff --git a/sites/tomsjavajive.com/public_html/admin/product-types.php b/sites/tomsjavajive.com/public_html/admin/product-types.php new file mode 100644 index 0000000..5145c13 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/admin/product-types.php @@ -0,0 +1,175 @@ +$name,'slug'=>$slug,'description'=>$description,'is_active'=>$isActive]; + if ($action === 'update' && $typeId) { + db()->update('product_types', $data, 'type_id = :id', ['id' => $typeId]); + setFlash('success', 'Product type updated'); + } else { + $data['type_id'] = generateId('pt_'); + db()->insert('product_types', $data); + setFlash('success', 'Product type created'); + } + } + header('Location: /admin/product-types.php'); + exit; + } + + if ($action === 'delete' && !empty($_POST['type_id'])) { + db()->delete('product_types', 'type_id = :id', ['id' => $_POST['type_id']]); + setFlash('success', 'Product type deleted'); + header('Location: /admin/product-types.php'); + exit; + } +} + +$types = db()->fetchAll( + "SELECT pt.*, COUNT(p.product_id) AS product_count + FROM product_types pt + LEFT JOIN products p ON p.product_type_id = pt.type_id AND p.is_active = 1 + GROUP BY pt.type_id + ORDER BY pt.name ASC" +); +?> + + + + +
+ + +
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameSlugDescriptionProductsStatusActions
No product types yet. Create one above.
+ + + + + + Active + + Hidden + + + +
+ + + +
+
+
+
+ + + + + + diff --git a/sites/tomsjavajive.com/public_html/admin/products.php b/sites/tomsjavajive.com/public_html/admin/products.php new file mode 100644 index 0000000..844432c --- /dev/null +++ b/sites/tomsjavajive.com/public_html/admin/products.php @@ -0,0 +1,290 @@ +delete('products', 'product_id = :id', ['id' => $_POST['product_id']]); + setFlash('success', 'Product deleted successfully'); + header('Location: /admin/products.php'); + exit; + } + + if ($action === 'bulk_delete' && !empty($_POST['product_ids'])) { + $ids = $_POST['product_ids']; + $placeholders = implode(',', array_fill(0, count($ids), '?')); + db()->query("DELETE FROM products WHERE product_id IN ($placeholders)", $ids); + setFlash('success', count($ids) . ' products deleted'); + header('Location: /admin/products.php'); + exit; + } + + if ($action === 'toggle_status' && !empty($_POST['product_id'])) { + $product = db()->fetch("SELECT is_active FROM products WHERE product_id = :id", ['id' => $_POST['product_id']]); + if ($product) { + db()->update('products', ['is_active' => !$product['is_active']], 'product_id = :id', ['id' => $_POST['product_id']]); + setFlash('success', 'Product status updated'); + } + header('Location: /admin/products.php'); + exit; + } +} + +// Filters +$search = $_GET['search'] ?? ''; +$category = $_GET['category'] ?? ''; +$status = $_GET['status'] ?? ''; +$page = max(1, intval($_GET['page'] ?? 1)); + +// Build query +$where = ['1=1']; +$params = []; + +if ($search) { + $where[] = '(name LIKE :search OR sku LIKE :search)'; + $params['search'] = '%' . $search . '%'; +} + +if ($category) { + $where[] = 'category = :category'; + $params['category'] = $category; +} + +if ($status === 'active') { + $where[] = 'is_active = 1'; +} elseif ($status === 'inactive') { + $where[] = 'is_active = 0'; +} elseif ($status === 'low_stock') { + $where[] = 'stock <= low_stock_threshold'; +} + +$whereClause = implode(' AND ', $where); + +// Get total and paginate +$totalProducts = db()->count('products', $whereClause, $params); +$pagination = paginate($totalProducts, $page, ADMIN_ITEMS_PER_PAGE); + +// Get products +$products = db()->fetchAll( + "SELECT * FROM products WHERE {$whereClause} ORDER BY created_at DESC LIMIT :limit OFFSET :offset", + array_merge($params, ['limit' => $pagination['per_page'], 'offset' => $pagination['offset']]) +); + +// Get categories +$categories = db()->fetchAll( + "SELECT DISTINCT category FROM products WHERE category IS NOT NULL AND category != '' ORDER BY category" +); +?> + + + + +
+ + +
+ + + +
+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ + + + + Clear + +
+
+
+ + +
+
+ products found + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + ImageProductSKUCategoryPriceStockStatusActions
+ No products found +
+ + + + + + + Featured + + + + + +
+ + + + +
+ + Out of Stock + + left + + + + + + Active + + Inactive + + + + + +
+ + + +
+
+ + + +
+
+
+
+ + + 1): ?> +
+ +
+ + + + + diff --git a/sites/tomsjavajive.com/public_html/admin/reviews.php b/sites/tomsjavajive.com/public_html/admin/reviews.php new file mode 100644 index 0000000..841f45b --- /dev/null +++ b/sites/tomsjavajive.com/public_html/admin/reviews.php @@ -0,0 +1,281 @@ +update('reviews', ['is_approved' => 1], 'review_id = :id', ['id' => $reviewId]); + setFlash('success', 'Review approved'); + } + + if ($action === 'reject' && $reviewId) { + db()->update('reviews', ['is_approved' => 0], 'review_id = :id', ['id' => $reviewId]); + setFlash('success', 'Review rejected'); + } + + if ($action === 'update' && $reviewId) { + $rating = max(1, min(5, intval($_POST['rating'] ?? 5))); + $title = trim($_POST['title'] ?? ''); + $comment = trim($_POST['comment'] ?? ''); + db()->update('reviews', [ + 'rating' => $rating, + 'title' => $title ?: null, + 'comment' => $comment ?: null, + ], 'review_id = :id', ['id' => $reviewId]); + setFlash('success', 'Review updated'); + } + + if ($action === 'delete' && $reviewId) { + db()->delete('reviews', 'review_id = :id', ['id' => $reviewId]); + setFlash('success', 'Review deleted'); + } + + header('Location: /admin/reviews.php'); + exit; +} + +// Filters +$status = $_GET['status'] ?? ''; +$rating = $_GET['rating'] ?? ''; + +$where = ['1=1']; +$params = []; + +if ($status === 'pending') { + $where[] = 'is_approved = 0'; +} elseif ($status === 'approved') { + $where[] = 'is_approved = 1'; +} + +if ($rating) { + $where[] = 'rating = :rating'; + $params['rating'] = $rating; +} + +$whereClause = implode(' AND ', $where); + +$reviews = db()->fetchAll( + "SELECT r.*, p.name as product_name FROM reviews r + LEFT JOIN products p ON r.product_id = p.product_id + WHERE {$whereClause} ORDER BY r.created_at DESC LIMIT 100", + $params +); + +// Stats +$totalReviews = db()->count('reviews'); +$pendingReviews = db()->count('reviews', 'is_approved = 0'); +$avgRating = db()->fetch("SELECT AVG(rating) as avg FROM reviews WHERE is_approved = 1")['avg'] ?? 0; +?> + + + + +
+ + + +
+
+
+
+
+
Average Rating
+
+
+
+
+
+
+
Total Reviews
+
+
+
+
+
+
+
Pending Approval
+
+
+
+ + +
+
+
+
+ + +
+
+ + +
+ + + Clear + +
+
+
+ + +
+ +
+
+ No reviews found +
+
+ + +
+
+
+
+
+ + + Verified Purchase + + + Pending + +
+
+ on + • +
+
+
+ +
+ + + +
+ +
+ + + +
+ + +
+ + + +
+
+
+ +
+ + + +
+ + +

+ + +

+ +

+
+
+ + +
+ + + + + + + diff --git a/sites/tomsjavajive.com/public_html/admin/settings.php b/sites/tomsjavajive.com/public_html/admin/settings.php new file mode 100644 index 0000000..a194871 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/admin/settings.php @@ -0,0 +1,233 @@ + trim($_POST['store_name'] ?? ''), + 'email' => trim($_POST['store_email'] ?? ''), + 'phone' => trim($_POST['store_phone'] ?? ''), + 'address' => trim($_POST['store_address'] ?? ''), + 'currency' => $_POST['currency'] ?? 'USD', + 'timezone' => $_POST['timezone'] ?? 'America/New_York' + ]); + setFlash('success', 'Store settings updated'); + } + + if ($section === 'tax') { + setSetting('tax', [ + 'enabled' => isset($_POST['tax_enabled']), + 'rate' => floatval($_POST['tax_rate'] ?? 0), + 'included_in_price' => isset($_POST['tax_included']) + ]); + setFlash('success', 'Tax settings updated'); + } + + if ($section === 'checkout') { + setSetting('checkout', [ + 'guest_checkout' => isset($_POST['guest_checkout']), + 'require_phone' => isset($_POST['require_phone']), + 'order_notes' => isset($_POST['order_notes']), + 'terms_required' => isset($_POST['terms_required']), + 'terms_url' => trim($_POST['terms_url'] ?? '') + ]); + setFlash('success', 'Checkout settings updated'); + } + + header('Location: /admin/settings.php'); + exit; +} + +// Get current settings +$store = getSetting('store', [ + 'name' => "Tom's Java Jive", + 'email' => '', + 'phone' => '', + 'address' => '', + 'currency' => 'USD', + 'timezone' => 'America/New_York' +]); + +$tax = getSetting('tax', [ + 'enabled' => false, + 'rate' => 0, + 'included_in_price' => false +]); + +$checkout = getSetting('checkout', [ + 'guest_checkout' => true, + 'require_phone' => false, + 'order_notes' => true, + 'terms_required' => false, + 'terms_url' => '' +]); +?> + + + + +
+ + +
+ + + + +
+ +
+ +
+
+

General Settings

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

Tax Settings

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

Checkout Settings

+
+
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ + +
+ + +
+
+
+
+
+ + diff --git a/sites/tomsjavajive.com/public_html/admin/shipping.php b/sites/tomsjavajive.com/public_html/admin/shipping.php new file mode 100644 index 0000000..de20dae --- /dev/null +++ b/sites/tomsjavajive.com/public_html/admin/shipping.php @@ -0,0 +1,124 @@ + isset($_POST['flat_rate_enabled']), + 'flat_rate_amount' => floatval($_POST['flat_rate_amount'] ?? 0), + 'free_shipping_enabled' => isset($_POST['free_shipping_enabled']), + 'free_shipping_threshold' => floatval($_POST['free_shipping_threshold'] ?? 0), + 'local_pickup_enabled' => isset($_POST['local_pickup_enabled']), + 'processing_time' => trim($_POST['processing_time'] ?? '') + ]); + setFlash('success', 'Shipping settings updated'); + header('Location: /admin/shipping.php'); + exit; +} + +$shipping = getSetting('shipping', [ + 'flat_rate_enabled' => true, + 'flat_rate_amount' => 5.99, + 'free_shipping_enabled' => true, + 'free_shipping_threshold' => 50, + 'local_pickup_enabled' => false, + 'processing_time' => '1-2 business days' +]); +?> + + + + +
+ + +
+ + +
+
+
+
+

Shipping Methods

+
+
+ +
+
+ +
+
+ +
+ $ + +
+
+
+ + +
+
+ +
+
+ +
+ $ + +
+
+
+ + +
+
+ + Allow customers to pick up orders at your location +
+
+ + +
+ + + Displayed to customers during checkout +
+ + +
+
+
+
+
+ + diff --git a/sites/tomsjavajive.com/public_html/admin/splashes.php b/sites/tomsjavajive.com/public_html/admin/splashes.php new file mode 100644 index 0000000..815c0d4 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/admin/splashes.php @@ -0,0 +1,410 @@ +insert('homepage_splashes', [ + 'splash_id' => generateId('spl_'), + 'icon' => trim($_POST['icon'] ?? 'fas fa-star'), + 'image_url' => trim($_POST['image_url'] ?? '') ?: null, + 'title' => $title, + 'description' => trim($_POST['description'] ?? '') ?: null, + 'sort_order' => intval($_POST['sort_order'] ?? 0), + 'is_active' => 1, + ]); + setFlash('success', 'Splash block created'); + } + } + + if ($action === 'update' && $splashId) { + $title = trim($_POST['title'] ?? ''); + if ($title) { + db()->update('homepage_splashes', [ + 'icon' => trim($_POST['icon'] ?? 'fas fa-star'), + 'image_url' => trim($_POST['image_url'] ?? '') ?: null, + 'title' => $title, + 'description' => trim($_POST['description'] ?? '') ?: null, + 'sort_order' => intval($_POST['sort_order'] ?? 0), + 'is_active' => isset($_POST['is_active']) ? 1 : 0, + ], 'splash_id = :id', ['id' => $splashId]); + setFlash('success', 'Splash block updated'); + } + } + + if ($action === 'delete' && $splashId) { + db()->delete('homepage_splashes', 'splash_id = :id', ['id' => $splashId]); + setFlash('success', 'Splash block deleted'); + } + + if ($action === 'reorder') { + $ids = json_decode($_POST['order'] ?? '[]', true); + foreach ($ids as $pos => $sid) { + db()->update('homepage_splashes', ['sort_order' => $pos + 1], + 'splash_id = :id', ['id' => $sid]); + } + echo json_encode(['ok' => true]); exit; + } + + header('Location: /admin/splashes.php'); exit; +} + +$splashes = db()->fetchAll( + "SELECT * FROM homepage_splashes ORDER BY sort_order ASC, id ASC" +); + +$iconOptions = [ + 'fas fa-leaf' => 'Leaf', + 'fas fa-fire' => 'Fire', + 'fas fa-truck' => 'Truck', + 'fas fa-heart' => 'Heart', + 'fas fa-star' => 'Star', + 'fas fa-coffee' => 'Coffee', + 'fas fa-mug-hot' => 'Mug Hot', + 'fas fa-seedling' => 'Seedling', + 'fas fa-shield-alt' => 'Shield', + 'fas fa-check-circle' => 'Check', + 'fas fa-gift' => 'Gift', + 'fas fa-globe' => 'Globe', + 'fas fa-award' => 'Award', + 'fas fa-smile' => 'Smile', + 'fas fa-bolt' => 'Bolt', + 'fas fa-recycle' => 'Recycle', + 'fas fa-hand-holding-heart'=> 'Care', + 'fas fa-crown' => 'Crown', + 'fas fa-gem' => 'Gem', + 'fas fa-thumbs-up' => 'Thumbs Up', +]; +?> + + + + +
+ + +
+
+

+ Drag rows to reorder — saves automatically. + block total. + Homepage scrolls horizontally when more than 4 are active. + Each block can show an icon or a custom image.

+
+
+ + +
+

Homepage Preview

+
+
+ +
+
+ + + + + +
+
+
+
+ +
+
+
+ + +
+
+ +
No splash blocks yet. Click Add Splash to get started.
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
VisualTitleDescriptionOrderStatusActions
+
+ + + + + +
+
+ + + Active' + : 'Hidden' ?> + + +
+ + + +
+
+ +
+
+ + + + + + + + + diff --git a/sites/tomsjavajive.com/public_html/admin/upload-image.php b/sites/tomsjavajive.com/public_html/admin/upload-image.php new file mode 100644 index 0000000..c957b4b --- /dev/null +++ b/sites/tomsjavajive.com/public_html/admin/upload-image.php @@ -0,0 +1,27 @@ + 'Unauthorized']); + exit; +} + +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + echo json_encode(['error' => 'No file received']); + exit; +} + +$result = handleImageUpload('image', __DIR__ . '/../uploads/products/', 'product_'); + +if (isset($result['success'])) { + echo json_encode(['success' => true, 'url' => '/uploads/products/' . $result['filename']]); +} else { + echo json_encode(['error' => $result['error']]); +} diff --git a/sites/tomsjavajive.com/public_html/admin/users.php b/sites/tomsjavajive.com/public_html/admin/users.php new file mode 100644 index 0000000..eb609c8 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/admin/users.php @@ -0,0 +1,267 @@ + isset($_POST['perm_dashboard']), + 'pos' => isset($_POST['perm_pos']), + 'products' => isset($_POST['perm_products']), + 'orders' => isset($_POST['perm_orders']), + 'customers' => isset($_POST['perm_customers']), + 'settings_payment' => isset($_POST['perm_settings']), + 'settings_shipping' => isset($_POST['perm_settings']), + 'settings_email' => isset($_POST['perm_settings']), + 'admin_management' => isset($_POST['perm_admin']) + ]; + + if (empty($email) || empty($name)) { + setFlash('error', 'Email and name are required'); + } else { + $data = [ + 'email' => strtolower($email), + 'name' => $name, + 'is_master' => $isMaster, + 'permissions' => json_encode($permissions) + ]; + + if ($action === 'update' && $userId) { + if (!empty($password)) { + $data['password_hash'] = hashPassword($password); + } + db()->update('admin_users', $data, 'user_id = :id', ['id' => $userId]); + setFlash('success', 'Admin user updated'); + } else { + if (empty($password)) { + setFlash('error', 'Password is required for new users'); + } else { + $existing = db()->fetch("SELECT id FROM admin_users WHERE email = :email", ['email' => strtolower($email)]); + if ($existing) { + setFlash('error', 'Email already exists'); + } else { + $data['user_id'] = generateId('admin_'); + $data['password_hash'] = hashPassword($password); + $data['is_admin'] = 1; + db()->insert('admin_users', $data); + setFlash('success', 'Admin user created'); + } + } + } + } + + header('Location: /admin/users.php'); + exit; + } + + if ($action === 'delete' && !empty($_POST['user_id'])) { + // Don't allow deleting self or last master + $user = db()->fetch("SELECT is_master FROM admin_users WHERE user_id = :id", ['id' => $_POST['user_id']]); + if ($user && $user['is_master']) { + $masterCount = db()->count('admin_users', 'is_master = 1'); + if ($masterCount <= 1) { + setFlash('error', 'Cannot delete the last master admin'); + header('Location: /admin/users.php'); + exit; + } + } + + db()->delete('admin_users', 'user_id = :id', ['id' => $_POST['user_id']]); + setFlash('success', 'Admin user deleted'); + header('Location: /admin/users.php'); + exit; + } +} + +$users = db()->fetchAll("SELECT * FROM admin_users ORDER BY is_master DESC, name ASC"); +?> + + + + +
+ + +
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + +
NameEmailRoleCreatedActions
+ + + You + + + + Master Admin + + Admin + + + + +
+ + + +
+ +
+
+
+ + + + + + + diff --git a/sites/tomsjavajive.com/public_html/api/apply-wallet-credit.php b/sites/tomsjavajive.com/public_html/api/apply-wallet-credit.php new file mode 100644 index 0000000..aa1073e --- /dev/null +++ b/sites/tomsjavajive.com/public_html/api/apply-wallet-credit.php @@ -0,0 +1,58 @@ + 'Method not allowed'], 405); +} + +if (!CustomerAuth::isLoggedIn()) { + jsonResponse(['error' => 'Please log in to use wallet balance or a gift card'], 401); +} + +$customer = CustomerAuth::getFullUser(); +$input = json_decode(file_get_contents('php://input'), true); + +$orderTotal = (float) ($input['order_total'] ?? 0); +if ($orderTotal <= 0) { + jsonResponse(['error' => 'Invalid order total'], 400); +} + +$giftCardCode = trim($input['gift_card_code'] ?? ''); + +if (!empty($giftCardCode)) { + $redeemResult = loyalty()->redeemGiftCardToWallet($customer['customer_id'], $giftCardCode); + if (!$redeemResult['success']) { + jsonResponse(['error' => $redeemResult['error']], 400); + } + $walletBalance = $redeemResult['new_balance']; +} else { + $fresh = db()->fetch("SELECT wallet_balance FROM customers WHERE customer_id = :id", ['id' => $customer['customer_id']]); + $walletBalance = (float) ($fresh['wallet_balance'] ?? 0); +} + +$requested = isset($input['amount']) ? (float) $input['amount'] : $walletBalance; +$applyAmount = max(0, round(min($requested, $walletBalance, $orderTotal), 2)); + +jsonResponse([ + 'success' => true, + 'apply_amount' => $applyAmount, + 'wallet_balance' => $walletBalance, + 'new_total' => round($orderTotal - $applyAmount, 2) +]); diff --git a/sites/tomsjavajive.com/public_html/api/cart.php b/sites/tomsjavajive.com/public_html/api/cart.php new file mode 100644 index 0000000..990de9c --- /dev/null +++ b/sites/tomsjavajive.com/public_html/api/cart.php @@ -0,0 +1,121 @@ + 'Product ID required'], 400); + } + + // Verify product exists and is active + $product = db()->fetch( + "SELECT product_id, stock FROM products WHERE product_id = :id AND is_active = 1", + ['id' => $productId] + ); + + if (!$product) { + jsonResponse(['error' => 'Product not found'], 404); + } + + if ($product['stock'] < $quantity) { + jsonResponse(['error' => 'Not enough stock'], 400); + } + + addToCart($productId, $quantity); + + jsonResponse([ + 'success' => true, + 'cart_count' => getCartCount(), + 'message' => 'Item added to cart' + ]); + break; + + case 'update': + $productId = $input['product_id'] ?? ''; + $quantity = intval($input['quantity'] ?? 0); + + if (!$productId) { + jsonResponse(['error' => 'Product ID required'], 400); + } + + updateCartItem($productId, $quantity); + + jsonResponse([ + 'success' => true, + 'cart_count' => getCartCount(), + 'subtotal' => getCartTotal() + ]); + break; + + case 'remove': + $productId = $input['product_id'] ?? ''; + + if (!$productId) { + jsonResponse(['error' => 'Product ID required'], 400); + } + + removeFromCart($productId); + + jsonResponse([ + 'success' => true, + 'cart_count' => getCartCount(), + 'subtotal' => getCartTotal() + ]); + break; + + case 'clear': + clearCart(); + jsonResponse(['success' => true, 'cart_count' => 0]); + break; + + case 'get': + $cart = getCart(); + $items = []; + $subtotal = 0; + + foreach ($cart as $productId => $quantity) { + $product = db()->fetch( + "SELECT product_id, name, price, sale_price, stock, images FROM products WHERE product_id = :id", + ['id' => $productId] + ); + + if ($product) { + $images = json_decode($product['images'] ?? '[]', true); + $unitPrice = $product['sale_price'] ?? $product['price']; + $total = $unitPrice * $quantity; + $subtotal += $total; + + $items[] = [ + 'product_id' => $product['product_id'], + 'name' => $product['name'], + 'price' => $unitPrice, + 'quantity' => $quantity, + 'total' => $total, + 'image' => !empty($images) ? $images[0] : null, + 'stock' => $product['stock'] + ]; + } + } + + jsonResponse([ + 'items' => $items, + 'count' => getCartCount(), + 'subtotal' => $subtotal + ]); + break; + + default: + jsonResponse(['error' => 'Invalid action'], 400); +} diff --git a/sites/tomsjavajive.com/public_html/api/create-square-payment.php b/sites/tomsjavajive.com/public_html/api/create-square-payment.php new file mode 100644 index 0000000..a32e1dc --- /dev/null +++ b/sites/tomsjavajive.com/public_html/api/create-square-payment.php @@ -0,0 +1,94 @@ + 'Method not allowed'], 405); +} + +$input = json_decode(file_get_contents('php://input'), true); +$orderId = $input['order_id'] ?? ''; +$sourceId = $input['source_id'] ?? ''; + +if (empty($orderId)) { + jsonResponse(['error' => 'Order ID required'], 400); +} + +$order = db()->fetch( + "SELECT * FROM orders WHERE order_id = :id", + ['id' => $orderId] +); + +if (!$order) { + jsonResponse(['error' => 'Order not found'], 404); +} + +if ($order['payment_status'] === 'paid') { + jsonResponse(['error' => 'Order already paid'], 400); +} + +// Demo mode - Square not configured +if (!isSquareConfigured()) { + db()->update('orders', + [ + 'payment_status' => 'paid', + 'order_status' => 'confirmed', + 'square_payment_id' => 'demo_' . bin2hex(random_bytes(8)), + 'payment_method' => 'square', + ], + 'order_id = :id', + ['id' => $orderId] + ); + + jsonResponse([ + 'demo_mode' => true, + 'message' => 'Payment simulated (Square not configured)', + 'redirect' => '/order-confirmation.php?order=' . $orderId + ]); +} + +if (empty($sourceId)) { + jsonResponse(['error' => 'Card details required'], 400); +} + +try { + $result = squareCreatePayment( + $sourceId, + (float) $order['total'], + $orderId, + ['note' => 'Order #' . $order['order_number']] + ); + $payment = $result['payment'] ?? []; + $status = $payment['status'] ?? ''; + + if ($status === 'COMPLETED') { + markSquarePaymentResult($payment['id'], $orderId, 'COMPLETED'); + jsonResponse([ + 'success' => true, + 'redirect' => '/order-confirmation.php?order=' . $orderId + ]); + } + + // Store the payment id even if not yet completed, so the reconciliation + // poll and webhook can find this order by square_payment_id. + db()->update('orders', + ['square_payment_id' => $payment['id'] ?? null], + 'order_id = :id', + ['id' => $orderId] + ); + + jsonResponse(['error' => 'Payment was not completed (status: ' . $status . ')'], 400); + +} catch (Exception $e) { + error_log('Square payment error: ' . $e->getMessage()); + jsonResponse(['error' => 'Payment failed: ' . $e->getMessage()], 500); +} diff --git a/sites/tomsjavajive.com/public_html/api/delete-account.php b/sites/tomsjavajive.com/public_html/api/delete-account.php new file mode 100644 index 0000000..83c31c3 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/api/delete-account.php @@ -0,0 +1,53 @@ +query("START TRANSACTION"); + + // Delete wallet transactions + db()->query("DELETE FROM wallet_transactions WHERE customer_id = :id", ['id' => $customer['customer_id']]); + + // Delete reviews + db()->query("DELETE FROM reviews WHERE customer_id = :id", ['id' => $customer['customer_id']]); + + // Delete wishlist + db()->query("DELETE FROM wishlist WHERE customer_id = :id", ['id' => $customer['customer_id']]); + + // Anonymize orders (keep for records but remove personal info) + db()->query( + "UPDATE orders SET customer_name = 'Deleted User', customer_email = 'deleted@example.com', + shipping_address = NULL, billing_address = NULL WHERE customer_id = :id", + ['id' => $customer['customer_id']] + ); + + // Remove from email subscribers + db()->query("DELETE FROM email_subscribers WHERE email = :email", ['email' => $customer['email']]); + + // Delete customer + db()->query("DELETE FROM customers WHERE customer_id = :id", ['id' => $customer['customer_id']]); + + db()->query("COMMIT"); + + // Logout + CustomerAuth::logout(); + + setFlash('success', 'Your account has been deleted. We\'re sorry to see you go!'); + redirect('/'); + +} catch (Exception $e) { + db()->query("ROLLBACK"); + setFlash('error', 'Failed to delete account. Please contact support.'); + redirect('/account/profile.php'); +} diff --git a/sites/tomsjavajive.com/public_html/api/loyalty.php b/sites/tomsjavajive.com/public_html/api/loyalty.php new file mode 100644 index 0000000..4224f3d --- /dev/null +++ b/sites/tomsjavajive.com/public_html/api/loyalty.php @@ -0,0 +1,94 @@ + 'Authentication required'], 401); +} + +$customer = CustomerAuth::getUser(); +$method = $_SERVER['REQUEST_METHOD']; +$input = json_decode(file_get_contents('php://input'), true); +$action = $input['action'] ?? $_GET['action'] ?? ''; + +switch ($action) { + case 'status': + // Get customer's loyalty status + $status = loyalty()->getCustomerTier($customer['customer_id']); + $conversion = loyalty()->getConversionInfo(); + + jsonResponse([ + 'tier' => $status['tier'], + 'tier_name' => $status['info']['name'], + 'tier_color' => $status['info']['color'], + 'tier_icon' => $status['info']['icon'], + 'benefits' => $status['info']['benefits'], + 'multiplier' => $status['info']['multiplier'], + 'points' => $status['points'], + 'lifetime_points' => $status['lifetime_points'], + 'points_value' => $status['points'] * $conversion['points_value'], + 'next_tier' => $status['next_tier'], + 'next_tier_name' => $status['next_tier_info']['name'] ?? null, + 'points_to_next' => $status['points_to_next'], + 'progress_percent' => $status['progress_percent'], + 'conversion' => $conversion + ]); + break; + + case 'history': + // Get loyalty transaction history + $limit = min(50, intval($_GET['limit'] ?? 20)); + $history = loyalty()->getHistory($customer['customer_id'], $limit); + + jsonResponse(['transactions' => $history]); + break; + + case 'redeem': + // Redeem points for credit + if ($method !== 'POST') { + jsonResponse(['error' => 'POST required'], 405); + } + + $points = intval($input['points'] ?? 0); + + if ($points < 100) { + jsonResponse(['error' => 'Minimum 100 points required for redemption'], 400); + } + + $result = loyalty()->redeemPoints($customer['customer_id'], $points); + + if ($result['success']) { + jsonResponse([ + 'success' => true, + 'points_redeemed' => $result['points_redeemed'], + 'credit_value' => $result['credit_value'], + 'new_points_balance' => $result['new_points_balance'], + 'new_wallet_balance' => $result['new_wallet_balance'], + 'message' => 'Successfully redeemed ' . $points . ' points for ' . formatCurrency($result['credit_value']) + ]); + } else { + jsonResponse(['error' => $result['error']], 400); + } + break; + + case 'tiers': + // Get all tier information + $tiers = loyalty()->getTiers(); + $conversion = loyalty()->getConversionInfo(); + + jsonResponse([ + 'tiers' => $tiers, + 'conversion' => $conversion + ]); + break; + + default: + jsonResponse(['error' => 'Invalid action'], 400); +} diff --git a/sites/tomsjavajive.com/public_html/api/orders.php b/sites/tomsjavajive.com/public_html/api/orders.php new file mode 100644 index 0000000..2785386 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/api/orders.php @@ -0,0 +1,183 @@ +fetch( + "SELECT * FROM orders WHERE order_id = :id", + ['id' => $orderId] + ); + + if (!$order) { + jsonResponse(['error' => 'Order not found'], 404); + } + + // Only the admin or the customer who placed this order may view it by raw id. + $customer = CustomerAuth::getUser(); + $isOwner = $customer && (string)$customer['customer_id'] === (string)$order['customer_id']; + if (!AdminAuth::isLoggedIn() && !$isOwner) { + jsonResponse(['error' => 'Unauthorized'], 401); + } + + $order['items'] = json_decode($order['items'], true); + $order['shipping_address'] = json_decode($order['shipping_address'], true); + unset($order['id']); + + jsonResponse($order); + } elseif ($orderNumber) { + $email = $_GET['email'] ?? ''; + + $order = db()->fetch( + "SELECT * FROM orders WHERE order_number = :num AND customer_email = :email", + ['num' => $orderNumber, 'email' => strtolower($email)] + ); + + if (!$order) { + jsonResponse(['error' => 'Order not found'], 404); + } + + $order['items'] = json_decode($order['items'], true); + $order['shipping_address'] = json_decode($order['shipping_address'], true); + unset($order['id']); + + jsonResponse($order); + } else { + // List orders (admin only or customer's own) + $customer = CustomerAuth::getUser(); + + if ($customer) { + $orders = db()->fetchAll( + "SELECT order_id, order_number, total, payment_status, order_status, created_at + FROM orders WHERE customer_id = :cid ORDER BY created_at DESC LIMIT 50", + ['cid' => $customer['customer_id']] + ); + } else { + jsonResponse(['error' => 'Authentication required'], 401); + } + + jsonResponse(['orders' => $orders]); + } + break; + + case 'POST': + // Update order status (admin) + if ($action === 'update_status') { + if (!AdminAuth::isLoggedIn()) { + jsonResponse(['error' => 'Unauthorized'], 401); + } + $orderId = $input['order_id'] ?? ''; + $status = $input['status'] ?? ''; + $trackingNumber = $input['tracking_number'] ?? null; + + if (empty($orderId) || empty($status)) { + jsonResponse(['error' => 'Order ID and status required'], 400); + } + + $validStatuses = ['pending', 'confirmed', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded']; + if (!in_array($status, $validStatuses)) { + jsonResponse(['error' => 'Invalid status'], 400); + } + + $updateData = ['order_status' => $status]; + if ($trackingNumber) { + $updateData['tracking_number'] = $trackingNumber; + } + + db()->update('orders', $updateData, 'order_id = :id', ['id' => $orderId]); + + // If status is shipped or delivered, send email + $order = db()->fetch("SELECT * FROM orders WHERE order_id = :id", ['id' => $orderId]); + if ($order && in_array($status, ['shipped', 'delivered'])) { + sendStatusUpdateEmail($order, $status, $trackingNumber); + } + + jsonResponse(['success' => true, 'status' => $status]); + } + + // Cancel order + if ($action === 'cancel') { + $orderId = $input['order_id'] ?? ''; + $customer = CustomerAuth::getUser(); + + if (!$customer) { + jsonResponse(['error' => 'Authentication required'], 401); + } + + $order = db()->fetch( + "SELECT * FROM orders WHERE order_id = :id AND customer_id = :cid", + ['id' => $orderId, 'cid' => $customer['customer_id']] + ); + + if (!$order) { + jsonResponse(['error' => 'Order not found'], 404); + } + + if (!in_array($order['order_status'], ['pending', 'confirmed'])) { + jsonResponse(['error' => 'This order cannot be cancelled'], 400); + } + + db()->update('orders', + ['order_status' => 'cancelled'], + 'order_id = :id', + ['id' => $orderId] + ); + + // Restore stock + $items = json_decode($order['items'], true) ?? []; + foreach ($items as $item) { + db()->query( + "UPDATE products SET stock = stock + :qty WHERE product_id = :id", + ['qty' => $item['quantity'], 'id' => $item['product_id']] + ); + } + + jsonResponse(['success' => true]); + } + + jsonResponse(['error' => 'Invalid action'], 400); + break; + + default: + jsonResponse(['error' => 'Method not allowed'], 405); +} + +function sendStatusUpdateEmail($order, $status, $trackingNumber = null) { + $statusMessages = [ + 'shipped' => 'Your order has been shipped!', + 'delivered' => 'Your order has been delivered!' + ]; + + $tracking = $trackingNumber ? "

Tracking #: {$trackingNumber}

" : ''; + + $html = << +
+

Tom's Java Jive

+
+
+

{$statusMessages[$status]}

+

Hi {$order['customer_name']},

+

Order #{$order['order_number']} has been updated to: {$status}

+ {$tracking} +
+
+ HTML; + + sendEmail($order['customer_email'], "Order Update - #{$order['order_number']}", $html); +} diff --git a/sites/tomsjavajive.com/public_html/api/payment-status.php b/sites/tomsjavajive.com/public_html/api/payment-status.php new file mode 100644 index 0000000..9da23e0 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/api/payment-status.php @@ -0,0 +1,93 @@ + 'Method not allowed'], 405); +} + +$orderId = $_GET['order_id'] ?? ''; + +if (empty($orderId)) { + jsonResponse(['error' => 'Order ID required'], 400); +} + +$order = db()->fetch( + "SELECT * FROM orders WHERE order_id = :id", + ['id' => $orderId] +); + +if (!$order) { + jsonResponse(['error' => 'Order not found'], 404); +} + +if ($order['payment_status'] === 'paid') { + jsonResponse([ + 'status' => 'complete', + 'payment_status' => 'paid', + 'order_id' => $order['order_id'], + 'order_number' => $order['order_number'], + 'redirect' => '/order-confirmation.php?order=' . $order['order_id'] + ]); +} + +if (!isSquareConfigured()) { + jsonResponse([ + 'status' => 'demo_mode', + 'payment_status' => $order['payment_status'], + 'message' => 'Square not configured - running in demo mode' + ]); +} + +if (empty($order['square_payment_id'])) { + jsonResponse([ + 'status' => 'pending', + 'payment_status' => $order['payment_status'] + ]); +} + +try { + $result = squareGetPayment($order['square_payment_id']); + $payment = $result['payment'] ?? []; + $status = $payment['status'] ?? ''; + + markSquarePaymentResult($order['square_payment_id'], $order['order_id'], $status); + + $updated = db()->fetch("SELECT * FROM orders WHERE order_id = :id", ['id' => $order['order_id']]); + + if ($updated['payment_status'] === 'paid') { + jsonResponse([ + 'status' => 'complete', + 'payment_status' => 'paid', + 'order_id' => $updated['order_id'], + 'order_number' => $updated['order_number'], + 'redirect' => '/order-confirmation.php?order=' . $updated['order_id'] + ]); + } + + jsonResponse([ + 'status' => $status, + 'payment_status' => $updated['payment_status'] + ]); + +} catch (Exception $e) { + error_log('Square payment status check error: ' . $e->getMessage()); + jsonResponse([ + 'status' => 'error', + 'payment_status' => $order['payment_status'], + 'error' => 'Failed to check payment status' + ]); +} diff --git a/sites/tomsjavajive.com/public_html/api/pos-order.php b/sites/tomsjavajive.com/public_html/api/pos-order.php new file mode 100644 index 0000000..398dc43 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/api/pos-order.php @@ -0,0 +1,191 @@ + 'Method not allowed'], 405); +} + +$input = json_decode(file_get_contents('php://input'), true); + +if (empty($input['items']) || !is_array($input['items'])) { + jsonResponse(['error' => 'No items provided'], 400); +} + +$items = $input['items']; +$paymentMethod = $input['payment_method'] ?? 'cash'; +$notes = $input['notes'] ?? ''; +$customerId = $input['customer_id'] ?? null; +$customerEmail = $input['customer_email'] ?? null; +$discountAmount = floatval($input['discount'] ?? 0); +$couponCode = $input['coupon_code'] ?? null; + +// Calculate totals +$subtotal = 0; +$orderItems = []; + +foreach ($items as $item) { + // Verify product exists and has stock + $product = db()->fetch( + "SELECT product_id, name, price, sale_price, stock FROM products WHERE product_id = :id AND is_active = 1", + ['id' => $item['product_id']] + ); + + if (!$product) { + jsonResponse(['error' => 'Product not found: ' . $item['name']], 400); + } + + if ($product['stock'] < $item['quantity']) { + jsonResponse(['error' => 'Insufficient stock for: ' . $product['name']], 400); + } + + $price = $product['sale_price'] ?? $product['price']; + $lineTotal = $price * $item['quantity']; + $subtotal += $lineTotal; + + $orderItems[] = [ + 'product_id' => $product['product_id'], + 'name' => $product['name'], + 'price' => $price, + 'quantity' => $item['quantity'], + 'total' => $lineTotal + ]; +} + +// Apply coupon if provided +$couponDiscount = 0; +if ($couponCode) { + $coupon = db()->fetch( + "SELECT * FROM coupons WHERE code = :code AND is_active = 1 + AND (starts_at IS NULL OR starts_at <= NOW()) + AND (expires_at IS NULL OR expires_at > NOW()) + AND (max_uses IS NULL OR times_used < max_uses)", + ['code' => strtoupper($couponCode)] + ); + + if ($coupon) { + if ($coupon['min_order_amount'] && $subtotal < $coupon['min_order_amount']) { + // Coupon minimum not met, ignore + } else { + if ($coupon['discount_type'] === 'percentage') { + $couponDiscount = $subtotal * ($coupon['discount_value'] / 100); + } else { + $couponDiscount = min($coupon['discount_value'], $subtotal); + } + + // Update coupon usage + db()->query("UPDATE coupons SET times_used = times_used + 1 WHERE coupon_id = :id", + ['id' => $coupon['coupon_id']]); + } + } +} + +// Calculate final total +$discount = $discountAmount + $couponDiscount; +$taxRate = 0; // Adjust based on settings +$tax = ($subtotal - $discount) * $taxRate; +$total = $subtotal - $discount + $tax; + +// Handle wallet payment +$walletUsed = 0; +if ($paymentMethod === 'wallet' && $customerId) { + $customer = db()->fetch( + "SELECT wallet_balance FROM customers WHERE customer_id = :id", + ['id' => $customerId] + ); + + if (!$customer || $customer['wallet_balance'] < $total) { + jsonResponse(['error' => 'Insufficient wallet balance'], 400); + } + + $walletUsed = $total; + + // Deduct from wallet + db()->query( + "UPDATE customers SET wallet_balance = wallet_balance - :amount WHERE customer_id = :id", + ['amount' => $walletUsed, 'id' => $customerId] + ); + + // Log wallet transaction + $newBalance = $customer['wallet_balance'] - $walletUsed; + db()->insert('wallet_transactions', [ + 'transaction_id' => generateId('wt_'), + 'customer_id' => $customerId, + 'amount' => -$walletUsed, + 'balance_after' => $newBalance, + 'type' => 'purchase', + 'description' => 'POS Purchase' + ]); +} + +// Generate order +$orderId = generateId('ord_'); +$orderNumber = generateOrderNumber(); + +try { + // Create order + db()->insert('orders', [ + 'order_id' => $orderId, + 'order_number' => $orderNumber, + 'customer_id' => $customerId, + 'customer_email' => $customerEmail ?? 'pos@store.local', + 'customer_name' => $input['customer_name'] ?? 'POS Customer', + 'items' => json_encode($orderItems), + 'subtotal' => $subtotal, + 'tax' => $tax, + 'discount' => $discount, + 'wallet_amount_used' => $walletUsed, + 'total' => $total, + 'payment_method' => $paymentMethod, + 'payment_status' => 'paid', + 'order_status' => 'confirmed', + 'notes' => $notes, + 'is_pos_order' => 1 + ]); + + // Insert order items + foreach ($orderItems as $item) { + db()->insert('order_items', [ + 'order_id' => $orderId, + 'product_id' => $item['product_id'], + 'name' => $item['name'], + 'price' => $item['price'], + 'quantity' => $item['quantity'], + 'total' => $item['total'] + ]); + + // Update stock + db()->query( + "UPDATE products SET stock = stock - :qty WHERE product_id = :id", + ['qty' => $item['quantity'], 'id' => $item['product_id']] + ); + } + + // Award reward points if customer + if ($customerId) { + $pointsEarned = floor($total); // 1 point per dollar + db()->query( + "UPDATE customers SET reward_points = reward_points + :points WHERE customer_id = :id", + ['points' => $pointsEarned, 'id' => $customerId] + ); + } + + jsonResponse([ + 'success' => true, + 'order_id' => $orderId, + 'order_number' => $orderNumber, + 'total' => $total, + 'items' => $orderItems + ]); + +} catch (Exception $e) { + jsonResponse(['error' => 'Failed to create order: ' . $e->getMessage()], 500); +} diff --git a/sites/tomsjavajive.com/public_html/api/products.php b/sites/tomsjavajive.com/public_html/api/products.php new file mode 100644 index 0000000..be9abae --- /dev/null +++ b/sites/tomsjavajive.com/public_html/api/products.php @@ -0,0 +1,93 @@ +fetch( + "SELECT * FROM products WHERE product_id = :id AND is_active = 1", + ['id' => $productId] + ); + + if (!$product) { + jsonResponse(['error' => 'Product not found'], 404); + } + + $product['images'] = json_decode($product['images'] ?? '[]', true); + $product['tags'] = json_decode($product['tags'] ?? '[]', true); + unset($product['id']); + + // Get reviews + $reviews = db()->fetchAll( + "SELECT review_id, customer_name, rating, title, comment, is_verified_purchase, created_at + FROM reviews WHERE product_id = :id AND is_approved = 1 ORDER BY created_at DESC", + ['id' => $productId] + ); + + $product['reviews'] = $reviews; + $product['average_rating'] = !empty($reviews) + ? round(array_sum(array_column($reviews, 'rating')) / count($reviews), 1) + : 0; + + jsonResponse($product); + } else { + // Get products list + $category = $_GET['category'] ?? ''; + $search = $_GET['search'] ?? ''; + $featured = $_GET['featured'] ?? ''; + $limit = min(100, intval($_GET['limit'] ?? 20)); + $offset = intval($_GET['offset'] ?? 0); + + $where = ['is_active = 1']; + $params = []; + + if ($category) { + $where[] = 'category = :category'; + $params['category'] = $category; + } + + if ($search) { + $where[] = '(name LIKE :search OR description LIKE :search)'; + $params['search'] = '%' . $search . '%'; + } + + if ($featured === '1') { + $where[] = 'is_featured = 1'; + } + + $whereClause = implode(' AND ', $where); + + $products = db()->fetchAll( + "SELECT product_id, name, description, price, sale_price, category, images, stock, is_featured + FROM products WHERE {$whereClause} + ORDER BY is_featured DESC, created_at DESC + LIMIT :limit OFFSET :offset", + array_merge($params, ['limit' => $limit, 'offset' => $offset]) + ); + + foreach ($products as &$p) { + $p['images'] = json_decode($p['images'] ?? '[]', true); + } + + $total = db()->count('products', $whereClause, $params); + + jsonResponse([ + 'products' => $products, + 'total' => $total, + 'limit' => $limit, + 'offset' => $offset + ]); + } +} + +jsonResponse(['error' => 'Method not allowed'], 405); diff --git a/sites/tomsjavajive.com/public_html/api/push-subscribe.php b/sites/tomsjavajive.com/public_html/api/push-subscribe.php new file mode 100644 index 0000000..ca1c219 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/api/push-subscribe.php @@ -0,0 +1,83 @@ + 'Invalid subscription data'], 400); + } + + $customerId = null; + if (CustomerAuth::isLoggedIn()) { + $customerId = CustomerAuth::getUser()['customer_id']; + } + + // Check if subscription already exists + $existing = db()->fetch( + "SELECT id FROM push_subscriptions WHERE endpoint = :endpoint", + ['endpoint' => $endpoint] + ); + + if ($existing) { + // Update existing + db()->query( + "UPDATE push_subscriptions SET + customer_id = :cid, p256dh_key = :p256dh, auth_key = :auth, + is_active = 1, updated_at = NOW() + WHERE endpoint = :endpoint", + ['cid' => $customerId, 'p256dh' => $p256dh, 'auth' => $auth, 'endpoint' => $endpoint] + ); + } else { + // Create new + db()->insert('push_subscriptions', [ + 'customer_id' => $customerId, + 'endpoint' => $endpoint, + 'p256dh_key' => $p256dh, + 'auth_key' => $auth, + 'is_active' => 1 + ]); + } + + jsonResponse(['success' => true, 'message' => 'Subscribed to notifications']); + break; + + case 'DELETE': + // Unsubscribe + $endpoint = $input['endpoint'] ?? ''; + + if (empty($endpoint)) { + jsonResponse(['error' => 'Endpoint required'], 400); + } + + db()->query( + "UPDATE push_subscriptions SET is_active = 0 WHERE endpoint = :endpoint", + ['endpoint' => $endpoint] + ); + + jsonResponse(['success' => true, 'message' => 'Unsubscribed from notifications']); + break; + + case 'GET': + // Get VAPID public key + require_once __DIR__ . '/../includes/push.php'; + jsonResponse(['publicKey' => pushNotify()->getPublicKey()]); + break; + + default: + jsonResponse(['error' => 'Method not allowed'], 405); +} diff --git a/sites/tomsjavajive.com/public_html/api/redeem-gift-card.php b/sites/tomsjavajive.com/public_html/api/redeem-gift-card.php new file mode 100644 index 0000000..0e37830 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/api/redeem-gift-card.php @@ -0,0 +1,34 @@ + 'Method not allowed'], 405); +} + +if (!CustomerAuth::isLoggedIn()) { + jsonResponse(['error' => 'Please log in to redeem a gift card'], 401); +} + +$customer = CustomerAuth::getFullUser(); +$input = json_decode(file_get_contents('php://input'), true); + +$result = loyalty()->redeemGiftCardToWallet($customer['customer_id'], $input['code'] ?? ''); + +if (!$result['success']) { + jsonResponse(['error' => $result['error']], 400); +} + +jsonResponse([ + 'success' => true, + 'amount' => $result['amount'], + 'new_balance' => $result['new_balance'], + 'message' => formatCurrency($result['amount']) . ' has been added to your wallet!' +]); diff --git a/sites/tomsjavajive.com/public_html/api/search-customers.php b/sites/tomsjavajive.com/public_html/api/search-customers.php new file mode 100644 index 0000000..be49bc6 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/api/search-customers.php @@ -0,0 +1,30 @@ + 'Unauthorized'], 401); +} + +$query = $_GET['q'] ?? ''; + +if (strlen($query) < 2) { + jsonResponse([]); +} + +$customers = db()->fetchAll( + "SELECT customer_id, email, name, phone, wallet_balance, reward_points + FROM customers + WHERE (email LIKE :q1 OR name LIKE :q2 OR phone LIKE :q3) AND is_active = 1 + ORDER BY name ASC + LIMIT 20", + ['q1' => '%' . $query . '%', 'q2' => '%' . $query . '%', 'q3' => '%' . $query . '%'] +); + +jsonResponse($customers); diff --git a/sites/tomsjavajive.com/public_html/api/submit-review.php b/sites/tomsjavajive.com/public_html/api/submit-review.php new file mode 100644 index 0000000..6246018 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/api/submit-review.php @@ -0,0 +1,66 @@ + 'Method not allowed'], 405); +} + +if (!CustomerAuth::isLoggedIn()) { + jsonResponse(['error' => 'Please log in to submit a review'], 401); +} + +$customer = CustomerAuth::getFullUser(); +$input = json_decode(file_get_contents('php://input'), true); + +$productId = $input['product_id'] ?? ''; +$rating = intval($input['rating'] ?? 0); +$title = trim($input['title'] ?? ''); +$content = trim($input['content'] ?? ''); + +if (empty($productId) || $rating < 1 || $rating > 5 || empty($content)) { + jsonResponse(['error' => 'Invalid input. Rating and review content are required.'], 400); +} + +// Check if product exists +$product = db()->fetch("SELECT product_id FROM products WHERE product_id = :id", ['id' => $productId]); +if (!$product) { + jsonResponse(['error' => 'Product not found'], 404); +} + +// Check if already reviewed +$existingReview = db()->fetch( + "SELECT review_id FROM reviews WHERE customer_id = :cid AND product_id = :pid", + ['cid' => $customer['customer_id'], 'pid' => $productId] +); + +if ($existingReview) { + jsonResponse(['error' => 'You have already reviewed this product'], 400); +} + +// Create review +$reviewId = generateId('rev_'); + +db()->insert('reviews', [ + 'review_id' => $reviewId, + 'product_id' => $productId, + 'customer_id' => $customer['customer_id'], + 'customer_name' => $customer['name'] ?? explode('@', $customer['email'])[0], + 'customer_email' => $customer['email'], + 'rating' => $rating, + 'title' => $title, + 'comment' => $content, + 'is_approved' => 0 // Reviews require admin approval +]); + +jsonResponse([ + 'success' => true, + 'message' => 'Review submitted successfully. It will be visible after approval.', + 'review_id' => $reviewId +]); diff --git a/sites/tomsjavajive.com/public_html/api/subscribe.php b/sites/tomsjavajive.com/public_html/api/subscribe.php new file mode 100644 index 0000000..a6e5808 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/api/subscribe.php @@ -0,0 +1,75 @@ + 'Method not allowed'], 405); +} + +$input = json_decode(file_get_contents('php://input'), true); +$email = trim($input['email'] ?? $_POST['email'] ?? ''); + +if (empty($email)) { + jsonResponse(['error' => 'Email is required'], 400); +} + +if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + jsonResponse(['error' => 'Please enter a valid email address'], 400); +} + +// Check if already subscribed +$existing = db()->fetch( + "SELECT id FROM email_subscribers WHERE email = :email", + ['email' => strtolower($email)] +); + +if ($existing) { + jsonResponse(['error' => 'This email is already subscribed'], 400); +} + +// Add subscriber +try { + db()->insert('email_subscribers', [ + 'email' => strtolower($email), + 'source' => 'website', + 'is_active' => 1 + ]); + + // Send welcome email + $html = << +
+

Tom's Java Jive

+
+
+

Welcome to the Java Jive Family!

+

Thanks for subscribing to our newsletter. You'll be the first to know about:

+
    +
  • New coffee releases
  • +
  • Exclusive discounts and promotions
  • +
  • Brewing tips and recipes
  • +
  • Behind-the-scenes at our roastery
  • +
+

As a thank you, enjoy 10% off your first order with code: WELCOME10

+

+ Shop Now +

+
+
+

Tom's Java Jive | Premium Coffee

+
+
+ HTML; + + sendEmail($email, 'Welcome to Tom\'s Java Jive!', $html); + + jsonResponse(['success' => true, 'message' => 'Successfully subscribed!']); + +} catch (Exception $e) { + jsonResponse(['error' => 'Subscription failed. Please try again.'], 500); +} diff --git a/sites/tomsjavajive.com/public_html/api/test-notification.php b/sites/tomsjavajive.com/public_html/api/test-notification.php new file mode 100644 index 0000000..99509a7 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/api/test-notification.php @@ -0,0 +1,65 @@ + 'Method not allowed'], 405); +} + +$input = json_decode(file_get_contents('php://input'), true); +$type = $input['type'] ?? ''; +$recipient = $input['recipient'] ?? ''; + +if (empty($recipient)) { + jsonResponse(['error' => 'Recipient is required'], 400); +} + +switch ($type) { + case 'email': + if (!filter_var($recipient, FILTER_VALIDATE_EMAIL)) { + jsonResponse(['error' => 'Invalid email address'], 400); + } + + $result = emailService()->send( + $recipient, + "Test Email from Tom's Java Jive", + "
+

Test Email

+

This is a test email from your Tom's Java Jive store.

+

If you received this, your SendGrid integration is working correctly!

+

+ Sent at: " . date('Y-m-d H:i:s') . " +

+
" + ); + + if ($result['success']) { + jsonResponse(['success' => true, 'message' => 'Test email sent to ' . $recipient]); + } else { + jsonResponse(['success' => false, 'error' => $result['error'] ?? 'Failed to send email']); + } + break; + + case 'sms': + $result = sendSMS()->send( + $recipient, + "Tom's Java Jive: This is a test message. If you received this, your Twilio integration is working! Sent at " . date('g:i A') + ); + + if ($result['success']) { + jsonResponse(['success' => true, 'message' => 'Test SMS sent to ' . $recipient]); + } else { + jsonResponse(['success' => false, 'error' => $result['error'] ?? 'Failed to send SMS']); + } + break; + + default: + jsonResponse(['error' => 'Invalid notification type'], 400); +} diff --git a/sites/tomsjavajive.com/public_html/api/validate-coupon.php b/sites/tomsjavajive.com/public_html/api/validate-coupon.php new file mode 100644 index 0000000..957c697 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/api/validate-coupon.php @@ -0,0 +1,59 @@ + 'Method not allowed'], 405); +} + +$input = json_decode(file_get_contents('php://input'), true); +$code = strtoupper(trim($input['code'] ?? '')); +$subtotal = floatval($input['subtotal'] ?? 0); + +if (empty($code)) { + jsonResponse(['error' => 'Coupon code required'], 400); +} + +$coupon = db()->fetch( + "SELECT * FROM coupons WHERE code = :code AND is_active = 1", + ['code' => $code] +); + +if (!$coupon) { + jsonResponse(['error' => 'Invalid coupon code']); +} + +// Check if expired +if ($coupon['expires_at'] && strtotime($coupon['expires_at']) < time()) { + jsonResponse(['error' => 'Coupon has expired']); +} + +// Check if not started yet +if ($coupon['starts_at'] && strtotime($coupon['starts_at']) > time()) { + jsonResponse(['error' => 'Coupon is not yet active']); +} + +// Check usage limit +if ($coupon['max_uses'] && $coupon['times_used'] >= $coupon['max_uses']) { + jsonResponse(['error' => 'Coupon usage limit reached']); +} + +// Check minimum order +if ($coupon['min_order_amount'] && $subtotal < $coupon['min_order_amount']) { + jsonResponse(['error' => 'Minimum order of ' . formatCurrency($coupon['min_order_amount']) . ' required']); +} + +jsonResponse([ + 'valid' => true, + 'code' => $coupon['code'], + 'type' => $coupon['discount_type'], + 'value' => floatval($coupon['discount_value']), + 'description' => $coupon['discount_type'] === 'percentage' + ? $coupon['discount_value'] . '% off' + : formatCurrency($coupon['discount_value']) . ' off' +]); diff --git a/sites/tomsjavajive.com/public_html/api/webhook.php b/sites/tomsjavajive.com/public_html/api/webhook.php new file mode 100644 index 0000000..02701ee --- /dev/null +++ b/sites/tomsjavajive.com/public_html/api/webhook.php @@ -0,0 +1,69 @@ +update('orders', + [ + 'payment_status' => 'refunded', + 'order_status' => 'refunded', + ], + 'square_payment_id = :pid', + ['pid' => $paymentId] + ); + } + break; +} + +http_response_code(200); +echo json_encode(['received' => true]); diff --git a/sites/tomsjavajive.com/public_html/api/wishlist.php b/sites/tomsjavajive.com/public_html/api/wishlist.php new file mode 100644 index 0000000..3f3565a --- /dev/null +++ b/sites/tomsjavajive.com/public_html/api/wishlist.php @@ -0,0 +1,95 @@ + 'Please log in to manage your wishlist'], 401); +} + +$customer = CustomerAuth::getFullUser(); +$input = json_decode(file_get_contents('php://input'), true); +$action = $input['action'] ?? $_GET['action'] ?? ''; +$productId = $input['product_id'] ?? $_GET['product_id'] ?? ''; + +switch ($action) { + case 'add': + if (empty($productId)) { + jsonResponse(['error' => 'Product ID required'], 400); + } + + // Check if product exists + $product = db()->fetch("SELECT product_id FROM products WHERE product_id = :id", ['id' => $productId]); + if (!$product) { + jsonResponse(['error' => 'Product not found'], 404); + } + + // Check if already in wishlist + $existing = db()->fetch( + "SELECT id FROM wishlist WHERE customer_id = :cid AND product_id = :pid", + ['cid' => $customer['customer_id'], 'pid' => $productId] + ); + + if ($existing) { + jsonResponse(['success' => true, 'message' => 'Already in wishlist']); + } + + db()->insert('wishlist', [ + 'customer_id' => $customer['customer_id'], + 'product_id' => $productId + ]); + + jsonResponse(['success' => true, 'message' => 'Added to wishlist']); + break; + + case 'remove': + if (empty($productId)) { + jsonResponse(['error' => 'Product ID required'], 400); + } + + db()->query( + "DELETE FROM wishlist WHERE customer_id = :cid AND product_id = :pid", + ['cid' => $customer['customer_id'], 'pid' => $productId] + ); + + jsonResponse(['success' => true, 'message' => 'Removed from wishlist']); + break; + + case 'check': + if (empty($productId)) { + jsonResponse(['error' => 'Product ID required'], 400); + } + + $exists = db()->fetch( + "SELECT id FROM wishlist WHERE customer_id = :cid AND product_id = :pid", + ['cid' => $customer['customer_id'], 'pid' => $productId] + ); + + jsonResponse(['in_wishlist' => (bool)$exists]); + break; + + case 'list': + $items = db()->fetchAll( + "SELECT p.product_id, p.name, p.slug, p.price, p.sale_price, p.images, p.stock + FROM wishlist w + JOIN products p ON w.product_id = p.product_id + WHERE w.customer_id = :id + ORDER BY w.created_at DESC", + ['id' => $customer['customer_id']] + ); + + foreach ($items as &$item) { + $item['images'] = json_decode($item['images'] ?? '[]', true); + } + + jsonResponse(['items' => $items]); + break; + + default: + jsonResponse(['error' => 'Invalid action'], 400); +} diff --git a/sites/tomsjavajive.com/public_html/assets/css/account.css b/sites/tomsjavajive.com/public_html/assets/css/account.css new file mode 100644 index 0000000..1291293 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/assets/css/account.css @@ -0,0 +1,223 @@ +/* Account Layout */ +.account-layout { + display: grid; + grid-template-columns: 250px 1fr; + gap: 2rem; +} + +.account-sidebar { + background: var(--color-surface); + border-radius: var(--radius-lg); + padding: 1.5rem; + height: fit-content; + position: sticky; + top: 90px; +} + +.account-avatar { + width: 80px; + height: 80px; + background: var(--color-primary); + color: white; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 2rem; + margin: 0 auto 1rem; +} + +.account-sidebar-user { + text-align: center; + margin-bottom: 1.5rem; +} + +.account-sidebar-user h3 { + margin: 0 0 0.25rem; + font-size: 1rem; +} + +.account-sidebar-user p { + font-size: 0.875rem; + margin: 0; + color: var(--color-text-muted); +} + +.account-nav { + list-style: none; +} + +.account-nav li { + margin-bottom: 0.5rem; +} + +.account-nav a { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + border-radius: var(--radius-md); + color: var(--color-text); + transition: all 0.2s; +} + +.account-nav a:hover { + background: var(--color-background); + color: var(--color-primary); +} + +.account-nav a.active { + background: var(--color-primary); + color: white; +} + +.account-nav a i { + width: 20px; + text-align: center; +} + +.account-nav a.danger { + color: var(--color-error); +} + +.account-content { + min-height: 500px; +} + +.account-header { + margin-bottom: 2rem; +} + +.account-header h1 { + font-size: 1.75rem; + margin-bottom: 0.5rem; +} + +/* Section Cards */ +.section-card { + background: var(--color-surface); + border-radius: var(--radius-lg); + margin-bottom: 1.5rem; +} + +.section-card-header { + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--color-border); + display: flex; + justify-content: space-between; + align-items: center; +} + +.section-card-header h3 { + margin: 0; + font-size: 1rem; +} + +.section-card-body { + padding: 1.5rem; +} + +/* Order List Items */ +.order-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 0; + border-bottom: 1px solid var(--color-border); +} + +.order-item:last-child { + border-bottom: none; + padding-bottom: 0; +} + +.order-item:first-child { + padding-top: 0; +} + +.order-info h4 { + margin: 0 0 0.25rem; + font-size: 0.9375rem; +} + +.order-info p { + margin: 0; + color: var(--color-text-muted); + font-size: 0.875rem; +} + +.order-meta { + text-align: right; +} + +.order-meta .amount { + font-weight: 600; + margin-bottom: 0.25rem; +} + +/* Dashboard Stats Grid */ +.dashboard-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 1rem; + margin-bottom: 2rem; +} + +.dashboard-stat { + background: var(--color-surface); + border-radius: var(--radius-lg); + padding: 1.5rem; + text-align: center; +} + +.dashboard-stat-icon { + width: 50px; + height: 50px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto 1rem; + font-size: 1.25rem; +} + +.dashboard-stat-icon.primary { + background: rgba(255, 94, 26, 0.1); + color: var(--color-primary); +} + +.dashboard-stat-icon.success { + background: rgba(16, 185, 129, 0.1); + color: var(--color-success); +} + +.dashboard-stat-icon.warning { + background: rgba(245, 158, 11, 0.1); + color: var(--color-warning); +} + +.dashboard-stat-value { + font-size: 1.5rem; + font-weight: 700; + margin-bottom: 0.25rem; +} + +.dashboard-stat-label { + color: var(--color-text-muted); + font-size: 0.875rem; +} + +/* Responsive */ +@media (max-width: 768px) { + .account-layout { + grid-template-columns: 1fr; + } + + .account-sidebar { + position: static; + } + + .dashboard-grid { + grid-template-columns: repeat(2, 1fr); + } +} diff --git a/sites/tomsjavajive.com/public_html/assets/css/admin.css b/sites/tomsjavajive.com/public_html/assets/css/admin.css new file mode 100644 index 0000000..6d71986 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/assets/css/admin.css @@ -0,0 +1,543 @@ +/** + * Tom's Java Jive - Admin Panel Styles + */ + +:root { + /* Admin Colors */ + --admin-sidebar-bg: #1F2937; + --admin-sidebar-hover: #374151; + --admin-sidebar-active: var(--color-primary); + --admin-header-bg: #FFFFFF; + --admin-content-bg: #F9FAFB; + --admin-text-light: #9CA3AF; +} + +/* ================================ + Admin Layout + ================================ */ +.admin-layout { + display: flex; + min-height: 100vh; +} + +.admin-sidebar { + width: 260px; + background: var(--admin-sidebar-bg); + color: white; + display: flex; + flex-direction: column; + position: fixed; + top: 0; + left: 0; + bottom: 0; + z-index: 100; + transition: transform 0.3s ease; +} + +.admin-sidebar-header { + padding: 1.5rem; + border-bottom: 1px solid rgba(255,255,255,0.1); +} + +.admin-logo { + display: flex; + align-items: center; + gap: 0.75rem; + color: white; + font-family: var(--font-heading); + font-size: 1.25rem; + font-weight: 600; +} + +.admin-logo img { + height: 32px; +} + +.admin-nav { + flex: 1; + padding: 1rem 0; + overflow-y: auto; +} + +.admin-nav-section { + margin-bottom: 1.5rem; +} + +.admin-nav-title { + padding: 0.5rem 1.5rem; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--admin-text-light); +} + +.admin-nav-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1.5rem; + color: rgba(255,255,255,0.8); + transition: all 0.2s ease; +} + +.admin-nav-item:hover { + background: var(--admin-sidebar-hover); + color: white; +} + +.admin-nav-item.active { + background: var(--admin-sidebar-active); + color: white; +} + +.admin-nav-item i { + width: 20px; + text-align: center; +} + +.admin-nav-badge { + margin-left: auto; + padding: 0.125rem 0.5rem; + font-size: 0.75rem; + font-weight: 600; + background: var(--color-error); + color: white; + border-radius: 9999px; +} + +.admin-main { + flex: 1; + margin-left: 260px; + background: var(--admin-content-bg); + min-height: 100vh; +} + +.admin-header { + background: var(--admin-header-bg); + padding: 1rem 1.5rem; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--color-border); + position: sticky; + top: 0; + z-index: 50; +} + +.admin-header-left { + display: flex; + align-items: center; + gap: 1rem; +} + +.admin-search { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: var(--color-background); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + min-width: 300px; +} + +.admin-search input { + border: none; + background: transparent; + outline: none; + width: 100%; +} + +.admin-header-right { + display: flex; + align-items: center; + gap: 1rem; +} + +.admin-user { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem; + border-radius: var(--radius-md); + cursor: pointer; +} + +.admin-user:hover { + background: var(--color-background); +} + +.admin-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + background: var(--color-primary); + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: 600; +} + +.admin-content { + padding: 1.5rem; +} + +/* ================================ + Admin Cards + ================================ */ +.admin-card { + background: var(--color-surface); + border-radius: var(--radius-lg); + border: 1px solid var(--color-border); + margin-bottom: 1.5rem; +} + +.admin-card-header { + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--color-border); + display: flex; + align-items: center; + justify-content: space-between; +} + +.admin-card-title { + font-size: 1rem; + font-weight: 600; + margin: 0; +} + +.admin-card-body { + padding: 1.5rem; +} + +/* ================================ + Stats Cards + ================================ */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1.5rem; + margin-bottom: 1.5rem; +} + +.stat-card { + background: var(--color-surface); + border-radius: var(--radius-lg); + border: 1px solid var(--color-border); + padding: 1.5rem; + display: flex; + align-items: flex-start; + gap: 1rem; +} + +.stat-card-icon { + width: 48px; + height: 48px; + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.25rem; +} + +.stat-card-icon.primary { + background: rgba(232, 106, 51, 0.1); + color: var(--color-primary); +} + +.stat-card-icon.success { + background: rgba(16, 185, 129, 0.1); + color: var(--color-success); +} + +.stat-card-icon.warning { + background: rgba(245, 158, 11, 0.1); + color: var(--color-warning); +} + +.stat-card-icon.error { + background: rgba(239, 68, 68, 0.1); + color: var(--color-error); +} + +.stat-card-value { + font-size: 1.5rem; + font-weight: 700; + color: var(--color-text); +} + +.stat-card-label { + font-size: 0.875rem; + color: var(--color-text-muted); +} + +/* ================================ + Admin Tables + ================================ */ +.admin-table { + width: 100%; + border-collapse: collapse; +} + +.admin-table th, +.admin-table td { + padding: 0.875rem 1rem; + text-align: left; + border-bottom: 1px solid var(--color-border); +} + +.admin-table th { + font-weight: 600; + font-size: 0.875rem; + color: var(--color-text-muted); + background: var(--color-background); +} + +.admin-table tbody tr:hover { + background: var(--color-background); +} + +.admin-table td a { + color: var(--color-primary); + font-weight: 500; +} + +/* ================================ + Page Header + ================================ */ +.page-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1.5rem; +} + +.page-title { + font-size: 1.5rem; + margin: 0; +} + +/* ================================ + Form Improvements for Admin + ================================ */ +.admin-form-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1.5rem; +} + +.admin-form-grid .full-width { + grid-column: 1 / -1; +} + +/* ================================ + Responsive Admin + ================================ */ +.sidebar-toggle { + display: none; + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: var(--color-text); +} + +@media (max-width: 1024px) { + .admin-sidebar { + transform: translateX(-100%); + } + + .admin-sidebar.open { + transform: translateX(0); + } + + .admin-main { + margin-left: 0; + } + + .sidebar-toggle { + display: block; + } + + .admin-search { + min-width: 200px; + } +} + +@media (max-width: 768px) { + .stats-grid { + grid-template-columns: repeat(2, 1fr); + } + + .admin-form-grid { + grid-template-columns: 1fr; + } + + .admin-table { + display: block; + overflow-x: auto; + } +} + +/* ================================ + POS Specific Styles + ================================ */ +.pos-layout { + display: grid; + grid-template-columns: 1fr 400px; + gap: 1.5rem; + height: calc(100vh - 140px); +} + +.pos-products { + overflow-y: auto; +} + +.pos-cart { + background: var(--color-surface); + border-radius: var(--radius-lg); + border: 1px solid var(--color-border); + display: flex; + flex-direction: column; +} + +.pos-cart-header { + padding: 1rem; + border-bottom: 1px solid var(--color-border); +} + +.pos-cart-items { + flex: 1; + overflow-y: auto; + padding: 1rem; +} + +.pos-cart-item { + display: flex; + gap: 0.75rem; + padding: 0.75rem 0; + border-bottom: 1px solid var(--color-border); +} + +.pos-cart-footer { + padding: 1rem; + border-top: 1px solid var(--color-border); + background: var(--color-background); +} + +.pos-totals { + margin-bottom: 1rem; +} + +.pos-total-row { + display: flex; + justify-content: space-between; + margin-bottom: 0.5rem; +} + +.pos-total-row.grand-total { + font-size: 1.25rem; + font-weight: 700; + padding-top: 0.5rem; + border-top: 1px solid var(--color-border); +} + +.pos-product-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 1rem; +} + +.pos-product-card { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: 0.75rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.pos-product-card:hover { + border-color: var(--color-primary); + box-shadow: var(--shadow-md); +} + +.pos-product-card img { + width: 100%; + aspect-ratio: 1; + object-fit: cover; + border-radius: var(--radius-sm); + margin-bottom: 0.5rem; +} + +.pos-product-name { + font-size: 0.875rem; + font-weight: 500; + margin-bottom: 0.25rem; +} + +.pos-product-price { + font-size: 0.875rem; + color: var(--color-primary); + font-weight: 600; +} + +/* Payment Buttons */ +.payment-methods { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.75rem; + margin-top: 1rem; +} + +.payment-btn { + padding: 1rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + border: 2px solid var(--color-border); + background: var(--color-surface); + border-radius: var(--radius-md); + cursor: pointer; + transition: all 0.2s ease; +} + +.payment-btn:hover { + border-color: var(--color-primary); +} + +.payment-btn.selected { + border-color: var(--color-primary); + background: rgba(232, 106, 51, 0.05); +} + +.payment-btn i { + font-size: 1.5rem; + color: var(--color-primary); +} + +@media (max-width: 1024px) { + .pos-layout { + grid-template-columns: 1fr; + height: auto; + } + + .pos-cart { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 50vh; + transform: translateY(calc(100% - 60px)); + transition: transform 0.3s ease; + z-index: 100; + border-radius: var(--radius-lg) var(--radius-lg) 0 0; + } + + .pos-cart.expanded { + transform: translateY(0); + } +} diff --git a/sites/tomsjavajive.com/public_html/assets/css/cart.css b/sites/tomsjavajive.com/public_html/assets/css/cart.css new file mode 100644 index 0000000..5bbf104 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/assets/css/cart.css @@ -0,0 +1,154 @@ +/* Cart Page Layout */ +.cart-layout { + display: grid; + grid-template-columns: 1fr 350px; + gap: 2rem; + align-items: start; +} + +.cart-empty { + padding: 3rem; + text-align: center; +} + +.cart-empty-icon { + font-size: 4rem; + color: var(--color-text-muted); + margin-bottom: 1rem; +} + +/* Cart Items */ +.cart-items-list { + display: grid; + gap: 1rem; +} + +.cart-item { + display: grid; + grid-template-columns: 80px 1fr auto auto; + gap: 1rem; + align-items: center; + padding-bottom: 1rem; + border-bottom: 1px solid var(--color-border); +} + +.cart-item:last-child { + border-bottom: none; + padding-bottom: 0; +} + +.cart-item-img { + width: 80px; + height: 80px; + object-fit: cover; + border-radius: var(--radius-md); +} + +.cart-item-name { + font-weight: 600; + margin-bottom: 0.25rem; +} + +.cart-item-price { + color: var(--color-text-muted); + margin: 0; +} + +/* Quantity Selector */ +.quantity-selector { + display: flex; + align-items: center; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); +} + +.qty-btn { + padding: 0.25rem 0.75rem; + background: none; + border: none; + cursor: pointer; + font-size: 1rem; + color: var(--color-text); + transition: var(--transition); +} + +.qty-btn:hover { + color: var(--color-primary); +} + +.qty-input { + width: 50px; + text-align: center; + border: none; + outline: none; + font-size: 1rem; + font-family: inherit; +} + +.cart-item-total { + text-align: right; +} + +.cart-item-total .amount { + font-weight: 600; + margin-bottom: 0.25rem; +} + +.btn-remove { + background: none; + border: none; + color: var(--color-error); + cursor: pointer; + font-size: 0.875rem; + padding: 0; +} + +/* Order Summary Sidebar */ +.cart-summary { + position: sticky; + top: 100px; +} + +.cart-summary-row { + display: flex; + justify-content: space-between; + margin-bottom: 0.5rem; +} + +.cart-summary-total { + display: flex; + justify-content: space-between; + font-size: 1.25rem; + font-weight: 600; +} + +.free-shipping-msg { + font-size: 0.875rem; + color: var(--color-primary); + margin: 0.5rem 0; +} + +@media (max-width: 768px) { + .cart-layout { + grid-template-columns: 1fr; + } + + .cart-summary { + position: static; + } + + .cart-item { + grid-template-columns: 64px 1fr; + grid-template-rows: auto auto; + } + + .cart-item-img { + width: 64px; + height: 64px; + } + + .quantity-selector, + .cart-item-total { + grid-column: 2; + } +} diff --git a/sites/tomsjavajive.com/public_html/assets/css/checkout.css b/sites/tomsjavajive.com/public_html/assets/css/checkout.css new file mode 100644 index 0000000..4ca76a0 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/assets/css/checkout.css @@ -0,0 +1,103 @@ +/* Checkout Page Layout */ +.checkout-layout { + display: grid; + grid-template-columns: 1fr 400px; + gap: 2rem; + align-items: start; +} + +/* Address grid: city / state / zip */ +.address-grid { + display: grid; + grid-template-columns: 2fr 1fr 1fr; + gap: 1rem; +} + +/* Order Summary Sidebar */ +.checkout-summary { + position: sticky; + top: 100px; +} + +/* Scrollable cart preview in sidebar */ +.checkout-items-preview { + max-height: 250px; + overflow-y: auto; + margin-bottom: 1rem; +} + +.checkout-item { + display: flex; + gap: 1rem; + margin-bottom: 1rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--color-border); +} + +.checkout-item:last-child { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; +} + +.checkout-item-img { + width: 60px; + height: 60px; + object-fit: cover; + border-radius: var(--radius-md); + flex-shrink: 0; +} + +.checkout-item-info { + flex: 1; +} + +.checkout-item-info p { + font-weight: 500; + margin-bottom: 0.25rem; +} + +.checkout-item-info small { + color: var(--color-text-muted); + font-size: 0.875rem; +} + +.checkout-item-total { + text-align: right; + font-weight: 600; +} + +.checkout-summary-row { + display: flex; + justify-content: space-between; + margin-bottom: 0.5rem; +} + +.checkout-summary-total { + display: flex; + justify-content: space-between; + font-size: 1.25rem; + font-weight: 600; +} + +.secure-badge { + font-size: 0.75rem; + color: var(--color-text-muted); + text-align: center; + margin-top: 1rem; +} + +@media (max-width: 768px) { + .checkout-layout { + grid-template-columns: 1fr; + } + + .checkout-summary { + position: static; + order: -1; + } + + .address-grid { + grid-template-columns: 1fr 1fr; + } +} diff --git a/sites/tomsjavajive.com/public_html/assets/css/home.css b/sites/tomsjavajive.com/public_html/assets/css/home.css new file mode 100644 index 0000000..f5e5f37 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/assets/css/home.css @@ -0,0 +1,182 @@ +/* Hero Section */ +.hero { + background: linear-gradient(135deg, rgba(27,27,27,0.7) 0%, rgba(27,27,27,0.4) 100%), + url('/assets/images/hero-coffee.jpg'); + background-size: cover; + background-position: center; + min-height: 600px; + display: flex; + align-items: center; + text-align: center; + color: white; + padding: 4rem 0; +} + +.hero h1 { + font-family: var(--font-display); + font-size: 3.5rem; + font-weight: 700; + margin-bottom: 1rem; + line-height: 1.2; +} + +.hero p { + font-size: 1.25rem; + margin-bottom: 2rem; + opacity: 0.9; + max-width: 600px; + margin-left: auto; + margin-right: auto; +} + +/* Features Section */ +.features-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 2rem; +} + +.feature-card { + text-align: center; + padding: 2rem; +} + +.feature-icon { + width: 64px; + height: 64px; + background: linear-gradient(135deg, var(--color-primary), var(--color-primary-dark)); + border-radius: var(--radius-lg); + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto 1.5rem; + color: white; + font-size: 1.5rem; +} + +.feature-card h3 { + font-size: 1.25rem; + font-weight: 600; + margin-bottom: 0.75rem; +} + +.feature-card p { + color: var(--color-text-muted); + font-size: 0.9375rem; +} + +/* Newsletter Section */ +.newsletter { + background: linear-gradient(135deg, var(--color-secondary) 0%, #5D2E0A 100%); + color: white; + padding: 4rem 0; + text-align: center; +} + +.newsletter h2 { + font-family: var(--font-display); + font-size: 2rem; + margin-bottom: 0.5rem; +} + +.newsletter p { + opacity: 0.9; + margin-bottom: 1.5rem; +} + +.newsletter-form { + display: flex; + gap: 0.75rem; + max-width: 450px; + margin: 0 auto; +} + +.newsletter-form input { + flex: 1; + padding: 0.875rem 1rem; + border: none; + border-radius: var(--radius-md); + font-size: 1rem; +} + +.newsletter-form button { + padding: 0.875rem 1.5rem; + background: var(--color-primary); + color: white; + border: none; + border-radius: var(--radius-md); + font-weight: 600; + cursor: pointer; + transition: var(--transition); +} + +.newsletter-form button:hover { + background: var(--color-primary-dark); +} + +/* Coffee Splash Blocks */ +.splash-section { overflow: hidden; } + +.splash-scroll-wrap { + position: relative; + display: flex; + align-items: center; + gap: .5rem; +} + +.splash-scroll-track { + display: flex; + gap: 1.5rem; + overflow-x: auto; + scroll-behavior: smooth; + scrollbar-width: none; + -ms-overflow-style: none; + padding: .5rem 0; + flex: 1; +} + +.splash-scroll-track::-webkit-scrollbar { display: none; } + +.splash-item { + min-width: 220px; + flex-shrink: 0; +} + +.splash-arrow { + flex-shrink: 0; + width: 40px; + height: 40px; + border-radius: 50%; + border: 2px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all .2s; + z-index: 2; +} + +.splash-arrow:hover { + background: var(--color-primary); + border-color: var(--color-primary); + color: white; +} + +.splash-arrow:disabled { + opacity: .3; + cursor: default; +} + +@media (max-width: 768px) { + .hero h1 { font-size: 2.5rem; } + .hero p { font-size: 1.125rem; } + .newsletter-form { flex-direction: column; } + .splash-arrow { display: none; } + .splash-scroll-track { padding-bottom: .5rem; } +} + +@media (max-width: 480px) { + .hero h1 { font-size: 2rem; } +} diff --git a/sites/tomsjavajive.com/public_html/assets/css/products.css b/sites/tomsjavajive.com/public_html/assets/css/products.css new file mode 100644 index 0000000..e408c81 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/assets/css/products.css @@ -0,0 +1,118 @@ +/* Product Grid */ +.product-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 2rem; +} + +/* Product Card */ +.product-card { + background: var(--color-surface); + border-radius: var(--radius-lg); + overflow: hidden; + transition: var(--transition); + box-shadow: var(--shadow-sm); +} + +.product-card:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-xl); +} + +.product-card-image { + display: block; + position: relative; + padding-top: 100%; + overflow: hidden; +} + +.product-card-image img { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.5s ease; +} + +.product-card:hover .product-card-image img { + transform: scale(1.05); +} + +.product-card-badge { + position: absolute; + top: 1rem; + left: 1rem; + background: var(--color-primary); + color: white; + padding: 0.25rem 0.75rem; + border-radius: var(--radius-full); + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; +} + +.product-card-body { + padding: 1.25rem; +} + +.product-card-category { + font-size: 0.8125rem; + color: var(--color-primary); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 0.5rem; +} + +.product-card-title { + font-size: 1.125rem; + font-weight: 600; + margin-bottom: 0.5rem; + color: var(--color-text); +} + +.product-card-title a { + color: inherit; +} + +.product-card-title a:hover { + color: var(--color-primary); +} + +.product-card-price { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.product-card-price .current { + font-size: 1.25rem; + font-weight: 700; + color: var(--color-primary); +} + +.product-card-price .original { + font-size: 0.9375rem; + color: var(--color-text-muted); + text-decoration: line-through; +} + +.product-card-rating { + display: flex; + align-items: center; + gap: 0.25rem; + color: #F59E0B; + font-size: 0.875rem; + margin-bottom: 1rem; +} + +.product-card-rating span { + color: var(--color-text-muted); + margin-left: 0.25rem; +} + +@media (max-width: 480px) { + .product-grid { grid-template-columns: 1fr; } +} diff --git a/sites/tomsjavajive.com/public_html/assets/css/style.css b/sites/tomsjavajive.com/public_html/assets/css/style.css new file mode 100644 index 0000000..583954f --- /dev/null +++ b/sites/tomsjavajive.com/public_html/assets/css/style.css @@ -0,0 +1,538 @@ +/** + * Tom's Java Jive - Main Stylesheet + * Matching original React design + */ + +:root { + --color-primary: #FF5E1A; + --color-primary-dark: #E54D0D; + --color-secondary: #8B4513; + --color-accent: #FF5E1A; + --color-background: #FDFBF7; + --color-surface: #FFFFFF; + --color-text: #1B1B1B; + --color-text-muted: #6B7280; + --color-text-light: #9CA3AF; + --color-border: #E8E2D9; + --color-success: #10B981; + --color-warning: #F59E0B; + --color-error: #EF4444; + + --font-primary: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + --font-display: 'Playfair Display', Georgia, serif; + + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 16px; + --radius-full: 9999px; + + --shadow-sm: 0 1px 2px rgba(0,0,0,0.05); + --shadow-md: 0 4px 6px -1px rgba(0,0,0,0.1); + --shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.1); + --shadow-xl: 0 20px 25px -5px rgba(0,0,0,0.1); + + --transition: all 0.2s ease; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + scroll-behavior: smooth; +} + +body { + font-family: var(--font-primary); + font-size: 16px; + line-height: 1.6; + color: var(--color-text); + background: var(--color-background); + -webkit-font-smoothing: antialiased; +} + +a { + color: var(--color-primary); + text-decoration: none; + transition: var(--transition); +} + +a:hover { + color: var(--color-primary-dark); +} + +img { + max-width: 100%; + height: auto; +} + +/* Container */ +.container { + width: 100%; + max-width: 1200px; + margin: 0 auto; + padding: 0 1.5rem; +} + +/* Header / Navigation */ +.header { + background: var(--color-surface); + border-bottom: 1px solid var(--color-border); + position: sticky; + top: 0; + z-index: 100; +} + +.nav { + display: flex; + align-items: center; + justify-content: space-between; + height: 70px; +} + +.logo { + display: flex; + align-items: center; + gap: 0.75rem; + font-weight: 700; + font-size: 1.25rem; + color: var(--color-text); +} + +.logo:hover { + color: var(--color-text); +} + +.logo-img { + height: 70px; + width: auto; +} + +.logo-text { + font-family: var(--font-display); + font-size: 1.5rem; + color: var(--color-secondary); +} + +.nav-links { + display: flex; + align-items: center; + gap: 2rem; + list-style: none; +} + +.nav-links a { + color: var(--color-text); + font-weight: 500; + font-size: 0.9375rem; + padding: 0.5rem 0; + position: relative; +} + +.nav-links a:hover { + color: var(--color-primary); +} + +.nav-links a.active { + color: var(--color-primary); +} + +.nav-links a::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + width: 0; + height: 2px; + background: var(--color-primary); + transition: width 0.3s ease; +} + +.nav-links a:hover::after, +.nav-links a.active::after { + width: 100%; +} + +.nav-actions { + display: flex; + align-items: center; + gap: 1rem; +} + +.cart-btn { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + border-radius: var(--radius-full); + background: var(--color-background); + color: var(--color-text); + font-size: 1.25rem; + transition: var(--transition); +} + +.cart-btn:hover { + background: var(--color-primary); + color: white; +} + +.cart-count { + position: absolute; + top: -4px; + right: -4px; + background: var(--color-primary); + color: white; + font-size: 0.75rem; + font-weight: 600; + min-width: 20px; + height: 20px; + border-radius: var(--radius-full); + display: flex; + align-items: center; + justify-content: center; +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.75rem 1.5rem; + font-size: 1rem; + font-weight: 600; + border-radius: var(--radius-md); + border: none; + cursor: pointer; + transition: var(--transition); + text-decoration: none; +} + +.btn-primary { + background: var(--color-primary); + color: white; +} + +.btn-primary:hover { + background: var(--color-primary-dark); + color: white; + transform: translateY(-2px); + box-shadow: var(--shadow-lg); +} + +.btn-secondary { + background: white; + color: var(--color-text); + border: 2px solid var(--color-border); +} + +.btn-secondary:hover { + border-color: var(--color-primary); + color: var(--color-primary); +} + +.btn-outline { + background: transparent; + color: white; + border: 2px solid white; +} + +.btn-outline:hover { + background: white; + color: var(--color-text); +} + +.btn-lg { + padding: 1rem 2rem; + font-size: 1.125rem; +} + +.btn-sm { + padding: 0.5rem 1rem; + font-size: 0.875rem; +} + +.btn-block { + display: flex; + width: 100%; +} + +/* Sections */ +.section { + padding: 5rem 0; +} + +.section-header { + text-align: center; + margin-bottom: 3rem; +} + +.section-header h2 { + font-family: var(--font-display); + font-size: 2.5rem; + font-weight: 700; + margin-bottom: 0.5rem; + color: var(--color-text); +} + +.section-header p { + font-size: 1.125rem; + color: var(--color-text-muted); +} + +/* Cards */ +.card { + background: var(--color-surface); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + overflow: hidden; +} + +.card-header { + padding: 1.25rem; + border-bottom: 1px solid var(--color-border); +} + +.card-body { + padding: 1.5rem; +} + +/* Forms */ +.form-group { + margin-bottom: 1.25rem; +} + +.form-label { + display: block; + font-size: 0.875rem; + font-weight: 500; + margin-bottom: 0.5rem; + color: var(--color-text); +} + +.form-input, +.form-select, +.form-textarea { + width: 100%; + padding: 0.75rem 1rem; + font-size: 1rem; + border: 2px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-surface); + transition: var(--transition); + font-family: inherit; +} + +.form-input:focus, +.form-select:focus, +.form-textarea:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px rgba(255,94,26,0.1); +} + +.form-textarea { + min-height: 120px; + resize: vertical; +} + +.form-error { + color: var(--color-error); + font-size: 0.8125rem; + margin-top: 0.25rem; +} + +.form-checkbox { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; +} + +/* Alerts */ +.alert { + padding: 1rem 1.25rem; + border-radius: var(--radius-md); + margin-bottom: 1rem; + display: flex; + align-items: center; + gap: 0.75rem; +} + +.alert-success { + background: rgba(16, 185, 129, 0.1); + color: var(--color-success); + border: 1px solid rgba(16, 185, 129, 0.3); +} + +.alert-error { + background: rgba(239, 68, 68, 0.1); + color: var(--color-error); + border: 1px solid rgba(239, 68, 68, 0.3); +} + +.alert-warning { + background: rgba(245, 158, 11, 0.1); + color: var(--color-warning); + border: 1px solid rgba(245, 158, 11, 0.3); +} + +/* Badges */ +.badge { + display: inline-flex; + align-items: center; + padding: 0.25rem 0.75rem; + font-size: 0.75rem; + font-weight: 600; + border-radius: var(--radius-full); +} + +.badge-primary { + background: rgba(255,94,26,0.15); + color: var(--color-primary); +} + +.badge-success { + background: rgba(16, 185, 129, 0.15); + color: var(--color-success); +} + +.badge-warning { + background: rgba(245, 158, 11, 0.15); + color: var(--color-warning); +} + +.badge-error { + background: rgba(239, 68, 68, 0.15); + color: var(--color-error); +} + +/* Footer */ +.footer { + background: var(--color-text); + color: white; + padding: 2.5rem 0 1.5rem; +} + +.footer-grid { + display: grid; + grid-template-columns: 2fr 1fr 1fr; + gap: 2rem; + margin-bottom: 2rem; +} + +.footer-brand p { + color: rgba(255,255,255,0.7); + margin-top: 1rem; + font-size: 0.9375rem; +} + +.footer h4 { + font-size: 1rem; + font-weight: 600; + margin-bottom: 1.25rem; + color: white; +} + +.footer-links { + list-style: none; +} + +.footer-links li { + margin-bottom: 0.5rem; +} + +.footer-links a { + color: rgba(255,255,255,0.7); + font-size: 0.9375rem; + transition: var(--transition); +} + +.footer-links a:hover { + color: var(--color-primary); +} + +.footer-bottom { + padding-top: 2rem; + border-top: 1px solid rgba(255,255,255,0.1); + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.875rem; + color: rgba(255,255,255,0.5); +} + +.footer-social { + display: flex; + gap: 1rem; +} + +.footer-social a { + color: rgba(255,255,255,0.7); + font-size: 1.25rem; +} + +.footer-social a:hover { + color: var(--color-primary); +} + +/* Utilities */ +.text-center { text-align: center; } +.text-muted { color: var(--color-text-muted); } +.text-success { color: var(--color-success); } +.text-error { color: var(--color-error); } + +.mb-0 { margin-bottom: 0; } +.mb-1 { margin-bottom: 1rem; } +.mb-2 { margin-bottom: 1.5rem; } +.mt-1 { margin-top: 1rem; } +.mt-2 { margin-top: 1.5rem; } + +/* Loading Spinner */ +.loading { + display: inline-block; + width: 20px; + height: 20px; + border: 2px solid currentColor; + border-radius: 50%; + border-top-color: transparent; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Responsive */ +@media (max-width: 1024px) { + .footer-grid { + grid-template-columns: 1fr 1fr; + } +} + +@media (max-width: 768px) { + .section-header h2 { + font-size: 2rem; + } + + .nav-links { + display: none; + } + + .footer-grid { + grid-template-columns: 1fr; + gap: 2rem; + } + + .footer-bottom { + flex-direction: column; + gap: 1rem; + text-align: center; + } +} + diff --git a/sites/tomsjavajive.com/public_html/assets/icons/badge-72.svg b/sites/tomsjavajive.com/public_html/assets/icons/badge-72.svg new file mode 100644 index 0000000..b26a584 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/assets/icons/badge-72.svg @@ -0,0 +1,5 @@ + + + + TJ + \ No newline at end of file diff --git a/sites/tomsjavajive.com/public_html/assets/icons/icon-128.svg b/sites/tomsjavajive.com/public_html/assets/icons/icon-128.svg new file mode 100644 index 0000000..6aeb221 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/assets/icons/icon-128.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/sites/tomsjavajive.com/public_html/assets/icons/icon-144.svg b/sites/tomsjavajive.com/public_html/assets/icons/icon-144.svg new file mode 100644 index 0000000..26bd20c --- /dev/null +++ b/sites/tomsjavajive.com/public_html/assets/icons/icon-144.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/sites/tomsjavajive.com/public_html/assets/icons/icon-152.svg b/sites/tomsjavajive.com/public_html/assets/icons/icon-152.svg new file mode 100644 index 0000000..39a72d1 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/assets/icons/icon-152.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/sites/tomsjavajive.com/public_html/assets/icons/icon-192.png b/sites/tomsjavajive.com/public_html/assets/icons/icon-192.png new file mode 100644 index 0000000..c297f52 Binary files /dev/null and b/sites/tomsjavajive.com/public_html/assets/icons/icon-192.png differ diff --git a/sites/tomsjavajive.com/public_html/assets/icons/icon-192.svg b/sites/tomsjavajive.com/public_html/assets/icons/icon-192.svg new file mode 100644 index 0000000..2d3676b --- /dev/null +++ b/sites/tomsjavajive.com/public_html/assets/icons/icon-192.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/sites/tomsjavajive.com/public_html/assets/icons/icon-384.svg b/sites/tomsjavajive.com/public_html/assets/icons/icon-384.svg new file mode 100644 index 0000000..275b40f --- /dev/null +++ b/sites/tomsjavajive.com/public_html/assets/icons/icon-384.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/sites/tomsjavajive.com/public_html/assets/icons/icon-512.png b/sites/tomsjavajive.com/public_html/assets/icons/icon-512.png new file mode 100644 index 0000000..d62cd5e Binary files /dev/null and b/sites/tomsjavajive.com/public_html/assets/icons/icon-512.png differ diff --git a/sites/tomsjavajive.com/public_html/assets/icons/icon-512.svg b/sites/tomsjavajive.com/public_html/assets/icons/icon-512.svg new file mode 100644 index 0000000..a25dcb0 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/assets/icons/icon-512.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/sites/tomsjavajive.com/public_html/assets/icons/icon-72.svg b/sites/tomsjavajive.com/public_html/assets/icons/icon-72.svg new file mode 100644 index 0000000..708eaf1 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/assets/icons/icon-72.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/sites/tomsjavajive.com/public_html/assets/icons/icon-96.svg b/sites/tomsjavajive.com/public_html/assets/icons/icon-96.svg new file mode 100644 index 0000000..2b3fe58 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/assets/icons/icon-96.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/sites/tomsjavajive.com/public_html/assets/images/coffee-beans.jpg b/sites/tomsjavajive.com/public_html/assets/images/coffee-beans.jpg new file mode 100644 index 0000000..877b17e Binary files /dev/null and b/sites/tomsjavajive.com/public_html/assets/images/coffee-beans.jpg differ diff --git a/sites/tomsjavajive.com/public_html/assets/images/coffee-brewing.jpg b/sites/tomsjavajive.com/public_html/assets/images/coffee-brewing.jpg new file mode 100644 index 0000000..ff76a37 Binary files /dev/null and b/sites/tomsjavajive.com/public_html/assets/images/coffee-brewing.jpg differ diff --git a/sites/tomsjavajive.com/public_html/assets/images/favicon.ico b/sites/tomsjavajive.com/public_html/assets/images/favicon.ico new file mode 100644 index 0000000..7d936e0 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/assets/images/favicon.ico @@ -0,0 +1,312 @@ + + + + + + + + + Tom's Java Jive | Premium Coffee Beans & Fresh Roasted Coffee | Weatherford TX + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +

+ Made with Emergent +

+
+ + + diff --git a/sites/tomsjavajive.com/public_html/assets/images/friends-coffee.jpg b/sites/tomsjavajive.com/public_html/assets/images/friends-coffee.jpg new file mode 100644 index 0000000..16f770e Binary files /dev/null and b/sites/tomsjavajive.com/public_html/assets/images/friends-coffee.jpg differ diff --git a/sites/tomsjavajive.com/public_html/assets/images/hero-coffee.jpg b/sites/tomsjavajive.com/public_html/assets/images/hero-coffee.jpg new file mode 100644 index 0000000..8adc5e9 Binary files /dev/null and b/sites/tomsjavajive.com/public_html/assets/images/hero-coffee.jpg differ diff --git a/sites/tomsjavajive.com/public_html/assets/images/icon-192.png b/sites/tomsjavajive.com/public_html/assets/images/icon-192.png new file mode 100644 index 0000000..fa3c711 Binary files /dev/null and b/sites/tomsjavajive.com/public_html/assets/images/icon-192.png differ diff --git a/sites/tomsjavajive.com/public_html/assets/images/icon-512.png b/sites/tomsjavajive.com/public_html/assets/images/icon-512.png new file mode 100644 index 0000000..859494c Binary files /dev/null and b/sites/tomsjavajive.com/public_html/assets/images/icon-512.png differ diff --git a/sites/tomsjavajive.com/public_html/assets/images/logo-download-new.png b/sites/tomsjavajive.com/public_html/assets/images/logo-download-new.png new file mode 100644 index 0000000..49656b6 Binary files /dev/null and b/sites/tomsjavajive.com/public_html/assets/images/logo-download-new.png differ diff --git a/sites/tomsjavajive.com/public_html/assets/images/logo-download-original.png/current-logo.png b/sites/tomsjavajive.com/public_html/assets/images/logo-download-original.png/current-logo.png new file mode 100644 index 0000000..5680c6a Binary files /dev/null and b/sites/tomsjavajive.com/public_html/assets/images/logo-download-original.png/current-logo.png differ diff --git a/sites/tomsjavajive.com/public_html/assets/images/logo-download-original.png/tjj-logo-transparent.png b/sites/tomsjavajive.com/public_html/assets/images/logo-download-original.png/tjj-logo-transparent.png new file mode 100644 index 0000000..49656b6 Binary files /dev/null and b/sites/tomsjavajive.com/public_html/assets/images/logo-download-original.png/tjj-logo-transparent.png differ diff --git a/sites/tomsjavajive.com/public_html/assets/images/logo-icon.png b/sites/tomsjavajive.com/public_html/assets/images/logo-icon.png new file mode 100644 index 0000000..49656b6 Binary files /dev/null and b/sites/tomsjavajive.com/public_html/assets/images/logo-icon.png differ diff --git a/sites/tomsjavajive.com/public_html/assets/images/logo-icon.svg b/sites/tomsjavajive.com/public_html/assets/images/logo-icon.svg new file mode 100644 index 0000000..5c768c2 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/assets/images/logo-icon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/sites/tomsjavajive.com/public_html/assets/images/logo.png b/sites/tomsjavajive.com/public_html/assets/images/logo.png new file mode 100644 index 0000000..43291bc Binary files /dev/null and b/sites/tomsjavajive.com/public_html/assets/images/logo.png differ diff --git a/sites/tomsjavajive.com/public_html/assets/images/logo.svg b/sites/tomsjavajive.com/public_html/assets/images/logo.svg new file mode 100644 index 0000000..b4fd11a --- /dev/null +++ b/sites/tomsjavajive.com/public_html/assets/images/logo.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + Tom's + Java Jive + \ No newline at end of file diff --git a/sites/tomsjavajive.com/public_html/assets/images/og-image.jpg b/sites/tomsjavajive.com/public_html/assets/images/og-image.jpg new file mode 100644 index 0000000..1122d78 Binary files /dev/null and b/sites/tomsjavajive.com/public_html/assets/images/og-image.jpg differ diff --git a/sites/tomsjavajive.com/public_html/assets/images/placeholder-product.svg b/sites/tomsjavajive.com/public_html/assets/images/placeholder-product.svg new file mode 100644 index 0000000..d6a9813 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/assets/images/placeholder-product.svg @@ -0,0 +1,7 @@ + + + + + + Product Image + \ No newline at end of file diff --git a/sites/tomsjavajive.com/public_html/assets/images/premium-coffee.jpg b/sites/tomsjavajive.com/public_html/assets/images/premium-coffee.jpg new file mode 100644 index 0000000..4632c9e Binary files /dev/null and b/sites/tomsjavajive.com/public_html/assets/images/premium-coffee.jpg differ diff --git a/sites/tomsjavajive.com/public_html/assets/images/tjj-logo-website.png b/sites/tomsjavajive.com/public_html/assets/images/tjj-logo-website.png new file mode 100644 index 0000000..43291bc Binary files /dev/null and b/sites/tomsjavajive.com/public_html/assets/images/tjj-logo-website.png differ diff --git a/sites/tomsjavajive.com/public_html/assets/images/tjj-logo-with-url.png b/sites/tomsjavajive.com/public_html/assets/images/tjj-logo-with-url.png new file mode 100644 index 0000000..3e51b6c Binary files /dev/null and b/sites/tomsjavajive.com/public_html/assets/images/tjj-logo-with-url.png differ diff --git a/sites/tomsjavajive.com/public_html/assets/js/main.js b/sites/tomsjavajive.com/public_html/assets/js/main.js new file mode 100644 index 0000000..17eb693 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/assets/js/main.js @@ -0,0 +1,394 @@ +/** + * Tom's Java Jive - Main JavaScript + */ + +// ================================ +// Toast Notifications +// ================================ +const ToastManager = { + container: null, + + init() { + if (!this.container) { + this.container = document.createElement('div'); + this.container.className = 'toast-container'; + document.body.appendChild(this.container); + } + }, + + show(message, type = 'success', duration = 3000) { + this.init(); + + const toast = document.createElement('div'); + toast.className = `toast ${type}`; + toast.innerHTML = ` + + ${message} + `; + + this.container.appendChild(toast); + + setTimeout(() => { + toast.style.animation = 'slideIn 0.3s ease reverse'; + setTimeout(() => toast.remove(), 300); + }, duration); + }, + + success(message) { this.show(message, 'success'); }, + error(message) { this.show(message, 'error'); }, + info(message) { this.show(message, 'info'); } +}; + +// ================================ +// Cart Functions +// ================================ +async function addToCart(productId, quantity = 1) { + try { + const response = await fetch('/api/cart.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'add', + product_id: productId, + quantity: parseInt(quantity) + }) + }); + + const data = await response.json(); + + if (data.error) { + ToastManager.error(data.error); + return; + } + + // Update cart count in header + updateCartCount(data.cart_count); + ToastManager.success(data.message || 'Added to cart!'); + + } catch (error) { + console.error('Add to cart error:', error); + ToastManager.error('Failed to add item to cart'); + } +} + +async function updateCartItem(productId, quantity) { + try { + const response = await fetch('/api/cart.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'update', + product_id: productId, + quantity: parseInt(quantity) + }) + }); + + const data = await response.json(); + + if (data.error) { + ToastManager.error(data.error); + return; + } + + // Update cart count + updateCartCount(data.cart_count); + + // Reload page if cart is now empty or update display + if (data.cart_count === 0) { + location.reload(); + } else if (typeof updateCartDisplay === 'function') { + updateCartDisplay(data); + } else { + location.reload(); + } + + } catch (error) { + console.error('Update cart error:', error); + ToastManager.error('Failed to update cart'); + } +} + +async function removeFromCart(productId) { + try { + const response = await fetch('/api/cart.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'remove', + product_id: productId + }) + }); + + const data = await response.json(); + + if (data.error) { + ToastManager.error(data.error); + return; + } + + updateCartCount(data.cart_count); + + // Remove item from DOM or reload + const cartItem = document.querySelector(`.cart-item[data-product-id="${productId}"]`); + if (cartItem) { + cartItem.style.animation = 'slideIn 0.3s ease reverse'; + setTimeout(() => { + cartItem.remove(); + if (data.cart_count === 0) { + location.reload(); + } + }, 300); + } else { + location.reload(); + } + + ToastManager.success('Item removed from cart'); + + } catch (error) { + console.error('Remove from cart error:', error); + ToastManager.error('Failed to remove item'); + } +} + +function updateCartCount(count) { + const cartCountEl = document.querySelector('.cart-count'); + if (cartCountEl) { + if (count > 0) { + cartCountEl.textContent = count; + cartCountEl.style.display = 'flex'; + } else { + cartCountEl.style.display = 'none'; + } + } else if (count > 0) { + const cartLink = document.querySelector('.cart-link'); + if (cartLink) { + const badge = document.createElement('span'); + badge.className = 'cart-count'; + badge.textContent = count; + cartLink.appendChild(badge); + } + } +} + +// ================================ +// Quantity Selectors +// ================================ +document.addEventListener('DOMContentLoaded', function() { + // Quantity +/- buttons + document.querySelectorAll('.qty-minus').forEach(btn => { + btn.addEventListener('click', function() { + const input = this.closest('.quantity-selector').querySelector('.qty-input'); + const current = parseInt(input.value) || 1; + if (current > 1) { + input.value = current - 1; + input.dispatchEvent(new Event('change')); + } + }); + }); + + document.querySelectorAll('.qty-plus').forEach(btn => { + btn.addEventListener('click', function() { + const input = this.closest('.quantity-selector').querySelector('.qty-input'); + const current = parseInt(input.value) || 1; + const max = parseInt(input.max) || 999; + if (current < max) { + input.value = current + 1; + input.dispatchEvent(new Event('change')); + } + }); + }); + + // Add to cart buttons + document.querySelectorAll('.add-to-cart-btn').forEach(btn => { + btn.addEventListener('click', function(e) { + if (!this.dataset.productId) return; + + e.preventDefault(); + const productId = this.dataset.productId; + const qtyInput = document.querySelector('.qty-input'); + const quantity = qtyInput ? parseInt(qtyInput.value) : 1; + + addToCart(productId, quantity); + }); + }); + + // Mobile menu toggle + const menuToggle = document.querySelector('.mobile-menu-toggle'); + const navMenu = document.querySelector('.nav-menu'); + if (menuToggle && navMenu) { + menuToggle.addEventListener('click', function() { + navMenu.classList.toggle('active'); + this.querySelector('i').classList.toggle('fa-bars'); + this.querySelector('i').classList.toggle('fa-times'); + }); + } + + // Confirm delete dialogs + document.querySelectorAll('[data-confirm]').forEach(el => { + el.addEventListener('click', function(e) { + if (!confirm(this.dataset.confirm)) { + e.preventDefault(); + } + }); + }); + + // Auto-hide alerts + document.querySelectorAll('.alert').forEach(alert => { + setTimeout(() => { + alert.style.opacity = '0'; + alert.style.transform = 'translateY(-10px)'; + setTimeout(() => alert.remove(), 300); + }, 5000); + }); +}); + +// ================================ +// Form Helpers +// ================================ +function serializeForm(form) { + const formData = new FormData(form); + const data = {}; + formData.forEach((value, key) => { + data[key] = value; + }); + return data; +} + +async function submitForm(form, url, method = 'POST') { + const data = serializeForm(form); + + try { + const response = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + + return await response.json(); + } catch (error) { + console.error('Form submit error:', error); + throw error; + } +} + +// ================================ +// Format Helpers +// ================================ +function formatCurrency(amount) { + return '$' + parseFloat(amount).toFixed(2); +} + +function formatDate(date) { + return new Date(date).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric' + }); +} + +// ================================ +// Loading States +// ================================ +function setLoading(button, isLoading) { + if (isLoading) { + button.dataset.originalText = button.innerHTML; + button.innerHTML = ' Loading...'; + button.disabled = true; + } else { + button.innerHTML = button.dataset.originalText || button.innerHTML; + button.disabled = false; + } +} + +// ================================ +// Image Preview +// ================================ +function previewImage(input, previewId) { + const preview = document.getElementById(previewId); + if (!preview) return; + + if (input.files && input.files[0]) { + const reader = new FileReader(); + reader.onload = function(e) { + preview.src = e.target.result; + preview.style.display = 'block'; + }; + reader.readAsDataURL(input.files[0]); + } +} + +// ================================ +// Newsletter Subscription +// ================================ +document.querySelectorAll('.newsletter-form').forEach(form => { + form.addEventListener('submit', async function(e) { + e.preventDefault(); + + const email = this.querySelector('input[type="email"]').value; + const button = this.querySelector('button'); + + setLoading(button, true); + + try { + const response = await fetch('/api/subscribe.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }) + }); + + const data = await response.json(); + + if (data.error) { + ToastManager.error(data.error); + } else { + ToastManager.success('Thank you for subscribing!'); + this.reset(); + } + } catch (error) { + ToastManager.error('Subscription failed. Please try again.'); + } finally { + setLoading(button, false); + } + }); +}); + +// ================================ +// Debounce Helper +// ================================ +function debounce(func, wait) { + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; +} + +// ================================ +// Local Storage Helpers +// ================================ +const Storage = { + get(key, defaultValue = null) { + try { + const item = localStorage.getItem(key); + return item ? JSON.parse(item) : defaultValue; + } catch { + return defaultValue; + } + }, + + set(key, value) { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch (e) { + console.error('Storage error:', e); + } + }, + + remove(key) { + localStorage.removeItem(key); + } +}; diff --git a/sites/tomsjavajive.com/public_html/cart.php b/sites/tomsjavajive.com/public_html/cart.php new file mode 100644 index 0000000..5c5e1e0 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/cart.php @@ -0,0 +1,168 @@ +'; +require_once __DIR__ . '/includes/header.php'; + +$cart = getCart(); +$cartItems = []; +$subtotal = 0; + +// Get product details for cart items +foreach ($cart as $productId => $quantity) { + $product = db()->fetch( + "SELECT product_id, name, price, sale_price, stock, images FROM products WHERE product_id = :id AND is_active = 1", + ['id' => $productId] + ); + + if ($product) { + $images = json_decode($product['images'] ?? '[]', true); + $product['image'] = !empty($images) ? $images[0] : '/assets/images/placeholder-product.svg'; + $product['quantity'] = min($quantity, $product['stock']); + $product['unit_price'] = $product['sale_price'] ?? $product['price']; + $product['total'] = $product['unit_price'] * $product['quantity']; + $subtotal += $product['total']; + $cartItems[] = $product; + } +} + +// Get shipping settings +$shippingSettings = getSetting('shipping', [ + 'flat_rate_enabled' => true, + 'flat_rate_amount' => 5.99, + 'free_shipping_threshold' => 50 +]); + +$shippingCost = 0; +if ($shippingSettings['flat_rate_enabled'] ?? true) { + if ($subtotal >= ($shippingSettings['free_shipping_threshold'] ?? 50)) { + $shippingCost = 0; + } else { + $shippingCost = $shippingSettings['flat_rate_amount'] ?? 5.99; + } +} + +$total = $subtotal + $shippingCost; +?> + +
+
+

Shopping Cart

+ + +
+
+ +

Your cart is empty

+

Looks like you haven't added any items yet.

+ Start Shopping +
+
+ +
+ + +
+
+
+ +
+ + <?= htmlspecialchars($item['name']) ?> + +
+ +

+
+

each

+
+ +
+ + + +
+ +
+

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

Order Summary

+
+
+
+ Subtotal + +
+
+ Shipping + + + FREE + + + + +
+ + 0 && isset($shippingSettings['free_shipping_threshold'])): + $remaining = $shippingSettings['free_shipping_threshold'] - $subtotal; + ?> +

+ + Add more for FREE shipping! +

+ + +
+ +
+ Total + +
+ + + Proceed to Checkout + + + + Continue Shopping + +
+
+
+ +
+
+ + + + diff --git a/sites/tomsjavajive.com/public_html/checkout.php b/sites/tomsjavajive.com/public_html/checkout.php new file mode 100644 index 0000000..e10f0b6 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/checkout.php @@ -0,0 +1,467 @@ + $quantity) { + $product = db()->fetch( + "SELECT product_id, name, price, sale_price, stock, images FROM products WHERE product_id = :id AND is_active = 1", + ['id' => $productId] + ); + + if ($product) { + $images = json_decode($product['images'] ?? '[]', true); + $product['image'] = !empty($images) ? $images[0] : '/assets/images/placeholder-product.svg'; + $product['quantity'] = min($quantity, $product['stock']); + $product['unit_price'] = $product['sale_price'] ?? $product['price']; + $product['total'] = $product['unit_price'] * $product['quantity']; + $subtotal += $product['total']; + $cartItems[] = $product; + } +} + +// Get shipping settings +$shippingSettings = getSetting('shipping', [ + 'flat_rate_enabled' => true, + 'flat_rate_amount' => 5.99, + 'free_shipping_threshold' => 50 +]); + +$shippingCost = 0; +if ($shippingSettings['flat_rate_enabled'] ?? true) { + if ($subtotal >= ($shippingSettings['free_shipping_threshold'] ?? 50)) { + $shippingCost = 0; + } else { + $shippingCost = $shippingSettings['flat_rate_amount'] ?? 5.99; + } +} + +$preDiscountTotal = $subtotal + $shippingCost; +$processor = defined('PAYMENT_PROCESSOR') ? PAYMENT_PROCESSOR : 'stripe'; + +$errors = []; + +// Handle form submission +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + // Validate form + $email = trim($_POST['email'] ?? ''); + $name = trim($_POST['name'] ?? ''); + $phone = trim($_POST['phone'] ?? ''); + $address = trim($_POST['address'] ?? ''); + $city = trim($_POST['city'] ?? ''); + $state = trim($_POST['state'] ?? ''); + $zip = trim($_POST['zip'] ?? ''); + + if (empty($email)) $errors['email'] = 'Email is required'; + if (empty($name)) $errors['name'] = 'Name is required'; + if (empty($address)) $errors['address'] = 'Address is required'; + if (empty($city)) $errors['city'] = 'City is required'; + if (empty($state)) $errors['state'] = 'State is required'; + if (empty($zip)) $errors['zip'] = 'ZIP code is required'; + + // Re-validate any requested wallet credit server-side against the + // customer's current balance - never trust the client-submitted amount. + $walletAmountUsed = 0; + if ($customer && !empty($_POST['wallet_amount_used'])) { + $requestedWallet = (float) $_POST['wallet_amount_used']; + $freshCustomer = db()->fetch("SELECT wallet_balance FROM customers WHERE customer_id = :id", ['id' => $customer['customer_id']]); + $currentBalance = (float) ($freshCustomer['wallet_balance'] ?? 0); + $walletAmountUsed = max(0, round(min($requestedWallet, $currentBalance, $preDiscountTotal), 2)); + } + + $total = round($preDiscountTotal - $walletAmountUsed, 2); + + if (empty($errors)) { + // Create order + $orderId = generateId('ord_'); + $orderNumber = generateOrderNumber(); + + // Get or create customer + $customerId = null; + if ($customer) { + $customerId = $customer['customer_id']; + } else { + $customerId = CustomerAuth::createGuest($email, $name, $phone); + } + + // Prepare order items + $orderItems = []; + foreach ($cartItems as $item) { + $orderItems[] = [ + 'product_id' => $item['product_id'], + 'name' => $item['name'], + 'price' => $item['unit_price'], + 'quantity' => $item['quantity'], + 'total' => $item['total'] + ]; + } + + // Insert order + db()->insert('orders', [ + 'order_id' => $orderId, + 'order_number' => $orderNumber, + 'customer_id' => $customerId, + 'customer_email' => $email, + 'customer_name' => $name, + 'customer_phone' => $phone, + 'items' => json_encode($orderItems), + 'subtotal' => $subtotal, + 'shipping_cost' => $shippingCost, + 'wallet_amount_used' => $walletAmountUsed, + 'total' => $total, + 'shipping_address' => json_encode([ + 'address' => $address, + 'city' => $city, + 'state' => $state, + 'zip' => $zip, + 'country' => 'USA' + ]), + 'shipping_method' => 'standard', + 'payment_method' => $processor, + 'payment_status' => 'pending', + 'order_status' => 'pending' + ]); + + // Insert order items for reporting + foreach ($orderItems as $item) { + db()->insert('order_items', [ + 'order_id' => $orderId, + 'product_id' => $item['product_id'], + 'name' => $item['name'], + 'price' => $item['price'], + 'quantity' => $item['quantity'], + 'total' => $item['total'] + ]); + } + + // Reduce stock + foreach ($cartItems as $item) { + db()->query( + "UPDATE products SET stock = stock - :qty WHERE product_id = :id", + ['qty' => $item['quantity'], 'id' => $item['product_id']] + ); + } + + // Store order ID for payment + $_SESSION['pending_order_id'] = $orderId; + + // Redirect to payment page + redirect('/payment.php?order=' . $orderId); + } +} + +$metaTitle = "Secure Checkout | Tom's Java Jive"; +$metaDescription = 'Complete your coffee order with secure checkout.'; +$canonicalUrl = 'https://tomsjavajive.com/checkout.php'; +$metaRobots = "noindex, nofollow"; +$suppressSchema = true; +$extraHead = ''; +require_once __DIR__ . '/includes/header.php'; +?> + +
+
+

Checkout

+ +
+ +
+ + +
+ +
+
+

Contact Information

+
+
+ +

Logged in as

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

+ Already have an account? Sign in +

+ +
+
+ + +
+
+

Shipping Address

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

Wallet & Gift Cards

+
+
+

+ Wallet balance: +

+ 0): ?> + + +
+
+ + +
+ +
+
+ +
+
+ + + +
+
+

Order Notes (Optional)

+
+
+ +
+
+
+ + +
+
+

Order Summary

+
+
+ +
+ +
+ +
+

+ x +
+
+ +
+
+ +
+ + +
+ Subtotal + +
+
+ Shipping + + + FREE + + + + +
+ + +
+ +
+ Total + +
+ + + + + Back to Cart + + +

+ Secure checkout powered by +

+
+
+
+
+
+
+ + + + + + diff --git a/sites/tomsjavajive.com/public_html/composer.json b/sites/tomsjavajive.com/public_html/composer.json new file mode 100644 index 0000000..76042ed --- /dev/null +++ b/sites/tomsjavajive.com/public_html/composer.json @@ -0,0 +1,5 @@ +{ + "require": { + "phpmailer/phpmailer": "^7.1" + } +} diff --git a/sites/tomsjavajive.com/public_html/composer.lock b/sites/tomsjavajive.com/public_html/composer.lock new file mode 100644 index 0000000..db8db27 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/composer.lock @@ -0,0 +1,101 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "c7173d6a700b02b111e51b703acd8c8e", + "packages": [ + { + "name": "phpmailer/phpmailer", + "version": "v7.1.1", + "source": { + "type": "git", + "url": "https://github.com/PHPMailer/PHPMailer.git", + "reference": "1bc1716a507a65e039d4ac9d9adebbbd0d346e15" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/1bc1716a507a65e039d4ac9d9adebbbd0d346e15", + "reference": "1bc1716a507a65e039d4ac9d9adebbbd0d346e15", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-filter": "*", + "ext-hash": "*", + "php": ">=5.5.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "doctrine/annotations": "^1.2.6 || ^1.13.3", + "php-parallel-lint/php-console-highlighter": "^1.0.0", + "php-parallel-lint/php-parallel-lint": "^1.3.2", + "phpcompatibility/php-compatibility": "^10.0.0@dev", + "squizlabs/php_codesniffer": "^3.13.5", + "yoast/phpunit-polyfills": "^1.0.4" + }, + "suggest": { + "decomplexity/SendOauth2": "Adapter for using XOAUTH2 authentication", + "directorytree/imapengine": "For uploading sent messages via IMAP, see gmail example", + "ext-imap": "Needed to support advanced email address parsing according to RFC822", + "ext-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses", + "ext-openssl": "Needed for secure SMTP sending and DKIM signing", + "greew/oauth2-azure-provider": "Needed for Microsoft Azure XOAUTH2 authentication", + "hayageek/oauth2-yahoo": "Needed for Yahoo XOAUTH2 authentication", + "league/oauth2-google": "Needed for Google XOAUTH2 authentication", + "psr/log": "For optional PSR-3 debug logging", + "symfony/polyfill-mbstring": "To support UTF-8 if the Mbstring PHP extension is not enabled (^1.2)", + "thenetworg/oauth2-azure": "Needed for Microsoft XOAUTH2 authentication" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPMailer\\PHPMailer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-only" + ], + "authors": [ + { + "name": "Marcus Bointon", + "email": "phpmailer@synchromedia.co.uk" + }, + { + "name": "Jim Jagielski", + "email": "jimjag@gmail.com" + }, + { + "name": "Andy Prevost", + "email": "codeworxtech@users.sourceforge.net" + }, + { + "name": "Brent R. Matzelle" + } + ], + "description": "PHPMailer is a full-featured email creation and transfer class for PHP", + "support": { + "issues": "https://github.com/PHPMailer/PHPMailer/issues", + "source": "https://github.com/PHPMailer/PHPMailer/tree/v7.1.1" + }, + "funding": [ + { + "url": "https://github.com/Synchro", + "type": "github" + } + ], + "time": "2026-05-18T08:06:14+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/sites/tomsjavajive.com/public_html/config/config.php b/sites/tomsjavajive.com/public_html/config/config.php new file mode 100644 index 0000000..932a7ce --- /dev/null +++ b/sites/tomsjavajive.com/public_html/config/config.php @@ -0,0 +1,2 @@ +s %b "%{Referer}i" "%{User-Agent}i"" + logHeaders 5 + rollingSize 10M + keepDays 10 + compressArchive 1 +} + +scripthandler { + add lsapi:tomsj4710 php +} + +extprocessor tomsj4710 { + type lsapi + address UDS://tmp/lshttpd/tomsj4710.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 tomsj4710 + extGroup tomsj4710 + 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/tomsjavajive.com/privkey.pem + certFile /etc/letsencrypt/live/tomsjavajive.com/fullchain.pem + certChain 1 + sslProtocol 24 + enableECDHE 1 + renegProtection 1 + sslSessionCache 1 + enableSpdy 15 + enableStapling 1 + ocspRespMaxAge 86400 +} diff --git a/sites/tomsjavajive.com/public_html/config/vhost/vhost.conf.txt b/sites/tomsjavajive.com/public_html/config/vhost/vhost.conf.txt new file mode 100755 index 0000000..06d1363 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/config/vhost/vhost.conf.txt @@ -0,0 +1,85 @@ +docroot $VH_ROOT/public_html +vhaliases www.$VH_NAME +enablegzip 1 +phpinioverride +vhdomain $VH_NAME +enableipgeo 1 +adminemails admin@tomsjavajive.com + +extprocessor tomsj4710 { + inittimeout 600 + retrytimeout 0 + persistconn 1 + maxconns 10 + type lsapi + address UDS://tmp/lshttpd/tomsj4710.sock + extuser tomsj4710 + extgroup tomsj4710 + prochardlimit 500 + pckeepalivetimeout 1 + respbuffer 0 + autostart 1 + path /usr/local/lsws/lsphp85/bin/lsphp + env LSAPI_CHILDREN=10 + procsoftlimit 400 + memsoftlimit 1024M + memhardlimit 1024M +} + +vhssl { + keyfile /etc/letsencrypt/live/tomsjavajive.com/privkey.pem + certfile /etc/letsencrypt/live/tomsjavajive.com/fullchain.pem + certchain 1 + enablespdy 15 + enablestapling 1 + ocsprespmaxage 86400 + sslprotocol 24 + enableecdhe 1 + renegprotection 1 + sslsessioncache 1 +} + +scripthandler { + add lsapi:tomsj4710 php +} + +module cache { + param storagepath /usr/local/lsws/cachedata/$VH_NAME + unknownkeywords storagepath /usr/local/lsws/cachedata/$VH_NAME +} + +accesslog $VH_ROOT/logs/$VH_NAME.access_log { + keepdays 10 + useserver 0 + logformat "%h %l %u %t "%r" %>s %b "%{Referer}i" "%{User-Agent}i"" + compressarchive 1 + logheaders 5 + rollingsize 10M +} + +index { + indexfiles index.php, index.html + useserver 0 +} + +context /.well-known/acme-challenge { + phpinioverride + adddefaultcharset off + allowbrowse 1 + location /usr/local/lsws/Example/html/.well-known/acme-challenge + + rewrite { + enable 0 + } +} + +rewrite { + enable 1 + autoloadhtaccess 1 +} + +errorlog $VH_ROOT/logs/$VH_NAME.error_log { + rollingsize 10M + useserver 0 + loglevel WARN +} diff --git a/sites/tomsjavajive.com/public_html/config/vhost/vhost.conf0 b/sites/tomsjavajive.com/public_html/config/vhost/vhost.conf0 new file mode 100755 index 0000000..454a318 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/config/vhost/vhost.conf0 @@ -0,0 +1,91 @@ +docRoot $VH_ROOT/public_html +vhDomain $VH_NAME +vhAliases www.$VH_NAME +adminEmails admin@tomsjavajive.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:tomsj4710 php +} + +extprocessor tomsj4710 { + type lsapi + address UDS://tmp/lshttpd/tomsj4710.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 tomsj4710 + extGroup tomsj4710 + 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/tomsjavajive.com/privkey.pem + certFile /etc/letsencrypt/live/tomsjavajive.com/fullchain.pem + certChain 1 + sslProtocol 24 + enableECDHE 1 + renegProtection 1 + sslSessionCache 1 + enableSpdy 15 + enableStapling 1 + ocspRespMaxAge 86400 +} diff --git a/sites/tomsjavajive.com/public_html/config/vhost/vhost.conf0,v b/sites/tomsjavajive.com/public_html/config/vhost/vhost.conf0,v new file mode 100755 index 0000000..bccd488 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/config/vhost/vhost.conf0,v @@ -0,0 +1,130 @@ +head 1.2; +access; +symbols; +locks + root:1.2; strict; +comment @# @; + + +1.2 +date 2026.05.15.20.13.36; author root; state Exp; +branches; +next 1.1; + +1.1 +date 2026.05.15.20.12.31; author root; state Exp; +branches; +next ; + + +desc +@/usr/local/lsws/conf/vhosts/tomsjavajive.com/vhost.conf0 +@ + + +1.2 +log +@Update +@ +text +@docRoot $VH_ROOT/public_html +vhDomain $VH_NAME +vhAliases www.$VH_NAME +adminEmails admin@@tomsjavajive.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:tomsj4710 php +} + +extprocessor tomsj4710 { + type lsapi + address UDS://tmp/lshttpd/tomsj4710.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 tomsj4710 + extGroup tomsj4710 + 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/tomsjavajive.com/privkey.pem + certFile /etc/letsencrypt/live/tomsjavajive.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 +@ diff --git a/sites/tomsjavajive.com/public_html/contact.php b/sites/tomsjavajive.com/public_html/contact.php new file mode 100644 index 0000000..88a4dfb --- /dev/null +++ b/sites/tomsjavajive.com/public_html/contact.php @@ -0,0 +1,169 @@ + 'Home', 'url' => 'https://tomsjavajive.com'], + ['name' => 'Contact', 'url' => 'https://tomsjavajive.com/contact.php'] +]; +require_once __DIR__ . '/includes/functions.php'; + +$sent = false; +$errors = []; + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $name = trim($_POST['name'] ?? ''); + $email = trim($_POST['email'] ?? ''); + $subject = trim($_POST['subject'] ?? ''); + $message = trim($_POST['message'] ?? ''); + + if (empty($name)) $errors['name'] = 'Name is required.'; + if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) + $errors['email'] = 'A valid email address is required.'; + if (empty($subject)) $errors['subject'] = 'Please choose a subject.'; + if (empty($message)) $errors['message'] = 'Message cannot be empty.'; + + if (empty($errors)) { + $html = "

Name: {$name}
Email: {$email}
Subject: {$subject}

" . nl2br(htmlspecialchars($message)) . "

"; + sendEmail('sales@tomsjavajive.com', "Contact Form: {$subject}", $html); + $sent = true; + } +} + +require_once __DIR__ . '/includes/header.php'; +?> + +
+
+ +
+

Contact Us

+

Questions about your order or just want to talk coffee? We'd love to hear from you.

+
+ +
+ + +
+
+
+

Get in Touch

+ +
+
+ +
+
+
Email
+ sales@tomsjavajive.com +
We reply within 1 business day
+
+
+ +
+
+ +
+
+
Phone
+ (817) 266-2022 +
Mon–Fri, 9am–5pm CT
+
+
+ +
+
+ +
+
+
Location
+
Weatherford, TX 76088
+
United States
+
+
+
+
+ + +
+ + +
+
+ +
+
+

Message Sent!

+

Thanks for reaching out. We'll get back to you within one business day.

+ Browse Our Coffee +
+ +

Send a Message

+
+
+
+ + + + + +
+
+ + + + + +
+
+ +
+ + + + + +
+ +
+ + + + + +
+ + +
+ +
+
+ +
+
+
+ + diff --git a/sites/tomsjavajive.com/public_html/db/schema.sql b/sites/tomsjavajive.com/public_html/db/schema.sql new file mode 100644 index 0000000..46b4dd8 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/db/schema.sql @@ -0,0 +1,786 @@ +/*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 `abandoned_carts`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `abandoned_carts` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `cart_id` varchar(50) NOT NULL, + `customer_id` varchar(50) DEFAULT NULL, + `customer_email` varchar(255) DEFAULT NULL, + `items` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL CHECK (json_valid(`items`)), + `subtotal` decimal(10,2) NOT NULL, + `recovery_email_sent` tinyint(1) DEFAULT 0, + `recovered` tinyint(1) DEFAULT 0, + `created_at` timestamp NULL DEFAULT current_timestamp(), + `updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE KEY `cart_id` (`cart_id`), + KEY `idx_customer_email` (`customer_email`), + KEY `idx_recovered` (`recovered`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `about_us_sections`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `about_us_sections` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `section_id` varchar(50) NOT NULL, + `heading` varchar(255) DEFAULT NULL, + `body` longtext NOT NULL, + `sort_order` int(11) DEFAULT 0, + `is_active` tinyint(1) DEFAULT 1, + `updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + `created_at` timestamp NULL DEFAULT current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE KEY `section_id` (`section_id`) +) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +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` int(11) NOT NULL AUTO_INCREMENT, + `user_id` varchar(50) NOT NULL, + `email` varchar(255) NOT NULL, + `password_hash` varchar(255) DEFAULT NULL, + `name` varchar(255) DEFAULT NULL, + `picture` varchar(500) DEFAULT NULL, + `is_admin` tinyint(1) DEFAULT 1, + `is_master` tinyint(1) DEFAULT 0, + `permissions` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`permissions`)), + `created_at` timestamp NULL DEFAULT current_timestamp(), + `last_login` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `user_id` (`user_id`), + UNIQUE KEY `email` (`email`), + KEY `idx_email` (`email`) +) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `categories`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `categories` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `category_id` varchar(50) NOT NULL, + `name` varchar(255) NOT NULL, + `slug` varchar(255) NOT NULL, + `description` text DEFAULT NULL, + `image` varchar(500) DEFAULT NULL, + `parent_id` varchar(50) DEFAULT NULL, + `sort_order` int(11) DEFAULT 0, + `is_active` tinyint(1) DEFAULT 1, + `created_at` timestamp NULL DEFAULT current_timestamp(), + `updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE KEY `category_id` (`category_id`), + UNIQUE KEY `slug` (`slug`), + KEY `idx_slug` (`slug`), + KEY `idx_is_active` (`is_active`) +) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `coupons`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `coupons` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `coupon_id` varchar(50) NOT NULL, + `code` varchar(50) NOT NULL, + `discount_type` enum('percentage','fixed') NOT NULL DEFAULT 'percentage', + `discount_value` decimal(10,2) NOT NULL, + `min_order_amount` decimal(10,2) DEFAULT NULL, + `max_uses` int(11) DEFAULT NULL, + `times_used` int(11) DEFAULT 0, + `is_active` tinyint(1) DEFAULT 1, + `starts_at` timestamp NULL DEFAULT NULL, + `expires_at` timestamp NULL DEFAULT NULL, + `created_at` timestamp NULL DEFAULT current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE KEY `coupon_id` (`coupon_id`), + UNIQUE KEY `code` (`code`), + KEY `idx_code` (`code`), + KEY `idx_is_active` (`is_active`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `customers`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `customers` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `customer_id` varchar(50) NOT NULL, + `email` varchar(255) NOT NULL, + `password_hash` varchar(255) DEFAULT NULL, + `name` varchar(255) DEFAULT NULL, + `phone` varchar(50) DEFAULT NULL, + `shipping_address` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`shipping_address`)), + `billing_address` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`billing_address`)), + `wallet_balance` decimal(10,2) DEFAULT 0.00, + `reward_points` int(11) DEFAULT 0, + `lifetime_points` int(11) DEFAULT 0, + `loyalty_tier` varchar(20) DEFAULT 'bronze', + `is_guest` tinyint(1) DEFAULT 0, + `created_via` varchar(50) DEFAULT 'web', + `email_verified` tinyint(1) DEFAULT 0, + `created_at` timestamp NULL DEFAULT current_timestamp(), + `updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + `is_active` tinyint(1) DEFAULT 1, + `addresses` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`addresses`)), + `preferences` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`preferences`)), + PRIMARY KEY (`id`), + UNIQUE KEY `customer_id` (`customer_id`), + UNIQUE KEY `email` (`email`), + KEY `idx_email` (`email`), + KEY `idx_customer_id` (`customer_id`) +) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `email_campaigns`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `email_campaigns` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `campaign_id` varchar(50) NOT NULL, + `name` varchar(255) NOT NULL, + `subject` varchar(255) NOT NULL, + `content` text NOT NULL, + `recipient_type` enum('all','customers_only','subscribers_only') DEFAULT 'all', + `status` enum('draft','scheduled','sent','cancelled') DEFAULT 'draft', + `scheduled_at` timestamp NULL DEFAULT NULL, + `sent_at` timestamp NULL DEFAULT NULL, + `recipients_count` int(11) DEFAULT 0, + `opened_count` int(11) DEFAULT 0, + `clicked_count` int(11) DEFAULT 0, + `created_at` timestamp NULL DEFAULT current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE KEY `campaign_id` (`campaign_id`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `email_subscribers`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `email_subscribers` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `email` varchar(255) NOT NULL, + `name` varchar(255) DEFAULT NULL, + `is_active` tinyint(1) DEFAULT 1, + `source` varchar(50) DEFAULT 'website', + `created_at` timestamp NULL DEFAULT current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE KEY `email` (`email`), + KEY `idx_is_active` (`is_active`) +) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `gift_card_transactions`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `gift_card_transactions` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `gift_card_id` varchar(50) NOT NULL, + `order_id` varchar(50) DEFAULT NULL, + `amount` decimal(10,2) NOT NULL, + `balance_after` decimal(10,2) NOT NULL, + `type` enum('purchase','redemption','refund') NOT NULL, + `created_at` timestamp NULL DEFAULT current_timestamp(), + PRIMARY KEY (`id`), + KEY `idx_gift_card_id` (`gift_card_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `gift_cards`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `gift_cards` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `gift_card_id` varchar(50) NOT NULL, + `code` varchar(20) NOT NULL, + `initial_balance` decimal(10,2) NOT NULL, + `current_balance` decimal(10,2) NOT NULL, + `purchaser_email` varchar(255) DEFAULT NULL, + `recipient_email` varchar(255) DEFAULT NULL, + `recipient_name` varchar(255) DEFAULT NULL, + `message` text DEFAULT NULL, + `is_active` tinyint(1) DEFAULT 1, + `expires_at` timestamp NULL DEFAULT NULL, + `created_at` timestamp NULL DEFAULT current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE KEY `gift_card_id` (`gift_card_id`), + UNIQUE KEY `code` (`code`), + KEY `idx_code` (`code`), + KEY `idx_is_active` (`is_active`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `homepage_splashes`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `homepage_splashes` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `splash_id` varchar(50) NOT NULL, + `icon` varchar(100) NOT NULL DEFAULT 'fas fa-star', + `image_url` varchar(500) DEFAULT NULL, + `title` varchar(255) NOT NULL, + `description` text DEFAULT NULL, + `sort_order` int(11) DEFAULT 0, + `is_active` tinyint(1) DEFAULT 1, + `created_at` timestamp NULL DEFAULT current_timestamp(), + `updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE KEY `splash_id` (`splash_id`) +) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `loyalty_settings`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `loyalty_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=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `loyalty_tiers`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `loyalty_tiers` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `name` varchar(100) NOT NULL, + `min_points` int(11) NOT NULL DEFAULT 0, + `multiplier` decimal(3,2) DEFAULT 1.00, + `benefits` longtext DEFAULT NULL CHECK (json_valid(`benefits`)), + `color` varchar(20) DEFAULT '#gray', + `created_at` datetime DEFAULT current_timestamp(), + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `loyalty_transactions`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `loyalty_transactions` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `transaction_id` varchar(50) DEFAULT NULL, + `customer_id` varchar(50) NOT NULL, + `type` enum('earn','redeem','expire','adjust','birthday_bonus','referral_bonus','referral_welcome','tier_upgrade') DEFAULT NULL, + `points` int(11) NOT NULL, + `balance_after` int(11) NOT NULL DEFAULT 0, + `description` varchar(255) DEFAULT NULL, + `reference_amount` decimal(10,2) DEFAULT NULL, + `order_id` varchar(50) DEFAULT NULL, + `created_at` datetime DEFAULT current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE KEY `transaction_id` (`transaction_id`), + KEY `idx_customer` (`customer_id`), + KEY `idx_created` (`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `order_items`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `order_items` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `order_id` varchar(50) NOT NULL, + `product_id` varchar(50) NOT NULL, + `name` varchar(255) NOT NULL, + `price` decimal(10,2) NOT NULL, + `quantity` int(11) NOT NULL, + `total` decimal(10,2) NOT NULL, + PRIMARY KEY (`id`), + KEY `idx_order_id` (`order_id`), + KEY `idx_product_id` (`product_id`), + CONSTRAINT `order_items_ibfk_1` FOREIGN KEY (`order_id`) REFERENCES `orders` (`order_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 `orders`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `orders` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `order_id` varchar(50) NOT NULL, + `order_number` varchar(20) NOT NULL, + `customer_id` varchar(50) DEFAULT NULL, + `customer_email` varchar(255) NOT NULL, + `customer_name` varchar(255) DEFAULT NULL, + `customer_phone` varchar(50) DEFAULT NULL, + `items` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL CHECK (json_valid(`items`)), + `subtotal` decimal(10,2) NOT NULL, + `shipping_cost` decimal(10,2) DEFAULT 0.00, + `tax` decimal(10,2) DEFAULT 0.00, + `discount` decimal(10,2) DEFAULT 0.00, + `gift_card_discount` decimal(10,2) DEFAULT 0.00, + `wallet_amount_used` decimal(10,2) DEFAULT 0.00, + `total` decimal(10,2) NOT NULL, + `shipping_address` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`shipping_address`)), + `billing_address` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`billing_address`)), + `shipping_method` varchar(50) DEFAULT NULL, + `payment_method` varchar(50) DEFAULT NULL, + `payment_status` enum('pending','paid','failed','refunded','partially_refunded') DEFAULT 'pending', + `order_status` enum('pending','confirmed','processing','shipped','delivered','cancelled','refunded') DEFAULT 'pending', + `stripe_session_id` varchar(255) DEFAULT NULL, + `stripe_payment_intent` varchar(255) DEFAULT NULL, + `square_payment_id` varchar(255) DEFAULT NULL, + `square_order_id` varchar(255) DEFAULT NULL, + `tracking_number` varchar(100) DEFAULT NULL, + `tracking_url` varchar(500) DEFAULT NULL, + `notes` text DEFAULT NULL, + `is_pos_order` tinyint(1) DEFAULT 0, + `created_at` timestamp NULL DEFAULT current_timestamp(), + `updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE KEY `order_id` (`order_id`), + UNIQUE KEY `order_number` (`order_number`), + KEY `idx_order_id` (`order_id`), + KEY `idx_customer_id` (`customer_id`), + KEY `idx_customer_email` (`customer_email`), + KEY `idx_order_status` (`order_status`), + KEY `idx_payment_status` (`payment_status`), + 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 `password_reset_tokens`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `password_reset_tokens` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `email` varchar(255) NOT NULL, + `token` varchar(255) NOT NULL, + `user_type` enum('admin','customer') NOT NULL, + `expires_at` timestamp NOT NULL, + `used` tinyint(1) DEFAULT 0, + `created_at` timestamp NULL DEFAULT current_timestamp(), + PRIMARY KEY (`id`), + KEY `idx_token` (`token`), + 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 `pma__bookmark`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `pma__bookmark` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `dbase` varchar(255) NOT NULL DEFAULT '', + `user` varchar(255) NOT NULL DEFAULT '', + `label` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL DEFAULT '', + `query` text NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_bin COMMENT='Bookmarks'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `pma__central_columns`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `pma__central_columns` ( + `db_name` varchar(64) NOT NULL, + `col_name` varchar(64) NOT NULL, + `col_type` varchar(64) NOT NULL, + `col_length` text DEFAULT NULL, + `col_collation` varchar(64) NOT NULL, + `col_isNull` tinyint(1) NOT NULL, + `col_extra` varchar(255) DEFAULT '', + `col_default` text DEFAULT NULL, + PRIMARY KEY (`db_name`,`col_name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_bin COMMENT='Central list of columns'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `pma__column_info`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `pma__column_info` ( + `id` int(5) unsigned NOT NULL AUTO_INCREMENT, + `db_name` varchar(64) NOT NULL DEFAULT '', + `table_name` varchar(64) NOT NULL DEFAULT '', + `column_name` varchar(64) NOT NULL DEFAULT '', + `comment` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL DEFAULT '', + `mimetype` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL DEFAULT '', + `transformation` varchar(255) NOT NULL DEFAULT '', + `transformation_options` varchar(255) NOT NULL DEFAULT '', + `input_transformation` varchar(255) NOT NULL DEFAULT '', + `input_transformation_options` varchar(255) NOT NULL DEFAULT '', + PRIMARY KEY (`id`), + UNIQUE KEY `db_name` (`db_name`,`table_name`,`column_name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_bin COMMENT='Column information for phpMyAdmin'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `pma__designer_settings`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `pma__designer_settings` ( + `username` varchar(64) NOT NULL, + `settings_data` text NOT NULL, + PRIMARY KEY (`username`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_bin COMMENT='Settings related to Designer'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `pma__export_templates`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `pma__export_templates` ( + `id` int(5) unsigned NOT NULL AUTO_INCREMENT, + `username` varchar(64) NOT NULL, + `export_type` varchar(10) NOT NULL, + `template_name` varchar(64) NOT NULL, + `template_data` text NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `u_user_type_template` (`username`,`export_type`,`template_name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_bin COMMENT='Saved export templates'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `pma__favorite`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `pma__favorite` ( + `username` varchar(64) NOT NULL, + `tables` text NOT NULL, + PRIMARY KEY (`username`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_bin COMMENT='Favorite tables'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `pma__history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `pma__history` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `username` varchar(64) NOT NULL DEFAULT '', + `db` varchar(64) NOT NULL DEFAULT '', + `table` varchar(64) NOT NULL DEFAULT '', + `timevalue` timestamp NOT NULL DEFAULT current_timestamp(), + `sqlquery` text NOT NULL, + PRIMARY KEY (`id`), + KEY `username` (`username`,`db`,`table`,`timevalue`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_bin COMMENT='SQL history for phpMyAdmin'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `pma__navigationhiding`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `pma__navigationhiding` ( + `username` varchar(64) NOT NULL, + `item_name` varchar(64) NOT NULL, + `item_type` varchar(64) NOT NULL, + `db_name` varchar(64) NOT NULL, + `table_name` varchar(64) NOT NULL, + PRIMARY KEY (`username`,`item_name`,`item_type`,`db_name`,`table_name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_bin COMMENT='Hidden items of navigation tree'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `pma__pdf_pages`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `pma__pdf_pages` ( + `db_name` varchar(64) NOT NULL DEFAULT '', + `page_nr` int(10) unsigned NOT NULL AUTO_INCREMENT, + `page_descr` varchar(50) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL DEFAULT '', + PRIMARY KEY (`page_nr`), + KEY `db_name` (`db_name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_bin COMMENT='PDF relation pages for phpMyAdmin'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `pma__recent`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `pma__recent` ( + `username` varchar(64) NOT NULL, + `tables` text NOT NULL, + PRIMARY KEY (`username`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_bin COMMENT='Recently accessed tables'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `pma__relation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `pma__relation` ( + `master_db` varchar(64) NOT NULL DEFAULT '', + `master_table` varchar(64) NOT NULL DEFAULT '', + `master_field` varchar(64) NOT NULL DEFAULT '', + `foreign_db` varchar(64) NOT NULL DEFAULT '', + `foreign_table` varchar(64) NOT NULL DEFAULT '', + `foreign_field` varchar(64) NOT NULL DEFAULT '', + PRIMARY KEY (`master_db`,`master_table`,`master_field`), + KEY `foreign_field` (`foreign_db`,`foreign_table`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_bin COMMENT='Relation table'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `pma__savedsearches`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `pma__savedsearches` ( + `id` int(5) unsigned NOT NULL AUTO_INCREMENT, + `username` varchar(64) NOT NULL DEFAULT '', + `db_name` varchar(64) NOT NULL DEFAULT '', + `search_name` varchar(64) NOT NULL DEFAULT '', + `search_data` text NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `u_savedsearches_username_dbname` (`username`,`db_name`,`search_name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_bin COMMENT='Saved searches'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `pma__table_coords`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `pma__table_coords` ( + `db_name` varchar(64) NOT NULL DEFAULT '', + `table_name` varchar(64) NOT NULL DEFAULT '', + `pdf_page_number` int(11) NOT NULL DEFAULT 0, + `x` float unsigned NOT NULL DEFAULT 0, + `y` float unsigned NOT NULL DEFAULT 0, + PRIMARY KEY (`db_name`,`table_name`,`pdf_page_number`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_bin COMMENT='Table coordinates for phpMyAdmin PDF output'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `pma__table_info`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `pma__table_info` ( + `db_name` varchar(64) NOT NULL DEFAULT '', + `table_name` varchar(64) NOT NULL DEFAULT '', + `display_field` varchar(64) NOT NULL DEFAULT '', + PRIMARY KEY (`db_name`,`table_name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_bin COMMENT='Table information for phpMyAdmin'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `pma__table_uiprefs`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `pma__table_uiprefs` ( + `username` varchar(64) NOT NULL, + `db_name` varchar(64) NOT NULL, + `table_name` varchar(64) NOT NULL, + `prefs` text NOT NULL, + `last_update` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`username`,`db_name`,`table_name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_bin COMMENT='Tables'' UI preferences'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `pma__tracking`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `pma__tracking` ( + `db_name` varchar(64) NOT NULL, + `table_name` varchar(64) NOT NULL, + `version` int(10) unsigned NOT NULL, + `date_created` datetime NOT NULL, + `date_updated` datetime NOT NULL, + `schema_snapshot` text NOT NULL, + `schema_sql` text DEFAULT NULL, + `data_sql` longtext DEFAULT NULL, + `tracking` set('UPDATE','REPLACE','INSERT','DELETE','TRUNCATE','CREATE DATABASE','ALTER DATABASE','DROP DATABASE','CREATE TABLE','ALTER TABLE','RENAME TABLE','DROP TABLE','CREATE INDEX','DROP INDEX','CREATE VIEW','ALTER VIEW','DROP VIEW') DEFAULT NULL, + `tracking_active` int(1) unsigned NOT NULL DEFAULT 1, + PRIMARY KEY (`db_name`,`table_name`,`version`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_bin COMMENT='Database changes tracking for phpMyAdmin'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `pma__userconfig`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `pma__userconfig` ( + `username` varchar(64) NOT NULL, + `timevalue` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + `config_data` text NOT NULL, + PRIMARY KEY (`username`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_bin COMMENT='User preferences storage for phpMyAdmin'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `pma__usergroups`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `pma__usergroups` ( + `usergroup` varchar(64) NOT NULL, + `tab` varchar(64) NOT NULL, + `allowed` enum('Y','N') NOT NULL DEFAULT 'N', + PRIMARY KEY (`usergroup`,`tab`,`allowed`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_bin COMMENT='User groups with configured menu items'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `pma__users`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `pma__users` ( + `username` varchar(64) NOT NULL, + `usergroup` varchar(64) NOT NULL, + PRIMARY KEY (`username`,`usergroup`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_bin COMMENT='Users and their assignments to user groups'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `product_types`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `product_types` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `type_id` varchar(50) NOT NULL, + `name` varchar(255) NOT NULL, + `slug` varchar(255) NOT NULL, + `description` text DEFAULT NULL, + `sort_order` int(11) DEFAULT 0, + `is_active` tinyint(1) DEFAULT 1, + `created_at` timestamp NULL DEFAULT current_timestamp(), + `updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE KEY `type_id` (`type_id`), + UNIQUE KEY `slug` (`slug`), + KEY `idx_slug` (`slug`), + KEY `idx_active` (`is_active`) +) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `products`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `products` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `product_id` varchar(50) NOT NULL, + `name` varchar(255) NOT NULL, + `description` text DEFAULT NULL, + `price` decimal(10,2) NOT NULL, + `sale_price` decimal(10,2) DEFAULT NULL, + `cost_price` decimal(10,2) DEFAULT NULL, + `sku` varchar(100) DEFAULT NULL, + `barcode` varchar(100) DEFAULT NULL, + `category` varchar(100) DEFAULT NULL, + `product_type_id` varchar(50) DEFAULT NULL, + `tags` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`tags`)), + `images` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`images`)), + `stock` int(11) DEFAULT 0, + `low_stock_threshold` int(11) DEFAULT 10, + `weight` decimal(10,2) DEFAULT NULL, + `dimensions` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`dimensions`)), + `is_active` tinyint(1) DEFAULT 1, + `is_featured` tinyint(1) DEFAULT 0, + `created_at` timestamp NULL DEFAULT current_timestamp(), + `updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE KEY `product_id` (`product_id`), + KEY `idx_product_id` (`product_id`), + KEY `idx_category` (`category`), + KEY `idx_is_active` (`is_active`), + KEY `idx_type` (`product_type_id`), + FULLTEXT KEY `idx_search` (`name`,`description`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `referrals`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `referrals` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `referral_id` varchar(50) NOT NULL, + `referrer_customer_id` varchar(50) NOT NULL, + `referral_code` varchar(20) NOT NULL, + `referred_customer_id` varchar(50) DEFAULT NULL, + `referred_email` varchar(255) DEFAULT NULL, + `status` enum('pending','completed','expired') DEFAULT 'pending', + `reward_amount` decimal(10,2) DEFAULT 5.00, + `reward_given` tinyint(1) DEFAULT 0, + `created_at` timestamp NULL DEFAULT current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE KEY `referral_id` (`referral_id`), + UNIQUE KEY `referral_code` (`referral_code`), + KEY `idx_referral_code` (`referral_code`), + KEY `idx_referrer` (`referrer_customer_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `reviews`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `reviews` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `review_id` varchar(50) NOT NULL, + `product_id` varchar(50) NOT NULL, + `customer_id` varchar(50) DEFAULT NULL, + `customer_name` varchar(255) NOT NULL, + `customer_email` varchar(255) NOT NULL, + `rating` int(11) NOT NULL CHECK (`rating` >= 1 and `rating` <= 5), + `title` varchar(255) DEFAULT NULL, + `comment` text DEFAULT NULL, + `is_verified_purchase` tinyint(1) DEFAULT 0, + `is_approved` tinyint(1) DEFAULT 0, + `created_at` timestamp NULL DEFAULT current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE KEY `review_id` (`review_id`), + KEY `idx_product_id` (`product_id`), + KEY `idx_is_approved` (`is_approved`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `sessions`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `sessions` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `session_id` varchar(128) NOT NULL, + `user_id` varchar(50) DEFAULT NULL, + `user_type` enum('admin','customer') DEFAULT NULL, + `data` text DEFAULT NULL, + `ip_address` varchar(45) DEFAULT NULL, + `user_agent` varchar(255) DEFAULT NULL, + `created_at` timestamp NULL DEFAULT current_timestamp(), + `expires_at` timestamp NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `session_id` (`session_id`), + KEY `idx_session_id` (`session_id`), + KEY `idx_expires_at` (`expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `settings`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `settings` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `setting_key` varchar(100) NOT NULL, + `setting_value` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`setting_value`)), + `created_at` timestamp NULL DEFAULT current_timestamp(), + `updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE KEY `setting_key` (`setting_key`) +) ENGINE=InnoDB AUTO_INCREMENT=15 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `visitor_sessions`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `visitor_sessions` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `session_id` varchar(100) NOT NULL, + `visitor_id` varchar(50) NOT NULL, + `ip_address` varchar(45) DEFAULT NULL, + `user_agent` text DEFAULT NULL, + `current_page` varchar(500) DEFAULT NULL, + `referrer` varchar(500) DEFAULT NULL, + `country` varchar(100) DEFAULT NULL, + `city` varchar(100) DEFAULT NULL, + `is_active` tinyint(1) DEFAULT 1, + `page_views` int(11) DEFAULT 1, + `started_at` timestamp NULL DEFAULT current_timestamp(), + `last_activity` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE KEY `session_id` (`session_id`), + KEY `idx_is_active` (`is_active`), + KEY `idx_last_activity` (`last_activity`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `wallet_transactions`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `wallet_transactions` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `transaction_id` varchar(50) NOT NULL, + `customer_id` varchar(50) NOT NULL, + `amount` decimal(10,2) NOT NULL, + `balance_after` decimal(10,2) NOT NULL, + `type` enum('deposit','withdrawal','purchase','refund','reward','loyalty_redemption','credit','debit') DEFAULT NULL, + `description` varchar(255) DEFAULT NULL, + `order_id` varchar(50) DEFAULT NULL, + `created_at` timestamp NULL DEFAULT current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE KEY `transaction_id` (`transaction_id`), + KEY `idx_customer_id` (`customer_id`), + KEY `idx_type` (`type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `wishlist`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `wishlist` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `customer_id` varchar(50) NOT NULL, + `product_id` varchar(50) NOT NULL, + `created_at` datetime DEFAULT current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE KEY `unique_wishlist` (`customer_id`,`product_id`), + KEY `idx_customer` (`customer_id`) +) 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 */; + diff --git a/sites/tomsjavajive.com/public_html/docs/DEPLOYMENT.md b/sites/tomsjavajive.com/public_html/docs/DEPLOYMENT.md new file mode 100644 index 0000000..cdec1f2 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/docs/DEPLOYMENT.md @@ -0,0 +1,314 @@ +# Tom's Java Jive - PHP/MySQL Deployment Guide + +## cPanel FTP Deployment Instructions + +This guide covers deploying Tom's Java Jive to a cPanel shared hosting environment with FTP access only (no SSH/root). + +### Requirements + +- PHP 8.4+ +- MySQL 8.0+ +- FTP Client (FileZilla, Cyberduck, etc.) +- cPanel access for database creation + +--- + +## Step 1: Create MySQL Database in cPanel + +1. Log into your cPanel dashboard +2. Navigate to **MySQL® Databases** +3. Create a new database: + - Database name: `tomsjavajive` (or your preferred name) + - Note the full database name (usually `cpaneluser_tomsjavajive`) +4. Create a database user: + - Username: (your choice) + - Password: (strong password) +5. Add user to database: + - Select the user and database + - Grant **ALL PRIVILEGES** +6. Note down your credentials: + - Database host: `localhost` (usually) + - Database name: (full name from step 3) + - Username: (full username from step 4) + - Password: (from step 4) + +--- + +## Step 2: Configure Application Settings + +### Edit `config/database.php` + +```php +define('DB_HOST', 'localhost'); +define('DB_NAME', 'cpaneluser_tomsjavajive'); // Your full database name +define('DB_USER', 'cpaneluser_dbuser'); // Your full database username +define('DB_PASS', 'your_secure_password'); // Your database password +``` + +### Edit `config/config.php` + +```php +define('SITE_NAME', "Tom's Java Jive"); +define('SITE_URL', 'https://yourdomain.com'); // Your actual domain +define('SITE_EMAIL', 'support@yourdomain.com'); + +// Set to 'production' for live site +define('ENVIRONMENT', 'production'); +define('DEBUG_MODE', false); + +// Stripe Keys (get from https://dashboard.stripe.com/apikeys) +define('STRIPE_SECRET_KEY', 'sk_live_xxxx'); // Use sk_test_ for testing +define('STRIPE_PUBLISHABLE_KEY', 'pk_live_xxxx'); +define('STRIPE_WEBHOOK_SECRET', 'whsec_xxxx'); + +// SendGrid (get from https://app.sendgrid.com/settings/api_keys) +define('SENDGRID_API_KEY', 'SG.xxxx'); +define('SENDER_EMAIL', 'noreply@yourdomain.com'); + +// Twilio (optional - get from https://www.twilio.com/console) +define('TWILIO_SID', ''); +define('TWILIO_AUTH_TOKEN', ''); +define('TWILIO_PHONE', ''); +``` + +--- + +## Step 3: Upload Files via FTP + +1. Connect to your server using FTP: + - Host: Your domain or ftp.yourdomain.com + - Port: 21 (or 22 for SFTP) + - Username: Your cPanel username + - Password: Your cPanel password + +2. Navigate to `public_html` (or your web root) + +3. Upload ALL files from the `tomsjavajive-php` folder: + ``` + /public_html/ + ├── admin/ + ├── api/ + ├── assets/ + ├── account/ + ├── config/ + ├── docs/ + ├── includes/ + ├── install/ + ├── pages/ + ├── index.php + ├── shop.php + ├── product.php + ├── cart.php + ├── checkout.php + ├── payment.php + ├── login.php + ├── register.php + ├── logout.php + └── ... (other files) + ``` + +4. Set file permissions (via FTP client or cPanel File Manager): + - All `.php` files: 644 + - All directories: 755 + - `uploads/` directory: 755 (create if not exists) + - `config/` directory: 755 + +--- + +## Step 4: Import Database Schema + +### Option A: Using phpMyAdmin (Recommended) + +1. In cPanel, open **phpMyAdmin** +2. Select your database from the left sidebar +3. Click the **Import** tab +4. Click **Choose File** and select `install/schema.sql` +5. Click **Go** to execute + +### Option B: Using MySQL command in cPanel Terminal (if available) + +```bash +mysql -u cpaneluser_dbuser -p cpaneluser_tomsjavajive < install/schema.sql +``` + +--- + +## Step 5: Create First Admin User + +After importing the schema, create your first admin user via phpMyAdmin: + +1. Open phpMyAdmin +2. Select your database +3. Click the **SQL** tab +4. Run this query (replace with your details): + +```sql +INSERT INTO admin_users (user_id, email, password_hash, name, is_admin, is_master, permissions) +VALUES ( + 'admin_001', + 'admin@yourdomain.com', + '$2y$12$xxxxx', -- Generate bcrypt hash (see below) + 'Admin', + 1, + 1, + '{"dashboard":true,"pos":true,"products":true,"orders":true,"customers":true,"settings_payment":true,"settings_shipping":true,"settings_email":true,"admin_management":true}' +); +``` + +**To generate password hash:** +Create a temporary PHP file called `generate_hash.php`: +```php + 12]); +?> +``` +Upload it, visit it in browser, copy the hash, then DELETE the file. + +--- + +## Step 6: Configure Stripe Webhook (Optional but Recommended) + +1. Go to [Stripe Dashboard > Webhooks](https://dashboard.stripe.com/webhooks) +2. Click **Add endpoint** +3. Set endpoint URL: `https://yourdomain.com/api/webhook.php` +4. Select events: + - `payment_intent.succeeded` + - `payment_intent.payment_failed` + - `charge.refunded` +5. Copy the **Signing secret** to your `config.php` + +--- + +## Step 7: Test Your Installation + +1. Visit `https://yourdomain.com` - Should show storefront +2. Visit `https://yourdomain.com/admin/` - Should show admin login +3. Login with your admin credentials +4. Add a test product +5. Test the checkout flow + +--- + +## Data Migration from MongoDB + +If you have existing data in MongoDB that needs to be migrated: + +### Prerequisites +- PHP with MongoDB extension (`pecl install mongodb`) +- Access to your MongoDB server + +### Running Migration +The migration script is located at `install/migrate_from_mongodb.php`. + +Since cPanel typically doesn't have MongoDB extension, you have two options: + +**Option A: Export/Import Manually** +1. Export MongoDB collections to JSON using `mongoexport` +2. Convert and import to MySQL using phpMyAdmin or custom scripts + +**Option B: Run Migration Locally** +1. Install PHP MongoDB extension locally +2. Connect to both databases +3. Run: `php migrate_from_mongodb.php [mongodb_url] [mongodb_dbname]` + +--- + +## Security Checklist + +Before going live: + +- [ ] Change default admin password +- [ ] Update `SITE_URL` in config.php +- [ ] Set `ENVIRONMENT` to 'production' +- [ ] Set `DEBUG_MODE` to false +- [ ] Configure real Stripe keys (not test keys) +- [ ] Configure SendGrid for emails +- [ ] Delete `install/` folder after setup +- [ ] Set up SSL certificate (HTTPS) +- [ ] Enable Stripe webhook +- [ ] Test payment flow end-to-end + +--- + +## Troubleshooting + +### "Database connection failed" +- Check database credentials in `config/database.php` +- Verify database exists in cPanel +- Ensure user has privileges on the database + +### "500 Internal Server Error" +- Check `.htaccess` file exists +- View error logs in cPanel > Error Log +- Temporarily enable `DEBUG_MODE` in config.php + +### "Blank page" +- Enable PHP error display temporarily +- Check PHP version (requires 8.4+) +- View Apache/PHP error logs + +### "Session errors" +- Ensure `session.save_path` is writable +- Check `sessions/` folder permissions + +### "Payment not working" +- Verify Stripe keys are correct +- Check browser console for JS errors +- Verify webhook endpoint is accessible + +--- + +## File Structure Reference + +``` +tomsjavajive-php/ +├── admin/ # Admin panel +│ ├── assets/ # Admin CSS/JS +│ ├── includes/ # Admin header/footer +│ ├── index.php # Dashboard +│ ├── products.php # Product management +│ ├── orders.php # Order management +│ └── ... +├── api/ # API endpoints +│ ├── cart.php +│ ├── products.php +│ ├── orders.php +│ └── webhook.php # Stripe webhook +├── assets/ # Public assets +│ ├── css/ +│ ├── js/ +│ └── images/ +├── account/ # Customer account pages +├── config/ # Configuration files +│ ├── config.php # Main config +│ └── database.php # DB credentials +├── includes/ # Shared includes +│ ├── auth.php +│ ├── db.php +│ ├── functions.php +│ ├── header.php +│ └── footer.php +├── install/ # Installation files +│ ├── schema.sql # Database schema +│ └── migrate_from_mongodb.php +├── index.php # Homepage +├── shop.php # Shop page +├── product.php # Product detail +├── cart.php # Shopping cart +├── checkout.php # Checkout +└── payment.php # Stripe payment +``` + +--- + +## Support + +For issues with this deployment: +1. Check the troubleshooting section above +2. Review error logs in cPanel +3. Verify all configuration values are correct + +--- + +*Last updated: December 2025* +*PHP Version: 8.4.19 | MySQL Version: 8.0* diff --git a/sites/tomsjavajive.com/public_html/faq.php b/sites/tomsjavajive.com/public_html/faq.php new file mode 100644 index 0000000..f0de33f --- /dev/null +++ b/sites/tomsjavajive.com/public_html/faq.php @@ -0,0 +1,88 @@ + 'Home', 'url' => 'https://tomsjavajive.com'], + ['name' => 'FAQ', 'url' => 'https://tomsjavajive.com/faq.php'] +]; +require_once __DIR__ . '/includes/header.php'; +?> + +
+
+ +
+

Frequently Asked Questions

+

Everything you need to know about Tom's Java Jive.

+
+ + [ + ['q' => 'How do I place an order?', 'a' => 'Browse our shop, add items to your cart, and proceed to checkout. We accept all major credit cards, debit cards, and PayPal.'], + ['q' => 'Can I modify or cancel my order?', 'a' => 'Orders can be modified or cancelled within 1 hour of placement. After that, orders enter processing and cannot be changed. Contact us immediately at sales@tomsjavajive.com if you need to make a change.'], + ['q' => 'Is my payment information secure?', 'a' => 'Yes. All payments are processed through Stripe, a PCI-DSS Level 1 certified payment processor. We never store your card details on our servers.'], + ['q' => 'Do you offer gift cards?', 'a' => 'Yes! Digital gift cards are available in our shop and are delivered by email. They never expire.'], + ], + 'Coffee & Products' => [ + ['q' => 'When is my coffee roasted?', 'a' => 'We roast to order — your beans are roasted within 24–48 hours of your order being placed, ensuring maximum freshness when they arrive.'], + ['q' => 'What grind sizes do you offer?', 'a' => 'We offer whole bean, coarse (French press/cold brew), medium (drip/pour-over), and fine (espresso). Select your preferred grind at checkout.'], + ['q' => 'How should I store my coffee?', 'a' => 'For maximum freshness, store your coffee in an airtight container in the freezer. Whole beans keep longer than ground coffee. If storing at room temperature, keep in a cool, dark place away from direct sunlight, heat, and moisture.'], + ['q' => 'Are your coffees organic or fair trade?', 'a' => 'Many of our single origin coffees are sourced from farms with organic and fair trade practices. Each product listing includes sourcing details.'], + ], + 'Coffee Freshness & Storage' => [ + ['q' => 'How long does coffee last?', 'a' => 'Properly stored coffee can last for months and even years, though flavor is best enjoyed within a few weeks of the roast date. Whole beans stay fresh longer than ground coffee because less surface area is exposed to air.'], + ['q' => 'How can I tell if my coffee has gone bad?', 'a' => 'The best way to tell is by smell. If the pleasant aroma is gone, the coffee has likely gone stale. Stale coffee won\'t harm you, but it will have little flavor. It may also appear a lighter brown rather than its usual deep, dark color.'], + ['q' => 'How should I store coffee beans to keep them fresh?', 'a' => 'Store beans in an airtight container in the freezer for maximum freshness. Whole beans keep longer than ground coffee because less surface area is exposed to air. Grind only the amount you plan to use each day to minimize unnecessary exposure.'], + ['q' => 'How should I store ground coffee?', 'a' => 'Store ground coffee in an airtight container in your freezer immediately after opening. Freezer storage doesn\'t freeze it solid — it\'s always ready to use without defrosting. Just scoop and brew.'], + ], + 'Account & Loyalty' => [ + ['q' => 'Do I need an account to order?', 'a' => 'No, you can check out as a guest. However, creating an account lets you track orders, earn loyalty points, save addresses, and view your order history.'], + ['q' => 'How does the loyalty program work?', 'a' => 'You earn 1 point for every $1 spent. Points can be redeemed for discounts on future orders. Points are credited once your order is delivered.'], + ['q' => 'How do I reset my password?', 'a' => 'Click "Forgot Password" on the login page and we\'ll email you a reset link.'], + ], + ]; + + foreach ($faqs as $category => $items): ?> +
+

+
+ $faq): ?> +
+ +
+ +
+
+ +
+
+ + +
+
+

Still have questions?

+

We're happy to help. Reach out and we'll get back to you within one business day.

+ Contact Us +
+
+ +
+
+ + + + diff --git a/sites/tomsjavajive.com/public_html/favicon.ico b/sites/tomsjavajive.com/public_html/favicon.ico new file mode 100644 index 0000000..7d936e0 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/favicon.ico @@ -0,0 +1,312 @@ + + + + + + + + + Tom's Java Jive | Premium Coffee Beans & Fresh Roasted Coffee | Weatherford TX + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +

+ Made with Emergent +

+
+ + + diff --git a/sites/tomsjavajive.com/public_html/includes/auth.php b/sites/tomsjavajive.com/public_html/includes/auth.php new file mode 100644 index 0000000..d39e68c --- /dev/null +++ b/sites/tomsjavajive.com/public_html/includes/auth.php @@ -0,0 +1,284 @@ +fetch( + "SELECT * FROM admin_users WHERE email = :email", + ['email' => strtolower($email)] + ); + + if (!$admin || !verifyPassword($password, $admin['password_hash'])) { + return false; + } + + // Update last login + db()->update('admin_users', + ['last_login' => date('Y-m-d H:i:s')], + 'user_id = :id', + ['id' => $admin['user_id']] + ); + + // Set session + $_SESSION['admin'] = [ + 'user_id' => $admin['user_id'], + 'email' => $admin['email'], + 'name' => $admin['name'], + 'is_master' => (bool)$admin['is_master'], + 'permissions' => json_decode($admin['permissions'] ?? '[]', true) + ]; + + // Regenerate session ID for security + session_regenerate_id(true); + + return true; + } + + public static function logout() { + unset($_SESSION['admin']); + session_regenerate_id(true); + } + + public static function isLoggedIn() { + return isset($_SESSION['admin']['user_id']); + } + + public static function getUser() { + return $_SESSION['admin'] ?? null; + } + + public static function require() { + if (!self::isLoggedIn()) { + if (isAjax()) { + jsonResponse(['error' => 'Unauthorized'], 401); + } + $_SESSION['admin_redirect'] = currentUrl(); + redirect('/admin/login.php'); + } + } + + public static function hasPermission($permission) { + $admin = self::getUser(); + if (!$admin) return false; + if ($admin['is_master']) return true; + return in_array($permission, $admin['permissions'] ?? []); + } + + public static function register($email, $password, $name = null, $isMaster = false) { + $userId = generateId('admin_'); + + db()->insert('admin_users', [ + 'user_id' => $userId, + 'email' => strtolower($email), + 'password_hash' => hashPassword($password), + 'name' => $name ?? $email, + 'is_admin' => 1, + 'is_master' => $isMaster ? 1 : 0 + ]); + + return $userId; + } +} + +/** + * Customer Authentication + */ +class CustomerAuth { + + public static function login($email, $password) { + $customer = db()->fetch( + "SELECT * FROM customers WHERE email = :email AND password_hash IS NOT NULL", + ['email' => strtolower($email)] + ); + + if (!$customer || !verifyPassword($password, $customer['password_hash'])) { + return false; + } + + // Set session + $_SESSION['customer'] = [ + 'customer_id' => $customer['customer_id'], + 'email' => $customer['email'], + 'name' => $customer['name'] + ]; + + session_regenerate_id(true); + return true; + } + + public static function logout() { + unset($_SESSION['customer']); + session_regenerate_id(true); + } + + public static function isLoggedIn() { + return isset($_SESSION['customer']['customer_id']); + } + + public static function getUser() { + return $_SESSION['customer'] ?? null; + } + + public static function getFullUser() { + if (!self::isLoggedIn()) return null; + + return db()->fetch( + "SELECT customer_id, email, name, phone, shipping_address, billing_address, + wallet_balance, reward_points, addresses, preferences, password_hash, created_at + FROM customers WHERE customer_id = :id", + ['id' => $_SESSION['customer']['customer_id']] + ); + } + + public static function require() { + if (!self::isLoggedIn()) { + if (isAjax()) { + jsonResponse(['error' => 'Unauthorized'], 401); + } + $_SESSION['redirect_after_login'] = currentUrl(); + redirect('/login.php'); + } + } + + public static function register($email, $password, $name = null, $phone = null) { + // Check if email exists + $existing = db()->fetch( + "SELECT customer_id FROM customers WHERE email = :email", + ['email' => strtolower($email)] + ); + + if ($existing) { + return ['error' => 'Email already registered']; + } + + $customerId = generateId('cust_'); + + db()->insert('customers', [ + 'customer_id' => $customerId, + 'email' => strtolower($email), + 'password_hash' => hashPassword($password), + 'name' => $name, + 'phone' => $phone + ]); + + // Auto login after registration + $_SESSION['customer'] = [ + 'customer_id' => $customerId, + 'email' => strtolower($email), + 'name' => $name + ]; + + return ['success' => true, 'customer_id' => $customerId]; + } + + public static function createGuest($email, $name = null, $phone = null) { + // Check if customer exists + $existing = db()->fetch( + "SELECT customer_id FROM customers WHERE email = :email", + ['email' => strtolower($email)] + ); + + if ($existing) { + return $existing['customer_id']; + } + + $customerId = generateId('cust_'); + + db()->insert('customers', [ + 'customer_id' => $customerId, + 'email' => strtolower($email), + 'name' => $name, + 'phone' => $phone, + 'is_guest' => 1 + ]); + + return $customerId; + } + + public static function requestPasswordReset($email) { + $customer = db()->fetch( + "SELECT customer_id FROM customers WHERE email = :email AND password_hash IS NOT NULL", + ['email' => strtolower($email)] + ); + + if (!$customer) { + return false; + } + + $token = bin2hex(random_bytes(32)); + $expiresAt = date('Y-m-d H:i:s', strtotime('+1 hour')); + + db()->insert('password_reset_tokens', [ + 'email' => strtolower($email), + 'token' => $token, + 'user_type' => 'customer', + 'expires_at' => $expiresAt + ]); + + // Send email + $resetUrl = SITE_URL . '/reset-password.php?token=' . $token; + $html = " +

Password Reset Request

+

Click the link below to reset your password:

+

{$resetUrl}

+

This link will expire in 1 hour.

+

If you didn't request this, please ignore this email.

+ "; + + sendEmail($email, 'Password Reset - ' . SITE_NAME, $html); + + return true; + } + + public static function resetPassword($token, $newPassword) { + $reset = db()->fetch( + "SELECT * FROM password_reset_tokens + WHERE token = :token AND user_type = 'customer' AND used = 0 AND expires_at > NOW()", + ['token' => $token] + ); + + if (!$reset) { + return ['error' => 'Invalid or expired token']; + } + + // Update password + db()->update('customers', + ['password_hash' => hashPassword($newPassword)], + 'email = :email', + ['email' => $reset['email']] + ); + + // Mark token as used + db()->update('password_reset_tokens', + ['used' => 1], + 'id = :id', + ['id' => $reset['id']] + ); + + return ['success' => true]; + } +} + +// Initialize session on include +initSession(); diff --git a/sites/tomsjavajive.com/public_html/includes/db.php b/sites/tomsjavajive.com/public_html/includes/db.php new file mode 100644 index 0000000..b77ac6f --- /dev/null +++ b/sites/tomsjavajive.com/public_html/includes/db.php @@ -0,0 +1,104 @@ +pdo = new PDO($dsn, DB_USER, DB_PASS, DB_OPTIONS); + } catch (PDOException $e) { + if (ENVIRONMENT === 'development') { + die("Database connection failed: " . $e->getMessage()); + } else { + die("Database connection failed. Please try again later."); + } + } + } + + public static function getInstance() { + if (self::$instance === null) { + self::$instance = new self(); + } + return self::$instance; + } + + public function getConnection() { + return $this->pdo; + } + + public function query($sql, $params = []) { + $stmt = $this->pdo->prepare($sql); + $stmt->execute($params); + return $stmt; + } + + public function fetch($sql, $params = []) { + return $this->query($sql, $params)->fetch(); + } + + public function fetchAll($sql, $params = []) { + return $this->query($sql, $params)->fetchAll(); + } + + public function insert($table, $data) { + $columns = implode(', ', array_keys($data)); + $placeholders = ':' . implode(', :', array_keys($data)); + + $sql = "INSERT INTO {$table} ({$columns}) VALUES ({$placeholders})"; + $this->query($sql, $data); + + return $this->pdo->lastInsertId(); + } + + public function update($table, $data, $where, $whereParams = []) { + $set = []; + foreach (array_keys($data) as $column) { + $set[] = "{$column} = :{$column}"; + } + $setString = implode(', ', $set); + + $sql = "UPDATE {$table} SET {$setString} WHERE {$where}"; + return $this->query($sql, array_merge($data, $whereParams))->rowCount(); + } + + public function delete($table, $where, $params = []) { + $sql = "DELETE FROM {$table} WHERE {$where}"; + return $this->query($sql, $params)->rowCount(); + } + + public function count($table, $where = '1=1', $params = []) { + $sql = "SELECT COUNT(*) as count FROM {$table} WHERE {$where}"; + $result = $this->fetch($sql, $params); + return $result['count'] ?? 0; + } + + public function lastInsertId() { + return $this->pdo->lastInsertId(); + } + + public function beginTransaction() { + return $this->pdo->beginTransaction(); + } + + public function commit() { + return $this->pdo->commit(); + } + + public function rollback() { + return $this->pdo->rollBack(); + } +} + +// Helper function to get database instance +function db() { + return Database::getInstance(); +} diff --git a/sites/tomsjavajive.com/public_html/includes/email.php b/sites/tomsjavajive.com/public_html/includes/email.php new file mode 100644 index 0000000..bc4f0b2 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/includes/email.php @@ -0,0 +1,327 @@ +apiKey = getSetting('cybermail_api_key', defined('CYBERMAIL_API_KEY') ? CYBERMAIL_API_KEY : ''); + $this->fromEmail = getSetting('cybermail_from_email', defined('SENDER_EMAIL') ? SENDER_EMAIL : 'noreply@tomsjavajive.com'); + $this->fromName = getSetting('cybermail_from_name', defined('SENDER_NAME') ? SENDER_NAME : "Tom's Java Jive"); + } + + private function logEmail(string $to, string $subject, string $html, array $result, array $options = []): void { + try { + $preview = strip_tags($html); + $preview = preg_replace('/\s+/', ' ', trim($preview)); + $preview = substr($preview, 0, 500); + + // Find customer_id by email + $customer = db()->fetch( + "SELECT customer_id FROM customers WHERE email = :email LIMIT 1", + ['email' => $to] + ); + + db()->insert('email_log', [ + 'customer_id' => $customer['customer_id'] ?? null, + 'recipient_email'=> $to, + 'subject' => $subject, + 'preview' => $preview, + 'message_id' => $result['message_id'] ?? null, + 'status' => $result['success'] ? 'sent' : 'failed', + 'error_message' => $result['success'] ? null : ($result['error'] ?? null), + 'tags' => isset($options['tags']) ? implode(',', $options['tags']) : null, + ]); + } catch (\Throwable $e) { + // Never let logging break email sending + } + } + + public function checkDeliveryStatus(string $messageId): array { + $ch = curl_init("https://platform.cyberpersons.com/email/v1/messages/" . urlencode($messageId)); + curl_setopt_array($ch, [ + CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $this->apiKey], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 10, + ]); + $response = curl_exec($ch); + curl_close($ch); + $body = json_decode($response, true); + return $body['data'] ?? []; + } + + public function send(string $to, string $subject, string $html, ?string $text = null, array $options = []): array { + // Strip internal-only keys that are not CyberMail API fields + $apiOptions = array_diff_key($options, array_flip(['metadata'])); + $payload = array_merge([ + 'from' => $this->fromEmail, + 'from_name' => $this->fromName, + 'to' => $to, + 'subject' => $subject, + 'html' => $html, + ], $apiOptions); + if ($text) $payload['text'] = $text; + + $ch = curl_init('https://platform.cyberpersons.com/email/v1/send'); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => json_encode($payload), + CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $this->apiKey, 'Content-Type: application/json'], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + CURLOPT_SSL_VERIFYPEER => true, + ]); + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlErr = curl_error($ch); + curl_close($ch); + + if ($curlErr) { + error_log('[TJJ Email] cURL error: ' . $curlErr); + $result = ['success' => false, 'error' => $curlErr, 'code' => 0]; + $this->logEmail($to, $subject, $html, $result, $options); + return $result; + } + + $body = json_decode($response, true); + if ($httpCode === 202) { + $result = ['success' => true, 'message_id' => $body['data']['message_id'] ?? null]; + $this->logEmail($to, $subject, $html, $result, $options); + return $result; + } + $errorMsg = $body['error']['message'] ?? 'Unknown error'; + $result = ['success' => false, 'error' => $errorMsg, 'code' => $httpCode]; + $this->logEmail($to, $subject, $html, $result, $options); + return $result; + } + + public function sendOrderConfirmation(array $order): array { + $items = json_decode($order['items'], true); + $itemsHtml = ''; + foreach ($items as $item) { + $itemsHtml .= sprintf( + '%s + %d + %s', + htmlspecialchars($item['name']), + $item['quantity'], + formatCurrency($item['total']) + ); + } + $html = $this->getTemplate('order_confirmation', [ + 'order_number' => htmlspecialchars($order['order_number']), + 'customer_name' => htmlspecialchars($order['customer_name'] ?? 'Valued Customer'), + 'items_html' => $itemsHtml, + 'subtotal' => formatCurrency($order['subtotal']), + 'tax' => formatCurrency($order['tax']), + 'discount' => $order['discount'] > 0 ? '-' . formatCurrency($order['discount']) : '$0.00', + 'total' => formatCurrency($order['total']), + 'payment_method' => ucfirst($order['payment_method'] ?? 'N/A'), + 'order_date' => date('F j, Y', strtotime($order['created_at'])) + ]); + return $this->send( + $order['customer_email'], + "Order Confirmation - #{$order['order_number']}", + $html, + null, + ['tags' => ['order', 'confirmation'], 'metadata' => ['order_id' => $order['order_number']]] + ); + } + + public function sendShippingNotification(array $order): array { + $html = $this->getTemplate('shipping_notification', [ + 'order_number' => htmlspecialchars($order['order_number']), + 'customer_name' => htmlspecialchars($order['customer_name'] ?? 'Valued Customer'), + 'tracking_number' => htmlspecialchars($order['tracking_number']), + 'tracking_url' => htmlspecialchars($order['tracking_url'] ?? '#'), + 'carrier' => htmlspecialchars($order['shipping_carrier'] ?? 'Our shipping partner') + ]); + return $this->send( + $order['customer_email'], + "Your Order Has Shipped - #{$order['order_number']}", + $html, + null, + ['tags' => ['order', 'shipping'], 'metadata' => ['order_id' => $order['order_number']]] + ); + } + + public function sendPasswordReset(string $email, string $resetToken, string $name = ''): array { + $resetUrl = SITE_URL . '/reset-password.php?token=' . $resetToken; + $html = $this->getTemplate('password_reset', [ + 'customer_name' => htmlspecialchars($name ?: 'there'), + 'reset_url' => htmlspecialchars($resetUrl), + 'expires' => '1 hour' + ]); + return $this->send($email, "Reset Your Password - Tom's Java Jive", $html, null, ['tags' => ['password-reset']]); + } + + public function sendWelcome(string $email, string $name = ''): array { + $html = $this->getTemplate('welcome', [ + 'customer_name' => htmlspecialchars($name ?: 'Coffee Lover'), + 'shop_url' => SITE_URL . '/shop.php' + ]); + return $this->send($email, "Welcome to Tom's Java Jive!", $html, null, ['tags' => ['welcome']]); + } + + public function sendAbandonedCartReminder(array $cart): array { + $items = json_decode($cart['items'], true); + $itemsHtml = ''; + foreach ($items as $item) { + $itemsHtml .= sprintf( + '
%s - %s
', + htmlspecialchars($item['name']), + formatCurrency($item['price']) + ); + } + $html = $this->getTemplate('abandoned_cart', [ + 'items_html' => $itemsHtml, + 'total' => formatCurrency($cart['subtotal']), + 'cart_url' => SITE_URL . '/cart.php' + ]); + return $this->send($cart['customer_email'], "You left something behind!", $html, null, ['tags' => ['abandoned-cart']]); + } + + private function getTemplate(string $name, array $vars = []): string { + $year = date('Y'); + $templates = [ + 'order_confirmation' => ' +
+
+

Order Confirmed!

+
+
+

Hi {{customer_name}},

+

Thank you for your order! We\'ve received it and will begin processing right away.

+
+

Order #{{order_number}}

+

{{order_date}}

+
+ + + + + + + {{items_html}} +
ItemQtyPrice
+
+

Subtotal: {{subtotal}}

+

Tax: {{tax}}

+

Discount: {{discount}}

+

Total: {{total}}

+
+

Payment Method: {{payment_method}}

+
+
+

© ' . $year . ' Tom\'s Java Jive. All rights reserved.

+
+
', + + 'shipping_notification' => ' +
+
+

Your Order Has Shipped!

+
+
+

Hi {{customer_name}},

+

Great news! Your order #{{order_number}} is on its way.

+
+

Tracking Number

+

{{tracking_number}}

+

Carrier: {{carrier}}

+
+ +
+
+

© ' . $year . ' Tom\'s Java Jive. All rights reserved.

+
+
', + + 'password_reset' => ' +
+
+

Reset Your Password

+
+
+

Hi {{customer_name}},

+

We received a request to reset your password. Click the button below to create a new one:

+ +

This link expires in {{expires}}. If you didn\'t request this, ignore this email.

+
+
+

© ' . $year . ' Tom\'s Java Jive. All rights reserved.

+
+
', + + 'welcome' => ' +
+
+

Welcome to the Family!

+
+
+

Hi {{customer_name}},

+

Welcome to Tom\'s Java Jive! We\'re thrilled to have you join our community of coffee lovers.

+

Here\'s what you can look forward to:

+
    +
  • Exclusive member discounts
  • +
  • Early access to new roasts
  • +
  • Reward points on every purchase
  • +
  • Birthday treats and special offers
  • +
+ +

Cheers,
The Tom\'s Java Jive Team

+
+
+

© ' . $year . ' Tom\'s Java Jive. All rights reserved.

+
+
', + + 'abandoned_cart' => ' +
+
+

Forget Something?

+
+
+

Hey there!

+

We noticed you left some amazing items in your cart. Don\'t let them get away!

+
+ {{items_html}} +
Total: {{total}}
+
+ +
+
+

© ' . $year . ' Tom\'s Java Jive. All rights reserved.

+
+
' + ]; + + $template = $templates[$name] ?? '

Email template not found.

'; + foreach ($vars as $key => $value) { + $template = str_replace('{{' . $key . '}}', $value, $template); + } + return $template; + } +} + +function emailService(): Email { + static $instance = null; + if ($instance === null) { + $instance = new Email(); + } + return $instance; +} diff --git a/sites/tomsjavajive.com/public_html/includes/footer.php b/sites/tomsjavajive.com/public_html/includes/footer.php new file mode 100644 index 0000000..4e6bb8f --- /dev/null +++ b/sites/tomsjavajive.com/public_html/includes/footer.php @@ -0,0 +1,61 @@ + + + +
+
+ + + +
+
+ + + + + + diff --git a/sites/tomsjavajive.com/public_html/includes/functions.php b/sites/tomsjavajive.com/public_html/includes/functions.php new file mode 100644 index 0000000..e8228d7 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/includes/functions.php @@ -0,0 +1,422 @@ + HASH_COST]); +} + +/** + * Verify password + */ +function verifyPassword($password, $hash) { + return password_verify($password, $hash); +} + +/** + * Sanitize input + */ +function sanitize($input) { + if (is_array($input)) { + return array_map('sanitize', $input); + } + return htmlspecialchars(trim($input), ENT_QUOTES, 'UTF-8'); +} + +/** + * Format currency + */ +function formatCurrency($amount) { + return TJJ_CURRENCY_SYMBOL . number_format((float)$amount, 2); +} + +/** + * Format date + */ +function formatDate($date, $format = 'M j, Y') { + return date($format, strtotime($date)); +} + +/** + * Format datetime + */ +function formatDateTime($date, $format = 'M j, Y g:i A') { + return date($format, strtotime($date)); +} + +/** + * JSON response helper + */ +function jsonResponse($data, $statusCode = 200) { + http_response_code($statusCode); + header('Content-Type: application/json'); + echo json_encode($data); + exit; +} + +/** + * Redirect helper + */ +function redirect($url) { + header("Location: $url"); + exit; +} + +/** + * Get current URL + */ +function currentUrl() { + $protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http'; + return $protocol . '://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; +} + +/** + * Check if request is AJAX + */ +function isAjax() { + return !empty($_SERVER['HTTP_X_REQUESTED_WITH']) && + strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest'; +} + +/** + * Get client IP address + */ +function getClientIp() { + $ip = $_SERVER['REMOTE_ADDR']; + if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { + $ip = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])[0]; + } elseif (!empty($_SERVER['HTTP_CLIENT_IP'])) { + $ip = $_SERVER['HTTP_CLIENT_IP']; + } + return trim($ip); +} + +/** + * Generate CSRF token + */ +function generateCsrfToken() { + if (empty($_SESSION[CSRF_TOKEN_NAME])) { + $_SESSION[CSRF_TOKEN_NAME] = bin2hex(random_bytes(32)); + } + return $_SESSION[CSRF_TOKEN_NAME]; +} + +/** + * Verify CSRF token + */ +function verifyCsrfToken($token) { + return isset($_SESSION[CSRF_TOKEN_NAME]) && hash_equals($_SESSION[CSRF_TOKEN_NAME], $token); +} + +/** + * Get setting value + */ +function getSetting($key, $default = null) { + $result = db()->fetch( + "SELECT setting_value FROM settings WHERE setting_key = :key", + ['key' => $key] + ); + if ($result) { + return json_decode($result['setting_value'], true) ?? $result['setting_value']; + } + return $default; +} + +/** + * Update setting value + */ +function setSetting($key, $value) { + $jsonValue = json_encode($value); + $existing = db()->fetch( + "SELECT id FROM settings WHERE setting_key = :key", + ['key' => $key] + ); + + if ($existing) { + db()->update('settings', ['setting_value' => $jsonValue], 'setting_key = :key', ['key' => $key]); + } else { + db()->insert('settings', ['setting_key' => $key, 'setting_value' => $jsonValue]); + } +} + +/** + * Flash message helpers + */ +function setFlash($type, $message) { + $_SESSION['flash'][$type] = $message; +} + +function getFlash($type) { + if (isset($_SESSION['flash'][$type])) { + $message = $_SESSION['flash'][$type]; + unset($_SESSION['flash'][$type]); + return $message; + } + return null; +} + +function hasFlash($type) { + return isset($_SESSION['flash'][$type]); +} + +/** + * Pagination helper + */ +function paginate($totalItems, $currentPage, $perPage = ITEMS_PER_PAGE) { + $totalPages = ceil($totalItems / $perPage); + $currentPage = max(1, min($currentPage, $totalPages)); + $offset = ($currentPage - 1) * $perPage; + + return [ + 'total_items' => $totalItems, + 'total_pages' => $totalPages, + 'current_page' => $currentPage, + 'per_page' => $perPage, + 'offset' => $offset, + 'has_prev' => $currentPage > 1, + 'has_next' => $currentPage < $totalPages + ]; +} + +/** + * Render pagination HTML + */ +function renderPagination($pagination, $baseUrl) { + if ($pagination['total_pages'] <= 1) return ''; + + $cur = $pagination['current_page']; + $total = $pagination['total_pages']; + // baseUrl may already contain query params; append page with & + $sep = strpos($baseUrl, '?') !== false ? '&' : '?'; + $url = fn($p) => $baseUrl . $sep . 'page=' . $p; + + $btn = fn($href, $label, $active = false, $disabled = false) => + '' . $label . ''; + + $html = '
'; + + // Prev + $html .= $btn($url($cur - 1), '← Prev', false, !$pagination['has_prev']); + + // Page numbers with ellipsis + $pages = []; + for ($i = 1; $i <= $total; $i++) { + if ($i === 1 || $i === $total || abs($i - $cur) <= 2) { + $pages[] = $i; + } + } + $prev = null; + foreach ($pages as $p) { + if ($prev !== null && $p - $prev > 1) { + $html .= ''; + } + $html .= $btn($url($p), $p, $p === $cur); + $prev = $p; + } + + // Next + $html .= $btn($url($cur + 1), 'Next →', false, !$pagination['has_next']); + + $html .= '
'; + return $html; +} + +/** + * Truncate text + */ +function truncate($text, $length = 100, $suffix = '...') { + if (strlen($text) <= $length) return $text; + return substr($text, 0, $length) . $suffix; +} + +/** + * Slugify text + */ +function slugify($text) { + $text = preg_replace('~[^\pL\d]+~u', '-', $text); + $text = iconv('utf-8', 'us-ascii//TRANSLIT', $text); + $text = preg_replace('~[^-\w]+~', '', $text); + $text = trim($text, '-'); + $text = preg_replace('~-+~', '-', $text); + return strtolower($text); +} + +/** + * Get cart from session + */ +function getCart() { + return $_SESSION['cart'] ?? []; +} + +/** + * Add item to cart + */ +function addToCart($productId, $quantity = 1) { + if (!isset($_SESSION['cart'])) { + $_SESSION['cart'] = []; + } + + if (isset($_SESSION['cart'][$productId])) { + $_SESSION['cart'][$productId] += $quantity; + } else { + $_SESSION['cart'][$productId] = $quantity; + } +} + +/** + * Update cart item quantity + */ +function updateCartItem($productId, $quantity) { + if ($quantity <= 0) { + removeFromCart($productId); + } else { + $_SESSION['cart'][$productId] = $quantity; + } +} + +/** + * Remove item from cart + */ +function removeFromCart($productId) { + unset($_SESSION['cart'][$productId]); +} + +/** + * Clear cart + */ +function clearCart() { + $_SESSION['cart'] = []; +} + +/** + * Get cart count + */ +function getCartCount() { + return array_sum($_SESSION['cart'] ?? []); +} + +/** + * Get cart total + */ +function getCartTotal() { + $total = 0; + $cart = getCart(); + + foreach ($cart as $productId => $quantity) { + $product = db()->fetch( + "SELECT price, sale_price FROM products WHERE product_id = :id AND is_active = 1", + ['id' => $productId] + ); + if ($product) { + $price = $product['sale_price'] ?? $product['price']; + $total += $price * $quantity; + } + } + + return $total; +} + +/** + * Send email via CyberMail API. + * Thin wrapper around Email::send() (includes/email.php) so there is a + * single implementation of the CyberMail HTTP call, TLS settings, and + * error handling instead of two copies that can drift out of sync. + */ +function sendEmail($to, $subject, $htmlContent, $textContent = '') { + require_once __DIR__ . '/email.php'; + $result = emailService()->send($to, $subject, $htmlContent, $textContent ?: null); + return $result['success'] ?? false; +} + +/** + * Log activity + */ +function logActivity($action, $details = [], $userId = null) { + // Implement activity logging if needed +} + +/** + * Validate and save an uploaded image, verifying the ACTUAL file content + * (not the client-supplied MIME type or filename extension, both of which + * are attacker-controlled) before writing it to disk. Used by every admin + * image-upload endpoint so the checks live in exactly one place. + * + * @param string $fieldName Key in $_FILES to read + * @param string $destDir Absolute directory to save into (created if missing) + * @param string $prefix Filename prefix, e.g. "product_" or "splash_" + * @return array ['success' => bool, 'url' => string, 'path' => string] or ['error' => string] + */ +function handleImageUpload(string $fieldName, string $destDir, string $prefix = 'img_'): array { + if (empty($_FILES[$fieldName]) || $_FILES[$fieldName]['error'] !== UPLOAD_ERR_OK) { + return ['error' => 'No file received']; + } + + $file = $_FILES[$fieldName]; + + if ($file['size'] > MAX_UPLOAD_SIZE) { + return ['error' => 'File too large. Maximum ' . (MAX_UPLOAD_SIZE / (1024 * 1024)) . 'MB.']; + } + + // Never trust $file['type'] (client-supplied) or the extension in + // $file['name'] (attacker-supplied) — inspect the real file bytes. + $imageInfo = @getimagesize($file['tmp_name']); + if ($imageInfo === false) { + return ['error' => 'File is not a valid image.']; + } + + $extensionByMime = [ + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/gif' => 'gif', + 'image/webp' => 'webp', + ]; + $mime = $imageInfo['mime']; + if (!in_array($mime, ALLOWED_IMAGE_TYPES, true) || !isset($extensionByMime[$mime])) { + return ['error' => 'Invalid file type. Use JPG, PNG, WebP, or GIF.']; + } + + if (!is_dir($destDir)) { + mkdir($destDir, 0755, true); + } + + $filename = $prefix . time() . '_' . bin2hex(random_bytes(4)) . '.' . $extensionByMime[$mime]; + $filepath = rtrim($destDir, '/\\') . '/' . $filename; + + if (!move_uploaded_file($file['tmp_name'], $filepath)) { + return ['error' => 'Failed to save file. Check directory permissions.']; + } + + return ['success' => true, 'filename' => $filename, 'path' => $filepath]; +} diff --git a/sites/tomsjavajive.com/public_html/includes/header.php b/sites/tomsjavajive.com/public_html/includes/header.php new file mode 100644 index 0000000..28ad50b --- /dev/null +++ b/sites/tomsjavajive.com/public_html/includes/header.php @@ -0,0 +1,122 @@ + + + + + + + <?= $pageTitle ?? SITE_NAME ?> + + "> + "> + + + + + + + "> + "> + + + + + "> + "> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
diff --git a/sites/tomsjavajive.com/public_html/includes/loyalty.php b/sites/tomsjavajive.com/public_html/includes/loyalty.php new file mode 100644 index 0000000..925d43f --- /dev/null +++ b/sites/tomsjavajive.com/public_html/includes/loyalty.php @@ -0,0 +1,509 @@ +loadTiers(); + } + + private function loadTiers(): void { + $rows = db()->fetchAll("SELECT * FROM loyalty_tiers ORDER BY min_points ASC"); + foreach ($rows as $row) { + $key = strtolower($row['name']); + $benefits = json_decode($row['benefits'] ?? '[]', true) ?? []; + $this->tiers[$key] = [ + 'name' => $row['name'], + 'min_points' => (int) $row['min_points'], + 'multiplier' => (float) $row['multiplier'], + 'benefits' => $benefits, + 'color' => $row['color'] ?? '#888', + 'icon' => 'fa-coffee', + ]; + } + // Fallback if DB is empty + if (empty($this->tiers)) { + $this->tiers = [ + 'bronze' => ['name'=>'Bronze', 'min_points'=>0, 'multiplier'=>1.0, 'benefits'=>[], 'color'=>'#CD7F32', 'icon'=>'fa-coffee'], + 'silver' => ['name'=>'Silver', 'min_points'=>500, 'multiplier'=>1.5, 'benefits'=>[], 'color'=>'#C0C0C0', 'icon'=>'fa-mug-hot'], + 'gold' => ['name'=>'Gold', 'min_points'=>1500, 'multiplier'=>2.0, 'benefits'=>[], 'color'=>'#FFD700', 'icon'=>'fa-crown'], + 'platinum' => ['name'=>'Platinum', 'min_points'=>3000, 'multiplier'=>3.0, 'benefits'=>[], 'color'=>'#E5E4E2', 'icon'=>'fa-gem'], + ]; + } + } + + // Points redemption rates + private float $pointsToValue = 0.01; // 1 point = $0.01 (100 points = $1) + + /** + * Get all tier definitions + */ + public function getTiers(): array { + return $this->tiers; + } + + /** + * Get a specific tier + */ + public function getTier(string $tierKey): ?array { + return $this->tiers[$tierKey] ?? null; + } + + /** + * Determine customer's tier based on total points earned (lifetime) + */ + public function calculateTier(int $lifetimePoints): string { + $currentTier = 'bronze'; + + foreach ($this->tiers as $key => $tier) { + if ($lifetimePoints >= $tier['min_points']) { + $currentTier = $key; + } + } + + return $currentTier; + } + + /** + * Get customer's current tier info + */ + public function getCustomerTier(string $customerId): array { + $customer = db()->fetch( + "SELECT reward_points, lifetime_points, loyalty_tier FROM customers WHERE customer_id = :id", + ['id' => $customerId] + ); + + if (!$customer) { + return ['tier' => 'bronze', 'info' => $this->tiers['bronze'], 'points' => 0]; + } + + $lifetimePoints = $customer['lifetime_points'] ?? $customer['reward_points'] ?? 0; + $tierKey = $this->calculateTier($lifetimePoints); + $tier = $this->tiers[$tierKey]; + + // Calculate progress to next tier + $nextTierKey = $this->getNextTier($tierKey); + $nextTier = $nextTierKey ? $this->tiers[$nextTierKey] : null; + + $progress = 100; + $pointsToNext = 0; + + if ($nextTier) { + $currentMin = $tier['min_points']; + $nextMin = $nextTier['min_points']; + $pointsToNext = $nextMin - $lifetimePoints; + $progress = min(100, (($lifetimePoints - $currentMin) / ($nextMin - $currentMin)) * 100); + } + + return [ + 'tier' => $tierKey, + 'info' => $tier, + 'points' => $customer['reward_points'] ?? 0, + 'lifetime_points' => $lifetimePoints, + 'next_tier' => $nextTierKey, + 'next_tier_info' => $nextTier, + 'points_to_next' => $pointsToNext, + 'progress_percent' => round($progress, 1) + ]; + } + + /** + * Get next tier key + */ + private function getNextTier(string $currentTier): ?string { + $keys = array_keys($this->tiers); + $index = array_search($currentTier, $keys); + + return isset($keys[$index + 1]) ? $keys[$index + 1] : null; + } + + /** + * Award points for a purchase + */ + public function awardPoints(string $customerId, float $amount, string $description = 'Purchase', string $orderId = ''): array { + // Prevent duplicate awards for the same order + if ($orderId) { + $already = db()->fetch( + "SELECT id FROM loyalty_transactions WHERE order_id = :oid AND type = 'earn' LIMIT 1", + ['oid' => $orderId] + ); + if ($already) { + return ['points_earned' => 0, 'already_awarded' => true]; + } + } + + $customerTier = $this->getCustomerTier($customerId); + $multiplier = $customerTier['info']['multiplier']; + + // Calculate points (base: 1 point per dollar) + $basePoints = (int) floor($amount); + $bonusPoints = (int) floor($basePoints * ($multiplier - 1)); + $totalPoints = $basePoints + $bonusPoints; + + // Update customer points + db()->query( + "UPDATE customers SET + reward_points = reward_points + :points, + lifetime_points = COALESCE(lifetime_points, 0) + :points2, + updated_at = NOW() + WHERE customer_id = :id", + ['points' => $totalPoints, 'points2' => $totalPoints, 'id' => $customerId] + ); + + $newBalance = db()->fetch( + "SELECT reward_points FROM customers WHERE customer_id = :id", + ['id' => $customerId] + )['reward_points'] ?? $totalPoints; + + // Log the transaction + db()->insert('loyalty_transactions', [ + 'transaction_id' => generateId('lt_'), + 'customer_id' => $customerId, + 'points' => $totalPoints, + 'balance_after' => $newBalance, + 'type' => 'earn', + 'description' => $description . ($bonusPoints > 0 ? " (+{$bonusPoints} bonus)" : ''), + 'reference_amount' => $amount, + 'order_id' => $orderId ?: null, + ]); + + // Check for tier upgrade + $newTier = $this->checkTierUpgrade($customerId, $customerTier['tier']); + + return [ + 'points_earned' => $totalPoints, + 'base_points' => $basePoints, + 'bonus_points' => $bonusPoints, + 'multiplier' => $multiplier, + 'tier_upgraded' => $newTier !== null, + 'new_tier' => $newTier + ]; + } + + /** + * Redeem points for credit + */ + public function redeemPoints(string $customerId, int $points): array { + $customer = db()->fetch( + "SELECT reward_points FROM customers WHERE customer_id = :id", + ['id' => $customerId] + ); + + if (!$customer || $customer['reward_points'] < $points) { + return ['success' => false, 'error' => 'Insufficient points']; + } + + $creditValue = $points * $this->pointsToValue; + + // Deduct points + db()->query( + "UPDATE customers SET reward_points = reward_points - :points, updated_at = NOW() WHERE customer_id = :id", + ['points' => $points, 'id' => $customerId] + ); + + // Log the redemption + db()->insert('loyalty_transactions', [ + 'transaction_id' => generateId('lt_'), + 'customer_id' => $customerId, + 'points' => -$points, + 'type' => 'redeem', + 'description' => "Redeemed for " . formatCurrency($creditValue) . " credit", + 'reference_amount' => $creditValue + ]); + + // Add to wallet + $newBalance = db()->fetch( + "SELECT wallet_balance FROM customers WHERE customer_id = :id", + ['id' => $customerId] + )['wallet_balance'] ?? 0; + + $newBalance += $creditValue; + + db()->query( + "UPDATE customers SET wallet_balance = :balance WHERE customer_id = :id", + ['balance' => $newBalance, 'id' => $customerId] + ); + + // Log wallet transaction + db()->insert('wallet_transactions', [ + 'transaction_id' => generateId('wt_'), + 'customer_id' => $customerId, + 'amount' => $creditValue, + 'balance_after' => $newBalance, + 'type' => 'loyalty_redemption', + 'description' => "Redeemed {$points} loyalty points" + ]); + + return [ + 'success' => true, + 'points_redeemed' => $points, + 'credit_value' => $creditValue, + 'new_points_balance' => $customer['reward_points'] - $points, + 'new_wallet_balance' => $newBalance + ]; + } + + /** + * Check and process tier upgrade + */ + public function checkTierUpgrade(string $customerId, string $currentTier): ?string { + $customer = db()->fetch( + "SELECT lifetime_points, loyalty_tier FROM customers WHERE customer_id = :id", + ['id' => $customerId] + ); + + if (!$customer) { + return null; + } + + $calculatedTier = $this->calculateTier($customer['lifetime_points'] ?? 0); + $storedTier = $customer['loyalty_tier'] ?? 'bronze'; + + // Compare tier levels + $tierOrder = ['bronze', 'silver', 'gold', 'platinum']; + $calculatedIndex = array_search($calculatedTier, $tierOrder); + $storedIndex = array_search($storedTier, $tierOrder); + + if ($calculatedIndex > $storedIndex) { + // Upgrade! + db()->query( + "UPDATE customers SET loyalty_tier = :tier, updated_at = NOW() WHERE customer_id = :id", + ['tier' => $calculatedTier, 'id' => $customerId] + ); + + // Log the upgrade + db()->insert('loyalty_transactions', [ + 'transaction_id' => generateId('lt_'), + 'customer_id' => $customerId, + 'points' => 0, + 'type' => 'tier_upgrade', + 'description' => "Upgraded from {$this->tiers[$storedTier]['name']} to {$this->tiers[$calculatedTier]['name']}" + ]); + + // Send notifications + $this->sendTierUpgradeNotifications($customerId, $calculatedTier); + + return $calculatedTier; + } + + return null; + } + + /** + * Send tier upgrade notifications + */ + private function sendTierUpgradeNotifications(string $customerId, string $newTier): void { + $customer = db()->fetch( + "SELECT email, phone, name FROM customers WHERE customer_id = :id", + ['id' => $customerId] + ); + + if (!$customer) return; + + $tierInfo = $this->tiers[$newTier]; + + // Send email notification + if (!empty($customer['email'])) { + require_once __DIR__ . '/email.php'; + // Custom email for tier upgrade would go here + } + + // Send SMS notification + if (!empty($customer['phone'])) { + require_once __DIR__ . '/sms.php'; + sendSMS()->sendTierUpgrade($customer['phone'], $tierInfo['name']); + } + + // Send push notification + require_once __DIR__ . '/push.php'; + pushNotify()->sendTierNotification($customerId, $tierInfo['name'], $tierInfo['benefits']); + } + + /** + * Get points conversion info + */ + public function getConversionInfo(): array { + return [ + 'points_per_dollar' => 1, + 'points_value' => $this->pointsToValue, + 'points_for_one_dollar' => intval(1 / $this->pointsToValue), + 'description' => 'Earn 1 point for every $1 spent. Redeem 100 points for $1 credit.' + ]; + } + + /** + * Get customer's loyalty history + */ + public function getHistory(string $customerId, int $limit = 20): array { + return db()->fetchAll( + "SELECT * FROM loyalty_transactions WHERE customer_id = :id ORDER BY created_at DESC LIMIT :limit", + ['id' => $customerId, 'limit' => $limit] + ); + } + + /** + * Award birthday bonus + */ + public function awardBirthdayBonus(string $customerId): array { + $customerTier = $this->getCustomerTier($customerId); + + // Bonus points based on tier + $bonusPoints = match($customerTier['tier']) { + 'platinum' => 500, + 'gold' => 300, + 'silver' => 200, + default => 100 + }; + + db()->query( + "UPDATE customers SET reward_points = reward_points + :points WHERE customer_id = :id", + ['points' => $bonusPoints, 'id' => $customerId] + ); + + db()->insert('loyalty_transactions', [ + 'transaction_id' => generateId('lt_'), + 'customer_id' => $customerId, + 'points' => $bonusPoints, + 'type' => 'birthday_bonus', + 'description' => "Birthday reward - Happy Birthday!" + ]); + + return ['success' => true, 'points' => $bonusPoints]; + } + + /** + * Award referral bonus + */ + public function awardReferralBonus(string $referrerId, string $newCustomerId): array { + $referrerBonus = 100; + $newCustomerBonus = 50; + + // Award to referrer + db()->query( + "UPDATE customers SET reward_points = reward_points + :points WHERE customer_id = :id", + ['points' => $referrerBonus, 'id' => $referrerId] + ); + + db()->insert('loyalty_transactions', [ + 'transaction_id' => generateId('lt_'), + 'customer_id' => $referrerId, + 'points' => $referrerBonus, + 'type' => 'referral_bonus', + 'description' => "Referral bonus - Thank you for spreading the word!" + ]); + + // Award to new customer + db()->query( + "UPDATE customers SET reward_points = reward_points + :points WHERE customer_id = :id", + ['points' => $newCustomerBonus, 'id' => $newCustomerId] + ); + + db()->insert('loyalty_transactions', [ + 'transaction_id' => generateId('lt_'), + 'customer_id' => $newCustomerId, + 'points' => $newCustomerBonus, + 'type' => 'referral_welcome', + 'description' => "Welcome bonus from referral" + ]); + + return [ + 'referrer_bonus' => $referrerBonus, + 'new_customer_bonus' => $newCustomerBonus + ]; + } + + /** + * Redeem a gift card code into wallet balance. Shared by the Wallet page + * and checkout so both go through the same transaction logic instead of + * duplicating it. + */ + public function redeemGiftCardToWallet(string $customerId, string $rawCode): array { + $code = strtoupper(str_replace(['-', ' '], '', trim($rawCode))); + + if (empty($code) || strlen($code) < 8) { + return ['success' => false, 'error' => 'Invalid gift card code']; + } + + $giftCard = db()->fetch( + "SELECT * FROM gift_cards WHERE code = :code AND is_active = 1", + ['code' => $code] + ); + + if (!$giftCard) { + return ['success' => false, 'error' => 'Gift card not found or already used']; + } + + if ($giftCard['current_balance'] <= 0) { + return ['success' => false, 'error' => 'This gift card has no remaining balance']; + } + + if ($giftCard['expires_at'] && strtotime($giftCard['expires_at']) < time()) { + return ['success' => false, 'error' => 'This gift card has expired']; + } + + $customer = db()->fetch("SELECT wallet_balance FROM customers WHERE customer_id = :id", ['id' => $customerId]); + if (!$customer) { + return ['success' => false, 'error' => 'Customer not found']; + } + + $amount = $giftCard['current_balance']; + + try { + db()->query("START TRANSACTION"); + + db()->query( + "UPDATE gift_cards SET current_balance = 0, is_active = 0 WHERE gift_card_id = :id", + ['id' => $giftCard['gift_card_id']] + ); + + db()->insert('gift_card_transactions', [ + 'gift_card_id' => $giftCard['gift_card_id'], + 'amount' => -$amount, + 'balance_after' => 0, + 'type' => 'redemption', + ]); + + $newWalletBalance = (float) $customer['wallet_balance'] + $amount; + + db()->query( + "UPDATE customers SET wallet_balance = :balance, updated_at = NOW() WHERE customer_id = :id", + ['balance' => $newWalletBalance, 'id' => $customerId] + ); + + db()->insert('wallet_transactions', [ + 'transaction_id' => generateId('wt_'), + 'customer_id' => $customerId, + 'amount' => $amount, + 'balance_after' => $newWalletBalance, + 'type' => 'deposit', + 'description' => 'Gift card redeemed: ' . $code + ]); + + db()->query("COMMIT"); + + return [ + 'success' => true, + 'amount' => $amount, + 'new_balance' => $newWalletBalance + ]; + } catch (Exception $e) { + db()->query("ROLLBACK"); + return ['success' => false, 'error' => 'Failed to redeem gift card. Please try again.']; + } + } +} + +// Helper function +function loyalty(): LoyaltyProgram { + static $instance = null; + if ($instance === null) { + $instance = new LoyaltyProgram(); + } + return $instance; +} diff --git a/sites/tomsjavajive.com/public_html/includes/push.php b/sites/tomsjavajive.com/public_html/includes/push.php new file mode 100644 index 0000000..d102c4e --- /dev/null +++ b/sites/tomsjavajive.com/public_html/includes/push.php @@ -0,0 +1,181 @@ +publicKey = getSetting('vapid_public_key', 'YOUR_VAPID_PUBLIC_KEY'); + $this->privateKey = getSetting('vapid_private_key', 'YOUR_VAPID_PRIVATE_KEY'); + $this->subject = 'mailto:' . getSetting('admin_email', 'admin@tomsjavajive.com'); + } + + /** + * Get VAPID public key for client + */ + public function getPublicKey(): string { + return $this->publicKey; + } + + /** + * Send push notification to a subscription + */ + public function send(array $subscription, string $title, string $body, array $options = []): array { + $payload = json_encode([ + 'title' => $title, + 'body' => $body, + 'icon' => $options['icon'] ?? '/assets/icons/icon-192.png', + 'badge' => $options['badge'] ?? '/assets/icons/badge-72.png', + 'url' => $options['url'] ?? '/', + 'tag' => $options['tag'] ?? null, + 'data' => $options['data'] ?? [] + ]); + + // For now, we'll store notifications for when the user comes online + // Full web push requires a library like minishlink/web-push + // This is a simplified version that works with the service worker + + try { + // Store notification for retrieval + $notificationId = generateId('notif_'); + db()->insert('push_notifications', [ + 'notification_id' => $notificationId, + 'subscription_endpoint' => $subscription['endpoint'], + 'payload' => $payload, + 'status' => 'pending', + 'created_at' => date('Y-m-d H:i:s') + ]); + + return ['success' => true, 'notification_id' => $notificationId]; + } catch (Exception $e) { + return ['success' => false, 'error' => $e->getMessage()]; + } + } + + /** + * Send notification to all subscribed users + */ + public function broadcast(string $title, string $body, array $options = []): array { + $subscriptions = db()->fetchAll("SELECT * FROM push_subscriptions WHERE is_active = 1"); + + $results = ['sent' => 0, 'failed' => 0]; + + foreach ($subscriptions as $sub) { + $subscription = [ + 'endpoint' => $sub['endpoint'], + 'keys' => [ + 'p256dh' => $sub['p256dh_key'], + 'auth' => $sub['auth_key'] + ] + ]; + + $result = $this->send($subscription, $title, $body, $options); + + if ($result['success']) { + $results['sent']++; + } else { + $results['failed']++; + } + } + + return $results; + } + + /** + * Send order status update notification + */ + public function sendOrderUpdate(string $customerId, array $order, string $status): array { + $subscription = $this->getCustomerSubscription($customerId); + + if (!$subscription) { + return ['success' => false, 'error' => 'No subscription found']; + } + + $messages = [ + 'confirmed' => "Your order #{$order['order_number']} has been confirmed!", + 'processing' => "We're preparing your order #{$order['order_number']}", + 'shipped' => "Your order #{$order['order_number']} is on its way!", + 'delivered' => "Your order #{$order['order_number']} has been delivered!", + 'ready' => "Your order #{$order['order_number']} is ready for pickup!" + ]; + + return $this->send( + $subscription, + "Order Update", + $messages[$status] ?? "Order #{$order['order_number']} status: {$status}", + [ + 'url' => "/account/order.php?id={$order['order_id']}", + 'tag' => "order-{$order['order_id']}" + ] + ); + } + + /** + * Send promotional notification + */ + public function sendPromotion(string $customerId, string $title, string $message, string $url = '/shop.php'): array { + $subscription = $this->getCustomerSubscription($customerId); + + if (!$subscription) { + return ['success' => false, 'error' => 'No subscription found']; + } + + return $this->send($subscription, $title, $message, ['url' => $url]); + } + + /** + * Send loyalty tier notification + */ + public function sendTierNotification(string $customerId, string $tierName, array $benefits): array { + $subscription = $this->getCustomerSubscription($customerId); + + if (!$subscription) { + return ['success' => false, 'error' => 'No subscription found']; + } + + return $this->send( + $subscription, + "Congratulations! You're now {$tierName}!", + "Enjoy new benefits: " . implode(', ', array_slice($benefits, 0, 2)), + ['url' => '/account/'] + ); + } + + /** + * Get customer's push subscription + */ + private function getCustomerSubscription(string $customerId): ?array { + $sub = db()->fetch( + "SELECT * FROM push_subscriptions WHERE customer_id = :id AND is_active = 1 ORDER BY created_at DESC LIMIT 1", + ['id' => $customerId] + ); + + if (!$sub) { + return null; + } + + return [ + 'endpoint' => $sub['endpoint'], + 'keys' => [ + 'p256dh' => $sub['p256dh_key'], + 'auth' => $sub['auth_key'] + ] + ]; + } +} + +// Helper function for easy access +function pushNotify(): PushNotification { + static $instance = null; + if ($instance === null) { + $instance = new PushNotification(); + } + return $instance; +} diff --git a/sites/tomsjavajive.com/public_html/includes/sms.php b/sites/tomsjavajive.com/public_html/includes/sms.php new file mode 100644 index 0000000..513f247 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/includes/sms.php @@ -0,0 +1,195 @@ +accountSid = getSetting('twilio_account_sid', 'YOUR_TWILIO_ACCOUNT_SID'); + $this->authToken = getSetting('twilio_auth_token', 'YOUR_TWILIO_AUTH_TOKEN'); + $this->fromNumber = getSetting('twilio_phone_number', '+1234567890'); + } + + /** + * Send SMS via Twilio API + */ + public function send(string $to, string $message): array { + // Ensure phone number is in E.164 format + $to = $this->formatPhoneNumber($to); + + if (!$to) { + return ['success' => false, 'error' => 'Invalid phone number']; + } + + $url = "https://api.twilio.com/2010-04-01/Accounts/{$this->accountSid}/Messages.json"; + + $data = [ + 'To' => $to, + 'From' => $this->fromNumber, + 'Body' => $message + ]; + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => http_build_query($data), + CURLOPT_USERPWD => "{$this->accountSid}:{$this->authToken}", + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30 + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + curl_close($ch); + + if ($error) { + return ['success' => false, 'error' => $error]; + } + + $result = json_decode($response, true); + + if ($httpCode >= 200 && $httpCode < 300) { + return [ + 'success' => true, + 'sid' => $result['sid'] ?? null, + 'status' => $result['status'] ?? 'queued' + ]; + } + + return [ + 'success' => false, + 'error' => $result['message'] ?? 'Failed to send SMS', + 'code' => $result['code'] ?? $httpCode + ]; + } + + /** + * Format phone number to E.164 format + */ + private function formatPhoneNumber(string $phone): ?string { + // Remove all non-numeric characters except + + $phone = preg_replace('/[^0-9+]/', '', $phone); + + // If already in E.164 format + if (preg_match('/^\+[1-9]\d{1,14}$/', $phone)) { + return $phone; + } + + // US number without country code + if (preg_match('/^1?\d{10}$/', $phone)) { + $phone = preg_replace('/^1?/', '', $phone); + return '+1' . $phone; + } + + return null; + } + + /** + * Send order confirmation SMS + */ + public function sendOrderConfirmation(array $order, string $phone): array { + $message = "Tom's Java Jive: Your order #{$order['order_number']} has been confirmed! " . + "Total: " . formatCurrency($order['total']) . ". " . + "Thank you for your purchase!"; + + return $this->send($phone, $message); + } + + /** + * Send shipping notification SMS + */ + public function sendShippingNotification(array $order, string $phone): array { + $message = "Tom's Java Jive: Your order #{$order['order_number']} has shipped! " . + "Tracking: {$order['tracking_number']}. " . + "Track at: " . ($order['tracking_url'] ?? SITE_URL); + + return $this->send($phone, $message); + } + + /** + * Send delivery notification SMS + */ + public function sendDeliveryNotification(array $order, string $phone): array { + $message = "Tom's Java Jive: Great news! Your order #{$order['order_number']} " . + "has been delivered. Enjoy your coffee!"; + + return $this->send($phone, $message); + } + + /** + * Send password reset SMS + */ + public function sendPasswordResetCode(string $phone, string $code): array { + $message = "Tom's Java Jive: Your password reset code is {$code}. " . + "This code expires in 15 minutes. Don't share it with anyone."; + + return $this->send($phone, $message); + } + + /** + * Send OTP verification SMS + */ + public function sendVerificationCode(string $phone, string $code): array { + $message = "Tom's Java Jive: Your verification code is {$code}. " . + "Valid for 10 minutes."; + + return $this->send($phone, $message); + } + + /** + * Send promotional SMS (with opt-out info) + */ + public function sendPromotion(string $phone, string $promoMessage): array { + $message = "Tom's Java Jive: {$promoMessage} " . + "Reply STOP to unsubscribe."; + + return $this->send($phone, $message); + } + + /** + * Send order ready for pickup SMS + */ + public function sendReadyForPickup(array $order, string $phone): array { + $message = "Tom's Java Jive: Your order #{$order['order_number']} is ready for pickup! " . + "Show this message at the counter."; + + return $this->send($phone, $message); + } + + /** + * Send low wallet balance alert + */ + public function sendLowBalanceAlert(string $phone, float $balance): array { + $message = "Tom's Java Jive: Your wallet balance is " . formatCurrency($balance) . ". " . + "Top up now to continue enjoying fast checkout!"; + + return $this->send($phone, $message); + } + + /** + * Send loyalty tier upgrade notification + */ + public function sendTierUpgrade(string $phone, string $tierName): array { + $message = "Tom's Java Jive: Congratulations! You've reached {$tierName} status! " . + "Enjoy your new benefits and rewards. Thank you for being a loyal customer!"; + + return $this->send($phone, $message); + } +} + +// Helper function for easy access +function sendSMS(): TwilioSMS { + static $instance = null; + if ($instance === null) { + $instance = new TwilioSMS(); + } + return $instance; +} diff --git a/sites/tomsjavajive.com/public_html/includes/square.php b/sites/tomsjavajive.com/public_html/includes/square.php new file mode 100644 index 0000000..ae14e76 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/includes/square.php @@ -0,0 +1,175 @@ + true, + CURLOPT_TIMEOUT => 30, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/json', + 'Authorization: Bearer ' . SQUARE_ACCESS_TOKEN, + 'Square-Version: 2024-01-18', + ], + ]; + if ($method !== 'GET') { + $opts[CURLOPT_CUSTOMREQUEST] = $method; + $opts[CURLOPT_POSTFIELDS] = json_encode($body ?: new stdClass()); + } + curl_setopt_array($ch, $opts); + + $resp = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $err = curl_error($ch); + curl_close($ch); + + if ($err) { + throw new Exception('Square API connection error: ' . $err); + } + + $decoded = json_decode($resp ?: '{}', true); + + if (!empty($decoded['errors'])) { + $msg = $decoded['errors'][0]['detail'] ?? ($decoded['errors'][0]['code'] ?? 'Unknown Square error'); + throw new Exception($msg); + } + + if ($httpCode >= 400) { + throw new Exception('Square API error (HTTP ' . $httpCode . ')'); + } + + return $decoded; +} + +/** + * Create a payment. $sourceId is the nonce from the Web Payments SDK's tokenize() call. + */ +function squareCreatePayment(string $sourceId, float $amount, string $referenceId, array $options = []): array { + $body = [ + 'source_id' => $sourceId, + 'idempotency_key' => $referenceId . '_' . bin2hex(random_bytes(8)), + 'amount_money' => [ + 'amount' => (int) round($amount * 100), + 'currency' => 'USD', + ], + 'location_id' => SQUARE_LOCATION_ID, + 'autocomplete' => true, + 'reference_id' => $referenceId, + ]; + if (!empty($options['note'])) { + $body['note'] = $options['note']; + } + return squareApi('POST', '/payments', $body); +} + +function squareGetPayment(string $paymentId): array { + return squareApi('GET', '/payments/' . $paymentId); +} + +function squareRefundPayment(string $paymentId, float $amount, string $reason = ''): array { + $body = [ + 'idempotency_key' => 'refund_' . $paymentId . '_' . bin2hex(random_bytes(6)), + 'amount_money' => [ + 'amount' => (int) round($amount * 100), + 'currency' => 'USD', + ], + 'payment_id' => $paymentId, + ]; + if ($reason) { + $body['reason'] = substr($reason, 0, 192); + } + return squareApi('POST', '/refunds', $body); +} + +function isSquareConfigured(): bool { + return defined('SQUARE_ACCESS_TOKEN') && defined('SQUARE_APP_ID') && defined('SQUARE_LOCATION_ID') + && !empty(SQUARE_ACCESS_TOKEN) && !empty(SQUARE_APP_ID) && !empty(SQUARE_LOCATION_ID) + && SQUARE_ACCESS_TOKEN !== 'REPLACE_ME'; +} + +/** + * Square's webhook signature: base64(HMAC-SHA256(notification_url . raw_body, signature_key)). + * The notification URL must exactly match what's configured in the Square Dashboard subscription. + */ +function verifySquareWebhookSignature(string $payload, string $signature, string $notificationUrl, string $signatureKey): bool { + if (empty($signatureKey) || empty($signature)) { + return false; + } + $expected = base64_encode(hash_hmac('sha256', $notificationUrl . $payload, $signatureKey, true)); + return hash_equals($expected, $signature); +} + +/** + * Single source of truth for "an order's Square payment resolved" — called from the + * synchronous create-payment response, the webhook, and the reconciliation poll, so + * loyalty points/emails are never awarded or sent more than once regardless of which + * path resolves the order first. + */ +function markSquarePaymentResult(string $paymentId, ?string $orderId, string $status): void { + $order = $orderId + ? db()->fetch("SELECT * FROM orders WHERE order_id = :id", ['id' => $orderId]) + : db()->fetch("SELECT * FROM orders WHERE square_payment_id = :pid", ['pid' => $paymentId]); + + if (!$order) { + return; + } + + if ($status === 'COMPLETED') { + if ($order['order_status'] !== 'confirmed') { + db()->update('orders', + [ + 'payment_status' => 'paid', + 'order_status' => 'confirmed', + 'square_payment_id' => $paymentId, + 'payment_method' => 'square', + ], + 'order_id = :id', + ['id' => $order['order_id']] + ); + + if (!empty($order['customer_id'])) { + loyalty()->awardPoints( + $order['customer_id'], + (float) $order['total'], + 'Order #' . $order['order_number'], + $order['order_id'] + ); + } + + if (!empty($order['wallet_amount_used']) && (float) $order['wallet_amount_used'] > 0 && !empty($order['customer_id'])) { + $walletAmount = (float) $order['wallet_amount_used']; + $cust = db()->fetch("SELECT wallet_balance FROM customers WHERE customer_id = :id", ['id' => $order['customer_id']]); + if ($cust) { + $newBalance = max(0, (float) $cust['wallet_balance'] - $walletAmount); + db()->update('customers', ['wallet_balance' => $newBalance], 'customer_id = :id', ['id' => $order['customer_id']]); + db()->insert('wallet_transactions', [ + 'transaction_id' => generateId('wt_'), + 'customer_id' => $order['customer_id'], + 'amount' => -$walletAmount, + 'balance_after' => $newBalance, + 'type' => 'purchase', + 'description' => 'Applied to Order #' . $order['order_number'], + ]); + } + } + + $updated = db()->fetch("SELECT * FROM orders WHERE order_id = :id", ['id' => $order['order_id']]); + if ($updated) { + emailService()->sendOrderConfirmation($updated); + } + } + } elseif (in_array($status, ['FAILED', 'CANCELED'], true)) { + db()->update('orders', + ['payment_status' => 'failed'], + 'order_id = :id', + ['id' => $order['order_id']] + ); + } +} diff --git a/sites/tomsjavajive.com/public_html/includes/stripe.php b/sites/tomsjavajive.com/public_html/includes/stripe.php new file mode 100644 index 0000000..5dbf9b1 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/includes/stripe.php @@ -0,0 +1,215 @@ +secretKey = $secretKey ?: STRIPE_SECRET_KEY; + } + + /** + * Make a cURL request to Stripe API + */ + private function request($method, $endpoint, $data = []) { + $url = $this->apiBase . $endpoint; + + $ch = curl_init(); + + $headers = [ + 'Authorization: Bearer ' . $this->secretKey, + 'Content-Type: application/x-www-form-urlencoded' + ]; + + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + curl_setopt($ch, CURLOPT_TIMEOUT, 30); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); + + if ($method === 'POST') { + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data)); + } elseif ($method === 'GET' && !empty($data)) { + $url .= '?' . http_build_query($data); + curl_setopt($ch, CURLOPT_URL, $url); + } + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + curl_close($ch); + + if ($error) { + throw new Exception('Stripe API Error: ' . $error); + } + + $decoded = json_decode($response, true); + + if ($httpCode >= 400) { + $errorMsg = $decoded['error']['message'] ?? 'Unknown Stripe error'; + throw new Exception($errorMsg); + } + + return $decoded; + } + + /** + * Create a Payment Intent + */ + public function createPaymentIntent($amount, $currency = 'usd', $options = []) { + $data = [ + 'amount' => (int)($amount * 100), // Convert to cents + 'currency' => strtolower($currency), + 'automatic_payment_methods' => ['enabled' => 'true'] + ]; + + if (!empty($options['metadata'])) { + foreach ($options['metadata'] as $key => $value) { + $data["metadata[$key]"] = $value; + } + } + + if (!empty($options['receipt_email'])) { + $data['receipt_email'] = $options['receipt_email']; + } + + if (!empty($options['description'])) { + $data['description'] = $options['description']; + } + + return $this->request('POST', '/payment_intents', $data); + } + + /** + * Retrieve a Payment Intent + */ + public function getPaymentIntent($paymentIntentId) { + return $this->request('GET', '/payment_intents/' . $paymentIntentId); + } + + /** + * Create a Checkout Session (hosted payment page) + */ + public function createCheckoutSession($lineItems, $successUrl, $cancelUrl, $options = []) { + $data = [ + 'mode' => $options['mode'] ?? 'payment', + 'success_url' => $successUrl, + 'cancel_url' => $cancelUrl + ]; + + // Add line items + foreach ($lineItems as $i => $item) { + $data["line_items[$i][price_data][currency]"] = $item['currency'] ?? 'usd'; + $data["line_items[$i][price_data][product_data][name]"] = $item['name']; + $data["line_items[$i][price_data][unit_amount]"] = (int)($item['price'] * 100); + $data["line_items[$i][quantity]"] = $item['quantity'] ?? 1; + + if (!empty($item['description'])) { + $data["line_items[$i][price_data][product_data][description]"] = $item['description']; + } + } + + if (!empty($options['customer_email'])) { + $data['customer_email'] = $options['customer_email']; + } + + if (!empty($options['metadata'])) { + foreach ($options['metadata'] as $key => $value) { + $data["metadata[$key]"] = $value; + $data["payment_intent_data[metadata][$key]"] = $value; + } + } + + return $this->request('POST', '/checkout/sessions', $data); + } + + /** + * Retrieve a Checkout Session + */ + public function getCheckoutSession($sessionId) { + return $this->request('GET', '/checkout/sessions/' . $sessionId); + } + + /** + * Verify webhook signature + */ + public function verifyWebhookSignature($payload, $sigHeader, $webhookSecret) { + if (empty($webhookSecret)) { + return true; // Skip verification if secret not configured + } + + $elements = explode(',', $sigHeader); + $timestamp = null; + $signatures = []; + + foreach ($elements as $element) { + $parts = explode('=', $element, 2); + if (count($parts) === 2) { + if ($parts[0] === 't') { + $timestamp = $parts[1]; + } elseif ($parts[0] === 'v1') { + $signatures[] = $parts[1]; + } + } + } + + if (empty($timestamp) || empty($signatures)) { + throw new Exception('Invalid signature format'); + } + + // Check timestamp tolerance (5 minutes) + if (abs(time() - $timestamp) > 300) { + throw new Exception('Timestamp outside tolerance'); + } + + $signedPayload = $timestamp . '.' . $payload; + $expectedSignature = hash_hmac('sha256', $signedPayload, $webhookSecret); + + foreach ($signatures as $sig) { + if (hash_equals($expectedSignature, $sig)) { + return true; + } + } + + throw new Exception('Signature verification failed'); + } + + /** + * Create a refund + */ + public function createRefund($paymentIntentId, $amount = null) { + $data = ['payment_intent' => $paymentIntentId]; + + if ($amount !== null) { + $data['amount'] = (int)($amount * 100); + } + + return $this->request('POST', '/refunds', $data); + } +} + +/** + * Get Stripe instance + */ +function stripe() { + static $stripe = null; + if ($stripe === null) { + $stripe = new StripeAPI(); + } + return $stripe; +} + +/** + * Check if Stripe is properly configured + */ +function isStripeConfigured() { + return !empty(STRIPE_SECRET_KEY) && + STRIPE_SECRET_KEY !== 'sk_test_your_stripe_key' && + !empty(STRIPE_PUBLISHABLE_KEY) && + STRIPE_PUBLISHABLE_KEY !== 'pk_test_your_stripe_key'; +} diff --git a/sites/tomsjavajive.com/public_html/index.php b/sites/tomsjavajive.com/public_html/index.php new file mode 100644 index 0000000..bf1702d --- /dev/null +++ b/sites/tomsjavajive.com/public_html/index.php @@ -0,0 +1,223 @@ + +'; +require_once __DIR__ . '/includes/functions.php'; + +// Get about us sections +$aboutSections = db()->fetchAll( + "SELECT * FROM about_us_sections WHERE is_active = 1 ORDER BY sort_order ASC" +); + +// Get homepage splashes +$splashBlocks = db()->fetchAll( + "SELECT * FROM homepage_splashes WHERE is_active = 1 ORDER BY sort_order ASC, id ASC" +); + +// Get featured products +$featuredProducts = db()->fetchAll( + "SELECT * FROM products WHERE is_active = 1 AND is_featured = 1 ORDER BY created_at DESC LIMIT 4" +); + +// If no featured products, get latest products +if (empty($featuredProducts)) { + $featuredProducts = db()->fetchAll( + "SELECT * FROM products WHERE is_active = 1 ORDER BY created_at DESC LIMIT 4" + ); +} + +$metaTitle = "Fresh Roasted Artisan Coffee | Tom's Java Jive"; +$metaDescription = "Premium artisan coffee beans freshly roasted and delivered to your door. Shop single origin, blends, and specialty coffee from Tom's Java Jive in Weatherford, Texas."; +$metaKeywords = 'artisan coffee beans, fresh roasted coffee, single origin coffee, specialty coffee, Weatherford Texas'; +$canonicalUrl = 'https://tomsjavajive.com/'; +require_once __DIR__ . '/includes/header.php'; +?> + + +
+
+

Premium Coffee, Delivered Fresh

+

Artisan roasted coffee beans sourced from the world's finest growing regions. Experience the perfect cup, every time.

+ +
+
+ + + + 4; ?> +
+
+ + + +
+ +
+
+ + <?= htmlspecialchars($sp['title']) ?> + + + +
+

+

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

Featured Products

+

Our most popular coffee selections

+
+ +
+ +
+

Products coming soon! Check back later.

+ Add Products +
+ + +
+ + <?= htmlspecialchars($product['name']) ?> + + Sale + + +
+ +
+ +

+ +

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

Our Story

+ + +

+ + +

+ +

+ + + Explore Our Coffee +
+
+ Coffee brewing +
+
+
+
+ + + + + + + + diff --git a/sites/tomsjavajive.com/public_html/login.php b/sites/tomsjavajive.com/public_html/login.php new file mode 100644 index 0000000..db664d4 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/login.php @@ -0,0 +1,94 @@ + + +
+
+
+
+

Welcome Back

+ + +
+ + +
+ + +
+
+ + +
+ +
+ + +
+ +
+ + Forgot password? +
+ + +
+ +
+ +

+ Don't have an account? Create one +

+ +

+ Or track your order with your order number +

+
+
+
+
+ + diff --git a/sites/tomsjavajive.com/public_html/logout.php b/sites/tomsjavajive.com/public_html/logout.php new file mode 100644 index 0000000..23933be --- /dev/null +++ b/sites/tomsjavajive.com/public_html/logout.php @@ -0,0 +1,10 @@ +fetchAll( + "SELECT p.*, pt.name AS type_name + FROM products p + LEFT JOIN product_types pt ON p.product_type_id = pt.type_id + WHERE p.is_active = 1 + ORDER BY p.name, p.category" +); + +echo '' . "\n"; +echo '' . "\n"; +echo '' . "\n"; +echo ' Tom\'s Java Jive' . "\n"; +echo ' ' . $base . '' . "\n"; +echo ' Premium artisan flavored coffee beans, freshly roasted and delivered to your door.' . "\n"; + +foreach ($products as $p) { + $images = json_decode($p['images'] ?? '[]', true); + $imageUrl = !empty($images) ? $base . $images[0] : $base . '/assets/images/placeholder-product.svg'; + + $categoryLabel = $p['category'] === 'Bean' ? 'Whole Bean' : 'Ground'; + $grindNote = $p['category'] === 'Bean' + ? 'Available as whole bean so you can grind fresh for maximum flavor.' + : 'Pre-ground to a versatile medium grind, ready to brew right out of the bag.'; + $title = htmlspecialchars($p['name']); + $rawDesc = $p['description'] ?? ''; + if (!empty($rawDesc)) { + $desc = htmlspecialchars( + strip_tags($rawDesc) . ' ' . $grindNote . ' 12 oz bag. Ships from Weatherford, TX.' + ); + } else { + $desc = htmlspecialchars( + "Tom's Java Jive {$p['name']} flavored artisan coffee, freshly roasted in small batches in Weatherford, Texas. " . + "All-natural flavoring with no artificial additives. {$grindNote} " . + "12 oz bag. Free shipping on orders over \$50." + ); + } + $link = htmlspecialchars($base . '/product.php?id=' . $p['product_id']); + $imageLink = htmlspecialchars($imageUrl); + $price = number_format((float)($p['sale_price'] ?? $p['price']), 2, '.', ''); + $salePrice = $p['sale_price'] ? number_format((float)$p['sale_price'], 2, '.', '') : null; + $avail = (int)$p['stock'] > 0 ? 'in stock' : 'out of stock'; + $id = htmlspecialchars($p['product_id']); + $updatedAt = substr($p['updated_at'] ?? date('Y-m-d'), 0, 10); + + // Google product category for flavored coffee + // https://www.google.com/basepages/producttype/taxonomy-with-ids.en-US.txt + // 2273 = Food, Beverages & Tobacco > Beverages > Coffee + $gCategory = 'Food, Beverages & Tobacco > Beverages > Coffee'; + + echo " \n"; + echo " {$id}\n"; + echo " {$title}\n"; + echo " {$desc}\n"; + echo " {$link}\n"; + echo " {$imageLink}\n"; + echo " new\n"; + echo " {$avail}\n"; + echo " {$price} USD\n"; + if ($salePrice) { + echo " {$salePrice} USD\n"; + } + echo " Tom's Java Jive\n"; + echo " {$gCategory}\n"; + echo " Coffee > Flavored Coffee > {$categoryLabel}\n"; + echo " false\n"; + echo " {$categoryLabel}\n"; + echo " \n"; + echo " US\n"; + echo " Standard\n"; + echo " 5.99 USD\n"; + echo " 1\n"; + echo " 2\n"; + echo " 3\n"; + echo " 7\n"; + echo " \n"; + echo " default\n"; + echo " \n"; +} + +echo '' . "\n"; +echo ''; diff --git a/sites/tomsjavajive.com/public_html/offline.html b/sites/tomsjavajive.com/public_html/offline.html new file mode 100644 index 0000000..08184fc --- /dev/null +++ b/sites/tomsjavajive.com/public_html/offline.html @@ -0,0 +1,119 @@ + + + + + + Offline - Tom's Java Jive + + + +
+
+ + + + +
+ +

You're Offline

+

It looks like you've lost your internet connection. Please check your connection and try again.

+ + + +
+

Some pages you've visited before may still be available offline.

+
+
+ + + + diff --git a/sites/tomsjavajive.com/public_html/order-confirmation.php b/sites/tomsjavajive.com/public_html/order-confirmation.php new file mode 100644 index 0000000..36c1ac6 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/order-confirmation.php @@ -0,0 +1,135 @@ +fetch( + "SELECT * FROM orders WHERE order_id = :id", + ['id' => $orderId] +); + +if (!$order) { + redirect('/'); +} + +// Clear cart and pending order +clearCart(); +unset($_SESSION['pending_order_id']); + +$items = json_decode($order['items'], true) ?? []; +$shippingAddress = json_decode($order['shipping_address'], true) ?? []; + +require_once __DIR__ . '/includes/header.php'; +?> + +
+
+
+
+
+ +
+ +

Thank You!

+

+ Your order has been placed successfully. +

+ +
+
+ Order Number + +
+ +
+ Status + + + +
+ +
+ Total + +
+
+ +
+

Order Details

+ + +
+ + + x + + +
+ + +
+ Subtotal + +
+ +
+ Shipping + 0 ? formatCurrency($order['shipping_cost']) : 'FREE' ?> +
+ + 0): ?> +
+ Wallet / Gift Card + - +
+ + +
+ Total + +
+
+ +
+

Shipping Address

+

+
+
+ , + + +

+
+ +

+ A confirmation email has been sent to . + You will receive tracking information once your order ships. +

+ + +
+
+
+
+ + diff --git a/sites/tomsjavajive.com/public_html/payment.php b/sites/tomsjavajive.com/public_html/payment.php new file mode 100644 index 0000000..981fe54 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/payment.php @@ -0,0 +1,461 @@ +fetch( + "SELECT * FROM orders WHERE order_id = :id", + ['id' => $orderId] +); + +if (!$order) { + redirect('/cart.php'); +} + +if ($order['payment_status'] === 'paid') { + clearCart(); + redirect('/order-confirmation.php?order=' . $orderId); +} + +$total = $order['total']; + +if ($processor === 'square') { + $squareConfigured = isSquareConfigured(); +} else { + $stripePublishableKey = STRIPE_PUBLISHABLE_KEY; + $stripeConfigured = isStripeConfigured(); +} + +require_once __DIR__ . '/includes/header.php'; +?> + +
+
+
+
+

Complete Payment

+
+
+ +
+ Payment was cancelled. Please try again. +
+ + +
+
+ Order # + +
+

+ +

+
+ + + +
+ Demo Mode: Square is not configured. Click below to simulate a successful payment. +
+
+ +
+ +
+ + +
+ +
+
+ +
+
+
+ + +
+ + + + + +
+ Demo Mode: Stripe is not configured. Click below to simulate a successful payment. +
+
+ +
+ +
+ +

or enter card details below

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

+ Your payment is secure and encrypted +

+
+
+
+
+ + + + + + + + diff --git a/sites/tomsjavajive.com/public_html/privacy.php b/sites/tomsjavajive.com/public_html/privacy.php new file mode 100644 index 0000000..73975f9 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/privacy.php @@ -0,0 +1,193 @@ + 'Home', 'url' => 'https://tomsjavajive.com'], + ['name' => 'Privacy Policy', 'url' => 'https://tomsjavajive.com/privacy.php'] +]; +require_once __DIR__ . '/includes/header.php'; +?> + +
+
+ +
+

Privacy Policy

+

Last updated:

+
+ + +
+

Consent

+

By using our website, you hereby consent to our Privacy Policy and agree to its terms.

+
+ + +
+
+

At Tom's Java Jive, accessible from tomsjavajive.com, one of our main priorities is the privacy of our visitors and customers. This Privacy Policy document describes the types of information that is collected and recorded by Tom's Java Jive and how we use it.

+

If you have additional questions or require more information about our Privacy Policy, do not hesitate to contact us.

+
+
+ + '📋', + 'title' => 'Personal Information We Collect', + 'body' => ' +

When you place an order or create an account on our website, we collect certain personal information including your name, email address, shipping address, phone number, and payment information. We also collect information about visitors to our site using the following technologies:

+
    +
  • Cookies — data files placed on your device that often include an anonymous unique identifier. For more information about cookies and how to disable them, visit allaboutcookies.org.
  • +
  • Log files — track actions on the site and collect data including your IP address, browser type, internet service provider, referring/exit pages, and date/time stamps.
  • +
  • Web beacons, tags, and pixels — electronic files used to record information about how you browse our site.
  • +
+ ' + ], + [ + 'icon' => '🔍', + 'title' => 'How We Use Your Personal Information', + 'body' => ' +

We use the personal information we collect to:

+
    +
  • Process and fulfill your orders
  • +
  • Communicate with you about your order, account, or inquiries
  • +
  • Send you updates, promotions, and information about our products (you may opt out at any time)
  • +
  • Improve and optimize our website and customer experience
  • +
  • Comply with applicable laws and regulations
  • +
+ ' + ], + [ + 'icon' => '🤝', + 'title' => 'Sharing Your Personal Information', + 'body' => ' +

We may share your personal information with trusted third parties strictly to fulfill your order, including:

+
    +
  • Payment processors (Stripe) to securely handle transactions
  • +
  • Shipping carriers to deliver your order
  • +
  • Email service providers to send order confirmations and updates
  • +
+

We may also share your information to comply with applicable laws and regulations, to respond to a subpoena, search warrant, or other lawful request for information, or to otherwise protect our rights.

+ ' + ], + [ + 'icon' => '📢', + 'title' => 'Behavioural Advertising', + 'body' => ' +

We may use your personal information to provide you with targeted advertisements or marketing communications we believe may be of interest to you. For more information about how targeted advertising works, visit the Network Advertising Initiative.

+

You can opt out of targeted advertising at aboutads.info/choices or through the Digital Advertising Alliance opt-out portal.

+ ' + ], + [ + 'icon' => '🍪', + 'title' => 'Cookies and Web Beacons', + 'body' => ' +

Tom\'s Java Jive uses cookies to store information including your preferences and the pages you visited on our website. This information is used to optimize your experience by customizing our content based on your browser type and other information.

+

You can choose to disable cookies through your browser settings. For more information about managing cookies in your specific browser, please refer to your browser\'s documentation.

+ ' + ], + [ + 'icon' => '📁', + 'title' => 'Log Files', + 'body' => ' +

Tom\'s Java Jive follows standard practice of using log files. These files log visitors when they visit our website. The information collected includes:

+
    +
  • Internet protocol (IP) addresses
  • +
  • Browser type and Internet Service Provider (ISP)
  • +
  • Date and time stamps
  • +
  • Referring and exit pages
  • +
+

This information is not linked to any personally identifiable data and is used for analyzing trends, administering the site, and gathering demographic information.

+ ' + ], + [ + 'icon' => '🇪🇺', + 'title' => 'General Data Protection Regulation (GDPR)', + 'body' => ' +

Tom\'s Java Jive is a Data Controller of your personal information. Our legal basis for collecting and using your personal information includes:

+
    +
  • Performing a contract with you (e.g. fulfilling your order)
  • +
  • Your consent to do so
  • +
  • Pursuing our legitimate business interests
  • +
  • Compliance with the law
  • +
+

If you are a resident of the European Economic Area (EEA), you have the following data protection rights:

+
    +
  • The right to access, update, or delete the information we hold about you
  • +
  • The right of rectification
  • +
  • The right to object to processing
  • +
  • The right to restriction of processing
  • +
  • The right to data portability
  • +
  • The right to withdraw consent
  • +
+

To exercise any of these rights, please contact us. Please note that your information may be transferred outside of Europe, including to the United States.

+ ' + ], + [ + 'icon' => '🔒', + 'title' => 'Data Retention', + 'body' => ' +

When you place an order through our site, we will maintain your order information for our records unless and until you ask us to delete it. We will retain and use your information to the extent necessary to comply with our legal obligations, resolve disputes, and enforce our policies.

+ ' + ], + [ + 'icon' => '🔗', + 'title' => 'Third-Party Privacy Policies', + 'body' => ' +

Tom\'s Java Jive\'s Privacy Policy does not apply to other advertisers or third-party websites. We advise you to consult the respective privacy policies of any third-party services for more detailed information about their practices.

+

Third-party services we use may utilize technologies such as cookies, JavaScript, or web beacons in their advertisements or service delivery. Tom\'s Java Jive has no access to or control over cookies used by third-party providers.

+ ' + ], + [ + 'icon' => '🌐', + 'title' => 'Online Privacy Policy Only', + 'body' => ' +

This Privacy Policy applies only to our online activities and is valid for visitors to our website with regard to information shared and/or collected through tomsjavajive.com. This policy does not apply to any information collected offline or via channels other than this website.

+ ' + ], + [ + 'icon' => '👶', + 'title' => "Children's Information", + 'body' => ' +

Protecting children\'s privacy online is important to us. We encourage parents and guardians to observe, participate in, and monitor their children\'s online activity.

+

Tom\'s Java Jive does not knowingly collect any personally identifiable information from children under the age of 13. If you believe your child has provided such information on our website, please contact us immediately and we will promptly remove that information from our records.

+ ' + ], + [ + 'icon' => '🔄', + 'title' => 'Changes to This Policy', + 'body' => ' +

We may update this Privacy Policy from time to time to reflect changes to our practices or for operational, legal, or regulatory reasons. When we do, we will update the date at the top of this page. We encourage you to review this policy periodically.

+ ' + ], + ]; + + foreach ($sections as $s): ?> +
+
+
+
+

+
+ +
+
+ + + +
+
+
✉️
+

Contact Us

+

For more information about our privacy practices, if you have questions, or if you would like to make a complaint, please reach out:

+

Tom's Java Jive

+

Weatherford, TX 76088

+

sales@tomsjavajive.com

+
+
+ +
+
+ + diff --git a/sites/tomsjavajive.com/public_html/product.php b/sites/tomsjavajive.com/public_html/product.php new file mode 100644 index 0000000..5f3b72f --- /dev/null +++ b/sites/tomsjavajive.com/public_html/product.php @@ -0,0 +1,317 @@ +'; +require_once __DIR__ . '/includes/functions.php'; + +$productId = $_GET['id'] ?? ''; + +if (!$productId) { + header('Location: /shop.php'); + exit; +} + +// Get product +$product = db()->fetch( + "SELECT * FROM products WHERE product_id = :id AND is_active = 1", + ['id' => $productId] +); + +if (!$product) { + header('Location: /shop.php'); + exit; +} + +// Get product reviews +$reviews = db()->fetchAll( + "SELECT * FROM reviews WHERE product_id = :id AND is_approved = 1 ORDER BY created_at DESC LIMIT 10", + ['id' => $productId] +); + +$avgRating = 0; +$reviewCount = count($reviews); +if ($reviewCount > 0) { + $avgRating = array_sum(array_column($reviews, 'rating')) / $reviewCount; +} + +// Get related products +$relatedProducts = db()->fetchAll( + "SELECT * FROM products WHERE category = :category AND product_id != :id AND is_active = 1 LIMIT 4", + ['category' => $product['category'], 'id' => $productId] +); + +$images = json_decode($product['images'] ?? '[]', true); +$mainImage = !empty($images) ? $images[0] : '/assets/images/placeholder-product.svg'; +$salePrice = $product['sale_price']; +$price = $product['price']; +$inStock = $product['stock'] > 0; +$displayPrice = $salePrice ?? $price; + +// SEO meta +$categoryLabel = ucfirst($product['category'] ?? 'Coffee'); +$metaTitle = $product['name'] . ' ' . $categoryLabel . " Coffee | Tom's Java Jive"; +$metaDescription = truncate(strip_tags($product['description'] ?? ''), 155) + ?: $product['name'] . ' flavored artisan coffee beans freshly roasted in Weatherford, TX. Available in whole bean and ground. Ships nationwide.'; +$metaKeywords = strtolower($product['name']) . ' coffee, ' . strtolower($categoryLabel) . ' coffee, flavored coffee beans, artisan coffee, buy coffee online'; +$canonicalUrl = 'https://tomsjavajive.com/product.php?id=' . $productId; +$ogType = 'product'; +$ogImage = (strpos($mainImage, 'http') === 0) ? $mainImage : 'https://tomsjavajive.com' . $mainImage; + +// Structured data — Product schema +$productSchemaData = [ + '@context' => 'https://schema.org', + '@type' => 'Product', + 'name' => $product['name'] . ' ' . $categoryLabel . ' Coffee', + 'url' => $canonicalUrl, + 'image' => $ogImage, + 'description' => $metaDescription, + 'brand' => ['@type' => 'Brand', 'name' => "Tom's Java Jive"], + 'offers' => [ + '@type' => 'Offer', + 'priceCurrency' => 'USD', + 'price' => number_format((float) $displayPrice, 2, '.', ''), + 'priceValidUntil' => date('Y-12-31', strtotime('+1 year')), + 'availability' => $inStock ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock', + 'url' => $canonicalUrl, + 'seller' => ['@type' => 'Organization', 'name' => "Tom's Java Jive"], + 'shippingDetails' => [ + '@type' => 'OfferShippingDetails', + 'shippingRate' => ['@type' => 'MonetaryAmount', 'value' => '5.99', 'currency' => 'USD'], + 'shippingDestination'=> ['@type' => 'DefinedRegion', 'addressCountry' => 'US'], + 'deliveryTime' => [ + '@type' => 'ShippingDeliveryTime', + 'handlingTime' => ['@type' => 'QuantitativeValue', 'minValue' => 1, 'maxValue' => 2, 'unitCode' => 'DAY'], + 'transitTime' => ['@type' => 'QuantitativeValue', 'minValue' => 3, 'maxValue' => 7, 'unitCode' => 'DAY'], + ], + ], + 'hasMerchantReturnPolicy' => [ + '@type' => 'MerchantReturnPolicy', + 'applicableCountry' => 'US', + 'returnPolicyCategory' => 'https://schema.org/MerchantReturnFiniteReturnWindow', + 'merchantReturnDays' => 30, + 'returnMethod' => 'https://schema.org/ReturnByMail', + 'returnFees' => 'https://schema.org/FreeReturn', + 'returnPolicyLink' => 'https://tomsjavajive.com/returns.php', + ], + ], +]; +if ($reviewCount > 0) { + $productSchemaData['aggregateRating'] = [ + '@type' => 'AggregateRating', + 'ratingValue' => round($avgRating, 1), + 'reviewCount' => $reviewCount, + 'bestRating' => 5, + 'worstRating' => 1, + ]; +} +$productSchema = json_encode($productSchemaData, JSON_UNESCAPED_SLASHES); + +// Breadcrumbs +$breadcrumbs = [ + ['name' => 'Home', 'url' => 'https://tomsjavajive.com/'], + ['name' => 'Shop', 'url' => 'https://tomsjavajive.com/shop.php'], + ['name' => $product['name'], 'url' => $canonicalUrl], +]; + +require_once __DIR__ . '/includes/header.php'; +?> + +
+
+
+ + +
+
+ <?= htmlspecialchars($product['name']) ?> +
+ + 1): ?> +
+ $img): ?> + Product image <?= $index + 1 ?> + +
+ +
+ + +
+ +

+ + + +

+ + +

+ + + 0): ?> +
+
+ + + +
+ ( reviews) +
+ + + +
+ + + + + Save % + + + + +
+ + +

+ + In Stock + + - Only left! + + + Out of Stock + +

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

Description

+
+ +
+
+ + +
+

Product Details

+ + + + + + + + + + + + + + + + + +
SKU
Weight oz
Category
+
+
+
+ + +
+

Customer Reviews

+ + +
+ +
+
+
+
+ + + + Verified Purchase + + +
+ +
+
+ + + +
+ +

+ +

+
+
+ +
+ +

No reviews yet. Be the first to review this product!

+ +
+ + + +
+

Related Products

+ +
+ +
+
+ + diff --git a/sites/tomsjavajive.com/public_html/register.php b/sites/tomsjavajive.com/public_html/register.php new file mode 100644 index 0000000..c399ceb --- /dev/null +++ b/sites/tomsjavajive.com/public_html/register.php @@ -0,0 +1,131 @@ +fetch("SELECT id FROM email_subscribers WHERE email = :email", ['email' => strtolower($email)]); + if (!$existing) { + db()->insert('email_subscribers', [ + 'email' => strtolower($email), + 'name' => $name, + 'source' => 'registration' + ]); + } + } + + setFlash('success', 'Welcome! Your account has been created.'); + redirect('/account/'); + } + } +} + +$metaTitle = "Create Account | Tom's Java Jive"; +$metaDescription = 'Create an account to earn rewards, track orders, and get exclusive deals.'; +$metaKeywords = 'coffee loyalty rewards, coffee subscription account'; +$canonicalUrl = 'https://tomsjavajive.com/register.php'; +require_once __DIR__ . '/includes/header.php'; +?> + +
+
+
+
+

Create Your Account

+ + +
+ + +
+ + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + Minimum 8 characters +
+ +
+ + +
+ +
+ +
+ + +
+ +
+ +

+ Already have an account? Sign in +

+
+
+
+
+ + diff --git a/sites/tomsjavajive.com/public_html/returns.php b/sites/tomsjavajive.com/public_html/returns.php new file mode 100644 index 0000000..0f18aec --- /dev/null +++ b/sites/tomsjavajive.com/public_html/returns.php @@ -0,0 +1,99 @@ + 'Home', 'url' => 'https://tomsjavajive.com'], + ['name' => 'Returns', 'url' => 'https://tomsjavajive.com/returns.php'] +]; +require_once __DIR__ . '/includes/header.php'; +?> + +
+
+ +
+

Returns & Refunds

+

Simple, transparent, and always focused on making things right.

+
+ + +
+
+

At Tom's Java Jive, your satisfaction is our priority. We accept returns and exchanges within 30 days of delivery for any reason — whether the product is defective or you simply changed your mind.

+
+
+
📦
+
30-Day Window
+
Returns & exchanges accepted within 30 days of delivery
+
+
+
🏷️
+
Free Return Label
+
We email you a prepaid label — just download, print, and drop off
+
+
+
💳
+
No Restocking Fees
+
You get back exactly what you paid — no deductions
+
+
+
+
+ + +
+
+
+
+

What We Accept

+
+
    +
  • Defective products — damaged, stale, or incorrect items sent by us
  • +
  • Non-defective products — changed your mind, ordered the wrong flavor or grind
  • +
  • Exchanges — swap for a different product of equal or lesser value
  • +
  • All items must be in their original, unopened packaging (except in cases of defect)
  • +
  • Applies to orders shipped within the United States
  • +
+
+
+ + +
+
+
+
📬
+

How to Return or Exchange

+
+
    +
  1. Email sales@tomsjavajive.com with your order number and reason for the return or exchange.
  2. +
  3. If the item arrived damaged or defective, include a photo — it helps us resolve things faster.
  4. +
  5. We'll respond within 1 business day with a free prepaid return label — download and print it at home.
  6. +
  7. Pack the item securely and drop it off at any compatible carrier location.
  8. +
  9. Once we receive your return, your refund will be processed within 5 business days to your original payment method.
  10. +
+
+
+ + +
+
+
+
💰
+

Refund Details

+
+
    +
  • Refunds are issued to your original payment method
  • +
  • Processed within 5 business days of receiving your return
  • +
  • No restocking fees — you receive the full amount paid
  • +
  • Return shipping is free — we provide a prepaid label
  • +
  • For exchanges, replacement orders ship within 1–2 business days of receiving your return
  • +
+
+
+ +

Have a question about your order? Contact us and we'll take care of you.

+ +
+
+ + diff --git a/sites/tomsjavajive.com/public_html/robots.txt b/sites/tomsjavajive.com/public_html/robots.txt new file mode 100644 index 0000000..7e99f8c --- /dev/null +++ b/sites/tomsjavajive.com/public_html/robots.txt @@ -0,0 +1,11 @@ +User-agent: * +Allow: / +Disallow: /admin/ +Disallow: /account/ +Disallow: /api/ +Disallow: /cart.php +Disallow: /checkout.php +Disallow: /payment.php +Disallow: /config/ +Disallow: /install/ +Sitemap: https://tomsjavajive.com/sitemap.php diff --git a/sites/tomsjavajive.com/public_html/shipping.php b/sites/tomsjavajive.com/public_html/shipping.php new file mode 100644 index 0000000..131e5ad --- /dev/null +++ b/sites/tomsjavajive.com/public_html/shipping.php @@ -0,0 +1,111 @@ + 'Home', 'url' => 'https://tomsjavajive.com'], + ['name' => 'Shipping Info', 'url' => 'https://tomsjavajive.com/shipping.php'] +]; +require_once __DIR__ . '/includes/header.php'; +?> + +
+
+ +
+

Shipping Information

+

Fresh-roasted coffee delivered right to your door.

+
+ + +
+
🚚
+

Free Shipping on Orders Over $50

+

Applies to all domestic orders shipped within the contiguous United States.

+
+ + +
+
+

Shipping Rates

+ + + + + + + + + + + + + + + + + + + + + + + + + +
MethodDelivery TimeRate
Standard Shipping3–5 business days after processing$5.99 (Free over $50)
Expedited Shipping2–3 business days after processing$12.99
Overnight ShippingNext business day after processing$24.99
+
+
+ + +
+
+

Processing Time

+

Every order at Tom's Java Jive is made fresh to ship. Because we roast to order, please allow 1–2 business days for your coffee to be roasted, packaged, and handed off to the carrier. Delivery time is in addition to this processing time.

+

Orders placed before 12:00 PM CST Monday–Friday typically begin processing the same day.

+
+
+ + +
+
+

What to Expect

+
+ '☕', 'title' => 'Order Placed', 'desc' => 'You\'ll receive an order confirmation email immediately.'], + ['icon' => '🔥', 'title' => 'Roasted Fresh', 'desc' => 'Your beans are roasted to order within 1–2 business days.'], + ['icon' => '📦', 'title' => 'Packed & Shipped', 'desc' => 'Your order is carefully packaged and picked up by the carrier. You\'ll receive a tracking number by email.'], + ['icon' => '🚪', 'title' => 'Delivered', 'desc' => 'Fresh coffee arrives at your door, ready to brew.'], + ]; + foreach ($steps as $i => $step): ?> +
+
+
+
+
+
+
+ +
+
+
+ + +
+
+

Additional Notes

+
    +
  • We currently ship within the contiguous United States only. Hawaii, Alaska, and international shipping are not available at this time.
  • +
  • Shipping times may be delayed during holidays or peak seasons.
  • +
  • We are not responsible for delays caused by the carrier once the package has been handed off.
  • +
  • If your package is lost or damaged in transit, please contact us and we will make it right.
  • +
+
+
+ +

Questions about your shipment? Track your order or contact us.

+ +
+
+ + diff --git a/sites/tomsjavajive.com/public_html/shop.php b/sites/tomsjavajive.com/public_html/shop.php new file mode 100644 index 0000000..b0d3c0d --- /dev/null +++ b/sites/tomsjavajive.com/public_html/shop.php @@ -0,0 +1,210 @@ +'; +require_once __DIR__ . '/includes/functions.php'; + +// Get filters +$category = $_GET['category'] ?? ''; +$subcat = $_GET['subcat'] ?? ''; +$search = $_GET['search'] ?? ''; +$sort = $_GET['sort'] ?? 'newest'; +$page = max(1, intval($_GET['page'] ?? 1)); + +// Build query +$where = ['is_active = 1']; +$params = []; + +if ($category) { + $where[] = 'category = :category'; + $params['category'] = $category; +} + +if ($subcat) { + $where[] = 'product_type_id = :subcat'; + $params['subcat'] = $subcat; +} + +if ($search) { + $where[] = '(name LIKE :search OR description LIKE :search)'; + $params['search'] = '%' . $search . '%'; +} + +$whereClause = implode(' AND ', $where); +// Prefix columns that are ambiguous in the JOIN query +$joinWhereClause = str_replace( + ['is_active = 1', 'category =', 'product_type_id =', '(name LIKE', 'description LIKE'], + ['p.is_active = 1', 'p.category =', 'p.product_type_id =', '(p.name LIKE', 'p.description LIKE'], + $whereClause +); + +// Sort +$orderBy = match($sort) { + 'price_low' => 'COALESCE(p.sale_price, p.price) ASC', + 'price_high' => 'COALESCE(p.sale_price, p.price) DESC', + 'name' => 'p.name ASC', + default => 'p.created_at DESC' +}; + +// Get total count +$totalProducts = db()->count('products', $whereClause, $params); +$pagination = paginate($totalProducts, $page, 12); + +// Get products +$products = db()->fetchAll( + "SELECT p.*, pt.name AS type_name, pt.type_id AS type_slug FROM products p LEFT JOIN product_types pt ON p.product_type_id = pt.type_id WHERE {$joinWhereClause} ORDER BY {$orderBy} LIMIT :limit OFFSET :offset", + array_merge($params, ['limit' => $pagination['per_page'], 'offset' => $pagination['offset']]) +); + +// Get categories for filter +$categories = db()->fetchAll( + "SELECT DISTINCT category FROM products WHERE category IS NOT NULL AND category != '' AND is_active = 1 ORDER BY category" +); + +$metaTitle = "Shop Premium Coffee Beans | Tom's Java Jive"; +$metaDescription = 'Browse our selection of premium artisan coffee beans. Single origin, blends, light, medium and dark roasts. Free shipping over $50.'; +$metaKeywords = 'buy coffee beans online, artisan coffee, single origin, blends, light roast, dark roast'; +$canonicalUrl = 'https://tomsjavajive.com/shop.php'; +require_once __DIR__ . '/includes/header.php'; +$productTypesList = db()->fetchAll("SELECT type_id, name, slug FROM product_types WHERE is_active=1 ORDER BY sort_order ASC"); + +?> + + $category, 'subcat' => $subcat])); +$filterQs = $filterQs ? '&' . $filterQs : ''; +?> + + +
+
+
+

Our Coffee Collection

+
+
+ + + + +
+ +
+
+ + +
+ Category: + All + + + + + + + + + Type: + All + + + + + + +
+
+
+ +
+
+ + + +

+ Showing of products + + +

+ + + +
+ +

No products found

+

Try adjusting your search or filters

+ View All Products +
+ +
+ +
+ + <?= htmlspecialchars($product['name']) ?> + + Sale + + Featured + + +
+ +
+ + + + +
+ +

+ +

+
+ + + + +
+ +
+
+ +
+ + + 1): ?> + $category, 'sort' => $sort]))) ?> + + +
+
+ + diff --git a/sites/tomsjavajive.com/public_html/sitemap.php b/sites/tomsjavajive.com/public_html/sitemap.php new file mode 100644 index 0000000..98d7802 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/sitemap.php @@ -0,0 +1,42 @@ +fetchAll( + "SELECT product_id, updated_at FROM products WHERE is_active = 1 ORDER BY created_at DESC" +); + +echo '' . "\n"; +echo '' . "\n"; + +// Static pages +$staticPages = [ + ['loc' => '/', 'priority' => '1.0', 'changefreq' => 'weekly'], + ['loc' => '/shop.php', 'priority' => '0.9', 'changefreq' => 'daily'], + ['loc' => '/about.php', 'priority' => '0.6', 'changefreq' => 'monthly'], + ['loc' => '/contact.php', 'priority' => '0.5', 'changefreq' => 'monthly'], + ['loc' => '/login.php', 'priority' => '0.3', 'changefreq' => 'monthly'], + ['loc' => '/register.php','priority' => '0.3', 'changefreq' => 'monthly'], +]; + +foreach ($staticPages as $p) { + $loc = htmlspecialchars($base . $p['loc']); + echo " {$loc}{$now}{$p['changefreq']}{$p['priority']}\n"; +} + +// Product pages +foreach ($products as $product) { + $loc = htmlspecialchars($base . '/product.php?id=' . $product['product_id']); + $lastmod = substr($product['updated_at'] ?? $now, 0, 10); + echo " {$loc}{$lastmod}weekly0.8\n"; +} + +echo ''; diff --git a/sites/tomsjavajive.com/public_html/sitemap.xml b/sites/tomsjavajive.com/public_html/sitemap.xml new file mode 100644 index 0000000..7a7b66c --- /dev/null +++ b/sites/tomsjavajive.com/public_html/sitemap.xml @@ -0,0 +1,6 @@ + + + https://tomsjavajive.com/2026-05-19weekly1.0 + https://tomsjavajive.com/shop.php2026-05-19daily0.9 + https://tomsjavajive.com/register.php2026-05-19monthly0.4 + \ No newline at end of file diff --git a/sites/tomsjavajive.com/public_html/sw.js b/sites/tomsjavajive.com/public_html/sw.js new file mode 100644 index 0000000..f89efcf --- /dev/null +++ b/sites/tomsjavajive.com/public_html/sw.js @@ -0,0 +1,9 @@ +// Unregister this service worker and clear all caches +self.addEventListener('install', () => self.skipWaiting()); +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys() + .then(keys => Promise.all(keys.map(key => caches.delete(key)))) + .then(() => self.registration.unregister()) + ); +}); diff --git a/sites/tomsjavajive.com/public_html/track-order.php b/sites/tomsjavajive.com/public_html/track-order.php new file mode 100644 index 0000000..d387092 --- /dev/null +++ b/sites/tomsjavajive.com/public_html/track-order.php @@ -0,0 +1,249 @@ + 'Home', 'url' => 'https://tomsjavajive.com'], + ['name' => 'Track Order', 'url' => 'https://tomsjavajive.com/track-order.php'] +]; +require_once __DIR__ . '/includes/header.php'; +require_once __DIR__ . '/includes/auth.php'; + +$order = null; +$error = null; +$searched = false; + +// Pre-fill from query string (e.g. from order confirmation email link) +$prefillOrder = $_GET['order'] ?? ''; +$prefillEmail = $_GET['email'] ?? ''; + +if ($_SERVER['REQUEST_METHOD'] === 'POST' || ($prefillOrder && $prefillEmail)) { + $searched = true; + $orderNum = trim($_POST['order_number'] ?? $prefillOrder); + $email = strtolower(trim($_POST['email'] ?? $prefillEmail)); + + if (empty($orderNum) || empty($email)) { + $error = 'Please enter both your order number and email address.'; + } else { + $order = db()->fetch( + "SELECT * FROM orders WHERE (order_number = :num OR order_id = :id) AND LOWER(customer_email) = :email", + ['num' => $orderNum, 'id' => $orderNum, 'email' => $email] + ); + if (!$order) { + $error = 'No order found with that order number and email. Please double-check and try again.'; + } + } +} + +// If logged in, also show their recent orders +$recentOrders = []; +if (CustomerAuth::isLoggedIn()) { + $user = CustomerAuth::getUser(); + $recentOrders = db()->fetchAll( + "SELECT * FROM orders WHERE customer_id = :cid ORDER BY created_at DESC LIMIT 5", + ['cid' => $user['customer_id']] + ); +} + +$statusSteps = ['pending','confirmed','processing','shipped','delivered']; +$statusLabels = [ + 'pending' => 'Order Received', + 'confirmed' => 'Confirmed', + 'processing' => 'Roasting & Packing', + 'shipped' => 'Shipped', + 'delivered' => 'Delivered', +]; +$statusIcons = [ + 'pending' => '📋', + 'confirmed' => '✅', + 'processing' => '☕', + 'shipped' => '🚚', + 'delivered' => '🏠', +]; +?> + +
+
+ +
+

Track Your Order

+

Enter your order number and email to see your order status.

+
+ + +
+
+
+
+
+ + +
+
+ + +
+
+ +
+
+
+ + +
+ +
+ + + + +
+
+ +
+
+
Order Number
+
+
+
+
Placed
+
+
+
+ + +
+
+
Order Cancelled
+
If you have questions, please contact us.
+
+ + +
+
+ +
+ +
+ + $step): ?> + +
+
+ +
+
+ +
+
+ +
+
+ + + + +
+
+
Tracking Number
+
+
+ + + Track Shipment → + + +
+ +
+ 🔔 A tracking number will be emailed to you once your order ships. +
+ + + +
+
Items Ordered
+
+ +
+ × + $ +
+ +
+ Total + $ +
+
+
+ + + +
+ Shipping to: + , + , + , + + +
+ +
+
+ + + + +
+
+

Your Recent Orders

+
+ +
+
+
+
· $
+
+
+ + + +
+ + + +
+
+
+ +
+
+
+ + + +

+ Can't find your order? Contact us and we'll look it up for you. +

+ + +
+
+ + diff --git a/sites/tomtomgames.com/public_html/.gitignore b/sites/tomtomgames.com/public_html/.gitignore new file mode 100644 index 0000000..c90ad4e --- /dev/null +++ b/sites/tomtomgames.com/public_html/.gitignore @@ -0,0 +1,5 @@ +*.log +.DS_Store +*.swp + +uploads/ diff --git a/sites/tomtomgames.com/public_html/.htaccess b/sites/tomtomgames.com/public_html/.htaccess new file mode 100644 index 0000000..ac8b99d --- /dev/null +++ b/sites/tomtomgames.com/public_html/.htaccess @@ -0,0 +1,124 @@ +# ══════════════════════════════════════════════════════════ +# TomTomGames Security Configuration +# ══════════════════════════════════════════════════════════ + +Options -Indexes -Includes +ServerSignature Off + +# ── Block all sensitive file types ─────────────────────── + + Order allow,deny + Deny from all + + +# ── Block direct access to sensitive PHP files ─────────── + + Order allow,deny + Deny from all + + +# ── Block access to includes and vendor folders ────────── + + RewriteEngine On + RewriteRule ^includes/ - [F,L] + RewriteRule ^vendor/ - [F,L] + RewriteRule ^mail_queue/ - [F,L] + RewriteRule ^\.git/ - [F,L] + + # Block sensitive file extensions anywhere in the tree — the + # /Order,Deny block above is not honored by this + # OpenLiteSpeed vhost (confirmed: db/schema.sql was still + # served 200 despite that rule), so enforce via RewriteRule, + # the mechanism proven to work here (.git/, vendor/ etc. above). + RewriteRule \.(sql|env|log|sh|bak|backup|old|orig|tmp|swp|cfg|ini|conf|yaml|yml)$ - [F,L] + + +# ── Block common attack vectors ────────────────────────── + + RewriteEngine On + + # Block SQL injection attempts in query strings + RewriteCond %{QUERY_STRING} (union|select|insert|drop|delete|update|cast|exec|declare|char|convert|truncate).*= [NC,OR] + RewriteCond %{QUERY_STRING} (<|%3C).*script.*(>|%3E) [NC,OR] + RewriteCond %{QUERY_STRING} \.\./\.\. [NC,OR] + RewriteCond %{QUERY_STRING} (javascript|vbscript|expression|applet|meta|xml|blink|link|iframe|input|embed|script|object|marquee) [NC] + RewriteRule .* - [F,L] + + # Block base64 encoded attacks + RewriteCond %{QUERY_STRING} base64_encode.*\(.*\) [NC,OR] + RewriteCond %{QUERY_STRING} base64_(en|de)code[^(]*\([^)]*\) [NC] + RewriteRule .* - [F,L] + + # Block common exploit scanners and bad bots + RewriteCond %{HTTP_USER_AGENT} (nikto|sqlmap|havij|nessus|masscan|zgrab|python-requests/2\.6|libwww-perl|wget|curl\/7\.[0-4]) [NC] + RewriteRule .* - [F,L] + + +# ── Block access to WordPress paths (scanners look for these) ── + + RewriteRule ^wp-admin - [F,L] + RewriteRule ^wp-login - [F,L] + RewriteRule ^xmlrpc - [F,L] + RewriteRule ^\.env - [F,L] + RewriteRule ^composer\. - [F,L] + + +# ── Security Headers ────────────────────────────────────── + + # Prevent MIME type sniffing + Header always set X-Content-Type-Options "nosniff" + + # Prevent clickjacking + Header always set X-Frame-Options "DENY" + + # XSS protection + Header always set X-XSS-Protection "1; mode=block" + + # Referrer policy + Header always set Referrer-Policy "strict-origin-when-cross-origin" + + # Permissions policy — disable dangerous browser features + Header always set Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), magnetometer=(), gyroscope=()" + + # Content Security Policy + Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://web.squarecdn.com https://sandbox.web.squarecdn.com https://js.squareup.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: blob: https:; connect-src 'self' https: wss:; frame-src 'none'; object-src 'none'" + + # Strict Transport Security — force HTTPS for 1 year + Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains" + + # Remove server info headers + Header unset Server + Header unset X-Powered-By + + +# ── Canonical HTTPS + non-www redirect ─────────────────── + + RewriteEngine On + RewriteCond %{HTTPS} off + RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L] + RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC] + RewriteRule ^ https://%1%{REQUEST_URI} [R=301,L] + + +# ── Block PHP execution in uploads folder (if it exists) ─ + + RewriteRule ^uploads/.*\.php$ - [F,L] + + +# ── Gzip compression ────────────────────────────────────── + + AddOutputFilterByType DEFLATE text/html text/css text/javascript application/javascript application/json image/svg+xml + + +# ── Browser caching ─────────────────────────────────────── + + ExpiresActive On + ExpiresByType text/html "access plus 1 hour" + ExpiresByType text/css "access plus 1 month" + ExpiresByType application/javascript "access plus 1 month" + ExpiresByType image/svg+xml "access plus 1 month" + ExpiresByType image/png "access plus 1 month" + ExpiresByType image/jpeg "access plus 1 month" + ExpiresByType image/webp "access plus 1 month" + ExpiresByType application/json "access plus 1 day" + diff --git a/sites/tomtomgames.com/public_html/admin/index.php b/sites/tomtomgames.com/public_html/admin/index.php new file mode 100644 index 0000000..763a588 --- /dev/null +++ b/sites/tomtomgames.com/public_html/admin/index.php @@ -0,0 +1,3904 @@ + + + + + + + + + + +TomTomGames Admin + + + + + +
+ + + +
+ + +
+
📊 Dashboard
+ + +
+
Total Users
+
Total Revenue
+
Tokens Sold
+
Pending Purchases
+
Pending Cashouts
+
+
+
⚡ Pending Purchase Approvals
+
+
+
+
⚡ Pending Cashout Requests
+
+
+ + +
+
+ 🕹️ Platform Credit Overview + +
+
+
+
+ + +
+
🧾 Token Purchases
+
+ + + + +
+
+
+ + +
+
💸 Cashout Requests
+
+ + + + +
+
+
+ + + +
+ +
+
🎮 Gamer Management
+
+
+ + +
+
+ + + + +
+
+
+
+
+ + + +
+ + +
+
💳 Payment Settings
+
+ Enable or disable each payment method. Disabled methods are hidden from players instantly. Card payments run through Square — your Square account must be active for card to work. +
+
+
+ + + +
+
🎁 Referral Management
+
+ + + + + +
+
+ + +
+ +
+
🔑 Platform Account Requests
+
+ + + +
+
+
+ + +
+
📢 Broadcast Messages
+ + +
+
✉️ Send Broadcast
+
+
+ + +
+
+ + +
+
+
+ + +
+ +
+
+ + +
+
+
Sent Broadcasts
+ +
+
+
+ + + +
+ + +
+
💰 Payout Settings
+
+ Configure how you send cashout payments to players. Square Gift Card sends instantly. Manual methods show the player handle so you send from the app and mark done. +
+
+
+ + +
+
💸 Cashout Methods
+
+ 💸 Manage the payout method types available to players when they cash out. Active methods appear in the player's payout method dropdown. +
+ + +
+
➕ Add Cashout Method
+
+ +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
Active & Inactive Methods
+
Loading...
+
+
+
+
+ + +
+
+
🕹️ Game Management
+ + + +
+ + +
+
➕ Add New Game
+
+ +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+ + + + + + + +
+
+
💳 Credit Accounting — Admin Only
+
+
+
+
Available Credits
+
+
+ +
+
+ +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
Active & Inactive Games
+
Loading...
+
+
+
+ + + +
+
+
🗄️ Archived Games
+ +
+
Loading...
+
+ +
+ + +
+
📋 Full Audit Log 90-day rolling · 20 per page
+ + +
+ + + + + + + +
+ + +
+ + +
+ + +
+
+ + +
+
💾 Backup System
+ +
+
+
🕐 Automated Schedule
+
Daily at 2:00 AM · 7 rolling backups · Files + full database export
+
+
+ + +
+
+ +
+ +
+
+
Available Backups (last 7 days)
+
+
+
Loading...
+
+
+ + +
+
⏳ Pending Signups
+
+ ⏳ Players who registered but haven't verified their email yet. Approve to create their account immediately, or Delete to remove the request. +
+
+
+ + +
+
Live Chat + + ● Auto-refreshing every 5s + +
+ + +
+ + +
+
✉️ Send Message to Player
+ +
+ + +
+ + + + +
+
Ctrl+Enter to send · Player will see it in their Support chat
+ +
+
+
+ + +
+ 📨 Messages from players appear below. Click any conversation to open it and reply. + +
+
+
+
+
+ + + +
+ + + + + + + +
+ + +
+ TomTomGames Admin v1.0.0 +
+ + diff --git a/sites/tomtomgames.com/public_html/admin/login.php b/sites/tomtomgames.com/public_html/admin/login.php new file mode 100644 index 0000000..55faefa --- /dev/null +++ b/sites/tomtomgames.com/public_html/admin/login.php @@ -0,0 +1,54 @@ + + + + + + + +TomTomGames Admin Login + + + + +
+ +
ADMIN ACCESS
+
+
+
+
+ +
+
+ + diff --git a/sites/tomtomgames.com/public_html/api/admin.php b/sites/tomtomgames.com/public_html/api/admin.php new file mode 100644 index 0000000..424d1de --- /dev/null +++ b/sites/tomtomgames.com/public_html/api/admin.php @@ -0,0 +1,1121 @@ + true, 'stats' => [ + 'total_users' => db()->query("SELECT COUNT(*) FROM users")->fetchColumn(), + 'active_users' => db()->query("SELECT COUNT(*) FROM users WHERE status='active'")->fetchColumn(), + 'pending_purchases' => db()->query("SELECT COUNT(*) FROM token_purchases WHERE status='pending'")->fetchColumn(), + 'pending_cashouts' => db()->query("SELECT COUNT(*) FROM cashout_requests WHERE status='pending'")->fetchColumn(), + 'pending_signups' => db()->query("SELECT COUNT(*) FROM pending_registrations WHERE expires_at > NOW() AND username != '__reset__'")->fetchColumn(), + 'total_tokens_sold' => db()->query("SELECT COALESCE(SUM(tokens),0) FROM token_purchases WHERE status='completed'")->fetchColumn(), + 'total_revenue' => db()->query("SELECT COALESCE(SUM(amount_cents),0)/100 FROM token_purchases WHERE status='completed'")->fetchColumn(), + ]]); + break; + + // ─── PENDING SIGNUPS ────────────────────────────────────── + case 'pending_signups': + $rows = db()->query("SELECT id,username,alias,email,expires_at,created_at FROM pending_registrations WHERE expires_at > NOW() AND username != '__reset__' ORDER BY created_at DESC")->fetchAll(); + echo json_encode(['success'=>true,'pending'=>$rows]); + break; + + case 'delete_pending': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + $data = json_decode(file_get_contents('php://input'), true); + $id = (int)($data['id'] ?? 0); + db()->prepare("DELETE FROM pending_registrations WHERE id=?")->execute([$id]); + echo json_encode(['success'=>true]); + break; + + case 'approve_pending': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + $data = json_decode(file_get_contents('php://input'), true); + $id = (int)($data['id'] ?? 0); + // Fetch the pending record + $stmt = db()->prepare("SELECT * FROM pending_registrations WHERE id=?"); + $stmt->execute([$id]); + $pending = $stmt->fetch(); + if (!$pending) { echo json_encode(['success'=>false,'error'=>'Pending signup not found']); exit; } + // Check username/email not already taken + $chkUser = db()->prepare("SELECT id FROM users WHERE username=?"); + $chkUser->execute([$pending['username']]); + if ($chkUser->fetch()) { echo json_encode(['success'=>false,'error'=>'Username already taken']); exit; } + if (!empty($pending['email'])) { + $chkEmail = db()->prepare("SELECT id FROM users WHERE email=?"); + $chkEmail->execute([$pending['email']]); + if ($chkEmail->fetch()) { echo json_encode(['success'=>false,'error'=>'Email already registered']); exit; } + } + // Create the user account — bypass email verification + db()->beginTransaction(); + try { + db()->prepare("INSERT INTO users (username,password,alias,email,email_verified,tokens,is_admin,status) + VALUES (?,?,?,?,1,0,0,'active')") + ->execute([$pending['username'],$pending['password'],$pending['alias'],$pending['email']]); + db()->prepare("DELETE FROM pending_registrations WHERE id=?")->execute([$id]); + db()->commit(); + logActivity('account_approved', (int)db()->lastInsertId(), (int)$_SESSION['user_id'], 'user', 0, 'Account approved for '.$pending['username']); + echo json_encode(['success'=>true,'username'=>$pending['username']]); + } catch (Exception $e) { + db()->rollBack(); + echo json_encode(['success'=>false,'error'=>'Could not create account']); + } + break; + + // ─── PLATFORM STATS ────────────────────────────────────── + case 'platform_stats': + $rows = db()->query(" + SELECT p.id, p.name, p.slug, p.color, + COALESCE(SUM(CASE WHEN pc.type='debit' THEN -pc.credits_purchased ELSE pc.credits_purchased END),0) AS credits_balance, + (SELECT COALESCE(SUM(tp.amount_cents),0)/100 FROM token_purchases tp WHERE tp.platform_id=p.slug AND tp.status='completed') AS purchases_total, + (SELECT COALESCE(SUM(cr.tokens),0) FROM cashout_requests cr WHERE cr.platform_id=p.slug AND cr.status IN ('sent','approved')) AS cashouts_total + FROM platforms p + LEFT JOIN platform_credits pc ON pc.platform_id=p.id + WHERE p.is_deleted=0 AND p.is_active=1 + GROUP BY p.id, p.name, p.slug, p.color + ORDER BY p.sort_order, p.id + ")->fetchAll(); + echo json_encode(['success'=>true,'platforms'=>$rows]); + break; + + // ─── PURCHASES ──────────────────────────────────────────── + case 'purchases': + $status = $_GET['status'] ?? 'pending'; + if ($status === 'all') { + $stmt = db()->query("SELECT tp.*, u.username, u.alias FROM token_purchases tp JOIN users u ON tp.user_id=u.id ORDER BY tp.created_at DESC LIMIT 200"); + } else { + $stmt = db()->prepare("SELECT tp.*, u.username, u.alias FROM token_purchases tp JOIN users u ON tp.user_id=u.id WHERE tp.status=? ORDER BY tp.created_at DESC LIMIT 200"); + $stmt->execute([$status]); + } + echo json_encode(['success' => true, 'purchases' => $stmt->fetchAll()]); + break; + + // ─── RESOLVE PURCHASE (approve manual / reject) ────────── + case 'resolve_purchase': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + $data = json_decode(file_get_contents('php://input'), true); + $id = (int)($data['id'] ?? 0); + $status = $data['status'] ?? ''; + $note = trim($data['note'] ?? ''); + + if (!in_array($status, ['completed','failed'])) { + echo json_encode(['success'=>false,'error'=>'Invalid status']); exit; + } + + // Fetch purchase + $row = db()->prepare("SELECT * FROM token_purchases WHERE id=? AND status='pending'"); + $row->execute([$id]); + $purchase = $row->fetch(); + + if (!$purchase) { + echo json_encode(['success'=>false,'error'=>'Purchase not found or already resolved']); exit; + } + + db()->beginTransaction(); + try { + if ($status === 'completed') { + db()->prepare("UPDATE users SET tokens=tokens+? WHERE id=?")->execute([$purchase['tokens'], $purchase['user_id']]); + } + db()->prepare("UPDATE token_purchases SET status=?,admin_note=? WHERE id=?")->execute([$status, $note, $id]); + db()->commit(); + } catch (Exception $e) { + db()->rollBack(); + echo json_encode(['success'=>false,'error'=>'DB error']); exit; + } + + // Insert debit entry into platform_credits when approved + if ($status === 'completed' && !empty($purchase['platform_id'])) { + $platRow = db()->prepare("SELECT id FROM platforms WHERE slug=?"); + $platRow->execute([$purchase['platform_id']]); + $platNumId = (int)$platRow->fetchColumn(); + if ($platNumId) { + $userRow = db()->prepare("SELECT username FROM users WHERE id=?"); + $userRow->execute([$purchase['user_id']]); + $username = $userRow->fetchColumn() ?: 'User#'.$purchase['user_id']; + $amtDollars = number_format($purchase['amount_cents'] / 100, 2); + $playerLabel = trim($purchase['player_name'] ?: $purchase['game_alias'] ?: $username); + $debitNotes = "Purchase #{$id} · {$playerLabel} ({$username}) · {$purchase['tokens']} tokens · \${$amtDollars} via {$purchase['payment_method']}"; + db()->prepare("INSERT INTO platform_credits (platform_id, credits_purchased, credit_date, payment_method, notes, type, purchase_ref_id) VALUES (?,?,CURDATE(),?,?,?,?)") + ->execute([$platNumId, $purchase['tokens'], $purchase['payment_method'], $debitNotes, 'debit', $id]); + } + } + + echo json_encode(['success'=>true]); + break; + + // ─── REFUND PURCHASE (real Square refund) ──────────────── + case 'refund_purchase': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + $data = json_decode(file_get_contents('php://input'), true); + $id = (int)($data['id'] ?? 0); + $reason = trim($data['reason'] ?? ''); + + $row = db()->prepare("SELECT * FROM token_purchases WHERE id=? AND status='completed'"); + $row->execute([$id]); + $purchase = $row->fetch(); + + if (!$purchase) { + echo json_encode(['success'=>false,'error'=>'Purchase not found or not in a refundable state']); exit; + } + if ($purchase['payment_method'] !== 'card' || empty($purchase['square_payment_id'])) { + echo json_encode(['success'=>false,'error'=>'This purchase has no Square payment to refund']); exit; + } + + $amountCents = isset($data['amount']) ? (int) round((float)$data['amount'] * 100) : (int) $purchase['amount_cents']; + if ($amountCents <= 0 || $amountCents > (int) $purchase['amount_cents']) { + echo json_encode(['success'=>false,'error'=>'Invalid refund amount']); exit; + } + + $square = new SquarePayment(); + $result = $square->refund($purchase['square_payment_id'], $amountCents, $reason ?: ('Purchase #' . $id)); + + if (!$result['success']) { + echo json_encode(['success'=>false,'error'=>$result['error']]); exit; + } + + $note = 'Refunded $' . number_format($amountCents / 100, 2) . ' via Square (refund ID: ' . $result['refund_id'] . ')' . ($reason ? ' - ' . $reason : ''); + $existingNote = $purchase['admin_note'] ? $purchase['admin_note'] . " | " . $note : $note; + + db()->beginTransaction(); + try { + db()->prepare("UPDATE token_purchases SET status='refunded', admin_note=?, refund_id=? WHERE id=?") + ->execute([$existingNote, $result['refund_id'], $id]); + + // Claw back the tokens credited on purchase, clamped at 0 + $userRow = db()->prepare("SELECT tokens FROM users WHERE id=?"); + $userRow->execute([$purchase['user_id']]); + $currentTokens = (float) ($userRow->fetchColumn() ?: 0); + $newTokens = max(0, $currentTokens - (float) $purchase['tokens']); + db()->prepare("UPDATE users SET tokens=? WHERE id=?")->execute([$newTokens, $purchase['user_id']]); + + db()->commit(); + } catch (Exception $e) { + db()->rollBack(); + echo json_encode(['success'=>false,'error'=>'Refund processed on Square but failed to update local records: ' . $e->getMessage()]); exit; + } + + $tokensClawedBack = min((float) $purchase['tokens'], $currentTokens); + $shortfall = (float) $purchase['tokens'] - $tokensClawedBack; + + echo json_encode([ + 'success' => true, + 'refund_id' => $result['refund_id'], + 'tokens_clawed_back' => $tokensClawedBack, + 'token_shortfall' => $shortfall, + ]); + break; + + // ─── CASHOUTS ───────────────────────────────────────────── + case 'cashouts': + $status = $_GET['status'] ?? 'pending'; + $valid = ['pending','sent','approved','rejected','deleted']; + if (!in_array($status, $valid)) $status = 'pending'; + if ($status === 'pending') { + // Show both pending (player editing) and locked (submitted to admin) + $stmt = db()->prepare("SELECT cr.*, u.username, u.alias AS user_alias FROM cashout_requests cr JOIN users u ON cr.user_id=u.id WHERE cr.status IN ('pending','locked') ORDER BY cr.status DESC, cr.created_at DESC"); + $stmt->execute(); + } elseif ($status === 'sent') { + $stmt = db()->prepare("SELECT cr.*, u.username, u.alias AS user_alias FROM cashout_requests cr JOIN users u ON cr.user_id=u.id WHERE cr.status IN ('sent','approved') ORDER BY cr.created_at DESC"); + $stmt->execute(); + } else { + $stmt = db()->prepare("SELECT cr.*, u.username, u.alias AS user_alias FROM cashout_requests cr JOIN users u ON cr.user_id=u.id WHERE cr.status=? ORDER BY cr.created_at DESC"); + $stmt->execute([$status]); + } + echo json_encode(['success'=>true,'cashouts'=>$stmt->fetchAll()]); + break; + + case 'resolve_cashout': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + $data = json_decode(file_get_contents('php://input'), true); + $id = (int)($data['id'] ?? 0); + $status = $data['status'] ?? ''; + $note = trim($data['note'] ?? ''); + // 'approved' is treated as 'sent' (payment sent to player) + if ($status === 'approved') $status = 'sent'; + if (!in_array($status, ['sent','rejected','deleted'])) { echo json_encode(['success'=>false,'error'=>'Invalid status']); exit; } + + $r = db()->prepare("SELECT user_id,tokens FROM cashout_requests WHERE id=? AND status IN ('pending','locked')"); + $r->execute([$id]); + $req = $r->fetch(); + if (!$req) { echo json_encode(['success'=>false,'error'=>'Not found or already resolved']); exit; } + + db()->beginTransaction(); + try { + // Return tokens to player if denied or deleted + if (in_array($status, ['rejected','deleted'])) { + db()->prepare("UPDATE users SET tokens=tokens+? WHERE id=?")->execute([$req['tokens'],$req['user_id']]); + } + db()->prepare("UPDATE cashout_requests SET status=?,admin_note=?,resolved_at=NOW() WHERE id=?")->execute([$status,$note,$id]); + db()->commit(); + logActivity('cashout_'.$status, $req['user_id'], (int)$_SESSION['user_id'], 'cashout', $id, + 'Cashout '.$status.' by admin. Tokens: '.$req['tokens'].($note?' Note: '.$note:'')); + echo json_encode(['success'=>true,'status'=>$status]); + } catch (Exception $e) { + db()->rollBack(); + echo json_encode(['success'=>false,'error'=>'DB error']); + } + break; + if (!in_array($status, ['approved','rejected'])) { echo json_encode(['success'=>false,'error'=>'Invalid status']); exit; } + + if ($status === 'rejected') { + $r = db()->prepare("SELECT user_id,tokens FROM cashout_requests WHERE id=? AND status='pending'"); + $r->execute([$id]); + $req = $r->fetch(); + if ($req) db()->prepare("UPDATE users SET tokens=tokens+? WHERE id=?")->execute([$req['tokens'],$req['user_id']]); + } + db()->prepare("UPDATE cashout_requests SET status=?,admin_note=?,resolved_at=NOW() WHERE id=?")->execute([$status,$note,$id]); + echo json_encode(['success'=>true]); + break; + + // ─── USERS LIST ─────────────────────────────────────────── + case 'users': + $users = db()->query("SELECT id,username,alias,email,email_verified,tokens,is_admin,status,created_at,last_login FROM users ORDER BY created_at DESC")->fetchAll(); + echo json_encode(['success'=>true,'users'=>$users]); + break; + + // ─── SINGLE USER DETAIL ─────────────────────────────────── + case 'user_detail': + $uid = (int)($_GET['user_id'] ?? 0); + if (!$uid) { echo json_encode(['success'=>false,'error'=>'user_id required']); exit; } + $stmt = db()->prepare("SELECT id,username,alias,email,email_verified,tokens,is_admin,status,created_at,last_login FROM users WHERE id=?"); + $stmt->execute([$uid]); + $user = $stmt->fetch(); + if (!$user) { echo json_encode(['success'=>false,'error'=>'User not found']); exit; } + // Rich stats + $s1 = db()->prepare("SELECT COALESCE(SUM(amount_cents),0)/100 FROM token_purchases WHERE user_id=? AND status='completed'"); + $s1->execute([$uid]); + $s2 = db()->prepare("SELECT COUNT(*) FROM token_purchases WHERE user_id=? AND status='completed'"); $s2->execute([$uid]); + $s3 = db()->prepare("SELECT COUNT(*) FROM token_purchases WHERE user_id=? AND status='pending'"); $s3->execute([$uid]); + $s4 = db()->prepare("SELECT COUNT(*) FROM token_purchases WHERE user_id=? AND status='failed'"); $s4->execute([$uid]); + $s5 = db()->prepare("SELECT COUNT(*) FROM cashout_requests WHERE user_id=?"); $s5->execute([$uid]); + $s6 = db()->prepare("SELECT COALESCE(SUM(tokens),0) FROM token_purchases WHERE user_id=? AND status='completed'"); $s6->execute([$uid]); + $stats = [ + 'total_spent' => $s1->fetchColumn(), + 'completed_purchases'=> $s2->fetchColumn(), + 'pending_purchases' => $s3->fetchColumn(), + 'failed_purchases' => $s4->fetchColumn(), + 'total_cashouts' => $s5->fetchColumn(), + 'total_tokens_bought'=> $s6->fetchColumn(), + ]; + echo json_encode(['success'=>true,'user'=>$user,'stats'=>$stats]); + break; + + // ─── USER PURCHASES ─────────────────────────────────────── + case 'user_purchases': + $uid = (int)($_GET['user_id'] ?? 0); + $stmt = db()->prepare("SELECT id,tokens,amount_cents,payment_method,square_payment_id,platform_id,game_alias,player_name,billing_name,billing_address,billing_city,billing_state,billing_zip,billing_email,is_custom,failure_reason,card_brand,card_last4,receipt_url,status,admin_note,created_at FROM token_purchases WHERE user_id=? ORDER BY created_at DESC LIMIT 100"); + $stmt->execute([$uid]); + echo json_encode(['success'=>true,'purchases'=>$stmt->fetchAll()]); + break; + + // ─── USER CASHOUTS ──────────────────────────────────────── + case 'user_cashouts': + $uid = (int)($_GET['user_id'] ?? 0); + $stmt = db()->prepare("SELECT * FROM cashout_requests WHERE user_id=? ORDER BY created_at DESC LIMIT 100"); + $stmt->execute([$uid]); + echo json_encode(['success'=>true,'cashouts'=>$stmt->fetchAll()]); + break; + + // ─── ADJUST TOKENS ──────────────────────────────────────── + case 'adjust_tokens': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + $data = json_decode(file_get_contents('php://input'), true); + $uid = (int)($data['user_id'] ?? 0); + $amount = (float)($data['amount'] ?? 0); + db()->prepare("UPDATE users SET tokens=tokens+? WHERE id=?")->execute([$amount,$uid]); + $bal = db()->prepare("SELECT tokens FROM users WHERE id=?"); $bal->execute([$uid]); + $newBal = $bal->fetchColumn(); + echo json_encode(['success'=>true,'new_balance'=>$newBal]); + break; + + // ─── SET EXACT TOKEN BALANCE ────────────────────────────── + case 'set_tokens': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + $data = json_decode(file_get_contents('php://input'), true); + $uid = (int)($data['user_id'] ?? 0); + $bal = (float)($data['balance'] ?? 0); + if ($bal < 0) { echo json_encode(['success'=>false,'error'=>'Balance cannot be negative']); exit; } + db()->prepare("UPDATE users SET tokens=? WHERE id=?")->execute([$bal,$uid]); + echo json_encode(['success'=>true,'new_balance'=>$bal]); + break; + + // ─── EDIT USER ──────────────────────────────────────────── + case 'edit_user': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + $data = json_decode(file_get_contents('php://input'), true); + $uid = (int)($data['user_id'] ?? 0); + $username = strtolower(trim($data['username'] ?? '')); + $alias = trim($data['alias'] ?? ''); + $email = strtolower(trim($data['email'] ?? '')); + $password = trim($data['password'] ?? ''); + + if (!$uid || empty($username) || empty($alias)) + { echo json_encode(['success'=>false,'error'=>'Username and alias required']); exit; } + if (!preg_match('/^[a-z0-9_]{3,50}$/', $username)) + { echo json_encode(['success'=>false,'error'=>'Invalid username format']); exit; } + if (!empty($email) && !filter_var($email, FILTER_VALIDATE_EMAIL)) + { echo json_encode(['success'=>false,'error'=>'Invalid email address']); exit; } + + // Check username not taken by another user + $chk = db()->prepare("SELECT id FROM users WHERE username=? AND id!=?"); + $chk->execute([$username,$uid]); + if ($chk->fetch()) { echo json_encode(['success'=>false,'error'=>'Username already taken']); exit; } + + if (!empty($email)) { + $chk2 = db()->prepare("SELECT id FROM users WHERE email=? AND id!=?"); + $chk2->execute([$email,$uid]); + if ($chk2->fetch()) { echo json_encode(['success'=>false,'error'=>'Email already in use']); exit; } + } + + if (!empty($password)) { + if (strlen($password) < 6) { echo json_encode(['success'=>false,'error'=>'Password must be 6+ characters']); exit; } + $hash = password_hash($password, PASSWORD_BCRYPT); + db()->prepare("UPDATE users SET username=?,alias=?,email=?,password=? WHERE id=?")->execute([$username,$alias,$email,$hash,$uid]); + } else { + db()->prepare("UPDATE users SET username=?,alias=?,email=? WHERE id=?")->execute([$username,$alias,$email,$uid]); + } + echo json_encode(['success'=>true]); + break; + + // ─── TOGGLE ADMIN ROLE ─────────────────────────────────── + case 'toggle_admin': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + $data = json_decode(file_get_contents('php://input'), true); + $uid = (int)($data['user_id'] ?? 0); + // Master admin (ID=1) can NEVER lose admin status + if ($uid === MASTER_ADMIN_ID) { + echo json_encode(['success'=>false,'error'=>'Master admin cannot be modified.']); exit; + } + // Cannot remove your own admin + if ($uid === (int)$_SESSION['user_id']) { + echo json_encode(['success'=>false,'error'=>'You cannot change your own admin status.']); exit; + } + // Only master admin can grant/revoke admin + if ((int)$_SESSION['user_id'] !== MASTER_ADMIN_ID) { + echo json_encode(['success'=>false,'error'=>'Only the master admin can change admin roles.']); exit; + } + $stmt = db()->prepare("SELECT is_admin FROM users WHERE id=?"); + $stmt->execute([$uid]); + $current = $stmt->fetchColumn(); + $new_val = $current ? 0 : 1; + if ($new_val) { + db()->prepare("UPDATE users SET is_admin=1, email_verified=1 WHERE id=?")->execute([$uid]); + } else { + db()->prepare("UPDATE users SET is_admin=0 WHERE id=?")->execute([$uid]); + } + // Force the affected user to re-login by invalidating their sessions + // Store a flag in DB that forces re-auth on next request + db()->prepare("UPDATE users SET last_login=last_login WHERE id=?")->execute([$uid]); + logActivity($new_val?'admin_granted':'admin_revoked', $uid, (int)$_SESSION['user_id'], 'user', $uid, 'Admin status changed to '.($new_val?'admin':'player'), '', 'admin', '', '', 'warning'); + echo json_encode(['success'=>true, 'is_admin'=>$new_val, 'needs_relogin'=>true, 'message'=>$new_val ? 'Admin access granted. User must log out and back in.' : 'Admin access removed. User must log out and back in.']); + break; + + // ─── TOGGLE SUSPEND ─────────────────────────────────────── + case 'toggle_user': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + $data = json_decode(file_get_contents('php://input'), true); + $uid = (int)($data['user_id'] ?? 0); + if ($uid === MASTER_ADMIN_ID) { echo json_encode(['success'=>false,'error'=>'Cannot suspend the master admin.']); exit; } + db()->prepare("UPDATE users SET status=IF(status='active','suspended','active') WHERE id=?")->execute([$uid]); + logAdminAction('USER_STATUS_CHANGE', (int)$_SESSION['user_id'], 'user', $uid, 'Changed user status', '', ($data['status']??''), 'warning'); + echo json_encode(['success'=>true]); + break; + + // ─── DELETE USER ────────────────────────────────────────── + case 'delete_user': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + $data = json_decode(file_get_contents('php://input'), true); + $uid = (int)($data['user_id'] ?? 0); + if (!$uid) { echo json_encode(['success'=>false,'error'=>'Invalid user']); exit; } + // Prevent deleting own account + if ($uid === MASTER_ADMIN_ID) { echo json_encode(['success'=>false,'error'=>'Cannot delete the master admin account.']); exit; } + if ($uid === (int)$_SESSION['user_id']) { echo json_encode(['success'=>false,'error'=>'Cannot delete your own account']); exit; } + db()->beginTransaction(); + try { + db()->prepare("DELETE FROM chat_messages WHERE user_id=?")->execute([$uid]); + db()->prepare("DELETE FROM cashout_requests WHERE user_id=?")->execute([$uid]); + db()->prepare("DELETE FROM token_purchases WHERE user_id=?")->execute([$uid]); + db()->prepare("DELETE FROM users WHERE id=?")->execute([$uid]); + db()->commit(); + echo json_encode(['success'=>true]); + } catch (Exception $e) { + db()->rollBack(); + echo json_encode(['success'=>false,'error'=>'Delete failed']); + } + break; + + // ─── SEND PASSWORD RESET ────────────────────────────────── + case 'send_password_reset': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + $data = json_decode(file_get_contents('php://input'), true); + $uid = (int)($data['user_id'] ?? 0); + $stmt = db()->prepare("SELECT email,alias FROM users WHERE id=?"); + $stmt->execute([$uid]); + $user = $stmt->fetch(); + if (!$user || empty($user['email'])) { echo json_encode(['success'=>false,'error'=>'No email on file']); exit; } + // Generate reset token — reuse pending_registrations pattern + $token = bin2hex(random_bytes(32)); + $exp = date('Y-m-d H:i:s', time() + 3600); // 1 hour + db()->prepare("INSERT INTO pending_registrations (username,password,alias,email,token,expires_at) VALUES ('__reset__','',?,?,?,?) ON DUPLICATE KEY UPDATE token=VALUES(token),expires_at=VALUES(expires_at)")->execute([$user['alias'],$user['email'],$token,$exp]); + // Simple reset email + $resetUrl = rtrim(SITE_URL,'/') . '/reset_password.php?token=' . urlencode($token); + $subject = SITE_NAME . ' — Password Reset Request'; + $body = "Hi {$user['alias']},\n\nA password reset was requested for your account.\n\nClick here to reset: {$resetUrl}\n\nExpires in 1 hour. If you didn't request this, ignore this email.\n\n— " . SITE_NAME; + $sent = sendPasswordResetEmail($user['email'], $user['alias'], $resetUrl); + echo json_encode($sent ? ['success'=>true] : ['success'=>false,'error'=>'Failed to send reset email. Please try again.']); + break; + + // ─── PLATFORM ACCOUNTS ──────────────────────────────── + case 'platform_accounts_list': + $status = $_GET['status'] ?? 'pending'; + $uid = (int)($_GET['user_id'] ?? 0); + if ($uid) { + $stmt = db()->prepare("SELECT pa.*, COALESCE(p.name,pa.platform_slug) AS platform_name, u.username, u.alias AS user_alias FROM platform_accounts pa LEFT JOIN platforms p ON pa.platform_slug=p.slug JOIN users u ON pa.user_id=u.id WHERE pa.user_id=? ORDER BY pa.requested_at DESC"); + $stmt->execute([$uid]); + } else { + $valid = ['pending','approved','denied','deleted']; + if (!in_array($status,$valid)) $status='pending'; + $stmt = db()->prepare("SELECT pa.*, COALESCE(p.name,pa.platform_slug) AS platform_name, u.username, u.alias AS user_alias FROM platform_accounts pa LEFT JOIN platforms p ON pa.platform_slug=p.slug JOIN users u ON pa.user_id=u.id WHERE pa.status=? ORDER BY pa.requested_at DESC"); + $stmt->execute([$status]); + } + echo json_encode(['success'=>true,'accounts'=>$stmt->fetchAll()]); + break; + + case 'platform_account_resolve': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + $d = json_decode(file_get_contents('php://input'), true); + $id=$d['id']??0; $status=$d['status']??''; + $uname=substr(trim($d['platform_username']??''),0,100); + $pass=substr(trim($d['platform_password']??''),0,200); + $note=substr(trim($d['admin_note']??''),0,300); + if (!in_array($status,['approved','denied','deleted'])){echo json_encode(['success'=>false,'error'=>'Invalid status']);exit;} + $chk=db()->prepare("SELECT user_id,platform_slug FROM platform_accounts WHERE id=?");$chk->execute([$id]);$row=$chk->fetch(); + if (!$row){echo json_encode(['success'=>false,'error'=>'Not found']);exit;} + db()->prepare("UPDATE platform_accounts SET status=?,platform_username=?,platform_password=?,admin_note=?,resolved_at=NOW(),admin_id=? WHERE id=?") + ->execute([$status,$uname,$pass,$note,(int)$_SESSION['user_id'],$id]); + if ($status==='approved'&&$uname) { + db()->prepare("INSERT INTO game_aliases (user_id,platform_slug,alias) VALUES (?,?,?) ON DUPLICATE KEY UPDATE alias=VALUES(alias)") + ->execute([$row['user_id'],$row['platform_slug'],$uname]); + } + echo json_encode(['success'=>true]); + break; + + case 'platform_account_update': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + $d=json_decode(file_get_contents('php://input'),true); + $id=$d['id']??0; + $uname=substr(trim($d['platform_username']??''),0,100); + $pass=substr(trim($d['platform_password']??''),0,200); + $note=substr(trim($d['admin_note']??''),0,300); + $chk=db()->prepare("SELECT user_id,platform_slug FROM platform_accounts WHERE id=?");$chk->execute([$id]);$row=$chk->fetch(); + if (!$row){echo json_encode(['success'=>false,'error'=>'Not found']);exit;} + db()->prepare("UPDATE platform_accounts SET platform_username=?,platform_password=?,admin_note=? WHERE id=?") + ->execute([$uname,$pass,$note,$id]); + if ($uname){ + db()->prepare("INSERT INTO game_aliases (user_id,platform_slug,alias) VALUES (?,?,?) ON DUPLICATE KEY UPDATE alias=VALUES(alias)") + ->execute([$row['user_id'],$row['platform_slug'],$uname]); + } + echo json_encode(['success'=>true]); + break; + + case 'broadcast_list': + try { + $sql = "SELECT b.id, b.subject, b.message, b.target, b.sent_at, + u.username AS sender_name, + (SELECT COUNT(*) FROM broadcast_reads WHERE broadcast_id=b.id) AS read_count, + (SELECT COUNT(*) FROM broadcast_replies WHERE broadcast_id=b.id) AS reply_count, + (SELECT COUNT(*) FROM users WHERE status='active' AND is_admin=0) AS total_players + FROM broadcasts b + JOIN users u ON b.admin_id=u.id + ORDER BY b.sent_at DESC + LIMIT 100"; + $stmt = db()->query($sql); + echo json_encode(['success'=>true,'broadcasts'=>$stmt->fetchAll()]); + } catch(Exception $e) { + echo json_encode(['success'=>false,'error'=>$e->getMessage()]); + } + break; + + case 'broadcast_send': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + $d = json_decode(file_get_contents('php://input'), true); + $subject = substr(trim($d['subject']??''),0,200); + $message = substr(trim($d['message']??''),0,5000); + $target = in_array($d['target']??'',['all','verified','unverified','admins']) ? $d['target'] : 'all'; + if (!$subject||!$message) { echo json_encode(['success'=>false,'error'=>'Subject and message required']); exit; } + db()->prepare("INSERT INTO broadcasts (admin_id,subject,message,target) VALUES (?,?,?,?)") + ->execute([$_SESSION['user_id'],$subject,$message,$target]); + $bid = db()->lastInsertId(); + // Count recipients + $countQ = [ + 'all' => "SELECT COUNT(*) FROM users WHERE status='active' AND is_admin=0", + 'verified' => "SELECT COUNT(*) FROM users WHERE status='active' AND is_admin=0 AND email_verified=1", + 'unverified' => "SELECT COUNT(*) FROM users WHERE status='active' AND is_admin=0 AND email_verified=0", + 'admins' => "SELECT COUNT(*) FROM users WHERE is_admin=1", + ]; + $count = db()->query($countQ[$target])->fetchColumn(); + echo json_encode(['success'=>true,'id'=>$bid,'recipient_count'=>(int)$count]); + break; + + case 'broadcast_delete': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + $d = json_decode(file_get_contents('php://input'), true); + $id = (int)($d['id']??0); + db()->prepare("DELETE FROM broadcasts WHERE id=?")->execute([$id]); + echo json_encode(['success'=>true]); + break; + + case 'broadcast_edit': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + $d = json_decode(file_get_contents('php://input'), true); + $id = (int)($d['id'] ?? 0); + $subject = substr(trim($d['subject'] ?? ''), 0, 200); + $message = trim($d['message'] ?? ''); + $target = in_array($d['target']??'', ['all','verified','unverified','admins']) ? $d['target'] : 'all'; + if (!$id || !$subject || !$message) { echo json_encode(['success'=>false,'error'=>'Missing fields']); exit; } + db()->prepare("UPDATE broadcasts SET subject=?, message=?, target=? WHERE id=?")->execute([$subject, $message, $target, $id]); + logAdminAction('BROADCAST_EDITED', $adminId, 'broadcast', $id, 'Edited broadcast #'.$id, '', '', 'info'); + echo json_encode(['success'=>true]); + break; + + case 'broadcast_resend': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + $d = json_decode(file_get_contents('php://input'), true); + $id = (int)($d['id'] ?? 0); + if (!$id) { echo json_encode(['success'=>false,'error'=>'Missing ID']); exit; } + $bc = db()->prepare("SELECT * FROM broadcasts WHERE id=?"); + $bc->execute([$id]); + $orig = $bc->fetch(); + if (!$orig) { echo json_encode(['success'=>false,'error'=>'Broadcast not found']); exit; } + // Count recipients + $target = $orig['target']; + $countSql = "SELECT COUNT(*) FROM users WHERE status='active' AND is_admin=0"; + if ($target === 'verified') $countSql .= " AND email_verified=1"; + if ($target === 'unverified') $countSql .= " AND email_verified=0"; + $recipientCount = (int)db()->query($countSql)->fetchColumn(); + // Delete old reads so everyone sees it again + db()->prepare("DELETE FROM broadcast_reads WHERE broadcast_id=?")->execute([$id]); + // Update sent_at to now + db()->prepare("UPDATE broadcasts SET sent_at=NOW() WHERE id=?")->execute([$id]); + logAdminAction('BROADCAST_RESENT', $adminId, 'broadcast', $id, 'Resent broadcast #'.$id.' to '.$recipientCount.' players', '', '', 'info'); + echo json_encode(['success'=>true,'recipient_count'=>$recipientCount]); + break; + + case 'broadcast_reads': + $bid = (int)($_GET['broadcast_id']??0); + $rows = db()->prepare("SELECT br.read_at, u.username, u.alias FROM broadcast_reads br JOIN users u ON br.user_id=u.id WHERE br.broadcast_id=? ORDER BY br.read_at ASC"); + $rows->execute([$bid]); + echo json_encode(['success'=>true,'reads'=>$rows->fetchAll()]); + break; + + case 'broadcast_replies': + $bid = (int)($_GET['broadcast_id']??0); + $rows = db()->prepare("SELECT br.*, u.username, u.alias, u.is_admin FROM broadcast_replies br JOIN users u ON br.user_id=u.id WHERE br.broadcast_id=? ORDER BY br.created_at ASC"); + $rows->execute([$bid]); + echo json_encode(['success'=>true,'replies'=>$rows->fetchAll()]); + break; + + case 'broadcast_reply': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + $d = json_decode(file_get_contents('php://input'), true); + $bid = (int)($d['broadcast_id']??0); + $msg = substr(trim($d['message']??''),0,1000); + if (!$bid||!$msg) { echo json_encode(['success'=>false,'error'=>'Required fields missing']); exit; } + db()->prepare("INSERT INTO broadcast_replies (broadcast_id,user_id,message) VALUES (?,?,?)") + ->execute([$bid,$_SESSION['user_id'],$msg]); + echo json_encode(['success'=>true,'id'=>db()->lastInsertId()]); + break; + $rows = db()->query("SELECT * FROM cashout_method_types ORDER BY sort_order ASC, id ASC")->fetchAll(); + echo json_encode(['success'=>true,'types'=>$rows]); + break; + + case 'cashout_methods_create': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + $d = json_decode(file_get_contents('php://input'), true); + $slug = preg_replace('/[^a-z0-9_]/','',strtolower(trim($d['slug']??''))); + $label= substr(trim($d['label']??''),0,100); + $icon = substr(trim($d['icon']??'💰'),0,10); + $desc = substr(trim($d['description']??''),0,200); + $sort = (int)($d['sort_order']??99); + $active=(int)(bool)($d['is_active']??1); + if (!$slug||!$label){echo json_encode(['success'=>false,'error'=>'Slug and label required']);exit;} + try { + db()->prepare("INSERT INTO cashout_method_types (slug,label,icon,description,is_active,sort_order) VALUES (?,?,?,?,?,?)") + ->execute([$slug,$label,$icon,$desc,$active,$sort]); + echo json_encode(['success'=>true,'id'=>db()->lastInsertId()]); + } catch(Exception $e){ echo json_encode(['success'=>false,'error'=>'Slug already exists']); } + break; + + case 'cashout_methods_update': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + $d = json_decode(file_get_contents('php://input'), true); + $id = (int)($d['id']??0); + $label= substr(trim($d['label']??''),0,100); + $icon = substr(trim($d['icon']??'💰'),0,10); + $desc = substr(trim($d['description']??''),0,200); + $sort = (int)($d['sort_order']??0); + $active=(int)(bool)($d['is_active']??1); + if (!$id||!$label){echo json_encode(['success'=>false,'error'=>'ID and label required']);exit;} + db()->prepare("UPDATE cashout_method_types SET label=?,icon=?,description=?,is_active=?,sort_order=? WHERE id=?") + ->execute([$label,$icon,$desc,$active,$sort,$id]); + echo json_encode(['success'=>true]); + break; + + case 'cashout_methods_delete': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + $d=(json_decode(file_get_contents('php://input'),true)); + $id=(int)($d['id']??0); + if (!$id){echo json_encode(['success'=>false,'error'=>'ID required']);exit;} + db()->prepare("DELETE FROM cashout_method_types WHERE id=?")->execute([$id]); + echo json_encode(['success'=>true]); + break; + + // ── + case 'platform_account_deny': + if ($_SERVER['REQUEST_METHOD']!=='POST'){echo json_encode(['success'=>false]);exit;} + $d=json_decode(file_get_contents('php://input'),true); + $id=(int)($d['id']??0);$nt=substr(trim($d['admin_note']??''),0,500); + db()->prepare("UPDATE platform_accounts SET status='denied',admin_note=?,admin_id=? WHERE id=?")->execute([$nt,$_SESSION['user_id'],$id]); + echo json_encode(['success'=>true]); + break; + + case 'platform_account_delete': + if ($_SERVER['REQUEST_METHOD']!=='POST'){echo json_encode(['success'=>false]);exit;} + $d=json_decode(file_get_contents('php://input'),true); + $id=(int)($d['id']??0); + db()->prepare("DELETE FROM platform_accounts WHERE id=?")->execute([$id]); + echo json_encode(['success'=>true]); + break; + + case 'platform_accounts_user': + $uid=(int)($_GET['user_id']??0); + $stmt=db()->prepare("SELECT pa.*,COALESCE(p.name,pa.platform_name,pa.platform_slug) AS display_name,p.color,p.player_url FROM platform_accounts pa LEFT JOIN platforms p ON pa.platform_slug=p.slug WHERE pa.user_id=? ORDER BY pa.requested_at DESC"); + $stmt->execute([$uid]); + echo json_encode(['success'=>true,'accounts'=>$stmt->fetchAll()]); + break; + case 'activity_log': + case 'activity_log_v2': + $page = max(1, (int)($_GET['page']??1)); + $limit = 20; + $offset = ($page - 1) * $limit; + $category = trim($_GET['category'] ?? ''); + $severity = trim($_GET['severity'] ?? ''); + $search = trim($_GET['search'] ?? ''); + $date = trim($_GET['date'] ?? ''); + + $where = ["al.created_at >= DATE_SUB(NOW(), INTERVAL 90 DAY)"]; + $params = []; + + if ($category) { $where[] = "al.category = ?"; $params[] = $category; } + if ($severity) { $where[] = "al.severity = ?"; $params[] = $severity; } + if ($date) { $where[] = "DATE(al.created_at) = ?"; $params[] = $date; } + if ($search) { + $where[] = "(al.action LIKE ? OR al.detail LIKE ? OR u.username LIKE ? OR u.alias LIKE ? OR al.ip LIKE ?)"; + $s = '%'.$search.'%'; + $params = array_merge($params, [$s,$s,$s,$s,$s]); + } + + $whereStr = implode(' AND ', $where); + $baseQuery = "FROM activity_log al + LEFT JOIN users u ON al.user_id = u.id + LEFT JOIN users a ON al.admin_id = a.id + WHERE $whereStr"; + + $countStmt = db()->prepare("SELECT COUNT(*) $baseQuery"); + $countStmt->execute($params); + $total = (int)$countStmt->fetchColumn(); + + $dataStmt = db()->prepare("SELECT al.*, u.username, u.alias, + a.username AS admin_username + $baseQuery + ORDER BY al.created_at DESC + LIMIT $limit OFFSET $offset"); + $dataStmt->execute($params); + $events = $dataStmt->fetchAll(); + + // Stats for the current filter set + $statsParams = $params; + $statsStmt = db()->prepare("SELECT + SUM(al.severity='critical') AS critical, + SUM(al.severity='warning') AS warning, + COUNT(DISTINCT al.ip) AS unique_ips + $baseQuery"); + $statsStmt->execute($statsParams); + $stats = $statsStmt->fetch(); + + echo json_encode(['success'=>true,'events'=>$events,'total'=>$total,'page'=>$page,'stats'=>$stats]); + break; + + case 'activity_log_csv': + $category = trim($_GET['category'] ?? ''); + $severity = trim($_GET['severity'] ?? ''); + $search = trim($_GET['search'] ?? ''); + $date = trim($_GET['date'] ?? ''); + + $where = ["al.created_at >= DATE_SUB(NOW(), INTERVAL 90 DAY)"]; + $params = []; + if ($category) { $where[] = "al.category = ?"; $params[] = $category; } + if ($severity) { $where[] = "al.severity = ?"; $params[] = $severity; } + if ($date) { $where[] = "DATE(al.created_at) = ?"; $params[] = $date; } + if ($search) { + $where[] = "(al.action LIKE ? OR al.detail LIKE ? OR u.username LIKE ?)"; + $s = '%'.$search.'%'; $params = array_merge($params, [$s,$s,$s]); + } + + $whereStr = implode(' AND ', $where); + $stmt = db()->prepare("SELECT al.*, u.username, u.alias, a.username AS admin_username + FROM activity_log al + LEFT JOIN users u ON al.user_id=u.id + LEFT JOIN users a ON al.admin_id=a.id + WHERE $whereStr ORDER BY al.created_at DESC LIMIT 5000"); + $stmt->execute($params); + $rows = $stmt->fetchAll(); + + header('Content-Type: text/csv'); + header('Content-Disposition: attachment; filename="tomtomgames_audit_' . date('Y-m-d') . '.csv"'); + $out = fopen('php://output', 'w'); + fputcsv($out, ['ID','Timestamp','Category','Severity','Action','Username','Alias','Admin','Detail','Old Value','New Value','IP','User Agent','Page','Session ID']); + foreach ($rows as $r) { + fputcsv($out, [$r['id'],$r['created_at'],$r['category'],$r['severity'],$r['action'], + $r['username']??'',$r['alias']??'',$r['admin_username']??'', + $r['detail']??'',$r['old_value']??'',$r['new_value']??'', + $r['ip']??'',$r['user_agent']??'',$r['page']??'',$r['session_id']??'']); + } + fclose($out); + exit; + break; + + // ─── CASHOUT METHODS: list (admin) ──────────────────── + case 'cashout_methods_list': + $rows = db()->query("SELECT * FROM cashout_method_types ORDER BY sort_order ASC, id ASC")->fetchAll(); + echo json_encode(['success'=>true,'types'=>$rows]); + break; + + // ─── PAYMENT SETTINGS: list (admin) ─────────────────── + case 'payment_settings_list': + $rows = db()->query("SELECT * FROM payment_settings ORDER BY sort_order ASC, id ASC")->fetchAll(); + echo json_encode(['success'=>true,'methods'=>$rows]); + break; + + case 'payment_settings_update': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + $d = json_decode(file_get_contents('php://input'), true); + $id = (int)($d['id'] ?? 0); + $label= substr(trim($d['label']??''), 0, 100); + $handle = substr(trim($d['handle']??''), 0, 200); + $inst = substr(trim($d['instructions']??''), 0, 500); + $enabled = (int)(bool)($d['is_enabled'] ?? 1); + $sort = (int)($d['sort_order'] ?? 0); + if (!$id) { echo json_encode(['success'=>false,'error'=>'ID required']); exit; } + db()->prepare("UPDATE payment_settings SET label=?,handle=?,instructions=?,is_enabled=?,sort_order=? WHERE id=?") + ->execute([$label,$handle,$inst,$enabled,$sort,$id]); + echo json_encode(['success'=>true]); + break; + + + // ─── PAYOUT METHODS: get for user ──────────────────────── + case 'payout_methods_get': + $uid = (int)($_GET['user_id'] ?? 0); + if (!$uid) { echo json_encode(['success'=>false,'error'=>'user_id required']); exit; } + $rows = db()->prepare("SELECT * FROM payout_methods WHERE user_id=? ORDER BY is_default DESC, id ASC"); + $rows->execute([$uid]); + echo json_encode(['success'=>true,'methods'=>$rows->fetchAll()]); + break; + + // ─── GAME ALIASES: get ──────────────────────────────── + case 'game_aliases_get': + $uid = (int)($_GET['user_id'] ?? 0); + if (!$uid) { echo json_encode(['success'=>false,'error'=>'user_id required']); exit; } + $stmt = db()->prepare("SELECT platform_slug, alias FROM game_aliases WHERE user_id=?"); + $stmt->execute([$uid]); + $rows = $stmt->fetchAll(); + $map = []; + foreach ($rows as $r) $map[$r['platform_slug']] = $r['alias']; + echo json_encode(['success'=>true,'aliases'=>$map]); + break; + + // ─── GAME ALIASES: save all ─────────────────────────── + case 'game_aliases_save_all': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + $data = json_decode(file_get_contents('php://input'), true); + $uid = (int)($data['user_id'] ?? 0); + $aliases = $data['aliases'] ?? []; + if (!$uid) { echo json_encode(['success'=>false,'error'=>'user_id required']); exit; } + $stmt = db()->prepare("INSERT INTO game_aliases (user_id,platform_slug,alias) VALUES (?,?,?) + ON DUPLICATE KEY UPDATE alias=VALUES(alias)"); + $del = db()->prepare("DELETE FROM game_aliases WHERE user_id=? AND platform_slug=?"); + foreach ($aliases as $slug => $alias) { + $slug = preg_replace('/[^a-z0-9_]/', '', strtolower(trim($slug))); + $alias = substr(trim($alias), 0, 100); + if (!$slug) continue; + if ($alias === '') $del->execute([$uid, $slug]); + else $stmt->execute([$uid, $slug, $alias]); + } + echo json_encode(['success'=>true]); + break; + + // ─── PLATFORMS: admin list (active + inactive, no archived) ── + case 'platforms_admin': + $rows = db()->query("SELECT * FROM platforms WHERE is_deleted=0 ORDER BY sort_order ASC, id ASC")->fetchAll(); + echo json_encode(['success'=>true,'platforms'=>$rows]); + break; + + // ─── PLATFORMS: archived list ───────────────────────── + case 'platforms_archived': + $rows = db()->query("SELECT * FROM platforms WHERE is_deleted=1 ORDER BY deleted_at DESC")->fetchAll(); + echo json_encode(['success'=>true,'platforms'=>$rows]); + break; + + // ─── PLATFORMS: restore archived ────────────────────── + case 'platforms_restore': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + $d = json_decode(file_get_contents('php://input'), true); + $id = (int)($d['id'] ?? 0); + if (!$id) { echo json_encode(['success'=>false,'error'=>'ID required']); exit; } + db()->prepare("UPDATE platforms SET is_deleted=0, deleted_at=NULL, updated_at=NOW() WHERE id=?")->execute([$id]); + echo json_encode(['success'=>true]); + break; + + // ─── PLATFORMS: permanent delete (archived only) ────── + case 'platforms_purge': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + $d = json_decode(file_get_contents('php://input'), true); + $id = (int)($d['id'] ?? 0); + if (!$id) { echo json_encode(['success'=>false,'error'=>'ID required']); exit; } + db()->prepare("DELETE FROM platforms WHERE id=? AND is_deleted=1")->execute([$id]); + echo json_encode(['success'=>true]); + break; + + // ─── PLATFORMS: create ──────────────────────────────── + case 'platforms_create': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + if ((int)($_SESSION['user_id'] ?? 0) !== MASTER_ADMIN_ID) { echo json_encode(['success'=>false,'error'=>'Only master admin can add games']); exit; } + $d = json_decode(file_get_contents('php://input'), true); + $isMasterAdmin = true; + $slug = preg_replace('/[^a-z0-9_]/', '', strtolower(trim($d['slug'] ?? ''))); + $name = substr(trim($d['name'] ?? ''), 0, 100); + $purl = substr(trim($d['player_url'] ?? ''), 0, 500); + $color = preg_match('/^#[0-9a-fA-F]{3,8}$/', $d['color']??'') ? $d['color'] : '#f0c040'; + $sort = (int)($d['sort_order'] ?? 99); + $active = (int)(bool)($d['is_active'] ?? 1); + $agent_link = $isMasterAdmin ? substr(trim($d['agent_link'] ?? ''), 0, 500) : ''; + $agent_login = $isMasterAdmin ? substr(trim($d['agent_login'] ?? ''), 0, 200) : ''; + $agent_password = $isMasterAdmin ? substr(trim($d['agent_password'] ?? ''), 0, 200) : ''; + $games_link = $isMasterAdmin ? substr(trim($d['games_link'] ?? ''), 0, 500) : ''; + $agent_guide = $isMasterAdmin ? trim($d['agent_guide'] ?? '') : ''; + $sub_agent_login = $isMasterAdmin ? substr(trim($d['sub_agent_login'] ?? ''), 0, 200) : ''; + $sub_agent_password = $isMasterAdmin ? substr(trim($d['sub_agent_password'] ?? ''), 0, 200) : ''; + $cashier_login = $isMasterAdmin ? substr(trim($d['cashier_login'] ?? ''), 0, 200) : ''; + $cashier_password = $isMasterAdmin ? substr(trim($d['cashier_password'] ?? ''), 0, 200) : ''; + if (!$slug||!$name||!$purl) { echo json_encode(['success'=>false,'error'=>'Slug, name, and player URL required']); exit; } + // Check if slug belongs to an archived platform — reactivate it instead of inserting + $existing = db()->prepare("SELECT id FROM platforms WHERE slug=? AND is_deleted=1 LIMIT 1"); + $existing->execute([$slug]); + $archivedId = $existing->fetchColumn(); + if ($archivedId) { + db()->prepare("UPDATE platforms SET name=?,player_url=?,agent_link=?,agent_login=?,agent_password=?,games_link=?,agent_guide=?,sub_agent_login=?,sub_agent_password=?,cashier_login=?,cashier_password=?,color=?,sort_order=?,is_active=?,is_deleted=0,deleted_at=NULL,updated_at=NOW() WHERE id=?") + ->execute([$name,$purl,$agent_link,$agent_login,$agent_password,$games_link,$agent_guide,$sub_agent_login,$sub_agent_password,$cashier_login,$cashier_password,$color,$sort,$active,$archivedId]); + echo json_encode(['success'=>true,'id'=>$archivedId,'restored'=>true]); + } else { + try { + db()->prepare("INSERT INTO platforms (slug,name,player_url,agent_link,agent_login,agent_password,games_link,agent_guide,sub_agent_login,sub_agent_password,cashier_login,cashier_password,color,sort_order,is_active) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)") + ->execute([$slug,$name,$purl,$agent_link,$agent_login,$agent_password,$games_link,$agent_guide,$sub_agent_login,$sub_agent_password,$cashier_login,$cashier_password,$color,$sort,$active]); + echo json_encode(['success'=>true,'id'=>db()->lastInsertId()]); + } catch (Exception $e) { echo json_encode(['success'=>false,'error'=>'Slug already in use by an active game']); } + } + break; + + // ─── PLATFORMS: update ──────────────────────────────── + case 'platforms_update': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + $d = json_decode(file_get_contents('php://input'), true); + $isMasterAdmin = (int)($_SESSION['user_id'] ?? 0) === MASTER_ADMIN_ID; + $id = (int)($d['id'] ?? 0); + $name = substr(trim($d['name'] ?? ''), 0, 100); + $purl = substr(trim($d['player_url'] ?? ''), 0, 500); + $color = preg_match('/^#[0-9a-fA-F]{3,8}$/', $d['color']??'') ? $d['color'] : '#f0c040'; + $sort = (int)($d['sort_order'] ?? 99); + $active = (int)(bool)($d['is_active'] ?? 1); + if (!$id||!$name||!$purl) { echo json_encode(['success'=>false,'error'=>'ID, name, and URL required']); exit; } + if ($isMasterAdmin) { + $agent_link = substr(trim($d['agent_link'] ?? ''), 0, 500); + $agent_login = substr(trim($d['agent_login'] ?? ''), 0, 200); + $agent_password = substr(trim($d['agent_password'] ?? ''), 0, 200); + $games_link = substr(trim($d['games_link'] ?? ''), 0, 500); + $agent_guide = trim($d['agent_guide'] ?? ''); + $sub_agent_login = substr(trim($d['sub_agent_login'] ?? ''), 0, 200); + $sub_agent_password = substr(trim($d['sub_agent_password'] ?? ''), 0, 200); + $cashier_login = substr(trim($d['cashier_login'] ?? ''), 0, 200); + $cashier_password = substr(trim($d['cashier_password'] ?? ''), 0, 200); + db()->prepare("UPDATE platforms SET name=?,player_url=?,agent_link=?,agent_login=?,agent_password=?,games_link=?,agent_guide=?,sub_agent_login=?,sub_agent_password=?,cashier_login=?,cashier_password=?,color=?,sort_order=?,is_active=?,updated_at=NOW() WHERE id=?") + ->execute([$name,$purl,$agent_link,$agent_login,$agent_password,$games_link,$agent_guide,$sub_agent_login,$sub_agent_password,$cashier_login,$cashier_password,$color,$sort,$active,$id]); + } else { + db()->prepare("UPDATE platforms SET name=?,player_url=?,color=?,sort_order=?,is_active=?,updated_at=NOW() WHERE id=?") + ->execute([$name,$purl,$color,$sort,$active,$id]); + } + echo json_encode(['success'=>true]); + break; + + // ─── PLATFORMS: soft-delete (archive) ──────────────── + case 'platforms_delete': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + if ((int)($_SESSION['user_id'] ?? 0) !== MASTER_ADMIN_ID) { echo json_encode(['success'=>false,'error'=>'Only master admin can archive games']); exit; } + $d = json_decode(file_get_contents('php://input'), true); + $id = (int)($d['id'] ?? 0); + if (!$id) { echo json_encode(['success'=>false,'error'=>'ID required']); exit; } + db()->prepare("UPDATE platforms SET is_deleted=1, deleted_at=NOW(), updated_at=NOW() WHERE id=?")->execute([$id]); + echo json_encode(['success'=>true]); + break; + case 'billing_get': + $uid = (int)($_GET['user_id'] ?? 0); + if (!$uid) { echo json_encode(['success'=>false,'error'=>'user_id required']); exit; } + $stmt = db()->prepare("SELECT * FROM saved_billing WHERE user_id=?"); + $stmt->execute([$uid]); + $row = $stmt->fetch(); + // Admin sees masked card info + if ($row) { + $row['card_display'] = $row['card_brand'] && $row['card_last4'] + ? $row['card_brand'] . ' ····' . $row['card_last4'] : null; + // Don't expose raw sq_card_id + unset($row['sq_card_id']); + } + echo json_encode(['success'=>true,'billing'=>$row ?: null]); + break; + + // ─── BILLING: save/update ──────────────────────────────── + case 'billing_save': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + $data = json_decode(file_get_contents('php://input'), true); + $uid = (int)($data['user_id'] ?? 0); + if (!$uid) { echo json_encode(['success'=>false,'error'=>'user_id required']); exit; } + $stmt = db()->prepare(" + INSERT INTO saved_billing (user_id,first_name,last_name,email,address,city,state,zip) + VALUES (?,?,?,?,?,?,?,?) + ON DUPLICATE KEY UPDATE + first_name=VALUES(first_name), last_name=VALUES(last_name), + email=VALUES(email), address=VALUES(address), + city=VALUES(city), state=VALUES(state), zip=VALUES(zip) + "); + $stmt->execute([ + $uid, + substr(trim($data['first_name']??''),0,80), + substr(trim($data['last_name'] ??''),0,80), + substr(strtolower(trim($data['email']??'')),0,150), + substr(trim($data['address'] ??''),0,200), + substr(trim($data['city'] ??''),0,80), + strtoupper(substr(trim($data['state']??''),0,2)), + substr(trim($data['zip'] ??''),0,10), + ]); + echo json_encode(['success'=>true]); + break; + + // ─── BILLING: clear card ───────────────────────────────── + case 'billing_clear_card': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + $data = json_decode(file_get_contents('php://input'), true); + $uid = (int)($data['user_id'] ?? 0); + db()->prepare("UPDATE saved_billing SET card_brand=NULL,card_last4=NULL,card_exp_month=NULL,card_exp_year=NULL,sq_card_id=NULL WHERE user_id=?")->execute([$uid]); + echo json_encode(['success'=>true]); + break; + + // ─── BILLING: clear all ────────────────────────────────── + case 'billing_clear_all': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + $data = json_decode(file_get_contents('php://input'), true); + $uid = (int)($data['user_id'] ?? 0); + db()->prepare("DELETE FROM saved_billing WHERE user_id=?")->execute([$uid]); + echo json_encode(['success'=>true]); + break; + + // ─── RESEND VERIFICATION (from admin) ───────────────────── + case 'resend_verification': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + $data = json_decode(file_get_contents('php://input'), true); + $uid = (int)($data['user_id'] ?? 0); + $stmt = db()->prepare("SELECT email,alias FROM users WHERE id=?"); + $stmt->execute([$uid]); + $user = $stmt->fetch(); + if (!$user) { echo json_encode(['success'=>false,'error'=>'User not found']); exit; } + $result = resendVerification($user['email']); + echo json_encode($result); + break; + + // ─── CHAT: inbox list ────────────────────────────────── + case 'chat_inbox': + $rows = db()->query(" + SELECT u.id AS user_id, u.username, u.alias, + cm.message AS last_message, cm.sender AS last_sender, + cm.created_at AS last_time, + (SELECT COUNT(*) FROM chat_messages + WHERE user_id=u.id AND sender='user' AND is_read=0) AS unread_count + FROM users u + INNER JOIN chat_messages cm ON cm.id=( + SELECT id FROM chat_messages WHERE user_id=u.id ORDER BY id DESC LIMIT 1 + ) + ORDER BY cm.created_at DESC + ")->fetchAll(); + echo json_encode(['success'=>true,'inbox'=>$rows]); + break; + + // ─── CHAT: full thread for one user ─────────────────── + case 'chat_thread': + $tid = (int)($_GET['user_id'] ?? 0); + $since = (int)($_GET['since'] ?? 0); + if (!$tid) { echo json_encode(['success'=>false,'error'=>'user_id required']); exit; } + $stmt = db()->prepare("SELECT id,sender,message,is_read,created_at FROM chat_messages WHERE user_id=? AND id>? ORDER BY id ASC LIMIT 300"); + $stmt->execute([$tid, $since]); + // Mark user messages read + db()->prepare("UPDATE chat_messages SET is_read=1 WHERE user_id=? AND sender='user' AND is_read=0")->execute([$tid]); + $uStmt = db()->prepare("SELECT id,username,alias,tokens FROM users WHERE id=?"); + $uStmt->execute([$tid]); + echo json_encode(['success'=>true,'messages'=>$stmt->fetchAll(),'user'=>$uStmt->fetch()]); + break; + + // ─── CHAT: admin reply ──────────────────────────────── + case 'chat_admin_send': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + $data = json_decode(file_get_contents('php://input'), true); + $tid = (int)($data['user_id'] ?? 0); + $msg = trim($data['message'] ?? ''); + if (!$tid || empty($msg)) { echo json_encode(['success'=>false,'error'=>'Invalid']); exit; } + $stmt = db()->prepare("INSERT INTO chat_messages (user_id,sender,message) VALUES (?,'admin',?)"); + $stmt->execute([$tid, $msg]); + echo json_encode(['success'=>true,'id'=>db()->lastInsertId()]); + break; + + // ─── CHAT: clear single user thread ─────────────────── + case 'chat_clear_thread': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + $data = json_decode(file_get_contents('php://input'), true); + $tid = (int)($data['user_id'] ?? 0); + if (!$tid) { echo json_encode(['success'=>false,'error'=>'user_id required']); exit; } + db()->prepare("DELETE FROM chat_messages WHERE user_id=?")->execute([$tid]); + echo json_encode(['success'=>true]); + break; + + // ─── CHAT: clear ALL chats ──────────────────────────── + case 'chat_clear_all': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + try { + db()->exec("DELETE FROM chat_messages"); + logAdminAction('CHAT_CLEAR_ALL', (int)$_SESSION['user_id'], 'chat', 0, 'Cleared all chat messages', '', '', 'warning'); + echo json_encode(['success'=>true]); + } catch (Exception $e) { + echo json_encode(['success'=>false,'error'=>'Failed to clear chat']); + } + break; + case 'chat_unread': + $count = db()->query("SELECT COUNT(*) FROM chat_messages WHERE sender='user' AND is_read=0")->fetchColumn(); + echo json_encode(['success'=>true,'count'=>(int)$count]); + break; + + default: + echo json_encode(['success'=>false,'error'=>'Unknown action']); +} diff --git a/sites/tomtomgames.com/public_html/api/backup.php b/sites/tomtomgames.com/public_html/api/backup.php new file mode 100644 index 0000000..b93abb6 --- /dev/null +++ b/sites/tomtomgames.com/public_html/api/backup.php @@ -0,0 +1,109 @@ + [ + 'name' => basename($f), + 'size' => filesize($f), + 'created' => date('Y-m-d H:i:s', filemtime($f)), + ], $files); + echo json_encode(['success'=>true, 'backups'=>$backups]); + break; + + case 'create': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false,'error'=>'POST required']); exit; } + set_time_limit(300); + ignore_user_abort(true); + + $date = date('Y-m-d_H-i-s'); + $sqlFile = "/tmp/ttg_db_{$date}.sql"; + $zipFile = "{$backupDir}/ttg_backup_{$date}.zip"; + $siteDir = '/home/tomtomgames.com/public_html'; + + // Export database + $dbCmd = sprintf( + '/usr/bin/mysqldump -u %s -p%s %s > %s 2>/dev/null', + escapeshellarg(DB_USER), escapeshellarg(DB_PASS), + escapeshellarg(DB_NAME), escapeshellarg($sqlFile) + ); + exec($dbCmd, $dbOut, $dbRc); + if ($dbRc !== 0 || !file_exists($sqlFile) || filesize($sqlFile) < 10) { + @unlink($sqlFile); + echo json_encode(['success'=>false,'error'=>'Database export failed']); exit; + } + + // Zip site files + db dump into one archive + $zipCmd = sprintf( + '/usr/bin/zip -r %s %s %s -x "*/backups/*" 2>&1', + escapeshellarg($zipFile), + escapeshellarg($siteDir), + escapeshellarg($sqlFile) + ); + exec($zipCmd, $zipOut, $zipRc); + @unlink($sqlFile); + + if ($zipRc !== 0 || !file_exists($zipFile)) { + @unlink($zipFile); + echo json_encode(['success'=>false,'error'=>'Archive creation failed']); exit; + } + + // Prune — keep only the 7 most recent + $all = glob($backupDir . '/ttg_backup_*.zip') ?: []; + rsort($all); + foreach (array_slice($all, 7) as $old) @unlink($old); + + echo json_encode([ + 'success' => true, + 'name' => basename($zipFile), + 'size' => filesize($zipFile), + 'created' => date('Y-m-d H:i:s'), + ]); + break; + + case 'download': + $name = basename($_GET['file'] ?? ''); + if (!preg_match('/^ttg_backup_[\d_-]+\.zip$/', $name)) { + http_response_code(400); echo 'Invalid filename'; exit; + } + $path = $backupDir . '/' . $name; + if (!file_exists($path)) { http_response_code(404); echo 'Not found'; exit; } + + header('Content-Type: application/zip'); + header('Content-Disposition: attachment; filename="' . $name . '"'); + header('Content-Length: ' . filesize($path)); + header('Cache-Control: no-cache'); + readfile($path); + exit; + + case 'delete': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false,'error'=>'POST required']); exit; } + $data = json_decode(file_get_contents('php://input'), true); + $name = basename($data['name'] ?? ''); + if (!preg_match('/^ttg_backup_[\d_-]+\.zip$/', $name)) { + echo json_encode(['success'=>false,'error'=>'Invalid filename']); exit; + } + $path = $backupDir . '/' . $name; + if (file_exists($path)) @unlink($path); + echo json_encode(['success'=>true]); + break; + + default: + echo json_encode(['success'=>false,'error'=>'Unknown action']); +} diff --git a/sites/tomtomgames.com/public_html/api/billing.php b/sites/tomtomgames.com/public_html/api/billing.php new file mode 100644 index 0000000..5e6a8e2 --- /dev/null +++ b/sites/tomtomgames.com/public_html/api/billing.php @@ -0,0 +1,91 @@ +false,'error'=>'Not authenticated']); exit; } + +$action = $_GET['action'] ?? ''; +$userId = $_SESSION['user_id']; +$isAdmin = !empty($_SESSION['is_admin']); + +switch ($action) { + + // ── Get saved billing (user sees own; admin passes user_id param) ── + case 'get': + $uid = $isAdmin ? (int)($_GET['user_id'] ?? $userId) : $userId; + $stmt = db()->prepare("SELECT * FROM saved_billing WHERE user_id=?"); + $stmt->execute([$uid]); + $row = $stmt->fetch(); + if ($row && !$isAdmin) { + // Mask card number for non-admin + $row['card_display'] = $row['card_brand'] && $row['card_last4'] + ? $row['card_brand'] . ' ····' . $row['card_last4'] + : null; + unset($row['sq_card_id']); + } + echo json_encode(['success'=>true, 'billing'=>$row ?: null]); + break; + + // ── Save / update billing info ───────────────────────────── + case 'save': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + $data = json_decode(file_get_contents('php://input'), true); + $uid = $isAdmin && isset($data['user_id']) ? (int)$data['user_id'] : $userId; + + $firstName = substr(trim($data['first_name'] ?? ''), 0, 80); + $lastName = substr(trim($data['last_name'] ?? ''), 0, 80); + $email = substr(strtolower(trim($data['email'] ?? '')), 0, 150); + $address = substr(trim($data['address'] ?? ''), 0, 200); + $city = substr(trim($data['city'] ?? ''), 0, 80); + $state = strtoupper(substr(trim($data['state'] ?? ''), 0, 2)); + $zip = substr(trim($data['zip'] ?? ''), 0, 10); + + // Card info — only update if provided + $cardBrand = isset($data['card_brand']) ? substr(trim($data['card_brand']), 0, 30) : null; + $cardLast4 = isset($data['card_last4']) ? substr(trim($data['card_last4']), 0, 4) : null; + $cardExpMonth = isset($data['card_exp_month'])? substr(trim($data['card_exp_month']),0, 2) : null; + $cardExpYear = isset($data['card_exp_year']) ? substr(trim($data['card_exp_year']), 0, 4) : null; + $sqCardId = isset($data['sq_card_id']) ? substr(trim($data['sq_card_id']), 0, 255) : null; + + $stmt = db()->prepare(" + INSERT INTO saved_billing + (user_id, first_name, last_name, email, address, city, state, zip, + card_brand, card_last4, card_exp_month, card_exp_year, sq_card_id) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + ON DUPLICATE KEY UPDATE + first_name=VALUES(first_name), last_name=VALUES(last_name), + email=VALUES(email), address=VALUES(address), city=VALUES(city), + state=VALUES(state), zip=VALUES(zip), + card_brand=COALESCE(VALUES(card_brand), card_brand), + card_last4=COALESCE(VALUES(card_last4), card_last4), + card_exp_month=COALESCE(VALUES(card_exp_month), card_exp_month), + card_exp_year=COALESCE(VALUES(card_exp_year), card_exp_year), + sq_card_id=COALESCE(VALUES(sq_card_id), sq_card_id) + "); + $stmt->execute([$uid,$firstName,$lastName,$email,$address,$city,$state,$zip, + $cardBrand,$cardLast4,$cardExpMonth,$cardExpYear,$sqCardId]); + echo json_encode(['success'=>true]); + break; + + // ── Clear card info only ─────────────────────────────────── + case 'clear_card': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + $data = json_decode(file_get_contents('php://input'), true); + $uid = $isAdmin && isset($data['user_id']) ? (int)$data['user_id'] : $userId; + db()->prepare("UPDATE saved_billing SET card_brand=NULL, card_last4=NULL, card_exp_month=NULL, card_exp_year=NULL, sq_card_id=NULL WHERE user_id=?") + ->execute([$uid]); + echo json_encode(['success'=>true]); + break; + + // ── Clear all billing info ──────────────────────────────── + case 'clear_all': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + $data = json_decode(file_get_contents('php://input'), true); + $uid = $isAdmin && isset($data['user_id']) ? (int)$data['user_id'] : $userId; + db()->prepare("DELETE FROM saved_billing WHERE user_id=?")->execute([$uid]); + echo json_encode(['success'=>true]); + break; + + default: + echo json_encode(['success'=>false,'error'=>'Unknown action']); +} diff --git a/sites/tomtomgames.com/public_html/api/broadcast.php b/sites/tomtomgames.com/public_html/api/broadcast.php new file mode 100644 index 0000000..09654e4 --- /dev/null +++ b/sites/tomtomgames.com/public_html/api/broadcast.php @@ -0,0 +1,89 @@ +false,'error'=>'Server error']); exit; } +ob_end_clean(); +header('Content-Type: application/json'); + +if (!isLoggedIn()) { echo json_encode(['success'=>false,'error'=>'Not authenticated']); exit; } + +$action = $_GET['action'] ?? 'list'; +$userId = (int)$_SESSION['user_id']; +$isAdmin = !empty($_SESSION['is_admin']); + +switch ($action) { + + // ── List broadcasts for this user ───────────────────── + case 'list': + // Get broadcasts targeting this user + $stmt = db()->prepare(" + SELECT b.*, + u.username AS sender_name, + u.alias AS sender_alias, + (SELECT COUNT(*) FROM broadcast_replies WHERE broadcast_id=b.id) AS reply_count, + (SELECT COUNT(*) FROM broadcast_reads WHERE broadcast_id=b.id AND user_id=?) AS is_read, + (SELECT COUNT(*) FROM broadcast_reads WHERE broadcast_id=b.id) AS read_count + FROM broadcasts b + JOIN users u ON b.admin_id = u.id + WHERE b.target = 'all' + OR (b.target = 'verified' AND EXISTS(SELECT 1 FROM users WHERE id=? AND email_verified=1 AND is_admin=0)) + OR (b.target = 'unverified' AND EXISTS(SELECT 1 FROM users WHERE id=? AND email_verified=0)) + OR (b.target = 'admins' AND ?) + ORDER BY b.sent_at DESC + "); + $stmt->execute([$userId, $userId, $userId, $isAdmin ? 1 : 0]); + echo json_encode(['success'=>true, 'broadcasts'=>$stmt->fetchAll()]); + break; + + // ── Mark as read ────────────────────────────────────── + case 'mark_read': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + $d = json_decode(file_get_contents('php://input'), true); + $bid= (int)($d['broadcast_id'] ?? 0); + if (!$bid) { echo json_encode(['success'=>false]); exit; } + db()->prepare("INSERT IGNORE INTO broadcast_reads (broadcast_id,user_id) VALUES (?,?)")->execute([$bid,$userId]); + echo json_encode(['success'=>true]); + break; + + // ── Get replies for a broadcast ─────────────────────── + case 'replies': + $bid = (int)($_GET['broadcast_id'] ?? 0); + if (!$bid) { echo json_encode(['success'=>false]); exit; } + $stmt = db()->prepare(" + SELECT br.*, u.username, u.alias, u.is_admin + FROM broadcast_replies br + JOIN users u ON br.user_id = u.id + WHERE br.broadcast_id = ? + ORDER BY br.created_at ASC + "); + $stmt->execute([$bid]); + echo json_encode(['success'=>true,'replies'=>$stmt->fetchAll()]); + break; + + // ── Post a reply ────────────────────────────────────── + case 'reply': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + $d = json_decode(file_get_contents('php://input'), true); + $bid = (int)($d['broadcast_id'] ?? 0); + $msg = substr(trim($d['message'] ?? ''), 0, 1000); + if (!$bid || !$msg) { echo json_encode(['success'=>false,'error'=>'broadcast_id and message required']); exit; } + db()->prepare("INSERT INTO broadcast_replies (broadcast_id,user_id,message) VALUES (?,?,?)")->execute([$bid,$userId,$msg]); + db()->prepare("INSERT IGNORE INTO broadcast_reads (broadcast_id,user_id) VALUES (?,?)")->execute([$bid,$userId]); + echo json_encode(['success'=>true,'id'=>db()->lastInsertId()]); + break; + + // ── Unread count ────────────────────────────────────── + case 'unread_count': + $stmt = db()->prepare(" + SELECT COUNT(*) FROM broadcasts b + WHERE (b.target='all' OR (b.target='verified' AND EXISTS(SELECT 1 FROM users WHERE id=? AND email_verified=1 AND is_admin=0)) + OR (b.target='unverified' AND EXISTS(SELECT 1 FROM users WHERE id=? AND email_verified=0)) + OR (b.target='admins' AND ?)) + AND NOT EXISTS (SELECT 1 FROM broadcast_reads WHERE broadcast_id=b.id AND user_id=?) + "); + $stmt->execute([$userId,$userId,$isAdmin?1:0,$userId]); + echo json_encode(['success'=>true,'count'=>(int)$stmt->fetchColumn()]); + break; + + default: + echo json_encode(['success'=>false,'error'=>'Unknown action']); +} diff --git a/sites/tomtomgames.com/public_html/api/cashout.php b/sites/tomtomgames.com/public_html/api/cashout.php new file mode 100644 index 0000000..ab571d1 --- /dev/null +++ b/sites/tomtomgames.com/public_html/api/cashout.php @@ -0,0 +1,158 @@ +false,'error'=>'Not authenticated']); exit; } + +$userId = (int)$_SESSION['user_id']; +$method = $_SERVER['REQUEST_METHOD']; + +// ══════════════════════════════════════════════════════════ +// GET — player's own requests (list, delete, update, lock) +// ══════════════════════════════════════════════════════════ +if ($method === 'GET') { + $action = $_GET['action'] ?? 'list'; + + if ($action === 'list') { + $stmt = db()->prepare(" + SELECT cr.*, + COALESCE(p.name, cr.platform_id) AS platform_name + FROM cashout_requests cr + LEFT JOIN platforms p ON cr.platform_id = p.slug + WHERE cr.user_id = ? + ORDER BY cr.created_at DESC + LIMIT 50 + "); + $stmt->execute([$userId]); + echo json_encode(['success'=>true, 'requests'=>$stmt->fetchAll()]); + exit; + } + + if ($action === 'delete') { + $id = (int)($_GET['id'] ?? 0); + $chk = db()->prepare("SELECT id,tokens FROM cashout_requests WHERE id=? AND user_id=? AND status='pending'"); + $chk->execute([$id, $userId]); + $row = $chk->fetch(); + if (!$row) { echo json_encode(['success'=>false,'error'=>'Request not found or already locked']); exit; } + db()->prepare("UPDATE users SET tokens=tokens+? WHERE id=?")->execute([$row['tokens'], $userId]); + db()->prepare("DELETE FROM cashout_requests WHERE id=?")->execute([$id]); + $nb = db()->prepare("SELECT tokens FROM users WHERE id=?"); + $nb->execute([$userId]); + echo json_encode(['success'=>true,'new_balance'=>(float)$nb->fetchColumn()]); + exit; + } + + if ($action === 'update') { + $id = (int)($_GET['id'] ?? 0); + $tokens = (float)($_GET['tokens'] ?? 0); + $alias = substr(trim($_GET['alias'] ?? ''), 0, 100); + $chk = db()->prepare("SELECT id,tokens AS old_tokens FROM cashout_requests WHERE id=? AND user_id=? AND status='pending'"); + $chk->execute([$id, $userId]); + $row = $chk->fetch(); + if (!$row) { echo json_encode(['success'=>false,'error'=>'Request not found or already locked']); exit; } + if ($tokens < 1) { echo json_encode(['success'=>false,'error'=>'Minimum 1 token']); exit; } + $diff = $tokens - $row['old_tokens']; + if ($diff > 0) { + $balChk = db()->prepare("SELECT tokens FROM users WHERE id=?"); + $balChk->execute([$userId]); + if ($diff > (float)$balChk->fetchColumn()) { echo json_encode(['success'=>false,'error'=>'Insufficient balance']); exit; } + } + db()->beginTransaction(); + db()->prepare("UPDATE users SET tokens=tokens-? WHERE id=?")->execute([$diff, $userId]); + db()->prepare("UPDATE cashout_requests SET tokens=?,alias=? WHERE id=?")->execute([$tokens, $alias, $id]); + db()->commit(); + echo json_encode(['success'=>true]); + exit; + } + + if ($action === 'lock') { + $id = (int)($_GET['id'] ?? 0); + $chk = db()->prepare("SELECT id FROM cashout_requests WHERE id=? AND user_id=? AND status='pending'"); + $chk->execute([$id, $userId]); + if (!$chk->fetch()) { echo json_encode(['success'=>false,'error'=>'Request not found']); exit; } + try { + db()->exec("ALTER TABLE cashout_requests MODIFY COLUMN status ENUM('pending','locked','sent','approved','rejected','deleted') DEFAULT 'pending'"); + } catch (Exception $e) {} + db()->prepare("UPDATE cashout_requests SET status='locked' WHERE id=?")->execute([$id]); + echo json_encode(['success'=>true]); + exit; + } + + echo json_encode(['success'=>false,'error'=>'Unknown action']); + exit; +} + +// ══════════════════════════════════════════════════════════ +// POST — submit new cashout request +// ══════════════════════════════════════════════════════════ +if ($method !== 'POST') { echo json_encode(['success'=>false,'error'=>'Method not allowed']); exit; } + +$data = json_decode(file_get_contents('php://input'), true); +$platformId = trim($data['platform_id'] ?? ''); +$alias = trim($data['alias'] ?? ''); +$tokens = (float)($data['tokens'] ?? 0); +$payoutMethodId = (int)($data['payout_method_id'] ?? 0); +$payoutMethodType = trim($data['payout_method_type'] ?? ''); +$payoutHandle = trim($data['payout_handle'] ?? ''); + +// Validate platform +$platStmt = db()->prepare("SELECT slug FROM platforms WHERE slug=? AND is_active=1 LIMIT 1"); +$platStmt->execute([$platformId]); +if (!$platStmt->fetch()) { + $platforms = json_decode(PLATFORMS, true); + if (empty(array_filter($platforms, fn($p) => $p['id'] === $platformId))) { + echo json_encode(['success'=>false,'error'=>'Invalid platform.']); exit; + } +} + +if (empty($alias)) { echo json_encode(['success'=>false,'error'=>'Platform alias required.']); exit; } +if ($tokens < 1) { echo json_encode(['success'=>false,'error'=>'Minimum cashout is 1 token.']); exit; } + +// Validate payout method +if ($payoutMethodId) { + $chk = db()->prepare("SELECT method_type,account_handle FROM payout_methods WHERE id=? AND user_id=?"); + $chk->execute([$payoutMethodId, $userId]); + if ($pm = $chk->fetch()) { + $payoutMethodType = $pm['method_type']; + $payoutHandle = $pm['account_handle']; + } +} + +// Reject if the payout method type is not currently admin-enabled +if ($payoutMethodType) { + $enaStmt = db()->prepare("SELECT is_enabled FROM admin_payout_settings WHERE method_key=?"); + $enaStmt->execute([$payoutMethodType]); + $enaRow = $enaStmt->fetch(); + if (!$enaRow || !$enaRow['is_enabled']) { + echo json_encode(['success'=>false,'error'=>'This payout method is no longer available. Please select a different method or add a new one.']); exit; + } +} + +// Check balance +$balStmt = db()->prepare("SELECT tokens FROM users WHERE id=?"); +$balStmt->execute([$userId]); +$balance = (float)$balStmt->fetchColumn(); +if ($tokens > $balance) { echo json_encode(['success'=>false,'error'=>'Insufficient token balance.']); exit; } + +// Deduct & create +db()->beginTransaction(); +try { + db()->prepare("UPDATE users SET tokens=tokens-? WHERE id=?")->execute([$tokens, $userId]); + db()->prepare("INSERT INTO cashout_requests (user_id,platform_id,alias,tokens,payout_method_type,payout_handle) VALUES (?,?,?,?,?,?)") + ->execute([$userId, $platformId, $alias, $tokens, $payoutMethodType, $payoutHandle]); + db()->commit(); +} catch (Exception $e) { + db()->rollBack(); + echo json_encode(['success'=>false,'error'=>'Request failed. Try again.']); exit; +} + +$newBalStmt = db()->prepare("SELECT tokens FROM users WHERE id=?"); +$newBalStmt->execute([$userId]); +$nb = (float)$newBalStmt->fetchColumn(); + +try { logActivity('cashout_request', $userId, null, 'cashout', 0, "Cashout: {$tokens} tokens via {$payoutMethodType}"); } catch(Exception $e){} + +echo json_encode(['success'=>true, 'new_balance'=>$nb]); diff --git a/sites/tomtomgames.com/public_html/api/cashout_method_types.php b/sites/tomtomgames.com/public_html/api/cashout_method_types.php new file mode 100644 index 0000000..e1cce92 --- /dev/null +++ b/sites/tomtomgames.com/public_html/api/cashout_method_types.php @@ -0,0 +1,79 @@ +false,'error'=>'Server error']); exit; } +ob_end_clean(); +header('Content-Type: application/json'); + +$action = $_GET['action'] ?? 'list'; +$isAdmin = isLoggedIn() && !empty($_SESSION['is_admin']); + +switch ($action) { + + // Public: active types that admin has enabled for payout processing + case 'list': + $rows = db()->query(" + SELECT cmt.slug, cmt.label, cmt.icon, cmt.description + FROM cashout_method_types cmt + INNER JOIN admin_payout_settings aps ON aps.method_key = cmt.slug + WHERE cmt.is_active = 1 AND aps.is_enabled = 1 + ORDER BY cmt.sort_order ASC, cmt.id ASC + ")->fetchAll(); + echo json_encode(['success'=>true, 'types'=>$rows]); + break; + + // Admin: all types + case 'admin_list': + if (!$isAdmin) { echo json_encode(['success'=>false,'error'=>'Forbidden']); exit; } + $rows = db()->query("SELECT * FROM cashout_method_types ORDER BY sort_order ASC, id ASC")->fetchAll(); + echo json_encode(['success'=>true, 'types'=>$rows]); + break; + + // Admin: create + case 'create': + if (!$isAdmin || $_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false,'error'=>'Forbidden']); exit; } + $d = json_decode(file_get_contents('php://input'), true); + $slug = preg_replace('/[^a-z0-9_]/', '', strtolower(trim($d['slug'] ?? ''))); + $label= substr(trim($d['label'] ?? ''), 0, 100); + $icon = substr(trim($d['icon'] ?? '💰'), 0, 10); + $desc = substr(trim($d['description'] ?? ''), 0, 200); + $sort = (int)($d['sort_order'] ?? 99); + $active = (int)(bool)($d['is_active'] ?? 1); + if (!$slug || !$label) { echo json_encode(['success'=>false,'error'=>'Slug and label required']); exit; } + try { + db()->prepare("INSERT INTO cashout_method_types (slug,label,icon,description,is_active,sort_order) VALUES (?,?,?,?,?,?)") + ->execute([$slug,$label,$icon,$desc,$active,$sort]); + echo json_encode(['success'=>true,'id'=>db()->lastInsertId()]); + } catch (Exception $e) { + echo json_encode(['success'=>false,'error'=>'Slug already exists']); + } + break; + + // Admin: update + case 'update': + if (!$isAdmin || $_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false,'error'=>'Forbidden']); exit; } + $d = json_decode(file_get_contents('php://input'), true); + $id = (int)($d['id'] ?? 0); + $label= substr(trim($d['label'] ?? ''), 0, 100); + $icon = substr(trim($d['icon'] ?? '💰'), 0, 10); + $desc = substr(trim($d['description'] ?? ''), 0, 200); + $sort = (int)($d['sort_order'] ?? 0); + $active = (int)(bool)($d['is_active'] ?? 1); + if (!$id || !$label) { echo json_encode(['success'=>false,'error'=>'ID and label required']); exit; } + db()->prepare("UPDATE cashout_method_types SET label=?,icon=?,description=?,is_active=?,sort_order=? WHERE id=?") + ->execute([$label,$icon,$desc,$active,$sort,$id]); + echo json_encode(['success'=>true]); + break; + + // Admin: delete + case 'delete': + if (!$isAdmin || $_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false,'error'=>'Forbidden']); exit; } + $d = json_decode(file_get_contents('php://input'), true); + $id = (int)($d['id'] ?? 0); + if (!$id) { echo json_encode(['success'=>false,'error'=>'ID required']); exit; } + db()->prepare("DELETE FROM cashout_method_types WHERE id=?")->execute([$id]); + echo json_encode(['success'=>true]); + break; + + default: + echo json_encode(['success'=>false,'error'=>'Unknown action']); +} diff --git a/sites/tomtomgames.com/public_html/api/chat.php b/sites/tomtomgames.com/public_html/api/chat.php new file mode 100644 index 0000000..daf0bad --- /dev/null +++ b/sites/tomtomgames.com/public_html/api/chat.php @@ -0,0 +1,124 @@ +false,'error'=>'Not authenticated']); exit; } + +$action = $_GET['action'] ?? ''; +$userId = $_SESSION['user_id']; +$isAdmin = !empty($_SESSION['is_admin']); + +switch ($action) { + + // ── User: get own messages ───────────────────────────── + case 'messages': + $since = (int)($_GET['since'] ?? 0); // last message id for polling + $stmt = db()->prepare(" + SELECT id, sender, message, is_read, created_at + FROM chat_messages + WHERE user_id = ? AND id > ? + ORDER BY id ASC + LIMIT 100 + "); + $stmt->execute([$userId, $since]); + $msgs = $stmt->fetchAll(); + + // Mark admin messages as read + db()->prepare("UPDATE chat_messages SET is_read=1 WHERE user_id=? AND sender='admin' AND is_read=0")->execute([$userId]); + + echo json_encode(['success'=>true, 'messages'=>$msgs]); + break; + + // ── User: send message to admin ─────────────────────── + case 'send': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + $data = json_decode(file_get_contents('php://input'), true); + $msg = trim($data['message'] ?? ''); + if (empty($msg) || mb_strlen($msg) > 2000) { echo json_encode(['success'=>false,'error'=>'Invalid message']); exit; } + + $stmt = db()->prepare("INSERT INTO chat_messages (user_id, sender, message) VALUES (?, 'user', ?)"); + $stmt->execute([$userId, $msg]); + echo json_encode(['success'=>true, 'id'=>db()->lastInsertId()]); + break; + + // ── Admin: get inbox list (one row per user, latest msg) ── + case 'admin_inbox': + if (!$isAdmin) { echo json_encode(['success'=>false,'error'=>'Forbidden']); exit; } + $rows = db()->query(" + SELECT + u.id AS user_id, + u.username, + u.alias, + cm.message AS last_message, + cm.sender AS last_sender, + cm.created_at AS last_time, + SUM(CASE WHEN cm2.sender='user' AND cm2.is_read=0 THEN 1 ELSE 0 END) AS unread_count + FROM users u + JOIN chat_messages cm ON cm.id = ( + SELECT id FROM chat_messages + WHERE user_id = u.id + ORDER BY id DESC LIMIT 1 + ) + LEFT JOIN chat_messages cm2 ON cm2.user_id = u.id + GROUP BY u.id, u.username, u.alias, cm.message, cm.sender, cm.created_at + ORDER BY cm.created_at DESC + ")->fetchAll(); + echo json_encode(['success'=>true, 'inbox'=>$rows]); + break; + + // ── Admin: get all messages for a specific user ──────── + case 'admin_thread': + if (!$isAdmin) { echo json_encode(['success'=>false,'error'=>'Forbidden']); exit; } + $tid = (int)($_GET['user_id'] ?? 0); + $since = (int)($_GET['since'] ?? 0); + if (!$tid) { echo json_encode(['success'=>false,'error'=>'user_id required']); exit; } + + $stmt = db()->prepare(" + SELECT id, sender, message, is_read, created_at + FROM chat_messages + WHERE user_id = ? AND id > ? + ORDER BY id ASC LIMIT 200 + "); + $stmt->execute([$tid, $since]); + $msgs = $stmt->fetchAll(); + + // Mark user messages as read + db()->prepare("UPDATE chat_messages SET is_read=1 WHERE user_id=? AND sender='user' AND is_read=0")->execute([$tid]); + + // Get user info + $uStmt = db()->prepare("SELECT id, username, alias, tokens FROM users WHERE id=?"); + $uStmt->execute([$tid]); + $user = $uStmt->fetch(); + + echo json_encode(['success'=>true, 'messages'=>$msgs, 'user'=>$user]); + break; + + // ── Admin: reply to a user ──────────────────────────── + case 'admin_send': + if (!$isAdmin) { echo json_encode(['success'=>false,'error'=>'Forbidden']); exit; } + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + $data = json_decode(file_get_contents('php://input'), true); + $tid = (int)($data['user_id'] ?? 0); + $msg = trim($data['message'] ?? ''); + if (!$tid || empty($msg) || mb_strlen($msg) > 2000) { echo json_encode(['success'=>false,'error'=>'Invalid']); exit; } + + $stmt = db()->prepare("INSERT INTO chat_messages (user_id, sender, message) VALUES (?, 'admin', ?)"); + $stmt->execute([$tid, $msg]); + echo json_encode(['success'=>true, 'id'=>db()->lastInsertId()]); + break; + + // ── Unread count (for badge) ────────────────────────── + case 'unread': + if ($isAdmin) { + $count = db()->query("SELECT COUNT(*) FROM chat_messages WHERE sender='user' AND is_read=0")->fetchColumn(); + } else { + $stmt = db()->prepare("SELECT COUNT(*) FROM chat_messages WHERE user_id=? AND sender='admin' AND is_read=0"); + $stmt->execute([$userId]); + $count = $stmt->fetchColumn(); + } + echo json_encode(['success'=>true, 'count'=>(int)$count]); + break; + + default: + echo json_encode(['success'=>false,'error'=>'Unknown action']); +} diff --git a/sites/tomtomgames.com/public_html/api/game_aliases.php b/sites/tomtomgames.com/public_html/api/game_aliases.php new file mode 100644 index 0000000..3832cc4 --- /dev/null +++ b/sites/tomtomgames.com/public_html/api/game_aliases.php @@ -0,0 +1,65 @@ +false,'error'=>'Server error']); exit; } +ob_end_clean(); +header('Content-Type: application/json'); + +if (!isLoggedIn()) { echo json_encode(['success'=>false,'error'=>'Not authenticated']); exit; } + +$action = $_GET['action'] ?? ''; +$userId = $_SESSION['user_id']; +$isAdmin = !empty($_SESSION['is_admin']); + +switch ($action) { + + // ── Get all aliases for a user ──────────────────────── + case 'get': + $uid = $isAdmin ? (int)($_GET['user_id'] ?? $userId) : $userId; + $stmt = db()->prepare("SELECT platform_slug, alias FROM game_aliases WHERE user_id=?"); + $stmt->execute([$uid]); + $rows = $stmt->fetchAll(); + $map = []; + foreach ($rows as $r) $map[$r['platform_slug']] = $r['alias']; + echo json_encode(['success'=>true, 'aliases'=>$map]); + break; + + // ── Save a single alias ─────────────────────────────── + case 'save': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + $data = json_decode(file_get_contents('php://input'), true); + $uid = $isAdmin && isset($data['user_id']) ? (int)$data['user_id'] : $userId; + $slug = preg_replace('/[^a-z0-9_]/', '', strtolower(trim($data['platform_slug'] ?? ''))); + $alias = substr(trim($data['alias'] ?? ''), 0, 100); + if (!$slug) { echo json_encode(['success'=>false,'error'=>'Platform slug required']); exit; } + if ($alias === '') { + // Empty alias = delete it + db()->prepare("DELETE FROM game_aliases WHERE user_id=? AND platform_slug=?")->execute([$uid,$slug]); + } else { + db()->prepare("INSERT INTO game_aliases (user_id,platform_slug,alias) VALUES (?,?,?) + ON DUPLICATE KEY UPDATE alias=VALUES(alias)")->execute([$uid,$slug,$alias]); + } + echo json_encode(['success'=>true]); + break; + + // ── Save all aliases at once (bulk) ─────────────────── + case 'save_all': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + $data = json_decode(file_get_contents('php://input'), true); + $uid = $isAdmin && isset($data['user_id']) ? (int)$data['user_id'] : $userId; + $aliases = $data['aliases'] ?? []; + $stmt = db()->prepare("INSERT INTO game_aliases (user_id,platform_slug,alias) VALUES (?,?,?) + ON DUPLICATE KEY UPDATE alias=VALUES(alias)"); + $del = db()->prepare("DELETE FROM game_aliases WHERE user_id=? AND platform_slug=?"); + foreach ($aliases as $slug => $alias) { + $slug = preg_replace('/[^a-z0-9_]/', '', strtolower(trim($slug))); + $alias = substr(trim($alias), 0, 100); + if (!$slug) continue; + if ($alias === '') $del->execute([$uid, $slug]); + else $stmt->execute([$uid, $slug, $alias]); + } + echo json_encode(['success'=>true]); + break; + + default: + echo json_encode(['success'=>false,'error'=>'Unknown action']); +} diff --git a/sites/tomtomgames.com/public_html/api/login.php b/sites/tomtomgames.com/public_html/api/login.php new file mode 100644 index 0000000..e263a49 --- /dev/null +++ b/sites/tomtomgames.com/public_html/api/login.php @@ -0,0 +1,37 @@ +false,'error'=>'Server error']); + exit; +} +ob_end_clean(); +header('Content-Type: application/json'); + +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + echo json_encode(['success'=>false,'error'=>'Method not allowed']); exit; +} + +$data = json_decode(file_get_contents('php://input'), true); +$username = trim($data['username'] ?? ''); +$password = trim($data['password'] ?? ''); + +if (empty($username) || empty($password)) { + echo json_encode(['success'=>false,'error'=>'Username and password required']); exit; +} + +try { + $result = loginUser($username, $password); +} catch (Throwable $e) { + echo json_encode(['success'=>false,'error'=>'Login error. Please try again.']); exit; +} + +if ($result['success'] && isset($result['user'])) { + logPlayerAction('LOGIN_SUCCESS', $result['user']['id'], 'User logged in', 'auth', 'info'); + unset($result['user']['password']); +} +if (!$result['success']) { logSecurityEvent('LOGIN_FAILED', null, 'Failed login attempt for: ' . $username, 'warning'); } +echo json_encode($result); diff --git a/sites/tomtomgames.com/public_html/api/logout.php b/sites/tomtomgames.com/public_html/api/logout.php new file mode 100644 index 0000000..2afa501 --- /dev/null +++ b/sites/tomtomgames.com/public_html/api/logout.php @@ -0,0 +1,5 @@ + true]); diff --git a/sites/tomtomgames.com/public_html/api/me.php b/sites/tomtomgames.com/public_html/api/me.php new file mode 100644 index 0000000..8b77479 --- /dev/null +++ b/sites/tomtomgames.com/public_html/api/me.php @@ -0,0 +1,35 @@ + false, 'error' => 'Server error']); + exit; +} +ob_end_clean(); +header('Content-Type: application/json'); + +if (!isLoggedIn()) { + echo json_encode(['success' => false, 'error' => 'Not authenticated']); + exit; +} + +try { + $user = currentUser(); +} catch (Throwable $e) { + echo json_encode(['success' => false, 'error' => 'DB error']); + exit; +} + +if (!$user) { + echo json_encode(['success' => false, 'error' => 'User not found']); + exit; +} + +// Sync session is_admin with DB value — catches admin elevation/demotion +$_SESSION['is_admin'] = (int)$user['is_admin']; + +unset($user['password']); +echo json_encode(['success' => true, 'user' => $user]); diff --git a/sites/tomtomgames.com/public_html/api/my_activity.php b/sites/tomtomgames.com/public_html/api/my_activity.php new file mode 100644 index 0000000..b6268d6 --- /dev/null +++ b/sites/tomtomgames.com/public_html/api/my_activity.php @@ -0,0 +1,76 @@ +false,'error'=>'Not authenticated']); exit; } + +$userId = (int)$_SESSION['user_id']; +$action = $_GET['action'] ?? 'all'; + +// ── Purchases ────────────────────────────────────────────── +if ($action === 'all' || $action === 'purchases') { + $stmt = db()->prepare(" + SELECT id, tokens, amount_cents, payment_method, platform_id, game_alias, + card_brand, card_last4, status, admin_note, created_at + FROM token_purchases + WHERE user_id=? + ORDER BY created_at DESC + LIMIT 50 + "); + $stmt->execute([$userId]); + $purchases = $stmt->fetchAll(); +} + +// ── Cashouts ─────────────────────────────────────────────── +if ($action === 'all' || $action === 'cashouts') { + $stmt = db()->prepare(" + SELECT cr.*, + COALESCE(p.name, cr.platform_id) AS platform_name + FROM cashout_requests cr + LEFT JOIN platforms p ON cr.platform_id = p.slug + WHERE cr.user_id=? + ORDER BY cr.created_at DESC + LIMIT 50 + "); + $stmt->execute([$userId]); + $cashouts = $stmt->fetchAll(); +} + +// ── Broadcasts/Invites (use broadcasts as announcements) ─── +if ($action === 'all' || $action === 'broadcasts') { + $stmt = db()->prepare(" + SELECT b.id, b.subject, b.message, b.sent_at, + u.username AS sender, + (SELECT COUNT(*) FROM broadcast_reads WHERE broadcast_id=b.id AND user_id=?) AS is_read, + (SELECT COUNT(*) FROM broadcast_replies WHERE broadcast_id=b.id AND user_id=?) AS replied + FROM broadcasts b + JOIN users u ON b.admin_id=u.id + WHERE b.target='all' + OR (b.target='verified' AND EXISTS(SELECT 1 FROM users WHERE id=? AND email_verified=1)) + OR (b.target='unverified' AND EXISTS(SELECT 1 FROM users WHERE id=? AND email_verified=0)) + OR (b.target='admins' AND 0) + ORDER BY b.sent_at DESC + LIMIT 20 + "); + $stmt->execute([$userId,$userId,$userId,$userId]); + $broadcasts = $stmt->fetchAll(); +} + +if ($action === 'all') { + echo json_encode([ + 'success' => true, + 'purchases' => $purchases, + 'cashouts' => $cashouts, + 'broadcasts' => $broadcasts, + ]); +} elseif ($action === 'purchases') { + echo json_encode(['success'=>true,'purchases'=>$purchases]); +} elseif ($action === 'cashouts') { + echo json_encode(['success'=>true,'cashouts'=>$cashouts]); +} elseif ($action === 'broadcasts') { + echo json_encode(['success'=>true,'broadcasts'=>$broadcasts]); +} else { + echo json_encode(['success'=>false,'error'=>'Unknown action']); +} diff --git a/sites/tomtomgames.com/public_html/api/my_purchases.php b/sites/tomtomgames.com/public_html/api/my_purchases.php new file mode 100644 index 0000000..b340f8f --- /dev/null +++ b/sites/tomtomgames.com/public_html/api/my_purchases.php @@ -0,0 +1,17 @@ +false,'error'=>'Not authenticated']); exit; } + +$userId = $_SESSION['user_id']; +$stmt = db()->prepare(" + SELECT id, tokens, amount_cents, payment_method, platform_id, game_alias, + card_brand, card_last4, status, created_at + FROM token_purchases + WHERE user_id = ? + ORDER BY created_at DESC + LIMIT 20 +"); +$stmt->execute([$userId]); +echo json_encode(['success'=>true, 'purchases'=>$stmt->fetchAll()]); diff --git a/sites/tomtomgames.com/public_html/api/payment_settings.php b/sites/tomtomgames.com/public_html/api/payment_settings.php new file mode 100644 index 0000000..d4f5267 --- /dev/null +++ b/sites/tomtomgames.com/public_html/api/payment_settings.php @@ -0,0 +1,44 @@ +false,'error'=>'Server error']); exit; } +ob_end_clean(); +header('Content-Type: application/json'); + +$action = $_GET['action'] ?? 'list'; +$isAdmin = isLoggedIn() && !empty($_SESSION['is_admin']); + +switch ($action) { + + // Public: get all enabled payment methods including card status + case 'list': + // Include card row (is_enabled controls whether card appears at checkout) + $rows = db()->query("SELECT method_key, label, handle, instructions, is_enabled FROM payment_settings ORDER BY sort_order ASC, id ASC")->fetchAll(); + echo json_encode(['success'=>true, 'methods'=>$rows]); + break; + + // Admin: get all methods including disabled + case 'admin_list': + if (!$isAdmin) { echo json_encode(['success'=>false,'error'=>'Forbidden']); exit; } + $rows = db()->query("SELECT * FROM payment_settings ORDER BY sort_order ASC, id ASC")->fetchAll(); + echo json_encode(['success'=>true, 'methods'=>$rows]); + break; + + // Admin: update a single method + case 'update': + if (!$isAdmin || $_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + $d = json_decode(file_get_contents('php://input'), true); + $id = (int)($d['id'] ?? 0); + $label= substr(trim($d['label']??''), 0, 100); + $handle = substr(trim($d['handle']??''), 0, 200); + $instructions = substr(trim($d['instructions']??''), 0, 500); + $enabled = (int)(bool)($d['is_enabled'] ?? 1); + $sort = (int)($d['sort_order'] ?? 0); + if (!$id) { echo json_encode(['success'=>false,'error'=>'ID required']); exit; } + db()->prepare("UPDATE payment_settings SET label=?,handle=?,instructions=?,is_enabled=?,sort_order=? WHERE id=?") + ->execute([$label,$handle,$instructions,$enabled,$sort,$id]); + echo json_encode(['success'=>true]); + break; + + default: + echo json_encode(['success'=>false,'error'=>'Unknown action']); +} diff --git a/sites/tomtomgames.com/public_html/api/payout_methods.php b/sites/tomtomgames.com/public_html/api/payout_methods.php new file mode 100644 index 0000000..c01377d --- /dev/null +++ b/sites/tomtomgames.com/public_html/api/payout_methods.php @@ -0,0 +1,82 @@ +false,'error'=>'Server error']); exit; } +ob_end_clean(); +header('Content-Type: application/json'); + +if (!isLoggedIn()) { echo json_encode(['success'=>false,'error'=>'Not authenticated']); exit; } + +$action = $_GET['action'] ?? ''; +$userId = (int)$_SESSION['user_id']; +$isAdmin = !empty($_SESSION['is_admin']); + +switch ($action) { + + case 'list': + $uid = $isAdmin ? (int)($_GET['user_id'] ?? $userId) : $userId; + $rows = db()->prepare(" + SELECT pm.*, + COALESCE(aps.is_enabled, 0) AS admin_enabled + FROM payout_methods pm + LEFT JOIN admin_payout_settings aps ON aps.method_key = pm.method_type + WHERE pm.user_id = ? + ORDER BY pm.is_default DESC, pm.id ASC + "); + $rows->execute([$uid]); + echo json_encode(['success'=>true, 'methods'=>$rows->fetchAll()]); + break; + + case 'add': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + $d = json_decode(file_get_contents('php://input'), true); + $uid = $isAdmin && isset($d['user_id']) ? (int)$d['user_id'] : $userId; + $type = preg_replace('/[^a-z0-9_]/', '', strtolower(trim($d['method_type'] ?? ''))); + $label = substr(trim($d['label'] ?? ''), 0, 100); + $handle= substr(trim($d['account_handle'] ?? ''), 0, 200); + $def = (int)(bool)($d['is_default'] ?? 0); + if (!$type || !$label || !$handle) { echo json_encode(['success'=>false,'error'=>'All fields required']); exit; } + db()->beginTransaction(); + if ($def) db()->prepare("UPDATE payout_methods SET is_default=0 WHERE user_id=?")->execute([$uid]); + // If first method, auto-set as default + $count = db()->prepare("SELECT COUNT(*) FROM payout_methods WHERE user_id=?"); $count->execute([$uid]); + if ((int)$count->fetchColumn() === 0) $def = 1; + db()->prepare("INSERT INTO payout_methods (user_id,method_type,label,account_handle,is_default) VALUES (?,?,?,?,?)") + ->execute([$uid,$type,$label,$handle,$def]); + $newId = db()->lastInsertId(); + db()->commit(); + echo json_encode(['success'=>true,'id'=>$newId]); + break; + + case 'set_default': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + $d = json_decode(file_get_contents('php://input'), true); + $id = (int)($d['id'] ?? 0); + // Verify ownership + $chk = db()->prepare("SELECT user_id FROM payout_methods WHERE id=?"); $chk->execute([$id]); + $row = $chk->fetch(); + if (!$row || ($row['user_id'] != $userId && !$isAdmin)) { echo json_encode(['success'=>false,'error'=>'Not found']); exit; } + $uid = $row['user_id']; + db()->prepare("UPDATE payout_methods SET is_default=0 WHERE user_id=?")->execute([$uid]); + db()->prepare("UPDATE payout_methods SET is_default=1 WHERE id=?")->execute([$id]); + echo json_encode(['success'=>true]); + break; + + case 'delete': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false]); exit; } + $d = json_decode(file_get_contents('php://input'), true); + $id = (int)($d['id'] ?? 0); + $chk = db()->prepare("SELECT user_id,is_default FROM payout_methods WHERE id=?"); $chk->execute([$id]); + $row = $chk->fetch(); + if (!$row || ($row['user_id'] != $userId && !$isAdmin)) { echo json_encode(['success'=>false,'error'=>'Not found']); exit; } + db()->prepare("DELETE FROM payout_methods WHERE id=?")->execute([$id]); + // If deleted default, set next one as default + if ($row['is_default']) { + $next = db()->prepare("SELECT id FROM payout_methods WHERE user_id=? LIMIT 1"); $next->execute([$row['user_id']]); + if ($n = $next->fetch()) db()->prepare("UPDATE payout_methods SET is_default=1 WHERE id=?")->execute([$n['id']]); + } + echo json_encode(['success'=>true]); + break; + + default: + echo json_encode(['success'=>false,'error'=>'Unknown action']); +} diff --git a/sites/tomtomgames.com/public_html/api/payout_process.php b/sites/tomtomgames.com/public_html/api/payout_process.php new file mode 100644 index 0000000..53993fb --- /dev/null +++ b/sites/tomtomgames.com/public_html/api/payout_process.php @@ -0,0 +1,153 @@ +false,'error'=>'Forbidden']); exit; +} + +$adminId = (int)$_SESSION['user_id']; +$method = $_SERVER['REQUEST_METHOD']; +$action = $_GET['action'] ?? 'list_settings'; + +// ── GET actions ────────────────────────────────────────── +if ($method === 'GET') { + if ($action === 'list_settings') { + $rows = db()->query("SELECT * FROM admin_payout_settings ORDER BY sort_order ASC, id ASC")->fetchAll(); + echo json_encode(['success'=>true, 'settings'=>$rows]); + exit; + } + if ($action === 'cashout_detail') { + $id = (int)($_GET['id'] ?? 0); + $stmt = db()->prepare(" + SELECT cr.*, u.username, u.alias AS user_alias, u.email, + pm.method_type AS saved_payout_type, pm.account_handle AS saved_payout_handle, pm.label AS saved_payout_label + FROM cashout_requests cr + JOIN users u ON cr.user_id = u.id + LEFT JOIN payout_methods pm ON pm.user_id = cr.user_id AND pm.is_default = 1 + WHERE cr.id = ? + "); + $stmt->execute([$id]); + $row = $stmt->fetch(); + echo json_encode(['success'=>true, 'cashout'=>$row]); + exit; + } + echo json_encode(['success'=>false,'error'=>'Unknown action']); exit; +} + +if ($method !== 'POST') { echo json_encode(['success'=>false,'error'=>'Method not allowed']); exit; } +$d = json_decode(file_get_contents('php://input'), true); + +// ── Update payout settings ──────────────────────────────── +if ($action === 'update_setting') { + $id = (int)($d['id'] ?? 0); + $handle = substr(trim($d['handle']??''), 0, 200); + $instr = substr(trim($d['instructions']??''), 0, 500); + $enabled = (int)(bool)($d['is_enabled']??1); + $label = substr(trim($d['label']??''), 0, 100); + $sort = (int)($d['sort_order']??0); + db()->prepare("UPDATE admin_payout_settings SET label=?,handle=?,instructions=?,is_enabled=?,sort_order=? WHERE id=?") + ->execute([$label,$handle,$instr,$enabled,$sort,$id]); + echo json_encode(['success'=>true]); exit; +} + +// ── Process cashout payout ──────────────────────────────── +if ($action === 'process_payout') { + $cashoutId = (int)($d['cashout_id'] ?? 0); + $payoutKey = trim($d['payout_method_key'] ?? ''); + $payoutType = trim($d['payout_type'] ?? 'manual'); // 'manual' or 'square_gift_card' + $note = substr(trim($d['note'] ?? ''), 0, 500); + + // Load cashout + $stmt = db()->prepare("SELECT cr.*, u.username, u.alias, u.email FROM cashout_requests cr JOIN users u ON cr.user_id=u.id WHERE cr.id=? AND cr.status IN ('pending','locked')"); + $stmt->execute([$cashoutId]); + $cashout = $stmt->fetch(); + if (!$cashout) { echo json_encode(['success'=>false,'error'=>'Cashout not found or already processed']); exit; } + + // Amount in cents (1 token = $1) + $amountCents = (int)round($cashout['tokens'] * 100); + + // Load payout setting + $pset = db()->prepare("SELECT * FROM admin_payout_settings WHERE method_key=? AND is_enabled=1"); + $pset->execute([$payoutKey]); + $setting = $pset->fetch(); + + $squareTxnId = null; + $giftCardGan = null; + $giftCardBal = null; + $txnStatus = 'completed'; + + if ($payoutType === 'square_gift_card') { + // ── Square Gift Card Flow ───────────────────────── + try { + // 1. Create gift card + $gcRes = SquareClient::post('/v2/gift-cards', [ + 'idempotency_key' => 'gc-create-' . $cashoutId . '-' . time(), + 'location_id' => SQUARE_LOCATION_ID, + 'gift_card' => ['type' => 'DIGITAL'], + ]); + + if (empty($gcRes['gift_card']['id'])) { + throw new Exception('Failed to create gift card: ' . json_encode($gcRes)); + } + + $gcId = $gcRes['gift_card']['id']; + $gcGan = $gcRes['gift_card']['gan'] ?? ''; + + // 2. Activate gift card with balance + $actRes = SquareClient::post('/v2/gift-card-activities', [ + 'idempotency_key' => 'gc-activate-' . $cashoutId . '-' . time(), + 'gift_card_activity' => [ + 'type' => 'ACTIVATE', + 'location_id' => SQUARE_LOCATION_ID, + 'gift_card_id' => $gcId, + 'activate_activity_details' => [ + 'amount_money' => [ + 'amount' => $amountCents, + 'currency' => 'USD', + ], + ], + ], + ]); + + if (empty($actRes['gift_card_activity']['id'])) { + throw new Exception('Failed to activate gift card: ' . json_encode($actRes)); + } + + $giftCardGan = $gcGan; + $giftCardBal = $amountCents; + $squareTxnId = $actRes['gift_card_activity']['id']; + + } catch (Exception $e) { + echo json_encode(['success'=>false,'error'=>'Square error: ' . $e->getMessage()]); exit; + } + } + + // ── Mark cashout as sent ────────────────────────────── + db()->beginTransaction(); + try { + db()->prepare("UPDATE cashout_requests SET status='sent', admin_note=?, resolved_at=NOW() WHERE id=?") + ->execute([$note, $cashoutId]); + + db()->prepare("INSERT INTO cashout_transactions (cashout_id,admin_id,payout_method,payout_type,amount_cents,square_txn_id,gift_card_gan,gift_card_balance,note,status) + VALUES (?,?,?,?,?,?,?,?,?,'completed')") + ->execute([$cashoutId, $adminId, $payoutKey, $payoutType, $amountCents, $squareTxnId, $giftCardGan, $giftCardBal, $note]); + + db()->commit(); + } catch (Exception $e) { + db()->rollBack(); + echo json_encode(['success'=>false,'error'=>'DB error: '.$e->getMessage()]); exit; + } + + $response = ['success'=>true, 'status'=>'sent', 'amount_cents'=>$amountCents]; + if ($giftCardGan) { + $response['gift_card_gan'] = $giftCardGan; + $response['gift_card_balance'] = $giftCardBal; + } + echo json_encode($response); exit; +} + +echo json_encode(['success'=>false,'error'=>'Unknown action']); diff --git a/sites/tomtomgames.com/public_html/api/platform_accounts.php b/sites/tomtomgames.com/public_html/api/platform_accounts.php new file mode 100644 index 0000000..1df2991 --- /dev/null +++ b/sites/tomtomgames.com/public_html/api/platform_accounts.php @@ -0,0 +1,92 @@ +false,'error'=>'Not authenticated']); exit; } +$userId = (int)$_SESSION['user_id']; +$isAdmin = !empty($_SESSION['is_admin']); +$method = $_SERVER['REQUEST_METHOD']; +$action = $_GET['action'] ?? 'list'; + +if ($method === 'GET') { + if ($action === 'list') { + $uid = $isAdmin ? (int)($_GET['user_id'] ?? $userId) : $userId; + $stmt = db()->prepare("SELECT pa.*, COALESCE(p.name, pa.platform_slug) AS platform_name, p.color + FROM platform_accounts pa LEFT JOIN platforms p ON pa.platform_slug=p.slug + WHERE pa.user_id=? ORDER BY pa.requested_at DESC"); + $stmt->execute([$uid]); + $rows = $stmt->fetchAll(); + foreach ($rows as &$row) { + if (!$isAdmin && $row['status'] !== 'approved') $row['platform_password'] = null; + } + echo json_encode(['success'=>true,'accounts'=>$rows]); + } elseif ($action === 'check_onboarding') { + $cnt = db()->prepare("SELECT COUNT(*) FROM platform_accounts WHERE user_id=?"); + $cnt->execute([$userId]); + $hasAny = (int)$cnt->fetchColumn() > 0; + // Check flag — graceful fallback if column doesn't exist + $done = false; + try { + $s = db()->prepare("SELECT platform_onboarding_done FROM users WHERE id=?"); + $s->execute([$userId]); + $r = $s->fetch(); $done = !empty($r['platform_onboarding_done']); + } catch(Exception $e){} + echo json_encode(['success'=>true,'needs_onboarding'=>(!$done && !$hasAny),'has_accounts'=>$hasAny]); + } else { + echo json_encode(['success'=>false,'error'=>'Unknown action']); + } + exit; +} + +if ($method !== 'POST') { echo json_encode(['success'=>false,'error'=>'Method not allowed']); exit; } +$d = json_decode(file_get_contents('php://input'), true); + +if ($action === 'request') { + $slug = preg_replace('/[^a-z0-9_]/','',strtolower(trim($d['platform_slug']??''))); + if (!$slug) { echo json_encode(['success'=>false,'error'=>'Platform required']); exit; } + try { + db()->prepare("INSERT INTO platform_accounts (user_id,platform_slug) VALUES (?,?)")->execute([$userId,$slug]); + try { db()->prepare("UPDATE users SET platform_onboarding_done=1 WHERE id=?")->execute([$userId]); } catch(Exception $e){} + echo json_encode(['success'=>true]); + } catch(Exception $e) { echo json_encode(['success'=>false,'error'=>'Already requested for this platform']); } + exit; +} +if ($action === 'dismiss_onboarding') { + try { db()->prepare("UPDATE users SET platform_onboarding_done=1 WHERE id=?")->execute([$userId]); } catch(Exception $e){} + echo json_encode(['success'=>true]); + exit; +} +if (!$isAdmin) { echo json_encode(['success'=>false,'error'=>'Forbidden']); exit; } +if ($action === 'resolve') { + $id=$d['id']??0; $status=$d['status']??''; + $uname=substr(trim($d['platform_username']??''),0,100); + $pass=substr(trim($d['platform_password']??''),0,200); + $note=substr(trim($d['admin_note']??''),0,300); + if (!in_array($status,['approved','denied','deleted'])){echo json_encode(['success'=>false,'error'=>'Invalid status']);exit;} + $chk=db()->prepare("SELECT user_id,platform_slug FROM platform_accounts WHERE id=?");$chk->execute([$id]);$row=$chk->fetch(); + if (!$row){echo json_encode(['success'=>false,'error'=>'Not found']);exit;} + db()->prepare("UPDATE platform_accounts SET status=?,platform_username=?,platform_password=?,admin_note=?,resolved_at=NOW(),admin_id=? WHERE id=?") + ->execute([$status,$uname,$pass,$note,(int)$_SESSION['user_id'],$id]); + if ($status==='approved'&&$uname) { + db()->prepare("INSERT INTO game_aliases (user_id,platform_slug,alias) VALUES (?,?,?) ON DUPLICATE KEY UPDATE alias=VALUES(alias)") + ->execute([$row['user_id'],$row['platform_slug'],$uname]); + } + echo json_encode(['success'=>true]);exit; +} +if ($action === 'update_credentials') { + $id=$d['id']??0; + $uname=substr(trim($d['platform_username']??''),0,100); + $pass=substr(trim($d['platform_password']??''),0,200); + $note=substr(trim($d['admin_note']??''),0,300); + $chk=db()->prepare("SELECT user_id,platform_slug FROM platform_accounts WHERE id=?");$chk->execute([$id]);$row=$chk->fetch(); + if (!$row){echo json_encode(['success'=>false,'error'=>'Not found']);exit;} + db()->prepare("UPDATE platform_accounts SET platform_username=?,platform_password=?,admin_note=? WHERE id=?") + ->execute([$uname,$pass,$note,$id]); + if ($uname) { + db()->prepare("INSERT INTO game_aliases (user_id,platform_slug,alias) VALUES (?,?,?) ON DUPLICATE KEY UPDATE alias=VALUES(alias)") + ->execute([$row['user_id'],$row['platform_slug'],$uname]); + } + echo json_encode(['success'=>true]);exit; +} +echo json_encode(['success'=>false,'error'=>'Unknown action']); diff --git a/sites/tomtomgames.com/public_html/api/platforms.php b/sites/tomtomgames.com/public_html/api/platforms.php new file mode 100644 index 0000000..dbeddc5 --- /dev/null +++ b/sites/tomtomgames.com/public_html/api/platforms.php @@ -0,0 +1,185 @@ +false,'error'=>'Server error']); exit; } +ob_end_clean(); +header('Content-Type: application/json'); + +$action = $_GET['action'] ?? 'list'; +$isAdmin = isLoggedIn() && !empty($_SESSION['is_admin']); +$isMasterAdmin = $isAdmin && (int)($_SESSION['user_id'] ?? 0) === MASTER_ADMIN_ID; + +switch ($action) { + + // ── Public: active platforms for player app ─────────── + case 'list': + $stmt = db()->query("SELECT slug,name,player_url,url_alias_param,color,icon_path FROM platforms WHERE is_active=1 AND is_deleted=0 ORDER BY sort_order ASC, id ASC"); + $rows = $stmt->fetchAll(); + $out = array_map(fn($r) => [ + 'id' => $r['slug'], + 'name' => $r['name'], + 'url' => $r['player_url'], + 'alias_param' => $r['url_alias_param'] ?? '', + 'color' => $r['color'], + ], $rows); + echo json_encode(['success'=>true, 'platforms'=>$out]); + break; + + // ── Admin: full list including agent fields and inactive ─ + case 'admin_list': + if (!$isAdmin) { echo json_encode(['success'=>false,'error'=>'Forbidden']); exit; } + $rows = db()->query("SELECT * FROM platforms WHERE is_deleted=0 ORDER BY sort_order ASC, id ASC")->fetchAll(); + echo json_encode(['success'=>true, 'platforms'=>$rows]); + break; + + // ── Admin: create platform ──────────────────────────── + case 'create': + if (!$isAdmin || $_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false,'error'=>'Forbidden']); exit; } + $d = json_decode(file_get_contents('php://input'), true); + $slug = preg_replace('/[^a-z0-9_]/', '', strtolower(trim($d['slug'] ?? ''))); + $name = substr(trim($d['name'] ?? ''), 0, 100); + $player_url = substr(trim($d['player_url'] ?? ''), 0, 500); + $url_alias_param = preg_replace('/[^a-zA-Z0-9_\-]/', '', trim($d['url_alias_param'] ?? '')); + $agent_link = substr(trim($d['agent_link'] ?? ''), 0, 500); + $agent_login = substr(trim($d['agent_login'] ?? ''), 0, 200); + $agent_password = substr(trim($d['agent_password'] ?? ''), 0, 200); + $games_link = substr(trim($d['games_link'] ?? ''), 0, 500); + $agent_guide = trim($d['agent_guide'] ?? ''); + $sub_agent_login = substr(trim($d['sub_agent_login'] ?? ''), 0, 200); + $sub_agent_password = substr(trim($d['sub_agent_password'] ?? ''), 0, 200); + $cashier_login = substr(trim($d['cashier_login'] ?? ''), 0, 200); + $cashier_password = substr(trim($d['cashier_password'] ?? ''), 0, 200); + $color = preg_match('/^#[0-9a-fA-F]{3,8}$/', $d['color'] ?? '') ? $d['color'] : '#f0c040'; + $sort_order = (int)($d['sort_order'] ?? 99); + $is_active = isset($d['is_active']) ? (int)(bool)$d['is_active'] : 1; + if (!$slug || !$name || !$player_url) { echo json_encode(['success'=>false,'error'=>'Slug, name, and player URL are required']); exit; } + try { + $stmt = db()->prepare("INSERT INTO platforms (slug,name,player_url,url_alias_param,agent_link,agent_login,agent_password,games_link,agent_guide,sub_agent_login,sub_agent_password,cashier_login,cashier_password,color,sort_order,is_active) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"); + $stmt->execute([$slug,$name,$player_url,$url_alias_param,$agent_link,$agent_login,$agent_password,$games_link,$agent_guide,$sub_agent_login,$sub_agent_password,$cashier_login,$cashier_password,$color,$sort_order,$is_active]); + echo json_encode(['success'=>true,'id'=>db()->lastInsertId()]); + } catch (Exception $e) { + echo json_encode(['success'=>false,'error'=>'Slug already exists or DB error']); + } + break; + + // ── Admin: update platform ──────────────────────────── + case 'update': + if (!$isAdmin || $_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false,'error'=>'Forbidden']); exit; } + $d = json_decode(file_get_contents('php://input'), true); + $id = (int)($d['id'] ?? 0); + $name = substr(trim($d['name'] ?? ''), 0, 100); + $player_url = substr(trim($d['player_url'] ?? ''), 0, 500); + $url_alias_param = preg_replace('/[^a-zA-Z0-9_\-]/', '', trim($d['url_alias_param'] ?? '')); + $agent_link = substr(trim($d['agent_link'] ?? ''), 0, 500); + $agent_login = substr(trim($d['agent_login'] ?? ''), 0, 200); + $agent_password = substr(trim($d['agent_password'] ?? ''), 0, 200); + $games_link = substr(trim($d['games_link'] ?? ''), 0, 500); + $agent_guide = trim($d['agent_guide'] ?? ''); + $sub_agent_login = substr(trim($d['sub_agent_login'] ?? ''), 0, 200); + $sub_agent_password = substr(trim($d['sub_agent_password'] ?? ''), 0, 200); + $cashier_login = substr(trim($d['cashier_login'] ?? ''), 0, 200); + $cashier_password = substr(trim($d['cashier_password'] ?? ''), 0, 200); + $color = preg_match('/^#[0-9a-fA-F]{3,8}$/', $d['color'] ?? '') ? $d['color'] : '#f0c040'; + $sort_order = (int)($d['sort_order'] ?? 99); + $is_active = (int)(bool)($d['is_active'] ?? 1); + if (!$id || !$name || !$player_url) { echo json_encode(['success'=>false,'error'=>'ID, name, and player URL required']); exit; } + if ($isMasterAdmin) { + // Master admin: update all fields including agent info + db()->prepare("UPDATE platforms SET name=?,player_url=?,url_alias_param=?,agent_link=?,agent_login=?,agent_password=?,games_link=?,agent_guide=?,sub_agent_login=?,sub_agent_password=?,cashier_login=?,cashier_password=?,color=?,sort_order=?,is_active=? WHERE id=?") + ->execute([$name,$player_url,$url_alias_param,$agent_link,$agent_login,$agent_password,$games_link,$agent_guide,$sub_agent_login,$sub_agent_password,$cashier_login,$cashier_password,$color,$sort_order,$is_active,$id]); + } else { + // Regular admin: update non-sensitive fields including alias param + db()->prepare("UPDATE platforms SET name=?,player_url=?,url_alias_param=?,color=?,sort_order=?,is_active=? WHERE id=?") + ->execute([$name,$player_url,$url_alias_param,$color,$sort_order,$is_active,$id]); + } + echo json_encode(['success'=>true]); + break; + + // ── Admin: delete platform ──────────────────────────── + case 'delete': + if (!$isAdmin || $_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false,'error'=>'Forbidden']); exit; } + $d = json_decode(file_get_contents('php://input'), true); + $id = (int)($d['id'] ?? 0); + if (!$id) { echo json_encode(['success'=>false,'error'=>'ID required']); exit; } + db()->prepare("DELETE FROM platforms WHERE id=?")->execute([$id]); + echo json_encode(['success'=>true]); + break; + + // ── Admin: reorder platforms ────────────────────────── + case 'reorder': + if (!$isAdmin || $_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false,'error'=>'Forbidden']); exit; } + $d = json_decode(file_get_contents('php://input'), true); + $order = $d['order'] ?? []; // array of IDs in desired order + $stmt = db()->prepare("UPDATE platforms SET sort_order=? WHERE id=?"); + foreach ($order as $i => $pid) { $stmt->execute([$i, (int)$pid]); } + echo json_encode(['success'=>true]); + break; + + // ── Admin: list credits for a platform ─────────────── + case 'credits_list': + if (!$isAdmin) { echo json_encode(['success'=>false,'error'=>'Forbidden']); exit; } + $pid = (int)($_GET['platform_id'] ?? 0); + if (!$pid) { echo json_encode(['success'=>false,'error'=>'platform_id required']); exit; } + $rows = db()->prepare("SELECT * FROM platform_credits WHERE platform_id=? ORDER BY credit_date DESC, id DESC"); + $rows->execute([$pid]); + $credits = $rows->fetchAll(); + $total = db()->prepare("SELECT COALESCE(SUM(CASE WHEN type='debit' THEN -credits_purchased ELSE credits_purchased END),0) FROM platform_credits WHERE platform_id=?"); + $total->execute([$pid]); + echo json_encode(['success'=>true,'credits'=>$credits,'total'=>(float)$total->fetchColumn()]); + break; + + // ── Admin: add credit entry ─────────────────────────── + case 'credits_create': + if (!$isMasterAdmin || $_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false,'error'=>'Forbidden']); exit; } + $d = json_decode(file_get_contents('php://input'), true); + $pid = (int)($d['platform_id'] ?? 0); + $credits = (float)($d['credits_purchased'] ?? 0); + $date = $d['credit_date'] ?? date('Y-m-d'); + $method = substr(trim($d['payment_method'] ?? ''), 0, 100); + $notes = trim($d['notes'] ?? ''); + if (!$pid || $credits <= 0 || !$date) { echo json_encode(['success'=>false,'error'=>'platform_id, credits_purchased, and credit_date are required']); exit; } + $stmt = db()->prepare("INSERT INTO platform_credits (platform_id,credits_purchased,credit_date,payment_method,notes) VALUES (?,?,?,?,?)"); + $stmt->execute([$pid,$credits,$date,$method,$notes]); + $newId = db()->lastInsertId(); + $total = db()->prepare("SELECT COALESCE(SUM(credits_purchased),0) FROM platform_credits WHERE platform_id=?"); + $total->execute([$pid]); + echo json_encode(['success'=>true,'id'=>$newId,'total'=>(float)$total->fetchColumn()]); + break; + + // ── Admin: update credit entry ──────────────────────── + case 'credits_update': + if (!$isMasterAdmin || $_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false,'error'=>'Forbidden']); exit; } + $d = json_decode(file_get_contents('php://input'), true); + $id = (int)($d['id'] ?? 0); + $credits = (float)($d['credits_purchased'] ?? 0); + $date = $d['credit_date'] ?? date('Y-m-d'); + $method = substr(trim($d['payment_method'] ?? ''), 0, 100); + $notes = trim($d['notes'] ?? ''); + if (!$id || $credits <= 0 || !$date) { echo json_encode(['success'=>false,'error'=>'id, credits_purchased, and credit_date are required']); exit; } + db()->prepare("UPDATE platform_credits SET credits_purchased=?,credit_date=?,payment_method=?,notes=? WHERE id=?") + ->execute([$credits,$date,$method,$notes,$id]); + $row = db()->prepare("SELECT platform_id FROM platform_credits WHERE id=?"); + $row->execute([$id]); + $pid = (int)($row->fetchColumn() ?: 0); + $total = db()->prepare("SELECT COALESCE(SUM(credits_purchased),0) FROM platform_credits WHERE platform_id=?"); + $total->execute([$pid]); + echo json_encode(['success'=>true,'total'=>(float)$total->fetchColumn()]); + break; + + // ── Admin: delete credit entry ──────────────────────── + case 'credits_delete': + if (!$isMasterAdmin || $_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false,'error'=>'Forbidden']); exit; } + $d = json_decode(file_get_contents('php://input'), true); + $id = (int)($d['id'] ?? 0); + if (!$id) { echo json_encode(['success'=>false,'error'=>'ID required']); exit; } + $row = db()->prepare("SELECT platform_id FROM platform_credits WHERE id=?"); + $row->execute([$id]); + $pid = (int)($row->fetchColumn() ?: 0); + db()->prepare("DELETE FROM platform_credits WHERE id=?")->execute([$id]); + $total = db()->prepare("SELECT COALESCE(SUM(credits_purchased),0) FROM platform_credits WHERE platform_id=?"); + $total->execute([$pid]); + echo json_encode(['success'=>true,'total'=>(float)$total->fetchColumn()]); + break; + + default: + echo json_encode(['success'=>false,'error'=>'Unknown action']); +} diff --git a/sites/tomtomgames.com/public_html/api/purchase.php b/sites/tomtomgames.com/public_html/api/purchase.php new file mode 100644 index 0000000..f3accf3 --- /dev/null +++ b/sites/tomtomgames.com/public_html/api/purchase.php @@ -0,0 +1,163 @@ +false,'error'=>'Not authenticated']); exit; } +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false,'error'=>'Method not allowed']); exit; } + +$data = json_decode(file_get_contents('php://input'), true); +$sourceId = trim($data['source_id'] ?? ''); +$tokens = (int)($data['tokens'] ?? 0); +$priceCents = (int)($data['price_cents'] ?? 0); +$method = trim($data['method'] ?? 'card'); +$platformId = trim($data['platform_id'] ?? ''); +$gameAlias = trim($data['game_alias'] ?? ''); +$playerName = trim($data['player_name'] ?? ''); +$isCustom = (bool)($data['is_custom'] ?? false); +$billing = $data['billing'] ?? []; +$userId = $_SESSION['user_id']; + +// ─── Validate ───────────────────────────────────────────── +if ($tokens < 1 || $priceCents < 100) { + echo json_encode(['success'=>false,'error'=>'Minimum purchase is $1 (1 token).']); exit; +} + +// Validate preset packages (custom bypasses preset check) +if (!$isCustom) { + $packages = json_decode(TOKEN_PACKAGES, true); + $validPkg = false; + foreach ($packages as $pkg) { + if ($pkg['tokens'] === $tokens && ($pkg['price'] * 100) === $priceCents) { $validPkg = true; break; } + } + if (!$validPkg) { + echo json_encode(['success'=>false,'error'=>'Invalid token package.']); exit; + } +} else { + // Custom: tokens must equal dollars (1:1 ratio), cap at $500 + if ($tokens !== ($priceCents / 100) || $tokens > 500) { + echo json_encode(['success'=>false,'error'=>'Invalid custom amount.']); exit; + } +} + +// Sanitize billing fields +$billingFirst = substr(trim($billing['first_name'] ?? ''), 0, 80); +$billingLast = substr(trim($billing['last_name'] ?? ''), 0, 80); +$billingAddress = substr(trim($billing['address'] ?? ''), 0, 200); +$billingCity = substr(trim($billing['city'] ?? ''), 0, 80); +$billingState = strtoupper(substr(trim($billing['state'] ?? ''), 0, 2)); +$billingZip = substr(trim($billing['zip'] ?? ''), 0, 10); +$billingEmail = substr(strtolower(trim($billing['email'] ?? '')), 0, 150); +$cardholderName = trim("$billingFirst $billingLast"); + +$isManual = in_array($method, ['venmo','chime','cashapp','zelle']); + +// ─── Manual payment ──────────────────────────────────────── +if ($isManual) { + $stmt = db()->prepare(" + INSERT INTO token_purchases + (user_id, tokens, amount_cents, payment_method, platform_id, game_alias, + player_name, billing_name, billing_email, is_custom, status) + VALUES (?,?,?,?,?,?,?,?,?,?,'pending') + "); + $stmt->execute([ + $userId, $tokens, $priceCents, $method, $platformId, $gameAlias, + $playerName, $cardholderName, $billingEmail, $isCustom ? 1 : 0 + ]); + $pid = db()->lastInsertId(); + logActivity('manual_payment_pending', $userId, null, 'purchase', 0, "Manual payment pending: {$method} \$" . number_format($priceCents / 100, 2)); +echo json_encode(['success'=>true,'manual'=>true,'purchase_id'=>$pid, + 'message'=>"Request #{$pid} submitted! Tokens credited after payment verification."]); + exit; +} + +// ─── Card payment via Square ─────────────────────────────── +if (empty($sourceId)) { echo json_encode(['success'=>false,'error'=>'Card payment token required.']); exit; } + +$square = new SquarePayment(); +$note = "TomGames {$tokens}tok | {$platformId} | {$gameAlias} | user#{$userId}" . ($isCustom ? ' [CUSTOM]' : ''); + +// Build buyer info for Square +$buyerInfo = []; +if ($cardholderName) $buyerInfo['buyer_email_address'] = $billingEmail ?: null; +$billingAddr = []; +if ($billingAddress) $billingAddr['address_line_1'] = $billingAddress; +if ($billingCity) $billingAddr['locality'] = $billingCity; +if ($billingState) $billingAddr['administrative_district_level_1'] = $billingState; +if ($billingZip) $billingAddr['postal_code'] = $billingZip; +$billingAddr['country'] = 'US'; + +$result = $square->charge($sourceId, $priceCents, $note, $cardholderName, $billingAddr, $billingEmail); + +if (!$result['success']) { + // Log failed attempt + db()->prepare("INSERT INTO token_purchases (user_id,tokens,amount_cents,payment_method,platform_id,game_alias,player_name,billing_name,billing_email,is_custom,status,failure_reason) VALUES (?,?,?,'card',?,?,?,?,?,?,'failed',?)") + ->execute([$userId,$tokens,$priceCents,$platformId,$gameAlias,$playerName,$cardholderName,$billingEmail,$isCustom?1:0,$result['error']]); + echo json_encode(['success'=>false,'error'=>$result['error']]); exit; +} + +// ─── Credit tokens ───────────────────────────────────────── +db()->beginTransaction(); +try { + db()->prepare("UPDATE users SET tokens=tokens+? WHERE id=?")->execute([$tokens,$userId]); + $cardBrand = $result['card_brand'] ?? null; + $cardLast4 = $result['last_4'] ?? null; + $receiptUrl= $result['receipt_url'] ?? null; + + db()->prepare(" + INSERT INTO token_purchases + (user_id, tokens, amount_cents, payment_method, square_payment_id, + platform_id, game_alias, player_name, billing_name, billing_address, + billing_city, billing_state, billing_zip, billing_email, + is_custom, card_brand, card_last4, receipt_url, status) + VALUES (?,?,?,'card',?,?,?,?,?,?,?,?,?,?,?,?,?,?,'completed') + ")->execute([ + $userId, $tokens, $priceCents, $result['payment_id'], + $platformId, $gameAlias, $playerName, + $cardholderName, $billingAddress, $billingCity, $billingState, $billingZip, $billingEmail, + $isCustom ? 1 : 0, $cardBrand, $cardLast4, $receiptUrl + ]); + + db()->commit(); +} catch (Exception $e) { + db()->rollBack(); + echo json_encode(['success'=>false,'error'=>'Token credit failed. Payment ID: '.$result['payment_id']]); exit; +} + +// ─── Update saved billing (separate try — must NOT roll back token credit) ── +try { + db()->prepare(" + INSERT INTO saved_billing (user_id,first_name,last_name,email,address,city,state,zip,card_brand,card_last4) + VALUES (?,?,?,?,?,?,?,?,?,?) + ON DUPLICATE KEY UPDATE + card_brand=VALUES(card_brand), card_last4=VALUES(card_last4), + first_name=COALESCE(NULLIF(VALUES(first_name),''),first_name), + last_name=COALESCE(NULLIF(VALUES(last_name),''),last_name), + email=COALESCE(NULLIF(VALUES(email),''),email), + address=COALESCE(NULLIF(VALUES(address),''),address), + city=COALESCE(NULLIF(VALUES(city),''),city), + state=COALESCE(NULLIF(VALUES(state),''),state), + zip=COALESCE(NULLIF(VALUES(zip),''),zip) + ")->execute([ + $userId, $billingFirst, $billingLast, $billingEmail, + $billingAddress, $billingCity, $billingState, $billingZip, + $cardBrand, $cardLast4 + ]); +} catch (Exception $e) { /* non-critical — tokens already credited */ } + +$bal = db()->prepare("SELECT tokens FROM users WHERE id=?"); +$bal->execute([$userId]); +$newBal = (float)$bal->fetchColumn(); +logActivity('token_purchase', $userId, null, 'purchase', (int)db()->lastInsertId(), + "Bought {$tokens} tokens via {$method} for \$" . number_format($priceCents / 100, 2)); +echo json_encode([ + 'success' => true, + 'manual' => false, + 'tokens_added' => (int)$tokens, + 'new_balance' => $newBal, + 'payment_id' => $result['payment_id'], + 'card_brand' => $cardBrand, + 'card_last4' => $cardLast4, + 'receipt_url' => $receiptUrl, +]); diff --git a/sites/tomtomgames.com/public_html/api/referrals.php b/sites/tomtomgames.com/public_html/api/referrals.php new file mode 100644 index 0000000..b0144c1 --- /dev/null +++ b/sites/tomtomgames.com/public_html/api/referrals.php @@ -0,0 +1,362 @@ +false,'error'=>'Not authenticated']); exit; } + +$userId = (int)$_SESSION['user_id']; +$isAdmin = !empty($_SESSION['is_admin']); +$method = $_SERVER['REQUEST_METHOD']; +$action = $_GET['action'] ?? 'status'; + +// ── GET actions ─────────────────────────────────────────── +if ($method === 'GET') { + + if ($action === 'status') { + // Player's referral dashboard data + $user = db()->prepare("SELECT referral_code, referred_by FROM users WHERE id=?"); + $user->execute([$userId]); + $u = $user->fetch(); + + // Auto-generate code if missing + if (empty($u['referral_code'])) { + $code = strtoupper(substr(md5($userId.uniqid()),0,8)); + db()->prepare("UPDATE users SET referral_code=? WHERE id=?")->execute([$code, $userId]); + $u['referral_code'] = $code; + } + + // Count verified referrals + $countStmt = db()->prepare("SELECT COUNT(*) FROM referrals WHERE referrer_id=? AND status='verified'"); + $countStmt->execute([$userId]); + $verified = (int)$countStmt->fetchColumn(); + + // All referrals + $refs = db()->prepare(" + SELECT r.*, u.username, u.alias, u.email_verified, u.created_at AS joined_at + FROM referrals r + JOIN users u ON r.referred_id = u.id + WHERE r.referrer_id = ? + ORDER BY r.created_at DESC + "); + $refs->execute([$userId]); + + // Current tier + $tier = db()->query(" + SELECT * FROM referral_tiers + WHERE is_active=1 AND min_referrals <= $verified + ORDER BY min_referrals DESC LIMIT 1 + ")->fetch(); + + // Next tier + $nextTier = db()->query(" + SELECT * FROM referral_tiers + WHERE is_active=1 AND min_referrals > $verified + ORDER BY min_referrals ASC LIMIT 1 + ")->fetch(); + + // Total tokens earned from referrals + $earned = db()->prepare("SELECT COALESCE(SUM(tokens_awarded),0) FROM referrals WHERE referrer_id=? AND status='verified'"); + $earned->execute([$userId]); + + // Social shares + $shares = db()->prepare("SELECT * FROM referral_social_shares WHERE user_id=? ORDER BY created_at DESC"); + $shares->execute([$userId]); + + echo json_encode([ + 'success' => true, + 'referral_code' => $u['referral_code'] ?? '', + 'referral_url' => (defined('SITE_URL')?SITE_URL:'https://tomtomgames.com') . '/?ref=' . ($u['referral_code'] ?? ''), + 'verified_count' => $verified, + 'total_earned' => (float)$earned->fetchColumn(), + 'current_tier' => $tier, + 'next_tier' => $nextTier, + 'referrals' => $refs->fetchAll(), + 'social_shares' => $shares->fetchAll(), + ]); + exit; + } + + if ($action === 'tiers') { + $rows = db()->query("SELECT * FROM referral_tiers WHERE is_active=1 ORDER BY min_referrals ASC")->fetchAll(); + echo json_encode(['success'=>true,'tiers'=>$rows]); + exit; + } + + if ($action === 'all_tiers' && $isAdmin) { + $rows = db()->query("SELECT * FROM referral_tiers ORDER BY sort_order ASC, min_referrals ASC")->fetchAll(); + echo json_encode(['success'=>true,'tiers'=>$rows]); + exit; + } + + if ($action === 'admin_list' && $isAdmin) { + $status = $_GET['status'] ?? 'pending'; + $stmt = db()->prepare(" + SELECT r.*, + ru.username AS referrer_name, ru.alias AS referrer_alias, + rd.username AS referred_name, rd.alias AS referred_alias, rd.email_verified, + t.name AS tier_name + FROM referrals r + JOIN users ru ON r.referrer_id = ru.id + JOIN users rd ON r.referred_id = rd.id + LEFT JOIN referral_tiers t ON r.tier_id = t.id + WHERE r.status = ? + ORDER BY r.created_at DESC + LIMIT 100 + "); + $stmt->execute([$status]); + echo json_encode(['success'=>true,'referrals'=>$stmt->fetchAll()]); + exit; + } + + if ($action === 'admin_shares' && $isAdmin) { + $status = $_GET['status'] ?? 'pending'; + $stmt = db()->prepare(" + SELECT rs.*, u.username, u.alias + FROM referral_social_shares rs + JOIN users u ON rs.user_id = u.id + WHERE rs.status = ? + ORDER BY rs.created_at DESC + "); + $stmt->execute([$status]); + echo json_encode(['success'=>true,'shares'=>$stmt->fetchAll()]); + exit; + } + + echo json_encode(['success'=>false,'error'=>'Unknown action']); exit; +} + +// ── Helpers ─────────────────────────────────────────────── + +function inferPlatformFromUrl(string $url): string { + $host = strtolower(parse_url($url, PHP_URL_HOST) ?? ''); + if (str_contains($host, 'reddit.com')) return 'reddit'; + if (str_contains($host, 'twitter.com') || str_contains($host, 'x.com')) return 'twitter'; + if (str_contains($host, 'facebook.com')) return 'facebook'; + if (str_contains($host, 'instagram.com')) return 'instagram'; + if (str_contains($host, 'tiktok.com')) return 'tiktok'; + if (str_contains($host, 'youtube.com')) return 'youtube'; + if (str_contains($host, 'discord.com')) return 'discord'; + if (str_contains($host, 'twitch.tv')) return 'twitch'; + return 'other'; +} + +function scrapeForReferralCode(string $url, string $code): array { + // SSRF guard — only http/https, no private/reserved IPs + $parsed = parse_url($url); + if (!$parsed || !in_array(strtolower($parsed['scheme'] ?? ''), ['http','https'])) { + return ['verified'=>false,'reason'=>'invalid_url']; + } + $host = $parsed['host'] ?? ''; + $ip = gethostbyname($host); + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) { + return ['verified'=>false,'reason'=>'invalid_url']; + } + + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 8, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_MAXREDIRS => 3, + CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_HTTPHEADER => ['Accept: text/html,application/xhtml+xml,*/*;q=0.8'], + CURLOPT_BUFFERSIZE => 131072, // read up to 128KB + ]); + $html = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlErr = curl_error($ch); + curl_close($ch); + + if ($curlErr || !$html) return ['verified'=>false,'reason'=>'unreachable']; + if ($httpCode === 401 || $httpCode === 403) return ['verified'=>false,'reason'=>'login_required']; + if ($httpCode >= 400) return ['verified'=>false,'reason'=>'unreachable']; + + // Truncate to 512KB to avoid scanning huge pages + $content = substr($html, 0, 524288); + + if (stripos($content, $code) !== false) return ['verified'=>true, 'reason'=>'code_found']; + if (stripos($content, 'tomtomgames.com') !== false) return ['verified'=>false,'reason'=>'site_found_no_code']; + return ['verified'=>false,'reason'=>'not_found']; +} + +// ── POST actions ────────────────────────────────────────── +if ($method !== 'POST') { echo json_encode(['success'=>false,'error'=>'Method not allowed']); exit; } +$d = json_decode(file_get_contents('php://input'), true); + +if ($action === 'submit_share') { + $url = trim($d['url'] ?? ''); + $platform = preg_replace('/[^a-z0-9_]/', '', strtolower(trim($d['platform'] ?? ''))); + + if (!$url) { echo json_encode(['success'=>false,'error'=>'Please paste the URL of your post']); exit; } + if (!filter_var($url, FILTER_VALIDATE_URL)) { echo json_encode(['success'=>false,'error'=>'That doesn\'t look like a valid URL']); exit; } + + if (!$platform) $platform = inferPlatformFromUrl($url); + + // Get referrer's code for scraping + $codeRow = db()->prepare("SELECT referral_code FROM users WHERE id=?"); + $codeRow->execute([$userId]); + $referralCode = (string)$codeRow->fetchColumn(); + + // Reject duplicate URLs from same user + $dup = db()->prepare("SELECT id FROM referral_social_shares WHERE user_id=? AND share_url=?"); + $dup->execute([$userId, $url]); + if ($dup->fetchColumn()) { echo json_encode(['success'=>false,'error'=>'You already submitted this URL']); exit; } + + $bonus = 5; + $scrape = scrapeForReferralCode($url, $referralCode); + $status = $scrape['verified'] ? 'approved' : 'pending'; + + $reasonMessages = [ + 'code_found' => null, // auto-verified, no extra message needed + 'login_required' => 'This post requires a login to view — our team will review it manually.', + 'unreachable' => "We couldn't reach that URL — our team will review it manually.", + 'not_found' => "Your referral code wasn't found on that page — our team will review it manually.", + 'site_found_no_code'=> 'TomTomGames was mentioned but your referral code wasn\'t found — our team will review it manually.', + 'invalid_url' => 'That URL doesn\'t look valid — please check and try again.', + ]; + + if ($scrape['reason'] === 'invalid_url') { + echo json_encode(['success'=>false,'error'=>$reasonMessages['invalid_url']]); + exit; + } + + try { + db()->beginTransaction(); + db()->prepare("INSERT INTO referral_social_shares (user_id,platform,bonus_tokens,share_url,auto_verified,verify_result,status) VALUES (?,?,?,?,?,?,?)") + ->execute([$userId, $platform, $bonus, $url, $scrape['verified']?1:0, $scrape['reason'], $status]); + $shareId = (int)db()->lastInsertId(); + + if ($scrape['verified']) { + db()->prepare("UPDATE users SET tokens=tokens+? WHERE id=?")->execute([$bonus, $userId]); + logAdminAction('SOCIAL_SHARE_AUTO_VERIFIED', (int)$_SESSION['user_id'], 'referral_share', $shareId, + 'Auto-verified share on '.$platform.' — awarded '.$bonus.' tokens. URL: '.$url, '', 'approved', 'info'); + } + db()->commit(); + + $resp = ['success'=>true,'auto_verified'=>$scrape['verified'],'platform'=>$platform]; + if ($scrape['verified']) { + $resp['tokens'] = $bonus; + $resp['message'] = '🎉 Verified! +'.$bonus.' tokens awarded automatically.'; + } else { + $resp['pending'] = true; + $resp['message'] = $reasonMessages[$scrape['reason']] ?? 'Submitted for manual review.'; + } + echo json_encode($resp); + } catch(Exception $e) { + db()->rollBack(); + echo json_encode(['success'=>false,'error'=>'Failed to submit share']); + } + exit; +} + +// ── Admin only below ────────────────────────────────────── +if (!$isAdmin) { echo json_encode(['success'=>false,'error'=>'Forbidden']); exit; } + +if ($action === 'resolve_referral') { + $id = (int)($d['id'] ?? 0); + $status = $d['status'] ?? ''; + $note = substr(trim($d['note'] ?? ''), 0, 300); + if (!in_array($status, ['verified','denied','deleted'])) { echo json_encode(['success'=>false,'error'=>'Invalid status']); exit; } + + $chk = db()->prepare("SELECT r.*, t.tokens_per_ref, t.min_referrals, t.bonus_tokens FROM referrals r LEFT JOIN referral_tiers t ON r.tier_id=t.id WHERE r.id=?"); + $chk->execute([$id]); + $ref = $chk->fetch(); + if (!$ref) { echo json_encode(['success'=>false,'error'=>'Not found']); exit; } + + if ($status === 'verified') { + // Determine best tier for referrer + $countStmt = db()->prepare("SELECT COUNT(*) FROM referrals WHERE referrer_id=? AND status='verified'"); + $countStmt->execute([$ref['referrer_id']]); + $verifiedCount = (int)$countStmt->fetchColumn() + 1; // +1 for this one + + $tier = db()->query("SELECT * FROM referral_tiers WHERE is_active=1 AND min_referrals <= $verifiedCount ORDER BY min_referrals DESC LIMIT 1")->fetch(); + $tokensToAward = $tier ? (float)$tier['tokens_per_ref'] : 5; + + // Check if this hits a bonus milestone + $bonusTokens = 0; + if ($tier && $verifiedCount == (int)$tier['min_referrals']) { + $bonusTokens = (float)$tier['bonus_tokens']; + } + + $totalAward = $tokensToAward + $bonusTokens; + + db()->beginTransaction(); + try { + db()->prepare("UPDATE referrals SET status='verified', tier_id=?, tokens_awarded=?, admin_id=?, admin_note=?, resolved_at=NOW() WHERE id=?") + ->execute([$tier['id'] ?? null, $totalAward, (int)$_SESSION['user_id'], $note, $id]); + db()->prepare("UPDATE users SET tokens=tokens+? WHERE id=?")->execute([$totalAward, $ref['referrer_id']]); + logAdminAction('REFERRAL_VERIFIED', (int)$_SESSION['user_id'], 'referral', $id, 'Verified referral #'.$id.' — awarded '.$totalAward.' tokens to user #'.$ref['referrer_id'], 'pending', 'verified', 'info'); + db()->commit(); + echo json_encode(['success'=>true,'tokens_awarded'=>$totalAward,'bonus'=>$bonusTokens,'tier'=>$tier['name']??'']); + } catch(Exception $e) { + db()->rollBack(); + echo json_encode(['success'=>false,'error'=>'DB error']); + } + } else { + db()->prepare("UPDATE referrals SET status=?, admin_id=?, admin_note=?, resolved_at=NOW() WHERE id=?") + ->execute([$status, (int)$_SESSION['user_id'], $note, $id]); + echo json_encode(['success'=>true]); + } + exit; +} + +if ($action === 'resolve_share') { + $id = (int)($d['id'] ?? 0); + $status = $d['status'] ?? ''; + if (!in_array($status, ['approved','denied'])) { echo json_encode(['success'=>false,'error'=>'Invalid']); exit; } + $chk = db()->prepare("SELECT * FROM referral_social_shares WHERE id=?"); $chk->execute([$id]); $share = $chk->fetch(); + if (!$share) { echo json_encode(['success'=>false,'error'=>'Not found']); exit; } + db()->prepare("UPDATE referral_social_shares SET status=?,admin_id=?,resolved_at=NOW() WHERE id=?")->execute([$status,(int)$_SESSION['user_id'],$id]); + if ($status === 'approved') { + db()->prepare("UPDATE users SET tokens=tokens+? WHERE id=?")->execute([$share['bonus_tokens'],$share['user_id']]); + logAdminAction('SOCIAL_SHARE_APPROVED', (int)$_SESSION['user_id'], 'referral_share', $id, 'Approved social share #'.$id.' — awarded '.$share['bonus_tokens'].' bonus tokens to user #'.$share['user_id'], '', 'approved', 'info'); + } + echo json_encode(['success'=>true]); + exit; +} + +// ── Tier CRUD ───────────────────────────────────────────── +if ($action === 'tier_create') { + $name = substr(trim($d['name']??''),0,100); + $minRefs = (int)($d['min_referrals']??1); + $tokPer = (float)($d['tokens_per_ref']??5); + $bonus = (float)($d['bonus_tokens']??0); + $desc = substr(trim($d['description']??''),0,300); + $sort = (int)($d['sort_order']??99); + $active = (int)(bool)($d['is_active']??1); + if (!$name) { echo json_encode(['success'=>false,'error'=>'Name required']); exit; } + db()->prepare("INSERT INTO referral_tiers (name,min_referrals,tokens_per_ref,bonus_tokens,description,is_active,sort_order) VALUES (?,?,?,?,?,?,?)") + ->execute([$name,$minRefs,$tokPer,$bonus,$desc,$active,$sort]); + echo json_encode(['success'=>true,'id'=>db()->lastInsertId()]); + exit; +} + +if ($action === 'tier_update') { + $id = (int)($d['id']??0); + $name = substr(trim($d['name']??''),0,100); + $minRefs = (int)($d['min_referrals']??1); + $tokPer = (float)($d['tokens_per_ref']??5); + $bonus = (float)($d['bonus_tokens']??0); + $desc = substr(trim($d['description']??''),0,300); + $sort = (int)($d['sort_order']??0); + $active = (int)(bool)($d['is_active']??1); + if (!$id||!$name) { echo json_encode(['success'=>false,'error'=>'ID and name required']); exit; } + db()->prepare("UPDATE referral_tiers SET name=?,min_referrals=?,tokens_per_ref=?,bonus_tokens=?,description=?,is_active=?,sort_order=? WHERE id=?") + ->execute([$name,$minRefs,$tokPer,$bonus,$desc,$active,$sort,$id]); + echo json_encode(['success'=>true]); + exit; +} + +if ($action === 'tier_delete') { + $id = (int)($d['id']??0); + if (!$id) { echo json_encode(['success'=>false,'error'=>'ID required']); exit; } + db()->prepare("DELETE FROM referral_tiers WHERE id=?")->execute([$id]); + echo json_encode(['success'=>true]); + exit; +} + +echo json_encode(['success'=>false,'error'=>'Unknown action']); diff --git a/sites/tomtomgames.com/public_html/api/register.php b/sites/tomtomgames.com/public_html/api/register.php new file mode 100644 index 0000000..6d2bce9 --- /dev/null +++ b/sites/tomtomgames.com/public_html/api/register.php @@ -0,0 +1,18 @@ +false,'error'=>'Method not allowed']); exit; +} + +$data = json_decode(file_get_contents('php://input'), true); +$username = trim($data['username'] ?? ''); +$password = trim($data['password'] ?? ''); +$alias = trim($data['alias'] ?? ''); +$email = trim($data['email'] ?? ''); +$referralCode= trim($data['referral_code']?? ''); + +logSecurityEvent('REGISTER_ATTEMPT', null, 'Registration attempt for: ' . $email, 'info'); +$result = initiateRegistration($username, $password, $alias, $email, $referralCode); +echo json_encode($result); diff --git a/sites/tomtomgames.com/public_html/api/resend_verify.php b/sites/tomtomgames.com/public_html/api/resend_verify.php new file mode 100644 index 0000000..7d1c539 --- /dev/null +++ b/sites/tomtomgames.com/public_html/api/resend_verify.php @@ -0,0 +1,16 @@ +false,'error'=>'Method not allowed']); exit; +} + +$data = json_decode(file_get_contents('php://input'), true); +$email = trim($data['email'] ?? ''); + +if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) { + echo json_encode(['success'=>false,'error'=>'Valid email required']); exit; +} + +echo json_encode(resendVerification($email)); diff --git a/sites/tomtomgames.com/public_html/assets/img/egame99.svg b/sites/tomtomgames.com/public_html/assets/img/egame99.svg new file mode 100644 index 0000000..acbed0c --- /dev/null +++ b/sites/tomtomgames.com/public_html/assets/img/egame99.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + 🎮 + + eGAME 99 + + + + + + diff --git a/sites/tomtomgames.com/public_html/assets/img/firekirin.svg b/sites/tomtomgames.com/public_html/assets/img/firekirin.svg new file mode 100644 index 0000000..768a024 --- /dev/null +++ b/sites/tomtomgames.com/public_html/assets/img/firekirin.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + 🔥 + + KIRIN + + + + + + diff --git a/sites/tomtomgames.com/public_html/assets/img/icon-192.png b/sites/tomtomgames.com/public_html/assets/img/icon-192.png new file mode 100644 index 0000000..fd3f325 Binary files /dev/null and b/sites/tomtomgames.com/public_html/assets/img/icon-192.png differ diff --git a/sites/tomtomgames.com/public_html/assets/img/icon-512.png b/sites/tomtomgames.com/public_html/assets/img/icon-512.png new file mode 100644 index 0000000..bf41cc8 Binary files /dev/null and b/sites/tomtomgames.com/public_html/assets/img/icon-512.png differ diff --git a/sites/tomtomgames.com/public_html/assets/img/logo-icon.svg b/sites/tomtomgames.com/public_html/assets/img/logo-icon.svg new file mode 100644 index 0000000..cd251a4 --- /dev/null +++ b/sites/tomtomgames.com/public_html/assets/img/logo-icon.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sites/tomtomgames.com/public_html/assets/img/milkyway.svg b/sites/tomtomgames.com/public_html/assets/img/milkyway.svg new file mode 100644 index 0000000..b89a5fd --- /dev/null +++ b/sites/tomtomgames.com/public_html/assets/img/milkyway.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 🌌 + MILKY WAY + + + diff --git a/sites/tomtomgames.com/public_html/assets/img/noble777.svg b/sites/tomtomgames.com/public_html/assets/img/noble777.svg new file mode 100644 index 0000000..16a61e5 --- /dev/null +++ b/sites/tomtomgames.com/public_html/assets/img/noble777.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + 👑 + + 777 + NOBLE + + + + + + diff --git a/sites/tomtomgames.com/public_html/assets/img/og-image.svg b/sites/tomtomgames.com/public_html/assets/img/og-image.svg new file mode 100644 index 0000000..62fee62 --- /dev/null +++ b/sites/tomtomgames.com/public_html/assets/img/og-image.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TomTomGames + + Buy tokens for VBlink777 · Fire Kirin · Milky Way · Ultra Panda & more + + + ⚡ Instant Delivery + + 🔒 SSL Secured + + 💬 24/7 Support + diff --git a/sites/tomtomgames.com/public_html/assets/img/pandamaster.svg b/sites/tomtomgames.com/public_html/assets/img/pandamaster.svg new file mode 100644 index 0000000..b7ec28a --- /dev/null +++ b/sites/tomtomgames.com/public_html/assets/img/pandamaster.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + 🐾 + + MASTER + + diff --git a/sites/tomtomgames.com/public_html/assets/img/ultrapanda.svg b/sites/tomtomgames.com/public_html/assets/img/ultrapanda.svg new file mode 100644 index 0000000..f8e384f --- /dev/null +++ b/sites/tomtomgames.com/public_html/assets/img/ultrapanda.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + 🐼 + + ULTRA + + + + diff --git a/sites/tomtomgames.com/public_html/assets/img/vblink777.svg b/sites/tomtomgames.com/public_html/assets/img/vblink777.svg new file mode 100644 index 0000000..47e8e61 --- /dev/null +++ b/sites/tomtomgames.com/public_html/assets/img/vblink777.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + 🎰 + + 777 + + + + + + diff --git a/sites/tomtomgames.com/public_html/bump_version.php b/sites/tomtomgames.com/public_html/bump_version.php new file mode 100644 index 0000000..2d2b8e2 --- /dev/null +++ b/sites/tomtomgames.com/public_html/bump_version.php @@ -0,0 +1,52 @@ + 'Forbidden']); + exit; +} + +require_once __DIR__ . '/../includes/db.php'; + +// Get current version +$current = db()->query("SELECT version FROM app_version ORDER BY id DESC LIMIT 1")->fetchColumn() ?: '1.0.0'; +[$major, $minor, $patch] = array_map('intval', explode('.', $current)); + +// Determine new version +if (!empty($_GET['version'])) { + $newVersion = trim($_GET['version']); +} elseif (!empty($_GET['bump'])) { + switch ($_GET['bump']) { + case 'major': $newVersion = ($major+1).'.0.0'; break; + case 'minor': $newVersion = $major.'.'.($minor+1).'.0'; break; + default: $newVersion = $major.'.'.$minor.'.'.($patch+1); break; + } +} else { + // Default: bump patch + $newVersion = $major.'.'.$minor.'.'.($patch+1); +} + +$notes = trim($_GET['notes'] ?? 'Build ' . date('Y-m-d H:i:s')); + +db()->prepare("INSERT INTO app_version (version, notes) VALUES (?, ?)") + ->execute([$newVersion, $notes]); + +header('Content-Type: application/json'); +echo json_encode([ + 'success' => true, + 'previous' => $current, + 'new_version' => $newVersion, + 'notes' => $notes, + 'timestamp' => date('Y-m-d H:i:s'), +]); diff --git a/sites/tomtomgames.com/public_html/config/vhost/vhost.conf b/sites/tomtomgames.com/public_html/config/vhost/vhost.conf new file mode 100755 index 0000000..b82c1cc --- /dev/null +++ b/sites/tomtomgames.com/public_html/config/vhost/vhost.conf @@ -0,0 +1,91 @@ +docRoot $VH_ROOT/public_html +vhDomain $VH_NAME +vhAliases www.$VH_NAME +adminEmails admin@tomtomgames.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:tomto8868 php +} + +extprocessor tomto8868 { + type lsapi + address UDS://tmp/lshttpd/tomto8868.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 tomto8868 + extGroup tomto8868 + 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/tomtomgames.com/privkey.pem + certFile /etc/letsencrypt/live/tomtomgames.com/fullchain.pem + certChain 1 + sslProtocol 24 + enableECDHE 1 + renegProtection 1 + sslSessionCache 1 + enableSpdy 15 + enableStapling 1 + ocspRespMaxAge 86400 +} diff --git a/sites/tomtomgames.com/public_html/config/vhost/vhost.conf.txt b/sites/tomtomgames.com/public_html/config/vhost/vhost.conf.txt new file mode 100755 index 0000000..2c3f7e7 --- /dev/null +++ b/sites/tomtomgames.com/public_html/config/vhost/vhost.conf.txt @@ -0,0 +1,85 @@ +adminemails admin@tomtomgames.com +enablegzip 1 +vhaliases www.$VH_NAME +docroot $VH_ROOT/public_html +vhdomain $VH_NAME +enableipgeo 1 +phpinioverride + +scripthandler { + add lsapi:tomto8868 php +} + +extprocessor tomto8868 { + type lsapi + address UDS://tmp/lshttpd/tomto8868.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 tomto8868 + extgroup tomto8868 + memsoftlimit 1024M + memhardlimit 1024M + procsoftlimit 400 + prochardlimit 500 +} + +errorlog $VH_ROOT/logs/$VH_NAME.error_log { + useserver 0 + loglevel WARN + rollingsize 10M +} + +vhssl { + keyfile /etc/letsencrypt/live/tomtomgames.com/privkey.pem + certfile /etc/letsencrypt/live/tomtomgames.com/fullchain.pem + certchain 1 + sslprotocol 24 + enableecdhe 1 + renegprotection 1 + sslsessioncache 1 + enablespdy 15 + enablestapling 1 + ocsprespmaxage 86400 +} + +index { + useserver 0 + indexfiles index.php, index.html +} + +accesslog $VH_ROOT/logs/$VH_NAME.access_log { + keepdays 10 + compressarchive 1 + logformat "%h %l %u %t "%r" %>s %b "%{Referer}i" "%{User-Agent}i"" + logheaders 5 + rollingsize 10M + useserver 0 +} + +module cache { + param storagepath /usr/local/lsws/cachedata/$VH_NAME + unknownkeywords storagepath /usr/local/lsws/cachedata/$VH_NAME +} + +rewrite { + enable 1 + autoloadhtaccess 1 +} + +context /.well-known/acme-challenge { + adddefaultcharset off + phpinioverride + location /usr/local/lsws/Example/html/.well-known/acme-challenge + allowbrowse 1 + + rewrite { + enable 0 + } +} diff --git a/sites/tomtomgames.com/public_html/config/vhost/vhost.conf0 b/sites/tomtomgames.com/public_html/config/vhost/vhost.conf0 new file mode 100755 index 0000000..b82c1cc --- /dev/null +++ b/sites/tomtomgames.com/public_html/config/vhost/vhost.conf0 @@ -0,0 +1,91 @@ +docRoot $VH_ROOT/public_html +vhDomain $VH_NAME +vhAliases www.$VH_NAME +adminEmails admin@tomtomgames.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:tomto8868 php +} + +extprocessor tomto8868 { + type lsapi + address UDS://tmp/lshttpd/tomto8868.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 tomto8868 + extGroup tomto8868 + 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/tomtomgames.com/privkey.pem + certFile /etc/letsencrypt/live/tomtomgames.com/fullchain.pem + certChain 1 + sslProtocol 24 + enableECDHE 1 + renegProtection 1 + sslSessionCache 1 + enableSpdy 15 + enableStapling 1 + ocspRespMaxAge 86400 +} diff --git a/sites/tomtomgames.com/public_html/config/vhost/vhost.conf0,v b/sites/tomtomgames.com/public_html/config/vhost/vhost.conf0,v new file mode 100755 index 0000000..9b2b861 --- /dev/null +++ b/sites/tomtomgames.com/public_html/config/vhost/vhost.conf0,v @@ -0,0 +1,130 @@ +head 1.2; +access; +symbols; +locks + root:1.2; strict; +comment @# @; + + +1.2 +date 2026.05.16.13.11.17; author root; state Exp; +branches; +next 1.1; + +1.1 +date 2026.05.15.20.01.17; author root; state Exp; +branches; +next ; + + +desc +@/usr/local/lsws/conf/vhosts/tomtomgames.com/vhost.conf0 +@ + + +1.2 +log +@Update +@ +text +@docRoot $VH_ROOT/public_html +vhDomain $VH_NAME +vhAliases www.$VH_NAME +adminEmails admin@@tomtomgames.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:tomto8868 php +} + +extprocessor tomto8868 { + type lsapi + address UDS://tmp/lshttpd/tomto8868.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 tomto8868 + extGroup tomto8868 + 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/tomtomgames.com/privkey.pem + certFile /etc/letsencrypt/live/tomtomgames.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 +@ diff --git a/sites/tomtomgames.com/public_html/db/schema.sql b/sites/tomtomgames.com/public_html/db/schema.sql new file mode 100644 index 0000000..9a5c8cc --- /dev/null +++ b/sites/tomtomgames.com/public_html/db/schema.sql @@ -0,0 +1,453 @@ +/*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 `activity_log`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `activity_log` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `user_id` int(11) DEFAULT NULL, + `admin_id` int(11) DEFAULT NULL, + `action` varchar(80) NOT NULL, + `category` varchar(40) DEFAULT 'general', + `entity_type` varchar(40) DEFAULT NULL, + `entity_id` int(11) DEFAULT NULL, + `detail` text DEFAULT NULL, + `old_value` text DEFAULT NULL, + `new_value` text DEFAULT NULL, + `ip` varchar(45) DEFAULT NULL, + `user_agent` varchar(300) DEFAULT NULL, + `page` varchar(200) DEFAULT NULL, + `session_id` varchar(64) DEFAULT NULL, + `severity` enum('info','warning','critical') DEFAULT 'info', + `created_at` datetime DEFAULT current_timestamp(), + PRIMARY KEY (`id`), + KEY `idx_created` (`created_at`), + KEY `idx_user` (`user_id`) +) ENGINE=InnoDB AUTO_INCREMENT=356 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `admin_payout_settings`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `admin_payout_settings` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `method_key` varchar(50) NOT NULL, + `label` varchar(100) NOT NULL, + `method_type` enum('manual','square_gift_card') DEFAULT 'manual', + `is_enabled` tinyint(1) DEFAULT 1, + `handle` varchar(200) DEFAULT NULL, + `instructions` text DEFAULT NULL, + `sort_order` int(11) DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `method_key` (`method_key`) +) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `app_version`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `app_version` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `version` varchar(20) NOT NULL, + `notes` text DEFAULT NULL, + `created_at` datetime DEFAULT current_timestamp(), + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `broadcast_reads`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `broadcast_reads` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `broadcast_id` int(11) NOT NULL, + `user_id` int(11) NOT NULL, + `read_at` datetime DEFAULT current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE KEY `uq_br` (`broadcast_id`,`user_id`), + KEY `user_id` (`user_id`), + CONSTRAINT `broadcast_reads_ibfk_1` FOREIGN KEY (`broadcast_id`) REFERENCES `broadcasts` (`id`) ON DELETE CASCADE, + CONSTRAINT `broadcast_reads_ibfk_2` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=13 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `broadcast_replies`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `broadcast_replies` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `broadcast_id` int(11) NOT NULL, + `user_id` int(11) NOT NULL, + `message` text NOT NULL, + `created_at` datetime DEFAULT current_timestamp(), + PRIMARY KEY (`id`), + KEY `broadcast_id` (`broadcast_id`), + KEY `user_id` (`user_id`), + CONSTRAINT `broadcast_replies_ibfk_1` FOREIGN KEY (`broadcast_id`) REFERENCES `broadcasts` (`id`) ON DELETE CASCADE, + CONSTRAINT `broadcast_replies_ibfk_2` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `broadcasts`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `broadcasts` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `admin_id` int(11) NOT NULL, + `subject` varchar(200) NOT NULL, + `message` text NOT NULL, + `target` enum('all','verified','unverified','admins') DEFAULT 'all', + `sent_at` datetime DEFAULT current_timestamp(), + PRIMARY KEY (`id`), + KEY `admin_id` (`admin_id`), + CONSTRAINT `broadcasts_ibfk_1` FOREIGN KEY (`admin_id`) REFERENCES `users` (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `cashout_method_types`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `cashout_method_types` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `slug` varchar(50) NOT NULL, + `label` varchar(100) NOT NULL, + `icon` varchar(10) DEFAULT '?', + `description` varchar(200) DEFAULT NULL, + `is_active` tinyint(1) DEFAULT 1, + `sort_order` int(11) DEFAULT 0, + `created_at` datetime DEFAULT current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE KEY `slug` (`slug`) +) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `cashout_requests`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `cashout_requests` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `user_id` int(11) NOT NULL, + `platform_id` varchar(50) NOT NULL, + `alias` varchar(100) NOT NULL, + `payout_method_type` varchar(50) DEFAULT NULL, + `payout_handle` varchar(200) DEFAULT NULL, + `tokens` decimal(10,2) NOT NULL, + `status` enum('pending','approved','sent','rejected','deleted') DEFAULT 'pending', + `admin_note` text DEFAULT NULL, + `sent_note` text DEFAULT NULL, + `created_at` datetime DEFAULT current_timestamp(), + `resolved_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `user_id` (`user_id`), + CONSTRAINT `cashout_requests_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `cashout_transactions`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `cashout_transactions` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `cashout_id` int(11) NOT NULL, + `admin_id` int(11) NOT NULL, + `payout_method` varchar(50) NOT NULL, + `payout_type` enum('manual','square_gift_card') DEFAULT 'manual', + `amount_cents` int(11) NOT NULL, + `square_txn_id` varchar(200) DEFAULT NULL, + `gift_card_gan` varchar(100) DEFAULT NULL, + `gift_card_balance` int(11) DEFAULT NULL, + `note` text DEFAULT NULL, + `status` enum('pending','completed','failed') DEFAULT 'pending', + `created_at` datetime DEFAULT current_timestamp(), + PRIMARY KEY (`id`), + KEY `cashout_id` (`cashout_id`), + CONSTRAINT `cashout_transactions_ibfk_1` FOREIGN KEY (`cashout_id`) REFERENCES `cashout_requests` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `chat_messages`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `chat_messages` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `user_id` int(11) NOT NULL, + `sender` enum('user','admin') NOT NULL, + `message` text NOT NULL, + `is_read` tinyint(1) DEFAULT 0, + `created_at` datetime DEFAULT current_timestamp(), + PRIMARY KEY (`id`), + KEY `user_id` (`user_id`), + CONSTRAINT `chat_messages_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=37 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `game_aliases`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `game_aliases` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `user_id` int(11) NOT NULL, + `platform_slug` varchar(50) NOT NULL, + `alias` varchar(100) NOT NULL, + `updated_at` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE KEY `uq_user_platform` (`user_id`,`platform_slug`), + CONSTRAINT `game_aliases_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=46 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `payment_settings`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `payment_settings` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `method_key` varchar(50) NOT NULL, + `label` varchar(100) NOT NULL, + `is_enabled` tinyint(1) DEFAULT 1, + `handle` varchar(200) DEFAULT NULL, + `instructions` text DEFAULT NULL, + `sort_order` int(11) DEFAULT 0, + `updated_at` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE KEY `method_key` (`method_key`) +) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `payout_methods`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `payout_methods` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `user_id` int(11) NOT NULL, + `method_type` varchar(50) NOT NULL, + `label` varchar(100) NOT NULL, + `account_handle` varchar(200) NOT NULL, + `is_default` tinyint(1) DEFAULT 0, + `created_at` datetime DEFAULT current_timestamp(), + PRIMARY KEY (`id`), + KEY `user_id` (`user_id`), + CONSTRAINT `payout_methods_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `pending_registrations`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `pending_registrations` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `username` varchar(50) NOT NULL, + `password` varchar(255) NOT NULL, + `alias` varchar(100) NOT NULL, + `email` varchar(150) NOT NULL, + `token` varchar(64) NOT NULL, + `expires_at` datetime NOT NULL, + `created_at` datetime DEFAULT current_timestamp(), + `referred_by` int(11) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `token` (`token`) +) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `platform_accounts`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `platform_accounts` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `user_id` int(11) NOT NULL, + `platform_slug` varchar(50) NOT NULL, + `requested_at` datetime DEFAULT current_timestamp(), + `status` enum('pending','approved','denied','deleted') DEFAULT 'pending', + `platform_username` varchar(100) DEFAULT NULL, + `platform_password` varchar(200) DEFAULT NULL, + `admin_note` varchar(300) DEFAULT NULL, + `resolved_at` datetime DEFAULT NULL, + `admin_id` int(11) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_user_platform` (`user_id`,`platform_slug`), + CONSTRAINT `platform_accounts_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `platforms`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `platforms` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `slug` varchar(50) NOT NULL, + `name` varchar(100) NOT NULL, + `player_url` varchar(500) NOT NULL, + `console_url` varchar(500) DEFAULT NULL, + `color` varchar(20) DEFAULT '#f0c040', + `icon_path` varchar(200) DEFAULT NULL, + `is_active` tinyint(1) DEFAULT 1, + `sort_order` int(11) DEFAULT 0, + `created_at` datetime DEFAULT current_timestamp(), + `updated_at` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE KEY `slug` (`slug`) +) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `platform_credits`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `platform_credits` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `platform_id` int(11) NOT NULL, + `credits_purchased` decimal(12,2) NOT NULL DEFAULT 0.00, + `credit_date` date NOT NULL, + `payment_method` varchar(100) DEFAULT NULL, + `notes` text DEFAULT NULL, + `type` enum('credit','debit') NOT NULL DEFAULT 'credit', + `purchase_ref_id` int(11) DEFAULT NULL, + `created_at` datetime DEFAULT current_timestamp(), + `updated_at` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`id`), + KEY `platform_id` (`platform_id`), + KEY `credit_date` (`credit_date`), + KEY `idx_pc_type` (`type`), + KEY `idx_pc_ref` (`purchase_ref_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `referral_social_shares`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `referral_social_shares` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `user_id` int(11) NOT NULL, + `platform` varchar(50) NOT NULL, + `bonus_tokens` decimal(10,2) DEFAULT 0.00, + `share_url` varchar(500) DEFAULT NULL, + `auto_verified` tinyint(1) DEFAULT 0, + `verify_result` varchar(100) DEFAULT NULL, + `status` enum('pending','approved','denied') DEFAULT 'pending', + `admin_id` int(11) DEFAULT NULL, + `created_at` datetime DEFAULT current_timestamp(), + `resolved_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `user_id` (`user_id`), + CONSTRAINT `referral_social_shares_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `referral_tiers`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `referral_tiers` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `name` varchar(100) NOT NULL, + `min_referrals` int(11) NOT NULL DEFAULT 1, + `tokens_per_ref` decimal(10,2) NOT NULL DEFAULT 10.00, + `bonus_tokens` decimal(10,2) NOT NULL DEFAULT 0.00, + `description` varchar(300) DEFAULT NULL, + `is_active` tinyint(1) DEFAULT 1, + `sort_order` int(11) DEFAULT 0, + `created_at` datetime DEFAULT current_timestamp(), + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `referrals`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `referrals` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `referrer_id` int(11) NOT NULL, + `referred_id` int(11) NOT NULL, + `tier_id` int(11) DEFAULT NULL, + `status` enum('pending','verified','denied','deleted') DEFAULT 'pending', + `tokens_awarded` decimal(10,2) DEFAULT 0.00, + `admin_id` int(11) DEFAULT NULL, + `admin_note` varchar(300) DEFAULT NULL, + `created_at` datetime DEFAULT current_timestamp(), + `resolved_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `referred_id` (`referred_id`), + KEY `referrer_id` (`referrer_id`), + CONSTRAINT `referrals_ibfk_1` FOREIGN KEY (`referrer_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + CONSTRAINT `referrals_ibfk_2` FOREIGN KEY (`referred_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `saved_billing`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `saved_billing` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `user_id` int(11) NOT NULL, + `first_name` varchar(80) DEFAULT NULL, + `last_name` varchar(80) DEFAULT NULL, + `email` varchar(150) DEFAULT NULL, + `address` varchar(200) DEFAULT NULL, + `city` varchar(80) DEFAULT NULL, + `state` varchar(2) DEFAULT NULL, + `zip` varchar(10) DEFAULT NULL, + `card_brand` varchar(30) DEFAULT NULL, + `card_last4` varchar(4) DEFAULT NULL, + `card_exp_month` varchar(2) DEFAULT NULL, + `card_exp_year` varchar(4) DEFAULT NULL, + `sq_card_id` varchar(255) DEFAULT NULL, + `updated_at` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE KEY `user_id` (`user_id`), + CONSTRAINT `saved_billing_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `token_purchases`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `token_purchases` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `user_id` int(11) NOT NULL, + `tokens` int(11) NOT NULL, + `amount_cents` int(11) NOT NULL, + `payment_method` varchar(20) DEFAULT 'card', + `square_payment_id` varchar(255) DEFAULT NULL, + `platform_id` varchar(50) DEFAULT NULL, + `game_alias` varchar(100) DEFAULT NULL, + `player_name` varchar(100) DEFAULT NULL, + `billing_name` varchar(160) DEFAULT NULL, + `billing_address` varchar(200) DEFAULT NULL, + `billing_city` varchar(80) DEFAULT NULL, + `billing_state` varchar(2) DEFAULT NULL, + `billing_zip` varchar(10) DEFAULT NULL, + `billing_email` varchar(150) DEFAULT NULL, + `is_custom` tinyint(1) DEFAULT 0, + `failure_reason` text DEFAULT NULL, + `card_brand` varchar(30) DEFAULT NULL, + `card_last4` varchar(4) DEFAULT NULL, + `receipt_url` varchar(512) DEFAULT NULL, + `status` enum('pending','completed','failed') DEFAULT 'pending', + `admin_note` text DEFAULT NULL, + `created_at` datetime DEFAULT current_timestamp(), + PRIMARY KEY (`id`), + KEY `user_id` (`user_id`), + CONSTRAINT `token_purchases_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `users`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `users` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `username` varchar(50) NOT NULL, + `password` varchar(255) NOT NULL, + `alias` varchar(100) NOT NULL, + `email` varchar(150) DEFAULT NULL, + `email_verified` tinyint(1) DEFAULT 0, + `tokens` decimal(10,2) DEFAULT 0.00, + `is_admin` tinyint(1) DEFAULT 0, + `status` enum('active','suspended') DEFAULT 'active', + `created_at` datetime DEFAULT current_timestamp(), + `last_login` datetime DEFAULT NULL, + `onboarding_done` tinyint(1) DEFAULT 0, + `platform_onboarding_done` tinyint(1) DEFAULT 0, + `referral_code` varchar(20) DEFAULT NULL, + `referred_by` int(11) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `username` (`username`), + UNIQUE KEY `email` (`email`), + UNIQUE KEY `referral_code` (`referral_code`) +) ENGINE=InnoDB AUTO_INCREMENT=9 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 */; + diff --git a/sites/tomtomgames.com/public_html/favicon.ico b/sites/tomtomgames.com/public_html/favicon.ico new file mode 100644 index 0000000..ef9c616 Binary files /dev/null and b/sites/tomtomgames.com/public_html/favicon.ico differ diff --git a/sites/tomtomgames.com/public_html/favicon.svg b/sites/tomtomgames.com/public_html/favicon.svg new file mode 100644 index 0000000..80a423b --- /dev/null +++ b/sites/tomtomgames.com/public_html/favicon.svg @@ -0,0 +1,5 @@ + + + + T + \ No newline at end of file diff --git a/sites/tomtomgames.com/public_html/games/index.php b/sites/tomtomgames.com/public_html/games/index.php new file mode 100644 index 0000000..87004dd --- /dev/null +++ b/sites/tomtomgames.com/public_html/games/index.php @@ -0,0 +1,232 @@ + + + + + + +Buy Fish Table Game Tokens | VBlink777, Fire Kirin, Milky Way | TomTomGames + + + + + + + + + + + + + + + + +
+ +
+ +
+
+

Buy Game Tokens for Fish Table & Skill Games

+

The fastest, most trusted way to load up on tokens for VBlink777, Fire Kirin, Milky Way, Ultra Panda, Panda Master, Noble777, and eGame99.

+ 🪙 Buy Tokens Now +
+ 🔒 SSL Secured + ⚡ Instant Delivery + 💳 Multiple Payment Methods + 💬 24/7 Support + 🎮 7 Game Platforms +
+
+
+ +
+
+

Supported Game Platforms

+

Buy tokens for all major fish table and skill game platforms in one place.

+
+
+

VBlink777

+

One of the most popular fish table games. Buy VBlink777 tokens securely and get them credited fast to your game account.

+ Buy VBlink777 Tokens → +
+
+

Fire Kirin

+

Fire Kirin is a top-rated skill game with exciting fish table gameplay. Purchase Fire Kirin tokens through TomTomGames for instant delivery.

+ Buy Fire Kirin Tokens → +
+
+

Milky Way

+

Milky Way 777 game credits — buy tokens for one of the best online fish table platforms. Secure payment, fast top-up.

+ Buy Milky Way Tokens → +
+
+

Ultra Panda

+

Ultra Panda game tokens for sale. Top up your account instantly through our secure portal and start playing right away.

+ Buy Ultra Panda Tokens → +
+
+

Panda Master

+

Panda Master tokens — buy game credits quickly and safely. Multiple payment methods accepted including card, Venmo, and Cash App.

+ Buy Panda Master Tokens → +
+
+

Noble 777

+

Noble 777 game token purchases made easy. Register, select your package, and have tokens in your account within minutes.

+ Buy Noble 777 Tokens → +
+
+

eGame99

+

eGame99 tokens for sale at the best rates. Fast crediting, 24/7 customer support, and a seamless buying experience.

+ Buy eGame99 Tokens → +
+
+
+
+ +
+
+

How to Buy Tokens in 3 Steps

+

Get tokens credited to your game account in minutes.

+
+
+
1
+

Create Account

+

Register free in under 60 seconds. Verify your email and log in.

+
+
+
2
+

Select Game & Package

+

Choose your game platform, enter your in-game alias, and pick a token package — or enter a custom amount.

+
+
+
3
+

Pay & Play

+

Pay securely by card or manual transfer. Tokens are credited to your game account fast.

+
+
+
+

Accepted Payment Methods

+
+
💳 Credit / Debit Card
+
💙 Venmo
+
💚 Cash App
+
🟢 Chime
+
💜 Zelle
+
+
+
+
+ +
+
+

Frequently Asked Questions

+

Everything you need to know about buying game tokens.

+
+
+

What is TomTomGames?

+

TomTomGames is a token portal that lets you purchase game credits for popular fish table and skill games including VBlink777, Fire Kirin, Milky Way, Ultra Panda, Panda Master, Noble777, and eGame99. We handle the token purchase securely so you can focus on playing.

+
+
+

How quickly are tokens delivered?

+

Card payments are processed instantly through Square. Manual payments (Venmo, Zelle, Cash App, Chime) are credited within a few minutes after we confirm receipt of your payment.

+
+
+

How much do tokens cost?

+

Tokens are priced at $1 per token. We offer packages starting from 5 tokens ($5) up to 100 tokens ($100), or you can enter a custom amount up to $500. Volume packages are available — contact support for details.

+
+
+

Is my payment information secure?

+

Yes. All card transactions are processed through Square, a fully PCI-compliant payment processor. We never store your full card number. Manual payment methods require no card details at all.

+
+
+

What if I need help?

+

Our support team is available 24/7 through the live chat feature inside the app. You can also send a message through your account and we'll respond within minutes.

+
+
+

Can I cash out my tokens?

+

Yes. You can request a cashout through your account and receive your funds via your preferred payment method. Cashouts are processed by our team promptly.

+
+
+ +
+
+ +
+
+

TomTomGames — Your trusted token portal for fish table and skill games.

+

Home · Games · Support

+

© TomTomGames. All rights reserved. Game tokens are for entertainment purposes on supported platforms only. Please play responsibly.

+
+
+ + + diff --git a/sites/tomtomgames.com/public_html/get_location.php b/sites/tomtomgames.com/public_html/get_location.php new file mode 100644 index 0000000..cac2f09 --- /dev/null +++ b/sites/tomtomgames.com/public_html/get_location.php @@ -0,0 +1,108 @@ + true, + CURLOPT_HTTPHEADER => [ + 'Authorization: Bearer ' . $token, + 'Square-Version: 2024-01-18', + 'Content-Type: application/json', + ], + CURLOPT_TIMEOUT => 15, +]); +$resp = curl_exec($ch); +$code = curl_getinfo($ch, CURLINFO_HTTP_CODE); +curl_close($ch); + +if ($code === 200) { + $data = json_decode($resp, true); + $locations = $data['locations'] ?? []; +} else { + $error = "HTTP $code: " . htmlspecialchars($resp); +} +?> + + + + + +Square Location ID Finder + + + +

🔑 Square Location Finder

+
TomGames — Run once, then delete this file
+ + +
Error fetching locations:
+ +
No locations found on this Square account.
+ +

Found location(s). Copy the ID for your main location:

+ +
+
+
+
+ +
+
+ + · + Currency: · + Country: +
+
+ + + + + +
⚠️ Security: Delete get_location.php from your server after use. It exposes your access token.
+ + + + diff --git a/sites/tomtomgames.com/public_html/index.php b/sites/tomtomgames.com/public_html/index.php new file mode 100644 index 0000000..5e3f4c6 --- /dev/null +++ b/sites/tomtomgames.com/public_html/index.php @@ -0,0 +1,3416 @@ + + + + + + + + + + + + + + +TomTomGames — Buy Game Tokens | VBlink777, Fire Kirin, Milky Way & More + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + +
+
+
+ + +
+
+ +
+ +
+ + + + + + + + + + + + + + + + + + diff --git a/sites/tomtomgames.com/public_html/manifest.json b/sites/tomtomgames.com/public_html/manifest.json new file mode 100644 index 0000000..b6df8ee --- /dev/null +++ b/sites/tomtomgames.com/public_html/manifest.json @@ -0,0 +1,43 @@ +{ + "name": "TomTomGames — Game Token Portal", + "short_name": "TomTomGames", + "description": "Buy tokens for VBlink777, Fire Kirin, Milky Way, Ultra Panda, Panda Master, Noble777 and eGame99. Fast, secure, mobile-first.", + "start_url": "/", + "display": "standalone", + "background_color": "#0a0a12", + "theme_color": "#f0c040", + "orientation": "portrait", + "scope": "/", + "lang": "en-US", + "categories": ["games", "entertainment"], + "icons": [ + { + "src": "/assets/img/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/assets/img/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ], + "shortcuts": [ + { + "name": "Buy Tokens", + "short_name": "Buy", + "description": "Buy game tokens instantly", + "url": "/?action=buy", + "icons": [{ "src": "/assets/img/icon-192.png", "sizes": "192x192" }] + }, + { + "name": "Support", + "short_name": "Help", + "description": "Contact TomTomGames support", + "url": "/?action=chat", + "icons": [{ "src": "/assets/img/icon-192.png", "sizes": "192x192" }] + } + ] +} diff --git a/sites/tomtomgames.com/public_html/reset_password.php b/sites/tomtomgames.com/public_html/reset_password.php new file mode 100644 index 0000000..6d603b5 --- /dev/null +++ b/sites/tomtomgames.com/public_html/reset_password.php @@ -0,0 +1,113 @@ +prepare( + "SELECT * FROM pending_registrations WHERE token=? AND username='__reset__' AND expires_at > NOW()" + ); + $stmt->execute([$token]); + $pending = $stmt->fetch(); +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $token = trim($_POST['token'] ?? ''); + $password = $_POST['password'] ?? ''; + $confirm = $_POST['confirm'] ?? ''; + + // Re-fetch pending row inside POST to prevent token reuse after expiry + $stmt = db()->prepare( + "SELECT * FROM pending_registrations WHERE token=? AND username='__reset__' AND expires_at > NOW()" + ); + $stmt->execute([$token]); + $pending = $stmt->fetch(); + + if (!$pending) { + $error = 'This reset link has expired or already been used. Please request a new one.'; + } elseif (strlen($password) < 6) { + $error = 'Password must be at least 6 characters.'; + } elseif ($password !== $confirm) { + $error = 'Passwords do not match.'; + } else { + $hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 8]); + $updated = db()->prepare("UPDATE users SET password=? WHERE email=?") + ->execute([$hash, $pending['email']]); + db()->prepare("DELETE FROM pending_registrations WHERE token=?")->execute([$token]); + $success = true; + $pending = null; + } +} +?> + + + + + +<?= SITE_NAME ?> — Reset Password + + + + +
+ + + + +
Password Updated!
+

Your password has been reset successfully. You can now log in with your new password.

+ BACK TO LOGIN + + + +
Invalid Link
+

This password reset link is invalid or has expired.
Please request a new one from the app.

+ BACK TO HOME + + +
Reset Password
+

Enter a new password for your account.

+ + +
+ + +
+ +
+ + +
+
+ + +
+ +
+ +
+ + diff --git a/sites/tomtomgames.com/public_html/robots.txt b/sites/tomtomgames.com/public_html/robots.txt new file mode 100644 index 0000000..297f710 --- /dev/null +++ b/sites/tomtomgames.com/public_html/robots.txt @@ -0,0 +1,23 @@ +User-agent: * +Allow: / +Allow: /assets/ +Disallow: /admin/ +Disallow: /api/ +Disallow: /install.php +Disallow: /test.php +Disallow: /test_login.php +Disallow: /get_location.php +Disallow: /?q= + +# Block AI training bots (optional but protects content) +User-agent: GPTBot +Disallow: / + +User-agent: Google-Extended +Disallow: / + +User-agent: CCBot +Disallow: / + +# Sitemap +Sitemap: https://tomtomgames.com/sitemap.xml diff --git a/sites/tomtomgames.com/public_html/scripts/ttg-backup.sh b/sites/tomtomgames.com/public_html/scripts/ttg-backup.sh new file mode 100644 index 0000000..d6958dc --- /dev/null +++ b/sites/tomtomgames.com/public_html/scripts/ttg-backup.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# TomTomGames automated backup — runs daily at 2 AM via cron +# Cron entry: 0 2 * * * /usr/local/bin/ttg-backup.sh >> /home/tomtomgames.com/backups/backup.log 2>&1 + +BACKUP_DIR="/home/tomtomgames.com/backups" +SITE_DIR="/home/tomtomgames.com/public_html" +DB_NAME="tomt_ttg_db" +DB_USER="tomt_ttg_user" +DB_PASS='q#q+mrOcozsa7I6J' +DATE=$(date +%Y-%m-%d_%H-%M-%S) +SQL_FILE="/tmp/ttg_db_${DATE}.sql" +ZIP_FILE="${BACKUP_DIR}/ttg_backup_${DATE}.zip" + +mkdir -p "$BACKUP_DIR" +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Starting backup..." + +# Export database +/usr/bin/mysqldump -u "$DB_USER" "-p${DB_PASS}" "$DB_NAME" > "$SQL_FILE" 2>/dev/null +if [ $? -ne 0 ] || [ ! -s "$SQL_FILE" ]; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: Database export failed" + rm -f "$SQL_FILE" + exit 1 +fi +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Database exported ($(du -sh "$SQL_FILE" | cut -f1))" + +# Create zip archive (site files + db dump) +/usr/bin/zip -r "$ZIP_FILE" "$SITE_DIR" "$SQL_FILE" -x "*/backups/*" > /dev/null 2>&1 +RC=$? +rm -f "$SQL_FILE" + +if [ $RC -ne 0 ] || [ ! -f "$ZIP_FILE" ]; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: Archive creation failed" + rm -f "$ZIP_FILE" + exit 1 +fi +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Archive created: $(basename "$ZIP_FILE") ($(du -sh "$ZIP_FILE" | cut -f1))" + +# Keep only the 7 most recent backups +ls -t "${BACKUP_DIR}"/ttg_backup_*.zip 2>/dev/null | tail -n +8 | while read old; do + rm -f "$old" + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Pruned old backup: $(basename "$old")" +done + +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Backup complete." diff --git a/sites/tomtomgames.com/public_html/sitemap.xml b/sites/tomtomgames.com/public_html/sitemap.xml new file mode 100644 index 0000000..661c133 --- /dev/null +++ b/sites/tomtomgames.com/public_html/sitemap.xml @@ -0,0 +1,21 @@ + + + + + + https://tomtomgames.com/ + 2026-05-19 + daily + 1.0 + + + + + https://tomtomgames.com/games/ + 2026-05-19 + weekly + 0.9 + + + diff --git a/sites/tomtomgames.com/public_html/verify.php b/sites/tomtomgames.com/public_html/verify.php new file mode 100644 index 0000000..e2d8d37 --- /dev/null +++ b/sites/tomtomgames.com/public_html/verify.php @@ -0,0 +1,66 @@ + + + + + + +<?= SITE_NAME ?> — Email Verified + + + + + + + +
+ + + + 🎉 +
Account Verified!
+

+ Welcome to , !

+ Your account is active and you've been automatically logged in. Let's play! +

+ 🎮 ENTER TOMTOMGAMES +

Redirecting in 4 seconds...

+ + + + +
Verification Failed
+

+ REGISTER AGAIN + BACK TO HOME + +
+ + diff --git a/sites/tomtomgames.com/public_html/webhook.php b/sites/tomtomgames.com/public_html/webhook.php new file mode 100644 index 0000000..16e2eef --- /dev/null +++ b/sites/tomtomgames.com/public_html/webhook.php @@ -0,0 +1,63 @@ + 'Webhook not configured']); + exit; +} +define('DEPLOY_QUEUE', '/tmp/jarvis-deploy-queue.txt'); +define('DEPLOY_LOG', '/home/tomtomgames.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 = [ + 'tomtomgames' => '/home/tomtomgames.com/public_html', + 'tomsjavajive' => '/home/tomsjavajive.com/public_html', + 'epictravelexpeditions' => '/home/epictravelexpeditions.com/public_html', + 'parkerslingshotrentals' => '/home/parkerslingshotrentals.com/public_html', + '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]); diff --git a/sites/worktracking.orbishosting.com/public_html/.gitignore b/sites/worktracking.orbishosting.com/public_html/.gitignore new file mode 100644 index 0000000..e43b0f9 --- /dev/null +++ b/sites/worktracking.orbishosting.com/public_html/.gitignore @@ -0,0 +1 @@ +.DS_Store diff --git a/sites/worktracking.orbishosting.com/public_html/.htaccess b/sites/worktracking.orbishosting.com/public_html/.htaccess new file mode 100644 index 0000000..e404086 --- /dev/null +++ b/sites/worktracking.orbishosting.com/public_html/.htaccess @@ -0,0 +1,30 @@ +# Work Tracking - Security Configuration + +Options -Indexes -Includes +ServerSignature Off + + + RewriteEngine On + + # Block sensitive paths and file types - RewriteRule confirmed to actually + # work on this OpenLiteSpeed setup, unlike /Order,Deny which + # is not honored here. + RewriteCond %{REQUEST_URI} ^/(\.git|includes/|config) + RewriteRule .* - [F,L] + RewriteCond %{REQUEST_URI} \.(sql|env|log|bak|backup|old|orig|tmp|swp|py)$ + RewriteRule .* - [F,L] + + # Canonical HTTPS + RewriteCond %{HTTPS} off + RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L] + + + + Header always set X-Content-Type-Options "nosniff" + Header always set X-Frame-Options "DENY" + Header always set X-XSS-Protection "1; mode=block" + Header always set Referrer-Policy "strict-origin-when-cross-origin" + Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains" + Header unset Server + Header unset X-Powered-By + diff --git a/sites/worktracking.orbishosting.com/public_html/admin/history.php b/sites/worktracking.orbishosting.com/public_html/admin/history.php new file mode 100644 index 0000000..1f3dcc4 --- /dev/null +++ b/sites/worktracking.orbishosting.com/public_html/admin/history.php @@ -0,0 +1,45 @@ +fetchAll(" + SELECT ws.worker_id, ws.week_start, ws.paid_at, w.name + FROM week_settlements ws + JOIN workers w ON w.id = ws.worker_id + WHERE ws.paid = 1 + ORDER BY ws.week_start DESC, w.name ASC +"); + +$owedByRow = []; +foreach ($rows as $i => $r) { + $dates = getWeekDates($r['week_start']); + $placeholders = implode(',', array_fill(0, count($dates), '?')); + $entries = db()->query("SELECT * FROM day_entries WHERE worker_id = ? AND work_date IN ({$placeholders})", array_merge([$r['worker_id']], $dates))->fetchAll(); + $owedByRow[$i] = calcOwed($entries); +} + +$pageTitle = 'Full History - ChuckCo Time Keeper'; +require_once __DIR__ . '/../includes/header.php'; +?> +
+

Full Payment History

+ ← Back +
+
+
+

All Paid Weeks

+ +

No paid weeks yet.

+ + $r): ?> + + + +
+
+ diff --git a/sites/worktracking.orbishosting.com/public_html/admin/index.php b/sites/worktracking.orbishosting.com/public_html/admin/index.php new file mode 100644 index 0000000..6808b04 --- /dev/null +++ b/sites/worktracking.orbishosting.com/public_html/admin/index.php @@ -0,0 +1,100 @@ +insert('workers', [ + 'name' => $name, + 'entry_token' => generateToken(), + 'share_token' => generateToken(), + 'payer_token' => generateToken(), + ]); + } + header('Location: /admin/index.php'); + exit; +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delete_worker') { + $workerId = (int) ($_POST['worker_id'] ?? 0); + if ($workerId > 0) { + // day_entries and week_settlements both have ON DELETE CASCADE on worker_id, + // so deleting the worker row removes all their related data too. + db()->query("DELETE FROM workers WHERE id = :id", ['id' => $workerId]); + } + header('Location: /admin/index.php'); + exit; +} + +$workers = db()->fetchAll("SELECT * FROM workers WHERE active = 1 ORDER BY name ASC"); + +$owedByWorker = []; +$rows = db()->fetchAll(" + SELECT de.worker_id, + SUM(CASE WHEN de.status='full' THEN " . FULL_DAY_RATE . " WHEN de.status='half' THEN " . HALF_DAY_RATE . " ELSE 0 END) AS owed + FROM day_entries de + LEFT JOIN week_settlements ws ON ws.worker_id = de.worker_id AND ws.week_start = de.week_start + WHERE ws.paid IS NULL OR ws.paid = 0 + GROUP BY de.worker_id +"); +foreach ($rows as $r) { + $owedByWorker[$r['worker_id']] = (float) $r['owed']; +} + +$pageTitle = 'Admin Dashboard - ChuckCo Time Keeper'; +require_once __DIR__ . '/../includes/header.php'; +?> +
+

ChuckCo Time Keeper

+ Log out +
+
+ +
+

All Workers Overview

+

One link showing everyone's current week together.

+ Open All-Workers View + Full History (Everyone) +
+ +
+

Add Worker

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

Workers

+ +

No workers yet. Add one above.

+ + +
+
+ + owed +
+ +
+ + +
+ +
+ diff --git a/sites/worktracking.orbishosting.com/public_html/admin/login.php b/sites/worktracking.orbishosting.com/public_html/admin/login.php new file mode 100644 index 0000000..b6570cf --- /dev/null +++ b/sites/worktracking.orbishosting.com/public_html/admin/login.php @@ -0,0 +1,39 @@ + +
+
+

ChuckCo Time Keeper

+ +
+ +
+
+ + +
+ +
+
+
+ diff --git a/sites/worktracking.orbishosting.com/public_html/admin/logout.php b/sites/worktracking.orbishosting.com/public_html/admin/logout.php new file mode 100644 index 0000000..76b288a --- /dev/null +++ b/sites/worktracking.orbishosting.com/public_html/admin/logout.php @@ -0,0 +1,5 @@ +fetch("SELECT * FROM workers WHERE id = :id", ['id' => $workerId]); +if (!$worker) { + header('Location: /admin/index.php'); + exit; +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $action = $_POST['action'] ?? ''; + + if ($action === 'set_day') { + $date = $_POST['date'] ?? ''; + $status = $_POST['status'] ?? ''; + $reason = trim($_POST['reason'] ?? ''); + $location = trim($_POST['location'] ?? ''); + if (in_array($status, ['full', 'half', 'absent'], true) && $date) { + $weekStart = getWeekStart($date); + db()->query(" + INSERT INTO day_entries (worker_id, work_date, week_start, status, absence_reason, location) + VALUES (:wid, :date, :week, :status, :reason, :location) + ON DUPLICATE KEY UPDATE status = :status2, absence_reason = :reason2, location = :location2, week_start = :week2 + ", [ + 'wid' => $workerId, 'date' => $date, 'week' => $weekStart, + 'status' => $status, 'reason' => $reason ?: null, 'location' => $location ?: null, + 'status2' => $status, 'reason2' => $reason ?: null, 'location2' => $location ?: null, 'week2' => $weekStart, + ]); + } + header('Location: /admin/worker.php?id=' . $workerId); + exit; + } + + if ($action === 'clear_day') { + $date = $_POST['date'] ?? ''; + db()->query("DELETE FROM day_entries WHERE worker_id = :wid AND work_date = :date", ['wid' => $workerId, 'date' => $date]); + header('Location: /admin/worker.php?id=' . $workerId); + exit; + } + + if ($action === 'toggle_paid') { + $weekStart = $_POST['week_start'] ?? ''; + $existing = db()->fetch("SELECT * FROM week_settlements WHERE worker_id = :wid AND week_start = :w", ['wid' => $workerId, 'w' => $weekStart]); + if ($existing) { + $newPaid = $existing['paid'] ? 0 : 1; + db()->update('week_settlements', ['paid' => $newPaid, 'paid_at' => $newPaid ? date('Y-m-d H:i:s') : null], 'id = :id', ['id' => $existing['id']]); + } else { + db()->insert('week_settlements', ['worker_id' => $workerId, 'week_start' => $weekStart, 'paid' => 1, 'paid_at' => date('Y-m-d H:i:s')]); + } + header('Location: /admin/worker.php?id=' . $workerId); + exit; + } +} + +// Gather all weeks that have entries, plus the current week even if empty +$weekStarts = db()->fetchAll("SELECT DISTINCT week_start FROM day_entries WHERE worker_id = :wid ORDER BY week_start DESC", ['wid' => $workerId]); +$weekStartList = array_column($weekStarts, 'week_start'); +if (!in_array(currentWeekStart(), $weekStartList, true)) { + array_unshift($weekStartList, currentWeekStart()); +} +rsort($weekStartList); + +$settlements = db()->fetchAll("SELECT * FROM week_settlements WHERE worker_id = :wid", ['wid' => $workerId]); +$paidMap = []; +foreach ($settlements as $s) { $paidMap[$s['week_start']] = (bool) $s['paid']; } + +$allEntries = db()->fetchAll("SELECT * FROM day_entries WHERE worker_id = :wid", ['wid' => $workerId]); +$entriesByDate = []; +foreach ($allEntries as $en) { $entriesByDate[$en['work_date']] = $en; } + +$pageTitle = $worker['name'] . ' - ChuckCo Time Keeper'; +require_once __DIR__ . '/../includes/header.php'; +?> +
+

+ ← Back +
+
+ + + + +
+
+

Week of

+ +
+ + +
+
+
+ +
+ + +
+ +
+
+ + +
+ + + +
+
+ +
+
+ +
+
+
+ + +
+ Owed + +
+
+ + + +
+
+ + +
+ diff --git a/sites/worktracking.orbishosting.com/public_html/all.php b/sites/worktracking.orbishosting.com/public_html/all.php new file mode 100644 index 0000000..aeb2c7a --- /dev/null +++ b/sites/worktracking.orbishosting.com/public_html/all.php @@ -0,0 +1,71 @@ +fetchAll("SELECT * FROM workers WHERE active = 1 ORDER BY name ASC"); + +$placeholders = implode(',', array_fill(0, count($dates), '?')); +$allEntries = db()->query("SELECT * FROM day_entries WHERE work_date IN ({$placeholders})", $dates)->fetchAll(); +$entriesByWorkerDate = []; +foreach ($allEntries as $en) { $entriesByWorkerDate[$en['worker_id']][$en['work_date']] = $en; } + +$settlements = db()->fetchAll("SELECT worker_id, paid FROM week_settlements WHERE week_start = :w", ['w' => $weekStart]); +$paidByWorker = []; +foreach ($settlements as $s) { $paidByWorker[$s['worker_id']] = (bool) $s['paid']; } + +$pageTitle = 'All Workers - ChuckCo Time Keeper'; +require_once __DIR__ . '/includes/header.php'; +?> +
+
+

Week of

+
+ + + + + + + + + + + + + + + + + + + + + +
Workerformat('D')) ?>OwedStatus
+ + +
+ +
+
+
+
+ diff --git a/sites/worktracking.orbishosting.com/public_html/assets/style.css b/sites/worktracking.orbishosting.com/public_html/assets/style.css new file mode 100644 index 0000000..2c159b3 --- /dev/null +++ b/sites/worktracking.orbishosting.com/public_html/assets/style.css @@ -0,0 +1,278 @@ +:root { + --bg: #f5f6f8; + --card: #ffffff; + --border: #e2e5ea; + --text: #1c1f26; + --text-muted: #6b7280; + --primary: #2563eb; + --primary-dark: #1d4ed8; + --success: #16a34a; + --warning: #d97706; + --error: #dc2626; + --radius: 12px; + --caution: #f5a623; + --caution-dark: #1a1a1a; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.5; +} + +.container { + max-width: 720px; + margin: 0 auto; + padding: 1.25rem 1rem 3rem; +} + +.brand-strip { + background: var(--caution-dark); + color: var(--caution); + text-align: center; + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 0.3px; + padding: 0.4rem; +} +.brand-strip span { color: #f4f4f4; } +.brand-strip b { color: var(--caution); } + +.topbar { + background: var(--card); + border-bottom: 1px solid var(--border); + padding: 0.9rem 1rem; + display: flex; + align-items: center; + justify-content: space-between; + position: relative; +} + +.topbar::after { + content: ''; + position: absolute; + bottom: -4px; left: 0; right: 0; + height: 4px; + background: repeating-linear-gradient( + 45deg, + var(--caution), + var(--caution) 6px, + var(--caution-dark) 6px, + var(--caution-dark) 12px + ); +} + +.topbar h1 { + font-size: 1.1rem; + margin: 0; +} + +.topbar a.logout { + color: var(--text-muted); + font-size: 0.85rem; + text-decoration: none; +} + +.card { + position: relative; + background: var(--card); + border: 1px solid var(--border); + border-top: none; + border-radius: var(--radius); + padding: 1.1rem; + margin-bottom: 1rem; + overflow: hidden; +} + +.card::before { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; + height: 5px; + background: repeating-linear-gradient( + 45deg, + var(--caution), + var(--caution) 8px, + var(--caution-dark) 8px, + var(--caution-dark) 16px + ); +} + +.card h2, .card h3 { + margin-top: 0; +} + +.btn { + display: inline-block; + padding: 0.65rem 1.1rem; + border-radius: 9px; + border: none; + font-size: 0.95rem; + font-weight: 600; + text-decoration: none; + cursor: pointer; + text-align: center; +} + +.btn-primary { background: var(--primary); color: #fff; } +.btn-primary:hover { background: var(--primary-dark); } +.btn-secondary { background: #eef0f3; color: var(--text); } +.btn-success { background: var(--success); color: #fff; } +.btn-block { display: block; width: 100%; } +.btn-sm { padding: 0.4rem 0.75rem; font-size: 0.8rem; } + +.form-group { margin-bottom: 0.9rem; } +.form-label { display: block; font-size: 0.85rem; font-weight: 600; margin-bottom: 0.3rem; color: var(--text-muted); } +.form-input { + width: 100%; + padding: 0.6rem 0.75rem; + border: 1px solid var(--border); + border-radius: 8px; + font-size: 1rem; +} + +.worker-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 0; + border-bottom: 1px solid var(--border); + flex-wrap: wrap; + gap: 0.5rem; +} +.worker-row:last-child { border-bottom: none; } +.worker-name { font-weight: 600; } +.worker-owed { color: var(--success); font-weight: 700; } + +.link-row { + display: flex; + gap: 0.4rem; + flex-wrap: wrap; + margin-top: 0.4rem; +} + +.badge { + display: inline-block; + padding: 0.15rem 0.55rem; + border-radius: 20px; + font-size: 0.75rem; + font-weight: 700; +} +.badge-full { background: rgba(22,163,74,.12); color: var(--success); } +.badge-half { background: rgba(217,119,6,.12); color: var(--warning); } +.badge-absent { background: rgba(220,38,38,.12); color: var(--error); } +.badge-unset { background: #eef0f3; color: var(--text-muted); } + +.day-tap { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.9rem; + border: 1px solid var(--border); + border-radius: 10px; + margin-bottom: 0.6rem; +} +.day-tap .date { font-weight: 600; } +.day-tap .actions { display: flex; gap: 0.4rem; } +.day-btn-row { + display: flex; + gap: 0.5rem; + width: 100%; +} +.day-btn { + flex: 1; + min-width: 0; + white-space: normal; + overflow-wrap: break-word; + padding: 0.6rem 0.3rem; + border-radius: 8px; + border: 1px solid var(--border); + background: #fff; + color: var(--text); + font-size: 0.8rem; + font-weight: 700; + cursor: pointer; + text-align: center; + line-height: 1.2; +} +.day-field-row { + width: 100%; + margin-top: 0.5rem; +} +.day-field-row .form-input { + width: 100%; +} +.day-btn.active-full { background: var(--success); color: #fff; border-color: var(--success); } +.day-btn.active-half { background: var(--warning); color: #fff; border-color: var(--warning); } +.day-btn.active-absent { background: var(--error); color: #fff; border-color: var(--error); } + +.reason-text { font-size: 0.85rem; color: var(--text-muted); margin-top: 0.4rem; } + +.text-muted { color: var(--text-muted); } +.text-center { text-align: center; } + +.alert { + padding: 0.75rem 1rem; + border-radius: 10px; + margin-bottom: 1rem; + font-size: 0.9rem; +} +.alert-error { background: rgba(220,38,38,.08); color: var(--error); } +.alert-success { background: rgba(22,163,74,.08); color: var(--success); } + +/* Screenshot page - sized for iPhone screenshot */ +.screenshot-card { + position: relative; + max-width: 390px; + margin: 1rem auto; + background: var(--card); + border-radius: 18px; + padding: 1.75rem 1.5rem 1.5rem; + box-shadow: 0 2px 12px rgba(0,0,0,.06); + overflow: hidden; +} + +.screenshot-card::before { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; + height: 6px; + background: repeating-linear-gradient( + 45deg, + var(--caution), + var(--caution) 8px, + var(--caution-dark) 8px, + var(--caution-dark) 16px + ); +} +.screenshot-header { text-align: center; margin-bottom: 1.25rem; } +.screenshot-header .name { font-size: 1.4rem; font-weight: 800; } +.screenshot-header .range { color: var(--text-muted); font-size: 0.9rem; margin-top: 0.2rem; } +.screenshot-day { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.7rem 0; + border-bottom: 1px solid var(--border); +} +.screenshot-day:last-child { border-bottom: none; } +.screenshot-day .date { font-weight: 600; } +.more-link { text-align: center; margin-top: 1rem; } +.more-link a { color: var(--primary); font-size: 0.85rem; text-decoration: none; font-weight: 600; } + +.owed-total { + margin-top: 1rem; + padding-top: 1rem; + border-top: 2px solid var(--border); + display: flex; + justify-content: space-between; + font-weight: 800; + font-size: 1.1rem; +} + +table.all-table { width: 100%; border-collapse: collapse; font-size: 0.9rem; } +table.all-table th, table.all-table td { padding: 0.5rem; text-align: left; border-bottom: 1px solid var(--border); } diff --git a/sites/worktracking.orbishosting.com/public_html/history.php b/sites/worktracking.orbishosting.com/public_html/history.php new file mode 100644 index 0000000..15ed6b9 --- /dev/null +++ b/sites/worktracking.orbishosting.com/public_html/history.php @@ -0,0 +1,51 @@ +fetch("SELECT * FROM workers WHERE payer_token = :t AND active = 1", ['t' => $token]) : null; + +if (!$worker) { + http_response_code(404); + echo 'Link not found.'; + exit; +} + +$paidWeeks = db()->fetchAll(" + SELECT week_start, paid_at FROM week_settlements + WHERE worker_id = :wid AND paid = 1 + ORDER BY week_start DESC +", ['wid' => $worker['id']]); + +$owedByWeek = []; +foreach ($paidWeeks as $pw) { + $dates = getWeekDates($pw['week_start']); + $placeholders = implode(',', array_fill(0, count($dates), '?')); + $entries = db()->query("SELECT * FROM day_entries WHERE worker_id = ? AND work_date IN ({$placeholders})", array_merge([$worker['id']], $dates))->fetchAll(); + $owedByWeek[$pw['week_start']] = calcOwed($entries); +} + +$pageTitle = 'Payment History - ChuckCo Time Keeper'; +require_once __DIR__ . '/includes/header.php'; +?> +
+

- History

+ ← Back +
+
+
+

Paid Weeks

+ +

No paid weeks yet.

+ + + + + +
+
+ diff --git a/sites/worktracking.orbishosting.com/public_html/includes/auth.php b/sites/worktracking.orbishosting.com/public_html/includes/auth.php new file mode 100644 index 0000000..aaff6c8 --- /dev/null +++ b/sites/worktracking.orbishosting.com/public_html/includes/auth.php @@ -0,0 +1,62 @@ +query("DELETE FROM login_attempts WHERE ip_address = :ip", ['ip' => $ip]); + $_SESSION['wt_admin'] = true; + session_regenerate_id(true); + return true; + } + + self::recordFailure($ip); + return false; + } + + public static function isLockedOut(string $ip): bool { + $row = db()->fetch("SELECT * FROM login_attempts WHERE ip_address = :ip", ['ip' => $ip]); + if (!$row || (int) $row['attempts'] < self::MAX_ATTEMPTS) { + return false; + } + $lockoutUntil = strtotime($row['last_attempt']) + self::LOCKOUT_MINUTES * 60; + return time() < $lockoutUntil; + } + + private static function recordFailure(string $ip): void { + $row = db()->fetch("SELECT * FROM login_attempts WHERE ip_address = :ip", ['ip' => $ip]); + if ($row) { + $lockoutUntil = strtotime($row['last_attempt']) + self::LOCKOUT_MINUTES * 60; + // Reset the counter once the previous lockout window has fully expired. + $attempts = (time() < $lockoutUntil) ? (int) $row['attempts'] + 1 : 1; + db()->update('login_attempts', ['attempts' => $attempts, 'last_attempt' => date('Y-m-d H:i:s')], 'ip_address = :ip', ['ip' => $ip]); + } else { + db()->insert('login_attempts', ['ip_address' => $ip, 'attempts' => 1, 'last_attempt' => date('Y-m-d H:i:s')]); + } + } + + public static function isLoggedIn(): bool { + return !empty($_SESSION['wt_admin']); + } + + public static function require(): void { + if (!self::isLoggedIn()) { + header('Location: /admin/login.php'); + exit; + } + } + + public static function logout(): void { + unset($_SESSION['wt_admin']); + session_regenerate_id(true); + } +} diff --git a/sites/worktracking.orbishosting.com/public_html/includes/db.php b/sites/worktracking.orbishosting.com/public_html/includes/db.php new file mode 100644 index 0000000..9f5b9b4 --- /dev/null +++ b/sites/worktracking.orbishosting.com/public_html/includes/db.php @@ -0,0 +1,57 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + } + return self::$pdo; + } + + public function query(string $sql, array $params = []): PDOStatement { + $stmt = self::get()->prepare($sql); + $stmt->execute($params); + return $stmt; + } + + public function fetch(string $sql, array $params = []): ?array { + $row = $this->query($sql, $params)->fetch(); + return $row ?: null; + } + + public function fetchAll(string $sql, array $params = []): array { + return $this->query($sql, $params)->fetchAll(); + } + + public function insert(string $table, array $data) { + $columns = implode(', ', array_keys($data)); + $placeholders = ':' . implode(', :', array_keys($data)); + $this->query("INSERT INTO {$table} ({$columns}) VALUES ({$placeholders})", $data); + return self::get()->lastInsertId(); + } + + public function update(string $table, array $data, string $where, array $whereParams = []) { + $set = []; + foreach (array_keys($data) as $column) { + $set[] = "{$column} = :{$column}"; + } + $sql = "UPDATE {$table} SET " . implode(', ', $set) . " WHERE {$where}"; + return $this->query($sql, array_merge($data, $whereParams))->rowCount(); + } +} + +function db(): Database { + static $instance = null; + if ($instance === null) { + $instance = new Database(); + } + return $instance; +} diff --git a/sites/worktracking.orbishosting.com/public_html/includes/footer.php b/sites/worktracking.orbishosting.com/public_html/includes/footer.php new file mode 100644 index 0000000..308b1d0 --- /dev/null +++ b/sites/worktracking.orbishosting.com/public_html/includes/footer.php @@ -0,0 +1,2 @@ + + diff --git a/sites/worktracking.orbishosting.com/public_html/includes/functions.php b/sites/worktracking.orbishosting.com/public_html/includes/functions.php new file mode 100644 index 0000000..caf7343 --- /dev/null +++ b/sites/worktracking.orbishosting.com/public_html/includes/functions.php @@ -0,0 +1,95 @@ +format('N'); // 1=Mon ... 5=Fri, 6=Sat, 7=Sun + if ($dow === 5) { + // Friday itself + return $d->format('Y-m-d'); + } elseif ($dow === 6) { + // Saturday - previous day is Friday + $d->modify('-1 day'); + return $d->format('Y-m-d'); + } elseif ($dow === 7) { + // Sunday - 2 days back is Friday + $d->modify('-2 days'); + return $d->format('Y-m-d'); + } else { + // Mon(1)->Fri is 3 days prior; Tue(2)->4; Wed(3)->5; Thu(4)->6 + $daysBack = $dow + 2; + $d->modify("-{$daysBack} days"); + return $d->format('Y-m-d'); + } +} + +/** + * Returns the 5 workdate strings (Y-m-d) for the week starting on $weekStart (a Friday), + * in order: Friday, Monday, Tuesday, Wednesday, Thursday. + */ +function getWeekDates(string $weekStart): array { + $fri = new DateTime($weekStart); + $dates = [$fri->format('Y-m-d')]; + $d = clone $fri; + $d->modify('+3 days'); // Monday + for ($i = 0; $i < 4; $i++) { + $dates[] = $d->format('Y-m-d'); + $d->modify('+1 day'); + } + return $dates; +} + +function dayLabel(string $dateStr): string { + $d = new DateTime($dateStr); + return $d->format('D, M j'); +} + +function currentWeekStart(): string { + return getWeekStart(date('Y-m-d')); +} + +function nextWeekStart(string $weekStart): string { + $d = new DateTime($weekStart); + $d->modify('+7 days'); + return $d->format('Y-m-d'); +} + +/** + * Compute the dollar amount owed for a set of day_entries rows. + */ +function calcOwed(array $entries): float { + $total = 0.0; + foreach ($entries as $e) { + if ($e['status'] === 'full') $total += FULL_DAY_RATE; + elseif ($e['status'] === 'half') $total += HALF_DAY_RATE; + } + return $total; +} + +function e(string $s): string { + return htmlspecialchars($s, ENT_QUOTES, 'UTF-8'); +} + +/** + * Validates a Y-m-d date string strictly (rejects malformed input that + * would otherwise throw when passed to `new DateTime()`). + */ +function isValidDateStr(?string $s): bool { + if (!$s) return false; + $d = DateTime::createFromFormat('Y-m-d', $s); + return $d !== false && $d->format('Y-m-d') === $s; +} diff --git a/sites/worktracking.orbishosting.com/public_html/includes/header.php b/sites/worktracking.orbishosting.com/public_html/includes/header.php new file mode 100644 index 0000000..5a66d96 --- /dev/null +++ b/sites/worktracking.orbishosting.com/public_html/includes/header.php @@ -0,0 +1,14 @@ + + + + + + + + + +<?= e($pageTitle) ?> + + + +
👷 ChuckCo Time Keeper
diff --git a/sites/worktracking.orbishosting.com/public_html/index.html b/sites/worktracking.orbishosting.com/public_html/index.html new file mode 100644 index 0000000..8402c68 --- /dev/null +++ b/sites/worktracking.orbishosting.com/public_html/index.html @@ -0,0 +1,85 @@ + + + + + + + + +ChuckCo Time Keeper + + + +
+
+
+
+
👷
+
ChuckCo Time Keeper
+
+ + diff --git a/sites/worktracking.orbishosting.com/public_html/p.php b/sites/worktracking.orbishosting.com/public_html/p.php new file mode 100644 index 0000000..55f6c8f --- /dev/null +++ b/sites/worktracking.orbishosting.com/public_html/p.php @@ -0,0 +1,111 @@ +fetch("SELECT * FROM workers WHERE payer_token = :t AND active = 1", ['t' => $token]) : null; + +if (!$worker) { + http_response_code(404); + echo 'Link not found.'; + exit; +} + +$markPaidError = ''; +if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'mark_paid' && isValidDateStr($_POST['week_start'] ?? '')) { + $weekStart = $_POST['week_start']; + $weekDates = getWeekDates($weekStart); + $ph = implode(',', array_fill(0, count($weekDates), '?')); + $weekEntries = db()->query("SELECT work_date FROM day_entries WHERE worker_id = ? AND work_date IN ({$ph})", array_merge([$worker['id']], $weekDates))->fetchAll(); + $filledDates = array_column($weekEntries, 'work_date'); + $missing = array_diff($weekDates, $filledDates); + + if (!empty($missing)) { + $markPaidError = 'Cannot mark paid - ' . count($missing) . ' day(s) still need an entry.'; + } else { + $existing = db()->fetch("SELECT * FROM week_settlements WHERE worker_id = :wid AND week_start = :w", ['wid' => $worker['id'], 'w' => $weekStart]); + if ($existing) { + db()->update('week_settlements', ['paid' => 1, 'paid_at' => date('Y-m-d H:i:s')], 'id = :id', ['id' => $existing['id']]); + } else { + db()->insert('week_settlements', ['worker_id' => $worker['id'], 'week_start' => $weekStart, 'paid' => 1, 'paid_at' => date('Y-m-d H:i:s')]); + } + header('Location: /p.php?t=' . urlencode($token) . '&week=' . urlencode(nextWeekStart($weekStart))); + exit; + } +} + +$weekStart = $_GET['week'] ?? ''; +$weekStart = isValidDateStr($weekStart) ? $weekStart : currentWeekStart(); +$dates = getWeekDates($weekStart); +$detail = !empty($_GET['detail']); + +$placeholders = implode(',', array_fill(0, count($dates), '?')); +$entries = db()->query("SELECT * FROM day_entries WHERE worker_id = ? AND work_date IN ({$placeholders})", array_merge([$worker['id']], $dates))->fetchAll(); +$entriesByDate = []; +foreach ($entries as $en) { $entriesByDate[$en['work_date']] = $en; } + +$owed = calcOwed($entries); +$settlement = db()->fetch("SELECT * FROM week_settlements WHERE worker_id = :wid AND week_start = :w", ['wid' => $worker['id'], 'w' => $weekStart]); +$isPaid = $settlement ? (bool) $settlement['paid'] : false; + +$hasLongReason = false; +foreach ($entriesByDate as $en) { + if (!empty($en['absence_reason']) && strlen($en['absence_reason']) > 40) $hasLongReason = true; +} + +$pageTitle = 'Week Summary (Payer) - ChuckCo Time Keeper'; +require_once __DIR__ . '/includes/header.php'; +?> +
+
+
+
+
Week of ·
+
+ + +
+
+
+ +
+ + +
40 ? substr($reason, 0, 40) . '…' : $reason)) ?>
+ +
+ +
+ + +
+ Owed + +
+ + + + + + + + +
+ + +
+ + + +
+ + +
+
+ diff --git a/sites/worktracking.orbishosting.com/public_html/s.php b/sites/worktracking.orbishosting.com/public_html/s.php new file mode 100644 index 0000000..506facf --- /dev/null +++ b/sites/worktracking.orbishosting.com/public_html/s.php @@ -0,0 +1,65 @@ +fetch("SELECT * FROM workers WHERE share_token = :t AND active = 1", ['t' => $token]) : null; + +if (!$worker) { + http_response_code(404); + echo 'Link not found.'; + exit; +} + +$weekStart = $_GET['week'] ?? ''; +$weekStart = isValidDateStr($weekStart) ? $weekStart : currentWeekStart(); +$dates = getWeekDates($weekStart); +$detail = !empty($_GET['detail']); + +$placeholders = implode(',', array_fill(0, count($dates), '?')); +$entries = db()->query("SELECT * FROM day_entries WHERE worker_id = ? AND work_date IN ({$placeholders})", array_merge([$worker['id']], $dates))->fetchAll(); +$entriesByDate = []; +foreach ($entries as $en) { $entriesByDate[$en['work_date']] = $en; } + +$hasLongReason = false; +foreach ($entriesByDate as $en) { + if (!empty($en['absence_reason']) && strlen($en['absence_reason']) > 40) $hasLongReason = true; +} + +$pageTitle = 'Week Summary - ChuckCo Time Keeper'; +require_once __DIR__ . '/includes/header.php'; +?> +
+
+
+
+
Week of
+
+ + +
+
+
+ +
+ + +
40 ? substr($reason, 0, 40) . '…' : $reason)) ?>
+ +
+ +
+ + + + + + + +
+
+ diff --git a/sites/worktracking.orbishosting.com/public_html/w-history.php b/sites/worktracking.orbishosting.com/public_html/w-history.php new file mode 100644 index 0000000..49d32c9 --- /dev/null +++ b/sites/worktracking.orbishosting.com/public_html/w-history.php @@ -0,0 +1,61 @@ +fetch("SELECT * FROM workers WHERE entry_token = :t AND active = 1", ['t' => $token]) : null; + +if (!$worker) { + http_response_code(404); + echo 'Link not found.'; + exit; +} + +$paidWeeks = db()->fetchAll(" + SELECT week_start, paid_at FROM week_settlements + WHERE worker_id = :wid AND paid = 1 + ORDER BY week_start DESC +", ['wid' => $worker['id']]); + +$pageTitle = 'My History - ChuckCo Time Keeper'; +require_once __DIR__ . '/includes/header.php'; +?> +
+

My History

+ ← Back +
+
+
+

Paid Weeks

+ +

No paid weeks yet.

+ + + query("SELECT * FROM day_entries WHERE worker_id = ? AND work_date IN ({$placeholders})", array_merge([$worker['id']], $dates))->fetchAll(); + $entriesByDate = []; + foreach ($entries as $en) { $entriesByDate[$en['work_date']] = $en; } + ?> +
+

Week of

+ +
+
+
+ +
+ +
+ +
+ +
+ + +
+
+ diff --git a/sites/worktracking.orbishosting.com/public_html/w.php b/sites/worktracking.orbishosting.com/public_html/w.php new file mode 100644 index 0000000..6cbbbd4 --- /dev/null +++ b/sites/worktracking.orbishosting.com/public_html/w.php @@ -0,0 +1,177 @@ +fetch("SELECT * FROM workers WHERE entry_token = :t AND active = 1", ['t' => $token]) : null; + +if (!$worker) { + http_response_code(404); + echo 'Link not found.'; + exit; +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'set_day') { + $date = $_POST['date'] ?? ''; + $status = $_POST['status'] ?? ''; + $reason = trim($_POST['reason'] ?? ''); + $location = trim($_POST['location'] ?? ''); + $currentWeek = currentWeekStart(); + // Only allow editing the current week's dates via this self-entry page + if (in_array($status, ['full', 'half', 'absent'], true) && in_array($date, getWeekDates($currentWeek), true)) { + db()->query(" + INSERT INTO day_entries (worker_id, work_date, week_start, status, absence_reason, location) + VALUES (:wid, :date, :week, :status, :reason, :location) + ON DUPLICATE KEY UPDATE status = :status2, absence_reason = :reason2, location = :location2 + ", [ + 'wid' => $worker['id'], 'date' => $date, 'week' => $currentWeek, + 'status' => $status, 'reason' => $reason ?: null, 'location' => $location ?: null, + 'status2' => $status, 'reason2' => $reason ?: null, 'location2' => $location ?: null, + ]); + } + header('Location: /w.php?t=' . urlencode($token)); + exit; +} + +$markPaidError = ''; +if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'mark_paid' && ($_POST['week_start'] ?? '') === currentWeekStart()) { + $weekStart = currentWeekStart(); + $weekDates = getWeekDates($weekStart); + $ph = implode(',', array_fill(0, count($weekDates), '?')); + $weekEntries = db()->query("SELECT work_date FROM day_entries WHERE worker_id = ? AND work_date IN ({$ph})", array_merge([$worker['id']], $weekDates))->fetchAll(); + $filledDates = array_column($weekEntries, 'work_date'); + $missing = array_diff($weekDates, $filledDates); + + if (!empty($missing)) { + $markPaidError = 'Cannot mark paid - ' . count($missing) . ' day(s) still need an entry.'; + } else { + $existing = db()->fetch("SELECT * FROM week_settlements WHERE worker_id = :wid AND week_start = :w", ['wid' => $worker['id'], 'w' => $weekStart]); + if ($existing) { + db()->update('week_settlements', ['paid' => 1, 'paid_at' => date('Y-m-d H:i:s')], 'id = :id', ['id' => $existing['id']]); + } else { + db()->insert('week_settlements', ['worker_id' => $worker['id'], 'week_start' => $weekStart, 'paid' => 1, 'paid_at' => date('Y-m-d H:i:s')]); + } + header('Location: /w.php?t=' . urlencode($token)); + exit; + } +} + +$currentWeek = currentWeekStart(); +$currentDates = getWeekDates($currentWeek); + +$currentSettlement = db()->fetch("SELECT * FROM week_settlements WHERE worker_id = :wid AND week_start = :w", ['wid' => $worker['id'], 'w' => $currentWeek]); +$currentWeekPaid = $currentSettlement ? (bool) $currentSettlement['paid'] : false; + +$allEntries = db()->fetchAll("SELECT * FROM day_entries WHERE worker_id = :wid ORDER BY work_date DESC", ['wid' => $worker['id']]); +$entriesByDate = []; +foreach ($allEntries as $en) { $entriesByDate[$en['work_date']] = $en; } + +// Past 2 weeks (read-only), excluding current +$pastWeeks = []; +foreach ($allEntries as $en) { + if ($en['week_start'] !== $currentWeek && !in_array($en['week_start'], $pastWeeks, true)) { + $pastWeeks[] = $en['week_start']; + } +} +rsort($pastWeeks); +$pastWeeks = array_slice($pastWeeks, 0, 2); + +$pageTitle = 'My Work Week - ChuckCo Time Keeper'; +require_once __DIR__ . '/includes/header.php'; +?> +
+

Hi,

+
+
+ +
+
+

This Week —

+ + Paid + +
+ + + +
+
+
+ +
+ +
+ +
+ +

This week is locked in. Come back next week to enter new days.

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

Week of

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