Initial commit

This commit is contained in:
2026-05-22 12:52:44 +00:00
commit 996ca0d621
122 changed files with 22749 additions and 0 deletions
+105
View File
@@ -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(' <li><a href="/wholesale.php">Wholesale</a></li>\n', '')
c = c.replace(' <li><a href="/careers.php">Careers</a></li>\n', '')
# Also try without leading spaces
c = c.replace('<li><a href="/wholesale.php">Wholesale</a></li>\n', '')
c = c.replace('<li><a href="/careers.php">Careers</a></li>\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!")
+37
View File
@@ -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`;
+96
View File
@@ -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;
+402
View File
@@ -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;
+6
View File
@@ -0,0 +1,6 @@
*.log
.DS_Store
*.swp
config/database.php
uploads/
+70
View File
@@ -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)
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/plain text/css text/javascript application/javascript application/json
</IfModule>
# Browser caching (optional)
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType image/jpg "access plus 1 year"
ExpiresByType image/jpeg "access plus 1 year"
ExpiresByType image/png "access plus 1 year"
ExpiresByType image/webp "access plus 1 year"
ExpiresByType text/css "access plus 1 month"
ExpiresByType application/javascript "access plus 1 month"
</IfModule>
# Security headers
<IfModule mod_headers.c>
Header set X-Content-Type-Options "nosniff"
Header set X-Frame-Options "SAMEORIGIN"
Header set X-XSS-Protection "1; mode=block"
</IfModule>
# Custom error pages (optional)
# ErrorDocument 404 /404.php
# ErrorDocument 500 /500.php
# SEO ADDITIONS
RewriteCond %{HTTP_HOST} ^www\.tomsjavajive\.com [NC]
RewriteRule ^(.*)$ https://tomsjavajive.com/$1 [R=301,L]
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType image/jpg "access plus 1 year"
ExpiresByType image/jpeg "access plus 1 year"
ExpiresByType image/png "access plus 1 year"
ExpiresByType image/webp "access plus 1 year"
ExpiresByType text/css "access plus 1 month"
ExpiresByType application/javascript "access plus 1 month"
</IfModule>
+208
View File
@@ -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
+289
View File
@@ -0,0 +1,289 @@
<?php
/**
* Tom's Java Jive - Customer Addresses
*/
$pageTitle = "My Addresses - Tom's Java Jive";
require_once __DIR__ . '/../includes/functions.php';
require_once __DIR__ . '/../includes/auth.php';
CustomerAuth::require();
$customer = CustomerAuth::getFullUser();
$currentPage = 'addresses';
// Get addresses from customer record
$addresses = json_decode($customer['addresses'] ?? '[]', true);
if (!is_array($addresses)) $addresses = [];
// Handle form submissions
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
if ($action === 'add' || $action === 'edit') {
$index = isset($_POST['index']) ? intval($_POST['index']) : -1;
$address = [
'id' => $index >= 0 && isset($addresses[$index]['id']) ? $addresses[$index]['id'] : uniqid('addr_'),
'name' => trim($_POST['name'] ?? ''),
'phone' => trim($_POST['phone'] ?? ''),
'address' => trim($_POST['address'] ?? ''),
'address2' => trim($_POST['address2'] ?? ''),
'city' => trim($_POST['city'] ?? ''),
'state' => trim($_POST['state'] ?? ''),
'zip' => trim($_POST['zip'] ?? ''),
'country' => trim($_POST['country'] ?? 'USA'),
'is_default' => isset($_POST['is_default']),
];
// Validate
if (empty($address['name']) || empty($address['address']) || empty($address['city']) || empty($address['zip'])) {
setFlash('error', 'Please fill in all required fields');
} else {
// Handle default
if ($address['is_default']) {
foreach ($addresses as &$a) {
$a['is_default'] = false;
}
}
if ($index >= 0 && isset($addresses[$index])) {
$addresses[$index] = $address;
} else {
$addresses[] = $address;
}
// Save
db()->query(
"UPDATE customers SET addresses = :addresses WHERE customer_id = :id",
['addresses' => json_encode($addresses), 'id' => $customer['customer_id']]
);
setFlash('success', $action === 'add' ? 'Address added' : 'Address updated');
redirect('/account/addresses.php');
}
}
if ($action === 'delete') {
$index = intval($_POST['index'] ?? -1);
if ($index >= 0 && isset($addresses[$index])) {
array_splice($addresses, $index, 1);
db()->query(
"UPDATE customers SET addresses = :addresses WHERE customer_id = :id",
['addresses' => json_encode($addresses), 'id' => $customer['customer_id']]
);
setFlash('success', 'Address deleted');
}
redirect('/account/addresses.php');
}
if ($action === 'set_default') {
$index = intval($_POST['index'] ?? -1);
if ($index >= 0 && isset($addresses[$index])) {
foreach ($addresses as $i => &$a) {
$a['is_default'] = ($i === $index);
}
db()->query(
"UPDATE customers SET addresses = :addresses WHERE customer_id = :id",
['addresses' => json_encode($addresses), 'id' => $customer['customer_id']]
);
setFlash('success', 'Default address updated');
}
redirect('/account/addresses.php');
}
}
require_once __DIR__ . '/../includes/header.php';
require_once __DIR__ . '/includes/sidebar.php';
?>
<div class="account-header" style="display: flex; justify-content: space-between; align-items: center;">
<div>
<h1>My Addresses</h1>
<p class="text-muted">Manage your shipping and billing addresses</p>
</div>
<button class="btn btn-primary" onclick="openAddressModal()">
<i class="fas fa-plus"></i> Add Address
</button>
</div>
<?php if (hasFlash('success')): ?>
<div class="alert alert-success mb-2">
<i class="fas fa-check-circle"></i> <?= getFlash('success') ?>
</div>
<?php endif; ?>
<?php if (hasFlash('error')): ?>
<div class="alert alert-error mb-2">
<i class="fas fa-exclamation-circle"></i> <?= getFlash('error') ?>
</div>
<?php endif; ?>
<?php if (empty($addresses)): ?>
<div class="section-card">
<div class="section-card-body text-center" style="padding: 3rem;">
<i class="fas fa-map-marker-alt" style="font-size: 3rem; color: var(--color-text-muted); margin-bottom: 1rem;"></i>
<h3>No addresses saved</h3>
<p class="text-muted">Add an address for faster checkout.</p>
<button class="btn btn-primary mt-1" onclick="openAddressModal()">
<i class="fas fa-plus"></i> Add Address
</button>
</div>
</div>
<?php else: ?>
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1.5rem;">
<?php foreach ($addresses as $index => $addr): ?>
<div class="section-card">
<div class="section-card-body">
<?php if (!empty($addr['is_default'])): ?>
<span class="badge badge-primary" style="margin-bottom: 0.75rem;">Default</span>
<?php endif; ?>
<h4 style="margin: 0 0 0.5rem;"><?= htmlspecialchars($addr['name']) ?></h4>
<p style="margin: 0 0 0.25rem;"><?= htmlspecialchars($addr['address']) ?></p>
<?php if (!empty($addr['address2'])): ?>
<p style="margin: 0 0 0.25rem;"><?= htmlspecialchars($addr['address2']) ?></p>
<?php endif; ?>
<p style="margin: 0 0 0.25rem;">
<?= htmlspecialchars($addr['city']) ?>, <?= htmlspecialchars($addr['state']) ?> <?= htmlspecialchars($addr['zip']) ?>
</p>
<p style="margin: 0 0 0.75rem;"><?= htmlspecialchars($addr['country'] ?? 'USA') ?></p>
<?php if (!empty($addr['phone'])): ?>
<p class="text-muted" style="margin: 0 0 1rem;">
<i class="fas fa-phone"></i> <?= htmlspecialchars($addr['phone']) ?>
</p>
<?php endif; ?>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
<button class="btn btn-sm btn-secondary" onclick="editAddress(<?= $index ?>)">
<i class="fas fa-edit"></i> Edit
</button>
<?php if (empty($addr['is_default'])): ?>
<form method="POST" style="display: inline;">
<input type="hidden" name="action" value="set_default">
<input type="hidden" name="index" value="<?= $index ?>">
<button type="submit" class="btn btn-sm btn-secondary">
<i class="fas fa-check"></i> Make Default
</button>
</form>
<?php endif; ?>
<form method="POST" style="display: inline;" onsubmit="return confirm('Delete this address?')">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="index" value="<?= $index ?>">
<button type="submit" class="btn btn-sm btn-danger">
<i class="fas fa-trash"></i>
</button>
</form>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<!-- Address Modal -->
<div class="modal-overlay" id="addressModal">
<div class="modal">
<div class="modal-header">
<h3 class="modal-title" id="addressModalTitle">Add Address</h3>
<button type="button" class="modal-close" onclick="Modal.close('addressModal')">&times;</button>
</div>
<form method="POST" id="addressForm">
<input type="hidden" name="action" id="addressAction" value="add">
<input type="hidden" name="index" id="addressIndex" value="-1">
<div class="modal-body">
<div class="form-group">
<label class="form-label">Full Name *</label>
<input type="text" name="name" id="addr_name" class="form-input" required>
</div>
<div class="form-group">
<label class="form-label">Phone Number</label>
<input type="tel" name="phone" id="addr_phone" class="form-input">
</div>
<div class="form-group">
<label class="form-label">Street Address *</label>
<input type="text" name="address" id="addr_address" class="form-input" required>
</div>
<div class="form-group">
<label class="form-label">Apartment, suite, etc.</label>
<input type="text" name="address2" id="addr_address2" class="form-input">
</div>
<div style="display: grid; grid-template-columns: 2fr 1fr 1fr; gap: 1rem;">
<div class="form-group">
<label class="form-label">City *</label>
<input type="text" name="city" id="addr_city" class="form-input" required>
</div>
<div class="form-group">
<label class="form-label">State *</label>
<input type="text" name="state" id="addr_state" class="form-input" required>
</div>
<div class="form-group">
<label class="form-label">ZIP *</label>
<input type="text" name="zip" id="addr_zip" class="form-input" required>
</div>
</div>
<div class="form-group">
<label class="form-label">Country</label>
<select name="country" id="addr_country" class="form-select">
<option value="USA">United States</option>
<option value="Canada">Canada</option>
</select>
</div>
<div class="form-group">
<label class="form-checkbox">
<input type="checkbox" name="is_default" id="addr_default">
Set as default address
</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="Modal.close('addressModal')">Cancel</button>
<button type="submit" class="btn btn-primary">Save Address</button>
</div>
</form>
</div>
</div>
<script>
const addresses = <?= json_encode($addresses) ?>;
function openAddressModal() {
document.getElementById('addressModalTitle').textContent = 'Add Address';
document.getElementById('addressAction').value = 'add';
document.getElementById('addressIndex').value = '-1';
document.getElementById('addressForm').reset();
Modal.open('addressModal');
}
function editAddress(index) {
const addr = addresses[index];
if (!addr) return;
document.getElementById('addressModalTitle').textContent = 'Edit Address';
document.getElementById('addressAction').value = 'edit';
document.getElementById('addressIndex').value = index;
document.getElementById('addr_name').value = addr.name || '';
document.getElementById('addr_phone').value = addr.phone || '';
document.getElementById('addr_address').value = addr.address || '';
document.getElementById('addr_address2').value = addr.address2 || '';
document.getElementById('addr_city').value = addr.city || '';
document.getElementById('addr_state').value = addr.state || '';
document.getElementById('addr_zip').value = addr.zip || '';
document.getElementById('addr_country').value = addr.country || 'USA';
document.getElementById('addr_default').checked = !!addr.is_default;
Modal.open('addressModal');
}
</script>
<?php require_once __DIR__ . '/includes/footer.php'; ?>
+6
View File
@@ -0,0 +1,6 @@
</div>
</div>
</div>
</section>
<?php require_once __DIR__ . '/../../includes/footer.php'; ?>
+132
View File
@@ -0,0 +1,132 @@
<?php
/**
* Account Page Sidebar Include
*/
$customer = CustomerAuth::getFullUser();
?>
<style>
.account-layout {
display: grid;
grid-template-columns: 250px 1fr;
gap: 2rem;
}
.account-sidebar {
background: var(--color-surface);
border-radius: var(--radius-lg);
padding: 1.5rem;
height: fit-content;
position: sticky;
top: 90px;
}
.account-nav {
list-style: none;
}
.account-nav li {
margin-bottom: 0.5rem;
}
.account-nav a {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-radius: var(--radius-md);
color: var(--color-text);
transition: all 0.2s;
}
.account-nav a:hover {
background: var(--color-background);
color: var(--color-primary);
}
.account-nav a.active {
background: var(--color-primary);
color: white;
}
.account-nav a i {
width: 20px;
text-align: center;
}
.account-content {
min-height: 500px;
}
.account-header {
margin-bottom: 2rem;
}
.account-header h1 {
font-size: 1.75rem;
margin-bottom: 0.5rem;
}
.section-card {
background: var(--color-surface);
border-radius: var(--radius-lg);
margin-bottom: 1.5rem;
}
.section-card-header {
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--color-border);
display: flex;
justify-content: space-between;
align-items: center;
}
.section-card-header h3 {
margin: 0;
font-size: 1rem;
}
.section-card-body {
padding: 1.5rem;
}
@media (max-width: 768px) {
.account-layout {
grid-template-columns: 1fr;
}
.account-sidebar {
position: static;
}
}
</style>
<section class="section" style="padding-top: 2rem;">
<div class="container">
<div class="account-layout">
<!-- Sidebar -->
<aside class="account-sidebar">
<div style="text-align: center; margin-bottom: 1.5rem;">
<div style="width: 80px; height: 80px; background: var(--color-primary); color: white; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 2rem; margin: 0 auto 1rem;">
<?= strtoupper(substr($customer['name'] ?? $customer['email'], 0, 1)) ?>
</div>
<h3 style="margin: 0 0 0.25rem; font-size: 1rem;"><?= htmlspecialchars($customer['name'] ?? 'Customer') ?></h3>
<p class="text-muted" style="font-size: 0.875rem; margin: 0;"><?= htmlspecialchars($customer['email']) ?></p>
</div>
<ul class="account-nav">
<li><a href="/account/" class="<?= ($currentPage ?? '') === 'dashboard' ? 'active' : '' ?>"><i class="fas fa-tachometer-alt"></i> Dashboard</a></li>
<li><a href="/account/orders.php" class="<?= ($currentPage ?? '') === 'orders' ? 'active' : '' ?>"><i class="fas fa-shopping-bag"></i> My Orders</a></li>
<li><a href="/account/wishlist.php" class="<?= ($currentPage ?? '') === 'wishlist' ? 'active' : '' ?>"><i class="fas fa-heart"></i> Wishlist</a></li>
<li><a href="/account/wallet.php" class="<?= ($currentPage ?? '') === 'wallet' ? 'active' : '' ?>"><i class="fas fa-wallet"></i> Wallet</a></li>
<li><a href="/account/rewards.php" class="<?= ($currentPage ?? '') === 'rewards' ? 'active' : '' ?>"><i class="fas fa-crown"></i> Rewards</a></li>
<li><a href="/account/addresses.php" class="<?= ($currentPage ?? '') === 'addresses' ? 'active' : '' ?>"><i class="fas fa-map-marker-alt"></i> Addresses</a></li>
<li><a href="/account/profile.php" class="<?= ($currentPage ?? '') === 'profile' ? 'active' : '' ?>"><i class="fas fa-user"></i> Profile</a></li>
<li><a href="/account/reviews.php" class="<?= ($currentPage ?? '') === 'reviews' ? 'active' : '' ?>"><i class="fas fa-star"></i> My Reviews</a></li>
<li><a href="/logout.php" style="color: var(--color-error);"><i class="fas fa-sign-out-alt"></i> Logout</a></li>
</ul>
</aside>
<!-- Main Content -->
<div class="account-content">
+357
View File
@@ -0,0 +1,357 @@
<?php
/**
* Tom's Java Jive - Customer Account Dashboard
*/
$pageTitle = "My Account - Tom's Java Jive";
require_once __DIR__ . '/../includes/functions.php';
require_once __DIR__ . '/../includes/auth.php';
CustomerAuth::require();
$customer = CustomerAuth::getFullUser();
// Get recent orders
$recentOrders = db()->fetchAll(
"SELECT * FROM orders WHERE customer_id = :id ORDER BY created_at DESC LIMIT 5",
['id' => $customer['customer_id']]
);
// Get order stats
$orderStats = db()->fetch(
"SELECT COUNT(*) as total_orders, COALESCE(SUM(total), 0) as total_spent
FROM orders WHERE customer_id = :id AND payment_status = 'paid'",
['id' => $customer['customer_id']]
);
// Get wishlist count
$wishlistCount = db()->count('wishlist', 'customer_id = :id', ['id' => $customer['customer_id']]);
// Get recent wallet transactions
$walletTransactions = db()->fetchAll(
"SELECT * FROM wallet_transactions WHERE customer_id = :id ORDER BY created_at DESC LIMIT 5",
['id' => $customer['customer_id']]
);
require_once __DIR__ . '/../includes/header.php';
?>
<style>
.account-layout {
display: grid;
grid-template-columns: 250px 1fr;
gap: 2rem;
}
.account-sidebar {
background: var(--color-surface);
border-radius: var(--radius-lg);
padding: 1.5rem;
height: fit-content;
position: sticky;
top: 90px;
}
.account-nav {
list-style: none;
}
.account-nav li {
margin-bottom: 0.5rem;
}
.account-nav a {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-radius: var(--radius-md);
color: var(--color-text);
transition: all 0.2s;
}
.account-nav a:hover {
background: var(--color-background);
color: var(--color-primary);
}
.account-nav a.active {
background: var(--color-primary);
color: white;
}
.account-nav a i {
width: 20px;
text-align: center;
}
.account-content {
min-height: 500px;
}
.account-header {
margin-bottom: 2rem;
}
.account-header h1 {
font-size: 1.75rem;
margin-bottom: 0.5rem;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
margin-bottom: 2rem;
}
.dashboard-stat {
background: var(--color-surface);
border-radius: var(--radius-lg);
padding: 1.5rem;
text-align: center;
}
.dashboard-stat-icon {
width: 50px;
height: 50px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 1rem;
font-size: 1.25rem;
}
.dashboard-stat-icon.primary {
background: rgba(255, 94, 26, 0.1);
color: var(--color-primary);
}
.dashboard-stat-icon.success {
background: rgba(16, 185, 129, 0.1);
color: var(--color-success);
}
.dashboard-stat-icon.warning {
background: rgba(245, 158, 11, 0.1);
color: var(--color-warning);
}
.dashboard-stat-value {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 0.25rem;
}
.dashboard-stat-label {
color: var(--color-text-muted);
font-size: 0.875rem;
}
.section-card {
background: var(--color-surface);
border-radius: var(--radius-lg);
margin-bottom: 1.5rem;
}
.section-card-header {
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--color-border);
display: flex;
justify-content: space-between;
align-items: center;
}
.section-card-header h3 {
margin: 0;
font-size: 1rem;
}
.section-card-body {
padding: 1.5rem;
}
.order-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 0;
border-bottom: 1px solid var(--color-border);
}
.order-item:last-child {
border-bottom: none;
padding-bottom: 0;
}
.order-item:first-child {
padding-top: 0;
}
.order-info h4 {
margin: 0 0 0.25rem;
font-size: 0.9375rem;
}
.order-info p {
margin: 0;
color: var(--color-text-muted);
font-size: 0.875rem;
}
@media (max-width: 768px) {
.account-layout {
grid-template-columns: 1fr;
}
.account-sidebar {
position: static;
}
.dashboard-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>
<section class="section" style="padding-top: 2rem;">
<div class="container">
<?php if (hasFlash('success')): ?>
<div class="alert alert-success mb-2">
<i class="fas fa-check-circle"></i> <?= getFlash('success') ?>
</div>
<?php endif; ?>
<div class="account-layout">
<!-- Sidebar -->
<aside class="account-sidebar">
<div style="text-align: center; margin-bottom: 1.5rem;">
<div style="width: 80px; height: 80px; background: var(--color-primary); color: white; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 2rem; margin: 0 auto 1rem;">
<?= strtoupper(substr($customer['name'] ?? $customer['email'], 0, 1)) ?>
</div>
<h3 style="margin: 0 0 0.25rem; font-size: 1rem;"><?= htmlspecialchars($customer['name'] ?? 'Customer') ?></h3>
<p class="text-muted" style="font-size: 0.875rem; margin: 0;"><?= htmlspecialchars($customer['email']) ?></p>
</div>
<ul class="account-nav">
<li><a href="/account/" class="active"><i class="fas fa-tachometer-alt"></i> Dashboard</a></li>
<li><a href="/account/orders.php"><i class="fas fa-shopping-bag"></i> My Orders</a></li>
<li><a href="/account/wishlist.php"><i class="fas fa-heart"></i> Wishlist</a></li>
<li><a href="/account/wallet.php"><i class="fas fa-wallet"></i> Wallet</a></li>
<li><a href="/account/addresses.php"><i class="fas fa-map-marker-alt"></i> Addresses</a></li>
<li><a href="/account/profile.php"><i class="fas fa-user"></i> Profile</a></li>
<li><a href="/account/reviews.php"><i class="fas fa-star"></i> My Reviews</a></li>
<li><a href="/logout.php" style="color: var(--color-error);"><i class="fas fa-sign-out-alt"></i> Logout</a></li>
</ul>
</aside>
<!-- Main Content -->
<div class="account-content">
<div class="account-header">
<h1>Welcome back, <?= htmlspecialchars($customer['name'] ?? 'Coffee Lover') ?>!</h1>
<p class="text-muted">Here's an overview of your account activity.</p>
</div>
<!-- Stats Grid -->
<div class="dashboard-grid">
<div class="dashboard-stat">
<div class="dashboard-stat-icon primary">
<i class="fas fa-shopping-bag"></i>
</div>
<div class="dashboard-stat-value"><?= $orderStats['total_orders'] ?? 0 ?></div>
<div class="dashboard-stat-label">Total Orders</div>
</div>
<div class="dashboard-stat">
<div class="dashboard-stat-icon success">
<i class="fas fa-dollar-sign"></i>
</div>
<div class="dashboard-stat-value"><?= formatCurrency($orderStats['total_spent'] ?? 0) ?></div>
<div class="dashboard-stat-label">Total Spent</div>
</div>
<div class="dashboard-stat">
<div class="dashboard-stat-icon warning">
<i class="fas fa-wallet"></i>
</div>
<div class="dashboard-stat-value"><?= formatCurrency($customer['wallet_balance'] ?? 0) ?></div>
<div class="dashboard-stat-label">Wallet Balance</div>
</div>
<div class="dashboard-stat">
<div class="dashboard-stat-icon primary">
<i class="fas fa-star"></i>
</div>
<div class="dashboard-stat-value"><?= number_format($customer['reward_points'] ?? 0) ?></div>
<div class="dashboard-stat-label">Reward Points</div>
</div>
</div>
<!-- Recent Orders -->
<div class="section-card">
<div class="section-card-header">
<h3><i class="fas fa-clock"></i> Recent Orders</h3>
<a href="/account/orders.php" class="btn btn-sm btn-secondary">View All</a>
</div>
<div class="section-card-body">
<?php if (empty($recentOrders)): ?>
<p class="text-muted text-center" style="padding: 2rem 0;">
<i class="fas fa-shopping-bag" style="font-size: 2rem; opacity: 0.5; display: block; margin-bottom: 1rem;"></i>
No orders yet. <a href="/shop.php">Start shopping!</a>
</p>
<?php else: ?>
<?php foreach ($recentOrders as $order): ?>
<div class="order-item">
<div class="order-info">
<h4><a href="/account/order.php?id=<?= $order['order_id'] ?>">Order #<?= htmlspecialchars($order['order_number']) ?></a></h4>
<p><?= formatDate($order['created_at']) ?> · <?= count(json_decode($order['items'], true)) ?> items</p>
</div>
<div style="text-align: right;">
<div style="font-weight: 600; margin-bottom: 0.25rem;"><?= formatCurrency($order['total']) ?></div>
<?php
$statusClass = match($order['order_status']) {
'pending' => 'warning',
'confirmed', 'processing' => 'primary',
'shipped' => 'primary',
'delivered' => 'success',
'cancelled', 'refunded' => 'error',
default => 'primary'
};
?>
<span class="badge badge-<?= $statusClass ?>"><?= ucfirst($order['order_status']) ?></span>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
<!-- Wallet Activity -->
<?php if (!empty($walletTransactions)): ?>
<div class="section-card">
<div class="section-card-header">
<h3><i class="fas fa-wallet"></i> Recent Wallet Activity</h3>
<a href="/account/wallet.php" class="btn btn-sm btn-secondary">View All</a>
</div>
<div class="section-card-body">
<?php foreach ($walletTransactions as $tx): ?>
<div class="order-item">
<div class="order-info">
<h4><?= htmlspecialchars($tx['description'] ?? ucfirst($tx['type'])) ?></h4>
<p><?= formatDateTime($tx['created_at']) ?></p>
</div>
<div style="text-align: right;">
<div style="font-weight: 600; color: <?= $tx['amount'] > 0 ? 'var(--color-success)' : 'var(--color-error)' ?>;">
<?= $tx['amount'] > 0 ? '+' : '' ?><?= formatCurrency($tx['amount']) ?>
</div>
<small class="text-muted">Balance: <?= formatCurrency($tx['balance_after']) ?></small>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
</div>
</div>
</section>
<?php require_once __DIR__ . '/../includes/footer.php'; ?>
+246
View File
@@ -0,0 +1,246 @@
<?php
/**
* Tom's Java Jive - Order Detail Page
*/
$pageTitle = "Order Details - Tom's Java Jive";
require_once __DIR__ . '/../includes/functions.php';
require_once __DIR__ . '/../includes/auth.php';
CustomerAuth::require();
$customer = CustomerAuth::getFullUser();
$currentPage = 'orders';
$orderId = $_GET['id'] ?? '';
$order = db()->fetch(
"SELECT * FROM orders WHERE order_id = :id AND customer_id = :cid",
['id' => $orderId, 'cid' => $customer['customer_id']]
);
if (!$order) {
redirect('/account/orders.php');
}
$items = json_decode($order['items'], true);
$shippingAddress = json_decode($order['shipping_address'] ?? '{}', true);
require_once __DIR__ . '/../includes/header.php';
require_once __DIR__ . '/includes/sidebar.php';
$statusClass = match($order['order_status']) {
'pending' => 'warning',
'confirmed', 'processing' => 'primary',
'shipped' => 'primary',
'delivered' => 'success',
'cancelled', 'refunded' => 'error',
default => 'primary'
};
?>
<div class="account-header">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<a href="/account/orders.php" class="text-muted" style="font-size: 0.875rem;">
<i class="fas fa-arrow-left"></i> Back to Orders
</a>
<h1 style="margin-top: 0.5rem;">Order #<?= htmlspecialchars($order['order_number']) ?></h1>
</div>
<span class="badge badge-<?= $statusClass ?>" style="font-size: 1rem; padding: 0.5rem 1rem;">
<?= ucfirst($order['order_status']) ?>
</span>
</div>
</div>
<!-- Order Progress -->
<?php if (!in_array($order['order_status'], ['cancelled', 'refunded'])): ?>
<div class="section-card">
<div class="section-card-body">
<?php
$steps = ['pending', 'confirmed', 'processing', 'shipped', 'delivered'];
$currentStep = array_search($order['order_status'], $steps);
if ($currentStep === false) $currentStep = 0;
?>
<div style="display: flex; justify-content: space-between; position: relative;">
<div style="position: absolute; top: 15px; left: 0; right: 0; height: 2px; background: var(--color-border);"></div>
<div style="position: absolute; top: 15px; left: 0; width: <?= ($currentStep / 4) * 100 ?>%; height: 2px; background: var(--color-primary); transition: width 0.3s;"></div>
<?php foreach ($steps as $i => $step): ?>
<div style="text-align: center; position: relative; z-index: 1;">
<div style="width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 0.5rem; <?= $i <= $currentStep ? 'background: var(--color-primary); color: white;' : 'background: var(--color-surface); border: 2px solid var(--color-border);' ?>">
<?php if ($i < $currentStep): ?>
<i class="fas fa-check"></i>
<?php else: ?>
<?= $i + 1 ?>
<?php endif; ?>
</div>
<div style="font-size: 0.75rem; <?= $i <= $currentStep ? 'color: var(--color-text);' : 'color: var(--color-text-muted);' ?>">
<?= ucfirst($step) ?>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<?php endif; ?>
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 1.5rem;">
<div>
<!-- Order Items -->
<div class="section-card">
<div class="section-card-header">
<h3>Order Items</h3>
</div>
<div class="section-card-body" style="padding: 0;">
<table class="table">
<thead>
<tr>
<th>Product</th>
<th style="text-align: center;">Qty</th>
<th style="text-align: right;">Price</th>
<th style="text-align: right;">Total</th>
</tr>
</thead>
<tbody>
<?php foreach ($items as $item): ?>
<tr>
<td>
<strong><?= htmlspecialchars($item['name']) ?></strong>
<?php if (!empty($item['options'])): ?>
<br><small class="text-muted"><?= htmlspecialchars($item['options']) ?></small>
<?php endif; ?>
</td>
<td style="text-align: center;"><?= $item['quantity'] ?></td>
<td style="text-align: right;"><?= formatCurrency($item['price']) ?></td>
<td style="text-align: right;"><?= formatCurrency($item['total']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<!-- Shipping Info -->
<?php if (!empty($shippingAddress)): ?>
<div class="section-card">
<div class="section-card-header">
<h3>Shipping Information</h3>
</div>
<div class="section-card-body">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 2rem;">
<div>
<h4 style="margin: 0 0 0.5rem; font-size: 0.875rem; color: var(--color-text-muted);">Shipping Address</h4>
<p style="margin: 0;">
<?= htmlspecialchars($shippingAddress['name'] ?? $order['customer_name']) ?><br>
<?= htmlspecialchars($shippingAddress['address'] ?? '') ?><br>
<?= htmlspecialchars($shippingAddress['city'] ?? '') ?>, <?= htmlspecialchars($shippingAddress['state'] ?? '') ?> <?= htmlspecialchars($shippingAddress['zip'] ?? '') ?><br>
<?= htmlspecialchars($shippingAddress['country'] ?? '') ?>
</p>
</div>
<?php if ($order['tracking_number']): ?>
<div>
<h4 style="margin: 0 0 0.5rem; font-size: 0.875rem; color: var(--color-text-muted);">Tracking</h4>
<p style="margin: 0 0 1rem;">
<strong><?= htmlspecialchars($order['tracking_number']) ?></strong>
</p>
<?php if ($order['tracking_url']): ?>
<a href="<?= htmlspecialchars($order['tracking_url']) ?>" target="_blank" class="btn btn-sm btn-primary">
<i class="fas fa-truck"></i> Track Package
</a>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
</div>
</div>
<?php endif; ?>
</div>
<div>
<!-- Order Summary -->
<div class="section-card">
<div class="section-card-header">
<h3>Order Summary</h3>
</div>
<div class="section-card-body">
<div style="margin-bottom: 1rem;">
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;">
<span class="text-muted">Subtotal</span>
<span><?= formatCurrency($order['subtotal']) ?></span>
</div>
<?php if ($order['discount'] > 0): ?>
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem; color: var(--color-success);">
<span>Discount</span>
<span>-<?= formatCurrency($order['discount']) ?></span>
</div>
<?php endif; ?>
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;">
<span class="text-muted">Shipping</span>
<span><?= $order['shipping'] > 0 ? formatCurrency($order['shipping']) : 'Free' ?></span>
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;">
<span class="text-muted">Tax</span>
<span><?= formatCurrency($order['tax']) ?></span>
</div>
<?php if ($order['wallet_amount_used'] > 0): ?>
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem; color: var(--color-success);">
<span>Wallet</span>
<span>-<?= formatCurrency($order['wallet_amount_used']) ?></span>
</div>
<?php endif; ?>
</div>
<div style="border-top: 2px solid var(--color-border); padding-top: 1rem; display: flex; justify-content: space-between;">
<strong>Total</strong>
<strong style="font-size: 1.25rem; color: var(--color-primary);"><?= formatCurrency($order['total']) ?></strong>
</div>
</div>
</div>
<!-- Payment Info -->
<div class="section-card">
<div class="section-card-header">
<h3>Payment</h3>
</div>
<div class="section-card-body">
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;">
<span class="text-muted">Method</span>
<span><?= ucfirst($order['payment_method'] ?? 'N/A') ?></span>
</div>
<div style="display: flex; justify-content: space-between;">
<span class="text-muted">Status</span>
<span class="badge badge-<?= $order['payment_status'] === 'paid' ? 'success' : 'warning' ?>">
<?= ucfirst($order['payment_status'] ?? 'Pending') ?>
</span>
</div>
</div>
</div>
<!-- Order Date -->
<div class="section-card">
<div class="section-card-body">
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;">
<span class="text-muted">Order Date</span>
<span><?= formatDateTime($order['created_at']) ?></span>
</div>
<?php if ($order['updated_at']): ?>
<div style="display: flex; justify-content: space-between;">
<span class="text-muted">Last Updated</span>
<span><?= formatDateTime($order['updated_at']) ?></span>
</div>
<?php endif; ?>
</div>
</div>
<!-- Need Help -->
<div class="section-card">
<div class="section-card-body text-center">
<p class="text-muted" style="margin: 0 0 1rem;">Need help with this order?</p>
<a href="/contact.php?order=<?= $order['order_number'] ?>" class="btn btn-secondary btn-block">
<i class="fas fa-headset"></i> Contact Support
</a>
</div>
</div>
</div>
</div>
<?php require_once __DIR__ . '/includes/footer.php'; ?>
+137
View File
@@ -0,0 +1,137 @@
<?php
/**
* Tom's Java Jive - Customer Orders
*/
$pageTitle = "My Orders - Tom's Java Jive";
require_once __DIR__ . '/../includes/functions.php';
require_once __DIR__ . '/../includes/auth.php';
CustomerAuth::require();
$customer = CustomerAuth::getFullUser();
$currentPage = 'orders';
// Pagination
$page = max(1, intval($_GET['page'] ?? 1));
$status = $_GET['status'] ?? '';
$where = 'customer_id = :id';
$params = ['id' => $customer['customer_id']];
if ($status) {
$where .= ' AND order_status = :status';
$params['status'] = $status;
}
$total = db()->count('orders', $where, $params);
$pagination = paginate($total, $page, 10);
$orders = db()->fetchAll(
"SELECT * FROM orders WHERE {$where} ORDER BY created_at DESC LIMIT :limit OFFSET :offset",
array_merge($params, ['limit' => $pagination['per_page'], 'offset' => $pagination['offset']])
);
require_once __DIR__ . '/../includes/header.php';
require_once __DIR__ . '/includes/sidebar.php';
?>
<div class="account-header">
<h1>My Orders</h1>
<p class="text-muted">View and track your order history</p>
</div>
<!-- Filter -->
<div class="section-card">
<div class="section-card-body" style="padding: 1rem 1.5rem;">
<form method="GET" style="display: flex; gap: 1rem; flex-wrap: wrap; align-items: center;">
<select name="status" class="form-select" style="width: auto;" onchange="this.form.submit()">
<option value="">All Orders</option>
<option value="pending" <?= $status === 'pending' ? 'selected' : '' ?>>Pending</option>
<option value="confirmed" <?= $status === 'confirmed' ? 'selected' : '' ?>>Confirmed</option>
<option value="processing" <?= $status === 'processing' ? 'selected' : '' ?>>Processing</option>
<option value="shipped" <?= $status === 'shipped' ? 'selected' : '' ?>>Shipped</option>
<option value="delivered" <?= $status === 'delivered' ? 'selected' : '' ?>>Delivered</option>
<option value="cancelled" <?= $status === 'cancelled' ? 'selected' : '' ?>>Cancelled</option>
</select>
<span class="text-muted"><?= $total ?> orders found</span>
</form>
</div>
</div>
<?php if (empty($orders)): ?>
<div class="section-card">
<div class="section-card-body text-center" style="padding: 3rem;">
<i class="fas fa-shopping-bag" style="font-size: 3rem; color: var(--color-text-muted); margin-bottom: 1rem;"></i>
<h3>No orders yet</h3>
<p class="text-muted">Start shopping to see your orders here!</p>
<a href="/shop.php" class="btn btn-primary mt-1">Browse Products</a>
</div>
</div>
<?php else: ?>
<?php foreach ($orders as $order):
$items = json_decode($order['items'], true);
$statusClass = match($order['order_status']) {
'pending' => 'warning',
'confirmed', 'processing' => 'primary',
'shipped' => 'primary',
'delivered' => 'success',
'cancelled', 'refunded' => 'error',
default => 'primary'
};
?>
<div class="section-card">
<div class="section-card-header">
<div>
<strong>Order #<?= htmlspecialchars($order['order_number']) ?></strong>
<span class="text-muted" style="margin-left: 1rem;"><?= formatDate($order['created_at']) ?></span>
</div>
<span class="badge badge-<?= $statusClass ?>"><?= ucfirst($order['order_status']) ?></span>
</div>
<div class="section-card-body">
<div style="display: flex; gap: 1rem; flex-wrap: wrap;">
<?php foreach (array_slice($items, 0, 4) as $item): ?>
<div style="display: flex; align-items: center; gap: 0.75rem; background: var(--color-background); padding: 0.5rem 1rem; border-radius: var(--radius-md);">
<span><?= $item['quantity'] ?>x</span>
<span><?= htmlspecialchars(truncate($item['name'], 30)) ?></span>
</div>
<?php endforeach; ?>
<?php if (count($items) > 4): ?>
<div style="padding: 0.5rem 1rem; color: var(--color-text-muted);">
+<?= count($items) - 4 ?> more items
</div>
<?php endif; ?>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--color-border);">
<div>
<span class="text-muted">Total:</span>
<strong style="font-size: 1.125rem; margin-left: 0.5rem;"><?= formatCurrency($order['total']) ?></strong>
</div>
<div style="display: flex; gap: 0.5rem;">
<?php if ($order['tracking_number']): ?>
<a href="<?= htmlspecialchars($order['tracking_url'] ?? '#') ?>" target="_blank" class="btn btn-sm btn-secondary">
<i class="fas fa-truck"></i> Track
</a>
<?php endif; ?>
<a href="/account/order.php?id=<?= $order['order_id'] ?>" class="btn btn-sm btn-primary">
View Details
</a>
</div>
</div>
</div>
</div>
<?php endforeach; ?>
<?php if ($pagination['total_pages'] > 1): ?>
<div style="display: flex; justify-content: center; gap: 0.5rem; margin-top: 2rem;">
<?php for ($i = 1; $i <= $pagination['total_pages']; $i++): ?>
<a href="/account/orders.php?page=<?= $i ?><?= $status ? '&status=' . $status : '' ?>"
class="btn <?= $i === $page ? 'btn-primary' : 'btn-secondary' ?>">
<?= $i ?>
</a>
<?php endfor; ?>
</div>
<?php endif; ?>
<?php endif; ?>
<?php require_once __DIR__ . '/includes/footer.php'; ?>
+248
View File
@@ -0,0 +1,248 @@
<?php
/**
* Tom's Java Jive - Customer Profile
*/
$pageTitle = "My Profile - Tom's Java Jive";
require_once __DIR__ . '/../includes/functions.php';
require_once __DIR__ . '/../includes/auth.php';
CustomerAuth::require();
$customer = CustomerAuth::getFullUser();
$currentPage = 'profile';
$error = '';
$success = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
if ($action === 'update_profile') {
$name = trim($_POST['name'] ?? '');
$phone = trim($_POST['phone'] ?? '');
db()->query(
"UPDATE customers SET name = :name, phone = :phone, updated_at = NOW() WHERE customer_id = :id",
['name' => $name, 'phone' => $phone, 'id' => $customer['customer_id']]
);
$success = 'Profile updated successfully';
$customer['name'] = $name;
$customer['phone'] = $phone;
}
if ($action === 'change_password') {
$currentPassword = $_POST['current_password'] ?? '';
$newPassword = $_POST['new_password'] ?? '';
$confirmPassword = $_POST['confirm_password'] ?? '';
if (!password_verify($currentPassword, $customer['password_hash'])) {
$error = 'Current password is incorrect';
} elseif (strlen($newPassword) < 8) {
$error = 'New password must be at least 8 characters';
} elseif ($newPassword !== $confirmPassword) {
$error = 'New passwords do not match';
} else {
$newHash = password_hash($newPassword, PASSWORD_DEFAULT);
db()->query(
"UPDATE customers SET password_hash = :hash, updated_at = NOW() WHERE customer_id = :id",
['hash' => $newHash, 'id' => $customer['customer_id']]
);
$success = 'Password changed successfully';
}
}
if ($action === 'update_preferences') {
$newsletter = isset($_POST['newsletter']) ? 1 : 0;
$smsNotifications = isset($_POST['sms_notifications']) ? 1 : 0;
$preferences = [
'newsletter' => $newsletter,
'sms_notifications' => $smsNotifications
];
db()->query(
"UPDATE customers SET preferences = :prefs, updated_at = NOW() WHERE customer_id = :id",
['prefs' => json_encode($preferences), 'id' => $customer['customer_id']]
);
// Update newsletter subscription
if ($newsletter) {
$existing = db()->fetch("SELECT id FROM email_subscribers WHERE email = :email", ['email' => $customer['email']]);
if (!$existing) {
db()->insert('email_subscribers', [
'email' => strtolower($customer['email']),
'name' => $customer['name'],
'source' => 'account'
]);
}
} else {
db()->query("DELETE FROM email_subscribers WHERE email = :email", ['email' => $customer['email']]);
}
$success = 'Preferences updated';
}
}
$preferences = json_decode($customer['preferences'] ?? '{}', true);
require_once __DIR__ . '/../includes/header.php';
require_once __DIR__ . '/includes/sidebar.php';
?>
<div class="account-header">
<h1>My Profile</h1>
<p class="text-muted">Manage your account settings</p>
</div>
<?php if ($success): ?>
<div class="alert alert-success mb-2">
<i class="fas fa-check-circle"></i> <?= htmlspecialchars($success) ?>
</div>
<?php endif; ?>
<?php if ($error): ?>
<div class="alert alert-error mb-2">
<i class="fas fa-exclamation-circle"></i> <?= htmlspecialchars($error) ?>
</div>
<?php endif; ?>
<!-- Profile Information -->
<div class="section-card">
<div class="section-card-header">
<h3><i class="fas fa-user"></i> Personal Information</h3>
</div>
<div class="section-card-body">
<form method="POST">
<input type="hidden" name="action" value="update_profile">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem;">
<div class="form-group">
<label class="form-label">Full Name</label>
<input type="text" name="name" class="form-input" value="<?= htmlspecialchars($customer['name'] ?? '') ?>">
</div>
<div class="form-group">
<label class="form-label">Email Address</label>
<input type="email" class="form-input" value="<?= htmlspecialchars($customer['email']) ?>" disabled>
<small class="text-muted">Contact support to change your email</small>
</div>
<div class="form-group">
<label class="form-label">Phone Number</label>
<input type="tel" name="phone" class="form-input" value="<?= htmlspecialchars($customer['phone'] ?? '') ?>">
</div>
<div class="form-group">
<label class="form-label">Member Since</label>
<input type="text" class="form-input" value="<?= formatDate($customer['created_at']) ?>" disabled>
</div>
</div>
<button type="submit" class="btn btn-primary mt-1">
<i class="fas fa-save"></i> Save Changes
</button>
</form>
</div>
</div>
<!-- Change Password -->
<div class="section-card">
<div class="section-card-header">
<h3><i class="fas fa-lock"></i> Change Password</h3>
</div>
<div class="section-card-body">
<form method="POST">
<input type="hidden" name="action" value="change_password">
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1.5rem;">
<div class="form-group">
<label class="form-label">Current Password</label>
<input type="password" name="current_password" class="form-input" required>
</div>
<div class="form-group">
<label class="form-label">New Password</label>
<input type="password" name="new_password" class="form-input" required minlength="8">
</div>
<div class="form-group">
<label class="form-label">Confirm New Password</label>
<input type="password" name="confirm_password" class="form-input" required>
</div>
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-key"></i> Change Password
</button>
</form>
</div>
</div>
<!-- Communication Preferences -->
<div class="section-card">
<div class="section-card-header">
<h3><i class="fas fa-bell"></i> Communication Preferences</h3>
</div>
<div class="section-card-body">
<form method="POST">
<input type="hidden" name="action" value="update_preferences">
<div class="form-group">
<label class="form-checkbox">
<input type="checkbox" name="newsletter" <?= !empty($preferences['newsletter']) ? 'checked' : '' ?>>
<strong>Email Newsletter</strong>
<br><span class="text-muted" style="font-size: 0.875rem; margin-left: 1.5rem;">
Receive updates about new products, promotions, and news
</span>
</label>
</div>
<div class="form-group">
<label class="form-checkbox">
<input type="checkbox" name="sms_notifications" <?= !empty($preferences['sms_notifications']) ? 'checked' : '' ?>>
<strong>SMS Notifications</strong>
<br><span class="text-muted" style="font-size: 0.875rem; margin-left: 1.5rem;">
Receive order updates and alerts via text message
</span>
</label>
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Save Preferences
</button>
</form>
</div>
</div>
<!-- Delete Account -->
<div class="section-card" style="border: 1px solid var(--color-error);">
<div class="section-card-header" style="background: rgba(239, 68, 68, 0.1);">
<h3 style="color: var(--color-error);"><i class="fas fa-exclamation-triangle"></i> Danger Zone</h3>
</div>
<div class="section-card-body">
<p class="text-muted" style="margin-bottom: 1rem;">
Once you delete your account, there is no going back. Please be certain.
</p>
<button class="btn btn-danger" onclick="confirmDeleteAccount()">
<i class="fas fa-trash"></i> Delete My Account
</button>
</div>
</div>
<script>
function confirmDeleteAccount() {
if (confirm('Are you sure you want to delete your account? This action cannot be undone.')) {
if (confirm('This will permanently delete all your data including orders, wishlist, and wallet balance. Type your email to confirm.')) {
const email = prompt('Type your email to confirm deletion:');
if (email === '<?= addslashes($customer['email']) ?>') {
window.location.href = '/api/delete-account.php';
} else {
alert('Email does not match. Account not deleted.');
}
}
}
}
</script>
<?php require_once __DIR__ . '/includes/footer.php'; ?>
+289
View File
@@ -0,0 +1,289 @@
<?php
/**
* Tom's Java Jive - Customer Reviews
*/
$pageTitle = "My Reviews - Tom's Java Jive";
require_once __DIR__ . '/../includes/functions.php';
require_once __DIR__ . '/../includes/auth.php';
CustomerAuth::require();
$customer = CustomerAuth::getFullUser();
$currentPage = 'reviews';
// Handle delete action
if (isset($_POST['delete_review'])) {
$reviewId = $_POST['review_id'] ?? '';
db()->query(
"DELETE FROM reviews WHERE review_id = :rid AND customer_id = :cid",
['rid' => $reviewId, 'cid' => $customer['customer_id']]
);
setFlash('success', 'Review deleted');
redirect('/account/reviews.php');
}
// Get customer's reviews with product info
$reviews = db()->fetchAll(
"SELECT r.*, p.name as product_name, p.product_id as product_pid, p.images as product_images
FROM reviews r
LEFT JOIN products p ON r.product_id = p.product_id
WHERE r.customer_id = :id
ORDER BY r.created_at DESC",
['id' => $customer['customer_id']]
);
// Get products eligible for review (purchased but not reviewed)
$eligibleProducts = db()->fetchAll(
"SELECT DISTINCT p.product_id, p.name, p.images
FROM orders o
JOIN order_items oi ON o.order_id = oi.order_id
JOIN products p ON oi.product_id = p.product_id
WHERE o.customer_id = :cid
AND o.order_status = 'delivered'
AND p.product_id NOT IN (SELECT product_id FROM reviews WHERE customer_id = :cid2)
LIMIT 10",
['cid' => $customer['customer_id'], 'cid2' => $customer['customer_id']]
);
require_once __DIR__ . '/../includes/header.php';
require_once __DIR__ . '/includes/sidebar.php';
?>
<div class="account-header">
<h1>My Reviews</h1>
<p class="text-muted">Manage your product reviews</p>
</div>
<?php if (hasFlash('success')): ?>
<div class="alert alert-success mb-2">
<i class="fas fa-check-circle"></i> <?= getFlash('success') ?>
</div>
<?php endif; ?>
<!-- Products to Review -->
<?php if (!empty($eligibleProducts)): ?>
<div class="section-card">
<div class="section-card-header">
<h3><i class="fas fa-edit"></i> Products to Review</h3>
</div>
<div class="section-card-body">
<p class="text-muted" style="margin-bottom: 1rem;">You've purchased these products. Share your experience!</p>
<div style="display: flex; gap: 1rem; flex-wrap: wrap;">
<?php foreach ($eligibleProducts as $product):
$images = json_decode($product['images'] ?? '[]', true);
$imageUrl = !empty($images) ? $images[0] : '/assets/images/placeholder-product.svg';
?>
<div style="display: flex; align-items: center; gap: 0.75rem; background: var(--color-background); padding: 0.75rem 1rem; border-radius: var(--radius-md);">
<img src="<?= htmlspecialchars($imageUrl) ?>" alt="" style="width: 40px; height: 40px; border-radius: 4px; object-fit: cover;" onerror="this.src='/assets/images/placeholder-product.svg'">
<span style="font-size: 0.875rem;"><?= htmlspecialchars(truncate($product['name'], 25)) ?></span>
<button class="btn btn-sm btn-primary" onclick="openReviewModal('<?= $product['product_id'] ?>', '<?= htmlspecialchars(addslashes($product['name'])) ?>')">
Review
</button>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<?php endif; ?>
<!-- My Reviews -->
<div class="section-card">
<div class="section-card-header">
<h3><i class="fas fa-star"></i> My Reviews (<?= count($reviews) ?>)</h3>
</div>
<div class="section-card-body" style="padding: 0;">
<?php if (empty($reviews)): ?>
<div class="text-center" style="padding: 3rem;">
<i class="fas fa-star" style="font-size: 3rem; color: var(--color-text-muted); margin-bottom: 1rem;"></i>
<h3>No reviews yet</h3>
<p class="text-muted">Purchase products and share your thoughts!</p>
<a href="/shop.php" class="btn btn-primary mt-1">Browse Products</a>
</div>
<?php else: ?>
<?php foreach ($reviews as $review):
$images = json_decode($review['product_images'] ?? '[]', true);
$imageUrl = !empty($images) ? $images[0] : '/assets/images/placeholder-product.svg';
?>
<div style="padding: 1.5rem; border-bottom: 1px solid var(--color-border);">
<div style="display: flex; gap: 1rem;">
<img src="<?= htmlspecialchars($imageUrl) ?>" alt="" style="width: 80px; height: 80px; border-radius: var(--radius-md); object-fit: cover;" onerror="this.src='/assets/images/placeholder-product.svg'">
<div style="flex: 1;">
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 0.5rem;">
<div>
<a href="/product.php?id=<?= htmlspecialchars($review['product_pid']) ?>" style="font-weight: 600; color: inherit;">
<?= htmlspecialchars($review['product_name'] ?? 'Product') ?>
</a>
<div style="margin-top: 0.25rem;">
<?php for ($i = 1; $i <= 5; $i++): ?>
<i class="fas fa-star" style="color: <?= $i <= $review['rating'] ? 'var(--color-warning)' : 'var(--color-border)' ?>; font-size: 0.875rem;"></i>
<?php endfor; ?>
<span class="text-muted" style="margin-left: 0.5rem; font-size: 0.875rem;">
<?= formatDate($review['created_at']) ?>
</span>
</div>
</div>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<?php
$statusBadge = $review['is_approved'] ? ['success', 'Published'] : ['warning', 'Pending'];
?>
<span class="badge badge-<?= $statusBadge[0] ?>"><?= $statusBadge[1] ?></span>
<form method="POST" style="display: inline;" onsubmit="return confirm('Delete this review?')">
<input type="hidden" name="review_id" value="<?= $review['review_id'] ?>">
<button type="submit" name="delete_review" class="btn btn-sm btn-danger">
<i class="fas fa-trash"></i>
</button>
</form>
</div>
</div>
<?php if ($review['title']): ?>
<h4 style="margin: 0 0 0.5rem; font-size: 1rem;"><?= htmlspecialchars($review['title']) ?></h4>
<?php endif; ?>
<p style="margin: 0; color: var(--color-text-muted); font-size: 0.9375rem;">
<?= nl2br(htmlspecialchars($review['comment'] ?? '')) ?>
</p>
</div>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
<!-- Review Modal -->
<div class="modal-overlay" id="reviewModal">
<div class="modal">
<div class="modal-header">
<h3 class="modal-title">Write a Review</h3>
<button type="button" class="modal-close" onclick="Modal.close('reviewModal')">&times;</button>
</div>
<form action="/api/submit-review.php" method="POST" id="reviewForm">
<input type="hidden" name="product_id" id="review_product_id">
<div class="modal-body">
<p style="margin-bottom: 1rem;"><strong id="review_product_name"></strong></p>
<div class="form-group">
<label class="form-label">Rating *</label>
<div id="rating-stars" style="font-size: 2rem;">
<?php for ($i = 1; $i <= 5; $i++): ?>
<i class="far fa-star rating-star" data-rating="<?= $i ?>" style="cursor: pointer; color: var(--color-warning);"></i>
<?php endfor; ?>
</div>
<input type="hidden" name="rating" id="rating_value" required>
</div>
<div class="form-group">
<label class="form-label">Review Title</label>
<input type="text" name="title" class="form-input" placeholder="Summarize your review">
</div>
<div class="form-group">
<label class="form-label">Your Review *</label>
<textarea name="content" class="form-input" rows="4" required placeholder="Share your experience with this product"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="Modal.close('reviewModal')">Cancel</button>
<button type="submit" class="btn btn-primary">
<i class="fas fa-paper-plane"></i> Submit Review
</button>
</div>
</form>
</div>
</div>
<script>
function openReviewModal(productId, productName) {
document.getElementById('review_product_id').value = productId;
document.getElementById('review_product_name').textContent = productName;
document.getElementById('rating_value').value = '';
document.getElementById('reviewForm').reset();
// Reset stars
document.querySelectorAll('.rating-star').forEach(star => {
star.classList.remove('fas');
star.classList.add('far');
});
Modal.open('reviewModal');
}
// Star rating interaction
document.querySelectorAll('.rating-star').forEach(star => {
star.addEventListener('click', function() {
const rating = parseInt(this.dataset.rating);
document.getElementById('rating_value').value = rating;
document.querySelectorAll('.rating-star').forEach((s, index) => {
if (index < rating) {
s.classList.remove('far');
s.classList.add('fas');
} else {
s.classList.remove('fas');
s.classList.add('far');
}
});
});
star.addEventListener('mouseenter', function() {
const rating = parseInt(this.dataset.rating);
document.querySelectorAll('.rating-star').forEach((s, index) => {
if (index < rating) {
s.classList.remove('far');
s.classList.add('fas');
}
});
});
star.addEventListener('mouseleave', function() {
const currentRating = parseInt(document.getElementById('rating_value').value) || 0;
document.querySelectorAll('.rating-star').forEach((s, index) => {
if (index < currentRating) {
s.classList.remove('far');
s.classList.add('fas');
} else {
s.classList.remove('fas');
s.classList.add('far');
}
});
});
});
// Form submission
document.getElementById('reviewForm').addEventListener('submit', async function(e) {
e.preventDefault();
const rating = document.getElementById('rating_value').value;
if (!rating) {
alert('Please select a rating');
return;
}
const formData = new FormData(this);
const data = Object.fromEntries(formData.entries());
try {
const response = await fetch('/api/submit-review.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await response.json();
if (result.error) {
alert(result.error);
} else {
alert('Thank you! Your review has been submitted and is pending approval.');
window.location.reload();
}
} catch (err) {
alert('Failed to submit review. Please try again.');
}
});
</script>
<?php require_once __DIR__ . '/includes/footer.php'; ?>
+452
View File
@@ -0,0 +1,452 @@
<?php
/**
* Tom's Java Jive - Customer Loyalty Rewards Page
*/
$pageTitle = "My Rewards - Tom's Java Jive";
require_once __DIR__ . '/../includes/functions.php';
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/loyalty.php';
CustomerAuth::require();
$customer = CustomerAuth::getFullUser();
$currentPage = 'rewards';
// Get loyalty status
$loyaltyStatus = loyalty()->getCustomerTier($customer['customer_id']);
$tiers = loyalty()->getTiers();
$conversion = loyalty()->getConversionInfo();
$history = loyalty()->getHistory($customer['customer_id'], 20);
// Handle redemption
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['redeem_points'])) {
$points = intval($_POST['points'] ?? 0);
if ($points >= 100) {
$result = loyalty()->redeemPoints($customer['customer_id'], $points);
if ($result['success']) {
setFlash('success', "Successfully redeemed {$points} points for " . formatCurrency($result['credit_value']) . " wallet credit!");
} else {
setFlash('error', $result['error']);
}
} else {
setFlash('error', 'Minimum 100 points required for redemption');
}
redirect('/account/rewards.php');
}
require_once __DIR__ . '/../includes/header.php';
require_once __DIR__ . '/includes/sidebar.php';
?>
<style>
.tier-card {
background: linear-gradient(135deg, <?= $loyaltyStatus['info']['color'] ?>22, <?= $loyaltyStatus['info']['color'] ?>44);
border: 2px solid <?= $loyaltyStatus['info']['color'] ?>;
border-radius: var(--radius-lg);
padding: 2rem;
margin-bottom: 2rem;
position: relative;
overflow: hidden;
}
.tier-card::before {
content: '';
position: absolute;
top: -50%;
right: -20%;
width: 300px;
height: 300px;
background: <?= $loyaltyStatus['info']['color'] ?>;
opacity: 0.1;
border-radius: 50%;
}
.tier-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: <?= $loyaltyStatus['info']['color'] ?>;
color: <?= in_array($loyaltyStatus['tier'], ['silver', 'platinum']) ? '#333' : 'white' ?>;
padding: 0.5rem 1rem;
border-radius: 20px;
font-weight: 600;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.points-display {
display: flex;
gap: 3rem;
margin: 1.5rem 0;
}
.points-item {
text-align: center;
}
.points-value {
font-size: 2.5rem;
font-weight: 700;
color: var(--color-text);
}
.points-label {
color: var(--color-text-muted);
font-size: 0.875rem;
}
.progress-to-next {
background: var(--color-surface);
border-radius: var(--radius-lg);
padding: 1.5rem;
margin-top: 1rem;
}
.progress-bar-wrapper {
background: var(--color-border);
border-radius: 10px;
height: 12px;
overflow: hidden;
margin: 0.75rem 0;
}
.progress-bar-fill {
height: 100%;
background: linear-gradient(90deg, <?= $loyaltyStatus['info']['color'] ?>, <?= $loyaltyStatus['next_tier_info']['color'] ?? $loyaltyStatus['info']['color'] ?>);
border-radius: 10px;
transition: width 0.5s ease;
}
.tier-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
margin: 2rem 0;
}
.tier-item {
background: var(--color-surface);
border-radius: var(--radius-lg);
padding: 1.25rem;
text-align: center;
border: 2px solid transparent;
transition: all 0.2s;
}
.tier-item.current {
border-color: var(--color-primary);
box-shadow: 0 4px 12px rgba(255, 94, 26, 0.2);
}
.tier-item-icon {
width: 50px;
height: 50px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 0.75rem;
font-size: 1.25rem;
}
.tier-item h4 {
margin: 0 0 0.25rem;
font-size: 0.9rem;
}
.tier-item .points {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.redeem-card {
background: var(--color-surface);
border-radius: var(--radius-lg);
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.redeem-options {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
margin-top: 1rem;
}
.redeem-option {
background: var(--color-background);
border: 2px solid var(--color-border);
border-radius: var(--radius-md);
padding: 1rem;
text-align: center;
cursor: pointer;
transition: all 0.2s;
}
.redeem-option:hover {
border-color: var(--color-primary);
}
.redeem-option.selected {
border-color: var(--color-primary);
background: rgba(255, 94, 26, 0.05);
}
.redeem-option .points {
font-size: 1.5rem;
font-weight: 700;
color: var(--color-primary);
}
.redeem-option .value {
color: var(--color-success);
font-weight: 600;
}
@media (max-width: 768px) {
.points-display {
gap: 1.5rem;
}
.tier-grid {
grid-template-columns: repeat(2, 1fr);
}
.redeem-options {
grid-template-columns: 1fr;
}
}
</style>
<div class="account-header">
<h1>My Rewards</h1>
<p class="text-muted">Earn points and unlock exclusive benefits</p>
</div>
<?php if (hasFlash('success')): ?>
<div class="alert alert-success mb-2">
<i class="fas fa-check-circle"></i> <?= getFlash('success') ?>
</div>
<?php endif; ?>
<?php if (hasFlash('error')): ?>
<div class="alert alert-error mb-2">
<i class="fas fa-exclamation-circle"></i> <?= getFlash('error') ?>
</div>
<?php endif; ?>
<!-- Current Tier Card -->
<div class="tier-card">
<div class="tier-badge">
<i class="fas <?= $loyaltyStatus['info']['icon'] ?>"></i>
<?= $loyaltyStatus['info']['name'] ?>
</div>
<div class="points-display">
<div class="points-item">
<div class="points-value"><?= number_format($loyaltyStatus['points']) ?></div>
<div class="points-label">Available Points</div>
</div>
<div class="points-item">
<div class="points-value"><?= formatCurrency($loyaltyStatus['points'] * $conversion['points_value']) ?></div>
<div class="points-label">Points Value</div>
</div>
<div class="points-item">
<div class="points-value"><?= $loyaltyStatus['info']['multiplier'] ?>x</div>
<div class="points-label">Earning Rate</div>
</div>
</div>
<?php if ($loyaltyStatus['next_tier']): ?>
<div class="progress-to-next">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span>Progress to <?= $loyaltyStatus['next_tier_info']['name'] ?></span>
<span style="font-weight: 600;"><?= number_format($loyaltyStatus['points_to_next']) ?> points to go</span>
</div>
<div class="progress-bar-wrapper">
<div class="progress-bar-fill" style="width: <?= $loyaltyStatus['progress_percent'] ?>%;"></div>
</div>
</div>
<?php else: ?>
<div class="progress-to-next" style="text-align: center;">
<i class="fas fa-crown" style="font-size: 2rem; color: <?= $loyaltyStatus['info']['color'] ?>; margin-bottom: 0.5rem;"></i>
<p style="margin: 0; font-weight: 600;">You've reached the highest tier!</p>
<p class="text-muted" style="margin: 0.25rem 0 0;">Enjoy all the exclusive benefits of <?= $loyaltyStatus['info']['name'] ?></p>
</div>
<?php endif; ?>
</div>
<!-- All Tiers -->
<div class="section-card">
<div class="section-card-header">
<h3><i class="fas fa-layer-group"></i> Loyalty Tiers</h3>
</div>
<div class="section-card-body">
<div class="tier-grid">
<?php foreach ($tiers as $key => $tier): ?>
<div class="tier-item <?= $key === $loyaltyStatus['tier'] ? 'current' : '' ?>">
<div class="tier-item-icon" style="background: <?= $tier['color'] ?>22; color: <?= $tier['color'] ?>;">
<i class="fas <?= $tier['icon'] ?>"></i>
</div>
<h4><?= $tier['name'] ?></h4>
<div class="points"><?= number_format($tier['min_points']) ?>+ pts</div>
<div style="margin-top: 0.5rem; font-weight: 600;"><?= $tier['multiplier'] ?>x points</div>
</div>
<?php endforeach; ?>
</div>
<details style="margin-top: 1rem;">
<summary style="cursor: pointer; font-weight: 600; color: var(--color-primary);">View All Benefits</summary>
<div style="margin-top: 1rem;">
<?php foreach ($tiers as $key => $tier): ?>
<div style="margin-bottom: 1rem; padding: 1rem; background: var(--color-background); border-radius: var(--radius-md);">
<h4 style="margin: 0 0 0.5rem; display: flex; align-items: center; gap: 0.5rem;">
<i class="fas <?= $tier['icon'] ?>" style="color: <?= $tier['color'] ?>;"></i>
<?= $tier['name'] ?>
</h4>
<ul style="margin: 0; padding-left: 1.5rem; color: var(--color-text-muted);">
<?php foreach ($tier['benefits'] as $benefit): ?>
<li><?= htmlspecialchars($benefit) ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endforeach; ?>
</div>
</details>
</div>
</div>
<!-- Redeem Points -->
<div class="redeem-card">
<h3 style="margin: 0 0 0.5rem;"><i class="fas fa-gift"></i> Redeem Points</h3>
<p class="text-muted" style="margin: 0 0 1rem;">Convert your points to wallet credit. <?= $conversion['points_for_one_dollar'] ?> points = $1</p>
<?php if ($loyaltyStatus['points'] >= 100): ?>
<form method="POST">
<div class="redeem-options">
<?php
$redeemOptions = [100, 500, 1000];
foreach ($redeemOptions as $pts):
if ($pts <= $loyaltyStatus['points']):
?>
<label class="redeem-option">
<input type="radio" name="points" value="<?= $pts ?>" style="display: none;">
<div class="points"><?= number_format($pts) ?></div>
<div>points</div>
<div class="value" style="margin-top: 0.5rem;"><?= formatCurrency($pts * $conversion['points_value']) ?> credit</div>
</label>
<?php
endif;
endforeach;
?>
</div>
<div style="margin-top: 1rem; display: flex; gap: 1rem; align-items: center;">
<div class="form-group" style="margin: 0; flex: 1;">
<input type="number" name="custom_points" class="form-input" placeholder="Or enter custom amount" min="100" max="<?= $loyaltyStatus['points'] ?>" step="50">
</div>
<button type="submit" name="redeem_points" class="btn btn-primary">
<i class="fas fa-exchange-alt"></i> Redeem
</button>
</div>
</form>
<?php else: ?>
<p class="text-muted" style="text-align: center; padding: 2rem;">
You need at least 100 points to redeem. Keep shopping to earn more!
</p>
<?php endif; ?>
</div>
<!-- Points History -->
<div class="section-card">
<div class="section-card-header">
<h3><i class="fas fa-history"></i> Points History</h3>
</div>
<div class="section-card-body" style="padding: 0;">
<?php if (empty($history)): ?>
<p class="text-muted text-center" style="padding: 2rem;">No points activity yet</p>
<?php else: ?>
<table class="table">
<thead>
<tr>
<th>Date</th>
<th>Activity</th>
<th style="text-align: right;">Points</th>
</tr>
</thead>
<tbody>
<?php foreach ($history as $tx): ?>
<tr>
<td><?= formatDate($tx['created_at']) ?></td>
<td>
<?php
$icon = match($tx['type']) {
'earn' => 'fa-plus-circle',
'redeem' => 'fa-gift',
'tier_upgrade' => 'fa-arrow-up',
'birthday_bonus' => 'fa-birthday-cake',
'referral_bonus', 'referral_welcome' => 'fa-user-plus',
default => 'fa-circle'
};
?>
<i class="fas <?= $icon ?>" style="color: <?= $tx['points'] > 0 ? 'var(--color-success)' : 'var(--color-primary)' ?>;"></i>
<?= htmlspecialchars($tx['description'] ?? ucfirst(str_replace('_', ' ', $tx['type']))) ?>
</td>
<td style="text-align: right; font-weight: 600; color: <?= $tx['points'] > 0 ? 'var(--color-success)' : 'var(--color-text)' ?>;">
<?= $tx['points'] > 0 ? '+' : '' ?><?= number_format($tx['points']) ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</div>
<script>
// Handle redeem option selection
document.querySelectorAll('.redeem-option').forEach(opt => {
opt.addEventListener('click', function() {
document.querySelectorAll('.redeem-option').forEach(o => o.classList.remove('selected'));
this.classList.add('selected');
this.querySelector('input[type="radio"]').checked = true;
document.querySelector('input[name="custom_points"]').value = '';
});
});
// Custom points input clears radio selection
document.querySelector('input[name="custom_points"]')?.addEventListener('input', function() {
if (this.value) {
document.querySelectorAll('.redeem-option').forEach(o => {
o.classList.remove('selected');
o.querySelector('input[type="radio"]').checked = false;
});
}
});
// Form submission - use custom or selected
document.querySelector('form')?.addEventListener('submit', function(e) {
const customPoints = document.querySelector('input[name="custom_points"]').value;
const selectedRadio = document.querySelector('input[name="points"]:checked');
if (!customPoints && !selectedRadio) {
e.preventDefault();
alert('Please select or enter the number of points to redeem');
return;
}
if (customPoints) {
// Create hidden input with custom points as 'points'
const hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.name = 'points';
hidden.value = customPoints;
this.appendChild(hidden);
}
});
</script>
<?php require_once __DIR__ . '/includes/footer.php'; ?>
+196
View File
@@ -0,0 +1,196 @@
<?php
/**
* Tom's Java Jive - Customer Wallet
*/
$pageTitle = "My Wallet - Tom's Java Jive";
require_once __DIR__ . '/../includes/functions.php';
require_once __DIR__ . '/../includes/auth.php';
CustomerAuth::require();
$customer = CustomerAuth::getFullUser();
$currentPage = 'wallet';
// Pagination
$page = max(1, intval($_GET['page'] ?? 1));
$total = db()->count('wallet_transactions', 'customer_id = :id', ['id' => $customer['customer_id']]);
$pagination = paginate($total, $page, 15);
$transactions = db()->fetchAll(
"SELECT * FROM wallet_transactions WHERE customer_id = :id ORDER BY created_at DESC LIMIT :limit OFFSET :offset",
['id' => $customer['customer_id'], 'limit' => $pagination['per_page'], 'offset' => $pagination['offset']]
);
require_once __DIR__ . '/../includes/header.php';
require_once __DIR__ . '/includes/sidebar.php';
?>
<div class="account-header">
<h1>My Wallet</h1>
<p class="text-muted">View your wallet balance and transaction history</p>
</div>
<!-- Wallet Balance Card -->
<div class="section-card" style="background: linear-gradient(135deg, var(--color-primary), #c4420f); color: white;">
<div class="section-card-body" style="padding: 2rem;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<div style="font-size: 0.875rem; opacity: 0.9; margin-bottom: 0.5rem;">Available Balance</div>
<div style="font-size: 2.5rem; font-weight: 700;"><?= formatCurrency($customer['wallet_balance'] ?? 0) ?></div>
</div>
<div style="background: rgba(255,255,255,0.2); padding: 1.5rem; border-radius: var(--radius-lg);">
<i class="fas fa-wallet" style="font-size: 2.5rem;"></i>
</div>
</div>
<div style="display: flex; gap: 1rem; margin-top: 1.5rem;">
<div style="flex: 1; background: rgba(255,255,255,0.15); padding: 1rem; border-radius: var(--radius-md); text-align: center;">
<div style="font-size: 1.25rem; font-weight: 600;"><?= number_format($customer['reward_points'] ?? 0) ?></div>
<div style="font-size: 0.75rem; opacity: 0.9;">Reward Points</div>
</div>
<div style="flex: 1; background: rgba(255,255,255,0.15); padding: 1rem; border-radius: var(--radius-md); text-align: center;">
<div style="font-size: 1.25rem; font-weight: 600;"><?= formatCurrency(($customer['reward_points'] ?? 0) * 0.01) ?></div>
<div style="font-size: 0.75rem; opacity: 0.9;">Points Value</div>
</div>
</div>
</div>
</div>
<!-- Info Cards -->
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem; margin-bottom: 1.5rem;">
<div class="section-card">
<div class="section-card-body text-center">
<i class="fas fa-gift" style="font-size: 2rem; color: var(--color-primary); margin-bottom: 0.75rem;"></i>
<h4 style="margin: 0 0 0.5rem;">Have a Gift Card?</h4>
<p class="text-muted" style="font-size: 0.875rem; margin-bottom: 1rem;">Redeem your gift card to add funds to your wallet</p>
<button class="btn btn-secondary" onclick="Modal.open('redeemModal')">
<i class="fas fa-plus"></i> Redeem Gift Card
</button>
</div>
</div>
<div class="section-card">
<div class="section-card-body text-center">
<i class="fas fa-star" style="font-size: 2rem; color: var(--color-warning); margin-bottom: 0.75rem;"></i>
<h4 style="margin: 0 0 0.5rem;">Earn More Points</h4>
<p class="text-muted" style="font-size: 0.875rem; margin-bottom: 1rem;">Earn 1 point for every $1 spent. 100 points = $1</p>
<a href="/shop.php" class="btn btn-primary">
<i class="fas fa-shopping-cart"></i> Shop Now
</a>
</div>
</div>
</div>
<!-- Transaction History -->
<div class="section-card">
<div class="section-card-header">
<h3><i class="fas fa-history"></i> Transaction History</h3>
</div>
<div class="section-card-body" style="padding: 0;">
<?php if (empty($transactions)): ?>
<div class="text-center" style="padding: 3rem;">
<i class="fas fa-receipt" style="font-size: 3rem; color: var(--color-text-muted); margin-bottom: 1rem;"></i>
<p class="text-muted">No transactions yet</p>
</div>
<?php else: ?>
<table class="table">
<thead>
<tr>
<th>Date</th>
<th>Description</th>
<th>Type</th>
<th style="text-align: right;">Amount</th>
<th style="text-align: right;">Balance</th>
</tr>
</thead>
<tbody>
<?php foreach ($transactions as $tx): ?>
<tr>
<td><?= formatDate($tx['created_at']) ?></td>
<td><?= htmlspecialchars($tx['description'] ?? ucfirst($tx['type'])) ?></td>
<td>
<?php
$typeIcon = match($tx['type']) {
'deposit', 'gift_card', 'refund' => 'fa-arrow-down',
'purchase', 'withdrawal' => 'fa-arrow-up',
default => 'fa-exchange-alt'
};
$typeColor = $tx['amount'] > 0 ? 'success' : 'error';
?>
<span class="badge badge-<?= $typeColor ?>">
<i class="fas <?= $typeIcon ?>"></i> <?= ucfirst(str_replace('_', ' ', $tx['type'])) ?>
</span>
</td>
<td style="text-align: right; font-weight: 600; color: <?= $tx['amount'] > 0 ? 'var(--color-success)' : 'var(--color-error)' ?>;">
<?= $tx['amount'] > 0 ? '+' : '' ?><?= formatCurrency($tx['amount']) ?>
</td>
<td style="text-align: right;"><?= formatCurrency($tx['balance_after']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</div>
<?php if ($pagination['total_pages'] > 1): ?>
<div style="display: flex; justify-content: center; gap: 0.5rem; margin-top: 2rem;">
<?php for ($i = 1; $i <= $pagination['total_pages']; $i++): ?>
<a href="/account/wallet.php?page=<?= $i ?>"
class="btn <?= $i === $page ? 'btn-primary' : 'btn-secondary' ?>">
<?= $i ?>
</a>
<?php endfor; ?>
</div>
<?php endif; ?>
<!-- Redeem Gift Card Modal -->
<div class="modal-overlay" id="redeemModal">
<div class="modal">
<div class="modal-header">
<h3 class="modal-title">Redeem Gift Card</h3>
<button type="button" class="modal-close" onclick="Modal.close('redeemModal')">&times;</button>
</div>
<form action="/api/redeem-gift-card.php" method="POST" id="redeemForm">
<div class="modal-body">
<div class="form-group">
<label class="form-label">Gift Card Code</label>
<input type="text" name="code" class="form-input" required placeholder="XXXX-XXXX-XXXX" style="text-transform: uppercase; text-align: center; font-size: 1.25rem; letter-spacing: 2px;">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="Modal.close('redeemModal')">Cancel</button>
<button type="submit" class="btn btn-primary">
<i class="fas fa-gift"></i> Redeem
</button>
</div>
</form>
</div>
</div>
<script>
document.getElementById('redeemForm').addEventListener('submit', async function(e) {
e.preventDefault();
const code = this.querySelector('[name="code"]').value.trim();
try {
const response = await fetch('/api/redeem-gift-card.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code })
});
const data = await response.json();
if (data.error) {
alert(data.error);
} else {
alert('Success! ' + data.message);
window.location.reload();
}
} catch (err) {
alert('Failed to redeem gift card. Please try again.');
}
});
</script>
<?php require_once __DIR__ . '/includes/footer.php'; ?>
+148
View File
@@ -0,0 +1,148 @@
<?php
/**
* Tom's Java Jive - Customer Wishlist
*/
$pageTitle = "My Wishlist - Tom's Java Jive";
require_once __DIR__ . '/../includes/functions.php';
require_once __DIR__ . '/../includes/auth.php';
CustomerAuth::require();
$customer = CustomerAuth::getFullUser();
$currentPage = 'wishlist';
// Handle remove action
if (isset($_POST['remove_wishlist'])) {
$productId = $_POST['product_id'] ?? '';
db()->query(
"DELETE FROM wishlist WHERE customer_id = :cid AND product_id = :pid",
['cid' => $customer['customer_id'], 'pid' => $productId]
);
setFlash('success', 'Item removed from wishlist');
redirect('/account/wishlist.php');
}
// Get wishlist items with product details
$wishlistItems = db()->fetchAll(
"SELECT w.*, p.product_id, p.name, p.price, p.sale_price, p.images, p.stock, p.is_active
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']]
);
require_once __DIR__ . '/../includes/header.php';
require_once __DIR__ . '/includes/sidebar.php';
?>
<div class="account-header">
<h1>My Wishlist</h1>
<p class="text-muted"><?= count($wishlistItems) ?> items saved</p>
</div>
<?php if (hasFlash('success')): ?>
<div class="alert alert-success mb-2">
<i class="fas fa-check-circle"></i> <?= getFlash('success') ?>
</div>
<?php endif; ?>
<?php if (empty($wishlistItems)): ?>
<div class="section-card">
<div class="section-card-body text-center" style="padding: 3rem;">
<i class="fas fa-heart" style="font-size: 3rem; color: var(--color-text-muted); margin-bottom: 1rem;"></i>
<h3>Your wishlist is empty</h3>
<p class="text-muted">Save items you love by clicking the heart icon on products.</p>
<a href="/shop.php" class="btn btn-primary mt-1">Browse Products</a>
</div>
</div>
<?php else: ?>
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 1.5rem;">
<?php foreach ($wishlistItems as $item):
$images = json_decode($item['images'] ?? '[]', true);
$imageUrl = !empty($images) ? $images[0] : '/assets/images/placeholder-product.svg';
$isAvailable = $item['is_active'] && $item['stock'] > 0;
?>
<div class="section-card" style="overflow: hidden;">
<a href="/product.php?id=<?= htmlspecialchars($item['product_id']) ?>">
<img src="<?= htmlspecialchars($imageUrl) ?>"
alt="<?= htmlspecialchars($item['name']) ?>"
style="width: 100%; height: 200px; object-fit: cover;"
onerror="this.src='/assets/images/placeholder-product.svg'">
</a>
<div class="section-card-body">
<h4 style="margin: 0 0 0.5rem;">
<a href="/product.php?id=<?= htmlspecialchars($item['product_id']) ?>" style="color: inherit;">
<?= htmlspecialchars($item['name']) ?>
</a>
</h4>
<div style="margin-bottom: 1rem;">
<?php if ($item['sale_price']): ?>
<span style="font-size: 1.25rem; font-weight: 600; color: var(--color-primary);">
<?= formatCurrency($item['sale_price']) ?>
</span>
<span style="text-decoration: line-through; color: var(--color-text-muted); margin-left: 0.5rem;">
<?= formatCurrency($item['price']) ?>
</span>
<?php else: ?>
<span style="font-size: 1.25rem; font-weight: 600;">
<?= formatCurrency($item['price']) ?>
</span>
<?php endif; ?>
</div>
<?php if (!$isAvailable): ?>
<p style="color: var(--color-error); font-size: 0.875rem; margin-bottom: 1rem;">
<i class="fas fa-times-circle"></i> Out of stock
</p>
<?php endif; ?>
<div style="display: flex; gap: 0.5rem;">
<?php if ($isAvailable): ?>
<button class="btn btn-primary" style="flex: 1;" onclick="addToCart('<?= $item['product_id'] ?>')">
<i class="fas fa-shopping-cart"></i> Add to Cart
</button>
<?php else: ?>
<button class="btn btn-secondary" style="flex: 1;" disabled>
<i class="fas fa-shopping-cart"></i> Unavailable
</button>
<?php endif; ?>
<form method="POST" style="display: inline;">
<input type="hidden" name="product_id" value="<?= $item['product_id'] ?>">
<button type="submit" name="remove_wishlist" class="btn btn-danger" title="Remove">
<i class="fas fa-trash"></i>
</button>
</form>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<script>
function addToCart(productId) {
fetch('/api/cart.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'add', product_id: productId, quantity: 1 })
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('Added to cart!');
// Update cart count if exists
const cartCount = document.querySelector('.cart-count');
if (cartCount) {
cartCount.textContent = data.cart_count || '';
}
} else {
alert(data.error || 'Failed to add to cart');
}
});
}
</script>
<?php require_once __DIR__ . '/includes/footer.php'; ?>
+292
View File
@@ -0,0 +1,292 @@
<?php
ob_start();
$pageTitle = 'About Us Content';
$currentPage = 'about-us';
require_once __DIR__ . '/includes/header.php';
/* ── Actions ─────────────────────────────────────── */
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
$sectionId = $_POST['section_id'] ?? '';
if ($action === 'create') {
$body = trim($_POST['body'] ?? '');
if ($body) {
db()->insert('about_us_sections', [
'section_id' => generateId('sec_'),
'heading' => trim($_POST['heading'] ?? '') ?: null,
'body' => $body,
'sort_order' => intval($_POST['sort_order'] ?? 0),
'is_active' => 1,
]);
setFlash('success', 'Section added');
}
}
if ($action === 'update' && $sectionId) {
$body = trim($_POST['body'] ?? '');
if ($body) {
db()->update('about_us_sections', [
'heading' => trim($_POST['heading'] ?? '') ?: null,
'body' => $body,
'sort_order' => intval($_POST['sort_order'] ?? 0),
'is_active' => isset($_POST['is_active']) ? 1 : 0,
], 'section_id = :id', ['id' => $sectionId]);
setFlash('success', 'Section updated');
}
}
if ($action === 'delete' && $sectionId) {
db()->delete('about_us_sections', 'section_id = :id', ['id' => $sectionId]);
setFlash('success', 'Section deleted');
}
if ($action === 'reorder') {
$ids = json_decode($_POST['order'] ?? '[]', true);
foreach ($ids as $pos => $sid) {
db()->update('about_us_sections', ['sort_order' => $pos + 1],
'section_id = :id', ['id' => $sid]);
}
echo json_encode(['ok' => true]); exit;
}
header('Location: /admin/about-us.php'); exit;
}
$sections = db()->fetchAll(
"SELECT * FROM about_us_sections ORDER BY sort_order ASC, id ASC"
);
?>
<div class="page-header">
<h1 class="page-title">About Us Content</h1>
<button class="btn btn-primary" onclick="openModal()">
<i class="fas fa-plus"></i> Add Section
</button>
</div>
<?php if (hasFlash('success')): ?>
<div class="alert alert-success"><i class="fas fa-check-circle"></i> <?= getFlash('success') ?></div>
<?php endif; ?>
<div class="admin-card" style="margin-bottom:1.5rem">
<div class="admin-card-body" style="padding:.85rem 1.5rem">
<p class="text-muted" style="margin:0">
<i class="fas fa-info-circle"></i>
These sections appear on the homepage between <strong>Our Story</strong> and the <strong>Explore Our Coffee</strong> button.
Drag rows to reorder. Blank lines in the text become paragraph breaks.
</p>
</div>
</div>
<!-- Live Preview -->
<div class="admin-card" style="margin-bottom:1.5rem">
<div class="admin-card-header">
<h3><i class="fas fa-eye"></i> Homepage Preview</h3>
<a href="/" target="_blank" class="btn btn-sm btn-secondary"><i class="fas fa-external-link-alt"></i> View Live</a>
</div>
<div class="admin-card-body" style="background:var(--admin-bg);border-radius:var(--radius-md);padding:2rem">
<h2 style="font-family:Georgia,serif;font-size:1.75rem;margin:0 0 1rem">Our Story</h2>
<div id="livePreview" style="font-size:.9375rem;color:var(--admin-text-muted);line-height:1.7">
<?php foreach ($sections as $sec): if (!$sec['is_active']) continue; ?>
<?php if (!empty($sec['heading'])): ?>
<h3 style="font-size:1.1rem;font-weight:600;margin:.5rem 0 .4rem"><?= htmlspecialchars($sec['heading']) ?></h3>
<?php endif; ?>
<?php foreach (array_filter(array_map('trim', preg_split('/\n{2,}/', $sec['body']))) as $para): ?>
<p style="margin:0 0 1rem"><?= nl2br(htmlspecialchars($para)) ?></p>
<?php endforeach; ?>
<?php endforeach; ?>
</div>
<a href="#" class="btn btn-primary" style="margin-top:.5rem;pointer-events:none;opacity:.8">Explore Our Coffee →</a>
</div>
</div>
<!-- Section List -->
<div class="admin-card">
<div class="admin-card-body" style="padding:0">
<?php if (empty($sections)): ?>
<div class="text-center text-muted" style="padding:3rem">No sections yet. Click <strong>Add Section</strong> to get started.</div>
<?php else: ?>
<table class="admin-table">
<thead>
<tr>
<th style="width:30px"></th>
<th>Heading</th>
<th>Body Text</th>
<th style="width:60px">Order</th>
<th style="width:80px">Status</th>
<th style="width:100px">Actions</th>
</tr>
</thead>
<tbody id="sectionTbody">
<?php foreach ($sections as $sec): ?>
<tr data-id="<?= $sec['section_id'] ?>">
<td style="color:var(--admin-text-muted);text-align:center;cursor:grab"><i class="fas fa-grip-vertical"></i></td>
<td style="font-weight:600;min-width:120px"><?= htmlspecialchars($sec['heading'] ?? '—') ?></td>
<td style="color:var(--admin-text-muted);font-size:.875rem">
<div style="max-width:420px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
<?= htmlspecialchars($sec['body']) ?>
</div>
</td>
<td class="sort-cell"><?= $sec['sort_order'] ?></td>
<td>
<?= $sec['is_active']
? '<span class="badge badge-success">Active</span>'
: '<span class="badge badge-error">Hidden</span>' ?>
</td>
<td>
<button class="btn btn-sm btn-secondary" onclick='openModal(<?= json_encode($sec, JSON_HEX_APOS | JSON_HEX_QUOT) ?>)' title="Edit">
<i class="fas fa-edit"></i>
</button>
<form method="POST" style="display:inline">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="section_id" value="<?= $sec['section_id'] ?>">
<button type="submit" class="btn btn-sm btn-danger" data-confirm="Delete this section?">
<i class="fas fa-trash"></i>
</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</div>
<!-- ── Modal ──────────────────────────────────────── -->
<div class="modal-overlay" id="sectionModal">
<div class="modal" style="max-width:680px;width:95vw">
<div class="modal-header">
<h3 class="modal-title" id="modalTitle">Add Section</h3>
<button type="button" class="modal-close" onclick="Modal.close('sectionModal')">&times;</button>
</div>
<form method="POST" id="sectionForm">
<div class="modal-body">
<input type="hidden" name="action" id="formAction" value="create">
<input type="hidden" name="section_id" id="formSectionId">
<div class="form-group">
<label class="form-label">
Heading <span class="text-muted" style="font-weight:400">(optional sub-heading above this block)</span>
</label>
<input type="text" name="heading" id="formHeading" class="form-input"
placeholder="e.g. Our Mission, From Farm to Cup…">
</div>
<div class="form-group">
<label class="form-label">Body Text *</label>
<textarea name="body" id="formBody" class="form-input" rows="10"
style="resize:vertical;font-size:.9375rem;line-height:1.6"
placeholder="Type your text here. Leave a blank line between paragraphs to create separate paragraph blocks."
required oninput="updatePreview()"></textarea>
<small class="text-muted"><i class="fas fa-paragraph"></i> Blank line = new paragraph &nbsp;|&nbsp; <i class="fas fa-level-down-alt fa-rotate-90"></i> Single line break = &lt;br&gt;</small>
</div>
<!-- Inline preview inside modal -->
<div class="form-group" style="margin-bottom:0">
<label class="form-label" style="display:flex;justify-content:space-between">
<span>Text Preview</span>
<span class="text-muted" style="font-weight:400;font-size:.8rem">Updates as you type</span>
</label>
<div id="inlinePreview"
style="border:1px solid var(--color-border);border-radius:var(--radius-md);padding:1rem 1.25rem;
background:var(--admin-bg);min-height:80px;font-size:.9375rem;color:var(--admin-text-muted);line-height:1.7">
<em style="opacity:.4">Preview will appear here…</em>
</div>
</div>
</div>
<div class="modal-footer">
<div id="statusWrap" style="display:none;margin-right:auto">
<label style="display:flex;align-items:center;gap:.5rem;cursor:pointer">
<input type="checkbox" name="is_active" id="formActive" checked> Active
</label>
</div>
<div class="form-group mb-0" style="margin-right:auto">
<label class="form-label" style="display:inline;margin-right:.5rem">Order</label>
<input type="number" name="sort_order" id="formOrder" class="form-input"
value="0" min="0" style="width:70px;display:inline-block">
</div>
<button type="button" class="btn btn-secondary" onclick="Modal.close('sectionModal')">Cancel</button>
<button type="submit" class="btn btn-primary" id="formSubmitBtn">Add Section</button>
</div>
</form>
</div>
</div>
<style>
#sectionTbody tr { cursor: grab; }
#sectionTbody tr.drag-over { background: rgba(255,94,26,.06); }
#formBody:focus { border-color: var(--admin-primary); }
</style>
<script>
/* ── Modal ───────────────────────────────────────── */
function openModal(sec) {
var isEdit = !!sec;
document.getElementById('modalTitle').textContent = isEdit ? 'Edit Section' : 'Add Section';
document.getElementById('formSubmitBtn').textContent = isEdit ? 'Save Changes' : 'Add Section';
document.getElementById('formAction').value = isEdit ? 'update' : 'create';
document.getElementById('formSectionId').value = isEdit ? sec.section_id : '';
document.getElementById('formHeading').value = isEdit ? (sec.heading || '') : '';
document.getElementById('formBody').value = isEdit ? sec.body : '';
document.getElementById('formOrder').value = isEdit ? sec.sort_order : 0;
document.getElementById('formActive').checked = isEdit ? !!parseInt(sec.is_active) : true;
document.getElementById('statusWrap').style.display = isEdit ? '' : 'none';
updatePreview();
Modal.open('sectionModal');
// Focus textarea after modal opens
setTimeout(function() { document.getElementById('formBody').focus(); }, 150);
}
/* ── Inline text preview ─────────────────────────── */
function updatePreview() {
var raw = document.getElementById('formBody').value;
var box = document.getElementById('inlinePreview');
if (!raw.trim()) {
box.innerHTML = '<em style="opacity:.4">Preview will appear here…</em>';
return;
}
var paras = raw.split(/\n{2,}/).map(function(p) { return p.trim(); }).filter(Boolean);
box.innerHTML = paras.map(function(p) {
return '<p style="margin:0 0 .75rem">' + p.replace(/\n/g, '<br>').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/&lt;br&gt;/g,'<br>') + '</p>';
}).join('');
}
/* ── Drag-to-reorder ─────────────────────────────── */
(function() {
var tbody = document.getElementById('sectionTbody');
if (!tbody) return;
var dragging = null;
tbody.querySelectorAll('tr').forEach(function(row) {
row.draggable = true;
row.addEventListener('dragstart', function() { dragging = this; this.style.opacity = '.4'; });
row.addEventListener('dragend', function() { this.style.opacity = ''; dragging = null; saveOrder(); });
row.addEventListener('dragover', function(e) { e.preventDefault(); this.classList.add('drag-over'); });
row.addEventListener('dragleave', function() { this.classList.remove('drag-over'); });
row.addEventListener('drop', function(e) {
e.preventDefault(); this.classList.remove('drag-over');
if (dragging && dragging !== this) {
if (Array.from(tbody.querySelectorAll('tr')).indexOf(dragging) <
Array.from(tbody.querySelectorAll('tr')).indexOf(this)) this.after(dragging);
else this.before(dragging);
}
});
});
function saveOrder() {
var ids = Array.from(tbody.querySelectorAll('tr')).map(function(r) { return r.dataset.id; });
fetch('/admin/about-us.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'action=reorder&order=' + encodeURIComponent(JSON.stringify(ids))
});
tbody.querySelectorAll('tr').forEach(function(r, i) {
r.querySelector('.sort-cell').textContent = i + 1;
});
}
})();
</script>
<?php require_once __DIR__ . '/includes/footer.php'; ?>
+714
View File
@@ -0,0 +1,714 @@
<?php
/**
* Tom's Java Jive - Advanced Analytics Dashboard
*/
$pageTitle = 'Advanced Analytics';
require_once __DIR__ . '/includes/header.php';
// Get date range from query params or default to last 30 days
$endDate = $_GET['end_date'] ?? date('Y-m-d');
$startDate = $_GET['start_date'] ?? date('Y-m-d', strtotime('-30 days'));
$period = $_GET['period'] ?? '30';
if ($period === '7') {
$startDate = date('Y-m-d', strtotime('-7 days'));
} elseif ($period === '30') {
$startDate = date('Y-m-d', strtotime('-30 days'));
} elseif ($period === '90') {
$startDate = date('Y-m-d', strtotime('-90 days'));
} elseif ($period === '365') {
$startDate = date('Y-m-d', strtotime('-1 year'));
}
try {
// Sales Overview
$salesOverview = db()->fetch(
"SELECT
COUNT(*) as total_orders,
COALESCE(SUM(total), 0) as total_revenue,
COALESCE(AVG(total), 0) as avg_order_value,
COUNT(DISTINCT customer_id) as unique_customers
FROM orders
WHERE DATE(created_at) BETWEEN :start AND :end AND payment_status = 'paid'",
['start' => $startDate, 'end' => $endDate]
);
// Ensure defaults
if (!$salesOverview) {
$salesOverview = ['total_orders' => 0, 'total_revenue' => 0, 'avg_order_value' => 0, 'unique_customers' => 0];
} else {
$salesOverview['total_orders'] = (int)($salesOverview['total_orders'] ?? 0);
$salesOverview['total_revenue'] = (float)($salesOverview['total_revenue'] ?? 0);
$salesOverview['avg_order_value'] = (float)($salesOverview['avg_order_value'] ?? 0);
$salesOverview['unique_customers'] = (int)($salesOverview['unique_customers'] ?? 0);
}
} catch (Exception $e) {
$salesOverview = ['total_orders' => 0, 'total_revenue' => 0, 'avg_order_value' => 0, 'unique_customers' => 0];
}
// Previous period for comparison
$daysDiff = (strtotime($endDate) - strtotime($startDate)) / 86400;
$prevEndDate = date('Y-m-d', strtotime($startDate . ' -1 day'));
$prevStartDate = date('Y-m-d', strtotime($prevEndDate . " -{$daysDiff} days"));
try {
$prevSalesOverview = db()->fetch(
"SELECT
COUNT(*) as total_orders,
COALESCE(SUM(total), 0) as total_revenue
FROM orders
WHERE DATE(created_at) BETWEEN :start AND :end AND payment_status = 'paid'",
['start' => $prevStartDate, 'end' => $prevEndDate]
);
if (!$prevSalesOverview) {
$prevSalesOverview = ['total_orders' => 0, 'total_revenue' => 0];
} else {
$prevSalesOverview['total_orders'] = (int)($prevSalesOverview['total_orders'] ?? 0);
$prevSalesOverview['total_revenue'] = (float)($prevSalesOverview['total_revenue'] ?? 0);
}
} catch (Exception $e) {
$prevSalesOverview = ['total_orders' => 0, 'total_revenue' => 0];
}
try {
// Daily Sales Data for chart
$dailySales = db()->fetchAll(
"SELECT
DATE(created_at) as date,
COUNT(*) as orders,
COALESCE(SUM(total), 0) as revenue
FROM orders
WHERE DATE(created_at) BETWEEN :start AND :end AND payment_status = 'paid'
GROUP BY DATE(created_at)
ORDER BY date ASC",
['start' => $startDate, 'end' => $endDate]
);
} catch (Exception $e) {
$dailySales = [];
}
// Top Selling Products (from orders JSON items field)
$topProducts = [];
try {
$orders = db()->fetchAll(
"SELECT items, total FROM orders
WHERE DATE(created_at) BETWEEN :start AND :end AND payment_status = 'paid'",
['start' => $startDate, 'end' => $endDate]
);
$productCounts = [];
foreach ($orders as $order) {
$items = json_decode($order['items'], true) ?? [];
foreach ($items as $item) {
$name = $item['name'] ?? 'Unknown';
if (!isset($productCounts[$name])) {
$productCounts[$name] = ['name' => $name, 'total_sold' => 0, 'total_revenue' => 0];
}
$productCounts[$name]['total_sold'] += $item['quantity'] ?? 1;
$productCounts[$name]['total_revenue'] += $item['total'] ?? 0;
}
}
usort($productCounts, fn($a, $b) => $b['total_sold'] - $a['total_sold']);
$topProducts = array_slice(array_values($productCounts), 0, 10);
} catch (Exception $e) {
$topProducts = [];
}
// Sales by Category (from orders JSON - more reliable)
$categoryStats = [];
try {
foreach ($orders ?? [] as $order) {
$items = json_decode($order['items'], true) ?? [];
foreach ($items as $item) {
$cat = 'General';
if (!isset($categoryStats[$cat])) {
$categoryStats[$cat] = ['category' => $cat, 'orders' => 0, 'items_sold' => 0, 'revenue' => 0];
}
$categoryStats[$cat]['items_sold'] += $item['quantity'] ?? 1;
$categoryStats[$cat]['revenue'] += $item['total'] ?? 0;
}
}
$categoryStats = array_values($categoryStats);
} catch (Exception $e) {
$categoryStats = [];
}
try {
// Customer Acquisition
$newCustomers = db()->fetch(
"SELECT COUNT(*) as count FROM customers WHERE DATE(created_at) BETWEEN :start AND :end",
['start' => $startDate, 'end' => $endDate]
)['count'] ?? 0;
} catch (Exception $e) {
$newCustomers = 0;
}
try {
$returningCustomers = db()->fetch(
"SELECT COUNT(DISTINCT customer_id) as count
FROM orders o
WHERE DATE(o.created_at) BETWEEN :start AND :end
AND payment_status = 'paid'
AND customer_id IN (
SELECT customer_id FROM orders
WHERE DATE(created_at) < :start2 AND payment_status = 'paid'
)",
['start' => $startDate, 'end' => $endDate, 'start2' => $startDate]
)['count'] ?? 0;
} catch (Exception $e) {
$returningCustomers = 0;
}
try {
// Payment Methods Distribution
$paymentMethods = db()->fetchAll(
"SELECT
COALESCE(payment_method, 'Unknown') as method,
COUNT(*) as count,
SUM(total) as revenue
FROM orders
WHERE DATE(created_at) BETWEEN :start AND :end AND payment_status = 'paid'
GROUP BY payment_method
ORDER BY count DESC",
['start' => $startDate, 'end' => $endDate]
);
} catch (Exception $e) {
$paymentMethods = [];
}
try {
// Abandoned Carts
$abandonedCarts = db()->fetch(
"SELECT COUNT(*) as count, COALESCE(SUM(subtotal), 0) as value
FROM abandoned_carts
WHERE DATE(created_at) BETWEEN :start AND :end AND recovered = 0",
['start' => $startDate, 'end' => $endDate]
);
if (!$abandonedCarts) {
$abandonedCarts = ['count' => 0, 'value' => 0];
} else {
$abandonedCarts['count'] = (int)($abandonedCarts['count'] ?? 0);
$abandonedCarts['value'] = (float)($abandonedCarts['value'] ?? 0);
}
} catch (Exception $e) {
$abandonedCarts = ['count' => 0, 'value' => 0];
}
try {
// Order Status Distribution
$orderStatuses = db()->fetchAll(
"SELECT order_status, COUNT(*) as count
FROM orders
WHERE DATE(created_at) BETWEEN :start AND :end
GROUP BY order_status",
['start' => $startDate, 'end' => $endDate]
);
} catch (Exception $e) {
$orderStatuses = [];
}
try {
// Hourly Sales Pattern
$hourlySales = db()->fetchAll(
"SELECT
HOUR(created_at) as hour,
COUNT(*) as orders,
COALESCE(SUM(total), 0) as revenue
FROM orders
WHERE DATE(created_at) BETWEEN :start AND :end AND payment_status = 'paid'
GROUP BY HOUR(created_at)
ORDER BY hour",
['start' => $startDate, 'end' => $endDate]
);
} catch (Exception $e) {
$hourlySales = [];
}
// Top Customers
try {
$topCustomers = db()->fetchAll(
"SELECT
c.customer_id,
c.name,
c.email,
COUNT(o.order_id) as order_count,
COALESCE(SUM(o.total), 0) as total_spent
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
WHERE DATE(o.created_at) BETWEEN :start AND :end AND o.payment_status = 'paid'
GROUP BY c.customer_id
ORDER BY total_spent DESC
LIMIT 10",
['start' => $startDate, 'end' => $endDate]
);
} catch (Exception $e) {
$topCustomers = [];
}
// Inventory Stats
try {
$lowStockCount = db()->count('products', 'stock <= low_stock_threshold AND stock > 0');
$outOfStockCount = db()->count('products', 'stock = 0 AND is_active = 1');
} catch (Exception $e) {
$lowStockCount = 0;
$outOfStockCount = 0;
}
// Calculate percentage changes
$revenueChange = $prevSalesOverview['total_revenue'] > 0
? (($salesOverview['total_revenue'] - $prevSalesOverview['total_revenue']) / $prevSalesOverview['total_revenue']) * 100
: 0;
$ordersChange = $prevSalesOverview['total_orders'] > 0
? (($salesOverview['total_orders'] - $prevSalesOverview['total_orders']) / $prevSalesOverview['total_orders']) * 100
: 0;
?>
<style>
.analytics-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.date-filter {
display: flex;
gap: 0.5rem;
align-items: center;
}
.date-filter .btn {
padding: 0.5rem 1rem;
}
.date-filter .btn.active {
background: var(--admin-primary);
color: white;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: var(--admin-surface);
border-radius: var(--admin-radius);
padding: 1.5rem;
}
.stat-card-header {
display: flex;
justify-content: space-between;
align-items: start;
margin-bottom: 1rem;
}
.stat-card-title {
color: var(--admin-text-muted);
font-size: 0.875rem;
margin: 0;
}
.stat-card-icon {
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.125rem;
}
.stat-card-icon.primary {
background: rgba(255, 94, 26, 0.1);
color: var(--admin-primary);
}
.stat-card-icon.success {
background: rgba(16, 185, 129, 0.1);
color: var(--admin-success);
}
.stat-card-icon.warning {
background: rgba(245, 158, 11, 0.1);
color: var(--admin-warning);
}
.stat-card-icon.info {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
.stat-card-value {
font-size: 1.75rem;
font-weight: 700;
margin-bottom: 0.25rem;
}
.stat-card-change {
font-size: 0.75rem;
display: flex;
align-items: center;
gap: 0.25rem;
}
.stat-card-change.positive {
color: var(--admin-success);
}
.stat-card-change.negative {
color: var(--admin-error);
}
.chart-container {
background: var(--admin-surface);
border-radius: var(--admin-radius);
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.chart-title {
font-size: 1rem;
font-weight: 600;
margin: 0;
}
.analytics-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 1.5rem;
}
.analytics-grid-equal {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
.list-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 0;
border-bottom: 1px solid var(--admin-border);
}
.list-item:last-child {
border-bottom: none;
}
.progress-bar {
height: 8px;
background: var(--admin-bg);
border-radius: 4px;
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
background: var(--admin-primary);
border-radius: 4px;
}
.mini-chart {
height: 200px;
display: flex;
align-items: flex-end;
gap: 4px;
padding-top: 1rem;
}
.mini-chart-bar {
flex: 1;
background: rgba(255, 94, 26, 0.3);
border-radius: 4px 4px 0 0;
min-height: 4px;
transition: all 0.2s;
}
.mini-chart-bar:hover {
background: var(--admin-primary);
}
@media (max-width: 1200px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.analytics-grid,
.analytics-grid-equal {
grid-template-columns: 1fr;
}
}
</style>
<div class="analytics-header">
<div>
<h1 class="page-title">Advanced Analytics</h1>
<p class="text-muted"><?= date('M d, Y', strtotime($startDate)) ?> - <?= date('M d, Y', strtotime($endDate)) ?></p>
</div>
<div class="date-filter">
<a href="?period=7" class="btn <?= $period === '7' ? 'btn-primary active' : 'btn-secondary' ?>">7 Days</a>
<a href="?period=30" class="btn <?= $period === '30' ? 'btn-primary active' : 'btn-secondary' ?>">30 Days</a>
<a href="?period=90" class="btn <?= $period === '90' ? 'btn-primary active' : 'btn-secondary' ?>">90 Days</a>
<a href="?period=365" class="btn <?= $period === '365' ? 'btn-primary active' : 'btn-secondary' ?>">1 Year</a>
</div>
</div>
<!-- Overview Stats -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-card-header">
<h3 class="stat-card-title">Total Revenue</h3>
<div class="stat-card-icon primary"><i class="fas fa-dollar-sign"></i></div>
</div>
<div class="stat-card-value"><?= formatCurrency($salesOverview['total_revenue'] ?? 0) ?></div>
<div class="stat-card-change <?= $revenueChange >= 0 ? 'positive' : 'negative' ?>">
<i class="fas fa-<?= $revenueChange >= 0 ? 'arrow-up' : 'arrow-down' ?>"></i>
<?= abs(round($revenueChange, 1)) ?>% vs previous period
</div>
</div>
<div class="stat-card">
<div class="stat-card-header">
<h3 class="stat-card-title">Total Orders</h3>
<div class="stat-card-icon success"><i class="fas fa-shopping-bag"></i></div>
</div>
<div class="stat-card-value"><?= number_format($salesOverview['total_orders'] ?? 0) ?></div>
<div class="stat-card-change <?= $ordersChange >= 0 ? 'positive' : 'negative' ?>">
<i class="fas fa-<?= $ordersChange >= 0 ? 'arrow-up' : 'arrow-down' ?>"></i>
<?= abs(round($ordersChange, 1)) ?>% vs previous period
</div>
</div>
<div class="stat-card">
<div class="stat-card-header">
<h3 class="stat-card-title">Average Order Value</h3>
<div class="stat-card-icon warning"><i class="fas fa-receipt"></i></div>
</div>
<div class="stat-card-value"><?= formatCurrency($salesOverview['avg_order_value'] ?? 0) ?></div>
<div class="stat-card-change" style="color: var(--admin-text-muted);">
<?= $salesOverview['unique_customers'] ?? 0 ?> unique customers
</div>
</div>
<div class="stat-card">
<div class="stat-card-header">
<h3 class="stat-card-title">Abandoned Carts</h3>
<div class="stat-card-icon info"><i class="fas fa-cart-arrow-down"></i></div>
</div>
<div class="stat-card-value"><?= number_format($abandonedCarts['count'] ?? 0) ?></div>
<div class="stat-card-change" style="color: var(--admin-warning);">
<?= formatCurrency($abandonedCarts['value'] ?? 0) ?> potential revenue
</div>
</div>
</div>
<!-- Revenue Chart -->
<div class="chart-container">
<div class="chart-header">
<h3 class="chart-title">Revenue Trend</h3>
</div>
<div class="mini-chart" id="revenueChart">
<?php
$maxRevenue = max(array_column($dailySales, 'revenue') ?: [1]);
foreach ($dailySales as $day):
$height = $maxRevenue > 0 ? ($day['revenue'] / $maxRevenue) * 100 : 0;
?>
<div class="mini-chart-bar"
style="height: <?= max(4, $height) ?>%;"
title="<?= date('M d', strtotime($day['date'])) ?>: <?= formatCurrency($day['revenue']) ?>"></div>
<?php endforeach; ?>
<?php if (empty($dailySales)): ?>
<div style="width: 100%; text-align: center; padding: 2rem; color: var(--admin-text-muted);">
No sales data for this period
</div>
<?php endif; ?>
</div>
</div>
<div class="analytics-grid">
<!-- Top Products -->
<div class="chart-container">
<div class="chart-header">
<h3 class="chart-title">Top Selling Products</h3>
</div>
<?php if (empty($topProducts)): ?>
<p class="text-muted text-center">No product data available</p>
<?php else: ?>
<?php
$maxSold = max(array_column($topProducts, 'total_sold') ?: [1]);
foreach ($topProducts as $i => $product):
?>
<div class="list-item">
<div style="flex: 1; min-width: 0;">
<div style="display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.25rem;">
<span style="font-weight: 500;"><?= $i + 1 ?>.</span>
<span style="font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
<?= htmlspecialchars(truncate($product['name'], 30)) ?>
</span>
</div>
<div class="progress-bar" style="width: 200px;">
<div class="progress-bar-fill" style="width: <?= ($product['total_sold'] / $maxSold) * 100 ?>%;"></div>
</div>
</div>
<div style="text-align: right;">
<div style="font-weight: 600;"><?= number_format($product['total_sold']) ?> sold</div>
<div class="text-muted" style="font-size: 0.75rem;"><?= formatCurrency($product['total_revenue']) ?></div>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
<!-- Sales by Category -->
<div class="chart-container">
<div class="chart-header">
<h3 class="chart-title">Sales by Category</h3>
</div>
<?php if (empty($categoryStats)): ?>
<p class="text-muted text-center">No category data available</p>
<?php else: ?>
<?php
$totalCatRevenue = array_sum(array_column($categoryStats, 'revenue')) ?: 1;
$colors = ['#FF5E1A', '#10B981', '#F59E0B', '#3B82F6', '#8B5CF6', '#EC4899'];
foreach ($categoryStats as $i => $cat):
$percentage = ($cat['revenue'] / $totalCatRevenue) * 100;
?>
<div class="list-item">
<div style="display: flex; align-items: center; gap: 0.75rem;">
<div style="width: 12px; height: 12px; border-radius: 3px; background: <?= $colors[$i % count($colors)] ?>;"></div>
<span style="font-weight: 500;"><?= htmlspecialchars($cat['category']) ?></span>
</div>
<div style="text-align: right;">
<div style="font-weight: 600;"><?= formatCurrency($cat['revenue']) ?></div>
<div class="text-muted" style="font-size: 0.75rem;"><?= round($percentage, 1) ?>%</div>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
<div class="analytics-grid-equal">
<!-- Customer Insights -->
<div class="chart-container">
<div class="chart-header">
<h3 class="chart-title">Customer Insights</h3>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 1.5rem;">
<div style="text-align: center; padding: 1.5rem; background: var(--admin-bg); border-radius: var(--admin-radius);">
<div style="font-size: 2rem; font-weight: 700; color: var(--admin-success);"><?= $newCustomers ?></div>
<div class="text-muted" style="font-size: 0.875rem;">New Customers</div>
</div>
<div style="text-align: center; padding: 1.5rem; background: var(--admin-bg); border-radius: var(--admin-radius);">
<div style="font-size: 2rem; font-weight: 700; color: var(--admin-primary);"><?= $returningCustomers ?></div>
<div class="text-muted" style="font-size: 0.875rem;">Returning Customers</div>
</div>
</div>
<h4 style="margin: 0 0 0.75rem; font-size: 0.875rem;">Top Customers</h4>
<?php foreach (array_slice($topCustomers, 0, 5) as $customer): ?>
<div class="list-item" style="padding: 0.5rem 0;">
<div>
<div style="font-weight: 500;"><?= htmlspecialchars($customer['name'] ?? $customer['email']) ?></div>
<div class="text-muted" style="font-size: 0.75rem;"><?= $customer['order_count'] ?> orders</div>
</div>
<div style="font-weight: 600; color: var(--admin-success);"><?= formatCurrency($customer['total_spent']) ?></div>
</div>
<?php endforeach; ?>
</div>
<!-- Payment & Inventory -->
<div class="chart-container">
<div class="chart-header">
<h3 class="chart-title">Payment Methods</h3>
</div>
<?php if (empty($paymentMethods)): ?>
<p class="text-muted text-center">No payment data available</p>
<?php else: ?>
<?php
$totalPayments = array_sum(array_column($paymentMethods, 'count')) ?: 1;
foreach ($paymentMethods as $method):
?>
<div class="list-item" style="padding: 0.5rem 0;">
<div style="display: flex; align-items: center; gap: 0.75rem;">
<i class="fas fa-<?= match($method['method']) {
'card', 'stripe' => 'credit-card',
'cash' => 'money-bill',
'wallet' => 'wallet',
default => 'money-check'
} ?>" style="color: var(--admin-text-muted);"></i>
<span style="font-weight: 500;"><?= ucfirst($method['method']) ?></span>
</div>
<div style="text-align: right;">
<div style="font-weight: 600;"><?= round(($method['count'] / $totalPayments) * 100, 1) ?>%</div>
<div class="text-muted" style="font-size: 0.75rem;"><?= formatCurrency($method['revenue']) ?></div>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
<h4 style="margin: 1.5rem 0 0.75rem; font-size: 0.875rem;">Inventory Alerts</h4>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
<div style="padding: 1rem; background: rgba(245, 158, 11, 0.1); border-radius: var(--admin-radius); text-align: center;">
<div style="font-size: 1.5rem; font-weight: 700; color: var(--admin-warning);"><?= $lowStockCount ?></div>
<div class="text-muted" style="font-size: 0.75rem;">Low Stock</div>
</div>
<div style="padding: 1rem; background: rgba(239, 68, 68, 0.1); border-radius: var(--admin-radius); text-align: center;">
<div style="font-size: 1.5rem; font-weight: 700; color: var(--admin-error);"><?= $outOfStockCount ?></div>
<div class="text-muted" style="font-size: 0.75rem;">Out of Stock</div>
</div>
</div>
</div>
</div>
<!-- Hourly Sales Pattern -->
<div class="chart-container">
<div class="chart-header">
<h3 class="chart-title">Sales by Hour of Day</h3>
</div>
<div class="mini-chart" style="height: 120px;">
<?php
// Fill in missing hours
$hourlyData = array_fill(0, 24, 0);
foreach ($hourlySales as $h) {
$hourlyData[$h['hour']] = $h['orders'];
}
$maxHourly = max($hourlyData) ?: 1;
for ($h = 0; $h < 24; $h++):
$height = ($hourlyData[$h] / $maxHourly) * 100;
$label = $h === 0 ? '12am' : ($h < 12 ? "{$h}am" : ($h === 12 ? '12pm' : ($h - 12) . 'pm'));
?>
<div class="mini-chart-bar"
style="height: <?= max(4, $height) ?>%;"
title="<?= $label ?>: <?= $hourlyData[$h] ?> orders"></div>
<?php endfor; ?>
</div>
<div style="display: flex; justify-content: space-between; margin-top: 0.5rem; font-size: 0.7rem; color: var(--admin-text-muted);">
<span>12am</span>
<span>6am</span>
<span>12pm</span>
<span>6pm</span>
<span>12am</span>
</div>
</div>
<?php require_once __DIR__ . '/includes/footer.php'; ?>
+15
View File
@@ -0,0 +1,15 @@
<?php
require_once __DIR__ . '/../../includes/auth.php';
header('Content-Type: application/json');
if (!AdminAuth::isLoggedIn()) { echo json_encode(['error'=>'Unauthorized']); exit; }
$cid = trim($_GET['customer_id'] ?? '');
if (!$cid) { echo json_encode(['error'=>'No customer ID','orders'=>[]]); exit; }
try {
$orders = db()->fetchAll(
"SELECT order_id, order_number, total, order_status, payment_status, items, shipping_address, tracking_number, created_at FROM orders WHERE customer_id = :id ORDER BY created_at DESC",
['id' => $cid]
);
echo json_encode(['success'=>true,'orders'=>$orders]);
} catch (Exception $e) {
echo json_encode(['error'=>$e->getMessage(),'orders'=>[]]);
}
+29
View File
@@ -0,0 +1,29 @@
<?php
require_once __DIR__ . '/../includes/header.php';
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || empty($_FILES['image'])) {
echo json_encode(['error' => 'No file received']); exit;
}
$file = $_FILES['image'];
$allowed = ['image/jpeg','image/png','image/gif','image/webp'];
if (!in_array($file['type'], $allowed)) {
echo json_encode(['error' => 'Invalid type. Use JPG, PNG, WebP or GIF.']); exit;
}
if ($file['size'] > 5 * 1024 * 1024) {
echo json_encode(['error' => 'File too large (max 5 MB).']); exit;
}
$dir = __DIR__ . '/../../uploads/splashes/';
if (!is_dir($dir)) mkdir($dir, 0755, true);
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
$name = 'splash_' . time() . '_' . bin2hex(random_bytes(4)) . '.' . $ext;
$path = $dir . $name;
if (move_uploaded_file($file['tmp_name'], $path)) {
echo json_encode(['success' => true, 'url' => '/uploads/splashes/' . $name]);
} else {
echo json_encode(['error' => 'Could not save file.']);
}
+642
View File
@@ -0,0 +1,642 @@
/**
* Tom's Java Jive - Admin Stylesheet
*/
:root {
--admin-primary: #E86A33;
--admin-primary-dark: #C4562A;
--admin-secondary: #8B4513;
--admin-success: #10B981;
--admin-warning: #F59E0B;
--admin-error: #EF4444;
--admin-info: #3B82F6;
--admin-bg: #F3F4F6;
--admin-surface: #FFFFFF;
--admin-border: #E5E7EB;
--admin-text: #111827;
--admin-text-muted: #6B7280;
--admin-text-light: #9CA3AF;
--admin-sidebar-bg: #1F2937;
--admin-sidebar-text: #E5E7EB;
--admin-sidebar-active: rgba(232, 106, 51, 0.2);
--admin-radius: 8px;
--admin-shadow: 0 1px 3px rgba(0,0,0,0.1), 0 1px 2px rgba(0,0,0,0.06);
--admin-shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.1);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body.admin-body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 0.9375rem;
background: var(--admin-bg);
color: var(--admin-text);
line-height: 1.5;
}
/* Layout */
.admin-layout {
display: flex;
min-height: 100vh;
}
/* Sidebar */
.admin-sidebar {
width: 260px;
background: var(--admin-sidebar-bg);
color: var(--admin-sidebar-text);
display: flex;
flex-direction: column;
position: fixed;
top: 0;
left: 0;
bottom: 0;
z-index: 100;
transition: transform 0.3s ease;
}
.sidebar-header {
padding: 1.25rem;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.admin-logo {
display: flex;
align-items: center;
gap: 0.75rem;
color: white;
text-decoration: none;
font-weight: 600;
font-size: 1.125rem;
}
.admin-logo .logo-img {
height: 32px;
width: auto;
}
.sidebar-nav {
flex: 1;
padding: 1rem 0;
overflow-y: auto;
}
.nav-group {
margin-bottom: 0.5rem;
}
.nav-group-title {
display: block;
padding: 0.5rem 1.25rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--admin-text-light);
}
.nav-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 1.25rem;
color: var(--admin-sidebar-text);
text-decoration: none;
transition: all 0.15s ease;
}
.nav-item:hover {
background: rgba(255,255,255,0.05);
color: white;
}
.nav-item.active {
background: var(--admin-sidebar-active);
color: var(--admin-primary);
border-right: 3px solid var(--admin-primary);
}
.nav-item i {
width: 20px;
text-align: center;
opacity: 0.7;
}
.nav-item.active i {
opacity: 1;
}
.sidebar-footer {
padding: 1rem;
border-top: 1px solid rgba(255,255,255,0.1);
}
/* Main Content */
.admin-main {
flex: 1;
margin-left: 260px;
display: flex;
flex-direction: column;
}
/* Header */
.admin-header {
background: var(--admin-surface);
padding: 1rem 1.5rem;
display: flex;
align-items: center;
gap: 1rem;
border-bottom: 1px solid var(--admin-border);
position: sticky;
top: 0;
z-index: 50;
}
.sidebar-toggle {
display: none;
background: none;
border: none;
font-size: 1.25rem;
cursor: pointer;
color: var(--admin-text);
}
.header-search {
flex: 1;
max-width: 400px;
position: relative;
}
.header-search i {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--admin-text-light);
}
.header-search input {
width: 100%;
padding: 0.5rem 0.75rem 0.5rem 2.5rem;
border: 1px solid var(--admin-border);
border-radius: var(--admin-radius);
font-size: 0.875rem;
background: var(--admin-bg);
}
.header-search input:focus {
outline: none;
border-color: var(--admin-primary);
background: var(--admin-surface);
}
.header-actions {
display: flex;
align-items: center;
gap: 1rem;
margin-left: auto;
}
.admin-user {
display: flex;
align-items: center;
gap: 0.75rem;
}
/* Content Area */
.admin-content {
flex: 1;
padding: 1.5rem;
}
/* Page Header */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
}
.page-title {
font-size: 1.5rem;
font-weight: 600;
color: var(--admin-text);
}
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-card {
background: var(--admin-surface);
border-radius: var(--admin-radius);
padding: 1.25rem;
display: flex;
align-items: center;
gap: 1rem;
box-shadow: var(--admin-shadow);
}
.stat-card-icon {
width: 48px;
height: 48px;
border-radius: var(--admin-radius);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
}
.stat-card-icon.primary { background: rgba(232, 106, 51, 0.15); color: var(--admin-primary); }
.stat-card-icon.success { background: rgba(16, 185, 129, 0.15); color: var(--admin-success); }
.stat-card-icon.warning { background: rgba(245, 158, 11, 0.15); color: var(--admin-warning); }
.stat-card-icon.error { background: rgba(239, 68, 68, 0.15); color: var(--admin-error); }
.stat-card-value {
font-size: 1.5rem;
font-weight: 700;
color: var(--admin-text);
}
.stat-card-label {
font-size: 0.875rem;
color: var(--admin-text-muted);
}
/* Admin Cards */
.admin-card {
background: var(--admin-surface);
border-radius: var(--admin-radius);
box-shadow: var(--admin-shadow);
margin-bottom: 1rem;
}
.admin-card-header {
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--admin-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.25rem;
}
/* Tables */
.admin-table {
width: 100%;
border-collapse: collapse;
}
.admin-table th,
.admin-table td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid var(--admin-border);
}
.admin-table th {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--admin-text-muted);
background: var(--admin-bg);
}
.admin-table tr:hover {
background: var(--admin-bg);
}
.admin-table tr:last-child td {
border-bottom: none;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
border-radius: var(--admin-radius);
border: none;
cursor: pointer;
transition: all 0.15s ease;
text-decoration: none;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-primary {
background: var(--admin-primary);
color: white;
}
.btn-primary:hover:not(:disabled) {
background: var(--admin-primary-dark);
color: white;
}
.btn-secondary {
background: var(--admin-surface);
color: var(--admin-text);
border: 1px solid var(--admin-border);
}
.btn-secondary:hover:not(:disabled) {
background: var(--admin-bg);
color: var(--admin-text);
}
.btn-danger {
background: var(--admin-error);
color: white;
}
.btn-danger:hover:not(:disabled) {
background: #DC2626;
color: white;
}
.btn-success {
background: var(--admin-success);
color: white;
}
.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.8125rem;
}
.btn-lg {
padding: 0.75rem 1.5rem;
font-size: 1rem;
}
.btn-block {
display: flex;
width: 100%;
}
/* Forms */
.form-group {
margin-bottom: 1rem;
}
.form-label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.375rem;
color: var(--admin-text);
}
.form-input,
.form-select,
.form-textarea {
width: 100%;
padding: 0.5rem 0.75rem;
font-size: 0.9375rem;
border: 1px solid var(--admin-border);
border-radius: var(--admin-radius);
background: var(--admin-surface);
transition: border-color 0.15s ease;
}
.form-input:focus,
.form-select:focus,
.form-textarea:focus {
outline: none;
border-color: var(--admin-primary);
box-shadow: 0 0 0 3px rgba(232, 106, 51, 0.1);
}
.form-textarea {
min-height: 100px;
resize: vertical;
}
.form-error {
font-size: 0.8125rem;
color: var(--admin-error);
margin-top: 0.25rem;
}
.form-row {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
/* Badges */
.badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
font-weight: 500;
border-radius: 9999px;
}
.badge-primary {
background: rgba(232, 106, 51, 0.15);
color: var(--admin-primary);
}
.badge-success {
background: rgba(16, 185, 129, 0.15);
color: var(--admin-success);
}
.badge-warning {
background: rgba(245, 158, 11, 0.15);
color: var(--admin-warning);
}
.badge-error {
background: rgba(239, 68, 68, 0.15);
color: var(--admin-error);
}
/* Alerts */
.alert {
padding: 0.75rem 1rem;
border-radius: var(--admin-radius);
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.75rem;
}
.alert-success {
background: rgba(16, 185, 129, 0.1);
color: var(--admin-success);
border: 1px solid rgba(16, 185, 129, 0.3);
}
.alert-error {
background: rgba(239, 68, 68, 0.1);
color: var(--admin-error);
border: 1px solid rgba(239, 68, 68, 0.3);
}
.alert-warning {
background: rgba(245, 158, 11, 0.1);
color: var(--admin-warning);
border: 1px solid rgba(245, 158, 11, 0.3);
}
/* Utilities */
.text-muted { color: var(--admin-text-muted); }
.text-error { color: var(--admin-error); }
.text-success { color: var(--admin-success); }
.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; }
/* Responsive */
@media (max-width: 1024px) {
.admin-sidebar {
transform: translateX(-100%);
}
.admin-sidebar.open {
transform: translateX(0);
}
.admin-main {
margin-left: 0;
}
.sidebar-toggle {
display: block;
}
}
@media (max-width: 640px) {
.stats-grid {
grid-template-columns: 1fr;
}
.form-row {
grid-template-columns: 1fr;
}
.admin-table {
font-size: 0.8125rem;
}
.admin-table th,
.admin-table td {
padding: 0.5rem;
}
}
/* Loading */
.loading {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid currentColor;
border-radius: 50%;
border-top-color: transparent;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
}
.modal-overlay.active {
opacity: 1;
visibility: visible;
}
.modal {
background: var(--admin-surface);
border-radius: var(--admin-radius);
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
transform: scale(0.95);
transition: transform 0.3s ease;
}
.modal-overlay.active .modal {
transform: scale(1);
}
.modal-header {
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--admin-border);
display: flex;
align-items: center;
justify-content: space-between;
}
.modal-title {
font-size: 1.125rem;
font-weight: 600;
}
.modal-close {
background: none;
border: none;
font-size: 1.25rem;
cursor: pointer;
color: var(--admin-text-muted);
}
.modal-body {
padding: 1.25rem;
}
.modal-footer {
padding: 1rem 1.25rem;
border-top: 1px solid var(--admin-border);
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
+200
View File
@@ -0,0 +1,200 @@
/**
* Tom's Java Jive - Admin JavaScript
*/
// Sidebar Toggle
document.addEventListener('DOMContentLoaded', function() {
const sidebarToggle = document.getElementById('sidebarToggle');
const sidebar = document.querySelector('.admin-sidebar');
if (sidebarToggle && sidebar) {
sidebarToggle.addEventListener('click', function() {
sidebar.classList.toggle('open');
});
// Close sidebar when clicking outside on mobile
document.addEventListener('click', function(e) {
if (window.innerWidth <= 1024) {
if (!sidebar.contains(e.target) && !sidebarToggle.contains(e.target)) {
sidebar.classList.remove('open');
}
}
});
}
// Confirm 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);
});
});
// Toast notifications
const AdminToast = {
container: null,
init() {
if (!this.container) {
this.container = document.createElement('div');
this.container.style.cssText = 'position:fixed;bottom:20px;right:20px;z-index:1000;display:flex;flex-direction:column;gap:8px;';
document.body.appendChild(this.container);
}
},
show(message, type = 'success', duration = 3000) {
this.init();
const colors = {
success: '#10B981',
error: '#EF4444',
warning: '#F59E0B',
info: '#3B82F6'
};
const toast = document.createElement('div');
toast.style.cssText = `
background: white;
padding: 12px 16px;
border-radius: 8px;
box-shadow: 0 10px 15px rgba(0,0,0,0.1);
border-left: 4px solid ${colors[type] || colors.info};
display: flex;
align-items: center;
gap: 10px;
animation: slideIn 0.3s ease;
`;
toast.innerHTML = `
<i class="fas fa-${type === 'success' ? 'check-circle' : type === 'error' ? 'exclamation-circle' : 'info-circle'}" style="color:${colors[type]}"></i>
<span>${message}</span>
`;
this.container.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'slideIn 0.3s ease reverse';
setTimeout(() => toast.remove(), 300);
}, duration);
},
success(msg) { this.show(msg, 'success'); },
error(msg) { this.show(msg, 'error'); },
warning(msg) { this.show(msg, 'warning'); },
info(msg) { this.show(msg, 'info'); }
};
// API helper
async function adminFetch(url, options = {}) {
const defaults = {
headers: {
'Content-Type': 'application/json',
}
};
const response = await fetch(url, { ...defaults, ...options });
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Request failed');
}
return data;
}
// Loading state
function setLoading(button, isLoading) {
if (isLoading) {
button.dataset.originalHtml = button.innerHTML;
button.innerHTML = '<span class="loading"></span> Loading...';
button.disabled = true;
} else {
button.innerHTML = button.dataset.originalHtml || button.innerHTML;
button.disabled = false;
}
}
// Format currency
function formatCurrency(amount) {
return '$' + parseFloat(amount).toFixed(2);
}
// Format date
function formatDate(dateString) {
return new Date(dateString).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
}
// Debounce
function debounce(func, wait) {
let timeout;
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
}
// Modal
const Modal = {
open(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
modal.classList.add('active');
document.body.style.overflow = 'hidden';
}
},
close(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
modal.classList.remove('active');
document.body.style.overflow = '';
}
}
};
// Close modals on overlay click
document.addEventListener('click', function(e) {
if (e.target.classList.contains('modal-overlay')) {
e.target.classList.remove('active');
document.body.style.overflow = '';
}
});
// 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]);
}
}
// Add slideIn animation
const style = document.createElement('style');
style.textContent = `
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
`;
document.head.appendChild(style);
+243
View File
@@ -0,0 +1,243 @@
<?php
ob_start();
/**
* Tom's Java Jive - Admin Email Campaigns
*/
$pageTitle = 'Email Campaigns';
require_once __DIR__ . '/includes/header.php';
// Handle send campaign
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $_POST['action'] === 'send') {
$subject = trim($_POST['subject'] ?? '');
$content = trim($_POST['content'] ?? '');
$testEmail = trim($_POST['test_email'] ?? '');
if (empty($subject) || empty($content)) {
setFlash('error', 'Subject and content are required');
} else {
if ($testEmail) {
// Send test email
$sent = sendEmail($testEmail, $subject, $content);
if ($sent) {
setFlash('success', 'Test email sent to ' . $testEmail);
} else {
setFlash('error', 'Failed to send test email');
}
} else {
// Send to all subscribers
$subscribers = db()->fetchAll("SELECT email, name FROM email_subscribers WHERE is_active = 1");
$sentCount = 0;
foreach ($subscribers as $sub) {
$personalizedContent = str_replace(
['{{name}}', '{{email}}'],
[$sub['name'] ?? 'Valued Customer', $sub['email']],
$content
);
if (sendEmail($sub['email'], $subject, $personalizedContent)) {
$sentCount++;
}
}
setFlash('success', "Campaign sent to $sentCount subscribers");
}
}
header('Location: /admin/campaigns.php');
exit;
}
// Get subscriber stats
$totalSubscribers = db()->count('email_subscribers', 'is_active = 1');
$recentSubscribers = db()->fetchAll(
"SELECT * FROM email_subscribers ORDER BY created_at DESC LIMIT 10"
);
?>
<div class="page-header">
<h1 class="page-title">Email Campaigns</h1>
</div>
<?php if (hasFlash('success')): ?>
<div class="alert alert-success"><i class="fas fa-check-circle"></i> <?= getFlash('success') ?></div>
<?php endif; ?>
<?php if (hasFlash('error')): ?>
<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> <?= getFlash('error') ?></div>
<?php endif; ?>
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 1.5rem;">
<!-- Campaign Form -->
<div>
<form method="POST">
<input type="hidden" name="action" value="send">
<div class="admin-card">
<div class="admin-card-header">
<h3 class="admin-card-title">Create Campaign</h3>
</div>
<div class="admin-card-body">
<div class="form-group">
<label class="form-label">Subject Line *</label>
<input type="text" name="subject" class="form-input" required placeholder="e.g., New Coffee Arrivals!">
</div>
<div class="form-group">
<label class="form-label">Email Content (HTML) *</label>
<textarea name="content" class="form-textarea" rows="15" required placeholder="Enter HTML email content..."></textarea>
<small class="text-muted">Use {{name}} for subscriber name, {{email}} for email</small>
</div>
<div style="border-top: 1px solid var(--admin-border); padding-top: 1rem; margin-top: 1rem;">
<div class="form-group">
<label class="form-label">Send Test Email First (optional)</label>
<div style="display: flex; gap: 0.5rem;">
<input type="email" name="test_email" class="form-input" placeholder="your@email.com">
<button type="submit" class="btn btn-secondary">Send Test</button>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary btn-lg" onclick="return confirm('Send this campaign to <?= $totalSubscribers ?> subscribers?')">
<i class="fas fa-paper-plane"></i> Send to All Subscribers (<?= $totalSubscribers ?>)
</button>
</div>
</div>
</form>
<!-- Email Templates -->
<div class="admin-card">
<div class="admin-card-header">
<h3 class="admin-card-title">Quick Templates</h3>
</div>
<div class="admin-card-body">
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem;">
<button class="btn btn-secondary" onclick="loadTemplate('welcome')">
<i class="fas fa-hand-wave"></i> Welcome
</button>
<button class="btn btn-secondary" onclick="loadTemplate('promo')">
<i class="fas fa-percent"></i> Promo
</button>
<button class="btn btn-secondary" onclick="loadTemplate('new_product')">
<i class="fas fa-box-open"></i> New Product
</button>
</div>
</div>
</div>
</div>
<!-- Sidebar -->
<div>
<!-- Stats -->
<div class="admin-card">
<div class="admin-card-header">
<h3 class="admin-card-title">Subscriber Stats</h3>
</div>
<div class="admin-card-body">
<div style="text-align: center; margin-bottom: 1rem;">
<div style="font-size: 2.5rem; font-weight: 700; color: var(--admin-primary);"><?= $totalSubscribers ?></div>
<div class="text-muted">Active Subscribers</div>
</div>
</div>
</div>
<!-- Recent Subscribers -->
<div class="admin-card">
<div class="admin-card-header">
<h3 class="admin-card-title">Recent Subscribers</h3>
</div>
<div class="admin-card-body" style="padding: 0;">
<?php if (empty($recentSubscribers)): ?>
<p class="text-muted" style="padding: 1rem; text-align: center;">No subscribers yet</p>
<?php else: ?>
<ul style="list-style: none; padding: 0; margin: 0;">
<?php foreach ($recentSubscribers as $sub): ?>
<li style="padding: 0.75rem 1rem; border-bottom: 1px solid var(--admin-border); display: flex; justify-content: space-between; align-items: center;">
<div>
<div style="font-size: 0.875rem;"><?= htmlspecialchars($sub['email']) ?></div>
<div class="text-muted" style="font-size: 0.75rem;"><?= formatDate($sub['created_at']) ?></div>
</div>
<?php if (!$sub['is_active']): ?>
<span class="badge badge-error">Unsubscribed</span>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
</div>
</div>
</div>
<script>
const templates = {
welcome: {
subject: 'Welcome to Tom\'s Java Jive! ☕',
content: `<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: #8B4513; color: white; padding: 30px; text-align: center;">
<h1 style="margin: 0;">Welcome to the Family!</h1>
</div>
<div style="padding: 30px; background: #FDFBF7;">
<p>Hi {{name}},</p>
<p>Thank you for subscribing to Tom's Java Jive newsletter! We're thrilled to have you as part of our coffee-loving community.</p>
<p>As a welcome gift, enjoy <strong>10% off</strong> your first order with code: <strong>WELCOME10</strong></p>
<p style="text-align: center; margin-top: 30px;">
<a href="https://tomsjavajive.com/shop.php" style="background: #E86A33; color: white; padding: 15px 30px; text-decoration: none; border-radius: 6px; font-weight: bold;">Shop Now</a>
</p>
<p style="margin-top: 30px;">Happy brewing!</p>
<p>- The Tom's Java Jive Team</p>
</div>
</div>`
},
promo: {
subject: '🎉 Special Offer Inside!',
content: `<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: linear-gradient(135deg, #E86A33, #8B4513); color: white; padding: 40px; text-align: center;">
<h1 style="margin: 0; font-size: 36px;">FLASH SALE</h1>
<p style="font-size: 24px; margin-top: 10px;">20% OFF Everything</p>
</div>
<div style="padding: 30px; background: #FDFBF7; text-align: center;">
<p>Hi {{name}},</p>
<p>For a limited time, enjoy 20% off your entire order!</p>
<p style="font-size: 24px; font-weight: bold; color: #E86A33; margin: 20px 0;">Use code: FLASH20</p>
<p>Hurry - offer ends soon!</p>
<p style="margin-top: 30px;">
<a href="https://tomsjavajive.com/shop.php" style="background: #E86A33; color: white; padding: 15px 30px; text-decoration: none; border-radius: 6px; font-weight: bold;">Shop the Sale</a>
</p>
</div>
</div>`
},
new_product: {
subject: '☕ New Arrival: You\'re Going to Love This!',
content: `<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: #8B4513; color: white; padding: 20px; text-align: center;">
<h1 style="margin: 0;">New Coffee Alert!</h1>
</div>
<div style="padding: 30px; background: #FDFBF7;">
<p>Hi {{name}},</p>
<p>We're excited to introduce our latest addition to the Tom's Java Jive family!</p>
<div style="background: white; padding: 20px; border-radius: 8px; margin: 20px 0; text-align: center;">
<h2 style="color: #8B4513;">[Product Name]</h2>
<p>[Product Description]</p>
<p style="font-size: 20px; font-weight: bold; color: #E86A33;">$XX.XX</p>
</div>
<p>Be among the first to try it!</p>
<p style="text-align: center; margin-top: 30px;">
<a href="https://tomsjavajive.com/shop.php" style="background: #E86A33; color: white; padding: 15px 30px; text-decoration: none; border-radius: 6px; font-weight: bold;">Try It Now</a>
</p>
</div>
</div>`
}
};
function loadTemplate(name) {
const template = templates[name];
if (template) {
document.querySelector('input[name="subject"]').value = template.subject;
document.querySelector('textarea[name="content"]').value = template.content;
AdminToast.success('Template loaded');
}
}
</script>
<?php require_once __DIR__ . '/includes/footer.php'; ?>
+206
View File
@@ -0,0 +1,206 @@
<?php
ob_start();
/**
* Tom's Java Jive - Admin Categories
*/
$pageTitle = 'Categories';
require_once __DIR__ . '/includes/header.php';
// Handle actions
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
if ($action === 'create' || $action === 'update') {
$categoryId = $_POST['category_id'] ?? '';
$name = trim($_POST['name'] ?? '');
$slug = trim($_POST['slug'] ?? '') ?: slugify($name);
$description = trim($_POST['description'] ?? '');
$isActive = isset($_POST['is_active']) ? 1 : 0;
if (empty($name)) {
setFlash('error', 'Category name is required');
} else {
$data = [
'name' => $name,
'slug' => $slug,
'description' => $description,
'is_active' => $isActive
];
if ($action === 'update' && $categoryId) {
db()->update('categories', $data, 'category_id = :id', ['id' => $categoryId]);
setFlash('success', 'Category updated');
} else {
$data['category_id'] = generateId('cat_');
db()->insert('categories', $data);
setFlash('success', 'Category created');
}
}
header('Location: /admin/categories.php');
exit;
}
if ($action === 'delete' && !empty($_POST['category_id'])) {
db()->delete('categories', 'category_id = :id', ['id' => $_POST['category_id']]);
setFlash('success', 'Category deleted');
header('Location: /admin/categories.php');
exit;
}
}
// Get categories with product counts
$categories = db()->fetchAll(
"SELECT c.*,
(SELECT COUNT(*) FROM products p WHERE p.category = c.slug OR p.category = c.name) as product_count
FROM categories c
ORDER BY c.name ASC"
);
// Also get uncategorized products
$uncategorizedCount = db()->count('products', "category IS NULL OR category = ''");
?>
<div class="page-header">
<h1 class="page-title">Categories</h1>
<button class="btn btn-primary" onclick="openCategoryModal()">
<i class="fas fa-plus"></i> Add Category
</button>
</div>
<?php if (hasFlash('success')): ?>
<div class="alert alert-success"><i class="fas fa-check-circle"></i> <?= getFlash('success') ?></div>
<?php endif; ?>
<?php if (hasFlash('error')): ?>
<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> <?= getFlash('error') ?></div>
<?php endif; ?>
<div class="admin-card">
<div class="admin-card-body" style="padding: 0;">
<table class="admin-table">
<thead>
<tr>
<th>Name</th>
<th>Slug</th>
<th>Products</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($categories)): ?>
<tr><td colspan="5" class="text-muted" style="text-align: center; padding: 2rem;">No categories yet. Create one above.</td></tr>
<?php else: ?>
<?php foreach ($categories as $cat): ?>
<tr>
<td><strong><?= htmlspecialchars($cat['name']) ?></strong></td>
<td class="text-muted"><?= htmlspecialchars($cat['slug']) ?></td>
<td>
<?php if ($cat['product_count'] > 0): ?>
<a href="/admin/products.php?category=<?= urlencode($cat['slug']) ?>">
<?= $cat['product_count'] ?> products
</a>
<?php else: ?>
<span class="text-muted">0 products</span>
<?php endif; ?>
</td>
<td>
<?php if ($cat['is_active']): ?>
<span class="badge badge-success">Active</span>
<?php else: ?>
<span class="badge badge-error">Hidden</span>
<?php endif; ?>
</td>
<td>
<button class="btn btn-sm btn-secondary" onclick='openCategoryModal(<?= json_encode($cat) ?>)'>
<i class="fas fa-edit"></i>
</button>
<form method="POST" style="display: inline;">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="category_id" value="<?= $cat['category_id'] ?>">
<button type="submit" class="btn btn-sm btn-danger" data-confirm="Delete this category? Products won't be deleted.">
<i class="fas fa-trash"></i>
</button>
</form>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
<?php if ($uncategorizedCount > 0): ?>
<tr style="background: var(--admin-bg);">
<td><em>Uncategorized</em></td>
<td class="text-muted">-</td>
<td>
<a href="/admin/products.php?category=uncategorized"><?= $uncategorizedCount ?> products</a>
</td>
<td><span class="badge badge-warning">Default</span></td>
<td></td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<!-- Category Modal -->
<div class="modal-overlay" id="categoryModal">
<div class="modal">
<div class="modal-header">
<h3 class="modal-title" id="categoryModalTitle">Add Category</h3>
<button type="button" class="modal-close" onclick="Modal.close('categoryModal')">&times;</button>
</div>
<form method="POST">
<div class="modal-body">
<input type="hidden" name="action" id="categoryAction" value="create">
<input type="hidden" name="category_id" id="categoryId">
<div class="form-group">
<label class="form-label">Category Name *</label>
<input type="text" name="name" id="categoryName" class="form-input" required>
</div>
<div class="form-group">
<label class="form-label">Slug</label>
<input type="text" name="slug" id="categorySlug" class="form-input" placeholder="auto-generated if empty">
</div>
<div class="form-group">
<label class="form-label">Description</label>
<textarea name="description" id="categoryDesc" class="form-textarea" rows="2"></textarea>
</div>
<div class="form-group mb-0">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" name="is_active" id="categoryActive" checked>
Active (visible on store)
</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="Modal.close('categoryModal')">Cancel</button>
<button type="submit" class="btn btn-primary" id="categorySubmitBtn">Add Category</button>
</div>
</form>
</div>
</div>
<script>
function openCategoryModal(category = null) {
const isEdit = !!category;
document.getElementById('categoryModalTitle').textContent = isEdit ? 'Edit Category' : 'Add Category';
document.getElementById('categorySubmitBtn').textContent = isEdit ? 'Update Category' : 'Add Category';
document.getElementById('categoryAction').value = isEdit ? 'update' : 'create';
document.getElementById('categoryId').value = isEdit ? category.category_id : '';
document.getElementById('categoryName').value = isEdit ? category.name : '';
document.getElementById('categorySlug').value = isEdit ? category.slug : '';
document.getElementById('categoryDesc').value = isEdit ? (category.description || '') : '';
document.getElementById('categoryActive').checked = isEdit ? category.is_active : true;
Modal.open('categoryModal');
}
</script>
<?php require_once __DIR__ . '/includes/footer.php'; ?>
+260
View File
@@ -0,0 +1,260 @@
<?php
ob_start();
/**
* Tom's Java Jive - Admin Coupons
*/
$pageTitle = 'Coupons';
require_once __DIR__ . '/includes/header.php';
// Handle actions
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
if ($action === 'create' || $action === 'update') {
$couponId = $_POST['coupon_id'] ?? '';
$code = strtoupper(trim($_POST['code'] ?? ''));
$discountType = $_POST['discount_type'] ?? 'percentage';
$discountValue = floatval($_POST['discount_value'] ?? 0);
$minOrderAmount = floatval($_POST['min_order_amount'] ?? 0);
$maxUses = intval($_POST['max_uses'] ?? 0);
$expiresAt = $_POST['expires_at'] ?: null;
$isActive = isset($_POST['is_active']) ? 1 : 0;
if (empty($code) || $discountValue <= 0) {
setFlash('error', 'Code and discount value are required');
} else {
$data = [
'code' => $code,
'discount_type' => $discountType,
'discount_value' => $discountValue,
'min_order_amount' => $minOrderAmount ?: null,
'max_uses' => $maxUses ?: null,
'expires_at' => $expiresAt,
'is_active' => $isActive
];
if ($action === 'update' && $couponId) {
db()->update('coupons', $data, 'coupon_id = :id', ['id' => $couponId]);
setFlash('success', 'Coupon updated');
} else {
// Check for duplicate code
$existing = db()->fetch("SELECT id FROM coupons WHERE code = :code", ['code' => $code]);
if ($existing) {
setFlash('error', 'Coupon code already exists');
} else {
$data['coupon_id'] = generateId('coup_');
$data['times_used'] = 0;
db()->insert('coupons', $data);
setFlash('success', 'Coupon created');
}
}
}
header('Location: /admin/coupons.php');
exit;
}
if ($action === 'delete' && !empty($_POST['coupon_id'])) {
db()->delete('coupons', 'coupon_id = :id', ['id' => $_POST['coupon_id']]);
setFlash('success', 'Coupon deleted');
header('Location: /admin/coupons.php');
exit;
}
if ($action === 'toggle' && !empty($_POST['coupon_id'])) {
$coupon = db()->fetch("SELECT is_active FROM coupons WHERE coupon_id = :id", ['id' => $_POST['coupon_id']]);
if ($coupon) {
db()->update('coupons', ['is_active' => !$coupon['is_active']], 'coupon_id = :id', ['id' => $_POST['coupon_id']]);
setFlash('success', 'Coupon status updated');
}
header('Location: /admin/coupons.php');
exit;
}
}
// Get coupons
$coupons = db()->fetchAll("SELECT * FROM coupons ORDER BY created_at DESC");
?>
<div class="page-header">
<h1 class="page-title">Coupons & Discounts</h1>
<button class="btn btn-primary" onclick="openCouponModal()">
<i class="fas fa-plus"></i> Create Coupon
</button>
</div>
<?php if (hasFlash('success')): ?>
<div class="alert alert-success"><i class="fas fa-check-circle"></i> <?= getFlash('success') ?></div>
<?php endif; ?>
<?php if (hasFlash('error')): ?>
<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> <?= getFlash('error') ?></div>
<?php endif; ?>
<div class="admin-card">
<div class="admin-card-body" style="padding: 0;">
<table class="admin-table">
<thead>
<tr>
<th>Code</th>
<th>Discount</th>
<th>Min Order</th>
<th>Usage</th>
<th>Expires</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($coupons)): ?>
<tr><td colspan="7" class="text-muted" style="text-align: center; padding: 2rem;">No coupons created yet</td></tr>
<?php else: ?>
<?php foreach ($coupons as $coupon):
$isExpired = $coupon['expires_at'] && strtotime($coupon['expires_at']) < time();
$isMaxed = $coupon['max_uses'] && $coupon['times_used'] >= $coupon['max_uses'];
?>
<tr>
<td><strong style="font-family: monospace;"><?= htmlspecialchars($coupon['code']) ?></strong></td>
<td>
<?php if ($coupon['discount_type'] === 'percentage'): ?>
<?= $coupon['discount_value'] ?>% off
<?php else: ?>
<?= formatCurrency($coupon['discount_value']) ?> off
<?php endif; ?>
</td>
<td><?= $coupon['min_order_amount'] ? formatCurrency($coupon['min_order_amount']) : '-' ?></td>
<td>
<?= $coupon['times_used'] ?><?= $coupon['max_uses'] ? '/' . $coupon['max_uses'] : '' ?>
</td>
<td>
<?php if ($coupon['expires_at']): ?>
<?php if ($isExpired): ?>
<span class="text-error"><?= formatDate($coupon['expires_at']) ?></span>
<?php else: ?>
<?= formatDate($coupon['expires_at']) ?>
<?php endif; ?>
<?php else: ?>
<span class="text-muted">Never</span>
<?php endif; ?>
</td>
<td>
<?php if (!$coupon['is_active']): ?>
<span class="badge badge-error">Disabled</span>
<?php elseif ($isExpired): ?>
<span class="badge badge-warning">Expired</span>
<?php elseif ($isMaxed): ?>
<span class="badge badge-warning">Maxed Out</span>
<?php else: ?>
<span class="badge badge-success">Active</span>
<?php endif; ?>
</td>
<td>
<button class="btn btn-sm btn-secondary" onclick='openCouponModal(<?= json_encode($coupon) ?>)'>
<i class="fas fa-edit"></i>
</button>
<form method="POST" style="display: inline;">
<input type="hidden" name="action" value="toggle">
<input type="hidden" name="coupon_id" value="<?= $coupon['coupon_id'] ?>">
<button type="submit" class="btn btn-sm btn-secondary">
<i class="fas fa-<?= $coupon['is_active'] ? 'ban' : 'check' ?>"></i>
</button>
</form>
<form method="POST" style="display: inline;">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="coupon_id" value="<?= $coupon['coupon_id'] ?>">
<button type="submit" class="btn btn-sm btn-danger" data-confirm="Delete this coupon?">
<i class="fas fa-trash"></i>
</button>
</form>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<!-- Coupon Modal -->
<div class="modal-overlay" id="couponModal">
<div class="modal">
<div class="modal-header">
<h3 class="modal-title" id="couponModalTitle">Create Coupon</h3>
<button type="button" class="modal-close" onclick="Modal.close('couponModal')">&times;</button>
</div>
<form method="POST" id="couponForm">
<div class="modal-body">
<input type="hidden" name="action" id="couponAction" value="create">
<input type="hidden" name="coupon_id" id="couponId">
<div class="form-group">
<label class="form-label">Coupon Code *</label>
<input type="text" name="code" id="couponCode" class="form-input" required style="text-transform: uppercase;">
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Discount Type</label>
<select name="discount_type" id="couponType" class="form-select">
<option value="percentage">Percentage (%)</option>
<option value="fixed">Fixed Amount ($)</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Discount Value *</label>
<input type="number" name="discount_value" id="couponValue" class="form-input" step="0.01" min="0" required>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Min Order Amount</label>
<input type="number" name="min_order_amount" id="couponMinOrder" class="form-input" step="0.01" min="0">
</div>
<div class="form-group">
<label class="form-label">Max Uses (0 = unlimited)</label>
<input type="number" name="max_uses" id="couponMaxUses" class="form-input" min="0">
</div>
</div>
<div class="form-group">
<label class="form-label">Expiration Date</label>
<input type="date" name="expires_at" id="couponExpires" class="form-input">
</div>
<div class="form-group mb-0">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" name="is_active" id="couponActive" value="1" checked>
Active
</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="Modal.close('couponModal')">Cancel</button>
<button type="submit" class="btn btn-primary" id="couponSubmitBtn">Create Coupon</button>
</div>
</form>
</div>
</div>
<script>
function openCouponModal(coupon = null) {
const isEdit = !!coupon;
document.getElementById('couponModalTitle').textContent = isEdit ? 'Edit Coupon' : 'Create Coupon';
document.getElementById('couponSubmitBtn').textContent = isEdit ? 'Update Coupon' : 'Create Coupon';
document.getElementById('couponAction').value = isEdit ? 'update' : 'create';
document.getElementById('couponId').value = isEdit ? coupon.coupon_id : '';
document.getElementById('couponCode').value = isEdit ? coupon.code : '';
document.getElementById('couponType').value = isEdit ? coupon.discount_type : 'percentage';
document.getElementById('couponValue').value = isEdit ? coupon.discount_value : '';
document.getElementById('couponMinOrder').value = isEdit && coupon.min_order_amount ? coupon.min_order_amount : '';
document.getElementById('couponMaxUses').value = isEdit && coupon.max_uses ? coupon.max_uses : '';
document.getElementById('couponExpires').value = isEdit && coupon.expires_at ? coupon.expires_at.split(' ')[0] : '';
document.getElementById('couponActive').checked = isEdit ? coupon.is_active : true;
Modal.open('couponModal');
}
</script>
<?php require_once __DIR__ . '/includes/footer.php'; ?>
+528
View File
@@ -0,0 +1,528 @@
<?php
ob_start();
/**
* Tom's Java Jive - Admin Customers Management
*/
$pageTitle = 'Customers';
require_once __DIR__ . '/includes/header.php';
// Handle actions
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
if ($action === 'create') {
$email = strtolower(trim($_POST['email'] ?? ''));
$name = trim($_POST['name'] ?? '');
$phone = trim($_POST['phone'] ?? '');
$walletBalance = floatval($_POST['wallet_balance'] ?? 0);
$rewardPoints = intval($_POST['reward_points'] ?? 0);
if (empty($email)) {
setFlash('error', 'Email is required');
} else {
$existing = db()->fetch("SELECT id FROM customers WHERE email = :email", ['email' => $email]);
if ($existing) {
setFlash('error', 'Customer with this email already exists');
} else {
db()->insert('customers', [
'customer_id' => generateId('cust_'),
'email' => $email,
'name' => $name ?: null,
'phone' => $phone ?: null,
'wallet_balance' => $walletBalance,
'reward_points' => $rewardPoints,
'is_active' => 1
]);
setFlash('success', 'Customer created successfully');
}
}
header('Location: /admin/customers.php');
exit;
}
if ($action === 'update' && !empty($_POST['customer_id'])) {
$customerId = $_POST['customer_id'];
$name = trim($_POST['name'] ?? '');
$phone = trim($_POST['phone'] ?? '');
$walletBalance = floatval($_POST['wallet_balance'] ?? 0);
$rewardPoints = intval($_POST['reward_points'] ?? 0);
$isActive = isset($_POST['is_active']) ? 1 : 0;
db()->update('customers', [
'name' => $name ?: null,
'phone' => $phone ?: null,
'wallet_balance' => $walletBalance,
'reward_points' => $rewardPoints,
'is_active' => $isActive
], 'customer_id = :id', ['id' => $customerId]);
setFlash('success', 'Customer updated successfully');
header('Location: /admin/customers.php');
exit;
}
if ($action === 'delete' && !empty($_POST['customer_id'])) {
db()->delete('customers', 'customer_id = :id', ['id' => $_POST['customer_id']]);
setFlash('success', 'Customer deleted');
header('Location: /admin/customers.php');
exit;
}
if ($action === 'adjust_wallet' && !empty($_POST['customer_id'])) {
$amount = floatval($_POST['amount'] ?? 0);
$reason = trim($_POST['reason'] ?? '');
if ($amount != 0) {
db()->query(
"UPDATE customers SET wallet_balance = wallet_balance + :amt WHERE customer_id = :id",
['amt' => $amount, 'id' => $_POST['customer_id']]
);
// Log transaction
db()->insert('wallet_transactions', [
'transaction_id' => generateId('wt_'),
'customer_id' => $_POST['customer_id'],
'amount' => $amount,
'type' => $amount > 0 ? 'deposit' : 'withdrawal',
'description' => $reason ?: 'Admin adjustment',
'balance_after' => db()->fetch("SELECT wallet_balance FROM customers WHERE customer_id = :id", ['id' => $_POST['customer_id']])['wallet_balance'] ?? 0
]);
setFlash('success', 'Wallet adjusted by $' . number_format($amount, 2));
}
header('Location: /admin/customers.php');
exit;
}
}
// Filters
$search = $_GET['search'] ?? '';
$status = $_GET['status'] ?? '';
$page = max(1, intval($_GET['page'] ?? 1));
$where = ['1=1'];
$params = [];
if ($search) {
$where[] = '(email LIKE :search OR name LIKE :search OR phone LIKE :search)';
$params['search'] = '%' . $search . '%';
}
if ($status === 'active') {
$where[] = 'is_active = 1';
} elseif ($status === 'inactive') {
$where[] = 'is_active = 0';
}
$whereClause = implode(' AND ', $where);
$total = db()->count('customers', $whereClause, $params);
$pagination = paginate($total, $page, ADMIN_ITEMS_PER_PAGE);
$customers = db()->fetchAll(
"SELECT c.customer_id, c.email, c.name, c.phone, c.wallet_balance, c.reward_points, c.is_active, c.created_at,
COALESCE((SELECT COUNT(*) FROM orders o WHERE o.customer_id = c.customer_id), 0) as order_count,
COALESCE((SELECT SUM(total) FROM orders o WHERE o.customer_id = c.customer_id AND o.payment_status = 'paid'), 0) as total_spent
FROM customers c
WHERE {$whereClause}
ORDER BY c.created_at DESC
LIMIT " . (int)$pagination['per_page'] . " OFFSET " . (int)$pagination['offset'],
$params
);
// Stats
$totalCustomers = db()->count('customers');
$activeCustomers = db()->count('customers', 'is_active = 1');
$totalWalletBalance = (float)(db()->fetch("SELECT COALESCE(SUM(wallet_balance),0) as total FROM customers")['total'] ?? 0);
?>
<div class="page-header">
<h1 class="page-title">Customers</h1>
<button class="btn btn-primary" onclick="openCustomerModal()">
<i class="fas fa-plus"></i> Add Customer
</button>
</div>
<?php if (hasFlash('success')): ?>
<div class="alert alert-success"><i class="fas fa-check-circle"></i> <?= getFlash('success') ?></div>
<?php endif; ?>
<?php if (hasFlash('error')): ?>
<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> <?= getFlash('error') ?></div>
<?php endif; ?>
<!-- Stats -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-card-icon primary"><i class="fas fa-users"></i></div>
<div>
<div class="stat-card-value"><?= $totalCustomers ?></div>
<div class="stat-card-label">Total Customers</div>
</div>
</div>
<div class="stat-card">
<div class="stat-card-icon success"><i class="fas fa-user-check"></i></div>
<div>
<div class="stat-card-value"><?= $activeCustomers ?></div>
<div class="stat-card-label">Active</div>
</div>
</div>
<div class="stat-card">
<div class="stat-card-icon warning"><i class="fas fa-wallet"></i></div>
<div>
<div class="stat-card-value"><?= formatCurrency($totalWalletBalance) ?></div>
<div class="stat-card-label">Total Wallet Balance</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="admin-card">
<div class="admin-card-body">
<form method="GET" style="display: flex; gap: 1rem; flex-wrap: wrap; align-items: end;">
<div class="form-group mb-0" style="flex: 1; min-width: 200px;">
<label class="form-label">Search</label>
<input type="text" name="search" class="form-input" placeholder="Email, name, phone..." value="<?= htmlspecialchars($search) ?>">
</div>
<div class="form-group mb-0">
<label class="form-label">Status</label>
<select name="status" class="form-select">
<option value="">All</option>
<option value="active" <?= $status === 'active' ? 'selected' : '' ?>>Active</option>
<option value="inactive" <?= $status === 'inactive' ? 'selected' : '' ?>>Inactive</option>
</select>
</div>
<button type="submit" class="btn btn-secondary"><i class="fas fa-filter"></i> Filter</button>
<?php if ($search || $status): ?>
<a href="/admin/customers.php" class="btn btn-secondary">Clear</a>
<?php endif; ?>
</form>
</div>
</div>
<!-- Table -->
<div class="admin-card">
<div class="admin-card-header">
<span><?= $total ?> customers found</span>
</div>
<div class="admin-card-body" style="padding: 0;">
<table class="admin-table">
<thead>
<tr>
<th>Customer</th>
<th>Phone</th>
<th>Orders</th>
<th>Total Spent</th>
<th>Wallet</th>
<th>Points</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($customers)): ?>
<tr><td colspan="8" class="text-muted" style="text-align: center; padding: 2rem;">No customers found</td></tr>
<?php else: ?>
<?php foreach ($customers as $customer): ?>
<tr>
<td>
<strong><?= htmlspecialchars($customer['name'] ?? 'No Name') ?></strong><br>
<small class="text-muted"><?= htmlspecialchars($customer['email']) ?></small>
</td>
<td><?= htmlspecialchars($customer['phone'] ?? '-') ?></td>
<td><?= $customer['order_count'] ?? 0 ?></td>
<td><?= formatCurrency($customer['total_spent'] ?? 0) ?></td>
<td>
<strong class="<?= ($customer['wallet_balance'] ?? 0) > 0 ? 'text-success' : '' ?>">
<?= formatCurrency($customer['wallet_balance'] ?? 0) ?>
</strong>
</td>
<td><?= $customer['reward_points'] ?? 0 ?></td>
<td>
<?php if ($customer['is_active']): ?>
<span class="badge badge-success">Active</span>
<?php else: ?>
<span class="badge badge-error">Inactive</span>
<?php endif; ?>
</td>
<td>
<button class="btn btn-sm btn-secondary" onclick='openCustomerModal(<?= json_encode($customer) ?>)' title="Edit">
<i class="fas fa-edit"></i>
</button>
<button class="btn btn-sm btn-secondary" onclick='openWalletModal("<?= $customer['customer_id'] ?>", "<?= htmlspecialchars($customer['name'] ?? $customer['email']) ?>", <?= $customer['wallet_balance'] ?? 0 ?>)' title="Adjust Wallet">
<i class="fas fa-wallet"></i>
</button>
<form method="POST" style="display: inline;">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="customer_id" value="<?= $customer['customer_id'] ?>">
<button type="submit" class="btn btn-sm btn-danger" data-confirm="Delete this customer?">
<i class="fas fa-trash"></i>
</button>
</form>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php if ($pagination['total_pages'] > 1): ?>
<div style="display: flex; justify-content: center; margin-top: 1rem;">
<?= renderPagination($pagination, '/admin/customers.php?search=' . urlencode($search) . '&status=' . $status) ?>
</div>
<?php endif; ?>
<!-- Customer Modal -->
<div class="modal-overlay" id="customerModal">
<div class="modal" style="max-width:680px;width:95vw">
<div class="modal-header">
<h3 class="modal-title" id="customerModalTitle">Add Customer</h3>
<button type="button" class="modal-close" onclick="Modal.close('customerModal')">&times;</button>
</div>
<div id="customerTabs" style="display:none;border-bottom:1px solid var(--color-border);padding:0 1.5rem">
<button type="button" onclick="switchCTab('details')" id="ctab-details"
style="padding:.6rem 1rem;border:none;border-bottom:2px solid var(--color-primary);background:none;cursor:pointer;font-weight:600;color:var(--color-primary);margin-bottom:-1px">
<i class="fas fa-user"></i> Details
</button>
<button type="button" onclick="switchCTab('orders')" id="ctab-orders"
style="padding:.6rem 1rem;border:none;border-bottom:2px solid transparent;background:none;cursor:pointer;font-weight:600;color:var(--color-text-muted);margin-bottom:-1px">
<i class="fas fa-shopping-bag"></i> Orders
<span id="cOrderBadge" style="background:var(--color-border);border-radius:10px;padding:1px 7px;font-size:.75rem;margin-left:3px"></span>
</button>
</div>
<div id="cpanel-details">
<form method="POST" id="customerForm">
<div class="modal-body">
<input type="hidden" name="action" id="customerAction" value="create">
<input type="hidden" name="customer_id" id="customerId">
<div class="form-group">
<label class="form-label">Email *</label>
<input type="email" name="email" id="customerEmail" class="form-input" required>
</div>
<div class="form-group">
<label class="form-label">Name</label>
<input type="text" name="name" id="customerName" class="form-input">
</div>
<div class="form-group">
<label class="form-label">Phone</label>
<input type="tel" name="phone" id="customerPhone" class="form-input">
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Wallet Balance ($)</label>
<input type="number" name="wallet_balance" id="customerWallet" class="form-input" step="0.01" min="0" value="0">
</div>
<div class="form-group">
<label class="form-label">Reward Points</label>
<input type="number" name="reward_points" id="customerPoints" class="form-input" min="0" value="0">
</div>
</div>
<div class="form-group mb-0" id="statusGroup" style="display:none">
<label style="display:flex;align-items:center;gap:.5rem;cursor:pointer">
<input type="checkbox" name="is_active" id="customerActive" checked>
Active
</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="Modal.close('customerModal')">Cancel</button>
<button type="submit" class="btn btn-primary" id="customerSubmitBtn">Add Customer</button>
</div>
</form>
</div>
<div id="cpanel-orders" style="display:none">
<div class="modal-body" style="padding:0;max-height:440px;overflow-y:auto">
<div id="cOrdLoading" style="text-align:center;padding:2rem;color:var(--color-text-muted)">
<i class="fas fa-spinner fa-spin"></i> Loading orders...
</div>
<div id="cOrdContent" style="display:none">
<div id="cOrdEmpty" style="display:none;text-align:center;padding:2rem;color:var(--color-text-muted)">
<i class="fas fa-shopping-bag" style="font-size:2rem;opacity:.3;display:block;margin-bottom:.5rem"></i>
No orders yet
</div>
<table id="cOrdTable" style="width:100%;border-collapse:collapse;display:none">
<thead>
<tr style="background:var(--color-surface)">
<th style="padding:.6rem 1rem;font-size:.75rem;color:var(--color-text-muted);text-transform:uppercase;text-align:left">Order</th>
<th style="padding:.6rem 1rem;font-size:.75rem;color:var(--color-text-muted);text-transform:uppercase;text-align:left">Date</th>
<th style="padding:.6rem 1rem;font-size:.75rem;color:var(--color-text-muted);text-transform:uppercase;text-align:left">Total</th>
<th style="padding:.6rem 1rem;font-size:.75rem;color:var(--color-text-muted);text-transform:uppercase;text-align:left">Status</th>
<th style="padding:.6rem 1rem"></th>
</tr>
</thead>
<tbody id="cOrdBody"></tbody>
</table>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="Modal.close('customerModal')">Close</button>
<a id="cOrdViewAll" href="/admin/orders.php" class="btn btn-primary" target="_blank">View All Orders</a>
</div>
</div>
</div>
</div>
<style>
.cord-row td{padding:.65rem 1rem;border-bottom:1px solid var(--color-border);font-size:.875rem;vertical-align:middle}
.cord-row:hover td{background:var(--color-surface)}
.cord-detail{display:none}
.cord-detail.open{display:table-row}
.cord-detail td{padding:.75rem 1rem 1rem 1.5rem;border-bottom:1px solid var(--color-border);background:var(--color-surface)}
</style>
<script>
// switchCTab defined above
function _switchCTabDummy(tab){
['details','orders'].forEach(function(t){
document.getElementById('ctab-'+t).style.borderBottomColor=t===tab?'var(--color-primary)':'transparent';
document.getElementById('ctab-'+t).style.color=t===tab?'var(--color-primary)':'var(--color-text-muted)';
document.getElementById('cpanel-'+t).style.display=t===tab?'':'none';
});
if(tab==='orders'&&_cCustId&&!_cLoaded)loadCOrders(_cCustId);
}
function loadCOrders(cid){
_cLoaded=true;
document.getElementById('cOrdLoading').style.display='block';
document.getElementById('cOrdContent').style.display='none';
fetch('/admin/api/customer-orders.php?customer_id='+encodeURIComponent(cid))
.then(function(r){return r.json();})
.then(function(data){
document.getElementById('cOrdLoading').style.display='none';
document.getElementById('cOrdContent').style.display='block';
var orders=data.orders||[];
document.getElementById('cOrderBadge').textContent=orders.length;
if(!orders.length){
document.getElementById('cOrdEmpty').style.display='block';
document.getElementById('cOrdTable').style.display='none';
return;
}
document.getElementById('cOrdEmpty').style.display='none';
document.getElementById('cOrdTable').style.display='table';
var tb=document.getElementById('cOrdBody');
tb.innerHTML='';
var sc={pending:'warning',processing:'primary',confirmed:'primary',shipped:'primary',delivered:'success',cancelled:'error',refunded:'error'};
orders.forEach(function(o){
var tr=document.createElement('tr');
tr.className='cord-row';
tr.innerHTML='<td><strong>#'+o.order_number+'</strong></td>'+
'<td>'+new Date(o.created_at).toLocaleDateString()+'</td>'+
'<td><strong>$'+parseFloat(o.total).toFixed(2)+'</strong></td>'+
'<td><span class="badge badge-'+(sc[o.order_status]||'primary')+'">'+o.order_status+'</span></td>'+
'<td><button type="button" class="btn btn-sm btn-secondary" onclick="toggleCOrd(''+o.order_id+'')">'+
'<i class="fas fa-chevron-down" id="cico-'+o.order_id+'"></i></button></td>';
tb.appendChild(tr);
var items=[];
try{items=JSON.parse(o.items||'[]');}catch(e){}
var html='<div style="font-weight:600;margin-bottom:.4rem">Items</div>';
if(items.length){
items.forEach(function(it){
html+='<div style="display:flex;justify-content:space-between;padding:.2rem 0;font-size:.8rem;border-bottom:1px solid var(--color-border)">'+
'<span>'+(it.name||it.product_name||'Item')+' &times; '+(it.quantity||1)+'</span>'+
'<span>$'+parseFloat(it.total||it.price||0).toFixed(2)+'</span></div>';
});
}else{html+='<div style="color:var(--color-text-muted);font-size:.8rem">No item details available</div>';}
html+='<div style="display:flex;justify-content:space-between;font-weight:700;margin-top:.4rem;padding-top:.4rem;border-top:2px solid var(--color-border)">'+
'<span>Total</span><span>$'+parseFloat(o.total).toFixed(2)+'</span></div>';
if(o.tracking_number)html+='<div style="margin-top:.4rem;font-size:.8rem"><strong>Tracking:</strong> '+o.tracking_number+'</div>';
html+='<div style="margin-top:.6rem"><a href="/admin/order.php?id='+o.order_id+'" class="btn btn-sm btn-primary" target="_blank">'+
'<i class="fas fa-external-link-alt"></i> Open Order</a></div>';
var dr=document.createElement('tr');
dr.className='cord-detail';
dr.id='cdet-'+o.order_id;
dr.innerHTML='<td colspan="5">'+html+'</td>';
tb.appendChild(dr);
});
})
.catch(function(){
document.getElementById('cOrdLoading').innerHTML='<div style="color:var(--color-error);text-align:center;padding:1rem"><i class="fas fa-exclamation-circle"></i> Failed to load orders</div>';
});
}
function toggleCOrd(id){
var d=document.getElementById('cdet-'+id);
var i=document.getElementById('cico-'+id);
d.classList.toggle('open');
i.className=d.classList.contains('open')?'fas fa-chevron-up':'fas fa-chevron-down';
}
</script>
<div class="modal-overlay" id="walletModal">
<div class="modal">
<div class="modal-header">
<h3 class="modal-title">Adjust Wallet Balance</h3>
<button type="button" class="modal-close" onclick="Modal.close('walletModal')">&times;</button>
</div>
<form method="POST">
<div class="modal-body">
<input type="hidden" name="action" value="adjust_wallet">
<input type="hidden" name="customer_id" id="walletCustomerId">
<p>Customer: <strong id="walletCustomerName"></strong></p>
<p>Current Balance: <strong id="walletCurrentBalance"></strong></p>
<div class="form-group">
<label class="form-label">Adjustment Amount</label>
<input type="number" name="amount" class="form-input" step="0.01" required placeholder="Use negative to deduct">
<small class="text-muted">Positive to add, negative to subtract</small>
</div>
<div class="form-group mb-0">
<label class="form-label">Reason</label>
<input type="text" name="reason" class="form-input" placeholder="e.g., Refund, Bonus, Correction">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="Modal.close('walletModal')">Cancel</button>
<button type="submit" class="btn btn-primary">Apply Adjustment</button>
</div>
</form>
</div>
</div>
<script>
var _cCustId = null;
var _cLoaded = false;
function switchCTab(tab) {
['details','orders'].forEach(function(t) {
var tabBtn = document.getElementById('ctab-' + t);
var panel = document.getElementById('cpanel-' + t);
if (tabBtn) {
tabBtn.style.borderBottomColor = t === tab ? 'var(--color-primary)' : 'transparent';
tabBtn.style.color = t === tab ? 'var(--color-primary)' : 'var(--color-text-muted)';
}
if (panel) panel.style.display = t === tab ? '' : 'none';
});
if (tab === 'orders' && _cCustId && !_cLoaded) loadCOrders(_cCustId);
}
function openCustomerModal(customer = null) {
const isEdit = !!customer;
_cCustId = isEdit ? customer.customer_id : null;
_cLoaded = false;
document.getElementById('customerModalTitle').textContent = isEdit ? 'Edit Customer' : 'Add Customer';
document.getElementById('customerSubmitBtn').textContent = isEdit ? 'Update Customer' : 'Add Customer';
document.getElementById('customerAction').value = isEdit ? 'update' : 'create';
document.getElementById('customerId').value = isEdit ? customer.customer_id : '';
document.getElementById('customerEmail').value = isEdit ? customer.email : '';
document.getElementById('customerEmail').readOnly = isEdit;
document.getElementById('customerName').value = isEdit ? (customer.name || '') : '';
document.getElementById('customerPhone').value = isEdit ? (customer.phone || '') : '';
document.getElementById('customerWallet').value = isEdit ? (customer.wallet_balance || 0) : 0;
document.getElementById('customerPoints').value = isEdit ? (customer.reward_points || 0) : 0;
document.getElementById('customerActive').checked = isEdit ? customer.is_active : true;
document.getElementById('statusGroup').style.display = isEdit ? 'block' : 'none';
document.getElementById('customerTabs').style.display = isEdit ? 'flex' : 'none';
document.getElementById('cOrderBadge').textContent = '';
switchCTab('details');
Modal.open('customerModal');
}
function openWalletModal(customerId, name, balance) {
document.getElementById('walletCustomerId').value = customerId;
document.getElementById('walletCustomerName').textContent = name;
document.getElementById('walletCurrentBalance').textContent = '$' + parseFloat(balance).toFixed(2);
Modal.open('walletModal');
}
</script>
<?php require_once __DIR__ . '/includes/footer.php'; ?>
+218
View File
@@ -0,0 +1,218 @@
<?php
ob_start();
/**
* Tom's Java Jive - Admin Email Settings
*/
$pageTitle = 'Email Settings';
require_once __DIR__ . '/includes/header.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$section = $_POST['section'] ?? '';
if ($section === 'sendgrid') {
setSetting('email_sendgrid', [
'api_key' => trim($_POST['sendgrid_api_key'] ?? ''),
'from_email' => trim($_POST['from_email'] ?? ''),
'from_name' => trim($_POST['from_name'] ?? '')
]);
setFlash('success', 'SendGrid settings updated');
}
if ($section === 'notifications') {
setSetting('email_notifications', [
'order_confirmation' => isset($_POST['notif_order_confirmation']),
'order_shipped' => isset($_POST['notif_order_shipped']),
'order_delivered' => isset($_POST['notif_order_delivered']),
'abandoned_cart' => isset($_POST['notif_abandoned_cart']),
'low_stock' => isset($_POST['notif_low_stock']),
'admin_new_order' => isset($_POST['notif_admin_new_order']),
'admin_email' => trim($_POST['admin_email'] ?? '')
]);
setFlash('success', 'Notification settings updated');
}
if ($section === 'test') {
$testEmail = trim($_POST['test_email'] ?? '');
if ($testEmail && filter_var($testEmail, FILTER_VALIDATE_EMAIL)) {
$sent = sendEmail($testEmail, 'Test Email from Tom\'s Java Jive',
'<div style="font-family: Arial; padding: 20px;"><h2>Test Email</h2><p>If you received this, your email settings are working correctly!</p></div>'
);
if ($sent) {
setFlash('success', 'Test email sent to ' . $testEmail);
} else {
setFlash('error', 'Failed to send test email. Check your SendGrid settings.');
}
}
}
header('Location: /admin/emails.php');
exit;
}
$sendgrid = getSetting('email_sendgrid', [
'api_key' => '',
'from_email' => '',
'from_name' => "Tom's Java Jive"
]);
$notifications = getSetting('email_notifications', [
'order_confirmation' => true,
'order_shipped' => true,
'order_delivered' => true,
'abandoned_cart' => false,
'low_stock' => true,
'admin_new_order' => true,
'admin_email' => ''
]);
?>
<div class="page-header">
<h1 class="page-title">Email Settings</h1>
</div>
<?php if (hasFlash('success')): ?>
<div class="alert alert-success"><i class="fas fa-check-circle"></i> <?= getFlash('success') ?></div>
<?php endif; ?>
<?php if (hasFlash('error')): ?>
<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> <?= getFlash('error') ?></div>
<?php endif; ?>
<div style="display: grid; grid-template-columns: 200px 1fr; gap: 1.5rem;">
<div>
<div class="admin-card">
<div class="admin-card-body" style="padding: 0.5rem;">
<a href="/admin/settings.php" class="nav-item"><i class="fas fa-store"></i> General</a>
<a href="/admin/shipping.php" class="nav-item"><i class="fas fa-truck"></i> Shipping</a>
<a href="/admin/payments.php" class="nav-item"><i class="fas fa-credit-card"></i> Payments</a>
<a href="/admin/emails.php" class="nav-item active"><i class="fas fa-envelope"></i> Emails</a>
</div>
</div>
</div>
<div>
<!-- SendGrid Settings -->
<form method="POST">
<input type="hidden" name="section" value="sendgrid">
<div class="admin-card">
<div class="admin-card-header">
<h3 class="admin-card-title"><i class="fas fa-paper-plane"></i> SendGrid Configuration</h3>
</div>
<div class="admin-card-body">
<div class="form-group">
<label class="form-label">SendGrid API Key</label>
<input type="password" name="sendgrid_api_key" class="form-input"
value="<?= htmlspecialchars($sendgrid['api_key']) ?>"
placeholder="SG.xxxx...">
<small class="text-muted">Get this from <a href="https://app.sendgrid.com/settings/api_keys" target="_blank">SendGrid Dashboard</a></small>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">From Email</label>
<input type="email" name="from_email" class="form-input"
value="<?= htmlspecialchars($sendgrid['from_email']) ?>"
placeholder="noreply@yourdomain.com">
</div>
<div class="form-group">
<label class="form-label">From Name</label>
<input type="text" name="from_name" class="form-input"
value="<?= htmlspecialchars($sendgrid['from_name']) ?>"
placeholder="Tom's Java Jive">
</div>
</div>
<button type="submit" class="btn btn-primary">Save SendGrid Settings</button>
</div>
</div>
</form>
<!-- Notification Settings -->
<form method="POST">
<input type="hidden" name="section" value="notifications">
<div class="admin-card">
<div class="admin-card-header">
<h3 class="admin-card-title">Email Notifications</h3>
</div>
<div class="admin-card-body">
<h4 style="margin-bottom: 1rem; font-size: 0.9rem; color: var(--admin-text-muted);">Customer Notifications</h4>
<div class="form-group">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" name="notif_order_confirmation" <?= $notifications['order_confirmation'] ? 'checked' : '' ?>>
Order confirmation email
</label>
</div>
<div class="form-group">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" name="notif_order_shipped" <?= $notifications['order_shipped'] ? 'checked' : '' ?>>
Order shipped notification
</label>
</div>
<div class="form-group">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" name="notif_order_delivered" <?= $notifications['order_delivered'] ? 'checked' : '' ?>>
Order delivered notification
</label>
</div>
<div class="form-group">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" name="notif_abandoned_cart" <?= $notifications['abandoned_cart'] ? 'checked' : '' ?>>
Abandoned cart reminders
</label>
</div>
<hr style="margin: 1.5rem 0;">
<h4 style="margin-bottom: 1rem; font-size: 0.9rem; color: var(--admin-text-muted);">Admin Notifications</h4>
<div class="form-group">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" name="notif_admin_new_order" <?= $notifications['admin_new_order'] ? 'checked' : '' ?>>
New order notification
</label>
</div>
<div class="form-group">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" name="notif_low_stock" <?= $notifications['low_stock'] ? 'checked' : '' ?>>
Low stock alerts
</label>
</div>
<div class="form-group mb-0">
<label class="form-label">Admin Email Address</label>
<input type="email" name="admin_email" class="form-input"
value="<?= htmlspecialchars($notifications['admin_email']) ?>"
placeholder="admin@yourdomain.com">
</div>
<button type="submit" class="btn btn-primary mt-2">Save Notification Settings</button>
</div>
</div>
</form>
<!-- Test Email -->
<form method="POST">
<input type="hidden" name="section" value="test">
<div class="admin-card">
<div class="admin-card-header">
<h3 class="admin-card-title">Test Email</h3>
</div>
<div class="admin-card-body">
<div class="form-group mb-0">
<label class="form-label">Send test email to:</label>
<div style="display: flex; gap: 0.5rem;">
<input type="email" name="test_email" class="form-input" placeholder="your@email.com" required>
<button type="submit" class="btn btn-secondary">Send Test</button>
</div>
</div>
</div>
</div>
</form>
</div>
</div>
<?php require_once __DIR__ . '/includes/footer.php'; ?>
+326
View File
@@ -0,0 +1,326 @@
<?php
ob_start();
/**
* Tom's Java Jive - Admin Gift Cards
*/
$pageTitle = 'Gift Cards';
require_once __DIR__ . '/includes/header.php';
// Handle actions
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
if ($action === 'create') {
$initialBalance = floatval($_POST['initial_balance'] ?? 0);
$recipientEmail = trim($_POST['recipient_email'] ?? '');
$recipientName = trim($_POST['recipient_name'] ?? '');
$message = trim($_POST['message'] ?? '');
$purchaserEmail = trim($_POST['purchaser_email'] ?? '');
if ($initialBalance > 0) {
$code = strtoupper('GC' . bin2hex(random_bytes(4)));
db()->insert('gift_cards', [
'gift_card_id' => generateId('gc_'),
'code' => $code,
'initial_balance' => $initialBalance,
'current_balance' => $initialBalance,
'purchaser_email' => $purchaserEmail ?: null,
'recipient_email' => $recipientEmail ?: null,
'recipient_name' => $recipientName ?: null,
'message' => $message ?: null,
'is_active' => 1
]);
setFlash('success', "Gift card created! Code: $code");
// Send email if recipient provided
if ($recipientEmail) {
$html = <<<HTML
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: #8B4513; color: white; padding: 20px; text-align: center;">
<h1 style="margin: 0;">You've Received a Gift!</h1>
</div>
<div style="padding: 30px; background: #FDFBF7; text-align: center;">
<p>Hi{$recipientName},</p>
<p>You've received a Tom's Java Jive gift card!</p>
<div style="background: white; padding: 20px; border-radius: 8px; margin: 20px 0;">
<p style="font-size: 24px; font-weight: bold; color: #E86A33;">$initialBalance</p>
<p style="font-size: 18px; letter-spacing: 3px;">$code</p>
</div>
<?php if ($message): ?><p style='font-style: italic;'>\<?php endif; ?>
<a href="https://tomsjavajive.com/shop.php" style="display: inline-block; background: #E86A33; color: white; padding: 12px 24px; border-radius: 6px; text-decoration: none; margin-top: 20px;">Shop Now</a>
</div>
</div>
HTML;
sendEmail($recipientEmail, "You've Received a Gift Card!", $html);
}
} else {
setFlash('error', 'Invalid balance amount');
}
header('Location: /admin/gift-cards.php');
exit;
}
if ($action === 'toggle' && !empty($_POST['gift_card_id'])) {
$gc = db()->fetch("SELECT is_active FROM gift_cards WHERE gift_card_id = :id", ['id' => $_POST['gift_card_id']]);
if ($gc) {
db()->update('gift_cards', ['is_active' => !$gc['is_active']], 'gift_card_id = :id', ['id' => $_POST['gift_card_id']]);
setFlash('success', 'Gift card status updated');
}
header('Location: /admin/gift-cards.php');
exit;
}
if ($action === 'adjust' && !empty($_POST['gift_card_id'])) {
$amount = floatval($_POST['amount'] ?? 0);
if ($amount != 0) {
db()->query(
"UPDATE gift_cards SET current_balance = current_balance + :amt WHERE gift_card_id = :id",
['amt' => $amount, 'id' => $_POST['gift_card_id']]
);
setFlash('success', 'Balance adjusted');
}
header('Location: /admin/gift-cards.php');
exit;
}
}
// Filters
$search = $_GET['search'] ?? '';
$status = $_GET['status'] ?? '';
$page = max(1, intval($_GET['page'] ?? 1));
$where = ['1=1'];
$params = [];
if ($search) {
$where[] = '(code LIKE :search OR recipient_email LIKE :search)';
$params['search'] = '%' . $search . '%';
}
if ($status === 'active') {
$where[] = 'is_active = 1 AND current_balance > 0';
} elseif ($status === 'depleted') {
$where[] = 'current_balance <= 0';
} elseif ($status === 'disabled') {
$where[] = 'is_active = 0';
}
$whereClause = implode(' AND ', $where);
$total = db()->count('gift_cards', $whereClause, $params);
$pagination = paginate($total, $page, ADMIN_ITEMS_PER_PAGE);
$giftCards = db()->fetchAll(
"SELECT * FROM gift_cards WHERE {$whereClause} ORDER BY created_at DESC LIMIT :limit OFFSET :offset",
array_merge($params, ['limit' => $pagination['per_page'], 'offset' => $pagination['offset']])
);
// Stats
$totalValue = db()->fetch("SELECT SUM(current_balance) as total FROM gift_cards WHERE is_active = 1")['total'] ?? 0;
$activeCount = db()->count('gift_cards', 'is_active = 1 AND current_balance > 0');
?>
<div class="page-header">
<h1 class="page-title">Gift Cards</h1>
<button class="btn btn-primary" onclick="Modal.open('createModal')">
<i class="fas fa-plus"></i> Create Gift Card
</button>
</div>
<?php if (hasFlash('success')): ?>
<div class="alert alert-success"><i class="fas fa-check-circle"></i> <?= getFlash('success') ?></div>
<?php endif; ?>
<?php if (hasFlash('error')): ?>
<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> <?= getFlash('error') ?></div>
<?php endif; ?>
<!-- Stats -->
<div class="stats-grid" style="margin-bottom: 1.5rem;">
<div class="stat-card">
<div class="stat-card-icon primary"><i class="fas fa-gift"></i></div>
<div>
<div class="stat-card-value"><?= $activeCount ?></div>
<div class="stat-card-label">Active Cards</div>
</div>
</div>
<div class="stat-card">
<div class="stat-card-icon success"><i class="fas fa-dollar-sign"></i></div>
<div>
<div class="stat-card-value"><?= formatCurrency($totalValue) ?></div>
<div class="stat-card-label">Outstanding Value</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="admin-card">
<div class="admin-card-body">
<form method="GET" style="display: flex; gap: 1rem; flex-wrap: wrap; align-items: end;">
<div class="form-group mb-0" style="flex: 1; min-width: 200px;">
<label class="form-label">Search</label>
<input type="text" name="search" class="form-input" placeholder="Code or email..." value="<?= htmlspecialchars($search) ?>">
</div>
<div class="form-group mb-0">
<label class="form-label">Status</label>
<select name="status" class="form-select">
<option value="">All</option>
<option value="active" <?= $status === 'active' ? 'selected' : '' ?>>Active</option>
<option value="depleted" <?= $status === 'depleted' ? 'selected' : '' ?>>Depleted</option>
<option value="disabled" <?= $status === 'disabled' ? 'selected' : '' ?>>Disabled</option>
</select>
</div>
<button type="submit" class="btn btn-secondary"><i class="fas fa-filter"></i> Filter</button>
<?php if ($search || $status): ?>
<a href="/admin/gift-cards.php" class="btn btn-secondary">Clear</a>
<?php endif; ?>
</form>
</div>
</div>
<!-- Table -->
<div class="admin-card">
<div class="admin-card-body" style="padding: 0;">
<table class="admin-table">
<thead>
<tr>
<th>Code</th>
<th>Recipient</th>
<th>Initial</th>
<th>Balance</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($giftCards)): ?>
<tr><td colspan="7" class="text-muted" style="text-align: center; padding: 2rem;">No gift cards found</td></tr>
<?php else: ?>
<?php foreach ($giftCards as $gc): ?>
<tr>
<td><strong style="font-family: monospace;"><?= htmlspecialchars($gc['code']) ?></strong></td>
<td><?= htmlspecialchars($gc['recipient_email'] ?? $gc['recipient_name'] ?? '-') ?></td>
<td><?= formatCurrency($gc['initial_balance']) ?></td>
<td>
<?php if ($gc['current_balance'] <= 0): ?>
<span class="badge badge-error">$0.00</span>
<?php else: ?>
<strong class="text-success"><?= formatCurrency($gc['current_balance']) ?></strong>
<?php endif; ?>
</td>
<td>
<?php if (!$gc['is_active']): ?>
<span class="badge badge-error">Disabled</span>
<?php elseif ($gc['current_balance'] <= 0): ?>
<span class="badge badge-warning">Depleted</span>
<?php else: ?>
<span class="badge badge-success">Active</span>
<?php endif; ?>
</td>
<td class="text-muted"><?= formatDate($gc['created_at']) ?></td>
<td>
<button class="btn btn-sm btn-secondary" onclick="openAdjustModal('<?= $gc['gift_card_id'] ?>', '<?= $gc['code'] ?>', <?= $gc['current_balance'] ?>)">
<i class="fas fa-edit"></i>
</button>
<form method="POST" style="display: inline;">
<input type="hidden" name="action" value="toggle">
<input type="hidden" name="gift_card_id" value="<?= $gc['gift_card_id'] ?>">
<button type="submit" class="btn btn-sm btn-secondary" title="<?= $gc['is_active'] ? 'Disable' : 'Enable' ?>">
<i class="fas fa-<?= $gc['is_active'] ? 'ban' : 'check' ?>"></i>
</button>
</form>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php if ($pagination['total_pages'] > 1): ?>
<div style="display: flex; justify-content: center; margin-top: 1rem;">
<?= renderPagination($pagination, '/admin/gift-cards.php?search=' . urlencode($search) . '&status=' . $status) ?>
</div>
<?php endif; ?>
<!-- Create Modal -->
<div class="modal-overlay" id="createModal">
<div class="modal">
<div class="modal-header">
<h3 class="modal-title">Create Gift Card</h3>
<button type="button" class="modal-close" onclick="Modal.close('createModal')">&times;</button>
</div>
<form method="POST">
<div class="modal-body">
<input type="hidden" name="action" value="create">
<div class="form-group">
<label class="form-label">Amount *</label>
<input type="number" name="initial_balance" class="form-input" step="0.01" min="1" required>
</div>
<div class="form-group">
<label class="form-label">Recipient Email (optional)</label>
<input type="email" name="recipient_email" class="form-input">
</div>
<div class="form-group">
<label class="form-label">Recipient Name (optional)</label>
<input type="text" name="recipient_name" class="form-input">
</div>
<div class="form-group">
<label class="form-label">Personal Message (optional)</label>
<textarea name="message" class="form-textarea" rows="2"></textarea>
</div>
<div class="form-group mb-0">
<label class="form-label">Purchaser Email (optional)</label>
<input type="email" name="purchaser_email" class="form-input">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="Modal.close('createModal')">Cancel</button>
<button type="submit" class="btn btn-primary">Create Gift Card</button>
</div>
</form>
</div>
</div>
<!-- Adjust Modal -->
<div class="modal-overlay" id="adjustModal">
<div class="modal">
<div class="modal-header">
<h3 class="modal-title">Adjust Balance</h3>
<button type="button" class="modal-close" onclick="Modal.close('adjustModal')">&times;</button>
</div>
<form method="POST">
<div class="modal-body">
<input type="hidden" name="action" value="adjust">
<input type="hidden" name="gift_card_id" id="adjust-gc-id">
<p>Card: <strong id="adjust-gc-code"></strong></p>
<p>Current Balance: <strong id="adjust-gc-balance"></strong></p>
<div class="form-group">
<label class="form-label">Adjustment Amount</label>
<input type="number" name="amount" class="form-input" step="0.01" placeholder="Use negative to deduct">
<small class="text-muted">Enter positive to add, negative to subtract</small>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="Modal.close('adjustModal')">Cancel</button>
<button type="submit" class="btn btn-primary">Apply Adjustment</button>
</div>
</form>
</div>
</div>
<script>
function openAdjustModal(id, code, balance) {
document.getElementById('adjust-gc-id').value = id;
document.getElementById('adjust-gc-code').textContent = code;
document.getElementById('adjust-gc-balance').textContent = '$' + parseFloat(balance).toFixed(2);
Modal.open('adjustModal');
}
</script>
<?php require_once __DIR__ . '/includes/footer.php'; ?>
+582
View File
@@ -0,0 +1,582 @@
<?php
ob_start();
$pageTitle = 'Import / Export Inventory';
$currentPage = 'import-export';
require_once __DIR__ . '/includes/header.php';
/* ────────────────────────────────────────────────────
EXPORT
──────────────────────────────────────────────────── */
if (isset($_GET['export'])) {
$products = db()->fetchAll(
"SELECT p.*, c.name as category_name FROM products p
LEFT JOIN categories c ON p.category = c.slug
ORDER BY p.name ASC"
);
$cols = ['product_id','name','description','price','sale_price','cost_price',
'sku','barcode','category','tags','stock','low_stock_threshold',
'weight','is_active','is_featured','images'];
ob_end_clean();
header('Content-Type: text/csv; charset=UTF-8');
header('Content-Disposition: attachment; filename="inventory_' . date('Y-m-d') . '.csv"');
header('Cache-Control: no-cache');
echo "\xEF\xBB\xBF"; // UTF-8 BOM — makes Excel open correctly
$out = fopen('php://output', 'w');
fputcsv($out, $cols);
foreach ($products as $p) {
fputcsv($out, array_map(fn($c) => $p[$c] ?? '', $cols));
}
fclose($out);
exit;
}
/* ────────────────────────────────────────────────────
INLINE FIELD EDIT (AJAX)
──────────────────────────────────────────────────── */
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'inline_edit') {
ob_end_clean();
header('Content-Type: application/json');
$pid = $_POST['product_id'] ?? '';
$field = $_POST['field'] ?? '';
$val = $_POST['value'] ?? '';
$allowed = ['stock','price','sale_price','cost_price','sku','is_active'];
if (!$pid || !in_array($field, $allowed)) {
echo json_encode(['error' => 'Invalid']); exit;
}
if ($field === 'sale_price' && trim($val) === '') $val = null;
db()->update('products', [$field => $val === '' ? null : $val],
'product_id = :id', ['id' => $pid]);
echo json_encode(['ok' => true, 'val' => $val]);
exit;
}
/* ────────────────────────────────────────────────────
IMPORT (POST with file)
──────────────────────────────────────────────────── */
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'import') {
$mode = $_POST['mode'] ?? 'smart'; // smart | replace
if (empty($_FILES['csv_file']['tmp_name'])) {
setFlash('error', 'No file uploaded'); header('Location: /admin/import-export.php'); exit;
}
$fh = fopen($_FILES['csv_file']['tmp_name'], 'r');
// Strip UTF-8 BOM if present
$bom = fread($fh, 3);
if ($bom !== "\xEF\xBB\xBF") rewind($fh);
$headers = fgetcsv($fh);
if (!$headers) {
setFlash('error', 'Could not read CSV headers'); header('Location: /admin/import-export.php'); exit;
}
$headers = array_map('trim', $headers);
// Required: name + price
if (!in_array('name', $headers) || !in_array('price', $headers)) {
setFlash('error', 'CSV must have at least "name" and "price" columns');
header('Location: /admin/import-export.php'); exit;
}
$rows = [];
while (($row = fgetcsv($fh)) !== false) {
if (count($row) < 2) continue;
$rows[] = array_combine($headers, array_pad($row, count($headers), ''));
}
fclose($fh);
if (empty($rows)) {
setFlash('error', 'No data rows found in CSV');
header('Location: /admin/import-export.php'); exit;
}
$inserted = $updated = $deleted = 0;
try {
// Get current product IDs
$existing = array_column(db()->fetchAll("SELECT product_id FROM products"), 'product_id');
$importedIds = array_filter(array_column($rows, 'product_id'));
if ($mode === 'replace') {
// Wipe and reimport — warn user before this in the UI
db()->query("DELETE FROM products WHERE product_id IS NOT NULL", []);
foreach ($rows as $r) {
$pid = (!empty($r['product_id'])) ? $r['product_id'] : generateId('prod_');
db()->insert('products', [
'product_id' => $pid,
'name' => $r['name'],
'description' => $r['description'] ?? null,
'price' => floatval($r['price'] ?? 0),
'sale_price' => ($r['sale_price'] ?? '') !== '' ? floatval($r['sale_price']) : null,
'cost_price' => ($r['cost_price'] ?? '') !== '' ? floatval($r['cost_price']) : null,
'sku' => $r['sku'] ?? null,
'barcode' => $r['barcode'] ?? null,
'category' => $r['category'] ?? null,
'tags' => $r['tags'] ?? null,
'stock' => intval($r['stock'] ?? 0),
'low_stock_threshold'=> intval($r['low_stock_threshold'] ?? 10),
'weight' => ($r['weight'] ?? '') !== '' ? floatval($r['weight']) : null,
'is_active' => intval($r['is_active'] ?? 1),
'is_featured' => intval($r['is_featured'] ?? 0),
'images' => $r['images'] ?? null,
]);
$inserted++;
}
} else {
// Smart mode: update existing, insert new, delete removed
foreach ($rows as $r) {
$pid = $r['product_id'] ?? '';
$data = [
'name' => $r['name'],
'description' => $r['description'] ?? null,
'price' => floatval($r['price'] ?? 0),
'sale_price' => ($r['sale_price'] ?? '') !== '' ? floatval($r['sale_price']) : null,
'cost_price' => ($r['cost_price'] ?? '') !== '' ? floatval($r['cost_price']) : null,
'sku' => $r['sku'] ?? null,
'barcode' => $r['barcode'] ?? null,
'category' => $r['category'] ?? null,
'tags' => $r['tags'] ?? null,
'stock' => intval($r['stock'] ?? 0),
'low_stock_threshold'=> intval($r['low_stock_threshold'] ?? 10),
'weight' => ($r['weight'] ?? '') !== '' ? floatval($r['weight']) : null,
'is_active' => intval($r['is_active'] ?? 1),
'is_featured' => intval($r['is_featured'] ?? 0),
'images' => $r['images'] ?? null,
];
if ($pid && in_array($pid, $existing)) {
db()->update('products', $data, 'product_id = :id', ['id' => $pid]);
$updated++;
} else {
$data['product_id'] = $pid ?: generateId('prod_');
db()->insert('products', $data);
$inserted++;
}
}
// Delete products not in import
$toDelete = array_diff($existing, $importedIds);
foreach ($toDelete as $pid) {
db()->delete('products', 'product_id = :id', ['id' => $pid]);
$deleted++;
}
}
$msg = "Import complete — {$inserted} added, {$updated} updated";
if ($deleted) $msg .= ", {$deleted} removed";
setFlash('success', $msg);
} catch (Exception $e) {
setFlash('error', 'Import failed: ' . $e->getMessage());
}
header('Location: /admin/import-export.php'); exit;
}
/* ────────────────────────────────────────────────────
DELETE single product
──────────────────────────────────────────────────── */
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delete') {
db()->delete('products', 'product_id = :id', ['id' => $_POST['product_id'] ?? '']);
setFlash('success', 'Product deleted');
header('Location: /admin/import-export.php'); exit;
}
/* ────────────────────────────────────────────────────
LIST
──────────────────────────────────────────────────── */
$search = trim($_GET['search'] ?? '');
$catFilter = $_GET['category'] ?? '';
$where = ['1=1'];
$params = [];
if ($search) {
$where[] = '(p.name LIKE :q OR p.sku LIKE :q2 OR p.category LIKE :q3)';
$params['q'] = $params['q2'] = $params['q3'] = "%$search%";
}
if ($catFilter) {
$where[] = 'p.category = :cat';
$params['cat'] = $catFilter;
}
$whereSQL = implode(' AND ', $where);
$products = db()->fetchAll(
"SELECT p.* FROM products p WHERE $whereSQL ORDER BY p.name ASC",
$params
);
$categories = db()->fetchAll("SELECT name, slug FROM categories WHERE is_active=1 ORDER BY name");
$totalProducts = db()->count('products');
$totalStock = db()->fetch("SELECT SUM(stock) as s FROM products")['s'] ?? 0;
$lowStock = db()->count('products', 'stock <= low_stock_threshold AND is_active = 1');
?>
<div class="page-header" style="flex-wrap:wrap;gap:.75rem">
<h1 class="page-title">Import / Export Inventory</h1>
<div style="display:flex;gap:.75rem;flex-wrap:wrap">
<a href="/admin/import-export.php?export=1" class="btn btn-success">
<i class="fas fa-file-download"></i> Export CSV
</a>
<button class="btn btn-primary" onclick="document.getElementById('importPanel').classList.toggle('hidden')">
<i class="fas fa-file-upload"></i> Import CSV
</button>
<a href="/admin/product-edit.php" class="btn btn-secondary">
<i class="fas fa-plus"></i> Add Product
</a>
</div>
</div>
<?php if (hasFlash('success')): ?>
<div class="alert alert-success"><i class="fas fa-check-circle"></i> <?= getFlash('success') ?></div>
<?php endif; ?>
<?php if (hasFlash('error')): ?>
<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> <?= getFlash('error') ?></div>
<?php endif; ?>
<!-- Stats -->
<div class="stats-grid" style="margin-bottom:1.5rem">
<div class="stat-card">
<div class="stat-card-icon primary"><i class="fas fa-box"></i></div>
<div><div class="stat-card-value"><?= $totalProducts ?></div><div class="stat-card-label">Total Products</div></div>
</div>
<div class="stat-card">
<div class="stat-card-icon success"><i class="fas fa-cubes"></i></div>
<div><div class="stat-card-value"><?= number_format($totalStock) ?></div><div class="stat-card-label">Total Units in Stock</div></div>
</div>
<div class="stat-card">
<div class="stat-card-icon <?= $lowStock > 0 ? 'warning' : 'success' ?>"><i class="fas fa-exclamation-triangle"></i></div>
<div><div class="stat-card-value"><?= $lowStock ?></div><div class="stat-card-label">Low Stock Alerts</div></div>
</div>
</div>
<!-- Import Panel (hidden by default) -->
<div id="importPanel" class="hidden admin-card" style="margin-bottom:1.5rem;border:2px solid var(--admin-primary)">
<div class="admin-card-header" style="background:rgba(255,94,26,.06)">
<h3><i class="fas fa-file-upload"></i> Import Inventory from CSV</h3>
</div>
<div class="admin-card-body">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:2rem">
<!-- Mode selector -->
<div>
<h4 style="margin:0 0 .75rem">Import Mode</h4>
<label id="modeSmart" style="display:flex;align-items:flex-start;gap:.75rem;padding:1rem;border:2px solid var(--admin-primary);border-radius:var(--radius-md);cursor:pointer;margin-bottom:.75rem;background:rgba(255,94,26,.04)">
<input type="radio" name="importMode" value="smart" checked onchange="setMode(this)" style="margin-top:.15rem;flex-shrink:0">
<div>
<strong>Smart Update</strong> <span class="badge badge-success" style="margin-left:.25rem">Recommended</span><br>
<span class="text-muted" style="font-size:.85rem">Updates existing products, adds new ones, removes products not in the file. <strong>Preserves order history.</strong></span>
</div>
</label>
<label id="modeReplace" style="display:flex;align-items:flex-start;gap:.75rem;padding:1rem;border:2px solid var(--color-border);border-radius:var(--radius-md);cursor:pointer">
<input type="radio" name="importMode" value="replace" onchange="setMode(this)" style="margin-top:.15rem;flex-shrink:0">
<div>
<strong style="color:var(--color-error)"><i class="fas fa-exclamation-triangle"></i> Full Replace</strong><br>
<span class="text-muted" style="font-size:.85rem">Deletes ALL existing products first, then imports. Use only if you want a clean slate.</span>
</div>
</label>
</div>
<!-- Upload form -->
<div>
<h4 style="margin:0 0 .75rem">Upload File</h4>
<form method="POST" enctype="multipart/form-data" id="importForm" onsubmit="return confirmImport()">
<input type="hidden" name="action" value="import">
<input type="hidden" name="mode" id="importModeField" value="smart">
<div id="csvDrop" style="border:2px dashed var(--color-border);border-radius:var(--radius-md);padding:2rem;text-align:center;cursor:pointer;transition:all .2s;position:relative;margin-bottom:1rem">
<i class="fas fa-file-csv" style="font-size:2.5rem;color:var(--admin-text-muted);display:block;margin-bottom:.5rem"></i>
<div style="font-size:.9rem;color:var(--admin-text-muted)">Drop CSV here or <span style="color:var(--admin-primary);font-weight:600">browse</span></div>
<div id="csvFileName" style="margin-top:.5rem;font-weight:600;color:var(--admin-primary)"></div>
<input type="file" name="csv_file" id="csvFile" accept=".csv,text/csv" required
style="position:absolute;inset:0;opacity:0;cursor:pointer" onchange="showFileName(this)">
</div>
<div id="replaceWarning" style="display:none;background:#FEF2F2;border:1px solid #FCA5A5;border-radius:var(--radius-md);padding:.75rem 1rem;margin-bottom:1rem;font-size:.875rem;color:#991B1B">
<i class="fas fa-exclamation-triangle"></i>
<strong>Warning:</strong> Full Replace will permanently delete all existing products before importing. This cannot be undone.
</div>
<div style="display:flex;gap:.75rem;align-items:center">
<button type="submit" class="btn btn-primary" style="flex:1">
<i class="fas fa-upload"></i> Import Now
</button>
<a href="/admin/import-export.php?export=1" class="btn btn-secondary" title="Download a template first">
<i class="fas fa-download"></i> Get Template
</a>
</div>
<p class="text-muted" style="font-size:.8rem;margin:.5rem 0 0">Tip: Export first to get the correct column format, edit in Excel, then import.</p>
</form>
</div>
</div>
</div>
</div>
<!-- Search + Filter -->
<div class="admin-card" style="margin-bottom:1rem">
<div class="admin-card-body">
<form method="GET" style="display:flex;gap:1rem;flex-wrap:wrap;align-items:flex-end">
<div class="form-group mb-0" style="flex:1;min-width:200px">
<label class="form-label">Search</label>
<input type="text" name="search" class="form-input" placeholder="Name, SKU, category…"
value="<?= htmlspecialchars($search) ?>">
</div>
<div class="form-group mb-0">
<label class="form-label">Category</label>
<select name="category" class="form-select">
<option value="">All</option>
<?php foreach ($categories as $cat): ?>
<option value="<?= htmlspecialchars($cat['slug']) ?>"
<?= $catFilter === $cat['slug'] ? 'selected' : '' ?>>
<?= htmlspecialchars($cat['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="btn btn-secondary"><i class="fas fa-filter"></i> Filter</button>
<?php if ($search || $catFilter): ?>
<a href="/admin/import-export.php" class="btn btn-secondary">Clear</a>
<?php endif; ?>
<span class="text-muted" style="line-height:2.5rem;font-size:.875rem"><?= count($products) ?> product<?= count($products) !== 1 ? 's' : '' ?></span>
</form>
</div>
</div>
<!-- Inventory Table -->
<div class="admin-card">
<div class="admin-card-header">
<span style="font-size:.85rem;color:var(--admin-text-muted)">
<i class="fas fa-info-circle"></i> Click any price or stock cell to edit inline. Changes save instantly.
</span>
</div>
<div class="admin-card-body" style="padding:0;overflow-x:auto">
<?php if (empty($products)): ?>
<div class="text-center text-muted" style="padding:3rem">No products found.</div>
<?php else: ?>
<table class="admin-table" style="min-width:900px">
<thead>
<tr>
<th>Product</th>
<th>SKU</th>
<th>Category</th>
<th style="text-align:right">Price</th>
<th style="text-align:right">Sale</th>
<th style="text-align:right">Cost</th>
<th style="text-align:center">Stock</th>
<th style="text-align:center">Active</th>
<th style="text-align:center">Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($products as $p):
$isLow = $p['stock'] <= $p['low_stock_threshold'] && $p['is_active'];
?>
<tr class="<?= !$p['is_active'] ? 'row-inactive' : '' ?>">
<td>
<strong><?= htmlspecialchars($p['name']) ?></strong>
<?php if ($isLow): ?>
<span class="badge badge-warning" style="margin-left:.4rem;font-size:.7rem">Low Stock</span>
<?php endif; ?>
<div style="font-size:.75rem;color:var(--admin-text-muted)"><?= htmlspecialchars($p['product_id']) ?></div>
</td>
<td class="editable-cell" data-field="sku" data-id="<?= $p['product_id'] ?>">
<span class="cell-display"><?= htmlspecialchars($p['sku'] ?? '—') ?></span>
<input class="cell-input form-input" style="display:none;width:90px;padding:.2rem .4rem;font-size:.85rem" value="<?= htmlspecialchars($p['sku'] ?? '') ?>">
</td>
<td><?= htmlspecialchars($p['category'] ?? '—') ?></td>
<td class="editable-cell" data-field="price" data-id="<?= $p['product_id'] ?>" style="text-align:right">
<span class="cell-display">$<?= number_format($p['price'], 2) ?></span>
<input class="cell-input form-input" style="display:none;width:80px;padding:.2rem .4rem;font-size:.85rem;text-align:right" value="<?= $p['price'] ?>">
</td>
<td class="editable-cell" data-field="sale_price" data-id="<?= $p['product_id'] ?>" style="text-align:right">
<span class="cell-display"><?= $p['sale_price'] ? '$'.number_format($p['sale_price'],2) : '<span style="color:var(--admin-text-muted)">—</span>' ?></span>
<input class="cell-input form-input" style="display:none;width:80px;padding:.2rem .4rem;font-size:.85rem;text-align:right" value="<?= $p['sale_price'] ?? '' ?>" placeholder="none">
</td>
<td class="editable-cell" data-field="cost_price" data-id="<?= $p['product_id'] ?>" style="text-align:right">
<span class="cell-display"><?= $p['cost_price'] ? '$'.number_format($p['cost_price'],2) : '<span style="color:var(--admin-text-muted)">—</span>' ?></span>
<input class="cell-input form-input" style="display:none;width:80px;padding:.2rem .4rem;font-size:.85rem;text-align:right" value="<?= $p['cost_price'] ?? '' ?>" placeholder="none">
</td>
<td class="editable-cell" data-field="stock" data-id="<?= $p['product_id'] ?>" style="text-align:center">
<span class="cell-display" style="font-weight:600;color:<?= $isLow ? 'var(--color-warning)' : 'inherit' ?>"><?= $p['stock'] ?></span>
<input class="cell-input form-input" style="display:none;width:70px;padding:.2rem .4rem;font-size:.85rem;text-align:center" value="<?= $p['stock'] ?>">
</td>
<td style="text-align:center">
<label class="toggle-switch" title="Toggle active">
<input type="checkbox" class="active-toggle"
data-id="<?= $p['product_id'] ?>"
<?= $p['is_active'] ? 'checked' : '' ?>>
<span class="toggle-slider"></span>
</label>
</td>
<td style="text-align:center;white-space:nowrap">
<a href="/admin/product-edit.php?id=<?= $p['product_id'] ?>" class="btn btn-sm btn-secondary" title="Full Edit">
<i class="fas fa-edit"></i>
</a>
<form method="POST" style="display:inline">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="product_id" value="<?= $p['product_id'] ?>">
<button type="submit" class="btn btn-sm btn-danger" data-confirm="Delete <?= htmlspecialchars(addslashes($p['name'])) ?>?">
<i class="fas fa-trash"></i>
</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</div>
<style>
.hidden { display: none !important; }
.row-inactive td { opacity: .5; }
.editable-cell {
cursor: pointer;
position: relative;
}
.editable-cell:hover .cell-display {
background: rgba(255,94,26,.08);
border-radius: 4px;
padding: 2px 6px;
outline: 1px dashed var(--admin-primary);
}
.editable-cell.editing .cell-display { display: none; }
.editable-cell.editing .cell-input { display: inline-block !important; }
.editable-cell .saving-dot {
display: none;
color: var(--admin-primary);
font-size: .75rem;
margin-left: .3rem;
}
/* Toggle switch */
.toggle-switch {
position: relative; display: inline-block;
width: 36px; height: 20px; cursor: pointer;
}
.toggle-switch input { opacity: 0; width: 0; height: 0; }
.toggle-slider {
position: absolute; inset: 0;
background: var(--color-border); border-radius: 20px;
transition: .2s;
}
.toggle-slider::before {
content: ''; position: absolute;
width: 14px; height: 14px; left: 3px; bottom: 3px;
background: white; border-radius: 50%; transition: .2s;
}
.toggle-switch input:checked + .toggle-slider { background: var(--admin-primary); }
.toggle-switch input:checked + .toggle-slider::before { transform: translateX(16px); }
#csvDrop.drag-active { border-color: var(--admin-primary); background: rgba(255,94,26,.04); }
</style>
<script>
/* ── Import panel mode ───────────────────────────── */
function setMode(radio) {
document.getElementById('importModeField').value = radio.value;
document.getElementById('replaceWarning').style.display = radio.value === 'replace' ? '' : 'none';
document.getElementById('modeSmart').style.borderColor = radio.value === 'smart' ? 'var(--admin-primary)' : 'var(--color-border)';
document.getElementById('modeReplace').style.borderColor = radio.value === 'replace' ? 'var(--color-error)' : 'var(--color-border)';
}
function showFileName(input) {
if (input.files[0]) {
document.getElementById('csvFileName').textContent = input.files[0].name;
}
}
function confirmImport() {
var mode = document.getElementById('importModeField').value;
if (mode === 'replace') {
return confirm('WARNING: Full Replace will DELETE all existing products and reimport from the CSV.\n\nThis cannot be undone. Are you sure?');
}
var file = document.getElementById('csvFile').files[0];
if (!file) { alert('Please select a CSV file.'); return false; }
return confirm('Import ' + file.name + '?\n\nSmart Update will update existing products and add/remove to match the file.');
}
/* ── CSV drag & drop ─────────────────────────────── */
var drop = document.getElementById('csvDrop');
['dragenter','dragover'].forEach(function(e) {
drop.addEventListener(e, function(ev) { ev.preventDefault(); drop.classList.add('drag-active'); });
});
['dragleave','drop'].forEach(function(e) {
drop.addEventListener(e, function(ev) { ev.preventDefault(); drop.classList.remove('drag-active'); });
});
drop.addEventListener('drop', function(ev) {
var f = ev.dataTransfer.files[0];
if (f) {
var dt = new DataTransfer();
dt.items.add(f);
document.getElementById('csvFile').files = dt.files;
document.getElementById('csvFileName').textContent = f.name;
}
});
/* ── Inline cell editing ─────────────────────────── */
document.querySelectorAll('.editable-cell').forEach(function(cell) {
var display = cell.querySelector('.cell-display');
var input = cell.querySelector('.cell-input');
var pid = cell.dataset.id;
var field = cell.dataset.field;
cell.addEventListener('click', function(e) {
if (cell.classList.contains('editing')) return;
cell.classList.add('editing');
input.style.display = 'inline-block';
input.focus();
input.select();
});
function save() {
cell.classList.remove('editing');
var val = input.value;
var fd = new FormData();
fd.append('action', 'inline_edit');
fd.append('product_id', pid);
fd.append('field', field);
fd.append('value', val);
fetch('/admin/import-export.php', { method: 'POST', body: fd })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.ok) {
// Refresh display text
if (field === 'sku') {
display.textContent = val || '—';
} else if (field === 'stock') {
display.textContent = val;
} else {
display.innerHTML = val !== '' ? '$' + parseFloat(val).toFixed(2) : '<span style="color:var(--admin-text-muted)">—</span>';
}
}
});
}
input.addEventListener('blur', save);
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter') { e.preventDefault(); save(); }
if (e.key === 'Escape') { cell.classList.remove('editing'); input.value = input.defaultValue; }
});
});
/* ── Active toggle ───────────────────────────────── */
document.querySelectorAll('.active-toggle').forEach(function(cb) {
cb.addEventListener('change', function() {
var fd = new FormData();
fd.append('action', 'inline_edit');
fd.append('product_id', this.dataset.id);
fd.append('field', 'is_active');
fd.append('value', this.checked ? 1 : 0);
fetch('/admin/import-export.php', { method: 'POST', body: fd });
var row = this.closest('tr');
if (row) row.classList.toggle('row-inactive', !this.checked);
});
});
</script>
<?php require_once __DIR__ . '/includes/footer.php'; ?>
+8
View File
@@ -0,0 +1,8 @@
</main>
</div>
</div>
<script src="/admin/assets/admin.js"></script>
<?php if (isset($extraScripts)) echo $extraScripts; ?>
</body>
</html>
+182
View File
@@ -0,0 +1,182 @@
<?php
/**
* Tom's Java Jive - Admin Header
* Authentication temporarily disabled
*/
require_once __DIR__ . '/../../includes/auth.php';
AdminAuth::require();
$adminUser = AdminAuth::getUser();
$currentPage = basename($_SERVER['PHP_SELF'], '.php');
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= $pageTitle ?? 'Admin' ?> - Tom's Java Jive Admin</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="/admin/assets/admin.css">
<?php if (isset($extraHead)) echo $extraHead; ?>
</head>
<body class="admin-body">
<div class="admin-layout">
<!-- Sidebar -->
<aside class="admin-sidebar">
<div class="sidebar-header">
<a href="/admin/" class="admin-logo">
<img src="/assets/images/logo.svg" alt="Logo" class="logo-img" style="height: 32px;">
<span>Admin</span>
</a>
</div>
<nav class="sidebar-nav">
<a href="/admin/" class="nav-item <?= $currentPage === 'index' ? 'active' : '' ?>">
<i class="fas fa-chart-line"></i> Dashboard
</a>
<div class="nav-group">
<span class="nav-group-title">Sales</span>
<a href="/admin/orders.php" class="nav-item <?= $currentPage === 'orders' ? 'active' : '' ?>">
<i class="fas fa-shopping-cart"></i> Orders
</a>
<a href="/admin/pos.php" class="nav-item <?= $currentPage === 'pos' ? 'active' : '' ?>">
<i class="fas fa-cash-register"></i> POS
</a>
<a href="/admin/analytics.php" class="nav-item <?= $currentPage === 'analytics' ? 'active' : '' ?>">
<i class="fas fa-chart-bar"></i> Analytics
</a>
</div>
<div class="nav-group">
<span class="nav-group-title">Catalog</span>
<a href="/admin/products.php" class="nav-item <?= $currentPage === 'products' ? 'active' : '' ?>">
<i class="fas fa-box"></i> Products
</a>
<a href="/admin/categories.php" class="nav-item <?= $currentPage === 'categories' ? 'active' : '' ?>">
<i class="fas fa-tags"></i> Categories
</a>
<a href="/admin/product-types.php" class="nav-item <?= $currentPage === 'product-types' ? 'active' : '' ?>">
<i class="fas fa-layer-group"></i> Product Types
</a>
<a href="/admin/inventory.php" class="nav-item <?= $currentPage === 'inventory' ? 'active' : '' ?>">
<i class="fas fa-warehouse"></i> Inventory
</a>
<a href="/admin/import-export.php" class="nav-item <?= $currentPage === 'import-export' ? 'active' : '' ?>">
<i class="fas fa-file-csv"></i> Import / Export
</a>
</div>
<div class="nav-group">
<span class="nav-group-title">Customers</span>
<a href="/admin/customers.php" class="nav-item <?= $currentPage === 'customers' ? 'active' : '' ?>">
<i class="fas fa-users"></i> Customers
</a>
<a href="/admin/reviews.php" class="nav-item <?= $currentPage === 'reviews' ? 'active' : '' ?>">
<i class="fas fa-star"></i> Reviews
</a>
</div>
<div class="nav-group">
<span class="nav-group-title">Content</span>
<a href="/admin/splashes.php" class="nav-item <?= $currentPage === 'splashes' ? 'active' : '' ?>">
<i class="fas fa-th-large"></i> Splash Box
</a>
<a href="/admin/about-us.php" class="nav-item <?= $currentPage === 'about-us' ? 'active' : '' ?>">
<i class="fas fa-align-left"></i> About Us Text
</a>
</div>
<div class="nav-group">
<span class="nav-group-title">Marketing</span>
<a href="/admin/gift-cards.php" class="nav-item <?= $currentPage === 'gift-cards' ? 'active' : '' ?>">
<i class="fas fa-gift"></i> Gift Cards
</a>
<a href="/admin/coupons.php" class="nav-item <?= $currentPage === 'coupons' ? 'active' : '' ?>">
<i class="fas fa-ticket-alt"></i> Coupons
</a>
<a href="/admin/campaigns.php" class="nav-item <?= $currentPage === 'campaigns' ? 'active' : '' ?>">
<i class="fas fa-envelope"></i> Email Campaigns
</a>
</div>
<div class="nav-group">
<span class="nav-group-title">Settings</span>
<a href="/admin/settings.php" class="nav-item <?= $currentPage === 'settings' ? 'active' : '' ?>">
<i class="fas fa-cog"></i> Store Settings
</a>
<a href="/admin/integrations.php" class="nav-item <?= $currentPage === 'integrations' ? 'active' : '' ?>">
<i class="fas fa-plug"></i> Integrations
</a>
<a href="/admin/shipping.php" class="nav-item <?= $currentPage === 'shipping' ? 'active' : '' ?>">
<i class="fas fa-truck"></i> Shipping
</a>
<a href="/admin/payments.php" class="nav-item <?= $currentPage === 'payments' ? 'active' : '' ?>">
<i class="fas fa-credit-card"></i> Payments
</a>
<a href="/admin/users.php" class="nav-item <?= $currentPage === 'users' ? 'active' : '' ?>">
<i class="fas fa-user-shield"></i> Admin Users
</a>
</div>
</nav>
<div class="sidebar-footer">
<a href="/" target="_blank" class="nav-item">
<i class="fas fa-external-link-alt"></i> View Store
</a>
</div>
</aside>
<!-- Main Content -->
<div class="admin-main">
<header class="admin-header">
<button class="sidebar-toggle" id="sidebarToggle">
<i class="fas fa-bars"></i>
</button>
<div class="header-search">
<i class="fas fa-search"></i>
<input type="text" placeholder="Search...">
</div>
<div class="header-actions">
<div class="admin-user">
<span><?= htmlspecialchars($adminUser['name']) ?></span>
<a href="/admin/logout.php" class="btn btn-sm">
<i class="fas fa-sign-out-alt"></i>
</a>
</div>
</div>
</header>
<main class="admin-content">
<?php if (hasFlash('success')): ?>
<div style="background:rgba(16,185,129,.12);border:1px solid rgba(16,185,129,.35);color:#10b981;padding:13px 16px;border-radius:8px;margin-bottom:20px;font-size:14px;font-weight:500;display:flex;align-items:center;gap:10px">
<i class="fas fa-check-circle" style="font-size:16px"></i> <?= getFlash('success') ?>
</div>
<?php endif; ?>
<?php if (hasFlash('error')): ?>
<div style="background:rgba(220,38,38,.1);border:1px solid rgba(220,38,38,.3);color:#f87171;padding:13px 16px;border-radius:8px;margin-bottom:20px;font-size:14px;font-weight:500;display:flex;align-items:center;gap:10px">
<i class="fas fa-exclamation-circle" style="font-size:16px"></i> <?= getFlash('error') ?>
</div>
<?php endif; ?>
<?php if (hasFlash('info')): ?>
<div style="background:rgba(59,130,246,.1);border:1px solid rgba(59,130,246,.3);color:#60a5fa;padding:13px 16px;border-radius:8px;margin-bottom:20px;font-size:14px;font-weight:500;display:flex;align-items:center;gap:10px">
<i class="fas fa-info-circle" style="font-size:16px"></i> <?= getFlash('info') ?>
</div>
<?php endif; ?>
<?php if (hasFlash('warning')): ?>
<div style="background:rgba(245,158,11,.1);border:1px solid rgba(245,158,11,.3);color:#f59e0b;padding:13px 16px;border-radius:8px;margin-bottom:20px;font-size:14px;font-weight:500;display:flex;align-items:center;gap:10px">
<i class="fas fa-exclamation-triangle" style="font-size:16px"></i> <?= getFlash('warning') ?>
</div>
<?php endif; ?>
<?php if (hasFlash("success")): ?><div style="background:rgba(16,185,129,.12);border:1px solid rgba(16,185,129,.35);color:#10b981;padding:13px 16px;border-radius:8px;margin-bottom:20px;font-size:14px;font-weight:500;display:flex;align-items:center;gap:8px"><i class="fas fa-check-circle"></i><?= getFlash("success") ?></div><?php endif; ?>
<?php if (hasFlash("error")): ?><div style="background:rgba(220,38,38,.1);border:1px solid rgba(220,38,38,.3);color:#f87171;padding:13px 16px;border-radius:8px;margin-bottom:20px;font-size:14px;font-weight:500;display:flex;align-items:center;gap:8px"><i class="fas fa-exclamation-circle"></i><?= getFlash("error") ?></div><?php endif; ?>
<?php if (hasFlash("info")): ?><div style="background:rgba(59,130,246,.1);border:1px solid rgba(59,130,246,.3);color:#60a5fa;padding:13px 16px;border-radius:8px;margin-bottom:20px;font-size:14px;font-weight:500;display:flex;align-items:center;gap:8px"><i class="fas fa-info-circle"></i><?= getFlash("info") ?></div><?php endif; ?>
+197
View File
@@ -0,0 +1,197 @@
<?php
/**
* Tom's Java Jive - Admin Dashboard
*/
$pageTitle = 'Dashboard';
require_once __DIR__ . '/includes/header.php';
// Get stats
$totalOrders = db()->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"
);
?>
<div class="page-header">
<h1 class="page-title">Dashboard</h1>
<span class="text-muted"><?= date('l, F j, Y') ?></span>
</div>
<!-- Stats Cards -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-card-icon primary">
<i class="fas fa-dollar-sign"></i>
</div>
<div class="stat-card-value"><?= formatCurrency($todayRevenue) ?></div>
<div class="stat-card-label">Today's Revenue</div>
</div>
<div class="stat-card">
<div class="stat-card-icon success">
<i class="fas fa-shopping-cart"></i>
</div>
<div class="stat-card-value"><?= $todayOrders ?></div>
<div class="stat-card-label">Today's Orders</div>
</div>
<div class="stat-card">
<div class="stat-card-icon warning">
<i class="fas fa-users"></i>
</div>
<div class="stat-card-value"><?= $totalCustomers ?></div>
<div class="stat-card-label">Total Customers</div>
</div>
<div class="stat-card">
<div class="stat-card-icon <?= $pendingOrders > 0 ? 'error' : 'primary' ?>">
<i class="fas fa-clock"></i>
</div>
<div class="stat-card-value"><?= $pendingOrders ?></div>
<div class="stat-card-label">Pending Orders</div>
</div>
</div>
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 1.5rem;">
<!-- Recent Orders -->
<div class="admin-card">
<div class="admin-card-header">
<h3 class="admin-card-title">Recent Orders</h3>
<a href="/admin/orders.php" class="btn btn-sm btn-secondary">View All</a>
</div>
<div class="admin-card-body" style="padding: 0;">
<table class="admin-table">
<thead>
<tr>
<th>Order</th>
<th>Customer</th>
<th>Total</th>
<th>Status</th>
<th>Date</th>
</tr>
</thead>
<tbody>
<?php if (empty($recentOrders)): ?>
<tr>
<td colspan="5" class="text-muted" style="text-align: center; padding: 2rem;">
No orders yet
</td>
</tr>
<?php else: ?>
<?php foreach ($recentOrders as $order): ?>
<tr>
<td>
<a href="/admin/order.php?id=<?= $order['order_id'] ?>">
<?= htmlspecialchars($order['order_number']) ?>
</a>
</td>
<td><?= htmlspecialchars($order['customer_name'] ?? $order['customer_email']) ?></td>
<td><?= formatCurrency($order['total']) ?></td>
<td>
<?php
$statusClass = match($order['order_status']) {
'pending' => 'warning',
'confirmed', 'processing' => 'primary',
'shipped', 'delivered' => 'success',
'cancelled', 'refunded' => 'error',
default => 'primary'
};
?>
<span class="badge badge-<?= $statusClass ?>">
<?= ucfirst($order['order_status']) ?>
</span>
</td>
<td class="text-muted"><?= formatDate($order['created_at']) ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<!-- Sidebar Stats -->
<div>
<!-- Quick Stats -->
<div class="admin-card">
<div class="admin-card-header">
<h3 class="admin-card-title">Overview</h3>
</div>
<div class="admin-card-body">
<div style="display: flex; justify-content: space-between; margin-bottom: 1rem;">
<span>Total Revenue</span>
<strong><?= formatCurrency($totalRevenue) ?></strong>
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 1rem;">
<span>Total Orders</span>
<strong><?= $totalOrders ?></strong>
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 1rem;">
<span>Active Products</span>
<strong><?= $totalProducts ?></strong>
</div>
<div style="display: flex; justify-content: space-between;">
<span>Low Stock Items</span>
<strong class="<?= $lowStockProducts > 0 ? 'text-error' : '' ?>"><?= $lowStockProducts ?></strong>
</div>
</div>
</div>
<!-- Low Stock Alert -->
<?php if (!empty($lowStockItems)): ?>
<div class="admin-card">
<div class="admin-card-header">
<h3 class="admin-card-title">
<i class="fas fa-exclamation-triangle text-warning"></i> Low Stock
</h3>
</div>
<div class="admin-card-body">
<?php foreach ($lowStockItems as $item): ?>
<div style="display: flex; justify-content: space-between; margin-bottom: 0.75rem;">
<span><?= htmlspecialchars(truncate($item['name'], 25)) ?></span>
<span class="badge badge-error"><?= $item['stock'] ?> left</span>
</div>
<?php endforeach; ?>
<a href="/admin/inventory.php" class="btn btn-sm btn-secondary btn-block mt-1">
Manage Inventory
</a>
</div>
</div>
<?php endif; ?>
<!-- Quick Actions -->
<div class="admin-card">
<div class="admin-card-header">
<h3 class="admin-card-title">Quick Actions</h3>
</div>
<div class="admin-card-body">
<a href="/admin/product-edit.php" class="btn btn-primary btn-block mb-1">
<i class="fas fa-plus"></i> Add Product
</a>
<a href="/admin/pos.php" class="btn btn-secondary btn-block mb-1">
<i class="fas fa-cash-register"></i> Open POS
</a>
<a href="/admin/orders.php?status=pending" class="btn btn-secondary btn-block">
<i class="fas fa-clock"></i> Pending Orders
</a>
</div>
</div>
</div>
</div>
<?php require_once __DIR__ . '/includes/footer.php'; ?>
+523
View File
@@ -0,0 +1,523 @@
<?php
ob_start();
/**
* Tom's Java Jive - Admin Integrations Settings
*/
$pageTitle = 'Integrations';
$currentPage = 'integrations';
require_once __DIR__ . '/includes/header.php';
// Handle form submission
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$section = $_POST['section'] ?? '';
$settingsMap = [
'sendgrid' => ['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'];
}
?>
<style>
.integration-card {
background: var(--admin-surface);
border-radius: var(--admin-radius);
margin-bottom: 1.5rem;
}
.integration-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--admin-border);
}
.integration-title {
display: flex;
align-items: center;
gap: 1rem;
}
.integration-icon {
width: 48px;
height: 48px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
}
.integration-icon.sendgrid { background: #1A82E2; color: white; }
.integration-icon.twilio { background: #F22F46; color: white; }
.integration-icon.push { background: #8B5CF6; color: white; }
.integration-icon.loyalty { background: #F59E0B; color: white; }
.integration-body {
padding: 1.5rem;
}
.status-badge {
padding: 0.375rem 0.75rem;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
}
.status-badge.configured {
background: rgba(16, 185, 129, 0.1);
color: var(--admin-success);
}
.status-badge.not-configured {
background: rgba(245, 158, 11, 0.1);
color: var(--admin-warning);
}
.status-badge.enabled {
background: rgba(16, 185, 129, 0.1);
color: var(--admin-success);
}
.status-badge.disabled {
background: rgba(239, 68, 68, 0.1);
color: var(--admin-error);
}
.key-input {
font-family: monospace;
font-size: 0.875rem;
}
.test-btn {
margin-top: 0.5rem;
}
.help-text {
font-size: 0.75rem;
color: var(--admin-text-muted);
margin-top: 0.25rem;
}
.help-link {
color: var(--admin-primary);
}
</style>
<div class="page-header">
<h1 class="page-title">Integrations</h1>
<p class="text-muted">Configure third-party service integrations</p>
</div>
<?php if (hasFlash('success')): ?>
<div class="alert alert-success mb-2">
<i class="fas fa-check-circle"></i> <?= getFlash('success') ?>
</div>
<?php endif; ?>
<!-- SendGrid Email -->
<div class="integration-card">
<div class="integration-header">
<div class="integration-title">
<div class="integration-icon sendgrid">
<i class="fas fa-envelope"></i>
</div>
<div>
<h3 style="margin: 0;">SendGrid Email</h3>
<p style="margin: 0.25rem 0 0; color: var(--admin-text-muted); font-size: 0.875rem;">
Send transactional emails (order confirmations, shipping updates, etc.)
</p>
</div>
</div>
<?php
$sgConfigured = !empty($settings['sendgrid_api_key']) && $settings['sendgrid_api_key'] !== 'YOUR_SENDGRID_API_KEY_HERE';
$sgEnabled = ($settings['email_notifications_enabled'] ?? '0') === '1';
?>
<span class="status-badge <?= $sgConfigured && $sgEnabled ? 'enabled' : ($sgConfigured ? 'configured' : 'not-configured') ?>">
<?= $sgConfigured && $sgEnabled ? 'Enabled' : ($sgConfigured ? 'Configured' : 'Not Configured') ?>
</span>
</div>
<div class="integration-body">
<form method="POST">
<input type="hidden" name="section" value="sendgrid">
<div class="form-row">
<div class="form-group" style="flex: 2;">
<label class="form-label">SendGrid API Key</label>
<input type="password" name="sendgrid_api_key" class="form-input key-input"
value="<?= htmlspecialchars($settings['sendgrid_api_key'] ?? '') ?>"
placeholder="SG.xxxxxxxxxxxxxxxxxxxxxxxx">
<p class="help-text">
Get your API key from
<a href="https://app.sendgrid.com/settings/api_keys" target="_blank" class="help-link">SendGrid Dashboard</a>
</p>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">From Email</label>
<input type="email" name="sendgrid_from_email" class="form-input"
value="<?= htmlspecialchars($settings['sendgrid_from_email'] ?? 'noreply@tomsjavajive.com') ?>">
</div>
<div class="form-group">
<label class="form-label">From Name</label>
<input type="text" name="sendgrid_from_name" class="form-input"
value="<?= htmlspecialchars($settings['sendgrid_from_name'] ?? "Tom's Java Jive") ?>">
</div>
</div>
<div class="form-group">
<label class="form-checkbox">
<input type="checkbox" name="email_notifications_enabled" value="1"
<?= ($settings['email_notifications_enabled'] ?? '0') === '1' ? 'checked' : '' ?>>
Enable email notifications
</label>
</div>
<div style="display: flex; gap: 0.5rem;">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Save Settings
</button>
<button type="button" class="btn btn-secondary" onclick="testSendGrid()">
<i class="fas fa-paper-plane"></i> Send Test Email
</button>
</div>
</form>
</div>
</div>
<!-- Twilio SMS -->
<div class="integration-card">
<div class="integration-header">
<div class="integration-title">
<div class="integration-icon twilio">
<i class="fas fa-sms"></i>
</div>
<div>
<h3 style="margin: 0;">Twilio SMS</h3>
<p style="margin: 0.25rem 0 0; color: var(--admin-text-muted); font-size: 0.875rem;">
Send SMS notifications for orders, shipping updates, and promotions
</p>
</div>
</div>
<?php
$twConfigured = !empty($settings['twilio_account_sid']) && !empty($settings['twilio_auth_token']);
$twEnabled = ($settings['sms_notifications_enabled'] ?? '0') === '1';
?>
<span class="status-badge <?= $twConfigured && $twEnabled ? 'enabled' : ($twConfigured ? 'configured' : 'not-configured') ?>">
<?= $twConfigured && $twEnabled ? 'Enabled' : ($twConfigured ? 'Configured' : 'Not Configured') ?>
</span>
</div>
<div class="integration-body">
<form method="POST">
<input type="hidden" name="section" value="twilio">
<div class="form-row">
<div class="form-group">
<label class="form-label">Account SID</label>
<input type="text" name="twilio_account_sid" class="form-input key-input"
value="<?= htmlspecialchars($settings['twilio_account_sid'] ?? '') ?>"
placeholder="ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx">
</div>
<div class="form-group">
<label class="form-label">Auth Token</label>
<input type="password" name="twilio_auth_token" class="form-input key-input"
value="<?= htmlspecialchars($settings['twilio_auth_token'] ?? '') ?>"
placeholder="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx">
</div>
</div>
<div class="form-group">
<label class="form-label">Twilio Phone Number</label>
<input type="text" name="twilio_phone_number" class="form-input"
value="<?= htmlspecialchars($settings['twilio_phone_number'] ?? '') ?>"
placeholder="+1234567890">
<p class="help-text">
Get your credentials from
<a href="https://console.twilio.com/" target="_blank" class="help-link">Twilio Console</a>
</p>
</div>
<div class="form-group">
<label class="form-checkbox">
<input type="checkbox" name="sms_notifications_enabled" value="1"
<?= ($settings['sms_notifications_enabled'] ?? '0') === '1' ? 'checked' : '' ?>>
Enable SMS notifications
</label>
</div>
<div style="display: flex; gap: 0.5rem;">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Save Settings
</button>
<button type="button" class="btn btn-secondary" onclick="testTwilio()">
<i class="fas fa-sms"></i> Send Test SMS
</button>
</div>
</form>
</div>
</div>
<!-- Push Notifications -->
<div class="integration-card">
<div class="integration-header">
<div class="integration-title">
<div class="integration-icon push">
<i class="fas fa-bell"></i>
</div>
<div>
<h3 style="margin: 0;">Push Notifications</h3>
<p style="margin: 0.25rem 0 0; color: var(--admin-text-muted); font-size: 0.875rem;">
Web push notifications for order updates and promotions
</p>
</div>
</div>
<?php
$pushConfigured = !empty($settings['vapid_public_key']) && !empty($settings['vapid_private_key']);
$pushEnabled = ($settings['push_notifications_enabled'] ?? '0') === '1';
?>
<span class="status-badge <?= $pushConfigured && $pushEnabled ? 'enabled' : ($pushConfigured ? 'configured' : 'not-configured') ?>">
<?= $pushConfigured && $pushEnabled ? 'Enabled' : ($pushConfigured ? 'Configured' : 'Not Configured') ?>
</span>
</div>
<div class="integration-body">
<form method="POST">
<input type="hidden" name="section" value="push">
<div class="form-group">
<label class="form-label">VAPID Public Key</label>
<input type="text" name="vapid_public_key" class="form-input key-input"
value="<?= htmlspecialchars($settings['vapid_public_key'] ?? '') ?>"
placeholder="BNxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx">
</div>
<div class="form-group">
<label class="form-label">VAPID Private Key</label>
<input type="password" name="vapid_private_key" class="form-input key-input"
value="<?= htmlspecialchars($settings['vapid_private_key'] ?? '') ?>"
placeholder="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx">
<p class="help-text">
Generate VAPID keys at
<a href="https://web-push-codelab.glitch.me/" target="_blank" class="help-link">Web Push Codelab</a>
</p>
</div>
<div class="form-group">
<label class="form-checkbox">
<input type="checkbox" name="push_notifications_enabled" value="1"
<?= ($settings['push_notifications_enabled'] ?? '0') === '1' ? 'checked' : '' ?>>
Enable push notifications
</label>
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Save Settings
</button>
</form>
</div>
</div>
<!-- Loyalty Program -->
<div class="integration-card">
<div class="integration-header">
<div class="integration-title">
<div class="integration-icon loyalty">
<i class="fas fa-crown"></i>
</div>
<div>
<h3 style="margin: 0;">Loyalty Program</h3>
<p style="margin: 0.25rem 0 0; color: var(--admin-text-muted); font-size: 0.875rem;">
Reward customers with points and tiers (Bronze, Silver, Gold, Platinum)
</p>
</div>
</div>
<?php $loyaltyEnabled = ($settings['loyalty_enabled'] ?? '1') === '1'; ?>
<span class="status-badge <?= $loyaltyEnabled ? 'enabled' : 'disabled' ?>">
<?= $loyaltyEnabled ? 'Enabled' : 'Disabled' ?>
</span>
</div>
<div class="integration-body">
<form method="POST">
<input type="hidden" name="section" value="loyalty">
<div class="form-group">
<label class="form-checkbox">
<input type="checkbox" name="loyalty_enabled" value="1"
<?= $loyaltyEnabled ? 'checked' : '' ?>>
Enable loyalty program
</label>
</div>
<div style="background: var(--admin-bg); padding: 1.5rem; border-radius: var(--admin-radius); margin: 1rem 0;">
<h4 style="margin: 0 0 1rem;">Tier Structure</h4>
<table class="table" style="margin: 0;">
<thead>
<tr>
<th>Tier</th>
<th>Min Points</th>
<th>Multiplier</th>
<th>Key Benefits</th>
</tr>
</thead>
<tbody>
<tr>
<td><span style="color: #CD7F32;"><i class="fas fa-coffee"></i></span> Bronze Bean</td>
<td>0</td>
<td>1x</td>
<td>1 point/$1, Birthday reward</td>
</tr>
<tr>
<td><span style="color: #C0C0C0;"><i class="fas fa-mug-hot"></i></span> Silver Roast</td>
<td>500</td>
<td>1.25x</td>
<td>Free shipping $25+, Double points weekends</td>
</tr>
<tr>
<td><span style="color: #FFD700;"><i class="fas fa-crown"></i></span> Gold Blend</td>
<td>1,500</td>
<td>1.5x</td>
<td>Free shipping all orders, Priority support</td>
</tr>
<tr>
<td><span style="color: #E5E4E2;"><i class="fas fa-gem"></i></span> Platinum Reserve</td>
<td>5,000</td>
<td>2x</td>
<td>Express shipping, VIP events, Account manager</td>
</tr>
</tbody>
</table>
<p class="text-muted" style="margin: 1rem 0 0; font-size: 0.875rem;">
100 points = $1 credit • Points earned on every purchase
</p>
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Save Settings
</button>
</form>
</div>
</div>
<!-- Test Modal -->
<div class="modal-overlay" id="testModal">
<div class="modal">
<div class="modal-header">
<h3 class="modal-title" id="testModalTitle">Send Test</h3>
<button type="button" class="modal-close" onclick="Modal.close('testModal')">&times;</button>
</div>
<form id="testForm">
<div class="modal-body">
<div class="form-group">
<label class="form-label" id="testInputLabel">Recipient</label>
<input type="text" id="testRecipient" class="form-input" required>
</div>
<div id="testResult" style="display: none; padding: 1rem; border-radius: var(--admin-radius); margin-top: 1rem;"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="Modal.close('testModal')">Cancel</button>
<button type="submit" class="btn btn-primary" id="testSubmitBtn">
<i class="fas fa-paper-plane"></i> Send
</button>
</div>
</form>
</div>
</div>
<script>
let testType = '';
function testSendGrid() {
testType = 'email';
document.getElementById('testModalTitle').textContent = 'Send Test Email';
document.getElementById('testInputLabel').textContent = 'Recipient Email';
document.getElementById('testRecipient').type = 'email';
document.getElementById('testRecipient').placeholder = 'test@example.com';
document.getElementById('testResult').style.display = 'none';
Modal.open('testModal');
}
function testTwilio() {
testType = 'sms';
document.getElementById('testModalTitle').textContent = 'Send Test SMS';
document.getElementById('testInputLabel').textContent = 'Phone Number';
document.getElementById('testRecipient').type = 'tel';
document.getElementById('testRecipient').placeholder = '+1234567890';
document.getElementById('testResult').style.display = 'none';
Modal.open('testModal');
}
document.getElementById('testForm').addEventListener('submit', async function(e) {
e.preventDefault();
const recipient = document.getElementById('testRecipient').value;
const resultDiv = document.getElementById('testResult');
const submitBtn = document.getElementById('testSubmitBtn');
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Sending...';
try {
const response = await fetch('/api/test-notification.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: testType, recipient })
});
const data = await response.json();
resultDiv.style.display = 'block';
if (data.success) {
resultDiv.style.background = 'rgba(16, 185, 129, 0.1)';
resultDiv.innerHTML = '<i class="fas fa-check-circle" style="color: var(--admin-success);"></i> ' + (data.message || 'Test sent successfully!');
} else {
resultDiv.style.background = 'rgba(239, 68, 68, 0.1)';
resultDiv.innerHTML = '<i class="fas fa-times-circle" style="color: var(--admin-error);"></i> ' + (data.error || 'Failed to send test');
}
} catch (err) {
resultDiv.style.display = 'block';
resultDiv.style.background = 'rgba(239, 68, 68, 0.1)';
resultDiv.innerHTML = '<i class="fas fa-times-circle" style="color: var(--admin-error);"></i> Error: ' + err.message;
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = '<i class="fas fa-paper-plane"></i> Send';
}
});
</script>
<?php require_once __DIR__ . '/includes/footer.php'; ?>
+337
View File
@@ -0,0 +1,337 @@
<?php
ob_start();
/**
* Tom's Java Jive - Admin Inventory Management
*/
$pageTitle = 'Inventory';
require_once __DIR__ . '/includes/header.php';
// Handle actions
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
if ($action === 'adjust' && !empty($_POST['product_id'])) {
$adjustment = intval($_POST['adjustment'] ?? 0);
$reason = trim($_POST['reason'] ?? '');
if ($adjustment != 0) {
$product = db()->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;
?>
<div class="page-header">
<h1 class="page-title">Inventory Management</h1>
<button class="btn btn-primary" onclick="document.getElementById('bulkForm').style.display = document.getElementById('bulkForm').style.display === 'none' ? 'block' : 'none'">
<i class="fas fa-boxes"></i> Bulk Adjust
</button>
</div>
<?php if (hasFlash('success')): ?>
<div class="alert alert-success"><i class="fas fa-check-circle"></i> <?= getFlash('success') ?></div>
<?php endif; ?>
<!-- Stats -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-card-icon primary"><i class="fas fa-boxes"></i></div>
<div>
<div class="stat-card-value"><?= number_format($totalStock) ?></div>
<div class="stat-card-label">Total Units</div>
</div>
</div>
<div class="stat-card">
<div class="stat-card-icon success"><i class="fas fa-dollar-sign"></i></div>
<div>
<div class="stat-card-value"><?= formatCurrency($inventoryValue) ?></div>
<div class="stat-card-label">Inventory Value</div>
</div>
</div>
<div class="stat-card">
<div class="stat-card-icon warning"><i class="fas fa-exclamation-triangle"></i></div>
<div>
<div class="stat-card-value"><?= $lowStockCount ?></div>
<div class="stat-card-label">Low Stock</div>
</div>
</div>
<div class="stat-card">
<div class="stat-card-icon error"><i class="fas fa-times-circle"></i></div>
<div>
<div class="stat-card-value"><?= $outOfStockCount ?></div>
<div class="stat-card-label">Out of Stock</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="admin-card">
<div class="admin-card-body">
<form method="GET" style="display: flex; gap: 1rem; flex-wrap: wrap; align-items: end;">
<div class="form-group mb-0" style="flex: 1; min-width: 200px;">
<label class="form-label">Search</label>
<input type="text" name="search" class="form-input" placeholder="Name, SKU, barcode..." value="<?= htmlspecialchars($search) ?>">
</div>
<div class="form-group mb-0">
<label class="form-label">Category</label>
<select name="category" class="form-select">
<option value="">All Categories</option>
<?php foreach ($categories as $cat): ?>
<option value="<?= htmlspecialchars($cat['category']) ?>" <?= $category === $cat['category'] ? 'selected' : '' ?>>
<?= htmlspecialchars(ucfirst($cat['category'])) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group mb-0">
<label class="form-label">Stock Status</label>
<select name="filter" class="form-select">
<option value="">All</option>
<option value="in" <?= $filter === 'in' ? 'selected' : '' ?>>In Stock</option>
<option value="low" <?= $filter === 'low' ? 'selected' : '' ?>>Low Stock</option>
<option value="out" <?= $filter === 'out' ? 'selected' : '' ?>>Out of Stock</option>
</select>
</div>
<button type="submit" class="btn btn-secondary"><i class="fas fa-filter"></i> Filter</button>
<?php if ($filter || $search || $category): ?>
<a href="/admin/inventory.php" class="btn btn-secondary">Clear</a>
<?php endif; ?>
</form>
</div>
</div>
<!-- Bulk Adjustment Form (hidden by default) -->
<form method="POST" id="bulkForm" style="display: none;">
<input type="hidden" name="action" value="bulk_adjust">
<div class="admin-card">
<div class="admin-card-header">
<h3 class="admin-card-title">Bulk Stock Adjustment</h3>
</div>
<div class="admin-card-body">
<p class="text-muted mb-1">Enter adjustment values for each product (positive to add, negative to subtract). Leave blank or 0 to skip.</p>
<button type="submit" class="btn btn-primary mb-1">Apply All Adjustments</button>
</div>
</div>
</form>
<!-- Inventory Table -->
<div class="admin-card">
<div class="admin-card-header">
<span><?= count($products) ?> products</span>
</div>
<div class="admin-card-body" style="padding: 0;">
<table class="admin-table">
<thead>
<tr>
<th>Product</th>
<th>SKU / Barcode</th>
<th>Category</th>
<th>Stock</th>
<th>Threshold</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($products)): ?>
<tr><td colspan="7" class="text-muted" style="text-align: center; padding: 2rem;">No products found</td></tr>
<?php else: ?>
<?php foreach ($products as $product): ?>
<tr>
<td>
<strong><?= htmlspecialchars($product['name']) ?></strong>
<?php if (!$product['is_active']): ?>
<span class="badge badge-error">Inactive</span>
<?php endif; ?>
</td>
<td>
<?php if ($product['sku']): ?>
<code><?= htmlspecialchars($product['sku']) ?></code>
<?php endif; ?>
<?php if ($product['barcode']): ?>
<br><small class="text-muted"><?= htmlspecialchars($product['barcode']) ?></small>
<?php endif; ?>
<?php if (!$product['sku'] && !$product['barcode']): ?>
<span class="text-muted">-</span>
<?php endif; ?>
</td>
<td><?= htmlspecialchars($product['category'] ?? '-') ?></td>
<td>
<span style="font-size: 1.5rem; font-weight: 700; color: <?= $product['stock'] <= 0 ? 'var(--admin-error)' : ($product['stock'] <= $product['low_stock_threshold'] ? 'var(--admin-warning)' : 'var(--admin-success)') ?>;">
<?= $product['stock'] ?>
</span>
<input type="number" name="adjustments[<?= $product['product_id'] ?>]" form="bulkForm"
class="form-input" style="width: 80px; margin-left: 0.5rem; display: none;" placeholder="+/-">
</td>
<td>
<span class="text-muted"><?= $product['low_stock_threshold'] ?></span>
</td>
<td>
<?php if ($product['stock'] <= 0): ?>
<span class="badge badge-error">Out of Stock</span>
<?php elseif ($product['stock'] <= $product['low_stock_threshold']): ?>
<span class="badge badge-warning">Low Stock</span>
<?php else: ?>
<span class="badge badge-success">In Stock</span>
<?php endif; ?>
</td>
<td>
<button class="btn btn-sm btn-secondary" onclick="openAdjustModal('<?= $product['product_id'] ?>', '<?= htmlspecialchars(addslashes($product['name'])) ?>', <?= $product['stock'] ?>, <?= $product['low_stock_threshold'] ?>)" title="Adjust Stock">
<i class="fas fa-edit"></i>
</button>
<a href="/admin/product-edit.php?id=<?= $product['product_id'] ?>" class="btn btn-sm btn-secondary" title="Edit Product">
<i class="fas fa-cog"></i>
</a>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<!-- Adjust Modal -->
<div class="modal-overlay" id="adjustModal">
<div class="modal">
<div class="modal-header">
<h3 class="modal-title">Adjust Stock</h3>
<button type="button" class="modal-close" onclick="Modal.close('adjustModal')">&times;</button>
</div>
<form method="POST">
<div class="modal-body">
<input type="hidden" name="action" value="adjust">
<input type="hidden" name="product_id" id="adjustProductId">
<p><strong id="adjustProductName"></strong></p>
<p>Current Stock: <strong id="adjustCurrentStock"></strong></p>
<div class="form-group">
<label class="form-label">Stock Adjustment</label>
<input type="number" name="adjustment" id="adjustmentInput" class="form-input" required placeholder="e.g., 10 or -5">
<small class="text-muted">Positive to add, negative to subtract</small>
</div>
<div class="form-group">
<label class="form-label">Reason (optional)</label>
<input type="text" name="reason" class="form-input" placeholder="e.g., Received shipment, Damaged goods">
</div>
<hr>
<div class="form-group mb-0">
<label class="form-label">Low Stock Threshold</label>
<input type="number" name="low_stock_threshold" id="adjustThreshold" class="form-input" min="0">
<small class="text-muted">Alert when stock falls to this level</small>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="Modal.close('adjustModal')">Cancel</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>
</div>
</div>
<script>
function openAdjustModal(productId, name, stock, threshold) {
document.getElementById('adjustProductId').value = productId;
document.getElementById('adjustProductName').textContent = name;
document.getElementById('adjustCurrentStock').textContent = stock;
document.getElementById('adjustThreshold').value = threshold;
document.getElementById('adjustmentInput').value = '';
Modal.open('adjustModal');
}
// Toggle bulk adjustment inputs
document.getElementById('bulkForm').addEventListener('toggle', function() {
document.querySelectorAll('input[name^="adjustments"]').forEach(input => {
input.style.display = this.style.display === 'none' ? 'none' : 'inline-block';
});
});
</script>
<?php require_once __DIR__ . '/includes/footer.php'; ?>
+68
View File
@@ -0,0 +1,68 @@
<?php
/**
* Tom's Java Jive - Admin Login
*/
require_once __DIR__ . '/../includes/auth.php';
if (AdminAuth::isLoggedIn()) {
header('Location: /admin/');
exit;
}
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$email = trim($_POST['email'] ?? '');
$password = trim($_POST['password'] ?? '');
if (AdminAuth::login($email, $password)) {
$redirect = $_SESSION['admin_redirect'] ?? '/admin/';
unset($_SESSION['admin_redirect']);
header('Location: ' . $redirect);
exit;
}
$error = 'Invalid email or password.';
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Login — Tom's Java Jive</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{background:#0f0f0f;font-family:Inter,sans-serif;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px;background-image:radial-gradient(circle at 30% 50%,rgba(255,94,26,.06),transparent 50%)}
.box{background:#1a1a1a;border:1px solid #2a2a2a;border-radius:16px;padding:44px;width:100%;max-width:420px;box-shadow:0 24px 60px rgba(0,0,0,.5)}
.logo{text-align:center;margin-bottom:32px}
.logo h1{font-size:22px;font-weight:700;color:#FF5E1A;margin-bottom:4px}
.logo p{font-size:13px;color:#555;letter-spacing:.5px;text-transform:uppercase}
label{display:block;font-size:12px;font-weight:600;color:#777;text-transform:uppercase;letter-spacing:.5px;margin-bottom:7px}
input{width:100%;background:#111;border:1.5px solid #2a2a2a;border-radius:8px;padding:13px 15px;color:#fff;font-family:Inter,sans-serif;font-size:15px;outline:none;margin-bottom:18px;transition:border-color .2s}
input:focus{border-color:#FF5E1A}
.btn{width:100%;padding:14px;border:none;border-radius:8px;background:#FF5E1A;color:#fff;font-family:Inter,sans-serif;font-weight:600;font-size:15px;cursor:pointer;transition:background .2s;margin-top:4px}
.btn:hover{background:#e54d0f}
.error{background:rgba(220,38,38,.1);border:1px solid rgba(220,38,38,.3);color:#f87171;padding:12px 15px;border-radius:8px;font-size:14px;margin-bottom:20px;display:flex;align-items:center;gap:8px}
.back{display:block;text-align:center;margin-top:20px;color:#555;font-size:13px;text-decoration:none;transition:color .2s}
.back:hover{color:#FF5E1A}
</style>
</head>
<body>
<div class="box">
<div class="logo">
<h1>☕ Tom's Java Jive</h1>
<p>Admin Panel</p>
</div>
<?php if ($error): ?>
<div class="error">⚠ <?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<form method="POST">
<label>Email Address</label>
<input type="email" name="email" placeholder="admin@tomsjavajive.com" required autocomplete="email">
<label>Password</label>
<input type="password" name="password" placeholder="••••••••" required autocomplete="current-password">
<button type="submit" class="btn">Sign In to Admin</button>
</form>
<a href="/" class="back">← Back to Store</a>
</div>
</body>
</html>
+10
View File
@@ -0,0 +1,10 @@
<?php
/**
* Tom's Java Jive - Admin Logout
*/
require_once __DIR__ . '/../includes/auth.php';
AdminAuth::logout();
session_unset();
session_destroy();
header('Location: /admin/login.php');
exit;
+299
View File
@@ -0,0 +1,299 @@
<?php
/**
* Tom's Java Jive - Admin Order Detail
*/
$pageTitle = 'Order Details';
require_once __DIR__ . '/includes/header.php';
$orderId = $_GET['id'] ?? '';
if (empty($orderId)) {
header('Location: /admin/orders.php');
exit;
}
$order = db()->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'];
?>
<div class="page-header">
<div style="display: flex; align-items: center; gap: 1rem;">
<a href="/admin/orders.php" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i>
</a>
<h1 class="page-title">Order #<?= htmlspecialchars($order['order_number']) ?></h1>
<?php if ($order['is_pos_order']): ?>
<span class="badge badge-primary">POS Order</span>
<?php endif; ?>
</div>
<div style="display: flex; gap: 0.5rem;">
<button class="btn btn-secondary" onclick="window.print()">
<i class="fas fa-print"></i> Print
</button>
</div>
</div>
<?php if (hasFlash('success')): ?>
<div class="alert alert-success"><i class="fas fa-check-circle"></i> <?= getFlash('success') ?></div>
<?php endif; ?>
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 1.5rem;">
<!-- Main Column -->
<div>
<!-- Order Items -->
<div class="admin-card">
<div class="admin-card-header">
<h3 class="admin-card-title">Order Items</h3>
</div>
<div class="admin-card-body" style="padding: 0;">
<table class="admin-table">
<thead>
<tr>
<th>Product</th>
<th>Price</th>
<th>Qty</th>
<th style="text-align: right;">Total</th>
</tr>
</thead>
<tbody>
<?php foreach ($items as $item): ?>
<tr>
<td>
<?php if (isset($item['product_id'])): ?>
<a href="/admin/product-edit.php?id=<?= $item['product_id'] ?>">
<?= htmlspecialchars($item['name']) ?>
</a>
<?php else: ?>
<?= htmlspecialchars($item['name']) ?>
<?php endif; ?>
</td>
<td><?= formatCurrency($item['price']) ?></td>
<td><?= $item['quantity'] ?></td>
<td style="text-align: right;"><?= formatCurrency($item['total']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot>
<tr>
<td colspan="3" style="text-align: right;">Subtotal</td>
<td style="text-align: right;"><?= formatCurrency($order['subtotal']) ?></td>
</tr>
<?php if ($order['shipping_cost'] > 0): ?>
<tr>
<td colspan="3" style="text-align: right;">Shipping</td>
<td style="text-align: right;"><?= formatCurrency($order['shipping_cost']) ?></td>
</tr>
<?php endif; ?>
<?php if (($order['tax'] ?? 0) > 0): ?>
<tr>
<td colspan="3" style="text-align: right;">Tax</td>
<td style="text-align: right;"><?= formatCurrency($order['tax']) ?></td>
</tr>
<?php endif; ?>
<?php if (($order['discount'] ?? 0) > 0): ?>
<tr>
<td colspan="3" style="text-align: right;">Discount</td>
<td style="text-align: right; color: var(--admin-success);">-<?= formatCurrency($order['discount']) ?></td>
</tr>
<?php endif; ?>
<tr style="font-size: 1.125rem; font-weight: 600;">
<td colspan="3" style="text-align: right;">Total</td>
<td style="text-align: right;"><?= formatCurrency($order['total']) ?></td>
</tr>
</tfoot>
</table>
</div>
</div>
<!-- Notes -->
<div class="admin-card">
<div class="admin-card-header">
<h3 class="admin-card-title">Order Notes</h3>
</div>
<div class="admin-card-body">
<?php if ($order['notes']): ?>
<pre style="white-space: pre-wrap; font-family: inherit; margin-bottom: 1rem; padding: 1rem; background: var(--admin-bg); border-radius: var(--admin-radius);"><?= htmlspecialchars($order['notes']) ?></pre>
<?php else: ?>
<p class="text-muted" style="margin-bottom: 1rem;">No notes yet.</p>
<?php endif; ?>
<form method="POST">
<input type="hidden" name="action" value="add_note">
<div class="form-group mb-0">
<div style="display: flex; gap: 0.5rem;">
<input type="text" name="note" class="form-input" placeholder="Add a note...">
<button type="submit" class="btn btn-secondary">Add Note</button>
</div>
</div>
</form>
</div>
</div>
</div>
<!-- Sidebar -->
<div>
<!-- Status -->
<div class="admin-card">
<div class="admin-card-header">
<h3 class="admin-card-title">Order Status</h3>
</div>
<div class="admin-card-body">
<form method="POST">
<input type="hidden" name="action" value="update_status">
<div class="form-group">
<label class="form-label">Status</label>
<select name="status" class="form-select">
<?php foreach ($statuses as $s): ?>
<option value="<?= $s ?>" <?= $order['order_status'] === $s ? 'selected' : '' ?>>
<?= ucfirst($s) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">Tracking Number</label>
<input type="text" name="tracking_number" class="form-input" value="<?= htmlspecialchars($order['tracking_number'] ?? '') ?>">
</div>
<button type="submit" class="btn btn-primary btn-block">Update Status</button>
</form>
</div>
</div>
<!-- Customer -->
<div class="admin-card">
<div class="admin-card-header">
<h3 class="admin-card-title">Customer</h3>
</div>
<div class="admin-card-body">
<p><strong><?= htmlspecialchars($order['customer_name']) ?></strong></p>
<p><?= htmlspecialchars($order['customer_email']) ?></p>
<?php if ($order['customer_phone']): ?>
<p><?= htmlspecialchars($order['customer_phone']) ?></p>
<?php endif; ?>
<?php if ($order['customer_id']): ?>
<a href="/admin/customers.php?id=<?= $order['customer_id'] ?>" class="btn btn-sm btn-secondary mt-1">
View Customer
</a>
<?php endif; ?>
</div>
</div>
<!-- Shipping Address -->
<?php if (!empty($shippingAddress) && ($shippingAddress['type'] ?? '') !== 'pickup'): ?>
<div class="admin-card">
<div class="admin-card-header">
<h3 class="admin-card-title">Shipping Address</h3>
</div>
<div class="admin-card-body">
<p>
<?= htmlspecialchars($shippingAddress['address'] ?? '') ?><br>
<?= htmlspecialchars($shippingAddress['city'] ?? '') ?>,
<?= htmlspecialchars($shippingAddress['state'] ?? '') ?>
<?= htmlspecialchars($shippingAddress['zip'] ?? '') ?>
</p>
</div>
</div>
<?php endif; ?>
<!-- Payment -->
<div class="admin-card">
<div class="admin-card-header">
<h3 class="admin-card-title">Payment</h3>
</div>
<div class="admin-card-body">
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;">
<span>Method</span>
<span><?= ucfirst($order['payment_method'] ?? 'N/A') ?></span>
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;">
<span>Status</span>
<?php
$paymentClass = match($order['payment_status']) {
'paid' => 'success',
'failed' => 'error',
'refunded' => 'warning',
default => 'primary'
};
?>
<span class="badge badge-<?= $paymentClass ?>"><?= ucfirst($order['payment_status']) ?></span>
</div>
<?php if ($order['stripe_payment_intent']): ?>
<div style="display: flex; justify-content: space-between;">
<span>Stripe ID</span>
<span class="text-muted" style="font-size: 0.8rem;"><?= htmlspecialchars(substr($order['stripe_payment_intent'], 0, 20)) ?>...</span>
</div>
<?php endif; ?>
</div>
</div>
<!-- Timeline -->
<div class="admin-card">
<div class="admin-card-header">
<h3 class="admin-card-title">Timeline</h3>
</div>
<div class="admin-card-body">
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;">
<span class="text-muted">Created</span>
<span><?= formatDateTime($order['created_at']) ?></span>
</div>
<?php if ($order['updated_at'] && $order['updated_at'] !== $order['created_at']): ?>
<div style="display: flex; justify-content: space-between;">
<span class="text-muted">Updated</span>
<span><?= formatDateTime($order['updated_at']) ?></span>
</div>
<?php endif; ?>
</div>
</div>
</div>
</div>
<?php require_once __DIR__ . '/includes/footer.php'; ?>
+279
View File
@@ -0,0 +1,279 @@
<?php
ob_start();
/**
* Tom's Java Jive - Admin Orders Page
*/
$pageTitle = 'Orders';
require_once __DIR__ . '/includes/header.php';
// Handle status update
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
$action = $_POST['action'];
$orderId = $_POST['order_id'] ?? '';
if ($action === 'update_status' && $orderId) {
$status = $_POST['status'] ?? '';
$trackingNumber = $_POST['tracking_number'] ?? null;
$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/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'];
?>
<div class="page-header">
<h1 class="page-title">Orders</h1>
<a href="/admin/pos.php" class="btn btn-primary">
<i class="fas fa-plus"></i> New Order (POS)
</a>
</div>
<?php if (hasFlash('success')): ?>
<div class="alert alert-success">
<i class="fas fa-check-circle"></i>
<?= getFlash('success') ?>
</div>
<?php endif; ?>
<!-- Filters -->
<div class="admin-card">
<div class="admin-card-body">
<form method="GET" style="display: flex; gap: 1rem; flex-wrap: wrap; align-items: end;">
<div class="form-group mb-0" style="flex: 1; min-width: 200px;">
<label class="form-label">Search</label>
<input type="text" name="search" class="form-input" placeholder="Order #, name, email..."
value="<?= htmlspecialchars($search) ?>">
</div>
<div class="form-group mb-0">
<label class="form-label">Status</label>
<select name="status" class="form-select">
<option value="">All Statuses</option>
<?php foreach ($statuses as $s): ?>
<option value="<?= $s ?>" <?= $status === $s ? 'selected' : '' ?>>
<?= ucfirst($s) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group mb-0">
<label class="form-label">From Date</label>
<input type="date" name="date_from" class="form-input" value="<?= $dateFrom ?>">
</div>
<div class="form-group mb-0">
<label class="form-label">To Date</label>
<input type="date" name="date_to" class="form-input" value="<?= $dateTo ?>">
</div>
<button type="submit" class="btn btn-secondary">
<i class="fas fa-filter"></i> Filter
</button>
<?php if ($status || $search || $dateFrom || $dateTo): ?>
<a href="/admin/orders.php" class="btn btn-secondary">Clear</a>
<?php endif; ?>
</form>
</div>
</div>
<!-- Orders Table -->
<div class="admin-card">
<div class="admin-card-header">
<span><?= $totalOrders ?> orders found</span>
</div>
<div class="admin-card-body" style="padding: 0;">
<table class="admin-table">
<thead>
<tr>
<th>Order</th>
<th>Customer</th>
<th>Items</th>
<th>Total</th>
<th>Payment</th>
<th>Status</th>
<th>Date</th>
<th style="width: 150px;">Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($orders)): ?>
<tr>
<td colspan="8" class="text-muted" style="text-align: center; padding: 2rem;">
No orders found
</td>
</tr>
<?php else: ?>
<?php foreach ($orders as $order):
$items = json_decode($order['items'], true) ?? [];
$itemCount = array_sum(array_column($items, 'quantity'));
?>
<tr>
<td>
<strong><?= htmlspecialchars($order['order_number']) ?></strong>
<?php if ($order['is_pos_order']): ?>
<span class="badge badge-primary">POS</span>
<?php endif; ?>
</td>
<td>
<div><?= htmlspecialchars($order['customer_name'] ?? 'Guest') ?></div>
<small class="text-muted"><?= htmlspecialchars($order['customer_email']) ?></small>
</td>
<td><?= $itemCount ?> item<?= $itemCount !== 1 ? 's' : '' ?></td>
<td><strong><?= formatCurrency($order['total']) ?></strong></td>
<td>
<?php
$paymentClass = match($order['payment_status']) {
'paid' => 'success',
'failed' => 'error',
'refunded' => 'warning',
default => 'primary'
};
?>
<span class="badge badge-<?= $paymentClass ?>">
<?= ucfirst($order['payment_status']) ?>
</span>
</td>
<td>
<?php
$statusClass = match($order['order_status']) {
'pending' => 'warning',
'confirmed', 'processing' => 'primary',
'shipped', 'delivered' => 'success',
'cancelled', 'refunded' => 'error',
default => 'primary'
};
?>
<span class="badge badge-<?= $statusClass ?>">
<?= ucfirst($order['order_status']) ?>
</span>
</td>
<td class="text-muted"><?= formatDate($order['created_at']) ?></td>
<td>
<a href="/admin/order.php?id=<?= $order['order_id'] ?>"
class="btn btn-sm btn-secondary" title="View">
<i class="fas fa-eye"></i>
</a>
<button type="button" class="btn btn-sm btn-secondary"
onclick="openStatusModal('<?= $order['order_id'] ?>', '<?= $order['order_status'] ?>')"
title="Update Status">
<i class="fas fa-edit"></i>
</button>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<!-- Pagination -->
<?php if ($pagination['total_pages'] > 1): ?>
<div style="display: flex; justify-content: center; margin-top: 1rem;">
<?= renderPagination($pagination, '/admin/orders.php?status=' . urlencode($status) . '&search=' . urlencode($search)) ?>
</div>
<?php endif; ?>
<!-- Status Update Modal -->
<div class="modal-overlay" id="statusModal">
<div class="modal">
<div class="modal-header">
<h3 class="modal-title">Update Order Status</h3>
<button type="button" class="modal-close" onclick="Modal.close('statusModal')">&times;</button>
</div>
<form method="POST" action="">
<div class="modal-body">
<input type="hidden" name="action" value="update_status">
<input type="hidden" name="order_id" id="modalOrderId">
<div class="form-group">
<label class="form-label">Status</label>
<select name="status" id="modalStatus" class="form-select" required>
<?php foreach ($statuses as $s): ?>
<option value="<?= $s ?>"><?= ucfirst($s) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group mb-0" id="trackingGroup" style="display: none;">
<label class="form-label">Tracking Number</label>
<input type="text" name="tracking_number" class="form-input" placeholder="Enter tracking number">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="Modal.close('statusModal')">Cancel</button>
<button type="submit" class="btn btn-primary">Update Status</button>
</div>
</form>
</div>
</div>
<script>
function openStatusModal(orderId, currentStatus) {
document.getElementById('modalOrderId').value = orderId;
document.getElementById('modalStatus').value = currentStatus;
Modal.open('statusModal');
}
document.getElementById('modalStatus').addEventListener('change', function() {
document.getElementById('trackingGroup').style.display =
this.value === 'shipped' ? 'block' : 'none';
});
</script>
<?php require_once __DIR__ . '/includes/footer.php'; ?>
+169
View File
@@ -0,0 +1,169 @@
<?php
ob_start();
/**
* Tom's Java Jive - Admin Payment Settings
*/
$pageTitle = 'Payment Settings';
require_once __DIR__ . '/includes/header.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$section = $_POST['section'] ?? '';
if ($section === 'stripe') {
setSetting('payment_stripe', [
'enabled' => 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
]);
?>
<div class="page-header">
<h1 class="page-title">Payment Settings</h1>
</div>
<?php if (hasFlash('success')): ?>
<div class="alert alert-success"><i class="fas fa-check-circle"></i> <?= getFlash('success') ?></div>
<?php endif; ?>
<div style="display: grid; grid-template-columns: 200px 1fr; gap: 1.5rem;">
<div>
<div class="admin-card">
<div class="admin-card-body" style="padding: 0.5rem;">
<a href="/admin/settings.php" class="nav-item"><i class="fas fa-store"></i> General</a>
<a href="/admin/shipping.php" class="nav-item"><i class="fas fa-truck"></i> Shipping</a>
<a href="/admin/payments.php" class="nav-item active"><i class="fas fa-credit-card"></i> Payments</a>
<a href="/admin/emails.php" class="nav-item"><i class="fas fa-envelope"></i> Emails</a>
</div>
</div>
</div>
<div>
<!-- Stripe Settings -->
<form method="POST">
<input type="hidden" name="section" value="stripe">
<div class="admin-card">
<div class="admin-card-header">
<h3 class="admin-card-title"><i class="fab fa-stripe" style="color: #635BFF;"></i> Stripe</h3>
</div>
<div class="admin-card-body">
<div class="form-group">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" name="stripe_enabled" <?= $stripe['enabled'] ? 'checked' : '' ?>>
Enable Stripe payments
</label>
</div>
<div class="form-group">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" name="stripe_test_mode" <?= $stripe['test_mode'] ? 'checked' : '' ?>>
Test mode (use test keys)
</label>
</div>
<div class="form-group">
<label class="form-label">Publishable Key</label>
<input type="text" name="stripe_publishable_key" class="form-input"
value="<?= htmlspecialchars($stripe['publishable_key']) ?>"
placeholder="pk_test_... or pk_live_...">
</div>
<div class="form-group">
<label class="form-label">Secret Key</label>
<input type="password" name="stripe_secret_key" class="form-input"
value="<?= htmlspecialchars($stripe['secret_key']) ?>"
placeholder="sk_test_... or sk_live_...">
</div>
<div class="form-group mb-0">
<label class="form-label">Webhook Secret</label>
<input type="password" name="stripe_webhook_secret" class="form-input"
value="<?= htmlspecialchars($stripe['webhook_secret']) ?>"
placeholder="whsec_...">
<small class="text-muted">Get this from your Stripe webhook settings</small>
</div>
<button type="submit" class="btn btn-primary mt-2">Save Stripe Settings</button>
</div>
</div>
</form>
<!-- POS Payment Methods -->
<form method="POST">
<input type="hidden" name="section" value="methods">
<div class="admin-card">
<div class="admin-card-header">
<h3 class="admin-card-title">POS Payment Methods</h3>
</div>
<div class="admin-card-body">
<p class="text-muted" style="margin-bottom: 1rem;">Select which payment methods are available in the Point of Sale system.</p>
<div class="form-group">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" name="method_card" <?= $methods['card'] ? 'checked' : '' ?>>
<i class="fas fa-credit-card"></i> Card (Terminal)
</label>
</div>
<div class="form-group">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" name="method_cash" <?= $methods['cash'] ? 'checked' : '' ?>>
<i class="fas fa-money-bill"></i> Cash
</label>
</div>
<div class="form-group">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" name="method_wallet" <?= $methods['wallet'] ? 'checked' : '' ?>>
<i class="fas fa-wallet"></i> Customer Wallet
</label>
</div>
<div class="form-group mb-0">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" name="method_gift_card" <?= $methods['gift_card'] ? 'checked' : '' ?>>
<i class="fas fa-gift"></i> Gift Card
</label>
</div>
<button type="submit" class="btn btn-primary mt-2">Save Payment Methods</button>
</div>
</div>
</form>
</div>
</div>
<?php require_once __DIR__ . '/includes/footer.php'; ?>
+1386
View File
File diff suppressed because it is too large Load Diff
+400
View File
@@ -0,0 +1,400 @@
<?php
ob_start();
/**
* Tom's Java Jive - Admin Product Edit/Add
*/
$pageTitle = 'Product';
require_once __DIR__ . '/includes/header.php';
// Load categories and product types for dropdowns
$categoriesList = db()->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"
);
?>
<div class="page-header">
<h1 class="page-title"><?= $isEdit ? 'Edit' : 'Add' ?> Product</h1>
<a href="/admin/products.php" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Back to Products
</a>
</div>
<?php if (!empty($errors)): ?>
<div class="alert alert-error">
<i class="fas fa-exclamation-circle"></i>
Please fix the errors below
</div>
<?php endif; ?>
<form method="POST" action="">
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 1.5rem; align-items: start;">
<!-- Main Info -->
<div>
<div class="admin-card">
<div class="admin-card-header">
<h3 class="admin-card-title">Basic Information</h3>
</div>
<div class="admin-card-body">
<div class="form-group">
<label class="form-label">Product Name *</label>
<input type="text" name="name" class="form-input"
value="<?= htmlspecialchars($product['name']) ?>" required>
<?php if (isset($errors['name'])): ?>
<span class="form-error"><?= $errors['name'] ?></span>
<?php endif; ?>
</div>
<div class="form-group">
<label class="form-label">Description</label>
<textarea name="description" class="form-textarea" rows="4"><?= htmlspecialchars($product['description']) ?></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Category</label>
<select name="category" class="form-input">
<option value="">-- Select Category --</option>
<?php foreach ($categoriesList as $cat): ?>
<option value="<?= htmlspecialchars($cat['name']) ?>"
<?= ($product['category'] ?? '') === $cat['name'] ? 'selected' : '' ?>>
<?= htmlspecialchars($cat['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">Product Type</label>
<select name="product_type_id" class="form-input">
<option value="">-- Select Type --</option>
<?php foreach ($productTypesList as $pt): ?>
<option value="<?= htmlspecialchars($pt['type_id']) ?>"
<?= ($product['product_type_id'] ?? '') === $pt['type_id'] ? 'selected' : '' ?>>
<?= htmlspecialchars($pt['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">Weight (oz)</label>
<input type="number" name="weight" class="form-input" step="0.01"
value="<?= htmlspecialchars($product['weight']) ?>">
</div>
</div>
</div>
</div>
<div class="admin-card">
<div class="admin-card-header">
<h3 class="admin-card-title">Pricing</h3>
</div>
<div class="admin-card-body">
<div class="form-row">
<div class="form-group">
<label class="form-label">Price *</label>
<input type="number" name="price" class="form-input" step="0.01" min="0"
value="<?= htmlspecialchars($product['price']) ?>" required>
<?php if (isset($errors['price'])): ?>
<span class="form-error"><?= $errors['price'] ?></span>
<?php endif; ?>
</div>
<div class="form-group">
<label class="form-label">Sale Price</label>
<input type="number" name="sale_price" class="form-input" step="0.01" min="0"
value="<?= htmlspecialchars($product['sale_price'] ?? '') ?>">
</div>
</div>
<div class="form-group">
<label class="form-label">Cost Price (for profit tracking)</label>
<input type="number" name="cost_price" class="form-input" step="0.01" min="0"
value="<?= htmlspecialchars($product['cost_price'] ?? '') ?>">
</div>
</div>
</div>
<div class="admin-card">
<div class="admin-card-header">
<h3 class="admin-card-title">Images</h3>
</div>
<div class="admin-card-body">
<div class="form-group">
<label class="form-label"><div class="form-group">
<label class="form-label">Product Images</label>
<!-- Drag & Drop Upload Zone -->
<div id="dropZone" style="border:2px dashed var(--color-border);border-radius:var(--radius-md);padding:2rem;text-align:center;cursor:pointer;transition:all .2s;margin-bottom:1rem;background:var(--color-background)" ondragover="handleDragOver(event)" ondragleave="handleDragLeave(event)" ondrop="handleDrop(event)" onclick="document.getElementById('imageFileInput').click()">
<i class="fas fa-cloud-upload-alt" style="font-size:2rem;color:var(--color-text-muted);margin-bottom:.5rem;display:block"></i>
<div style="font-weight:600;margin-bottom:.25rem">Drag & drop images here</div>
<div style="font-size:.875rem;color:var(--color-text-muted)">or click to browse — JPG, PNG, WebP, GIF up to 5MB each</div>
<input type="file" id="imageFileInput" multiple accept="image/*" style="display:none" onchange="handleFileSelect(this.files)">
</div>
<!-- Upload Preview -->
<div id="uploadPreviews" style="display:flex;flex-wrap:wrap;gap:.5rem;margin-bottom:1rem"></div>
<!-- URL Input -->
<div style="position:relative;margin-bottom:.5rem">
<div style="display:flex;align-items:center;gap:.5rem;margin-bottom:.5rem;color:var(--color-text-muted);font-size:.875rem">
<hr style="flex:1;border:none;border-top:1px solid var(--color-border)">
<span>or add image URLs</span>
<hr style="flex:1;border:none;border-top:1px solid var(--color-border)">
</div>
<textarea name="images" id="imageUrls" class="form-textarea" rows="3" placeholder="https://example.com/image1.jpg&#10;https://example.com/image2.jpg"><?= htmlspecialchars(is_array($product['images']) ? implode("
", $product['images']) : ($product['images'] ?? '')) ?></textarea>
<small class="text-muted">Enter one URL per line</small>
</div>
<script>
function handleDragOver(e) {
e.preventDefault();
document.getElementById('dropZone').style.borderColor = 'var(--color-primary)';
document.getElementById('dropZone').style.background = 'rgba(255,94,26,.05)';
}
function handleDragLeave(e) {
document.getElementById('dropZone').style.borderColor = 'var(--color-border)';
document.getElementById('dropZone').style.background = 'var(--color-background)';
}
function handleDrop(e) {
e.preventDefault();
handleDragLeave(e);
handleFileSelect(e.dataTransfer.files);
}
function handleFileSelect(files) {
Array.from(files).forEach(file => {
if (!file.type.startsWith('image/')) return;
if (file.size > 5 * 1024 * 1024) {
alert(file.name + ' is too large (max 5MB)');
return;
}
var reader = new FileReader();
reader.onload = function(e) {
uploadImage(file, e.target.result);
};
reader.readAsDataURL(file);
});
}
function uploadImage(file, dataUrl) {
var preview = document.createElement('div');
preview.style.cssText = 'position:relative;width:100px;height:100px;border-radius:var(--radius-md);overflow:hidden;border:1px solid var(--color-border)';
preview.innerHTML = '<img src="' + dataUrl + '" style="width:100%;height:100%;object-fit:cover">' +
'<div style="position:absolute;inset:0;background:rgba(0,0,0,.4);display:flex;align-items:center;justify-content:center">' +
'<i class="fas fa-spinner fa-spin" style="color:#fff;font-size:1.25rem"></i></div>';
document.getElementById('uploadPreviews').appendChild(preview);
var formData = new FormData();
formData.append('image', file);
formData.append('action', 'upload_image');
fetch('/admin/upload-image.php', {method:'POST', body:formData})
.then(r => r.json())
.then(data => {
if (data.url) {
preview.innerHTML = '<img src="' + data.url + '" style="width:100%;height:100%;object-fit:cover">' +
'<button type="button" onclick="removePreview(this, '' + data.url + '')" style="position:absolute;top:2px;right:2px;background:rgba(0,0,0,.6);border:none;color:#fff;border-radius:50%;width:20px;height:20px;cursor:pointer;font-size:.7rem;display:flex;align-items:center;justify-content:center">&times;</button>';
var urls = document.getElementById('imageUrls');
urls.value = (urls.value ? urls.value + '
' : '') + data.url;
} else {
preview.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:.75rem;color:var(--color-error);padding:.5rem;text-align:center">' + (data.error || 'Upload failed') + '</div>';
}
})
.catch(() => {
preview.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:.75rem;color:var(--color-error);padding:.5rem;text-align:center">Upload failed</div>';
});
}
function removePreview(btn, url) {
btn.closest('div').remove();
var urls = document.getElementById('imageUrls');
urls.value = urls.value.split('
').filter(u => u.trim() !== url.trim()).join('
');
}
</script>
</div>
<?php if (!empty($product['images'])): ?>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 1rem;">
<?php foreach ($product['images'] as $img): ?>
<img src="<?= htmlspecialchars($img) ?>" alt="Product image"
style="width: 80px; height: 80px; object-fit: cover; border-radius: var(--admin-radius);">
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
</div>
<!-- Sidebar -->
<div>
<div class="admin-card">
<div class="admin-card-header">
<h3 class="admin-card-title">Status</h3>
</div>
<div class="admin-card-body">
<div class="form-group">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" name="is_active" value="1"
<?= $product['is_active'] ? 'checked' : '' ?>>
Active (visible in store)
</label>
</div>
<div class="form-group mb-0">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" name="is_featured" value="1"
<?= $product['is_featured'] ? 'checked' : '' ?>>
Featured product
</label>
</div>
</div>
</div>
<div class="admin-card">
<div class="admin-card-header">
<h3 class="admin-card-title">Inventory</h3>
</div>
<div class="admin-card-body">
<div class="form-group">
<label class="form-label">Stock Quantity</label>
<input type="number" name="stock" class="form-input" min="0"
value="<?= htmlspecialchars($product['stock']) ?>">
</div>
<div class="form-group mb-0">
<label class="form-label">Low Stock Alert Threshold</label>
<input type="number" name="low_stock_threshold" class="form-input" min="0"
value="<?= htmlspecialchars($product['low_stock_threshold']) ?>">
</div>
</div>
</div>
<div class="admin-card">
<div class="admin-card-header">
<h3 class="admin-card-title">Identifiers</h3>
</div>
<div class="admin-card-body">
<div class="form-group">
<label class="form-label">SKU</label>
<input type="text" name="sku" class="form-input"
value="<?= htmlspecialchars($product['sku'] ?? '') ?>">
</div>
<div class="form-group mb-0">
<label class="form-label">Barcode</label>
<input type="text" name="barcode" class="form-input"
value="<?= htmlspecialchars($product['barcode'] ?? '') ?>">
</div>
</div>
</div>
<button type="submit" class="btn btn-primary btn-lg btn-block">
<i class="fas fa-save"></i> <?= $isEdit ? 'Update' : 'Create' ?> Product
</button>
</div>
</div>
</form>
<?php require_once __DIR__ . '/includes/footer.php'; ?>
+163
View File
@@ -0,0 +1,163 @@
<?php
ob_start();
$pageTitle = 'Product Types';
require_once __DIR__ . '/includes/header.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
if ($action === 'create' || $action === 'update') {
$typeId = $_POST['type_id'] ?? '';
$name = trim($_POST['name'] ?? '');
$slug = trim($_POST['slug'] ?? '') ?: slugify($name);
$description = trim($_POST['description'] ?? '');
$isActive = isset($_POST['is_active']) ? 1 : 0;
if (empty($name)) {
setFlash('error', 'Product type name is required');
} else {
$data = ['name'=>$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");
?>
<div class="page-header">
<h1 class="page-title">Product Types</h1>
<button class="btn btn-primary" onclick="openTypeModal()">
<i class="fas fa-plus"></i> Add Product Type
</button>
</div>
<?php if (hasFlash('success')): ?>
<div class="alert alert-success"><i class="fas fa-check-circle"></i> <?= getFlash('success') ?></div>
<?php endif; ?>
<?php if (hasFlash('error')): ?>
<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> <?= getFlash('error') ?></div>
<?php endif; ?>
<div class="admin-card">
<div class="admin-card-body" style="padding: 0;">
<table class="admin-table">
<thead>
<tr>
<th>Name</th>
<th>Slug</th>
<th>Description</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($types)): ?>
<tr><td colspan="5" class="text-muted" style="text-align:center;padding:2rem">No product types yet. Create one above.</td></tr>
<?php else: ?>
<?php foreach ($types as $t): ?>
<tr>
<td><strong><?= htmlspecialchars($t['name']) ?></strong></td>
<td class="text-muted"><?= htmlspecialchars($t['slug']) ?></td>
<td class="text-muted"><?= htmlspecialchars(substr($t['description'] ?? '', 0, 60)) ?></td>
<td>
<?php if ($t['is_active']): ?>
<span class="badge badge-success">Active</span>
<?php else: ?>
<span class="badge badge-error">Hidden</span>
<?php endif; ?>
</td>
<td>
<button class="btn btn-sm btn-secondary" onclick='openTypeModal(<?= json_encode($t) ?>)'>
<i class="fas fa-edit"></i>
</button>
<form method="POST" style="display: inline;">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="type_id" value="<?= htmlspecialchars($t['type_id']) ?>">
<button type="submit" class="btn btn-sm btn-danger" data-confirm="Delete this product type?">
<i class="fas fa-trash"></i>
</button>
</form>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<div class="modal-overlay" id="typeModal">
<div class="modal">
<div class="modal-header">
<h3 class="modal-title" id="typeModalTitle">Add Product Type</h3>
<button type="button" class="modal-close" onclick="Modal.close('typeModal')">&times;</button>
</div>
<form method="POST">
<div class="modal-body">
<input type="hidden" name="action" id="typeAction" value="create">
<input type="hidden" name="type_id" id="typeId">
<div class="form-group">
<label class="form-label">Product Type Name *</label>
<input type="text" name="name" id="typeName" class="form-input" required>
</div>
<div class="form-group">
<label class="form-label">Slug</label>
<input type="text" name="slug" id="typeSlug" class="form-input" placeholder="auto-generated if empty">
</div>
<div class="form-group">
<label class="form-label">Description</label>
<textarea name="description" id="typeDesc" class="form-textarea" rows="2"></textarea>
</div>
<div class="form-group mb-0">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" name="is_active" id="typeActive" checked>
Active (visible in product forms)
</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="Modal.close('typeModal')">Cancel</button>
<button type="submit" class="btn btn-primary" id="typeSubmitBtn">Add Product Type</button>
</div>
</form>
</div>
</div>
<script>
function openTypeModal(type) {
type = type || null;
var isEdit = !!type;
document.getElementById('typeModalTitle').textContent = isEdit ? 'Edit Product Type' : 'Add Product Type';
document.getElementById('typeSubmitBtn').textContent = isEdit ? 'Update Product Type' : 'Add Product Type';
document.getElementById('typeAction').value = isEdit ? 'update' : 'create';
document.getElementById('typeId').value = isEdit ? type.type_id : '';
document.getElementById('typeName').value = isEdit ? type.name : '';
document.getElementById('typeSlug').value = isEdit ? type.slug : '';
document.getElementById('typeDesc').value = isEdit ? (type.description || '') : '';
document.getElementById('typeActive').checked = isEdit ? type.is_active == 1 : true;
Modal.open('typeModal');
}
</script>
<?php require_once __DIR__ . '/includes/footer.php'; ?>
+290
View File
@@ -0,0 +1,290 @@
<?php
ob_start();
/**
* Tom's Java Jive - Admin Products Page
*/
$pageTitle = 'Products';
require_once __DIR__ . '/includes/header.php';
// Handle actions
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
if ($action === 'delete' && !empty($_POST['product_id'])) {
db()->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"
);
?>
<div class="page-header">
<h1 class="page-title">Products</h1>
<a href="/admin/product-edit.php" class="btn btn-primary">
<i class="fas fa-plus"></i> Add Product
</a>
</div>
<?php if (hasFlash('success')): ?>
<div class="alert alert-success">
<i class="fas fa-check-circle"></i>
<?= getFlash('success') ?>
</div>
<?php endif; ?>
<!-- Filters -->
<div class="admin-card">
<div class="admin-card-body">
<form method="GET" style="display: flex; gap: 1rem; flex-wrap: wrap; align-items: end;">
<div class="form-group mb-0" style="flex: 1; min-width: 200px;">
<label class="form-label">Search</label>
<input type="text" name="search" class="form-input" placeholder="Name or SKU..."
value="<?= htmlspecialchars($search) ?>">
</div>
<div class="form-group mb-0">
<label class="form-label">Category</label>
<select name="category" class="form-select">
<option value="">All Categories</option>
<?php foreach ($categories as $cat): ?>
<option value="<?= htmlspecialchars($cat['category']) ?>"
<?= $category === $cat['category'] ? 'selected' : '' ?>>
<?= htmlspecialchars(ucfirst($cat['category'])) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group mb-0">
<label class="form-label">Status</label>
<select name="status" class="form-select">
<option value="">All</option>
<option value="active" <?= $status === 'active' ? 'selected' : '' ?>>Active</option>
<option value="inactive" <?= $status === 'inactive' ? 'selected' : '' ?>>Inactive</option>
<option value="low_stock" <?= $status === 'low_stock' ? 'selected' : '' ?>>Low Stock</option>
</select>
</div>
<button type="submit" class="btn btn-secondary">
<i class="fas fa-filter"></i> Filter
</button>
<?php if ($search || $category || $status): ?>
<a href="/admin/products.php" class="btn btn-secondary">Clear</a>
<?php endif; ?>
</form>
</div>
</div>
<!-- Products Table -->
<div class="admin-card">
<div class="admin-card-header">
<span><?= $totalProducts ?> products found</span>
<div id="bulkActions" style="display: none;">
<form method="POST" style="display: inline;">
<input type="hidden" name="action" value="bulk_delete">
<input type="hidden" name="product_ids" id="selectedIds">
<button type="submit" class="btn btn-sm btn-danger" data-confirm="Delete selected products?">
<i class="fas fa-trash"></i> Delete Selected
</button>
</form>
</div>
</div>
<div class="admin-card-body" style="padding: 0;">
<table class="admin-table">
<thead>
<tr>
<th style="width: 40px;">
<input type="checkbox" id="selectAll">
</th>
<th style="width: 60px;">Image</th>
<th>Product</th>
<th>SKU</th>
<th>Category</th>
<th>Price</th>
<th>Stock</th>
<th>Status</th>
<th style="width: 120px;">Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($products)): ?>
<tr>
<td colspan="9" class="text-muted" style="text-align: center; padding: 2rem;">
No products found
</td>
</tr>
<?php else: ?>
<?php foreach ($products as $product):
$images = json_decode($product['images'] ?? '[]', true);
$imageUrl = !empty($images) ? $images[0] : '/assets/images/placeholder-product.jpg';
?>
<tr>
<td>
<input type="checkbox" class="product-checkbox" value="<?= $product['product_id'] ?>">
</td>
<td>
<img src="<?= htmlspecialchars($imageUrl) ?>" alt=""
style="width: 50px; height: 50px; object-fit: cover; border-radius: 4px;">
</td>
<td>
<strong><?= htmlspecialchars($product['name']) ?></strong>
<?php if ($product['is_featured']): ?>
<span class="badge badge-primary">Featured</span>
<?php endif; ?>
</td>
<td class="text-muted"><?= htmlspecialchars($product['sku'] ?? '-') ?></td>
<td><?= htmlspecialchars(ucfirst($product['category'] ?? '-')) ?></td>
<td>
<?php if ($product['sale_price'] && $product['sale_price'] < $product['price']): ?>
<span style="text-decoration: line-through; color: var(--admin-text-light);">
<?= formatCurrency($product['price']) ?>
</span><br>
<strong class="text-success"><?= formatCurrency($product['sale_price']) ?></strong>
<?php else: ?>
<?= formatCurrency($product['price']) ?>
<?php endif; ?>
</td>
<td>
<?php if ($product['stock'] <= 0): ?>
<span class="badge badge-error">Out of Stock</span>
<?php elseif ($product['stock'] <= $product['low_stock_threshold']): ?>
<span class="badge badge-warning"><?= $product['stock'] ?> left</span>
<?php else: ?>
<span class="text-success"><?= $product['stock'] ?></span>
<?php endif; ?>
</td>
<td>
<?php if ($product['is_active']): ?>
<span class="badge badge-success">Active</span>
<?php else: ?>
<span class="badge badge-error">Inactive</span>
<?php endif; ?>
</td>
<td>
<a href="/admin/product-edit.php?id=<?= $product['product_id'] ?>"
class="btn btn-sm btn-secondary" title="Edit">
<i class="fas fa-edit"></i>
</a>
<form method="POST" style="display: inline;">
<input type="hidden" name="action" value="toggle_status">
<input type="hidden" name="product_id" value="<?= $product['product_id'] ?>">
<button type="submit" class="btn btn-sm btn-secondary"
title="<?= $product['is_active'] ? 'Deactivate' : 'Activate' ?>">
<i class="fas fa-<?= $product['is_active'] ? 'eye-slash' : 'eye' ?>"></i>
</button>
</form>
<form method="POST" style="display: inline;">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="product_id" value="<?= $product['product_id'] ?>">
<button type="submit" class="btn btn-sm btn-danger"
data-confirm="Delete this product?" title="Delete">
<i class="fas fa-trash"></i>
</button>
</form>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<!-- Pagination -->
<?php if ($pagination['total_pages'] > 1): ?>
<div style="display: flex; justify-content: center; margin-top: 1rem;">
<?= renderPagination($pagination, '/admin/products.php?search=' . urlencode($search) . '&category=' . urlencode($category) . '&status=' . $status) ?>
</div>
<?php endif; ?>
<script>
// Bulk selection
const selectAll = document.getElementById('selectAll');
const checkboxes = document.querySelectorAll('.product-checkbox');
const bulkActions = document.getElementById('bulkActions');
const selectedIds = document.getElementById('selectedIds');
selectAll.addEventListener('change', function() {
checkboxes.forEach(cb => cb.checked = this.checked);
updateBulkActions();
});
checkboxes.forEach(cb => {
cb.addEventListener('change', updateBulkActions);
});
function updateBulkActions() {
const checked = document.querySelectorAll('.product-checkbox:checked');
bulkActions.style.display = checked.length > 0 ? 'block' : 'none';
selectedIds.value = Array.from(checked).map(cb => cb.value).join(',');
}
</script>
<?php require_once __DIR__ . '/includes/footer.php'; ?>
+281
View File
@@ -0,0 +1,281 @@
<?php
ob_start();
/**
* Tom's Java Jive - Admin Reviews
*/
$pageTitle = 'Reviews';
require_once __DIR__ . '/includes/header.php';
// Handle actions
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
$reviewId = $_POST['review_id'] ?? '';
if ($action === 'approve' && $reviewId) {
db()->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;
?>
<div class="page-header">
<h1 class="page-title">Product Reviews</h1>
</div>
<?php if (hasFlash('success')): ?>
<div class="alert alert-success"><i class="fas fa-check-circle"></i> <?= getFlash('success') ?></div>
<?php endif; ?>
<!-- Stats -->
<div class="stats-grid" style="margin-bottom: 1.5rem;">
<div class="stat-card">
<div class="stat-card-icon primary"><i class="fas fa-star"></i></div>
<div>
<div class="stat-card-value"><?= number_format($avgRating, 1) ?></div>
<div class="stat-card-label">Average Rating</div>
</div>
</div>
<div class="stat-card">
<div class="stat-card-icon success"><i class="fas fa-comments"></i></div>
<div>
<div class="stat-card-value"><?= $totalReviews ?></div>
<div class="stat-card-label">Total Reviews</div>
</div>
</div>
<div class="stat-card">
<div class="stat-card-icon <?= $pendingReviews > 0 ? 'warning' : 'success' ?>"><i class="fas fa-clock"></i></div>
<div>
<div class="stat-card-value"><?= $pendingReviews ?></div>
<div class="stat-card-label">Pending Approval</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="admin-card">
<div class="admin-card-body">
<form method="GET" style="display: flex; gap: 1rem; flex-wrap: wrap; align-items: end;">
<div class="form-group mb-0">
<label class="form-label">Status</label>
<select name="status" class="form-select">
<option value="">All</option>
<option value="pending" <?= $status === 'pending' ? 'selected' : '' ?>>Pending</option>
<option value="approved" <?= $status === 'approved' ? 'selected' : '' ?>>Approved</option>
</select>
</div>
<div class="form-group mb-0">
<label class="form-label">Rating</label>
<select name="rating" class="form-select">
<option value="">All Ratings</option>
<?php for ($i = 5; $i >= 1; $i--): ?>
<option value="<?= $i ?>" <?= $rating == $i ? 'selected' : '' ?>><?= $i ?> Star<?= $i > 1 ? 's' : '' ?></option>
<?php endfor; ?>
</select>
</div>
<button type="submit" class="btn btn-secondary"><i class="fas fa-filter"></i> Filter</button>
<?php if ($status || $rating): ?>
<a href="/admin/reviews.php" class="btn btn-secondary">Clear</a>
<?php endif; ?>
</form>
</div>
</div>
<!-- Reviews List -->
<div style="display: flex; flex-direction: column; gap: 1rem;">
<?php if (empty($reviews)): ?>
<div class="admin-card">
<div class="admin-card-body text-center text-muted" style="padding: 3rem;">
No reviews found
</div>
</div>
<?php else: ?>
<?php foreach ($reviews as $review): ?>
<div class="admin-card">
<div class="admin-card-body">
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 1rem;">
<div>
<div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;">
<strong><?= htmlspecialchars($review['customer_name'] ?? 'Anonymous') ?></strong>
<?php if ($review['is_verified_purchase']): ?>
<span class="badge badge-success">Verified Purchase</span>
<?php endif; ?>
<?php if (!$review['is_approved']): ?>
<span class="badge badge-warning">Pending</span>
<?php endif; ?>
</div>
<div class="text-muted" style="font-size: 0.875rem;">
on <a href="/admin/product-edit.php?id=<?= $review['product_id'] ?>"><?= htmlspecialchars($review['product_name'] ?? 'Unknown Product') ?></a>
• <?= formatDate($review['created_at']) ?>
</div>
</div>
<div style="display: flex; gap: 0.5rem;">
<?php if (!$review['is_approved']): ?>
<form method="POST" style="display: inline;">
<input type="hidden" name="action" value="approve">
<input type="hidden" name="review_id" value="<?= $review['review_id'] ?>">
<button type="submit" class="btn btn-sm btn-success"><i class="fas fa-check"></i> Approve</button>
</form>
<?php else: ?>
<form method="POST" style="display: inline;">
<input type="hidden" name="action" value="reject">
<input type="hidden" name="review_id" value="<?= $review['review_id'] ?>">
<button type="submit" class="btn btn-sm btn-secondary"><i class="fas fa-times"></i> Unapprove</button>
</form>
<?php endif; ?>
<button type="button" class="btn btn-sm btn-secondary" title="Edit"
onclick='openEditReview(<?= json_encode(['review_id' => $review['review_id'], 'rating' => $review['rating'], 'title' => $review['title'], 'comment' => $review['comment']]) ?>)'>
<i class="fas fa-edit"></i>
</button>
<form method="POST" style="display: inline;">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="review_id" value="<?= $review['review_id'] ?>">
<button type="submit" class="btn btn-sm btn-danger" data-confirm="Delete this review?"><i class="fas fa-trash"></i></button>
</form>
</div>
</div>
<div style="margin-bottom: 0.75rem;">
<?php for ($i = 1; $i <= 5; $i++): ?>
<i class="fas fa-star" style="color: <?= $i <= $review['rating'] ? '#F59E0B' : '#E5E7EB' ?>;"></i>
<?php endfor; ?>
</div>
<?php if ($review['title']): ?>
<h4 style="margin-bottom: 0.5rem;"><?= htmlspecialchars($review['title']) ?></h4>
<?php endif; ?>
<p style="margin: 0; color: var(--admin-text);">
<?= nl2br(htmlspecialchars($review['comment'])) ?>
</p>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
<!-- Edit Review Modal -->
<div class="modal-overlay" id="editReviewModal">
<div class="modal" style="max-width:560px;width:95vw">
<div class="modal-header">
<h3 class="modal-title">Edit Review</h3>
<button type="button" class="modal-close" onclick="Modal.close('editReviewModal')">&times;</button>
</div>
<form method="POST" id="editReviewForm">
<div class="modal-body">
<input type="hidden" name="action" value="update">
<input type="hidden" name="review_id" id="editReviewId">
<div class="form-group">
<label class="form-label">Rating</label>
<div id="starRating" style="display:flex;gap:.25rem;font-size:1.5rem;cursor:pointer;margin-bottom:.25rem">
<?php for ($s = 1; $s <= 5; $s++): ?>
<i class="fas fa-star" data-star="<?= $s ?>" style="color:#E5E7EB"></i>
<?php endfor; ?>
</div>
<input type="hidden" name="rating" id="editRating" value="5">
</div>
<div class="form-group">
<label class="form-label">Title</label>
<input type="text" name="title" id="editTitle" class="form-input" placeholder="Review title (optional)">
</div>
<div class="form-group mb-0">
<label class="form-label">Comment</label>
<textarea name="comment" id="editComment" class="form-input" rows="5" style="resize:vertical"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="Modal.close('editReviewModal')">Cancel</button>
<button type="submit" class="btn btn-primary"><i class="fas fa-save"></i> Save Changes</button>
</div>
</form>
</div>
</div>
<script>
function openEditReview(review) {
document.getElementById('editReviewId').value = review.review_id;
document.getElementById('editTitle').value = review.title || '';
document.getElementById('editComment').value = review.comment || '';
setStarRating(review.rating || 5);
Modal.open('editReviewModal');
}
function setStarRating(val) {
document.getElementById('editRating').value = val;
document.querySelectorAll('#starRating i').forEach(function(star) {
star.style.color = parseInt(star.dataset.star) <= val ? '#F59E0B' : '#E5E7EB';
});
}
document.querySelectorAll('#starRating i').forEach(function(star) {
star.addEventListener('click', function() { setStarRating(parseInt(this.dataset.star)); });
star.addEventListener('mouseenter', function() {
var val = parseInt(this.dataset.star);
document.querySelectorAll('#starRating i').forEach(function(s) {
s.style.color = parseInt(s.dataset.star) <= val ? '#F59E0B' : '#E5E7EB';
});
});
star.addEventListener('mouseleave', function() {
setStarRating(parseInt(document.getElementById('editRating').value));
});
});
</script>
<?php require_once __DIR__ . '/includes/footer.php'; ?>
+233
View File
@@ -0,0 +1,233 @@
<?php
ob_start();
/**
* Tom's Java Jive - Admin Settings
*/
$pageTitle = 'Store Settings';
require_once __DIR__ . '/includes/header.php';
// Handle form submission
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$section = $_POST['section'] ?? '';
if ($section === 'general') {
setSetting('store', [
'name' => 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' => ''
]);
?>
<div class="page-header">
<h1 class="page-title">Store Settings</h1>
</div>
<?php if (hasFlash('success')): ?>
<div class="alert alert-success"><i class="fas fa-check-circle"></i> <?= getFlash('success') ?></div>
<?php endif; ?>
<div style="display: grid; grid-template-columns: 200px 1fr; gap: 1.5rem;">
<!-- Sidebar Navigation -->
<div>
<div class="admin-card">
<div class="admin-card-body" style="padding: 0.5rem;">
<a href="/admin/settings.php" class="nav-item active"><i class="fas fa-store"></i> General</a>
<a href="/admin/shipping.php" class="nav-item"><i class="fas fa-truck"></i> Shipping</a>
<a href="/admin/payments.php" class="nav-item"><i class="fas fa-credit-card"></i> Payments</a>
<a href="/admin/emails.php" class="nav-item"><i class="fas fa-envelope"></i> Emails</a>
</div>
</div>
</div>
<!-- Settings Forms -->
<div>
<!-- General Settings -->
<form method="POST">
<input type="hidden" name="section" value="general">
<div class="admin-card">
<div class="admin-card-header">
<h3 class="admin-card-title">General Settings</h3>
</div>
<div class="admin-card-body">
<div class="form-group">
<label class="form-label">Store Name</label>
<input type="text" name="store_name" class="form-input" value="<?= htmlspecialchars($store['name']) ?>">
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Store Email</label>
<input type="email" name="store_email" class="form-input" value="<?= htmlspecialchars($store['email']) ?>">
</div>
<div class="form-group">
<label class="form-label">Store Phone</label>
<input type="text" name="store_phone" class="form-input" value="<?= htmlspecialchars($store['phone']) ?>">
</div>
</div>
<div class="form-group">
<label class="form-label">Store Address</label>
<textarea name="store_address" class="form-textarea" rows="2"><?= htmlspecialchars($store['address']) ?></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Currency</label>
<select name="currency" class="form-select">
<option value="USD" <?= $store['currency'] === 'USD' ? 'selected' : '' ?>>USD ($)</option>
<option value="EUR" <?= $store['currency'] === 'EUR' ? 'selected' : '' ?>>EUR (€)</option>
<option value="GBP" <?= $store['currency'] === 'GBP' ? 'selected' : '' ?>>GBP (£)</option>
<option value="CAD" <?= $store['currency'] === 'CAD' ? 'selected' : '' ?>>CAD ($)</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Timezone</label>
<select name="timezone" class="form-select">
<option value="America/New_York" <?= $store['timezone'] === 'America/New_York' ? 'selected' : '' ?>>Eastern Time</option>
<option value="America/Chicago" <?= $store['timezone'] === 'America/Chicago' ? 'selected' : '' ?>>Central Time</option>
<option value="America/Denver" <?= $store['timezone'] === 'America/Denver' ? 'selected' : '' ?>>Mountain Time</option>
<option value="America/Los_Angeles" <?= $store['timezone'] === 'America/Los_Angeles' ? 'selected' : '' ?>>Pacific Time</option>
</select>
</div>
</div>
<button type="submit" class="btn btn-primary">Save General Settings</button>
</div>
</div>
</form>
<!-- Tax Settings -->
<form method="POST">
<input type="hidden" name="section" value="tax">
<div class="admin-card">
<div class="admin-card-header">
<h3 class="admin-card-title">Tax Settings</h3>
</div>
<div class="admin-card-body">
<div class="form-group">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" name="tax_enabled" <?= $tax['enabled'] ? 'checked' : '' ?>>
Enable tax calculation
</label>
</div>
<div class="form-group">
<label class="form-label">Tax Rate (%)</label>
<input type="number" name="tax_rate" class="form-input" step="0.01" min="0" max="100" value="<?= $tax['rate'] ?>" style="max-width: 150px;">
</div>
<div class="form-group mb-0">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" name="tax_included" <?= $tax['included_in_price'] ? 'checked' : '' ?>>
Prices include tax
</label>
</div>
<button type="submit" class="btn btn-primary mt-2">Save Tax Settings</button>
</div>
</div>
</form>
<!-- Checkout Settings -->
<form method="POST">
<input type="hidden" name="section" value="checkout">
<div class="admin-card">
<div class="admin-card-header">
<h3 class="admin-card-title">Checkout Settings</h3>
</div>
<div class="admin-card-body">
<div class="form-group">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" name="guest_checkout" <?= $checkout['guest_checkout'] ? 'checked' : '' ?>>
Allow guest checkout
</label>
</div>
<div class="form-group">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" name="require_phone" <?= $checkout['require_phone'] ? 'checked' : '' ?>>
Require phone number
</label>
</div>
<div class="form-group">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" name="order_notes" <?= $checkout['order_notes'] ? 'checked' : '' ?>>
Allow order notes
</label>
</div>
<div class="form-group">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" name="terms_required" <?= $checkout['terms_required'] ? 'checked' : '' ?>>
Require terms acceptance
</label>
</div>
<div class="form-group mb-0">
<label class="form-label">Terms & Conditions URL</label>
<input type="url" name="terms_url" class="form-input" value="<?= htmlspecialchars($checkout['terms_url']) ?>" placeholder="https://example.com/terms">
</div>
<button type="submit" class="btn btn-primary mt-2">Save Checkout Settings</button>
</div>
</div>
</form>
</div>
</div>
<?php require_once __DIR__ . '/includes/footer.php'; ?>
+124
View File
@@ -0,0 +1,124 @@
<?php
ob_start();
/**
* Tom's Java Jive - Admin Shipping Settings
*/
$pageTitle = 'Shipping Settings';
require_once __DIR__ . '/includes/header.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
setSetting('shipping', [
'flat_rate_enabled' => 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'
]);
?>
<div class="page-header">
<h1 class="page-title">Shipping Settings</h1>
</div>
<?php if (hasFlash('success')): ?>
<div class="alert alert-success"><i class="fas fa-check-circle"></i> <?= getFlash('success') ?></div>
<?php endif; ?>
<div style="display: grid; grid-template-columns: 200px 1fr; gap: 1.5rem;">
<div>
<div class="admin-card">
<div class="admin-card-body" style="padding: 0.5rem;">
<a href="/admin/settings.php" class="nav-item"><i class="fas fa-store"></i> General</a>
<a href="/admin/shipping.php" class="nav-item active"><i class="fas fa-truck"></i> Shipping</a>
<a href="/admin/payments.php" class="nav-item"><i class="fas fa-credit-card"></i> Payments</a>
<a href="/admin/emails.php" class="nav-item"><i class="fas fa-envelope"></i> Emails</a>
</div>
</div>
</div>
<div>
<form method="POST">
<div class="admin-card">
<div class="admin-card-header">
<h3 class="admin-card-title">Shipping Methods</h3>
</div>
<div class="admin-card-body">
<!-- Flat Rate -->
<div style="border: 1px solid var(--admin-border); border-radius: var(--admin-radius); padding: 1rem; margin-bottom: 1rem;">
<div class="form-group">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer; font-weight: 600;">
<input type="checkbox" name="flat_rate_enabled" <?= $shipping['flat_rate_enabled'] ? 'checked' : '' ?>>
<i class="fas fa-box"></i> Flat Rate Shipping
</label>
</div>
<div class="form-group mb-0">
<label class="form-label">Flat Rate Amount</label>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<span>$</span>
<input type="number" name="flat_rate_amount" class="form-input" step="0.01" min="0"
value="<?= $shipping['flat_rate_amount'] ?>" style="max-width: 120px;">
</div>
</div>
</div>
<!-- Free Shipping -->
<div style="border: 1px solid var(--admin-border); border-radius: var(--admin-radius); padding: 1rem; margin-bottom: 1rem;">
<div class="form-group">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer; font-weight: 600;">
<input type="checkbox" name="free_shipping_enabled" <?= $shipping['free_shipping_enabled'] ? 'checked' : '' ?>>
<i class="fas fa-gift"></i> Free Shipping (Threshold)
</label>
</div>
<div class="form-group mb-0">
<label class="form-label">Minimum Order for Free Shipping</label>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<span>$</span>
<input type="number" name="free_shipping_threshold" class="form-input" step="0.01" min="0"
value="<?= $shipping['free_shipping_threshold'] ?>" style="max-width: 120px;">
</div>
</div>
</div>
<!-- Local Pickup -->
<div style="border: 1px solid var(--admin-border); border-radius: var(--admin-radius); padding: 1rem; margin-bottom: 1rem;">
<div class="form-group mb-0">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer; font-weight: 600;">
<input type="checkbox" name="local_pickup_enabled" <?= $shipping['local_pickup_enabled'] ? 'checked' : '' ?>>
<i class="fas fa-store"></i> Local Pickup
</label>
<small class="text-muted">Allow customers to pick up orders at your location</small>
</div>
</div>
<!-- Processing Time -->
<div class="form-group mb-0">
<label class="form-label">Processing Time</label>
<input type="text" name="processing_time" class="form-input"
value="<?= htmlspecialchars($shipping['processing_time']) ?>"
placeholder="e.g., 1-2 business days">
<small class="text-muted">Displayed to customers during checkout</small>
</div>
<button type="submit" class="btn btn-primary mt-2">Save Shipping Settings</button>
</div>
</div>
</form>
</div>
</div>
<?php require_once __DIR__ . '/includes/footer.php'; ?>
+410
View File
@@ -0,0 +1,410 @@
<?php
ob_start();
$pageTitle = 'Splash Box';
$currentPage = 'splashes';
require_once __DIR__ . '/includes/header.php';
/* ── Actions ─────────────────────────────────────── */
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
$splashId = $_POST['splash_id'] ?? '';
if ($action === 'create') {
$title = trim($_POST['title'] ?? '');
if ($title) {
db()->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',
];
?>
<div class="page-header">
<h1 class="page-title">Splash Box</h1>
<button class="btn btn-primary" onclick="openSplashModal()">
<i class="fas fa-plus"></i> Add Splash
</button>
</div>
<?php if (hasFlash('success')): ?>
<div class="alert alert-success"><i class="fas fa-check-circle"></i> <?= getFlash('success') ?></div>
<?php endif; ?>
<div class="admin-card" style="margin-bottom:1.5rem">
<div class="admin-card-body" style="padding:.85rem 1.5rem">
<p class="text-muted" style="margin:0"><i class="fas fa-info-circle"></i>
Drag rows to reorder — saves automatically.
<?= count($splashes) ?> block<?= count($splashes) !== 1 ? 's' : '' ?> total.
Homepage scrolls horizontally when more than 4 are active.
Each block can show an icon <em>or</em> a custom image.</p>
</div>
</div>
<!-- Live Preview -->
<div class="admin-card" style="margin-bottom:1.5rem">
<div class="admin-card-header"><h3><i class="fas fa-eye"></i> Homepage Preview</h3></div>
<div class="admin-card-body" style="background:var(--admin-bg);border-radius:var(--radius-md);padding:0;overflow:hidden">
<div id="splashPreview" style="display:flex;gap:1.5rem;overflow-x:auto;padding:2rem;scrollbar-width:thin">
<?php foreach ($splashes as $sp): if (!$sp['is_active']) continue; ?>
<div style="min-width:190px;text-align:center;padding:1.5rem 1rem;background:var(--admin-card);border-radius:var(--radius-lg);flex-shrink:0;box-shadow:0 1px 4px rgba(0,0,0,.08)">
<div style="width:56px;height:56px;background:linear-gradient(135deg,var(--admin-primary),#c4420f);border-radius:12px;display:flex;align-items:center;justify-content:center;margin:0 auto .75rem;color:#fff;font-size:1.35rem;overflow:hidden">
<?php if (!empty($sp['image_url'])): ?>
<img src="<?= htmlspecialchars($sp['image_url']) ?>" style="width:100%;height:100%;object-fit:cover" alt="">
<?php else: ?>
<i class="<?= htmlspecialchars($sp['icon']) ?>"></i>
<?php endif; ?>
</div>
<div style="font-weight:600;font-size:.9rem;margin-bottom:.35rem"><?= htmlspecialchars($sp['title']) ?></div>
<div style="font-size:.78rem;color:var(--admin-text-muted);line-height:1.4"><?= htmlspecialchars($sp['description'] ?? '') ?></div>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<!-- Table -->
<div class="admin-card">
<div class="admin-card-body" style="padding:0">
<?php if (empty($splashes)): ?>
<div class="text-center text-muted" style="padding:3rem">No splash blocks yet. Click <strong>Add Splash</strong> to get started.</div>
<?php else: ?>
<table class="admin-table">
<thead>
<tr>
<th style="width:30px"></th>
<th style="width:60px">Visual</th>
<th>Title</th>
<th>Description</th>
<th style="width:60px">Order</th>
<th style="width:80px">Status</th>
<th style="width:100px">Actions</th>
</tr>
</thead>
<tbody id="splashTbody">
<?php foreach ($splashes as $sp): ?>
<tr data-id="<?= $sp['splash_id'] ?>">
<td style="color:var(--admin-text-muted);text-align:center;cursor:grab"><i class="fas fa-grip-vertical"></i></td>
<td>
<div style="width:42px;height:42px;background:linear-gradient(135deg,var(--admin-primary),#c4420f);border-radius:8px;display:flex;align-items:center;justify-content:center;color:#fff;overflow:hidden">
<?php if (!empty($sp['image_url'])): ?>
<img src="<?= htmlspecialchars($sp['image_url']) ?>" style="width:100%;height:100%;object-fit:cover" alt="">
<?php else: ?>
<i class="<?= htmlspecialchars($sp['icon']) ?>"></i>
<?php endif; ?>
</div>
</td>
<td><strong><?= htmlspecialchars($sp['title']) ?></strong></td>
<td style="max-width:260px;color:var(--admin-text-muted);font-size:.875rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
<?= htmlspecialchars($sp['description'] ?? '') ?>
</td>
<td class="sort-cell"><?= $sp['sort_order'] ?></td>
<td>
<?= $sp['is_active']
? '<span class="badge badge-success">Active</span>'
: '<span class="badge badge-error">Hidden</span>' ?>
</td>
<td>
<button class="btn btn-sm btn-secondary" onclick='openSplashModal(<?= json_encode($sp) ?>)' title="Edit">
<i class="fas fa-edit"></i>
</button>
<form method="POST" style="display:inline">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="splash_id" value="<?= $sp['splash_id'] ?>">
<button type="submit" class="btn btn-sm btn-danger" data-confirm="Delete this splash block?">
<i class="fas fa-trash"></i>
</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</div>
<!-- ── Modal ──────────────────────────────────────── -->
<div class="modal-overlay" id="splashModal">
<div class="modal" style="max-width:620px;width:95vw">
<div class="modal-header">
<h3 class="modal-title" id="splashModalTitle">Add Splash Block</h3>
<button type="button" class="modal-close" onclick="Modal.close('splashModal')">&times;</button>
</div>
<form method="POST" id="splashForm">
<div class="modal-body">
<input type="hidden" name="action" id="splashAction" value="create">
<input type="hidden" name="splash_id" id="splashId">
<input type="hidden" name="image_url" id="splashImageUrl">
<!-- Image upload zone -->
<div class="form-group">
<label class="form-label">Image <span class="text-muted" style="font-weight:400">(optional — overrides icon when set)</span></label>
<div id="dropZone" style="border:2px dashed var(--color-border);border-radius:var(--radius-md);padding:1.5rem;text-align:center;cursor:pointer;transition:all .2s;position:relative">
<div id="dropPlaceholder">
<i class="fas fa-cloud-upload-alt" style="font-size:2rem;color:var(--admin-text-muted);display:block;margin-bottom:.5rem"></i>
<div style="font-size:.875rem;color:var(--admin-text-muted)">Drag &amp; drop an image here, or <span style="color:var(--admin-primary);font-weight:600">browse</span></div>
<div style="font-size:.75rem;color:var(--admin-text-muted);margin-top:.25rem">JPG, PNG, WebP, GIF · Max 5 MB</div>
</div>
<div id="dropPreview" style="display:none">
<img id="dropPreviewImg" src="" alt="" style="max-height:120px;max-width:100%;border-radius:var(--radius-md);margin-bottom:.5rem">
<div>
<button type="button" class="btn btn-sm btn-danger" onclick="clearImage(event)">
<i class="fas fa-times"></i> Remove image
</button>
</div>
</div>
<input type="file" id="dropInput" accept="image/*" style="position:absolute;inset:0;opacity:0;cursor:pointer">
<div id="dropUploading" style="display:none;color:var(--admin-text-muted)"><i class="fas fa-spinner fa-spin"></i> Uploading…</div>
</div>
</div>
<!-- Icon picker -->
<div class="form-group" id="iconGroup">
<label class="form-label">Icon <span class="text-muted" style="font-weight:400">(used when no image is set)</span></label>
<div style="display:flex;flex-wrap:wrap;gap:.4rem;margin-bottom:.6rem">
<?php foreach ($iconOptions as $cls => $label): ?>
<button type="button" class="icon-opt btn btn-secondary" data-icon="<?= $cls ?>" title="<?= $label ?>"
style="width:40px;height:40px;padding:0;display:flex;align-items:center;justify-content:center"
onclick="pickIcon('<?= $cls ?>')">
<i class="<?= $cls ?>"></i>
</button>
<?php endforeach; ?>
</div>
<input type="text" name="icon" id="splashIcon" class="form-input" placeholder="Custom FA class e.g. fas fa-mug-hot" value="fas fa-star">
</div>
<div class="form-group">
<label class="form-label">Title *</label>
<input type="text" name="title" id="splashTitle" class="form-input" required>
</div>
<div class="form-group">
<label class="form-label">Description</label>
<textarea name="description" id="splashDesc" class="form-input" rows="3" style="resize:vertical"></textarea>
</div>
<div style="display:flex;gap:1rem">
<div class="form-group" style="flex:1">
<label class="form-label">Sort Order</label>
<input type="number" name="sort_order" id="splashOrder" class="form-input" value="0" min="0">
</div>
<div class="form-group" id="splashStatusGroup" style="display:none;flex:1">
<label class="form-label">Status</label>
<label style="display:flex;align-items:center;gap:.5rem;margin-top:.6rem;cursor:pointer">
<input type="checkbox" name="is_active" id="splashActive" checked> Active
</label>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="Modal.close('splashModal')">Cancel</button>
<button type="submit" class="btn btn-primary" id="splashSubmitBtn">Add Splash</button>
</div>
</form>
</div>
</div>
<style>
.icon-opt.selected { background:var(--admin-primary)!important;color:#fff!important;border-color:var(--admin-primary)!important; }
#dropZone.drag-active { border-color:var(--admin-primary);background:rgba(255,94,26,.04); }
#splashTbody tr { cursor:grab; }
#splashTbody tr.drag-over { background:rgba(255,94,26,.06); }
</style>
<script>
/* ── Icon picker ─────────────────────────────────── */
function pickIcon(cls) {
document.getElementById('splashIcon').value = cls;
document.querySelectorAll('.icon-opt').forEach(function(b) {
b.classList.toggle('selected', b.dataset.icon === cls);
});
}
document.getElementById('splashIcon').addEventListener('input', function() {
document.querySelectorAll('.icon-opt').forEach(function(b) {
b.classList.toggle('selected', b.dataset.icon === this.value);
}.bind(this));
});
/* ── Image drop zone ─────────────────────────────── */
var dropZone = document.getElementById('dropZone');
var dropInput = document.getElementById('dropInput');
['dragenter','dragover'].forEach(function(ev) {
dropZone.addEventListener(ev, function(e) { e.preventDefault(); dropZone.classList.add('drag-active'); });
});
['dragleave','drop'].forEach(function(ev) {
dropZone.addEventListener(ev, function(e) { e.preventDefault(); dropZone.classList.remove('drag-active'); });
});
dropZone.addEventListener('drop', function(e) { handleFile(e.dataTransfer.files[0]); });
dropInput.addEventListener('change', function() { if (this.files[0]) handleFile(this.files[0]); });
function handleFile(file) {
if (!file) return;
var allowed = ['image/jpeg','image/png','image/gif','image/webp'];
if (!allowed.includes(file.type)) { alert('Please use JPG, PNG, WebP or GIF.'); return; }
if (file.size > 5 * 1024 * 1024) { alert('File too large (max 5 MB).'); return; }
document.getElementById('dropPlaceholder').style.display = 'none';
document.getElementById('dropPreview').style.display = 'none';
document.getElementById('dropUploading').style.display = 'block';
var fd = new FormData();
fd.append('image', file);
fetch('/admin/api/upload-splash.php', { method: 'POST', body: fd })
.then(function(r) { return r.json(); })
.then(function(data) {
document.getElementById('dropUploading').style.display = 'none';
if (data.error) { alert(data.error); showDropPlaceholder(); return; }
document.getElementById('splashImageUrl').value = data.url;
document.getElementById('dropPreviewImg').src = data.url;
document.getElementById('dropPreview').style.display = 'block';
})
.catch(function() { document.getElementById('dropUploading').style.display='none'; showDropPlaceholder(); });
}
function showDropPlaceholder() {
document.getElementById('dropPlaceholder').style.display = 'block';
document.getElementById('dropPreview').style.display = 'none';
}
function clearImage(e) {
e.stopPropagation();
document.getElementById('splashImageUrl').value = '';
document.getElementById('dropInput').value = '';
showDropPlaceholder();
}
/* ── Open modal ──────────────────────────────────── */
function openSplashModal(sp) {
var isEdit = !!sp;
document.getElementById('splashModalTitle').textContent = isEdit ? 'Edit Splash Block' : 'Add Splash Block';
document.getElementById('splashSubmitBtn').textContent = isEdit ? 'Save Changes' : 'Add Splash';
document.getElementById('splashAction').value = isEdit ? 'update' : 'create';
document.getElementById('splashId').value = isEdit ? sp.splash_id : '';
document.getElementById('splashTitle').value = isEdit ? sp.title : '';
document.getElementById('splashDesc').value = isEdit ? (sp.description || '') : '';
document.getElementById('splashOrder').value = isEdit ? sp.sort_order : 0;
document.getElementById('splashActive').checked = isEdit ? !!parseInt(sp.is_active) : true;
document.getElementById('splashStatusGroup').style.display = isEdit ? '' : 'none';
pickIcon(isEdit && sp.icon ? sp.icon : 'fas fa-star');
// image
var imgUrl = isEdit ? (sp.image_url || '') : '';
document.getElementById('splashImageUrl').value = imgUrl;
document.getElementById('dropInput').value = '';
if (imgUrl) {
document.getElementById('dropPreviewImg').src = imgUrl;
document.getElementById('dropPreview').style.display = 'block';
document.getElementById('dropPlaceholder').style.display = 'none';
document.getElementById('dropUploading').style.display = 'none';
} else {
showDropPlaceholder();
document.getElementById('dropUploading').style.display = 'none';
}
Modal.open('splashModal');
}
/* ── Drag-to-reorder rows ────────────────────────── */
(function() {
var tbody = document.getElementById('splashTbody');
if (!tbody) return;
var dragging = null;
tbody.querySelectorAll('tr').forEach(function(row) {
row.draggable = true;
row.addEventListener('dragstart', function() { dragging = this; this.style.opacity = '.4'; });
row.addEventListener('dragend', function() { this.style.opacity = ''; dragging = null; saveOrder(); });
row.addEventListener('dragover', function(e) { e.preventDefault(); this.classList.add('drag-over'); });
row.addEventListener('dragleave', function() { this.classList.remove('drag-over'); });
row.addEventListener('drop', function(e) {
e.preventDefault(); this.classList.remove('drag-over');
if (dragging && dragging !== this) {
var rows = Array.from(tbody.querySelectorAll('tr'));
if (rows.indexOf(dragging) < rows.indexOf(this)) this.after(dragging);
else this.before(dragging);
}
});
});
function saveOrder() {
var ids = Array.from(tbody.querySelectorAll('tr')).map(function(r) { return r.dataset.id; });
fetch('/admin/splashes.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'action=reorder&order=' + encodeURIComponent(JSON.stringify(ids))
});
tbody.querySelectorAll('tr').forEach(function(r, i) {
r.querySelector('.sort-cell').textContent = i + 1;
});
}
})();
</script>
<?php require_once __DIR__ . '/includes/footer.php'; ?>
+44
View File
@@ -0,0 +1,44 @@
<?php
/**
* Tom's Java Jive - Admin Image Upload Handler
*/
require_once __DIR__ . '/includes/header.php';
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || empty($_FILES['image'])) {
echo json_encode(['error' => '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.']);
}
+267
View File
@@ -0,0 +1,267 @@
<?php
ob_start();
/**
* Tom's Java Jive - Admin Users Management
*/
$pageTitle = 'Admin Users';
require_once __DIR__ . '/includes/header.php';
// Handle actions
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
if ($action === 'create' || $action === 'update') {
$userId = $_POST['user_id'] ?? '';
$email = trim($_POST['email'] ?? '');
$name = trim($_POST['name'] ?? '');
$password = $_POST['password'] ?? '';
$isMaster = isset($_POST['is_master']) ? 1 : 0;
$permissions = [
'dashboard' => 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");
?>
<div class="page-header">
<h1 class="page-title">Admin Users</h1>
<button class="btn btn-primary" onclick="openUserModal()">
<i class="fas fa-plus"></i> Add Admin User
</button>
</div>
<?php if (hasFlash('success')): ?>
<div class="alert alert-success"><i class="fas fa-check-circle"></i> <?= getFlash('success') ?></div>
<?php endif; ?>
<?php if (hasFlash('error')): ?>
<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> <?= getFlash('error') ?></div>
<?php endif; ?>
<div class="admin-card">
<div class="admin-card-body" style="padding: 0;">
<table class="admin-table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Role</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($users as $user): ?>
<tr>
<td>
<strong><?= htmlspecialchars($user['name']) ?></strong>
<?php if ($user['user_id'] === $adminUser['user_id']): ?>
<span class="badge badge-primary">You</span>
<?php endif; ?>
</td>
<td><?= htmlspecialchars($user['email']) ?></td>
<td>
<?php if ($user['is_master']): ?>
<span class="badge badge-warning">Master Admin</span>
<?php else: ?>
<span class="badge badge-primary">Admin</span>
<?php endif; ?>
</td>
<td class="text-muted"><?= formatDate($user['created_at']) ?></td>
<td>
<button class="btn btn-sm btn-secondary" onclick='openUserModal(<?= json_encode($user) ?>)'>
<i class="fas fa-edit"></i>
</button>
<?php if ($user['user_id'] !== $adminUser['user_id']): ?>
<form method="POST" style="display: inline;">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="user_id" value="<?= $user['user_id'] ?>">
<button type="submit" class="btn btn-sm btn-danger" data-confirm="Delete this admin user?">
<i class="fas fa-trash"></i>
</button>
</form>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<!-- User Modal -->
<div class="modal-overlay" id="userModal">
<div class="modal">
<div class="modal-header">
<h3 class="modal-title" id="userModalTitle">Add Admin User</h3>
<button type="button" class="modal-close" onclick="Modal.close('userModal')">&times;</button>
</div>
<form method="POST" id="userForm">
<div class="modal-body">
<input type="hidden" name="action" id="userAction" value="create">
<input type="hidden" name="user_id" id="userId">
<div class="form-group">
<label class="form-label">Name *</label>
<input type="text" name="name" id="userName" class="form-input" required>
</div>
<div class="form-group">
<label class="form-label">Email *</label>
<input type="email" name="email" id="userEmail" class="form-input" required>
</div>
<div class="form-group">
<label class="form-label">Password <span id="passwordRequired">*</span></label>
<input type="password" name="password" id="userPassword" class="form-input">
<small class="text-muted" id="passwordHint">Leave blank to keep current password</small>
</div>
<div class="form-group">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" name="is_master" id="userMaster">
Master Admin (full access)
</label>
</div>
<div id="permissionsSection">
<h4 style="margin-bottom: 0.75rem; font-size: 0.9rem;">Permissions</h4>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem;">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" name="perm_dashboard" id="permDashboard" checked> Dashboard
</label>
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" name="perm_pos" id="permPos" checked> POS
</label>
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" name="perm_products" id="permProducts" checked> Products
</label>
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" name="perm_orders" id="permOrders" checked> Orders
</label>
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" name="perm_customers" id="permCustomers" checked> Customers
</label>
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" name="perm_settings" id="permSettings"> Settings
</label>
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" name="perm_admin" id="permAdmin"> Admin Users
</label>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="Modal.close('userModal')">Cancel</button>
<button type="submit" class="btn btn-primary" id="userSubmitBtn">Add User</button>
</div>
</form>
</div>
</div>
<script>
document.getElementById('userMaster').addEventListener('change', function() {
document.getElementById('permissionsSection').style.display = this.checked ? 'none' : 'block';
});
function openUserModal(user = null) {
const isEdit = !!user;
document.getElementById('userModalTitle').textContent = isEdit ? 'Edit Admin User' : 'Add Admin User';
document.getElementById('userSubmitBtn').textContent = isEdit ? 'Update User' : 'Add User';
document.getElementById('userAction').value = isEdit ? 'update' : 'create';
document.getElementById('userId').value = isEdit ? user.user_id : '';
document.getElementById('userName').value = isEdit ? user.name : '';
document.getElementById('userEmail').value = isEdit ? user.email : '';
document.getElementById('userPassword').value = '';
document.getElementById('userMaster').checked = isEdit ? user.is_master : false;
document.getElementById('passwordRequired').style.display = isEdit ? 'none' : 'inline';
document.getElementById('passwordHint').style.display = isEdit ? 'block' : 'none';
document.getElementById('userPassword').required = !isEdit;
if (isEdit) {
const perms = JSON.parse(user.permissions || '{}');
document.getElementById('permDashboard').checked = perms.dashboard ?? true;
document.getElementById('permPos').checked = perms.pos ?? true;
document.getElementById('permProducts').checked = perms.products ?? true;
document.getElementById('permOrders').checked = perms.orders ?? true;
document.getElementById('permCustomers').checked = perms.customers ?? true;
document.getElementById('permSettings').checked = perms.settings_payment ?? false;
document.getElementById('permAdmin').checked = perms.admin_management ?? false;
}
document.getElementById('permissionsSection').style.display =
document.getElementById('userMaster').checked ? 'none' : 'block';
Modal.open('userModal');
}
</script>
<?php require_once __DIR__ . '/includes/footer.php'; ?>
+121
View File
@@ -0,0 +1,121 @@
<?php
/**
* Tom's Java Jive - Cart API
*/
require_once __DIR__ . '/../includes/functions.php';
header('Content-Type: application/json');
$input = json_decode(file_get_contents('php://input'), true);
$action = $input['action'] ?? $_POST['action'] ?? '';
switch ($action) {
case 'add':
$productId = $input['product_id'] ?? '';
$quantity = intval($input['quantity'] ?? 1);
if (!$productId) {
jsonResponse(['error' => '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);
}
+119
View File
@@ -0,0 +1,119 @@
<?php
/**
* Tom's Java Jive - Create Stripe Checkout Session API
* Uses hosted checkout page (redirects to Stripe)
*/
require_once __DIR__ . '/../includes/functions.php';
require_once __DIR__ . '/../includes/stripe.php';
header('Content-Type: application/json');
// Only accept POST
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonResponse(['error' => '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);
}
+87
View File
@@ -0,0 +1,87 @@
<?php
/**
* Tom's Java Jive - Create Stripe Payment Intent API
* Uses cURL-based Stripe integration (no Composer required)
*/
require_once __DIR__ . '/../includes/functions.php';
require_once __DIR__ . '/../includes/stripe.php';
header('Content-Type: application/json');
// Only accept POST
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonResponse(['error' => '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);
}
+53
View File
@@ -0,0 +1,53 @@
<?php
/**
* Tom's Java Jive - Delete Account API
*/
require_once __DIR__ . '/../includes/functions.php';
require_once __DIR__ . '/../includes/auth.php';
if (!CustomerAuth::isLoggedIn()) {
redirect('/login.php');
}
$customer = CustomerAuth::getFullUser();
try {
// Start transaction
db()->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');
}
+94
View File
@@ -0,0 +1,94 @@
<?php
/**
* Tom's Java Jive - Loyalty Points API
*/
header('Content-Type: application/json');
require_once __DIR__ . '/../includes/functions.php';
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/loyalty.php';
if (!CustomerAuth::isLoggedIn()) {
jsonResponse(['error' => '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);
}
+174
View File
@@ -0,0 +1,174 @@
<?php
/**
* Tom's Java Jive - Orders API
*/
require_once __DIR__ . '/../includes/functions.php';
require_once __DIR__ . '/../includes/auth.php';
header('Content-Type: application/json');
$method = $_SERVER['REQUEST_METHOD'];
$input = json_decode(file_get_contents('php://input'), true);
$action = $input['action'] ?? $_POST['action'] ?? '';
switch ($method) {
case 'GET':
// Get order(s)
$orderId = $_GET['id'] ?? '';
$orderNumber = $_GET['number'] ?? '';
if ($orderId) {
$order = db()->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 ? "<p><strong>Tracking #:</strong> {$trackingNumber}</p>" : '';
$html = <<<HTML
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: #8B4513; color: white; padding: 20px; text-align: center;">
<h1 style="margin: 0;">Tom's Java Jive</h1>
</div>
<div style="padding: 30px; background: #FDFBF7;">
<h2>{$statusMessages[$status]}</h2>
<p>Hi {$order['customer_name']},</p>
<p>Order <strong>#{$order['order_number']}</strong> has been updated to: <strong>{$status}</strong></p>
{$tracking}
</div>
</div>
HTML;
sendEmail($order['customer_email'], "Order Update - #{$order['order_number']}", $html);
}
+136
View File
@@ -0,0 +1,136 @@
<?php
/**
* Tom's Java Jive - Check Payment Status API
* Polls Stripe for payment/checkout session status
*/
require_once __DIR__ . '/../includes/functions.php';
require_once __DIR__ . '/../includes/stripe.php';
header('Content-Type: application/json');
// Only accept GET
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
jsonResponse(['error' => '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'
]);
}
+191
View File
@@ -0,0 +1,191 @@
<?php
/**
* Tom's Java Jive - POS Order API
* Creates orders from the POS system
*/
header('Content-Type: application/json');
require_once __DIR__ . '/../includes/functions.php';
require_once __DIR__ . '/../includes/auth.php';
// Only accept POST
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonResponse(['error' => '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);
}
+93
View File
@@ -0,0 +1,93 @@
<?php
/**
* Tom's Java Jive - Products API
*/
require_once __DIR__ . '/../includes/functions.php';
header('Content-Type: application/json');
$method = $_SERVER['REQUEST_METHOD'];
if ($method === 'GET') {
$productId = $_GET['id'] ?? null;
if ($productId) {
// Get single product
$product = db()->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);
+83
View File
@@ -0,0 +1,83 @@
<?php
/**
* Tom's Java Jive - Push Subscription API
*/
header('Content-Type: application/json');
require_once __DIR__ . '/../includes/functions.php';
require_once __DIR__ . '/../includes/auth.php';
$method = $_SERVER['REQUEST_METHOD'];
$input = json_decode(file_get_contents('php://input'), true);
switch ($method) {
case 'POST':
// Subscribe to push notifications
$endpoint = $input['endpoint'] ?? '';
$p256dh = $input['keys']['p256dh'] ?? '';
$auth = $input['keys']['auth'] ?? '';
if (empty($endpoint) || empty($p256dh) || empty($auth)) {
jsonResponse(['error' => '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);
}
+97
View File
@@ -0,0 +1,97 @@
<?php
/**
* Tom's Java Jive - Redeem Gift Card API
*/
header('Content-Type: application/json');
require_once __DIR__ . '/../includes/functions.php';
require_once __DIR__ . '/../includes/auth.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonResponse(['error' => '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);
}
+25
View File
@@ -0,0 +1,25 @@
<?php
/**
* Tom's Java Jive - Customer Search API
*/
header('Content-Type: application/json');
require_once __DIR__ . '/../includes/functions.php';
$query = $_GET['q'] ?? '';
if (strlen($query) < 2) {
jsonResponse([]);
}
$customers = db()->fetchAll(
"SELECT customer_id, email, name, phone, wallet_balance, reward_points
FROM customers
WHERE (email LIKE :q OR name LIKE :q OR phone LIKE :q) AND is_active = 1
ORDER BY name ASC
LIMIT 20",
['q' => '%' . $query . '%']
);
jsonResponse($customers);
+66
View File
@@ -0,0 +1,66 @@
<?php
/**
* Tom's Java Jive - Submit Review API
*/
header('Content-Type: application/json');
require_once __DIR__ . '/../includes/functions.php';
require_once __DIR__ . '/../includes/auth.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonResponse(['error' => '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
]);
+75
View File
@@ -0,0 +1,75 @@
<?php
/**
* Tom's Java Jive - Newsletter Subscribe API
*/
require_once __DIR__ . '/../includes/functions.php';
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonResponse(['error' => '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 = <<<HTML
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: #8B4513; color: white; padding: 20px; text-align: center;">
<h1 style="margin: 0;">Tom's Java Jive</h1>
</div>
<div style="padding: 30px; background: #FDFBF7;">
<h2>Welcome to the Java Jive Family!</h2>
<p>Thanks for subscribing to our newsletter. You'll be the first to know about:</p>
<ul>
<li>New coffee releases</li>
<li>Exclusive discounts and promotions</li>
<li>Brewing tips and recipes</li>
<li>Behind-the-scenes at our roastery</li>
</ul>
<p>As a thank you, enjoy <strong>10% off</strong> your first order with code: <strong>WELCOME10</strong></p>
<p style="text-align: center; margin-top: 20px;">
<a href="https://tomsjavajive.com/shop.php" style="background: #E86A33; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px;">Shop Now</a>
</p>
</div>
<div style="padding: 20px; text-align: center; color: #666; font-size: 12px;">
<p>Tom's Java Jive | Premium Coffee</p>
</div>
</div>
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);
}
+65
View File
@@ -0,0 +1,65 @@
<?php
/**
* Tom's Java Jive - Test Notification API
*/
header('Content-Type: application/json');
require_once __DIR__ . '/../includes/functions.php';
require_once __DIR__ . '/../includes/email.php';
require_once __DIR__ . '/../includes/sms.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonResponse(['error' => '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",
"<div style='font-family: Arial, sans-serif; max-width: 500px; margin: 0 auto; padding: 20px;'>
<h2 style='color: #FF5E1A;'>Test Email</h2>
<p>This is a test email from your Tom's Java Jive store.</p>
<p>If you received this, your SendGrid integration is working correctly!</p>
<p style='color: #666; font-size: 0.9em; margin-top: 30px;'>
Sent at: " . date('Y-m-d H:i:s') . "
</p>
</div>"
);
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);
}
+59
View File
@@ -0,0 +1,59 @@
<?php
/**
* Tom's Java Jive - Coupon Validation API
*/
header('Content-Type: application/json');
require_once __DIR__ . '/../includes/functions.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonResponse(['error' => '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'
]);
+147
View File
@@ -0,0 +1,147 @@
<?php
/**
* Tom's Java Jive - Stripe Webhook Handler
* Uses cURL-based Stripe integration (no Composer required)
*/
require_once __DIR__ . '/../includes/functions.php';
require_once __DIR__ . '/../includes/stripe.php';
header('Content-Type: application/json');
$payload = file_get_contents('php://input');
$sigHeader = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
// Verify webhook signature (if secret is configured)
if (!empty(STRIPE_WEBHOOK_SECRET) && STRIPE_WEBHOOK_SECRET !== 'whsec_your_webhook_secret') {
try {
stripe()->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(
'<tr><td>%s x%d</td><td style="text-align:right;">$%.2f</td></tr>',
htmlspecialchars($item['name']),
$item['quantity'],
$item['total']
);
}
$html = <<<HTML
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: #8B4513; color: white; padding: 20px; text-align: center;">
<h1 style="margin: 0;">Tom's Java Jive</h1>
</div>
<div style="padding: 30px; background: #FDFBF7;">
<h2>Order Confirmed!</h2>
<p>Thank you for your order, {$order['customer_name']}!</p>
<div style="background: white; padding: 20px; border-radius: 8px; margin: 20px 0;">
<p><strong>Order #:</strong> {$order['order_number']}</p>
<p><strong>Total:</strong> \${$order['total']}</p>
</div>
<h3>Order Details</h3>
<table style="width: 100%; border-collapse: collapse;">
{$itemsHtml}
<tr style="border-top: 2px solid #ccc;">
<td><strong>Total</strong></td>
<td style="text-align:right;"><strong>\${$order['total']}</strong></td>
</tr>
</table>
<h3>Shipping To</h3>
<p>
{$shippingAddress['address']}<br>
{$shippingAddress['city']}, {$shippingAddress['state']} {$shippingAddress['zip']}
</p>
<p style="color: #666; font-size: 14px;">
We'll send you tracking information once your order ships.
</p>
</div>
<div style="padding: 20px; text-align: center; color: #666; font-size: 12px;">
<p>Tom's Java Jive | Premium Coffee</p>
</div>
</div>
HTML;
sendEmail($order['customer_email'], "Order Confirmed - #{$order['order_number']}", $html);
}
+95
View File
@@ -0,0 +1,95 @@
<?php
/**
* Tom's Java Jive - Wishlist API
*/
header('Content-Type: application/json');
require_once __DIR__ . '/../includes/functions.php';
require_once __DIR__ . '/../includes/auth.php';
if (!CustomerAuth::isLoggedIn()) {
jsonResponse(['error' => '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);
}
+543
View File
@@ -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);
}
}
+850
View File
@@ -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; }
}
/* ────────────────────────────────────────────────── */
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="72" height="72" viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
<circle cx="36" cy="36" r="36" fill="#FF5E1A"/>
<text x="36" y="45" font-family="Arial, sans-serif" font-size="28" font-weight="bold" fill="white" text-anchor="middle">TJ</text>
</svg>

After

Width:  |  Height:  |  Size: 311 B

+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
<rect width="128" height="128" rx="19" fill="#FF5E1A"/>
<g transform="translate(12, 12) scale(1.28)">
<path fill="#FFF8F0" d="M60 20H20c-2.2 0-4 1.8-4 4v44c0 6.6 5.4 12 12 12h24c6.6 0 12-5.4 12-12V24c0-2.2-1.8-4-4-4zm-8 4v4H28v-4h24zm8 44c0 4.4-3.6 8-8 8H28c-4.4 0-8-3.6-8-8V32h40v36z"/>
<path fill="#FFF8F0" d="M72 32h-4v8h4c2.2 0 4 1.8 4 4v8c0 2.2-1.8 4-4 4h-4v8h4c6.6 0 12-5.4 12-12v-8c0-6.6-5.4-12-12-12z"/>
<path fill="#FFF8F0" opacity="0.6" d="M32 12c0-2.2 1.8-4 4-4s4 1.8 4 4v4h-8v-4zm12 0c0-2.2 1.8-4 4-4s4 1.8 4 4v4h-8v-4zm12 0c0-2.2 1.8-4 4-4s4 1.8 4 4v4h-8v-4z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 731 B

+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="144" height="144" viewBox="0 0 144 144" xmlns="http://www.w3.org/2000/svg">
<rect width="144" height="144" rx="21" fill="#FF5E1A"/>
<g transform="translate(14, 14) scale(1.44)">
<path fill="#FFF8F0" d="M60 20H20c-2.2 0-4 1.8-4 4v44c0 6.6 5.4 12 12 12h24c6.6 0 12-5.4 12-12V24c0-2.2-1.8-4-4-4zm-8 4v4H28v-4h24zm8 44c0 4.4-3.6 8-8 8H28c-4.4 0-8-3.6-8-8V32h40v36z"/>
<path fill="#FFF8F0" d="M72 32h-4v8h4c2.2 0 4 1.8 4 4v8c0 2.2-1.8 4-4 4h-4v8h4c6.6 0 12-5.4 12-12v-8c0-6.6-5.4-12-12-12z"/>
<path fill="#FFF8F0" opacity="0.6" d="M32 12c0-2.2 1.8-4 4-4s4 1.8 4 4v4h-8v-4zm12 0c0-2.2 1.8-4 4-4s4 1.8 4 4v4h-8v-4zm12 0c0-2.2 1.8-4 4-4s4 1.8 4 4v4h-8v-4z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 731 B

+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="152" height="152" viewBox="0 0 152 152" xmlns="http://www.w3.org/2000/svg">
<rect width="152" height="152" rx="22" fill="#FF5E1A"/>
<g transform="translate(15, 15) scale(1.52)">
<path fill="#FFF8F0" d="M60 20H20c-2.2 0-4 1.8-4 4v44c0 6.6 5.4 12 12 12h24c6.6 0 12-5.4 12-12V24c0-2.2-1.8-4-4-4zm-8 4v4H28v-4h24zm8 44c0 4.4-3.6 8-8 8H28c-4.4 0-8-3.6-8-8V32h40v36z"/>
<path fill="#FFF8F0" d="M72 32h-4v8h4c2.2 0 4 1.8 4 4v8c0 2.2-1.8 4-4 4h-4v8h4c6.6 0 12-5.4 12-12v-8c0-6.6-5.4-12-12-12z"/>
<path fill="#FFF8F0" opacity="0.6" d="M32 12c0-2.2 1.8-4 4-4s4 1.8 4 4v4h-8v-4zm12 0c0-2.2 1.8-4 4-4s4 1.8 4 4v4h-8v-4zm12 0c0-2.2 1.8-4 4-4s4 1.8 4 4v4h-8v-4z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 731 B

+8
View File
@@ -0,0 +1,8 @@
<svg width="192" height="192" viewBox="0 0 192 192" xmlns="http://www.w3.org/2000/svg">
<rect width="192" height="192" rx="28" fill="#FF5E1A"/>
<g transform="translate(19, 19) scale(1.54)">
<path fill="#FFF8F0" d="M60 20H20c-2.2 0-4 1.8-4 4v44c0 6.6 5.4 12 12 12h24c6.6 0 12-5.4 12-12V24c0-2.2-1.8-4-4-4zm-8 4v4H28v-4h24zm8 44c0 4.4-3.6 8-8 8H28c-4.4 0-8-3.6-8-8V32h40v36z"/>
<path fill="#FFF8F0" d="M72 32h-4v8h4c2.2 0 4 1.8 4 4v8c0 2.2-1.8 4-4 4h-4v8h4c6.6 0 12-5.4 12-12v-8c0-6.6-5.4-12-12-12z"/>
<path fill="#FFF8F0" opacity="0.6" d="M32 12c0-2.2 1.8-4 4-4s4 1.8 4 4v4h-8v-4zm12 0c0-2.2 1.8-4 4-4s4 1.8 4 4v4h-8v-4zm12 0c0-2.2 1.8-4 4-4s4 1.8 4 4v4h-8v-4z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 692 B

+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="192" height="192" viewBox="0 0 192 192" xmlns="http://www.w3.org/2000/svg">
<rect width="192" height="192" rx="28" fill="#FF5E1A"/>
<g transform="translate(19, 19) scale(1.92)">
<path fill="#FFF8F0" d="M60 20H20c-2.2 0-4 1.8-4 4v44c0 6.6 5.4 12 12 12h24c6.6 0 12-5.4 12-12V24c0-2.2-1.8-4-4-4zm-8 4v4H28v-4h24zm8 44c0 4.4-3.6 8-8 8H28c-4.4 0-8-3.6-8-8V32h40v36z"/>
<path fill="#FFF8F0" d="M72 32h-4v8h4c2.2 0 4 1.8 4 4v8c0 2.2-1.8 4-4 4h-4v8h4c6.6 0 12-5.4 12-12v-8c0-6.6-5.4-12-12-12z"/>
<path fill="#FFF8F0" opacity="0.6" d="M32 12c0-2.2 1.8-4 4-4s4 1.8 4 4v4h-8v-4zm12 0c0-2.2 1.8-4 4-4s4 1.8 4 4v4h-8v-4zm12 0c0-2.2 1.8-4 4-4s4 1.8 4 4v4h-8v-4z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 731 B

+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="384" height="384" viewBox="0 0 384 384" xmlns="http://www.w3.org/2000/svg">
<rect width="384" height="384" rx="57" fill="#FF5E1A"/>
<g transform="translate(38, 38) scale(3.84)">
<path fill="#FFF8F0" d="M60 20H20c-2.2 0-4 1.8-4 4v44c0 6.6 5.4 12 12 12h24c6.6 0 12-5.4 12-12V24c0-2.2-1.8-4-4-4zm-8 4v4H28v-4h24zm8 44c0 4.4-3.6 8-8 8H28c-4.4 0-8-3.6-8-8V32h40v36z"/>
<path fill="#FFF8F0" d="M72 32h-4v8h4c2.2 0 4 1.8 4 4v8c0 2.2-1.8 4-4 4h-4v8h4c6.6 0 12-5.4 12-12v-8c0-6.6-5.4-12-12-12z"/>
<path fill="#FFF8F0" opacity="0.6" d="M32 12c0-2.2 1.8-4 4-4s4 1.8 4 4v4h-8v-4zm12 0c0-2.2 1.8-4 4-4s4 1.8 4 4v4h-8v-4zm12 0c0-2.2 1.8-4 4-4s4 1.8 4 4v4h-8v-4z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 731 B

+8
View File
@@ -0,0 +1,8 @@
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<rect width="512" height="512" rx="76" fill="#FF5E1A"/>
<g transform="translate(51, 51) scale(4.1)">
<path fill="#FFF8F0" d="M60 20H20c-2.2 0-4 1.8-4 4v44c0 6.6 5.4 12 12 12h24c6.6 0 12-5.4 12-12V24c0-2.2-1.8-4-4-4zm-8 4v4H28v-4h24zm8 44c0 4.4-3.6 8-8 8H28c-4.4 0-8-3.6-8-8V32h40v36z"/>
<path fill="#FFF8F0" d="M72 32h-4v8h4c2.2 0 4 1.8 4 4v8c0 2.2-1.8 4-4 4h-4v8h4c6.6 0 12-5.4 12-12v-8c0-6.6-5.4-12-12-12z"/>
<path fill="#FFF8F0" opacity="0.6" d="M32 12c0-2.2 1.8-4 4-4s4 1.8 4 4v4h-8v-4zm12 0c0-2.2 1.8-4 4-4s4 1.8 4 4v4h-8v-4zm12 0c0-2.2 1.8-4 4-4s4 1.8 4 4v4h-8v-4z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 691 B

+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<rect width="512" height="512" rx="76" fill="#FF5E1A"/>
<g transform="translate(51, 51) scale(5.12)">
<path fill="#FFF8F0" d="M60 20H20c-2.2 0-4 1.8-4 4v44c0 6.6 5.4 12 12 12h24c6.6 0 12-5.4 12-12V24c0-2.2-1.8-4-4-4zm-8 4v4H28v-4h24zm8 44c0 4.4-3.6 8-8 8H28c-4.4 0-8-3.6-8-8V32h40v36z"/>
<path fill="#FFF8F0" d="M72 32h-4v8h4c2.2 0 4 1.8 4 4v8c0 2.2-1.8 4-4 4h-4v8h4c6.6 0 12-5.4 12-12v-8c0-6.6-5.4-12-12-12z"/>
<path fill="#FFF8F0" opacity="0.6" d="M32 12c0-2.2 1.8-4 4-4s4 1.8 4 4v4h-8v-4zm12 0c0-2.2 1.8-4 4-4s4 1.8 4 4v4h-8v-4zm12 0c0-2.2 1.8-4 4-4s4 1.8 4 4v4h-8v-4z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 731 B

+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="72" height="72" viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
<rect width="72" height="72" rx="10" fill="#FF5E1A"/>
<g transform="translate(7, 7) scale(0.72)">
<path fill="#FFF8F0" d="M60 20H20c-2.2 0-4 1.8-4 4v44c0 6.6 5.4 12 12 12h24c6.6 0 12-5.4 12-12V24c0-2.2-1.8-4-4-4zm-8 4v4H28v-4h24zm8 44c0 4.4-3.6 8-8 8H28c-4.4 0-8-3.6-8-8V32h40v36z"/>
<path fill="#FFF8F0" d="M72 32h-4v8h4c2.2 0 4 1.8 4 4v8c0 2.2-1.8 4-4 4h-4v8h4c6.6 0 12-5.4 12-12v-8c0-6.6-5.4-12-12-12z"/>
<path fill="#FFF8F0" opacity="0.6" d="M32 12c0-2.2 1.8-4 4-4s4 1.8 4 4v4h-8v-4zm12 0c0-2.2 1.8-4 4-4s4 1.8 4 4v4h-8v-4zm12 0c0-2.2 1.8-4 4-4s4 1.8 4 4v4h-8v-4z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 723 B

+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="96" height="96" viewBox="0 0 96 96" xmlns="http://www.w3.org/2000/svg">
<rect width="96" height="96" rx="14" fill="#FF5E1A"/>
<g transform="translate(9, 9) scale(0.96)">
<path fill="#FFF8F0" d="M60 20H20c-2.2 0-4 1.8-4 4v44c0 6.6 5.4 12 12 12h24c6.6 0 12-5.4 12-12V24c0-2.2-1.8-4-4-4zm-8 4v4H28v-4h24zm8 44c0 4.4-3.6 8-8 8H28c-4.4 0-8-3.6-8-8V32h40v36z"/>
<path fill="#FFF8F0" d="M72 32h-4v8h4c2.2 0 4 1.8 4 4v8c0 2.2-1.8 4-4 4h-4v8h4c6.6 0 12-5.4 12-12v-8c0-6.6-5.4-12-12-12z"/>
<path fill="#FFF8F0" opacity="0.6" d="M32 12c0-2.2 1.8-4 4-4s4 1.8 4 4v4h-8v-4zm12 0c0-2.2 1.8-4 4-4s4 1.8 4 4v4h-8v-4zm12 0c0-2.2 1.8-4 4-4s4 1.8 4 4v4h-8v-4z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 723 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

+312
View File
@@ -0,0 +1,312 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5" />
<meta name="theme-color" content="#8B4513" />
<!-- Primary SEO Meta Tags -->
<title>Tom's Java Jive | Premium Coffee Beans & Fresh Roasted Coffee | Weatherford TX</title>
<meta name="title" content="Tom's Java Jive | Premium Coffee Beans & Fresh Roasted Coffee | Weatherford TX" />
<meta name="description" content="Shop premium coffee beans and freshly roasted coffee grounds at Tom's Java Jive. Hand-selected, expertly roasted, delivered fresh to your door. Located in Weatherford, Texas. Free shipping on orders over $50!" />
<meta name="keywords" content="coffee beans, fresh roasted coffee, premium coffee, coffee grounds, Weatherford Texas coffee, coffee shop, espresso beans, arabica coffee, single origin coffee, coffee subscription, gift cards, local coffee roaster" />
<meta name="author" content="Tom's Java Jive" />
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1" />
<meta name="googlebot" content="index, follow" />
<meta name="bingbot" content="index, follow" />
<link rel="canonical" href="https://tomsjavaJive.com" />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://tomsjavaJive.com/" />
<meta property="og:title" content="Tom's Java Jive | Premium Coffee Beans & Fresh Roasted Coffee" />
<meta property="og:description" content="Shop premium coffee beans and freshly roasted coffee grounds. Hand-selected, expertly roasted, delivered fresh to your door. Free shipping on orders over $50!" />
<meta property="og:image" content="https://tomsjavaJive.com/og-image.jpg" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:locale" content="en_US" />
<meta property="og:site_name" content="Tom's Java Jive" />
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content="https://tomsjavaJive.com/" />
<meta property="twitter:title" content="Tom's Java Jive | Premium Coffee Beans & Fresh Roasted Coffee" />
<meta property="twitter:description" content="Shop premium coffee beans and freshly roasted coffee grounds. Hand-selected, expertly roasted, delivered fresh to your door." />
<meta property="twitter:image" content="https://tomsjavaJive.com/og-image.jpg" />
<!-- Additional SEO Meta Tags -->
<meta name="geo.region" content="US-TX" />
<meta name="geo.placename" content="Weatherford" />
<meta name="geo.position" content="32.7593;-97.7972" />
<meta name="ICBM" content="32.7593, -97.7972" />
<meta name="rating" content="General" />
<meta name="revisit-after" content="7 days" />
<meta name="distribution" content="global" />
<!-- PWA Meta Tags -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Java Jive" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="application-name" content="Tom's Java Jive" />
<meta name="msapplication-TileColor" content="#8B4513" />
<meta name="msapplication-config" content="/browserconfig.xml" />
<link rel="apple-touch-icon" href="/icons/icon-192x192.png" />
<link rel="apple-touch-icon" sizes="152x152" href="/icons/icon-152x152.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/icons/icon-180x180.png" />
<link rel="apple-touch-icon" sizes="167x167" href="/icons/icon-167x167.png" />
<link rel="manifest" href="/manifest.json" />
<!-- Preconnect for Performance -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="dns-prefetch" href="https://fonts.googleapis.com" />
<link rel="dns-prefetch" href="https://js.stripe.com" />
<!-- Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@400;500;600;700&display=swap" rel="stylesheet" />
<!-- Favicon -->
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" type="image/png" sizes="32x32" href="/icons/icon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/icons/icon-16x16.png" />
<!-- Structured Data - Local Business -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "CoffeeStore",
"name": "Tom's Java Jive",
"description": "Premium coffee beans and freshly roasted coffee grounds. Hand-selected, expertly roasted, delivered fresh to your door.",
"url": "https://tomsjavaJive.com",
"telephone": "+1-817-266-2022",
"email": "hello@tomsjavaJive.com",
"address": {
"@type": "PostalAddress",
"streetAddress": "",
"addressLocality": "Weatherford",
"addressRegion": "TX",
"postalCode": "",
"addressCountry": "US"
},
"geo": {
"@type": "GeoCoordinates",
"latitude": 32.7593,
"longitude": -97.7972
},
"openingHoursSpecification": [
{
"@type": "OpeningHoursSpecification",
"dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
"opens": "07:00",
"closes": "18:00"
},
{
"@type": "OpeningHoursSpecification",
"dayOfWeek": ["Saturday"],
"opens": "08:00",
"closes": "16:00"
}
],
"priceRange": "$$",
"servesCuisine": "Coffee",
"paymentAccepted": "Cash, Credit Card, Debit Card, Apple Pay, Google Pay",
"currenciesAccepted": "USD",
"hasMenu": "https://tomsjavaJive.com/shop",
"sameAs": [
"https://facebook.com/tomsjavaJive",
"https://instagram.com/tomsjavaJive",
"https://twitter.com/tomsjavaJive"
],
"image": "https://tomsjavaJive.com/og-image.jpg",
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "4.8",
"reviewCount": "127"
}
}
</script>
<!-- Structured Data - Website -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "Tom's Java Jive",
"url": "https://tomsjavaJive.com",
"potentialAction": {
"@type": "SearchAction",
"target": "https://tomsjavaJive.com/shop?search={search_term_string}",
"query-input": "required name=search_term_string"
}
}
</script>
<!-- Structured Data - Organization -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Organization",
"name": "Tom's Java Jive",
"url": "https://tomsjavaJive.com",
"logo": "https://tomsjavaJive.com/icons/icon-512x512.png",
"contactPoint": {
"@type": "ContactPoint",
"telephone": "+1-817-266-2022",
"contactType": "customer service",
"availableLanguage": ["English", "Spanish"]
}
}
</script>
<script>window.addEventListener("error",function(e){if(e.error instanceof DOMException&&e.error.name==="DataCloneError"&&e.message&&e.message.includes("PerformanceServerTiming")){e.stopImmediatePropagation();e.preventDefault()}},true);</script>
<script src="https://assets.emergent.sh/scripts/emergent-main.js"></script>
<!-- Register Service Worker -->
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/service-worker.js')
.then(reg => console.log('SW registered'))
.catch(err => console.log('SW registration failed:', err));
});
}
</script>
<script defer src="/static/js/bundle.js"></script><script>
if(window.self!==window.top){
var s=document.createElement("script");s.src="/visual-edit-overlay.js";document.head.appendChild(s);
window.tailwind=window.tailwind||{};tailwind.config={corePlugins:{preflight:false}};var t=document.createElement("script");t.src="https://cdn.tailwindcss.com";document.head.appendChild(t);
}
</script>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
<a
id="emergent-badge"
target="_blank"
href="https://app.emergent.sh/?utm_source=emergent-badge"
style="
display: inline-flex !important;
box-sizing: border-box;
width: 178px;
height: 40px;
padding: 8px 12px 8px 12px;
align-items: center !important;
gap: 8px;
border-radius: 50px !important;
background: #000 !important;
position: fixed !important;
bottom: 16px;
right: 16px;
text-decoration: none;
font-family: -apple-system, BlinkMacSystemFont,
&quot;Segoe UI&quot;, Roboto, Oxygen, Ubuntu, Cantarell,
&quot;Open Sans&quot;, &quot;Helvetica Neue&quot;,
sans-serif !important;
font-size: 12px !important;
z-index: 9999 !important;
"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M15.5702 8.13142C15.7729 8.0412 16.0007 8.18878 15.9892 8.4103C15.8374 11.3192 14.0965 14.0405 11.2531 15.3065C8.40964 16.5725 5.2224 16.0453 2.95912 14.2117C2.78676 14.072 2.82955 13.804 3.03219 13.7137L4.95677 12.8568C5.04866 12.8159 5.15446 12.823 5.24204 12.8725C6.73377 13.7153 8.59176 13.8649 10.2772 13.1145C11.9626 12.3641 13.0947 10.8833 13.4665 9.21075C13.4883 9.11256 13.5539 9.02918 13.6457 8.98827L15.5702 8.13142Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.3066 4.74698L15.5067 5.19653C15.5759 5.35178 15.5061 5.53366 15.3508 5.60278L1.29992 11.8586C1.14467 11.9278 0.962794 11.8579 0.893675 11.7027L0.701732 11.2716L0.693457 11.2531C-1.10317 7.21778 0.711626 2.49007 4.74692 0.693443C8.78221 -1.10318 13.51 0.711693 15.3066 4.74698ZM2.82356 8.55367C2.63552 8.63739 2.41991 8.51617 2.40853 8.31065C2.28373 6.05724 3.53858 3.85787 5.72286 2.88536C7.90715 1.91286 10.3813 2.45199 11.9724 4.05256C12.1175 4.19854 12.0633 4.43988 11.8753 4.5236L2.82356 8.55367Z" fill="white"/>
</svg>
<p
style="
color: #FFF !important;
font-family: 'Inter', sans-serif !important;
font-size: 13px !important;
font-style: normal !important;
font-weight: 600 !important;
line-height: 20px !important;
margin: 0 !important;
white-space: nowrap !important;
"
>
Made with Emergent
</p>
</a>
<script>
!(function (t, e) {
var o, n, p, r;
e.__SV ||
((window.posthog = e),
(e._i = []),
(e.init = function (i, s, a) {
function g(t, e) {
var o = e.split(".");
2 == o.length && ((t = t[o[0]]), (e = o[1])),
(t[e] = function () {
t.push(
[e].concat(
Array.prototype.slice.call(
arguments,
0,
),
),
);
});
}
((p = t.createElement("script")).type =
"text/javascript"),
(p.crossOrigin = "anonymous"),
(p.async = !0),
(p.src =
s.api_host.replace(
".i.posthog.com",
"-assets.i.posthog.com",
) + "/static/array.js"),
(r =
t.getElementsByTagName(
"script",
)[0]).parentNode.insertBefore(p, r);
var u = e;
for (
void 0 !== a ? (u = e[a] = []) : (a = "posthog"),
u.people = u.people || [],
u.toString = function (t) {
var e = "posthog";
return (
"posthog" !== a && (e += "." + a),
t || (e += " (stub)"),
e
);
},
u.people.toString = function () {
return u.toString(1) + ".people (stub)";
},
o =
"init me ws ys ps bs capture je Di ks register register_once register_for_session unregister unregister_for_session Ps getFeatureFlag getFeatureFlagPayload isFeatureEnabled reloadFeatureFlags updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures on onFeatureFlags onSurveysLoaded onSessionId getSurveys getActiveMatchingSurveys renderSurvey canRenderSurvey canRenderSurveyAsync identify setPersonProperties group resetGroups setPersonPropertiesForFlags resetPersonPropertiesForFlags setGroupPropertiesForFlags resetGroupPropertiesForFlags reset get_distinct_id getGroups get_session_id get_session_replay_url alias set_config startSessionRecording stopSessionRecording sessionRecordingStarted captureException loadToolbar get_property getSessionProperty Es $s createPersonProfile Is opt_in_capturing opt_out_capturing has_opted_in_capturing has_opted_out_capturing clear_opt_in_out_capturing Ss debug xs getPageViewId captureTraceFeedback captureTraceMetric".split(
" ",
),
n = 0;
n < o.length;
n++
)
g(u, o[n]);
e._i.push([i, s, a]);
}),
(e.__SV = 1));
})(document, window.posthog || []);
posthog.init("phc_xAvL2Iq4tFmANRE7kzbKwaSqp1HJjN7x48s3vr0CMjs", {
api_host: "https://us.i.posthog.com",
person_profiles: "identified_only", // or 'always' to create profiles for anonymous users as well,
session_recording: {
recordCrossOriginIframes: true,
capturePerformance: false,
},
});
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

+312
View File
@@ -0,0 +1,312 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5" />
<meta name="theme-color" content="#8B4513" />
<!-- Primary SEO Meta Tags -->
<title>Tom's Java Jive | Premium Coffee Beans & Fresh Roasted Coffee | Weatherford TX</title>
<meta name="title" content="Tom's Java Jive | Premium Coffee Beans & Fresh Roasted Coffee | Weatherford TX" />
<meta name="description" content="Shop premium coffee beans and freshly roasted coffee grounds at Tom's Java Jive. Hand-selected, expertly roasted, delivered fresh to your door. Located in Weatherford, Texas. Free shipping on orders over $50!" />
<meta name="keywords" content="coffee beans, fresh roasted coffee, premium coffee, coffee grounds, Weatherford Texas coffee, coffee shop, espresso beans, arabica coffee, single origin coffee, coffee subscription, gift cards, local coffee roaster" />
<meta name="author" content="Tom's Java Jive" />
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1" />
<meta name="googlebot" content="index, follow" />
<meta name="bingbot" content="index, follow" />
<link rel="canonical" href="https://tomsjavaJive.com" />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://tomsjavaJive.com/" />
<meta property="og:title" content="Tom's Java Jive | Premium Coffee Beans & Fresh Roasted Coffee" />
<meta property="og:description" content="Shop premium coffee beans and freshly roasted coffee grounds. Hand-selected, expertly roasted, delivered fresh to your door. Free shipping on orders over $50!" />
<meta property="og:image" content="https://tomsjavaJive.com/og-image.jpg" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:locale" content="en_US" />
<meta property="og:site_name" content="Tom's Java Jive" />
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content="https://tomsjavaJive.com/" />
<meta property="twitter:title" content="Tom's Java Jive | Premium Coffee Beans & Fresh Roasted Coffee" />
<meta property="twitter:description" content="Shop premium coffee beans and freshly roasted coffee grounds. Hand-selected, expertly roasted, delivered fresh to your door." />
<meta property="twitter:image" content="https://tomsjavaJive.com/og-image.jpg" />
<!-- Additional SEO Meta Tags -->
<meta name="geo.region" content="US-TX" />
<meta name="geo.placename" content="Weatherford" />
<meta name="geo.position" content="32.7593;-97.7972" />
<meta name="ICBM" content="32.7593, -97.7972" />
<meta name="rating" content="General" />
<meta name="revisit-after" content="7 days" />
<meta name="distribution" content="global" />
<!-- PWA Meta Tags -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Java Jive" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="application-name" content="Tom's Java Jive" />
<meta name="msapplication-TileColor" content="#8B4513" />
<meta name="msapplication-config" content="/browserconfig.xml" />
<link rel="apple-touch-icon" href="/icons/icon-192x192.png" />
<link rel="apple-touch-icon" sizes="152x152" href="/icons/icon-152x152.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/icons/icon-180x180.png" />
<link rel="apple-touch-icon" sizes="167x167" href="/icons/icon-167x167.png" />
<link rel="manifest" href="/manifest.json" />
<!-- Preconnect for Performance -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="dns-prefetch" href="https://fonts.googleapis.com" />
<link rel="dns-prefetch" href="https://js.stripe.com" />
<!-- Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@400;500;600;700&display=swap" rel="stylesheet" />
<!-- Favicon -->
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" type="image/png" sizes="32x32" href="/icons/icon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/icons/icon-16x16.png" />
<!-- Structured Data - Local Business -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "CoffeeStore",
"name": "Tom's Java Jive",
"description": "Premium coffee beans and freshly roasted coffee grounds. Hand-selected, expertly roasted, delivered fresh to your door.",
"url": "https://tomsjavaJive.com",
"telephone": "+1-817-266-2022",
"email": "hello@tomsjavaJive.com",
"address": {
"@type": "PostalAddress",
"streetAddress": "",
"addressLocality": "Weatherford",
"addressRegion": "TX",
"postalCode": "",
"addressCountry": "US"
},
"geo": {
"@type": "GeoCoordinates",
"latitude": 32.7593,
"longitude": -97.7972
},
"openingHoursSpecification": [
{
"@type": "OpeningHoursSpecification",
"dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
"opens": "07:00",
"closes": "18:00"
},
{
"@type": "OpeningHoursSpecification",
"dayOfWeek": ["Saturday"],
"opens": "08:00",
"closes": "16:00"
}
],
"priceRange": "$$",
"servesCuisine": "Coffee",
"paymentAccepted": "Cash, Credit Card, Debit Card, Apple Pay, Google Pay",
"currenciesAccepted": "USD",
"hasMenu": "https://tomsjavaJive.com/shop",
"sameAs": [
"https://facebook.com/tomsjavaJive",
"https://instagram.com/tomsjavaJive",
"https://twitter.com/tomsjavaJive"
],
"image": "https://tomsjavaJive.com/og-image.jpg",
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "4.8",
"reviewCount": "127"
}
}
</script>
<!-- Structured Data - Website -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "Tom's Java Jive",
"url": "https://tomsjavaJive.com",
"potentialAction": {
"@type": "SearchAction",
"target": "https://tomsjavaJive.com/shop?search={search_term_string}",
"query-input": "required name=search_term_string"
}
}
</script>
<!-- Structured Data - Organization -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Organization",
"name": "Tom's Java Jive",
"url": "https://tomsjavaJive.com",
"logo": "https://tomsjavaJive.com/icons/icon-512x512.png",
"contactPoint": {
"@type": "ContactPoint",
"telephone": "+1-817-266-2022",
"contactType": "customer service",
"availableLanguage": ["English", "Spanish"]
}
}
</script>
<script>window.addEventListener("error",function(e){if(e.error instanceof DOMException&&e.error.name==="DataCloneError"&&e.message&&e.message.includes("PerformanceServerTiming")){e.stopImmediatePropagation();e.preventDefault()}},true);</script>
<script src="https://assets.emergent.sh/scripts/emergent-main.js"></script>
<!-- Register Service Worker -->
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/service-worker.js')
.then(reg => console.log('SW registered'))
.catch(err => console.log('SW registration failed:', err));
});
}
</script>
<script defer src="/static/js/bundle.js"></script><script>
if(window.self!==window.top){
var s=document.createElement("script");s.src="/visual-edit-overlay.js";document.head.appendChild(s);
window.tailwind=window.tailwind||{};tailwind.config={corePlugins:{preflight:false}};var t=document.createElement("script");t.src="https://cdn.tailwindcss.com";document.head.appendChild(t);
}
</script>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
<a
id="emergent-badge"
target="_blank"
href="https://app.emergent.sh/?utm_source=emergent-badge"
style="
display: inline-flex !important;
box-sizing: border-box;
width: 178px;
height: 40px;
padding: 8px 12px 8px 12px;
align-items: center !important;
gap: 8px;
border-radius: 50px !important;
background: #000 !important;
position: fixed !important;
bottom: 16px;
right: 16px;
text-decoration: none;
font-family: -apple-system, BlinkMacSystemFont,
&quot;Segoe UI&quot;, Roboto, Oxygen, Ubuntu, Cantarell,
&quot;Open Sans&quot;, &quot;Helvetica Neue&quot;,
sans-serif !important;
font-size: 12px !important;
z-index: 9999 !important;
"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M15.5702 8.13142C15.7729 8.0412 16.0007 8.18878 15.9892 8.4103C15.8374 11.3192 14.0965 14.0405 11.2531 15.3065C8.40964 16.5725 5.2224 16.0453 2.95912 14.2117C2.78676 14.072 2.82955 13.804 3.03219 13.7137L4.95677 12.8568C5.04866 12.8159 5.15446 12.823 5.24204 12.8725C6.73377 13.7153 8.59176 13.8649 10.2772 13.1145C11.9626 12.3641 13.0947 10.8833 13.4665 9.21075C13.4883 9.11256 13.5539 9.02918 13.6457 8.98827L15.5702 8.13142Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.3066 4.74698L15.5067 5.19653C15.5759 5.35178 15.5061 5.53366 15.3508 5.60278L1.29992 11.8586C1.14467 11.9278 0.962794 11.8579 0.893675 11.7027L0.701732 11.2716L0.693457 11.2531C-1.10317 7.21778 0.711626 2.49007 4.74692 0.693443C8.78221 -1.10318 13.51 0.711693 15.3066 4.74698ZM2.82356 8.55367C2.63552 8.63739 2.41991 8.51617 2.40853 8.31065C2.28373 6.05724 3.53858 3.85787 5.72286 2.88536C7.90715 1.91286 10.3813 2.45199 11.9724 4.05256C12.1175 4.19854 12.0633 4.43988 11.8753 4.5236L2.82356 8.55367Z" fill="white"/>
</svg>
<p
style="
color: #FFF !important;
font-family: 'Inter', sans-serif !important;
font-size: 13px !important;
font-style: normal !important;
font-weight: 600 !important;
line-height: 20px !important;
margin: 0 !important;
white-space: nowrap !important;
"
>
Made with Emergent
</p>
</a>
<script>
!(function (t, e) {
var o, n, p, r;
e.__SV ||
((window.posthog = e),
(e._i = []),
(e.init = function (i, s, a) {
function g(t, e) {
var o = e.split(".");
2 == o.length && ((t = t[o[0]]), (e = o[1])),
(t[e] = function () {
t.push(
[e].concat(
Array.prototype.slice.call(
arguments,
0,
),
),
);
});
}
((p = t.createElement("script")).type =
"text/javascript"),
(p.crossOrigin = "anonymous"),
(p.async = !0),
(p.src =
s.api_host.replace(
".i.posthog.com",
"-assets.i.posthog.com",
) + "/static/array.js"),
(r =
t.getElementsByTagName(
"script",
)[0]).parentNode.insertBefore(p, r);
var u = e;
for (
void 0 !== a ? (u = e[a] = []) : (a = "posthog"),
u.people = u.people || [],
u.toString = function (t) {
var e = "posthog";
return (
"posthog" !== a && (e += "." + a),
t || (e += " (stub)"),
e
);
},
u.people.toString = function () {
return u.toString(1) + ".people (stub)";
},
o =
"init me ws ys ps bs capture je Di ks register register_once register_for_session unregister unregister_for_session Ps getFeatureFlag getFeatureFlagPayload isFeatureEnabled reloadFeatureFlags updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures on onFeatureFlags onSurveysLoaded onSessionId getSurveys getActiveMatchingSurveys renderSurvey canRenderSurvey canRenderSurveyAsync identify setPersonProperties group resetGroups setPersonPropertiesForFlags resetPersonPropertiesForFlags setGroupPropertiesForFlags resetGroupPropertiesForFlags reset get_distinct_id getGroups get_session_id get_session_replay_url alias set_config startSessionRecording stopSessionRecording sessionRecordingStarted captureException loadToolbar get_property getSessionProperty Es $s createPersonProfile Is opt_in_capturing opt_out_capturing has_opted_in_capturing has_opted_out_capturing clear_opt_in_out_capturing Ss debug xs getPageViewId captureTraceFeedback captureTraceMetric".split(
" ",
),
n = 0;
n < o.length;
n++
)
g(u, o[n]);
e._i.push([i, s, a]);
}),
(e.__SV = 1));
})(document, window.posthog || []);
posthog.init("phc_xAvL2Iq4tFmANRE7kzbKwaSqp1HJjN7x48s3vr0CMjs", {
api_host: "https://us.i.posthog.com",
person_profiles: "identified_only", // or 'always' to create profiles for anonymous users as well,
session_recording: {
recordCrossOriginIframes: true,
capturePerformance: false,
},
});
</script>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" fill="#FDFBF7"/>
<g fill="#FF5E1A">
<!-- Coffee cup -->
<path d="M128 160h192c8.8 0 16 7.2 16 16v160c0 44.2-35.8 80-80 80H192c-44.2 0-80-35.8-80-80V176c0-8.8 7.2-16 16-16z"/>
<!-- Cup handle -->
<path d="M336 192h32c26.5 0 48 21.5 48 48v32c0 26.5-21.5 48-48 48h-32v-128z"/>
<!-- Steam lines -->
<path d="M160 128c0-17.7 14.3-32 32-32s32 14.3 32 32c0-17.7 14.3-32 32-32s32 14.3 32 32" stroke="#FF5E1A" stroke-width="16" fill="none" stroke-linecap="round"/>
</g>
<!-- Plate -->
<ellipse cx="224" cy="420" rx="120" ry="20" fill="#E8E2D9"/>
</svg>

After

Width:  |  Height:  |  Size: 671 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 80">
<defs>
<linearGradient id="coffeeGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#8B4513"/>
<stop offset="100%" style="stop-color:#E86A33"/>
</linearGradient>
</defs>
<!-- Coffee Cup Icon -->
<g transform="translate(10, 10)">
<!-- Cup body -->
<path d="M5 15 L10 55 Q12 60 20 60 L40 60 Q48 60 50 55 L55 15 Z" fill="url(#coffeeGrad)"/>
<!-- Cup handle -->
<path d="M55 20 Q70 20 70 35 Q70 50 55 50" stroke="#8B4513" stroke-width="4" fill="none"/>
<!-- Steam -->
<path d="M20 5 Q25 0 20 -5 Q15 -10 20 -15" stroke="#E86A33" stroke-width="2" fill="none" opacity="0.7"/>
<path d="M30 8 Q35 3 30 -2 Q25 -7 30 -12" stroke="#E86A33" stroke-width="2" fill="none" opacity="0.5"/>
<path d="M40 5 Q45 0 40 -5 Q35 -10 40 -15" stroke="#E86A33" stroke-width="2" fill="none" opacity="0.7"/>
</g>
<!-- Text -->
<text x="85" y="35" font-family="Georgia, serif" font-size="28" font-weight="bold" fill="#8B4513">Tom's</text>
<text x="85" y="60" font-family="Georgia, serif" font-size="24" font-weight="bold" fill="#E86A33">Java Jive</text>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+17
View File
@@ -0,0 +1,17 @@
<svg xmlns='http://www.w3.org/2000/svg' width='1200' height='630' viewBox='0 0 1200 630'>
<rect width='1200' height='630' fill='#1a0a00'/>
<rect x='0' y='0' width='1200' height='630' fill='url(#g1)'/>
<defs>
<linearGradient id='g1' x1='0' y1='0' x2='1' y2='1'>
<stop offset='0%' stop-color='#2d1200'/>
<stop offset='100%' stop-color='#0d0500'/>
</linearGradient>
</defs>
<circle cx='600' cy='220' r='80' fill='none' stroke='#FF5E1A' stroke-width='4' opacity='0.6'/>
<text x='600' y='235' font-family='Georgia,serif' font-size='60' fill='#FF5E1A' text-anchor='middle'>&#9749;</text>
<text x='600' y='340' font-family='Georgia,serif' font-size='58' font-weight='bold' fill='#ffffff' text-anchor='middle'>Tom&apos;s Java Jive</text>
<text x='600' y='400' font-family='Arial,sans-serif' font-size='26' fill='#cc7a3a' text-anchor='middle'>Premium Artisan Coffee &bull; Weatherford, TX</text>
<text x='600' y='450' font-family='Arial,sans-serif' font-size='22' fill='rgba(255,255,255,0.5)' text-anchor='middle'>Freshly Roasted &bull; Single Origin &bull; Specialty Blends</text>
<rect x='440' y='490' width='320' height='50' rx='8' fill='#FF5E1A'/>
<text x='600' y='522' font-family='Arial,sans-serif' font-size='22' font-weight='bold' fill='white' text-anchor='middle'>Shop tomsjavajive.com</text>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400">
<rect width="400" height="400" fill="#F5F0EB"/>
<circle cx="200" cy="180" r="100" fill="#D4A574"/>
<ellipse cx="200" cy="180" rx="70" ry="30" fill="#8B4513"/>
<path d="M130 180 Q130 280 200 280 Q270 280 270 180" fill="#D4A574"/>
<text x="200" y="340" text-anchor="middle" font-family="Georgia, serif" font-size="24" fill="#8B4513">Product Image</text>
</svg>

After

Width:  |  Height:  |  Size: 431 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

+394
View File
@@ -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 = `
<i class="fas fa-${type === 'success' ? 'check-circle' : type === 'error' ? 'exclamation-circle' : 'info-circle'}"></i>
<span>${message}</span>
`;
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 = '<span class="loading"></span> 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);
}
};
+177
View File
@@ -0,0 +1,177 @@
<?php
/**
* Tom's Java Jive - Shopping Cart
*/
$pageTitle = "Shopping Cart - Tom's Java Jive";
$metaTitle = "Your Cart | Tom's Java Jive";
$metaDescription = 'Review your coffee selections and checkout securely.';
$canonicalUrl = 'https://tomsjavajive.com/cart.php';
$metaRobots = "noindex, nofollow";
$suppressSchema = true;
require_once __DIR__ . '/includes/header.php';
$cart = getCart();
$cartItems = [];
$subtotal = 0;
// Get product details for cart items
foreach ($cart as $productId => $quantity) {
$product = db()->fetch(
"SELECT product_id, name, price, sale_price, stock, images FROM products WHERE product_id = :id AND is_active = 1",
['id' => $productId]
);
if ($product) {
$images = json_decode($product['images'] ?? '[]', true);
$product['image'] = !empty($images) ? $images[0] : '/assets/images/placeholder-product.svg';
$product['quantity'] = min($quantity, $product['stock']);
$product['unit_price'] = $product['sale_price'] ?? $product['price'];
$product['total'] = $product['unit_price'] * $product['quantity'];
$subtotal += $product['total'];
$cartItems[] = $product;
}
}
// Get shipping settings
$shippingSettings = getSetting('shipping', [
'flat_rate_enabled' => true,
'flat_rate_amount' => 5.99,
'free_shipping_threshold' => 50
]);
$shippingCost = 0;
if ($shippingSettings['flat_rate_enabled'] ?? true) {
if ($subtotal >= ($shippingSettings['free_shipping_threshold'] ?? 50)) {
$shippingCost = 0;
} else {
$shippingCost = $shippingSettings['flat_rate_amount'] ?? 5.99;
}
}
$total = $subtotal + $shippingCost;
?>
<section class="section" style="padding-top: 2rem;">
<div class="container">
<h1 style="margin-bottom: 2rem;">Shopping Cart</h1>
<?php if (empty($cartItems)): ?>
<div class="card">
<div class="card-body text-center" style="padding: 3rem;">
<i class="fas fa-shopping-bag" style="font-size: 4rem; color: var(--color-text-muted); margin-bottom: 1rem;"></i>
<h3>Your cart is empty</h3>
<p class="text-muted mb-2">Looks like you haven't added any items yet.</p>
<a href="/shop.php" class="btn btn-primary">Start Shopping</a>
</div>
</div>
<?php else: ?>
<div style="display: grid; grid-template-columns: 1fr 350px; gap: 2rem; align-items: start;">
<!-- Cart Items -->
<div class="card">
<div class="card-body">
<div style="display: grid; gap: 1rem;">
<?php foreach ($cartItems as $item): ?>
<div class="cart-item" data-product-id="<?= $item['product_id'] ?>"
style="display: grid; grid-template-columns: 80px 1fr auto auto; gap: 1rem; align-items: center; padding-bottom: 1rem; border-bottom: 1px solid var(--color-border);">
<img src="<?= htmlspecialchars($item['image']) ?>" alt="<?= htmlspecialchars($item['name']) ?>"
style="width: 80px; height: 80px; object-fit: cover; border-radius: var(--radius-md);">
<div>
<a href="/product.php?id=<?= $item['product_id'] ?>">
<h4 style="margin-bottom: 0.25rem;"><?= htmlspecialchars($item['name']) ?></h4>
</a>
<p class="text-muted mb-0"><?= formatCurrency($item['unit_price']) ?> each</p>
</div>
<div class="quantity-selector" style="display: flex; align-items: center; border: 1px solid var(--color-border); border-radius: var(--radius-md);">
<button type="button" class="qty-minus btn" style="padding: 0.25rem 0.75rem;"
onclick="updateCartItem('<?= $item['product_id'] ?>', <?= $item['quantity'] - 1 ?>)">-</button>
<input type="number" class="qty-input" value="<?= $item['quantity'] ?>" min="1" max="<?= $item['stock'] ?>"
style="width: 50px; text-align: center; border: none; outline: none;"
onchange="updateCartItem('<?= $item['product_id'] ?>', this.value)">
<button type="button" class="qty-plus btn" style="padding: 0.25rem 0.75rem;"
onclick="updateCartItem('<?= $item['product_id'] ?>', <?= $item['quantity'] + 1 ?>)">+</button>
</div>
<div style="text-align: right;">
<p style="font-weight: 600; margin-bottom: 0.25rem;"><?= formatCurrency($item['total']) ?></p>
<button type="button" class="btn btn-sm" style="color: var(--color-error);"
onclick="removeFromCart('<?= $item['product_id'] ?>')">
<i class="fas fa-trash"></i> Remove
</button>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<!-- Order Summary -->
<div class="card" style="position: sticky; top: 100px;">
<div class="card-header">
<h3 style="margin: 0;">Order Summary</h3>
</div>
<div class="card-body">
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;">
<span>Subtotal</span>
<span id="cart-subtotal"><?= formatCurrency($subtotal) ?></span>
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;">
<span>Shipping</span>
<span id="cart-shipping">
<?php if ($shippingCost == 0): ?>
<span style="color: var(--color-success);">FREE</span>
<?php else: ?>
<?= formatCurrency($shippingCost) ?>
<?php endif; ?>
</span>
</div>
<?php if ($shippingCost > 0 && isset($shippingSettings['free_shipping_threshold'])):
$remaining = $shippingSettings['free_shipping_threshold'] - $subtotal;
?>
<p style="font-size: 0.875rem; color: var(--color-primary); margin: 0.5rem 0;">
<i class="fas fa-truck"></i>
Add <?= formatCurrency($remaining) ?> more for FREE shipping!
</p>
<?php endif; ?>
<hr style="margin: 1rem 0;">
<div style="display: flex; justify-content: space-between; font-size: 1.25rem; font-weight: 600;">
<span>Total</span>
<span id="cart-total"><?= formatCurrency($total) ?></span>
</div>
<a href="/checkout.php" class="btn btn-primary btn-lg btn-block mt-2">
Proceed to Checkout
</a>
<a href="/shop.php" class="btn btn-outline btn-block mt-1">
Continue Shopping
</a>
</div>
</div>
</div>
<?php endif; ?>
</div>
</section>
<script>
function updateCartDisplay(data) {
if (data.cart_items && data.cart_items.length === 0) {
location.reload();
}
if (data.subtotal !== undefined) {
document.getElementById('cart-subtotal').textContent = '$' + parseFloat(data.subtotal).toFixed(2);
}
if (data.total !== undefined) {
document.getElementById('cart-total').textContent = '$' + parseFloat(data.total).toFixed(2);
}
}
</script>
<?php require_once __DIR__ . '/includes/footer.php'; ?>
+343
View File
@@ -0,0 +1,343 @@
<?php
/**
* Tom's Java Jive - Checkout Page
*/
$pageTitle = "Checkout - Tom's Java Jive";
require_once __DIR__ . '/includes/functions.php';
require_once __DIR__ . '/includes/auth.php';
$cart = getCart();
if (empty($cart)) {
redirect('/cart.php');
}
$customer = CustomerAuth::getFullUser();
$cartItems = [];
$subtotal = 0;
// Get product details for cart items
foreach ($cart as $productId => $quantity) {
$product = db()->fetch(
"SELECT product_id, name, price, sale_price, stock, images FROM products WHERE product_id = :id AND is_active = 1",
['id' => $productId]
);
if ($product) {
$images = json_decode($product['images'] ?? '[]', true);
$product['image'] = !empty($images) ? $images[0] : '/assets/images/placeholder-product.svg';
$product['quantity'] = min($quantity, $product['stock']);
$product['unit_price'] = $product['sale_price'] ?? $product['price'];
$product['total'] = $product['unit_price'] * $product['quantity'];
$subtotal += $product['total'];
$cartItems[] = $product;
}
}
// Get shipping settings
$shippingSettings = getSetting('shipping', [
'flat_rate_enabled' => true,
'flat_rate_amount' => 5.99,
'free_shipping_threshold' => 50
]);
$shippingCost = 0;
if ($shippingSettings['flat_rate_enabled'] ?? true) {
if ($subtotal >= ($shippingSettings['free_shipping_threshold'] ?? 50)) {
$shippingCost = 0;
} else {
$shippingCost = $shippingSettings['flat_rate_amount'] ?? 5.99;
}
}
$total = $subtotal + $shippingCost;
// 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';
?>
<section class="section" style="padding-top: 2rem;">
<div class="container">
<h1 style="margin-bottom: 2rem;">Checkout</h1>
<form method="POST" action="" id="checkout-form">
<div style="display: grid; grid-template-columns: 1fr 400px; gap: 2rem; align-items: start;">
<!-- Customer & Shipping Info -->
<div>
<!-- Contact Information -->
<div class="card mb-2">
<div class="card-header">
<h3 style="margin: 0;">Contact Information</h3>
</div>
<div class="card-body">
<?php if ($customer): ?>
<p>Logged in as <strong><?= htmlspecialchars($customer['email']) ?></strong></p>
<input type="hidden" name="email" value="<?= htmlspecialchars($customer['email']) ?>">
<input type="hidden" name="name" value="<?= htmlspecialchars($customer['name']) ?>">
<?php else: ?>
<div class="form-group">
<label class="form-label">Email Address *</label>
<input type="email" name="email" class="form-input"
value="<?= htmlspecialchars($_POST['email'] ?? '') ?>" required>
<?php if (isset($errors['email'])): ?>
<span class="form-error"><?= $errors['email'] ?></span>
<?php endif; ?>
</div>
<div class="form-group">
<label class="form-label">Full Name *</label>
<input type="text" name="name" class="form-input"
value="<?= htmlspecialchars($_POST['name'] ?? '') ?>" required>
<?php if (isset($errors['name'])): ?>
<span class="form-error"><?= $errors['name'] ?></span>
<?php endif; ?>
</div>
<div class="form-group mb-0">
<label class="form-label">Phone (Optional)</label>
<input type="tel" name="phone" class="form-input"
value="<?= htmlspecialchars($_POST['phone'] ?? '') ?>">
</div>
<p class="text-muted mt-1" style="font-size: 0.875rem;">
Already have an account? <a href="/login.php">Sign in</a>
</p>
<?php endif; ?>
</div>
</div>
<!-- Shipping Address -->
<div class="card mb-2">
<div class="card-header">
<h3 style="margin: 0;">Shipping Address</h3>
</div>
<div class="card-body">
<?php
$savedAddress = $customer ? json_decode($customer['shipping_address'] ?? '{}', true) : [];
?>
<div class="form-group">
<label class="form-label">Street Address *</label>
<input type="text" name="address" class="form-input"
value="<?= htmlspecialchars($_POST['address'] ?? $savedAddress['address'] ?? '') ?>" required>
<?php if (isset($errors['address'])): ?>
<span class="form-error"><?= $errors['address'] ?></span>
<?php endif; ?>
</div>
<div style="display: grid; grid-template-columns: 2fr 1fr 1fr; gap: 1rem;">
<div class="form-group">
<label class="form-label">City *</label>
<input type="text" name="city" class="form-input"
value="<?= htmlspecialchars($_POST['city'] ?? $savedAddress['city'] ?? '') ?>" required>
<?php if (isset($errors['city'])): ?>
<span class="form-error"><?= $errors['city'] ?></span>
<?php endif; ?>
</div>
<div class="form-group">
<label class="form-label">State *</label>
<input type="text" name="state" class="form-input"
value="<?= htmlspecialchars($_POST['state'] ?? $savedAddress['state'] ?? '') ?>" required>
<?php if (isset($errors['state'])): ?>
<span class="form-error"><?= $errors['state'] ?></span>
<?php endif; ?>
</div>
<div class="form-group">
<label class="form-label">ZIP *</label>
<input type="text" name="zip" class="form-input"
value="<?= htmlspecialchars($_POST['zip'] ?? $savedAddress['zip'] ?? '') ?>" required>
<?php if (isset($errors['zip'])): ?>
<span class="form-error"><?= $errors['zip'] ?></span>
<?php endif; ?>
</div>
</div>
</div>
</div>
<!-- Order Notes -->
<div class="card">
<div class="card-header">
<h3 style="margin: 0;">Order Notes (Optional)</h3>
</div>
<div class="card-body">
<textarea name="notes" class="form-textarea" placeholder="Special delivery instructions..."
style="min-height: 80px;"></textarea>
</div>
</div>
</div>
<!-- Order Summary -->
<div class="card" style="position: sticky; top: 100px;">
<div class="card-header">
<h3 style="margin: 0;">Order Summary</h3>
</div>
<div class="card-body">
<!-- Cart Items -->
<div style="max-height: 250px; overflow-y: auto; margin-bottom: 1rem;">
<?php foreach ($cartItems as $item): ?>
<div style="display: flex; gap: 1rem; margin-bottom: 1rem; padding-bottom: 1rem; border-bottom: 1px solid var(--color-border);">
<img src="<?= htmlspecialchars($item['image']) ?>" alt=""
style="width: 60px; height: 60px; object-fit: cover; border-radius: var(--radius-md);">
<div style="flex: 1;">
<p style="font-weight: 500; margin-bottom: 0.25rem;"><?= htmlspecialchars($item['name']) ?></p>
<p class="text-muted" style="font-size: 0.875rem;">
<?= formatCurrency($item['unit_price']) ?> x <?= $item['quantity'] ?>
</p>
</div>
<div style="text-align: right;">
<strong><?= formatCurrency($item['total']) ?></strong>
</div>
</div>
<?php endforeach; ?>
</div>
<!-- Totals -->
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;">
<span>Subtotal</span>
<span><?= formatCurrency($subtotal) ?></span>
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;">
<span>Shipping</span>
<span>
<?php if ($shippingCost == 0): ?>
<span style="color: var(--color-success);">FREE</span>
<?php else: ?>
<?= formatCurrency($shippingCost) ?>
<?php endif; ?>
</span>
</div>
<hr style="margin: 1rem 0;">
<div style="display: flex; justify-content: space-between; font-size: 1.25rem; font-weight: 600;">
<span>Total</span>
<span><?= formatCurrency($total) ?></span>
</div>
<button type="submit" class="btn btn-primary btn-lg btn-block mt-2">
Continue to Payment
</button>
<a href="/cart.php" class="btn btn-secondary btn-block mt-1">
<i class="fas fa-arrow-left"></i> Back to Cart
</a>
<p class="text-muted text-center mt-2" style="font-size: 0.75rem;">
<i class="fas fa-lock"></i> Secure checkout powered by Stripe
</p>
</div>
</div>
</div>
</form>
</div>
</section>
<?php require_once __DIR__ . '/includes/footer.php'; ?>
+62
View File
@@ -0,0 +1,62 @@
<?php
/**
* Tom's Java Jive - Main Configuration
*
* Edit these values for your deployment
*/
// Site Configuration
if (!defined('SITE_NAME')) { define('SITE_NAME', "Tom's Java Jive"); }
if (!defined('SITE_URL')) { define('SITE_URL', 'https://tomsjavajive.com'); } // Change to your domain
if (!defined('SITE_EMAIL')) { define('SITE_EMAIL', 'support@tomsjavajive.com'); }
// Environment
if (!defined('ENVIRONMENT')) { define('ENVIRONMENT', 'production'); } // development or production
if (!defined('DEBUG_MODE')) { define('DEBUG_MODE', false); }
// Session Configuration
if (!defined('SESSION_NAME')) { define('SESSION_NAME', 'tjj_session'); }
if (!defined('SESSION_LIFETIME')) { define('SESSION_LIFETIME', 86400 * 7); } // 7 days
// Security
if (!defined('HASH_COST')) { define('HASH_COST', 12); } // bcrypt cost factor
if (!defined('CSRF_TOKEN_NAME')) { define('CSRF_TOKEN_NAME', 'csrf_token'); }
// API Keys - UPDATE THESE
if (!defined('STRIPE_SECRET_KEY')) { define('STRIPE_SECRET_KEY', 'sk_test_your_stripe_key'); }
if (!defined('STRIPE_PUBLISHABLE_KEY')) { define('STRIPE_PUBLISHABLE_KEY', 'pk_test_your_stripe_key'); }
if (!defined('STRIPE_WEBHOOK_SECRET')) { define('STRIPE_WEBHOOK_SECRET', 'whsec_your_webhook_secret'); }
if (!defined('SENDGRID_API_KEY')) { define('SENDGRID_API_KEY', 'SG.your_sendgrid_key'); }
if (!defined('SENDER_EMAIL')) { define('SENDER_EMAIL', 'noreply@tomsjavajive.com'); }
if (!defined('SENDER_NAME')) { define('SENDER_NAME', "Tom's Java Jive"); }
// Twilio (optional)
if (!defined('TWILIO_SID')) { define('TWILIO_SID', ''); }
if (!defined('TWILIO_AUTH_TOKEN')) { define('TWILIO_AUTH_TOKEN', ''); }
if (!defined('TWILIO_PHONE')) { define('TWILIO_PHONE', ''); }
// File Uploads
if (!defined('UPLOAD_DIR')) { define('UPLOAD_DIR', __DIR__ . '/../uploads/'); }
if (!defined('MAX_UPLOAD_SIZE')) { define('MAX_UPLOAD_SIZE', 5 * 1024 * 1024); } // 5MB
if (!defined('ALLOWED_IMAGE_TYPES')) { define('ALLOWED_IMAGE_TYPES', ['image/jpeg', 'image/png', 'image/gif', 'image/webp']); }
// Pagination
if (!defined('ITEMS_PER_PAGE')) { define('ITEMS_PER_PAGE', 12); }
if (!defined('ADMIN_ITEMS_PER_PAGE')) { define('ADMIN_ITEMS_PER_PAGE', 20); }
// Currency
if (!defined('CURRENCY_CODE')) { define('CURRENCY_CODE', 'USD'); }
if (!defined('TJJ_CURRENCY_SYMBOL')) { define('TJJ_CURRENCY_SYMBOL', '$'); }
// Timezone
date_default_timezone_set('America/New_York');
// Error Reporting
if (ENVIRONMENT === 'development' || DEBUG_MODE) {
error_reporting(E_ALL);
ini_set('display_errors', 1);
} else {
error_reporting(0);
ini_set('display_errors', 0);
}
+314
View File
@@ -0,0 +1,314 @@
# Tom's Java Jive - PHP/MySQL Deployment Guide
## cPanel FTP Deployment Instructions
This guide covers deploying Tom's Java Jive to a cPanel shared hosting environment with FTP access only (no SSH/root).
### Requirements
- PHP 8.4+
- MySQL 8.0+
- FTP Client (FileZilla, Cyberduck, etc.)
- cPanel access for database creation
---
## Step 1: Create MySQL Database in cPanel
1. Log into your cPanel dashboard
2. Navigate to **MySQL® Databases**
3. Create a new database:
- Database name: `tomsjavajive` (or your preferred name)
- Note the full database name (usually `cpaneluser_tomsjavajive`)
4. Create a database user:
- Username: (your choice)
- Password: (strong password)
5. Add user to database:
- Select the user and database
- Grant **ALL PRIVILEGES**
6. Note down your credentials:
- Database host: `localhost` (usually)
- Database name: (full name from step 3)
- Username: (full username from step 4)
- Password: (from step 4)
---
## Step 2: Configure Application Settings
### Edit `config/database.php`
```php
define('DB_HOST', 'localhost');
define('DB_NAME', 'cpaneluser_tomsjavajive'); // Your full database name
define('DB_USER', 'cpaneluser_dbuser'); // Your full database username
define('DB_PASS', 'your_secure_password'); // Your database password
```
### Edit `config/config.php`
```php
define('SITE_NAME', "Tom's Java Jive");
define('SITE_URL', 'https://yourdomain.com'); // Your actual domain
define('SITE_EMAIL', 'support@yourdomain.com');
// Set to 'production' for live site
define('ENVIRONMENT', 'production');
define('DEBUG_MODE', false);
// Stripe Keys (get from https://dashboard.stripe.com/apikeys)
define('STRIPE_SECRET_KEY', 'sk_live_xxxx'); // Use sk_test_ for testing
define('STRIPE_PUBLISHABLE_KEY', 'pk_live_xxxx');
define('STRIPE_WEBHOOK_SECRET', 'whsec_xxxx');
// SendGrid (get from https://app.sendgrid.com/settings/api_keys)
define('SENDGRID_API_KEY', 'SG.xxxx');
define('SENDER_EMAIL', 'noreply@yourdomain.com');
// Twilio (optional - get from https://www.twilio.com/console)
define('TWILIO_SID', '');
define('TWILIO_AUTH_TOKEN', '');
define('TWILIO_PHONE', '');
```
---
## Step 3: Upload Files via FTP
1. Connect to your server using FTP:
- Host: Your domain or ftp.yourdomain.com
- Port: 21 (or 22 for SFTP)
- Username: Your cPanel username
- Password: Your cPanel password
2. Navigate to `public_html` (or your web root)
3. Upload ALL files from the `tomsjavajive-php` folder:
```
/public_html/
├── admin/
├── api/
├── assets/
├── account/
├── config/
├── docs/
├── includes/
├── install/
├── pages/
├── index.php
├── shop.php
├── product.php
├── cart.php
├── checkout.php
├── payment.php
├── login.php
├── register.php
├── logout.php
└── ... (other files)
```
4. Set file permissions (via FTP client or cPanel File Manager):
- All `.php` files: 644
- All directories: 755
- `uploads/` directory: 755 (create if not exists)
- `config/` directory: 755
---
## Step 4: Import Database Schema
### Option A: Using phpMyAdmin (Recommended)
1. In cPanel, open **phpMyAdmin**
2. Select your database from the left sidebar
3. Click the **Import** tab
4. Click **Choose File** and select `install/schema.sql`
5. Click **Go** to execute
### Option B: Using MySQL command in cPanel Terminal (if available)
```bash
mysql -u cpaneluser_dbuser -p cpaneluser_tomsjavajive < install/schema.sql
```
---
## Step 5: Create First Admin User
After importing the schema, create your first admin user via phpMyAdmin:
1. Open phpMyAdmin
2. Select your database
3. Click the **SQL** tab
4. Run this query (replace with your details):
```sql
INSERT INTO admin_users (user_id, email, password_hash, name, is_admin, is_master, permissions)
VALUES (
'admin_001',
'admin@yourdomain.com',
'$2y$12$xxxxx', -- Generate bcrypt hash (see below)
'Admin',
1,
1,
'{"dashboard":true,"pos":true,"products":true,"orders":true,"customers":true,"settings_payment":true,"settings_shipping":true,"settings_email":true,"admin_management":true}'
);
```
**To generate password hash:**
Create a temporary PHP file called `generate_hash.php`:
```php
<?php
echo password_hash('YourSecurePassword123', PASSWORD_BCRYPT, ['cost' => 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*
+284
View File
@@ -0,0 +1,284 @@
<?php
/**
* Tom's Java Jive - Authentication Helper
*/
require_once __DIR__ . '/functions.php';
/**
* Start secure session
*/
function initSession() {
if (session_status() === PHP_SESSION_NONE) {
ini_set('session.cookie_httponly', 1);
ini_set('session.cookie_secure', isset($_SERVER['HTTPS']));
ini_set('session.use_strict_mode', 1);
session_name(SESSION_NAME);
session_start();
}
}
/**
* Admin Authentication
*/
class AdminAuth {
public static function login($email, $password) {
$admin = db()->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 = "
<h2>Password Reset Request</h2>
<p>Click the link below to reset your password:</p>
<p><a href='{$resetUrl}'>{$resetUrl}</a></p>
<p>This link will expire in 1 hour.</p>
<p>If you didn't request this, please ignore this email.</p>
";
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();

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