- 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:
Ty Clifford
2026-06-10 13:27:42 -04:00
parent d46b0be488
commit 16235369cb
49 changed files with 7685 additions and 2 deletions
+651
View File
@@ -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');
}
}
+265
View File
@@ -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');
}
}
+36
View File
@@ -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);
}
}
+231
View File
@@ -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'] !== []
));
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace FatBottom\Core;
final class Auth
{
private ?array $cachedUser = null;
private bool $resolved = false;
public function __construct(private readonly Database $db)
{
}
public function user(): ?array
{
if ($this->resolved) {
return $this->cachedUser;
}
$this->resolved = true;
$id = (int) ($_SESSION['user_id'] ?? 0);
if ($id === 0) {
return null;
}
$this->cachedUser = $this->db->fetch(
'SELECT * FROM users WHERE id = ? AND active = 1',
[$id]
);
return $this->cachedUser;
}
public function check(): bool
{
return $this->user() !== null;
}
public function login(array $user): void
{
session_regenerate_id(true);
$_SESSION['user_id'] = (int) $user['id'];
unset($_SESSION['pending_2fa_user_id']);
$this->resolved = true;
$this->cachedUser = $user;
}
public function logout(): void
{
unset($_SESSION['user_id'], $_SESSION['pending_2fa_user_id']);
session_regenerate_id(true);
$this->resolved = true;
$this->cachedUser = null;
}
public function requireLogin(): array
{
$user = $this->user();
if ($user === null) {
Http::flash('warning', 'Please sign in to continue.');
Http::redirect('/login');
}
return $user;
}
public function requireStaff(): array
{
$user = $this->requireLogin();
if (!in_array($user['role'], ['admin', 'manager', 'staff'], true)) {
Http::abort(403, 'You do not have access to the staff dashboard.');
}
return $user;
}
public function requireAdmin(): array
{
$user = $this->requireLogin();
if ($user['role'] !== 'admin') {
Http::abort(403, 'Administrator access is required.');
}
return $user;
}
}
+77
View File
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace FatBottom\Core;
final class Config
{
private array $values;
public function __construct(
private readonly array $defaults,
private readonly string $path
) {
$saved = [];
if (is_file($path)) {
$decoded = json_decode((string) file_get_contents($path), true);
if (is_array($decoded)) {
$saved = $decoded;
}
}
$this->values = array_replace_recursive($defaults, $saved);
}
public function all(): array
{
return $this->values;
}
public function get(string $key, mixed $default = null): mixed
{
$value = $this->values;
foreach (explode('.', $key) as $segment) {
if (!is_array($value) || !array_key_exists($segment, $value)) {
return $default;
}
$value = $value[$segment];
}
return $value;
}
public function set(string $key, mixed $newValue): void
{
$segments = explode('.', $key);
$value = &$this->values;
foreach ($segments as $segment) {
if (!isset($value[$segment]) || !is_array($value[$segment])) {
$value[$segment] = [];
}
$value = &$value[$segment];
}
$value = $newValue;
}
public function save(): void
{
$directory = dirname($this->path);
if (!is_dir($directory)) {
mkdir($directory, 0775, true);
}
file_put_contents(
$this->path,
json_encode($this->values, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL,
LOCK_EX
);
@chmod($this->path, 0600);
}
public function reset(): void
{
$this->values = $this->defaults;
$this->save();
}
}
+402
View File
@@ -0,0 +1,402 @@
<?php
declare(strict_types=1);
namespace FatBottom\Core;
use PDO;
use RuntimeException;
final class Database
{
private PDO $pdo;
private bool $sqlite;
public function __construct(private readonly Config $config)
{
$driver = (string) $config->get('database.driver', 'sqlite');
$this->sqlite = $driver !== 'mysql';
if ($this->sqlite) {
$directory = BASE_PATH . '/storage/database';
if (!is_dir($directory)) {
mkdir($directory, 0775, true);
}
$this->pdo = new PDO('sqlite:' . $directory . '/store.sqlite');
$this->pdo->exec('PRAGMA foreign_keys = ON');
$this->pdo->exec('PRAGMA journal_mode = WAL');
} else {
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4',
$config->get('database.mysql_host'),
(int) $config->get('database.mysql_port', 3306),
$config->get('database.mysql_database')
);
$this->pdo = new PDO(
$dsn,
(string) $config->get('database.mysql_username'),
(string) $config->get('database.mysql_password')
);
$this->pdo->exec("SET time_zone = '+00:00'");
}
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$this->pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$this->pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
}
public function pdo(): PDO
{
return $this->pdo;
}
public function isSqlite(): bool
{
return $this->sqlite;
}
public function migrate(): void
{
if (!$this->sqlite) {
return;
}
$schema = <<<'SQL'
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
full_name TEXT NOT NULL,
phone TEXT NOT NULL DEFAULT '',
street_address TEXT NOT NULL DEFAULT '',
city TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL DEFAULT '',
postal_code TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'customer',
newsletter_opt_in INTEGER NOT NULL DEFAULT 1,
email_verified_at TEXT,
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS login_codes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
code_hash TEXT NOT NULL,
purpose TEXT NOT NULL,
expires_at TEXT NOT NULL,
used_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS menu_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
display_order INTEGER NOT NULL DEFAULT 0,
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS menu_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category_id INTEGER NOT NULL,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
price_cents INTEGER NOT NULL,
compare_at_cents INTEGER,
image_url TEXT NOT NULL DEFAULT '',
dietary_tags TEXT NOT NULL DEFAULT '[]',
featured INTEGER NOT NULL DEFAULT 0,
active INTEGER NOT NULL DEFAULT 1,
display_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (category_id) REFERENCES menu_categories(id)
);
CREATE TABLE IF NOT EXISTS specials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
menu_item_id INTEGER,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
badge TEXT NOT NULL DEFAULT 'Special',
price_cents INTEGER,
starts_at TEXT,
ends_at TEXT,
active INTEGER NOT NULL DEFAULT 1,
display_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (menu_item_id) REFERENCES menu_items(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS tax_rates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
jurisdiction TEXT NOT NULL,
rate_basis_points INTEGER NOT NULL,
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS carts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT NOT NULL UNIQUE,
user_id INTEGER,
status TEXT NOT NULL DEFAULT 'active',
subtotal_cents INTEGER NOT NULL DEFAULT 0,
last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
converted_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS cart_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cart_id INTEGER NOT NULL,
menu_item_id INTEGER NOT NULL,
quantity INTEGER NOT NULL DEFAULT 1,
unit_price_cents INTEGER NOT NULL,
notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(cart_id, menu_item_id, notes),
FOREIGN KEY (cart_id) REFERENCES carts(id) ON DELETE CASCADE,
FOREIGN KEY (menu_item_id) REFERENCES menu_items(id)
);
CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_number TEXT NOT NULL UNIQUE,
user_id INTEGER,
cart_id INTEGER,
customer_name TEXT NOT NULL,
customer_email TEXT NOT NULL,
customer_phone TEXT NOT NULL,
order_type TEXT NOT NULL DEFAULT 'pickup',
status TEXT NOT NULL DEFAULT 'paid',
payment_status TEXT NOT NULL DEFAULT 'paid',
payment_provider TEXT NOT NULL DEFAULT 'demo',
payment_reference TEXT NOT NULL DEFAULT '',
subtotal_cents INTEGER NOT NULL,
tax_cents INTEGER NOT NULL,
total_cents INTEGER NOT NULL,
special_instructions TEXT NOT NULL DEFAULT '',
pickup_time TEXT,
placed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (cart_id) REFERENCES carts(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS order_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL,
menu_item_id INTEGER,
item_name TEXT NOT NULL,
quantity INTEGER NOT NULL,
unit_price_cents INTEGER NOT NULL,
line_total_cents INTEGER NOT NULL,
notes TEXT NOT NULL DEFAULT '',
FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE,
FOREIGN KEY (menu_item_id) REFERENCES menu_items(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS order_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL,
user_id INTEGER,
event_type TEXT NOT NULL,
details TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS refunds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL,
staff_user_id INTEGER,
amount_cents INTEGER NOT NULL,
reason TEXT NOT NULL DEFAULT '',
provider_reference TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (order_id) REFERENCES orders(id),
FOREIGN KEY (staff_user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS visitor_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
user_id INTEGER,
route TEXT NOT NULL,
referrer TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT '',
ip_hash TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS newsletters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
subject TEXT NOT NULL,
content_html TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft',
recipient_count INTEGER NOT NULL DEFAULT 0,
created_by INTEGER,
sent_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_menu_items_category ON menu_items(category_id, active);
CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status, placed_at);
CREATE INDEX IF NOT EXISTS idx_carts_status ON carts(status, last_seen_at);
CREATE INDEX IF NOT EXISTS idx_visitors_created ON visitor_events(created_at);
CREATE INDEX IF NOT EXISTS idx_users_newsletter ON users(newsletter_opt_in, active);
SQL;
$this->pdo->exec($schema);
$this->seed();
}
private function seed(): void
{
$categoryCount = (int) $this->pdo->query('SELECT COUNT(*) FROM menu_categories')->fetchColumn();
if ($categoryCount > 0) {
return;
}
$this->pdo->beginTransaction();
try {
$categories = [
['Smash Burgers', 'smash-burgers', 'Hand-pressed beef, crisp edges, and the good stuff.', 10],
['Burritos & Bowls', 'burritos-bowls', 'Big, packed, and built your way.', 20],
['Dinner Plates', 'dinner-plates', 'Comfort-food plates made for a proper supper.', 30],
['Sides', 'sides', 'The supporting cast that tends to steal the show.', 40],
['Drinks', 'drinks', 'Cold drinks for hot food.', 50],
];
$categoryStatement = $this->pdo->prepare(
'INSERT INTO menu_categories (name, slug, description, display_order) VALUES (?, ?, ?, ?)'
);
foreach ($categories as $category) {
$categoryStatement->execute($category);
}
$categoryIds = [];
foreach ($this->pdo->query('SELECT id, slug FROM menu_categories')->fetchAll() as $category) {
$categoryIds[$category['slug']] = (int) $category['id'];
}
$items = [
['smash-burgers', 'The Fat Bottom', 'the-fat-bottom', 'Two smashed patties, American cheese, grilled onion, pickles, and house sauce.', 1199, 1, '["House favorite"]'],
['smash-burgers', 'Piedmont Bacon Burger', 'piedmont-bacon-burger', 'Two patties, smoked bacon, cheddar, lettuce, tomato, onion, and burger sauce.', 1349, 1, '[]'],
['smash-burgers', 'Allegheny Melt', 'allegheny-melt', 'Smashed beef, Swiss, caramelized onions, and peppercorn mayo on toasted sourdough.', 1299, 0, '[]'],
['burritos-bowls', 'Mountaineer Burrito', 'mountaineer-burrito', 'Seasoned beef, rice, black beans, queso, pico, lettuce, and crema.', 1249, 1, '["Big appetite"]'],
['burritos-bowls', 'Chicken Fajita Bowl', 'chicken-fajita-bowl', 'Grilled chicken, peppers, onions, rice, corn salsa, avocado crema.', 1199, 0, '["Gluten conscious"]'],
['dinner-plates', 'Country Fried Chicken', 'country-fried-chicken', 'Crispy chicken, peppered gravy, mashed potatoes, and green beans.', 1499, 1, '[]'],
['dinner-plates', 'Keyser Meatloaf', 'keyser-meatloaf', 'House meatloaf with brown gravy, mashed potatoes, and seasonal vegetables.', 1449, 0, '[]'],
['sides', 'Loaded Fries', 'loaded-fries', 'Crispy fries, queso, bacon, scallions, and ranch drizzle.', 699, 1, '[]'],
['sides', 'Mac & Cheese', 'mac-cheese', 'Creamy, baked, and unapologetically cheesy.', 449, 0, '["Vegetarian"]'],
['drinks', 'Sweet Tea', 'sweet-tea', 'Fresh-brewed southern sweet tea.', 299, 0, '[]'],
['drinks', 'Fountain Drink', 'fountain-drink', 'Your choice of fountain soda.', 299, 0, '[]'],
];
$itemStatement = $this->pdo->prepare(
'INSERT INTO menu_items
(category_id, name, slug, description, price_cents, featured, dietary_tags)
VALUES (?, ?, ?, ?, ?, ?, ?)'
);
foreach ($items as $item) {
$itemStatement->execute([
$categoryIds[$item[0]],
$item[1],
$item[2],
$item[3],
$item[4],
$item[5],
$item[6],
]);
}
$fatBottomId = (int) $this->pdo->query(
"SELECT id FROM menu_items WHERE slug = 'the-fat-bottom'"
)->fetchColumn();
$loadedFriesId = (int) $this->pdo->query(
"SELECT id FROM menu_items WHERE slug = 'loaded-fries'"
)->fetchColumn();
$specialStatement = $this->pdo->prepare(
'INSERT INTO specials (menu_item_id, title, description, badge, price_cents, display_order)
VALUES (?, ?, ?, ?, ?, ?)'
);
$specialStatement->execute([
$fatBottomId,
'The Fat Bottom Deal',
'Our signature burger at a special online price.',
'Online Special',
1099,
10,
]);
$specialStatement->execute([
$loadedFriesId,
'Loaded-Up Lunch',
'Loaded fries are $1 off weekdays from 11 to 2.',
'Weekday Deal',
599,
20,
]);
$taxStatement = $this->pdo->prepare(
'INSERT INTO tax_rates (name, jurisdiction, rate_basis_points) VALUES (?, ?, ?)'
);
$taxStatement->execute(['West Virginia Sales Tax', 'state', 600]);
$taxStatement->execute(['Keyser Municipal Tax', 'city', 100]);
$adminStatement = $this->pdo->prepare(
'INSERT INTO users
(email, password_hash, full_name, phone, role, newsletter_opt_in, email_verified_at)
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)'
);
$adminStatement->execute([
'admin@fatbottomgrille.com',
password_hash('ChangeMe123!', PASSWORD_DEFAULT),
'Store Administrator',
'',
'admin',
0,
]);
$this->pdo->commit();
} catch (\Throwable $error) {
$this->pdo->rollBack();
throw new RuntimeException('Unable to seed the store database.', 0, $error);
}
}
public function fetchAll(string $sql, array $parameters = []): array
{
$statement = $this->pdo->prepare($sql);
$statement->execute($parameters);
return $statement->fetchAll();
}
public function fetch(string $sql, array $parameters = []): ?array
{
$statement = $this->pdo->prepare($sql);
$statement->execute($parameters);
$row = $statement->fetch();
return $row === false ? null : $row;
}
public function execute(string $sql, array $parameters = []): bool
{
$statement = $this->pdo->prepare($sql);
return $statement->execute($parameters);
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace FatBottom\Core;
final class Http
{
public static function redirect(string $path): never
{
header('Location: ' . $path);
exit;
}
public static function abort(int $status, string $message): never
{
http_response_code($status);
echo $message;
exit;
}
public static function flash(string $type, string $message): void
{
$_SESSION['_flash'][] = ['type' => $type, 'message' => $message];
}
public static function consumeFlash(): array
{
$messages = $_SESSION['_flash'] ?? [];
unset($_SESSION['_flash']);
return $messages;
}
public static function old(array $input): void
{
$_SESSION['_old'] = $input;
}
public static function consumeOld(): array
{
$old = $_SESSION['_old'] ?? [];
unset($_SESSION['_old']);
return $old;
}
public static function path(): string
{
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
$path = is_string($path) ? rtrim($path, '/') : '/';
return $path === '' ? '/' : $path;
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace FatBottom\Core;
final class Router
{
private array $routes = [];
public function get(string $path, callable $handler): void
{
$this->routes['GET'][$path] = $handler;
}
public function post(string $path, callable $handler): void
{
$this->routes['POST'][$path] = $handler;
}
public function dispatch(string $method, string $path): void
{
$handler = $this->routes[$method][$path] ?? null;
if ($handler === null) {
Http::abort(404, 'The page you requested could not be found.');
}
$handler();
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace FatBottom\Core;
final class Security
{
public static function csrfToken(): string
{
if (empty($_SESSION['_csrf'])) {
$_SESSION['_csrf'] = bin2hex(random_bytes(32));
}
return (string) $_SESSION['_csrf'];
}
public static function verifyCsrf(): void
{
$submitted = $_POST['_token'] ?? '';
if (!is_string($submitted) || !hash_equals(self::csrfToken(), $submitted)) {
Http::abort(419, 'Your session expired. Please go back and try again.');
}
}
public static function newCaptcha(): array
{
$left = random_int(2, 9);
$right = random_int(1, 9);
$_SESSION['_captcha_answer'] = $left + $right;
return [$left, $right];
}
public static function verifyCaptcha(array $input): bool
{
if (!empty($input['website'])) {
return false;
}
$expected = (int) ($_SESSION['_captcha_answer'] ?? -1);
unset($_SESSION['_captcha_answer']);
return isset($input['captcha']) && (int) $input['captcha'] === $expected;
}
public static function clean(string $value): string
{
return trim(strip_tags($value));
}
public static function slug(string $value): string
{
$value = strtolower(trim($value));
$value = preg_replace('/[^a-z0-9]+/', '-', $value) ?? '';
return trim($value, '-') ?: bin2hex(random_bytes(4));
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace FatBottom\Core;
final class View
{
public static function render(string $template, array $data = []): void
{
$templatePath = BASE_PATH . '/views/' . $template . '.php';
if (!is_file($templatePath)) {
Http::abort(500, 'View not found.');
}
extract($data, EXTR_SKIP);
require BASE_PATH . '/views/layout.php';
}
public static function partial(string $template, array $data = []): void
{
extract($data, EXTR_SKIP);
require BASE_PATH . '/views/' . $template . '.php';
}
}
+147
View File
@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace FatBottom\Services;
use FatBottom\Core\Database;
final class CartService
{
public function __construct(private readonly Database $db)
{
}
public function current(?int $userId = null): array
{
$token = $_COOKIE['fatbottom_cart'] ?? '';
$cart = null;
if (is_string($token) && $token !== '') {
$cart = $this->db->fetch('SELECT * FROM carts WHERE token = ? AND status = ?', [$token, 'active']);
}
if ($cart === null) {
$token = bin2hex(random_bytes(24));
$this->db->execute(
'INSERT INTO carts (token, user_id, status) VALUES (?, ?, ?)',
[$token, $userId, 'active']
);
setcookie('fatbottom_cart', $token, [
'expires' => time() + 60 * 60 * 24 * 30,
'path' => '/',
'secure' => !empty($_SERVER['HTTPS']),
'httponly' => true,
'samesite' => 'Lax',
]);
$cart = $this->db->fetch('SELECT * FROM carts WHERE token = ?', [$token]);
} elseif ($userId !== null && empty($cart['user_id'])) {
$this->db->execute('UPDATE carts SET user_id = ? WHERE id = ?', [$userId, (int) $cart['id']]);
$cart['user_id'] = $userId;
}
$this->touch((int) $cart['id']);
return $cart;
}
public function add(int $menuItemId, int $quantity, string $notes, ?int $userId = null): bool
{
$item = $this->db->fetch('SELECT * FROM menu_items WHERE id = ? AND active = 1', [$menuItemId]);
if ($item === null) {
return false;
}
$special = $this->db->fetch(
"SELECT price_cents FROM specials
WHERE menu_item_id = ? AND active = 1 AND price_cents IS NOT NULL
AND (starts_at IS NULL OR starts_at <= CURRENT_TIMESTAMP)
AND (ends_at IS NULL OR ends_at >= CURRENT_TIMESTAMP)
ORDER BY price_cents ASC LIMIT 1",
[$menuItemId]
);
$unitPrice = $special !== null
? min((int) $item['price_cents'], (int) $special['price_cents'])
: (int) $item['price_cents'];
$cart = $this->current($userId);
$notes = trim(substr($notes, 0, 240));
$existing = $this->db->fetch(
'SELECT * FROM cart_items WHERE cart_id = ? AND menu_item_id = ? AND notes = ?',
[(int) $cart['id'], $menuItemId, $notes]
);
if ($existing) {
$this->db->execute(
'UPDATE cart_items SET quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
[min(20, (int) $existing['quantity'] + max(1, $quantity)), (int) $existing['id']]
);
} else {
$this->db->execute(
'INSERT INTO cart_items (cart_id, menu_item_id, quantity, unit_price_cents, notes)
VALUES (?, ?, ?, ?, ?)',
[(int) $cart['id'], $menuItemId, min(20, max(1, $quantity)), $unitPrice, $notes]
);
}
$this->recalculate((int) $cart['id']);
return true;
}
public function update(array $quantities, ?int $userId = null): void
{
$cart = $this->current($userId);
foreach ($quantities as $itemId => $quantity) {
$itemId = (int) $itemId;
$quantity = (int) $quantity;
if ($quantity <= 0) {
$this->db->execute('DELETE FROM cart_items WHERE id = ? AND cart_id = ?', [$itemId, (int) $cart['id']]);
} else {
$this->db->execute(
'UPDATE cart_items SET quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND cart_id = ?',
[min(20, $quantity), $itemId, (int) $cart['id']]
);
}
}
$this->recalculate((int) $cart['id']);
}
public function remove(int $cartItemId, ?int $userId = null): void
{
$cart = $this->current($userId);
$this->db->execute('DELETE FROM cart_items WHERE id = ? AND cart_id = ?', [$cartItemId, (int) $cart['id']]);
$this->recalculate((int) $cart['id']);
}
public function details(?int $userId = null): array
{
$cart = $this->current($userId);
$items = $this->db->fetchAll(
'SELECT ci.*, mi.name, mi.description, mi.active
FROM cart_items ci
JOIN menu_items mi ON mi.id = ci.menu_item_id
WHERE ci.cart_id = ?
ORDER BY ci.id',
[(int) $cart['id']]
);
$cart['items'] = $items;
$cart['item_count'] = array_sum(array_map(static fn (array $item): int => (int) $item['quantity'], $items));
return $cart;
}
private function touch(int $cartId): void
{
$this->db->execute('UPDATE carts SET last_seen_at = CURRENT_TIMESTAMP WHERE id = ?', [$cartId]);
}
private function recalculate(int $cartId): void
{
$subtotal = (int) $this->db->fetch(
'SELECT COALESCE(SUM(quantity * unit_price_cents), 0) AS subtotal
FROM cart_items WHERE cart_id = ?',
[$cartId]
)['subtotal'];
$this->db->execute(
'UPDATE carts SET subtotal_cents = ?, last_seen_at = CURRENT_TIMESTAMP WHERE id = ?',
[$subtotal, $cartId]
);
}
}
+80
View File
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace FatBottom\Services;
use FatBottom\Core\Config;
use RuntimeException;
final class Mailer
{
public function __construct(private readonly Config $config)
{
}
public function send(string $to, string $subject, string $html): bool
{
if (!(bool) $this->config->get('mailgun.enabled', false)) {
$this->writeToLog($to, $subject, $html);
return true;
}
if (!function_exists('curl_init')) {
throw new RuntimeException('The PHP cURL extension is required for Mailgun.');
}
$domain = (string) $this->config->get('mailgun.domain');
$apiKey = (string) $this->config->get('mailgun.api_key');
if ($domain === '' || $apiKey === '') {
throw new RuntimeException('Mailgun is enabled but its credentials are incomplete.');
}
$from = sprintf(
'%s <%s>',
$this->config->get('mailgun.from_name', 'Fat Bottom Grille'),
$this->config->get('mailgun.from_email')
);
$curl = curl_init('https://api.mailgun.net/v3/' . rawurlencode($domain) . '/messages');
curl_setopt_array($curl, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_USERPWD => 'api:' . $apiKey,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => [
'from' => $from,
'to' => $to,
'subject' => $subject,
'html' => $html,
],
CURLOPT_TIMEOUT => 20,
]);
$response = curl_exec($curl);
$status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$error = curl_error($curl);
curl_close($curl);
if ($response === false || $status < 200 || $status >= 300) {
throw new RuntimeException('Mailgun delivery failed: ' . ($error ?: 'HTTP ' . $status));
}
return true;
}
private function writeToLog(string $to, string $subject, string $html): void
{
$directory = BASE_PATH . '/storage/logs';
if (!is_dir($directory)) {
mkdir($directory, 0775, true);
}
$line = sprintf(
"[%s] TO: %s | SUBJECT: %s\n%s\n\n",
date('c'),
$to,
$subject,
trim(strip_tags($html))
);
file_put_contents($directory . '/mail.log', $line, FILE_APPEND | LOCK_EX);
}
}
+110
View File
@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace FatBottom\Services;
final class MenuPdfExporter
{
public function output(string $businessName, array $categories): never
{
$pages = [];
$lines = [];
foreach ($categories as $category) {
$lines[] = ['type' => 'category', 'text' => strtoupper((string) $category['name'])];
foreach ($category['items'] as $item) {
$lines[] = [
'type' => 'item',
'text' => sprintf('%s $%.2f', $item['name'], $item['price_cents'] / 100),
];
$lines[] = ['type' => 'description', 'text' => (string) $item['description']];
}
}
foreach (array_chunk($lines, 24) as $chunk) {
$pages[] = $this->pageStream($businessName, $chunk);
}
if ($pages === []) {
$pages[] = $this->pageStream($businessName, []);
}
$pdf = $this->buildPdf($pages);
header('Content-Type: application/pdf');
header('Content-Disposition: attachment; filename="fat-bottom-grille-menu.pdf"');
header('Content-Length: ' . strlen($pdf));
echo $pdf;
exit;
}
private function pageStream(string $businessName, array $lines): string
{
$escape = static function (string $value): string {
$ascii = function_exists('iconv')
? (iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value) ?: $value)
: preg_replace('/[^\x20-\x7E]/', '', $value);
return str_replace(
['\\', '(', ')', "\r", "\n"],
['\\\\', '\\(', '\\)', ' ', ' '],
(string) $ascii
);
};
$stream = "0.035 0.071 0.102 rg\n0 0 612 792 re f\n";
$stream .= "0.78 0.58 0.20 rg\nBT /F1 25 Tf 46 738 Td (" . $escape($businessName) . ") Tj ET\n";
$stream .= "0.91 0.92 0.90 rg\nBT /F1 10 Tf 46 718 Td (KEYSER, WEST VIRGINIA | ONLINE MENU) Tj ET\n";
$stream .= "0.78 0.58 0.20 RG\n46 704 m 566 704 l S\n";
$y = 678;
foreach ($lines as $line) {
$text = $escape(substr((string) $line['text'], 0, 92));
if ($line['type'] === 'category') {
$stream .= "0.78 0.58 0.20 rg\nBT /F1 15 Tf 46 {$y} Td ({$text}) Tj ET\n";
$y -= 24;
} elseif ($line['type'] === 'item') {
$stream .= "0.96 0.95 0.89 rg\nBT /F1 12 Tf 54 {$y} Td ({$text}) Tj ET\n";
$y -= 17;
} else {
$stream .= "0.68 0.72 0.72 rg\nBT /F1 8 Tf 54 {$y} Td ({$text}) Tj ET\n";
$y -= 23;
}
}
$stream .= "0.55 0.60 0.62 rg\nBT /F1 8 Tf 46 35 Td (Prices and availability are subject to change.) Tj ET\n";
return $stream;
}
private function buildPdf(array $streams): string
{
$objects = [];
$objects[1] = '<< /Type /Catalog /Pages 2 0 R >>';
$pageIds = [];
$nextId = 4;
foreach ($streams as $index => $stream) {
$pageId = $nextId++;
$contentId = $nextId++;
$pageIds[] = $pageId . ' 0 R';
$objects[$pageId] = sprintf(
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 3 0 R >> >> /Contents %d 0 R >>',
$contentId
);
$objects[$contentId] = "<< /Length " . strlen($stream) . " >>\nstream\n{$stream}endstream";
}
$objects[2] = '<< /Type /Pages /Kids [' . implode(' ', $pageIds) . '] /Count ' . count($pageIds) . ' >>';
$objects[3] = '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>';
ksort($objects);
$pdf = "%PDF-1.4\n";
$offsets = [0];
foreach ($objects as $id => $object) {
$offsets[$id] = strlen($pdf);
$pdf .= "{$id} 0 obj\n{$object}\nendobj\n";
}
$xref = strlen($pdf);
$count = max(array_keys($objects)) + 1;
$pdf .= "xref\n0 {$count}\n0000000000 65535 f \n";
for ($id = 1; $id < $count; $id++) {
$pdf .= sprintf("%010d 00000 n \n", $offsets[$id] ?? 0);
}
$pdf .= "trailer\n<< /Size {$count} /Root 1 0 R >>\nstartxref\n{$xref}\n%%EOF";
return $pdf;
}
}
+97
View File
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace FatBottom\Services;
use FatBottom\Core\Config;
use PDO;
use RuntimeException;
final class MySqlMigrator
{
public function __construct(private readonly Config $config)
{
}
public function migrate(): array
{
$sqlitePath = BASE_PATH . '/storage/database/store.sqlite';
if (!is_file($sqlitePath)) {
throw new RuntimeException('The SQLite store database does not exist yet.');
}
if (!extension_loaded('pdo_mysql')) {
throw new RuntimeException('The pdo_mysql PHP extension is required for migration.');
}
$mysql = new PDO(
sprintf(
'mysql:host=%s;port=%d;charset=utf8mb4',
$this->config->get('database.mysql_host'),
(int) $this->config->get('database.mysql_port', 3306)
),
(string) $this->config->get('database.mysql_username'),
(string) $this->config->get('database.mysql_password'),
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
$database = preg_replace('/[^a-zA-Z0-9_]/', '', (string) $this->config->get('database.mysql_database'));
if ($database === '') {
throw new RuntimeException('Enter a valid MySQL database name.');
}
$mysql->exec("CREATE DATABASE IF NOT EXISTS `{$database}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
$mysql->exec("USE `{$database}`");
$sqlite = new PDO('sqlite:' . $sqlitePath, null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$tables = $sqlite->query(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
)->fetchAll(PDO::FETCH_COLUMN);
$rowCount = 0;
$mysql->exec('SET FOREIGN_KEY_CHECKS = 0');
foreach ($tables as $table) {
$columns = $sqlite->query("PRAGMA table_info(`{$table}`)")->fetchAll(PDO::FETCH_ASSOC);
$definitions = [];
$primary = [];
foreach ($columns as $column) {
$type = strtoupper((string) $column['type']);
$mysqlType = str_contains($type, 'INT') ? 'BIGINT'
: (str_contains($type, 'REAL') || str_contains($type, 'FLOA') ? 'DOUBLE' : 'TEXT');
if ((int) $column['pk'] === 1 && $column['name'] === 'id') {
$definitions[] = '`id` BIGINT NOT NULL AUTO_INCREMENT';
$primary[] = '`id`';
} else {
$definitions[] = sprintf(
'`%s` %s %s',
str_replace('`', '', (string) $column['name']),
$mysqlType,
(int) $column['notnull'] === 1 ? 'NOT NULL' : 'NULL'
);
if ((int) $column['pk'] === 1) {
$primary[] = '`' . str_replace('`', '', (string) $column['name']) . '`';
}
}
}
if ($primary !== []) {
$definitions[] = 'PRIMARY KEY (' . implode(', ', $primary) . ')';
}
$mysql->exec("DROP TABLE IF EXISTS `{$table}`");
$mysql->exec("CREATE TABLE `{$table}` (" . implode(', ', $definitions) . ') ENGINE=InnoDB');
$rows = $sqlite->query("SELECT * FROM `{$table}`")->fetchAll(PDO::FETCH_ASSOC);
if ($rows !== []) {
$names = array_keys($rows[0]);
$placeholders = implode(', ', array_fill(0, count($names), '?'));
$quotedNames = implode(', ', array_map(static fn (string $name): string => "`{$name}`", $names));
$insert = $mysql->prepare("INSERT INTO `{$table}` ({$quotedNames}) VALUES ({$placeholders})");
foreach ($rows as $row) {
$insert->execute(array_values($row));
$rowCount++;
}
}
}
$mysql->exec('SET FOREIGN_KEY_CHECKS = 1');
return ['tables' => count($tables), 'rows' => $rowCount];
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace FatBottom\Services;
use FatBottom\Core\Database;
use RuntimeException;
final class OrderService
{
public function __construct(
private readonly Database $db,
private readonly PaymentGateway $payments
) {
}
public function totals(array $cart): array
{
$subtotal = (int) $cart['subtotal_cents'];
$taxRate = (int) $this->db->fetch(
'SELECT COALESCE(SUM(rate_basis_points), 0) AS total_rate FROM tax_rates WHERE active = 1'
)['total_rate'];
$tax = (int) round($subtotal * $taxRate / 10000);
return [
'subtotal_cents' => $subtotal,
'tax_rate_basis_points' => $taxRate,
'tax_cents' => $tax,
'total_cents' => $subtotal + $tax,
];
}
public function place(array $cart, array $customer, ?int $userId, string $sourceId): array
{
if (empty($cart['items'])) {
throw new RuntimeException('Your cart is empty.');
}
$totals = $this->totals($cart);
$orderNumber = 'FBG-' . date('ymd') . '-'
. strtoupper(substr(hash('sha256', (string) $cart['token']), 0, 6));
$payment = $this->payments->charge($sourceId, $totals['total_cents'], $orderNumber);
$pdo = $this->db->pdo();
$pdo->beginTransaction();
try {
$statement = $pdo->prepare(
'INSERT INTO orders
(order_number, user_id, cart_id, customer_name, customer_email, customer_phone,
order_type, status, payment_status, payment_provider, payment_reference,
subtotal_cents, tax_cents, total_cents, special_instructions, pickup_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
$statement->execute([
$orderNumber,
$userId,
(int) $cart['id'],
$customer['customer_name'],
$customer['customer_email'],
$customer['customer_phone'],
'pickup',
'paid',
'paid',
$payment['provider'],
$payment['reference'],
$totals['subtotal_cents'],
$totals['tax_cents'],
$totals['total_cents'],
$customer['special_instructions'],
$customer['pickup_time'] ?: null,
]);
$orderId = (int) $pdo->lastInsertId();
$itemStatement = $pdo->prepare(
'INSERT INTO order_items
(order_id, menu_item_id, item_name, quantity, unit_price_cents, line_total_cents, notes)
VALUES (?, ?, ?, ?, ?, ?, ?)'
);
foreach ($cart['items'] as $item) {
$itemStatement->execute([
$orderId,
(int) $item['menu_item_id'],
$item['name'],
(int) $item['quantity'],
(int) $item['unit_price_cents'],
(int) $item['quantity'] * (int) $item['unit_price_cents'],
$item['notes'],
]);
}
$pdo->prepare(
'INSERT INTO order_events (order_id, user_id, event_type, details) VALUES (?, ?, ?, ?)'
)->execute([$orderId, $userId, 'order_placed', 'Online pickup order paid successfully.']);
$pdo->prepare(
"UPDATE carts SET status = 'converted', converted_at = CURRENT_TIMESTAMP WHERE id = ?"
)->execute([(int) $cart['id']]);
$pdo->commit();
} catch (\Throwable $error) {
$pdo->rollBack();
throw $error;
}
return $this->db->fetch('SELECT * FROM orders WHERE id = ?', [$orderId]) ?? [];
}
}
+132
View File
@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace FatBottom\Services;
use FatBottom\Core\Config;
use RuntimeException;
final class PaymentGateway
{
public function __construct(private readonly Config $config)
{
}
public function charge(string $sourceId, int $amountCents, string $orderNumber): array
{
if (!(bool) $this->config->get('square.enabled', false)) {
return [
'success' => true,
'provider' => 'demo',
'reference' => 'DEMO-' . strtoupper(bin2hex(random_bytes(5))),
'status' => 'COMPLETED',
];
}
if ($sourceId === '') {
throw new RuntimeException('Card details are required.');
}
if (!function_exists('curl_init')) {
throw new RuntimeException('The PHP cURL extension is required for Square payments.');
}
$environment = (string) $this->config->get('square.environment', 'sandbox');
$baseUrl = $environment === 'production'
? 'https://connect.squareup.com'
: 'https://connect.squareupsandbox.com';
$accessToken = (string) $this->config->get('square.access_token');
$locationId = (string) $this->config->get('square.location_id');
if ($accessToken === '' || $locationId === '') {
throw new RuntimeException('Square is enabled but its server credentials are incomplete.');
}
$payload = json_encode([
'source_id' => $sourceId,
'idempotency_key' => substr(hash('sha256', 'fatbottom-payment|' . $orderNumber), 0, 45),
'amount_money' => [
'amount' => $amountCents,
'currency' => 'USD',
],
'location_id' => $locationId,
'reference_id' => $orderNumber,
'note' => 'Online pickup order ' . $orderNumber,
'autocomplete' => true,
], JSON_THROW_ON_ERROR);
$curl = curl_init($baseUrl . '/v2/payments');
curl_setopt_array($curl, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $accessToken,
'Content-Type: application/json',
'Square-Version: 2026-05-20',
],
CURLOPT_TIMEOUT => 30,
]);
$response = curl_exec($curl);
$status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$error = curl_error($curl);
curl_close($curl);
$decoded = is_string($response) ? json_decode($response, true) : null;
if ($response === false || $status < 200 || $status >= 300 || !isset($decoded['payment'])) {
$message = $decoded['errors'][0]['detail'] ?? $error ?: 'Payment was declined.';
throw new RuntimeException((string) $message);
}
return [
'success' => true,
'provider' => 'square',
'reference' => (string) $decoded['payment']['id'],
'status' => (string) $decoded['payment']['status'],
];
}
public function refund(string $paymentReference, int $amountCents, string $reason): array
{
if (!(bool) $this->config->get('square.enabled', false) || str_starts_with($paymentReference, 'DEMO-')) {
return ['success' => true, 'reference' => 'REF-DEMO-' . strtoupper(bin2hex(random_bytes(4)))];
}
if (!function_exists('curl_init')) {
throw new RuntimeException('The PHP cURL extension is required for Square refunds.');
}
$environment = (string) $this->config->get('square.environment', 'sandbox');
$baseUrl = $environment === 'production'
? 'https://connect.squareup.com'
: 'https://connect.squareupsandbox.com';
$payload = json_encode([
'idempotency_key' => bin2hex(random_bytes(16)),
'payment_id' => $paymentReference,
'amount_money' => ['amount' => $amountCents, 'currency' => 'USD'],
'reason' => substr($reason, 0, 190),
], JSON_THROW_ON_ERROR);
$curl = curl_init($baseUrl . '/v2/refunds');
curl_setopt_array($curl, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $this->config->get('square.access_token'),
'Content-Type: application/json',
'Square-Version: 2026-05-20',
],
CURLOPT_TIMEOUT => 30,
]);
$response = curl_exec($curl);
$status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
curl_close($curl);
$decoded = is_string($response) ? json_decode($response, true) : null;
if ($status < 200 || $status >= 300 || !isset($decoded['refund'])) {
throw new RuntimeException((string) ($decoded['errors'][0]['detail'] ?? 'Refund failed.'));
}
return ['success' => true, 'reference' => (string) $decoded['refund']['id']];
}
}
+100
View File
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace FatBottom\Services;
use FatBottom\Core\Database;
final class StatsService
{
public function __construct(private readonly Database $db)
{
}
public function record(string $route, ?int $userId): void
{
if (str_starts_with($route, '/assets/')) {
return;
}
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
$this->db->execute(
'INSERT INTO visitor_events (session_id, user_id, route, referrer, user_agent, ip_hash)
VALUES (?, ?, ?, ?, ?, ?)',
[
session_id(),
$userId,
substr($route, 0, 255),
substr((string) ($_SERVER['HTTP_REFERER'] ?? ''), 0, 500),
substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 500),
hash('sha256', $ip . date('Y-m-d')),
]
);
}
public function dashboard(): array
{
if (!$this->db->isSqlite()) {
return [
'today_orders' => (int) $this->db->fetch(
'SELECT COUNT(*) AS value FROM orders WHERE DATE(placed_at) = CURRENT_DATE'
)['value'],
'today_sales' => (int) $this->db->fetch(
"SELECT COALESCE(SUM(o.total_cents), 0) - COALESCE(SUM(r.refunded_cents), 0) AS value
FROM orders o
LEFT JOIN (
SELECT order_id, SUM(amount_cents) AS refunded_cents FROM refunds GROUP BY order_id
) r ON r.order_id = o.id
WHERE o.payment_status IN ('paid', 'partially_refunded', 'refunded')
AND DATE(o.placed_at) = CURRENT_DATE"
)['value'],
'open_orders' => (int) $this->db->fetch(
"SELECT COUNT(*) AS value FROM orders WHERE status IN ('paid', 'preparing', 'ready')"
)['value'],
'abandoned_carts' => (int) $this->db->fetch(
"SELECT COUNT(*) AS value FROM carts
WHERE status = 'active' AND subtotal_cents > 0
AND last_seen_at < DATE_SUB(NOW(), INTERVAL 30 MINUTE)"
)['value'],
'today_visitors' => (int) $this->db->fetch(
'SELECT COUNT(DISTINCT session_id) AS value FROM visitor_events
WHERE DATE(created_at) = CURRENT_DATE'
)['value'],
'newsletter_members' => (int) $this->db->fetch(
'SELECT COUNT(*) AS value FROM users WHERE newsletter_opt_in = 1 AND active = 1'
)['value'],
];
}
return [
'today_orders' => (int) $this->db->fetch(
"SELECT COUNT(*) AS value FROM orders
WHERE date(placed_at, 'localtime') = date('now', 'localtime')"
)['value'],
'today_sales' => (int) $this->db->fetch(
"SELECT COALESCE(SUM(o.total_cents), 0) - COALESCE(SUM(r.refunded_cents), 0) AS value
FROM orders o
LEFT JOIN (
SELECT order_id, SUM(amount_cents) AS refunded_cents FROM refunds GROUP BY order_id
) r ON r.order_id = o.id
WHERE o.payment_status IN ('paid', 'partially_refunded', 'refunded')
AND date(o.placed_at, 'localtime') = date('now', 'localtime')"
)['value'],
'open_orders' => (int) $this->db->fetch(
"SELECT COUNT(*) AS value FROM orders WHERE status IN ('paid', 'preparing', 'ready')"
)['value'],
'abandoned_carts' => (int) $this->db->fetch(
"SELECT COUNT(*) AS value FROM carts
WHERE status = 'active' AND subtotal_cents > 0
AND last_seen_at < datetime('now', '-30 minutes')"
)['value'],
'today_visitors' => (int) $this->db->fetch(
"SELECT COUNT(DISTINCT session_id) AS value FROM visitor_events
WHERE date(created_at, 'localtime') = date('now', 'localtime')"
)['value'],
'newsletter_members' => (int) $this->db->fetch(
'SELECT COUNT(*) AS value FROM users WHERE newsletter_opt_in = 1 AND active = 1'
)['value'],
];
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace FatBottom\Services;
use FatBottom\Core\Config;
use FatBottom\Core\Database;
final class TwoFactorService
{
public function __construct(
private readonly Database $db,
private readonly Mailer $mailer,
private readonly Config $config
) {
}
public function issue(array $user, string $purpose = 'login'): ?string
{
$code = (string) random_int(100000, 999999);
$expiresAt = gmdate('Y-m-d H:i:s', time() + 600);
$this->db->execute(
'INSERT INTO login_codes (user_id, code_hash, purpose, expires_at) VALUES (?, ?, ?, ?)',
[(int) $user['id'], password_hash($code, PASSWORD_DEFAULT), $purpose, $expiresAt]
);
$name = htmlspecialchars((string) $user['full_name'], ENT_QUOTES, 'UTF-8');
$this->mailer->send(
(string) $user['email'],
'Your Fat Bottom Grille verification code',
"<p>Hi {$name},</p><p>Your verification code is <strong>{$code}</strong>.</p><p>It expires in 10 minutes.</p>"
);
return (bool) $this->config->get('mailgun.enabled', false) ? null : $code;
}
public function verify(int $userId, string $code, string $purpose = 'login'): bool
{
$record = $this->db->fetch(
'SELECT * FROM login_codes
WHERE user_id = ? AND purpose = ? AND used_at IS NULL AND expires_at > CURRENT_TIMESTAMP
ORDER BY id DESC LIMIT 1',
[$userId, $purpose]
);
if ($record === null || !password_verify($code, (string) $record['code_hash'])) {
return false;
}
$this->db->execute('UPDATE login_codes SET used_at = CURRENT_TIMESTAMP WHERE id = ?', [(int) $record['id']]);
return true;
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
define('BASE_PATH', dirname(__DIR__));
require_once BASE_PATH . '/app/helpers.php';
spl_autoload_register(static function (string $class): void {
$prefix = 'FatBottom\\';
if (!str_starts_with($class, $prefix)) {
return;
}
$relative = str_replace('\\', '/', substr($class, strlen($prefix)));
$path = BASE_PATH . '/app/' . $relative . '.php';
if (is_file($path)) {
require $path;
}
});
use FatBottom\Core\Config;
use FatBottom\Core\Database;
$config = new Config(
require BASE_PATH . '/config/defaults.php',
BASE_PATH . '/storage/config/settings.json'
);
if ((string) $config->get('app.key', '') === '') {
$config->set('app.key', bin2hex(random_bytes(32)));
$config->save();
}
date_default_timezone_set((string) $config->get('app.timezone', 'America/New_York'));
if (session_status() !== PHP_SESSION_ACTIVE) {
$sessionPath = BASE_PATH . '/storage/sessions';
if (!is_dir($sessionPath)) {
mkdir($sessionPath, 0775, true);
}
session_save_path($sessionPath);
session_name('fatbottom_session');
session_set_cookie_params([
'lifetime' => 60 * 60 * 24 * 14,
'path' => '/',
'secure' => !empty($_SERVER['HTTPS']),
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
}
$database = new Database($config);
$database->migrate();
return [
'config' => $config,
'db' => $database,
];
+56
View File
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
use FatBottom\Core\Security;
function e(mixed $value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
function money(int|string|null $cents): string
{
return '$' . number_format(((int) $cents) / 100, 2);
}
function csrf_field(): string
{
return '<input type="hidden" name="_token" value="' . e(Security::csrfToken()) . '">';
}
function active_path(string $path): string
{
$current = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
return $current === $path ? 'is-active' : '';
}
function selected(mixed $value, mixed $expected): string
{
return (string) $value === (string) $expected ? 'selected' : '';
}
function checked(mixed $value): string
{
return (bool) $value ? 'checked' : '';
}
function store_datetime(?string $value, string $format = 'M j, Y g:i A'): string
{
if ($value === null || $value === '') {
return '';
}
$date = new DateTimeImmutable($value, new DateTimeZone('UTC'));
return $date->setTimezone(new DateTimeZone(date_default_timezone_get()))->format($format);
}
function utc_datetime(?string $value): ?string
{
if ($value === null || trim($value) === '') {
return null;
}
$date = new DateTimeImmutable($value, new DateTimeZone(date_default_timezone_get()));
return $date->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s');
}