50 Commits

Author SHA1 Message Date
63384aa209 Fix: TypeScript-Fehler für Multi-Treatment-Migration beheben
- admin-calendar.tsx: getTreatmentNames für treatments[] angepasst
- admin-calendar.tsx: getAvailableTimes für treatmentIds[] umgestellt
- admin-calendar.tsx: createManualBooking sendet treatments[] statt treatmentId
- cancellation.ts: treatmentId optional behandeln (Rückwärtskompatibilität)
- review-submission-page.tsx: treatmentName durch treatments[] ersetzt
- booking-status-page.tsx: proposed date/time als optional markiert

Docker-Build erfolgreich getestet.
2025-10-08 19:57:10 +02:00
ebd9d8a72e Refactor: Verbessere CalDAV und Bookings für Multi-Treatment-Support
- CalDAV SUMMARY zeigt jetzt alle Treatment-Namen als Liste statt Anzahl
- Treatments-Array im Booking-Type optional für Rückwärtskompatibilität
- Neue addMinutesToTime Helper-Funktion für saubere DTEND-Berechnung
- getTreatmentNames filtert leere Namen und liefert sicheren Fallback
2025-10-08 19:50:16 +02:00
ccba9d443b Fix ICS calendar issues and improve email template code quality
- Fix ATTENDEE mailto to use customerEmail instead of customerName
- Add icsEscape helper for proper RFC 5545 text escaping
- Calculate ICS duration from treatments array instead of separate param
- Add renderTreatmentList helper to reduce code duplication in email templates
2025-10-08 18:45:34 +02:00
9583148e02 Fix: Korrigiere Konflikt-Erkennung für Multi-Treatment-Buchungen in recurring-availability.ts - Berechne Dauer korrekt für neue Behandlungen-Arrays - Filtere undefined Treatment-IDs aus Legacy-Cache - Erstelle Treatment-Cache nur bei Bedarf 2025-10-08 18:26:09 +02:00
d153aad8b3 Refactor booking-form: Add compatibility fallback, functional state updates, memoized calculations, and treatment reconciliation 2025-10-08 18:17:59 +02:00
94e269697a Config: Caddy Log-Level auf INFO setzen
Unterdrückt WARN-Meldungen für normale Live-Subscription disconnects
2025-10-08 12:57:41 +02:00
ad79531f33 Release v0.1.4
Features:
- Admin kann Nachrichten an Kunden senden
- Email-System mit BCC an Admin
- UI: Nachricht-Button und Modal
- HTML-Escaping für sichere Nachrichtenanzeige
- Detailliertes Logging
2025-10-08 11:24:41 +02:00
db1a401230 Build: Update server-dist artifacts for v0.1.4 2025-10-08 11:24:03 +02:00
cceb4d4e60 Feature: Admin kann Nachrichten an Kunden senden
- Neues Email-Template für Kundennachrichten
- RPC-Funktion sendCustomerMessage für zukünftige Termine
- UI: Nachricht-Button und Modal in Admin-Buchungen
- Email mit BCC an Admin für Monitoring
- HTML-Escaping für sichere Nachrichtenanzeige
- Detailliertes Logging für Debugging
2025-10-08 11:13:59 +02:00
ca20516080 v0.1.3 2025-10-07 14:15:17 +02:00
f2963ca951 Android PWA-Installationshinweis mit direktem Install-Button hinzugefügt 2025-10-07 13:57:28 +02:00
8aea5bb400 Verbesserungen für PWA-Installations-Prompt: Mobile-Menü-Überlappung behoben, iOS safe-area Unterstützung, localStorage-Fehlerbehandlung und erweiterte standalone-Erkennung 2025-10-07 13:41:03 +02:00
14d0c2f9c3 Add PWA manifest and apple-touch-icon meta tag 2025-10-07 13:12:31 +02:00
eb9ddc535f Add static serving for /icons/* and manifest.json 2025-10-07 12:58:36 +02:00
8fa17f58c9 Add PWA icons (192x192, 512x512, apple-touch-icon) 2025-10-07 12:46:17 +02:00
92ed7a2c93 feat: Firmenname aus .env in E-Mail-Titeln anzeigen
- COMPANY_NAME wird aus .env gelesen
- Wird in separater Zeile über dem eigentlichen Titel angezeigt
- Format: Firmenname (grau, kleiner) + Titel (pink, größer)
- Fallback auf 'Stargirlnails Kiel' wenn nicht gesetzt
2025-10-07 10:50:50 +02:00
ce644c31e1 feat: Offizielle Social-Media-Icons in E-Mails
- Ersetze Emojis (📷 🎵) durch offizielle SVG-Icons
- Gleiche Icons wie auf der Startseite (Instagram & TikTok)
- Inline SVG für bessere Darstellung in E-Mail-Clients
- Icons sind 16x16px mit margin-right für besseren Abstand
2025-10-07 10:44:30 +02:00
3b67c26216 feat: Altersbestätigung (16+) im Buchungsformular
- Neue Pflicht-Checkbox: Mindestalter 16 Jahre
- Direkt unter der AGB-Checkbox platziert
- Validierung beim Absenden des Formulars
- Wird nach erfolgreicher Buchung zurückgesetzt
2025-10-07 10:19:43 +02:00
f2fed22ea1 fix: AGB.pdf Download funktioniert jetzt
- Route für /AGB.pdf hinzugefügt (serveStatic)
- AGB.pdf wird aus dem public-Verzeichnis bereitgestellt
- Behebt Problem, dass Link zur Startseite führte
2025-10-07 10:17:59 +02:00
ab5e5e67a6 feat: Login-Formular merkt sich Benutzername
- Benutzername wird in localStorage gespeichert
- Beim nächsten Login automatisch ausgefüllt
- Verbessert UX für wiederkehrende Logins
2025-10-07 10:14:15 +02:00
78a379546c feat: Buchungsformular merkt sich Benutzerdaten
- Name, E-Mail und Telefonnummer werden in localStorage gespeichert
- Beim nächsten Besuch werden die Felder automatisch ausgefüllt
- Verbessert die User Experience für wiederkehrende Buchungen
2025-10-07 10:13:06 +02:00
953a970220 fix: Caddy-Timeouts für Live-Queries deaktiviert
- read_timeout und write_timeout auf 0 gesetzt für unbegrenzte SSE-Verbindungen
- flush_interval -1 für sofortiges Flushen von Streaming-Daten
- Behebt 'context canceled' Fehler bei /rpc/recurringAvailability/live/listRules
2025-10-07 09:47:26 +02:00
c7d9fc689e style: Button 'Termin buchen' dunkler für besseren Kontrast (#790dc6) 2025-10-07 09:40:59 +02:00
f4593cd706 feat: Social-Media-Badges für TikTok und Instagram hinzugefügt
- Neue RPC-Route für Social-Media-URLs aus .env (social.ts)
- Social-Media-Badges auf der Startseite mit attraktiven Buttons
- Social-Media-Icons im Footer aller Seiten
- Social-Media-Links in allen E-Mail-Templates
- URLs aus .env: TIKTOK_PROFILE und INSTAGRAM_PROFILE
2025-10-07 09:32:06 +02:00
fbfdceeee6 feat: CalDAV-Integration für Admin-Kalender
- Neue CalDAV-Route mit PROPFIND und GET-Endpoints
- ICS-Format-Generator für Buchungsdaten
- Token-basierte Authentifizierung für CalDAV-Zugriff
- Admin-Interface mit CalDAV-Link-Generator
- Schritt-für-Schritt-Anleitung für Kalender-Apps
- 24h-Token-Ablaufzeit für Sicherheit
- Unterstützung für Outlook, Google Calendar, Apple Calendar, Thunderbird

Fixes: Admin kann jetzt Terminkalender in externen Apps abonnieren
2025-10-06 12:41:50 +02:00
244eeee142 Prod: rebuild script Healthcheck ohne jq (docker inspect fallback) 2025-10-05 20:21:31 +02:00
9c2e47ef9a Prod: rebuild script verbessert (down --remove-orphans, pull, healthcheck, Logs) 2025-10-05 20:17:52 +02:00
27a106de13 CSP: connect-src um data: und blob: erweitert (Fix für DataURL-Fetch bei Bildkompression) 2025-10-05 20:15:58 +02:00
83a3a6a19f CSP: img-src um blob: erweitert (Fix für blob: Previews auf Prod) 2025-10-05 20:13:20 +02:00
53aca01131 Email: Review-Link auf /review/:token umgestellt; Token-Erzeugung konsolidiert. Reviews: Client-Validation hinzugefügt. Verfügbarkeiten: Auto-Update nach Regelanlage. Galerie: Cover-Foto-Flag + Setzen im Admin, sofortige Aktualisierung nach Upload/Löschen/Reihenfolge-Änderung. Startseite: Featured-Foto = Reihenfolge 0, Seitenverhältnis beibehalten, Texte aktualisiert. 2025-10-05 20:09:12 +02:00
6d7e8eceba Entferne Slots-Tab und Slot-RPCs; bereinige recurring-availability; Texte angepasst 2025-10-05 17:21:56 +02:00
6cf657168b Fix TypeScript errors for Docker build
- Fix optional chaining for booking properties
- Fix useMutation isLoading to isPending
- Fix email parameter types
- Fix expiredDetails array typing
2025-10-05 16:28:28 +02:00
a8cec16d7a Fix reschedule token handling and improve admin notifications
- Fix getBookingByToken to only accept booking_access tokens
- Add sweepExpiredRescheduleProposals with admin notifications
- Return isExpired flag instead of throwing errors for expired proposals
- Fix email template to use actual token expiry time
- Remove duplicate admin emails in acceptReschedule
- Add one-click accept/decline support via URL parameters
2025-10-05 16:11:37 +02:00
97c1d3493f Verbessere Booking-Form UX: Reset selectedTime bei Treatment-Wechsel, bessere Loading-States und lokale Datumsvalidierung 2025-10-04 18:09:46 +02:00
3a13c8dffb Fix: Change email CC to BCC for admin notifications
- Change confirmation emails from CC to BCC for admin notifications
- Change cancellation emails from CC to BCC for admin notifications
- Improve privacy: customers no longer see admin email address
- Admin still receives copies of all customer communications
- Maintain GDPR compliance and data protection
2025-10-02 16:18:48 +02:00
6f6b21e7c8 Fix: Allow OpenStreetMap iframe in Content Security Policy
- Add frame-src directive to CSP for OpenStreetMap.org
- Fix Impressum map display issue in production
- Allow embedding of OpenStreetMap iframes while maintaining security
- Update Caddyfile CSP configuration
2025-10-02 15:45:01 +02:00
d7b1ae3525 Fix: Improve booking status page error handling
- Add better error messages for invalid/expired booking tokens
- Replace generic 'Internal server error' with user-friendly explanations
- List possible reasons why booking links might not work
- Add clear call-to-action for new bookings
- Improve user experience with helpful guidance
2025-10-02 15:38:22 +02:00
6502f0d416 Fix: Cancel button functionality and live updates in booking management
- Add confirmation modal for booking cancellations
- Implement proper error handling and success messages
- Fix live updates for booking status changes
- Add manual refetch to ensure immediate UI updates
- Auto-delete past availability slots on list access
- Add manual cleanup function for past slots
- Improve user experience with instant feedback
2025-10-02 14:27:24 +02:00
0b4e7e725f Fix: Live updates in availability management
- Add manual refetch calls after slot creation/deletion
- Ensure availability list updates immediately after changes
- Fix issue where slots didn't appear in list after adding
- Improve user experience with real-time updates
2025-10-02 13:55:24 +02:00
938ee76e32 Add explicit DISABLE_DUPLICATE_CHECK=false to production config
- Ensure consistent environment variable handling between dev and prod
- Explicitly disable duplicate check bypass in production environment
- Maintain security by preventing multiple bookings per email in production
2025-10-02 13:40:32 +02:00
5baa231d3c Fix: Slot reservation only after successful email validation
- Move email validation before slot reservation in backend
- Remove duplicate frontend email validation
- Slots are no longer blocked by failed booking attempts
- Clean up unused email error UI components
- Ensure slots remain available if email validation fails
2025-10-02 13:39:13 +02:00
73cf733c5f Fix E-Mail-Versand und verbessere Fehlerbehandlung
- Behebe Port-Konfiguration für interne RPC-Verbindungen (5173 -> 3000)
- Verbessere oRPC-Fehlerbehandlung: ursprüngliche Fehlermeldungen werden beibehalten
- Erweitere Frontend-Fehlerbehandlung für bessere oRPC-Integration
- Deaktiviere Duplikat-Prüfung in Development-Modus (NODE_ENV=development)
- Lokale Entwicklung ermöglicht mehrere Buchungen pro E-Mail-Adresse
- Produktion behält Duplikat-Schutz bei
2025-10-02 10:01:01 +02:00
f2e12df6d5 Add rebuild script for Windows 2025-10-02 09:28:01 +02:00
d663abb1ab Add restart script 2025-10-02 08:52:11 +02:00
c0b0edc00e Fix: Improve RPC error handling and routing
- Changed from rpcApp.use to rpcApp.all for better route handling
- Added proper error handling with try-catch
- Return 404 for unmatched routes instead of calling next()
- Return 500 for internal server errors
- Improves RPC endpoint reliability and debugging
2025-10-02 01:09:46 +02:00
9a104e8862 Optimize: Improve table column widths and text truncation
- Added table-fixed layout for consistent column widths
- Set specific column widths: Behandlung (2/5), Kategorie (1/6), Dauer (1/12), Preis (1/12), Aktionen (1/6)
- Truncate long descriptions to 50 characters with tooltip
- Added truncate class to prevent text overflow
- Ensures all columns are always visible without horizontal scrolling
2025-10-02 01:03:28 +02:00
84fc9ee890 Fix: Enable horizontal scrolling for treatments table
- Changed overflow-hidden to overflow-x-auto
- Fixes missing Edit/Delete buttons in treatments table
- Allows horizontal scrolling when table is too wide
- Resolves CSS layout issue in production
2025-10-02 00:58:42 +02:00
277be954b7 Fix: Remove duplicate /assets/ prefix from manifest paths
- Manifest already includes 'assets/' prefix
- Fixes double /assets/assets/ paths in production
- Ensures correct asset loading
2025-10-02 00:51:52 +02:00
65a0b8c823 Fix: Correct Vite manifest key lookup
- Changed from 'src/client/main.tsx' to 'index.html' to match actual manifest
- Fixes production asset loading
- Resolves empty page issue in production mode
2025-10-02 00:49:53 +02:00
1285560f62 Fix: Add manifest: true to Vite build config
- Enables Vite manifest generation for production builds
- Fixes CSS loading issues in production
- Resolves missing buttons in admin treatments page
- Ensures proper asset path resolution
2025-10-02 00:40:35 +02:00
71 changed files with 9963 additions and 885 deletions

View File

@@ -10,7 +10,9 @@ RESEND_API_KEY=your_resend_api_key_here
EMAIL_FROM=noreply@yourdomain.com EMAIL_FROM=noreply@yourdomain.com
ADMIN_EMAIL=admin@yourdomain.com ADMIN_EMAIL=admin@yourdomain.com
# Frontend URL (for E-Mail Links) # Social media profiles
TIKTOK_PROFILE=https://www.tiktok.com/@<dein Tiktok Profil>
INSTAGRAM_PROFILE=https://www.instagram.com/<dein Instragram Profil>
# Cancellation Policy (in hours) # Cancellation Policy (in hours)
MIN_STORNO_TIMESPAN=24 MIN_STORNO_TIMESPAN=24

1
.gitignore vendored
View File

@@ -22,6 +22,7 @@ Thumbs.db
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
.notes.txt
# Turbo # Turbo
.turbo .turbo

View File

@@ -8,6 +8,16 @@ stargirlnails.de {
health_uri /health health_uri /health
health_interval 30s health_interval 30s
health_timeout 5s health_timeout 5s
# Timeouts für lange laufende Verbindungen (Live-Queries)
transport http {
read_timeout 0
write_timeout 0
dial_timeout 30s
}
# Buffer-Flush für Server-Sent Events (SSE) aktivieren
flush_interval -1
} }
# Sicherheits-Header # Sicherheits-Header
@@ -17,7 +27,7 @@ stargirlnails.de {
X-Content-Type-Options "nosniff" X-Content-Type-Options "nosniff"
X-XSS-Protection "1; mode=block" X-XSS-Protection "1; mode=block"
Referrer-Policy "strict-origin-when-cross-origin" Referrer-Policy "strict-origin-when-cross-origin"
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self';" Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; connect-src 'self' data: blob:; frame-src 'self' https://www.openstreetmap.org;"
# HSTS (wird automatisch von Caddy gesetzt) # HSTS (wird automatisch von Caddy gesetzt)
Strict-Transport-Security "max-age=31536000; includeSubDomains" Strict-Transport-Security "max-age=31536000; includeSubDomains"
@@ -30,6 +40,7 @@ stargirlnails.de {
log { log {
output file /var/log/caddy/access.log output file /var/log/caddy/access.log
format json format json
level INFO
} }
# Favicon-Konfiguration (innerhalb der Hauptdomain) # Favicon-Konfiguration (innerhalb der Hauptdomain)

View File

@@ -10,6 +10,7 @@ services:
- .env - .env
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- DISABLE_DUPLICATE_CHECK=false
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- storage-data:/app/.storage - storage-data:/app/.storage

View File

@@ -16,3 +16,4 @@ services:
start_period: 40s start_period: 40s
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- DISABLE_DUPLICATE_CHECK=true

View File

@@ -4,7 +4,9 @@
- ~~ICS-Anhang/Link in EMails (Kalendereintrag)~~ - ~~ICS-Anhang/Link in EMails (Kalendereintrag)~~
- Erinnerungsmails (24h/3h vor Termin) - Erinnerungsmails (24h/3h vor Termin)
- ~~Umbuchen/Stornieren per sicherem Kundenlink (Token)~~ - ~~Umbuchen/Stornieren per sicherem Kundenlink (Token)~~
- Pufferzeiten und Sperrtage/Feiertage konfigurierbar - ~~Wiederkehrende Verfügbarkeitsregeln (z.B. "Montags 13-18 Uhr")~~
- ~~Urlaubszeiten/Blockierungen konfigurierbar~~
- Pufferzeiten zwischen Terminen konfigurierbar
- ~~Slots dynamisch an Behandlungsdauer anpassen; Überschneidungen verhindern~~ - ~~Slots dynamisch an Behandlungsdauer anpassen; Überschneidungen verhindern~~
### Sicherheit & Qualität ### Sicherheit & Qualität

View File

@@ -3,7 +3,13 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/favicon.png" /> <link rel="icon" type="image/png" href="/favicon.png" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.json" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#ec4899" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Stargirlnails" />
<title>Stargirlnails Kiel - Terminbuchung</title> <title>Stargirlnails Kiel - Terminbuchung</title>
</head> </head>
<body> <body>

View File

@@ -1,7 +1,7 @@
{ {
"name": "quests-template-basic", "name": "quests-template-basic",
"private": true, "private": true,
"version": "0.0.0", "version": "0.1.4",
"type": "module", "type": "module",
"scripts": { "scripts": {
"check:types": "tsc --noEmit", "check:types": "tsc --noEmit",

45
public/icons/README.md Normal file
View File

@@ -0,0 +1,45 @@
# PWA Icons Required
This directory must contain the following icon files for proper PWA installation on iOS and Android devices:
## Required Icon Files
### Android Icons
- **icon-192x192.png** (192×192 pixels)
- Used for Android home screen and app drawer
- Should have transparent background or match theme color
- Include safe zone padding for maskable icons (40px margin)
- **icon-512x512.png** (512×512 pixels)
- Used for Android splash screens and high-resolution displays
- Should have transparent background or match theme color
- Include safe zone padding for maskable icons (102px margin)
### iOS Icon
- **apple-touch-icon.png** (180×180 pixels)
- Used for iOS home screen
- Should NOT have transparent background (iOS adds its own rounded corners)
- Fill entire canvas with brand colors/logo
- iOS automatically applies rounded corners and shadow
## Design Guidelines
1. **Brand consistency**: Use Stargirlnails logo and brand colors
2. **Theme color**: Primary pink (#ec4899) matches manifest theme_color
3. **Contrast**: Ensure icon is visible on various backgrounds
4. **Simplicity**: Icons should be recognizable at small sizes
5. **No text**: Avoid small text that becomes unreadable when scaled
## Testing
After adding icons:
- Test on Android: Check home screen icon appearance
- Test on iOS Safari: Add to home screen and verify icon quality
- Validate with Lighthouse PWA audit
## Placeholder
Until actual icons are created, you can use a favicon.png (if available) or generate placeholder icons using tools like:
- https://realfavicongenerator.net/
- https://www.pwabuilder.com/imageGenerator

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

32
public/manifest.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "Stargirlnails Kiel - Terminbuchung",
"short_name": "Stargirlnails",
"description": "Online Terminbuchung für Nagelstudio Stargirlnails in Kiel",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#ec4899",
"orientation": "portrait",
"lang": "de",
"icons": [
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/apple-touch-icon.png",
"sizes": "180x180",
"type": "image/png"
}
]
}

6
scripts/rebuild-dev.cmd Normal file
View File

@@ -0,0 +1,6 @@
@echo off
docker compose -f docker-compose.yml down
git pull
docker compose -f docker-compose.yml build
docker compose -f docker-compose.yml up -d
docker compose -f docker-compose.yml logs -f stargirlnails

5
scripts/rebuild-dev.sh Executable file
View File

@@ -0,0 +1,5 @@
#! /bin/bash
docker compose -f docker-compose.yml down
docker compose -f docker-compose.yml build --no-cache
docker compose -f docker-compose.yml up -d
# docker compose -f docker-compose.yml logs -f stargirlnails

View File

@@ -1,6 +1,49 @@
#! /bin/bash #! /bin/bash
sudo docker compose -f docker-compose-prod.yml down set -euo pipefail
git pull
sudo docker compose -f docker-compose-prod.yml build --no-cache # Usage: ./scripts/rebuild-prod.sh [branch]
sudo docker compose -f docker-compose-prod.yml up -d # Default branch is current; pass a branch to checkout before pulling/building.
sudo docker compose -f docker-compose-prod.yml logs -f stargirlnails
COMPOSE_FILE=docker-compose-prod.yml
echo "[1/7] Git: Fetch & pull latest changes"
if [ "${1-}" != "" ]; then
git fetch origin "$1"
git checkout "$1"
fi
git pull --rebase
echo "[2/7] Stop and remove running services (including orphans)"
sudo docker compose -f "$COMPOSE_FILE" down --remove-orphans || true
echo "[3/7] Pull base images (e.g., caddy)"
sudo docker compose -f "$COMPOSE_FILE" pull || true
echo "[4/7] Build application image without cache"
sudo docker compose -f "$COMPOSE_FILE" build --no-cache
echo "[5/7] Start services in background"
sudo docker compose -f "$COMPOSE_FILE" up -d
echo "[6/7] Wait for app healthcheck to pass"
# Wait up to ~90s for healthy status using docker inspect (no jq dependency)
for i in {1..18}; do
# Check health status if available
HEALTH=$(sudo docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' stargirlnails-app 2>/dev/null || true)
if [ "$HEALTH" = "healthy" ]; then
echo "Service is healthy."
break
fi
# Fallback: ensure container is running
STATE=$(sudo docker inspect -f '{{.State.Status}}' stargirlnails-app 2>/dev/null || true)
if [ "$STATE" = "running" ] && [ -z "$HEALTH" ]; then
echo "Service is running (no healthcheck reported)."
break
fi
sleep 5
done
echo "[7/7] Tail recent logs (press Ctrl+C to exit)"
sudo docker compose -f "$COMPOSE_FILE" logs --since=10m -f

74
server-dist/index.js Normal file
View File

@@ -0,0 +1,74 @@
import { Hono } from "hono";
import { serve } from '@hono/node-server';
import { serveStatic } from '@hono/node-server/serve-static';
import { rpcApp } from "./routes/rpc.js";
import { caldavApp } from "./routes/caldav.js";
import { clientEntry } from "./routes/client-entry.js";
const app = new Hono();
// Allow all hosts for Tailscale Funnel
app.use("*", async (c, next) => {
// Accept requests from any host
return next();
});
// Health check endpoint
app.get("/health", (c) => {
return c.json({ status: "ok", timestamp: new Date().toISOString() });
});
// Legal config endpoint (temporary fix for RPC issue)
app.get("/api/legal-config", async (c) => {
try {
const { getLegalConfig } = await import("./lib/legal-config.js");
const config = getLegalConfig();
return c.json(config);
}
catch (error) {
console.error("Legal config error:", error);
return c.json({ error: "Failed to load legal config" }, 500);
}
});
// Security.txt endpoint (RFC 9116)
app.get("/.well-known/security.txt", (c) => {
const securityContact = process.env.SECURITY_CONTACT || "security@example.com";
const securityText = `Contact: ${securityContact}
Expires: 2025-12-31T23:59:59.000Z
Preferred-Languages: de, en
Canonical: https://${process.env.DOMAIN || 'localhost:5173'}/.well-known/security.txt
# Security Policy
# Please report security vulnerabilities responsibly by contacting us via email.
# We will respond to security reports within 48 hours.
#
# Scope: This security policy applies to the Stargirlnails booking system.
#
# Rewards: We appreciate security researchers who help us improve our security.
# While we don't have a formal bug bounty program, we may offer recognition
# for significant security improvements.
`;
return c.text(securityText, 200, {
"Content-Type": "text/plain; charset=utf-8",
"Cache-Control": "public, max-age=86400", // Cache for 24 hours
});
});
// Serve static files (only in production)
if (process.env.NODE_ENV === 'production') {
app.use('/static/*', serveStatic({ root: './dist' }));
app.use('/assets/*', serveStatic({ root: './dist' }));
}
app.use('/favicon.png', serveStatic({ path: './public/favicon.png' }));
app.use('/AGB.pdf', serveStatic({ path: './public/AGB.pdf' }));
app.use('/icons/*', serveStatic({ root: './public' }));
app.use('/manifest.json', serveStatic({ path: './public/manifest.json' }));
app.route("/rpc", rpcApp);
app.route("/caldav", caldavApp);
app.get("/*", clientEntry);
// Start server
const port = process.env.PORT ? parseInt(process.env.PORT) : 3000;
const host = process.env.HOST || "0.0.0.0";
console.log(`🚀 Server starting on ${host}:${port}`);
// Start the server
serve({
fetch: app.fetch,
port,
hostname: host,
});
export default app;

13
server-dist/lib/auth.js Normal file
View File

@@ -0,0 +1,13 @@
import { createKV } from "./create-kv.js";
export const sessionsKV = createKV("sessions");
export const usersKV = createKV("users");
export async function assertOwner(sessionId) {
const session = await sessionsKV.getItem(sessionId);
if (!session)
throw new Error("Invalid session");
if (new Date(session.expiresAt) < new Date())
throw new Error("Session expired");
const user = await usersKV.getItem(session.userId);
if (!user || user.role !== "owner")
throw new Error("Forbidden");
}

View File

@@ -0,0 +1,33 @@
import { createStorage } from "unstorage";
import fsDriver from "unstorage/drivers/fs";
const STORAGE_PATH = "./.storage"; // It is .gitignored
export function createKV(name) {
const storage = createStorage({
driver: fsDriver({ base: `${STORAGE_PATH}/${name}` }),
});
// Async generator to play work well with oRPC live queries
async function* subscribe() {
let resolve;
let promise = new Promise((r) => (resolve = r));
const unwatch = await storage.watch((event, key) => {
resolve({ event, key });
promise = new Promise((r) => (resolve = r));
});
try {
while (true)
yield await promise;
}
finally {
await unwatch();
}
}
return {
...storage,
getAllItems: async () => {
const keys = await storage.getKeys();
const values = await storage.getItems(keys);
return values.map(({ value }) => value);
},
subscribe,
};
}

View File

@@ -0,0 +1,317 @@
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
// Helper function to convert date from yyyy-mm-dd to dd.mm.yyyy
function formatDateGerman(dateString) {
const [year, month, day] = dateString.split('-');
return `${day}.${month}.${year}`;
}
let cachedLogoDataUrl = null;
async function getLogoDataUrl() {
if (cachedLogoDataUrl)
return cachedLogoDataUrl;
try {
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const logoPath = resolve(__dirname, "../../../assets/stargilnails_logo_transparent.png");
const buf = await readFile(logoPath);
const base64 = buf.toString("base64");
cachedLogoDataUrl = `data:image/png;base64,${base64}`;
return cachedLogoDataUrl;
}
catch {
return null;
}
}
async function renderBrandedEmail(title, bodyHtml) {
const logo = await getLogoDataUrl();
const domain = process.env.DOMAIN || 'localhost:5173';
const protocol = domain.includes('localhost') ? 'http' : 'https';
const homepageUrl = `${protocol}://${domain}`;
const instagramProfile = process.env.INSTAGRAM_PROFILE;
const tiktokProfile = process.env.TIKTOK_PROFILE;
const companyName = process.env.COMPANY_NAME || 'Stargirlnails Kiel';
return `
<div style="font-family: Arial, sans-serif; color: #0f172a; background:#fdf2f8; padding:24px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:640px; margin:0 auto; background:#ffffff; border-radius:12px; overflow:hidden; box-shadow:0 1px 3px rgba(0,0,0,0.06)">
<tr>
<td style="padding:24px 24px 0 24px; text-align:center;">
${logo ? `<img src="${logo}" alt="${companyName}" style="width:120px; height:auto; display:inline-block;" />` : `<div style=\"font-size:24px\">💅</div>`}
<div style="margin:16px 0 4px 0; font-size:16px; font-weight:600; color:#64748b;">${companyName}</div>
<h1 style="margin:0; font-size:22px; color:#db2777;">${title}</h1>
</td>
</tr>
<tr>
<td style="padding:16px 24px 24px 24px;">
<div style="font-size:16px; line-height:1.6; color:#334155;">
${bodyHtml}
</div>
<hr style="border:none; border-top:1px solid #f1f5f9; margin:24px 0" />
<div style="text-align:center; margin-bottom:16px;">
<a href="${homepageUrl}" style="display: inline-block; background-color: #db2777; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 14px;">Zur Website</a>
</div>
${(instagramProfile || tiktokProfile) ? `
<div style="text-align:center; margin-bottom:16px;">
<p style="font-size:14px; color:#64748b; margin:0 0 8px 0;">Folge uns auf Social Media:</p>
<div style="display:inline-block;">
${instagramProfile ? `
<a href="${instagramProfile}" target="_blank" rel="noopener noreferrer" style="display:inline-block; margin:0 6px; background:linear-gradient(45deg, #f09433 0%,#e6683c 25%,#dc2743 50%,#cc2366 75%,#bc1888 100%); color:white; padding:10px 20px; text-decoration:none; border-radius:20px; font-size:14px; font-weight:600;">
<svg width="16" height="16" fill="currentColor" viewBox="0 0 24 24" style="vertical-align:middle; margin-right:6px;">
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/>
</svg>
Instagram
</a>
` : ''}
${tiktokProfile ? `
<a href="${tiktokProfile}" target="_blank" rel="noopener noreferrer" style="display:inline-block; margin:0 6px; background:#000000; color:white; padding:10px 20px; text-decoration:none; border-radius:20px; font-size:14px; font-weight:600;">
<svg width="16" height="16" fill="currentColor" viewBox="0 0 24 24" style="vertical-align:middle; margin-right:6px;">
<path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1-.1z"/>
</svg>
TikTok
</a>
` : ''}
</div>
</div>
` : ''}
<div style="font-size:12px; color:#64748b; text-align:center;">
&copy; ${new Date().getFullYear()} Stargirlnails Kiel • Professional Nail Care
</div>
</td>
</tr>
</table>
</div>`;
}
export async function renderBookingPendingHTML(params) {
const { name, date, time, statusUrl } = params;
const formattedDate = formatDateGerman(date);
const domain = process.env.DOMAIN || 'localhost:5173';
const protocol = domain.includes('localhost') ? 'http' : 'https';
const legalUrl = `${protocol}://${domain}/legal`;
const inner = `
<p>Hallo ${name},</p>
<p>wir haben deine Anfrage für <strong>${formattedDate}</strong> um <strong>${time}</strong> erhalten.</p>
<p>Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.</p>
${statusUrl ? `
<div style="background-color: #fef9f5; border-left: 4px solid #f59e0b; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #f59e0b;">⏳ Termin-Status ansehen:</p>
<p style="margin: 8px 0 12px 0; color: #475569;">Du kannst den aktuellen Status deiner Buchung jederzeit einsehen:</p>
<a href="${statusUrl}" style="display: inline-block; background-color: #f59e0b; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">Status ansehen</a>
</div>
` : ''}
<div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #3b82f6;">📋 Rechtliche Informationen:</p>
<p style="margin: 8px 0 12px 0; color: #475569;">Weitere Informationen findest du in unserem <a href="${legalUrl}" style="color: #3b82f6; text-decoration: underline;">Impressum und Datenschutz</a>.</p>
</div>
<p>Liebe Grüße,<br/>Stargirlnails Kiel</p>
`;
return renderBrandedEmail("Deine Terminanfrage ist eingegangen", inner);
}
export async function renderBookingConfirmedHTML(params) {
const { name, date, time, cancellationUrl, reviewUrl } = params;
const formattedDate = formatDateGerman(date);
const domain = process.env.DOMAIN || 'localhost:5173';
const protocol = domain.includes('localhost') ? 'http' : 'https';
const legalUrl = `${protocol}://${domain}/legal`;
const inner = `
<p>Hallo ${name},</p>
<p>wir haben deinen Termin am <strong>${formattedDate}</strong> um <strong>${time}</strong> bestätigt.</p>
<p>Wir freuen uns auf dich!</p>
<div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #db2777;">📋 Wichtiger Hinweis:</p>
<p style="margin: 8px 0 0 0; color: #475569;">Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.</p>
</div>
${cancellationUrl ? `
<div style="background-color: #fef9f5; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #db2777;">📅 Termin verwalten:</p>
<p style="margin: 8px 0 12px 0; color: #475569;">Du kannst deinen Termin-Status einsehen und bei Bedarf stornieren:</p>
<a href="${cancellationUrl}" style="display: inline-block; background-color: #db2777; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">Termin ansehen & verwalten</a>
</div>
` : ''}
${reviewUrl ? `
<div style="background-color: #eff6ff; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #3b82f6;">⭐ Bewertung abgeben:</p>
<p style="margin: 8px 0 12px 0; color: #475569;">Nach deinem Termin würden wir uns über deine Bewertung freuen!</p>
<a href="${reviewUrl}" style="display: inline-block; background-color: #3b82f6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">Bewertung schreiben</a>
<p style="margin: 12px 0 0 0; color: #64748b; font-size: 13px;">Du kannst deine Bewertung nach dem Termin über diesen Link abgeben.</p>
</div>
` : ''}
<div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #3b82f6;">📋 Rechtliche Informationen:</p>
<p style="margin: 8px 0 12px 0; color: #475569;">Weitere Informationen findest du in unserem <a href="${legalUrl}" style="color: #3b82f6; text-decoration: underline;">Impressum und Datenschutz</a>.</p>
</div>
<p>Liebe Grüße,<br/>Stargirlnails Kiel</p>
`;
return renderBrandedEmail("Termin bestätigt", inner);
}
export async function renderBookingCancelledHTML(params) {
const { name, date, time } = params;
const formattedDate = formatDateGerman(date);
const domain = process.env.DOMAIN || 'localhost:5173';
const protocol = domain.includes('localhost') ? 'http' : 'https';
const legalUrl = `${protocol}://${domain}/legal`;
const inner = `
<p>Hallo ${name},</p>
<p>dein Termin am <strong>${formattedDate}</strong> um <strong>${time}</strong> wurde abgesagt.</p>
<p>Bitte buche einen neuen Termin. Bei Fragen helfen wir dir gerne weiter.</p>
<div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #3b82f6;">📋 Rechtliche Informationen:</p>
<p style="margin: 8px 0 12px 0; color: #475569;">Weitere Informationen findest du in unserem <a href="${legalUrl}" style="color: #3b82f6; text-decoration: underline;">Impressum und Datenschutz</a>.</p>
</div>
<p>Liebe Grüße,<br/>Stargirlnails Kiel</p>
`;
return renderBrandedEmail("Termin abgesagt", inner);
}
export async function renderAdminBookingNotificationHTML(params) {
const { name, date, time, treatment, phone, notes, hasInspirationPhoto } = params;
const formattedDate = formatDateGerman(date);
const inner = `
<p>Hallo Admin,</p>
<p>eine neue Buchungsanfrage ist eingegangen:</p>
<div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #db2777;">📅 Buchungsdetails:</p>
<ul style="margin: 8px 0 0 0; color: #475569; list-style: none; padding: 0;">
<li><strong>Name:</strong> ${name}</li>
<li><strong>Telefon:</strong> ${phone}</li>
<li><strong>Behandlung:</strong> ${treatment}</li>
<li><strong>Datum:</strong> ${formattedDate}</li>
<li><strong>Uhrzeit:</strong> ${time}</li>
${notes ? `<li><strong>Notizen:</strong> ${notes}</li>` : ''}
<li><strong>Inspiration-Foto:</strong> ${hasInspirationPhoto ? '✅ Im Anhang verfügbar' : '❌ Kein Foto hochgeladen'}</li>
</ul>
</div>
<p>Bitte logge dich in das Admin-Panel ein, um die Buchung zu bestätigen oder abzulehnen.</p>
<p>Liebe Grüße,<br/>Stargirlnails System</p>
`;
return renderBrandedEmail("Neue Buchungsanfrage - Admin-Benachrichtigung", inner);
}
export async function renderBookingRescheduleProposalHTML(params) {
const formattedOriginalDate = formatDateGerman(params.originalDate);
const formattedProposedDate = formatDateGerman(params.proposedDate);
const expiryDate = new Date(params.expiresAt);
const formattedExpiry = `${expiryDate.toLocaleDateString('de-DE')} ${expiryDate.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`;
const inner = `
<p>Hallo ${params.name},</p>
<p>wir müssen deinen Termin leider verschieben. Hier ist unser Vorschlag:</p>
<div style="background-color: #f8fafc; border-left: 4px solid #f59e0b; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #92400e;">📅 Übersicht</p>
<table role="presentation" cellspacing="0" cellpadding="0" style="width:100%; margin-top:8px; font-size:14px; color:#475569;">
<tr>
<td style="padding:6px 0; width:45%"><strong>Alter Termin</strong></td>
<td style="padding:6px 0;">${formattedOriginalDate} um ${params.originalTime} Uhr</td>
</tr>
<tr>
<td style="padding:6px 0; width:45%"><strong>Neuer Vorschlag</strong></td>
<td style="padding:6px 0; color:#b45309;"><strong>${formattedProposedDate} um ${params.proposedTime} Uhr</strong></td>
</tr>
<tr>
<td style="padding:6px 0; width:45%"><strong>Behandlung</strong></td>
<td style="padding:6px 0;">${params.treatmentName}</td>
</tr>
</table>
</div>
<div style="background-color: #fffbeb; border-left: 4px solid #f59e0b; padding: 12px; margin: 16px 0; border-radius: 4px; color:#92400e;">
⏰ Bitte antworte bis ${formattedExpiry}.
</div>
<div style="text-align:center; margin: 20px 0;">
<a href="${params.acceptUrl}" style="display:inline-block; background-color:#16a34a; color:#ffffff; padding:12px 18px; border-radius:8px; text-decoration:none; font-weight:700; margin-right:8px;">Neuen Termin akzeptieren</a>
<a href="${params.declineUrl}" style="display:inline-block; background-color:#dc2626; color:#ffffff; padding:12px 18px; border-radius:8px; text-decoration:none; font-weight:700;">Termin ablehnen</a>
</div>
<div style="background-color: #f8fafc; border-left: 4px solid #10b981; padding: 12px; margin: 16px 0; border-radius: 4px; color:#065f46;">
Wenn du den Vorschlag ablehnst, bleibt dein ursprünglicher Termin bestehen und wir kontaktieren dich für eine alternative Lösung.
</div>
<p>Falls du einen komplett neuen Termin buchen möchtest, kannst du deinen aktuellen Termin stornieren und einen neuen Termin auf unserer Website buchen.</p>
<p>Liebe Grüße,<br/>Stargirlnails Kiel</p>
`;
return renderBrandedEmail("Terminänderung vorgeschlagen", inner);
}
export async function renderAdminRescheduleDeclinedHTML(params) {
const inner = `
<p>Hallo Admin,</p>
<p>der Kunde <strong>${params.customerName}</strong> hat den Terminänderungsvorschlag abgelehnt.</p>
<div style="background-color:#f8fafc; border-left:4px solid #ef4444; padding:16px; margin:16px 0; border-radius:4px;">
<ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:14px;">
<li><strong>Kunde:</strong> ${params.customerName}</li>
${params.customerEmail ? `<li><strong>E-Mail:</strong> ${params.customerEmail}</li>` : ''}
${params.customerPhone ? `<li><strong>Telefon:</strong> ${params.customerPhone}</li>` : ''}
<li><strong>Behandlung:</strong> ${params.treatmentName}</li>
<li><strong>Ursprünglicher Termin:</strong> ${formatDateGerman(params.originalDate)} um ${params.originalTime} Uhr (bleibt bestehen)</li>
<li><strong>Abgelehnter Vorschlag:</strong> ${formatDateGerman(params.proposedDate)} um ${params.proposedTime} Uhr</li>
</ul>
</div>
<p>Bitte kontaktiere den Kunden, um eine alternative Lösung zu finden.</p>
`;
return renderBrandedEmail("Kunde hat Terminänderung abgelehnt", inner);
}
export async function renderAdminRescheduleAcceptedHTML(params) {
const inner = `
<p>Hallo Admin,</p>
<p>der Kunde <strong>${params.customerName}</strong> hat den Terminänderungsvorschlag akzeptiert.</p>
<div style="background-color:#ecfeff; border-left:4px solid #10b981; padding:16px; margin:16px 0; border-radius:4px;">
<ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:14px;">
<li><strong>Kunde:</strong> ${params.customerName}</li>
<li><strong>Behandlung:</strong> ${params.treatmentName}</li>
<li><strong>Alter Termin:</strong> ${formatDateGerman(params.originalDate)} um ${params.originalTime} Uhr</li>
<li><strong>Neuer Termin:</strong> ${formatDateGerman(params.newDate)} um ${params.newTime} Uhr ✅</li>
</ul>
</div>
<p>Der Termin wurde automatisch aktualisiert.</p>
`;
return renderBrandedEmail("Kunde hat Terminänderung akzeptiert", inner);
}
export async function renderAdminRescheduleExpiredHTML(params) {
const inner = `
<p>Hallo Admin,</p>
<p><strong>${params.expiredProposals.length} Terminänderungsvorschlag${params.expiredProposals.length > 1 ? 'e' : ''} ${params.expiredProposals.length > 1 ? 'sind' : 'ist'} abgelaufen</strong> und wurde${params.expiredProposals.length > 1 ? 'n' : ''} automatisch entfernt.</p>
<div style="background-color:#fef2f2; border-left:4px solid #ef4444; padding:16px; margin:16px 0; border-radius:4px;">
<p style="margin:0 0 12px 0; font-weight:600; color:#dc2626;">⚠️ Abgelaufene Vorschläge:</p>
${params.expiredProposals.map(proposal => `
<div style="background-color:#ffffff; border:1px solid #fecaca; border-radius:4px; padding:12px; margin:8px 0;">
<ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:13px;">
<li><strong>Kunde:</strong> ${proposal.customerName}</li>
${proposal.customerEmail ? `<li><strong>E-Mail:</strong> ${proposal.customerEmail}</li>` : ''}
${proposal.customerPhone ? `<li><strong>Telefon:</strong> ${proposal.customerPhone}</li>` : ''}
<li><strong>Behandlung:</strong> ${proposal.treatmentName}</li>
<li><strong>Ursprünglicher Termin:</strong> ${formatDateGerman(proposal.originalDate)} um ${proposal.originalTime} Uhr</li>
<li><strong>Vorgeschlagener Termin:</strong> ${formatDateGerman(proposal.proposedDate)} um ${proposal.proposedTime} Uhr</li>
<li><strong>Abgelaufen am:</strong> ${new Date(proposal.expiredAt).toLocaleString('de-DE')}</li>
</ul>
</div>
`).join('')}
</div>
<p style="color:#dc2626; font-weight:600;">Bitte kontaktiere die Kunden, um eine alternative Lösung zu finden.</p>
<p>Die ursprünglichen Termine bleiben bestehen.</p>
`;
return renderBrandedEmail("Abgelaufene Terminänderungsvorschläge", inner);
}
export async function renderCustomerMessageHTML(params) {
const { customerName, message, appointmentDate, appointmentTime, treatmentName } = params;
const formattedDate = appointmentDate ? formatDateGerman(appointmentDate) : null;
const domain = process.env.DOMAIN || 'localhost:5173';
const protocol = domain.includes('localhost') ? 'http' : 'https';
const legalUrl = `${protocol}://${domain}/legal`;
const ownerName = process.env.OWNER_NAME || 'Stargirlnails Kiel';
const inner = `
<p>Hallo ${customerName},</p>
${(appointmentDate && appointmentTime && treatmentName) ? `
<div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #db2777;">📅 Zu deinem Termin:</p>
<ul style="margin: 8px 0 0 0; color: #475569; list-style: none; padding: 0;">
<li><strong>Behandlung:</strong> ${treatmentName}</li>
<li><strong>Datum:</strong> ${formattedDate}</li>
<li><strong>Uhrzeit:</strong> ${appointmentTime}</li>
</ul>
</div>
` : ''}
<div style="background-color: #fef9f5; border-left: 4px solid #f59e0b; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #f59e0b;">💬 Nachricht von ${ownerName}:</p>
<div style="margin: 12px 0 0 0; color: #475569; white-space: pre-wrap; line-height: 1.6;">${message.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</div>
</div>
<p>Bei Fragen oder Anliegen kannst du einfach auf diese E-Mail antworten wir helfen dir gerne weiter!</p>
<div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #3b82f6;">📋 Rechtliche Informationen:</p>
<p style="margin: 8px 0 12px 0; color: #475569;">Weitere Informationen findest du in unserem <a href="${legalUrl}" style="color: #3b82f6; text-decoration: underline;">Impressum und Datenschutz</a>.</p>
</div>
<p>Liebe Grüße,<br/>${ownerName}</p>
`;
return renderBrandedEmail("Nachricht zu deinem Termin", inner);
}

View File

@@ -0,0 +1,88 @@
// Email validation using Rapid Email Validator API
// API: https://rapid-email-verifier.fly.dev/
// Privacy-focused, no data storage, completely free
/**
* Validate email address using Rapid Email Validator API
* Returns true if email is valid, false otherwise
*/
export async function validateEmail(email) {
try {
// Call Rapid Email Validator API
const response = await fetch(`https://rapid-email-verifier.fly.dev/api/validate?email=${encodeURIComponent(email)}`, {
method: 'GET',
headers: {
'Accept': 'application/json',
},
});
if (!response.ok) {
console.error(`Email validation API error: ${response.status}`);
// If API is down, reject the email with error message
return {
valid: false,
reason: 'E-Mail-Validierung ist derzeit nicht verfügbar. Bitte überprüfe deine E-Mail-Adresse und versuche es erneut.'
};
}
const data = await response.json();
// Check if email is disposable/temporary
if (data.validations.is_disposable) {
return {
valid: false,
reason: 'Temporäre oder Wegwerf-E-Mail-Adressen sind nicht erlaubt. Bitte verwende eine echte E-Mail-Adresse.',
};
}
// Check if MX records exist (deliverable)
if (!data.validations.mx_records) {
return {
valid: false,
reason: 'Diese E-Mail-Adresse kann keine E-Mails empfangen. Bitte überprüfe deine E-Mail-Adresse.',
};
}
// Check if domain exists
if (!data.validations.domain_exists) {
return {
valid: false,
reason: 'Die E-Mail-Domain existiert nicht. Bitte überprüfe deine E-Mail-Adresse.',
};
}
// Check if email syntax is valid
if (!data.validations.syntax) {
return {
valid: false,
reason: 'Ungültige E-Mail-Adresse. Bitte überprüfe die Schreibweise.',
};
}
// Email is valid
return { valid: true };
}
catch (error) {
console.error('Email validation error:', error);
// If validation fails, reject the email with error message
return {
valid: false,
reason: 'E-Mail-Validierung ist derzeit nicht verfügbar. Bitte überprüfe deine E-Mail-Adresse und versuche es erneut.'
};
}
}
/**
* Batch validate multiple emails
* @param emails Array of email addresses to validate
* @returns Array of validation results
*/
export async function validateEmailBatch(emails) {
const results = new Map();
// Validate up to 100 emails at once (API limit)
const batchSize = 100;
for (let i = 0; i < emails.length; i += batchSize) {
const batch = emails.slice(i, i + batchSize);
// Call each validation in parallel for better performance
const validations = await Promise.all(batch.map(async (email) => {
const result = await validateEmail(email);
return { email, result };
}));
// Store results
validations.forEach(({ email, result }) => {
results.set(email, result);
});
}
return results;
}

191
server-dist/lib/email.js Normal file
View File

@@ -0,0 +1,191 @@
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const RESEND_API_KEY = process.env.RESEND_API_KEY;
const DEFAULT_FROM = process.env.EMAIL_FROM || "Stargirlnails <no-reply@stargirlnails.de>";
// Helper function to format dates for ICS files (YYYYMMDDTHHMMSS)
function formatDateForICS(date, time) {
// date is in YYYY-MM-DD format, time is in HH:MM format
const [year, month, day] = date.split('-');
const [hours, minutes] = time.split(':');
return `${year}${month}${day}T${hours}${minutes}00`;
}
// Helper function to create ICS (iCalendar) file content
function createICSFile(params) {
const { date, time, durationMinutes, customerName, treatmentName } = params;
// Calculate start and end times in Europe/Berlin timezone
const dtStart = formatDateForICS(date, time);
// Calculate end time
const [hours, minutes] = time.split(':').map(Number);
const startDate = new Date(`${date}T${time}:00`);
const endDate = new Date(startDate.getTime() + durationMinutes * 60000);
const endHours = String(endDate.getHours()).padStart(2, '0');
const endMinutes = String(endDate.getMinutes()).padStart(2, '0');
const dtEnd = formatDateForICS(date, `${endHours}:${endMinutes}`);
// Create unique ID for this event
const uid = `booking-${Date.now()}-${Math.random().toString(36).substr(2, 9)}@stargirlnails.de`;
// Current timestamp for DTSTAMP
const now = new Date();
const dtstamp = now.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
// ICS content
const icsContent = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Stargirlnails Kiel//Booking System//DE',
'CALSCALE:GREGORIAN',
'METHOD:REQUEST',
'BEGIN:VEVENT',
`UID:${uid}`,
`DTSTAMP:${dtstamp}`,
`DTSTART;TZID=Europe/Berlin:${dtStart}`,
`DTEND;TZID=Europe/Berlin:${dtEnd}`,
`SUMMARY:${treatmentName} - Stargirlnails Kiel`,
`DESCRIPTION:Termin für ${treatmentName} bei Stargirlnails Kiel`,
'LOCATION:Stargirlnails Kiel',
`ORGANIZER;CN=Stargirlnails Kiel:mailto:${process.env.EMAIL_FROM?.match(/<(.+)>/)?.[1] || 'no-reply@stargirlnails.de'}`,
`ATTENDEE;CN=${customerName};RSVP=TRUE:mailto:${customerName}`,
'STATUS:CONFIRMED',
'SEQUENCE:0',
'BEGIN:VALARM',
'TRIGGER:-PT24H',
'ACTION:DISPLAY',
'DESCRIPTION:Erinnerung: Termin morgen bei Stargirlnails Kiel',
'END:VALARM',
'END:VEVENT',
'BEGIN:VTIMEZONE',
'TZID:Europe/Berlin',
'BEGIN:DAYLIGHT',
'TZOFFSETFROM:+0100',
'TZOFFSETTO:+0200',
'TZNAME:CEST',
'DTSTART:19700329T020000',
'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU',
'END:DAYLIGHT',
'BEGIN:STANDARD',
'TZOFFSETFROM:+0200',
'TZOFFSETTO:+0100',
'TZNAME:CET',
'DTSTART:19701025T030000',
'RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU',
'END:STANDARD',
'END:VTIMEZONE',
'END:VCALENDAR'
].join('\r\n');
return icsContent;
}
// Cache for AGB PDF to avoid reading it multiple times
let cachedAGBPDF = null;
async function getAGBPDFBase64() {
if (cachedAGBPDF)
return cachedAGBPDF;
try {
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const agbPath = resolve(__dirname, "../../../AGB.pdf");
const buf = await readFile(agbPath);
cachedAGBPDF = buf.toString('base64');
return cachedAGBPDF;
}
catch (error) {
console.warn("Could not read AGB.pdf:", error);
return null;
}
}
export async function sendEmail(params) {
if (!RESEND_API_KEY) {
// In development or if not configured, skip sending but don't fail the flow
console.warn("Resend API key not configured. Skipping email send.");
return { success: false };
}
const payload = {
from: params.from || DEFAULT_FROM,
to: Array.isArray(params.to) ? params.to : [params.to],
subject: params.subject,
text: params.text,
html: params.html,
cc: params.cc ? (Array.isArray(params.cc) ? params.cc : [params.cc]) : undefined,
bcc: params.bcc ? (Array.isArray(params.bcc) ? params.bcc : [params.bcc]) : undefined,
reply_to: params.replyTo ? (Array.isArray(params.replyTo) ? params.replyTo : [params.replyTo]) : undefined,
attachments: params.attachments,
};
console.log(`Sending email via Resend: to=${JSON.stringify(payload.to)}, subject="${params.subject}"`);
const response = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
"Authorization": `Bearer ${RESEND_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const body = await response.text().catch(() => "");
console.error("Resend send error:", response.status, body);
return { success: false };
}
const responseData = await response.json().catch(() => ({}));
console.log("Resend email sent successfully:", responseData);
return { success: true };
}
export async function sendEmailWithAGB(params) {
const agbBase64 = await getAGBPDFBase64();
if (agbBase64) {
params.attachments = [
...(params.attachments || []),
{
filename: "AGB_Stargirlnails_Kiel.pdf",
content: agbBase64,
type: "application/pdf"
}
];
}
return sendEmail(params);
}
export async function sendEmailWithAGBAndCalendar(params, calendarParams) {
const agbBase64 = await getAGBPDFBase64();
// Create ICS file content
const icsContent = createICSFile(calendarParams);
const icsBase64 = Buffer.from(icsContent, 'utf-8').toString('base64');
// Attach both AGB and ICS file
params.attachments = [...(params.attachments || [])];
if (agbBase64) {
params.attachments.push({
filename: "AGB_Stargirlnails_Kiel.pdf",
content: agbBase64,
type: "application/pdf"
});
}
params.attachments.push({
filename: "Termin_Stargirlnails.ics",
content: icsBase64,
type: "text/calendar"
});
return sendEmail(params);
}
export async function sendEmailWithInspirationPhoto(params, photoData, customerName) {
if (!photoData) {
return sendEmail(params);
}
// Extract file extension from base64 data URL
const match = photoData.match(/data:image\/([^;]+);base64,(.+)/);
if (!match) {
console.warn("Invalid photo data format");
return sendEmail(params);
}
const [, extension, base64Content] = match;
const filename = `inspiration_${customerName.replace(/[^a-zA-Z0-9]/g, '_')}_${Date.now()}.${extension}`;
// Check if attachment is too large (max 1MB base64 content)
if (base64Content.length > 1024 * 1024) {
console.warn(`Photo attachment too large: ${base64Content.length} chars, skipping attachment`);
return sendEmail(params);
}
// console.log(`Sending email with photo attachment: ${filename}, size: ${base64Content.length} chars`);
params.attachments = [
...(params.attachments || []),
{
filename,
content: base64Content,
type: `image/${extension}`
}
];
return sendEmail(params);
}

View File

@@ -0,0 +1,39 @@
// Default configuration - should be overridden by environment variables
export const defaultLegalConfig = {
companyName: process.env.COMPANY_NAME || "Stargirlnails Kiel",
ownerName: process.env.OWNER_NAME || "Inhaber Name",
address: {
street: process.env.ADDRESS_STREET || "Liebigstr. 15",
city: process.env.ADDRESS_CITY || "Kiel",
postalCode: process.env.ADDRESS_POSTAL_CODE || "24145",
country: process.env.ADDRESS_COUNTRY || "Deutschland",
latitude: process.env.ADDRESS_LATITUDE ? parseFloat(process.env.ADDRESS_LATITUDE) : 54.3233,
longitude: process.env.ADDRESS_LONGITUDE ? parseFloat(process.env.ADDRESS_LONGITUDE) : 10.1228,
},
contact: {
phone: process.env.CONTACT_PHONE || "+49 431 123456",
email: process.env.CONTACT_EMAIL || "info@stargirlnails.de",
website: process.env.DOMAIN || "stargirlnails.de",
},
businessDetails: {
taxId: process.env.TAX_ID || "",
vatId: process.env.VAT_ID || "",
commercialRegister: process.env.COMMERCIAL_REGISTER || "",
responsibleForContent: process.env.RESPONSIBLE_FOR_CONTENT || "Inhaber Name",
},
dataProtection: {
responsiblePerson: process.env.DATA_PROTECTION_RESPONSIBLE || "Inhaber Name",
email: process.env.DATA_PROTECTION_EMAIL || "datenschutz@stargirlnails.de",
purpose: process.env.DATA_PROTECTION_PURPOSE || "Terminbuchung und Kundenbetreuung",
legalBasis: process.env.DATA_PROTECTION_LEGAL_BASIS || "Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung) und Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse)",
dataRetention: process.env.DATA_PROTECTION_RETENTION || "Buchungsdaten werden 3 Jahre nach Vertragsende gespeichert",
rights: process.env.DATA_PROTECTION_RIGHTS || "Auskunft, Berichtigung, Löschung, Einschränkung, Widerspruch, Beschwerde bei der Aufsichtsbehörde",
cookies: process.env.DATA_PROTECTION_COOKIES || "Wir verwenden technisch notwendige Cookies für die Funktionalität der Website",
thirdPartyServices: process.env.THIRD_PARTY_SERVICES ? process.env.THIRD_PARTY_SERVICES.split(',') : ["Resend (E-Mail-Versand)"],
dataSecurity: process.env.DATA_PROTECTION_SECURITY || "SSL-Verschlüsselung, sichere Server, regelmäßige Updates",
contactDataProtection: process.env.DATA_PROTECTION_CONTACT || "Bei Fragen zum Datenschutz wenden Sie sich an: datenschutz@stargirlnails.de",
},
};
export function getLegalConfig() {
return defaultLegalConfig;
}

14
server-dist/lib/openai.js Normal file
View File

@@ -0,0 +1,14 @@
import { jsonrepair } from "jsonrepair";
import { z } from "zod";
import { makeParseableResponseFormat } from "openai/lib/parser";
export function zodResponseFormat(zodObject, name, props) {
return makeParseableResponseFormat({
type: "json_schema",
json_schema: {
...props,
name,
strict: true,
schema: z.toJSONSchema(zodObject, { target: "draft-7" }),
},
}, (content) => zodObject.parse(JSON.parse(jsonrepair(content))));
}

View File

@@ -0,0 +1,117 @@
// Simple in-memory rate limiter for IP and email-based requests
// For production with multiple instances, consider using Redis
const rateLimitStore = new Map();
// Cleanup old entries every 10 minutes to prevent memory leaks
setInterval(() => {
const now = Date.now();
for (const [key, entry] of rateLimitStore.entries()) {
if (entry.resetAt < now) {
rateLimitStore.delete(key);
}
}
}, 10 * 60 * 1000);
/**
* Check if a request is allowed based on rate limiting
* @param key - Unique identifier (IP, email, or combination)
* @param config - Rate limit configuration
* @returns RateLimitResult with allow status and metadata
*/
export function checkRateLimit(key, config) {
const now = Date.now();
const entry = rateLimitStore.get(key);
// No existing entry or window expired - allow and create new entry
if (!entry || entry.resetAt < now) {
rateLimitStore.set(key, {
count: 1,
resetAt: now + config.windowMs,
});
return {
allowed: true,
remaining: config.maxRequests - 1,
resetAt: now + config.windowMs,
};
}
// Existing entry within window
if (entry.count >= config.maxRequests) {
// Rate limit exceeded
const retryAfterSeconds = Math.ceil((entry.resetAt - now) / 1000);
return {
allowed: false,
remaining: 0,
resetAt: entry.resetAt,
retryAfterSeconds,
};
}
// Increment count and allow
entry.count++;
rateLimitStore.set(key, entry);
return {
allowed: true,
remaining: config.maxRequests - entry.count,
resetAt: entry.resetAt,
};
}
/**
* Check rate limit for booking creation
* Applies multiple checks: per IP and per email
*/
export function checkBookingRateLimit(params) {
const { ip, email } = params;
// Config: max 3 bookings per email per hour
const emailConfig = {
maxRequests: 3,
windowMs: 60 * 60 * 1000, // 1 hour
};
// Config: max 5 bookings per IP per 10 minutes
const ipConfig = {
maxRequests: 5,
windowMs: 10 * 60 * 1000, // 10 minutes
};
// Check email rate limit
const emailKey = `booking:email:${email.toLowerCase()}`;
const emailResult = checkRateLimit(emailKey, emailConfig);
if (!emailResult.allowed) {
return {
...emailResult,
allowed: false,
};
}
// Check IP rate limit (if IP is available)
if (ip) {
const ipKey = `booking:ip:${ip}`;
const ipResult = checkRateLimit(ipKey, ipConfig);
if (!ipResult.allowed) {
return {
...ipResult,
allowed: false,
};
}
}
// Both checks passed
return {
allowed: true,
remaining: Math.min(emailResult.remaining, ip ? Infinity : emailResult.remaining),
resetAt: emailResult.resetAt,
};
}
/**
* Get client IP from various headers (for proxy/load balancer support)
*/
export function getClientIP(headers) {
// Check common proxy headers
const forwardedFor = headers['x-forwarded-for'];
if (forwardedFor) {
// x-forwarded-for can contain multiple IPs, take the first one
return forwardedFor.split(',')[0].trim();
}
const realIP = headers['x-real-ip'];
if (realIP) {
return realIP;
}
const cfConnectingIP = headers['cf-connecting-ip']; // Cloudflare
if (cfConnectingIP) {
return cfConnectingIP;
}
// No IP found
return undefined;
}

View File

@@ -0,0 +1,176 @@
import { Hono } from "hono";
import { createKV } from "../lib/create-kv.js";
// KV-Stores
const bookingsKV = createKV("bookings");
const treatmentsKV = createKV("treatments");
const sessionsKV = createKV("sessions");
export const caldavApp = new Hono();
// Helper-Funktionen für ICS-Format
function formatDateTime(dateStr, timeStr) {
// Konvertiere YYYY-MM-DD HH:MM zu UTC-Format für ICS
const [year, month, day] = dateStr.split('-').map(Number);
const [hours, minutes] = timeStr.split(':').map(Number);
const date = new Date(year, month - 1, day, hours, minutes);
return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
}
function generateICSContent(bookings, treatments) {
const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
let ics = `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Stargirlnails//Booking Calendar//DE
CALSCALE:GREGORIAN
METHOD:PUBLISH
X-WR-CALNAME:Stargirlnails Termine
X-WR-CALDESC:Terminkalender für Stargirlnails
X-WR-TIMEZONE:Europe/Berlin
`;
// Nur bestätigte und ausstehende Termine in den Kalender aufnehmen
const activeBookings = bookings.filter(b => b.status === 'confirmed' || b.status === 'pending');
for (const booking of activeBookings) {
const treatment = treatments.find(t => t.id === booking.treatmentId);
const treatmentName = treatment?.name || 'Unbekannte Behandlung';
const duration = booking.bookedDurationMinutes || treatment?.duration || 60;
const startTime = formatDateTime(booking.appointmentDate, booking.appointmentTime);
const endTime = formatDateTime(booking.appointmentDate, `${String(Math.floor((parseInt(booking.appointmentTime.split(':')[0]) * 60 + parseInt(booking.appointmentTime.split(':')[1]) + duration) / 60)).padStart(2, '0')}:${String((parseInt(booking.appointmentTime.split(':')[0]) * 60 + parseInt(booking.appointmentTime.split(':')[1]) + duration) % 60).padStart(2, '0')}`);
// UID für jeden Termin (eindeutig)
const uid = `booking-${booking.id}@stargirlnails.de`;
// Status für ICS
const status = booking.status === 'confirmed' ? 'CONFIRMED' : 'TENTATIVE';
ics += `BEGIN:VEVENT
UID:${uid}
DTSTAMP:${now}
DTSTART:${startTime}
DTEND:${endTime}
SUMMARY:${treatmentName} - ${booking.customerName}
DESCRIPTION:Behandlung: ${treatmentName}\\nKunde: ${booking.customerName}${booking.customerPhone ? `\\nTelefon: ${booking.customerPhone}` : ''}${booking.notes ? `\\nNotizen: ${booking.notes}` : ''}
STATUS:${status}
TRANSP:OPAQUE
END:VEVENT
`;
}
ics += `END:VCALENDAR`;
return ics;
}
// CalDAV Discovery (PROPFIND auf Root)
caldavApp.all("/", async (c) => {
if (c.req.method !== 'PROPFIND') {
return c.text('Method Not Allowed', 405);
}
const response = `<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:response>
<D:href>/caldav/</D:href>
<D:propstat>
<D:prop>
<D:displayname>Stargirlnails Terminkalender</D:displayname>
<C:calendar-description>Termine für Stargirlnails</C:calendar-description>
<C:supported-calendar-component-set>
<C:comp name="VEVENT"/>
</C:supported-calendar-component-set>
<C:calendar-timezone>Europe/Berlin</C:calendar-timezone>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>`;
return c.text(response, 207, {
"Content-Type": "application/xml; charset=utf-8",
"DAV": "1, 3, calendar-access, calendar-schedule",
});
});
// Calendar Collection PROPFIND
caldavApp.all("/calendar/", async (c) => {
if (c.req.method !== 'PROPFIND') {
return c.text('Method Not Allowed', 405);
}
const response = `<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/">
<D:response>
<D:href>/caldav/calendar/</D:href>
<D:propstat>
<D:prop>
<D:displayname>Stargirlnails Termine</D:displayname>
<C:calendar-description>Alle Termine von Stargirlnails</C:calendar-description>
<C:supported-calendar-component-set>
<C:comp name="VEVENT"/>
</C:supported-calendar-component-set>
<C:calendar-timezone>Europe/Berlin</C:calendar-timezone>
<CS:getctag>${Date.now()}</CS:getctag>
<D:sync-token>${Date.now()}</D:sync-token>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>`;
return c.text(response, 207, {
"Content-Type": "application/xml; charset=utf-8",
});
});
// Calendar Events PROPFIND
caldavApp.all("/calendar/events.ics", async (c) => {
if (c.req.method !== 'PROPFIND') {
return c.text('Method Not Allowed', 405);
}
const response = `<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/">
<D:response>
<D:href>/caldav/calendar/events.ics</D:href>
<D:propstat>
<D:prop>
<D:getcontenttype>text/calendar; charset=utf-8</D:getcontenttype>
<D:getetag>"${Date.now()}"</D:getetag>
<D:displayname>Stargirlnails Termine</D:displayname>
<C:calendar-data>BEGIN:VCALENDAR\\nVERSION:2.0\\nEND:VCALENDAR</C:calendar-data>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>`;
return c.text(response, 207, {
"Content-Type": "application/xml; charset=utf-8",
});
});
// GET Calendar Data (ICS-Datei)
caldavApp.get("/calendar/events.ics", async (c) => {
try {
// Authentifizierung über Token im Query-Parameter
const token = c.req.query('token');
if (!token) {
return c.text('Unauthorized - Token required', 401);
}
// Token validieren
const tokenData = await sessionsKV.getItem(token);
if (!tokenData) {
return c.text('Unauthorized - Invalid token', 401);
}
// Prüfe, ob es ein CalDAV-Token ist (durch Ablaufzeit und fehlende type-Eigenschaft erkennbar)
// CalDAV-Tokens haben eine kürzere Ablaufzeit (24h) als normale Sessions
const tokenAge = Date.now() - new Date(tokenData.createdAt).getTime();
if (tokenAge > 24 * 60 * 60 * 1000) { // 24 Stunden
return c.text('Unauthorized - Token expired', 401);
}
// Token-Ablaufzeit prüfen
if (new Date(tokenData.expiresAt) < new Date()) {
return c.text('Unauthorized - Token expired', 401);
}
const bookings = await bookingsKV.getAllItems();
const treatments = await treatmentsKV.getAllItems();
const icsContent = generateICSContent(bookings, treatments);
return c.text(icsContent, 200, {
"Content-Type": "text/calendar; charset=utf-8",
"Content-Disposition": "inline; filename=\"stargirlnails-termine.ics\"",
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
"Expires": "0",
});
}
catch (error) {
console.error("CalDAV GET error:", error);
return c.text('Internal Server Error', 500);
}
});
// Fallback für andere CalDAV-Requests
caldavApp.all("*", async (c) => {
console.log(`CalDAV: Unhandled ${c.req.method} request to ${c.req.url}`);
return c.text('Not Found', 404);
});

View File

@@ -0,0 +1,28 @@
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "hono/jsx/jsx-runtime";
import { readFileSync } from "fs";
import { join } from "path";
export function clientEntry(c) {
let jsFile = "/src/client/main.tsx";
let cssFiles = null;
if (process.env.NODE_ENV === 'production') {
try {
// Read Vite manifest to get the correct file names
const manifestPath = join(process.cwd(), 'dist', '.vite', 'manifest.json');
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
const entry = manifest['index.html'];
if (entry) {
jsFile = `/${entry.file}`;
if (entry.css) {
cssFiles = entry.css.map((css) => `/${css}`);
}
}
}
catch (error) {
console.warn('Could not read Vite manifest, using fallback:', error);
// Fallback to a generic path
jsFile = "/assets/index-Ccx6A0bN.js";
cssFiles = ["/assets/index-RdX4PbOO.css"];
}
}
return c.html(_jsxs("html", { lang: "de", children: [_jsxs("head", { children: [_jsx("meta", { charSet: "utf-8" }), _jsx("meta", { content: "width=device-width, initial-scale=1", name: "viewport" }), _jsx("meta", { name: "theme-color", content: "#ec4899" }), _jsx("meta", { name: "apple-mobile-web-app-capable", content: "yes" }), _jsx("meta", { name: "apple-mobile-web-app-status-bar-style", content: "default" }), _jsx("meta", { name: "apple-mobile-web-app-title", content: "Stargirlnails" }), _jsx("title", { children: "Stargirlnails Kiel" }), _jsx("link", { rel: "icon", type: "image/png", href: "/favicon.png" }), _jsx("link", { rel: "apple-touch-icon", href: "/icons/apple-touch-icon.png" }), _jsx("link", { rel: "manifest", href: "/manifest.json" }), cssFiles && cssFiles.map((css) => (_jsx("link", { rel: "stylesheet", href: css }, css))), process.env.NODE_ENV === 'production' ? (_jsx("script", { src: jsFile, type: "module" })) : (_jsxs(_Fragment, { children: [_jsx("script", { src: "/@vite/client", type: "module" }), _jsx("script", { src: jsFile, type: "module" })] }))] }), _jsx("body", { children: _jsx("div", { id: "root" }) })] }));
}

21
server-dist/routes/rpc.js Normal file
View File

@@ -0,0 +1,21 @@
import { RPCHandler } from "@orpc/server/fetch";
import { router } from "../rpc/index.js";
import { Hono } from "hono";
export const rpcApp = new Hono();
const handler = new RPCHandler(router);
rpcApp.all("/*", async (c) => {
try {
const { matched, response } = await handler.handle(c.req.raw, {
prefix: "/rpc",
});
if (matched) {
return c.newResponse(response.body, response);
}
return c.json({ error: "Not found" }, 404);
}
catch (error) {
console.error("RPC Handler error:", error);
// Let oRPC handle errors properly
throw error;
}
});

148
server-dist/rpc/auth.js Normal file
View File

@@ -0,0 +1,148 @@
import { os } from "@orpc/server";
import { z } from "zod";
import { randomUUID } from "crypto";
import { createKV } from "../lib/create-kv.js";
import { config } from "dotenv";
// Load environment variables from .env file
config();
const UserSchema = z.object({
id: z.string(),
username: z.string().min(3, "Benutzername muss mindestens 3 Zeichen lang sein"),
email: z.string().email("Ungültige E-Mail-Adresse"),
passwordHash: z.string(),
role: z.enum(["customer", "owner"]),
createdAt: z.string(),
});
const SessionSchema = z.object({
id: z.string(),
userId: z.string(),
expiresAt: z.string(),
createdAt: z.string(),
});
const usersKV = createKV("users");
const sessionsKV = createKV("sessions");
// Simple password hashing (in production, use bcrypt or similar)
const hashPassword = (password) => {
return Buffer.from(password).toString('base64');
};
const verifyPassword = (password, hash) => {
return hashPassword(password) === hash;
};
// Export hashPassword for external use (e.g., generating hashes for .env)
export const generatePasswordHash = hashPassword;
// Initialize default owner account
const initializeOwner = async () => {
const existingUsers = await usersKV.getAllItems();
if (existingUsers.length === 0) {
const ownerId = randomUUID();
// Get admin credentials from environment variables
const adminUsername = process.env.ADMIN_USERNAME || "owner";
const adminPasswordHash = process.env.ADMIN_PASSWORD_HASH || hashPassword("admin123");
const adminEmail = process.env.ADMIN_EMAIL || "owner@stargirlnails.de";
const owner = {
id: ownerId,
username: adminUsername,
email: adminEmail,
passwordHash: adminPasswordHash,
role: "owner",
createdAt: new Date().toISOString(),
};
await usersKV.setItem(ownerId, owner);
console.log(`✅ Admin account created: username="${adminUsername}", email="${adminEmail}"`);
}
};
// Initialize on module load
initializeOwner();
const login = os
.input(z.object({
username: z.string(),
password: z.string(),
}))
.handler(async ({ input }) => {
const users = await usersKV.getAllItems();
const user = users.find(u => u.username === input.username);
if (!user || !verifyPassword(input.password, user.passwordHash)) {
throw new Error("Invalid credentials");
}
// Create session
const sessionId = randomUUID();
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 24); // 24 hours
const session = {
id: sessionId,
userId: user.id,
expiresAt: expiresAt.toISOString(),
createdAt: new Date().toISOString(),
};
await sessionsKV.setItem(sessionId, session);
return {
sessionId,
user: {
id: user.id,
username: user.username,
email: user.email,
role: user.role,
},
};
});
const logout = os
.input(z.string()) // sessionId
.handler(async ({ input }) => {
await sessionsKV.removeItem(input);
return { success: true };
});
const verifySession = os
.input(z.string()) // sessionId
.handler(async ({ input }) => {
const session = await sessionsKV.getItem(input);
if (!session) {
throw new Error("Invalid session");
}
if (new Date(session.expiresAt) < new Date()) {
await sessionsKV.removeItem(input);
throw new Error("Session expired");
}
const user = await usersKV.getItem(session.userId);
if (!user) {
throw new Error("User not found");
}
return {
user: {
id: user.id,
username: user.username,
email: user.email,
role: user.role,
},
};
});
const changePassword = os
.input(z.object({
sessionId: z.string(),
currentPassword: z.string(),
newPassword: z.string(),
}))
.handler(async ({ input }) => {
const session = await sessionsKV.getItem(input.sessionId);
if (!session) {
throw new Error("Invalid session");
}
const user = await usersKV.getItem(session.userId);
if (!user) {
throw new Error("User not found");
}
if (!verifyPassword(input.currentPassword, user.passwordHash)) {
throw new Error("Current password is incorrect");
}
const updatedUser = {
...user,
passwordHash: hashPassword(input.newPassword),
};
await usersKV.setItem(user.id, updatedUser);
return { success: true };
});
export const router = {
login,
logout,
verifySession,
changePassword,
};

807
server-dist/rpc/bookings.js Normal file
View File

@@ -0,0 +1,807 @@
import { call, os } from "@orpc/server";
import { z } from "zod";
import { randomUUID } from "crypto";
import { createKV } from "../lib/create-kv.js";
import { sendEmail, sendEmailWithAGBAndCalendar, sendEmailWithInspirationPhoto } from "../lib/email.js";
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML, renderBookingRescheduleProposalHTML, renderAdminRescheduleAcceptedHTML, renderAdminRescheduleDeclinedHTML, renderCustomerMessageHTML } from "../lib/email-templates.js";
import { createORPCClient } from "@orpc/client";
import { RPCLink } from "@orpc/client/fetch";
import { checkBookingRateLimit } from "../lib/rate-limiter.js";
import { validateEmail } from "../lib/email-validator.js";
// Create a server-side client to call other RPC endpoints
const serverPort = process.env.PORT ? parseInt(process.env.PORT) : 3000;
const link = new RPCLink({ url: `http://localhost:${serverPort}/rpc` });
// Typisierung über any, um Build-Inkompatibilität mit NestedClient zu vermeiden (nur für interne Server-Calls)
const queryClient = createORPCClient(link);
// Helper function to convert date from yyyy-mm-dd to dd.mm.yyyy
function formatDateGerman(dateString) {
const [year, month, day] = dateString.split('-');
return `${day}.${month}.${year}`;
}
// Helper function to generate URLs from DOMAIN environment variable
function generateUrl(path = '') {
const domain = process.env.DOMAIN || 'localhost:5173';
const protocol = domain.includes('localhost') ? 'http' : 'https';
return `${protocol}://${domain}${path}`;
}
// Helper function to parse time string to minutes since midnight
function parseTime(timeStr) {
const [hours, minutes] = timeStr.split(':').map(Number);
return hours * 60 + minutes;
}
// Helper function to check if date is in time-off period
function isDateInTimeOffPeriod(date, periods) {
return periods.some(period => date >= period.startDate && date <= period.endDate);
}
// Helper function to validate booking time against recurring rules
async function validateBookingAgainstRules(date, time, treatmentDuration) {
// Parse date to get day of week
const [year, month, day] = date.split('-').map(Number);
const localDate = new Date(year, month - 1, day);
const dayOfWeek = localDate.getDay();
// Check time-off periods
const timeOffPeriods = await timeOffPeriodsKV.getAllItems();
if (isDateInTimeOffPeriod(date, timeOffPeriods)) {
throw new Error("Dieser Tag ist nicht verfügbar (Urlaubszeit).");
}
// Find matching recurring rules
const allRules = await recurringRulesKV.getAllItems();
const matchingRules = allRules.filter(rule => rule.isActive === true && rule.dayOfWeek === dayOfWeek);
if (matchingRules.length === 0) {
throw new Error("Für diesen Wochentag sind keine Termine verfügbar.");
}
// Check if booking time falls within any rule's time span
const bookingStartMinutes = parseTime(time);
const bookingEndMinutes = bookingStartMinutes + treatmentDuration;
const isWithinRules = matchingRules.some(rule => {
const ruleStartMinutes = parseTime(rule.startTime);
const ruleEndMinutes = parseTime(rule.endTime);
// Booking must start at or after rule start and end at or before rule end
return bookingStartMinutes >= ruleStartMinutes && bookingEndMinutes <= ruleEndMinutes;
});
if (!isWithinRules) {
throw new Error("Die gewählte Uhrzeit liegt außerhalb der verfügbaren Zeiten.");
}
}
// Helper function to check for booking conflicts
async function checkBookingConflicts(date, time, treatmentDuration, excludeBookingId) {
const allBookings = await kv.getAllItems();
const dateBookings = allBookings.filter(booking => booking.appointmentDate === date &&
['pending', 'confirmed', 'completed'].includes(booking.status) &&
booking.id !== excludeBookingId);
const bookingStartMinutes = parseTime(time);
const bookingEndMinutes = bookingStartMinutes + treatmentDuration;
// Cache treatment durations by ID to avoid N+1 lookups
const uniqueTreatmentIds = [...new Set(dateBookings.map(booking => booking.treatmentId))];
const treatmentDurationMap = new Map();
for (const treatmentId of uniqueTreatmentIds) {
const treatment = await treatmentsKV.getItem(treatmentId);
treatmentDurationMap.set(treatmentId, treatment?.duration || 60);
}
// Check for overlaps with existing bookings
for (const existingBooking of dateBookings) {
// Use cached duration or fallback to bookedDurationMinutes if available
let existingDuration = treatmentDurationMap.get(existingBooking.treatmentId) || 60;
if (existingBooking.bookedDurationMinutes) {
existingDuration = existingBooking.bookedDurationMinutes;
}
const existingStartMinutes = parseTime(existingBooking.appointmentTime);
const existingEndMinutes = existingStartMinutes + existingDuration;
// Check overlap: bookingStart < existingEnd && bookingEnd > existingStart
if (bookingStartMinutes < existingEndMinutes && bookingEndMinutes > existingStartMinutes) {
throw new Error("Dieser Zeitslot ist bereits gebucht. Bitte wähle eine andere Zeit.");
}
}
}
const CreateBookingInputSchema = z.object({
treatmentId: z.string(),
customerName: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein"),
customerEmail: z.string().email("Ungültige E-Mail-Adresse"),
customerPhone: z.string().min(5, "Telefonnummer muss mindestens 5 Zeichen lang sein").optional(),
appointmentDate: z.string(), // ISO date string
appointmentTime: z.string(), // HH:MM format
notes: z.string().optional(),
inspirationPhoto: z.string().optional(), // Base64 encoded image data
});
const BookingSchema = z.object({
id: z.string(),
treatmentId: z.string(),
customerName: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein"),
customerEmail: z.string().email("Ungültige E-Mail-Adresse").optional(),
customerPhone: z.string().min(5, "Telefonnummer muss mindestens 5 Zeichen lang sein").optional(),
appointmentDate: z.string(), // ISO date string
appointmentTime: z.string(), // HH:MM format
status: z.enum(["pending", "confirmed", "cancelled", "completed"]),
notes: z.string().optional(),
inspirationPhoto: z.string().optional(), // Base64 encoded image data
bookedDurationMinutes: z.number().optional(), // Snapshot of treatment duration at booking time
createdAt: z.string(),
// DEPRECATED: slotId is no longer used for validation, kept for backward compatibility
slotId: z.string().optional(),
});
const kv = createKV("bookings");
const recurringRulesKV = createKV("recurringRules");
const timeOffPeriodsKV = createKV("timeOffPeriods");
const treatmentsKV = createKV("treatments");
const create = os
.input(CreateBookingInputSchema)
.handler(async ({ input }) => {
// console.log("Booking create called with input:", {
// ...input,
// inspirationPhoto: input.inspirationPhoto ? `[${input.inspirationPhoto.length} chars]` : null
// });
try {
// Rate limiting check (ohne IP, falls Context-Header im Build nicht verfügbar sind)
const rateLimitResult = checkBookingRateLimit({
ip: undefined,
email: input.customerEmail,
});
if (!rateLimitResult.allowed) {
const retryMinutes = rateLimitResult.retryAfterSeconds
? Math.ceil(rateLimitResult.retryAfterSeconds / 60)
: 10;
throw new Error(`Zu viele Buchungsanfragen. Bitte versuche es in ${retryMinutes} Minute${retryMinutes > 1 ? 'n' : ''} erneut.`);
}
// Email validation before slot reservation
console.log(`Validating email: ${input.customerEmail}`);
const emailValidation = await validateEmail(input.customerEmail);
console.log(`Email validation result:`, emailValidation);
if (!emailValidation.valid) {
console.log(`Email validation failed: ${emailValidation.reason}`);
throw new Error(emailValidation.reason || "Ungültige E-Mail-Adresse");
}
// Validate appointment time is on 15-minute grid
const appointmentMinutes = parseTime(input.appointmentTime);
if (appointmentMinutes % 15 !== 0) {
throw new Error("Termine müssen auf 15-Minuten-Raster ausgerichtet sein (z.B. 09:00, 09:15, 09:30, 09:45).");
}
// Validate that the booking is not in the past
const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
if (input.appointmentDate < today) {
throw new Error("Buchungen für vergangene Termine sind nicht möglich.");
}
// For today's bookings, check if the time is not in the past
if (input.appointmentDate === today) {
const now = new Date();
const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
if (input.appointmentTime <= currentTime) {
throw new Error("Buchungen für vergangene Uhrzeiten sind nicht möglich.");
}
}
// Prevent double booking: same customer email with pending/confirmed on same date
// Skip duplicate check when DISABLE_DUPLICATE_CHECK is set
if (!process.env.DISABLE_DUPLICATE_CHECK) {
const existing = await kv.getAllItems();
const hasConflict = existing.some(b => (b.customerEmail && b.customerEmail.toLowerCase() === input.customerEmail.toLowerCase()) &&
b.appointmentDate === input.appointmentDate &&
(b.status === "pending" || b.status === "confirmed"));
if (hasConflict) {
throw new Error("Du hast bereits eine Buchung für dieses Datum. Bitte wähle einen anderen Tag oder storniere zuerst.");
}
}
// Get treatment duration for validation
const treatment = await treatmentsKV.getItem(input.treatmentId);
if (!treatment) {
throw new Error("Behandlung nicht gefunden.");
}
// Validate booking time against recurring rules
await validateBookingAgainstRules(input.appointmentDate, input.appointmentTime, treatment.duration);
// Check for booking conflicts
await checkBookingConflicts(input.appointmentDate, input.appointmentTime, treatment.duration);
const id = randomUUID();
const booking = {
id,
...input,
bookedDurationMinutes: treatment.duration, // Snapshot treatment duration
status: "pending",
createdAt: new Date().toISOString()
};
// Save the booking
await kv.setItem(id, booking);
// Notify customer: request received (pending)
void (async () => {
// Create booking access token for status viewing
const bookingAccessToken = await queryClient.cancellation.createToken({ bookingId: id });
const bookingUrl = generateUrl(`/booking/${bookingAccessToken.token}`);
const formattedDate = formatDateGerman(input.appointmentDate);
const homepageUrl = generateUrl();
const html = await renderBookingPendingHTML({
name: input.customerName,
date: input.appointmentDate,
time: input.appointmentTime,
statusUrl: bookingUrl
});
await sendEmail({
to: input.customerEmail,
subject: "Deine Terminanfrage ist eingegangen",
text: `Hallo ${input.customerName},\n\nwir haben deine Anfrage für ${formattedDate} um ${input.appointmentTime} erhalten. Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.\n\nTermin-Status ansehen: ${bookingUrl}\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
html,
}).catch(() => { });
})();
// Notify admin: new booking request (with photo if available)
void (async () => {
if (!process.env.ADMIN_EMAIL)
return;
// Get treatment name from KV
const allTreatments = await treatmentsKV.getAllItems();
const treatment = allTreatments.find(t => t.id === input.treatmentId);
const treatmentName = treatment?.name || "Unbekannte Behandlung";
const adminHtml = await renderAdminBookingNotificationHTML({
name: input.customerName,
date: input.appointmentDate,
time: input.appointmentTime,
treatment: treatmentName,
phone: input.customerPhone || "Nicht angegeben",
notes: input.notes,
hasInspirationPhoto: !!input.inspirationPhoto
});
const homepageUrl = generateUrl();
const adminText = `Neue Buchungsanfrage eingegangen:\n\n` +
`Name: ${input.customerName}\n` +
`Telefon: ${input.customerPhone || "Nicht angegeben"}\n` +
`Behandlung: ${treatmentName}\n` +
`Datum: ${formatDateGerman(input.appointmentDate)}\n` +
`Uhrzeit: ${input.appointmentTime}\n` +
`${input.notes ? `Notizen: ${input.notes}\n` : ''}` +
`Inspiration-Foto: ${input.inspirationPhoto ? 'Im Anhang verfügbar' : 'Kein Foto hochgeladen'}\n\n` +
`Zur Website: ${homepageUrl}\n\n` +
`Bitte logge dich in das Admin-Panel ein, um die Buchung zu bearbeiten.`;
if (input.inspirationPhoto) {
await sendEmailWithInspirationPhoto({
to: process.env.ADMIN_EMAIL,
subject: `Neue Buchungsanfrage - ${input.customerName}`,
text: adminText,
html: adminHtml,
}, input.inspirationPhoto, input.customerName).catch(() => { });
}
else {
await sendEmail({
to: process.env.ADMIN_EMAIL,
subject: `Neue Buchungsanfrage - ${input.customerName}`,
text: adminText,
html: adminHtml,
}).catch(() => { });
}
})();
return booking;
}
catch (error) {
console.error("Booking creation error:", error);
// Re-throw the error for oRPC to handle
throw error;
}
});
const sessionsKV = createKV("sessions");
const usersKV = createKV("users");
async function assertOwner(sessionId) {
const session = await sessionsKV.getItem(sessionId);
if (!session)
throw new Error("Invalid session");
if (new Date(session.expiresAt) < new Date())
throw new Error("Session expired");
const user = await usersKV.getItem(session.userId);
if (!user || user.role !== "owner")
throw new Error("Forbidden");
}
const updateStatus = os
.input(z.object({
sessionId: z.string(),
id: z.string(),
status: z.enum(["pending", "confirmed", "cancelled", "completed"])
}))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const booking = await kv.getItem(input.id);
if (!booking)
throw new Error("Booking not found");
const previousStatus = booking.status;
const updatedBooking = { ...booking, status: input.status };
await kv.setItem(input.id, updatedBooking);
// Note: Slot state management removed - bookings now validated against recurring rules
// Email notifications on status changes
try {
if (input.status === "confirmed") {
// Create booking access token for this booking (status + cancellation)
const bookingAccessToken = await queryClient.cancellation.createToken({ bookingId: booking.id });
const formattedDate = formatDateGerman(booking.appointmentDate);
const bookingUrl = generateUrl(`/booking/${bookingAccessToken.token}`);
const homepageUrl = generateUrl();
const html = await renderBookingConfirmedHTML({
name: booking.customerName,
date: booking.appointmentDate,
time: booking.appointmentTime,
cancellationUrl: bookingUrl, // Now points to booking status page
reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`)
});
// Get treatment information for ICS file
const allTreatments = await treatmentsKV.getAllItems();
const treatment = allTreatments.find(t => t.id === booking.treatmentId);
const treatmentName = treatment?.name || "Behandlung";
// Use bookedDurationMinutes if available, otherwise fallback to treatment duration
const treatmentDuration = booking.bookedDurationMinutes || treatment?.duration || 60;
if (booking.customerEmail) {
await sendEmailWithAGBAndCalendar({
to: booking.customerEmail,
subject: "Dein Termin wurde bestätigt - AGB im Anhang",
text: `Hallo ${booking.customerName},\n\nwir haben deinen Termin am ${formattedDate} um ${booking.appointmentTime} bestätigt.\n\nWichtiger Hinweis: Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.\n\nTermin-Status ansehen und verwalten: ${bookingUrl}\nFalls du den Termin stornieren möchtest, kannst du das über den obigen Link tun.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nBis bald!\nStargirlnails Kiel`,
html,
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
}, {
date: booking.appointmentDate,
time: booking.appointmentTime,
durationMinutes: treatmentDuration,
customerName: booking.customerName,
treatmentName: treatmentName
});
}
}
else if (input.status === "cancelled") {
const formattedDate = formatDateGerman(booking.appointmentDate);
const homepageUrl = generateUrl();
const html = await renderBookingCancelledHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime });
if (booking.customerEmail) {
await sendEmail({
to: booking.customerEmail,
subject: "Dein Termin wurde abgesagt",
text: `Hallo ${booking.customerName},\n\nleider wurde dein Termin am ${formattedDate} um ${booking.appointmentTime} abgesagt. Bitte buche einen neuen Termin.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
html,
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
});
}
}
}
catch (e) {
console.error("Email send failed:", e);
}
return updatedBooking;
});
const remove = os
.input(z.object({
sessionId: z.string(),
id: z.string(),
sendEmail: z.boolean().optional().default(false)
}))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const booking = await kv.getItem(input.id);
if (!booking)
throw new Error("Booking not found");
// Guard against deletion of past bookings or completed bookings
const today = new Date().toISOString().split("T")[0];
const isPastDate = booking.appointmentDate < today;
const isCompleted = booking.status === 'completed';
if (isPastDate || isCompleted) {
// For past/completed bookings, disable email sending to avoid confusing customers
if (input.sendEmail) {
console.log(`Email sending disabled for past/completed booking ${input.id}`);
}
input.sendEmail = false;
}
const wasAlreadyCancelled = booking.status === 'cancelled';
const updatedBooking = { ...booking, status: "cancelled" };
await kv.setItem(input.id, updatedBooking);
if (input.sendEmail && !wasAlreadyCancelled && booking.customerEmail) {
try {
const formattedDate = formatDateGerman(booking.appointmentDate);
const homepageUrl = generateUrl();
const html = await renderBookingCancelledHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime });
await sendEmail({
to: booking.customerEmail,
subject: "Dein Termin wurde abgesagt",
text: `Hallo ${booking.customerName},\n\nleider wurde dein Termin am ${formattedDate} um ${booking.appointmentTime} abgesagt. Bitte buche einen neuen Termin.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
html,
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
});
}
catch (e) {
console.error("Email send failed:", e);
}
}
return updatedBooking;
});
// Admin-only manual booking creation (immediately confirmed)
const createManual = os
.input(z.object({
sessionId: z.string(),
treatmentId: z.string(),
customerName: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein"),
customerEmail: z.string().email("Ungültige E-Mail-Adresse").optional(),
customerPhone: z.string().min(5, "Telefonnummer muss mindestens 5 Zeichen lang sein").optional(),
appointmentDate: z.string(),
appointmentTime: z.string(),
notes: z.string().optional(),
}))
.handler(async ({ input }) => {
// Admin authentication
await assertOwner(input.sessionId);
// Validate appointment time is on 15-minute grid
const appointmentMinutes = parseTime(input.appointmentTime);
if (appointmentMinutes % 15 !== 0) {
throw new Error("Termine müssen auf 15-Minuten-Raster ausgerichtet sein (z.B. 09:00, 09:15, 09:30, 09:45).");
}
// Validate that the booking is not in the past
const today = new Date().toISOString().split("T")[0];
if (input.appointmentDate < today) {
throw new Error("Buchungen für vergangene Termine sind nicht möglich.");
}
// For today's bookings, check if the time is not in the past
if (input.appointmentDate === today) {
const now = new Date();
const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
if (input.appointmentTime <= currentTime) {
throw new Error("Buchungen für vergangene Uhrzeiten sind nicht möglich.");
}
}
// Get treatment duration for validation
const treatment = await treatmentsKV.getItem(input.treatmentId);
if (!treatment) {
throw new Error("Behandlung nicht gefunden.");
}
// Validate booking time against recurring rules
await validateBookingAgainstRules(input.appointmentDate, input.appointmentTime, treatment.duration);
// Check for booking conflicts
await checkBookingConflicts(input.appointmentDate, input.appointmentTime, treatment.duration);
const id = randomUUID();
const booking = {
id,
treatmentId: input.treatmentId,
customerName: input.customerName,
customerEmail: input.customerEmail,
customerPhone: input.customerPhone,
appointmentDate: input.appointmentDate,
appointmentTime: input.appointmentTime,
notes: input.notes,
bookedDurationMinutes: treatment.duration,
status: "confirmed",
createdAt: new Date().toISOString()
};
// Save the booking
await kv.setItem(id, booking);
// Create booking access token for status viewing and cancellation (always create token)
const bookingAccessToken = await queryClient.cancellation.createToken({ bookingId: id });
// Send confirmation email if email is provided
if (input.customerEmail) {
void (async () => {
try {
const bookingUrl = generateUrl(`/booking/${bookingAccessToken.token}`);
const formattedDate = formatDateGerman(input.appointmentDate);
const homepageUrl = generateUrl();
const html = await renderBookingConfirmedHTML({
name: input.customerName,
date: input.appointmentDate,
time: input.appointmentTime,
cancellationUrl: bookingUrl,
reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`)
});
await sendEmailWithAGBAndCalendar({
to: input.customerEmail,
subject: "Dein Termin wurde bestätigt - AGB im Anhang",
text: `Hallo ${input.customerName},\n\nwir haben deinen Termin am ${formattedDate} um ${input.appointmentTime} bestätigt.\n\nWichtiger Hinweis: Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.\n\nTermin-Status ansehen und verwalten: ${bookingUrl}\nFalls du den Termin stornieren möchtest, kannst du das über den obigen Link tun.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nBis bald!\nStargirlnails Kiel`,
html,
}, {
date: input.appointmentDate,
time: input.appointmentTime,
durationMinutes: treatment.duration,
customerName: input.customerName,
treatmentName: treatment.name
});
}
catch (e) {
console.error("Email send failed for manual booking:", e);
}
})();
}
// Optionally return the token in the RPC response for UI to copy/share (admin usage only)
return {
...booking,
bookingAccessToken: bookingAccessToken.token
};
});
const list = os.handler(async () => {
return kv.getAllItems();
});
const get = os.input(z.string()).handler(async ({ input }) => {
return kv.getItem(input);
});
const getByDate = os
.input(z.string()) // YYYY-MM-DD format
.handler(async ({ input }) => {
const allBookings = await kv.getAllItems();
return allBookings.filter(booking => booking.appointmentDate === input);
});
const live = {
list: os.handler(async function* ({ signal }) {
yield call(list, {}, { signal });
for await (const _ of kv.subscribe()) {
yield call(list, {}, { signal });
}
}),
byDate: os
.input(z.string())
.handler(async function* ({ input, signal }) {
yield call(getByDate, input, { signal });
for await (const _ of kv.subscribe()) {
yield call(getByDate, input, { signal });
}
}),
};
export const router = {
create,
createManual,
updateStatus,
remove,
list,
get,
getByDate,
live,
// Admin proposes a reschedule for a confirmed booking
proposeReschedule: os
.input(z.object({
sessionId: z.string(),
bookingId: z.string(),
proposedDate: z.string(),
proposedTime: z.string(),
}))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const booking = await kv.getItem(input.bookingId);
if (!booking)
throw new Error("Booking not found");
if (booking.status !== "confirmed")
throw new Error("Nur bestätigte Termine können umgebucht werden.");
const treatment = await treatmentsKV.getItem(booking.treatmentId);
if (!treatment)
throw new Error("Behandlung nicht gefunden.");
// Validate grid and not in past
const appointmentMinutes = parseTime(input.proposedTime);
if (appointmentMinutes % 15 !== 0) {
throw new Error("Termine müssen auf 15-Minuten-Raster ausgerichtet sein (z.B. 09:00, 09:15, 09:30, 09:45).");
}
const today = new Date().toISOString().split("T")[0];
if (input.proposedDate < today) {
throw new Error("Buchungen für vergangene Termine sind nicht möglich.");
}
if (input.proposedDate === today) {
const now = new Date();
const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
if (input.proposedTime <= currentTime) {
throw new Error("Buchungen für vergangene Uhrzeiten sind nicht möglich.");
}
}
await validateBookingAgainstRules(input.proposedDate, input.proposedTime, booking.bookedDurationMinutes || treatment.duration);
await checkBookingConflicts(input.proposedDate, input.proposedTime, booking.bookedDurationMinutes || treatment.duration, booking.id);
// Invalidate and create new reschedule token via cancellation router
const res = await queryClient.cancellation.createRescheduleToken({
bookingId: booking.id,
proposedDate: input.proposedDate,
proposedTime: input.proposedTime,
});
const acceptUrl = generateUrl(`/booking/${res.token}?action=accept`);
const declineUrl = generateUrl(`/booking/${res.token}?action=decline`);
// Send proposal email to customer
if (booking.customerEmail) {
const html = await renderBookingRescheduleProposalHTML({
name: booking.customerName,
originalDate: booking.appointmentDate,
originalTime: booking.appointmentTime,
proposedDate: input.proposedDate,
proposedTime: input.proposedTime,
treatmentName: (await treatmentsKV.getItem(booking.treatmentId))?.name || "Behandlung",
acceptUrl,
declineUrl,
expiresAt: res.expiresAt,
});
await sendEmail({
to: booking.customerEmail,
subject: "Vorschlag zur Terminänderung",
text: `Hallo ${booking.customerName}, wir schlagen vor, deinen Termin von ${formatDateGerman(booking.appointmentDate)} ${booking.appointmentTime} auf ${formatDateGerman(input.proposedDate)} ${input.proposedTime} zu verschieben. Akzeptieren: ${acceptUrl} | Ablehnen: ${declineUrl}`,
html,
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
}).catch(() => { });
}
return { success: true, token: res.token };
}),
// Customer accepts reschedule via token
acceptReschedule: os
.input(z.object({ token: z.string() }))
.handler(async ({ input }) => {
const proposal = await queryClient.cancellation.getRescheduleProposal({ token: input.token });
const booking = await kv.getItem(proposal.booking.id);
if (!booking)
throw new Error("Booking not found");
if (booking.status !== "confirmed")
throw new Error("Buchung ist nicht mehr in bestätigtem Zustand.");
const treatment = await treatmentsKV.getItem(booking.treatmentId);
const duration = booking.bookedDurationMinutes || treatment?.duration || 60;
// Re-validate slot to ensure still available
await validateBookingAgainstRules(proposal.proposed.date, proposal.proposed.time, duration);
await checkBookingConflicts(proposal.proposed.date, proposal.proposed.time, duration, booking.id);
const updated = { ...booking, appointmentDate: proposal.proposed.date, appointmentTime: proposal.proposed.time };
await kv.setItem(updated.id, updated);
// Remove token
await queryClient.cancellation.removeRescheduleToken({ token: input.token });
// Send confirmation to customer (no BCC to avoid duplicate admin emails)
if (updated.customerEmail) {
const bookingAccessToken = await queryClient.cancellation.createToken({ bookingId: updated.id });
const html = await renderBookingConfirmedHTML({
name: updated.customerName,
date: updated.appointmentDate,
time: updated.appointmentTime,
cancellationUrl: generateUrl(`/booking/${bookingAccessToken.token}`),
reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`),
});
await sendEmailWithAGBAndCalendar({
to: updated.customerEmail,
subject: "Terminänderung bestätigt",
text: `Hallo ${updated.customerName}, dein neuer Termin ist am ${formatDateGerman(updated.appointmentDate)} um ${updated.appointmentTime}.`,
html,
}, {
date: updated.appointmentDate,
time: updated.appointmentTime,
durationMinutes: duration,
customerName: updated.customerName,
treatmentName: (await treatmentsKV.getItem(updated.treatmentId))?.name || "Behandlung",
}).catch(() => { });
}
if (process.env.ADMIN_EMAIL) {
const adminHtml = await renderAdminRescheduleAcceptedHTML({
customerName: updated.customerName,
originalDate: proposal.original.date,
originalTime: proposal.original.time,
newDate: updated.appointmentDate,
newTime: updated.appointmentTime,
treatmentName: (await treatmentsKV.getItem(updated.treatmentId))?.name || "Behandlung",
});
await sendEmail({
to: process.env.ADMIN_EMAIL,
subject: `Reschedule akzeptiert - ${updated.customerName}`,
text: `Reschedule akzeptiert: ${updated.customerName} von ${formatDateGerman(proposal.original.date)} ${proposal.original.time} auf ${formatDateGerman(updated.appointmentDate)} ${updated.appointmentTime}.`,
html: adminHtml,
}).catch(() => { });
}
return { success: true, message: `Termin auf ${formatDateGerman(updated.appointmentDate)} um ${updated.appointmentTime} aktualisiert.` };
}),
// Customer declines reschedule via token
declineReschedule: os
.input(z.object({ token: z.string() }))
.handler(async ({ input }) => {
const proposal = await queryClient.cancellation.getRescheduleProposal({ token: input.token });
const booking = await kv.getItem(proposal.booking.id);
if (!booking)
throw new Error("Booking not found");
// Remove token
await queryClient.cancellation.removeRescheduleToken({ token: input.token });
// Notify customer that original stays
if (booking.customerEmail) {
const bookingAccessToken = await queryClient.cancellation.createToken({ bookingId: booking.id });
await sendEmail({
to: booking.customerEmail,
subject: "Terminänderung abgelehnt",
text: `Du hast den Vorschlag zur Terminänderung abgelehnt. Dein ursprünglicher Termin am ${formatDateGerman(booking.appointmentDate)} um ${booking.appointmentTime} bleibt bestehen.`,
html: await renderBookingConfirmedHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime, cancellationUrl: generateUrl(`/booking/${bookingAccessToken.token}`), reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`) }),
}).catch(() => { });
}
// Notify admin
if (process.env.ADMIN_EMAIL) {
const html = await renderAdminRescheduleDeclinedHTML({
customerName: booking.customerName,
originalDate: proposal.original.date,
originalTime: proposal.original.time,
proposedDate: proposal.proposed.date,
proposedTime: proposal.proposed.time,
treatmentName: (await treatmentsKV.getItem(booking.treatmentId))?.name || "Behandlung",
customerEmail: booking.customerEmail,
customerPhone: booking.customerPhone,
});
await sendEmail({
to: process.env.ADMIN_EMAIL,
subject: `Reschedule abgelehnt - ${booking.customerName}`,
text: `Abgelehnt: ${booking.customerName}. Ursprünglich: ${formatDateGerman(proposal.original.date)} ${proposal.original.time}. Vorschlag: ${formatDateGerman(proposal.proposed.date)} ${proposal.proposed.time}.`,
html,
}).catch(() => { });
}
return { success: true, message: "Du hast den Vorschlag abgelehnt. Dein ursprünglicher Termin bleibt bestehen." };
}),
// CalDAV Token für Admin generieren
generateCalDAVToken: os
.input(z.object({ sessionId: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
// Generiere einen sicheren Token für CalDAV-Zugriff
const token = randomUUID();
// Hole Session-Daten für Token-Erstellung
const session = await sessionsKV.getItem(input.sessionId);
if (!session)
throw new Error("Session nicht gefunden");
// Speichere Token mit Ablaufzeit (24 Stunden)
const tokenData = {
id: token,
sessionId: input.sessionId,
userId: session.userId, // Benötigt für Session-Typ
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), // 24 Stunden
createdAt: new Date().toISOString(),
};
// Verwende den sessionsKV Store für Token-Speicherung
await sessionsKV.setItem(token, tokenData);
const domain = process.env.DOMAIN || 'localhost:3000';
const protocol = domain.includes('localhost') ? 'http' : 'https';
const caldavUrl = `${protocol}://${domain}/caldav/calendar/events.ics?token=${token}`;
return {
token,
caldavUrl,
expiresAt: tokenData.expiresAt,
instructions: {
title: "CalDAV-Kalender abonnieren",
steps: [
"Kopiere die CalDAV-URL unten",
"Füge sie in deiner Kalender-App als Abonnement hinzu:",
"- Outlook: Datei → Konto hinzufügen → Internetkalender",
"- Google Calendar: Andere Kalender hinzufügen → Von URL",
"- Apple Calendar: Abonnement → Neue Abonnements",
"- Thunderbird: Kalender hinzufügen → Im Netzwerk",
"Der Kalender wird automatisch aktualisiert"
],
note: "Dieser Token ist 24 Stunden gültig. Bei Bedarf kannst du einen neuen Token generieren."
}
};
}),
// Admin sendet Nachricht an Kunden
sendCustomerMessage: os
.input(z.object({
sessionId: z.string(),
bookingId: z.string(),
message: z.string().min(1, "Nachricht darf nicht leer sein").max(5000, "Nachricht ist zu lang (max. 5000 Zeichen)"),
}))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const booking = await kv.getItem(input.bookingId);
if (!booking)
throw new Error("Buchung nicht gefunden");
// Check if booking has customer email
if (!booking.customerEmail) {
throw new Error("Diese Buchung hat keine E-Mail-Adresse. Bitte kontaktiere den Kunden telefonisch.");
}
// Check if booking is in the future
const today = new Date().toISOString().split("T")[0];
const bookingDate = booking.appointmentDate;
if (bookingDate < today) {
throw new Error("Nachrichten können nur für zukünftige Termine gesendet werden.");
}
// Get treatment name for context
const treatment = await treatmentsKV.getItem(booking.treatmentId);
const treatmentName = treatment?.name || "Behandlung";
// Prepare email with Reply-To header
const ownerName = process.env.OWNER_NAME || "Stargirlnails Kiel";
const emailFrom = process.env.EMAIL_FROM || "Stargirlnails <no-reply@stargirlnails.de>";
const replyToEmail = process.env.ADMIN_EMAIL;
const formattedDate = formatDateGerman(bookingDate);
const html = await renderCustomerMessageHTML({
customerName: booking.customerName,
message: input.message,
appointmentDate: bookingDate,
appointmentTime: booking.appointmentTime,
treatmentName: treatmentName,
});
const textContent = `Hallo ${booking.customerName},\n\nZu deinem Termin:\nBehandlung: ${treatmentName}\nDatum: ${formattedDate}\nUhrzeit: ${booking.appointmentTime}\n\nNachricht von ${ownerName}:\n${input.message}\n\nBei Fragen oder Anliegen kannst du einfach auf diese E-Mail antworten wir helfen dir gerne weiter!\n\nLiebe Grüße,\n${ownerName}`;
// Send email with BCC to admin for monitoring
// Note: Not using explicit 'from' or 'replyTo' to match behavior of other system emails
console.log(`Sending customer message to ${booking.customerEmail} for booking ${input.bookingId}`);
console.log(`Email config: from=${emailFrom}, replyTo=${replyToEmail}, bcc=${replyToEmail}`);
const emailResult = await sendEmail({
to: booking.customerEmail,
subject: `Nachricht zu deinem Termin am ${formattedDate}`,
text: textContent,
html: html,
bcc: replyToEmail ? [replyToEmail] : undefined,
});
if (!emailResult.success) {
console.error(`Failed to send customer message to ${booking.customerEmail}`);
throw new Error("E-Mail konnte nicht versendet werden. Bitte überprüfe die E-Mail-Konfiguration oder versuche es später erneut.");
}
console.log(`Successfully sent customer message to ${booking.customerEmail}`);
return {
success: true,
message: `Nachricht wurde erfolgreich an ${booking.customerEmail} gesendet.`
};
}),
};

View File

@@ -0,0 +1,310 @@
import { os } from "@orpc/server";
import { z } from "zod";
import { createKV } from "../lib/create-kv.js";
import { createKV as createAvailabilityKV } from "../lib/create-kv.js";
import { randomUUID } from "crypto";
// Schema for booking access token (used for both status viewing and cancellation)
const BookingAccessTokenSchema = z.object({
id: z.string(),
bookingId: z.string(),
token: z.string(),
expiresAt: z.string(),
createdAt: z.string(),
purpose: z.enum(["booking_access", "reschedule_proposal"]), // Extended for reschedule proposals
// Optional metadata for reschedule proposals
proposedDate: z.string().optional(),
proposedTime: z.string().optional(),
originalDate: z.string().optional(),
originalTime: z.string().optional(),
});
const cancellationKV = createKV("cancellation_tokens");
const bookingsKV = createKV("bookings");
const availabilityKV = createAvailabilityKV("availability");
// Helper function to convert date from yyyy-mm-dd to dd.mm.yyyy
function formatDateGerman(dateString) {
const [year, month, day] = dateString.split('-');
return `${day}.${month}.${year}`;
}
// Helper to invalidate all reschedule tokens for a specific booking
async function invalidateRescheduleTokensForBooking(bookingId) {
const tokens = await cancellationKV.getAllItems();
const related = tokens.filter(t => t.bookingId === bookingId && t.purpose === "reschedule_proposal");
for (const tok of related) {
await cancellationKV.removeItem(tok.id);
}
}
// Create cancellation token for a booking
const createToken = os
.input(z.object({ bookingId: z.string() }))
.handler(async ({ input }) => {
const booking = await bookingsKV.getItem(input.bookingId);
if (!booking) {
throw new Error("Booking not found");
}
if (booking.status === "cancelled") {
throw new Error("Booking is already cancelled");
}
// Create token that expires in 30 days
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 30);
const token = randomUUID();
const cancellationToken = {
id: randomUUID(),
bookingId: input.bookingId,
token,
expiresAt: expiresAt.toISOString(),
createdAt: new Date().toISOString(),
purpose: "booking_access",
};
await cancellationKV.setItem(cancellationToken.id, cancellationToken);
return { token, expiresAt: expiresAt.toISOString() };
});
// Get booking details by token
const getBookingByToken = os
.input(z.object({ token: z.string() }))
.handler(async ({ input }) => {
const tokens = await cancellationKV.getAllItems();
const validToken = tokens.find(t => t.token === input.token &&
new Date(t.expiresAt) > new Date() &&
t.purpose === 'booking_access');
if (!validToken) {
throw new Error("Invalid or expired cancellation token");
}
const booking = await bookingsKV.getItem(validToken.bookingId);
if (!booking) {
throw new Error("Booking not found");
}
// Get treatment details
const treatmentsKV = createKV("treatments");
const treatment = await treatmentsKV.getItem(booking.treatmentId);
// Calculate if cancellation is still possible
const minStornoTimespan = parseInt(process.env.MIN_STORNO_TIMESPAN || "24");
const appointmentDateTime = new Date(`${booking.appointmentDate}T${booking.appointmentTime}:00`);
const now = new Date();
const timeDifferenceHours = (appointmentDateTime.getTime() - now.getTime()) / (1000 * 60 * 60);
const canCancel = timeDifferenceHours >= minStornoTimespan &&
booking.status !== "cancelled" &&
booking.status !== "completed";
return {
id: booking.id,
customerName: booking.customerName,
customerEmail: booking.customerEmail,
customerPhone: booking.customerPhone,
appointmentDate: booking.appointmentDate,
appointmentTime: booking.appointmentTime,
treatmentId: booking.treatmentId,
treatmentName: treatment?.name || "Unbekannte Behandlung",
treatmentDuration: treatment?.duration || 60,
treatmentPrice: treatment?.price || 0,
status: booking.status,
notes: booking.notes,
formattedDate: formatDateGerman(booking.appointmentDate),
createdAt: booking.createdAt,
canCancel,
hoursUntilAppointment: Math.max(0, Math.round(timeDifferenceHours)),
};
});
// Cancel booking by token
const cancelByToken = os
.input(z.object({ token: z.string() }))
.handler(async ({ input }) => {
const tokens = await cancellationKV.getAllItems();
const validToken = tokens.find(t => t.token === input.token &&
new Date(t.expiresAt) > new Date());
if (!validToken) {
throw new Error("Invalid or expired cancellation token");
}
const booking = await bookingsKV.getItem(validToken.bookingId);
if (!booking) {
throw new Error("Booking not found");
}
// Check if booking is already cancelled
if (booking.status === "cancelled") {
throw new Error("Booking is already cancelled");
}
// Check minimum cancellation timespan from environment variable
const minStornoTimespan = parseInt(process.env.MIN_STORNO_TIMESPAN || "24"); // Default: 24 hours
const appointmentDateTime = new Date(`${booking.appointmentDate}T${booking.appointmentTime}:00`);
const now = new Date();
const timeDifferenceHours = (appointmentDateTime.getTime() - now.getTime()) / (1000 * 60 * 60);
if (timeDifferenceHours < minStornoTimespan) {
throw new Error(`Stornierung ist nur bis ${minStornoTimespan} Stunden vor dem Termin möglich. Der Termin liegt nur noch ${Math.round(timeDifferenceHours)} Stunden in der Zukunft.`);
}
// Check if booking is in the past (additional safety check)
const today = new Date().toISOString().split("T")[0];
if (booking.appointmentDate < today) {
throw new Error("Cannot cancel past bookings");
}
// For today's bookings, check if the time is not in the past
if (booking.appointmentDate === today) {
const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
if (booking.appointmentTime <= currentTime) {
throw new Error("Cannot cancel bookings that have already started");
}
}
// Update booking status
const updatedBooking = { ...booking, status: "cancelled" };
await bookingsKV.setItem(booking.id, updatedBooking);
// Free the slot if it exists
if (booking.slotId) {
const slot = await availabilityKV.getItem(booking.slotId);
if (slot) {
const updatedSlot = {
...slot,
status: "free",
reservedByBookingId: undefined,
};
await availabilityKV.setItem(slot.id, updatedSlot);
}
}
// Invalidate the token
await cancellationKV.removeItem(validToken.id);
return {
success: true,
message: "Booking cancelled successfully",
formattedDate: formatDateGerman(booking.appointmentDate),
};
});
export const router = {
createToken,
getBookingByToken,
cancelByToken,
// Create a reschedule proposal token (48h expiry)
createRescheduleToken: os
.input(z.object({ bookingId: z.string(), proposedDate: z.string(), proposedTime: z.string() }))
.handler(async ({ input }) => {
const booking = await bookingsKV.getItem(input.bookingId);
if (!booking) {
throw new Error("Booking not found");
}
if (booking.status === "cancelled" || booking.status === "completed") {
throw new Error("Reschedule not allowed for this booking");
}
// Invalidate existing reschedule proposals for this booking
await invalidateRescheduleTokensForBooking(input.bookingId);
// Create token that expires in 48 hours
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 48);
const token = randomUUID();
const rescheduleToken = {
id: randomUUID(),
bookingId: input.bookingId,
token,
expiresAt: expiresAt.toISOString(),
createdAt: new Date().toISOString(),
purpose: "reschedule_proposal",
proposedDate: input.proposedDate,
proposedTime: input.proposedTime,
originalDate: booking.appointmentDate,
originalTime: booking.appointmentTime,
};
await cancellationKV.setItem(rescheduleToken.id, rescheduleToken);
return { token, expiresAt: expiresAt.toISOString() };
}),
// Get reschedule proposal details by token
getRescheduleProposal: os
.input(z.object({ token: z.string() }))
.handler(async ({ input }) => {
const tokens = await cancellationKV.getAllItems();
const proposal = tokens.find(t => t.token === input.token && t.purpose === "reschedule_proposal");
if (!proposal) {
throw new Error("Ungültiger Reschedule-Token");
}
const booking = await bookingsKV.getItem(proposal.bookingId);
if (!booking) {
throw new Error("Booking not found");
}
const treatmentsKV = createKV("treatments");
const treatment = await treatmentsKV.getItem(booking.treatmentId);
const now = new Date();
const isExpired = new Date(proposal.expiresAt) <= now;
const hoursUntilExpiry = isExpired ? 0 : Math.max(0, Math.round((new Date(proposal.expiresAt).getTime() - now.getTime()) / (1000 * 60 * 60)));
return {
booking: {
id: booking.id,
customerName: booking.customerName,
customerEmail: booking.customerEmail,
customerPhone: booking.customerPhone,
status: booking.status,
treatmentId: booking.treatmentId,
treatmentName: treatment?.name || "Unbekannte Behandlung",
},
original: {
date: proposal.originalDate || booking.appointmentDate,
time: proposal.originalTime || booking.appointmentTime,
},
proposed: {
date: proposal.proposedDate,
time: proposal.proposedTime,
},
expiresAt: proposal.expiresAt,
hoursUntilExpiry,
isExpired,
canRespond: booking.status === "confirmed" && !isExpired,
};
}),
// Helper endpoint to remove a reschedule token by value (used after accept/decline)
removeRescheduleToken: os
.input(z.object({ token: z.string() }))
.handler(async ({ input }) => {
const tokens = await cancellationKV.getAllItems();
const proposal = tokens.find(t => t.token === input.token && t.purpose === "reschedule_proposal");
if (proposal) {
await cancellationKV.removeItem(proposal.id);
}
return { success: true };
}),
// Clean up expired reschedule proposals and notify admin
sweepExpiredRescheduleProposals: os
.handler(async () => {
const tokens = await cancellationKV.getAllItems();
const now = new Date();
const expiredProposals = tokens.filter(t => t.purpose === "reschedule_proposal" &&
new Date(t.expiresAt) <= now);
if (expiredProposals.length === 0) {
return { success: true, expiredCount: 0 };
}
// Get booking details for each expired proposal
const expiredDetails = [];
for (const proposal of expiredProposals) {
const booking = await bookingsKV.getItem(proposal.bookingId);
if (booking) {
const treatmentsKV = createKV("treatments");
const treatment = await treatmentsKV.getItem(booking.treatmentId);
expiredDetails.push({
customerName: booking.customerName,
originalDate: proposal.originalDate || booking.appointmentDate,
originalTime: proposal.originalTime || booking.appointmentTime,
proposedDate: proposal.proposedDate,
proposedTime: proposal.proposedTime,
treatmentName: treatment?.name || "Unbekannte Behandlung",
customerEmail: booking.customerEmail,
customerPhone: booking.customerPhone,
expiredAt: proposal.expiresAt,
});
}
// Remove the expired token
await cancellationKV.removeItem(proposal.id);
}
// Notify admin if there are expired proposals
if (expiredDetails.length > 0 && process.env.ADMIN_EMAIL) {
try {
const { renderAdminRescheduleExpiredHTML } = await import("../lib/email-templates.js");
const { sendEmail } = await import("../lib/email.js");
const html = await renderAdminRescheduleExpiredHTML({
expiredProposals: expiredDetails,
});
await sendEmail({
to: process.env.ADMIN_EMAIL,
subject: `${expiredDetails.length} abgelaufene Terminänderungsvorschläge`,
text: `Es sind ${expiredDetails.length} Terminänderungsvorschläge abgelaufen. Details in der HTML-Version.`,
html,
});
}
catch (error) {
console.error("Failed to send admin notification for expired proposals:", error);
}
}
return { success: true, expiredCount: expiredDetails.length };
}),
};

View File

@@ -0,0 +1,79 @@
import OpenAI from "openai";
import { os } from "@orpc/server";
import { z } from "zod";
import { zodResponseFormat } from "../../lib/openai";
if (!process.env.OPENAI_BASE_URL) {
throw new Error("OPENAI_BASE_URL is not set");
}
if (!process.env.OPENAI_API_KEY) {
throw new Error("OPENAI_API_KEY is not set");
}
const openai = new OpenAI({
baseURL: process.env.OPENAI_BASE_URL,
apiKey: process.env.OPENAI_API_KEY,
});
if (!process.env.OPENAI_DEFAULT_MODEL) {
throw new Error("OPENAI_DEFAULT_MODEL is not set");
}
const DEFAULT_MODEL = process.env.OPENAI_DEFAULT_MODEL;
const ChatCompletionInputSchema = z.object({
message: z.string(),
systemPrompt: z.string().optional(),
});
const GeneratePersonInputSchema = z.object({
prompt: z.string(),
});
const complete = os
.input(ChatCompletionInputSchema)
.handler(async ({ input }) => {
const { message, systemPrompt } = input;
const completion = await openai.chat.completions.create({
model: DEFAULT_MODEL,
messages: [
...(systemPrompt
? [{ role: "system", content: systemPrompt }]
: []),
{ role: "user", content: message },
],
});
return {
response: completion.choices[0]?.message?.content || "",
};
});
// Object generation schemas only support nullability, not optionality.
// Use .nullable() instead of .optional() for fields that may not have values.
const DemoSchema = z.object({
name: z.string().describe("The name of the person"),
age: z.number().describe("The age of the person"),
occupation: z.string().describe("The occupation of the person"),
bio: z.string().describe("The bio of the person"),
nickname: z
.string()
.nullable()
.describe("The person's nickname, if they have one"),
});
const generate = os
.input(GeneratePersonInputSchema)
.handler(async ({ input }) => {
const completion = await openai.chat.completions.parse({
model: DEFAULT_MODEL,
messages: [
{
role: "user",
content: `Generate a person based on this prompt: ${input.prompt}`,
},
],
response_format: zodResponseFormat(DemoSchema, "person"),
});
const person = completion.choices[0]?.message?.parsed;
if (!person) {
throw new Error("No parsed data received from OpenAI");
}
return {
person,
};
});
export const router = {
complete,
generate,
};

View File

@@ -0,0 +1,4 @@
import { router as storageRouter } from "./storage.js";
export const demo = {
storage: storageRouter,
};

View File

@@ -0,0 +1,42 @@
import { call, os } from "@orpc/server";
import { z } from "zod";
import { randomUUID } from "crypto";
import { createKV } from "../../lib/create-kv.js";
const DemoSchema = z.object({
id: z.string(),
value: z.string(),
});
// createKV provides simple key-value storage with publisher/subscriber support
// perfect for live queries and small amounts of data
const kv = createKV("demo");
// Handler with input validation using .input() and schema
const create = os
.input(DemoSchema.omit({ id: true }))
.handler(async ({ input }) => {
const id = randomUUID();
const item = { id, value: input.value };
await kv.setItem(id, item);
});
const remove = os.input(z.string()).handler(async ({ input }) => {
await kv.removeItem(input);
});
// Handler without input - returns all items
const list = os.handler(async () => {
return kv.getAllItems();
});
// Live data stream using generator function
// Yields initial data, then subscribes to changes for real-time updates
const live = {
list: os.handler(async function* ({ signal }) {
yield call(list, {}, { signal });
for await (const _ of kv.subscribe()) {
yield call(list, {}, { signal });
}
}),
};
export const router = {
create,
remove,
list,
live,
};

131
server-dist/rpc/gallery.js Normal file
View File

@@ -0,0 +1,131 @@
import { call, os } from "@orpc/server";
import { z } from "zod";
import { randomUUID } from "crypto";
import { createKV } from "../lib/create-kv.js";
import { assertOwner } from "../lib/auth.js";
// Schema Definition
const GalleryPhotoSchema = z.object({
id: z.string(),
base64Data: z.string(),
title: z.string().optional().default(""),
order: z.number().int(),
createdAt: z.string(),
cover: z.boolean().optional().default(false),
});
// KV Storage
const galleryPhotosKV = createKV("galleryPhotos");
// Authentication centralized in ../lib/auth.ts
// CRUD Endpoints
const uploadPhoto = os
.input(z.object({
sessionId: z.string(),
base64Data: z
.string()
.regex(/^data:image\/(png|jpe?g|webp|gif);base64,/i, 'Unsupported image format'),
title: z.string().optional().default(""),
}))
.handler(async ({ input }) => {
try {
await assertOwner(input.sessionId);
const id = randomUUID();
const existing = await galleryPhotosKV.getAllItems();
const maxOrder = existing.length > 0 ? Math.max(...existing.map((p) => p.order)) : -1;
const nextOrder = maxOrder + 1;
const photo = {
id,
base64Data: input.base64Data,
title: input.title ?? "",
order: nextOrder,
createdAt: new Date().toISOString(),
cover: false,
};
await galleryPhotosKV.setItem(id, photo);
return photo;
}
catch (err) {
console.error("gallery.uploadPhoto error", err);
throw err;
}
});
const setCoverPhoto = os
.input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const all = await galleryPhotosKV.getAllItems();
let updatedCover = null;
for (const p of all) {
const isCover = p.id === input.id;
const next = { ...p, cover: isCover };
await galleryPhotosKV.setItem(p.id, next);
if (isCover)
updatedCover = next;
}
return updatedCover;
});
const deletePhoto = os
.input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
await galleryPhotosKV.removeItem(input.id);
});
const updatePhotoOrder = os
.input(z.object({
sessionId: z.string(),
photoOrders: z.array(z.object({ id: z.string(), order: z.number().int() })),
}))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const updated = [];
for (const { id, order } of input.photoOrders) {
const existing = await galleryPhotosKV.getItem(id);
if (!existing)
continue;
const updatedPhoto = { ...existing, order };
await galleryPhotosKV.setItem(id, updatedPhoto);
updated.push(updatedPhoto);
}
const all = await galleryPhotosKV.getAllItems();
return all.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
});
const listPhotos = os.handler(async () => {
const all = await galleryPhotosKV.getAllItems();
return all.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
});
const adminListPhotos = os
.input(z.object({ sessionId: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const all = await galleryPhotosKV.getAllItems();
return all.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
});
// Live Queries
const live = {
listPhotos: os.handler(async function* ({ signal }) {
yield call(listPhotos, {}, { signal });
for await (const _ of galleryPhotosKV.subscribe()) {
yield call(listPhotos, {}, { signal });
}
}),
adminListPhotos: os
.input(z.object({ sessionId: z.string() }))
.handler(async function* ({ input, signal }) {
await assertOwner(input.sessionId);
const all = await galleryPhotosKV.getAllItems();
const sorted = all.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
yield sorted;
for await (const _ of galleryPhotosKV.subscribe()) {
const updated = await galleryPhotosKV.getAllItems();
const sortedUpdated = updated.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
yield sortedUpdated;
}
}),
};
export const router = {
uploadPhoto,
deletePhoto,
updatePhotoOrder,
listPhotos,
adminListPhotos,
setCoverPhoto,
live,
};

22
server-dist/rpc/index.js Normal file
View File

@@ -0,0 +1,22 @@
import { demo } from "./demo/index.js";
import { router as treatments } from "./treatments.js";
import { router as bookings } from "./bookings.js";
import { router as auth } from "./auth.js";
import { router as recurringAvailability } from "./recurring-availability.js";
import { router as cancellation } from "./cancellation.js";
import { router as legal } from "./legal.js";
import { router as gallery } from "./gallery.js";
import { router as reviews } from "./reviews.js";
import { router as social } from "./social.js";
export const router = {
demo,
treatments,
bookings,
auth,
recurringAvailability,
cancellation,
legal,
gallery,
reviews,
social,
};

16
server-dist/rpc/legal.js Normal file
View File

@@ -0,0 +1,16 @@
import { os } from "@orpc/server";
import { getLegalConfig } from "../lib/legal-config.js";
export const router = {
getConfig: os.handler(async () => {
console.log("Legal getConfig called");
try {
const config = getLegalConfig();
console.log("Legal config:", config);
return config;
}
catch (error) {
console.error("Legal config error:", error);
throw error;
}
}),
};

View File

@@ -0,0 +1,396 @@
import { call, os } from "@orpc/server";
import { z } from "zod";
import { randomUUID } from "crypto";
import { createKV } from "../lib/create-kv.js";
import { assertOwner } from "../lib/auth.js";
// Datenmodelle
const RecurringRuleSchema = z.object({
id: z.string(),
dayOfWeek: z.number().int().min(0).max(6), // 0=Sonntag, 1=Montag, ..., 6=Samstag
startTime: z.string().regex(/^\d{2}:\d{2}$/), // HH:MM Format
endTime: z.string().regex(/^\d{2}:\d{2}$/), // HH:MM Format
isActive: z.boolean(),
createdAt: z.string(),
// LEGACY: slotDurationMinutes - deprecated field for generateSlots only, will be removed
slotDurationMinutes: z.number().int().min(1).optional(),
});
const TimeOffPeriodSchema = z.object({
id: z.string(),
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), // YYYY-MM-DD
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), // YYYY-MM-DD
reason: z.string(),
createdAt: z.string(),
});
// KV-Stores
const recurringRulesKV = createKV("recurringRules");
const timeOffPeriodsKV = createKV("timeOffPeriods");
// Import bookings and treatments KV stores for getAvailableTimes endpoint
const bookingsKV = createKV("bookings");
const treatmentsKV = createKV("treatments");
// Owner-Authentifizierung zentralisiert in ../lib/auth.ts
// Helper-Funktionen
function parseTime(timeStr) {
const [hours, minutes] = timeStr.split(':').map(Number);
return hours * 60 + minutes; // Minuten seit Mitternacht
}
function formatTime(minutes) {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
}
function addDays(date, days) {
const result = new Date(date);
result.setDate(result.getDate() + days);
return result;
}
function formatDate(date) {
return date.toISOString().split('T')[0];
}
function isDateInTimeOffPeriod(date, periods) {
return periods.some(period => date >= period.startDate && date <= period.endDate);
}
// Helper-Funktion zur Erkennung überlappender Regeln
function detectOverlappingRules(newRule, existingRules) {
const newStart = parseTime(newRule.startTime);
const newEnd = parseTime(newRule.endTime);
return existingRules.filter(rule => {
// Gleicher Wochentag und nicht dieselbe Regel (bei Updates)
if (rule.dayOfWeek !== newRule.dayOfWeek || rule.id === newRule.id) {
return false;
}
const existingStart = parseTime(rule.startTime);
const existingEnd = parseTime(rule.endTime);
// Überlappung wenn: neue Startzeit < bestehende Endzeit UND neue Endzeit > bestehende Startzeit
return newStart < existingEnd && newEnd > existingStart;
});
}
// CRUD-Endpoints für Recurring Rules
const createRule = os
.input(z.object({
sessionId: z.string(),
dayOfWeek: z.number().int().min(0).max(6),
startTime: z.string().regex(/^\d{2}:\d{2}$/),
endTime: z.string().regex(/^\d{2}:\d{2}$/),
}).passthrough())
.handler(async ({ input }) => {
try {
await assertOwner(input.sessionId);
// Validierung: startTime < endTime
const startMinutes = parseTime(input.startTime);
const endMinutes = parseTime(input.endTime);
if (startMinutes >= endMinutes) {
throw new Error("Startzeit muss vor der Endzeit liegen.");
}
// Überlappungsprüfung
const allRules = await recurringRulesKV.getAllItems();
const overlappingRules = detectOverlappingRules(input, allRules);
if (overlappingRules.length > 0) {
const overlappingTimes = overlappingRules.map(rule => `${rule.startTime}-${rule.endTime}`).join(", ");
throw new Error(`Überlappung mit bestehenden Regeln erkannt: ${overlappingTimes}. Bitte Zeitfenster anpassen.`);
}
const id = randomUUID();
const rule = {
id,
dayOfWeek: input.dayOfWeek,
startTime: input.startTime,
endTime: input.endTime,
isActive: true,
createdAt: new Date().toISOString(),
};
await recurringRulesKV.setItem(id, rule);
return rule;
}
catch (err) {
console.error("recurring-availability.createRule error", err);
throw err;
}
});
const updateRule = os
.input(RecurringRuleSchema.extend({ sessionId: z.string() }).passthrough())
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
// Validierung: startTime < endTime
const startMinutes = parseTime(input.startTime);
const endMinutes = parseTime(input.endTime);
if (startMinutes >= endMinutes) {
throw new Error("Startzeit muss vor der Endzeit liegen.");
}
// Überlappungsprüfung
const allRules = await recurringRulesKV.getAllItems();
const overlappingRules = detectOverlappingRules(input, allRules);
if (overlappingRules.length > 0) {
const overlappingTimes = overlappingRules.map(rule => `${rule.startTime}-${rule.endTime}`).join(", ");
throw new Error(`Überlappung mit bestehenden Regeln erkannt: ${overlappingTimes}. Bitte Zeitfenster anpassen.`);
}
const { sessionId, ...rule } = input;
await recurringRulesKV.setItem(rule.id, rule);
return rule;
});
const deleteRule = os
.input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
await recurringRulesKV.removeItem(input.id);
});
const toggleRuleActive = os
.input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const rule = await recurringRulesKV.getItem(input.id);
if (!rule)
throw new Error("Regel nicht gefunden.");
rule.isActive = !rule.isActive;
await recurringRulesKV.setItem(input.id, rule);
return rule;
});
const listRules = os.handler(async () => {
const allRules = await recurringRulesKV.getAllItems();
return allRules.sort((a, b) => {
if (a.dayOfWeek !== b.dayOfWeek)
return a.dayOfWeek - b.dayOfWeek;
return a.startTime.localeCompare(b.startTime);
});
});
const adminListRules = os
.input(z.object({ sessionId: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const allRules = await recurringRulesKV.getAllItems();
return allRules.sort((a, b) => {
if (a.dayOfWeek !== b.dayOfWeek)
return a.dayOfWeek - b.dayOfWeek;
return a.startTime.localeCompare(b.startTime);
});
});
// CRUD-Endpoints für Time-Off Periods
const createTimeOff = os
.input(z.object({
sessionId: z.string(),
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
reason: z.string(),
}))
.handler(async ({ input }) => {
try {
await assertOwner(input.sessionId);
// Validierung: startDate <= endDate
if (input.startDate > input.endDate) {
throw new Error("Startdatum muss vor oder am Enddatum liegen.");
}
const id = randomUUID();
const timeOff = {
id,
startDate: input.startDate,
endDate: input.endDate,
reason: input.reason,
createdAt: new Date().toISOString(),
};
await timeOffPeriodsKV.setItem(id, timeOff);
return timeOff;
}
catch (err) {
console.error("recurring-availability.createTimeOff error", err);
throw err;
}
});
const updateTimeOff = os
.input(TimeOffPeriodSchema.extend({ sessionId: z.string() }).passthrough())
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
// Validierung: startDate <= endDate
if (input.startDate > input.endDate) {
throw new Error("Startdatum muss vor oder am Enddatum liegen.");
}
const { sessionId, ...timeOff } = input;
await timeOffPeriodsKV.setItem(timeOff.id, timeOff);
return timeOff;
});
const deleteTimeOff = os
.input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
await timeOffPeriodsKV.removeItem(input.id);
});
const listTimeOff = os.handler(async () => {
const allTimeOff = await timeOffPeriodsKV.getAllItems();
return allTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate));
});
const adminListTimeOff = os
.input(z.object({ sessionId: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const allTimeOff = await timeOffPeriodsKV.getAllItems();
return allTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate));
});
// Get Available Times Endpoint
const getAvailableTimes = os
.input(z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
treatmentId: z.string(),
}))
.handler(async ({ input }) => {
try {
// Validate that the date is not in the past
const today = new Date();
const inputDate = new Date(input.date);
today.setHours(0, 0, 0, 0);
inputDate.setHours(0, 0, 0, 0);
if (inputDate < today) {
return [];
}
// Get treatment duration
const treatment = await treatmentsKV.getItem(input.treatmentId);
if (!treatment) {
throw new Error("Behandlung nicht gefunden.");
}
const treatmentDuration = treatment.duration;
// Parse the date to get day of week
const [year, month, day] = input.date.split('-').map(Number);
const localDate = new Date(year, month - 1, day);
const dayOfWeek = localDate.getDay(); // 0=Sonntag, 1=Montag, ...
// Find matching recurring rules
const allRules = await recurringRulesKV.getAllItems();
const matchingRules = allRules.filter(rule => rule.isActive === true && rule.dayOfWeek === dayOfWeek);
if (matchingRules.length === 0) {
return []; // No rules for this day of week
}
// Check time-off periods
const timeOffPeriods = await timeOffPeriodsKV.getAllItems();
if (isDateInTimeOffPeriod(input.date, timeOffPeriods)) {
return []; // Date is blocked by time-off period
}
// Generate 15-minute intervals with boundary alignment
const availableTimes = [];
// Helper functions for 15-minute boundary alignment
const ceilTo15 = (m) => m % 15 === 0 ? m : m + (15 - (m % 15));
const floorTo15 = (m) => m - (m % 15);
for (const rule of matchingRules) {
const startMinutes = parseTime(rule.startTime);
const endMinutes = parseTime(rule.endTime);
let currentMinutes = ceilTo15(startMinutes);
const endBound = floorTo15(endMinutes);
while (currentMinutes + treatmentDuration <= endBound) {
const timeStr = formatTime(currentMinutes);
availableTimes.push(timeStr);
currentMinutes += 15; // 15-minute intervals
}
}
// Get all bookings for this date and their treatments
const allBookings = await bookingsKV.getAllItems();
const dateBookings = allBookings.filter(booking => booking.appointmentDate === input.date &&
['pending', 'confirmed', 'completed'].includes(booking.status));
// Optimize treatment duration lookup with Map caching
const uniqueTreatmentIds = [...new Set(dateBookings.map(booking => booking.treatmentId))];
const treatmentDurationMap = new Map();
for (const treatmentId of uniqueTreatmentIds) {
const treatment = await treatmentsKV.getItem(treatmentId);
treatmentDurationMap.set(treatmentId, treatment?.duration || 60);
}
// Get treatment durations for all bookings using the cached map
const bookingTreatments = new Map();
for (const booking of dateBookings) {
// Use bookedDurationMinutes if available, otherwise fallback to treatment duration
const duration = booking.bookedDurationMinutes || treatmentDurationMap.get(booking.treatmentId) || 60;
bookingTreatments.set(booking.id, duration);
}
// Filter out booking conflicts
const availableTimesFiltered = availableTimes.filter(slotTime => {
const slotStartMinutes = parseTime(slotTime);
const slotEndMinutes = slotStartMinutes + treatmentDuration;
// Check if this slot overlaps with any existing booking
const hasConflict = dateBookings.some(booking => {
const bookingStartMinutes = parseTime(booking.appointmentTime);
const bookingDuration = bookingTreatments.get(booking.id) || 60;
const bookingEndMinutes = bookingStartMinutes + bookingDuration;
// Check overlap: slotStart < bookingEnd && slotEnd > bookingStart
return slotStartMinutes < bookingEndMinutes && slotEndMinutes > bookingStartMinutes;
});
return !hasConflict;
});
// Filter out past times for today
const now = new Date();
const isToday = inputDate.getTime() === today.getTime();
const finalAvailableTimes = isToday
? availableTimesFiltered.filter(timeStr => {
const slotTime = parseTime(timeStr);
const currentTime = now.getHours() * 60 + now.getMinutes();
return slotTime > currentTime;
})
: availableTimesFiltered;
// Deduplicate and sort chronologically
const unique = Array.from(new Set(finalAvailableTimes));
return unique.sort((a, b) => a.localeCompare(b));
}
catch (err) {
console.error("recurring-availability.getAvailableTimes error", err);
throw err;
}
});
// Live-Queries
const live = {
listRules: os.handler(async function* ({ signal }) {
yield call(listRules, {}, { signal });
for await (const _ of recurringRulesKV.subscribe()) {
yield call(listRules, {}, { signal });
}
}),
listTimeOff: os.handler(async function* ({ signal }) {
yield call(listTimeOff, {}, { signal });
for await (const _ of timeOffPeriodsKV.subscribe()) {
yield call(listTimeOff, {}, { signal });
}
}),
adminListRules: os
.input(z.object({ sessionId: z.string() }))
.handler(async function* ({ input, signal }) {
await assertOwner(input.sessionId);
const allRules = await recurringRulesKV.getAllItems();
const sortedRules = allRules.sort((a, b) => {
if (a.dayOfWeek !== b.dayOfWeek)
return a.dayOfWeek - b.dayOfWeek;
return a.startTime.localeCompare(b.startTime);
});
yield sortedRules;
for await (const _ of recurringRulesKV.subscribe()) {
const updatedRules = await recurringRulesKV.getAllItems();
const sortedUpdatedRules = updatedRules.sort((a, b) => {
if (a.dayOfWeek !== b.dayOfWeek)
return a.dayOfWeek - b.dayOfWeek;
return a.startTime.localeCompare(b.startTime);
});
yield sortedUpdatedRules;
}
}),
adminListTimeOff: os
.input(z.object({ sessionId: z.string() }))
.handler(async function* ({ input, signal }) {
await assertOwner(input.sessionId);
const allTimeOff = await timeOffPeriodsKV.getAllItems();
const sortedTimeOff = allTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate));
yield sortedTimeOff;
for await (const _ of timeOffPeriodsKV.subscribe()) {
const updatedTimeOff = await timeOffPeriodsKV.getAllItems();
const sortedUpdatedTimeOff = updatedTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate));
yield sortedUpdatedTimeOff;
}
}),
};
export const router = {
// Recurring Rules
createRule,
updateRule,
deleteRule,
toggleRuleActive,
listRules,
adminListRules,
// Time-Off Periods
createTimeOff,
updateTimeOff,
deleteTimeOff,
listTimeOff,
adminListTimeOff,
// Availability
getAvailableTimes,
// Live queries
live,
};

220
server-dist/rpc/reviews.js Normal file
View File

@@ -0,0 +1,220 @@
import { call, os } from "@orpc/server";
import { z } from "zod";
import { createKV } from "../lib/create-kv.js";
import { assertOwner, sessionsKV } from "../lib/auth.js";
// Schema Definition
const ReviewSchema = z.object({
id: z.string(),
bookingId: z.string(),
customerName: z.string().min(2, "Kundenname muss mindestens 2 Zeichen lang sein"),
customerEmail: z.string().email("Ungültige E-Mail-Adresse").optional(),
rating: z.number().int().min(1).max(5),
comment: z.string().min(10, "Kommentar muss mindestens 10 Zeichen lang sein"),
status: z.enum(["pending", "approved", "rejected"]),
createdAt: z.string(),
reviewedAt: z.string().optional(),
reviewedBy: z.string().optional(),
});
// KV Storage
const reviewsKV = createKV("reviews");
const cancellationKV = createKV("cancellation_tokens");
const bookingsKV = createKV("bookings");
// Helper Function: validateBookingToken
async function validateBookingToken(token) {
const tokens = await cancellationKV.getAllItems();
const validToken = tokens.find(t => t.token === token &&
new Date(t.expiresAt) > new Date() &&
t.purpose === 'booking_access');
if (!validToken) {
throw new Error("Ungültiger oder abgelaufener Buchungs-Token");
}
const booking = await bookingsKV.getItem(validToken.bookingId);
if (!booking) {
throw new Error("Buchung nicht gefunden");
}
// Only allow reviews for completed appointments
if (!(booking.status === "completed")) {
throw new Error("Bewertungen sind nur für abgeschlossene Termine möglich");
}
return booking;
}
// Public Endpoint: submitReview
const submitReview = os
.input(z.object({
bookingToken: z.string(),
rating: z.number().int().min(1).max(5),
comment: z.string().min(10, "Kommentar muss mindestens 10 Zeichen lang sein"),
}))
.handler(async ({ input }) => {
try {
// Validate bookingToken
const booking = await validateBookingToken(input.bookingToken);
// Enforce uniqueness by using booking.id as the KV key
const existing = await reviewsKV.getItem(booking.id);
if (existing) {
throw new Error("Für diese Buchung wurde bereits eine Bewertung abgegeben");
}
// Create review object
const review = {
id: booking.id,
bookingId: booking.id,
customerName: booking.customerName,
customerEmail: booking.customerEmail,
rating: input.rating,
comment: input.comment,
status: "pending",
createdAt: new Date().toISOString(),
};
await reviewsKV.setItem(booking.id, review);
return review;
}
catch (err) {
console.error("reviews.submitReview error", err);
throw err;
}
});
// Admin Endpoint: approveReview
const approveReview = os
.input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input }) => {
try {
await assertOwner(input.sessionId);
const review = await reviewsKV.getItem(input.id);
if (!review) {
throw new Error("Bewertung nicht gefunden");
}
const session = await sessionsKV.getItem(input.sessionId).catch(() => undefined);
const updatedReview = {
...review,
status: "approved",
reviewedAt: new Date().toISOString(),
reviewedBy: session?.userId || review.reviewedBy,
};
await reviewsKV.setItem(input.id, updatedReview);
return updatedReview;
}
catch (err) {
console.error("reviews.approveReview error", err);
throw err;
}
});
// Admin Endpoint: rejectReview
const rejectReview = os
.input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input }) => {
try {
await assertOwner(input.sessionId);
const review = await reviewsKV.getItem(input.id);
if (!review) {
throw new Error("Bewertung nicht gefunden");
}
const session = await sessionsKV.getItem(input.sessionId).catch(() => undefined);
const updatedReview = {
...review,
status: "rejected",
reviewedAt: new Date().toISOString(),
reviewedBy: session?.userId || review.reviewedBy,
};
await reviewsKV.setItem(input.id, updatedReview);
return updatedReview;
}
catch (err) {
console.error("reviews.rejectReview error", err);
throw err;
}
});
// Admin Endpoint: deleteReview
const deleteReview = os
.input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input }) => {
try {
await assertOwner(input.sessionId);
await reviewsKV.removeItem(input.id);
}
catch (err) {
console.error("reviews.deleteReview error", err);
throw err;
}
});
// Public Endpoint: listPublishedReviews
const listPublishedReviews = os.handler(async () => {
try {
const allReviews = await reviewsKV.getAllItems();
const published = allReviews.filter(r => r.status === "approved");
const sorted = published.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
const publicSafe = sorted.map(r => ({
customerName: r.customerName,
rating: r.rating,
comment: r.comment,
status: r.status,
bookingId: r.bookingId,
createdAt: r.createdAt,
}));
return publicSafe;
}
catch (err) {
console.error("reviews.listPublishedReviews error", err);
throw err;
}
});
// Admin Endpoint: adminListReviews
const adminListReviews = os
.input(z.object({
sessionId: z.string(),
statusFilter: z.enum(["all", "pending", "approved", "rejected"]).optional().default("all"),
}))
.handler(async ({ input }) => {
try {
await assertOwner(input.sessionId);
const allReviews = await reviewsKV.getAllItems();
const filtered = input.statusFilter === "all"
? allReviews
: allReviews.filter(r => r.status === input.statusFilter);
const sorted = filtered.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
return sorted;
}
catch (err) {
console.error("reviews.adminListReviews error", err);
throw err;
}
});
// Live Queries
const live = {
listPublishedReviews: os.handler(async function* ({ signal }) {
yield call(listPublishedReviews, {}, { signal });
for await (const _ of reviewsKV.subscribe()) {
yield call(listPublishedReviews, {}, { signal });
}
}),
adminListReviews: os
.input(z.object({
sessionId: z.string(),
statusFilter: z.enum(["all", "pending", "approved", "rejected"]).optional().default("all"),
}))
.handler(async function* ({ input, signal }) {
await assertOwner(input.sessionId);
const allReviews = await reviewsKV.getAllItems();
const filtered = input.statusFilter === "all"
? allReviews
: allReviews.filter(r => r.status === input.statusFilter);
const sorted = filtered.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
yield sorted;
for await (const _ of reviewsKV.subscribe()) {
const updated = await reviewsKV.getAllItems();
const filteredUpdated = input.statusFilter === "all"
? updated
: updated.filter(r => r.status === input.statusFilter);
const sortedUpdated = filteredUpdated.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
yield sortedUpdated;
}
}),
};
export const router = {
submitReview,
approveReview,
rejectReview,
deleteReview,
listPublishedReviews,
adminListReviews,
live,
};

10
server-dist/rpc/social.js Normal file
View File

@@ -0,0 +1,10 @@
import { os } from "@orpc/server";
const getSocialMedia = os.handler(async () => {
return {
tiktokProfile: process.env.TIKTOK_PROFILE,
instagramProfile: process.env.INSTAGRAM_PROFILE,
};
});
export const router = os.router({
getSocialMedia,
});

View File

@@ -0,0 +1,52 @@
import { call, os } from "@orpc/server";
import { z } from "zod";
import { randomUUID } from "crypto";
import { createKV } from "../lib/create-kv.js";
const TreatmentSchema = z.object({
id: z.string(),
name: z.string(),
description: z.string(),
duration: z.number(), // duration in minutes
price: z.number(), // price in cents
category: z.string(),
});
const kv = createKV("treatments");
const create = os
.input(TreatmentSchema.omit({ id: true }))
.handler(async ({ input }) => {
const id = randomUUID();
const treatment = { id, ...input };
await kv.setItem(id, treatment);
return treatment;
});
const update = os
.input(TreatmentSchema)
.handler(async ({ input }) => {
await kv.setItem(input.id, input);
return input;
});
const remove = os.input(z.string()).handler(async ({ input }) => {
await kv.removeItem(input);
});
const list = os.handler(async () => {
return kv.getAllItems();
});
const get = os.input(z.string()).handler(async ({ input }) => {
return kv.getItem(input);
});
const live = {
list: os.handler(async function* ({ signal }) {
yield call(list, {}, { signal });
for await (const _ of kv.subscribe()) {
yield call(list, {}, { signal });
}
}),
};
export const router = {
create,
update,
remove,
list,
get,
live,
};

View File

@@ -1,4 +1,6 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { queryClient } from "@/client/rpc-client";
import { useAuth } from "@/client/components/auth-provider"; import { useAuth } from "@/client/components/auth-provider";
import { LoginForm } from "@/client/components/login-form"; import { LoginForm } from "@/client/components/login-form";
import { UserProfile } from "@/client/components/user-profile"; import { UserProfile } from "@/client/components/user-profile";
@@ -8,19 +10,53 @@ import { AdminBookings } from "@/client/components/admin-bookings";
import { AdminCalendar } from "@/client/components/admin-calendar"; import { AdminCalendar } from "@/client/components/admin-calendar";
import { InitialDataLoader } from "@/client/components/initial-data-loader"; import { InitialDataLoader } from "@/client/components/initial-data-loader";
import { AdminAvailability } from "@/client/components/admin-availability"; import { AdminAvailability } from "@/client/components/admin-availability";
import { AdminGallery } from "@/client/components/admin-gallery";
import { AdminReviews } from "@/client/components/admin-reviews";
import BookingStatusPage from "@/client/components/booking-status-page"; import BookingStatusPage from "@/client/components/booking-status-page";
import ReviewSubmissionPage from "@/client/components/review-submission-page";
import LegalPage from "@/client/components/legal-page"; import LegalPage from "@/client/components/legal-page";
import { ProfileLanding } from "@/client/components/profile-landing";
import { PWAInstallPrompt } from "@/client/components/pwa-install-prompt";
function App() { function App() {
const { user, isLoading, isOwner } = useAuth(); const { user, isLoading, isOwner } = useAuth();
const [activeTab, setActiveTab] = useState<"booking" | "admin-treatments" | "admin-bookings" | "admin-calendar" | "admin-availability" | "profile" | "legal">("booking"); const [activeTab, setActiveTab] = useState<"profile-landing" | "booking" | "admin-treatments" | "admin-bookings" | "admin-calendar" | "admin-availability" | "admin-gallery" | "admin-reviews" | "profile" | "legal">("profile-landing");
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const { data: socialMedia } = useQuery(
queryClient.social.getSocialMedia.queryOptions()
);
const hasSocialMedia = (socialMedia as any)?.tiktokProfile || (socialMedia as any)?.instagramProfile;
// Prevent background scroll when menu is open
useEffect(() => {
document.body.classList.toggle('overflow-hidden', isMobileMenuOpen);
return () => document.body.classList.remove('overflow-hidden');
}, [isMobileMenuOpen]);
// Handle booking status page // Handle booking status page
const path = window.location.pathname; const path = window.location.pathname;
const PwaPrompt = <PWAInstallPrompt />;
if (path.startsWith('/booking/')) { if (path.startsWith('/booking/')) {
const token = path.split('/booking/')[1]; const token = path.split('/booking/')[1];
if (token) { if (token) {
return <BookingStatusPage token={token} />; return <>
{PwaPrompt}
<BookingStatusPage token={token} />
</>;
}
}
// Handle review submission page
if (path.startsWith('/review/')) {
const token = path.split('/review/')[1];
if (token) {
return <>
{PwaPrompt}
<ReviewSubmissionPage token={token} />
</>;
} }
} }
@@ -41,7 +77,7 @@ function App() {
} }
// Show login form if user is not authenticated and trying to access admin features // Show login form if user is not authenticated and trying to access admin features
const needsAuth = !user && (activeTab === "admin-treatments" || activeTab === "admin-bookings" || activeTab === "admin-calendar" || activeTab === "admin-availability" || activeTab === "profile"); const needsAuth = !user && (activeTab === "admin-treatments" || activeTab === "admin-bookings" || activeTab === "admin-calendar" || activeTab === "admin-availability" || activeTab === "admin-gallery" || activeTab === "admin-reviews" || activeTab === "profile");
if (needsAuth) { if (needsAuth) {
return <LoginForm />; return <LoginForm />;
} }
@@ -52,12 +88,15 @@ function App() {
} }
const tabs = [ const tabs = [
{ id: "profile-landing", label: "Startseite", icon: "🏠", requiresAuth: false },
{ id: "booking", label: "Termin buchen", icon: "📅", requiresAuth: false }, { id: "booking", label: "Termin buchen", icon: "📅", requiresAuth: false },
{ id: "legal", label: "Impressum/Datenschutz", icon: "📋", requiresAuth: false }, { id: "legal", label: "Impressum/Datenschutz", icon: "📋", requiresAuth: false },
{ id: "admin-treatments", label: "Behandlungen verwalten", icon: "💅", requiresAuth: true }, { id: "admin-treatments", label: "Behandlungen verwalten", icon: "💅", requiresAuth: true },
{ id: "admin-bookings", label: "Buchungen verwalten", icon: "📋", requiresAuth: true }, { id: "admin-bookings", label: "Buchungen verwalten", icon: "📋", requiresAuth: true },
{ id: "admin-calendar", label: "Kalender", icon: "📆", requiresAuth: true }, { id: "admin-calendar", label: "Kalender", icon: "📆", requiresAuth: true },
{ id: "admin-availability", label: "Verfügbarkeiten", icon: "⏰", requiresAuth: true }, { id: "admin-availability", label: "Verfügbarkeiten", icon: "⏰", requiresAuth: true },
{ id: "admin-gallery", label: "Photo-Wall", icon: "📸", requiresAuth: true },
{ id: "admin-reviews", label: "Bewertungen", icon: "⭐", requiresAuth: true },
...(user ? [{ id: "profile", label: "Profil", icon: "👤", requiresAuth: true }] : []), ...(user ? [{ id: "profile", label: "Profil", icon: "👤", requiresAuth: true }] : []),
] as const; ] as const;
@@ -71,7 +110,7 @@ function App() {
<div className="flex justify-between items-center py-6"> <div className="flex justify-between items-center py-6">
<div <div
className="flex items-center space-x-3 cursor-pointer hover:opacity-80 transition-opacity" className="flex items-center space-x-3 cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => setActiveTab("booking")} onClick={() => setActiveTab("profile-landing")}
> >
<img <img
src="/assets/stargilnails_logo_transparent_112.png" src="/assets/stargilnails_logo_transparent_112.png"
@@ -84,11 +123,26 @@ function App() {
</div> </div>
</div> </div>
{/* Hamburger Button für Mobile */}
<button
type="button"
aria-label="Menü öffnen"
aria-controls="mobile-menu"
aria-expanded={isMobileMenuOpen}
className="md:hidden p-2 -ml-2 text-3xl text-gray-700 hover:text-pink-600 transition-colors"
onClick={() => setIsMobileMenuOpen(true)}
>
</button>
{user && ( {user && (
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<span className="text-sm text-gray-600"> <span className="text-sm text-gray-600 hidden sm:inline">
Willkommen, {user.username} Willkommen, {user.username}
</span> </span>
<span className="text-sm text-gray-600 sm:hidden">
{user.username}
</span>
{isOwner && ( {isOwner && (
<span className="bg-pink-100 text-pink-800 px-2 py-1 rounded-full text-xs font-medium"> <span className="bg-pink-100 text-pink-800 px-2 py-1 rounded-full text-xs font-medium">
Inhaber Inhaber
@@ -100,8 +154,8 @@ function App() {
</div> </div>
</header> </header>
{/* Navigation */} {/* Desktop Navigation */}
<nav className="bg-white shadow-sm"> <nav className="bg-white shadow-sm hidden md:block">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex space-x-8"> <div className="flex space-x-8">
{tabs.map((tab) => { {tabs.map((tab) => {
@@ -146,8 +200,82 @@ function App() {
</div> </div>
</nav> </nav>
{/* Mobile Backdrop */}
{isMobileMenuOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 md:hidden"
onClick={() => setIsMobileMenuOpen(false)}
/>
)}
{/* Mobile Slide-in Panel */}
<div
id="mobile-menu"
role="dialog"
aria-modal="true"
aria-label="Navigation"
className={`fixed inset-y-0 left-0 w-64 bg-white shadow-xl z-50 md:hidden transform transition-transform duration-300 ease-in-out ${isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full'}`}
>
<button
type="button"
aria-label="Menü schließen"
className="absolute top-4 right-4 text-2xl text-gray-600 hover:text-pink-600"
onClick={() => setIsMobileMenuOpen(false)}
>
×
</button>
<div className="p-6">
<img
src="/assets/stargilnails_logo_transparent_112.png"
alt="Stargil Nails Logo"
className="w-10 h-10 mb-6 object-contain"
/>
<nav className="mt-2 flex flex-col space-y-2">
{tabs.map((tab) => {
// Hide admin tabs for non-owners
if (tab.requiresAuth && !isOwner && tab.id !== 'profile') return null;
return (
<button
key={tab.id}
onClick={() => {
setActiveTab(tab.id as any);
setIsMobileMenuOpen(false);
}}
className={`flex items-center space-x-3 px-4 py-3 rounded-lg transition-colors ${
activeTab === tab.id ? 'bg-pink-100 text-pink-600' : 'text-gray-700 hover:bg-gray-100'
}`}
>
<span>{tab.icon}</span>
<span>{tab.label}</span>
</button>
);
})}
{!user && (
<button
onClick={() => {
setActiveTab('profile');
setIsMobileMenuOpen(false);
}}
className="flex items-center space-x-3 px-4 py-3 rounded-lg text-gray-700 hover:bg-gray-100 transition-colors"
>
<span>🔑</span>
<span>Inhaber Login</span>
</button>
)}
</nav>
</div>
</div>
{/* Main Content */} {/* Main Content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{activeTab === "profile-landing" && (
<ProfileLanding onNavigateToBooking={() => setActiveTab("booking")} />
)}
{activeTab === "booking" && ( {activeTab === "booking" && (
<div> <div>
<div className="text-center mb-8"> <div className="text-center mb-8">
@@ -212,13 +340,41 @@ function App() {
Verfügbarkeiten verwalten Verfügbarkeiten verwalten
</h2> </h2>
<p className="text-lg text-gray-600"> <p className="text-lg text-gray-600">
Lege freie Slots an und entferne sie bei Bedarf. Verwalte wiederkehrende Zeiten und Urlaubszeiten.
</p> </p>
</div> </div>
<AdminAvailability /> <AdminAvailability />
</div> </div>
)} )}
{activeTab === "admin-gallery" && isOwner && (
<div>
<div className="text-center mb-8">
<h2 className="text-3xl font-bold text-gray-900 mb-4">
Photo-Wall verwalten
</h2>
<p className="text-lg text-gray-600">
Lade Fotos hoch und verwalte deine Galerie.
</p>
</div>
<AdminGallery />
</div>
)}
{activeTab === "admin-reviews" && isOwner && (
<div>
<div className="text-center mb-8">
<h2 className="text-3xl font-bold text-gray-900 mb-4">
Bewertungen verwalten
</h2>
<p className="text-lg text-gray-600">
Prüfe und verwalte Kundenbewertungen.
</p>
</div>
<AdminReviews />
</div>
)}
{activeTab === "profile" && user && ( {activeTab === "profile" && user && (
<div> <div>
<div className="text-center mb-8"> <div className="text-center mb-8">
@@ -234,11 +390,44 @@ function App() {
)} )}
</main> </main>
{/* PWA Installation Prompt for iOS */}
<PWAInstallPrompt hidden={isMobileMenuOpen} />
{/* Footer */} {/* Footer */}
<footer className="bg-white border-t border-pink-100 mt-16"> <footer className="bg-white border-t border-pink-100 mt-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="text-center text-gray-600"> <div className="text-center text-gray-600">
<p>&copy; 2025 Stargirlnails Kiel. Professional nail design & care with 🩷 and passion in Kiel 🌇.</p> <p className="mb-4">&copy; 2025 Stargirlnails Kiel. Professional nail design & care with 🩷 and passion in Kiel 🌇.</p>
{hasSocialMedia && (
<div className="flex justify-center items-center gap-3 mt-4">
{(socialMedia as any)?.instagramProfile && (
<a
href={(socialMedia as any).instagramProfile}
target="_blank"
rel="noopener noreferrer"
className="text-pink-600 hover:text-pink-700 transition-colors"
aria-label="Instagram"
>
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/>
</svg>
</a>
)}
{(socialMedia as any)?.tiktokProfile && (
<a
href={(socialMedia as any).tiktokProfile}
target="_blank"
rel="noopener noreferrer"
className="text-gray-800 hover:text-gray-900 transition-colors"
aria-label="TikTok"
>
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1-.1z"/>
</svg>
</a>
)}
</div>
)}
</div> </div>
</div> </div>
</footer> </footer>

View File

@@ -4,18 +4,33 @@ import { queryClient } from "@/client/rpc-client";
export function AdminAvailability() { export function AdminAvailability() {
const today = new Date().toISOString().split("T")[0]; const today = new Date().toISOString().split("T")[0];
const [selectedDate, setSelectedDate] = useState<string>(today);
const [time, setTime] = useState<string>("09:00"); // Tab-Navigation (Slots entfernt)
const [duration, setDuration] = useState<number>(30); const [activeSubTab, setActiveSubTab] = useState<"recurring" | "timeoff">("recurring");
const [selectedTreatmentId, setSelectedTreatmentId] = useState<string>("");
const [slotType, setSlotType] = useState<"treatment" | "manual">("treatment"); // States für Recurring Rules
const [selectedDayOfWeek, setSelectedDayOfWeek] = useState<number>(1); // 1=Montag
const { data: allSlots } = useQuery( const [ruleStartTime, setRuleStartTime] = useState<string>("13:00");
queryClient.availability.live.list.experimental_liveOptions() const [ruleEndTime, setRuleEndTime] = useState<string>("18:00");
const [editingRuleId, setEditingRuleId] = useState<string>("");
// States für Time-Off
const [timeOffStartDate, setTimeOffStartDate] = useState<string>("");
const [timeOffEndDate, setTimeOffEndDate] = useState<string>("");
const [timeOffReason, setTimeOffReason] = useState<string>("");
const [editingTimeOffId, setEditingTimeOffId] = useState<string>("");
// Neue Queries für wiederkehrende Verfügbarkeiten (mit Authentifizierung)
const { data: recurringRules, refetch: refetchRecurringRules } = useQuery(
queryClient.recurringAvailability.live.adminListRules.experimental_liveOptions({
input: { sessionId: localStorage.getItem("sessionId") || "" }
})
); );
const { data: timeOffPeriods } = useQuery(
const { data: treatments } = useQuery( queryClient.recurringAvailability.live.adminListTimeOff.experimental_liveOptions({
queryClient.treatments.live.list.experimental_liveOptions() input: { sessionId: localStorage.getItem("sessionId") || "" }
})
); );
const [errorMsg, setErrorMsg] = useState<string>(""); const [errorMsg, setErrorMsg] = useState<string>("");
@@ -36,292 +51,183 @@ export function AdminAvailability() {
} }
}, [successMsg]); }, [successMsg]);
const { mutate: createSlot, isPending: isCreating } = useMutation( // Neue Mutations für wiederkehrende Verfügbarkeiten
queryClient.availability.create.mutationOptions() const { mutate: createRule } = useMutation(
queryClient.recurringAvailability.createRule.mutationOptions()
); );
const { mutate: removeSlot } = useMutation( const { mutate: updateRule } = useMutation(
queryClient.availability.remove.mutationOptions() queryClient.recurringAvailability.updateRule.mutationOptions()
);
const { mutate: deleteRule } = useMutation(
queryClient.recurringAvailability.deleteRule.mutationOptions()
);
const { mutate: toggleRuleActive } = useMutation(
queryClient.recurringAvailability.toggleRuleActive.mutationOptions()
);
const { mutate: createTimeOff } = useMutation(
queryClient.recurringAvailability.createTimeOff.mutationOptions()
);
const { mutate: updateTimeOff } = useMutation(
queryClient.recurringAvailability.updateTimeOff.mutationOptions()
);
const { mutate: deleteTimeOff } = useMutation(
queryClient.recurringAvailability.deleteTimeOff.mutationOptions()
); );
// Auto-update duration when treatment is selected
useEffect(() => { // Helper-Funktion für Wochentag-Namen
if (selectedTreatmentId && treatments) { const getDayName = (dayOfWeek: number): string => {
const treatment = treatments.find(t => t.id === selectedTreatmentId); const days = ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"];
if (treatment) { return days[dayOfWeek];
setDuration(treatment.duration);
}
}
}, [selectedTreatmentId, treatments]);
// Get selected treatment details
const selectedTreatment = treatments?.find(t => t.id === selectedTreatmentId);
// Get treatment name for display
const getTreatmentName = (treatmentId: string) => {
return treatments?.find(t => t.id === treatmentId)?.name || "Unbekannte Behandlung";
};
const addSlot = () => {
setErrorMsg("");
setSuccessMsg("");
// Validation based on slot type
if (slotType === "treatment" && !selectedTreatmentId) {
setErrorMsg("Bitte eine Behandlung auswählen.");
return;
}
if (!selectedDate || !time || !duration) {
setErrorMsg("Bitte Datum, Uhrzeit und Dauer angeben.");
return;
}
const sessionId = localStorage.getItem("sessionId") || "";
if (!sessionId) {
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
return;
}
createSlot(
{ sessionId, date: selectedDate, time, durationMinutes: duration },
{
onSuccess: () => {
const slotDescription = slotType === "treatment"
? `${getTreatmentName(selectedTreatmentId)} (${duration} Min)`
: `Manueller Slot (${duration} Min)`;
setSuccessMsg(`${slotDescription} angelegt.`);
// advance time by the duration of the slot
const [hStr, mStr] = time.split(":");
let h = parseInt(hStr, 10);
let m = parseInt(mStr, 10);
m += duration;
if (m >= 60) { h += Math.floor(m / 60); m = m % 60; }
if (h >= 24) { h = h % 24; }
const next = `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
setTime(next);
},
onError: (err: any) => {
const msg = (err && (err.message || (err as any).toString())) || "Fehler beim Anlegen.";
setErrorMsg(msg);
},
}
);
}; };
return ( return (
<div className="max-w-4xl mx-auto space-y-6"> <div className="max-w-4xl mx-auto space-y-6">
{/* Slot Type Selection */} {/* Tab-Navigation (Slots entfernt) */}
<div className="bg-white rounded-lg shadow p-4">
<h3 className="text-lg font-semibold mb-4">Neuen Slot anlegen</h3>
<div className="flex flex-wrap gap-2 mb-4">
<button
onClick={() => setSlotType("treatment")}
className={`px-4 py-2 rounded-md font-medium transition-colors ${
slotType === "treatment"
? "bg-pink-600 text-white"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
>
💅 Behandlungs-Slot
</button>
<button
onClick={() => setSlotType("manual")}
className={`px-4 py-2 rounded-md font-medium transition-colors ${
slotType === "manual"
? "bg-pink-600 text-white"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
>
Manueller Slot
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Datum
</label>
<input
type="date"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-pink-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Startzeit
</label>
<input
type="time"
value={time}
onChange={(e) => setTime(e.target.value)}
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-pink-500"
/>
</div>
{slotType === "treatment" ? (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Behandlung
</label>
<select
value={selectedTreatmentId}
onChange={(e) => setSelectedTreatmentId(e.target.value)}
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-pink-500"
>
<option value="">Behandlung wählen...</option>
{treatments?.map((treatment) => (
<option key={treatment.id} value={treatment.id}>
{treatment.name} ({treatment.duration} Min)
</option>
))}
</select>
</div>
) : (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Dauer (Minuten)
</label>
<input
type="number"
min={5}
step={5}
value={duration}
onChange={(e) => setDuration(Number(e.target.value))}
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-pink-500"
/>
</div>
)}
<div className="flex items-end">
<button
onClick={addSlot}
disabled={isCreating || (slotType === "treatment" && !selectedTreatmentId)}
className="w-full bg-pink-600 text-white px-4 py-2 rounded-md hover:bg-pink-700 disabled:bg-gray-400 disabled:cursor-not-allowed font-medium transition-colors"
>
{isCreating ? "Anlegen..." : "Slot hinzufügen"}
</button>
</div>
</div>
{/* Treatment Info Display */}
{slotType === "treatment" && selectedTreatment && (
<div className="mt-4 p-3 bg-pink-50 rounded-md border border-pink-200">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium text-pink-900">{selectedTreatment.name}</h4>
<p className="text-sm text-pink-700">{selectedTreatment.description}</p>
</div>
<div className="text-right">
<div className="text-lg font-semibold text-pink-900">
{(selectedTreatment.price / 100).toFixed(2)}
</div>
<div className="text-sm text-pink-700">
{selectedTreatment.duration} Minuten
</div>
</div>
</div>
</div>
)}
</div>
{(errorMsg || successMsg) && (
<div className="fixed top-4 right-4 z-50 max-w-md">
{errorMsg && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded-lg shadow-lg mb-2">
<div className="flex items-center">
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
<span className="font-medium">Fehler:</span>
<span className="ml-1">{errorMsg}</span>
</div>
</div>
)}
{successMsg && (
<div className="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded-lg shadow-lg">
<div className="flex items-center">
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
<span className="font-medium">Erfolg:</span>
<span className="ml-1">{successMsg}</span>
</div>
</div>
)}
</div>
)}
{/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white rounded-lg shadow p-4">
<div className="text-2xl font-bold text-green-600">
{allSlots?.filter(s => s.status === "free").length || 0}
</div>
<div className="text-sm text-gray-600">Freie Slots</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-2xl font-bold text-yellow-600">
{allSlots?.filter(s => s.status === "reserved").length || 0}
</div>
<div className="text-sm text-gray-600">Reservierte Slots</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-2xl font-bold text-blue-600">
{allSlots?.length || 0}
</div>
<div className="text-sm text-gray-600">Slots gesamt</div>
</div>
</div>
{/* All Slots List */}
<div className="bg-white rounded-lg shadow"> <div className="bg-white rounded-lg shadow">
<div className="p-4 border-b"> <div className="border-b border-gray-200">
<h3 className="text-lg font-semibold">Alle Slots</h3> <nav className="-mb-px flex space-x-8 px-6">
<button
onClick={() => setActiveSubTab("recurring")}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeSubTab === "recurring"
? "border-pink-500 text-pink-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
🔄 Wiederkehrende Zeiten
</button>
<button
onClick={() => setActiveSubTab("timeoff")}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeSubTab === "timeoff"
? "border-pink-500 text-pink-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
🏖 Urlaubszeiten
</button>
</nav>
</div> </div>
<div className="divide-y"> </div>
{allSlots
?.sort((a, b) => (a.date === b.date ? a.time.localeCompare(b.time) : a.date.localeCompare(b.date))) {/* Slot-Tab und Slot-UI entfernt */}
.map((slot) => {
// Try to find matching treatment based on duration {/* Tab "Wiederkehrende Zeiten" */}
const matchingTreatments = treatments?.filter(t => t.duration === slot.durationMinutes) || []; {activeSubTab === "recurring" && (
<div className="space-y-6">
{/* Neue Regel erstellen */}
<div className="bg-white rounded-lg shadow p-4">
<h3 className="text-lg font-semibold mb-4">Neue wiederkehrende Regel erstellen</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Wochentag
</label>
<select
value={selectedDayOfWeek}
onChange={(e) => setSelectedDayOfWeek(Number(e.target.value))}
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-pink-500"
>
<option value={1}>Montag</option>
<option value={2}>Dienstag</option>
<option value={3}>Mittwoch</option>
<option value={4}>Donnerstag</option>
<option value={5}>Freitag</option>
<option value={6}>Samstag</option>
<option value={0}>Sonntag</option>
</select>
</div>
return ( <div>
<div key={slot.id} className="p-4 hover:bg-gray-50 transition-colors"> <label className="block text-sm font-medium text-gray-700 mb-1">
Startzeit
</label>
<input
type="time"
value={ruleStartTime}
onChange={(e) => setRuleStartTime(e.target.value)}
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-pink-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Endzeit
</label>
<input
type="time"
value={ruleEndTime}
onChange={(e) => setRuleEndTime(e.target.value)}
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-pink-500"
/>
</div>
</div>
<div className="mt-4">
<button
onClick={() => {
setErrorMsg("");
setSuccessMsg("");
if (ruleStartTime >= ruleEndTime) {
setErrorMsg("Startzeit muss vor der Endzeit liegen.");
return;
}
const sessionId = localStorage.getItem("sessionId") || "";
if (!sessionId) {
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
return;
}
createRule(
{ sessionId, dayOfWeek: selectedDayOfWeek, startTime: ruleStartTime, endTime: ruleEndTime },
{
onSuccess: () => {
setSuccessMsg(`Regel für ${getDayName(selectedDayOfWeek)} erstellt.`);
// Sofort aktualisieren (zusätzlich zur Live-Subscription), damit Nutzer den Eintrag direkt sieht
refetchRecurringRules();
},
onError: (err: any) => {
setErrorMsg(err?.message || "Fehler beim Erstellen der Regel.");
}
}
);
}}
className="bg-pink-600 text-white px-4 py-2 rounded-md hover:bg-pink-700 font-medium transition-colors"
>
Regel hinzufügen
</button>
</div>
</div>
{/* Bestehende Regeln */}
<div className="bg-white rounded-lg shadow">
<div className="p-4 border-b">
<h3 className="text-lg font-semibold">Bestehende Regeln</h3>
</div>
<div className="divide-y">
{recurringRules?.length === 0 && (
<div className="p-8 text-center text-gray-500">
<div className="text-lg font-medium mb-2">Noch keine wiederkehrenden Regeln definiert</div>
<div className="text-sm">Erstellen Sie Ihre erste Regel, um automatisch Slots zu generieren.</div>
</div>
)}
{recurringRules?.map((rule) => (
<div key={rule.id} className="p-4 hover:bg-gray-50 transition-colors">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="text-center"> <div className="font-medium">{getDayName(rule.dayOfWeek)}</div>
<div className="text-sm text-gray-500">Datum</div> <div className="text-gray-600">{rule.startTime} - {rule.endTime} Uhr</div>
<div className="font-medium">{new Date(slot.date).toLocaleDateString('de-DE')}</div> <span className={`px-2 py-1 rounded-full text-xs font-medium ${
</div> rule.isActive
<div className="text-center">
<div className="text-sm text-gray-500">Zeit</div>
<div className="font-mono text-lg">{slot.time}</div>
</div>
<div className="text-center">
<div className="text-sm text-gray-500">Dauer</div>
<div className="font-medium">{slot.durationMinutes} Min</div>
</div>
{matchingTreatments.length > 0 && (
<div className="text-center">
<div className="text-sm text-gray-500">Passende Behandlungen</div>
<div className="text-sm">
{matchingTreatments.length === 1
? matchingTreatments[0].name
: `${matchingTreatments.length} Behandlungen`
}
</div>
</div>
)}
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
slot.status === "free"
? "bg-green-100 text-green-800" ? "bg-green-100 text-green-800"
: "bg-yellow-100 text-yellow-800" : "bg-gray-100 text-gray-800"
}`}> }`}>
{slot.status === "free" ? "Frei" : "Reserviert"} {rule.isActive ? "Aktiv" : "Inaktiv"}
</span> </span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -332,52 +238,213 @@ export function AdminAvailability() {
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden."); setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
return; return;
} }
removeSlot( toggleRuleActive(
{ sessionId, id: slot.id }, { sessionId, id: rule.id },
{ {
onSuccess: () => { onSuccess: () => {
setSuccessMsg("Slot erfolgreich gelöscht."); setSuccessMsg(`Regel ${rule.isActive ? "deaktiviert" : "aktiviert"}.`);
}, },
onError: (err: any) => { onError: (err: any) => {
const msg = (err && (err.message || (err as any).toString())) || "Fehler beim schen des Slots."; setErrorMsg(err?.message || "Fehler beim Umschalten der Regel.");
setErrorMsg(msg); }
}
);
}}
className="px-3 py-1 text-blue-600 hover:bg-blue-50 rounded-md transition-colors text-sm"
>
{rule.isActive ? "Deaktivieren" : "Aktivieren"}
</button>
<button
onClick={() => {
const sessionId = localStorage.getItem("sessionId");
if (!sessionId) {
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
return;
}
deleteRule(
{ sessionId, id: rule.id },
{
onSuccess: () => {
setSuccessMsg("Regel gelöscht.");
},
onError: (err: any) => {
setErrorMsg(err?.message || "Fehler beim Löschen der Regel.");
} }
} }
); );
}} }}
className="px-3 py-1 text-red-600 hover:bg-red-50 rounded-md transition-colors text-sm" className="px-3 py-1 text-red-600 hover:bg-red-50 rounded-md transition-colors text-sm"
disabled={slot.status === "reserved"}
title={slot.status === "reserved" ? "Slot ist reserviert" : "Slot löschen"}
> >
Löschen Löschen
</button> </button>
</div> </div>
</div> </div>
</div>
))}
</div>
</div>
</div>
)}
{/* Tab "Urlaubszeiten" */}
{activeSubTab === "timeoff" && (
<div className="space-y-6">
{/* Neue Urlaubszeit erstellen */}
<div className="bg-white rounded-lg shadow p-4">
<h3 className="text-lg font-semibold mb-4">Neue Urlaubszeit erstellen</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Von Datum
</label>
<input
type="date"
value={timeOffStartDate}
onChange={(e) => setTimeOffStartDate(e.target.value)}
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-pink-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Bis Datum
</label>
<input
type="date"
value={timeOffEndDate}
onChange={(e) => setTimeOffEndDate(e.target.value)}
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-pink-500"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">
Grund/Notiz
</label>
<input
type="text"
placeholder="z.B. Sommerurlaub, Feiertag"
value={timeOffReason}
onChange={(e) => setTimeOffReason(e.target.value)}
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-pink-500"
/>
</div>
</div>
<div className="mt-4">
<button
onClick={() => {
setErrorMsg("");
setSuccessMsg("");
{/* Show matching treatments if multiple */} if (!timeOffStartDate || !timeOffEndDate || !timeOffReason) {
{matchingTreatments.length > 1 && ( setErrorMsg("Bitte alle Felder ausfüllen.");
<div className="mt-2 ml-20"> return;
<div className="text-xs text-gray-500 mb-1">Passende Behandlungen:</div> }
<div className="flex flex-wrap gap-1">
{matchingTreatments.map(treatment => ( if (timeOffStartDate > timeOffEndDate) {
<span key={treatment.id} className="px-2 py-1 bg-pink-100 text-pink-700 rounded text-xs"> setErrorMsg("Startdatum muss vor dem Enddatum liegen.");
{treatment.name} return;
</span> }
))}
const sessionId = localStorage.getItem("sessionId") || "";
if (!sessionId) {
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
return;
}
createTimeOff(
{ sessionId, startDate: timeOffStartDate, endDate: timeOffEndDate, reason: timeOffReason },
{
onSuccess: () => {
setSuccessMsg("Urlaubszeit hinzugefügt.");
setTimeOffStartDate("");
setTimeOffEndDate("");
setTimeOffReason("");
},
onError: (err: any) => {
setErrorMsg(err?.message || "Fehler beim Hinzufügen der Urlaubszeit.");
}
}
);
}}
className="bg-pink-600 text-white px-4 py-2 rounded-md hover:bg-pink-700 font-medium transition-colors"
>
Urlaubszeit hinzufügen
</button>
</div>
</div>
{/* Bestehende Urlaubszeiten */}
<div className="bg-white rounded-lg shadow">
<div className="p-4 border-b">
<h3 className="text-lg font-semibold">Bestehende Urlaubszeiten</h3>
</div>
<div className="divide-y">
{timeOffPeriods?.length === 0 && (
<div className="p-8 text-center text-gray-500">
<div className="text-lg font-medium mb-2">Keine Urlaubszeiten eingetragen</div>
<div className="text-sm">Fügen Sie Urlaubszeiten hinzu, um automatisch Slots zu blockieren.</div>
</div>
)}
{timeOffPeriods?.map((period) => {
const today = new Date().toISOString().split("T")[0];
const isPast = period.endDate < today;
const isCurrent = period.startDate <= today && period.endDate >= today;
const isFuture = period.startDate > today;
return (
<div key={period.id} className="p-4 hover:bg-gray-50 transition-colors">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="font-medium">
{new Date(period.startDate).toLocaleDateString('de-DE')} - {new Date(period.endDate).toLocaleDateString('de-DE')}
</div>
<div className="text-gray-600">{period.reason}</div>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
isPast
? "bg-gray-100 text-gray-800"
: isCurrent
? "bg-red-100 text-red-800"
: "bg-blue-100 text-blue-800"
}`}>
{isPast ? "Vergangen" : isCurrent ? "Aktuell" : "Geplant"}
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => {
const sessionId = localStorage.getItem("sessionId");
if (!sessionId) {
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
return;
}
deleteTimeOff(
{ sessionId, id: period.id },
{
onSuccess: () => {
setSuccessMsg("Urlaubszeit gelöscht.");
},
onError: (err: any) => {
setErrorMsg(err?.message || "Fehler beim Löschen der Urlaubszeit.");
}
}
);
}}
className="px-3 py-1 text-red-600 hover:bg-red-50 rounded-md transition-colors text-sm"
>
Löschen
</button>
</div> </div>
</div> </div>
)} </div>
</div> );
); })}
})}
{allSlots?.length === 0 && (
<div className="p-8 text-center text-gray-500">
<div className="text-lg font-medium mb-2">Keine Slots vorhanden</div>
<div className="text-sm">Legen Sie den ersten Slot an, um zu beginnen.</div>
</div> </div>
)} </div>
</div> </div>
</div> )}
</div> </div>
); );
} }

View File

@@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { queryClient } from "@/client/rpc-client"; import { queryClient } from "@/client/rpc-client";
@@ -6,8 +6,28 @@ export function AdminBookings() {
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]); const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
const [selectedPhoto, setSelectedPhoto] = useState<string>(""); const [selectedPhoto, setSelectedPhoto] = useState<string>("");
const [showPhotoModal, setShowPhotoModal] = useState(false); const [showPhotoModal, setShowPhotoModal] = useState(false);
const [showCancelConfirm, setShowCancelConfirm] = useState<string | null>(null);
const [showMessageModal, setShowMessageModal] = useState<string | null>(null);
const [messageText, setMessageText] = useState<string>("");
const [successMsg, setSuccessMsg] = useState<string>("");
const [errorMsg, setErrorMsg] = useState<string>("");
const { data: bookings } = useQuery( // Auto-clear messages after 5 seconds
useEffect(() => {
if (errorMsg) {
const timer = setTimeout(() => setErrorMsg(""), 5000);
return () => clearTimeout(timer);
}
}, [errorMsg]);
useEffect(() => {
if (successMsg) {
const timer = setTimeout(() => setSuccessMsg(""), 5000);
return () => clearTimeout(timer);
}
}, [successMsg]);
const { data: bookings, refetch: refetchBookings } = useQuery(
queryClient.bookings.live.list.experimental_liveOptions() queryClient.bookings.live.list.experimental_liveOptions()
); );
@@ -16,11 +36,48 @@ export function AdminBookings() {
); );
const { mutate: updateBookingStatus } = useMutation( const { mutate: updateBookingStatus } = useMutation(
queryClient.bookings.updateStatus.mutationOptions() queryClient.bookings.updateStatus.mutationOptions({
onSuccess: (data, variables) => {
const statusText = getStatusText(variables.status);
setSuccessMsg(`Buchung wurde erfolgreich auf "${statusText}" gesetzt.`);
setShowCancelConfirm(null);
// Manually refetch bookings to ensure live updates
refetchBookings();
},
onError: (error: any) => {
setErrorMsg(error?.message || "Fehler beim Aktualisieren der Buchung.");
setShowCancelConfirm(null);
}
})
); );
const getTreatmentName = (treatmentId: string) => { const { mutate: sendMessage, isPending: isSendingMessage } = useMutation(
return treatments?.find(t => t.id === treatmentId)?.name || "Unbekannte Behandlung"; queryClient.bookings.sendCustomerMessage.mutationOptions({
onSuccess: () => {
setSuccessMsg("Nachricht wurde erfolgreich gesendet.");
setShowMessageModal(null);
setMessageText("");
},
onError: (error: any) => {
setErrorMsg(error?.message || "Fehler beim Senden der Nachricht.");
}
})
);
const getTreatmentNames = (booking: any) => {
// Handle new treatments array structure
if (booking.treatments && Array.isArray(booking.treatments) && booking.treatments.length > 0) {
const names = booking.treatments
.map((t: any) => t.name)
.filter((name: string) => name && name.trim())
.join(", ");
return names || "Keine Behandlung";
}
// Fallback to deprecated treatmentId for backward compatibility
if (booking.treatmentId) {
return treatments?.find(t => t.id === booking.treatmentId)?.name || "Unbekannte Behandlung";
}
return "Keine Behandlung";
}; };
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
@@ -33,6 +90,16 @@ export function AdminBookings() {
} }
}; };
const getStatusText = (status: string) => {
switch (status) {
case "pending": return "Ausstehend";
case "confirmed": return "Bestätigt";
case "cancelled": return "Storniert";
case "completed": return "Abgeschlossen";
default: return status;
}
};
const openPhotoModal = (photoData: string) => { const openPhotoModal = (photoData: string) => {
setSelectedPhoto(photoData); setSelectedPhoto(photoData);
setShowPhotoModal(true); setShowPhotoModal(true);
@@ -43,6 +110,35 @@ export function AdminBookings() {
setSelectedPhoto(""); setSelectedPhoto("");
}; };
const openMessageModal = (bookingId: string) => {
setShowMessageModal(bookingId);
setMessageText("");
};
const closeMessageModal = () => {
setShowMessageModal(null);
setMessageText("");
};
const handleSendMessage = () => {
if (!showMessageModal || !messageText.trim()) {
setErrorMsg("Bitte gib eine Nachricht ein.");
return;
}
sendMessage({
sessionId: localStorage.getItem("sessionId") || "",
bookingId: showMessageModal,
message: messageText.trim(),
});
};
// Check if booking is in the future
const isFutureBooking = (appointmentDate: string) => {
const today = new Date().toISOString().split("T")[0];
return appointmentDate >= today;
};
const filteredBookings = bookings?.filter(booking => const filteredBookings = bookings?.filter(booking =>
selectedDate ? booking.appointmentDate === selectedDate : true selectedDate ? booking.appointmentDate === selectedDate : true
).sort((a, b) => { ).sort((a, b) => {
@@ -66,6 +162,34 @@ export function AdminBookings() {
return ( return (
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl mx-auto">
{/* Success/Error Messages */}
{(successMsg || errorMsg) && (
<div className="mb-4">
{errorMsg && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
<div className="flex items-center">
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
<span className="font-medium">Fehler:</span>
<span className="ml-1">{errorMsg}</span>
</div>
</div>
)}
{successMsg && (
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-md">
<div className="flex items-center">
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
<span className="font-medium">Erfolg:</span>
<span className="ml-1">{successMsg}</span>
</div>
</div>
)}
</div>
)}
{/* Quick Stats */} {/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6"> <div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-lg shadow p-4"> <div className="bg-white rounded-lg shadow p-4">
@@ -144,12 +268,12 @@ export function AdminBookings() {
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<div> <div>
<div className="text-sm font-medium text-gray-900">{booking.customerName}</div> <div className="text-sm font-medium text-gray-900">{booking.customerName}</div>
<div className="text-sm text-gray-500">{booking.customerEmail}</div> <div className="text-sm text-gray-500">{booking.customerEmail || '—'}</div>
<div className="text-sm text-gray-500">{booking.customerPhone}</div> <div className="text-sm text-gray-500">{booking.customerPhone || '—'}</div>
</div> </div>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4">
<div className="text-sm text-gray-900">{getTreatmentName(booking.treatmentId)}</div> <div className="text-sm text-gray-900">{getTreatmentNames(booking)}</div>
{booking.notes && ( {booking.notes && (
<div className="text-sm text-gray-500">Notizen: {booking.notes}</div> <div className="text-sm text-gray-500">Notizen: {booking.notes}</div>
)} )}
@@ -183,45 +307,57 @@ export function AdminBookings() {
</span> </span>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium"> <td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex space-x-2"> <div className="flex flex-col space-y-2">
{booking.status === "pending" && ( <div className="flex space-x-2">
<> {booking.status === "pending" && (
<>
<button
onClick={() => updateBookingStatus({ sessionId: localStorage.getItem("sessionId") || "", id: booking.id, status: "confirmed" })}
className="text-green-600 hover:text-green-900"
>
Confirm
</button>
<button
onClick={() => setShowCancelConfirm(booking.id)}
className="text-red-600 hover:text-red-900"
>
Cancel
</button>
</>
)}
{booking.status === "confirmed" && (
<>
<button
onClick={() => updateBookingStatus({ sessionId: localStorage.getItem("sessionId") || "", id: booking.id, status: "completed" })}
className="text-blue-600 hover:text-blue-900"
>
Complete
</button>
<button
onClick={() => setShowCancelConfirm(booking.id)}
className="text-red-600 hover:text-red-900"
>
Cancel
</button>
</>
)}
{(booking.status === "cancelled" || booking.status === "completed") && (
<button <button
onClick={() => updateBookingStatus({ sessionId: localStorage.getItem("sessionId") || "", id: booking.id, status: "confirmed" })} onClick={() => updateBookingStatus({ sessionId: localStorage.getItem("sessionId") || "", id: booking.id, status: "confirmed" })}
className="text-green-600 hover:text-green-900" className="text-green-600 hover:text-green-900"
> >
Confirm Reactivate
</button> </button>
<button )}
onClick={() => updateBookingStatus({ sessionId: localStorage.getItem("sessionId") || "", id: booking.id, status: "cancelled" })} </div>
className="text-red-600 hover:text-red-900" {/* Show message button for future bookings with email */}
> {isFutureBooking(booking.appointmentDate) && booking.customerEmail && (
Cancel
</button>
</>
)}
{booking.status === "confirmed" && (
<>
<button
onClick={() => updateBookingStatus({ sessionId: localStorage.getItem("sessionId") || "", id: booking.id, status: "completed" })}
className="text-blue-600 hover:text-blue-900"
>
Complete
</button>
<button
onClick={() => updateBookingStatus({ sessionId: localStorage.getItem("sessionId") || "", id: booking.id, status: "cancelled" })}
className="text-red-600 hover:text-red-900"
>
Cancel
</button>
</>
)}
{(booking.status === "cancelled" || booking.status === "completed") && (
<button <button
onClick={() => updateBookingStatus({ sessionId: localStorage.getItem("sessionId") || "", id: booking.id, status: "confirmed" })} onClick={() => openMessageModal(booking.id)}
className="text-green-600 hover:text-green-900" className="text-pink-600 hover:text-pink-900 text-left"
title="Nachricht an Kunden senden"
> >
Reactivate 💬 Nachricht
</button> </button>
)} )}
</div> </div>
@@ -272,6 +408,145 @@ export function AdminBookings() {
</div> </div>
</div> </div>
)} )}
{/* Cancel Confirmation Modal */}
{showCancelConfirm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<h3 className="text-lg font-medium text-gray-900 mb-4">Buchung stornieren</h3>
<p className="text-gray-600 mb-6">
Bist du sicher, dass du diese Buchung stornieren möchtest? Diese Aktion kann nicht rückgängig gemacht werden.
</p>
<div className="flex space-x-3">
<button
onClick={() => {
const sessionId = localStorage.getItem("sessionId") || "";
updateBookingStatus({ sessionId, id: showCancelConfirm, status: "cancelled" });
}}
className="flex-1 bg-red-600 text-white py-2 px-4 rounded-md hover:bg-red-700 transition-colors"
>
Ja, stornieren
</button>
<button
onClick={() => setShowCancelConfirm(null)}
className="flex-1 bg-gray-200 text-gray-800 py-2 px-4 rounded-md hover:bg-gray-300 transition-colors"
>
Abbrechen
</button>
</div>
</div>
</div>
)}
{/* Message Modal */}
{showMessageModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-2xl w-full mx-4">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-gray-900">Nachricht an Kunden senden</h3>
<button
onClick={closeMessageModal}
className="text-gray-400 hover:text-gray-600 text-2xl"
disabled={isSendingMessage}
>
×
</button>
</div>
{(() => {
const booking = bookings?.find(b => b.id === showMessageModal);
if (!booking) return null;
// Calculate totals for multiple treatments
const hasTreatments = booking.treatments && Array.isArray(booking.treatments) && booking.treatments.length > 0;
const totalDuration = hasTreatments
? booking.treatments.reduce((sum: number, t: any) => sum + (t.duration || 0), 0)
: (booking.bookedDurationMinutes || 0);
const totalPrice = hasTreatments
? booking.treatments.reduce((sum: number, t: any) => sum + (t.price || 0), 0)
: 0;
return (
<div className="mb-4 bg-gray-50 p-4 rounded-md">
<p className="text-sm text-gray-700">
<strong>Kunde:</strong> {booking.customerName}
</p>
<p className="text-sm text-gray-700">
<strong>E-Mail:</strong> {booking.customerEmail}
</p>
<p className="text-sm text-gray-700">
<strong>Termin:</strong> {new Date(booking.appointmentDate).toLocaleDateString()} um {booking.appointmentTime}
</p>
<div className="text-sm text-gray-700 mt-2">
<strong>Behandlungen:</strong>
{hasTreatments ? (
<div className="mt-1 ml-2">
{booking.treatments.map((treatment: any, index: number) => (
<div key={index} className="mb-1">
{treatment.name} ({treatment.duration} Min., {treatment.price})
</div>
))}
{booking.treatments.length > 1 && (
<div className="mt-2 pt-2 border-t border-gray-300 font-semibold">
Gesamt: {totalDuration} Min., {totalPrice.toFixed(2)}
</div>
)}
</div>
) : booking.treatmentId ? (
<div className="mt-1 ml-2">
{treatments?.find(t => t.id === booking.treatmentId)?.name || "Unbekannte Behandlung"}
</div>
) : (
<span className="ml-2 text-gray-500">Keine Behandlung</span>
)}
</div>
</div>
);
})()}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Deine Nachricht
</label>
<textarea
value={messageText}
onChange={(e) => setMessageText(e.target.value)}
placeholder="Schreibe hier deine Nachricht an den Kunden..."
rows={6}
maxLength={5000}
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
disabled={isSendingMessage}
/>
<p className="text-xs text-gray-500 mt-1">
{messageText.length} / 5000 Zeichen
</p>
</div>
<div className="bg-blue-50 border-l-4 border-blue-400 p-3 mb-4">
<p className="text-sm text-blue-700">
💡 <strong>Hinweis:</strong> Der Kunde kann direkt auf diese E-Mail antworten. Die Antwort geht an die in den Einstellungen hinterlegte Admin-E-Mail-Adresse.
</p>
</div>
<div className="flex space-x-3">
<button
onClick={handleSendMessage}
disabled={isSendingMessage || !messageText.trim()}
className="flex-1 bg-pink-600 text-white py-2 px-4 rounded-md hover:bg-pink-700 transition-colors disabled:bg-gray-300 disabled:cursor-not-allowed"
>
{isSendingMessage ? "Wird gesendet..." : "Nachricht senden"}
</button>
<button
onClick={closeMessageModal}
disabled={isSendingMessage}
className="flex-1 bg-gray-200 text-gray-800 py-2 px-4 rounded-md hover:bg-gray-300 transition-colors disabled:bg-gray-100 disabled:cursor-not-allowed"
>
Abbrechen
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@@ -5,6 +5,34 @@ import { queryClient } from "@/client/rpc-client";
export function AdminCalendar() { export function AdminCalendar() {
const [currentMonth, setCurrentMonth] = useState(new Date()); const [currentMonth, setCurrentMonth] = useState(new Date());
const [selectedDate, setSelectedDate] = useState<string | null>(null); const [selectedDate, setSelectedDate] = useState<string | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(null);
const [sendDeleteEmail, setSendDeleteEmail] = useState(false);
const [deleteActionType, setDeleteActionType] = useState<'delete' | 'cancel'>('delete');
// CalDAV state
const [caldavData, setCaldavData] = useState<any>(null);
const [showCaldavInstructions, setShowCaldavInstructions] = useState(false);
// Manual booking modal state
const [showCreateModal, setShowCreateModal] = useState(false);
const [createFormData, setCreateFormData] = useState({
customerName: '',
treatmentId: '',
appointmentDate: '',
appointmentTime: '',
customerEmail: '',
customerPhone: '',
notes: ''
});
const [createError, setCreateError] = useState<string>('');
// Reschedule modal state
const [showRescheduleModal, setShowRescheduleModal] = useState<string | null>(null);
const [rescheduleFormData, setRescheduleFormData] = useState({
appointmentDate: '',
appointmentTime: ''
});
const [rescheduleError, setRescheduleError] = useState<string>('');
const { data: bookings } = useQuery( const { data: bookings } = useQuery(
queryClient.bookings.live.list.experimental_liveOptions() queryClient.bookings.live.list.experimental_liveOptions()
@@ -14,12 +42,69 @@ export function AdminCalendar() {
queryClient.treatments.live.list.experimental_liveOptions() queryClient.treatments.live.list.experimental_liveOptions()
); );
// Optional query for available times when treatment and date are selected
const { data: availableTimes } = useQuery({
...queryClient.recurringAvailability.getAvailableTimes.queryOptions({
input: {
date: createFormData.appointmentDate,
treatmentIds: createFormData.treatmentId ? [createFormData.treatmentId] : []
}
}),
enabled: !!createFormData.appointmentDate && !!createFormData.treatmentId
});
// Available times for reschedule modal
const { data: rescheduleAvailableTimes } = useQuery({
...queryClient.recurringAvailability.getAvailableTimes.queryOptions({
input: {
date: rescheduleFormData.appointmentDate,
treatmentIds: (() => {
const booking = showRescheduleModal ? bookings?.find(b => b.id === showRescheduleModal) : null;
if (!booking) return [];
// Use new treatments array if available
if (booking.treatments && Array.isArray(booking.treatments) && booking.treatments.length > 0) {
return booking.treatments.map((t: any) => t.id);
}
// Fallback to deprecated treatmentId for backward compatibility
return booking.treatmentId ? [booking.treatmentId] : [];
})()
}
}),
enabled: !!showRescheduleModal && !!rescheduleFormData.appointmentDate
});
const { mutate: updateBookingStatus } = useMutation( const { mutate: updateBookingStatus } = useMutation(
queryClient.bookings.updateStatus.mutationOptions() queryClient.bookings.updateStatus.mutationOptions()
); );
const getTreatmentName = (treatmentId: string) => { const { mutate: removeBooking } = useMutation(
return treatments?.find(t => t.id === treatmentId)?.name || "Unbekannte Behandlung"; queryClient.bookings.remove.mutationOptions()
);
const { mutate: createManualBooking } = useMutation(
queryClient.bookings.createManual.mutationOptions()
);
// Propose reschedule mutation
const { mutate: proposeReschedule, isPending: isProposingReschedule } = useMutation(
queryClient.bookings.proposeReschedule.mutationOptions()
);
// CalDAV token generation mutation
const { mutate: generateCalDAVToken, isPending: isGeneratingToken } = useMutation(
queryClient.bookings.generateCalDAVToken.mutationOptions()
);
const getTreatmentNames = (booking: any) => {
// Handle new treatments array structure
if (booking.treatments && Array.isArray(booking.treatments) && booking.treatments.length > 0) {
return booking.treatments.map((t: any) => t.name).join(", ");
}
// Fallback to deprecated treatmentId for backward compatibility
if (booking.treatmentId) {
return treatments?.find(t => t.id === booking.treatmentId)?.name || "Unbekannte Behandlung";
}
return "Keine Behandlung";
}; };
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
@@ -106,8 +191,161 @@ export function AdminCalendar() {
}); });
}; };
const handleDeleteBooking = () => {
const sessionId = localStorage.getItem('sessionId');
if (!sessionId || !showDeleteConfirm) return;
if (deleteActionType === 'cancel') {
// For cancel action, use updateStatus instead of remove
updateBookingStatus({
sessionId,
id: showDeleteConfirm,
status: "cancelled"
}, {
onSuccess: () => {
setShowDeleteConfirm(null);
setSendDeleteEmail(false);
setDeleteActionType('delete');
},
onError: () => {
// no-op; errors can be surfaced via existing patterns/toasts later
}
});
} else {
// For delete action, use remove with email option
removeBooking({
sessionId,
id: showDeleteConfirm,
sendEmail: sendDeleteEmail,
}, {
onSuccess: () => {
setShowDeleteConfirm(null);
setSendDeleteEmail(false);
setDeleteActionType('delete');
},
onError: () => {
// no-op; errors can be surfaced via existing patterns/toasts later
}
});
}
};
const today = new Date().toISOString().split('T')[0]; const today = new Date().toISOString().split('T')[0];
const handleCreateBooking = () => {
const sessionId = localStorage.getItem('sessionId');
if (!sessionId) return;
// Convert treatmentId to treatments array
const selectedTreatment = treatments?.find(t => t.id === createFormData.treatmentId);
if (!selectedTreatment) {
setCreateError('Bitte wähle eine Behandlung aus.');
return;
}
const treatmentsArray = [{
id: selectedTreatment.id,
name: selectedTreatment.name,
duration: selectedTreatment.duration,
price: selectedTreatment.price
}];
createManualBooking({
sessionId,
treatments: treatmentsArray,
customerName: createFormData.customerName,
appointmentDate: createFormData.appointmentDate,
appointmentTime: createFormData.appointmentTime,
customerEmail: createFormData.customerEmail,
customerPhone: createFormData.customerPhone,
notes: createFormData.notes
}, {
onSuccess: () => {
setShowCreateModal(false);
setCreateFormData({
customerName: '',
treatmentId: '',
appointmentDate: '',
appointmentTime: '',
customerEmail: '',
customerPhone: '',
notes: ''
});
setCreateError('');
},
onError: (error: any) => {
setCreateError(error?.message || 'Fehler beim Erstellen der Buchung');
}
});
};
const handleFormChange = (field: string, value: string) => {
setCreateFormData(prev => ({
...prev,
[field]: value,
// Reset time when treatment or date changes
...(field === 'treatmentId' || field === 'appointmentDate' ? { appointmentTime: '' } : {})
}));
setCreateError('');
};
const handleRescheduleFormChange = (field: string, value: string) => {
setRescheduleFormData(prev => ({
...prev,
[field]: value,
...(field === 'appointmentDate' ? { appointmentTime: '' } : {})
}));
setRescheduleError('');
};
const handleRescheduleBooking = () => {
const sessionId = localStorage.getItem('sessionId');
if (!sessionId || !showRescheduleModal) return;
const booking = bookings?.find(b => b.id === showRescheduleModal);
if (!booking) return;
proposeReschedule({
sessionId,
bookingId: booking.id,
proposedDate: rescheduleFormData.appointmentDate,
proposedTime: rescheduleFormData.appointmentTime,
}, {
onSuccess: () => {
setShowRescheduleModal(null);
setRescheduleFormData({ appointmentDate: '', appointmentTime: '' });
setRescheduleError('');
},
onError: (error: any) => {
setRescheduleError(error?.message || 'Fehler beim Senden des Vorschlags');
}
});
};
const handleGenerateCalDAVToken = () => {
const sessionId = localStorage.getItem('sessionId');
if (!sessionId) return;
generateCalDAVToken({
sessionId
}, {
onSuccess: (data) => {
setCaldavData(data);
setShowCaldavInstructions(true);
},
onError: (error: any) => {
console.error('CalDAV Token Generation Error:', error);
}
});
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text).then(() => {
// Optional: Show success message
}).catch(err => {
console.error('Failed to copy text: ', err);
});
};
return ( return (
<div className="max-w-7xl mx-auto"> <div className="max-w-7xl mx-auto">
<h2 className="text-2xl font-bold text-gray-900 mb-6">Kalender - Bevorstehende Buchungen</h2> <h2 className="text-2xl font-bold text-gray-900 mb-6">Kalender - Bevorstehende Buchungen</h2>
@@ -140,6 +378,62 @@ export function AdminCalendar() {
</div> </div>
</div> </div>
{/* CalDAV Integration */}
<div className="bg-white rounded-lg shadow p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-gray-900">Kalender-Abonnement</h3>
<p className="text-sm text-gray-600">Abonniere deinen Terminkalender in deiner Kalender-App</p>
</div>
<button
onClick={handleGenerateCalDAVToken}
disabled={isGeneratingToken}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm font-medium"
>
{isGeneratingToken ? 'Generiere...' : 'CalDAV-Link erstellen'}
</button>
</div>
{caldavData && (
<div className="border-t pt-4">
<div className="bg-gray-50 rounded-lg p-4 mb-4">
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-gray-700">CalDAV-URL:</label>
<button
onClick={() => copyToClipboard(caldavData.caldavUrl)}
className="text-blue-600 hover:text-blue-800 text-sm"
>
Kopieren
</button>
</div>
<input
type="text"
value={caldavData.caldavUrl}
readOnly
className="w-full p-2 bg-white border border-gray-300 rounded text-sm font-mono"
/>
<div className="text-xs text-gray-500 mt-2">
Gültig bis: {new Date(caldavData.expiresAt).toLocaleString('de-DE')}
</div>
</div>
<div className="text-sm text-gray-600">
<p className="mb-2">
<strong>So abonnierst du den Kalender:</strong>
</p>
<ul className="list-disc list-inside space-y-1 text-sm">
{caldavData.instructions.steps.map((step: string, index: number) => (
<li key={index}>{step}</li>
))}
</ul>
<p className="mt-3 text-amber-700 bg-amber-50 p-2 rounded">
<strong>Hinweis:</strong> {caldavData.instructions.note}
</p>
</div>
</div>
)}
</div>
{/* Calendar */} {/* Calendar */}
<div className="bg-white rounded-lg shadow-lg overflow-hidden"> <div className="bg-white rounded-lg shadow-lg overflow-hidden">
{/* Calendar Header */} {/* Calendar Header */}
@@ -153,9 +447,17 @@ export function AdminCalendar() {
</svg> </svg>
</button> </button>
<h3 className="text-xl font-semibold text-gray-900"> <div className="flex items-center space-x-4">
{monthNames[month]} {year} <h3 className="text-xl font-semibold text-gray-900">
</h3> {monthNames[month]} {year}
</h3>
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-pink-600 text-white rounded-lg hover:bg-pink-700 transition-colors text-sm font-medium"
>
Termin erstellen
</button>
</div>
<button <button
onClick={() => navigateMonth('next')} onClick={() => navigateMonth('next')}
@@ -204,7 +506,7 @@ export function AdminCalendar() {
<div <div
key={booking.id} key={booking.id}
className={`text-xs p-1 rounded border-l-2 ${getStatusColor(booking.status)} truncate`} className={`text-xs p-1 rounded border-l-2 ${getStatusColor(booking.status)} truncate`}
title={`${booking.customerName} - ${getTreatmentName(booking.treatmentId)} (${booking.appointmentTime})`} title={`${booking.customerName} - ${getTreatmentNames(booking)} (${booking.appointmentTime})`}
> >
<div className="font-medium">{booking.appointmentTime}</div> <div className="font-medium">{booking.appointmentTime}</div>
<div className="truncate">{booking.customerName}</div> <div className="truncate">{booking.customerName}</div>
@@ -261,16 +563,16 @@ export function AdminCalendar() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm text-gray-600"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm text-gray-600">
<div> <div>
<strong>Behandlung:</strong> {getTreatmentName(booking.treatmentId)} <strong>Behandlung:</strong> {getTreatmentNames(booking)}
</div> </div>
<div> <div>
<strong>Uhrzeit:</strong> {booking.appointmentTime} <strong>Uhrzeit:</strong> {booking.appointmentTime}
</div> </div>
<div> <div>
<strong>E-Mail:</strong> {booking.customerEmail} <strong>E-Mail:</strong> {booking.customerEmail || '—'}
</div> </div>
<div> <div>
<strong>Telefon:</strong> {booking.customerPhone} <strong>Telefon:</strong> {booking.customerPhone || '—'}
</div> </div>
</div> </div>
@@ -293,7 +595,11 @@ export function AdminCalendar() {
Bestätigen Bestätigen
</button> </button>
<button <button
onClick={() => handleStatusUpdate(booking.id, "cancelled")} onClick={() => {
setDeleteActionType('cancel');
setShowDeleteConfirm(booking.id);
setSendDeleteEmail(true); // Default to sending email for cancel
}}
className="px-3 py-1 bg-red-600 text-white text-sm rounded hover:bg-red-700 transition-colors" className="px-3 py-1 bg-red-600 text-white text-sm rounded hover:bg-red-700 transition-colors"
> >
Stornieren Stornieren
@@ -302,13 +608,51 @@ export function AdminCalendar() {
)} )}
{booking.status === "confirmed" && ( {booking.status === "confirmed" && (
<button <>
onClick={() => handleStatusUpdate(booking.id, "completed")} <button
className="px-3 py-1 bg-blue-600 text-white text-sm rounded hover:bg-blue-700 transition-colors" onClick={() => handleStatusUpdate(booking.id, "completed")}
> className="px-3 py-1 bg-blue-600 text-white text-sm rounded hover:bg-blue-700 transition-colors"
Als erledigt markieren >
</button> Als erledigt markieren
</button>
<button
onClick={() => {
setShowRescheduleModal(booking.id);
setRescheduleFormData({ appointmentDate: '', appointmentTime: '' });
setRescheduleError('');
}}
className="px-3 py-1 bg-orange-600 text-white text-sm rounded hover:bg-orange-700 transition-colors"
>
Umbuchen
</button>
</>
)} )}
{(() => {
const isPastDate = booking.appointmentDate < today;
const isCompleted = booking.status === 'completed';
const shouldDisableDelete = isPastDate || isCompleted;
return (
<button
onClick={() => {
if (!shouldDisableDelete) {
setDeleteActionType('delete');
setShowDeleteConfirm(booking.id);
setSendDeleteEmail(isPastDate || isCompleted ? false : false); // Disable email for past/completed
}
}}
disabled={shouldDisableDelete}
className={`px-3 py-1 text-sm rounded transition-colors ${
shouldDisableDelete
? 'bg-gray-400 text-gray-200 cursor-not-allowed'
: 'bg-red-600 text-white hover:bg-red-700'
}`}
title={shouldDisableDelete ? 'Löschen für vergangene/abgeschlossene Termine nicht verfügbar' : ''}
>
Löschen
</button>
);
})()}
</div> </div>
</div> </div>
</div> </div>
@@ -317,6 +661,289 @@ export function AdminCalendar() {
)} )}
</div> </div>
)} )}
{showDeleteConfirm !== null && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl p-6 w-full max-w-md">
<h4 className="text-lg font-semibold text-gray-900 mb-2">
{deleteActionType === 'cancel' ? 'Termin stornieren?' : 'Termin löschen?'}
</h4>
<p className="text-sm text-gray-600 mb-4">
{deleteActionType === 'cancel'
? 'Dieser Termin wird als storniert markiert. Der Zeitslot wird wieder freigegeben.'
: 'Dieser Termin wird als storniert markiert. Der Zeitslot wird wieder freigegeben.'
}
</p>
{deleteActionType === 'delete' && (
<label className="flex items-center space-x-2 mb-4">
<input
type="checkbox"
checked={sendDeleteEmail}
onChange={(e) => setSendDeleteEmail(e.target.checked)}
className="h-4 w-4 text-pink-600 border-gray-300 rounded"
/>
<span className="text-sm text-gray-700">Stornierungsmail an Kunde senden</span>
</label>
)}
<div className="flex flex-col sm:flex-row sm:space-x-3 space-y-2 sm:space-y-0">
<button
onClick={() => {
setShowDeleteConfirm(null);
setSendDeleteEmail(false);
setDeleteActionType('delete');
}}
className="px-4 py-2 rounded bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors w-full"
>
Abbrechen
</button>
<button
onClick={handleDeleteBooking}
className="px-4 py-2 rounded bg-red-600 text-white hover:bg-red-700 transition-colors w-full"
>
{deleteActionType === 'cancel' ? 'Stornieren' : 'Löschen'}
</button>
</div>
</div>
</div>
)}
{/* Create Manual Booking Modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl p-6 w-full max-w-md max-h-[90vh] overflow-y-auto">
<h4 className="text-lg font-semibold text-gray-900 mb-4">Termin erstellen</h4>
{createError && (
<div className="mb-4 bg-red-50 border border-red-200 text-red-700 px-3 py-2 rounded-md text-sm">
{createError}
</div>
)}
<div className="space-y-4">
{/* Customer Name */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Kundenname *
</label>
<input
type="text"
value={createFormData.customerName}
onChange={(e) => handleFormChange('customerName', e.target.value)}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
required
/>
</div>
{/* Treatment */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Behandlung *
</label>
<select
value={createFormData.treatmentId}
onChange={(e) => handleFormChange('treatmentId', e.target.value)}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
required
>
<option value="">Behandlung wählen</option>
{treatments?.map(treatment => (
<option key={treatment.id} value={treatment.id}>
{treatment.name}
</option>
))}
</select>
</div>
{/* Date */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Datum *
</label>
<input
type="date"
value={createFormData.appointmentDate}
onChange={(e) => handleFormChange('appointmentDate', e.target.value)}
min={today}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
required
/>
</div>
{/* Time */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Uhrzeit *
</label>
<select
value={createFormData.appointmentTime}
onChange={(e) => handleFormChange('appointmentTime', e.target.value)}
disabled={!createFormData.treatmentId || !createFormData.appointmentDate}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500 disabled:bg-gray-100"
required
>
<option value="">Zeit wählen</option>
{availableTimes?.map(time => (
<option key={time} value={time}>
{time}
</option>
))}
</select>
{(!createFormData.treatmentId || !createFormData.appointmentDate) && (
<p className="text-xs text-gray-500 mt-1">
Wähle zuerst Behandlung und Datum
</p>
)}
</div>
{/* Email */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
E-Mail (optional)
</label>
<input
type="email"
value={createFormData.customerEmail}
onChange={(e) => handleFormChange('customerEmail', e.target.value)}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
/>
</div>
{/* Phone */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Telefon (optional)
</label>
<input
type="tel"
value={createFormData.customerPhone}
onChange={(e) => handleFormChange('customerPhone', e.target.value)}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
/>
</div>
{/* Notes */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Notizen (optional)
</label>
<textarea
value={createFormData.notes}
onChange={(e) => handleFormChange('notes', e.target.value)}
rows={3}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
/>
</div>
</div>
<div className="flex space-x-3 mt-6">
<button
onClick={() => {
setShowCreateModal(false);
setCreateFormData({
customerName: '',
treatmentId: '',
appointmentDate: '',
appointmentTime: '',
customerEmail: '',
customerPhone: '',
notes: ''
});
setCreateError('');
}}
className="flex-1 px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition-colors"
>
Abbrechen
</button>
<button
onClick={handleCreateBooking}
disabled={!createFormData.customerName || !createFormData.treatmentId || !createFormData.appointmentDate || !createFormData.appointmentTime}
className="flex-1 px-4 py-2 bg-pink-600 text-white rounded-md hover:bg-pink-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Termin erstellen
</button>
</div>
</div>
</div>
)}
{/* Reschedule Modal */}
{showRescheduleModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl p-6 w-full max-w-md max-h-[90vh] overflow-y-auto">
<h4 className="text-lg font-semibold text-gray-900 mb-4">Termin umbuchen</h4>
{rescheduleError && (
<div className="mb-4 bg-red-50 border border-red-200 text-red-700 px-3 py-2 rounded-md text-sm">
{rescheduleError}
</div>
)}
{(() => {
const booking = bookings?.find(b => b.id === showRescheduleModal);
const treatmentName = booking ? getTreatmentNames(booking) : '';
return booking ? (
<div className="mb-4 text-sm text-gray-700">
<div className="mb-2"><strong>Kunde:</strong> {booking.customerName}</div>
<div className="mb-2"><strong>Aktueller Termin:</strong> {booking.appointmentDate} um {booking.appointmentTime} Uhr</div>
<div className="mb-2"><strong>Behandlung:</strong> {treatmentName}</div>
</div>
) : null;
})()}
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Neues Datum</label>
<input
type="date"
value={rescheduleFormData.appointmentDate}
onChange={(e) => handleRescheduleFormChange('appointmentDate', e.target.value)}
min={today}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Neue Uhrzeit</label>
<select
value={rescheduleFormData.appointmentTime}
onChange={(e) => handleRescheduleFormChange('appointmentTime', e.target.value)}
disabled={!rescheduleFormData.appointmentDate}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-orange-500 focus:border-orange-500 disabled:bg-gray-100"
>
<option value="">Zeit wählen</option>
{rescheduleAvailableTimes?.map(time => (
<option key={time} value={time}>{time}</option>
))}
</select>
{!rescheduleFormData.appointmentDate && (
<p className="text-xs text-gray-500 mt-1">Wähle zuerst ein Datum</p>
)}
</div>
<div className="bg-amber-50 border border-amber-200 text-amber-800 px-3 py-2 rounded-md text-sm">
Der Kunde erhält eine E-Mail mit dem Vorschlag. Er hat 48 Stunden Zeit zu antworten.
</div>
</div>
<div className="flex space-x-3 mt-6">
<button
onClick={() => {
setShowRescheduleModal(null);
setRescheduleFormData({ appointmentDate: '', appointmentTime: '' });
setRescheduleError('');
}}
className="flex-1 px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition-colors"
>
Abbrechen
</button>
<button
onClick={handleRescheduleBooking}
disabled={!rescheduleFormData.appointmentDate || !rescheduleFormData.appointmentTime || isProposingReschedule}
className="flex-1 px-4 py-2 bg-orange-600 text-white rounded-md hover:bg-orange-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isProposingReschedule ? 'Senden...' : 'Vorschlag senden'}
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,464 @@
import { useState, useEffect, useMemo } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { queryClient } from "@/client/rpc-client";
export function AdminGallery() {
// Component state
const [photoTitle, setPhotoTitle] = useState<string>("");
const [photoPreview, setPhotoPreview] = useState<string>("");
const [photoBase64, setPhotoBase64] = useState<string>("");
const [errorMsg, setErrorMsg] = useState<string>("");
const [successMsg, setSuccessMsg] = useState<string>("");
const [draggedPhotoId, setDraggedPhotoId] = useState<string | null>(null);
// Data fetching with live query
const { data: photos, refetch: refetchPhotos } = useQuery(
queryClient.gallery.live.adminListPhotos.experimental_liveOptions({
input: { sessionId: localStorage.getItem("sessionId") || "" }
})
);
// Memoized sorted photos to avoid re-sorting on every render and prevent cache mutation
const sortedPhotos = useMemo(() => [...(photos || [])].sort((a, b) => a.order - b.order), [photos]);
// Mutations
const { mutate: uploadPhoto, isPending: isUploading } = useMutation(
queryClient.gallery.uploadPhoto.mutationOptions()
);
const { mutate: deletePhoto } = useMutation(
queryClient.gallery.deletePhoto.mutationOptions()
);
const { mutate: updatePhotoOrder } = useMutation(
queryClient.gallery.updatePhotoOrder.mutationOptions()
);
const { mutate: setCoverPhoto } = useMutation(
queryClient.gallery.setCoverPhoto.mutationOptions()
);
// Auto-clear messages after 5 seconds
useEffect(() => {
if (errorMsg) {
const timer = setTimeout(() => setErrorMsg(""), 5000);
return () => clearTimeout(timer);
}
}, [errorMsg]);
useEffect(() => {
if (successMsg) {
const timer = setTimeout(() => setSuccessMsg(""), 5000);
return () => clearTimeout(timer);
}
}, [successMsg]);
// Image compression function (adapted from booking-form.tsx)
const compressImage = (file: File, maxWidth: number = 800, quality: number = 0.8): Promise<string> => {
return new Promise((resolve, reject) => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const img = new Image();
img.onload = () => {
// Calculate new dimensions
let { width, height } = img;
if (width > maxWidth) {
height = (height * maxWidth) / width;
width = maxWidth;
}
canvas.width = width;
canvas.height = height;
// Draw and compress
ctx?.drawImage(img, 0, 0, width, height);
const compressedDataUrl = canvas.toDataURL('image/jpeg', quality);
resolve(compressedDataUrl);
};
img.onerror = reject;
img.src = URL.createObjectURL(file);
});
};
// File upload handler
const handlePhotoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Validate file type
if (!file.type.startsWith('image/')) {
setErrorMsg("Bitte wähle nur Bilddateien aus.");
return;
}
// Check file size (max 1MB)
if (file.size > 1 * 1024 * 1024) {
setErrorMsg("Das Foto ist zu groß. Bitte wähle ein Bild unter 1MB.");
return;
}
try {
// Helper: measure DataURL size via Blob
const getDataUrlSizeBytes = async (dataUrl: string): Promise<number> => {
const res = await fetch(dataUrl);
const blob = await res.blob();
return blob.size;
};
// Try compressing with decreasing quality until <= 1MB or give up
const qualitySteps = [0.8, 0.6, 0.4];
let finalDataUrl = "";
for (const q of qualitySteps) {
const candidate = await compressImage(file, 800, q);
const sizeBytes = await getDataUrlSizeBytes(candidate);
if (sizeBytes <= 1 * 1024 * 1024) {
finalDataUrl = candidate;
break;
}
}
if (!finalDataUrl) {
setErrorMsg("Das komprimierte Bild ist weiterhin größer als 1MB. Bitte wähle ein kleineres Bild.");
return;
}
setPhotoBase64(finalDataUrl);
setPhotoPreview(finalDataUrl);
setErrorMsg("");
} catch (error) {
console.error('Photo compression failed:', error);
setErrorMsg('Fehler beim Verarbeiten des Bildes. Bitte versuche es mit einem anderen Bild.');
return;
}
};
const removePhoto = () => {
setPhotoBase64("");
setPhotoPreview("");
// Reset file input
const fileInput = document.getElementById('gallery-photo-upload') as HTMLInputElement;
if (fileInput) fileInput.value = '';
};
// Drag and drop handlers
const handleDragStart = (e: React.DragEvent, photoId: string) => {
setDraggedPhotoId(photoId);
e.dataTransfer.effectAllowed = 'move';
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
};
const handleDrop = (e: React.DragEvent, targetPhotoId: string) => {
e.preventDefault();
if (!draggedPhotoId || draggedPhotoId === targetPhotoId) {
setDraggedPhotoId(null);
return;
}
const draggedPhoto = photos?.find(p => p.id === draggedPhotoId);
const targetPhoto = photos?.find(p => p.id === targetPhotoId);
if (!draggedPhoto || !targetPhoto) {
setDraggedPhotoId(null);
return;
}
const sessionId = localStorage.getItem("sessionId");
if (!sessionId) {
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
setDraggedPhotoId(null);
return;
}
// Build a fully reordered list based on the current sorted order
const sorted = [...(photos || [])].sort((a, b) => a.order - b.order);
const fromIndex = sorted.findIndex(p => p.id === draggedPhotoId);
const toIndex = sorted.findIndex(p => p.id === targetPhotoId);
if (fromIndex === -1 || toIndex === -1) {
setDraggedPhotoId(null);
return;
}
const reordered = [...sorted];
const [moved] = reordered.splice(fromIndex, 1);
reordered.splice(toIndex, 0, moved);
// Reindex orders 0..n-1
const photoOrders = reordered.map((p, idx) => ({ id: p.id, order: idx }));
updatePhotoOrder(
{
sessionId,
photoOrders
},
{
onSuccess: () => {
setSuccessMsg("Foto-Reihenfolge aktualisiert.");
// Sofortige Aktualisierung der Thumbnails in korrekter Reihenfolge
refetchPhotos();
},
onError: (err: any) => {
setErrorMsg(err?.message || "Fehler beim Aktualisieren der Reihenfolge.");
}
}
);
setDraggedPhotoId(null);
};
const handleDragEnd = () => {
setDraggedPhotoId(null);
};
return (
<div className="max-w-6xl mx-auto space-y-6">
{/* Error and Success Messages */}
{errorMsg && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
<div className="flex items-center">
<svg className="w-5 h-5 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
<span className="font-medium">{errorMsg}</span>
</div>
</div>
)}
{successMsg && (
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-md">
<div className="flex items-center">
<svg className="w-5 h-5 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
<span className="font-medium">{successMsg}</span>
</div>
</div>
)}
{/* Upload Form Section */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold mb-4">Neues Foto hochladen</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Foto auswählen *
</label>
<input
id="gallery-photo-upload"
type="file"
accept="image/*"
onChange={handlePhotoUpload}
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-pink-50 file:text-pink-700 hover:file:bg-pink-100"
/>
<p className="text-xs text-gray-500 mt-1">
Max. 1MB, alle Bildformate erlaubt
</p>
</div>
{photoPreview && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Vorschau
</label>
<div className="relative inline-block">
<img
src={photoPreview}
alt="Foto Vorschau"
className="w-32 h-32 object-cover rounded-lg border border-gray-200"
/>
<button
type="button"
onClick={removePhoto}
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-sm hover:bg-red-600"
>
×
</button>
</div>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Titel (optional)
</label>
<input
type="text"
value={photoTitle}
onChange={(e) => setPhotoTitle(e.target.value)}
placeholder="z.B. French Manicure, Gel Nails..."
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-pink-500"
/>
</div>
<button
onClick={() => {
setErrorMsg("");
setSuccessMsg("");
if (!photoBase64) {
setErrorMsg("Bitte wähle zuerst ein Foto aus.");
return;
}
const sessionId = localStorage.getItem("sessionId") || "";
if (!sessionId) {
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
return;
}
uploadPhoto(
{
sessionId,
base64Data: photoBase64,
title: photoTitle || undefined
},
{
onSuccess: () => {
setSuccessMsg("Foto erfolgreich hochgeladen.");
setPhotoTitle("");
setPhotoPreview("");
setPhotoBase64("");
// Reset file input
const fileInput = document.getElementById('gallery-photo-upload') as HTMLInputElement;
if (fileInput) fileInput.value = '';
// Sofortige Aktualisierung der Liste
refetchPhotos();
},
onError: (err: any) => {
setErrorMsg(err?.message || "Fehler beim Hochladen des Fotos.");
}
}
);
}}
disabled={isUploading}
className={`bg-pink-600 text-white px-4 py-2 rounded-md font-medium transition-colors ${isUploading ? 'opacity-60 cursor-not-allowed' : 'hover:bg-pink-700'}`}
>
{isUploading ? 'Lädt hoch…' : 'Foto hochladen'}
</button>
</div>
</div>
{/* Photo Grid Section */}
<div className="bg-white rounded-lg shadow">
<div className="p-4 border-b">
<h3 className="text-lg font-semibold">Foto-Galerie verwalten</h3>
<p className="text-sm text-gray-600 mt-1">
Ziehe Fotos per Drag & Drop, um die Reihenfolge zu ändern. Klicke auf das X, um ein Foto zu löschen.
</p>
</div>
<div className="p-6">
{photos?.length === 0 && (
<div className="text-center text-gray-500 py-8">
<div className="text-lg font-medium mb-2">Noch keine Fotos hochgeladen</div>
<div className="text-sm">Lade dein erstes Foto hoch, um deine Galerie zu starten.</div>
</div>
)}
{sortedPhotos && sortedPhotos.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{sortedPhotos.map((photo) => (
<div
key={photo.id}
draggable={true}
onDragStart={(e) => handleDragStart(e, photo.id)}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, photo.id)}
onDragEnd={handleDragEnd}
className={`relative bg-gray-50 rounded-lg overflow-hidden border-2 transition-all duration-200 hover:shadow-md cursor-move ${
draggedPhotoId === photo.id ? 'opacity-50 border-pink-300' : 'border-transparent'
}`}
>
<img
src={photo.base64Data}
alt={photo.title || "Galerie Foto"}
className="w-full h-48 object-cover"
/>
<div className="p-3">
<div className="flex items-center justify-between">
<div className="flex-1">
{photo.title && (
<div className="font-medium text-gray-900 text-sm mb-1">
{photo.title}
</div>
)}
<div className="text-xs text-gray-500">
Reihenfolge: {photo.order}
</div>
<div className="text-xs text-gray-400">
{new Date(photo.createdAt).toLocaleDateString('de-DE')}
</div>
</div>
<button
onClick={() => {
if (confirm("Möchtest du dieses Foto wirklich löschen?")) {
const sessionId = localStorage.getItem("sessionId");
if (!sessionId) {
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
return;
}
deletePhoto(
{ sessionId, id: photo.id },
{
onSuccess: () => {
setSuccessMsg("Foto gelöscht.");
// Sofortige Aktualisierung der Liste
refetchPhotos();
},
onError: (err: any) => {
setErrorMsg(err?.message || "Fehler beim Löschen des Fotos.");
}
}
);
}
}}
className="ml-2 text-red-600 hover:text-red-800 hover:bg-red-50 rounded-full p-1 transition-colors"
title="Foto löschen"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<button
onClick={() => {
const sessionId = localStorage.getItem("sessionId");
if (!sessionId) {
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
return;
}
setCoverPhoto(
{ sessionId, id: photo.id },
{
onSuccess: () => setSuccessMsg("Als Cover-Bild gesetzt."),
onError: (err: any) => setErrorMsg(err?.message || "Fehler beim Setzen des Cover-Bildes."),
}
);
}}
className={`ml-2 ${photo.cover ? 'text-green-700 hover:text-green-800 hover:bg-green-50' : 'text-gray-600 hover:text-gray-800 hover:bg-gray-50'} rounded-full p-1 transition-colors`}
title={photo.cover ? "Cover-Bild (aktiv)" : "Als Cover-Bild setzen"}
>
{photo.cover ? (
<svg className="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-7.25 7.25a1 1 0 01-1.414 0l-3-3a1 1 0 111.414-1.414L8.5 11.086l6.543-6.543a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l6-6 4 4 6-6" />
</svg>
)}
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,356 @@
import React, { useState, useEffect } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { queryClient } from "@/client/rpc-client";
type ReviewStatus = "pending" | "approved" | "rejected";
type Review = {
id: string;
bookingId: string;
customerName: string;
customerEmail?: string;
rating: number;
comment: string;
status: "pending" | "approved" | "rejected";
createdAt: string;
reviewedAt?: string;
reviewedBy?: string;
};
function getStatusText(status: string) {
switch (status) {
case "pending":
return "Ausstehend";
case "approved":
return "Genehmigt";
case "rejected":
return "Abgelehnt";
default:
return status;
}
}
function getStatusColor(status: string) {
switch (status) {
case "pending":
return "bg-yellow-100 text-yellow-800";
case "approved":
return "bg-green-100 text-green-800";
case "rejected":
return "bg-red-100 text-red-800";
default:
return "bg-gray-100 text-gray-800";
}
}
function renderStars(rating: number) {
const stars = [] as React.ReactElement[];
for (let i = 1; i <= 5; i++) {
const filled = i <= rating;
stars.push(
<span key={i} className={filled ? "text-yellow-500" : "text-gray-300"}>
{filled ? "★" : "☆"}
</span>
);
}
return <div className="text-lg">{stars}</div>;
}
function formatDate(isoString?: string) {
if (!isoString) return "";
try {
return new Date(isoString).toLocaleDateString("de-DE", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return isoString || "";
}
}
export function AdminReviews() {
const [activeStatusTab, setActiveStatusTab] = useState<ReviewStatus>("pending");
const [successMsg, setSuccessMsg] = useState<string>("");
const [errorMsg, setErrorMsg] = useState<string>("");
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(null);
useEffect(() => {
if (errorMsg) {
const t = setTimeout(() => setErrorMsg(""), 5000);
return () => clearTimeout(t);
}
}, [errorMsg]);
useEffect(() => {
if (successMsg) {
const t = setTimeout(() => setSuccessMsg(""), 5000);
return () => clearTimeout(t);
}
}, [successMsg]);
const sessionId = typeof window !== "undefined" ? localStorage.getItem("sessionId") || "" : "";
const { data: reviews } = useQuery(
queryClient.reviews.live.adminListReviews.experimental_liveOptions({
input: { sessionId, statusFilter: activeStatusTab },
})
);
// Separate queries for quick stats calculation
const { data: allReviews } = useQuery(
queryClient.reviews.live.adminListReviews.experimental_liveOptions({
input: { sessionId },
})
);
const { mutate: approveReview } = useMutation(
queryClient.reviews.approveReview.mutationOptions({
onSuccess: () => {
setSuccessMsg("Bewertung wurde genehmigt.");
},
onError: (err: any) => {
setErrorMsg(err?.message || "Fehler beim Genehmigen der Bewertung.");
},
})
);
const { mutate: rejectReview } = useMutation(
queryClient.reviews.rejectReview.mutationOptions({
onSuccess: () => {
setSuccessMsg("Bewertung wurde abgelehnt.");
},
onError: (err: any) => {
setErrorMsg(err?.message || "Fehler beim Ablehnen der Bewertung.");
},
})
);
const { mutate: deleteReview } = useMutation(
queryClient.reviews.deleteReview.mutationOptions({
onSuccess: () => {
setSuccessMsg("Bewertung wurde gelöscht.");
setShowDeleteConfirm(null);
},
onError: (err: any) => {
setErrorMsg(err?.message || "Fehler beim Löschen der Bewertung.");
setShowDeleteConfirm(null);
},
})
);
// Calculate quick stats from full dataset
const pendingCount = allReviews?.filter((r: Review) => r.status === "pending").length || 0;
const approvedCount = allReviews?.filter((r: Review) => r.status === "approved").length || 0;
const rejectedCount = allReviews?.filter((r: Review) => r.status === "rejected").length || 0;
const totalCount = allReviews?.length || 0;
return (
<div className="max-w-6xl mx-auto space-y-6">
{(successMsg || errorMsg) && (
<div className="mb-4">
{errorMsg && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
<div className="flex items-center">
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
<span className="font-medium">Fehler:</span>
<span className="ml-1">{errorMsg}</span>
</div>
</div>
)}
{successMsg && (
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-md">
<div className="flex items-center">
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
<span className="font-medium">Erfolg:</span>
<span className="ml-1">{successMsg}</span>
</div>
</div>
)}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-lg shadow p-4">
<div className="text-2xl font-bold text-yellow-600">{pendingCount}</div>
<div className="text-sm text-gray-600">Ausstehend</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-2xl font-bold text-green-600">{approvedCount}</div>
<div className="text-sm text-gray-600">Genehmigt</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-2xl font-bold text-red-600">{rejectedCount}</div>
<div className="text-sm text-gray-600">Abgelehnt</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-2xl font-bold text-gray-600">{totalCount}</div>
<div className="text-sm text-gray-600">Summe</div>
</div>
</div>
<div className="bg-white rounded-lg shadow">
<div className="border-b border-gray-200">
<nav className="-mb-px flex space-x-8 px-6">
<button
onClick={() => setActiveStatusTab("pending")}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeStatusTab === "pending"
? "border-pink-500 text-pink-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
Ausstehend
</button>
<button
onClick={() => setActiveStatusTab("approved")}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeStatusTab === "approved"
? "border-pink-500 text-pink-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
Genehmigt
</button>
<button
onClick={() => setActiveStatusTab("rejected")}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeStatusTab === "rejected"
? "border-pink-500 text-pink-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
Abgelehnt
</button>
</nav>
</div>
</div>
<div className="bg-white rounded-lg shadow">
{(!reviews || reviews.length === 0) && (
<div className="p-8 text-center text-gray-500">
<div className="text-lg font-medium mb-2">Keine Bewertungen gefunden</div>
<div className="text-sm">Es liegen aktuell keine Bewertungen in dieser Ansicht vor.</div>
</div>
)}
<div className="divide-y">
{reviews?.map((review: Review) => (
<div key={review.id} className="p-6 hover:bg-gray-50 transition-colors">
<div className="flex items-start justify-between">
<div>
<div className="text-lg font-semibold text-gray-900">{review.customerName}</div>
<div className="text-sm text-gray-500">{review.customerEmail || "—"}</div>
</div>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(review.status)}`}>
{getStatusText(review.status)}
</span>
</div>
<div className="mt-2">{renderStars(review.rating)}</div>
<div className="mt-2 text-gray-700 whitespace-pre-wrap break-words">{review.comment}</div>
<div className="mt-2 text-sm text-gray-500 space-x-4">
<span>Buchung: {review.bookingId}</span>
<span>Eingereicht am: {formatDate(review.createdAt)}</span>
{review.reviewedAt && <span>Geprüft am: {formatDate(review.reviewedAt)}</span>}
</div>
<div className="mt-4 flex items-center gap-2">
{review.status === "pending" && (
<>
<button
onClick={() => approveReview({ sessionId, id: review.id })}
className="bg-green-600 hover:bg-green-700 text-white px-3 py-1.5 rounded-md text-sm"
>
Genehmigen
</button>
<button
onClick={() => rejectReview({ sessionId, id: review.id })}
className="bg-red-600 hover:bg-red-700 text-white px-3 py-1.5 rounded-md text-sm"
>
Ablehnen
</button>
<button
onClick={() => setShowDeleteConfirm(review.id)}
className="bg-gray-600 hover:bg-gray-700 text-white px-3 py-1.5 rounded-md text-sm"
>
Löschen
</button>
</>
)}
{review.status === "approved" && (
<>
<button
onClick={() => rejectReview({ sessionId, id: review.id })}
className="bg-red-600 hover:bg-red-700 text-white px-3 py-1.5 rounded-md text-sm"
>
Ablehnen
</button>
<button
onClick={() => setShowDeleteConfirm(review.id)}
className="bg-gray-600 hover:bg-gray-700 text-white px-3 py-1.5 rounded-md text-sm"
>
Löschen
</button>
</>
)}
{review.status === "rejected" && (
<>
<button
onClick={() => approveReview({ sessionId, id: review.id })}
className="bg-green-600 hover:bg-green-700 text-white px-3 py-1.5 rounded-md text-sm"
>
Genehmigen
</button>
<button
onClick={() => setShowDeleteConfirm(review.id)}
className="bg-gray-600 hover:bg-gray-700 text-white px-3 py-1.5 rounded-md text-sm"
>
Löschen
</button>
</>
)}
</div>
</div>
))}
</div>
</div>
{showDeleteConfirm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<h3 className="text-lg font-medium text-gray-900 mb-4">Bewertung löschen</h3>
<p className="text-gray-600 mb-6">
Bist du sicher, dass du diese Bewertung löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.
</p>
<div className="flex space-x-3">
<button
onClick={() => deleteReview({ sessionId, id: showDeleteConfirm })}
className="flex-1 bg-red-600 text-white py-2 px-4 rounded-md hover:bg-red-700 transition-colors"
>
Ja, löschen
</button>
<button
onClick={() => setShowDeleteConfirm(null)}
className="flex-1 bg-gray-200 text-gray-800 py-2 px-4 rounded-md hover:bg-gray-300 transition-colors"
>
Abbrechen
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -197,23 +197,23 @@ export function AdminTreatments() {
</div> </div>
)} )}
<div className="bg-white rounded-lg shadow-lg overflow-hidden"> <div className="bg-white rounded-lg shadow-lg overflow-x-auto">
<table className="w-full"> <table className="w-full table-fixed">
<thead className="bg-gray-50"> <thead className="bg-gray-50">
<tr> <tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="w-2/5 px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Behandlung Behandlung
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="w-1/6 px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Kategorie Kategorie
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="w-1/12 px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Dauer Dauer
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="w-1/12 px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Preis Preis
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="w-1/6 px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Aktionen Aktionen
</th> </th>
</tr> </tr>
@@ -221,22 +221,26 @@ export function AdminTreatments() {
<tbody className="bg-white divide-y divide-gray-200"> <tbody className="bg-white divide-y divide-gray-200">
{treatments?.map((treatment) => ( {treatments?.map((treatment) => (
<tr key={treatment.id}> <tr key={treatment.id}>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4">
<div> <div>
<div className="text-sm font-medium text-gray-900">{treatment.name}</div> <div className="text-sm font-medium text-gray-900 truncate">{treatment.name}</div>
<div className="text-sm text-gray-500">{treatment.description}</div> <div className="text-sm text-gray-500 truncate" title={treatment.description}>
{treatment.description.length > 50
? `${treatment.description.substring(0, 50)}...`
: treatment.description}
</div>
</div> </div>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> <td className="px-6 py-4 text-sm text-gray-900 truncate">
{treatment.category} {treatment.category}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> <td className="px-6 py-4 text-sm text-gray-900">
{treatment.duration} Min {treatment.duration} Min
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> <td className="px-6 py-4 text-sm text-gray-900">
{(treatment.price / 100).toFixed(2)} {(treatment.price / 100).toFixed(2)}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2"> <td className="px-6 py-4 text-sm font-medium space-x-2">
<button <button
onClick={() => handleEdit(treatment)} onClick={() => handleEdit(treatment)}
className="text-pink-600 hover:text-pink-900" className="text-pink-600 hover:text-pink-900"

View File

@@ -1,76 +1,159 @@
import { useState } from "react"; import { useState, useEffect, useMemo } from "react";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { queryClient } from "@/client/rpc-client"; import { queryClient } from "@/client/rpc-client";
// Feature flag for multi-treatments availability API compatibility
const USE_MULTI_TREATMENTS_AVAILABILITY = false;
export function BookingForm() { export function BookingForm() {
const [selectedTreatment, setSelectedTreatment] = useState(""); const [selectedTreatments, setSelectedTreatments] = useState<Array<{id: string, name: string, duration: number, price: number}>>([]);
const [customerName, setCustomerName] = useState(""); const [customerName, setCustomerName] = useState("");
const [customerEmail, setCustomerEmail] = useState(""); const [customerEmail, setCustomerEmail] = useState("");
const [customerPhone, setCustomerPhone] = useState(""); const [customerPhone, setCustomerPhone] = useState("");
const [appointmentDate, setAppointmentDate] = useState(""); const [appointmentDate, setAppointmentDate] = useState("");
const [selectedSlotId, setSelectedSlotId] = useState<string>(""); const [selectedTime, setSelectedTime] = useState("");
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
const [agbAccepted, setAgbAccepted] = useState(false); const [agbAccepted, setAgbAccepted] = useState(false);
const [ageConfirmed, setAgeConfirmed] = useState(false);
const [inspirationPhoto, setInspirationPhoto] = useState<string>(""); const [inspirationPhoto, setInspirationPhoto] = useState<string>("");
const [photoPreview, setPhotoPreview] = useState<string>(""); const [photoPreview, setPhotoPreview] = useState<string>("");
const [errorMessage, setErrorMessage] = useState<string>(""); const [errorMessage, setErrorMessage] = useState<string>("");
const [isInitialized, setIsInitialized] = useState(false);
// Load saved customer data from localStorage on mount
useEffect(() => {
const savedName = localStorage.getItem("bookingForm_customerName");
const savedEmail = localStorage.getItem("bookingForm_customerEmail");
const savedPhone = localStorage.getItem("bookingForm_customerPhone");
if (savedName) setCustomerName(savedName);
if (savedEmail) setCustomerEmail(savedEmail);
if (savedPhone) setCustomerPhone(savedPhone);
setIsInitialized(true);
}, []);
// Save customer data to localStorage when it changes (after initial load)
useEffect(() => {
if (!isInitialized) return;
if (customerName) {
localStorage.setItem("bookingForm_customerName", customerName);
} else {
localStorage.removeItem("bookingForm_customerName");
}
}, [customerName, isInitialized]);
useEffect(() => {
if (!isInitialized) return;
if (customerEmail) {
localStorage.setItem("bookingForm_customerEmail", customerEmail);
} else {
localStorage.removeItem("bookingForm_customerEmail");
}
}, [customerEmail, isInitialized]);
useEffect(() => {
if (!isInitialized) return;
if (customerPhone) {
localStorage.setItem("bookingForm_customerPhone", customerPhone);
} else {
localStorage.removeItem("bookingForm_customerPhone");
}
}, [customerPhone, isInitialized]);
const { data: treatments } = useQuery( const { data: treatments } = useQuery(
queryClient.treatments.live.list.experimental_liveOptions() queryClient.treatments.live.list.experimental_liveOptions()
); );
// Lade alle Slots live und filtere freie Slots // Comment 3: Compute total duration and price once per render
const { data: allSlots } = useQuery( const totalDuration = useMemo(
queryClient.availability.live.list.experimental_liveOptions() () => selectedTreatments.reduce((sum, t) => sum + t.duration, 0),
[selectedTreatments]
); );
// Filtere freie Slots und entferne vergangene Termine const totalPrice = useMemo(
const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD () => selectedTreatments.reduce((sum, t) => sum + t.price, 0),
const freeSlots = (allSlots || []).filter((s) => { [selectedTreatments]
// Nur freie Slots );
if (s.status !== "free") return false;
// Comment 1: Dynamische Verfügbarkeitsabfrage mit Kompatibilitäts-Fallback
// Nur zukünftige oder heutige Termine const availabilityQueryInput = USE_MULTI_TREATMENTS_AVAILABILITY
if (s.date < today) return false; ? { date: appointmentDate, treatmentIds: selectedTreatments.map(t => t.id) }
: { date: appointmentDate, treatmentId: selectedTreatments[0]?.id ?? "" };
// Für heute: nur zukünftige Uhrzeiten
if (s.date === today) { const availabilityQueryEnabled = USE_MULTI_TREATMENTS_AVAILABILITY
const now = new Date(); ? !!appointmentDate && selectedTreatments.length > 0
const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`; : !!appointmentDate && selectedTreatments.length > 0;
if (s.time <= currentTime) return false;
} const { data: availableTimes, isLoading, isFetching, error } = useQuery({
...queryClient.recurringAvailability.getAvailableTimes.queryOptions({
return true; input: availabilityQueryInput as any
}),
enabled: availabilityQueryEnabled
}); });
const availableDates = Array.from(new Set(freeSlots.map((s) => s.date))).sort();
const slotsByDate = appointmentDate
? freeSlots.filter((s) => s.date === appointmentDate)
: [];
const { mutate: createBooking, isPending } = useMutation( const { mutate: createBooking, isPending } = useMutation(
queryClient.bookings.create.mutationOptions() queryClient.bookings.create.mutationOptions()
); );
const selectedTreatmentData = treatments?.find((t) => t.id === selectedTreatment); // Comment 2: Handle treatment checkbox toggle with functional state updates
const availableSlots = slotsByDate || []; // Slots sind bereits gefiltert const handleTreatmentToggle = (treatment: {id: string, name: string, duration: number, price: number}) => {
setSelectedTreatments((prev) => {
// Debug logging (commented out - uncomment if needed) const isSelected = prev.some(t => t.id === treatment.id);
// console.log("Debug - All slots:", allSlots);
// console.log("Debug - Free slots:", freeSlots); if (isSelected) {
// console.log("Debug - Available dates:", availableDates); // Remove from selection
// console.log("Debug - Selected date:", appointmentDate); return prev.filter(t => t.id !== treatment.id);
// console.log("Debug - Slots by date:", slotsByDate); } else if (prev.length < 3) {
// console.log("Debug - Available slots:", availableSlots); // Add to selection (only if limit not reached)
return [...prev, {
// Additional debugging for slot status id: treatment.id,
// if (allSlots && allSlots.length > 0) { name: treatment.name,
// const statusCounts = allSlots.reduce((acc, slot) => { duration: treatment.duration,
// acc[slot.status] = (acc[slot.status] || 0) + 1; price: treatment.price
// return acc; }];
// }, {} as Record<string, number>); }
// console.log("Debug - Slot status counts:", statusCounts);
// } // Return unchanged if limit reached
return prev;
});
// Clear selected time when treatments change
setSelectedTime("");
};
// Comment 4: Reconcile selectedTreatments when treatments list changes
useEffect(() => {
if (!treatments) return;
setSelectedTreatments((prev) => {
const validTreatments = prev.filter((selected) =>
treatments.some((t) => t.id === selected.id)
);
// Only update state if something changed to avoid unnecessary re-renders
if (validTreatments.length !== prev.length) {
return validTreatments;
}
return prev;
});
}, [treatments]);
// Clear selectedTime when it becomes invalid
useEffect(() => {
if (selectedTime && availableTimes && !availableTimes.includes(selectedTime)) {
setSelectedTime("");
}
}, [availableTimes, selectedTime]);
// Helper function for local date in YYYY-MM-DD format
const getLocalYmd = () => {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const handlePhotoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const handlePhotoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
@@ -138,12 +221,13 @@ export function BookingForm() {
if (fileInput) fileInput.value = ''; if (fileInput) fileInput.value = '';
}; };
const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setErrorMessage(""); // Clear any previous error messages setErrorMessage(""); // Clear any previous error messages
// console.log("Form submitted with data:", { // console.log("Form submitted with data:", {
// selectedTreatment, // selectedTreatments,
// customerName, // customerName,
// customerEmail, // customerEmail,
// customerPhone, // customerPhone,
@@ -152,30 +236,38 @@ export function BookingForm() {
// agbAccepted // agbAccepted
// }); // });
if (!selectedTreatment || !customerName || !customerEmail || !customerPhone || !appointmentDate || !selectedSlotId) { if (selectedTreatments.length === 0 || !customerName || !customerEmail || !customerPhone || !appointmentDate || !selectedTime) {
setErrorMessage("Bitte fülle alle erforderlichen Felder aus."); if (selectedTreatments.length === 0) {
setErrorMessage("Bitte wähle mindestens eine Behandlung aus.");
} else {
setErrorMessage("Bitte fülle alle erforderlichen Felder aus.");
}
return; return;
} }
if (!agbAccepted) { if (!agbAccepted) {
setErrorMessage("Bitte bestätige die Kenntnisnahme der Allgemeinen Geschäftsbedingungen."); setErrorMessage("Bitte bestätige die Kenntnisnahme der Allgemeinen Geschäftsbedingungen.");
return; return;
} }
const slot = availableSlots.find((s) => s.id === selectedSlotId); if (!ageConfirmed) {
const appointmentTime = slot?.time || ""; setErrorMessage("Bitte bestätige, dass du mindestens 16 Jahre alt bist.");
return;
}
// Email validation now handled in backend before booking creation
const appointmentTime = selectedTime;
// console.log("Creating booking with data:", { // console.log("Creating booking with data:", {
// treatmentId: selectedTreatment, // treatments: selectedTreatments,
// customerName, // customerName,
// customerEmail, // customerEmail,
// customerPhone, // customerPhone,
// appointmentDate, // appointmentDate,
// appointmentTime, // appointmentTime,
// notes, // notes,
// inspirationPhoto, // inspirationPhoto
// slotId: selectedSlotId,
// }); // });
createBooking( createBooking(
{ {
treatmentId: selectedTreatment, treatments: selectedTreatments,
customerName, customerName,
customerEmail, customerEmail,
customerPhone, customerPhone,
@@ -183,18 +275,18 @@ export function BookingForm() {
appointmentTime, appointmentTime,
notes, notes,
inspirationPhoto, inspirationPhoto,
slotId: selectedSlotId,
}, },
{ {
onSuccess: () => { onSuccess: () => {
setSelectedTreatment(""); setSelectedTreatments([]);
setCustomerName(""); setCustomerName("");
setCustomerEmail(""); setCustomerEmail("");
setCustomerPhone(""); setCustomerPhone("");
setAppointmentDate(""); setAppointmentDate("");
setSelectedSlotId(""); setSelectedTime("");
setNotes(""); setNotes("");
setAgbAccepted(false); setAgbAccepted(false);
setAgeConfirmed(false);
setInspirationPhoto(""); setInspirationPhoto("");
setPhotoPreview(""); setPhotoPreview("");
setErrorMessage(""); setErrorMessage("");
@@ -205,14 +297,24 @@ export function BookingForm() {
}, },
onError: (error: any) => { onError: (error: any) => {
console.error("Booking error:", error); console.error("Booking error:", error);
const errorText = error?.message || error?.toString() || "Ein unbekannter Fehler ist aufgetreten.";
// Simple error handling for oRPC errors
let errorText = "Ein unbekannter Fehler ist aufgetreten.";
if (error?.cause?.message) {
errorText = error.cause.message;
} else if (error?.message && error.message !== "Internal server error") {
errorText = error.message;
}
setErrorMessage(errorText); setErrorMessage(errorText);
}, },
} }
); );
}; };
// Get minimum date (today) nicht mehr genutzt, Datumsauswahl erfolgt aus freien Slots // Dynamische Zeitauswahl: Kunde wählt beliebiges zukünftiges Datum,
// System berechnet verfügbare Zeiten in 15-Minuten-Intervallen basierend auf wiederkehrenden Regeln
return ( return (
<div className="max-w-2xl mx-auto bg-white rounded-lg shadow-lg p-6"> <div className="max-w-2xl mx-auto bg-white rounded-lg shadow-lg p-6">
@@ -221,24 +323,65 @@ export function BookingForm() {
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
{/* Treatment Selection */} {/* Treatment Selection */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <div className="flex justify-between items-center mb-2">
Behandlung auswählen * <label className="block text-sm font-medium text-gray-700">
</label> Behandlungen auswählen (1-3) *
<select </label>
value={selectedTreatment} <span className="text-sm text-gray-600">
onChange={(e) => setSelectedTreatment(e.target.value)} {selectedTreatments.length} von 3 ausgewählt
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500" </span>
required </div>
>
<option value="">Wähle eine Behandlung</option> {/* Checkbox List Container */}
{treatments?.map((treatment) => ( <div className="max-h-96 overflow-y-auto border border-gray-300 rounded-md p-3 space-y-2" aria-label="Wähle bis zu 3 Behandlungen">
<option key={treatment.id} value={treatment.id}> {treatments?.map((treatment) => {
{treatment.name} - {(treatment.price / 100).toFixed(2)} ({treatment.duration} Min) const isSelected = selectedTreatments.some(t => t.id === treatment.id);
</option> const isDisabled = selectedTreatments.length >= 3 && !isSelected;
))}
</select> return (
{selectedTreatmentData && ( <div key={treatment.id} className="flex items-start space-x-3">
<p className="mt-2 text-sm text-gray-600">{selectedTreatmentData.description}</p> <input
type="checkbox"
id={`treatment-${treatment.id}`}
checked={isSelected}
disabled={isDisabled}
onChange={() => handleTreatmentToggle({
id: treatment.id,
name: treatment.name,
duration: treatment.duration,
price: treatment.price
})}
className="h-4 w-4 text-pink-600 border-gray-300 rounded flex-shrink-0 mt-1"
/>
<label htmlFor={`treatment-${treatment.id}`} className={`flex-1 text-sm cursor-pointer ${isDisabled ? 'text-gray-400' : 'text-gray-700'}`}>
{treatment.name} - {treatment.duration} Min - {(treatment.price / 100).toFixed(2)}
</label>
</div>
);
})}
</div>
{/* Treatment Descriptions */}
{selectedTreatments.length > 0 && (
<div className="mt-3 space-y-2">
{selectedTreatments.map((selectedTreatment) => {
const fullTreatment = treatments?.find(t => t.id === selectedTreatment.id);
return fullTreatment ? (
<p key={selectedTreatment.id} className="text-sm text-gray-600">
<span className="font-medium">{fullTreatment.name}:</span> {fullTreatment.description}
</p>
) : null;
})}
</div>
)}
{/* Live Calculation Display */}
{selectedTreatments.length > 0 && (
<div className="mt-3 bg-pink-50 border border-pink-200 rounded-lg p-4">
<p className="font-semibold text-pink-700">
📊 Gesamt: {totalDuration} Min | {(totalPrice / 100).toFixed(2)}
</p>
</div>
)} )}
</div> </div>
@@ -287,48 +430,53 @@ export function BookingForm() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Datum (nur freie Termine) * Wunschdatum *
</label> </label>
<select <input
type="date"
value={appointmentDate} value={appointmentDate}
onChange={(e) => { setAppointmentDate(e.target.value); setSelectedSlotId(""); }} onChange={(e) => { setAppointmentDate(e.target.value); setSelectedTime(""); }}
min={getLocalYmd()}
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500" className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
required required
> />
<option value="">Datum auswählen</option>
{availableDates.map((d) => (
<option key={d} value={d}>{d}</option>
))}
</select>
{availableDates.length === 0 && (
<p className="mt-2 text-sm text-gray-500">Aktuell keine freien Termine verfügbar.</p>
)}
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Verfügbare Uhrzeit * Verfügbare Uhrzeit (15-Min-Raster) *
</label> </label>
<select <select
value={selectedSlotId} value={selectedTime}
onChange={(e) => setSelectedSlotId(e.target.value)} onChange={(e) => setSelectedTime(e.target.value)}
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500" className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
disabled={!appointmentDate || !selectedTreatment} disabled={!appointmentDate || selectedTreatments.length === 0 || isLoading || isFetching}
required required
> >
<option value="">Zeit auswählen</option> <option value="">Zeit auswählen</option>
{availableSlots {availableTimes?.map((time) => (
.sort((a, b) => a.time.localeCompare(b.time)) <option key={time} value={time}>
.map((slot) => ( {time}
<option key={slot.id} value={slot.id}> </option>
{slot.time} ({slot.durationMinutes} min) ))}
</option>
))}
</select> </select>
{appointmentDate && availableSlots.length === 0 && ( {appointmentDate && selectedTreatments.length > 0 && isLoading && (
<p className="mt-2 text-sm text-gray-500"> <p className="mt-2 text-sm text-gray-500">
Keine freien Zeitslots für {appointmentDate} verfügbar. Lade verfügbare Zeiten...
</p> </p>
)} )}
{appointmentDate && selectedTreatments.length > 0 && error && (
<p className="mt-2 text-sm text-red-500">
Fehler beim Laden der verfügbaren Zeiten. Bitte versuche es erneut.
</p>
)}
{appointmentDate && selectedTreatments.length > 0 && !isLoading && !isFetching && !error && (!availableTimes || availableTimes.length === 0) && (
<p className="mt-2 text-sm text-gray-500">
Keine verfügbaren Zeiten für dieses Datum. Bitte wähle ein anderes Datum.
</p>
)}
{selectedTreatments.length > 0 && (
<p className="mt-1 text-xs text-gray-500">Gesamtdauer: {totalDuration} Minuten</p>
)}
</div> </div>
</div> </div>
@@ -382,7 +530,7 @@ export function BookingForm() {
</div> </div>
{/* AGB Acceptance */} {/* AGB Acceptance */}
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4"> <div className="bg-gray-50 border border-gray-200 rounded-lg p-4 space-y-4">
<div className="flex items-start space-x-3"> <div className="flex items-start space-x-3">
<input <input
type="checkbox" type="checkbox"
@@ -408,6 +556,22 @@ export function BookingForm() {
</p> </p>
</div> </div>
</div> </div>
<div className="flex items-start space-x-3">
<input
type="checkbox"
id="age-confirmation"
checked={ageConfirmed}
onChange={(e) => setAgeConfirmed(e.target.checked)}
className="mt-1 h-4 w-4 text-pink-600 focus:ring-pink-500 border-gray-300 rounded"
required
/>
<div className="flex-1">
<label htmlFor="age-confirmation" className="text-sm font-medium text-gray-700 cursor-pointer">
Ich bestätige, dass ich mindestens 16 Jahre alt bin *
</label>
</div>
</div>
</div> </div>
{/* Error Message */} {/* Error Message */}

View File

@@ -1,4 +1,4 @@
import React, { useState } from "react"; import React, { useState, useEffect } from "react";
import { useQuery, useMutation } from "@tanstack/react-query"; import { useQuery, useMutation } from "@tanstack/react-query";
import { queryClient } from "@/client/rpc-client"; import { queryClient } from "@/client/rpc-client";
@@ -8,6 +8,50 @@ interface BookingStatusPageProps {
type BookingStatus = "pending" | "confirmed" | "cancelled" | "completed"; type BookingStatus = "pending" | "confirmed" | "cancelled" | "completed";
interface Treatment {
id: string;
name: string;
duration: number;
price: number;
}
interface BookingDetails {
id: string;
customerName: string;
customerEmail?: string;
customerPhone?: string;
appointmentDate: string;
appointmentTime: string;
treatments: Treatment[];
totalDuration: number;
totalPrice: number;
status: BookingStatus;
notes?: string;
formattedDate: string;
createdAt: string;
canCancel: boolean;
hoursUntilAppointment: number;
}
interface RescheduleProposalDetails {
booking: {
id: string;
customerName: string;
customerEmail?: string;
customerPhone?: string;
status: BookingStatus;
treatments: Treatment[];
totalDuration: number;
totalPrice: number;
};
original: { date: string; time: string };
proposed: { date?: string; time?: string };
expiresAt: string;
hoursUntilExpiry: number;
isExpired: boolean;
canRespond: boolean;
}
function getStatusInfo(status: BookingStatus) { function getStatusInfo(status: BookingStatus) {
switch (status) { switch (status) {
case "pending": case "pending":
@@ -57,12 +101,34 @@ export default function BookingStatusPage({ token }: BookingStatusPageProps) {
const [showCancelConfirm, setShowCancelConfirm] = useState(false); const [showCancelConfirm, setShowCancelConfirm] = useState(false);
const [isCancelling, setIsCancelling] = useState(false); const [isCancelling, setIsCancelling] = useState(false);
const [cancellationResult, setCancellationResult] = useState<{ success: boolean; message: string; formattedDate?: string } | null>(null); const [cancellationResult, setCancellationResult] = useState<{ success: boolean; message: string; formattedDate?: string } | null>(null);
const [rescheduleProposal, setRescheduleProposal] = useState<RescheduleProposalDetails | null>(null);
const [rescheduleResult, setRescheduleResult] = useState<{ success: boolean; message: string } | null>(null);
const [isAccepting, setIsAccepting] = useState(false);
const [isDeclining, setIsDeclining] = useState(false);
const [showDeclineConfirm, setShowDeclineConfirm] = useState(false);
const [oneClickAction, setOneClickAction] = useState<string | null>(null);
const [oneClickLoading, setOneClickLoading] = useState(false);
// Fetch booking details // Fetch booking details
const { data: booking, isLoading, error, refetch } = useQuery( const { data: booking, isLoading, error, refetch, error: bookingError } = useQuery(
queryClient.cancellation.getBookingByToken.queryOptions({ input: { token } }) queryClient.cancellation.getBookingByToken.queryOptions({ input: { token } })
); );
// Try fetching reschedule proposal if booking not found or error
const rescheduleQuery = useQuery<RescheduleProposalDetails>({
...queryClient.cancellation.getRescheduleProposal.queryOptions({ input: { token } }),
enabled: !!token && (!!bookingError || !booking),
});
// Handle reschedule proposal data
useEffect(() => {
if (rescheduleQuery.data) {
setRescheduleProposal(rescheduleQuery.data);
} else if (rescheduleQuery.error) {
setRescheduleProposal(null);
}
}, [rescheduleQuery.data, rescheduleQuery.error]);
// Cancellation mutation // Cancellation mutation
const cancelMutation = useMutation({ const cancelMutation = useMutation({
...queryClient.cancellation.cancelByToken.mutationOptions(), ...queryClient.cancellation.cancelByToken.mutationOptions(),
@@ -85,12 +151,88 @@ export default function BookingStatusPage({ token }: BookingStatusPageProps) {
}, },
}); });
const acceptMutation = useMutation({
...queryClient.bookings.acceptReschedule.mutationOptions(),
onSuccess: (result: any) => {
setRescheduleResult({ success: true, message: result.message });
setIsAccepting(false);
setShowDeclineConfirm(false);
refetch();
},
onError: (error: any) => {
setRescheduleResult({ success: false, message: error?.message || 'Ein Fehler ist aufgetreten.' });
setIsAccepting(false);
}
});
const declineMutation = useMutation({
...queryClient.bookings.declineReschedule.mutationOptions(),
onSuccess: (result: any) => {
setRescheduleResult({ success: true, message: result.message });
setIsDeclining(false);
setShowDeclineConfirm(false);
},
onError: (error: any) => {
setRescheduleResult({ success: false, message: error?.message || 'Ein Fehler ist aufgetreten.' });
setIsDeclining(false);
}
});
const handleCancel = () => { const handleCancel = () => {
setIsCancelling(true); setIsCancelling(true);
setCancellationResult(null); setCancellationResult(null);
cancelMutation.mutate({ token }); cancelMutation.mutate({ token });
}; };
// Handle one-click actions from URL parameters
useEffect(() => {
if (rescheduleProposal && !oneClickAction) {
const urlParams = new URLSearchParams(window.location.search);
const action = urlParams.get('action');
if (action === 'accept' || action === 'decline') {
setOneClickAction(action);
}
}
}, [rescheduleProposal, oneClickAction]);
// Auto-execute one-click action
useEffect(() => {
if (oneClickAction && rescheduleProposal && !oneClickLoading && !rescheduleResult) {
setOneClickLoading(true);
if (oneClickAction === 'accept') {
const confirmAccept = window.confirm(
`Möchtest du den neuen Termin am ${rescheduleProposal.proposed.date || 'TBD'} um ${rescheduleProposal.proposed.time || 'TBD'} Uhr akzeptieren?`
);
if (confirmAccept) {
acceptMutation.mutate({ token });
} else {
setOneClickLoading(false);
setOneClickAction(null);
}
} else if (oneClickAction === 'decline') {
const confirmDecline = window.confirm(
`Möchtest du den Vorschlag ablehnen? Dein ursprünglicher Termin am ${rescheduleProposal.original.date} um ${rescheduleProposal.original.time} Uhr bleibt dann bestehen.`
);
if (confirmDecline) {
declineMutation.mutate({ token });
} else {
setOneClickLoading(false);
setOneClickAction(null);
}
}
}
}, [oneClickAction, rescheduleProposal, oneClickLoading, rescheduleResult, acceptMutation, declineMutation, token]);
// Reset one-click loading when mutations complete
useEffect(() => {
if (rescheduleResult) {
setOneClickLoading(false);
setOneClickAction(null);
}
}, [rescheduleResult]);
if (isLoading) { if (isLoading) {
return ( return (
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 flex items-center justify-center p-4"> <div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 flex items-center justify-center p-4">
@@ -104,7 +246,7 @@ export default function BookingStatusPage({ token }: BookingStatusPageProps) {
); );
} }
if (error) { if (error && !rescheduleProposal) {
return ( return (
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 flex items-center justify-center p-4"> <div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 flex items-center justify-center p-4">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full"> <div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full">
@@ -114,23 +256,33 @@ export default function BookingStatusPage({ token }: BookingStatusPageProps) {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</div> </div>
<h2 className="text-xl font-bold text-gray-900 mb-2">Fehler</h2> <h2 className="text-xl font-bold text-gray-900 mb-2">Link nicht verfügbar</h2>
<p className="text-gray-600 mb-4"> <p className="text-gray-600 mb-4">
{error?.message || "Der Link ist ungültig oder abgelaufen."} Dieser Buchungslink ist nicht mehr verfügbar. Mögliche Gründe:
</p> </p>
<a <ul className="text-sm text-gray-600 text-left mb-6 space-y-2">
href="/" <li> Der Link ist abgelaufen</li>
className="inline-flex items-center px-4 py-2 bg-pink-600 text-white rounded-lg hover:bg-pink-700 transition-colors" <li> Die Buchung wurde bereits storniert</li>
> <li> Der Link wurde bereits verwendet</li>
Zur Startseite </ul>
</a> <div className="space-y-3">
<a
href="/"
className="inline-flex items-center px-4 py-2 bg-pink-600 text-white rounded-lg hover:bg-pink-700 transition-colors"
>
Neue Buchung erstellen
</a>
<p className="text-sm text-gray-500">
Bei Fragen wende dich direkt an uns.
</p>
</div>
</div> </div>
</div> </div>
</div> </div>
); );
} }
if (!booking) { if (!booking && !rescheduleProposal) {
return ( return (
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 flex items-center justify-center p-4"> <div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 flex items-center justify-center p-4">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full"> <div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full">
@@ -151,7 +303,186 @@ export default function BookingStatusPage({ token }: BookingStatusPageProps) {
); );
} }
const statusInfo = getStatusInfo(booking.status); if (rescheduleProposal) {
const isExpired = rescheduleProposal.isExpired;
const handleAccept = () => {
setIsAccepting(true);
setRescheduleResult(null);
acceptMutation.mutate({ token });
};
const handleDecline = () => {
setIsDeclining(true);
setRescheduleResult(null);
declineMutation.mutate({ token });
};
return (
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 py-8 px-4">
<div className="max-w-2xl mx-auto">
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<img src="/assets/stargilnails_logo_transparent_112.png" alt="Stargil Nails Logo" className="w-16 h-16 object-contain" />
<span className={`px-4 py-2 rounded-full text-sm font-semibold bg-orange-100 text-orange-800`}>
Terminänderung vorgeschlagen
</span>
</div>
<h1 className="text-2xl font-bold text-gray-900">Vorschlag zur Terminänderung</h1>
<p className="text-gray-600 mt-1">Bitte bestätige, ob der neue Termin für dich passt.</p>
</div>
{oneClickLoading && (
<div className="mb-6 p-4 rounded-lg bg-blue-50 border border-blue-200">
<div className="flex items-center">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-500 mr-3"></div>
<p className="text-blue-800">
{oneClickAction === 'accept' ? 'Akzeptiere Termin...' : 'Lehne Termin ab...'}
</p>
</div>
</div>
)}
{rescheduleResult && (
<div className={`mb-6 p-4 rounded-lg ${rescheduleResult.success ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'}`}>
<p className={rescheduleResult.success ? 'text-green-800' : 'text-red-800'}>
{rescheduleResult.message}
</p>
</div>
)}
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Vergleich</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="border rounded-lg p-4 bg-gray-50">
<div className="text-sm text-gray-500 font-semibold mb-1">Aktueller Termin</div>
<div className="text-gray-900 font-medium">{rescheduleProposal.original.date} um {rescheduleProposal.original.time} Uhr</div>
<div className="text-gray-700 text-sm mt-2">
{rescheduleProposal.booking.treatments && rescheduleProposal.booking.treatments.length > 0 ? (
<>
{rescheduleProposal.booking.treatments.length <= 2 ? (
rescheduleProposal.booking.treatments.map((t, i) => (
<div key={i}>{t.name}</div>
))
) : (
<>
{rescheduleProposal.booking.treatments.slice(0, 2).map((t, i) => (
<div key={i}>{t.name}</div>
))}
<div className="text-gray-500 italic">+{rescheduleProposal.booking.treatments.length - 2} weitere</div>
</>
)}
<div className="text-gray-600 mt-1 text-xs">
{rescheduleProposal.booking.totalDuration} Min
</div>
</>
) : (
<span className="text-gray-400 italic">Keine Behandlungen</span>
)}
</div>
</div>
<div className="border rounded-lg p-4 bg-orange-50">
<div className="text-sm text-orange-700 font-semibold mb-1">Neuer Vorschlag</div>
<div className="text-gray-900 font-medium">{rescheduleProposal.proposed.date || 'TBD'} um {rescheduleProposal.proposed.time || 'TBD'} Uhr</div>
<div className="text-gray-700 text-sm mt-2">
{rescheduleProposal.booking.treatments && rescheduleProposal.booking.treatments.length > 0 ? (
<>
{rescheduleProposal.booking.treatments.length <= 2 ? (
rescheduleProposal.booking.treatments.map((t, i) => (
<div key={i}>{t.name}</div>
))
) : (
<>
{rescheduleProposal.booking.treatments.slice(0, 2).map((t, i) => (
<div key={i}>{t.name}</div>
))}
<div className="text-gray-500 italic">+{rescheduleProposal.booking.treatments.length - 2} weitere</div>
</>
)}
<div className="text-gray-600 mt-1 text-xs">
{rescheduleProposal.booking.totalDuration} Min
</div>
</>
) : (
<span className="text-gray-400 italic">Keine Behandlungen</span>
)}
</div>
</div>
</div>
<div className="mt-4 bg-yellow-50 border border-yellow-200 rounded-lg p-3 text-sm text-yellow-800">
Bitte antworte bis {new Date(rescheduleProposal.expiresAt).toLocaleDateString('de-DE')} {new Date(rescheduleProposal.expiresAt).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} ({rescheduleProposal.hoursUntilExpiry} Stunden).
</div>
</div>
{!isExpired && !rescheduleResult && !oneClickLoading && (
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
<div className="flex flex-col sm:flex-row gap-3">
<button
onClick={handleAccept}
disabled={isAccepting}
className="flex-1 bg-green-600 text-white py-3 px-4 rounded-lg font-medium hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isAccepting ? 'Akzeptiere...' : 'Neuen Termin akzeptieren'}
</button>
<button
onClick={() => setShowDeclineConfirm(true)}
disabled={isDeclining}
className="flex-1 bg-red-600 text-white py-3 px-4 rounded-lg font-medium hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Vorschlag ablehnen
</button>
</div>
<div className="mt-3 text-sm text-gray-600">Wenn du ablehnst, bleibt dein ursprünglicher Termin bestehen.</div>
</div>
)}
{isExpired && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
<p className="text-yellow-800 text-sm">
Diese Terminänderung ist abgelaufen. Dein ursprünglicher Termin bleibt bestehen. Bei Fragen kontaktiere uns bitte.
</p>
</div>
)}
{showDeclineConfirm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl p-6 w-full max-w-md">
<h4 className="text-lg font-semibold text-gray-900 mb-2">Vorschlag ablehnen?</h4>
<p className="text-sm text-gray-700 mb-4">
Bist du sicher, dass du den neuen Terminvorschlag ablehnen möchtest?<br />
Dein ursprünglicher Termin am {rescheduleProposal.original.date} um {rescheduleProposal.original.time} bleibt dann bestehen.
</p>
<div className="flex gap-3">
<button
onClick={() => setShowDeclineConfirm(false)}
className="flex-1 bg-gray-100 text-gray-700 py-2 rounded-lg hover:bg-gray-200"
>
Abbrechen
</button>
<button
onClick={handleDecline}
disabled={isDeclining}
className="flex-1 bg-red-600 text-white py-2 rounded-lg hover:bg-red-700 disabled:opacity-50"
>
{isDeclining ? 'Lehne ab...' : 'Ja, ablehnen'}
</button>
</div>
</div>
</div>
)}
<div className="text-center">
<a href="/" className="inline-flex items-center text-pink-600 hover:text-pink-700 font-medium">
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Zurück zur Startseite
</a>
</div>
</div>
</div>
);
}
const statusInfo = getStatusInfo(booking?.status || "pending");
return ( return (
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 py-8 px-4"> <div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 py-8 px-4">
@@ -194,22 +525,22 @@ export default function BookingStatusPage({ token }: BookingStatusPageProps) {
<h2 className={`text-xl font-bold ${statusInfo.textColor} mb-2`}> <h2 className={`text-xl font-bold ${statusInfo.textColor} mb-2`}>
Status: {statusInfo.label} Status: {statusInfo.label}
</h2> </h2>
{booking.status === "pending" && ( {booking?.status === "pending" && (
<p className={statusInfo.textColor}> <p className={statusInfo.textColor}>
Wir haben deine Terminanfrage erhalten und werden sie in Kürze prüfen. Du erhältst eine E-Mail, sobald dein Termin bestätigt wurde. Wir haben deine Terminanfrage erhalten und werden sie in Kürze prüfen. Du erhältst eine E-Mail, sobald dein Termin bestätigt wurde.
</p> </p>
)} )}
{booking.status === "confirmed" && ( {booking?.status === "confirmed" && (
<p className={statusInfo.textColor}> <p className={statusInfo.textColor}>
Dein Termin wurde bestätigt! Wir freuen uns auf dich. Du hast eine Bestätigungs-E-Mail mit Kalendereintrag erhalten. Dein Termin wurde bestätigt! Wir freuen uns auf dich. Du hast eine Bestätigungs-E-Mail mit Kalendereintrag erhalten.
</p> </p>
)} )}
{booking.status === "cancelled" && ( {booking?.status === "cancelled" && (
<p className={statusInfo.textColor}> <p className={statusInfo.textColor}>
Dieser Termin wurde storniert. Du kannst jederzeit einen neuen Termin buchen. Dieser Termin wurde storniert. Du kannst jederzeit einen neuen Termin buchen.
</p> </p>
)} )}
{booking.status === "completed" && ( {booking?.status === "completed" && (
<p className={statusInfo.textColor}> <p className={statusInfo.textColor}>
Dieser Termin wurde erfolgreich abgeschlossen. Vielen Dank für deinen Besuch! Dieser Termin wurde erfolgreich abgeschlossen. Vielen Dank für deinen Besuch!
</p> </p>
@@ -229,27 +560,51 @@ export default function BookingStatusPage({ token }: BookingStatusPageProps) {
<div className="space-y-3"> <div className="space-y-3">
<div className="flex justify-between py-2 border-b border-gray-100"> <div className="flex justify-between py-2 border-b border-gray-100">
<span className="text-gray-600">Datum:</span> <span className="text-gray-600">Datum:</span>
<span className="font-medium text-gray-900">{booking.formattedDate}</span> <span className="font-medium text-gray-900">{booking?.formattedDate}</span>
</div> </div>
<div className="flex justify-between py-2 border-b border-gray-100"> <div className="flex justify-between py-2 border-b border-gray-100">
<span className="text-gray-600">Uhrzeit:</span> <span className="text-gray-600">Uhrzeit:</span>
<span className="font-medium text-gray-900">{booking.appointmentTime} Uhr</span> <span className="font-medium text-gray-900">{booking?.appointmentTime} Uhr</span>
</div> </div>
<div className="flex justify-between py-2 border-b border-gray-100">
<span className="text-gray-600">Behandlung:</span> {/* Treatments List */}
<span className="font-medium text-gray-900">{booking.treatmentName}</span> <div className="py-2 border-b border-gray-100">
<div className="text-gray-600 mb-2">Behandlungen:</div>
{booking?.treatments && booking.treatments.length > 0 ? (
<div className="bg-gray-50 rounded-lg p-3 space-y-2">
{booking.treatments.map((treatment, index) => (
<div key={index} className="flex justify-between items-center text-sm">
<span className="font-medium text-gray-900"> {treatment.name}</span>
<span className="text-gray-600">
{treatment.duration} Min - {treatment.price.toFixed(2)}
</span>
</div>
))}
<div className="flex justify-between items-center pt-2 mt-2 border-t border-gray-200 font-semibold">
<span className="text-gray-900">Gesamt:</span>
<span className="text-gray-900">
{booking.totalDuration} Min - {booking.totalPrice.toFixed(2)}
</span>
</div>
</div>
) : (
<div className="space-y-2">
<span className="text-gray-400 text-sm italic">Keine Behandlungen angegeben</span>
{((booking?.totalDuration ?? 0) > 0 || (booking?.totalPrice ?? 0) > 0) && (
<div className="bg-gray-50 rounded-lg p-3">
<div className="flex justify-between items-center font-semibold text-sm">
<span className="text-gray-900">Gesamt:</span>
<span className="text-gray-900">
{booking?.totalDuration ?? 0} Min - {(booking?.totalPrice ?? 0).toFixed(2)}
</span>
</div>
</div>
)}
</div>
)}
</div> </div>
<div className="flex justify-between py-2 border-b border-gray-100">
<span className="text-gray-600">Dauer:</span> {booking?.hoursUntilAppointment && booking.hoursUntilAppointment > 0 && booking.status !== "cancelled" && booking.status !== "completed" && (
<span className="font-medium text-gray-900">{booking.treatmentDuration} Minuten</span>
</div>
{booking.treatmentPrice > 0 && (
<div className="flex justify-between py-2 border-b border-gray-100">
<span className="text-gray-600">Preis:</span>
<span className="font-medium text-gray-900">{booking.treatmentPrice.toFixed(2)} </span>
</div>
)}
{booking.hoursUntilAppointment > 0 && booking.status !== "cancelled" && booking.status !== "completed" && (
<div className="flex justify-between py-2"> <div className="flex justify-between py-2">
<span className="text-gray-600">Verbleibende Zeit:</span> <span className="text-gray-600">Verbleibende Zeit:</span>
<span className="font-medium text-pink-600"> <span className="font-medium text-pink-600">
@@ -271,18 +626,18 @@ export default function BookingStatusPage({ token }: BookingStatusPageProps) {
<div className="space-y-3"> <div className="space-y-3">
<div className="flex justify-between py-2 border-b border-gray-100"> <div className="flex justify-between py-2 border-b border-gray-100">
<span className="text-gray-600">Name:</span> <span className="text-gray-600">Name:</span>
<span className="font-medium text-gray-900">{booking.customerName}</span> <span className="font-medium text-gray-900">{booking?.customerName}</span>
</div> </div>
<div className="flex justify-between py-2 border-b border-gray-100"> <div className="flex justify-between py-2 border-b border-gray-100">
<span className="text-gray-600">E-Mail:</span> <span className="text-gray-600">E-Mail:</span>
<span className="font-medium text-gray-900">{booking.customerEmail}</span> <span className="font-medium text-gray-900">{booking?.customerEmail || '—'}</span>
</div> </div>
<div className="flex justify-between py-2"> <div className="flex justify-between py-2">
<span className="text-gray-600">Telefon:</span> <span className="text-gray-600">Telefon:</span>
<span className="font-medium text-gray-900">{booking.customerPhone}</span> <span className="font-medium text-gray-900">{booking?.customerPhone || '—'}</span>
</div> </div>
</div> </div>
{booking.notes && ( {booking?.notes && (
<div className="mt-4 pt-4 border-t border-gray-200"> <div className="mt-4 pt-4 border-t border-gray-200">
<h3 className="text-sm font-semibold text-gray-700 mb-2">Notizen:</h3> <h3 className="text-sm font-semibold text-gray-700 mb-2">Notizen:</h3>
<p className="text-gray-600 text-sm">{booking.notes}</p> <p className="text-gray-600 text-sm">{booking.notes}</p>
@@ -291,7 +646,7 @@ export default function BookingStatusPage({ token }: BookingStatusPageProps) {
</div> </div>
{/* Cancellation Section */} {/* Cancellation Section */}
{booking.canCancel && !cancellationResult?.success && ( {booking?.canCancel && !cancellationResult?.success && (
<div className="bg-white rounded-lg shadow-lg p-6 mb-6"> <div className="bg-white rounded-lg shadow-lg p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center"> <h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<svg className="w-5 h-5 mr-2 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5 mr-2 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -351,7 +706,7 @@ export default function BookingStatusPage({ token }: BookingStatusPageProps) {
</div> </div>
)} )}
{!booking.canCancel && booking.status !== "cancelled" && booking.status !== "completed" && ( {!booking?.canCancel && booking?.status !== "cancelled" && booking?.status !== "completed" && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6"> <div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
<p className="text-yellow-800 text-sm"> <p className="text-yellow-800 text-sm">
<strong> Stornierungsfrist abgelaufen:</strong> Dieser Termin liegt weniger als {parseInt(process.env.MIN_STORNO_TIMESPAN || "24")} Stunden in der Zukunft und kann nicht mehr online storniert werden. Bitte kontaktiere uns direkt. <strong> Stornierungsfrist abgelaufen:</strong> Dieser Termin liegt weniger als {parseInt(process.env.MIN_STORNO_TIMESPAN || "24")} Stunden in der Zukunft und kann nicht mehr online storniert werden. Bitte kontaktiere uns direkt.

View File

@@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import { useAuth } from "@/client/components/auth-provider"; import { useAuth } from "@/client/components/auth-provider";
export function LoginForm() { export function LoginForm() {
@@ -6,9 +6,27 @@ export function LoginForm() {
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isInitialized, setIsInitialized] = useState(false);
const { login } = useAuth(); const { login } = useAuth();
// Load saved username from localStorage on mount
useEffect(() => {
const savedUsername = localStorage.getItem("loginForm_username");
if (savedUsername) setUsername(savedUsername);
setIsInitialized(true);
}, []);
// Save username to localStorage when it changes (after initial load)
useEffect(() => {
if (!isInitialized) return;
if (username) {
localStorage.setItem("loginForm_username", username);
} else {
localStorage.removeItem("loginForm_username");
}
}, [username, isInitialized]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(""); setError("");

View File

@@ -0,0 +1,262 @@
import { useQuery } from "@tanstack/react-query";
import { queryClient } from "@/client/rpc-client";
interface ProfileLandingProps {
onNavigateToBooking: () => void;
}
function StarRating({ rating }: { rating: number }) {
return (
<div className="flex space-x-1">
{[1, 2, 3, 4, 5].map((star) => (
<span
key={star}
className={star <= rating ? "text-yellow-400" : "text-gray-300"}
>
</span>
))}
</div>
);
}
function getDayName(dayOfWeek: number): string {
const days = [
"Sonntag",
"Montag",
"Dienstag",
"Mittwoch",
"Donnerstag",
"Freitag",
"Samstag",
];
return days[dayOfWeek];
}
function formatDate(date: Date): string {
return date.toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
export function ProfileLanding({ onNavigateToBooking }: ProfileLandingProps) {
// Data fetching with live queries
const { data: galleryPhotos = [] } = useQuery(
queryClient.gallery.live.listPhotos.experimental_liveOptions()
);
const sortedPhotos = ([...galleryPhotos] as any[]).sort((a, b) => (a.order || 0) - (b.order || 0));
const featuredPhoto = sortedPhotos[0];
const { data: reviews = [] } = useQuery(
queryClient.reviews.live.listPublishedReviews.experimental_liveOptions()
);
const { data: recurringRules = [] } = useQuery(
queryClient.recurringAvailability.live.listRules.experimental_liveOptions()
);
const { data: socialMedia } = useQuery(
queryClient.social.getSocialMedia.queryOptions()
);
// Calculate next 7 days for opening hours
const getNext7Days = () => {
const days: Date[] = [];
const today = new Date();
for (let i = 0; i < 7; i++) {
const date = new Date(today);
date.setDate(today.getDate() + i);
days.push(date);
}
return days;
};
const next7Days = getNext7Days();
return (
<div className="max-w-6xl mx-auto space-y-12 py-8">
{/* Hero Section */}
<div className="bg-gradient-to-r from-pink-500 to-purple-600 rounded-lg shadow-lg p-8 text-white">
<h1 className="text-4xl md:text-5xl font-bold mb-4">
Stargirlnails Kiel
</h1>
<p className="text-xl mb-2">Professionelles Nageldesign und -Pflege in Kiel</p>
<p className="text-lg mb-8 opacity-90">
Lass dich von mir verwöhnen und genieße hochwertige Nail Art und Pflegebehandlungen.
</p>
<button
onClick={onNavigateToBooking}
className="bg-[#790dc6] text-white py-4 px-8 rounded-lg hover:bg-[#6609ad] text-lg font-semibold shadow-lg transition-colors w-full md:w-auto"
>
Termin buchen
</button>
</div>
{/* Social Media Badges */}
{((socialMedia as any)?.tiktokProfile || (socialMedia as any)?.instagramProfile) && (
<div className="flex justify-center items-center gap-4">
{(socialMedia as any)?.instagramProfile && (
<a
href={(socialMedia as any).instagramProfile}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 bg-gradient-to-r from-purple-500 via-pink-500 to-orange-500 text-white px-6 py-3 rounded-full hover:shadow-lg transition-all hover:scale-105 font-semibold"
>
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/>
</svg>
Instagram
</a>
)}
{(socialMedia as any)?.tiktokProfile && (
<a
href={(socialMedia as any).tiktokProfile}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 bg-black text-white px-6 py-3 rounded-full hover:shadow-lg transition-all hover:scale-105 font-semibold"
>
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1-.1z"/>
</svg>
TikTok
</a>
)}
</div>
)}
{/* Featured Section: Erstes Foto (Reihenfolge 0) */}
{featuredPhoto && (
<div className="bg-white rounded-lg shadow-lg p-0 overflow-hidden">
<img
src={(featuredPhoto as any).base64Data}
alt={(featuredPhoto as any).title || "Featured"}
className="w-full h-auto object-contain"
loading="eager"
decoding="async"
/>
{(featuredPhoto as any).title && (
<div className="p-4 border-t">
<h2 className="text-xl font-semibold text-gray-900">{(featuredPhoto as any).title}</h2>
</div>
)}
</div>
)}
{/* Photo Gallery Section */}
<div className="bg-white rounded-lg shadow-lg p-6">
<h2 className="text-2xl font-bold text-gray-900 mb-6">Unsere Arbeiten</h2>
{galleryPhotos.length > 0 ? (
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{(sortedPhotos as typeof galleryPhotos)
.filter((p) => (featuredPhoto ? (p as any).id !== (featuredPhoto as any).id : true))
.slice(0, 9)
.map((photo, index) => (
<img
key={photo.id || index}
src={photo.base64Data}
alt={photo.title || "Gallery"}
loading="lazy"
decoding="async"
className="w-full h-48 object-cover rounded-lg shadow-md"
/>
))}
</div>
) : (
<p className="text-gray-600 text-center py-8">
Galerie wird bald aktualisiert
</p>
)}
</div>
{/* Opening Hours Section */}
<div className="bg-white rounded-lg shadow-lg p-6">
<h2 className="text-2xl font-bold text-gray-900 mb-6">
Öffnungszeiten (Nächste 7 Tage)
</h2>
<div className="space-y-2">
{next7Days.map((date, index) => {
const dayOfWeek = date.getDay();
const dayRules = recurringRules.filter(
(rule) => rule.isActive && rule.dayOfWeek === dayOfWeek
);
const sorted = [...dayRules].sort((a, b) =>
a.startTime.localeCompare(b.startTime)
);
return (
<div
key={index}
className={`p-4 rounded-lg ${
index % 2 === 0 ? "bg-gray-50" : "bg-white"
}`}
>
<div className="flex justify-between items-center">
<span className="font-semibold text-gray-900">
{getDayName(dayOfWeek)}, {formatDate(date)}
</span>
<div className="text-gray-700">
{dayRules.length > 0 ? (
<div className="space-y-1">
{sorted.map((rule) => (
<div
key={`${rule.dayOfWeek}-${rule.startTime}-${rule.endTime}`}
>
{rule.startTime} - {rule.endTime} Uhr
</div>
))}
</div>
) : (
<span className="text-gray-500">Geschlossen</span>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
{/* Customer Reviews Section */}
<div className="bg-white rounded-lg shadow-lg p-6">
<h2 className="text-2xl font-bold text-gray-900 mb-6">
Kundenbewertungen
</h2>
{reviews.length > 0 ? (
<div className="space-y-4">
{reviews.slice(0, 10).map((review) => (
<div
key={
(review as any).id ||
(review as any).bookingId ||
`${(review as any).createdAt}-${(review as any).customerName}`
}
className="bg-gray-50 p-4 rounded-lg shadow-md"
>
<div className="flex justify-between items-start mb-2">
<div>
<h3 className="font-semibold text-gray-900">
{review.customerName}
</h3>
<StarRating rating={review.rating} />
</div>
<span className="text-sm text-gray-500">
{new Date(review.createdAt).toLocaleDateString("de-DE")}
</span>
</div>
{review.comment && (
<p className="text-gray-700 mt-2">{review.comment}</p>
)}
</div>
))}
</div>
) : (
<p className="text-gray-600 text-center py-8">
Noch keine Bewertungen vorhanden
</p>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,187 @@
import { useEffect, useState } from 'react';
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
declare global {
interface Navigator { standalone?: boolean }
interface WindowEventMap {
beforeinstallprompt: BeforeInstallPromptEvent;
}
}
const LAST_SHOWN_KEY = 'pwaInstallPrompt_lastShown';
const ANDROID_DISMISSED_KEY = 'pwaInstallPrompt_androidDismissed';
function isIOS(): boolean {
if (typeof navigator === 'undefined') return false;
const ua = navigator.userAgent || '';
const iOS = /iPhone|iPad|iPod/i.test(ua);
const iPadOS13Plus = /Macintosh/.test(ua) && 'ontouchend' in document;
return iOS || iPadOS13Plus;
}
function isSafari(): boolean {
const ua = navigator.userAgent || '';
const isSafari = /Safari/i.test(ua) && !/CriOS|FxiOS|EdgiOS/i.test(ua);
return isSafari;
}
function isStandalone(): boolean {
const navStandalone = (navigator as any)?.standalone === true;
const mm = typeof window !== 'undefined' && window.matchMedia
? window.matchMedia('(display-mode: standalone)').matches
: false;
return Boolean(navStandalone || mm);
}
export function PWAInstallPrompt({ hidden = false }: { hidden?: boolean }) {
if (hidden) return null;
const [show, setShow] = useState(false);
const [promptType, setPromptType] = useState<'ios' | 'android' | null>(null);
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [initialized, setInitialized] = useState(false);
useEffect(() => {
// Only run on client
if (typeof window === 'undefined') return;
// Check if already in standalone mode
if (isStandalone()) {
setInitialized(true);
return;
}
// Handle iOS
const isIOSDevice = isIOS() && isSafari();
if (isIOSDevice) {
let lastShown = 0;
try {
lastShown = Number(localStorage.getItem(LAST_SHOWN_KEY) || 0);
} catch {}
const oneWeek = 7 * 24 * 60 * 60 * 1000;
const shouldShow = !lastShown || Date.now() - lastShown > oneWeek;
if (shouldShow) {
setPromptType('ios');
setShow(true);
}
setInitialized(true);
return;
}
// Handle Android (beforeinstallprompt)
const handleBeforeInstall = (e: BeforeInstallPromptEvent) => {
e.preventDefault();
// Check if user has dismissed Android prompt before
let dismissed = false;
try {
dismissed = localStorage.getItem(ANDROID_DISMISSED_KEY) === 'true';
} catch {}
if (!dismissed) {
setDeferredPrompt(e);
setPromptType('android');
setShow(true);
}
};
window.addEventListener('beforeinstallprompt', handleBeforeInstall);
setInitialized(true);
return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstall);
};
}, []);
const dismiss = () => {
if (promptType === 'ios') {
try { localStorage.setItem(LAST_SHOWN_KEY, String(Date.now())); } catch {}
} else if (promptType === 'android') {
try { localStorage.setItem(ANDROID_DISMISSED_KEY, 'true'); } catch {}
}
setShow(false);
};
const handleAndroidInstall = async () => {
if (!deferredPrompt) return;
try {
await deferredPrompt.prompt();
const choiceResult = await deferredPrompt.userChoice;
if (choiceResult.outcome === 'accepted') {
console.log('PWA installation accepted');
}
setDeferredPrompt(null);
setShow(false);
} catch (error) {
console.error('Error during PWA installation:', error);
}
};
if (!initialized || !show || !promptType) return null;
return (
<div
role="dialog"
aria-label={promptType === 'android' ? 'PWA Installation' : 'PWA Installation Anleitung'}
className="fixed bottom-0 left-0 right-0 z-50 px-4 pb-4"
style={{ paddingBottom: `max(env(safe-area-inset-bottom), 1rem)` }}
>
<div className="relative max-w-3xl mx-auto rounded-xl shadow-lg bg-gradient-to-r from-pink-500 to-purple-600 text-white p-4 sm:p-6">
<button aria-label="Hinweis schließen" onClick={dismiss} className="absolute top-2 right-2 text-white/90 hover:text-white text-2xl leading-none">×</button>
{promptType === 'android' ? (
// Android: Direct install button
<div className="flex items-center gap-4">
<div className="text-3xl">📱</div>
<div className="flex-1">
<h3 className="text-lg sm:text-xl font-bold mb-2">App installieren</h3>
<p className="text-white/90 mb-3">
Installiere Stargirlnails als App für schnellen Zugriff direkt vom Startbildschirm!
</p>
<div className="flex gap-2">
<button
onClick={handleAndroidInstall}
className="bg-white text-pink-600 px-4 py-2 rounded-lg font-semibold hover:bg-pink-50 transition-colors"
>
Jetzt installieren
</button>
<button
onClick={dismiss}
className="bg-white/20 text-white px-4 py-2 rounded-lg font-semibold hover:bg-white/30 transition-colors"
>
Später
</button>
</div>
</div>
</div>
) : (
// iOS: Manual instructions
<div className="flex items-start gap-3">
<div className="text-2xl">📱</div>
<div className="flex-1">
<h3 className="text-lg sm:text-xl font-bold mb-2">App installieren</h3>
<p className="text-white/90 mb-2">So installierst du Stargirlnails als App auf deinem iPhone/iPad:</p>
<ol className="list-decimal pl-5 space-y-1 text-white/95">
<li>Öffne diese Seite in Safari (nicht Chrome oder andere Browser).</li>
<li>Tippe auf das Teilen-Symbol () unten in der Mitte.</li>
<li>Scrolle nach unten und wähle Zum Home-Bildschirm".</li>
<li>Tippe auf „Hinzufügen".</li>
</ol>
<p className="mt-3 text-sm text-white/90"> Schneller Zugriff, keine App-Store-Installation nötig, automatische Updates.</p>
</div>
</div>
)}
</div>
</div>
);
}
export default PWAInstallPrompt;

View File

@@ -0,0 +1,226 @@
import React, { useMemo, useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { queryClient } from "@/client/rpc-client";
interface ReviewSubmissionPageProps {
token: string;
}
export default function ReviewSubmissionPage({ token }: ReviewSubmissionPageProps) {
const [rating, setRating] = useState<number | null>(null);
const [hoverRating, setHoverRating] = useState<number | null>(null);
const [comment, setComment] = useState("");
const [submitResult, setSubmitResult] = useState<{ success: boolean; message: string } | null>(null);
// Fetch booking info by token
const bookingQuery = useQuery({
...queryClient.cancellation.getBookingByToken.queryOptions({ input: { token } }),
staleTime: 0,
});
const isCompleted = bookingQuery.data?.status === "completed";
const submitMutation = useMutation({
...queryClient.reviews.submitReview.mutationOptions(),
onSuccess: () => {
setSubmitResult({ success: true, message: "Danke für deine Bewertung! Sie wird nach Prüfung veröffentlicht." });
},
onError: (error: any) => {
setSubmitResult({ success: false, message: error?.message || "Ein Fehler ist aufgetreten." });
},
});
const canSubmit = useMemo(() => {
return !!rating && comment.trim().length >= 10 && isCompleted && !submitMutation.isPending;
}, [rating, comment, isCompleted, submitMutation.isPending]);
const handleSubmit = () => {
setSubmitResult(null);
const trimmedComment = comment.trim();
if (rating == null || rating < 1 || rating > 5) {
setSubmitResult({ success: false, message: "Bitte wähle eine Bewertung von 1 bis 5 Sternen." });
return;
}
if (trimmedComment.length < 10) {
setSubmitResult({ success: false, message: "Der Kommentar muss mindestens 10 Zeichen enthalten." });
return;
}
if (!isCompleted) {
setSubmitResult({ success: false, message: "Bewertungen sind nur für abgeschlossene Termine möglich." });
return;
}
submitMutation.mutate({ bookingToken: token, rating, comment: trimmedComment });
};
if (bookingQuery.isLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 flex items-center justify-center p-4">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full">
<div className="flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-pink-500"></div>
<span className="ml-3 text-gray-600">Lade Buchung...</span>
</div>
</div>
</div>
);
}
if (bookingQuery.error || !bookingQuery.data) {
return (
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 flex items-center justify-center p-4">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center">
<div className="w-16 h-16 mx-auto mb-4 bg-red-100 rounded-full flex items-center justify-center">
<svg className="w-8 h-8 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<h2 className="text-xl font-bold text-gray-900 mb-2">Link nicht verfügbar</h2>
<p className="text-gray-600 mb-4">Dieser Link ist ungültig oder abgelaufen.</p>
<a href="/" className="inline-flex items-center px-4 py-2 bg-pink-600 text-white rounded-lg hover:bg-pink-700 transition-colors">Zur Startseite</a>
</div>
</div>
);
}
// Guard: Only allow reviews for completed bookings
if (!isCompleted) {
return (
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 flex items-center justify-center p-4">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-lg w-full">
<div className="text-center">
<img src="/assets/stargilnails_logo_transparent_112.png" alt="Stargil Nails Logo" className="w-16 h-16 mx-auto mb-4 object-contain" />
<h1 className="text-2xl font-bold text-gray-900">Bewertung abgeben</h1>
<p className="text-gray-600 mt-2">Bewertungen sind nur für abgeschlossene Termine möglich.</p>
<div className="mt-6">
<a href="/" className="inline-flex items-center text-pink-600 hover:text-pink-700 font-medium">
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Zurück zur Startseite
</a>
</div>
</div>
</div>
</div>
);
}
const booking = bookingQuery.data;
return (
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 py-8 px-4">
<div className="max-w-2xl mx-auto">
{/* Header */}
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<img src="/assets/stargilnails_logo_transparent_112.png" alt="Stargil Nails Logo" className="w-16 h-16 object-contain" />
<span className="px-4 py-2 rounded-full text-sm font-semibold bg-pink-100 text-pink-800"> Bewertung</span>
</div>
<h1 className="text-2xl font-bold text-gray-900">Bewertung abgeben</h1>
<p className="text-gray-600 mt-1">Teile deine Erfahrung mit uns das hilft anderen Kundinnen!</p>
</div>
{/* Booking Details */}
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<svg className="w-5 h-5 mr-2 text-pink-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
Termin-Details
</h2>
<div className="space-y-3 text-sm">
<div className="flex justify-between py-2 border-b border-gray-100">
<span className="text-gray-600">Datum:</span>
<span className="font-medium text-gray-900">{booking.formattedDate}</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-100">
<span className="text-gray-600">Uhrzeit:</span>
<span className="font-medium text-gray-900">{booking.appointmentTime} Uhr</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-100">
<span className="text-gray-600">Behandlung:</span>
<span className="font-medium text-gray-900">
{booking.treatments && booking.treatments.length > 0
? booking.treatments.map((t: any) => t.name).join(", ")
: "Keine Behandlung"}
</span>
</div>
<div className="flex justify-between py-2">
<span className="text-gray-600">Name:</span>
<span className="font-medium text-gray-900">{booking.customerName}</span>
</div>
</div>
</div>
{/* Result Banner */}
{submitResult && (
<div className={`mb-6 p-4 rounded-lg ${submitResult.success ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'}`}>
<p className={submitResult.success ? 'text-green-800' : 'text-red-800'}>{submitResult.message}</p>
</div>
)}
{/* Review Form */}
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Deine Bewertung</h2>
{/* Stars */}
<div className="flex items-center mb-4">
{[1,2,3,4,5].map((star) => {
const isActive = (hoverRating ?? rating ?? 0) >= star;
return (
<button
key={star}
type="button"
className={`text-3xl mr-2 transition-colors ${isActive ? 'text-yellow-400' : 'text-gray-300'} ${submitMutation.isPending ? 'cursor-not-allowed' : 'cursor-pointer'}`}
onMouseEnter={() => !submitMutation.isPending && setHoverRating(star)}
onMouseLeave={() => !submitMutation.isPending && setHoverRating(null)}
onClick={() => !submitMutation.isPending && setRating(star)}
aria-label={`${star} Sterne`}
>
</button>
);
})}
</div>
{!rating && <p className="text-sm text-red-600 mb-2">Bitte wähle eine Bewertung von 1 bis 5 Sternen.</p>}
{/* Comment */}
<label className="block text-sm font-medium text-gray-700 mb-1">Kommentar</label>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Teile deine Erfahrung mit uns..."
rows={5}
className="w-full p-3 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-pink-500"
disabled={submitMutation.isPending || !!submitResult?.success}
/>
{comment.trim().length > 0 && comment.trim().length < 10 && (
<p className="text-sm text-red-600 mt-1">Der Kommentar muss mindestens 10 Zeichen enthalten.</p>
)}
{/* Submit */}
<button
onClick={handleSubmit}
disabled={!canSubmit || !!submitResult?.success}
className="mt-4 w-full bg-pink-600 text-white py-3 px-4 rounded-lg font-medium hover:bg-pink-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{submitMutation.isPending ? 'Sende Bewertung...' : 'Bewertung absenden'}
</button>
<p className="text-xs text-gray-500 mt-3">Mit dem Absenden stimmst du der Veröffentlichung deiner Bewertung nach Prüfung zu.</p>
</div>
{/* Footer */}
<div className="text-center">
<a href="/" className="inline-flex items-center text-pink-600 hover:text-pink-700 font-medium">
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Zurück zur Startseite
</a>
</div>
</div>
</div>
);
}

View File

@@ -3,6 +3,7 @@ import { serve } from '@hono/node-server';
import { serveStatic } from '@hono/node-server/serve-static'; import { serveStatic } from '@hono/node-server/serve-static';
import { rpcApp } from "./routes/rpc.js"; import { rpcApp } from "./routes/rpc.js";
import { caldavApp } from "./routes/caldav.js";
import { clientEntry } from "./routes/client-entry.js"; import { clientEntry } from "./routes/client-entry.js";
const app = new Hono(); const app = new Hono();
@@ -61,8 +62,12 @@ if (process.env.NODE_ENV === 'production') {
app.use('/assets/*', serveStatic({ root: './dist' })); app.use('/assets/*', serveStatic({ root: './dist' }));
} }
app.use('/favicon.png', serveStatic({ path: './public/favicon.png' })); app.use('/favicon.png', serveStatic({ path: './public/favicon.png' }));
app.use('/AGB.pdf', serveStatic({ path: './public/AGB.pdf' }));
app.use('/icons/*', serveStatic({ root: './public' }));
app.use('/manifest.json', serveStatic({ path: './public/manifest.json' }));
app.route("/rpc", rpcApp); app.route("/rpc", rpcApp);
app.route("/caldav", caldavApp);
app.get("/*", clientEntry); app.get("/*", clientEntry);
// Start server // Start server

17
src/server/lib/auth.ts Normal file
View File

@@ -0,0 +1,17 @@
import { createKV } from "./create-kv.js";
type Session = { id: string; userId: string; expiresAt: string; createdAt: string };
type User = { id: string; username: string; email: string; passwordHash: string; role: "customer" | "owner"; createdAt: string };
export const sessionsKV = createKV<Session>("sessions");
export const usersKV = createKV<User>("users");
export async function assertOwner(sessionId: string): Promise<void> {
const session = await sessionsKV.getItem(sessionId);
if (!session) throw new Error("Invalid session");
if (new Date(session.expiresAt) < new Date()) throw new Error("Session expired");
const user = await usersKV.getItem(session.userId);
if (!user || user.role !== "owner") throw new Error("Forbidden");
}

View File

@@ -8,6 +8,27 @@ function formatDateGerman(dateString: string): string {
return `${day}.${month}.${year}`; return `${day}.${month}.${year}`;
} }
// Helper function to render treatment list HTML
function renderTreatmentList(
treatments: Array<{id: string; name: string; duration: number; price: number}>,
options: { showPrices: boolean } = { showPrices: true }
): string {
const totalDuration = treatments.reduce((sum, t) => sum + t.duration, 0);
const totalPrice = treatments.reduce((sum, t) => sum + t.price, 0);
const treatmentItems = treatments.map(t =>
options.showPrices
? `<li><strong>${t.name}</strong> - ${t.duration} Min - ${t.price.toFixed(2)} €</li>`
: `<li>${t.name} - ${t.duration} Min - ${t.price.toFixed(2)} €</li>`
).join('');
const totalLine = options.showPrices
? `<li style="border-top: 1px solid #e2e8f0; margin-top: 8px; padding-top: 8px;"><strong>Gesamt:</strong> ${totalDuration} Min - ${totalPrice.toFixed(2)} €</li>`
: `<li style="font-weight: 600; margin-top: 4px;">Gesamt: ${totalDuration} Min - ${totalPrice.toFixed(2)} €</li>`;
return `${treatmentItems}${totalLine}`;
}
let cachedLogoDataUrl: string | null = null; let cachedLogoDataUrl: string | null = null;
async function getLogoDataUrl(): Promise<string | null> { async function getLogoDataUrl(): Promise<string | null> {
@@ -31,13 +52,18 @@ async function renderBrandedEmail(title: string, bodyHtml: string): Promise<stri
const protocol = domain.includes('localhost') ? 'http' : 'https'; const protocol = domain.includes('localhost') ? 'http' : 'https';
const homepageUrl = `${protocol}://${domain}`; const homepageUrl = `${protocol}://${domain}`;
const instagramProfile = process.env.INSTAGRAM_PROFILE;
const tiktokProfile = process.env.TIKTOK_PROFILE;
const companyName = process.env.COMPANY_NAME || 'Stargirlnails Kiel';
return ` return `
<div style="font-family: Arial, sans-serif; color: #0f172a; background:#fdf2f8; padding:24px;"> <div style="font-family: Arial, sans-serif; color: #0f172a; background:#fdf2f8; padding:24px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:640px; margin:0 auto; background:#ffffff; border-radius:12px; overflow:hidden; box-shadow:0 1px 3px rgba(0,0,0,0.06)"> <table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:640px; margin:0 auto; background:#ffffff; border-radius:12px; overflow:hidden; box-shadow:0 1px 3px rgba(0,0,0,0.06)">
<tr> <tr>
<td style="padding:24px 24px 0 24px; text-align:center;"> <td style="padding:24px 24px 0 24px; text-align:center;">
${logo ? `<img src="${logo}" alt="Stargirlnails" style="width:120px; height:auto; display:inline-block;" />` : `<div style=\"font-size:24px\">💅</div>`} ${logo ? `<img src="${logo}" alt="${companyName}" style="width:120px; height:auto; display:inline-block;" />` : `<div style=\"font-size:24px\">💅</div>`}
<h1 style="margin:16px 0 0 0; font-size:22px; color:#db2777;">${title}</h1> <div style="margin:16px 0 4px 0; font-size:16px; font-weight:600; color:#64748b;">${companyName}</div>
<h1 style="margin:0; font-size:22px; color:#db2777;">${title}</h1>
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -49,6 +75,29 @@ async function renderBrandedEmail(title: string, bodyHtml: string): Promise<stri
<div style="text-align:center; margin-bottom:16px;"> <div style="text-align:center; margin-bottom:16px;">
<a href="${homepageUrl}" style="display: inline-block; background-color: #db2777; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 14px;">Zur Website</a> <a href="${homepageUrl}" style="display: inline-block; background-color: #db2777; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 14px;">Zur Website</a>
</div> </div>
${(instagramProfile || tiktokProfile) ? `
<div style="text-align:center; margin-bottom:16px;">
<p style="font-size:14px; color:#64748b; margin:0 0 8px 0;">Folge uns auf Social Media:</p>
<div style="display:inline-block;">
${instagramProfile ? `
<a href="${instagramProfile}" target="_blank" rel="noopener noreferrer" style="display:inline-block; margin:0 6px; background:linear-gradient(45deg, #f09433 0%,#e6683c 25%,#dc2743 50%,#cc2366 75%,#bc1888 100%); color:white; padding:10px 20px; text-decoration:none; border-radius:20px; font-size:14px; font-weight:600;">
<svg width="16" height="16" fill="currentColor" viewBox="0 0 24 24" style="vertical-align:middle; margin-right:6px;">
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/>
</svg>
Instagram
</a>
` : ''}
${tiktokProfile ? `
<a href="${tiktokProfile}" target="_blank" rel="noopener noreferrer" style="display:inline-block; margin:0 6px; background:#000000; color:white; padding:10px 20px; text-decoration:none; border-radius:20px; font-size:14px; font-weight:600;">
<svg width="16" height="16" fill="currentColor" viewBox="0 0 24 24" style="vertical-align:middle; margin-right:6px;">
<path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1-.1z"/>
</svg>
TikTok
</a>
` : ''}
</div>
</div>
` : ''}
<div style="font-size:12px; color:#64748b; text-align:center;"> <div style="font-size:12px; color:#64748b; text-align:center;">
&copy; ${new Date().getFullYear()} Stargirlnails Kiel • Professional Nail Care &copy; ${new Date().getFullYear()} Stargirlnails Kiel • Professional Nail Care
</div> </div>
@@ -58,8 +107,8 @@ async function renderBrandedEmail(title: string, bodyHtml: string): Promise<stri
</div>`; </div>`;
} }
export async function renderBookingPendingHTML(params: { name: string; date: string; time: string; statusUrl?: string }) { export async function renderBookingPendingHTML(params: { name: string; date: string; time: string; statusUrl?: string; treatments: Array<{id: string; name: string; duration: number; price: number}> }) {
const { name, date, time, statusUrl } = params; const { name, date, time, statusUrl, treatments } = params;
const formattedDate = formatDateGerman(date); const formattedDate = formatDateGerman(date);
const domain = process.env.DOMAIN || 'localhost:5173'; const domain = process.env.DOMAIN || 'localhost:5173';
const protocol = domain.includes('localhost') ? 'http' : 'https'; const protocol = domain.includes('localhost') ? 'http' : 'https';
@@ -68,6 +117,12 @@ export async function renderBookingPendingHTML(params: { name: string; date: str
const inner = ` const inner = `
<p>Hallo ${name},</p> <p>Hallo ${name},</p>
<p>wir haben deine Anfrage für <strong>${formattedDate}</strong> um <strong>${time}</strong> erhalten.</p> <p>wir haben deine Anfrage für <strong>${formattedDate}</strong> um <strong>${time}</strong> erhalten.</p>
<div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0 0 8px 0; font-weight: 600; color: #db2777;">💅 Deine Behandlungen:</p>
<ul style="margin: 0; color: #475569; list-style: none; padding: 0;">
${renderTreatmentList(treatments, { showPrices: true })}
</ul>
</div>
<p>Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.</p> <p>Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.</p>
${statusUrl ? ` ${statusUrl ? `
<div style="background-color: #fef9f5; border-left: 4px solid #f59e0b; padding: 16px; margin: 20px 0; border-radius: 4px;"> <div style="background-color: #fef9f5; border-left: 4px solid #f59e0b; padding: 16px; margin: 20px 0; border-radius: 4px;">
@@ -85,8 +140,8 @@ export async function renderBookingPendingHTML(params: { name: string; date: str
return renderBrandedEmail("Deine Terminanfrage ist eingegangen", inner); return renderBrandedEmail("Deine Terminanfrage ist eingegangen", inner);
} }
export async function renderBookingConfirmedHTML(params: { name: string; date: string; time: string; cancellationUrl?: string }) { export async function renderBookingConfirmedHTML(params: { name: string; date: string; time: string; cancellationUrl?: string; reviewUrl?: string; treatments: Array<{id: string; name: string; duration: number; price: number}> }) {
const { name, date, time, cancellationUrl } = params; const { name, date, time, cancellationUrl, reviewUrl, treatments } = params;
const formattedDate = formatDateGerman(date); const formattedDate = formatDateGerman(date);
const domain = process.env.DOMAIN || 'localhost:5173'; const domain = process.env.DOMAIN || 'localhost:5173';
const protocol = domain.includes('localhost') ? 'http' : 'https'; const protocol = domain.includes('localhost') ? 'http' : 'https';
@@ -95,6 +150,12 @@ export async function renderBookingConfirmedHTML(params: { name: string; date: s
const inner = ` const inner = `
<p>Hallo ${name},</p> <p>Hallo ${name},</p>
<p>wir haben deinen Termin am <strong>${formattedDate}</strong> um <strong>${time}</strong> bestätigt.</p> <p>wir haben deinen Termin am <strong>${formattedDate}</strong> um <strong>${time}</strong> bestätigt.</p>
<div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0 0 8px 0; font-weight: 600; color: #db2777;">💅 Deine Behandlungen:</p>
<ul style="margin: 0; color: #475569; list-style: none; padding: 0;">
${renderTreatmentList(treatments, { showPrices: true })}
</ul>
</div>
<p>Wir freuen uns auf dich!</p> <p>Wir freuen uns auf dich!</p>
<div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;"> <div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #db2777;">📋 Wichtiger Hinweis:</p> <p style="margin: 0; font-weight: 600; color: #db2777;">📋 Wichtiger Hinweis:</p>
@@ -107,6 +168,14 @@ export async function renderBookingConfirmedHTML(params: { name: string; date: s
<a href="${cancellationUrl}" style="display: inline-block; background-color: #db2777; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">Termin ansehen & verwalten</a> <a href="${cancellationUrl}" style="display: inline-block; background-color: #db2777; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">Termin ansehen & verwalten</a>
</div> </div>
` : ''} ` : ''}
${reviewUrl ? `
<div style="background-color: #eff6ff; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #3b82f6;">⭐ Bewertung abgeben:</p>
<p style="margin: 8px 0 12px 0; color: #475569;">Nach deinem Termin würden wir uns über deine Bewertung freuen!</p>
<a href="${reviewUrl}" style="display: inline-block; background-color: #3b82f6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">Bewertung schreiben</a>
<p style="margin: 12px 0 0 0; color: #64748b; font-size: 13px;">Du kannst deine Bewertung nach dem Termin über diesen Link abgeben.</p>
</div>
` : ''}
<div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;"> <div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #3b82f6;">📋 Rechtliche Informationen:</p> <p style="margin: 0; font-weight: 600; color: #3b82f6;">📋 Rechtliche Informationen:</p>
<p style="margin: 8px 0 12px 0; color: #475569;">Weitere Informationen findest du in unserem <a href="${legalUrl}" style="color: #3b82f6; text-decoration: underline;">Impressum und Datenschutz</a>.</p> <p style="margin: 8px 0 12px 0; color: #475569;">Weitere Informationen findest du in unserem <a href="${legalUrl}" style="color: #3b82f6; text-decoration: underline;">Impressum und Datenschutz</a>.</p>
@@ -116,8 +185,8 @@ export async function renderBookingConfirmedHTML(params: { name: string; date: s
return renderBrandedEmail("Termin bestätigt", inner); return renderBrandedEmail("Termin bestätigt", inner);
} }
export async function renderBookingCancelledHTML(params: { name: string; date: string; time: string }) { export async function renderBookingCancelledHTML(params: { name: string; date: string; time: string; treatments: Array<{id: string; name: string; duration: number; price: number}> }) {
const { name, date, time } = params; const { name, date, time, treatments } = params;
const formattedDate = formatDateGerman(date); const formattedDate = formatDateGerman(date);
const domain = process.env.DOMAIN || 'localhost:5173'; const domain = process.env.DOMAIN || 'localhost:5173';
const protocol = domain.includes('localhost') ? 'http' : 'https'; const protocol = domain.includes('localhost') ? 'http' : 'https';
@@ -126,6 +195,12 @@ export async function renderBookingCancelledHTML(params: { name: string; date: s
const inner = ` const inner = `
<p>Hallo ${name},</p> <p>Hallo ${name},</p>
<p>dein Termin am <strong>${formattedDate}</strong> um <strong>${time}</strong> wurde abgesagt.</p> <p>dein Termin am <strong>${formattedDate}</strong> um <strong>${time}</strong> wurde abgesagt.</p>
<div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0 0 8px 0; font-weight: 600; color: #db2777;">💅 Abgesagte Behandlungen:</p>
<ul style="margin: 0; color: #475569; list-style: none; padding: 0;">
${renderTreatmentList(treatments, { showPrices: true })}
</ul>
</div>
<p>Bitte buche einen neuen Termin. Bei Fragen helfen wir dir gerne weiter.</p> <p>Bitte buche einen neuen Termin. Bei Fragen helfen wir dir gerne weiter.</p>
<div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;"> <div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #3b82f6;">📋 Rechtliche Informationen:</p> <p style="margin: 0; font-weight: 600; color: #3b82f6;">📋 Rechtliche Informationen:</p>
@@ -140,13 +215,14 @@ export async function renderAdminBookingNotificationHTML(params: {
name: string; name: string;
date: string; date: string;
time: string; time: string;
treatment: string; treatments: Array<{id: string; name: string; duration: number; price: number}>;
phone: string; phone: string;
notes?: string; notes?: string;
hasInspirationPhoto: boolean; hasInspirationPhoto: boolean;
}) { }) {
const { name, date, time, treatment, phone, notes, hasInspirationPhoto } = params; const { name, date, time, treatments, phone, notes, hasInspirationPhoto } = params;
const formattedDate = formatDateGerman(date); const formattedDate = formatDateGerman(date);
const inner = ` const inner = `
<p>Hallo Admin,</p> <p>Hallo Admin,</p>
<p>eine neue Buchungsanfrage ist eingegangen:</p> <p>eine neue Buchungsanfrage ist eingegangen:</p>
@@ -155,7 +231,11 @@ export async function renderAdminBookingNotificationHTML(params: {
<ul style="margin: 8px 0 0 0; color: #475569; list-style: none; padding: 0;"> <ul style="margin: 8px 0 0 0; color: #475569; list-style: none; padding: 0;">
<li><strong>Name:</strong> ${name}</li> <li><strong>Name:</strong> ${name}</li>
<li><strong>Telefon:</strong> ${phone}</li> <li><strong>Telefon:</strong> ${phone}</li>
<li><strong>Behandlung:</strong> ${treatment}</li> <li><strong>Behandlungen:</strong>
<ul style="margin: 4px 0 0 0; list-style: none; padding: 0 0 0 16px;">
${renderTreatmentList(treatments, { showPrices: false })}
</ul>
</li>
<li><strong>Datum:</strong> ${formattedDate}</li> <li><strong>Datum:</strong> ${formattedDate}</li>
<li><strong>Uhrzeit:</strong> ${time}</li> <li><strong>Uhrzeit:</strong> ${time}</li>
${notes ? `<li><strong>Notizen:</strong> ${notes}</li>` : ''} ${notes ? `<li><strong>Notizen:</strong> ${notes}</li>` : ''}
@@ -169,3 +249,185 @@ export async function renderAdminBookingNotificationHTML(params: {
} }
export async function renderBookingRescheduleProposalHTML(params: {
name: string;
originalDate: string;
originalTime: string;
proposedDate: string;
proposedTime: string;
treatmentName: string;
acceptUrl: string;
declineUrl: string;
expiresAt: string;
}) {
const formattedOriginalDate = formatDateGerman(params.originalDate);
const formattedProposedDate = formatDateGerman(params.proposedDate);
const expiryDate = new Date(params.expiresAt);
const formattedExpiry = `${expiryDate.toLocaleDateString('de-DE')} ${expiryDate.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`;
const inner = `
<p>Hallo ${params.name},</p>
<p>wir müssen deinen Termin leider verschieben. Hier ist unser Vorschlag:</p>
<div style="background-color: #f8fafc; border-left: 4px solid #f59e0b; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #92400e;">📅 Übersicht</p>
<table role="presentation" cellspacing="0" cellpadding="0" style="width:100%; margin-top:8px; font-size:14px; color:#475569;">
<tr>
<td style="padding:6px 0; width:45%"><strong>Alter Termin</strong></td>
<td style="padding:6px 0;">${formattedOriginalDate} um ${params.originalTime} Uhr</td>
</tr>
<tr>
<td style="padding:6px 0; width:45%"><strong>Neuer Vorschlag</strong></td>
<td style="padding:6px 0; color:#b45309;"><strong>${formattedProposedDate} um ${params.proposedTime} Uhr</strong></td>
</tr>
<tr>
<td style="padding:6px 0; width:45%"><strong>Behandlung</strong></td>
<td style="padding:6px 0;">${params.treatmentName}</td>
</tr>
</table>
</div>
<div style="background-color: #fffbeb; border-left: 4px solid #f59e0b; padding: 12px; margin: 16px 0; border-radius: 4px; color:#92400e;">
⏰ Bitte antworte bis ${formattedExpiry}.
</div>
<div style="text-align:center; margin: 20px 0;">
<a href="${params.acceptUrl}" style="display:inline-block; background-color:#16a34a; color:#ffffff; padding:12px 18px; border-radius:8px; text-decoration:none; font-weight:700; margin-right:8px;">Neuen Termin akzeptieren</a>
<a href="${params.declineUrl}" style="display:inline-block; background-color:#dc2626; color:#ffffff; padding:12px 18px; border-radius:8px; text-decoration:none; font-weight:700;">Termin ablehnen</a>
</div>
<div style="background-color: #f8fafc; border-left: 4px solid #10b981; padding: 12px; margin: 16px 0; border-radius: 4px; color:#065f46;">
Wenn du den Vorschlag ablehnst, bleibt dein ursprünglicher Termin bestehen und wir kontaktieren dich für eine alternative Lösung.
</div>
<p>Falls du einen komplett neuen Termin buchen möchtest, kannst du deinen aktuellen Termin stornieren und einen neuen Termin auf unserer Website buchen.</p>
<p>Liebe Grüße,<br/>Stargirlnails Kiel</p>
`;
return renderBrandedEmail("Terminänderung vorgeschlagen", inner);
}
export async function renderAdminRescheduleDeclinedHTML(params: {
customerName: string;
originalDate: string;
originalTime: string;
proposedDate: string;
proposedTime: string;
treatmentName: string;
customerEmail?: string;
customerPhone?: string;
}) {
const inner = `
<p>Hallo Admin,</p>
<p>der Kunde <strong>${params.customerName}</strong> hat den Terminänderungsvorschlag abgelehnt.</p>
<div style="background-color:#f8fafc; border-left:4px solid #ef4444; padding:16px; margin:16px 0; border-radius:4px;">
<ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:14px;">
<li><strong>Kunde:</strong> ${params.customerName}</li>
${params.customerEmail ? `<li><strong>E-Mail:</strong> ${params.customerEmail}</li>` : ''}
${params.customerPhone ? `<li><strong>Telefon:</strong> ${params.customerPhone}</li>` : ''}
<li><strong>Behandlung:</strong> ${params.treatmentName}</li>
<li><strong>Ursprünglicher Termin:</strong> ${formatDateGerman(params.originalDate)} um ${params.originalTime} Uhr (bleibt bestehen)</li>
<li><strong>Abgelehnter Vorschlag:</strong> ${formatDateGerman(params.proposedDate)} um ${params.proposedTime} Uhr</li>
</ul>
</div>
<p>Bitte kontaktiere den Kunden, um eine alternative Lösung zu finden.</p>
`;
return renderBrandedEmail("Kunde hat Terminänderung abgelehnt", inner);
}
export async function renderAdminRescheduleAcceptedHTML(params: {
customerName: string;
originalDate: string;
originalTime: string;
newDate: string;
newTime: string;
treatmentName: string;
}) {
const inner = `
<p>Hallo Admin,</p>
<p>der Kunde <strong>${params.customerName}</strong> hat den Terminänderungsvorschlag akzeptiert.</p>
<div style="background-color:#ecfeff; border-left:4px solid #10b981; padding:16px; margin:16px 0; border-radius:4px;">
<ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:14px;">
<li><strong>Kunde:</strong> ${params.customerName}</li>
<li><strong>Behandlung:</strong> ${params.treatmentName}</li>
<li><strong>Alter Termin:</strong> ${formatDateGerman(params.originalDate)} um ${params.originalTime} Uhr</li>
<li><strong>Neuer Termin:</strong> ${formatDateGerman(params.newDate)} um ${params.newTime} Uhr ✅</li>
</ul>
</div>
<p>Der Termin wurde automatisch aktualisiert.</p>
`;
return renderBrandedEmail("Kunde hat Terminänderung akzeptiert", inner);
}
export async function renderAdminRescheduleExpiredHTML(params: {
expiredProposals: Array<{
customerName: string;
originalDate: string;
originalTime: string;
proposedDate: string;
proposedTime: string;
treatmentName: string;
customerEmail?: string;
customerPhone?: string;
expiredAt: string;
}>;
}) {
const inner = `
<p>Hallo Admin,</p>
<p><strong>${params.expiredProposals.length} Terminänderungsvorschlag${params.expiredProposals.length > 1 ? 'e' : ''} ${params.expiredProposals.length > 1 ? 'sind' : 'ist'} abgelaufen</strong> und wurde${params.expiredProposals.length > 1 ? 'n' : ''} automatisch entfernt.</p>
<div style="background-color:#fef2f2; border-left:4px solid #ef4444; padding:16px; margin:16px 0; border-radius:4px;">
<p style="margin:0 0 12px 0; font-weight:600; color:#dc2626;">⚠️ Abgelaufene Vorschläge:</p>
${params.expiredProposals.map(proposal => `
<div style="background-color:#ffffff; border:1px solid #fecaca; border-radius:4px; padding:12px; margin:8px 0;">
<ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:13px;">
<li><strong>Kunde:</strong> ${proposal.customerName}</li>
${proposal.customerEmail ? `<li><strong>E-Mail:</strong> ${proposal.customerEmail}</li>` : ''}
${proposal.customerPhone ? `<li><strong>Telefon:</strong> ${proposal.customerPhone}</li>` : ''}
<li><strong>Behandlung:</strong> ${proposal.treatmentName}</li>
<li><strong>Ursprünglicher Termin:</strong> ${formatDateGerman(proposal.originalDate)} um ${proposal.originalTime} Uhr</li>
<li><strong>Vorgeschlagener Termin:</strong> ${formatDateGerman(proposal.proposedDate)} um ${proposal.proposedTime} Uhr</li>
<li><strong>Abgelaufen am:</strong> ${new Date(proposal.expiredAt).toLocaleString('de-DE')}</li>
</ul>
</div>
`).join('')}
</div>
<p style="color:#dc2626; font-weight:600;">Bitte kontaktiere die Kunden, um eine alternative Lösung zu finden.</p>
<p>Die ursprünglichen Termine bleiben bestehen.</p>
`;
return renderBrandedEmail("Abgelaufene Terminänderungsvorschläge", inner);
}
export async function renderCustomerMessageHTML(params: {
customerName: string;
message: string;
appointmentDate?: string;
appointmentTime?: string;
treatmentName?: string;
}) {
const { customerName, message, appointmentDate, appointmentTime, treatmentName } = params;
const formattedDate = appointmentDate ? formatDateGerman(appointmentDate) : null;
const domain = process.env.DOMAIN || 'localhost:5173';
const protocol = domain.includes('localhost') ? 'http' : 'https';
const legalUrl = `${protocol}://${domain}/legal`;
const ownerName = process.env.OWNER_NAME || 'Stargirlnails Kiel';
const inner = `
<p>Hallo ${customerName},</p>
${(appointmentDate && appointmentTime && treatmentName) ? `
<div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #db2777;">📅 Zu deinem Termin:</p>
<ul style="margin: 8px 0 0 0; color: #475569; list-style: none; padding: 0;">
<li><strong>Behandlung:</strong> ${treatmentName}</li>
<li><strong>Datum:</strong> ${formattedDate}</li>
<li><strong>Uhrzeit:</strong> ${appointmentTime}</li>
</ul>
</div>
` : ''}
<div style="background-color: #fef9f5; border-left: 4px solid #f59e0b; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #f59e0b;">💬 Nachricht von ${ownerName}:</p>
<div style="margin: 12px 0 0 0; color: #475569; white-space: pre-wrap; line-height: 1.6;">${message.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</div>
</div>
<p>Bei Fragen oder Anliegen kannst du einfach auf diese E-Mail antworten wir helfen dir gerne weiter!</p>
<div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #3b82f6;">📋 Rechtliche Informationen:</p>
<p style="margin: 8px 0 12px 0; color: #475569;">Weitere Informationen findest du in unserem <a href="${legalUrl}" style="color: #3b82f6; text-decoration: underline;">Impressum und Datenschutz</a>.</p>
</div>
<p>Liebe Grüße,<br/>${ownerName}</p>
`;
return renderBrandedEmail("Nachricht zu deinem Termin", inner);
}

View File

@@ -3,15 +3,17 @@
// Privacy-focused, no data storage, completely free // Privacy-focused, no data storage, completely free
type EmailValidationResult = { type EmailValidationResult = {
valid: boolean;
email: string; email: string;
domain?: string; validations: {
disposable?: boolean; syntax: boolean;
role?: boolean; domain_exists: boolean;
typo?: boolean; mx_records: boolean;
suggestion?: string; mailbox_exists: boolean;
mx?: boolean; is_disposable: boolean;
error?: string; is_role_based: boolean;
};
score: number;
status: string;
}; };
/** /**
@@ -25,7 +27,7 @@ export async function validateEmail(email: string): Promise<{
}> { }> {
try { try {
// Call Rapid Email Validator API // Call Rapid Email Validator API
const response = await fetch(`https://rapid-email-verifier.fly.dev/verify/${encodeURIComponent(email)}`, { const response = await fetch(`https://rapid-email-verifier.fly.dev/api/validate?email=${encodeURIComponent(email)}`, {
method: 'GET', method: 'GET',
headers: { headers: {
'Accept': 'application/json', 'Accept': 'application/json',
@@ -33,15 +35,18 @@ export async function validateEmail(email: string): Promise<{
}); });
if (!response.ok) { if (!response.ok) {
console.warn(`Email validation API error: ${response.status}`); console.error(`Email validation API error: ${response.status}`);
// If API is down, allow the email (fallback to Zod validation only) // If API is down, reject the email with error message
return { valid: true }; return {
valid: false,
reason: 'E-Mail-Validierung ist derzeit nicht verfügbar. Bitte überprüfe deine E-Mail-Adresse und versuche es erneut.'
};
} }
const data: EmailValidationResult = await response.json(); const data: EmailValidationResult = await response.json();
// Check if email is disposable/temporary // Check if email is disposable/temporary
if (data.disposable) { if (data.validations.is_disposable) {
return { return {
valid: false, valid: false,
reason: 'Temporäre oder Wegwerf-E-Mail-Adressen sind nicht erlaubt. Bitte verwende eine echte E-Mail-Adresse.', reason: 'Temporäre oder Wegwerf-E-Mail-Adressen sind nicht erlaubt. Bitte verwende eine echte E-Mail-Adresse.',
@@ -49,20 +54,26 @@ export async function validateEmail(email: string): Promise<{
} }
// Check if MX records exist (deliverable) // Check if MX records exist (deliverable)
if (data.mx === false) { if (!data.validations.mx_records) {
return { return {
valid: false, valid: false,
reason: 'Diese E-Mail-Adresse kann keine E-Mails empfangen. Bitte überprüfe deine E-Mail-Adresse.', reason: 'Diese E-Mail-Adresse kann keine E-Mails empfangen. Bitte überprüfe deine E-Mail-Adresse.',
}; };
} }
// Check if email is generally valid // Check if domain exists
if (!data.valid) { if (!data.validations.domain_exists) {
const suggestion = data.suggestion ? ` Meintest du vielleicht: ${data.suggestion}?` : '';
return { return {
valid: false, valid: false,
reason: `Ungültige E-Mail-Adresse.${suggestion}`, reason: 'Die E-Mail-Domain existiert nicht. Bitte überprüfe deine E-Mail-Adresse.',
suggestion: data.suggestion, };
}
// Check if email syntax is valid
if (!data.validations.syntax) {
return {
valid: false,
reason: 'Ungültige E-Mail-Adresse. Bitte überprüfe die Schreibweise.',
}; };
} }
@@ -71,9 +82,11 @@ export async function validateEmail(email: string): Promise<{
} catch (error) { } catch (error) {
console.error('Email validation error:', error); console.error('Email validation error:', error);
// If validation fails, allow the email (fallback to Zod validation only) // If validation fails, reject the email with error message
// This ensures the booking system continues to work even if the API is down return {
return { valid: true }; valid: false,
reason: 'E-Mail-Validierung ist derzeit nicht verfügbar. Bitte überprüfe deine E-Mail-Adresse und versuche es erneut.'
};
} }
} }

View File

@@ -6,6 +6,7 @@ type SendEmailParams = {
from?: string; from?: string;
cc?: string | string[]; cc?: string | string[];
bcc?: string | string[]; bcc?: string | string[];
replyTo?: string | string[];
attachments?: Array<{ attachments?: Array<{
filename: string; filename: string;
content: string; // base64 encoded content: string; // base64 encoded
@@ -28,15 +29,27 @@ function formatDateForICS(date: string, time: string): string {
return `${year}${month}${day}T${hours}${minutes}00`; return `${year}${month}${day}T${hours}${minutes}00`;
} }
// Helper function to escape text values for ICS files (RFC 5545)
function icsEscape(text: string): string {
return text
.replace(/\\/g, '\\\\') // Backslash must be escaped first
.replace(/;/g, '\\;') // Semicolon
.replace(/,/g, '\\,') // Comma
.replace(/\n/g, '\\n'); // Newline
}
// Helper function to create ICS (iCalendar) file content // Helper function to create ICS (iCalendar) file content
function createICSFile(params: { function createICSFile(params: {
date: string; // YYYY-MM-DD date: string; // YYYY-MM-DD
time: string; // HH:MM time: string; // HH:MM
durationMinutes: number;
customerName: string; customerName: string;
treatmentName: string; customerEmail?: string;
treatments: Array<{id: string; name: string; duration: number; price: number}>;
}): string { }): string {
const { date, time, durationMinutes, customerName, treatmentName } = params; const { date, time, customerName, customerEmail, treatments } = params;
// Calculate duration from treatments
const durationMinutes = treatments.reduce((sum, t) => sum + t.duration, 0);
// Calculate start and end times in Europe/Berlin timezone // Calculate start and end times in Europe/Berlin timezone
const dtStart = formatDateForICS(date, time); const dtStart = formatDateForICS(date, time);
@@ -56,6 +69,17 @@ function createICSFile(params: {
const now = new Date(); const now = new Date();
const dtstamp = now.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'; const dtstamp = now.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
// Build treatments list for SUMMARY and DESCRIPTION
const treatmentNames = icsEscape(treatments.map(t => t.name).join(', '));
const totalDuration = treatments.reduce((sum, t) => sum + t.duration, 0);
const totalPrice = treatments.reduce((sum, t) => sum + t.price, 0);
const treatmentDetails = treatments.map(t =>
`${icsEscape(t.name)} (${t.duration} Min, ${t.price.toFixed(2)} EUR)`
).join('\\n');
const description = `Behandlungen:\\n${treatmentDetails}\\n\\nGesamt: ${totalDuration} Min, ${totalPrice.toFixed(2)} EUR\\n\\nTermin bei Stargirlnails Kiel`;
// ICS content // ICS content
const icsContent = [ const icsContent = [
'BEGIN:VCALENDAR', 'BEGIN:VCALENDAR',
@@ -68,11 +92,11 @@ function createICSFile(params: {
`DTSTAMP:${dtstamp}`, `DTSTAMP:${dtstamp}`,
`DTSTART;TZID=Europe/Berlin:${dtStart}`, `DTSTART;TZID=Europe/Berlin:${dtStart}`,
`DTEND;TZID=Europe/Berlin:${dtEnd}`, `DTEND;TZID=Europe/Berlin:${dtEnd}`,
`SUMMARY:${treatmentName} - Stargirlnails Kiel`, `SUMMARY:${treatmentNames} - Stargirlnails Kiel`,
`DESCRIPTION:Termin für ${treatmentName} bei Stargirlnails Kiel`, `DESCRIPTION:${description}`,
'LOCATION:Stargirlnails Kiel', 'LOCATION:Stargirlnails Kiel',
`ORGANIZER;CN=Stargirlnails Kiel:mailto:${process.env.EMAIL_FROM?.match(/<(.+)>/)?.[1] || 'no-reply@stargirlnails.de'}`, `ORGANIZER;CN=Stargirlnails Kiel:mailto:${process.env.EMAIL_FROM?.match(/<(.+)>/)?.[1] || 'no-reply@stargirlnails.de'}`,
`ATTENDEE;CN=${customerName};RSVP=TRUE:mailto:${customerName}`, ...(customerEmail ? [`ATTENDEE;CN=${customerName};RSVP=TRUE:mailto:${customerEmail}`] : []),
'STATUS:CONFIRMED', 'STATUS:CONFIRMED',
'SEQUENCE:0', 'SEQUENCE:0',
'BEGIN:VALARM', 'BEGIN:VALARM',
@@ -130,22 +154,27 @@ export async function sendEmail(params: SendEmailParams): Promise<{ success: boo
return { success: false }; return { success: false };
} }
const payload = {
from: params.from || DEFAULT_FROM,
to: Array.isArray(params.to) ? params.to : [params.to],
subject: params.subject,
text: params.text,
html: params.html,
cc: params.cc ? (Array.isArray(params.cc) ? params.cc : [params.cc]) : undefined,
bcc: params.bcc ? (Array.isArray(params.bcc) ? params.bcc : [params.bcc]) : undefined,
reply_to: params.replyTo ? (Array.isArray(params.replyTo) ? params.replyTo : [params.replyTo]) : undefined,
attachments: params.attachments,
};
console.log(`Sending email via Resend: to=${JSON.stringify(payload.to)}, subject="${params.subject}"`);
const response = await fetch("https://api.resend.com/emails", { const response = await fetch("https://api.resend.com/emails", {
method: "POST", method: "POST",
headers: { headers: {
"Authorization": `Bearer ${RESEND_API_KEY}`, "Authorization": `Bearer ${RESEND_API_KEY}`,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify(payload),
from: params.from || DEFAULT_FROM,
to: Array.isArray(params.to) ? params.to : [params.to],
subject: params.subject,
text: params.text,
html: params.html,
cc: params.cc ? (Array.isArray(params.cc) ? params.cc : [params.cc]) : undefined,
bcc: params.bcc ? (Array.isArray(params.bcc) ? params.bcc : [params.bcc]) : undefined,
attachments: params.attachments,
}),
}); });
if (!response.ok) { if (!response.ok) {
@@ -153,6 +182,9 @@ export async function sendEmail(params: SendEmailParams): Promise<{ success: boo
console.error("Resend send error:", response.status, body); console.error("Resend send error:", response.status, body);
return { success: false }; return { success: false };
} }
const responseData = await response.json().catch(() => ({}));
console.log("Resend email sent successfully:", responseData);
return { success: true }; return { success: true };
} }
@@ -178,9 +210,9 @@ export async function sendEmailWithAGBAndCalendar(
calendarParams: { calendarParams: {
date: string; date: string;
time: string; time: string;
durationMinutes: number;
customerName: string; customerName: string;
treatmentName: string; customerEmail?: string;
treatments: Array<{id: string; name: string; duration: number; price: number}>;
} }
): Promise<{ success: boolean }> { ): Promise<{ success: boolean }> {
const agbBase64 = await getAGBPDFBase64(); const agbBase64 = await getAGBPDFBase64();

270
src/server/routes/caldav.ts Normal file
View File

@@ -0,0 +1,270 @@
import { Hono } from "hono";
import { createKV } from "../lib/create-kv.js";
import { assertOwner } from "../lib/auth.js";
// Types für Buchungen (vereinfacht für CalDAV)
type Booking = {
id: string;
treatments?: Array<{id: string, name: string, duration: number, price: number}>;
customerName: string;
customerEmail?: string;
customerPhone?: string;
appointmentDate: string; // YYYY-MM-DD
appointmentTime: string; // HH:MM
status: "pending" | "confirmed" | "cancelled" | "completed";
notes?: string;
// Deprecated fields for backward compatibility
treatmentId?: string;
bookedDurationMinutes?: number;
createdAt: string;
};
type Treatment = {
id: string;
name: string;
description: string;
price: number;
duration: number;
category: string;
createdAt: string;
};
// KV-Stores
const bookingsKV = createKV<Booking>("bookings");
const treatmentsKV = createKV<Treatment>("treatments");
const sessionsKV = createKV<any>("sessions");
export const caldavApp = new Hono();
// Helper-Funktionen für ICS-Format
function formatDateTime(dateStr: string, timeStr: string): string {
// Konvertiere YYYY-MM-DD HH:MM zu UTC-Format für ICS
const [year, month, day] = dateStr.split('-').map(Number);
const [hours, minutes] = timeStr.split(':').map(Number);
const date = new Date(year, month - 1, day, hours, minutes);
return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
}
function addMinutesToTime(timeStr: string, minutesToAdd: number): string {
const [hours, minutes] = timeStr.split(':').map(Number);
const totalMinutes = hours * 60 + minutes + minutesToAdd;
const newHours = Math.floor(totalMinutes / 60);
const newMinutes = totalMinutes % 60;
return `${String(newHours).padStart(2, '0')}:${String(newMinutes).padStart(2, '0')}`;
}
function generateICSContent(bookings: Booking[], treatments: Treatment[]): string {
const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
let ics = `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Stargirlnails//Booking Calendar//DE
CALSCALE:GREGORIAN
METHOD:PUBLISH
X-WR-CALNAME:Stargirlnails Termine
X-WR-CALDESC:Terminkalender für Stargirlnails
X-WR-TIMEZONE:Europe/Berlin
`;
// Nur bestätigte und ausstehende Termine in den Kalender aufnehmen
const activeBookings = bookings.filter(b =>
b.status === 'confirmed' || b.status === 'pending'
);
for (const booking of activeBookings) {
// Handle new treatments array structure
let treatmentNames: string;
let duration: number;
let treatmentDetails: string;
let totalPrice = 0;
if (booking.treatments && Array.isArray(booking.treatments) && booking.treatments.length > 0) {
// Use new treatments array
treatmentNames = booking.treatments.map(t => t.name).join(', ');
duration = booking.treatments.reduce((sum, t) => sum + (t.duration || 0), 0);
totalPrice = booking.treatments.reduce((sum, t) => sum + (t.price || 0), 0);
// Build detailed treatment list for description
treatmentDetails = booking.treatments
.map(t => `- ${t.name} (${t.duration} Min., ${t.price}€)`)
.join('\\n');
if (booking.treatments.length > 1) {
treatmentDetails += `\\n\\nGesamt: ${duration} Min., ${totalPrice.toFixed(2)}`;
}
} else {
// Fallback to deprecated treatmentId for backward compatibility
const treatment = booking.treatmentId ? treatments.find(t => t.id === booking.treatmentId) : null;
treatmentNames = treatment?.name || 'Unbekannte Behandlung';
duration = booking.bookedDurationMinutes || treatment?.duration || 60;
treatmentDetails = `Behandlung: ${treatmentNames}`;
if (treatment?.price) {
treatmentDetails += ` (${duration} Min., ${treatment.price}€)`;
}
}
const startTime = formatDateTime(booking.appointmentDate, booking.appointmentTime);
const endTimeStr = addMinutesToTime(booking.appointmentTime, duration);
const endTime = formatDateTime(booking.appointmentDate, endTimeStr);
// UID für jeden Termin (eindeutig)
const uid = `booking-${booking.id}@stargirlnails.de`;
// Status für ICS
const status = booking.status === 'confirmed' ? 'CONFIRMED' : 'TENTATIVE';
ics += `BEGIN:VEVENT
UID:${uid}
DTSTAMP:${now}
DTSTART:${startTime}
DTEND:${endTime}
SUMMARY:${treatmentNames} - ${booking.customerName}
DESCRIPTION:${treatmentDetails}\\n\\nKunde: ${booking.customerName}${booking.customerPhone ? `\\nTelefon: ${booking.customerPhone}` : ''}${booking.notes ? `\\nNotizen: ${booking.notes}` : ''}
STATUS:${status}
TRANSP:OPAQUE
END:VEVENT
`;
}
ics += `END:VCALENDAR`;
return ics;
}
// CalDAV Discovery (PROPFIND auf Root)
caldavApp.all("/", async (c) => {
if (c.req.method !== 'PROPFIND') {
return c.text('Method Not Allowed', 405);
}
const response = `<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:response>
<D:href>/caldav/</D:href>
<D:propstat>
<D:prop>
<D:displayname>Stargirlnails Terminkalender</D:displayname>
<C:calendar-description>Termine für Stargirlnails</C:calendar-description>
<C:supported-calendar-component-set>
<C:comp name="VEVENT"/>
</C:supported-calendar-component-set>
<C:calendar-timezone>Europe/Berlin</C:calendar-timezone>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>`;
return c.text(response, 207, {
"Content-Type": "application/xml; charset=utf-8",
"DAV": "1, 3, calendar-access, calendar-schedule",
});
});
// Calendar Collection PROPFIND
caldavApp.all("/calendar/", async (c) => {
if (c.req.method !== 'PROPFIND') {
return c.text('Method Not Allowed', 405);
}
const response = `<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/">
<D:response>
<D:href>/caldav/calendar/</D:href>
<D:propstat>
<D:prop>
<D:displayname>Stargirlnails Termine</D:displayname>
<C:calendar-description>Alle Termine von Stargirlnails</C:calendar-description>
<C:supported-calendar-component-set>
<C:comp name="VEVENT"/>
</C:supported-calendar-component-set>
<C:calendar-timezone>Europe/Berlin</C:calendar-timezone>
<CS:getctag>${Date.now()}</CS:getctag>
<D:sync-token>${Date.now()}</D:sync-token>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>`;
return c.text(response, 207, {
"Content-Type": "application/xml; charset=utf-8",
});
});
// Calendar Events PROPFIND
caldavApp.all("/calendar/events.ics", async (c) => {
if (c.req.method !== 'PROPFIND') {
return c.text('Method Not Allowed', 405);
}
const response = `<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/">
<D:response>
<D:href>/caldav/calendar/events.ics</D:href>
<D:propstat>
<D:prop>
<D:getcontenttype>text/calendar; charset=utf-8</D:getcontenttype>
<D:getetag>"${Date.now()}"</D:getetag>
<D:displayname>Stargirlnails Termine</D:displayname>
<C:calendar-data>BEGIN:VCALENDAR\\nVERSION:2.0\\nEND:VCALENDAR</C:calendar-data>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>`;
return c.text(response, 207, {
"Content-Type": "application/xml; charset=utf-8",
});
});
// GET Calendar Data (ICS-Datei)
caldavApp.get("/calendar/events.ics", async (c) => {
try {
// Authentifizierung über Token im Query-Parameter
const token = c.req.query('token');
if (!token) {
return c.text('Unauthorized - Token required', 401);
}
// Token validieren
const tokenData = await sessionsKV.getItem(token);
if (!tokenData) {
return c.text('Unauthorized - Invalid token', 401);
}
// Prüfe, ob es ein CalDAV-Token ist (durch Ablaufzeit und fehlende type-Eigenschaft erkennbar)
// CalDAV-Tokens haben eine kürzere Ablaufzeit (24h) als normale Sessions
const tokenAge = Date.now() - new Date(tokenData.createdAt).getTime();
if (tokenAge > 24 * 60 * 60 * 1000) { // 24 Stunden
return c.text('Unauthorized - Token expired', 401);
}
// Token-Ablaufzeit prüfen
if (new Date(tokenData.expiresAt) < new Date()) {
return c.text('Unauthorized - Token expired', 401);
}
const bookings = await bookingsKV.getAllItems();
const treatments = await treatmentsKV.getAllItems();
const icsContent = generateICSContent(bookings, treatments);
return c.text(icsContent, 200, {
"Content-Type": "text/calendar; charset=utf-8",
"Content-Disposition": "inline; filename=\"stargirlnails-termine.ics\"",
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
"Expires": "0",
});
} catch (error) {
console.error("CalDAV GET error:", error);
return c.text('Internal Server Error', 500);
}
});
// Fallback für andere CalDAV-Requests
caldavApp.all("*", async (c) => {
console.log(`CalDAV: Unhandled ${c.req.method} request to ${c.req.url}`);
return c.text('Not Found', 404);
});

View File

@@ -14,11 +14,11 @@ export function clientEntry(c: Context<BlankEnv>) {
// Read Vite manifest to get the correct file names // Read Vite manifest to get the correct file names
const manifestPath = join(process.cwd(), 'dist', '.vite', 'manifest.json'); const manifestPath = join(process.cwd(), 'dist', '.vite', 'manifest.json');
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')); const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
const entry = manifest['src/client/main.tsx']; const entry = manifest['index.html'];
if (entry) { if (entry) {
jsFile = `/assets/${entry.file}`; jsFile = `/${entry.file}`;
if (entry.css) { if (entry.css) {
cssFiles = entry.css.map((css: string) => `/assets/${css}`); cssFiles = entry.css.map((css: string) => `/${css}`);
} }
} }
} catch (error) { } catch (error) {
@@ -30,12 +30,18 @@ export function clientEntry(c: Context<BlankEnv>) {
} }
return c.html( return c.html(
<html lang="en"> <html lang="de">
<head> <head>
<meta charSet="utf-8" /> <meta charSet="utf-8" />
<meta content="width=device-width, initial-scale=1" name="viewport" /> <meta content="width=device-width, initial-scale=1" name="viewport" />
<meta name="theme-color" content="#ec4899" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Stargirlnails" />
<title>Stargirlnails Kiel</title> <title>Stargirlnails Kiel</title>
<link rel="icon" type="image/png" href="/favicon.png" /> <link rel="icon" type="image/png" href="/favicon.png" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.json" />
{cssFiles && cssFiles.map((css: string) => ( {cssFiles && cssFiles.map((css: string) => (
<link key={css} rel="stylesheet" href={css} /> <link key={css} rel="stylesheet" href={css} />
))} ))}

View File

@@ -7,15 +7,21 @@ export const rpcApp = new Hono();
const handler = new RPCHandler(router); const handler = new RPCHandler(router);
rpcApp.use("/*", async (c, next) => { rpcApp.all("/*", async (c) => {
const { matched, response } = await handler.handle(c.req.raw, { try {
prefix: "/rpc", const { matched, response } = await handler.handle(c.req.raw, {
}); prefix: "/rpc",
});
if (matched) { if (matched) {
return c.newResponse(response.body, response); return c.newResponse(response.body, response);
}
return c.json({ error: "Not found" }, 404);
} catch (error) {
console.error("RPC Handler error:", error);
// Let oRPC handle errors properly
throw error;
} }
await next();
return;
}); });

View File

@@ -1,156 +0,0 @@
import { call, os } from "@orpc/server";
import { z } from "zod";
import { randomUUID } from "crypto";
import { createKV } from "../lib/create-kv.js";
const AvailabilitySchema = z.object({
id: z.string(),
date: z.string(), // YYYY-MM-DD
time: z.string(), // HH:MM
durationMinutes: z.number().int().positive(),
status: z.enum(["free", "reserved"]),
reservedByBookingId: z.string().optional(),
createdAt: z.string(),
});
export type Availability = z.output<typeof AvailabilitySchema>;
const kv = createKV<Availability>("availability");
// Minimal Owner-Prüfung über Sessions/Users KV
type Session = { id: string; userId: string; expiresAt: string; createdAt: string };
type User = { id: string; username: string; email: string; passwordHash: string; role: "customer" | "owner"; createdAt: string };
const sessionsKV = createKV<Session>("sessions");
const usersKV = createKV<User>("users");
async function assertOwner(sessionId: string): Promise<void> {
const session = await sessionsKV.getItem(sessionId);
if (!session) throw new Error("Invalid session");
if (new Date(session.expiresAt) < new Date()) throw new Error("Session expired");
const user = await usersKV.getItem(session.userId);
if (!user || user.role !== "owner") throw new Error("Forbidden");
}
const create = os
.input(
z.object({
sessionId: z.string(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
time: z.string().regex(/^\d{2}:\d{2}$/),
durationMinutes: z.number().int().positive(),
})
)
.handler(async ({ input }) => {
try {
await assertOwner(input.sessionId);
// Prevent duplicate slot on same date+time
const existing = await kv.getAllItems();
const conflict = existing.some((s) => s.date === input.date && s.time === input.time);
if (conflict) {
throw new Error("Es existiert bereits ein Slot zu diesem Datum und dieser Uhrzeit.");
}
const id = randomUUID();
const slot: Availability = {
id,
date: input.date,
time: input.time,
durationMinutes: input.durationMinutes,
status: "free",
createdAt: new Date().toISOString(),
};
await kv.setItem(id, slot);
return slot;
} catch (err) {
console.error("availability.create error", err);
throw err;
}
});
const update = os
.input(AvailabilitySchema.extend({ sessionId: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const { sessionId, ...rest } = input as any;
await kv.setItem(rest.id, rest as Availability);
return rest as Availability;
});
const remove = os
.input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const slot = await kv.getItem(input.id);
if (slot && slot.status === "reserved") throw new Error("Cannot delete reserved slot");
await kv.removeItem(input.id);
});
const list = os.handler(async () => {
const allSlots = await kv.getAllItems();
// Filter out past slots automatically
const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
const now = new Date();
const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
const filteredSlots = allSlots.filter(slot => {
// Keep slots for future dates
if (slot.date > today) return true;
// For today: only keep future time slots
if (slot.date === today) {
return slot.time > currentTime;
}
// Remove past slots
return false;
});
// Debug logging (commented out - uncomment if needed)
// const statusCounts = filteredSlots.reduce((acc, slot) => {
// acc[slot.status] = (acc[slot.status] || 0) + 1;
// return acc;
// }, {} as Record<string, number>);
// console.log(`Availability list: ${filteredSlots.length} slots (${JSON.stringify(statusCounts)})`);
return filteredSlots;
});
const get = os.input(z.string()).handler(async ({ input }) => {
return kv.getItem(input);
});
const getByDate = os
.input(z.string()) // YYYY-MM-DD
.handler(async ({ input }) => {
const all = await kv.getAllItems();
return all.filter((s) => s.date === input);
});
const live = {
list: os.handler(async function* ({ signal }) {
yield call(list, {}, { signal });
for await (const _ of kv.subscribe()) {
yield call(list, {}, { signal });
}
}),
byDate: os
.input(z.string())
.handler(async function* ({ input, signal }) {
yield call(getByDate, input, { signal });
for await (const _ of kv.subscribe()) {
yield call(getByDate, input, { signal });
}
}),
};
export const router = {
create,
update,
remove,
list,
get,
getByDate,
live,
};

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,12 @@ const BookingAccessTokenSchema = z.object({
token: z.string(), token: z.string(),
expiresAt: z.string(), expiresAt: z.string(),
createdAt: z.string(), createdAt: z.string(),
purpose: z.enum(["booking_access"]), // For future extensibility purpose: z.enum(["booking_access", "reschedule_proposal"]), // Extended for reschedule proposals
// Optional metadata for reschedule proposals
proposedDate: z.string().optional(),
proposedTime: z.string().optional(),
originalDate: z.string().optional(),
originalTime: z.string().optional(),
}); });
type BookingAccessToken = z.output<typeof BookingAccessTokenSchema>; type BookingAccessToken = z.output<typeof BookingAccessTokenSchema>;
@@ -23,10 +28,18 @@ const cancellationKV = createKV<BookingAccessToken>("cancellation_tokens");
// Types for booking and availability // Types for booking and availability
type Booking = { type Booking = {
id: string; id: string;
treatmentId: string; treatments: Array<{
id: string;
name: string;
duration: number;
price: number;
}>;
// Deprecated fields for backward compatibility
treatmentId?: string;
bookedDurationMinutes?: number;
customerName: string; customerName: string;
customerEmail: string; customerEmail?: string;
customerPhone: string; customerPhone?: string;
appointmentDate: string; appointmentDate: string;
appointmentTime: string; appointmentTime: string;
notes?: string; notes?: string;
@@ -55,6 +68,15 @@ function formatDateGerman(dateString: string): string {
return `${day}.${month}.${year}`; return `${day}.${month}.${year}`;
} }
// Helper to invalidate all reschedule tokens for a specific booking
async function invalidateRescheduleTokensForBooking(bookingId: string): Promise<void> {
const tokens = await cancellationKV.getAllItems();
const related = tokens.filter(t => t.bookingId === bookingId && t.purpose === "reschedule_proposal");
for (const tok of related) {
await cancellationKV.removeItem(tok.id);
}
}
// Create cancellation token for a booking // Create cancellation token for a booking
const createToken = os const createToken = os
.input(z.object({ bookingId: z.string() })) .input(z.object({ bookingId: z.string() }))
@@ -93,7 +115,8 @@ const getBookingByToken = os
const tokens = await cancellationKV.getAllItems(); const tokens = await cancellationKV.getAllItems();
const validToken = tokens.find(t => const validToken = tokens.find(t =>
t.token === input.token && t.token === input.token &&
new Date(t.expiresAt) > new Date() new Date(t.expiresAt) > new Date() &&
t.purpose === 'booking_access'
); );
if (!validToken) { if (!validToken) {
@@ -105,9 +128,42 @@ const getBookingByToken = os
throw new Error("Booking not found"); throw new Error("Booking not found");
} }
// Get treatment details // Handle treatments array
const treatmentsKV = createKV<any>("treatments"); let treatments: Array<{id: string; name: string; duration: number; price: number}>;
const treatment = await treatmentsKV.getItem(booking.treatmentId); let totalDuration: number;
let totalPrice: number;
if (booking.treatments && booking.treatments.length > 0) {
// New bookings with treatments array
treatments = booking.treatments;
totalDuration = treatments.reduce((sum, t) => sum + t.duration, 0);
totalPrice = treatments.reduce((sum, t) => sum + t.price, 0);
} else if (booking.treatmentId) {
// Old bookings with single treatmentId (backward compatibility)
const treatmentsKV = createKV<any>("treatments");
const treatment = await treatmentsKV.getItem(booking.treatmentId);
if (treatment) {
treatments = [{
id: treatment.id,
name: treatment.name,
duration: treatment.duration,
price: treatment.price,
}];
totalDuration = treatment.duration;
totalPrice = treatment.price;
} else {
// Fallback if treatment not found
treatments = [];
totalDuration = booking.bookedDurationMinutes || 60;
totalPrice = 0;
}
} else {
// Edge case: no treatments and no treatmentId
treatments = [];
totalDuration = 0;
totalPrice = 0;
}
// Calculate if cancellation is still possible // Calculate if cancellation is still possible
const minStornoTimespan = parseInt(process.env.MIN_STORNO_TIMESPAN || "24"); const minStornoTimespan = parseInt(process.env.MIN_STORNO_TIMESPAN || "24");
@@ -125,10 +181,9 @@ const getBookingByToken = os
customerPhone: booking.customerPhone, customerPhone: booking.customerPhone,
appointmentDate: booking.appointmentDate, appointmentDate: booking.appointmentDate,
appointmentTime: booking.appointmentTime, appointmentTime: booking.appointmentTime,
treatmentId: booking.treatmentId, treatments,
treatmentName: treatment?.name || "Unbekannte Behandlung", totalDuration,
treatmentDuration: treatment?.duration || 60, totalPrice,
treatmentPrice: treatment?.price || 0,
status: booking.status, status: booking.status,
notes: booking.notes, notes: booking.notes,
formattedDate: formatDateGerman(booking.appointmentDate), formattedDate: formatDateGerman(booking.appointmentDate),
@@ -217,4 +272,214 @@ export const router = {
createToken, createToken,
getBookingByToken, getBookingByToken,
cancelByToken, cancelByToken,
// Create a reschedule proposal token (48h expiry)
createRescheduleToken: os
.input(z.object({ bookingId: z.string(), proposedDate: z.string(), proposedTime: z.string() }))
.handler(async ({ input }) => {
const booking = await bookingsKV.getItem(input.bookingId);
if (!booking) {
throw new Error("Booking not found");
}
if (booking.status === "cancelled" || booking.status === "completed") {
throw new Error("Reschedule not allowed for this booking");
}
// Invalidate existing reschedule proposals for this booking
await invalidateRescheduleTokensForBooking(input.bookingId);
// Create token that expires in 48 hours
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 48);
const token = randomUUID();
const rescheduleToken: BookingAccessToken = {
id: randomUUID(),
bookingId: input.bookingId,
token,
expiresAt: expiresAt.toISOString(),
createdAt: new Date().toISOString(),
purpose: "reschedule_proposal",
proposedDate: input.proposedDate,
proposedTime: input.proposedTime,
originalDate: booking.appointmentDate,
originalTime: booking.appointmentTime,
};
await cancellationKV.setItem(rescheduleToken.id, rescheduleToken);
return { token, expiresAt: expiresAt.toISOString() };
}),
// Get reschedule proposal details by token
getRescheduleProposal: os
.input(z.object({ token: z.string() }))
.handler(async ({ input }) => {
const tokens = await cancellationKV.getAllItems();
const proposal = tokens.find(t => t.token === input.token && t.purpose === "reschedule_proposal");
if (!proposal) {
throw new Error("Ungültiger Reschedule-Token");
}
const booking = await bookingsKV.getItem(proposal.bookingId);
if (!booking) {
throw new Error("Booking not found");
}
// Handle treatments array
let treatments: Array<{id: string; name: string; duration: number; price: number}>;
let totalDuration: number;
let totalPrice: number;
if (booking.treatments && booking.treatments.length > 0) {
// New bookings with treatments array
treatments = booking.treatments;
totalDuration = treatments.reduce((sum, t) => sum + t.duration, 0);
totalPrice = treatments.reduce((sum, t) => sum + t.price, 0);
} else if (booking.treatmentId) {
// Old bookings with single treatmentId (backward compatibility)
const treatmentsKV = createKV<any>("treatments");
const treatment = await treatmentsKV.getItem(booking.treatmentId);
if (treatment) {
treatments = [{
id: treatment.id,
name: treatment.name,
duration: treatment.duration,
price: treatment.price,
}];
totalDuration = treatment.duration;
totalPrice = treatment.price;
} else {
// Fallback if treatment not found
treatments = [];
totalDuration = booking.bookedDurationMinutes || 60;
totalPrice = 0;
}
} else {
// Edge case: no treatments and no treatmentId
treatments = [];
totalDuration = 0;
totalPrice = 0;
}
const now = new Date();
const isExpired = new Date(proposal.expiresAt) <= now;
const hoursUntilExpiry = isExpired ? 0 : Math.max(0, Math.round((new Date(proposal.expiresAt).getTime() - now.getTime()) / (1000 * 60 * 60)));
return {
booking: {
id: booking.id,
customerName: booking.customerName,
customerEmail: booking.customerEmail,
customerPhone: booking.customerPhone,
status: booking.status,
treatments,
totalDuration,
totalPrice,
},
original: {
date: proposal.originalDate || booking.appointmentDate,
time: proposal.originalTime || booking.appointmentTime,
},
proposed: {
date: proposal.proposedDate,
time: proposal.proposedTime,
},
expiresAt: proposal.expiresAt,
hoursUntilExpiry,
isExpired,
canRespond: booking.status === "confirmed" && !isExpired,
};
}),
// Helper endpoint to remove a reschedule token by value (used after accept/decline)
removeRescheduleToken: os
.input(z.object({ token: z.string() }))
.handler(async ({ input }) => {
const tokens = await cancellationKV.getAllItems();
const proposal = tokens.find(t => t.token === input.token && t.purpose === "reschedule_proposal");
if (proposal) {
await cancellationKV.removeItem(proposal.id);
}
return { success: true };
}),
// Clean up expired reschedule proposals and notify admin
sweepExpiredRescheduleProposals: os
.handler(async () => {
const tokens = await cancellationKV.getAllItems();
const now = new Date();
const expiredProposals = tokens.filter(t =>
t.purpose === "reschedule_proposal" &&
new Date(t.expiresAt) <= now
);
if (expiredProposals.length === 0) {
return { success: true, expiredCount: 0 };
}
// Get booking details for each expired proposal
const expiredDetails: Array<{
customerName: string;
originalDate: string;
originalTime: string;
proposedDate: string;
proposedTime: string;
treatmentName: string;
customerEmail?: string;
customerPhone?: string;
expiredAt: string;
}> = [];
for (const proposal of expiredProposals) {
const booking = await bookingsKV.getItem(proposal.bookingId);
if (booking) {
const treatmentsKV = createKV<any>("treatments");
// Get treatment name(s) from new treatments array or fallback to deprecated treatmentId
let treatmentName = "Unbekannte Behandlung";
if (booking.treatments && Array.isArray(booking.treatments) && booking.treatments.length > 0) {
treatmentName = booking.treatments.map((t: any) => t.name).join(", ");
} else if (booking.treatmentId) {
const treatment = await treatmentsKV.getItem(booking.treatmentId);
treatmentName = treatment?.name || "Unbekannte Behandlung";
}
expiredDetails.push({
customerName: booking.customerName,
originalDate: proposal.originalDate || booking.appointmentDate,
originalTime: proposal.originalTime || booking.appointmentTime,
proposedDate: proposal.proposedDate!,
proposedTime: proposal.proposedTime!,
treatmentName: treatmentName,
customerEmail: booking.customerEmail,
customerPhone: booking.customerPhone,
expiredAt: proposal.expiresAt,
});
}
// Remove the expired token
await cancellationKV.removeItem(proposal.id);
}
// Notify admin if there are expired proposals
if (expiredDetails.length > 0 && process.env.ADMIN_EMAIL) {
try {
const { renderAdminRescheduleExpiredHTML } = await import("../lib/email-templates.js");
const { sendEmail } = await import("../lib/email.js");
const html = await renderAdminRescheduleExpiredHTML({
expiredProposals: expiredDetails,
});
await sendEmail({
to: process.env.ADMIN_EMAIL,
subject: `${expiredDetails.length} abgelaufene Terminänderungsvorschläge`,
text: `Es sind ${expiredDetails.length} Terminänderungsvorschläge abgelaufen. Details in der HTML-Version.`,
html,
});
} catch (error) {
console.error("Failed to send admin notification for expired proposals:", error);
}
}
return { success: true, expiredCount: expiredDetails.length };
}),
}; };

150
src/server/rpc/gallery.ts Normal file
View File

@@ -0,0 +1,150 @@
import { call, os } from "@orpc/server";
import { z } from "zod";
import { randomUUID } from "crypto";
import { createKV } from "../lib/create-kv.js";
import { assertOwner } from "../lib/auth.js";
// Schema Definition
const GalleryPhotoSchema = z.object({
id: z.string(),
base64Data: z.string(),
title: z.string().optional().default(""),
order: z.number().int(),
createdAt: z.string(),
cover: z.boolean().optional().default(false),
});
export type GalleryPhoto = z.output<typeof GalleryPhotoSchema>;
// KV Storage
const galleryPhotosKV = createKV<GalleryPhoto>("galleryPhotos");
// Authentication centralized in ../lib/auth.ts
// CRUD Endpoints
const uploadPhoto = os
.input(
z.object({
sessionId: z.string(),
base64Data: z
.string()
.regex(/^data:image\/(png|jpe?g|webp|gif);base64,/i, 'Unsupported image format'),
title: z.string().optional().default(""),
})
)
.handler(async ({ input }) => {
try {
await assertOwner(input.sessionId);
const id = randomUUID();
const existing = await galleryPhotosKV.getAllItems();
const maxOrder = existing.length > 0 ? Math.max(...existing.map((p) => p.order)) : -1;
const nextOrder = maxOrder + 1;
const photo: GalleryPhoto = {
id,
base64Data: input.base64Data,
title: input.title ?? "",
order: nextOrder,
createdAt: new Date().toISOString(),
cover: false,
};
await galleryPhotosKV.setItem(id, photo);
return photo;
} catch (err) {
console.error("gallery.uploadPhoto error", err);
throw err;
}
});
const setCoverPhoto = os
.input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const all = await galleryPhotosKV.getAllItems();
let updatedCover: GalleryPhoto | null = null;
for (const p of all) {
const isCover = p.id === input.id;
const next: GalleryPhoto = { ...p, cover: isCover };
await galleryPhotosKV.setItem(p.id, next);
if (isCover) updatedCover = next;
}
return updatedCover;
});
const deletePhoto = os
.input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
await galleryPhotosKV.removeItem(input.id);
});
const updatePhotoOrder = os
.input(
z.object({
sessionId: z.string(),
photoOrders: z.array(z.object({ id: z.string(), order: z.number().int() })),
})
)
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const updated: GalleryPhoto[] = [];
for (const { id, order } of input.photoOrders) {
const existing = await galleryPhotosKV.getItem(id);
if (!existing) continue;
const updatedPhoto: GalleryPhoto = { ...existing, order };
await galleryPhotosKV.setItem(id, updatedPhoto);
updated.push(updatedPhoto);
}
const all = await galleryPhotosKV.getAllItems();
return all.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
});
const listPhotos = os.handler(async () => {
const all = await galleryPhotosKV.getAllItems();
return all.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
});
const adminListPhotos = os
.input(z.object({ sessionId: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const all = await galleryPhotosKV.getAllItems();
return all.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
});
// Live Queries
const live = {
listPhotos: os.handler(async function* ({ signal }) {
yield call(listPhotos, {}, { signal });
for await (const _ of galleryPhotosKV.subscribe()) {
yield call(listPhotos, {}, { signal });
}
}),
adminListPhotos: os
.input(z.object({ sessionId: z.string() }))
.handler(async function* ({ input, signal }) {
await assertOwner(input.sessionId);
const all = await galleryPhotosKV.getAllItems();
const sorted = all.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
yield sorted;
for await (const _ of galleryPhotosKV.subscribe()) {
const updated = await galleryPhotosKV.getAllItems();
const sortedUpdated = updated.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
yield sortedUpdated;
}
}),
};
export const router = {
uploadPhoto,
deletePhoto,
updatePhotoOrder,
listPhotos,
adminListPhotos,
setCoverPhoto,
live,
};

View File

@@ -2,16 +2,22 @@ import { demo } from "./demo/index.js";
import { router as treatments } from "./treatments.js"; import { router as treatments } from "./treatments.js";
import { router as bookings } from "./bookings.js"; import { router as bookings } from "./bookings.js";
import { router as auth } from "./auth.js"; import { router as auth } from "./auth.js";
import { router as availability } from "./availability.js"; import { router as recurringAvailability } from "./recurring-availability.js";
import { router as cancellation } from "./cancellation.js"; import { router as cancellation } from "./cancellation.js";
import { router as legal } from "./legal.js"; import { router as legal } from "./legal.js";
import { router as gallery } from "./gallery.js";
import { router as reviews } from "./reviews.js";
import { router as social } from "./social.js";
export const router = { export const router = {
demo, demo,
treatments, treatments,
bookings, bookings,
auth, auth,
availability, recurringAvailability,
cancellation, cancellation,
legal, legal,
gallery,
reviews,
social,
}; };

View File

@@ -0,0 +1,492 @@
import { call, os } from "@orpc/server";
import { z } from "zod";
import { randomUUID } from "crypto";
import { createKV } from "../lib/create-kv.js";
import { assertOwner } from "../lib/auth.js";
// Datenmodelle
const RecurringRuleSchema = z.object({
id: z.string(),
dayOfWeek: z.number().int().min(0).max(6), // 0=Sonntag, 1=Montag, ..., 6=Samstag
startTime: z.string().regex(/^\d{2}:\d{2}$/), // HH:MM Format
endTime: z.string().regex(/^\d{2}:\d{2}$/), // HH:MM Format
isActive: z.boolean(),
createdAt: z.string(),
// LEGACY: slotDurationMinutes - deprecated field for generateSlots only, will be removed
slotDurationMinutes: z.number().int().min(1).optional(),
});
const TimeOffPeriodSchema = z.object({
id: z.string(),
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), // YYYY-MM-DD
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), // YYYY-MM-DD
reason: z.string(),
createdAt: z.string(),
});
export type RecurringRule = z.output<typeof RecurringRuleSchema>;
export type TimeOffPeriod = z.output<typeof TimeOffPeriodSchema>;
// KV-Stores
const recurringRulesKV = createKV<RecurringRule>("recurringRules");
const timeOffPeriodsKV = createKV<TimeOffPeriod>("timeOffPeriods");
// Import bookings and treatments KV stores for getAvailableTimes endpoint
const bookingsKV = createKV<any>("bookings");
const treatmentsKV = createKV<any>("treatments");
// Owner-Authentifizierung zentralisiert in ../lib/auth.ts
// Helper-Funktionen
function parseTime(timeStr: string): number {
const [hours, minutes] = timeStr.split(':').map(Number);
return hours * 60 + minutes; // Minuten seit Mitternacht
}
function formatTime(minutes: number): string {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
}
function addDays(date: Date, days: number): Date {
const result = new Date(date);
result.setDate(result.getDate() + days);
return result;
}
function formatDate(date: Date): string {
return date.toISOString().split('T')[0];
}
function isDateInTimeOffPeriod(date: string, periods: TimeOffPeriod[]): boolean {
return periods.some(period => date >= period.startDate && date <= period.endDate);
}
// Helper-Funktion zur Erkennung überlappender Regeln
function detectOverlappingRules(newRule: { dayOfWeek: number; startTime: string; endTime: string; id?: string }, existingRules: RecurringRule[]): RecurringRule[] {
const newStart = parseTime(newRule.startTime);
const newEnd = parseTime(newRule.endTime);
return existingRules.filter(rule => {
// Gleicher Wochentag und nicht dieselbe Regel (bei Updates)
if (rule.dayOfWeek !== newRule.dayOfWeek || rule.id === newRule.id) {
return false;
}
const existingStart = parseTime(rule.startTime);
const existingEnd = parseTime(rule.endTime);
// Überlappung wenn: neue Startzeit < bestehende Endzeit UND neue Endzeit > bestehende Startzeit
return newStart < existingEnd && newEnd > existingStart;
});
}
// CRUD-Endpoints für Recurring Rules
const createRule = os
.input(
z.object({
sessionId: z.string(),
dayOfWeek: z.number().int().min(0).max(6),
startTime: z.string().regex(/^\d{2}:\d{2}$/),
endTime: z.string().regex(/^\d{2}:\d{2}$/),
}).passthrough()
)
.handler(async ({ input }) => {
try {
await assertOwner(input.sessionId);
// Validierung: startTime < endTime
const startMinutes = parseTime(input.startTime);
const endMinutes = parseTime(input.endTime);
if (startMinutes >= endMinutes) {
throw new Error("Startzeit muss vor der Endzeit liegen.");
}
// Überlappungsprüfung
const allRules = await recurringRulesKV.getAllItems();
const overlappingRules = detectOverlappingRules(input, allRules);
if (overlappingRules.length > 0) {
const overlappingTimes = overlappingRules.map(rule => `${rule.startTime}-${rule.endTime}`).join(", ");
throw new Error(`Überlappung mit bestehenden Regeln erkannt: ${overlappingTimes}. Bitte Zeitfenster anpassen.`);
}
const id = randomUUID();
const rule: RecurringRule = {
id,
dayOfWeek: input.dayOfWeek,
startTime: input.startTime,
endTime: input.endTime,
isActive: true,
createdAt: new Date().toISOString(),
};
await recurringRulesKV.setItem(id, rule);
return rule;
} catch (err) {
console.error("recurring-availability.createRule error", err);
throw err;
}
});
const updateRule = os
.input(RecurringRuleSchema.extend({ sessionId: z.string() }).passthrough())
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
// Validierung: startTime < endTime
const startMinutes = parseTime(input.startTime);
const endMinutes = parseTime(input.endTime);
if (startMinutes >= endMinutes) {
throw new Error("Startzeit muss vor der Endzeit liegen.");
}
// Überlappungsprüfung
const allRules = await recurringRulesKV.getAllItems();
const overlappingRules = detectOverlappingRules(input, allRules);
if (overlappingRules.length > 0) {
const overlappingTimes = overlappingRules.map(rule => `${rule.startTime}-${rule.endTime}`).join(", ");
throw new Error(`Überlappung mit bestehenden Regeln erkannt: ${overlappingTimes}. Bitte Zeitfenster anpassen.`);
}
const { sessionId, ...rule } = input as any;
await recurringRulesKV.setItem(rule.id, rule as RecurringRule);
return rule as RecurringRule;
});
const deleteRule = os
.input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
await recurringRulesKV.removeItem(input.id);
});
const toggleRuleActive = os
.input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const rule = await recurringRulesKV.getItem(input.id);
if (!rule) throw new Error("Regel nicht gefunden.");
rule.isActive = !rule.isActive;
await recurringRulesKV.setItem(input.id, rule);
return rule;
});
const listRules = os.handler(async () => {
const allRules = await recurringRulesKV.getAllItems();
return allRules.sort((a, b) => {
if (a.dayOfWeek !== b.dayOfWeek) return a.dayOfWeek - b.dayOfWeek;
return a.startTime.localeCompare(b.startTime);
});
});
const adminListRules = os
.input(z.object({ sessionId: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const allRules = await recurringRulesKV.getAllItems();
return allRules.sort((a, b) => {
if (a.dayOfWeek !== b.dayOfWeek) return a.dayOfWeek - b.dayOfWeek;
return a.startTime.localeCompare(b.startTime);
});
});
// CRUD-Endpoints für Time-Off Periods
const createTimeOff = os
.input(
z.object({
sessionId: z.string(),
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
reason: z.string(),
})
)
.handler(async ({ input }) => {
try {
await assertOwner(input.sessionId);
// Validierung: startDate <= endDate
if (input.startDate > input.endDate) {
throw new Error("Startdatum muss vor oder am Enddatum liegen.");
}
const id = randomUUID();
const timeOff: TimeOffPeriod = {
id,
startDate: input.startDate,
endDate: input.endDate,
reason: input.reason,
createdAt: new Date().toISOString(),
};
await timeOffPeriodsKV.setItem(id, timeOff);
return timeOff;
} catch (err) {
console.error("recurring-availability.createTimeOff error", err);
throw err;
}
});
const updateTimeOff = os
.input(TimeOffPeriodSchema.extend({ sessionId: z.string() }).passthrough())
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
// Validierung: startDate <= endDate
if (input.startDate > input.endDate) {
throw new Error("Startdatum muss vor oder am Enddatum liegen.");
}
const { sessionId, ...timeOff } = input as any;
await timeOffPeriodsKV.setItem(timeOff.id, timeOff as TimeOffPeriod);
return timeOff as TimeOffPeriod;
});
const deleteTimeOff = os
.input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
await timeOffPeriodsKV.removeItem(input.id);
});
const listTimeOff = os.handler(async () => {
const allTimeOff = await timeOffPeriodsKV.getAllItems();
return allTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate));
});
const adminListTimeOff = os
.input(z.object({ sessionId: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const allTimeOff = await timeOffPeriodsKV.getAllItems();
return allTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate));
});
// Get Available Times Endpoint
const getAvailableTimes = os
.input(
z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
treatmentIds: z.array(z.string())
.min(1, "Mindestens eine Behandlung muss ausgewählt werden")
.max(3, "Maximal 3 Behandlungen können ausgewählt werden")
.refine(list => {
return list.length === new Set(list).size;
}, { message: "Doppelte Behandlungen sind nicht erlaubt" }),
})
)
.handler(async ({ input }) => {
try {
// Validate that the date is not in the past
const today = new Date();
const inputDate = new Date(input.date);
today.setHours(0, 0, 0, 0);
inputDate.setHours(0, 0, 0, 0);
if (inputDate < today) {
return [];
}
// Get multiple treatments and calculate total duration
const treatments = await Promise.all(
input.treatmentIds.map(id => treatmentsKV.getItem(id))
);
// Validate that all treatments exist
const missingTreatments = treatments
.map((t, i) => t ? null : input.treatmentIds[i])
.filter(id => id !== null);
if (missingTreatments.length > 0) {
throw new Error(`Behandlung(en) nicht gefunden: ${missingTreatments.join(', ')}`);
}
// Calculate total duration by summing all treatment durations
const treatmentDuration = treatments.reduce((sum, t) => sum + (t?.duration || 0), 0);
// Parse the date to get day of week
const [year, month, day] = input.date.split('-').map(Number);
const localDate = new Date(year, month - 1, day);
const dayOfWeek = localDate.getDay(); // 0=Sonntag, 1=Montag, ...
// Find matching recurring rules
const allRules = await recurringRulesKV.getAllItems();
const matchingRules = allRules.filter(rule =>
rule.isActive === true && rule.dayOfWeek === dayOfWeek
);
if (matchingRules.length === 0) {
return []; // No rules for this day of week
}
// Check time-off periods
const timeOffPeriods = await timeOffPeriodsKV.getAllItems();
if (isDateInTimeOffPeriod(input.date, timeOffPeriods)) {
return []; // Date is blocked by time-off period
}
// Generate 15-minute intervals with boundary alignment
const availableTimes: string[] = [];
// Helper functions for 15-minute boundary alignment
const ceilTo15 = (m: number) => m % 15 === 0 ? m : m + (15 - (m % 15));
const floorTo15 = (m: number) => m - (m % 15);
for (const rule of matchingRules) {
const startMinutes = parseTime(rule.startTime);
const endMinutes = parseTime(rule.endTime);
let currentMinutes = ceilTo15(startMinutes);
const endBound = floorTo15(endMinutes);
while (currentMinutes + treatmentDuration <= endBound) {
const timeStr = formatTime(currentMinutes);
availableTimes.push(timeStr);
currentMinutes += 15; // 15-minute intervals
}
}
// Get all bookings for this date and their treatments
const allBookings = await bookingsKV.getAllItems();
const dateBookings = allBookings.filter(booking =>
booking.appointmentDate === input.date &&
['pending', 'confirmed', 'completed'].includes(booking.status)
);
// Build cache only for legacy treatmentId bookings
const legacyTreatmentIds = [...new Set(dateBookings.filter(b => b.treatmentId).map(b => b.treatmentId as string))];
const treatmentDurationMap = new Map<string, number>();
// Only build cache if there are legacy bookings
if (legacyTreatmentIds.length > 0) {
for (const id of legacyTreatmentIds) {
const t = await treatmentsKV.getItem(id);
treatmentDurationMap.set(id, t?.duration || 60);
}
}
// Filter out booking conflicts
const availableTimesFiltered = availableTimes.filter(slotTime => {
const slotStartMinutes = parseTime(slotTime);
const slotEndMinutes = slotStartMinutes + treatmentDuration; // total from selected treatments
const hasConflict = dateBookings.some(booking => {
let bookingDuration: number;
if (booking.treatments && booking.treatments.length > 0) {
bookingDuration = booking.treatments.reduce((sum: number, t: { duration: number }) => sum + t.duration, 0);
} else if (booking.bookedDurationMinutes) {
bookingDuration = booking.bookedDurationMinutes;
} else if (booking.treatmentId) {
bookingDuration = treatmentDurationMap.get(booking.treatmentId) || 60;
} else {
bookingDuration = 60;
}
const bookingStart = parseTime(booking.appointmentTime);
const bookingEnd = bookingStart + bookingDuration;
return slotStartMinutes < bookingEnd && slotEndMinutes > bookingStart;
});
return !hasConflict;
});
// Filter out past times for today
const now = new Date();
const isToday = inputDate.getTime() === today.getTime();
const finalAvailableTimes = isToday
? availableTimesFiltered.filter(timeStr => {
const slotTime = parseTime(timeStr);
const currentTime = now.getHours() * 60 + now.getMinutes();
return slotTime > currentTime;
})
: availableTimesFiltered;
// Deduplicate and sort chronologically
const unique = Array.from(new Set(finalAvailableTimes));
return unique.sort((a, b) => a.localeCompare(b));
} catch (err) {
console.error("recurring-availability.getAvailableTimes error", err);
throw err;
}
});
// Live-Queries
const live = {
listRules: os.handler(async function* ({ signal }) {
yield call(listRules, {}, { signal });
for await (const _ of recurringRulesKV.subscribe()) {
yield call(listRules, {}, { signal });
}
}),
listTimeOff: os.handler(async function* ({ signal }) {
yield call(listTimeOff, {}, { signal });
for await (const _ of timeOffPeriodsKV.subscribe()) {
yield call(listTimeOff, {}, { signal });
}
}),
adminListRules: os
.input(z.object({ sessionId: z.string() }))
.handler(async function* ({ input, signal }) {
await assertOwner(input.sessionId);
const allRules = await recurringRulesKV.getAllItems();
const sortedRules = allRules.sort((a, b) => {
if (a.dayOfWeek !== b.dayOfWeek) return a.dayOfWeek - b.dayOfWeek;
return a.startTime.localeCompare(b.startTime);
});
yield sortedRules;
for await (const _ of recurringRulesKV.subscribe()) {
const updatedRules = await recurringRulesKV.getAllItems();
const sortedUpdatedRules = updatedRules.sort((a, b) => {
if (a.dayOfWeek !== b.dayOfWeek) return a.dayOfWeek - b.dayOfWeek;
return a.startTime.localeCompare(b.startTime);
});
yield sortedUpdatedRules;
}
}),
adminListTimeOff: os
.input(z.object({ sessionId: z.string() }))
.handler(async function* ({ input, signal }) {
await assertOwner(input.sessionId);
const allTimeOff = await timeOffPeriodsKV.getAllItems();
const sortedTimeOff = allTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate));
yield sortedTimeOff;
for await (const _ of timeOffPeriodsKV.subscribe()) {
const updatedTimeOff = await timeOffPeriodsKV.getAllItems();
const sortedUpdatedTimeOff = updatedTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate));
yield sortedUpdatedTimeOff;
}
}),
};
export const router = {
// Recurring Rules
createRule,
updateRule,
deleteRule,
toggleRuleActive,
listRules,
adminListRules,
// Time-Off Periods
createTimeOff,
updateTimeOff,
deleteTimeOff,
listTimeOff,
adminListTimeOff,
// Availability
getAvailableTimes,
// Live queries
live,
};

294
src/server/rpc/reviews.ts Normal file
View File

@@ -0,0 +1,294 @@
import { call, os } from "@orpc/server";
import { z } from "zod";
import { randomUUID } from "crypto";
import { createKV } from "../lib/create-kv.js";
import { assertOwner, sessionsKV } from "../lib/auth.js";
// Schema Definition
const ReviewSchema = z.object({
id: z.string(),
bookingId: z.string(),
customerName: z.string().min(2, "Kundenname muss mindestens 2 Zeichen lang sein"),
customerEmail: z.string().email("Ungültige E-Mail-Adresse").optional(),
rating: z.number().int().min(1).max(5),
comment: z.string().min(10, "Kommentar muss mindestens 10 Zeichen lang sein"),
status: z.enum(["pending", "approved", "rejected"]),
createdAt: z.string(),
reviewedAt: z.string().optional(),
reviewedBy: z.string().optional(),
});
export type Review = z.output<typeof ReviewSchema>;
// Public-safe review type for listings on the website
export type PublicReview = {
customerName: string;
rating: number;
comment: string;
status: "pending" | "approved" | "rejected";
bookingId: string;
createdAt: string;
};
// KV Storage
const reviewsKV = createKV<Review>("reviews");
// References to other KV stores needed for validation with strong typing
type BookingAccessToken = {
id: string;
bookingId: string;
token: string;
expiresAt: string;
createdAt: string;
purpose: "booking_access" | "reschedule_proposal";
proposedDate?: string;
proposedTime?: string;
originalDate?: string;
originalTime?: string;
};
type Booking = {
id: string;
treatmentId: string;
customerName: string;
customerEmail?: string;
customerPhone?: string;
appointmentDate: string;
appointmentTime: string;
notes?: string;
inspirationPhoto?: string;
slotId?: string;
status: "pending" | "confirmed" | "cancelled" | "completed";
createdAt: string;
};
const cancellationKV = createKV<BookingAccessToken>("cancellation_tokens");
const bookingsKV = createKV<Booking>("bookings");
// Helper Function: validateBookingToken
async function validateBookingToken(token: string) {
const tokens = await cancellationKV.getAllItems();
const validToken = tokens.find(t =>
t.token === token &&
new Date(t.expiresAt) > new Date() &&
t.purpose === 'booking_access'
);
if (!validToken) {
throw new Error("Ungültiger oder abgelaufener Buchungs-Token");
}
const booking = await bookingsKV.getItem(validToken.bookingId);
if (!booking) {
throw new Error("Buchung nicht gefunden");
}
// Only allow reviews for completed appointments
if (!(booking.status === "completed")) {
throw new Error("Bewertungen sind nur für abgeschlossene Termine möglich");
}
return booking;
}
// Public Endpoint: submitReview
const submitReview = os
.input(
z.object({
bookingToken: z.string(),
rating: z.number().int().min(1).max(5),
comment: z.string().min(10, "Kommentar muss mindestens 10 Zeichen lang sein"),
})
)
.handler(async ({ input }) => {
try {
// Validate bookingToken
const booking = await validateBookingToken(input.bookingToken);
// Enforce uniqueness by using booking.id as the KV key
const existing = await reviewsKV.getItem(booking.id);
if (existing) {
throw new Error("Für diese Buchung wurde bereits eine Bewertung abgegeben");
}
// Create review object
const review: Review = {
id: booking.id,
bookingId: booking.id,
customerName: booking.customerName,
customerEmail: booking.customerEmail,
rating: input.rating,
comment: input.comment,
status: "pending",
createdAt: new Date().toISOString(),
};
await reviewsKV.setItem(booking.id, review);
return review;
} catch (err) {
console.error("reviews.submitReview error", err);
throw err;
}
});
// Admin Endpoint: approveReview
const approveReview = os
.input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input }) => {
try {
await assertOwner(input.sessionId);
const review = await reviewsKV.getItem(input.id);
if (!review) {
throw new Error("Bewertung nicht gefunden");
}
const session = await sessionsKV.getItem(input.sessionId).catch(() => undefined);
const updatedReview = {
...review,
status: "approved" as const,
reviewedAt: new Date().toISOString(),
reviewedBy: session?.userId || review.reviewedBy,
};
await reviewsKV.setItem(input.id, updatedReview);
return updatedReview;
} catch (err) {
console.error("reviews.approveReview error", err);
throw err;
}
});
// Admin Endpoint: rejectReview
const rejectReview = os
.input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input }) => {
try {
await assertOwner(input.sessionId);
const review = await reviewsKV.getItem(input.id);
if (!review) {
throw new Error("Bewertung nicht gefunden");
}
const session = await sessionsKV.getItem(input.sessionId).catch(() => undefined);
const updatedReview = {
...review,
status: "rejected" as const,
reviewedAt: new Date().toISOString(),
reviewedBy: session?.userId || review.reviewedBy,
};
await reviewsKV.setItem(input.id, updatedReview);
return updatedReview;
} catch (err) {
console.error("reviews.rejectReview error", err);
throw err;
}
});
// Admin Endpoint: deleteReview
const deleteReview = os
.input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input }) => {
try {
await assertOwner(input.sessionId);
await reviewsKV.removeItem(input.id);
} catch (err) {
console.error("reviews.deleteReview error", err);
throw err;
}
});
// Public Endpoint: listPublishedReviews
const listPublishedReviews = os.handler(async (): Promise<PublicReview[]> => {
try {
const allReviews = await reviewsKV.getAllItems();
const published = allReviews.filter(r => r.status === "approved");
const sorted = published.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
const publicSafe: PublicReview[] = sorted.map(r => ({
customerName: r.customerName,
rating: r.rating,
comment: r.comment,
status: r.status,
bookingId: r.bookingId,
createdAt: r.createdAt,
}));
return publicSafe;
} catch (err) {
console.error("reviews.listPublishedReviews error", err);
throw err;
}
});
// Admin Endpoint: adminListReviews
const adminListReviews = os
.input(
z.object({
sessionId: z.string(),
statusFilter: z.enum(["all", "pending", "approved", "rejected"]).optional().default("all"),
})
)
.handler(async ({ input }) => {
try {
await assertOwner(input.sessionId);
const allReviews = await reviewsKV.getAllItems();
const filtered = input.statusFilter === "all"
? allReviews
: allReviews.filter(r => r.status === input.statusFilter);
const sorted = filtered.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
return sorted;
} catch (err) {
console.error("reviews.adminListReviews error", err);
throw err;
}
});
// Live Queries
const live = {
listPublishedReviews: os.handler(async function* ({ signal }) {
yield call(listPublishedReviews, {}, { signal });
for await (const _ of reviewsKV.subscribe()) {
yield call(listPublishedReviews, {}, { signal });
}
}),
adminListReviews: os
.input(
z.object({
sessionId: z.string(),
statusFilter: z.enum(["all", "pending", "approved", "rejected"]).optional().default("all"),
})
)
.handler(async function* ({ input, signal }) {
await assertOwner(input.sessionId);
const allReviews = await reviewsKV.getAllItems();
const filtered = input.statusFilter === "all"
? allReviews
: allReviews.filter(r => r.status === input.statusFilter);
const sorted = filtered.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
yield sorted;
for await (const _ of reviewsKV.subscribe()) {
const updated = await reviewsKV.getAllItems();
const filteredUpdated = input.statusFilter === "all"
? updated
: updated.filter(r => r.status === input.statusFilter);
const sortedUpdated = filteredUpdated.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
yield sortedUpdated;
}
}),
};
export const router = {
submitReview,
approveReview,
rejectReview,
deleteReview,
listPublishedReviews,
adminListReviews,
live,
};

13
src/server/rpc/social.ts Normal file
View File

@@ -0,0 +1,13 @@
import { os } from "@orpc/server";
const getSocialMedia = os.handler(async () => {
return {
tiktokProfile: process.env.TIKTOK_PROFILE,
instagramProfile: process.env.INSTAGRAM_PROFILE,
};
});
export const router = os.router({
getSocialMedia,
});

View File

@@ -23,6 +23,7 @@ export default defineConfig(({ mode }) => {
publicDir: "public", publicDir: "public",
build: { build: {
outDir: "dist", outDir: "dist",
manifest: true,
rollupOptions: { rollupOptions: {
input: "index.html" input: "index.html"
} }