Compare commits

3 Commits

Author SHA1 Message Date
Ty Clifford b14f4c1796 - Implemented the Lakeside Orders demo rebrand:
New licensed lake, picnic, dining, server, burger, and food photography.
Rebuilt homepage gallery, logo, colors, copy, menu, specials, and 
exports.
Added dashboard-configurable branding in [settings.php (line 
18)](/Users/tyemeclifford/Documents/GH/fatbottomgrille/views/admin/settings.php:18).
Added a public attribution page and full [image 
credits](/Users/tyemeclifford/Documents/GH/fatbottomgrille/public/assets/images/CREDITS.md).
Migrated existing SQLite menu data and admin email to 
admin@lakesideorders.test.
Verified 51 PHP files, JSON/JavaScript, storefront pages, database 
migration, admin settings, and menu PDF. All passed.
2026-06-14 14:26:09 -04:00
Ty Clifford 03348cad79 - Implemented the full order lifecycle, secure customer tracker, automatic acceptance emails, configurable status templates, and guest/account CRM management.
Key areas: 
[OrderStatusService.php](/Users/tyemeclifford/Documents/GH/fatbottomgrille/app/Services/OrderStatusService.php), 
[AdminController.php](/Users/tyemeclifford/Documents/GH/fatbottomgrille/app/Controllers/AdminController.php), 
and 
[Database.php](/Users/tyemeclifford/Documents/GH/fatbottomgrille/app/Core/Database.php).
SQLite migration applied successfully. PHP/JS syntax, integration 
workflows, rendered pages, database integrity, and tracker authorization 
all passed. Invalid tracker tokens correctly return 403.
2026-06-14 13:35:31 -04:00
Ty Clifford 3417589a7c - Order 2026-06-11 17:04:54 -04:00
46 changed files with 1748 additions and 194 deletions
+6 -5
View File
@@ -1,9 +1,9 @@
# Fat Bottom Grille e-Commerce
# Lakeside Orders
_Fat Bottom Grille declined to review the website or proposal. Open sourced for all. The menu is not real. Orders do not work._
_Demo restaurant website. The location and menu are fictional._
A dependency-light online food ordering system for Fat Bottom Grille at
410 W Piedmont St in Keyser, West Virginia.
A dependency-light online ordering and customer-management demo for Lakeside
Orders, a fictional restaurant near the water.
## Included
@@ -22,6 +22,7 @@ A dependency-light online food ordering system for Fat Bottom Grille at
- Automatic newsletter unsubscribe links
- SQLite by default with an optional MySQL/MariaDB copy-and-switch tool
- JSON-backed business, ordering, Square, Mailgun, and database settings
- Public-domain and Creative Commons storefront photography with attribution
## Requirements
@@ -72,7 +73,7 @@ and assets automatically include that subdirectory.
## Initial Administrator
- Email: `admin@fatbottomgrille.com`
- Email: `admin@lakesideorders.test`
- Password: `ChangeMe123!`
Email 2FA is disabled by default for every account. It can be enabled from
+155 -17
View File
@@ -16,6 +16,7 @@ use FatBottom\Services\MenuPdfExporter;
use FatBottom\Services\MySqlMigrator;
use FatBottom\Services\PaymentGateway;
use FatBottom\Services\StatsService;
use FatBottom\Services\OrderStatusService;
final class AdminController extends BaseController
{
@@ -26,7 +27,8 @@ final class AdminController extends BaseController
private readonly Database $db,
private readonly StatsService $stats,
private readonly PaymentGateway $payments,
private readonly Mailer $mailer
private readonly Mailer $mailer,
private readonly OrderStatusService $orderStatuses
) {
parent::__construct($config, $auth, $cart);
}
@@ -132,6 +134,9 @@ final class AdminController extends BaseController
WHERE r.order_id = ? ORDER BY r.created_at DESC',
[$orderId]
),
'customer' => !empty($order['customer_id'])
? $this->db->fetch('SELECT * FROM customers WHERE id = ?', [(int) $order['customer_id']])
: null,
]));
}
@@ -141,24 +146,124 @@ final class AdminController extends BaseController
$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');
$note = Security::clean((string) ($_POST['status_note'] ?? ''));
try {
$result = $this->orderStatuses->update(
$orderId,
$status,
$note,
isset($_POST['customer_visible']),
isset($_POST['send_email']),
(int) $staff['id']
);
if ($result['email_error'] !== null) {
Http::flash('warning', 'Status updated, but the email could not be sent: ' . $result['email_error']);
} else {
Http::flash(
'success',
'Order status updated' . ($result['email_requested'] ? ' and the customer was emailed.' : '.')
);
}
} catch (\Throwable $error) {
Http::flash('error', $error->getMessage());
}
$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 customers(): void
{
$this->auth->requireStaff();
$query = Security::clean((string) ($_GET['q'] ?? ''));
$active = Security::clean((string) ($_GET['active'] ?? ''));
$where = ['1 = 1'];
$parameters = [];
if ($query !== '') {
$where[] = '(c.full_name LIKE ? OR c.email LIKE ? OR c.phone LIKE ?)';
$parameters = ['%' . $query . '%', '%' . $query . '%', '%' . $query . '%'];
}
if ($active === '1' || $active === '0') {
$where[] = 'c.active = ?';
$parameters[] = (int) $active;
}
View::render('admin/customers', $this->shared([
'title' => 'Customers',
'adminPage' => 'customers',
'customers' => $this->db->fetchAll(
'SELECT c.*, u.email AS account_email
FROM customers c LEFT JOIN users u ON u.id = c.user_id
WHERE ' . implode(' AND ', $where) . '
ORDER BY c.last_order_at DESC, c.full_name',
$parameters
),
'query' => $query,
'activeFilter' => $active,
]));
}
public function customerDetail(): void
{
$this->auth->requireStaff();
$customerId = (int) ($_GET['id'] ?? 0);
$customer = $this->db->fetch(
'SELECT c.*, u.email AS account_email, u.active AS account_active
FROM customers c LEFT JOIN users u ON u.id = c.user_id
WHERE c.id = ?',
[$customerId]
);
if ($customer === null) {
Http::abort(404, 'Customer not found.');
}
View::render('admin/customer-detail', $this->shared([
'title' => $customer['full_name'],
'adminPage' => 'customers',
'customer' => $customer,
'orders' => $this->db->fetchAll(
'SELECT * FROM orders WHERE customer_id = ? ORDER BY placed_at DESC',
[$customerId]
),
]));
}
public function saveCustomer(): void
{
Security::verifyCsrf();
$this->auth->requireStaff();
$customerId = (int) ($_POST['customer_id'] ?? 0);
$customer = $this->db->fetch('SELECT * FROM customers WHERE id = ?', [$customerId]);
if ($customer === null) {
Http::abort(404, 'Customer not found.');
}
$name = Security::clean((string) ($_POST['full_name'] ?? ''));
if ($name === '') {
Http::flash('error', 'Customer name is required.');
Http::redirect('/admin/customer?id=' . $customerId);
}
$this->db->execute(
'UPDATE customers SET full_name = ?, phone = ?, active = ?, internal_notes = ?,
updated_at = CURRENT_TIMESTAMP WHERE id = ?',
[
$name,
Security::clean((string) ($_POST['phone'] ?? '')),
isset($_POST['active']) ? 1 : 0,
Security::clean((string) ($_POST['internal_notes'] ?? '')),
$customerId,
]
);
Http::flash('success', 'Customer record saved.');
Http::redirect('/admin/customer?id=' . $customerId);
}
public function deleteCustomer(): void
{
Security::verifyCsrf();
$this->auth->requireStaff();
$customerId = (int) ($_POST['customer_id'] ?? 0);
$this->db->execute('UPDATE orders SET customer_id = NULL WHERE customer_id = ?', [$customerId]);
$this->db->execute('DELETE FROM customers WHERE id = ?', [$customerId]);
Http::flash('success', 'Customer record deleted. Historical orders were kept.');
Http::redirect('/admin/customers');
}
public function refundOrder(): void
{
Security::verifyCsrf();
@@ -462,6 +567,21 @@ final class AdminController extends BaseController
'business.postal_code',
'business.hours',
'business.announcement',
'branding.location_label',
'branding.meta_description',
'branding.hero_eyebrow',
'branding.hero_heading',
'branding.hero_emphasis',
'branding.hero_intro',
'branding.hero_image',
'branding.gallery_picnic_image',
'branding.gallery_patio_image',
'branding.gallery_service_image',
'branding.story_image',
'branding.story_eyebrow',
'branding.story_heading',
'branding.story_text',
'branding.footer_tagline',
'square.environment',
'square.application_id',
'square.location_id',
@@ -478,6 +598,22 @@ final class AdminController extends BaseController
$this->config->set($key, trim((string) $_POST[$group][$field]));
}
}
$emailTemplates = isset($_POST['order_emails']) && is_array($_POST['order_emails'])
? $_POST['order_emails']
: [];
foreach (OrderStatusService::STATUSES as $status) {
if (!isset($emailTemplates[$status]) || !is_array($emailTemplates[$status])) {
continue;
}
$this->config->set(
'order_emails.' . $status . '.subject',
trim((string) ($emailTemplates[$status]['subject'] ?? ''))
);
$this->config->set(
'order_emails.' . $status . '.body',
trim((string) ($emailTemplates[$status]['body'] ?? ''))
);
}
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])
@@ -566,7 +702,9 @@ final class AdminController extends BaseController
. '/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. '
. '<hr><p style="font-size:12px">You received this because you joined the '
. htmlspecialchars((string) $this->config->get('business.name'), ENT_QUOTES, 'UTF-8')
. ' news list. '
. '<a href="' . htmlspecialchars($unsubscribe, ENT_QUOTES, 'UTF-8') . '">Unsubscribe</a></p>';
try {
$this->mailer->send((string) $recipient['email'], $subject, $html);
@@ -603,7 +741,7 @@ final class AdminController extends BaseController
$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"');
header('Content-Disposition: attachment; filename="lakeside-orders-' . date('Y-m-d') . '.csv"');
$output = fopen('php://output', 'wb');
fputcsv($output, [
'Order', 'Placed', 'Customer', 'Email', 'Phone', 'Status', 'Payment',
+8
View File
@@ -154,6 +154,14 @@ final class AuthController extends BaseController
Http::flash('error', 'Your account could not be loaded after registration.');
Http::redirect('/login');
}
$this->db->execute(
'UPDATE customers SET user_id = ?, updated_at = CURRENT_TIMESTAMP WHERE email = ?',
[(int) $user['id'], $clean['email']]
);
$this->db->execute(
'UPDATE orders SET user_id = ? WHERE user_id IS NULL AND customer_email = ?',
[(int) $user['id'], $clean['email']]
);
$this->auth->login($user);
Http::flash('success', 'Welcome, ' . $user['full_name'] . '. Your account is ready.');
Http::redirect('/account');
+53 -11
View File
@@ -11,8 +11,8 @@ use FatBottom\Core\Http;
use FatBottom\Core\Security;
use FatBottom\Core\View;
use FatBottom\Services\CartService;
use FatBottom\Services\Mailer;
use FatBottom\Services\OrderService;
use FatBottom\Services\OrderStatusService;
final class StoreController extends BaseController
{
@@ -22,7 +22,7 @@ final class StoreController extends BaseController
CartService $cart,
private readonly Database $db,
private readonly OrderService $orders,
private readonly Mailer $mailer
private readonly OrderStatusService $orderStatuses
) {
parent::__construct($config, $auth, $cart);
}
@@ -48,7 +48,7 @@ final class StoreController extends BaseController
);
View::render('store/home', $this->shared([
'title' => 'Keyser comfort food, ready when you are',
'title' => 'Lakeside food, ready when you are',
'categories' => $categories,
'specials' => $specials,
'featured' => $featured,
@@ -66,6 +66,13 @@ final class StoreController extends BaseController
]));
}
public function credits(): void
{
View::render('store/credits', $this->shared([
'title' => 'Image Credits',
]));
}
public function addToCart(): void
{
Security::verifyCsrf();
@@ -169,13 +176,11 @@ final class StoreController extends BaseController
$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>'
);
try {
$this->orderStatuses->sendStatusEmail($order, 'pending');
} catch (\Throwable) {
Http::flash('warning', 'Your order was placed, but the confirmation email could not be sent.');
}
setcookie('fatbottom_cart', '', ['expires' => time() - 3600, 'path' => '/']);
$_SESSION['last_order_id'] = (int) $order['id'];
Http::redirect('/order/complete');
@@ -198,6 +203,44 @@ final class StoreController extends BaseController
'title' => 'Order Received',
'order' => $order,
'items' => $items,
'trackingUrl' => $this->orderStatuses->trackingUrl($order),
]));
}
public function orderTracker(): void
{
$orderNumber = Security::clean((string) ($_GET['order'] ?? ''));
$token = (string) ($_GET['token'] ?? '');
$order = $this->db->fetch('SELECT * FROM orders WHERE order_number = ?', [$orderNumber]);
if ($order === null) {
Http::abort(404, 'Order not found.');
}
$user = $this->auth->user();
$ownsOrder = $user !== null && (
(int) ($order['user_id'] ?? 0) === (int) $user['id']
|| strtolower((string) $order['customer_email']) === strtolower((string) $user['email'])
);
$validToken = $token !== ''
&& (string) $order['tracking_token'] !== ''
&& hash_equals((string) $order['tracking_token'], $token);
if (!$ownsOrder && !$validToken) {
Http::abort(403, 'This tracking link is invalid.');
}
View::render('store/order-tracker', $this->shared([
'title' => 'Track ' . $order['order_number'],
'order' => $order,
'events' => $this->db->fetchAll(
"SELECT * FROM order_events
WHERE order_id = ? AND (status <> '' OR customer_visible = 1)
ORDER BY created_at, id",
[(int) $order['id']]
),
'items' => $this->db->fetchAll(
'SELECT * FROM order_items WHERE order_id = ? ORDER BY id',
[(int) $order['id']]
),
]));
}
@@ -228,4 +271,3 @@ final class StoreController extends BaseController
));
}
}
+264 -35
View File
@@ -62,6 +62,11 @@ final class Database
}
$schema = <<<'SQL'
CREATE TABLE IF NOT EXISTS app_migrations (
id TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
@@ -173,16 +178,35 @@ CREATE TABLE IF NOT EXISTS cart_items (
FOREIGN KEY (menu_item_id) REFERENCES menu_items(id)
);
CREATE TABLE IF NOT EXISTS customers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
email TEXT NOT NULL UNIQUE,
full_name TEXT NOT NULL,
phone TEXT NOT NULL DEFAULT '',
active INTEGER NOT NULL DEFAULT 1,
internal_notes TEXT NOT NULL DEFAULT '',
order_count INTEGER NOT NULL DEFAULT 0,
lifetime_value_cents INTEGER NOT NULL DEFAULT 0,
first_order_at TEXT,
last_order_at TEXT,
created_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
);
CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_number TEXT NOT NULL UNIQUE,
user_id INTEGER,
customer_id INTEGER,
cart_id INTEGER,
tracking_token TEXT NOT NULL DEFAULT '',
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',
status TEXT NOT NULL DEFAULT 'pending',
payment_status TEXT NOT NULL DEFAULT 'paid',
payment_provider TEXT NOT NULL DEFAULT 'demo',
payment_reference TEXT NOT NULL DEFAULT '',
@@ -194,6 +218,7 @@ CREATE TABLE IF NOT EXISTS orders (
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 (customer_id) REFERENCES customers(id) ON DELETE SET NULL,
FOREIGN KEY (cart_id) REFERENCES carts(id) ON DELETE SET NULL
);
@@ -216,6 +241,10 @@ CREATE TABLE IF NOT EXISTS order_events (
user_id INTEGER,
event_type TEXT NOT NULL,
details TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
customer_visible INTEGER NOT NULL DEFAULT 0,
email_sent INTEGER NOT NULL DEFAULT 0,
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
@@ -266,6 +295,23 @@ SQL;
$this->pdo->exec($schema);
$this->ensureSqliteColumn('users', 'two_factor_enabled', 'INTEGER NOT NULL DEFAULT 0');
$this->ensureSqliteColumn('orders', 'customer_id', 'INTEGER');
$this->ensureSqliteColumn('orders', 'tracking_token', "TEXT NOT NULL DEFAULT ''");
$this->ensureSqliteColumn('order_events', 'status', "TEXT NOT NULL DEFAULT ''");
$this->ensureSqliteColumn('order_events', 'note', "TEXT NOT NULL DEFAULT ''");
$this->ensureSqliteColumn('order_events', 'customer_visible', 'INTEGER NOT NULL DEFAULT 0');
$this->ensureSqliteColumn('order_events', 'email_sent', 'INTEGER NOT NULL DEFAULT 0');
$this->backfillOrderTrackingTokens();
$this->runMigration('20260614_order_status_crm', function (): void {
$this->normalizeLegacyOrderStatuses();
$this->syncCustomersFromOrders();
});
$this->runMigration('20260614_lakeside_orders_demo', function (): void {
$this->applyLakesideDemoBranding();
});
$this->pdo->exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_orders_tracking_token ON orders(tracking_token)');
$this->pdo->exec('CREATE INDEX IF NOT EXISTS idx_orders_customer ON orders(customer_id, placed_at)');
$this->pdo->exec('CREATE INDEX IF NOT EXISTS idx_customers_active ON customers(active, last_order_at)');
$this->seed();
}
@@ -281,6 +327,188 @@ SQL;
$this->pdo->exec("ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}");
}
private function runMigration(string $id, callable $migration): void
{
$statement = $this->pdo->prepare('SELECT id FROM app_migrations WHERE id = ?');
$statement->execute([$id]);
if ($statement->fetchColumn() !== false) {
return;
}
$this->pdo->beginTransaction();
try {
$migration();
$this->pdo->prepare('INSERT INTO app_migrations (id) VALUES (?)')->execute([$id]);
$this->pdo->commit();
} catch (\Throwable $error) {
$this->pdo->rollBack();
throw $error;
}
}
private function backfillOrderTrackingTokens(): void
{
$orders = $this->pdo->query(
"SELECT id FROM orders WHERE tracking_token IS NULL OR tracking_token = ''"
)->fetchAll();
$update = $this->pdo->prepare('UPDATE orders SET tracking_token = ? WHERE id = ?');
foreach ($orders as $order) {
$update->execute([bin2hex(random_bytes(24)), (int) $order['id']]);
}
}
private function normalizeLegacyOrderStatuses(): void
{
$this->pdo->exec("UPDATE orders SET status = 'pending' WHERE status = 'paid'");
$this->pdo->exec("UPDATE orders SET status = 'processing' WHERE status = 'preparing'");
$this->pdo->exec("UPDATE orders SET status = 'declined' WHERE status = 'cancelled'");
}
private function syncCustomersFromOrders(): void
{
$orders = $this->pdo->query(
'SELECT id, user_id, customer_name, customer_email, customer_phone
FROM orders ORDER BY placed_at, id'
)->fetchAll();
$findCustomer = $this->pdo->prepare('SELECT id, user_id FROM customers WHERE email = ?');
$findUser = $this->pdo->prepare('SELECT id FROM users WHERE email = ?');
$insert = $this->pdo->prepare(
'INSERT INTO customers (user_id, email, full_name, phone) VALUES (?, ?, ?, ?)'
);
$update = $this->pdo->prepare(
'UPDATE customers SET user_id = ?, full_name = ?, phone = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
);
$linkOrder = $this->pdo->prepare('UPDATE orders SET customer_id = ? WHERE id = ?');
$customerIds = [];
foreach ($orders as $order) {
$email = strtolower(trim((string) $order['customer_email']));
if ($email === '') {
continue;
}
$findCustomer->execute([$email]);
$customer = $findCustomer->fetch();
$userId = (int) ($order['user_id'] ?? 0) ?: null;
if ($userId === null) {
$findUser->execute([$email]);
$userId = (int) ($findUser->fetchColumn() ?: 0) ?: null;
}
if ($customer === false) {
$insert->execute([$userId, $email, $order['customer_name'], $order['customer_phone']]);
$customerId = (int) $this->pdo->lastInsertId();
} else {
$customerId = (int) $customer['id'];
$update->execute([
$userId ?: ((int) ($customer['user_id'] ?? 0) ?: null),
$order['customer_name'],
$order['customer_phone'],
$customerId,
]);
}
$linkOrder->execute([$customerId, (int) $order['id']]);
$customerIds[$customerId] = true;
}
$refresh = $this->pdo->prepare(
'UPDATE customers SET
order_count = (SELECT COUNT(*) FROM orders WHERE customer_id = ?),
lifetime_value_cents = COALESCE((SELECT SUM(total_cents) FROM orders WHERE customer_id = ?), 0),
first_order_at = (SELECT MIN(placed_at) FROM orders WHERE customer_id = ?),
last_order_at = (SELECT MAX(placed_at) FROM orders WHERE customer_id = ?),
updated_at = CURRENT_TIMESTAMP
WHERE id = ?'
);
foreach (array_keys($customerIds) as $customerId) {
$refresh->execute([$customerId, $customerId, $customerId, $customerId, $customerId]);
}
}
private function applyLakesideDemoBranding(): void
{
$categories = [
[1, 'Dockside Burgers', 'dockside-burgers', 'Flame-grilled favorites stacked for hungry lake days.'],
[2, 'Picnic Baskets', 'picnic-baskets', 'Wraps, bowls, and handhelds made to travel well.'],
[3, 'Lakehouse Plates', 'lakehouse-plates', 'Relaxed comfort food for a proper table by the water.'],
[4, 'Boardwalk Sides', 'boardwalk-sides', 'Shareable extras for the dock, blanket, or patio.'],
[5, 'Cold Drinks', 'cold-drinks', 'Fresh pours for sunny afternoons.'],
];
$categoryStatement = $this->pdo->prepare(
'UPDATE menu_categories
SET name = ?, slug = ?, description = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?'
);
foreach ($categories as [$id, $name, $slug, $description]) {
$categoryStatement->execute([$name, $slug, $description, $id]);
}
$items = [
[1, 1, 'Lakeside Double', 'lakeside-double', 'Two grilled patties, cheddar, lettuce, tomato, pickles, and dock sauce.', 1299, '/assets/images/lakeside-burger.jpg', '["House favorite"]', 1],
[2, 1, 'Dockmaster Bacon Burger', 'dockmaster-bacon-burger', 'Two patties, smoked bacon, cheddar, crisp lettuce, tomato, and onion.', 1399, '/assets/images/lakeside-burger.jpg', '[]', 1],
[3, 1, 'Sunset Mushroom Melt', 'sunset-mushroom-melt', 'Grilled beef, Swiss, mushrooms, caramelized onions, and peppercorn mayo.', 1299, '/assets/images/lakeside-burger.jpg', '[]', 0],
[4, 2, 'Marina Chicken Wrap', 'marina-chicken-wrap', 'Chicken, spinach, tomato, cucumber, and creamy herb dressing in a soft wrap.', 1199, '/assets/images/picnic-wrap.jpg', '["Picnic pick"]', 1],
[5, 2, 'Shoreline Falafel Bowl', 'shoreline-falafel-bowl', 'Falafel, seasoned rice, bright slaw, peppers, herbs, lemon, and green tahini.', 1249, '/assets/images/shoreline-bowl.jpg', '["Vegetarian"]', 1],
[6, 3, 'Lakehouse Chicken Plate', 'lakehouse-chicken-plate', 'Seasoned chicken legs with a creamy picnic side and rotating vegetables.', 1499, '/assets/images/lakehouse-chicken.jpg', '[]', 1],
[7, 3, 'Picnic Club Wrap', 'picnic-club-wrap', 'Sliced chicken, greens, tomato, smoky bacon, and herb dressing.', 1299, '/assets/images/picnic-wrap.jpg', '[]', 0],
[8, 4, 'Boardwalk Fries', 'boardwalk-fries', 'Golden fries finished with flaky salt and your choice of dipping sauce.', 599, '/assets/images/boardwalk-fries.jpg', '["Vegetarian"]', 1],
[9, 4, 'Garden Side Bowl', 'garden-side-bowl', 'Rice, chopped vegetables, herbs, lemon, and creamy green dressing.', 649, '/assets/images/shoreline-bowl.jpg', '["Vegetarian"]', 0],
[10, 5, 'Lemon Iced Tea', 'lemon-iced-tea', 'Fresh-brewed iced tea served with lemon.', 299, '/assets/images/lemon-iced-tea.jpg', '[]', 0],
[11, 5, 'Lakeside Lemonade', 'lakeside-lemonade', 'Bright house lemonade poured over ice.', 329, '/assets/images/lemon-iced-tea.jpg', '[]', 0],
];
$itemStatement = $this->pdo->prepare(
'UPDATE menu_items
SET category_id = ?, name = ?, slug = ?, description = ?, price_cents = ?,
image_url = ?, dietary_tags = ?, featured = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?'
);
foreach ($items as [$id, $categoryId, $name, $slug, $description, $price, $image, $tags, $featured]) {
$itemStatement->execute([
$categoryId,
$name,
$slug,
$description,
$price,
$image,
$tags,
$featured,
$id,
]);
}
$specialStatement = $this->pdo->prepare(
'UPDATE specials
SET menu_item_id = ?, title = ?, description = ?, badge = ?, price_cents = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?'
);
$specialStatement->execute([
1,
'Sunset Burger Pick',
'The Lakeside Double at its easygoing online demo price.',
'Dock Deal',
1299,
1,
]);
$specialStatement->execute([
8,
'Boardwalk Break',
'A hot order of Boardwalk Fries for the table or picnic blanket.',
'Picnic Pick',
599,
2,
]);
$this->pdo->exec("UPDATE tax_rates SET name = 'Demo State Sales Tax' WHERE id = 1");
$this->pdo->exec("UPDATE tax_rates SET name = 'Lakeview Demo Tax' WHERE id = 2");
$this->pdo->exec(
"UPDATE users SET email = 'admin@lakesideorders.test'
WHERE email = 'admin@fatbottomgrille.com'
AND NOT EXISTS (
SELECT 1 FROM users AS existing
WHERE existing.email = 'admin@lakesideorders.test'
)"
);
}
private function seed(): void
{
$categoryCount = (int) $this->pdo->query('SELECT COUNT(*) FROM menu_categories')->fetchColumn();
@@ -291,11 +519,11 @@ SQL;
$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],
['Dockside Burgers', 'dockside-burgers', 'Flame-grilled favorites stacked for hungry lake days.', 10],
['Picnic Baskets', 'picnic-baskets', 'Wraps, bowls, and handhelds made to travel well.', 20],
['Lakehouse Plates', 'lakehouse-plates', 'Relaxed comfort food for a proper table by the water.', 30],
['Boardwalk Sides', 'boardwalk-sides', 'Shareable extras for the dock, blanket, or patio.', 40],
['Cold Drinks', 'cold-drinks', 'Fresh pours for sunny afternoons.', 50],
];
$categoryStatement = $this->pdo->prepare(
'INSERT INTO menu_categories (name, slug, description, display_order) VALUES (?, ?, ?, ?)'
@@ -310,22 +538,22 @@ SQL;
}
$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, '[]'],
['dockside-burgers', 'Lakeside Double', 'lakeside-double', 'Two grilled patties, cheddar, lettuce, tomato, pickles, and dock sauce.', 1299, '/assets/images/lakeside-burger.jpg', 1, '["House favorite"]'],
['dockside-burgers', 'Dockmaster Bacon Burger', 'dockmaster-bacon-burger', 'Two patties, smoked bacon, cheddar, crisp lettuce, tomato, and onion.', 1399, '/assets/images/lakeside-burger.jpg', 1, '[]'],
['dockside-burgers', 'Sunset Mushroom Melt', 'sunset-mushroom-melt', 'Grilled beef, Swiss, mushrooms, caramelized onions, and peppercorn mayo.', 1299, '/assets/images/lakeside-burger.jpg', 0, '[]'],
['picnic-baskets', 'Marina Chicken Wrap', 'marina-chicken-wrap', 'Chicken, spinach, tomato, cucumber, and creamy herb dressing in a soft wrap.', 1199, '/assets/images/picnic-wrap.jpg', 1, '["Picnic pick"]'],
['picnic-baskets', 'Shoreline Falafel Bowl', 'shoreline-falafel-bowl', 'Falafel, seasoned rice, bright slaw, peppers, herbs, lemon, and green tahini.', 1249, '/assets/images/shoreline-bowl.jpg', 1, '["Vegetarian"]'],
['lakehouse-plates', 'Lakehouse Chicken Plate', 'lakehouse-chicken-plate', 'Seasoned chicken legs with a creamy picnic side and rotating vegetables.', 1499, '/assets/images/lakehouse-chicken.jpg', 1, '[]'],
['lakehouse-plates', 'Picnic Club Wrap', 'picnic-club-wrap', 'Sliced chicken, greens, tomato, smoky bacon, and herb dressing.', 1299, '/assets/images/picnic-wrap.jpg', 0, '[]'],
['boardwalk-sides', 'Boardwalk Fries', 'boardwalk-fries', 'Golden fries finished with flaky salt and your choice of dipping sauce.', 599, '/assets/images/boardwalk-fries.jpg', 1, '["Vegetarian"]'],
['boardwalk-sides', 'Garden Side Bowl', 'garden-side-bowl', 'Rice, chopped vegetables, herbs, lemon, and creamy green dressing.', 649, '/assets/images/shoreline-bowl.jpg', 0, '["Vegetarian"]'],
['cold-drinks', 'Lemon Iced Tea', 'lemon-iced-tea', 'Fresh-brewed iced tea served with lemon.', 299, '/assets/images/lemon-iced-tea.jpg', 0, '[]'],
['cold-drinks', 'Lakeside Lemonade', 'lakeside-lemonade', 'Bright house lemonade poured over ice.', 329, '/assets/images/lemon-iced-tea.jpg', 0, '[]'],
];
$itemStatement = $this->pdo->prepare(
'INSERT INTO menu_items
(category_id, name, slug, description, price_cents, featured, dietary_tags)
VALUES (?, ?, ?, ?, ?, ?, ?)'
(category_id, name, slug, description, price_cents, image_url, featured, dietary_tags)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
);
foreach ($items as $item) {
$itemStatement->execute([
@@ -336,32 +564,33 @@ SQL;
$item[4],
$item[5],
$item[6],
$item[7],
]);
}
$fatBottomId = (int) $this->pdo->query(
"SELECT id FROM menu_items WHERE slug = 'the-fat-bottom'"
$lakesideDoubleId = (int) $this->pdo->query(
"SELECT id FROM menu_items WHERE slug = 'lakeside-double'"
)->fetchColumn();
$loadedFriesId = (int) $this->pdo->query(
"SELECT id FROM menu_items WHERE slug = 'loaded-fries'"
$boardwalkFriesId = (int) $this->pdo->query(
"SELECT id FROM menu_items WHERE slug = 'boardwalk-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,
$lakesideDoubleId,
'Sunset Burger Pick',
'The Lakeside Double at its easygoing online demo price.',
'Dock Deal',
1299,
10,
]);
$specialStatement->execute([
$loadedFriesId,
'Loaded-Up Lunch',
'Loaded fries are $1 off weekdays from 11 to 2.',
'Weekday Deal',
$boardwalkFriesId,
'Boardwalk Break',
'A hot order of Boardwalk Fries for the table or picnic blanket.',
'Picnic Pick',
599,
20,
]);
@@ -369,8 +598,8 @@ SQL;
$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]);
$taxStatement->execute(['Demo State Sales Tax', 'state', 600]);
$taxStatement->execute(['Lakeview Demo Tax', 'city', 100]);
$adminStatement = $this->pdo->prepare(
'INSERT INTO users
@@ -379,7 +608,7 @@ SQL;
VALUES (?, ?, ?, ?, ?, ?, 0, CURRENT_TIMESTAMP)'
);
$adminStatement->execute([
'admin@fatbottomgrille.com',
'admin@lakesideorders.test',
password_hash('ChangeMe123!', PASSWORD_DEFAULT),
'Store Administrator',
'',
+57
View File
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace FatBottom\Services;
use FatBottom\Core\Database;
final class CustomerService
{
public function __construct(private readonly Database $db)
{
}
public function capture(array $customer, ?int $userId): int
{
$email = strtolower(trim((string) $customer['customer_email']));
$record = $this->db->fetch('SELECT * FROM customers WHERE email = ?', [$email]);
if ($record === null) {
$this->db->execute(
'INSERT INTO customers (user_id, email, full_name, phone) VALUES (?, ?, ?, ?)',
[
$userId,
$email,
(string) $customer['customer_name'],
(string) $customer['customer_phone'],
]
);
return (int) $this->db->pdo()->lastInsertId();
}
$this->db->execute(
'UPDATE customers SET user_id = ?, full_name = ?, phone = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
[
$userId ?: ((int) ($record['user_id'] ?? 0) ?: null),
(string) $customer['customer_name'],
(string) $customer['customer_phone'],
(int) $record['id'],
]
);
return (int) $record['id'];
}
public function refresh(int $customerId): void
{
$this->db->execute(
'UPDATE customers SET
order_count = (SELECT COUNT(*) FROM orders WHERE customer_id = ?),
lifetime_value_cents = COALESCE((SELECT SUM(total_cents) FROM orders WHERE customer_id = ?), 0),
first_order_at = (SELECT MIN(placed_at) FROM orders WHERE customer_id = ?),
last_order_at = (SELECT MAX(placed_at) FROM orders WHERE customer_id = ?),
updated_at = CURRENT_TIMESTAMP
WHERE id = ?',
[$customerId, $customerId, $customerId, $customerId, $customerId]
);
}
}
+1 -2
View File
@@ -32,7 +32,7 @@ final class Mailer
$from = sprintf(
'%s <%s>',
$this->config->get('mailgun.from_name', 'Fat Bottom Grille'),
$this->config->get('mailgun.from_name', 'Lakeside Orders'),
$this->config->get('mailgun.from_email')
);
@@ -77,4 +77,3 @@ final class Mailer
file_put_contents($directory . '/mail.log', $line, FILE_APPEND | LOCK_EX);
}
}
+2 -2
View File
@@ -30,7 +30,7 @@ final class MenuPdfExporter
$pdf = $this->buildPdf($pages);
header('Content-Type: application/pdf');
header('Content-Disposition: attachment; filename="fat-bottom-grille-menu.pdf"');
header('Content-Disposition: attachment; filename="lakeside-orders-menu.pdf"');
header('Content-Length: ' . strlen($pdf));
echo $pdf;
exit;
@@ -52,7 +52,7 @@ final class MenuPdfExporter
$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.91 0.92 0.90 rg\nBT /F1 10 Tf 46 718 Td (LAKESIDE PICKUP | 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) {
+21 -6
View File
@@ -11,7 +11,8 @@ final class OrderService
{
public function __construct(
private readonly Database $db,
private readonly PaymentGateway $payments
private readonly PaymentGateway $payments,
private readonly CustomerService $customers
) {
}
@@ -44,22 +45,27 @@ final class OrderService
$pdo = $this->db->pdo();
$pdo->beginTransaction();
try {
$customerId = $this->customers->capture($customer, $userId);
$trackingToken = bin2hex(random_bytes(24));
$statement = $pdo->prepare(
'INSERT INTO orders
(order_number, user_id, cart_id, customer_name, customer_email, customer_phone,
(order_number, user_id, customer_id, cart_id, tracking_token,
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
$statement->execute([
$orderNumber,
$userId,
$customerId,
(int) $cart['id'],
$trackingToken,
$customer['customer_name'],
$customer['customer_email'],
$customer['customer_phone'],
'pickup',
'paid',
'pending',
'paid',
$payment['provider'],
$payment['reference'],
@@ -89,11 +95,20 @@ final class OrderService
}
$pdo->prepare(
'INSERT INTO order_events (order_id, user_id, event_type, details) VALUES (?, ?, ?, ?)'
)->execute([$orderId, $userId, 'order_placed', 'Online pickup order paid successfully.']);
'INSERT INTO order_events
(order_id, user_id, event_type, details, status, customer_visible)
VALUES (?, ?, ?, ?, ?, 1)'
)->execute([
$orderId,
$userId,
'order_placed',
'Online pickup order paid successfully and is awaiting staff acceptance.',
'pending',
]);
$pdo->prepare(
"UPDATE carts SET status = 'converted', converted_at = CURRENT_TIMESTAMP WHERE id = ?"
)->execute([(int) $cart['id']]);
$this->customers->refresh($customerId);
$pdo->commit();
} catch (\Throwable $error) {
$pdo->rollBack();
+121
View File
@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace FatBottom\Services;
use FatBottom\Core\Config;
use FatBottom\Core\Database;
use RuntimeException;
final class OrderStatusService
{
public const STATUSES = ['pending', 'accepted', 'declined', 'processing', 'ready', 'completed'];
public function __construct(
private readonly Config $config,
private readonly Database $db,
private readonly Mailer $mailer
) {
}
public function update(
int $orderId,
string $status,
string $note,
bool $customerVisible,
bool $sendEmail,
int $staffUserId
): array {
if (!in_array($status, self::STATUSES, true) || $status === 'pending') {
throw new RuntimeException('Choose a valid order status.');
}
$order = $this->db->fetch('SELECT * FROM orders WHERE id = ?', [$orderId]);
if ($order === null) {
throw new RuntimeException('Order not found.');
}
$sendEmail = $sendEmail || $status === 'accepted';
$pdo = $this->db->pdo();
$pdo->beginTransaction();
try {
$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, status, note, customer_visible, email_sent)
VALUES (?, ?, ?, ?, ?, ?, ?, 0)',
[
$orderId,
$staffUserId,
'status_changed',
'Status changed to ' . order_status_label($status) . ($note !== '' ? '. ' . $note : '.'),
$status,
$note,
$customerVisible && $note !== '' ? 1 : 0,
]
);
$eventId = (int) $pdo->lastInsertId();
$pdo->commit();
} catch (\Throwable $error) {
$pdo->rollBack();
throw $error;
}
$emailError = null;
if ($sendEmail) {
$order['status'] = $status;
try {
$this->sendStatusEmail($order, $status, $customerVisible ? $note : '');
$this->db->execute('UPDATE order_events SET email_sent = 1 WHERE id = ?', [$eventId]);
} catch (\Throwable $error) {
$emailError = $error->getMessage();
}
}
return ['email_requested' => $sendEmail, 'email_error' => $emailError];
}
public function sendStatusEmail(array $order, string $status, string $visibleNote = ''): void
{
$subjectTemplate = (string) $this->config->get(
'order_emails.' . $status . '.subject',
'{{business_name}} order {{order_number}}: {{status_label}}'
);
$bodyTemplate = (string) $this->config->get(
'order_emails.' . $status . '.body',
"Hi {{customer_name}},\n\nYour order {{order_number}} is now {{status_label}}.\n\n{{note}}\n\n{{tracking_url}}"
);
$values = [
'{{business_name}}' => (string) $this->config->get('business.name', 'Lakeside Orders'),
'{{customer_name}}' => (string) $order['customer_name'],
'{{order_number}}' => (string) $order['order_number'],
'{{status_label}}' => order_status_label($status),
'{{note}}' => $visibleNote,
'{{rejection_reason}}' => $status === 'declined' ? $visibleNote : '',
'{{tracking_url}}' => $this->trackingUrl($order),
];
$subject = trim(strip_tags(strtr($subjectTemplate, $values)));
$html = nl2br(strtr(htmlspecialchars($bodyTemplate, ENT_QUOTES, 'UTF-8'), [
'{{business_name}}' => e($values['{{business_name}}']),
'{{customer_name}}' => e($values['{{customer_name}}']),
'{{order_number}}' => e($values['{{order_number}}']),
'{{status_label}}' => e($values['{{status_label}}']),
'{{note}}' => e($values['{{note}}']),
'{{rejection_reason}}' => e($values['{{rejection_reason}}']),
'{{tracking_url}}' => '<a href="' . e($values['{{tracking_url}}']) . '">View your order tracker</a>',
]));
$this->mailer->send((string) $order['customer_email'], $subject, '<p>' . $html . '</p>');
}
public function trackingUrl(array $order): string
{
return rtrim((string) $this->config->get('app.url'), '/')
. '/order/track?order=' . rawurlencode((string) $order['order_number'])
. '&token=' . rawurlencode((string) $order['tracking_token']);
}
}
+2 -2
View File
@@ -49,7 +49,7 @@ final class StatsService
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')"
"SELECT COUNT(*) AS value FROM orders WHERE status IN ('pending', 'accepted', 'processing', 'ready')"
)['value'],
'abandoned_carts' => (int) $this->db->fetch(
"SELECT COUNT(*) AS value FROM carts
@@ -81,7 +81,7 @@ final class StatsService
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')"
"SELECT COUNT(*) AS value FROM orders WHERE status IN ('pending', 'accepted', 'processing', 'ready')"
)['value'],
'abandoned_carts' => (int) $this->db->fetch(
"SELECT COUNT(*) AS value FROM carts
+2 -1
View File
@@ -27,9 +27,10 @@ final class TwoFactorService
);
$name = htmlspecialchars((string) $user['full_name'], ENT_QUOTES, 'UTF-8');
$businessName = (string) $this->config->get('business.name', 'Lakeside Orders');
$this->mailer->send(
(string) $user['email'],
'Your Fat Bottom Grille verification code',
"Your {$businessName} verification code",
"<p>Hi {$name},</p><p>Your verification code is <strong>{$code}</strong>.</p><p>It expires in 10 minutes.</p>"
);
+15
View File
@@ -45,6 +45,21 @@ function checked(mixed $value): string
return (bool) $value ? 'checked' : '';
}
function order_status_label(string $status): string
{
return [
'pending' => 'Awaiting acceptance',
'paid' => 'Awaiting acceptance',
'accepted' => 'Accepted',
'declined' => 'Declined',
'processing' => 'Processing / Active',
'preparing' => 'Processing / Active',
'ready' => 'Ready for pickup',
'completed' => 'Complete',
'cancelled' => 'Declined',
][$status] ?? ucwords(str_replace('_', ' ', $status));
}
function store_datetime(?string $value, string $format = 'M j, Y g:i A'): string
{
if ($value === null || $value === '') {
+54 -11
View File
@@ -2,22 +2,39 @@
return [
'app' => [
'name' => 'Fat Bottom Grille',
'name' => 'Lakeside Orders',
'url' => 'http://localhost:8080',
'key' => '',
'timezone' => 'America/New_York',
'environment' => 'development',
],
'business' => [
'name' => 'Fat Bottom Grille',
'name' => 'Lakeside Orders',
'phone' => '',
'email' => '',
'street' => '410 W Piedmont St',
'city' => 'Keyser',
'state' => 'WV',
'postal_code' => '26726',
'hours' => 'Hours coming soon',
'announcement' => 'Big flavor, made right here in Keyser.',
'street' => '12 Marina Way',
'city' => 'Lakeview',
'state' => 'MI',
'postal_code' => '00000',
'hours' => 'Demo hours: daily 11 AM-8 PM',
'announcement' => 'Demo ordering for easy days by the water.',
],
'branding' => [
'location_label' => 'Lakeview Commons',
'meta_description' => 'Order dockside burgers, picnic-ready wraps, bowls, sides, and cold drinks from Lakeside Orders.',
'hero_eyebrow' => 'Lake days. Easy pickup.',
'hero_heading' => 'Order by the water.',
'hero_emphasis' => 'Picnic anywhere.',
'hero_intro' => 'Burgers, picnic baskets, fresh bowls, and lakehouse favorites made for dock days, patio tables, and sunset suppers.',
'hero_image' => 'assets/images/lakeside-burger.jpg',
'gallery_picnic_image' => 'assets/images/lakeside-picnic.jpg',
'gallery_patio_image' => 'assets/images/patio-dining.jpg',
'gallery_service_image' => 'assets/images/garden-dining.jpg',
'story_image' => 'assets/images/lakeside-sunset.jpg',
'story_eyebrow' => 'Made for lake days',
'story_heading' => 'Good food travels well.',
'story_text' => 'Lakeside Orders is a demo restaurant experience built around relaxed pickup, picnic-ready meals, and welcoming tables near the water.',
'footer_tagline' => 'Dockside burgers, picnic-ready favorites, and a table near the water.',
],
'ordering' => [
'accepting_orders' => true,
@@ -35,14 +52,40 @@ return [
'enabled' => false,
'domain' => '',
'api_key' => '',
'from_name' => 'Fat Bottom Grille',
'from_email' => 'orders@fatbottomgrille.com',
'from_name' => 'Lakeside Orders',
'from_email' => 'orders@lakesideorders.test',
],
'order_emails' => [
'pending' => [
'subject' => 'We received order {{order_number}}',
'body' => "Hi {{customer_name}},\n\nWe received your paid order {{order_number}}. It is waiting for staff acceptance.\n\n{{tracking_url}}",
],
'accepted' => [
'subject' => 'Order {{order_number}} was accepted',
'body' => "Hi {{customer_name}},\n\nYour order {{order_number}} has been accepted.\n\n{{note}}\n\n{{tracking_url}}",
],
'declined' => [
'subject' => 'Update for order {{order_number}}',
'body' => "Hi {{customer_name}},\n\nWe are sorry, but your order {{order_number}} was declined.\n\n{{rejection_reason}}\n\n{{tracking_url}}",
],
'processing' => [
'subject' => 'Order {{order_number}} is being prepared',
'body' => "Hi {{customer_name}},\n\nYour order {{order_number}} is now processing and active in the kitchen.\n\n{{note}}\n\n{{tracking_url}}",
],
'ready' => [
'subject' => 'Order {{order_number}} is ready for pickup',
'body' => "Hi {{customer_name}},\n\nYour order {{order_number}} is ready for pickup.\n\n{{note}}\n\n{{tracking_url}}",
],
'completed' => [
'subject' => 'Order {{order_number}} is complete',
'body' => "Hi {{customer_name}},\n\nYour order {{order_number}} has been marked complete.\n\n{{note}}\n\n{{tracking_url}}",
],
],
'database' => [
'driver' => 'sqlite',
'mysql_host' => '127.0.0.1',
'mysql_port' => 3306,
'mysql_database' => 'fatbottomgrille',
'mysql_database' => 'lakesideorders',
'mysql_username' => '',
'mysql_password' => '',
],
+519 -43
View File
@@ -3,8 +3,8 @@
--ink-soft: #0e1d2b;
--navy: #12283b;
--navy-light: #1c3a50;
--gold: #c8973b;
--gold-light: #e1b75e;
--gold: #35d6c1;
--gold-light: #8af5e6;
--cream: #f3eedf;
--paper: #faf7ef;
--white: #ffffff;
@@ -41,9 +41,10 @@ body {
body.store-body {
background-image:
radial-gradient(circle at 0 0, rgba(200, 151, 59, 0.08), transparent 28%),
radial-gradient(circle at 0 0, rgba(53, 214, 193, 0.1), transparent 28%),
radial-gradient(circle at 92% 12%, rgba(114, 92, 255, 0.09), transparent 24%),
linear-gradient(rgba(255, 255, 255, 0.018) 1px, transparent 1px);
background-size: auto, 100% 22px;
background-size: auto, auto, 100% 22px;
}
a {
@@ -409,8 +410,8 @@ h3 {
overflow: hidden;
color: var(--cream);
background:
linear-gradient(90deg, rgba(5, 12, 18, 0.99) 0%, rgba(8, 20, 30, 0.96) 48%, rgba(8, 18, 28, 0.76) 100%),
url("images/reuben-fries.jpg") center 48% / cover no-repeat;
linear-gradient(90deg, rgba(5, 12, 18, 0.99) 0%, rgba(8, 20, 30, 0.95) 46%, rgba(8, 18, 28, 0.48) 100%),
url("images/lakeside-sunset.jpg") center 56% / cover no-repeat;
}
.hero::after {
@@ -708,25 +709,25 @@ h3 {
.special-card:nth-child(4n + 1) {
background-image:
linear-gradient(112deg, rgba(5, 12, 18, 0.98), rgba(8, 18, 28, 0.82)),
url("images/quesadilla.jpg");
url("images/lakeside-burger.jpg");
}
.special-card:nth-child(4n + 2) {
background-image:
linear-gradient(112deg, rgba(5, 12, 18, 0.98), rgba(8, 18, 28, 0.8)),
url("images/sausage-kale-soup.jpg");
url("images/boardwalk-fries.jpg");
}
.special-card:nth-child(4n + 3) {
background-image:
linear-gradient(112deg, rgba(5, 12, 18, 0.98), rgba(8, 18, 28, 0.78)),
url("images/grilled-chicken-salad.jpg");
url("images/shoreline-bowl.jpg");
}
.special-card:nth-child(4n + 4) {
background-image:
linear-gradient(112deg, rgba(5, 12, 18, 0.98), rgba(8, 18, 28, 0.8)),
url("images/curry-rice.jpg");
url("images/picnic-wrap.jpg");
}
.special-card::after {
@@ -832,43 +833,43 @@ h3 {
.menu-card:nth-child(7n + 1) .menu-card-art:not(.has-image) {
background-image:
linear-gradient(rgba(5, 12, 18, 0.62), rgba(5, 12, 18, 0.86)),
url("images/reuben-fries.jpg");
url("images/lakeside-burger.jpg");
}
.menu-card:nth-child(7n + 2) .menu-card-art:not(.has-image) {
background-image:
linear-gradient(rgba(5, 12, 18, 0.62), rgba(5, 12, 18, 0.86)),
url("images/chicken-sandwich-fries.jpg");
url("images/picnic-wrap.jpg");
}
.menu-card:nth-child(7n + 3) .menu-card-art:not(.has-image) {
background-image:
linear-gradient(rgba(5, 12, 18, 0.62), rgba(5, 12, 18, 0.86)),
url("images/quesadilla.jpg");
url("images/shoreline-bowl.jpg");
}
.menu-card:nth-child(7n + 4) .menu-card-art:not(.has-image) {
background-image:
linear-gradient(rgba(5, 12, 18, 0.62), rgba(5, 12, 18, 0.86)),
url("images/curry-rice.jpg");
url("images/lakehouse-chicken.jpg");
}
.menu-card:nth-child(7n + 5) .menu-card-art:not(.has-image) {
background-image:
linear-gradient(rgba(5, 12, 18, 0.62), rgba(5, 12, 18, 0.86)),
url("images/italian-sub.jpg");
url("images/boardwalk-fries.jpg");
}
.menu-card:nth-child(7n + 6) .menu-card-art:not(.has-image) {
background-image:
linear-gradient(rgba(5, 12, 18, 0.62), rgba(5, 12, 18, 0.86)),
url("images/sausage-kale-soup.jpg");
url("images/lemon-iced-tea.jpg");
}
.menu-card:nth-child(7n) .menu-card-art:not(.has-image) {
background-image:
linear-gradient(rgba(5, 12, 18, 0.62), rgba(5, 12, 18, 0.86)),
url("images/grilled-chicken-salad.jpg");
url("images/patio-dining.jpg");
}
.menu-card-art:not(.has-image) {
@@ -968,6 +969,78 @@ h3 {
background: #08141f;
}
.lakeside-gallery {
background:
radial-gradient(circle at 92% 16%, rgba(239, 91, 161, 0.11), transparent 28%),
radial-gradient(circle at 8% 84%, rgba(31, 124, 255, 0.1), transparent 30%),
#07131d;
}
.experience-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
}
.experience-card {
position: relative;
min-height: 320px;
margin: 0;
overflow: hidden;
background: var(--navy);
border: 1px solid rgba(138, 245, 230, 0.2);
border-radius: var(--radius-large);
}
.experience-card-wide {
min-height: 470px;
grid-column: 1 / -1;
}
.experience-card img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 420ms ease;
}
.experience-card:hover img {
transform: scale(1.025);
}
.experience-card::after {
position: absolute;
inset: 0;
background: linear-gradient(transparent 38%, rgba(5, 12, 18, 0.9));
content: "";
}
.experience-card figcaption {
position: absolute;
z-index: 2;
right: 26px;
bottom: 24px;
left: 26px;
display: flex;
flex-direction: column;
}
.experience-card figcaption span {
color: var(--gold-light);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.experience-card figcaption strong {
color: var(--cream);
font-family: var(--font-display);
font-size: clamp(1.8rem, 4vw, 3.4rem);
line-height: 1;
text-transform: uppercase;
}
.category-band {
padding-block: 55px;
color: var(--cream);
@@ -1034,10 +1107,23 @@ h3 {
min-height: 410px;
place-items: center;
overflow: hidden;
color: var(--gold);
background:
linear-gradient(rgba(5, 12, 18, 0.58), rgba(5, 12, 18, 0.9)),
url("images/italian-sub.jpg") center / cover no-repeat;
color: var(--gold-light);
background: var(--navy);
}
.story-art::after {
position: absolute;
inset: 0;
background: linear-gradient(rgba(5, 12, 18, 0.16), rgba(5, 12, 18, 0.78));
content: "";
}
.story-art img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.story-number {
@@ -1069,7 +1155,7 @@ h3 {
color: var(--cream);
background:
linear-gradient(90deg, rgba(5, 12, 18, 0.97), rgba(8, 18, 28, 0.8)),
url("images/italian-sub.jpg") center 55% / cover no-repeat;
url("images/lakeside-sunset.jpg") center 56% / cover no-repeat;
}
.page-hero-compact {
@@ -1086,6 +1172,41 @@ h3 {
color: #b9c4ca;
}
.narrow-content {
max-width: 880px;
}
.credits-list {
border-top: 1px solid var(--line);
}
.credits-list article {
display: flex;
align-items: center;
justify-content: space-between;
gap: 25px;
padding-block: 22px;
border-bottom: 1px solid var(--line);
}
.credits-list h2 {
margin-bottom: 5px;
color: var(--cream);
font-size: 1.35rem;
text-transform: uppercase;
}
.credits-list p,
.credits-note {
margin: 0;
color: var(--muted);
font-size: 0.84rem;
}
.credits-note {
margin-top: 28px;
}
.page-hero-inner {
display: grid;
grid-template-columns: 1fr minmax(340px, 0.55fr);
@@ -1866,18 +1987,34 @@ textarea:focus {
white-space: nowrap;
}
.status-paid,
.status-completed,
.status-pending,
.status-paid {
color: #8a5618;
background: #f8e5c7;
}
.status-accepted {
color: #17633f;
background: #d7f4e5;
}
.status-ready {
color: #246449;
background: #ddefe6;
color: #195d83;
background: #d9effc;
}
.status-completed {
color: #65368c;
background: #ecdffc;
}
.status-processing,
.status-preparing {
color: #765417;
background: #f5e8c9;
color: #7d2d72;
background: #f7dcf2;
}
.status-declined,
.status-cancelled,
.status-refunded {
color: #7d3f3f;
@@ -1942,8 +2079,8 @@ textarea:focus {
padding-top: 68px;
color: #b6c0c6;
background:
linear-gradient(90deg, rgba(5, 12, 18, 0.99), rgba(5, 12, 18, 0.94)),
url("images/fat-bottom-grille-logo.jpg") right -80px center / 520px no-repeat,
linear-gradient(90deg, rgba(5, 12, 18, 0.99), rgba(5, 12, 18, 0.86)),
url("images/lakeside-sunset.jpg") center 62% / cover no-repeat,
#050c12;
}
@@ -2031,14 +2168,14 @@ textarea:focus {
.store-body .story-section {
background:
linear-gradient(90deg, rgba(8, 18, 28, 0.99), rgba(8, 18, 28, 0.92)),
url("images/sausage-kale-soup.jpg") right center / 46% auto no-repeat;
radial-gradient(circle at 86% 20%, rgba(114, 92, 255, 0.12), transparent 30%),
#08121c;
}
.store-body .category-band {
background:
linear-gradient(90deg, rgba(5, 12, 18, 0.98), rgba(5, 12, 18, 0.86)),
url("images/curry-rice.jpg") center 56% / cover no-repeat;
url("images/patio-dining.jpg") center 48% / cover no-repeat;
}
.store-body .menu-category-heading {
@@ -2100,20 +2237,20 @@ textarea:focus {
.store-body .order-summary {
background:
linear-gradient(145deg, rgba(8, 18, 28, 0.98), rgba(8, 18, 28, 0.88)),
url("images/quesadilla.jpg") center / cover no-repeat;
url("images/lakeside-burger.jpg") center / cover no-repeat;
border: 1px solid rgba(200, 151, 59, 0.22);
}
.store-body .auth-section {
background:
linear-gradient(90deg, rgba(5, 12, 18, 0.97), rgba(8, 18, 28, 0.87)),
url("images/grilled-chicken-salad.jpg") center / cover no-repeat;
url("images/garden-dining.jpg") center / cover no-repeat;
}
.store-body .confirmation-section {
background:
linear-gradient(rgba(5, 12, 18, 0.94), rgba(5, 12, 18, 0.97)),
url("images/sausage-kale-soup.jpg") center / cover no-repeat;
url("images/lakeside-sunset.jpg") center / cover no-repeat;
}
.store-body .status-pill {
@@ -2121,18 +2258,34 @@ textarea:focus {
background: #182a37;
}
.store-body .status-paid,
.store-body .status-completed,
.store-body .status-pending,
.store-body .status-paid {
color: #ffbd59;
background: #3d2a14;
}
.store-body .status-accepted {
color: #6cffad;
background: #143a29;
}
.store-body .status-ready {
color: #9de0c0;
background: #17382d;
color: #67d5ff;
background: #11334a;
}
.store-body .status-completed {
color: #cf8dff;
background: #321c49;
}
.store-body .status-processing,
.store-body .status-preparing {
color: #f0ce83;
background: #3b3019;
color: #ff82dc;
background: #46183c;
}
.store-body .status-declined,
.store-body .status-cancelled,
.store-body .status-refunded {
color: #efaaaa;
@@ -2698,6 +2851,213 @@ td small {
color: var(--gold-light);
}
.order-action-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.order-action-grid .button:last-child {
grid-column: 1 / -1;
}
.email-template-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.email-template-list details {
background: #0a1722;
border: 1px solid #283b49;
border-radius: var(--radius);
}
.email-template-list summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 14px 16px;
cursor: pointer;
}
.email-template-list summary span {
color: #758690;
font-size: 0.68rem;
text-transform: uppercase;
}
.email-template-list .inline-editor {
padding: 0 16px 16px;
}
.crm-stat-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.crm-stat-grid > div {
display: flex;
min-height: 105px;
flex-direction: column;
justify-content: center;
padding: 18px;
background: #0e1c28;
border: 1px solid var(--line);
border-radius: var(--radius-large);
}
.crm-stat-grid span {
color: #7f909a;
font-size: 0.64rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.crm-stat-grid strong {
margin-top: 6px;
color: var(--cream);
font-family: var(--font-display);
font-size: 1.45rem;
}
.tracker-layout {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(300px, 0.65fr);
align-items: start;
gap: 28px;
}
.tracker-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
}
.tracker-heading h2 {
margin: 8px 0 30px;
font-size: clamp(2rem, 5vw, 3.7rem);
text-transform: uppercase;
}
.order-progress {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
margin: 12px 0 28px;
}
.order-progress > div {
position: relative;
display: flex;
align-items: center;
flex-direction: column;
gap: 9px;
color: #71818c;
font-size: 0.66rem;
font-weight: 700;
text-align: center;
text-transform: uppercase;
}
.order-progress > div::before {
position: absolute;
z-index: 0;
top: 17px;
right: 50%;
width: 100%;
height: 2px;
background: #2d404e;
content: "";
}
.order-progress > div:first-child::before {
display: none;
}
.order-progress i {
position: relative;
z-index: 1;
display: grid;
width: 36px;
height: 36px;
place-items: center;
color: #81919a;
background: #142532;
border: 2px solid #314552;
border-radius: 50%;
font-style: normal;
}
.order-progress .is-complete::before,
.order-progress .is-complete i {
color: #07140f;
background: #54c996;
border-color: #54c996;
}
.order-progress .is-current i {
box-shadow: 0 0 0 6px rgba(84, 201, 150, 0.13);
}
.tracker-declined {
margin-bottom: 28px;
padding: 18px;
color: #f0b4b4;
background: #321b20;
border: 1px solid #69313a;
border-radius: var(--radius);
}
.tracker-declined p {
margin: 8px 0 0;
}
.tracker-updates {
margin-top: 28px;
padding-top: 24px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.tracker-updates h3 {
margin-bottom: 10px;
text-transform: uppercase;
}
.tracker-updates article {
padding: 14px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.09);
}
.tracker-updates article > div {
display: flex;
justify-content: space-between;
gap: 16px;
}
.tracker-updates time {
color: #748690;
font-size: 0.68rem;
}
.tracker-updates p {
margin: 6px 0 0;
color: #aebbc3;
}
.nav-theme-toggle {
padding: 5px 0;
color: var(--gold-light);
background: transparent;
border: 0;
font-size: 0.72rem;
font-weight: 700;
}
.refund-list {
margin-top: 18px;
border-top: 1px solid var(--line);
@@ -3062,7 +3422,8 @@ code {
.account-grid,
.checkout-layout,
.admin-detail-grid,
.newsletter-grid {
.newsletter-grid,
.tracker-layout {
grid-template-columns: 1fr;
}
@@ -3079,6 +3440,10 @@ code {
gap: 40px;
}
.experience-card-wide {
min-height: 390px;
}
.story-art {
min-height: 320px;
}
@@ -3138,6 +3503,10 @@ code {
.special-admin-grid {
grid-template-columns: 1fr;
}
.crm-stat-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 620px) {
@@ -3149,6 +3518,30 @@ code {
padding-block: 60px;
}
.order-progress {
grid-template-columns: 1fr;
gap: 10px;
}
.order-progress > div {
align-items: center;
flex-direction: row;
text-align: left;
}
.order-progress > div::before {
display: none;
}
.crm-stat-grid,
.order-action-grid {
grid-template-columns: 1fr;
}
.order-action-grid .button:last-child {
grid-column: auto;
}
.brand-copy strong {
font-size: 1.1rem;
}
@@ -3196,6 +3589,7 @@ code {
}
.menu-grid,
.experience-grid,
.category-links,
.footer-grid,
.form-grid,
@@ -3206,6 +3600,18 @@ code {
grid-template-columns: 1fr;
}
.experience-card,
.experience-card-wide {
min-height: 280px;
grid-column: auto;
}
.credits-list article {
align-items: flex-start;
flex-direction: column;
gap: 8px;
}
.category-links a {
min-height: 85px;
}
@@ -3305,6 +3711,10 @@ code {
display: none;
}
.admin-top-actions .theme-toggle {
display: inline-flex;
}
.admin-topbar {
flex-direction: row;
align-items: center;
@@ -3336,3 +3746,69 @@ code {
max-width: none;
}
}
/* User-selected light theme */
html[data-theme="light"] body,
html[data-theme="light"] .store-body main,
html[data-theme="light"] .admin-body {
color: #15232d;
background: #f5f2e9;
}
html[data-theme="light"] .site-header,
html[data-theme="light"] .admin-sidebar,
html[data-theme="light"] .admin-topbar {
color: #15232d;
background: #fffdf7;
border-color: #d8d3c7;
}
html[data-theme="light"] .store-body .panel,
html[data-theme="light"] .store-body .cart-list,
html[data-theme="light"] .store-body .empty-state,
html[data-theme="light"] .store-body .auth-card,
html[data-theme="light"] .admin-body .admin-panel,
html[data-theme="light"] .stat-card,
html[data-theme="light"] .crm-stat-grid > div {
color: #15232d;
background: #fffdf7;
border-color: #d8d3c7;
}
html[data-theme="light"] .admin-body input,
html[data-theme="light"] .admin-body select,
html[data-theme="light"] .admin-body textarea,
html[data-theme="light"] .store-body input,
html[data-theme="light"] .store-body select,
html[data-theme="light"] .store-body textarea {
color: #15232d;
background: #ffffff;
border-color: #b9b4a8;
}
html[data-theme="light"] .admin-body label,
html[data-theme="light"] .store-body label,
html[data-theme="light"] .tracker-updates p,
html[data-theme="light"] td,
html[data-theme="light"] .customer-card {
color: #4e5b63;
}
html[data-theme="light"] .admin-nav a:hover,
html[data-theme="light"] .admin-nav a.is-active,
html[data-theme="light"] .email-template-list details,
html[data-theme="light"] .admin-body .notice,
html[data-theme="light"] .admin-body .check-control,
html[data-theme="light"] .store-body .notice,
html[data-theme="light"] .store-body .check-control {
color: #263640;
background: #eee9dd;
border-color: #d2ccbf;
}
html[data-theme="light"] .detail-title-row h2,
html[data-theme="light"] .admin-panel-heading h2,
html[data-theme="light"] .tracker-heading h2,
html[data-theme="light"] .crm-stat-grid strong {
color: #15232d;
}
+19 -1
View File
@@ -1,4 +1,23 @@
(() => {
const savedTheme = window.localStorage.getItem("fatbottom-theme");
const preferredTheme = window.matchMedia?.("(prefers-color-scheme: light)").matches ? "light" : "dark";
document.documentElement.dataset.theme = savedTheme || preferredTheme;
const syncThemeButtons = () => {
document.querySelectorAll("[data-theme-toggle]").forEach((button) => {
button.textContent = document.documentElement.dataset.theme === "light" ? "Dark Mode" : "Light Mode";
});
};
syncThemeButtons();
document.querySelectorAll("[data-theme-toggle]").forEach((button) => {
button.addEventListener("click", () => {
const next = document.documentElement.dataset.theme === "light" ? "dark" : "light";
document.documentElement.dataset.theme = next;
window.localStorage.setItem("fatbottom-theme", next);
syncThemeButtons();
});
});
const navToggle = document.querySelector(".nav-toggle");
const nav = document.querySelector(".site-nav");
if (navToggle && nav) {
@@ -82,4 +101,3 @@
initializeSquare();
})();
+25
View File
@@ -0,0 +1,25 @@
# Lakeside Orders image credits
The active demo photography was sourced from Wikimedia Commons. Images may be
cropped or resized by the website through CSS.
- `lakeside-sunset.jpg` - Bonnie Moreland, public domain:
https://commons.wikimedia.org/wiki/File:Sunset_Odell_Lake,_Oregon_-_Flickr_-_Bonnie_Moreland_(free_images).jpg
- `lakeside-picnic.jpg` - Gholamreza Shamsnetari, CC BY 4.0:
https://commons.wikimedia.org/wiki/File:Sizdah_Be-Dar_2018,_Valasht_Lake_(13970114000329636583522676851541_54966).jpg
- `patio-dining.jpg` - Elvert Barnes, CC BY-SA 2.0:
https://commons.wikimedia.org/wiki/File:People_eating_outside_restaurant_Baltimore.jpg
- `garden-dining.jpg` - Ibrahim Achiri, CC BY-SA 4.0:
https://commons.wikimedia.org/wiki/File:People_dining_and_socializing_at_Zen_Garden.jpg
- `lakeside-burger.jpg` - Daderot, CC0:
https://commons.wikimedia.org/wiki/File:Hamburger_with_blue_cheese,_guacamole,_and_other_toppings,_plus_French_fries_-_Massachusetts.jpg
- `picnic-wrap.jpg` - Tomwsulcer, CC0:
https://commons.wikimedia.org/wiki/File:Lunch_chicken_wrap_with_spinach_and_sauce.JPG
- `shoreline-bowl.jpg` - Andy Li, CC0:
https://commons.wikimedia.org/wiki/File:Falafel_Super_Bowl_Salad_-_Leon_2025-10-27.jpg
- `lakehouse-chicken.jpg` - Vyacheslav Argenberg, CC BY 4.0:
https://commons.wikimedia.org/wiki/File:Fried_chicken_legs,_Rostov-on-Don,_Russia.jpg
- `lemon-iced-tea.jpg` - Renee Comet, public domain:
https://commons.wikimedia.org/wiki/File:Iced_tea.jpg
- `boardwalk-fries.jpg` - Thriving Vegetarian, CC BY 2.0:
https://commons.wikimedia.org/wiki/File:Perfect_French_Fries_-_11457817933.jpg
Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 732 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" role="img" aria-labelledby="title">
<title id="title">Lakeside Orders</title>
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#725cff"/>
<stop offset="0.58" stop-color="#ef5ba1"/>
<stop offset="1" stop-color="#ff9d4d"/>
</linearGradient>
<linearGradient id="water" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#31e6cf"/>
<stop offset="1" stop-color="#1f7cff"/>
</linearGradient>
<clipPath id="round">
<circle cx="60" cy="60" r="54"/>
</clipPath>
</defs>
<circle cx="60" cy="60" r="57" fill="#07131d" stroke="#8af5e6" stroke-width="4"/>
<g clip-path="url(#round)">
<rect x="6" y="6" width="108" height="62" fill="url(#sky)"/>
<circle cx="82" cy="39" r="15" fill="#fff0a8"/>
<path d="M0 67 31 40 49 58 68 35 96 62 120 45 126 78H0Z" fill="#0d2740"/>
<path d="M0 66c18-5 31-4 47 0 18 5 36 4 73-2v56H0Z" fill="url(#water)"/>
<path d="M0 82c24-8 43 5 66-1 19-5 36-7 60-1M-4 98c27-7 46 5 69-1 20-5 39-5 65 0" fill="none" stroke="#e8fffb" stroke-width="4" stroke-linecap="round" opacity=".7"/>
<path d="M35 76h50l-6 9H41Z" fill="#07131d"/>
<path d="M49 75 60 62l11 13" fill="none" stroke="#07131d" stroke-width="5" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 710 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

+22 -4
View File
@@ -9,8 +9,10 @@ use FatBottom\Core\Auth;
use FatBottom\Core\Http;
use FatBottom\Core\Router;
use FatBottom\Services\CartService;
use FatBottom\Services\CustomerService;
use FatBottom\Services\Mailer;
use FatBottom\Services\OrderService;
use FatBottom\Services\OrderStatusService;
use FatBottom\Services\PaymentGateway;
use FatBottom\Services\StatsService;
use FatBottom\Services\TwoFactorService;
@@ -23,13 +25,24 @@ $auth = new Auth($db);
$cart = new CartService($db);
$mailer = new Mailer($config);
$payments = new PaymentGateway($config);
$orders = new OrderService($db, $payments);
$customers = new CustomerService($db);
$orderStatuses = new OrderStatusService($config, $db, $mailer);
$orders = new OrderService($db, $payments, $customers);
$stats = new StatsService($db);
$twoFactor = new TwoFactorService($db, $mailer, $config);
$store = new StoreController($config, $auth, $cart, $db, $orders, $mailer);
$store = new StoreController($config, $auth, $cart, $db, $orders, $orderStatuses);
$authentication = new AuthController($config, $auth, $cart, $db, $twoFactor);
$admin = new AdminController($config, $auth, $cart, $db, $stats, $payments, $mailer);
$admin = new AdminController(
$config,
$auth,
$cart,
$db,
$stats,
$payments,
$mailer,
$orderStatuses
);
$path = Http::path();
$user = $auth->user();
@@ -38,6 +51,7 @@ $stats->record($path, $user ? (int) $user['id'] : null);
$router = new Router();
$router->get('/', [$store, 'home']);
$router->get('/menu', [$store, 'menu']);
$router->get('/credits', [$store, 'credits']);
$router->post('/cart/add', [$store, 'addToCart']);
$router->get('/cart', [$store, 'cart']);
$router->post('/cart/update', [$store, 'updateCart']);
@@ -45,6 +59,7 @@ $router->post('/cart/remove', [$store, 'removeFromCart']);
$router->get('/checkout', [$store, 'checkout']);
$router->post('/checkout', [$store, 'placeOrder']);
$router->get('/order/complete', [$store, 'orderComplete']);
$router->get('/order/track', [$store, 'orderTracker']);
$router->get('/login', [$authentication, 'loginForm']);
$router->post('/login', [$authentication, 'login']);
@@ -62,6 +77,10 @@ $router->get('/admin/orders', [$admin, 'orders']);
$router->get('/admin/order', [$admin, 'orderDetail']);
$router->post('/admin/order', [$admin, 'updateOrder']);
$router->post('/admin/order/refund', [$admin, 'refundOrder']);
$router->get('/admin/customers', [$admin, 'customers']);
$router->get('/admin/customer', [$admin, 'customerDetail']);
$router->post('/admin/customer/save', [$admin, 'saveCustomer']);
$router->post('/admin/customer/delete', [$admin, 'deleteCustomer']);
$router->get('/admin/menu', [$admin, 'menu']);
$router->post('/admin/category/save', [$admin, 'saveCategory']);
$router->post('/admin/item/save', [$admin, 'saveItem']);
@@ -79,4 +98,3 @@ $router->get('/admin/export/orders.csv', [$admin, 'exportOrdersCsv']);
$router->post('/admin/migrate/mysql', [$admin, 'migrateMySql']);
$router->dispatch($_SERVER['REQUEST_METHOD'] ?? 'GET', $path);
+2 -2
View File
@@ -86,10 +86,10 @@
<?php foreach ($orders as $order): ?>
<article>
<div>
<strong><?= e($order['order_number']) ?></strong>
<a href="<?= e(url('/order/track')) ?>?order=<?= e(rawurlencode((string) $order['order_number'])) ?>"><strong><?= e($order['order_number']) ?></strong></a>
<span><?= e(store_datetime((string) $order['placed_at'])) ?></span>
</div>
<span class="status-pill status-<?= e($order['status']) ?>"><?= e(ucwords(str_replace('_', ' ', (string) $order['status']))) ?></span>
<span class="status-pill status-<?= e($order['status']) ?>"><?= e(order_status_label((string) $order['status'])) ?></span>
<strong><?= money($order['total_cents']) ?></strong>
</article>
<?php endforeach; ?>
+73
View File
@@ -0,0 +1,73 @@
<div class="detail-heading">
<div>
<a class="back-link" href="<?= e(url('/admin/customers')) ?>">&larr; Back to customers</a>
<div class="detail-title-row">
<h2><?= e($customer['full_name']) ?></h2>
<span class="status-dot <?= $customer['active'] ? 'is-on' : '' ?>"><?= $customer['active'] ? 'Active' : 'Deactivated' ?></span>
</div>
<p>Customer since <?= e(store_datetime((string) $customer['created_at'], 'F j, Y')) ?></p>
</div>
<strong class="detail-total"><?= money($customer['lifetime_value_cents']) ?></strong>
</div>
<div class="crm-stat-grid">
<div><span>Orders</span><strong><?= (int) $customer['order_count'] ?></strong></div>
<div><span>Lifetime value</span><strong><?= money($customer['lifetime_value_cents']) ?></strong></div>
<div><span>First order</span><strong><?= $customer['first_order_at'] ? e(store_datetime((string) $customer['first_order_at'], 'M j, Y')) : '—' ?></strong></div>
<div><span>Account</span><strong><?= $customer['user_id'] ? 'Registered' : 'Guest' ?></strong></div>
</div>
<div class="admin-detail-grid">
<section class="admin-panel">
<div class="admin-panel-heading"><h2>Order History</h2></div>
<div class="table-wrap">
<table>
<thead><tr><th>Order</th><th>Placed</th><th>Status</th><th>Total</th><th></th></tr></thead>
<tbody>
<?php if (!$orders): ?><tr><td colspan="5" class="empty-cell">No linked orders.</td></tr><?php endif; ?>
<?php foreach ($orders as $order): ?>
<tr>
<td><strong><?= e($order['order_number']) ?></strong></td>
<td><?= e(store_datetime((string) $order['placed_at'], 'M j, Y g:i A')) ?></td>
<td><span class="status-pill status-<?= e($order['status']) ?>"><?= e(order_status_label((string) $order['status'])) ?></span></td>
<td><?= money($order['total_cents']) ?></td>
<td><a href="<?= e(url('/admin/order')) ?>?id=<?= (int) $order['id'] ?>">Open</a></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</section>
<aside>
<section class="admin-panel">
<div class="admin-panel-heading"><h2>CRM Profile</h2></div>
<form class="form-stack" action="<?= e(url('/admin/customer/save')) ?>" method="post">
<?= csrf_field() ?>
<input type="hidden" name="customer_id" value="<?= (int) $customer['id'] ?>">
<label>Full name<input type="text" name="full_name" required value="<?= e($customer['full_name']) ?>"></label>
<label>Email<input type="email" value="<?= e($customer['email']) ?>" disabled></label>
<label>Phone<input type="tel" name="phone" value="<?= e($customer['phone']) ?>"></label>
<label>Internal CRM notes<textarea name="internal_notes" rows="5" placeholder="Preferences, service notes, follow-up details..."><?= e($customer['internal_notes']) ?></textarea></label>
<label class="check-control compact"><input type="checkbox" name="active" value="1" <?= checked($customer['active']) ?>><span><strong>Active customer</strong><small>Deactivate without removing order history.</small></span></label>
<button class="button button-block" type="submit">Save Customer</button>
</form>
</section>
<section class="admin-panel">
<div class="admin-panel-heading"><h2>Contact & Account</h2></div>
<div class="customer-card">
<a href="mailto:<?= e($customer['email']) ?>"><?= e($customer['email']) ?></a>
<?php if ($customer['phone']): ?><a href="tel:<?= e(preg_replace('/[^0-9+]/', '', (string) $customer['phone'])) ?>"><?= e($customer['phone']) ?></a><?php endif; ?>
<span><?= $customer['user_id'] ? 'Customer account: ' . e($customer['account_active'] ? 'active' : 'disabled') : 'No customer account' ?></span>
</div>
</section>
<section class="admin-panel">
<div class="admin-panel-heading"><h2>Delete CRM Record</h2></div>
<p class="muted">This removes the CRM profile but keeps every historical order. A future order with this email will create a new profile.</p>
<form action="<?= e(url('/admin/customer/delete')) ?>" method="post">
<?= csrf_field() ?>
<input type="hidden" name="customer_id" value="<?= (int) $customer['id'] ?>">
<button class="button button-danger button-block" type="submit" data-confirm="Delete this customer CRM record? Historical orders will remain.">Delete Customer</button>
</form>
</section>
</aside>
</div>
+39
View File
@@ -0,0 +1,39 @@
<div class="admin-page-actions">
<form class="admin-filters" action="<?= e(url('/admin/customers')) ?>" method="get">
<input type="search" name="q" value="<?= e($query) ?>" placeholder="Search name, email, or phone">
<select name="active">
<option value="">All customers</option>
<option value="1" <?= selected($activeFilter, '1') ?>>Active</option>
<option value="0" <?= selected($activeFilter, '0') ?>>Deactivated</option>
</select>
<button class="button button-small" type="submit">Filter</button>
</form>
</div>
<section class="admin-panel">
<div class="admin-panel-heading">
<div><span class="eyebrow">Customer relationships</span><h2>Customer CRM</h2></div>
<span class="count-badge"><?= count($customers) ?> shown</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr><th>Customer</th><th>Account</th><th>Status</th><th>Orders</th><th>Lifetime Value</th><th>Last Order</th><th></th></tr>
</thead>
<tbody>
<?php if (!$customers): ?><tr><td colspan="7" class="empty-cell">No customers match those filters.</td></tr><?php endif; ?>
<?php foreach ($customers as $customer): ?>
<tr>
<td><strong><?= e($customer['full_name']) ?></strong><small><?= e($customer['email']) ?> · <?= e($customer['phone']) ?></small></td>
<td><span class="role-badge"><?= $customer['user_id'] ? 'Has account' : 'Guest' ?></span></td>
<td><span class="status-dot <?= $customer['active'] ? 'is-on' : '' ?>"><?= $customer['active'] ? 'Active' : 'Deactivated' ?></span></td>
<td><?= (int) $customer['order_count'] ?></td>
<td><?= money($customer['lifetime_value_cents']) ?></td>
<td><?= $customer['last_order_at'] ? e(store_datetime((string) $customer['last_order_at'], 'M j, Y')) : '—' ?></td>
<td><a class="button button-small button-ghost" href="<?= e(url('/admin/customer')) ?>?id=<?= (int) $customer['id'] ?>">Manage</a></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</section>
+1 -1
View File
@@ -49,7 +49,7 @@
<td><a href="<?= e(url('/admin/order')) ?>?id=<?= (int) $order['id'] ?>"><?= e($order['order_number']) ?></a></td>
<td><?= e($order['customer_name']) ?><small><?= e($order['customer_phone']) ?></small></td>
<td><?= e(store_datetime((string) $order['placed_at'], 'g:i A')) ?></td>
<td><span class="status-pill status-<?= e($order['status']) ?>"><?= e(ucfirst((string) $order['status'])) ?></span></td>
<td><span class="status-pill status-<?= e($order['status']) ?>"><?= e(order_status_label((string) $order['status'])) ?></span></td>
<td><?= money($order['total_cents']) ?></td>
</tr>
<?php endforeach; ?>
+1 -1
View File
@@ -7,7 +7,7 @@
<div class="admin-panel-heading"><div><span class="eyebrow">Send an update</span><h2>New Newsletter</h2></div></div>
<form class="form-stack" action="<?= e(url('/admin/newsletters/send')) ?>" method="post">
<?= csrf_field() ?>
<label>Subject line<input type="text" name="subject" maxlength="140" required placeholder="This weekend at Fat Bottom Grille"></label>
<label>Subject line<input type="text" name="subject" maxlength="140" required placeholder="This weekend at <?= e($config->get('business.name')) ?>"></label>
<label>Message<textarea name="content" rows="12" required placeholder="Share a new special, schedule update, or note from the kitchen..."></textarea></label>
<div class="notice">Every email includes an unsubscribe link. Users can also opt out from My Account.</div>
<button class="button button-large button-block" type="submit" data-confirm="Send this newsletter to every opted-in user?">Send to <?= (int) $recipientCount ?> Subscribers</button>
+25 -12
View File
@@ -3,7 +3,7 @@
<a class="back-link" href="<?= e(url('/admin/orders')) ?>">&larr; Back to orders</a>
<div class="detail-title-row">
<h2><?= e($order['order_number']) ?></h2>
<span class="status-pill status-<?= e($order['status']) ?>"><?= e(ucfirst((string) $order['status'])) ?></span>
<span class="status-pill status-<?= e($order['status']) ?>"><?= e(order_status_label((string) $order['status'])) ?></span>
</div>
<p>Placed <?= e(store_datetime((string) $order['placed_at'], 'F j, Y \a\t g:i A')) ?></p>
</div>
@@ -39,8 +39,11 @@
<div>
<i></i>
<span>
<strong><?= e(ucwords(str_replace('_', ' ', (string) $event['event_type']))) ?></strong>
<strong><?= e($event['status'] ? order_status_label((string) $event['status']) : ucwords(str_replace('_', ' ', (string) $event['event_type']))) ?></strong>
<p><?= e($event['details']) ?></p>
<?php if ($event['note']): ?>
<small><?= $event['customer_visible'] ? 'Customer-visible note' : 'Internal note' ?><?= $event['email_sent'] ? ' · Emailed' : '' ?></small>
<?php endif; ?>
<small><?= e($event['staff_name'] ?: 'System') ?> &middot; <?= e(store_datetime((string) $event['created_at'], 'M j, g:i A')) ?></small>
</span>
</div>
@@ -50,21 +53,28 @@
</div>
<aside>
<section class="admin-panel">
<div class="admin-panel-heading"><h2>Update Order</h2></div>
<div class="admin-panel-heading"><h2>Order Actions</h2></div>
<form class="form-stack" action="<?= e(url('/admin/order')) ?>" method="post">
<?= csrf_field() ?>
<input type="hidden" name="order_id" value="<?= (int) $order['id'] ?>">
<label>Status
<select name="status">
<?php foreach (['paid', 'preparing', 'ready', 'completed', 'cancelled'] as $status): ?>
<option value="<?= $status ?>" <?= selected($order['status'], $status) ?>><?= e(ucfirst($status)) ?></option>
<?php endforeach; ?>
</select>
<label>Reason or note
<textarea name="status_note" rows="3" placeholder="Optional. A blank rejection reason will not appear to the customer."></textarea>
</label>
<label>Staff note
<textarea name="staff_note" rows="3" placeholder="Optional internal note"></textarea>
<label class="check-control compact">
<input type="checkbox" name="customer_visible" value="1">
<span><strong>Show this note to the customer</strong><small>The status itself is always visible.</small></span>
</label>
<button class="button button-block" type="submit">Update Status</button>
<label class="check-control compact">
<input type="checkbox" name="send_email" value="1">
<span><strong>Email this update</strong><small>Acceptance emails are always sent.</small></span>
</label>
<div class="order-action-grid">
<button class="button" type="submit" name="status" value="accepted">Accept</button>
<button class="button button-danger" type="submit" name="status" value="declined">Reject</button>
<button class="button button-outline" type="submit" name="status" value="processing">Start Order</button>
<button class="button" type="submit" name="status" value="ready">Complete / Ready</button>
<button class="button button-ghost" type="submit" name="status" value="completed">Picked Up</button>
</div>
</form>
</section>
<section class="admin-panel">
@@ -74,6 +84,9 @@
<a href="mailto:<?= e($order['customer_email']) ?>"><?= e($order['customer_email']) ?></a>
<a href="tel:<?= e(preg_replace('/[^0-9+]/', '', (string) $order['customer_phone'])) ?>"><?= e($order['customer_phone']) ?></a>
<span>Pickup<?= $order['pickup_time'] ? ' at ' . e($order['pickup_time']) : '' ?></span>
<?php if ($customer): ?>
<a class="button button-small button-ghost" href="<?= e(url('/admin/customer')) ?>?id=<?= (int) $customer['id'] ?>">Open CRM Profile</a>
<?php endif; ?>
</div>
</section>
<section class="admin-panel">
+3 -3
View File
@@ -4,8 +4,8 @@
<input type="search" name="q" value="<?= e($query) ?>" placeholder="Order, customer, or email">
<select name="status">
<option value="">All statuses</option>
<?php foreach (['paid', 'preparing', 'ready', 'completed', 'cancelled'] as $status): ?>
<option value="<?= $status ?>" <?= selected($statusFilter, $status) ?>><?= e(ucfirst($status)) ?></option>
<?php foreach (['pending', 'accepted', 'declined', 'processing', 'ready', 'completed'] as $status): ?>
<option value="<?= $status ?>" <?= selected($statusFilter, $status) ?>><?= e(order_status_label($status)) ?></option>
<?php endforeach; ?>
</select>
<button class="button button-small" type="submit">Filter</button>
@@ -24,7 +24,7 @@
<td><strong><?= e($order['order_number']) ?></strong></td>
<td><?= e($order['customer_name']) ?><small><?= e($order['customer_email']) ?></small></td>
<td><?= e(store_datetime((string) $order['placed_at'], 'M j, g:i A')) ?></td>
<td><span class="status-pill status-<?= e($order['status']) ?>"><?= e(ucfirst((string) $order['status'])) ?></span></td>
<td><span class="status-pill status-<?= e($order['status']) ?>"><?= e(order_status_label((string) $order['status'])) ?></span></td>
<td>
<span class="status-pill status-<?= e($order['payment_status']) ?>"><?= e(ucwords(str_replace('_', ' ', (string) $order['payment_status']))) ?></span>
<?php if ((int) $order['refunded_cents'] > 0): ?><small><?= money($order['refunded_cents']) ?> returned</small><?php endif; ?>
+48
View File
@@ -15,6 +15,27 @@
<label class="field-wide">Announcement bar<input type="text" name="business[announcement]" value="<?= e($config->get('business.announcement')) ?>"></label>
</div>
</section>
<section class="admin-panel">
<div class="admin-panel-heading"><div><span class="eyebrow">Storefront presentation</span><h2>Branding & Homepage</h2></div></div>
<div class="notice">Edit the demo message and active image paths here. Image files live in <code>public/assets/images</code>.</div>
<div class="form-grid">
<label>Location label<input type="text" name="branding[location_label]" value="<?= e($config->get('branding.location_label')) ?>"></label>
<label>Hero eyebrow<input type="text" name="branding[hero_eyebrow]" value="<?= e($config->get('branding.hero_eyebrow')) ?>"></label>
<label>Hero heading<input type="text" name="branding[hero_heading]" value="<?= e($config->get('branding.hero_heading')) ?>"></label>
<label>Hero emphasized line<input type="text" name="branding[hero_emphasis]" value="<?= e($config->get('branding.hero_emphasis')) ?>"></label>
<label class="field-wide">Search description<textarea name="branding[meta_description]" rows="2"><?= e($config->get('branding.meta_description')) ?></textarea></label>
<label class="field-wide">Hero introduction<textarea name="branding[hero_intro]" rows="3"><?= e($config->get('branding.hero_intro')) ?></textarea></label>
<label>Hero food image<input type="text" name="branding[hero_image]" value="<?= e($config->get('branding.hero_image')) ?>"></label>
<label>Picnic gallery image<input type="text" name="branding[gallery_picnic_image]" value="<?= e($config->get('branding.gallery_picnic_image')) ?>"></label>
<label>Patio gallery image<input type="text" name="branding[gallery_patio_image]" value="<?= e($config->get('branding.gallery_patio_image')) ?>"></label>
<label>Service gallery image<input type="text" name="branding[gallery_service_image]" value="<?= e($config->get('branding.gallery_service_image')) ?>"></label>
<label>Story image<input type="text" name="branding[story_image]" value="<?= e($config->get('branding.story_image')) ?>"></label>
<label>Story eyebrow<input type="text" name="branding[story_eyebrow]" value="<?= e($config->get('branding.story_eyebrow')) ?>"></label>
<label>Story heading<input type="text" name="branding[story_heading]" value="<?= e($config->get('branding.story_heading')) ?>"></label>
<label class="field-wide">Story text<textarea name="branding[story_text]" rows="3"><?= e($config->get('branding.story_text')) ?></textarea></label>
<label class="field-wide">Footer tagline<input type="text" name="branding[footer_tagline]" value="<?= e($config->get('branding.footer_tagline')) ?>"></label>
</div>
</section>
<section class="admin-panel">
<div class="admin-panel-heading"><div><span class="eyebrow">Kitchen controls</span><h2>Online Ordering</h2></div></div>
<div class="settings-row">
@@ -44,6 +65,33 @@
</div>
<label class="check-control"><input type="checkbox" name="mailgun[enabled]" value="1" <?= checked($config->get('mailgun.enabled')) ?>><span><strong>Enable Mailgun delivery</strong></span></label>
</section>
<section class="admin-panel">
<div class="admin-panel-heading"><div><span class="eyebrow">Customer notifications</span><h2>Order Status Email Templates</h2></div></div>
<div class="notice">
Available placeholders: <code>{{customer_name}}</code>, <code>{{order_number}}</code>,
<code>{{status_label}}</code>, <code>{{note}}</code>, <code>{{rejection_reason}}</code>,
<code>{{tracking_url}}</code>, and <code>{{business_name}}</code>.
</div>
<div class="email-template-list">
<?php foreach (['pending', 'accepted', 'declined', 'processing', 'ready', 'completed'] as $emailStatus): ?>
<details <?= $emailStatus === 'accepted' ? 'open' : '' ?>>
<summary>
<strong><?= e(order_status_label($emailStatus)) ?></strong>
<span><?= $emailStatus === 'pending' ? 'Order receipt' : 'Status update' ?></span>
</summary>
<div class="inline-editor form-stack">
<label>Subject
<input type="text" name="order_emails[<?= e($emailStatus) ?>][subject]" required
value="<?= e($config->get('order_emails.' . $emailStatus . '.subject')) ?>">
</label>
<label>Message
<textarea name="order_emails[<?= e($emailStatus) ?>][body]" rows="6" required><?= e($config->get('order_emails.' . $emailStatus . '.body')) ?></textarea>
</label>
</div>
</details>
<?php endforeach; ?>
</div>
</section>
<section class="admin-panel">
<div class="admin-panel-heading"><div><span class="eyebrow">Optional database server</span><h2>MySQL / MariaDB Connection</h2></div></div>
<div class="notice">These values are only used by the migration tool below. The application continues using SQLite unless a migration succeeds and you explicitly switch drivers.</div>
+4 -4
View File
@@ -30,13 +30,13 @@
<input type="text" name="street_address" required autocomplete="street-address" value="<?= e($old['street_address'] ?? '') ?>">
</label>
<label>City
<input type="text" name="city" required autocomplete="address-level2" value="<?= e($old['city'] ?? 'Keyser') ?>">
<input type="text" name="city" required autocomplete="address-level2" value="<?= e($old['city'] ?? $config->get('business.city')) ?>">
</label>
<label>State
<input type="text" name="state" required maxlength="2" autocomplete="address-level1" value="<?= e($old['state'] ?? 'WV') ?>">
<input type="text" name="state" required maxlength="2" autocomplete="address-level1" value="<?= e($old['state'] ?? $config->get('business.state')) ?>">
</label>
<label>ZIP code
<input type="text" name="postal_code" required autocomplete="postal-code" value="<?= e($old['postal_code'] ?? '26726') ?>">
<input type="text" name="postal_code" required autocomplete="postal-code" value="<?= e($old['postal_code'] ?? $config->get('business.postal_code')) ?>">
</label>
</div>
<div class="form-grid">
@@ -53,7 +53,7 @@
<label>Quick check: what is <?= (int) $captcha[0] ?> + <?= (int) $captcha[1] ?>?
<input type="number" name="captcha" required inputmode="numeric" autocomplete="off">
</label>
<div class="notice">Creating an account opts you into occasional Fat Bottom Grille news. You can opt out from any email or My Account.</div>
<div class="notice">Creating an account opts you into occasional <?= e($config->get('business.name')) ?> news. You can opt out from any email or My Account.</div>
<button class="button button-large button-block" type="submit">Create My Account</button>
<p class="auth-switch">Already have an account? <a href="<?= e(url('/login')) ?>">Sign in</a></p>
</form>
+8 -6
View File
@@ -2,7 +2,7 @@
use FatBottom\Core\View;
$businessName = (string) $config->get('business.name', 'Fat Bottom Grille');
$businessName = (string) $config->get('business.name', 'Lakeside Orders');
$isAdmin = isset($adminPage);
?>
<!doctype html>
@@ -11,7 +11,7 @@ $isAdmin = isset($adminPage);
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#09131d">
<meta name="description" content="Order burgers, burritos, dinner plates, sides, and drinks online from Fat Bottom Grille in Keyser, West Virginia.">
<meta name="description" content="<?= e($config->get('branding.meta_description')) ?>">
<title><?= e($title ?? $businessName) ?> | <?= e($businessName) ?></title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -36,6 +36,7 @@ $isAdmin = isset($adminPage);
<h1><?= e($title ?? 'Dashboard') ?></h1>
</div>
<div class="admin-top-actions">
<button class="button button-ghost button-small theme-toggle" type="button" data-theme-toggle>Light Mode</button>
<a class="button button-ghost button-small" href="<?= e(url('/')) ?>" target="_blank" rel="noopener">View Store</a>
<form action="<?= e(url('/logout')) ?>" method="post">
<?= csrf_field() ?>
@@ -58,11 +59,11 @@ $isAdmin = isset($adminPage);
<div class="container nav-wrap">
<a class="brand" href="<?= e(url('/')) ?>" aria-label="<?= e($businessName) ?> home">
<span class="brand-mark">
<img src="<?= e(asset('assets/images/fat-bottom-grille-logo.jpg')) ?>" alt="">
<img src="<?= e(asset('assets/images/lakeside-orders-logo.svg')) ?>" alt="">
</span>
<span class="brand-copy">
<strong><?= e($businessName) ?></strong>
<small>Keyser, West Virginia</small>
<small><?= e($config->get('branding.location_label')) ?></small>
</span>
</a>
<button class="nav-toggle" type="button" aria-expanded="false" aria-controls="site-nav">
@@ -80,6 +81,7 @@ $isAdmin = isset($adminPage);
<?php else: ?>
<a class="<?= active_path('/login') ?>" href="<?= e(url('/login')) ?>">Sign In</a>
<?php endif; ?>
<button class="theme-toggle nav-theme-toggle" type="button" data-theme-toggle>Light Mode</button>
<a class="cart-link" href="<?= e(url('/cart')) ?>">
Your Order
<span class="cart-count"><?= (int) ($currentCart['item_count'] ?? 0) ?></span>
@@ -95,7 +97,7 @@ $isAdmin = isset($adminPage);
<div class="container footer-grid">
<div>
<div class="footer-brand"><?= e($businessName) ?></div>
<p>Good food, generous portions, and a seat at the table in Keyser.</p>
<p><?= e($config->get('branding.footer_tagline')) ?></p>
</div>
<div>
<h2>Visit</h2>
@@ -126,7 +128,7 @@ $isAdmin = isset($adminPage);
</div>
<div class="container footer-bottom">
<span>&copy; <?= date('Y') ?> <?= e($businessName) ?></span>
<span>410 W Piedmont St, Keyser, WV</span>
<a href="<?= e(url('/credits')) ?>">Image credits</a>
</div>
</footer>
<?php endif; ?>
+2 -1
View File
@@ -1,7 +1,7 @@
<aside class="admin-sidebar">
<a class="admin-brand" href="<?= e(url('/admin')) ?>">
<span class="brand-mark">
<img src="<?= e(asset('assets/images/fat-bottom-grille-logo.jpg')) ?>" alt="">
<img src="<?= e(asset('assets/images/lakeside-orders-logo.svg')) ?>" alt="">
</span>
<span>
<strong><?= e($config->get('business.name')) ?></strong>
@@ -11,6 +11,7 @@
<nav class="admin-nav" aria-label="Dashboard navigation">
<a class="<?= $adminPage === 'dashboard' ? 'is-active' : '' ?>" href="<?= e(url('/admin')) ?>">Overview</a>
<a class="<?= $adminPage === 'orders' ? 'is-active' : '' ?>" href="<?= e(url('/admin/orders')) ?>">Orders</a>
<a class="<?= $adminPage === 'customers' ? 'is-active' : '' ?>" href="<?= e(url('/admin/customers')) ?>">Customers</a>
<a class="<?= $adminPage === 'menu' ? 'is-active' : '' ?>" href="<?= e(url('/admin/menu')) ?>">Menu</a>
<a class="<?= $adminPage === 'specials' ? 'is-active' : '' ?>" href="<?= e(url('/admin/specials')) ?>">Specials</a>
<a class="<?= $adminPage === 'newsletters' ? 'is-active' : '' ?>" href="<?= e(url('/admin/newsletters')) ?>">Newsletters</a>
+1 -1
View File
@@ -51,7 +51,7 @@
<div class="summary-total"><dt>Total</dt><dd><?= money($totals['total_cents']) ?></dd></div>
</dl>
<a class="button button-block button-large" href="<?= e(url('/checkout')) ?>">Continue to Checkout</a>
<p class="summary-note">Pickup at 410 W Piedmont St, Keyser.</p>
<p class="summary-note">Pickup at <?= e($config->get('business.street')) ?>, <?= e($config->get('business.city')) ?>.</p>
</aside>
<?php endif; ?>
</div>
+38
View File
@@ -0,0 +1,38 @@
<?php
$credits = [
['Sunset at Odell Lake', 'Bonnie Moreland', 'Public domain', 'https://commons.wikimedia.org/wiki/File:Sunset_Odell_Lake,_Oregon_-_Flickr_-_Bonnie_Moreland_(free_images).jpg'],
['Picnic at Valasht Lake', 'Gholamreza Shamsnetari', 'CC BY 4.0', 'https://commons.wikimedia.org/wiki/File:Sizdah_Be-Dar_2018,_Valasht_Lake_(13970114000329636583522676851541_54966).jpg'],
['People eating outside a restaurant', 'Elvert Barnes', 'CC BY-SA 2.0', 'https://commons.wikimedia.org/wiki/File:People_eating_outside_restaurant_Baltimore.jpg'],
['People dining at Zen Garden', 'Ibrahim Achiri', 'CC BY-SA 4.0', 'https://commons.wikimedia.org/wiki/File:People_dining_and_socializing_at_Zen_Garden.jpg'],
['Hamburger with fries', 'Daderot', 'CC0', 'https://commons.wikimedia.org/wiki/File:Hamburger_with_blue_cheese,_guacamole,_and_other_toppings,_plus_French_fries_-_Massachusetts.jpg'],
['Chicken wrap with spinach', 'Tomwsulcer', 'CC0', 'https://commons.wikimedia.org/wiki/File:Lunch_chicken_wrap_with_spinach_and_sauce.JPG'],
['Falafel bowl', 'Andy Li', 'CC0', 'https://commons.wikimedia.org/wiki/File:Falafel_Super_Bowl_Salad_-_Leon_2025-10-27.jpg'],
['Chicken legs', 'Vyacheslav Argenberg', 'CC BY 4.0', 'https://commons.wikimedia.org/wiki/File:Fried_chicken_legs,_Rostov-on-Don,_Russia.jpg'],
['Iced tea', 'Renee Comet', 'Public domain', 'https://commons.wikimedia.org/wiki/File:Iced_tea.jpg'],
['French fries', 'Thriving Vegetarian', 'CC BY 2.0', 'https://commons.wikimedia.org/wiki/File:Perfect_French_Fries_-_11457817933.jpg'],
];
?>
<section class="page-hero page-hero-compact">
<div class="container">
<span class="eyebrow">Open media</span>
<h1>Image Credits</h1>
<p>The Lakeside Orders demo uses public-domain and Creative Commons photography.</p>
</div>
</section>
<section class="section">
<div class="container narrow-content">
<div class="credits-list">
<?php foreach ($credits as [$title, $artist, $license, $source]): ?>
<article>
<div>
<h2><?= e($title) ?></h2>
<p><?= e($artist) ?> &middot; <?= e($license) ?></p>
</div>
<a class="text-link" href="<?= e($source) ?>" target="_blank" rel="license noopener">View source</a>
</article>
<?php endforeach; ?>
</div>
<p class="credits-note">Photography is displayed with CSS crops for the responsive layout. No photographer or licensor endorses this demo.</p>
</div>
</section>
+44 -18
View File
@@ -2,9 +2,9 @@
<div class="hero-texture"></div>
<div class="container hero-grid">
<div class="hero-copy">
<span class="eyebrow">Keyser made. Come hungry.</span>
<h1>Big flavor.<br><em>No shortcuts.</em></h1>
<p>Stacked burgers, loaded burritos, and dinner plates that eat like Sunday supper. Order ahead and we will have it hot.</p>
<span class="eyebrow"><?= e($config->get('branding.hero_eyebrow')) ?></span>
<h1><?= e($config->get('branding.hero_heading')) ?><br><em><?= e($config->get('branding.hero_emphasis')) ?></em></h1>
<p><?= e($config->get('branding.hero_intro')) ?></p>
<div class="hero-actions">
<a class="button button-large" href="<?= e(url('/menu')) ?>">Start Your Order</a>
<?php if ($config->get('business.phone')): ?>
@@ -20,15 +20,15 @@
</div>
<div class="hero-plate">
<div class="hero-food-photo">
<img src="<?= e(asset('assets/images/chicken-sandwich-fries.jpg')) ?>" alt="Chicken sandwich and hand-cut fries from Fat Bottom Grille">
<img src="<?= e(asset((string) $config->get('branding.hero_image'))) ?>" alt="Burger and fries ready for a lakeside meal">
<div class="hero-photo-caption">
<span>Made in Keyser</span>
<strong>Hot. Fresh. Generous.</strong>
<span>From grill to shoreline</span>
<strong>Fresh pickup. Easy afternoons.</strong>
</div>
</div>
<div class="hero-stamp" aria-hidden="true">
<strong>410</strong>
<span>W Piedmont</span>
<strong>DEMO</strong>
<span>Lakeside pickup</span>
</div>
</div>
</div>
@@ -40,7 +40,7 @@
<div class="section-heading">
<div>
<span class="eyebrow">Happening now</span>
<h2>Todays Specials</h2>
<h2>Dockside Specials</h2>
</div>
<a class="text-link" href="<?= e(url('/menu')) ?>">See the full menu</a>
</div>
@@ -75,10 +75,10 @@
<div class="container">
<div class="section-heading">
<div>
<span class="eyebrow">Built to satisfy</span>
<h2>Local Favorites</h2>
<span class="eyebrow">Packed for the day</span>
<h2>Shoreline Favorites</h2>
</div>
<p>These are the orders that keep showing up at the counter.</p>
<p>Easygoing food for docks, picnic blankets, and patio tables.</p>
</div>
<div class="menu-grid">
<?php foreach ($featured as $item): ?>
@@ -88,6 +88,32 @@
</div>
</section>
<section class="lakeside-gallery section">
<div class="container">
<div class="section-heading">
<div>
<span class="eyebrow">Stay awhile</span>
<h2>Food fits the view</h2>
</div>
<p>Grab a picnic, settle into a sunny table, or let the evening stretch out under the lights.</p>
</div>
<div class="experience-grid">
<figure class="experience-card experience-card-wide">
<img src="<?= e(asset((string) $config->get('branding.gallery_picnic_image'))) ?>" alt="People enjoying a picnic beside a lake">
<figcaption><span>Take it outside</span><strong>Picnic-ready</strong></figcaption>
</figure>
<figure class="experience-card">
<img src="<?= e(asset((string) $config->get('branding.gallery_patio_image'))) ?>" alt="Guests sitting and eating at outdoor restaurant tables">
<figcaption><span>Meet by the water</span><strong>Tables in the sun</strong></figcaption>
</figure>
<figure class="experience-card">
<img src="<?= e(asset((string) $config->get('branding.gallery_service_image'))) ?>" alt="A lively restaurant with diners and servers moving among the tables">
<figcaption><span>Settle in</span><strong>Friendly table service</strong></figcaption>
</figure>
</div>
</div>
</section>
<section class="category-band">
<div class="container">
<span class="eyebrow">Pick your direction</span>
@@ -104,14 +130,14 @@
<section class="story-section section">
<div class="container story-grid">
<div class="story-art" aria-hidden="true">
<span class="story-number">26726</span>
<div class="mountain-lines"></div>
<div class="story-art">
<img src="<?= e(asset((string) $config->get('branding.story_image'))) ?>" alt="Sunset over a calm lake and dock">
<span class="story-number">LAKE</span>
</div>
<div>
<span class="eyebrow">Proudly from Keyser</span>
<h2>Food that shows up hungry.</h2>
<p>Fat Bottom Grille is the kind of place where the portions are honest and nobody leaves wondering what is for dinner. Find us at 410 W Piedmont Street, right here in Keyser.</p>
<span class="eyebrow"><?= e($config->get('branding.story_eyebrow')) ?></span>
<h2><?= e($config->get('branding.story_heading')) ?></h2>
<p><?= e($config->get('branding.story_text')) ?></p>
<a class="button button-outline" href="<?= e(url('/menu')) ?>">Order for Pickup</a>
</div>
</div>
+2 -2
View File
@@ -1,9 +1,9 @@
<section class="page-hero">
<div class="container page-hero-inner">
<div>
<span class="eyebrow">Hot, fresh, yours</span>
<span class="eyebrow">From the grill to the shoreline</span>
<h1>Order Online</h1>
<p>Choose your favorites and we will get the kitchen moving.</p>
<p>Choose a dockside favorite and we will get your lakeside pickup moving.</p>
</div>
<form class="menu-search" action="<?= e(url('/menu')) ?>" method="get">
<label for="menu-query">Search the menu</label>
+4 -3
View File
@@ -1,12 +1,12 @@
<section class="confirmation-section section">
<div class="container confirmation-card">
<div class="confirmation-mark"></div>
<span class="eyebrow">Paid and sent to the kitchen</span>
<span class="eyebrow">Paid and awaiting acceptance</span>
<h1>Weve got it, <?= e(explode(' ', (string) $order['customer_name'])[0]) ?>.</h1>
<p>Your order number is <strong><?= e($order['order_number']) ?></strong>. We sent a confirmation to <?= e($order['customer_email']) ?>.</p>
<div class="confirmation-details">
<div><span>Pickup</span><strong><?= e($order['pickup_time'] ?: 'About ' . $config->get('ordering.pickup_eta_minutes', 25) . ' minutes') ?></strong></div>
<div><span>Location</span><strong>410 W Piedmont St</strong></div>
<div><span>Location</span><strong><?= e($config->get('business.street')) ?></strong></div>
<div><span>Total paid</span><strong><?= money($order['total_cents']) ?></strong></div>
</div>
<div class="confirmation-items">
@@ -15,7 +15,8 @@
<?php endforeach; ?>
</div>
<div class="confirmation-actions">
<a class="button" href="<?= e(url('/menu')) ?>">Order Something Else</a>
<a class="button" href="<?= e($trackingUrl) ?>">Track This Order</a>
<a class="button button-outline" href="<?= e(url('/menu')) ?>">Order Something Else</a>
<?php if ($currentUser): ?><a class="button button-outline" href="<?= e(url('/account')) ?>">View My Orders</a><?php endif; ?>
</div>
</div>
+80
View File
@@ -0,0 +1,80 @@
<?php
$status = (string) $order['status'];
$steps = ['accepted', 'processing', 'ready', 'completed'];
$currentIndex = array_search($status, $steps, true);
?>
<section class="page-hero page-hero-compact">
<div class="container">
<span class="eyebrow">Live order tracker</span>
<h1><?= e($order['order_number']) ?></h1>
<p>Updates for <?= e($order['customer_name']) ?>.</p>
</div>
</section>
<div class="container tracker-layout section">
<section class="panel tracker-card">
<div class="tracker-heading">
<div>
<span class="eyebrow">Current status</span>
<h2><?= e(order_status_label($status)) ?></h2>
</div>
<span class="status-pill status-<?= e($status) ?>"><?= e(order_status_label($status)) ?></span>
</div>
<?php if ($status === 'declined'): ?>
<div class="tracker-declined">
<strong>This order was declined.</strong>
<?php
$visibleReason = '';
foreach (array_reverse($events) as $event) {
if ($event['status'] === 'declined' && $event['customer_visible'] && $event['note'] !== '') {
$visibleReason = (string) $event['note'];
break;
}
}
?>
<?php if ($visibleReason !== ''): ?><p><strong>Reason:</strong> <?= e($visibleReason) ?></p><?php endif; ?>
</div>
<?php else: ?>
<div class="order-progress" aria-label="Order progress">
<?php foreach ($steps as $index => $step): ?>
<?php $complete = $currentIndex !== false && $index <= $currentIndex; ?>
<div class="<?= $complete ? 'is-complete' : '' ?> <?= $status === $step ? 'is-current' : '' ?>">
<i><?= $complete ? '✓' : $index + 1 ?></i>
<span><?= e(order_status_label($step)) ?></span>
</div>
<?php endforeach; ?>
</div>
<?php if ($status === 'pending'): ?>
<div class="notice">Your paid order is waiting for staff to accept or decline it.</div>
<?php endif; ?>
<?php endif; ?>
<div class="tracker-updates">
<h3>Updates</h3>
<?php foreach (array_reverse($events) as $event): ?>
<article>
<div>
<strong><?= e($event['status'] ? order_status_label((string) $event['status']) : 'Order update') ?></strong>
<time><?= e(store_datetime((string) $event['created_at'], 'M j, g:i A')) ?></time>
</div>
<?php if ($event['customer_visible'] && $event['note']): ?><p><?= e($event['note']) ?></p><?php endif; ?>
</article>
<?php endforeach; ?>
</div>
</section>
<aside class="order-summary">
<span class="eyebrow">Pickup order</span>
<h2>Order Details</h2>
<div class="summary-items">
<?php foreach ($items as $item): ?>
<div><span><?= (int) $item['quantity'] ?>x <?= e($item['item_name']) ?></span><strong><?= money($item['line_total_cents']) ?></strong></div>
<?php endforeach; ?>
</div>
<dl>
<div><dt>Total paid</dt><dd><?= money($order['total_cents']) ?></dd></div>
<div><dt>Pickup</dt><dd><?= e($order['pickup_time'] ?: 'As soon as ready') ?></dd></div>
</dl>
</aside>
</div>