- Implemented the full Fat Bottom Grille storefront and staff dashboard.
Highlights include configurable menus, specials, cart/checkout, taxes, customer accounts with email 2FA, newsletters, analytics, refunds, PDF/CSV exports, Square/Mailgun adapters, and optional MySQL migration. See README.md for setup and production configuration. Verified PHP/JavaScript syntax, responsive layouts, registration, 2FA, checkout, dashboard workflows, settings persistence, and PDF generation. Real Square, Mailgun, and MySQL connections require credentials/server access. Square implementation follows the official Payments API and Refunds API.
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
/storage/database/*.sqlite
|
||||||
|
/storage/database/*.sqlite-*
|
||||||
|
/storage/logs/*.log
|
||||||
|
/storage/sessions/*
|
||||||
|
/storage/config/settings.json
|
||||||
|
/.env
|
||||||
|
/.DS_Store
|
||||||
@@ -1,3 +1,135 @@
|
|||||||
# fatbottomgrille
|
# Fat Bottom Grille e-Commerce
|
||||||
|
|
||||||
The codebase for Fat Bottom Grille
|
A dependency-light online food ordering system for Fat Bottom Grille at
|
||||||
|
410 W Piedmont St in Keyser, West Virginia.
|
||||||
|
|
||||||
|
## Included
|
||||||
|
|
||||||
|
- Configurable menu categories, items, prices, availability, images, and tags
|
||||||
|
- Homepage specials that automatically adapt to the number currently active
|
||||||
|
- Persistent cart and abandoned-cart tracking
|
||||||
|
- Configurable state, city, and county tax rates
|
||||||
|
- Pickup checkout with Square Web Payments and Payments API support
|
||||||
|
- Customer accounts with email verification, email 2FA, captcha, and honeypots
|
||||||
|
- Customer order history, saved pickup details, password changes, and newsletter preferences
|
||||||
|
- Staff order queue, status history, internal notes, and Square refunds
|
||||||
|
- Staff roster and role management
|
||||||
|
- Visitor, order, sales, popular-item, subscriber, and abandoned-cart statistics
|
||||||
|
- Styled menu PDF and order CSV exports
|
||||||
|
- Mailgun transactional email and newsletter delivery
|
||||||
|
- Automatic newsletter unsubscribe links
|
||||||
|
- SQLite by default with an optional MySQL/MariaDB copy-and-switch tool
|
||||||
|
- JSON-backed business, ordering, Square, Mailgun, and database settings
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- PHP 8.1 or newer
|
||||||
|
- PHP extensions: PDO, PDO SQLite, cURL, JSON, and sessions
|
||||||
|
- Optional: `pdo_mysql` for MySQL/MariaDB migration
|
||||||
|
|
||||||
|
No Composer, Node.js, MySQL server, Square account, or Mailgun account is
|
||||||
|
required for local development.
|
||||||
|
|
||||||
|
## Start Locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php -S 127.0.0.1:8080 -t public public/router.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://127.0.0.1:8080](http://127.0.0.1:8080).
|
||||||
|
|
||||||
|
The application creates its SQLite database, JSON settings, sessions, and mail
|
||||||
|
log inside `storage/` on first request.
|
||||||
|
|
||||||
|
Before public use, enter the restaurant's verified phone number, email address,
|
||||||
|
and hours in **Dashboard > Settings**. They are intentionally not invented in
|
||||||
|
the default configuration.
|
||||||
|
|
||||||
|
## Initial Administrator
|
||||||
|
|
||||||
|
- Email: `admin@fatbottomgrille.com`
|
||||||
|
- Password: `ChangeMe123!`
|
||||||
|
|
||||||
|
Mailgun is disabled by default, so the six-digit sign-in code appears in the
|
||||||
|
browser and is also written to `storage/logs/mail.log`.
|
||||||
|
|
||||||
|
Change the initial password from **My Account** before deploying the site.
|
||||||
|
|
||||||
|
## Dashboard
|
||||||
|
|
||||||
|
Sign in, complete email verification, and open `/admin`.
|
||||||
|
|
||||||
|
The dashboard controls:
|
||||||
|
|
||||||
|
- Menu categories and food items
|
||||||
|
- Homepage specials and schedules
|
||||||
|
- Orders, fulfillment status, notes, and refunds
|
||||||
|
- Customers and staff roles
|
||||||
|
- Business information and ordering availability
|
||||||
|
- Tax rates
|
||||||
|
- Square and Mailgun credentials
|
||||||
|
- Newsletters
|
||||||
|
- Menu PDF and order CSV exports
|
||||||
|
- MySQL/MariaDB connection and migration
|
||||||
|
|
||||||
|
## Square
|
||||||
|
|
||||||
|
Checkout runs in clearly labeled demo mode until Square is enabled.
|
||||||
|
|
||||||
|
In **Dashboard > Settings**, enter matching Square environment credentials:
|
||||||
|
|
||||||
|
1. Application ID
|
||||||
|
2. Location ID
|
||||||
|
3. Access token
|
||||||
|
4. Environment (`sandbox` or `production`)
|
||||||
|
5. Enable Square payments
|
||||||
|
|
||||||
|
The browser tokenizes card details with Square Web Payments. Card data is never
|
||||||
|
posted to or stored by this application. The server sends the token and
|
||||||
|
server-calculated amount to `POST /v2/payments`.
|
||||||
|
|
||||||
|
## Mailgun
|
||||||
|
|
||||||
|
In **Dashboard > Settings**, enter the Mailgun domain, API key, sender name,
|
||||||
|
and sender email, then enable Mailgun.
|
||||||
|
|
||||||
|
When Mailgun is disabled, messages are stored locally in
|
||||||
|
`storage/logs/mail.log`. This keeps registration, 2FA, order confirmation, and
|
||||||
|
newsletter workflows testable without an email service.
|
||||||
|
|
||||||
|
## SQLite and MySQL/MariaDB
|
||||||
|
|
||||||
|
SQLite is the default runtime database:
|
||||||
|
|
||||||
|
```text
|
||||||
|
storage/database/store.sqlite
|
||||||
|
```
|
||||||
|
|
||||||
|
To migrate, save the MySQL/MariaDB connection in **Dashboard > Settings**, then
|
||||||
|
run the migration tool. It creates the target database, copies every
|
||||||
|
application table and row, and can optionally switch the application driver.
|
||||||
|
|
||||||
|
Keep SQLite selected unless the MySQL/MariaDB server is reliably available.
|
||||||
|
|
||||||
|
## Production Checklist
|
||||||
|
|
||||||
|
1. Set the public application URL and verified business contact details in the dashboard.
|
||||||
|
2. Change the initial administrator password.
|
||||||
|
3. Enable HTTPS and secure cookies at the web server.
|
||||||
|
4. Configure Mailgun and verify its sending domain.
|
||||||
|
5. Test Square in sandbox before enabling production credentials.
|
||||||
|
6. Confirm tax rates with the business's tax professional.
|
||||||
|
7. Back up `storage/database/store.sqlite` and `storage/config/settings.json`.
|
||||||
|
8. Point the web server document root to `public/`.
|
||||||
|
|
||||||
|
## Project Layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/Core/ HTTP, configuration, database, auth, security, and views
|
||||||
|
app/Controllers/ Storefront, account, and dashboard request handlers
|
||||||
|
app/Services/ Cart, orders, payment, email, stats, PDF, and migration
|
||||||
|
config/ Safe application defaults
|
||||||
|
public/ Web document root and frontend assets
|
||||||
|
views/ Storefront, account, and dashboard templates
|
||||||
|
storage/ Runtime database, JSON settings, sessions, and logs
|
||||||
|
```
|
||||||
|
|||||||
@@ -0,0 +1,651 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace FatBottom\Controllers;
|
||||||
|
|
||||||
|
use FatBottom\Core\Auth;
|
||||||
|
use FatBottom\Core\Config;
|
||||||
|
use FatBottom\Core\Database;
|
||||||
|
use FatBottom\Core\Http;
|
||||||
|
use FatBottom\Core\Security;
|
||||||
|
use FatBottom\Core\View;
|
||||||
|
use FatBottom\Services\CartService;
|
||||||
|
use FatBottom\Services\Mailer;
|
||||||
|
use FatBottom\Services\MenuPdfExporter;
|
||||||
|
use FatBottom\Services\MySqlMigrator;
|
||||||
|
use FatBottom\Services\PaymentGateway;
|
||||||
|
use FatBottom\Services\StatsService;
|
||||||
|
|
||||||
|
final class AdminController extends BaseController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
Config $config,
|
||||||
|
Auth $auth,
|
||||||
|
CartService $cart,
|
||||||
|
private readonly Database $db,
|
||||||
|
private readonly StatsService $stats,
|
||||||
|
private readonly PaymentGateway $payments,
|
||||||
|
private readonly Mailer $mailer
|
||||||
|
) {
|
||||||
|
parent::__construct($config, $auth, $cart);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dashboard(): void
|
||||||
|
{
|
||||||
|
$this->auth->requireStaff();
|
||||||
|
$recentOrders = $this->db->fetchAll(
|
||||||
|
'SELECT * FROM orders ORDER BY placed_at DESC LIMIT 10'
|
||||||
|
);
|
||||||
|
$popularItems = $this->db->fetchAll(
|
||||||
|
'SELECT oi.item_name, SUM(oi.quantity) AS quantity, SUM(oi.line_total_cents) AS sales_cents
|
||||||
|
FROM order_items oi
|
||||||
|
JOIN orders o ON o.id = oi.order_id
|
||||||
|
WHERE o.payment_status = ?
|
||||||
|
GROUP BY oi.item_name
|
||||||
|
ORDER BY quantity DESC LIMIT 6',
|
||||||
|
['paid']
|
||||||
|
);
|
||||||
|
$traffic = $this->db->isSqlite()
|
||||||
|
? $this->db->fetchAll(
|
||||||
|
"SELECT date(created_at, 'localtime') AS day, COUNT(DISTINCT session_id) AS visitors
|
||||||
|
FROM visitor_events
|
||||||
|
WHERE created_at >= datetime('now', '-6 days')
|
||||||
|
GROUP BY date(created_at, 'localtime')
|
||||||
|
ORDER BY day"
|
||||||
|
)
|
||||||
|
: $this->db->fetchAll(
|
||||||
|
"SELECT DATE(created_at) AS day, COUNT(DISTINCT session_id) AS visitors
|
||||||
|
FROM visitor_events
|
||||||
|
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 6 DAY)
|
||||||
|
GROUP BY DATE(created_at)
|
||||||
|
ORDER BY day"
|
||||||
|
);
|
||||||
|
|
||||||
|
View::render('admin/dashboard', $this->shared([
|
||||||
|
'title' => 'Dashboard',
|
||||||
|
'adminPage' => 'dashboard',
|
||||||
|
'stats' => $this->stats->dashboard(),
|
||||||
|
'recentOrders' => $recentOrders,
|
||||||
|
'popularItems' => $popularItems,
|
||||||
|
'traffic' => $traffic,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function orders(): void
|
||||||
|
{
|
||||||
|
$this->auth->requireStaff();
|
||||||
|
$status = Security::clean((string) ($_GET['status'] ?? ''));
|
||||||
|
$query = Security::clean((string) ($_GET['q'] ?? ''));
|
||||||
|
$where = ['1 = 1'];
|
||||||
|
$parameters = [];
|
||||||
|
if ($status !== '') {
|
||||||
|
$where[] = 'o.status = ?';
|
||||||
|
$parameters[] = $status;
|
||||||
|
}
|
||||||
|
if ($query !== '') {
|
||||||
|
$where[] = '(o.order_number LIKE ? OR o.customer_name LIKE ? OR o.customer_email LIKE ?)';
|
||||||
|
$parameters[] = '%' . $query . '%';
|
||||||
|
$parameters[] = '%' . $query . '%';
|
||||||
|
$parameters[] = '%' . $query . '%';
|
||||||
|
}
|
||||||
|
$orders = $this->db->fetchAll(
|
||||||
|
'SELECT o.*, COALESCE(SUM(r.amount_cents), 0) AS refunded_cents
|
||||||
|
FROM orders o
|
||||||
|
LEFT JOIN refunds r ON r.order_id = o.id
|
||||||
|
WHERE ' . implode(' AND ', $where) . '
|
||||||
|
GROUP BY o.id
|
||||||
|
ORDER BY o.placed_at DESC LIMIT 200',
|
||||||
|
$parameters
|
||||||
|
);
|
||||||
|
View::render('admin/orders', $this->shared([
|
||||||
|
'title' => 'Orders',
|
||||||
|
'adminPage' => 'orders',
|
||||||
|
'orders' => $orders,
|
||||||
|
'statusFilter' => $status,
|
||||||
|
'query' => $query,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function orderDetail(): void
|
||||||
|
{
|
||||||
|
$this->auth->requireStaff();
|
||||||
|
$orderId = (int) ($_GET['id'] ?? 0);
|
||||||
|
$order = $this->db->fetch('SELECT * FROM orders WHERE id = ?', [$orderId]);
|
||||||
|
if ($order === null) {
|
||||||
|
Http::abort(404, 'Order not found.');
|
||||||
|
}
|
||||||
|
View::render('admin/order-detail', $this->shared([
|
||||||
|
'title' => 'Order ' . $order['order_number'],
|
||||||
|
'adminPage' => 'orders',
|
||||||
|
'order' => $order,
|
||||||
|
'items' => $this->db->fetchAll('SELECT * FROM order_items WHERE order_id = ?', [$orderId]),
|
||||||
|
'events' => $this->db->fetchAll(
|
||||||
|
'SELECT oe.*, u.full_name AS staff_name
|
||||||
|
FROM order_events oe LEFT JOIN users u ON u.id = oe.user_id
|
||||||
|
WHERE oe.order_id = ? ORDER BY oe.created_at DESC, oe.id DESC',
|
||||||
|
[$orderId]
|
||||||
|
),
|
||||||
|
'refunds' => $this->db->fetchAll(
|
||||||
|
'SELECT r.*, u.full_name AS staff_name
|
||||||
|
FROM refunds r LEFT JOIN users u ON u.id = r.staff_user_id
|
||||||
|
WHERE r.order_id = ? ORDER BY r.created_at DESC',
|
||||||
|
[$orderId]
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateOrder(): void
|
||||||
|
{
|
||||||
|
Security::verifyCsrf();
|
||||||
|
$staff = $this->auth->requireStaff();
|
||||||
|
$orderId = (int) ($_POST['order_id'] ?? 0);
|
||||||
|
$status = Security::clean((string) ($_POST['status'] ?? ''));
|
||||||
|
$allowed = ['paid', 'preparing', 'ready', 'completed', 'cancelled'];
|
||||||
|
if (!in_array($status, $allowed, true)) {
|
||||||
|
Http::flash('error', 'Choose a valid order status.');
|
||||||
|
Http::redirect('/admin/orders');
|
||||||
|
}
|
||||||
|
$notes = Security::clean((string) ($_POST['staff_note'] ?? ''));
|
||||||
|
$this->db->execute(
|
||||||
|
'UPDATE orders SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||||
|
[$status, $orderId]
|
||||||
|
);
|
||||||
|
$this->db->execute(
|
||||||
|
'INSERT INTO order_events (order_id, user_id, event_type, details) VALUES (?, ?, ?, ?)',
|
||||||
|
[$orderId, (int) $staff['id'], 'status_changed', 'Status changed to ' . $status . ($notes ? '. ' . $notes : '.')]
|
||||||
|
);
|
||||||
|
Http::flash('success', 'Order status updated.');
|
||||||
|
Http::redirect('/admin/order?id=' . $orderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function refundOrder(): void
|
||||||
|
{
|
||||||
|
Security::verifyCsrf();
|
||||||
|
$staff = $this->auth->requireStaff();
|
||||||
|
$orderId = (int) ($_POST['order_id'] ?? 0);
|
||||||
|
$order = $this->db->fetch(
|
||||||
|
'SELECT o.*, COALESCE(SUM(r.amount_cents), 0) AS refunded_cents
|
||||||
|
FROM orders o LEFT JOIN refunds r ON r.order_id = o.id
|
||||||
|
WHERE o.id = ? GROUP BY o.id',
|
||||||
|
[$orderId]
|
||||||
|
);
|
||||||
|
if ($order === null) {
|
||||||
|
Http::abort(404, 'Order not found.');
|
||||||
|
}
|
||||||
|
$amountCents = (int) round(((float) ($_POST['amount'] ?? 0)) * 100);
|
||||||
|
$available = (int) $order['total_cents'] - (int) $order['refunded_cents'];
|
||||||
|
$reason = Security::clean((string) ($_POST['reason'] ?? 'Staff refund'));
|
||||||
|
if ($amountCents <= 0 || $amountCents > $available) {
|
||||||
|
Http::flash('error', 'Refund amount must be greater than zero and no more than the remaining paid amount.');
|
||||||
|
Http::redirect('/admin/order?id=' . $orderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$refund = $this->payments->refund((string) $order['payment_reference'], $amountCents, $reason);
|
||||||
|
$this->db->execute(
|
||||||
|
'INSERT INTO refunds (order_id, staff_user_id, amount_cents, reason, provider_reference)
|
||||||
|
VALUES (?, ?, ?, ?, ?)',
|
||||||
|
[$orderId, (int) $staff['id'], $amountCents, $reason, $refund['reference']]
|
||||||
|
);
|
||||||
|
$newRefunded = (int) $order['refunded_cents'] + $amountCents;
|
||||||
|
$paymentStatus = $newRefunded >= (int) $order['total_cents'] ? 'refunded' : 'partially_refunded';
|
||||||
|
$this->db->execute('UPDATE orders SET payment_status = ? WHERE id = ?', [$paymentStatus, $orderId]);
|
||||||
|
$this->db->execute(
|
||||||
|
'INSERT INTO order_events (order_id, user_id, event_type, details) VALUES (?, ?, ?, ?)',
|
||||||
|
[$orderId, (int) $staff['id'], 'refund_issued', sprintf('$%.2f refunded: %s', $amountCents / 100, $reason)]
|
||||||
|
);
|
||||||
|
Http::flash('success', 'Refund issued successfully.');
|
||||||
|
} catch (\Throwable $error) {
|
||||||
|
Http::flash('error', $error->getMessage());
|
||||||
|
}
|
||||||
|
Http::redirect('/admin/order?id=' . $orderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function menu(): void
|
||||||
|
{
|
||||||
|
$this->auth->requireStaff();
|
||||||
|
$categories = $this->db->fetchAll('SELECT * FROM menu_categories ORDER BY display_order, name');
|
||||||
|
$items = $this->db->fetchAll(
|
||||||
|
'SELECT mi.*, mc.name AS category_name
|
||||||
|
FROM menu_items mi JOIN menu_categories mc ON mc.id = mi.category_id
|
||||||
|
ORDER BY mc.display_order, mi.display_order, mi.name'
|
||||||
|
);
|
||||||
|
View::render('admin/menu', $this->shared([
|
||||||
|
'title' => 'Menu Manager',
|
||||||
|
'adminPage' => 'menu',
|
||||||
|
'categories' => $categories,
|
||||||
|
'items' => $items,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveCategory(): void
|
||||||
|
{
|
||||||
|
Security::verifyCsrf();
|
||||||
|
$this->auth->requireStaff();
|
||||||
|
$id = (int) ($_POST['id'] ?? 0);
|
||||||
|
$name = Security::clean((string) ($_POST['name'] ?? ''));
|
||||||
|
if ($name === '') {
|
||||||
|
Http::flash('error', 'Category name is required.');
|
||||||
|
Http::redirect('/admin/menu');
|
||||||
|
}
|
||||||
|
$values = [
|
||||||
|
$name,
|
||||||
|
Security::slug((string) ($_POST['slug'] ?? $name)),
|
||||||
|
Security::clean((string) ($_POST['description'] ?? '')),
|
||||||
|
(int) ($_POST['display_order'] ?? 0),
|
||||||
|
isset($_POST['active']) ? 1 : 0,
|
||||||
|
];
|
||||||
|
if ($id > 0) {
|
||||||
|
$values[] = $id;
|
||||||
|
$this->db->execute(
|
||||||
|
'UPDATE menu_categories SET name = ?, slug = ?, description = ?, display_order = ?, active = ?,
|
||||||
|
updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||||
|
$values
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$this->db->execute(
|
||||||
|
'INSERT INTO menu_categories (name, slug, description, display_order, active) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
$values
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Http::flash('success', 'Menu category saved.');
|
||||||
|
Http::redirect('/admin/menu');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveItem(): void
|
||||||
|
{
|
||||||
|
Security::verifyCsrf();
|
||||||
|
$this->auth->requireStaff();
|
||||||
|
$id = (int) ($_POST['id'] ?? 0);
|
||||||
|
$name = Security::clean((string) ($_POST['name'] ?? ''));
|
||||||
|
$categoryId = (int) ($_POST['category_id'] ?? 0);
|
||||||
|
$priceCents = (int) round(((float) ($_POST['price'] ?? 0)) * 100);
|
||||||
|
if ($name === '' || $categoryId < 1 || $priceCents < 0) {
|
||||||
|
Http::flash('error', 'Item name, category, and a valid price are required.');
|
||||||
|
Http::redirect('/admin/menu');
|
||||||
|
}
|
||||||
|
$compareAt = trim((string) ($_POST['compare_at_price'] ?? '')) === ''
|
||||||
|
? null
|
||||||
|
: (int) round(((float) $_POST['compare_at_price']) * 100);
|
||||||
|
$tags = array_values(array_filter(array_map(
|
||||||
|
static fn (string $tag): string => Security::clean($tag),
|
||||||
|
explode(',', (string) ($_POST['dietary_tags'] ?? ''))
|
||||||
|
)));
|
||||||
|
$values = [
|
||||||
|
$categoryId,
|
||||||
|
$name,
|
||||||
|
Security::slug((string) ($_POST['slug'] ?? $name)),
|
||||||
|
Security::clean((string) ($_POST['description'] ?? '')),
|
||||||
|
$priceCents,
|
||||||
|
$compareAt,
|
||||||
|
Security::clean((string) ($_POST['image_url'] ?? '')),
|
||||||
|
json_encode($tags, JSON_UNESCAPED_SLASHES),
|
||||||
|
isset($_POST['featured']) ? 1 : 0,
|
||||||
|
isset($_POST['active']) ? 1 : 0,
|
||||||
|
(int) ($_POST['display_order'] ?? 0),
|
||||||
|
];
|
||||||
|
if ($id > 0) {
|
||||||
|
$values[] = $id;
|
||||||
|
$this->db->execute(
|
||||||
|
'UPDATE menu_items SET category_id = ?, name = ?, slug = ?, description = ?, price_cents = ?,
|
||||||
|
compare_at_cents = ?, image_url = ?, dietary_tags = ?, featured = ?, active = ?,
|
||||||
|
display_order = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||||
|
$values
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$this->db->execute(
|
||||||
|
'INSERT INTO menu_items
|
||||||
|
(category_id, name, slug, description, price_cents, compare_at_cents, image_url,
|
||||||
|
dietary_tags, featured, active, display_order)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||||
|
$values
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Http::flash('success', 'Menu item saved.');
|
||||||
|
Http::redirect('/admin/menu');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function specials(): void
|
||||||
|
{
|
||||||
|
$this->auth->requireStaff();
|
||||||
|
View::render('admin/specials', $this->shared([
|
||||||
|
'title' => 'Homepage Specials',
|
||||||
|
'adminPage' => 'specials',
|
||||||
|
'specials' => $this->db->fetchAll(
|
||||||
|
'SELECT s.*, mi.name AS item_name
|
||||||
|
FROM specials s LEFT JOIN menu_items mi ON mi.id = s.menu_item_id
|
||||||
|
ORDER BY s.display_order, s.id'
|
||||||
|
),
|
||||||
|
'items' => $this->db->fetchAll('SELECT id, name FROM menu_items WHERE active = 1 ORDER BY name'),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveSpecial(): void
|
||||||
|
{
|
||||||
|
Security::verifyCsrf();
|
||||||
|
$this->auth->requireStaff();
|
||||||
|
$id = (int) ($_POST['id'] ?? 0);
|
||||||
|
$title = Security::clean((string) ($_POST['title'] ?? ''));
|
||||||
|
if ($title === '') {
|
||||||
|
Http::flash('error', 'Special title is required.');
|
||||||
|
Http::redirect('/admin/specials');
|
||||||
|
}
|
||||||
|
$price = trim((string) ($_POST['price'] ?? '')) === ''
|
||||||
|
? null
|
||||||
|
: (int) round(((float) $_POST['price']) * 100);
|
||||||
|
$values = [
|
||||||
|
(int) ($_POST['menu_item_id'] ?? 0) ?: null,
|
||||||
|
$title,
|
||||||
|
Security::clean((string) ($_POST['description'] ?? '')),
|
||||||
|
Security::clean((string) ($_POST['badge'] ?? 'Special')),
|
||||||
|
$price,
|
||||||
|
utc_datetime(Security::clean((string) ($_POST['starts_at'] ?? ''))),
|
||||||
|
utc_datetime(Security::clean((string) ($_POST['ends_at'] ?? ''))),
|
||||||
|
isset($_POST['active']) ? 1 : 0,
|
||||||
|
(int) ($_POST['display_order'] ?? 0),
|
||||||
|
];
|
||||||
|
if ($id > 0) {
|
||||||
|
$values[] = $id;
|
||||||
|
$this->db->execute(
|
||||||
|
'UPDATE specials SET menu_item_id = ?, title = ?, description = ?, badge = ?, price_cents = ?,
|
||||||
|
starts_at = ?, ends_at = ?, active = ?, display_order = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||||
|
$values
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$this->db->execute(
|
||||||
|
'INSERT INTO specials
|
||||||
|
(menu_item_id, title, description, badge, price_cents, starts_at, ends_at, active, display_order)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||||
|
$values
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Http::flash('success', 'Homepage special saved.');
|
||||||
|
Http::redirect('/admin/specials');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function users(): void
|
||||||
|
{
|
||||||
|
$this->auth->requireAdmin();
|
||||||
|
$query = Security::clean((string) ($_GET['q'] ?? ''));
|
||||||
|
$parameters = [];
|
||||||
|
$where = '1 = 1';
|
||||||
|
if ($query !== '') {
|
||||||
|
$where = '(email LIKE ? OR full_name LIKE ? OR phone LIKE ?)';
|
||||||
|
$parameters = ['%' . $query . '%', '%' . $query . '%', '%' . $query . '%'];
|
||||||
|
}
|
||||||
|
View::render('admin/users', $this->shared([
|
||||||
|
'title' => 'Users & Staff',
|
||||||
|
'adminPage' => 'users',
|
||||||
|
'users' => $this->db->fetchAll("SELECT * FROM users WHERE {$where} ORDER BY role, full_name", $parameters),
|
||||||
|
'query' => $query,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveUser(): void
|
||||||
|
{
|
||||||
|
Security::verifyCsrf();
|
||||||
|
$admin = $this->auth->requireAdmin();
|
||||||
|
$id = (int) ($_POST['id'] ?? 0);
|
||||||
|
$role = Security::clean((string) ($_POST['role'] ?? 'customer'));
|
||||||
|
if (!in_array($role, ['customer', 'staff', 'manager', 'admin'], true)) {
|
||||||
|
$role = 'customer';
|
||||||
|
}
|
||||||
|
if ($id === (int) $admin['id'] && !isset($_POST['active'])) {
|
||||||
|
Http::flash('error', 'You cannot deactivate your own account.');
|
||||||
|
Http::redirect('/admin/users');
|
||||||
|
}
|
||||||
|
if ($id > 0) {
|
||||||
|
$this->db->execute(
|
||||||
|
'UPDATE users SET full_name = ?, phone = ?, role = ?, active = ?, newsletter_opt_in = ?,
|
||||||
|
updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||||
|
[
|
||||||
|
Security::clean((string) ($_POST['full_name'] ?? '')),
|
||||||
|
Security::clean((string) ($_POST['phone'] ?? '')),
|
||||||
|
$role,
|
||||||
|
isset($_POST['active']) ? 1 : 0,
|
||||||
|
isset($_POST['newsletter_opt_in']) ? 1 : 0,
|
||||||
|
$id,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$email = strtolower(Security::clean((string) ($_POST['email'] ?? '')));
|
||||||
|
$password = (string) ($_POST['password'] ?? '');
|
||||||
|
if (!filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($password) < 10) {
|
||||||
|
Http::flash('error', 'New staff accounts need a valid email and a 10-character password.');
|
||||||
|
Http::redirect('/admin/users');
|
||||||
|
}
|
||||||
|
$this->db->execute(
|
||||||
|
'INSERT INTO users
|
||||||
|
(email, password_hash, full_name, phone, role, active, newsletter_opt_in, email_verified_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 1, ?, CURRENT_TIMESTAMP)',
|
||||||
|
[
|
||||||
|
$email,
|
||||||
|
password_hash($password, PASSWORD_DEFAULT),
|
||||||
|
Security::clean((string) ($_POST['full_name'] ?? '')),
|
||||||
|
Security::clean((string) ($_POST['phone'] ?? '')),
|
||||||
|
$role,
|
||||||
|
isset($_POST['newsletter_opt_in']) ? 1 : 0,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Http::flash('success', 'User account saved.');
|
||||||
|
Http::redirect('/admin/users');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function settings(): void
|
||||||
|
{
|
||||||
|
$this->auth->requireAdmin();
|
||||||
|
View::render('admin/settings', $this->shared([
|
||||||
|
'title' => 'Store Settings',
|
||||||
|
'adminPage' => 'settings',
|
||||||
|
'taxRates' => $this->db->fetchAll('SELECT * FROM tax_rates ORDER BY jurisdiction, name'),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveSettings(): void
|
||||||
|
{
|
||||||
|
Security::verifyCsrf();
|
||||||
|
$this->auth->requireAdmin();
|
||||||
|
$textFields = [
|
||||||
|
'app.url',
|
||||||
|
'business.name',
|
||||||
|
'business.phone',
|
||||||
|
'business.email',
|
||||||
|
'business.street',
|
||||||
|
'business.city',
|
||||||
|
'business.state',
|
||||||
|
'business.postal_code',
|
||||||
|
'business.hours',
|
||||||
|
'business.announcement',
|
||||||
|
'square.environment',
|
||||||
|
'square.application_id',
|
||||||
|
'square.location_id',
|
||||||
|
'mailgun.domain',
|
||||||
|
'mailgun.from_name',
|
||||||
|
'mailgun.from_email',
|
||||||
|
'database.mysql_host',
|
||||||
|
'database.mysql_database',
|
||||||
|
'database.mysql_username',
|
||||||
|
];
|
||||||
|
foreach ($textFields as $key) {
|
||||||
|
[$group, $field] = explode('.', $key, 2);
|
||||||
|
if (isset($_POST[$group]) && is_array($_POST[$group]) && array_key_exists($field, $_POST[$group])) {
|
||||||
|
$this->config->set($key, trim((string) $_POST[$group][$field]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (['square.access_token', 'mailgun.api_key', 'database.mysql_password'] as $secretKey) {
|
||||||
|
[$group, $field] = explode('.', $secretKey, 2);
|
||||||
|
$secret = isset($_POST[$group]) && is_array($_POST[$group])
|
||||||
|
? trim((string) ($_POST[$group][$field] ?? ''))
|
||||||
|
: '';
|
||||||
|
if ($secret !== '') {
|
||||||
|
$this->config->set($secretKey, $secret);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$ordering = isset($_POST['ordering']) && is_array($_POST['ordering']) ? $_POST['ordering'] : [];
|
||||||
|
$square = isset($_POST['square']) && is_array($_POST['square']) ? $_POST['square'] : [];
|
||||||
|
$mailgun = isset($_POST['mailgun']) && is_array($_POST['mailgun']) ? $_POST['mailgun'] : [];
|
||||||
|
$database = isset($_POST['database']) && is_array($_POST['database']) ? $_POST['database'] : [];
|
||||||
|
$this->config->set('ordering.accepting_orders', isset($ordering['accepting_orders']));
|
||||||
|
$this->config->set('ordering.pickup_eta_minutes', max(5, (int) ($ordering['pickup_eta_minutes'] ?? 25)));
|
||||||
|
$this->config->set('square.enabled', isset($square['enabled']));
|
||||||
|
$this->config->set('mailgun.enabled', isset($mailgun['enabled']));
|
||||||
|
$this->config->set('database.mysql_port', (int) ($database['mysql_port'] ?? 3306));
|
||||||
|
$this->config->save();
|
||||||
|
Http::flash('success', 'Store settings saved.');
|
||||||
|
Http::redirect('/admin/settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveTax(): void
|
||||||
|
{
|
||||||
|
Security::verifyCsrf();
|
||||||
|
$this->auth->requireAdmin();
|
||||||
|
$id = (int) ($_POST['id'] ?? 0);
|
||||||
|
$values = [
|
||||||
|
Security::clean((string) ($_POST['name'] ?? 'Tax')),
|
||||||
|
Security::clean((string) ($_POST['jurisdiction'] ?? 'state')),
|
||||||
|
(int) round(((float) ($_POST['rate'] ?? 0)) * 100),
|
||||||
|
isset($_POST['active']) ? 1 : 0,
|
||||||
|
];
|
||||||
|
if ($id > 0) {
|
||||||
|
$values[] = $id;
|
||||||
|
$this->db->execute(
|
||||||
|
'UPDATE tax_rates SET name = ?, jurisdiction = ?, rate_basis_points = ?, active = ?,
|
||||||
|
updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||||
|
$values
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$this->db->execute(
|
||||||
|
'INSERT INTO tax_rates (name, jurisdiction, rate_basis_points, active) VALUES (?, ?, ?, ?)',
|
||||||
|
$values
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Http::flash('success', 'Tax rate saved.');
|
||||||
|
Http::redirect('/admin/settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function newsletters(): void
|
||||||
|
{
|
||||||
|
$this->auth->requireStaff();
|
||||||
|
View::render('admin/newsletters', $this->shared([
|
||||||
|
'title' => 'Newsletters',
|
||||||
|
'adminPage' => 'newsletters',
|
||||||
|
'newsletters' => $this->db->fetchAll(
|
||||||
|
'SELECT n.*, u.full_name AS author
|
||||||
|
FROM newsletters n LEFT JOIN users u ON u.id = n.created_by
|
||||||
|
ORDER BY n.created_at DESC'
|
||||||
|
),
|
||||||
|
'recipientCount' => (int) $this->db->fetch(
|
||||||
|
'SELECT COUNT(*) AS count FROM users WHERE newsletter_opt_in = 1 AND active = 1'
|
||||||
|
)['count'],
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sendNewsletter(): void
|
||||||
|
{
|
||||||
|
Security::verifyCsrf();
|
||||||
|
$staff = $this->auth->requireStaff();
|
||||||
|
$subject = Security::clean((string) ($_POST['subject'] ?? ''));
|
||||||
|
$content = trim((string) ($_POST['content'] ?? ''));
|
||||||
|
if ($subject === '' || $content === '') {
|
||||||
|
Http::flash('error', 'Newsletter subject and message are required.');
|
||||||
|
Http::redirect('/admin/newsletters');
|
||||||
|
}
|
||||||
|
$recipients = $this->db->fetchAll(
|
||||||
|
'SELECT id, email, full_name FROM users WHERE newsletter_opt_in = 1 AND active = 1 ORDER BY id'
|
||||||
|
);
|
||||||
|
$sent = 0;
|
||||||
|
foreach ($recipients as $recipient) {
|
||||||
|
$signature = hash_hmac('sha256', (string) $recipient['id'], $this->newsletterKey());
|
||||||
|
$unsubscribe = rtrim((string) $this->config->get('app.url'), '/')
|
||||||
|
. '/unsubscribe?user=' . $recipient['id'] . '&signature=' . $signature;
|
||||||
|
$html = '<p>Hi ' . htmlspecialchars((string) $recipient['full_name'], ENT_QUOTES, 'UTF-8') . ',</p>'
|
||||||
|
. '<div>' . nl2br(htmlspecialchars($content, ENT_QUOTES, 'UTF-8')) . '</div>'
|
||||||
|
. '<hr><p style="font-size:12px">You received this because you joined the Fat Bottom Grille news list. '
|
||||||
|
. '<a href="' . htmlspecialchars($unsubscribe, ENT_QUOTES, 'UTF-8') . '">Unsubscribe</a></p>';
|
||||||
|
try {
|
||||||
|
$this->mailer->send((string) $recipient['email'], $subject, $html);
|
||||||
|
$sent++;
|
||||||
|
} catch (\Throwable) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->db->execute(
|
||||||
|
'INSERT INTO newsletters (subject, content_html, status, recipient_count, created_by, sent_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)',
|
||||||
|
[$subject, nl2br(htmlspecialchars($content, ENT_QUOTES, 'UTF-8')), 'sent', $sent, (int) $staff['id']]
|
||||||
|
);
|
||||||
|
Http::flash('success', "Newsletter sent to {$sent} subscribed users.");
|
||||||
|
Http::redirect('/admin/newsletters');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exportMenuPdf(): void
|
||||||
|
{
|
||||||
|
$this->auth->requireStaff();
|
||||||
|
$categories = $this->db->fetchAll('SELECT * FROM menu_categories WHERE active = 1 ORDER BY display_order, name');
|
||||||
|
foreach ($categories as &$category) {
|
||||||
|
$category['items'] = $this->db->fetchAll(
|
||||||
|
'SELECT * FROM menu_items WHERE category_id = ? AND active = 1 ORDER BY display_order, name',
|
||||||
|
[(int) $category['id']]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
unset($category);
|
||||||
|
(new MenuPdfExporter())->output((string) $this->config->get('business.name'), $categories);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exportOrdersCsv(): never
|
||||||
|
{
|
||||||
|
$this->auth->requireStaff();
|
||||||
|
$orders = $this->db->fetchAll('SELECT * FROM orders ORDER BY placed_at DESC');
|
||||||
|
header('Content-Type: text/csv');
|
||||||
|
header('Content-Disposition: attachment; filename="fat-bottom-orders-' . date('Y-m-d') . '.csv"');
|
||||||
|
$output = fopen('php://output', 'wb');
|
||||||
|
fputcsv($output, [
|
||||||
|
'Order', 'Placed', 'Customer', 'Email', 'Phone', 'Status', 'Payment',
|
||||||
|
'Subtotal', 'Tax', 'Total', 'Instructions',
|
||||||
|
]);
|
||||||
|
foreach ($orders as $order) {
|
||||||
|
fputcsv($output, [
|
||||||
|
$order['order_number'],
|
||||||
|
$order['placed_at'],
|
||||||
|
$order['customer_name'],
|
||||||
|
$order['customer_email'],
|
||||||
|
$order['customer_phone'],
|
||||||
|
$order['status'],
|
||||||
|
$order['payment_status'],
|
||||||
|
number_format((int) $order['subtotal_cents'] / 100, 2, '.', ''),
|
||||||
|
number_format((int) $order['tax_cents'] / 100, 2, '.', ''),
|
||||||
|
number_format((int) $order['total_cents'] / 100, 2, '.', ''),
|
||||||
|
$order['special_instructions'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
fclose($output);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function migrateMySql(): void
|
||||||
|
{
|
||||||
|
Security::verifyCsrf();
|
||||||
|
$this->auth->requireAdmin();
|
||||||
|
try {
|
||||||
|
$result = (new MySqlMigrator($this->config))->migrate();
|
||||||
|
if (isset($_POST['switch_driver'])) {
|
||||||
|
$this->config->set('database.driver', 'mysql');
|
||||||
|
$this->config->save();
|
||||||
|
}
|
||||||
|
Http::flash(
|
||||||
|
'success',
|
||||||
|
sprintf('Copied %d tables and %d rows to MySQL%s.', $result['tables'], $result['rows'], isset($_POST['switch_driver']) ? ' and enabled MySQL mode' : '')
|
||||||
|
);
|
||||||
|
} catch (\Throwable $error) {
|
||||||
|
Http::flash('error', $error->getMessage());
|
||||||
|
}
|
||||||
|
Http::redirect('/admin/settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function newsletterKey(): string
|
||||||
|
{
|
||||||
|
return hash('sha256', (string) $this->config->get('app.key') . '|newsletter');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace FatBottom\Controllers;
|
||||||
|
|
||||||
|
use FatBottom\Core\Auth;
|
||||||
|
use FatBottom\Core\Config;
|
||||||
|
use FatBottom\Core\Database;
|
||||||
|
use FatBottom\Core\Http;
|
||||||
|
use FatBottom\Core\Security;
|
||||||
|
use FatBottom\Core\View;
|
||||||
|
use FatBottom\Services\CartService;
|
||||||
|
use FatBottom\Services\TwoFactorService;
|
||||||
|
|
||||||
|
final class AuthController extends BaseController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
Config $config,
|
||||||
|
Auth $auth,
|
||||||
|
CartService $cart,
|
||||||
|
private readonly Database $db,
|
||||||
|
private readonly TwoFactorService $twoFactor
|
||||||
|
) {
|
||||||
|
parent::__construct($config, $auth, $cart);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function loginForm(): void
|
||||||
|
{
|
||||||
|
if ($this->auth->check()) {
|
||||||
|
Http::redirect('/account');
|
||||||
|
}
|
||||||
|
View::render('auth/login', $this->shared([
|
||||||
|
'title' => 'Sign In',
|
||||||
|
'captcha' => Security::newCaptcha(),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function login(): void
|
||||||
|
{
|
||||||
|
Security::verifyCsrf();
|
||||||
|
$input = $_POST;
|
||||||
|
$email = strtolower(Security::clean((string) ($input['email'] ?? '')));
|
||||||
|
if (!Security::verifyCaptcha($input)) {
|
||||||
|
Http::old(['email' => $email]);
|
||||||
|
Http::flash('error', 'Please complete the anti-spam check.');
|
||||||
|
Http::redirect('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->db->fetch('SELECT * FROM users WHERE email = ? AND active = 1', [$email]);
|
||||||
|
if ($user === null || !password_verify((string) ($input['password'] ?? ''), (string) $user['password_hash'])) {
|
||||||
|
usleep(350000);
|
||||||
|
Http::old(['email' => $email]);
|
||||||
|
Http::flash('error', 'The email address or password was not recognized.');
|
||||||
|
Http::redirect('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
unset($_SESSION['pending_verification_purpose']);
|
||||||
|
$_SESSION['pending_2fa_user_id'] = (int) $user['id'];
|
||||||
|
$developmentCode = $this->twoFactor->issue($user, 'login');
|
||||||
|
if ($developmentCode !== null) {
|
||||||
|
Http::flash('info', 'Development mail mode: your verification code is ' . $developmentCode . '.');
|
||||||
|
} else {
|
||||||
|
Http::flash('success', 'We emailed you a six-digit verification code.');
|
||||||
|
}
|
||||||
|
Http::redirect('/verify');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function registerForm(): void
|
||||||
|
{
|
||||||
|
if ($this->auth->check()) {
|
||||||
|
Http::redirect('/account');
|
||||||
|
}
|
||||||
|
View::render('auth/register', $this->shared([
|
||||||
|
'title' => 'Create Account',
|
||||||
|
'captcha' => Security::newCaptcha(),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
Security::verifyCsrf();
|
||||||
|
$input = $_POST;
|
||||||
|
$clean = [
|
||||||
|
'email' => strtolower(Security::clean((string) ($input['email'] ?? ''))),
|
||||||
|
'full_name' => Security::clean((string) ($input['full_name'] ?? '')),
|
||||||
|
'phone' => Security::clean((string) ($input['phone'] ?? '')),
|
||||||
|
'street_address' => Security::clean((string) ($input['street_address'] ?? '')),
|
||||||
|
'city' => Security::clean((string) ($input['city'] ?? '')),
|
||||||
|
'state' => strtoupper(substr(Security::clean((string) ($input['state'] ?? 'WV')), 0, 2)),
|
||||||
|
'postal_code' => Security::clean((string) ($input['postal_code'] ?? '')),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!Security::verifyCaptcha($input)) {
|
||||||
|
Http::old($clean);
|
||||||
|
Http::flash('error', 'Please complete the anti-spam check.');
|
||||||
|
Http::redirect('/register');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!filter_var($clean['email'], FILTER_VALIDATE_EMAIL)
|
||||||
|
|| $clean['full_name'] === ''
|
||||||
|
|| $clean['phone'] === ''
|
||||||
|
|| $clean['street_address'] === ''
|
||||||
|
|| $clean['city'] === ''
|
||||||
|
|| $clean['state'] === ''
|
||||||
|
|| $clean['postal_code'] === ''
|
||||||
|
|| strlen((string) ($input['password'] ?? '')) < 10
|
||||||
|
) {
|
||||||
|
Http::old($clean);
|
||||||
|
Http::flash('error', 'Complete your contact and address details, enter a valid email, and use a password of at least 10 characters.');
|
||||||
|
Http::redirect('/register');
|
||||||
|
}
|
||||||
|
if (($input['password'] ?? '') !== ($input['password_confirmation'] ?? '')) {
|
||||||
|
Http::old($clean);
|
||||||
|
Http::flash('error', 'The password confirmation does not match.');
|
||||||
|
Http::redirect('/register');
|
||||||
|
}
|
||||||
|
if ($this->db->fetch('SELECT id FROM users WHERE email = ?', [$clean['email']])) {
|
||||||
|
Http::old($clean);
|
||||||
|
Http::flash('error', 'An account already exists for that email address.');
|
||||||
|
Http::redirect('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->db->execute(
|
||||||
|
'INSERT INTO users
|
||||||
|
(email, password_hash, full_name, phone, street_address, city, state, postal_code, newsletter_opt_in)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)',
|
||||||
|
[
|
||||||
|
$clean['email'],
|
||||||
|
password_hash((string) $input['password'], PASSWORD_DEFAULT),
|
||||||
|
$clean['full_name'],
|
||||||
|
$clean['phone'],
|
||||||
|
$clean['street_address'],
|
||||||
|
$clean['city'],
|
||||||
|
$clean['state'],
|
||||||
|
$clean['postal_code'],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
$user = $this->db->fetch('SELECT * FROM users WHERE email = ?', [$clean['email']]);
|
||||||
|
$_SESSION['pending_2fa_user_id'] = (int) $user['id'];
|
||||||
|
$_SESSION['pending_verification_purpose'] = 'register';
|
||||||
|
$developmentCode = $this->twoFactor->issue($user, 'register');
|
||||||
|
if ($developmentCode !== null) {
|
||||||
|
Http::flash('info', 'Development mail mode: your verification code is ' . $developmentCode . '.');
|
||||||
|
}
|
||||||
|
Http::redirect('/verify');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verifyForm(): void
|
||||||
|
{
|
||||||
|
if (empty($_SESSION['pending_2fa_user_id'])) {
|
||||||
|
Http::redirect('/login');
|
||||||
|
}
|
||||||
|
View::render('auth/verify', $this->shared([
|
||||||
|
'title' => 'Verify Your Email',
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verify(): void
|
||||||
|
{
|
||||||
|
Security::verifyCsrf();
|
||||||
|
$userId = (int) ($_SESSION['pending_2fa_user_id'] ?? 0);
|
||||||
|
$purpose = (string) ($_SESSION['pending_verification_purpose'] ?? 'login');
|
||||||
|
if (!$this->twoFactor->verify($userId, Security::clean((string) ($_POST['code'] ?? '')), $purpose)) {
|
||||||
|
Http::flash('error', 'That code is invalid or has expired.');
|
||||||
|
Http::redirect('/verify');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->db->fetch('SELECT * FROM users WHERE id = ?', [$userId]);
|
||||||
|
if ($user === null) {
|
||||||
|
Http::redirect('/login');
|
||||||
|
}
|
||||||
|
if ($purpose === 'register') {
|
||||||
|
$this->db->execute('UPDATE users SET email_verified_at = CURRENT_TIMESTAMP WHERE id = ?', [$userId]);
|
||||||
|
unset($_SESSION['pending_verification_purpose']);
|
||||||
|
}
|
||||||
|
$this->auth->login($user);
|
||||||
|
Http::flash('success', 'Welcome, ' . $user['full_name'] . '.');
|
||||||
|
Http::redirect(in_array($user['role'], ['admin', 'manager', 'staff'], true) ? '/admin' : '/account');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logout(): void
|
||||||
|
{
|
||||||
|
Security::verifyCsrf();
|
||||||
|
$this->auth->logout();
|
||||||
|
Http::flash('success', 'You have been signed out.');
|
||||||
|
Http::redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function account(): void
|
||||||
|
{
|
||||||
|
$user = $this->auth->requireLogin();
|
||||||
|
$orders = $this->db->fetchAll(
|
||||||
|
'SELECT * FROM orders WHERE user_id = ? ORDER BY placed_at DESC LIMIT 25',
|
||||||
|
[(int) $user['id']]
|
||||||
|
);
|
||||||
|
View::render('account/index', $this->shared([
|
||||||
|
'title' => 'My Account',
|
||||||
|
'orders' => $orders,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateAccount(): void
|
||||||
|
{
|
||||||
|
Security::verifyCsrf();
|
||||||
|
$user = $this->auth->requireLogin();
|
||||||
|
$newsletter = isset($_POST['newsletter_opt_in']) ? 1 : 0;
|
||||||
|
$newPassword = (string) ($_POST['new_password'] ?? '');
|
||||||
|
if (
|
||||||
|
$newPassword !== ''
|
||||||
|
&& (
|
||||||
|
!password_verify((string) ($_POST['current_password'] ?? ''), (string) $user['password_hash'])
|
||||||
|
|| strlen($newPassword) < 10
|
||||||
|
|| $newPassword !== (string) ($_POST['new_password_confirmation'] ?? '')
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Http::flash('error', 'Password was not changed. Check your current password and use a matching new password of at least 10 characters.');
|
||||||
|
Http::redirect('/account');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->db->execute(
|
||||||
|
'UPDATE users SET full_name = ?, phone = ?, street_address = ?, city = ?, state = ?,
|
||||||
|
postal_code = ?, newsletter_opt_in = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||||
|
[
|
||||||
|
Security::clean((string) ($_POST['full_name'] ?? '')),
|
||||||
|
Security::clean((string) ($_POST['phone'] ?? '')),
|
||||||
|
Security::clean((string) ($_POST['street_address'] ?? '')),
|
||||||
|
Security::clean((string) ($_POST['city'] ?? '')),
|
||||||
|
strtoupper(substr(Security::clean((string) ($_POST['state'] ?? 'WV')), 0, 2)),
|
||||||
|
Security::clean((string) ($_POST['postal_code'] ?? '')),
|
||||||
|
$newsletter,
|
||||||
|
(int) $user['id'],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($newPassword !== '') {
|
||||||
|
$this->db->execute(
|
||||||
|
'UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||||
|
[password_hash($newPassword, PASSWORD_DEFAULT), (int) $user['id']]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Http::flash('success', 'Your account has been updated.');
|
||||||
|
Http::redirect('/account');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function unsubscribe(): void
|
||||||
|
{
|
||||||
|
$userId = (int) ($_GET['user'] ?? 0);
|
||||||
|
$signature = (string) ($_GET['signature'] ?? '');
|
||||||
|
$expected = hash_hmac('sha256', (string) $userId, $this->newsletterKey());
|
||||||
|
if ($userId > 0 && hash_equals($expected, $signature)) {
|
||||||
|
$this->db->execute('UPDATE users SET newsletter_opt_in = 0 WHERE id = ?', [$userId]);
|
||||||
|
Http::flash('success', 'You have been unsubscribed from email news.');
|
||||||
|
} else {
|
||||||
|
Http::flash('error', 'That unsubscribe link is invalid.');
|
||||||
|
}
|
||||||
|
Http::redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function newsletterKey(): string
|
||||||
|
{
|
||||||
|
return hash('sha256', (string) $this->config->get('app.key') . '|newsletter');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace FatBottom\Controllers;
|
||||||
|
|
||||||
|
use FatBottom\Core\Auth;
|
||||||
|
use FatBottom\Core\Config;
|
||||||
|
use FatBottom\Core\Http;
|
||||||
|
use FatBottom\Services\CartService;
|
||||||
|
|
||||||
|
abstract class BaseController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected readonly Config $config,
|
||||||
|
protected readonly Auth $auth,
|
||||||
|
protected readonly CartService $cart
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function shared(array $data = []): array
|
||||||
|
{
|
||||||
|
$user = $this->auth->user();
|
||||||
|
$cart = $this->cart->details($user ? (int) $user['id'] : null);
|
||||||
|
|
||||||
|
return array_merge([
|
||||||
|
'config' => $this->config,
|
||||||
|
'auth' => $this->auth,
|
||||||
|
'currentUser' => $user,
|
||||||
|
'currentCart' => $cart,
|
||||||
|
'flashMessages' => Http::consumeFlash(),
|
||||||
|
'old' => Http::consumeOld(),
|
||||||
|
], $data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace FatBottom\Controllers;
|
||||||
|
|
||||||
|
use FatBottom\Core\Auth;
|
||||||
|
use FatBottom\Core\Config;
|
||||||
|
use FatBottom\Core\Database;
|
||||||
|
use FatBottom\Core\Http;
|
||||||
|
use FatBottom\Core\Security;
|
||||||
|
use FatBottom\Core\View;
|
||||||
|
use FatBottom\Services\CartService;
|
||||||
|
use FatBottom\Services\Mailer;
|
||||||
|
use FatBottom\Services\OrderService;
|
||||||
|
|
||||||
|
final class StoreController extends BaseController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
Config $config,
|
||||||
|
Auth $auth,
|
||||||
|
CartService $cart,
|
||||||
|
private readonly Database $db,
|
||||||
|
private readonly OrderService $orders,
|
||||||
|
private readonly Mailer $mailer
|
||||||
|
) {
|
||||||
|
parent::__construct($config, $auth, $cart);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function home(): void
|
||||||
|
{
|
||||||
|
$categories = $this->menuCategories(true);
|
||||||
|
$specials = $this->db->fetchAll(
|
||||||
|
"SELECT s.*, mi.name AS item_name
|
||||||
|
FROM specials s
|
||||||
|
LEFT JOIN menu_items mi ON mi.id = s.menu_item_id
|
||||||
|
WHERE s.active = 1
|
||||||
|
AND (s.starts_at IS NULL OR s.starts_at <= CURRENT_TIMESTAMP)
|
||||||
|
AND (s.ends_at IS NULL OR s.ends_at >= CURRENT_TIMESTAMP)
|
||||||
|
ORDER BY s.display_order, s.id"
|
||||||
|
);
|
||||||
|
$featured = $this->db->fetchAll(
|
||||||
|
'SELECT mi.*, mc.name AS category_name
|
||||||
|
FROM menu_items mi
|
||||||
|
JOIN menu_categories mc ON mc.id = mi.category_id
|
||||||
|
WHERE mi.active = 1 AND mc.active = 1 AND mi.featured = 1
|
||||||
|
ORDER BY mi.display_order, mi.id LIMIT 6'
|
||||||
|
);
|
||||||
|
|
||||||
|
View::render('store/home', $this->shared([
|
||||||
|
'title' => 'Keyser comfort food, ready when you are',
|
||||||
|
'categories' => $categories,
|
||||||
|
'specials' => $specials,
|
||||||
|
'featured' => $featured,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function menu(): void
|
||||||
|
{
|
||||||
|
$query = Security::clean((string) ($_GET['q'] ?? ''));
|
||||||
|
$categories = $this->menuCategories(false, $query);
|
||||||
|
View::render('store/menu', $this->shared([
|
||||||
|
'title' => 'Order Online',
|
||||||
|
'categories' => $categories,
|
||||||
|
'query' => $query,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addToCart(): void
|
||||||
|
{
|
||||||
|
Security::verifyCsrf();
|
||||||
|
$user = $this->auth->user();
|
||||||
|
$added = $this->cart->add(
|
||||||
|
(int) ($_POST['menu_item_id'] ?? 0),
|
||||||
|
(int) ($_POST['quantity'] ?? 1),
|
||||||
|
Security::clean((string) ($_POST['notes'] ?? '')),
|
||||||
|
$user ? (int) $user['id'] : null
|
||||||
|
);
|
||||||
|
|
||||||
|
Http::flash($added ? 'success' : 'error', $added ? 'Added to your order.' : 'That item is not currently available.');
|
||||||
|
$returnTo = (string) ($_POST['return_to'] ?? '/menu');
|
||||||
|
Http::redirect(str_starts_with($returnTo, '/') ? $returnTo : '/menu');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cart(): void
|
||||||
|
{
|
||||||
|
$user = $this->auth->user();
|
||||||
|
$details = $this->cart->details($user ? (int) $user['id'] : null);
|
||||||
|
View::render('store/cart', $this->shared([
|
||||||
|
'title' => 'Your Order',
|
||||||
|
'cart' => $details,
|
||||||
|
'totals' => $this->orders->totals($details),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateCart(): void
|
||||||
|
{
|
||||||
|
Security::verifyCsrf();
|
||||||
|
$user = $this->auth->user();
|
||||||
|
$quantities = isset($_POST['quantity']) && is_array($_POST['quantity'])
|
||||||
|
? $_POST['quantity']
|
||||||
|
: [];
|
||||||
|
$this->cart->update($quantities, $user ? (int) $user['id'] : null);
|
||||||
|
Http::flash('success', 'Your order has been updated.');
|
||||||
|
Http::redirect('/cart');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeFromCart(): void
|
||||||
|
{
|
||||||
|
Security::verifyCsrf();
|
||||||
|
$user = $this->auth->user();
|
||||||
|
$this->cart->remove((int) ($_POST['cart_item_id'] ?? 0), $user ? (int) $user['id'] : null);
|
||||||
|
Http::flash('success', 'Item removed.');
|
||||||
|
Http::redirect('/cart');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function checkout(): void
|
||||||
|
{
|
||||||
|
$user = $this->auth->user();
|
||||||
|
$details = $this->cart->details($user ? (int) $user['id'] : null);
|
||||||
|
if (empty($details['items'])) {
|
||||||
|
Http::flash('warning', 'Add something delicious before checking out.');
|
||||||
|
Http::redirect('/menu');
|
||||||
|
}
|
||||||
|
|
||||||
|
View::render('store/checkout', $this->shared([
|
||||||
|
'title' => 'Checkout',
|
||||||
|
'cart' => $details,
|
||||||
|
'totals' => $this->orders->totals($details),
|
||||||
|
'taxRates' => $this->db->fetchAll('SELECT * FROM tax_rates WHERE active = 1 ORDER BY id'),
|
||||||
|
'squareEnabled' => (bool) $this->config->get('square.enabled', false),
|
||||||
|
'squareAppId' => (string) $this->config->get('square.application_id', ''),
|
||||||
|
'squareLocationId' => (string) $this->config->get('square.location_id', ''),
|
||||||
|
'squareEnvironment' => (string) $this->config->get('square.environment', 'sandbox'),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function placeOrder(): void
|
||||||
|
{
|
||||||
|
Security::verifyCsrf();
|
||||||
|
$user = $this->auth->user();
|
||||||
|
$cart = $this->cart->details($user ? (int) $user['id'] : null);
|
||||||
|
$input = [
|
||||||
|
'customer_name' => Security::clean((string) ($_POST['customer_name'] ?? '')),
|
||||||
|
'customer_email' => strtolower(Security::clean((string) ($_POST['customer_email'] ?? ''))),
|
||||||
|
'customer_phone' => Security::clean((string) ($_POST['customer_phone'] ?? '')),
|
||||||
|
'pickup_time' => Security::clean((string) ($_POST['pickup_time'] ?? '')),
|
||||||
|
'special_instructions' => Security::clean((string) ($_POST['special_instructions'] ?? '')),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (
|
||||||
|
$input['customer_name'] === ''
|
||||||
|
|| !filter_var($input['customer_email'], FILTER_VALIDATE_EMAIL)
|
||||||
|
|| $input['customer_phone'] === ''
|
||||||
|
) {
|
||||||
|
Http::old($input);
|
||||||
|
Http::flash('error', 'Name, a valid email address, and phone number are required.');
|
||||||
|
Http::redirect('/checkout');
|
||||||
|
}
|
||||||
|
if (!(bool) $this->config->get('ordering.accepting_orders', true)) {
|
||||||
|
Http::flash('error', 'Online ordering is currently paused.');
|
||||||
|
Http::redirect('/cart');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$order = $this->orders->place(
|
||||||
|
$cart,
|
||||||
|
$input,
|
||||||
|
$user ? (int) $user['id'] : null,
|
||||||
|
(string) ($_POST['square_token'] ?? '')
|
||||||
|
);
|
||||||
|
$this->mailer->send(
|
||||||
|
$input['customer_email'],
|
||||||
|
'We received order ' . $order['order_number'],
|
||||||
|
'<p>Thanks, ' . htmlspecialchars($input['customer_name'], ENT_QUOTES, 'UTF-8') . '.</p>'
|
||||||
|
. '<p>Your order <strong>' . htmlspecialchars((string) $order['order_number'], ENT_QUOTES, 'UTF-8')
|
||||||
|
. '</strong> has been paid and sent to the kitchen.</p>'
|
||||||
|
);
|
||||||
|
setcookie('fatbottom_cart', '', ['expires' => time() - 3600, 'path' => '/']);
|
||||||
|
$_SESSION['last_order_id'] = (int) $order['id'];
|
||||||
|
Http::redirect('/order/complete');
|
||||||
|
} catch (\Throwable $error) {
|
||||||
|
Http::old($input);
|
||||||
|
Http::flash('error', $error->getMessage());
|
||||||
|
Http::redirect('/checkout');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function orderComplete(): void
|
||||||
|
{
|
||||||
|
$orderId = (int) ($_SESSION['last_order_id'] ?? 0);
|
||||||
|
$order = $this->db->fetch('SELECT * FROM orders WHERE id = ?', [$orderId]);
|
||||||
|
if ($order === null) {
|
||||||
|
Http::redirect('/');
|
||||||
|
}
|
||||||
|
$items = $this->db->fetchAll('SELECT * FROM order_items WHERE order_id = ?', [$orderId]);
|
||||||
|
View::render('store/order-complete', $this->shared([
|
||||||
|
'title' => 'Order Received',
|
||||||
|
'order' => $order,
|
||||||
|
'items' => $items,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function menuCategories(bool $homepage, string $query = ''): array
|
||||||
|
{
|
||||||
|
$categories = $this->db->fetchAll(
|
||||||
|
'SELECT * FROM menu_categories WHERE active = 1 ORDER BY display_order, name'
|
||||||
|
);
|
||||||
|
foreach ($categories as &$category) {
|
||||||
|
$parameters = [(int) $category['id']];
|
||||||
|
$where = 'category_id = ? AND active = 1';
|
||||||
|
if ($query !== '') {
|
||||||
|
$where .= ' AND (name LIKE ? OR description LIKE ?)';
|
||||||
|
$parameters[] = '%' . $query . '%';
|
||||||
|
$parameters[] = '%' . $query . '%';
|
||||||
|
}
|
||||||
|
$limit = $homepage ? ' LIMIT 4' : '';
|
||||||
|
$category['items'] = $this->db->fetchAll(
|
||||||
|
"SELECT * FROM menu_items WHERE {$where} ORDER BY display_order, name{$limit}",
|
||||||
|
$parameters
|
||||||
|
);
|
||||||
|
}
|
||||||
|
unset($category);
|
||||||
|
|
||||||
|
return array_values(array_filter(
|
||||||
|
$categories,
|
||||||
|
static fn (array $category): bool => $category['items'] !== []
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace FatBottom\Core;
|
||||||
|
|
||||||
|
final class Auth
|
||||||
|
{
|
||||||
|
private ?array $cachedUser = null;
|
||||||
|
private bool $resolved = false;
|
||||||
|
|
||||||
|
public function __construct(private readonly Database $db)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user(): ?array
|
||||||
|
{
|
||||||
|
if ($this->resolved) {
|
||||||
|
return $this->cachedUser;
|
||||||
|
}
|
||||||
|
$this->resolved = true;
|
||||||
|
$id = (int) ($_SESSION['user_id'] ?? 0);
|
||||||
|
if ($id === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->cachedUser = $this->db->fetch(
|
||||||
|
'SELECT * FROM users WHERE id = ? AND active = 1',
|
||||||
|
[$id]
|
||||||
|
);
|
||||||
|
return $this->cachedUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function check(): bool
|
||||||
|
{
|
||||||
|
return $this->user() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function login(array $user): void
|
||||||
|
{
|
||||||
|
session_regenerate_id(true);
|
||||||
|
$_SESSION['user_id'] = (int) $user['id'];
|
||||||
|
unset($_SESSION['pending_2fa_user_id']);
|
||||||
|
$this->resolved = true;
|
||||||
|
$this->cachedUser = $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logout(): void
|
||||||
|
{
|
||||||
|
unset($_SESSION['user_id'], $_SESSION['pending_2fa_user_id']);
|
||||||
|
session_regenerate_id(true);
|
||||||
|
$this->resolved = true;
|
||||||
|
$this->cachedUser = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requireLogin(): array
|
||||||
|
{
|
||||||
|
$user = $this->user();
|
||||||
|
if ($user === null) {
|
||||||
|
Http::flash('warning', 'Please sign in to continue.');
|
||||||
|
Http::redirect('/login');
|
||||||
|
}
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requireStaff(): array
|
||||||
|
{
|
||||||
|
$user = $this->requireLogin();
|
||||||
|
if (!in_array($user['role'], ['admin', 'manager', 'staff'], true)) {
|
||||||
|
Http::abort(403, 'You do not have access to the staff dashboard.');
|
||||||
|
}
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requireAdmin(): array
|
||||||
|
{
|
||||||
|
$user = $this->requireLogin();
|
||||||
|
if ($user['role'] !== 'admin') {
|
||||||
|
Http::abort(403, 'Administrator access is required.');
|
||||||
|
}
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace FatBottom\Core;
|
||||||
|
|
||||||
|
final class Config
|
||||||
|
{
|
||||||
|
private array $values;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly array $defaults,
|
||||||
|
private readonly string $path
|
||||||
|
) {
|
||||||
|
$saved = [];
|
||||||
|
if (is_file($path)) {
|
||||||
|
$decoded = json_decode((string) file_get_contents($path), true);
|
||||||
|
if (is_array($decoded)) {
|
||||||
|
$saved = $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->values = array_replace_recursive($defaults, $saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function all(): array
|
||||||
|
{
|
||||||
|
return $this->values;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $key, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
$value = $this->values;
|
||||||
|
foreach (explode('.', $key) as $segment) {
|
||||||
|
if (!is_array($value) || !array_key_exists($segment, $value)) {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
$value = $value[$segment];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function set(string $key, mixed $newValue): void
|
||||||
|
{
|
||||||
|
$segments = explode('.', $key);
|
||||||
|
$value = &$this->values;
|
||||||
|
foreach ($segments as $segment) {
|
||||||
|
if (!isset($value[$segment]) || !is_array($value[$segment])) {
|
||||||
|
$value[$segment] = [];
|
||||||
|
}
|
||||||
|
$value = &$value[$segment];
|
||||||
|
}
|
||||||
|
$value = $newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
$directory = dirname($this->path);
|
||||||
|
if (!is_dir($directory)) {
|
||||||
|
mkdir($directory, 0775, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
file_put_contents(
|
||||||
|
$this->path,
|
||||||
|
json_encode($this->values, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL,
|
||||||
|
LOCK_EX
|
||||||
|
);
|
||||||
|
@chmod($this->path, 0600);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reset(): void
|
||||||
|
{
|
||||||
|
$this->values = $this->defaults;
|
||||||
|
$this->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace FatBottom\Core;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class Database
|
||||||
|
{
|
||||||
|
private PDO $pdo;
|
||||||
|
private bool $sqlite;
|
||||||
|
|
||||||
|
public function __construct(private readonly Config $config)
|
||||||
|
{
|
||||||
|
$driver = (string) $config->get('database.driver', 'sqlite');
|
||||||
|
$this->sqlite = $driver !== 'mysql';
|
||||||
|
|
||||||
|
if ($this->sqlite) {
|
||||||
|
$directory = BASE_PATH . '/storage/database';
|
||||||
|
if (!is_dir($directory)) {
|
||||||
|
mkdir($directory, 0775, true);
|
||||||
|
}
|
||||||
|
$this->pdo = new PDO('sqlite:' . $directory . '/store.sqlite');
|
||||||
|
$this->pdo->exec('PRAGMA foreign_keys = ON');
|
||||||
|
$this->pdo->exec('PRAGMA journal_mode = WAL');
|
||||||
|
} else {
|
||||||
|
$dsn = sprintf(
|
||||||
|
'mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4',
|
||||||
|
$config->get('database.mysql_host'),
|
||||||
|
(int) $config->get('database.mysql_port', 3306),
|
||||||
|
$config->get('database.mysql_database')
|
||||||
|
);
|
||||||
|
$this->pdo = new PDO(
|
||||||
|
$dsn,
|
||||||
|
(string) $config->get('database.mysql_username'),
|
||||||
|
(string) $config->get('database.mysql_password')
|
||||||
|
);
|
||||||
|
$this->pdo->exec("SET time_zone = '+00:00'");
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
|
$this->pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||||
|
$this->pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pdo(): PDO
|
||||||
|
{
|
||||||
|
return $this->pdo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isSqlite(): bool
|
||||||
|
{
|
||||||
|
return $this->sqlite;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function migrate(): void
|
||||||
|
{
|
||||||
|
if (!$this->sqlite) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$schema = <<<'SQL'
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
full_name TEXT NOT NULL,
|
||||||
|
phone TEXT NOT NULL DEFAULT '',
|
||||||
|
street_address TEXT NOT NULL DEFAULT '',
|
||||||
|
city TEXT NOT NULL DEFAULT '',
|
||||||
|
state TEXT NOT NULL DEFAULT '',
|
||||||
|
postal_code TEXT NOT NULL DEFAULT '',
|
||||||
|
role TEXT NOT NULL DEFAULT 'customer',
|
||||||
|
newsletter_opt_in INTEGER NOT NULL DEFAULT 1,
|
||||||
|
email_verified_at TEXT,
|
||||||
|
active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS login_codes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
code_hash TEXT NOT NULL,
|
||||||
|
purpose TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
used_at TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS menu_categories (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
display_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS menu_items (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
category_id INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
price_cents INTEGER NOT NULL,
|
||||||
|
compare_at_cents INTEGER,
|
||||||
|
image_url TEXT NOT NULL DEFAULT '',
|
||||||
|
dietary_tags TEXT NOT NULL DEFAULT '[]',
|
||||||
|
featured INTEGER NOT NULL DEFAULT 0,
|
||||||
|
active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
display_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (category_id) REFERENCES menu_categories(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS specials (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
menu_item_id INTEGER,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
badge TEXT NOT NULL DEFAULT 'Special',
|
||||||
|
price_cents INTEGER,
|
||||||
|
starts_at TEXT,
|
||||||
|
ends_at TEXT,
|
||||||
|
active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
display_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (menu_item_id) REFERENCES menu_items(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tax_rates (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
jurisdiction TEXT NOT NULL,
|
||||||
|
rate_basis_points INTEGER NOT NULL,
|
||||||
|
active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS carts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
user_id INTEGER,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
subtotal_cents INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
converted_at TEXT,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS cart_items (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
cart_id INTEGER NOT NULL,
|
||||||
|
menu_item_id INTEGER NOT NULL,
|
||||||
|
quantity INTEGER NOT NULL DEFAULT 1,
|
||||||
|
unit_price_cents INTEGER NOT NULL,
|
||||||
|
notes TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(cart_id, menu_item_id, notes),
|
||||||
|
FOREIGN KEY (cart_id) REFERENCES carts(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (menu_item_id) REFERENCES menu_items(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS orders (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
order_number TEXT NOT NULL UNIQUE,
|
||||||
|
user_id INTEGER,
|
||||||
|
cart_id INTEGER,
|
||||||
|
customer_name TEXT NOT NULL,
|
||||||
|
customer_email TEXT NOT NULL,
|
||||||
|
customer_phone TEXT NOT NULL,
|
||||||
|
order_type TEXT NOT NULL DEFAULT 'pickup',
|
||||||
|
status TEXT NOT NULL DEFAULT 'paid',
|
||||||
|
payment_status TEXT NOT NULL DEFAULT 'paid',
|
||||||
|
payment_provider TEXT NOT NULL DEFAULT 'demo',
|
||||||
|
payment_reference TEXT NOT NULL DEFAULT '',
|
||||||
|
subtotal_cents INTEGER NOT NULL,
|
||||||
|
tax_cents INTEGER NOT NULL,
|
||||||
|
total_cents INTEGER NOT NULL,
|
||||||
|
special_instructions TEXT NOT NULL DEFAULT '',
|
||||||
|
pickup_time TEXT,
|
||||||
|
placed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (cart_id) REFERENCES carts(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS order_items (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
order_id INTEGER NOT NULL,
|
||||||
|
menu_item_id INTEGER,
|
||||||
|
item_name TEXT NOT NULL,
|
||||||
|
quantity INTEGER NOT NULL,
|
||||||
|
unit_price_cents INTEGER NOT NULL,
|
||||||
|
line_total_cents INTEGER NOT NULL,
|
||||||
|
notes TEXT NOT NULL DEFAULT '',
|
||||||
|
FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (menu_item_id) REFERENCES menu_items(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS order_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
order_id INTEGER NOT NULL,
|
||||||
|
user_id INTEGER,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
details TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS refunds (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
order_id INTEGER NOT NULL,
|
||||||
|
staff_user_id INTEGER,
|
||||||
|
amount_cents INTEGER NOT NULL,
|
||||||
|
reason TEXT NOT NULL DEFAULT '',
|
||||||
|
provider_reference TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (order_id) REFERENCES orders(id),
|
||||||
|
FOREIGN KEY (staff_user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS visitor_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
user_id INTEGER,
|
||||||
|
route TEXT NOT NULL,
|
||||||
|
referrer TEXT NOT NULL DEFAULT '',
|
||||||
|
user_agent TEXT NOT NULL DEFAULT '',
|
||||||
|
ip_hash TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS newsletters (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
subject TEXT NOT NULL,
|
||||||
|
content_html TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'draft',
|
||||||
|
recipient_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_by INTEGER,
|
||||||
|
sent_at TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_menu_items_category ON menu_items(category_id, active);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status, placed_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_carts_status ON carts(status, last_seen_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_visitors_created ON visitor_events(created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_newsletter ON users(newsletter_opt_in, active);
|
||||||
|
SQL;
|
||||||
|
|
||||||
|
$this->pdo->exec($schema);
|
||||||
|
$this->seed();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seed(): void
|
||||||
|
{
|
||||||
|
$categoryCount = (int) $this->pdo->query('SELECT COUNT(*) FROM menu_categories')->fetchColumn();
|
||||||
|
if ($categoryCount > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->pdo->beginTransaction();
|
||||||
|
try {
|
||||||
|
$categories = [
|
||||||
|
['Smash Burgers', 'smash-burgers', 'Hand-pressed beef, crisp edges, and the good stuff.', 10],
|
||||||
|
['Burritos & Bowls', 'burritos-bowls', 'Big, packed, and built your way.', 20],
|
||||||
|
['Dinner Plates', 'dinner-plates', 'Comfort-food plates made for a proper supper.', 30],
|
||||||
|
['Sides', 'sides', 'The supporting cast that tends to steal the show.', 40],
|
||||||
|
['Drinks', 'drinks', 'Cold drinks for hot food.', 50],
|
||||||
|
];
|
||||||
|
$categoryStatement = $this->pdo->prepare(
|
||||||
|
'INSERT INTO menu_categories (name, slug, description, display_order) VALUES (?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
foreach ($categories as $category) {
|
||||||
|
$categoryStatement->execute($category);
|
||||||
|
}
|
||||||
|
|
||||||
|
$categoryIds = [];
|
||||||
|
foreach ($this->pdo->query('SELECT id, slug FROM menu_categories')->fetchAll() as $category) {
|
||||||
|
$categoryIds[$category['slug']] = (int) $category['id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = [
|
||||||
|
['smash-burgers', 'The Fat Bottom', 'the-fat-bottom', 'Two smashed patties, American cheese, grilled onion, pickles, and house sauce.', 1199, 1, '["House favorite"]'],
|
||||||
|
['smash-burgers', 'Piedmont Bacon Burger', 'piedmont-bacon-burger', 'Two patties, smoked bacon, cheddar, lettuce, tomato, onion, and burger sauce.', 1349, 1, '[]'],
|
||||||
|
['smash-burgers', 'Allegheny Melt', 'allegheny-melt', 'Smashed beef, Swiss, caramelized onions, and peppercorn mayo on toasted sourdough.', 1299, 0, '[]'],
|
||||||
|
['burritos-bowls', 'Mountaineer Burrito', 'mountaineer-burrito', 'Seasoned beef, rice, black beans, queso, pico, lettuce, and crema.', 1249, 1, '["Big appetite"]'],
|
||||||
|
['burritos-bowls', 'Chicken Fajita Bowl', 'chicken-fajita-bowl', 'Grilled chicken, peppers, onions, rice, corn salsa, avocado crema.', 1199, 0, '["Gluten conscious"]'],
|
||||||
|
['dinner-plates', 'Country Fried Chicken', 'country-fried-chicken', 'Crispy chicken, peppered gravy, mashed potatoes, and green beans.', 1499, 1, '[]'],
|
||||||
|
['dinner-plates', 'Keyser Meatloaf', 'keyser-meatloaf', 'House meatloaf with brown gravy, mashed potatoes, and seasonal vegetables.', 1449, 0, '[]'],
|
||||||
|
['sides', 'Loaded Fries', 'loaded-fries', 'Crispy fries, queso, bacon, scallions, and ranch drizzle.', 699, 1, '[]'],
|
||||||
|
['sides', 'Mac & Cheese', 'mac-cheese', 'Creamy, baked, and unapologetically cheesy.', 449, 0, '["Vegetarian"]'],
|
||||||
|
['drinks', 'Sweet Tea', 'sweet-tea', 'Fresh-brewed southern sweet tea.', 299, 0, '[]'],
|
||||||
|
['drinks', 'Fountain Drink', 'fountain-drink', 'Your choice of fountain soda.', 299, 0, '[]'],
|
||||||
|
];
|
||||||
|
$itemStatement = $this->pdo->prepare(
|
||||||
|
'INSERT INTO menu_items
|
||||||
|
(category_id, name, slug, description, price_cents, featured, dietary_tags)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$itemStatement->execute([
|
||||||
|
$categoryIds[$item[0]],
|
||||||
|
$item[1],
|
||||||
|
$item[2],
|
||||||
|
$item[3],
|
||||||
|
$item[4],
|
||||||
|
$item[5],
|
||||||
|
$item[6],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$fatBottomId = (int) $this->pdo->query(
|
||||||
|
"SELECT id FROM menu_items WHERE slug = 'the-fat-bottom'"
|
||||||
|
)->fetchColumn();
|
||||||
|
$loadedFriesId = (int) $this->pdo->query(
|
||||||
|
"SELECT id FROM menu_items WHERE slug = 'loaded-fries'"
|
||||||
|
)->fetchColumn();
|
||||||
|
$specialStatement = $this->pdo->prepare(
|
||||||
|
'INSERT INTO specials (menu_item_id, title, description, badge, price_cents, display_order)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
$specialStatement->execute([
|
||||||
|
$fatBottomId,
|
||||||
|
'The Fat Bottom Deal',
|
||||||
|
'Our signature burger at a special online price.',
|
||||||
|
'Online Special',
|
||||||
|
1099,
|
||||||
|
10,
|
||||||
|
]);
|
||||||
|
$specialStatement->execute([
|
||||||
|
$loadedFriesId,
|
||||||
|
'Loaded-Up Lunch',
|
||||||
|
'Loaded fries are $1 off weekdays from 11 to 2.',
|
||||||
|
'Weekday Deal',
|
||||||
|
599,
|
||||||
|
20,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$taxStatement = $this->pdo->prepare(
|
||||||
|
'INSERT INTO tax_rates (name, jurisdiction, rate_basis_points) VALUES (?, ?, ?)'
|
||||||
|
);
|
||||||
|
$taxStatement->execute(['West Virginia Sales Tax', 'state', 600]);
|
||||||
|
$taxStatement->execute(['Keyser Municipal Tax', 'city', 100]);
|
||||||
|
|
||||||
|
$adminStatement = $this->pdo->prepare(
|
||||||
|
'INSERT INTO users
|
||||||
|
(email, password_hash, full_name, phone, role, newsletter_opt_in, email_verified_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)'
|
||||||
|
);
|
||||||
|
$adminStatement->execute([
|
||||||
|
'admin@fatbottomgrille.com',
|
||||||
|
password_hash('ChangeMe123!', PASSWORD_DEFAULT),
|
||||||
|
'Store Administrator',
|
||||||
|
'',
|
||||||
|
'admin',
|
||||||
|
0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->pdo->commit();
|
||||||
|
} catch (\Throwable $error) {
|
||||||
|
$this->pdo->rollBack();
|
||||||
|
throw new RuntimeException('Unable to seed the store database.', 0, $error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fetchAll(string $sql, array $parameters = []): array
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->prepare($sql);
|
||||||
|
$statement->execute($parameters);
|
||||||
|
return $statement->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fetch(string $sql, array $parameters = []): ?array
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->prepare($sql);
|
||||||
|
$statement->execute($parameters);
|
||||||
|
$row = $statement->fetch();
|
||||||
|
return $row === false ? null : $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function execute(string $sql, array $parameters = []): bool
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->prepare($sql);
|
||||||
|
return $statement->execute($parameters);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace FatBottom\Core;
|
||||||
|
|
||||||
|
final class Http
|
||||||
|
{
|
||||||
|
public static function redirect(string $path): never
|
||||||
|
{
|
||||||
|
header('Location: ' . $path);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function abort(int $status, string $message): never
|
||||||
|
{
|
||||||
|
http_response_code($status);
|
||||||
|
echo $message;
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function flash(string $type, string $message): void
|
||||||
|
{
|
||||||
|
$_SESSION['_flash'][] = ['type' => $type, 'message' => $message];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function consumeFlash(): array
|
||||||
|
{
|
||||||
|
$messages = $_SESSION['_flash'] ?? [];
|
||||||
|
unset($_SESSION['_flash']);
|
||||||
|
return $messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function old(array $input): void
|
||||||
|
{
|
||||||
|
$_SESSION['_old'] = $input;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function consumeOld(): array
|
||||||
|
{
|
||||||
|
$old = $_SESSION['_old'] ?? [];
|
||||||
|
unset($_SESSION['_old']);
|
||||||
|
return $old;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function path(): string
|
||||||
|
{
|
||||||
|
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
|
||||||
|
$path = is_string($path) ? rtrim($path, '/') : '/';
|
||||||
|
return $path === '' ? '/' : $path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace FatBottom\Core;
|
||||||
|
|
||||||
|
final class Router
|
||||||
|
{
|
||||||
|
private array $routes = [];
|
||||||
|
|
||||||
|
public function get(string $path, callable $handler): void
|
||||||
|
{
|
||||||
|
$this->routes['GET'][$path] = $handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function post(string $path, callable $handler): void
|
||||||
|
{
|
||||||
|
$this->routes['POST'][$path] = $handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dispatch(string $method, string $path): void
|
||||||
|
{
|
||||||
|
$handler = $this->routes[$method][$path] ?? null;
|
||||||
|
if ($handler === null) {
|
||||||
|
Http::abort(404, 'The page you requested could not be found.');
|
||||||
|
}
|
||||||
|
$handler();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace FatBottom\Core;
|
||||||
|
|
||||||
|
final class Security
|
||||||
|
{
|
||||||
|
public static function csrfToken(): string
|
||||||
|
{
|
||||||
|
if (empty($_SESSION['_csrf'])) {
|
||||||
|
$_SESSION['_csrf'] = bin2hex(random_bytes(32));
|
||||||
|
}
|
||||||
|
return (string) $_SESSION['_csrf'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function verifyCsrf(): void
|
||||||
|
{
|
||||||
|
$submitted = $_POST['_token'] ?? '';
|
||||||
|
if (!is_string($submitted) || !hash_equals(self::csrfToken(), $submitted)) {
|
||||||
|
Http::abort(419, 'Your session expired. Please go back and try again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function newCaptcha(): array
|
||||||
|
{
|
||||||
|
$left = random_int(2, 9);
|
||||||
|
$right = random_int(1, 9);
|
||||||
|
$_SESSION['_captcha_answer'] = $left + $right;
|
||||||
|
return [$left, $right];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function verifyCaptcha(array $input): bool
|
||||||
|
{
|
||||||
|
if (!empty($input['website'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$expected = (int) ($_SESSION['_captcha_answer'] ?? -1);
|
||||||
|
unset($_SESSION['_captcha_answer']);
|
||||||
|
return isset($input['captcha']) && (int) $input['captcha'] === $expected;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function clean(string $value): string
|
||||||
|
{
|
||||||
|
return trim(strip_tags($value));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function slug(string $value): string
|
||||||
|
{
|
||||||
|
$value = strtolower(trim($value));
|
||||||
|
$value = preg_replace('/[^a-z0-9]+/', '-', $value) ?? '';
|
||||||
|
return trim($value, '-') ?: bin2hex(random_bytes(4));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace FatBottom\Core;
|
||||||
|
|
||||||
|
final class View
|
||||||
|
{
|
||||||
|
public static function render(string $template, array $data = []): void
|
||||||
|
{
|
||||||
|
$templatePath = BASE_PATH . '/views/' . $template . '.php';
|
||||||
|
if (!is_file($templatePath)) {
|
||||||
|
Http::abort(500, 'View not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
extract($data, EXTR_SKIP);
|
||||||
|
require BASE_PATH . '/views/layout.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function partial(string $template, array $data = []): void
|
||||||
|
{
|
||||||
|
extract($data, EXTR_SKIP);
|
||||||
|
require BASE_PATH . '/views/' . $template . '.php';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace FatBottom\Services;
|
||||||
|
|
||||||
|
use FatBottom\Core\Database;
|
||||||
|
|
||||||
|
final class CartService
|
||||||
|
{
|
||||||
|
public function __construct(private readonly Database $db)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function current(?int $userId = null): array
|
||||||
|
{
|
||||||
|
$token = $_COOKIE['fatbottom_cart'] ?? '';
|
||||||
|
$cart = null;
|
||||||
|
if (is_string($token) && $token !== '') {
|
||||||
|
$cart = $this->db->fetch('SELECT * FROM carts WHERE token = ? AND status = ?', [$token, 'active']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($cart === null) {
|
||||||
|
$token = bin2hex(random_bytes(24));
|
||||||
|
$this->db->execute(
|
||||||
|
'INSERT INTO carts (token, user_id, status) VALUES (?, ?, ?)',
|
||||||
|
[$token, $userId, 'active']
|
||||||
|
);
|
||||||
|
setcookie('fatbottom_cart', $token, [
|
||||||
|
'expires' => time() + 60 * 60 * 24 * 30,
|
||||||
|
'path' => '/',
|
||||||
|
'secure' => !empty($_SERVER['HTTPS']),
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => 'Lax',
|
||||||
|
]);
|
||||||
|
$cart = $this->db->fetch('SELECT * FROM carts WHERE token = ?', [$token]);
|
||||||
|
} elseif ($userId !== null && empty($cart['user_id'])) {
|
||||||
|
$this->db->execute('UPDATE carts SET user_id = ? WHERE id = ?', [$userId, (int) $cart['id']]);
|
||||||
|
$cart['user_id'] = $userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->touch((int) $cart['id']);
|
||||||
|
return $cart;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function add(int $menuItemId, int $quantity, string $notes, ?int $userId = null): bool
|
||||||
|
{
|
||||||
|
$item = $this->db->fetch('SELECT * FROM menu_items WHERE id = ? AND active = 1', [$menuItemId]);
|
||||||
|
if ($item === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$special = $this->db->fetch(
|
||||||
|
"SELECT price_cents FROM specials
|
||||||
|
WHERE menu_item_id = ? AND active = 1 AND price_cents IS NOT NULL
|
||||||
|
AND (starts_at IS NULL OR starts_at <= CURRENT_TIMESTAMP)
|
||||||
|
AND (ends_at IS NULL OR ends_at >= CURRENT_TIMESTAMP)
|
||||||
|
ORDER BY price_cents ASC LIMIT 1",
|
||||||
|
[$menuItemId]
|
||||||
|
);
|
||||||
|
$unitPrice = $special !== null
|
||||||
|
? min((int) $item['price_cents'], (int) $special['price_cents'])
|
||||||
|
: (int) $item['price_cents'];
|
||||||
|
|
||||||
|
$cart = $this->current($userId);
|
||||||
|
$notes = trim(substr($notes, 0, 240));
|
||||||
|
$existing = $this->db->fetch(
|
||||||
|
'SELECT * FROM cart_items WHERE cart_id = ? AND menu_item_id = ? AND notes = ?',
|
||||||
|
[(int) $cart['id'], $menuItemId, $notes]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
$this->db->execute(
|
||||||
|
'UPDATE cart_items SET quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||||
|
[min(20, (int) $existing['quantity'] + max(1, $quantity)), (int) $existing['id']]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$this->db->execute(
|
||||||
|
'INSERT INTO cart_items (cart_id, menu_item_id, quantity, unit_price_cents, notes)
|
||||||
|
VALUES (?, ?, ?, ?, ?)',
|
||||||
|
[(int) $cart['id'], $menuItemId, min(20, max(1, $quantity)), $unitPrice, $notes]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->recalculate((int) $cart['id']);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(array $quantities, ?int $userId = null): void
|
||||||
|
{
|
||||||
|
$cart = $this->current($userId);
|
||||||
|
foreach ($quantities as $itemId => $quantity) {
|
||||||
|
$itemId = (int) $itemId;
|
||||||
|
$quantity = (int) $quantity;
|
||||||
|
if ($quantity <= 0) {
|
||||||
|
$this->db->execute('DELETE FROM cart_items WHERE id = ? AND cart_id = ?', [$itemId, (int) $cart['id']]);
|
||||||
|
} else {
|
||||||
|
$this->db->execute(
|
||||||
|
'UPDATE cart_items SET quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND cart_id = ?',
|
||||||
|
[min(20, $quantity), $itemId, (int) $cart['id']]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->recalculate((int) $cart['id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function remove(int $cartItemId, ?int $userId = null): void
|
||||||
|
{
|
||||||
|
$cart = $this->current($userId);
|
||||||
|
$this->db->execute('DELETE FROM cart_items WHERE id = ? AND cart_id = ?', [$cartItemId, (int) $cart['id']]);
|
||||||
|
$this->recalculate((int) $cart['id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function details(?int $userId = null): array
|
||||||
|
{
|
||||||
|
$cart = $this->current($userId);
|
||||||
|
$items = $this->db->fetchAll(
|
||||||
|
'SELECT ci.*, mi.name, mi.description, mi.active
|
||||||
|
FROM cart_items ci
|
||||||
|
JOIN menu_items mi ON mi.id = ci.menu_item_id
|
||||||
|
WHERE ci.cart_id = ?
|
||||||
|
ORDER BY ci.id',
|
||||||
|
[(int) $cart['id']]
|
||||||
|
);
|
||||||
|
$cart['items'] = $items;
|
||||||
|
$cart['item_count'] = array_sum(array_map(static fn (array $item): int => (int) $item['quantity'], $items));
|
||||||
|
return $cart;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function touch(int $cartId): void
|
||||||
|
{
|
||||||
|
$this->db->execute('UPDATE carts SET last_seen_at = CURRENT_TIMESTAMP WHERE id = ?', [$cartId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function recalculate(int $cartId): void
|
||||||
|
{
|
||||||
|
$subtotal = (int) $this->db->fetch(
|
||||||
|
'SELECT COALESCE(SUM(quantity * unit_price_cents), 0) AS subtotal
|
||||||
|
FROM cart_items WHERE cart_id = ?',
|
||||||
|
[$cartId]
|
||||||
|
)['subtotal'];
|
||||||
|
$this->db->execute(
|
||||||
|
'UPDATE carts SET subtotal_cents = ?, last_seen_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||||
|
[$subtotal, $cartId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace FatBottom\Services;
|
||||||
|
|
||||||
|
use FatBottom\Core\Config;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class Mailer
|
||||||
|
{
|
||||||
|
public function __construct(private readonly Config $config)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function send(string $to, string $subject, string $html): bool
|
||||||
|
{
|
||||||
|
if (!(bool) $this->config->get('mailgun.enabled', false)) {
|
||||||
|
$this->writeToLog($to, $subject, $html);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('curl_init')) {
|
||||||
|
throw new RuntimeException('The PHP cURL extension is required for Mailgun.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$domain = (string) $this->config->get('mailgun.domain');
|
||||||
|
$apiKey = (string) $this->config->get('mailgun.api_key');
|
||||||
|
if ($domain === '' || $apiKey === '') {
|
||||||
|
throw new RuntimeException('Mailgun is enabled but its credentials are incomplete.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$from = sprintf(
|
||||||
|
'%s <%s>',
|
||||||
|
$this->config->get('mailgun.from_name', 'Fat Bottom Grille'),
|
||||||
|
$this->config->get('mailgun.from_email')
|
||||||
|
);
|
||||||
|
|
||||||
|
$curl = curl_init('https://api.mailgun.net/v3/' . rawurlencode($domain) . '/messages');
|
||||||
|
curl_setopt_array($curl, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_USERPWD => 'api:' . $apiKey,
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => [
|
||||||
|
'from' => $from,
|
||||||
|
'to' => $to,
|
||||||
|
'subject' => $subject,
|
||||||
|
'html' => $html,
|
||||||
|
],
|
||||||
|
CURLOPT_TIMEOUT => 20,
|
||||||
|
]);
|
||||||
|
$response = curl_exec($curl);
|
||||||
|
$status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||||||
|
$error = curl_error($curl);
|
||||||
|
curl_close($curl);
|
||||||
|
|
||||||
|
if ($response === false || $status < 200 || $status >= 300) {
|
||||||
|
throw new RuntimeException('Mailgun delivery failed: ' . ($error ?: 'HTTP ' . $status));
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function writeToLog(string $to, string $subject, string $html): void
|
||||||
|
{
|
||||||
|
$directory = BASE_PATH . '/storage/logs';
|
||||||
|
if (!is_dir($directory)) {
|
||||||
|
mkdir($directory, 0775, true);
|
||||||
|
}
|
||||||
|
$line = sprintf(
|
||||||
|
"[%s] TO: %s | SUBJECT: %s\n%s\n\n",
|
||||||
|
date('c'),
|
||||||
|
$to,
|
||||||
|
$subject,
|
||||||
|
trim(strip_tags($html))
|
||||||
|
);
|
||||||
|
file_put_contents($directory . '/mail.log', $line, FILE_APPEND | LOCK_EX);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace FatBottom\Services;
|
||||||
|
|
||||||
|
final class MenuPdfExporter
|
||||||
|
{
|
||||||
|
public function output(string $businessName, array $categories): never
|
||||||
|
{
|
||||||
|
$pages = [];
|
||||||
|
$lines = [];
|
||||||
|
foreach ($categories as $category) {
|
||||||
|
$lines[] = ['type' => 'category', 'text' => strtoupper((string) $category['name'])];
|
||||||
|
foreach ($category['items'] as $item) {
|
||||||
|
$lines[] = [
|
||||||
|
'type' => 'item',
|
||||||
|
'text' => sprintf('%s $%.2f', $item['name'], $item['price_cents'] / 100),
|
||||||
|
];
|
||||||
|
$lines[] = ['type' => 'description', 'text' => (string) $item['description']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (array_chunk($lines, 24) as $chunk) {
|
||||||
|
$pages[] = $this->pageStream($businessName, $chunk);
|
||||||
|
}
|
||||||
|
if ($pages === []) {
|
||||||
|
$pages[] = $this->pageStream($businessName, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdf = $this->buildPdf($pages);
|
||||||
|
header('Content-Type: application/pdf');
|
||||||
|
header('Content-Disposition: attachment; filename="fat-bottom-grille-menu.pdf"');
|
||||||
|
header('Content-Length: ' . strlen($pdf));
|
||||||
|
echo $pdf;
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function pageStream(string $businessName, array $lines): string
|
||||||
|
{
|
||||||
|
$escape = static function (string $value): string {
|
||||||
|
$ascii = function_exists('iconv')
|
||||||
|
? (iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value) ?: $value)
|
||||||
|
: preg_replace('/[^\x20-\x7E]/', '', $value);
|
||||||
|
|
||||||
|
return str_replace(
|
||||||
|
['\\', '(', ')', "\r", "\n"],
|
||||||
|
['\\\\', '\\(', '\\)', ' ', ' '],
|
||||||
|
(string) $ascii
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
$stream = "0.035 0.071 0.102 rg\n0 0 612 792 re f\n";
|
||||||
|
$stream .= "0.78 0.58 0.20 rg\nBT /F1 25 Tf 46 738 Td (" . $escape($businessName) . ") Tj ET\n";
|
||||||
|
$stream .= "0.91 0.92 0.90 rg\nBT /F1 10 Tf 46 718 Td (KEYSER, WEST VIRGINIA | ONLINE MENU) Tj ET\n";
|
||||||
|
$stream .= "0.78 0.58 0.20 RG\n46 704 m 566 704 l S\n";
|
||||||
|
$y = 678;
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$text = $escape(substr((string) $line['text'], 0, 92));
|
||||||
|
if ($line['type'] === 'category') {
|
||||||
|
$stream .= "0.78 0.58 0.20 rg\nBT /F1 15 Tf 46 {$y} Td ({$text}) Tj ET\n";
|
||||||
|
$y -= 24;
|
||||||
|
} elseif ($line['type'] === 'item') {
|
||||||
|
$stream .= "0.96 0.95 0.89 rg\nBT /F1 12 Tf 54 {$y} Td ({$text}) Tj ET\n";
|
||||||
|
$y -= 17;
|
||||||
|
} else {
|
||||||
|
$stream .= "0.68 0.72 0.72 rg\nBT /F1 8 Tf 54 {$y} Td ({$text}) Tj ET\n";
|
||||||
|
$y -= 23;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$stream .= "0.55 0.60 0.62 rg\nBT /F1 8 Tf 46 35 Td (Prices and availability are subject to change.) Tj ET\n";
|
||||||
|
return $stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildPdf(array $streams): string
|
||||||
|
{
|
||||||
|
$objects = [];
|
||||||
|
$objects[1] = '<< /Type /Catalog /Pages 2 0 R >>';
|
||||||
|
$pageIds = [];
|
||||||
|
$nextId = 4;
|
||||||
|
foreach ($streams as $index => $stream) {
|
||||||
|
$pageId = $nextId++;
|
||||||
|
$contentId = $nextId++;
|
||||||
|
$pageIds[] = $pageId . ' 0 R';
|
||||||
|
$objects[$pageId] = sprintf(
|
||||||
|
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 3 0 R >> >> /Contents %d 0 R >>',
|
||||||
|
$contentId
|
||||||
|
);
|
||||||
|
$objects[$contentId] = "<< /Length " . strlen($stream) . " >>\nstream\n{$stream}endstream";
|
||||||
|
}
|
||||||
|
$objects[2] = '<< /Type /Pages /Kids [' . implode(' ', $pageIds) . '] /Count ' . count($pageIds) . ' >>';
|
||||||
|
$objects[3] = '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>';
|
||||||
|
ksort($objects);
|
||||||
|
|
||||||
|
$pdf = "%PDF-1.4\n";
|
||||||
|
$offsets = [0];
|
||||||
|
foreach ($objects as $id => $object) {
|
||||||
|
$offsets[$id] = strlen($pdf);
|
||||||
|
$pdf .= "{$id} 0 obj\n{$object}\nendobj\n";
|
||||||
|
}
|
||||||
|
$xref = strlen($pdf);
|
||||||
|
$count = max(array_keys($objects)) + 1;
|
||||||
|
$pdf .= "xref\n0 {$count}\n0000000000 65535 f \n";
|
||||||
|
for ($id = 1; $id < $count; $id++) {
|
||||||
|
$pdf .= sprintf("%010d 00000 n \n", $offsets[$id] ?? 0);
|
||||||
|
}
|
||||||
|
$pdf .= "trailer\n<< /Size {$count} /Root 1 0 R >>\nstartxref\n{$xref}\n%%EOF";
|
||||||
|
return $pdf;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace FatBottom\Services;
|
||||||
|
|
||||||
|
use FatBottom\Core\Config;
|
||||||
|
use PDO;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class MySqlMigrator
|
||||||
|
{
|
||||||
|
public function __construct(private readonly Config $config)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function migrate(): array
|
||||||
|
{
|
||||||
|
$sqlitePath = BASE_PATH . '/storage/database/store.sqlite';
|
||||||
|
if (!is_file($sqlitePath)) {
|
||||||
|
throw new RuntimeException('The SQLite store database does not exist yet.');
|
||||||
|
}
|
||||||
|
if (!extension_loaded('pdo_mysql')) {
|
||||||
|
throw new RuntimeException('The pdo_mysql PHP extension is required for migration.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$mysql = new PDO(
|
||||||
|
sprintf(
|
||||||
|
'mysql:host=%s;port=%d;charset=utf8mb4',
|
||||||
|
$this->config->get('database.mysql_host'),
|
||||||
|
(int) $this->config->get('database.mysql_port', 3306)
|
||||||
|
),
|
||||||
|
(string) $this->config->get('database.mysql_username'),
|
||||||
|
(string) $this->config->get('database.mysql_password'),
|
||||||
|
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
|
||||||
|
);
|
||||||
|
$database = preg_replace('/[^a-zA-Z0-9_]/', '', (string) $this->config->get('database.mysql_database'));
|
||||||
|
if ($database === '') {
|
||||||
|
throw new RuntimeException('Enter a valid MySQL database name.');
|
||||||
|
}
|
||||||
|
$mysql->exec("CREATE DATABASE IF NOT EXISTS `{$database}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
|
||||||
|
$mysql->exec("USE `{$database}`");
|
||||||
|
|
||||||
|
$sqlite = new PDO('sqlite:' . $sqlitePath, null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
|
||||||
|
$tables = $sqlite->query(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
|
||||||
|
)->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
$rowCount = 0;
|
||||||
|
|
||||||
|
$mysql->exec('SET FOREIGN_KEY_CHECKS = 0');
|
||||||
|
foreach ($tables as $table) {
|
||||||
|
$columns = $sqlite->query("PRAGMA table_info(`{$table}`)")->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
$definitions = [];
|
||||||
|
$primary = [];
|
||||||
|
foreach ($columns as $column) {
|
||||||
|
$type = strtoupper((string) $column['type']);
|
||||||
|
$mysqlType = str_contains($type, 'INT') ? 'BIGINT'
|
||||||
|
: (str_contains($type, 'REAL') || str_contains($type, 'FLOA') ? 'DOUBLE' : 'TEXT');
|
||||||
|
if ((int) $column['pk'] === 1 && $column['name'] === 'id') {
|
||||||
|
$definitions[] = '`id` BIGINT NOT NULL AUTO_INCREMENT';
|
||||||
|
$primary[] = '`id`';
|
||||||
|
} else {
|
||||||
|
$definitions[] = sprintf(
|
||||||
|
'`%s` %s %s',
|
||||||
|
str_replace('`', '', (string) $column['name']),
|
||||||
|
$mysqlType,
|
||||||
|
(int) $column['notnull'] === 1 ? 'NOT NULL' : 'NULL'
|
||||||
|
);
|
||||||
|
if ((int) $column['pk'] === 1) {
|
||||||
|
$primary[] = '`' . str_replace('`', '', (string) $column['name']) . '`';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($primary !== []) {
|
||||||
|
$definitions[] = 'PRIMARY KEY (' . implode(', ', $primary) . ')';
|
||||||
|
}
|
||||||
|
$mysql->exec("DROP TABLE IF EXISTS `{$table}`");
|
||||||
|
$mysql->exec("CREATE TABLE `{$table}` (" . implode(', ', $definitions) . ') ENGINE=InnoDB');
|
||||||
|
|
||||||
|
$rows = $sqlite->query("SELECT * FROM `{$table}`")->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
if ($rows !== []) {
|
||||||
|
$names = array_keys($rows[0]);
|
||||||
|
$placeholders = implode(', ', array_fill(0, count($names), '?'));
|
||||||
|
$quotedNames = implode(', ', array_map(static fn (string $name): string => "`{$name}`", $names));
|
||||||
|
$insert = $mysql->prepare("INSERT INTO `{$table}` ({$quotedNames}) VALUES ({$placeholders})");
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$insert->execute(array_values($row));
|
||||||
|
$rowCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$mysql->exec('SET FOREIGN_KEY_CHECKS = 1');
|
||||||
|
|
||||||
|
return ['tables' => count($tables), 'rows' => $rowCount];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace FatBottom\Services;
|
||||||
|
|
||||||
|
use FatBottom\Core\Database;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class OrderService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly Database $db,
|
||||||
|
private readonly PaymentGateway $payments
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function totals(array $cart): array
|
||||||
|
{
|
||||||
|
$subtotal = (int) $cart['subtotal_cents'];
|
||||||
|
$taxRate = (int) $this->db->fetch(
|
||||||
|
'SELECT COALESCE(SUM(rate_basis_points), 0) AS total_rate FROM tax_rates WHERE active = 1'
|
||||||
|
)['total_rate'];
|
||||||
|
$tax = (int) round($subtotal * $taxRate / 10000);
|
||||||
|
return [
|
||||||
|
'subtotal_cents' => $subtotal,
|
||||||
|
'tax_rate_basis_points' => $taxRate,
|
||||||
|
'tax_cents' => $tax,
|
||||||
|
'total_cents' => $subtotal + $tax,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function place(array $cart, array $customer, ?int $userId, string $sourceId): array
|
||||||
|
{
|
||||||
|
if (empty($cart['items'])) {
|
||||||
|
throw new RuntimeException('Your cart is empty.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$totals = $this->totals($cart);
|
||||||
|
$orderNumber = 'FBG-' . date('ymd') . '-'
|
||||||
|
. strtoupper(substr(hash('sha256', (string) $cart['token']), 0, 6));
|
||||||
|
$payment = $this->payments->charge($sourceId, $totals['total_cents'], $orderNumber);
|
||||||
|
|
||||||
|
$pdo = $this->db->pdo();
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
try {
|
||||||
|
$statement = $pdo->prepare(
|
||||||
|
'INSERT INTO orders
|
||||||
|
(order_number, user_id, cart_id, customer_name, customer_email, customer_phone,
|
||||||
|
order_type, status, payment_status, payment_provider, payment_reference,
|
||||||
|
subtotal_cents, tax_cents, total_cents, special_instructions, pickup_time)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
$statement->execute([
|
||||||
|
$orderNumber,
|
||||||
|
$userId,
|
||||||
|
(int) $cart['id'],
|
||||||
|
$customer['customer_name'],
|
||||||
|
$customer['customer_email'],
|
||||||
|
$customer['customer_phone'],
|
||||||
|
'pickup',
|
||||||
|
'paid',
|
||||||
|
'paid',
|
||||||
|
$payment['provider'],
|
||||||
|
$payment['reference'],
|
||||||
|
$totals['subtotal_cents'],
|
||||||
|
$totals['tax_cents'],
|
||||||
|
$totals['total_cents'],
|
||||||
|
$customer['special_instructions'],
|
||||||
|
$customer['pickup_time'] ?: null,
|
||||||
|
]);
|
||||||
|
$orderId = (int) $pdo->lastInsertId();
|
||||||
|
|
||||||
|
$itemStatement = $pdo->prepare(
|
||||||
|
'INSERT INTO order_items
|
||||||
|
(order_id, menu_item_id, item_name, quantity, unit_price_cents, line_total_cents, notes)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
foreach ($cart['items'] as $item) {
|
||||||
|
$itemStatement->execute([
|
||||||
|
$orderId,
|
||||||
|
(int) $item['menu_item_id'],
|
||||||
|
$item['name'],
|
||||||
|
(int) $item['quantity'],
|
||||||
|
(int) $item['unit_price_cents'],
|
||||||
|
(int) $item['quantity'] * (int) $item['unit_price_cents'],
|
||||||
|
$item['notes'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->prepare(
|
||||||
|
'INSERT INTO order_events (order_id, user_id, event_type, details) VALUES (?, ?, ?, ?)'
|
||||||
|
)->execute([$orderId, $userId, 'order_placed', 'Online pickup order paid successfully.']);
|
||||||
|
$pdo->prepare(
|
||||||
|
"UPDATE carts SET status = 'converted', converted_at = CURRENT_TIMESTAMP WHERE id = ?"
|
||||||
|
)->execute([(int) $cart['id']]);
|
||||||
|
$pdo->commit();
|
||||||
|
} catch (\Throwable $error) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
throw $error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->db->fetch('SELECT * FROM orders WHERE id = ?', [$orderId]) ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace FatBottom\Services;
|
||||||
|
|
||||||
|
use FatBottom\Core\Config;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class PaymentGateway
|
||||||
|
{
|
||||||
|
public function __construct(private readonly Config $config)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function charge(string $sourceId, int $amountCents, string $orderNumber): array
|
||||||
|
{
|
||||||
|
if (!(bool) $this->config->get('square.enabled', false)) {
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'provider' => 'demo',
|
||||||
|
'reference' => 'DEMO-' . strtoupper(bin2hex(random_bytes(5))),
|
||||||
|
'status' => 'COMPLETED',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($sourceId === '') {
|
||||||
|
throw new RuntimeException('Card details are required.');
|
||||||
|
}
|
||||||
|
if (!function_exists('curl_init')) {
|
||||||
|
throw new RuntimeException('The PHP cURL extension is required for Square payments.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$environment = (string) $this->config->get('square.environment', 'sandbox');
|
||||||
|
$baseUrl = $environment === 'production'
|
||||||
|
? 'https://connect.squareup.com'
|
||||||
|
: 'https://connect.squareupsandbox.com';
|
||||||
|
$accessToken = (string) $this->config->get('square.access_token');
|
||||||
|
$locationId = (string) $this->config->get('square.location_id');
|
||||||
|
|
||||||
|
if ($accessToken === '' || $locationId === '') {
|
||||||
|
throw new RuntimeException('Square is enabled but its server credentials are incomplete.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = json_encode([
|
||||||
|
'source_id' => $sourceId,
|
||||||
|
'idempotency_key' => substr(hash('sha256', 'fatbottom-payment|' . $orderNumber), 0, 45),
|
||||||
|
'amount_money' => [
|
||||||
|
'amount' => $amountCents,
|
||||||
|
'currency' => 'USD',
|
||||||
|
],
|
||||||
|
'location_id' => $locationId,
|
||||||
|
'reference_id' => $orderNumber,
|
||||||
|
'note' => 'Online pickup order ' . $orderNumber,
|
||||||
|
'autocomplete' => true,
|
||||||
|
], JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
$curl = curl_init($baseUrl . '/v2/payments');
|
||||||
|
curl_setopt_array($curl, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => $payload,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'Authorization: Bearer ' . $accessToken,
|
||||||
|
'Content-Type: application/json',
|
||||||
|
'Square-Version: 2026-05-20',
|
||||||
|
],
|
||||||
|
CURLOPT_TIMEOUT => 30,
|
||||||
|
]);
|
||||||
|
$response = curl_exec($curl);
|
||||||
|
$status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||||||
|
$error = curl_error($curl);
|
||||||
|
curl_close($curl);
|
||||||
|
|
||||||
|
$decoded = is_string($response) ? json_decode($response, true) : null;
|
||||||
|
if ($response === false || $status < 200 || $status >= 300 || !isset($decoded['payment'])) {
|
||||||
|
$message = $decoded['errors'][0]['detail'] ?? $error ?: 'Payment was declined.';
|
||||||
|
throw new RuntimeException((string) $message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'provider' => 'square',
|
||||||
|
'reference' => (string) $decoded['payment']['id'],
|
||||||
|
'status' => (string) $decoded['payment']['status'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function refund(string $paymentReference, int $amountCents, string $reason): array
|
||||||
|
{
|
||||||
|
if (!(bool) $this->config->get('square.enabled', false) || str_starts_with($paymentReference, 'DEMO-')) {
|
||||||
|
return ['success' => true, 'reference' => 'REF-DEMO-' . strtoupper(bin2hex(random_bytes(4)))];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('curl_init')) {
|
||||||
|
throw new RuntimeException('The PHP cURL extension is required for Square refunds.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$environment = (string) $this->config->get('square.environment', 'sandbox');
|
||||||
|
$baseUrl = $environment === 'production'
|
||||||
|
? 'https://connect.squareup.com'
|
||||||
|
: 'https://connect.squareupsandbox.com';
|
||||||
|
$payload = json_encode([
|
||||||
|
'idempotency_key' => bin2hex(random_bytes(16)),
|
||||||
|
'payment_id' => $paymentReference,
|
||||||
|
'amount_money' => ['amount' => $amountCents, 'currency' => 'USD'],
|
||||||
|
'reason' => substr($reason, 0, 190),
|
||||||
|
], JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
$curl = curl_init($baseUrl . '/v2/refunds');
|
||||||
|
curl_setopt_array($curl, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => $payload,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'Authorization: Bearer ' . $this->config->get('square.access_token'),
|
||||||
|
'Content-Type: application/json',
|
||||||
|
'Square-Version: 2026-05-20',
|
||||||
|
],
|
||||||
|
CURLOPT_TIMEOUT => 30,
|
||||||
|
]);
|
||||||
|
$response = curl_exec($curl);
|
||||||
|
$status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||||||
|
curl_close($curl);
|
||||||
|
$decoded = is_string($response) ? json_decode($response, true) : null;
|
||||||
|
if ($status < 200 || $status >= 300 || !isset($decoded['refund'])) {
|
||||||
|
throw new RuntimeException((string) ($decoded['errors'][0]['detail'] ?? 'Refund failed.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['success' => true, 'reference' => (string) $decoded['refund']['id']];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace FatBottom\Services;
|
||||||
|
|
||||||
|
use FatBottom\Core\Database;
|
||||||
|
|
||||||
|
final class StatsService
|
||||||
|
{
|
||||||
|
public function __construct(private readonly Database $db)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function record(string $route, ?int $userId): void
|
||||||
|
{
|
||||||
|
if (str_starts_with($route, '/assets/')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
|
||||||
|
$this->db->execute(
|
||||||
|
'INSERT INTO visitor_events (session_id, user_id, route, referrer, user_agent, ip_hash)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)',
|
||||||
|
[
|
||||||
|
session_id(),
|
||||||
|
$userId,
|
||||||
|
substr($route, 0, 255),
|
||||||
|
substr((string) ($_SERVER['HTTP_REFERER'] ?? ''), 0, 500),
|
||||||
|
substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 500),
|
||||||
|
hash('sha256', $ip . date('Y-m-d')),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dashboard(): array
|
||||||
|
{
|
||||||
|
if (!$this->db->isSqlite()) {
|
||||||
|
return [
|
||||||
|
'today_orders' => (int) $this->db->fetch(
|
||||||
|
'SELECT COUNT(*) AS value FROM orders WHERE DATE(placed_at) = CURRENT_DATE'
|
||||||
|
)['value'],
|
||||||
|
'today_sales' => (int) $this->db->fetch(
|
||||||
|
"SELECT COALESCE(SUM(o.total_cents), 0) - COALESCE(SUM(r.refunded_cents), 0) AS value
|
||||||
|
FROM orders o
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT order_id, SUM(amount_cents) AS refunded_cents FROM refunds GROUP BY order_id
|
||||||
|
) r ON r.order_id = o.id
|
||||||
|
WHERE o.payment_status IN ('paid', 'partially_refunded', 'refunded')
|
||||||
|
AND DATE(o.placed_at) = CURRENT_DATE"
|
||||||
|
)['value'],
|
||||||
|
'open_orders' => (int) $this->db->fetch(
|
||||||
|
"SELECT COUNT(*) AS value FROM orders WHERE status IN ('paid', 'preparing', 'ready')"
|
||||||
|
)['value'],
|
||||||
|
'abandoned_carts' => (int) $this->db->fetch(
|
||||||
|
"SELECT COUNT(*) AS value FROM carts
|
||||||
|
WHERE status = 'active' AND subtotal_cents > 0
|
||||||
|
AND last_seen_at < DATE_SUB(NOW(), INTERVAL 30 MINUTE)"
|
||||||
|
)['value'],
|
||||||
|
'today_visitors' => (int) $this->db->fetch(
|
||||||
|
'SELECT COUNT(DISTINCT session_id) AS value FROM visitor_events
|
||||||
|
WHERE DATE(created_at) = CURRENT_DATE'
|
||||||
|
)['value'],
|
||||||
|
'newsletter_members' => (int) $this->db->fetch(
|
||||||
|
'SELECT COUNT(*) AS value FROM users WHERE newsletter_opt_in = 1 AND active = 1'
|
||||||
|
)['value'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'today_orders' => (int) $this->db->fetch(
|
||||||
|
"SELECT COUNT(*) AS value FROM orders
|
||||||
|
WHERE date(placed_at, 'localtime') = date('now', 'localtime')"
|
||||||
|
)['value'],
|
||||||
|
'today_sales' => (int) $this->db->fetch(
|
||||||
|
"SELECT COALESCE(SUM(o.total_cents), 0) - COALESCE(SUM(r.refunded_cents), 0) AS value
|
||||||
|
FROM orders o
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT order_id, SUM(amount_cents) AS refunded_cents FROM refunds GROUP BY order_id
|
||||||
|
) r ON r.order_id = o.id
|
||||||
|
WHERE o.payment_status IN ('paid', 'partially_refunded', 'refunded')
|
||||||
|
AND date(o.placed_at, 'localtime') = date('now', 'localtime')"
|
||||||
|
)['value'],
|
||||||
|
'open_orders' => (int) $this->db->fetch(
|
||||||
|
"SELECT COUNT(*) AS value FROM orders WHERE status IN ('paid', 'preparing', 'ready')"
|
||||||
|
)['value'],
|
||||||
|
'abandoned_carts' => (int) $this->db->fetch(
|
||||||
|
"SELECT COUNT(*) AS value FROM carts
|
||||||
|
WHERE status = 'active' AND subtotal_cents > 0
|
||||||
|
AND last_seen_at < datetime('now', '-30 minutes')"
|
||||||
|
)['value'],
|
||||||
|
'today_visitors' => (int) $this->db->fetch(
|
||||||
|
"SELECT COUNT(DISTINCT session_id) AS value FROM visitor_events
|
||||||
|
WHERE date(created_at, 'localtime') = date('now', 'localtime')"
|
||||||
|
)['value'],
|
||||||
|
'newsletter_members' => (int) $this->db->fetch(
|
||||||
|
'SELECT COUNT(*) AS value FROM users WHERE newsletter_opt_in = 1 AND active = 1'
|
||||||
|
)['value'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace FatBottom\Services;
|
||||||
|
|
||||||
|
use FatBottom\Core\Config;
|
||||||
|
use FatBottom\Core\Database;
|
||||||
|
|
||||||
|
final class TwoFactorService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly Database $db,
|
||||||
|
private readonly Mailer $mailer,
|
||||||
|
private readonly Config $config
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function issue(array $user, string $purpose = 'login'): ?string
|
||||||
|
{
|
||||||
|
$code = (string) random_int(100000, 999999);
|
||||||
|
$expiresAt = gmdate('Y-m-d H:i:s', time() + 600);
|
||||||
|
|
||||||
|
$this->db->execute(
|
||||||
|
'INSERT INTO login_codes (user_id, code_hash, purpose, expires_at) VALUES (?, ?, ?, ?)',
|
||||||
|
[(int) $user['id'], password_hash($code, PASSWORD_DEFAULT), $purpose, $expiresAt]
|
||||||
|
);
|
||||||
|
|
||||||
|
$name = htmlspecialchars((string) $user['full_name'], ENT_QUOTES, 'UTF-8');
|
||||||
|
$this->mailer->send(
|
||||||
|
(string) $user['email'],
|
||||||
|
'Your Fat Bottom Grille verification code',
|
||||||
|
"<p>Hi {$name},</p><p>Your verification code is <strong>{$code}</strong>.</p><p>It expires in 10 minutes.</p>"
|
||||||
|
);
|
||||||
|
|
||||||
|
return (bool) $this->config->get('mailgun.enabled', false) ? null : $code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verify(int $userId, string $code, string $purpose = 'login'): bool
|
||||||
|
{
|
||||||
|
$record = $this->db->fetch(
|
||||||
|
'SELECT * FROM login_codes
|
||||||
|
WHERE user_id = ? AND purpose = ? AND used_at IS NULL AND expires_at > CURRENT_TIMESTAMP
|
||||||
|
ORDER BY id DESC LIMIT 1',
|
||||||
|
[$userId, $purpose]
|
||||||
|
);
|
||||||
|
if ($record === null || !password_verify($code, (string) $record['code_hash'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->db->execute('UPDATE login_codes SET used_at = CURRENT_TIMESTAMP WHERE id = ?', [(int) $record['id']]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
define('BASE_PATH', dirname(__DIR__));
|
||||||
|
|
||||||
|
require_once BASE_PATH . '/app/helpers.php';
|
||||||
|
|
||||||
|
spl_autoload_register(static function (string $class): void {
|
||||||
|
$prefix = 'FatBottom\\';
|
||||||
|
if (!str_starts_with($class, $prefix)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$relative = str_replace('\\', '/', substr($class, strlen($prefix)));
|
||||||
|
$path = BASE_PATH . '/app/' . $relative . '.php';
|
||||||
|
if (is_file($path)) {
|
||||||
|
require $path;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
use FatBottom\Core\Config;
|
||||||
|
use FatBottom\Core\Database;
|
||||||
|
|
||||||
|
$config = new Config(
|
||||||
|
require BASE_PATH . '/config/defaults.php',
|
||||||
|
BASE_PATH . '/storage/config/settings.json'
|
||||||
|
);
|
||||||
|
|
||||||
|
if ((string) $config->get('app.key', '') === '') {
|
||||||
|
$config->set('app.key', bin2hex(random_bytes(32)));
|
||||||
|
$config->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
date_default_timezone_set((string) $config->get('app.timezone', 'America/New_York'));
|
||||||
|
|
||||||
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||||
|
$sessionPath = BASE_PATH . '/storage/sessions';
|
||||||
|
if (!is_dir($sessionPath)) {
|
||||||
|
mkdir($sessionPath, 0775, true);
|
||||||
|
}
|
||||||
|
session_save_path($sessionPath);
|
||||||
|
session_name('fatbottom_session');
|
||||||
|
session_set_cookie_params([
|
||||||
|
'lifetime' => 60 * 60 * 24 * 14,
|
||||||
|
'path' => '/',
|
||||||
|
'secure' => !empty($_SERVER['HTTPS']),
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => 'Lax',
|
||||||
|
]);
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
$database = new Database($config);
|
||||||
|
$database->migrate();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'config' => $config,
|
||||||
|
'db' => $database,
|
||||||
|
];
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use FatBottom\Core\Security;
|
||||||
|
|
||||||
|
function e(mixed $value): string
|
||||||
|
{
|
||||||
|
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function money(int|string|null $cents): string
|
||||||
|
{
|
||||||
|
return '$' . number_format(((int) $cents) / 100, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function csrf_field(): string
|
||||||
|
{
|
||||||
|
return '<input type="hidden" name="_token" value="' . e(Security::csrfToken()) . '">';
|
||||||
|
}
|
||||||
|
|
||||||
|
function active_path(string $path): string
|
||||||
|
{
|
||||||
|
$current = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
|
||||||
|
return $current === $path ? 'is-active' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function selected(mixed $value, mixed $expected): string
|
||||||
|
{
|
||||||
|
return (string) $value === (string) $expected ? 'selected' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function checked(mixed $value): string
|
||||||
|
{
|
||||||
|
return (bool) $value ? 'checked' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function store_datetime(?string $value, string $format = 'M j, Y g:i A'): string
|
||||||
|
{
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$date = new DateTimeImmutable($value, new DateTimeZone('UTC'));
|
||||||
|
return $date->setTimezone(new DateTimeZone(date_default_timezone_get()))->format($format);
|
||||||
|
}
|
||||||
|
|
||||||
|
function utc_datetime(?string $value): ?string
|
||||||
|
{
|
||||||
|
if ($value === null || trim($value) === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$date = new DateTimeImmutable($value, new DateTimeZone(date_default_timezone_get()));
|
||||||
|
return $date->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s');
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'app' => [
|
||||||
|
'name' => 'Fat Bottom Grille',
|
||||||
|
'url' => 'http://localhost:8080',
|
||||||
|
'key' => '',
|
||||||
|
'timezone' => 'America/New_York',
|
||||||
|
'environment' => 'development',
|
||||||
|
],
|
||||||
|
'business' => [
|
||||||
|
'name' => 'Fat Bottom Grille',
|
||||||
|
'phone' => '',
|
||||||
|
'email' => '',
|
||||||
|
'street' => '410 W Piedmont St',
|
||||||
|
'city' => 'Keyser',
|
||||||
|
'state' => 'WV',
|
||||||
|
'postal_code' => '26726',
|
||||||
|
'hours' => 'Hours coming soon',
|
||||||
|
'announcement' => 'Big flavor, made right here in Keyser.',
|
||||||
|
],
|
||||||
|
'ordering' => [
|
||||||
|
'accepting_orders' => true,
|
||||||
|
'pickup_eta_minutes' => 25,
|
||||||
|
'minimum_order_cents' => 0,
|
||||||
|
],
|
||||||
|
'square' => [
|
||||||
|
'enabled' => false,
|
||||||
|
'environment' => 'sandbox',
|
||||||
|
'application_id' => '',
|
||||||
|
'location_id' => '',
|
||||||
|
'access_token' => '',
|
||||||
|
],
|
||||||
|
'mailgun' => [
|
||||||
|
'enabled' => false,
|
||||||
|
'domain' => '',
|
||||||
|
'api_key' => '',
|
||||||
|
'from_name' => 'Fat Bottom Grille',
|
||||||
|
'from_email' => 'orders@fatbottomgrille.com',
|
||||||
|
],
|
||||||
|
'database' => [
|
||||||
|
'driver' => 'sqlite',
|
||||||
|
'mysql_host' => '127.0.0.1',
|
||||||
|
'mysql_port' => 3306,
|
||||||
|
'mysql_database' => 'fatbottomgrille',
|
||||||
|
'mysql_username' => '',
|
||||||
|
'mysql_password' => '',
|
||||||
|
],
|
||||||
|
];
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
|||||||
|
(() => {
|
||||||
|
const navToggle = document.querySelector(".nav-toggle");
|
||||||
|
const nav = document.querySelector(".site-nav");
|
||||||
|
if (navToggle && nav) {
|
||||||
|
navToggle.addEventListener("click", () => {
|
||||||
|
const open = nav.classList.toggle("is-open");
|
||||||
|
navToggle.setAttribute("aria-expanded", String(open));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll(".flash-close").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => button.closest(".flash")?.remove());
|
||||||
|
});
|
||||||
|
|
||||||
|
window.setTimeout(() => {
|
||||||
|
document.querySelectorAll(".flash").forEach((flash) => {
|
||||||
|
flash.style.opacity = "0";
|
||||||
|
window.setTimeout(() => flash.remove(), 220);
|
||||||
|
});
|
||||||
|
}, 7000);
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-confirm]").forEach((element) => {
|
||||||
|
element.addEventListener("click", (event) => {
|
||||||
|
if (!window.confirm(element.dataset.confirm || "Continue?")) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const checkoutForm = document.querySelector("#checkout-form");
|
||||||
|
if (!checkoutForm || checkoutForm.dataset.squareEnabled !== "true") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const appId = checkoutForm.dataset.squareAppId;
|
||||||
|
const locationId = checkoutForm.dataset.squareLocationId;
|
||||||
|
const submit = document.querySelector("#checkout-submit");
|
||||||
|
const errors = document.querySelector("#card-errors");
|
||||||
|
let card;
|
||||||
|
|
||||||
|
const initializeSquare = async () => {
|
||||||
|
if (!window.Square || !appId || !locationId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const payments = window.Square.payments(appId, locationId);
|
||||||
|
card = await payments.card();
|
||||||
|
await card.attach("#card-container");
|
||||||
|
} catch (error) {
|
||||||
|
if (errors) {
|
||||||
|
errors.textContent = "Payment form could not be loaded. Please refresh and try again.";
|
||||||
|
}
|
||||||
|
if (submit) {
|
||||||
|
submit.disabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkoutForm.addEventListener("submit", async (event) => {
|
||||||
|
if (!card || checkoutForm.dataset.tokenized === "true") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
submit.disabled = true;
|
||||||
|
submit.textContent = "Securing Payment...";
|
||||||
|
errors.textContent = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await card.tokenize();
|
||||||
|
if (result.status !== "OK") {
|
||||||
|
throw new Error(result.errors?.[0]?.message || "Card details could not be verified.");
|
||||||
|
}
|
||||||
|
document.querySelector("#square-token").value = result.token;
|
||||||
|
checkoutForm.dataset.tokenized = "true";
|
||||||
|
checkoutForm.submit();
|
||||||
|
} catch (error) {
|
||||||
|
errors.textContent = error.message || "Payment could not be processed.";
|
||||||
|
submit.disabled = false;
|
||||||
|
submit.textContent = "Try Payment Again";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
initializeSquare();
|
||||||
|
})();
|
||||||
|
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use FatBottom\Controllers\AdminController;
|
||||||
|
use FatBottom\Controllers\AuthController;
|
||||||
|
use FatBottom\Controllers\StoreController;
|
||||||
|
use FatBottom\Core\Auth;
|
||||||
|
use FatBottom\Core\Http;
|
||||||
|
use FatBottom\Core\Router;
|
||||||
|
use FatBottom\Services\CartService;
|
||||||
|
use FatBottom\Services\Mailer;
|
||||||
|
use FatBottom\Services\OrderService;
|
||||||
|
use FatBottom\Services\PaymentGateway;
|
||||||
|
use FatBottom\Services\StatsService;
|
||||||
|
use FatBottom\Services\TwoFactorService;
|
||||||
|
|
||||||
|
$container = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
$config = $container['config'];
|
||||||
|
$db = $container['db'];
|
||||||
|
|
||||||
|
$auth = new Auth($db);
|
||||||
|
$cart = new CartService($db);
|
||||||
|
$mailer = new Mailer($config);
|
||||||
|
$payments = new PaymentGateway($config);
|
||||||
|
$orders = new OrderService($db, $payments);
|
||||||
|
$stats = new StatsService($db);
|
||||||
|
$twoFactor = new TwoFactorService($db, $mailer, $config);
|
||||||
|
|
||||||
|
$store = new StoreController($config, $auth, $cart, $db, $orders, $mailer);
|
||||||
|
$authentication = new AuthController($config, $auth, $cart, $db, $twoFactor);
|
||||||
|
$admin = new AdminController($config, $auth, $cart, $db, $stats, $payments, $mailer);
|
||||||
|
|
||||||
|
$path = Http::path();
|
||||||
|
$user = $auth->user();
|
||||||
|
$stats->record($path, $user ? (int) $user['id'] : null);
|
||||||
|
|
||||||
|
$router = new Router();
|
||||||
|
$router->get('/', [$store, 'home']);
|
||||||
|
$router->get('/menu', [$store, 'menu']);
|
||||||
|
$router->post('/cart/add', [$store, 'addToCart']);
|
||||||
|
$router->get('/cart', [$store, 'cart']);
|
||||||
|
$router->post('/cart/update', [$store, 'updateCart']);
|
||||||
|
$router->post('/cart/remove', [$store, 'removeFromCart']);
|
||||||
|
$router->get('/checkout', [$store, 'checkout']);
|
||||||
|
$router->post('/checkout', [$store, 'placeOrder']);
|
||||||
|
$router->get('/order/complete', [$store, 'orderComplete']);
|
||||||
|
|
||||||
|
$router->get('/login', [$authentication, 'loginForm']);
|
||||||
|
$router->post('/login', [$authentication, 'login']);
|
||||||
|
$router->get('/register', [$authentication, 'registerForm']);
|
||||||
|
$router->post('/register', [$authentication, 'register']);
|
||||||
|
$router->get('/verify', [$authentication, 'verifyForm']);
|
||||||
|
$router->post('/verify', [$authentication, 'verify']);
|
||||||
|
$router->post('/logout', [$authentication, 'logout']);
|
||||||
|
$router->get('/account', [$authentication, 'account']);
|
||||||
|
$router->post('/account', [$authentication, 'updateAccount']);
|
||||||
|
$router->get('/unsubscribe', [$authentication, 'unsubscribe']);
|
||||||
|
|
||||||
|
$router->get('/admin', [$admin, 'dashboard']);
|
||||||
|
$router->get('/admin/orders', [$admin, 'orders']);
|
||||||
|
$router->get('/admin/order', [$admin, 'orderDetail']);
|
||||||
|
$router->post('/admin/order', [$admin, 'updateOrder']);
|
||||||
|
$router->post('/admin/order/refund', [$admin, 'refundOrder']);
|
||||||
|
$router->get('/admin/menu', [$admin, 'menu']);
|
||||||
|
$router->post('/admin/category/save', [$admin, 'saveCategory']);
|
||||||
|
$router->post('/admin/item/save', [$admin, 'saveItem']);
|
||||||
|
$router->get('/admin/specials', [$admin, 'specials']);
|
||||||
|
$router->post('/admin/special/save', [$admin, 'saveSpecial']);
|
||||||
|
$router->get('/admin/users', [$admin, 'users']);
|
||||||
|
$router->post('/admin/user/save', [$admin, 'saveUser']);
|
||||||
|
$router->get('/admin/settings', [$admin, 'settings']);
|
||||||
|
$router->post('/admin/settings', [$admin, 'saveSettings']);
|
||||||
|
$router->post('/admin/tax/save', [$admin, 'saveTax']);
|
||||||
|
$router->get('/admin/newsletters', [$admin, 'newsletters']);
|
||||||
|
$router->post('/admin/newsletters/send', [$admin, 'sendNewsletter']);
|
||||||
|
$router->get('/admin/export/menu.pdf', [$admin, 'exportMenuPdf']);
|
||||||
|
$router->get('/admin/export/orders.csv', [$admin, 'exportOrdersCsv']);
|
||||||
|
$router->post('/admin/migrate/mysql', [$admin, 'migrateMySql']);
|
||||||
|
|
||||||
|
$router->dispatch($_SERVER['REQUEST_METHOD'] ?? 'GET', $path);
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
|
||||||
|
$file = __DIR__ . $path;
|
||||||
|
if ($path !== '/' && is_file($file)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
require __DIR__ . '/index.php';
|
||||||
|
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<section class="page-hero page-hero-compact">
|
||||||
|
<div class="container">
|
||||||
|
<span class="eyebrow">Good to see you</span>
|
||||||
|
<h1>My Account</h1>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="container account-grid section">
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Your details</span>
|
||||||
|
<h2>Pickup Profile</h2>
|
||||||
|
</div>
|
||||||
|
<span class="account-email"><?= e($currentUser['email']) ?></span>
|
||||||
|
</div>
|
||||||
|
<form class="form-stack" action="/account" method="post">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>Full name
|
||||||
|
<input type="text" name="full_name" required value="<?= e($currentUser['full_name']) ?>">
|
||||||
|
</label>
|
||||||
|
<label>Phone
|
||||||
|
<input type="tel" name="phone" required value="<?= e($currentUser['phone']) ?>">
|
||||||
|
</label>
|
||||||
|
<label class="field-wide">Street address
|
||||||
|
<input type="text" name="street_address" value="<?= e($currentUser['street_address']) ?>">
|
||||||
|
</label>
|
||||||
|
<label>City
|
||||||
|
<input type="text" name="city" value="<?= e($currentUser['city']) ?>">
|
||||||
|
</label>
|
||||||
|
<label>State
|
||||||
|
<input type="text" name="state" maxlength="2" value="<?= e($currentUser['state']) ?>">
|
||||||
|
</label>
|
||||||
|
<label>ZIP code
|
||||||
|
<input type="text" name="postal_code" value="<?= e($currentUser['postal_code']) ?>">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="check-control">
|
||||||
|
<input type="checkbox" name="newsletter_opt_in" value="1" <?= checked($currentUser['newsletter_opt_in']) ?>>
|
||||||
|
<span>
|
||||||
|
<strong>Send me restaurant news and specials</strong>
|
||||||
|
<small>You can change this any time.</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<details class="account-password">
|
||||||
|
<summary>Change password</summary>
|
||||||
|
<div class="form-stack">
|
||||||
|
<label>Current password
|
||||||
|
<input type="password" name="current_password" autocomplete="current-password">
|
||||||
|
</label>
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>New password
|
||||||
|
<input type="password" name="new_password" minlength="10" autocomplete="new-password">
|
||||||
|
</label>
|
||||||
|
<label>Confirm new password
|
||||||
|
<input type="password" name="new_password_confirmation" minlength="10" autocomplete="new-password">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<button class="button" type="submit">Save Account</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Past pickups</span>
|
||||||
|
<h2>Order History</h2>
|
||||||
|
</div>
|
||||||
|
<a class="button button-small" href="/menu">Order Again</a>
|
||||||
|
</div>
|
||||||
|
<?php if (!$orders): ?>
|
||||||
|
<div class="empty-state compact">
|
||||||
|
<p>Your first online order will appear here.</p>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="order-history">
|
||||||
|
<?php foreach ($orders as $order): ?>
|
||||||
|
<article>
|
||||||
|
<div>
|
||||||
|
<strong><?= e($order['order_number']) ?></strong>
|
||||||
|
<span><?= e(store_datetime((string) $order['placed_at'])) ?></span>
|
||||||
|
</div>
|
||||||
|
<span class="status-pill status-<?= e($order['status']) ?>"><?= e(ucwords(str_replace('_', ' ', (string) $order['status']))) ?></span>
|
||||||
|
<strong><?= money($order['total_cents']) ?></strong>
|
||||||
|
</article>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<div class="stat-grid">
|
||||||
|
<article class="stat-card stat-primary">
|
||||||
|
<span>Today’s sales</span>
|
||||||
|
<strong><?= money($stats['today_sales']) ?></strong>
|
||||||
|
<small><?= (int) $stats['today_orders'] ?> paid orders</small>
|
||||||
|
</article>
|
||||||
|
<article class="stat-card">
|
||||||
|
<span>Open orders</span>
|
||||||
|
<strong><?= (int) $stats['open_orders'] ?></strong>
|
||||||
|
<small>Paid through ready</small>
|
||||||
|
</article>
|
||||||
|
<article class="stat-card">
|
||||||
|
<span>Abandoned carts</span>
|
||||||
|
<strong><?= (int) $stats['abandoned_carts'] ?></strong>
|
||||||
|
<small>Inactive for 30+ minutes</small>
|
||||||
|
</article>
|
||||||
|
<article class="stat-card">
|
||||||
|
<span>Today’s visitors</span>
|
||||||
|
<strong><?= (int) $stats['today_visitors'] ?></strong>
|
||||||
|
<small><?= (int) $stats['newsletter_members'] ?> news subscribers</small>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (password_verify('ChangeMe123!', (string) $currentUser['password_hash'])): ?>
|
||||||
|
<div class="notice admin-security-notice">
|
||||||
|
<strong>Security reminder:</strong> the initial administrator password is still active.
|
||||||
|
Change it from <a href="/account">My Account</a> before deployment.
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="admin-dashboard-grid">
|
||||||
|
<section class="admin-panel admin-panel-wide">
|
||||||
|
<div class="admin-panel-heading">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Live queue</span>
|
||||||
|
<h2>Recent Orders</h2>
|
||||||
|
</div>
|
||||||
|
<a class="button button-small" href="/admin/orders">View All Orders</a>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Order</th><th>Customer</th><th>Placed</th><th>Status</th><th>Total</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<?php if (!$recentOrders): ?>
|
||||||
|
<tr><td colspan="5" class="empty-cell">No orders yet. The kitchen is standing by.</td></tr>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php foreach ($recentOrders as $order): ?>
|
||||||
|
<tr>
|
||||||
|
<td><a href="/admin/order?id=<?= (int) $order['id'] ?>"><?= e($order['order_number']) ?></a></td>
|
||||||
|
<td><?= e($order['customer_name']) ?><small><?= e($order['customer_phone']) ?></small></td>
|
||||||
|
<td><?= e(store_datetime((string) $order['placed_at'], 'g:i A')) ?></td>
|
||||||
|
<td><span class="status-pill status-<?= e($order['status']) ?>"><?= e(ucfirst((string) $order['status'])) ?></span></td>
|
||||||
|
<td><?= money($order['total_cents']) ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="admin-panel">
|
||||||
|
<div class="admin-panel-heading">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Last 7 days</span>
|
||||||
|
<h2>Store Traffic</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php $maxTraffic = max(1, ...array_map(static fn (array $row): int => (int) $row['visitors'], $traffic)); ?>
|
||||||
|
<div class="bar-chart">
|
||||||
|
<?php if (!$traffic): ?><p class="muted">Traffic data will appear as visitors arrive.</p><?php endif; ?>
|
||||||
|
<?php foreach ($traffic as $day): ?>
|
||||||
|
<div>
|
||||||
|
<span class="bar-value"><?= (int) $day['visitors'] ?></span>
|
||||||
|
<i style="height:<?= max(8, round(((int) $day['visitors'] / $maxTraffic) * 120)) ?>px"></i>
|
||||||
|
<small><?= e((new DateTimeImmutable((string) $day['day']))->format('D')) ?></small>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="admin-panel">
|
||||||
|
<div class="admin-panel-heading">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">What’s moving</span>
|
||||||
|
<h2>Popular Items</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rank-list">
|
||||||
|
<?php if (!$popularItems): ?><p class="muted">Item sales will appear after the first order.</p><?php endif; ?>
|
||||||
|
<?php foreach ($popularItems as $index => $item): ?>
|
||||||
|
<div>
|
||||||
|
<span><?= $index + 1 ?></span>
|
||||||
|
<strong><?= e($item['item_name']) ?></strong>
|
||||||
|
<small><?= (int) $item['quantity'] ?> sold</small>
|
||||||
|
<b><?= money($item['sales_cents']) ?></b>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<div class="admin-page-actions">
|
||||||
|
<p>Categories and items appear on the storefront as soon as they are active.</p>
|
||||||
|
<a class="button button-ghost" href="/admin/export/menu.pdf">Download Styled PDF</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-stack">
|
||||||
|
<section class="admin-panel">
|
||||||
|
<div class="admin-panel-heading">
|
||||||
|
<div><span class="eyebrow">Organize the storefront</span><h2>Menu Categories</h2></div>
|
||||||
|
<span class="count-badge"><?= count($categories) ?> categories</span>
|
||||||
|
</div>
|
||||||
|
<div class="editable-list">
|
||||||
|
<?php foreach ($categories as $category): ?>
|
||||||
|
<details>
|
||||||
|
<summary>
|
||||||
|
<span class="drag-index"><?= (int) $category['display_order'] ?></span>
|
||||||
|
<span><strong><?= e($category['name']) ?></strong><small><?= e($category['description']) ?></small></span>
|
||||||
|
<span class="status-dot <?= $category['active'] ? 'is-on' : '' ?>"><?= $category['active'] ? 'Active' : 'Hidden' ?></span>
|
||||||
|
<span class="edit-label">Edit</span>
|
||||||
|
</summary>
|
||||||
|
<form class="inline-editor form-stack" action="/admin/category/save" method="post">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="id" value="<?= (int) $category['id'] ?>">
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>Name<input type="text" name="name" required value="<?= e($category['name']) ?>"></label>
|
||||||
|
<label>URL slug<input type="text" name="slug" required value="<?= e($category['slug']) ?>"></label>
|
||||||
|
<label class="field-wide">Description<input type="text" name="description" value="<?= e($category['description']) ?>"></label>
|
||||||
|
<label>Display order<input type="number" name="display_order" value="<?= (int) $category['display_order'] ?>"></label>
|
||||||
|
<label class="check-control compact"><input type="checkbox" name="active" value="1" <?= checked($category['active']) ?>><span><strong>Visible</strong></span></label>
|
||||||
|
</div>
|
||||||
|
<button class="button button-small" type="submit">Save Category</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<details class="new-record">
|
||||||
|
<summary>+ Add Category</summary>
|
||||||
|
<form class="inline-editor form-stack" action="/admin/category/save" method="post">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>Name<input type="text" name="name" required></label>
|
||||||
|
<label>URL slug<input type="text" name="slug" placeholder="Generated from name if blank"></label>
|
||||||
|
<label class="field-wide">Description<input type="text" name="description"></label>
|
||||||
|
<label>Display order<input type="number" name="display_order" value="60"></label>
|
||||||
|
<label class="check-control compact"><input type="checkbox" name="active" value="1" checked><span><strong>Visible</strong></span></label>
|
||||||
|
</div>
|
||||||
|
<button class="button button-small" type="submit">Create Category</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="admin-panel">
|
||||||
|
<div class="admin-panel-heading">
|
||||||
|
<div><span class="eyebrow">Price and availability</span><h2>Menu Items</h2></div>
|
||||||
|
<span class="count-badge"><?= count($items) ?> items</span>
|
||||||
|
</div>
|
||||||
|
<div class="editable-list item-editable-list">
|
||||||
|
<?php foreach ($items as $item): ?>
|
||||||
|
<?php $tags = json_decode((string) $item['dietary_tags'], true); ?>
|
||||||
|
<details>
|
||||||
|
<summary>
|
||||||
|
<span class="item-avatar"><?= e(strtoupper(substr((string) $item['name'], 0, 2))) ?></span>
|
||||||
|
<span><strong><?= e($item['name']) ?></strong><small><?= e($item['category_name']) ?></small></span>
|
||||||
|
<strong class="item-price"><?= money($item['price_cents']) ?></strong>
|
||||||
|
<span class="status-dot <?= $item['active'] ? 'is-on' : '' ?>"><?= $item['active'] ? 'Active' : 'Hidden' ?></span>
|
||||||
|
<span class="edit-label">Edit</span>
|
||||||
|
</summary>
|
||||||
|
<form class="inline-editor form-stack" action="/admin/item/save" method="post">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="id" value="<?= (int) $item['id'] ?>">
|
||||||
|
<div class="form-grid form-grid-three">
|
||||||
|
<label>Name<input type="text" name="name" required value="<?= e($item['name']) ?>"></label>
|
||||||
|
<label>Category
|
||||||
|
<select name="category_id" required>
|
||||||
|
<?php foreach ($categories as $category): ?>
|
||||||
|
<option value="<?= (int) $category['id'] ?>" <?= selected($item['category_id'], $category['id']) ?>><?= e($category['name']) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Price<input type="number" name="price" min="0" step="0.01" required value="<?= number_format((int) $item['price_cents'] / 100, 2, '.', '') ?>"></label>
|
||||||
|
<label>Compare-at price<input type="number" name="compare_at_price" min="0" step="0.01" value="<?= $item['compare_at_cents'] !== null ? number_format((int) $item['compare_at_cents'] / 100, 2, '.', '') : '' ?>"></label>
|
||||||
|
<label>URL slug<input type="text" name="slug" value="<?= e($item['slug']) ?>"></label>
|
||||||
|
<label>Display order<input type="number" name="display_order" value="<?= (int) $item['display_order'] ?>"></label>
|
||||||
|
<label class="field-wide">Description<textarea name="description" rows="2"><?= e($item['description']) ?></textarea></label>
|
||||||
|
<label class="field-wide">Image URL<input type="url" name="image_url" value="<?= e($item['image_url']) ?>" placeholder="Optional HTTPS image"></label>
|
||||||
|
<label class="field-wide">Tags, comma separated<input type="text" name="dietary_tags" value="<?= e(is_array($tags) ? implode(', ', $tags) : '') ?>"></label>
|
||||||
|
</div>
|
||||||
|
<div class="check-row">
|
||||||
|
<label class="check-control compact"><input type="checkbox" name="active" value="1" <?= checked($item['active']) ?>><span><strong>Available</strong></span></label>
|
||||||
|
<label class="check-control compact"><input type="checkbox" name="featured" value="1" <?= checked($item['featured']) ?>><span><strong>Featured</strong></span></label>
|
||||||
|
</div>
|
||||||
|
<button class="button button-small" type="submit">Save Item</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<details class="new-record">
|
||||||
|
<summary>+ Add Menu Item</summary>
|
||||||
|
<form class="inline-editor form-stack" action="/admin/item/save" method="post">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<div class="form-grid form-grid-three">
|
||||||
|
<label>Name<input type="text" name="name" required></label>
|
||||||
|
<label>Category
|
||||||
|
<select name="category_id" required>
|
||||||
|
<?php foreach ($categories as $category): ?><option value="<?= (int) $category['id'] ?>"><?= e($category['name']) ?></option><?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Price<input type="number" name="price" min="0" step="0.01" required></label>
|
||||||
|
<label>Compare-at price<input type="number" name="compare_at_price" min="0" step="0.01"></label>
|
||||||
|
<label>URL slug<input type="text" name="slug" placeholder="Generated from name"></label>
|
||||||
|
<label>Display order<input type="number" name="display_order" value="0"></label>
|
||||||
|
<label class="field-wide">Description<textarea name="description" rows="2"></textarea></label>
|
||||||
|
<label class="field-wide">Image URL<input type="url" name="image_url"></label>
|
||||||
|
<label class="field-wide">Tags, comma separated<input type="text" name="dietary_tags"></label>
|
||||||
|
</div>
|
||||||
|
<div class="check-row">
|
||||||
|
<label class="check-control compact"><input type="checkbox" name="active" value="1" checked><span><strong>Available</strong></span></label>
|
||||||
|
<label class="check-control compact"><input type="checkbox" name="featured" value="1"><span><strong>Featured</strong></span></label>
|
||||||
|
</div>
|
||||||
|
<button class="button button-small" type="submit">Create Item</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<div class="admin-page-actions">
|
||||||
|
<p><strong><?= (int) $recipientCount ?></strong> active users are currently opted in.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-detail-grid newsletter-grid">
|
||||||
|
<section class="admin-panel">
|
||||||
|
<div class="admin-panel-heading"><div><span class="eyebrow">Send an update</span><h2>New Newsletter</h2></div></div>
|
||||||
|
<form class="form-stack" action="/admin/newsletters/send" method="post">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<label>Subject line<input type="text" name="subject" maxlength="140" required placeholder="This weekend at Fat Bottom Grille"></label>
|
||||||
|
<label>Message<textarea name="content" rows="12" required placeholder="Share a new special, schedule update, or note from the kitchen..."></textarea></label>
|
||||||
|
<div class="notice">Every email includes an unsubscribe link. Users can also opt out from My Account.</div>
|
||||||
|
<button class="button button-large button-block" type="submit" data-confirm="Send this newsletter to every opted-in user?">Send to <?= (int) $recipientCount ?> Subscribers</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section class="admin-panel">
|
||||||
|
<div class="admin-panel-heading"><div><span class="eyebrow">Delivery history</span><h2>Past Sends</h2></div></div>
|
||||||
|
<div class="newsletter-history">
|
||||||
|
<?php if (!$newsletters): ?><p class="muted">No newsletters have been sent yet.</p><?php endif; ?>
|
||||||
|
<?php foreach ($newsletters as $newsletter): ?>
|
||||||
|
<article>
|
||||||
|
<div><strong><?= e($newsletter['subject']) ?></strong><span><?= e(store_datetime((string) ($newsletter['sent_at'] ?: $newsletter['created_at']))) ?></span></div>
|
||||||
|
<span class="role-badge"><?= (int) $newsletter['recipient_count'] ?> sent</span>
|
||||||
|
<small>by <?= e($newsletter['author'] ?: 'System') ?></small>
|
||||||
|
</article>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<div class="detail-heading">
|
||||||
|
<div>
|
||||||
|
<a class="back-link" href="/admin/orders">← Back to orders</a>
|
||||||
|
<div class="detail-title-row">
|
||||||
|
<h2><?= e($order['order_number']) ?></h2>
|
||||||
|
<span class="status-pill status-<?= e($order['status']) ?>"><?= e(ucfirst((string) $order['status'])) ?></span>
|
||||||
|
</div>
|
||||||
|
<p>Placed <?= e(store_datetime((string) $order['placed_at'], 'F j, Y \a\t g:i A')) ?></p>
|
||||||
|
</div>
|
||||||
|
<strong class="detail-total"><?= money($order['total_cents']) ?></strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-detail-grid">
|
||||||
|
<div>
|
||||||
|
<section class="admin-panel">
|
||||||
|
<div class="admin-panel-heading"><h2>Order Items</h2></div>
|
||||||
|
<div class="order-item-list">
|
||||||
|
<?php foreach ($items as $item): ?>
|
||||||
|
<div>
|
||||||
|
<span class="item-quantity"><?= (int) $item['quantity'] ?>x</span>
|
||||||
|
<span><strong><?= e($item['item_name']) ?></strong><?php if ($item['notes']): ?><small><?= e($item['notes']) ?></small><?php endif; ?></span>
|
||||||
|
<strong><?= money($item['line_total_cents']) ?></strong>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<dl class="admin-totals">
|
||||||
|
<div><dt>Subtotal</dt><dd><?= money($order['subtotal_cents']) ?></dd></div>
|
||||||
|
<div><dt>Tax</dt><dd><?= money($order['tax_cents']) ?></dd></div>
|
||||||
|
<div><dt>Total</dt><dd><?= money($order['total_cents']) ?></dd></div>
|
||||||
|
</dl>
|
||||||
|
<?php if ($order['special_instructions']): ?>
|
||||||
|
<div class="notice"><strong>Customer note:</strong> <?= e($order['special_instructions']) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</section>
|
||||||
|
<section class="admin-panel">
|
||||||
|
<div class="admin-panel-heading"><h2>Activity</h2></div>
|
||||||
|
<div class="timeline">
|
||||||
|
<?php foreach ($events as $event): ?>
|
||||||
|
<div>
|
||||||
|
<i></i>
|
||||||
|
<span>
|
||||||
|
<strong><?= e(ucwords(str_replace('_', ' ', (string) $event['event_type']))) ?></strong>
|
||||||
|
<p><?= e($event['details']) ?></p>
|
||||||
|
<small><?= e($event['staff_name'] ?: 'System') ?> · <?= e(store_datetime((string) $event['created_at'], 'M j, g:i A')) ?></small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<aside>
|
||||||
|
<section class="admin-panel">
|
||||||
|
<div class="admin-panel-heading"><h2>Update Order</h2></div>
|
||||||
|
<form class="form-stack" action="/admin/order" method="post">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="order_id" value="<?= (int) $order['id'] ?>">
|
||||||
|
<label>Status
|
||||||
|
<select name="status">
|
||||||
|
<?php foreach (['paid', 'preparing', 'ready', 'completed', 'cancelled'] as $status): ?>
|
||||||
|
<option value="<?= $status ?>" <?= selected($order['status'], $status) ?>><?= e(ucfirst($status)) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Staff note
|
||||||
|
<textarea name="staff_note" rows="3" placeholder="Optional internal note"></textarea>
|
||||||
|
</label>
|
||||||
|
<button class="button button-block" type="submit">Update Status</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section class="admin-panel">
|
||||||
|
<div class="admin-panel-heading"><h2>Customer</h2></div>
|
||||||
|
<div class="customer-card">
|
||||||
|
<strong><?= e($order['customer_name']) ?></strong>
|
||||||
|
<a href="mailto:<?= e($order['customer_email']) ?>"><?= e($order['customer_email']) ?></a>
|
||||||
|
<a href="tel:<?= e(preg_replace('/[^0-9+]/', '', (string) $order['customer_phone'])) ?>"><?= e($order['customer_phone']) ?></a>
|
||||||
|
<span>Pickup<?= $order['pickup_time'] ? ' at ' . e($order['pickup_time']) : '' ?></span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="admin-panel">
|
||||||
|
<div class="admin-panel-heading"><h2>Issue Refund</h2></div>
|
||||||
|
<form class="form-stack" action="/admin/order/refund" method="post">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="order_id" value="<?= (int) $order['id'] ?>">
|
||||||
|
<label>Amount
|
||||||
|
<input type="number" name="amount" min="0.01" max="<?= number_format((int) $order['total_cents'] / 100, 2, '.', '') ?>" step="0.01" required>
|
||||||
|
</label>
|
||||||
|
<label>Reason
|
||||||
|
<input type="text" name="reason" required placeholder="Customer request, unavailable item...">
|
||||||
|
</label>
|
||||||
|
<button class="button button-danger button-block" type="submit" data-confirm="Issue this refund? This action sends money back through the payment provider.">Issue Refund</button>
|
||||||
|
</form>
|
||||||
|
<?php if ($refunds): ?>
|
||||||
|
<div class="refund-list">
|
||||||
|
<?php foreach ($refunds as $refund): ?>
|
||||||
|
<div><strong>-<?= money($refund['amount_cents']) ?></strong><span><?= e($refund['reason']) ?></span><small><?= e($refund['staff_name'] ?? 'Staff') ?></small></div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<section class="admin-panel">
|
||||||
|
<div class="admin-panel-heading admin-toolbar">
|
||||||
|
<form class="admin-filters" action="/admin/orders" method="get">
|
||||||
|
<input type="search" name="q" value="<?= e($query) ?>" placeholder="Order, customer, or email">
|
||||||
|
<select name="status">
|
||||||
|
<option value="">All statuses</option>
|
||||||
|
<?php foreach (['paid', 'preparing', 'ready', 'completed', 'cancelled'] as $status): ?>
|
||||||
|
<option value="<?= $status ?>" <?= selected($statusFilter, $status) ?>><?= e(ucfirst($status)) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<button class="button button-small" type="submit">Filter</button>
|
||||||
|
</form>
|
||||||
|
<a class="button button-small button-ghost" href="/admin/export/orders.csv">Export CSV</a>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Order</th><th>Customer</th><th>Placed</th><th>Fulfillment</th><th>Payment</th><th>Total</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php if (!$orders): ?><tr><td colspan="7" class="empty-cell">No orders match those filters.</td></tr><?php endif; ?>
|
||||||
|
<?php foreach ($orders as $order): ?>
|
||||||
|
<tr>
|
||||||
|
<td><strong><?= e($order['order_number']) ?></strong></td>
|
||||||
|
<td><?= e($order['customer_name']) ?><small><?= e($order['customer_email']) ?></small></td>
|
||||||
|
<td><?= e(store_datetime((string) $order['placed_at'], 'M j, g:i A')) ?></td>
|
||||||
|
<td><span class="status-pill status-<?= e($order['status']) ?>"><?= e(ucfirst((string) $order['status'])) ?></span></td>
|
||||||
|
<td>
|
||||||
|
<span class="status-pill status-<?= e($order['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; ?>
|
||||||
|
</td>
|
||||||
|
<td><?= money($order['total_cents']) ?></td>
|
||||||
|
<td><a class="button button-small button-ghost" href="/admin/order?id=<?= (int) $order['id'] ?>">Open</a></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
<form class="admin-stack" action="/admin/settings" method="post">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<section class="admin-panel">
|
||||||
|
<div class="admin-panel-heading"><div><span class="eyebrow">Public identity</span><h2>Business Information</h2></div></div>
|
||||||
|
<div class="form-grid form-grid-three">
|
||||||
|
<label>Public site URL<input type="url" name="app[url]" required value="<?= e($config->get('app.url')) ?>"></label>
|
||||||
|
<label>Business name<input type="text" name="business[name]" required value="<?= e($config->get('business.name')) ?>"></label>
|
||||||
|
<label>Phone<input type="tel" name="business[phone]" value="<?= e($config->get('business.phone')) ?>"></label>
|
||||||
|
<label>Order email<input type="email" name="business[email]" value="<?= e($config->get('business.email')) ?>"></label>
|
||||||
|
<label>Street<input type="text" name="business[street]" value="<?= e($config->get('business.street')) ?>"></label>
|
||||||
|
<label>City<input type="text" name="business[city]" value="<?= e($config->get('business.city')) ?>"></label>
|
||||||
|
<label>State<input type="text" name="business[state]" value="<?= e($config->get('business.state')) ?>"></label>
|
||||||
|
<label>ZIP code<input type="text" name="business[postal_code]" value="<?= e($config->get('business.postal_code')) ?>"></label>
|
||||||
|
<label>Published hours<input type="text" name="business[hours]" value="<?= e($config->get('business.hours')) ?>"></label>
|
||||||
|
<label class="field-wide">Announcement bar<input type="text" name="business[announcement]" value="<?= e($config->get('business.announcement')) ?>"></label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="admin-panel">
|
||||||
|
<div class="admin-panel-heading"><div><span class="eyebrow">Kitchen controls</span><h2>Online Ordering</h2></div></div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<label class="check-control"><input type="checkbox" name="ordering[accepting_orders]" value="1" <?= checked($config->get('ordering.accepting_orders')) ?>><span><strong>Accept online orders</strong><small>Turn this off to pause checkout without hiding the menu.</small></span></label>
|
||||||
|
<label>Estimated pickup time, minutes<input type="number" min="5" max="180" name="ordering[pickup_eta_minutes]" value="<?= (int) $config->get('ordering.pickup_eta_minutes') ?>"></label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="admin-panel">
|
||||||
|
<div class="admin-panel-heading"><div><span class="eyebrow">Card processing</span><h2>Square</h2></div><span class="status-dot <?= $config->get('square.enabled') ? 'is-on' : '' ?>"><?= $config->get('square.enabled') ? 'Enabled' : 'Demo mode' ?></span></div>
|
||||||
|
<div class="notice">When disabled, checkout runs in demo mode and never charges a card. Enable only after entering matching Square application, location, and access credentials.</div>
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>Environment<select name="square[environment]"><option value="sandbox" <?= selected($config->get('square.environment'), 'sandbox') ?>>Sandbox</option><option value="production" <?= selected($config->get('square.environment'), 'production') ?>>Production</option></select></label>
|
||||||
|
<label>Application ID<input type="text" name="square[application_id]" value="<?= e($config->get('square.application_id')) ?>" autocomplete="off"></label>
|
||||||
|
<label>Location ID<input type="text" name="square[location_id]" value="<?= e($config->get('square.location_id')) ?>" autocomplete="off"></label>
|
||||||
|
<label>Access token<input type="password" name="square[access_token]" value="" placeholder="<?= $config->get('square.access_token') ? 'Saved credential - leave blank to keep' : 'Enter access token' ?>" autocomplete="new-password"></label>
|
||||||
|
</div>
|
||||||
|
<label class="check-control"><input type="checkbox" name="square[enabled]" value="1" <?= checked($config->get('square.enabled')) ?>><span><strong>Enable Square payments</strong><small>Live checkout will require a valid card token.</small></span></label>
|
||||||
|
</section>
|
||||||
|
<section class="admin-panel">
|
||||||
|
<div class="admin-panel-heading"><div><span class="eyebrow">Transactional email</span><h2>Mailgun</h2></div><span class="status-dot <?= $config->get('mailgun.enabled') ? 'is-on' : '' ?>"><?= $config->get('mailgun.enabled') ? 'Enabled' : 'Local log mode' ?></span></div>
|
||||||
|
<div class="notice">When disabled, verification and newsletter messages are written to <code>storage/logs/mail.log</code> for local testing.</div>
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>Mailgun domain<input type="text" name="mailgun[domain]" value="<?= e($config->get('mailgun.domain')) ?>"></label>
|
||||||
|
<label>API key<input type="password" name="mailgun[api_key]" value="" placeholder="<?= $config->get('mailgun.api_key') ? 'Saved credential - leave blank to keep' : 'Enter API key' ?>" autocomplete="new-password"></label>
|
||||||
|
<label>From name<input type="text" name="mailgun[from_name]" value="<?= e($config->get('mailgun.from_name')) ?>"></label>
|
||||||
|
<label>From email<input type="email" name="mailgun[from_email]" value="<?= e($config->get('mailgun.from_email')) ?>"></label>
|
||||||
|
</div>
|
||||||
|
<label class="check-control"><input type="checkbox" name="mailgun[enabled]" value="1" <?= checked($config->get('mailgun.enabled')) ?>><span><strong>Enable Mailgun delivery</strong></span></label>
|
||||||
|
</section>
|
||||||
|
<section class="admin-panel">
|
||||||
|
<div class="admin-panel-heading"><div><span class="eyebrow">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="form-grid form-grid-three">
|
||||||
|
<label>Host<input type="text" name="database[mysql_host]" value="<?= e($config->get('database.mysql_host')) ?>"></label>
|
||||||
|
<label>Port<input type="number" min="1" max="65535" name="database[mysql_port]" value="<?= (int) $config->get('database.mysql_port') ?>"></label>
|
||||||
|
<label>Database<input type="text" name="database[mysql_database]" value="<?= e($config->get('database.mysql_database')) ?>"></label>
|
||||||
|
<label>Username<input type="text" name="database[mysql_username]" value="<?= e($config->get('database.mysql_username')) ?>" autocomplete="off"></label>
|
||||||
|
<label>Password<input type="password" name="database[mysql_password]" value="" placeholder="<?= $config->get('database.mysql_password') ? 'Saved credential - leave blank to keep' : 'Enter database password' ?>" autocomplete="new-password"></label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<div class="sticky-save"><button class="button button-large" type="submit">Save Store Settings</button></div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section class="admin-panel settings-followup">
|
||||||
|
<div class="admin-panel-heading"><div><span class="eyebrow">State and city</span><h2>Tax Rates</h2></div></div>
|
||||||
|
<div class="editable-list">
|
||||||
|
<?php foreach ($taxRates as $tax): ?>
|
||||||
|
<details>
|
||||||
|
<summary>
|
||||||
|
<span class="item-avatar">%</span>
|
||||||
|
<span><strong><?= e($tax['name']) ?></strong><small><?= e(ucfirst((string) $tax['jurisdiction'])) ?> jurisdiction</small></span>
|
||||||
|
<strong><?= number_format((int) $tax['rate_basis_points'] / 100, 2) ?>%</strong>
|
||||||
|
<span class="status-dot <?= $tax['active'] ? 'is-on' : '' ?>"><?= $tax['active'] ? 'Active' : 'Disabled' ?></span>
|
||||||
|
<span class="edit-label">Edit</span>
|
||||||
|
</summary>
|
||||||
|
<form class="inline-editor form-stack" action="/admin/tax/save" method="post">
|
||||||
|
<?= csrf_field() ?><input type="hidden" name="id" value="<?= (int) $tax['id'] ?>">
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>Name<input type="text" name="name" value="<?= e($tax['name']) ?>" required></label>
|
||||||
|
<label>Jurisdiction<select name="jurisdiction"><option value="state" <?= selected($tax['jurisdiction'], 'state') ?>>State</option><option value="city" <?= selected($tax['jurisdiction'], 'city') ?>>City</option><option value="county" <?= selected($tax['jurisdiction'], 'county') ?>>County</option></select></label>
|
||||||
|
<label>Rate, percent<input type="number" min="0" step="0.01" name="rate" value="<?= number_format((int) $tax['rate_basis_points'] / 100, 2, '.', '') ?>"></label>
|
||||||
|
<label class="check-control compact"><input type="checkbox" name="active" value="1" <?= checked($tax['active']) ?>><span><strong>Active</strong></span></label>
|
||||||
|
</div>
|
||||||
|
<button class="button button-small" type="submit">Save Tax</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<details class="new-record">
|
||||||
|
<summary>+ Add Tax Rate</summary>
|
||||||
|
<form class="inline-editor form-stack" action="/admin/tax/save" method="post">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>Name<input type="text" name="name" required></label>
|
||||||
|
<label>Jurisdiction<select name="jurisdiction"><option value="state">State</option><option value="city">City</option><option value="county">County</option></select></label>
|
||||||
|
<label>Rate, percent<input type="number" min="0" step="0.01" name="rate" required></label>
|
||||||
|
<label class="check-control compact"><input type="checkbox" name="active" value="1" checked><span><strong>Active</strong></span></label>
|
||||||
|
</div>
|
||||||
|
<button class="button button-small" type="submit">Create Tax Rate</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="admin-panel settings-followup">
|
||||||
|
<div class="admin-panel-heading"><div><span class="eyebrow">Optional database mode</span><h2>MySQL / MariaDB Migration</h2></div><span class="role-badge"><?= e(strtoupper((string) $config->get('database.driver', 'sqlite'))) ?> active</span></div>
|
||||||
|
<p class="muted">SQLite remains the default and requires no database server. This tool copies every application table and row into MySQL or MariaDB; switching drivers is optional.</p>
|
||||||
|
<form class="form-stack" action="/admin/migrate/mysql" method="post">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<div class="form-grid form-grid-three">
|
||||||
|
<label>Host<input type="text" value="<?= e($config->get('database.mysql_host')) ?>" disabled></label>
|
||||||
|
<label>Port<input type="text" value="<?= e($config->get('database.mysql_port')) ?>" disabled></label>
|
||||||
|
<label>Database<input type="text" value="<?= e($config->get('database.mysql_database')) ?>" disabled></label>
|
||||||
|
<label>Username<input type="text" value="<?= e($config->get('database.mysql_username')) ?>" disabled></label>
|
||||||
|
</div>
|
||||||
|
<p class="form-footnote">Connection values are saved in the Store Settings form above before running migration.</p>
|
||||||
|
<label class="check-control"><input type="checkbox" name="switch_driver" value="1"><span><strong>Switch the application to MySQL after a successful copy</strong><small>Do this only when the MySQL service will remain available.</small></span></label>
|
||||||
|
<button class="button button-outline" type="submit" data-confirm="Copy the SQLite database to the configured MySQL server?">Run Database Migration</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<div class="admin-page-actions">
|
||||||
|
<p>All active specials are automatically arranged on the homepage based on how many are running.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="admin-panel">
|
||||||
|
<div class="admin-panel-heading">
|
||||||
|
<div><span class="eyebrow">Homepage promotion</span><h2>Specials</h2></div>
|
||||||
|
<span class="count-badge"><?= count($specials) ?> configured</span>
|
||||||
|
</div>
|
||||||
|
<div class="special-admin-grid">
|
||||||
|
<?php foreach ($specials as $special): ?>
|
||||||
|
<details class="special-admin-card">
|
||||||
|
<summary>
|
||||||
|
<span class="special-badge"><?= e($special['badge']) ?></span>
|
||||||
|
<h3><?= e($special['title']) ?></h3>
|
||||||
|
<p><?= e($special['description']) ?></p>
|
||||||
|
<div><strong><?= $special['price_cents'] !== null ? money($special['price_cents']) : 'No override' ?></strong><span class="status-dot <?= $special['active'] ? 'is-on' : '' ?>"><?= $special['active'] ? 'Active' : 'Hidden' ?></span></div>
|
||||||
|
</summary>
|
||||||
|
<form class="inline-editor form-stack" action="/admin/special/save" method="post">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="id" value="<?= (int) $special['id'] ?>">
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>Title<input type="text" name="title" required value="<?= e($special['title']) ?>"></label>
|
||||||
|
<label>Badge<input type="text" name="badge" value="<?= e($special['badge']) ?>"></label>
|
||||||
|
<label>Linked menu item
|
||||||
|
<select name="menu_item_id">
|
||||||
|
<option value="">No linked item</option>
|
||||||
|
<?php foreach ($items as $item): ?><option value="<?= (int) $item['id'] ?>" <?= selected($special['menu_item_id'], $item['id']) ?>><?= e($item['name']) ?></option><?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Promotional price<input type="number" name="price" min="0" step="0.01" value="<?= $special['price_cents'] !== null ? number_format((int) $special['price_cents'] / 100, 2, '.', '') : '' ?>"></label>
|
||||||
|
<label class="field-wide">Description<textarea name="description" rows="2"><?= e($special['description']) ?></textarea></label>
|
||||||
|
<label>Starts at<input type="datetime-local" name="starts_at" value="<?= e(store_datetime($special['starts_at'], 'Y-m-d\TH:i')) ?>"></label>
|
||||||
|
<label>Ends at<input type="datetime-local" name="ends_at" value="<?= e(store_datetime($special['ends_at'], 'Y-m-d\TH:i')) ?>"></label>
|
||||||
|
<label>Display order<input type="number" name="display_order" value="<?= (int) $special['display_order'] ?>"></label>
|
||||||
|
<label class="check-control compact"><input type="checkbox" name="active" value="1" <?= checked($special['active']) ?>><span><strong>Active</strong></span></label>
|
||||||
|
</div>
|
||||||
|
<button class="button button-small" type="submit">Save Special</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<details class="new-record">
|
||||||
|
<summary>+ Add Homepage Special</summary>
|
||||||
|
<form class="inline-editor form-stack" action="/admin/special/save" method="post">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>Title<input type="text" name="title" required></label>
|
||||||
|
<label>Badge<input type="text" name="badge" value="Special"></label>
|
||||||
|
<label>Linked menu item
|
||||||
|
<select name="menu_item_id"><option value="">No linked item</option><?php foreach ($items as $item): ?><option value="<?= (int) $item['id'] ?>"><?= e($item['name']) ?></option><?php endforeach; ?></select>
|
||||||
|
</label>
|
||||||
|
<label>Promotional price<input type="number" name="price" min="0" step="0.01"></label>
|
||||||
|
<label class="field-wide">Description<textarea name="description" rows="2"></textarea></label>
|
||||||
|
<label>Starts at<input type="datetime-local" name="starts_at"></label>
|
||||||
|
<label>Ends at<input type="datetime-local" name="ends_at"></label>
|
||||||
|
<label>Display order<input type="number" name="display_order" value="0"></label>
|
||||||
|
<label class="check-control compact"><input type="checkbox" name="active" value="1" checked><span><strong>Active</strong></span></label>
|
||||||
|
</div>
|
||||||
|
<button class="button button-small" type="submit">Create Special</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<div class="admin-page-actions">
|
||||||
|
<form class="admin-filters" action="/admin/users" method="get">
|
||||||
|
<input type="search" name="q" value="<?= e($query) ?>" placeholder="Search name, email, or phone">
|
||||||
|
<button class="button button-small" type="submit">Search</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="admin-panel">
|
||||||
|
<div class="admin-panel-heading">
|
||||||
|
<div><span class="eyebrow">Access and roster</span><h2>Users & Staff</h2></div>
|
||||||
|
<span class="count-badge"><?= count($users) ?> shown</span>
|
||||||
|
</div>
|
||||||
|
<div class="editable-list">
|
||||||
|
<?php foreach ($users as $user): ?>
|
||||||
|
<details>
|
||||||
|
<summary>
|
||||||
|
<span class="avatar"><?= e(strtoupper(substr((string) $user['full_name'], 0, 1))) ?></span>
|
||||||
|
<span><strong><?= e($user['full_name']) ?></strong><small><?= e($user['email']) ?> · <?= e($user['phone']) ?></small></span>
|
||||||
|
<span class="role-badge"><?= e(ucfirst((string) $user['role'])) ?></span>
|
||||||
|
<span class="status-dot <?= $user['active'] ? 'is-on' : '' ?>"><?= $user['active'] ? 'Active' : 'Disabled' ?></span>
|
||||||
|
<span class="edit-label">Edit</span>
|
||||||
|
</summary>
|
||||||
|
<form class="inline-editor form-stack" action="/admin/user/save" method="post">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="id" value="<?= (int) $user['id'] ?>">
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>Full name<input type="text" name="full_name" required value="<?= e($user['full_name']) ?>"></label>
|
||||||
|
<label>Phone<input type="tel" name="phone" value="<?= e($user['phone']) ?>"></label>
|
||||||
|
<label>Role
|
||||||
|
<select name="role">
|
||||||
|
<?php foreach (['customer', 'staff', 'manager', 'admin'] as $role): ?><option value="<?= $role ?>" <?= selected($user['role'], $role) ?>><?= e(ucfirst($role)) ?></option><?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="check-row">
|
||||||
|
<label class="check-control compact"><input type="checkbox" name="active" value="1" <?= checked($user['active']) ?>><span><strong>Active account</strong></span></label>
|
||||||
|
<label class="check-control compact"><input type="checkbox" name="newsletter_opt_in" value="1" <?= checked($user['newsletter_opt_in']) ?>><span><strong>News opted in</strong></span></label>
|
||||||
|
</div>
|
||||||
|
<button class="button button-small" type="submit">Save User</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<details class="new-record">
|
||||||
|
<summary>+ Add Staff Member</summary>
|
||||||
|
<form class="inline-editor form-stack" action="/admin/user/save" method="post">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<div class="form-grid form-grid-three">
|
||||||
|
<label>Full name<input type="text" name="full_name" required></label>
|
||||||
|
<label>Email<input type="email" name="email" required></label>
|
||||||
|
<label>Phone<input type="tel" name="phone"></label>
|
||||||
|
<label>Temporary password<input type="password" name="password" required minlength="10"></label>
|
||||||
|
<label>Role
|
||||||
|
<select name="role"><option value="staff">Staff</option><option value="manager">Manager</option><option value="admin">Administrator</option><option value="customer">Customer</option></select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="check-row">
|
||||||
|
<label class="check-control compact"><input type="checkbox" name="active" value="1" checked><span><strong>Active account</strong></span></label>
|
||||||
|
<label class="check-control compact"><input type="checkbox" name="newsletter_opt_in" value="1"><span><strong>News opted in</strong></span></label>
|
||||||
|
</div>
|
||||||
|
<button class="button button-small" type="submit">Create User</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<section class="auth-section section">
|
||||||
|
<div class="container auth-grid">
|
||||||
|
<div class="auth-intro">
|
||||||
|
<span class="eyebrow">Welcome back</span>
|
||||||
|
<h1>Your usual is waiting.</h1>
|
||||||
|
<p>Sign in to check past orders, save your pickup information, and manage restaurant news.</p>
|
||||||
|
<div class="auth-callout">
|
||||||
|
<strong>Staff member?</strong>
|
||||||
|
<span>Your staff account uses the same secure sign-in.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form class="auth-card form-stack" action="/login" method="post">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Account access</span>
|
||||||
|
<h2>Sign In</h2>
|
||||||
|
</div>
|
||||||
|
<label>Email address
|
||||||
|
<input type="email" name="email" required autocomplete="email" value="<?= e($old['email'] ?? '') ?>">
|
||||||
|
</label>
|
||||||
|
<label>Password
|
||||||
|
<input type="password" name="password" required autocomplete="current-password">
|
||||||
|
</label>
|
||||||
|
<div class="honeypot" aria-hidden="true">
|
||||||
|
<label>Website <input type="text" name="website" tabindex="-1" autocomplete="off"></label>
|
||||||
|
</div>
|
||||||
|
<label>Quick check: what is <?= (int) $captcha[0] ?> + <?= (int) $captcha[1] ?>?
|
||||||
|
<input type="number" name="captcha" required inputmode="numeric" autocomplete="off">
|
||||||
|
</label>
|
||||||
|
<button class="button button-large button-block" type="submit">Continue Securely</button>
|
||||||
|
<p class="form-footnote">We will email a six-digit verification code after your password is accepted.</p>
|
||||||
|
<p class="auth-switch">New here? <a href="/register">Create an account</a></p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<section class="auth-section section">
|
||||||
|
<div class="container auth-grid auth-grid-wide">
|
||||||
|
<div class="auth-intro">
|
||||||
|
<span class="eyebrow">Join the table</span>
|
||||||
|
<h1>Faster pickup starts here.</h1>
|
||||||
|
<p>Create an account to keep your contact details, see order history, and hear about new specials.</p>
|
||||||
|
<ul class="check-list">
|
||||||
|
<li>Email verification protects your account</li>
|
||||||
|
<li>Newsletter preferences stay in your control</li>
|
||||||
|
<li>Your payment details are handled by Square, not stored here</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<form class="auth-card form-stack" action="/register" method="post">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Customer account</span>
|
||||||
|
<h2>Create Account</h2>
|
||||||
|
</div>
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>Full name
|
||||||
|
<input type="text" name="full_name" required autocomplete="name" value="<?= e($old['full_name'] ?? '') ?>">
|
||||||
|
</label>
|
||||||
|
<label>Phone number
|
||||||
|
<input type="tel" name="phone" required autocomplete="tel" value="<?= e($old['phone'] ?? '') ?>">
|
||||||
|
</label>
|
||||||
|
<label class="field-wide">Email address
|
||||||
|
<input type="email" name="email" required autocomplete="email" value="<?= e($old['email'] ?? '') ?>">
|
||||||
|
</label>
|
||||||
|
<label class="field-wide">Street address
|
||||||
|
<input type="text" name="street_address" required autocomplete="street-address" value="<?= e($old['street_address'] ?? '') ?>">
|
||||||
|
</label>
|
||||||
|
<label>City
|
||||||
|
<input type="text" name="city" required autocomplete="address-level2" value="<?= e($old['city'] ?? 'Keyser') ?>">
|
||||||
|
</label>
|
||||||
|
<label>State
|
||||||
|
<input type="text" name="state" required maxlength="2" autocomplete="address-level1" value="<?= e($old['state'] ?? 'WV') ?>">
|
||||||
|
</label>
|
||||||
|
<label>ZIP code
|
||||||
|
<input type="text" name="postal_code" required autocomplete="postal-code" value="<?= e($old['postal_code'] ?? '26726') ?>">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>Password
|
||||||
|
<input type="password" name="password" minlength="10" required autocomplete="new-password">
|
||||||
|
</label>
|
||||||
|
<label>Confirm password
|
||||||
|
<input type="password" name="password_confirmation" minlength="10" required autocomplete="new-password">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="honeypot" aria-hidden="true">
|
||||||
|
<label>Website <input type="text" name="website" tabindex="-1" autocomplete="off"></label>
|
||||||
|
</div>
|
||||||
|
<label>Quick check: what is <?= (int) $captcha[0] ?> + <?= (int) $captcha[1] ?>?
|
||||||
|
<input type="number" name="captcha" required inputmode="numeric" autocomplete="off">
|
||||||
|
</label>
|
||||||
|
<div class="notice">Creating an account opts you into occasional Fat Bottom Grille news. You can opt out from any email or My Account.</div>
|
||||||
|
<button class="button button-large button-block" type="submit">Create My Account</button>
|
||||||
|
<p class="auth-switch">Already have an account? <a href="/login">Sign in</a></p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<section class="auth-section section">
|
||||||
|
<div class="container narrow-container">
|
||||||
|
<form class="auth-card form-stack verify-card" action="/verify" method="post">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<div class="verification-icon">6</div>
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">One more step</span>
|
||||||
|
<h1>Check your email</h1>
|
||||||
|
<p>Enter the six-digit code we sent you. It is valid for 10 minutes.</p>
|
||||||
|
</div>
|
||||||
|
<label>Verification code
|
||||||
|
<input class="code-input" type="text" name="code" required inputmode="numeric" maxlength="6" pattern="[0-9]{6}" autocomplete="one-time-code" autofocus>
|
||||||
|
</label>
|
||||||
|
<button class="button button-large button-block" type="submit">Verify & Sign In</button>
|
||||||
|
<a class="text-link text-center" href="/login">Start over</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use FatBottom\Core\View;
|
||||||
|
|
||||||
|
$businessName = (string) $config->get('business.name', 'Fat Bottom Grille');
|
||||||
|
$isAdmin = isset($adminPage);
|
||||||
|
?>
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="theme-color" content="#09131d">
|
||||||
|
<meta name="description" content="Order burgers, burritos, dinner plates, sides, and drinks online from Fat Bottom Grille in Keyser, West Virginia.">
|
||||||
|
<title><?= e($title ?? $businessName) ?> | <?= e($businessName) ?></title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@600;700;800&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="/assets/app.css">
|
||||||
|
<?php if (!empty($squareEnabled) && !empty($squareAppId) && !empty($squareLocationId)): ?>
|
||||||
|
<script src="<?= $squareEnvironment === 'production'
|
||||||
|
? 'https://web.squarecdn.com/v1/square.js'
|
||||||
|
: 'https://sandbox.web.squarecdn.com/v1/square.js' ?>"></script>
|
||||||
|
<?php endif; ?>
|
||||||
|
</head>
|
||||||
|
<body class="<?= $isAdmin ? 'admin-body' : 'store-body' ?>">
|
||||||
|
<a class="skip-link" href="#main">Skip to content</a>
|
||||||
|
|
||||||
|
<?php if ($isAdmin): ?>
|
||||||
|
<div class="admin-shell">
|
||||||
|
<?php View::partial('partials/admin-nav', compact('adminPage', 'config', 'currentUser')); ?>
|
||||||
|
<div class="admin-content">
|
||||||
|
<header class="admin-topbar">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Staff workspace</span>
|
||||||
|
<h1><?= e($title ?? 'Dashboard') ?></h1>
|
||||||
|
</div>
|
||||||
|
<div class="admin-top-actions">
|
||||||
|
<a class="button button-ghost button-small" href="/" target="_blank" rel="noopener">View Store</a>
|
||||||
|
<form action="/logout" method="post">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<button class="button button-small" type="submit">Sign Out</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<?php View::partial('partials/flash', compact('flashMessages')); ?>
|
||||||
|
<main id="main" class="admin-main">
|
||||||
|
<?php require $templatePath; ?>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="announcement">
|
||||||
|
<span><?= e($config->get('business.announcement')) ?></span>
|
||||||
|
<span class="announcement-detail"><?= e($config->get('business.hours')) ?></span>
|
||||||
|
</div>
|
||||||
|
<header class="site-header">
|
||||||
|
<div class="container nav-wrap">
|
||||||
|
<a class="brand" href="/" aria-label="<?= e($businessName) ?> home">
|
||||||
|
<span class="brand-mark">FB</span>
|
||||||
|
<span class="brand-copy">
|
||||||
|
<strong><?= e($businessName) ?></strong>
|
||||||
|
<small>Keyser, West Virginia</small>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<button class="nav-toggle" type="button" aria-expanded="false" aria-controls="site-nav">
|
||||||
|
<span></span><span></span><span></span>
|
||||||
|
<span class="sr-only">Toggle navigation</span>
|
||||||
|
</button>
|
||||||
|
<nav class="site-nav" id="site-nav" aria-label="Main navigation">
|
||||||
|
<a class="<?= active_path('/') ?>" href="/">Home</a>
|
||||||
|
<a class="<?= active_path('/menu') ?>" href="/menu">Order Online</a>
|
||||||
|
<?php if ($currentUser): ?>
|
||||||
|
<?php if (in_array($currentUser['role'], ['admin', 'manager', 'staff'], true)): ?>
|
||||||
|
<a href="/admin">Dashboard</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<a class="<?= active_path('/account') ?>" href="/account">My Account</a>
|
||||||
|
<?php else: ?>
|
||||||
|
<a class="<?= active_path('/login') ?>" href="/login">Sign In</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<a class="cart-link" href="/cart">
|
||||||
|
Your Order
|
||||||
|
<span class="cart-count"><?= (int) ($currentCart['item_count'] ?? 0) ?></span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<?php View::partial('partials/flash', compact('flashMessages')); ?>
|
||||||
|
<main id="main">
|
||||||
|
<?php require $templatePath; ?>
|
||||||
|
</main>
|
||||||
|
<footer class="site-footer">
|
||||||
|
<div class="container footer-grid">
|
||||||
|
<div>
|
||||||
|
<div class="footer-brand"><?= e($businessName) ?></div>
|
||||||
|
<p>Good food, generous portions, and a seat at the table in Keyser.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2>Visit</h2>
|
||||||
|
<address>
|
||||||
|
<?= e($config->get('business.street')) ?><br>
|
||||||
|
<?= e($config->get('business.city')) ?>, <?= e($config->get('business.state')) ?>
|
||||||
|
<?= e($config->get('business.postal_code')) ?>
|
||||||
|
</address>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2>Get In Touch</h2>
|
||||||
|
<?php if ($config->get('business.phone')): ?>
|
||||||
|
<a href="tel:<?= e(preg_replace('/[^0-9+]/', '', (string) $config->get('business.phone'))) ?>">
|
||||||
|
<?= e($config->get('business.phone')) ?>
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($config->get('business.email')): ?>
|
||||||
|
<a href="mailto:<?= e($config->get('business.email')) ?>"><?= e($config->get('business.email')) ?></a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (!$config->get('business.phone') && !$config->get('business.email')): ?>
|
||||||
|
<p>Contact details coming soon.</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2>Hours</h2>
|
||||||
|
<p><?= e($config->get('business.hours')) ?></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="container footer-bottom">
|
||||||
|
<span>© <?= date('Y') ?> <?= e($businessName) ?></span>
|
||||||
|
<span>410 W Piedmont St, Keyser, WV</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<script src="/assets/app.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<aside class="admin-sidebar">
|
||||||
|
<a class="admin-brand" href="/admin">
|
||||||
|
<span class="brand-mark">FB</span>
|
||||||
|
<span>
|
||||||
|
<strong><?= e($config->get('business.name')) ?></strong>
|
||||||
|
<small>Operations</small>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<nav class="admin-nav" aria-label="Dashboard navigation">
|
||||||
|
<a class="<?= $adminPage === 'dashboard' ? 'is-active' : '' ?>" href="/admin">Overview</a>
|
||||||
|
<a class="<?= $adminPage === 'orders' ? 'is-active' : '' ?>" href="/admin/orders">Orders</a>
|
||||||
|
<a class="<?= $adminPage === 'menu' ? 'is-active' : '' ?>" href="/admin/menu">Menu</a>
|
||||||
|
<a class="<?= $adminPage === 'specials' ? 'is-active' : '' ?>" href="/admin/specials">Specials</a>
|
||||||
|
<a class="<?= $adminPage === 'newsletters' ? 'is-active' : '' ?>" href="/admin/newsletters">Newsletters</a>
|
||||||
|
<?php if (($currentUser['role'] ?? '') === 'admin'): ?>
|
||||||
|
<a class="<?= $adminPage === 'users' ? 'is-active' : '' ?>" href="/admin/users">Users & Staff</a>
|
||||||
|
<a class="<?= $adminPage === 'settings' ? 'is-active' : '' ?>" href="/admin/settings">Settings</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</nav>
|
||||||
|
<div class="admin-user">
|
||||||
|
<span class="avatar"><?= e(strtoupper(substr((string) ($currentUser['full_name'] ?? 'S'), 0, 1))) ?></span>
|
||||||
|
<span>
|
||||||
|
<strong><?= e($currentUser['full_name'] ?? '') ?></strong>
|
||||||
|
<small><?= e(ucfirst((string) ($currentUser['role'] ?? 'staff'))) ?></small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php if (!empty($flashMessages)): ?>
|
||||||
|
<div class="<?= isset($adminPage) ? 'admin-flash-wrap' : 'container flash-wrap' ?>" aria-live="polite">
|
||||||
|
<?php foreach ($flashMessages as $message): ?>
|
||||||
|
<div class="flash flash-<?= e($message['type']) ?>">
|
||||||
|
<span><?= e($message['message']) ?></span>
|
||||||
|
<button type="button" class="flash-close" aria-label="Dismiss message">×</button>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
$tags = json_decode((string) ($item['dietary_tags'] ?? '[]'), true);
|
||||||
|
$tags = is_array($tags) ? $tags : [];
|
||||||
|
?>
|
||||||
|
<article class="menu-card">
|
||||||
|
<div class="menu-card-art <?= !empty($item['image_url']) ? 'has-image' : '' ?>"
|
||||||
|
<?php if (!empty($item['image_url'])): ?>style="background-image:url('<?= e($item['image_url']) ?>')"<?php endif; ?>>
|
||||||
|
<?php if (empty($item['image_url'])): ?>
|
||||||
|
<span><?= e(strtoupper(substr((string) $item['name'], 0, 2))) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (!empty($item['featured'])): ?><span class="card-badge">House Pick</span><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="menu-card-body">
|
||||||
|
<div class="menu-card-heading">
|
||||||
|
<h3><?= e($item['name']) ?></h3>
|
||||||
|
<strong><?= money($item['price_cents']) ?></strong>
|
||||||
|
</div>
|
||||||
|
<p><?= e($item['description']) ?></p>
|
||||||
|
<?php if ($tags): ?>
|
||||||
|
<div class="tag-list">
|
||||||
|
<?php foreach ($tags as $tag): ?><span><?= e($tag) ?></span><?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<form class="quick-add" action="/cart/add" method="post">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="menu_item_id" value="<?= (int) $item['id'] ?>">
|
||||||
|
<input type="hidden" name="return_to" value="<?= e($_SERVER['REQUEST_URI'] ?? '/menu') ?>">
|
||||||
|
<label>
|
||||||
|
<span class="sr-only">Quantity</span>
|
||||||
|
<select name="quantity" aria-label="Quantity for <?= e($item['name']) ?>">
|
||||||
|
<?php for ($quantity = 1; $quantity <= 8; $quantity++): ?>
|
||||||
|
<option value="<?= $quantity ?>"><?= $quantity ?></option>
|
||||||
|
<?php endfor; ?>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button class="button button-small" type="submit">Add to Order</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<section class="page-hero page-hero-compact">
|
||||||
|
<div class="container">
|
||||||
|
<span class="eyebrow">Almost yours</span>
|
||||||
|
<h1>Your Order</h1>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="container checkout-layout section">
|
||||||
|
<section class="checkout-main">
|
||||||
|
<?php if (empty($cart['items'])): ?>
|
||||||
|
<div class="empty-state">
|
||||||
|
<h2>Your order is wide open.</h2>
|
||||||
|
<p>Start with a burger, a burrito, or whatever sounds good right now.</p>
|
||||||
|
<a class="button" href="/menu">Browse the Menu</a>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<form action="/cart/update" method="post">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<div class="cart-list">
|
||||||
|
<?php foreach ($cart['items'] as $item): ?>
|
||||||
|
<article class="cart-item">
|
||||||
|
<div class="cart-item-mark"><?= e(strtoupper(substr((string) $item['name'], 0, 2))) ?></div>
|
||||||
|
<div class="cart-item-copy">
|
||||||
|
<h2><?= e($item['name']) ?></h2>
|
||||||
|
<?php if ($item['notes']): ?><p>Note: <?= e($item['notes']) ?></p><?php endif; ?>
|
||||||
|
<span><?= money($item['unit_price_cents']) ?> each</span>
|
||||||
|
</div>
|
||||||
|
<label class="quantity-field">
|
||||||
|
<span>Qty</span>
|
||||||
|
<input type="number" min="0" max="20" name="quantity[<?= (int) $item['id'] ?>]" value="<?= (int) $item['quantity'] ?>">
|
||||||
|
</label>
|
||||||
|
<strong class="cart-line-total"><?= money((int) $item['quantity'] * (int) $item['unit_price_cents']) ?></strong>
|
||||||
|
<button class="icon-button remove-cart-item" type="submit" formaction="/cart/remove" name="cart_item_id" value="<?= (int) $item['id'] ?>" aria-label="Remove <?= e($item['name']) ?>">×</button>
|
||||||
|
</article>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<div class="cart-actions">
|
||||||
|
<a class="text-link" href="/menu">Add More Items</a>
|
||||||
|
<button class="button button-outline" type="submit">Update Order</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
</section>
|
||||||
|
<?php if (!empty($cart['items'])): ?>
|
||||||
|
<aside class="order-summary">
|
||||||
|
<span class="eyebrow">Pickup summary</span>
|
||||||
|
<h2><?= (int) $cart['item_count'] ?> <?= (int) $cart['item_count'] === 1 ? 'item' : 'items' ?></h2>
|
||||||
|
<dl>
|
||||||
|
<div><dt>Subtotal</dt><dd><?= money($totals['subtotal_cents']) ?></dd></div>
|
||||||
|
<div><dt>Estimated tax</dt><dd><?= money($totals['tax_cents']) ?></dd></div>
|
||||||
|
<div class="summary-total"><dt>Total</dt><dd><?= money($totals['total_cents']) ?></dd></div>
|
||||||
|
</dl>
|
||||||
|
<a class="button button-block button-large" href="/checkout">Continue to Checkout</a>
|
||||||
|
<p class="summary-note">Pickup at 410 W Piedmont St, Keyser.</p>
|
||||||
|
</aside>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<section class="page-hero page-hero-compact">
|
||||||
|
<div class="container">
|
||||||
|
<span class="eyebrow">Secure checkout</span>
|
||||||
|
<h1>Finish Your Order</h1>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="container checkout-layout section">
|
||||||
|
<section class="checkout-main">
|
||||||
|
<form id="checkout-form" class="panel form-stack"
|
||||||
|
action="/checkout" method="post"
|
||||||
|
data-square-enabled="<?= $squareEnabled ? 'true' : 'false' ?>"
|
||||||
|
data-square-app-id="<?= e($squareAppId) ?>"
|
||||||
|
data-square-location-id="<?= e($squareLocationId) ?>">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" id="square-token" name="square_token" value="">
|
||||||
|
<div class="form-section">
|
||||||
|
<div class="form-section-heading">
|
||||||
|
<span>1</span>
|
||||||
|
<div>
|
||||||
|
<h2>Who is picking up?</h2>
|
||||||
|
<p>We will use this to confirm your order.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>Full name
|
||||||
|
<input type="text" name="customer_name" required autocomplete="name"
|
||||||
|
value="<?= e($old['customer_name'] ?? $currentUser['full_name'] ?? '') ?>">
|
||||||
|
</label>
|
||||||
|
<label>Email address
|
||||||
|
<input type="email" name="customer_email" required autocomplete="email"
|
||||||
|
value="<?= e($old['customer_email'] ?? $currentUser['email'] ?? '') ?>">
|
||||||
|
</label>
|
||||||
|
<label>Phone number
|
||||||
|
<input type="tel" name="customer_phone" required autocomplete="tel"
|
||||||
|
value="<?= e($old['customer_phone'] ?? $currentUser['phone'] ?? '') ?>">
|
||||||
|
</label>
|
||||||
|
<label>Preferred pickup time
|
||||||
|
<input type="time" name="pickup_time" value="<?= e($old['pickup_time'] ?? '') ?>">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label>Special instructions
|
||||||
|
<textarea name="special_instructions" rows="3" placeholder="Sauce on the side, allergy note, pickup details..."><?= e($old['special_instructions'] ?? '') ?></textarea>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-section">
|
||||||
|
<div class="form-section-heading">
|
||||||
|
<span>2</span>
|
||||||
|
<div>
|
||||||
|
<h2>Payment</h2>
|
||||||
|
<p><?= $squareEnabled ? 'Securely processed by Square.' : 'Demo payment mode is active until Square is enabled in Settings.' ?></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php if ($squareEnabled): ?>
|
||||||
|
<?php if ($squareAppId && $squareLocationId): ?>
|
||||||
|
<div id="card-container" class="square-card"></div>
|
||||||
|
<div id="card-errors" class="field-error" role="alert"></div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="notice notice-error">Square is enabled, but the application or location ID is missing. Ask an administrator to finish the payment settings.</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="demo-payment">
|
||||||
|
<span class="demo-payment-mark">D</span>
|
||||||
|
<div>
|
||||||
|
<strong>Demo payment</strong>
|
||||||
|
<p>No card will be charged. Orders are recorded as paid for local testing.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<button id="checkout-submit" class="button button-large button-block" type="submit"
|
||||||
|
<?= $squareEnabled && (!$squareAppId || !$squareLocationId) ? 'disabled' : '' ?>>
|
||||||
|
Pay <?= money($totals['total_cents']) ?> & Place Order
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<aside class="order-summary">
|
||||||
|
<span class="eyebrow">Your order</span>
|
||||||
|
<div class="mini-order-list">
|
||||||
|
<?php foreach ($cart['items'] as $item): ?>
|
||||||
|
<div>
|
||||||
|
<span><b><?= (int) $item['quantity'] ?>x</b> <?= e($item['name']) ?></span>
|
||||||
|
<strong><?= money((int) $item['quantity'] * (int) $item['unit_price_cents']) ?></strong>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<dl>
|
||||||
|
<div><dt>Subtotal</dt><dd><?= money($totals['subtotal_cents']) ?></dd></div>
|
||||||
|
<?php foreach ($taxRates as $tax): ?>
|
||||||
|
<div><dt><?= e($tax['name']) ?> (<?= number_format((int) $tax['rate_basis_points'] / 100, 2) ?>%)</dt><dd>Included below</dd></div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<div><dt>Tax</dt><dd><?= money($totals['tax_cents']) ?></dd></div>
|
||||||
|
<div class="summary-total"><dt>Total</dt><dd><?= money($totals['total_cents']) ?></dd></div>
|
||||||
|
</dl>
|
||||||
|
<p class="summary-note">Estimated ready time: <?= (int) $config->get('ordering.pickup_eta_minutes', 25) ?> minutes.</p>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
<section class="hero">
|
||||||
|
<div class="hero-texture"></div>
|
||||||
|
<div class="container hero-grid">
|
||||||
|
<div class="hero-copy">
|
||||||
|
<span class="eyebrow">Keyser made. Come hungry.</span>
|
||||||
|
<h1>Big flavor.<br><em>No shortcuts.</em></h1>
|
||||||
|
<p>Stacked burgers, loaded burritos, and dinner plates that eat like Sunday supper. Order ahead and we will have it hot.</p>
|
||||||
|
<div class="hero-actions">
|
||||||
|
<a class="button button-large" href="/menu">Start Your Order</a>
|
||||||
|
<?php if ($config->get('business.phone')): ?>
|
||||||
|
<a class="text-link" href="tel:<?= e(preg_replace('/[^0-9+]/', '', (string) $config->get('business.phone'))) ?>">
|
||||||
|
Call <?= e($config->get('business.phone')) ?>
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="hero-meta">
|
||||||
|
<span>Pickup in about <?= (int) $config->get('ordering.pickup_eta_minutes', 25) ?> minutes</span>
|
||||||
|
<span><?= (bool) $config->get('ordering.accepting_orders', true) ? 'Ordering now open' : 'Ordering currently paused' ?></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hero-plate" aria-hidden="true">
|
||||||
|
<div class="plate-ring">
|
||||||
|
<div class="burger">
|
||||||
|
<span class="bun bun-top"></span>
|
||||||
|
<span class="lettuce"></span>
|
||||||
|
<span class="cheese"></span>
|
||||||
|
<span class="patty"></span>
|
||||||
|
<span class="onion"></span>
|
||||||
|
<span class="patty patty-two"></span>
|
||||||
|
<span class="bun bun-bottom"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hero-stamp">
|
||||||
|
<strong>410</strong>
|
||||||
|
<span>W Piedmont</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<?php if ($specials): ?>
|
||||||
|
<section class="specials-section section">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Happening now</span>
|
||||||
|
<h2>Today’s Specials</h2>
|
||||||
|
</div>
|
||||||
|
<a class="text-link" href="/menu">See the full menu</a>
|
||||||
|
</div>
|
||||||
|
<div class="special-grid special-count-<?= min(count($specials), 4) ?>">
|
||||||
|
<?php foreach ($specials as $special): ?>
|
||||||
|
<article class="special-card">
|
||||||
|
<span class="special-badge"><?= e($special['badge']) ?></span>
|
||||||
|
<h3><?= e($special['title']) ?></h3>
|
||||||
|
<p><?= e($special['description']) ?></p>
|
||||||
|
<div class="special-footer">
|
||||||
|
<?php if ($special['price_cents'] !== null): ?>
|
||||||
|
<strong><?= money($special['price_cents']) ?></strong>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($special['menu_item_id']): ?>
|
||||||
|
<form action="/cart/add" method="post">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="menu_item_id" value="<?= (int) $special['menu_item_id'] ?>">
|
||||||
|
<input type="hidden" name="quantity" value="1">
|
||||||
|
<input type="hidden" name="return_to" value="/">
|
||||||
|
<button class="button button-small button-light" type="submit">Add Item</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<section class="section menu-preview">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Built to satisfy</span>
|
||||||
|
<h2>Local Favorites</h2>
|
||||||
|
</div>
|
||||||
|
<p>These are the orders that keep showing up at the counter.</p>
|
||||||
|
</div>
|
||||||
|
<div class="menu-grid">
|
||||||
|
<?php foreach ($featured as $item): ?>
|
||||||
|
<?php \FatBottom\Core\View::partial('partials/menu-card', compact('item')); ?>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="category-band">
|
||||||
|
<div class="container">
|
||||||
|
<span class="eyebrow">Pick your direction</span>
|
||||||
|
<div class="category-links">
|
||||||
|
<?php foreach ($categories as $category): ?>
|
||||||
|
<a href="/menu#<?= e($category['slug']) ?>">
|
||||||
|
<strong><?= e($category['name']) ?></strong>
|
||||||
|
<span><?= count($category['items']) ?> picks</span>
|
||||||
|
</a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="story-section section">
|
||||||
|
<div class="container story-grid">
|
||||||
|
<div class="story-art" aria-hidden="true">
|
||||||
|
<span class="story-number">26726</span>
|
||||||
|
<div class="mountain-lines"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Proudly from Keyser</span>
|
||||||
|
<h2>Food that shows up hungry.</h2>
|
||||||
|
<p>Fat Bottom Grille is the kind of place where the portions are honest and nobody leaves wondering what is for dinner. Find us at 410 W Piedmont Street, right here in Keyser.</p>
|
||||||
|
<a class="button button-outline" href="/menu">Order for Pickup</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<section class="page-hero">
|
||||||
|
<div class="container page-hero-inner">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Hot, fresh, yours</span>
|
||||||
|
<h1>Order Online</h1>
|
||||||
|
<p>Choose your favorites and we will get the kitchen moving.</p>
|
||||||
|
</div>
|
||||||
|
<form class="menu-search" action="/menu" method="get">
|
||||||
|
<label for="menu-query">Search the menu</label>
|
||||||
|
<div class="search-row">
|
||||||
|
<input id="menu-query" type="search" name="q" value="<?= e($query) ?>" placeholder="Burger, chicken, fries...">
|
||||||
|
<button class="button" type="submit">Search</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="category-jump">
|
||||||
|
<div class="container">
|
||||||
|
<?php foreach ($categories as $category): ?>
|
||||||
|
<a href="#<?= e($category['slug']) ?>"><?= e($category['name']) ?></a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container menu-page section">
|
||||||
|
<?php if (!$categories): ?>
|
||||||
|
<div class="empty-state">
|
||||||
|
<span class="eyebrow">No match yet</span>
|
||||||
|
<h2>We could not find “<?= e($query) ?>”</h2>
|
||||||
|
<p>Try another search or browse the full menu.</p>
|
||||||
|
<a class="button" href="/menu">Clear Search</a>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php foreach ($categories as $category): ?>
|
||||||
|
<section class="menu-category" id="<?= e($category['slug']) ?>">
|
||||||
|
<div class="menu-category-heading">
|
||||||
|
<div>
|
||||||
|
<span class="category-index"><?= str_pad((string) $category['display_order'], 2, '0', STR_PAD_LEFT) ?></span>
|
||||||
|
<h2><?= e($category['name']) ?></h2>
|
||||||
|
</div>
|
||||||
|
<p><?= e($category['description']) ?></p>
|
||||||
|
</div>
|
||||||
|
<div class="menu-grid">
|
||||||
|
<?php foreach ($category['items'] as $item): ?>
|
||||||
|
<?php \FatBottom\Core\View::partial('partials/menu-card', compact('item')); ?>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<section class="confirmation-section section">
|
||||||
|
<div class="container confirmation-card">
|
||||||
|
<div class="confirmation-mark">✓</div>
|
||||||
|
<span class="eyebrow">Paid and sent to the kitchen</span>
|
||||||
|
<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>
|
||||||
|
<div class="confirmation-details">
|
||||||
|
<div><span>Pickup</span><strong><?= e($order['pickup_time'] ?: 'About ' . $config->get('ordering.pickup_eta_minutes', 25) . ' minutes') ?></strong></div>
|
||||||
|
<div><span>Location</span><strong>410 W Piedmont St</strong></div>
|
||||||
|
<div><span>Total paid</span><strong><?= money($order['total_cents']) ?></strong></div>
|
||||||
|
</div>
|
||||||
|
<div class="confirmation-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>
|
||||||
|
<div class="confirmation-actions">
|
||||||
|
<a class="button" href="/menu">Order Something Else</a>
|
||||||
|
<?php if ($currentUser): ?><a class="button button-outline" href="/account">View My Orders</a><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
Reference in New Issue
Block a user