Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b14f4c1796 | |||
| 03348cad79 | |||
| 3417589a7c |
@@ -1,9 +1,9 @@
|
|||||||
# Fat Bottom Grille e-Commerce
|
# Lakeside Orders
|
||||||
|
|
||||||
_Fat Bottom Grille declined to review the website or proposal. Open sourced for all. The menu is not real. Orders do not work._
|
_Demo restaurant website. The location and menu are fictional._
|
||||||
|
|
||||||
A dependency-light online food ordering system for Fat Bottom Grille at
|
A dependency-light online ordering and customer-management demo for Lakeside
|
||||||
410 W Piedmont St in Keyser, West Virginia.
|
Orders, a fictional restaurant near the water.
|
||||||
|
|
||||||
## Included
|
## Included
|
||||||
|
|
||||||
@@ -22,6 +22,7 @@ A dependency-light online food ordering system for Fat Bottom Grille at
|
|||||||
- Automatic newsletter unsubscribe links
|
- Automatic newsletter unsubscribe links
|
||||||
- SQLite by default with an optional MySQL/MariaDB copy-and-switch tool
|
- SQLite by default with an optional MySQL/MariaDB copy-and-switch tool
|
||||||
- JSON-backed business, ordering, Square, Mailgun, and database settings
|
- JSON-backed business, ordering, Square, Mailgun, and database settings
|
||||||
|
- Public-domain and Creative Commons storefront photography with attribution
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -72,7 +73,7 @@ and assets automatically include that subdirectory.
|
|||||||
|
|
||||||
## Initial Administrator
|
## Initial Administrator
|
||||||
|
|
||||||
- Email: `admin@fatbottomgrille.com`
|
- Email: `admin@lakesideorders.test`
|
||||||
- Password: `ChangeMe123!`
|
- Password: `ChangeMe123!`
|
||||||
|
|
||||||
Email 2FA is disabled by default for every account. It can be enabled from
|
Email 2FA is disabled by default for every account. It can be enabled from
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -462,6 +567,21 @@ final class AdminController extends BaseController
|
|||||||
'business.postal_code',
|
'business.postal_code',
|
||||||
'business.hours',
|
'business.hours',
|
||||||
'business.announcement',
|
'business.announcement',
|
||||||
|
'branding.location_label',
|
||||||
|
'branding.meta_description',
|
||||||
|
'branding.hero_eyebrow',
|
||||||
|
'branding.hero_heading',
|
||||||
|
'branding.hero_emphasis',
|
||||||
|
'branding.hero_intro',
|
||||||
|
'branding.hero_image',
|
||||||
|
'branding.gallery_picnic_image',
|
||||||
|
'branding.gallery_patio_image',
|
||||||
|
'branding.gallery_service_image',
|
||||||
|
'branding.story_image',
|
||||||
|
'branding.story_eyebrow',
|
||||||
|
'branding.story_heading',
|
||||||
|
'branding.story_text',
|
||||||
|
'branding.footer_tagline',
|
||||||
'square.environment',
|
'square.environment',
|
||||||
'square.application_id',
|
'square.application_id',
|
||||||
'square.location_id',
|
'square.location_id',
|
||||||
@@ -478,6 +598,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])
|
||||||
@@ -566,7 +702,9 @@ final class AdminController extends BaseController
|
|||||||
. '/unsubscribe?user=' . $recipient['id'] . '&signature=' . $signature;
|
. '/unsubscribe?user=' . $recipient['id'] . '&signature=' . $signature;
|
||||||
$html = '<p>Hi ' . htmlspecialchars((string) $recipient['full_name'], ENT_QUOTES, 'UTF-8') . ',</p>'
|
$html = '<p>Hi ' . htmlspecialchars((string) $recipient['full_name'], ENT_QUOTES, 'UTF-8') . ',</p>'
|
||||||
. '<div>' . nl2br(htmlspecialchars($content, ENT_QUOTES, 'UTF-8')) . '</div>'
|
. '<div>' . nl2br(htmlspecialchars($content, ENT_QUOTES, 'UTF-8')) . '</div>'
|
||||||
. '<hr><p style="font-size:12px">You received this because you joined the Fat Bottom Grille news list. '
|
. '<hr><p style="font-size:12px">You received this because you joined the '
|
||||||
|
. htmlspecialchars((string) $this->config->get('business.name'), ENT_QUOTES, 'UTF-8')
|
||||||
|
. ' news list. '
|
||||||
. '<a href="' . htmlspecialchars($unsubscribe, ENT_QUOTES, 'UTF-8') . '">Unsubscribe</a></p>';
|
. '<a href="' . htmlspecialchars($unsubscribe, ENT_QUOTES, 'UTF-8') . '">Unsubscribe</a></p>';
|
||||||
try {
|
try {
|
||||||
$this->mailer->send((string) $recipient['email'], $subject, $html);
|
$this->mailer->send((string) $recipient['email'], $subject, $html);
|
||||||
@@ -603,7 +741,7 @@ final class AdminController extends BaseController
|
|||||||
$this->auth->requireStaff();
|
$this->auth->requireStaff();
|
||||||
$orders = $this->db->fetchAll('SELECT * FROM orders ORDER BY placed_at DESC');
|
$orders = $this->db->fetchAll('SELECT * FROM orders ORDER BY placed_at DESC');
|
||||||
header('Content-Type: text/csv');
|
header('Content-Type: text/csv');
|
||||||
header('Content-Disposition: attachment; filename="fat-bottom-orders-' . date('Y-m-d') . '.csv"');
|
header('Content-Disposition: attachment; filename="lakeside-orders-' . date('Y-m-d') . '.csv"');
|
||||||
$output = fopen('php://output', 'wb');
|
$output = fopen('php://output', 'wb');
|
||||||
fputcsv($output, [
|
fputcsv($output, [
|
||||||
'Order', 'Placed', 'Customer', 'Email', 'Phone', 'Status', 'Payment',
|
'Order', 'Placed', 'Customer', 'Email', 'Phone', 'Status', 'Payment',
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -48,7 +48,7 @@ final class StoreController extends BaseController
|
|||||||
);
|
);
|
||||||
|
|
||||||
View::render('store/home', $this->shared([
|
View::render('store/home', $this->shared([
|
||||||
'title' => 'Keyser comfort food, ready when you are',
|
'title' => 'Lakeside food, ready when you are',
|
||||||
'categories' => $categories,
|
'categories' => $categories,
|
||||||
'specials' => $specials,
|
'specials' => $specials,
|
||||||
'featured' => $featured,
|
'featured' => $featured,
|
||||||
@@ -66,6 +66,13 @@ final class StoreController extends BaseController
|
|||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function credits(): void
|
||||||
|
{
|
||||||
|
View::render('store/credits', $this->shared([
|
||||||
|
'title' => 'Image Credits',
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
public function addToCart(): void
|
public function addToCart(): void
|
||||||
{
|
{
|
||||||
Security::verifyCsrf();
|
Security::verifyCsrf();
|
||||||
@@ -169,13 +176,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 +203,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 +271,3 @@ final class StoreController extends BaseController
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,23 @@ 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->runMigration('20260614_lakeside_orders_demo', function (): void {
|
||||||
|
$this->applyLakesideDemoBranding();
|
||||||
|
});
|
||||||
|
$this->pdo->exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_orders_tracking_token ON orders(tracking_token)');
|
||||||
|
$this->pdo->exec('CREATE INDEX IF NOT EXISTS idx_orders_customer ON orders(customer_id, placed_at)');
|
||||||
|
$this->pdo->exec('CREATE INDEX IF NOT EXISTS idx_customers_active ON customers(active, last_order_at)');
|
||||||
$this->seed();
|
$this->seed();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,6 +327,188 @@ 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 applyLakesideDemoBranding(): void
|
||||||
|
{
|
||||||
|
$categories = [
|
||||||
|
[1, 'Dockside Burgers', 'dockside-burgers', 'Flame-grilled favorites stacked for hungry lake days.'],
|
||||||
|
[2, 'Picnic Baskets', 'picnic-baskets', 'Wraps, bowls, and handhelds made to travel well.'],
|
||||||
|
[3, 'Lakehouse Plates', 'lakehouse-plates', 'Relaxed comfort food for a proper table by the water.'],
|
||||||
|
[4, 'Boardwalk Sides', 'boardwalk-sides', 'Shareable extras for the dock, blanket, or patio.'],
|
||||||
|
[5, 'Cold Drinks', 'cold-drinks', 'Fresh pours for sunny afternoons.'],
|
||||||
|
];
|
||||||
|
$categoryStatement = $this->pdo->prepare(
|
||||||
|
'UPDATE menu_categories
|
||||||
|
SET name = ?, slug = ?, description = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?'
|
||||||
|
);
|
||||||
|
foreach ($categories as [$id, $name, $slug, $description]) {
|
||||||
|
$categoryStatement->execute([$name, $slug, $description, $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = [
|
||||||
|
[1, 1, 'Lakeside Double', 'lakeside-double', 'Two grilled patties, cheddar, lettuce, tomato, pickles, and dock sauce.', 1299, '/assets/images/lakeside-burger.jpg', '["House favorite"]', 1],
|
||||||
|
[2, 1, 'Dockmaster Bacon Burger', 'dockmaster-bacon-burger', 'Two patties, smoked bacon, cheddar, crisp lettuce, tomato, and onion.', 1399, '/assets/images/lakeside-burger.jpg', '[]', 1],
|
||||||
|
[3, 1, 'Sunset Mushroom Melt', 'sunset-mushroom-melt', 'Grilled beef, Swiss, mushrooms, caramelized onions, and peppercorn mayo.', 1299, '/assets/images/lakeside-burger.jpg', '[]', 0],
|
||||||
|
[4, 2, 'Marina Chicken Wrap', 'marina-chicken-wrap', 'Chicken, spinach, tomato, cucumber, and creamy herb dressing in a soft wrap.', 1199, '/assets/images/picnic-wrap.jpg', '["Picnic pick"]', 1],
|
||||||
|
[5, 2, 'Shoreline Falafel Bowl', 'shoreline-falafel-bowl', 'Falafel, seasoned rice, bright slaw, peppers, herbs, lemon, and green tahini.', 1249, '/assets/images/shoreline-bowl.jpg', '["Vegetarian"]', 1],
|
||||||
|
[6, 3, 'Lakehouse Chicken Plate', 'lakehouse-chicken-plate', 'Seasoned chicken legs with a creamy picnic side and rotating vegetables.', 1499, '/assets/images/lakehouse-chicken.jpg', '[]', 1],
|
||||||
|
[7, 3, 'Picnic Club Wrap', 'picnic-club-wrap', 'Sliced chicken, greens, tomato, smoky bacon, and herb dressing.', 1299, '/assets/images/picnic-wrap.jpg', '[]', 0],
|
||||||
|
[8, 4, 'Boardwalk Fries', 'boardwalk-fries', 'Golden fries finished with flaky salt and your choice of dipping sauce.', 599, '/assets/images/boardwalk-fries.jpg', '["Vegetarian"]', 1],
|
||||||
|
[9, 4, 'Garden Side Bowl', 'garden-side-bowl', 'Rice, chopped vegetables, herbs, lemon, and creamy green dressing.', 649, '/assets/images/shoreline-bowl.jpg', '["Vegetarian"]', 0],
|
||||||
|
[10, 5, 'Lemon Iced Tea', 'lemon-iced-tea', 'Fresh-brewed iced tea served with lemon.', 299, '/assets/images/lemon-iced-tea.jpg', '[]', 0],
|
||||||
|
[11, 5, 'Lakeside Lemonade', 'lakeside-lemonade', 'Bright house lemonade poured over ice.', 329, '/assets/images/lemon-iced-tea.jpg', '[]', 0],
|
||||||
|
];
|
||||||
|
$itemStatement = $this->pdo->prepare(
|
||||||
|
'UPDATE menu_items
|
||||||
|
SET category_id = ?, name = ?, slug = ?, description = ?, price_cents = ?,
|
||||||
|
image_url = ?, dietary_tags = ?, featured = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?'
|
||||||
|
);
|
||||||
|
foreach ($items as [$id, $categoryId, $name, $slug, $description, $price, $image, $tags, $featured]) {
|
||||||
|
$itemStatement->execute([
|
||||||
|
$categoryId,
|
||||||
|
$name,
|
||||||
|
$slug,
|
||||||
|
$description,
|
||||||
|
$price,
|
||||||
|
$image,
|
||||||
|
$tags,
|
||||||
|
$featured,
|
||||||
|
$id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$specialStatement = $this->pdo->prepare(
|
||||||
|
'UPDATE specials
|
||||||
|
SET menu_item_id = ?, title = ?, description = ?, badge = ?, price_cents = ?,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?'
|
||||||
|
);
|
||||||
|
$specialStatement->execute([
|
||||||
|
1,
|
||||||
|
'Sunset Burger Pick',
|
||||||
|
'The Lakeside Double at its easygoing online demo price.',
|
||||||
|
'Dock Deal',
|
||||||
|
1299,
|
||||||
|
1,
|
||||||
|
]);
|
||||||
|
$specialStatement->execute([
|
||||||
|
8,
|
||||||
|
'Boardwalk Break',
|
||||||
|
'A hot order of Boardwalk Fries for the table or picnic blanket.',
|
||||||
|
'Picnic Pick',
|
||||||
|
599,
|
||||||
|
2,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->pdo->exec("UPDATE tax_rates SET name = 'Demo State Sales Tax' WHERE id = 1");
|
||||||
|
$this->pdo->exec("UPDATE tax_rates SET name = 'Lakeview Demo Tax' WHERE id = 2");
|
||||||
|
$this->pdo->exec(
|
||||||
|
"UPDATE users SET email = 'admin@lakesideorders.test'
|
||||||
|
WHERE email = 'admin@fatbottomgrille.com'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM users AS existing
|
||||||
|
WHERE existing.email = 'admin@lakesideorders.test'
|
||||||
|
)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private function seed(): void
|
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();
|
||||||
@@ -291,11 +519,11 @@ SQL;
|
|||||||
$this->pdo->beginTransaction();
|
$this->pdo->beginTransaction();
|
||||||
try {
|
try {
|
||||||
$categories = [
|
$categories = [
|
||||||
['Smash Burgers', 'smash-burgers', 'Hand-pressed beef, crisp edges, and the good stuff.', 10],
|
['Dockside Burgers', 'dockside-burgers', 'Flame-grilled favorites stacked for hungry lake days.', 10],
|
||||||
['Burritos & Bowls', 'burritos-bowls', 'Big, packed, and built your way.', 20],
|
['Picnic Baskets', 'picnic-baskets', 'Wraps, bowls, and handhelds made to travel well.', 20],
|
||||||
['Dinner Plates', 'dinner-plates', 'Comfort-food plates made for a proper supper.', 30],
|
['Lakehouse Plates', 'lakehouse-plates', 'Relaxed comfort food for a proper table by the water.', 30],
|
||||||
['Sides', 'sides', 'The supporting cast that tends to steal the show.', 40],
|
['Boardwalk Sides', 'boardwalk-sides', 'Shareable extras for the dock, blanket, or patio.', 40],
|
||||||
['Drinks', 'drinks', 'Cold drinks for hot food.', 50],
|
['Cold Drinks', 'cold-drinks', 'Fresh pours for sunny afternoons.', 50],
|
||||||
];
|
];
|
||||||
$categoryStatement = $this->pdo->prepare(
|
$categoryStatement = $this->pdo->prepare(
|
||||||
'INSERT INTO menu_categories (name, slug, description, display_order) VALUES (?, ?, ?, ?)'
|
'INSERT INTO menu_categories (name, slug, description, display_order) VALUES (?, ?, ?, ?)'
|
||||||
@@ -310,22 +538,22 @@ SQL;
|
|||||||
}
|
}
|
||||||
|
|
||||||
$items = [
|
$items = [
|
||||||
['smash-burgers', 'The Fat Bottom', 'the-fat-bottom', 'Two smashed patties, American cheese, grilled onion, pickles, and house sauce.', 1199, 1, '["House favorite"]'],
|
['dockside-burgers', 'Lakeside Double', 'lakeside-double', 'Two grilled patties, cheddar, lettuce, tomato, pickles, and dock sauce.', 1299, '/assets/images/lakeside-burger.jpg', 1, '["House favorite"]'],
|
||||||
['smash-burgers', 'Piedmont Bacon Burger', 'piedmont-bacon-burger', 'Two patties, smoked bacon, cheddar, lettuce, tomato, onion, and burger sauce.', 1349, 1, '[]'],
|
['dockside-burgers', 'Dockmaster Bacon Burger', 'dockmaster-bacon-burger', 'Two patties, smoked bacon, cheddar, crisp lettuce, tomato, and onion.', 1399, '/assets/images/lakeside-burger.jpg', 1, '[]'],
|
||||||
['smash-burgers', 'Allegheny Melt', 'allegheny-melt', 'Smashed beef, Swiss, caramelized onions, and peppercorn mayo on toasted sourdough.', 1299, 0, '[]'],
|
['dockside-burgers', 'Sunset Mushroom Melt', 'sunset-mushroom-melt', 'Grilled beef, Swiss, mushrooms, caramelized onions, and peppercorn mayo.', 1299, '/assets/images/lakeside-burger.jpg', 0, '[]'],
|
||||||
['burritos-bowls', 'Mountaineer Burrito', 'mountaineer-burrito', 'Seasoned beef, rice, black beans, queso, pico, lettuce, and crema.', 1249, 1, '["Big appetite"]'],
|
['picnic-baskets', 'Marina Chicken Wrap', 'marina-chicken-wrap', 'Chicken, spinach, tomato, cucumber, and creamy herb dressing in a soft wrap.', 1199, '/assets/images/picnic-wrap.jpg', 1, '["Picnic pick"]'],
|
||||||
['burritos-bowls', 'Chicken Fajita Bowl', 'chicken-fajita-bowl', 'Grilled chicken, peppers, onions, rice, corn salsa, avocado crema.', 1199, 0, '["Gluten conscious"]'],
|
['picnic-baskets', 'Shoreline Falafel Bowl', 'shoreline-falafel-bowl', 'Falafel, seasoned rice, bright slaw, peppers, herbs, lemon, and green tahini.', 1249, '/assets/images/shoreline-bowl.jpg', 1, '["Vegetarian"]'],
|
||||||
['dinner-plates', 'Country Fried Chicken', 'country-fried-chicken', 'Crispy chicken, peppered gravy, mashed potatoes, and green beans.', 1499, 1, '[]'],
|
['lakehouse-plates', 'Lakehouse Chicken Plate', 'lakehouse-chicken-plate', 'Seasoned chicken legs with a creamy picnic side and rotating vegetables.', 1499, '/assets/images/lakehouse-chicken.jpg', 1, '[]'],
|
||||||
['dinner-plates', 'Keyser Meatloaf', 'keyser-meatloaf', 'House meatloaf with brown gravy, mashed potatoes, and seasonal vegetables.', 1449, 0, '[]'],
|
['lakehouse-plates', 'Picnic Club Wrap', 'picnic-club-wrap', 'Sliced chicken, greens, tomato, smoky bacon, and herb dressing.', 1299, '/assets/images/picnic-wrap.jpg', 0, '[]'],
|
||||||
['sides', 'Loaded Fries', 'loaded-fries', 'Crispy fries, queso, bacon, scallions, and ranch drizzle.', 699, 1, '[]'],
|
['boardwalk-sides', 'Boardwalk Fries', 'boardwalk-fries', 'Golden fries finished with flaky salt and your choice of dipping sauce.', 599, '/assets/images/boardwalk-fries.jpg', 1, '["Vegetarian"]'],
|
||||||
['sides', 'Mac & Cheese', 'mac-cheese', 'Creamy, baked, and unapologetically cheesy.', 449, 0, '["Vegetarian"]'],
|
['boardwalk-sides', 'Garden Side Bowl', 'garden-side-bowl', 'Rice, chopped vegetables, herbs, lemon, and creamy green dressing.', 649, '/assets/images/shoreline-bowl.jpg', 0, '["Vegetarian"]'],
|
||||||
['drinks', 'Sweet Tea', 'sweet-tea', 'Fresh-brewed southern sweet tea.', 299, 0, '[]'],
|
['cold-drinks', 'Lemon Iced Tea', 'lemon-iced-tea', 'Fresh-brewed iced tea served with lemon.', 299, '/assets/images/lemon-iced-tea.jpg', 0, '[]'],
|
||||||
['drinks', 'Fountain Drink', 'fountain-drink', 'Your choice of fountain soda.', 299, 0, '[]'],
|
['cold-drinks', 'Lakeside Lemonade', 'lakeside-lemonade', 'Bright house lemonade poured over ice.', 329, '/assets/images/lemon-iced-tea.jpg', 0, '[]'],
|
||||||
];
|
];
|
||||||
$itemStatement = $this->pdo->prepare(
|
$itemStatement = $this->pdo->prepare(
|
||||||
'INSERT INTO menu_items
|
'INSERT INTO menu_items
|
||||||
(category_id, name, slug, description, price_cents, featured, dietary_tags)
|
(category_id, name, slug, description, price_cents, image_url, featured, dietary_tags)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)'
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||||
);
|
);
|
||||||
foreach ($items as $item) {
|
foreach ($items as $item) {
|
||||||
$itemStatement->execute([
|
$itemStatement->execute([
|
||||||
@@ -336,32 +564,33 @@ SQL;
|
|||||||
$item[4],
|
$item[4],
|
||||||
$item[5],
|
$item[5],
|
||||||
$item[6],
|
$item[6],
|
||||||
|
$item[7],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$fatBottomId = (int) $this->pdo->query(
|
$lakesideDoubleId = (int) $this->pdo->query(
|
||||||
"SELECT id FROM menu_items WHERE slug = 'the-fat-bottom'"
|
"SELECT id FROM menu_items WHERE slug = 'lakeside-double'"
|
||||||
)->fetchColumn();
|
)->fetchColumn();
|
||||||
$loadedFriesId = (int) $this->pdo->query(
|
$boardwalkFriesId = (int) $this->pdo->query(
|
||||||
"SELECT id FROM menu_items WHERE slug = 'loaded-fries'"
|
"SELECT id FROM menu_items WHERE slug = 'boardwalk-fries'"
|
||||||
)->fetchColumn();
|
)->fetchColumn();
|
||||||
$specialStatement = $this->pdo->prepare(
|
$specialStatement = $this->pdo->prepare(
|
||||||
'INSERT INTO specials (menu_item_id, title, description, badge, price_cents, display_order)
|
'INSERT INTO specials (menu_item_id, title, description, badge, price_cents, display_order)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)'
|
VALUES (?, ?, ?, ?, ?, ?)'
|
||||||
);
|
);
|
||||||
$specialStatement->execute([
|
$specialStatement->execute([
|
||||||
$fatBottomId,
|
$lakesideDoubleId,
|
||||||
'The Fat Bottom Deal',
|
'Sunset Burger Pick',
|
||||||
'Our signature burger at a special online price.',
|
'The Lakeside Double at its easygoing online demo price.',
|
||||||
'Online Special',
|
'Dock Deal',
|
||||||
1099,
|
1299,
|
||||||
10,
|
10,
|
||||||
]);
|
]);
|
||||||
$specialStatement->execute([
|
$specialStatement->execute([
|
||||||
$loadedFriesId,
|
$boardwalkFriesId,
|
||||||
'Loaded-Up Lunch',
|
'Boardwalk Break',
|
||||||
'Loaded fries are $1 off weekdays from 11 to 2.',
|
'A hot order of Boardwalk Fries for the table or picnic blanket.',
|
||||||
'Weekday Deal',
|
'Picnic Pick',
|
||||||
599,
|
599,
|
||||||
20,
|
20,
|
||||||
]);
|
]);
|
||||||
@@ -369,8 +598,8 @@ SQL;
|
|||||||
$taxStatement = $this->pdo->prepare(
|
$taxStatement = $this->pdo->prepare(
|
||||||
'INSERT INTO tax_rates (name, jurisdiction, rate_basis_points) VALUES (?, ?, ?)'
|
'INSERT INTO tax_rates (name, jurisdiction, rate_basis_points) VALUES (?, ?, ?)'
|
||||||
);
|
);
|
||||||
$taxStatement->execute(['West Virginia Sales Tax', 'state', 600]);
|
$taxStatement->execute(['Demo State Sales Tax', 'state', 600]);
|
||||||
$taxStatement->execute(['Keyser Municipal Tax', 'city', 100]);
|
$taxStatement->execute(['Lakeview Demo Tax', 'city', 100]);
|
||||||
|
|
||||||
$adminStatement = $this->pdo->prepare(
|
$adminStatement = $this->pdo->prepare(
|
||||||
'INSERT INTO users
|
'INSERT INTO users
|
||||||
@@ -379,7 +608,7 @@ SQL;
|
|||||||
VALUES (?, ?, ?, ?, ?, ?, 0, CURRENT_TIMESTAMP)'
|
VALUES (?, ?, ?, ?, ?, ?, 0, CURRENT_TIMESTAMP)'
|
||||||
);
|
);
|
||||||
$adminStatement->execute([
|
$adminStatement->execute([
|
||||||
'admin@fatbottomgrille.com',
|
'admin@lakesideorders.test',
|
||||||
password_hash('ChangeMe123!', PASSWORD_DEFAULT),
|
password_hash('ChangeMe123!', PASSWORD_DEFAULT),
|
||||||
'Store Administrator',
|
'Store Administrator',
|
||||||
'',
|
'',
|
||||||
|
|||||||
@@ -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]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,7 +32,7 @@ final class Mailer
|
|||||||
|
|
||||||
$from = sprintf(
|
$from = sprintf(
|
||||||
'%s <%s>',
|
'%s <%s>',
|
||||||
$this->config->get('mailgun.from_name', 'Fat Bottom Grille'),
|
$this->config->get('mailgun.from_name', 'Lakeside Orders'),
|
||||||
$this->config->get('mailgun.from_email')
|
$this->config->get('mailgun.from_email')
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -77,4 +77,3 @@ final class Mailer
|
|||||||
file_put_contents($directory . '/mail.log', $line, FILE_APPEND | LOCK_EX);
|
file_put_contents($directory . '/mail.log', $line, FILE_APPEND | LOCK_EX);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ final class MenuPdfExporter
|
|||||||
|
|
||||||
$pdf = $this->buildPdf($pages);
|
$pdf = $this->buildPdf($pages);
|
||||||
header('Content-Type: application/pdf');
|
header('Content-Type: application/pdf');
|
||||||
header('Content-Disposition: attachment; filename="fat-bottom-grille-menu.pdf"');
|
header('Content-Disposition: attachment; filename="lakeside-orders-menu.pdf"');
|
||||||
header('Content-Length: ' . strlen($pdf));
|
header('Content-Length: ' . strlen($pdf));
|
||||||
echo $pdf;
|
echo $pdf;
|
||||||
exit;
|
exit;
|
||||||
@@ -52,7 +52,7 @@ final class MenuPdfExporter
|
|||||||
|
|
||||||
$stream = "0.035 0.071 0.102 rg\n0 0 612 792 re f\n";
|
$stream = "0.035 0.071 0.102 rg\n0 0 612 792 re f\n";
|
||||||
$stream .= "0.78 0.58 0.20 rg\nBT /F1 25 Tf 46 738 Td (" . $escape($businessName) . ") Tj ET\n";
|
$stream .= "0.78 0.58 0.20 rg\nBT /F1 25 Tf 46 738 Td (" . $escape($businessName) . ") Tj ET\n";
|
||||||
$stream .= "0.91 0.92 0.90 rg\nBT /F1 10 Tf 46 718 Td (KEYSER, WEST VIRGINIA | ONLINE MENU) Tj ET\n";
|
$stream .= "0.91 0.92 0.90 rg\nBT /F1 10 Tf 46 718 Td (LAKESIDE PICKUP | ONLINE MENU) Tj ET\n";
|
||||||
$stream .= "0.78 0.58 0.20 RG\n46 704 m 566 704 l S\n";
|
$stream .= "0.78 0.58 0.20 RG\n46 704 m 566 704 l S\n";
|
||||||
$y = 678;
|
$y = 678;
|
||||||
foreach ($lines as $line) {
|
foreach ($lines as $line) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace FatBottom\Services;
|
||||||
|
|
||||||
|
use FatBottom\Core\Config;
|
||||||
|
use FatBottom\Core\Database;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class OrderStatusService
|
||||||
|
{
|
||||||
|
public const STATUSES = ['pending', 'accepted', 'declined', 'processing', 'ready', 'completed'];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly Config $config,
|
||||||
|
private readonly Database $db,
|
||||||
|
private readonly Mailer $mailer
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(
|
||||||
|
int $orderId,
|
||||||
|
string $status,
|
||||||
|
string $note,
|
||||||
|
bool $customerVisible,
|
||||||
|
bool $sendEmail,
|
||||||
|
int $staffUserId
|
||||||
|
): array {
|
||||||
|
if (!in_array($status, self::STATUSES, true) || $status === 'pending') {
|
||||||
|
throw new RuntimeException('Choose a valid order status.');
|
||||||
|
}
|
||||||
|
$order = $this->db->fetch('SELECT * FROM orders WHERE id = ?', [$orderId]);
|
||||||
|
if ($order === null) {
|
||||||
|
throw new RuntimeException('Order not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$sendEmail = $sendEmail || $status === 'accepted';
|
||||||
|
$pdo = $this->db->pdo();
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
try {
|
||||||
|
$this->db->execute(
|
||||||
|
'UPDATE orders SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||||
|
[$status, $orderId]
|
||||||
|
);
|
||||||
|
$this->db->execute(
|
||||||
|
'INSERT INTO order_events
|
||||||
|
(order_id, user_id, event_type, details, status, note, customer_visible, email_sent)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, 0)',
|
||||||
|
[
|
||||||
|
$orderId,
|
||||||
|
$staffUserId,
|
||||||
|
'status_changed',
|
||||||
|
'Status changed to ' . order_status_label($status) . ($note !== '' ? '. ' . $note : '.'),
|
||||||
|
$status,
|
||||||
|
$note,
|
||||||
|
$customerVisible && $note !== '' ? 1 : 0,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
$eventId = (int) $pdo->lastInsertId();
|
||||||
|
$pdo->commit();
|
||||||
|
} catch (\Throwable $error) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
throw $error;
|
||||||
|
}
|
||||||
|
|
||||||
|
$emailError = null;
|
||||||
|
if ($sendEmail) {
|
||||||
|
$order['status'] = $status;
|
||||||
|
try {
|
||||||
|
$this->sendStatusEmail($order, $status, $customerVisible ? $note : '');
|
||||||
|
$this->db->execute('UPDATE order_events SET email_sent = 1 WHERE id = ?', [$eventId]);
|
||||||
|
} catch (\Throwable $error) {
|
||||||
|
$emailError = $error->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['email_requested' => $sendEmail, 'email_error' => $emailError];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sendStatusEmail(array $order, string $status, string $visibleNote = ''): void
|
||||||
|
{
|
||||||
|
$subjectTemplate = (string) $this->config->get(
|
||||||
|
'order_emails.' . $status . '.subject',
|
||||||
|
'{{business_name}} order {{order_number}}: {{status_label}}'
|
||||||
|
);
|
||||||
|
$bodyTemplate = (string) $this->config->get(
|
||||||
|
'order_emails.' . $status . '.body',
|
||||||
|
"Hi {{customer_name}},\n\nYour order {{order_number}} is now {{status_label}}.\n\n{{note}}\n\n{{tracking_url}}"
|
||||||
|
);
|
||||||
|
$values = [
|
||||||
|
'{{business_name}}' => (string) $this->config->get('business.name', 'Lakeside Orders'),
|
||||||
|
'{{customer_name}}' => (string) $order['customer_name'],
|
||||||
|
'{{order_number}}' => (string) $order['order_number'],
|
||||||
|
'{{status_label}}' => order_status_label($status),
|
||||||
|
'{{note}}' => $visibleNote,
|
||||||
|
'{{rejection_reason}}' => $status === 'declined' ? $visibleNote : '',
|
||||||
|
'{{tracking_url}}' => $this->trackingUrl($order),
|
||||||
|
];
|
||||||
|
$subject = trim(strip_tags(strtr($subjectTemplate, $values)));
|
||||||
|
|
||||||
|
$html = nl2br(strtr(htmlspecialchars($bodyTemplate, ENT_QUOTES, 'UTF-8'), [
|
||||||
|
'{{business_name}}' => e($values['{{business_name}}']),
|
||||||
|
'{{customer_name}}' => e($values['{{customer_name}}']),
|
||||||
|
'{{order_number}}' => e($values['{{order_number}}']),
|
||||||
|
'{{status_label}}' => e($values['{{status_label}}']),
|
||||||
|
'{{note}}' => e($values['{{note}}']),
|
||||||
|
'{{rejection_reason}}' => e($values['{{rejection_reason}}']),
|
||||||
|
'{{tracking_url}}' => '<a href="' . e($values['{{tracking_url}}']) . '">View your order tracker</a>',
|
||||||
|
]));
|
||||||
|
|
||||||
|
$this->mailer->send((string) $order['customer_email'], $subject, '<p>' . $html . '</p>');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function trackingUrl(array $order): string
|
||||||
|
{
|
||||||
|
return rtrim((string) $this->config->get('app.url'), '/')
|
||||||
|
. '/order/track?order=' . rawurlencode((string) $order['order_number'])
|
||||||
|
. '&token=' . rawurlencode((string) $order['tracking_token']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -27,9 +27,10 @@ final class TwoFactorService
|
|||||||
);
|
);
|
||||||
|
|
||||||
$name = htmlspecialchars((string) $user['full_name'], ENT_QUOTES, 'UTF-8');
|
$name = htmlspecialchars((string) $user['full_name'], ENT_QUOTES, 'UTF-8');
|
||||||
|
$businessName = (string) $this->config->get('business.name', 'Lakeside Orders');
|
||||||
$this->mailer->send(
|
$this->mailer->send(
|
||||||
(string) $user['email'],
|
(string) $user['email'],
|
||||||
'Your Fat Bottom Grille verification code',
|
"Your {$businessName} verification code",
|
||||||
"<p>Hi {$name},</p><p>Your verification code is <strong>{$code}</strong>.</p><p>It expires in 10 minutes.</p>"
|
"<p>Hi {$name},</p><p>Your verification code is <strong>{$code}</strong>.</p><p>It expires in 10 minutes.</p>"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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 === '') {
|
||||||
|
|||||||
@@ -2,22 +2,39 @@
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'app' => [
|
'app' => [
|
||||||
'name' => 'Fat Bottom Grille',
|
'name' => 'Lakeside Orders',
|
||||||
'url' => 'http://localhost:8080',
|
'url' => 'http://localhost:8080',
|
||||||
'key' => '',
|
'key' => '',
|
||||||
'timezone' => 'America/New_York',
|
'timezone' => 'America/New_York',
|
||||||
'environment' => 'development',
|
'environment' => 'development',
|
||||||
],
|
],
|
||||||
'business' => [
|
'business' => [
|
||||||
'name' => 'Fat Bottom Grille',
|
'name' => 'Lakeside Orders',
|
||||||
'phone' => '',
|
'phone' => '',
|
||||||
'email' => '',
|
'email' => '',
|
||||||
'street' => '410 W Piedmont St',
|
'street' => '12 Marina Way',
|
||||||
'city' => 'Keyser',
|
'city' => 'Lakeview',
|
||||||
'state' => 'WV',
|
'state' => 'MI',
|
||||||
'postal_code' => '26726',
|
'postal_code' => '00000',
|
||||||
'hours' => 'Hours coming soon',
|
'hours' => 'Demo hours: daily 11 AM-8 PM',
|
||||||
'announcement' => 'Big flavor, made right here in Keyser.',
|
'announcement' => 'Demo ordering for easy days by the water.',
|
||||||
|
],
|
||||||
|
'branding' => [
|
||||||
|
'location_label' => 'Lakeview Commons',
|
||||||
|
'meta_description' => 'Order dockside burgers, picnic-ready wraps, bowls, sides, and cold drinks from Lakeside Orders.',
|
||||||
|
'hero_eyebrow' => 'Lake days. Easy pickup.',
|
||||||
|
'hero_heading' => 'Order by the water.',
|
||||||
|
'hero_emphasis' => 'Picnic anywhere.',
|
||||||
|
'hero_intro' => 'Burgers, picnic baskets, fresh bowls, and lakehouse favorites made for dock days, patio tables, and sunset suppers.',
|
||||||
|
'hero_image' => 'assets/images/lakeside-burger.jpg',
|
||||||
|
'gallery_picnic_image' => 'assets/images/lakeside-picnic.jpg',
|
||||||
|
'gallery_patio_image' => 'assets/images/patio-dining.jpg',
|
||||||
|
'gallery_service_image' => 'assets/images/garden-dining.jpg',
|
||||||
|
'story_image' => 'assets/images/lakeside-sunset.jpg',
|
||||||
|
'story_eyebrow' => 'Made for lake days',
|
||||||
|
'story_heading' => 'Good food travels well.',
|
||||||
|
'story_text' => 'Lakeside Orders is a demo restaurant experience built around relaxed pickup, picnic-ready meals, and welcoming tables near the water.',
|
||||||
|
'footer_tagline' => 'Dockside burgers, picnic-ready favorites, and a table near the water.',
|
||||||
],
|
],
|
||||||
'ordering' => [
|
'ordering' => [
|
||||||
'accepting_orders' => true,
|
'accepting_orders' => true,
|
||||||
@@ -35,14 +52,40 @@ return [
|
|||||||
'enabled' => false,
|
'enabled' => false,
|
||||||
'domain' => '',
|
'domain' => '',
|
||||||
'api_key' => '',
|
'api_key' => '',
|
||||||
'from_name' => 'Fat Bottom Grille',
|
'from_name' => 'Lakeside Orders',
|
||||||
'from_email' => 'orders@fatbottomgrille.com',
|
'from_email' => 'orders@lakesideorders.test',
|
||||||
|
],
|
||||||
|
'order_emails' => [
|
||||||
|
'pending' => [
|
||||||
|
'subject' => 'We received order {{order_number}}',
|
||||||
|
'body' => "Hi {{customer_name}},\n\nWe received your paid order {{order_number}}. It is waiting for staff acceptance.\n\n{{tracking_url}}",
|
||||||
|
],
|
||||||
|
'accepted' => [
|
||||||
|
'subject' => 'Order {{order_number}} was accepted',
|
||||||
|
'body' => "Hi {{customer_name}},\n\nYour order {{order_number}} has been accepted.\n\n{{note}}\n\n{{tracking_url}}",
|
||||||
|
],
|
||||||
|
'declined' => [
|
||||||
|
'subject' => 'Update for order {{order_number}}',
|
||||||
|
'body' => "Hi {{customer_name}},\n\nWe are sorry, but your order {{order_number}} was declined.\n\n{{rejection_reason}}\n\n{{tracking_url}}",
|
||||||
|
],
|
||||||
|
'processing' => [
|
||||||
|
'subject' => 'Order {{order_number}} is being prepared',
|
||||||
|
'body' => "Hi {{customer_name}},\n\nYour order {{order_number}} is now processing and active in the kitchen.\n\n{{note}}\n\n{{tracking_url}}",
|
||||||
|
],
|
||||||
|
'ready' => [
|
||||||
|
'subject' => 'Order {{order_number}} is ready for pickup',
|
||||||
|
'body' => "Hi {{customer_name}},\n\nYour order {{order_number}} is ready for pickup.\n\n{{note}}\n\n{{tracking_url}}",
|
||||||
|
],
|
||||||
|
'completed' => [
|
||||||
|
'subject' => 'Order {{order_number}} is complete',
|
||||||
|
'body' => "Hi {{customer_name}},\n\nYour order {{order_number}} has been marked complete.\n\n{{note}}\n\n{{tracking_url}}",
|
||||||
|
],
|
||||||
],
|
],
|
||||||
'database' => [
|
'database' => [
|
||||||
'driver' => 'sqlite',
|
'driver' => 'sqlite',
|
||||||
'mysql_host' => '127.0.0.1',
|
'mysql_host' => '127.0.0.1',
|
||||||
'mysql_port' => 3306,
|
'mysql_port' => 3306,
|
||||||
'mysql_database' => 'fatbottomgrille',
|
'mysql_database' => 'lakesideorders',
|
||||||
'mysql_username' => '',
|
'mysql_username' => '',
|
||||||
'mysql_password' => '',
|
'mysql_password' => '',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
--ink-soft: #0e1d2b;
|
--ink-soft: #0e1d2b;
|
||||||
--navy: #12283b;
|
--navy: #12283b;
|
||||||
--navy-light: #1c3a50;
|
--navy-light: #1c3a50;
|
||||||
--gold: #c8973b;
|
--gold: #35d6c1;
|
||||||
--gold-light: #e1b75e;
|
--gold-light: #8af5e6;
|
||||||
--cream: #f3eedf;
|
--cream: #f3eedf;
|
||||||
--paper: #faf7ef;
|
--paper: #faf7ef;
|
||||||
--white: #ffffff;
|
--white: #ffffff;
|
||||||
@@ -41,9 +41,10 @@ body {
|
|||||||
|
|
||||||
body.store-body {
|
body.store-body {
|
||||||
background-image:
|
background-image:
|
||||||
radial-gradient(circle at 0 0, rgba(200, 151, 59, 0.08), transparent 28%),
|
radial-gradient(circle at 0 0, rgba(53, 214, 193, 0.1), transparent 28%),
|
||||||
|
radial-gradient(circle at 92% 12%, rgba(114, 92, 255, 0.09), transparent 24%),
|
||||||
linear-gradient(rgba(255, 255, 255, 0.018) 1px, transparent 1px);
|
linear-gradient(rgba(255, 255, 255, 0.018) 1px, transparent 1px);
|
||||||
background-size: auto, 100% 22px;
|
background-size: auto, auto, 100% 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
@@ -409,8 +410,8 @@ h3 {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--cream);
|
color: var(--cream);
|
||||||
background:
|
background:
|
||||||
linear-gradient(90deg, rgba(5, 12, 18, 0.99) 0%, rgba(8, 20, 30, 0.96) 48%, rgba(8, 18, 28, 0.76) 100%),
|
linear-gradient(90deg, rgba(5, 12, 18, 0.99) 0%, rgba(8, 20, 30, 0.95) 46%, rgba(8, 18, 28, 0.48) 100%),
|
||||||
url("images/reuben-fries.jpg") center 48% / cover no-repeat;
|
url("images/lakeside-sunset.jpg") center 56% / cover no-repeat;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero::after {
|
.hero::after {
|
||||||
@@ -708,25 +709,25 @@ h3 {
|
|||||||
.special-card:nth-child(4n + 1) {
|
.special-card:nth-child(4n + 1) {
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(112deg, rgba(5, 12, 18, 0.98), rgba(8, 18, 28, 0.82)),
|
linear-gradient(112deg, rgba(5, 12, 18, 0.98), rgba(8, 18, 28, 0.82)),
|
||||||
url("images/quesadilla.jpg");
|
url("images/lakeside-burger.jpg");
|
||||||
}
|
}
|
||||||
|
|
||||||
.special-card:nth-child(4n + 2) {
|
.special-card:nth-child(4n + 2) {
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(112deg, rgba(5, 12, 18, 0.98), rgba(8, 18, 28, 0.8)),
|
linear-gradient(112deg, rgba(5, 12, 18, 0.98), rgba(8, 18, 28, 0.8)),
|
||||||
url("images/sausage-kale-soup.jpg");
|
url("images/boardwalk-fries.jpg");
|
||||||
}
|
}
|
||||||
|
|
||||||
.special-card:nth-child(4n + 3) {
|
.special-card:nth-child(4n + 3) {
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(112deg, rgba(5, 12, 18, 0.98), rgba(8, 18, 28, 0.78)),
|
linear-gradient(112deg, rgba(5, 12, 18, 0.98), rgba(8, 18, 28, 0.78)),
|
||||||
url("images/grilled-chicken-salad.jpg");
|
url("images/shoreline-bowl.jpg");
|
||||||
}
|
}
|
||||||
|
|
||||||
.special-card:nth-child(4n + 4) {
|
.special-card:nth-child(4n + 4) {
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(112deg, rgba(5, 12, 18, 0.98), rgba(8, 18, 28, 0.8)),
|
linear-gradient(112deg, rgba(5, 12, 18, 0.98), rgba(8, 18, 28, 0.8)),
|
||||||
url("images/curry-rice.jpg");
|
url("images/picnic-wrap.jpg");
|
||||||
}
|
}
|
||||||
|
|
||||||
.special-card::after {
|
.special-card::after {
|
||||||
@@ -832,43 +833,43 @@ h3 {
|
|||||||
.menu-card:nth-child(7n + 1) .menu-card-art:not(.has-image) {
|
.menu-card:nth-child(7n + 1) .menu-card-art:not(.has-image) {
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(rgba(5, 12, 18, 0.62), rgba(5, 12, 18, 0.86)),
|
linear-gradient(rgba(5, 12, 18, 0.62), rgba(5, 12, 18, 0.86)),
|
||||||
url("images/reuben-fries.jpg");
|
url("images/lakeside-burger.jpg");
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-card:nth-child(7n + 2) .menu-card-art:not(.has-image) {
|
.menu-card:nth-child(7n + 2) .menu-card-art:not(.has-image) {
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(rgba(5, 12, 18, 0.62), rgba(5, 12, 18, 0.86)),
|
linear-gradient(rgba(5, 12, 18, 0.62), rgba(5, 12, 18, 0.86)),
|
||||||
url("images/chicken-sandwich-fries.jpg");
|
url("images/picnic-wrap.jpg");
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-card:nth-child(7n + 3) .menu-card-art:not(.has-image) {
|
.menu-card:nth-child(7n + 3) .menu-card-art:not(.has-image) {
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(rgba(5, 12, 18, 0.62), rgba(5, 12, 18, 0.86)),
|
linear-gradient(rgba(5, 12, 18, 0.62), rgba(5, 12, 18, 0.86)),
|
||||||
url("images/quesadilla.jpg");
|
url("images/shoreline-bowl.jpg");
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-card:nth-child(7n + 4) .menu-card-art:not(.has-image) {
|
.menu-card:nth-child(7n + 4) .menu-card-art:not(.has-image) {
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(rgba(5, 12, 18, 0.62), rgba(5, 12, 18, 0.86)),
|
linear-gradient(rgba(5, 12, 18, 0.62), rgba(5, 12, 18, 0.86)),
|
||||||
url("images/curry-rice.jpg");
|
url("images/lakehouse-chicken.jpg");
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-card:nth-child(7n + 5) .menu-card-art:not(.has-image) {
|
.menu-card:nth-child(7n + 5) .menu-card-art:not(.has-image) {
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(rgba(5, 12, 18, 0.62), rgba(5, 12, 18, 0.86)),
|
linear-gradient(rgba(5, 12, 18, 0.62), rgba(5, 12, 18, 0.86)),
|
||||||
url("images/italian-sub.jpg");
|
url("images/boardwalk-fries.jpg");
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-card:nth-child(7n + 6) .menu-card-art:not(.has-image) {
|
.menu-card:nth-child(7n + 6) .menu-card-art:not(.has-image) {
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(rgba(5, 12, 18, 0.62), rgba(5, 12, 18, 0.86)),
|
linear-gradient(rgba(5, 12, 18, 0.62), rgba(5, 12, 18, 0.86)),
|
||||||
url("images/sausage-kale-soup.jpg");
|
url("images/lemon-iced-tea.jpg");
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-card:nth-child(7n) .menu-card-art:not(.has-image) {
|
.menu-card:nth-child(7n) .menu-card-art:not(.has-image) {
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(rgba(5, 12, 18, 0.62), rgba(5, 12, 18, 0.86)),
|
linear-gradient(rgba(5, 12, 18, 0.62), rgba(5, 12, 18, 0.86)),
|
||||||
url("images/grilled-chicken-salad.jpg");
|
url("images/patio-dining.jpg");
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-card-art:not(.has-image) {
|
.menu-card-art:not(.has-image) {
|
||||||
@@ -968,6 +969,78 @@ h3 {
|
|||||||
background: #08141f;
|
background: #08141f;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lakeside-gallery {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 92% 16%, rgba(239, 91, 161, 0.11), transparent 28%),
|
||||||
|
radial-gradient(circle at 8% 84%, rgba(31, 124, 255, 0.1), transparent 30%),
|
||||||
|
#07131d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.experience-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.experience-card {
|
||||||
|
position: relative;
|
||||||
|
min-height: 320px;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--navy);
|
||||||
|
border: 1px solid rgba(138, 245, 230, 0.2);
|
||||||
|
border-radius: var(--radius-large);
|
||||||
|
}
|
||||||
|
|
||||||
|
.experience-card-wide {
|
||||||
|
min-height: 470px;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.experience-card img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: transform 420ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.experience-card:hover img {
|
||||||
|
transform: scale(1.025);
|
||||||
|
}
|
||||||
|
|
||||||
|
.experience-card::after {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(transparent 38%, rgba(5, 12, 18, 0.9));
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.experience-card figcaption {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
right: 26px;
|
||||||
|
bottom: 24px;
|
||||||
|
left: 26px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.experience-card figcaption span {
|
||||||
|
color: var(--gold-light);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.experience-card figcaption strong {
|
||||||
|
color: var(--cream);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: clamp(1.8rem, 4vw, 3.4rem);
|
||||||
|
line-height: 1;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
.category-band {
|
.category-band {
|
||||||
padding-block: 55px;
|
padding-block: 55px;
|
||||||
color: var(--cream);
|
color: var(--cream);
|
||||||
@@ -1034,10 +1107,23 @@ h3 {
|
|||||||
min-height: 410px;
|
min-height: 410px;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--gold);
|
color: var(--gold-light);
|
||||||
background:
|
background: var(--navy);
|
||||||
linear-gradient(rgba(5, 12, 18, 0.58), rgba(5, 12, 18, 0.9)),
|
}
|
||||||
url("images/italian-sub.jpg") center / cover no-repeat;
|
|
||||||
|
.story-art::after {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(rgba(5, 12, 18, 0.16), rgba(5, 12, 18, 0.78));
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-art img {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.story-number {
|
.story-number {
|
||||||
@@ -1069,7 +1155,7 @@ h3 {
|
|||||||
color: var(--cream);
|
color: var(--cream);
|
||||||
background:
|
background:
|
||||||
linear-gradient(90deg, rgba(5, 12, 18, 0.97), rgba(8, 18, 28, 0.8)),
|
linear-gradient(90deg, rgba(5, 12, 18, 0.97), rgba(8, 18, 28, 0.8)),
|
||||||
url("images/italian-sub.jpg") center 55% / cover no-repeat;
|
url("images/lakeside-sunset.jpg") center 56% / cover no-repeat;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-hero-compact {
|
.page-hero-compact {
|
||||||
@@ -1086,6 +1172,41 @@ h3 {
|
|||||||
color: #b9c4ca;
|
color: #b9c4ca;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.narrow-content {
|
||||||
|
max-width: 880px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credits-list {
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.credits-list article {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 25px;
|
||||||
|
padding-block: 22px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.credits-list h2 {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
color: var(--cream);
|
||||||
|
font-size: 1.35rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credits-list p,
|
||||||
|
.credits-note {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credits-note {
|
||||||
|
margin-top: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
.page-hero-inner {
|
.page-hero-inner {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr minmax(340px, 0.55fr);
|
grid-template-columns: 1fr minmax(340px, 0.55fr);
|
||||||
@@ -1866,18 +1987,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;
|
||||||
@@ -1942,8 +2079,8 @@ textarea:focus {
|
|||||||
padding-top: 68px;
|
padding-top: 68px;
|
||||||
color: #b6c0c6;
|
color: #b6c0c6;
|
||||||
background:
|
background:
|
||||||
linear-gradient(90deg, rgba(5, 12, 18, 0.99), rgba(5, 12, 18, 0.94)),
|
linear-gradient(90deg, rgba(5, 12, 18, 0.99), rgba(5, 12, 18, 0.86)),
|
||||||
url("images/fat-bottom-grille-logo.jpg") right -80px center / 520px no-repeat,
|
url("images/lakeside-sunset.jpg") center 62% / cover no-repeat,
|
||||||
#050c12;
|
#050c12;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2031,14 +2168,14 @@ textarea:focus {
|
|||||||
|
|
||||||
.store-body .story-section {
|
.store-body .story-section {
|
||||||
background:
|
background:
|
||||||
linear-gradient(90deg, rgba(8, 18, 28, 0.99), rgba(8, 18, 28, 0.92)),
|
radial-gradient(circle at 86% 20%, rgba(114, 92, 255, 0.12), transparent 30%),
|
||||||
url("images/sausage-kale-soup.jpg") right center / 46% auto no-repeat;
|
#08121c;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-body .category-band {
|
.store-body .category-band {
|
||||||
background:
|
background:
|
||||||
linear-gradient(90deg, rgba(5, 12, 18, 0.98), rgba(5, 12, 18, 0.86)),
|
linear-gradient(90deg, rgba(5, 12, 18, 0.98), rgba(5, 12, 18, 0.86)),
|
||||||
url("images/curry-rice.jpg") center 56% / cover no-repeat;
|
url("images/patio-dining.jpg") center 48% / cover no-repeat;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-body .menu-category-heading {
|
.store-body .menu-category-heading {
|
||||||
@@ -2100,20 +2237,20 @@ textarea:focus {
|
|||||||
.store-body .order-summary {
|
.store-body .order-summary {
|
||||||
background:
|
background:
|
||||||
linear-gradient(145deg, rgba(8, 18, 28, 0.98), rgba(8, 18, 28, 0.88)),
|
linear-gradient(145deg, rgba(8, 18, 28, 0.98), rgba(8, 18, 28, 0.88)),
|
||||||
url("images/quesadilla.jpg") center / cover no-repeat;
|
url("images/lakeside-burger.jpg") center / cover no-repeat;
|
||||||
border: 1px solid rgba(200, 151, 59, 0.22);
|
border: 1px solid rgba(200, 151, 59, 0.22);
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-body .auth-section {
|
.store-body .auth-section {
|
||||||
background:
|
background:
|
||||||
linear-gradient(90deg, rgba(5, 12, 18, 0.97), rgba(8, 18, 28, 0.87)),
|
linear-gradient(90deg, rgba(5, 12, 18, 0.97), rgba(8, 18, 28, 0.87)),
|
||||||
url("images/grilled-chicken-salad.jpg") center / cover no-repeat;
|
url("images/garden-dining.jpg") center / cover no-repeat;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-body .confirmation-section {
|
.store-body .confirmation-section {
|
||||||
background:
|
background:
|
||||||
linear-gradient(rgba(5, 12, 18, 0.94), rgba(5, 12, 18, 0.97)),
|
linear-gradient(rgba(5, 12, 18, 0.94), rgba(5, 12, 18, 0.97)),
|
||||||
url("images/sausage-kale-soup.jpg") center / cover no-repeat;
|
url("images/lakeside-sunset.jpg") center / cover no-repeat;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-body .status-pill {
|
.store-body .status-pill {
|
||||||
@@ -2121,18 +2258,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 +2851,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 +3422,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3079,6 +3440,10 @@ code {
|
|||||||
gap: 40px;
|
gap: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.experience-card-wide {
|
||||||
|
min-height: 390px;
|
||||||
|
}
|
||||||
|
|
||||||
.story-art {
|
.story-art {
|
||||||
min-height: 320px;
|
min-height: 320px;
|
||||||
}
|
}
|
||||||
@@ -3138,6 +3503,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 +3518,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;
|
||||||
}
|
}
|
||||||
@@ -3196,6 +3589,7 @@ code {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.menu-grid,
|
.menu-grid,
|
||||||
|
.experience-grid,
|
||||||
.category-links,
|
.category-links,
|
||||||
.footer-grid,
|
.footer-grid,
|
||||||
.form-grid,
|
.form-grid,
|
||||||
@@ -3206,6 +3600,18 @@ code {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.experience-card,
|
||||||
|
.experience-card-wide {
|
||||||
|
min-height: 280px;
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credits-list article {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.category-links a {
|
.category-links a {
|
||||||
min-height: 85px;
|
min-height: 85px;
|
||||||
}
|
}
|
||||||
@@ -3305,6 +3711,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 +3746,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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Lakeside Orders image credits
|
||||||
|
|
||||||
|
The active demo photography was sourced from Wikimedia Commons. Images may be
|
||||||
|
cropped or resized by the website through CSS.
|
||||||
|
|
||||||
|
- `lakeside-sunset.jpg` - Bonnie Moreland, public domain:
|
||||||
|
https://commons.wikimedia.org/wiki/File:Sunset_Odell_Lake,_Oregon_-_Flickr_-_Bonnie_Moreland_(free_images).jpg
|
||||||
|
- `lakeside-picnic.jpg` - Gholamreza Shamsnetari, CC BY 4.0:
|
||||||
|
https://commons.wikimedia.org/wiki/File:Sizdah_Be-Dar_2018,_Valasht_Lake_(13970114000329636583522676851541_54966).jpg
|
||||||
|
- `patio-dining.jpg` - Elvert Barnes, CC BY-SA 2.0:
|
||||||
|
https://commons.wikimedia.org/wiki/File:People_eating_outside_restaurant_Baltimore.jpg
|
||||||
|
- `garden-dining.jpg` - Ibrahim Achiri, CC BY-SA 4.0:
|
||||||
|
https://commons.wikimedia.org/wiki/File:People_dining_and_socializing_at_Zen_Garden.jpg
|
||||||
|
- `lakeside-burger.jpg` - Daderot, CC0:
|
||||||
|
https://commons.wikimedia.org/wiki/File:Hamburger_with_blue_cheese,_guacamole,_and_other_toppings,_plus_French_fries_-_Massachusetts.jpg
|
||||||
|
- `picnic-wrap.jpg` - Tomwsulcer, CC0:
|
||||||
|
https://commons.wikimedia.org/wiki/File:Lunch_chicken_wrap_with_spinach_and_sauce.JPG
|
||||||
|
- `shoreline-bowl.jpg` - Andy Li, CC0:
|
||||||
|
https://commons.wikimedia.org/wiki/File:Falafel_Super_Bowl_Salad_-_Leon_2025-10-27.jpg
|
||||||
|
- `lakehouse-chicken.jpg` - Vyacheslav Argenberg, CC BY 4.0:
|
||||||
|
https://commons.wikimedia.org/wiki/File:Fried_chicken_legs,_Rostov-on-Don,_Russia.jpg
|
||||||
|
- `lemon-iced-tea.jpg` - Renee Comet, public domain:
|
||||||
|
https://commons.wikimedia.org/wiki/File:Iced_tea.jpg
|
||||||
|
- `boardwalk-fries.jpg` - Thriving Vegetarian, CC BY 2.0:
|
||||||
|
https://commons.wikimedia.org/wiki/File:Perfect_French_Fries_-_11457817933.jpg
|
||||||
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 732 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 316 KiB |
@@ -0,0 +1,27 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" role="img" aria-labelledby="title">
|
||||||
|
<title id="title">Lakeside Orders</title>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#725cff"/>
|
||||||
|
<stop offset="0.58" stop-color="#ef5ba1"/>
|
||||||
|
<stop offset="1" stop-color="#ff9d4d"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="water" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#31e6cf"/>
|
||||||
|
<stop offset="1" stop-color="#1f7cff"/>
|
||||||
|
</linearGradient>
|
||||||
|
<clipPath id="round">
|
||||||
|
<circle cx="60" cy="60" r="54"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<circle cx="60" cy="60" r="57" fill="#07131d" stroke="#8af5e6" stroke-width="4"/>
|
||||||
|
<g clip-path="url(#round)">
|
||||||
|
<rect x="6" y="6" width="108" height="62" fill="url(#sky)"/>
|
||||||
|
<circle cx="82" cy="39" r="15" fill="#fff0a8"/>
|
||||||
|
<path d="M0 67 31 40 49 58 68 35 96 62 120 45 126 78H0Z" fill="#0d2740"/>
|
||||||
|
<path d="M0 66c18-5 31-4 47 0 18 5 36 4 73-2v56H0Z" fill="url(#water)"/>
|
||||||
|
<path d="M0 82c24-8 43 5 66-1 19-5 36-7 60-1M-4 98c27-7 46 5 69-1 20-5 39-5 65 0" fill="none" stroke="#e8fffb" stroke-width="4" stroke-linecap="round" opacity=".7"/>
|
||||||
|
<path d="M35 76h50l-6 9H41Z" fill="#07131d"/>
|
||||||
|
<path d="M49 75 60 62l11 13" fill="none" stroke="#07131d" stroke-width="5" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 259 KiB |
|
After Width: | Height: | Size: 414 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 710 KiB |
|
After Width: | Height: | Size: 255 KiB |
|
After Width: | Height: | Size: 276 KiB |
@@ -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();
|
||||||
@@ -38,6 +51,7 @@ $stats->record($path, $user ? (int) $user['id'] : null);
|
|||||||
$router = new Router();
|
$router = new Router();
|
||||||
$router->get('/', [$store, 'home']);
|
$router->get('/', [$store, 'home']);
|
||||||
$router->get('/menu', [$store, 'menu']);
|
$router->get('/menu', [$store, 'menu']);
|
||||||
|
$router->get('/credits', [$store, 'credits']);
|
||||||
$router->post('/cart/add', [$store, 'addToCart']);
|
$router->post('/cart/add', [$store, 'addToCart']);
|
||||||
$router->get('/cart', [$store, 'cart']);
|
$router->get('/cart', [$store, 'cart']);
|
||||||
$router->post('/cart/update', [$store, 'updateCart']);
|
$router->post('/cart/update', [$store, 'updateCart']);
|
||||||
@@ -45,6 +59,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 +77,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 +98,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);
|
||||||
|
|
||||||
|
|||||||
@@ -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; ?>
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<div class="detail-heading">
|
||||||
|
<div>
|
||||||
|
<a class="back-link" href="<?= e(url('/admin/customers')) ?>">← 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>
|
||||||
@@ -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>
|
||||||
@@ -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; ?>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<div class="admin-panel-heading"><div><span class="eyebrow">Send an update</span><h2>New Newsletter</h2></div></div>
|
<div class="admin-panel-heading"><div><span class="eyebrow">Send an update</span><h2>New Newsletter</h2></div></div>
|
||||||
<form class="form-stack" action="<?= e(url('/admin/newsletters/send')) ?>" method="post">
|
<form class="form-stack" action="<?= e(url('/admin/newsletters/send')) ?>" method="post">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<label>Subject line<input type="text" name="subject" maxlength="140" required placeholder="This weekend at Fat Bottom Grille"></label>
|
<label>Subject line<input type="text" name="subject" maxlength="140" required placeholder="This weekend at <?= e($config->get('business.name')) ?>"></label>
|
||||||
<label>Message<textarea name="content" rows="12" required placeholder="Share a new special, schedule update, or note from the kitchen..."></textarea></label>
|
<label>Message<textarea name="content" rows="12" required placeholder="Share a new special, schedule update, or note from the kitchen..."></textarea></label>
|
||||||
<div class="notice">Every email includes an unsubscribe link. Users can also opt out from My Account.</div>
|
<div class="notice">Every email includes an unsubscribe link. Users can also opt out from My Account.</div>
|
||||||
<button class="button button-large button-block" type="submit" data-confirm="Send this newsletter to every opted-in user?">Send to <?= (int) $recipientCount ?> Subscribers</button>
|
<button class="button button-large button-block" type="submit" data-confirm="Send this newsletter to every opted-in user?">Send to <?= (int) $recipientCount ?> Subscribers</button>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<a class="back-link" href="<?= e(url('/admin/orders')) ?>">← Back to orders</a>
|
<a class="back-link" href="<?= e(url('/admin/orders')) ?>">← 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') ?> · <?= e(store_datetime((string) $event['created_at'], 'M j, g:i A')) ?></small>
|
<small><?= e($event['staff_name'] ?: 'System') ?> · <?= 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">
|
||||||
|
|||||||
@@ -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; ?>
|
||||||
|
|||||||
@@ -15,6 +15,27 @@
|
|||||||
<label class="field-wide">Announcement bar<input type="text" name="business[announcement]" value="<?= e($config->get('business.announcement')) ?>"></label>
|
<label class="field-wide">Announcement bar<input type="text" name="business[announcement]" value="<?= e($config->get('business.announcement')) ?>"></label>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="admin-panel">
|
||||||
|
<div class="admin-panel-heading"><div><span class="eyebrow">Storefront presentation</span><h2>Branding & Homepage</h2></div></div>
|
||||||
|
<div class="notice">Edit the demo message and active image paths here. Image files live in <code>public/assets/images</code>.</div>
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>Location label<input type="text" name="branding[location_label]" value="<?= e($config->get('branding.location_label')) ?>"></label>
|
||||||
|
<label>Hero eyebrow<input type="text" name="branding[hero_eyebrow]" value="<?= e($config->get('branding.hero_eyebrow')) ?>"></label>
|
||||||
|
<label>Hero heading<input type="text" name="branding[hero_heading]" value="<?= e($config->get('branding.hero_heading')) ?>"></label>
|
||||||
|
<label>Hero emphasized line<input type="text" name="branding[hero_emphasis]" value="<?= e($config->get('branding.hero_emphasis')) ?>"></label>
|
||||||
|
<label class="field-wide">Search description<textarea name="branding[meta_description]" rows="2"><?= e($config->get('branding.meta_description')) ?></textarea></label>
|
||||||
|
<label class="field-wide">Hero introduction<textarea name="branding[hero_intro]" rows="3"><?= e($config->get('branding.hero_intro')) ?></textarea></label>
|
||||||
|
<label>Hero food image<input type="text" name="branding[hero_image]" value="<?= e($config->get('branding.hero_image')) ?>"></label>
|
||||||
|
<label>Picnic gallery image<input type="text" name="branding[gallery_picnic_image]" value="<?= e($config->get('branding.gallery_picnic_image')) ?>"></label>
|
||||||
|
<label>Patio gallery image<input type="text" name="branding[gallery_patio_image]" value="<?= e($config->get('branding.gallery_patio_image')) ?>"></label>
|
||||||
|
<label>Service gallery image<input type="text" name="branding[gallery_service_image]" value="<?= e($config->get('branding.gallery_service_image')) ?>"></label>
|
||||||
|
<label>Story image<input type="text" name="branding[story_image]" value="<?= e($config->get('branding.story_image')) ?>"></label>
|
||||||
|
<label>Story eyebrow<input type="text" name="branding[story_eyebrow]" value="<?= e($config->get('branding.story_eyebrow')) ?>"></label>
|
||||||
|
<label>Story heading<input type="text" name="branding[story_heading]" value="<?= e($config->get('branding.story_heading')) ?>"></label>
|
||||||
|
<label class="field-wide">Story text<textarea name="branding[story_text]" rows="3"><?= e($config->get('branding.story_text')) ?></textarea></label>
|
||||||
|
<label class="field-wide">Footer tagline<input type="text" name="branding[footer_tagline]" value="<?= e($config->get('branding.footer_tagline')) ?>"></label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<section class="admin-panel">
|
<section class="admin-panel">
|
||||||
<div class="admin-panel-heading"><div><span class="eyebrow">Kitchen controls</span><h2>Online Ordering</h2></div></div>
|
<div class="admin-panel-heading"><div><span class="eyebrow">Kitchen controls</span><h2>Online Ordering</h2></div></div>
|
||||||
<div class="settings-row">
|
<div class="settings-row">
|
||||||
@@ -44,6 +65,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>
|
||||||
|
|||||||
@@ -30,13 +30,13 @@
|
|||||||
<input type="text" name="street_address" required autocomplete="street-address" value="<?= e($old['street_address'] ?? '') ?>">
|
<input type="text" name="street_address" required autocomplete="street-address" value="<?= e($old['street_address'] ?? '') ?>">
|
||||||
</label>
|
</label>
|
||||||
<label>City
|
<label>City
|
||||||
<input type="text" name="city" required autocomplete="address-level2" value="<?= e($old['city'] ?? 'Keyser') ?>">
|
<input type="text" name="city" required autocomplete="address-level2" value="<?= e($old['city'] ?? $config->get('business.city')) ?>">
|
||||||
</label>
|
</label>
|
||||||
<label>State
|
<label>State
|
||||||
<input type="text" name="state" required maxlength="2" autocomplete="address-level1" value="<?= e($old['state'] ?? 'WV') ?>">
|
<input type="text" name="state" required maxlength="2" autocomplete="address-level1" value="<?= e($old['state'] ?? $config->get('business.state')) ?>">
|
||||||
</label>
|
</label>
|
||||||
<label>ZIP code
|
<label>ZIP code
|
||||||
<input type="text" name="postal_code" required autocomplete="postal-code" value="<?= e($old['postal_code'] ?? '26726') ?>">
|
<input type="text" name="postal_code" required autocomplete="postal-code" value="<?= e($old['postal_code'] ?? $config->get('business.postal_code')) ?>">
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
<label>Quick check: what is <?= (int) $captcha[0] ?> + <?= (int) $captcha[1] ?>?
|
<label>Quick check: what is <?= (int) $captcha[0] ?> + <?= (int) $captcha[1] ?>?
|
||||||
<input type="number" name="captcha" required inputmode="numeric" autocomplete="off">
|
<input type="number" name="captcha" required inputmode="numeric" autocomplete="off">
|
||||||
</label>
|
</label>
|
||||||
<div class="notice">Creating an account opts you into occasional Fat Bottom Grille news. You can opt out from any email or My Account.</div>
|
<div class="notice">Creating an account opts you into occasional <?= e($config->get('business.name')) ?> news. You can opt out from any email or My Account.</div>
|
||||||
<button class="button button-large button-block" type="submit">Create My Account</button>
|
<button class="button button-large button-block" type="submit">Create My Account</button>
|
||||||
<p class="auth-switch">Already have an account? <a href="<?= e(url('/login')) ?>">Sign in</a></p>
|
<p class="auth-switch">Already have an account? <a href="<?= e(url('/login')) ?>">Sign in</a></p>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
use FatBottom\Core\View;
|
use FatBottom\Core\View;
|
||||||
|
|
||||||
$businessName = (string) $config->get('business.name', 'Fat Bottom Grille');
|
$businessName = (string) $config->get('business.name', 'Lakeside Orders');
|
||||||
$isAdmin = isset($adminPage);
|
$isAdmin = isset($adminPage);
|
||||||
?>
|
?>
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
@@ -11,7 +11,7 @@ $isAdmin = isset($adminPage);
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="theme-color" content="#09131d">
|
<meta name="theme-color" content="#09131d">
|
||||||
<meta name="description" content="Order burgers, burritos, dinner plates, sides, and drinks online from Fat Bottom Grille in Keyser, West Virginia.">
|
<meta name="description" content="<?= e($config->get('branding.meta_description')) ?>">
|
||||||
<title><?= e($title ?? $businessName) ?> | <?= e($businessName) ?></title>
|
<title><?= e($title ?? $businessName) ?> | <?= e($businessName) ?></title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
@@ -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() ?>
|
||||||
@@ -58,11 +59,11 @@ $isAdmin = isset($adminPage);
|
|||||||
<div class="container nav-wrap">
|
<div class="container nav-wrap">
|
||||||
<a class="brand" href="<?= e(url('/')) ?>" aria-label="<?= e($businessName) ?> home">
|
<a class="brand" href="<?= e(url('/')) ?>" aria-label="<?= e($businessName) ?> home">
|
||||||
<span class="brand-mark">
|
<span class="brand-mark">
|
||||||
<img src="<?= e(asset('assets/images/fat-bottom-grille-logo.jpg')) ?>" alt="">
|
<img src="<?= e(asset('assets/images/lakeside-orders-logo.svg')) ?>" alt="">
|
||||||
</span>
|
</span>
|
||||||
<span class="brand-copy">
|
<span class="brand-copy">
|
||||||
<strong><?= e($businessName) ?></strong>
|
<strong><?= e($businessName) ?></strong>
|
||||||
<small>Keyser, West Virginia</small>
|
<small><?= e($config->get('branding.location_label')) ?></small>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
<button class="nav-toggle" type="button" aria-expanded="false" aria-controls="site-nav">
|
<button class="nav-toggle" type="button" aria-expanded="false" aria-controls="site-nav">
|
||||||
@@ -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>
|
||||||
@@ -95,7 +97,7 @@ $isAdmin = isset($adminPage);
|
|||||||
<div class="container footer-grid">
|
<div class="container footer-grid">
|
||||||
<div>
|
<div>
|
||||||
<div class="footer-brand"><?= e($businessName) ?></div>
|
<div class="footer-brand"><?= e($businessName) ?></div>
|
||||||
<p>Good food, generous portions, and a seat at the table in Keyser.</p>
|
<p><?= e($config->get('branding.footer_tagline')) ?></p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2>Visit</h2>
|
<h2>Visit</h2>
|
||||||
@@ -126,7 +128,7 @@ $isAdmin = isset($adminPage);
|
|||||||
</div>
|
</div>
|
||||||
<div class="container footer-bottom">
|
<div class="container footer-bottom">
|
||||||
<span>© <?= date('Y') ?> <?= e($businessName) ?></span>
|
<span>© <?= date('Y') ?> <?= e($businessName) ?></span>
|
||||||
<span>410 W Piedmont St, Keyser, WV</span>
|
<a href="<?= e(url('/credits')) ?>">Image credits</a>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<aside class="admin-sidebar">
|
<aside class="admin-sidebar">
|
||||||
<a class="admin-brand" href="<?= e(url('/admin')) ?>">
|
<a class="admin-brand" href="<?= e(url('/admin')) ?>">
|
||||||
<span class="brand-mark">
|
<span class="brand-mark">
|
||||||
<img src="<?= e(asset('assets/images/fat-bottom-grille-logo.jpg')) ?>" alt="">
|
<img src="<?= e(asset('assets/images/lakeside-orders-logo.svg')) ?>" alt="">
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<strong><?= e($config->get('business.name')) ?></strong>
|
<strong><?= e($config->get('business.name')) ?></strong>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
<div class="summary-total"><dt>Total</dt><dd><?= money($totals['total_cents']) ?></dd></div>
|
<div class="summary-total"><dt>Total</dt><dd><?= money($totals['total_cents']) ?></dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
<a class="button button-block button-large" href="<?= e(url('/checkout')) ?>">Continue to Checkout</a>
|
<a class="button button-block button-large" href="<?= e(url('/checkout')) ?>">Continue to Checkout</a>
|
||||||
<p class="summary-note">Pickup at 410 W Piedmont St, Keyser.</p>
|
<p class="summary-note">Pickup at <?= e($config->get('business.street')) ?>, <?= e($config->get('business.city')) ?>.</p>
|
||||||
</aside>
|
</aside>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
$credits = [
|
||||||
|
['Sunset at Odell Lake', 'Bonnie Moreland', 'Public domain', 'https://commons.wikimedia.org/wiki/File:Sunset_Odell_Lake,_Oregon_-_Flickr_-_Bonnie_Moreland_(free_images).jpg'],
|
||||||
|
['Picnic at Valasht Lake', 'Gholamreza Shamsnetari', 'CC BY 4.0', 'https://commons.wikimedia.org/wiki/File:Sizdah_Be-Dar_2018,_Valasht_Lake_(13970114000329636583522676851541_54966).jpg'],
|
||||||
|
['People eating outside a restaurant', 'Elvert Barnes', 'CC BY-SA 2.0', 'https://commons.wikimedia.org/wiki/File:People_eating_outside_restaurant_Baltimore.jpg'],
|
||||||
|
['People dining at Zen Garden', 'Ibrahim Achiri', 'CC BY-SA 4.0', 'https://commons.wikimedia.org/wiki/File:People_dining_and_socializing_at_Zen_Garden.jpg'],
|
||||||
|
['Hamburger with fries', 'Daderot', 'CC0', 'https://commons.wikimedia.org/wiki/File:Hamburger_with_blue_cheese,_guacamole,_and_other_toppings,_plus_French_fries_-_Massachusetts.jpg'],
|
||||||
|
['Chicken wrap with spinach', 'Tomwsulcer', 'CC0', 'https://commons.wikimedia.org/wiki/File:Lunch_chicken_wrap_with_spinach_and_sauce.JPG'],
|
||||||
|
['Falafel bowl', 'Andy Li', 'CC0', 'https://commons.wikimedia.org/wiki/File:Falafel_Super_Bowl_Salad_-_Leon_2025-10-27.jpg'],
|
||||||
|
['Chicken legs', 'Vyacheslav Argenberg', 'CC BY 4.0', 'https://commons.wikimedia.org/wiki/File:Fried_chicken_legs,_Rostov-on-Don,_Russia.jpg'],
|
||||||
|
['Iced tea', 'Renee Comet', 'Public domain', 'https://commons.wikimedia.org/wiki/File:Iced_tea.jpg'],
|
||||||
|
['French fries', 'Thriving Vegetarian', 'CC BY 2.0', 'https://commons.wikimedia.org/wiki/File:Perfect_French_Fries_-_11457817933.jpg'],
|
||||||
|
];
|
||||||
|
?>
|
||||||
|
<section class="page-hero page-hero-compact">
|
||||||
|
<div class="container">
|
||||||
|
<span class="eyebrow">Open media</span>
|
||||||
|
<h1>Image Credits</h1>
|
||||||
|
<p>The Lakeside Orders demo uses public-domain and Creative Commons photography.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<div class="container narrow-content">
|
||||||
|
<div class="credits-list">
|
||||||
|
<?php foreach ($credits as [$title, $artist, $license, $source]): ?>
|
||||||
|
<article>
|
||||||
|
<div>
|
||||||
|
<h2><?= e($title) ?></h2>
|
||||||
|
<p><?= e($artist) ?> · <?= e($license) ?></p>
|
||||||
|
</div>
|
||||||
|
<a class="text-link" href="<?= e($source) ?>" target="_blank" rel="license noopener">View source</a>
|
||||||
|
</article>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<p class="credits-note">Photography is displayed with CSS crops for the responsive layout. No photographer or licensor endorses this demo.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
<div class="hero-texture"></div>
|
<div class="hero-texture"></div>
|
||||||
<div class="container hero-grid">
|
<div class="container hero-grid">
|
||||||
<div class="hero-copy">
|
<div class="hero-copy">
|
||||||
<span class="eyebrow">Keyser made. Come hungry.</span>
|
<span class="eyebrow"><?= e($config->get('branding.hero_eyebrow')) ?></span>
|
||||||
<h1>Big flavor.<br><em>No shortcuts.</em></h1>
|
<h1><?= e($config->get('branding.hero_heading')) ?><br><em><?= e($config->get('branding.hero_emphasis')) ?></em></h1>
|
||||||
<p>Stacked burgers, loaded burritos, and dinner plates that eat like Sunday supper. Order ahead and we will have it hot.</p>
|
<p><?= e($config->get('branding.hero_intro')) ?></p>
|
||||||
<div class="hero-actions">
|
<div class="hero-actions">
|
||||||
<a class="button button-large" href="<?= e(url('/menu')) ?>">Start Your Order</a>
|
<a class="button button-large" href="<?= e(url('/menu')) ?>">Start Your Order</a>
|
||||||
<?php if ($config->get('business.phone')): ?>
|
<?php if ($config->get('business.phone')): ?>
|
||||||
@@ -20,15 +20,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="hero-plate">
|
<div class="hero-plate">
|
||||||
<div class="hero-food-photo">
|
<div class="hero-food-photo">
|
||||||
<img src="<?= e(asset('assets/images/chicken-sandwich-fries.jpg')) ?>" alt="Chicken sandwich and hand-cut fries from Fat Bottom Grille">
|
<img src="<?= e(asset((string) $config->get('branding.hero_image'))) ?>" alt="Burger and fries ready for a lakeside meal">
|
||||||
<div class="hero-photo-caption">
|
<div class="hero-photo-caption">
|
||||||
<span>Made in Keyser</span>
|
<span>From grill to shoreline</span>
|
||||||
<strong>Hot. Fresh. Generous.</strong>
|
<strong>Fresh pickup. Easy afternoons.</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-stamp" aria-hidden="true">
|
<div class="hero-stamp" aria-hidden="true">
|
||||||
<strong>410</strong>
|
<strong>DEMO</strong>
|
||||||
<span>W Piedmont</span>
|
<span>Lakeside pickup</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<span class="eyebrow">Happening now</span>
|
<span class="eyebrow">Happening now</span>
|
||||||
<h2>Today’s Specials</h2>
|
<h2>Dockside Specials</h2>
|
||||||
</div>
|
</div>
|
||||||
<a class="text-link" href="<?= e(url('/menu')) ?>">See the full menu</a>
|
<a class="text-link" href="<?= e(url('/menu')) ?>">See the full menu</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -75,10 +75,10 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<span class="eyebrow">Built to satisfy</span>
|
<span class="eyebrow">Packed for the day</span>
|
||||||
<h2>Local Favorites</h2>
|
<h2>Shoreline Favorites</h2>
|
||||||
</div>
|
</div>
|
||||||
<p>These are the orders that keep showing up at the counter.</p>
|
<p>Easygoing food for docks, picnic blankets, and patio tables.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="menu-grid">
|
<div class="menu-grid">
|
||||||
<?php foreach ($featured as $item): ?>
|
<?php foreach ($featured as $item): ?>
|
||||||
@@ -88,6 +88,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="lakeside-gallery section">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Stay awhile</span>
|
||||||
|
<h2>Food fits the view</h2>
|
||||||
|
</div>
|
||||||
|
<p>Grab a picnic, settle into a sunny table, or let the evening stretch out under the lights.</p>
|
||||||
|
</div>
|
||||||
|
<div class="experience-grid">
|
||||||
|
<figure class="experience-card experience-card-wide">
|
||||||
|
<img src="<?= e(asset((string) $config->get('branding.gallery_picnic_image'))) ?>" alt="People enjoying a picnic beside a lake">
|
||||||
|
<figcaption><span>Take it outside</span><strong>Picnic-ready</strong></figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure class="experience-card">
|
||||||
|
<img src="<?= e(asset((string) $config->get('branding.gallery_patio_image'))) ?>" alt="Guests sitting and eating at outdoor restaurant tables">
|
||||||
|
<figcaption><span>Meet by the water</span><strong>Tables in the sun</strong></figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure class="experience-card">
|
||||||
|
<img src="<?= e(asset((string) $config->get('branding.gallery_service_image'))) ?>" alt="A lively restaurant with diners and servers moving among the tables">
|
||||||
|
<figcaption><span>Settle in</span><strong>Friendly table service</strong></figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="category-band">
|
<section class="category-band">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<span class="eyebrow">Pick your direction</span>
|
<span class="eyebrow">Pick your direction</span>
|
||||||
@@ -104,14 +130,14 @@
|
|||||||
|
|
||||||
<section class="story-section section">
|
<section class="story-section section">
|
||||||
<div class="container story-grid">
|
<div class="container story-grid">
|
||||||
<div class="story-art" aria-hidden="true">
|
<div class="story-art">
|
||||||
<span class="story-number">26726</span>
|
<img src="<?= e(asset((string) $config->get('branding.story_image'))) ?>" alt="Sunset over a calm lake and dock">
|
||||||
<div class="mountain-lines"></div>
|
<span class="story-number">LAKE</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="eyebrow">Proudly from Keyser</span>
|
<span class="eyebrow"><?= e($config->get('branding.story_eyebrow')) ?></span>
|
||||||
<h2>Food that shows up hungry.</h2>
|
<h2><?= e($config->get('branding.story_heading')) ?></h2>
|
||||||
<p>Fat Bottom Grille is the kind of place where the portions are honest and nobody leaves wondering what is for dinner. Find us at 410 W Piedmont Street, right here in Keyser.</p>
|
<p><?= e($config->get('branding.story_text')) ?></p>
|
||||||
<a class="button button-outline" href="<?= e(url('/menu')) ?>">Order for Pickup</a>
|
<a class="button button-outline" href="<?= e(url('/menu')) ?>">Order for Pickup</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<section class="page-hero">
|
<section class="page-hero">
|
||||||
<div class="container page-hero-inner">
|
<div class="container page-hero-inner">
|
||||||
<div>
|
<div>
|
||||||
<span class="eyebrow">Hot, fresh, yours</span>
|
<span class="eyebrow">From the grill to the shoreline</span>
|
||||||
<h1>Order Online</h1>
|
<h1>Order Online</h1>
|
||||||
<p>Choose your favorites and we will get the kitchen moving.</p>
|
<p>Choose a dockside favorite and we will get your lakeside pickup moving.</p>
|
||||||
</div>
|
</div>
|
||||||
<form class="menu-search" action="<?= e(url('/menu')) ?>" method="get">
|
<form class="menu-search" action="<?= e(url('/menu')) ?>" method="get">
|
||||||
<label for="menu-query">Search the menu</label>
|
<label for="menu-query">Search the menu</label>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<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>We’ve got it, <?= e(explode(' ', (string) $order['customer_name'])[0]) ?>.</h1>
|
<h1>We’ve 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">
|
||||||
<div><span>Pickup</span><strong><?= e($order['pickup_time'] ?: 'About ' . $config->get('ordering.pickup_eta_minutes', 25) . ' minutes') ?></strong></div>
|
<div><span>Pickup</span><strong><?= e($order['pickup_time'] ?: 'About ' . $config->get('ordering.pickup_eta_minutes', 25) . ' minutes') ?></strong></div>
|
||||||
<div><span>Location</span><strong>410 W Piedmont St</strong></div>
|
<div><span>Location</span><strong><?= e($config->get('business.street')) ?></strong></div>
|
||||||
<div><span>Total paid</span><strong><?= money($order['total_cents']) ?></strong></div>
|
<div><span>Total paid</span><strong><?= money($order['total_cents']) ?></strong></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="confirmation-items">
|
<div class="confirmation-items">
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||