Files
order/app/Controllers/StoreController.php
Ty Clifford b14f4c1796 - Implemented the Lakeside Orders demo rebrand:
New licensed lake, picnic, dining, server, burger, and food photography.
Rebuilt homepage gallery, logo, colors, copy, menu, specials, and 
exports.
Added dashboard-configurable branding in [settings.php (line 
18)](/Users/tyemeclifford/Documents/GH/fatbottomgrille/views/admin/settings.php:18).
Added a public attribution page and full [image 
credits](/Users/tyemeclifford/Documents/GH/fatbottomgrille/public/assets/images/CREDITS.md).
Migrated existing SQLite menu data and admin email to 
admin@lakesideorders.test.
Verified 51 PHP files, JSON/JavaScript, storefront pages, database 
migration, admin settings, and menu PDF. All passed.
2026-06-14 14:26:09 -04:00

274 lines
10 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\OrderService;
use FatBottom\Services\OrderStatusService;
final class StoreController extends BaseController
{
public function __construct(
Config $config,
Auth $auth,
CartService $cart,
private readonly Database $db,
private readonly OrderService $orders,
private readonly OrderStatusService $orderStatuses
) {
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' => 'Lakeside 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 credits(): void
{
View::render('store/credits', $this->shared([
'title' => 'Image Credits',
]));
}
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'] ?? '')
);
try {
$this->orderStatuses->sendStatusEmail($order, 'pending');
} catch (\Throwable) {
Http::flash('warning', 'Your order was placed, but the confirmation email could not be sent.');
}
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,
'trackingUrl' => $this->orderStatuses->trackingUrl($order),
]));
}
public function orderTracker(): void
{
$orderNumber = Security::clean((string) ($_GET['order'] ?? ''));
$token = (string) ($_GET['token'] ?? '');
$order = $this->db->fetch('SELECT * FROM orders WHERE order_number = ?', [$orderNumber]);
if ($order === null) {
Http::abort(404, 'Order not found.');
}
$user = $this->auth->user();
$ownsOrder = $user !== null && (
(int) ($order['user_id'] ?? 0) === (int) $user['id']
|| strtolower((string) $order['customer_email']) === strtolower((string) $user['email'])
);
$validToken = $token !== ''
&& (string) $order['tracking_token'] !== ''
&& hash_equals((string) $order['tracking_token'], $token);
if (!$ownsOrder && !$validToken) {
Http::abort(403, 'This tracking link is invalid.');
}
View::render('store/order-tracker', $this->shared([
'title' => 'Track ' . $order['order_number'],
'order' => $order,
'events' => $this->db->fetchAll(
"SELECT * FROM order_events
WHERE order_id = ? AND (status <> '' OR customer_visible = 1)
ORDER BY created_at, id",
[(int) $order['id']]
),
'items' => $this->db->fetchAll(
'SELECT * FROM order_items WHERE order_id = ? ORDER BY id',
[(int) $order['id']]
),
]));
}
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'] !== []
));
}
}