Compare commits

..
3 Commits
Author SHA1 Message Date
lukas e19461e41d ✨ add bull logo
CI / test (push) Successful in 5m3s
CI / build (push) Successful in 2m42s
CI / deploy (push) Failing after 13s
2026-08-28 12:24:19 +02:00
lukas 23fddd543f ✨ add menu pdf & pdf lib
CI / test (push) Successful in 5m10s
CI / build (push) Successful in 4m36s
CI / deploy (push) Successful in 40s
2026-08-28 11:26:49 +02:00
lukas ecbc42846b ✨ first version
CI / test (push) Successful in 5m16s
CI / deploy (push) Successful in 32s
CI / build (push) Successful in 4m2s
2026-08-28 10:24:15 +02:00
23 changed files with 1984 additions and 529 deletions
+20
View File
@@ -0,0 +1,20 @@
node_modules
dist
dist-ssr
.git
.gitea
.github
.idea
.vscode
.env
.env.*
!.env.example
*.local
.nitro
.tanstack
.wrangler
.output
.vinxi
__unconfig*
todos.json
README.md
+130 -64
View File
@@ -1,42 +1,29 @@
name: CI
on:
pull_request:
branches: ['**']
push:
branches:
- main
- proof
workflow_dispatch:
env:
REGISTRY: git.lukas-harat.com
IMAGE_NAME: ${{ github.repository }}
permissions:
contents: read
packages: write
jobs:
build-and-test:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: steakhouse
POSTGRES_PASSWORD: steakhouse
POSTGRES_DB: steakhouse
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U steakhouse"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
DATABASE_URL: postgres://steakhouse:steakhouse@localhost:5432/steakhouse
BETTER_AUTH_SECRET: ci-only-secret-not-used-in-production
BETTER_AUTH_URL: http://localhost:4173
STAFF_SEED_EMAIL: admin@example.com
STAFF_SEED_PASSWORD: ci-only-password-not-used-in-production
RESEND_API_KEY: re_ci_dummy_key
RESERVATIONS_FROM_EMAIL: 'Steakhaus bei Mirko <onboarding@resend.dev>'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
@@ -45,50 +32,129 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Type check
- name: Typecheck
run: npx tsc --noEmit
- name: Check that Drizzle migrations are up to date
build:
needs: test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.USERNAME }}
password: ${{ secrets.TOKEN }}
- name: Build and push image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy to TrueNAS
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }}
BETTER_AUTH_URL: ${{ secrets.BETTER_AUTH_URL }}
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
STAFF_SEED_EMAIL: ${{ secrets.STAFF_SEED_EMAIL }}
STAFF_SEED_PASSWORD: ${{ secrets.STAFF_SEED_PASSWORD }}
RESERVATIONS_FROM_EMAIL: ${{ secrets.RESERVATIONS_FROM_EMAIL }}
IMAGE_TAG: ${{ github.sha }}
run: |
npx drizzle-kit generate
if [ -n "$(git status --porcelain drizzle)" ]; then
echo "::error::src/db/schema.ts changed without a matching migration. Run 'npm run db:generate' and commit the result."
git status --porcelain drizzle
git diff drizzle
exit 1
fi
set -euo pipefail
- name: Apply migrations
run: npm run db:migrate
mkdir -p ~/.ssh
- name: Seed baseline data
run: npm run db:seed
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
- name: Build
run: npm run build
ssh-keyscan \
-p "${DEPLOY_PORT:-22}" \
-H "$DEPLOY_HOST" \
>> ~/.ssh/known_hosts
- name: Smoke test the production build
run: |
npx vite preview --port 4173 &
PREVIEW_PID=$!
echo "Creating deployment directory if needed..."
for i in $(seq 1 30); do
curl -sf http://localhost:4173/ >/dev/null 2>&1 && break
sleep 1
done
ssh \
-i ~/.ssh/deploy_key \
-p "${DEPLOY_PORT:-22}" \
"$DEPLOY_USER@$DEPLOY_HOST" \
"mkdir -p '$DEPLOY_PATH'"
check() {
code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:4173$1")
if [ "$code" != "$2" ]; then
echo "::error::GET $1 returned $code, expected $2"
kill "$PREVIEW_PID"
exit 1
fi
echo "GET $1 -> $code"
}
echo "Uploading compose file..."
check / 200
check /staff/login 200
check /impressum 200
scp \
-i ~/.ssh/deploy_key \
-P "${DEPLOY_PORT:-22}" \
docker-compose.prod.yml \
"$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/docker-compose.yml"
kill "$PREVIEW_PID"
echo "Deploying..."
ssh \
-i ~/.ssh/deploy_key \
-p "${DEPLOY_PORT:-22}" \
"$DEPLOY_USER@$DEPLOY_HOST" \
"POSTGRES_PASSWORD=$(printf '%q' "$POSTGRES_PASSWORD") \
BETTER_AUTH_SECRET=$(printf '%q' "$BETTER_AUTH_SECRET") \
BETTER_AUTH_URL=$(printf '%q' "$BETTER_AUTH_URL") \
RESEND_API_KEY=$(printf '%q' "$RESEND_API_KEY") \
RESERVATIONS_FROM_EMAIL=$(printf '%q' "$RESERVATIONS_FROM_EMAIL") \
STAFF_SEED_EMAIL=$(printf '%q' "$STAFF_SEED_EMAIL") \
STAFF_SEED_PASSWORD=$(printf '%q' "$STAFF_SEED_PASSWORD") \
IMAGE_TAG=$(printf '%q' "$IMAGE_TAG") \
bash -c '
set -euo pipefail
cd \"$DEPLOY_PATH\"
echo \"Pulling images...\"
docker compose pull
echo \"Starting PostgreSQL...\"
docker compose up -d postgres
echo "Running database migrations..."
docker compose run --rm app npx drizzle-kit migrate
echo "Seeding database..."
docker compose run --rm app npm run db:seed
echo "Starting application..."
docker compose up -d
echo \"Cleaning unused images...\"
docker image prune -f
echo \"Deployment complete.\"
'"
+31
View File
@@ -0,0 +1,31 @@
# syntax=docker/dockerfile:1
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
# Dummy values so the build-time env checks in src/db/index.ts and
# src/lib/auth-instance.ts don't throw while bundling. The real values are
# supplied at container runtime.
ENV DATABASE_URL=postgres://build:build@localhost:5432/build \
BETTER_AUTH_SECRET=build-time-placeholder \
BETTER_AUTH_URL=http://localhost:3000
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# This build has no standalone Node server entrypoint — dist/server/server.js
# only exports a fetch handler. `vite preview` (via the tanstackStart plugin)
# is the supported way to serve it, and it re-resolves the app's router entry
# from src/ at startup, so the full source tree (not just dist/) has to ship.
COPY --from=builder /app ./
EXPOSE 3000
CMD ["npm", "run", "preview", "--", "--host", "0.0.0.0", "--port", "3000"]
+35
View File
@@ -0,0 +1,35 @@
services:
app:
image: git.lukas-harat.com/lukas/promo_steakhouse:${IMAGE_TAG:-latest}
restart: unless-stopped
ports:
- '3003:3000'
environment:
DATABASE_URL: postgres://steakhouse:${POSTGRES_PASSWORD}@postgres:5432/steakhouse
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
BETTER_AUTH_URL: ${BETTER_AUTH_URL}
STAFF_SEED_EMAIL: ${STAFF_SEED_EMAIL}
STAFF_SEED_PASSWORD: ${STAFF_SEED_PASSWORD}
RESEND_API_KEY: ${RESEND_API_KEY}
RESERVATIONS_FROM_EMAIL: ${RESERVATIONS_FROM_EMAIL}
depends_on:
postgres:
condition: service_healthy
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: steakhouse
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: steakhouse
volumes:
- steakhouse-postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U steakhouse -d steakhouse"]
interval: 5s
timeout: 5s
retries: 10
volumes:
steakhouse-postgres-data:
+278
View File
@@ -10,6 +10,7 @@
"@tanstack/react-start": "latest",
"better-auth": "^1.6.29",
"drizzle-orm": "^0.45.2",
"pdfjs-dist": "^6.2.108",
"postgres": "^3.4.9",
"react": "^19.2.0",
"react-dom": "^19.2.0",
@@ -1310,6 +1311,271 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@napi-rs/canvas": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.8.tgz",
"integrity": "sha512-/SaLcvlqGWdm0HSCWMgHu7cjJiQXfP8/mOY+6dUyV9flQz7sPBBZ+ed2zYtoukojPmxOaL7bm+d/G4GeWWoN7g==",
"license": "MIT",
"optional": true,
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "1.0.8",
"@napi-rs/canvas-darwin-arm64": "1.0.8",
"@napi-rs/canvas-darwin-x64": "1.0.8",
"@napi-rs/canvas-linux-arm-gnueabihf": "1.0.8",
"@napi-rs/canvas-linux-arm64-gnu": "1.0.8",
"@napi-rs/canvas-linux-arm64-musl": "1.0.8",
"@napi-rs/canvas-linux-riscv64-gnu": "1.0.8",
"@napi-rs/canvas-linux-x64-gnu": "1.0.8",
"@napi-rs/canvas-linux-x64-musl": "1.0.8",
"@napi-rs/canvas-win32-arm64-msvc": "1.0.8",
"@napi-rs/canvas-win32-x64-msvc": "1.0.8"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.8.tgz",
"integrity": "sha512-5+nkh8i3gt6lqS/d2jTZ1xAn6tdgtB4Lf1mW6T0Qm5/rXNwBuV1sAEyLEWan5o9gJPU/GuvHR3rvSeZ+FaGrbw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.8.tgz",
"integrity": "sha512-7jQ47gi+fZ7KJmfc/5rNyy1CYw/cu4kZ0KPIYbo9UUgSdW0bKQJpt+WihEor6s4Lyp7+xc3a+3HeyXmAEbbnPg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.8.tgz",
"integrity": "sha512-rRjDMZs9pIRKGxgijwezplKc1RnJsqUokrA9h88bbTkqQ+7ePj0ZN4ZnZDy8Vu0tXs7KRlI2tQLaK4mx9QlxHg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.8.tgz",
"integrity": "sha512-jGcCd+8ra6Q61xKqZeiItujTpp9a9eRLcQ0jW6qYNku+WpupqOPFPY0SrsuSnXFviJwkpKYT9p7QrB4lsf3LNQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.8.tgz",
"integrity": "sha512-od6I2Y7kU7i1SwZYG2EKW8rWz6JiedtPpko4WEe1DDsiikrfaotVBCRaUTM5/yeZKaZ92EatoAS+5xG+6uJlYA==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.8.tgz",
"integrity": "sha512-yYkPbJDJiWj6N0gASA3CAvRypZmVpJnxU0DQg3aBhneLDQde9TPLKADsQkobNoJUtTT/lj46aWpzT48PDb3Qcg==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.8.tgz",
"integrity": "sha512-PB00MSKAp4VwK/xwe6duKxRKmH8UH4GIl1pqHSbxng0jnU9Dr7FwaDypDiqwNFZ774N+8G7mJLGuLtg9NTcQsg==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.8.tgz",
"integrity": "sha512-TWM2XWJoitLiIPCvgJh7SriC+L/T9qkYCVzC66AidsZy0QP1hkKzBzVwshCdcA3q6fIn3yE0ISbq4lMJSy8jFw==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.8.tgz",
"integrity": "sha512-hb20MxKXXb5IB7AAwN8UHz9WRsa2HmdZfjsDCzjElwJoeV1aotVEwFU4FrFQcYQVzsJQLeaCc/2Qdt/0Q72mMg==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.8.tgz",
"integrity": "sha512-WwPN08IXE4SkL+FhJyPz/iFnycMAUkbphFIT4cmKLlvbSU0Zfn1R7BGJ3Hqky1S89QUYc0Q4IOScXb/42Re9wQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.8.tgz",
"integrity": "sha512-XkrVqKb+pxyba7kjy2LJvABFVBTE0DNpEl7MrG4OYUmaWarrXH+t54z/Czj2YxCKtizYTV4mg6phNm3x24qjhQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@noble/ciphers": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.3.0.tgz",
@@ -3732,6 +3998,18 @@
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"license": "MIT"
},
"node_modules/pdfjs-dist": {
"version": "6.2.108",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.2.108.tgz",
"integrity": "sha512-YxFb+SQcodN2rnX9Tn3dHYlqfb7NjlzzfONPpJd+AKoKtUjEdevTfbC07d5TcczzOK6261auRkP/M8OBHs9vFQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=22.13.0 || >=24"
},
"optionalDependencies": {
"@napi-rs/canvas": "^1.0.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+1
View File
@@ -21,6 +21,7 @@
"@tanstack/react-start": "latest",
"better-auth": "^1.6.29",
"drizzle-orm": "^0.45.2",
"pdfjs-dist": "^6.2.108",
"postgres": "^3.4.9",
"react": "^19.2.0",
"react-dom": "^19.2.0",
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#17120f"/>
<rect x="2" y="2" width="60" height="60" rx="12" fill="none" stroke="#c9a15a" stroke-width="2"/>
<text x="32" y="44" font-family="Georgia, 'Times New Roman', serif" font-size="34" font-weight="600" text-anchor="middle" fill="#e0bb78">M</text>
</svg>

After

Width:  |  Height:  |  Size: 371 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 500"><path d="M 79.47,45.64 C 71.12,45.78 60.80,48.79 55.89,52.64 C 50.98,56.49 48.85,62.75 50.00,68.74 C 51.15,74.74 56.86,81.99 62.81,88.62 C 68.75,95.24 77.19,102.34 85.70,108.49 C 94.20,114.64 104.30,120.74 113.83,125.51 C 123.36,130.27 133.30,134.13 142.89,137.07 C 152.48,140.00 161.90,141.73 171.36,143.12 C 180.83,144.51 190.19,144.97 199.69,145.40 C 209.20,145.83 218.68,145.63 228.38,145.70 C 238.08,145.76 247.88,145.49 257.92,145.78 C 267.95,146.06 278.42,146.34 288.57,147.41 C 298.73,148.47 309.21,149.81 318.83,152.17 C 328.44,154.53 337.98,157.42 346.24,161.57 C 354.50,165.73 362.29,170.66 368.39,177.11 C 374.49,183.56 379.31,191.38 382.83,200.28 C 386.35,209.19 386.77,220.92 389.51,230.55 C 392.24,240.17 395.02,249.65 399.25,258.04 C 403.47,266.43 409.89,273.06 414.85,280.88 C 419.80,288.70 425.82,296.22 428.98,304.97 C 432.14,313.71 433.03,323.66 433.82,333.38 C 434.62,343.09 433.47,353.42 433.75,363.25 C 434.04,373.07 433.68,382.96 435.52,392.31 C 437.35,401.67 440.01,411.11 444.78,419.37 C 449.55,427.63 456.73,436.04 464.14,441.88 C 471.56,447.71 480.75,452.58 489.28,454.36 C 497.81,456.14 507.05,455.10 515.32,452.56 C 523.60,450.02 531.95,444.80 538.93,439.13 C 545.90,433.45 552.25,426.02 557.18,418.50 C 562.11,410.98 565.65,402.67 568.51,394.01 C 571.36,385.34 572.93,376.03 574.30,366.50 C 575.67,356.97 575.77,346.70 576.73,336.83 C 577.68,326.96 577.97,316.66 580.02,307.28 C 582.06,297.89 584.88,288.90 588.99,280.55 C 593.09,272.19 599.37,265.01 604.64,257.15 C 609.90,249.28 616.07,241.81 620.59,233.35 C 625.11,224.89 628.08,215.26 631.74,206.39 C 635.41,197.51 637.83,187.81 642.59,180.10 C 647.35,172.40 652.72,165.26 660.30,160.18 C 667.89,155.10 678.09,152.07 688.10,149.61 C 698.10,147.15 709.79,146.33 720.34,145.43 C 730.89,144.53 741.33,144.43 751.41,144.20 C 761.48,143.97 771.14,144.17 780.77,144.04 C 790.40,143.91 799.75,143.96 809.20,143.44 C 818.65,142.91 827.94,142.31 837.47,140.89 C 846.99,139.47 856.56,137.70 866.35,134.89 C 876.14,132.08 886.65,128.54 896.23,124.04 C 905.80,119.54 915.88,114.14 923.78,107.87 C 931.68,101.61 939.26,94.04 943.63,86.46 C 948.00,78.89 951.03,69.06 950.00,62.42 C 948.97,55.78 943.45,49.15 937.43,46.64 C 931.42,44.12 922.45,45.15 913.92,47.34 C 905.39,49.53 895.66,55.24 886.23,59.77 C 876.79,64.29 866.85,70.36 857.32,74.49 C 847.79,78.63 838.46,82.23 829.04,84.56 C 819.61,86.89 810.31,88.20 800.77,88.46 C 791.24,88.71 781.58,87.55 771.84,86.11 C 762.10,84.67 752.23,82.10 742.35,79.81 C 732.47,77.52 722.50,74.57 712.58,72.36 C 702.65,70.15 692.68,67.75 682.79,66.54 C 672.91,65.34 663.05,64.73 653.27,65.12 C 643.50,65.52 633.80,67.12 624.15,68.91 C 614.49,70.71 604.90,73.41 595.33,75.88 C 585.76,78.36 576.24,81.36 566.73,83.76 C 557.21,86.17 547.73,88.72 538.24,90.29 C 528.75,91.86 519.27,93.10 509.77,93.19 C 500.26,93.28 490.75,92.26 481.22,90.82 C 471.68,89.39 462.13,86.92 452.56,84.58 C 442.98,82.23 433.39,79.25 423.76,76.77 C 414.14,74.29 404.50,71.56 394.82,69.72 C 385.14,67.88 375.44,66.28 365.70,65.75 C 355.96,65.22 346.18,65.68 336.39,66.54 C 326.60,67.41 316.78,69.20 306.95,70.92 C 297.12,72.64 287.26,74.96 277.41,76.86 C 267.56,78.77 257.70,80.94 247.85,82.35 C 238.00,83.77 228.13,85.07 218.32,85.38 C 208.51,85.68 198.66,85.47 188.99,84.19 C 179.32,82.91 169.69,80.84 160.31,77.69 C 150.93,74.54 141.76,69.61 132.71,65.30 C 123.65,60.99 114.85,55.10 105.97,51.83 C 97.10,48.55 87.82,45.51 79.47,45.64 Z" fill="#7B2D2F"/></svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
View File
Binary file not shown.
+191
View File
@@ -0,0 +1,191 @@
import { useEffect, useRef, useState } from 'react'
import type { PDFDocumentProxy, RenderTask } from 'pdfjs-dist'
type Props = {
src: string
}
// pdfjs-dist touches the DOM/Canvas at import time, so it's loaded lazily inside
// the effect below instead of at module scope — this file also runs during SSR.
export function PdfMenu({ src }: Props) {
const containerRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const pdfRef = useRef<PDFDocumentProxy | null>(null)
const directionRef = useRef(1)
const hasRenderedOnceRef = useRef(false)
const [error, setError] = useState(false)
const [numPages, setNumPages] = useState(0)
const [pageNumber, setPageNumber] = useState(1)
const [isBusy, setIsBusy] = useState(false)
function goToPage(next: number) {
directionRef.current = next > pageNumber ? 1 : -1
setPageNumber(next)
}
useEffect(() => {
let cancelled = false
setError(false)
setNumPages(0)
setPageNumber(1)
pdfRef.current = null
hasRenderedOnceRef.current = false
async function load() {
const pdfjsLib = await import('pdfjs-dist')
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url,
).href
try {
const pdf = await pdfjsLib.getDocument({ url: src }).promise
if (cancelled) return
pdfRef.current = pdf
setNumPages(pdf.numPages)
} catch (err) {
console.error('PdfMenu failed to load', err)
if (!cancelled) setError(true)
}
}
load()
return () => {
cancelled = true
}
}, [src])
useEffect(() => {
const pdf = pdfRef.current
const canvas = canvasRef.current
const container = containerRef.current
if (!pdf || !canvas || !container || numPages === 0) return
let cancelled = false
let renderTask: RenderTask | undefined
let exitTimeout: ReturnType<typeof setTimeout> | undefined
function wait(ms: number) {
return new Promise<void>((resolve) => {
exitTimeout = setTimeout(resolve, ms)
})
}
async function render(pdf: PDFDocumentProxy, canvas: HTMLCanvasElement, container: HTMLDivElement) {
const direction = directionRef.current
setIsBusy(true)
if (hasRenderedOnceRef.current) {
canvas.style.transition = 'opacity 0.18s ease, transform 0.18s ease'
canvas.style.opacity = '0'
canvas.style.transform = `translateX(${direction * -2}rem)`
await wait(180)
if (cancelled) return
}
try {
const page = await pdf.getPage(pageNumber)
if (cancelled) return
const unscaledViewport = page.getViewport({ scale: 1 })
const scale = container.clientWidth / unscaledViewport.width
const viewport = page.getViewport({ scale })
const pixelRatio = window.devicePixelRatio || 1
canvas.width = viewport.width * pixelRatio
canvas.height = viewport.height * pixelRatio
canvas.style.width = `${viewport.width}px`
canvas.style.height = `${viewport.height}px`
const context = canvas.getContext('2d')
if (!context) return
context.scale(pixelRatio, pixelRatio)
renderTask = page.render({ canvasContext: context, viewport, canvas })
await renderTask.promise
if (cancelled) return
// Jump to the entry offset with transitions off, then force the browser to
// commit that position before re-enabling them — otherwise the two style
// writes land in the same frame and the entry animation never paints.
canvas.style.transition = 'none'
canvas.style.transform = `translateX(${direction * 2}rem)`
canvas.offsetHeight
canvas.style.transition = 'opacity 0.22s ease, transform 0.22s ease'
canvas.style.opacity = '1'
canvas.style.transform = 'translateX(0)'
hasRenderedOnceRef.current = true
} catch (err) {
if (!cancelled) {
console.error('PdfMenu failed to render page', err)
setError(true)
}
} finally {
if (!cancelled) setIsBusy(false)
}
}
render(pdf, canvas, container)
return () => {
cancelled = true
clearTimeout(exitTimeout)
renderTask?.cancel()
}
}, [numPages, pageNumber])
if (error) {
return (
<p className="field-hint">
Die Speisekarte konnte nicht geladen werden.{' '}
<a href={src} target="_blank" rel="noopener noreferrer">
Als PDF öffnen
</a>
.
</p>
)
}
return (
<div className="pdf-menu" ref={containerRef}>
<div className="pdf-menu-viewport">
<canvas className="pdf-menu-page" ref={canvasRef} />
{numPages === 0 && (
<div className="pdf-menu-loading" role="status">
<div className="pdf-menu-spinner" aria-hidden="true" />
<p>Die Speisekarte wird aufgetischt …</p>
</div>
)}
</div>
{numPages > 1 && (
<div className="pdf-menu-pagination">
<button
type="button"
className="btn btn-secondary"
onClick={() => goToPage(pageNumber - 1)}
disabled={isBusy || pageNumber <= 1}
>
Zurück
</button>
<span>
Seite {pageNumber} von {numPages}
</span>
<button
type="button"
className="btn btn-secondary"
onClick={() => goToPage(pageNumber + 1)}
disabled={isBusy || pageNumber >= numPages}
>
Weiter
</button>
</div>
)}
<p className="field-hint">
Speisekarte nicht geladen?{' '}
<a href={src} target="_blank" rel="noopener noreferrer">
Klick hier
</a>
</p>
</div>
)
}
@@ -0,0 +1,108 @@
import { useEffect, useRef } from 'react'
type Props = {
open: boolean
customerName: string
customerEmail: string
tableName: string
date: string
startTime: string
endTime: string
partySize: number
onClose: () => void
}
function FlameBadgeIcon() {
return (
<svg className="reservation-confirmed-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>
)
}
export function ReservationConfirmedModal({
open,
customerName,
customerEmail,
tableName,
date,
startTime,
endTime,
partySize,
onClose,
}: Props) {
const closeRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
if (!open) return
closeRef.current?.focus()
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [open, onClose])
if (!open) return null
return (
<div className="confirm-dialog-backdrop" onClick={onClose}>
<div
className="confirm-dialog reservation-confirmed-dialog"
role="alertdialog"
aria-modal="true"
aria-labelledby="reservation-confirmed-title"
onClick={(event) => event.stopPropagation()}
>
<FlameBadgeIcon />
<h2 id="reservation-confirmed-title" className="reservation-confirmed-title">
Tisch reserviert!
</h2>
<p className="reservation-confirmed-message">
Bis bald, {customerName}! Wir halten Ihnen den Platz warm und legen schon mal Kohle nach.
</p>
<dl className="detail-list">
<div>
<dt>Tisch</dt>
<dd>{tableName}</dd>
</div>
<div>
<dt>Datum</dt>
<dd>{date}</dd>
</div>
<div>
<dt>Uhrzeit</dt>
<dd>
{startTime} – {endTime}
</dd>
</div>
<div>
<dt>Personen</dt>
<dd>{partySize}</dd>
</div>
</dl>
<p className="reservation-confirmed-footnote">
Eine Bestätigung ist unterwegs an <strong>{customerEmail}</strong>. Dort finden Sie auch einen
Link, falls sich doch noch etwas ändert.
</p>
<div className="confirm-dialog-actions">
<button ref={closeRef} type="button" className="btn btn-primary" onClick={onClose}>
Alles klar
</button>
</div>
</div>
</div>
)
}
@@ -14,6 +14,9 @@ type Props = {
// made — both configurable restaurant settings, falling back to sane defaults.
intervalMinutes?: number
minLeadTimeMinutes?: number
// How long a reservation booked at a slot occupies the table — a slot too close
// to closing time to fit the whole reservation is left out of the list entirely.
durationMinutes?: number
// How far into the future a booking may be made. Undefined leaves the horizon open,
// since not every caller of this field needs the restriction.
maxLeadTimeDays?: number
@@ -101,6 +104,7 @@ export function ReservationDateTimeField({
bookedTimes = [],
intervalMinutes = RESERVATION_INTERVAL_MINUTES,
minLeadTimeMinutes = 0,
durationMinutes = RESERVATION_INTERVAL_MINUTES,
maxLeadTimeDays,
}: Props) {
const { date, time } = splitValue(value)
@@ -189,7 +193,7 @@ export function ReservationDateTimeField({
// otherwise) can't slip through with a leftover, still-"valid"-looking time.
function optionsForDate(dateStr: string) {
if (!dateStr || dateStr < today || (latestDate !== null && dateStr > latestDate)) return []
return generateTimeOptions(getOpeningRangesForWeekday(getWeekday(dateStr)), intervalMinutes)
return generateTimeOptions(getOpeningRangesForWeekday(getWeekday(dateStr)), intervalMinutes, durationMinutes)
}
function isSelectableDate(dateStr: string) {
@@ -1,136 +0,0 @@
import { useEffect } from 'react'
import { ActionMenu } from '#/components/action-menu'
import { RESERVATION_STATUS_LABELS, type ReservationStatus, type getReservations } from '#/lib/reservations'
type ReservationRow = Awaited<ReturnType<typeof getReservations>>[number]
type Props = {
reservation: ReservationRow | null
onClose: () => void
onStatusChange: (status: ReservationStatus) => void
isUpdatingStatus?: boolean
onEdit: () => void
onDelete: () => void
isDeleting?: boolean
}
const STATUS_OPTIONS = Object.keys(RESERVATION_STATUS_LABELS) as ReservationStatus[]
const dateTimeFormatter = new Intl.DateTimeFormat('de-DE', {
dateStyle: 'full',
timeStyle: 'short',
})
export function ReservationDetailDialog({
reservation,
onClose,
onStatusChange,
isUpdatingStatus,
onEdit,
onDelete,
isDeleting,
}: Props) {
useEffect(() => {
if (!reservation) return
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [reservation, onClose])
if (!reservation) return null
return (
<div className="confirm-dialog-backdrop" onClick={onClose}>
<div
className="confirm-dialog reservation-detail-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="reservation-detail-title"
onClick={(event) => event.stopPropagation()}
>
<div className="confirm-dialog-header">
<h2 id="reservation-detail-title" className="confirm-dialog-title">
Reservierung von {reservation.customerName}
</h2>
<ActionMenu label={`Aktionen für Reservierung von ${reservation.customerName}`}>
<button type="button" className="btn btn-secondary" onClick={onEdit}>
Bearbeiten
</button>
<button type="button" className="btn btn-danger" disabled={isDeleting} onClick={onDelete}>
{isDeleting ? 'Löschen …' : 'Löschen'}
</button>
</ActionMenu>
</div>
<div className="status-options">
{STATUS_OPTIONS.map((status) => (
<button
key={status}
type="button"
className={`status-badge status-option status-${status}${
reservation.status === status ? ' is-active' : ''
}`}
disabled={isUpdatingStatus}
onClick={() => onStatusChange(status)}
>
{RESERVATION_STATUS_LABELS[status]}
</button>
))}
</div>
<dl className="detail-list">
{reservation.tableNumber !== null && (
<div>
<dt>Tischnummer</dt>
<dd>{reservation.tableNumber}</dd>
</div>
)}
<div>
<dt>Tisch</dt>
<dd>{reservation.tableName}</dd>
</div>
<div>
<dt>Datum &amp; Uhrzeit</dt>
<dd>{dateTimeFormatter.format(new Date(reservation.reservationTime))}</dd>
</div>
<div>
<dt>Personen</dt>
<dd>{reservation.partySize}</dd>
</div>
<div>
<dt>E-Mail</dt>
<dd>
<a href={`mailto:${reservation.customerEmail}`}>{reservation.customerEmail}</a>
</dd>
</div>
{reservation.customerPhone && (
<div>
<dt>Telefon</dt>
<dd>
<a href={`tel:${reservation.customerPhone}`}>{reservation.customerPhone}</a>
</dd>
</div>
)}
</dl>
{reservation.message && (
<div className="detail-message">
<span className="detail-message-label">Nachricht</span>
<p>{reservation.message}</p>
</div>
)}
<div className="confirm-dialog-actions">
<button type="button" className="btn btn-secondary" onClick={onClose}>
Schließen
</button>
</div>
</div>
</div>
)
}
+456
View File
@@ -0,0 +1,456 @@
import { useEffect, useState } from 'react'
import { ActionMenu } from '#/components/action-menu'
import { ReservationDateTimeField } from '#/components/reservation-datetime-field'
import { formatDateOnly, formatDateTimeLocal } from '#/lib/datetime'
import { generateTimeOptions, getNextAvailableSlot, getOpeningRangesForWeekday } from '#/lib/opening-hours'
import {
RESERVATION_STATUS_LABELS,
type ReservationStatus,
type getReservations,
type getTables,
} from '#/lib/reservations'
import { type getReservationSettings } from '#/lib/settings'
type ReservationRow = Awaited<ReturnType<typeof getReservations>>[number]
type TableRow = Awaited<ReturnType<typeof getTables>>[number]
type SettingsRow = Awaited<ReturnType<typeof getReservationSettings>>
export type ReservationPayload = {
tableId: number
customerName: string
customerEmail: string
customerPhone: string
message: string
partySize: number
reservationTime: string
}
type FormState = {
tableId: number | string
customerName: string
customerEmail: string
customerPhone: string
message: string
partySize: number
reservationTime: string
}
type Props = {
// `null` reservation with `open: true` is "create" mode.
open: boolean
reservation: ReservationRow | null
tables: TableRow[]
settings: SettingsRow
reservations: ReservationRow[]
onClose: () => void
onCreate: (payload: ReservationPayload) => Promise<void>
onUpdate: (id: number, payload: ReservationPayload) => Promise<void>
onStatusChange: (status: ReservationStatus) => void
isUpdatingStatus?: boolean
onDelete: () => void
statusError?: string | null
}
const STATUS_OPTIONS = Object.keys(RESERVATION_STATUS_LABELS) as ReservationStatus[]
const dateFormatter = new Intl.DateTimeFormat('de-DE', {
dateStyle: 'full',
})
const timeFormatter = new Intl.DateTimeFormat('de-DE', {
timeStyle: 'short',
})
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
// Also used as the phone input's `pattern` attribute below, so the two stay in sync.
const PHONE_PATTERN_SOURCE = String.raw`[+]?[0-9()\-.\s]{6,30}`
const PHONE_PATTERN = new RegExp(`^${PHONE_PATTERN_SOURCE}$`)
function emptyForm(defaultTableId: number | string, settings: SettingsRow): FormState {
const slot = getNextAvailableSlot(
new Date(Date.now() + settings.minLeadTimeMinutes * 60_000),
settings.reservationIntervalMinutes,
settings.reservationDurationMinutes,
)
return {
tableId: defaultTableId,
customerName: '',
customerEmail: '',
customerPhone: '',
message: '',
partySize: 2,
reservationTime: slot ? `${slot.date}T${slot.time}` : '',
}
}
function formFromReservation(reservation: ReservationRow): FormState {
return {
tableId: reservation.tableId,
customerName: reservation.customerName,
customerEmail: reservation.customerEmail,
customerPhone: reservation.customerPhone ?? '',
message: reservation.message ?? '',
partySize: reservation.partySize,
reservationTime: formatDateTimeLocal(new Date(reservation.reservationTime)),
}
}
// Rendered with a `key` (see staff.index.tsx) so a fresh instance mounts every time
// the dialog opens — that's what lets this component safely derive its initial
// state from props once, instead of syncing it via effects.
export function ReservationDialog({
open,
reservation,
tables,
settings,
reservations,
onClose,
onCreate,
onUpdate,
onStatusChange,
isUpdatingStatus,
onDelete,
statusError,
}: Props) {
const [isEditing, setIsEditing] = useState(reservation === null)
const [form, setForm] = useState<FormState>(() =>
reservation ? formFromReservation(reservation) : emptyForm(tables[0]?.id ?? '', settings),
)
const [error, setError] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [onClose])
if (!open) return null
// Other reservations already sitting on the currently selected table + date, so the
// time picker can block them instead of letting staff pick a slot the server will
// just reject as double-booked. A reservation occupies its whole configured
// duration, not just its exact start time, so every interval-grid slot that falls
// inside that window counts as taken too. `minLeadTimeMinutes` is reused here as a
// table turnover buffer, extending the taken window on both sides so back-to-back
// bookings on the same table always leave that much of a gap.
const [selectedDate] = form.reservationTime.split('T')
const otherReservationsOnSelection = reservations.filter(
(r) =>
r.id !== reservation?.id &&
String(r.tableId) === String(form.tableId) &&
formatDateOnly(new Date(r.reservationTime)) === selectedDate,
)
const turnoverBufferMs = settings.minLeadTimeMinutes * 60_000
const bookedTimesForForm = selectedDate
? generateTimeOptions(
getOpeningRangesForWeekday(new Date(`${selectedDate}T00:00`).getDay()),
settings.reservationIntervalMinutes,
settings.reservationDurationMinutes,
).filter((option) => {
const slotStart = new Date(`${selectedDate}T${option}`).getTime()
return otherReservationsOnSelection.some((r) => {
const otherStart = new Date(r.reservationTime).getTime() - turnoverBufferMs
const otherEnd = otherStart + r.durationMinutes * 60_000 + 2 * turnoverBufferMs
return slotStart >= otherStart && slotStart < otherEnd
})
})
: []
// Mirrors the form fields' own `required`/`type`/`pattern`/`min`/`max` constraints,
// so the submit button is disabled until they'd all actually pass — instead of only
// catching an invalid table/time on submit and leaving everything else to the
// browser's native validation popup.
const isFormValid =
Boolean(form.tableId) &&
form.customerName.trim().length > 0 &&
EMAIL_PATTERN.test(form.customerEmail) &&
PHONE_PATTERN.test(form.customerPhone) &&
form.partySize >= 1 &&
form.partySize <= 20 &&
Boolean(form.reservationTime.split('T')[1])
function startEditing() {
if (reservation) setForm(formFromReservation(reservation))
setError(null)
setIsEditing(true)
}
function cancelEditing() {
if (!reservation) {
onClose()
return
}
setForm(formFromReservation(reservation))
setError(null)
setIsEditing(false)
}
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
setError(null)
if (!form.tableId) {
setError('Bitte wählen Sie einen Tisch aus')
return
}
if (!form.reservationTime.split('T')[1]) {
setError('Bitte wählen Sie eine gültige Uhrzeit aus')
return
}
setIsSubmitting(true)
try {
const payload: ReservationPayload = {
tableId: Number(form.tableId),
customerName: form.customerName,
customerEmail: form.customerEmail,
customerPhone: form.customerPhone,
message: form.message,
partySize: Number(form.partySize),
reservationTime: new Date(form.reservationTime).toISOString(),
}
if (reservation) {
await onUpdate(reservation.id, payload)
} else {
await onCreate(payload)
}
onClose()
} catch (err) {
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
} finally {
setIsSubmitting(false)
}
}
const title = reservation
? isEditing
? `Reservierung von ${reservation.customerName} bearbeiten`
: `Reservierung von ${reservation.customerName}`
: 'Neue Reservierung'
return (
<div className="confirm-dialog-backdrop" onClick={onClose}>
<div
className={`confirm-dialog reservation-detail-dialog${isEditing ? ' reservation-dialog-editing' : ''}`}
role="dialog"
aria-modal="true"
aria-labelledby="reservation-dialog-title"
onClick={(event) => event.stopPropagation()}
>
<div className="confirm-dialog-header">
<h2 id="reservation-dialog-title" className="confirm-dialog-title">
{title}
</h2>
{reservation && !isEditing && (
<div className="confirm-dialog-header-actions">
<ActionMenu label={`Weitere Aktionen für Reservierung von ${reservation.customerName}`}>
<button type="button" className="btn btn-danger" onClick={onDelete}>
Löschen
</button>
</ActionMenu>
</div>
)}
</div>
{isEditing ? (
<form className="reservation-form reservation-form-dialog" onSubmit={handleSubmit}>
<label>
Tisch
<select
value={form.tableId}
onChange={(e) => setForm((f) => ({ ...f, tableId: 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={form.customerName}
onChange={(e) => setForm((f) => ({ ...f, customerName: e.target.value }))}
required
/>
</label>
<label>
E-Mail
<input
type="email"
value={form.customerEmail}
onChange={(e) => setForm((f) => ({ ...f, customerEmail: e.target.value }))}
required
/>
</label>
</div>
<div className="form-row">
<label>
Telefon
<input
type="tel"
inputMode="tel"
pattern={PHONE_PATTERN_SOURCE}
title="Nur Ziffern, Leerzeichen und + ( ) - ."
value={form.customerPhone}
onChange={(e) => setForm((f) => ({ ...f, customerPhone: e.target.value }))}
required
/>
</label>
<label>
Anzahl Personen
<input
type="number"
inputMode="numeric"
min={1}
max={20}
value={form.partySize}
onChange={(e) => setForm((f) => ({ ...f, partySize: Number(e.target.value) }))}
required
/>
</label>
</div>
<label>
Datum &amp; Uhrzeit
<ReservationDateTimeField
value={form.reservationTime}
onChange={(reservationTime) => setForm((f) => ({ ...f, reservationTime }))}
bookedTimes={bookedTimesForForm}
intervalMinutes={settings.reservationIntervalMinutes}
minLeadTimeMinutes={settings.minLeadTimeMinutes}
durationMinutes={settings.reservationDurationMinutes}
maxLeadTimeDays={settings.maxLeadTimeDays}
/>
</label>
<label>
Nachricht (optional)
<textarea
rows={2}
maxLength={500}
value={form.message}
onChange={(e) => setForm((f) => ({ ...f, message: e.target.value }))}
/>
</label>
{error && <p className="message message-error">{error}</p>}
<div className="confirm-dialog-actions">
<button type="button" className="btn btn-secondary" onClick={cancelEditing}>
Abbrechen
</button>
<button type="submit" className="btn btn-primary" disabled={isSubmitting || !isFormValid}>
{isSubmitting ? 'Wird gespeichert …' : reservation ? 'Speichern' : 'Anlegen'}
</button>
</div>
</form>
) : (
reservation && (
<>
<div className="status-options">
{STATUS_OPTIONS.map((status) => (
<button
key={status}
type="button"
className={`status-badge status-option status-${status}${
reservation.status === status ? ' is-active' : ''
}`}
disabled={isUpdatingStatus}
onClick={() => onStatusChange(status)}
>
{RESERVATION_STATUS_LABELS[status]}
</button>
))}
</div>
<dl className="detail-list">
{reservation.tableNumber !== null && (
<div>
<dt>Tischnummer</dt>
<dd>{reservation.tableNumber}</dd>
</div>
)}
<div>
<dt>Tisch</dt>
<dd>{reservation.tableName}</dd>
</div>
<div>
<dt>Datum</dt>
<dd>{dateFormatter.format(new Date(reservation.reservationTime))}</dd>
</div>
<div>
<dt>Start</dt>
<dd>{timeFormatter.format(new Date(reservation.reservationTime))}</dd>
</div>
<div>
<dt>Ende</dt>
<dd>
{timeFormatter.format(
new Date(new Date(reservation.reservationTime).getTime() + reservation.durationMinutes * 60_000),
)}
</dd>
</div>
<div>
<dt>Personen</dt>
<dd>{reservation.partySize}</dd>
</div>
<div>
<dt>E-Mail</dt>
<dd>
<a href={`mailto:${reservation.customerEmail}`}>{reservation.customerEmail}</a>
</dd>
</div>
{reservation.customerPhone && (
<div>
<dt>Telefon</dt>
<dd>
<a href={`tel:${reservation.customerPhone}`}>{reservation.customerPhone}</a>
</dd>
</div>
)}
</dl>
{reservation.message && (
<div className="detail-message">
<span className="detail-message-label">Nachricht</span>
<p>{reservation.message}</p>
</div>
)}
{statusError && <p className="message message-error">{statusError}</p>}
<div className="confirm-dialog-actions confirm-dialog-actions-split">
<button type="button" className="btn btn-ghost" onClick={startEditing}>
Bearbeiten
</button>
<button type="button" className="btn btn-secondary" onClick={onClose}>
Schließen
</button>
</div>
</>
)
)}
</div>
</div>
)
}
+67
View File
@@ -0,0 +1,67 @@
import { useEffect, useRef } from 'react'
type Props = {
open: boolean
title: string
message: React.ReactNode
onClose: () => void
}
function CheckIcon() {
return (
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="1.6" />
<path
d="m8.2 12.3 2.6 2.6 5-5.4"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
export function SuccessModal({ open, title, message, onClose }: Props) {
const closeRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
if (!open) return
closeRef.current?.focus()
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [open, onClose])
if (!open) return null
return (
<div className="confirm-dialog-backdrop" onClick={onClose}>
<div
className="confirm-dialog success-modal"
role="alertdialog"
aria-modal="true"
aria-labelledby="success-modal-title"
onClick={(event) => event.stopPropagation()}
>
<div className="success-modal-icon">
<CheckIcon />
</div>
<h2 id="success-modal-title" className="confirm-dialog-title">
{title}
</h2>
<p className="confirm-dialog-message">{message}</p>
<div className="confirm-dialog-actions">
<button ref={closeRef} type="button" className="btn btn-primary" onClick={onClose}>
OK
</button>
</div>
</div>
</div>
)
}
+15 -4
View File
@@ -57,12 +57,16 @@ export function isOnReservationInterval(date: Date, intervalMinutes: number = RE
return date.getUTCMinutes() % intervalMinutes === 0 && date.getUTCSeconds() === 0
}
export function isWithinOpeningHours(date: Date) {
// `durationMinutes` requires the whole reservation — not just its start — to fit
// before closing, so a slot too close to close time is rejected outright.
export function isWithinOpeningHours(date: Date, durationMinutes = 0) {
const { weekday, minutesSinceMidnight } = getBerlinTimeParts(date)
const ranges = OPENING_HOURS_BY_WEEKDAY[weekday] ?? []
return ranges.some(
(range) => minutesSinceMidnight >= toMinutes(range.start) && minutesSinceMidnight < toMinutes(range.end),
(range) =>
minutesSinceMidnight >= toMinutes(range.start) &&
minutesSinceMidnight + durationMinutes <= toMinutes(range.end),
)
}
@@ -89,9 +93,15 @@ export function getOpeningRangesForWeekday(weekday: number): OpeningRange[] {
return OPENING_HOURS_BY_WEEKDAY[weekday] ?? []
}
// `durationMinutes` (how long a reservation booked at a slot would occupy the
// table) defaults to `intervalMinutes` so a bare call still behaves sensibly —
// but every real caller should pass the restaurant's actual reservation
// duration, so a slot too close to closing time to fit the whole reservation
// is excluded rather than offered and then silently running past close.
export function generateTimeOptions(
ranges: OpeningRange[],
intervalMinutes: number = RESERVATION_INTERVAL_MINUTES,
durationMinutes: number = intervalMinutes,
): string[] {
const options: string[] = []
@@ -99,7 +109,7 @@ export function generateTimeOptions(
const startMinutes = toMinutes(range.start)
const endMinutes = toMinutes(range.end)
for (let minutes = startMinutes; minutes < endMinutes; minutes += intervalMinutes) {
for (let minutes = startMinutes; minutes + durationMinutes <= endMinutes; minutes += intervalMinutes) {
const hours = Math.floor(minutes / 60)
const remainder = minutes % 60
options.push(`${String(hours).padStart(2, '0')}:${String(remainder).padStart(2, '0')}`)
@@ -112,13 +122,14 @@ export function generateTimeOptions(
export function getNextAvailableSlot(
from: Date,
intervalMinutes: number = RESERVATION_INTERVAL_MINUTES,
durationMinutes: number = intervalMinutes,
): { date: string; time: string } | null {
for (let dayOffset = 0; dayOffset < 14; dayOffset++) {
const day = new Date(from)
day.setDate(from.getDate() + dayOffset)
const dateStr = formatDateOnly(day)
const options = generateTimeOptions(getOpeningRangesForWeekday(day.getDay()), intervalMinutes)
const options = generateTimeOptions(getOpeningRangesForWeekday(day.getDay()), intervalMinutes, durationMinutes)
for (const time of options) {
const slot = new Date(`${dateStr}T${time}`)
+28 -5
View File
@@ -29,6 +29,7 @@ function assertValidReservationSlot(
intervalMinutes: number,
minLeadTimeMinutes: number,
maxLeadTimeDays: number,
durationMinutes: number,
) {
const earliestAllowed = Date.now() + minLeadTimeMinutes * 60_000
@@ -50,28 +51,33 @@ function assertValidReservationSlot(
throw new Error(`Reservierungen sind nur im ${intervalMinutes}-Minuten-Raster möglich`)
}
if (!isWithinOpeningHours(reservationTime)) {
throw new Error('Zu dieser Zeit haben wir leider geschlossen')
if (!isWithinOpeningHours(reservationTime, durationMinutes)) {
throw new Error('Zu dieser Zeit reicht die verbleibende Zeit bis zum Schließen nicht mehr aus')
}
}
// A table is occupied for its own configured duration, not just the exact minute it
// was booked for — so overlapping bookings (not just identical ones) are conflicts.
// Each reservation's own stored duration is used (not the current setting), since an
// older booking keeps whatever duration was in effect when it was made.
// older booking keeps whatever duration was in effect when it was made. `bufferMinutes`
// (the current minLeadTimeMinutes setting, reused here as a table turnover gap) is
// added on both sides of the existing reservation, so back-to-back bookings need at
// least that much breathing room between one guest leaving and the next arriving.
async function findOverlappingReservation(
tableId: number,
start: Date,
durationMinutes: number,
bufferMinutes: number,
excludeId?: number,
) {
const end = start.getTime() + durationMinutes * 60_000
const bufferMs = bufferMinutes * 60_000
const candidates = await db.select().from(reservations).where(eq(reservations.tableId, tableId))
return candidates.find((r) => {
if (excludeId !== undefined && r.id === excludeId) return false
const existingStart = r.reservationTime.getTime()
const existingEnd = existingStart + r.durationMinutes * 60_000
const existingStart = r.reservationTime.getTime() - bufferMs
const existingEnd = existingStart + r.durationMinutes * 60_000 + 2 * bufferMs
return existingStart < end && start.getTime() < existingEnd
})
}
@@ -217,6 +223,19 @@ export const getReservations = createServerFn({ method: 'GET' }).handler(async (
.orderBy(reservations.reservationTime)
})
// Unauthenticated — powers the public booking form's time picker, so it only
// exposes what's needed to block already-taken slots (table + time span), never
// any customer data.
export const getReservationAvailability = createServerFn({ method: 'GET' }).handler(async () => {
return db
.select({
tableId: reservations.tableId,
reservationTime: reservations.reservationTime,
durationMinutes: reservations.durationMinutes,
})
.from(reservations)
})
const createReservationSchema = z.object({
tableId: z.number().int().positive(),
customerName: z.string().trim().min(1, 'Name is required').max(120),
@@ -245,6 +264,7 @@ export const createReservation = createServerFn({ method: 'POST' })
settings.reservationIntervalMinutes,
settings.minLeadTimeMinutes,
settings.maxLeadTimeDays,
settings.reservationDurationMinutes,
)
const [table] = await db
@@ -264,6 +284,7 @@ export const createReservation = createServerFn({ method: 'POST' })
data.tableId,
reservationTime,
settings.reservationDurationMinutes,
settings.minLeadTimeMinutes,
)
if (conflict) {
@@ -314,6 +335,7 @@ export const updateReservation = createServerFn({ method: 'POST' })
settings.reservationIntervalMinutes,
settings.minLeadTimeMinutes,
settings.maxLeadTimeDays,
settings.reservationDurationMinutes,
)
const [table] = await db
@@ -333,6 +355,7 @@ export const updateReservation = createServerFn({ method: 'POST' })
data.tableId,
reservationTime,
settings.reservationDurationMinutes,
settings.minLeadTimeMinutes,
data.id,
)
+5
View File
@@ -26,6 +26,11 @@ export const Route = createRootRoute({
rel: 'stylesheet',
href: appCss,
},
{
rel: 'icon',
href: '/favicon.svg',
type: 'image/svg+xml',
},
],
}),
shellComponent: RootDocument,
+1 -1
View File
@@ -57,7 +57,7 @@ function Impressum() {
bedürfen der schriftlichen Zustimmung des jeweiligen Erstellers.
</p>
<h2>Datenschutz</h2>
<h2 id="datenschutz">Datenschutz</h2>
<p>
Verantwortlich für die Verarbeitung personenbezogener Daten auf dieser Website ist die
oben genannte Drenica &amp; Mousa GbR.
+137 -24
View File
@@ -1,39 +1,57 @@
import { useState } from 'react'
import { createFileRoute, useRouter } from '@tanstack/react-router'
import { PdfMenu } from '#/components/pdf-menu'
import { ReservationConfirmedModal } from '#/components/reservation-confirmed-modal'
import { ReservationDateTimeField } from '#/components/reservation-datetime-field'
import { SiteFooter } from '#/components/site-footer'
import { SiteHeader } from '#/components/site-header'
import { getNextAvailableSlot, getOpeningHoursForDisplay, getTodayLabelBerlin } from '#/lib/opening-hours'
import { createReservation, getTables } from '#/lib/reservations'
import { formatDateOnly } from '#/lib/datetime'
import {
generateTimeOptions,
getNextAvailableSlot,
getOpeningHoursForDisplay,
getOpeningRangesForWeekday,
getTodayLabelBerlin,
} from '#/lib/opening-hours'
import { createReservation, getReservationAvailability, getTables } from '#/lib/reservations'
import { getReservationSettings } from '#/lib/settings'
export const Route = createFileRoute('/')({
component: Home,
loader: async () => {
const [tables, settings] = await Promise.all([getTables(), getReservationSettings()])
return { tables, settings }
const [tables, settings, availability] = await Promise.all([
getTables(),
getReservationSettings(),
getReservationAvailability(),
])
return { tables, settings, availability }
},
})
const OPENING_HOURS = getOpeningHoursForDisplay()
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>
)
const confirmedDateFormatter = new Intl.DateTimeFormat('de-DE', { dateStyle: 'full' })
const confirmedTimeFormatter = new Intl.DateTimeFormat('de-DE', { timeStyle: 'short' })
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
// Also used as the phone input's `pattern` attribute below, so the two stay in sync.
const PHONE_PATTERN_SOURCE = String.raw`[+]?[0-9()\-.\s]{6,30}`
const PHONE_PATTERN = new RegExp(`^${PHONE_PATTERN_SOURCE}$`)
type ConfirmedReservation = {
customerName: string
customerEmail: string
tableName: string
date: string
startTime: string
endTime: string
partySize: number
}
function Home() {
const router = useRouter()
const { tables, settings } = Route.useLoaderData()
const { tables, settings, availability } = Route.useLoaderData()
const [tableId, setTableId] = useState<number | string>(tables[0]?.id ?? '')
const [customerName, setCustomerName] = useState('')
@@ -41,24 +59,62 @@ function Home() {
const [customerPhone, setCustomerPhone] = useState('')
const [message, setMessage] = useState('')
const [partySize, setPartySize] = useState(2)
const [acceptsTerms, setAcceptsTerms] = useState(false)
const [reservationTime, setReservationTime] = useState(() => {
const slot = getNextAvailableSlot(
new Date(Date.now() + settings.minLeadTimeMinutes * 60_000),
settings.reservationIntervalMinutes,
settings.reservationDurationMinutes,
)
return slot ? `${slot.date}T${slot.time}` : ''
})
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [confirmedReservation, setConfirmedReservation] = useState<ConfirmedReservation | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const todayName = getTodayLabelBerlin()
const selectedTable = tables.find((table) => String(table.id) === String(tableId))
const [selectedDate] = reservationTime.split('T')
const otherReservationsOnSelection = availability.filter(
(r) => String(r.tableId) === String(tableId) && formatDateOnly(new Date(r.reservationTime)) === selectedDate,
)
// `minLeadTimeMinutes` is reused here as a table turnover buffer, extending each
// existing reservation's taken window on both sides so back-to-back bookings on the
// same table always leave that much of a gap.
const turnoverBufferMs = settings.minLeadTimeMinutes * 60_000
const bookedTimesForForm = selectedDate
? generateTimeOptions(
getOpeningRangesForWeekday(new Date(`${selectedDate}T00:00`).getDay()),
settings.reservationIntervalMinutes,
settings.reservationDurationMinutes,
).filter((option) => {
const slotStart = new Date(`${selectedDate}T${option}`).getTime()
return otherReservationsOnSelection.some((r) => {
const otherStart = new Date(r.reservationTime).getTime() - turnoverBufferMs
const otherEnd = otherStart + r.durationMinutes * 60_000 + 2 * turnoverBufferMs
return slotStart >= otherStart && slotStart < otherEnd
})
})
: []
// Mirrors the fields' own `required`/`type`/`pattern`/`min`/`max` constraints, so
// the submit button is disabled until they'd all actually pass — instead of only
// catching a missing table/time/consent on submit and leaving everything else to
// the browser's native validation popup.
const isFormValid =
Boolean(tableId) &&
customerName.trim().length > 0 &&
EMAIL_PATTERN.test(customerEmail) &&
PHONE_PATTERN.test(customerPhone) &&
partySize >= 1 &&
partySize <= 20 &&
Boolean(reservationTime.split('T')[1]) &&
acceptsTerms
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
setError(null)
setSuccess(null)
if (!tableId) {
setError('Bitte wählen Sie einen Tisch aus')
@@ -70,9 +126,14 @@ function Home() {
return
}
if (!acceptsTerms) {
setError('Bitte stimmen Sie der Verarbeitung Ihrer Daten zu')
return
}
setIsSubmitting(true)
try {
await createReservation({
const created = await createReservation({
data: {
tableId: Number(tableId),
customerName,
@@ -84,12 +145,23 @@ function Home() {
},
})
setSuccess('Vielen Dank! Ihre Reservierung ist bestätigt.')
const start = new Date(created.reservationTime)
const end = new Date(start.getTime() + created.durationMinutes * 60_000)
setConfirmedReservation({
customerName: created.customerName,
customerEmail: created.customerEmail,
tableName: selectedTable?.name ?? '',
date: confirmedDateFormatter.format(start),
startTime: confirmedTimeFormatter.format(start),
endTime: confirmedTimeFormatter.format(end),
partySize: created.partySize,
})
setCustomerName('')
setCustomerEmail('')
setCustomerPhone('')
setMessage('')
setPartySize(2)
setAcceptsTerms(false)
await router.invalidate()
} catch (err) {
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
@@ -103,7 +175,7 @@ function Home() {
<SiteHeader />
<section className="hero" id="top">
<FlameIcon />
<img className="hero-flame" src="/logo.svg" alt="" aria-hidden="true" />
<h1>
Vom Feuer verwöhnt, <span>mit Liebe serviert</span>
</h1>
@@ -143,6 +215,16 @@ function Home() {
</div>
</section>
<section className="section" id="speisekarte">
<div className="section-grid">
<div>
<span className="section-kicker">Was uns schmeckt</span>
<h2 className="section-heading">Speisekarte</h2>
</div>
<PdfMenu src="/menu.pdf" />
</div>
</section>
<section className="section" id="reservieren">
<div className="section-grid">
<div>
@@ -223,7 +305,7 @@ function Home() {
<input
type="tel"
inputMode="tel"
pattern="[+]?[0-9()\-.\s]{6,30}"
pattern={PHONE_PATTERN_SOURCE}
title="Nur Ziffern, Leerzeichen und + ( ) - ."
value={customerPhone}
onChange={(e) => setCustomerPhone(e.target.value)}
@@ -250,8 +332,10 @@ function Home() {
<ReservationDateTimeField
value={reservationTime}
onChange={setReservationTime}
bookedTimes={bookedTimesForForm}
intervalMinutes={settings.reservationIntervalMinutes}
minLeadTimeMinutes={settings.minLeadTimeMinutes}
durationMinutes={settings.reservationDurationMinutes}
maxLeadTimeDays={settings.maxLeadTimeDays}
/>
</label>
@@ -272,12 +356,27 @@ function Home() {
<a href="#oeffnungszeiten">Öffnungszeiten</a>.
</p>
<button type="submit" disabled={isSubmitting}>
<label className="radio-option consent-option">
<input
type="checkbox"
checked={acceptsTerms}
onChange={(e) => setAcceptsTerms(e.target.checked)}
required
/>
<span>
Ich stimme der Verarbeitung meiner Daten gemäß der{' '}
<a href="/impressum#datenschutz" target="_blank" rel="noopener noreferrer">
Datenschutzerklärung
</a>{' '}
zu.
</span>
</label>
<button type="submit" disabled={isSubmitting || !isFormValid}>
{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>
@@ -311,6 +410,20 @@ function Home() {
</main>
<SiteFooter />
{confirmedReservation && (
<ReservationConfirmedModal
open
customerName={confirmedReservation.customerName}
customerEmail={confirmedReservation.customerEmail}
tableName={confirmedReservation.tableName}
date={confirmedReservation.date}
startTime={confirmedReservation.startTime}
endTime={confirmedReservation.endTime}
partySize={confirmedReservation.partySize}
onClose={() => setConfirmedReservation(null)}
/>
)}
</>
)
}
+106 -277
View File
@@ -3,14 +3,12 @@ import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-rout
import { ConfirmDialog } from '#/components/confirm-dialog'
import { ReservationCharts } from '#/components/reservation-charts'
import { ReservationDateTimeField } from '#/components/reservation-datetime-field'
import { ReservationDetailDialog } from '#/components/reservation-detail-dialog'
import { ReservationDialog, type ReservationPayload } from '#/components/reservation-dialog'
import { StaffTabs } from '#/components/staff-tabs'
import { StatusBadge } from '#/components/status-badge'
import { SuccessModal } from '#/components/success-modal'
import { getCurrentStaffUser } from '#/lib/auth'
import { authClient } from '#/lib/auth-client'
import { formatDateOnly, formatDateTimeLocal } from '#/lib/datetime'
import { generateTimeOptions, getNextAvailableSlot, getOpeningRangesForWeekday } from '#/lib/opening-hours'
import {
RESERVATION_STATUS_LABELS,
createReservation,
@@ -42,8 +40,11 @@ export const Route = createFileRoute('/staff/')({
},
})
const dateTimeFormatter = new Intl.DateTimeFormat('de-DE', {
const dateFormatter = new Intl.DateTimeFormat('de-DE', {
dateStyle: 'medium',
})
const timeFormatter = new Intl.DateTimeFormat('de-DE', {
timeStyle: 'short',
})
@@ -55,53 +56,23 @@ function isSameDay(a: Date, b: Date) {
type ReservationRow = Awaited<ReturnType<typeof getReservations>>[number]
type FormState = {
tableId: number | string
customerName: string
customerEmail: string
customerPhone: string
message: string
partySize: number
reservationTime: string
}
type SettingsRow = Awaited<ReturnType<typeof getReservationSettings>>
function emptyForm(defaultTableId: number | string, settings: SettingsRow): FormState {
const slot = getNextAvailableSlot(
new Date(Date.now() + settings.minLeadTimeMinutes * 60_000),
settings.reservationIntervalMinutes,
)
return {
tableId: defaultTableId,
customerName: '',
customerEmail: '',
customerPhone: '',
message: '',
partySize: 2,
reservationTime: slot ? `${slot.date}T${slot.time}` : '',
}
}
function StaffDashboard() {
const router = useRouter()
const { staffUser, reservations, tables, settings } = Route.useLoaderData()
const [editingId, setEditingId] = useState<number | null>(null)
const [form, setForm] = useState<FormState>(() => emptyForm(tables[0]?.id ?? '', settings))
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [deletingId, setDeletingId] = useState<number | null>(null)
const [successModal, setSuccessModal] = useState<{ title: string; message: string } | null>(null)
const [pendingDelete, setPendingDelete] = useState<ReservationRow | null>(null)
const [viewingReservation, setViewingReservation] = useState<ReservationRow | null>(null)
const [isCreating, setIsCreating] = useState(false)
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [tableFilter, setTableFilter] = useState('all')
const [timeFilter, setTimeFilter] = useState<'all' | 'today' | 'upcoming' | 'past'>('all')
const [timeFilter, setTimeFilter] = useState<'all' | 'today' | 'upcoming' | 'past'>('today')
const [statusFilter, setStatusFilter] = useState<'all' | ReservationStatus>('all')
const dialogOpen = viewingReservation !== null || isCreating
const now = new Date()
const todaysReservations = reservations.filter((r) => isSameDay(new Date(r.reservationTime), now))
const guestsToday = todaysReservations.reduce((sum, r) => sum + r.partySize, 0)
@@ -125,96 +96,27 @@ function StaffDashboard() {
return true
})
// Other reservations already sitting on the currently selected table + date, so the
// time picker can block them instead of letting staff pick a slot the server will
// just reject as double-booked. A reservation occupies its whole configured
// duration, not just its exact start time, so every interval-grid slot that falls
// inside that window counts as taken too.
const [selectedDate] = form.reservationTime.split('T')
const otherReservationsOnSelection = reservations.filter(
(r) =>
r.id !== editingId &&
String(r.tableId) === String(form.tableId) &&
formatDateOnly(new Date(r.reservationTime)) === selectedDate,
)
const bookedTimesForForm = selectedDate
? generateTimeOptions(
getOpeningRangesForWeekday(new Date(`${selectedDate}T00:00`).getDay()),
settings.reservationIntervalMinutes,
).filter((option) => {
const slotStart = new Date(`${selectedDate}T${option}`).getTime()
return otherReservationsOnSelection.some((r) => {
const otherStart = new Date(r.reservationTime).getTime()
const otherEnd = otherStart + r.durationMinutes * 60_000
return slotStart >= otherStart && slotStart < otherEnd
})
})
: []
function closeDialog() {
setViewingReservation(null)
setIsCreating(false)
}
function startEdit(reservation: ReservationRow) {
setEditingId(reservation.id)
setForm({
tableId: reservation.tableId,
customerName: reservation.customerName,
customerEmail: reservation.customerEmail,
customerPhone: reservation.customerPhone ?? '',
message: reservation.message ?? '',
partySize: reservation.partySize,
reservationTime: formatDateTimeLocal(new Date(reservation.reservationTime)),
async function handleCreate(payload: ReservationPayload) {
await createReservation({ data: payload })
setSuccessModal({
title: 'Reservierung angelegt',
message: `Die Reservierung für ${payload.customerName} wurde erfolgreich angelegt.`,
})
setError(null)
setSuccess(null)
await router.invalidate()
}
function cancelEdit() {
setEditingId(null)
setForm(emptyForm(tables[0]?.id ?? '', settings))
setError(null)
}
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
setError(null)
setSuccess(null)
if (!form.tableId) {
setError('Bitte wählen Sie einen Tisch aus')
return
}
if (!form.reservationTime.split('T')[1]) {
setError('Bitte wählen Sie eine gültige Uhrzeit aus')
return
}
setIsSubmitting(true)
try {
const payload = {
tableId: Number(form.tableId),
customerName: form.customerName,
customerEmail: form.customerEmail,
customerPhone: form.customerPhone,
message: form.message,
partySize: Number(form.partySize),
reservationTime: new Date(form.reservationTime).toISOString(),
}
if (editingId) {
await updateReservation({ data: { id: editingId, ...payload } })
setSuccess('Reservierung wurde aktualisiert.')
} else {
await createReservation({ data: payload })
setSuccess('Reservierung wurde angelegt.')
}
setEditingId(null)
setForm(emptyForm(tables[0]?.id ?? '', settings))
await router.invalidate()
} catch (err) {
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
} finally {
setIsSubmitting(false)
}
async function handleUpdate(id: number, payload: ReservationPayload) {
await updateReservation({ data: { id, ...payload } })
setSuccessModal({
title: 'Reservierung aktualisiert',
message: `Die Reservierung für ${payload.customerName} wurde erfolgreich aktualisiert.`,
})
await router.invalidate()
}
async function confirmDelete() {
@@ -222,33 +124,27 @@ function StaffDashboard() {
if (!reservation) return
setPendingDelete(null)
setDeletingId(reservation.id)
setError(null)
setSuccess(null)
try {
await deleteReservation({ data: { id: reservation.id } })
if (editingId === reservation.id) {
cancelEdit()
}
setSuccess('Reservierung wurde gelöscht.')
setSuccessModal({
title: 'Reservierung gelöscht',
message: `Die Reservierung von ${reservation.customerName} wurde gelöscht.`,
})
await router.invalidate()
} catch (err) {
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
} finally {
setDeletingId(null)
}
}
async function handleStatusChange(reservation: ReservationRow, status: ReservationStatus) {
setIsUpdatingStatus(true)
setError(null)
setSuccess(null)
try {
await updateReservationStatus({ data: { id: reservation.id, status } })
setViewingReservation((current) => (current ? { ...current, status } : current))
setSuccess(`Status wurde auf „${RESERVATION_STATUS_LABELS[status]}“ geändert.`)
await router.invalidate()
} catch (err) {
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
@@ -286,6 +182,12 @@ function StaffDashboard() {
<main className="page staff-main">
<h1 className="section-heading">Reservierungen</h1>
{!dialogOpen && error && (
<div className="staff-page-messages">
<p className="message message-error">{error}</p>
</div>
)}
<div className="staff-stats">
<div className="stat-card">
<span className="stat-value">{reservations.length}</span>
@@ -307,119 +209,14 @@ function StaffDashboard() {
<ReservationCharts reservations={reservations} tables={tables} />
<section className="staff-form-section">
<h2 className="section-heading staff-subheading">
{editingId ? 'Reservierung bearbeiten' : 'Neue Reservierung'}
</h2>
<form className="reservation-form" onSubmit={handleSubmit}>
<label>
Tisch
<select
value={form.tableId}
onChange={(e) => setForm((f) => ({ ...f, tableId: 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={form.customerName}
onChange={(e) => setForm((f) => ({ ...f, customerName: e.target.value }))}
required
/>
</label>
<label>
E-Mail
<input
type="email"
value={form.customerEmail}
onChange={(e) => setForm((f) => ({ ...f, customerEmail: e.target.value }))}
required
/>
</label>
</div>
<div className="form-row">
<label>
Telefon
<input
type="tel"
inputMode="tel"
pattern="[+]?[0-9()\-.\s]{6,30}"
title="Nur Ziffern, Leerzeichen und + ( ) - ."
value={form.customerPhone}
onChange={(e) => setForm((f) => ({ ...f, customerPhone: e.target.value }))}
required
/>
</label>
<label>
Anzahl Personen
<input
type="number"
inputMode="numeric"
min={1}
max={20}
value={form.partySize}
onChange={(e) => setForm((f) => ({ ...f, partySize: Number(e.target.value) }))}
required
/>
</label>
</div>
<label>
Datum &amp; Uhrzeit
<ReservationDateTimeField
value={form.reservationTime}
onChange={(reservationTime) => setForm((f) => ({ ...f, reservationTime }))}
bookedTimes={bookedTimesForForm}
intervalMinutes={settings.reservationIntervalMinutes}
minLeadTimeMinutes={settings.minLeadTimeMinutes}
maxLeadTimeDays={settings.maxLeadTimeDays}
/>
</label>
<label>
Nachricht (optional)
<textarea
rows={2}
maxLength={500}
value={form.message}
onChange={(e) => setForm((f) => ({ ...f, message: e.target.value }))}
/>
</label>
<div className="form-actions">
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Wird gespeichert …' : editingId ? 'Speichern' : 'Anlegen'}
</button>
{editingId && (
<button type="button" className="btn btn-secondary" onClick={cancelEdit}>
Abbrechen
</button>
)}
</div>
{error && <p className="message message-error">{error}</p>}
{success && <p className="message message-success">{success}</p>}
</form>
</section>
<section className="staff-table-section">
<div className="table-toolbar">
<h2 className="section-heading staff-subheading">Alle Reservierungen</h2>
<button type="button" className="btn btn-primary" onClick={() => setIsCreating(true)}>
+ Neue Reservierung
</button>
</div>
{reservations.length > 0 && (
<div className="table-filters">
<input
@@ -474,7 +271,9 @@ function StaffDashboard() {
<table className="reservations-table reservations-table-clickable">
<thead>
<tr>
<th>Datum &amp; Uhrzeit</th>
{timeFilter !== 'today' && <th>Datum</th>}
<th>Start</th>
<th>Ende</th>
<th>Nr.</th>
<th>Tisch</th>
<th>Kunde</th>
@@ -485,15 +284,30 @@ function StaffDashboard() {
</thead>
<tbody>
{filteredReservations.map((reservation) => (
<tr
key={reservation.id}
className={editingId === reservation.id ? 'is-editing' : undefined}
onClick={() => setViewingReservation(reservation)}
>
<td>{dateTimeFormatter.format(new Date(reservation.reservationTime))}</td>
<td>{reservation.tableNumber ?? '—'}</td>
<td>{reservation.tableName}</td>
<td>
<tr key={reservation.id} onClick={() => setViewingReservation(reservation)}>
{timeFilter !== 'today' && (
<td data-label="Datum" className="cell-datetime">
{dateFormatter.format(new Date(reservation.reservationTime))}
</td>
)}
<td data-label="Start" className="cell-datetime">
{timeFormatter.format(new Date(reservation.reservationTime))}
</td>
<td data-label="Ende" className="cell-datetime">
{timeFormatter.format(
new Date(
new Date(reservation.reservationTime).getTime() +
reservation.durationMinutes * 60_000,
),
)}
</td>
<td data-label="Nr." className="cell-table-nr">
{reservation.tableNumber ?? '—'}
</td>
<td data-label="Tisch" className="cell-table-name">
{reservation.tableName}
</td>
<td data-label="Kunde" className="cell-customer">
{reservation.customerName}
<br />
<span className="muted">{reservation.customerEmail}</span>
@@ -504,11 +318,15 @@ function StaffDashboard() {
</>
)}
</td>
<td>{reservation.partySize}</td>
<td>
<td data-label="Personen" className="cell-party">
{reservation.partySize}
</td>
<td data-label="Status" className="cell-status">
<StatusBadge status={reservation.status as ReservationStatus} />
</td>
<td className="message-cell">{reservation.message || <span className="muted">—</span>}</td>
<td data-label="Nachricht" className="message-cell">
{reservation.message || <span className="muted">—</span>}
</td>
</tr>
))}
</tbody>
@@ -536,22 +354,33 @@ function StaffDashboard() {
onCancel={() => setPendingDelete(null)}
/>
<ReservationDetailDialog
reservation={viewingReservation}
onClose={() => setViewingReservation(null)}
onStatusChange={(status) => viewingReservation && handleStatusChange(viewingReservation, status)}
isUpdatingStatus={isUpdatingStatus}
onEdit={() => {
if (!viewingReservation) return
startEdit(viewingReservation)
setViewingReservation(null)
}}
onDelete={() => {
if (!viewingReservation) return
setPendingDelete(viewingReservation)
setViewingReservation(null)
}}
isDeleting={viewingReservation !== null && deletingId === viewingReservation.id}
{dialogOpen && (
<ReservationDialog
key={viewingReservation ? `view-${viewingReservation.id}` : 'create'}
open={dialogOpen}
reservation={viewingReservation}
tables={tables}
settings={settings}
reservations={reservations}
onClose={closeDialog}
onCreate={handleCreate}
onUpdate={handleUpdate}
onStatusChange={(status) => viewingReservation && handleStatusChange(viewingReservation, status)}
isUpdatingStatus={isUpdatingStatus}
statusError={error}
onDelete={() => {
if (!viewingReservation) return
setPendingDelete(viewingReservation)
setViewingReservation(null)
}}
/>
)}
<SuccessModal
open={successModal !== null}
title={successModal?.title ?? ''}
message={successModal?.message ?? ''}
onClose={() => setSuccessModal(null)}
/>
</div>
)
+361 -17
View File
@@ -142,10 +142,10 @@ a {
}
.hero-flame {
width: 4rem;
height: 4rem;
display: block;
width: 7rem;
height: 3.5rem;
margin: 0 auto 1.5rem;
color: var(--ember);
}
.hero h1 {
@@ -183,6 +183,11 @@ a {
border: 1px solid transparent;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-primary {
background: var(--gold);
color: #201709;
@@ -203,6 +208,17 @@ a {
color: var(--text);
}
.btn-ghost {
background: none;
border-color: transparent;
color: var(--text-muted);
}
.btn-ghost:hover {
color: var(--text);
background: rgba(243, 234, 217, 0.06);
}
/* Sections */
.section {
@@ -626,6 +642,18 @@ a {
color: var(--text);
}
/* `.radio-option` centers its input against a single-line label, which breaks
down for the consent checkbox's multi-line sentence — the checkbox needs to
sit at the top of the text instead of drifting to its vertical center. */
.reservation-form .consent-option {
align-items: flex-start;
}
.reservation-form .consent-option input {
margin-top: 0.2rem;
flex-shrink: 0;
}
/* `:not(.btn)` keeps this scoped to the plain submit buttons — anything already
using the `.btn` component (Abbrechen, Bearbeiten, Löschen, …) styles itself
and shouldn't be overridden here (a previous version of this rule beat
@@ -690,6 +718,13 @@ a {
border: 1px solid #23502c;
}
.staff-page-messages {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 1.25rem 0 0;
}
/* Contact */
.contact-list {
@@ -846,6 +881,10 @@ a {
.confirm-dialog {
animation: none;
}
.reservation-confirmed-flame {
animation: none;
}
}
.staff-main {
@@ -1090,6 +1129,19 @@ a {
margin-top: 2.5rem;
}
.table-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.25rem;
}
.table-toolbar .staff-subheading {
margin: 0;
}
.table-filters {
display: flex;
flex-wrap: wrap;
@@ -1176,6 +1228,71 @@ a {
text-overflow: ellipsis;
}
@media (max-width: 40rem) {
/* On mobile the column headers are hidden and each row becomes a card laid
out on a 2-column grid — related fields (table nr/name, party/status)
sit side by side, while date/time, customer and message span full width. */
.reservations-table thead {
display: none;
}
.reservations-table,
.reservations-table tbody {
display: block;
width: 100%;
}
.reservations-table tbody {
padding: 0.75rem;
}
.reservations-table tbody tr {
display: grid;
grid-template-columns: 1fr 1fr;
column-gap: 1rem;
row-gap: 0.6rem;
padding: 0.9rem 1rem;
background: var(--bg-raised);
border: 1px solid var(--line);
border-radius: 0.5rem;
}
.reservations-table tbody tr + tr {
margin-top: 0.75rem;
}
.reservations-table td {
display: block;
max-width: none;
padding: 0;
white-space: normal;
word-break: break-word;
border-bottom: none;
}
.reservations-table td[data-label]::before {
content: attr(data-label);
display: block;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
margin-bottom: 0.15rem;
}
.reservations-table .cell-datetime,
.reservations-table .cell-customer,
.reservations-table .message-cell {
grid-column: 1 / -1;
}
.reservations-table .message-cell {
max-width: none;
overflow: visible;
text-overflow: unset;
}
}
.status-badge {
display: inline-flex;
align-items: center;
@@ -1207,35 +1324,57 @@ a {
border: 1px solid #23502c;
}
/* A single grouped 3-way toggle track, not three loose floating pills — the
active segment gets a solid fill, the other two stay flat text until hovered. */
.status-options {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
gap: 0.25rem;
margin: 0 0 1.5rem;
padding: 0.25rem;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 999px;
}
.status-option {
flex: 1;
cursor: pointer;
padding: 0.4rem 0.75rem;
opacity: 0.55;
padding: 0.45rem 0.75rem;
transition:
background-color 0.15s ease,
color 0.15s ease;
}
/* `.status-badge.status-option` — matches `.status-badge.status-confirmed` (etc.)
on specificity so this actually wins and overrides their `border`, rather than
losing to them by cascade order. */
on specificity so this actually wins and overrides their fixed `width`/`border`,
rather than losing to them by cascade order. */
.status-badge.status-option {
width: auto;
border: none;
}
.status-option:hover {
opacity: 0.85;
.status-option:disabled {
cursor: not-allowed;
}
.status-option.is-active {
opacity: 1;
/* `currentColor` picks up each status's own `color` (gold/red/green) instead
of one fixed ring color that wouldn't match every status. */
box-shadow: 0 0 0 2px currentColor;
.status-option:hover:not(:disabled):not(.is-active) {
color: var(--text);
background: rgba(243, 234, 217, 0.06);
}
.status-option.is-active.status-confirmed {
background: var(--gold);
color: #201709;
}
.status-option.is-active.status-no_show {
background: var(--ember);
color: #fdeceb;
}
.status-option.is-active.status-completed {
background: #2f7a3c;
color: #eafcee;
}
.table-actions {
@@ -1369,6 +1508,83 @@ a {
pointer-events: none;
}
.pdf-menu {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.pdf-menu-viewport {
position: relative;
width: 100%;
overflow: hidden;
border-radius: 0.4rem;
border: 1px solid var(--line);
background: #fff;
box-shadow: 0 0.5rem 1.5rem rgb(0 0 0 / 25%);
}
.pdf-menu-loading {
position: absolute;
top: 50%;
left: 50%;
width: 90%;
max-width: 20rem;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
opacity: 0;
animation: pdf-menu-spinner-fade-in 0.15s ease 0.15s forwards;
}
.pdf-menu-loading p {
margin: 0;
text-align: center;
font-family: 'Fraunces', Georgia, serif;
font-style: italic;
color: #6b5f4d;
}
.pdf-menu-spinner {
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
border: 3px solid var(--line);
border-top-color: var(--gold);
animation: pdf-menu-spin 0.8s linear infinite;
}
@keyframes pdf-menu-spin {
to {
transform: rotate(360deg);
}
}
@keyframes pdf-menu-spinner-fade-in {
to {
opacity: 1;
}
}
.pdf-menu-page {
display: block;
width: 100%;
max-width: 100%;
aspect-ratio: 210 / 297;
opacity: 0;
}
.pdf-menu-pagination {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
color: var(--text-muted);
}
/* A classic map-pin: a square with three rounded corners (50% 50% 50% 0 leaves
the bottom-left one sharp), rotated -45deg around that exact sharp corner so
the corner itself — not the box center — lands on the `left`/`top` coordinate
@@ -1475,6 +1691,18 @@ a {
margin: 0;
}
.confirm-dialog-header-actions {
display: flex;
align-items: center;
gap: 0.4rem;
flex-shrink: 0;
}
.confirm-dialog-header-actions .btn {
padding: 0.45rem 0.9rem;
font-size: 0.85rem;
}
.confirm-dialog-message {
margin: 0 0 1.25rem;
color: var(--text-muted);
@@ -1515,11 +1743,115 @@ a {
gap: 0.75rem;
}
/* Puts "Bearbeiten" and "Schließen" on opposite ends of the row instead of the
usual right-aligned button cluster, since they aren't a confirm/cancel pair. */
.confirm-dialog-actions-split {
justify-content: space-between;
}
.confirm-dialog-actions .btn {
padding: 0.6rem 1.25rem;
font-size: 0.9rem;
}
/* Beats the rule above on source order (same specificity), so it reads as a
plain text action instead of a button-shaped one. */
.confirm-dialog-actions .btn-ghost {
padding: 0;
font-size: 0.8rem;
text-decoration: underline;
}
.reservation-confirmed-dialog {
max-width: 26rem;
text-align: center;
}
.reservation-confirmed-flame {
width: 3.25rem;
height: 3.25rem;
margin: 0 auto 1rem;
color: var(--ember);
animation: reservation-confirmed-flicker 2.2s ease-in-out infinite;
}
@keyframes reservation-confirmed-flicker {
0%,
100% {
transform: rotate(0deg) scale(1);
color: var(--ember);
}
25% {
transform: rotate(-3deg) scale(1.05);
}
50% {
transform: rotate(2deg) scale(0.97);
color: var(--gold-bright);
}
75% {
transform: rotate(-2deg) scale(1.03);
}
}
.reservation-confirmed-title {
margin: 0 0 0.5rem;
font-size: 1.5rem;
color: var(--gold-bright);
}
.reservation-confirmed-message {
margin: 0 0 1.5rem;
color: var(--text-muted);
}
.reservation-confirmed-dialog .detail-list {
text-align: left;
}
.reservation-confirmed-footnote {
margin: 0 0 1.5rem;
font-size: 0.82rem;
color: var(--text-muted);
}
.reservation-confirmed-footnote strong {
color: var(--text);
font-weight: 600;
}
.reservation-confirmed-dialog .confirm-dialog-actions {
justify-content: center;
}
.success-modal {
max-width: 22rem;
text-align: center;
}
.success-modal-icon {
display: flex;
align-items: center;
justify-content: center;
width: 2.75rem;
height: 2.75rem;
margin: 0 auto 1rem;
border: 1px solid var(--gold);
border-radius: 50%;
color: var(--gold-bright);
}
.success-modal .confirm-dialog-title {
margin-bottom: 0.5rem;
}
.success-modal .confirm-dialog-message {
margin-bottom: 1.5rem;
}
.success-modal .confirm-dialog-actions {
justify-content: center;
}
.reservation-detail-dialog {
max-width: 28rem;
}
@@ -1528,6 +1860,18 @@ a {
margin-bottom: 1.25rem;
}
.reservation-dialog-editing {
max-width: 34rem;
}
/* The dialog card already provides the background/border/padding — strip
`.reservation-form`'s own so the form doesn't render as a card-in-a-card. */
.reservation-form-dialog {
background: none;
border: none;
padding: 0;
}
.detail-list {
display: flex;
flex-direction: column;
@@ -1623,7 +1967,7 @@ a {
/* Legal page */
.legal-page {
padding: 3.5rem 0 4rem;
padding: 3.5rem 1rem 4rem;
}
.legal-page h1 {
+3
View File
@@ -7,6 +7,9 @@ import viteReact from '@vitejs/plugin-react'
const config = defineConfig({
resolve: { tsconfigPaths: true },
plugins: [tanstackStart(), viteReact()],
preview: {
allowedHosts: ['localhost', '127.0.0.1', 'promo-steakhouse.lukas-harat.com'],
}
})
export default config