Files
order/app/Controllers/StoreController.php
T
Ty Clifford 16235369cb - Implemented the full Fat Bottom Grille storefront and staff dashboard.
Highlights include configurable menus, specials, cart/checkout, taxes, 
customer accounts with email 2FA, newsletters, analytics, refunds, 
PDF/CSV exports, Square/Mailgun adapters, and optional MySQL migration.

See README.md for setup and production configuration. Verified 
PHP/JavaScript syntax, responsive layouts, registration, 2FA, checkout, 
dashboard workflows, settings persistence, and PDF generation.
Real Square, Mailgun, and MySQL connections require credentials/server 
access. Square implementation follows the official Payments API and 
Refunds API.
2026-06-10 13:27:42 -04:00

232 lines
8.5 KiB
PHP

<?php
declare(strict_types=1);
namespace FatBottom\Controllers;
use FatBottom\Core\Auth;
use FatBottom\Core\Config;
use FatBottom\Core\Database;
use FatBottom\Core\Http;
use FatBottom\Core\Security;
use FatBottom\Core\View;
use FatBottom\Services\CartService;
use FatBottom\Services\Mailer;
use FatBottom\Services\OrderService;
final class StoreController extends BaseController
{
public function __construct(
Config $config,
Auth $auth,
CartService $cart,
private readonly Database $db,
private readonly OrderService $orders,
private readonly Mailer $mailer
) {
parent::__construct($config, $auth, $cart);
}
public function home(): void
{
$categories = $this->menuCategories(true);
$specials = $this->db->fetchAll(
"SELECT s.*, mi.name AS item_name
FROM specials s
LEFT JOIN menu_items mi ON mi.id = s.menu_item_id
WHERE s.active = 1
AND (s.starts_at IS NULL OR s.starts_at <= CURRENT_TIMESTAMP)
AND (s.ends_at IS NULL OR s.ends_at >= CURRENT_TIMESTAMP)
ORDER BY s.display_order, s.id"
);
$featured = $this->db->fetchAll(
'SELECT mi.*, mc.name AS category_name
FROM menu_items mi
JOIN menu_categories mc ON mc.id = mi.category_id
WHERE mi.active = 1 AND mc.active = 1 AND mi.featured = 1
ORDER BY mi.display_order, mi.id LIMIT 6'
);
View::render('store/home', $this->shared([
'title' => 'Keyser comfort food, ready when you are',
'categories' => $categories,
'specials' => $specials,
'featured' => $featured,
]));
}
public function menu(): void
{
$query = Security::clean((string) ($_GET['q'] ?? ''));
$categories = $this->menuCategories(false, $query);
View::render('store/menu', $this->shared([
'title' => 'Order Online',
'categories' => $categories,
'query' => $query,
]));
}
public function addToCart(): void
{
Security::verifyCsrf();
$user = $this->auth->user();
$added = $this->cart->add(
(int) ($_POST['menu_item_id'] ?? 0),
(int) ($_POST['quantity'] ?? 1),
Security::clean((string) ($_POST['notes'] ?? '')),
$user ? (int) $user['id'] : null
);
Http::flash($added ? 'success' : 'error', $added ? 'Added to your order.' : 'That item is not currently available.');
$returnTo = (string) ($_POST['return_to'] ?? '/menu');
Http::redirect(str_starts_with($returnTo, '/') ? $returnTo : '/menu');
}
public function cart(): void
{
$user = $this->auth->user();
$details = $this->cart->details($user ? (int) $user['id'] : null);
View::render('store/cart', $this->shared([
'title' => 'Your Order',
'cart' => $details,
'totals' => $this->orders->totals($details),
]));
}
public function updateCart(): void
{
Security::verifyCsrf();
$user = $this->auth->user();
$quantities = isset($_POST['quantity']) && is_array($_POST['quantity'])
? $_POST['quantity']
: [];
$this->cart->update($quantities, $user ? (int) $user['id'] : null);
Http::flash('success', 'Your order has been updated.');
Http::redirect('/cart');
}
public function removeFromCart(): void
{
Security::verifyCsrf();
$user = $this->auth->user();
$this->cart->remove((int) ($_POST['cart_item_id'] ?? 0), $user ? (int) $user['id'] : null);
Http::flash('success', 'Item removed.');
Http::redirect('/cart');
}
public function checkout(): void
{
$user = $this->auth->user();
$details = $this->cart->details($user ? (int) $user['id'] : null);
if (empty($details['items'])) {
Http::flash('warning', 'Add something delicious before checking out.');
Http::redirect('/menu');
}
View::render('store/checkout', $this->shared([
'title' => 'Checkout',
'cart' => $details,
'totals' => $this->orders->totals($details),
'taxRates' => $this->db->fetchAll('SELECT * FROM tax_rates WHERE active = 1 ORDER BY id'),
'squareEnabled' => (bool) $this->config->get('square.enabled', false),
'squareAppId' => (string) $this->config->get('square.application_id', ''),
'squareLocationId' => (string) $this->config->get('square.location_id', ''),
'squareEnvironment' => (string) $this->config->get('square.environment', 'sandbox'),
]));
}
public function placeOrder(): void
{
Security::verifyCsrf();
$user = $this->auth->user();
$cart = $this->cart->details($user ? (int) $user['id'] : null);
$input = [
'customer_name' => Security::clean((string) ($_POST['customer_name'] ?? '')),
'customer_email' => strtolower(Security::clean((string) ($_POST['customer_email'] ?? ''))),
'customer_phone' => Security::clean((string) ($_POST['customer_phone'] ?? '')),
'pickup_time' => Security::clean((string) ($_POST['pickup_time'] ?? '')),
'special_instructions' => Security::clean((string) ($_POST['special_instructions'] ?? '')),
];
if (
$input['customer_name'] === ''
|| !filter_var($input['customer_email'], FILTER_VALIDATE_EMAIL)
|| $input['customer_phone'] === ''
) {
Http::old($input);
Http::flash('error', 'Name, a valid email address, and phone number are required.');
Http::redirect('/checkout');
}
if (!(bool) $this->config->get('ordering.accepting_orders', true)) {
Http::flash('error', 'Online ordering is currently paused.');
Http::redirect('/cart');
}
try {
$order = $this->orders->place(
$cart,
$input,
$user ? (int) $user['id'] : null,
(string) ($_POST['square_token'] ?? '')
);
$this->mailer->send(
$input['customer_email'],
'We received order ' . $order['order_number'],
'<p>Thanks, ' . htmlspecialchars($input['customer_name'], ENT_QUOTES, 'UTF-8') . '.</p>'
. '<p>Your order <strong>' . htmlspecialchars((string) $order['order_number'], ENT_QUOTES, 'UTF-8')
. '</strong> has been paid and sent to the kitchen.</p>'
);
setcookie('fatbottom_cart', '', ['expires' => time() - 3600, 'path' => '/']);
$_SESSION['last_order_id'] = (int) $order['id'];
Http::redirect('/order/complete');
} catch (\Throwable $error) {
Http::old($input);
Http::flash('error', $error->getMessage());
Http::redirect('/checkout');
}
}
public function orderComplete(): void
{
$orderId = (int) ($_SESSION['last_order_id'] ?? 0);
$order = $this->db->fetch('SELECT * FROM orders WHERE id = ?', [$orderId]);
if ($order === null) {
Http::redirect('/');
}
$items = $this->db->fetchAll('SELECT * FROM order_items WHERE order_id = ?', [$orderId]);
View::render('store/order-complete', $this->shared([
'title' => 'Order Received',
'order' => $order,
'items' => $items,
]));
}
private function menuCategories(bool $homepage, string $query = ''): array
{
$categories = $this->db->fetchAll(
'SELECT * FROM menu_categories WHERE active = 1 ORDER BY display_order, name'
);
foreach ($categories as &$category) {
$parameters = [(int) $category['id']];
$where = 'category_id = ? AND active = 1';
if ($query !== '') {
$where .= ' AND (name LIKE ? OR description LIKE ?)';
$parameters[] = '%' . $query . '%';
$parameters[] = '%' . $query . '%';
}
$limit = $homepage ? ' LIMIT 4' : '';
$category['items'] = $this->db->fetchAll(
"SELECT * FROM menu_items WHERE {$where} ORDER BY display_order, name{$limit}",
$parameters
);
}
unset($category);
return array_values(array_filter(
$categories,
static fn (array $category): bool => $category['items'] !== []
));
}
}