commit 996ca0d621f60b395bca4acfde1a97c7a282e7cc Author: Myron Blair Date: Fri May 22 12:52:44 2026 +0000 Initial commit diff --git a/!install!!/fix_tjj.py b/!install!!/fix_tjj.py new file mode 100644 index 0000000..fcdcae5 --- /dev/null +++ b/!install!!/fix_tjj.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +import subprocess, os + +BASE = '/home/tomsjavajive.com/public_html' + +# ── Fix 1: Remove wholesale and careers from footer ────────── +print("[1] Fixing footer...") +f = BASE + '/includes/footer.php' +with open(f) as fh: + c = fh.read() + +before = len(c) +c = c.replace('
  • Wholesale
  • \n', '') +c = c.replace('
  • Careers
  • \n', '') +# Also try without leading spaces +c = c.replace('
  • Wholesale
  • \n', '') +c = c.replace('
  • Careers
  • \n', '') + +with open(f, 'w') as fh: + fh.write(c) +removed = before - len(c) +print(f" Footer: removed {removed} chars (wholesale + careers)") + +# ── Fix 2: Check account pages for errors ─────────────────── +print("\n[2] Checking account pages...") +for page in ['rewards', 'wishlist', 'reviews']: + path = f'{BASE}/account/{page}.php' + r = subprocess.run( + ['/usr/local/lsws/lsphp85/bin/php', '-d', 'display_errors=1', + '-d', 'error_reporting=32767', path], + capture_output=True, text=True + ) + output = r.stdout + r.stderr + errors = [l for l in output.split('\n') + if any(x in l for x in ['Fatal','Error','Undefined','undefined','Call to'])] + if errors: + print(f" {page}.php ERRORS:") + for e in errors[:3]: + print(f" {e[:120]}") + else: + # Check if page has actual HTML content + has_html = '<' in r.stdout and len(r.stdout) > 100 + print(f" {page}.php: {'OK - has content' if has_html else 'WARNING - minimal output'}") + +# ── Fix 3: Check admin customers Modal ────────────────────── +print("\n[3] Checking admin customers...") +f2 = BASE + '/admin/customers.php' +with open(f2) as fh: + c2 = fh.read() + +print(f" Modal.open present: {'Modal.open' in c2}") +print(f" openCustomerModal present: {'openCustomerModal' in c2}") +print(f" admin.js exists: {os.path.exists(BASE+'/admin/assets/admin.js')}") + +# Check if admin.js has Modal object +if os.path.exists(BASE+'/admin/assets/admin.js'): + with open(BASE+'/admin/assets/admin.js') as fh: + js = fh.read() + print(f" Modal object in admin.js: {'Modal' in js}") + print(f" admin.js size: {len(js)} chars") +else: + print(" admin.js MISSING - this is why customer modal doesn't work!") + # Create a basic Modal object + modal_js = """ +// Modal helper +const Modal = { + open: function(id) { + const el = document.getElementById(id); + if (el) { el.style.display = 'flex'; el.classList.add('active'); } + }, + close: function(id) { + const el = document.getElementById(id); + if (el) { el.style.display = 'none'; el.classList.remove('active'); } + } +}; +document.addEventListener('keydown', function(e) { + if (e.key === 'Escape') { + document.querySelectorAll('.modal-overlay.active').forEach(m => { + m.style.display = 'none'; m.classList.remove('active'); + }); + } +}); +document.addEventListener('click', function(e) { + if (e.target.classList.contains('modal-overlay')) { + e.target.style.display = 'none'; e.target.classList.remove('active'); + } +}); +""" + os.makedirs(BASE+'/admin/assets', exist_ok=True) + with open(BASE+'/admin/assets/admin.js', 'w') as fh: + fh.write(modal_js) + print(" Created admin.js with Modal object") + +# ── Fix 4: Check account page includes ───────────────────── +print("\n[4] Checking account page structure...") +for page in ['rewards', 'wishlist', 'reviews']: + path = f'{BASE}/account/{page}.php' + with open(path) as fh: + content = fh.read() + has_auth = 'CustomerAuth::require' in content + has_header = "require_once" in content and 'header' in content + has_footer = 'footer' in content + print(f" {page}.php: auth={has_auth}, header={has_header}, footer={has_footer}, size={len(content)}") + +print("\nDone!") 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/.gitignore b/.gitignore new file mode 100644 index 0000000..98ac107 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +*.log +.DS_Store +*.swp + +config/database.php +uploads/ diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..f89bc71 --- /dev/null +++ b/.htaccess @@ -0,0 +1,70 @@ +# 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 +# SEO ADDITIONS +RewriteCond %{HTTP_HOST} ^www\.tomsjavajive\.com [NC] +RewriteRule ^(.*)$ https://tomsjavajive.com/$1 [R=301,L] + + ExpiresActive On + ExpiresByType image/jpg "access plus 1 year" + ExpiresByType image/jpeg "access plus 1 year" + ExpiresByType image/png "access plus 1 year" + ExpiresByType image/webp "access plus 1 year" + ExpiresByType text/css "access plus 1 month" + ExpiresByType application/javascript "access plus 1 month" + diff --git a/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..003b228 --- /dev/null +++ b/admin/includes/header.php @@ -0,0 +1,182 @@ + + + + + + + <?= $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..dcaf1e1 --- /dev/null +++ b/admin/product-edit.php @@ -0,0 +1,400 @@ +fetchAll("SELECT category_id, name FROM categories WHERE is_active = 1 ORDER BY name ASC"); +$productTypesList = db()->fetchAll("SELECT type_id, name FROM product_types WHERE is_active = 1 ORDER BY sort_order ASC, name ASC"); + + +$productId = $_GET['id'] ?? ''; +$product = null; +$isEdit = false; +$errors = []; + +if ($productId) { + $product = db()->fetch("SELECT * FROM products WHERE product_id = :id", ['id' => $productId]); + if ($product) { + $isEdit = true; + $pageTitle = 'Edit Product'; + $product['images'] = json_decode($product['images'] ?? '[]', true); + $product['tags'] = json_decode($product['tags'] ?? '[]', true); + } +} + +if (!$product) { + $product = [ + 'product_id' => '', + 'name' => '', + 'description' => '', + 'price' => '', + 'sale_price' => '', + 'cost_price' => '', + 'sku' => '', + 'barcode' => '', + 'category' => '', + 'tags' => [], + 'images' => [], + 'stock' => 100, + 'low_stock_threshold' => 10, + 'weight' => '', + 'is_active' => 1, + 'is_featured' => 0 + ]; + $pageTitle = 'Add Product'; +} + +// Handle form submission +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $name = trim($_POST['name'] ?? ''); + $description = trim($_POST['description'] ?? ''); + $price = floatval($_POST['price'] ?? 0); + $salePrice = !empty($_POST['sale_price']) ? floatval($_POST['sale_price']) : null; + $costPrice = !empty($_POST['cost_price']) ? floatval($_POST['cost_price']) : null; + $sku = trim($_POST['sku'] ?? ''); + $barcode = trim($_POST['barcode'] ?? ''); + $category = trim($_POST['category'] ?? ''); + $stock = intval($_POST['stock'] ?? 0); + $lowStockThreshold = intval($_POST['low_stock_threshold'] ?? 10); + $weight = !empty($_POST['weight']) ? floatval($_POST['weight']) : null; + $isActive = isset($_POST['is_active']) ? 1 : 0; + $isFeatured = isset($_POST['is_featured']) ? 1 : 0; + $imageUrls = array_filter(array_map('trim', explode("\n", $_POST['image_urls'] ?? ''))); + + // Validate + if (empty($name)) $errors['name'] = 'Product name is required'; + if ($price <= 0) $errors['price'] = 'Price must be greater than 0'; + + if (empty($errors)) { + $data = [ + 'name' => $name, + 'description' => $description, + 'price' => $price, + 'sale_price' => $salePrice, + 'cost_price' => $costPrice, + 'sku' => $sku ?: null, + 'barcode' => $barcode ?: null, + 'category' => $category ?: null, + 'product_type_id' => trim($_POST['product_type_id'] ?? '') ?: null, + 'images' => json_encode($imageUrls), + 'stock' => $stock, + 'low_stock_threshold' => $lowStockThreshold, + 'weight' => $weight, + 'is_active' => $isActive, + 'is_featured' => $isFeatured + ]; + + if ($isEdit) { + db()->update('products', $data, 'product_id = :id', ['id' => $productId]); + setFlash('success', 'Product updated successfully'); + } else { + $data['product_id'] = generateId('prod_'); + db()->insert('products', $data); + setFlash('success', 'Product created successfully'); + } + + header('Location: /admin/products.php'); + exit; + } + + // Keep form values on error + $product = array_merge($product, $_POST); + $product['images'] = $imageUrls; +} + +// Get categories for dropdown +$categories = db()->fetchAll( + "SELECT DISTINCT category FROM products WHERE category IS NOT NULL AND category != '' ORDER BY category" +); +?> + + + + +
    + + Please fix the errors below +
    + + +
    +
    + +
    +
    +
    +

    Basic Information

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

    Pricing

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

    Images

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

    Status

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

    Inventory

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

    Identifiers

    +
    +
    +
    + + +
    + +
    + + +
    +
    +
    + + +
    +
    + + + diff --git a/admin/product-types.php b/admin/product-types.php new file mode 100644 index 0000000..c3525b8 --- /dev/null +++ b/admin/product-types.php @@ -0,0 +1,163 @@ +$name,'slug'=>$slug,'description'=>$description,'is_active'=>$isActive]; + if ($action === 'update' && $typeId) { + db()->update('product_types', $data, 'type_id = :id', ['id' => $typeId]); + setFlash('success', 'Product type updated'); + } else { + $data['type_id'] = generateId('pt_'); + db()->insert('product_types', $data); + setFlash('success', 'Product type created'); + } + } + header('Location: /admin/product-types.php'); + exit; + } + + if ($action === 'delete' && !empty($_POST['type_id'])) { + db()->delete('product_types', 'type_id = :id', ['id' => $_POST['type_id']]); + setFlash('success', 'Product type deleted'); + header('Location: /admin/product-types.php'); + exit; + } +} + +$types = db()->fetchAll("SELECT * FROM product_types ORDER BY name ASC"); +?> + + + + +
    + + +
    + + +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameSlugDescriptionStatusActions
    No product types yet. Create one above.
    + + Active + + Hidden + + + +
    + + + +
    +
    +
    +
    + + + + + + 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..841f45b --- /dev/null +++ b/admin/reviews.php @@ -0,0 +1,281 @@ +update('reviews', ['is_approved' => 1], 'review_id = :id', ['id' => $reviewId]); + setFlash('success', 'Review approved'); + } + + if ($action === 'reject' && $reviewId) { + db()->update('reviews', ['is_approved' => 0], 'review_id = :id', ['id' => $reviewId]); + setFlash('success', 'Review rejected'); + } + + if ($action === 'update' && $reviewId) { + $rating = max(1, min(5, intval($_POST['rating'] ?? 5))); + $title = trim($_POST['title'] ?? ''); + $comment = trim($_POST['comment'] ?? ''); + db()->update('reviews', [ + 'rating' => $rating, + 'title' => $title ?: null, + 'comment' => $comment ?: null, + ], 'review_id = :id', ['id' => $reviewId]); + setFlash('success', 'Review updated'); + } + + if ($action === 'delete' && $reviewId) { + db()->delete('reviews', 'review_id = :id', ['id' => $reviewId]); + setFlash('success', 'Review deleted'); + } + + header('Location: /admin/reviews.php'); + exit; +} + +// Filters +$status = $_GET['status'] ?? ''; +$rating = $_GET['rating'] ?? ''; + +$where = ['1=1']; +$params = []; + +if ($status === 'pending') { + $where[] = 'is_approved = 0'; +} elseif ($status === 'approved') { + $where[] = 'is_approved = 1'; +} + +if ($rating) { + $where[] = 'rating = :rating'; + $params['rating'] = $rating; +} + +$whereClause = implode(' AND ', $where); + +$reviews = db()->fetchAll( + "SELECT r.*, p.name as product_name FROM reviews r + LEFT JOIN products p ON r.product_id = p.product_id + WHERE {$whereClause} ORDER BY r.created_at DESC LIMIT 100", + $params +); + +// Stats +$totalReviews = db()->count('reviews'); +$pendingReviews = db()->count('reviews', 'is_approved = 0'); +$avgRating = db()->fetch("SELECT AVG(rating) as avg FROM reviews WHERE is_approved = 1")['avg'] ?? 0; +?> + + + + +
    + + + +
    +
    +
    +
    +
    +
    Average Rating
    +
    +
    +
    +
    +
    +
    +
    Total Reviews
    +
    +
    +
    +
    +
    +
    +
    Pending Approval
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    + + +
    + + + Clear + +
    +
    +
    + + +
    + +
    +
    + No reviews found +
    +
    + + +
    +
    +
    +
    +
    + + + Verified Purchase + + + Pending + +
    +
    + on + • +
    +
    +
    + +
    + + + +
    + +
    + + + +
    + + +
    + + + +
    +
    +
    + +
    + + + +
    + + +

    + + +

    + +

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

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

    +
    +
    + + +
    +

    Homepage Preview

    +
    +
    + +
    +
    + + + + + +
    +
    +
    +
    + +
    +
    +
    + + +
    +
    + +
    No splash blocks yet. Click Add Splash to get started.
    + + + + + + + + + + + + + + + + + + + + + + + + + + +
    VisualTitleDescriptionOrderStatusActions
    +
    + + + + + +
    +
    + + + Active' + : 'Hidden' ?> + + +
    + + + +
    +
    + +
    +
    + + + + + + + + + diff --git a/admin/upload-image.php b/admin/upload-image.php new file mode 100644 index 0000000..bc70e3b --- /dev/null +++ b/admin/upload-image.php @@ -0,0 +1,44 @@ + 'No file received']); + exit; +} + +$file = $_FILES['image']; +$allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; +$maxSize = 5 * 1024 * 1024; // 5MB + +if (!in_array($file['type'], $allowedTypes)) { + echo json_encode(['error' => 'Invalid file type. Use JPG, PNG, WebP, or GIF.']); + exit; +} + +if ($file['size'] > $maxSize) { + echo json_encode(['error' => 'File too large. Maximum 5MB.']); + exit; +} + +// Create upload directory +$uploadDir = __DIR__ . '/../uploads/products/'; +if (!is_dir($uploadDir)) { + mkdir($uploadDir, 0755, true); +} + +// Generate unique filename +$ext = pathinfo($file['name'], PATHINFO_EXTENSION); +$filename = 'product_' . time() . '_' . bin2hex(random_bytes(4)) . '.' . strtolower($ext); +$filepath = $uploadDir . $filename; + +if (move_uploaded_file($file['tmp_name'], $filepath)) { + $url = '/uploads/products/' . $filename; + echo json_encode(['success' => true, 'url' => $url]); +} else { + echo json_encode(['error' => 'Failed to save file. Check directory permissions.']); +} 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..c5ef5ff --- /dev/null +++ b/assets/css/style.css @@ -0,0 +1,850 @@ +/** + * 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; + } +} + +/* ── Coffee Splash Blocks ─────────────────────────── */ +.splash-section { overflow: hidden; } + +.splash-scroll-wrap { + position: relative; + display: flex; + align-items: center; + gap: .5rem; +} + +.splash-scroll-track { + display: flex; + gap: 1.5rem; + overflow-x: auto; + scroll-behavior: smooth; + scrollbar-width: none; + -ms-overflow-style: none; + padding: .5rem 0; + flex: 1; +} + +.splash-scroll-track::-webkit-scrollbar { display: none; } + +.splash-item { + min-width: 220px; + flex-shrink: 0; +} + +.splash-arrow { + flex-shrink: 0; + width: 40px; + height: 40px; + border-radius: 50%; + border: 2px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all .2s; + z-index: 2; +} + +.splash-arrow:hover { + background: var(--color-primary); + border-color: var(--color-primary); + color: white; +} + +.splash-arrow:disabled { + opacity: .3; + cursor: default; +} + +@media (max-width: 768px) { + .splash-arrow { display: none; } + .splash-scroll-track { padding-bottom: .5rem; } +} +/* ────────────────────────────────────────────────── */ 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/og-image.jpg b/assets/images/og-image.jpg new file mode 100644 index 0000000..f7f8eb4 --- /dev/null +++ b/assets/images/og-image.jpg @@ -0,0 +1,17 @@ + + + + + + + + + + + + Tom's Java Jive + Premium Artisan Coffee • Weatherford, TX + Freshly Roasted • Single Origin • Specialty Blends + + Shop tomsjavajive.com + 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..f2c6414 --- /dev/null +++ b/cart.php @@ -0,0 +1,177 @@ + $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..79f9c13 --- /dev/null +++ b/checkout.php @@ -0,0 +1,343 @@ + $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); + } +} + +$metaTitle = "Secure Checkout | Tom's Java Jive"; +$metaDescription = 'Complete your coffee order with secure checkout.'; +$canonicalUrl = 'https://tomsjavajive.com/checkout.php'; +$metaRobots = "noindex, nofollow"; +$suppressSchema = true; +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 @@ + 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..ba65c59 --- /dev/null +++ b/includes/email.php @@ -0,0 +1,369 @@ +apiKey = getSetting('sendgrid_api_key', defined('SENDGRID_API_KEY') ? SENDGRID_API_KEY : ''); + $this->fromEmail = getSetting('sendgrid_from_email', defined('SENDER_EMAIL') ? SENDER_EMAIL : 'noreply@tomsjavajive.com'); + $this->fromName = getSetting('sendgrid_from_name', defined('SENDER_NAME') ? SENDER_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..83d32f2 --- /dev/null +++ b/includes/footer.php @@ -0,0 +1,77 @@ + + + + + + + + + + + + + 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..247cde7 --- /dev/null +++ b/includes/header.php @@ -0,0 +1,116 @@ + + + + + + + <?= $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..47429d3 --- /dev/null +++ b/index.php @@ -0,0 +1,221 @@ +fetchAll( + "SELECT * FROM about_us_sections WHERE is_active = 1 ORDER BY sort_order ASC" +); + +// Get homepage splashes +$splashBlocks = db()->fetchAll( + "SELECT * FROM homepage_splashes WHERE is_active = 1 ORDER BY sort_order ASC, id ASC" +); + +// Get featured products +$featuredProducts = db()->fetchAll( + "SELECT * FROM products WHERE is_active = 1 AND is_featured = 1 ORDER BY created_at DESC LIMIT 4" +); + +// If no featured products, get latest products +if (empty($featuredProducts)) { + $featuredProducts = db()->fetchAll( + "SELECT * FROM products WHERE is_active = 1 ORDER BY created_at DESC LIMIT 4" + ); +} + +$metaTitle = "Fresh Roasted Artisan Coffee | Tom's Java Jive"; +$metaDescription = "Premium artisan coffee beans freshly roasted and delivered to your door. Shop single origin, blends, and specialty coffee from Tom's Java Jive in Weatherford, Texas."; +$metaKeywords = 'artisan coffee beans, fresh roasted coffee, single origin coffee, specialty coffee, Weatherford Texas'; +$canonicalUrl = 'https://tomsjavajive.com/'; +require_once __DIR__ . '/includes/header.php'; +?> + + +
    +
    +

    Premium Coffee, Delivered Fresh

    +

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

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

    +

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

    Featured Products

    +

    Our most popular coffee selections

    +
    + +
    + +
    +

    Products coming soon! Check back later.

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

    + +

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

    Our Story

    + + +

    + + +

    + +

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

    Welcome Back

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

    + Don't have an account? Create one +

    + +

    + Or track your order with your order number +

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

    Create Your Account

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

    + Already have an account? Sign in +

    +
    +
    +
    +
    + + diff --git a/robots.txt b/robots.txt new file mode 100644 index 0000000..4eb8727 --- /dev/null +++ b/robots.txt @@ -0,0 +1,11 @@ +User-agent: * +Allow: / +Disallow: /admin/ +Disallow: /account/ +Disallow: /api/ +Disallow: /cart.php +Disallow: /checkout.php +Disallow: /payment.php +Disallow: /config/ +Disallow: /install/ +Sitemap: https://tomsjavajive.com/sitemap.xml diff --git a/shop.php b/shop.php new file mode 100644 index 0000000..2afee2c --- /dev/null +++ b/shop.php @@ -0,0 +1,170 @@ + '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" +); + +$metaTitle = "Shop Premium Coffee Beans | Tom's Java Jive"; +$metaDescription = 'Browse our selection of premium artisan coffee beans. Single origin, blends, light, medium and dark roasts. Free shipping over $50.'; +$metaKeywords = 'buy coffee beans online, artisan coffee, single origin, blends, light roast, dark roast'; +$canonicalUrl = 'https://tomsjavajive.com/shop.php'; +require_once __DIR__ . '/includes/header.php'; +$productTypesList = db()->fetchAll("SELECT type_id, name, slug FROM product_types WHERE is_active=1 ORDER BY sort_order ASC"); + +?> + + +
    +
    +

    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/sitemap.xml b/sitemap.xml new file mode 100644 index 0000000..7a7b66c --- /dev/null +++ b/sitemap.xml @@ -0,0 +1,6 @@ + + + https://tomsjavajive.com/2026-05-19weekly1.0 + https://tomsjavajive.com/shop.php2026-05-19daily0.9 + https://tomsjavajive.com/register.php2026-05-19monthly0.4 + \ No newline at end of file diff --git a/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...'); +}