diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1ec5d74 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/storage/database/*.sqlite +/storage/database/*.sqlite-* +/storage/logs/*.log +/storage/sessions/* +/storage/config/settings.json +/.env +/.DS_Store diff --git a/README.md b/README.md index 8ec7bf6..d43048d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,135 @@ -# fatbottomgrille +# Fat Bottom Grille e-Commerce -The codebase for Fat Bottom Grille \ No newline at end of file +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 +``` diff --git a/app/Controllers/AdminController.php b/app/Controllers/AdminController.php new file mode 100644 index 0000000..0dd332c --- /dev/null +++ b/app/Controllers/AdminController.php @@ -0,0 +1,651 @@ +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 = '

Hi ' . htmlspecialchars((string) $recipient['full_name'], ENT_QUOTES, 'UTF-8') . ',

' + . '
' . nl2br(htmlspecialchars($content, ENT_QUOTES, 'UTF-8')) . '
' + . '

You received this because you joined the Fat Bottom Grille news list. ' + . 'Unsubscribe

'; + 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'); + } +} diff --git a/app/Controllers/AuthController.php b/app/Controllers/AuthController.php new file mode 100644 index 0000000..754ecc1 --- /dev/null +++ b/app/Controllers/AuthController.php @@ -0,0 +1,265 @@ +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'); + } +} diff --git a/app/Controllers/BaseController.php b/app/Controllers/BaseController.php new file mode 100644 index 0000000..f685f88 --- /dev/null +++ b/app/Controllers/BaseController.php @@ -0,0 +1,36 @@ +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); + } +} + diff --git a/app/Controllers/StoreController.php b/app/Controllers/StoreController.php new file mode 100644 index 0000000..68e9fc0 --- /dev/null +++ b/app/Controllers/StoreController.php @@ -0,0 +1,231 @@ +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'], + '

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

' + . '

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

' + ); + 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'] !== [] + )); + } +} + diff --git a/app/Core/Auth.php b/app/Core/Auth.php new file mode 100644 index 0000000..0b2dc89 --- /dev/null +++ b/app/Core/Auth.php @@ -0,0 +1,84 @@ +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; + } +} + diff --git a/app/Core/Config.php b/app/Core/Config.php new file mode 100644 index 0000000..90d1619 --- /dev/null +++ b/app/Core/Config.php @@ -0,0 +1,77 @@ +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(); + } +} diff --git a/app/Core/Database.php b/app/Core/Database.php new file mode 100644 index 0000000..ab589e7 --- /dev/null +++ b/app/Core/Database.php @@ -0,0 +1,402 @@ +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); + } +} diff --git a/app/Core/Http.php b/app/Core/Http.php new file mode 100644 index 0000000..5679eed --- /dev/null +++ b/app/Core/Http.php @@ -0,0 +1,53 @@ + $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; + } +} + diff --git a/app/Core/Router.php b/app/Core/Router.php new file mode 100644 index 0000000..231d468 --- /dev/null +++ b/app/Core/Router.php @@ -0,0 +1,30 @@ +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(); + } +} + diff --git a/app/Core/Security.php b/app/Core/Security.php new file mode 100644 index 0000000..f448b09 --- /dev/null +++ b/app/Core/Security.php @@ -0,0 +1,56 @@ +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] + ); + } +} diff --git a/app/Services/Mailer.php b/app/Services/Mailer.php new file mode 100644 index 0000000..022396c --- /dev/null +++ b/app/Services/Mailer.php @@ -0,0 +1,80 @@ +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); + } +} + diff --git a/app/Services/MenuPdfExporter.php b/app/Services/MenuPdfExporter.php new file mode 100644 index 0000000..7b63cbe --- /dev/null +++ b/app/Services/MenuPdfExporter.php @@ -0,0 +1,110 @@ + '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; + } +} diff --git a/app/Services/MySqlMigrator.php b/app/Services/MySqlMigrator.php new file mode 100644 index 0000000..517a5b2 --- /dev/null +++ b/app/Services/MySqlMigrator.php @@ -0,0 +1,97 @@ +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]; + } +} + diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php new file mode 100644 index 0000000..91b98f0 --- /dev/null +++ b/app/Services/OrderService.php @@ -0,0 +1,105 @@ +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]) ?? []; + } +} diff --git a/app/Services/PaymentGateway.php b/app/Services/PaymentGateway.php new file mode 100644 index 0000000..d94ff1c --- /dev/null +++ b/app/Services/PaymentGateway.php @@ -0,0 +1,132 @@ +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']]; + } +} diff --git a/app/Services/StatsService.php b/app/Services/StatsService.php new file mode 100644 index 0000000..1ac9898 --- /dev/null +++ b/app/Services/StatsService.php @@ -0,0 +1,100 @@ +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'], + ]; + } +} diff --git a/app/Services/TwoFactorService.php b/app/Services/TwoFactorService.php new file mode 100644 index 0000000..c1af835 --- /dev/null +++ b/app/Services/TwoFactorService.php @@ -0,0 +1,54 @@ +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', + "

Hi {$name},

Your verification code is {$code}.

It expires in 10 minutes.

" + ); + + 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; + } +} diff --git a/app/bootstrap.php b/app/bootstrap.php new file mode 100644 index 0000000..4a4fd3e --- /dev/null +++ b/app/bootstrap.php @@ -0,0 +1,60 @@ +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, +]; diff --git a/app/helpers.php b/app/helpers.php new file mode 100644 index 0000000..a0dc3e9 --- /dev/null +++ b/app/helpers.php @@ -0,0 +1,56 @@ +'; +} + +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'); +} diff --git a/config/defaults.php b/config/defaults.php new file mode 100644 index 0000000..cd275a0 --- /dev/null +++ b/config/defaults.php @@ -0,0 +1,49 @@ + [ + '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' => '', + ], +]; diff --git a/public/assets/app.css b/public/assets/app.css new file mode 100644 index 0000000..aaa3a3b --- /dev/null +++ b/public/assets/app.css @@ -0,0 +1,3056 @@ +:root { + --ink: #08121c; + --ink-soft: #0e1d2b; + --navy: #12283b; + --navy-light: #1c3a50; + --gold: #c8973b; + --gold-light: #e1b75e; + --cream: #f3eedf; + --paper: #faf7ef; + --white: #ffffff; + --muted: #9cabb5; + --line: rgba(255, 255, 255, 0.12); + --line-dark: #d8d6ce; + --success: #2e936a; + --warning: #d29136; + --danger: #b94c4c; + --shadow: 0 20px 50px rgba(2, 9, 15, 0.18); + --radius: 4px; + --radius-large: 10px; + --font-display: "Barlow Condensed", Impact, sans-serif; + --font-body: "Inter", Arial, sans-serif; +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + color: var(--ink); + background: var(--paper); + font-family: var(--font-body); + font-size: 16px; + line-height: 1.6; + -webkit-font-smoothing: antialiased; +} + +body.store-body { + background-image: + radial-gradient(circle at 0 0, rgba(200, 151, 59, 0.06), transparent 28%), + linear-gradient(rgba(8, 18, 28, 0.018) 1px, transparent 1px); + background-size: auto, 100% 22px; +} + +a { + color: inherit; + text-decoration: none; +} + +button, +input, +select, +textarea { + font: inherit; +} + +button, +select { + cursor: pointer; +} + +img { + max-width: 100%; +} + +h1, +h2, +h3, +p { + margin-top: 0; +} + +h1, +h2, +h3 { + font-family: var(--font-display); + line-height: 0.98; +} + +h1 { + font-size: clamp(3rem, 7vw, 6.8rem); + letter-spacing: -0.035em; + text-transform: uppercase; +} + +h2 { + font-size: clamp(2rem, 4vw, 3.7rem); + letter-spacing: -0.025em; +} + +h3 { + font-size: 1.45rem; + letter-spacing: -0.01em; +} + +.container { + width: min(1180px, calc(100% - 40px)); + margin-inline: auto; +} + +.narrow-container { + width: min(540px, calc(100% - 40px)); + margin-inline: auto; +} + +.section { + padding-block: 88px; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.skip-link { + position: fixed; + z-index: 9999; + left: 12px; + top: -100px; + padding: 10px 16px; + color: var(--ink); + background: var(--gold-light); +} + +.skip-link:focus { + top: 12px; +} + +.eyebrow { + display: block; + color: var(--gold); + font-family: var(--font-display); + font-size: 0.82rem; + font-weight: 800; + letter-spacing: 0.17em; + line-height: 1.2; + text-transform: uppercase; +} + +.button { + display: inline-flex; + min-height: 46px; + align-items: center; + justify-content: center; + gap: 8px; + padding: 10px 20px; + border: 1px solid var(--gold); + border-radius: var(--radius); + color: var(--ink); + background: var(--gold); + font-family: var(--font-display); + font-size: 0.95rem; + font-weight: 800; + letter-spacing: 0.06em; + line-height: 1; + text-transform: uppercase; + transition: background 160ms ease, border-color 160ms ease, color 160ms ease, transform 160ms ease; +} + +.button:hover { + color: var(--ink); + background: var(--gold-light); + border-color: var(--gold-light); + transform: translateY(-1px); +} + +.button:disabled { + cursor: not-allowed; + opacity: 0.45; + transform: none; +} + +.button-small { + min-height: 38px; + padding: 8px 14px; + font-size: 0.8rem; +} + +.button-large { + min-height: 56px; + padding: 15px 25px; + font-size: 1rem; +} + +.button-block { + width: 100%; +} + +.button-outline { + color: var(--ink); + background: transparent; + border-color: var(--ink); +} + +.button-outline:hover { + color: var(--cream); + background: var(--ink); + border-color: var(--ink); +} + +.button-ghost { + color: inherit; + background: transparent; + border-color: currentColor; +} + +.button-light { + color: var(--ink); + background: var(--cream); + border-color: var(--cream); +} + +.button-danger { + color: var(--white); + background: var(--danger); + border-color: var(--danger); +} + +.text-link { + display: inline-flex; + align-items: center; + color: inherit; + font-weight: 700; + border-bottom: 1px solid currentColor; +} + +.text-center { + justify-content: center; + text-align: center; +} + +.announcement { + display: flex; + min-height: 34px; + align-items: center; + justify-content: center; + gap: 30px; + padding: 6px 20px; + color: var(--ink); + background: var(--gold); + font-family: var(--font-display); + font-size: 0.75rem; + font-weight: 800; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.announcement-detail { + opacity: 0.72; +} + +.site-header { + position: relative; + z-index: 50; + color: var(--cream); + background: rgba(8, 18, 28, 0.98); + border-bottom: 1px solid var(--line); +} + +.nav-wrap { + display: flex; + min-height: 84px; + align-items: center; + justify-content: space-between; + gap: 30px; +} + +.brand, +.admin-brand { + display: flex; + align-items: center; + gap: 12px; +} + +.brand-mark { + display: grid; + width: 48px; + height: 48px; + place-items: center; + color: var(--ink); + background: var(--gold); + border: 2px solid var(--gold-light); + border-radius: 50%; + box-shadow: inset 0 0 0 4px var(--ink); + font-family: var(--font-display); + font-size: 1rem; + font-weight: 800; + letter-spacing: 0.02em; +} + +.brand-copy, +.admin-brand > span:last-child { + display: flex; + flex-direction: column; +} + +.brand-copy strong, +.admin-brand strong { + font-family: var(--font-display); + font-size: 1.35rem; + letter-spacing: 0.03em; + line-height: 1; + text-transform: uppercase; +} + +.brand-copy small, +.admin-brand small { + margin-top: 5px; + color: var(--muted); + font-size: 0.62rem; + letter-spacing: 0.13em; + line-height: 1; + text-transform: uppercase; +} + +.site-nav { + display: flex; + align-items: center; + gap: 26px; + font-family: var(--font-display); + font-size: 0.9rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.site-nav > a { + position: relative; + padding-block: 30px; +} + +.site-nav > a:not(.cart-link)::after { + position: absolute; + right: 0; + bottom: 20px; + left: 0; + height: 2px; + background: var(--gold); + content: ""; + transform: scaleX(0); + transition: transform 160ms ease; +} + +.site-nav > a:hover::after, +.site-nav > a.is-active::after { + transform: scaleX(1); +} + +.cart-link { + display: flex; + min-height: 42px; + align-items: center; + gap: 10px; + padding: 10px 14px !important; + border: 1px solid var(--gold); + border-radius: var(--radius); +} + +.cart-count { + display: grid; + min-width: 22px; + height: 22px; + place-items: center; + color: var(--ink); + background: var(--gold); + border-radius: 50%; + font-family: var(--font-body); + font-size: 0.7rem; +} + +.nav-toggle { + display: none; + width: 46px; + height: 42px; + padding: 9px; + border: 1px solid var(--line); + background: transparent; +} + +.nav-toggle span:not(.sr-only) { + display: block; + height: 2px; + margin: 4px 0; + background: var(--cream); +} + +.hero { + position: relative; + min-height: 690px; + overflow: hidden; + color: var(--cream); + background: + linear-gradient(108deg, rgba(8, 18, 28, 0.98) 0%, rgba(12, 30, 43, 0.96) 52%, rgba(16, 42, 58, 0.9) 100%); +} + +.hero::after { + position: absolute; + right: -10%; + bottom: -38%; + width: 60%; + aspect-ratio: 1; + border: 1px solid rgba(200, 151, 59, 0.16); + border-radius: 50%; + content: ""; + box-shadow: + 0 0 0 80px rgba(200, 151, 59, 0.03), + 0 0 0 160px rgba(200, 151, 59, 0.025); +} + +.hero-texture { + position: absolute; + inset: 0; + opacity: 0.42; + background-image: + linear-gradient(130deg, transparent 0 49%, rgba(255, 255, 255, 0.025) 50% 51%, transparent 52%), + repeating-linear-gradient(0deg, transparent 0 7px, rgba(255, 255, 255, 0.018) 8px); + background-size: 90px 90px, auto; +} + +.hero-grid { + position: relative; + z-index: 2; + display: grid; + min-height: 690px; + grid-template-columns: 1.02fr 0.98fr; + align-items: center; + gap: 40px; +} + +.hero-copy { + padding-block: 80px; +} + +.hero h1 { + max-width: 700px; + margin: 18px 0 24px; +} + +.hero h1 em { + color: var(--gold); + font-style: normal; +} + +.hero-copy > p { + max-width: 620px; + color: #c8d0d4; + font-size: 1.08rem; +} + +.hero-actions { + display: flex; + align-items: center; + gap: 28px; + margin-top: 34px; +} + +.hero-meta { + display: flex; + gap: 28px; + margin-top: 44px; + padding-top: 22px; + border-top: 1px solid var(--line); + color: #aebac0; + font-size: 0.78rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.hero-meta span::before { + display: inline-block; + width: 7px; + height: 7px; + margin-right: 9px; + background: var(--gold); + border-radius: 50%; + content: ""; +} + +.hero-plate { + position: relative; + display: grid; + min-height: 560px; + place-items: center; +} + +.plate-ring { + display: grid; + width: min(470px, 40vw); + aspect-ratio: 1; + place-items: center; + border: 2px solid rgba(225, 183, 94, 0.35); + border-radius: 50%; + background: + radial-gradient(circle, rgba(255, 255, 255, 0.11) 0 52%, rgba(255, 255, 255, 0.03) 53% 65%, transparent 66%), + linear-gradient(145deg, rgba(255, 255, 255, 0.08), transparent 65%); + box-shadow: 0 45px 90px rgba(0, 0, 0, 0.35); + transform: rotate(-6deg); +} + +.burger { + position: relative; + width: 74%; + height: 250px; + filter: drop-shadow(0 28px 20px rgba(0, 0, 0, 0.32)); +} + +.burger > span { + position: absolute; + right: 5%; + left: 5%; + display: block; + border-radius: 50%; +} + +.bun { + height: 67px; + background: + radial-gradient(ellipse at 25% 25%, #f4db8f 0 3px, transparent 4px), + radial-gradient(ellipse at 60% 16%, #f4db8f 0 3px, transparent 4px), + radial-gradient(ellipse at 78% 34%, #f4db8f 0 2px, transparent 3px), + linear-gradient(#c98530, #e6ad4e); +} + +.bun-top { + top: 8px; + height: 88px; + border-radius: 60% 60% 35% 35%; +} + +.bun-bottom { + bottom: 8px; + border-radius: 35% 35% 50% 50%; +} + +.patty { + top: 108px; + height: 43px; + background: linear-gradient(#4d2118, #24100d); + box-shadow: inset 0 8px 0 rgba(255, 255, 255, 0.06); +} + +.patty-two { + top: 158px; +} + +.lettuce { + top: 91px; + z-index: 2; + height: 26px; + background: #688c35; + clip-path: polygon(0 25%, 10% 0, 20% 35%, 30% 5%, 42% 42%, 55% 2%, 68% 35%, 79% 0, 90% 35%, 100% 10%, 97% 75%, 0 80%); +} + +.cheese { + top: 135px; + z-index: 2; + height: 35px; + background: #e6b83e; + clip-path: polygon(3% 0, 97% 0, 91% 60%, 66% 54%, 52% 100%, 38% 52%, 10% 70%); +} + +.onion { + top: 151px; + z-index: 3; + height: 12px; + border: 4px solid #cba8c5; + background: transparent; +} + +.hero-stamp { + position: absolute; + right: 4%; + bottom: 9%; + display: grid; + width: 120px; + height: 120px; + place-content: center; + text-align: center; + color: var(--gold); + border: 2px solid var(--gold); + border-radius: 50%; + transform: rotate(8deg); +} + +.hero-stamp strong { + font-family: var(--font-display); + font-size: 2.4rem; + line-height: 0.8; +} + +.hero-stamp span { + font-size: 0.57rem; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.section-heading { + display: flex; + align-items: end; + justify-content: space-between; + gap: 40px; + margin-bottom: 36px; +} + +.section-heading h2 { + margin: 8px 0 0; +} + +.section-heading > p { + max-width: 430px; + margin-bottom: 8px; + color: #5b6670; +} + +.specials-section { + color: var(--cream); + background: var(--navy); +} + +.special-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 16px; +} + +.special-count-1 { + grid-template-columns: minmax(0, 1fr); +} + +.special-card { + position: relative; + min-height: 250px; + padding: 30px; + overflow: hidden; + color: var(--cream); + background: + linear-gradient(145deg, rgba(255, 255, 255, 0.07), transparent 58%), + var(--ink-soft); + border: 1px solid rgba(200, 151, 59, 0.28); + border-radius: var(--radius); +} + +.special-card::after { + position: absolute; + right: -70px; + bottom: -70px; + width: 170px; + height: 170px; + border: 1px solid rgba(200, 151, 59, 0.22); + border-radius: 50%; + content: ""; +} + +.special-badge, +.card-badge { + display: inline-block; + padding: 5px 9px; + color: var(--ink); + background: var(--gold); + border-radius: 2px; + font-family: var(--font-display); + font-size: 0.68rem; + font-weight: 800; + letter-spacing: 0.09em; + line-height: 1; + text-transform: uppercase; +} + +.special-card h3 { + margin: 40px 0 12px; + font-size: 2rem; + text-transform: uppercase; +} + +.special-card p { + max-width: 420px; + color: #b9c2c8; +} + +.special-footer { + position: relative; + z-index: 2; + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + margin-top: 24px; +} + +.special-footer > strong { + color: var(--gold-light); + font-family: var(--font-display); + font-size: 1.9rem; +} + +.menu-preview { + background: var(--paper); +} + +.menu-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 20px; +} + +.menu-card { + min-width: 0; + overflow: hidden; + background: var(--white); + border: 1px solid #dedcd4; + border-radius: var(--radius-large); + box-shadow: 0 8px 28px rgba(8, 18, 28, 0.06); + transition: transform 160ms ease, box-shadow 160ms ease; +} + +.menu-card:hover { + transform: translateY(-4px); + box-shadow: var(--shadow); +} + +.menu-card-art { + position: relative; + display: grid; + height: 185px; + place-items: center; + overflow: hidden; + color: rgba(225, 183, 94, 0.92); + background: + radial-gradient(circle at 70% 20%, rgba(200, 151, 59, 0.18), transparent 25%), + repeating-linear-gradient(135deg, transparent 0 10px, rgba(255, 255, 255, 0.025) 11px 12px), + var(--navy); + background-position: center; + background-size: cover; + font-family: var(--font-display); + font-size: 4.4rem; + font-weight: 800; + letter-spacing: -0.06em; +} + +.menu-card-art::after { + position: absolute; + width: 150px; + height: 150px; + border: 1px solid rgba(200, 151, 59, 0.24); + border-radius: 50%; + content: ""; +} + +.menu-card-art.has-image::after { + inset: 0; + width: auto; + height: auto; + border: 0; + border-radius: 0; + background: linear-gradient(transparent, rgba(8, 18, 28, 0.28)); +} + +.menu-card-art > span:first-child { + position: relative; + z-index: 2; +} + +.menu-card-art .card-badge { + position: absolute; + z-index: 3; + top: 14px; + left: 14px; + font-size: 0.65rem; +} + +.menu-card-body { + padding: 22px; +} + +.menu-card-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 15px; +} + +.menu-card-heading h3 { + margin-bottom: 0; + text-transform: uppercase; +} + +.menu-card-heading > strong { + color: #8e6422; + white-space: nowrap; +} + +.menu-card-body > p { + min-height: 50px; + margin: 12px 0 16px; + color: #68737a; + font-size: 0.86rem; + line-height: 1.5; +} + +.tag-list { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 16px; +} + +.tag-list span { + padding: 3px 8px; + color: #5f6b72; + background: #f0eee8; + border-radius: 20px; + font-size: 0.65rem; + font-weight: 600; +} + +.quick-add { + display: grid; + grid-template-columns: 62px 1fr; + gap: 8px; +} + +.quick-add select { + width: 100%; + height: 38px; + padding-inline: 10px; + border: 1px solid #cbc9c1; + border-radius: var(--radius); + background: var(--white); +} + +.category-band { + padding-block: 55px; + color: var(--cream); + background: var(--ink); +} + +.category-links { + display: grid; + grid-template-columns: repeat(5, 1fr); + margin-top: 22px; + border-top: 1px solid var(--line); + border-left: 1px solid var(--line); +} + +.category-links a { + display: flex; + min-height: 115px; + flex-direction: column; + justify-content: flex-end; + padding: 20px; + border-right: 1px solid var(--line); + border-bottom: 1px solid var(--line); + transition: background 160ms ease; +} + +.category-links a:hover { + background: var(--navy); +} + +.category-links strong { + font-family: var(--font-display); + font-size: 1.2rem; + text-transform: uppercase; +} + +.category-links span { + margin-top: 5px; + color: var(--muted); + font-size: 0.7rem; + text-transform: uppercase; +} + +.story-grid { + display: grid; + grid-template-columns: 1fr 1fr; + align-items: center; + gap: 80px; +} + +.story-grid h2 { + margin: 12px 0 22px; + text-transform: uppercase; +} + +.story-grid p { + margin-bottom: 30px; + color: #5e6970; + font-size: 1.05rem; +} + +.story-art { + position: relative; + display: grid; + min-height: 410px; + place-items: center; + overflow: hidden; + color: var(--gold); + background: var(--navy); +} + +.story-number { + position: relative; + z-index: 2; + font-family: var(--font-display); + font-size: clamp(4rem, 9vw, 8.5rem); + font-weight: 800; + letter-spacing: -0.07em; +} + +.mountain-lines { + position: absolute; + right: -8%; + bottom: -18%; + left: -8%; + height: 70%; + border-top: 1px solid rgba(225, 183, 94, 0.5); + transform: skewY(-13deg); + box-shadow: + 0 -32px 0 -31px rgba(225, 183, 94, 0.5), + 0 -64px 0 -63px rgba(225, 183, 94, 0.45), + 0 -96px 0 -95px rgba(225, 183, 94, 0.38), + 0 -128px 0 -127px rgba(225, 183, 94, 0.3); +} + +.page-hero { + padding-block: 72px; + color: var(--cream); + background: + radial-gradient(circle at 80% 0, rgba(200, 151, 59, 0.18), transparent 30%), + var(--navy); +} + +.page-hero-compact { + padding-block: 54px; +} + +.page-hero h1 { + margin: 12px 0 0; + font-size: clamp(3.3rem, 7vw, 5.5rem); +} + +.page-hero p { + margin-bottom: 0; + color: #b9c4ca; +} + +.page-hero-inner { + display: grid; + grid-template-columns: 1fr minmax(340px, 0.55fr); + align-items: end; + gap: 70px; +} + +.menu-search label { + display: block; + margin-bottom: 8px; + color: #c3cbd0; + font-size: 0.76rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.search-row { + display: grid; + grid-template-columns: 1fr auto; +} + +.search-row input { + min-width: 0; + border-radius: var(--radius) 0 0 var(--radius); +} + +.search-row .button { + border-radius: 0 var(--radius) var(--radius) 0; +} + +.category-jump { + position: sticky; + z-index: 30; + top: 0; + overflow-x: auto; + color: var(--cream); + background: var(--ink); + border-top: 1px solid var(--line); +} + +.category-jump .container { + display: flex; + gap: 32px; +} + +.category-jump a { + flex: 0 0 auto; + padding-block: 16px; + font-family: var(--font-display); + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.category-jump a:hover { + color: var(--gold); +} + +.menu-category { + scroll-margin-top: 65px; + margin-bottom: 86px; +} + +.menu-category:last-child { + margin-bottom: 0; +} + +.menu-category-heading { + display: grid; + grid-template-columns: 1fr minmax(280px, 0.48fr); + align-items: end; + gap: 40px; + margin-bottom: 28px; + padding-bottom: 20px; + border-bottom: 2px solid var(--ink); +} + +.menu-category-heading > div { + display: flex; + align-items: baseline; + gap: 15px; +} + +.menu-category-heading h2 { + margin-bottom: 0; + font-size: 3rem; + text-transform: uppercase; +} + +.menu-category-heading p { + margin-bottom: 4px; + color: #667077; +} + +.category-index { + color: var(--gold); + font-family: var(--font-display); + font-weight: 800; +} + +.checkout-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 360px; + align-items: start; + gap: 34px; +} + +.checkout-main { + min-width: 0; +} + +.panel, +.admin-panel { + padding: 30px; + background: var(--white); + border: 1px solid #dcdbd4; + border-radius: var(--radius-large); + box-shadow: 0 8px 25px rgba(8, 18, 28, 0.05); +} + +.order-summary { + position: sticky; + top: 25px; + padding: 28px; + color: var(--cream); + background: var(--navy); + border-radius: var(--radius-large); + box-shadow: var(--shadow); +} + +.order-summary h2 { + margin: 10px 0 22px; + font-size: 2.4rem; +} + +.order-summary dl { + margin: 20px 0; +} + +.order-summary dl > div { + display: flex; + justify-content: space-between; + gap: 15px; + padding-block: 8px; + color: #b9c3c9; + font-size: 0.84rem; +} + +.order-summary dd { + margin: 0; + text-align: right; +} + +.order-summary .summary-total { + margin-top: 12px; + padding-top: 18px; + color: var(--cream); + border-top: 1px solid var(--line); + font-family: var(--font-display); + font-size: 1.4rem; + font-weight: 800; + text-transform: uppercase; +} + +.summary-note { + margin: 15px 0 0; + color: var(--muted); + font-size: 0.72rem; + text-align: center; +} + +.cart-list { + overflow: hidden; + background: var(--white); + border: 1px solid #dcdbd4; + border-radius: var(--radius-large); +} + +.cart-item { + display: grid; + grid-template-columns: 72px minmax(0, 1fr) 80px 90px 36px; + align-items: center; + gap: 18px; + padding: 22px; + border-bottom: 1px solid #e5e3db; +} + +.cart-item:last-child { + border-bottom: 0; +} + +.cart-item-mark, +.item-avatar { + display: grid; + width: 56px; + height: 56px; + place-items: center; + color: var(--gold); + background: var(--navy); + border-radius: 50%; + font-family: var(--font-display); + font-size: 1.05rem; + font-weight: 800; +} + +.cart-item-copy h2 { + margin-bottom: 4px; + font-size: 1.35rem; + text-transform: uppercase; +} + +.cart-item-copy p, +.cart-item-copy span { + margin: 0; + color: #758088; + font-size: 0.76rem; +} + +.quantity-field span { + display: block; + color: #7a8389; + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; +} + +.quantity-field input { + width: 70px; +} + +.cart-line-total { + text-align: right; +} + +.icon-button { + width: 32px; + height: 32px; + padding: 0; + color: #8d5353; + background: transparent; + border: 1px solid #d9c3c3; + border-radius: 50%; + font-size: 1.2rem; +} + +.cart-actions { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 22px; +} + +.form-stack { + display: flex; + flex-direction: column; + gap: 20px; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; +} + +.form-grid-three { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.field-wide { + grid-column: 1 / -1; +} + +label { + color: #3d4951; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.03em; +} + +input, +select, +textarea { + width: 100%; + min-height: 46px; + padding: 10px 12px; + color: var(--ink); + background: var(--white); + border: 1px solid #c9c9c3; + border-radius: var(--radius); + outline: 0; + transition: border-color 150ms ease, box-shadow 150ms ease; +} + +textarea { + min-height: 90px; + resize: vertical; +} + +label > input, +label > select, +label > textarea { + display: block; + margin-top: 7px; +} + +input:focus, +select:focus, +textarea:focus { + border-color: var(--gold); + box-shadow: 0 0 0 3px rgba(200, 151, 59, 0.15); +} + +.form-section { + padding-bottom: 28px; + border-bottom: 1px solid #e2e0d8; +} + +.form-section:last-of-type { + padding-bottom: 0; + border: 0; +} + +.form-section-heading { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 22px; +} + +.form-section-heading > span { + display: grid; + width: 35px; + height: 35px; + flex: 0 0 auto; + place-items: center; + color: var(--ink); + background: var(--gold); + border-radius: 50%; + font-family: var(--font-display); + font-weight: 800; +} + +.form-section-heading h2 { + margin-bottom: 3px; + font-size: 1.7rem; + text-transform: uppercase; +} + +.form-section-heading p { + margin: 0; + color: #778188; + font-size: 0.77rem; +} + +.square-card { + min-height: 90px; + padding: 16px; + border: 1px solid #d1d0c8; + border-radius: var(--radius); +} + +.field-error { + margin-top: 8px; + color: var(--danger); + font-size: 0.8rem; +} + +.demo-payment { + display: flex; + align-items: center; + gap: 15px; + padding: 18px; + background: #f0eee7; + border: 1px dashed #b7b3a7; + border-radius: var(--radius); +} + +.demo-payment-mark { + display: grid; + width: 42px; + height: 42px; + flex: 0 0 auto; + place-items: center; + color: var(--cream); + background: var(--navy); + border-radius: 50%; + font-family: var(--font-display); + font-weight: 800; +} + +.demo-payment p { + margin: 2px 0 0; + color: #6d777d; + font-size: 0.75rem; +} + +.mini-order-list { + padding-block: 15px; + border-bottom: 1px solid var(--line); +} + +.mini-order-list > div { + display: flex; + justify-content: space-between; + gap: 14px; + padding-block: 7px; + font-size: 0.8rem; +} + +.mini-order-list b { + color: var(--gold-light); +} + +.empty-state { + padding: 70px 25px; + text-align: center; + background: var(--white); + border: 1px dashed #c5c2b7; + border-radius: var(--radius-large); +} + +.empty-state h2 { + text-transform: uppercase; +} + +.empty-state p { + color: #6f797f; +} + +.empty-state.compact { + padding: 35px 20px; +} + +.confirmation-section { + min-height: 690px; + color: var(--cream); + background: + radial-gradient(circle at 50% 0, rgba(200, 151, 59, 0.17), transparent 34%), + var(--ink); +} + +.confirmation-card { + max-width: 780px; + text-align: center; +} + +.confirmation-mark { + display: grid; + width: 80px; + height: 80px; + margin: 0 auto 25px; + place-items: center; + color: var(--ink); + background: var(--gold); + border-radius: 50%; + font-size: 2.3rem; + font-weight: 800; +} + +.confirmation-card h1 { + margin: 16px 0 20px; + font-size: clamp(3rem, 7vw, 5.5rem); +} + +.confirmation-card > p { + color: #bdc6cb; +} + +.confirmation-details { + display: grid; + grid-template-columns: repeat(3, 1fr); + margin-block: 40px; + border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line); +} + +.confirmation-details div { + display: flex; + min-height: 110px; + flex-direction: column; + justify-content: center; + padding: 15px; + border-right: 1px solid var(--line); +} + +.confirmation-details div:last-child { + border: 0; +} + +.confirmation-details span { + color: var(--muted); + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.confirmation-items { + max-width: 480px; + margin-inline: auto; +} + +.confirmation-items > div { + display: flex; + justify-content: space-between; + padding-block: 8px; +} + +.confirmation-actions { + display: flex; + justify-content: center; + gap: 12px; + margin-top: 35px; +} + +.confirmation-actions .button-outline { + color: var(--cream); + border-color: var(--cream); +} + +.auth-section { + min-height: 710px; + background: + linear-gradient(90deg, rgba(8, 18, 28, 0.95), rgba(8, 18, 28, 0.87)), + repeating-linear-gradient(135deg, var(--navy) 0 10px, var(--ink) 11px 12px); +} + +.auth-grid { + display: grid; + grid-template-columns: minmax(0, 0.8fr) minmax(440px, 0.65fr); + align-items: center; + gap: 100px; +} + +.auth-grid-wide { + grid-template-columns: minmax(0, 0.65fr) minmax(520px, 0.85fr); +} + +.auth-intro { + color: var(--cream); +} + +.auth-intro h1 { + margin: 16px 0 25px; +} + +.auth-intro > p { + max-width: 520px; + color: #b7c1c6; + font-size: 1.05rem; +} + +.auth-callout { + display: flex; + flex-direction: column; + margin-top: 40px; + padding: 20px; + border-left: 3px solid var(--gold); + background: rgba(255, 255, 255, 0.04); +} + +.auth-callout span { + color: var(--muted); + font-size: 0.8rem; +} + +.auth-card { + padding: 34px; + background: var(--paper); + border-radius: var(--radius-large); + box-shadow: 0 30px 80px rgba(0, 0, 0, 0.35); +} + +.auth-card h1, +.auth-card h2 { + margin: 8px 0 0; + font-size: 2.4rem; + text-transform: uppercase; +} + +.auth-switch, +.form-footnote { + margin: 0; + color: #717b81; + font-size: 0.75rem; + text-align: center; +} + +.auth-switch a { + color: #8c621d; + font-weight: 700; +} + +.honeypot { + position: absolute !important; + left: -10000px !important; + width: 1px !important; + height: 1px !important; + overflow: hidden !important; +} + +.check-list { + padding: 0; + margin: 35px 0 0; + list-style: none; +} + +.check-list li { + padding: 10px 0 10px 28px; + color: #c2cbd0; + border-bottom: 1px solid var(--line); +} + +.check-list li::before { + margin-left: -28px; + margin-right: 12px; + color: var(--gold); + content: "✓"; +} + +.verification-icon { + display: grid; + width: 68px; + height: 68px; + margin-inline: auto; + place-items: center; + color: var(--ink); + background: var(--gold); + border-radius: 50%; + font-family: var(--font-display); + font-size: 2rem; + font-weight: 800; +} + +.verify-card { + text-align: center; +} + +.code-input { + min-height: 62px; + font-family: var(--font-display); + font-size: 2rem; + font-weight: 800; + letter-spacing: 0.35em; + text-align: center; +} + +.notice { + padding: 13px 15px; + color: #58646b; + background: #f0eee7; + border-left: 3px solid var(--gold); + border-radius: 0 var(--radius) var(--radius) 0; + font-size: 0.76rem; +} + +.notice-error { + color: #783b3b; + background: #f7e6e6; + border-color: var(--danger); +} + +.check-control { + display: flex; + align-items: flex-start; + gap: 11px; + padding: 14px; + background: #f4f2eb; + border: 1px solid #ddd9ce; + border-radius: var(--radius); +} + +.check-control input { + width: 18px; + min-height: 18px; + margin: 2px 0 0; +} + +.check-control span { + display: flex; + flex-direction: column; +} + +.check-control small { + margin-top: 2px; + color: #7a8388; + font-weight: 400; +} + +.check-control.compact { + align-items: center; + align-self: end; + padding: 10px 12px; +} + +.account-password { + padding: 14px; + background: #f4f2eb; + border: 1px solid #ddd9ce; + border-radius: var(--radius); +} + +.account-password > summary { + cursor: pointer; + font-size: 0.78rem; + font-weight: 700; +} + +.account-password[open] > summary { + margin-bottom: 16px; +} + +.account-grid { + display: grid; + grid-template-columns: minmax(0, 1.08fr) minmax(360px, 0.92fr); + align-items: start; + gap: 28px; +} + +.panel-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + margin-bottom: 25px; +} + +.panel-heading h2 { + margin: 7px 0 0; + font-size: 2rem; + text-transform: uppercase; +} + +.account-email { + color: #737c82; + font-size: 0.75rem; +} + +.order-history article { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: 14px; + padding-block: 16px; + border-bottom: 1px solid #e2e0d9; +} + +.order-history article:last-child { + border: 0; +} + +.order-history article > div { + display: flex; + flex-direction: column; +} + +.order-history article > div span { + color: #7b8489; + font-size: 0.7rem; +} + +.status-pill, +.role-badge, +.count-badge { + display: inline-flex; + width: fit-content; + align-items: center; + padding: 4px 9px; + color: #44515a; + background: #e8e8e3; + border-radius: 20px; + font-size: 0.65rem; + font-weight: 700; + line-height: 1.2; + text-transform: uppercase; + white-space: nowrap; +} + +.status-paid, +.status-completed, +.status-ready { + color: #246449; + background: #ddefe6; +} + +.status-preparing { + color: #765417; + background: #f5e8c9; +} + +.status-cancelled, +.status-refunded { + color: #7d3f3f; + background: #f5dddd; +} + +.status-partially_refunded { + color: #80511d; + background: #f5e6d2; +} + +.flash-wrap, +.admin-flash-wrap { + position: fixed; + z-index: 100; + top: 115px; + right: 20px; + width: min(420px, calc(100% - 40px)); +} + +.admin-flash-wrap { + top: 20px; +} + +.flash { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 15px; + margin-bottom: 8px; + padding: 14px 16px; + color: var(--white); + background: var(--navy); + border-left: 4px solid var(--gold); + border-radius: var(--radius); + box-shadow: var(--shadow); + font-size: 0.8rem; +} + +.flash-success { + border-color: var(--success); +} + +.flash-error { + border-color: var(--danger); +} + +.flash-warning { + border-color: var(--warning); +} + +.flash-close { + padding: 0; + color: currentColor; + background: transparent; + border: 0; + font-size: 1.2rem; + line-height: 1; +} + +.site-footer { + padding-top: 68px; + color: #b6c0c6; + background: #050c12; +} + +.footer-grid { + display: grid; + grid-template-columns: 1.6fr 1fr 1fr 1fr; + gap: 50px; + padding-bottom: 50px; +} + +.footer-brand { + color: var(--cream); + font-family: var(--font-display); + font-size: 2rem; + font-weight: 800; + text-transform: uppercase; +} + +.footer-grid h2 { + margin-bottom: 14px; + color: var(--gold); + font-size: 0.88rem; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.footer-grid p, +.footer-grid address, +.footer-grid a { + display: block; + margin: 0 0 6px; + font-size: 0.8rem; + font-style: normal; +} + +.footer-bottom { + display: flex; + justify-content: space-between; + padding-block: 18px; + color: #6e7a81; + border-top: 1px solid var(--line); + font-size: 0.68rem; + text-transform: uppercase; +} + +/* Dashboard */ +.admin-body { + color: #dbe2e6; + background: #08121c; +} + +.admin-shell { + display: grid; + min-height: 100vh; + grid-template-columns: 250px minmax(0, 1fr); +} + +.admin-sidebar { + position: sticky; + top: 0; + display: flex; + height: 100vh; + flex-direction: column; + padding: 26px 18px 20px; + background: #06101a; + border-right: 1px solid var(--line); +} + +.admin-brand { + min-height: 62px; + padding: 0 8px 22px; + color: var(--cream); + border-bottom: 1px solid var(--line); +} + +.admin-brand .brand-mark { + width: 42px; + height: 42px; +} + +.admin-nav { + display: flex; + flex-direction: column; + gap: 4px; + padding-block: 28px; +} + +.admin-nav a { + padding: 11px 13px; + color: #90a0aa; + border-radius: var(--radius); + font-family: var(--font-display); + font-size: 0.86rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.admin-nav a:hover, +.admin-nav a.is-active { + color: var(--cream); + background: var(--navy); +} + +.admin-nav a.is-active { + box-shadow: inset 3px 0 var(--gold); +} + +.admin-user { + display: flex; + align-items: center; + gap: 10px; + padding: 16px 8px 0; + margin-top: auto; + border-top: 1px solid var(--line); +} + +.avatar { + display: grid; + width: 36px; + height: 36px; + flex: 0 0 auto; + place-items: center; + color: var(--ink); + background: var(--gold); + border-radius: 50%; + font-family: var(--font-display); + font-weight: 800; +} + +.admin-user > span:last-child { + display: flex; + min-width: 0; + flex-direction: column; +} + +.admin-user strong { + overflow: hidden; + font-size: 0.75rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-user small { + color: var(--muted); + font-size: 0.65rem; +} + +.admin-content { + min-width: 0; +} + +.admin-topbar { + display: flex; + min-height: 106px; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 20px 34px; + background: var(--ink-soft); + border-bottom: 1px solid var(--line); +} + +.admin-topbar h1 { + margin: 5px 0 0; + font-size: 2.35rem; + letter-spacing: -0.015em; +} + +.admin-top-actions { + display: flex; + align-items: center; + gap: 9px; +} + +.admin-main { + padding: 30px 34px 60px; +} + +.admin-body .button-outline { + color: #d9e0e4; + border-color: #71818c; +} + +.admin-body .button-outline:hover { + color: var(--ink); + background: var(--gold); + border-color: var(--gold); +} + +.admin-body .admin-panel { + color: #dbe2e6; + background: #0e1c28; + border-color: var(--line); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.14); +} + +.admin-body label { + color: #aebbc3; +} + +.admin-body input, +.admin-body select, +.admin-body textarea { + color: #e5eaed; + background: #08141f; + border-color: #304251; +} + +.admin-body input:disabled { + color: #788792; + background: #0b1721; +} + +.admin-body .notice, +.admin-body .check-control { + color: #aebbc3; + background: #0a1722; + border-color: #283b49; +} + +.admin-body .check-control small, +.admin-body .muted { + color: #82919b; +} + +.stat-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 16px; + margin-bottom: 22px; +} + +.stat-card { + display: flex; + min-height: 150px; + flex-direction: column; + justify-content: center; + padding: 22px; + background: #0e1c28; + border: 1px solid var(--line); + border-radius: var(--radius-large); +} + +.stat-card span { + color: #91a1aa; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.stat-card strong { + margin: 8px 0 2px; + color: var(--cream); + font-family: var(--font-display); + font-size: 2.6rem; + line-height: 1; +} + +.stat-card small { + color: #73848f; + font-size: 0.68rem; +} + +.stat-card.stat-primary { + background: + linear-gradient(135deg, rgba(200, 151, 59, 0.2), transparent 72%), + var(--navy); + border-color: rgba(200, 151, 59, 0.35); +} + +.stat-card.stat-primary strong { + color: var(--gold-light); +} + +.admin-dashboard-grid { + display: grid; + grid-template-columns: 1.45fr 0.75fr; + gap: 20px; +} + +.admin-panel-wide { + grid-row: span 2; +} + +.admin-panel-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + margin-bottom: 22px; +} + +.admin-panel-heading h2 { + margin: 5px 0 0; + font-size: 1.55rem; + text-transform: uppercase; +} + +.table-wrap { + overflow-x: auto; + margin: 0 -30px -30px; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, +td { + padding: 14px 18px; + text-align: left; + border-top: 1px solid var(--line); +} + +th { + color: #7f909a; + font-size: 0.64rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +td { + color: #cbd3d8; + font-size: 0.76rem; +} + +td a { + color: var(--gold-light); + font-weight: 700; +} + +td small { + display: block; + color: #74848e; + font-size: 0.65rem; +} + +.empty-cell { + padding: 35px; + text-align: center; +} + +.bar-chart { + display: flex; + min-height: 180px; + align-items: end; + justify-content: center; + gap: 12px; + padding-top: 25px; +} + +.bar-chart > div { + display: flex; + flex: 1; + flex-direction: column; + align-items: center; + gap: 5px; +} + +.bar-chart i { + display: block; + width: 100%; + max-width: 30px; + background: linear-gradient(var(--gold-light), var(--gold)); + border-radius: 3px 3px 0 0; +} + +.bar-chart small, +.bar-value { + color: #7e8d96; + font-size: 0.58rem; + font-style: normal; +} + +.rank-list > div { + display: grid; + grid-template-columns: 25px minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + padding-block: 11px; + border-bottom: 1px solid var(--line); +} + +.rank-list > div > span { + color: var(--gold); + font-family: var(--font-display); + font-weight: 800; +} + +.rank-list strong { + font-size: 0.77rem; +} + +.rank-list small { + grid-column: 2; + color: #71818c; + font-size: 0.62rem; +} + +.rank-list b { + grid-column: 3; + grid-row: 1 / span 2; + color: #9faeb6; + font-size: 0.72rem; +} + +.admin-toolbar, +.admin-page-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; +} + +.admin-page-actions { + margin-bottom: 20px; +} + +.admin-page-actions p { + margin: 0; + color: #91a0aa; + font-size: 0.78rem; +} + +.admin-security-notice { + margin: -4px 0 22px; +} + +.admin-security-notice a { + color: var(--gold-light); + font-weight: 700; + text-decoration: underline; +} + +.admin-filters { + display: flex; + flex: 1; + gap: 8px; +} + +.admin-filters input { + max-width: 330px; +} + +.admin-filters select { + max-width: 180px; +} + +.detail-heading { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 20px; + margin-bottom: 22px; +} + +.back-link { + color: #93a2ab; + font-size: 0.72rem; +} + +.detail-title-row { + display: flex; + align-items: center; + gap: 14px; +} + +.detail-title-row h2 { + margin: 8px 0 0; + font-size: 2.4rem; +} + +.detail-heading p { + margin: 4px 0 0; + color: #778892; + font-size: 0.72rem; +} + +.detail-total { + color: var(--gold-light); + font-family: var(--font-display); + font-size: 2rem; +} + +.admin-detail-grid { + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(300px, 0.6fr); + align-items: start; + gap: 20px; +} + +.admin-detail-grid > div, +.admin-detail-grid > aside { + display: flex; + min-width: 0; + flex-direction: column; + gap: 20px; +} + +.order-item-list > div { + display: grid; + grid-template-columns: 38px minmax(0, 1fr) auto; + gap: 12px; + padding-block: 13px; + border-bottom: 1px solid var(--line); +} + +.item-quantity { + color: var(--gold); + font-family: var(--font-display); + font-weight: 800; +} + +.order-item-list small { + display: block; + color: #7f8e97; +} + +.admin-totals { + max-width: 300px; + margin: 20px 0 0 auto; +} + +.admin-totals div { + display: flex; + justify-content: space-between; + padding-block: 4px; + color: #9baab3; +} + +.admin-totals div:last-child { + margin-top: 6px; + padding-top: 10px; + color: var(--cream); + border-top: 1px solid var(--line); + font-weight: 800; +} + +.admin-totals dd { + margin: 0; +} + +.timeline > div { + display: grid; + grid-template-columns: 14px 1fr; + gap: 12px; + padding-bottom: 20px; +} + +.timeline i { + position: relative; + display: block; + width: 9px; + height: 9px; + margin-top: 6px; + background: var(--gold); + border-radius: 50%; +} + +.timeline i::after { + position: absolute; + top: 12px; + left: 4px; + width: 1px; + height: 60px; + background: var(--line); + content: ""; +} + +.timeline > div:last-child i::after { + display: none; +} + +.timeline p { + margin: 2px 0; + color: #97a5ae; + font-size: 0.74rem; +} + +.timeline small { + color: #667984; + font-size: 0.62rem; +} + +.customer-card { + display: flex; + flex-direction: column; + gap: 7px; + color: #9dacb5; + font-size: 0.78rem; +} + +.customer-card strong { + color: var(--cream); + font-size: 0.95rem; +} + +.customer-card a { + color: var(--gold-light); +} + +.refund-list { + margin-top: 18px; + border-top: 1px solid var(--line); +} + +.refund-list > div { + display: grid; + grid-template-columns: auto 1fr; + gap: 8px; + padding-top: 12px; + color: #9ba9b2; + font-size: 0.68rem; +} + +.refund-list strong { + color: #df9090; +} + +.refund-list small { + grid-column: 2; + color: #6d7e88; +} + +.admin-stack { + display: flex; + flex-direction: column; + gap: 20px; +} + +.editable-list details, +.new-record { + border-top: 1px solid var(--line); +} + +.editable-list summary { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto auto auto; + align-items: center; + gap: 15px; + padding: 14px 4px; + cursor: pointer; + list-style: none; +} + +.editable-list summary::-webkit-details-marker, +.new-record > summary::-webkit-details-marker, +.special-admin-card > summary::-webkit-details-marker { + display: none; +} + +.editable-list summary > span:nth-child(2) { + display: flex; + min-width: 0; + flex-direction: column; +} + +.editable-list summary small { + overflow: hidden; + color: #73848f; + font-size: 0.65rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.drag-index { + display: grid; + width: 30px; + height: 30px; + place-items: center; + color: #7c8c96; + background: #091722; + border: 1px solid var(--line); + border-radius: 50%; + font-size: 0.62rem; +} + +.status-dot { + display: inline-flex; + align-items: center; + gap: 6px; + color: #81909a; + font-size: 0.62rem; + font-weight: 700; + text-transform: uppercase; +} + +.status-dot::before { + width: 7px; + height: 7px; + background: #6a7379; + border-radius: 50%; + content: ""; +} + +.status-dot.is-on::before { + background: #48b384; + box-shadow: 0 0 0 3px rgba(72, 179, 132, 0.12); +} + +.edit-label { + color: var(--gold); + font-size: 0.65rem; + font-weight: 700; + text-transform: uppercase; +} + +.item-price { + color: var(--gold-light); + font-size: 0.78rem; +} + +.editable-list .item-avatar { + width: 38px; + height: 38px; + font-size: 0.78rem; +} + +.inline-editor { + padding: 20px; + margin-bottom: 12px; + background: #0a1722; + border: 1px solid #243845; + border-radius: var(--radius); +} + +.check-row { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.new-record > summary { + padding: 16px 4px 4px; + color: var(--gold); + cursor: pointer; + font-family: var(--font-display); + font-size: 0.82rem; + font-weight: 800; + letter-spacing: 0.06em; + list-style: none; + text-transform: uppercase; +} + +.new-record[open] > summary { + margin-bottom: 14px; +} + +.special-admin-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; +} + +.special-admin-card { + padding: 20px; + background: #091721; + border: 1px solid #253846; + border-radius: var(--radius); +} + +.special-admin-card > summary { + cursor: pointer; + list-style: none; +} + +.special-admin-card h3 { + margin: 24px 0 8px; + font-size: 1.4rem; + text-transform: uppercase; +} + +.special-admin-card p { + min-height: 65px; + color: #8999a2; + font-size: 0.72rem; +} + +.special-admin-card summary > div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.special-admin-card summary > div > strong { + color: var(--gold-light); +} + +.special-admin-card .inline-editor { + padding: 20px 0 0; + margin: 20px 0 0; + background: transparent; + border-width: 1px 0 0; + border-radius: 0; +} + +.settings-row { + display: grid; + grid-template-columns: 1fr 250px; + align-items: end; + gap: 20px; +} + +.sticky-save { + position: sticky; + z-index: 10; + bottom: 15px; + display: flex; + justify-content: flex-end; + padding: 14px; + background: rgba(8, 18, 28, 0.9); + border: 1px solid var(--line); + border-radius: var(--radius-large); + backdrop-filter: blur(8px); +} + +.settings-followup { + margin-top: 20px; +} + +.newsletter-grid { + grid-template-columns: minmax(0, 0.85fr) minmax(340px, 1.15fr); +} + +.newsletter-history article { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + padding-block: 14px; + border-bottom: 1px solid var(--line); +} + +.newsletter-history article > div { + display: flex; + min-width: 0; + flex-direction: column; +} + +.newsletter-history article span, +.newsletter-history article small { + color: #74858f; + font-size: 0.63rem; +} + +.newsletter-history article small { + grid-column: 1 / -1; +} + +code { + padding: 2px 5px; + color: var(--gold-light); + background: #06101a; + border-radius: 3px; +} + +@media (max-width: 1080px) { + .menu-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .hero-grid { + grid-template-columns: 1.1fr 0.9fr; + } + + .hero-plate { + transform: scale(0.85); + } + + .category-links { + grid-template-columns: repeat(3, 1fr); + } + + .footer-grid { + grid-template-columns: 1.4fr 1fr 1fr; + } + + .footer-grid > div:last-child { + grid-column: 2; + } + + .stat-grid { + grid-template-columns: repeat(2, 1fr); + } + + .admin-dashboard-grid { + grid-template-columns: 1fr; + } + + .admin-panel-wide { + grid-row: auto; + } + + .special-admin-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 840px) { + .announcement-detail { + display: none; + } + + .nav-toggle { + display: block; + } + + .site-nav { + position: absolute; + top: 100%; + right: 0; + left: 0; + display: none; + flex-direction: column; + align-items: stretch; + gap: 0; + padding: 10px 20px 20px; + background: var(--ink); + border-top: 1px solid var(--line); + box-shadow: 0 18px 35px rgba(0, 0, 0, 0.25); + } + + .site-nav.is-open { + display: flex; + } + + .site-nav > a { + padding: 14px 5px; + border-bottom: 1px solid var(--line); + } + + .site-nav > a::after { + display: none; + } + + .cart-link { + margin-top: 10px; + padding: 11px 14px !important; + } + + .hero-grid { + min-height: auto; + grid-template-columns: 1fr; + } + + .hero-copy { + padding: 80px 0 40px; + } + + .hero-plate { + min-height: 400px; + margin-top: -50px; + } + + .plate-ring { + width: min(470px, 78vw); + } + + .page-hero-inner, + .story-grid, + .auth-grid, + .auth-grid-wide, + .account-grid, + .checkout-layout, + .admin-detail-grid, + .newsletter-grid { + grid-template-columns: 1fr; + } + + .page-hero-inner { + align-items: start; + gap: 30px; + } + + .menu-search { + max-width: 550px; + } + + .story-grid { + gap: 40px; + } + + .story-art { + min-height: 320px; + } + + .checkout-layout { + gap: 25px; + } + + .order-summary { + position: static; + } + + .auth-grid { + gap: 45px; + } + + .admin-shell { + grid-template-columns: 1fr; + } + + .admin-sidebar { + position: static; + width: 100%; + height: auto; + padding: 14px 20px; + } + + .admin-brand { + min-height: auto; + padding: 0 0 14px; + } + + .admin-nav { + flex-direction: row; + gap: 5px; + padding: 12px 0 0; + overflow-x: auto; + } + + .admin-nav a { + flex: 0 0 auto; + padding: 8px 10px; + } + + .admin-user { + display: none; + } + + .admin-topbar { + padding-inline: 20px; + } + + .admin-main { + padding: 22px 20px 50px; + } + + .special-admin-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 620px) { + .container { + width: min(100% - 28px, 1180px); + } + + .section { + padding-block: 60px; + } + + .brand-copy strong { + font-size: 1.1rem; + } + + .brand-mark { + width: 42px; + height: 42px; + } + + .hero { + min-height: 0; + } + + .hero-copy { + padding-top: 65px; + } + + .hero-actions, + .hero-meta, + .section-heading, + .cart-actions, + .admin-topbar, + .admin-toolbar, + .admin-page-actions, + .detail-heading { + align-items: stretch; + flex-direction: column; + } + + .hero-meta { + gap: 10px; + } + + .hero-plate { + min-height: 330px; + } + + .burger { + height: 220px; + } + + .hero-stamp { + width: 90px; + height: 90px; + } + + .menu-grid, + .category-links, + .footer-grid, + .form-grid, + .form-grid-three, + .confirmation-details, + .stat-grid, + .settings-row { + grid-template-columns: 1fr; + } + + .category-links a { + min-height: 85px; + } + + .footer-grid { + gap: 28px; + } + + .footer-grid > div:last-child { + grid-column: auto; + } + + .footer-bottom { + flex-direction: column; + gap: 5px; + } + + .menu-category-heading { + grid-template-columns: 1fr; + gap: 12px; + } + + .page-hero { + padding-block: 50px; + } + + .category-jump .container { + gap: 22px; + } + + .cart-item { + grid-template-columns: 48px 1fr auto; + gap: 12px; + } + + .cart-item-mark { + width: 48px; + height: 48px; + } + + .quantity-field { + grid-column: 2; + } + + .cart-line-total { + grid-column: 3; + grid-row: 1; + } + + .remove-cart-item { + grid-column: 3; + grid-row: 2; + } + + .confirmation-details div { + min-height: 80px; + border-right: 0; + border-bottom: 1px solid var(--line); + } + + .confirmation-details div:last-child { + border-bottom: 0; + } + + .confirmation-actions { + flex-direction: column; + } + + .auth-card, + .panel, + .admin-panel { + padding: 22px; + } + + .auth-grid, + .auth-grid-wide { + gap: 35px; + } + + .auth-intro h1 { + font-size: 3.2rem; + } + + .field-wide { + grid-column: auto; + } + + .order-history article { + grid-template-columns: 1fr auto; + } + + .order-history article > strong { + grid-column: 2; + } + + .admin-top-actions .button-ghost { + display: none; + } + + .admin-topbar { + flex-direction: row; + align-items: center; + } + + .table-wrap { + margin: 0 -22px -22px; + } + + .editable-list summary { + grid-template-columns: auto minmax(0, 1fr) auto; + } + + .editable-list summary .status-dot, + .editable-list summary .edit-label { + display: none; + } + + .inline-editor { + padding: 15px; + } + + .admin-filters { + flex-direction: column; + } + + .admin-filters input, + .admin-filters select { + max-width: none; + } +} diff --git a/public/assets/app.js b/public/assets/app.js new file mode 100644 index 0000000..3c12468 --- /dev/null +++ b/public/assets/app.js @@ -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(); +})(); + diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..e008be7 --- /dev/null +++ b/public/index.php @@ -0,0 +1,82 @@ +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); + diff --git a/public/router.php b/public/router.php new file mode 100644 index 0000000..2065f2c --- /dev/null +++ b/public/router.php @@ -0,0 +1,10 @@ + +
+ Good to see you +

My Account

+
+ + +
+
+
+
+ Your details +

Pickup Profile

+
+ +
+
+ +
+ + + + + + +
+ + + +
+
+
+
+
+ Past pickups +

Order History

+
+ Order Again +
+ +
+

Your first online order will appear here.

+
+ +
+ +
+
+ + +
+ + +
+ +
+ +
+
diff --git a/views/admin/dashboard.php b/views/admin/dashboard.php new file mode 100644 index 0000000..b3a5d78 --- /dev/null +++ b/views/admin/dashboard.php @@ -0,0 +1,98 @@ +
+
+ Today’s sales + + paid orders +
+
+ Open orders + + Paid through ready +
+
+ Abandoned carts + + Inactive for 30+ minutes +
+
+ Today’s visitors + + news subscribers +
+
+ + +
+ Security reminder: the initial administrator password is still active. + Change it from My Account before deployment. +
+ + +
+
+
+
+ Live queue +

Recent Orders

+
+ View All Orders +
+
+ + + + + + + + + + + + + + + + +
OrderCustomerPlacedStatusTotal
No orders yet. The kitchen is standing by.
+
+
+
+
+
+ Last 7 days +

Store Traffic

+
+
+ (int) $row['visitors'], $traffic)); ?> +
+

Traffic data will appear as visitors arrive.

+ +
+ + + format('D')) ?> +
+ +
+
+
+
+
+ What’s moving +

Popular Items

+
+
+
+

Item sales will appear after the first order.

+ $item): ?> +
+ + + sold + +
+ +
+
+
diff --git a/views/admin/menu.php b/views/admin/menu.php new file mode 100644 index 0000000..33ad8f6 --- /dev/null +++ b/views/admin/menu.php @@ -0,0 +1,125 @@ +
+

Categories and items appear on the storefront as soon as they are active.

+ Download Styled PDF +
+ +
+
+
+
Organize the storefront

Menu Categories

+ categories +
+
+ +
+ + + + + Edit + +
+ + +
+ + + + + +
+ +
+
+ +
+
+ + Add Category +
+ +
+ + + + + +
+ +
+
+
+ +
+
+
Price and availability

Menu Items

+ items +
+
+ + +
+ + + + + + Edit + +
+ + +
+ + + + + + + + + +
+
+ + +
+ +
+
+ +
+
+ + Add Menu Item +
+ +
+ + + + + + + + + +
+
+ + +
+ +
+
+
+
+ diff --git a/views/admin/newsletters.php b/views/admin/newsletters.php new file mode 100644 index 0000000..7c50b31 --- /dev/null +++ b/views/admin/newsletters.php @@ -0,0 +1,29 @@ +
+

active users are currently opted in.

+
+ +
+
+
Send an update

New Newsletter

+
+ + + +
Every email includes an unsubscribe link. Users can also opt out from My Account.
+ +
+
+
+
Delivery history

Past Sends

+ +
+
diff --git a/views/admin/order-detail.php b/views/admin/order-detail.php new file mode 100644 index 0000000..ea8d18e --- /dev/null +++ b/views/admin/order-detail.php @@ -0,0 +1,101 @@ +
+
+ ← Back to orders +
+

+ +
+

Placed

+
+ +
+ +
+
+
+

Order Items

+
+ +
+ x + + +
+ +
+
+
Subtotal
+
Tax
+
Total
+
+ +
Customer note:
+ +
+
+

Activity

+
+ +
+ + + +

+ · +
+
+ +
+
+
+ +
diff --git a/views/admin/orders.php b/views/admin/orders.php new file mode 100644 index 0000000..bd85272 --- /dev/null +++ b/views/admin/orders.php @@ -0,0 +1,39 @@ +
+
+
+ + + +
+ Export CSV +
+
+ + + + + + + + + + + + + + + + + + +
OrderCustomerPlacedFulfillmentPaymentTotal
No orders match those filters.
+ + 0): ?> returned + Open
+
+
diff --git a/views/admin/settings.php b/views/admin/settings.php new file mode 100644 index 0000000..67103ee --- /dev/null +++ b/views/admin/settings.php @@ -0,0 +1,116 @@ +
+ +
+
Public identity

Business Information

+
+ + + + + + + + + + +
+
+
+
Kitchen controls

Online Ordering

+
+ + +
+
+
+
Card processing

Square

get('square.enabled') ? 'Enabled' : 'Demo mode' ?>
+
When disabled, checkout runs in demo mode and never charges a card. Enable only after entering matching Square application, location, and access credentials.
+
+ + + + +
+ +
+
+
Transactional email

Mailgun

get('mailgun.enabled') ? 'Enabled' : 'Local log mode' ?>
+
When disabled, verification and newsletter messages are written to storage/logs/mail.log for local testing.
+
+ + + + +
+ +
+
+
Optional database server

MySQL / MariaDB Connection

+
These values are only used by the migration tool below. The application continues using SQLite unless a migration succeeds and you explicitly switch drivers.
+
+ + + + + +
+
+
+
+ +
+
State and city

Tax Rates

+
+ +
+ + % + jurisdiction + % + + Edit + +
+ +
+ + + + +
+ +
+
+ +
+
+ + Add Tax Rate +
+ +
+ + + + +
+ +
+
+
+ +
+
Optional database mode

MySQL / MariaDB Migration

get('database.driver', 'sqlite'))) ?> active
+

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.

+
+ +
+ + + + +
+

Connection values are saved in the Store Settings form above before running migration.

+ + +
+
diff --git a/views/admin/specials.php b/views/admin/specials.php new file mode 100644 index 0000000..3eb2740 --- /dev/null +++ b/views/admin/specials.php @@ -0,0 +1,63 @@ +
+

All active specials are automatically arranged on the homepage based on how many are running.

+
+ +
+
+
Homepage promotion

Specials

+ configured +
+
+ +
+ + +

+

+
+
+
+ + +
+ + + + + + + + + +
+ +
+
+ +
+
+ + Add Homepage Special +
+ +
+ + + + + + + + + +
+ +
+
+
diff --git a/views/admin/users.php b/views/admin/users.php new file mode 100644 index 0000000..c04c4c0 --- /dev/null +++ b/views/admin/users.php @@ -0,0 +1,65 @@ +
+
+ + +
+
+ +
+
+
Access and roster

Users & Staff

+ shown +
+
+ +
+ + + · + + + Edit + +
+ + +
+ + + +
+
+ + +
+ +
+
+ +
+
+ + Add Staff Member +
+ +
+ + + + + +
+
+ + +
+ +
+
+
+ diff --git a/views/auth/login.php b/views/auth/login.php new file mode 100644 index 0000000..0fa9637 --- /dev/null +++ b/views/auth/login.php @@ -0,0 +1,36 @@ +
+
+
+ Welcome back +

Your usual is waiting.

+

Sign in to check past orders, save your pickup information, and manage restaurant news.

+
+ Staff member? + Your staff account uses the same secure sign-in. +
+
+
+ +
+ Account access +

Sign In

+
+ + + + + +

We will email a six-digit verification code after your password is accepted.

+

New here? Create an account

+
+
+
+ diff --git a/views/auth/register.php b/views/auth/register.php new file mode 100644 index 0000000..f8a8c07 --- /dev/null +++ b/views/auth/register.php @@ -0,0 +1,61 @@ +
+
+
+ Join the table +

Faster pickup starts here.

+

Create an account to keep your contact details, see order history, and hear about new specials.

+
    +
  • Email verification protects your account
  • +
  • Newsletter preferences stay in your control
  • +
  • Your payment details are handled by Square, not stored here
  • +
+
+
+ +
+ Customer account +

Create Account

+
+
+ + + + + + + +
+
+ + +
+ + +
Creating an account opts you into occasional Fat Bottom Grille news. You can opt out from any email or My Account.
+ +

Already have an account? Sign in

+
+
+
diff --git a/views/auth/verify.php b/views/auth/verify.php new file mode 100644 index 0000000..d43968d --- /dev/null +++ b/views/auth/verify.php @@ -0,0 +1,19 @@ +
+
+
+ +
6
+
+ One more step +

Check your email

+

Enter the six-digit code we sent you. It is valid for 10 minutes.

+
+ + + Start over +
+
+
+ diff --git a/views/layout.php b/views/layout.php new file mode 100644 index 0000000..f64bc38 --- /dev/null +++ b/views/layout.php @@ -0,0 +1,134 @@ +get('business.name', 'Fat Bottom Grille'); +$isAdmin = isset($adminPage); +?> + + + + + + + + <?= e($title ?? $businessName) ?> | <?= e($businessName) ?> + + + + + + + + + + + + +
+ +
+
+
+ Staff workspace +

+
+
+ View Store +
+ + +
+
+
+ +
+ +
+
+
+ +
+ get('business.announcement')) ?> + get('business.hours')) ?> +
+ + +
+ +
+ + + + + + diff --git a/views/partials/admin-nav.php b/views/partials/admin-nav.php new file mode 100644 index 0000000..4a76d42 --- /dev/null +++ b/views/partials/admin-nav.php @@ -0,0 +1,28 @@ + + diff --git a/views/partials/flash.php b/views/partials/flash.php new file mode 100644 index 0000000..41e4347 --- /dev/null +++ b/views/partials/flash.php @@ -0,0 +1,11 @@ + +
+ +
+ + +
+ +
+ + diff --git a/views/partials/menu-card.php b/views/partials/menu-card.php new file mode 100644 index 0000000..6fa0428 --- /dev/null +++ b/views/partials/menu-card.php @@ -0,0 +1,40 @@ + + + diff --git a/views/store/cart.php b/views/store/cart.php new file mode 100644 index 0000000..4f7894d --- /dev/null +++ b/views/store/cart.php @@ -0,0 +1,58 @@ +
+
+ Almost yours +

Your Order

+
+
+ +
+
+ +
+

Your order is wide open.

+

Start with a burger, a burrito, or whatever sounds good right now.

+ Browse the Menu +
+ +
+ +
+ +
+
+
+

+

Note:

+ each +
+ + + +
+ +
+
+ Add More Items + +
+
+ +
+ + + +
+ diff --git a/views/store/checkout.php b/views/store/checkout.php new file mode 100644 index 0000000..090d72a --- /dev/null +++ b/views/store/checkout.php @@ -0,0 +1,98 @@ +
+
+ Secure checkout +

Finish Your Order

+
+
+ +
+
+
+ + +
+
+ 1 +
+

Who is picking up?

+

We will use this to confirm your order.

+
+
+
+ + + + +
+ +
+
+
+ 2 +
+

Payment

+

+
+
+ + +
+ + +
Square is enabled, but the application or location ID is missing. Ask an administrator to finish the payment settings.
+ + +
+ D +
+ Demo payment +

No card will be charged. Orders are recorded as paid for local testing.

+
+
+ +
+ +
+
+ +
+ diff --git a/views/store/home.php b/views/store/home.php new file mode 100644 index 0000000..1ec26f1 --- /dev/null +++ b/views/store/home.php @@ -0,0 +1,122 @@ +
+
+
+
+ Keyser made. Come hungry. +

Big flavor.
No shortcuts.

+

Stacked burgers, loaded burritos, and dinner plates that eat like Sunday supper. Order ahead and we will have it hot.

+
+ Start Your Order + get('business.phone')): ?> + + Call get('business.phone')) ?> + + +
+
+ Pickup in about get('ordering.pickup_eta_minutes', 25) ?> minutes + get('ordering.accepting_orders', true) ? 'Ordering now open' : 'Ordering currently paused' ?> +
+
+ +
+
+ + +
+
+
+
+ Happening now +

Today’s Specials

+
+ See the full menu +
+
+ +
+ +

+

+ +
+ +
+
+
+ + + + +
+
+ Pick your direction + +
+
+ +
+
+ +
+ Proudly from Keyser +

Food that shows up hungry.

+

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.

+ Order for Pickup +
+
+
diff --git a/views/store/menu.php b/views/store/menu.php new file mode 100644 index 0000000..12c386d --- /dev/null +++ b/views/store/menu.php @@ -0,0 +1,52 @@ +
+
+
+ Hot, fresh, yours +

Order Online

+

Choose your favorites and we will get the kitchen moving.

+
+ +
+
+ +
+
+ + + +
+
+ + + diff --git a/views/store/order-complete.php b/views/store/order-complete.php new file mode 100644 index 0000000..17da310 --- /dev/null +++ b/views/store/order-complete.php @@ -0,0 +1,23 @@ +
+
+
+ Paid and sent to the kitchen +

We’ve got it, .

+

Your order number is . We sent a confirmation to .

+
+
Pickupget('ordering.pickup_eta_minutes', 25) . ' minutes') ?>
+
Location410 W Piedmont St
+
Total paid
+
+
+ +
x
+ +
+ +
+
+