Compare commits

..
1 Commits
Author SHA1 Message Date
lukas dd3aa45e02 wip
CI / build-and-test (pull_request) Failing after 46s
2026-08-25 23:04:37 +02:00
23 changed files with 529 additions and 1984 deletions
-20
View File
@@ -1,20 +0,0 @@
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
+64 -130
View File
@@ -1,29 +1,42 @@
name: CI name: CI
on: on:
push: pull_request:
branches: branches: ['**']
- main
- proof
workflow_dispatch:
env:
REGISTRY: git.lukas-harat.com
IMAGE_NAME: ${{ github.repository }}
permissions:
contents: read
packages: write
jobs: jobs:
test: build-and-test:
runs-on: ubuntu-latest 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: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup Node - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
@@ -32,129 +45,50 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Typecheck - name: Type check
run: npx tsc --noEmit run: npx tsc --noEmit
build: - name: Check that Drizzle migrations are up to date
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: | run: |
set -euo pipefail 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
mkdir -p ~/.ssh - name: Apply migrations
run: npm run db:migrate
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key - name: Seed baseline data
chmod 600 ~/.ssh/deploy_key run: npm run db:seed
ssh-keyscan \ - name: Build
-p "${DEPLOY_PORT:-22}" \ run: npm run build
-H "$DEPLOY_HOST" \
>> ~/.ssh/known_hosts
echo "Creating deployment directory if needed..." - name: Smoke test the production build
run: |
npx vite preview --port 4173 &
PREVIEW_PID=$!
ssh \ for i in $(seq 1 30); do
-i ~/.ssh/deploy_key \ curl -sf http://localhost:4173/ >/dev/null 2>&1 && break
-p "${DEPLOY_PORT:-22}" \ sleep 1
"$DEPLOY_USER@$DEPLOY_HOST" \ done
"mkdir -p '$DEPLOY_PATH'"
echo "Uploading compose file..." 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"
}
scp \ check / 200
-i ~/.ssh/deploy_key \ check /staff/login 200
-P "${DEPLOY_PORT:-22}" \ check /impressum 200
docker-compose.prod.yml \
"$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/docker-compose.yml"
echo "Deploying..." kill "$PREVIEW_PID"
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
@@ -1,31 +0,0 @@
# 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
@@ -1,35 +0,0 @@
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,7 +10,6 @@
"@tanstack/react-start": "latest", "@tanstack/react-start": "latest",
"better-auth": "^1.6.29", "better-auth": "^1.6.29",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"pdfjs-dist": "^6.2.108",
"postgres": "^3.4.9", "postgres": "^3.4.9",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
@@ -1311,271 +1310,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@noble/ciphers": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.3.0.tgz", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.3.0.tgz",
@@ -3998,18 +3732,6 @@
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"license": "MIT" "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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
-1
View File
@@ -21,7 +21,6 @@
"@tanstack/react-start": "latest", "@tanstack/react-start": "latest",
"better-auth": "^1.6.29", "better-auth": "^1.6.29",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"pdfjs-dist": "^6.2.108",
"postgres": "^3.4.9", "postgres": "^3.4.9",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
-5
View File
@@ -1,5 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 371 B

-1
View File
@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 3.4 KiB

BIN
View File
Binary file not shown.
-191
View File
@@ -1,191 +0,0 @@
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>
)
}
@@ -1,108 +0,0 @@
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,9 +14,6 @@ type Props = {
// made — both configurable restaurant settings, falling back to sane defaults. // made — both configurable restaurant settings, falling back to sane defaults.
intervalMinutes?: number intervalMinutes?: number
minLeadTimeMinutes?: 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, // 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. // since not every caller of this field needs the restriction.
maxLeadTimeDays?: number maxLeadTimeDays?: number
@@ -104,7 +101,6 @@ export function ReservationDateTimeField({
bookedTimes = [], bookedTimes = [],
intervalMinutes = RESERVATION_INTERVAL_MINUTES, intervalMinutes = RESERVATION_INTERVAL_MINUTES,
minLeadTimeMinutes = 0, minLeadTimeMinutes = 0,
durationMinutes = RESERVATION_INTERVAL_MINUTES,
maxLeadTimeDays, maxLeadTimeDays,
}: Props) { }: Props) {
const { date, time } = splitValue(value) const { date, time } = splitValue(value)
@@ -193,7 +189,7 @@ export function ReservationDateTimeField({
// otherwise) can't slip through with a leftover, still-"valid"-looking time. // otherwise) can't slip through with a leftover, still-"valid"-looking time.
function optionsForDate(dateStr: string) { function optionsForDate(dateStr: string) {
if (!dateStr || dateStr < today || (latestDate !== null && dateStr > latestDate)) return [] if (!dateStr || dateStr < today || (latestDate !== null && dateStr > latestDate)) return []
return generateTimeOptions(getOpeningRangesForWeekday(getWeekday(dateStr)), intervalMinutes, durationMinutes) return generateTimeOptions(getOpeningRangesForWeekday(getWeekday(dateStr)), intervalMinutes)
} }
function isSelectableDate(dateStr: string) { function isSelectableDate(dateStr: string) {
@@ -0,0 +1,136 @@
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
@@ -1,456 +0,0 @@
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
@@ -1,67 +0,0 @@
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>
)
}
+4 -15
View File
@@ -57,16 +57,12 @@ export function isOnReservationInterval(date: Date, intervalMinutes: number = RE
return date.getUTCMinutes() % intervalMinutes === 0 && date.getUTCSeconds() === 0 return date.getUTCMinutes() % intervalMinutes === 0 && date.getUTCSeconds() === 0
} }
// `durationMinutes` requires the whole reservation — not just its start — to fit export function isWithinOpeningHours(date: Date) {
// 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 { weekday, minutesSinceMidnight } = getBerlinTimeParts(date)
const ranges = OPENING_HOURS_BY_WEEKDAY[weekday] ?? [] const ranges = OPENING_HOURS_BY_WEEKDAY[weekday] ?? []
return ranges.some( return ranges.some(
(range) => (range) => minutesSinceMidnight >= toMinutes(range.start) && minutesSinceMidnight < toMinutes(range.end),
minutesSinceMidnight >= toMinutes(range.start) &&
minutesSinceMidnight + durationMinutes <= toMinutes(range.end),
) )
} }
@@ -93,15 +89,9 @@ export function getOpeningRangesForWeekday(weekday: number): OpeningRange[] {
return OPENING_HOURS_BY_WEEKDAY[weekday] ?? [] 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( export function generateTimeOptions(
ranges: OpeningRange[], ranges: OpeningRange[],
intervalMinutes: number = RESERVATION_INTERVAL_MINUTES, intervalMinutes: number = RESERVATION_INTERVAL_MINUTES,
durationMinutes: number = intervalMinutes,
): string[] { ): string[] {
const options: string[] = [] const options: string[] = []
@@ -109,7 +99,7 @@ export function generateTimeOptions(
const startMinutes = toMinutes(range.start) const startMinutes = toMinutes(range.start)
const endMinutes = toMinutes(range.end) const endMinutes = toMinutes(range.end)
for (let minutes = startMinutes; minutes + durationMinutes <= endMinutes; minutes += intervalMinutes) { for (let minutes = startMinutes; minutes < endMinutes; minutes += intervalMinutes) {
const hours = Math.floor(minutes / 60) const hours = Math.floor(minutes / 60)
const remainder = minutes % 60 const remainder = minutes % 60
options.push(`${String(hours).padStart(2, '0')}:${String(remainder).padStart(2, '0')}`) options.push(`${String(hours).padStart(2, '0')}:${String(remainder).padStart(2, '0')}`)
@@ -122,14 +112,13 @@ export function generateTimeOptions(
export function getNextAvailableSlot( export function getNextAvailableSlot(
from: Date, from: Date,
intervalMinutes: number = RESERVATION_INTERVAL_MINUTES, intervalMinutes: number = RESERVATION_INTERVAL_MINUTES,
durationMinutes: number = intervalMinutes,
): { date: string; time: string } | null { ): { date: string; time: string } | null {
for (let dayOffset = 0; dayOffset < 14; dayOffset++) { for (let dayOffset = 0; dayOffset < 14; dayOffset++) {
const day = new Date(from) const day = new Date(from)
day.setDate(from.getDate() + dayOffset) day.setDate(from.getDate() + dayOffset)
const dateStr = formatDateOnly(day) const dateStr = formatDateOnly(day)
const options = generateTimeOptions(getOpeningRangesForWeekday(day.getDay()), intervalMinutes, durationMinutes) const options = generateTimeOptions(getOpeningRangesForWeekday(day.getDay()), intervalMinutes)
for (const time of options) { for (const time of options) {
const slot = new Date(`${dateStr}T${time}`) const slot = new Date(`${dateStr}T${time}`)
+5 -28
View File
@@ -29,7 +29,6 @@ function assertValidReservationSlot(
intervalMinutes: number, intervalMinutes: number,
minLeadTimeMinutes: number, minLeadTimeMinutes: number,
maxLeadTimeDays: number, maxLeadTimeDays: number,
durationMinutes: number,
) { ) {
const earliestAllowed = Date.now() + minLeadTimeMinutes * 60_000 const earliestAllowed = Date.now() + minLeadTimeMinutes * 60_000
@@ -51,33 +50,28 @@ function assertValidReservationSlot(
throw new Error(`Reservierungen sind nur im ${intervalMinutes}-Minuten-Raster möglich`) throw new Error(`Reservierungen sind nur im ${intervalMinutes}-Minuten-Raster möglich`)
} }
if (!isWithinOpeningHours(reservationTime, durationMinutes)) { if (!isWithinOpeningHours(reservationTime)) {
throw new Error('Zu dieser Zeit reicht die verbleibende Zeit bis zum Schließen nicht mehr aus') throw new Error('Zu dieser Zeit haben wir leider geschlossen')
} }
} }
// A table is occupied for its own configured duration, not just the exact minute it // 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. // 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 // 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. `bufferMinutes` // older booking keeps whatever duration was in effect when it was made.
// (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( async function findOverlappingReservation(
tableId: number, tableId: number,
start: Date, start: Date,
durationMinutes: number, durationMinutes: number,
bufferMinutes: number,
excludeId?: number, excludeId?: number,
) { ) {
const end = start.getTime() + durationMinutes * 60_000 const end = start.getTime() + durationMinutes * 60_000
const bufferMs = bufferMinutes * 60_000
const candidates = await db.select().from(reservations).where(eq(reservations.tableId, tableId)) const candidates = await db.select().from(reservations).where(eq(reservations.tableId, tableId))
return candidates.find((r) => { return candidates.find((r) => {
if (excludeId !== undefined && r.id === excludeId) return false if (excludeId !== undefined && r.id === excludeId) return false
const existingStart = r.reservationTime.getTime() - bufferMs const existingStart = r.reservationTime.getTime()
const existingEnd = existingStart + r.durationMinutes * 60_000 + 2 * bufferMs const existingEnd = existingStart + r.durationMinutes * 60_000
return existingStart < end && start.getTime() < existingEnd return existingStart < end && start.getTime() < existingEnd
}) })
} }
@@ -223,19 +217,6 @@ export const getReservations = createServerFn({ method: 'GET' }).handler(async (
.orderBy(reservations.reservationTime) .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({ const createReservationSchema = z.object({
tableId: z.number().int().positive(), tableId: z.number().int().positive(),
customerName: z.string().trim().min(1, 'Name is required').max(120), customerName: z.string().trim().min(1, 'Name is required').max(120),
@@ -264,7 +245,6 @@ export const createReservation = createServerFn({ method: 'POST' })
settings.reservationIntervalMinutes, settings.reservationIntervalMinutes,
settings.minLeadTimeMinutes, settings.minLeadTimeMinutes,
settings.maxLeadTimeDays, settings.maxLeadTimeDays,
settings.reservationDurationMinutes,
) )
const [table] = await db const [table] = await db
@@ -284,7 +264,6 @@ export const createReservation = createServerFn({ method: 'POST' })
data.tableId, data.tableId,
reservationTime, reservationTime,
settings.reservationDurationMinutes, settings.reservationDurationMinutes,
settings.minLeadTimeMinutes,
) )
if (conflict) { if (conflict) {
@@ -335,7 +314,6 @@ export const updateReservation = createServerFn({ method: 'POST' })
settings.reservationIntervalMinutes, settings.reservationIntervalMinutes,
settings.minLeadTimeMinutes, settings.minLeadTimeMinutes,
settings.maxLeadTimeDays, settings.maxLeadTimeDays,
settings.reservationDurationMinutes,
) )
const [table] = await db const [table] = await db
@@ -355,7 +333,6 @@ export const updateReservation = createServerFn({ method: 'POST' })
data.tableId, data.tableId,
reservationTime, reservationTime,
settings.reservationDurationMinutes, settings.reservationDurationMinutes,
settings.minLeadTimeMinutes,
data.id, data.id,
) )
-5
View File
@@ -26,11 +26,6 @@ export const Route = createRootRoute({
rel: 'stylesheet', rel: 'stylesheet',
href: appCss, href: appCss,
}, },
{
rel: 'icon',
href: '/favicon.svg',
type: 'image/svg+xml',
},
], ],
}), }),
shellComponent: RootDocument, shellComponent: RootDocument,
+1 -1
View File
@@ -57,7 +57,7 @@ function Impressum() {
bedürfen der schriftlichen Zustimmung des jeweiligen Erstellers. bedürfen der schriftlichen Zustimmung des jeweiligen Erstellers.
</p> </p>
<h2 id="datenschutz">Datenschutz</h2> <h2>Datenschutz</h2>
<p> <p>
Verantwortlich für die Verarbeitung personenbezogener Daten auf dieser Website ist die Verantwortlich für die Verarbeitung personenbezogener Daten auf dieser Website ist die
oben genannte Drenica &amp; Mousa GbR. oben genannte Drenica &amp; Mousa GbR.
+24 -137
View File
@@ -1,57 +1,39 @@
import { useState } from 'react' import { useState } from 'react'
import { createFileRoute, useRouter } from '@tanstack/react-router' 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 { ReservationDateTimeField } from '#/components/reservation-datetime-field'
import { SiteFooter } from '#/components/site-footer' import { SiteFooter } from '#/components/site-footer'
import { SiteHeader } from '#/components/site-header' import { SiteHeader } from '#/components/site-header'
import { formatDateOnly } from '#/lib/datetime' import { getNextAvailableSlot, getOpeningHoursForDisplay, getTodayLabelBerlin } from '#/lib/opening-hours'
import { import { createReservation, getTables } from '#/lib/reservations'
generateTimeOptions,
getNextAvailableSlot,
getOpeningHoursForDisplay,
getOpeningRangesForWeekday,
getTodayLabelBerlin,
} from '#/lib/opening-hours'
import { createReservation, getReservationAvailability, getTables } from '#/lib/reservations'
import { getReservationSettings } from '#/lib/settings' import { getReservationSettings } from '#/lib/settings'
export const Route = createFileRoute('/')({ export const Route = createFileRoute('/')({
component: Home, component: Home,
loader: async () => { loader: async () => {
const [tables, settings, availability] = await Promise.all([ const [tables, settings] = await Promise.all([getTables(), getReservationSettings()])
getTables(), return { tables, settings }
getReservationSettings(),
getReservationAvailability(),
])
return { tables, settings, availability }
}, },
}) })
const OPENING_HOURS = getOpeningHoursForDisplay() const OPENING_HOURS = getOpeningHoursForDisplay()
const confirmedDateFormatter = new Intl.DateTimeFormat('de-DE', { dateStyle: 'full' }) function FlameIcon() {
const confirmedTimeFormatter = new Intl.DateTimeFormat('de-DE', { timeStyle: 'short' }) return (
<svg className="hero-flame" viewBox="0 0 24 24" fill="none" aria-hidden="true">
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ <path
// Also used as the phone input's `pattern` attribute below, so the two stay in sync. 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"
const PHONE_PATTERN_SOURCE = String.raw`[+]?[0-9()\-.\s]{6,30}` stroke="currentColor"
const PHONE_PATTERN = new RegExp(`^${PHONE_PATTERN_SOURCE}$`) strokeWidth="1.4"
strokeLinejoin="round"
type ConfirmedReservation = { />
customerName: string </svg>
customerEmail: string )
tableName: string
date: string
startTime: string
endTime: string
partySize: number
} }
function Home() { function Home() {
const router = useRouter() const router = useRouter()
const { tables, settings, availability } = Route.useLoaderData() const { tables, settings } = Route.useLoaderData()
const [tableId, setTableId] = useState<number | string>(tables[0]?.id ?? '') const [tableId, setTableId] = useState<number | string>(tables[0]?.id ?? '')
const [customerName, setCustomerName] = useState('') const [customerName, setCustomerName] = useState('')
@@ -59,62 +41,24 @@ function Home() {
const [customerPhone, setCustomerPhone] = useState('') const [customerPhone, setCustomerPhone] = useState('')
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
const [partySize, setPartySize] = useState(2) const [partySize, setPartySize] = useState(2)
const [acceptsTerms, setAcceptsTerms] = useState(false)
const [reservationTime, setReservationTime] = useState(() => { const [reservationTime, setReservationTime] = useState(() => {
const slot = getNextAvailableSlot( const slot = getNextAvailableSlot(
new Date(Date.now() + settings.minLeadTimeMinutes * 60_000), new Date(Date.now() + settings.minLeadTimeMinutes * 60_000),
settings.reservationIntervalMinutes, settings.reservationIntervalMinutes,
settings.reservationDurationMinutes,
) )
return slot ? `${slot.date}T${slot.time}` : '' return slot ? `${slot.date}T${slot.time}` : ''
}) })
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [confirmedReservation, setConfirmedReservation] = useState<ConfirmedReservation | null>(null) const [success, setSuccess] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const todayName = getTodayLabelBerlin() const todayName = getTodayLabelBerlin()
const selectedTable = tables.find((table) => String(table.id) === String(tableId)) 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) { async function handleSubmit(event: React.FormEvent) {
event.preventDefault() event.preventDefault()
setError(null) setError(null)
setSuccess(null)
if (!tableId) { if (!tableId) {
setError('Bitte wählen Sie einen Tisch aus') setError('Bitte wählen Sie einen Tisch aus')
@@ -126,14 +70,9 @@ function Home() {
return return
} }
if (!acceptsTerms) {
setError('Bitte stimmen Sie der Verarbeitung Ihrer Daten zu')
return
}
setIsSubmitting(true) setIsSubmitting(true)
try { try {
const created = await createReservation({ await createReservation({
data: { data: {
tableId: Number(tableId), tableId: Number(tableId),
customerName, customerName,
@@ -145,23 +84,12 @@ function Home() {
}, },
}) })
const start = new Date(created.reservationTime) setSuccess('Vielen Dank! Ihre Reservierung ist bestätigt.')
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('') setCustomerName('')
setCustomerEmail('') setCustomerEmail('')
setCustomerPhone('') setCustomerPhone('')
setMessage('') setMessage('')
setPartySize(2) setPartySize(2)
setAcceptsTerms(false)
await router.invalidate() await router.invalidate()
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen') setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
@@ -175,7 +103,7 @@ function Home() {
<SiteHeader /> <SiteHeader />
<section className="hero" id="top"> <section className="hero" id="top">
<img className="hero-flame" src="/logo.svg" alt="" aria-hidden="true" /> <FlameIcon />
<h1> <h1>
Vom Feuer verwöhnt, <span>mit Liebe serviert</span> Vom Feuer verwöhnt, <span>mit Liebe serviert</span>
</h1> </h1>
@@ -215,16 +143,6 @@ function Home() {
</div> </div>
</section> </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"> <section className="section" id="reservieren">
<div className="section-grid"> <div className="section-grid">
<div> <div>
@@ -305,7 +223,7 @@ function Home() {
<input <input
type="tel" type="tel"
inputMode="tel" inputMode="tel"
pattern={PHONE_PATTERN_SOURCE} pattern="[+]?[0-9()\-.\s]{6,30}"
title="Nur Ziffern, Leerzeichen und + ( ) - ." title="Nur Ziffern, Leerzeichen und + ( ) - ."
value={customerPhone} value={customerPhone}
onChange={(e) => setCustomerPhone(e.target.value)} onChange={(e) => setCustomerPhone(e.target.value)}
@@ -332,10 +250,8 @@ function Home() {
<ReservationDateTimeField <ReservationDateTimeField
value={reservationTime} value={reservationTime}
onChange={setReservationTime} onChange={setReservationTime}
bookedTimes={bookedTimesForForm}
intervalMinutes={settings.reservationIntervalMinutes} intervalMinutes={settings.reservationIntervalMinutes}
minLeadTimeMinutes={settings.minLeadTimeMinutes} minLeadTimeMinutes={settings.minLeadTimeMinutes}
durationMinutes={settings.reservationDurationMinutes}
maxLeadTimeDays={settings.maxLeadTimeDays} maxLeadTimeDays={settings.maxLeadTimeDays}
/> />
</label> </label>
@@ -356,27 +272,12 @@ function Home() {
<a href="#oeffnungszeiten">Öffnungszeiten</a>. <a href="#oeffnungszeiten">Öffnungszeiten</a>.
</p> </p>
<label className="radio-option consent-option"> <button type="submit" disabled={isSubmitting}>
<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'} {isSubmitting ? 'Wird gebucht …' : 'Jetzt reservieren'}
</button> </button>
{error && <p className="message message-error">{error}</p>} {error && <p className="message message-error">{error}</p>}
{success && <p className="message message-success">{success}</p>}
</form> </form>
</div> </div>
</section> </section>
@@ -410,20 +311,6 @@ function Home() {
</main> </main>
<SiteFooter /> <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)}
/>
)}
</> </>
) )
} }
+266 -95
View File
@@ -3,12 +3,14 @@ import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-rout
import { ConfirmDialog } from '#/components/confirm-dialog' import { ConfirmDialog } from '#/components/confirm-dialog'
import { ReservationCharts } from '#/components/reservation-charts' import { ReservationCharts } from '#/components/reservation-charts'
import { ReservationDialog, type ReservationPayload } from '#/components/reservation-dialog' import { ReservationDateTimeField } from '#/components/reservation-datetime-field'
import { ReservationDetailDialog } from '#/components/reservation-detail-dialog'
import { StaffTabs } from '#/components/staff-tabs' import { StaffTabs } from '#/components/staff-tabs'
import { StatusBadge } from '#/components/status-badge' import { StatusBadge } from '#/components/status-badge'
import { SuccessModal } from '#/components/success-modal'
import { getCurrentStaffUser } from '#/lib/auth' import { getCurrentStaffUser } from '#/lib/auth'
import { authClient } from '#/lib/auth-client' import { authClient } from '#/lib/auth-client'
import { formatDateOnly, formatDateTimeLocal } from '#/lib/datetime'
import { generateTimeOptions, getNextAvailableSlot, getOpeningRangesForWeekday } from '#/lib/opening-hours'
import { import {
RESERVATION_STATUS_LABELS, RESERVATION_STATUS_LABELS,
createReservation, createReservation,
@@ -40,11 +42,8 @@ export const Route = createFileRoute('/staff/')({
}, },
}) })
const dateFormatter = new Intl.DateTimeFormat('de-DE', { const dateTimeFormatter = new Intl.DateTimeFormat('de-DE', {
dateStyle: 'medium', dateStyle: 'medium',
})
const timeFormatter = new Intl.DateTimeFormat('de-DE', {
timeStyle: 'short', timeStyle: 'short',
}) })
@@ -56,23 +55,53 @@ function isSameDay(a: Date, b: Date) {
type ReservationRow = Awaited<ReturnType<typeof getReservations>>[number] 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() { function StaffDashboard() {
const router = useRouter() const router = useRouter()
const { staffUser, reservations, tables, settings } = Route.useLoaderData() 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 [error, setError] = useState<string | null>(null)
const [successModal, setSuccessModal] = useState<{ title: string; message: string } | null>(null) const [success, setSuccess] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [deletingId, setDeletingId] = useState<number | null>(null)
const [pendingDelete, setPendingDelete] = useState<ReservationRow | null>(null) const [pendingDelete, setPendingDelete] = useState<ReservationRow | null>(null)
const [viewingReservation, setViewingReservation] = useState<ReservationRow | null>(null) const [viewingReservation, setViewingReservation] = useState<ReservationRow | null>(null)
const [isCreating, setIsCreating] = useState(false)
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false) const [isUpdatingStatus, setIsUpdatingStatus] = useState(false)
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [tableFilter, setTableFilter] = useState('all') const [tableFilter, setTableFilter] = useState('all')
const [timeFilter, setTimeFilter] = useState<'all' | 'today' | 'upcoming' | 'past'>('today') const [timeFilter, setTimeFilter] = useState<'all' | 'today' | 'upcoming' | 'past'>('all')
const [statusFilter, setStatusFilter] = useState<'all' | ReservationStatus>('all') const [statusFilter, setStatusFilter] = useState<'all' | ReservationStatus>('all')
const dialogOpen = viewingReservation !== null || isCreating
const now = new Date() const now = new Date()
const todaysReservations = reservations.filter((r) => isSameDay(new Date(r.reservationTime), now)) const todaysReservations = reservations.filter((r) => isSameDay(new Date(r.reservationTime), now))
const guestsToday = todaysReservations.reduce((sum, r) => sum + r.partySize, 0) const guestsToday = todaysReservations.reduce((sum, r) => sum + r.partySize, 0)
@@ -96,27 +125,96 @@ function StaffDashboard() {
return true return true
}) })
function closeDialog() { // Other reservations already sitting on the currently selected table + date, so the
setViewingReservation(null) // time picker can block them instead of letting staff pick a slot the server will
setIsCreating(false) // 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 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)),
})
setError(null)
setSuccess(null)
} }
async function handleCreate(payload: ReservationPayload) { 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 }) await createReservation({ data: payload })
setSuccessModal({ setSuccess('Reservierung wurde angelegt.')
title: 'Reservierung angelegt',
message: `Die Reservierung für ${payload.customerName} wurde erfolgreich angelegt.`,
})
await router.invalidate()
} }
async function handleUpdate(id: number, payload: ReservationPayload) { setEditingId(null)
await updateReservation({ data: { id, ...payload } }) setForm(emptyForm(tables[0]?.id ?? '', settings))
setSuccessModal({
title: 'Reservierung aktualisiert',
message: `Die Reservierung für ${payload.customerName} wurde erfolgreich aktualisiert.`,
})
await router.invalidate() await router.invalidate()
} catch (err) {
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
} finally {
setIsSubmitting(false)
}
} }
async function confirmDelete() { async function confirmDelete() {
@@ -124,27 +222,33 @@ function StaffDashboard() {
if (!reservation) return if (!reservation) return
setPendingDelete(null) setPendingDelete(null)
setDeletingId(reservation.id)
setError(null) setError(null)
setSuccess(null)
try { try {
await deleteReservation({ data: { id: reservation.id } }) await deleteReservation({ data: { id: reservation.id } })
setSuccessModal({ if (editingId === reservation.id) {
title: 'Reservierung gelöscht', cancelEdit()
message: `Die Reservierung von ${reservation.customerName} wurde gelöscht.`, }
}) setSuccess('Reservierung wurde gelöscht.')
await router.invalidate() await router.invalidate()
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen') setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
} finally {
setDeletingId(null)
} }
} }
async function handleStatusChange(reservation: ReservationRow, status: ReservationStatus) { async function handleStatusChange(reservation: ReservationRow, status: ReservationStatus) {
setIsUpdatingStatus(true) setIsUpdatingStatus(true)
setError(null) setError(null)
setSuccess(null)
try { try {
await updateReservationStatus({ data: { id: reservation.id, status } }) await updateReservationStatus({ data: { id: reservation.id, status } })
setViewingReservation((current) => (current ? { ...current, status } : current)) setViewingReservation((current) => (current ? { ...current, status } : current))
setSuccess(`Status wurde auf „${RESERVATION_STATUS_LABELS[status]}“ geändert.`)
await router.invalidate() await router.invalidate()
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen') setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
@@ -182,12 +286,6 @@ function StaffDashboard() {
<main className="page staff-main"> <main className="page staff-main">
<h1 className="section-heading">Reservierungen</h1> <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="staff-stats">
<div className="stat-card"> <div className="stat-card">
<span className="stat-value">{reservations.length}</span> <span className="stat-value">{reservations.length}</span>
@@ -209,14 +307,119 @@ function StaffDashboard() {
<ReservationCharts reservations={reservations} tables={tables} /> <ReservationCharts reservations={reservations} tables={tables} />
<section className="staff-table-section"> <section className="staff-form-section">
<div className="table-toolbar"> <h2 className="section-heading staff-subheading">
<h2 className="section-heading staff-subheading">Alle Reservierungen</h2> {editingId ? 'Reservierung bearbeiten' : 'Neue Reservierung'}
<button type="button" className="btn btn-primary" onClick={() => setIsCreating(true)}> </h2>
+ Neue Reservierung
</button> <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>
<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">
{reservations.length > 0 && ( {reservations.length > 0 && (
<div className="table-filters"> <div className="table-filters">
<input <input
@@ -271,9 +474,7 @@ function StaffDashboard() {
<table className="reservations-table reservations-table-clickable"> <table className="reservations-table reservations-table-clickable">
<thead> <thead>
<tr> <tr>
{timeFilter !== 'today' && <th>Datum</th>} <th>Datum &amp; Uhrzeit</th>
<th>Start</th>
<th>Ende</th>
<th>Nr.</th> <th>Nr.</th>
<th>Tisch</th> <th>Tisch</th>
<th>Kunde</th> <th>Kunde</th>
@@ -284,30 +485,15 @@ function StaffDashboard() {
</thead> </thead>
<tbody> <tbody>
{filteredReservations.map((reservation) => ( {filteredReservations.map((reservation) => (
<tr key={reservation.id} onClick={() => setViewingReservation(reservation)}> <tr
{timeFilter !== 'today' && ( key={reservation.id}
<td data-label="Datum" className="cell-datetime"> className={editingId === reservation.id ? 'is-editing' : undefined}
{dateFormatter.format(new Date(reservation.reservationTime))} onClick={() => setViewingReservation(reservation)}
</td> >
)} <td>{dateTimeFormatter.format(new Date(reservation.reservationTime))}</td>
<td data-label="Start" className="cell-datetime"> <td>{reservation.tableNumber ?? '—'}</td>
{timeFormatter.format(new Date(reservation.reservationTime))} <td>{reservation.tableName}</td>
</td> <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} {reservation.customerName}
<br /> <br />
<span className="muted">{reservation.customerEmail}</span> <span className="muted">{reservation.customerEmail}</span>
@@ -318,15 +504,11 @@ function StaffDashboard() {
</> </>
)} )}
</td> </td>
<td data-label="Personen" className="cell-party"> <td>{reservation.partySize}</td>
{reservation.partySize} <td>
</td>
<td data-label="Status" className="cell-status">
<StatusBadge status={reservation.status as ReservationStatus} /> <StatusBadge status={reservation.status as ReservationStatus} />
</td> </td>
<td data-label="Nachricht" className="message-cell"> <td className="message-cell">{reservation.message || <span className="muted">—</span>}</td>
{reservation.message || <span className="muted">—</span>}
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -354,33 +536,22 @@ function StaffDashboard() {
onCancel={() => setPendingDelete(null)} onCancel={() => setPendingDelete(null)}
/> />
{dialogOpen && ( <ReservationDetailDialog
<ReservationDialog
key={viewingReservation ? `view-${viewingReservation.id}` : 'create'}
open={dialogOpen}
reservation={viewingReservation} reservation={viewingReservation}
tables={tables} onClose={() => setViewingReservation(null)}
settings={settings}
reservations={reservations}
onClose={closeDialog}
onCreate={handleCreate}
onUpdate={handleUpdate}
onStatusChange={(status) => viewingReservation && handleStatusChange(viewingReservation, status)} onStatusChange={(status) => viewingReservation && handleStatusChange(viewingReservation, status)}
isUpdatingStatus={isUpdatingStatus} isUpdatingStatus={isUpdatingStatus}
statusError={error} onEdit={() => {
if (!viewingReservation) return
startEdit(viewingReservation)
setViewingReservation(null)
}}
onDelete={() => { onDelete={() => {
if (!viewingReservation) return if (!viewingReservation) return
setPendingDelete(viewingReservation) setPendingDelete(viewingReservation)
setViewingReservation(null) setViewingReservation(null)
}} }}
/> isDeleting={viewingReservation !== null && deletingId === viewingReservation.id}
)}
<SuccessModal
open={successModal !== null}
title={successModal?.title ?? ''}
message={successModal?.message ?? ''}
onClose={() => setSuccessModal(null)}
/> />
</div> </div>
) )
+17 -361
View File
@@ -142,10 +142,10 @@ a {
} }
.hero-flame { .hero-flame {
display: block; width: 4rem;
width: 7rem; height: 4rem;
height: 3.5rem;
margin: 0 auto 1.5rem; margin: 0 auto 1.5rem;
color: var(--ember);
} }
.hero h1 { .hero h1 {
@@ -183,11 +183,6 @@ a {
border: 1px solid transparent; border: 1px solid transparent;
} }
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-primary { .btn-primary {
background: var(--gold); background: var(--gold);
color: #201709; color: #201709;
@@ -208,17 +203,6 @@ a {
color: var(--text); 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 */ /* Sections */
.section { .section {
@@ -642,18 +626,6 @@ a {
color: var(--text); 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 /* `:not(.btn)` keeps this scoped to the plain submit buttons — anything already
using the `.btn` component (Abbrechen, Bearbeiten, Löschen, …) styles itself using the `.btn` component (Abbrechen, Bearbeiten, Löschen, …) styles itself
and shouldn't be overridden here (a previous version of this rule beat and shouldn't be overridden here (a previous version of this rule beat
@@ -718,13 +690,6 @@ a {
border: 1px solid #23502c; border: 1px solid #23502c;
} }
.staff-page-messages {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 1.25rem 0 0;
}
/* Contact */ /* Contact */
.contact-list { .contact-list {
@@ -881,10 +846,6 @@ a {
.confirm-dialog { .confirm-dialog {
animation: none; animation: none;
} }
.reservation-confirmed-flame {
animation: none;
}
} }
.staff-main { .staff-main {
@@ -1129,19 +1090,6 @@ a {
margin-top: 2.5rem; 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 { .table-filters {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -1228,71 +1176,6 @@ a {
text-overflow: ellipsis; 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 { .status-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -1324,57 +1207,35 @@ a {
border: 1px solid #23502c; 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 { .status-options {
display: flex; display: flex;
gap: 0.25rem; flex-wrap: wrap;
gap: 0.5rem;
margin: 0 0 1.5rem; margin: 0 0 1.5rem;
padding: 0.25rem;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 999px;
} }
.status-option { .status-option {
flex: 1;
cursor: pointer; cursor: pointer;
padding: 0.45rem 0.75rem; padding: 0.4rem 0.75rem;
transition: opacity: 0.55;
background-color 0.15s ease,
color 0.15s ease;
} }
/* `.status-badge.status-option` — matches `.status-badge.status-confirmed` (etc.) /* `.status-badge.status-option` — matches `.status-badge.status-confirmed` (etc.)
on specificity so this actually wins and overrides their fixed `width`/`border`, on specificity so this actually wins and overrides their `border`, rather than
rather than losing to them by cascade order. */ losing to them by cascade order. */
.status-badge.status-option { .status-badge.status-option {
width: auto;
border: none; border: none;
} }
.status-option:disabled { .status-option:hover {
cursor: not-allowed; opacity: 0.85;
} }
.status-option:hover:not(:disabled):not(.is-active) { .status-option.is-active {
color: var(--text); opacity: 1;
background: rgba(243, 234, 217, 0.06); /* `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.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 { .table-actions {
@@ -1508,83 +1369,6 @@ a {
pointer-events: none; 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 /* 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 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 the corner itself — not the box center — lands on the `left`/`top` coordinate
@@ -1691,18 +1475,6 @@ a {
margin: 0; 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 { .confirm-dialog-message {
margin: 0 0 1.25rem; margin: 0 0 1.25rem;
color: var(--text-muted); color: var(--text-muted);
@@ -1743,115 +1515,11 @@ a {
gap: 0.75rem; 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 { .confirm-dialog-actions .btn {
padding: 0.6rem 1.25rem; padding: 0.6rem 1.25rem;
font-size: 0.9rem; 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 { .reservation-detail-dialog {
max-width: 28rem; max-width: 28rem;
} }
@@ -1860,18 +1528,6 @@ a {
margin-bottom: 1.25rem; 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 { .detail-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1967,7 +1623,7 @@ a {
/* Legal page */ /* Legal page */
.legal-page { .legal-page {
padding: 3.5rem 1rem 4rem; padding: 3.5rem 0 4rem;
} }
.legal-page h1 { .legal-page h1 {
-3
View File
@@ -7,9 +7,6 @@ import viteReact from '@vitejs/plugin-react'
const config = defineConfig({ const config = defineConfig({
resolve: { tsconfigPaths: true }, resolve: { tsconfigPaths: true },
plugins: [tanstackStart(), viteReact()], plugins: [tanstackStart(), viteReact()],
preview: {
allowedHosts: ['localhost', '127.0.0.1', 'promo-steakhouse.lukas-harat.com'],
}
}) })
export default config export default config