wip
CI / build-and-test (push) Failing after 51s

This commit is contained in:
2026-08-25 11:50:17 +02:00
parent 55a51a521d
commit 151b1cced2
36 changed files with 5024 additions and 275 deletions
+2
View File
@@ -3,3 +3,5 @@ BETTER_AUTH_SECRET=replace-with-a-long-random-string
BETTER_AUTH_URL=http://localhost:3000
STAFF_SEED_EMAIL=admin@steakhaus-meppen.de
STAFF_SEED_PASSWORD=replace-with-a-strong-password
RESEND_API_KEY=replace-with-your-resend-api-key
RESERVATIONS_FROM_EMAIL=Steakhaus bei Mirko <reservierungen@steakhaus-meppen.de>
+105
View File
@@ -0,0 +1,105 @@
# Requires a Gitea runner with internet access to fetch `actions/checkout` and
# `actions/setup-node` from GitHub (or a Gitea instance with those actions mirrored
# internally) plus Docker available for the Postgres service container.
name: CI
on:
push:
branches: ['**']
pull_request:
branches: ['**']
jobs:
build-and-test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: steakhouse
POSTGRES_PASSWORD: steakhouse
POSTGRES_DB: steakhouse
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U steakhouse"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
DATABASE_URL: postgres://steakhouse:steakhouse@localhost:5432/steakhouse
BETTER_AUTH_SECRET: ci-only-secret-not-used-in-production
BETTER_AUTH_URL: http://localhost:4173
STAFF_SEED_EMAIL: admin@example.com
STAFF_SEED_PASSWORD: ci-only-password-not-used-in-production
RESEND_API_KEY: re_ci_dummy_key
RESERVATIONS_FROM_EMAIL: 'Steakhaus bei Mirko <onboarding@resend.dev>'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# Gitea auto-provisions this as a repo-scoped token for the runner; without
# it, actions/checkout falls back to an unauthenticated fetch, which fails
# here because the repo requires auth ("could not read Username ... terminal
# prompts disabled").
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Type check
run: npx tsc --noEmit
- name: Check that Drizzle migrations are up to date
run: |
npx drizzle-kit generate
if [ -n "$(git status --porcelain drizzle)" ]; then
echo "::error::src/db/schema.ts changed without a matching migration. Run 'npm run db:generate' and commit the result."
git status --porcelain drizzle
git diff drizzle
exit 1
fi
- name: Apply migrations
run: npm run db:migrate
- name: Seed baseline data
run: npm run db:seed
- name: Build
run: npm run build
- name: Smoke test the production build
run: |
npx vite preview --port 4173 &
PREVIEW_PID=$!
for i in $(seq 1 30); do
curl -sf http://localhost:4173/ >/dev/null 2>&1 && break
sleep 1
done
check() {
code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:4173$1")
if [ "$code" != "$2" ]; then
echo "::error::GET $1 returned $code, expected $2"
kill "$PREVIEW_PID"
exit 1
fi
echo "GET $1 -> $code"
}
check / 200
check /staff/login 200
check /impressum 200
kill "$PREVIEW_PID"
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="postgres@localhost" uuid="40aa621c-e69e-45d6-a5a3-1ce067327512">
<driver-ref>postgresql</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
<jdbc-url>jdbc:postgresql://localhost:5432/postgres</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="JsonSchemaCatalogProjectConfiguration">
<enabled>false</enabled>
</component>
</project>
+73
View File
@@ -0,0 +1,73 @@
CREATE TABLE "account" (
"id" text PRIMARY KEY NOT NULL,
"account_id" text NOT NULL,
"provider_id" text NOT NULL,
"user_id" text NOT NULL,
"access_token" text,
"refresh_token" text,
"id_token" text,
"access_token_expires_at" timestamp,
"refresh_token_expires_at" timestamp,
"scope" text,
"password" text,
"created_at" timestamp NOT NULL,
"updated_at" timestamp NOT NULL
);
--> statement-breakpoint
CREATE TABLE "reservations" (
"id" serial PRIMARY KEY NOT NULL,
"table_id" integer NOT NULL,
"customer_name" text NOT NULL,
"customer_email" text NOT NULL,
"party_size" integer NOT NULL,
"reservation_time" timestamp with time zone NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "reservations_table_id_reservation_time_unique" UNIQUE("table_id","reservation_time")
);
--> statement-breakpoint
CREATE TABLE "restaurant_tables" (
"id" serial PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"seats" integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE "session" (
"id" text PRIMARY KEY NOT NULL,
"expires_at" timestamp NOT NULL,
"token" text NOT NULL,
"created_at" timestamp NOT NULL,
"updated_at" timestamp NOT NULL,
"ip_address" text,
"user_agent" text,
"user_id" text NOT NULL,
"impersonated_by" text,
CONSTRAINT "session_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "user" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"email" text NOT NULL,
"email_verified" boolean NOT NULL,
"image" text,
"created_at" timestamp NOT NULL,
"updated_at" timestamp NOT NULL,
"role" text,
"banned" boolean,
"ban_reason" text,
"ban_expires" timestamp,
CONSTRAINT "user_email_unique" UNIQUE("email")
);
--> statement-breakpoint
CREATE TABLE "verification" (
"id" text PRIMARY KEY NOT NULL,
"identifier" text NOT NULL,
"value" text NOT NULL,
"expires_at" timestamp NOT NULL,
"created_at" timestamp,
"updated_at" timestamp
);
--> statement-breakpoint
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "reservations" ADD CONSTRAINT "reservations_table_id_restaurant_tables_id_fk" FOREIGN KEY ("table_id") REFERENCES "public"."restaurant_tables"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE "reservations" ADD COLUMN "cancel_token" text DEFAULT gen_random_uuid() NOT NULL;--> statement-breakpoint
ALTER TABLE "reservations" ADD CONSTRAINT "reservations_cancel_token_unique" UNIQUE("cancel_token");
+8
View File
@@ -0,0 +1,8 @@
CREATE TABLE "restaurant_settings" (
"id" serial PRIMARY KEY NOT NULL,
"reservation_interval_minutes" integer DEFAULT 15 NOT NULL,
"reservation_duration_minutes" integer DEFAULT 90 NOT NULL,
"min_lead_time_minutes" integer DEFAULT 60 NOT NULL
);
--> statement-breakpoint
ALTER TABLE "reservations" ADD COLUMN "duration_minutes" integer DEFAULT 90 NOT NULL;
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "restaurant_settings" ADD COLUMN "max_lead_time_days" integer DEFAULT 60 NOT NULL;
+459
View File
@@ -0,0 +1,459 @@
{
"id": "fc9539d6-3015-4ae3-bbf2-7a1a9f4de482",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.reservations": {
"name": "reservations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"table_id": {
"name": "table_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"customer_name": {
"name": "customer_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"customer_email": {
"name": "customer_email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"party_size": {
"name": "party_size",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"reservation_time": {
"name": "reservation_time",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"reservations_table_id_restaurant_tables_id_fk": {
"name": "reservations_table_id_restaurant_tables_id_fk",
"tableFrom": "reservations",
"tableTo": "restaurant_tables",
"columnsFrom": [
"table_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"reservations_table_id_reservation_time_unique": {
"name": "reservations_table_id_reservation_time_unique",
"nullsNotDistinct": false,
"columns": [
"table_id",
"reservation_time"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_tables": {
"name": "restaurant_tables",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"seats": {
"name": "seats",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"impersonated_by": {
"name": "impersonated_by",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": false
},
"banned": {
"name": "banned",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"ban_reason": {
"name": "ban_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ban_expires": {
"name": "ban_expires",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+473
View File
@@ -0,0 +1,473 @@
{
"id": "65d581ab-b3c7-4ffb-8a79-afcf26be5af5",
"prevId": "fc9539d6-3015-4ae3-bbf2-7a1a9f4de482",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.reservations": {
"name": "reservations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"table_id": {
"name": "table_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"customer_name": {
"name": "customer_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"customer_email": {
"name": "customer_email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"party_size": {
"name": "party_size",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"reservation_time": {
"name": "reservation_time",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"cancel_token": {
"name": "cancel_token",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "gen_random_uuid()"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"reservations_table_id_restaurant_tables_id_fk": {
"name": "reservations_table_id_restaurant_tables_id_fk",
"tableFrom": "reservations",
"tableTo": "restaurant_tables",
"columnsFrom": [
"table_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"reservations_cancel_token_unique": {
"name": "reservations_cancel_token_unique",
"nullsNotDistinct": false,
"columns": [
"cancel_token"
]
},
"reservations_table_id_reservation_time_unique": {
"name": "reservations_table_id_reservation_time_unique",
"nullsNotDistinct": false,
"columns": [
"table_id",
"reservation_time"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_tables": {
"name": "restaurant_tables",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"seats": {
"name": "seats",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"impersonated_by": {
"name": "impersonated_by",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": false
},
"banned": {
"name": "banned",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"ban_reason": {
"name": "ban_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ban_expires": {
"name": "ban_expires",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+520
View File
@@ -0,0 +1,520 @@
{
"id": "5e900bf2-030d-4eb3-a8ab-3b4c7d85e203",
"prevId": "65d581ab-b3c7-4ffb-8a79-afcf26be5af5",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.reservations": {
"name": "reservations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"table_id": {
"name": "table_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"customer_name": {
"name": "customer_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"customer_email": {
"name": "customer_email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"party_size": {
"name": "party_size",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"reservation_time": {
"name": "reservation_time",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"duration_minutes": {
"name": "duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"cancel_token": {
"name": "cancel_token",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "gen_random_uuid()"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"reservations_table_id_restaurant_tables_id_fk": {
"name": "reservations_table_id_restaurant_tables_id_fk",
"tableFrom": "reservations",
"tableTo": "restaurant_tables",
"columnsFrom": [
"table_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"reservations_cancel_token_unique": {
"name": "reservations_cancel_token_unique",
"nullsNotDistinct": false,
"columns": [
"cancel_token"
]
},
"reservations_table_id_reservation_time_unique": {
"name": "reservations_table_id_reservation_time_unique",
"nullsNotDistinct": false,
"columns": [
"table_id",
"reservation_time"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_settings": {
"name": "restaurant_settings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"reservation_interval_minutes": {
"name": "reservation_interval_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 15
},
"reservation_duration_minutes": {
"name": "reservation_duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"min_lead_time_minutes": {
"name": "min_lead_time_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_tables": {
"name": "restaurant_tables",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"seats": {
"name": "seats",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"impersonated_by": {
"name": "impersonated_by",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": false
},
"banned": {
"name": "banned",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"ban_reason": {
"name": "ban_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ban_expires": {
"name": "ban_expires",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+527
View File
@@ -0,0 +1,527 @@
{
"id": "d03b9103-cb6a-4968-a016-c04270ec176a",
"prevId": "5e900bf2-030d-4eb3-a8ab-3b4c7d85e203",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.reservations": {
"name": "reservations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"table_id": {
"name": "table_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"customer_name": {
"name": "customer_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"customer_email": {
"name": "customer_email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"party_size": {
"name": "party_size",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"reservation_time": {
"name": "reservation_time",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"duration_minutes": {
"name": "duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"cancel_token": {
"name": "cancel_token",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "gen_random_uuid()"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"reservations_table_id_restaurant_tables_id_fk": {
"name": "reservations_table_id_restaurant_tables_id_fk",
"tableFrom": "reservations",
"tableTo": "restaurant_tables",
"columnsFrom": [
"table_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"reservations_cancel_token_unique": {
"name": "reservations_cancel_token_unique",
"nullsNotDistinct": false,
"columns": [
"cancel_token"
]
},
"reservations_table_id_reservation_time_unique": {
"name": "reservations_table_id_reservation_time_unique",
"nullsNotDistinct": false,
"columns": [
"table_id",
"reservation_time"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_settings": {
"name": "restaurant_settings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"reservation_interval_minutes": {
"name": "reservation_interval_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 15
},
"reservation_duration_minutes": {
"name": "reservation_duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"min_lead_time_minutes": {
"name": "min_lead_time_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"max_lead_time_days": {
"name": "max_lead_time_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_tables": {
"name": "restaurant_tables",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"seats": {
"name": "seats",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"impersonated_by": {
"name": "impersonated_by",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": false
},
"banned": {
"name": "banned",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"ban_reason": {
"name": "ban_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ban_expires": {
"name": "ban_expires",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+34
View File
@@ -0,0 +1,34 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1787335013744,
"tag": "0000_milky_squadron_sinister",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1787338587505,
"tag": "0001_melted_supernaut",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1787604850455,
"tag": "0002_deep_dorian_gray",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1787646264161,
"tag": "0003_big_jack_flag",
"breakpoints": true
}
]
}
+52 -91
View File
@@ -13,6 +13,7 @@
"postgres": "^3.4.9",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"resend": "^6.21.0",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -453,7 +454,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -470,7 +470,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -487,7 +486,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -504,7 +502,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -521,7 +518,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -538,7 +534,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -555,7 +550,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -572,7 +566,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -589,7 +582,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -606,7 +598,6 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -623,7 +614,6 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -640,7 +630,6 @@
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -657,7 +646,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -674,7 +662,6 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -691,7 +678,6 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -708,7 +694,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -725,7 +710,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -742,7 +726,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -759,7 +742,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -776,7 +758,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -793,7 +774,6 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -810,7 +790,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -877,7 +856,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -894,7 +872,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -911,7 +888,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -928,7 +904,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -945,7 +920,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -962,7 +936,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -979,7 +952,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -996,7 +968,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1013,7 +984,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1030,7 +1000,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1047,7 +1016,6 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1064,7 +1032,6 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1081,7 +1048,6 @@
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1098,7 +1064,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1115,7 +1080,6 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1132,7 +1096,6 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1149,7 +1112,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1166,7 +1128,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1183,7 +1144,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1200,7 +1160,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1217,7 +1176,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1234,7 +1192,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1251,7 +1208,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1268,7 +1224,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1285,7 +1240,6 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1302,7 +1256,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1455,7 +1408,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1472,7 +1424,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1489,7 +1440,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1506,7 +1456,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1523,7 +1472,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1540,7 +1488,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1557,7 +1504,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1574,7 +1520,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1591,7 +1536,6 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1608,7 +1552,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1625,7 +1568,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1642,7 +1584,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1659,7 +1600,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1676,7 +1616,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1693,6 +1632,12 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/@stablelib/base64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -2158,7 +2103,7 @@
"version": "22.20.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
@@ -2621,7 +2566,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2638,7 +2582,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2655,7 +2598,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2672,7 +2614,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2689,7 +2630,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2706,7 +2646,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2723,7 +2662,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2740,7 +2678,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2757,7 +2694,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2774,7 +2710,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2791,7 +2726,6 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2808,7 +2742,6 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2825,7 +2758,6 @@
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2842,7 +2774,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2859,7 +2790,6 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2876,7 +2806,6 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2893,7 +2822,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2910,7 +2838,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2927,7 +2854,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2944,7 +2870,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2961,7 +2886,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2978,7 +2902,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2995,7 +2918,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3012,7 +2934,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3029,7 +2950,6 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3046,7 +2966,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3293,6 +3212,12 @@
"integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==",
"license": "MIT"
},
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense"
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -3320,7 +3245,6 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -3826,6 +3750,12 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/postal-mime": {
"version": "2.7.5",
"resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.5.tgz",
"integrity": "sha512-GNEXKvWFQnbgO5NlrGzVa0FmWzBZ24PersAWErttSg1Hjpf0ATxTwS5DOMGaOpTG6bUh5cTr7xi0jAD942wCJA==",
"license": "MIT-0"
},
"node_modules/postcss": {
"version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
@@ -3927,6 +3857,27 @@
"node": ">=0.10.0"
}
},
"node_modules/resend": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/resend/-/resend-6.21.0.tgz",
"integrity": "sha512-XT1eP2iA8rZ51NSO2CJlL1ZE2baBoPpOGMgz2p0o2uluz/YU3XfItpGymXDlL0Sp2Mc+y9+Xxsfdccfi4f2FzQ==",
"license": "MIT",
"dependencies": {
"postal-mime": "2.7.5",
"standardwebhooks": "1.0.0"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@react-email/render": "*"
},
"peerDependenciesMeta": {
"@react-email/render": {
"optional": true
}
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
@@ -4070,6 +4021,16 @@
"node": ">=20.16.0"
}
},
"node_modules/standardwebhooks": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
"license": "MIT",
"dependencies": {
"@stablelib/base64": "^1.0.0",
"fast-sha256": "^1.3.0"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@@ -4157,7 +4118,7 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/unplugin": {
+2 -1
View File
@@ -6,7 +6,7 @@
"#/*": "./src/*"
},
"scripts": {
"dev": "vite dev --port 3000",
"dev": "vite dev --port 3000 --host",
"generate-routes": "tsr generate",
"build": "vite build",
"preview": "vite preview",
@@ -24,6 +24,7 @@
"postgres": "^3.4.9",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"resend": "^6.21.0",
"zod": "^4.4.3"
},
"devDependencies": {
+128
View File
@@ -0,0 +1,128 @@
import { useEffect, useRef, useState } from 'react'
import { Link, useRouter } from '@tanstack/react-router'
import { authClient } from '#/lib/auth-client'
type StaffUser = { name: string; role?: string | null }
type SectionLink = { label: string; hash: string }
type Props = {
staffUser?: StaffUser | null
sectionLinks?: SectionLink[]
}
function MenuIcon() {
return (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" aria-hidden="true">
<path d="M4 6h16M4 12h16M4 18h16" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
)
}
export function AppSidebar({ staffUser, sectionLinks }: Props) {
const router = useRouter()
const [isOpen, setIsOpen] = useState(false)
const navRef = useRef<HTMLElement>(null)
useEffect(() => {
if (!isOpen) return
function handleOutsideClick(event: MouseEvent) {
if (navRef.current && !navRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') setIsOpen(false)
}
document.addEventListener('mousedown', handleOutsideClick)
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('mousedown', handleOutsideClick)
document.removeEventListener('keydown', handleKeyDown)
}
}, [isOpen])
async function handleLogout() {
await authClient.signOut()
await router.navigate({ to: '/staff/login' })
await router.invalidate()
}
return (
<nav className="app-sidebar" aria-label="Hauptnavigation" ref={navRef}>
<div className="app-sidebar-bar">
<Link className="wordmark app-sidebar-brand" to="/" onClick={() => setIsOpen(false)}>
Steakhaus <span>bei Mirko</span>
</Link>
<button
type="button"
className="app-sidebar-toggle"
aria-label={isOpen ? 'Menü schließen' : 'Menü öffnen'}
aria-expanded={isOpen}
onClick={() => setIsOpen((open) => !open)}
>
<MenuIcon />
</button>
</div>
<div className={isOpen ? 'app-sidebar-panel is-open' : 'app-sidebar-panel'} onClick={() => setIsOpen(false)}>
<ul className="app-sidebar-nav">
{sectionLinks && sectionLinks.length > 0 && (
<>
{sectionLinks.map((section) => (
<li key={section.hash}>
<Link to="/" hash={section.hash}>
{section.label}
</Link>
</li>
))}
<li className="app-sidebar-divider" role="separator" />
</>
)}
{staffUser && (
<li>
<Link to="/staff" activeOptions={{ exact: true }} activeProps={{ className: 'is-active' }}>
Reservierungen
</Link>
</li>
)}
{staffUser?.role === 'admin' && (
<li>
<Link to="/staff/team" activeProps={{ className: 'is-active' }}>
Mitarbeiter
</Link>
</li>
)}
{staffUser && <li className="app-sidebar-divider" role="separator" />}
<li>
<Link to="/" activeOptions={{ exact: true }} activeProps={{ className: 'is-active' }}>
Startseite
</Link>
</li>
<li>
<Link to="/impressum" activeProps={{ className: 'is-active' }}>
Impressum
</Link>
</li>
</ul>
<div className="app-sidebar-footer">
{staffUser ? (
<>
<span className="app-sidebar-user">Angemeldet als {staffUser.name}</span>
<button type="button" className="btn btn-secondary" onClick={handleLogout}>
Abmelden
</button>
</>
) : (
<Link className="btn btn-secondary" to="/staff/login">
Mitarbeiter-Login
</Link>
)}
</div>
</div>
</nav>
)
}
+73
View File
@@ -0,0 +1,73 @@
import { useEffect, useRef } from 'react'
type Props = {
open: boolean
title: string
message: string
confirmLabel?: string
cancelLabel?: string
danger?: boolean
onConfirm: () => void
onCancel: () => void
}
export function ConfirmDialog({
open,
title,
message,
confirmLabel = 'Bestätigen',
cancelLabel = 'Abbrechen',
danger,
onConfirm,
onCancel,
}: Props) {
const confirmRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
if (!open) return
confirmRef.current?.focus()
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') onCancel()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [open, onCancel])
if (!open) return null
return (
<div className="confirm-dialog-backdrop" onClick={onCancel}>
<div
className="confirm-dialog"
role="alertdialog"
aria-modal="true"
aria-labelledby="confirm-dialog-title"
aria-describedby="confirm-dialog-message"
onClick={(event) => event.stopPropagation()}
>
<h2 id="confirm-dialog-title" className="confirm-dialog-title">
{title}
</h2>
<p id="confirm-dialog-message" className="confirm-dialog-message">
{message}
</p>
<div className="confirm-dialog-actions">
<button type="button" className="btn btn-secondary" onClick={onCancel}>
{cancelLabel}
</button>
<button
ref={confirmRef}
type="button"
className={danger ? 'btn btn-danger' : 'btn btn-primary'}
onClick={onConfirm}
>
{confirmLabel}
</button>
</div>
</div>
</div>
)
}
+321
View File
@@ -0,0 +1,321 @@
import { useState } from 'react'
import { getOpeningRangesForWeekday, type OpeningRange } from '#/lib/opening-hours'
type ReservationLike = {
tableId: number
reservationTime: Date
}
type TableLike = {
id: number
name: string
}
type Props = {
reservations: ReservationLike[]
tables: TableLike[]
}
type BarDatum = {
label: string
value: number
tooltip: string
}
type Granularity = 'today' | 'week' | 'month'
type DateBucketGranularity = 'week' | 'month'
const GRANULARITY_LABELS: Record<Granularity, string> = {
today: 'Heute',
week: 'Woche',
month: 'Monat',
}
// How many buckets — and how far out — each granularity covers: enough weeks/months
// to spot a trend without the axis becoming unreadable.
const BUCKET_COUNTS: Record<DateBucketGranularity, number> = {
week: 8,
month: 6,
}
const WEEK_LABEL_FORMATTER = new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit' })
const MONTH_AXIS_FORMATTER = new Intl.DateTimeFormat('de-DE', { month: 'short' })
const MONTH_TOOLTIP_FORMATTER = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' })
function reservationsLabel(count: number) {
return count === 1 ? 'Reservierung' : 'Reservierungen'
}
function addBucketUnit(date: Date, granularity: DateBucketGranularity, amount: number) {
const d = new Date(date)
if (granularity === 'week') d.setDate(d.getDate() + amount * 7)
else d.setMonth(d.getMonth() + amount)
return d
}
// The start of the first bucket: the Monday of this week, or the 1st of this month —
// so the current, still-in-progress bucket covers the whole period, not just what's
// left of it (the caller is expected to pass in ALL reservations, not just upcoming
// ones, so this week's/month's earlier days are still counted).
function bucketRangeStart(granularity: DateBucketGranularity) {
const now = new Date()
now.setHours(0, 0, 0, 0)
if (granularity === 'month') {
now.setDate(1)
return now
}
const mondayOffset = (now.getDay() + 6) % 7
now.setDate(now.getDate() - mondayOffset)
return now
}
function formatBucketLabel(start: Date, granularity: DateBucketGranularity) {
if (granularity === 'month') return MONTH_AXIS_FORMATTER.format(start)
return WEEK_LABEL_FORMATTER.format(start)
}
function formatBucketTooltip(start: Date, end: Date, granularity: DateBucketGranularity, count: number) {
const countLabel = `${count} ${reservationsLabel(count)}`
if (granularity === 'month') return `${MONTH_TOOLTIP_FORMATTER.format(start)}: ${countLabel}`
const lastDayOfWeek = new Date(end.getTime() - 24 * 60 * 60 * 1000)
return `${WEEK_LABEL_FORMATTER.format(start)}–${WEEK_LABEL_FORMATTER.format(lastDayOfWeek)}: ${countLabel}`
}
function buildBucketedCounts(reservations: ReservationLike[], granularity: DateBucketGranularity): BarDatum[] {
const rangeStart = bucketRangeStart(granularity)
const bucketCount = BUCKET_COUNTS[granularity]
return Array.from({ length: bucketCount }, (_, i) => {
const start = addBucketUnit(rangeStart, granularity, i)
const end = addBucketUnit(rangeStart, granularity, i + 1)
const count = reservations.filter((r) => {
const time = new Date(r.reservationTime).getTime()
return time >= start.getTime() && time < end.getTime()
}).length
return {
label: formatBucketLabel(start, granularity),
value: count,
tooltip: formatBucketTooltip(start, end, granularity, count),
}
})
}
// The restaurant can have more than one service window in a day (e.g. lunch +
// dinner) — span the whole gap between them rather than faceting the chart, so a
// quiet hour between services reads as "no bookings then", not a missing bar.
function hourSpanForRanges(ranges: OpeningRange[]) {
const startHour = Math.min(...ranges.map((r) => Number(r.start.split(':')[0])))
const endHour = Math.max(
...ranges.map((r) => {
const [hour, minute] = r.end.split(':').map(Number)
return minute > 0 ? hour + 1 : hour
}),
)
return { startHour, endHour }
}
function buildHourlyCountsForToday(reservations: ReservationLike[], ranges: OpeningRange[]): BarDatum[] {
const { startHour, endHour } = hourSpanForRanges(ranges)
const dayStart = new Date()
dayStart.setHours(0, 0, 0, 0)
return Array.from({ length: endHour - startHour }, (_, i) => {
const hour = startHour + i
const start = new Date(dayStart)
start.setHours(hour)
const end = new Date(dayStart)
end.setHours(hour + 1)
const count = reservations.filter((r) => {
const time = new Date(r.reservationTime).getTime()
return time >= start.getTime() && time < end.getTime()
}).length
return {
label: `${String(hour).padStart(2, '0')}:00`,
value: count,
tooltip: `${String(hour).padStart(2, '0')}:00–${String(hour + 1).padStart(2, '0')}:00: ${count} ${reservationsLabel(count)}`,
}
})
}
function buildTimelineData(
reservations: ReservationLike[],
granularity: Granularity,
): { data: BarDatum[]; emptyMessage: string } {
if (granularity === 'today') {
const ranges = getOpeningRangesForWeekday(new Date().getDay())
if (ranges.length === 0) {
return { data: [], emptyMessage: 'Heute ist Ruhetag.' }
}
return {
data: buildHourlyCountsForToday(reservations, ranges),
emptyMessage: 'Heute sind noch keine Reservierungen eingetragen.',
}
}
return {
data: buildBucketedCounts(reservations, granularity),
emptyMessage: 'In diesem Zeitraum sind noch keine Reservierungen eingetragen.',
}
}
// The full date range the timeline chart currently covers, so the per-table chart
// can be filtered to the same window and the two charts always tell one consistent
// story instead of the table breakdown silently covering a different span.
function getTimelineRange(granularity: Granularity): { start: Date; end: Date } {
if (granularity === 'today') {
const start = new Date()
start.setHours(0, 0, 0, 0)
const end = new Date(start)
end.setDate(end.getDate() + 1)
return { start, end }
}
const start = bucketRangeStart(granularity)
const end = addBucketUnit(start, granularity, BUCKET_COUNTS[granularity])
return { start, end }
}
function buildTableCounts(reservations: ReservationLike[], tables: TableLike[]): BarDatum[] {
return tables.map((table) => {
const count = reservations.filter((r) => r.tableId === table.id).length
return {
label: table.name,
value: count,
tooltip: `${table.name}: ${count} ${reservationsLabel(count)}`,
}
})
}
// Produces clean, evenly-stepped tick values (0, 2, 4, 6, …) so the gridlines
// never land on an awkward number like 7 or 13.
function niceTicks(maxValue: number, tickCount = 4): number[] {
if (maxValue <= 0) return [0, 1]
const rawStep = maxValue / (tickCount - 1)
const magnitude = 10 ** Math.floor(Math.log10(rawStep))
const residual = rawStep / magnitude
const niceResidual = residual <= 1 ? 1 : residual <= 2 ? 2 : residual <= 5 ? 5 : 10
// Counts are always whole numbers, so a sub-1 step (e.g. 0.5) would just
// round two distinct ticks down to the same displayed integer.
const step = Math.max(1, niceResidual * magnitude)
const niceMax = Math.ceil(maxValue / step) * step
const ticks: number[] = []
for (let v = 0; v <= niceMax + 1e-9; v += step) ticks.push(Math.round(v))
return ticks
}
function BarChartBody({ data, showValueLabels }: { data: BarDatum[]; showValueLabels?: boolean }) {
const ticks = niceTicks(Math.max(...data.map((d) => d.value), 0))
const chartMax = ticks[ticks.length - 1] || 1
return (
<div className="bar-chart">
<div className="bar-chart-plot">
{ticks.map((tick) => (
<div key={tick} className="bar-chart-gridline" style={{ bottom: `${(tick / chartMax) * 100}%` }}>
<span className="bar-chart-tick">{tick}</span>
</div>
))}
<div className="bar-chart-bars">
{data.map((d, i) => (
<div className="bar-chart-col" key={i} tabIndex={0} aria-label={d.tooltip}>
<div className="bar-chart-bar" style={{ height: `${(d.value / chartMax) * 100}%` }}>
{showValueLabels && d.value > 0 && <span className="bar-chart-value">{d.value}</span>}
</div>
<div className="bar-chart-tooltip">{d.tooltip}</div>
</div>
))}
</div>
</div>
<div className="bar-chart-axis">
{data.map((d, i) => (
<span key={i} className="bar-chart-axis-label">
{d.label}
</span>
))}
</div>
</div>
)
}
function ChartCard({
title,
headerExtra,
data,
showValueLabels,
emptyMessage,
}: {
title: string
headerExtra?: React.ReactNode
data: BarDatum[]
showValueLabels?: boolean
emptyMessage: string
}) {
const hasData = data.some((d) => d.value > 0)
return (
<div className="chart-card">
<div className="chart-card-header">
<h3 className="chart-title">{title}</h3>
{headerExtra}
</div>
{hasData ? <BarChartBody data={data} showValueLabels={showValueLabels} /> : <p className="empty-state">{emptyMessage}</p>}
</div>
)
}
function GranularityToggle({ value, onChange }: { value: Granularity; onChange: (g: Granularity) => void }) {
return (
<div className="chart-toggle" role="group" aria-label="Zeitraum">
{(Object.keys(GRANULARITY_LABELS) as Granularity[]).map((g) => (
<button
key={g}
type="button"
className={g === value ? 'is-active' : undefined}
aria-pressed={g === value}
onClick={() => onChange(g)}
>
{GRANULARITY_LABELS[g]}
</button>
))}
</div>
)
}
export function ReservationCharts({ reservations, tables }: Props) {
const [granularity, setGranularity] = useState<Granularity>('today')
const timeline = buildTimelineData(reservations, granularity)
const { start, end } = getTimelineRange(granularity)
const reservationsInRange = reservations.filter((r) => {
const time = new Date(r.reservationTime).getTime()
return time >= start.getTime() && time < end.getTime()
})
const tableCounts = buildTableCounts(reservationsInRange, tables)
return (
<div className="charts-grid">
<ChartCard
title="Reservierungen"
headerExtra={<GranularityToggle value={granularity} onChange={setGranularity} />}
data={timeline.data}
emptyMessage={timeline.emptyMessage}
/>
<ChartCard
title="Reservierungen je Tisch"
data={tableCounts}
showValueLabels
emptyMessage={timeline.emptyMessage}
/>
</div>
)
}
@@ -0,0 +1,315 @@
import { useEffect, useRef, useState } from 'react'
import { formatDateOnly } from '#/lib/datetime'
import { generateTimeOptions, getOpeningRangesForWeekday, RESERVATION_INTERVAL_MINUTES } from '#/lib/opening-hours'
type Props = {
value: string
onChange: (value: string) => void
// "HH:MM" times already taken (e.g. by another reservation on the same table)
// for the currently selected date — shown disabled rather than removed, so the
// slot doesn't look like it doesn't exist, just that it isn't free.
bookedTimes?: string[]
// How far apart selectable times are, and how far in advance a booking must be
// made — both configurable restaurant settings, falling back to sane defaults.
intervalMinutes?: number
minLeadTimeMinutes?: number
// How far into the future a booking may be made. Undefined leaves the horizon open,
// since not every caller of this field needs the restriction.
maxLeadTimeDays?: number
}
type DayCell = { day: number; dateStr: string } | null
function splitValue(value: string) {
const [date = '', time = ''] = value.split('T')
return { date, time }
}
function getWeekday(dateStr: string) {
return new Date(`${dateStr}T00:00`).getDay()
}
function pad2(n: number) {
return String(n).padStart(2, '0')
}
function isoToDisplay(iso: string) {
if (!iso) return ''
const [year, month, day] = iso.split('-')
return `${day}.${month}.${year}`
}
// Progressively inserts dots as digits are typed, e.g. "22081" -> "22.08.1".
function maskDateInput(raw: string) {
const digits = raw.replace(/\D/g, '').slice(0, 8)
let out = digits.slice(0, 2)
if (digits.length > 2) out += '.' + digits.slice(2, 4)
if (digits.length > 4) out += '.' + digits.slice(4, 8)
return out
}
// Returns an ISO "YYYY-MM-DD" string, or null if the text isn't a complete, real calendar date.
function parseDisplayDate(raw: string) {
const digits = raw.replace(/\D/g, '')
if (digits.length !== 8) return null
const day = Number(digits.slice(0, 2))
const month = Number(digits.slice(2, 4))
const year = Number(digits.slice(4, 8))
const parsed = new Date(year, month - 1, day)
if (parsed.getFullYear() !== year || parsed.getMonth() !== month - 1 || parsed.getDate() !== day) {
return null
}
return `${digits.slice(4, 8)}-${digits.slice(2, 4)}-${digits.slice(0, 2)}`
}
// Monday-first weeks covering the given month, with null filler cells for days
// that belong to the adjacent month.
function getMonthGrid(year: number, month: number): DayCell[][] {
const firstOfMonth = new Date(year, month, 1)
const leadingBlanks = (firstOfMonth.getDay() + 6) % 7
const daysInMonth = new Date(year, month + 1, 0).getDate()
const cells: DayCell[] = Array(leadingBlanks).fill(null)
for (let day = 1; day <= daysInMonth; day++) {
cells.push({ day, dateStr: `${year}-${pad2(month + 1)}-${pad2(day)}` })
}
while (cells.length % 7 !== 0) cells.push(null)
const weeks: DayCell[][] = []
for (let i = 0; i < cells.length; i += 7) weeks.push(cells.slice(i, i + 7))
return weeks
}
const WEEKDAY_HEADER_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
function CalendarIcon() {
return (
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
<rect x="3.5" y="5" width="17" height="16" rx="2" stroke="currentColor" strokeWidth="1.4" />
<path d="M3.5 9.5h17M8 3v4M16 3v4" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
</svg>
)
}
export function ReservationDateTimeField({
value,
onChange,
bookedTimes = [],
intervalMinutes = RESERVATION_INTERVAL_MINUTES,
minLeadTimeMinutes = 0,
maxLeadTimeDays,
}: Props) {
const { date, time } = splitValue(value)
const [dateText, setDateText] = useState(() => isoToDisplay(date))
const [isCalendarOpen, setIsCalendarOpen] = useState(false)
const [view, setView] = useState(() => {
const [year, month] = (date || formatDateOnly(new Date())).split('-')
return { year: Number(year), month: Number(month) - 1 }
})
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
setDateText(isoToDisplay(date))
}, [date])
useEffect(() => {
if (!isCalendarOpen) return
const [year, month] = (date || formatDateOnly(new Date())).split('-')
setView({ year: Number(year), month: Number(month) - 1 })
function handleOutsideClick(event: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsCalendarOpen(false)
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') setIsCalendarOpen(false)
}
document.addEventListener('mousedown', handleOutsideClick)
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('mousedown', handleOutsideClick)
document.removeEventListener('keydown', handleKeyDown)
}
// Only re-run when the popover opens/closes — reopening should reset to the
// currently selected month, not follow every keystroke in the text field.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isCalendarOpen])
const today = formatDateOnly(new Date())
const isPast = date !== '' && date < today
const earliestAllowedTime = new Date(Date.now() + minLeadTimeMinutes * 60_000)
const latestDate =
maxLeadTimeDays === undefined ? null : formatDateOnly(new Date(Date.now() + maxLeadTimeDays * 86_400_000))
const isBeyondHorizon = date !== '' && latestDate !== null && date > latestDate
// Past dates and dates beyond the booking horizon never have slots — shared by both
// the render below and commitDate, so a date committed from anywhere (typed, or
// otherwise) can't slip through with a leftover, still-"valid"-looking time.
function optionsForDate(dateStr: string) {
if (!dateStr || dateStr < today || (latestDate !== null && dateStr > latestDate)) return []
return generateTimeOptions(getOpeningRangesForWeekday(getWeekday(dateStr)), intervalMinutes)
}
function isSelectableDate(dateStr: string) {
return optionsForDate(dateStr).length > 0
}
const allOptions = optionsForDate(date)
// Compares full timestamps (not just "is it today") so a lead time longer than
// what's left of today correctly spills over into tomorrow's early slots too.
const timeOptions = allOptions.filter(
(option) => new Date(`${date}T${option}`).getTime() >= earliestAllowedTime.getTime(),
)
const bookedTimesSet = new Set(bookedTimes)
const availableTimeOptions = timeOptions.filter((option) => !bookedTimesSet.has(option))
const isClosed = date !== '' && !isPast && !isBeyondHorizon && allOptions.length === 0
const noSlotsLeft = date !== '' && !isPast && allOptions.length > 0 && availableTimeOptions.length === 0
const dateIncomplete = parseDisplayDate(dateText) === null
function commitDate(newDate: string) {
const newOptions = optionsForDate(newDate)
const stillValid = newOptions.includes(time)
onChange(`${newDate}T${stillValid ? time : newOptions[0] ?? ''}`)
}
function handleTextChange(raw: string) {
const masked = maskDateInput(raw)
setDateText(masked)
const parsed = parseDisplayDate(masked)
if (parsed) {
commitDate(parsed)
}
}
function handleDayPick(dateStr: string) {
commitDate(dateStr)
setIsCalendarOpen(false)
}
function handleTimeChange(newTime: string) {
onChange(`${date}T${newTime}`)
}
let timePlaceholder = 'Uhrzeit wählen'
if (dateIncomplete) timePlaceholder = 'Datum eingeben'
else if (isPast) timePlaceholder = 'Datum liegt in der Vergangenheit'
else if (isBeyondHorizon) timePlaceholder = 'Datum liegt zu weit in der Zukunft'
else if (isClosed) timePlaceholder = 'An diesem Tag geschlossen'
else if (noSlotsLeft) timePlaceholder = 'Keine Zeiten mehr verfügbar'
const monthLabel = new Date(view.year, view.month, 1).toLocaleDateString('de-DE', {
month: 'long',
year: 'numeric',
})
const [todayYear, todayMonthStr] = today.split('-')
const isAtEarliestMonth = view.year === Number(todayYear) && view.month === Number(todayMonthStr) - 1
const isAtLatestMonth = (() => {
if (latestDate === null) return false
const [latestYear, latestMonthStr] = latestDate.split('-')
return view.year === Number(latestYear) && view.month === Number(latestMonthStr) - 1
})()
function goToMonth(offset: number) {
setView((v) => {
const total = v.year * 12 + v.month + offset
return { year: Math.floor(total / 12), month: ((total % 12) + 12) % 12 }
})
}
return (
<div className="datetime-field">
<div className="date-input-group" ref={containerRef}>
<input
type="text"
inputMode="numeric"
placeholder="TT.MM.JJJJ"
pattern="\d{2}\.\d{2}\.\d{4}"
maxLength={10}
value={dateText}
onChange={(e) => handleTextChange(e.target.value)}
required
/>
<button
type="button"
className="date-picker-trigger"
aria-label="Kalender öffnen"
aria-expanded={isCalendarOpen}
onClick={() => setIsCalendarOpen((open) => !open)}
>
<CalendarIcon />
</button>
{isCalendarOpen && (
<div className="date-popover" role="dialog" aria-label="Datum auswählen">
<div className="date-popover-header">
<button
type="button"
aria-label="Vorheriger Monat"
disabled={isAtEarliestMonth}
onClick={() => goToMonth(-1)}
>
‹
</button>
<span>{monthLabel}</span>
<button
type="button"
aria-label="Nächster Monat"
disabled={isAtLatestMonth}
onClick={() => goToMonth(1)}
>
›
</button>
</div>
<div className="date-popover-weekdays">
{WEEKDAY_HEADER_LABELS.map((label) => (
<span key={label}>{label}</span>
))}
</div>
<div className="date-popover-grid">
{getMonthGrid(view.year, view.month)
.flat()
.map((cell, index) => (
<button
key={cell?.dateStr ?? `blank-${index}`}
type="button"
className={cell?.dateStr === date ? 'is-selected' : undefined}
disabled={!cell || !isSelectableDate(cell.dateStr)}
onClick={() => cell && handleDayPick(cell.dateStr)}
>
{cell?.day ?? ''}
</button>
))}
</div>
</div>
)}
</div>
<select
value={time}
onChange={(e) => handleTimeChange(e.target.value)}
disabled={availableTimeOptions.length === 0}
required
>
<option value="" disabled>
{timePlaceholder}
</option>
{timeOptions.map((option) => (
<option key={option} value={option} disabled={bookedTimesSet.has(option)}>
{option}
{bookedTimesSet.has(option) ? ' (belegt)' : ''}
</option>
))}
</select>
</div>
)
}
+4 -18
View File
@@ -3,28 +3,14 @@ import { Link } from '@tanstack/react-router'
export function SiteHeader() {
return (
<header className="site-header">
<div className="site-header-inner">
<Link className="wordmark" to="/">
Steakhaus <span>bei Mirko</span>
</Link>
<nav>
<ul className="site-nav">
<li>
<Link to="/" hash="reservieren">
Reservieren
<Link className="staff-login-link" to="/staff/login">
Mitarbeiter-Login
</Link>
</li>
<li>
<Link to="/" hash="oeffnungszeiten">
Öffnungszeiten
</Link>
</li>
<li>
<Link to="/" hash="kontakt">
Kontakt
</Link>
</li>
</ul>
</nav>
</div>
</header>
)
}
+41
View File
@@ -0,0 +1,41 @@
import { Link } from '@tanstack/react-router'
type Props = {
role?: string | null
}
export function StaffTabs({ role }: Props) {
return (
<nav className="staff-tabs" aria-label="Bereich">
<Link
className="staff-tab"
to="/staff"
activeOptions={{ exact: true }}
activeProps={{ className: 'is-active' }}
viewTransition
>
Reservierungen
</Link>
{role === 'admin' && (
<Link
className="staff-tab"
to="/staff/team"
activeProps={{ className: 'is-active' }}
viewTransition
>
Mitarbeiter
</Link>
)}
{role === 'admin' && (
<Link
className="staff-tab"
to="/staff/settings"
activeProps={{ className: 'is-active' }}
viewTransition
>
Einstellungen
</Link>
)}
</nav>
)
}
+19
View File
@@ -1,3 +1,4 @@
import { sql } from 'drizzle-orm'
import { boolean, integer, pgTable, serial, text, timestamp, unique } from 'drizzle-orm/pg-core'
export const restaurantTables = pgTable('restaurant_tables', {
@@ -17,11 +18,28 @@ export const reservations = pgTable(
customerEmail: text('customer_email').notNull(),
partySize: integer('party_size').notNull(),
reservationTime: timestamp('reservation_time', { withTimezone: true }).notNull(),
// Captured from the reservation-duration setting at booking time, so a later
// change to that setting doesn't retroactively change how long past bookings
// are considered to occupy their table.
durationMinutes: integer('duration_minutes').notNull().default(90),
cancelToken: text('cancel_token')
.notNull()
.unique()
.default(sql`gen_random_uuid()`),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [unique().on(table.tableId, table.reservationTime)],
)
// Single-row table of restaurant-wide booking rules, editable by admins.
export const restaurantSettings = pgTable('restaurant_settings', {
id: serial('id').primaryKey(),
reservationIntervalMinutes: integer('reservation_interval_minutes').notNull().default(15),
reservationDurationMinutes: integer('reservation_duration_minutes').notNull().default(90),
minLeadTimeMinutes: integer('min_lead_time_minutes').notNull().default(60),
maxLeadTimeDays: integer('max_lead_time_days').notNull().default(60),
})
// Better Auth's standard schema (staff accounts) — see https://www.better-auth.com/docs/concepts/database
// `role`/`banned`/`banReason`/`banExpires` and `session.impersonatedBy` are added by the `admin` plugin.
export const user = pgTable('user', {
@@ -82,3 +100,4 @@ export const verification = pgTable('verification', {
export type RestaurantTable = typeof restaurantTables.$inferSelect
export type Reservation = typeof reservations.$inferSelect
export type NewReservation = typeof reservations.$inferInsert
export type RestaurantSettings = typeof restaurantSettings.$inferSelect
+8 -3
View File
@@ -1,10 +1,15 @@
import { createServerFn } from '@tanstack/react-start'
import { createServerFn, createServerOnlyFn } from '@tanstack/react-start'
import { getRequest } from '@tanstack/react-start/server'
export async function getStaffSession() {
// `staff.index.tsx` and friends import `getCurrentStaffUser` from this module for
// their `beforeLoad` checks, which pulls the whole file into the client bundle too.
// `createServerOnlyFn` tells the compiler this closure (and the server-only
// `getRequest` import it uses) can be stripped from that client bundle instead of
// dragging the Postgres driver into the browser.
export const getStaffSession = createServerOnlyFn(async function getStaffSession() {
const { getAuth } = await import('#/lib/auth-instance')
return getAuth().api.getSession({ headers: getRequest().headers })
}
})
export async function requireStaffSession() {
const session = await getStaffSession()
+6 -1
View File
@@ -1,4 +1,9 @@
export function formatDateOnly(date: Date) {
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
}
export function formatDateTimeLocal(date: Date) {
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
return `${formatDateOnly(date)}T${pad(date.getHours())}:${pad(date.getMinutes())}`
}
+260
View File
@@ -0,0 +1,260 @@
import { Resend } from 'resend'
import type { RestaurantTable } from '#/db/schema'
const dateTimeFormatter = new Intl.DateTimeFormat('de-DE', {
dateStyle: 'full',
timeStyle: 'short',
})
const COLORS = {
bg: '#17120f',
bgRaised: '#1e1712',
line: '#3a2f26',
text: '#f3ead9',
textMuted: '#c9baa3',
gold: '#c9a15a',
goldBright: '#e0bb78',
}
function escapeHtml(value: string) {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
}
let resendClient: Resend | undefined
function getResendClient() {
const apiKey = process.env.RESEND_API_KEY
if (!apiKey) {
return null
}
resendClient ??= new Resend(apiKey)
return resendClient
}
type ReservationEmailInfo = {
customerName: string
customerEmail: string
partySize: number
reservationTime: Date
cancelToken: string
table: Pick<RestaurantTable, 'name'>
}
const CONTACT_EMAIL = 'info@steakhaus-meppen.de'
function getSiteUrl() {
return process.env.BETTER_AUTH_URL ?? 'http://localhost:3000'
}
function buildCancelUrl(cancelToken: string) {
return `${getSiteUrl()}/stornieren/${cancelToken}`
}
function renderReservationEmailHtml(
heading: string,
intro: string,
reservation: ReservationEmailInfo,
showActions: boolean,
) {
const formattedTime = dateTimeFormatter.format(reservation.reservationTime)
const name = escapeHtml(reservation.customerName)
const tableName = escapeHtml(reservation.table.name)
const cancelUrl = buildCancelUrl(reservation.cancelToken)
const row = (label: string, value: string) => `
<tr>
<td style="padding:10px 0;border-top:1px solid ${COLORS.line};color:${COLORS.textMuted};font:400 14px/1.4 'Helvetica Neue',Arial,sans-serif;letter-spacing:0.03em;text-transform:uppercase;white-space:nowrap;">
${label}
</td>
<td style="padding:10px 0;border-top:1px solid ${COLORS.line};color:${COLORS.text};font:600 15px/1.4 Georgia,'Times New Roman',serif;text-align:right;">
${value}
</td>
</tr>`
return `
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${heading}</title>
</head>
<body style="margin:0;padding:32px 16px;background:${COLORS.bg};font:400 16px/1.5 'Helvetica Neue',Arial,sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td align="center">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:480px;">
<tr>
<td align="center" style="padding-bottom:28px;">
<span style="font:600 20px/1.3 Georgia,'Times New Roman',serif;letter-spacing:0.03em;color:${COLORS.text};">
Steakhaus <span style="color:${COLORS.goldBright};">bei Mirko</span>
</span>
</td>
</tr>
<tr>
<td style="background:${COLORS.bgRaised};border:1px solid ${COLORS.line};border-radius:10px;padding:32px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td align="center" style="padding-bottom:20px;">
<span style="display:inline-block;width:36px;height:2px;background:${COLORS.gold};"></span>
</td>
</tr>
<tr>
<td style="font:600 22px/1.3 Georgia,'Times New Roman',serif;color:${COLORS.goldBright};padding-bottom:12px;">
${heading}
</td>
</tr>
<tr>
<td style="color:${COLORS.textMuted};font-size:15px;padding-bottom:8px;">
Hallo ${name},
</td>
</tr>
<tr>
<td style="color:${COLORS.textMuted};font-size:15px;padding-bottom:20px;">
${intro}
</td>
</tr>
<tr>
<td>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
${row('Tisch', tableName)}
${row('Datum &amp; Uhrzeit', formattedTime)}
${row('Personen', String(reservation.partySize))}
</table>
</td>
</tr>
${
showActions
? `
<tr>
<td align="center" style="padding-top:28px;">
<a href="${cancelUrl}" style="display:inline-block;padding:0.7rem 1.4rem;border:1px solid ${COLORS.line};border-radius:4px;color:${COLORS.text};font:600 13px/1 'Helvetica Neue',Arial,sans-serif;text-decoration:none;">
Reservierung stornieren
</a>
</td>
</tr>
<tr>
<td align="center" style="padding-top:16px;color:${COLORS.textMuted};font-size:13px;">
Möchten Sie etwas ändern? Schreiben Sie uns einfach eine E-Mail an
<a href="mailto:${CONTACT_EMAIL}" style="color:${COLORS.goldBright};">${CONTACT_EMAIL}</a>.
</td>
</tr>`
: ''
}
</table>
</td>
</tr>
<tr>
<td align="center" style="padding-top:28px;color:${COLORS.textMuted};font-size:13px;">
Wir freuen uns auf Sie!
</td>
</tr>
<tr>
<td align="center" style="padding-top:16px;color:${COLORS.textMuted};font-size:12px;">
Meppener Straße 8 · 49716 Meppen · 05931 - 12750
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`
}
function renderReservationEmailText(
heading: string,
intro: string,
reservation: ReservationEmailInfo,
showActions: boolean,
) {
const formattedTime = dateTimeFormatter.format(reservation.reservationTime)
return [
'Steakhaus bei Mirko',
'',
heading,
'',
`Hallo ${reservation.customerName},`,
'',
intro,
'',
`Tisch: ${reservation.table.name}`,
`Datum & Uhrzeit: ${formattedTime}`,
`Personen: ${reservation.partySize}`,
'',
...(showActions
? [
`Reservierung stornieren: ${buildCancelUrl(reservation.cancelToken)}`,
`Möchten Sie etwas ändern? Schreiben Sie uns eine E-Mail an ${CONTACT_EMAIL}.`,
'',
]
: []),
'Wir freuen uns auf Sie!',
'Steakhaus bei Mirko',
'Meppener Straße 8 · 49716 Meppen · 05931 - 12750',
].join('\n')
}
async function sendReservationEmail(
subject: string,
intro: string,
reservation: ReservationEmailInfo,
showActions: boolean,
) {
const client = getResendClient()
if (!client) {
console.warn('RESEND_API_KEY is not set — skipping reservation email')
return
}
const fromAddress = process.env.RESERVATIONS_FROM_EMAIL ?? 'Steakhaus bei Mirko <reservierungen@steakhaus-meppen.de>'
try {
await client.emails.send({
from: fromAddress,
to: reservation.customerEmail,
subject,
html: renderReservationEmailHtml(subject, intro, reservation, showActions),
text: renderReservationEmailText(subject, intro, reservation, showActions),
})
} catch (err) {
console.error('Failed to send reservation email', err)
}
}
export function sendReservationConfirmationEmail(reservation: ReservationEmailInfo) {
return sendReservationEmail(
'Ihre Reservierung ist bestätigt',
'vielen Dank für Ihre Reservierung. Hier sind die Details:',
reservation,
true,
)
}
export function sendReservationUpdateEmail(reservation: ReservationEmailInfo) {
return sendReservationEmail(
'Ihre Reservierung wurde geändert',
'Ihre Reservierung wurde aktualisiert. Hier sind die neuen Details:',
reservation,
true,
)
}
export function sendReservationCancellationEmail(reservation: ReservationEmailInfo) {
return sendReservationEmail(
'Ihre Reservierung wurde storniert',
'Ihre folgende Reservierung wurde storniert:',
reservation,
false,
)
}
+132
View File
@@ -0,0 +1,132 @@
import { formatDateOnly } from '#/lib/datetime'
export const RESTAURANT_TIME_ZONE = 'Europe/Berlin'
export const RESERVATION_INTERVAL_MINUTES = 15
export type OpeningRange = { start: string; end: string }
// Keyed by the JS `Date#getDay()` convention: 0 = Sunday … 6 = Saturday.
const OPENING_HOURS_BY_WEEKDAY: Record<number, OpeningRange[]> = {
0: [
{ start: '11:30', end: '13:30' },
{ start: '17:00', end: '22:00' },
],
1: [],
2: [{ start: '17:00', end: '21:30' }],
3: [{ start: '17:00', end: '21:30' }],
4: [{ start: '17:00', end: '21:30' }],
5: [{ start: '17:00', end: '22:00' }],
6: [{ start: '17:00', end: '22:00' }],
}
const WEEKDAY_LABELS_MONDAY_FIRST: Array<{ day: string; weekday: number }> = [
{ day: 'Montag', weekday: 1 },
{ day: 'Dienstag', weekday: 2 },
{ day: 'Mittwoch', weekday: 3 },
{ day: 'Donnerstag', weekday: 4 },
{ day: 'Freitag', weekday: 5 },
{ day: 'Samstag', weekday: 6 },
{ day: 'Sonntag', weekday: 0 },
]
const WEEKDAY_SHORT_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const berlinPartsFormatter = new Intl.DateTimeFormat('en-US', {
timeZone: RESTAURANT_TIME_ZONE,
weekday: 'short',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
})
function toMinutes(time: string) {
const [hours, minutes] = time.split(':').map(Number)
return hours * 60 + minutes
}
function getBerlinTimeParts(date: Date) {
const parts = berlinPartsFormatter.formatToParts(date)
const byType = Object.fromEntries(parts.map((part) => [part.type, part.value]))
return {
weekday: WEEKDAY_SHORT_NAMES.indexOf(byType.weekday),
minutesSinceMidnight: Number(byType.hour) * 60 + Number(byType.minute),
}
}
export function isOnReservationInterval(date: Date, intervalMinutes: number = RESERVATION_INTERVAL_MINUTES) {
return date.getUTCMinutes() % intervalMinutes === 0 && date.getUTCSeconds() === 0
}
export function isWithinOpeningHours(date: Date) {
const { weekday, minutesSinceMidnight } = getBerlinTimeParts(date)
const ranges = OPENING_HOURS_BY_WEEKDAY[weekday] ?? []
return ranges.some(
(range) => minutesSinceMidnight >= toMinutes(range.start) && minutesSinceMidnight < toMinutes(range.end),
)
}
export function getOpeningHoursForDisplay() {
return WEEKDAY_LABELS_MONDAY_FIRST.map(({ day, weekday }) => {
const ranges = OPENING_HOURS_BY_WEEKDAY[weekday]
return {
day,
hours: ranges.length ? ranges.map((range) => `${range.start} – ${range.end}`) : null,
}
})
}
export function getTodayLabelBerlin() {
return new Intl.DateTimeFormat('de-DE', { timeZone: RESTAURANT_TIME_ZONE, weekday: 'long' }).format(new Date())
}
// The functions below drive the customer-facing date/time picker. They work with the
// browser's local time (not RESTAURANT_TIME_ZONE) since visitors are assumed to be
// browsing from the restaurant's own timezone — same assumption the rest of the
// booking form already makes when it builds a Date from an unqualified local string.
export function getOpeningRangesForWeekday(weekday: number): OpeningRange[] {
return OPENING_HOURS_BY_WEEKDAY[weekday] ?? []
}
export function generateTimeOptions(
ranges: OpeningRange[],
intervalMinutes: number = RESERVATION_INTERVAL_MINUTES,
): string[] {
const options: string[] = []
for (const range of ranges) {
const startMinutes = toMinutes(range.start)
const endMinutes = toMinutes(range.end)
for (let minutes = startMinutes; minutes < endMinutes; minutes += intervalMinutes) {
const hours = Math.floor(minutes / 60)
const remainder = minutes % 60
options.push(`${String(hours).padStart(2, '0')}:${String(remainder).padStart(2, '0')}`)
}
}
return options
}
export function getNextAvailableSlot(
from: Date,
intervalMinutes: number = RESERVATION_INTERVAL_MINUTES,
): { date: string; time: string } | null {
for (let dayOffset = 0; dayOffset < 14; dayOffset++) {
const day = new Date(from)
day.setDate(from.getDate() + dayOffset)
const dateStr = formatDateOnly(day)
const options = generateTimeOptions(getOpeningRangesForWeekday(day.getDay()), intervalMinutes)
for (const time of options) {
const slot = new Date(`${dateStr}T${time}`)
if (slot.getTime() >= from.getTime()) {
return { date: dateStr, time }
}
}
}
return null
}
+183 -21
View File
@@ -1,10 +1,77 @@
import { createServerFn } from '@tanstack/react-start'
import { and, eq, ne } from 'drizzle-orm'
import { eq } from 'drizzle-orm'
import { z } from 'zod'
import { db } from '#/db'
import { reservations, restaurantTables } from '#/db/schema'
import { requireStaffSession } from '#/lib/auth'
import {
sendReservationCancellationEmail,
sendReservationConfirmationEmail,
sendReservationUpdateEmail,
} from '#/lib/email'
import { isOnReservationInterval, isWithinOpeningHours } from '#/lib/opening-hours'
import { loadReservationSettings } from '#/lib/settings'
function formatLeadTime(minutes: number) {
if (minutes % 60 === 0) {
const hours = minutes / 60
return `${hours} ${hours === 1 ? 'Stunde' : 'Stunden'}`
}
return `${minutes} Minuten`
}
function assertValidReservationSlot(
reservationTime: Date,
intervalMinutes: number,
minLeadTimeMinutes: number,
maxLeadTimeDays: number,
) {
const earliestAllowed = Date.now() + minLeadTimeMinutes * 60_000
if (reservationTime.getTime() < earliestAllowed) {
throw new Error(
minLeadTimeMinutes > 0
? `Reservierungen müssen mindestens ${formatLeadTime(minLeadTimeMinutes)} im Voraus gebucht werden`
: 'Die Reservierungszeit muss in der Zukunft liegen',
)
}
const latestAllowed = Date.now() + maxLeadTimeDays * 24 * 60 * 60_000
if (reservationTime.getTime() > latestAllowed) {
throw new Error(`Reservierungen sind höchstens ${maxLeadTimeDays} Tage im Voraus möglich`)
}
if (!isOnReservationInterval(reservationTime, intervalMinutes)) {
throw new Error(`Reservierungen sind nur im ${intervalMinutes}-Minuten-Raster möglich`)
}
if (!isWithinOpeningHours(reservationTime)) {
throw new Error('Zu dieser Zeit haben wir leider geschlossen')
}
}
// A table is occupied for its own configured duration, not just the exact minute it
// was booked for — so overlapping bookings (not just identical ones) are conflicts.
// Each reservation's own stored duration is used (not the current setting), since an
// older booking keeps whatever duration was in effect when it was made.
async function findOverlappingReservation(
tableId: number,
start: Date,
durationMinutes: number,
excludeId?: number,
) {
const end = start.getTime() + durationMinutes * 60_000
const candidates = await db.select().from(reservations).where(eq(reservations.tableId, tableId))
return candidates.find((r) => {
if (excludeId !== undefined && r.id === excludeId) return false
const existingStart = r.reservationTime.getTime()
const existingEnd = existingStart + r.durationMinutes * 60_000
return existingStart < end && start.getTime() < existingEnd
})
}
export const getTables = createServerFn({ method: 'GET' }).handler(async () => {
return db.select().from(restaurantTables).orderBy(restaurantTables.name)
@@ -22,6 +89,7 @@ export const getReservations = createServerFn({ method: 'GET' }).handler(async (
customerEmail: reservations.customerEmail,
partySize: reservations.partySize,
reservationTime: reservations.reservationTime,
durationMinutes: reservations.durationMinutes,
})
.from(reservations)
.innerJoin(restaurantTables, eq(reservations.tableId, restaurantTables.id))
@@ -41,11 +109,15 @@ const createReservationSchema = z.object({
export const createReservation = createServerFn({ method: 'POST' })
.validator(createReservationSchema)
.handler(async ({ data }) => {
const settings = await loadReservationSettings()
const reservationTime = new Date(data.reservationTime)
if (reservationTime.getTime() < Date.now()) {
throw new Error('Reservation time must be in the future')
}
assertValidReservationSlot(
reservationTime,
settings.reservationIntervalMinutes,
settings.minLeadTimeMinutes,
settings.maxLeadTimeDays,
)
const [table] = await db
.select()
@@ -60,14 +132,10 @@ export const createReservation = createServerFn({ method: 'POST' })
throw new Error(`${table.name} only seats up to ${table.seats} guests`)
}
const [conflict] = await db
.select()
.from(reservations)
.where(
and(
eq(reservations.tableId, data.tableId),
eq(reservations.reservationTime, reservationTime),
),
const conflict = await findOverlappingReservation(
data.tableId,
reservationTime,
settings.reservationDurationMinutes,
)
if (conflict) {
@@ -80,11 +148,21 @@ export const createReservation = createServerFn({ method: 'POST' })
tableId: data.tableId,
customerName: data.customerName,
customerEmail: data.customerEmail,
durationMinutes: settings.reservationDurationMinutes,
partySize: data.partySize,
reservationTime,
})
.returning()
await sendReservationConfirmationEmail({
customerName: reservation.customerName,
customerEmail: reservation.customerEmail,
partySize: reservation.partySize,
reservationTime: reservation.reservationTime,
cancelToken: reservation.cancelToken,
table,
})
return reservation
})
@@ -97,8 +175,16 @@ export const updateReservation = createServerFn({ method: 'POST' })
.handler(async ({ data }) => {
await requireStaffSession()
const settings = await loadReservationSettings()
const reservationTime = new Date(data.reservationTime)
assertValidReservationSlot(
reservationTime,
settings.reservationIntervalMinutes,
settings.minLeadTimeMinutes,
settings.maxLeadTimeDays,
)
const [table] = await db
.select()
.from(restaurantTables)
@@ -112,15 +198,11 @@ export const updateReservation = createServerFn({ method: 'POST' })
throw new Error(`${table.name} only seats up to ${table.seats} guests`)
}
const [conflict] = await db
.select()
.from(reservations)
.where(
and(
eq(reservations.tableId, data.tableId),
eq(reservations.reservationTime, reservationTime),
ne(reservations.id, data.id),
),
const conflict = await findOverlappingReservation(
data.tableId,
reservationTime,
settings.reservationDurationMinutes,
data.id,
)
if (conflict) {
@@ -135,6 +217,7 @@ export const updateReservation = createServerFn({ method: 'POST' })
customerEmail: data.customerEmail,
partySize: data.partySize,
reservationTime,
durationMinutes: settings.reservationDurationMinutes,
})
.where(eq(reservations.id, data.id))
.returning()
@@ -143,6 +226,15 @@ export const updateReservation = createServerFn({ method: 'POST' })
throw new Error('Reservation not found')
}
await sendReservationUpdateEmail({
customerName: updated.customerName,
customerEmail: updated.customerEmail,
partySize: updated.partySize,
reservationTime: updated.reservationTime,
cancelToken: updated.cancelToken,
table,
})
return updated
})
@@ -161,5 +253,75 @@ export const deleteReservation = createServerFn({ method: 'POST' })
throw new Error('Reservation not found')
}
const [table] = await db
.select()
.from(restaurantTables)
.where(eq(restaurantTables.id, deleted.tableId))
if (table) {
await sendReservationCancellationEmail({
customerName: deleted.customerName,
customerEmail: deleted.customerEmail,
partySize: deleted.partySize,
reservationTime: deleted.reservationTime,
cancelToken: deleted.cancelToken,
table,
})
}
return deleted
})
const getReservationByCancelTokenSchema = z.object({
token: z.string().min(1),
})
export const getReservationByCancelToken = createServerFn({ method: 'GET' })
.validator(getReservationByCancelTokenSchema)
.handler(async ({ data }) => {
const [reservation] = await db
.select({
customerName: reservations.customerName,
partySize: reservations.partySize,
reservationTime: reservations.reservationTime,
tableName: restaurantTables.name,
})
.from(reservations)
.innerJoin(restaurantTables, eq(reservations.tableId, restaurantTables.id))
.where(eq(reservations.cancelToken, data.token))
return reservation ?? null
})
const cancelReservationByTokenSchema = z.object({
token: z.string().min(1),
})
export const cancelReservationByToken = createServerFn({ method: 'POST' })
.validator(cancelReservationByTokenSchema)
.handler(async ({ data }) => {
const [deleted] = await db
.delete(reservations)
.where(eq(reservations.cancelToken, data.token))
.returning()
if (!deleted) {
throw new Error('Reservation not found')
}
const [table] = await db
.select()
.from(restaurantTables)
.where(eq(restaurantTables.id, deleted.tableId))
if (table) {
await sendReservationCancellationEmail({
customerName: deleted.customerName,
customerEmail: deleted.customerEmail,
partySize: deleted.partySize,
reservationTime: deleted.reservationTime,
cancelToken: deleted.cancelToken,
table,
})
}
})
+60
View File
@@ -0,0 +1,60 @@
import { createServerFn, createServerOnlyFn } from '@tanstack/react-start'
import { eq } from 'drizzle-orm'
import { z } from 'zod'
import { db } from '#/db'
import { restaurantSettings } from '#/db/schema'
import { requireStaffSession } from '#/lib/auth'
// Reads never require a session — the public booking form needs the interval and
// lead time to generate its own time-slot grid, just like getTables(). Exported
// (rather than only reachable through the server fn below) so reservations.ts can
// read the current settings directly while validating a booking. This module is
// imported by client-reachable route files for the createServerFns below, so —
// same issue as `#/lib/auth` — a plain, unwrapped export using `db` directly would
// drag the Postgres driver into the browser bundle; `createServerOnlyFn` keeps it
// server-side only.
export const loadReservationSettings = createServerOnlyFn(async function loadReservationSettings() {
const [existing] = await db.select().from(restaurantSettings).limit(1)
if (existing) return existing
const [created] = await db.insert(restaurantSettings).values({}).returning()
return created
})
export const getReservationSettings = createServerFn({ method: 'GET' }).handler(async () => {
return loadReservationSettings()
})
const updateReservationSettingsSchema = z.object({
reservationIntervalMinutes: z.number().int().min(5).max(120),
reservationDurationMinutes: z.number().int().min(15).max(480),
minLeadTimeMinutes: z.number().int().min(0).max(10080),
maxLeadTimeDays: z.number().int().min(1).max(365),
})
export const updateReservationSettings = createServerFn({ method: 'POST' })
.validator(updateReservationSettingsSchema)
.handler(async ({ data }) => {
console.log('[settings] updateReservationSettings called with', data)
const session = await requireStaffSession()
console.log('[settings] session role:', session.user.role)
if (session.user.role !== 'admin') {
throw new Error('Nur Admins dürfen die Einstellungen ändern')
}
const existing = await loadReservationSettings()
console.log('[settings] existing row before update:', existing)
const [updated] = await db
.update(restaurantSettings)
.set(data)
.where(eq(restaurantSettings.id, existing.id))
.returning()
console.log('[settings] row after update:', updated)
return updated
})
+60 -3
View File
@@ -13,7 +13,9 @@ import { Route as IndexRouteImport } from './routes/index'
import { Route as ImpressumRouteImport } from './routes/impressum'
import { Route as StaffIndexRouteImport } from './routes/staff.index'
import { Route as StaffLoginRouteImport } from './routes/staff.login'
import { Route as StaffSettingsRouteImport } from './routes/staff.settings'
import { Route as StaffTeamRouteImport } from './routes/staff.team'
import { Route as StornierenTokenRouteImport } from './routes/stornieren.$token'
const IndexRoute = IndexRouteImport.update({
id: '/',
@@ -35,24 +37,38 @@ const StaffLoginRoute = StaffLoginRouteImport.update({
path: '/staff/login',
getParentRoute: () => rootRouteImport,
} as any)
const StaffSettingsRoute = StaffSettingsRouteImport.update({
id: '/staff/settings',
path: '/staff/settings',
getParentRoute: () => rootRouteImport,
} as any)
const StaffTeamRoute = StaffTeamRouteImport.update({
id: '/staff/team',
path: '/staff/team',
getParentRoute: () => rootRouteImport,
} as any)
const StornierenTokenRoute = StornierenTokenRouteImport.update({
id: '/stornieren/$token',
path: '/stornieren/$token',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/impressum': typeof ImpressumRoute
'/staff/login': typeof StaffLoginRoute
'/staff/settings': typeof StaffSettingsRoute
'/staff/team': typeof StaffTeamRoute
'/stornieren/$token': typeof StornierenTokenRoute
'/staff/': typeof StaffIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/impressum': typeof ImpressumRoute
'/staff/login': typeof StaffLoginRoute
'/staff/settings': typeof StaffSettingsRoute
'/staff/team': typeof StaffTeamRoute
'/stornieren/$token': typeof StornierenTokenRoute
'/staff': typeof StaffIndexRoute
}
export interface FileRoutesById {
@@ -60,23 +76,48 @@ export interface FileRoutesById {
'/': typeof IndexRoute
'/impressum': typeof ImpressumRoute
'/staff/login': typeof StaffLoginRoute
'/staff/settings': typeof StaffSettingsRoute
'/staff/team': typeof StaffTeamRoute
'/stornieren/$token': typeof StornierenTokenRoute
'/staff/': typeof StaffIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/impressum' | '/staff/login' | '/staff/team' | '/staff/'
fullPaths:
| '/'
| '/impressum'
| '/staff/login'
| '/staff/settings'
| '/staff/team'
| '/stornieren/$token'
| '/staff/'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/impressum' | '/staff/login' | '/staff/team' | '/staff'
to:
| '/'
| '/impressum'
| '/staff/login'
| '/staff/settings'
| '/staff/team'
| '/stornieren/$token'
| '/staff'
id:
'__root__' | '/' | '/impressum' | '/staff/login' | '/staff/team' | '/staff/'
| '__root__'
| '/'
| '/impressum'
| '/staff/login'
| '/staff/settings'
| '/staff/team'
| '/stornieren/$token'
| '/staff/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
ImpressumRoute: typeof ImpressumRoute
StaffLoginRoute: typeof StaffLoginRoute
StaffSettingsRoute: typeof StaffSettingsRoute
StaffTeamRoute: typeof StaffTeamRoute
StornierenTokenRoute: typeof StornierenTokenRoute
StaffIndexRoute: typeof StaffIndexRoute
}
@@ -110,6 +151,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof StaffLoginRouteImport
parentRoute: typeof rootRouteImport
}
'/staff/settings': {
id: '/staff/settings'
path: '/staff/settings'
fullPath: '/staff/settings'
preLoaderRoute: typeof StaffSettingsRouteImport
parentRoute: typeof rootRouteImport
}
'/staff/team': {
id: '/staff/team'
path: '/staff/team'
@@ -117,6 +165,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof StaffTeamRouteImport
parentRoute: typeof rootRouteImport
}
'/stornieren/$token': {
id: '/stornieren/$token'
path: '/stornieren/$token'
fullPath: '/stornieren/$token'
preLoaderRoute: typeof StornierenTokenRouteImport
parentRoute: typeof rootRouteImport
}
}
}
@@ -124,7 +179,9 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
ImpressumRoute: ImpressumRoute,
StaffLoginRoute: StaffLoginRoute,
StaffSettingsRoute: StaffSettingsRoute,
StaffTeamRoute: StaffTeamRoute,
StornierenTokenRoute: StornierenTokenRoute,
StaffIndexRoute: StaffIndexRoute,
}
export const routeTree = rootRouteImport
+30 -20
View File
@@ -1,28 +1,22 @@
import { useState } from 'react'
import { createFileRoute, useRouter } from '@tanstack/react-router'
import { ReservationDateTimeField } from '#/components/reservation-datetime-field'
import { SiteFooter } from '#/components/site-footer'
import { SiteHeader } from '#/components/site-header'
import { formatDateTimeLocal } from '#/lib/datetime'
import { getNextAvailableSlot, getOpeningHoursForDisplay, getTodayLabelBerlin } from '#/lib/opening-hours'
import { createReservation, getTables } from '#/lib/reservations'
import { getReservationSettings } from '#/lib/settings'
export const Route = createFileRoute('/')({
component: Home,
loader: async () => {
const tables = await getTables()
return { tables }
const [tables, settings] = await Promise.all([getTables(), getReservationSettings()])
return { tables, settings }
},
})
const OPENING_HOURS = [
{ day: 'Montag', hours: null },
{ day: 'Dienstag', hours: ['17:00 – 21:30'] },
{ day: 'Mittwoch', hours: ['17:00 – 21:30'] },
{ day: 'Donnerstag', hours: ['17:00 – 21:30'] },
{ day: 'Freitag', hours: ['17:00 – 22:00'] },
{ day: 'Samstag', hours: ['17:00 – 22:00'] },
{ day: 'Sonntag', hours: ['11:30 – 13:30', '17:00 – 22:00'] },
]
const OPENING_HOURS = getOpeningHoursForDisplay()
function FlameIcon() {
return (
@@ -39,20 +33,24 @@ function FlameIcon() {
function Home() {
const router = useRouter()
const { tables } = Route.useLoaderData()
const { tables, settings } = Route.useLoaderData()
const [tableId, setTableId] = useState<number | string>(tables[0]?.id ?? '')
const [customerName, setCustomerName] = useState('')
const [customerEmail, setCustomerEmail] = useState('')
const [partySize, setPartySize] = useState(2)
const [reservationTime, setReservationTime] = useState(() =>
formatDateTimeLocal(new Date(Date.now() + 60 * 60 * 1000)),
const [reservationTime, setReservationTime] = useState(() => {
const slot = getNextAvailableSlot(
new Date(Date.now() + settings.minLeadTimeMinutes * 60_000),
settings.reservationIntervalMinutes,
)
return slot ? `${slot.date}T${slot.time}` : ''
})
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const todayName = new Date().toLocaleDateString('de-DE', { weekday: 'long' })
const todayName = getTodayLabelBerlin()
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
@@ -64,6 +62,11 @@ function Home() {
return
}
if (!reservationTime.split('T')[1]) {
setError('Bitte wählen Sie eine gültige Uhrzeit aus')
return
}
setIsSubmitting(true)
try {
await createReservation({
@@ -182,6 +185,7 @@ function Home() {
Anzahl Personen
<input
type="number"
inputMode="numeric"
min={1}
max={20}
value={partySize}
@@ -192,15 +196,21 @@ function Home() {
<label>
Datum &amp; Uhrzeit
<input
type="datetime-local"
<ReservationDateTimeField
value={reservationTime}
onChange={(e) => setReservationTime(e.target.value)}
required
onChange={setReservationTime}
intervalMinutes={settings.reservationIntervalMinutes}
minLeadTimeMinutes={settings.minLeadTimeMinutes}
maxLeadTimeDays={settings.maxLeadTimeDays}
/>
</label>
</div>
<p className="field-hint">
Verfügbare Uhrzeiten richten sich nach unseren{' '}
<a href="#oeffnungszeiten">Öffnungszeiten</a>.
</p>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Wird gebucht …' : 'Jetzt reservieren'}
</button>
+153 -27
View File
@@ -1,9 +1,14 @@
import { useState } from 'react'
import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
import { ConfirmDialog } from '#/components/confirm-dialog'
import { ReservationCharts } from '#/components/reservation-charts'
import { ReservationDateTimeField } from '#/components/reservation-datetime-field'
import { StaffTabs } from '#/components/staff-tabs'
import { getCurrentStaffUser } from '#/lib/auth'
import { authClient } from '#/lib/auth-client'
import { formatDateTimeLocal } from '#/lib/datetime'
import { formatDateOnly, formatDateTimeLocal } from '#/lib/datetime'
import { generateTimeOptions, getNextAvailableSlot, getOpeningRangesForWeekday } from '#/lib/opening-hours'
import {
createReservation,
deleteReservation,
@@ -11,6 +16,7 @@ import {
getTables,
updateReservation,
} from '#/lib/reservations'
import { getReservationSettings } from '#/lib/settings'
export const Route = createFileRoute('/staff/')({
component: StaffDashboard,
@@ -22,8 +28,12 @@ export const Route = createFileRoute('/staff/')({
return { staffUser }
},
loader: async ({ context }) => {
const [reservations, tables] = await Promise.all([getReservations(), getTables()])
return { staffUser: context.staffUser, reservations, tables }
const [reservations, tables, settings] = await Promise.all([
getReservations(),
getTables(),
getReservationSettings(),
])
return { staffUser: context.staffUser, reservations, tables, settings }
},
})
@@ -48,32 +58,86 @@ type FormState = {
reservationTime: string
}
function emptyForm(defaultTableId: number | string): FormState {
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: '',
partySize: 2,
reservationTime: formatDateTimeLocal(new Date(Date.now() + 60 * 60 * 1000)),
reservationTime: slot ? `${slot.date}T${slot.time}` : '',
}
}
function StaffDashboard() {
const router = useRouter()
const { staffUser, reservations, tables } = 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 ?? ''))
const [form, setForm] = useState<FormState>(() => emptyForm(tables[0]?.id ?? '', settings))
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [deletingId, setDeletingId] = useState<number | null>(null)
const [pendingDelete, setPendingDelete] = useState<ReservationRow | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const [tableFilter, setTableFilter] = useState('all')
const [timeFilter, setTimeFilter] = useState<'all' | 'today' | 'upcoming' | 'past'>('all')
const now = new Date()
const todaysReservations = reservations.filter((r) => isSameDay(new Date(r.reservationTime), now))
const guestsToday = todaysReservations.reduce((sum, r) => sum + r.partySize, 0)
const upcoming = reservations.filter((r) => new Date(r.reservationTime).getTime() >= now.getTime())
const filteredReservations = reservations.filter((r) => {
if (tableFilter !== 'all' && String(r.tableId) !== tableFilter) return false
const reservationTime = new Date(r.reservationTime)
if (timeFilter === 'today' && !isSameDay(reservationTime, now)) return false
if (timeFilter === 'upcoming' && reservationTime.getTime() < now.getTime()) return false
if (timeFilter === 'past' && reservationTime.getTime() >= now.getTime()) return false
const query = searchQuery.trim().toLowerCase()
if (query) {
const haystack = `${r.customerName} ${r.customerEmail} ${r.tableName}`.toLowerCase()
if (!haystack.includes(query)) return false
}
return true
})
// Other reservations already sitting on the currently selected table + date, so the
// time picker can block them instead of letting staff pick a slot the server will
// just reject as double-booked. A reservation occupies its whole configured
// duration, not just its exact start time, so every interval-grid slot that falls
// inside that window counts as taken too.
const [selectedDate] = form.reservationTime.split('T')
const otherReservationsOnSelection = reservations.filter(
(r) =>
r.id !== editingId &&
String(r.tableId) === String(form.tableId) &&
formatDateOnly(new Date(r.reservationTime)) === selectedDate,
)
const bookedTimesForForm = selectedDate
? generateTimeOptions(
getOpeningRangesForWeekday(new Date(`${selectedDate}T00:00`).getDay()),
settings.reservationIntervalMinutes,
).filter((option) => {
const slotStart = new Date(`${selectedDate}T${option}`).getTime()
return otherReservationsOnSelection.some((r) => {
const otherStart = new Date(r.reservationTime).getTime()
const otherEnd = otherStart + r.durationMinutes * 60_000
return slotStart >= otherStart && slotStart < otherEnd
})
})
: []
function startEdit(reservation: ReservationRow) {
setEditingId(reservation.id)
setForm({
@@ -89,7 +153,7 @@ function StaffDashboard() {
function cancelEdit() {
setEditingId(null)
setForm(emptyForm(tables[0]?.id ?? ''))
setForm(emptyForm(tables[0]?.id ?? '', settings))
setError(null)
}
@@ -103,6 +167,11 @@ function StaffDashboard() {
return
}
if (!form.reservationTime.split('T')[1]) {
setError('Bitte wählen Sie eine gültige Uhrzeit aus')
return
}
setIsSubmitting(true)
try {
const payload = {
@@ -122,7 +191,7 @@ function StaffDashboard() {
}
setEditingId(null)
setForm(emptyForm(tables[0]?.id ?? ''))
setForm(emptyForm(tables[0]?.id ?? '', settings))
await router.invalidate()
} catch (err) {
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
@@ -131,11 +200,11 @@ function StaffDashboard() {
}
}
async function handleDelete(reservation: ReservationRow) {
if (!window.confirm(`Reservierung von ${reservation.customerName} wirklich löschen?`)) {
return
}
async function confirmDelete() {
const reservation = pendingDelete
if (!reservation) return
setPendingDelete(null)
setDeletingId(reservation.id)
setError(null)
setSuccess(null)
@@ -162,16 +231,12 @@ function StaffDashboard() {
return (
<div className="staff-page">
<div className="staff-nav">
<header className="staff-topbar">
<Link className="wordmark" to="/">
Steakhaus <span>bei Mirko</span>
</Link>
<div className="staff-topbar-right">
{staffUser.role === 'admin' && (
<Link className="btn btn-secondary" to="/staff/team">
Mitarbeiter
</Link>
)}
<span>Angemeldet als {staffUser.name}</span>
<button type="button" className="btn btn-secondary" onClick={handleLogout}>
Abmelden
@@ -179,6 +244,11 @@ function StaffDashboard() {
</div>
</header>
<div className="staff-tabs-bar">
<StaffTabs role={staffUser.role} />
</div>
</div>
<main className="page staff-main">
<h1 className="section-heading">Reservierungen</h1>
@@ -201,6 +271,8 @@ function StaffDashboard() {
</div>
</div>
<ReservationCharts reservations={reservations} tables={tables} />
<section className="staff-form-section">
<h2 className="section-heading staff-subheading">
{editingId ? 'Reservierung bearbeiten' : 'Neue Reservierung'}
@@ -252,6 +324,7 @@ function StaffDashboard() {
Anzahl Personen
<input
type="number"
inputMode="numeric"
min={1}
max={20}
value={form.partySize}
@@ -262,11 +335,13 @@ function StaffDashboard() {
<label>
Datum &amp; Uhrzeit
<input
type="datetime-local"
<ReservationDateTimeField
value={form.reservationTime}
onChange={(e) => setForm((f) => ({ ...f, reservationTime: e.target.value }))}
required
onChange={(reservationTime) => setForm((f) => ({ ...f, reservationTime }))}
bookedTimes={bookedTimesForForm}
intervalMinutes={settings.reservationIntervalMinutes}
minLeadTimeMinutes={settings.minLeadTimeMinutes}
maxLeadTimeDays={settings.maxLeadTimeDays}
/>
</label>
</div>
@@ -288,8 +363,43 @@ function StaffDashboard() {
</section>
<section className="staff-table-section">
{reservations.length === 0 ? (
<p className="empty-state">Noch keine Reservierungen vorhanden.</p>
{reservations.length > 0 && (
<div className="table-filters">
<input
type="search"
className="table-search"
placeholder="Suche nach Name, E-Mail oder Tisch …"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
aria-label="Reservierungen durchsuchen"
/>
<select value={tableFilter} onChange={(e) => setTableFilter(e.target.value)} aria-label="Nach Tisch filtern">
<option value="all">Alle Tische</option>
{tables.map((table) => (
<option key={table.id} value={String(table.id)}>
{table.name}
</option>
))}
</select>
<select
value={timeFilter}
onChange={(e) => setTimeFilter(e.target.value as typeof timeFilter)}
aria-label="Nach Zeitraum filtern"
>
<option value="all">Alle Zeiträume</option>
<option value="today">Heute</option>
<option value="upcoming">Anstehend</option>
<option value="past">Vergangen</option>
</select>
</div>
)}
{filteredReservations.length === 0 ? (
<p className="empty-state">
{reservations.length === 0
? 'Noch keine Reservierungen vorhanden.'
: 'Keine Reservierungen für diese Filter gefunden.'}
</p>
) : (
<div className="table-scroll">
<table className="reservations-table">
@@ -303,7 +413,7 @@ function StaffDashboard() {
</tr>
</thead>
<tbody>
{reservations.map((reservation) => (
{filteredReservations.map((reservation) => (
<tr key={reservation.id} className={editingId === reservation.id ? 'is-editing' : undefined}>
<td>{dateTimeFormatter.format(new Date(reservation.reservationTime))}</td>
<td>{reservation.tableName}</td>
@@ -313,7 +423,8 @@ function StaffDashboard() {
<span className="muted">{reservation.customerEmail}</span>
</td>
<td>{reservation.partySize}</td>
<td className="table-actions">
<td>
<div className="table-actions">
<button type="button" className="btn btn-secondary" onClick={() => startEdit(reservation)}>
Bearbeiten
</button>
@@ -321,10 +432,11 @@ function StaffDashboard() {
type="button"
className="btn btn-danger"
disabled={deletingId === reservation.id}
onClick={() => handleDelete(reservation)}
onClick={() => setPendingDelete(reservation)}
>
{deletingId === reservation.id ? 'Löschen …' : 'Löschen'}
</button>
</div>
</td>
</tr>
))}
@@ -334,6 +446,20 @@ function StaffDashboard() {
)}
</section>
</main>
<ConfirmDialog
open={pendingDelete !== null}
title="Reservierung löschen"
message={
pendingDelete
? `Reservierung von ${pendingDelete.customerName} wirklich löschen? Der Gast erhält eine E-Mail zur Bestätigung der Stornierung. Dies kann nicht rückgängig gemacht werden.`
: ''
}
confirmLabel="Löschen"
danger
onConfirm={confirmDelete}
onCancel={() => setPendingDelete(null)}
/>
</div>
)
}
+20 -1
View File
@@ -1,9 +1,23 @@
import { useState } from 'react'
import { createFileRoute, redirect, useRouter } from '@tanstack/react-router'
import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
import { getCurrentStaffUser } from '#/lib/auth'
import { authClient } from '#/lib/auth-client'
function ArrowLeftIcon() {
return (
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" aria-hidden="true">
<path
d="M19 12H5M11 6l-6 6 6 6"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
export const Route = createFileRoute('/staff/login')({
component: StaffLogin,
beforeLoad: async () => {
@@ -41,6 +55,11 @@ function StaffLogin() {
return (
<div className="staff-page staff-login-page">
<form className="reservation-form staff-login-form" onSubmit={handleSubmit}>
<Link className="back-link" to="/">
<ArrowLeftIcon />
Zurück zur Startseite
</Link>
<div className="staff-login-heading">
<span className="section-kicker">Mitarbeiterbereich</span>
<h1 className="section-heading">Anmelden</h1>
+175
View File
@@ -0,0 +1,175 @@
import { useState } from 'react'
import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
import { StaffTabs } from '#/components/staff-tabs'
import { getCurrentStaffUser } from '#/lib/auth'
import { authClient } from '#/lib/auth-client'
import { getReservationSettings, updateReservationSettings } from '#/lib/settings'
export const Route = createFileRoute('/staff/settings')({
component: StaffSettings,
beforeLoad: async () => {
const staffUser = await getCurrentStaffUser()
if (!staffUser) {
throw redirect({ to: '/staff/login' })
}
if (staffUser.role !== 'admin') {
throw redirect({ to: '/staff' })
}
return { staffUser }
},
loader: async ({ context }) => {
const settings = await getReservationSettings()
return { staffUser: context.staffUser, settings }
},
})
type FormState = {
reservationIntervalMinutes: number
reservationDurationMinutes: number
minLeadTimeMinutes: number
maxLeadTimeDays: number
}
function StaffSettings() {
const router = useRouter()
const { staffUser, settings } = Route.useLoaderData()
const [form, setForm] = useState<FormState>({
reservationIntervalMinutes: settings.reservationIntervalMinutes,
reservationDurationMinutes: settings.reservationDurationMinutes,
minLeadTimeMinutes: settings.minLeadTimeMinutes,
maxLeadTimeDays: settings.maxLeadTimeDays,
})
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
async function handleLogout() {
await authClient.signOut()
await router.navigate({ to: '/staff/login' })
await router.invalidate()
}
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
setError(null)
setSuccess(null)
setIsSubmitting(true)
try {
await updateReservationSettings({ data: form })
setSuccess('Einstellungen wurden gespeichert.')
await router.invalidate()
} catch (err) {
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
} finally {
setIsSubmitting(false)
}
}
return (
<div className="staff-page">
<div className="staff-nav">
<header className="staff-topbar">
<Link className="wordmark" to="/">
Steakhaus <span>bei Mirko</span>
</Link>
<div className="staff-topbar-right">
<span>Angemeldet als {staffUser.name}</span>
<button type="button" className="btn btn-secondary" onClick={handleLogout}>
Abmelden
</button>
</div>
</header>
<div className="staff-tabs-bar">
<StaffTabs role={staffUser.role} />
</div>
</div>
<main className="page staff-main">
<h1 className="section-heading">Einstellungen</h1>
<section className="staff-form-section">
<h2 className="section-heading staff-subheading">Reservierungsregeln</h2>
<form className="reservation-form" onSubmit={handleSubmit}>
<label>
Reservierungsintervall (Minuten)
<input
type="number"
inputMode="numeric"
min={5}
max={120}
step={5}
value={form.reservationIntervalMinutes}
onChange={(e) => setForm((f) => ({ ...f, reservationIntervalMinutes: Number(e.target.value) }))}
required
/>
</label>
<p className="field-hint">Abstand zwischen den wählbaren Uhrzeiten im Buchungsformular.</p>
<label>
Reservierungsdauer (Minuten)
<input
type="number"
inputMode="numeric"
min={15}
max={480}
step={15}
value={form.reservationDurationMinutes}
onChange={(e) => setForm((f) => ({ ...f, reservationDurationMinutes: Number(e.target.value) }))}
required
/>
</label>
<p className="field-hint">
Wie lange ein Tisch nach einer Reservierung als belegt gilt — verhindert überlappende Buchungen.
</p>
<label>
Mindestvorlaufzeit (Minuten)
<input
type="number"
inputMode="numeric"
min={0}
max={10080}
step={15}
value={form.minLeadTimeMinutes}
onChange={(e) => setForm((f) => ({ ...f, minLeadTimeMinutes: Number(e.target.value) }))}
required
/>
</label>
<p className="field-hint">
Wie kurzfristig frühestens reserviert werden kann, z. B. 60 für „mindestens eine Stunde im Voraus“.
</p>
<label>
Buchbar bis zu (Tage im Voraus)
<input
type="number"
inputMode="numeric"
min={1}
max={365}
step={1}
value={form.maxLeadTimeDays}
onChange={(e) => setForm((f) => ({ ...f, maxLeadTimeDays: Number(e.target.value) }))}
required
/>
</label>
<p className="field-hint">
Wie weit im Voraus reserviert werden kann, z. B. 60 für „höchstens 60 Tage im Voraus“.
</p>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Wird gespeichert …' : 'Speichern'}
</button>
{error && <p className="message message-error">{error}</p>}
{success && <p className="message message-success">{success}</p>}
</form>
</section>
</main>
</div>
)
}
+19 -4
View File
@@ -1,6 +1,7 @@
import { useState } from 'react'
import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
import { StaffTabs } from '#/components/staff-tabs'
import { getCurrentStaffUser } from '#/lib/auth'
import { authClient } from '#/lib/auth-client'
import { listStaffMembers } from '#/lib/staff'
@@ -129,6 +130,12 @@ function StaffTeam() {
setBusyId(null)
}
async function handleLogout() {
await authClient.signOut()
await router.navigate({ to: '/staff/login' })
await router.invalidate()
}
async function handleSetPassword(member: StaffMember) {
const newPassword = window.prompt(`Neues Passwort für ${member.name} (mind. 8 Zeichen):`)
@@ -156,18 +163,24 @@ function StaffTeam() {
return (
<div className="staff-page">
<div className="staff-nav">
<header className="staff-topbar">
<Link className="wordmark" to="/">
Steakhaus <span>bei Mirko</span>
</Link>
<div className="staff-topbar-right">
<Link className="btn btn-secondary" to="/staff">
Zu den Reservierungen
</Link>
<span>Angemeldet als {staffUser.name}</span>
<button type="button" className="btn btn-secondary" onClick={handleLogout}>
Abmelden
</button>
</div>
</header>
<div className="staff-tabs-bar">
<StaffTabs role={staffUser.role} />
</div>
</div>
<main className="page staff-main">
<h1 className="section-heading">Mitarbeiter</h1>
@@ -249,7 +262,8 @@ function StaffTeam() {
<td>{member.email}</td>
<td>{member.role === 'admin' ? 'Admin' : 'Mitarbeiter'}</td>
<td>{member.banned ? 'Gesperrt' : 'Aktiv'}</td>
<td className="table-actions">
<td>
<div className="table-actions">
<button
type="button"
className="btn btn-secondary"
@@ -282,6 +296,7 @@ function StaffTeam() {
>
Entfernen
</button>
</div>
</td>
</tr>
))}
+96
View File
@@ -0,0 +1,96 @@
import { useState } from 'react'
import { createFileRoute, Link } from '@tanstack/react-router'
import { SiteFooter } from '#/components/site-footer'
import { SiteHeader } from '#/components/site-header'
import { cancelReservationByToken, getReservationByCancelToken } from '#/lib/reservations'
export const Route = createFileRoute('/stornieren/$token')({
component: CancelReservationPage,
loader: async ({ params }) => {
const reservation = await getReservationByCancelToken({ data: { token: params.token } })
return { reservation }
},
})
const dateTimeFormatter = new Intl.DateTimeFormat('de-DE', {
dateStyle: 'full',
timeStyle: 'short',
})
function CancelReservationPage() {
const { token } = Route.useParams()
const { reservation } = Route.useLoaderData()
const [isCancelled, setIsCancelled] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleCancel() {
setError(null)
setIsSubmitting(true)
try {
await cancelReservationByToken({ data: { token } })
setIsCancelled(true)
} catch (err) {
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
} finally {
setIsSubmitting(false)
}
}
return (
<>
<SiteHeader />
<main className="page">
<section className="section">
<div className="section-grid">
<div>
<span className="section-kicker">Reservierung verwalten</span>
<h1 className="section-heading">Reservierung stornieren</h1>
</div>
{!reservation ? (
<p className="empty-state">
Diese Reservierung wurde nicht gefunden oder ist bereits storniert.{' '}
<Link to="/">Zurück zur Startseite</Link>
</p>
) : isCancelled ? (
<p className="message message-success">
Ihre Reservierung wurde storniert. Wir haben Ihnen eine Bestätigung per E-Mail
gesendet. <Link to="/">Zurück zur Startseite</Link>
</p>
) : (
<div className="reservation-form">
<ul className="contact-list">
<li>
<span className="label">Tisch</span>
<span>{reservation.tableName}</span>
</li>
<li>
<span className="label">Datum &amp; Uhrzeit</span>
<span>{dateTimeFormatter.format(new Date(reservation.reservationTime))}</span>
</li>
<li>
<span className="label">Personen</span>
<span>{reservation.partySize}</span>
</li>
</ul>
<button type="button" className="btn btn-danger" disabled={isSubmitting} onClick={handleCancel}>
{isSubmitting ? 'Wird storniert …' : 'Reservierung stornieren'}
</button>
{error && <p className="message message-error">{error}</p>}
</div>
)}
</div>
</section>
</main>
<SiteFooter />
</>
)
}
+580 -20
View File
@@ -19,9 +19,12 @@ html {
scroll-behavior: smooth;
}
/* `clip`, not `hidden` — `hidden` on both axes-owners forces the browser to also
compute `overflow-y: auto` on each, which creates two nested scroll containers
and breaks `position: sticky` for everything on the page. */
html,
body {
overflow-x: hidden;
overflow-x: clip;
}
body {
@@ -42,7 +45,7 @@ h3 {
}
a {
color: var(--gold-bright);
color: var(--text);
}
.page {
@@ -54,6 +57,17 @@ a {
/* Header */
.site-header {
position: sticky;
top: 0;
z-index: 50;
background: var(--bg);
border-bottom: 1px solid var(--line);
display: flex;
align-content: center;
justify-content: center;
}
.site-header-inner {
display: flex;
align-items: center;
justify-content: space-between;
@@ -61,12 +75,13 @@ a {
row-gap: 0.5rem;
gap: 1rem;
padding: 1.25rem 1.5rem;
max-width: 68rem;
margin: 0 auto;
min-height: 5rem;
width: 100%;
max-width: 120rem
}
@media (max-width: 30rem) {
.site-header {
.site-header-inner {
justify-content: center;
text-align: center;
}
@@ -101,7 +116,19 @@ a {
}
.site-nav a:hover {
color: var(--gold-bright);
color: var(--text);
}
.staff-login-link {
color: var(--text-muted);
font-size: 0.8rem;
text-decoration: none;
opacity: 0.75;
}
.staff-login-link:hover {
color: var(--text);
opacity: 1;
}
/* Hero */
@@ -134,7 +161,7 @@ a {
}
.hero h1 span {
color: var(--gold-bright);
color: var(--text);
}
.hero p {
@@ -179,8 +206,8 @@ a {
}
.btn-secondary:hover {
border-color: var(--gold);
color: var(--gold-bright);
border-color: var(--text-muted);
color: var(--text);
}
/* Sections */
@@ -204,7 +231,7 @@ a {
font-size: 0.8rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--gold);
color: var(--text-muted);
margin-bottom: 0.75rem;
}
@@ -293,11 +320,173 @@ a {
color: var(--text-muted);
}
.datetime-field {
display: flex;
flex-wrap: wrap;
gap: 0.6rem
}
.date-input-group {
position: relative;
flex: 1 1 10rem;
min-width: 10rem;
}
.date-input-group input[type='text'] {
width: 100%;
padding-right: 2.5rem;
}
.datetime-field select {
flex: 1 1 6.5rem;
min-width: 6.5rem;
}
.datetime-field select:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.date-picker-trigger {
position: absolute;
top: 50%;
right: 0.3rem;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 1.9rem;
height: 1.9rem;
padding: 0;
border: none;
border-radius: 0.25rem;
background: transparent;
cursor: pointer;
}
.date-picker-trigger svg {
flex-shrink: 0;
width: 18px;
height: 18px;
color: var(--text);
}
.date-picker-trigger:hover,
.date-picker-trigger[aria-expanded='true'] {
anchor-name: --anchor-date-time;
}
.date-popover {
position: absolute;
position-anchor: --anchor-date-time;
left: calc(anchor(center) - 100%);
z-index: 20;
padding: 0.6rem;
background: var(--bg-raised);
border: 1px solid var(--line);
border-radius: 0.4rem;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.35);
overflow: hidden;
}
.date-popover-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.4rem;
color: var(--text);
font-size: 0.8rem;
font-weight: 500;
}
.date-popover-header button {
width: 1.5rem;
height: 1.5rem;
padding: 0;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 0.25rem;
background: transparent;
color: var(--text-muted);
font: inherit;
font-size: 0.9rem;
cursor: pointer;
}
.date-popover-header button:hover:not(:disabled) {
color: var(--text);
}
.date-popover-header button:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.date-popover-weekdays,
.date-popover-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0.1rem;
span {
width: 1.8rem;
}
button {
display: flex;
align-items: center;
justify-content: center;
}
}
.date-popover-weekdays {
margin-bottom: 0.15rem;
font-size: 0.68rem;
color: var(--text-muted);
text-align: center;
}
.date-popover-grid button {
aspect-ratio: 1;
min-width: 0;
min-height: 0;
padding: 0;
margin: 0;
align-self: auto;
border: none;
border-radius: 50%;
background: transparent;
color: var(--text);
font: inherit;
font-size: 0.78rem;
cursor: pointer;
}
.date-popover-grid button:hover:not(:disabled) {
background: var(--line);
}
.date-popover-grid button:disabled {
color: var(--text-muted);
opacity: 0.35;
cursor: not-allowed;
}
.date-popover-grid button.is-selected {
background: var(--text);
color: var(--bg);
font-weight: 600;
}
.reservation-form input,
.reservation-form select {
width: 100%;
min-width: 0;
font: inherit;
font-family: inherit;
font-size: 1rem;
padding: 0.6rem 0.7rem;
border: 1px solid var(--line);
border-radius: 0.3rem;
@@ -312,7 +501,14 @@ a {
box-shadow: 0 0 0 2px rgba(201, 161, 90, 0.25);
}
.reservation-form button {
/* `:not(.btn)` keeps this scoped to the plain submit buttons — anything already
using the `.btn` component (Abbrechen, Bearbeiten, Löschen, …) styles itself
and shouldn't be overridden here (a previous version of this rule beat
`.btn-secondary` on specificity and left its hover text unreadable).
`:not(.date-popover *):not(.date-picker-trigger)` keeps it off the
calendar's own buttons — they live inside this form too, and without this
exclusion this rule's specificity beats the calendar's button styles. */
.reservation-form button:not(.btn):not(.date-popover *):not(.date-picker-trigger) {
align-self: flex-start;
font: inherit;
font-weight: 600;
@@ -324,15 +520,26 @@ a {
cursor: pointer;
}
.reservation-form button:hover:not(:disabled) {
.reservation-form button:not(.btn):not(.date-popover *):not(.date-picker-trigger):hover:not(:disabled) {
background: var(--gold-bright);
}
.reservation-form button:disabled {
.reservation-form button:not(.btn):not(.date-popover *):not(.date-picker-trigger):disabled {
opacity: 0.6;
cursor: not-allowed;
}
.field-hint {
margin: -0.4rem 0 0;
font-size: 0.82rem;
color: var(--text-muted);
}
.field-hint a {
color: inherit;
text-decoration: underline;
}
.message {
margin: 0;
padding: 0.65rem 0.9rem;
@@ -370,7 +577,7 @@ a {
}
.contact-list .label {
color: var(--gold);
color: var(--text-muted);
min-width: 6rem;
font-size: 0.85rem;
text-transform: uppercase;
@@ -405,7 +612,7 @@ a {
}
.footer-nav a:hover {
color: var(--gold-bright);
color: var(--text);
}
/* Staff dashboard */
@@ -414,6 +621,16 @@ a {
min-height: 100vh;
}
/* Sticks the topbar and the tabs below it together as one unit — sticking them
separately would need the tabs' `top` hardcoded to the topbar's height, which
shifts whenever the topbar wraps onto two lines on narrow screens. */
.staff-nav {
position: sticky;
top: 0;
z-index: 50;
background: var(--bg);
}
.staff-topbar {
display: flex;
align-items: center;
@@ -421,6 +638,10 @@ a {
flex-wrap: wrap;
gap: 1rem;
padding: 1.25rem 1.5rem;
/* Matches `.site-header-inner`'s floor so both bars read as the same height —
this bar's own content is already taller, so the min-height is a no-op here
and never shrinks it. */
min-height: 5rem;
border-bottom: 1px solid var(--line);
}
@@ -436,6 +657,61 @@ a {
padding: 0.5rem 1rem;
}
.staff-tabs-bar {
border-bottom: 1px solid var(--line);
}
.staff-tabs {
display: flex;
gap: 1.5rem;
max-width: 68rem;
margin: 0 auto;
padding: 0 1.5rem;
}
.staff-tab {
display: inline-flex;
align-items: center;
padding: 0.9rem 0.1rem;
margin-bottom: -1px;
color: var(--text-muted);
font-size: 0.9rem;
font-weight: 500;
text-decoration: none;
border-bottom: 2px solid transparent;
}
.staff-tab:hover {
color: var(--text);
}
.staff-tab.is-active {
color: var(--gold-bright);
border-bottom-color: var(--gold-bright);
view-transition-name: staff-tab-active;
}
::view-transition-group(staff-tab-active) {
animation-duration: 0.22s;
animation-timing-function: ease;
}
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
.bar-chart-bar {
transition: none;
}
.confirm-dialog {
animation: none;
}
}
.staff-main {
padding: 3rem 1.5rem 4rem;
}
@@ -471,7 +747,7 @@ a {
.stat-value {
font-family: 'Fraunces', Georgia, serif;
font-size: 1.75rem;
color: var(--gold-bright);
color: var(--text);
}
.stat-label {
@@ -481,6 +757,184 @@ a {
color: var(--text-muted);
}
.charts-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 1.5rem;
margin-top: 1.5rem;
}
@media (max-width: 48rem) {
.charts-grid {
grid-template-columns: 1fr;
}
}
.chart-card {
padding: 1.25rem 1.25rem 1rem;
background: var(--bg-raised);
border: 1px solid var(--line);
border-radius: 0.5rem;
}
.chart-card-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.chart-title {
font-family: 'Inter', system-ui, sans-serif;
font-size: 0.9rem;
font-weight: 600;
margin: 0;
}
.chart-toggle {
display: inline-flex;
gap: 0.15rem;
padding: 0.15rem;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 0.4rem;
}
.chart-toggle button {
padding: 0.3rem 0.65rem;
border: none;
border-radius: 0.3rem;
background: transparent;
color: var(--text-muted);
font: inherit;
font-size: 0.8rem;
cursor: pointer;
}
.chart-toggle button:hover {
color: var(--text);
}
.chart-toggle button.is-active {
background: var(--gold);
color: var(--bg);
}
.bar-chart-plot {
position: relative;
height: 10rem;
}
.bar-chart-gridline {
position: absolute;
left: 0;
right: 0;
border-top: 1px solid var(--line);
}
.bar-chart-tick {
position: absolute;
left: 0;
bottom: 0;
transform: translateY(50%);
font-size: 0.7rem;
color: var(--text-muted);
}
.bar-chart-bars {
position: absolute;
inset: 0;
display: flex;
align-items: flex-end;
gap: 0.4rem;
padding-left: 1.5rem;
}
.bar-chart-col {
position: relative;
flex: 1 1 0;
height: 100%;
min-width: 0;
display: flex;
align-items: flex-end;
justify-content: center;
outline: none;
}
.bar-chart-bar {
position: relative;
width: 100%;
max-width: 1.5rem;
margin-top: auto;
min-height: 2px;
background: var(--gold);
border-radius: 4px 4px 0 0;
transition: background-color 0.15s ease, height 0.2s ease;
}
.bar-chart-col:hover .bar-chart-bar,
.bar-chart-col:focus-visible .bar-chart-bar {
background: var(--gold-bright);
}
.bar-chart-col:focus-visible .bar-chart-bar {
outline: 2px solid var(--gold-bright);
outline-offset: 2px;
}
.bar-chart-value {
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
margin-bottom: 0.3rem;
font-size: 0.75rem;
color: var(--text-muted);
white-space: nowrap;
}
.bar-chart-tooltip {
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%) translateY(-0.4rem);
margin-bottom: 0.5rem;
padding: 0.4rem 0.6rem;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 0.35rem;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.35);
color: var(--text);
font-size: 0.75rem;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 0.12s ease;
z-index: 1;
}
.bar-chart-col:hover .bar-chart-tooltip,
.bar-chart-col:focus-visible .bar-chart-tooltip {
opacity: 1;
}
.bar-chart-axis {
display: flex;
gap: 0.4rem;
padding-left: 1.5rem;
margin-top: 0.5rem;
}
.bar-chart-axis-label {
flex: 1 1 0;
min-width: 0;
text-align: center;
font-size: 0.7rem;
color: var(--text-muted);
}
.staff-form-section {
max-width: 36rem;
}
@@ -500,6 +954,36 @@ a {
margin-top: 2.5rem;
}
.table-filters {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.table-filters input,
.table-filters select {
font-family: inherit;
font-size: 0.9rem;
padding: 0.55rem 0.7rem;
border: 1px solid var(--line);
border-radius: 0.3rem;
background: var(--bg);
color: var(--text);
}
.table-filters input:focus,
.table-filters select:focus {
outline: none;
border-color: var(--gold);
box-shadow: 0 0 0 2px rgba(201, 161, 90, 0.25);
}
.table-search {
flex: 1 1 16rem;
min-width: 0;
}
.table-scroll {
overflow-x: auto;
border: 1px solid var(--line);
@@ -521,7 +1005,7 @@ a {
}
.reservations-table th {
color: var(--gold);
color: var(--text-muted);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
@@ -533,7 +1017,7 @@ a {
}
.reservations-table tr.is-editing {
background: rgba(201, 161, 90, 0.08);
background: rgba(243, 234, 217, 0.06);
}
.reservations-table .muted {
@@ -567,6 +1051,60 @@ a {
cursor: not-allowed;
}
.confirm-dialog-backdrop {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
background: rgba(0, 0, 0, 0.6);
}
.confirm-dialog {
width: 100%;
max-width: 24rem;
padding: 1.5rem;
background: var(--bg-raised);
border: 1px solid var(--line);
border-radius: 0.5rem;
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.45);
animation: confirm-dialog-fade-in 0.15s ease-out;
}
@keyframes confirm-dialog-fade-in {
from {
opacity: 0;
transform: translateY(-8px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.confirm-dialog-title {
font-size: 1.15rem;
margin: 0 0 0.75rem;
}
.confirm-dialog-message {
margin: 0 0 1.5rem;
color: var(--text-muted);
}
.confirm-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.confirm-dialog-actions .btn {
padding: 0.6rem 1.25rem;
font-size: 0.9rem;
}
.empty-state {
color: var(--text-muted);
font-style: italic;
@@ -593,6 +1131,28 @@ a {
font-size: 1.5rem;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin-bottom: 1.75rem;
color: var(--text-muted);
font-size: 0.85rem;
text-decoration: none;
}
.back-link svg {
transition: transform 0.15s ease;
}
.back-link:hover {
color: var(--text);
}
.back-link:hover svg {
transform: translateX(-3px);
}
/* Legal page */
.legal-page {
@@ -606,7 +1166,7 @@ a {
.legal-page h2 {
font-size: 1.25rem;
color: var(--gold-bright);
color: var(--text);
margin: 2.25rem 0 0.75rem;
}