b14f4c1796
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.
274 lines
10 KiB
PHP
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'] !== []
|
|
));
|
|
}
|
|
}
|