diff --git a/.htaccess b/.htaccess
new file mode 100644
index 0000000..47158ea
--- /dev/null
+++ b/.htaccess
@@ -0,0 +1,12 @@
+RewriteEngine On
+
+# When public/ is the DocumentRoot, allow its own .htaccess to handle the request.
+RewriteRule ^public/ - [L]
+
+# Never expose application internals when the repository root is public.
+RewriteRule ^(?:app|config|storage|views)(?:/|$) - [F,END,NC]
+
+# Serve public assets directly, then send every application URL to PHP.
+RewriteRule ^assets/(.*)$ public/assets/$1 [END,NC]
+RewriteRule ^favicon\.ico$ public/favicon.ico [END,NC]
+RewriteRule ^ public/index.php [QSA,END]
diff --git a/README.md b/README.md
index d43048d..5ca6d40 100644
--- a/README.md
+++ b/README.md
@@ -45,6 +45,29 @@ 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.
+## Apache Routing
+
+The repository includes two rewrite configurations:
+
+- `public/.htaccess` when the Apache `DocumentRoot` points to `public/`
+- `.htaccess` when shared hosting exposes the repository directory itself
+
+Apache must have `mod_rewrite` enabled and must permit `.htaccess` overrides.
+A typical virtual host uses:
+
+```apache
+DocumentRoot /path/to/fatbottomgrille/public
+
+
+ AllowOverride All
+ Require all granted
+
+```
+
+The preferred setup is to point the web root at `public/`. If the application
+is installed in a URL subdirectory, generated links, form actions, redirects,
+and assets automatically include that subdirectory.
+
## Initial Administrator
- Email: `admin@fatbottomgrille.com`
diff --git a/app/Core/Http.php b/app/Core/Http.php
index 5679eed..02ff210 100644
--- a/app/Core/Http.php
+++ b/app/Core/Http.php
@@ -8,7 +8,7 @@ final class Http
{
public static function redirect(string $path): never
{
- header('Location: ' . $path);
+ header('Location: ' . self::url($path));
exit;
}
@@ -46,8 +46,60 @@ final class Http
public static function path(): string
{
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
- $path = is_string($path) ? rtrim($path, '/') : '/';
+ $path = is_string($path) ? rawurldecode($path) : '/';
+ $basePath = self::basePath();
+
+ if ($basePath !== '' && ($path === $basePath || str_starts_with($path, $basePath . '/'))) {
+ $path = substr($path, strlen($basePath)) ?: '/';
+ }
+
+ $path = '/' . ltrim($path, '/');
+ $path = rtrim($path, '/');
return $path === '' ? '/' : $path;
}
-}
+ public static function url(string $path = '/'): string
+ {
+ if (
+ preg_match('#^(?:[a-z][a-z0-9+.-]*:)?//#i', $path)
+ || str_starts_with($path, 'mailto:')
+ || str_starts_with($path, 'tel:')
+ || str_starts_with($path, '#')
+ ) {
+ return $path;
+ }
+
+ $basePath = self::basePath();
+ $path = '/' . ltrim($path, '/');
+ return ($basePath === '' ? '' : $basePath) . ($path === '/' ? '/' : $path);
+ }
+
+ public static function basePath(): string
+ {
+ static $basePath;
+ if (isset($basePath)) {
+ return $basePath;
+ }
+
+ $documentRoot = realpath((string) ($_SERVER['DOCUMENT_ROOT'] ?? ''));
+ $applicationRoot = realpath(BASE_PATH);
+ $publicRoot = realpath(BASE_PATH . '/public');
+
+ if ($documentRoot && $publicRoot && $documentRoot === $publicRoot) {
+ return $basePath = '';
+ }
+
+ if (
+ $documentRoot
+ && $applicationRoot
+ && str_starts_with($applicationRoot . DIRECTORY_SEPARATOR, rtrim($documentRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR)
+ ) {
+ $relative = substr($applicationRoot, strlen(rtrim($documentRoot, DIRECTORY_SEPARATOR)));
+ return $basePath = rtrim('/' . trim(str_replace(DIRECTORY_SEPARATOR, '/', $relative), '/'), '/');
+ }
+
+ $script = str_replace('\\', '/', (string) ($_SERVER['SCRIPT_NAME'] ?? ''));
+ $script = preg_replace('#/(?:public/)?index\.php$#', '', $script) ?? '';
+ return $basePath = rtrim($script, '/');
+ }
+}
diff --git a/app/helpers.php b/app/helpers.php
index a0dc3e9..dbec569 100644
--- a/app/helpers.php
+++ b/app/helpers.php
@@ -21,10 +21,20 @@ function csrf_field(): string
function active_path(string $path): string
{
- $current = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
+ $current = \FatBottom\Core\Http::path();
return $current === $path ? 'is-active' : '';
}
+function url(string $path = '/'): string
+{
+ return \FatBottom\Core\Http::url($path);
+}
+
+function asset(string $path): string
+{
+ return \FatBottom\Core\Http::url('/' . ltrim($path, '/'));
+}
+
function selected(mixed $value, mixed $expected): string
{
return (string) $value === (string) $expected ? 'selected' : '';
diff --git a/public/.htaccess b/public/.htaccess
new file mode 100644
index 0000000..b8bbe97
--- /dev/null
+++ b/public/.htaccess
@@ -0,0 +1,8 @@
+RewriteEngine On
+
+# Existing public files are served normally; all other URLs use the app router.
+RewriteCond %{REQUEST_FILENAME} -f [OR]
+RewriteCond %{REQUEST_FILENAME} -d
+RewriteRule ^ - [END]
+
+RewriteRule ^ index.php [QSA,END]
diff --git a/public/assets/app.css b/public/assets/app.css
index aaa3a3b..4b7a3e0 100644
--- a/public/assets/app.css
+++ b/public/assets/app.css
@@ -31,8 +31,8 @@ html {
body {
margin: 0;
- color: var(--ink);
- background: var(--paper);
+ color: var(--cream);
+ background: var(--ink);
font-family: var(--font-body);
font-size: 16px;
line-height: 1.6;
@@ -41,8 +41,8 @@ body {
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);
+ radial-gradient(circle at 0 0, rgba(200, 151, 59, 0.08), transparent 28%),
+ linear-gradient(rgba(255, 255, 255, 0.018) 1px, transparent 1px);
background-size: auto, 100% 22px;
}
@@ -297,6 +297,14 @@ h3 {
font-size: 1rem;
font-weight: 800;
letter-spacing: 0.02em;
+ overflow: hidden;
+}
+
+.brand-mark img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ transform: scale(1.05);
}
.brand-copy,
@@ -401,7 +409,8 @@ h3 {
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%);
+ linear-gradient(90deg, rgba(5, 12, 18, 0.99) 0%, rgba(8, 20, 30, 0.96) 48%, rgba(8, 18, 28, 0.76) 100%),
+ url("images/reuben-fries.jpg") center 48% / cover no-repeat;
}
.hero::after {
@@ -495,18 +504,61 @@ h3 {
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%;
+.hero-food-photo {
+ position: relative;
+ width: min(460px, 39vw);
+ aspect-ratio: 0.88;
+ overflow: hidden;
+ border: 1px solid rgba(225, 183, 94, 0.42);
+ border-radius: 44% 44% 8px 8px;
+ box-shadow: 0 45px 90px rgba(0, 0, 0, 0.52);
+ transform: rotate(2deg);
+}
+
+.hero-food-photo::after {
+ position: absolute;
+ inset: 0;
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);
+ linear-gradient(180deg, rgba(5, 12, 18, 0.06), rgba(5, 12, 18, 0.22) 54%, rgba(5, 12, 18, 0.94)),
+ linear-gradient(90deg, rgba(200, 151, 59, 0.08), transparent);
+ content: "";
+}
+
+.hero-food-photo img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ object-position: 48% center;
+ filter: saturate(0.82) contrast(1.06) brightness(0.72);
+}
+
+.hero-photo-caption {
+ position: absolute;
+ z-index: 2;
+ right: 28px;
+ bottom: 25px;
+ left: 28px;
+ display: flex;
+ flex-direction: column;
+ padding-top: 18px;
+ border-top: 1px solid rgba(225, 183, 94, 0.48);
+}
+
+.hero-photo-caption span {
+ color: var(--gold-light);
+ font-size: 0.62rem;
+ font-weight: 800;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+}
+
+.hero-photo-caption strong {
+ margin-top: 4px;
+ color: var(--cream);
+ font-family: var(--font-display);
+ font-size: 1.35rem;
+ letter-spacing: 0.02em;
+ text-transform: uppercase;
}
.burger {
@@ -646,13 +698,37 @@ h3 {
padding: 30px;
overflow: hidden;
color: var(--cream);
- background:
- linear-gradient(145deg, rgba(255, 255, 255, 0.07), transparent 58%),
- var(--ink-soft);
+ background-color: var(--ink-soft);
+ background-position: center;
+ background-size: cover;
border: 1px solid rgba(200, 151, 59, 0.28);
border-radius: var(--radius);
}
+.special-card:nth-child(4n + 1) {
+ background-image:
+ linear-gradient(112deg, rgba(5, 12, 18, 0.98), rgba(8, 18, 28, 0.82)),
+ url("images/quesadilla.jpg");
+}
+
+.special-card:nth-child(4n + 2) {
+ background-image:
+ linear-gradient(112deg, rgba(5, 12, 18, 0.98), rgba(8, 18, 28, 0.8)),
+ url("images/sausage-kale-soup.jpg");
+}
+
+.special-card:nth-child(4n + 3) {
+ background-image:
+ linear-gradient(112deg, rgba(5, 12, 18, 0.98), rgba(8, 18, 28, 0.78)),
+ url("images/grilled-chicken-salad.jpg");
+}
+
+.special-card:nth-child(4n + 4) {
+ background-image:
+ linear-gradient(112deg, rgba(5, 12, 18, 0.98), rgba(8, 18, 28, 0.8)),
+ url("images/curry-rice.jpg");
+}
+
.special-card::after {
position: absolute;
right: -70px;
@@ -707,7 +783,9 @@ h3 {
}
.menu-preview {
- background: var(--paper);
+ background:
+ radial-gradient(circle at 86% 14%, rgba(200, 151, 59, 0.08), transparent 24%),
+ #09141e;
}
.menu-grid {
@@ -719,8 +797,9 @@ h3 {
.menu-card {
min-width: 0;
overflow: hidden;
- background: var(--white);
- border: 1px solid #dedcd4;
+ color: var(--cream);
+ background: #0d1c28;
+ border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: var(--radius-large);
box-shadow: 0 8px 28px rgba(8, 18, 28, 0.06);
transition: transform 160ms ease, box-shadow 160ms ease;
@@ -750,6 +829,53 @@ h3 {
letter-spacing: -0.06em;
}
+.menu-card:nth-child(7n + 1) .menu-card-art:not(.has-image) {
+ background-image:
+ linear-gradient(rgba(5, 12, 18, 0.62), rgba(5, 12, 18, 0.86)),
+ url("images/reuben-fries.jpg");
+}
+
+.menu-card:nth-child(7n + 2) .menu-card-art:not(.has-image) {
+ background-image:
+ linear-gradient(rgba(5, 12, 18, 0.62), rgba(5, 12, 18, 0.86)),
+ url("images/chicken-sandwich-fries.jpg");
+}
+
+.menu-card:nth-child(7n + 3) .menu-card-art:not(.has-image) {
+ background-image:
+ linear-gradient(rgba(5, 12, 18, 0.62), rgba(5, 12, 18, 0.86)),
+ url("images/quesadilla.jpg");
+}
+
+.menu-card:nth-child(7n + 4) .menu-card-art:not(.has-image) {
+ background-image:
+ linear-gradient(rgba(5, 12, 18, 0.62), rgba(5, 12, 18, 0.86)),
+ url("images/curry-rice.jpg");
+}
+
+.menu-card:nth-child(7n + 5) .menu-card-art:not(.has-image) {
+ background-image:
+ linear-gradient(rgba(5, 12, 18, 0.62), rgba(5, 12, 18, 0.86)),
+ url("images/italian-sub.jpg");
+}
+
+.menu-card:nth-child(7n + 6) .menu-card-art:not(.has-image) {
+ background-image:
+ linear-gradient(rgba(5, 12, 18, 0.62), rgba(5, 12, 18, 0.86)),
+ url("images/sausage-kale-soup.jpg");
+}
+
+.menu-card:nth-child(7n) .menu-card-art:not(.has-image) {
+ background-image:
+ linear-gradient(rgba(5, 12, 18, 0.62), rgba(5, 12, 18, 0.86)),
+ url("images/grilled-chicken-salad.jpg");
+}
+
+.menu-card-art:not(.has-image) {
+ background-position: center;
+ background-size: cover;
+}
+
.menu-card-art::after {
position: absolute;
width: 150px;
@@ -798,14 +924,14 @@ h3 {
}
.menu-card-heading > strong {
- color: #8e6422;
+ color: var(--gold-light);
white-space: nowrap;
}
.menu-card-body > p {
min-height: 50px;
margin: 12px 0 16px;
- color: #68737a;
+ color: #aebbc3;
font-size: 0.86rem;
line-height: 1.5;
}
@@ -819,8 +945,8 @@ h3 {
.tag-list span {
padding: 3px 8px;
- color: #5f6b72;
- background: #f0eee8;
+ color: #b9c4ca;
+ background: #152938;
border-radius: 20px;
font-size: 0.65rem;
font-weight: 600;
@@ -836,9 +962,10 @@ h3 {
width: 100%;
height: 38px;
padding-inline: 10px;
- border: 1px solid #cbc9c1;
+ color: var(--cream);
+ border: 1px solid #344857;
border-radius: var(--radius);
- background: var(--white);
+ background: #08141f;
}
.category-band {
@@ -908,7 +1035,9 @@ h3 {
place-items: center;
overflow: hidden;
color: var(--gold);
- background: var(--navy);
+ background:
+ linear-gradient(rgba(5, 12, 18, 0.58), rgba(5, 12, 18, 0.9)),
+ url("images/italian-sub.jpg") center / cover no-repeat;
}
.story-number {
@@ -939,8 +1068,8 @@ h3 {
padding-block: 72px;
color: var(--cream);
background:
- radial-gradient(circle at 80% 0, rgba(200, 151, 59, 0.18), transparent 30%),
- var(--navy);
+ linear-gradient(90deg, rgba(5, 12, 18, 0.97), rgba(8, 18, 28, 0.8)),
+ url("images/italian-sub.jpg") center 55% / cover no-repeat;
}
.page-hero-compact {
@@ -1812,7 +1941,10 @@ textarea:focus {
.site-footer {
padding-top: 68px;
color: #b6c0c6;
- background: #050c12;
+ background:
+ linear-gradient(90deg, rgba(5, 12, 18, 0.99), rgba(5, 12, 18, 0.94)),
+ url("images/fat-bottom-grille-logo.jpg") right -80px center / 520px no-repeat,
+ #050c12;
}
.footer-grid {
@@ -1857,6 +1989,156 @@ textarea:focus {
text-transform: uppercase;
}
+/* Dark storefront surfaces */
+.store-body main {
+ background:
+ radial-gradient(circle at 10% 18%, rgba(200, 151, 59, 0.055), transparent 25%),
+ #09141e;
+}
+
+.store-body .button-outline {
+ color: var(--cream);
+ border-color: #788892;
+}
+
+.store-body .button-outline:hover {
+ color: var(--ink);
+ background: var(--gold);
+ border-color: var(--gold);
+}
+
+.store-body .section-heading > p,
+.store-body .story-grid p,
+.store-body .menu-category-heading p,
+.store-body .empty-state p,
+.store-body .cart-item-copy p,
+.store-body .cart-item-copy span,
+.store-body .form-section-heading p,
+.store-body .demo-payment p,
+.store-body .account-email,
+.store-body .order-history article > div span,
+.store-body .auth-switch,
+.store-body .form-footnote {
+ color: #9eacb5;
+}
+
+.store-body .story-section,
+.store-body .menu-page,
+.store-body .checkout-layout,
+.store-body .account-grid {
+ color: var(--cream);
+}
+
+.store-body .story-section {
+ background:
+ linear-gradient(90deg, rgba(8, 18, 28, 0.99), rgba(8, 18, 28, 0.92)),
+ url("images/sausage-kale-soup.jpg") right center / 46% auto no-repeat;
+}
+
+.store-body .category-band {
+ background:
+ linear-gradient(90deg, rgba(5, 12, 18, 0.98), rgba(5, 12, 18, 0.86)),
+ url("images/curry-rice.jpg") center 56% / cover no-repeat;
+}
+
+.store-body .menu-category-heading {
+ border-bottom-color: rgba(225, 183, 94, 0.34);
+}
+
+.store-body .panel,
+.store-body .cart-list,
+.store-body .empty-state,
+.store-body .auth-card {
+ color: var(--cream);
+ background: #0d1c28;
+ border-color: rgba(255, 255, 255, 0.11);
+ box-shadow: 0 16px 42px rgba(0, 0, 0, 0.24);
+}
+
+.store-body .cart-item,
+.store-body .form-section,
+.store-body .order-history article {
+ border-color: rgba(255, 255, 255, 0.1);
+}
+
+.store-body label {
+ color: #b8c3ca;
+}
+
+.store-body input,
+.store-body select,
+.store-body textarea {
+ color: var(--cream);
+ background: #07131d;
+ border-color: #344653;
+}
+
+.store-body input::placeholder,
+.store-body textarea::placeholder {
+ color: #71818c;
+}
+
+.store-body .demo-payment,
+.store-body .notice,
+.store-body .check-control,
+.store-body .account-password {
+ color: #b8c3ca;
+ background: #0a1722;
+ border-color: #2d404e;
+}
+
+.store-body .check-control small {
+ color: #84939c;
+}
+
+.store-body .notice-error {
+ color: #efb1b1;
+ background: #2a171b;
+ border-color: var(--danger);
+}
+
+.store-body .order-summary {
+ background:
+ linear-gradient(145deg, rgba(8, 18, 28, 0.98), rgba(8, 18, 28, 0.88)),
+ url("images/quesadilla.jpg") center / cover no-repeat;
+ border: 1px solid rgba(200, 151, 59, 0.22);
+}
+
+.store-body .auth-section {
+ background:
+ linear-gradient(90deg, rgba(5, 12, 18, 0.97), rgba(8, 18, 28, 0.87)),
+ url("images/grilled-chicken-salad.jpg") center / cover no-repeat;
+}
+
+.store-body .confirmation-section {
+ background:
+ linear-gradient(rgba(5, 12, 18, 0.94), rgba(5, 12, 18, 0.97)),
+ url("images/sausage-kale-soup.jpg") center / cover no-repeat;
+}
+
+.store-body .status-pill {
+ color: #c2ccd2;
+ background: #182a37;
+}
+
+.store-body .status-paid,
+.store-body .status-completed,
+.store-body .status-ready {
+ color: #9de0c0;
+ background: #17382d;
+}
+
+.store-body .status-preparing {
+ color: #f0ce83;
+ background: #3b3019;
+}
+
+.store-body .status-cancelled,
+.store-body .status-refunded {
+ color: #efaaaa;
+ background: #3a2024;
+}
+
/* Dashboard */
.admin-body {
color: #dbe2e6;
@@ -2769,7 +3051,7 @@ code {
margin-top: -50px;
}
- .plate-ring {
+ .hero-food-photo {
width: min(470px, 78vw);
}
@@ -2904,8 +3186,8 @@ code {
min-height: 330px;
}
- .burger {
- height: 220px;
+ .hero-food-photo {
+ width: min(430px, 86vw);
}
.hero-stamp {
diff --git a/public/assets/images/chicken-sandwich-fries.jpg b/public/assets/images/chicken-sandwich-fries.jpg
new file mode 100644
index 0000000..87a4245
Binary files /dev/null and b/public/assets/images/chicken-sandwich-fries.jpg differ
diff --git a/public/assets/images/curry-rice.jpg b/public/assets/images/curry-rice.jpg
new file mode 100644
index 0000000..886bcdf
Binary files /dev/null and b/public/assets/images/curry-rice.jpg differ
diff --git a/public/assets/images/fat-bottom-grille-logo.jpg b/public/assets/images/fat-bottom-grille-logo.jpg
new file mode 100644
index 0000000..16c770c
Binary files /dev/null and b/public/assets/images/fat-bottom-grille-logo.jpg differ
diff --git a/public/assets/images/grilled-chicken-salad.jpg b/public/assets/images/grilled-chicken-salad.jpg
new file mode 100644
index 0000000..2ffc299
Binary files /dev/null and b/public/assets/images/grilled-chicken-salad.jpg differ
diff --git a/public/assets/images/italian-sub.jpg b/public/assets/images/italian-sub.jpg
new file mode 100644
index 0000000..b93830b
Binary files /dev/null and b/public/assets/images/italian-sub.jpg differ
diff --git a/public/assets/images/quesadilla.jpg b/public/assets/images/quesadilla.jpg
new file mode 100644
index 0000000..5f40bb5
Binary files /dev/null and b/public/assets/images/quesadilla.jpg differ
diff --git a/public/assets/images/reuben-fries.jpg b/public/assets/images/reuben-fries.jpg
new file mode 100644
index 0000000..6f3cef4
Binary files /dev/null and b/public/assets/images/reuben-fries.jpg differ
diff --git a/public/assets/images/sausage-kale-soup.jpg b/public/assets/images/sausage-kale-soup.jpg
new file mode 100644
index 0000000..87a8ec9
Binary files /dev/null and b/public/assets/images/sausage-kale-soup.jpg differ
diff --git a/public/router.php b/public/router.php
index 2065f2c..518ae21 100644
--- a/public/router.php
+++ b/public/router.php
@@ -1,10 +1,53 @@
'text/css; charset=UTF-8',
+ 'js' => 'application/javascript; charset=UTF-8',
+ 'json' => 'application/json; charset=UTF-8',
+ 'png' => 'image/png',
+ 'jpg' => 'image/jpeg',
+ 'jpeg' => 'image/jpeg',
+ 'gif' => 'image/gif',
+ 'svg' => 'image/svg+xml',
+ 'webp' => 'image/webp',
+ 'ico' => 'image/x-icon',
+ ];
+ $extension = strtolower(pathinfo($file, PATHINFO_EXTENSION));
+ header('Content-Type: ' . ($contentTypes[$extension] ?? 'application/octet-stream'));
+ header('Content-Length: ' . filesize($file));
+ readfile($file);
+ exit;
}
require __DIR__ . '/index.php';
-
diff --git a/views/account/index.php b/views/account/index.php
index 0b4c646..2fea1fb 100644
--- a/views/account/index.php
+++ b/views/account/index.php
@@ -14,7 +14,7 @@
= e($currentUser['email']) ?>
-