- Implemented the full Fat Bottom Grille storefront and staff dashboard.

Highlights include configurable menus, specials, cart/checkout, taxes, 
customer accounts with email 2FA, newsletters, analytics, refunds, 
PDF/CSV exports, Square/Mailgun adapters, and optional MySQL migration.

See README.md for setup and production configuration. Verified 
PHP/JavaScript syntax, responsive layouts, registration, 2FA, checkout, 
dashboard workflows, settings persistence, and PDF generation.
Real Square, Mailgun, and MySQL connections require credentials/server 
access. Square implementation follows the official Payments API and 
Refunds API.
This commit is contained in:
Ty Clifford
2026-06-10 13:27:42 -04:00
parent d46b0be488
commit 16235369cb
49 changed files with 7685 additions and 2 deletions
+402
View File
@@ -0,0 +1,402 @@
<?php
declare(strict_types=1);
namespace FatBottom\Core;
use PDO;
use RuntimeException;
final class Database
{
private PDO $pdo;
private bool $sqlite;
public function __construct(private readonly Config $config)
{
$driver = (string) $config->get('database.driver', 'sqlite');
$this->sqlite = $driver !== 'mysql';
if ($this->sqlite) {
$directory = BASE_PATH . '/storage/database';
if (!is_dir($directory)) {
mkdir($directory, 0775, true);
}
$this->pdo = new PDO('sqlite:' . $directory . '/store.sqlite');
$this->pdo->exec('PRAGMA foreign_keys = ON');
$this->pdo->exec('PRAGMA journal_mode = WAL');
} else {
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4',
$config->get('database.mysql_host'),
(int) $config->get('database.mysql_port', 3306),
$config->get('database.mysql_database')
);
$this->pdo = new PDO(
$dsn,
(string) $config->get('database.mysql_username'),
(string) $config->get('database.mysql_password')
);
$this->pdo->exec("SET time_zone = '+00:00'");
}
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$this->pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$this->pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
}
public function pdo(): PDO
{
return $this->pdo;
}
public function isSqlite(): bool
{
return $this->sqlite;
}
public function migrate(): void
{
if (!$this->sqlite) {
return;
}
$schema = <<<'SQL'
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
full_name TEXT NOT NULL,
phone TEXT NOT NULL DEFAULT '',
street_address TEXT NOT NULL DEFAULT '',
city TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL DEFAULT '',
postal_code TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'customer',
newsletter_opt_in INTEGER NOT NULL DEFAULT 1,
email_verified_at TEXT,
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS login_codes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
code_hash TEXT NOT NULL,
purpose TEXT NOT NULL,
expires_at TEXT NOT NULL,
used_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS menu_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
display_order INTEGER NOT NULL DEFAULT 0,
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS menu_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category_id INTEGER NOT NULL,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
price_cents INTEGER NOT NULL,
compare_at_cents INTEGER,
image_url TEXT NOT NULL DEFAULT '',
dietary_tags TEXT NOT NULL DEFAULT '[]',
featured INTEGER NOT NULL DEFAULT 0,
active INTEGER NOT NULL DEFAULT 1,
display_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (category_id) REFERENCES menu_categories(id)
);
CREATE TABLE IF NOT EXISTS specials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
menu_item_id INTEGER,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
badge TEXT NOT NULL DEFAULT 'Special',
price_cents INTEGER,
starts_at TEXT,
ends_at TEXT,
active INTEGER NOT NULL DEFAULT 1,
display_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (menu_item_id) REFERENCES menu_items(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS tax_rates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
jurisdiction TEXT NOT NULL,
rate_basis_points INTEGER NOT NULL,
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS carts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT NOT NULL UNIQUE,
user_id INTEGER,
status TEXT NOT NULL DEFAULT 'active',
subtotal_cents INTEGER NOT NULL DEFAULT 0,
last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
converted_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS cart_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cart_id INTEGER NOT NULL,
menu_item_id INTEGER NOT NULL,
quantity INTEGER NOT NULL DEFAULT 1,
unit_price_cents INTEGER NOT NULL,
notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(cart_id, menu_item_id, notes),
FOREIGN KEY (cart_id) REFERENCES carts(id) ON DELETE CASCADE,
FOREIGN KEY (menu_item_id) REFERENCES menu_items(id)
);
CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_number TEXT NOT NULL UNIQUE,
user_id INTEGER,
cart_id INTEGER,
customer_name TEXT NOT NULL,
customer_email TEXT NOT NULL,
customer_phone TEXT NOT NULL,
order_type TEXT NOT NULL DEFAULT 'pickup',
status TEXT NOT NULL DEFAULT 'paid',
payment_status TEXT NOT NULL DEFAULT 'paid',
payment_provider TEXT NOT NULL DEFAULT 'demo',
payment_reference TEXT NOT NULL DEFAULT '',
subtotal_cents INTEGER NOT NULL,
tax_cents INTEGER NOT NULL,
total_cents INTEGER NOT NULL,
special_instructions TEXT NOT NULL DEFAULT '',
pickup_time TEXT,
placed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (cart_id) REFERENCES carts(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS order_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL,
menu_item_id INTEGER,
item_name TEXT NOT NULL,
quantity INTEGER NOT NULL,
unit_price_cents INTEGER NOT NULL,
line_total_cents INTEGER NOT NULL,
notes TEXT NOT NULL DEFAULT '',
FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE,
FOREIGN KEY (menu_item_id) REFERENCES menu_items(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS order_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL,
user_id INTEGER,
event_type TEXT NOT NULL,
details TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS refunds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL,
staff_user_id INTEGER,
amount_cents INTEGER NOT NULL,
reason TEXT NOT NULL DEFAULT '',
provider_reference TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (order_id) REFERENCES orders(id),
FOREIGN KEY (staff_user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS visitor_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
user_id INTEGER,
route TEXT NOT NULL,
referrer TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT '',
ip_hash TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS newsletters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
subject TEXT NOT NULL,
content_html TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft',
recipient_count INTEGER NOT NULL DEFAULT 0,
created_by INTEGER,
sent_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_menu_items_category ON menu_items(category_id, active);
CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status, placed_at);
CREATE INDEX IF NOT EXISTS idx_carts_status ON carts(status, last_seen_at);
CREATE INDEX IF NOT EXISTS idx_visitors_created ON visitor_events(created_at);
CREATE INDEX IF NOT EXISTS idx_users_newsletter ON users(newsletter_opt_in, active);
SQL;
$this->pdo->exec($schema);
$this->seed();
}
private function seed(): void
{
$categoryCount = (int) $this->pdo->query('SELECT COUNT(*) FROM menu_categories')->fetchColumn();
if ($categoryCount > 0) {
return;
}
$this->pdo->beginTransaction();
try {
$categories = [
['Smash Burgers', 'smash-burgers', 'Hand-pressed beef, crisp edges, and the good stuff.', 10],
['Burritos & Bowls', 'burritos-bowls', 'Big, packed, and built your way.', 20],
['Dinner Plates', 'dinner-plates', 'Comfort-food plates made for a proper supper.', 30],
['Sides', 'sides', 'The supporting cast that tends to steal the show.', 40],
['Drinks', 'drinks', 'Cold drinks for hot food.', 50],
];
$categoryStatement = $this->pdo->prepare(
'INSERT INTO menu_categories (name, slug, description, display_order) VALUES (?, ?, ?, ?)'
);
foreach ($categories as $category) {
$categoryStatement->execute($category);
}
$categoryIds = [];
foreach ($this->pdo->query('SELECT id, slug FROM menu_categories')->fetchAll() as $category) {
$categoryIds[$category['slug']] = (int) $category['id'];
}
$items = [
['smash-burgers', 'The Fat Bottom', 'the-fat-bottom', 'Two smashed patties, American cheese, grilled onion, pickles, and house sauce.', 1199, 1, '["House favorite"]'],
['smash-burgers', 'Piedmont Bacon Burger', 'piedmont-bacon-burger', 'Two patties, smoked bacon, cheddar, lettuce, tomato, onion, and burger sauce.', 1349, 1, '[]'],
['smash-burgers', 'Allegheny Melt', 'allegheny-melt', 'Smashed beef, Swiss, caramelized onions, and peppercorn mayo on toasted sourdough.', 1299, 0, '[]'],
['burritos-bowls', 'Mountaineer Burrito', 'mountaineer-burrito', 'Seasoned beef, rice, black beans, queso, pico, lettuce, and crema.', 1249, 1, '["Big appetite"]'],
['burritos-bowls', 'Chicken Fajita Bowl', 'chicken-fajita-bowl', 'Grilled chicken, peppers, onions, rice, corn salsa, avocado crema.', 1199, 0, '["Gluten conscious"]'],
['dinner-plates', 'Country Fried Chicken', 'country-fried-chicken', 'Crispy chicken, peppered gravy, mashed potatoes, and green beans.', 1499, 1, '[]'],
['dinner-plates', 'Keyser Meatloaf', 'keyser-meatloaf', 'House meatloaf with brown gravy, mashed potatoes, and seasonal vegetables.', 1449, 0, '[]'],
['sides', 'Loaded Fries', 'loaded-fries', 'Crispy fries, queso, bacon, scallions, and ranch drizzle.', 699, 1, '[]'],
['sides', 'Mac & Cheese', 'mac-cheese', 'Creamy, baked, and unapologetically cheesy.', 449, 0, '["Vegetarian"]'],
['drinks', 'Sweet Tea', 'sweet-tea', 'Fresh-brewed southern sweet tea.', 299, 0, '[]'],
['drinks', 'Fountain Drink', 'fountain-drink', 'Your choice of fountain soda.', 299, 0, '[]'],
];
$itemStatement = $this->pdo->prepare(
'INSERT INTO menu_items
(category_id, name, slug, description, price_cents, featured, dietary_tags)
VALUES (?, ?, ?, ?, ?, ?, ?)'
);
foreach ($items as $item) {
$itemStatement->execute([
$categoryIds[$item[0]],
$item[1],
$item[2],
$item[3],
$item[4],
$item[5],
$item[6],
]);
}
$fatBottomId = (int) $this->pdo->query(
"SELECT id FROM menu_items WHERE slug = 'the-fat-bottom'"
)->fetchColumn();
$loadedFriesId = (int) $this->pdo->query(
"SELECT id FROM menu_items WHERE slug = 'loaded-fries'"
)->fetchColumn();
$specialStatement = $this->pdo->prepare(
'INSERT INTO specials (menu_item_id, title, description, badge, price_cents, display_order)
VALUES (?, ?, ?, ?, ?, ?)'
);
$specialStatement->execute([
$fatBottomId,
'The Fat Bottom Deal',
'Our signature burger at a special online price.',
'Online Special',
1099,
10,
]);
$specialStatement->execute([
$loadedFriesId,
'Loaded-Up Lunch',
'Loaded fries are $1 off weekdays from 11 to 2.',
'Weekday Deal',
599,
20,
]);
$taxStatement = $this->pdo->prepare(
'INSERT INTO tax_rates (name, jurisdiction, rate_basis_points) VALUES (?, ?, ?)'
);
$taxStatement->execute(['West Virginia Sales Tax', 'state', 600]);
$taxStatement->execute(['Keyser Municipal Tax', 'city', 100]);
$adminStatement = $this->pdo->prepare(
'INSERT INTO users
(email, password_hash, full_name, phone, role, newsletter_opt_in, email_verified_at)
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)'
);
$adminStatement->execute([
'admin@fatbottomgrille.com',
password_hash('ChangeMe123!', PASSWORD_DEFAULT),
'Store Administrator',
'',
'admin',
0,
]);
$this->pdo->commit();
} catch (\Throwable $error) {
$this->pdo->rollBack();
throw new RuntimeException('Unable to seed the store database.', 0, $error);
}
}
public function fetchAll(string $sql, array $parameters = []): array
{
$statement = $this->pdo->prepare($sql);
$statement->execute($parameters);
return $statement->fetchAll();
}
public function fetch(string $sql, array $parameters = []): ?array
{
$statement = $this->pdo->prepare($sql);
$statement->execute($parameters);
$row = $statement->fetch();
return $row === false ? null : $row;
}
public function execute(string $sql, array $parameters = []): bool
{
$statement = $this->pdo->prepare($sql);
return $statement->execute($parameters);
}
}