commit bf8b6225a330abcb10a00e74d9e7e0e3592ddb13 Author: Myron Blair Date: Sat May 16 23:00:37 2026 -0500 v1.0.0 - Initial backup diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..e0ec0cd --- /dev/null +++ b/.htaccess @@ -0,0 +1,58 @@ +# Tom's Java Jive - Apache Configuration + +# Enable URL rewriting +RewriteEngine On + +# 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/.*$ + +# 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 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..a2845b4 --- /dev/null +++ b/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** + - Visit: `https://yoursite.com/create-admin.php` + - Or import the default admin from schema.sql: + - Email: `admin@tomsjavajive.com` + - Password: `admin123!` + +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/account/addresses.php b/account/addresses.php new file mode 100644 index 0000000..dd568f5 --- /dev/null +++ b/account/addresses.php @@ -0,0 +1,289 @@ + $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'); + } +} + +require_once __DIR__ . '/../includes/header.php'; +require_once __DIR__ . '/includes/sidebar.php'; +?> + +
+
+

My Addresses

+

Manage your shipping and billing addresses

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

No addresses saved

+

Add an address for faster checkout.

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

+ +

+ +

+ +

+ , +

+

+ + +

+ +

+ + +
+ + + +
+ + + +
+ + +
+ + + +
+
+
+
+ +
+ + + + + + + + diff --git a/account/includes/footer.php b/account/includes/footer.php new file mode 100644 index 0000000..ea85462 --- /dev/null +++ b/account/includes/footer.php @@ -0,0 +1,6 @@ + + + + + + diff --git a/account/includes/sidebar.php b/account/includes/sidebar.php new file mode 100644 index 0000000..2f6bd28 --- /dev/null +++ b/account/includes/sidebar.php @@ -0,0 +1,132 @@ + + + + +
+
+ +
+ + + + + diff --git a/admin/includes/header.php b/admin/includes/header.php new file mode 100644 index 0000000..537255e --- /dev/null +++ b/admin/includes/header.php @@ -0,0 +1,166 @@ + + + + + + + <?= $pageTitle ?? 'Admin' ?> - Tom's Java Jive Admin + + + + + + + + + +
+ + + + +
+
+ + + + +
+
+ + + + +
+
+
+ +
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
diff --git a/admin/index.php b/admin/index.php new file mode 100644 index 0000000..0dcbaa6 --- /dev/null +++ b/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/admin/integrations.php b/admin/integrations.php new file mode 100644 index 0000000..d30f70f --- /dev/null +++ b/admin/integrations.php @@ -0,0 +1,523 @@ + ['sendgrid_api_key', 'sendgrid_from_email', 'sendgrid_from_name', 'email_notifications_enabled'], + 'twilio' => ['twilio_account_sid', 'twilio_auth_token', 'twilio_phone_number', 'sms_notifications_enabled'], + 'push' => ['vapid_public_key', 'vapid_private_key', 'push_notifications_enabled'], + 'loyalty' => ['loyalty_enabled'] + ]; + + if (isset($settingsMap[$section])) { + foreach ($settingsMap[$section] as $key) { + $value = $_POST[$key] ?? ''; + + // Check if setting exists + $existing = db()->fetch("SELECT id FROM settings WHERE setting_key = :key", ['key' => $key]); + + if ($existing) { + db()->query( + "UPDATE settings SET setting_value = :value, updated_at = NOW() WHERE setting_key = :key", + ['value' => $value, 'key' => $key] + ); + } else { + db()->insert('settings', [ + 'setting_key' => $key, + 'setting_value' => $value + ]); + } + } + + setFlash('success', ucfirst($section) . ' settings saved successfully!'); + } + + redirect('/admin/integrations.php'); +} + +// Load current settings +$settings = []; +$allSettings = db()->fetchAll("SELECT setting_key, setting_value FROM settings"); +foreach ($allSettings as $s) { + $settings[$s['setting_key']] = $s['setting_value']; +} +?> + + + + + + +
+ +
+ + + +
+
+
+
+ +
+
+

SendGrid Email

+

+ Send transactional emails (order confirmations, shipping updates, etc.) +

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

+ Get your API key from + SendGrid Dashboard +

+
+
+ +
+
+ + +
+
+ + "> +
+
+ +
+ +
+ +
+ + +
+
+
+
+ + +
+
+
+
+ +
+
+

Twilio SMS

+

+ Send SMS notifications for orders, shipping updates, and promotions +

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

+ Get your credentials from + Twilio Console +

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

Push Notifications

+

+ Web push notifications for order updates and promotions +

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

+ Generate VAPID keys at + Web Push Codelab +

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

Loyalty Program

+

+ Reward customers with points and tiers (Bronze, Silver, Gold, Platinum) +

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

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/admin/inventory.php b/admin/inventory.php new file mode 100644 index 0000000..eef142d --- /dev/null +++ b/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/admin/login.php b/admin/login.php new file mode 100644 index 0000000..4c1f4b7 --- /dev/null +++ b/admin/login.php @@ -0,0 +1,68 @@ + + + + + + +Admin Login โ€” Tom's Java Jive + + + + +
+ + +
โš 
+ +
+ + + + + +
+ โ† Back to Store +
+ + diff --git a/admin/logout.php b/admin/logout.php new file mode 100644 index 0000000..db5e9b3 --- /dev/null +++ b/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; + } + } +} + +$items = json_decode($order['items'], true) ?? []; +$shippingAddress = json_decode($order['shipping_address'], true) ?? []; + +$statuses = ['pending', 'confirmed', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded']; +?> + + + + +
+ + +
+ +
+ +
+
+

Order Items

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + 0): ?> + + + + + + 0): ?> + + + + + + 0): ?> + + + + + + + + + + +
ProductPriceQtyTotal
+ + + + + + + +
Subtotal
Shipping
Tax
Discount-
Total
+
+
+ + +
+
+

Order Notes

+
+
+ +
+ +

No notes yet.

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

Order Status

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

Customer

+
+
+

+

+ +

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

Shipping Address

+
+
+

+
+ , + + +

+
+
+ + + +
+
+

Payment

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

Timeline

+
+
+
+ Created + +
+ +
+ Updated + +
+ +
+
+
+
+ + diff --git a/admin/orders.php b/admin/orders.php new file mode 100644 index 0000000..e03091c --- /dev/null +++ b/admin/orders.php @@ -0,0 +1,279 @@ + $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 :search OR customer_name LIKE :search OR customer_email LIKE :search)'; + $params['search'] = '%' . $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/admin/payments.php b/admin/payments.php new file mode 100644 index 0000000..2c96e61 --- /dev/null +++ b/admin/payments.php @@ -0,0 +1,169 @@ + 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 === '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' => '' +]); + +$methods = getSetting('payment_methods', [ + 'card' => true, + 'cash' => true, + 'wallet' => true, + 'gift_card' => true +]); +?> + + + + +
+ + +
+ + +
+ +
+ +
+
+

Stripe

+
+
+
+ +
+ +
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + + 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/admin/pos.php b/admin/pos.php new file mode 100644 index 0000000..44b018f --- /dev/null +++ b/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/admin/product-edit.php b/admin/product-edit.php new file mode 100644 index 0000000..9876b22 --- /dev/null +++ b/admin/product-edit.php @@ -0,0 +1,293 @@ +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, + '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

+
+
+
+ + + Enter full URLs to product images +
+ + +
+ + Product image + +
+ +
+
+
+ + +
+
+
+

Status

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

Inventory

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

Identifiers

+
+
+
+ + +
+ +
+ + +
+
+
+ + +
+
+
+ + diff --git a/admin/products.php b/admin/products.php new file mode 100644 index 0000000..844432c --- /dev/null +++ b/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/admin/reviews.php b/admin/reviews.php new file mode 100644 index 0000000..13f9225 --- /dev/null +++ b/admin/reviews.php @@ -0,0 +1,198 @@ +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 === '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/admin/settings.php b/admin/settings.php new file mode 100644 index 0000000..a194871 --- /dev/null +++ b/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/admin/shipping.php b/admin/shipping.php new file mode 100644 index 0000000..de20dae --- /dev/null +++ b/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/admin/users.php b/admin/users.php new file mode 100644 index 0000000..eb609c8 --- /dev/null +++ b/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/api/cart.php b/api/cart.php new file mode 100644 index 0000000..990de9c --- /dev/null +++ b/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/api/create-checkout-session.php b/api/create-checkout-session.php new file mode 100644 index 0000000..4406b34 --- /dev/null +++ b/api/create-checkout-session.php @@ -0,0 +1,119 @@ + 'Method not allowed'], 405); +} + +$input = json_decode(file_get_contents('php://input'), true); +$orderId = $input['order_id'] ?? ''; +$originUrl = $input['origin_url'] ?? ''; + +if (empty($orderId)) { + jsonResponse(['error' => 'Order ID required'], 400); +} + +if (empty($originUrl)) { + $originUrl = SITE_URL; +} + +// Get order +$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); +} + +// Check if Stripe is configured +if (!isStripeConfigured()) { + // Demo mode - simulate successful payment + db()->update('orders', + [ + 'payment_status' => 'paid', + 'order_status' => 'confirmed', + 'stripe_payment_intent' => 'demo_' . bin2hex(random_bytes(8)) + ], + 'order_id = :id', + ['id' => $orderId] + ); + + jsonResponse([ + 'demo_mode' => true, + 'message' => 'Payment simulated (Stripe not configured)', + 'redirect' => '/order-confirmation.php?order=' . $orderId + ]); +} + +// Build line items from order +$items = json_decode($order['items'], true) ?? []; +$lineItems = []; + +foreach ($items as $item) { + $lineItems[] = [ + 'name' => $item['name'], + 'price' => floatval($item['price']), + 'quantity' => intval($item['quantity']), + 'currency' => 'usd' + ]; +} + +// Add shipping if applicable +if ($order['shipping_cost'] > 0) { + $lineItems[] = [ + 'name' => 'Shipping', + 'price' => floatval($order['shipping_cost']), + 'quantity' => 1, + 'currency' => 'usd' + ]; +} + +// Build success/cancel URLs +$successUrl = rtrim($originUrl, '/') . '/order-confirmation.php?order=' . $orderId . '&session_id={CHECKOUT_SESSION_ID}'; +$cancelUrl = rtrim($originUrl, '/') . '/payment.php?order=' . $orderId . '&cancelled=1'; + +try { + $session = stripe()->createCheckoutSession( + $lineItems, + $successUrl, + $cancelUrl, + [ + 'customer_email' => $order['customer_email'], + 'metadata' => [ + 'order_id' => $orderId, + 'order_number' => $order['order_number'] + ] + ] + ); + + // Store checkout session ID + db()->update('orders', + ['stripe_checkout_session' => $session['id']], + 'order_id = :id', + ['id' => $orderId] + ); + + jsonResponse([ + 'url' => $session['url'], + 'session_id' => $session['id'] + ]); + +} catch (Exception $e) { + error_log('Stripe Checkout error: ' . $e->getMessage()); + jsonResponse(['error' => 'Failed to create checkout session: ' . $e->getMessage()], 500); +} diff --git a/api/create-payment-intent.php b/api/create-payment-intent.php new file mode 100644 index 0000000..ae8ca5a --- /dev/null +++ b/api/create-payment-intent.php @@ -0,0 +1,87 @@ + 'Method not allowed'], 405); +} + +$input = json_decode(file_get_contents('php://input'), true); +$orderId = $input['order_id'] ?? ''; + +if (empty($orderId)) { + jsonResponse(['error' => 'Order ID required'], 400); +} + +// Get order +$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); +} + +// Check if Stripe is configured +if (!isStripeConfigured()) { + // Demo mode - simulate successful payment + db()->update('orders', + [ + 'payment_status' => 'paid', + 'order_status' => 'confirmed', + 'stripe_payment_intent' => 'demo_' . bin2hex(random_bytes(8)) + ], + 'order_id = :id', + ['id' => $orderId] + ); + + jsonResponse([ + 'demo_mode' => true, + 'message' => 'Payment simulated (Stripe not configured)', + 'redirect' => '/order-confirmation.php?order=' . $orderId + ]); +} + +// Create Stripe Payment Intent using cURL-based API +try { + $paymentIntent = stripe()->createPaymentIntent( + $order['total'], + 'usd', + [ + 'metadata' => [ + 'order_id' => $orderId, + 'order_number' => $order['order_number'] + ], + 'receipt_email' => $order['customer_email'], + 'description' => 'Order #' . $order['order_number'] + ] + ); + + // Store payment intent ID + db()->update('orders', + ['stripe_payment_intent' => $paymentIntent['id']], + 'order_id = :id', + ['id' => $orderId] + ); + + jsonResponse([ + 'client_secret' => $paymentIntent['client_secret'] + ]); + +} catch (Exception $e) { + error_log('Stripe error: ' . $e->getMessage()); + jsonResponse(['error' => 'Payment initialization failed: ' . $e->getMessage()], 500); +} diff --git a/api/delete-account.php b/api/delete-account.php new file mode 100644 index 0000000..83c31c3 --- /dev/null +++ b/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/api/loyalty.php b/api/loyalty.php new file mode 100644 index 0000000..4224f3d --- /dev/null +++ b/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/api/orders.php b/api/orders.php new file mode 100644 index 0000000..4fef865 --- /dev/null +++ b/api/orders.php @@ -0,0 +1,174 @@ +fetch( + "SELECT * FROM orders WHERE order_id = :id", + ['id' => $orderId] + ); + + 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); + } 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') { + // Admin check would go here + $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/api/payment-status.php b/api/payment-status.php new file mode 100644 index 0000000..4e97c76 --- /dev/null +++ b/api/payment-status.php @@ -0,0 +1,136 @@ + 'Method not allowed'], 405); +} + +$orderId = $_GET['order_id'] ?? ''; +$sessionId = $_GET['session_id'] ?? ''; + +if (empty($orderId) && empty($sessionId)) { + jsonResponse(['error' => 'Order ID or Session ID required'], 400); +} + +// Get order by ID or session +if (!empty($orderId)) { + $order = db()->fetch( + "SELECT * FROM orders WHERE order_id = :id", + ['id' => $orderId] + ); +} else { + $order = db()->fetch( + "SELECT * FROM orders WHERE stripe_checkout_session = :session OR stripe_payment_intent = :session", + ['session' => $sessionId] + ); +} + +if (!$order) { + jsonResponse(['error' => 'Order not found'], 404); +} + +// If already marked as paid, return success +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'] + ]); +} + +// Check if Stripe is configured +if (!isStripeConfigured()) { + jsonResponse([ + 'status' => 'demo_mode', + 'payment_status' => $order['payment_status'], + 'message' => 'Stripe not configured - running in demo mode' + ]); +} + +try { + // Check with Stripe + if (!empty($order['stripe_checkout_session'])) { + // Check checkout session status + $session = stripe()->getCheckoutSession($order['stripe_checkout_session']); + + if ($session['payment_status'] === 'paid') { + // Update order + db()->update('orders', + [ + 'payment_status' => 'paid', + 'order_status' => 'confirmed', + 'stripe_payment_intent' => $session['payment_intent'] ?? null + ], + 'order_id = :id', + ['id' => $order['order_id']] + ); + + jsonResponse([ + 'status' => 'complete', + 'payment_status' => 'paid', + 'order_id' => $order['order_id'], + 'order_number' => $order['order_number'], + 'redirect' => '/order-confirmation.php?order=' . $order['order_id'] + ]); + } + + jsonResponse([ + 'status' => $session['status'], + 'payment_status' => $session['payment_status'] + ]); + + } elseif (!empty($order['stripe_payment_intent'])) { + // Check payment intent status + $paymentIntent = stripe()->getPaymentIntent($order['stripe_payment_intent']); + + if ($paymentIntent['status'] === 'succeeded') { + // Update order + db()->update('orders', + [ + 'payment_status' => 'paid', + 'order_status' => 'confirmed' + ], + 'order_id = :id', + ['id' => $order['order_id']] + ); + + jsonResponse([ + 'status' => 'complete', + 'payment_status' => 'paid', + 'order_id' => $order['order_id'], + 'order_number' => $order['order_number'], + 'redirect' => '/order-confirmation.php?order=' . $order['order_id'] + ]); + } + + jsonResponse([ + 'status' => $paymentIntent['status'], + 'payment_status' => 'pending' + ]); + } + + // No Stripe reference found + jsonResponse([ + 'status' => 'pending', + 'payment_status' => $order['payment_status'] + ]); + +} catch (Exception $e) { + error_log('Payment status check error: ' . $e->getMessage()); + jsonResponse([ + 'status' => 'error', + 'payment_status' => $order['payment_status'], + 'error' => 'Failed to check payment status' + ]); +} diff --git a/api/pos-order.php b/api/pos-order.php new file mode 100644 index 0000000..398dc43 --- /dev/null +++ b/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/api/products.php b/api/products.php new file mode 100644 index 0000000..be9abae --- /dev/null +++ b/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/api/push-subscribe.php b/api/push-subscribe.php new file mode 100644 index 0000000..ca1c219 --- /dev/null +++ b/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/api/redeem-gift-card.php b/api/redeem-gift-card.php new file mode 100644 index 0000000..eed231c --- /dev/null +++ b/api/redeem-gift-card.php @@ -0,0 +1,97 @@ + '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); + +$code = strtoupper(str_replace(['-', ' '], '', trim($input['code'] ?? ''))); + +if (empty($code) || strlen($code) < 8) { + jsonResponse(['error' => 'Invalid gift card code'], 400); +} + +// Find gift card +$giftCard = db()->fetch( + "SELECT * FROM gift_cards WHERE code = :code AND is_active = 1", + ['code' => $code] +); + +if (!$giftCard) { + jsonResponse(['error' => 'Gift card not found or already used'], 404); +} + +if ($giftCard['balance'] <= 0) { + jsonResponse(['error' => 'This gift card has no remaining balance'], 400); +} + +if ($giftCard['expires_at'] && strtotime($giftCard['expires_at']) < time()) { + jsonResponse(['error' => 'This gift card has expired'], 400); +} + +$amount = $giftCard['balance']; + +try { + // Start transaction + db()->query("START TRANSACTION"); + + // Update gift card balance to 0 + db()->query( + "UPDATE gift_cards SET balance = 0, is_active = 0, updated_at = NOW() WHERE gift_card_id = :id", + ['id' => $giftCard['gift_card_id']] + ); + + // Log gift card transaction + db()->insert('gift_card_transactions', [ + 'gift_card_id' => $giftCard['gift_card_id'], + 'amount' => -$amount, + 'balance_after' => 0, + 'type' => 'redeem', + 'description' => 'Redeemed by customer: ' . $customer['email'] + ]); + + // Add to customer wallet + $newWalletBalance = ($customer['wallet_balance'] ?? 0) + $amount; + + db()->query( + "UPDATE customers SET wallet_balance = :balance, updated_at = NOW() WHERE customer_id = :id", + ['balance' => $newWalletBalance, 'id' => $customer['customer_id']] + ); + + // Log wallet transaction + db()->insert('wallet_transactions', [ + 'transaction_id' => generateId('wt_'), + 'customer_id' => $customer['customer_id'], + 'amount' => $amount, + 'balance_after' => $newWalletBalance, + 'type' => 'gift_card', + 'description' => 'Gift card redeemed: ' . $code + ]); + + db()->query("COMMIT"); + + jsonResponse([ + 'success' => true, + 'amount' => $amount, + 'new_balance' => $newWalletBalance, + 'message' => formatCurrency($amount) . ' has been added to your wallet!' + ]); + +} catch (Exception $e) { + db()->query("ROLLBACK"); + jsonResponse(['error' => 'Failed to redeem gift card. Please try again.'], 500); +} diff --git a/api/search-customers.php b/api/search-customers.php new file mode 100644 index 0000000..e52bbd3 --- /dev/null +++ b/api/search-customers.php @@ -0,0 +1,25 @@ +fetchAll( + "SELECT customer_id, email, name, phone, wallet_balance, reward_points + FROM customers + WHERE (email LIKE :q OR name LIKE :q OR phone LIKE :q) AND is_active = 1 + ORDER BY name ASC + LIMIT 20", + ['q' => '%' . $query . '%'] +); + +jsonResponse($customers); diff --git a/api/submit-review.php b/api/submit-review.php new file mode 100644 index 0000000..cbb54be --- /dev/null +++ b/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, + 'content' => $content, + 'status' => 'pending' // Reviews require admin approval +]); + +jsonResponse([ + 'success' => true, + 'message' => 'Review submitted successfully. It will be visible after approval.', + 'review_id' => $reviewId +]); diff --git a/api/subscribe.php b/api/subscribe.php new file mode 100644 index 0000000..a6e5808 --- /dev/null +++ b/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/api/test-notification.php b/api/test-notification.php new file mode 100644 index 0000000..abd90f6 --- /dev/null +++ b/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 = sendEmail()->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/api/validate-coupon.php b/api/validate-coupon.php new file mode 100644 index 0000000..957c697 --- /dev/null +++ b/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/api/webhook.php b/api/webhook.php new file mode 100644 index 0000000..2bd72c4 --- /dev/null +++ b/api/webhook.php @@ -0,0 +1,147 @@ +verifyWebhookSignature($payload, $sigHeader, STRIPE_WEBHOOK_SECRET); + $event = json_decode($payload, true); + } catch (Exception $e) { + error_log('Stripe webhook signature verification failed: ' . $e->getMessage()); + http_response_code(400); + exit(); + } +} else { + $event = json_decode($payload, true); + if (!$event) { + http_response_code(400); + exit(); + } +} + +$eventType = $event['type'] ?? ''; +$data = $event['data']['object'] ?? []; + +switch ($eventType) { + case 'payment_intent.succeeded': + $paymentIntentId = $data['id'] ?? ''; + $orderId = $data['metadata']['order_id'] ?? ''; + + if ($orderId) { + db()->update('orders', + [ + 'payment_status' => 'paid', + 'order_status' => 'confirmed' + ], + 'order_id = :id', + ['id' => $orderId] + ); + + // Send confirmation email + $order = db()->fetch("SELECT * FROM orders WHERE order_id = :id", ['id' => $orderId]); + if ($order) { + sendOrderConfirmationEmail($order); + } + } + break; + + case 'payment_intent.payment_failed': + $orderId = $data['metadata']['order_id'] ?? ''; + if ($orderId) { + db()->update('orders', + ['payment_status' => 'failed'], + 'order_id = :id', + ['id' => $orderId] + ); + } + break; + + case 'charge.refunded': + $paymentIntentId = $data['payment_intent'] ?? ''; + if ($paymentIntentId) { + db()->update('orders', + [ + 'payment_status' => 'refunded', + 'order_status' => 'refunded' + ], + 'stripe_payment_intent = :pi', + ['pi' => $paymentIntentId] + ); + } + break; +} + +http_response_code(200); +echo json_encode(['received' => true]); + +/** + * Send order confirmation email + */ +function sendOrderConfirmationEmail($order) { + $items = json_decode($order['items'], true) ?? []; + $shippingAddress = json_decode($order['shipping_address'], true) ?? []; + + $itemsHtml = ''; + foreach ($items as $item) { + $itemsHtml .= sprintf( + '%s x%d$%.2f', + htmlspecialchars($item['name']), + $item['quantity'], + $item['total'] + ); + } + + $html = << +
+

Tom's Java Jive

+
+ +
+

Order Confirmed!

+

Thank you for your order, {$order['customer_name']}!

+ +
+

Order #: {$order['order_number']}

+

Total: \${$order['total']}

+
+ +

Order Details

+ + {$itemsHtml} + + + + +
Total\${$order['total']}
+ +

Shipping To

+

+ {$shippingAddress['address']}
+ {$shippingAddress['city']}, {$shippingAddress['state']} {$shippingAddress['zip']} +

+ +

+ We'll send you tracking information once your order ships. +

+
+ +
+

Tom's Java Jive | Premium Coffee

+
+ + HTML; + + sendEmail($order['customer_email'], "Order Confirmed - #{$order['order_number']}", $html); +} diff --git a/api/wishlist.php b/api/wishlist.php new file mode 100644 index 0000000..3f3565a --- /dev/null +++ b/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/assets/css/admin.css b/assets/css/admin.css new file mode 100644 index 0000000..6d71986 --- /dev/null +++ b/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/assets/css/style.css b/assets/css/style.css new file mode 100644 index 0000000..83d4f5a --- /dev/null +++ b/assets/css/style.css @@ -0,0 +1,789 @@ +/** + * 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: 45px; + 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; +} + +/* 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; +} + +/* 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); +} + +/* 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 { + 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; +} + +/* 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; +} + +/* 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); +} + +/* 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); +} + +/* Footer */ +.footer { + background: var(--color-text); + color: white; + padding: 4rem 0 2rem; +} + +.footer-grid { + display: grid; + grid-template-columns: 2fr 1fr 1fr 1fr; + gap: 3rem; + margin-bottom: 3rem; +} + +.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.75rem; +} + +.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) { + .hero h1 { + font-size: 2.5rem; + } + + .hero p { + font-size: 1.125rem; + } + + .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; + } + + .newsletter-form { + flex-direction: column; + } +} + +@media (max-width: 480px) { + .hero h1 { + font-size: 2rem; + } + + .product-grid { + grid-template-columns: 1fr; + } +} diff --git a/assets/icons/badge-72.svg b/assets/icons/badge-72.svg new file mode 100644 index 0000000..b26a584 --- /dev/null +++ b/assets/icons/badge-72.svg @@ -0,0 +1,5 @@ + + + + TJ + \ No newline at end of file diff --git a/assets/icons/icon-128.svg b/assets/icons/icon-128.svg new file mode 100644 index 0000000..6aeb221 --- /dev/null +++ b/assets/icons/icon-128.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/assets/icons/icon-144.svg b/assets/icons/icon-144.svg new file mode 100644 index 0000000..26bd20c --- /dev/null +++ b/assets/icons/icon-144.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/assets/icons/icon-152.svg b/assets/icons/icon-152.svg new file mode 100644 index 0000000..39a72d1 --- /dev/null +++ b/assets/icons/icon-152.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/assets/icons/icon-192.png b/assets/icons/icon-192.png new file mode 100644 index 0000000..764bac8 --- /dev/null +++ b/assets/icons/icon-192.png @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/assets/icons/icon-192.svg b/assets/icons/icon-192.svg new file mode 100644 index 0000000..2d3676b --- /dev/null +++ b/assets/icons/icon-192.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/assets/icons/icon-384.svg b/assets/icons/icon-384.svg new file mode 100644 index 0000000..275b40f --- /dev/null +++ b/assets/icons/icon-384.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/assets/icons/icon-512.png b/assets/icons/icon-512.png new file mode 100644 index 0000000..f79da4b --- /dev/null +++ b/assets/icons/icon-512.png @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/assets/icons/icon-512.svg b/assets/icons/icon-512.svg new file mode 100644 index 0000000..a25dcb0 --- /dev/null +++ b/assets/icons/icon-512.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/assets/icons/icon-72.svg b/assets/icons/icon-72.svg new file mode 100644 index 0000000..708eaf1 --- /dev/null +++ b/assets/icons/icon-72.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/assets/icons/icon-96.svg b/assets/icons/icon-96.svg new file mode 100644 index 0000000..2b3fe58 --- /dev/null +++ b/assets/icons/icon-96.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/assets/images/coffee-beans.jpg b/assets/images/coffee-beans.jpg new file mode 100644 index 0000000..877b17e Binary files /dev/null and b/assets/images/coffee-beans.jpg differ diff --git a/assets/images/coffee-brewing.jpg b/assets/images/coffee-brewing.jpg new file mode 100644 index 0000000..ff76a37 Binary files /dev/null and b/assets/images/coffee-brewing.jpg differ diff --git a/assets/images/favicon.ico b/assets/images/favicon.ico new file mode 100644 index 0000000..7d936e0 --- /dev/null +++ b/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/assets/images/friends-coffee.jpg b/assets/images/friends-coffee.jpg new file mode 100644 index 0000000..16f770e Binary files /dev/null and b/assets/images/friends-coffee.jpg differ diff --git a/assets/images/hero-coffee.jpg b/assets/images/hero-coffee.jpg new file mode 100644 index 0000000..8adc5e9 Binary files /dev/null and b/assets/images/hero-coffee.jpg differ diff --git a/assets/images/logo-icon.png b/assets/images/logo-icon.png new file mode 100644 index 0000000..7d936e0 --- /dev/null +++ b/assets/images/logo-icon.png @@ -0,0 +1,312 @@ + + + + + + + + + Tom's Java Jive | Premium Coffee Beans & Fresh Roasted Coffee | Weatherford TX + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +

+ Made with Emergent +

+
+ + + diff --git a/assets/images/logo-icon.svg b/assets/images/logo-icon.svg new file mode 100644 index 0000000..5c768c2 --- /dev/null +++ b/assets/images/logo-icon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/images/logo.png b/assets/images/logo.png new file mode 100644 index 0000000..5680c6a Binary files /dev/null and b/assets/images/logo.png differ diff --git a/assets/images/logo.svg b/assets/images/logo.svg new file mode 100644 index 0000000..b4fd11a --- /dev/null +++ b/assets/images/logo.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + Tom's + Java Jive + \ No newline at end of file diff --git a/assets/images/placeholder-product.svg b/assets/images/placeholder-product.svg new file mode 100644 index 0000000..d6a9813 --- /dev/null +++ b/assets/images/placeholder-product.svg @@ -0,0 +1,7 @@ + + + + + + Product Image + \ No newline at end of file diff --git a/assets/images/premium-coffee.jpg b/assets/images/premium-coffee.jpg new file mode 100644 index 0000000..4632c9e Binary files /dev/null and b/assets/images/premium-coffee.jpg differ diff --git a/assets/js/main.js b/assets/js/main.js new file mode 100644 index 0000000..17eb693 --- /dev/null +++ b/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/cart.php b/cart.php new file mode 100644 index 0000000..3acfcdb --- /dev/null +++ b/cart.php @@ -0,0 +1,172 @@ + $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/checkout.php b/checkout.php new file mode 100644 index 0000000..3eb5951 --- /dev/null +++ b/checkout.php @@ -0,0 +1,338 @@ + $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; + +// Get Stripe publishable key +$stripeKey = STRIPE_PUBLISHABLE_KEY; + +$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'; + + 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, + 'total' => $total, + 'shipping_address' => json_encode([ + 'address' => $address, + 'city' => $city, + 'state' => $state, + 'zip' => $zip, + 'country' => 'USA' + ]), + 'shipping_method' => 'standard', + 'payment_method' => 'stripe', + '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); + } +} + +require_once __DIR__ . '/includes/header.php'; +?> + +
+
+

Checkout

+ +
+
+ + +
+ +
+
+

Contact Information

+
+
+ +

Logged in as

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

+ Already have an account? Sign in +

+ +
+
+ + +
+
+

Shipping Address

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

Order Notes (Optional)

+
+
+ +
+
+
+ + +
+
+

Order Summary

+
+
+ +
+ +
+ +
+

+

+ x +

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

+ Secure checkout powered by Stripe +

+
+
+
+
+
+
+ + diff --git a/config/config.php b/config/config.php new file mode 100644 index 0000000..471c53b --- /dev/null +++ b/config/config.php @@ -0,0 +1,62 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, +]); diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..cdec1f2 --- /dev/null +++ b/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/includes/auth.php b/includes/auth.php new file mode 100644 index 0000000..d39e68c --- /dev/null +++ b/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/includes/db.php b/includes/db.php new file mode 100644 index 0000000..b77ac6f --- /dev/null +++ b/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/includes/email.php b/includes/email.php new file mode 100644 index 0000000..8758a7a --- /dev/null +++ b/includes/email.php @@ -0,0 +1,369 @@ +apiKey = getSetting('sendgrid_api_key', 'YOUR_SENDGRID_API_KEY_HERE'); + $this->fromEmail = getSetting('sendgrid_from_email', 'noreply@tomsjavajive.com'); + $this->fromName = getSetting('sendgrid_from_name', "Tom's Java Jive"); + } + + /** + * Send email via SendGrid API + */ + public function send(string $to, string $subject, string $htmlContent, ?string $textContent = null): array { + $data = [ + 'personalizations' => [ + [ + 'to' => [['email' => $to]], + 'subject' => $subject + ] + ], + 'from' => [ + 'email' => $this->fromEmail, + 'name' => $this->fromName + ], + 'content' => [] + ]; + + if ($textContent) { + $data['content'][] = ['type' => 'text/plain', 'value' => $textContent]; + } + $data['content'][] = ['type' => 'text/html', 'value' => $htmlContent]; + + $ch = curl_init('https://api.sendgrid.com/v3/mail/send'); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => json_encode($data), + CURLOPT_HTTPHEADER => [ + 'Authorization: Bearer ' . $this->apiKey, + 'Content-Type: application/json' + ], + 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]; + } + + // SendGrid returns 202 for accepted + if ($httpCode >= 200 && $httpCode < 300) { + return ['success' => true]; + } + + return ['success' => false, 'error' => $response, 'code' => $httpCode]; + } + + /** + * Send order confirmation email + */ + 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' => $order['order_number'], + 'customer_name' => $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 + ); + } + + /** + * Send shipping notification email + */ + public function sendShippingNotification(array $order): array { + $html = $this->getTemplate('shipping_notification', [ + 'order_number' => $order['order_number'], + 'customer_name' => $order['customer_name'] ?? 'Valued Customer', + 'tracking_number' => $order['tracking_number'], + 'tracking_url' => $order['tracking_url'] ?? '#', + 'carrier' => $order['shipping_carrier'] ?? 'Our shipping partner' + ]); + + return $this->send( + $order['customer_email'], + "Your Order Has Shipped - #{$order['order_number']}", + $html + ); + } + + /** + * Send password reset email + */ + 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' => $name ?: 'there', + 'reset_url' => $resetUrl, + 'expires' => '1 hour' + ]); + + return $this->send( + $email, + "Reset Your Password - Tom's Java Jive", + $html + ); + } + + /** + * Send welcome email to new customer + */ + public function sendWelcome(string $email, string $name = ''): array { + $html = $this->getTemplate('welcome', [ + 'customer_name' => $name ?: 'Coffee Lover', + 'shop_url' => SITE_URL . '/shop.php' + ]); + + return $this->send( + $email, + "Welcome to Tom's Java Jive!", + $html + ); + } + + /** + * Send abandoned cart reminder + */ + 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 + ); + } + + /** + * Get email template with variables replaced + */ + private function getTemplate(string $name, array $vars = []): string { + $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}}

+ +

+ If you have any questions, reply to this email or visit our website. +

+
+
+

© ' . date('Y') . ' 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 to you.

+ +
+

Tracking Number

+

{{tracking_number}}

+

Carrier: {{carrier}}

+
+ +
+ Track Your Package +
+ +

+ Please allow 24-48 hours for tracking information to update. +

+
+
+

© ' . date('Y') . ' 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 password:

+ +
+ Reset Password +
+ +

+ This link will expire in {{expires}}. If you didn\'t request this, you can safely ignore this email. +

+
+
+

© ' . date('Y') . ' 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
  • +
+ +
+ Start Shopping +
+ +

Cheers,
The Tom\'s Java Jive Team

+
+
+

© ' . date('Y') . ' 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}} +
+
+ +
+ Complete Your Order +
+ +

+ Need help? Just reply to this email and we\'ll assist you! +

+
+
+

© ' . date('Y') . ' 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; + } +} + +// Helper function for easy access +function sendEmail(): SendGridEmail { + static $instance = null; + if ($instance === null) { + $instance = new SendGridEmail(); + } + return $instance; +} diff --git a/includes/footer.php b/includes/footer.php new file mode 100644 index 0000000..5e33b6c --- /dev/null +++ b/includes/footer.php @@ -0,0 +1,79 @@ + + + + + + + + + + + + + diff --git a/includes/functions.php b/includes/functions.php new file mode 100644 index 0000000..7a13742 --- /dev/null +++ b/includes/functions.php @@ -0,0 +1,378 @@ + 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 ''; + + $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 using SendGrid + */ +function sendEmail($to, $subject, $htmlContent, $textContent = '') { + if (empty(SENDGRID_API_KEY)) { + return false; + } + + $data = [ + 'personalizations' => [ + [ + 'to' => [['email' => $to]], + 'subject' => $subject + ] + ], + 'from' => [ + 'email' => SENDER_EMAIL, + 'name' => SENDER_NAME + ], + 'content' => [ + ['type' => 'text/html', 'value' => $htmlContent] + ] + ]; + + if ($textContent) { + array_unshift($data['content'], ['type' => 'text/plain', 'value' => $textContent]); + } + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, 'https://api.sendgrid.com/v3/mail/send'); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data)); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Authorization: Bearer ' . SENDGRID_API_KEY, + 'Content-Type: application/json' + ]); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + return $httpCode >= 200 && $httpCode < 300; +} + +/** + * Log activity + */ +function logActivity($action, $details = [], $userId = null) { + // Implement activity logging if needed +} diff --git a/includes/header.php b/includes/header.php new file mode 100644 index 0000000..8ed8c92 --- /dev/null +++ b/includes/header.php @@ -0,0 +1,88 @@ + + + + + + + <?= $pageTitle ?? SITE_NAME ?> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
diff --git a/includes/loyalty.php b/includes/loyalty.php new file mode 100644 index 0000000..2988c99 --- /dev/null +++ b/includes/loyalty.php @@ -0,0 +1,438 @@ + [ + 'name' => 'Bronze Bean', + 'min_points' => 0, + 'multiplier' => 1.0, + 'benefits' => [ + 'Earn 1 point per $1 spent', + 'Birthday reward', + 'Member-only offers' + ], + 'color' => '#CD7F32', + 'icon' => 'fa-coffee' + ], + 'silver' => [ + 'name' => 'Silver Roast', + 'min_points' => 500, + 'multiplier' => 1.25, + 'benefits' => [ + 'Earn 1.25 points per $1 spent', + 'Free shipping on orders $25+', + 'Early access to new products', + 'Double points weekends' + ], + 'color' => '#C0C0C0', + 'icon' => 'fa-mug-hot' + ], + 'gold' => [ + 'name' => 'Gold Blend', + 'min_points' => 1500, + 'multiplier' => 1.5, + 'benefits' => [ + 'Earn 1.5 points per $1 spent', + 'Free shipping on all orders', + 'Exclusive Gold-only products', + 'Priority customer support', + 'Quarterly free coffee sample' + ], + 'color' => '#FFD700', + 'icon' => 'fa-crown' + ], + 'platinum' => [ + 'name' => 'Platinum Reserve', + 'min_points' => 5000, + 'multiplier' => 2.0, + 'benefits' => [ + 'Earn 2 points per $1 spent', + 'Free express shipping', + 'VIP early access to everything', + 'Annual free bag of premium coffee', + 'Dedicated account manager', + 'Exclusive tasting events' + ], + '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'): array { + $customerTier = $this->getCustomerTier($customerId); + $multiplier = $customerTier['info']['multiplier']; + + // Calculate points (base: 1 point per dollar) + $basePoints = floor($amount); + $bonusPoints = 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) + :points, + updated_at = NOW() + WHERE customer_id = :id", + ['points' => $totalPoints, 'id' => $customerId] + ); + + // Log the transaction + db()->insert('loyalty_transactions', [ + 'transaction_id' => generateId('lt_'), + 'customer_id' => $customerId, + 'points' => $totalPoints, + 'type' => 'earn', + 'description' => $description . ($bonusPoints > 0 ? " (+{$bonusPoints} bonus)" : ''), + 'reference_amount' => $amount + ]); + + // 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 + ]; + } +} + +// Helper function +function loyalty(): LoyaltyProgram { + static $instance = null; + if ($instance === null) { + $instance = new LoyaltyProgram(); + } + return $instance; +} diff --git a/includes/push.php b/includes/push.php new file mode 100644 index 0000000..d102c4e --- /dev/null +++ b/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/includes/sms.php b/includes/sms.php new file mode 100644 index 0000000..513f247 --- /dev/null +++ b/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/includes/stripe.php b/includes/stripe.php new file mode 100644 index 0000000..e12aab8 --- /dev/null +++ b/includes/stripe.php @@ -0,0 +1,214 @@ +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; + } + } + + 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/index.php b/index.php new file mode 100644 index 0000000..bcf1408 --- /dev/null +++ b/index.php @@ -0,0 +1,192 @@ +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" + ); +} + +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.

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

Ethically Sourced

+

Direct trade relationships with farmers ensuring fair wages and sustainable practices.

+
+
+
+ +
+

Fresh Roasted

+

Roasted in small batches weekly to ensure maximum freshness and flavor.

+
+
+
+ +
+

Fast Delivery

+

Free shipping on orders over $50. Same-day dispatch for orders before 2pm.

+
+
+
+ +
+

Satisfaction Guaranteed

+

Not happy with your order? We'll make it right or your money back.

+
+
+
+
+ + +
+
+
+

Featured Products

+

Our most popular coffee selections

+
+ +
+ +
+

Products coming soon! Check back later.

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

+ +

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

Our Story

+

+ Founded in Fort Worth, Texas, Tom's Java Jive began with a simple mission: to bring exceptional coffee to our community. What started as a small roastery has grown into a beloved local institution. +

+

+ We source our beans directly from farmers around the world, ensuring fair prices and sustainable practices. Every batch is carefully roasted to bring out the unique flavors and characteristics of each origin. +

+ Explore Our Coffee +
+
+ Coffee brewing +
+
+
+
+ + + + + + + diff --git a/install/generate-icons.php b/install/generate-icons.php new file mode 100644 index 0000000..31e6812 --- /dev/null +++ b/install/generate-icons.php @@ -0,0 +1,53 @@ + + + + + + + + +'; + +foreach ($sizes as $size) { + $radius = floor($size * 0.15); + $offset = floor($size * 0.1); + $scale = $size / 100; + + $svg = str_replace( + ['{SIZE}', '{RADIUS}', '{OFFSET}', '{SCALE}'], + [$size, $radius, $offset, $scale], + $svgTemplate + ); + + // Save as SVG + file_put_contents($outputDir . "icon-{$size}.svg", $svg); + + echo "Generated icon-{$size}.svg\n"; +} + +// Also create badge icon +$badgeSvg = ' + + + TJ +'; + +file_put_contents($outputDir . 'badge-72.svg', $badgeSvg); +echo "Generated badge-72.svg\n"; + +echo "\nDone! Icons generated in: $outputDir\n"; +echo "Note: For production, convert SVGs to PNGs using an image tool.\n"; diff --git a/install/migrate_from_mongodb.php b/install/migrate_from_mongodb.php new file mode 100644 index 0000000..9002fbc --- /dev/null +++ b/install/migrate_from_mongodb.php @@ -0,0 +1,435 @@ +selectDatabase($mongoDbName); + echo "Connected to MongoDB database: $mongoDbName\n\n"; + + // Test MySQL connection + $mysql = db(); + echo "Connected to MySQL database: " . DB_NAME . "\n\n"; + +} catch (Exception $e) { + echo "ERROR: " . $e->getMessage() . "\n"; + exit(1); +} + +/** + * Helper function to convert MongoDB document to array + */ +function docToArray($doc) { + if ($doc instanceof MongoDB\Model\BSONDocument || $doc instanceof MongoDB\Model\BSONArray) { + return json_decode(json_encode($doc), true); + } + return $doc; +} + +/** + * Helper function to safely get nested value + */ +function safeGet($array, $key, $default = null) { + return isset($array[$key]) ? $array[$key] : $default; +} + +/** + * Migrate Admin Users + */ +function migrateAdminUsers($mongodb, $mysql) { + echo "Migrating admin users...\n"; + + $cursor = $mongodb->admin_users->find(); + $count = 0; + + foreach ($cursor as $doc) { + $doc = docToArray($doc); + + try { + $mysql->insert('admin_users', [ + 'user_id' => safeGet($doc, 'user_id') ?: safeGet($doc, 'admin_id'), + 'email' => safeGet($doc, 'email'), + 'password_hash' => safeGet($doc, 'password_hash'), + 'name' => safeGet($doc, 'name'), + 'picture' => safeGet($doc, 'picture'), + 'is_admin' => 1, + 'is_master' => safeGet($doc, 'is_master') ? 1 : 0, + 'permissions' => json_encode(safeGet($doc, 'permissions', [])), + 'created_at' => safeGet($doc, 'created_at') ?: date('Y-m-d H:i:s') + ]); + $count++; + } catch (Exception $e) { + echo " Warning: Could not migrate admin {$doc['email']}: " . $e->getMessage() . "\n"; + } + } + + echo " Migrated $count admin users\n\n"; +} + +/** + * Migrate Customers + */ +function migrateCustomers($mongodb, $mysql) { + echo "Migrating customers...\n"; + + $cursor = $mongodb->customers->find(); + $count = 0; + + foreach ($cursor as $doc) { + $doc = docToArray($doc); + + try { + $mysql->insert('customers', [ + 'customer_id' => safeGet($doc, 'customer_id'), + 'email' => safeGet($doc, 'email'), + 'password_hash' => safeGet($doc, 'password_hash'), + 'name' => safeGet($doc, 'name'), + 'phone' => safeGet($doc, 'phone'), + 'shipping_address' => json_encode(safeGet($doc, 'shipping_address')), + 'billing_address' => json_encode(safeGet($doc, 'billing_address')), + 'wallet_balance' => safeGet($doc, 'account_balance', 0), + 'reward_points' => safeGet($doc, 'loyalty_points', 0), + 'is_guest' => safeGet($doc, 'is_guest') ? 1 : 0, + 'created_via' => safeGet($doc, 'created_via', 'web'), + 'created_at' => safeGet($doc, 'created_at') ?: date('Y-m-d H:i:s') + ]); + + // Migrate wallet transactions + $transactions = safeGet($doc, 'account_transactions', []); + foreach ($transactions as $txn) { + try { + $mysql->insert('wallet_transactions', [ + 'transaction_id' => safeGet($txn, 'transaction_id') ?: 'txn_' . bin2hex(random_bytes(6)), + 'customer_id' => $doc['customer_id'], + 'amount' => safeGet($txn, 'amount', 0), + 'balance_after' => safeGet($txn, 'balance_after', 0), + 'type' => mapTransactionType(safeGet($txn, 'type')), + 'description' => safeGet($txn, 'description'), + 'order_id' => safeGet($txn, 'order_id'), + 'created_at' => safeGet($txn, 'date') ?: date('Y-m-d H:i:s') + ]); + } catch (Exception $e) { + // Skip transaction errors + } + } + + $count++; + } catch (Exception $e) { + echo " Warning: Could not migrate customer {$doc['email']}: " . $e->getMessage() . "\n"; + } + } + + echo " Migrated $count customers\n\n"; +} + +function mapTransactionType($type) { + $map = [ + 'deposit' => 'deposit', + 'payment' => 'withdrawal', + 'gift_card_purchase' => 'purchase', + 'admin_deposit' => 'deposit', + 'pos_payment' => 'withdrawal', + 'refund' => 'refund' + ]; + return isset($map[$type]) ? $map[$type] : 'deposit'; +} + +/** + * Migrate Products + */ +function migrateProducts($mongodb, $mysql) { + echo "Migrating products...\n"; + + $cursor = $mongodb->products->find(); + $count = 0; + + foreach ($cursor as $doc) { + $doc = docToArray($doc); + + // Map blend_type to category + $category = safeGet($doc, 'blend_type') ?: safeGet($doc, 'category') ?: 'coffee'; + + try { + $mysql->insert('products', [ + 'product_id' => safeGet($doc, 'product_id'), + 'name' => safeGet($doc, 'name'), + 'description' => safeGet($doc, 'description'), + 'price' => safeGet($doc, 'price', 0), + 'sale_price' => safeGet($doc, 'sale_price'), + 'sku' => safeGet($doc, 'sku'), + 'barcode' => safeGet($doc, 'barcode'), + 'category' => $category, + 'tags' => json_encode([safeGet($doc, 'roast_level'), safeGet($doc, 'product_type')]), + 'images' => json_encode([safeGet($doc, 'image_url')]), + 'stock' => safeGet($doc, 'stock_quantity', 100), + 'low_stock_threshold' => safeGet($doc, 'low_stock_threshold', 10), + 'weight' => safeGet($doc, 'weight'), + 'is_active' => safeGet($doc, 'in_stock', true) ? 1 : 0, + 'is_featured' => safeGet($doc, 'on_sale', false) ? 1 : 0, + 'created_at' => safeGet($doc, 'created_at') ?: date('Y-m-d H:i:s') + ]); + $count++; + } catch (Exception $e) { + echo " Warning: Could not migrate product {$doc['name']}: " . $e->getMessage() . "\n"; + } + } + + echo " Migrated $count products\n\n"; +} + +/** + * Migrate Orders + */ +function migrateOrders($mongodb, $mysql) { + echo "Migrating orders...\n"; + + $cursor = $mongodb->orders->find(); + $count = 0; + + foreach ($cursor as $doc) { + $doc = docToArray($doc); + + try { + $mysql->insert('orders', [ + 'order_id' => safeGet($doc, 'order_id'), + 'order_number' => safeGet($doc, 'order_number'), + 'customer_id' => safeGet($doc, 'customer_id'), + 'customer_email' => safeGet($doc, 'customer_email'), + 'customer_name' => safeGet($doc, 'customer_name'), + 'items' => json_encode(safeGet($doc, 'items', [])), + 'subtotal' => safeGet($doc, 'subtotal', 0), + 'shipping_cost' => safeGet($doc, 'shipping_cost', 0), + 'tax' => safeGet($doc, 'tax', 0), + 'discount' => safeGet($doc, 'discount_amount', 0), + 'total' => safeGet($doc, 'total', 0), + 'shipping_address' => json_encode(safeGet($doc, 'shipping_address')), + 'shipping_method' => safeGet($doc, 'shipping_method'), + 'payment_method' => safeGet($doc, 'payment_method'), + 'payment_status' => safeGet($doc, 'payment_status', 'pending'), + 'order_status' => safeGet($doc, 'order_status', 'pending'), + 'stripe_payment_intent' => safeGet($doc, 'checkout_session_id'), + 'tracking_number' => safeGet($doc, 'tracking_number'), + 'notes' => safeGet($doc, 'pos_notes'), + 'is_pos_order' => safeGet($doc, 'pos_order') ? 1 : 0, + 'created_at' => safeGet($doc, 'created_at') ?: date('Y-m-d H:i:s') + ]); + + // Also insert into order_items for reporting + $items = safeGet($doc, 'items', []); + foreach ($items as $item) { + try { + $mysql->insert('order_items', [ + 'order_id' => $doc['order_id'], + 'product_id' => safeGet($item, 'product_id'), + 'name' => safeGet($item, 'name'), + 'price' => safeGet($item, 'price', 0), + 'quantity' => safeGet($item, 'quantity', 1), + 'total' => safeGet($item, 'total', 0) + ]); + } catch (Exception $e) { + // Skip item errors + } + } + + $count++; + } catch (Exception $e) { + echo " Warning: Could not migrate order {$doc['order_number']}: " . $e->getMessage() . "\n"; + } + } + + echo " Migrated $count orders\n\n"; +} + +/** + * Migrate Gift Cards + */ +function migrateGiftCards($mongodb, $mysql) { + echo "Migrating gift cards...\n"; + + $cursor = $mongodb->gift_cards->find(); + $count = 0; + + foreach ($cursor as $doc) { + $doc = docToArray($doc); + + try { + $mysql->insert('gift_cards', [ + 'gift_card_id' => safeGet($doc, 'gift_card_id'), + 'code' => safeGet($doc, 'code'), + 'initial_balance' => safeGet($doc, 'initial_balance', 0), + 'current_balance' => safeGet($doc, 'current_balance', 0), + 'purchaser_email' => safeGet($doc, 'purchased_by_email'), + 'recipient_email' => safeGet($doc, 'recipient_email'), + 'recipient_name' => safeGet($doc, 'recipient_name'), + 'message' => safeGet($doc, 'message'), + 'is_active' => safeGet($doc, 'status') !== 'disabled' ? 1 : 0, + 'created_at' => safeGet($doc, 'created_at') ?: date('Y-m-d H:i:s') + ]); + $count++; + } catch (Exception $e) { + echo " Warning: Could not migrate gift card {$doc['code']}: " . $e->getMessage() . "\n"; + } + } + + echo " Migrated $count gift cards\n\n"; +} + +/** + * Migrate Reviews + */ +function migrateReviews($mongodb, $mysql) { + echo "Migrating reviews...\n"; + + $cursor = $mongodb->product_reviews->find(); + $count = 0; + + foreach ($cursor as $doc) { + $doc = docToArray($doc); + + try { + $mysql->insert('reviews', [ + 'review_id' => safeGet($doc, 'review_id'), + 'product_id' => safeGet($doc, 'product_id'), + 'customer_id' => safeGet($doc, 'customer_id'), + 'customer_name' => safeGet($doc, 'customer_name'), + 'customer_email' => safeGet($doc, 'customer_email'), + 'rating' => safeGet($doc, 'rating', 5), + 'title' => safeGet($doc, 'title'), + 'comment' => safeGet($doc, 'comment'), + 'is_verified_purchase' => safeGet($doc, 'verified_purchase') ? 1 : 0, + 'is_approved' => safeGet($doc, 'status') === 'approved' ? 1 : 0, + 'created_at' => safeGet($doc, 'created_at') ?: date('Y-m-d H:i:s') + ]); + $count++; + } catch (Exception $e) { + echo " Warning: Could not migrate review: " . $e->getMessage() . "\n"; + } + } + + echo " Migrated $count reviews\n\n"; +} + +/** + * Migrate Email Subscribers + */ +function migrateSubscribers($mongodb, $mysql) { + echo "Migrating email subscribers...\n"; + + $cursor = $mongodb->email_subscribers->find(); + $count = 0; + + foreach ($cursor as $doc) { + $doc = docToArray($doc); + + try { + $mysql->insert('email_subscribers', [ + 'email' => safeGet($doc, 'email'), + 'name' => safeGet($doc, 'name'), + 'is_active' => safeGet($doc, 'status') === 'active' ? 1 : 0, + 'source' => safeGet($doc, 'source', 'website'), + 'created_at' => safeGet($doc, 'subscribed_at') ?: date('Y-m-d H:i:s') + ]); + $count++; + } catch (Exception $e) { + // Skip duplicate emails + } + } + + echo " Migrated $count subscribers\n\n"; +} + +/** + * Migrate Settings + */ +function migrateSettings($mongodb, $mysql) { + echo "Migrating settings...\n"; + + // Shipping settings + $shipping = $mongodb->shipping_settings->findOne(['settings_id' => 'shipping_settings']); + if ($shipping) { + $shipping = docToArray($shipping); + $mysql->query( + "UPDATE settings SET setting_value = :val WHERE setting_key = 'shipping'", + ['val' => json_encode([ + 'flat_rate_enabled' => safeGet($shipping, 'flat_rate_enabled', true), + 'flat_rate_amount' => safeGet($shipping, 'flat_rate_amount', 5.99), + 'free_shipping_threshold' => safeGet($shipping, 'free_shipping_threshold', 50), + 'weight_based_enabled' => safeGet($shipping, 'weight_based_enabled', false) + ])] + ); + echo " Migrated shipping settings\n"; + } + + // Payment settings + $payment = $mongodb->payment_settings->findOne(['settings_id' => 'payment_settings']); + if ($payment) { + $payment = docToArray($payment); + $mysql->query( + "UPDATE settings SET setting_value = :val WHERE setting_key = 'payment'", + ['val' => json_encode([ + 'stripe_enabled' => safeGet($payment, 'stripe_enabled', true), + 'paypal_enabled' => safeGet($payment, 'paypal_enabled', false), + 'cod_enabled' => false + ])] + ); + echo " Migrated payment settings\n"; + } + + echo "\n"; +} + +// Run migrations +echo "Starting migration...\n\n"; + +try { + migrateAdminUsers($mongodb, $mysql); + migrateCustomers($mongodb, $mysql); + migrateProducts($mongodb, $mysql); + migrateOrders($mongodb, $mysql); + migrateGiftCards($mongodb, $mysql); + migrateReviews($mongodb, $mysql); + migrateSubscribers($mongodb, $mysql); + migrateSettings($mongodb, $mysql); + + echo "==============================================\n"; + echo "Migration completed successfully!\n"; + echo "==============================================\n"; + +} catch (Exception $e) { + echo "\nMIGRATION ERROR: " . $e->getMessage() . "\n"; + exit(1); +} diff --git a/install/migration_v2.sql b/install/migration_v2.sql new file mode 100644 index 0000000..b4cbb58 --- /dev/null +++ b/install/migration_v2.sql @@ -0,0 +1,37 @@ +-- Migration: Add wishlist table and addresses column to customers +-- Run this in phpMyAdmin to add the new features + +-- Add addresses column to customers table +ALTER TABLE `customers` ADD COLUMN `addresses` JSON DEFAULT NULL AFTER `billing_address`; + +-- Add preferences column to customers table +ALTER TABLE `customers` ADD COLUMN `preferences` JSON DEFAULT NULL AFTER `addresses`; + +-- Add is_active column to customers table if not exists +ALTER TABLE `customers` ADD COLUMN `is_active` TINYINT(1) DEFAULT 1 AFTER `preferences`; + +-- Create wishlist table +CREATE TABLE IF NOT EXISTS `wishlist` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `customer_id` VARCHAR(50) NOT NULL, + `product_id` VARCHAR(50) NOT NULL, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY `unique_wishlist` (`customer_id`, `product_id`), + INDEX `idx_customer` (`customer_id`), + INDEX `idx_product` (`product_id`), + FOREIGN KEY (`customer_id`) REFERENCES `customers`(`customer_id`) ON DELETE CASCADE, + FOREIGN KEY (`product_id`) REFERENCES `products`(`product_id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Add reorder_level column to products if not exists +ALTER TABLE `products` ADD COLUMN `reorder_level` INT DEFAULT 10 AFTER `low_stock_threshold`; + +-- Add slug column to products if not exists +ALTER TABLE `products` ADD COLUMN `slug` VARCHAR(255) DEFAULT NULL AFTER `name`; +ALTER TABLE `products` ADD INDEX `idx_slug` (`slug`); + +-- Update existing products to have slugs based on name +UPDATE `products` SET `slug` = LOWER(REPLACE(REPLACE(REPLACE(`name`, ' ', '-'), "'", ''), '"', '')) WHERE `slug` IS NULL; + +-- Add is_pos_order to orders table +ALTER TABLE `orders` ADD COLUMN `is_pos_order` TINYINT(1) DEFAULT 0 AFTER `notes`; diff --git a/install/migration_v3.sql b/install/migration_v3.sql new file mode 100644 index 0000000..8b55223 --- /dev/null +++ b/install/migration_v3.sql @@ -0,0 +1,96 @@ +-- Migration: Add tables for push notifications, loyalty program, and integration settings +-- Run this in phpMyAdmin + +-- Push notification subscriptions +CREATE TABLE IF NOT EXISTS `push_subscriptions` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `customer_id` VARCHAR(50) DEFAULT NULL, + `endpoint` TEXT NOT NULL, + `p256dh_key` VARCHAR(255) NOT NULL, + `auth_key` VARCHAR(255) NOT NULL, + `is_active` TINYINT(1) DEFAULT 1, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX `idx_customer` (`customer_id`), + INDEX `idx_active` (`is_active`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Push notifications queue +CREATE TABLE IF NOT EXISTS `push_notifications` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `notification_id` VARCHAR(50) NOT NULL UNIQUE, + `subscription_endpoint` TEXT NOT NULL, + `payload` TEXT NOT NULL, + `status` ENUM('pending', 'sent', 'failed') DEFAULT 'pending', + `error_message` TEXT DEFAULT NULL, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `sent_at` TIMESTAMP NULL DEFAULT NULL, + INDEX `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Loyalty transactions history +CREATE TABLE IF NOT EXISTS `loyalty_transactions` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `transaction_id` VARCHAR(50) NOT NULL UNIQUE, + `customer_id` VARCHAR(50) NOT NULL, + `points` INT NOT NULL, + `type` ENUM('earn', 'redeem', 'tier_upgrade', 'birthday_bonus', 'referral_bonus', 'referral_welcome', 'adjustment', 'expiry') NOT NULL, + `description` VARCHAR(255) DEFAULT NULL, + `reference_amount` DECIMAL(10,2) DEFAULT NULL, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX `idx_customer` (`customer_id`), + INDEX `idx_type` (`type`), + INDEX `idx_created` (`created_at`), + FOREIGN KEY (`customer_id`) REFERENCES `customers`(`customer_id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Add lifetime_points and loyalty_tier to customers +ALTER TABLE `customers` + ADD COLUMN IF NOT EXISTS `lifetime_points` INT DEFAULT 0 AFTER `reward_points`, + ADD COLUMN IF NOT EXISTS `loyalty_tier` ENUM('bronze', 'silver', 'gold', 'platinum') DEFAULT 'bronze' AFTER `lifetime_points`, + ADD COLUMN IF NOT EXISTS `birthday` DATE DEFAULT NULL AFTER `loyalty_tier`, + ADD COLUMN IF NOT EXISTS `referral_code` VARCHAR(20) DEFAULT NULL AFTER `birthday`, + ADD COLUMN IF NOT EXISTS `referred_by` VARCHAR(50) DEFAULT NULL AFTER `referral_code`; + +-- Add unique index on referral_code +ALTER TABLE `customers` ADD UNIQUE INDEX IF NOT EXISTS `idx_referral_code` (`referral_code`); + +-- Update settings table with integration keys (INSERT IGNORE to not overwrite existing) +INSERT IGNORE INTO `settings` (`setting_key`, `setting_value`, `updated_at`) VALUES +('sendgrid_api_key', '', NOW()), +('sendgrid_from_email', 'noreply@tomsjavajive.com', NOW()), +('sendgrid_from_name', 'Tom''s Java Jive', NOW()), +('twilio_account_sid', '', NOW()), +('twilio_auth_token', '', NOW()), +('twilio_phone_number', '', NOW()), +('vapid_public_key', '', NOW()), +('vapid_private_key', '', NOW()), +('loyalty_enabled', '1', NOW()), +('email_notifications_enabled', '1', NOW()), +('sms_notifications_enabled', '0', NOW()), +('push_notifications_enabled', '1', NOW()); + +-- Add Stripe checkout session column to orders +ALTER TABLE `orders` ADD COLUMN IF NOT EXISTS `stripe_checkout_session` VARCHAR(255) DEFAULT NULL AFTER `stripe_payment_intent`; + +-- Payment transactions table for tracking payment attempts +CREATE TABLE IF NOT EXISTS `payment_transactions` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `transaction_id` VARCHAR(50) NOT NULL UNIQUE, + `order_id` VARCHAR(50) NOT NULL, + `customer_id` VARCHAR(50) DEFAULT NULL, + `amount` DECIMAL(10,2) NOT NULL, + `currency` VARCHAR(3) DEFAULT 'USD', + `payment_method` VARCHAR(50) DEFAULT 'stripe', + `stripe_session_id` VARCHAR(255) DEFAULT NULL, + `stripe_payment_intent` VARCHAR(255) DEFAULT NULL, + `status` ENUM('initiated', 'pending', 'processing', 'succeeded', 'failed', 'cancelled', 'refunded') DEFAULT 'initiated', + `metadata` JSON DEFAULT NULL, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX `idx_order` (`order_id`), + INDEX `idx_customer` (`customer_id`), + INDEX `idx_status` (`status`), + INDEX `idx_stripe_session` (`stripe_session_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + diff --git a/install/schema.sql b/install/schema.sql new file mode 100644 index 0000000..131425f --- /dev/null +++ b/install/schema.sql @@ -0,0 +1,402 @@ +-- Tom's Java Jive - MySQL Database Schema +-- Version: 1.0 +-- Compatible with MySQL 8.0+ + +SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO"; +SET AUTOCOMMIT = 0; +START TRANSACTION; +SET time_zone = "+00:00"; + +-- -------------------------------------------------------- +-- Database Schema for Tom's Java Jive +-- NOTE: Database must already exist in cPanel +-- Select your database in phpMyAdmin before importing +-- -------------------------------------------------------- + +-- -------------------------------------------------------- +-- Table: settings +-- -------------------------------------------------------- +CREATE TABLE `settings` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `setting_key` VARCHAR(100) NOT NULL UNIQUE, + `setting_value` JSON, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- +-- Table: admin_users +-- -------------------------------------------------------- +CREATE TABLE `admin_users` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `user_id` VARCHAR(50) NOT NULL UNIQUE, + `email` VARCHAR(255) NOT NULL UNIQUE, + `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` JSON DEFAULT NULL, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `last_login` TIMESTAMP NULL, + INDEX `idx_email` (`email`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- +-- Table: customers +-- -------------------------------------------------------- +CREATE TABLE `customers` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `customer_id` VARCHAR(50) NOT NULL UNIQUE, + `email` VARCHAR(255) NOT NULL UNIQUE, + `password_hash` VARCHAR(255) DEFAULT NULL, + `name` VARCHAR(255) DEFAULT NULL, + `phone` VARCHAR(50) DEFAULT NULL, + `shipping_address` JSON DEFAULT NULL, + `billing_address` JSON DEFAULT NULL, + `wallet_balance` DECIMAL(10,2) DEFAULT 0.00, + `reward_points` INT DEFAULT 0, + `is_guest` TINYINT(1) DEFAULT 0, + `created_via` VARCHAR(50) DEFAULT 'web', + `email_verified` TINYINT(1) DEFAULT 0, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX `idx_email` (`email`), + INDEX `idx_customer_id` (`customer_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- +-- Table: products +-- -------------------------------------------------------- +CREATE TABLE `products` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `product_id` VARCHAR(50) NOT NULL UNIQUE, + `name` VARCHAR(255) NOT NULL, + `description` TEXT, + `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, + `tags` JSON DEFAULT NULL, + `images` JSON DEFAULT NULL, + `stock` INT DEFAULT 0, + `low_stock_threshold` INT DEFAULT 10, + `weight` DECIMAL(10,2) DEFAULT NULL, + `dimensions` JSON DEFAULT NULL, + `is_active` TINYINT(1) DEFAULT 1, + `is_featured` TINYINT(1) DEFAULT 0, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX `idx_product_id` (`product_id`), + INDEX `idx_category` (`category`), + INDEX `idx_is_active` (`is_active`), + FULLTEXT INDEX `idx_search` (`name`, `description`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- +-- Table: orders +-- -------------------------------------------------------- +CREATE TABLE `orders` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `order_id` VARCHAR(50) NOT NULL UNIQUE, + `order_number` VARCHAR(20) NOT NULL UNIQUE, + `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` JSON NOT NULL, + `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` JSON DEFAULT NULL, + `billing_address` JSON DEFAULT NULL, + `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, + `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 DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX `idx_order_id` (`order_id`), + INDEX `idx_customer_id` (`customer_id`), + INDEX `idx_customer_email` (`customer_email`), + INDEX `idx_order_status` (`order_status`), + INDEX `idx_payment_status` (`payment_status`), + INDEX `idx_created_at` (`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- +-- Table: order_items (normalized for reporting) +-- -------------------------------------------------------- +CREATE TABLE `order_items` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `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 NOT NULL, + `total` DECIMAL(10,2) NOT NULL, + INDEX `idx_order_id` (`order_id`), + INDEX `idx_product_id` (`product_id`), + FOREIGN KEY (`order_id`) REFERENCES `orders`(`order_id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- +-- Table: gift_cards +-- -------------------------------------------------------- +CREATE TABLE `gift_cards` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `gift_card_id` VARCHAR(50) NOT NULL UNIQUE, + `code` VARCHAR(20) NOT NULL UNIQUE, + `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, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX `idx_code` (`code`), + INDEX `idx_is_active` (`is_active`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- +-- Table: gift_card_transactions +-- -------------------------------------------------------- +CREATE TABLE `gift_card_transactions` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `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 DEFAULT CURRENT_TIMESTAMP, + INDEX `idx_gift_card_id` (`gift_card_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- +-- Table: wallet_transactions +-- -------------------------------------------------------- +CREATE TABLE `wallet_transactions` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `transaction_id` VARCHAR(50) NOT NULL UNIQUE, + `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') NOT NULL, + `description` VARCHAR(255) DEFAULT NULL, + `order_id` VARCHAR(50) DEFAULT NULL, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX `idx_customer_id` (`customer_id`), + INDEX `idx_type` (`type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- +-- Table: reviews +-- -------------------------------------------------------- +CREATE TABLE `reviews` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `review_id` VARCHAR(50) NOT NULL UNIQUE, + `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 NOT NULL CHECK (rating >= 1 AND rating <= 5), + `title` VARCHAR(255) DEFAULT NULL, + `comment` TEXT, + `is_verified_purchase` TINYINT(1) DEFAULT 0, + `is_approved` TINYINT(1) DEFAULT 0, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX `idx_product_id` (`product_id`), + INDEX `idx_is_approved` (`is_approved`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- +-- Table: email_campaigns +-- -------------------------------------------------------- +CREATE TABLE `email_campaigns` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `campaign_id` VARCHAR(50) NOT NULL UNIQUE, + `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, + `sent_at` TIMESTAMP NULL, + `recipients_count` INT DEFAULT 0, + `opened_count` INT DEFAULT 0, + `clicked_count` INT DEFAULT 0, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- +-- Table: email_subscribers +-- -------------------------------------------------------- +CREATE TABLE `email_subscribers` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `email` VARCHAR(255) NOT NULL UNIQUE, + `name` VARCHAR(255) DEFAULT NULL, + `is_active` TINYINT(1) DEFAULT 1, + `source` VARCHAR(50) DEFAULT 'website', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX `idx_is_active` (`is_active`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- +-- Table: abandoned_carts +-- -------------------------------------------------------- +CREATE TABLE `abandoned_carts` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `cart_id` VARCHAR(50) NOT NULL UNIQUE, + `customer_id` VARCHAR(50) DEFAULT NULL, + `customer_email` VARCHAR(255) DEFAULT NULL, + `items` JSON NOT NULL, + `subtotal` DECIMAL(10,2) NOT NULL, + `recovery_email_sent` TINYINT(1) DEFAULT 0, + `recovered` TINYINT(1) DEFAULT 0, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX `idx_customer_email` (`customer_email`), + INDEX `idx_recovered` (`recovered`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- +-- Table: referrals +-- -------------------------------------------------------- +CREATE TABLE `referrals` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `referral_id` VARCHAR(50) NOT NULL UNIQUE, + `referrer_customer_id` VARCHAR(50) NOT NULL, + `referral_code` VARCHAR(20) NOT NULL UNIQUE, + `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 DEFAULT CURRENT_TIMESTAMP, + INDEX `idx_referral_code` (`referral_code`), + INDEX `idx_referrer` (`referrer_customer_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- +-- Table: visitor_sessions +-- -------------------------------------------------------- +CREATE TABLE `visitor_sessions` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `session_id` VARCHAR(100) NOT NULL UNIQUE, + `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 DEFAULT 1, + `started_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `last_activity` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX `idx_is_active` (`is_active`), + INDEX `idx_last_activity` (`last_activity`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- +-- Table: categories +-- -------------------------------------------------------- +CREATE TABLE `categories` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `category_id` VARCHAR(50) NOT NULL UNIQUE, + `name` VARCHAR(255) NOT NULL, + `slug` VARCHAR(255) NOT NULL UNIQUE, + `description` TEXT DEFAULT NULL, + `image` VARCHAR(500) DEFAULT NULL, + `parent_id` VARCHAR(50) DEFAULT NULL, + `sort_order` INT DEFAULT 0, + `is_active` TINYINT(1) DEFAULT 1, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX `idx_slug` (`slug`), + INDEX `idx_is_active` (`is_active`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- +-- Table: coupons +-- -------------------------------------------------------- +CREATE TABLE `coupons` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `coupon_id` VARCHAR(50) NOT NULL UNIQUE, + `code` VARCHAR(50) NOT NULL UNIQUE, + `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 DEFAULT NULL, + `times_used` INT DEFAULT 0, + `is_active` TINYINT(1) DEFAULT 1, + `starts_at` TIMESTAMP NULL, + `expires_at` TIMESTAMP NULL, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX `idx_code` (`code`), + INDEX `idx_is_active` (`is_active`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- +-- Table: password_reset_tokens +-- -------------------------------------------------------- +CREATE TABLE `password_reset_tokens` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `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 DEFAULT CURRENT_TIMESTAMP, + INDEX `idx_token` (`token`), + INDEX `idx_email` (`email`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- +-- Table: sessions +-- -------------------------------------------------------- +CREATE TABLE `sessions` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `session_id` VARCHAR(128) NOT NULL UNIQUE, + `user_id` VARCHAR(50) DEFAULT NULL, + `user_type` ENUM('admin', 'customer') DEFAULT NULL, + `data` TEXT, + `ip_address` VARCHAR(45) DEFAULT NULL, + `user_agent` VARCHAR(255) DEFAULT NULL, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `expires_at` TIMESTAMP NOT NULL, + INDEX `idx_session_id` (`session_id`), + INDEX `idx_expires_at` (`expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- +-- Insert default settings +-- -------------------------------------------------------- +INSERT INTO `settings` (`setting_key`, `setting_value`) VALUES +('store_name', '"Tom\'s Java Jive"'), +('store_email', '"support@tomsjavajive.com"'), +('store_phone', '""'), +('store_address', '""'), +('currency', '"USD"'), +('currency_symbol', '"$"'), +('tax_rate', '0'), +('shipping', '{"flat_rate_enabled": true, "flat_rate_amount": 5.99, "free_shipping_threshold": 50, "weight_based_enabled": false}'), +('payment', '{"stripe_enabled": true, "paypal_enabled": false, "cod_enabled": false}'), +('email', '{"sendgrid_api_key": "", "sender_email": "noreply@tomsjavajive.com", "sender_name": "Tom\'s Java Jive"}'); + +COMMIT; diff --git a/login.php b/login.php new file mode 100644 index 0000000..40467e5 --- /dev/null +++ b/login.php @@ -0,0 +1,89 @@ + + +
+
+
+
+

Welcome Back

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

+ Don't have an account? Create one +

+ +

+ Or track your order with your order number +

+
+
+
+
+ + diff --git a/logout.php b/logout.php new file mode 100644 index 0000000..23933be --- /dev/null +++ b/logout.php @@ -0,0 +1,10 @@ + + + + + + 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/order-confirmation.php b/order-confirmation.php new file mode 100644 index 0000000..6080025 --- /dev/null +++ b/order-confirmation.php @@ -0,0 +1,128 @@ +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' ?> +
+ +
+ Total + +
+
+ +
+

Shipping Address

+

+
+
+ , + + +

+
+ +

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

+ + +
+
+
+
+ + diff --git a/payment.php b/payment.php new file mode 100644 index 0000000..36ed4be --- /dev/null +++ b/payment.php @@ -0,0 +1,287 @@ +fetch( + "SELECT * FROM orders WHERE order_id = :id", + ['id' => $orderId] +); + +if (!$order) { + redirect('/cart.php'); +} + +// If already paid, redirect to confirmation +if ($order['payment_status'] === 'paid') { + clearCart(); + redirect('/order-confirmation.php?order=' . $orderId); +} + +$stripePublishableKey = STRIPE_PUBLISHABLE_KEY; +$stripeConfigured = isStripeConfigured(); +$total = $order['total']; + +require_once __DIR__ . '/includes/header.php'; +?> + +
+
+
+
+

Complete Payment

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

+ +

+
+ + + +
+ 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/product.php b/product.php new file mode 100644 index 0000000..a7170ee --- /dev/null +++ b/product.php @@ -0,0 +1,252 @@ +fetch( + "SELECT * FROM products WHERE product_id = :id AND is_active = 1", + ['id' => $productId] +); + +if (!$product) { + header('Location: /shop.php'); + exit; +} + +$pageTitle = $product['name'] . " - Tom's Java Jive"; +$pageDescription = truncate(strip_tags($product['description']), 160); + +// 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; + +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/register.php b/register.php new file mode 100644 index 0000000..54eea9e --- /dev/null +++ b/register.php @@ -0,0 +1,127 @@ +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/'); + } + } +} + +require_once __DIR__ . '/includes/header.php'; +?> + +
+
+
+
+

Create Your Account

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

+ Already have an account? Sign in +

+
+
+
+
+ + diff --git a/shop.php b/shop.php new file mode 100644 index 0000000..d2bbc9f --- /dev/null +++ b/shop.php @@ -0,0 +1,164 @@ + 'COALESCE(sale_price, price) ASC', + 'price_high' => 'COALESCE(sale_price, price) DESC', + 'name' => 'name ASC', + default => 'created_at DESC' +}; + +// Get total count +$totalProducts = db()->count('products', $whereClause, $params); +$pagination = paginate($totalProducts, $page, 12); + +// Get products +$products = db()->fetchAll( + "SELECT * FROM products WHERE {$whereClause} 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" +); + +require_once __DIR__ . '/includes/header.php'; +?> + + +
+
+

Our Coffee Collection

+

Discover your perfect brew from our selection of premium coffees

+
+
+ +
+
+ +
+
+ All + + + + + +
+ +
+
+ + + + + +
+ + +
+
+ + +

+ Showing of products + +

+ + + +
+ +

No products found

+

Try adjusting your search or filters

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

+ +

+
+ + + + +
+ +
+
+ +
+ + + 1): ?> +
+ + + + + +
+ + +
+
+ + diff --git a/sw.js b/sw.js new file mode 100644 index 0000000..6145bc2 --- /dev/null +++ b/sw.js @@ -0,0 +1,269 @@ +const CACHE_NAME = 'tomsjavajive-v1'; +const STATIC_CACHE = 'tomsjavajive-static-v1'; +const DYNAMIC_CACHE = 'tomsjavajive-dynamic-v1'; + +// Static assets to cache immediately +const STATIC_ASSETS = [ + '/', + '/shop.php', + '/cart.php', + '/assets/css/style.css', + '/assets/js/main.js', + '/assets/images/logo.png', + '/manifest.json', + '/offline.html' +]; + +// Install event - cache static assets +self.addEventListener('install', (event) => { + console.log('[Service Worker] Installing...'); + + event.waitUntil( + caches.open(STATIC_CACHE) + .then((cache) => { + console.log('[Service Worker] Caching static assets'); + return cache.addAll(STATIC_ASSETS.map(url => { + return new Request(url, { cache: 'no-cache' }); + })).catch(err => { + console.log('[Service Worker] Some assets failed to cache:', err); + return Promise.resolve(); + }); + }) + .then(() => self.skipWaiting()) + ); +}); + +// Activate event - clean up old caches +self.addEventListener('activate', (event) => { + console.log('[Service Worker] Activating...'); + + event.waitUntil( + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames + .filter((cacheName) => { + return cacheName !== STATIC_CACHE && + cacheName !== DYNAMIC_CACHE && + cacheName.startsWith('tomsjavajive-'); + }) + .map((cacheName) => { + console.log('[Service Worker] Deleting old cache:', cacheName); + return caches.delete(cacheName); + }) + ); + }) + .then(() => self.clients.claim()) + ); +}); + +// Fetch event - serve from cache or network +self.addEventListener('fetch', (event) => { + const { request } = event; + const url = new URL(request.url); + + // Skip non-GET requests + if (request.method !== 'GET') { + return; + } + + // Skip admin panel + if (url.pathname.startsWith('/admin')) { + return; + } + + // Skip API requests (always network) + if (url.pathname.startsWith('/api')) { + return; + } + + // Handle navigation requests + if (request.mode === 'navigate') { + event.respondWith( + fetch(request) + .then((response) => { + // Clone and cache the response + const responseClone = response.clone(); + caches.open(DYNAMIC_CACHE).then((cache) => { + cache.put(request, responseClone); + }); + return response; + }) + .catch(() => { + // Try to serve from cache + return caches.match(request) + .then((cachedResponse) => { + if (cachedResponse) { + return cachedResponse; + } + // Serve offline page + return caches.match('/offline.html'); + }); + }) + ); + return; + } + + // Handle static assets (cache-first strategy) + if (isStaticAsset(url.pathname)) { + event.respondWith( + caches.match(request) + .then((cachedResponse) => { + if (cachedResponse) { + // Fetch in background to update cache + fetch(request).then((response) => { + if (response.ok) { + caches.open(STATIC_CACHE).then((cache) => { + cache.put(request, response); + }); + } + }).catch(() => {}); + + return cachedResponse; + } + + return fetch(request).then((response) => { + if (response.ok) { + const responseClone = response.clone(); + caches.open(STATIC_CACHE).then((cache) => { + cache.put(request, responseClone); + }); + } + return response; + }); + }) + ); + return; + } + + // Handle images (cache-first with network fallback) + if (isImageRequest(request)) { + event.respondWith( + caches.match(request) + .then((cachedResponse) => { + if (cachedResponse) { + return cachedResponse; + } + + return fetch(request) + .then((response) => { + if (response.ok) { + const responseClone = response.clone(); + caches.open(DYNAMIC_CACHE).then((cache) => { + cache.put(request, responseClone); + }); + } + return response; + }) + .catch(() => { + // Return placeholder image + return caches.match('/assets/images/placeholder-product.svg'); + }); + }) + ); + return; + } + + // Default: network-first with cache fallback + event.respondWith( + fetch(request) + .then((response) => { + if (response.ok) { + const responseClone = response.clone(); + caches.open(DYNAMIC_CACHE).then((cache) => { + cache.put(request, responseClone); + }); + } + return response; + }) + .catch(() => { + return caches.match(request); + }) + ); +}); + +// Helper functions +function isStaticAsset(pathname) { + return pathname.match(/\.(css|js|woff|woff2|ttf|eot)$/i); +} + +function isImageRequest(request) { + return request.destination === 'image' || + request.url.match(/\.(png|jpg|jpeg|gif|svg|webp|ico)$/i); +} + +// Push notification handling +self.addEventListener('push', (event) => { + console.log('[Service Worker] Push received'); + + let data = { title: "Tom's Java Jive", body: 'You have a new notification!' }; + + if (event.data) { + try { + data = event.data.json(); + } catch (e) { + data.body = event.data.text(); + } + } + + const options = { + body: data.body, + icon: '/assets/icons/icon-192.png', + badge: '/assets/icons/badge-72.png', + vibrate: [100, 50, 100], + data: { + url: data.url || '/' + }, + actions: [ + { action: 'view', title: 'View' }, + { action: 'close', title: 'Close' } + ] + }; + + event.waitUntil( + self.registration.showNotification(data.title, options) + ); +}); + +// Notification click handling +self.addEventListener('notificationclick', (event) => { + console.log('[Service Worker] Notification clicked'); + + event.notification.close(); + + if (event.action === 'close') { + return; + } + + const url = event.notification.data?.url || '/'; + + event.waitUntil( + clients.matchAll({ type: 'window', includeUncontrolled: true }) + .then((clientList) => { + // Focus existing window if available + for (const client of clientList) { + if (client.url === url && 'focus' in client) { + return client.focus(); + } + } + // Open new window + if (clients.openWindow) { + return clients.openWindow(url); + } + }) + ); +}); + +// Background sync for cart/orders +self.addEventListener('sync', (event) => { + console.log('[Service Worker] Background sync:', event.tag); + + if (event.tag === 'sync-cart') { + event.waitUntil(syncCart()); + } +}); + +async function syncCart() { + // Get pending cart actions from IndexedDB + // and sync with server + console.log('[Service Worker] Syncing cart...'); +} diff --git a/test.php b/test.php new file mode 100644 index 0000000..a04339f --- /dev/null +++ b/test.php @@ -0,0 +1,36 @@ +PHP Test Page"; +echo "

PHP Version: " . phpversion() . "

"; + +// Test database connection +try { + require_once __DIR__ . '/config/database.php'; + + $dsn = "mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=" . DB_CHARSET; + $pdo = new PDO($dsn, DB_USER, DB_PASS, DB_OPTIONS); + + echo "

โœ… Database connection successful!

"; + + // Check if tables exist + $tables = $pdo->query("SHOW TABLES")->fetchAll(PDO::FETCH_COLUMN); + echo "

Tables found: " . count($tables) . "

"; + + if (empty($tables)) { + echo "

โš ๏ธ No tables found. Please import schema.sql via phpMyAdmin.

"; + echo "

Schema file location: /public_html/install/schema.sql

"; + } else { + echo "
    "; + foreach ($tables as $table) { + echo "
  • $table
  • "; + } + echo "
"; + } + +} catch (PDOException $e) { + echo "

โŒ Database error: " . htmlspecialchars($e->getMessage()) . "

"; +} +?>