- 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.
This commit is contained in:
@@ -0,0 +1,651 @@
|
||||
<?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\MenuPdfExporter;
|
||||
use FatBottom\Services\MySqlMigrator;
|
||||
use FatBottom\Services\PaymentGateway;
|
||||
use FatBottom\Services\StatsService;
|
||||
|
||||
final class AdminController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
Config $config,
|
||||
Auth $auth,
|
||||
CartService $cart,
|
||||
private readonly Database $db,
|
||||
private readonly StatsService $stats,
|
||||
private readonly PaymentGateway $payments,
|
||||
private readonly Mailer $mailer
|
||||
) {
|
||||
parent::__construct($config, $auth, $cart);
|
||||
}
|
||||
|
||||
public function dashboard(): void
|
||||
{
|
||||
$this->auth->requireStaff();
|
||||
$recentOrders = $this->db->fetchAll(
|
||||
'SELECT * FROM orders ORDER BY placed_at DESC LIMIT 10'
|
||||
);
|
||||
$popularItems = $this->db->fetchAll(
|
||||
'SELECT oi.item_name, SUM(oi.quantity) AS quantity, SUM(oi.line_total_cents) AS sales_cents
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE o.payment_status = ?
|
||||
GROUP BY oi.item_name
|
||||
ORDER BY quantity DESC LIMIT 6',
|
||||
['paid']
|
||||
);
|
||||
$traffic = $this->db->isSqlite()
|
||||
? $this->db->fetchAll(
|
||||
"SELECT date(created_at, 'localtime') AS day, COUNT(DISTINCT session_id) AS visitors
|
||||
FROM visitor_events
|
||||
WHERE created_at >= datetime('now', '-6 days')
|
||||
GROUP BY date(created_at, 'localtime')
|
||||
ORDER BY day"
|
||||
)
|
||||
: $this->db->fetchAll(
|
||||
"SELECT DATE(created_at) AS day, COUNT(DISTINCT session_id) AS visitors
|
||||
FROM visitor_events
|
||||
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 6 DAY)
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY day"
|
||||
);
|
||||
|
||||
View::render('admin/dashboard', $this->shared([
|
||||
'title' => 'Dashboard',
|
||||
'adminPage' => 'dashboard',
|
||||
'stats' => $this->stats->dashboard(),
|
||||
'recentOrders' => $recentOrders,
|
||||
'popularItems' => $popularItems,
|
||||
'traffic' => $traffic,
|
||||
]));
|
||||
}
|
||||
|
||||
public function orders(): void
|
||||
{
|
||||
$this->auth->requireStaff();
|
||||
$status = Security::clean((string) ($_GET['status'] ?? ''));
|
||||
$query = Security::clean((string) ($_GET['q'] ?? ''));
|
||||
$where = ['1 = 1'];
|
||||
$parameters = [];
|
||||
if ($status !== '') {
|
||||
$where[] = 'o.status = ?';
|
||||
$parameters[] = $status;
|
||||
}
|
||||
if ($query !== '') {
|
||||
$where[] = '(o.order_number LIKE ? OR o.customer_name LIKE ? OR o.customer_email LIKE ?)';
|
||||
$parameters[] = '%' . $query . '%';
|
||||
$parameters[] = '%' . $query . '%';
|
||||
$parameters[] = '%' . $query . '%';
|
||||
}
|
||||
$orders = $this->db->fetchAll(
|
||||
'SELECT o.*, COALESCE(SUM(r.amount_cents), 0) AS refunded_cents
|
||||
FROM orders o
|
||||
LEFT JOIN refunds r ON r.order_id = o.id
|
||||
WHERE ' . implode(' AND ', $where) . '
|
||||
GROUP BY o.id
|
||||
ORDER BY o.placed_at DESC LIMIT 200',
|
||||
$parameters
|
||||
);
|
||||
View::render('admin/orders', $this->shared([
|
||||
'title' => 'Orders',
|
||||
'adminPage' => 'orders',
|
||||
'orders' => $orders,
|
||||
'statusFilter' => $status,
|
||||
'query' => $query,
|
||||
]));
|
||||
}
|
||||
|
||||
public function orderDetail(): void
|
||||
{
|
||||
$this->auth->requireStaff();
|
||||
$orderId = (int) ($_GET['id'] ?? 0);
|
||||
$order = $this->db->fetch('SELECT * FROM orders WHERE id = ?', [$orderId]);
|
||||
if ($order === null) {
|
||||
Http::abort(404, 'Order not found.');
|
||||
}
|
||||
View::render('admin/order-detail', $this->shared([
|
||||
'title' => 'Order ' . $order['order_number'],
|
||||
'adminPage' => 'orders',
|
||||
'order' => $order,
|
||||
'items' => $this->db->fetchAll('SELECT * FROM order_items WHERE order_id = ?', [$orderId]),
|
||||
'events' => $this->db->fetchAll(
|
||||
'SELECT oe.*, u.full_name AS staff_name
|
||||
FROM order_events oe LEFT JOIN users u ON u.id = oe.user_id
|
||||
WHERE oe.order_id = ? ORDER BY oe.created_at DESC, oe.id DESC',
|
||||
[$orderId]
|
||||
),
|
||||
'refunds' => $this->db->fetchAll(
|
||||
'SELECT r.*, u.full_name AS staff_name
|
||||
FROM refunds r LEFT JOIN users u ON u.id = r.staff_user_id
|
||||
WHERE r.order_id = ? ORDER BY r.created_at DESC',
|
||||
[$orderId]
|
||||
),
|
||||
]));
|
||||
}
|
||||
|
||||
public function updateOrder(): void
|
||||
{
|
||||
Security::verifyCsrf();
|
||||
$staff = $this->auth->requireStaff();
|
||||
$orderId = (int) ($_POST['order_id'] ?? 0);
|
||||
$status = Security::clean((string) ($_POST['status'] ?? ''));
|
||||
$allowed = ['paid', 'preparing', 'ready', 'completed', 'cancelled'];
|
||||
if (!in_array($status, $allowed, true)) {
|
||||
Http::flash('error', 'Choose a valid order status.');
|
||||
Http::redirect('/admin/orders');
|
||||
}
|
||||
$notes = Security::clean((string) ($_POST['staff_note'] ?? ''));
|
||||
$this->db->execute(
|
||||
'UPDATE orders SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
[$status, $orderId]
|
||||
);
|
||||
$this->db->execute(
|
||||
'INSERT INTO order_events (order_id, user_id, event_type, details) VALUES (?, ?, ?, ?)',
|
||||
[$orderId, (int) $staff['id'], 'status_changed', 'Status changed to ' . $status . ($notes ? '. ' . $notes : '.')]
|
||||
);
|
||||
Http::flash('success', 'Order status updated.');
|
||||
Http::redirect('/admin/order?id=' . $orderId);
|
||||
}
|
||||
|
||||
public function refundOrder(): void
|
||||
{
|
||||
Security::verifyCsrf();
|
||||
$staff = $this->auth->requireStaff();
|
||||
$orderId = (int) ($_POST['order_id'] ?? 0);
|
||||
$order = $this->db->fetch(
|
||||
'SELECT o.*, COALESCE(SUM(r.amount_cents), 0) AS refunded_cents
|
||||
FROM orders o LEFT JOIN refunds r ON r.order_id = o.id
|
||||
WHERE o.id = ? GROUP BY o.id',
|
||||
[$orderId]
|
||||
);
|
||||
if ($order === null) {
|
||||
Http::abort(404, 'Order not found.');
|
||||
}
|
||||
$amountCents = (int) round(((float) ($_POST['amount'] ?? 0)) * 100);
|
||||
$available = (int) $order['total_cents'] - (int) $order['refunded_cents'];
|
||||
$reason = Security::clean((string) ($_POST['reason'] ?? 'Staff refund'));
|
||||
if ($amountCents <= 0 || $amountCents > $available) {
|
||||
Http::flash('error', 'Refund amount must be greater than zero and no more than the remaining paid amount.');
|
||||
Http::redirect('/admin/order?id=' . $orderId);
|
||||
}
|
||||
|
||||
try {
|
||||
$refund = $this->payments->refund((string) $order['payment_reference'], $amountCents, $reason);
|
||||
$this->db->execute(
|
||||
'INSERT INTO refunds (order_id, staff_user_id, amount_cents, reason, provider_reference)
|
||||
VALUES (?, ?, ?, ?, ?)',
|
||||
[$orderId, (int) $staff['id'], $amountCents, $reason, $refund['reference']]
|
||||
);
|
||||
$newRefunded = (int) $order['refunded_cents'] + $amountCents;
|
||||
$paymentStatus = $newRefunded >= (int) $order['total_cents'] ? 'refunded' : 'partially_refunded';
|
||||
$this->db->execute('UPDATE orders SET payment_status = ? WHERE id = ?', [$paymentStatus, $orderId]);
|
||||
$this->db->execute(
|
||||
'INSERT INTO order_events (order_id, user_id, event_type, details) VALUES (?, ?, ?, ?)',
|
||||
[$orderId, (int) $staff['id'], 'refund_issued', sprintf('$%.2f refunded: %s', $amountCents / 100, $reason)]
|
||||
);
|
||||
Http::flash('success', 'Refund issued successfully.');
|
||||
} catch (\Throwable $error) {
|
||||
Http::flash('error', $error->getMessage());
|
||||
}
|
||||
Http::redirect('/admin/order?id=' . $orderId);
|
||||
}
|
||||
|
||||
public function menu(): void
|
||||
{
|
||||
$this->auth->requireStaff();
|
||||
$categories = $this->db->fetchAll('SELECT * FROM menu_categories ORDER BY display_order, name');
|
||||
$items = $this->db->fetchAll(
|
||||
'SELECT mi.*, mc.name AS category_name
|
||||
FROM menu_items mi JOIN menu_categories mc ON mc.id = mi.category_id
|
||||
ORDER BY mc.display_order, mi.display_order, mi.name'
|
||||
);
|
||||
View::render('admin/menu', $this->shared([
|
||||
'title' => 'Menu Manager',
|
||||
'adminPage' => 'menu',
|
||||
'categories' => $categories,
|
||||
'items' => $items,
|
||||
]));
|
||||
}
|
||||
|
||||
public function saveCategory(): void
|
||||
{
|
||||
Security::verifyCsrf();
|
||||
$this->auth->requireStaff();
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
$name = Security::clean((string) ($_POST['name'] ?? ''));
|
||||
if ($name === '') {
|
||||
Http::flash('error', 'Category name is required.');
|
||||
Http::redirect('/admin/menu');
|
||||
}
|
||||
$values = [
|
||||
$name,
|
||||
Security::slug((string) ($_POST['slug'] ?? $name)),
|
||||
Security::clean((string) ($_POST['description'] ?? '')),
|
||||
(int) ($_POST['display_order'] ?? 0),
|
||||
isset($_POST['active']) ? 1 : 0,
|
||||
];
|
||||
if ($id > 0) {
|
||||
$values[] = $id;
|
||||
$this->db->execute(
|
||||
'UPDATE menu_categories SET name = ?, slug = ?, description = ?, display_order = ?, active = ?,
|
||||
updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
$values
|
||||
);
|
||||
} else {
|
||||
$this->db->execute(
|
||||
'INSERT INTO menu_categories (name, slug, description, display_order, active) VALUES (?, ?, ?, ?, ?)',
|
||||
$values
|
||||
);
|
||||
}
|
||||
Http::flash('success', 'Menu category saved.');
|
||||
Http::redirect('/admin/menu');
|
||||
}
|
||||
|
||||
public function saveItem(): void
|
||||
{
|
||||
Security::verifyCsrf();
|
||||
$this->auth->requireStaff();
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
$name = Security::clean((string) ($_POST['name'] ?? ''));
|
||||
$categoryId = (int) ($_POST['category_id'] ?? 0);
|
||||
$priceCents = (int) round(((float) ($_POST['price'] ?? 0)) * 100);
|
||||
if ($name === '' || $categoryId < 1 || $priceCents < 0) {
|
||||
Http::flash('error', 'Item name, category, and a valid price are required.');
|
||||
Http::redirect('/admin/menu');
|
||||
}
|
||||
$compareAt = trim((string) ($_POST['compare_at_price'] ?? '')) === ''
|
||||
? null
|
||||
: (int) round(((float) $_POST['compare_at_price']) * 100);
|
||||
$tags = array_values(array_filter(array_map(
|
||||
static fn (string $tag): string => Security::clean($tag),
|
||||
explode(',', (string) ($_POST['dietary_tags'] ?? ''))
|
||||
)));
|
||||
$values = [
|
||||
$categoryId,
|
||||
$name,
|
||||
Security::slug((string) ($_POST['slug'] ?? $name)),
|
||||
Security::clean((string) ($_POST['description'] ?? '')),
|
||||
$priceCents,
|
||||
$compareAt,
|
||||
Security::clean((string) ($_POST['image_url'] ?? '')),
|
||||
json_encode($tags, JSON_UNESCAPED_SLASHES),
|
||||
isset($_POST['featured']) ? 1 : 0,
|
||||
isset($_POST['active']) ? 1 : 0,
|
||||
(int) ($_POST['display_order'] ?? 0),
|
||||
];
|
||||
if ($id > 0) {
|
||||
$values[] = $id;
|
||||
$this->db->execute(
|
||||
'UPDATE menu_items SET category_id = ?, name = ?, slug = ?, description = ?, price_cents = ?,
|
||||
compare_at_cents = ?, image_url = ?, dietary_tags = ?, featured = ?, active = ?,
|
||||
display_order = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
$values
|
||||
);
|
||||
} else {
|
||||
$this->db->execute(
|
||||
'INSERT INTO menu_items
|
||||
(category_id, name, slug, description, price_cents, compare_at_cents, image_url,
|
||||
dietary_tags, featured, active, display_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
$values
|
||||
);
|
||||
}
|
||||
Http::flash('success', 'Menu item saved.');
|
||||
Http::redirect('/admin/menu');
|
||||
}
|
||||
|
||||
public function specials(): void
|
||||
{
|
||||
$this->auth->requireStaff();
|
||||
View::render('admin/specials', $this->shared([
|
||||
'title' => 'Homepage Specials',
|
||||
'adminPage' => 'specials',
|
||||
'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
|
||||
ORDER BY s.display_order, s.id'
|
||||
),
|
||||
'items' => $this->db->fetchAll('SELECT id, name FROM menu_items WHERE active = 1 ORDER BY name'),
|
||||
]));
|
||||
}
|
||||
|
||||
public function saveSpecial(): void
|
||||
{
|
||||
Security::verifyCsrf();
|
||||
$this->auth->requireStaff();
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
$title = Security::clean((string) ($_POST['title'] ?? ''));
|
||||
if ($title === '') {
|
||||
Http::flash('error', 'Special title is required.');
|
||||
Http::redirect('/admin/specials');
|
||||
}
|
||||
$price = trim((string) ($_POST['price'] ?? '')) === ''
|
||||
? null
|
||||
: (int) round(((float) $_POST['price']) * 100);
|
||||
$values = [
|
||||
(int) ($_POST['menu_item_id'] ?? 0) ?: null,
|
||||
$title,
|
||||
Security::clean((string) ($_POST['description'] ?? '')),
|
||||
Security::clean((string) ($_POST['badge'] ?? 'Special')),
|
||||
$price,
|
||||
utc_datetime(Security::clean((string) ($_POST['starts_at'] ?? ''))),
|
||||
utc_datetime(Security::clean((string) ($_POST['ends_at'] ?? ''))),
|
||||
isset($_POST['active']) ? 1 : 0,
|
||||
(int) ($_POST['display_order'] ?? 0),
|
||||
];
|
||||
if ($id > 0) {
|
||||
$values[] = $id;
|
||||
$this->db->execute(
|
||||
'UPDATE specials SET menu_item_id = ?, title = ?, description = ?, badge = ?, price_cents = ?,
|
||||
starts_at = ?, ends_at = ?, active = ?, display_order = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
$values
|
||||
);
|
||||
} else {
|
||||
$this->db->execute(
|
||||
'INSERT INTO specials
|
||||
(menu_item_id, title, description, badge, price_cents, starts_at, ends_at, active, display_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
$values
|
||||
);
|
||||
}
|
||||
Http::flash('success', 'Homepage special saved.');
|
||||
Http::redirect('/admin/specials');
|
||||
}
|
||||
|
||||
public function users(): void
|
||||
{
|
||||
$this->auth->requireAdmin();
|
||||
$query = Security::clean((string) ($_GET['q'] ?? ''));
|
||||
$parameters = [];
|
||||
$where = '1 = 1';
|
||||
if ($query !== '') {
|
||||
$where = '(email LIKE ? OR full_name LIKE ? OR phone LIKE ?)';
|
||||
$parameters = ['%' . $query . '%', '%' . $query . '%', '%' . $query . '%'];
|
||||
}
|
||||
View::render('admin/users', $this->shared([
|
||||
'title' => 'Users & Staff',
|
||||
'adminPage' => 'users',
|
||||
'users' => $this->db->fetchAll("SELECT * FROM users WHERE {$where} ORDER BY role, full_name", $parameters),
|
||||
'query' => $query,
|
||||
]));
|
||||
}
|
||||
|
||||
public function saveUser(): void
|
||||
{
|
||||
Security::verifyCsrf();
|
||||
$admin = $this->auth->requireAdmin();
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
$role = Security::clean((string) ($_POST['role'] ?? 'customer'));
|
||||
if (!in_array($role, ['customer', 'staff', 'manager', 'admin'], true)) {
|
||||
$role = 'customer';
|
||||
}
|
||||
if ($id === (int) $admin['id'] && !isset($_POST['active'])) {
|
||||
Http::flash('error', 'You cannot deactivate your own account.');
|
||||
Http::redirect('/admin/users');
|
||||
}
|
||||
if ($id > 0) {
|
||||
$this->db->execute(
|
||||
'UPDATE users SET full_name = ?, phone = ?, role = ?, active = ?, newsletter_opt_in = ?,
|
||||
updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
[
|
||||
Security::clean((string) ($_POST['full_name'] ?? '')),
|
||||
Security::clean((string) ($_POST['phone'] ?? '')),
|
||||
$role,
|
||||
isset($_POST['active']) ? 1 : 0,
|
||||
isset($_POST['newsletter_opt_in']) ? 1 : 0,
|
||||
$id,
|
||||
]
|
||||
);
|
||||
} else {
|
||||
$email = strtolower(Security::clean((string) ($_POST['email'] ?? '')));
|
||||
$password = (string) ($_POST['password'] ?? '');
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($password) < 10) {
|
||||
Http::flash('error', 'New staff accounts need a valid email and a 10-character password.');
|
||||
Http::redirect('/admin/users');
|
||||
}
|
||||
$this->db->execute(
|
||||
'INSERT INTO users
|
||||
(email, password_hash, full_name, phone, role, active, newsletter_opt_in, email_verified_at)
|
||||
VALUES (?, ?, ?, ?, ?, 1, ?, CURRENT_TIMESTAMP)',
|
||||
[
|
||||
$email,
|
||||
password_hash($password, PASSWORD_DEFAULT),
|
||||
Security::clean((string) ($_POST['full_name'] ?? '')),
|
||||
Security::clean((string) ($_POST['phone'] ?? '')),
|
||||
$role,
|
||||
isset($_POST['newsletter_opt_in']) ? 1 : 0,
|
||||
]
|
||||
);
|
||||
}
|
||||
Http::flash('success', 'User account saved.');
|
||||
Http::redirect('/admin/users');
|
||||
}
|
||||
|
||||
public function settings(): void
|
||||
{
|
||||
$this->auth->requireAdmin();
|
||||
View::render('admin/settings', $this->shared([
|
||||
'title' => 'Store Settings',
|
||||
'adminPage' => 'settings',
|
||||
'taxRates' => $this->db->fetchAll('SELECT * FROM tax_rates ORDER BY jurisdiction, name'),
|
||||
]));
|
||||
}
|
||||
|
||||
public function saveSettings(): void
|
||||
{
|
||||
Security::verifyCsrf();
|
||||
$this->auth->requireAdmin();
|
||||
$textFields = [
|
||||
'app.url',
|
||||
'business.name',
|
||||
'business.phone',
|
||||
'business.email',
|
||||
'business.street',
|
||||
'business.city',
|
||||
'business.state',
|
||||
'business.postal_code',
|
||||
'business.hours',
|
||||
'business.announcement',
|
||||
'square.environment',
|
||||
'square.application_id',
|
||||
'square.location_id',
|
||||
'mailgun.domain',
|
||||
'mailgun.from_name',
|
||||
'mailgun.from_email',
|
||||
'database.mysql_host',
|
||||
'database.mysql_database',
|
||||
'database.mysql_username',
|
||||
];
|
||||
foreach ($textFields as $key) {
|
||||
[$group, $field] = explode('.', $key, 2);
|
||||
if (isset($_POST[$group]) && is_array($_POST[$group]) && array_key_exists($field, $_POST[$group])) {
|
||||
$this->config->set($key, trim((string) $_POST[$group][$field]));
|
||||
}
|
||||
}
|
||||
foreach (['square.access_token', 'mailgun.api_key', 'database.mysql_password'] as $secretKey) {
|
||||
[$group, $field] = explode('.', $secretKey, 2);
|
||||
$secret = isset($_POST[$group]) && is_array($_POST[$group])
|
||||
? trim((string) ($_POST[$group][$field] ?? ''))
|
||||
: '';
|
||||
if ($secret !== '') {
|
||||
$this->config->set($secretKey, $secret);
|
||||
}
|
||||
}
|
||||
$ordering = isset($_POST['ordering']) && is_array($_POST['ordering']) ? $_POST['ordering'] : [];
|
||||
$square = isset($_POST['square']) && is_array($_POST['square']) ? $_POST['square'] : [];
|
||||
$mailgun = isset($_POST['mailgun']) && is_array($_POST['mailgun']) ? $_POST['mailgun'] : [];
|
||||
$database = isset($_POST['database']) && is_array($_POST['database']) ? $_POST['database'] : [];
|
||||
$this->config->set('ordering.accepting_orders', isset($ordering['accepting_orders']));
|
||||
$this->config->set('ordering.pickup_eta_minutes', max(5, (int) ($ordering['pickup_eta_minutes'] ?? 25)));
|
||||
$this->config->set('square.enabled', isset($square['enabled']));
|
||||
$this->config->set('mailgun.enabled', isset($mailgun['enabled']));
|
||||
$this->config->set('database.mysql_port', (int) ($database['mysql_port'] ?? 3306));
|
||||
$this->config->save();
|
||||
Http::flash('success', 'Store settings saved.');
|
||||
Http::redirect('/admin/settings');
|
||||
}
|
||||
|
||||
public function saveTax(): void
|
||||
{
|
||||
Security::verifyCsrf();
|
||||
$this->auth->requireAdmin();
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
$values = [
|
||||
Security::clean((string) ($_POST['name'] ?? 'Tax')),
|
||||
Security::clean((string) ($_POST['jurisdiction'] ?? 'state')),
|
||||
(int) round(((float) ($_POST['rate'] ?? 0)) * 100),
|
||||
isset($_POST['active']) ? 1 : 0,
|
||||
];
|
||||
if ($id > 0) {
|
||||
$values[] = $id;
|
||||
$this->db->execute(
|
||||
'UPDATE tax_rates SET name = ?, jurisdiction = ?, rate_basis_points = ?, active = ?,
|
||||
updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
$values
|
||||
);
|
||||
} else {
|
||||
$this->db->execute(
|
||||
'INSERT INTO tax_rates (name, jurisdiction, rate_basis_points, active) VALUES (?, ?, ?, ?)',
|
||||
$values
|
||||
);
|
||||
}
|
||||
Http::flash('success', 'Tax rate saved.');
|
||||
Http::redirect('/admin/settings');
|
||||
}
|
||||
|
||||
public function newsletters(): void
|
||||
{
|
||||
$this->auth->requireStaff();
|
||||
View::render('admin/newsletters', $this->shared([
|
||||
'title' => 'Newsletters',
|
||||
'adminPage' => 'newsletters',
|
||||
'newsletters' => $this->db->fetchAll(
|
||||
'SELECT n.*, u.full_name AS author
|
||||
FROM newsletters n LEFT JOIN users u ON u.id = n.created_by
|
||||
ORDER BY n.created_at DESC'
|
||||
),
|
||||
'recipientCount' => (int) $this->db->fetch(
|
||||
'SELECT COUNT(*) AS count FROM users WHERE newsletter_opt_in = 1 AND active = 1'
|
||||
)['count'],
|
||||
]));
|
||||
}
|
||||
|
||||
public function sendNewsletter(): void
|
||||
{
|
||||
Security::verifyCsrf();
|
||||
$staff = $this->auth->requireStaff();
|
||||
$subject = Security::clean((string) ($_POST['subject'] ?? ''));
|
||||
$content = trim((string) ($_POST['content'] ?? ''));
|
||||
if ($subject === '' || $content === '') {
|
||||
Http::flash('error', 'Newsletter subject and message are required.');
|
||||
Http::redirect('/admin/newsletters');
|
||||
}
|
||||
$recipients = $this->db->fetchAll(
|
||||
'SELECT id, email, full_name FROM users WHERE newsletter_opt_in = 1 AND active = 1 ORDER BY id'
|
||||
);
|
||||
$sent = 0;
|
||||
foreach ($recipients as $recipient) {
|
||||
$signature = hash_hmac('sha256', (string) $recipient['id'], $this->newsletterKey());
|
||||
$unsubscribe = rtrim((string) $this->config->get('app.url'), '/')
|
||||
. '/unsubscribe?user=' . $recipient['id'] . '&signature=' . $signature;
|
||||
$html = '<p>Hi ' . htmlspecialchars((string) $recipient['full_name'], ENT_QUOTES, 'UTF-8') . ',</p>'
|
||||
. '<div>' . nl2br(htmlspecialchars($content, ENT_QUOTES, 'UTF-8')) . '</div>'
|
||||
. '<hr><p style="font-size:12px">You received this because you joined the Fat Bottom Grille news list. '
|
||||
. '<a href="' . htmlspecialchars($unsubscribe, ENT_QUOTES, 'UTF-8') . '">Unsubscribe</a></p>';
|
||||
try {
|
||||
$this->mailer->send((string) $recipient['email'], $subject, $html);
|
||||
$sent++;
|
||||
} catch (\Throwable) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
$this->db->execute(
|
||||
'INSERT INTO newsletters (subject, content_html, status, recipient_count, created_by, sent_at)
|
||||
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)',
|
||||
[$subject, nl2br(htmlspecialchars($content, ENT_QUOTES, 'UTF-8')), 'sent', $sent, (int) $staff['id']]
|
||||
);
|
||||
Http::flash('success', "Newsletter sent to {$sent} subscribed users.");
|
||||
Http::redirect('/admin/newsletters');
|
||||
}
|
||||
|
||||
public function exportMenuPdf(): void
|
||||
{
|
||||
$this->auth->requireStaff();
|
||||
$categories = $this->db->fetchAll('SELECT * FROM menu_categories WHERE active = 1 ORDER BY display_order, name');
|
||||
foreach ($categories as &$category) {
|
||||
$category['items'] = $this->db->fetchAll(
|
||||
'SELECT * FROM menu_items WHERE category_id = ? AND active = 1 ORDER BY display_order, name',
|
||||
[(int) $category['id']]
|
||||
);
|
||||
}
|
||||
unset($category);
|
||||
(new MenuPdfExporter())->output((string) $this->config->get('business.name'), $categories);
|
||||
}
|
||||
|
||||
public function exportOrdersCsv(): never
|
||||
{
|
||||
$this->auth->requireStaff();
|
||||
$orders = $this->db->fetchAll('SELECT * FROM orders ORDER BY placed_at DESC');
|
||||
header('Content-Type: text/csv');
|
||||
header('Content-Disposition: attachment; filename="fat-bottom-orders-' . date('Y-m-d') . '.csv"');
|
||||
$output = fopen('php://output', 'wb');
|
||||
fputcsv($output, [
|
||||
'Order', 'Placed', 'Customer', 'Email', 'Phone', 'Status', 'Payment',
|
||||
'Subtotal', 'Tax', 'Total', 'Instructions',
|
||||
]);
|
||||
foreach ($orders as $order) {
|
||||
fputcsv($output, [
|
||||
$order['order_number'],
|
||||
$order['placed_at'],
|
||||
$order['customer_name'],
|
||||
$order['customer_email'],
|
||||
$order['customer_phone'],
|
||||
$order['status'],
|
||||
$order['payment_status'],
|
||||
number_format((int) $order['subtotal_cents'] / 100, 2, '.', ''),
|
||||
number_format((int) $order['tax_cents'] / 100, 2, '.', ''),
|
||||
number_format((int) $order['total_cents'] / 100, 2, '.', ''),
|
||||
$order['special_instructions'],
|
||||
]);
|
||||
}
|
||||
fclose($output);
|
||||
exit;
|
||||
}
|
||||
|
||||
public function migrateMySql(): void
|
||||
{
|
||||
Security::verifyCsrf();
|
||||
$this->auth->requireAdmin();
|
||||
try {
|
||||
$result = (new MySqlMigrator($this->config))->migrate();
|
||||
if (isset($_POST['switch_driver'])) {
|
||||
$this->config->set('database.driver', 'mysql');
|
||||
$this->config->save();
|
||||
}
|
||||
Http::flash(
|
||||
'success',
|
||||
sprintf('Copied %d tables and %d rows to MySQL%s.', $result['tables'], $result['rows'], isset($_POST['switch_driver']) ? ' and enabled MySQL mode' : '')
|
||||
);
|
||||
} catch (\Throwable $error) {
|
||||
Http::flash('error', $error->getMessage());
|
||||
}
|
||||
Http::redirect('/admin/settings');
|
||||
}
|
||||
|
||||
private function newsletterKey(): string
|
||||
{
|
||||
return hash('sha256', (string) $this->config->get('app.key') . '|newsletter');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
<?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\TwoFactorService;
|
||||
|
||||
final class AuthController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
Config $config,
|
||||
Auth $auth,
|
||||
CartService $cart,
|
||||
private readonly Database $db,
|
||||
private readonly TwoFactorService $twoFactor
|
||||
) {
|
||||
parent::__construct($config, $auth, $cart);
|
||||
}
|
||||
|
||||
public function loginForm(): void
|
||||
{
|
||||
if ($this->auth->check()) {
|
||||
Http::redirect('/account');
|
||||
}
|
||||
View::render('auth/login', $this->shared([
|
||||
'title' => 'Sign In',
|
||||
'captcha' => Security::newCaptcha(),
|
||||
]));
|
||||
}
|
||||
|
||||
public function login(): void
|
||||
{
|
||||
Security::verifyCsrf();
|
||||
$input = $_POST;
|
||||
$email = strtolower(Security::clean((string) ($input['email'] ?? '')));
|
||||
if (!Security::verifyCaptcha($input)) {
|
||||
Http::old(['email' => $email]);
|
||||
Http::flash('error', 'Please complete the anti-spam check.');
|
||||
Http::redirect('/login');
|
||||
}
|
||||
|
||||
$user = $this->db->fetch('SELECT * FROM users WHERE email = ? AND active = 1', [$email]);
|
||||
if ($user === null || !password_verify((string) ($input['password'] ?? ''), (string) $user['password_hash'])) {
|
||||
usleep(350000);
|
||||
Http::old(['email' => $email]);
|
||||
Http::flash('error', 'The email address or password was not recognized.');
|
||||
Http::redirect('/login');
|
||||
}
|
||||
|
||||
unset($_SESSION['pending_verification_purpose']);
|
||||
$_SESSION['pending_2fa_user_id'] = (int) $user['id'];
|
||||
$developmentCode = $this->twoFactor->issue($user, 'login');
|
||||
if ($developmentCode !== null) {
|
||||
Http::flash('info', 'Development mail mode: your verification code is ' . $developmentCode . '.');
|
||||
} else {
|
||||
Http::flash('success', 'We emailed you a six-digit verification code.');
|
||||
}
|
||||
Http::redirect('/verify');
|
||||
}
|
||||
|
||||
public function registerForm(): void
|
||||
{
|
||||
if ($this->auth->check()) {
|
||||
Http::redirect('/account');
|
||||
}
|
||||
View::render('auth/register', $this->shared([
|
||||
'title' => 'Create Account',
|
||||
'captcha' => Security::newCaptcha(),
|
||||
]));
|
||||
}
|
||||
|
||||
public function register(): void
|
||||
{
|
||||
Security::verifyCsrf();
|
||||
$input = $_POST;
|
||||
$clean = [
|
||||
'email' => strtolower(Security::clean((string) ($input['email'] ?? ''))),
|
||||
'full_name' => Security::clean((string) ($input['full_name'] ?? '')),
|
||||
'phone' => Security::clean((string) ($input['phone'] ?? '')),
|
||||
'street_address' => Security::clean((string) ($input['street_address'] ?? '')),
|
||||
'city' => Security::clean((string) ($input['city'] ?? '')),
|
||||
'state' => strtoupper(substr(Security::clean((string) ($input['state'] ?? 'WV')), 0, 2)),
|
||||
'postal_code' => Security::clean((string) ($input['postal_code'] ?? '')),
|
||||
];
|
||||
|
||||
if (!Security::verifyCaptcha($input)) {
|
||||
Http::old($clean);
|
||||
Http::flash('error', 'Please complete the anti-spam check.');
|
||||
Http::redirect('/register');
|
||||
}
|
||||
if (
|
||||
!filter_var($clean['email'], FILTER_VALIDATE_EMAIL)
|
||||
|| $clean['full_name'] === ''
|
||||
|| $clean['phone'] === ''
|
||||
|| $clean['street_address'] === ''
|
||||
|| $clean['city'] === ''
|
||||
|| $clean['state'] === ''
|
||||
|| $clean['postal_code'] === ''
|
||||
|| strlen((string) ($input['password'] ?? '')) < 10
|
||||
) {
|
||||
Http::old($clean);
|
||||
Http::flash('error', 'Complete your contact and address details, enter a valid email, and use a password of at least 10 characters.');
|
||||
Http::redirect('/register');
|
||||
}
|
||||
if (($input['password'] ?? '') !== ($input['password_confirmation'] ?? '')) {
|
||||
Http::old($clean);
|
||||
Http::flash('error', 'The password confirmation does not match.');
|
||||
Http::redirect('/register');
|
||||
}
|
||||
if ($this->db->fetch('SELECT id FROM users WHERE email = ?', [$clean['email']])) {
|
||||
Http::old($clean);
|
||||
Http::flash('error', 'An account already exists for that email address.');
|
||||
Http::redirect('/login');
|
||||
}
|
||||
|
||||
$this->db->execute(
|
||||
'INSERT INTO users
|
||||
(email, password_hash, full_name, phone, street_address, city, state, postal_code, newsletter_opt_in)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)',
|
||||
[
|
||||
$clean['email'],
|
||||
password_hash((string) $input['password'], PASSWORD_DEFAULT),
|
||||
$clean['full_name'],
|
||||
$clean['phone'],
|
||||
$clean['street_address'],
|
||||
$clean['city'],
|
||||
$clean['state'],
|
||||
$clean['postal_code'],
|
||||
]
|
||||
);
|
||||
$user = $this->db->fetch('SELECT * FROM users WHERE email = ?', [$clean['email']]);
|
||||
$_SESSION['pending_2fa_user_id'] = (int) $user['id'];
|
||||
$_SESSION['pending_verification_purpose'] = 'register';
|
||||
$developmentCode = $this->twoFactor->issue($user, 'register');
|
||||
if ($developmentCode !== null) {
|
||||
Http::flash('info', 'Development mail mode: your verification code is ' . $developmentCode . '.');
|
||||
}
|
||||
Http::redirect('/verify');
|
||||
}
|
||||
|
||||
public function verifyForm(): void
|
||||
{
|
||||
if (empty($_SESSION['pending_2fa_user_id'])) {
|
||||
Http::redirect('/login');
|
||||
}
|
||||
View::render('auth/verify', $this->shared([
|
||||
'title' => 'Verify Your Email',
|
||||
]));
|
||||
}
|
||||
|
||||
public function verify(): void
|
||||
{
|
||||
Security::verifyCsrf();
|
||||
$userId = (int) ($_SESSION['pending_2fa_user_id'] ?? 0);
|
||||
$purpose = (string) ($_SESSION['pending_verification_purpose'] ?? 'login');
|
||||
if (!$this->twoFactor->verify($userId, Security::clean((string) ($_POST['code'] ?? '')), $purpose)) {
|
||||
Http::flash('error', 'That code is invalid or has expired.');
|
||||
Http::redirect('/verify');
|
||||
}
|
||||
|
||||
$user = $this->db->fetch('SELECT * FROM users WHERE id = ?', [$userId]);
|
||||
if ($user === null) {
|
||||
Http::redirect('/login');
|
||||
}
|
||||
if ($purpose === 'register') {
|
||||
$this->db->execute('UPDATE users SET email_verified_at = CURRENT_TIMESTAMP WHERE id = ?', [$userId]);
|
||||
unset($_SESSION['pending_verification_purpose']);
|
||||
}
|
||||
$this->auth->login($user);
|
||||
Http::flash('success', 'Welcome, ' . $user['full_name'] . '.');
|
||||
Http::redirect(in_array($user['role'], ['admin', 'manager', 'staff'], true) ? '/admin' : '/account');
|
||||
}
|
||||
|
||||
public function logout(): void
|
||||
{
|
||||
Security::verifyCsrf();
|
||||
$this->auth->logout();
|
||||
Http::flash('success', 'You have been signed out.');
|
||||
Http::redirect('/');
|
||||
}
|
||||
|
||||
public function account(): void
|
||||
{
|
||||
$user = $this->auth->requireLogin();
|
||||
$orders = $this->db->fetchAll(
|
||||
'SELECT * FROM orders WHERE user_id = ? ORDER BY placed_at DESC LIMIT 25',
|
||||
[(int) $user['id']]
|
||||
);
|
||||
View::render('account/index', $this->shared([
|
||||
'title' => 'My Account',
|
||||
'orders' => $orders,
|
||||
]));
|
||||
}
|
||||
|
||||
public function updateAccount(): void
|
||||
{
|
||||
Security::verifyCsrf();
|
||||
$user = $this->auth->requireLogin();
|
||||
$newsletter = isset($_POST['newsletter_opt_in']) ? 1 : 0;
|
||||
$newPassword = (string) ($_POST['new_password'] ?? '');
|
||||
if (
|
||||
$newPassword !== ''
|
||||
&& (
|
||||
!password_verify((string) ($_POST['current_password'] ?? ''), (string) $user['password_hash'])
|
||||
|| strlen($newPassword) < 10
|
||||
|| $newPassword !== (string) ($_POST['new_password_confirmation'] ?? '')
|
||||
)
|
||||
) {
|
||||
Http::flash('error', 'Password was not changed. Check your current password and use a matching new password of at least 10 characters.');
|
||||
Http::redirect('/account');
|
||||
}
|
||||
|
||||
$this->db->execute(
|
||||
'UPDATE users SET full_name = ?, phone = ?, street_address = ?, city = ?, state = ?,
|
||||
postal_code = ?, newsletter_opt_in = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
[
|
||||
Security::clean((string) ($_POST['full_name'] ?? '')),
|
||||
Security::clean((string) ($_POST['phone'] ?? '')),
|
||||
Security::clean((string) ($_POST['street_address'] ?? '')),
|
||||
Security::clean((string) ($_POST['city'] ?? '')),
|
||||
strtoupper(substr(Security::clean((string) ($_POST['state'] ?? 'WV')), 0, 2)),
|
||||
Security::clean((string) ($_POST['postal_code'] ?? '')),
|
||||
$newsletter,
|
||||
(int) $user['id'],
|
||||
]
|
||||
);
|
||||
|
||||
if ($newPassword !== '') {
|
||||
$this->db->execute(
|
||||
'UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
[password_hash($newPassword, PASSWORD_DEFAULT), (int) $user['id']]
|
||||
);
|
||||
}
|
||||
|
||||
Http::flash('success', 'Your account has been updated.');
|
||||
Http::redirect('/account');
|
||||
}
|
||||
|
||||
public function unsubscribe(): void
|
||||
{
|
||||
$userId = (int) ($_GET['user'] ?? 0);
|
||||
$signature = (string) ($_GET['signature'] ?? '');
|
||||
$expected = hash_hmac('sha256', (string) $userId, $this->newsletterKey());
|
||||
if ($userId > 0 && hash_equals($expected, $signature)) {
|
||||
$this->db->execute('UPDATE users SET newsletter_opt_in = 0 WHERE id = ?', [$userId]);
|
||||
Http::flash('success', 'You have been unsubscribed from email news.');
|
||||
} else {
|
||||
Http::flash('error', 'That unsubscribe link is invalid.');
|
||||
}
|
||||
Http::redirect('/');
|
||||
}
|
||||
|
||||
private function newsletterKey(): string
|
||||
{
|
||||
return hash('sha256', (string) $this->config->get('app.key') . '|newsletter');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace FatBottom\Controllers;
|
||||
|
||||
use FatBottom\Core\Auth;
|
||||
use FatBottom\Core\Config;
|
||||
use FatBottom\Core\Http;
|
||||
use FatBottom\Services\CartService;
|
||||
|
||||
abstract class BaseController
|
||||
{
|
||||
public function __construct(
|
||||
protected readonly Config $config,
|
||||
protected readonly Auth $auth,
|
||||
protected readonly CartService $cart
|
||||
) {
|
||||
}
|
||||
|
||||
protected function shared(array $data = []): array
|
||||
{
|
||||
$user = $this->auth->user();
|
||||
$cart = $this->cart->details($user ? (int) $user['id'] : null);
|
||||
|
||||
return array_merge([
|
||||
'config' => $this->config,
|
||||
'auth' => $this->auth,
|
||||
'currentUser' => $user,
|
||||
'currentCart' => $cart,
|
||||
'flashMessages' => Http::consumeFlash(),
|
||||
'old' => Http::consumeOld(),
|
||||
], $data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
<?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'] !== []
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user