🎉 initial commit

This commit is contained in:
Lukas Harat
2026-08-15 12:50:22 +02:00
commit cd04ebe254
31 changed files with 5423 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
{
"projectName": "steakhouse",
"mode": "file-router",
"typescript": true,
"tailwind": false,
"packageManager": "npm",
"git": false,
"install": true,
"intent": false,
"addOnOptions": {},
"projectPreset": "blank",
"includeExamples": false,
"routerOnly": false,
"version": 1,
"framework": "react",
"chosenAddOns": []
}
+1
View File
@@ -0,0 +1 @@
DATABASE_URL=postgres://steakhouse:steakhouse@localhost:5432/steakhouse
+13
View File
@@ -0,0 +1,13 @@
node_modules
.DS_Store
dist
dist-ssr
*.local
.env
.nitro
.tanstack
.wrangler
.output
.vinxi
__unconfig*
todos.json
+10
View File
@@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" languageLevel="JDK_25" default="true" project-jdk-name="jbr-25" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/steakhouse.iml" filepath="$PROJECT_DIR$/.idea/steakhouse.iml" />
</modules>
</component>
</project>
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
+11
View File
@@ -0,0 +1,11 @@
{
"files.watcherExclude": {
"**/routeTree.gen.ts": true
},
"search.exclude": {
"**/routeTree.gen.ts": true
},
"files.readonlyInclude": {
"**/routeTree.gen.ts": true
}
}
+18
View File
@@ -0,0 +1,18 @@
# steakhouse
A minimal TanStack Start app with one route and plain CSS.
```bash
npm install
npm run dev
```
Edit `src/routes/index.tsx` to get started. Add route files under
`src/routes`; TanStack Router updates `src/routeTree.gen.ts` for you.
Build the production app with:
```bash
npm run build
```
+15
View File
@@ -0,0 +1,15 @@
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: steakhouse
POSTGRES_PASSWORD: steakhouse
POSTGRES_DB: steakhouse
ports:
- '5432:5432'
volumes:
- steakhouse-postgres-data:/var/lib/postgresql/data
volumes:
steakhouse-postgres-data:
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from 'drizzle-kit'
if (!process.env.DATABASE_URL) {
throw new Error('DATABASE_URL environment variable is not set')
}
export default defineConfig({
schema: './src/db/schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL,
},
})
+4089
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
{
"name": "steakhouse",
"private": true,
"type": "module",
"imports": {
"#/*": "./src/*"
},
"scripts": {
"dev": "vite dev --port 3000",
"generate-routes": "tsr generate",
"build": "vite build",
"preview": "vite preview",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"db:seed": "tsx src/db/seed.ts"
},
"dependencies": {
"@tanstack/react-router": "latest",
"@tanstack/react-start": "latest",
"drizzle-orm": "^0.45.2",
"postgres": "^3.4.9",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@tanstack/router-cli": "^1.132.0",
"@types/node": "^22.10.2",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^6.0.1",
"dotenv": "^17.4.2",
"drizzle-kit": "^0.31.10",
"tsx": "^4.23.12",
"typescript": "^6.0.2",
"vite": "^8.0.0"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild",
"lightningcss"
]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

+16
View File
@@ -0,0 +1,16 @@
import { Link } from '@tanstack/react-router'
export function SiteFooter() {
return (
<footer className="site-footer">
<span className="wordmark">
Steakhaus <span>bei Mirko</span>
</span>
Meppener Straße 8 · 49716 Meppen · 05931 - 12750
<nav className="footer-nav">
<Link to="/">Startseite</Link>
<Link to="/impressum">Impressum</Link>
</nav>
</footer>
)
}
+30
View File
@@ -0,0 +1,30 @@
import { Link } from '@tanstack/react-router'
export function SiteHeader() {
return (
<header className="site-header">
<Link className="wordmark" to="/">
Steakhaus <span>bei Mirko</span>
</Link>
<nav>
<ul className="site-nav">
<li>
<Link to="/" hash="reservieren">
Reservieren
</Link>
</li>
<li>
<Link to="/" hash="oeffnungszeiten">
Öffnungszeiten
</Link>
</li>
<li>
<Link to="/" hash="kontakt">
Kontakt
</Link>
</li>
</ul>
</nav>
</header>
)
}
+14
View File
@@ -0,0 +1,14 @@
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
import * as schema from './schema'
const connectionString = process.env.DATABASE_URL
if (!connectionString) {
throw new Error('DATABASE_URL environment variable is not set')
}
const client = postgres(connectionString)
export const db = drizzle(client, { schema })
+27
View File
@@ -0,0 +1,27 @@
import { integer, pgTable, serial, text, timestamp, unique } from 'drizzle-orm/pg-core'
export const restaurantTables = pgTable('restaurant_tables', {
id: serial('id').primaryKey(),
name: text('name').notNull(),
seats: integer('seats').notNull(),
})
export const reservations = pgTable(
'reservations',
{
id: serial('id').primaryKey(),
tableId: integer('table_id')
.notNull()
.references(() => restaurantTables.id, { onDelete: 'cascade' }),
customerName: text('customer_name').notNull(),
customerEmail: text('customer_email').notNull(),
partySize: integer('party_size').notNull(),
reservationTime: timestamp('reservation_time', { withTimezone: true }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [unique().on(table.tableId, table.reservationTime)],
)
export type RestaurantTable = typeof restaurantTables.$inferSelect
export type Reservation = typeof reservations.$inferSelect
export type NewReservation = typeof reservations.$inferInsert
+26
View File
@@ -0,0 +1,26 @@
import 'dotenv/config'
import { db } from './index'
import { restaurantTables } from './schema'
async function seed() {
const tables = await db
.insert(restaurantTables)
.values([
{ name: 'Tisch 1', seats: 2 },
{ name: 'Tisch 2', seats: 2 },
{ name: 'Tisch 3', seats: 4 },
{ name: 'Tisch 4', seats: 4 },
{ name: 'Tisch 5', seats: 6 },
{ name: 'Tisch 6', seats: 8 },
])
.returning()
console.log(`Seeded ${tables.length} tables.`)
process.exit(0)
}
seed().catch((error) => {
console.error(error)
process.exit(1)
})
+86
View File
@@ -0,0 +1,86 @@
import { createServerFn } from '@tanstack/react-start'
import { and, eq } from 'drizzle-orm'
import { z } from 'zod'
import { db } from '#/db'
import { reservations, restaurantTables } from '#/db/schema'
export const getTables = createServerFn({ method: 'GET' }).handler(async () => {
return db.select().from(restaurantTables).orderBy(restaurantTables.name)
})
export const getReservations = createServerFn({ method: 'GET' }).handler(async () => {
return db
.select({
id: reservations.id,
tableId: reservations.tableId,
tableName: restaurantTables.name,
customerName: reservations.customerName,
customerEmail: reservations.customerEmail,
partySize: reservations.partySize,
reservationTime: reservations.reservationTime,
})
.from(reservations)
.innerJoin(restaurantTables, eq(reservations.tableId, restaurantTables.id))
.orderBy(reservations.reservationTime)
})
const createReservationSchema = z.object({
tableId: z.number().int().positive(),
customerName: z.string().trim().min(1, 'Name is required').max(120),
customerEmail: z.string().trim().email('Enter a valid email'),
partySize: z.number().int().min(1, 'At least 1 guest').max(20),
reservationTime: z.string().refine((value) => !Number.isNaN(Date.parse(value)), {
message: 'Enter a valid date and time',
}),
})
export const createReservation = createServerFn({ method: 'POST' })
.validator(createReservationSchema)
.handler(async ({ data }) => {
const reservationTime = new Date(data.reservationTime)
if (reservationTime.getTime() < Date.now()) {
throw new Error('Reservation time must be in the future')
}
const [table] = await db
.select()
.from(restaurantTables)
.where(eq(restaurantTables.id, data.tableId))
if (!table) {
throw new Error('Selected table does not exist')
}
if (data.partySize > table.seats) {
throw new Error(`${table.name} only seats up to ${table.seats} guests`)
}
const [conflict] = await db
.select()
.from(reservations)
.where(
and(
eq(reservations.tableId, data.tableId),
eq(reservations.reservationTime, reservationTime),
),
)
if (conflict) {
throw new Error('This table is already booked for that time')
}
const [reservation] = await db
.insert(reservations)
.values({
tableId: data.tableId,
customerName: data.customerName,
customerEmail: data.customerEmail,
partySize: data.partySize,
reservationTime,
})
.returning()
return reservation
})
+86
View File
@@ -0,0 +1,86 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as ImpressumRouteImport } from './routes/impressum'
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const ImpressumRoute = ImpressumRouteImport.update({
id: '/impressum',
path: '/impressum',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/impressum': typeof ImpressumRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/impressum': typeof ImpressumRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/impressum': typeof ImpressumRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/impressum'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/impressum'
id: '__root__' | '/' | '/impressum'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
ImpressumRoute: typeof ImpressumRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/impressum': {
id: '/impressum'
path: '/impressum'
fullPath: '/impressum'
preLoaderRoute: typeof ImpressumRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
ImpressumRoute: ImpressumRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
}
}
+19
View File
@@ -0,0 +1,19 @@
import { createRouter as createTanStackRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
export function getRouter() {
const router = createTanStackRouter({
routeTree,
scrollRestoration: true,
defaultPreload: 'intent',
defaultPreloadStaleTime: 0,
})
return router
}
declare module '@tanstack/react-router' {
interface Register {
router: ReturnType<typeof getRouter>
}
}
+47
View File
@@ -0,0 +1,47 @@
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
import appCss from '../styles.css?url'
export const Route = createRootRoute({
head: () => ({
meta: [
{
charSet: 'utf-8',
},
{
name: 'viewport',
content: 'width=device-width, initial-scale=1',
},
{
title: 'Steakhaus bei Mirko | Meppen',
},
{
name: 'description',
content:
'Steakhaus bei Mirko in Meppen – Steaks vom Grill, gemütliche Atmosphäre. Reservieren Sie Ihren Tisch online.',
},
],
links: [
{
rel: 'stylesheet',
href: appCss,
},
],
}),
shellComponent: RootDocument,
})
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html lang="de">
<head>
<HeadContent />
</head>
<body>
{children}
<Scripts />
</body>
</html>
)
}
+81
View File
@@ -0,0 +1,81 @@
import { createFileRoute } from '@tanstack/react-router'
import { SiteFooter } from '#/components/site-footer'
import { SiteHeader } from '#/components/site-header'
export const Route = createFileRoute('/impressum')({
component: Impressum,
})
function Impressum() {
return (
<>
<SiteHeader />
<main className="page legal-page">
<h1>Impressum</h1>
<h2>Angaben gemäß § 5 TMG</h2>
<p>
Drenica &amp; Mousa GbR
<br />
Meppener Str. 8
<br />
49716 Meppen
</p>
<h2>Kontakt</h2>
<p>
Telefon: <a href="tel:+49593112750">+49 (5931) 12750</a>
<br />
E-Mail: <a href="mailto:info@steakhaus-meppen.de">info@steakhaus-meppen.de</a>
</p>
<h2>Haftungsausschluss</h2>
<h3>Haftung für Inhalte</h3>
<p>
Die Inhalte dieser Seite wurden mit Sorgfalt erstellt. Für die Richtigkeit,
Vollständigkeit und Aktualität der Inhalte können wir jedoch keine Gewähr übernehmen. Als
Diensteanbieter sind wir für eigene Inhalte auf diesen Seiten nach den allgemeinen
Gesetzen verantwortlich, nicht jedoch dazu verpflichtet, übermittelte oder gespeicherte
fremde Informationen zu überwachen. Bei Bekanntwerden von Rechtsverletzungen entfernen wir
die betroffenen Inhalte umgehend.
</p>
<h3>Haftung für Links</h3>
<p>
Diese Seite kann Links zu externen Webseiten Dritter enthalten, auf deren Inhalte wir
keinen Einfluss haben. Für diese fremden Inhalte können wir daher keine Gewähr
übernehmen; verantwortlich ist stets der jeweilige Anbieter der verlinkten Seite. Bei
Bekanntwerden von Rechtsverletzungen werden entsprechende Links umgehend entfernt.
</p>
<h3>Urheberrecht</h3>
<p>
Die durch den Seitenbetreiber erstellten Inhalte unterliegen dem deutschen Urheberrecht.
Vervielfältigung, Bearbeitung und Verbreitung außerhalb der Grenzen des Urheberrechts
bedürfen der schriftlichen Zustimmung des jeweiligen Erstellers.
</p>
<h2>Datenschutz</h2>
<p>
Verantwortlich für die Verarbeitung personenbezogener Daten auf dieser Website ist die
oben genannte Drenica &amp; Mousa GbR.
</p>
<p>
Wenn Sie über das Reservierungsformular einen Tisch buchen, speichern wir die von Ihnen
angegebenen Daten (Name, E-Mail-Adresse, Personenzahl, Datum und Uhrzeit) ausschließlich
zur Bearbeitung und Bestätigung Ihrer Reservierung. Eine Weitergabe an Dritte findet
nicht statt.
</p>
<p>
Diese Seite bindet Schriftarten von Google Fonts ein. Dabei kann eine Verbindung Ihres
Browsers zu Servern von Google hergestellt und Ihre IP-Adresse übermittelt werden. Es
werden keine Analyse- oder Tracking-Cookies eingesetzt.
</p>
</main>
<SiteFooter />
</>
)
}
+249
View File
@@ -0,0 +1,249 @@
import { useState } from 'react'
import { createFileRoute, useRouter } from '@tanstack/react-router'
import { SiteFooter } from '#/components/site-footer'
import { SiteHeader } from '#/components/site-header'
import { createReservation, getTables } from '#/lib/reservations'
export const Route = createFileRoute('/')({
component: Home,
loader: async () => {
const tables = await getTables()
return { tables }
},
})
const OPENING_HOURS = [
{ day: 'Montag', hours: null },
{ day: 'Dienstag', hours: ['17:00 – 21:30'] },
{ day: 'Mittwoch', hours: ['17:00 – 21:30'] },
{ day: 'Donnerstag', hours: ['17:00 – 21:30'] },
{ day: 'Freitag', hours: ['17:00 – 22:00'] },
{ day: 'Samstag', hours: ['17:00 – 22:00'] },
{ day: 'Sonntag', hours: ['11:30 – 13:30', '17:00 – 22:00'] },
]
function formatDateTimeLocal(date: Date) {
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
}
function FlameIcon() {
return (
<svg className="hero-flame" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M12 2c1 3-3 4-3 7.5A3.5 3.5 0 0 0 12 13a3.5 3.5 0 0 0 3-5.3c1.5 1 2.5 3 2.5 5a5.5 5.5 0 1 1-11 0C6.5 8.5 9.5 6 12 2Z"
stroke="currentColor"
strokeWidth="1.4"
strokeLinejoin="round"
/>
</svg>
)
}
function Home() {
const router = useRouter()
const { tables } = Route.useLoaderData()
const [tableId, setTableId] = useState<number | string>(tables[0]?.id ?? '')
const [customerName, setCustomerName] = useState('')
const [customerEmail, setCustomerEmail] = useState('')
const [partySize, setPartySize] = useState(2)
const [reservationTime, setReservationTime] = useState(() =>
formatDateTimeLocal(new Date(Date.now() + 60 * 60 * 1000)),
)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const todayName = new Date().toLocaleDateString('de-DE', { weekday: 'long' })
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
setError(null)
setSuccess(null)
if (!tableId) {
setError('Bitte wählen Sie einen Tisch aus')
return
}
setIsSubmitting(true)
try {
await createReservation({
data: {
tableId: Number(tableId),
customerName,
customerEmail,
partySize: Number(partySize),
reservationTime: new Date(reservationTime).toISOString(),
},
})
setSuccess('Vielen Dank! Ihre Reservierung ist bestätigt.')
setCustomerName('')
setCustomerEmail('')
setPartySize(2)
await router.invalidate()
} catch (err) {
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
} finally {
setIsSubmitting(false)
}
}
return (
<>
<SiteHeader />
<section className="hero" id="top">
<FlameIcon />
<h1>
Steakhaus <span>bei Mirko</span>
</h1>
<p>
Steaks vom Grill, gute Gesellschaft und ein gemütlicher Abend mitten in Meppen. Reservieren
Sie Ihren Tisch – wir freuen uns auf Sie.
</p>
<div className="hero-actions">
<a className="btn btn-primary" href="#reservieren">
Tisch reservieren
</a>
<a className="btn btn-secondary" href="#oeffnungszeiten">
Öffnungszeiten
</a>
</div>
</section>
<main className="page">
<section className="section" id="oeffnungszeiten">
<div className="section-grid">
<div>
<span className="section-kicker">Wann wir für Sie da sind</span>
<h2 className="section-heading">Öffnungszeiten</h2>
</div>
<ul className="hours-list">
{OPENING_HOURS.map(({ day, hours }) => (
<li key={day} className={day === todayName ? 'today' : undefined}>
<span className="day">{day}</span>
{hours ? (
<span>{hours.join(' · ')}</span>
) : (
<span className="hours-closed">geschlossen</span>
)}
</li>
))}
</ul>
</div>
</section>
<section className="section" id="reservieren">
<div className="section-grid">
<div>
<span className="section-kicker">Wir freuen uns auf Sie</span>
<h2 className="section-heading">Tisch reservieren</h2>
</div>
<form className="reservation-form" onSubmit={handleSubmit}>
<label>
Tisch
<select value={tableId} onChange={(e) => setTableId(e.target.value)} required>
<option value="" disabled>
Tisch auswählen
</option>
{tables.map((table) => (
<option key={table.id} value={table.id}>
{table.name} ({table.seats} Plätze)
</option>
))}
</select>
</label>
<div className="form-row">
<label>
Name
<input
type="text"
value={customerName}
onChange={(e) => setCustomerName(e.target.value)}
required
/>
</label>
<label>
E-Mail
<input
type="email"
value={customerEmail}
onChange={(e) => setCustomerEmail(e.target.value)}
required
/>
</label>
</div>
<div className="form-row">
<label>
Anzahl Personen
<input
type="number"
min={1}
max={20}
value={partySize}
onChange={(e) => setPartySize(Number(e.target.value))}
required
/>
</label>
<label>
Datum &amp; Uhrzeit
<input
type="datetime-local"
value={reservationTime}
onChange={(e) => setReservationTime(e.target.value)}
required
/>
</label>
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Wird gebucht …' : 'Jetzt reservieren'}
</button>
{error && <p className="message message-error">{error}</p>}
{success && <p className="message message-success">{success}</p>}
</form>
</div>
</section>
<section className="section" id="kontakt">
<div className="section-grid">
<div>
<span className="section-kicker">Hier finden Sie uns</span>
<h2 className="section-heading">Kontakt</h2>
</div>
<ul className="contact-list">
<li>
<span className="label">Adresse</span>
<span>
Meppener Straße 8
<br />
49716 Meppen
</span>
</li>
<li>
<span className="label">Telefon</span>
<a href="tel:+49593112750">05931 - 12750</a>
</li>
<li>
<span className="label">E-Mail</span>
<a href="mailto:info@steakhaus-meppen.de">info@steakhaus-meppen.de</a>
</li>
</ul>
</div>
</section>
</main>
<SiteFooter />
</>
)
}
+436
View File
@@ -0,0 +1,436 @@
@import url('https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600;9..144,700&family=Inter:wght@400;500;600&display=swap');
:root {
--bg: #17120f;
--bg-raised: #1e1712;
--line: #3a2f26;
--text: #f3ead9;
--text-muted: #c9baa3;
--gold: #c9a15a;
--gold-bright: #e0bb78;
--ember: #a8402c;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
html,
body {
overflow-x: hidden;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: 'Inter', system-ui, sans-serif;
line-height: 1.5;
}
h1,
h2,
h3 {
font-family: 'Fraunces', Georgia, serif;
font-weight: 600;
letter-spacing: 0.01em;
margin: 0;
}
a {
color: var(--gold-bright);
}
.page {
max-width: 68rem;
margin: 0 auto;
padding: 0 1.5rem;
}
/* Header */
.site-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
row-gap: 0.5rem;
gap: 1rem;
padding: 1.25rem 1.5rem;
max-width: 68rem;
margin: 0 auto;
}
@media (max-width: 30rem) {
.site-header {
justify-content: center;
text-align: center;
}
}
.wordmark {
font-family: 'Fraunces', Georgia, serif;
font-size: 1.15rem;
letter-spacing: 0.03em;
color: var(--text);
text-decoration: none;
}
.wordmark span {
color: var(--gold-bright);
}
.site-nav {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.75rem 1.25rem;
list-style: none;
margin: 0;
padding: 0;
font-size: 0.9rem;
}
.site-nav a {
color: var(--text-muted);
text-decoration: none;
}
.site-nav a:hover {
color: var(--gold-bright);
}
/* Hero */
.hero {
position: relative;
padding: 4.5rem 1.5rem 5rem;
text-align: center;
background:
radial-gradient(ellipse at top, rgba(200, 90, 50, 0.2), transparent 60%),
linear-gradient(rgba(23, 18, 15, 0.55), rgba(23, 18, 15, 0.93)),
url('/hero-interior.jpg');
background-size: auto, auto, cover;
background-position: center, center, center 55%;
background-repeat: no-repeat;
border-bottom: 1px solid var(--line);
overflow: hidden;
}
.hero-flame {
width: 2.5rem;
height: 2.5rem;
margin: 0 auto 1.5rem;
color: var(--ember);
}
.hero h1 {
font-size: clamp(2.25rem, 5vw, 3.5rem);
color: var(--text);
}
.hero h1 span {
color: var(--gold-bright);
}
.hero p {
max-width: 34rem;
margin: 1rem auto 0;
color: var(--text-muted);
font-size: 1.05rem;
}
.hero-actions {
margin-top: 2rem;
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
}
.btn {
display: inline-block;
font: inherit;
font-weight: 600;
padding: 0.75rem 1.5rem;
border-radius: 0.25rem;
text-decoration: none;
cursor: pointer;
border: 1px solid transparent;
}
.btn-primary {
background: var(--gold);
color: #201709;
}
.btn-primary:hover {
background: var(--gold-bright);
}
.btn-secondary {
background: transparent;
border-color: var(--line);
color: var(--text);
}
.btn-secondary:hover {
border-color: var(--gold);
color: var(--gold-bright);
}
/* Sections */
.section {
padding: 4rem 0;
border-bottom: 1px solid var(--line);
}
.section:last-of-type {
border-bottom: none;
}
.section-heading {
font-size: 1.75rem;
margin-bottom: 0.5rem;
}
.section-kicker {
display: block;
font-size: 0.8rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--gold);
margin-bottom: 0.75rem;
}
.section-grid {
display: grid;
grid-template-columns: 1fr 1.3fr;
gap: 3rem;
}
.section-grid > * {
min-width: 0;
}
@media (max-width: 46rem) {
.section-grid {
grid-template-columns: 1fr;
}
}
/* Opening hours */
.hours-list {
list-style: none;
margin: 1.5rem 0 0;
padding: 0;
border-top: 1px solid var(--line);
}
.hours-list li {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 0.65rem 0;
border-bottom: 1px solid var(--line);
color: var(--text-muted);
}
.hours-list li.today {
color: var(--text);
font-weight: 600;
}
.hours-list .day {
font-family: 'Fraunces', Georgia, serif;
}
.hours-closed {
font-style: italic;
}
/* Reservation form */
.reservation-form {
display: flex;
flex-direction: column;
gap: 1.1rem;
background: var(--bg-raised);
border: 1px solid var(--line);
border-radius: 0.5rem;
padding: 1.75rem;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.1rem;
}
.form-row > * {
min-width: 0;
}
@media (max-width: 30rem) {
.form-row {
grid-template-columns: 1fr;
}
}
.reservation-form label {
display: flex;
flex-direction: column;
gap: 0.35rem;
min-width: 0;
font-weight: 500;
font-size: 0.9rem;
color: var(--text-muted);
}
.reservation-form input,
.reservation-form select {
width: 100%;
min-width: 0;
font: inherit;
padding: 0.6rem 0.7rem;
border: 1px solid var(--line);
border-radius: 0.3rem;
background: var(--bg);
color: var(--text);
}
.reservation-form input:focus,
.reservation-form select:focus {
outline: none;
border-color: var(--gold);
box-shadow: 0 0 0 2px rgba(201, 161, 90, 0.25);
}
.reservation-form button {
align-self: flex-start;
font: inherit;
font-weight: 600;
padding: 0.7rem 1.5rem;
border: none;
border-radius: 0.3rem;
background: var(--gold);
color: #201709;
cursor: pointer;
}
.reservation-form button:hover:not(:disabled) {
background: var(--gold-bright);
}
.reservation-form button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.message {
margin: 0;
padding: 0.65rem 0.9rem;
border-radius: 0.3rem;
font-size: 0.9rem;
}
.message-error {
background: #3a1414;
color: #f3a9a9;
border: 1px solid #5c1f1f;
}
.message-success {
background: #17301c;
color: #a9d9b4;
border: 1px solid #23502c;
}
/* Contact */
.contact-list {
list-style: none;
margin: 1.5rem 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.contact-list li {
display: flex;
gap: 0.75rem;
color: var(--text-muted);
}
.contact-list .label {
color: var(--gold);
min-width: 6rem;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.06em;
padding-top: 0.15rem;
}
/* Footer */
.site-footer {
padding: 2.5rem 1.5rem;
text-align: center;
color: var(--text-muted);
font-size: 0.85rem;
}
.site-footer .wordmark {
display: block;
margin-bottom: 0.5rem;
}
.footer-nav {
display: flex;
gap: 1.25rem;
justify-content: center;
margin-top: 0.75rem;
}
.footer-nav a {
color: var(--text-muted);
text-decoration: none;
}
.footer-nav a:hover {
color: var(--gold-bright);
}
/* Legal page */
.legal-page {
padding: 3.5rem 0 4rem;
}
.legal-page h1 {
font-size: 2.25rem;
margin-bottom: 2rem;
}
.legal-page h2 {
font-size: 1.25rem;
color: var(--gold-bright);
margin: 2.25rem 0 0.75rem;
}
.legal-page h3 {
font-size: 1rem;
margin: 1.5rem 0 0.5rem;
}
.legal-page p {
color: var(--text-muted);
max-width: 42rem;
}
+28
View File
@@ -0,0 +1,28 @@
{
"include": ["**/*.ts", "**/*.tsx"],
"compilerOptions": {
"target": "ES2022",
"jsx": "react-jsx",
"module": "ESNext",
"paths": {
"#/*": ["./src/*"],
"@/*": ["./src/*"]
},
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
/* Linting */
"skipLibCheck": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"target": "react"
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import viteReact from '@vitejs/plugin-react'
const config = defineConfig({
resolve: { tsconfigPaths: true },
plugins: [tanstackStart(), viteReact()],
})
export default config