mirror of
https://github.com/myronblair/tomsjavajive
synced 2026-07-27 16:52:36 -05:00
30daef5f74
Feature: checkout.php now lets logged-in customers apply existing wallet balance or redeem a gift card code (which tops up wallet first, then applies) toward their order total. Deduction is deferred to payment success (markSquarePaymentResult in includes/square.php), never at order creation, so an abandoned checkout never loses real wallet money - mirrors how loyalty points already work here, unlike stock which is decremented eagerly. payment.php gains a second Square Gift Card tab (payments.giftCard() SDK method) alongside the card form, both hitting the same create-square-payment.php endpoint since Square treats both source types identically. New api/apply-wallet-credit.php validates/quotes an amount without writing anything - actual spend happens only via markSquarePaymentResult(). The gift-card-to-wallet transaction logic was extracted out of api/redeem-gift-card.php into a shared loyalty()->redeemGiftCardToWallet() so the Wallet page and checkout both call the same code. Also fixed three unrelated pre-existing bugs surfaced while testing this: - loyalty.php awardPoints() reused the same named PDO parameter (:points) twice in one UPDATE - fails under real prepared statements, meaning loyalty points (and the email sent right after them) were silently never awarded for any order tied to a logged-in customer. - redeemGiftCardToWallet (formerly inline in redeem-gift-card.php) referenced a gift_cards.updated_at column that does not exist in the schema, and used invalid enum values (gift_card_transactions.type=redeem, wallet_transactions.type=gift_card) that do not match the actual enum definitions - gift card redemption has likely never worked at all. - account/rewards.php was missing the line that loads account.css, unlike every other account/*.php page, so its sidebar/layout rendered unstyled.
468 lines
21 KiB
PHP
468 lines
21 KiB
PHP
<?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';
|
|
require_once __DIR__ . '/includes/loyalty.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;
|
|
}
|
|
}
|
|
|
|
$preDiscountTotal = $subtotal + $shippingCost;
|
|
$processor = defined('PAYMENT_PROCESSOR') ? PAYMENT_PROCESSOR : 'stripe';
|
|
|
|
$errors = [];
|
|
|
|
// Handle form submission
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
// Validate form
|
|
$email = trim($_POST['email'] ?? '');
|
|
$name = trim($_POST['name'] ?? '');
|
|
$phone = trim($_POST['phone'] ?? '');
|
|
$address = trim($_POST['address'] ?? '');
|
|
$city = trim($_POST['city'] ?? '');
|
|
$state = trim($_POST['state'] ?? '');
|
|
$zip = trim($_POST['zip'] ?? '');
|
|
|
|
if (empty($email)) $errors['email'] = 'Email is required';
|
|
if (empty($name)) $errors['name'] = 'Name is required';
|
|
if (empty($address)) $errors['address'] = 'Address is required';
|
|
if (empty($city)) $errors['city'] = 'City is required';
|
|
if (empty($state)) $errors['state'] = 'State is required';
|
|
if (empty($zip)) $errors['zip'] = 'ZIP code is required';
|
|
|
|
// Re-validate any requested wallet credit server-side against the
|
|
// customer's current balance - never trust the client-submitted amount.
|
|
$walletAmountUsed = 0;
|
|
if ($customer && !empty($_POST['wallet_amount_used'])) {
|
|
$requestedWallet = (float) $_POST['wallet_amount_used'];
|
|
$freshCustomer = db()->fetch("SELECT wallet_balance FROM customers WHERE customer_id = :id", ['id' => $customer['customer_id']]);
|
|
$currentBalance = (float) ($freshCustomer['wallet_balance'] ?? 0);
|
|
$walletAmountUsed = max(0, round(min($requestedWallet, $currentBalance, $preDiscountTotal), 2));
|
|
}
|
|
|
|
$total = round($preDiscountTotal - $walletAmountUsed, 2);
|
|
|
|
if (empty($errors)) {
|
|
// Create order
|
|
$orderId = generateId('ord_');
|
|
$orderNumber = generateOrderNumber();
|
|
|
|
// Get or create customer
|
|
$customerId = null;
|
|
if ($customer) {
|
|
$customerId = $customer['customer_id'];
|
|
} else {
|
|
$customerId = CustomerAuth::createGuest($email, $name, $phone);
|
|
}
|
|
|
|
// Prepare order items
|
|
$orderItems = [];
|
|
foreach ($cartItems as $item) {
|
|
$orderItems[] = [
|
|
'product_id' => $item['product_id'],
|
|
'name' => $item['name'],
|
|
'price' => $item['unit_price'],
|
|
'quantity' => $item['quantity'],
|
|
'total' => $item['total']
|
|
];
|
|
}
|
|
|
|
// Insert order
|
|
db()->insert('orders', [
|
|
'order_id' => $orderId,
|
|
'order_number' => $orderNumber,
|
|
'customer_id' => $customerId,
|
|
'customer_email' => $email,
|
|
'customer_name' => $name,
|
|
'customer_phone' => $phone,
|
|
'items' => json_encode($orderItems),
|
|
'subtotal' => $subtotal,
|
|
'shipping_cost' => $shippingCost,
|
|
'wallet_amount_used' => $walletAmountUsed,
|
|
'total' => $total,
|
|
'shipping_address' => json_encode([
|
|
'address' => $address,
|
|
'city' => $city,
|
|
'state' => $state,
|
|
'zip' => $zip,
|
|
'country' => 'USA'
|
|
]),
|
|
'shipping_method' => 'standard',
|
|
'payment_method' => $processor,
|
|
'payment_status' => 'pending',
|
|
'order_status' => 'pending'
|
|
]);
|
|
|
|
// Insert order items for reporting
|
|
foreach ($orderItems as $item) {
|
|
db()->insert('order_items', [
|
|
'order_id' => $orderId,
|
|
'product_id' => $item['product_id'],
|
|
'name' => $item['name'],
|
|
'price' => $item['price'],
|
|
'quantity' => $item['quantity'],
|
|
'total' => $item['total']
|
|
]);
|
|
}
|
|
|
|
// Reduce stock
|
|
foreach ($cartItems as $item) {
|
|
db()->query(
|
|
"UPDATE products SET stock = stock - :qty WHERE product_id = :id",
|
|
['qty' => $item['quantity'], 'id' => $item['product_id']]
|
|
);
|
|
}
|
|
|
|
// Store order ID for payment
|
|
$_SESSION['pending_order_id'] = $orderId;
|
|
|
|
// Redirect to payment page
|
|
redirect('/payment.php?order=' . $orderId);
|
|
}
|
|
}
|
|
|
|
$metaTitle = "Secure Checkout | Tom's Java Jive";
|
|
$metaDescription = 'Complete your coffee order with secure checkout.';
|
|
$canonicalUrl = 'https://tomsjavajive.com/checkout.php';
|
|
$metaRobots = "noindex, nofollow";
|
|
$suppressSchema = true;
|
|
$extraHead = '<link rel="stylesheet" href="/assets/css/checkout.css?v=' . filemtime(__DIR__ . '/assets/css/checkout.css') . '">';
|
|
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">
|
|
<input type="hidden" name="wallet_amount_used" id="wallet_amount_used" value="0">
|
|
<div class="checkout-layout">
|
|
|
|
<!-- 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 class="address-grid">
|
|
<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>
|
|
|
|
<?php if ($customer): ?>
|
|
<!-- Wallet / Gift Card -->
|
|
<div class="card mb-2">
|
|
<div class="card-header">
|
|
<h3 style="margin: 0;">Wallet & Gift Cards</h3>
|
|
</div>
|
|
<div class="card-body">
|
|
<p class="text-muted" style="font-size: 0.875rem; margin-bottom: 1rem;">
|
|
Wallet balance: <strong id="wallet-balance-display"><?= formatCurrency($customer['wallet_balance'] ?? 0) ?></strong>
|
|
</p>
|
|
<?php if (($customer['wallet_balance'] ?? 0) > 0): ?>
|
|
<button type="button" id="apply-wallet-btn" class="btn btn-secondary mb-1">
|
|
<i class="fas fa-wallet"></i> Apply Wallet Balance
|
|
</button>
|
|
<?php endif; ?>
|
|
<div class="form-group" style="display: flex; gap: 0.5rem; align-items: flex-end;">
|
|
<div style="flex: 1;">
|
|
<label class="form-label">Gift Card Code</label>
|
|
<input type="text" id="gift-card-code" class="form-input"
|
|
placeholder="XXXX-XXXX-XXXX" style="text-transform: uppercase;">
|
|
</div>
|
|
<button type="button" id="apply-gift-card-btn" class="btn btn-secondary">Apply</button>
|
|
</div>
|
|
<div id="wallet-message" style="margin-top: 0.5rem;"></div>
|
|
<div id="wallet-applied-row" style="display: none; margin-top: 0.5rem; font-weight: 600;">
|
|
Applied: <span id="wallet-applied-amount"></span>
|
|
<button type="button" id="remove-wallet-btn" class="btn btn-secondary" style="margin-left: 0.5rem; padding: 0.15rem 0.6rem; font-size: 0.8rem;">Remove</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<?php endif; ?>
|
|
|
|
<!-- 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 checkout-summary">
|
|
<div class="card-header">
|
|
<h3 style="margin: 0;">Order Summary</h3>
|
|
</div>
|
|
<div class="card-body">
|
|
<!-- Cart Items -->
|
|
<div class="checkout-items-preview">
|
|
<?php foreach ($cartItems as $item): ?>
|
|
<div class="checkout-item">
|
|
<img src="<?= htmlspecialchars($item['image']) ?>" alt=""
|
|
class="checkout-item-img">
|
|
<div class="checkout-item-info">
|
|
<p><?= htmlspecialchars($item['name']) ?></p>
|
|
<small><?= formatCurrency($item['unit_price']) ?> x <?= $item['quantity'] ?></small>
|
|
</div>
|
|
<div class="checkout-item-total">
|
|
<?= formatCurrency($item['total']) ?>
|
|
</div>
|
|
</div>
|
|
<?php endforeach; ?>
|
|
</div>
|
|
|
|
<!-- Totals -->
|
|
<div class="checkout-summary-row">
|
|
<span>Subtotal</span>
|
|
<span><?= formatCurrency($subtotal) ?></span>
|
|
</div>
|
|
<div class="checkout-summary-row">
|
|
<span>Shipping</span>
|
|
<span>
|
|
<?php if ($shippingCost == 0): ?>
|
|
<span class="text-success">FREE</span>
|
|
<?php else: ?>
|
|
<?= formatCurrency($shippingCost) ?>
|
|
<?php endif; ?>
|
|
</span>
|
|
</div>
|
|
<div class="checkout-summary-row" id="wallet-discount-row" style="display: none;">
|
|
<span>Wallet / Gift Card</span>
|
|
<span id="wallet-discount-amount" class="text-success"></span>
|
|
</div>
|
|
|
|
<hr style="margin: 1rem 0;">
|
|
|
|
<div class="checkout-summary-total">
|
|
<span>Total</span>
|
|
<span id="checkout-total-display"><?= formatCurrency($preDiscountTotal) ?></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="secure-badge">
|
|
<i class="fas fa-lock"></i> Secure checkout powered by <?= $processor === 'square' ? 'Square' : 'Stripe' ?>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</section>
|
|
|
|
<?php if ($customer): ?>
|
|
<script>
|
|
const ORDER_TOTAL_PRE_DISCOUNT = <?= json_encode($preDiscountTotal) ?>;
|
|
const CURRENCY_SYMBOL = <?= json_encode(defined('TJJ_CURRENCY_SYMBOL') ? TJJ_CURRENCY_SYMBOL : '$') ?>;
|
|
|
|
function fmt(amount) {
|
|
return CURRENCY_SYMBOL + Number(amount).toFixed(2);
|
|
}
|
|
|
|
function showWalletMessage(msg, isError) {
|
|
const el = document.getElementById('wallet-message');
|
|
el.textContent = msg;
|
|
el.style.color = isError ? 'var(--color-error)' : 'var(--color-success)';
|
|
}
|
|
|
|
function applyWalletResult(data) {
|
|
document.getElementById('wallet_amount_used').value = data.apply_amount;
|
|
document.getElementById('checkout-total-display').textContent = fmt(data.new_total);
|
|
document.getElementById('wallet-balance-display').textContent = fmt(data.wallet_balance);
|
|
|
|
if (data.apply_amount > 0) {
|
|
document.getElementById('wallet-discount-row').style.display = '';
|
|
document.getElementById('wallet-discount-amount').textContent = '-' + fmt(data.apply_amount);
|
|
document.getElementById('wallet-applied-row').style.display = '';
|
|
document.getElementById('wallet-applied-amount').textContent = fmt(data.apply_amount);
|
|
} else {
|
|
document.getElementById('wallet-discount-row').style.display = 'none';
|
|
document.getElementById('wallet-applied-row').style.display = 'none';
|
|
}
|
|
}
|
|
|
|
async function applyCredit(body) {
|
|
try {
|
|
const response = await fetch('/api/apply-wallet-credit.php', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(Object.assign({ order_total: ORDER_TOTAL_PRE_DISCOUNT }, body))
|
|
});
|
|
const data = await response.json();
|
|
if (data.error) {
|
|
showWalletMessage(data.error, true);
|
|
return;
|
|
}
|
|
showWalletMessage('Applied ' + fmt(data.apply_amount) + ' to your order.', false);
|
|
applyWalletResult(data);
|
|
} catch (err) {
|
|
showWalletMessage('Failed to apply credit. Please try again.', true);
|
|
}
|
|
}
|
|
|
|
const applyWalletBtn = document.getElementById('apply-wallet-btn');
|
|
if (applyWalletBtn) {
|
|
applyWalletBtn.addEventListener('click', function() {
|
|
applyCredit({});
|
|
});
|
|
}
|
|
|
|
document.getElementById('apply-gift-card-btn').addEventListener('click', function() {
|
|
const code = document.getElementById('gift-card-code').value.trim();
|
|
if (!code) {
|
|
showWalletMessage('Enter a gift card code first.', true);
|
|
return;
|
|
}
|
|
applyCredit({ gift_card_code: code });
|
|
});
|
|
|
|
document.getElementById('remove-wallet-btn').addEventListener('click', function() {
|
|
document.getElementById('wallet_amount_used').value = 0;
|
|
document.getElementById('checkout-total-display').textContent = fmt(ORDER_TOTAL_PRE_DISCOUNT);
|
|
document.getElementById('wallet-discount-row').style.display = 'none';
|
|
document.getElementById('wallet-applied-row').style.display = 'none';
|
|
showWalletMessage('', false);
|
|
});
|
|
</script>
|
|
<?php endif; ?>
|
|
|
|
<?php require_once __DIR__ . '/includes/footer.php'; ?>
|