- 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:
Ty Clifford
2026-06-10 13:27:42 -04:00
parent d46b0be488
commit 16235369cb
49 changed files with 7685 additions and 2 deletions
+7
View File
@@ -0,0 +1,7 @@
/storage/database/*.sqlite
/storage/database/*.sqlite-*
/storage/logs/*.log
/storage/sessions/*
/storage/config/settings.json
/.env
/.DS_Store
+134 -2
View File
@@ -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
```
+651
View File
@@ -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');
}
}
+265
View File
@@ -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');
}
}
+36
View File
@@ -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);
}
}
+231
View File
@@ -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'] !== []
));
}
}
+84
View File
@@ -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;
}
}
+77
View File
@@ -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();
}
}
+402
View File
@@ -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);
}
}
+53
View File
@@ -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;
}
}
+30
View File
@@ -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();
}
}
+56
View File
@@ -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));
}
}
+26
View File
@@ -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';
}
}
+147
View File
@@ -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]
);
}
}
+80
View File
@@ -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);
}
}
+110
View File
@@ -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;
}
}
+97
View File
@@ -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];
}
}
+105
View File
@@ -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]) ?? [];
}
}
+132
View File
@@ -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']];
}
}
+100
View File
@@ -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'],
];
}
}
+54
View File
@@ -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;
}
}
+60
View File
@@ -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,
];
+56
View File
@@ -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');
}
+49
View File
@@ -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
+85
View File
@@ -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();
})();
+82
View File
@@ -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);
+10
View File
@@ -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';
+92
View File
@@ -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>
+98
View File
@@ -0,0 +1,98 @@
<div class="stat-grid">
<article class="stat-card stat-primary">
<span>Todays 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>Todays 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">Whats 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>
+125
View File
@@ -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>
+29
View File
@@ -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>
+101
View File
@@ -0,0 +1,101 @@
<div class="detail-heading">
<div>
<a class="back-link" href="/admin/orders">&larr; Back to orders</a>
<div class="detail-title-row">
<h2><?= e($order['order_number']) ?></h2>
<span class="status-pill status-<?= e($order['status']) ?>"><?= e(ucfirst((string) $order['status'])) ?></span>
</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') ?> &middot; <?= 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>
+39
View File
@@ -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>
+116
View File
@@ -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>
+63
View File
@@ -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>
+65
View File
@@ -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']) ?> &middot; <?= 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>
+36
View File
@@ -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>
+61
View File
@@ -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>
+19
View File
@@ -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>
+134
View File
@@ -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>&copy; <?= 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>
+28
View File
@@ -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>
+11
View File
@@ -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">&times;</button>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
+40
View File
@@ -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>
+58
View File
@@ -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']) ?>">&times;</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>
+98
View File
@@ -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>
+122
View File
@@ -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>Todays 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>
+52
View File
@@ -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>
+23
View File
@@ -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>Weve got it, <?= e(explode(' ', (string) $order['customer_name'])[0]) ?>.</h1>
<p>Your order number is <strong><?= e($order['order_number']) ?></strong>. We sent a confirmation to <?= e($order['customer_email']) ?>.</p>
<div class="confirmation-details">
<div><span>Pickup</span><strong><?= e($order['pickup_time'] ?: 'About ' . $config->get('ordering.pickup_eta_minutes', 25) . ' minutes') ?></strong></div>
<div><span>Location</span><strong>410 W Piedmont St</strong></div>
<div><span>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>