53 Commits

Author SHA1 Message Date
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
35 changed files with 2005 additions and 367 deletions

49
Caddyfile Normal file
View File

@@ -0,0 +1,49 @@
# 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
}
# 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:; font-src 'self' data:; connect-src 'self';"
# 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
}
}
# HTTP zu HTTPS Redirect (automatisch von Caddy)
http://stargirlnails.de {
redir https://stargirlnails.de{uri} permanent
}
# Favicon-Konfiguration
favicon.ico {
redir /favicon.png 301
}
favicon.png {
root * /app/dist
file favicon.png
}

View File

@@ -22,8 +22,8 @@ RUN pnpm build
# Production stage # Production stage
FROM node:22-alpine AS production FROM node:22-alpine AS production
# Install pnpm # Install pnpm and su-exec
RUN npm install -g pnpm RUN npm install -g pnpm ts-node && apk add --no-cache su-exec
# Set working directory # Set working directory
WORKDIR /app WORKDIR /app
@@ -36,20 +36,30 @@ RUN pnpm install --frozen-lockfile --prod
# Copy built application from base stage # Copy built application from base stage
COPY --from=base /app/dist ./dist 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 necessary files for runtime
COPY --from=base /app/src/server/index.ts ./src/server/index.ts 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/routes ./src/server/routes
COPY --from=base /app/src/server/rpc ./src/server/rpc COPY --from=base /app/src/server/rpc ./src/server/rpc
COPY --from=base /app/src/server/lib ./src/server/lib 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 # Create non-root user for security
RUN addgroup -g 1001 -S nodejs RUN addgroup -g 1001 -S nodejs
RUN adduser -S nextjs -u 1001 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 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 port
EXPOSE 3000 EXPOSE 3000
@@ -58,5 +68,5 @@ EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ 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 CMD node -e "require('http').get('http://localhost:3000/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) })" || exit 1
# Start the application # Start the application with startup script
CMD ["node", "--loader", "ts-node/esm", "src/server/index.ts"] CMD ["/app/start.sh"]

View File

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

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

@@ -0,0 +1,55 @@
# 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
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

@@ -9,10 +9,10 @@
### Sicherheit & Qualität ### Sicherheit & Qualität
- ~~RateLimiting (IP/EMail) für Formularspam~~ - ~~RateLimiting (IP/EMail) für Formularspam~~
- EMailVerifizierung (DoubleOptIn) optional - ~~EMailVerifizierung (DoubleOptIn) optional~~
- AuditLog (wer/was/wann) - AuditLog (wer/was/wann)
- DSGVO: Einwilligungstexte, Löschkonzept - ~~DSGVO: Einwilligungstexte, Löschkonzept~~
- Impressum - ~~Impressum~~
### EMail & Infrastruktur ### EMail & Infrastruktur
- Retry/Backoff + FallbackQueue bei ResendFehlern - Retry/Backoff + FallbackQueue bei ResendFehlern
@@ -22,7 +22,7 @@
### UX/UI ### UX/UI
- ~~Mobiler Kalender mit klarer SlotVisualisierung~~ - ~~Mobiler Kalender mit klarer SlotVisualisierung~~
- KundenStatusseite (pending/confirmed) - ~~KundenStatusseite (pending/confirmed)~~
- Prominente Fehlerzustände inkl. Hinweise bei Doppelbuchung - Prominente Fehlerzustände inkl. Hinweise bei Doppelbuchung
### Internationalisierung & Zeitzonen ### 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)
```

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Stargirlnails Kiel - Terminbuchung</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/client/main.tsx"></script>
</body>
</html>

View File

@@ -6,12 +6,13 @@
"scripts": { "scripts": {
"check:types": "tsc --noEmit", "check:types": "tsc --noEmit",
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build && tsc -p tsconfig.server.build.json",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@hono/node-server": "^1.19.5",
"@orpc/client": "^1.8.8", "@orpc/client": "^1.8.8",
"@orpc/server": "^1.8.8", "@orpc/server": "^1.8.8",
"@orpc/tanstack-query": "^1.8.8", "@orpc/tanstack-query": "^1.8.8",
@@ -24,6 +25,7 @@
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"tailwindcss": "^4", "tailwindcss": "^4",
"ts-node": "^10.9.2",
"unstorage": "^1.16.1", "unstorage": "^1.16.1",
"zod": "^4.0.17" "zod": "^4.0.17"
}, },

122
pnpm-lock.yaml generated
View File

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

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

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

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

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from "react"; import { useState } from "react";
import { useAuth } from "@/client/components/auth-provider"; import { useAuth } from "@/client/components/auth-provider";
import { LoginForm } from "@/client/components/login-form"; import { LoginForm } from "@/client/components/login-form";
import { UserProfile } from "@/client/components/user-profile"; import { UserProfile } from "@/client/components/user-profile";
@@ -8,32 +8,19 @@ import { AdminBookings } from "@/client/components/admin-bookings";
import { AdminCalendar } from "@/client/components/admin-calendar"; import { AdminCalendar } from "@/client/components/admin-calendar";
import { InitialDataLoader } from "@/client/components/initial-data-loader"; import { InitialDataLoader } from "@/client/components/initial-data-loader";
import { AdminAvailability } from "@/client/components/admin-availability"; import { AdminAvailability } from "@/client/components/admin-availability";
import CancellationPage from "@/client/components/cancellation-page"; import BookingStatusPage from "@/client/components/booking-status-page";
import LegalPage from "@/client/components/legal-page"; import LegalPage from "@/client/components/legal-page";
function App() { function App() {
const { user, isLoading, isOwner } = useAuth(); const { user, isLoading, isOwner } = useAuth();
const [activeTab, setActiveTab] = useState<"booking" | "admin-treatments" | "admin-bookings" | "admin-calendar" | "admin-availability" | "profile" | "legal">("booking"); const [activeTab, setActiveTab] = useState<"booking" | "admin-treatments" | "admin-bookings" | "admin-calendar" | "admin-availability" | "profile" | "legal">("booking");
// Check for cancellation token in URL // Handle booking status page
useEffect(() => {
const path = window.location.pathname; const path = window.location.pathname;
if (path.startsWith('/cancel/')) { if (path.startsWith('/booking/')) {
const token = path.split('/cancel/')[1]; const token = path.split('/booking/')[1];
if (token) { if (token) {
// Set a special state to show cancellation page return <BookingStatusPage token={token} />;
setActiveTab("cancellation" as any);
return;
}
}
}, []);
// Handle cancellation page
const path = window.location.pathname;
if (path.startsWith('/cancel/')) {
const token = path.split('/cancel/')[1];
if (token) {
return <CancellationPage token={token} />;
} }
} }

View File

@@ -69,8 +69,8 @@ export function AdminCalendar() {
const startDate = new Date(firstDay); const startDate = new Date(firstDay);
startDate.setDate(startDate.getDate() - firstDay.getDay()); startDate.setDate(startDate.getDate() - firstDay.getDay());
const calendarDays = []; const calendarDays: Date[] = [];
const currentDate = new Date(startDate); const currentDate: Date = new Date(startDate);
for (let i = 0; i < 42; i++) { for (let i = 0; i < 42; i++) {
calendarDays.push(new Date(currentDate)); calendarDays.push(new Date(currentDate));

View File

@@ -0,0 +1,378 @@
import React, { useState } 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);
// Fetch booking details
const { data: booking, isLoading, error, refetch } = useQuery(
queryClient.cancellation.getBookingByToken.queryOptions({ input: { token } })
);
// 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 handleCancel = () => {
setIsCancelling(true);
setCancellationResult(null);
cancelMutation.mutate({ token });
};
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) {
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">Fehler</h2>
<p className="text-gray-600 mb-4">
{error?.message || "Der 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 (!booking) {
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>
);
}
const statusInfo = getStatusInfo(booking.status);
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 > 0 && (
<div className="flex justify-between py-2 border-b border-gray-100">
<span className="text-gray-600">Preis:</span>
<span className="font-medium text-gray-900">{booking.treatmentPrice.toFixed(2)} </span>
</div>
)}
{booking.hoursUntilAppointment > 0 && booking.status !== "cancelled" && booking.status !== "completed" && (
<div className="flex justify-between py-2">
<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,7 +1,9 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { serve } from '@hono/node-server';
import { serveStatic } from '@hono/node-server/serve-static';
import { rpcApp } from "./routes/rpc"; import { rpcApp } from "./routes/rpc.js";
import { clientEntry } from "./routes/client-entry"; import { clientEntry } from "./routes/client-entry.js";
const app = new Hono(); const app = new Hono();
@@ -19,7 +21,7 @@ app.get("/health", (c) => {
// Legal config endpoint (temporary fix for RPC issue) // Legal config endpoint (temporary fix for RPC issue)
app.get("/api/legal-config", async (c) => { app.get("/api/legal-config", async (c) => {
try { try {
const { getLegalConfig } = await import("./lib/legal-config"); const { getLegalConfig } = await import("./lib/legal-config.js");
const config = getLegalConfig(); const config = getLegalConfig();
return c.json(config); return c.json(config);
} catch (error) { } catch (error) {
@@ -53,7 +55,25 @@ Canonical: https://${process.env.DOMAIN || 'localhost:5173'}/.well-known/securit
}); });
}); });
// Serve static files
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("/rpc", rpcApp);
app.get("/*", clientEntry); 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; export default app;

View File

@@ -58,8 +58,8 @@ async function renderBrandedEmail(title: string, bodyHtml: string): Promise<stri
</div>`; </div>`;
} }
export async function renderBookingPendingHTML(params: { name: string; date: string; time: string }) { export async function renderBookingPendingHTML(params: { name: string; date: string; time: string; statusUrl?: string }) {
const { name, date, time } = params; const { name, date, time, statusUrl } = params;
const formattedDate = formatDateGerman(date); const formattedDate = formatDateGerman(date);
const domain = process.env.DOMAIN || 'localhost:5173'; const domain = process.env.DOMAIN || 'localhost:5173';
const protocol = domain.includes('localhost') ? 'http' : 'https'; const protocol = domain.includes('localhost') ? 'http' : 'https';
@@ -69,6 +69,13 @@ export async function renderBookingPendingHTML(params: { name: string; date: str
<p>Hallo ${name},</p> <p>Hallo ${name},</p>
<p>wir haben deine Anfrage für <strong>${formattedDate}</strong> um <strong>${time}</strong> erhalten.</p> <p>wir haben deine Anfrage für <strong>${formattedDate}</strong> um <strong>${time}</strong> erhalten.</p>
<p>Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.</p> <p>Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.</p>
${statusUrl ? `
<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;"> <div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #3b82f6;">📋 Rechtliche Informationen:</p> <p style="margin: 0; font-weight: 600; color: #3b82f6;">📋 Rechtliche Informationen:</p>
<p style="margin: 8px 0 12px 0; color: #475569;">Weitere Informationen findest du in unserem <a href="${legalUrl}" style="color: #3b82f6; text-decoration: underline;">Impressum und Datenschutz</a>.</p> <p style="margin: 8px 0 12px 0; color: #475569;">Weitere Informationen findest du in unserem <a href="${legalUrl}" style="color: #3b82f6; text-decoration: underline;">Impressum und Datenschutz</a>.</p>
@@ -94,10 +101,10 @@ 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> <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> </div>
${cancellationUrl ? ` ${cancellationUrl ? `
<div style="background-color: #fef3f2; border-left: 4px solid #ef4444; padding: 16px; margin: 20px 0; border-radius: 4px;"> <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: #ef4444;"> Termin stornieren:</p> <p style="margin: 0; font-weight: 600; color: #db2777;">📅 Termin verwalten:</p>
<p style="margin: 8px 0 12px 0; color: #475569;">Falls du den Termin stornieren möchtest, kannst du das hier tun:</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: #ef4444; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">Termin stornieren</a> <a href="${cancellationUrl}" style="display: inline-block; background-color: #db2777; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">Termin ansehen & verwalten</a>
</div> </div>
` : ''} ` : ''}
<div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;"> <div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">

View File

@@ -1,10 +1,34 @@
/** @jsxImportSource hono/jsx */ /** @jsxImportSource hono/jsx */
import type { Context } from "hono"; 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"; import type { BlankEnv } from "hono/types";
export function clientEntry(c: Context<BlankEnv>) { 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['src/client/main.tsx'];
if (entry) {
jsFile = `/assets/${entry.file}`;
if (entry.css) {
cssFiles = entry.css.map((css: string) => `/assets/${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( return c.html(
<html lang="en"> <html lang="en">
<head> <head>
@@ -12,17 +36,15 @@ export function clientEntry(c: Context<BlankEnv>) {
<meta content="width=device-width, initial-scale=1" name="viewport" /> <meta content="width=device-width, initial-scale=1" name="viewport" />
<title>Stargirlnails Kiel</title> <title>Stargirlnails Kiel</title>
<link rel="icon" type="image/png" href="/favicon.png" /> <link rel="icon" type="image/png" href="/favicon.png" />
{import.meta.env.PROD ? ( {cssFiles && cssFiles.map((css: string) => (
<script src="/static/main.js" type="module" /> <link key={css} rel="stylesheet" href={css} />
))}
{process.env.NODE_ENV === 'production' ? (
<script src={jsFile} type="module" />
) : ( ) : (
<> <>
<script <script src="/@vite/client" type="module" />
dangerouslySetInnerHTML={{ <script src={jsFile} type="module" />
__html: viteReact.preambleCode.replace("__BASE__", "/"),
}}
type="module"
/>
<script src="/src/client/main.tsx" type="module" />
</> </>
)} )}
</head> </head>

View File

@@ -1,6 +1,6 @@
import { RPCHandler } from "@orpc/server/fetch"; import { RPCHandler } from "@orpc/server/fetch";
import { router } from "@/server/rpc"; import { router } from "../rpc/index.js";
import { Hono } from "hono"; import { Hono } from "hono";
export const rpcApp = new Hono(); export const rpcApp = new Hono();

View File

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

View File

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

View File

@@ -1,19 +1,20 @@
import { call, os } from "@orpc/server"; import { call, os } from "@orpc/server";
import { z } from "zod"; import { z } from "zod";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { createKV } from "@/server/lib/create-kv"; import { createKV } from "../lib/create-kv.js";
import { createKV as createAvailabilityKV } from "@/server/lib/create-kv"; import { createKV as createAvailabilityKV } from "../lib/create-kv.js";
import { sendEmail, sendEmailWithAGB, sendEmailWithAGBAndCalendar, sendEmailWithInspirationPhoto } from "@/server/lib/email"; import { sendEmail, sendEmailWithAGB, sendEmailWithAGBAndCalendar, sendEmailWithInspirationPhoto } from "../lib/email.js";
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML } from "@/server/lib/email-templates"; import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML } from "../lib/email-templates.js";
import { router } from "@/server/rpc"; import { router as rootRouter } from "./index.js";
import { createORPCClient } from "@orpc/client"; import { createORPCClient } from "@orpc/client";
import { RPCLink } from "@orpc/client/fetch"; import { RPCLink } from "@orpc/client/fetch";
import { checkBookingRateLimit, getClientIP } from "@/server/lib/rate-limiter"; import { checkBookingRateLimit, getClientIP } from "../lib/rate-limiter.js";
import { validateEmail } from "@/server/lib/email-validator"; import { validateEmail } from "../lib/email-validator.js";
// Create a server-side client to call other RPC endpoints // Create a server-side client to call other RPC endpoints
const link = new RPCLink({ url: "http://localhost:5173/rpc" }); const link = new RPCLink({ url: "http://localhost:5173/rpc" });
const queryClient = createORPCClient<typeof router>(link); // 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 // Helper function to convert date from yyyy-mm-dd to dd.mm.yyyy
function formatDateGerman(dateString: string): string { function formatDateGerman(dateString: string): string {
@@ -58,7 +59,7 @@ type Availability = {
const availabilityKV = createAvailabilityKV<Availability>("availability"); const availabilityKV = createAvailabilityKV<Availability>("availability");
// Import treatments KV for admin notifications // Import treatments KV for admin notifications
import { createKV as createTreatmentsKV } from "@/server/lib/create-kv"; import { createKV as createTreatmentsKV } from "../lib/create-kv.js";
type Treatment = { type Treatment = {
id: string; id: string;
name: string; name: string;
@@ -72,26 +73,16 @@ const treatmentsKV = createTreatmentsKV<Treatment>("treatments");
const create = os const create = os
.input(BookingSchema.omit({ id: true, createdAt: true, status: true })) .input(BookingSchema.omit({ id: true, createdAt: true, status: true }))
.handler(async ({ input, context }) => { .handler(async ({ input }) => {
// console.log("Booking create called with input:", { // console.log("Booking create called with input:", {
// ...input, // ...input,
// inspirationPhoto: input.inspirationPhoto ? `[${input.inspirationPhoto.length} chars]` : null // inspirationPhoto: input.inspirationPhoto ? `[${input.inspirationPhoto.length} chars]` : null
// }); // });
try { try {
// Rate limiting check // Rate limiting check (ohne IP, falls Context-Header im Build nicht verfügbar sind)
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);
const rateLimitResult = checkBookingRateLimit({ const rateLimitResult = checkBookingRateLimit({
ip: clientIP, ip: undefined,
email: input.customerEmail, email: input.customerEmail,
}); });
@@ -158,13 +149,22 @@ const create = os
// Notify customer: request received (pending) // Notify customer: request received (pending)
void (async () => { void (async () => {
// Create booking access token for status viewing
const bookingAccessToken = await queryClient.cancellation.createToken({ input: { bookingId: id } });
const bookingUrl = generateUrl(`/booking/${bookingAccessToken.token}`);
const formattedDate = formatDateGerman(input.appointmentDate); const formattedDate = formatDateGerman(input.appointmentDate);
const homepageUrl = generateUrl(); 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({ await sendEmail({
to: input.customerEmail, to: input.customerEmail,
subject: "Deine Terminanfrage ist eingegangen", 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, html,
}).catch(() => {}); }).catch(() => {});
})(); })();
@@ -292,18 +292,18 @@ const updateStatus = os
// Email notifications on status changes // Email notifications on status changes
try { try {
if (input.status === "confirmed") { if (input.status === "confirmed") {
// Create cancellation token for this booking // Create booking access token for this booking (status + cancellation)
const cancellationToken = await queryClient.cancellation.createToken({ bookingId: booking.id }); const bookingAccessToken = await queryClient.cancellation.createToken({ input: { bookingId: booking.id } });
const formattedDate = formatDateGerman(booking.appointmentDate); const formattedDate = formatDateGerman(booking.appointmentDate);
const cancellationUrl = generateUrl(`/cancel/${cancellationToken.token}`); const bookingUrl = generateUrl(`/booking/${bookingAccessToken.token}`);
const homepageUrl = generateUrl(); const homepageUrl = generateUrl();
const html = await renderBookingConfirmedHTML({ const html = await renderBookingConfirmedHTML({
name: booking.customerName, name: booking.customerName,
date: booking.appointmentDate, date: booking.appointmentDate,
time: booking.appointmentTime, time: booking.appointmentTime,
cancellationUrl cancellationUrl: bookingUrl // Now points to booking status page
}); });
// Get treatment information for ICS file // Get treatment information for ICS file
@@ -315,7 +315,7 @@ const updateStatus = os
await sendEmailWithAGBAndCalendar({ await sendEmailWithAGBAndCalendar({
to: booking.customerEmail, to: booking.customerEmail,
subject: "Dein Termin wurde bestätigt - AGB im Anhang", 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, html,
cc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined, cc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
}, { }, {

View File

@@ -1,21 +1,24 @@
import { call, os } from "@orpc/server"; import { call, os } from "@orpc/server";
import { z } from "zod"; import { z } from "zod";
import { createKV } from "@/server/lib/create-kv"; import { createKV } from "../lib/create-kv.js";
import { createKV as createAvailabilityKV } from "@/server/lib/create-kv"; import { createKV as createAvailabilityKV } from "../lib/create-kv.js";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
// Schema for cancellation token // Schema for booking access token (used for both status viewing and cancellation)
const CancellationTokenSchema = z.object({ const BookingAccessTokenSchema = z.object({
id: z.string(), id: z.string(),
bookingId: z.string(), bookingId: z.string(),
token: z.string(), token: z.string(),
expiresAt: z.string(), expiresAt: z.string(),
createdAt: z.string(), createdAt: z.string(),
purpose: z.enum(["booking_access"]), // For future extensibility
}); });
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 // Types for booking and availability
type Booking = { type Booking = {
@@ -70,12 +73,13 @@ const createToken = os
expiresAt.setDate(expiresAt.getDate() + 30); expiresAt.setDate(expiresAt.getDate() + 30);
const token = randomUUID(); const token = randomUUID();
const cancellationToken: CancellationToken = { const cancellationToken: BookingAccessToken = {
id: randomUUID(), id: randomUUID(),
bookingId: input.bookingId, bookingId: input.bookingId,
token, token,
expiresAt: expiresAt.toISOString(), expiresAt: expiresAt.toISOString(),
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
purpose: "booking_access",
}; };
await cancellationKV.setItem(cancellationToken.id, cancellationToken); await cancellationKV.setItem(cancellationToken.id, cancellationToken);
@@ -105,15 +109,32 @@ const getBookingByToken = os
const treatmentsKV = createKV<any>("treatments"); const treatmentsKV = createKV<any>("treatments");
const treatment = await treatmentsKV.getItem(booking.treatmentId); 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 { return {
id: booking.id, id: booking.id,
customerName: booking.customerName, customerName: booking.customerName,
customerEmail: booking.customerEmail,
customerPhone: booking.customerPhone,
appointmentDate: booking.appointmentDate, appointmentDate: booking.appointmentDate,
appointmentTime: booking.appointmentTime, appointmentTime: booking.appointmentTime,
treatmentId: booking.treatmentId, treatmentId: booking.treatmentId,
treatmentName: treatment?.name || "Unbekannte Behandlung", treatmentName: treatment?.name || "Unbekannte Behandlung",
treatmentDuration: treatment?.duration || 60,
treatmentPrice: treatment?.price || 0,
status: booking.status, status: booking.status,
notes: booking.notes,
formattedDate: formatDateGerman(booking.appointmentDate), formattedDate: formatDateGerman(booking.appointmentDate),
createdAt: booking.createdAt,
canCancel,
hoursUntilAppointment: Math.max(0, Math.round(timeDifferenceHours)),
}; };
}); });

View File

@@ -2,7 +2,7 @@ import OpenAI from "openai";
import { os } from "@orpc/server"; import { os } from "@orpc/server";
import { z } from "zod"; import { z } from "zod";
import { zodResponseFormat } from "@/server/lib/openai"; import { zodResponseFormat } from "../../lib/openai";
if (!process.env.OPENAI_BASE_URL) { if (!process.env.OPENAI_BASE_URL) {
throw new Error("OPENAI_BASE_URL is not set"); 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 = { export const demo = {
storage: storageRouter, storage: storageRouter,

View File

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

View File

@@ -1,10 +1,10 @@
import { demo } from "./demo"; import { demo } from "./demo/index.js";
import { router as treatments } from "./treatments"; import { router as treatments } from "./treatments.js";
import { router as bookings } from "./bookings"; import { router as bookings } from "./bookings.js";
import { router as auth } from "./auth"; import { router as auth } from "./auth.js";
import { router as availability } from "./availability"; import { router as availability } from "./availability.js";
import { router as cancellation } from "./cancellation"; import { router as cancellation } from "./cancellation.js";
import { router as legal } from "./legal"; import { router as legal } from "./legal.js";
export const router = { export const router = {
demo, demo,

View File

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

View File

@@ -1,7 +1,7 @@
import { call, os } from "@orpc/server"; import { call, os } from "@orpc/server";
import { z } from "zod"; import { z } from "zod";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { createKV } from "@/server/lib/create-kv"; import { createKV } from "../lib/create-kv.js";
const TreatmentSchema = z.object({ const TreatmentSchema = z.object({
id: z.string(), 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,21 @@ export default defineConfig(({ mode }) => {
process.env = env; process.env = env;
} }
return { return {
root: ".",
server: { server: {
host: "0.0.0.0", host: "0.0.0.0",
port: 5173, port: 5173,
// Erlaube Zugriffe von beliebigen Hosts (lokal + Proxy/Funnel) // Erlaube Zugriffe von beliebigen Hosts (lokal + Proxy/Funnel)
allowedHosts: ["localhost", "127.0.0.1", "master11.warbler-bearded.ts.net", ".ts.net"],
cors: true, cors: true,
// Keine explizite HMR/Origin-Konfiguration, Vite-Defaults für localhost funktionieren am stabilsten // Keine explizite HMR/Origin-Konfiguration, Vite-Defaults für localhost funktionieren am stabilsten
}, },
publicDir: "public", publicDir: "public",
build: {
outDir: "dist",
rollupOptions: {
input: "index.html"
}
},
plugins: [ plugins: [
tsconfigPaths(), tsconfigPaths(),
react(), react(),
@@ -30,8 +36,6 @@ export default defineConfig(({ mode }) => {
// it interferes with image imports. // it interferes with image imports.
exclude: [/src\/client\/.*/, ...defaultOptions.exclude], exclude: [/src\/client\/.*/, ...defaultOptions.exclude],
entry: "./src/server/index.ts", entry: "./src/server/index.ts",
// Allow all hosts for Tailscale Funnel
allowedHosts: ["localhost", "127.0.0.1", "master11.warbler-bearded.ts.net", ".ts.net"],
}), }),
], ],
}; };