98 Commits

Author SHA1 Message Date
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
49829a4573 Fix: Only serve static files from dist in production
- Added NODE_ENV check before serving static files from ./dist
- Prevents 'serveStatic: root path ./dist is not found' error in development
- Keeps Docker/production configuration intact
- Development mode now works with pnpm dev without build step
2025-10-02 00:29:12 +02:00
eacb063bc0 Fix: Move favicon configuration inside main domain block
- Moved favicon.ico and favicon.png handlers inside stargirlnails.de block
- This prevents Caddy from trying to create SSL certificates for favicon files
- Uses 'handle' directive for path-specific routing within the domain
- Fixes 'Invalid identifiers requested' error for favicon files
2025-10-02 00:22:45 +02:00
e6ffb0ef6d Fix: Replace invalid 'file' directive with 'try_files' in Caddyfile
- Changed 'file favicon.png' to 'try_files {path}' which is the correct Caddy syntax
- Updated root path to /app/public for consistency
- This fixes the 'unrecognized directive: file' error in Caddy
2025-10-02 00:20:48 +02:00
6e826922f6 Fix: Copy public directory to production container
- Added COPY --from=base /app/public ./public to Dockerfile
- This ensures all public assets (favicon.png, AGB.pdf, assets/) are available in production
- Fixes missing public files in the production container
- Public directory contains favicon.png, AGB.pdf, and logo assets
2025-10-02 00:10:14 +02:00
38594d30a2 Add favicon configuration to Caddyfile
- Added favicon.ico redirect to favicon.png (301 redirect)
- Added favicon.png serving from /app/dist directory
- This fixes favicon loading issues in browsers
- Both favicon.ico and favicon.png requests are now handled correctly
2025-10-02 00:09:10 +02:00
76874bc98a Fix: Remove invalid rate_limit directive from Caddyfile
- Removed rate_limit directive which is not supported in Caddy
- Caddyfile now uses only valid Caddy directives
- This fixes the configuration error that was preventing Caddy from starting
2025-10-01 23:41:34 +02:00
a77634bb13 Clean up: Remove obsolete nginx/certbot files and update README
- Deleted all nginx configuration files and directory
- Removed obsolete SSL setup scripts (check-ssl-*, setup-ssl-*, setup-simple.sh)
- Updated README.md to reflect Caddy-based production deployment
- Kept only essential scripts: setup-caddy.sh, rebuild-prod.sh, start-with-email.ps1
- Production deployment now uses docker-compose-prod.yml with automatic SSL
2025-10-01 23:36:52 +02:00
8ffe459d50 Replace Nginx/Certbot with Caddy for automatic SSL
- Replaced nginx and certbot services with caddy in docker-compose-prod.yml
- Added Caddyfile configuration with automatic SSL and security headers
- Created setup-caddy.sh script for easy deployment
- Caddy automatically handles Let's Encrypt certificates without manual setup
- Much simpler SSL management compared to nginx/certbot combination
2025-10-01 23:34:43 +02:00
c28d4fc4ec Add simple SSL check script using direct Docker commands
- Created check-ssl-simple.sh that uses direct Docker commands instead of docker-compose
- Uses alpine:latest container directly with volume mount
- Avoids Certbot communication issues that cause hanging
- Provides clean SSL certificate inspection without external dependencies
2025-10-01 23:26:32 +02:00
6b10c256a0 Fix: Use certbot service instead of alpine in SSL check script
- Changed from alpine to certbot service which is defined in docker-compose-prod.yml
- This fixes the 'no such service: alpine' error
- Script now uses the existing certbot container to check SSL certificates
2025-10-01 23:25:09 +02:00
6987d48bd6 Add direct SSL certificate check script
- Created check-ssl-direct.sh that checks SSL certificates without using Certbot
- Uses Alpine container to directly inspect the certbot-certs volume
- Avoids hanging issues with Certbot communication
- Can automatically enable HTTPS if certificates are found
2025-10-01 23:23:56 +02:00
97d17d67ee Add simple setup script without SSL complexity
- Created setup-simple.sh that starts the application with HTTP-only
- Avoids SSL certificate checking that was causing hangs
- Provides a working baseline before SSL setup
- Users can manually configure SSL later if needed
2025-10-01 23:21:01 +02:00
98858c1760 Add SSL certificate permissions diagnostic script
- Created check-ssl-permissions.sh to diagnose SSL certificate access issues
- Script checks certificate files, permissions, and ownership
- Attempts to repair permissions if needed
- This helps identify if SSL setup issues are permission-related
2025-10-01 23:19:45 +02:00
b3272d565b Fix: Add timeout and fallback for SSL certificate check
- Added 30-second timeout to certificate check to prevent hanging
- Added fallback to HTTP-only configuration if SSL setup fails
- Script now continues even if certificate verification fails
- This prevents the script from hanging indefinitely
2025-10-01 23:18:01 +02:00
e29f4374c0 Fix: Handle existing SSL certificates in setup script
- Added check for existing SSL certificates before attempting to create new ones
- Restore original HTTPS nginx.conf after certificate verification
- This prevents the script from hanging when certificates already exist
2025-10-01 23:16:07 +02:00
23ea0d801e Fix: Resolve Nginx SSL certificate loading issue
- Created nginx-http-only.conf for initial startup without SSL
- Added setup-ssl-improved.sh script that:
  - Starts app first, then HTTP-only Nginx
  - Creates SSL certificates via Certbot
  - Switches to HTTPS configuration after certificate creation
- This prevents Nginx from failing on missing SSL certificates during initial startup
2025-10-01 23:13:31 +02:00
b10df50688 add rebuild script 2025-10-01 23:11:02 +02:00
ffc21a76e7 Fix: Resolve permission issues with .storage directories
- Install su-exec in Dockerfile for user switching
- Modified start.sh to create directories as root, then change ownership
- Container starts as root but switches to nextjs user for app execution
- This prevents permission denied errors when creating .storage directories
2025-10-01 23:07:33 +02:00
857b60e1f5 Fix: Use startup script to create .storage directories at runtime
- Changed from bind mount to named volume for .storage
- Added start.sh script that creates required directories before starting the app
- This prevents ENOENT errors when initializing admin user
2025-10-01 23:05:21 +02:00
713da5a802 Fix: Create .storage directories in Dockerfile to prevent ENOENT errors 2025-10-01 23:01:35 +02:00
12b31d28d5 fix(client-entry): füge CSS-Fallback für Production-Build hinzu 2025-10-01 22:54:23 +02:00
84d6f5c07a fix(client-entry): korrigiere TypeScript-Typen für cssFiles Array 2025-10-01 22:51:01 +02:00
f4d9f60fc9 fix(client-entry): verwende korrekte Asset-Pfade aus Vite-Manifest für Production-Build 2025-10-01 22:49:57 +02:00
2c2a173b96 fix(server): füge statische Datei-Serving für Production-Build hinzu 2025-10-01 22:47:06 +02:00
3d5c6ffeaf fix(server): korrigiere Import-Position für @hono/node-server 2025-10-01 22:42:18 +02:00
72834a6977 fix(server): füge @hono/node-server hinzu und korrigiere Server-Start für Node.js 2025-10-01 22:41:11 +02:00
18b75fdde3 fix(server): füge Server-Start-Konfiguration hinzu für Hono-App 2025-10-01 22:38:44 +02:00
143051a90a fix(server-build): ersetze @/server-Pfad-Aliase durch relative Imports mit .js-Erweiterungen 2025-10-01 22:33:49 +02:00
1e1070dbb5 fix(server-build): füge .js-Erweiterungen zu allen lib-Imports in bookings.ts hinzu 2025-10-01 22:31:40 +02:00
19e52f7af6 fix(server-build): füge .js-Erweiterungen zu lib-Imports in RPC-Dateien hinzu 2025-10-01 22:29:23 +02:00
a80cb86cd5 fix(server-runtime): entferne Import von @vitejs/plugin-react im Server-HTML-Renderer; nutze Vite HMR Script direkt 2025-10-01 22:20:34 +02:00
74f55486bc fix(server-build): füge .js-Erweiterungen zu allen relativen Imports hinzu für ESNext-Module-Kompatibilität 2025-10-01 22:17:23 +02:00
c6c1455612 fix(server-build): ersetze import.meta.env.PROD durch process.env.NODE_ENV für Server-Build Kompatibilität 2025-10-01 22:15:18 +02:00
9d71842714 fix(server-build): Server-Build auf ESNext/bundler umgestellt für oRPC und import.meta Kompatibilität 2025-10-01 22:14:12 +02:00
b3df04a92d fix(server-build): korrigiere Import von router in bookings.ts - nutze './index' statt '..' 2025-10-01 22:12:51 +02:00
3d1bbe7265 fix(server-build): entferne Pfadalias '@/server/*' im Server-Code, nutze relative Imports; passe RPC-Route-Import und OpenAI-Import an; Server-Build nutzt CommonJS/Node Resolution 2025-10-01 22:11:30 +02:00
f44164c957 fix(build): entferne allowImportingTsExtensions für Server-Build (TS5096) 2025-10-01 22:06:51 +02:00
9da96d7af9 build(server): separater TS-Build für Server (server-dist) und Runtime auf Node JS statt ts-node; Dockerfile startet server-dist/index.js 2025-10-01 22:05:01 +02:00
4f901400a3 fix(runtime): füge tsconfig.server.json hinzu und setze TS_NODE_PROJECT für NodeNext-Loader 2025-10-01 22:01:39 +02:00
1cf727433d chore(lockfile): pnpm-lock.yaml aktualisiert nach Hinzufügen von ts-node 2025-10-01 21:58:02 +02:00
647016ff85 fix(runtime): füge ts-node als Dependency hinzu für ESM-Loader im Production-Container 2025-10-01 21:56:35 +02:00
fe3acccb93 fix(runtime): installiere ts-node im Production-Image, damit Node --loader ts-node/esm funktioniert 2025-10-01 21:47:35 +02:00
a7733c95f6 fix(build): füge index.html hinzu und konfiguriere Vite Build für Hono-Setup 2025-10-01 21:42:21 +02:00
4696948c6c fix(build): korrigiere mutate-Aufruf - oRPC Mutation erwartet direktes Objekt, kein input-Wrapper 2025-10-01 21:41:10 +02:00
73612caa1e fix(build): oRPC Query/Mutation options korrekt verwendet (input wrapper), interne RPC-Client-Typisierung gelockert und createToken-Aufrufe angepasst 2025-10-01 21:39:40 +02:00
fb30bb6395 fix(build): entferne Context-Header-Nutzung aus bookings.create (RateLimit nur per E-Mail) 2025-10-01 21:33:27 +02:00
4acb639e66 fix(build): Types in admin-calendar, oRPC React Query Helpers in booking-status, Router-Namenskonflikt, entferne unsupported allowedHosts aus Vite 2025-10-01 21:28:21 +02:00
52280b1b3b feat(setup-ssl): automatische sudo-Unterstützung für Docker/Compose, alle Aufrufe vereinheitlicht 2025-10-01 21:24:26 +02:00
f9d42b4c1e chore(compose): entferne version und behebe depends_on-Zyklus (nginx ↔ app) 2025-10-01 21:23:00 +02:00
18f97e4e5f fix(setup-ssl): Docker Compose Kompatibilität - unterstützt sowohl docker-compose als auch docker compose 2025-10-01 21:21:39 +02:00
17f1ff698e docker compose 2025-10-01 21:20:15 +02:00
71a107de52 fix(setup-ssl): .env nicht sourcen, DOMAIN/ADMIN_EMAIL robust parsen (Leerzeichen-kompatibel) 2025-10-01 21:18:42 +02:00
58fb163bbc feat: Produktions-Deployment mit Nginx und SSL
- docker-compose-prod.yml: Produktionsumgebung mit Nginx Reverse Proxy
- nginx/nginx.conf: Optimierte Nginx-Konfiguration mit SSL und Sicherheits-Headers
- Rate Limiting für API-Endpunkte (10/s) und Login (5/min)
- Automatische SSL-Zertifikate via Let's Encrypt/Certbot
- Gzip-Kompression und Performance-Optimierungen

Setup-Scripts:
- scripts/setup-ssl.sh: Bash-Script für Linux/macOS
- scripts/setup-ssl.ps1: PowerShell-Script für Windows
- Automatische Domain-Konfiguration aus .env (DOMAIN, ADMIN_EMAIL)
- Ein-Klick-Setup für SSL-Zertifikate

Dokumentation:
- docs/production-deployment.md: Vollständige Deployment-Anleitung
- Troubleshooting, Monitoring, Backup-Strategien
- Sicherheitsempfehlungen und Best Practices

Features:
- Automatische SSL-Zertifikat-Erneuerung (alle 12h)
- HSTS, CSP, XSS-Schutz
- Health Checks und Monitoring
- Persistente Daten über Docker Volumes
2025-10-01 21:13:49 +02:00
1d97e05000 feat: Google Apps Script für automatische Test-Formular-Erstellung
- Automatische Generierung eines Google Forms aus der Test-Checkliste
- ~180 Checkbox-Items über 14 Sections
- Testergebnis-Bereich mit Fehlerberichten und Gesamtbewertung
- Kein API-Key erforderlich - läuft direkt im Google Account
- Schritt-für-Schritt-Anleitung im Code-Kommentar
- Exportierbar nach Google Sheets
- Mehrfach verwendbar für verschiedene Test-Runden
2025-10-01 13:39:12 +02:00
86a73f2c16 docs: Umfassende Blackbox-Test-Checkliste für Statusseite
- 16 Hauptkategorien mit >150 Testfällen
- Alle Status-Typen abgedeckt (pending/confirmed/cancelled/completed)
- E-Mail-Integration und ICS-Dateien
- Stornierungslogik und Zeitvalidierung
- UI/UX, Performance und Accessibility
- Sicherheit und Edge Cases
- Browser-Kompatibilität
- Testergebnis-Bereich für Dokumentation
2025-10-01 13:17:55 +02:00
85fcde0805 feat: Token-basierte Kunden-Statusseite
- Neue /booking/{token} Route für einheitliche Buchungsübersicht
- Vollständige Termin-Details mit Status-Badges (pending/confirmed/cancelled/completed)
- Integrierte Stornierungsfunktion mit Bestätigungsdialog
- Anzeige von Behandlungsdetails, Kundendaten und verbleibender Zeit
- Automatische Berechnung ob Stornierung noch möglich
- Responsive UI mit modernem Design

Server-Erweiterungen:
- BookingAccessToken statt CancellationToken (semantisch präziser)
- Erweiterte Rückgabe von getBookingByToken (Preis, Dauer, canCancel, hoursUntilAppointment)
- Token-Generierung bei Buchungserstellung (pending) und Bestätigung

E-Mail-Integration:
- Status-Links in pending-Mails
- 'Termin verwalten' statt 'Termin stornieren' in confirmed-Mails
- Einheitliches Branding (Pink/Orange statt Rot)

Aufgeräumt:
- Legacy cancellation-page.tsx entfernt
- /cancel/ Route entfernt (keine Rückwärtskompatibilität nötig)
- Backlog aktualisiert
2025-10-01 13:14:27 +02:00
87 changed files with 11090 additions and 1119 deletions

View File

@@ -10,7 +10,9 @@ RESEND_API_KEY=your_resend_api_key_here
EMAIL_FROM=noreply@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)
MIN_STORNO_TIMESPAN=24

1
.gitignore vendored
View File

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

59
Caddyfile Normal file
View File

@@ -0,0 +1,59 @@
# Caddyfile für Stargirlnails Kiel
# Automatisches SSL mit Let's Encrypt
stargirlnails.de {
# Reverse Proxy zur Anwendung
reverse_proxy stargirlnails:3000 {
# Health Check
health_uri /health
health_interval 30s
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
header {
# Sicherheits-Header
X-Frame-Options "SAMEORIGIN"
X-Content-Type-Options "nosniff"
X-XSS-Protection "1; mode=block"
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: blob:; font-src 'self' data:; connect-src 'self' data: blob:; frame-src 'self' https://www.openstreetmap.org;"
# HSTS (wird automatisch von Caddy gesetzt)
Strict-Transport-Security "max-age=31536000; includeSubDomains"
}
# Gzip-Kompression
encode gzip
# Logging
log {
output file /var/log/caddy/access.log
format json
}
# Favicon-Konfiguration (innerhalb der Hauptdomain)
handle /favicon.ico {
redir /favicon.png 301
}
handle /favicon.png {
root * /app/public
try_files {path}
}
}
# HTTP zu HTTPS Redirect (automatisch von Caddy)
http://stargirlnails.de {
redir https://stargirlnails.de{uri} permanent
}

View File

@@ -22,8 +22,8 @@ RUN pnpm build
# Production stage
FROM node:22-alpine AS production
# Install pnpm
RUN npm install -g pnpm
# Install pnpm and su-exec
RUN npm install -g pnpm ts-node && apk add --no-cache su-exec
# Set working directory
WORKDIR /app
@@ -36,20 +36,30 @@ RUN pnpm install --frozen-lockfile --prod
# Copy built application from base stage
COPY --from=base /app/dist ./dist
COPY --from=base /app/server-dist ./server-dist
COPY --from=base /app/public ./public
# Copy necessary files for runtime
COPY --from=base /app/src/server/index.ts ./src/server/index.ts
COPY --from=base /app/src/server/routes ./src/server/routes
COPY --from=base /app/src/server/rpc ./src/server/rpc
COPY --from=base /app/src/server/lib ./src/server/lib
COPY --from=base /app/tsconfig.server.json ./tsconfig.server.json
COPY start.sh ./start.sh
# Create non-root user for security
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nextjs -u 1001
# Change ownership of the app directory
# Make start script executable
RUN chmod +x /app/start.sh
# Change ownership of the app directory (but keep root for .storage)
RUN chown -R nextjs:nodejs /app
USER nextjs
RUN chown root:root /app/.storage 2>/dev/null || true
# Don't switch to nextjs user here - the start script will handle it
# USER nextjs
# Expose port
EXPOSE 3000
@@ -58,5 +68,5 @@ EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) })" || exit 1
# Start the application
CMD ["node", "--loader", "ts-node/esm", "src/server/index.ts"]
# Start the application with startup script
CMD ["/app/start.sh"]

View File

@@ -154,25 +154,21 @@ docker-compose down
### Produktions-Deployment
Für den produktiven Einsatz:
Für den produktiven Einsatz mit automatischem SSL:
```bash
# Mit Docker Compose
docker-compose -f docker-compose.yml up -d
# Mit Docker Compose (empfohlen)
docker-compose -f docker-compose-prod.yml up -d
# Oder direkt mit Docker
docker run -d \
--name stargirlnails-prod \
-p 80:3000 \
--restart unless-stopped \
--env-file .env.production \
stargirlnails-booking
# Oder mit dem Caddy-Setup-Script
chmod +x scripts/setup-caddy.sh
./scripts/setup-caddy.sh
```
**Wichtige Produktions-Hinweise:**
- Verwende eine `.env.production` Datei mit Produktions-Konfiguration
- Setze `NODE_ENV=production` in der Umgebungsdatei
- Verwende einen Reverse Proxy (nginx, Traefik) für HTTPS
- **Automatisches SSL**: Caddy erstellt und verwaltet automatisch Let's Encrypt-Zertifikate
- Überwache Container mit Health Checks
- **Persistente Daten**: Der `.storage` Ordner wird als Volume gemountet, um Buchungen und Einstellungen zu erhalten

56
docker-compose-prod.yml Normal file
View File

@@ -0,0 +1,56 @@
# Production Docker Compose für Stargirlnails Kiel
# Mit Nginx Reverse Proxy und Let's Encrypt SSL-Zertifikaten
services:
# Hauptanwendung
stargirlnails:
build: .
container_name: stargirlnails-app
env_file:
- .env
environment:
- NODE_ENV=production
- DISABLE_DUPLICATE_CHECK=false
restart: unless-stopped
volumes:
- storage-data:/app/.storage
networks:
- stargirlnails-network
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# Keine Abhängigkeit zu nginx, um Dependency-Zyklen zu vermeiden
# Caddy Reverse Proxy mit automatischem SSL
caddy:
image: caddy:2-alpine
container_name: stargirlnails-caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
networks:
- stargirlnails-network
depends_on:
- stargirlnails
# Volumes für persistente Daten
volumes:
storage-data:
driver: local
caddy-data:
driver: local
caddy-config:
driver: local
# Netzwerk für interne Kommunikation
networks:
stargirlnails-network:
driver: bridge

View File

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

View File

@@ -4,15 +4,17 @@
- ~~ICS-Anhang/Link in EMails (Kalendereintrag)~~
- Erinnerungsmails (24h/3h vor Termin)
- ~~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~~
### Sicherheit & Qualität
- ~~RateLimiting (IP/EMail) für Formularspam~~
- EMailVerifizierung (DoubleOptIn) optional
- ~~EMailVerifizierung (DoubleOptIn) optional~~
- AuditLog (wer/was/wann)
- DSGVO: Einwilligungstexte, Löschkonzept
- Impressum
- ~~DSGVO: Einwilligungstexte, Löschkonzept~~
- ~~Impressum~~
### EMail & Infrastruktur
- Retry/Backoff + FallbackQueue bei ResendFehlern
@@ -22,7 +24,7 @@
### UX/UI
- ~~Mobiler Kalender mit klarer SlotVisualisierung~~
- KundenStatusseite (pending/confirmed)
- ~~KundenStatusseite (pending/confirmed)~~
- Prominente Fehlerzustände inkl. Hinweise bei Doppelbuchung
### Internationalisierung & Zeitzonen

View File

@@ -0,0 +1,422 @@
/**
* Google Apps Script zur automatischen Erstellung eines Test-Formulars
* für die Kunden-Statusseite
*
* ANLEITUNG:
* 1. Öffne https://script.google.com/
* 2. Klicke auf "Neues Projekt"
* 3. Kopiere diesen Code in den Editor
* 4. Klicke auf "Ausführen" (Play-Button) → Funktion "createTestForm"
* 5. Erlaube die benötigten Berechtigungen
* 6. Nach Ausführung wird die URL des erstellten Formulars in den Logs angezeigt
* 7. Öffne die URL oder finde das Formular in Google Drive
*/
function createTestForm() {
// Erstelle neues Formular
const form = FormApp.create('Test-Checkliste: Kunden-Statusseite');
// Formular-Einstellungen
form.setDescription('Blackbox-Tests für das Feature "Token-basierte Buchungsübersicht"\n\nBranch: Statusseite\nDatum: 2025-10-01');
form.setConfirmationMessage('Vielen Dank! Die Testergebnisse wurden gespeichert.');
form.setAllowResponseEdits(true);
form.setShowLinkToRespondAgain(false);
// Sammle E-Mail-Adressen
form.setCollectEmail(true);
// === VORBEREITUNG ===
addSection(form, 'Vorbereitung', 'Stelle sicher, dass die Testumgebung bereit ist');
addCheckboxes(form, [
'Entwicklungsserver läuft (pnpm dev)',
'E-Mail-Service konfiguriert (RESEND_API_KEY gesetzt)',
'Admin-Account verfügbar',
'Testbehandlungen vorhanden',
'Verfügbare Slots erstellt'
]);
// === 1. BUCHUNGSERSTELLUNG & TOKEN-GENERIERUNG ===
addSection(form, '1. Buchungserstellung & Token-Generierung', 'Teste die Token-Erstellung bei neuen Buchungen');
addParagraph(form, '1.1 Neue Buchung (Status: pending)');
addCheckboxes(form, [
'Buchung über Formular erstellen',
'Pending-E-Mail erhalten',
'Status-Link (/booking/{token}) in E-Mail vorhanden',
'Button "Status ansehen" vorhanden und korrekt verlinkt',
'Link funktioniert beim Klick'
]);
addParagraph(form, '1.2 Token-Validierung');
addCheckboxes(form, [
'Gültiger Token öffnet Statusseite',
'Ungültiger Token zeigt Fehlermeldung',
'Abgelaufener Token zeigt entsprechende Meldung',
'Token ohne Parameter zeigt Fehler'
]);
// === 2. STATUSSEITE UI/UX ===
addSection(form, '2. Statusseite UI/UX (Allgemein)', 'Teste das generelle Layout und Design');
addParagraph(form, '2.1 Layout & Design');
addCheckboxes(form, [
'Logo wird angezeigt',
'Seite ist responsive (Desktop, Tablet, Mobile)',
'Alle Texte sind auf Deutsch',
'Farben entsprechen dem Branding (Pink/Purple)',
'"Zurück zur Startseite" Link funktioniert'
]);
addParagraph(form, '2.2 Navigation');
addCheckboxes(form, [
'Link zur Startseite funktioniert',
'Browser-Zurück-Button funktioniert korrekt',
'URL ist teilbar (Copy & Paste)'
]);
// === 3. STATUS: PENDING ===
addSection(form, '3. Status: Pending', 'Teste den Status "Wartet auf Bestätigung"');
addParagraph(form, '3.1 Anzeige');
addCheckboxes(form, [
'Status-Badge zeigt "⏳ Wartet auf Bestätigung" (gelb)',
'Banner mit gelber Hintergrundfarbe',
'Text erklärt, dass Termin geprüft wird',
'Datum im Format dd.mm.yyyy',
'Uhrzeit wird angezeigt',
'Behandlung wird angezeigt',
'Dauer in Minuten wird angezeigt',
'Preis wird angezeigt (wenn > 0)',
'Kundenname wird angezeigt',
'E-Mail wird angezeigt',
'Telefon wird angezeigt',
'Notizen werden angezeigt (falls vorhanden)'
]);
addParagraph(form, '3.2 Stornierung');
addCheckboxes(form, [
'Stornierungsbereich ist NICHT sichtbar',
'Keine Stornierungsbuttons vorhanden'
]);
// === 4. STATUS: CONFIRMED ===
addSection(form, '4. Status: Confirmed', 'Teste den Status "Bestätigt"');
addParagraph(form, '4.1 Statuswechsel');
addCheckboxes(form, [
'Admin bestätigt Buchung',
'Confirmed-E-Mail wird versendet',
'E-Mail enthält "Termin verwalten" Button',
'Link in E-Mail zeigt auf /booking/{token}',
'ICS-Datei ist im E-Mail-Anhang',
'AGB-PDF ist im E-Mail-Anhang'
]);
addParagraph(form, '4.2 Anzeige');
addCheckboxes(form, [
'Status-Badge zeigt "✓ Bestätigt" (grün)',
'Banner mit grüner Hintergrundfarbe',
'Text bestätigt den Termin',
'"Verbleibende Zeit" wird angezeigt',
'Stunden bis zum Termin korrekt berechnet'
]);
addParagraph(form, '4.3 Stornierung (wenn möglich)');
addCheckboxes(form, [
'Stornierungsbereich ist sichtbar',
'Hinweistext zur Stornierungsfrist angezeigt',
'Button "Termin stornieren" vorhanden',
'Klick zeigt Bestätigungsdialog',
'Dialog enthält Warnhinweis in rot',
'Dialog hat "Ja, stornieren" Button',
'Dialog hat "Abbrechen" Button',
'"Abbrechen" schließt Dialog ohne Aktion',
'"Ja, stornieren" führt Stornierung durch',
'Nach Stornierung: Erfolgsmeldung angezeigt',
'Nach Stornierung: Status aktualisiert'
]);
addParagraph(form, '4.4 Stornierung (nicht mehr möglich)');
addCheckboxes(form, [
'Termin < 24h: Kein Stornierungsbutton',
'Gelber Hinweiskasten wird angezeigt',
'Text erklärt abgelaufene Frist',
'Kontakthinweis wird angezeigt'
]);
// === 5. STATUS: CANCELLED ===
addSection(form, '5. Status: Cancelled', 'Teste den Status "Storniert"');
addParagraph(form, '5.1 Nach Stornierung');
addCheckboxes(form, [
'Status-Badge zeigt "✕ Storniert" (rot)',
'Banner mit roter Hintergrundfarbe',
'Text erklärt Stornierung',
'Hinweis auf Neubuchung vorhanden',
'Stornierungsbereich nicht mehr sichtbar'
]);
addParagraph(form, '5.2 Cancelled-E-Mail');
addCheckboxes(form, [
'E-Mail wird an Kunden gesendet',
'E-Mail enthält storniertes Datum',
'E-Mail enthält Hinweis auf Neubuchung'
]);
// === 6. STATUS: COMPLETED ===
addSection(form, '6. Status: Completed', 'Teste den Status "Abgeschlossen"');
addCheckboxes(form, [
'Status-Badge zeigt "✓ Abgeschlossen" (grau)',
'Banner mit grauer Hintergrundfarbe',
'Dankestext wird angezeigt',
'Stornierungsbereich nicht sichtbar',
'Alle Termin-Details bleiben sichtbar'
]);
// === 7. E-MAIL-INTEGRATION ===
addSection(form, '7. E-Mail-Integration', 'Teste alle E-Mail-Typen');
addParagraph(form, '7.1 Pending-Mail');
addCheckboxes(form, [
'Betreff: "Deine Terminanfrage ist eingegangen"',
'Orangefarbener "Status ansehen" Button',
'Link funktioniert',
'Text erklärt folgende Bestätigung',
'Rechtliche Informationen enthalten'
]);
addParagraph(form, '7.2 Confirmed-Mail');
addCheckboxes(form, [
'Betreff: "Dein Termin wurde bestätigt - AGB im Anhang"',
'Pinker "Termin ansehen & verwalten" Button',
'Link zeigt auf /booking/{token}',
'ICS-Datei im Anhang: "Termin_Stargirlnails.ics"',
'ICS-Datei kann in Kalender importiert werden',
'AGB-PDF im Anhang: "AGB_Stargirlnails_Kiel.pdf"'
]);
addParagraph(form, '7.3 Cancelled-Mail');
addCheckboxes(form, [
'Betreff: "Dein Termin wurde abgesagt"',
'Text erklärt Stornierung',
'Hinweis auf Neubuchung',
'Rechtliche Informationen enthalten'
]);
// === 8. ICS-KALENDEREINTRÄGE ===
addSection(form, '8. ICS-Kalendereinträge', 'Teste die Kalenderdatei-Funktionalität');
addParagraph(form, '8.1 ICS-Datei Inhalt');
addCheckboxes(form, [
'Zeitzone: Europe/Berlin',
'Startzeit korrekt',
'Endzeit korrekt (Start + Behandlungsdauer)',
'Titel: "{Behandlung} - Stargirlnails Kiel"',
'Location: "Stargirlnails Kiel"',
'Beschreibung enthalten',
'24h-Erinnerung konfiguriert'
]);
addParagraph(form, '8.2 Kalender-Import');
addCheckboxes(form, [
'Import in Kalender-App funktioniert',
'Termin erscheint mit korrekter Zeit',
'Erinnerung wird ausgelöst'
]);
// === 9. STORNIERUNGSLOGIK ===
addSection(form, '9. Stornierungslogik', 'Teste die Stornierungsregeln');
addParagraph(form, '9.1 Zeitbasierte Validierung');
addCheckboxes(form, [
'Termin > 24h: Stornierung möglich',
'Termin < 24h: Stornierung nicht möglich',
'Termin in Vergangenheit: nicht möglich',
'Verbleibende Stunden korrekt berechnet'
]);
addParagraph(form, '9.2 Statusbasierte Validierung');
addCheckboxes(form, [
'Status "pending": Keine Stornierung',
'Status "confirmed": Stornierung möglich (wenn Zeit OK)',
'Status "cancelled": Keine Stornierung',
'Status "completed": Keine Stornierung'
]);
addParagraph(form, '9.3 Stornierungsablauf');
addCheckboxes(form, [
'Bestätigungsdialog erscheint',
'Loading-Spinner während Stornierung',
'Erfolgsmeldung nach Stornierung',
'Fehlermeldung bei Fehler',
'Token bleibt nach Stornierung gültig',
'Slot wird wieder freigegeben',
'Status aktualisiert sich'
]);
// === 10. FEHLERBEHANDLUNG ===
addSection(form, '10. Fehlerbehandlung', 'Teste das Fehler-Handling');
addParagraph(form, '10.1 Ungültige Token');
addCheckboxes(form, [
'Nicht existierender Token: Fehlermeldung',
'Abgelaufener Token: Fehlermeldung',
'Leerer Token: Fehlermeldung',
'Fehlermeldung benutzerfreundlich auf Deutsch'
]);
addParagraph(form, '10.2 Netzwerkfehler');
addCheckboxes(form, [
'API nicht erreichbar: Fehlermeldung',
'Timeout: Fehlermeldung',
'Fehler während Stornierung: Meldung bleibt sichtbar'
]);
// === 11. PERFORMANCE ===
addSection(form, '11. Performance & Ladezeiten', 'Teste Performance-Aspekte');
addCheckboxes(form, [
'Statusseite lädt in < 2 Sekunden',
'Keine sichtbaren Layout-Shifts',
'Loading-Spinner während Laden',
'Bilder optimiert geladen',
'Keine JavaScript-Fehler in Console'
]);
// === 12. ACCESSIBILITY & BROWSER ===
addSection(form, '12. Accessibility & Browser-Kompatibilität', 'Teste Zugänglichkeit und Browser');
addParagraph(form, '12.1 Accessibility');
addCheckboxes(form, [
'Buttons mit Tastatur erreichbar',
'Fokus-Indikatoren sichtbar',
'Farbkontraste ausreichend (WCAG AA)',
'Alt-Texte für Bilder vorhanden'
]);
addParagraph(form, '12.2 Browser-Kompatibilität');
addCheckboxes(form, [
'Chrome (aktuell): Funktioniert',
'Firefox (aktuell): Funktioniert',
'Edge (aktuell): Funktioniert',
'Mobile Browser: Funktioniert'
]);
addParagraph(form, '12.3 Responsive Design');
addCheckboxes(form, [
'Desktop (>1024px): Layout korrekt',
'Tablet (768-1024px): Layout korrekt',
'Mobile (320-767px): Layout korrekt',
'Touch-Targets ausreichend groß (≥44x44px)'
]);
// === 13. SICHERHEIT ===
addSection(form, '13. Sicherheit', 'Teste Sicherheitsaspekte');
addParagraph(form, '13.1 Token-Sicherheit');
addCheckboxes(form, [
'Token ausreichend lang (UUID)',
'Token nicht vorhersagbar',
'Token läuft nach 30 Tagen ab',
'Abgelaufene Token werden abgelehnt'
]);
addParagraph(form, '13.2 Datenschutz');
addCheckboxes(form, [
'Keine sensiblen Daten in URLs (außer Token)',
'Keine Kundendaten in Browser-Console',
'E-Mail-Adressen geschützt',
'Telefonnummern geschützt'
]);
// === 14. EDGE CASES ===
addSection(form, '14. Edge Cases', 'Teste Sonderfälle und Extremwerte');
addParagraph(form, '14.1 Extremwerte');
addCheckboxes(form, [
'Sehr lange Behandlungsnamen korrekt dargestellt',
'Sehr lange Notizen korrekt dargestellt',
'Sehr hohe Preise korrekt formatiert',
'Termin > 30 Tage: Token-Ablauf korrekt'
]);
addParagraph(form, '14.2 Sonderfälle');
addCheckboxes(form, [
'Termin heute 23:59: Frist korrekt',
'Sommerzeit/Winterzeit-Wechsel: Korrekt',
'Mehrere Buchungen desselben Kunden: Eigene Tokens',
'Gleichzeitiger Zugriff: Kein Konflikt'
]);
// === TESTERGEBNISSE ===
addSection(form, 'Testergebnisse & Bewertung', 'Dokumentiere deine Testergebnisse');
form.addTextItem()
.setTitle('Getestet von (Name)')
.setRequired(true);
form.addDateItem()
.setTitle('Test-Datum')
.setRequired(true);
form.addTextItem()
.setTitle('Browser/Gerät (z.B. "Chrome 120 auf Windows 11")');
form.addParagraphTextItem()
.setTitle('Kritische Fehler (Blocker)')
.setHelpText('Beschreibe kritische Fehler, die ein Release verhindern würden');
form.addParagraphTextItem()
.setTitle('Mittelschwere Fehler')
.setHelpText('Beschreibe Fehler, die behoben werden sollten');
form.addParagraphTextItem()
.setTitle('Kleinere Probleme')
.setHelpText('Beschreibe kleinere Verbesserungsvorschläge');
form.addMultipleChoiceItem()
.setTitle('Gesamtbewertung')
.setChoiceValues([
'✅ Alle Tests bestanden - Release-fähig',
'⚠️ Tests bestanden mit kleineren Problemen',
'❌ Kritische Fehler gefunden - Nachbesserung erforderlich'
])
.setRequired(true);
form.addParagraphTextItem()
.setTitle('Zusätzliche Notizen')
.setHelpText('Weitere Beobachtungen und Anmerkungen');
// === FORMULAR ABSCHLUSS ===
Logger.log('✅ Formular erfolgreich erstellt!');
Logger.log('📋 Titel: ' + form.getTitle());
Logger.log('🔗 URL: ' + form.getPublishedUrl());
Logger.log('📝 Editor-URL: ' + form.getEditUrl());
Logger.log('');
Logger.log('👉 Öffne das Formular in deinem Browser:');
Logger.log(form.getPublishedUrl());
return form;
}
// === HILFSFUNKTIONEN ===
function addSection(form, title, description) {
form.addPageBreakItem()
.setTitle(title)
.setHelpText(description);
}
function addParagraph(form, text) {
form.addSectionHeaderItem()
.setTitle(text);
}
function addCheckboxes(form, items) {
items.forEach(item => {
form.addCheckboxItem()
.setTitle(item)
.setChoiceValues(['✓'])
.showOtherOption(false);
});
}

View File

@@ -0,0 +1,259 @@
# Produktions-Deployment für Stargirlnails Kiel
Diese Anleitung beschreibt das Deployment der Stargirlnails Kiel Buchungsanwendung in einer produktiven Umgebung mit SSL-Zertifikaten.
## 🏗️ Architektur
```
Internet → Nginx (Port 80/443) → Stargirlnails App (Port 3000)
Certbot (SSL-Zertifikate)
```
### Services:
- **stargirlnails**: Hauptanwendung
- **nginx**: Reverse Proxy mit SSL-Terminierung
- **certbot**: Automatische SSL-Zertifikat-Verwaltung
## 📋 Voraussetzungen
### Server-Anforderungen:
- **OS**: Linux (Ubuntu 20.04+ empfohlen)
- **RAM**: Mindestens 2GB
- **CPU**: Mindestens 1 Core
- **Speicher**: Mindestens 10GB freier Speicher
- **Ports**: 80, 443 müssen erreichbar sein
### Software:
- Docker & Docker Compose installiert
- Git installiert
### Domain-Konfiguration:
- Domain muss auf Server-IP zeigen
- DNS-A-Eintrag korrekt konfiguriert
## 🚀 Deployment
### 1. Repository klonen
```bash
git clone <repository-url>
cd mybeautybooking
```
### 2. Umgebungsvariablen konfigurieren
```bash
cp .env.example .env
nano .env
```
**Wichtige Variablen für Produktion:**
```env
# Domain-Konfiguration (ERFORDERLICH für SSL)
DOMAIN=stargirlnails.de
# Admin-Konfiguration (ERFORDERLICH für SSL)
ADMIN_EMAIL=admin@stargirlnails.de
# E-Mail-Konfiguration
RESEND_API_KEY=your_resend_api_key_here
EMAIL_FROM=noreply@stargirlnails.de
# Produktionsmodus
NODE_ENV=production
```
### 3. SSL-Setup und Deployment
```bash
# Linux/macOS
chmod +x scripts/setup-ssl.sh
./scripts/setup-ssl.sh
# Windows (PowerShell)
.\scripts\setup-ssl.ps1
```
Das Script:
- ✅ Erstellt Docker Volumes
- ✅ Konfiguriert Nginx mit der Domain
- ✅ Erstellt SSL-Zertifikat via Let's Encrypt
- ✅ Startet alle Services
### 4. Verifikation
```bash
# Service-Status prüfen
docker-compose -f docker-compose-prod.yml ps
# Logs anzeigen
docker-compose -f docker-compose-prod.yml logs -f
# SSL-Zertifikat prüfen
curl -I https://your-domain.com
```
## 🔧 Verwaltung
### Service-Befehle
```bash
# Services starten
docker-compose -f docker-compose-prod.yml up -d
# Services stoppen
docker-compose -f docker-compose-prod.yml down
# Logs anzeigen
docker-compose -f docker-compose-prod.yml logs -f
# Einzelnen Service neu starten
docker-compose -f docker-compose-prod.yml restart stargirlnails
```
### SSL-Zertifikat-Verwaltung
```bash
# Zertifikat manuell erneuern
docker-compose -f docker-compose-prod.yml run --rm certbot certbot renew
# Zertifikat-Status prüfen
docker-compose -f docker-compose-prod.yml run --rm certbot certbot certificates
```
**Automatische Erneuerung**: Certbot erneuert Zertifikate automatisch alle 12 Stunden.
### Updates
```bash
# Code aktualisieren
git pull origin main
# Neues Image bauen und starten
docker-compose -f docker-compose-prod.yml up -d --build
# Alte Images aufräumen
docker image prune -f
```
## 🔒 Sicherheit
### Nginx-Sicherheitsfeatures:
-**SSL/TLS**: TLS 1.2+ mit modernen Ciphern
-**HSTS**: Strict Transport Security Header
-**Rate Limiting**: API-Endpunkte geschützt
-**Security Headers**: XSS, CSRF, Clickjacking-Schutz
-**Gzip-Kompression**: Optimierte Performance
### Rate Limits:
- **API-Endpunkte**: 10 Anfragen/Sekunde
- **Login-Endpunkte**: 5 Anfragen/Minute
### Firewall-Empfehlung:
```bash
# UFW (Ubuntu)
sudo ufw allow 22/tcp # SSH
sudo ufw allow 80/tcp # HTTP
sudo ufw allow 443/tcp # HTTPS
sudo ufw enable
```
## 📊 Monitoring
### Health Checks:
```bash
# Anwendungs-Health-Check
curl https://your-domain.com/health
# SSL-Zertifikat-Status
openssl s_client -connect your-domain.com:443 -servername your-domain.com
```
### Logs überwachen:
```bash
# Alle Logs
docker-compose -f docker-compose-prod.yml logs -f
# Nur Anwendungs-Logs
docker-compose -f docker-compose-prod.yml logs -f stargirlnails
# Nur Nginx-Logs
docker-compose -f docker-compose-prod.yml logs -f nginx
```
### Ressourcen-Monitoring:
```bash
# Container-Ressourcen
docker stats
# Disk-Usage
docker system df
```
## 🚨 Troubleshooting
### Häufige Probleme:
#### SSL-Zertifikat kann nicht erstellt werden
```bash
# Prüfe Domain-Erreichbarkeit
curl -I http://your-domain.com
# Prüfe DNS-Einträge
nslookup your-domain.com
# Prüfe Port 80
telnet your-domain.com 80
```
#### Services starten nicht
```bash
# Detaillierte Logs
docker-compose -f docker-compose-prod.yml logs
# Container-Status
docker-compose -f docker-compose-prod.yml ps
# Volumes prüfen
docker volume ls
```
#### Performance-Probleme
```bash
# Ressourcen-Check
docker stats
# Nginx-Logs prüfen
docker-compose -f docker-compose-prod.yml logs nginx | grep -i error
```
### Log-Dateien:
- **Anwendung**: `docker-compose logs stargirlnails`
- **Nginx**: `docker-compose logs nginx`
- **Certbot**: `docker-compose logs certbot`
## 🔄 Backup & Wiederherstellung
### Daten-Backup:
```bash
# Storage-Daten sichern
tar -czf backup-$(date +%Y%m%d).tar.gz .storage/
# SSL-Zertifikate sichern
docker run --rm -v certbot-certs:/data -v $(pwd):/backup alpine tar czf /backup/ssl-backup-$(date +%Y%m%d).tar.gz -C /data .
```
### Wiederherstellung:
```bash
# Storage-Daten wiederherstellen
tar -xzf backup-YYYYMMDD.tar.gz
# SSL-Zertifikate wiederherstellen
docker run --rm -v certbot-certs:/data -v $(pwd):/backup alpine tar xzf /backup/ssl-backup-YYYYMMDD.tar.gz -C /data
```
## 📞 Support
Bei Problemen:
1. Prüfe die Logs: `docker-compose -f docker-compose-prod.yml logs`
2. Prüfe den Service-Status: `docker-compose -f docker-compose-prod.yml ps`
3. Prüfe die Dokumentation in `docs/`
4. Erstelle ein Issue im Repository
---
**Wichtiger Hinweis**: Diese Konfiguration ist für Produktionsumgebungen optimiert. Für Entwicklung verwende `docker-compose.yml`.

View File

@@ -0,0 +1,362 @@
# Blackbox Test-Checkliste: Kunden-Statusseite
**Branch:** `Statusseite`
**Feature:** Token-basierte Buchungsübersicht mit integrierter Stornierung
**Datum:** 2025-10-01
## Vorbereitung
- [ ] Entwicklungsserver läuft (`pnpm dev`)
- [ ] E-Mail-Service konfiguriert (RESEND_API_KEY gesetzt)
- [ ] Admin-Account verfügbar
- [ ] Testbehandlungen vorhanden
- [ ] Verfügbare Slots erstellt
---
## 1. Buchungserstellung & Token-Generierung
### 1.1 Neue Buchung (Status: pending)
- [ ] Buchung über Formular erstellen
- [ ] Pending-E-Mail erhalten
- [ ] Status-Link (`/booking/{token}`) in E-Mail vorhanden
- [ ] Button "Status ansehen" vorhanden und korrekt verlinkt
- [ ] Link funktioniert beim Klick
### 1.2 Token-Validierung
- [ ] Gültiger Token öffnet Statusseite
- [ ] Ungültiger Token zeigt Fehlermeldung
- [ ] Abgelaufener Token zeigt entsprechende Meldung
- [ ] Token ohne Parameter zeigt Fehler
---
## 2. Statusseite UI/UX (Allgemein)
### 2.1 Layout & Design
- [ ] Logo wird angezeigt
- [ ] Seite ist responsive (Desktop, Tablet, Mobile)
- [ ] Alle Texte sind auf Deutsch
- [ ] Farben entsprechen dem Branding (Pink/Purple)
- [ ] "Zurück zur Startseite" Link funktioniert
### 2.2 Navigation
- [ ] Link zur Startseite funktioniert
- [ ] Browser-Zurück-Button funktioniert korrekt
- [ ] URL ist teilbar (Copy & Paste)
---
## 3. Status: Pending (⏳ Wartet auf Bestätigung)
### 3.1 Anzeige
- [ ] Status-Badge zeigt "⏳ Wartet auf Bestätigung" (gelb)
- [ ] Banner mit gelber Hintergrundfarbe
- [ ] Text erklärt, dass Termin geprüft wird
- [ ] Alle Termin-Details werden angezeigt:
- [ ] Datum (Format: dd.mm.yyyy)
- [ ] Uhrzeit
- [ ] Behandlung
- [ ] Dauer in Minuten
- [ ] Preis (wenn > 0)
- [ ] Kundendaten werden angezeigt:
- [ ] Name
- [ ] E-Mail
- [ ] Telefon
- [ ] Notizen (falls vorhanden)
### 3.2 Stornierung
- [ ] Stornierungsbereich ist NICHT sichtbar (pending kann nicht storniert werden)
- [ ] Keine Stornierungsbuttons vorhanden
---
## 4. Status: Confirmed (✓ Bestätigt)
### 4.1 Statuswechsel
- [ ] Admin bestätigt Buchung
- [ ] Confirmed-E-Mail wird versendet
- [ ] E-Mail enthält "Termin verwalten" Button
- [ ] Link in E-Mail zeigt auf `/booking/{token}`
- [ ] ICS-Datei ist im E-Mail-Anhang
- [ ] AGB-PDF ist im E-Mail-Anhang
### 4.2 Anzeige
- [ ] Status-Badge zeigt "✓ Bestätigt" (grün)
- [ ] Banner mit grüner Hintergrundfarbe
- [ ] Text bestätigt den Termin
- [ ] "Verbleibende Zeit" wird angezeigt (wenn Zukunft)
- [ ] Stunden bis zum Termin werden berechnet und angezeigt
### 4.3 Stornierung (wenn möglich)
- [ ] Stornierungsbereich ist sichtbar
- [ ] Hinweistext zur Stornierungsfrist wird angezeigt
- [ ] Button "Termin stornieren" ist vorhanden
- [ ] Klick auf Button zeigt Bestätigungsdialog
- [ ] Bestätigungsdialog enthält:
- [ ] Warnhinweis in rot
- [ ] "Ja, stornieren" Button
- [ ] "Abbrechen" Button
- [ ] "Abbrechen" schließt Dialog ohne Aktion
- [ ] "Ja, stornieren" führt Stornierung durch
- [ ] Nach Stornierung: Erfolgsmeldung wird angezeigt
- [ ] Nach Stornierung: Status aktualisiert sich
### 4.4 Stornierung (nicht mehr möglich)
- [ ] Termin < 24h (oder MIN_STORNO_TIMESPAN): Kein Stornierungsbutton
- [ ] Gelber Hinweiskasten wird angezeigt
- [ ] Text erklärt, dass Frist abgelaufen ist
- [ ] Kontakthinweis wird angezeigt
---
## 5. Status: Cancelled (✕ Storniert)
### 5.1 Nach Stornierung
- [ ] Status-Badge zeigt "✕ Storniert" (rot)
- [ ] Banner mit roter Hintergrundfarbe
- [ ] Text erklärt, dass Termin storniert wurde
- [ ] Hinweis auf Neubuchung wird angezeigt
- [ ] Stornierungsbereich ist nicht mehr sichtbar
### 5.2 Cancelled-E-Mail
- [ ] E-Mail wird an Kunden gesendet
- [ ] E-Mail enthält storniertes Datum
- [ ] E-Mail enthält Hinweis auf Neubuchung
---
## 6. Status: Completed (✓ Abgeschlossen)
### 6.1 Anzeige
- [ ] Status-Badge zeigt "✓ Abgeschlossen" (grau)
- [ ] Banner mit grauer Hintergrundfarbe
- [ ] Dankestext wird angezeigt
- [ ] Stornierungsbereich ist nicht sichtbar
- [ ] Alle Termin-Details bleiben sichtbar
---
## 7. E-Mail-Integration
### 7.1 Pending-Mail
- [ ] Betreff: "Deine Terminanfrage ist eingegangen"
- [ ] Enthält orangefarbenen "Status ansehen" Button
- [ ] Link funktioniert
- [ ] Text erklärt, dass Bestätigung folgt
- [ ] Rechtliche Informationen enthalten
### 7.2 Confirmed-Mail
- [ ] Betreff: "Dein Termin wurde bestätigt - AGB im Anhang"
- [ ] Enthält pinken "Termin ansehen & verwalten" Button (statt rot "Termin stornieren")
- [ ] Link zeigt auf `/booking/{token}`
- [ ] ICS-Datei im Anhang
- [ ] ICS-Datei hat korrekten Namen: "Termin_Stargirlnails.ics"
- [ ] ICS-Datei kann in Kalender importiert werden
- [ ] AGB-PDF im Anhang
- [ ] AGB-PDF heißt "AGB_Stargirlnails_Kiel.pdf"
### 7.3 Cancelled-Mail
- [ ] Betreff: "Dein Termin wurde abgesagt"
- [ ] Text erklärt Stornierung
- [ ] Hinweis auf Neubuchung
- [ ] Rechtliche Informationen enthalten
---
## 8. ICS-Kalendereinträge
### 8.1 ICS-Datei Inhalt
- [ ] Zeitzone: Europe/Berlin
- [ ] Startzeit korrekt
- [ ] Endzeit korrekt (Startzeit + Behandlungsdauer)
- [ ] Titel: "{Behandlung} - Stargirlnails Kiel"
- [ ] Location: "Stargirlnails Kiel"
- [ ] Beschreibung enthalten
- [ ] 24h-Erinnerung konfiguriert
### 8.2 Kalender-Import
- [ ] Outlook: Import funktioniert
- [ ] Google Calendar: Import funktioniert (wenn möglich zu testen)
- [ ] Apple Calendar: Import funktioniert (wenn möglich zu testen)
- [ ] Termin erscheint im Kalender mit korrekter Zeit
- [ ] Erinnerung wird 24h vorher ausgelöst
---
## 9. Stornierungslogik
### 9.1 Zeitbasierte Validierung
- [ ] Termin > 24h in Zukunft: Stornierung möglich
- [ ] Termin < 24h in Zukunft: Stornierung nicht möglich
- [ ] Termin in Vergangenheit: Stornierung nicht möglich
- [ ] Korrekte Berechnung der verbleibenden Stunden
### 9.2 Statusbasierte Validierung
- [ ] Status "pending": Keine Stornierung möglich
- [ ] Status "confirmed": Stornierung möglich (wenn Zeitfrist OK)
- [ ] Status "cancelled": Keine Stornierung möglich
- [ ] Status "completed": Keine Stornierung möglich
### 9.3 Stornierungsablauf
- [ ] Bestätigungsdialog erscheint
- [ ] Loading-Spinner während Stornierung
- [ ] Erfolgsmeldung nach Stornierung
- [ ] Fehlermeldung bei Fehler
- [ ] Token wird NICHT invalidiert nach Stornierung
- [ ] Slot wird wieder freigegeben
- [ ] Status aktualisiert sich auf Seite
---
## 10. Fehlerbehandlung
### 10.1 Ungültige Token
- [ ] Nicht existierender Token: Fehlermeldung
- [ ] Abgelaufener Token: Fehlermeldung
- [ ] Leerer Token: Fehlermeldung
- [ ] Fehlermeldung ist benutzerfreundlich (Deutsch)
### 10.2 Netzwerkfehler
- [ ] API nicht erreichbar: Fehlermeldung
- [ ] Timeout: Fehlermeldung
- [ ] Fehler während Stornierung: Fehlermeldung bleibt sichtbar
### 10.3 Validierungsfehler
- [ ] Stornierung außerhalb Frist: Klare Fehlermeldung
- [ ] Bereits stornierter Termin: Fehlermeldung
---
## 11. Performance & Ladezeiten
- [ ] Statusseite lädt in < 2 Sekunden
- [ ] Keine sichtbaren Layout-Shifts
- [ ] Loading-Spinner wird während Laden angezeigt
- [ ] Bilder werden optimiert geladen
- [ ] Keine JavaScript-Fehler in Browser-Console
---
## 12. Accessibility & Browser-Kompatibilität
### 12.1 Accessibility
- [ ] Buttons sind mit Tastatur erreichbar
- [ ] Fokus-Indikatoren sind sichtbar
- [ ] Farbkontraste sind ausreichend (WCAG AA)
- [ ] Alt-Texte für Bilder vorhanden
### 12.2 Browser
- [ ] Chrome (aktuell): Funktioniert
- [ ] Firefox (aktuell): Funktioniert
- [ ] Safari (wenn möglich): Funktioniert
- [ ] Edge (aktuell): Funktioniert
- [ ] Mobile Browser (iOS/Android): Funktioniert
### 12.3 Responsive Design
- [ ] Desktop (>1024px): Layout korrekt
- [ ] Tablet (768px-1024px): Layout korrekt
- [ ] Mobile (320px-767px): Layout korrekt
- [ ] Touch-Targets ausreichend groß (min. 44x44px)
---
## 13. Sicherheit
### 13.1 Token-Sicherheit
- [ ] Token ist ausreichend lang (UUID)
- [ ] Token ist nicht vorhersagbar
- [ ] Token läuft nach 30 Tagen ab
- [ ] Abgelaufene Token werden abgelehnt
### 13.2 Datenschutz
- [ ] Keine sensiblen Daten in URLs außer Token
- [ ] Keine Kundendaten in Browser-Console geloggt
- [ ] E-Mail-Adressen werden nicht im Klartext im HTML angezeigt
- [ ] Telefonnummern geschützt
---
## 14. Rückwärts-Kompatibilität
### 14.1 Legacy-Routen
- [ ] `/cancel/{token}` funktioniert NICHT (bewusst entfernt)
- [ ] Alte Links in E-Mails zeigen auf neue Route
- [ ] Bestehende Tokens funktionieren weiterhin
### 14.2 Datenbank-Kompatibilität
- [ ] Alte Buchungen werden korrekt angezeigt
- [ ] Alte Tokens funktionieren mit neuem Code
- [ ] Migration ist nicht erforderlich
---
## 15. Edge Cases
### 15.1 Extremwerte
- [ ] Sehr lange Behandlungsnamen werden korrekt dargestellt
- [ ] Sehr lange Notizen werden korrekt dargestellt
- [ ] Sehr hohe Preise werden korrekt formatiert
- [ ] Termin weit in Zukunft (> 30 Tage): Token abgelaufen
### 15.2 Sonderfälle
- [ ] Termin heute um 23:59: Stornierungsfrist korrekt berechnet
- [ ] Termin während Sommerzeit/Winterzeit-Wechsel: Korrekt
- [ ] Mehrere Buchungen desselben Kunden: Jede hat eigenen Token
- [ ] Gleichzeitiger Zugriff auf Statusseite: Kein Konflikt
---
## 16. Integration mit bestehendem System
### 16.1 Admin-Panel
- [ ] Buchungen werden im Admin-Panel angezeigt
- [ ] Statusänderungen im Admin-Panel reflektieren sich auf Statusseite
- [ ] Admin erhält weiterhin E-Mail-Benachrichtigungen
### 16.2 Slot-Management
- [ ] Stornierung gibt Slot frei
- [ ] Freigegebener Slot ist sofort buchbar
- [ ] Keine Slot-Konflikte
### 16.3 E-Mail-System
- [ ] E-Mails werden zuverlässig versendet
- [ ] Anhänge werden korrekt zugestellt
- [ ] CC an Admin funktioniert (bei confirmed)
---
## Testergebnisse
**Getestet von:** _________________
**Datum:** _________________
**Browser/Gerät:** _________________
### Kritische Fehler (Blocker)
```
(Hier kritische Fehler eintragen, die Release verhindern)
```
### Mittelschwere Fehler
```
(Hier mittelschwere Fehler eintragen, die behoben werden sollten)
```
### Kleinere Probleme
```
(Hier kleinere Verbesserungsvorschläge eintragen)
```
### Gesamtbewertung
- [ ] ✅ Alle Tests bestanden - Release-fähig
- [ ] ⚠️ Tests bestanden mit kleineren Problemen
- [ ] ❌ Kritische Fehler gefunden - Nachbesserung erforderlich
---
## Notizen
```
(Hier zusätzliche Beobachtungen und Anmerkungen eintragen)
```

19
index.html Normal file
View File

@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<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="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>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/client/main.tsx"></script>
</body>
</html>

View File

@@ -1,17 +1,18 @@
{
"name": "quests-template-basic",
"private": true,
"version": "0.0.0",
"version": "0.1.3",
"type": "module",
"scripts": {
"check:types": "tsc --noEmit",
"dev": "vite",
"build": "tsc -b && vite build",
"build": "tsc -b && vite build && tsc -p tsconfig.server.build.json",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"preview": "vite preview"
},
"dependencies": {
"@hono/node-server": "^1.19.5",
"@orpc/client": "^1.8.8",
"@orpc/server": "^1.8.8",
"@orpc/tanstack-query": "^1.8.8",
@@ -24,6 +25,7 @@
"react": "^19.1.1",
"react-dom": "^19.1.1",
"tailwindcss": "^4",
"ts-node": "^10.9.2",
"unstorage": "^1.16.1",
"zod": "^4.0.17"
},

122
pnpm-lock.yaml generated
View File

@@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@hono/node-server':
specifier: ^1.19.5
version: 1.19.5(hono@4.9.4)
'@orpc/client':
specifier: ^1.8.8
version: 1.8.8(@opentelemetry/api@1.9.0)
@@ -44,6 +47,9 @@ importers:
tailwindcss:
specifier: ^4
version: 4.1.12
ts-node:
specifier: ^10.9.2
version: 10.9.2(@types/node@22.17.2)(typescript@5.9.2)
unstorage:
specifier: ^1.16.1
version: 1.17.0
@@ -217,6 +223,10 @@ packages:
resolution: {integrity: sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==}
engines: {node: '>=6.9.0'}
'@cspotcode/source-map-support@0.8.1':
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'}
'@esbuild/aix-ppc64@0.25.5':
resolution: {integrity: sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==}
engines: {node: '>=18'}
@@ -410,8 +420,8 @@ packages:
resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@hono/node-server@1.14.4':
resolution: {integrity: sha512-DnxpshhYewr2q9ZN8ez/M5mmc3sucr8CT1sIgIy1bkeUXut9XWDkqHoFHRhWIQgkYnKpVRxunyhK7WzpJeJ6qQ==}
'@hono/node-server@1.19.5':
resolution: {integrity: sha512-iBuhh+uaaggeAuf+TftcjZyWh2GEgZcVGXkNtskLVoWaXhnJtC5HLHrU8W1KHDoucqO1MswwglmkWLFyiDn4WQ==}
engines: {node: '>=18.14.1'}
peerDependencies:
hono: ^4
@@ -469,6 +479,9 @@ packages:
'@jridgewell/trace-mapping@0.3.30':
resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==}
'@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -738,6 +751,18 @@ packages:
peerDependencies:
react: ^18 || ^19
'@tsconfig/node10@1.0.11':
resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==}
'@tsconfig/node12@1.0.11':
resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==}
'@tsconfig/node14@1.0.3':
resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==}
'@tsconfig/node16@1.0.4':
resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==}
'@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@@ -837,6 +862,10 @@ packages:
peerDependencies:
acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
acorn-walk@8.3.4:
resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==}
engines: {node: '>=0.4.0'}
acorn@8.15.0:
resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
engines: {node: '>=0.4.0'}
@@ -853,6 +882,9 @@ packages:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'}
arg@4.1.3:
resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==}
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
@@ -913,6 +945,9 @@ packages:
resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==}
engines: {node: '>=18'}
create-require@1.1.1:
resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -945,6 +980,10 @@ packages:
resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==}
engines: {node: '>=8'}
diff@4.0.2:
resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==}
engines: {node: '>=0.3.1'}
dotenv@17.2.3:
resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==}
engines: {node: '>=12'}
@@ -1273,6 +1312,9 @@ packages:
magic-string@0.30.17:
resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
make-error@1.3.6:
resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
merge2@1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
@@ -1490,6 +1532,20 @@ packages:
peerDependencies:
typescript: '>=4.8.4'
ts-node@10.9.2:
resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==}
hasBin: true
peerDependencies:
'@swc/core': '>=1.2.50'
'@swc/wasm': '>=1.2.50'
'@types/node': '*'
typescript: '>=2.7'
peerDependenciesMeta:
'@swc/core':
optional: true
'@swc/wasm':
optional: true
tsconfck@3.1.6:
resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==}
engines: {node: ^18 || >=20}
@@ -1600,6 +1656,9 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
v8-compile-cache-lib@3.0.1:
resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==}
vite-tsconfig-paths@5.1.4:
resolution: {integrity: sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==}
peerDependencies:
@@ -1664,6 +1723,10 @@ packages:
resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
engines: {node: '>=18'}
yn@3.1.1:
resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==}
engines: {node: '>=6'}
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
@@ -1851,6 +1914,10 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.27.1
'@cspotcode/source-map-support@0.8.1':
dependencies:
'@jridgewell/trace-mapping': 0.3.9
'@esbuild/aix-ppc64@0.25.5':
optional: true
@@ -1972,13 +2039,13 @@ snapshots:
'@eslint/core': 0.15.2
levn: 0.4.1
'@hono/node-server@1.14.4(hono@4.9.4)':
'@hono/node-server@1.19.5(hono@4.9.4)':
dependencies:
hono: 4.9.4
'@hono/vite-dev-server@0.20.1(hono@4.9.4)':
dependencies:
'@hono/node-server': 1.14.4(hono@4.9.4)
'@hono/node-server': 1.19.5(hono@4.9.4)
hono: 4.9.4
minimatch: 9.0.5
@@ -2018,6 +2085,11 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.0
'@jridgewell/trace-mapping@0.3.9':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.0
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -2264,6 +2336,14 @@ snapshots:
'@tanstack/query-core': 5.85.5
react: 19.1.1
'@tsconfig/node10@1.0.11': {}
'@tsconfig/node12@1.0.11': {}
'@tsconfig/node14@1.0.3': {}
'@tsconfig/node16@1.0.4': {}
'@types/babel__core@7.20.5':
dependencies:
'@babel/parser': 7.28.3
@@ -2410,6 +2490,10 @@ snapshots:
dependencies:
acorn: 8.15.0
acorn-walk@8.3.4:
dependencies:
acorn: 8.15.0
acorn@8.15.0: {}
ajv@6.12.6:
@@ -2428,6 +2512,8 @@ snapshots:
normalize-path: 3.0.0
picomatch: 2.3.1
arg@4.1.3: {}
argparse@2.0.1: {}
balanced-match@1.0.2: {}
@@ -2481,6 +2567,8 @@ snapshots:
cookie@1.0.2: {}
create-require@1.1.1: {}
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -2505,6 +2593,8 @@ snapshots:
detect-libc@2.0.4: {}
diff@4.0.2: {}
dotenv@17.2.3: {}
electron-to-chromium@1.5.171: {}
@@ -2831,6 +2921,8 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.0
make-error@1.3.6: {}
merge2@1.4.1: {}
micromatch@4.0.8:
@@ -3018,6 +3110,24 @@ snapshots:
dependencies:
typescript: 5.9.2
ts-node@10.9.2(@types/node@22.17.2)(typescript@5.9.2):
dependencies:
'@cspotcode/source-map-support': 0.8.1
'@tsconfig/node10': 1.0.11
'@tsconfig/node12': 1.0.11
'@tsconfig/node14': 1.0.3
'@tsconfig/node16': 1.0.4
'@types/node': 22.17.2
acorn: 8.15.0
acorn-walk: 8.3.4
arg: 4.1.3
create-require: 1.1.1
diff: 4.0.2
make-error: 1.3.6
typescript: 5.9.2
v8-compile-cache-lib: 3.0.1
yn: 3.1.1
tsconfck@3.1.6(typescript@5.9.2):
optionalDependencies:
typescript: 5.9.2
@@ -3068,6 +3178,8 @@ snapshots:
dependencies:
punycode: 2.3.1
v8-compile-cache-lib@3.0.1: {}
vite-tsconfig-paths@5.1.4(typescript@5.9.2)(vite@7.1.3(@types/node@22.17.2)(jiti@2.5.1)(lightningcss@1.30.1)):
dependencies:
debug: 4.4.1
@@ -3103,6 +3215,8 @@ snapshots:
yallist@5.0.0: {}
yn@3.1.1: {}
yocto-queue@0.1.0: {}
zod-validation-error@3.5.3(zod@3.25.76):

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

49
scripts/rebuild-prod.sh Normal file
View File

@@ -0,0 +1,49 @@
#! /bin/bash
set -euo pipefail
# Usage: ./scripts/rebuild-prod.sh [branch]
# Default branch is current; pass a branch to checkout before pulling/building.
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

103
scripts/setup-caddy.sh Normal file
View File

@@ -0,0 +1,103 @@
#!/bin/bash
# Farben für Output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
echo -e "${BLUE}🔧 Stargirlnails Kiel - Caddy Setup${NC}"
echo "====================================="
# Prüfe ob .env-Datei existiert
if [ ! -f .env ]; then
echo -e "${RED}❌ .env-Datei nicht gefunden!${NC}"
echo "Bitte erstelle eine .env-Datei mit DOMAIN und ADMIN_EMAIL"
exit 1
fi
# Extrahiere DOMAIN aus .env
DOMAIN=$(grep -E '^DOMAIN=' .env | cut -d '=' -f2- | tr -d '"')
if [ -z "$DOMAIN" ]; then
echo -e "${RED}❌ DOMAIN nicht in .env gefunden!${NC}"
exit 1
fi
echo -e "${GREEN}✅ Domain: $DOMAIN${NC}"
# Erkenne Docker Compose-Version
if command -v docker-compose >/dev/null 2>&1; then
DOCKER_COMPOSE="docker-compose"
elif docker compose version >/dev/null 2>&1; then
DOCKER_COMPOSE="docker compose"
else
echo -e "${RED}❌ Docker Compose nicht gefunden!${NC}"
exit 1
fi
# Prüfe Docker-Berechtigungen
SUDO=""
if ! docker info >/dev/null 2>&1; then
if command -v sudo >/dev/null 2>&1; then
SUDO="sudo "
echo -e "${YELLOW}⚠️ Docker benötigt Root-Rechte. Verwende 'sudo'.${NC}"
else
echo -e "${RED}❌ Docker läuft nicht und 'sudo' ist nicht verfügbar.${NC}"
exit 1
fi
fi
echo -e "${GREEN}✅ Verwende: ${SUDO}${DOCKER_COMPOSE}${NC}"
# Erstelle Docker Volumes
echo -e "${YELLOW}📦 Erstelle Docker Volumes...${NC}"
${SUDO}docker volume create caddy-data 2>/dev/null || true
${SUDO}docker volume create caddy-config 2>/dev/null || true
${SUDO}docker volume create storage-data 2>/dev/null || true
# Aktualisiere Caddyfile mit der korrekten Domain
echo -e "${YELLOW}📝 Aktualisiere Caddyfile...${NC}"
sed "s/stargirlnails.de/$DOMAIN/g" Caddyfile > Caddyfile.tmp
mv Caddyfile.tmp Caddyfile
# Stoppe alte Services
echo -e "${YELLOW}🛑 Stoppe alte Services...${NC}"
${SUDO}${DOCKER_COMPOSE} -f docker-compose-prod.yml down
# Starte alle Services
echo -e "${YELLOW}🚀 Starte alle Services...${NC}"
${SUDO}${DOCKER_COMPOSE} -f docker-compose-prod.yml up -d
# Warte kurz
echo -e "${YELLOW}⏳ Warte auf Services...${NC}"
sleep 15
# Prüfe Status
echo -e "${YELLOW}🔍 Prüfe Service-Status...${NC}"
if ${SUDO}${DOCKER_COMPOSE} -f docker-compose-prod.yml ps | grep -q "Up"; then
echo -e "${GREEN}✅ Alle Services laufen!${NC}"
echo ""
echo -e "${BLUE}🌐 Deine Anwendung ist jetzt verfügbar unter:${NC}"
echo -e "${GREEN} https://$DOMAIN${NC}"
echo -e "${GREEN} http://$DOMAIN (leitet zu HTTPS weiter)${NC}"
echo ""
echo -e "${BLUE}📋 Nützliche Befehle:${NC}"
echo " Status anzeigen: ${SUDO}${DOCKER_COMPOSE} -f docker-compose-prod.yml ps"
echo " Logs anzeigen: ${SUDO}${DOCKER_COMPOSE} -f docker-compose-prod.yml logs -f"
echo " Services stoppen: ${SUDO}${DOCKER_COMPOSE} -f docker-compose-prod.yml down"
echo " Caddy Logs: ${SUDO}${DOCKER_COMPOSE} -f docker-compose-prod.yml logs caddy"
echo ""
echo -e "${YELLOW}⚠️ Hinweis:${NC}"
echo " - SSL-Zertifikate werden automatisch von Caddy erstellt und erneuert"
echo " - Keine manuelle SSL-Konfiguration erforderlich"
echo " - Überwache die Caddy-Logs für SSL-Status"
else
echo -e "${RED}❌ Einige Services sind nicht gestartet!${NC}"
echo "Prüfe die Logs: ${SUDO}${DOCKER_COMPOSE} -f docker-compose-prod.yml logs"
exit 1
fi
echo -e "${GREEN}✅ Caddy-Setup abgeschlossen!${NC}"

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

@@ -0,0 +1,71 @@
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.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,258 @@
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}`;
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="Stargirlnails" 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>
</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>
<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);
}

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;
}

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

@@ -0,0 +1,186 @@
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 response = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
"Authorization": `Bearer ${RESEND_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
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) {
const body = await response.text().catch(() => "");
console.error("Resend send error:", response.status, body);
return { success: false };
}
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: "en", children: [_jsxs("head", { children: [_jsx("meta", { charSet: "utf-8" }), _jsx("meta", { content: "width=device-width, initial-scale=1", name: "viewport" }), _jsx("title", { children: "Stargirlnails Kiel" }), _jsx("link", { rel: "icon", type: "image/png", href: "/favicon.png" }), 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,
};

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

@@ -0,0 +1,748 @@
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 } 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."
}
};
}),
};

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,
};

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

@@ -0,0 +1,20 @@
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";
export const router = {
demo,
treatments,
bookings,
auth,
recurringAvailability,
cancellation,
legal,
gallery,
reviews,
};

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,
};

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, useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { queryClient } from "@/client/rpc-client";
import { useAuth } from "@/client/components/auth-provider";
import { LoginForm } from "@/client/components/login-form";
import { UserProfile } from "@/client/components/user-profile";
@@ -8,32 +10,53 @@ import { AdminBookings } from "@/client/components/admin-bookings";
import { AdminCalendar } from "@/client/components/admin-calendar";
import { InitialDataLoader } from "@/client/components/initial-data-loader";
import { AdminAvailability } from "@/client/components/admin-availability";
import CancellationPage from "@/client/components/cancellation-page";
import { AdminGallery } from "@/client/components/admin-gallery";
import { AdminReviews } from "@/client/components/admin-reviews";
import BookingStatusPage from "@/client/components/booking-status-page";
import ReviewSubmissionPage from "@/client/components/review-submission-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() {
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);
// Check for cancellation token in URL
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(() => {
const path = window.location.pathname;
if (path.startsWith('/cancel/')) {
const token = path.split('/cancel/')[1];
if (token) {
// Set a special state to show cancellation page
setActiveTab("cancellation" as any);
return;
}
}
}, []);
document.body.classList.toggle('overflow-hidden', isMobileMenuOpen);
return () => document.body.classList.remove('overflow-hidden');
}, [isMobileMenuOpen]);
// Handle cancellation page
// Handle booking status page
const path = window.location.pathname;
if (path.startsWith('/cancel/')) {
const token = path.split('/cancel/')[1];
const PwaPrompt = <PWAInstallPrompt />;
if (path.startsWith('/booking/')) {
const token = path.split('/booking/')[1];
if (token) {
return <CancellationPage 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} />
</>;
}
}
@@ -54,7 +77,7 @@ function App() {
}
// 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) {
return <LoginForm />;
}
@@ -65,12 +88,15 @@ function App() {
}
const tabs = [
{ id: "profile-landing", label: "Startseite", icon: "🏠", requiresAuth: false },
{ id: "booking", label: "Termin buchen", icon: "📅", requiresAuth: false },
{ id: "legal", label: "Impressum/Datenschutz", icon: "📋", requiresAuth: false },
{ id: "admin-treatments", label: "Behandlungen verwalten", icon: "💅", requiresAuth: true },
{ id: "admin-bookings", label: "Buchungen verwalten", icon: "📋", requiresAuth: true },
{ id: "admin-calendar", label: "Kalender", 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 }] : []),
] as const;
@@ -84,7 +110,7 @@ function App() {
<div className="flex justify-between items-center py-6">
<div
className="flex items-center space-x-3 cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => setActiveTab("booking")}
onClick={() => setActiveTab("profile-landing")}
>
<img
src="/assets/stargilnails_logo_transparent_112.png"
@@ -97,11 +123,26 @@ function App() {
</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 && (
<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}
</span>
<span className="text-sm text-gray-600 sm:hidden">
{user.username}
</span>
{isOwner && (
<span className="bg-pink-100 text-pink-800 px-2 py-1 rounded-full text-xs font-medium">
Inhaber
@@ -113,8 +154,8 @@ function App() {
</div>
</header>
{/* Navigation */}
<nav className="bg-white shadow-sm">
{/* Desktop Navigation */}
<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="flex space-x-8">
{tabs.map((tab) => {
@@ -159,8 +200,82 @@ function App() {
</div>
</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 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" && (
<div>
<div className="text-center mb-8">
@@ -225,13 +340,41 @@ function App() {
Verfügbarkeiten verwalten
</h2>
<p className="text-lg text-gray-600">
Lege freie Slots an und entferne sie bei Bedarf.
Verwalte wiederkehrende Zeiten und Urlaubszeiten.
</p>
</div>
<AdminAvailability />
</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 && (
<div>
<div className="text-center mb-8">
@@ -247,11 +390,44 @@ function App() {
)}
</main>
{/* PWA Installation Prompt for iOS */}
<PWAInstallPrompt hidden={isMobileMenuOpen} />
{/* Footer */}
<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="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>
</footer>

View File

@@ -4,18 +4,33 @@ import { queryClient } from "@/client/rpc-client";
export function AdminAvailability() {
const today = new Date().toISOString().split("T")[0];
const [selectedDate, setSelectedDate] = useState<string>(today);
const [time, setTime] = useState<string>("09:00");
const [duration, setDuration] = useState<number>(30);
const [selectedTreatmentId, setSelectedTreatmentId] = useState<string>("");
const [slotType, setSlotType] = useState<"treatment" | "manual">("treatment");
const { data: allSlots } = useQuery(
queryClient.availability.live.list.experimental_liveOptions()
// Tab-Navigation (Slots entfernt)
const [activeSubTab, setActiveSubTab] = useState<"recurring" | "timeoff">("recurring");
// States für Recurring Rules
const [selectedDayOfWeek, setSelectedDayOfWeek] = useState<number>(1); // 1=Montag
const [ruleStartTime, setRuleStartTime] = useState<string>("13:00");
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: treatments } = useQuery(
queryClient.treatments.live.list.experimental_liveOptions()
const { data: timeOffPeriods } = useQuery(
queryClient.recurringAvailability.live.adminListTimeOff.experimental_liveOptions({
input: { sessionId: localStorage.getItem("sessionId") || "" }
})
);
const [errorMsg, setErrorMsg] = useState<string>("");
@@ -36,292 +51,183 @@ export function AdminAvailability() {
}
}, [successMsg]);
const { mutate: createSlot, isPending: isCreating } = useMutation(
queryClient.availability.create.mutationOptions()
// Neue Mutations für wiederkehrende Verfügbarkeiten
const { mutate: createRule } = useMutation(
queryClient.recurringAvailability.createRule.mutationOptions()
);
const { mutate: removeSlot } = useMutation(
queryClient.availability.remove.mutationOptions()
const { mutate: updateRule } = useMutation(
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(() => {
if (selectedTreatmentId && treatments) {
const treatment = treatments.find(t => t.id === selectedTreatmentId);
if (treatment) {
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";
// Helper-Funktion für Wochentag-Namen
const getDayName = (dayOfWeek: number): string => {
const days = ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"];
return days[dayOfWeek];
};
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 (
<div className="max-w-4xl mx-auto space-y-6">
{/* Slot Type Selection */}
<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 */}
{/* Tab-Navigation (Slots entfernt) */}
<div className="bg-white rounded-lg shadow">
<div className="p-4 border-b">
<h3 className="text-lg font-semibold">Alle Slots</h3>
<div className="border-b border-gray-200">
<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 className="divide-y">
{allSlots
?.sort((a, b) => (a.date === b.date ? a.time.localeCompare(b.time) : a.date.localeCompare(b.date)))
.map((slot) => {
// Try to find matching treatment based on duration
const matchingTreatments = treatments?.filter(t => t.duration === slot.durationMinutes) || [];
</div>
return (
<div key={slot.id} className="p-4 hover:bg-gray-50 transition-colors">
{/* Slot-Tab und Slot-UI entfernt */}
{/* Tab "Wiederkehrende Zeiten" */}
{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>
<div>
<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 gap-4">
<div className="text-center">
<div className="text-sm text-gray-500">Datum</div>
<div className="font-medium">{new Date(slot.date).toLocaleDateString('de-DE')}</div>
</div>
<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"
<div className="font-medium">{getDayName(rule.dayOfWeek)}</div>
<div className="text-gray-600">{rule.startTime} - {rule.endTime} Uhr</div>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
rule.isActive
? "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>
</div>
<div className="flex items-center gap-2">
@@ -332,52 +238,213 @@ export function AdminAvailability() {
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
return;
}
removeSlot(
{ sessionId, id: slot.id },
toggleRuleActive(
{ sessionId, id: rule.id },
{
onSuccess: () => {
setSuccessMsg("Slot erfolgreich gelöscht.");
setSuccessMsg(`Regel ${rule.isActive ? "deaktiviert" : "aktiviert"}.`);
},
onError: (err: any) => {
const msg = (err && (err.message || (err as any).toString())) || "Fehler beim schen des Slots.";
setErrorMsg(msg);
setErrorMsg(err?.message || "Fehler beim Umschalten der Regel.");
}
}
);
}}
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"
disabled={slot.status === "reserved"}
title={slot.status === "reserved" ? "Slot ist reserviert" : "Slot löschen"}
>
Löschen
</button>
</div>
</div>
</div>
))}
</div>
</div>
</div>
)}
{/* Show matching treatments if multiple */}
{matchingTreatments.length > 1 && (
<div className="mt-2 ml-20">
<div className="text-xs text-gray-500 mb-1">Passende Behandlungen:</div>
<div className="flex flex-wrap gap-1">
{matchingTreatments.map(treatment => (
<span key={treatment.id} className="px-2 py-1 bg-pink-100 text-pink-700 rounded text-xs">
{treatment.name}
</span>
))}
{/* 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("");
if (!timeOffStartDate || !timeOffEndDate || !timeOffReason) {
setErrorMsg("Bitte alle Felder ausfüllen.");
return;
}
if (timeOffStartDate > timeOffEndDate) {
setErrorMsg("Startdatum muss vor dem Enddatum liegen.");
return;
}
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>
);
})}
{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>
);
}

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { queryClient } from "@/client/rpc-client";
@@ -6,8 +6,28 @@ export function AdminBookings() {
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
const [selectedPhoto, setSelectedPhoto] = useState<string>("");
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()
);
@@ -16,7 +36,32 @@ export function AdminBookings() {
);
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 { mutate: sendMessage, isPending: isSendingMessage } = useMutation(
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 getTreatmentName = (treatmentId: string) => {
@@ -33,6 +78,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) => {
setSelectedPhoto(photoData);
setShowPhotoModal(true);
@@ -43,6 +98,35 @@ export function AdminBookings() {
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 =>
selectedDate ? booking.appointmentDate === selectedDate : true
).sort((a, b) => {
@@ -66,6 +150,34 @@ export function AdminBookings() {
return (
<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 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-lg shadow p-4">
@@ -144,8 +256,8 @@ export function AdminBookings() {
<td className="px-6 py-4 whitespace-nowrap">
<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.customerPhone}</div>
<div className="text-sm text-gray-500">{booking.customerEmail || '—'}</div>
<div className="text-sm text-gray-500">{booking.customerPhone || '—'}</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
@@ -183,45 +295,57 @@ export function AdminBookings() {
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex space-x-2">
{booking.status === "pending" && (
<>
<div className="flex flex-col space-y-2">
<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
onClick={() => updateBookingStatus({ sessionId: localStorage.getItem("sessionId") || "", id: booking.id, status: "confirmed" })}
className="text-green-600 hover:text-green-900"
>
Confirm
Reactivate
</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 === "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") && (
)}
</div>
{/* Show message button for future bookings with email */}
{isFutureBooking(booking.appointmentDate) && booking.customerEmail && (
<button
onClick={() => updateBookingStatus({ sessionId: localStorage.getItem("sessionId") || "", id: booking.id, status: "confirmed" })}
className="text-green-600 hover:text-green-900"
onClick={() => openMessageModal(booking.id)}
className="text-pink-600 hover:text-pink-900 text-left"
title="Nachricht an Kunden senden"
>
Reactivate
💬 Nachricht
</button>
)}
</div>
@@ -272,6 +396,116 @@ export function AdminBookings() {
</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;
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>
<p className="text-sm text-gray-700">
<strong>Behandlung:</strong> {getTreatmentName(booking.treatmentId)}
</p>
</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>
);
}

View File

@@ -5,6 +5,34 @@ import { queryClient } from "@/client/rpc-client";
export function AdminCalendar() {
const [currentMonth, setCurrentMonth] = useState(new Date());
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(
queryClient.bookings.live.list.experimental_liveOptions()
@@ -14,10 +42,50 @@ export function AdminCalendar() {
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,
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,
treatmentId: (showRescheduleModal ? bookings?.find(b => b.id === showRescheduleModal)?.treatmentId : '') || ''
}
}),
enabled: !!showRescheduleModal && !!rescheduleFormData.appointmentDate
});
const { mutate: updateBookingStatus } = useMutation(
queryClient.bookings.updateStatus.mutationOptions()
);
const { mutate: removeBooking } = useMutation(
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 getTreatmentName = (treatmentId: string) => {
return treatments?.find(t => t.id === treatmentId)?.name || "Unbekannte Behandlung";
};
@@ -69,8 +137,8 @@ export function AdminCalendar() {
const startDate = new Date(firstDay);
startDate.setDate(startDate.getDate() - firstDay.getDay());
const calendarDays = [];
const currentDate = new Date(startDate);
const calendarDays: Date[] = [];
const currentDate: Date = new Date(startDate);
for (let i = 0; i < 42; i++) {
calendarDays.push(new Date(currentDate));
@@ -106,8 +174,141 @@ 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 handleCreateBooking = () => {
const sessionId = localStorage.getItem('sessionId');
if (!sessionId) return;
createManualBooking({
sessionId,
...createFormData
}, {
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 (
<div className="max-w-7xl mx-auto">
<h2 className="text-2xl font-bold text-gray-900 mb-6">Kalender - Bevorstehende Buchungen</h2>
@@ -140,6 +341,62 @@ export function AdminCalendar() {
</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 */}
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
{/* Calendar Header */}
@@ -153,9 +410,17 @@ export function AdminCalendar() {
</svg>
</button>
<h3 className="text-xl font-semibold text-gray-900">
{monthNames[month]} {year}
</h3>
<div className="flex items-center space-x-4">
<h3 className="text-xl font-semibold text-gray-900">
{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
onClick={() => navigateMonth('next')}
@@ -267,10 +532,10 @@ export function AdminCalendar() {
<strong>Uhrzeit:</strong> {booking.appointmentTime}
</div>
<div>
<strong>E-Mail:</strong> {booking.customerEmail}
<strong>E-Mail:</strong> {booking.customerEmail || '—'}
</div>
<div>
<strong>Telefon:</strong> {booking.customerPhone}
<strong>Telefon:</strong> {booking.customerPhone || '—'}
</div>
</div>
@@ -293,7 +558,11 @@ export function AdminCalendar() {
Bestätigen
</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"
>
Stornieren
@@ -302,13 +571,51 @@ export function AdminCalendar() {
)}
{booking.status === "confirmed" && (
<button
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>
<>
<button
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>
<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>
@@ -317,6 +624,289 @@ export function AdminCalendar() {
)}
</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 ? getTreatmentName(booking.treatmentId) : '';
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>
);
}

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 className="bg-white rounded-lg shadow-lg overflow-hidden">
<table className="w-full">
<div className="bg-white rounded-lg shadow-lg overflow-x-auto">
<table className="w-full table-fixed">
<thead className="bg-gray-50">
<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
</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
</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
</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
</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
</th>
</tr>
@@ -221,22 +221,26 @@ export function AdminTreatments() {
<tbody className="bg-white divide-y divide-gray-200">
{treatments?.map((treatment) => (
<tr key={treatment.id}>
<td className="px-6 py-4 whitespace-nowrap">
<td className="px-6 py-4">
<div>
<div className="text-sm font-medium text-gray-900">{treatment.name}</div>
<div className="text-sm text-gray-500">{treatment.description}</div>
<div className="text-sm font-medium text-gray-900 truncate">{treatment.name}</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>
</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}
</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
</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)}
</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
onClick={() => handleEdit(treatment)}
className="text-pink-600 hover:text-pink-900"

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { queryClient } from "@/client/rpc-client";
@@ -8,69 +8,98 @@ export function BookingForm() {
const [customerEmail, setCustomerEmail] = useState("");
const [customerPhone, setCustomerPhone] = useState("");
const [appointmentDate, setAppointmentDate] = useState("");
const [selectedSlotId, setSelectedSlotId] = useState<string>("");
const [selectedTime, setSelectedTime] = useState("");
const [notes, setNotes] = useState("");
const [agbAccepted, setAgbAccepted] = useState(false);
const [ageConfirmed, setAgeConfirmed] = useState(false);
const [inspirationPhoto, setInspirationPhoto] = useState<string>("");
const [photoPreview, setPhotoPreview] = 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(
queryClient.treatments.live.list.experimental_liveOptions()
);
// Lade alle Slots live und filtere freie Slots
const { data: allSlots } = useQuery(
queryClient.availability.live.list.experimental_liveOptions()
);
// Filtere freie Slots und entferne vergangene Termine
const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
const freeSlots = (allSlots || []).filter((s) => {
// Nur freie Slots
if (s.status !== "free") return false;
// Nur zukünftige oder heutige Termine
if (s.date < today) return false;
// Für heute: nur zukünftige Uhrzeiten
if (s.date === today) {
const now = new Date();
const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
if (s.time <= currentTime) return false;
}
return true;
// Dynamische Verfügbarkeitsabfrage für das gewählte Datum und die Behandlung
const { data: availableTimes, isLoading, isFetching, error } = useQuery({
...queryClient.recurringAvailability.getAvailableTimes.queryOptions({
input: {
date: appointmentDate,
treatmentId: selectedTreatment
}
}),
enabled: !!appointmentDate && !!selectedTreatment
});
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(
queryClient.bookings.create.mutationOptions()
);
const selectedTreatmentData = treatments?.find((t) => t.id === selectedTreatment);
const availableSlots = slotsByDate || []; // Slots sind bereits gefiltert
// Debug logging (commented out - uncomment if needed)
// console.log("Debug - All slots:", allSlots);
// console.log("Debug - Free slots:", freeSlots);
// console.log("Debug - Available dates:", availableDates);
// console.log("Debug - Selected date:", appointmentDate);
// console.log("Debug - Slots by date:", slotsByDate);
// console.log("Debug - Available slots:", availableSlots);
// Clear selectedTime when treatment changes
const handleTreatmentChange = (treatmentId: string) => {
setSelectedTreatment(treatmentId);
setSelectedTime("");
};
// Additional debugging for slot status
// if (allSlots && allSlots.length > 0) {
// const statusCounts = allSlots.reduce((acc, slot) => {
// acc[slot.status] = (acc[slot.status] || 0) + 1;
// return acc;
// }, {} as Record<string, number>);
// console.log("Debug - Slot status counts:", statusCounts);
// }
// 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 file = e.target.files?.[0];
@@ -138,7 +167,8 @@ export function BookingForm() {
if (fileInput) fileInput.value = '';
};
const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErrorMessage(""); // Clear any previous error messages
@@ -152,7 +182,7 @@ export function BookingForm() {
// agbAccepted
// });
if (!selectedTreatment || !customerName || !customerEmail || !customerPhone || !appointmentDate || !selectedSlotId) {
if (!selectedTreatment || !customerName || !customerEmail || !customerPhone || !appointmentDate || !selectedTime) {
setErrorMessage("Bitte fülle alle erforderlichen Felder aus.");
return;
}
@@ -160,8 +190,13 @@ export function BookingForm() {
setErrorMessage("Bitte bestätige die Kenntnisnahme der Allgemeinen Geschäftsbedingungen.");
return;
}
const slot = availableSlots.find((s) => s.id === selectedSlotId);
const appointmentTime = slot?.time || "";
if (!ageConfirmed) {
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:", {
// treatmentId: selectedTreatment,
// customerName,
@@ -170,8 +205,7 @@ export function BookingForm() {
// appointmentDate,
// appointmentTime,
// notes,
// inspirationPhoto,
// slotId: selectedSlotId,
// inspirationPhoto
// });
createBooking(
{
@@ -183,7 +217,6 @@ export function BookingForm() {
appointmentTime,
notes,
inspirationPhoto,
slotId: selectedSlotId,
},
{
onSuccess: () => {
@@ -192,9 +225,10 @@ export function BookingForm() {
setCustomerEmail("");
setCustomerPhone("");
setAppointmentDate("");
setSelectedSlotId("");
setSelectedTime("");
setNotes("");
setAgbAccepted(false);
setAgeConfirmed(false);
setInspirationPhoto("");
setPhotoPreview("");
setErrorMessage("");
@@ -205,14 +239,24 @@ export function BookingForm() {
},
onError: (error: any) => {
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);
},
}
);
};
// 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 (
<div className="max-w-2xl mx-auto bg-white rounded-lg shadow-lg p-6">
@@ -226,7 +270,7 @@ export function BookingForm() {
</label>
<select
value={selectedTreatment}
onChange={(e) => setSelectedTreatment(e.target.value)}
onChange={(e) => handleTreatmentChange(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"
required
>
@@ -287,48 +331,53 @@ export function BookingForm() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Datum (nur freie Termine) *
Wunschdatum *
</label>
<select
<input
type="date"
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"
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>
<label className="block text-sm font-medium text-gray-700 mb-2">
Verfügbare Uhrzeit *
Verfügbare Uhrzeit (15-Min-Raster) *
</label>
<select
value={selectedSlotId}
onChange={(e) => setSelectedSlotId(e.target.value)}
value={selectedTime}
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"
disabled={!appointmentDate || !selectedTreatment}
disabled={!appointmentDate || !selectedTreatment || isLoading || isFetching}
required
>
<option value="">Zeit auswählen</option>
{availableSlots
.sort((a, b) => a.time.localeCompare(b.time))
.map((slot) => (
<option key={slot.id} value={slot.id}>
{slot.time} ({slot.durationMinutes} min)
</option>
))}
{availableTimes?.map((time) => (
<option key={time} value={time}>
{time}
</option>
))}
</select>
{appointmentDate && availableSlots.length === 0 && (
{appointmentDate && selectedTreatment && isLoading && (
<p className="mt-2 text-sm text-gray-500">
Keine freien Zeitslots für {appointmentDate} verfügbar.
Lade verfügbare Zeiten...
</p>
)}
{appointmentDate && selectedTreatment && error && (
<p className="mt-2 text-sm text-red-500">
Fehler beim Laden der verfügbaren Zeiten. Bitte versuche es erneut.
</p>
)}
{appointmentDate && selectedTreatment && !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>
)}
{selectedTreatmentData && (
<p className="mt-1 text-xs text-gray-500">Dauer: {selectedTreatmentData.duration} Minuten</p>
)}
</div>
</div>
@@ -382,7 +431,7 @@ export function BookingForm() {
</div>
{/* 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">
<input
type="checkbox"
@@ -408,6 +457,22 @@ export function BookingForm() {
</p>
</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>
{/* Error Message */}

View File

@@ -0,0 +1,621 @@
import React, { useState, useEffect } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { queryClient } from "@/client/rpc-client";
interface BookingStatusPageProps {
token: string;
}
type BookingStatus = "pending" | "confirmed" | "cancelled" | "completed";
function getStatusInfo(status: BookingStatus) {
switch (status) {
case "pending":
return {
label: "Wartet auf Bestätigung",
color: "yellow",
icon: "⏳",
bgColor: "bg-yellow-50",
borderColor: "border-yellow-200",
textColor: "text-yellow-800",
badgeColor: "bg-yellow-100 text-yellow-800",
};
case "confirmed":
return {
label: "Bestätigt",
color: "green",
icon: "✓",
bgColor: "bg-green-50",
borderColor: "border-green-200",
textColor: "text-green-800",
badgeColor: "bg-green-100 text-green-800",
};
case "cancelled":
return {
label: "Storniert",
color: "red",
icon: "✕",
bgColor: "bg-red-50",
borderColor: "border-red-200",
textColor: "text-red-800",
badgeColor: "bg-red-100 text-red-800",
};
case "completed":
return {
label: "Abgeschlossen",
color: "gray",
icon: "✓",
bgColor: "bg-gray-50",
borderColor: "border-gray-200",
textColor: "text-gray-800",
badgeColor: "bg-gray-100 text-gray-800",
};
}
}
export default function BookingStatusPage({ token }: BookingStatusPageProps) {
const [showCancelConfirm, setShowCancelConfirm] = useState(false);
const [isCancelling, setIsCancelling] = useState(false);
const [cancellationResult, setCancellationResult] = useState<{ success: boolean; message: string; formattedDate?: string } | null>(null);
const [rescheduleProposal, setRescheduleProposal] = useState<any | 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
const { data: booking, isLoading, error, refetch, error: bookingError } = useQuery(
queryClient.cancellation.getBookingByToken.queryOptions({ input: { token } })
);
// Try fetching reschedule proposal if booking not found or error
const rescheduleQuery = useQuery({
...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
const cancelMutation = useMutation({
...queryClient.cancellation.cancelByToken.mutationOptions(),
onSuccess: (result: any) => {
setCancellationResult({
success: true,
message: result.message,
formattedDate: result.formattedDate,
});
setIsCancelling(false);
setShowCancelConfirm(false);
refetch(); // Refresh booking data
},
onError: (error: any) => {
setCancellationResult({
success: false,
message: error?.message || "Ein Fehler ist aufgetreten.",
});
setIsCancelling(false);
},
});
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 = () => {
setIsCancelling(true);
setCancellationResult(null);
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} um ${rescheduleProposal.proposed.time} 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) {
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-2xl 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">Buchung wird geladen...</span>
</div>
</div>
</div>
);
}
if (error && !rescheduleProposal) {
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="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 Buchungslink ist nicht mehr verfügbar. Mögliche Gründe:
</p>
<ul className="text-sm text-gray-600 text-left mb-6 space-y-2">
<li> Der Link ist abgelaufen</li>
<li> Die Buchung wurde bereits storniert</li>
<li> Der Link wurde bereits verwendet</li>
</ul>
<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>
);
}
if (!booking && !rescheduleProposal) {
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="text-center">
<h2 className="text-xl font-bold text-gray-900 mb-2">Buchung nicht gefunden</h2>
<p className="text-gray-600 mb-4">
Die angeforderte Buchung konnte nicht gefunden werden.
</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>
</div>
);
}
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">{rescheduleProposal.booking.treatmentName}</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} um {rescheduleProposal.proposed.time} Uhr</div>
<div className="text-gray-700 text-sm">{rescheduleProposal.booking.treatmentName}</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 (
<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 ${statusInfo.badgeColor}`}>
{statusInfo.icon} {statusInfo.label}
</span>
</div>
<h1 className="text-2xl font-bold text-gray-900">Buchungsübersicht</h1>
<p className="text-gray-600 mt-1">Hier findest du alle Details zu deinem Termin</p>
</div>
{/* Cancellation Result */}
{cancellationResult && (
<div className={`mb-6 p-4 rounded-lg ${
cancellationResult.success ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'
}`}>
<p className={cancellationResult.success ? 'text-green-800' : 'text-red-800'}>
{cancellationResult.message}
{cancellationResult.formattedDate && (
<><br />Stornierter Termin: {cancellationResult.formattedDate}</>
)}
</p>
</div>
)}
{/* Status Banner */}
<div className={`${statusInfo.bgColor} border ${statusInfo.borderColor} rounded-lg p-6 mb-6`}>
<div className="flex items-start">
<div className={`text-4xl mr-4 ${statusInfo.textColor}`}>{statusInfo.icon}</div>
<div className="flex-1">
<h2 className={`text-xl font-bold ${statusInfo.textColor} mb-2`}>
Status: {statusInfo.label}
</h2>
{booking?.status === "pending" && (
<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.
</p>
)}
{booking?.status === "confirmed" && (
<p className={statusInfo.textColor}>
Dein Termin wurde bestätigt! Wir freuen uns auf dich. Du hast eine Bestätigungs-E-Mail mit Kalendereintrag erhalten.
</p>
)}
{booking?.status === "cancelled" && (
<p className={statusInfo.textColor}>
Dieser Termin wurde storniert. Du kannst jederzeit einen neuen Termin buchen.
</p>
)}
{booking?.status === "completed" && (
<p className={statusInfo.textColor}>
Dieser Termin wurde erfolgreich abgeschlossen. Vielen Dank für deinen Besuch!
</p>
)}
</div>
</div>
</div>
{/* Appointment 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">
<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?.treatmentName}</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-100">
<span className="text-gray-600">Dauer:</span>
<span className="font-medium text-gray-900">{booking?.treatmentDuration} Minuten</span>
</div>
{booking?.treatmentPrice && 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 && booking.hoursUntilAppointment > 0 && booking.status !== "cancelled" && booking.status !== "completed" && (
<div className="flex justify-between py-2">
<span className="text-gray-600">Verbleibende Zeit:</span>
<span className="font-medium text-pink-600">
{booking.hoursUntilAppointment} Stunde{booking.hoursUntilAppointment !== 1 ? 'n' : ''}
</span>
</div>
)}
</div>
</div>
{/* Customer 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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
Deine Daten
</h2>
<div className="space-y-3">
<div className="flex justify-between py-2 border-b border-gray-100">
<span className="text-gray-600">Name:</span>
<span className="font-medium text-gray-900">{booking?.customerName}</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-100">
<span className="text-gray-600">E-Mail:</span>
<span className="font-medium text-gray-900">{booking?.customerEmail || '—'}</span>
</div>
<div className="flex justify-between py-2">
<span className="text-gray-600">Telefon:</span>
<span className="font-medium text-gray-900">{booking?.customerPhone || '—'}</span>
</div>
</div>
{booking?.notes && (
<div className="mt-4 pt-4 border-t border-gray-200">
<h3 className="text-sm font-semibold text-gray-700 mb-2">Notizen:</h3>
<p className="text-gray-600 text-sm">{booking.notes}</p>
</div>
)}
</div>
{/* Cancellation Section */}
{booking?.canCancel && !cancellationResult?.success && (
<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-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
Termin stornieren
</h2>
{!showCancelConfirm ? (
<div>
<p className="text-gray-600 mb-4">
Du kannst diesen Termin noch bis {parseInt(process.env.MIN_STORNO_TIMESPAN || "24")} Stunden vor dem Termin kostenlos stornieren.
</p>
<button
onClick={() => setShowCancelConfirm(true)}
className="w-full bg-red-600 text-white py-3 px-4 rounded-lg font-medium hover:bg-red-700 transition-colors flex items-center justify-center"
>
<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="M6 18L18 6M6 6l12 12" />
</svg>
Termin stornieren
</button>
</div>
) : (
<div>
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
<p className="text-red-800 font-semibold mb-2">Bist du sicher?</p>
<p className="text-red-700 text-sm">
Diese Aktion kann nicht rückgängig gemacht werden. Der Termin wird storniert und der Slot wird wieder für andere Kunden verfügbar.
</p>
</div>
<div className="flex gap-3">
<button
onClick={handleCancel}
disabled={isCancelling}
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 flex items-center justify-center"
>
{isCancelling ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Storniere...
</>
) : (
<>Ja, stornieren</>
)}
</button>
<button
onClick={() => setShowCancelConfirm(false)}
disabled={isCancelling}
className="flex-1 bg-gray-200 text-gray-800 py-3 px-4 rounded-lg font-medium hover:bg-gray-300 disabled:opacity-50 transition-colors"
>
Abbrechen
</button>
</div>
</div>
)}
</div>
)}
{!booking?.canCancel && booking?.status !== "cancelled" && booking?.status !== "completed" && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
<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.
</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

@@ -1,247 +0,0 @@
import React, { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { queryClient } from "@/client/rpc-client";
interface CancellationPageProps {
token: string;
}
export default function CancellationPage({ token }: CancellationPageProps) {
const [isCancelling, setIsCancelling] = useState(false);
const [cancellationResult, setCancellationResult] = useState<{ success: boolean; message: string; formattedDate?: string } | null>(null);
// Fetch booking details
const { data: booking, isLoading, error } = useQuery({
queryKey: ["cancellation", "booking", token],
queryFn: () => queryClient.cancellation.getBookingByToken({ token }),
retry: false,
});
// Cancellation mutation
const cancelMutation = useMutation({
mutationFn: () => queryClient.cancellation.cancelByToken({ token }),
onSuccess: (result) => {
setCancellationResult({
success: true,
message: result.message,
formattedDate: result.formattedDate,
});
setIsCancelling(false);
},
onError: (error: any) => {
setCancellationResult({
success: false,
message: error?.message || "Ein Fehler ist aufgetreten.",
});
setIsCancelling(false);
},
});
const handleCancel = () => {
setIsCancelling(true);
setCancellationResult(null);
cancelMutation.mutate();
};
if (isLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 flex items-center justify-center">
<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">Termin wird geladen...</span>
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 flex items-center justify-center">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full">
<div className="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">Fehler</h2>
<p className="text-gray-600 mb-4">
{error?.message || "Der Stornierungs-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>
</div>
);
}
if (cancellationResult) {
return (
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 flex items-center justify-center">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full">
<div className="text-center">
<div className={`w-16 h-16 mx-auto mb-4 rounded-full flex items-center justify-center ${
cancellationResult.success ? 'bg-green-100' : 'bg-red-100'
}`}>
{cancellationResult.success ? (
<svg className="w-8 h-8 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<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 mb-2 ${
cancellationResult.success ? 'text-green-700' : 'text-red-700'
}`}>
{cancellationResult.success ? 'Termin storniert' : 'Stornierung fehlgeschlagen'}
</h2>
<p className="text-gray-600 mb-4">
{cancellationResult.message}
{cancellationResult.formattedDate && (
<><br />Stornierter Termin: {cancellationResult.formattedDate}</>
)}
</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>
</div>
);
}
if (!booking) {
return (
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 flex items-center justify-center">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full">
<div className="text-center">
<h2 className="text-xl font-bold text-gray-900 mb-2">Termin nicht gefunden</h2>
<p className="text-gray-600 mb-4">
Der angeforderte Termin konnte nicht gefunden werden.
</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>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 flex items-center justify-center">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full">
<div className="text-center mb-6">
<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">Termin stornieren</h1>
</div>
<div className="bg-gray-50 rounded-lg p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Termin-Details</h2>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-gray-600">Name:</span>
<span className="font-medium text-gray-900">{booking.customerName}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Datum:</span>
<span className="font-medium text-gray-900">{booking.formattedDate}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Uhrzeit:</span>
<span className="font-medium text-gray-900">{booking.appointmentTime}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Behandlung:</span>
<span className="font-medium text-gray-900">{(booking as any).treatmentName || 'Unbekannte Behandlung'}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Status:</span>
<span className={`font-medium ${
booking.status === 'confirmed' ? 'text-green-600' :
booking.status === 'pending' ? 'text-yellow-600' :
'text-gray-600'
}`}>
{booking.status === 'confirmed' ? 'Bestätigt' :
booking.status === 'pending' ? 'Ausstehend' :
booking.status}
</span>
</div>
</div>
</div>
{booking.status === 'cancelled' ? (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
<p className="text-yellow-800 text-center">
Dieser Termin wurde bereits storniert.
</p>
</div>
) : (
<div className="space-y-4">
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<p className="text-blue-800 text-sm">
<strong> Stornierungsfrist:</strong> Termine können nur bis zu einer bestimmten Zeit vor dem Termin storniert werden.
Falls die Stornierung nicht möglich ist, erhältst du eine entsprechende Meldung.
</p>
</div>
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-red-800 text-sm">
<strong>Hinweis:</strong> Nach der Stornierung wird der Termin-Slot wieder für andere Kunden verfügbar.
Eine erneute Buchung ist jederzeit möglich.
</p>
</div>
<button
onClick={handleCancel}
disabled={isCancelling}
className="w-full 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 flex items-center justify-center"
>
{isCancelling ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Storniere...
</>
) : (
<>
<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="M6 18L18 6M6 6l12 12" />
</svg>
Ich möchte diesen Termin stornieren
</>
)}
</button>
</div>
)}
<div className="text-center mt-6">
<a
href="/"
className="text-pink-600 hover:text-pink-700 text-sm"
>
Zurück zur Startseite
</a>
</div>
</div>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { useAuth } from "@/client/components/auth-provider";
export function LoginForm() {
@@ -6,9 +6,27 @@ export function LoginForm() {
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [isInitialized, setIsInitialized] = useState(false);
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) => {
e.preventDefault();
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,222 @@
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.treatmentName}</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

@@ -1,7 +1,10 @@
import { Hono } from "hono";
import { serve } from '@hono/node-server';
import { serveStatic } from '@hono/node-server/serve-static';
import { rpcApp } from "./routes/rpc";
import { clientEntry } from "./routes/client-entry";
import { rpcApp } from "./routes/rpc.js";
import { caldavApp } from "./routes/caldav.js";
import { clientEntry } from "./routes/client-entry.js";
const app = new Hono();
@@ -19,7 +22,7 @@ app.get("/health", (c) => {
// Legal config endpoint (temporary fix for RPC issue)
app.get("/api/legal-config", async (c) => {
try {
const { getLegalConfig } = await import("./lib/legal-config");
const { getLegalConfig } = await import("./lib/legal-config.js");
const config = getLegalConfig();
return c.json(config);
} catch (error) {
@@ -53,7 +56,31 @@ Canonical: https://${process.env.DOMAIN || 'localhost:5173'}/.well-known/securit
});
});
// 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;

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

@@ -31,13 +31,18 @@ async function renderBrandedEmail(title: string, bodyHtml: string): Promise<stri
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="Stargirlnails" 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>
${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>
@@ -49,6 +54,29 @@ async function renderBrandedEmail(title: string, bodyHtml: string): Promise<stri
<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>
@@ -58,8 +86,8 @@ async function renderBrandedEmail(title: string, bodyHtml: string): Promise<stri
</div>`;
}
export async function renderBookingPendingHTML(params: { name: string; date: string; time: string }) {
const { name, date, time } = params;
export async function renderBookingPendingHTML(params: { name: string; date: string; time: string; statusUrl?: string }) {
const { name, date, time, statusUrl } = params;
const formattedDate = formatDateGerman(date);
const domain = process.env.DOMAIN || 'localhost:5173';
const protocol = domain.includes('localhost') ? 'http' : 'https';
@@ -69,6 +97,13 @@ export async function renderBookingPendingHTML(params: { name: string; date: str
<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>
@@ -78,8 +113,8 @@ export async function renderBookingPendingHTML(params: { name: string; date: str
return renderBrandedEmail("Deine Terminanfrage ist eingegangen", inner);
}
export async function renderBookingConfirmedHTML(params: { name: string; date: string; time: string; cancellationUrl?: string }) {
const { name, date, time, cancellationUrl } = params;
export async function renderBookingConfirmedHTML(params: { name: string; date: string; time: string; cancellationUrl?: string; reviewUrl?: string }) {
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';
@@ -94,10 +129,18 @@ export async function renderBookingConfirmedHTML(params: { name: string; date: s
<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: #fef3f2; border-left: 4px solid #ef4444; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #ef4444;"> Termin stornieren:</p>
<p style="margin: 8px 0 12px 0; color: #475569;">Falls du den Termin stornieren möchtest, kannst du das hier tun:</p>
<a href="${cancellationUrl}" style="display: inline-block; background-color: #ef4444; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">Termin stornieren</a>
<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;">
@@ -162,3 +205,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
type EmailValidationResult = {
valid: boolean;
email: string;
domain?: string;
disposable?: boolean;
role?: boolean;
typo?: boolean;
suggestion?: string;
mx?: boolean;
error?: string;
validations: {
syntax: boolean;
domain_exists: boolean;
mx_records: boolean;
mailbox_exists: boolean;
is_disposable: boolean;
is_role_based: boolean;
};
score: number;
status: string;
};
/**
@@ -25,7 +27,7 @@ export async function validateEmail(email: string): Promise<{
}> {
try {
// 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',
headers: {
'Accept': 'application/json',
@@ -33,15 +35,18 @@ export async function validateEmail(email: string): Promise<{
});
if (!response.ok) {
console.warn(`Email validation API error: ${response.status}`);
// If API is down, allow the email (fallback to Zod validation only)
return { valid: true };
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: EmailValidationResult = await response.json();
// Check if email is disposable/temporary
if (data.disposable) {
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.',
@@ -49,20 +54,26 @@ export async function validateEmail(email: string): Promise<{
}
// Check if MX records exist (deliverable)
if (data.mx === false) {
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 email is generally valid
if (!data.valid) {
const suggestion = data.suggestion ? ` Meintest du vielleicht: ${data.suggestion}?` : '';
// Check if domain exists
if (!data.validations.domain_exists) {
return {
valid: false,
reason: `Ungültige E-Mail-Adresse.${suggestion}`,
suggestion: data.suggestion,
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.',
};
}
@@ -71,9 +82,11 @@ export async function validateEmail(email: string): Promise<{
} catch (error) {
console.error('Email validation error:', error);
// If validation fails, allow the email (fallback to Zod validation only)
// This ensures the booking system continues to work even if the API is down
return { valid: true };
// 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.'
};
}
}

View File

@@ -6,6 +6,7 @@ type SendEmailParams = {
from?: string;
cc?: string | string[];
bcc?: string | string[];
replyTo?: string | string[];
attachments?: Array<{
filename: string;
content: string; // base64 encoded
@@ -130,22 +131,27 @@ export async function sendEmail(params: SendEmailParams): Promise<{ success: boo
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({
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,
}),
body: JSON.stringify(payload),
});
if (!response.ok) {
@@ -153,6 +159,9 @@ export async function sendEmail(params: SendEmailParams): Promise<{ success: boo
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 };
}

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

@@ -0,0 +1,233 @@
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;
treatmentId: string;
customerName: string;
customerEmail?: string;
customerPhone?: string;
appointmentDate: string; // YYYY-MM-DD
appointmentTime: string; // HH:MM
status: "pending" | "confirmed" | "cancelled" | "completed";
notes?: 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 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) {
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

@@ -1,28 +1,56 @@
/** @jsxImportSource hono/jsx */
import type { Context } from "hono";
import viteReact from "@vitejs/plugin-react";
import { readFileSync } from "fs";
import { join } from "path";
import type { BlankEnv } from "hono/types";
export function clientEntry(c: Context<BlankEnv>) {
let jsFile = "/src/client/main.tsx";
let cssFiles: string[] | null = 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: string) => `/${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(
<html lang="en">
<html lang="de">
<head>
<meta charSet="utf-8" />
<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>
<link rel="icon" type="image/png" href="/favicon.png" />
{import.meta.env.PROD ? (
<script src="/static/main.js" type="module" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.json" />
{cssFiles && cssFiles.map((css: string) => (
<link key={css} rel="stylesheet" href={css} />
))}
{process.env.NODE_ENV === 'production' ? (
<script src={jsFile} type="module" />
) : (
<>
<script
dangerouslySetInnerHTML={{
__html: viteReact.preambleCode.replace("__BASE__", "/"),
}}
type="module"
/>
<script src="/src/client/main.tsx" type="module" />
<script src="/@vite/client" type="module" />
<script src={jsFile} type="module" />
</>
)}
</head>

View File

@@ -1,21 +1,27 @@
import { RPCHandler } from "@orpc/server/fetch";
import { router } from "@/server/rpc";
import { router } from "../rpc/index.js";
import { Hono } from "hono";
export const rpcApp = new Hono();
const handler = new RPCHandler(router);
rpcApp.use("/*", async (c, next) => {
const { matched, response } = await handler.handle(c.req.raw, {
prefix: "/rpc",
});
rpcApp.all("/*", async (c) => {
try {
const { matched, response } = await handler.handle(c.req.raw, {
prefix: "/rpc",
});
if (matched) {
return c.newResponse(response.body, response);
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;
}
await next();
return;
});

View File

@@ -1,7 +1,7 @@
import { call, os } from "@orpc/server";
import { z } from "zod";
import { randomUUID } from "crypto";
import { createKV } from "@/server/lib/create-kv";
import { createKV } from "../lib/create-kv.js";
import { config } from "dotenv";
// Load environment variables from .env file

View File

@@ -1,156 +0,0 @@
import { call, os } from "@orpc/server";
import { z } from "zod";
import { randomUUID } from "crypto";
import { createKV } from "@/server/lib/create-kv";
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,
};

View File

@@ -1,19 +1,20 @@
import { call, os } from "@orpc/server";
import { z } from "zod";
import { randomUUID } from "crypto";
import { createKV } from "@/server/lib/create-kv";
import { createKV as createAvailabilityKV } from "@/server/lib/create-kv";
import { sendEmail, sendEmailWithAGB, sendEmailWithAGBAndCalendar, sendEmailWithInspirationPhoto } from "@/server/lib/email";
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML } from "@/server/lib/email-templates";
import { router } from "@/server/rpc";
import { createKV } from "../lib/create-kv.js";
import { sendEmail, sendEmailWithAGB, sendEmailWithAGBAndCalendar, sendEmailWithInspirationPhoto } from "../lib/email.js";
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML, renderBookingRescheduleProposalHTML, renderAdminRescheduleAcceptedHTML, renderAdminRescheduleDeclinedHTML, renderCustomerMessageHTML } from "../lib/email-templates.js";
import { router as rootRouter } from "./index.js";
import { createORPCClient } from "@orpc/client";
import { RPCLink } from "@orpc/client/fetch";
import { checkBookingRateLimit, getClientIP } from "@/server/lib/rate-limiter";
import { validateEmail } from "@/server/lib/email-validator";
import { checkBookingRateLimit, getClientIP } from "../lib/rate-limiter.js";
import { validateEmail } from "../lib/email-validator.js";
// Create a server-side client to call other RPC endpoints
const link = new RPCLink({ url: "http://localhost:5173/rpc" });
const queryClient = createORPCClient<typeof router>(link);
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<any>(link);
// Helper function to convert date from yyyy-mm-dd to dd.mm.yyyy
function formatDateGerman(dateString: string): string {
@@ -28,37 +29,171 @@ function generateUrl(path: string = ''): string {
return `${protocol}://${domain}${path}`;
}
// Helper function to parse time string to minutes since midnight
function parseTime(timeStr: string): number {
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: string, periods: TimeOffPeriod[]): boolean {
return periods.some(period => date >= period.startDate && date <= period.endDate);
}
// Helper function to validate booking time against recurring rules
async function validateBookingAgainstRules(
date: string,
time: string,
treatmentDuration: number
): Promise<void> {
// 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: string,
time: string,
treatmentDuration: number,
excludeBookingId?: string
): Promise<void> {
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<string, number>();
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"),
customerPhone: z.string().min(5, "Telefonnummer muss mindestens 5 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(),
});
type Booking = z.output<typeof BookingSchema>;
const kv = createKV<Booking>("bookings");
type Availability = {
// DEPRECATED: Availability slots are no longer used for booking validation
// type Availability = {
// id: string;
// date: string;
// time: string;
// durationMinutes: number;
// status: "free" | "reserved";
// reservedByBookingId?: string;
// createdAt: string;
// };
// const availabilityKV = createAvailabilityKV<Availability>("availability");
type RecurringRule = {
id: string;
date: string;
time: string;
durationMinutes: number;
status: "free" | "reserved";
reservedByBookingId?: string;
dayOfWeek: number;
startTime: string;
endTime: string;
isActive: boolean;
createdAt: string;
slotDurationMinutes?: number;
};
type TimeOffPeriod = {
id: string;
startDate: string;
endDate: string;
reason: string;
createdAt: string;
};
const availabilityKV = createAvailabilityKV<Availability>("availability");
const recurringRulesKV = createKV<RecurringRule>("recurringRules");
const timeOffPeriodsKV = createKV<TimeOffPeriod>("timeOffPeriods");
// Import treatments KV for admin notifications
import { createKV as createTreatmentsKV } from "@/server/lib/create-kv";
type Treatment = {
id: string;
name: string;
@@ -68,30 +203,20 @@ type Treatment = {
category: string;
createdAt: string;
};
const treatmentsKV = createTreatmentsKV<Treatment>("treatments");
const treatmentsKV = createKV<Treatment>("treatments");
const create = os
.input(BookingSchema.omit({ id: true, createdAt: true, status: true }))
.handler(async ({ input, context }) => {
.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
const headers = context.request?.headers || {};
const headersObj: Record<string, string | undefined> = {};
if (headers) {
// Convert Headers object to plain object
headers.forEach((value: string, key: string) => {
headersObj[key.toLowerCase()] = value;
});
}
const clientIP = getClientIP(headersObj);
// Rate limiting check (ohne IP, falls Context-Header im Build nicht verfügbar sind)
const rateLimitResult = checkBookingRateLimit({
ip: clientIP,
ip: undefined,
email: input.customerEmail,
});
@@ -104,12 +229,21 @@ const create = os
);
}
// Deep email validation using Rapid Email Validator API
// 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) {
@@ -126,45 +260,68 @@ const create = os
}
// Prevent double booking: same customer email with pending/confirmed on same date
const existing = await kv.getAllItems();
const hasConflict = existing.some(b =>
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.");
// 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" as const,
createdAt: new Date().toISOString()
};
// If a slotId is provided, tentatively reserve the slot (mark reserved but pending)
if (booking.slotId) {
const slot = await availabilityKV.getItem(booking.slotId);
if (!slot) throw new Error("Availability slot not found");
if (slot.status !== "free") throw new Error("Slot not available");
const updatedSlot: Availability = {
...slot,
status: "reserved",
reservedByBookingId: id,
};
await availabilityKV.setItem(slot.id, updatedSlot);
}
// 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 });
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.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
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(() => {});
})();
@@ -183,7 +340,7 @@ const create = os
date: input.appointmentDate,
time: input.appointmentTime,
treatment: treatmentName,
phone: input.customerPhone,
phone: input.customerPhone || "Nicht angegeben",
notes: input.notes,
hasInspirationPhoto: !!input.inspirationPhoto
});
@@ -192,7 +349,7 @@ const create = os
const adminText = `Neue Buchungsanfrage eingegangen:\n\n` +
`Name: ${input.customerName}\n` +
`Telefon: ${input.customerPhone}\n` +
`Telefon: ${input.customerPhone || "Nicht angegeben"}\n` +
`Behandlung: ${treatmentName}\n` +
`Datum: ${formatDateGerman(input.appointmentDate)}\n` +
`Uhrzeit: ${input.appointmentTime}\n` +
@@ -218,8 +375,10 @@ const create = os
}
})();
return booking;
} catch (error) {
} catch (error) {
console.error("Booking creation error:", error);
// Re-throw the error for oRPC to handle
throw error;
}
});
@@ -252,72 +411,39 @@ const updateStatus = os
const updatedBooking = { ...booking, status: input.status };
await kv.setItem(input.id, updatedBooking);
// Manage availability slot state transitions
if (booking.slotId) {
const slot = await availabilityKV.getItem(booking.slotId);
if (slot) {
// console.log(`Updating slot ${slot.id} (${slot.date} ${slot.time}) from ${slot.status} to ${input.status}`);
if (input.status === "cancelled") {
// Free the slot again
await availabilityKV.setItem(slot.id, {
...slot,
status: "free",
reservedByBookingId: undefined,
});
// console.log(`Slot ${slot.id} freed due to cancellation`);
} else if (input.status === "pending") {
// keep reserved as pending
if (slot.status !== "reserved") {
await availabilityKV.setItem(slot.id, {
...slot,
status: "reserved",
reservedByBookingId: booking.id,
});
// console.log(`Slot ${slot.id} reserved for pending booking`);
}
} else if (input.status === "confirmed" || input.status === "completed") {
// keep reserved; optionally noop
if (slot.status !== "reserved" || slot.reservedByBookingId !== booking.id) {
await availabilityKV.setItem(slot.id, {
...slot,
status: "reserved",
reservedByBookingId: booking.id,
});
// console.log(`Slot ${slot.id} confirmed as reserved`);
}
}
}
}
// Note: Slot state management removed - bookings now validated against recurring rules
// Email notifications on status changes
try {
if (input.status === "confirmed") {
// Create cancellation token for this booking
const cancellationToken = await queryClient.cancellation.createToken({ bookingId: booking.id });
// Create booking access token for this booking (status + cancellation)
const bookingAccessToken = await queryClient.cancellation.createToken({ bookingId: booking.id });
const formattedDate = formatDateGerman(booking.appointmentDate);
const cancellationUrl = generateUrl(`/cancel/${cancellationToken.token}`);
const bookingUrl = generateUrl(`/booking/${bookingAccessToken.token}`);
const homepageUrl = generateUrl();
const html = await renderBookingConfirmedHTML({
name: booking.customerName,
date: booking.appointmentDate,
time: booking.appointmentTime,
cancellationUrl
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";
const treatmentDuration = treatment?.duration || 60; // Default 60 minutes if not found
// 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\nFalls du den Termin stornieren möchtest, kannst du das hier tun: ${cancellationUrl}\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nBis bald!\nStargirlnails Kiel`,
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,
cc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
}, {
date: booking.appointmentDate,
time: booking.appointmentTime,
@@ -325,7 +451,57 @@ const updateStatus = os
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" as const };
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 });
@@ -334,18 +510,134 @@ const updateStatus = os
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,
cc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
});
} catch (e) {
console.error("Email send failed:", e);
}
} catch (e) {
console.error("Email send failed:", e);
}
return updatedBooking;
});
const remove = os.input(z.string()).handler(async ({ input }) => {
await kv.removeItem(input);
});
// 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" as const,
createdAt: new Date().toISOString()
} as Booking;
// 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();
@@ -382,10 +674,311 @@ const live = {
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 } as typeof booking;
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

@@ -1,29 +1,37 @@
import { call, os } from "@orpc/server";
import { z } from "zod";
import { createKV } from "@/server/lib/create-kv";
import { createKV as createAvailabilityKV } from "@/server/lib/create-kv";
import { createKV } from "../lib/create-kv.js";
import { createKV as createAvailabilityKV } from "../lib/create-kv.js";
import { randomUUID } from "crypto";
// Schema for cancellation token
const CancellationTokenSchema = z.object({
// 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(),
});
type CancellationToken = z.output<typeof CancellationTokenSchema>;
type BookingAccessToken = z.output<typeof BookingAccessTokenSchema>;
// Backwards compatibility alias
type CancellationToken = BookingAccessToken;
const cancellationKV = createKV<CancellationToken>("cancellation_tokens");
const cancellationKV = createKV<BookingAccessToken>("cancellation_tokens");
// Types for booking and availability
type Booking = {
id: string;
treatmentId: string;
customerName: string;
customerEmail: string;
customerPhone: string;
customerEmail?: string;
customerPhone?: string;
appointmentDate: string;
appointmentTime: string;
notes?: string;
@@ -52,6 +60,15 @@ function formatDateGerman(dateString: string): string {
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
const createToken = os
.input(z.object({ bookingId: z.string() }))
@@ -70,12 +87,13 @@ const createToken = os
expiresAt.setDate(expiresAt.getDate() + 30);
const token = randomUUID();
const cancellationToken: CancellationToken = {
const cancellationToken: BookingAccessToken = {
id: randomUUID(),
bookingId: input.bookingId,
token,
expiresAt: expiresAt.toISOString(),
createdAt: new Date().toISOString(),
purpose: "booking_access",
};
await cancellationKV.setItem(cancellationToken.id, cancellationToken);
@@ -89,7 +107,8 @@ const getBookingByToken = os
const tokens = await cancellationKV.getAllItems();
const validToken = tokens.find(t =>
t.token === input.token &&
new Date(t.expiresAt) > new Date()
new Date(t.expiresAt) > new Date() &&
t.purpose === 'booking_access'
);
if (!validToken) {
@@ -105,15 +124,32 @@ const getBookingByToken = os
const treatmentsKV = createKV<any>("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)),
};
});
@@ -196,4 +232,171 @@ 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: 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");
}
const treatmentsKV = createKV<any>("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: 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");
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

@@ -2,7 +2,7 @@ import OpenAI from "openai";
import { os } from "@orpc/server";
import { z } from "zod";
import { zodResponseFormat } from "@/server/lib/openai";
import { zodResponseFormat } from "../../lib/openai";
if (!process.env.OPENAI_BASE_URL) {
throw new Error("OPENAI_BASE_URL is not set");

View File

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

View File

@@ -1,7 +1,7 @@
import { call, os } from "@orpc/server";
import { z } from "zod";
import { randomUUID } from "crypto";
import { createKV } from "@/server/lib/create-kv";
import { createKV } from "../../lib/create-kv.js";
const DemoSchema = z.object({
id: z.string(),

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

@@ -1,17 +1,23 @@
import { demo } from "./demo";
import { router as treatments } from "./treatments";
import { router as bookings } from "./bookings";
import { router as auth } from "./auth";
import { router as availability } from "./availability";
import { router as cancellation } from "./cancellation";
import { router as legal } from "./legal";
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,
availability,
recurringAvailability,
cancellation,
legal,
gallery,
reviews,
social,
};

View File

@@ -1,5 +1,5 @@
import { os } from "@orpc/server";
import { getLegalConfig } from "@/server/lib/legal-config";
import { getLegalConfig } from "../lib/legal-config.js";
export const router = {
getConfig: os.handler(async () => {

View File

@@ -0,0 +1,476 @@
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}$/),
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: 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)
);
// Optimize treatment duration lookup with Map caching
const uniqueTreatmentIds = [...new Set(dateBookings.map(booking => booking.treatmentId))];
const treatmentDurationMap = new Map<string, number>();
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,
};

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

@@ -1,7 +1,7 @@
import { call, os } from "@orpc/server";
import { z } from "zod";
import { randomUUID } from "crypto";
import { createKV } from "@/server/lib/create-kv";
import { createKV } from "../lib/create-kv.js";
const TreatmentSchema = z.object({
id: z.string(),

14
start.sh Normal file
View File

@@ -0,0 +1,14 @@
#!/bin/sh
# Create .storage directories if they don't exist (as root)
mkdir -p /app/.storage/users
mkdir -p /app/.storage/bookings
mkdir -p /app/.storage/treatments
mkdir -p /app/.storage/availability
mkdir -p /app/.storage/cancellation-tokens
# Change ownership to nextjs user
chown -R nextjs:nodejs /app/.storage
# Start the application as nextjs user
exec su-exec nextjs node server-dist/index.js

View File

@@ -0,0 +1,22 @@
{
"extends": "./tsconfig.server.json",
"compilerOptions": {
"noEmit": false,
"outDir": "server-dist",
"sourceMap": false,
"declaration": false,
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": false,
"target": "ES2022",
"baseUrl": ".",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true
},
"include": [
"src/server/**/*.ts",
"src/server/**/*.tsx"
]
}

19
tsconfig.server.json Normal file
View File

@@ -0,0 +1,19 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": true,
"allowImportingTsExtensions": true,
"types": ["node"],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"src/server/**/*.ts",
"src/server/**/*.tsx"
]
}

View File

@@ -12,15 +12,22 @@ export default defineConfig(({ mode }) => {
process.env = env;
}
return {
root: ".",
server: {
host: "0.0.0.0",
port: 5173,
// Erlaube Zugriffe von beliebigen Hosts (lokal + Proxy/Funnel)
allowedHosts: ["localhost", "127.0.0.1", "master11.warbler-bearded.ts.net", ".ts.net"],
cors: true,
// Keine explizite HMR/Origin-Konfiguration, Vite-Defaults für localhost funktionieren am stabilsten
},
publicDir: "public",
build: {
outDir: "dist",
manifest: true,
rollupOptions: {
input: "index.html"
}
},
plugins: [
tsconfigPaths(),
react(),
@@ -30,8 +37,6 @@ export default defineConfig(({ mode }) => {
// it interferes with image imports.
exclude: [/src\/client\/.*/, ...defaultOptions.exclude],
entry: "./src/server/index.ts",
// Allow all hosts for Tailscale Funnel
allowedHosts: ["localhost", "127.0.0.1", "master11.warbler-bearded.ts.net", ".ts.net"],
}),
],
};