diff --git a/app/Controllers/AdminController.php b/app/Controllers/AdminController.php index efe3d95..8daeb0a 100644 --- a/app/Controllers/AdminController.php +++ b/app/Controllers/AdminController.php @@ -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(); @@ -478,6 +583,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]) diff --git a/app/Controllers/AuthController.php b/app/Controllers/AuthController.php index 5846105..1b90fc3 100644 --- a/app/Controllers/AuthController.php +++ b/app/Controllers/AuthController.php @@ -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'); diff --git a/app/Controllers/StoreController.php b/app/Controllers/StoreController.php index 68e9fc0..846a776 100644 --- a/app/Controllers/StoreController.php +++ b/app/Controllers/StoreController.php @@ -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); } @@ -169,13 +169,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'], - '

Thanks, ' . htmlspecialchars($input['customer_name'], ENT_QUOTES, 'UTF-8') . '.

' - . '

Your order ' . htmlspecialchars((string) $order['order_number'], ENT_QUOTES, 'UTF-8') - . ' has been paid and sent to the kitchen.

' - ); + 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 +196,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 +264,3 @@ final class StoreController extends BaseController )); } } - diff --git a/app/Core/Database.php b/app/Core/Database.php index 0b16eb7..c75f502 100644 --- a/app/Core/Database.php +++ b/app/Core/Database.php @@ -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,20 @@ 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->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 +324,102 @@ 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 seed(): void { $categoryCount = (int) $this->pdo->query('SELECT COUNT(*) FROM menu_categories')->fetchColumn(); diff --git a/app/Services/CustomerService.php b/app/Services/CustomerService.php new file mode 100644 index 0000000..bcb6e5b --- /dev/null +++ b/app/Services/CustomerService.php @@ -0,0 +1,57 @@ +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] + ); + } +} diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php index 91b98f0..7e10ef5 100644 --- a/app/Services/OrderService.php +++ b/app/Services/OrderService.php @@ -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(); diff --git a/app/Services/OrderStatusService.php b/app/Services/OrderStatusService.php new file mode 100644 index 0000000..deb04f8 --- /dev/null +++ b/app/Services/OrderStatusService.php @@ -0,0 +1,121 @@ +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}}' => 'View your order tracker', + ])); + + $this->mailer->send((string) $order['customer_email'], $subject, '

' . $html . '

'); + } + + 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']); + } +} diff --git a/app/Services/StatsService.php b/app/Services/StatsService.php index 1ac9898..42f85c1 100644 --- a/app/Services/StatsService.php +++ b/app/Services/StatsService.php @@ -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 diff --git a/app/helpers.php b/app/helpers.php index dbec569..a3098a4 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -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 === '') { diff --git a/config/defaults.php b/config/defaults.php index cd275a0..03b9803 100644 --- a/config/defaults.php +++ b/config/defaults.php @@ -38,6 +38,32 @@ return [ 'from_name' => 'Fat Bottom Grille', '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' => [ 'driver' => 'sqlite', 'mysql_host' => '127.0.0.1', diff --git a/public/assets/app.css b/public/assets/app.css index 4b7a3e0..92cc7c6 100644 --- a/public/assets/app.css +++ b/public/assets/app.css @@ -1866,18 +1866,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; @@ -2121,18 +2137,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 +2730,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 +3301,8 @@ code { .account-grid, .checkout-layout, .admin-detail-grid, - .newsletter-grid { + .newsletter-grid, + .tracker-layout { grid-template-columns: 1fr; } @@ -3138,6 +3378,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 +3393,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; } @@ -3305,6 +3573,10 @@ code { display: none; } + .admin-top-actions .theme-toggle { + display: inline-flex; + } + .admin-topbar { flex-direction: row; align-items: center; @@ -3336,3 +3608,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; +} diff --git a/public/assets/app.js b/public/assets/app.js index 3c12468..dde1890 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -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(); })(); - diff --git a/public/index.php b/public/index.php index e008be7..d2a2bbf 100644 --- a/public/index.php +++ b/public/index.php @@ -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(); @@ -45,6 +58,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 +76,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 +97,3 @@ $router->get('/admin/export/orders.csv', [$admin, 'exportOrdersCsv']); $router->post('/admin/migrate/mysql', [$admin, 'migrateMySql']); $router->dispatch($_SERVER['REQUEST_METHOD'] ?? 'GET', $path); - diff --git a/views/account/index.php b/views/account/index.php index 2b33ef2..9c52343 100644 --- a/views/account/index.php +++ b/views/account/index.php @@ -86,10 +86,10 @@
- +
- +
diff --git a/views/admin/customer-detail.php b/views/admin/customer-detail.php new file mode 100644 index 0000000..8ff5e63 --- /dev/null +++ b/views/admin/customer-detail.php @@ -0,0 +1,73 @@ +
+
+ ← Back to customers +
+

+ +
+

Customer since

+
+ +
+ +
+
Orders
+
Lifetime value
+
First order
+
Account
+
+ +
+
+

Order History

+
+ + + + + + + + + + + + + + +
OrderPlacedStatusTotal
No linked orders.
Open
+
+
+ +
diff --git a/views/admin/customers.php b/views/admin/customers.php new file mode 100644 index 0000000..9664f00 --- /dev/null +++ b/views/admin/customers.php @@ -0,0 +1,39 @@ +
+
+ + + +
+
+ +
+
+
Customer relationships

Customer CRM

+ shown +
+
+ + + + + + + + + + + + + + + + + + +
CustomerAccountStatusOrdersLifetime ValueLast Order
No customers match those filters.
· Manage
+
+
diff --git a/views/admin/dashboard.php b/views/admin/dashboard.php index 714b19e..86d4955 100644 --- a/views/admin/dashboard.php +++ b/views/admin/dashboard.php @@ -49,7 +49,7 @@ - + diff --git a/views/admin/order-detail.php b/views/admin/order-detail.php index 303e12a..a1a6b2a 100644 --- a/views/admin/order-detail.php +++ b/views/admin/order-detail.php @@ -3,7 +3,7 @@ ← Back to orders

- +

Placed

@@ -39,8 +39,11 @@
- +

+ + + ·
@@ -50,21 +53,28 @@