mirror of
https://github.com/myronblair/tomsjavajive
synced 2026-06-30 17:50:32 -05:00
Initial commit
This commit is contained in:
@@ -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();
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
/**
|
||||
* Tom's Java Jive - Database Class
|
||||
*
|
||||
* Singleton PDO database connection
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../config/database.php';
|
||||
|
||||
class Database {
|
||||
private static $instance = null;
|
||||
private $pdo;
|
||||
|
||||
private function __construct() {
|
||||
try {
|
||||
$dsn = "mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=" . DB_CHARSET;
|
||||
$this->pdo = new PDO($dsn, DB_USER, DB_PASS, DB_OPTIONS);
|
||||
} catch (PDOException $e) {
|
||||
if (ENVIRONMENT === 'development') {
|
||||
die("Database connection failed: " . $e->getMessage());
|
||||
} else {
|
||||
die("Database connection failed. Please try again later.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function getInstance() {
|
||||
if (self::$instance === null) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function getConnection() {
|
||||
return $this->pdo;
|
||||
}
|
||||
|
||||
public function query($sql, $params = []) {
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
return $stmt;
|
||||
}
|
||||
|
||||
public function fetch($sql, $params = []) {
|
||||
return $this->query($sql, $params)->fetch();
|
||||
}
|
||||
|
||||
public function fetchAll($sql, $params = []) {
|
||||
return $this->query($sql, $params)->fetchAll();
|
||||
}
|
||||
|
||||
public function insert($table, $data) {
|
||||
$columns = implode(', ', array_keys($data));
|
||||
$placeholders = ':' . implode(', :', array_keys($data));
|
||||
|
||||
$sql = "INSERT INTO {$table} ({$columns}) VALUES ({$placeholders})";
|
||||
$this->query($sql, $data);
|
||||
|
||||
return $this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
public function update($table, $data, $where, $whereParams = []) {
|
||||
$set = [];
|
||||
foreach (array_keys($data) as $column) {
|
||||
$set[] = "{$column} = :{$column}";
|
||||
}
|
||||
$setString = implode(', ', $set);
|
||||
|
||||
$sql = "UPDATE {$table} SET {$setString} WHERE {$where}";
|
||||
return $this->query($sql, array_merge($data, $whereParams))->rowCount();
|
||||
}
|
||||
|
||||
public function delete($table, $where, $params = []) {
|
||||
$sql = "DELETE FROM {$table} WHERE {$where}";
|
||||
return $this->query($sql, $params)->rowCount();
|
||||
}
|
||||
|
||||
public function count($table, $where = '1=1', $params = []) {
|
||||
$sql = "SELECT COUNT(*) as count FROM {$table} WHERE {$where}";
|
||||
$result = $this->fetch($sql, $params);
|
||||
return $result['count'] ?? 0;
|
||||
}
|
||||
|
||||
public function lastInsertId() {
|
||||
return $this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
public function beginTransaction() {
|
||||
return $this->pdo->beginTransaction();
|
||||
}
|
||||
|
||||
public function commit() {
|
||||
return $this->pdo->commit();
|
||||
}
|
||||
|
||||
public function rollback() {
|
||||
return $this->pdo->rollBack();
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to get database instance
|
||||
function db() {
|
||||
return Database::getInstance();
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
<?php
|
||||
/**
|
||||
* Tom's Java Jive - SendGrid Email Service
|
||||
*
|
||||
* Handles all transactional emails using SendGrid API
|
||||
*/
|
||||
|
||||
class SendGridEmail {
|
||||
private string $apiKey;
|
||||
private string $fromEmail;
|
||||
private string $fromName;
|
||||
|
||||
public function __construct() {
|
||||
// Load from settings or config
|
||||
$this->apiKey = getSetting('sendgrid_api_key', defined('SENDGRID_API_KEY') ? SENDGRID_API_KEY : '');
|
||||
$this->fromEmail = getSetting('sendgrid_from_email', defined('SENDER_EMAIL') ? SENDER_EMAIL : 'noreply@tomsjavajive.com');
|
||||
$this->fromName = getSetting('sendgrid_from_name', defined('SENDER_NAME') ? SENDER_NAME : "Tom's Java Jive");
|
||||
}
|
||||
|
||||
/**
|
||||
* Send email via SendGrid API
|
||||
*/
|
||||
public function send(string $to, string $subject, string $htmlContent, ?string $textContent = null): array {
|
||||
$data = [
|
||||
'personalizations' => [
|
||||
[
|
||||
'to' => [['email' => $to]],
|
||||
'subject' => $subject
|
||||
]
|
||||
],
|
||||
'from' => [
|
||||
'email' => $this->fromEmail,
|
||||
'name' => $this->fromName
|
||||
],
|
||||
'content' => []
|
||||
];
|
||||
|
||||
if ($textContent) {
|
||||
$data['content'][] = ['type' => 'text/plain', 'value' => $textContent];
|
||||
}
|
||||
$data['content'][] = ['type' => 'text/html', 'value' => $htmlContent];
|
||||
|
||||
$ch = curl_init('https://api.sendgrid.com/v3/mail/send');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => json_encode($data),
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Authorization: Bearer ' . $this->apiKey,
|
||||
'Content-Type: application/json'
|
||||
],
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 30
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($error) {
|
||||
return ['success' => false, 'error' => $error];
|
||||
}
|
||||
|
||||
// SendGrid returns 202 for accepted
|
||||
if ($httpCode >= 200 && $httpCode < 300) {
|
||||
return ['success' => true];
|
||||
}
|
||||
|
||||
return ['success' => false, 'error' => $response, 'code' => $httpCode];
|
||||
}
|
||||
|
||||
/**
|
||||
* Send order confirmation email
|
||||
*/
|
||||
public function sendOrderConfirmation(array $order): array {
|
||||
$items = json_decode($order['items'], true);
|
||||
$itemsHtml = '';
|
||||
foreach ($items as $item) {
|
||||
$itemsHtml .= sprintf(
|
||||
'<tr><td style="padding: 10px; border-bottom: 1px solid #eee;">%s</td>
|
||||
<td style="padding: 10px; border-bottom: 1px solid #eee; text-align: center;">%d</td>
|
||||
<td style="padding: 10px; border-bottom: 1px solid #eee; text-align: right;">%s</td></tr>',
|
||||
htmlspecialchars($item['name']),
|
||||
$item['quantity'],
|
||||
formatCurrency($item['total'])
|
||||
);
|
||||
}
|
||||
|
||||
$html = $this->getTemplate('order_confirmation', [
|
||||
'order_number' => $order['order_number'],
|
||||
'customer_name' => $order['customer_name'] ?? 'Valued Customer',
|
||||
'items_html' => $itemsHtml,
|
||||
'subtotal' => formatCurrency($order['subtotal']),
|
||||
'tax' => formatCurrency($order['tax']),
|
||||
'discount' => $order['discount'] > 0 ? '-' . formatCurrency($order['discount']) : '$0.00',
|
||||
'total' => formatCurrency($order['total']),
|
||||
'payment_method' => ucfirst($order['payment_method'] ?? 'N/A'),
|
||||
'order_date' => date('F j, Y', strtotime($order['created_at']))
|
||||
]);
|
||||
|
||||
return $this->send(
|
||||
$order['customer_email'],
|
||||
"Order Confirmation - #{$order['order_number']}",
|
||||
$html
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send shipping notification email
|
||||
*/
|
||||
public function sendShippingNotification(array $order): array {
|
||||
$html = $this->getTemplate('shipping_notification', [
|
||||
'order_number' => $order['order_number'],
|
||||
'customer_name' => $order['customer_name'] ?? 'Valued Customer',
|
||||
'tracking_number' => $order['tracking_number'],
|
||||
'tracking_url' => $order['tracking_url'] ?? '#',
|
||||
'carrier' => $order['shipping_carrier'] ?? 'Our shipping partner'
|
||||
]);
|
||||
|
||||
return $this->send(
|
||||
$order['customer_email'],
|
||||
"Your Order Has Shipped - #{$order['order_number']}",
|
||||
$html
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send password reset email
|
||||
*/
|
||||
public function sendPasswordReset(string $email, string $resetToken, string $name = ''): array {
|
||||
$resetUrl = SITE_URL . '/reset-password.php?token=' . $resetToken;
|
||||
|
||||
$html = $this->getTemplate('password_reset', [
|
||||
'customer_name' => $name ?: 'there',
|
||||
'reset_url' => $resetUrl,
|
||||
'expires' => '1 hour'
|
||||
]);
|
||||
|
||||
return $this->send(
|
||||
$email,
|
||||
"Reset Your Password - Tom's Java Jive",
|
||||
$html
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send welcome email to new customer
|
||||
*/
|
||||
public function sendWelcome(string $email, string $name = ''): array {
|
||||
$html = $this->getTemplate('welcome', [
|
||||
'customer_name' => $name ?: 'Coffee Lover',
|
||||
'shop_url' => SITE_URL . '/shop.php'
|
||||
]);
|
||||
|
||||
return $this->send(
|
||||
$email,
|
||||
"Welcome to Tom's Java Jive!",
|
||||
$html
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send abandoned cart reminder
|
||||
*/
|
||||
public function sendAbandonedCartReminder(array $cart): array {
|
||||
$items = json_decode($cart['items'], true);
|
||||
$itemsHtml = '';
|
||||
foreach ($items as $item) {
|
||||
$itemsHtml .= sprintf(
|
||||
'<div style="padding: 10px; border-bottom: 1px solid #eee;">%s - %s</div>',
|
||||
htmlspecialchars($item['name']),
|
||||
formatCurrency($item['price'])
|
||||
);
|
||||
}
|
||||
|
||||
$html = $this->getTemplate('abandoned_cart', [
|
||||
'items_html' => $itemsHtml,
|
||||
'total' => formatCurrency($cart['subtotal']),
|
||||
'cart_url' => SITE_URL . '/cart.php'
|
||||
]);
|
||||
|
||||
return $this->send(
|
||||
$cart['customer_email'],
|
||||
"You left something behind!",
|
||||
$html
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get email template with variables replaced
|
||||
*/
|
||||
private function getTemplate(string $name, array $vars = []): string {
|
||||
$templates = [
|
||||
'order_confirmation' => '
|
||||
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
|
||||
<div style="background: #FF5E1A; padding: 20px; text-align: center;">
|
||||
<h1 style="color: white; margin: 0;">Order Confirmed!</h1>
|
||||
</div>
|
||||
<div style="padding: 30px; background: #fff;">
|
||||
<p>Hi {{customer_name}},</p>
|
||||
<p>Thank you for your order! We\'ve received it and will begin processing right away.</p>
|
||||
|
||||
<div style="background: #f9f9f9; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||
<h3 style="margin-top: 0;">Order #{{order_number}}</h3>
|
||||
<p style="color: #666; margin-bottom: 0;">{{order_date}}</p>
|
||||
</div>
|
||||
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr style="background: #f5f5f5;">
|
||||
<th style="padding: 10px; text-align: left;">Item</th>
|
||||
<th style="padding: 10px; text-align: center;">Qty</th>
|
||||
<th style="padding: 10px; text-align: right;">Price</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{items_html}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div style="margin-top: 20px; text-align: right;">
|
||||
<p style="margin: 5px 0;">Subtotal: {{subtotal}}</p>
|
||||
<p style="margin: 5px 0;">Tax: {{tax}}</p>
|
||||
<p style="margin: 5px 0; color: #10B981;">Discount: {{discount}}</p>
|
||||
<p style="margin: 5px 0; font-size: 1.25em; font-weight: bold; color: #FF5E1A;">Total: {{total}}</p>
|
||||
</div>
|
||||
|
||||
<p style="margin-top: 30px;">Payment Method: {{payment_method}}</p>
|
||||
|
||||
<p style="color: #666; font-size: 0.9em; margin-top: 30px;">
|
||||
If you have any questions, reply to this email or visit our website.
|
||||
</p>
|
||||
</div>
|
||||
<div style="background: #333; color: #fff; padding: 20px; text-align: center;">
|
||||
<p style="margin: 0;">© ' . date('Y') . ' Tom\'s Java Jive. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
',
|
||||
|
||||
'shipping_notification' => '
|
||||
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
|
||||
<div style="background: #FF5E1A; padding: 20px; text-align: center;">
|
||||
<h1 style="color: white; margin: 0;">Your Order Has Shipped!</h1>
|
||||
</div>
|
||||
<div style="padding: 30px; background: #fff;">
|
||||
<p>Hi {{customer_name}},</p>
|
||||
<p>Great news! Your order #{{order_number}} is on its way to you.</p>
|
||||
|
||||
<div style="background: #f9f9f9; padding: 20px; border-radius: 8px; margin: 20px 0; text-align: center;">
|
||||
<p style="margin: 0 0 10px; color: #666;">Tracking Number</p>
|
||||
<p style="margin: 0; font-size: 1.25em; font-weight: bold;">{{tracking_number}}</p>
|
||||
<p style="margin: 10px 0 0;">Carrier: {{carrier}}</p>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{tracking_url}}" style="display: inline-block; background: #FF5E1A; color: white; padding: 15px 30px; text-decoration: none; border-radius: 8px; font-weight: bold;">Track Your Package</a>
|
||||
</div>
|
||||
|
||||
<p style="color: #666; font-size: 0.9em;">
|
||||
Please allow 24-48 hours for tracking information to update.
|
||||
</p>
|
||||
</div>
|
||||
<div style="background: #333; color: #fff; padding: 20px; text-align: center;">
|
||||
<p style="margin: 0;">© ' . date('Y') . ' Tom\'s Java Jive. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
',
|
||||
|
||||
'password_reset' => '
|
||||
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
|
||||
<div style="background: #FF5E1A; padding: 20px; text-align: center;">
|
||||
<h1 style="color: white; margin: 0;">Reset Your Password</h1>
|
||||
</div>
|
||||
<div style="padding: 30px; background: #fff;">
|
||||
<p>Hi {{customer_name}},</p>
|
||||
<p>We received a request to reset your password. Click the button below to create a new password:</p>
|
||||
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{reset_url}}" style="display: inline-block; background: #FF5E1A; color: white; padding: 15px 30px; text-decoration: none; border-radius: 8px; font-weight: bold;">Reset Password</a>
|
||||
</div>
|
||||
|
||||
<p style="color: #666; font-size: 0.9em;">
|
||||
This link will expire in {{expires}}. If you didn\'t request this, you can safely ignore this email.
|
||||
</p>
|
||||
</div>
|
||||
<div style="background: #333; color: #fff; padding: 20px; text-align: center;">
|
||||
<p style="margin: 0;">© ' . date('Y') . ' Tom\'s Java Jive. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
',
|
||||
|
||||
'welcome' => '
|
||||
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
|
||||
<div style="background: #FF5E1A; padding: 30px; text-align: center;">
|
||||
<h1 style="color: white; margin: 0;">Welcome to the Family!</h1>
|
||||
</div>
|
||||
<div style="padding: 30px; background: #fff;">
|
||||
<p>Hi {{customer_name}},</p>
|
||||
<p>Welcome to Tom\'s Java Jive! We\'re thrilled to have you join our community of coffee lovers.</p>
|
||||
|
||||
<h3>Here\'s what you can look forward to:</h3>
|
||||
<ul style="color: #666;">
|
||||
<li>Exclusive member discounts</li>
|
||||
<li>Early access to new roasts</li>
|
||||
<li>Reward points on every purchase</li>
|
||||
<li>Birthday treats and special offers</li>
|
||||
</ul>
|
||||
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{shop_url}}" style="display: inline-block; background: #FF5E1A; color: white; padding: 15px 30px; text-decoration: none; border-radius: 8px; font-weight: bold;">Start Shopping</a>
|
||||
</div>
|
||||
|
||||
<p>Cheers,<br>The Tom\'s Java Jive Team</p>
|
||||
</div>
|
||||
<div style="background: #333; color: #fff; padding: 20px; text-align: center;">
|
||||
<p style="margin: 0;">© ' . date('Y') . ' Tom\'s Java Jive. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
',
|
||||
|
||||
'abandoned_cart' => '
|
||||
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
|
||||
<div style="background: #FF5E1A; padding: 20px; text-align: center;">
|
||||
<h1 style="color: white; margin: 0;">Forget Something?</h1>
|
||||
</div>
|
||||
<div style="padding: 30px; background: #fff;">
|
||||
<p>Hey there!</p>
|
||||
<p>We noticed you left some amazing items in your cart. Don\'t let them get away!</p>
|
||||
|
||||
<div style="background: #f9f9f9; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||
{{items_html}}
|
||||
<div style="padding: 15px 10px; font-weight: bold; text-align: right;">
|
||||
Total: {{total}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{cart_url}}" style="display: inline-block; background: #FF5E1A; color: white; padding: 15px 30px; text-decoration: none; border-radius: 8px; font-weight: bold;">Complete Your Order</a>
|
||||
</div>
|
||||
|
||||
<p style="color: #666; font-size: 0.9em;">
|
||||
Need help? Just reply to this email and we\'ll assist you!
|
||||
</p>
|
||||
</div>
|
||||
<div style="background: #333; color: #fff; padding: 20px; text-align: center;">
|
||||
<p style="margin: 0;">© ' . date('Y') . ' Tom\'s Java Jive. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
'
|
||||
];
|
||||
|
||||
$template = $templates[$name] ?? '<p>Email template not found.</p>';
|
||||
|
||||
foreach ($vars as $key => $value) {
|
||||
$template = str_replace('{{' . $key . '}}', $value, $template);
|
||||
}
|
||||
|
||||
return $template;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function for easy access
|
||||
function sendEmail(): SendGridEmail {
|
||||
static $instance = null;
|
||||
if ($instance === null) {
|
||||
$instance = new SendGridEmail();
|
||||
}
|
||||
return $instance;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
/**
|
||||
* Tom's Java Jive - Footer Include
|
||||
*/
|
||||
?>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<div class="footer-grid">
|
||||
<div class="footer-brand">
|
||||
<a href="/" class="logo">
|
||||
<img src="/assets/images/logo.png" alt="<?= SITE_NAME ?>" style="height: 50px;">
|
||||
</a>
|
||||
<p>Premium artisan coffee beans freshly roasted and delivered to your door. Experience the perfect cup, every time.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4>Shop</h4>
|
||||
<ul class="footer-links">
|
||||
<li><a href="/shop.php">All Products</a></li>
|
||||
<li><a href="/shop.php?category=single-origin">Single Origin</a></li>
|
||||
<li><a href="/shop.php?category=blends">Blends</a></li>
|
||||
<li><a href="/shop.php?category=equipment">Equipment</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4>Company</h4>
|
||||
<ul class="footer-links">
|
||||
<li><a href="/#about">About Us</a></li>
|
||||
<li><a href="/contact.php">Contact</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4>Support</h4>
|
||||
<ul class="footer-links">
|
||||
<li><a href="/faq.php">FAQ</a></li>
|
||||
<li><a href="/shipping.php">Shipping Info</a></li>
|
||||
<li><a href="/returns.php">Returns</a></li>
|
||||
<li><a href="/track-order.php">Track Order</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer-bottom">
|
||||
<p>© <?= date('Y') ?> Tom's Java Jive. All rights reserved.</p>
|
||||
<div class="footer-social">
|
||||
<a href="#" title="Facebook"><i class="fab fa-facebook-f"></i></a>
|
||||
<a href="#" title="Instagram"><i class="fab fa-instagram"></i></a>
|
||||
<a href="#" title="Twitter"><i class="fab fa-twitter"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="/assets/js/main.js"></script>
|
||||
|
||||
<!-- PWA Service Worker Registration -->
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js')
|
||||
.then(registration => {
|
||||
console.log('SW registered:', registration.scope);
|
||||
})
|
||||
.catch(error => {
|
||||
console.log('SW registration failed:', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php if (isset($extraScripts)) echo $extraScripts; ?>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,378 @@
|
||||
<?php
|
||||
/**
|
||||
* Tom's Java Jive - Helper Functions
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../config/config.php';
|
||||
require_once __DIR__ . '/db.php';
|
||||
|
||||
/**
|
||||
* Generate a unique ID with prefix
|
||||
*/
|
||||
function generateId($prefix = '') {
|
||||
return $prefix . bin2hex(random_bytes(6));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate order number
|
||||
*/
|
||||
function generateOrderNumber() {
|
||||
return 'JJ' . strtoupper(bin2hex(random_bytes(4)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash password using bcrypt
|
||||
*/
|
||||
function hashPassword($password) {
|
||||
return password_hash($password, PASSWORD_BCRYPT, ['cost' => HASH_COST]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify password
|
||||
*/
|
||||
function verifyPassword($password, $hash) {
|
||||
return password_verify($password, $hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize input
|
||||
*/
|
||||
function sanitize($input) {
|
||||
if (is_array($input)) {
|
||||
return array_map('sanitize', $input);
|
||||
}
|
||||
return htmlspecialchars(trim($input), ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format currency
|
||||
*/
|
||||
function formatCurrency($amount) {
|
||||
return TJJ_CURRENCY_SYMBOL . number_format((float)$amount, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date
|
||||
*/
|
||||
function formatDate($date, $format = 'M j, Y') {
|
||||
return date($format, strtotime($date));
|
||||
}
|
||||
|
||||
/**
|
||||
* Format datetime
|
||||
*/
|
||||
function formatDateTime($date, $format = 'M j, Y g:i A') {
|
||||
return date($format, strtotime($date));
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON response helper
|
||||
*/
|
||||
function jsonResponse($data, $statusCode = 200) {
|
||||
http_response_code($statusCode);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode($data);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect helper
|
||||
*/
|
||||
function redirect($url) {
|
||||
header("Location: $url");
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current URL
|
||||
*/
|
||||
function currentUrl() {
|
||||
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
|
||||
return $protocol . '://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if request is AJAX
|
||||
*/
|
||||
function isAjax() {
|
||||
return !empty($_SERVER['HTTP_X_REQUESTED_WITH']) &&
|
||||
strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client IP address
|
||||
*/
|
||||
function getClientIp() {
|
||||
$ip = $_SERVER['REMOTE_ADDR'];
|
||||
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
|
||||
$ip = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])[0];
|
||||
} elseif (!empty($_SERVER['HTTP_CLIENT_IP'])) {
|
||||
$ip = $_SERVER['HTTP_CLIENT_IP'];
|
||||
}
|
||||
return trim($ip);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate CSRF token
|
||||
*/
|
||||
function generateCsrfToken() {
|
||||
if (empty($_SESSION[CSRF_TOKEN_NAME])) {
|
||||
$_SESSION[CSRF_TOKEN_NAME] = bin2hex(random_bytes(32));
|
||||
}
|
||||
return $_SESSION[CSRF_TOKEN_NAME];
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify CSRF token
|
||||
*/
|
||||
function verifyCsrfToken($token) {
|
||||
return isset($_SESSION[CSRF_TOKEN_NAME]) && hash_equals($_SESSION[CSRF_TOKEN_NAME], $token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get setting value
|
||||
*/
|
||||
function getSetting($key, $default = null) {
|
||||
$result = db()->fetch(
|
||||
"SELECT setting_value FROM settings WHERE setting_key = :key",
|
||||
['key' => $key]
|
||||
);
|
||||
if ($result) {
|
||||
return json_decode($result['setting_value'], true) ?? $result['setting_value'];
|
||||
}
|
||||
return $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update setting value
|
||||
*/
|
||||
function setSetting($key, $value) {
|
||||
$jsonValue = json_encode($value);
|
||||
$existing = db()->fetch(
|
||||
"SELECT id FROM settings WHERE setting_key = :key",
|
||||
['key' => $key]
|
||||
);
|
||||
|
||||
if ($existing) {
|
||||
db()->update('settings', ['setting_value' => $jsonValue], 'setting_key = :key', ['key' => $key]);
|
||||
} else {
|
||||
db()->insert('settings', ['setting_key' => $key, 'setting_value' => $jsonValue]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flash message helpers
|
||||
*/
|
||||
function setFlash($type, $message) {
|
||||
$_SESSION['flash'][$type] = $message;
|
||||
}
|
||||
|
||||
function getFlash($type) {
|
||||
if (isset($_SESSION['flash'][$type])) {
|
||||
$message = $_SESSION['flash'][$type];
|
||||
unset($_SESSION['flash'][$type]);
|
||||
return $message;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasFlash($type) {
|
||||
return isset($_SESSION['flash'][$type]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pagination helper
|
||||
*/
|
||||
function paginate($totalItems, $currentPage, $perPage = ITEMS_PER_PAGE) {
|
||||
$totalPages = ceil($totalItems / $perPage);
|
||||
$currentPage = max(1, min($currentPage, $totalPages));
|
||||
$offset = ($currentPage - 1) * $perPage;
|
||||
|
||||
return [
|
||||
'total_items' => $totalItems,
|
||||
'total_pages' => $totalPages,
|
||||
'current_page' => $currentPage,
|
||||
'per_page' => $perPage,
|
||||
'offset' => $offset,
|
||||
'has_prev' => $currentPage > 1,
|
||||
'has_next' => $currentPage < $totalPages
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render pagination HTML
|
||||
*/
|
||||
function renderPagination($pagination, $baseUrl) {
|
||||
if ($pagination['total_pages'] <= 1) return '';
|
||||
|
||||
$html = '<nav class="pagination"><ul>';
|
||||
|
||||
// Previous
|
||||
if ($pagination['has_prev']) {
|
||||
$html .= '<li><a href="' . $baseUrl . '?page=' . ($pagination['current_page'] - 1) . '">« Previous</a></li>';
|
||||
}
|
||||
|
||||
// Page numbers
|
||||
for ($i = 1; $i <= $pagination['total_pages']; $i++) {
|
||||
if ($i == $pagination['current_page']) {
|
||||
$html .= '<li class="active"><span>' . $i . '</span></li>';
|
||||
} else {
|
||||
$html .= '<li><a href="' . $baseUrl . '?page=' . $i . '">' . $i . '</a></li>';
|
||||
}
|
||||
}
|
||||
|
||||
// Next
|
||||
if ($pagination['has_next']) {
|
||||
$html .= '<li><a href="' . $baseUrl . '?page=' . ($pagination['current_page'] + 1) . '">Next »</a></li>';
|
||||
}
|
||||
|
||||
$html .= '</ul></nav>';
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate text
|
||||
*/
|
||||
function truncate($text, $length = 100, $suffix = '...') {
|
||||
if (strlen($text) <= $length) return $text;
|
||||
return substr($text, 0, $length) . $suffix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slugify text
|
||||
*/
|
||||
function slugify($text) {
|
||||
$text = preg_replace('~[^\pL\d]+~u', '-', $text);
|
||||
$text = iconv('utf-8', 'us-ascii//TRANSLIT', $text);
|
||||
$text = preg_replace('~[^-\w]+~', '', $text);
|
||||
$text = trim($text, '-');
|
||||
$text = preg_replace('~-+~', '-', $text);
|
||||
return strtolower($text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cart from session
|
||||
*/
|
||||
function getCart() {
|
||||
return $_SESSION['cart'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add item to cart
|
||||
*/
|
||||
function addToCart($productId, $quantity = 1) {
|
||||
if (!isset($_SESSION['cart'])) {
|
||||
$_SESSION['cart'] = [];
|
||||
}
|
||||
|
||||
if (isset($_SESSION['cart'][$productId])) {
|
||||
$_SESSION['cart'][$productId] += $quantity;
|
||||
} else {
|
||||
$_SESSION['cart'][$productId] = $quantity;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update cart item quantity
|
||||
*/
|
||||
function updateCartItem($productId, $quantity) {
|
||||
if ($quantity <= 0) {
|
||||
removeFromCart($productId);
|
||||
} else {
|
||||
$_SESSION['cart'][$productId] = $quantity;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove item from cart
|
||||
*/
|
||||
function removeFromCart($productId) {
|
||||
unset($_SESSION['cart'][$productId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cart
|
||||
*/
|
||||
function clearCart() {
|
||||
$_SESSION['cart'] = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cart count
|
||||
*/
|
||||
function getCartCount() {
|
||||
return array_sum($_SESSION['cart'] ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cart total
|
||||
*/
|
||||
function getCartTotal() {
|
||||
$total = 0;
|
||||
$cart = getCart();
|
||||
|
||||
foreach ($cart as $productId => $quantity) {
|
||||
$product = db()->fetch(
|
||||
"SELECT price, sale_price FROM products WHERE product_id = :id AND is_active = 1",
|
||||
['id' => $productId]
|
||||
);
|
||||
if ($product) {
|
||||
$price = $product['sale_price'] ?? $product['price'];
|
||||
$total += $price * $quantity;
|
||||
}
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send email using SendGrid
|
||||
*/
|
||||
function sendEmail($to, $subject, $htmlContent, $textContent = '') {
|
||||
if (empty(SENDGRID_API_KEY)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$data = [
|
||||
'personalizations' => [
|
||||
[
|
||||
'to' => [['email' => $to]],
|
||||
'subject' => $subject
|
||||
]
|
||||
],
|
||||
'from' => [
|
||||
'email' => SENDER_EMAIL,
|
||||
'name' => SENDER_NAME
|
||||
],
|
||||
'content' => [
|
||||
['type' => 'text/html', 'value' => $htmlContent]
|
||||
]
|
||||
];
|
||||
|
||||
if ($textContent) {
|
||||
array_unshift($data['content'], ['type' => 'text/plain', 'value' => $textContent]);
|
||||
}
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, 'https://api.sendgrid.com/v3/mail/send');
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Authorization: Bearer ' . SENDGRID_API_KEY,
|
||||
'Content-Type: application/json'
|
||||
]);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
return $httpCode >= 200 && $httpCode < 300;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log activity
|
||||
*/
|
||||
function logActivity($action, $details = [], $userId = null) {
|
||||
// Implement activity logging if needed
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
/**
|
||||
* Tom's Java Jive - Header Include
|
||||
*/
|
||||
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/db.php';
|
||||
require_once __DIR__ . '/../config/config.php';
|
||||
require_once __DIR__ . '/auth.php';
|
||||
|
||||
$cartCount = 0;
|
||||
$cart = $_SESSION['cart'] ?? [];
|
||||
foreach ($cart as $qty) {
|
||||
$cartCount += $qty;
|
||||
}
|
||||
|
||||
$isLoggedIn = CustomerAuth::isLoggedIn();
|
||||
$customerUser = $isLoggedIn ? CustomerAuth::getUser() : null;
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= $pageTitle ?? SITE_NAME ?></title>
|
||||
<!-- SEO Meta Tags -->
|
||||
<meta name="description" content="<?= htmlspecialchars($metaDescription ?? "Premium artisan coffee beans freshly roasted and delivered to your door. Shop single origin, blends, and specialty coffee from Tom's Java Jive.") ?>">
|
||||
<meta name="keywords" content="<?= htmlspecialchars($metaKeywords ?? "artisan coffee, coffee beans, single origin coffee, fresh roasted coffee, Weatherford Texas coffee, specialty coffee") ?>">
|
||||
<meta name="robots" content="<?= $metaRobots ?? 'index, follow' ?>">
|
||||
<link rel="canonical" href="<?= $canonicalUrl ?? 'https://tomsjavajive.com' . strtok($_SERVER['REQUEST_URI'], '?') ?>">
|
||||
<meta property="og:type" content="<?= $ogType ?? 'website' ?>">
|
||||
<meta property="og:site_name" content="Tom's Java Jive">
|
||||
<meta property="og:title" content="<?= htmlspecialchars($metaTitle ?? ($pageTitle ?? "Tom's Java Jive")) ?>">
|
||||
<meta property="og:description" content="<?= htmlspecialchars($metaDescription ?? "Premium artisan coffee beans freshly roasted and delivered to your door.") ?>">
|
||||
<meta property="og:url" content="<?= $canonicalUrl ?? 'https://tomsjavajive.com' . $_SERVER['REQUEST_URI'] ?>">
|
||||
<meta property="og:image" content="<?= $ogImage ?? 'https://tomsjavajive.com/assets/images/og-image.jpg' ?>">
|
||||
<meta property="og:locale" content="en_US">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="<?= htmlspecialchars($metaTitle ?? ($pageTitle ?? "Tom's Java Jive")) ?>">
|
||||
<meta name="twitter:description" content="<?= htmlspecialchars($metaDescription ?? "Premium artisan coffee beans freshly roasted and delivered to your door.") ?>">
|
||||
<meta name="twitter:image" content="<?= $ogImage ?? 'https://tomsjavajive.com/assets/images/og-image.jpg' ?>">
|
||||
<?php if (empty($suppressSchema)): ?>
|
||||
<script type="application/ld+json">
|
||||
{"@context":"https://schema.org","@graph":[{"@type":["LocalBusiness","Store","FoodEstablishment"],"@id":"https://tomsjavajive.com/#business","name":"Tom's Java Jive","description":"Premium artisan coffee beans freshly roasted and shipped to your door. Specializing in single origin, signature blends, and specialty roasts from Weatherford, Texas.","url":"https://tomsjavajive.com","telephone":"+18175550120","email":"hello@tomsjavajive.com","priceRange":"$$","currenciesAccepted":"USD","paymentAccepted":"Credit Card, PayPal","servesCuisine":"Coffee","address":{"@type":"PostalAddress","addressLocality":"Weatherford","addressRegion":"TX","postalCode":"76086","addressCountry":"US"},"geo":{"@type":"GeoCoordinates","latitude":32.7554,"longitude":-97.7981},"hasOfferCatalog":{"@type":"OfferCatalog","name":"Coffee Products","itemListElement":[{"@type":"Offer","itemOffered":{"@type":"Product","name":"Single Origin Coffee Beans","description":"Hand-selected single origin beans from around the world, freshly roasted to order."}},{"@type":"Offer","itemOffered":{"@type":"Product","name":"Signature Blend Coffee","description":"Tom's signature house blends crafted for balanced, full-flavored cups."}}]}},{"@type":"WebSite","@id":"https://tomsjavajive.com/#website","url":"https://tomsjavajive.com","name":"Tom's Java Jive","publisher":{"@id":"https://tomsjavajive.com/#business"},"potentialAction":{"@type":"SearchAction","target":"https://tomsjavajive.com/shop.php?search={search_term_string}","query-input":"required name=search_term_string"}}]}
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($breadcrumbs)): ?>
|
||||
<script type="application/ld+json">
|
||||
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":<?= json_encode(array_map(function($b,$i){return["@type"=>"ListItem","position"=>$i+1,"name"=>$b['name'],"item"=>$b['url']];},$breadcrumbs,array_keys($breadcrumbs)),JSON_UNESCAPED_SLASHES) ?>}
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($productSchema)): ?>
|
||||
<script type="application/ld+json"><?= json_encode($productSchema,JSON_UNESCAPED_SLASHES) ?></script>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
<meta name="theme-color" content="#FF5E1A">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<meta name="apple-mobile-web-app-title" content="Java Jive">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<link rel="apple-touch-icon" href="/assets/icons/icon-192.png">
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@600;700&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Icons -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
|
||||
<!-- Styles -->
|
||||
<link rel="stylesheet" href="/assets/css/style.css">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" href="/assets/images/logo.png" type="image/png">
|
||||
|
||||
<?php if (strpos($_SERVER['REQUEST_URI'], 'payment') !== false || strpos($_SERVER['REQUEST_URI'], 'checkout') !== false): ?>
|
||||
<script src="https://js.stripe.com/v3/"></script>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (isset($extraHead)) echo $extraHead; ?>
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<nav class="nav container">
|
||||
<a href="/" class="logo">
|
||||
<img src="/assets/images/logo.png" alt="<?= SITE_NAME ?>" class="logo-img">
|
||||
</a>
|
||||
|
||||
<ul class="nav-links">
|
||||
<li><a href="/" class="<?= $_SERVER['REQUEST_URI'] === '/' ? 'active' : '' ?>">Home</a></li>
|
||||
<li><a href="/shop.php" class="<?= strpos($_SERVER['REQUEST_URI'], 'shop') !== false ? 'active' : '' ?>">Shop</a></li>
|
||||
<li><a href="/#about">About</a></li>
|
||||
<?php if ($isLoggedIn): ?>
|
||||
<li><a href="/account/" class="<?= strpos($_SERVER['REQUEST_URI'], 'account') !== false ? 'active' : '' ?>">Account</a></li>
|
||||
<?php else: ?>
|
||||
<li><a href="/login.php">Account</a></li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
|
||||
<div class="nav-actions">
|
||||
<a href="/cart.php" class="cart-btn" title="Shopping Cart">
|
||||
<i class="fas fa-shopping-bag"></i>
|
||||
<?php if ($cartCount > 0): ?>
|
||||
<span class="cart-count"><?= $cartCount ?></span>
|
||||
<?php endif; ?>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
@@ -0,0 +1,438 @@
|
||||
<?php
|
||||
/**
|
||||
* Tom's Java Jive - Customer Loyalty Program
|
||||
*
|
||||
* Handles loyalty tiers, points, and rewards
|
||||
*/
|
||||
|
||||
class LoyaltyProgram {
|
||||
|
||||
// Loyalty tier definitions
|
||||
private array $tiers = [
|
||||
'bronze' => [
|
||||
'name' => 'Bronze Bean',
|
||||
'min_points' => 0,
|
||||
'multiplier' => 1.0,
|
||||
'benefits' => [
|
||||
'Earn 1 point per $1 spent',
|
||||
'Birthday reward',
|
||||
'Member-only offers'
|
||||
],
|
||||
'color' => '#CD7F32',
|
||||
'icon' => 'fa-coffee'
|
||||
],
|
||||
'silver' => [
|
||||
'name' => 'Silver Roast',
|
||||
'min_points' => 500,
|
||||
'multiplier' => 1.25,
|
||||
'benefits' => [
|
||||
'Earn 1.25 points per $1 spent',
|
||||
'Free shipping on orders $25+',
|
||||
'Early access to new products',
|
||||
'Double points weekends'
|
||||
],
|
||||
'color' => '#C0C0C0',
|
||||
'icon' => 'fa-mug-hot'
|
||||
],
|
||||
'gold' => [
|
||||
'name' => 'Gold Blend',
|
||||
'min_points' => 1500,
|
||||
'multiplier' => 1.5,
|
||||
'benefits' => [
|
||||
'Earn 1.5 points per $1 spent',
|
||||
'Free shipping on all orders',
|
||||
'Exclusive Gold-only products',
|
||||
'Priority customer support',
|
||||
'Quarterly free coffee sample'
|
||||
],
|
||||
'color' => '#FFD700',
|
||||
'icon' => 'fa-crown'
|
||||
],
|
||||
'platinum' => [
|
||||
'name' => 'Platinum Reserve',
|
||||
'min_points' => 5000,
|
||||
'multiplier' => 2.0,
|
||||
'benefits' => [
|
||||
'Earn 2 points per $1 spent',
|
||||
'Free express shipping',
|
||||
'VIP early access to everything',
|
||||
'Annual free bag of premium coffee',
|
||||
'Dedicated account manager',
|
||||
'Exclusive tasting events'
|
||||
],
|
||||
'color' => '#E5E4E2',
|
||||
'icon' => 'fa-gem'
|
||||
]
|
||||
];
|
||||
|
||||
// Points redemption rates
|
||||
private float $pointsToValue = 0.01; // 1 point = $0.01 (100 points = $1)
|
||||
|
||||
/**
|
||||
* Get all tier definitions
|
||||
*/
|
||||
public function getTiers(): array {
|
||||
return $this->tiers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific tier
|
||||
*/
|
||||
public function getTier(string $tierKey): ?array {
|
||||
return $this->tiers[$tierKey] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine customer's tier based on total points earned (lifetime)
|
||||
*/
|
||||
public function calculateTier(int $lifetimePoints): string {
|
||||
$currentTier = 'bronze';
|
||||
|
||||
foreach ($this->tiers as $key => $tier) {
|
||||
if ($lifetimePoints >= $tier['min_points']) {
|
||||
$currentTier = $key;
|
||||
}
|
||||
}
|
||||
|
||||
return $currentTier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get customer's current tier info
|
||||
*/
|
||||
public function getCustomerTier(string $customerId): array {
|
||||
$customer = db()->fetch(
|
||||
"SELECT reward_points, lifetime_points, loyalty_tier FROM customers WHERE customer_id = :id",
|
||||
['id' => $customerId]
|
||||
);
|
||||
|
||||
if (!$customer) {
|
||||
return ['tier' => 'bronze', 'info' => $this->tiers['bronze'], 'points' => 0];
|
||||
}
|
||||
|
||||
$lifetimePoints = $customer['lifetime_points'] ?? $customer['reward_points'] ?? 0;
|
||||
$tierKey = $this->calculateTier($lifetimePoints);
|
||||
$tier = $this->tiers[$tierKey];
|
||||
|
||||
// Calculate progress to next tier
|
||||
$nextTierKey = $this->getNextTier($tierKey);
|
||||
$nextTier = $nextTierKey ? $this->tiers[$nextTierKey] : null;
|
||||
|
||||
$progress = 100;
|
||||
$pointsToNext = 0;
|
||||
|
||||
if ($nextTier) {
|
||||
$currentMin = $tier['min_points'];
|
||||
$nextMin = $nextTier['min_points'];
|
||||
$pointsToNext = $nextMin - $lifetimePoints;
|
||||
$progress = min(100, (($lifetimePoints - $currentMin) / ($nextMin - $currentMin)) * 100);
|
||||
}
|
||||
|
||||
return [
|
||||
'tier' => $tierKey,
|
||||
'info' => $tier,
|
||||
'points' => $customer['reward_points'] ?? 0,
|
||||
'lifetime_points' => $lifetimePoints,
|
||||
'next_tier' => $nextTierKey,
|
||||
'next_tier_info' => $nextTier,
|
||||
'points_to_next' => $pointsToNext,
|
||||
'progress_percent' => round($progress, 1)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next tier key
|
||||
*/
|
||||
private function getNextTier(string $currentTier): ?string {
|
||||
$keys = array_keys($this->tiers);
|
||||
$index = array_search($currentTier, $keys);
|
||||
|
||||
return isset($keys[$index + 1]) ? $keys[$index + 1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Award points for a purchase
|
||||
*/
|
||||
public function awardPoints(string $customerId, float $amount, string $description = 'Purchase'): array {
|
||||
$customerTier = $this->getCustomerTier($customerId);
|
||||
$multiplier = $customerTier['info']['multiplier'];
|
||||
|
||||
// Calculate points (base: 1 point per dollar)
|
||||
$basePoints = floor($amount);
|
||||
$bonusPoints = floor($basePoints * ($multiplier - 1));
|
||||
$totalPoints = $basePoints + $bonusPoints;
|
||||
|
||||
// Update customer points
|
||||
db()->query(
|
||||
"UPDATE customers SET
|
||||
reward_points = reward_points + :points,
|
||||
lifetime_points = COALESCE(lifetime_points, 0) + :points,
|
||||
updated_at = NOW()
|
||||
WHERE customer_id = :id",
|
||||
['points' => $totalPoints, 'id' => $customerId]
|
||||
);
|
||||
|
||||
// Log the transaction
|
||||
db()->insert('loyalty_transactions', [
|
||||
'transaction_id' => generateId('lt_'),
|
||||
'customer_id' => $customerId,
|
||||
'points' => $totalPoints,
|
||||
'type' => 'earn',
|
||||
'description' => $description . ($bonusPoints > 0 ? " (+{$bonusPoints} bonus)" : ''),
|
||||
'reference_amount' => $amount
|
||||
]);
|
||||
|
||||
// Check for tier upgrade
|
||||
$newTier = $this->checkTierUpgrade($customerId, $customerTier['tier']);
|
||||
|
||||
return [
|
||||
'points_earned' => $totalPoints,
|
||||
'base_points' => $basePoints,
|
||||
'bonus_points' => $bonusPoints,
|
||||
'multiplier' => $multiplier,
|
||||
'tier_upgraded' => $newTier !== null,
|
||||
'new_tier' => $newTier
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeem points for credit
|
||||
*/
|
||||
public function redeemPoints(string $customerId, int $points): array {
|
||||
$customer = db()->fetch(
|
||||
"SELECT reward_points FROM customers WHERE customer_id = :id",
|
||||
['id' => $customerId]
|
||||
);
|
||||
|
||||
if (!$customer || $customer['reward_points'] < $points) {
|
||||
return ['success' => false, 'error' => 'Insufficient points'];
|
||||
}
|
||||
|
||||
$creditValue = $points * $this->pointsToValue;
|
||||
|
||||
// Deduct points
|
||||
db()->query(
|
||||
"UPDATE customers SET reward_points = reward_points - :points, updated_at = NOW() WHERE customer_id = :id",
|
||||
['points' => $points, 'id' => $customerId]
|
||||
);
|
||||
|
||||
// Log the redemption
|
||||
db()->insert('loyalty_transactions', [
|
||||
'transaction_id' => generateId('lt_'),
|
||||
'customer_id' => $customerId,
|
||||
'points' => -$points,
|
||||
'type' => 'redeem',
|
||||
'description' => "Redeemed for " . formatCurrency($creditValue) . " credit",
|
||||
'reference_amount' => $creditValue
|
||||
]);
|
||||
|
||||
// Add to wallet
|
||||
$newBalance = db()->fetch(
|
||||
"SELECT wallet_balance FROM customers WHERE customer_id = :id",
|
||||
['id' => $customerId]
|
||||
)['wallet_balance'] ?? 0;
|
||||
|
||||
$newBalance += $creditValue;
|
||||
|
||||
db()->query(
|
||||
"UPDATE customers SET wallet_balance = :balance WHERE customer_id = :id",
|
||||
['balance' => $newBalance, 'id' => $customerId]
|
||||
);
|
||||
|
||||
// Log wallet transaction
|
||||
db()->insert('wallet_transactions', [
|
||||
'transaction_id' => generateId('wt_'),
|
||||
'customer_id' => $customerId,
|
||||
'amount' => $creditValue,
|
||||
'balance_after' => $newBalance,
|
||||
'type' => 'loyalty_redemption',
|
||||
'description' => "Redeemed {$points} loyalty points"
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'points_redeemed' => $points,
|
||||
'credit_value' => $creditValue,
|
||||
'new_points_balance' => $customer['reward_points'] - $points,
|
||||
'new_wallet_balance' => $newBalance
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check and process tier upgrade
|
||||
*/
|
||||
public function checkTierUpgrade(string $customerId, string $currentTier): ?string {
|
||||
$customer = db()->fetch(
|
||||
"SELECT lifetime_points, loyalty_tier FROM customers WHERE customer_id = :id",
|
||||
['id' => $customerId]
|
||||
);
|
||||
|
||||
if (!$customer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$calculatedTier = $this->calculateTier($customer['lifetime_points'] ?? 0);
|
||||
$storedTier = $customer['loyalty_tier'] ?? 'bronze';
|
||||
|
||||
// Compare tier levels
|
||||
$tierOrder = ['bronze', 'silver', 'gold', 'platinum'];
|
||||
$calculatedIndex = array_search($calculatedTier, $tierOrder);
|
||||
$storedIndex = array_search($storedTier, $tierOrder);
|
||||
|
||||
if ($calculatedIndex > $storedIndex) {
|
||||
// Upgrade!
|
||||
db()->query(
|
||||
"UPDATE customers SET loyalty_tier = :tier, updated_at = NOW() WHERE customer_id = :id",
|
||||
['tier' => $calculatedTier, 'id' => $customerId]
|
||||
);
|
||||
|
||||
// Log the upgrade
|
||||
db()->insert('loyalty_transactions', [
|
||||
'transaction_id' => generateId('lt_'),
|
||||
'customer_id' => $customerId,
|
||||
'points' => 0,
|
||||
'type' => 'tier_upgrade',
|
||||
'description' => "Upgraded from {$this->tiers[$storedTier]['name']} to {$this->tiers[$calculatedTier]['name']}"
|
||||
]);
|
||||
|
||||
// Send notifications
|
||||
$this->sendTierUpgradeNotifications($customerId, $calculatedTier);
|
||||
|
||||
return $calculatedTier;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send tier upgrade notifications
|
||||
*/
|
||||
private function sendTierUpgradeNotifications(string $customerId, string $newTier): void {
|
||||
$customer = db()->fetch(
|
||||
"SELECT email, phone, name FROM customers WHERE customer_id = :id",
|
||||
['id' => $customerId]
|
||||
);
|
||||
|
||||
if (!$customer) return;
|
||||
|
||||
$tierInfo = $this->tiers[$newTier];
|
||||
|
||||
// Send email notification
|
||||
if (!empty($customer['email'])) {
|
||||
require_once __DIR__ . '/email.php';
|
||||
// Custom email for tier upgrade would go here
|
||||
}
|
||||
|
||||
// Send SMS notification
|
||||
if (!empty($customer['phone'])) {
|
||||
require_once __DIR__ . '/sms.php';
|
||||
sendSMS()->sendTierUpgrade($customer['phone'], $tierInfo['name']);
|
||||
}
|
||||
|
||||
// Send push notification
|
||||
require_once __DIR__ . '/push.php';
|
||||
pushNotify()->sendTierNotification($customerId, $tierInfo['name'], $tierInfo['benefits']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get points conversion info
|
||||
*/
|
||||
public function getConversionInfo(): array {
|
||||
return [
|
||||
'points_per_dollar' => 1,
|
||||
'points_value' => $this->pointsToValue,
|
||||
'points_for_one_dollar' => intval(1 / $this->pointsToValue),
|
||||
'description' => 'Earn 1 point for every $1 spent. Redeem 100 points for $1 credit.'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get customer's loyalty history
|
||||
*/
|
||||
public function getHistory(string $customerId, int $limit = 20): array {
|
||||
return db()->fetchAll(
|
||||
"SELECT * FROM loyalty_transactions WHERE customer_id = :id ORDER BY created_at DESC LIMIT :limit",
|
||||
['id' => $customerId, 'limit' => $limit]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Award birthday bonus
|
||||
*/
|
||||
public function awardBirthdayBonus(string $customerId): array {
|
||||
$customerTier = $this->getCustomerTier($customerId);
|
||||
|
||||
// Bonus points based on tier
|
||||
$bonusPoints = match($customerTier['tier']) {
|
||||
'platinum' => 500,
|
||||
'gold' => 300,
|
||||
'silver' => 200,
|
||||
default => 100
|
||||
};
|
||||
|
||||
db()->query(
|
||||
"UPDATE customers SET reward_points = reward_points + :points WHERE customer_id = :id",
|
||||
['points' => $bonusPoints, 'id' => $customerId]
|
||||
);
|
||||
|
||||
db()->insert('loyalty_transactions', [
|
||||
'transaction_id' => generateId('lt_'),
|
||||
'customer_id' => $customerId,
|
||||
'points' => $bonusPoints,
|
||||
'type' => 'birthday_bonus',
|
||||
'description' => "Birthday reward - Happy Birthday!"
|
||||
]);
|
||||
|
||||
return ['success' => true, 'points' => $bonusPoints];
|
||||
}
|
||||
|
||||
/**
|
||||
* Award referral bonus
|
||||
*/
|
||||
public function awardReferralBonus(string $referrerId, string $newCustomerId): array {
|
||||
$referrerBonus = 100;
|
||||
$newCustomerBonus = 50;
|
||||
|
||||
// Award to referrer
|
||||
db()->query(
|
||||
"UPDATE customers SET reward_points = reward_points + :points WHERE customer_id = :id",
|
||||
['points' => $referrerBonus, 'id' => $referrerId]
|
||||
);
|
||||
|
||||
db()->insert('loyalty_transactions', [
|
||||
'transaction_id' => generateId('lt_'),
|
||||
'customer_id' => $referrerId,
|
||||
'points' => $referrerBonus,
|
||||
'type' => 'referral_bonus',
|
||||
'description' => "Referral bonus - Thank you for spreading the word!"
|
||||
]);
|
||||
|
||||
// Award to new customer
|
||||
db()->query(
|
||||
"UPDATE customers SET reward_points = reward_points + :points WHERE customer_id = :id",
|
||||
['points' => $newCustomerBonus, 'id' => $newCustomerId]
|
||||
);
|
||||
|
||||
db()->insert('loyalty_transactions', [
|
||||
'transaction_id' => generateId('lt_'),
|
||||
'customer_id' => $newCustomerId,
|
||||
'points' => $newCustomerBonus,
|
||||
'type' => 'referral_welcome',
|
||||
'description' => "Welcome bonus from referral"
|
||||
]);
|
||||
|
||||
return [
|
||||
'referrer_bonus' => $referrerBonus,
|
||||
'new_customer_bonus' => $newCustomerBonus
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function
|
||||
function loyalty(): LoyaltyProgram {
|
||||
static $instance = null;
|
||||
if ($instance === null) {
|
||||
$instance = new LoyaltyProgram();
|
||||
}
|
||||
return $instance;
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
/**
|
||||
* Tom's Java Jive - Push Notification Service
|
||||
*
|
||||
* Handles web push notifications using VAPID
|
||||
*/
|
||||
|
||||
class PushNotification {
|
||||
private string $publicKey;
|
||||
private string $privateKey;
|
||||
private string $subject;
|
||||
|
||||
public function __construct() {
|
||||
// VAPID keys - generate your own at: https://web-push-codelab.glitch.me/
|
||||
$this->publicKey = getSetting('vapid_public_key', 'YOUR_VAPID_PUBLIC_KEY');
|
||||
$this->privateKey = getSetting('vapid_private_key', 'YOUR_VAPID_PRIVATE_KEY');
|
||||
$this->subject = 'mailto:' . getSetting('admin_email', 'admin@tomsjavajive.com');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get VAPID public key for client
|
||||
*/
|
||||
public function getPublicKey(): string {
|
||||
return $this->publicKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send push notification to a subscription
|
||||
*/
|
||||
public function send(array $subscription, string $title, string $body, array $options = []): array {
|
||||
$payload = json_encode([
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
'icon' => $options['icon'] ?? '/assets/icons/icon-192.png',
|
||||
'badge' => $options['badge'] ?? '/assets/icons/badge-72.png',
|
||||
'url' => $options['url'] ?? '/',
|
||||
'tag' => $options['tag'] ?? null,
|
||||
'data' => $options['data'] ?? []
|
||||
]);
|
||||
|
||||
// For now, we'll store notifications for when the user comes online
|
||||
// Full web push requires a library like minishlink/web-push
|
||||
// This is a simplified version that works with the service worker
|
||||
|
||||
try {
|
||||
// Store notification for retrieval
|
||||
$notificationId = generateId('notif_');
|
||||
db()->insert('push_notifications', [
|
||||
'notification_id' => $notificationId,
|
||||
'subscription_endpoint' => $subscription['endpoint'],
|
||||
'payload' => $payload,
|
||||
'status' => 'pending',
|
||||
'created_at' => date('Y-m-d H:i:s')
|
||||
]);
|
||||
|
||||
return ['success' => true, 'notification_id' => $notificationId];
|
||||
} catch (Exception $e) {
|
||||
return ['success' => false, 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send notification to all subscribed users
|
||||
*/
|
||||
public function broadcast(string $title, string $body, array $options = []): array {
|
||||
$subscriptions = db()->fetchAll("SELECT * FROM push_subscriptions WHERE is_active = 1");
|
||||
|
||||
$results = ['sent' => 0, 'failed' => 0];
|
||||
|
||||
foreach ($subscriptions as $sub) {
|
||||
$subscription = [
|
||||
'endpoint' => $sub['endpoint'],
|
||||
'keys' => [
|
||||
'p256dh' => $sub['p256dh_key'],
|
||||
'auth' => $sub['auth_key']
|
||||
]
|
||||
];
|
||||
|
||||
$result = $this->send($subscription, $title, $body, $options);
|
||||
|
||||
if ($result['success']) {
|
||||
$results['sent']++;
|
||||
} else {
|
||||
$results['failed']++;
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send order status update notification
|
||||
*/
|
||||
public function sendOrderUpdate(string $customerId, array $order, string $status): array {
|
||||
$subscription = $this->getCustomerSubscription($customerId);
|
||||
|
||||
if (!$subscription) {
|
||||
return ['success' => false, 'error' => 'No subscription found'];
|
||||
}
|
||||
|
||||
$messages = [
|
||||
'confirmed' => "Your order #{$order['order_number']} has been confirmed!",
|
||||
'processing' => "We're preparing your order #{$order['order_number']}",
|
||||
'shipped' => "Your order #{$order['order_number']} is on its way!",
|
||||
'delivered' => "Your order #{$order['order_number']} has been delivered!",
|
||||
'ready' => "Your order #{$order['order_number']} is ready for pickup!"
|
||||
];
|
||||
|
||||
return $this->send(
|
||||
$subscription,
|
||||
"Order Update",
|
||||
$messages[$status] ?? "Order #{$order['order_number']} status: {$status}",
|
||||
[
|
||||
'url' => "/account/order.php?id={$order['order_id']}",
|
||||
'tag' => "order-{$order['order_id']}"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send promotional notification
|
||||
*/
|
||||
public function sendPromotion(string $customerId, string $title, string $message, string $url = '/shop.php'): array {
|
||||
$subscription = $this->getCustomerSubscription($customerId);
|
||||
|
||||
if (!$subscription) {
|
||||
return ['success' => false, 'error' => 'No subscription found'];
|
||||
}
|
||||
|
||||
return $this->send($subscription, $title, $message, ['url' => $url]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send loyalty tier notification
|
||||
*/
|
||||
public function sendTierNotification(string $customerId, string $tierName, array $benefits): array {
|
||||
$subscription = $this->getCustomerSubscription($customerId);
|
||||
|
||||
if (!$subscription) {
|
||||
return ['success' => false, 'error' => 'No subscription found'];
|
||||
}
|
||||
|
||||
return $this->send(
|
||||
$subscription,
|
||||
"Congratulations! You're now {$tierName}!",
|
||||
"Enjoy new benefits: " . implode(', ', array_slice($benefits, 0, 2)),
|
||||
['url' => '/account/']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get customer's push subscription
|
||||
*/
|
||||
private function getCustomerSubscription(string $customerId): ?array {
|
||||
$sub = db()->fetch(
|
||||
"SELECT * FROM push_subscriptions WHERE customer_id = :id AND is_active = 1 ORDER BY created_at DESC LIMIT 1",
|
||||
['id' => $customerId]
|
||||
);
|
||||
|
||||
if (!$sub) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'endpoint' => $sub['endpoint'],
|
||||
'keys' => [
|
||||
'p256dh' => $sub['p256dh_key'],
|
||||
'auth' => $sub['auth_key']
|
||||
]
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function for easy access
|
||||
function pushNotify(): PushNotification {
|
||||
static $instance = null;
|
||||
if ($instance === null) {
|
||||
$instance = new PushNotification();
|
||||
}
|
||||
return $instance;
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
/**
|
||||
* Tom's Java Jive - Twilio SMS Service
|
||||
*
|
||||
* Handles all SMS notifications using Twilio API
|
||||
*/
|
||||
|
||||
class TwilioSMS {
|
||||
private string $accountSid;
|
||||
private string $authToken;
|
||||
private string $fromNumber;
|
||||
|
||||
public function __construct() {
|
||||
// Load from settings or config
|
||||
$this->accountSid = getSetting('twilio_account_sid', 'YOUR_TWILIO_ACCOUNT_SID');
|
||||
$this->authToken = getSetting('twilio_auth_token', 'YOUR_TWILIO_AUTH_TOKEN');
|
||||
$this->fromNumber = getSetting('twilio_phone_number', '+1234567890');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send SMS via Twilio API
|
||||
*/
|
||||
public function send(string $to, string $message): array {
|
||||
// Ensure phone number is in E.164 format
|
||||
$to = $this->formatPhoneNumber($to);
|
||||
|
||||
if (!$to) {
|
||||
return ['success' => false, 'error' => 'Invalid phone number'];
|
||||
}
|
||||
|
||||
$url = "https://api.twilio.com/2010-04-01/Accounts/{$this->accountSid}/Messages.json";
|
||||
|
||||
$data = [
|
||||
'To' => $to,
|
||||
'From' => $this->fromNumber,
|
||||
'Body' => $message
|
||||
];
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => http_build_query($data),
|
||||
CURLOPT_USERPWD => "{$this->accountSid}:{$this->authToken}",
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 30
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($error) {
|
||||
return ['success' => false, 'error' => $error];
|
||||
}
|
||||
|
||||
$result = json_decode($response, true);
|
||||
|
||||
if ($httpCode >= 200 && $httpCode < 300) {
|
||||
return [
|
||||
'success' => true,
|
||||
'sid' => $result['sid'] ?? null,
|
||||
'status' => $result['status'] ?? 'queued'
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $result['message'] ?? 'Failed to send SMS',
|
||||
'code' => $result['code'] ?? $httpCode
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format phone number to E.164 format
|
||||
*/
|
||||
private function formatPhoneNumber(string $phone): ?string {
|
||||
// Remove all non-numeric characters except +
|
||||
$phone = preg_replace('/[^0-9+]/', '', $phone);
|
||||
|
||||
// If already in E.164 format
|
||||
if (preg_match('/^\+[1-9]\d{1,14}$/', $phone)) {
|
||||
return $phone;
|
||||
}
|
||||
|
||||
// US number without country code
|
||||
if (preg_match('/^1?\d{10}$/', $phone)) {
|
||||
$phone = preg_replace('/^1?/', '', $phone);
|
||||
return '+1' . $phone;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send order confirmation SMS
|
||||
*/
|
||||
public function sendOrderConfirmation(array $order, string $phone): array {
|
||||
$message = "Tom's Java Jive: Your order #{$order['order_number']} has been confirmed! " .
|
||||
"Total: " . formatCurrency($order['total']) . ". " .
|
||||
"Thank you for your purchase!";
|
||||
|
||||
return $this->send($phone, $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send shipping notification SMS
|
||||
*/
|
||||
public function sendShippingNotification(array $order, string $phone): array {
|
||||
$message = "Tom's Java Jive: Your order #{$order['order_number']} has shipped! " .
|
||||
"Tracking: {$order['tracking_number']}. " .
|
||||
"Track at: " . ($order['tracking_url'] ?? SITE_URL);
|
||||
|
||||
return $this->send($phone, $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send delivery notification SMS
|
||||
*/
|
||||
public function sendDeliveryNotification(array $order, string $phone): array {
|
||||
$message = "Tom's Java Jive: Great news! Your order #{$order['order_number']} " .
|
||||
"has been delivered. Enjoy your coffee!";
|
||||
|
||||
return $this->send($phone, $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send password reset SMS
|
||||
*/
|
||||
public function sendPasswordResetCode(string $phone, string $code): array {
|
||||
$message = "Tom's Java Jive: Your password reset code is {$code}. " .
|
||||
"This code expires in 15 minutes. Don't share it with anyone.";
|
||||
|
||||
return $this->send($phone, $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send OTP verification SMS
|
||||
*/
|
||||
public function sendVerificationCode(string $phone, string $code): array {
|
||||
$message = "Tom's Java Jive: Your verification code is {$code}. " .
|
||||
"Valid for 10 minutes.";
|
||||
|
||||
return $this->send($phone, $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send promotional SMS (with opt-out info)
|
||||
*/
|
||||
public function sendPromotion(string $phone, string $promoMessage): array {
|
||||
$message = "Tom's Java Jive: {$promoMessage} " .
|
||||
"Reply STOP to unsubscribe.";
|
||||
|
||||
return $this->send($phone, $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send order ready for pickup SMS
|
||||
*/
|
||||
public function sendReadyForPickup(array $order, string $phone): array {
|
||||
$message = "Tom's Java Jive: Your order #{$order['order_number']} is ready for pickup! " .
|
||||
"Show this message at the counter.";
|
||||
|
||||
return $this->send($phone, $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send low wallet balance alert
|
||||
*/
|
||||
public function sendLowBalanceAlert(string $phone, float $balance): array {
|
||||
$message = "Tom's Java Jive: Your wallet balance is " . formatCurrency($balance) . ". " .
|
||||
"Top up now to continue enjoying fast checkout!";
|
||||
|
||||
return $this->send($phone, $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send loyalty tier upgrade notification
|
||||
*/
|
||||
public function sendTierUpgrade(string $phone, string $tierName): array {
|
||||
$message = "Tom's Java Jive: Congratulations! You've reached {$tierName} status! " .
|
||||
"Enjoy your new benefits and rewards. Thank you for being a loyal customer!";
|
||||
|
||||
return $this->send($phone, $message);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function for easy access
|
||||
function sendSMS(): TwilioSMS {
|
||||
static $instance = null;
|
||||
if ($instance === null) {
|
||||
$instance = new TwilioSMS();
|
||||
}
|
||||
return $instance;
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
<?php
|
||||
/**
|
||||
* Tom's Java Jive - Stripe Integration (cURL-based for vanilla PHP)
|
||||
* Works without Composer - uses direct API calls
|
||||
*/
|
||||
|
||||
class StripeAPI {
|
||||
private $secretKey;
|
||||
private $apiBase = 'https://api.stripe.com/v1';
|
||||
|
||||
public function __construct($secretKey = null) {
|
||||
$this->secretKey = $secretKey ?: STRIPE_SECRET_KEY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a cURL request to Stripe API
|
||||
*/
|
||||
private function request($method, $endpoint, $data = []) {
|
||||
$url = $this->apiBase . $endpoint;
|
||||
|
||||
$ch = curl_init();
|
||||
|
||||
$headers = [
|
||||
'Authorization: Bearer ' . $this->secretKey,
|
||||
'Content-Type: application/x-www-form-urlencoded'
|
||||
];
|
||||
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
|
||||
|
||||
if ($method === 'POST') {
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
|
||||
} elseif ($method === 'GET' && !empty($data)) {
|
||||
$url .= '?' . http_build_query($data);
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
}
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($error) {
|
||||
throw new Exception('Stripe API Error: ' . $error);
|
||||
}
|
||||
|
||||
$decoded = json_decode($response, true);
|
||||
|
||||
if ($httpCode >= 400) {
|
||||
$errorMsg = $decoded['error']['message'] ?? 'Unknown Stripe error';
|
||||
throw new Exception($errorMsg);
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Payment Intent
|
||||
*/
|
||||
public function createPaymentIntent($amount, $currency = 'usd', $options = []) {
|
||||
$data = [
|
||||
'amount' => (int)($amount * 100), // Convert to cents
|
||||
'currency' => strtolower($currency),
|
||||
'automatic_payment_methods' => ['enabled' => 'true']
|
||||
];
|
||||
|
||||
if (!empty($options['metadata'])) {
|
||||
foreach ($options['metadata'] as $key => $value) {
|
||||
$data["metadata[$key]"] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($options['receipt_email'])) {
|
||||
$data['receipt_email'] = $options['receipt_email'];
|
||||
}
|
||||
|
||||
if (!empty($options['description'])) {
|
||||
$data['description'] = $options['description'];
|
||||
}
|
||||
|
||||
return $this->request('POST', '/payment_intents', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a Payment Intent
|
||||
*/
|
||||
public function getPaymentIntent($paymentIntentId) {
|
||||
return $this->request('GET', '/payment_intents/' . $paymentIntentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Checkout Session (hosted payment page)
|
||||
*/
|
||||
public function createCheckoutSession($lineItems, $successUrl, $cancelUrl, $options = []) {
|
||||
$data = [
|
||||
'mode' => $options['mode'] ?? 'payment',
|
||||
'success_url' => $successUrl,
|
||||
'cancel_url' => $cancelUrl
|
||||
];
|
||||
|
||||
// Add line items
|
||||
foreach ($lineItems as $i => $item) {
|
||||
$data["line_items[$i][price_data][currency]"] = $item['currency'] ?? 'usd';
|
||||
$data["line_items[$i][price_data][product_data][name]"] = $item['name'];
|
||||
$data["line_items[$i][price_data][unit_amount]"] = (int)($item['price'] * 100);
|
||||
$data["line_items[$i][quantity]"] = $item['quantity'] ?? 1;
|
||||
|
||||
if (!empty($item['description'])) {
|
||||
$data["line_items[$i][price_data][product_data][description]"] = $item['description'];
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($options['customer_email'])) {
|
||||
$data['customer_email'] = $options['customer_email'];
|
||||
}
|
||||
|
||||
if (!empty($options['metadata'])) {
|
||||
foreach ($options['metadata'] as $key => $value) {
|
||||
$data["metadata[$key]"] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->request('POST', '/checkout/sessions', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a Checkout Session
|
||||
*/
|
||||
public function getCheckoutSession($sessionId) {
|
||||
return $this->request('GET', '/checkout/sessions/' . $sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify webhook signature
|
||||
*/
|
||||
public function verifyWebhookSignature($payload, $sigHeader, $webhookSecret) {
|
||||
if (empty($webhookSecret)) {
|
||||
return true; // Skip verification if secret not configured
|
||||
}
|
||||
|
||||
$elements = explode(',', $sigHeader);
|
||||
$timestamp = null;
|
||||
$signatures = [];
|
||||
|
||||
foreach ($elements as $element) {
|
||||
$parts = explode('=', $element, 2);
|
||||
if (count($parts) === 2) {
|
||||
if ($parts[0] === 't') {
|
||||
$timestamp = $parts[1];
|
||||
} elseif ($parts[0] === 'v1') {
|
||||
$signatures[] = $parts[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($timestamp) || empty($signatures)) {
|
||||
throw new Exception('Invalid signature format');
|
||||
}
|
||||
|
||||
// Check timestamp tolerance (5 minutes)
|
||||
if (abs(time() - $timestamp) > 300) {
|
||||
throw new Exception('Timestamp outside tolerance');
|
||||
}
|
||||
|
||||
$signedPayload = $timestamp . '.' . $payload;
|
||||
$expectedSignature = hash_hmac('sha256', $signedPayload, $webhookSecret);
|
||||
|
||||
foreach ($signatures as $sig) {
|
||||
if (hash_equals($expectedSignature, $sig)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Exception('Signature verification failed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a refund
|
||||
*/
|
||||
public function createRefund($paymentIntentId, $amount = null) {
|
||||
$data = ['payment_intent' => $paymentIntentId];
|
||||
|
||||
if ($amount !== null) {
|
||||
$data['amount'] = (int)($amount * 100);
|
||||
}
|
||||
|
||||
return $this->request('POST', '/refunds', $data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Stripe instance
|
||||
*/
|
||||
function stripe() {
|
||||
static $stripe = null;
|
||||
if ($stripe === null) {
|
||||
$stripe = new StripeAPI();
|
||||
}
|
||||
return $stripe;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Stripe is properly configured
|
||||
*/
|
||||
function isStripeConfigured() {
|
||||
return !empty(STRIPE_SECRET_KEY) &&
|
||||
STRIPE_SECRET_KEY !== 'sk_test_your_stripe_key' &&
|
||||
!empty(STRIPE_PUBLISHABLE_KEY) &&
|
||||
STRIPE_PUBLISHABLE_KEY !== 'pk_test_your_stripe_key';
|
||||
}
|
||||
Reference in New Issue
Block a user