Compare commits
3
Commits
dd3aa45e02
..
proof
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e19461e41d | ||
|
|
23fddd543f | ||
|
|
ecbc42846b |
@@ -0,0 +1,20 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.github
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.local
|
||||||
|
.nitro
|
||||||
|
.tanstack
|
||||||
|
.wrangler
|
||||||
|
.output
|
||||||
|
.vinxi
|
||||||
|
__unconfig*
|
||||||
|
todos.json
|
||||||
|
README.md
|
||||||
+130
-64
@@ -1,42 +1,29 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
push:
|
||||||
branches: ['**']
|
branches:
|
||||||
|
- main
|
||||||
|
- proof
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.lukas-harat.com
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-test:
|
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.js
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
@@ -45,50 +32,129 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Type check
|
- name: Typecheck
|
||||||
run: npx tsc --noEmit
|
run: npx tsc --noEmit
|
||||||
|
|
||||||
- name: Check that Drizzle migrations are up to date
|
build:
|
||||||
|
needs: test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ secrets.USERNAME }}
|
||||||
|
password: ${{ secrets.TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||||
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||||
|
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
|
||||||
|
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Deploy to TrueNAS
|
||||||
|
env:
|
||||||
|
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||||
|
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
|
||||||
|
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
|
||||||
|
DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
|
||||||
|
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||||
|
|
||||||
|
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||||
|
BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }}
|
||||||
|
BETTER_AUTH_URL: ${{ secrets.BETTER_AUTH_URL }}
|
||||||
|
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
|
||||||
|
STAFF_SEED_EMAIL: ${{ secrets.STAFF_SEED_EMAIL }}
|
||||||
|
STAFF_SEED_PASSWORD: ${{ secrets.STAFF_SEED_PASSWORD }}
|
||||||
|
RESERVATIONS_FROM_EMAIL: ${{ secrets.RESERVATIONS_FROM_EMAIL }}
|
||||||
|
|
||||||
|
IMAGE_TAG: ${{ github.sha }}
|
||||||
|
|
||||||
run: |
|
run: |
|
||||||
npx drizzle-kit generate
|
set -euo pipefail
|
||||||
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
|
|
||||||
|
|
||||||
- name: Apply migrations
|
mkdir -p ~/.ssh
|
||||||
run: npm run db:migrate
|
|
||||||
|
|
||||||
- name: Seed baseline data
|
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
|
||||||
run: npm run db:seed
|
chmod 600 ~/.ssh/deploy_key
|
||||||
|
|
||||||
- name: Build
|
ssh-keyscan \
|
||||||
run: npm run build
|
-p "${DEPLOY_PORT:-22}" \
|
||||||
|
-H "$DEPLOY_HOST" \
|
||||||
|
>> ~/.ssh/known_hosts
|
||||||
|
|
||||||
- name: Smoke test the production build
|
echo "Creating deployment directory if needed..."
|
||||||
run: |
|
|
||||||
npx vite preview --port 4173 &
|
|
||||||
PREVIEW_PID=$!
|
|
||||||
|
|
||||||
for i in $(seq 1 30); do
|
ssh \
|
||||||
curl -sf http://localhost:4173/ >/dev/null 2>&1 && break
|
-i ~/.ssh/deploy_key \
|
||||||
sleep 1
|
-p "${DEPLOY_PORT:-22}" \
|
||||||
done
|
"$DEPLOY_USER@$DEPLOY_HOST" \
|
||||||
|
"mkdir -p '$DEPLOY_PATH'"
|
||||||
|
|
||||||
check() {
|
echo "Uploading compose file..."
|
||||||
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"
|
|
||||||
}
|
|
||||||
|
|
||||||
check / 200
|
scp \
|
||||||
check /staff/login 200
|
-i ~/.ssh/deploy_key \
|
||||||
check /impressum 200
|
-P "${DEPLOY_PORT:-22}" \
|
||||||
|
docker-compose.prod.yml \
|
||||||
|
"$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/docker-compose.yml"
|
||||||
|
|
||||||
kill "$PREVIEW_PID"
|
echo "Deploying..."
|
||||||
|
|
||||||
|
ssh \
|
||||||
|
-i ~/.ssh/deploy_key \
|
||||||
|
-p "${DEPLOY_PORT:-22}" \
|
||||||
|
"$DEPLOY_USER@$DEPLOY_HOST" \
|
||||||
|
"POSTGRES_PASSWORD=$(printf '%q' "$POSTGRES_PASSWORD") \
|
||||||
|
BETTER_AUTH_SECRET=$(printf '%q' "$BETTER_AUTH_SECRET") \
|
||||||
|
BETTER_AUTH_URL=$(printf '%q' "$BETTER_AUTH_URL") \
|
||||||
|
RESEND_API_KEY=$(printf '%q' "$RESEND_API_KEY") \
|
||||||
|
RESERVATIONS_FROM_EMAIL=$(printf '%q' "$RESERVATIONS_FROM_EMAIL") \
|
||||||
|
STAFF_SEED_EMAIL=$(printf '%q' "$STAFF_SEED_EMAIL") \
|
||||||
|
STAFF_SEED_PASSWORD=$(printf '%q' "$STAFF_SEED_PASSWORD") \
|
||||||
|
IMAGE_TAG=$(printf '%q' "$IMAGE_TAG") \
|
||||||
|
bash -c '
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd \"$DEPLOY_PATH\"
|
||||||
|
|
||||||
|
echo \"Pulling images...\"
|
||||||
|
docker compose pull
|
||||||
|
|
||||||
|
echo \"Starting PostgreSQL...\"
|
||||||
|
docker compose up -d postgres
|
||||||
|
|
||||||
|
echo "Running database migrations..."
|
||||||
|
docker compose run --rm app npx drizzle-kit migrate
|
||||||
|
|
||||||
|
echo "Seeding database..."
|
||||||
|
docker compose run --rm app npm run db:seed
|
||||||
|
|
||||||
|
echo "Starting application..."
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
echo \"Cleaning unused images...\"
|
||||||
|
docker image prune -f
|
||||||
|
|
||||||
|
echo \"Deployment complete.\"
|
||||||
|
'"
|
||||||
|
|||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
FROM node:22-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Dummy values so the build-time env checks in src/db/index.ts and
|
||||||
|
# src/lib/auth-instance.ts don't throw while bundling. The real values are
|
||||||
|
# supplied at container runtime.
|
||||||
|
ENV DATABASE_URL=postgres://build:build@localhost:5432/build \
|
||||||
|
BETTER_AUTH_SECRET=build-time-placeholder \
|
||||||
|
BETTER_AUTH_URL=http://localhost:3000
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:22-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
# This build has no standalone Node server entrypoint — dist/server/server.js
|
||||||
|
# only exports a fetch handler. `vite preview` (via the tanstackStart plugin)
|
||||||
|
# is the supported way to serve it, and it re-resolves the app's router entry
|
||||||
|
# from src/ at startup, so the full source tree (not just dist/) has to ship.
|
||||||
|
COPY --from=builder /app ./
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["npm", "run", "preview", "--", "--host", "0.0.0.0", "--port", "3000"]
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: git.lukas-harat.com/lukas/promo_steakhouse:${IMAGE_TAG:-latest}
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- '3003:3000'
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgres://steakhouse:${POSTGRES_PASSWORD}@postgres:5432/steakhouse
|
||||||
|
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
|
||||||
|
BETTER_AUTH_URL: ${BETTER_AUTH_URL}
|
||||||
|
STAFF_SEED_EMAIL: ${STAFF_SEED_EMAIL}
|
||||||
|
STAFF_SEED_PASSWORD: ${STAFF_SEED_PASSWORD}
|
||||||
|
RESEND_API_KEY: ${RESEND_API_KEY}
|
||||||
|
RESERVATIONS_FROM_EMAIL: ${RESERVATIONS_FROM_EMAIL}
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: steakhouse
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
POSTGRES_DB: steakhouse
|
||||||
|
volumes:
|
||||||
|
- steakhouse-postgres-data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U steakhouse -d steakhouse"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
steakhouse-postgres-data:
|
||||||
Generated
+278
@@ -10,6 +10,7 @@
|
|||||||
"@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",
|
||||||
@@ -1310,6 +1311,271 @@
|
|||||||
"@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",
|
||||||
@@ -3732,6 +3998,18 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"@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",
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<rect width="64" height="64" rx="14" fill="#17120f"/>
|
||||||
|
<rect x="2" y="2" width="60" height="60" rx="12" fill="none" stroke="#c9a15a" stroke-width="2"/>
|
||||||
|
<text x="32" y="44" font-family="Georgia, 'Times New Roman', serif" font-size="34" font-weight="600" text-anchor="middle" fill="#e0bb78">M</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 371 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 500"><path d="M 79.47,45.64 C 71.12,45.78 60.80,48.79 55.89,52.64 C 50.98,56.49 48.85,62.75 50.00,68.74 C 51.15,74.74 56.86,81.99 62.81,88.62 C 68.75,95.24 77.19,102.34 85.70,108.49 C 94.20,114.64 104.30,120.74 113.83,125.51 C 123.36,130.27 133.30,134.13 142.89,137.07 C 152.48,140.00 161.90,141.73 171.36,143.12 C 180.83,144.51 190.19,144.97 199.69,145.40 C 209.20,145.83 218.68,145.63 228.38,145.70 C 238.08,145.76 247.88,145.49 257.92,145.78 C 267.95,146.06 278.42,146.34 288.57,147.41 C 298.73,148.47 309.21,149.81 318.83,152.17 C 328.44,154.53 337.98,157.42 346.24,161.57 C 354.50,165.73 362.29,170.66 368.39,177.11 C 374.49,183.56 379.31,191.38 382.83,200.28 C 386.35,209.19 386.77,220.92 389.51,230.55 C 392.24,240.17 395.02,249.65 399.25,258.04 C 403.47,266.43 409.89,273.06 414.85,280.88 C 419.80,288.70 425.82,296.22 428.98,304.97 C 432.14,313.71 433.03,323.66 433.82,333.38 C 434.62,343.09 433.47,353.42 433.75,363.25 C 434.04,373.07 433.68,382.96 435.52,392.31 C 437.35,401.67 440.01,411.11 444.78,419.37 C 449.55,427.63 456.73,436.04 464.14,441.88 C 471.56,447.71 480.75,452.58 489.28,454.36 C 497.81,456.14 507.05,455.10 515.32,452.56 C 523.60,450.02 531.95,444.80 538.93,439.13 C 545.90,433.45 552.25,426.02 557.18,418.50 C 562.11,410.98 565.65,402.67 568.51,394.01 C 571.36,385.34 572.93,376.03 574.30,366.50 C 575.67,356.97 575.77,346.70 576.73,336.83 C 577.68,326.96 577.97,316.66 580.02,307.28 C 582.06,297.89 584.88,288.90 588.99,280.55 C 593.09,272.19 599.37,265.01 604.64,257.15 C 609.90,249.28 616.07,241.81 620.59,233.35 C 625.11,224.89 628.08,215.26 631.74,206.39 C 635.41,197.51 637.83,187.81 642.59,180.10 C 647.35,172.40 652.72,165.26 660.30,160.18 C 667.89,155.10 678.09,152.07 688.10,149.61 C 698.10,147.15 709.79,146.33 720.34,145.43 C 730.89,144.53 741.33,144.43 751.41,144.20 C 761.48,143.97 771.14,144.17 780.77,144.04 C 790.40,143.91 799.75,143.96 809.20,143.44 C 818.65,142.91 827.94,142.31 837.47,140.89 C 846.99,139.47 856.56,137.70 866.35,134.89 C 876.14,132.08 886.65,128.54 896.23,124.04 C 905.80,119.54 915.88,114.14 923.78,107.87 C 931.68,101.61 939.26,94.04 943.63,86.46 C 948.00,78.89 951.03,69.06 950.00,62.42 C 948.97,55.78 943.45,49.15 937.43,46.64 C 931.42,44.12 922.45,45.15 913.92,47.34 C 905.39,49.53 895.66,55.24 886.23,59.77 C 876.79,64.29 866.85,70.36 857.32,74.49 C 847.79,78.63 838.46,82.23 829.04,84.56 C 819.61,86.89 810.31,88.20 800.77,88.46 C 791.24,88.71 781.58,87.55 771.84,86.11 C 762.10,84.67 752.23,82.10 742.35,79.81 C 732.47,77.52 722.50,74.57 712.58,72.36 C 702.65,70.15 692.68,67.75 682.79,66.54 C 672.91,65.34 663.05,64.73 653.27,65.12 C 643.50,65.52 633.80,67.12 624.15,68.91 C 614.49,70.71 604.90,73.41 595.33,75.88 C 585.76,78.36 576.24,81.36 566.73,83.76 C 557.21,86.17 547.73,88.72 538.24,90.29 C 528.75,91.86 519.27,93.10 509.77,93.19 C 500.26,93.28 490.75,92.26 481.22,90.82 C 471.68,89.39 462.13,86.92 452.56,84.58 C 442.98,82.23 433.39,79.25 423.76,76.77 C 414.14,74.29 404.50,71.56 394.82,69.72 C 385.14,67.88 375.44,66.28 365.70,65.75 C 355.96,65.22 346.18,65.68 336.39,66.54 C 326.60,67.41 316.78,69.20 306.95,70.92 C 297.12,72.64 287.26,74.96 277.41,76.86 C 267.56,78.77 257.70,80.94 247.85,82.35 C 238.00,83.77 228.13,85.07 218.32,85.38 C 208.51,85.68 198.66,85.47 188.99,84.19 C 179.32,82.91 169.69,80.84 160.31,77.69 C 150.93,74.54 141.76,69.61 132.71,65.30 C 123.65,60.99 114.85,55.10 105.97,51.83 C 97.10,48.55 87.82,45.51 79.47,45.64 Z" fill="#7B2D2F"/></svg>
|
||||||
|
After Width: | Height: | Size: 3.4 KiB |
Binary file not shown.
@@ -0,0 +1,191 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import type { PDFDocumentProxy, RenderTask } from 'pdfjs-dist'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
src: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// pdfjs-dist touches the DOM/Canvas at import time, so it's loaded lazily inside
|
||||||
|
// the effect below instead of at module scope — this file also runs during SSR.
|
||||||
|
export function PdfMenu({ src }: Props) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
|
const pdfRef = useRef<PDFDocumentProxy | null>(null)
|
||||||
|
const directionRef = useRef(1)
|
||||||
|
const hasRenderedOnceRef = useRef(false)
|
||||||
|
const [error, setError] = useState(false)
|
||||||
|
const [numPages, setNumPages] = useState(0)
|
||||||
|
const [pageNumber, setPageNumber] = useState(1)
|
||||||
|
const [isBusy, setIsBusy] = useState(false)
|
||||||
|
|
||||||
|
function goToPage(next: number) {
|
||||||
|
directionRef.current = next > pageNumber ? 1 : -1
|
||||||
|
setPageNumber(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
setError(false)
|
||||||
|
setNumPages(0)
|
||||||
|
setPageNumber(1)
|
||||||
|
pdfRef.current = null
|
||||||
|
hasRenderedOnceRef.current = false
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const pdfjsLib = await import('pdfjs-dist')
|
||||||
|
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||||
|
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||||
|
import.meta.url,
|
||||||
|
).href
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pdf = await pdfjsLib.getDocument({ url: src }).promise
|
||||||
|
if (cancelled) return
|
||||||
|
pdfRef.current = pdf
|
||||||
|
setNumPages(pdf.numPages)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('PdfMenu failed to load', err)
|
||||||
|
if (!cancelled) setError(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [src])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const pdf = pdfRef.current
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
const container = containerRef.current
|
||||||
|
if (!pdf || !canvas || !container || numPages === 0) return
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
let renderTask: RenderTask | undefined
|
||||||
|
let exitTimeout: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
|
function wait(ms: number) {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
exitTimeout = setTimeout(resolve, ms)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function render(pdf: PDFDocumentProxy, canvas: HTMLCanvasElement, container: HTMLDivElement) {
|
||||||
|
const direction = directionRef.current
|
||||||
|
setIsBusy(true)
|
||||||
|
|
||||||
|
if (hasRenderedOnceRef.current) {
|
||||||
|
canvas.style.transition = 'opacity 0.18s ease, transform 0.18s ease'
|
||||||
|
canvas.style.opacity = '0'
|
||||||
|
canvas.style.transform = `translateX(${direction * -2}rem)`
|
||||||
|
await wait(180)
|
||||||
|
if (cancelled) return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const page = await pdf.getPage(pageNumber)
|
||||||
|
if (cancelled) return
|
||||||
|
const unscaledViewport = page.getViewport({ scale: 1 })
|
||||||
|
const scale = container.clientWidth / unscaledViewport.width
|
||||||
|
const viewport = page.getViewport({ scale })
|
||||||
|
|
||||||
|
const pixelRatio = window.devicePixelRatio || 1
|
||||||
|
canvas.width = viewport.width * pixelRatio
|
||||||
|
canvas.height = viewport.height * pixelRatio
|
||||||
|
canvas.style.width = `${viewport.width}px`
|
||||||
|
canvas.style.height = `${viewport.height}px`
|
||||||
|
|
||||||
|
const context = canvas.getContext('2d')
|
||||||
|
if (!context) return
|
||||||
|
context.scale(pixelRatio, pixelRatio)
|
||||||
|
|
||||||
|
renderTask = page.render({ canvasContext: context, viewport, canvas })
|
||||||
|
await renderTask.promise
|
||||||
|
if (cancelled) return
|
||||||
|
|
||||||
|
// Jump to the entry offset with transitions off, then force the browser to
|
||||||
|
// commit that position before re-enabling them — otherwise the two style
|
||||||
|
// writes land in the same frame and the entry animation never paints.
|
||||||
|
canvas.style.transition = 'none'
|
||||||
|
canvas.style.transform = `translateX(${direction * 2}rem)`
|
||||||
|
canvas.offsetHeight
|
||||||
|
canvas.style.transition = 'opacity 0.22s ease, transform 0.22s ease'
|
||||||
|
canvas.style.opacity = '1'
|
||||||
|
canvas.style.transform = 'translateX(0)'
|
||||||
|
hasRenderedOnceRef.current = true
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) {
|
||||||
|
console.error('PdfMenu failed to render page', err)
|
||||||
|
setError(true)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setIsBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render(pdf, canvas, container)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
clearTimeout(exitTimeout)
|
||||||
|
renderTask?.cancel()
|
||||||
|
}
|
||||||
|
}, [numPages, pageNumber])
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<p className="field-hint">
|
||||||
|
Die Speisekarte konnte nicht geladen werden.{' '}
|
||||||
|
<a href={src} target="_blank" rel="noopener noreferrer">
|
||||||
|
Als PDF öffnen
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pdf-menu" ref={containerRef}>
|
||||||
|
<div className="pdf-menu-viewport">
|
||||||
|
<canvas className="pdf-menu-page" ref={canvasRef} />
|
||||||
|
{numPages === 0 && (
|
||||||
|
<div className="pdf-menu-loading" role="status">
|
||||||
|
<div className="pdf-menu-spinner" aria-hidden="true" />
|
||||||
|
<p>Die Speisekarte wird aufgetischt …</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{numPages > 1 && (
|
||||||
|
<div className="pdf-menu-pagination">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => goToPage(pageNumber - 1)}
|
||||||
|
disabled={isBusy || pageNumber <= 1}
|
||||||
|
>
|
||||||
|
Zurück
|
||||||
|
</button>
|
||||||
|
<span>
|
||||||
|
Seite {pageNumber} von {numPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => goToPage(pageNumber + 1)}
|
||||||
|
disabled={isBusy || pageNumber >= numPages}
|
||||||
|
>
|
||||||
|
Weiter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="field-hint">
|
||||||
|
Speisekarte nicht geladen?{' '}
|
||||||
|
<a href={src} target="_blank" rel="noopener noreferrer">
|
||||||
|
Klick hier
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean
|
||||||
|
customerName: string
|
||||||
|
customerEmail: string
|
||||||
|
tableName: string
|
||||||
|
date: string
|
||||||
|
startTime: string
|
||||||
|
endTime: string
|
||||||
|
partySize: number
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function FlameBadgeIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="reservation-confirmed-flame" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M12 2c1 3-3 4-3 7.5A3.5 3.5 0 0 0 12 13a3.5 3.5 0 0 0 3-5.3c1.5 1 2.5 3 2.5 5a5.5 5.5 0 1 1-11 0C6.5 8.5 9.5 6 12 2Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.4"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReservationConfirmedModal({
|
||||||
|
open,
|
||||||
|
customerName,
|
||||||
|
customerEmail,
|
||||||
|
tableName,
|
||||||
|
date,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
partySize,
|
||||||
|
onClose,
|
||||||
|
}: Props) {
|
||||||
|
const closeRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
|
||||||
|
closeRef.current?.focus()
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [open, onClose])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="confirm-dialog-backdrop" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="confirm-dialog reservation-confirmed-dialog"
|
||||||
|
role="alertdialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="reservation-confirmed-title"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<FlameBadgeIcon />
|
||||||
|
|
||||||
|
<h2 id="reservation-confirmed-title" className="reservation-confirmed-title">
|
||||||
|
Tisch reserviert!
|
||||||
|
</h2>
|
||||||
|
<p className="reservation-confirmed-message">
|
||||||
|
Bis bald, {customerName}! Wir halten Ihnen den Platz warm und legen schon mal Kohle nach.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<dl className="detail-list">
|
||||||
|
<div>
|
||||||
|
<dt>Tisch</dt>
|
||||||
|
<dd>{tableName}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Datum</dt>
|
||||||
|
<dd>{date}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Uhrzeit</dt>
|
||||||
|
<dd>
|
||||||
|
{startTime} – {endTime}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Personen</dt>
|
||||||
|
<dd>{partySize}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<p className="reservation-confirmed-footnote">
|
||||||
|
Eine Bestätigung ist unterwegs an <strong>{customerEmail}</strong>. Dort finden Sie auch einen
|
||||||
|
Link, falls sich doch noch etwas ändert.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="confirm-dialog-actions">
|
||||||
|
<button ref={closeRef} type="button" className="btn btn-primary" onClick={onClose}>
|
||||||
|
Alles klar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -14,6 +14,9 @@ type Props = {
|
|||||||
// made — both configurable restaurant settings, falling back to sane defaults.
|
// 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
|
||||||
@@ -101,6 +104,7 @@ 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)
|
||||||
@@ -189,7 +193,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)
|
return generateTimeOptions(getOpeningRangesForWeekday(getWeekday(dateStr)), intervalMinutes, durationMinutes)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSelectableDate(dateStr: string) {
|
function isSelectableDate(dateStr: string) {
|
||||||
|
|||||||
@@ -1,136 +0,0 @@
|
|||||||
import { useEffect } from 'react'
|
|
||||||
|
|
||||||
import { ActionMenu } from '#/components/action-menu'
|
|
||||||
import { RESERVATION_STATUS_LABELS, type ReservationStatus, type getReservations } from '#/lib/reservations'
|
|
||||||
|
|
||||||
type ReservationRow = Awaited<ReturnType<typeof getReservations>>[number]
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
reservation: ReservationRow | null
|
|
||||||
onClose: () => void
|
|
||||||
onStatusChange: (status: ReservationStatus) => void
|
|
||||||
isUpdatingStatus?: boolean
|
|
||||||
onEdit: () => void
|
|
||||||
onDelete: () => void
|
|
||||||
isDeleting?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUS_OPTIONS = Object.keys(RESERVATION_STATUS_LABELS) as ReservationStatus[]
|
|
||||||
|
|
||||||
const dateTimeFormatter = new Intl.DateTimeFormat('de-DE', {
|
|
||||||
dateStyle: 'full',
|
|
||||||
timeStyle: 'short',
|
|
||||||
})
|
|
||||||
|
|
||||||
export function ReservationDetailDialog({
|
|
||||||
reservation,
|
|
||||||
onClose,
|
|
||||||
onStatusChange,
|
|
||||||
isUpdatingStatus,
|
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
isDeleting,
|
|
||||||
}: Props) {
|
|
||||||
useEffect(() => {
|
|
||||||
if (!reservation) return
|
|
||||||
|
|
||||||
function handleKeyDown(event: KeyboardEvent) {
|
|
||||||
if (event.key === 'Escape') onClose()
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('keydown', handleKeyDown)
|
|
||||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
||||||
}, [reservation, onClose])
|
|
||||||
|
|
||||||
if (!reservation) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="confirm-dialog-backdrop" onClick={onClose}>
|
|
||||||
<div
|
|
||||||
className="confirm-dialog reservation-detail-dialog"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="reservation-detail-title"
|
|
||||||
onClick={(event) => event.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="confirm-dialog-header">
|
|
||||||
<h2 id="reservation-detail-title" className="confirm-dialog-title">
|
|
||||||
Reservierung von {reservation.customerName}
|
|
||||||
</h2>
|
|
||||||
<ActionMenu label={`Aktionen für Reservierung von ${reservation.customerName}`}>
|
|
||||||
<button type="button" className="btn btn-secondary" onClick={onEdit}>
|
|
||||||
Bearbeiten
|
|
||||||
</button>
|
|
||||||
<button type="button" className="btn btn-danger" disabled={isDeleting} onClick={onDelete}>
|
|
||||||
{isDeleting ? 'Löschen …' : 'Löschen'}
|
|
||||||
</button>
|
|
||||||
</ActionMenu>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="status-options">
|
|
||||||
{STATUS_OPTIONS.map((status) => (
|
|
||||||
<button
|
|
||||||
key={status}
|
|
||||||
type="button"
|
|
||||||
className={`status-badge status-option status-${status}${
|
|
||||||
reservation.status === status ? ' is-active' : ''
|
|
||||||
}`}
|
|
||||||
disabled={isUpdatingStatus}
|
|
||||||
onClick={() => onStatusChange(status)}
|
|
||||||
>
|
|
||||||
{RESERVATION_STATUS_LABELS[status]}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<dl className="detail-list">
|
|
||||||
{reservation.tableNumber !== null && (
|
|
||||||
<div>
|
|
||||||
<dt>Tischnummer</dt>
|
|
||||||
<dd>{reservation.tableNumber}</dd>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<dt>Tisch</dt>
|
|
||||||
<dd>{reservation.tableName}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Datum & 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,456 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import { ActionMenu } from '#/components/action-menu'
|
||||||
|
import { ReservationDateTimeField } from '#/components/reservation-datetime-field'
|
||||||
|
import { formatDateOnly, formatDateTimeLocal } from '#/lib/datetime'
|
||||||
|
import { generateTimeOptions, getNextAvailableSlot, getOpeningRangesForWeekday } from '#/lib/opening-hours'
|
||||||
|
import {
|
||||||
|
RESERVATION_STATUS_LABELS,
|
||||||
|
type ReservationStatus,
|
||||||
|
type getReservations,
|
||||||
|
type getTables,
|
||||||
|
} from '#/lib/reservations'
|
||||||
|
import { type getReservationSettings } from '#/lib/settings'
|
||||||
|
|
||||||
|
type ReservationRow = Awaited<ReturnType<typeof getReservations>>[number]
|
||||||
|
type TableRow = Awaited<ReturnType<typeof getTables>>[number]
|
||||||
|
type SettingsRow = Awaited<ReturnType<typeof getReservationSettings>>
|
||||||
|
|
||||||
|
export type ReservationPayload = {
|
||||||
|
tableId: number
|
||||||
|
customerName: string
|
||||||
|
customerEmail: string
|
||||||
|
customerPhone: string
|
||||||
|
message: string
|
||||||
|
partySize: number
|
||||||
|
reservationTime: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormState = {
|
||||||
|
tableId: number | string
|
||||||
|
customerName: string
|
||||||
|
customerEmail: string
|
||||||
|
customerPhone: string
|
||||||
|
message: string
|
||||||
|
partySize: number
|
||||||
|
reservationTime: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
// `null` reservation with `open: true` is "create" mode.
|
||||||
|
open: boolean
|
||||||
|
reservation: ReservationRow | null
|
||||||
|
tables: TableRow[]
|
||||||
|
settings: SettingsRow
|
||||||
|
reservations: ReservationRow[]
|
||||||
|
onClose: () => void
|
||||||
|
onCreate: (payload: ReservationPayload) => Promise<void>
|
||||||
|
onUpdate: (id: number, payload: ReservationPayload) => Promise<void>
|
||||||
|
onStatusChange: (status: ReservationStatus) => void
|
||||||
|
isUpdatingStatus?: boolean
|
||||||
|
onDelete: () => void
|
||||||
|
statusError?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = Object.keys(RESERVATION_STATUS_LABELS) as ReservationStatus[]
|
||||||
|
|
||||||
|
const dateFormatter = new Intl.DateTimeFormat('de-DE', {
|
||||||
|
dateStyle: 'full',
|
||||||
|
})
|
||||||
|
|
||||||
|
const timeFormatter = new Intl.DateTimeFormat('de-DE', {
|
||||||
|
timeStyle: 'short',
|
||||||
|
})
|
||||||
|
|
||||||
|
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
// Also used as the phone input's `pattern` attribute below, so the two stay in sync.
|
||||||
|
const PHONE_PATTERN_SOURCE = String.raw`[+]?[0-9()\-.\s]{6,30}`
|
||||||
|
const PHONE_PATTERN = new RegExp(`^${PHONE_PATTERN_SOURCE}$`)
|
||||||
|
|
||||||
|
function emptyForm(defaultTableId: number | string, settings: SettingsRow): FormState {
|
||||||
|
const slot = getNextAvailableSlot(
|
||||||
|
new Date(Date.now() + settings.minLeadTimeMinutes * 60_000),
|
||||||
|
settings.reservationIntervalMinutes,
|
||||||
|
settings.reservationDurationMinutes,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
tableId: defaultTableId,
|
||||||
|
customerName: '',
|
||||||
|
customerEmail: '',
|
||||||
|
customerPhone: '',
|
||||||
|
message: '',
|
||||||
|
partySize: 2,
|
||||||
|
reservationTime: slot ? `${slot.date}T${slot.time}` : '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formFromReservation(reservation: ReservationRow): FormState {
|
||||||
|
return {
|
||||||
|
tableId: reservation.tableId,
|
||||||
|
customerName: reservation.customerName,
|
||||||
|
customerEmail: reservation.customerEmail,
|
||||||
|
customerPhone: reservation.customerPhone ?? '',
|
||||||
|
message: reservation.message ?? '',
|
||||||
|
partySize: reservation.partySize,
|
||||||
|
reservationTime: formatDateTimeLocal(new Date(reservation.reservationTime)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rendered with a `key` (see staff.index.tsx) so a fresh instance mounts every time
|
||||||
|
// the dialog opens — that's what lets this component safely derive its initial
|
||||||
|
// state from props once, instead of syncing it via effects.
|
||||||
|
export function ReservationDialog({
|
||||||
|
open,
|
||||||
|
reservation,
|
||||||
|
tables,
|
||||||
|
settings,
|
||||||
|
reservations,
|
||||||
|
onClose,
|
||||||
|
onCreate,
|
||||||
|
onUpdate,
|
||||||
|
onStatusChange,
|
||||||
|
isUpdatingStatus,
|
||||||
|
onDelete,
|
||||||
|
statusError,
|
||||||
|
}: Props) {
|
||||||
|
const [isEditing, setIsEditing] = useState(reservation === null)
|
||||||
|
const [form, setForm] = useState<FormState>(() =>
|
||||||
|
reservation ? formFromReservation(reservation) : emptyForm(tables[0]?.id ?? '', settings),
|
||||||
|
)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
// Other reservations already sitting on the currently selected table + date, so the
|
||||||
|
// time picker can block them instead of letting staff pick a slot the server will
|
||||||
|
// just reject as double-booked. A reservation occupies its whole configured
|
||||||
|
// duration, not just its exact start time, so every interval-grid slot that falls
|
||||||
|
// inside that window counts as taken too. `minLeadTimeMinutes` is reused here as a
|
||||||
|
// table turnover buffer, extending the taken window on both sides so back-to-back
|
||||||
|
// bookings on the same table always leave that much of a gap.
|
||||||
|
const [selectedDate] = form.reservationTime.split('T')
|
||||||
|
const otherReservationsOnSelection = reservations.filter(
|
||||||
|
(r) =>
|
||||||
|
r.id !== reservation?.id &&
|
||||||
|
String(r.tableId) === String(form.tableId) &&
|
||||||
|
formatDateOnly(new Date(r.reservationTime)) === selectedDate,
|
||||||
|
)
|
||||||
|
const turnoverBufferMs = settings.minLeadTimeMinutes * 60_000
|
||||||
|
const bookedTimesForForm = selectedDate
|
||||||
|
? generateTimeOptions(
|
||||||
|
getOpeningRangesForWeekday(new Date(`${selectedDate}T00:00`).getDay()),
|
||||||
|
settings.reservationIntervalMinutes,
|
||||||
|
settings.reservationDurationMinutes,
|
||||||
|
).filter((option) => {
|
||||||
|
const slotStart = new Date(`${selectedDate}T${option}`).getTime()
|
||||||
|
return otherReservationsOnSelection.some((r) => {
|
||||||
|
const otherStart = new Date(r.reservationTime).getTime() - turnoverBufferMs
|
||||||
|
const otherEnd = otherStart + r.durationMinutes * 60_000 + 2 * turnoverBufferMs
|
||||||
|
return slotStart >= otherStart && slotStart < otherEnd
|
||||||
|
})
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
|
||||||
|
// Mirrors the form fields' own `required`/`type`/`pattern`/`min`/`max` constraints,
|
||||||
|
// so the submit button is disabled until they'd all actually pass — instead of only
|
||||||
|
// catching an invalid table/time on submit and leaving everything else to the
|
||||||
|
// browser's native validation popup.
|
||||||
|
const isFormValid =
|
||||||
|
Boolean(form.tableId) &&
|
||||||
|
form.customerName.trim().length > 0 &&
|
||||||
|
EMAIL_PATTERN.test(form.customerEmail) &&
|
||||||
|
PHONE_PATTERN.test(form.customerPhone) &&
|
||||||
|
form.partySize >= 1 &&
|
||||||
|
form.partySize <= 20 &&
|
||||||
|
Boolean(form.reservationTime.split('T')[1])
|
||||||
|
|
||||||
|
function startEditing() {
|
||||||
|
if (reservation) setForm(formFromReservation(reservation))
|
||||||
|
setError(null)
|
||||||
|
setIsEditing(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEditing() {
|
||||||
|
if (!reservation) {
|
||||||
|
onClose()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setForm(formFromReservation(reservation))
|
||||||
|
setError(null)
|
||||||
|
setIsEditing(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(event: React.FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
if (!form.tableId) {
|
||||||
|
setError('Bitte wählen Sie einen Tisch aus')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.reservationTime.split('T')[1]) {
|
||||||
|
setError('Bitte wählen Sie eine gültige Uhrzeit aus')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true)
|
||||||
|
try {
|
||||||
|
const payload: ReservationPayload = {
|
||||||
|
tableId: Number(form.tableId),
|
||||||
|
customerName: form.customerName,
|
||||||
|
customerEmail: form.customerEmail,
|
||||||
|
customerPhone: form.customerPhone,
|
||||||
|
message: form.message,
|
||||||
|
partySize: Number(form.partySize),
|
||||||
|
reservationTime: new Date(form.reservationTime).toISOString(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reservation) {
|
||||||
|
await onUpdate(reservation.id, payload)
|
||||||
|
} else {
|
||||||
|
await onCreate(payload)
|
||||||
|
}
|
||||||
|
onClose()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = reservation
|
||||||
|
? isEditing
|
||||||
|
? `Reservierung von ${reservation.customerName} bearbeiten`
|
||||||
|
: `Reservierung von ${reservation.customerName}`
|
||||||
|
: 'Neue Reservierung'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="confirm-dialog-backdrop" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className={`confirm-dialog reservation-detail-dialog${isEditing ? ' reservation-dialog-editing' : ''}`}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="reservation-dialog-title"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="confirm-dialog-header">
|
||||||
|
<h2 id="reservation-dialog-title" className="confirm-dialog-title">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{reservation && !isEditing && (
|
||||||
|
<div className="confirm-dialog-header-actions">
|
||||||
|
<ActionMenu label={`Weitere Aktionen für Reservierung von ${reservation.customerName}`}>
|
||||||
|
<button type="button" className="btn btn-danger" onClick={onDelete}>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</ActionMenu>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEditing ? (
|
||||||
|
<form className="reservation-form reservation-form-dialog" onSubmit={handleSubmit}>
|
||||||
|
<label>
|
||||||
|
Tisch
|
||||||
|
<select
|
||||||
|
value={form.tableId}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, tableId: e.target.value }))}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
Tisch auswählen
|
||||||
|
</option>
|
||||||
|
{tables.map((table) => (
|
||||||
|
<option key={table.id} value={table.id}>
|
||||||
|
{table.name} ({table.seats} Plätze)
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<label>
|
||||||
|
Name
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.customerName}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, customerName: e.target.value }))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
E-Mail
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={form.customerEmail}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, customerEmail: e.target.value }))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<label>
|
||||||
|
Telefon
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
inputMode="tel"
|
||||||
|
pattern={PHONE_PATTERN_SOURCE}
|
||||||
|
title="Nur Ziffern, Leerzeichen und + ( ) - ."
|
||||||
|
value={form.customerPhone}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, customerPhone: e.target.value }))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Anzahl Personen
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={1}
|
||||||
|
max={20}
|
||||||
|
value={form.partySize}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, partySize: Number(e.target.value) }))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Datum & 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean
|
||||||
|
title: string
|
||||||
|
message: React.ReactNode
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function CheckIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="1.6" />
|
||||||
|
<path
|
||||||
|
d="m8.2 12.3 2.6 2.6 5-5.4"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SuccessModal({ open, title, message, onClose }: Props) {
|
||||||
|
const closeRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
|
||||||
|
closeRef.current?.focus()
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [open, onClose])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="confirm-dialog-backdrop" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="confirm-dialog success-modal"
|
||||||
|
role="alertdialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="success-modal-title"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="success-modal-icon">
|
||||||
|
<CheckIcon />
|
||||||
|
</div>
|
||||||
|
<h2 id="success-modal-title" className="confirm-dialog-title">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<p className="confirm-dialog-message">{message}</p>
|
||||||
|
<div className="confirm-dialog-actions">
|
||||||
|
<button ref={closeRef} type="button" className="btn btn-primary" onClick={onClose}>
|
||||||
|
OK
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -57,12 +57,16 @@ export function isOnReservationInterval(date: Date, intervalMinutes: number = RE
|
|||||||
return date.getUTCMinutes() % intervalMinutes === 0 && date.getUTCSeconds() === 0
|
return date.getUTCMinutes() % intervalMinutes === 0 && date.getUTCSeconds() === 0
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isWithinOpeningHours(date: Date) {
|
// `durationMinutes` requires the whole reservation — not just its start — to fit
|
||||||
|
// before closing, so a slot too close to close time is rejected outright.
|
||||||
|
export function isWithinOpeningHours(date: Date, durationMinutes = 0) {
|
||||||
const { weekday, minutesSinceMidnight } = getBerlinTimeParts(date)
|
const { 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) => minutesSinceMidnight >= toMinutes(range.start) && minutesSinceMidnight < toMinutes(range.end),
|
(range) =>
|
||||||
|
minutesSinceMidnight >= toMinutes(range.start) &&
|
||||||
|
minutesSinceMidnight + durationMinutes <= toMinutes(range.end),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,9 +93,15 @@ export function getOpeningRangesForWeekday(weekday: number): OpeningRange[] {
|
|||||||
return OPENING_HOURS_BY_WEEKDAY[weekday] ?? []
|
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[] = []
|
||||||
|
|
||||||
@@ -99,7 +109,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 < endMinutes; minutes += intervalMinutes) {
|
for (let minutes = startMinutes; minutes + durationMinutes <= 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')}`)
|
||||||
@@ -112,13 +122,14 @@ 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)
|
const options = generateTimeOptions(getOpeningRangesForWeekday(day.getDay()), intervalMinutes, durationMinutes)
|
||||||
|
|
||||||
for (const time of options) {
|
for (const time of options) {
|
||||||
const slot = new Date(`${dateStr}T${time}`)
|
const slot = new Date(`${dateStr}T${time}`)
|
||||||
|
|||||||
+28
-5
@@ -29,6 +29,7 @@ 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
|
||||||
|
|
||||||
@@ -50,28 +51,33 @@ 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)) {
|
if (!isWithinOpeningHours(reservationTime, durationMinutes)) {
|
||||||
throw new Error('Zu dieser Zeit haben wir leider geschlossen')
|
throw new Error('Zu dieser Zeit reicht die verbleibende Zeit bis zum Schließen nicht mehr aus')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A table is occupied for its own configured duration, not just the exact minute it
|
// 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.
|
// older booking keeps whatever duration was in effect when it was made. `bufferMinutes`
|
||||||
|
// (the current minLeadTimeMinutes setting, reused here as a table turnover gap) is
|
||||||
|
// added on both sides of the existing reservation, so back-to-back bookings need at
|
||||||
|
// least that much breathing room between one guest leaving and the next arriving.
|
||||||
async function findOverlappingReservation(
|
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()
|
const existingStart = r.reservationTime.getTime() - bufferMs
|
||||||
const existingEnd = existingStart + r.durationMinutes * 60_000
|
const existingEnd = existingStart + r.durationMinutes * 60_000 + 2 * bufferMs
|
||||||
return existingStart < end && start.getTime() < existingEnd
|
return existingStart < end && start.getTime() < existingEnd
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -217,6 +223,19 @@ 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),
|
||||||
@@ -245,6 +264,7 @@ 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
|
||||||
@@ -264,6 +284,7 @@ export const createReservation = createServerFn({ method: 'POST' })
|
|||||||
data.tableId,
|
data.tableId,
|
||||||
reservationTime,
|
reservationTime,
|
||||||
settings.reservationDurationMinutes,
|
settings.reservationDurationMinutes,
|
||||||
|
settings.minLeadTimeMinutes,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (conflict) {
|
if (conflict) {
|
||||||
@@ -314,6 +335,7 @@ 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
|
||||||
@@ -333,6 +355,7 @@ export const updateReservation = createServerFn({ method: 'POST' })
|
|||||||
data.tableId,
|
data.tableId,
|
||||||
reservationTime,
|
reservationTime,
|
||||||
settings.reservationDurationMinutes,
|
settings.reservationDurationMinutes,
|
||||||
|
settings.minLeadTimeMinutes,
|
||||||
data.id,
|
data.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ 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,
|
||||||
|
|||||||
@@ -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>Datenschutz</h2>
|
<h2 id="datenschutz">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 & Mousa GbR.
|
oben genannte Drenica & Mousa GbR.
|
||||||
|
|||||||
+137
-24
@@ -1,39 +1,57 @@
|
|||||||
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 { getNextAvailableSlot, getOpeningHoursForDisplay, getTodayLabelBerlin } from '#/lib/opening-hours'
|
import { formatDateOnly } from '#/lib/datetime'
|
||||||
import { createReservation, getTables } from '#/lib/reservations'
|
import {
|
||||||
|
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] = await Promise.all([getTables(), getReservationSettings()])
|
const [tables, settings, availability] = await Promise.all([
|
||||||
return { tables, settings }
|
getTables(),
|
||||||
|
getReservationSettings(),
|
||||||
|
getReservationAvailability(),
|
||||||
|
])
|
||||||
|
return { tables, settings, availability }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const OPENING_HOURS = getOpeningHoursForDisplay()
|
const OPENING_HOURS = getOpeningHoursForDisplay()
|
||||||
|
|
||||||
function FlameIcon() {
|
const confirmedDateFormatter = new Intl.DateTimeFormat('de-DE', { dateStyle: 'full' })
|
||||||
return (
|
const confirmedTimeFormatter = new Intl.DateTimeFormat('de-DE', { timeStyle: 'short' })
|
||||||
<svg className="hero-flame" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
||||||
<path
|
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
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"
|
// Also used as the phone input's `pattern` attribute below, so the two stay in sync.
|
||||||
stroke="currentColor"
|
const PHONE_PATTERN_SOURCE = String.raw`[+]?[0-9()\-.\s]{6,30}`
|
||||||
strokeWidth="1.4"
|
const PHONE_PATTERN = new RegExp(`^${PHONE_PATTERN_SOURCE}$`)
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
type ConfirmedReservation = {
|
||||||
</svg>
|
customerName: string
|
||||||
)
|
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 } = Route.useLoaderData()
|
const { tables, settings, availability } = 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('')
|
||||||
@@ -41,24 +59,62 @@ 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 [success, setSuccess] = useState<string | null>(null)
|
const [confirmedReservation, setConfirmedReservation] = useState<ConfirmedReservation | 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')
|
||||||
@@ -70,9 +126,14 @@ function Home() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!acceptsTerms) {
|
||||||
|
setError('Bitte stimmen Sie der Verarbeitung Ihrer Daten zu')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
try {
|
try {
|
||||||
await createReservation({
|
const created = await createReservation({
|
||||||
data: {
|
data: {
|
||||||
tableId: Number(tableId),
|
tableId: Number(tableId),
|
||||||
customerName,
|
customerName,
|
||||||
@@ -84,12 +145,23 @@ function Home() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
setSuccess('Vielen Dank! Ihre Reservierung ist bestätigt.')
|
const start = new Date(created.reservationTime)
|
||||||
|
const end = new Date(start.getTime() + created.durationMinutes * 60_000)
|
||||||
|
setConfirmedReservation({
|
||||||
|
customerName: created.customerName,
|
||||||
|
customerEmail: created.customerEmail,
|
||||||
|
tableName: selectedTable?.name ?? '',
|
||||||
|
date: confirmedDateFormatter.format(start),
|
||||||
|
startTime: confirmedTimeFormatter.format(start),
|
||||||
|
endTime: confirmedTimeFormatter.format(end),
|
||||||
|
partySize: created.partySize,
|
||||||
|
})
|
||||||
setCustomerName('')
|
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')
|
||||||
@@ -103,7 +175,7 @@ function Home() {
|
|||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
|
|
||||||
<section className="hero" id="top">
|
<section className="hero" id="top">
|
||||||
<FlameIcon />
|
<img className="hero-flame" src="/logo.svg" alt="" aria-hidden="true" />
|
||||||
<h1>
|
<h1>
|
||||||
Vom Feuer verwöhnt, <span>mit Liebe serviert</span>
|
Vom Feuer verwöhnt, <span>mit Liebe serviert</span>
|
||||||
</h1>
|
</h1>
|
||||||
@@ -143,6 +215,16 @@ 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>
|
||||||
@@ -223,7 +305,7 @@ function Home() {
|
|||||||
<input
|
<input
|
||||||
type="tel"
|
type="tel"
|
||||||
inputMode="tel"
|
inputMode="tel"
|
||||||
pattern="[+]?[0-9()\-.\s]{6,30}"
|
pattern={PHONE_PATTERN_SOURCE}
|
||||||
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)}
|
||||||
@@ -250,8 +332,10 @@ 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>
|
||||||
@@ -272,12 +356,27 @@ function Home() {
|
|||||||
<a href="#oeffnungszeiten">Öffnungszeiten</a>.
|
<a href="#oeffnungszeiten">Öffnungszeiten</a>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<button type="submit" disabled={isSubmitting}>
|
<label className="radio-option consent-option">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={acceptsTerms}
|
||||||
|
onChange={(e) => setAcceptsTerms(e.target.checked)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Ich stimme der Verarbeitung meiner Daten gemäß der{' '}
|
||||||
|
<a href="/impressum#datenschutz" target="_blank" rel="noopener noreferrer">
|
||||||
|
Datenschutzerklärung
|
||||||
|
</a>{' '}
|
||||||
|
zu.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit" disabled={isSubmitting || !isFormValid}>
|
||||||
{isSubmitting ? 'Wird gebucht …' : 'Jetzt reservieren'}
|
{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>
|
||||||
@@ -311,6 +410,20 @@ 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)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+106
-277
@@ -3,14 +3,12 @@ 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 { ReservationDateTimeField } from '#/components/reservation-datetime-field'
|
import { ReservationDialog, type ReservationPayload } from '#/components/reservation-dialog'
|
||||||
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,
|
||||||
@@ -42,8 +40,11 @@ export const Route = createFileRoute('/staff/')({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const dateTimeFormatter = new Intl.DateTimeFormat('de-DE', {
|
const dateFormatter = new Intl.DateTimeFormat('de-DE', {
|
||||||
dateStyle: 'medium',
|
dateStyle: 'medium',
|
||||||
|
})
|
||||||
|
|
||||||
|
const timeFormatter = new Intl.DateTimeFormat('de-DE', {
|
||||||
timeStyle: 'short',
|
timeStyle: 'short',
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -55,53 +56,23 @@ 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 [success, setSuccess] = useState<string | null>(null)
|
const [successModal, setSuccessModal] = useState<{ title: string; message: 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'>('all')
|
const [timeFilter, setTimeFilter] = useState<'all' | 'today' | 'upcoming' | 'past'>('today')
|
||||||
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)
|
||||||
@@ -125,96 +96,27 @@ function StaffDashboard() {
|
|||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
// Other reservations already sitting on the currently selected table + date, so the
|
function closeDialog() {
|
||||||
// time picker can block them instead of letting staff pick a slot the server will
|
setViewingReservation(null)
|
||||||
// just reject as double-booked. A reservation occupies its whole configured
|
setIsCreating(false)
|
||||||
// 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) {
|
async function handleCreate(payload: ReservationPayload) {
|
||||||
setEditingId(reservation.id)
|
await createReservation({ data: payload })
|
||||||
setForm({
|
setSuccessModal({
|
||||||
tableId: reservation.tableId,
|
title: 'Reservierung angelegt',
|
||||||
customerName: reservation.customerName,
|
message: `Die Reservierung für ${payload.customerName} wurde erfolgreich angelegt.`,
|
||||||
customerEmail: reservation.customerEmail,
|
|
||||||
customerPhone: reservation.customerPhone ?? '',
|
|
||||||
message: reservation.message ?? '',
|
|
||||||
partySize: reservation.partySize,
|
|
||||||
reservationTime: formatDateTimeLocal(new Date(reservation.reservationTime)),
|
|
||||||
})
|
})
|
||||||
setError(null)
|
await router.invalidate()
|
||||||
setSuccess(null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelEdit() {
|
async function handleUpdate(id: number, payload: ReservationPayload) {
|
||||||
setEditingId(null)
|
await updateReservation({ data: { id, ...payload } })
|
||||||
setForm(emptyForm(tables[0]?.id ?? '', settings))
|
setSuccessModal({
|
||||||
setError(null)
|
title: 'Reservierung aktualisiert',
|
||||||
}
|
message: `Die Reservierung für ${payload.customerName} wurde erfolgreich aktualisiert.`,
|
||||||
|
})
|
||||||
async function handleSubmit(event: React.FormEvent) {
|
await router.invalidate()
|
||||||
event.preventDefault()
|
|
||||||
setError(null)
|
|
||||||
setSuccess(null)
|
|
||||||
|
|
||||||
if (!form.tableId) {
|
|
||||||
setError('Bitte wählen Sie einen Tisch aus')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!form.reservationTime.split('T')[1]) {
|
|
||||||
setError('Bitte wählen Sie eine gültige Uhrzeit aus')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSubmitting(true)
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
tableId: Number(form.tableId),
|
|
||||||
customerName: form.customerName,
|
|
||||||
customerEmail: form.customerEmail,
|
|
||||||
customerPhone: form.customerPhone,
|
|
||||||
message: form.message,
|
|
||||||
partySize: Number(form.partySize),
|
|
||||||
reservationTime: new Date(form.reservationTime).toISOString(),
|
|
||||||
}
|
|
||||||
|
|
||||||
if (editingId) {
|
|
||||||
await updateReservation({ data: { id: editingId, ...payload } })
|
|
||||||
setSuccess('Reservierung wurde aktualisiert.')
|
|
||||||
} else {
|
|
||||||
await createReservation({ data: payload })
|
|
||||||
setSuccess('Reservierung wurde angelegt.')
|
|
||||||
}
|
|
||||||
|
|
||||||
setEditingId(null)
|
|
||||||
setForm(emptyForm(tables[0]?.id ?? '', settings))
|
|
||||||
await router.invalidate()
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmDelete() {
|
async function confirmDelete() {
|
||||||
@@ -222,33 +124,27 @@ 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 } })
|
||||||
if (editingId === reservation.id) {
|
setSuccessModal({
|
||||||
cancelEdit()
|
title: 'Reservierung gelöscht',
|
||||||
}
|
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')
|
||||||
@@ -286,6 +182,12 @@ 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>
|
||||||
@@ -307,119 +209,14 @@ function StaffDashboard() {
|
|||||||
|
|
||||||
<ReservationCharts reservations={reservations} tables={tables} />
|
<ReservationCharts reservations={reservations} tables={tables} />
|
||||||
|
|
||||||
<section className="staff-form-section">
|
|
||||||
<h2 className="section-heading staff-subheading">
|
|
||||||
{editingId ? 'Reservierung bearbeiten' : 'Neue Reservierung'}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<form className="reservation-form" onSubmit={handleSubmit}>
|
|
||||||
<label>
|
|
||||||
Tisch
|
|
||||||
<select
|
|
||||||
value={form.tableId}
|
|
||||||
onChange={(e) => setForm((f) => ({ ...f, tableId: e.target.value }))}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<option value="" disabled>
|
|
||||||
Tisch auswählen
|
|
||||||
</option>
|
|
||||||
{tables.map((table) => (
|
|
||||||
<option key={table.id} value={table.id}>
|
|
||||||
{table.name} ({table.seats} Plätze)
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="form-row">
|
|
||||||
<label>
|
|
||||||
Name
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={form.customerName}
|
|
||||||
onChange={(e) => setForm((f) => ({ ...f, customerName: e.target.value }))}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label>
|
|
||||||
E-Mail
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={form.customerEmail}
|
|
||||||
onChange={(e) => setForm((f) => ({ ...f, customerEmail: e.target.value }))}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-row">
|
|
||||||
<label>
|
|
||||||
Telefon
|
|
||||||
<input
|
|
||||||
type="tel"
|
|
||||||
inputMode="tel"
|
|
||||||
pattern="[+]?[0-9()\-.\s]{6,30}"
|
|
||||||
title="Nur Ziffern, Leerzeichen und + ( ) - ."
|
|
||||||
value={form.customerPhone}
|
|
||||||
onChange={(e) => setForm((f) => ({ ...f, customerPhone: e.target.value }))}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label>
|
|
||||||
Anzahl Personen
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
inputMode="numeric"
|
|
||||||
min={1}
|
|
||||||
max={20}
|
|
||||||
value={form.partySize}
|
|
||||||
onChange={(e) => setForm((f) => ({ ...f, partySize: Number(e.target.value) }))}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label>
|
|
||||||
Datum & 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">
|
<section className="staff-table-section">
|
||||||
|
<div className="table-toolbar">
|
||||||
|
<h2 className="section-heading staff-subheading">Alle Reservierungen</h2>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={() => setIsCreating(true)}>
|
||||||
|
+ Neue Reservierung
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{reservations.length > 0 && (
|
{reservations.length > 0 && (
|
||||||
<div className="table-filters">
|
<div className="table-filters">
|
||||||
<input
|
<input
|
||||||
@@ -474,7 +271,9 @@ function StaffDashboard() {
|
|||||||
<table className="reservations-table reservations-table-clickable">
|
<table className="reservations-table reservations-table-clickable">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Datum & Uhrzeit</th>
|
{timeFilter !== 'today' && <th>Datum</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>
|
||||||
@@ -485,15 +284,30 @@ function StaffDashboard() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filteredReservations.map((reservation) => (
|
{filteredReservations.map((reservation) => (
|
||||||
<tr
|
<tr key={reservation.id} onClick={() => setViewingReservation(reservation)}>
|
||||||
key={reservation.id}
|
{timeFilter !== 'today' && (
|
||||||
className={editingId === reservation.id ? 'is-editing' : undefined}
|
<td data-label="Datum" className="cell-datetime">
|
||||||
onClick={() => setViewingReservation(reservation)}
|
{dateFormatter.format(new Date(reservation.reservationTime))}
|
||||||
>
|
</td>
|
||||||
<td>{dateTimeFormatter.format(new Date(reservation.reservationTime))}</td>
|
)}
|
||||||
<td>{reservation.tableNumber ?? '—'}</td>
|
<td data-label="Start" className="cell-datetime">
|
||||||
<td>{reservation.tableName}</td>
|
{timeFormatter.format(new Date(reservation.reservationTime))}
|
||||||
<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>
|
||||||
@@ -504,11 +318,15 @@ function StaffDashboard() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>{reservation.partySize}</td>
|
<td data-label="Personen" className="cell-party">
|
||||||
<td>
|
{reservation.partySize}
|
||||||
|
</td>
|
||||||
|
<td data-label="Status" className="cell-status">
|
||||||
<StatusBadge status={reservation.status as ReservationStatus} />
|
<StatusBadge status={reservation.status as ReservationStatus} />
|
||||||
</td>
|
</td>
|
||||||
<td className="message-cell">{reservation.message || <span className="muted">—</span>}</td>
|
<td data-label="Nachricht" className="message-cell">
|
||||||
|
{reservation.message || <span className="muted">—</span>}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -536,22 +354,33 @@ function StaffDashboard() {
|
|||||||
onCancel={() => setPendingDelete(null)}
|
onCancel={() => setPendingDelete(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ReservationDetailDialog
|
{dialogOpen && (
|
||||||
reservation={viewingReservation}
|
<ReservationDialog
|
||||||
onClose={() => setViewingReservation(null)}
|
key={viewingReservation ? `view-${viewingReservation.id}` : 'create'}
|
||||||
onStatusChange={(status) => viewingReservation && handleStatusChange(viewingReservation, status)}
|
open={dialogOpen}
|
||||||
isUpdatingStatus={isUpdatingStatus}
|
reservation={viewingReservation}
|
||||||
onEdit={() => {
|
tables={tables}
|
||||||
if (!viewingReservation) return
|
settings={settings}
|
||||||
startEdit(viewingReservation)
|
reservations={reservations}
|
||||||
setViewingReservation(null)
|
onClose={closeDialog}
|
||||||
}}
|
onCreate={handleCreate}
|
||||||
onDelete={() => {
|
onUpdate={handleUpdate}
|
||||||
if (!viewingReservation) return
|
onStatusChange={(status) => viewingReservation && handleStatusChange(viewingReservation, status)}
|
||||||
setPendingDelete(viewingReservation)
|
isUpdatingStatus={isUpdatingStatus}
|
||||||
setViewingReservation(null)
|
statusError={error}
|
||||||
}}
|
onDelete={() => {
|
||||||
isDeleting={viewingReservation !== null && deletingId === viewingReservation.id}
|
if (!viewingReservation) return
|
||||||
|
setPendingDelete(viewingReservation)
|
||||||
|
setViewingReservation(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SuccessModal
|
||||||
|
open={successModal !== null}
|
||||||
|
title={successModal?.title ?? ''}
|
||||||
|
message={successModal?.message ?? ''}
|
||||||
|
onClose={() => setSuccessModal(null)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
+361
-17
@@ -142,10 +142,10 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hero-flame {
|
.hero-flame {
|
||||||
width: 4rem;
|
display: block;
|
||||||
height: 4rem;
|
width: 7rem;
|
||||||
|
height: 3.5rem;
|
||||||
margin: 0 auto 1.5rem;
|
margin: 0 auto 1.5rem;
|
||||||
color: var(--ember);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero h1 {
|
.hero h1 {
|
||||||
@@ -183,6 +183,11 @@ 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;
|
||||||
@@ -203,6 +208,17 @@ 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 {
|
||||||
@@ -626,6 +642,18 @@ 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
|
||||||
@@ -690,6 +718,13 @@ 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 {
|
||||||
@@ -846,6 +881,10 @@ a {
|
|||||||
.confirm-dialog {
|
.confirm-dialog {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reservation-confirmed-flame {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.staff-main {
|
.staff-main {
|
||||||
@@ -1090,6 +1129,19 @@ 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;
|
||||||
@@ -1176,6 +1228,71 @@ 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;
|
||||||
@@ -1207,35 +1324,57 @@ 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;
|
||||||
flex-wrap: wrap;
|
gap: 0.25rem;
|
||||||
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.4rem 0.75rem;
|
padding: 0.45rem 0.75rem;
|
||||||
opacity: 0.55;
|
transition:
|
||||||
|
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 `border`, rather than
|
on specificity so this actually wins and overrides their fixed `width`/`border`,
|
||||||
losing to them by cascade order. */
|
rather than losing to them by cascade order. */
|
||||||
.status-badge.status-option {
|
.status-badge.status-option {
|
||||||
|
width: auto;
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-option:hover {
|
.status-option:disabled {
|
||||||
opacity: 0.85;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-option.is-active {
|
.status-option:hover:not(:disabled):not(.is-active) {
|
||||||
opacity: 1;
|
color: var(--text);
|
||||||
/* `currentColor` picks up each status's own `color` (gold/red/green) instead
|
background: rgba(243, 234, 217, 0.06);
|
||||||
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 {
|
||||||
@@ -1369,6 +1508,83 @@ 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
|
||||||
@@ -1475,6 +1691,18 @@ 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);
|
||||||
@@ -1515,11 +1743,115 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -1528,6 +1860,18 @@ 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;
|
||||||
@@ -1623,7 +1967,7 @@ a {
|
|||||||
/* Legal page */
|
/* Legal page */
|
||||||
|
|
||||||
.legal-page {
|
.legal-page {
|
||||||
padding: 3.5rem 0 4rem;
|
padding: 3.5rem 1rem 4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.legal-page h1 {
|
.legal-page h1 {
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ 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
|
||||||
|
|||||||
Reference in New Issue
Block a user