- 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.
This commit is contained in:
Ty Clifford
2026-06-14 13:35:31 -04:00
parent 3417589a7c
commit 03348cad79
24 changed files with 1218 additions and 72 deletions
+136 -15
View File
@@ -16,6 +16,7 @@ use FatBottom\Services\MenuPdfExporter;
use FatBottom\Services\MySqlMigrator; use FatBottom\Services\MySqlMigrator;
use FatBottom\Services\PaymentGateway; use FatBottom\Services\PaymentGateway;
use FatBottom\Services\StatsService; use FatBottom\Services\StatsService;
use FatBottom\Services\OrderStatusService;
final class AdminController extends BaseController final class AdminController extends BaseController
{ {
@@ -26,7 +27,8 @@ final class AdminController extends BaseController
private readonly Database $db, private readonly Database $db,
private readonly StatsService $stats, private readonly StatsService $stats,
private readonly PaymentGateway $payments, private readonly PaymentGateway $payments,
private readonly Mailer $mailer private readonly Mailer $mailer,
private readonly OrderStatusService $orderStatuses
) { ) {
parent::__construct($config, $auth, $cart); parent::__construct($config, $auth, $cart);
} }
@@ -132,6 +134,9 @@ final class AdminController extends BaseController
WHERE r.order_id = ? ORDER BY r.created_at DESC', WHERE r.order_id = ? ORDER BY r.created_at DESC',
[$orderId] [$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(); $staff = $this->auth->requireStaff();
$orderId = (int) ($_POST['order_id'] ?? 0); $orderId = (int) ($_POST['order_id'] ?? 0);
$status = Security::clean((string) ($_POST['status'] ?? '')); $status = Security::clean((string) ($_POST['status'] ?? ''));
$allowed = ['paid', 'preparing', 'ready', 'completed', 'cancelled']; $note = Security::clean((string) ($_POST['status_note'] ?? ''));
if (!in_array($status, $allowed, true)) { try {
Http::flash('error', 'Choose a valid order status.'); $result = $this->orderStatuses->update(
Http::redirect('/admin/orders'); $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); 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 public function refundOrder(): void
{ {
Security::verifyCsrf(); Security::verifyCsrf();
@@ -478,6 +583,22 @@ final class AdminController extends BaseController
$this->config->set($key, trim((string) $_POST[$group][$field])); $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) { foreach (['square.access_token', 'mailgun.api_key', 'database.mysql_password'] as $secretKey) {
[$group, $field] = explode('.', $secretKey, 2); [$group, $field] = explode('.', $secretKey, 2);
$secret = isset($_POST[$group]) && is_array($_POST[$group]) $secret = isset($_POST[$group]) && is_array($_POST[$group])
+8
View File
@@ -154,6 +154,14 @@ final class AuthController extends BaseController
Http::flash('error', 'Your account could not be loaded after registration.'); Http::flash('error', 'Your account could not be loaded after registration.');
Http::redirect('/login'); 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); $this->auth->login($user);
Http::flash('success', 'Welcome, ' . $user['full_name'] . '. Your account is ready.'); Http::flash('success', 'Welcome, ' . $user['full_name'] . '. Your account is ready.');
Http::redirect('/account'); Http::redirect('/account');
+45 -10
View File
@@ -11,8 +11,8 @@ use FatBottom\Core\Http;
use FatBottom\Core\Security; use FatBottom\Core\Security;
use FatBottom\Core\View; use FatBottom\Core\View;
use FatBottom\Services\CartService; use FatBottom\Services\CartService;
use FatBottom\Services\Mailer;
use FatBottom\Services\OrderService; use FatBottom\Services\OrderService;
use FatBottom\Services\OrderStatusService;
final class StoreController extends BaseController final class StoreController extends BaseController
{ {
@@ -22,7 +22,7 @@ final class StoreController extends BaseController
CartService $cart, CartService $cart,
private readonly Database $db, private readonly Database $db,
private readonly OrderService $orders, private readonly OrderService $orders,
private readonly Mailer $mailer private readonly OrderStatusService $orderStatuses
) { ) {
parent::__construct($config, $auth, $cart); parent::__construct($config, $auth, $cart);
} }
@@ -169,13 +169,11 @@ final class StoreController extends BaseController
$user ? (int) $user['id'] : null, $user ? (int) $user['id'] : null,
(string) ($_POST['square_token'] ?? '') (string) ($_POST['square_token'] ?? '')
); );
$this->mailer->send( try {
$input['customer_email'], $this->orderStatuses->sendStatusEmail($order, 'pending');
'We received order ' . $order['order_number'], } catch (\Throwable) {
'<p>Thanks, ' . htmlspecialchars($input['customer_name'], ENT_QUOTES, 'UTF-8') . '.</p>' Http::flash('warning', 'Your order was placed, but the confirmation email could not be sent.');
. '<p>Your order <strong>' . htmlspecialchars((string) $order['order_number'], ENT_QUOTES, 'UTF-8') }
. '</strong> has been paid and sent to the kitchen.</p>'
);
setcookie('fatbottom_cart', '', ['expires' => time() - 3600, 'path' => '/']); setcookie('fatbottom_cart', '', ['expires' => time() - 3600, 'path' => '/']);
$_SESSION['last_order_id'] = (int) $order['id']; $_SESSION['last_order_id'] = (int) $order['id'];
Http::redirect('/order/complete'); Http::redirect('/order/complete');
@@ -198,6 +196,44 @@ final class StoreController extends BaseController
'title' => 'Order Received', 'title' => 'Order Received',
'order' => $order, 'order' => $order,
'items' => $items, '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 +264,3 @@ final class StoreController extends BaseController
)); ));
} }
} }
+140 -1
View File
@@ -62,6 +62,11 @@ final class Database
} }
$schema = <<<'SQL' $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 ( CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE, 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) 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 ( CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
order_number TEXT NOT NULL UNIQUE, order_number TEXT NOT NULL UNIQUE,
user_id INTEGER, user_id INTEGER,
customer_id INTEGER,
cart_id INTEGER, cart_id INTEGER,
tracking_token TEXT NOT NULL DEFAULT '',
customer_name TEXT NOT NULL, customer_name TEXT NOT NULL,
customer_email TEXT NOT NULL, customer_email TEXT NOT NULL,
customer_phone TEXT NOT NULL, customer_phone TEXT NOT NULL,
order_type TEXT NOT NULL DEFAULT 'pickup', 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_status TEXT NOT NULL DEFAULT 'paid',
payment_provider TEXT NOT NULL DEFAULT 'demo', payment_provider TEXT NOT NULL DEFAULT 'demo',
payment_reference TEXT NOT NULL DEFAULT '', payment_reference TEXT NOT NULL DEFAULT '',
@@ -194,6 +218,7 @@ CREATE TABLE IF NOT EXISTS orders (
placed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, placed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_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 (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 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, user_id INTEGER,
event_type TEXT NOT NULL, event_type TEXT NOT NULL,
details TEXT NOT NULL DEFAULT '', 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, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE, FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
@@ -266,6 +295,20 @@ SQL;
$this->pdo->exec($schema); $this->pdo->exec($schema);
$this->ensureSqliteColumn('users', 'two_factor_enabled', 'INTEGER NOT NULL DEFAULT 0'); $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->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(); $this->seed();
} }
@@ -281,6 +324,102 @@ SQL;
$this->pdo->exec("ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}"); $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 seed(): void private function seed(): void
{ {
$categoryCount = (int) $this->pdo->query('SELECT COUNT(*) FROM menu_categories')->fetchColumn(); $categoryCount = (int) $this->pdo->query('SELECT COUNT(*) FROM menu_categories')->fetchColumn();
+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]
);
}
}
+21 -6
View File
@@ -11,7 +11,8 @@ final class OrderService
{ {
public function __construct( public function __construct(
private readonly Database $db, 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 = $this->db->pdo();
$pdo->beginTransaction(); $pdo->beginTransaction();
try { try {
$customerId = $this->customers->capture($customer, $userId);
$trackingToken = bin2hex(random_bytes(24));
$statement = $pdo->prepare( $statement = $pdo->prepare(
'INSERT INTO orders '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, order_type, status, payment_status, payment_provider, payment_reference,
subtotal_cents, tax_cents, total_cents, special_instructions, pickup_time) subtotal_cents, tax_cents, total_cents, special_instructions, pickup_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
); );
$statement->execute([ $statement->execute([
$orderNumber, $orderNumber,
$userId, $userId,
$customerId,
(int) $cart['id'], (int) $cart['id'],
$trackingToken,
$customer['customer_name'], $customer['customer_name'],
$customer['customer_email'], $customer['customer_email'],
$customer['customer_phone'], $customer['customer_phone'],
'pickup', 'pickup',
'paid', 'pending',
'paid', 'paid',
$payment['provider'], $payment['provider'],
$payment['reference'], $payment['reference'],
@@ -89,11 +95,20 @@ final class OrderService
} }
$pdo->prepare( $pdo->prepare(
'INSERT INTO order_events (order_id, user_id, event_type, details) VALUES (?, ?, ?, ?)' 'INSERT INTO order_events
)->execute([$orderId, $userId, 'order_placed', 'Online pickup order paid successfully.']); (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( $pdo->prepare(
"UPDATE carts SET status = 'converted', converted_at = CURRENT_TIMESTAMP WHERE id = ?" "UPDATE carts SET status = 'converted', converted_at = CURRENT_TIMESTAMP WHERE id = ?"
)->execute([(int) $cart['id']]); )->execute([(int) $cart['id']]);
$this->customers->refresh($customerId);
$pdo->commit(); $pdo->commit();
} catch (\Throwable $error) { } catch (\Throwable $error) {
$pdo->rollBack(); $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', 'Fat Bottom Grille'),
'{{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" AND DATE(o.placed_at) = CURRENT_DATE"
)['value'], )['value'],
'open_orders' => (int) $this->db->fetch( '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'], )['value'],
'abandoned_carts' => (int) $this->db->fetch( 'abandoned_carts' => (int) $this->db->fetch(
"SELECT COUNT(*) AS value FROM carts "SELECT COUNT(*) AS value FROM carts
@@ -81,7 +81,7 @@ final class StatsService
AND date(o.placed_at, 'localtime') = date('now', 'localtime')" AND date(o.placed_at, 'localtime') = date('now', 'localtime')"
)['value'], )['value'],
'open_orders' => (int) $this->db->fetch( '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'], )['value'],
'abandoned_carts' => (int) $this->db->fetch( 'abandoned_carts' => (int) $this->db->fetch(
"SELECT COUNT(*) AS value FROM carts "SELECT COUNT(*) AS value FROM carts
+15
View File
@@ -45,6 +45,21 @@ function checked(mixed $value): string
return (bool) $value ? 'checked' : ''; 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 function store_datetime(?string $value, string $format = 'M j, Y g:i A'): string
{ {
if ($value === null || $value === '') { if ($value === null || $value === '') {
+26
View File
@@ -38,6 +38,32 @@ return [
'from_name' => 'Fat Bottom Grille', 'from_name' => 'Fat Bottom Grille',
'from_email' => 'orders@fatbottomgrille.com', 'from_email' => 'orders@fatbottomgrille.com',
], ],
'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' => [ 'database' => [
'driver' => 'sqlite', 'driver' => 'sqlite',
'mysql_host' => '127.0.0.1', 'mysql_host' => '127.0.0.1',
+351 -13
View File
@@ -1866,18 +1866,34 @@ textarea:focus {
white-space: nowrap; white-space: nowrap;
} }
.status-paid, .status-pending,
.status-completed, .status-paid {
color: #8a5618;
background: #f8e5c7;
}
.status-accepted {
color: #17633f;
background: #d7f4e5;
}
.status-ready { .status-ready {
color: #246449; color: #195d83;
background: #ddefe6; background: #d9effc;
} }
.status-completed {
color: #65368c;
background: #ecdffc;
}
.status-processing,
.status-preparing { .status-preparing {
color: #765417; color: #7d2d72;
background: #f5e8c9; background: #f7dcf2;
} }
.status-declined,
.status-cancelled, .status-cancelled,
.status-refunded { .status-refunded {
color: #7d3f3f; color: #7d3f3f;
@@ -2121,18 +2137,34 @@ textarea:focus {
background: #182a37; background: #182a37;
} }
.store-body .status-paid, .store-body .status-pending,
.store-body .status-completed, .store-body .status-paid {
color: #ffbd59;
background: #3d2a14;
}
.store-body .status-accepted {
color: #6cffad;
background: #143a29;
}
.store-body .status-ready { .store-body .status-ready {
color: #9de0c0; color: #67d5ff;
background: #17382d; background: #11334a;
} }
.store-body .status-completed {
color: #cf8dff;
background: #321c49;
}
.store-body .status-processing,
.store-body .status-preparing { .store-body .status-preparing {
color: #f0ce83; color: #ff82dc;
background: #3b3019; background: #46183c;
} }
.store-body .status-declined,
.store-body .status-cancelled, .store-body .status-cancelled,
.store-body .status-refunded { .store-body .status-refunded {
color: #efaaaa; color: #efaaaa;
@@ -2698,6 +2730,213 @@ td small {
color: var(--gold-light); 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 { .refund-list {
margin-top: 18px; margin-top: 18px;
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
@@ -3062,7 +3301,8 @@ code {
.account-grid, .account-grid,
.checkout-layout, .checkout-layout,
.admin-detail-grid, .admin-detail-grid,
.newsletter-grid { .newsletter-grid,
.tracker-layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -3138,6 +3378,10 @@ code {
.special-admin-grid { .special-admin-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.crm-stat-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
} }
@media (max-width: 620px) { @media (max-width: 620px) {
@@ -3149,6 +3393,30 @@ code {
padding-block: 60px; 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 { .brand-copy strong {
font-size: 1.1rem; font-size: 1.1rem;
} }
@@ -3305,6 +3573,10 @@ code {
display: none; display: none;
} }
.admin-top-actions .theme-toggle {
display: inline-flex;
}
.admin-topbar { .admin-topbar {
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
@@ -3336,3 +3608,69 @@ code {
max-width: none; 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 navToggle = document.querySelector(".nav-toggle");
const nav = document.querySelector(".site-nav"); const nav = document.querySelector(".site-nav");
if (navToggle && nav) { if (navToggle && nav) {
@@ -82,4 +101,3 @@
initializeSquare(); initializeSquare();
})(); })();
+21 -4
View File
@@ -9,8 +9,10 @@ use FatBottom\Core\Auth;
use FatBottom\Core\Http; use FatBottom\Core\Http;
use FatBottom\Core\Router; use FatBottom\Core\Router;
use FatBottom\Services\CartService; use FatBottom\Services\CartService;
use FatBottom\Services\CustomerService;
use FatBottom\Services\Mailer; use FatBottom\Services\Mailer;
use FatBottom\Services\OrderService; use FatBottom\Services\OrderService;
use FatBottom\Services\OrderStatusService;
use FatBottom\Services\PaymentGateway; use FatBottom\Services\PaymentGateway;
use FatBottom\Services\StatsService; use FatBottom\Services\StatsService;
use FatBottom\Services\TwoFactorService; use FatBottom\Services\TwoFactorService;
@@ -23,13 +25,24 @@ $auth = new Auth($db);
$cart = new CartService($db); $cart = new CartService($db);
$mailer = new Mailer($config); $mailer = new Mailer($config);
$payments = new PaymentGateway($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); $stats = new StatsService($db);
$twoFactor = new TwoFactorService($db, $mailer, $config); $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); $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(); $path = Http::path();
$user = $auth->user(); $user = $auth->user();
@@ -45,6 +58,7 @@ $router->post('/cart/remove', [$store, 'removeFromCart']);
$router->get('/checkout', [$store, 'checkout']); $router->get('/checkout', [$store, 'checkout']);
$router->post('/checkout', [$store, 'placeOrder']); $router->post('/checkout', [$store, 'placeOrder']);
$router->get('/order/complete', [$store, 'orderComplete']); $router->get('/order/complete', [$store, 'orderComplete']);
$router->get('/order/track', [$store, 'orderTracker']);
$router->get('/login', [$authentication, 'loginForm']); $router->get('/login', [$authentication, 'loginForm']);
$router->post('/login', [$authentication, 'login']); $router->post('/login', [$authentication, 'login']);
@@ -62,6 +76,10 @@ $router->get('/admin/orders', [$admin, 'orders']);
$router->get('/admin/order', [$admin, 'orderDetail']); $router->get('/admin/order', [$admin, 'orderDetail']);
$router->post('/admin/order', [$admin, 'updateOrder']); $router->post('/admin/order', [$admin, 'updateOrder']);
$router->post('/admin/order/refund', [$admin, 'refundOrder']); $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->get('/admin/menu', [$admin, 'menu']);
$router->post('/admin/category/save', [$admin, 'saveCategory']); $router->post('/admin/category/save', [$admin, 'saveCategory']);
$router->post('/admin/item/save', [$admin, 'saveItem']); $router->post('/admin/item/save', [$admin, 'saveItem']);
@@ -79,4 +97,3 @@ $router->get('/admin/export/orders.csv', [$admin, 'exportOrdersCsv']);
$router->post('/admin/migrate/mysql', [$admin, 'migrateMySql']); $router->post('/admin/migrate/mysql', [$admin, 'migrateMySql']);
$router->dispatch($_SERVER['REQUEST_METHOD'] ?? 'GET', $path); $router->dispatch($_SERVER['REQUEST_METHOD'] ?? 'GET', $path);
+2 -2
View File
@@ -86,10 +86,10 @@
<?php foreach ($orders as $order): ?> <?php foreach ($orders as $order): ?>
<article> <article>
<div> <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> <span><?= e(store_datetime((string) $order['placed_at'])) ?></span>
</div> </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> <strong><?= money($order['total_cents']) ?></strong>
</article> </article>
<?php endforeach; ?> <?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><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($order['customer_name']) ?><small><?= e($order['customer_phone']) ?></small></td>
<td><?= e(store_datetime((string) $order['placed_at'], 'g:i A')) ?></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> <td><?= money($order['total_cents']) ?></td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
+25 -12
View File
@@ -3,7 +3,7 @@
<a class="back-link" href="<?= e(url('/admin/orders')) ?>">&larr; Back to orders</a> <a class="back-link" href="<?= e(url('/admin/orders')) ?>">&larr; Back to orders</a>
<div class="detail-title-row"> <div class="detail-title-row">
<h2><?= e($order['order_number']) ?></h2> <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> </div>
<p>Placed <?= e(store_datetime((string) $order['placed_at'], 'F j, Y \a\t g:i A')) ?></p> <p>Placed <?= e(store_datetime((string) $order['placed_at'], 'F j, Y \a\t g:i A')) ?></p>
</div> </div>
@@ -39,8 +39,11 @@
<div> <div>
<i></i> <i></i>
<span> <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> <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> <small><?= e($event['staff_name'] ?: 'System') ?> &middot; <?= e(store_datetime((string) $event['created_at'], 'M j, g:i A')) ?></small>
</span> </span>
</div> </div>
@@ -50,21 +53,28 @@
</div> </div>
<aside> <aside>
<section class="admin-panel"> <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"> <form class="form-stack" action="<?= e(url('/admin/order')) ?>" method="post">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="order_id" value="<?= (int) $order['id'] ?>"> <input type="hidden" name="order_id" value="<?= (int) $order['id'] ?>">
<label>Status <label>Reason or note
<select name="status"> <textarea name="status_note" rows="3" placeholder="Optional. A blank rejection reason will not appear to the customer."></textarea>
<?php foreach (['paid', 'preparing', 'ready', 'completed', 'cancelled'] as $status): ?>
<option value="<?= $status ?>" <?= selected($order['status'], $status) ?>><?= e(ucfirst($status)) ?></option>
<?php endforeach; ?>
</select>
</label> </label>
<label>Staff note <label class="check-control compact">
<textarea name="staff_note" rows="3" placeholder="Optional internal note"></textarea> <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> </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> </form>
</section> </section>
<section class="admin-panel"> <section class="admin-panel">
@@ -74,6 +84,9 @@
<a href="mailto:<?= e($order['customer_email']) ?>"><?= e($order['customer_email']) ?></a> <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> <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> <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> </div>
</section> </section>
<section class="admin-panel"> <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"> <input type="search" name="q" value="<?= e($query) ?>" placeholder="Order, customer, or email">
<select name="status"> <select name="status">
<option value="">All statuses</option> <option value="">All statuses</option>
<?php foreach (['paid', 'preparing', 'ready', 'completed', 'cancelled'] as $status): ?> <?php foreach (['pending', 'accepted', 'declined', 'processing', 'ready', 'completed'] as $status): ?>
<option value="<?= $status ?>" <?= selected($statusFilter, $status) ?>><?= e(ucfirst($status)) ?></option> <option value="<?= $status ?>" <?= selected($statusFilter, $status) ?>><?= e(order_status_label($status)) ?></option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
<button class="button button-small" type="submit">Filter</button> <button class="button button-small" type="submit">Filter</button>
@@ -24,7 +24,7 @@
<td><strong><?= e($order['order_number']) ?></strong></td> <td><strong><?= e($order['order_number']) ?></strong></td>
<td><?= e($order['customer_name']) ?><small><?= e($order['customer_email']) ?></small></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><?= 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> <td>
<span class="status-pill status-<?= e($order['payment_status']) ?>"><?= e(ucwords(str_replace('_', ' ', (string) $order['payment_status']))) ?></span> <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; ?> <?php if ((int) $order['refunded_cents'] > 0): ?><small><?= money($order['refunded_cents']) ?> returned</small><?php endif; ?>
+27
View File
@@ -44,6 +44,33 @@
</div> </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> <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>
<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"> <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="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> <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>
+2
View File
@@ -36,6 +36,7 @@ $isAdmin = isset($adminPage);
<h1><?= e($title ?? 'Dashboard') ?></h1> <h1><?= e($title ?? 'Dashboard') ?></h1>
</div> </div>
<div class="admin-top-actions"> <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> <a class="button button-ghost button-small" href="<?= e(url('/')) ?>" target="_blank" rel="noopener">View Store</a>
<form action="<?= e(url('/logout')) ?>" method="post"> <form action="<?= e(url('/logout')) ?>" method="post">
<?= csrf_field() ?> <?= csrf_field() ?>
@@ -80,6 +81,7 @@ $isAdmin = isset($adminPage);
<?php else: ?> <?php else: ?>
<a class="<?= active_path('/login') ?>" href="<?= e(url('/login')) ?>">Sign In</a> <a class="<?= active_path('/login') ?>" href="<?= e(url('/login')) ?>">Sign In</a>
<?php endif; ?> <?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')) ?>"> <a class="cart-link" href="<?= e(url('/cart')) ?>">
Your Order Your Order
<span class="cart-count"><?= (int) ($currentCart['item_count'] ?? 0) ?></span> <span class="cart-count"><?= (int) ($currentCart['item_count'] ?? 0) ?></span>
+1
View File
@@ -11,6 +11,7 @@
<nav class="admin-nav" aria-label="Dashboard navigation"> <nav class="admin-nav" aria-label="Dashboard navigation">
<a class="<?= $adminPage === 'dashboard' ? 'is-active' : '' ?>" href="<?= e(url('/admin')) ?>">Overview</a> <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 === '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 === '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 === 'specials' ? 'is-active' : '' ?>" href="<?= e(url('/admin/specials')) ?>">Specials</a>
<a class="<?= $adminPage === 'newsletters' ? 'is-active' : '' ?>" href="<?= e(url('/admin/newsletters')) ?>">Newsletters</a> <a class="<?= $adminPage === 'newsletters' ? 'is-active' : '' ?>" href="<?= e(url('/admin/newsletters')) ?>">Newsletters</a>
+3 -2
View File
@@ -1,7 +1,7 @@
<section class="confirmation-section section"> <section class="confirmation-section section">
<div class="container confirmation-card"> <div class="container confirmation-card">
<div class="confirmation-mark"></div> <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> <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> <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 class="confirmation-details">
@@ -15,7 +15,8 @@
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<div class="confirmation-actions"> <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; ?> <?php if ($currentUser): ?><a class="button button-outline" href="<?= e(url('/account')) ?>">View My Orders</a><?php endif; ?>
</div> </div>
</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>