Compare commits
119 Commits
732f2fb0e6
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| c1aeb7c38b | |||
| 889e110dd9 | |||
| a603232ed8 | |||
| f0037226a9 | |||
| 12da9812df | |||
| ce019a2bd9 | |||
| 63384aa209 | |||
| ebd9d8a72e | |||
| ccba9d443b | |||
| 9583148e02 | |||
| d153aad8b3 | |||
| 94e269697a | |||
| ad79531f33 | |||
| db1a401230 | |||
| cceb4d4e60 | |||
| ca20516080 | |||
| f2963ca951 | |||
| 8aea5bb400 | |||
| 14d0c2f9c3 | |||
| eb9ddc535f | |||
| 8fa17f58c9 | |||
| 92ed7a2c93 | |||
| ce644c31e1 | |||
| 3b67c26216 | |||
| f2fed22ea1 | |||
| ab5e5e67a6 | |||
| 78a379546c | |||
| 953a970220 | |||
| c7d9fc689e | |||
| f4593cd706 | |||
| fbfdceeee6 | |||
| 244eeee142 | |||
| 9c2e47ef9a | |||
| 27a106de13 | |||
| 83a3a6a19f | |||
| 53aca01131 | |||
| 6d7e8eceba | |||
| 6cf657168b | |||
| a8cec16d7a | |||
| 97c1d3493f | |||
| 3a13c8dffb | |||
| 6f6b21e7c8 | |||
| d7b1ae3525 | |||
| 6502f0d416 | |||
| 0b4e7e725f | |||
| 938ee76e32 | |||
| 5baa231d3c | |||
| 73cf733c5f | |||
| f2e12df6d5 | |||
| d663abb1ab | |||
| c0b0edc00e | |||
| 9a104e8862 | |||
| 84fc9ee890 | |||
| 277be954b7 | |||
| 65a0b8c823 | |||
| 1285560f62 | |||
| 49829a4573 | |||
| eacb063bc0 | |||
| e6ffb0ef6d | |||
| 6e826922f6 | |||
| 38594d30a2 | |||
| 76874bc98a | |||
| a77634bb13 | |||
| 8ffe459d50 | |||
| c28d4fc4ec | |||
| 6b10c256a0 | |||
| 6987d48bd6 | |||
| 97d17d67ee | |||
| 98858c1760 | |||
| b3272d565b | |||
| e29f4374c0 | |||
| 23ea0d801e | |||
| b10df50688 | |||
| ffc21a76e7 | |||
| 857b60e1f5 | |||
| 713da5a802 | |||
| 12b31d28d5 | |||
| 84d6f5c07a | |||
| f4d9f60fc9 | |||
| 2c2a173b96 | |||
| 3d5c6ffeaf | |||
| 72834a6977 | |||
| 18b75fdde3 | |||
| 143051a90a | |||
| 1e1070dbb5 | |||
| 19e52f7af6 | |||
| a80cb86cd5 | |||
| 74f55486bc | |||
| c6c1455612 | |||
| 9d71842714 | |||
| b3df04a92d | |||
| 3d1bbe7265 | |||
| f44164c957 | |||
| 9da96d7af9 | |||
| 4f901400a3 | |||
| 1cf727433d | |||
| 647016ff85 | |||
| fe3acccb93 | |||
| a7733c95f6 | |||
| 4696948c6c | |||
| 73612caa1e | |||
| fb30bb6395 | |||
| 4acb639e66 | |||
| 52280b1b3b | |||
| f9d42b4c1e | |||
| 18f97e4e5f | |||
| 17f1ff698e | |||
| 71a107de52 | |||
| 58fb163bbc | |||
| 1d97e05000 | |||
| 86a73f2c16 | |||
| 85fcde0805 | |||
| 8ee2a2b3b6 | |||
| 2dcfb8e2ee | |||
| 2402afff13 | |||
| 558ee9cc56 | |||
| 9dd9fd0ac2 | |||
| 2ddd0704d7 | |||
| 01754bf142 |
11
.env.example
11
.env.example
@@ -10,7 +10,9 @@ RESEND_API_KEY=your_resend_api_key_here
|
|||||||
EMAIL_FROM=noreply@yourdomain.com
|
EMAIL_FROM=noreply@yourdomain.com
|
||||||
ADMIN_EMAIL=admin@yourdomain.com
|
ADMIN_EMAIL=admin@yourdomain.com
|
||||||
|
|
||||||
# Frontend URL (for E-Mail Links)
|
# Social media profiles
|
||||||
|
TIKTOK_PROFILE=https://www.tiktok.com/@<dein Tiktok Profil>
|
||||||
|
INSTAGRAM_PROFILE=https://www.instagram.com/<dein Instragram Profil>
|
||||||
|
|
||||||
# Cancellation Policy (in hours)
|
# Cancellation Policy (in hours)
|
||||||
MIN_STORNO_TIMESPAN=24
|
MIN_STORNO_TIMESPAN=24
|
||||||
@@ -18,10 +20,12 @@ MIN_STORNO_TIMESPAN=24
|
|||||||
# Legal Information (Impressum/Datenschutz)
|
# Legal Information (Impressum/Datenschutz)
|
||||||
COMPANY_NAME=Stargirlnails Kiel
|
COMPANY_NAME=Stargirlnails Kiel
|
||||||
OWNER_NAME=Inhaber Name
|
OWNER_NAME=Inhaber Name
|
||||||
ADDRESS_STREET=Musterstraße 123
|
ADDRESS_STREET=Liebigstr. 15
|
||||||
ADDRESS_CITY=Kiel
|
ADDRESS_CITY=Kiel
|
||||||
ADDRESS_POSTAL_CODE=24103
|
ADDRESS_POSTAL_CODE=24145
|
||||||
ADDRESS_COUNTRY=Deutschland
|
ADDRESS_COUNTRY=Deutschland
|
||||||
|
ADDRESS_LATITUDE=54.3233 # Optional: GPS-Koordinaten für Karte
|
||||||
|
ADDRESS_LONGITUDE=10.1228 # Optional: GPS-Koordinaten für Karte
|
||||||
CONTACT_PHONE=+49 431 123456
|
CONTACT_PHONE=+49 431 123456
|
||||||
CONTACT_EMAIL=info@stargirlnails.de
|
CONTACT_EMAIL=info@stargirlnails.de
|
||||||
|
|
||||||
@@ -53,3 +57,4 @@ AWS_SECRET_ACCESS_KEY=your_aws_secret_key_here
|
|||||||
|
|
||||||
# Other API Keys (optional)
|
# Other API Keys (optional)
|
||||||
BW_CLIENTSECRET=your_bw_client_secret_here
|
BW_CLIENTSECRET=your_bw_client_secret_here
|
||||||
|
SECURITY_CONTACT=security@stargirlnails.de # E-Mail für Sicherheitsmeldungen
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -22,6 +22,7 @@ Thumbs.db
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
.notes.txt
|
||||||
|
|
||||||
# Turbo
|
# Turbo
|
||||||
.turbo
|
.turbo
|
||||||
|
|||||||
60
Caddyfile
Normal file
60
Caddyfile
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# Caddyfile für Stargirlnails Kiel
|
||||||
|
# Automatisches SSL mit Let's Encrypt
|
||||||
|
|
||||||
|
stargirlnails.de {
|
||||||
|
# Reverse Proxy zur Anwendung
|
||||||
|
reverse_proxy stargirlnails:3000 {
|
||||||
|
# Health Check
|
||||||
|
health_uri /health
|
||||||
|
health_interval 30s
|
||||||
|
health_timeout 5s
|
||||||
|
|
||||||
|
# Timeouts für lange laufende Verbindungen (Live-Queries)
|
||||||
|
transport http {
|
||||||
|
read_timeout 0
|
||||||
|
write_timeout 0
|
||||||
|
dial_timeout 30s
|
||||||
|
}
|
||||||
|
|
||||||
|
# Buffer-Flush für Server-Sent Events (SSE) aktivieren
|
||||||
|
flush_interval -1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sicherheits-Header
|
||||||
|
header {
|
||||||
|
# Sicherheits-Header
|
||||||
|
X-Frame-Options "SAMEORIGIN"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
X-XSS-Protection "1; mode=block"
|
||||||
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; connect-src 'self' data: blob:; frame-src 'self' https://www.openstreetmap.org;"
|
||||||
|
|
||||||
|
# HSTS (wird automatisch von Caddy gesetzt)
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Gzip-Kompression
|
||||||
|
encode gzip
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
log {
|
||||||
|
output file /var/log/caddy/access.log
|
||||||
|
format json
|
||||||
|
level INFO
|
||||||
|
}
|
||||||
|
|
||||||
|
# Favicon-Konfiguration (innerhalb der Hauptdomain)
|
||||||
|
handle /favicon.ico {
|
||||||
|
redir /favicon.png 301
|
||||||
|
}
|
||||||
|
|
||||||
|
handle /favicon.png {
|
||||||
|
root * /app/public
|
||||||
|
try_files {path}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTP zu HTTPS Redirect (automatisch von Caddy)
|
||||||
|
http://stargirlnails.de {
|
||||||
|
redir https://stargirlnails.de{uri} permanent
|
||||||
|
}
|
||||||
22
Dockerfile
22
Dockerfile
@@ -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"]
|
||||||
|
|||||||
67
README.md
67
README.md
@@ -73,10 +73,12 @@ MIN_STORNO_TIMESPAN=24
|
|||||||
# Legal Information (Impressum/Datenschutz)
|
# Legal Information (Impressum/Datenschutz)
|
||||||
COMPANY_NAME=Stargirlnails Kiel
|
COMPANY_NAME=Stargirlnails Kiel
|
||||||
OWNER_NAME=Inhaber Name
|
OWNER_NAME=Inhaber Name
|
||||||
ADDRESS_STREET=Musterstraße 123
|
ADDRESS_STREET=Liebigstr. 15
|
||||||
ADDRESS_CITY=Kiel
|
ADDRESS_CITY=Kiel
|
||||||
ADDRESS_POSTAL_CODE=24103
|
ADDRESS_POSTAL_CODE=24145
|
||||||
ADDRESS_COUNTRY=Deutschland
|
ADDRESS_COUNTRY=Deutschland
|
||||||
|
ADDRESS_LATITUDE=54.3233 # Optional: GPS-Koordinaten für Karte
|
||||||
|
ADDRESS_LONGITUDE=10.1228 # Optional: GPS-Koordinaten für Karte
|
||||||
CONTACT_PHONE=+49 431 123456
|
CONTACT_PHONE=+49 431 123456
|
||||||
CONTACT_EMAIL=info@stargirlnails.de
|
CONTACT_EMAIL=info@stargirlnails.de
|
||||||
TAX_ID=12/345/67890 # Optional
|
TAX_ID=12/345/67890 # Optional
|
||||||
@@ -127,6 +129,8 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./.storage:/app/.storage
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -150,38 +154,50 @@ 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
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
### Buchungssystem
|
||||||
- 📅 **Terminbuchung**: Kunden können online Termine buchen
|
- 📅 **Terminbuchung**: Kunden können online Termine buchen
|
||||||
- 💅 **Behandlungsverwaltung**: Admin kann Behandlungen hinzufügen/bearbeiten
|
- 💅 **Behandlungsverwaltung**: Admin kann Behandlungen hinzufügen/bearbeiten
|
||||||
- 📆 **Kalender-Ansicht**: Übersichtliche Darstellung aller Termine
|
- 📆 **Kalender-Ansicht**: Übersichtliche Darstellung aller Termine
|
||||||
- ⏰ **Verfügbarkeits-Slots**: Flexible Slot-Verwaltung mit behandlungsspezifischen Dauern
|
- ⏰ **Verfügbarkeits-Slots**: Flexible Slot-Verwaltung mit behandlungsspezifischen Dauern
|
||||||
- 📧 **E-Mail-Benachrichtigungen**: Automatische Benachrichtigungen bei Buchungen
|
|
||||||
- ❌ **Termin-Stornierung**: Kunden können Termine über sichere Links stornieren
|
- ❌ **Termin-Stornierung**: Kunden können Termine über sichere Links stornieren
|
||||||
- ⏰ **Stornierungsfrist**: Konfigurierbare Mindestfrist vor dem Termin (MIN_STORNO_TIMESPAN)
|
- ⏰ **Stornierungsfrist**: Konfigurierbare Mindestfrist vor dem Termin (MIN_STORNO_TIMESPAN)
|
||||||
- 📋 **Impressum/Datenschutz**: Rechtliche Seiten mit konfigurierbaren Daten
|
|
||||||
|
### E-Mail & Benachrichtigungen
|
||||||
|
- 📧 **E-Mail-Benachrichtigungen**: Automatische Benachrichtigungen bei Buchungen
|
||||||
|
- 📅 **ICS-Kalendereinträge**: Termin-Bestätigungen mit ICS-Datei zum Importieren in Kalender-Apps
|
||||||
|
- ⏰ **Kalender-Erinnerungen**: 24h-Erinnerung im ICS-Kalendereintrag
|
||||||
|
- 📎 **AGB-Anhänge**: Automatischer PDF-Anhang der Allgemeinen Geschäftsbedingungen
|
||||||
|
|
||||||
|
### Sicherheit
|
||||||
|
- 🛡️ **Rate-Limiting**: IP- und E-Mail-basierter Schutz gegen Spam (3 Anfragen/E-Mail pro Stunde, 5 Anfragen/IP pro 10 Min)
|
||||||
|
- ✉️ **E-Mail-Validierung**: Mehrschichtige Validierung inkl. Disposable-Email-Detection und MX-Record-Prüfung
|
||||||
|
- 🚫 **Wegwerf-Email-Schutz**: Blockierung von temporären E-Mail-Adressen
|
||||||
- 🔐 **Admin-Panel**: Geschützter Bereich für Inhaber
|
- 🔐 **Admin-Panel**: Geschützter Bereich für Inhaber
|
||||||
|
- 🛡️ **Security.txt**: RFC 9116 konformer Endpoint für Sicherheitsmeldungen
|
||||||
|
|
||||||
|
### Rechtliches
|
||||||
|
- 📋 **Impressum/Datenschutz**: Rechtliche Seiten mit konfigurierbaren Daten
|
||||||
|
- ⚖️ **GDPR-konform**: Datenschutzfreundliche Implementierung
|
||||||
|
|
||||||
## Admin-Zugang
|
## Admin-Zugang
|
||||||
|
|
||||||
@@ -196,3 +212,24 @@ Nach dem Setup kannst du dich mit den in der `.env` konfigurierten Admin-Credent
|
|||||||
- Das Passwort wird als Base64-Hash in der `.env` Datei gespeichert
|
- Das Passwort wird als Base64-Hash in der `.env` Datei gespeichert
|
||||||
- Verwende ein sicheres Passwort und generiere den entsprechenden Hash
|
- Verwende ein sicheres Passwort und generiere den entsprechenden Hash
|
||||||
- Die `.env` Datei sollte niemals in das Repository committet werden
|
- Die `.env` Datei sollte niemals in das Repository committet werden
|
||||||
|
|
||||||
|
### Security.txt Endpoint
|
||||||
|
|
||||||
|
Die Anwendung bietet einen RFC 9116 konformen Security.txt Endpoint unter `/.well-known/security.txt`:
|
||||||
|
|
||||||
|
- **Kontakt**: Konfigurierbar über `SECURITY_CONTACT` Umgebungsvariable
|
||||||
|
- **Ablauf**: Automatisch gesetzt auf Ende des aktuellen Jahres
|
||||||
|
- **Sprachen**: Deutsch und Englisch bevorzugt
|
||||||
|
- **Caching**: 24 Stunden Cache-Header für bessere Performance
|
||||||
|
|
||||||
|
**Beispiel-Konfiguration:**
|
||||||
|
```env
|
||||||
|
SECURITY_CONTACT=security@stargirlnails.de
|
||||||
|
```
|
||||||
|
|
||||||
|
**Zugriff:**
|
||||||
|
```bash
|
||||||
|
curl https://your-domain.com/.well-known/security.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Dies ermöglicht Sicherheitsforschern, Sicherheitslücken verantwortungsvoll zu melden.
|
||||||
|
|||||||
56
docker-compose-prod.yml
Normal file
56
docker-compose-prod.yml
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# Production Docker Compose für Stargirlnails Kiel
|
||||||
|
# Mit Nginx Reverse Proxy und Let's Encrypt SSL-Zertifikaten
|
||||||
|
|
||||||
|
services:
|
||||||
|
# Hauptanwendung
|
||||||
|
stargirlnails:
|
||||||
|
build: .
|
||||||
|
container_name: stargirlnails-app
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- DISABLE_DUPLICATE_CHECK=false
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- storage-data:/app/.storage
|
||||||
|
networks:
|
||||||
|
- stargirlnails-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
# Keine Abhängigkeit zu nginx, um Dependency-Zyklen zu vermeiden
|
||||||
|
|
||||||
|
# Caddy Reverse Proxy mit automatischem SSL
|
||||||
|
caddy:
|
||||||
|
image: caddy:2-alpine
|
||||||
|
container_name: stargirlnails-caddy
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
- caddy-data:/data
|
||||||
|
- caddy-config:/config
|
||||||
|
networks:
|
||||||
|
- stargirlnails-network
|
||||||
|
depends_on:
|
||||||
|
- stargirlnails
|
||||||
|
|
||||||
|
# Volumes für persistente Daten
|
||||||
|
volumes:
|
||||||
|
storage-data:
|
||||||
|
driver: local
|
||||||
|
caddy-data:
|
||||||
|
driver: local
|
||||||
|
caddy-config:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
# Netzwerk für interne Kommunikation
|
||||||
|
networks:
|
||||||
|
stargirlnails-network:
|
||||||
|
driver: bridge
|
||||||
@@ -6,6 +6,8 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./.storage:/app/.storage
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/"]
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -14,3 +16,4 @@ services:
|
|||||||
start_period: 40s
|
start_period: 40s
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
|
- DISABLE_DUPLICATE_CHECK=true
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
## Backlog – Terminplanung & Infrastruktur
|
## Backlog – Terminplanung & Infrastruktur
|
||||||
|
|
||||||
### Kalender & Workflow
|
### Kalender & Workflow
|
||||||
- ICS-Anhang/Link in E‑Mails (Kalendereintrag)
|
- ~~ICS-Anhang/Link in E‑Mails (Kalendereintrag)~~
|
||||||
- Erinnerungsmails (24h/3h vor Termin)
|
- Erinnerungsmails (24h/3h vor Termin)
|
||||||
- ~~Umbuchen/Stornieren per sicherem Kundenlink (Token)~~
|
- ~~Umbuchen/Stornieren per sicherem Kundenlink (Token)~~
|
||||||
- Pufferzeiten und Sperrtage/Feiertage konfigurierbar
|
- ~~Wiederkehrende Verfügbarkeitsregeln (z.B. "Montags 13-18 Uhr")~~
|
||||||
|
- ~~Urlaubszeiten/Blockierungen konfigurierbar~~
|
||||||
|
- Pufferzeiten zwischen Terminen konfigurierbar
|
||||||
- ~~Slots dynamisch an Behandlungsdauer anpassen; Überschneidungen verhindern~~
|
- ~~Slots dynamisch an Behandlungsdauer anpassen; Überschneidungen verhindern~~
|
||||||
|
|
||||||
### Sicherheit & Qualität
|
### Sicherheit & Qualität
|
||||||
- Rate‑Limiting (IP/E‑Mail) für Formularspam
|
- ~~Rate‑Limiting (IP/E‑Mail) für Formularspam~~
|
||||||
- CAPTCHA im Buchungsformular
|
- ~~E‑Mail‑Verifizierung (Double‑Opt‑In) optional~~
|
||||||
- E‑Mail‑Verifizierung (Double‑Opt‑In) optional
|
|
||||||
- Audit‑Log (wer/was/wann)
|
- Audit‑Log (wer/was/wann)
|
||||||
- DSGVO: Einwilligungstexte, Löschkonzept
|
- ~~DSGVO: Einwilligungstexte, Löschkonzept~~
|
||||||
- Impressum
|
- ~~Impressum~~
|
||||||
|
|
||||||
### E‑Mail & Infrastruktur
|
### E‑Mail & Infrastruktur
|
||||||
- Retry/Backoff + Fallback‑Queue bei Resend‑Fehlern
|
- Retry/Backoff + Fallback‑Queue bei Resend‑Fehlern
|
||||||
@@ -23,7 +24,7 @@
|
|||||||
|
|
||||||
### UX/UI
|
### UX/UI
|
||||||
- ~~Mobiler Kalender mit klarer Slot‑Visualisierung~~
|
- ~~Mobiler Kalender mit klarer Slot‑Visualisierung~~
|
||||||
- Kunden‑Statusseite (pending/confirmed)
|
- ~~Kunden‑Statusseite (pending/confirmed)~~
|
||||||
- Prominente Fehlerzustände inkl. Hinweise bei Doppelbuchung
|
- Prominente Fehlerzustände inkl. Hinweise bei Doppelbuchung
|
||||||
|
|
||||||
### Internationalisierung & Zeitzonen
|
### Internationalisierung & Zeitzonen
|
||||||
|
|||||||
422
docs/google-apps-script-test-form.js
Normal file
422
docs/google-apps-script-test-form.js
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
259
docs/production-deployment.md
Normal file
259
docs/production-deployment.md
Normal 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`.
|
||||||
93
docs/rate-limiting.md
Normal file
93
docs/rate-limiting.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# Rate Limiting & E-Mail-Validierung
|
||||||
|
|
||||||
|
Das System verwendet ein mehrstufiges Sicherheitssystem, um Spam und Missbrauch des Buchungsformulars zu verhindern.
|
||||||
|
|
||||||
|
## E-Mail-Validierung
|
||||||
|
|
||||||
|
Neben der Zod-Schema-Validierung wird jede E-Mail-Adresse durch die [Rapid Email Validator API](https://rapid-email-verifier.fly.dev/) geprüft:
|
||||||
|
|
||||||
|
### Geprüfte Kriterien
|
||||||
|
- ✅ **Syntax-Validierung:** Korrekte E-Mail-Format-Prüfung
|
||||||
|
- ✅ **Disposable Email Detection:** Wegwerf-E-Mail-Adressen werden blockiert
|
||||||
|
- ✅ **MX Record Verification:** Prüft, ob die Domain E-Mails empfangen kann
|
||||||
|
- ✅ **Domain Verification:** Validiert die Existenz der Domain
|
||||||
|
- ✅ **Typo-Erkennung:** Schlägt Korrekturen bei häufigen Tippfehlern vor
|
||||||
|
|
||||||
|
### Datenschutz
|
||||||
|
Die verwendete API speichert **keine Daten** und ist GDPR/CCPA/PIPEDA-konform. Alle E-Mail-Adressen werden nur im Speicher verarbeitet und sofort verworfen.
|
||||||
|
|
||||||
|
### Fallback-Verhalten
|
||||||
|
Falls die Validierungs-API nicht erreichbar ist, fällt das System auf die Zod-Validierung zurück, um die Funktionalität zu gewährleisten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rate Limiting
|
||||||
|
|
||||||
|
Das System verwendet ein Rate-Limiting, um Spam und Missbrauch des Buchungsformulars zu verhindern.
|
||||||
|
|
||||||
|
## Aktuelle Konfiguration
|
||||||
|
|
||||||
|
### Buchungen (E-Mail-basiert)
|
||||||
|
- **Limit:** 3 Buchungsanfragen pro E-Mail-Adresse
|
||||||
|
- **Zeitfenster:** 1 Stunde
|
||||||
|
- **Verhalten:** Nach 3 Anfragen muss der Nutzer 1 Stunde warten
|
||||||
|
|
||||||
|
### Buchungen (IP-basiert)
|
||||||
|
- **Limit:** 5 Buchungsanfragen pro IP-Adresse
|
||||||
|
- **Zeitfenster:** 10 Minuten
|
||||||
|
- **Verhalten:** Nach 5 Anfragen muss der Nutzer 10 Minuten warten
|
||||||
|
|
||||||
|
## Wie es funktioniert
|
||||||
|
|
||||||
|
Das Rate-Limiting prüft **beide** Kriterien:
|
||||||
|
1. **E-Mail-Adresse:** Verhindert, dass dieselbe Person mit derselben E-Mail zu viele Anfragen stellt
|
||||||
|
2. **IP-Adresse:** Verhindert, dass jemand mit verschiedenen E-Mail-Adressen von derselben IP aus spammt
|
||||||
|
|
||||||
|
Wenn eines der Limits überschritten wird, erhält der Nutzer eine Fehlermeldung mit Angabe der Wartezeit.
|
||||||
|
|
||||||
|
## IP-Erkennung
|
||||||
|
|
||||||
|
Das System erkennt die Client-IP auch hinter Proxies und Load Balancern durch folgende Headers:
|
||||||
|
- `x-forwarded-for`
|
||||||
|
- `x-real-ip`
|
||||||
|
- `cf-connecting-ip` (Cloudflare)
|
||||||
|
|
||||||
|
## Implementierung
|
||||||
|
|
||||||
|
- **Speicherung:** In-Memory (Map)
|
||||||
|
- **Cleanup:** Automatisches Aufräumen alter Einträge alle 10 Minuten
|
||||||
|
- **Skalierung:** Für Produktionsumgebungen mit mehreren Server-Instanzen sollte Redis o.ä. verwendet werden
|
||||||
|
|
||||||
|
## Anpassung
|
||||||
|
|
||||||
|
Die Limits können in `src/server/lib/rate-limiter.ts` in der Funktion `checkBookingRateLimit()` angepasst werden:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// E-Mail-Limit anpassen
|
||||||
|
const emailConfig: RateLimitConfig = {
|
||||||
|
maxRequests: 3, // Anzahl der Anfragen
|
||||||
|
windowMs: 60 * 60 * 1000, // Zeitfenster in Millisekunden
|
||||||
|
};
|
||||||
|
|
||||||
|
// IP-Limit anpassen
|
||||||
|
const ipConfig: RateLimitConfig = {
|
||||||
|
maxRequests: 5, // Anzahl der Anfragen
|
||||||
|
windowMs: 10 * 60 * 1000, // Zeitfenster in Millisekunden
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fehlermeldungen
|
||||||
|
|
||||||
|
Bei Überschreitung des Limits erhält der Nutzer folgende Meldung:
|
||||||
|
```
|
||||||
|
Zu viele Buchungsanfragen. Bitte versuche es in X Minuten erneut.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Produktions-Empfehlungen
|
||||||
|
|
||||||
|
Für Produktionsumgebungen empfehlen sich:
|
||||||
|
- ✅ Redis als verteilter Speicher für Rate-Limit-Daten
|
||||||
|
- ✅ Überwachung der Rate-Limit-Trigger (Logging/Monitoring)
|
||||||
|
- ✅ Whitelist für vertrauenswürdige IPs (z.B. Admin-Zugang)
|
||||||
|
- ✅ Anpassung der Limits basierend auf tatsächlichem Nutzungsverhalten
|
||||||
|
|
||||||
362
docs/test-checkliste-statusseite.md
Normal file
362
docs/test-checkliste-statusseite.md
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
# Blackbox Test-Checkliste: Kunden-Statusseite
|
||||||
|
|
||||||
|
**Branch:** `Statusseite`
|
||||||
|
**Feature:** Token-basierte Buchungsübersicht mit integrierter Stornierung
|
||||||
|
**Datum:** 2025-10-01
|
||||||
|
|
||||||
|
## Vorbereitung
|
||||||
|
|
||||||
|
- [ ] Entwicklungsserver läuft (`pnpm dev`)
|
||||||
|
- [ ] E-Mail-Service konfiguriert (RESEND_API_KEY gesetzt)
|
||||||
|
- [ ] Admin-Account verfügbar
|
||||||
|
- [ ] Testbehandlungen vorhanden
|
||||||
|
- [ ] Verfügbare Slots erstellt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Buchungserstellung & Token-Generierung
|
||||||
|
|
||||||
|
### 1.1 Neue Buchung (Status: pending)
|
||||||
|
- [ ] Buchung über Formular erstellen
|
||||||
|
- [ ] Pending-E-Mail erhalten
|
||||||
|
- [ ] Status-Link (`/booking/{token}`) in E-Mail vorhanden
|
||||||
|
- [ ] Button "Status ansehen" vorhanden und korrekt verlinkt
|
||||||
|
- [ ] Link funktioniert beim Klick
|
||||||
|
|
||||||
|
### 1.2 Token-Validierung
|
||||||
|
- [ ] Gültiger Token öffnet Statusseite
|
||||||
|
- [ ] Ungültiger Token zeigt Fehlermeldung
|
||||||
|
- [ ] Abgelaufener Token zeigt entsprechende Meldung
|
||||||
|
- [ ] Token ohne Parameter zeigt Fehler
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Statusseite UI/UX (Allgemein)
|
||||||
|
|
||||||
|
### 2.1 Layout & Design
|
||||||
|
- [ ] Logo wird angezeigt
|
||||||
|
- [ ] Seite ist responsive (Desktop, Tablet, Mobile)
|
||||||
|
- [ ] Alle Texte sind auf Deutsch
|
||||||
|
- [ ] Farben entsprechen dem Branding (Pink/Purple)
|
||||||
|
- [ ] "Zurück zur Startseite" Link funktioniert
|
||||||
|
|
||||||
|
### 2.2 Navigation
|
||||||
|
- [ ] Link zur Startseite funktioniert
|
||||||
|
- [ ] Browser-Zurück-Button funktioniert korrekt
|
||||||
|
- [ ] URL ist teilbar (Copy & Paste)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Status: Pending (⏳ Wartet auf Bestätigung)
|
||||||
|
|
||||||
|
### 3.1 Anzeige
|
||||||
|
- [ ] Status-Badge zeigt "⏳ Wartet auf Bestätigung" (gelb)
|
||||||
|
- [ ] Banner mit gelber Hintergrundfarbe
|
||||||
|
- [ ] Text erklärt, dass Termin geprüft wird
|
||||||
|
- [ ] Alle Termin-Details werden angezeigt:
|
||||||
|
- [ ] Datum (Format: dd.mm.yyyy)
|
||||||
|
- [ ] Uhrzeit
|
||||||
|
- [ ] Behandlung
|
||||||
|
- [ ] Dauer in Minuten
|
||||||
|
- [ ] Preis (wenn > 0)
|
||||||
|
- [ ] Kundendaten werden angezeigt:
|
||||||
|
- [ ] Name
|
||||||
|
- [ ] E-Mail
|
||||||
|
- [ ] Telefon
|
||||||
|
- [ ] Notizen (falls vorhanden)
|
||||||
|
|
||||||
|
### 3.2 Stornierung
|
||||||
|
- [ ] Stornierungsbereich ist NICHT sichtbar (pending kann nicht storniert werden)
|
||||||
|
- [ ] Keine Stornierungsbuttons vorhanden
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Status: Confirmed (✓ Bestätigt)
|
||||||
|
|
||||||
|
### 4.1 Statuswechsel
|
||||||
|
- [ ] Admin bestätigt Buchung
|
||||||
|
- [ ] Confirmed-E-Mail wird versendet
|
||||||
|
- [ ] E-Mail enthält "Termin verwalten" Button
|
||||||
|
- [ ] Link in E-Mail zeigt auf `/booking/{token}`
|
||||||
|
- [ ] ICS-Datei ist im E-Mail-Anhang
|
||||||
|
- [ ] AGB-PDF ist im E-Mail-Anhang
|
||||||
|
|
||||||
|
### 4.2 Anzeige
|
||||||
|
- [ ] Status-Badge zeigt "✓ Bestätigt" (grün)
|
||||||
|
- [ ] Banner mit grüner Hintergrundfarbe
|
||||||
|
- [ ] Text bestätigt den Termin
|
||||||
|
- [ ] "Verbleibende Zeit" wird angezeigt (wenn Zukunft)
|
||||||
|
- [ ] Stunden bis zum Termin werden berechnet und angezeigt
|
||||||
|
|
||||||
|
### 4.3 Stornierung (wenn möglich)
|
||||||
|
- [ ] Stornierungsbereich ist sichtbar
|
||||||
|
- [ ] Hinweistext zur Stornierungsfrist wird angezeigt
|
||||||
|
- [ ] Button "Termin stornieren" ist vorhanden
|
||||||
|
- [ ] Klick auf Button zeigt Bestätigungsdialog
|
||||||
|
- [ ] Bestätigungsdialog enthält:
|
||||||
|
- [ ] Warnhinweis in rot
|
||||||
|
- [ ] "Ja, stornieren" Button
|
||||||
|
- [ ] "Abbrechen" Button
|
||||||
|
- [ ] "Abbrechen" schließt Dialog ohne Aktion
|
||||||
|
- [ ] "Ja, stornieren" führt Stornierung durch
|
||||||
|
- [ ] Nach Stornierung: Erfolgsmeldung wird angezeigt
|
||||||
|
- [ ] Nach Stornierung: Status aktualisiert sich
|
||||||
|
|
||||||
|
### 4.4 Stornierung (nicht mehr möglich)
|
||||||
|
- [ ] Termin < 24h (oder MIN_STORNO_TIMESPAN): Kein Stornierungsbutton
|
||||||
|
- [ ] Gelber Hinweiskasten wird angezeigt
|
||||||
|
- [ ] Text erklärt, dass Frist abgelaufen ist
|
||||||
|
- [ ] Kontakthinweis wird angezeigt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Status: Cancelled (✕ Storniert)
|
||||||
|
|
||||||
|
### 5.1 Nach Stornierung
|
||||||
|
- [ ] Status-Badge zeigt "✕ Storniert" (rot)
|
||||||
|
- [ ] Banner mit roter Hintergrundfarbe
|
||||||
|
- [ ] Text erklärt, dass Termin storniert wurde
|
||||||
|
- [ ] Hinweis auf Neubuchung wird angezeigt
|
||||||
|
- [ ] Stornierungsbereich ist nicht mehr sichtbar
|
||||||
|
|
||||||
|
### 5.2 Cancelled-E-Mail
|
||||||
|
- [ ] E-Mail wird an Kunden gesendet
|
||||||
|
- [ ] E-Mail enthält storniertes Datum
|
||||||
|
- [ ] E-Mail enthält Hinweis auf Neubuchung
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Status: Completed (✓ Abgeschlossen)
|
||||||
|
|
||||||
|
### 6.1 Anzeige
|
||||||
|
- [ ] Status-Badge zeigt "✓ Abgeschlossen" (grau)
|
||||||
|
- [ ] Banner mit grauer Hintergrundfarbe
|
||||||
|
- [ ] Dankestext wird angezeigt
|
||||||
|
- [ ] Stornierungsbereich ist nicht sichtbar
|
||||||
|
- [ ] Alle Termin-Details bleiben sichtbar
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. E-Mail-Integration
|
||||||
|
|
||||||
|
### 7.1 Pending-Mail
|
||||||
|
- [ ] Betreff: "Deine Terminanfrage ist eingegangen"
|
||||||
|
- [ ] Enthält orangefarbenen "Status ansehen" Button
|
||||||
|
- [ ] Link funktioniert
|
||||||
|
- [ ] Text erklärt, dass Bestätigung folgt
|
||||||
|
- [ ] Rechtliche Informationen enthalten
|
||||||
|
|
||||||
|
### 7.2 Confirmed-Mail
|
||||||
|
- [ ] Betreff: "Dein Termin wurde bestätigt - AGB im Anhang"
|
||||||
|
- [ ] Enthält pinken "Termin ansehen & verwalten" Button (statt rot "Termin stornieren")
|
||||||
|
- [ ] Link zeigt auf `/booking/{token}`
|
||||||
|
- [ ] ICS-Datei im Anhang
|
||||||
|
- [ ] ICS-Datei hat korrekten Namen: "Termin_Stargirlnails.ics"
|
||||||
|
- [ ] ICS-Datei kann in Kalender importiert werden
|
||||||
|
- [ ] AGB-PDF im Anhang
|
||||||
|
- [ ] AGB-PDF heißt "AGB_Stargirlnails_Kiel.pdf"
|
||||||
|
|
||||||
|
### 7.3 Cancelled-Mail
|
||||||
|
- [ ] Betreff: "Dein Termin wurde abgesagt"
|
||||||
|
- [ ] Text erklärt Stornierung
|
||||||
|
- [ ] Hinweis auf Neubuchung
|
||||||
|
- [ ] Rechtliche Informationen enthalten
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. ICS-Kalendereinträge
|
||||||
|
|
||||||
|
### 8.1 ICS-Datei Inhalt
|
||||||
|
- [ ] Zeitzone: Europe/Berlin
|
||||||
|
- [ ] Startzeit korrekt
|
||||||
|
- [ ] Endzeit korrekt (Startzeit + Behandlungsdauer)
|
||||||
|
- [ ] Titel: "{Behandlung} - Stargirlnails Kiel"
|
||||||
|
- [ ] Location: "Stargirlnails Kiel"
|
||||||
|
- [ ] Beschreibung enthalten
|
||||||
|
- [ ] 24h-Erinnerung konfiguriert
|
||||||
|
|
||||||
|
### 8.2 Kalender-Import
|
||||||
|
- [ ] Outlook: Import funktioniert
|
||||||
|
- [ ] Google Calendar: Import funktioniert (wenn möglich zu testen)
|
||||||
|
- [ ] Apple Calendar: Import funktioniert (wenn möglich zu testen)
|
||||||
|
- [ ] Termin erscheint im Kalender mit korrekter Zeit
|
||||||
|
- [ ] Erinnerung wird 24h vorher ausgelöst
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Stornierungslogik
|
||||||
|
|
||||||
|
### 9.1 Zeitbasierte Validierung
|
||||||
|
- [ ] Termin > 24h in Zukunft: Stornierung möglich
|
||||||
|
- [ ] Termin < 24h in Zukunft: Stornierung nicht möglich
|
||||||
|
- [ ] Termin in Vergangenheit: Stornierung nicht möglich
|
||||||
|
- [ ] Korrekte Berechnung der verbleibenden Stunden
|
||||||
|
|
||||||
|
### 9.2 Statusbasierte Validierung
|
||||||
|
- [ ] Status "pending": Keine Stornierung möglich
|
||||||
|
- [ ] Status "confirmed": Stornierung möglich (wenn Zeitfrist OK)
|
||||||
|
- [ ] Status "cancelled": Keine Stornierung möglich
|
||||||
|
- [ ] Status "completed": Keine Stornierung möglich
|
||||||
|
|
||||||
|
### 9.3 Stornierungsablauf
|
||||||
|
- [ ] Bestätigungsdialog erscheint
|
||||||
|
- [ ] Loading-Spinner während Stornierung
|
||||||
|
- [ ] Erfolgsmeldung nach Stornierung
|
||||||
|
- [ ] Fehlermeldung bei Fehler
|
||||||
|
- [ ] Token wird NICHT invalidiert nach Stornierung
|
||||||
|
- [ ] Slot wird wieder freigegeben
|
||||||
|
- [ ] Status aktualisiert sich auf Seite
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Fehlerbehandlung
|
||||||
|
|
||||||
|
### 10.1 Ungültige Token
|
||||||
|
- [ ] Nicht existierender Token: Fehlermeldung
|
||||||
|
- [ ] Abgelaufener Token: Fehlermeldung
|
||||||
|
- [ ] Leerer Token: Fehlermeldung
|
||||||
|
- [ ] Fehlermeldung ist benutzerfreundlich (Deutsch)
|
||||||
|
|
||||||
|
### 10.2 Netzwerkfehler
|
||||||
|
- [ ] API nicht erreichbar: Fehlermeldung
|
||||||
|
- [ ] Timeout: Fehlermeldung
|
||||||
|
- [ ] Fehler während Stornierung: Fehlermeldung bleibt sichtbar
|
||||||
|
|
||||||
|
### 10.3 Validierungsfehler
|
||||||
|
- [ ] Stornierung außerhalb Frist: Klare Fehlermeldung
|
||||||
|
- [ ] Bereits stornierter Termin: Fehlermeldung
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Performance & Ladezeiten
|
||||||
|
|
||||||
|
- [ ] Statusseite lädt in < 2 Sekunden
|
||||||
|
- [ ] Keine sichtbaren Layout-Shifts
|
||||||
|
- [ ] Loading-Spinner wird während Laden angezeigt
|
||||||
|
- [ ] Bilder werden optimiert geladen
|
||||||
|
- [ ] Keine JavaScript-Fehler in Browser-Console
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Accessibility & Browser-Kompatibilität
|
||||||
|
|
||||||
|
### 12.1 Accessibility
|
||||||
|
- [ ] Buttons sind mit Tastatur erreichbar
|
||||||
|
- [ ] Fokus-Indikatoren sind sichtbar
|
||||||
|
- [ ] Farbkontraste sind ausreichend (WCAG AA)
|
||||||
|
- [ ] Alt-Texte für Bilder vorhanden
|
||||||
|
|
||||||
|
### 12.2 Browser
|
||||||
|
- [ ] Chrome (aktuell): Funktioniert
|
||||||
|
- [ ] Firefox (aktuell): Funktioniert
|
||||||
|
- [ ] Safari (wenn möglich): Funktioniert
|
||||||
|
- [ ] Edge (aktuell): Funktioniert
|
||||||
|
- [ ] Mobile Browser (iOS/Android): Funktioniert
|
||||||
|
|
||||||
|
### 12.3 Responsive Design
|
||||||
|
- [ ] Desktop (>1024px): Layout korrekt
|
||||||
|
- [ ] Tablet (768px-1024px): Layout korrekt
|
||||||
|
- [ ] Mobile (320px-767px): Layout korrekt
|
||||||
|
- [ ] Touch-Targets ausreichend groß (min. 44x44px)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Sicherheit
|
||||||
|
|
||||||
|
### 13.1 Token-Sicherheit
|
||||||
|
- [ ] Token ist ausreichend lang (UUID)
|
||||||
|
- [ ] Token ist nicht vorhersagbar
|
||||||
|
- [ ] Token läuft nach 30 Tagen ab
|
||||||
|
- [ ] Abgelaufene Token werden abgelehnt
|
||||||
|
|
||||||
|
### 13.2 Datenschutz
|
||||||
|
- [ ] Keine sensiblen Daten in URLs außer Token
|
||||||
|
- [ ] Keine Kundendaten in Browser-Console geloggt
|
||||||
|
- [ ] E-Mail-Adressen werden nicht im Klartext im HTML angezeigt
|
||||||
|
- [ ] Telefonnummern geschützt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Rückwärts-Kompatibilität
|
||||||
|
|
||||||
|
### 14.1 Legacy-Routen
|
||||||
|
- [ ] `/cancel/{token}` funktioniert NICHT (bewusst entfernt)
|
||||||
|
- [ ] Alte Links in E-Mails zeigen auf neue Route
|
||||||
|
- [ ] Bestehende Tokens funktionieren weiterhin
|
||||||
|
|
||||||
|
### 14.2 Datenbank-Kompatibilität
|
||||||
|
- [ ] Alte Buchungen werden korrekt angezeigt
|
||||||
|
- [ ] Alte Tokens funktionieren mit neuem Code
|
||||||
|
- [ ] Migration ist nicht erforderlich
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Edge Cases
|
||||||
|
|
||||||
|
### 15.1 Extremwerte
|
||||||
|
- [ ] Sehr lange Behandlungsnamen werden korrekt dargestellt
|
||||||
|
- [ ] Sehr lange Notizen werden korrekt dargestellt
|
||||||
|
- [ ] Sehr hohe Preise werden korrekt formatiert
|
||||||
|
- [ ] Termin weit in Zukunft (> 30 Tage): Token abgelaufen
|
||||||
|
|
||||||
|
### 15.2 Sonderfälle
|
||||||
|
- [ ] Termin heute um 23:59: Stornierungsfrist korrekt berechnet
|
||||||
|
- [ ] Termin während Sommerzeit/Winterzeit-Wechsel: Korrekt
|
||||||
|
- [ ] Mehrere Buchungen desselben Kunden: Jede hat eigenen Token
|
||||||
|
- [ ] Gleichzeitiger Zugriff auf Statusseite: Kein Konflikt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Integration mit bestehendem System
|
||||||
|
|
||||||
|
### 16.1 Admin-Panel
|
||||||
|
- [ ] Buchungen werden im Admin-Panel angezeigt
|
||||||
|
- [ ] Statusänderungen im Admin-Panel reflektieren sich auf Statusseite
|
||||||
|
- [ ] Admin erhält weiterhin E-Mail-Benachrichtigungen
|
||||||
|
|
||||||
|
### 16.2 Slot-Management
|
||||||
|
- [ ] Stornierung gibt Slot frei
|
||||||
|
- [ ] Freigegebener Slot ist sofort buchbar
|
||||||
|
- [ ] Keine Slot-Konflikte
|
||||||
|
|
||||||
|
### 16.3 E-Mail-System
|
||||||
|
- [ ] E-Mails werden zuverlässig versendet
|
||||||
|
- [ ] Anhänge werden korrekt zugestellt
|
||||||
|
- [ ] CC an Admin funktioniert (bei confirmed)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testergebnisse
|
||||||
|
|
||||||
|
**Getestet von:** _________________
|
||||||
|
**Datum:** _________________
|
||||||
|
**Browser/Gerät:** _________________
|
||||||
|
|
||||||
|
### Kritische Fehler (Blocker)
|
||||||
|
```
|
||||||
|
(Hier kritische Fehler eintragen, die Release verhindern)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mittelschwere Fehler
|
||||||
|
```
|
||||||
|
(Hier mittelschwere Fehler eintragen, die behoben werden sollten)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kleinere Probleme
|
||||||
|
```
|
||||||
|
(Hier kleinere Verbesserungsvorschläge eintragen)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gesamtbewertung
|
||||||
|
- [ ] ✅ Alle Tests bestanden - Release-fähig
|
||||||
|
- [ ] ⚠️ Tests bestanden mit kleineren Problemen
|
||||||
|
- [ ] ❌ Kritische Fehler gefunden - Nachbesserung erforderlich
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notizen
|
||||||
|
|
||||||
|
```
|
||||||
|
(Hier zusätzliche Beobachtungen und Anmerkungen eintragen)
|
||||||
|
```
|
||||||
|
|
||||||
19
index.html
Normal file
19
index.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
|
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#ec4899" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Stargirlnails" />
|
||||||
|
<title>Stargirlnails Kiel - Terminbuchung</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/client/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,17 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "quests-template-basic",
|
"name": "quests-template-basic",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.1.5.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"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
122
pnpm-lock.yaml
generated
@@ -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):
|
||||||
|
|||||||
45
public/icons/README.md
Normal file
45
public/icons/README.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# PWA Icons Required
|
||||||
|
|
||||||
|
This directory must contain the following icon files for proper PWA installation on iOS and Android devices:
|
||||||
|
|
||||||
|
## Required Icon Files
|
||||||
|
|
||||||
|
### Android Icons
|
||||||
|
- **icon-192x192.png** (192×192 pixels)
|
||||||
|
- Used for Android home screen and app drawer
|
||||||
|
- Should have transparent background or match theme color
|
||||||
|
- Include safe zone padding for maskable icons (40px margin)
|
||||||
|
|
||||||
|
- **icon-512x512.png** (512×512 pixels)
|
||||||
|
- Used for Android splash screens and high-resolution displays
|
||||||
|
- Should have transparent background or match theme color
|
||||||
|
- Include safe zone padding for maskable icons (102px margin)
|
||||||
|
|
||||||
|
### iOS Icon
|
||||||
|
- **apple-touch-icon.png** (180×180 pixels)
|
||||||
|
- Used for iOS home screen
|
||||||
|
- Should NOT have transparent background (iOS adds its own rounded corners)
|
||||||
|
- Fill entire canvas with brand colors/logo
|
||||||
|
- iOS automatically applies rounded corners and shadow
|
||||||
|
|
||||||
|
## Design Guidelines
|
||||||
|
|
||||||
|
1. **Brand consistency**: Use Stargirlnails logo and brand colors
|
||||||
|
2. **Theme color**: Primary pink (#ec4899) matches manifest theme_color
|
||||||
|
3. **Contrast**: Ensure icon is visible on various backgrounds
|
||||||
|
4. **Simplicity**: Icons should be recognizable at small sizes
|
||||||
|
5. **No text**: Avoid small text that becomes unreadable when scaled
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
After adding icons:
|
||||||
|
- Test on Android: Check home screen icon appearance
|
||||||
|
- Test on iOS Safari: Add to home screen and verify icon quality
|
||||||
|
- Validate with Lighthouse PWA audit
|
||||||
|
|
||||||
|
## Placeholder
|
||||||
|
|
||||||
|
Until actual icons are created, you can use a favicon.png (if available) or generate placeholder icons using tools like:
|
||||||
|
- https://realfavicongenerator.net/
|
||||||
|
- https://www.pwabuilder.com/imageGenerator
|
||||||
|
|
||||||
BIN
public/icons/apple-touch-icon.png
Normal file
BIN
public/icons/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
public/icons/icon-192x192.png
Normal file
BIN
public/icons/icon-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
BIN
public/icons/icon-512x512.png
Normal file
BIN
public/icons/icon-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
32
public/manifest.json
Normal file
32
public/manifest.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "Stargirlnails Kiel - Terminbuchung",
|
||||||
|
"short_name": "Stargirlnails",
|
||||||
|
"description": "Online Terminbuchung für Nagelstudio Stargirlnails in Kiel",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"theme_color": "#ec4899",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"lang": "de",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/apple-touch-icon.png",
|
||||||
|
"sizes": "180x180",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
6
scripts/rebuild-dev.cmd
Normal file
6
scripts/rebuild-dev.cmd
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
@echo off
|
||||||
|
docker compose -f docker-compose.yml down
|
||||||
|
git pull
|
||||||
|
docker compose -f docker-compose.yml build
|
||||||
|
docker compose -f docker-compose.yml up -d
|
||||||
|
docker compose -f docker-compose.yml logs -f stargirlnails
|
||||||
5
scripts/rebuild-dev.sh
Executable file
5
scripts/rebuild-dev.sh
Executable file
@@ -0,0 +1,5 @@
|
|||||||
|
#! /bin/bash
|
||||||
|
docker compose -f docker-compose.yml down
|
||||||
|
docker compose -f docker-compose.yml build --no-cache
|
||||||
|
docker compose -f docker-compose.yml up -d
|
||||||
|
# docker compose -f docker-compose.yml logs -f stargirlnails
|
||||||
49
scripts/rebuild-prod.sh
Normal file
49
scripts/rebuild-prod.sh
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
#! /bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Usage: ./scripts/rebuild-prod.sh [branch]
|
||||||
|
# Default branch is current; pass a branch to checkout before pulling/building.
|
||||||
|
|
||||||
|
COMPOSE_FILE=docker-compose-prod.yml
|
||||||
|
|
||||||
|
echo "[1/7] Git: Fetch & pull latest changes"
|
||||||
|
if [ "${1-}" != "" ]; then
|
||||||
|
git fetch origin "$1"
|
||||||
|
git checkout "$1"
|
||||||
|
fi
|
||||||
|
git pull --rebase
|
||||||
|
|
||||||
|
echo "[2/7] Stop and remove running services (including orphans)"
|
||||||
|
sudo docker compose -f "$COMPOSE_FILE" down --remove-orphans || true
|
||||||
|
|
||||||
|
echo "[3/7] Pull base images (e.g., caddy)"
|
||||||
|
sudo docker compose -f "$COMPOSE_FILE" pull || true
|
||||||
|
|
||||||
|
echo "[4/7] Build application image without cache"
|
||||||
|
sudo docker compose -f "$COMPOSE_FILE" build --no-cache
|
||||||
|
|
||||||
|
echo "[5/7] Start services in background"
|
||||||
|
sudo docker compose -f "$COMPOSE_FILE" up -d
|
||||||
|
|
||||||
|
echo "[6/7] Wait for app healthcheck to pass"
|
||||||
|
# Wait up to ~90s for healthy status using docker inspect (no jq dependency)
|
||||||
|
for i in {1..18}; do
|
||||||
|
# Check health status if available
|
||||||
|
HEALTH=$(sudo docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' stargirlnails-app 2>/dev/null || true)
|
||||||
|
if [ "$HEALTH" = "healthy" ]; then
|
||||||
|
echo "Service is healthy."
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fallback: ensure container is running
|
||||||
|
STATE=$(sudo docker inspect -f '{{.State.Status}}' stargirlnails-app 2>/dev/null || true)
|
||||||
|
if [ "$STATE" = "running" ] && [ -z "$HEALTH" ]; then
|
||||||
|
echo "Service is running (no healthcheck reported)."
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[7/7] Tail recent logs (press Ctrl+C to exit)"
|
||||||
|
sudo docker compose -f "$COMPOSE_FILE" logs --since=10m -f
|
||||||
103
scripts/setup-caddy.sh
Normal file
103
scripts/setup-caddy.sh
Normal 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}"
|
||||||
74
server-dist/index.js
Normal file
74
server-dist/index.js
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { serve } from '@hono/node-server';
|
||||||
|
import { serveStatic } from '@hono/node-server/serve-static';
|
||||||
|
import { rpcApp } from "./routes/rpc.js";
|
||||||
|
import { caldavApp } from "./routes/caldav.js";
|
||||||
|
import { clientEntry } from "./routes/client-entry.js";
|
||||||
|
const app = new Hono();
|
||||||
|
// Allow all hosts for Tailscale Funnel
|
||||||
|
app.use("*", async (c, next) => {
|
||||||
|
// Accept requests from any host
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
// Health check endpoint
|
||||||
|
app.get("/health", (c) => {
|
||||||
|
return c.json({ status: "ok", timestamp: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
// Legal config endpoint (temporary fix for RPC issue)
|
||||||
|
app.get("/api/legal-config", async (c) => {
|
||||||
|
try {
|
||||||
|
const { getLegalConfig } = await import("./lib/legal-config.js");
|
||||||
|
const config = getLegalConfig();
|
||||||
|
return c.json(config);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error("Legal config error:", error);
|
||||||
|
return c.json({ error: "Failed to load legal config" }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Security.txt endpoint (RFC 9116)
|
||||||
|
app.get("/.well-known/security.txt", (c) => {
|
||||||
|
const securityContact = process.env.SECURITY_CONTACT || "security@example.com";
|
||||||
|
const securityText = `Contact: ${securityContact}
|
||||||
|
Expires: 2025-12-31T23:59:59.000Z
|
||||||
|
Preferred-Languages: de, en
|
||||||
|
Canonical: https://${process.env.DOMAIN || 'localhost:5173'}/.well-known/security.txt
|
||||||
|
|
||||||
|
# Security Policy
|
||||||
|
# Please report security vulnerabilities responsibly by contacting us via email.
|
||||||
|
# We will respond to security reports within 48 hours.
|
||||||
|
#
|
||||||
|
# Scope: This security policy applies to the Stargirlnails booking system.
|
||||||
|
#
|
||||||
|
# Rewards: We appreciate security researchers who help us improve our security.
|
||||||
|
# While we don't have a formal bug bounty program, we may offer recognition
|
||||||
|
# for significant security improvements.
|
||||||
|
`;
|
||||||
|
return c.text(securityText, 200, {
|
||||||
|
"Content-Type": "text/plain; charset=utf-8",
|
||||||
|
"Cache-Control": "public, max-age=86400", // Cache for 24 hours
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// Serve static files (only in production)
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
app.use('/static/*', serveStatic({ root: './dist' }));
|
||||||
|
app.use('/assets/*', serveStatic({ root: './dist' }));
|
||||||
|
}
|
||||||
|
app.use('/favicon.png', serveStatic({ path: './public/favicon.png' }));
|
||||||
|
app.use('/AGB.pdf', serveStatic({ path: './public/AGB.pdf' }));
|
||||||
|
app.use('/icons/*', serveStatic({ root: './public' }));
|
||||||
|
app.use('/manifest.json', serveStatic({ path: './public/manifest.json' }));
|
||||||
|
app.route("/rpc", rpcApp);
|
||||||
|
app.route("/caldav", caldavApp);
|
||||||
|
app.get("/*", clientEntry);
|
||||||
|
// Start server
|
||||||
|
const port = process.env.PORT ? parseInt(process.env.PORT) : 3000;
|
||||||
|
const host = process.env.HOST || "0.0.0.0";
|
||||||
|
console.log(`🚀 Server starting on ${host}:${port}`);
|
||||||
|
// Start the server
|
||||||
|
serve({
|
||||||
|
fetch: app.fetch,
|
||||||
|
port,
|
||||||
|
hostname: host,
|
||||||
|
});
|
||||||
|
export default app;
|
||||||
13
server-dist/lib/auth.js
Normal file
13
server-dist/lib/auth.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { createKV } from "./create-kv.js";
|
||||||
|
export const sessionsKV = createKV("sessions");
|
||||||
|
export const usersKV = createKV("users");
|
||||||
|
export async function assertOwner(sessionId) {
|
||||||
|
const session = await sessionsKV.getItem(sessionId);
|
||||||
|
if (!session)
|
||||||
|
throw new Error("Invalid session");
|
||||||
|
if (new Date(session.expiresAt) < new Date())
|
||||||
|
throw new Error("Session expired");
|
||||||
|
const user = await usersKV.getItem(session.userId);
|
||||||
|
if (!user || user.role !== "owner")
|
||||||
|
throw new Error("Forbidden");
|
||||||
|
}
|
||||||
33
server-dist/lib/create-kv.js
Normal file
33
server-dist/lib/create-kv.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { createStorage } from "unstorage";
|
||||||
|
import fsDriver from "unstorage/drivers/fs";
|
||||||
|
const STORAGE_PATH = "./.storage"; // It is .gitignored
|
||||||
|
export function createKV(name) {
|
||||||
|
const storage = createStorage({
|
||||||
|
driver: fsDriver({ base: `${STORAGE_PATH}/${name}` }),
|
||||||
|
});
|
||||||
|
// Async generator to play work well with oRPC live queries
|
||||||
|
async function* subscribe() {
|
||||||
|
let resolve;
|
||||||
|
let promise = new Promise((r) => (resolve = r));
|
||||||
|
const unwatch = await storage.watch((event, key) => {
|
||||||
|
resolve({ event, key });
|
||||||
|
promise = new Promise((r) => (resolve = r));
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
while (true)
|
||||||
|
yield await promise;
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
await unwatch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...storage,
|
||||||
|
getAllItems: async () => {
|
||||||
|
const keys = await storage.getKeys();
|
||||||
|
const values = await storage.getItems(keys);
|
||||||
|
return values.map(({ value }) => value);
|
||||||
|
},
|
||||||
|
subscribe,
|
||||||
|
};
|
||||||
|
}
|
||||||
317
server-dist/lib/email-templates.js
Normal file
317
server-dist/lib/email-templates.js
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
// Helper function to convert date from yyyy-mm-dd to dd.mm.yyyy
|
||||||
|
function formatDateGerman(dateString) {
|
||||||
|
const [year, month, day] = dateString.split('-');
|
||||||
|
return `${day}.${month}.${year}`;
|
||||||
|
}
|
||||||
|
let cachedLogoDataUrl = null;
|
||||||
|
async function getLogoDataUrl() {
|
||||||
|
if (cachedLogoDataUrl)
|
||||||
|
return cachedLogoDataUrl;
|
||||||
|
try {
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
const logoPath = resolve(__dirname, "../../../assets/stargilnails_logo_transparent.png");
|
||||||
|
const buf = await readFile(logoPath);
|
||||||
|
const base64 = buf.toString("base64");
|
||||||
|
cachedLogoDataUrl = `data:image/png;base64,${base64}`;
|
||||||
|
return cachedLogoDataUrl;
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function renderBrandedEmail(title, bodyHtml) {
|
||||||
|
const logo = await getLogoDataUrl();
|
||||||
|
const domain = process.env.DOMAIN || 'localhost:5173';
|
||||||
|
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||||
|
const homepageUrl = `${protocol}://${domain}`;
|
||||||
|
const instagramProfile = process.env.INSTAGRAM_PROFILE;
|
||||||
|
const tiktokProfile = process.env.TIKTOK_PROFILE;
|
||||||
|
const companyName = process.env.COMPANY_NAME || 'Stargirlnails Kiel';
|
||||||
|
return `
|
||||||
|
<div style="font-family: Arial, sans-serif; color: #0f172a; background:#fdf2f8; padding:24px;">
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:640px; margin:0 auto; background:#ffffff; border-radius:12px; overflow:hidden; box-shadow:0 1px 3px rgba(0,0,0,0.06)">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:24px 24px 0 24px; text-align:center;">
|
||||||
|
${logo ? `<img src="${logo}" alt="${companyName}" style="width:120px; height:auto; display:inline-block;" />` : `<div style=\"font-size:24px\">💅</div>`}
|
||||||
|
<div style="margin:16px 0 4px 0; font-size:16px; font-weight:600; color:#64748b;">${companyName}</div>
|
||||||
|
<h1 style="margin:0; font-size:22px; color:#db2777;">${title}</h1>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:16px 24px 24px 24px;">
|
||||||
|
<div style="font-size:16px; line-height:1.6; color:#334155;">
|
||||||
|
${bodyHtml}
|
||||||
|
</div>
|
||||||
|
<hr style="border:none; border-top:1px solid #f1f5f9; margin:24px 0" />
|
||||||
|
<div style="text-align:center; margin-bottom:16px;">
|
||||||
|
<a href="${homepageUrl}" style="display: inline-block; background-color: #db2777; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 14px;">Zur Website</a>
|
||||||
|
</div>
|
||||||
|
${(instagramProfile || tiktokProfile) ? `
|
||||||
|
<div style="text-align:center; margin-bottom:16px;">
|
||||||
|
<p style="font-size:14px; color:#64748b; margin:0 0 8px 0;">Folge uns auf Social Media:</p>
|
||||||
|
<div style="display:inline-block;">
|
||||||
|
${instagramProfile ? `
|
||||||
|
<a href="${instagramProfile}" target="_blank" rel="noopener noreferrer" style="display:inline-block; margin:0 6px; background:linear-gradient(45deg, #f09433 0%,#e6683c 25%,#dc2743 50%,#cc2366 75%,#bc1888 100%); color:white; padding:10px 20px; text-decoration:none; border-radius:20px; font-size:14px; font-weight:600;">
|
||||||
|
<svg width="16" height="16" fill="currentColor" viewBox="0 0 24 24" style="vertical-align:middle; margin-right:6px;">
|
||||||
|
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/>
|
||||||
|
</svg>
|
||||||
|
Instagram
|
||||||
|
</a>
|
||||||
|
` : ''}
|
||||||
|
${tiktokProfile ? `
|
||||||
|
<a href="${tiktokProfile}" target="_blank" rel="noopener noreferrer" style="display:inline-block; margin:0 6px; background:#000000; color:white; padding:10px 20px; text-decoration:none; border-radius:20px; font-size:14px; font-weight:600;">
|
||||||
|
<svg width="16" height="16" fill="currentColor" viewBox="0 0 24 24" style="vertical-align:middle; margin-right:6px;">
|
||||||
|
<path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1-.1z"/>
|
||||||
|
</svg>
|
||||||
|
TikTok
|
||||||
|
</a>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
<div style="font-size:12px; color:#64748b; text-align:center;">
|
||||||
|
© ${new Date().getFullYear()} Stargirlnails Kiel • Professional Nail Care
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
export async function renderBookingPendingHTML(params) {
|
||||||
|
const { name, date, time, statusUrl } = params;
|
||||||
|
const formattedDate = formatDateGerman(date);
|
||||||
|
const domain = process.env.DOMAIN || 'localhost:5173';
|
||||||
|
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||||
|
const legalUrl = `${protocol}://${domain}/legal`;
|
||||||
|
const inner = `
|
||||||
|
<p>Hallo ${name},</p>
|
||||||
|
<p>wir haben deine Anfrage für <strong>${formattedDate}</strong> um <strong>${time}</strong> erhalten.</p>
|
||||||
|
<p>Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.</p>
|
||||||
|
${statusUrl ? `
|
||||||
|
<div style="background-color: #fef9f5; border-left: 4px solid #f59e0b; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||||
|
<p style="margin: 0; font-weight: 600; color: #f59e0b;">⏳ Termin-Status ansehen:</p>
|
||||||
|
<p style="margin: 8px 0 12px 0; color: #475569;">Du kannst den aktuellen Status deiner Buchung jederzeit einsehen:</p>
|
||||||
|
<a href="${statusUrl}" style="display: inline-block; background-color: #f59e0b; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">Status ansehen</a>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
<div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||||
|
<p style="margin: 0; font-weight: 600; color: #3b82f6;">📋 Rechtliche Informationen:</p>
|
||||||
|
<p style="margin: 8px 0 12px 0; color: #475569;">Weitere Informationen findest du in unserem <a href="${legalUrl}" style="color: #3b82f6; text-decoration: underline;">Impressum und Datenschutz</a>.</p>
|
||||||
|
</div>
|
||||||
|
<p>Liebe Grüße,<br/>Stargirlnails Kiel</p>
|
||||||
|
`;
|
||||||
|
return renderBrandedEmail("Deine Terminanfrage ist eingegangen", inner);
|
||||||
|
}
|
||||||
|
export async function renderBookingConfirmedHTML(params) {
|
||||||
|
const { name, date, time, cancellationUrl, reviewUrl } = params;
|
||||||
|
const formattedDate = formatDateGerman(date);
|
||||||
|
const domain = process.env.DOMAIN || 'localhost:5173';
|
||||||
|
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||||
|
const legalUrl = `${protocol}://${domain}/legal`;
|
||||||
|
const inner = `
|
||||||
|
<p>Hallo ${name},</p>
|
||||||
|
<p>wir haben deinen Termin am <strong>${formattedDate}</strong> um <strong>${time}</strong> bestätigt.</p>
|
||||||
|
<p>Wir freuen uns auf dich!</p>
|
||||||
|
<div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||||
|
<p style="margin: 0; font-weight: 600; color: #db2777;">📋 Wichtiger Hinweis:</p>
|
||||||
|
<p style="margin: 8px 0 0 0; color: #475569;">Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.</p>
|
||||||
|
</div>
|
||||||
|
${cancellationUrl ? `
|
||||||
|
<div style="background-color: #fef9f5; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||||
|
<p style="margin: 0; font-weight: 600; color: #db2777;">📅 Termin verwalten:</p>
|
||||||
|
<p style="margin: 8px 0 12px 0; color: #475569;">Du kannst deinen Termin-Status einsehen und bei Bedarf stornieren:</p>
|
||||||
|
<a href="${cancellationUrl}" style="display: inline-block; background-color: #db2777; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">Termin ansehen & verwalten</a>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
${reviewUrl ? `
|
||||||
|
<div style="background-color: #eff6ff; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||||
|
<p style="margin: 0; font-weight: 600; color: #3b82f6;">⭐ Bewertung abgeben:</p>
|
||||||
|
<p style="margin: 8px 0 12px 0; color: #475569;">Nach deinem Termin würden wir uns über deine Bewertung freuen!</p>
|
||||||
|
<a href="${reviewUrl}" style="display: inline-block; background-color: #3b82f6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">Bewertung schreiben</a>
|
||||||
|
<p style="margin: 12px 0 0 0; color: #64748b; font-size: 13px;">Du kannst deine Bewertung nach dem Termin über diesen Link abgeben.</p>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
<div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||||
|
<p style="margin: 0; font-weight: 600; color: #3b82f6;">📋 Rechtliche Informationen:</p>
|
||||||
|
<p style="margin: 8px 0 12px 0; color: #475569;">Weitere Informationen findest du in unserem <a href="${legalUrl}" style="color: #3b82f6; text-decoration: underline;">Impressum und Datenschutz</a>.</p>
|
||||||
|
</div>
|
||||||
|
<p>Liebe Grüße,<br/>Stargirlnails Kiel</p>
|
||||||
|
`;
|
||||||
|
return renderBrandedEmail("Termin bestätigt", inner);
|
||||||
|
}
|
||||||
|
export async function renderBookingCancelledHTML(params) {
|
||||||
|
const { name, date, time } = params;
|
||||||
|
const formattedDate = formatDateGerman(date);
|
||||||
|
const domain = process.env.DOMAIN || 'localhost:5173';
|
||||||
|
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||||
|
const legalUrl = `${protocol}://${domain}/legal`;
|
||||||
|
const inner = `
|
||||||
|
<p>Hallo ${name},</p>
|
||||||
|
<p>dein Termin am <strong>${formattedDate}</strong> um <strong>${time}</strong> wurde abgesagt.</p>
|
||||||
|
<p>Bitte buche einen neuen Termin. Bei Fragen helfen wir dir gerne weiter.</p>
|
||||||
|
<div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||||
|
<p style="margin: 0; font-weight: 600; color: #3b82f6;">📋 Rechtliche Informationen:</p>
|
||||||
|
<p style="margin: 8px 0 12px 0; color: #475569;">Weitere Informationen findest du in unserem <a href="${legalUrl}" style="color: #3b82f6; text-decoration: underline;">Impressum und Datenschutz</a>.</p>
|
||||||
|
</div>
|
||||||
|
<p>Liebe Grüße,<br/>Stargirlnails Kiel</p>
|
||||||
|
`;
|
||||||
|
return renderBrandedEmail("Termin abgesagt", inner);
|
||||||
|
}
|
||||||
|
export async function renderAdminBookingNotificationHTML(params) {
|
||||||
|
const { name, date, time, treatment, phone, notes, hasInspirationPhoto } = params;
|
||||||
|
const formattedDate = formatDateGerman(date);
|
||||||
|
const inner = `
|
||||||
|
<p>Hallo Admin,</p>
|
||||||
|
<p>eine neue Buchungsanfrage ist eingegangen:</p>
|
||||||
|
<div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||||
|
<p style="margin: 0; font-weight: 600; color: #db2777;">📅 Buchungsdetails:</p>
|
||||||
|
<ul style="margin: 8px 0 0 0; color: #475569; list-style: none; padding: 0;">
|
||||||
|
<li><strong>Name:</strong> ${name}</li>
|
||||||
|
<li><strong>Telefon:</strong> ${phone}</li>
|
||||||
|
<li><strong>Behandlung:</strong> ${treatment}</li>
|
||||||
|
<li><strong>Datum:</strong> ${formattedDate}</li>
|
||||||
|
<li><strong>Uhrzeit:</strong> ${time}</li>
|
||||||
|
${notes ? `<li><strong>Notizen:</strong> ${notes}</li>` : ''}
|
||||||
|
<li><strong>Inspiration-Foto:</strong> ${hasInspirationPhoto ? '✅ Im Anhang verfügbar' : '❌ Kein Foto hochgeladen'}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<p>Bitte logge dich in das Admin-Panel ein, um die Buchung zu bestätigen oder abzulehnen.</p>
|
||||||
|
<p>Liebe Grüße,<br/>Stargirlnails System</p>
|
||||||
|
`;
|
||||||
|
return renderBrandedEmail("Neue Buchungsanfrage - Admin-Benachrichtigung", inner);
|
||||||
|
}
|
||||||
|
export async function renderBookingRescheduleProposalHTML(params) {
|
||||||
|
const formattedOriginalDate = formatDateGerman(params.originalDate);
|
||||||
|
const formattedProposedDate = formatDateGerman(params.proposedDate);
|
||||||
|
const expiryDate = new Date(params.expiresAt);
|
||||||
|
const formattedExpiry = `${expiryDate.toLocaleDateString('de-DE')} ${expiryDate.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`;
|
||||||
|
const inner = `
|
||||||
|
<p>Hallo ${params.name},</p>
|
||||||
|
<p>wir müssen deinen Termin leider verschieben. Hier ist unser Vorschlag:</p>
|
||||||
|
<div style="background-color: #f8fafc; border-left: 4px solid #f59e0b; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||||
|
<p style="margin: 0; font-weight: 600; color: #92400e;">📅 Übersicht</p>
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" style="width:100%; margin-top:8px; font-size:14px; color:#475569;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:6px 0; width:45%"><strong>Alter Termin</strong></td>
|
||||||
|
<td style="padding:6px 0;">${formattedOriginalDate} um ${params.originalTime} Uhr</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:6px 0; width:45%"><strong>Neuer Vorschlag</strong></td>
|
||||||
|
<td style="padding:6px 0; color:#b45309;"><strong>${formattedProposedDate} um ${params.proposedTime} Uhr</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:6px 0; width:45%"><strong>Behandlung</strong></td>
|
||||||
|
<td style="padding:6px 0;">${params.treatmentName}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div style="background-color: #fffbeb; border-left: 4px solid #f59e0b; padding: 12px; margin: 16px 0; border-radius: 4px; color:#92400e;">
|
||||||
|
⏰ Bitte antworte bis ${formattedExpiry}.
|
||||||
|
</div>
|
||||||
|
<div style="text-align:center; margin: 20px 0;">
|
||||||
|
<a href="${params.acceptUrl}" style="display:inline-block; background-color:#16a34a; color:#ffffff; padding:12px 18px; border-radius:8px; text-decoration:none; font-weight:700; margin-right:8px;">Neuen Termin akzeptieren</a>
|
||||||
|
<a href="${params.declineUrl}" style="display:inline-block; background-color:#dc2626; color:#ffffff; padding:12px 18px; border-radius:8px; text-decoration:none; font-weight:700;">Termin ablehnen</a>
|
||||||
|
</div>
|
||||||
|
<div style="background-color: #f8fafc; border-left: 4px solid #10b981; padding: 12px; margin: 16px 0; border-radius: 4px; color:#065f46;">
|
||||||
|
Wenn du den Vorschlag ablehnst, bleibt dein ursprünglicher Termin bestehen und wir kontaktieren dich für eine alternative Lösung.
|
||||||
|
</div>
|
||||||
|
<p>Falls du einen komplett neuen Termin buchen möchtest, kannst du deinen aktuellen Termin stornieren und einen neuen Termin auf unserer Website buchen.</p>
|
||||||
|
<p>Liebe Grüße,<br/>Stargirlnails Kiel</p>
|
||||||
|
`;
|
||||||
|
return renderBrandedEmail("Terminänderung vorgeschlagen", inner);
|
||||||
|
}
|
||||||
|
export async function renderAdminRescheduleDeclinedHTML(params) {
|
||||||
|
const inner = `
|
||||||
|
<p>Hallo Admin,</p>
|
||||||
|
<p>der Kunde <strong>${params.customerName}</strong> hat den Terminänderungsvorschlag abgelehnt.</p>
|
||||||
|
<div style="background-color:#f8fafc; border-left:4px solid #ef4444; padding:16px; margin:16px 0; border-radius:4px;">
|
||||||
|
<ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:14px;">
|
||||||
|
<li><strong>Kunde:</strong> ${params.customerName}</li>
|
||||||
|
${params.customerEmail ? `<li><strong>E-Mail:</strong> ${params.customerEmail}</li>` : ''}
|
||||||
|
${params.customerPhone ? `<li><strong>Telefon:</strong> ${params.customerPhone}</li>` : ''}
|
||||||
|
<li><strong>Behandlung:</strong> ${params.treatmentName}</li>
|
||||||
|
<li><strong>Ursprünglicher Termin:</strong> ${formatDateGerman(params.originalDate)} um ${params.originalTime} Uhr (bleibt bestehen)</li>
|
||||||
|
<li><strong>Abgelehnter Vorschlag:</strong> ${formatDateGerman(params.proposedDate)} um ${params.proposedTime} Uhr</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<p>Bitte kontaktiere den Kunden, um eine alternative Lösung zu finden.</p>
|
||||||
|
`;
|
||||||
|
return renderBrandedEmail("Kunde hat Terminänderung abgelehnt", inner);
|
||||||
|
}
|
||||||
|
export async function renderAdminRescheduleAcceptedHTML(params) {
|
||||||
|
const inner = `
|
||||||
|
<p>Hallo Admin,</p>
|
||||||
|
<p>der Kunde <strong>${params.customerName}</strong> hat den Terminänderungsvorschlag akzeptiert.</p>
|
||||||
|
<div style="background-color:#ecfeff; border-left:4px solid #10b981; padding:16px; margin:16px 0; border-radius:4px;">
|
||||||
|
<ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:14px;">
|
||||||
|
<li><strong>Kunde:</strong> ${params.customerName}</li>
|
||||||
|
<li><strong>Behandlung:</strong> ${params.treatmentName}</li>
|
||||||
|
<li><strong>Alter Termin:</strong> ${formatDateGerman(params.originalDate)} um ${params.originalTime} Uhr</li>
|
||||||
|
<li><strong>Neuer Termin:</strong> ${formatDateGerman(params.newDate)} um ${params.newTime} Uhr ✅</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<p>Der Termin wurde automatisch aktualisiert.</p>
|
||||||
|
`;
|
||||||
|
return renderBrandedEmail("Kunde hat Terminänderung akzeptiert", inner);
|
||||||
|
}
|
||||||
|
export async function renderAdminRescheduleExpiredHTML(params) {
|
||||||
|
const inner = `
|
||||||
|
<p>Hallo Admin,</p>
|
||||||
|
<p><strong>${params.expiredProposals.length} Terminänderungsvorschlag${params.expiredProposals.length > 1 ? 'e' : ''} ${params.expiredProposals.length > 1 ? 'sind' : 'ist'} abgelaufen</strong> und wurde${params.expiredProposals.length > 1 ? 'n' : ''} automatisch entfernt.</p>
|
||||||
|
<div style="background-color:#fef2f2; border-left:4px solid #ef4444; padding:16px; margin:16px 0; border-radius:4px;">
|
||||||
|
<p style="margin:0 0 12px 0; font-weight:600; color:#dc2626;">⚠️ Abgelaufene Vorschläge:</p>
|
||||||
|
${params.expiredProposals.map(proposal => `
|
||||||
|
<div style="background-color:#ffffff; border:1px solid #fecaca; border-radius:4px; padding:12px; margin:8px 0;">
|
||||||
|
<ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:13px;">
|
||||||
|
<li><strong>Kunde:</strong> ${proposal.customerName}</li>
|
||||||
|
${proposal.customerEmail ? `<li><strong>E-Mail:</strong> ${proposal.customerEmail}</li>` : ''}
|
||||||
|
${proposal.customerPhone ? `<li><strong>Telefon:</strong> ${proposal.customerPhone}</li>` : ''}
|
||||||
|
<li><strong>Behandlung:</strong> ${proposal.treatmentName}</li>
|
||||||
|
<li><strong>Ursprünglicher Termin:</strong> ${formatDateGerman(proposal.originalDate)} um ${proposal.originalTime} Uhr</li>
|
||||||
|
<li><strong>Vorgeschlagener Termin:</strong> ${formatDateGerman(proposal.proposedDate)} um ${proposal.proposedTime} Uhr</li>
|
||||||
|
<li><strong>Abgelaufen am:</strong> ${new Date(proposal.expiredAt).toLocaleString('de-DE')}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
<p style="color:#dc2626; font-weight:600;">Bitte kontaktiere die Kunden, um eine alternative Lösung zu finden.</p>
|
||||||
|
<p>Die ursprünglichen Termine bleiben bestehen.</p>
|
||||||
|
`;
|
||||||
|
return renderBrandedEmail("Abgelaufene Terminänderungsvorschläge", inner);
|
||||||
|
}
|
||||||
|
export async function renderCustomerMessageHTML(params) {
|
||||||
|
const { customerName, message, appointmentDate, appointmentTime, treatmentName } = params;
|
||||||
|
const formattedDate = appointmentDate ? formatDateGerman(appointmentDate) : null;
|
||||||
|
const domain = process.env.DOMAIN || 'localhost:5173';
|
||||||
|
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||||
|
const legalUrl = `${protocol}://${domain}/legal`;
|
||||||
|
const ownerName = process.env.OWNER_NAME || 'Stargirlnails Kiel';
|
||||||
|
const inner = `
|
||||||
|
<p>Hallo ${customerName},</p>
|
||||||
|
${(appointmentDate && appointmentTime && treatmentName) ? `
|
||||||
|
<div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||||
|
<p style="margin: 0; font-weight: 600; color: #db2777;">📅 Zu deinem Termin:</p>
|
||||||
|
<ul style="margin: 8px 0 0 0; color: #475569; list-style: none; padding: 0;">
|
||||||
|
<li><strong>Behandlung:</strong> ${treatmentName}</li>
|
||||||
|
<li><strong>Datum:</strong> ${formattedDate}</li>
|
||||||
|
<li><strong>Uhrzeit:</strong> ${appointmentTime}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
<div style="background-color: #fef9f5; border-left: 4px solid #f59e0b; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||||
|
<p style="margin: 0; font-weight: 600; color: #f59e0b;">💬 Nachricht von ${ownerName}:</p>
|
||||||
|
<div style="margin: 12px 0 0 0; color: #475569; white-space: pre-wrap; line-height: 1.6;">${message.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')}</div>
|
||||||
|
</div>
|
||||||
|
<p>Bei Fragen oder Anliegen kannst du einfach auf diese E-Mail antworten – wir helfen dir gerne weiter!</p>
|
||||||
|
<div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||||
|
<p style="margin: 0; font-weight: 600; color: #3b82f6;">📋 Rechtliche Informationen:</p>
|
||||||
|
<p style="margin: 8px 0 12px 0; color: #475569;">Weitere Informationen findest du in unserem <a href="${legalUrl}" style="color: #3b82f6; text-decoration: underline;">Impressum und Datenschutz</a>.</p>
|
||||||
|
</div>
|
||||||
|
<p>Liebe Grüße,<br/>${ownerName}</p>
|
||||||
|
`;
|
||||||
|
return renderBrandedEmail("Nachricht zu deinem Termin", inner);
|
||||||
|
}
|
||||||
88
server-dist/lib/email-validator.js
Normal file
88
server-dist/lib/email-validator.js
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
// Email validation using Rapid Email Validator API
|
||||||
|
// API: https://rapid-email-verifier.fly.dev/
|
||||||
|
// Privacy-focused, no data storage, completely free
|
||||||
|
/**
|
||||||
|
* Validate email address using Rapid Email Validator API
|
||||||
|
* Returns true if email is valid, false otherwise
|
||||||
|
*/
|
||||||
|
export async function validateEmail(email) {
|
||||||
|
try {
|
||||||
|
// Call Rapid Email Validator API
|
||||||
|
const response = await fetch(`https://rapid-email-verifier.fly.dev/api/validate?email=${encodeURIComponent(email)}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error(`Email validation API error: ${response.status}`);
|
||||||
|
// If API is down, reject the email with error message
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: 'E-Mail-Validierung ist derzeit nicht verfügbar. Bitte überprüfe deine E-Mail-Adresse und versuche es erneut.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
// Check if email is disposable/temporary
|
||||||
|
if (data.validations.is_disposable) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: 'Temporäre oder Wegwerf-E-Mail-Adressen sind nicht erlaubt. Bitte verwende eine echte E-Mail-Adresse.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Check if MX records exist (deliverable)
|
||||||
|
if (!data.validations.mx_records) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: 'Diese E-Mail-Adresse kann keine E-Mails empfangen. Bitte überprüfe deine E-Mail-Adresse.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Check if domain exists
|
||||||
|
if (!data.validations.domain_exists) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: 'Die E-Mail-Domain existiert nicht. Bitte überprüfe deine E-Mail-Adresse.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Check if email syntax is valid
|
||||||
|
if (!data.validations.syntax) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: 'Ungültige E-Mail-Adresse. Bitte überprüfe die Schreibweise.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Email is valid
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('Email validation error:', error);
|
||||||
|
// If validation fails, reject the email with error message
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: 'E-Mail-Validierung ist derzeit nicht verfügbar. Bitte überprüfe deine E-Mail-Adresse und versuche es erneut.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Batch validate multiple emails
|
||||||
|
* @param emails Array of email addresses to validate
|
||||||
|
* @returns Array of validation results
|
||||||
|
*/
|
||||||
|
export async function validateEmailBatch(emails) {
|
||||||
|
const results = new Map();
|
||||||
|
// Validate up to 100 emails at once (API limit)
|
||||||
|
const batchSize = 100;
|
||||||
|
for (let i = 0; i < emails.length; i += batchSize) {
|
||||||
|
const batch = emails.slice(i, i + batchSize);
|
||||||
|
// Call each validation in parallel for better performance
|
||||||
|
const validations = await Promise.all(batch.map(async (email) => {
|
||||||
|
const result = await validateEmail(email);
|
||||||
|
return { email, result };
|
||||||
|
}));
|
||||||
|
// Store results
|
||||||
|
validations.forEach(({ email, result }) => {
|
||||||
|
results.set(email, result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
191
server-dist/lib/email.js
Normal file
191
server-dist/lib/email.js
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
const RESEND_API_KEY = process.env.RESEND_API_KEY;
|
||||||
|
const DEFAULT_FROM = process.env.EMAIL_FROM || "Stargirlnails <no-reply@stargirlnails.de>";
|
||||||
|
// Helper function to format dates for ICS files (YYYYMMDDTHHMMSS)
|
||||||
|
function formatDateForICS(date, time) {
|
||||||
|
// date is in YYYY-MM-DD format, time is in HH:MM format
|
||||||
|
const [year, month, day] = date.split('-');
|
||||||
|
const [hours, minutes] = time.split(':');
|
||||||
|
return `${year}${month}${day}T${hours}${minutes}00`;
|
||||||
|
}
|
||||||
|
// Helper function to create ICS (iCalendar) file content
|
||||||
|
function createICSFile(params) {
|
||||||
|
const { date, time, durationMinutes, customerName, treatmentName } = params;
|
||||||
|
// Calculate start and end times in Europe/Berlin timezone
|
||||||
|
const dtStart = formatDateForICS(date, time);
|
||||||
|
// Calculate end time
|
||||||
|
const [hours, minutes] = time.split(':').map(Number);
|
||||||
|
const startDate = new Date(`${date}T${time}:00`);
|
||||||
|
const endDate = new Date(startDate.getTime() + durationMinutes * 60000);
|
||||||
|
const endHours = String(endDate.getHours()).padStart(2, '0');
|
||||||
|
const endMinutes = String(endDate.getMinutes()).padStart(2, '0');
|
||||||
|
const dtEnd = formatDateForICS(date, `${endHours}:${endMinutes}`);
|
||||||
|
// Create unique ID for this event
|
||||||
|
const uid = `booking-${Date.now()}-${Math.random().toString(36).substr(2, 9)}@stargirlnails.de`;
|
||||||
|
// Current timestamp for DTSTAMP
|
||||||
|
const now = new Date();
|
||||||
|
const dtstamp = now.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
|
||||||
|
// ICS content
|
||||||
|
const icsContent = [
|
||||||
|
'BEGIN:VCALENDAR',
|
||||||
|
'VERSION:2.0',
|
||||||
|
'PRODID:-//Stargirlnails Kiel//Booking System//DE',
|
||||||
|
'CALSCALE:GREGORIAN',
|
||||||
|
'METHOD:REQUEST',
|
||||||
|
'BEGIN:VEVENT',
|
||||||
|
`UID:${uid}`,
|
||||||
|
`DTSTAMP:${dtstamp}`,
|
||||||
|
`DTSTART;TZID=Europe/Berlin:${dtStart}`,
|
||||||
|
`DTEND;TZID=Europe/Berlin:${dtEnd}`,
|
||||||
|
`SUMMARY:${treatmentName} - Stargirlnails Kiel`,
|
||||||
|
`DESCRIPTION:Termin für ${treatmentName} bei Stargirlnails Kiel`,
|
||||||
|
'LOCATION:Stargirlnails Kiel',
|
||||||
|
`ORGANIZER;CN=Stargirlnails Kiel:mailto:${process.env.EMAIL_FROM?.match(/<(.+)>/)?.[1] || 'no-reply@stargirlnails.de'}`,
|
||||||
|
`ATTENDEE;CN=${customerName};RSVP=TRUE:mailto:${customerName}`,
|
||||||
|
'STATUS:CONFIRMED',
|
||||||
|
'SEQUENCE:0',
|
||||||
|
'BEGIN:VALARM',
|
||||||
|
'TRIGGER:-PT24H',
|
||||||
|
'ACTION:DISPLAY',
|
||||||
|
'DESCRIPTION:Erinnerung: Termin morgen bei Stargirlnails Kiel',
|
||||||
|
'END:VALARM',
|
||||||
|
'END:VEVENT',
|
||||||
|
'BEGIN:VTIMEZONE',
|
||||||
|
'TZID:Europe/Berlin',
|
||||||
|
'BEGIN:DAYLIGHT',
|
||||||
|
'TZOFFSETFROM:+0100',
|
||||||
|
'TZOFFSETTO:+0200',
|
||||||
|
'TZNAME:CEST',
|
||||||
|
'DTSTART:19700329T020000',
|
||||||
|
'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU',
|
||||||
|
'END:DAYLIGHT',
|
||||||
|
'BEGIN:STANDARD',
|
||||||
|
'TZOFFSETFROM:+0200',
|
||||||
|
'TZOFFSETTO:+0100',
|
||||||
|
'TZNAME:CET',
|
||||||
|
'DTSTART:19701025T030000',
|
||||||
|
'RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU',
|
||||||
|
'END:STANDARD',
|
||||||
|
'END:VTIMEZONE',
|
||||||
|
'END:VCALENDAR'
|
||||||
|
].join('\r\n');
|
||||||
|
return icsContent;
|
||||||
|
}
|
||||||
|
// Cache for AGB PDF to avoid reading it multiple times
|
||||||
|
let cachedAGBPDF = null;
|
||||||
|
async function getAGBPDFBase64() {
|
||||||
|
if (cachedAGBPDF)
|
||||||
|
return cachedAGBPDF;
|
||||||
|
try {
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
const agbPath = resolve(__dirname, "../../../AGB.pdf");
|
||||||
|
const buf = await readFile(agbPath);
|
||||||
|
cachedAGBPDF = buf.toString('base64');
|
||||||
|
return cachedAGBPDF;
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.warn("Could not read AGB.pdf:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export async function sendEmail(params) {
|
||||||
|
if (!RESEND_API_KEY) {
|
||||||
|
// In development or if not configured, skip sending but don't fail the flow
|
||||||
|
console.warn("Resend API key not configured. Skipping email send.");
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
from: params.from || DEFAULT_FROM,
|
||||||
|
to: Array.isArray(params.to) ? params.to : [params.to],
|
||||||
|
subject: params.subject,
|
||||||
|
text: params.text,
|
||||||
|
html: params.html,
|
||||||
|
cc: params.cc ? (Array.isArray(params.cc) ? params.cc : [params.cc]) : undefined,
|
||||||
|
bcc: params.bcc ? (Array.isArray(params.bcc) ? params.bcc : [params.bcc]) : undefined,
|
||||||
|
reply_to: params.replyTo ? (Array.isArray(params.replyTo) ? params.replyTo : [params.replyTo]) : undefined,
|
||||||
|
attachments: params.attachments,
|
||||||
|
};
|
||||||
|
console.log(`Sending email via Resend: to=${JSON.stringify(payload.to)}, subject="${params.subject}"`);
|
||||||
|
const response = await fetch("https://api.resend.com/emails", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${RESEND_API_KEY}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.text().catch(() => "");
|
||||||
|
console.error("Resend send error:", response.status, body);
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
const responseData = await response.json().catch(() => ({}));
|
||||||
|
console.log("Resend email sent successfully:", responseData);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
export async function sendEmailWithAGB(params) {
|
||||||
|
const agbBase64 = await getAGBPDFBase64();
|
||||||
|
if (agbBase64) {
|
||||||
|
params.attachments = [
|
||||||
|
...(params.attachments || []),
|
||||||
|
{
|
||||||
|
filename: "AGB_Stargirlnails_Kiel.pdf",
|
||||||
|
content: agbBase64,
|
||||||
|
type: "application/pdf"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return sendEmail(params);
|
||||||
|
}
|
||||||
|
export async function sendEmailWithAGBAndCalendar(params, calendarParams) {
|
||||||
|
const agbBase64 = await getAGBPDFBase64();
|
||||||
|
// Create ICS file content
|
||||||
|
const icsContent = createICSFile(calendarParams);
|
||||||
|
const icsBase64 = Buffer.from(icsContent, 'utf-8').toString('base64');
|
||||||
|
// Attach both AGB and ICS file
|
||||||
|
params.attachments = [...(params.attachments || [])];
|
||||||
|
if (agbBase64) {
|
||||||
|
params.attachments.push({
|
||||||
|
filename: "AGB_Stargirlnails_Kiel.pdf",
|
||||||
|
content: agbBase64,
|
||||||
|
type: "application/pdf"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
params.attachments.push({
|
||||||
|
filename: "Termin_Stargirlnails.ics",
|
||||||
|
content: icsBase64,
|
||||||
|
type: "text/calendar"
|
||||||
|
});
|
||||||
|
return sendEmail(params);
|
||||||
|
}
|
||||||
|
export async function sendEmailWithInspirationPhoto(params, photoData, customerName) {
|
||||||
|
if (!photoData) {
|
||||||
|
return sendEmail(params);
|
||||||
|
}
|
||||||
|
// Extract file extension from base64 data URL
|
||||||
|
const match = photoData.match(/data:image\/([^;]+);base64,(.+)/);
|
||||||
|
if (!match) {
|
||||||
|
console.warn("Invalid photo data format");
|
||||||
|
return sendEmail(params);
|
||||||
|
}
|
||||||
|
const [, extension, base64Content] = match;
|
||||||
|
const filename = `inspiration_${customerName.replace(/[^a-zA-Z0-9]/g, '_')}_${Date.now()}.${extension}`;
|
||||||
|
// Check if attachment is too large (max 1MB base64 content)
|
||||||
|
if (base64Content.length > 1024 * 1024) {
|
||||||
|
console.warn(`Photo attachment too large: ${base64Content.length} chars, skipping attachment`);
|
||||||
|
return sendEmail(params);
|
||||||
|
}
|
||||||
|
// console.log(`Sending email with photo attachment: ${filename}, size: ${base64Content.length} chars`);
|
||||||
|
params.attachments = [
|
||||||
|
...(params.attachments || []),
|
||||||
|
{
|
||||||
|
filename,
|
||||||
|
content: base64Content,
|
||||||
|
type: `image/${extension}`
|
||||||
|
}
|
||||||
|
];
|
||||||
|
return sendEmail(params);
|
||||||
|
}
|
||||||
39
server-dist/lib/legal-config.js
Normal file
39
server-dist/lib/legal-config.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
// Default configuration - should be overridden by environment variables
|
||||||
|
export const defaultLegalConfig = {
|
||||||
|
companyName: process.env.COMPANY_NAME || "Stargirlnails Kiel",
|
||||||
|
ownerName: process.env.OWNER_NAME || "Inhaber Name",
|
||||||
|
address: {
|
||||||
|
street: process.env.ADDRESS_STREET || "Liebigstr. 15",
|
||||||
|
city: process.env.ADDRESS_CITY || "Kiel",
|
||||||
|
postalCode: process.env.ADDRESS_POSTAL_CODE || "24145",
|
||||||
|
country: process.env.ADDRESS_COUNTRY || "Deutschland",
|
||||||
|
latitude: process.env.ADDRESS_LATITUDE ? parseFloat(process.env.ADDRESS_LATITUDE) : 54.3233,
|
||||||
|
longitude: process.env.ADDRESS_LONGITUDE ? parseFloat(process.env.ADDRESS_LONGITUDE) : 10.1228,
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
phone: process.env.CONTACT_PHONE || "+49 431 123456",
|
||||||
|
email: process.env.CONTACT_EMAIL || "info@stargirlnails.de",
|
||||||
|
website: process.env.DOMAIN || "stargirlnails.de",
|
||||||
|
},
|
||||||
|
businessDetails: {
|
||||||
|
taxId: process.env.TAX_ID || "",
|
||||||
|
vatId: process.env.VAT_ID || "",
|
||||||
|
commercialRegister: process.env.COMMERCIAL_REGISTER || "",
|
||||||
|
responsibleForContent: process.env.RESPONSIBLE_FOR_CONTENT || "Inhaber Name",
|
||||||
|
},
|
||||||
|
dataProtection: {
|
||||||
|
responsiblePerson: process.env.DATA_PROTECTION_RESPONSIBLE || "Inhaber Name",
|
||||||
|
email: process.env.DATA_PROTECTION_EMAIL || "datenschutz@stargirlnails.de",
|
||||||
|
purpose: process.env.DATA_PROTECTION_PURPOSE || "Terminbuchung und Kundenbetreuung",
|
||||||
|
legalBasis: process.env.DATA_PROTECTION_LEGAL_BASIS || "Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung) und Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse)",
|
||||||
|
dataRetention: process.env.DATA_PROTECTION_RETENTION || "Buchungsdaten werden 3 Jahre nach Vertragsende gespeichert",
|
||||||
|
rights: process.env.DATA_PROTECTION_RIGHTS || "Auskunft, Berichtigung, Löschung, Einschränkung, Widerspruch, Beschwerde bei der Aufsichtsbehörde",
|
||||||
|
cookies: process.env.DATA_PROTECTION_COOKIES || "Wir verwenden technisch notwendige Cookies für die Funktionalität der Website",
|
||||||
|
thirdPartyServices: process.env.THIRD_PARTY_SERVICES ? process.env.THIRD_PARTY_SERVICES.split(',') : ["Resend (E-Mail-Versand)"],
|
||||||
|
dataSecurity: process.env.DATA_PROTECTION_SECURITY || "SSL-Verschlüsselung, sichere Server, regelmäßige Updates",
|
||||||
|
contactDataProtection: process.env.DATA_PROTECTION_CONTACT || "Bei Fragen zum Datenschutz wenden Sie sich an: datenschutz@stargirlnails.de",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export function getLegalConfig() {
|
||||||
|
return defaultLegalConfig;
|
||||||
|
}
|
||||||
14
server-dist/lib/openai.js
Normal file
14
server-dist/lib/openai.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { jsonrepair } from "jsonrepair";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { makeParseableResponseFormat } from "openai/lib/parser";
|
||||||
|
export function zodResponseFormat(zodObject, name, props) {
|
||||||
|
return makeParseableResponseFormat({
|
||||||
|
type: "json_schema",
|
||||||
|
json_schema: {
|
||||||
|
...props,
|
||||||
|
name,
|
||||||
|
strict: true,
|
||||||
|
schema: z.toJSONSchema(zodObject, { target: "draft-7" }),
|
||||||
|
},
|
||||||
|
}, (content) => zodObject.parse(JSON.parse(jsonrepair(content))));
|
||||||
|
}
|
||||||
117
server-dist/lib/rate-limiter.js
Normal file
117
server-dist/lib/rate-limiter.js
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
// Simple in-memory rate limiter for IP and email-based requests
|
||||||
|
// For production with multiple instances, consider using Redis
|
||||||
|
const rateLimitStore = new Map();
|
||||||
|
// Cleanup old entries every 10 minutes to prevent memory leaks
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, entry] of rateLimitStore.entries()) {
|
||||||
|
if (entry.resetAt < now) {
|
||||||
|
rateLimitStore.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 10 * 60 * 1000);
|
||||||
|
/**
|
||||||
|
* Check if a request is allowed based on rate limiting
|
||||||
|
* @param key - Unique identifier (IP, email, or combination)
|
||||||
|
* @param config - Rate limit configuration
|
||||||
|
* @returns RateLimitResult with allow status and metadata
|
||||||
|
*/
|
||||||
|
export function checkRateLimit(key, config) {
|
||||||
|
const now = Date.now();
|
||||||
|
const entry = rateLimitStore.get(key);
|
||||||
|
// No existing entry or window expired - allow and create new entry
|
||||||
|
if (!entry || entry.resetAt < now) {
|
||||||
|
rateLimitStore.set(key, {
|
||||||
|
count: 1,
|
||||||
|
resetAt: now + config.windowMs,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
remaining: config.maxRequests - 1,
|
||||||
|
resetAt: now + config.windowMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Existing entry within window
|
||||||
|
if (entry.count >= config.maxRequests) {
|
||||||
|
// Rate limit exceeded
|
||||||
|
const retryAfterSeconds = Math.ceil((entry.resetAt - now) / 1000);
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
remaining: 0,
|
||||||
|
resetAt: entry.resetAt,
|
||||||
|
retryAfterSeconds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Increment count and allow
|
||||||
|
entry.count++;
|
||||||
|
rateLimitStore.set(key, entry);
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
remaining: config.maxRequests - entry.count,
|
||||||
|
resetAt: entry.resetAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Check rate limit for booking creation
|
||||||
|
* Applies multiple checks: per IP and per email
|
||||||
|
*/
|
||||||
|
export function checkBookingRateLimit(params) {
|
||||||
|
const { ip, email } = params;
|
||||||
|
// Config: max 3 bookings per email per hour
|
||||||
|
const emailConfig = {
|
||||||
|
maxRequests: 3,
|
||||||
|
windowMs: 60 * 60 * 1000, // 1 hour
|
||||||
|
};
|
||||||
|
// Config: max 5 bookings per IP per 10 minutes
|
||||||
|
const ipConfig = {
|
||||||
|
maxRequests: 5,
|
||||||
|
windowMs: 10 * 60 * 1000, // 10 minutes
|
||||||
|
};
|
||||||
|
// Check email rate limit
|
||||||
|
const emailKey = `booking:email:${email.toLowerCase()}`;
|
||||||
|
const emailResult = checkRateLimit(emailKey, emailConfig);
|
||||||
|
if (!emailResult.allowed) {
|
||||||
|
return {
|
||||||
|
...emailResult,
|
||||||
|
allowed: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Check IP rate limit (if IP is available)
|
||||||
|
if (ip) {
|
||||||
|
const ipKey = `booking:ip:${ip}`;
|
||||||
|
const ipResult = checkRateLimit(ipKey, ipConfig);
|
||||||
|
if (!ipResult.allowed) {
|
||||||
|
return {
|
||||||
|
...ipResult,
|
||||||
|
allowed: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Both checks passed
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
remaining: Math.min(emailResult.remaining, ip ? Infinity : emailResult.remaining),
|
||||||
|
resetAt: emailResult.resetAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Get client IP from various headers (for proxy/load balancer support)
|
||||||
|
*/
|
||||||
|
export function getClientIP(headers) {
|
||||||
|
// Check common proxy headers
|
||||||
|
const forwardedFor = headers['x-forwarded-for'];
|
||||||
|
if (forwardedFor) {
|
||||||
|
// x-forwarded-for can contain multiple IPs, take the first one
|
||||||
|
return forwardedFor.split(',')[0].trim();
|
||||||
|
}
|
||||||
|
const realIP = headers['x-real-ip'];
|
||||||
|
if (realIP) {
|
||||||
|
return realIP;
|
||||||
|
}
|
||||||
|
const cfConnectingIP = headers['cf-connecting-ip']; // Cloudflare
|
||||||
|
if (cfConnectingIP) {
|
||||||
|
return cfConnectingIP;
|
||||||
|
}
|
||||||
|
// No IP found
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
176
server-dist/routes/caldav.js
Normal file
176
server-dist/routes/caldav.js
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { createKV } from "../lib/create-kv.js";
|
||||||
|
// KV-Stores
|
||||||
|
const bookingsKV = createKV("bookings");
|
||||||
|
const treatmentsKV = createKV("treatments");
|
||||||
|
const sessionsKV = createKV("sessions");
|
||||||
|
export const caldavApp = new Hono();
|
||||||
|
// Helper-Funktionen für ICS-Format
|
||||||
|
function formatDateTime(dateStr, timeStr) {
|
||||||
|
// Konvertiere YYYY-MM-DD HH:MM zu UTC-Format für ICS
|
||||||
|
const [year, month, day] = dateStr.split('-').map(Number);
|
||||||
|
const [hours, minutes] = timeStr.split(':').map(Number);
|
||||||
|
const date = new Date(year, month - 1, day, hours, minutes);
|
||||||
|
return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||||||
|
}
|
||||||
|
function generateICSContent(bookings, treatments) {
|
||||||
|
const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||||||
|
let ics = `BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//Stargirlnails//Booking Calendar//DE
|
||||||
|
CALSCALE:GREGORIAN
|
||||||
|
METHOD:PUBLISH
|
||||||
|
X-WR-CALNAME:Stargirlnails Termine
|
||||||
|
X-WR-CALDESC:Terminkalender für Stargirlnails
|
||||||
|
X-WR-TIMEZONE:Europe/Berlin
|
||||||
|
`;
|
||||||
|
// Nur bestätigte und ausstehende Termine in den Kalender aufnehmen
|
||||||
|
const activeBookings = bookings.filter(b => b.status === 'confirmed' || b.status === 'pending');
|
||||||
|
for (const booking of activeBookings) {
|
||||||
|
const treatment = treatments.find(t => t.id === booking.treatmentId);
|
||||||
|
const treatmentName = treatment?.name || 'Unbekannte Behandlung';
|
||||||
|
const duration = booking.bookedDurationMinutes || treatment?.duration || 60;
|
||||||
|
const startTime = formatDateTime(booking.appointmentDate, booking.appointmentTime);
|
||||||
|
const endTime = formatDateTime(booking.appointmentDate, `${String(Math.floor((parseInt(booking.appointmentTime.split(':')[0]) * 60 + parseInt(booking.appointmentTime.split(':')[1]) + duration) / 60)).padStart(2, '0')}:${String((parseInt(booking.appointmentTime.split(':')[0]) * 60 + parseInt(booking.appointmentTime.split(':')[1]) + duration) % 60).padStart(2, '0')}`);
|
||||||
|
// UID für jeden Termin (eindeutig)
|
||||||
|
const uid = `booking-${booking.id}@stargirlnails.de`;
|
||||||
|
// Status für ICS
|
||||||
|
const status = booking.status === 'confirmed' ? 'CONFIRMED' : 'TENTATIVE';
|
||||||
|
ics += `BEGIN:VEVENT
|
||||||
|
UID:${uid}
|
||||||
|
DTSTAMP:${now}
|
||||||
|
DTSTART:${startTime}
|
||||||
|
DTEND:${endTime}
|
||||||
|
SUMMARY:${treatmentName} - ${booking.customerName}
|
||||||
|
DESCRIPTION:Behandlung: ${treatmentName}\\nKunde: ${booking.customerName}${booking.customerPhone ? `\\nTelefon: ${booking.customerPhone}` : ''}${booking.notes ? `\\nNotizen: ${booking.notes}` : ''}
|
||||||
|
STATUS:${status}
|
||||||
|
TRANSP:OPAQUE
|
||||||
|
END:VEVENT
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
ics += `END:VCALENDAR`;
|
||||||
|
return ics;
|
||||||
|
}
|
||||||
|
// CalDAV Discovery (PROPFIND auf Root)
|
||||||
|
caldavApp.all("/", async (c) => {
|
||||||
|
if (c.req.method !== 'PROPFIND') {
|
||||||
|
return c.text('Method Not Allowed', 405);
|
||||||
|
}
|
||||||
|
const response = `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||||
|
<D:response>
|
||||||
|
<D:href>/caldav/</D:href>
|
||||||
|
<D:propstat>
|
||||||
|
<D:prop>
|
||||||
|
<D:displayname>Stargirlnails Terminkalender</D:displayname>
|
||||||
|
<C:calendar-description>Termine für Stargirlnails</C:calendar-description>
|
||||||
|
<C:supported-calendar-component-set>
|
||||||
|
<C:comp name="VEVENT"/>
|
||||||
|
</C:supported-calendar-component-set>
|
||||||
|
<C:calendar-timezone>Europe/Berlin</C:calendar-timezone>
|
||||||
|
</D:prop>
|
||||||
|
<D:status>HTTP/1.1 200 OK</D:status>
|
||||||
|
</D:propstat>
|
||||||
|
</D:response>
|
||||||
|
</D:multistatus>`;
|
||||||
|
return c.text(response, 207, {
|
||||||
|
"Content-Type": "application/xml; charset=utf-8",
|
||||||
|
"DAV": "1, 3, calendar-access, calendar-schedule",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// Calendar Collection PROPFIND
|
||||||
|
caldavApp.all("/calendar/", async (c) => {
|
||||||
|
if (c.req.method !== 'PROPFIND') {
|
||||||
|
return c.text('Method Not Allowed', 405);
|
||||||
|
}
|
||||||
|
const response = `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/">
|
||||||
|
<D:response>
|
||||||
|
<D:href>/caldav/calendar/</D:href>
|
||||||
|
<D:propstat>
|
||||||
|
<D:prop>
|
||||||
|
<D:displayname>Stargirlnails Termine</D:displayname>
|
||||||
|
<C:calendar-description>Alle Termine von Stargirlnails</C:calendar-description>
|
||||||
|
<C:supported-calendar-component-set>
|
||||||
|
<C:comp name="VEVENT"/>
|
||||||
|
</C:supported-calendar-component-set>
|
||||||
|
<C:calendar-timezone>Europe/Berlin</C:calendar-timezone>
|
||||||
|
<CS:getctag>${Date.now()}</CS:getctag>
|
||||||
|
<D:sync-token>${Date.now()}</D:sync-token>
|
||||||
|
</D:prop>
|
||||||
|
<D:status>HTTP/1.1 200 OK</D:status>
|
||||||
|
</D:propstat>
|
||||||
|
</D:response>
|
||||||
|
</D:multistatus>`;
|
||||||
|
return c.text(response, 207, {
|
||||||
|
"Content-Type": "application/xml; charset=utf-8",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// Calendar Events PROPFIND
|
||||||
|
caldavApp.all("/calendar/events.ics", async (c) => {
|
||||||
|
if (c.req.method !== 'PROPFIND') {
|
||||||
|
return c.text('Method Not Allowed', 405);
|
||||||
|
}
|
||||||
|
const response = `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/">
|
||||||
|
<D:response>
|
||||||
|
<D:href>/caldav/calendar/events.ics</D:href>
|
||||||
|
<D:propstat>
|
||||||
|
<D:prop>
|
||||||
|
<D:getcontenttype>text/calendar; charset=utf-8</D:getcontenttype>
|
||||||
|
<D:getetag>"${Date.now()}"</D:getetag>
|
||||||
|
<D:displayname>Stargirlnails Termine</D:displayname>
|
||||||
|
<C:calendar-data>BEGIN:VCALENDAR\\nVERSION:2.0\\nEND:VCALENDAR</C:calendar-data>
|
||||||
|
</D:prop>
|
||||||
|
<D:status>HTTP/1.1 200 OK</D:status>
|
||||||
|
</D:propstat>
|
||||||
|
</D:response>
|
||||||
|
</D:multistatus>`;
|
||||||
|
return c.text(response, 207, {
|
||||||
|
"Content-Type": "application/xml; charset=utf-8",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// GET Calendar Data (ICS-Datei)
|
||||||
|
caldavApp.get("/calendar/events.ics", async (c) => {
|
||||||
|
try {
|
||||||
|
// Authentifizierung über Token im Query-Parameter
|
||||||
|
const token = c.req.query('token');
|
||||||
|
if (!token) {
|
||||||
|
return c.text('Unauthorized - Token required', 401);
|
||||||
|
}
|
||||||
|
// Token validieren
|
||||||
|
const tokenData = await sessionsKV.getItem(token);
|
||||||
|
if (!tokenData) {
|
||||||
|
return c.text('Unauthorized - Invalid token', 401);
|
||||||
|
}
|
||||||
|
// Prüfe, ob es ein CalDAV-Token ist (durch Ablaufzeit und fehlende type-Eigenschaft erkennbar)
|
||||||
|
// CalDAV-Tokens haben eine kürzere Ablaufzeit (24h) als normale Sessions
|
||||||
|
const tokenAge = Date.now() - new Date(tokenData.createdAt).getTime();
|
||||||
|
if (tokenAge > 24 * 60 * 60 * 1000) { // 24 Stunden
|
||||||
|
return c.text('Unauthorized - Token expired', 401);
|
||||||
|
}
|
||||||
|
// Token-Ablaufzeit prüfen
|
||||||
|
if (new Date(tokenData.expiresAt) < new Date()) {
|
||||||
|
return c.text('Unauthorized - Token expired', 401);
|
||||||
|
}
|
||||||
|
const bookings = await bookingsKV.getAllItems();
|
||||||
|
const treatments = await treatmentsKV.getAllItems();
|
||||||
|
const icsContent = generateICSContent(bookings, treatments);
|
||||||
|
return c.text(icsContent, 200, {
|
||||||
|
"Content-Type": "text/calendar; charset=utf-8",
|
||||||
|
"Content-Disposition": "inline; filename=\"stargirlnails-termine.ics\"",
|
||||||
|
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||||
|
"Pragma": "no-cache",
|
||||||
|
"Expires": "0",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error("CalDAV GET error:", error);
|
||||||
|
return c.text('Internal Server Error', 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Fallback für andere CalDAV-Requests
|
||||||
|
caldavApp.all("*", async (c) => {
|
||||||
|
console.log(`CalDAV: Unhandled ${c.req.method} request to ${c.req.url}`);
|
||||||
|
return c.text('Not Found', 404);
|
||||||
|
});
|
||||||
28
server-dist/routes/client-entry.js
Normal file
28
server-dist/routes/client-entry.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "hono/jsx/jsx-runtime";
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
export function clientEntry(c) {
|
||||||
|
let jsFile = "/src/client/main.tsx";
|
||||||
|
let cssFiles = null;
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
try {
|
||||||
|
// Read Vite manifest to get the correct file names
|
||||||
|
const manifestPath = join(process.cwd(), 'dist', '.vite', 'manifest.json');
|
||||||
|
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
||||||
|
const entry = manifest['index.html'];
|
||||||
|
if (entry) {
|
||||||
|
jsFile = `/${entry.file}`;
|
||||||
|
if (entry.css) {
|
||||||
|
cssFiles = entry.css.map((css) => `/${css}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.warn('Could not read Vite manifest, using fallback:', error);
|
||||||
|
// Fallback to a generic path
|
||||||
|
jsFile = "/assets/index-Ccx6A0bN.js";
|
||||||
|
cssFiles = ["/assets/index-RdX4PbOO.css"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c.html(_jsxs("html", { lang: "de", children: [_jsxs("head", { children: [_jsx("meta", { charSet: "utf-8" }), _jsx("meta", { content: "width=device-width, initial-scale=1", name: "viewport" }), _jsx("meta", { name: "theme-color", content: "#ec4899" }), _jsx("meta", { name: "apple-mobile-web-app-capable", content: "yes" }), _jsx("meta", { name: "apple-mobile-web-app-status-bar-style", content: "default" }), _jsx("meta", { name: "apple-mobile-web-app-title", content: "Stargirlnails" }), _jsx("title", { children: "Stargirlnails Kiel" }), _jsx("link", { rel: "icon", type: "image/png", href: "/favicon.png" }), _jsx("link", { rel: "apple-touch-icon", href: "/icons/apple-touch-icon.png" }), _jsx("link", { rel: "manifest", href: "/manifest.json" }), cssFiles && cssFiles.map((css) => (_jsx("link", { rel: "stylesheet", href: css }, css))), process.env.NODE_ENV === 'production' ? (_jsx("script", { src: jsFile, type: "module" })) : (_jsxs(_Fragment, { children: [_jsx("script", { src: "/@vite/client", type: "module" }), _jsx("script", { src: jsFile, type: "module" })] }))] }), _jsx("body", { children: _jsx("div", { id: "root" }) })] }));
|
||||||
|
}
|
||||||
21
server-dist/routes/rpc.js
Normal file
21
server-dist/routes/rpc.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { RPCHandler } from "@orpc/server/fetch";
|
||||||
|
import { router } from "../rpc/index.js";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
export const rpcApp = new Hono();
|
||||||
|
const handler = new RPCHandler(router);
|
||||||
|
rpcApp.all("/*", async (c) => {
|
||||||
|
try {
|
||||||
|
const { matched, response } = await handler.handle(c.req.raw, {
|
||||||
|
prefix: "/rpc",
|
||||||
|
});
|
||||||
|
if (matched) {
|
||||||
|
return c.newResponse(response.body, response);
|
||||||
|
}
|
||||||
|
return c.json({ error: "Not found" }, 404);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error("RPC Handler error:", error);
|
||||||
|
// Let oRPC handle errors properly
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
148
server-dist/rpc/auth.js
Normal file
148
server-dist/rpc/auth.js
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { os } from "@orpc/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import { createKV } from "../lib/create-kv.js";
|
||||||
|
import { config } from "dotenv";
|
||||||
|
// Load environment variables from .env file
|
||||||
|
config();
|
||||||
|
const UserSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
username: z.string().min(3, "Benutzername muss mindestens 3 Zeichen lang sein"),
|
||||||
|
email: z.string().email("Ungültige E-Mail-Adresse"),
|
||||||
|
passwordHash: z.string(),
|
||||||
|
role: z.enum(["customer", "owner"]),
|
||||||
|
createdAt: z.string(),
|
||||||
|
});
|
||||||
|
const SessionSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
userId: z.string(),
|
||||||
|
expiresAt: z.string(),
|
||||||
|
createdAt: z.string(),
|
||||||
|
});
|
||||||
|
const usersKV = createKV("users");
|
||||||
|
const sessionsKV = createKV("sessions");
|
||||||
|
// Simple password hashing (in production, use bcrypt or similar)
|
||||||
|
const hashPassword = (password) => {
|
||||||
|
return Buffer.from(password).toString('base64');
|
||||||
|
};
|
||||||
|
const verifyPassword = (password, hash) => {
|
||||||
|
return hashPassword(password) === hash;
|
||||||
|
};
|
||||||
|
// Export hashPassword for external use (e.g., generating hashes for .env)
|
||||||
|
export const generatePasswordHash = hashPassword;
|
||||||
|
// Initialize default owner account
|
||||||
|
const initializeOwner = async () => {
|
||||||
|
const existingUsers = await usersKV.getAllItems();
|
||||||
|
if (existingUsers.length === 0) {
|
||||||
|
const ownerId = randomUUID();
|
||||||
|
// Get admin credentials from environment variables
|
||||||
|
const adminUsername = process.env.ADMIN_USERNAME || "owner";
|
||||||
|
const adminPasswordHash = process.env.ADMIN_PASSWORD_HASH || hashPassword("admin123");
|
||||||
|
const adminEmail = process.env.ADMIN_EMAIL || "owner@stargirlnails.de";
|
||||||
|
const owner = {
|
||||||
|
id: ownerId,
|
||||||
|
username: adminUsername,
|
||||||
|
email: adminEmail,
|
||||||
|
passwordHash: adminPasswordHash,
|
||||||
|
role: "owner",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await usersKV.setItem(ownerId, owner);
|
||||||
|
console.log(`✅ Admin account created: username="${adminUsername}", email="${adminEmail}"`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Initialize on module load
|
||||||
|
initializeOwner();
|
||||||
|
const login = os
|
||||||
|
.input(z.object({
|
||||||
|
username: z.string(),
|
||||||
|
password: z.string(),
|
||||||
|
}))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
const users = await usersKV.getAllItems();
|
||||||
|
const user = users.find(u => u.username === input.username);
|
||||||
|
if (!user || !verifyPassword(input.password, user.passwordHash)) {
|
||||||
|
throw new Error("Invalid credentials");
|
||||||
|
}
|
||||||
|
// Create session
|
||||||
|
const sessionId = randomUUID();
|
||||||
|
const expiresAt = new Date();
|
||||||
|
expiresAt.setHours(expiresAt.getHours() + 24); // 24 hours
|
||||||
|
const session = {
|
||||||
|
id: sessionId,
|
||||||
|
userId: user.id,
|
||||||
|
expiresAt: expiresAt.toISOString(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await sessionsKV.setItem(sessionId, session);
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const logout = os
|
||||||
|
.input(z.string()) // sessionId
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
await sessionsKV.removeItem(input);
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
const verifySession = os
|
||||||
|
.input(z.string()) // sessionId
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
const session = await sessionsKV.getItem(input);
|
||||||
|
if (!session) {
|
||||||
|
throw new Error("Invalid session");
|
||||||
|
}
|
||||||
|
if (new Date(session.expiresAt) < new Date()) {
|
||||||
|
await sessionsKV.removeItem(input);
|
||||||
|
throw new Error("Session expired");
|
||||||
|
}
|
||||||
|
const user = await usersKV.getItem(session.userId);
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("User not found");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const changePassword = os
|
||||||
|
.input(z.object({
|
||||||
|
sessionId: z.string(),
|
||||||
|
currentPassword: z.string(),
|
||||||
|
newPassword: z.string(),
|
||||||
|
}))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
const session = await sessionsKV.getItem(input.sessionId);
|
||||||
|
if (!session) {
|
||||||
|
throw new Error("Invalid session");
|
||||||
|
}
|
||||||
|
const user = await usersKV.getItem(session.userId);
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("User not found");
|
||||||
|
}
|
||||||
|
if (!verifyPassword(input.currentPassword, user.passwordHash)) {
|
||||||
|
throw new Error("Current password is incorrect");
|
||||||
|
}
|
||||||
|
const updatedUser = {
|
||||||
|
...user,
|
||||||
|
passwordHash: hashPassword(input.newPassword),
|
||||||
|
};
|
||||||
|
await usersKV.setItem(user.id, updatedUser);
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
export const router = {
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
verifySession,
|
||||||
|
changePassword,
|
||||||
|
};
|
||||||
807
server-dist/rpc/bookings.js
Normal file
807
server-dist/rpc/bookings.js
Normal file
@@ -0,0 +1,807 @@
|
|||||||
|
import { call, os } from "@orpc/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import { createKV } from "../lib/create-kv.js";
|
||||||
|
import { sendEmail, sendEmailWithAGBAndCalendar, sendEmailWithInspirationPhoto } from "../lib/email.js";
|
||||||
|
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML, renderBookingRescheduleProposalHTML, renderAdminRescheduleAcceptedHTML, renderAdminRescheduleDeclinedHTML, renderCustomerMessageHTML } from "../lib/email-templates.js";
|
||||||
|
import { createORPCClient } from "@orpc/client";
|
||||||
|
import { RPCLink } from "@orpc/client/fetch";
|
||||||
|
import { checkBookingRateLimit } from "../lib/rate-limiter.js";
|
||||||
|
import { validateEmail } from "../lib/email-validator.js";
|
||||||
|
// Create a server-side client to call other RPC endpoints
|
||||||
|
const serverPort = process.env.PORT ? parseInt(process.env.PORT) : 3000;
|
||||||
|
const link = new RPCLink({ url: `http://localhost:${serverPort}/rpc` });
|
||||||
|
// Typisierung über any, um Build-Inkompatibilität mit NestedClient zu vermeiden (nur für interne Server-Calls)
|
||||||
|
const queryClient = createORPCClient(link);
|
||||||
|
// Helper function to convert date from yyyy-mm-dd to dd.mm.yyyy
|
||||||
|
function formatDateGerman(dateString) {
|
||||||
|
const [year, month, day] = dateString.split('-');
|
||||||
|
return `${day}.${month}.${year}`;
|
||||||
|
}
|
||||||
|
// Helper function to generate URLs from DOMAIN environment variable
|
||||||
|
function generateUrl(path = '') {
|
||||||
|
const domain = process.env.DOMAIN || 'localhost:5173';
|
||||||
|
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||||
|
return `${protocol}://${domain}${path}`;
|
||||||
|
}
|
||||||
|
// Helper function to parse time string to minutes since midnight
|
||||||
|
function parseTime(timeStr) {
|
||||||
|
const [hours, minutes] = timeStr.split(':').map(Number);
|
||||||
|
return hours * 60 + minutes;
|
||||||
|
}
|
||||||
|
// Helper function to check if date is in time-off period
|
||||||
|
function isDateInTimeOffPeriod(date, periods) {
|
||||||
|
return periods.some(period => date >= period.startDate && date <= period.endDate);
|
||||||
|
}
|
||||||
|
// Helper function to validate booking time against recurring rules
|
||||||
|
async function validateBookingAgainstRules(date, time, treatmentDuration) {
|
||||||
|
// Parse date to get day of week
|
||||||
|
const [year, month, day] = date.split('-').map(Number);
|
||||||
|
const localDate = new Date(year, month - 1, day);
|
||||||
|
const dayOfWeek = localDate.getDay();
|
||||||
|
// Check time-off periods
|
||||||
|
const timeOffPeriods = await timeOffPeriodsKV.getAllItems();
|
||||||
|
if (isDateInTimeOffPeriod(date, timeOffPeriods)) {
|
||||||
|
throw new Error("Dieser Tag ist nicht verfügbar (Urlaubszeit).");
|
||||||
|
}
|
||||||
|
// Find matching recurring rules
|
||||||
|
const allRules = await recurringRulesKV.getAllItems();
|
||||||
|
const matchingRules = allRules.filter(rule => rule.isActive === true && rule.dayOfWeek === dayOfWeek);
|
||||||
|
if (matchingRules.length === 0) {
|
||||||
|
throw new Error("Für diesen Wochentag sind keine Termine verfügbar.");
|
||||||
|
}
|
||||||
|
// Check if booking time falls within any rule's time span
|
||||||
|
const bookingStartMinutes = parseTime(time);
|
||||||
|
const bookingEndMinutes = bookingStartMinutes + treatmentDuration;
|
||||||
|
const isWithinRules = matchingRules.some(rule => {
|
||||||
|
const ruleStartMinutes = parseTime(rule.startTime);
|
||||||
|
const ruleEndMinutes = parseTime(rule.endTime);
|
||||||
|
// Booking must start at or after rule start and end at or before rule end
|
||||||
|
return bookingStartMinutes >= ruleStartMinutes && bookingEndMinutes <= ruleEndMinutes;
|
||||||
|
});
|
||||||
|
if (!isWithinRules) {
|
||||||
|
throw new Error("Die gewählte Uhrzeit liegt außerhalb der verfügbaren Zeiten.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Helper function to check for booking conflicts
|
||||||
|
async function checkBookingConflicts(date, time, treatmentDuration, excludeBookingId) {
|
||||||
|
const allBookings = await kv.getAllItems();
|
||||||
|
const dateBookings = allBookings.filter(booking => booking.appointmentDate === date &&
|
||||||
|
['pending', 'confirmed', 'completed'].includes(booking.status) &&
|
||||||
|
booking.id !== excludeBookingId);
|
||||||
|
const bookingStartMinutes = parseTime(time);
|
||||||
|
const bookingEndMinutes = bookingStartMinutes + treatmentDuration;
|
||||||
|
// Cache treatment durations by ID to avoid N+1 lookups
|
||||||
|
const uniqueTreatmentIds = [...new Set(dateBookings.map(booking => booking.treatmentId))];
|
||||||
|
const treatmentDurationMap = new Map();
|
||||||
|
for (const treatmentId of uniqueTreatmentIds) {
|
||||||
|
const treatment = await treatmentsKV.getItem(treatmentId);
|
||||||
|
treatmentDurationMap.set(treatmentId, treatment?.duration || 60);
|
||||||
|
}
|
||||||
|
// Check for overlaps with existing bookings
|
||||||
|
for (const existingBooking of dateBookings) {
|
||||||
|
// Use cached duration or fallback to bookedDurationMinutes if available
|
||||||
|
let existingDuration = treatmentDurationMap.get(existingBooking.treatmentId) || 60;
|
||||||
|
if (existingBooking.bookedDurationMinutes) {
|
||||||
|
existingDuration = existingBooking.bookedDurationMinutes;
|
||||||
|
}
|
||||||
|
const existingStartMinutes = parseTime(existingBooking.appointmentTime);
|
||||||
|
const existingEndMinutes = existingStartMinutes + existingDuration;
|
||||||
|
// Check overlap: bookingStart < existingEnd && bookingEnd > existingStart
|
||||||
|
if (bookingStartMinutes < existingEndMinutes && bookingEndMinutes > existingStartMinutes) {
|
||||||
|
throw new Error("Dieser Zeitslot ist bereits gebucht. Bitte wähle eine andere Zeit.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const CreateBookingInputSchema = z.object({
|
||||||
|
treatmentId: z.string(),
|
||||||
|
customerName: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein"),
|
||||||
|
customerEmail: z.string().email("Ungültige E-Mail-Adresse"),
|
||||||
|
customerPhone: z.string().min(5, "Telefonnummer muss mindestens 5 Zeichen lang sein").optional(),
|
||||||
|
appointmentDate: z.string(), // ISO date string
|
||||||
|
appointmentTime: z.string(), // HH:MM format
|
||||||
|
notes: z.string().optional(),
|
||||||
|
inspirationPhoto: z.string().optional(), // Base64 encoded image data
|
||||||
|
});
|
||||||
|
const BookingSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
treatmentId: z.string(),
|
||||||
|
customerName: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein"),
|
||||||
|
customerEmail: z.string().email("Ungültige E-Mail-Adresse").optional(),
|
||||||
|
customerPhone: z.string().min(5, "Telefonnummer muss mindestens 5 Zeichen lang sein").optional(),
|
||||||
|
appointmentDate: z.string(), // ISO date string
|
||||||
|
appointmentTime: z.string(), // HH:MM format
|
||||||
|
status: z.enum(["pending", "confirmed", "cancelled", "completed"]),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
inspirationPhoto: z.string().optional(), // Base64 encoded image data
|
||||||
|
bookedDurationMinutes: z.number().optional(), // Snapshot of treatment duration at booking time
|
||||||
|
createdAt: z.string(),
|
||||||
|
// DEPRECATED: slotId is no longer used for validation, kept for backward compatibility
|
||||||
|
slotId: z.string().optional(),
|
||||||
|
});
|
||||||
|
const kv = createKV("bookings");
|
||||||
|
const recurringRulesKV = createKV("recurringRules");
|
||||||
|
const timeOffPeriodsKV = createKV("timeOffPeriods");
|
||||||
|
const treatmentsKV = createKV("treatments");
|
||||||
|
const create = os
|
||||||
|
.input(CreateBookingInputSchema)
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
// console.log("Booking create called with input:", {
|
||||||
|
// ...input,
|
||||||
|
// inspirationPhoto: input.inspirationPhoto ? `[${input.inspirationPhoto.length} chars]` : null
|
||||||
|
// });
|
||||||
|
try {
|
||||||
|
// Rate limiting check (ohne IP, falls Context-Header im Build nicht verfügbar sind)
|
||||||
|
const rateLimitResult = checkBookingRateLimit({
|
||||||
|
ip: undefined,
|
||||||
|
email: input.customerEmail,
|
||||||
|
});
|
||||||
|
if (!rateLimitResult.allowed) {
|
||||||
|
const retryMinutes = rateLimitResult.retryAfterSeconds
|
||||||
|
? Math.ceil(rateLimitResult.retryAfterSeconds / 60)
|
||||||
|
: 10;
|
||||||
|
throw new Error(`Zu viele Buchungsanfragen. Bitte versuche es in ${retryMinutes} Minute${retryMinutes > 1 ? 'n' : ''} erneut.`);
|
||||||
|
}
|
||||||
|
// Email validation before slot reservation
|
||||||
|
console.log(`Validating email: ${input.customerEmail}`);
|
||||||
|
const emailValidation = await validateEmail(input.customerEmail);
|
||||||
|
console.log(`Email validation result:`, emailValidation);
|
||||||
|
if (!emailValidation.valid) {
|
||||||
|
console.log(`Email validation failed: ${emailValidation.reason}`);
|
||||||
|
throw new Error(emailValidation.reason || "Ungültige E-Mail-Adresse");
|
||||||
|
}
|
||||||
|
// Validate appointment time is on 15-minute grid
|
||||||
|
const appointmentMinutes = parseTime(input.appointmentTime);
|
||||||
|
if (appointmentMinutes % 15 !== 0) {
|
||||||
|
throw new Error("Termine müssen auf 15-Minuten-Raster ausgerichtet sein (z.B. 09:00, 09:15, 09:30, 09:45).");
|
||||||
|
}
|
||||||
|
// Validate that the booking is not in the past
|
||||||
|
const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
|
||||||
|
if (input.appointmentDate < today) {
|
||||||
|
throw new Error("Buchungen für vergangene Termine sind nicht möglich.");
|
||||||
|
}
|
||||||
|
// For today's bookings, check if the time is not in the past
|
||||||
|
if (input.appointmentDate === today) {
|
||||||
|
const now = new Date();
|
||||||
|
const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
||||||
|
if (input.appointmentTime <= currentTime) {
|
||||||
|
throw new Error("Buchungen für vergangene Uhrzeiten sind nicht möglich.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Prevent double booking: same customer email with pending/confirmed on same date
|
||||||
|
// Skip duplicate check when DISABLE_DUPLICATE_CHECK is set
|
||||||
|
if (!process.env.DISABLE_DUPLICATE_CHECK) {
|
||||||
|
const existing = await kv.getAllItems();
|
||||||
|
const hasConflict = existing.some(b => (b.customerEmail && b.customerEmail.toLowerCase() === input.customerEmail.toLowerCase()) &&
|
||||||
|
b.appointmentDate === input.appointmentDate &&
|
||||||
|
(b.status === "pending" || b.status === "confirmed"));
|
||||||
|
if (hasConflict) {
|
||||||
|
throw new Error("Du hast bereits eine Buchung für dieses Datum. Bitte wähle einen anderen Tag oder storniere zuerst.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Get treatment duration for validation
|
||||||
|
const treatment = await treatmentsKV.getItem(input.treatmentId);
|
||||||
|
if (!treatment) {
|
||||||
|
throw new Error("Behandlung nicht gefunden.");
|
||||||
|
}
|
||||||
|
// Validate booking time against recurring rules
|
||||||
|
await validateBookingAgainstRules(input.appointmentDate, input.appointmentTime, treatment.duration);
|
||||||
|
// Check for booking conflicts
|
||||||
|
await checkBookingConflicts(input.appointmentDate, input.appointmentTime, treatment.duration);
|
||||||
|
const id = randomUUID();
|
||||||
|
const booking = {
|
||||||
|
id,
|
||||||
|
...input,
|
||||||
|
bookedDurationMinutes: treatment.duration, // Snapshot treatment duration
|
||||||
|
status: "pending",
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
// Save the booking
|
||||||
|
await kv.setItem(id, booking);
|
||||||
|
// Notify customer: request received (pending)
|
||||||
|
void (async () => {
|
||||||
|
// Create booking access token for status viewing
|
||||||
|
const bookingAccessToken = await queryClient.cancellation.createToken({ bookingId: id });
|
||||||
|
const bookingUrl = generateUrl(`/booking/${bookingAccessToken.token}`);
|
||||||
|
const formattedDate = formatDateGerman(input.appointmentDate);
|
||||||
|
const homepageUrl = generateUrl();
|
||||||
|
const html = await renderBookingPendingHTML({
|
||||||
|
name: input.customerName,
|
||||||
|
date: input.appointmentDate,
|
||||||
|
time: input.appointmentTime,
|
||||||
|
statusUrl: bookingUrl
|
||||||
|
});
|
||||||
|
await sendEmail({
|
||||||
|
to: input.customerEmail,
|
||||||
|
subject: "Deine Terminanfrage ist eingegangen",
|
||||||
|
text: `Hallo ${input.customerName},\n\nwir haben deine Anfrage für ${formattedDate} um ${input.appointmentTime} erhalten. Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.\n\nTermin-Status ansehen: ${bookingUrl}\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
|
||||||
|
html,
|
||||||
|
}).catch(() => { });
|
||||||
|
})();
|
||||||
|
// Notify admin: new booking request (with photo if available)
|
||||||
|
void (async () => {
|
||||||
|
if (!process.env.ADMIN_EMAIL)
|
||||||
|
return;
|
||||||
|
// Get treatment name from KV
|
||||||
|
const allTreatments = await treatmentsKV.getAllItems();
|
||||||
|
const treatment = allTreatments.find(t => t.id === input.treatmentId);
|
||||||
|
const treatmentName = treatment?.name || "Unbekannte Behandlung";
|
||||||
|
const adminHtml = await renderAdminBookingNotificationHTML({
|
||||||
|
name: input.customerName,
|
||||||
|
date: input.appointmentDate,
|
||||||
|
time: input.appointmentTime,
|
||||||
|
treatment: treatmentName,
|
||||||
|
phone: input.customerPhone || "Nicht angegeben",
|
||||||
|
notes: input.notes,
|
||||||
|
hasInspirationPhoto: !!input.inspirationPhoto
|
||||||
|
});
|
||||||
|
const homepageUrl = generateUrl();
|
||||||
|
const adminText = `Neue Buchungsanfrage eingegangen:\n\n` +
|
||||||
|
`Name: ${input.customerName}\n` +
|
||||||
|
`Telefon: ${input.customerPhone || "Nicht angegeben"}\n` +
|
||||||
|
`Behandlung: ${treatmentName}\n` +
|
||||||
|
`Datum: ${formatDateGerman(input.appointmentDate)}\n` +
|
||||||
|
`Uhrzeit: ${input.appointmentTime}\n` +
|
||||||
|
`${input.notes ? `Notizen: ${input.notes}\n` : ''}` +
|
||||||
|
`Inspiration-Foto: ${input.inspirationPhoto ? 'Im Anhang verfügbar' : 'Kein Foto hochgeladen'}\n\n` +
|
||||||
|
`Zur Website: ${homepageUrl}\n\n` +
|
||||||
|
`Bitte logge dich in das Admin-Panel ein, um die Buchung zu bearbeiten.`;
|
||||||
|
if (input.inspirationPhoto) {
|
||||||
|
await sendEmailWithInspirationPhoto({
|
||||||
|
to: process.env.ADMIN_EMAIL,
|
||||||
|
subject: `Neue Buchungsanfrage - ${input.customerName}`,
|
||||||
|
text: adminText,
|
||||||
|
html: adminHtml,
|
||||||
|
}, input.inspirationPhoto, input.customerName).catch(() => { });
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await sendEmail({
|
||||||
|
to: process.env.ADMIN_EMAIL,
|
||||||
|
subject: `Neue Buchungsanfrage - ${input.customerName}`,
|
||||||
|
text: adminText,
|
||||||
|
html: adminHtml,
|
||||||
|
}).catch(() => { });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return booking;
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error("Booking creation error:", error);
|
||||||
|
// Re-throw the error for oRPC to handle
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const sessionsKV = createKV("sessions");
|
||||||
|
const usersKV = createKV("users");
|
||||||
|
async function assertOwner(sessionId) {
|
||||||
|
const session = await sessionsKV.getItem(sessionId);
|
||||||
|
if (!session)
|
||||||
|
throw new Error("Invalid session");
|
||||||
|
if (new Date(session.expiresAt) < new Date())
|
||||||
|
throw new Error("Session expired");
|
||||||
|
const user = await usersKV.getItem(session.userId);
|
||||||
|
if (!user || user.role !== "owner")
|
||||||
|
throw new Error("Forbidden");
|
||||||
|
}
|
||||||
|
const updateStatus = os
|
||||||
|
.input(z.object({
|
||||||
|
sessionId: z.string(),
|
||||||
|
id: z.string(),
|
||||||
|
status: z.enum(["pending", "confirmed", "cancelled", "completed"])
|
||||||
|
}))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
const booking = await kv.getItem(input.id);
|
||||||
|
if (!booking)
|
||||||
|
throw new Error("Booking not found");
|
||||||
|
const previousStatus = booking.status;
|
||||||
|
const updatedBooking = { ...booking, status: input.status };
|
||||||
|
await kv.setItem(input.id, updatedBooking);
|
||||||
|
// Note: Slot state management removed - bookings now validated against recurring rules
|
||||||
|
// Email notifications on status changes
|
||||||
|
try {
|
||||||
|
if (input.status === "confirmed") {
|
||||||
|
// Create booking access token for this booking (status + cancellation)
|
||||||
|
const bookingAccessToken = await queryClient.cancellation.createToken({ bookingId: booking.id });
|
||||||
|
const formattedDate = formatDateGerman(booking.appointmentDate);
|
||||||
|
const bookingUrl = generateUrl(`/booking/${bookingAccessToken.token}`);
|
||||||
|
const homepageUrl = generateUrl();
|
||||||
|
const html = await renderBookingConfirmedHTML({
|
||||||
|
name: booking.customerName,
|
||||||
|
date: booking.appointmentDate,
|
||||||
|
time: booking.appointmentTime,
|
||||||
|
cancellationUrl: bookingUrl, // Now points to booking status page
|
||||||
|
reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`)
|
||||||
|
});
|
||||||
|
// Get treatment information for ICS file
|
||||||
|
const allTreatments = await treatmentsKV.getAllItems();
|
||||||
|
const treatment = allTreatments.find(t => t.id === booking.treatmentId);
|
||||||
|
const treatmentName = treatment?.name || "Behandlung";
|
||||||
|
// Use bookedDurationMinutes if available, otherwise fallback to treatment duration
|
||||||
|
const treatmentDuration = booking.bookedDurationMinutes || treatment?.duration || 60;
|
||||||
|
if (booking.customerEmail) {
|
||||||
|
await sendEmailWithAGBAndCalendar({
|
||||||
|
to: booking.customerEmail,
|
||||||
|
subject: "Dein Termin wurde bestätigt - AGB im Anhang",
|
||||||
|
text: `Hallo ${booking.customerName},\n\nwir haben deinen Termin am ${formattedDate} um ${booking.appointmentTime} bestätigt.\n\nWichtiger Hinweis: Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.\n\nTermin-Status ansehen und verwalten: ${bookingUrl}\nFalls du den Termin stornieren möchtest, kannst du das über den obigen Link tun.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nBis bald!\nStargirlnails Kiel`,
|
||||||
|
html,
|
||||||
|
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
||||||
|
}, {
|
||||||
|
date: booking.appointmentDate,
|
||||||
|
time: booking.appointmentTime,
|
||||||
|
durationMinutes: treatmentDuration,
|
||||||
|
customerName: booking.customerName,
|
||||||
|
treatmentName: treatmentName
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (input.status === "cancelled") {
|
||||||
|
const formattedDate = formatDateGerman(booking.appointmentDate);
|
||||||
|
const homepageUrl = generateUrl();
|
||||||
|
const html = await renderBookingCancelledHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime });
|
||||||
|
if (booking.customerEmail) {
|
||||||
|
await sendEmail({
|
||||||
|
to: booking.customerEmail,
|
||||||
|
subject: "Dein Termin wurde abgesagt",
|
||||||
|
text: `Hallo ${booking.customerName},\n\nleider wurde dein Termin am ${formattedDate} um ${booking.appointmentTime} abgesagt. Bitte buche einen neuen Termin.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
|
||||||
|
html,
|
||||||
|
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
console.error("Email send failed:", e);
|
||||||
|
}
|
||||||
|
return updatedBooking;
|
||||||
|
});
|
||||||
|
const remove = os
|
||||||
|
.input(z.object({
|
||||||
|
sessionId: z.string(),
|
||||||
|
id: z.string(),
|
||||||
|
sendEmail: z.boolean().optional().default(false)
|
||||||
|
}))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
const booking = await kv.getItem(input.id);
|
||||||
|
if (!booking)
|
||||||
|
throw new Error("Booking not found");
|
||||||
|
// Guard against deletion of past bookings or completed bookings
|
||||||
|
const today = new Date().toISOString().split("T")[0];
|
||||||
|
const isPastDate = booking.appointmentDate < today;
|
||||||
|
const isCompleted = booking.status === 'completed';
|
||||||
|
if (isPastDate || isCompleted) {
|
||||||
|
// For past/completed bookings, disable email sending to avoid confusing customers
|
||||||
|
if (input.sendEmail) {
|
||||||
|
console.log(`Email sending disabled for past/completed booking ${input.id}`);
|
||||||
|
}
|
||||||
|
input.sendEmail = false;
|
||||||
|
}
|
||||||
|
const wasAlreadyCancelled = booking.status === 'cancelled';
|
||||||
|
const updatedBooking = { ...booking, status: "cancelled" };
|
||||||
|
await kv.setItem(input.id, updatedBooking);
|
||||||
|
if (input.sendEmail && !wasAlreadyCancelled && booking.customerEmail) {
|
||||||
|
try {
|
||||||
|
const formattedDate = formatDateGerman(booking.appointmentDate);
|
||||||
|
const homepageUrl = generateUrl();
|
||||||
|
const html = await renderBookingCancelledHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime });
|
||||||
|
await sendEmail({
|
||||||
|
to: booking.customerEmail,
|
||||||
|
subject: "Dein Termin wurde abgesagt",
|
||||||
|
text: `Hallo ${booking.customerName},\n\nleider wurde dein Termin am ${formattedDate} um ${booking.appointmentTime} abgesagt. Bitte buche einen neuen Termin.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
|
||||||
|
html,
|
||||||
|
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
console.error("Email send failed:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return updatedBooking;
|
||||||
|
});
|
||||||
|
// Admin-only manual booking creation (immediately confirmed)
|
||||||
|
const createManual = os
|
||||||
|
.input(z.object({
|
||||||
|
sessionId: z.string(),
|
||||||
|
treatmentId: z.string(),
|
||||||
|
customerName: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein"),
|
||||||
|
customerEmail: z.string().email("Ungültige E-Mail-Adresse").optional(),
|
||||||
|
customerPhone: z.string().min(5, "Telefonnummer muss mindestens 5 Zeichen lang sein").optional(),
|
||||||
|
appointmentDate: z.string(),
|
||||||
|
appointmentTime: z.string(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
}))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
// Admin authentication
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
// Validate appointment time is on 15-minute grid
|
||||||
|
const appointmentMinutes = parseTime(input.appointmentTime);
|
||||||
|
if (appointmentMinutes % 15 !== 0) {
|
||||||
|
throw new Error("Termine müssen auf 15-Minuten-Raster ausgerichtet sein (z.B. 09:00, 09:15, 09:30, 09:45).");
|
||||||
|
}
|
||||||
|
// Validate that the booking is not in the past
|
||||||
|
const today = new Date().toISOString().split("T")[0];
|
||||||
|
if (input.appointmentDate < today) {
|
||||||
|
throw new Error("Buchungen für vergangene Termine sind nicht möglich.");
|
||||||
|
}
|
||||||
|
// For today's bookings, check if the time is not in the past
|
||||||
|
if (input.appointmentDate === today) {
|
||||||
|
const now = new Date();
|
||||||
|
const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
||||||
|
if (input.appointmentTime <= currentTime) {
|
||||||
|
throw new Error("Buchungen für vergangene Uhrzeiten sind nicht möglich.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Get treatment duration for validation
|
||||||
|
const treatment = await treatmentsKV.getItem(input.treatmentId);
|
||||||
|
if (!treatment) {
|
||||||
|
throw new Error("Behandlung nicht gefunden.");
|
||||||
|
}
|
||||||
|
// Validate booking time against recurring rules
|
||||||
|
await validateBookingAgainstRules(input.appointmentDate, input.appointmentTime, treatment.duration);
|
||||||
|
// Check for booking conflicts
|
||||||
|
await checkBookingConflicts(input.appointmentDate, input.appointmentTime, treatment.duration);
|
||||||
|
const id = randomUUID();
|
||||||
|
const booking = {
|
||||||
|
id,
|
||||||
|
treatmentId: input.treatmentId,
|
||||||
|
customerName: input.customerName,
|
||||||
|
customerEmail: input.customerEmail,
|
||||||
|
customerPhone: input.customerPhone,
|
||||||
|
appointmentDate: input.appointmentDate,
|
||||||
|
appointmentTime: input.appointmentTime,
|
||||||
|
notes: input.notes,
|
||||||
|
bookedDurationMinutes: treatment.duration,
|
||||||
|
status: "confirmed",
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
// Save the booking
|
||||||
|
await kv.setItem(id, booking);
|
||||||
|
// Create booking access token for status viewing and cancellation (always create token)
|
||||||
|
const bookingAccessToken = await queryClient.cancellation.createToken({ bookingId: id });
|
||||||
|
// Send confirmation email if email is provided
|
||||||
|
if (input.customerEmail) {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const bookingUrl = generateUrl(`/booking/${bookingAccessToken.token}`);
|
||||||
|
const formattedDate = formatDateGerman(input.appointmentDate);
|
||||||
|
const homepageUrl = generateUrl();
|
||||||
|
const html = await renderBookingConfirmedHTML({
|
||||||
|
name: input.customerName,
|
||||||
|
date: input.appointmentDate,
|
||||||
|
time: input.appointmentTime,
|
||||||
|
cancellationUrl: bookingUrl,
|
||||||
|
reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`)
|
||||||
|
});
|
||||||
|
await sendEmailWithAGBAndCalendar({
|
||||||
|
to: input.customerEmail,
|
||||||
|
subject: "Dein Termin wurde bestätigt - AGB im Anhang",
|
||||||
|
text: `Hallo ${input.customerName},\n\nwir haben deinen Termin am ${formattedDate} um ${input.appointmentTime} bestätigt.\n\nWichtiger Hinweis: Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.\n\nTermin-Status ansehen und verwalten: ${bookingUrl}\nFalls du den Termin stornieren möchtest, kannst du das über den obigen Link tun.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nBis bald!\nStargirlnails Kiel`,
|
||||||
|
html,
|
||||||
|
}, {
|
||||||
|
date: input.appointmentDate,
|
||||||
|
time: input.appointmentTime,
|
||||||
|
durationMinutes: treatment.duration,
|
||||||
|
customerName: input.customerName,
|
||||||
|
treatmentName: treatment.name
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
console.error("Email send failed for manual booking:", e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
// Optionally return the token in the RPC response for UI to copy/share (admin usage only)
|
||||||
|
return {
|
||||||
|
...booking,
|
||||||
|
bookingAccessToken: bookingAccessToken.token
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const list = os.handler(async () => {
|
||||||
|
return kv.getAllItems();
|
||||||
|
});
|
||||||
|
const get = os.input(z.string()).handler(async ({ input }) => {
|
||||||
|
return kv.getItem(input);
|
||||||
|
});
|
||||||
|
const getByDate = os
|
||||||
|
.input(z.string()) // YYYY-MM-DD format
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
const allBookings = await kv.getAllItems();
|
||||||
|
return allBookings.filter(booking => booking.appointmentDate === input);
|
||||||
|
});
|
||||||
|
const live = {
|
||||||
|
list: os.handler(async function* ({ signal }) {
|
||||||
|
yield call(list, {}, { signal });
|
||||||
|
for await (const _ of kv.subscribe()) {
|
||||||
|
yield call(list, {}, { signal });
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
byDate: os
|
||||||
|
.input(z.string())
|
||||||
|
.handler(async function* ({ input, signal }) {
|
||||||
|
yield call(getByDate, input, { signal });
|
||||||
|
for await (const _ of kv.subscribe()) {
|
||||||
|
yield call(getByDate, input, { signal });
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
export const router = {
|
||||||
|
create,
|
||||||
|
createManual,
|
||||||
|
updateStatus,
|
||||||
|
remove,
|
||||||
|
list,
|
||||||
|
get,
|
||||||
|
getByDate,
|
||||||
|
live,
|
||||||
|
// Admin proposes a reschedule for a confirmed booking
|
||||||
|
proposeReschedule: os
|
||||||
|
.input(z.object({
|
||||||
|
sessionId: z.string(),
|
||||||
|
bookingId: z.string(),
|
||||||
|
proposedDate: z.string(),
|
||||||
|
proposedTime: z.string(),
|
||||||
|
}))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
const booking = await kv.getItem(input.bookingId);
|
||||||
|
if (!booking)
|
||||||
|
throw new Error("Booking not found");
|
||||||
|
if (booking.status !== "confirmed")
|
||||||
|
throw new Error("Nur bestätigte Termine können umgebucht werden.");
|
||||||
|
const treatment = await treatmentsKV.getItem(booking.treatmentId);
|
||||||
|
if (!treatment)
|
||||||
|
throw new Error("Behandlung nicht gefunden.");
|
||||||
|
// Validate grid and not in past
|
||||||
|
const appointmentMinutes = parseTime(input.proposedTime);
|
||||||
|
if (appointmentMinutes % 15 !== 0) {
|
||||||
|
throw new Error("Termine müssen auf 15-Minuten-Raster ausgerichtet sein (z.B. 09:00, 09:15, 09:30, 09:45).");
|
||||||
|
}
|
||||||
|
const today = new Date().toISOString().split("T")[0];
|
||||||
|
if (input.proposedDate < today) {
|
||||||
|
throw new Error("Buchungen für vergangene Termine sind nicht möglich.");
|
||||||
|
}
|
||||||
|
if (input.proposedDate === today) {
|
||||||
|
const now = new Date();
|
||||||
|
const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
||||||
|
if (input.proposedTime <= currentTime) {
|
||||||
|
throw new Error("Buchungen für vergangene Uhrzeiten sind nicht möglich.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await validateBookingAgainstRules(input.proposedDate, input.proposedTime, booking.bookedDurationMinutes || treatment.duration);
|
||||||
|
await checkBookingConflicts(input.proposedDate, input.proposedTime, booking.bookedDurationMinutes || treatment.duration, booking.id);
|
||||||
|
// Invalidate and create new reschedule token via cancellation router
|
||||||
|
const res = await queryClient.cancellation.createRescheduleToken({
|
||||||
|
bookingId: booking.id,
|
||||||
|
proposedDate: input.proposedDate,
|
||||||
|
proposedTime: input.proposedTime,
|
||||||
|
});
|
||||||
|
const acceptUrl = generateUrl(`/booking/${res.token}?action=accept`);
|
||||||
|
const declineUrl = generateUrl(`/booking/${res.token}?action=decline`);
|
||||||
|
// Send proposal email to customer
|
||||||
|
if (booking.customerEmail) {
|
||||||
|
const html = await renderBookingRescheduleProposalHTML({
|
||||||
|
name: booking.customerName,
|
||||||
|
originalDate: booking.appointmentDate,
|
||||||
|
originalTime: booking.appointmentTime,
|
||||||
|
proposedDate: input.proposedDate,
|
||||||
|
proposedTime: input.proposedTime,
|
||||||
|
treatmentName: (await treatmentsKV.getItem(booking.treatmentId))?.name || "Behandlung",
|
||||||
|
acceptUrl,
|
||||||
|
declineUrl,
|
||||||
|
expiresAt: res.expiresAt,
|
||||||
|
});
|
||||||
|
await sendEmail({
|
||||||
|
to: booking.customerEmail,
|
||||||
|
subject: "Vorschlag zur Terminänderung",
|
||||||
|
text: `Hallo ${booking.customerName}, wir schlagen vor, deinen Termin von ${formatDateGerman(booking.appointmentDate)} ${booking.appointmentTime} auf ${formatDateGerman(input.proposedDate)} ${input.proposedTime} zu verschieben. Akzeptieren: ${acceptUrl} | Ablehnen: ${declineUrl}`,
|
||||||
|
html,
|
||||||
|
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
||||||
|
}).catch(() => { });
|
||||||
|
}
|
||||||
|
return { success: true, token: res.token };
|
||||||
|
}),
|
||||||
|
// Customer accepts reschedule via token
|
||||||
|
acceptReschedule: os
|
||||||
|
.input(z.object({ token: z.string() }))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
const proposal = await queryClient.cancellation.getRescheduleProposal({ token: input.token });
|
||||||
|
const booking = await kv.getItem(proposal.booking.id);
|
||||||
|
if (!booking)
|
||||||
|
throw new Error("Booking not found");
|
||||||
|
if (booking.status !== "confirmed")
|
||||||
|
throw new Error("Buchung ist nicht mehr in bestätigtem Zustand.");
|
||||||
|
const treatment = await treatmentsKV.getItem(booking.treatmentId);
|
||||||
|
const duration = booking.bookedDurationMinutes || treatment?.duration || 60;
|
||||||
|
// Re-validate slot to ensure still available
|
||||||
|
await validateBookingAgainstRules(proposal.proposed.date, proposal.proposed.time, duration);
|
||||||
|
await checkBookingConflicts(proposal.proposed.date, proposal.proposed.time, duration, booking.id);
|
||||||
|
const updated = { ...booking, appointmentDate: proposal.proposed.date, appointmentTime: proposal.proposed.time };
|
||||||
|
await kv.setItem(updated.id, updated);
|
||||||
|
// Remove token
|
||||||
|
await queryClient.cancellation.removeRescheduleToken({ token: input.token });
|
||||||
|
// Send confirmation to customer (no BCC to avoid duplicate admin emails)
|
||||||
|
if (updated.customerEmail) {
|
||||||
|
const bookingAccessToken = await queryClient.cancellation.createToken({ bookingId: updated.id });
|
||||||
|
const html = await renderBookingConfirmedHTML({
|
||||||
|
name: updated.customerName,
|
||||||
|
date: updated.appointmentDate,
|
||||||
|
time: updated.appointmentTime,
|
||||||
|
cancellationUrl: generateUrl(`/booking/${bookingAccessToken.token}`),
|
||||||
|
reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`),
|
||||||
|
});
|
||||||
|
await sendEmailWithAGBAndCalendar({
|
||||||
|
to: updated.customerEmail,
|
||||||
|
subject: "Terminänderung bestätigt",
|
||||||
|
text: `Hallo ${updated.customerName}, dein neuer Termin ist am ${formatDateGerman(updated.appointmentDate)} um ${updated.appointmentTime}.`,
|
||||||
|
html,
|
||||||
|
}, {
|
||||||
|
date: updated.appointmentDate,
|
||||||
|
time: updated.appointmentTime,
|
||||||
|
durationMinutes: duration,
|
||||||
|
customerName: updated.customerName,
|
||||||
|
treatmentName: (await treatmentsKV.getItem(updated.treatmentId))?.name || "Behandlung",
|
||||||
|
}).catch(() => { });
|
||||||
|
}
|
||||||
|
if (process.env.ADMIN_EMAIL) {
|
||||||
|
const adminHtml = await renderAdminRescheduleAcceptedHTML({
|
||||||
|
customerName: updated.customerName,
|
||||||
|
originalDate: proposal.original.date,
|
||||||
|
originalTime: proposal.original.time,
|
||||||
|
newDate: updated.appointmentDate,
|
||||||
|
newTime: updated.appointmentTime,
|
||||||
|
treatmentName: (await treatmentsKV.getItem(updated.treatmentId))?.name || "Behandlung",
|
||||||
|
});
|
||||||
|
await sendEmail({
|
||||||
|
to: process.env.ADMIN_EMAIL,
|
||||||
|
subject: `Reschedule akzeptiert - ${updated.customerName}`,
|
||||||
|
text: `Reschedule akzeptiert: ${updated.customerName} von ${formatDateGerman(proposal.original.date)} ${proposal.original.time} auf ${formatDateGerman(updated.appointmentDate)} ${updated.appointmentTime}.`,
|
||||||
|
html: adminHtml,
|
||||||
|
}).catch(() => { });
|
||||||
|
}
|
||||||
|
return { success: true, message: `Termin auf ${formatDateGerman(updated.appointmentDate)} um ${updated.appointmentTime} aktualisiert.` };
|
||||||
|
}),
|
||||||
|
// Customer declines reschedule via token
|
||||||
|
declineReschedule: os
|
||||||
|
.input(z.object({ token: z.string() }))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
const proposal = await queryClient.cancellation.getRescheduleProposal({ token: input.token });
|
||||||
|
const booking = await kv.getItem(proposal.booking.id);
|
||||||
|
if (!booking)
|
||||||
|
throw new Error("Booking not found");
|
||||||
|
// Remove token
|
||||||
|
await queryClient.cancellation.removeRescheduleToken({ token: input.token });
|
||||||
|
// Notify customer that original stays
|
||||||
|
if (booking.customerEmail) {
|
||||||
|
const bookingAccessToken = await queryClient.cancellation.createToken({ bookingId: booking.id });
|
||||||
|
await sendEmail({
|
||||||
|
to: booking.customerEmail,
|
||||||
|
subject: "Terminänderung abgelehnt",
|
||||||
|
text: `Du hast den Vorschlag zur Terminänderung abgelehnt. Dein ursprünglicher Termin am ${formatDateGerman(booking.appointmentDate)} um ${booking.appointmentTime} bleibt bestehen.`,
|
||||||
|
html: await renderBookingConfirmedHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime, cancellationUrl: generateUrl(`/booking/${bookingAccessToken.token}`), reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`) }),
|
||||||
|
}).catch(() => { });
|
||||||
|
}
|
||||||
|
// Notify admin
|
||||||
|
if (process.env.ADMIN_EMAIL) {
|
||||||
|
const html = await renderAdminRescheduleDeclinedHTML({
|
||||||
|
customerName: booking.customerName,
|
||||||
|
originalDate: proposal.original.date,
|
||||||
|
originalTime: proposal.original.time,
|
||||||
|
proposedDate: proposal.proposed.date,
|
||||||
|
proposedTime: proposal.proposed.time,
|
||||||
|
treatmentName: (await treatmentsKV.getItem(booking.treatmentId))?.name || "Behandlung",
|
||||||
|
customerEmail: booking.customerEmail,
|
||||||
|
customerPhone: booking.customerPhone,
|
||||||
|
});
|
||||||
|
await sendEmail({
|
||||||
|
to: process.env.ADMIN_EMAIL,
|
||||||
|
subject: `Reschedule abgelehnt - ${booking.customerName}`,
|
||||||
|
text: `Abgelehnt: ${booking.customerName}. Ursprünglich: ${formatDateGerman(proposal.original.date)} ${proposal.original.time}. Vorschlag: ${formatDateGerman(proposal.proposed.date)} ${proposal.proposed.time}.`,
|
||||||
|
html,
|
||||||
|
}).catch(() => { });
|
||||||
|
}
|
||||||
|
return { success: true, message: "Du hast den Vorschlag abgelehnt. Dein ursprünglicher Termin bleibt bestehen." };
|
||||||
|
}),
|
||||||
|
// CalDAV Token für Admin generieren
|
||||||
|
generateCalDAVToken: os
|
||||||
|
.input(z.object({ sessionId: z.string() }))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
// Generiere einen sicheren Token für CalDAV-Zugriff
|
||||||
|
const token = randomUUID();
|
||||||
|
// Hole Session-Daten für Token-Erstellung
|
||||||
|
const session = await sessionsKV.getItem(input.sessionId);
|
||||||
|
if (!session)
|
||||||
|
throw new Error("Session nicht gefunden");
|
||||||
|
// Speichere Token mit Ablaufzeit (24 Stunden)
|
||||||
|
const tokenData = {
|
||||||
|
id: token,
|
||||||
|
sessionId: input.sessionId,
|
||||||
|
userId: session.userId, // Benötigt für Session-Typ
|
||||||
|
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), // 24 Stunden
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
// Verwende den sessionsKV Store für Token-Speicherung
|
||||||
|
await sessionsKV.setItem(token, tokenData);
|
||||||
|
const domain = process.env.DOMAIN || 'localhost:3000';
|
||||||
|
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||||
|
const caldavUrl = `${protocol}://${domain}/caldav/calendar/events.ics?token=${token}`;
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
caldavUrl,
|
||||||
|
expiresAt: tokenData.expiresAt,
|
||||||
|
instructions: {
|
||||||
|
title: "CalDAV-Kalender abonnieren",
|
||||||
|
steps: [
|
||||||
|
"Kopiere die CalDAV-URL unten",
|
||||||
|
"Füge sie in deiner Kalender-App als Abonnement hinzu:",
|
||||||
|
"- Outlook: Datei → Konto hinzufügen → Internetkalender",
|
||||||
|
"- Google Calendar: Andere Kalender hinzufügen → Von URL",
|
||||||
|
"- Apple Calendar: Abonnement → Neue Abonnements",
|
||||||
|
"- Thunderbird: Kalender hinzufügen → Im Netzwerk",
|
||||||
|
"Der Kalender wird automatisch aktualisiert"
|
||||||
|
],
|
||||||
|
note: "Dieser Token ist 24 Stunden gültig. Bei Bedarf kannst du einen neuen Token generieren."
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
// Admin sendet Nachricht an Kunden
|
||||||
|
sendCustomerMessage: os
|
||||||
|
.input(z.object({
|
||||||
|
sessionId: z.string(),
|
||||||
|
bookingId: z.string(),
|
||||||
|
message: z.string().min(1, "Nachricht darf nicht leer sein").max(5000, "Nachricht ist zu lang (max. 5000 Zeichen)"),
|
||||||
|
}))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
const booking = await kv.getItem(input.bookingId);
|
||||||
|
if (!booking)
|
||||||
|
throw new Error("Buchung nicht gefunden");
|
||||||
|
// Check if booking has customer email
|
||||||
|
if (!booking.customerEmail) {
|
||||||
|
throw new Error("Diese Buchung hat keine E-Mail-Adresse. Bitte kontaktiere den Kunden telefonisch.");
|
||||||
|
}
|
||||||
|
// Check if booking is in the future
|
||||||
|
const today = new Date().toISOString().split("T")[0];
|
||||||
|
const bookingDate = booking.appointmentDate;
|
||||||
|
if (bookingDate < today) {
|
||||||
|
throw new Error("Nachrichten können nur für zukünftige Termine gesendet werden.");
|
||||||
|
}
|
||||||
|
// Get treatment name for context
|
||||||
|
const treatment = await treatmentsKV.getItem(booking.treatmentId);
|
||||||
|
const treatmentName = treatment?.name || "Behandlung";
|
||||||
|
// Prepare email with Reply-To header
|
||||||
|
const ownerName = process.env.OWNER_NAME || "Stargirlnails Kiel";
|
||||||
|
const emailFrom = process.env.EMAIL_FROM || "Stargirlnails <no-reply@stargirlnails.de>";
|
||||||
|
const replyToEmail = process.env.ADMIN_EMAIL;
|
||||||
|
const formattedDate = formatDateGerman(bookingDate);
|
||||||
|
const html = await renderCustomerMessageHTML({
|
||||||
|
customerName: booking.customerName,
|
||||||
|
message: input.message,
|
||||||
|
appointmentDate: bookingDate,
|
||||||
|
appointmentTime: booking.appointmentTime,
|
||||||
|
treatmentName: treatmentName,
|
||||||
|
});
|
||||||
|
const textContent = `Hallo ${booking.customerName},\n\nZu deinem Termin:\nBehandlung: ${treatmentName}\nDatum: ${formattedDate}\nUhrzeit: ${booking.appointmentTime}\n\nNachricht von ${ownerName}:\n${input.message}\n\nBei Fragen oder Anliegen kannst du einfach auf diese E-Mail antworten – wir helfen dir gerne weiter!\n\nLiebe Grüße,\n${ownerName}`;
|
||||||
|
// Send email with BCC to admin for monitoring
|
||||||
|
// Note: Not using explicit 'from' or 'replyTo' to match behavior of other system emails
|
||||||
|
console.log(`Sending customer message to ${booking.customerEmail} for booking ${input.bookingId}`);
|
||||||
|
console.log(`Email config: from=${emailFrom}, replyTo=${replyToEmail}, bcc=${replyToEmail}`);
|
||||||
|
const emailResult = await sendEmail({
|
||||||
|
to: booking.customerEmail,
|
||||||
|
subject: `Nachricht zu deinem Termin am ${formattedDate}`,
|
||||||
|
text: textContent,
|
||||||
|
html: html,
|
||||||
|
bcc: replyToEmail ? [replyToEmail] : undefined,
|
||||||
|
});
|
||||||
|
if (!emailResult.success) {
|
||||||
|
console.error(`Failed to send customer message to ${booking.customerEmail}`);
|
||||||
|
throw new Error("E-Mail konnte nicht versendet werden. Bitte überprüfe die E-Mail-Konfiguration oder versuche es später erneut.");
|
||||||
|
}
|
||||||
|
console.log(`Successfully sent customer message to ${booking.customerEmail}`);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Nachricht wurde erfolgreich an ${booking.customerEmail} gesendet.`
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
310
server-dist/rpc/cancellation.js
Normal file
310
server-dist/rpc/cancellation.js
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
import { os } from "@orpc/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { createKV } from "../lib/create-kv.js";
|
||||||
|
import { createKV as createAvailabilityKV } from "../lib/create-kv.js";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
// Schema for booking access token (used for both status viewing and cancellation)
|
||||||
|
const BookingAccessTokenSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
bookingId: z.string(),
|
||||||
|
token: z.string(),
|
||||||
|
expiresAt: z.string(),
|
||||||
|
createdAt: z.string(),
|
||||||
|
purpose: z.enum(["booking_access", "reschedule_proposal"]), // Extended for reschedule proposals
|
||||||
|
// Optional metadata for reschedule proposals
|
||||||
|
proposedDate: z.string().optional(),
|
||||||
|
proposedTime: z.string().optional(),
|
||||||
|
originalDate: z.string().optional(),
|
||||||
|
originalTime: z.string().optional(),
|
||||||
|
});
|
||||||
|
const cancellationKV = createKV("cancellation_tokens");
|
||||||
|
const bookingsKV = createKV("bookings");
|
||||||
|
const availabilityKV = createAvailabilityKV("availability");
|
||||||
|
// Helper function to convert date from yyyy-mm-dd to dd.mm.yyyy
|
||||||
|
function formatDateGerman(dateString) {
|
||||||
|
const [year, month, day] = dateString.split('-');
|
||||||
|
return `${day}.${month}.${year}`;
|
||||||
|
}
|
||||||
|
// Helper to invalidate all reschedule tokens for a specific booking
|
||||||
|
async function invalidateRescheduleTokensForBooking(bookingId) {
|
||||||
|
const tokens = await cancellationKV.getAllItems();
|
||||||
|
const related = tokens.filter(t => t.bookingId === bookingId && t.purpose === "reschedule_proposal");
|
||||||
|
for (const tok of related) {
|
||||||
|
await cancellationKV.removeItem(tok.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Create cancellation token for a booking
|
||||||
|
const createToken = os
|
||||||
|
.input(z.object({ bookingId: z.string() }))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
const booking = await bookingsKV.getItem(input.bookingId);
|
||||||
|
if (!booking) {
|
||||||
|
throw new Error("Booking not found");
|
||||||
|
}
|
||||||
|
if (booking.status === "cancelled") {
|
||||||
|
throw new Error("Booking is already cancelled");
|
||||||
|
}
|
||||||
|
// Create token that expires in 30 days
|
||||||
|
const expiresAt = new Date();
|
||||||
|
expiresAt.setDate(expiresAt.getDate() + 30);
|
||||||
|
const token = randomUUID();
|
||||||
|
const cancellationToken = {
|
||||||
|
id: randomUUID(),
|
||||||
|
bookingId: input.bookingId,
|
||||||
|
token,
|
||||||
|
expiresAt: expiresAt.toISOString(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
purpose: "booking_access",
|
||||||
|
};
|
||||||
|
await cancellationKV.setItem(cancellationToken.id, cancellationToken);
|
||||||
|
return { token, expiresAt: expiresAt.toISOString() };
|
||||||
|
});
|
||||||
|
// Get booking details by token
|
||||||
|
const getBookingByToken = os
|
||||||
|
.input(z.object({ token: z.string() }))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
const tokens = await cancellationKV.getAllItems();
|
||||||
|
const validToken = tokens.find(t => t.token === input.token &&
|
||||||
|
new Date(t.expiresAt) > new Date() &&
|
||||||
|
t.purpose === 'booking_access');
|
||||||
|
if (!validToken) {
|
||||||
|
throw new Error("Invalid or expired cancellation token");
|
||||||
|
}
|
||||||
|
const booking = await bookingsKV.getItem(validToken.bookingId);
|
||||||
|
if (!booking) {
|
||||||
|
throw new Error("Booking not found");
|
||||||
|
}
|
||||||
|
// Get treatment details
|
||||||
|
const treatmentsKV = createKV("treatments");
|
||||||
|
const treatment = await treatmentsKV.getItem(booking.treatmentId);
|
||||||
|
// Calculate if cancellation is still possible
|
||||||
|
const minStornoTimespan = parseInt(process.env.MIN_STORNO_TIMESPAN || "24");
|
||||||
|
const appointmentDateTime = new Date(`${booking.appointmentDate}T${booking.appointmentTime}:00`);
|
||||||
|
const now = new Date();
|
||||||
|
const timeDifferenceHours = (appointmentDateTime.getTime() - now.getTime()) / (1000 * 60 * 60);
|
||||||
|
const canCancel = timeDifferenceHours >= minStornoTimespan &&
|
||||||
|
booking.status !== "cancelled" &&
|
||||||
|
booking.status !== "completed";
|
||||||
|
return {
|
||||||
|
id: booking.id,
|
||||||
|
customerName: booking.customerName,
|
||||||
|
customerEmail: booking.customerEmail,
|
||||||
|
customerPhone: booking.customerPhone,
|
||||||
|
appointmentDate: booking.appointmentDate,
|
||||||
|
appointmentTime: booking.appointmentTime,
|
||||||
|
treatmentId: booking.treatmentId,
|
||||||
|
treatmentName: treatment?.name || "Unbekannte Behandlung",
|
||||||
|
treatmentDuration: treatment?.duration || 60,
|
||||||
|
treatmentPrice: treatment?.price || 0,
|
||||||
|
status: booking.status,
|
||||||
|
notes: booking.notes,
|
||||||
|
formattedDate: formatDateGerman(booking.appointmentDate),
|
||||||
|
createdAt: booking.createdAt,
|
||||||
|
canCancel,
|
||||||
|
hoursUntilAppointment: Math.max(0, Math.round(timeDifferenceHours)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
// Cancel booking by token
|
||||||
|
const cancelByToken = os
|
||||||
|
.input(z.object({ token: z.string() }))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
const tokens = await cancellationKV.getAllItems();
|
||||||
|
const validToken = tokens.find(t => t.token === input.token &&
|
||||||
|
new Date(t.expiresAt) > new Date());
|
||||||
|
if (!validToken) {
|
||||||
|
throw new Error("Invalid or expired cancellation token");
|
||||||
|
}
|
||||||
|
const booking = await bookingsKV.getItem(validToken.bookingId);
|
||||||
|
if (!booking) {
|
||||||
|
throw new Error("Booking not found");
|
||||||
|
}
|
||||||
|
// Check if booking is already cancelled
|
||||||
|
if (booking.status === "cancelled") {
|
||||||
|
throw new Error("Booking is already cancelled");
|
||||||
|
}
|
||||||
|
// Check minimum cancellation timespan from environment variable
|
||||||
|
const minStornoTimespan = parseInt(process.env.MIN_STORNO_TIMESPAN || "24"); // Default: 24 hours
|
||||||
|
const appointmentDateTime = new Date(`${booking.appointmentDate}T${booking.appointmentTime}:00`);
|
||||||
|
const now = new Date();
|
||||||
|
const timeDifferenceHours = (appointmentDateTime.getTime() - now.getTime()) / (1000 * 60 * 60);
|
||||||
|
if (timeDifferenceHours < minStornoTimespan) {
|
||||||
|
throw new Error(`Stornierung ist nur bis ${minStornoTimespan} Stunden vor dem Termin möglich. Der Termin liegt nur noch ${Math.round(timeDifferenceHours)} Stunden in der Zukunft.`);
|
||||||
|
}
|
||||||
|
// Check if booking is in the past (additional safety check)
|
||||||
|
const today = new Date().toISOString().split("T")[0];
|
||||||
|
if (booking.appointmentDate < today) {
|
||||||
|
throw new Error("Cannot cancel past bookings");
|
||||||
|
}
|
||||||
|
// For today's bookings, check if the time is not in the past
|
||||||
|
if (booking.appointmentDate === today) {
|
||||||
|
const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
||||||
|
if (booking.appointmentTime <= currentTime) {
|
||||||
|
throw new Error("Cannot cancel bookings that have already started");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Update booking status
|
||||||
|
const updatedBooking = { ...booking, status: "cancelled" };
|
||||||
|
await bookingsKV.setItem(booking.id, updatedBooking);
|
||||||
|
// Free the slot if it exists
|
||||||
|
if (booking.slotId) {
|
||||||
|
const slot = await availabilityKV.getItem(booking.slotId);
|
||||||
|
if (slot) {
|
||||||
|
const updatedSlot = {
|
||||||
|
...slot,
|
||||||
|
status: "free",
|
||||||
|
reservedByBookingId: undefined,
|
||||||
|
};
|
||||||
|
await availabilityKV.setItem(slot.id, updatedSlot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Invalidate the token
|
||||||
|
await cancellationKV.removeItem(validToken.id);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Booking cancelled successfully",
|
||||||
|
formattedDate: formatDateGerman(booking.appointmentDate),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
export const router = {
|
||||||
|
createToken,
|
||||||
|
getBookingByToken,
|
||||||
|
cancelByToken,
|
||||||
|
// Create a reschedule proposal token (48h expiry)
|
||||||
|
createRescheduleToken: os
|
||||||
|
.input(z.object({ bookingId: z.string(), proposedDate: z.string(), proposedTime: z.string() }))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
const booking = await bookingsKV.getItem(input.bookingId);
|
||||||
|
if (!booking) {
|
||||||
|
throw new Error("Booking not found");
|
||||||
|
}
|
||||||
|
if (booking.status === "cancelled" || booking.status === "completed") {
|
||||||
|
throw new Error("Reschedule not allowed for this booking");
|
||||||
|
}
|
||||||
|
// Invalidate existing reschedule proposals for this booking
|
||||||
|
await invalidateRescheduleTokensForBooking(input.bookingId);
|
||||||
|
// Create token that expires in 48 hours
|
||||||
|
const expiresAt = new Date();
|
||||||
|
expiresAt.setHours(expiresAt.getHours() + 48);
|
||||||
|
const token = randomUUID();
|
||||||
|
const rescheduleToken = {
|
||||||
|
id: randomUUID(),
|
||||||
|
bookingId: input.bookingId,
|
||||||
|
token,
|
||||||
|
expiresAt: expiresAt.toISOString(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
purpose: "reschedule_proposal",
|
||||||
|
proposedDate: input.proposedDate,
|
||||||
|
proposedTime: input.proposedTime,
|
||||||
|
originalDate: booking.appointmentDate,
|
||||||
|
originalTime: booking.appointmentTime,
|
||||||
|
};
|
||||||
|
await cancellationKV.setItem(rescheduleToken.id, rescheduleToken);
|
||||||
|
return { token, expiresAt: expiresAt.toISOString() };
|
||||||
|
}),
|
||||||
|
// Get reschedule proposal details by token
|
||||||
|
getRescheduleProposal: os
|
||||||
|
.input(z.object({ token: z.string() }))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
const tokens = await cancellationKV.getAllItems();
|
||||||
|
const proposal = tokens.find(t => t.token === input.token && t.purpose === "reschedule_proposal");
|
||||||
|
if (!proposal) {
|
||||||
|
throw new Error("Ungültiger Reschedule-Token");
|
||||||
|
}
|
||||||
|
const booking = await bookingsKV.getItem(proposal.bookingId);
|
||||||
|
if (!booking) {
|
||||||
|
throw new Error("Booking not found");
|
||||||
|
}
|
||||||
|
const treatmentsKV = createKV("treatments");
|
||||||
|
const treatment = await treatmentsKV.getItem(booking.treatmentId);
|
||||||
|
const now = new Date();
|
||||||
|
const isExpired = new Date(proposal.expiresAt) <= now;
|
||||||
|
const hoursUntilExpiry = isExpired ? 0 : Math.max(0, Math.round((new Date(proposal.expiresAt).getTime() - now.getTime()) / (1000 * 60 * 60)));
|
||||||
|
return {
|
||||||
|
booking: {
|
||||||
|
id: booking.id,
|
||||||
|
customerName: booking.customerName,
|
||||||
|
customerEmail: booking.customerEmail,
|
||||||
|
customerPhone: booking.customerPhone,
|
||||||
|
status: booking.status,
|
||||||
|
treatmentId: booking.treatmentId,
|
||||||
|
treatmentName: treatment?.name || "Unbekannte Behandlung",
|
||||||
|
},
|
||||||
|
original: {
|
||||||
|
date: proposal.originalDate || booking.appointmentDate,
|
||||||
|
time: proposal.originalTime || booking.appointmentTime,
|
||||||
|
},
|
||||||
|
proposed: {
|
||||||
|
date: proposal.proposedDate,
|
||||||
|
time: proposal.proposedTime,
|
||||||
|
},
|
||||||
|
expiresAt: proposal.expiresAt,
|
||||||
|
hoursUntilExpiry,
|
||||||
|
isExpired,
|
||||||
|
canRespond: booking.status === "confirmed" && !isExpired,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
// Helper endpoint to remove a reschedule token by value (used after accept/decline)
|
||||||
|
removeRescheduleToken: os
|
||||||
|
.input(z.object({ token: z.string() }))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
const tokens = await cancellationKV.getAllItems();
|
||||||
|
const proposal = tokens.find(t => t.token === input.token && t.purpose === "reschedule_proposal");
|
||||||
|
if (proposal) {
|
||||||
|
await cancellationKV.removeItem(proposal.id);
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
// Clean up expired reschedule proposals and notify admin
|
||||||
|
sweepExpiredRescheduleProposals: os
|
||||||
|
.handler(async () => {
|
||||||
|
const tokens = await cancellationKV.getAllItems();
|
||||||
|
const now = new Date();
|
||||||
|
const expiredProposals = tokens.filter(t => t.purpose === "reschedule_proposal" &&
|
||||||
|
new Date(t.expiresAt) <= now);
|
||||||
|
if (expiredProposals.length === 0) {
|
||||||
|
return { success: true, expiredCount: 0 };
|
||||||
|
}
|
||||||
|
// Get booking details for each expired proposal
|
||||||
|
const expiredDetails = [];
|
||||||
|
for (const proposal of expiredProposals) {
|
||||||
|
const booking = await bookingsKV.getItem(proposal.bookingId);
|
||||||
|
if (booking) {
|
||||||
|
const treatmentsKV = createKV("treatments");
|
||||||
|
const treatment = await treatmentsKV.getItem(booking.treatmentId);
|
||||||
|
expiredDetails.push({
|
||||||
|
customerName: booking.customerName,
|
||||||
|
originalDate: proposal.originalDate || booking.appointmentDate,
|
||||||
|
originalTime: proposal.originalTime || booking.appointmentTime,
|
||||||
|
proposedDate: proposal.proposedDate,
|
||||||
|
proposedTime: proposal.proposedTime,
|
||||||
|
treatmentName: treatment?.name || "Unbekannte Behandlung",
|
||||||
|
customerEmail: booking.customerEmail,
|
||||||
|
customerPhone: booking.customerPhone,
|
||||||
|
expiredAt: proposal.expiresAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Remove the expired token
|
||||||
|
await cancellationKV.removeItem(proposal.id);
|
||||||
|
}
|
||||||
|
// Notify admin if there are expired proposals
|
||||||
|
if (expiredDetails.length > 0 && process.env.ADMIN_EMAIL) {
|
||||||
|
try {
|
||||||
|
const { renderAdminRescheduleExpiredHTML } = await import("../lib/email-templates.js");
|
||||||
|
const { sendEmail } = await import("../lib/email.js");
|
||||||
|
const html = await renderAdminRescheduleExpiredHTML({
|
||||||
|
expiredProposals: expiredDetails,
|
||||||
|
});
|
||||||
|
await sendEmail({
|
||||||
|
to: process.env.ADMIN_EMAIL,
|
||||||
|
subject: `${expiredDetails.length} abgelaufene Terminänderungsvorschläge`,
|
||||||
|
text: `Es sind ${expiredDetails.length} Terminänderungsvorschläge abgelaufen. Details in der HTML-Version.`,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error("Failed to send admin notification for expired proposals:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { success: true, expiredCount: expiredDetails.length };
|
||||||
|
}),
|
||||||
|
};
|
||||||
79
server-dist/rpc/demo/ai.js
Normal file
79
server-dist/rpc/demo/ai.js
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import OpenAI from "openai";
|
||||||
|
import { os } from "@orpc/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { zodResponseFormat } from "../../lib/openai";
|
||||||
|
if (!process.env.OPENAI_BASE_URL) {
|
||||||
|
throw new Error("OPENAI_BASE_URL is not set");
|
||||||
|
}
|
||||||
|
if (!process.env.OPENAI_API_KEY) {
|
||||||
|
throw new Error("OPENAI_API_KEY is not set");
|
||||||
|
}
|
||||||
|
const openai = new OpenAI({
|
||||||
|
baseURL: process.env.OPENAI_BASE_URL,
|
||||||
|
apiKey: process.env.OPENAI_API_KEY,
|
||||||
|
});
|
||||||
|
if (!process.env.OPENAI_DEFAULT_MODEL) {
|
||||||
|
throw new Error("OPENAI_DEFAULT_MODEL is not set");
|
||||||
|
}
|
||||||
|
const DEFAULT_MODEL = process.env.OPENAI_DEFAULT_MODEL;
|
||||||
|
const ChatCompletionInputSchema = z.object({
|
||||||
|
message: z.string(),
|
||||||
|
systemPrompt: z.string().optional(),
|
||||||
|
});
|
||||||
|
const GeneratePersonInputSchema = z.object({
|
||||||
|
prompt: z.string(),
|
||||||
|
});
|
||||||
|
const complete = os
|
||||||
|
.input(ChatCompletionInputSchema)
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
const { message, systemPrompt } = input;
|
||||||
|
const completion = await openai.chat.completions.create({
|
||||||
|
model: DEFAULT_MODEL,
|
||||||
|
messages: [
|
||||||
|
...(systemPrompt
|
||||||
|
? [{ role: "system", content: systemPrompt }]
|
||||||
|
: []),
|
||||||
|
{ role: "user", content: message },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
response: completion.choices[0]?.message?.content || "",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
// Object generation schemas only support nullability, not optionality.
|
||||||
|
// Use .nullable() instead of .optional() for fields that may not have values.
|
||||||
|
const DemoSchema = z.object({
|
||||||
|
name: z.string().describe("The name of the person"),
|
||||||
|
age: z.number().describe("The age of the person"),
|
||||||
|
occupation: z.string().describe("The occupation of the person"),
|
||||||
|
bio: z.string().describe("The bio of the person"),
|
||||||
|
nickname: z
|
||||||
|
.string()
|
||||||
|
.nullable()
|
||||||
|
.describe("The person's nickname, if they have one"),
|
||||||
|
});
|
||||||
|
const generate = os
|
||||||
|
.input(GeneratePersonInputSchema)
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
const completion = await openai.chat.completions.parse({
|
||||||
|
model: DEFAULT_MODEL,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: `Generate a person based on this prompt: ${input.prompt}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
response_format: zodResponseFormat(DemoSchema, "person"),
|
||||||
|
});
|
||||||
|
const person = completion.choices[0]?.message?.parsed;
|
||||||
|
if (!person) {
|
||||||
|
throw new Error("No parsed data received from OpenAI");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
person,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
export const router = {
|
||||||
|
complete,
|
||||||
|
generate,
|
||||||
|
};
|
||||||
4
server-dist/rpc/demo/index.js
Normal file
4
server-dist/rpc/demo/index.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { router as storageRouter } from "./storage.js";
|
||||||
|
export const demo = {
|
||||||
|
storage: storageRouter,
|
||||||
|
};
|
||||||
42
server-dist/rpc/demo/storage.js
Normal file
42
server-dist/rpc/demo/storage.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { call, os } from "@orpc/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import { createKV } from "../../lib/create-kv.js";
|
||||||
|
const DemoSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
value: z.string(),
|
||||||
|
});
|
||||||
|
// createKV provides simple key-value storage with publisher/subscriber support
|
||||||
|
// perfect for live queries and small amounts of data
|
||||||
|
const kv = createKV("demo");
|
||||||
|
// Handler with input validation using .input() and schema
|
||||||
|
const create = os
|
||||||
|
.input(DemoSchema.omit({ id: true }))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
const id = randomUUID();
|
||||||
|
const item = { id, value: input.value };
|
||||||
|
await kv.setItem(id, item);
|
||||||
|
});
|
||||||
|
const remove = os.input(z.string()).handler(async ({ input }) => {
|
||||||
|
await kv.removeItem(input);
|
||||||
|
});
|
||||||
|
// Handler without input - returns all items
|
||||||
|
const list = os.handler(async () => {
|
||||||
|
return kv.getAllItems();
|
||||||
|
});
|
||||||
|
// Live data stream using generator function
|
||||||
|
// Yields initial data, then subscribes to changes for real-time updates
|
||||||
|
const live = {
|
||||||
|
list: os.handler(async function* ({ signal }) {
|
||||||
|
yield call(list, {}, { signal });
|
||||||
|
for await (const _ of kv.subscribe()) {
|
||||||
|
yield call(list, {}, { signal });
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
export const router = {
|
||||||
|
create,
|
||||||
|
remove,
|
||||||
|
list,
|
||||||
|
live,
|
||||||
|
};
|
||||||
131
server-dist/rpc/gallery.js
Normal file
131
server-dist/rpc/gallery.js
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { call, os } from "@orpc/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import { createKV } from "../lib/create-kv.js";
|
||||||
|
import { assertOwner } from "../lib/auth.js";
|
||||||
|
// Schema Definition
|
||||||
|
const GalleryPhotoSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
base64Data: z.string(),
|
||||||
|
title: z.string().optional().default(""),
|
||||||
|
order: z.number().int(),
|
||||||
|
createdAt: z.string(),
|
||||||
|
cover: z.boolean().optional().default(false),
|
||||||
|
});
|
||||||
|
// KV Storage
|
||||||
|
const galleryPhotosKV = createKV("galleryPhotos");
|
||||||
|
// Authentication centralized in ../lib/auth.ts
|
||||||
|
// CRUD Endpoints
|
||||||
|
const uploadPhoto = os
|
||||||
|
.input(z.object({
|
||||||
|
sessionId: z.string(),
|
||||||
|
base64Data: z
|
||||||
|
.string()
|
||||||
|
.regex(/^data:image\/(png|jpe?g|webp|gif);base64,/i, 'Unsupported image format'),
|
||||||
|
title: z.string().optional().default(""),
|
||||||
|
}))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
const id = randomUUID();
|
||||||
|
const existing = await galleryPhotosKV.getAllItems();
|
||||||
|
const maxOrder = existing.length > 0 ? Math.max(...existing.map((p) => p.order)) : -1;
|
||||||
|
const nextOrder = maxOrder + 1;
|
||||||
|
const photo = {
|
||||||
|
id,
|
||||||
|
base64Data: input.base64Data,
|
||||||
|
title: input.title ?? "",
|
||||||
|
order: nextOrder,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
cover: false,
|
||||||
|
};
|
||||||
|
await galleryPhotosKV.setItem(id, photo);
|
||||||
|
return photo;
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error("gallery.uploadPhoto error", err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const setCoverPhoto = os
|
||||||
|
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
const all = await galleryPhotosKV.getAllItems();
|
||||||
|
let updatedCover = null;
|
||||||
|
for (const p of all) {
|
||||||
|
const isCover = p.id === input.id;
|
||||||
|
const next = { ...p, cover: isCover };
|
||||||
|
await galleryPhotosKV.setItem(p.id, next);
|
||||||
|
if (isCover)
|
||||||
|
updatedCover = next;
|
||||||
|
}
|
||||||
|
return updatedCover;
|
||||||
|
});
|
||||||
|
const deletePhoto = os
|
||||||
|
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
await galleryPhotosKV.removeItem(input.id);
|
||||||
|
});
|
||||||
|
const updatePhotoOrder = os
|
||||||
|
.input(z.object({
|
||||||
|
sessionId: z.string(),
|
||||||
|
photoOrders: z.array(z.object({ id: z.string(), order: z.number().int() })),
|
||||||
|
}))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
const updated = [];
|
||||||
|
for (const { id, order } of input.photoOrders) {
|
||||||
|
const existing = await galleryPhotosKV.getItem(id);
|
||||||
|
if (!existing)
|
||||||
|
continue;
|
||||||
|
const updatedPhoto = { ...existing, order };
|
||||||
|
await galleryPhotosKV.setItem(id, updatedPhoto);
|
||||||
|
updated.push(updatedPhoto);
|
||||||
|
}
|
||||||
|
const all = await galleryPhotosKV.getAllItems();
|
||||||
|
return all.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
||||||
|
});
|
||||||
|
const listPhotos = os.handler(async () => {
|
||||||
|
const all = await galleryPhotosKV.getAllItems();
|
||||||
|
return all.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
||||||
|
});
|
||||||
|
const adminListPhotos = os
|
||||||
|
.input(z.object({ sessionId: z.string() }))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
const all = await galleryPhotosKV.getAllItems();
|
||||||
|
return all.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
||||||
|
});
|
||||||
|
// Live Queries
|
||||||
|
const live = {
|
||||||
|
listPhotos: os.handler(async function* ({ signal }) {
|
||||||
|
yield call(listPhotos, {}, { signal });
|
||||||
|
for await (const _ of galleryPhotosKV.subscribe()) {
|
||||||
|
yield call(listPhotos, {}, { signal });
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
adminListPhotos: os
|
||||||
|
.input(z.object({ sessionId: z.string() }))
|
||||||
|
.handler(async function* ({ input, signal }) {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
const all = await galleryPhotosKV.getAllItems();
|
||||||
|
const sorted = all.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
||||||
|
yield sorted;
|
||||||
|
for await (const _ of galleryPhotosKV.subscribe()) {
|
||||||
|
const updated = await galleryPhotosKV.getAllItems();
|
||||||
|
const sortedUpdated = updated.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
||||||
|
yield sortedUpdated;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
export const router = {
|
||||||
|
uploadPhoto,
|
||||||
|
deletePhoto,
|
||||||
|
updatePhotoOrder,
|
||||||
|
listPhotos,
|
||||||
|
adminListPhotos,
|
||||||
|
setCoverPhoto,
|
||||||
|
live,
|
||||||
|
};
|
||||||
22
server-dist/rpc/index.js
Normal file
22
server-dist/rpc/index.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { demo } from "./demo/index.js";
|
||||||
|
import { router as treatments } from "./treatments.js";
|
||||||
|
import { router as bookings } from "./bookings.js";
|
||||||
|
import { router as auth } from "./auth.js";
|
||||||
|
import { router as recurringAvailability } from "./recurring-availability.js";
|
||||||
|
import { router as cancellation } from "./cancellation.js";
|
||||||
|
import { router as legal } from "./legal.js";
|
||||||
|
import { router as gallery } from "./gallery.js";
|
||||||
|
import { router as reviews } from "./reviews.js";
|
||||||
|
import { router as social } from "./social.js";
|
||||||
|
export const router = {
|
||||||
|
demo,
|
||||||
|
treatments,
|
||||||
|
bookings,
|
||||||
|
auth,
|
||||||
|
recurringAvailability,
|
||||||
|
cancellation,
|
||||||
|
legal,
|
||||||
|
gallery,
|
||||||
|
reviews,
|
||||||
|
social,
|
||||||
|
};
|
||||||
16
server-dist/rpc/legal.js
Normal file
16
server-dist/rpc/legal.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { os } from "@orpc/server";
|
||||||
|
import { getLegalConfig } from "../lib/legal-config.js";
|
||||||
|
export const router = {
|
||||||
|
getConfig: os.handler(async () => {
|
||||||
|
console.log("Legal getConfig called");
|
||||||
|
try {
|
||||||
|
const config = getLegalConfig();
|
||||||
|
console.log("Legal config:", config);
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error("Legal config error:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
};
|
||||||
396
server-dist/rpc/recurring-availability.js
Normal file
396
server-dist/rpc/recurring-availability.js
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
import { call, os } from "@orpc/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import { createKV } from "../lib/create-kv.js";
|
||||||
|
import { assertOwner } from "../lib/auth.js";
|
||||||
|
// Datenmodelle
|
||||||
|
const RecurringRuleSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
dayOfWeek: z.number().int().min(0).max(6), // 0=Sonntag, 1=Montag, ..., 6=Samstag
|
||||||
|
startTime: z.string().regex(/^\d{2}:\d{2}$/), // HH:MM Format
|
||||||
|
endTime: z.string().regex(/^\d{2}:\d{2}$/), // HH:MM Format
|
||||||
|
isActive: z.boolean(),
|
||||||
|
createdAt: z.string(),
|
||||||
|
// LEGACY: slotDurationMinutes - deprecated field for generateSlots only, will be removed
|
||||||
|
slotDurationMinutes: z.number().int().min(1).optional(),
|
||||||
|
});
|
||||||
|
const TimeOffPeriodSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), // YYYY-MM-DD
|
||||||
|
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), // YYYY-MM-DD
|
||||||
|
reason: z.string(),
|
||||||
|
createdAt: z.string(),
|
||||||
|
});
|
||||||
|
// KV-Stores
|
||||||
|
const recurringRulesKV = createKV("recurringRules");
|
||||||
|
const timeOffPeriodsKV = createKV("timeOffPeriods");
|
||||||
|
// Import bookings and treatments KV stores for getAvailableTimes endpoint
|
||||||
|
const bookingsKV = createKV("bookings");
|
||||||
|
const treatmentsKV = createKV("treatments");
|
||||||
|
// Owner-Authentifizierung zentralisiert in ../lib/auth.ts
|
||||||
|
// Helper-Funktionen
|
||||||
|
function parseTime(timeStr) {
|
||||||
|
const [hours, minutes] = timeStr.split(':').map(Number);
|
||||||
|
return hours * 60 + minutes; // Minuten seit Mitternacht
|
||||||
|
}
|
||||||
|
function formatTime(minutes) {
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const mins = minutes % 60;
|
||||||
|
return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
function addDays(date, days) {
|
||||||
|
const result = new Date(date);
|
||||||
|
result.setDate(result.getDate() + days);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
function formatDate(date) {
|
||||||
|
return date.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
function isDateInTimeOffPeriod(date, periods) {
|
||||||
|
return periods.some(period => date >= period.startDate && date <= period.endDate);
|
||||||
|
}
|
||||||
|
// Helper-Funktion zur Erkennung überlappender Regeln
|
||||||
|
function detectOverlappingRules(newRule, existingRules) {
|
||||||
|
const newStart = parseTime(newRule.startTime);
|
||||||
|
const newEnd = parseTime(newRule.endTime);
|
||||||
|
return existingRules.filter(rule => {
|
||||||
|
// Gleicher Wochentag und nicht dieselbe Regel (bei Updates)
|
||||||
|
if (rule.dayOfWeek !== newRule.dayOfWeek || rule.id === newRule.id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const existingStart = parseTime(rule.startTime);
|
||||||
|
const existingEnd = parseTime(rule.endTime);
|
||||||
|
// Überlappung wenn: neue Startzeit < bestehende Endzeit UND neue Endzeit > bestehende Startzeit
|
||||||
|
return newStart < existingEnd && newEnd > existingStart;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// CRUD-Endpoints für Recurring Rules
|
||||||
|
const createRule = os
|
||||||
|
.input(z.object({
|
||||||
|
sessionId: z.string(),
|
||||||
|
dayOfWeek: z.number().int().min(0).max(6),
|
||||||
|
startTime: z.string().regex(/^\d{2}:\d{2}$/),
|
||||||
|
endTime: z.string().regex(/^\d{2}:\d{2}$/),
|
||||||
|
}).passthrough())
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
// Validierung: startTime < endTime
|
||||||
|
const startMinutes = parseTime(input.startTime);
|
||||||
|
const endMinutes = parseTime(input.endTime);
|
||||||
|
if (startMinutes >= endMinutes) {
|
||||||
|
throw new Error("Startzeit muss vor der Endzeit liegen.");
|
||||||
|
}
|
||||||
|
// Überlappungsprüfung
|
||||||
|
const allRules = await recurringRulesKV.getAllItems();
|
||||||
|
const overlappingRules = detectOverlappingRules(input, allRules);
|
||||||
|
if (overlappingRules.length > 0) {
|
||||||
|
const overlappingTimes = overlappingRules.map(rule => `${rule.startTime}-${rule.endTime}`).join(", ");
|
||||||
|
throw new Error(`Überlappung mit bestehenden Regeln erkannt: ${overlappingTimes}. Bitte Zeitfenster anpassen.`);
|
||||||
|
}
|
||||||
|
const id = randomUUID();
|
||||||
|
const rule = {
|
||||||
|
id,
|
||||||
|
dayOfWeek: input.dayOfWeek,
|
||||||
|
startTime: input.startTime,
|
||||||
|
endTime: input.endTime,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await recurringRulesKV.setItem(id, rule);
|
||||||
|
return rule;
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error("recurring-availability.createRule error", err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const updateRule = os
|
||||||
|
.input(RecurringRuleSchema.extend({ sessionId: z.string() }).passthrough())
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
// Validierung: startTime < endTime
|
||||||
|
const startMinutes = parseTime(input.startTime);
|
||||||
|
const endMinutes = parseTime(input.endTime);
|
||||||
|
if (startMinutes >= endMinutes) {
|
||||||
|
throw new Error("Startzeit muss vor der Endzeit liegen.");
|
||||||
|
}
|
||||||
|
// Überlappungsprüfung
|
||||||
|
const allRules = await recurringRulesKV.getAllItems();
|
||||||
|
const overlappingRules = detectOverlappingRules(input, allRules);
|
||||||
|
if (overlappingRules.length > 0) {
|
||||||
|
const overlappingTimes = overlappingRules.map(rule => `${rule.startTime}-${rule.endTime}`).join(", ");
|
||||||
|
throw new Error(`Überlappung mit bestehenden Regeln erkannt: ${overlappingTimes}. Bitte Zeitfenster anpassen.`);
|
||||||
|
}
|
||||||
|
const { sessionId, ...rule } = input;
|
||||||
|
await recurringRulesKV.setItem(rule.id, rule);
|
||||||
|
return rule;
|
||||||
|
});
|
||||||
|
const deleteRule = os
|
||||||
|
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
await recurringRulesKV.removeItem(input.id);
|
||||||
|
});
|
||||||
|
const toggleRuleActive = os
|
||||||
|
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
const rule = await recurringRulesKV.getItem(input.id);
|
||||||
|
if (!rule)
|
||||||
|
throw new Error("Regel nicht gefunden.");
|
||||||
|
rule.isActive = !rule.isActive;
|
||||||
|
await recurringRulesKV.setItem(input.id, rule);
|
||||||
|
return rule;
|
||||||
|
});
|
||||||
|
const listRules = os.handler(async () => {
|
||||||
|
const allRules = await recurringRulesKV.getAllItems();
|
||||||
|
return allRules.sort((a, b) => {
|
||||||
|
if (a.dayOfWeek !== b.dayOfWeek)
|
||||||
|
return a.dayOfWeek - b.dayOfWeek;
|
||||||
|
return a.startTime.localeCompare(b.startTime);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const adminListRules = os
|
||||||
|
.input(z.object({ sessionId: z.string() }))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
const allRules = await recurringRulesKV.getAllItems();
|
||||||
|
return allRules.sort((a, b) => {
|
||||||
|
if (a.dayOfWeek !== b.dayOfWeek)
|
||||||
|
return a.dayOfWeek - b.dayOfWeek;
|
||||||
|
return a.startTime.localeCompare(b.startTime);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// CRUD-Endpoints für Time-Off Periods
|
||||||
|
const createTimeOff = os
|
||||||
|
.input(z.object({
|
||||||
|
sessionId: z.string(),
|
||||||
|
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
reason: z.string(),
|
||||||
|
}))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
// Validierung: startDate <= endDate
|
||||||
|
if (input.startDate > input.endDate) {
|
||||||
|
throw new Error("Startdatum muss vor oder am Enddatum liegen.");
|
||||||
|
}
|
||||||
|
const id = randomUUID();
|
||||||
|
const timeOff = {
|
||||||
|
id,
|
||||||
|
startDate: input.startDate,
|
||||||
|
endDate: input.endDate,
|
||||||
|
reason: input.reason,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await timeOffPeriodsKV.setItem(id, timeOff);
|
||||||
|
return timeOff;
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error("recurring-availability.createTimeOff error", err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const updateTimeOff = os
|
||||||
|
.input(TimeOffPeriodSchema.extend({ sessionId: z.string() }).passthrough())
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
// Validierung: startDate <= endDate
|
||||||
|
if (input.startDate > input.endDate) {
|
||||||
|
throw new Error("Startdatum muss vor oder am Enddatum liegen.");
|
||||||
|
}
|
||||||
|
const { sessionId, ...timeOff } = input;
|
||||||
|
await timeOffPeriodsKV.setItem(timeOff.id, timeOff);
|
||||||
|
return timeOff;
|
||||||
|
});
|
||||||
|
const deleteTimeOff = os
|
||||||
|
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
await timeOffPeriodsKV.removeItem(input.id);
|
||||||
|
});
|
||||||
|
const listTimeOff = os.handler(async () => {
|
||||||
|
const allTimeOff = await timeOffPeriodsKV.getAllItems();
|
||||||
|
return allTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate));
|
||||||
|
});
|
||||||
|
const adminListTimeOff = os
|
||||||
|
.input(z.object({ sessionId: z.string() }))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
const allTimeOff = await timeOffPeriodsKV.getAllItems();
|
||||||
|
return allTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate));
|
||||||
|
});
|
||||||
|
// Get Available Times Endpoint
|
||||||
|
const getAvailableTimes = os
|
||||||
|
.input(z.object({
|
||||||
|
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
treatmentId: z.string(),
|
||||||
|
}))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
// Validate that the date is not in the past
|
||||||
|
const today = new Date();
|
||||||
|
const inputDate = new Date(input.date);
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
inputDate.setHours(0, 0, 0, 0);
|
||||||
|
if (inputDate < today) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
// Get treatment duration
|
||||||
|
const treatment = await treatmentsKV.getItem(input.treatmentId);
|
||||||
|
if (!treatment) {
|
||||||
|
throw new Error("Behandlung nicht gefunden.");
|
||||||
|
}
|
||||||
|
const treatmentDuration = treatment.duration;
|
||||||
|
// Parse the date to get day of week
|
||||||
|
const [year, month, day] = input.date.split('-').map(Number);
|
||||||
|
const localDate = new Date(year, month - 1, day);
|
||||||
|
const dayOfWeek = localDate.getDay(); // 0=Sonntag, 1=Montag, ...
|
||||||
|
// Find matching recurring rules
|
||||||
|
const allRules = await recurringRulesKV.getAllItems();
|
||||||
|
const matchingRules = allRules.filter(rule => rule.isActive === true && rule.dayOfWeek === dayOfWeek);
|
||||||
|
if (matchingRules.length === 0) {
|
||||||
|
return []; // No rules for this day of week
|
||||||
|
}
|
||||||
|
// Check time-off periods
|
||||||
|
const timeOffPeriods = await timeOffPeriodsKV.getAllItems();
|
||||||
|
if (isDateInTimeOffPeriod(input.date, timeOffPeriods)) {
|
||||||
|
return []; // Date is blocked by time-off period
|
||||||
|
}
|
||||||
|
// Generate 15-minute intervals with boundary alignment
|
||||||
|
const availableTimes = [];
|
||||||
|
// Helper functions for 15-minute boundary alignment
|
||||||
|
const ceilTo15 = (m) => m % 15 === 0 ? m : m + (15 - (m % 15));
|
||||||
|
const floorTo15 = (m) => m - (m % 15);
|
||||||
|
for (const rule of matchingRules) {
|
||||||
|
const startMinutes = parseTime(rule.startTime);
|
||||||
|
const endMinutes = parseTime(rule.endTime);
|
||||||
|
let currentMinutes = ceilTo15(startMinutes);
|
||||||
|
const endBound = floorTo15(endMinutes);
|
||||||
|
while (currentMinutes + treatmentDuration <= endBound) {
|
||||||
|
const timeStr = formatTime(currentMinutes);
|
||||||
|
availableTimes.push(timeStr);
|
||||||
|
currentMinutes += 15; // 15-minute intervals
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Get all bookings for this date and their treatments
|
||||||
|
const allBookings = await bookingsKV.getAllItems();
|
||||||
|
const dateBookings = allBookings.filter(booking => booking.appointmentDate === input.date &&
|
||||||
|
['pending', 'confirmed', 'completed'].includes(booking.status));
|
||||||
|
// Optimize treatment duration lookup with Map caching
|
||||||
|
const uniqueTreatmentIds = [...new Set(dateBookings.map(booking => booking.treatmentId))];
|
||||||
|
const treatmentDurationMap = new Map();
|
||||||
|
for (const treatmentId of uniqueTreatmentIds) {
|
||||||
|
const treatment = await treatmentsKV.getItem(treatmentId);
|
||||||
|
treatmentDurationMap.set(treatmentId, treatment?.duration || 60);
|
||||||
|
}
|
||||||
|
// Get treatment durations for all bookings using the cached map
|
||||||
|
const bookingTreatments = new Map();
|
||||||
|
for (const booking of dateBookings) {
|
||||||
|
// Use bookedDurationMinutes if available, otherwise fallback to treatment duration
|
||||||
|
const duration = booking.bookedDurationMinutes || treatmentDurationMap.get(booking.treatmentId) || 60;
|
||||||
|
bookingTreatments.set(booking.id, duration);
|
||||||
|
}
|
||||||
|
// Filter out booking conflicts
|
||||||
|
const availableTimesFiltered = availableTimes.filter(slotTime => {
|
||||||
|
const slotStartMinutes = parseTime(slotTime);
|
||||||
|
const slotEndMinutes = slotStartMinutes + treatmentDuration;
|
||||||
|
// Check if this slot overlaps with any existing booking
|
||||||
|
const hasConflict = dateBookings.some(booking => {
|
||||||
|
const bookingStartMinutes = parseTime(booking.appointmentTime);
|
||||||
|
const bookingDuration = bookingTreatments.get(booking.id) || 60;
|
||||||
|
const bookingEndMinutes = bookingStartMinutes + bookingDuration;
|
||||||
|
// Check overlap: slotStart < bookingEnd && slotEnd > bookingStart
|
||||||
|
return slotStartMinutes < bookingEndMinutes && slotEndMinutes > bookingStartMinutes;
|
||||||
|
});
|
||||||
|
return !hasConflict;
|
||||||
|
});
|
||||||
|
// Filter out past times for today
|
||||||
|
const now = new Date();
|
||||||
|
const isToday = inputDate.getTime() === today.getTime();
|
||||||
|
const finalAvailableTimes = isToday
|
||||||
|
? availableTimesFiltered.filter(timeStr => {
|
||||||
|
const slotTime = parseTime(timeStr);
|
||||||
|
const currentTime = now.getHours() * 60 + now.getMinutes();
|
||||||
|
return slotTime > currentTime;
|
||||||
|
})
|
||||||
|
: availableTimesFiltered;
|
||||||
|
// Deduplicate and sort chronologically
|
||||||
|
const unique = Array.from(new Set(finalAvailableTimes));
|
||||||
|
return unique.sort((a, b) => a.localeCompare(b));
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error("recurring-availability.getAvailableTimes error", err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Live-Queries
|
||||||
|
const live = {
|
||||||
|
listRules: os.handler(async function* ({ signal }) {
|
||||||
|
yield call(listRules, {}, { signal });
|
||||||
|
for await (const _ of recurringRulesKV.subscribe()) {
|
||||||
|
yield call(listRules, {}, { signal });
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
listTimeOff: os.handler(async function* ({ signal }) {
|
||||||
|
yield call(listTimeOff, {}, { signal });
|
||||||
|
for await (const _ of timeOffPeriodsKV.subscribe()) {
|
||||||
|
yield call(listTimeOff, {}, { signal });
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
adminListRules: os
|
||||||
|
.input(z.object({ sessionId: z.string() }))
|
||||||
|
.handler(async function* ({ input, signal }) {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
const allRules = await recurringRulesKV.getAllItems();
|
||||||
|
const sortedRules = allRules.sort((a, b) => {
|
||||||
|
if (a.dayOfWeek !== b.dayOfWeek)
|
||||||
|
return a.dayOfWeek - b.dayOfWeek;
|
||||||
|
return a.startTime.localeCompare(b.startTime);
|
||||||
|
});
|
||||||
|
yield sortedRules;
|
||||||
|
for await (const _ of recurringRulesKV.subscribe()) {
|
||||||
|
const updatedRules = await recurringRulesKV.getAllItems();
|
||||||
|
const sortedUpdatedRules = updatedRules.sort((a, b) => {
|
||||||
|
if (a.dayOfWeek !== b.dayOfWeek)
|
||||||
|
return a.dayOfWeek - b.dayOfWeek;
|
||||||
|
return a.startTime.localeCompare(b.startTime);
|
||||||
|
});
|
||||||
|
yield sortedUpdatedRules;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
adminListTimeOff: os
|
||||||
|
.input(z.object({ sessionId: z.string() }))
|
||||||
|
.handler(async function* ({ input, signal }) {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
const allTimeOff = await timeOffPeriodsKV.getAllItems();
|
||||||
|
const sortedTimeOff = allTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate));
|
||||||
|
yield sortedTimeOff;
|
||||||
|
for await (const _ of timeOffPeriodsKV.subscribe()) {
|
||||||
|
const updatedTimeOff = await timeOffPeriodsKV.getAllItems();
|
||||||
|
const sortedUpdatedTimeOff = updatedTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate));
|
||||||
|
yield sortedUpdatedTimeOff;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
export const router = {
|
||||||
|
// Recurring Rules
|
||||||
|
createRule,
|
||||||
|
updateRule,
|
||||||
|
deleteRule,
|
||||||
|
toggleRuleActive,
|
||||||
|
listRules,
|
||||||
|
adminListRules,
|
||||||
|
// Time-Off Periods
|
||||||
|
createTimeOff,
|
||||||
|
updateTimeOff,
|
||||||
|
deleteTimeOff,
|
||||||
|
listTimeOff,
|
||||||
|
adminListTimeOff,
|
||||||
|
// Availability
|
||||||
|
getAvailableTimes,
|
||||||
|
// Live queries
|
||||||
|
live,
|
||||||
|
};
|
||||||
220
server-dist/rpc/reviews.js
Normal file
220
server-dist/rpc/reviews.js
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import { call, os } from "@orpc/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { createKV } from "../lib/create-kv.js";
|
||||||
|
import { assertOwner, sessionsKV } from "../lib/auth.js";
|
||||||
|
// Schema Definition
|
||||||
|
const ReviewSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
bookingId: z.string(),
|
||||||
|
customerName: z.string().min(2, "Kundenname muss mindestens 2 Zeichen lang sein"),
|
||||||
|
customerEmail: z.string().email("Ungültige E-Mail-Adresse").optional(),
|
||||||
|
rating: z.number().int().min(1).max(5),
|
||||||
|
comment: z.string().min(10, "Kommentar muss mindestens 10 Zeichen lang sein"),
|
||||||
|
status: z.enum(["pending", "approved", "rejected"]),
|
||||||
|
createdAt: z.string(),
|
||||||
|
reviewedAt: z.string().optional(),
|
||||||
|
reviewedBy: z.string().optional(),
|
||||||
|
});
|
||||||
|
// KV Storage
|
||||||
|
const reviewsKV = createKV("reviews");
|
||||||
|
const cancellationKV = createKV("cancellation_tokens");
|
||||||
|
const bookingsKV = createKV("bookings");
|
||||||
|
// Helper Function: validateBookingToken
|
||||||
|
async function validateBookingToken(token) {
|
||||||
|
const tokens = await cancellationKV.getAllItems();
|
||||||
|
const validToken = tokens.find(t => t.token === token &&
|
||||||
|
new Date(t.expiresAt) > new Date() &&
|
||||||
|
t.purpose === 'booking_access');
|
||||||
|
if (!validToken) {
|
||||||
|
throw new Error("Ungültiger oder abgelaufener Buchungs-Token");
|
||||||
|
}
|
||||||
|
const booking = await bookingsKV.getItem(validToken.bookingId);
|
||||||
|
if (!booking) {
|
||||||
|
throw new Error("Buchung nicht gefunden");
|
||||||
|
}
|
||||||
|
// Only allow reviews for completed appointments
|
||||||
|
if (!(booking.status === "completed")) {
|
||||||
|
throw new Error("Bewertungen sind nur für abgeschlossene Termine möglich");
|
||||||
|
}
|
||||||
|
return booking;
|
||||||
|
}
|
||||||
|
// Public Endpoint: submitReview
|
||||||
|
const submitReview = os
|
||||||
|
.input(z.object({
|
||||||
|
bookingToken: z.string(),
|
||||||
|
rating: z.number().int().min(1).max(5),
|
||||||
|
comment: z.string().min(10, "Kommentar muss mindestens 10 Zeichen lang sein"),
|
||||||
|
}))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
// Validate bookingToken
|
||||||
|
const booking = await validateBookingToken(input.bookingToken);
|
||||||
|
// Enforce uniqueness by using booking.id as the KV key
|
||||||
|
const existing = await reviewsKV.getItem(booking.id);
|
||||||
|
if (existing) {
|
||||||
|
throw new Error("Für diese Buchung wurde bereits eine Bewertung abgegeben");
|
||||||
|
}
|
||||||
|
// Create review object
|
||||||
|
const review = {
|
||||||
|
id: booking.id,
|
||||||
|
bookingId: booking.id,
|
||||||
|
customerName: booking.customerName,
|
||||||
|
customerEmail: booking.customerEmail,
|
||||||
|
rating: input.rating,
|
||||||
|
comment: input.comment,
|
||||||
|
status: "pending",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await reviewsKV.setItem(booking.id, review);
|
||||||
|
return review;
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error("reviews.submitReview error", err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Admin Endpoint: approveReview
|
||||||
|
const approveReview = os
|
||||||
|
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
const review = await reviewsKV.getItem(input.id);
|
||||||
|
if (!review) {
|
||||||
|
throw new Error("Bewertung nicht gefunden");
|
||||||
|
}
|
||||||
|
const session = await sessionsKV.getItem(input.sessionId).catch(() => undefined);
|
||||||
|
const updatedReview = {
|
||||||
|
...review,
|
||||||
|
status: "approved",
|
||||||
|
reviewedAt: new Date().toISOString(),
|
||||||
|
reviewedBy: session?.userId || review.reviewedBy,
|
||||||
|
};
|
||||||
|
await reviewsKV.setItem(input.id, updatedReview);
|
||||||
|
return updatedReview;
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error("reviews.approveReview error", err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Admin Endpoint: rejectReview
|
||||||
|
const rejectReview = os
|
||||||
|
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
const review = await reviewsKV.getItem(input.id);
|
||||||
|
if (!review) {
|
||||||
|
throw new Error("Bewertung nicht gefunden");
|
||||||
|
}
|
||||||
|
const session = await sessionsKV.getItem(input.sessionId).catch(() => undefined);
|
||||||
|
const updatedReview = {
|
||||||
|
...review,
|
||||||
|
status: "rejected",
|
||||||
|
reviewedAt: new Date().toISOString(),
|
||||||
|
reviewedBy: session?.userId || review.reviewedBy,
|
||||||
|
};
|
||||||
|
await reviewsKV.setItem(input.id, updatedReview);
|
||||||
|
return updatedReview;
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error("reviews.rejectReview error", err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Admin Endpoint: deleteReview
|
||||||
|
const deleteReview = os
|
||||||
|
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
await reviewsKV.removeItem(input.id);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error("reviews.deleteReview error", err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Public Endpoint: listPublishedReviews
|
||||||
|
const listPublishedReviews = os.handler(async () => {
|
||||||
|
try {
|
||||||
|
const allReviews = await reviewsKV.getAllItems();
|
||||||
|
const published = allReviews.filter(r => r.status === "approved");
|
||||||
|
const sorted = published.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||||
|
const publicSafe = sorted.map(r => ({
|
||||||
|
customerName: r.customerName,
|
||||||
|
rating: r.rating,
|
||||||
|
comment: r.comment,
|
||||||
|
status: r.status,
|
||||||
|
bookingId: r.bookingId,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
}));
|
||||||
|
return publicSafe;
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error("reviews.listPublishedReviews error", err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Admin Endpoint: adminListReviews
|
||||||
|
const adminListReviews = os
|
||||||
|
.input(z.object({
|
||||||
|
sessionId: z.string(),
|
||||||
|
statusFilter: z.enum(["all", "pending", "approved", "rejected"]).optional().default("all"),
|
||||||
|
}))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
const allReviews = await reviewsKV.getAllItems();
|
||||||
|
const filtered = input.statusFilter === "all"
|
||||||
|
? allReviews
|
||||||
|
: allReviews.filter(r => r.status === input.statusFilter);
|
||||||
|
const sorted = filtered.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error("reviews.adminListReviews error", err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Live Queries
|
||||||
|
const live = {
|
||||||
|
listPublishedReviews: os.handler(async function* ({ signal }) {
|
||||||
|
yield call(listPublishedReviews, {}, { signal });
|
||||||
|
for await (const _ of reviewsKV.subscribe()) {
|
||||||
|
yield call(listPublishedReviews, {}, { signal });
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
adminListReviews: os
|
||||||
|
.input(z.object({
|
||||||
|
sessionId: z.string(),
|
||||||
|
statusFilter: z.enum(["all", "pending", "approved", "rejected"]).optional().default("all"),
|
||||||
|
}))
|
||||||
|
.handler(async function* ({ input, signal }) {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
const allReviews = await reviewsKV.getAllItems();
|
||||||
|
const filtered = input.statusFilter === "all"
|
||||||
|
? allReviews
|
||||||
|
: allReviews.filter(r => r.status === input.statusFilter);
|
||||||
|
const sorted = filtered.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||||
|
yield sorted;
|
||||||
|
for await (const _ of reviewsKV.subscribe()) {
|
||||||
|
const updated = await reviewsKV.getAllItems();
|
||||||
|
const filteredUpdated = input.statusFilter === "all"
|
||||||
|
? updated
|
||||||
|
: updated.filter(r => r.status === input.statusFilter);
|
||||||
|
const sortedUpdated = filteredUpdated.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||||
|
yield sortedUpdated;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
export const router = {
|
||||||
|
submitReview,
|
||||||
|
approveReview,
|
||||||
|
rejectReview,
|
||||||
|
deleteReview,
|
||||||
|
listPublishedReviews,
|
||||||
|
adminListReviews,
|
||||||
|
live,
|
||||||
|
};
|
||||||
10
server-dist/rpc/social.js
Normal file
10
server-dist/rpc/social.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { os } from "@orpc/server";
|
||||||
|
const getSocialMedia = os.handler(async () => {
|
||||||
|
return {
|
||||||
|
tiktokProfile: process.env.TIKTOK_PROFILE,
|
||||||
|
instagramProfile: process.env.INSTAGRAM_PROFILE,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
export const router = os.router({
|
||||||
|
getSocialMedia,
|
||||||
|
});
|
||||||
52
server-dist/rpc/treatments.js
Normal file
52
server-dist/rpc/treatments.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { call, os } from "@orpc/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import { createKV } from "../lib/create-kv.js";
|
||||||
|
const TreatmentSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
duration: z.number(), // duration in minutes
|
||||||
|
price: z.number(), // price in cents
|
||||||
|
category: z.string(),
|
||||||
|
});
|
||||||
|
const kv = createKV("treatments");
|
||||||
|
const create = os
|
||||||
|
.input(TreatmentSchema.omit({ id: true }))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
const id = randomUUID();
|
||||||
|
const treatment = { id, ...input };
|
||||||
|
await kv.setItem(id, treatment);
|
||||||
|
return treatment;
|
||||||
|
});
|
||||||
|
const update = os
|
||||||
|
.input(TreatmentSchema)
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
await kv.setItem(input.id, input);
|
||||||
|
return input;
|
||||||
|
});
|
||||||
|
const remove = os.input(z.string()).handler(async ({ input }) => {
|
||||||
|
await kv.removeItem(input);
|
||||||
|
});
|
||||||
|
const list = os.handler(async () => {
|
||||||
|
return kv.getAllItems();
|
||||||
|
});
|
||||||
|
const get = os.input(z.string()).handler(async ({ input }) => {
|
||||||
|
return kv.getItem(input);
|
||||||
|
});
|
||||||
|
const live = {
|
||||||
|
list: os.handler(async function* ({ signal }) {
|
||||||
|
yield call(list, {}, { signal });
|
||||||
|
for await (const _ of kv.subscribe()) {
|
||||||
|
yield call(list, {}, { signal });
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
export const router = {
|
||||||
|
create,
|
||||||
|
update,
|
||||||
|
remove,
|
||||||
|
list,
|
||||||
|
get,
|
||||||
|
live,
|
||||||
|
};
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { queryClient } from "@/client/rpc-client";
|
||||||
import { useAuth } from "@/client/components/auth-provider";
|
import { useAuth } from "@/client/components/auth-provider";
|
||||||
import { LoginForm } from "@/client/components/login-form";
|
import { LoginForm } from "@/client/components/login-form";
|
||||||
import { UserProfile } from "@/client/components/user-profile";
|
import { UserProfile } from "@/client/components/user-profile";
|
||||||
@@ -8,32 +10,53 @@ import { AdminBookings } from "@/client/components/admin-bookings";
|
|||||||
import { AdminCalendar } from "@/client/components/admin-calendar";
|
import { AdminCalendar } from "@/client/components/admin-calendar";
|
||||||
import { InitialDataLoader } from "@/client/components/initial-data-loader";
|
import { InitialDataLoader } from "@/client/components/initial-data-loader";
|
||||||
import { AdminAvailability } from "@/client/components/admin-availability";
|
import { AdminAvailability } from "@/client/components/admin-availability";
|
||||||
import CancellationPage from "@/client/components/cancellation-page";
|
import { AdminGallery } from "@/client/components/admin-gallery";
|
||||||
|
import { AdminReviews } from "@/client/components/admin-reviews";
|
||||||
|
import BookingStatusPage from "@/client/components/booking-status-page";
|
||||||
|
import ReviewSubmissionPage from "@/client/components/review-submission-page";
|
||||||
import LegalPage from "@/client/components/legal-page";
|
import LegalPage from "@/client/components/legal-page";
|
||||||
|
import { ProfileLanding } from "@/client/components/profile-landing";
|
||||||
|
import { PWAInstallPrompt } from "@/client/components/pwa-install-prompt";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { user, isLoading, isOwner } = useAuth();
|
const { user, isLoading, isOwner } = useAuth();
|
||||||
const [activeTab, setActiveTab] = useState<"booking" | "admin-treatments" | "admin-bookings" | "admin-calendar" | "admin-availability" | "profile" | "legal">("booking");
|
const [activeTab, setActiveTab] = useState<"profile-landing" | "booking" | "admin-treatments" | "admin-bookings" | "admin-calendar" | "admin-availability" | "admin-gallery" | "admin-reviews" | "profile" | "legal">("profile-landing");
|
||||||
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
|
|
||||||
// Check for cancellation token in URL
|
const { data: socialMedia } = useQuery(
|
||||||
|
queryClient.social.getSocialMedia.queryOptions()
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasSocialMedia = (socialMedia as any)?.tiktokProfile || (socialMedia as any)?.instagramProfile;
|
||||||
|
|
||||||
|
// Prevent background scroll when menu is open
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const path = window.location.pathname;
|
document.body.classList.toggle('overflow-hidden', isMobileMenuOpen);
|
||||||
if (path.startsWith('/cancel/')) {
|
return () => document.body.classList.remove('overflow-hidden');
|
||||||
const token = path.split('/cancel/')[1];
|
}, [isMobileMenuOpen]);
|
||||||
if (token) {
|
|
||||||
// Set a special state to show cancellation page
|
|
||||||
setActiveTab("cancellation" as any);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Handle cancellation page
|
// Handle booking status page
|
||||||
const path = window.location.pathname;
|
const path = window.location.pathname;
|
||||||
if (path.startsWith('/cancel/')) {
|
const PwaPrompt = <PWAInstallPrompt />;
|
||||||
const token = path.split('/cancel/')[1];
|
|
||||||
|
if (path.startsWith('/booking/')) {
|
||||||
|
const token = path.split('/booking/')[1];
|
||||||
if (token) {
|
if (token) {
|
||||||
return <CancellationPage token={token} />;
|
return <>
|
||||||
|
{PwaPrompt}
|
||||||
|
<BookingStatusPage token={token} />
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle review submission page
|
||||||
|
if (path.startsWith('/review/')) {
|
||||||
|
const token = path.split('/review/')[1];
|
||||||
|
if (token) {
|
||||||
|
return <>
|
||||||
|
{PwaPrompt}
|
||||||
|
<ReviewSubmissionPage token={token} />
|
||||||
|
</>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +77,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Show login form if user is not authenticated and trying to access admin features
|
// Show login form if user is not authenticated and trying to access admin features
|
||||||
const needsAuth = !user && (activeTab === "admin-treatments" || activeTab === "admin-bookings" || activeTab === "admin-calendar" || activeTab === "admin-availability" || activeTab === "profile");
|
const needsAuth = !user && (activeTab === "admin-treatments" || activeTab === "admin-bookings" || activeTab === "admin-calendar" || activeTab === "admin-availability" || activeTab === "admin-gallery" || activeTab === "admin-reviews" || activeTab === "profile");
|
||||||
if (needsAuth) {
|
if (needsAuth) {
|
||||||
return <LoginForm />;
|
return <LoginForm />;
|
||||||
}
|
}
|
||||||
@@ -65,12 +88,15 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
|
{ id: "profile-landing", label: "Startseite", icon: "🏠", requiresAuth: false },
|
||||||
{ id: "booking", label: "Termin buchen", icon: "📅", requiresAuth: false },
|
{ id: "booking", label: "Termin buchen", icon: "📅", requiresAuth: false },
|
||||||
{ id: "legal", label: "Impressum/Datenschutz", icon: "📋", requiresAuth: false },
|
{ id: "legal", label: "Impressum/Datenschutz", icon: "📋", requiresAuth: false },
|
||||||
{ id: "admin-treatments", label: "Behandlungen verwalten", icon: "💅", requiresAuth: true },
|
{ id: "admin-treatments", label: "Behandlungen verwalten", icon: "💅", requiresAuth: true },
|
||||||
{ id: "admin-bookings", label: "Buchungen verwalten", icon: "📋", requiresAuth: true },
|
{ id: "admin-bookings", label: "Buchungen verwalten", icon: "📋", requiresAuth: true },
|
||||||
{ id: "admin-calendar", label: "Kalender", icon: "📆", requiresAuth: true },
|
{ id: "admin-calendar", label: "Kalender", icon: "📆", requiresAuth: true },
|
||||||
{ id: "admin-availability", label: "Verfügbarkeiten", icon: "⏰", requiresAuth: true },
|
{ id: "admin-availability", label: "Verfügbarkeiten", icon: "⏰", requiresAuth: true },
|
||||||
|
{ id: "admin-gallery", label: "Photo-Wall", icon: "📸", requiresAuth: true },
|
||||||
|
{ id: "admin-reviews", label: "Bewertungen", icon: "⭐", requiresAuth: true },
|
||||||
...(user ? [{ id: "profile", label: "Profil", icon: "👤", requiresAuth: true }] : []),
|
...(user ? [{ id: "profile", label: "Profil", icon: "👤", requiresAuth: true }] : []),
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -84,7 +110,7 @@ function App() {
|
|||||||
<div className="flex justify-between items-center py-6">
|
<div className="flex justify-between items-center py-6">
|
||||||
<div
|
<div
|
||||||
className="flex items-center space-x-3 cursor-pointer hover:opacity-80 transition-opacity"
|
className="flex items-center space-x-3 cursor-pointer hover:opacity-80 transition-opacity"
|
||||||
onClick={() => setActiveTab("booking")}
|
onClick={() => setActiveTab("profile-landing")}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="/assets/stargilnails_logo_transparent_112.png"
|
src="/assets/stargilnails_logo_transparent_112.png"
|
||||||
@@ -97,11 +123,26 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Hamburger Button für Mobile */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Menü öffnen"
|
||||||
|
aria-controls="mobile-menu"
|
||||||
|
aria-expanded={isMobileMenuOpen}
|
||||||
|
className="md:hidden p-2 -ml-2 text-3xl text-gray-700 hover:text-pink-600 transition-colors"
|
||||||
|
onClick={() => setIsMobileMenuOpen(true)}
|
||||||
|
>
|
||||||
|
☰
|
||||||
|
</button>
|
||||||
|
|
||||||
{user && (
|
{user && (
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<span className="text-sm text-gray-600">
|
<span className="text-sm text-gray-600 hidden sm:inline">
|
||||||
Willkommen, {user.username}
|
Willkommen, {user.username}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="text-sm text-gray-600 sm:hidden">
|
||||||
|
{user.username}
|
||||||
|
</span>
|
||||||
{isOwner && (
|
{isOwner && (
|
||||||
<span className="bg-pink-100 text-pink-800 px-2 py-1 rounded-full text-xs font-medium">
|
<span className="bg-pink-100 text-pink-800 px-2 py-1 rounded-full text-xs font-medium">
|
||||||
Inhaber
|
Inhaber
|
||||||
@@ -113,8 +154,8 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Desktop Navigation */}
|
||||||
<nav className="bg-white shadow-sm">
|
<nav className="bg-white shadow-sm hidden md:block">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex space-x-8">
|
<div className="flex space-x-8">
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
@@ -159,8 +200,82 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{/* Mobile Backdrop */}
|
||||||
|
{isMobileMenuOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 z-40 md:hidden"
|
||||||
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile Slide-in Panel */}
|
||||||
|
<div
|
||||||
|
id="mobile-menu"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Navigation"
|
||||||
|
className={`fixed inset-y-0 left-0 w-64 bg-white shadow-xl z-50 md:hidden transform transition-transform duration-300 ease-in-out ${isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full'}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Menü schließen"
|
||||||
|
className="absolute top-4 right-4 text-2xl text-gray-600 hover:text-pink-600"
|
||||||
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
<img
|
||||||
|
src="/assets/stargilnails_logo_transparent_112.png"
|
||||||
|
alt="Stargil Nails Logo"
|
||||||
|
className="w-10 h-10 mb-6 object-contain"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<nav className="mt-2 flex flex-col space-y-2">
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
// Hide admin tabs for non-owners
|
||||||
|
if (tab.requiresAuth && !isOwner && tab.id !== 'profile') return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveTab(tab.id as any);
|
||||||
|
setIsMobileMenuOpen(false);
|
||||||
|
}}
|
||||||
|
className={`flex items-center space-x-3 px-4 py-3 rounded-lg transition-colors ${
|
||||||
|
activeTab === tab.id ? 'bg-pink-100 text-pink-600' : 'text-gray-700 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{tab.icon}</span>
|
||||||
|
<span>{tab.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{!user && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setActiveTab('profile');
|
||||||
|
setIsMobileMenuOpen(false);
|
||||||
|
}}
|
||||||
|
className="flex items-center space-x-3 px-4 py-3 rounded-lg text-gray-700 hover:bg-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
<span>🔑</span>
|
||||||
|
<span>Inhaber Login</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
{activeTab === "profile-landing" && (
|
||||||
|
<ProfileLanding onNavigateToBooking={() => setActiveTab("booking")} />
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === "booking" && (
|
{activeTab === "booking" && (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
@@ -225,13 +340,41 @@ function App() {
|
|||||||
Verfügbarkeiten verwalten
|
Verfügbarkeiten verwalten
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-lg text-gray-600">
|
<p className="text-lg text-gray-600">
|
||||||
Lege freie Slots an und entferne sie bei Bedarf.
|
Verwalte wiederkehrende Zeiten und Urlaubszeiten.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<AdminAvailability />
|
<AdminAvailability />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === "admin-gallery" && isOwner && (
|
||||||
|
<div>
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h2 className="text-3xl font-bold text-gray-900 mb-4">
|
||||||
|
Photo-Wall verwalten
|
||||||
|
</h2>
|
||||||
|
<p className="text-lg text-gray-600">
|
||||||
|
Lade Fotos hoch und verwalte deine Galerie.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<AdminGallery />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "admin-reviews" && isOwner && (
|
||||||
|
<div>
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h2 className="text-3xl font-bold text-gray-900 mb-4">
|
||||||
|
Bewertungen verwalten
|
||||||
|
</h2>
|
||||||
|
<p className="text-lg text-gray-600">
|
||||||
|
Prüfe und verwalte Kundenbewertungen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<AdminReviews />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === "profile" && user && (
|
{activeTab === "profile" && user && (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
@@ -247,11 +390,44 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{/* PWA Installation Prompt for iOS */}
|
||||||
|
<PWAInstallPrompt hidden={isMobileMenuOpen} />
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="bg-white border-t border-pink-100 mt-16">
|
<footer className="bg-white border-t border-pink-100 mt-16">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<div className="text-center text-gray-600">
|
<div className="text-center text-gray-600">
|
||||||
<p>© 2024 Stargirlnails Kiel. Professional nail care services.</p>
|
<p className="mb-4">© 2025 Stargirlnails Kiel. Professional nail design & care with 🩷 and passion in Kiel 🌇.</p>
|
||||||
|
{hasSocialMedia && (
|
||||||
|
<div className="flex justify-center items-center gap-3 mt-4">
|
||||||
|
{(socialMedia as any)?.instagramProfile && (
|
||||||
|
<a
|
||||||
|
href={(socialMedia as any).instagramProfile}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-pink-600 hover:text-pink-700 transition-colors"
|
||||||
|
aria-label="Instagram"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{(socialMedia as any)?.tiktokProfile && (
|
||||||
|
<a
|
||||||
|
href={(socialMedia as any).tiktokProfile}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-gray-800 hover:text-gray-900 transition-colors"
|
||||||
|
aria-label="TikTok"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1-.1z"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -4,18 +4,33 @@ import { queryClient } from "@/client/rpc-client";
|
|||||||
|
|
||||||
export function AdminAvailability() {
|
export function AdminAvailability() {
|
||||||
const today = new Date().toISOString().split("T")[0];
|
const today = new Date().toISOString().split("T")[0];
|
||||||
const [selectedDate, setSelectedDate] = useState<string>(today);
|
|
||||||
const [time, setTime] = useState<string>("09:00");
|
|
||||||
const [duration, setDuration] = useState<number>(30);
|
|
||||||
const [selectedTreatmentId, setSelectedTreatmentId] = useState<string>("");
|
|
||||||
const [slotType, setSlotType] = useState<"treatment" | "manual">("treatment");
|
|
||||||
|
|
||||||
const { data: allSlots } = useQuery(
|
// Tab-Navigation (Slots entfernt)
|
||||||
queryClient.availability.live.list.experimental_liveOptions()
|
const [activeSubTab, setActiveSubTab] = useState<"recurring" | "timeoff">("recurring");
|
||||||
|
|
||||||
|
// States für Recurring Rules
|
||||||
|
const [selectedDayOfWeek, setSelectedDayOfWeek] = useState<number>(1); // 1=Montag
|
||||||
|
const [ruleStartTime, setRuleStartTime] = useState<string>("13:00");
|
||||||
|
const [ruleEndTime, setRuleEndTime] = useState<string>("18:00");
|
||||||
|
const [editingRuleId, setEditingRuleId] = useState<string>("");
|
||||||
|
|
||||||
|
// States für Time-Off
|
||||||
|
const [timeOffStartDate, setTimeOffStartDate] = useState<string>("");
|
||||||
|
const [timeOffEndDate, setTimeOffEndDate] = useState<string>("");
|
||||||
|
const [timeOffReason, setTimeOffReason] = useState<string>("");
|
||||||
|
const [editingTimeOffId, setEditingTimeOffId] = useState<string>("");
|
||||||
|
|
||||||
|
|
||||||
|
// Neue Queries für wiederkehrende Verfügbarkeiten (mit Authentifizierung)
|
||||||
|
const { data: recurringRules, refetch: refetchRecurringRules } = useQuery(
|
||||||
|
queryClient.recurringAvailability.live.adminListRules.experimental_liveOptions({
|
||||||
|
input: { sessionId: localStorage.getItem("sessionId") || "" }
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
const { data: timeOffPeriods } = useQuery(
|
||||||
const { data: treatments } = useQuery(
|
queryClient.recurringAvailability.live.adminListTimeOff.experimental_liveOptions({
|
||||||
queryClient.treatments.live.list.experimental_liveOptions()
|
input: { sessionId: localStorage.getItem("sessionId") || "" }
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const [errorMsg, setErrorMsg] = useState<string>("");
|
const [errorMsg, setErrorMsg] = useState<string>("");
|
||||||
@@ -36,42 +51,129 @@ export function AdminAvailability() {
|
|||||||
}
|
}
|
||||||
}, [successMsg]);
|
}, [successMsg]);
|
||||||
|
|
||||||
const { mutate: createSlot, isPending: isCreating } = useMutation(
|
// Neue Mutations für wiederkehrende Verfügbarkeiten
|
||||||
queryClient.availability.create.mutationOptions()
|
const { mutate: createRule } = useMutation(
|
||||||
|
queryClient.recurringAvailability.createRule.mutationOptions()
|
||||||
);
|
);
|
||||||
const { mutate: removeSlot } = useMutation(
|
const { mutate: updateRule } = useMutation(
|
||||||
queryClient.availability.remove.mutationOptions()
|
queryClient.recurringAvailability.updateRule.mutationOptions()
|
||||||
|
);
|
||||||
|
const { mutate: deleteRule } = useMutation(
|
||||||
|
queryClient.recurringAvailability.deleteRule.mutationOptions()
|
||||||
|
);
|
||||||
|
const { mutate: toggleRuleActive } = useMutation(
|
||||||
|
queryClient.recurringAvailability.toggleRuleActive.mutationOptions()
|
||||||
|
);
|
||||||
|
const { mutate: createTimeOff } = useMutation(
|
||||||
|
queryClient.recurringAvailability.createTimeOff.mutationOptions()
|
||||||
|
);
|
||||||
|
const { mutate: updateTimeOff } = useMutation(
|
||||||
|
queryClient.recurringAvailability.updateTimeOff.mutationOptions()
|
||||||
|
);
|
||||||
|
const { mutate: deleteTimeOff } = useMutation(
|
||||||
|
queryClient.recurringAvailability.deleteTimeOff.mutationOptions()
|
||||||
);
|
);
|
||||||
|
|
||||||
// Auto-update duration when treatment is selected
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedTreatmentId && treatments) {
|
|
||||||
const treatment = treatments.find(t => t.id === selectedTreatmentId);
|
|
||||||
if (treatment) {
|
|
||||||
setDuration(treatment.duration);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [selectedTreatmentId, treatments]);
|
|
||||||
|
|
||||||
// Get selected treatment details
|
// Helper-Funktion für Wochentag-Namen
|
||||||
const selectedTreatment = treatments?.find(t => t.id === selectedTreatmentId);
|
const getDayName = (dayOfWeek: number): string => {
|
||||||
|
const days = ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"];
|
||||||
// Get treatment name for display
|
return days[dayOfWeek];
|
||||||
const getTreatmentName = (treatmentId: string) => {
|
|
||||||
return treatments?.find(t => t.id === treatmentId)?.name || "Unbekannte Behandlung";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const addSlot = () => {
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
|
{/* Tab-Navigation (Slots entfernt) */}
|
||||||
|
<div className="bg-white rounded-lg shadow">
|
||||||
|
<div className="border-b border-gray-200">
|
||||||
|
<nav className="-mb-px flex space-x-8 px-6">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveSubTab("recurring")}
|
||||||
|
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||||
|
activeSubTab === "recurring"
|
||||||
|
? "border-pink-500 text-pink-600"
|
||||||
|
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
🔄 Wiederkehrende Zeiten
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveSubTab("timeoff")}
|
||||||
|
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||||
|
activeSubTab === "timeoff"
|
||||||
|
? "border-pink-500 text-pink-600"
|
||||||
|
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
🏖️ Urlaubszeiten
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Slot-Tab und Slot-UI entfernt */}
|
||||||
|
|
||||||
|
{/* Tab "Wiederkehrende Zeiten" */}
|
||||||
|
{activeSubTab === "recurring" && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Neue Regel erstellen */}
|
||||||
|
<div className="bg-white rounded-lg shadow p-4">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Neue wiederkehrende Regel erstellen</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Wochentag
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedDayOfWeek}
|
||||||
|
onChange={(e) => setSelectedDayOfWeek(Number(e.target.value))}
|
||||||
|
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-pink-500"
|
||||||
|
>
|
||||||
|
<option value={1}>Montag</option>
|
||||||
|
<option value={2}>Dienstag</option>
|
||||||
|
<option value={3}>Mittwoch</option>
|
||||||
|
<option value={4}>Donnerstag</option>
|
||||||
|
<option value={5}>Freitag</option>
|
||||||
|
<option value={6}>Samstag</option>
|
||||||
|
<option value={0}>Sonntag</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Startzeit
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={ruleStartTime}
|
||||||
|
onChange={(e) => setRuleStartTime(e.target.value)}
|
||||||
|
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-pink-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Endzeit
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={ruleEndTime}
|
||||||
|
onChange={(e) => setRuleEndTime(e.target.value)}
|
||||||
|
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-pink-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
setErrorMsg("");
|
setErrorMsg("");
|
||||||
setSuccessMsg("");
|
setSuccessMsg("");
|
||||||
|
|
||||||
// Validation based on slot type
|
if (ruleStartTime >= ruleEndTime) {
|
||||||
if (slotType === "treatment" && !selectedTreatmentId) {
|
setErrorMsg("Startzeit muss vor der Endzeit liegen.");
|
||||||
setErrorMsg("Bitte eine Behandlung auswählen.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!selectedDate || !time || !duration) {
|
|
||||||
setErrorMsg("Bitte Datum, Uhrzeit und Dauer angeben.");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,247 +183,51 @@ export function AdminAvailability() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
createSlot(
|
createRule(
|
||||||
{ sessionId, date: selectedDate, time, durationMinutes: duration },
|
{ sessionId, dayOfWeek: selectedDayOfWeek, startTime: ruleStartTime, endTime: ruleEndTime },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
const slotDescription = slotType === "treatment"
|
setSuccessMsg(`Regel für ${getDayName(selectedDayOfWeek)} erstellt.`);
|
||||||
? `${getTreatmentName(selectedTreatmentId)} (${duration} Min)`
|
// Sofort aktualisieren (zusätzlich zur Live-Subscription), damit Nutzer den Eintrag direkt sieht
|
||||||
: `Manueller Slot (${duration} Min)`;
|
refetchRecurringRules();
|
||||||
setSuccessMsg(`${slotDescription} angelegt.`);
|
|
||||||
|
|
||||||
// advance time by the duration of the slot
|
|
||||||
const [hStr, mStr] = time.split(":");
|
|
||||||
let h = parseInt(hStr, 10);
|
|
||||||
let m = parseInt(mStr, 10);
|
|
||||||
m += duration;
|
|
||||||
if (m >= 60) { h += Math.floor(m / 60); m = m % 60; }
|
|
||||||
if (h >= 24) { h = h % 24; }
|
|
||||||
const next = `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
|
||||||
setTime(next);
|
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any) => {
|
||||||
const msg = (err && (err.message || (err as any).toString())) || "Fehler beim Anlegen.";
|
setErrorMsg(err?.message || "Fehler beim Erstellen der Regel.");
|
||||||
setErrorMsg(msg);
|
}
|
||||||
},
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
};
|
}}
|
||||||
|
className="bg-pink-600 text-white px-4 py-2 rounded-md hover:bg-pink-700 font-medium transition-colors"
|
||||||
return (
|
|
||||||
<div className="max-w-4xl mx-auto space-y-6">
|
|
||||||
{/* Slot Type Selection */}
|
|
||||||
<div className="bg-white rounded-lg shadow p-4">
|
|
||||||
<h3 className="text-lg font-semibold mb-4">Neuen Slot anlegen</h3>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2 mb-4">
|
|
||||||
<button
|
|
||||||
onClick={() => setSlotType("treatment")}
|
|
||||||
className={`px-4 py-2 rounded-md font-medium transition-colors ${
|
|
||||||
slotType === "treatment"
|
|
||||||
? "bg-pink-600 text-white"
|
|
||||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
💅 Behandlungs-Slot
|
Regel hinzufügen
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setSlotType("manual")}
|
|
||||||
className={`px-4 py-2 rounded-md font-medium transition-colors ${
|
|
||||||
slotType === "manual"
|
|
||||||
? "bg-pink-600 text-white"
|
|
||||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
⚙️ Manueller Slot
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Datum
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={selectedDate}
|
|
||||||
onChange={(e) => setSelectedDate(e.target.value)}
|
|
||||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-pink-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Startzeit
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="time"
|
|
||||||
value={time}
|
|
||||||
onChange={(e) => setTime(e.target.value)}
|
|
||||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-pink-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{slotType === "treatment" ? (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Behandlung
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={selectedTreatmentId}
|
|
||||||
onChange={(e) => setSelectedTreatmentId(e.target.value)}
|
|
||||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-pink-500"
|
|
||||||
>
|
|
||||||
<option value="">Behandlung wählen...</option>
|
|
||||||
{treatments?.map((treatment) => (
|
|
||||||
<option key={treatment.id} value={treatment.id}>
|
|
||||||
{treatment.name} ({treatment.duration} Min)
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Dauer (Minuten)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={5}
|
|
||||||
step={5}
|
|
||||||
value={duration}
|
|
||||||
onChange={(e) => setDuration(Number(e.target.value))}
|
|
||||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-pink-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-end">
|
|
||||||
<button
|
|
||||||
onClick={addSlot}
|
|
||||||
disabled={isCreating || (slotType === "treatment" && !selectedTreatmentId)}
|
|
||||||
className="w-full bg-pink-600 text-white px-4 py-2 rounded-md hover:bg-pink-700 disabled:bg-gray-400 disabled:cursor-not-allowed font-medium transition-colors"
|
|
||||||
>
|
|
||||||
{isCreating ? "Anlegen..." : "Slot hinzufügen"}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Treatment Info Display */}
|
{/* Bestehende Regeln */}
|
||||||
{slotType === "treatment" && selectedTreatment && (
|
|
||||||
<div className="mt-4 p-3 bg-pink-50 rounded-md border border-pink-200">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-pink-900">{selectedTreatment.name}</h4>
|
|
||||||
<p className="text-sm text-pink-700">{selectedTreatment.description}</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="text-lg font-semibold text-pink-900">
|
|
||||||
{(selectedTreatment.price / 100).toFixed(2)} €
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-pink-700">
|
|
||||||
{selectedTreatment.duration} Minuten
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(errorMsg || successMsg) && (
|
|
||||||
<div className="fixed top-4 right-4 z-50 max-w-md">
|
|
||||||
{errorMsg && (
|
|
||||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded-lg shadow-lg mb-2">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
<span className="font-medium">Fehler:</span>
|
|
||||||
<span className="ml-1">{errorMsg}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{successMsg && (
|
|
||||||
<div className="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded-lg shadow-lg">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
<span className="font-medium">Erfolg:</span>
|
|
||||||
<span className="ml-1">{successMsg}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Quick Stats */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<div className="bg-white rounded-lg shadow p-4">
|
|
||||||
<div className="text-2xl font-bold text-green-600">
|
|
||||||
{allSlots?.filter(s => s.status === "free").length || 0}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-600">Freie Slots</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-lg shadow p-4">
|
|
||||||
<div className="text-2xl font-bold text-yellow-600">
|
|
||||||
{allSlots?.filter(s => s.status === "reserved").length || 0}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-600">Reservierte Slots</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-lg shadow p-4">
|
|
||||||
<div className="text-2xl font-bold text-blue-600">
|
|
||||||
{allSlots?.length || 0}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-600">Slots gesamt</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* All Slots List */}
|
|
||||||
<div className="bg-white rounded-lg shadow">
|
<div className="bg-white rounded-lg shadow">
|
||||||
<div className="p-4 border-b">
|
<div className="p-4 border-b">
|
||||||
<h3 className="text-lg font-semibold">Alle Slots</h3>
|
<h3 className="text-lg font-semibold">Bestehende Regeln</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="divide-y">
|
<div className="divide-y">
|
||||||
{allSlots
|
{recurringRules?.length === 0 && (
|
||||||
?.sort((a, b) => (a.date === b.date ? a.time.localeCompare(b.time) : a.date.localeCompare(b.date)))
|
<div className="p-8 text-center text-gray-500">
|
||||||
.map((slot) => {
|
<div className="text-lg font-medium mb-2">Noch keine wiederkehrenden Regeln definiert</div>
|
||||||
// Try to find matching treatment based on duration
|
<div className="text-sm">Erstellen Sie Ihre erste Regel, um automatisch Slots zu generieren.</div>
|
||||||
const matchingTreatments = treatments?.filter(t => t.duration === slot.durationMinutes) || [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={slot.id} className="p-4 hover:bg-gray-50 transition-colors">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-sm text-gray-500">Datum</div>
|
|
||||||
<div className="font-medium">{new Date(slot.date).toLocaleDateString('de-DE')}</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-sm text-gray-500">Zeit</div>
|
|
||||||
<div className="font-mono text-lg">{slot.time}</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-sm text-gray-500">Dauer</div>
|
|
||||||
<div className="font-medium">{slot.durationMinutes} Min</div>
|
|
||||||
</div>
|
|
||||||
{matchingTreatments.length > 0 && (
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-sm text-gray-500">Passende Behandlungen</div>
|
|
||||||
<div className="text-sm">
|
|
||||||
{matchingTreatments.length === 1
|
|
||||||
? matchingTreatments[0].name
|
|
||||||
: `${matchingTreatments.length} Behandlungen`
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
|
{recurringRules?.map((rule) => (
|
||||||
slot.status === "free"
|
<div key={rule.id} className="p-4 hover:bg-gray-50 transition-colors">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="font-medium">{getDayName(rule.dayOfWeek)}</div>
|
||||||
|
<div className="text-gray-600">{rule.startTime} - {rule.endTime} Uhr</div>
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||||
|
rule.isActive
|
||||||
? "bg-green-100 text-green-800"
|
? "bg-green-100 text-green-800"
|
||||||
: "bg-yellow-100 text-yellow-800"
|
: "bg-gray-100 text-gray-800"
|
||||||
}`}>
|
}`}>
|
||||||
{slot.status === "free" ? "Frei" : "Reserviert"}
|
{rule.isActive ? "Aktiv" : "Inaktiv"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -332,53 +238,214 @@ export function AdminAvailability() {
|
|||||||
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
|
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
removeSlot(
|
toggleRuleActive(
|
||||||
{ sessionId, id: slot.id },
|
{ sessionId, id: rule.id },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setSuccessMsg("Slot erfolgreich gelöscht.");
|
setSuccessMsg(`Regel ${rule.isActive ? "deaktiviert" : "aktiviert"}.`);
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any) => {
|
||||||
const msg = (err && (err.message || (err as any).toString())) || "Fehler beim Löschen des Slots.";
|
setErrorMsg(err?.message || "Fehler beim Umschalten der Regel.");
|
||||||
setErrorMsg(msg);
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="px-3 py-1 text-blue-600 hover:bg-blue-50 rounded-md transition-colors text-sm"
|
||||||
|
>
|
||||||
|
{rule.isActive ? "Deaktivieren" : "Aktivieren"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const sessionId = localStorage.getItem("sessionId");
|
||||||
|
if (!sessionId) {
|
||||||
|
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deleteRule(
|
||||||
|
{ sessionId, id: rule.id },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setSuccessMsg("Regel gelöscht.");
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
setErrorMsg(err?.message || "Fehler beim Löschen der Regel.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
className="px-3 py-1 text-red-600 hover:bg-red-50 rounded-md transition-colors text-sm"
|
className="px-3 py-1 text-red-600 hover:bg-red-50 rounded-md transition-colors text-sm"
|
||||||
disabled={slot.status === "reserved"}
|
|
||||||
title={slot.status === "reserved" ? "Slot ist reserviert" : "Slot löschen"}
|
|
||||||
>
|
>
|
||||||
Löschen
|
Löschen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{/* Show matching treatments if multiple */}
|
|
||||||
{matchingTreatments.length > 1 && (
|
|
||||||
<div className="mt-2 ml-20">
|
|
||||||
<div className="text-xs text-gray-500 mb-1">Passende Behandlungen:</div>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{matchingTreatments.map(treatment => (
|
|
||||||
<span key={treatment.id} className="px-2 py-1 bg-pink-100 text-pink-700 rounded text-xs">
|
|
||||||
{treatment.name}
|
|
||||||
</span>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Tab "Urlaubszeiten" */}
|
||||||
|
{activeSubTab === "timeoff" && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Neue Urlaubszeit erstellen */}
|
||||||
|
<div className="bg-white rounded-lg shadow p-4">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Neue Urlaubszeit erstellen</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Von Datum
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={timeOffStartDate}
|
||||||
|
onChange={(e) => setTimeOffStartDate(e.target.value)}
|
||||||
|
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-pink-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Bis Datum
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={timeOffEndDate}
|
||||||
|
onChange={(e) => setTimeOffEndDate(e.target.value)}
|
||||||
|
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-pink-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Grund/Notiz
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="z.B. Sommerurlaub, Feiertag"
|
||||||
|
value={timeOffReason}
|
||||||
|
onChange={(e) => setTimeOffReason(e.target.value)}
|
||||||
|
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-pink-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setErrorMsg("");
|
||||||
|
setSuccessMsg("");
|
||||||
|
|
||||||
|
if (!timeOffStartDate || !timeOffEndDate || !timeOffReason) {
|
||||||
|
setErrorMsg("Bitte alle Felder ausfüllen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timeOffStartDate > timeOffEndDate) {
|
||||||
|
setErrorMsg("Startdatum muss vor dem Enddatum liegen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = localStorage.getItem("sessionId") || "";
|
||||||
|
if (!sessionId) {
|
||||||
|
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
createTimeOff(
|
||||||
|
{ sessionId, startDate: timeOffStartDate, endDate: timeOffEndDate, reason: timeOffReason },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setSuccessMsg("Urlaubszeit hinzugefügt.");
|
||||||
|
setTimeOffStartDate("");
|
||||||
|
setTimeOffEndDate("");
|
||||||
|
setTimeOffReason("");
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
setErrorMsg(err?.message || "Fehler beim Hinzufügen der Urlaubszeit.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="bg-pink-600 text-white px-4 py-2 rounded-md hover:bg-pink-700 font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Urlaubszeit hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bestehende Urlaubszeiten */}
|
||||||
|
<div className="bg-white rounded-lg shadow">
|
||||||
|
<div className="p-4 border-b">
|
||||||
|
<h3 className="text-lg font-semibold">Bestehende Urlaubszeiten</h3>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y">
|
||||||
|
{timeOffPeriods?.length === 0 && (
|
||||||
|
<div className="p-8 text-center text-gray-500">
|
||||||
|
<div className="text-lg font-medium mb-2">Keine Urlaubszeiten eingetragen</div>
|
||||||
|
<div className="text-sm">Fügen Sie Urlaubszeiten hinzu, um automatisch Slots zu blockieren.</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{timeOffPeriods?.map((period) => {
|
||||||
|
const today = new Date().toISOString().split("T")[0];
|
||||||
|
const isPast = period.endDate < today;
|
||||||
|
const isCurrent = period.startDate <= today && period.endDate >= today;
|
||||||
|
const isFuture = period.startDate > today;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={period.id} className="p-4 hover:bg-gray-50 transition-colors">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="font-medium">
|
||||||
|
{new Date(period.startDate).toLocaleDateString('de-DE')} - {new Date(period.endDate).toLocaleDateString('de-DE')}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-600">{period.reason}</div>
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||||
|
isPast
|
||||||
|
? "bg-gray-100 text-gray-800"
|
||||||
|
: isCurrent
|
||||||
|
? "bg-red-100 text-red-800"
|
||||||
|
: "bg-blue-100 text-blue-800"
|
||||||
|
}`}>
|
||||||
|
{isPast ? "Vergangen" : isCurrent ? "Aktuell" : "Geplant"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const sessionId = localStorage.getItem("sessionId");
|
||||||
|
if (!sessionId) {
|
||||||
|
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deleteTimeOff(
|
||||||
|
{ sessionId, id: period.id },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setSuccessMsg("Urlaubszeit gelöscht.");
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
setErrorMsg(err?.message || "Fehler beim Löschen der Urlaubszeit.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="px-3 py-1 text-red-600 hover:bg-red-50 rounded-md transition-colors text-sm"
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{allSlots?.length === 0 && (
|
</div>
|
||||||
<div className="p-8 text-center text-gray-500">
|
</div>
|
||||||
<div className="text-lg font-medium mb-2">Keine Slots vorhanden</div>
|
|
||||||
<div className="text-sm">Legen Sie den ersten Slot an, um zu beginnen.</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,34 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { queryClient } from "@/client/rpc-client";
|
import { queryClient } from "@/client/rpc-client";
|
||||||
|
|
||||||
export function AdminBookings() {
|
export function AdminBookings() {
|
||||||
|
const [filterMode, setFilterMode] = useState<"upcoming" | "all" | "date">("upcoming");
|
||||||
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
|
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
|
||||||
const [selectedPhoto, setSelectedPhoto] = useState<string>("");
|
const [selectedPhoto, setSelectedPhoto] = useState<string>("");
|
||||||
const [showPhotoModal, setShowPhotoModal] = useState(false);
|
const [showPhotoModal, setShowPhotoModal] = useState(false);
|
||||||
|
const [showCancelConfirm, setShowCancelConfirm] = useState<string | null>(null);
|
||||||
|
const [showMessageModal, setShowMessageModal] = useState<string | null>(null);
|
||||||
|
const [messageText, setMessageText] = useState<string>("");
|
||||||
|
const [successMsg, setSuccessMsg] = useState<string>("");
|
||||||
|
const [errorMsg, setErrorMsg] = useState<string>("");
|
||||||
|
|
||||||
const { data: bookings } = useQuery(
|
// Auto-clear messages after 5 seconds
|
||||||
|
useEffect(() => {
|
||||||
|
if (errorMsg) {
|
||||||
|
const timer = setTimeout(() => setErrorMsg(""), 5000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [errorMsg]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (successMsg) {
|
||||||
|
const timer = setTimeout(() => setSuccessMsg(""), 5000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [successMsg]);
|
||||||
|
|
||||||
|
const { data: bookings, refetch: refetchBookings } = useQuery(
|
||||||
queryClient.bookings.live.list.experimental_liveOptions()
|
queryClient.bookings.live.list.experimental_liveOptions()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -16,11 +37,48 @@ export function AdminBookings() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { mutate: updateBookingStatus } = useMutation(
|
const { mutate: updateBookingStatus } = useMutation(
|
||||||
queryClient.bookings.updateStatus.mutationOptions()
|
queryClient.bookings.updateStatus.mutationOptions({
|
||||||
|
onSuccess: (data, variables) => {
|
||||||
|
const statusText = getStatusText(variables.status);
|
||||||
|
setSuccessMsg(`Buchung wurde erfolgreich auf "${statusText}" gesetzt.`);
|
||||||
|
setShowCancelConfirm(null);
|
||||||
|
// Manually refetch bookings to ensure live updates
|
||||||
|
refetchBookings();
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
setErrorMsg(error?.message || "Fehler beim Aktualisieren der Buchung.");
|
||||||
|
setShowCancelConfirm(null);
|
||||||
|
}
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const getTreatmentName = (treatmentId: string) => {
|
const { mutate: sendMessage, isPending: isSendingMessage } = useMutation(
|
||||||
return treatments?.find(t => t.id === treatmentId)?.name || "Unbekannte Behandlung";
|
queryClient.bookings.sendCustomerMessage.mutationOptions({
|
||||||
|
onSuccess: () => {
|
||||||
|
setSuccessMsg("Nachricht wurde erfolgreich gesendet.");
|
||||||
|
setShowMessageModal(null);
|
||||||
|
setMessageText("");
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
setErrorMsg(error?.message || "Fehler beim Senden der Nachricht.");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const getTreatmentNames = (booking: any) => {
|
||||||
|
// Handle new treatments array structure
|
||||||
|
if (booking.treatments && Array.isArray(booking.treatments) && booking.treatments.length > 0) {
|
||||||
|
const names = booking.treatments
|
||||||
|
.map((t: any) => t.name)
|
||||||
|
.filter((name: string) => name && name.trim())
|
||||||
|
.join(", ");
|
||||||
|
return names || "Keine Behandlung";
|
||||||
|
}
|
||||||
|
// Fallback to deprecated treatmentId for backward compatibility
|
||||||
|
if (booking.treatmentId) {
|
||||||
|
return treatments?.find(t => t.id === booking.treatmentId)?.name || "Unbekannte Behandlung";
|
||||||
|
}
|
||||||
|
return "Keine Behandlung";
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
@@ -33,6 +91,16 @@ export function AdminBookings() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getStatusText = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case "pending": return "Ausstehend";
|
||||||
|
case "confirmed": return "Bestätigt";
|
||||||
|
case "cancelled": return "Storniert";
|
||||||
|
case "completed": return "Abgeschlossen";
|
||||||
|
default: return status;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const openPhotoModal = (photoData: string) => {
|
const openPhotoModal = (photoData: string) => {
|
||||||
setSelectedPhoto(photoData);
|
setSelectedPhoto(photoData);
|
||||||
setShowPhotoModal(true);
|
setShowPhotoModal(true);
|
||||||
@@ -43,9 +111,52 @@ export function AdminBookings() {
|
|||||||
setSelectedPhoto("");
|
setSelectedPhoto("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredBookings = bookings?.filter(booking =>
|
const openMessageModal = (bookingId: string) => {
|
||||||
selectedDate ? booking.appointmentDate === selectedDate : true
|
setShowMessageModal(bookingId);
|
||||||
).sort((a, b) => {
|
setMessageText("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeMessageModal = () => {
|
||||||
|
setShowMessageModal(null);
|
||||||
|
setMessageText("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendMessage = () => {
|
||||||
|
if (!showMessageModal || !messageText.trim()) {
|
||||||
|
setErrorMsg("Bitte gib eine Nachricht ein.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendMessage({
|
||||||
|
sessionId: localStorage.getItem("sessionId") || "",
|
||||||
|
bookingId: showMessageModal,
|
||||||
|
message: messageText.trim(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if booking is in the future
|
||||||
|
const isFutureBooking = (appointmentDate: string) => {
|
||||||
|
const today = new Date().toISOString().split("T")[0];
|
||||||
|
return appointmentDate >= today;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter bookings based on selected filter mode
|
||||||
|
const filteredBookings = bookings?.filter(booking => {
|
||||||
|
if (filterMode === "upcoming") {
|
||||||
|
// Show all future bookings (not cancelled)
|
||||||
|
const bookingDate = new Date(booking.appointmentDate);
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
bookingDate.setHours(0, 0, 0, 0);
|
||||||
|
return bookingDate >= today && booking.status !== "cancelled";
|
||||||
|
} else if (filterMode === "date") {
|
||||||
|
// Show bookings for specific date
|
||||||
|
return booking.appointmentDate === selectedDate;
|
||||||
|
} else {
|
||||||
|
// Show all bookings
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}).sort((a, b) => {
|
||||||
if (a.appointmentDate === b.appointmentDate) {
|
if (a.appointmentDate === b.appointmentDate) {
|
||||||
return a.appointmentTime.localeCompare(b.appointmentTime);
|
return a.appointmentTime.localeCompare(b.appointmentTime);
|
||||||
}
|
}
|
||||||
@@ -66,6 +177,34 @@ export function AdminBookings() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
|
{/* Success/Error Messages */}
|
||||||
|
{(successMsg || errorMsg) && (
|
||||||
|
<div className="mb-4">
|
||||||
|
{errorMsg && (
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">Fehler:</span>
|
||||||
|
<span className="ml-1">{errorMsg}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{successMsg && (
|
||||||
|
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-md">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">Erfolg:</span>
|
||||||
|
<span className="ml-1">{successMsg}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Quick Stats */}
|
{/* Quick Stats */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||||
<div className="bg-white rounded-lg shadow p-4">
|
<div className="bg-white rounded-lg shadow p-4">
|
||||||
@@ -94,22 +233,54 @@ export function AdminBookings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Date Filter */}
|
{/* Filter Section */}
|
||||||
<div className="bg-white rounded-lg shadow p-4 mb-6">
|
<div className="bg-white rounded-lg shadow p-4 mb-6">
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex flex-col space-y-4">
|
||||||
<label className="text-sm font-medium text-gray-700">Filter by date:</label>
|
<div className="flex items-center space-x-2">
|
||||||
|
<label className="text-sm font-medium text-gray-700">Filter:</label>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilterMode("upcoming")}
|
||||||
|
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||||
|
filterMode === "upcoming"
|
||||||
|
? "bg-pink-600 text-white"
|
||||||
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Zukünftige
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilterMode("all")}
|
||||||
|
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||||
|
filterMode === "all"
|
||||||
|
? "bg-pink-600 text-white"
|
||||||
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Alle
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilterMode("date")}
|
||||||
|
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||||
|
filterMode === "date"
|
||||||
|
? "bg-pink-600 text-white"
|
||||||
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Datum
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filterMode === "date" && (
|
||||||
|
<div className="flex items-center space-x-4 pl-16">
|
||||||
|
<label className="text-sm font-medium text-gray-700">Wähle Datum:</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={selectedDate}
|
value={selectedDate}
|
||||||
onChange={(e) => setSelectedDate(e.target.value)}
|
onChange={(e) => setSelectedDate(e.target.value)}
|
||||||
className="p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
|
className="p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
|
||||||
/>
|
/>
|
||||||
<button
|
</div>
|
||||||
onClick={() => setSelectedDate("")}
|
)}
|
||||||
className="text-sm text-pink-600 hover:text-pink-800"
|
|
||||||
>
|
|
||||||
Show All
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -144,12 +315,12 @@ export function AdminBookings() {
|
|||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-gray-900">{booking.customerName}</div>
|
<div className="text-sm font-medium text-gray-900">{booking.customerName}</div>
|
||||||
<div className="text-sm text-gray-500">{booking.customerEmail}</div>
|
<div className="text-sm text-gray-500">{booking.customerEmail || '—'}</div>
|
||||||
<div className="text-sm text-gray-500">{booking.customerPhone}</div>
|
<div className="text-sm text-gray-500">{booking.customerPhone || '—'}</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4">
|
||||||
<div className="text-sm text-gray-900">{getTreatmentName(booking.treatmentId)}</div>
|
<div className="text-sm text-gray-900">{getTreatmentNames(booking)}</div>
|
||||||
{booking.notes && (
|
{booking.notes && (
|
||||||
<div className="text-sm text-gray-500">Notizen: {booking.notes}</div>
|
<div className="text-sm text-gray-500">Notizen: {booking.notes}</div>
|
||||||
)}
|
)}
|
||||||
@@ -183,6 +354,7 @@ export function AdminBookings() {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
|
<div className="flex flex-col space-y-2">
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
{booking.status === "pending" && (
|
{booking.status === "pending" && (
|
||||||
<>
|
<>
|
||||||
@@ -193,7 +365,7 @@ export function AdminBookings() {
|
|||||||
Confirm
|
Confirm
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => updateBookingStatus({ sessionId: localStorage.getItem("sessionId") || "", id: booking.id, status: "cancelled" })}
|
onClick={() => setShowCancelConfirm(booking.id)}
|
||||||
className="text-red-600 hover:text-red-900"
|
className="text-red-600 hover:text-red-900"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
@@ -209,7 +381,7 @@ export function AdminBookings() {
|
|||||||
Complete
|
Complete
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => updateBookingStatus({ sessionId: localStorage.getItem("sessionId") || "", id: booking.id, status: "cancelled" })}
|
onClick={() => setShowCancelConfirm(booking.id)}
|
||||||
className="text-red-600 hover:text-red-900"
|
className="text-red-600 hover:text-red-900"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
@@ -225,6 +397,17 @@ export function AdminBookings() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Show message button for future bookings with email */}
|
||||||
|
{isFutureBooking(booking.appointmentDate) && booking.customerEmail && (
|
||||||
|
<button
|
||||||
|
onClick={() => openMessageModal(booking.id)}
|
||||||
|
className="text-pink-600 hover:text-pink-900 text-left"
|
||||||
|
title="Nachricht an Kunden senden"
|
||||||
|
>
|
||||||
|
💬 Nachricht
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -233,8 +416,10 @@ export function AdminBookings() {
|
|||||||
|
|
||||||
{!filteredBookings?.length && (
|
{!filteredBookings?.length && (
|
||||||
<div className="text-center py-8 text-gray-500">
|
<div className="text-center py-8 text-gray-500">
|
||||||
{selectedDate
|
{filterMode === "date"
|
||||||
? `Keine Buchungen für ${new Date(selectedDate).toLocaleDateString()} gefunden`
|
? `Keine Buchungen für ${new Date(selectedDate).toLocaleDateString()} gefunden`
|
||||||
|
: filterMode === "upcoming"
|
||||||
|
? "Keine zukünftigen Buchungen verfügbar."
|
||||||
: "Keine Buchungen verfügbar."
|
: "Keine Buchungen verfügbar."
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -272,6 +457,145 @@ export function AdminBookings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Cancel Confirmation Modal */}
|
||||||
|
{showCancelConfirm && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-4">Buchung stornieren</h3>
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
Bist du sicher, dass du diese Buchung stornieren möchtest? Diese Aktion kann nicht rückgängig gemacht werden.
|
||||||
|
</p>
|
||||||
|
<div className="flex space-x-3">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const sessionId = localStorage.getItem("sessionId") || "";
|
||||||
|
updateBookingStatus({ sessionId, id: showCancelConfirm, status: "cancelled" });
|
||||||
|
}}
|
||||||
|
className="flex-1 bg-red-600 text-white py-2 px-4 rounded-md hover:bg-red-700 transition-colors"
|
||||||
|
>
|
||||||
|
Ja, stornieren
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCancelConfirm(null)}
|
||||||
|
className="flex-1 bg-gray-200 text-gray-800 py-2 px-4 rounded-md hover:bg-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Message Modal */}
|
||||||
|
{showMessageModal && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg p-6 max-w-2xl w-full mx-4">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Nachricht an Kunden senden</h3>
|
||||||
|
<button
|
||||||
|
onClick={closeMessageModal}
|
||||||
|
className="text-gray-400 hover:text-gray-600 text-2xl"
|
||||||
|
disabled={isSendingMessage}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
const booking = bookings?.find(b => b.id === showMessageModal);
|
||||||
|
if (!booking) return null;
|
||||||
|
|
||||||
|
// Calculate totals for multiple treatments
|
||||||
|
const hasTreatments = booking.treatments && Array.isArray(booking.treatments) && booking.treatments.length > 0;
|
||||||
|
const totalDuration = hasTreatments
|
||||||
|
? booking.treatments.reduce((sum: number, t: any) => sum + (t.duration || 0), 0)
|
||||||
|
: (booking.bookedDurationMinutes || 0);
|
||||||
|
const totalPrice = hasTreatments
|
||||||
|
? booking.treatments.reduce((sum: number, t: any) => sum + (t.price || 0), 0)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-4 bg-gray-50 p-4 rounded-md">
|
||||||
|
<p className="text-sm text-gray-700">
|
||||||
|
<strong>Kunde:</strong> {booking.customerName}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-700">
|
||||||
|
<strong>E-Mail:</strong> {booking.customerEmail}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-700">
|
||||||
|
<strong>Termin:</strong> {new Date(booking.appointmentDate).toLocaleDateString()} um {booking.appointmentTime}
|
||||||
|
</p>
|
||||||
|
<div className="text-sm text-gray-700 mt-2">
|
||||||
|
<strong>Behandlungen:</strong>
|
||||||
|
{hasTreatments ? (
|
||||||
|
<div className="mt-1 ml-2">
|
||||||
|
{booking.treatments.map((treatment: any, index: number) => (
|
||||||
|
<div key={index} className="mb-1">
|
||||||
|
• {treatment.name} ({treatment.duration} Min., {treatment.price}€)
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{booking.treatments.length > 1 && (
|
||||||
|
<div className="mt-2 pt-2 border-t border-gray-300 font-semibold">
|
||||||
|
Gesamt: {totalDuration} Min., {totalPrice.toFixed(2)}€
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : booking.treatmentId ? (
|
||||||
|
<div className="mt-1 ml-2">
|
||||||
|
• {treatments?.find(t => t.id === booking.treatmentId)?.name || "Unbekannte Behandlung"}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="ml-2 text-gray-500">Keine Behandlung</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Deine Nachricht
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={messageText}
|
||||||
|
onChange={(e) => setMessageText(e.target.value)}
|
||||||
|
placeholder="Schreibe hier deine Nachricht an den Kunden..."
|
||||||
|
rows={6}
|
||||||
|
maxLength={5000}
|
||||||
|
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
|
||||||
|
disabled={isSendingMessage}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
{messageText.length} / 5000 Zeichen
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-blue-50 border-l-4 border-blue-400 p-3 mb-4">
|
||||||
|
<p className="text-sm text-blue-700">
|
||||||
|
💡 <strong>Hinweis:</strong> Der Kunde kann direkt auf diese E-Mail antworten. Die Antwort geht an die in den Einstellungen hinterlegte Admin-E-Mail-Adresse.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex space-x-3">
|
||||||
|
<button
|
||||||
|
onClick={handleSendMessage}
|
||||||
|
disabled={isSendingMessage || !messageText.trim()}
|
||||||
|
className="flex-1 bg-pink-600 text-white py-2 px-4 rounded-md hover:bg-pink-700 transition-colors disabled:bg-gray-300 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isSendingMessage ? "Wird gesendet..." : "Nachricht senden"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={closeMessageModal}
|
||||||
|
disabled={isSendingMessage}
|
||||||
|
className="flex-1 bg-gray-200 text-gray-800 py-2 px-4 rounded-md hover:bg-gray-300 transition-colors disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,34 @@ import { queryClient } from "@/client/rpc-client";
|
|||||||
export function AdminCalendar() {
|
export function AdminCalendar() {
|
||||||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||||
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(null);
|
||||||
|
const [sendDeleteEmail, setSendDeleteEmail] = useState(false);
|
||||||
|
const [deleteActionType, setDeleteActionType] = useState<'delete' | 'cancel'>('delete');
|
||||||
|
|
||||||
|
// CalDAV state
|
||||||
|
const [caldavData, setCaldavData] = useState<any>(null);
|
||||||
|
const [showCaldavInstructions, setShowCaldavInstructions] = useState(false);
|
||||||
|
|
||||||
|
// Manual booking modal state
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
const [createFormData, setCreateFormData] = useState({
|
||||||
|
customerName: '',
|
||||||
|
treatmentId: '',
|
||||||
|
appointmentDate: '',
|
||||||
|
appointmentTime: '',
|
||||||
|
customerEmail: '',
|
||||||
|
customerPhone: '',
|
||||||
|
notes: ''
|
||||||
|
});
|
||||||
|
const [createError, setCreateError] = useState<string>('');
|
||||||
|
|
||||||
|
// Reschedule modal state
|
||||||
|
const [showRescheduleModal, setShowRescheduleModal] = useState<string | null>(null);
|
||||||
|
const [rescheduleFormData, setRescheduleFormData] = useState({
|
||||||
|
appointmentDate: '',
|
||||||
|
appointmentTime: ''
|
||||||
|
});
|
||||||
|
const [rescheduleError, setRescheduleError] = useState<string>('');
|
||||||
|
|
||||||
const { data: bookings } = useQuery(
|
const { data: bookings } = useQuery(
|
||||||
queryClient.bookings.live.list.experimental_liveOptions()
|
queryClient.bookings.live.list.experimental_liveOptions()
|
||||||
@@ -14,12 +42,69 @@ export function AdminCalendar() {
|
|||||||
queryClient.treatments.live.list.experimental_liveOptions()
|
queryClient.treatments.live.list.experimental_liveOptions()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Optional query for available times when treatment and date are selected
|
||||||
|
const { data: availableTimes } = useQuery({
|
||||||
|
...queryClient.recurringAvailability.getAvailableTimes.queryOptions({
|
||||||
|
input: {
|
||||||
|
date: createFormData.appointmentDate,
|
||||||
|
treatmentIds: createFormData.treatmentId ? [createFormData.treatmentId] : []
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
enabled: !!createFormData.appointmentDate && !!createFormData.treatmentId
|
||||||
|
});
|
||||||
|
|
||||||
|
// Available times for reschedule modal
|
||||||
|
const { data: rescheduleAvailableTimes } = useQuery({
|
||||||
|
...queryClient.recurringAvailability.getAvailableTimes.queryOptions({
|
||||||
|
input: {
|
||||||
|
date: rescheduleFormData.appointmentDate,
|
||||||
|
treatmentIds: (() => {
|
||||||
|
const booking = showRescheduleModal ? bookings?.find(b => b.id === showRescheduleModal) : null;
|
||||||
|
if (!booking) return [];
|
||||||
|
// Use new treatments array if available
|
||||||
|
if (booking.treatments && Array.isArray(booking.treatments) && booking.treatments.length > 0) {
|
||||||
|
return booking.treatments.map((t: any) => t.id);
|
||||||
|
}
|
||||||
|
// Fallback to deprecated treatmentId for backward compatibility
|
||||||
|
return booking.treatmentId ? [booking.treatmentId] : [];
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
enabled: !!showRescheduleModal && !!rescheduleFormData.appointmentDate
|
||||||
|
});
|
||||||
|
|
||||||
const { mutate: updateBookingStatus } = useMutation(
|
const { mutate: updateBookingStatus } = useMutation(
|
||||||
queryClient.bookings.updateStatus.mutationOptions()
|
queryClient.bookings.updateStatus.mutationOptions()
|
||||||
);
|
);
|
||||||
|
|
||||||
const getTreatmentName = (treatmentId: string) => {
|
const { mutate: removeBooking } = useMutation(
|
||||||
return treatments?.find(t => t.id === treatmentId)?.name || "Unbekannte Behandlung";
|
queryClient.bookings.remove.mutationOptions()
|
||||||
|
);
|
||||||
|
|
||||||
|
const { mutate: createManualBooking } = useMutation(
|
||||||
|
queryClient.bookings.createManual.mutationOptions()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Propose reschedule mutation
|
||||||
|
const { mutate: proposeReschedule, isPending: isProposingReschedule } = useMutation(
|
||||||
|
queryClient.bookings.proposeReschedule.mutationOptions()
|
||||||
|
);
|
||||||
|
|
||||||
|
// CalDAV token generation mutation
|
||||||
|
const { mutate: generateCalDAVToken, isPending: isGeneratingToken } = useMutation(
|
||||||
|
queryClient.bookings.generateCalDAVToken.mutationOptions()
|
||||||
|
);
|
||||||
|
|
||||||
|
const getTreatmentNames = (booking: any) => {
|
||||||
|
// Handle new treatments array structure
|
||||||
|
if (booking.treatments && Array.isArray(booking.treatments) && booking.treatments.length > 0) {
|
||||||
|
return booking.treatments.map((t: any) => t.name).join(", ");
|
||||||
|
}
|
||||||
|
// Fallback to deprecated treatmentId for backward compatibility
|
||||||
|
if (booking.treatmentId) {
|
||||||
|
return treatments?.find(t => t.id === booking.treatmentId)?.name || "Unbekannte Behandlung";
|
||||||
|
}
|
||||||
|
return "Keine Behandlung";
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
@@ -69,8 +154,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));
|
||||||
@@ -106,8 +191,161 @@ export function AdminCalendar() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteBooking = () => {
|
||||||
|
const sessionId = localStorage.getItem('sessionId');
|
||||||
|
if (!sessionId || !showDeleteConfirm) return;
|
||||||
|
|
||||||
|
if (deleteActionType === 'cancel') {
|
||||||
|
// For cancel action, use updateStatus instead of remove
|
||||||
|
updateBookingStatus({
|
||||||
|
sessionId,
|
||||||
|
id: showDeleteConfirm,
|
||||||
|
status: "cancelled"
|
||||||
|
}, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setShowDeleteConfirm(null);
|
||||||
|
setSendDeleteEmail(false);
|
||||||
|
setDeleteActionType('delete');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
// no-op; errors can be surfaced via existing patterns/toasts later
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// For delete action, use remove with email option
|
||||||
|
removeBooking({
|
||||||
|
sessionId,
|
||||||
|
id: showDeleteConfirm,
|
||||||
|
sendEmail: sendDeleteEmail,
|
||||||
|
}, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setShowDeleteConfirm(null);
|
||||||
|
setSendDeleteEmail(false);
|
||||||
|
setDeleteActionType('delete');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
// no-op; errors can be surfaced via existing patterns/toasts later
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const today = new Date().toISOString().split('T')[0];
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const handleCreateBooking = () => {
|
||||||
|
const sessionId = localStorage.getItem('sessionId');
|
||||||
|
if (!sessionId) return;
|
||||||
|
|
||||||
|
// Convert treatmentId to treatments array
|
||||||
|
const selectedTreatment = treatments?.find(t => t.id === createFormData.treatmentId);
|
||||||
|
if (!selectedTreatment) {
|
||||||
|
setCreateError('Bitte wähle eine Behandlung aus.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const treatmentsArray = [{
|
||||||
|
id: selectedTreatment.id,
|
||||||
|
name: selectedTreatment.name,
|
||||||
|
duration: selectedTreatment.duration,
|
||||||
|
price: selectedTreatment.price
|
||||||
|
}];
|
||||||
|
|
||||||
|
createManualBooking({
|
||||||
|
sessionId,
|
||||||
|
treatments: treatmentsArray,
|
||||||
|
customerName: createFormData.customerName,
|
||||||
|
appointmentDate: createFormData.appointmentDate,
|
||||||
|
appointmentTime: createFormData.appointmentTime,
|
||||||
|
customerEmail: createFormData.customerEmail,
|
||||||
|
customerPhone: createFormData.customerPhone,
|
||||||
|
notes: createFormData.notes
|
||||||
|
}, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setShowCreateModal(false);
|
||||||
|
setCreateFormData({
|
||||||
|
customerName: '',
|
||||||
|
treatmentId: '',
|
||||||
|
appointmentDate: '',
|
||||||
|
appointmentTime: '',
|
||||||
|
customerEmail: '',
|
||||||
|
customerPhone: '',
|
||||||
|
notes: ''
|
||||||
|
});
|
||||||
|
setCreateError('');
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
setCreateError(error?.message || 'Fehler beim Erstellen der Buchung');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFormChange = (field: string, value: string) => {
|
||||||
|
setCreateFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[field]: value,
|
||||||
|
// Reset time when treatment or date changes
|
||||||
|
...(field === 'treatmentId' || field === 'appointmentDate' ? { appointmentTime: '' } : {})
|
||||||
|
}));
|
||||||
|
setCreateError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRescheduleFormChange = (field: string, value: string) => {
|
||||||
|
setRescheduleFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[field]: value,
|
||||||
|
...(field === 'appointmentDate' ? { appointmentTime: '' } : {})
|
||||||
|
}));
|
||||||
|
setRescheduleError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRescheduleBooking = () => {
|
||||||
|
const sessionId = localStorage.getItem('sessionId');
|
||||||
|
if (!sessionId || !showRescheduleModal) return;
|
||||||
|
const booking = bookings?.find(b => b.id === showRescheduleModal);
|
||||||
|
if (!booking) return;
|
||||||
|
|
||||||
|
proposeReschedule({
|
||||||
|
sessionId,
|
||||||
|
bookingId: booking.id,
|
||||||
|
proposedDate: rescheduleFormData.appointmentDate,
|
||||||
|
proposedTime: rescheduleFormData.appointmentTime,
|
||||||
|
}, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setShowRescheduleModal(null);
|
||||||
|
setRescheduleFormData({ appointmentDate: '', appointmentTime: '' });
|
||||||
|
setRescheduleError('');
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
setRescheduleError(error?.message || 'Fehler beim Senden des Vorschlags');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerateCalDAVToken = () => {
|
||||||
|
const sessionId = localStorage.getItem('sessionId');
|
||||||
|
if (!sessionId) return;
|
||||||
|
|
||||||
|
generateCalDAVToken({
|
||||||
|
sessionId
|
||||||
|
}, {
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setCaldavData(data);
|
||||||
|
setShowCaldavInstructions(true);
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
console.error('CalDAV Token Generation Error:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = (text: string) => {
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
// Optional: Show success message
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('Failed to copy text: ', err);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">Kalender - Bevorstehende Buchungen</h2>
|
<h2 className="text-2xl font-bold text-gray-900 mb-6">Kalender - Bevorstehende Buchungen</h2>
|
||||||
@@ -140,6 +378,62 @@ export function AdminCalendar() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* CalDAV Integration */}
|
||||||
|
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">Kalender-Abonnement</h3>
|
||||||
|
<p className="text-sm text-gray-600">Abonniere deinen Terminkalender in deiner Kalender-App</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleGenerateCalDAVToken}
|
||||||
|
disabled={isGeneratingToken}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm font-medium"
|
||||||
|
>
|
||||||
|
{isGeneratingToken ? 'Generiere...' : 'CalDAV-Link erstellen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{caldavData && (
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<div className="bg-gray-50 rounded-lg p-4 mb-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<label className="text-sm font-medium text-gray-700">CalDAV-URL:</label>
|
||||||
|
<button
|
||||||
|
onClick={() => copyToClipboard(caldavData.caldavUrl)}
|
||||||
|
className="text-blue-600 hover:text-blue-800 text-sm"
|
||||||
|
>
|
||||||
|
Kopieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={caldavData.caldavUrl}
|
||||||
|
readOnly
|
||||||
|
className="w-full p-2 bg-white border border-gray-300 rounded text-sm font-mono"
|
||||||
|
/>
|
||||||
|
<div className="text-xs text-gray-500 mt-2">
|
||||||
|
Gültig bis: {new Date(caldavData.expiresAt).toLocaleString('de-DE')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
<p className="mb-2">
|
||||||
|
<strong>So abonnierst du den Kalender:</strong>
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc list-inside space-y-1 text-sm">
|
||||||
|
{caldavData.instructions.steps.map((step: string, index: number) => (
|
||||||
|
<li key={index}>{step}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p className="mt-3 text-amber-700 bg-amber-50 p-2 rounded">
|
||||||
|
<strong>Hinweis:</strong> {caldavData.instructions.note}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Calendar */}
|
{/* Calendar */}
|
||||||
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
|
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
|
||||||
{/* Calendar Header */}
|
{/* Calendar Header */}
|
||||||
@@ -153,9 +447,17 @@ export function AdminCalendar() {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
<h3 className="text-xl font-semibold text-gray-900">
|
<h3 className="text-xl font-semibold text-gray-900">
|
||||||
{monthNames[month]} {year}
|
{monthNames[month]} {year}
|
||||||
</h3>
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateModal(true)}
|
||||||
|
className="px-4 py-2 bg-pink-600 text-white rounded-lg hover:bg-pink-700 transition-colors text-sm font-medium"
|
||||||
|
>
|
||||||
|
Termin erstellen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => navigateMonth('next')}
|
onClick={() => navigateMonth('next')}
|
||||||
@@ -204,7 +506,7 @@ export function AdminCalendar() {
|
|||||||
<div
|
<div
|
||||||
key={booking.id}
|
key={booking.id}
|
||||||
className={`text-xs p-1 rounded border-l-2 ${getStatusColor(booking.status)} truncate`}
|
className={`text-xs p-1 rounded border-l-2 ${getStatusColor(booking.status)} truncate`}
|
||||||
title={`${booking.customerName} - ${getTreatmentName(booking.treatmentId)} (${booking.appointmentTime})`}
|
title={`${booking.customerName} - ${getTreatmentNames(booking)} (${booking.appointmentTime})`}
|
||||||
>
|
>
|
||||||
<div className="font-medium">{booking.appointmentTime}</div>
|
<div className="font-medium">{booking.appointmentTime}</div>
|
||||||
<div className="truncate">{booking.customerName}</div>
|
<div className="truncate">{booking.customerName}</div>
|
||||||
@@ -261,16 +563,16 @@ export function AdminCalendar() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm text-gray-600">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm text-gray-600">
|
||||||
<div>
|
<div>
|
||||||
<strong>Behandlung:</strong> {getTreatmentName(booking.treatmentId)}
|
<strong>Behandlung:</strong> {getTreatmentNames(booking)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>Uhrzeit:</strong> {booking.appointmentTime}
|
<strong>Uhrzeit:</strong> {booking.appointmentTime}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>E-Mail:</strong> {booking.customerEmail}
|
<strong>E-Mail:</strong> {booking.customerEmail || '—'}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>Telefon:</strong> {booking.customerPhone}
|
<strong>Telefon:</strong> {booking.customerPhone || '—'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -293,7 +595,11 @@ export function AdminCalendar() {
|
|||||||
Bestätigen
|
Bestätigen
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleStatusUpdate(booking.id, "cancelled")}
|
onClick={() => {
|
||||||
|
setDeleteActionType('cancel');
|
||||||
|
setShowDeleteConfirm(booking.id);
|
||||||
|
setSendDeleteEmail(true); // Default to sending email for cancel
|
||||||
|
}}
|
||||||
className="px-3 py-1 bg-red-600 text-white text-sm rounded hover:bg-red-700 transition-colors"
|
className="px-3 py-1 bg-red-600 text-white text-sm rounded hover:bg-red-700 transition-colors"
|
||||||
>
|
>
|
||||||
Stornieren
|
Stornieren
|
||||||
@@ -302,13 +608,51 @@ export function AdminCalendar() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{booking.status === "confirmed" && (
|
{booking.status === "confirmed" && (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleStatusUpdate(booking.id, "completed")}
|
onClick={() => handleStatusUpdate(booking.id, "completed")}
|
||||||
className="px-3 py-1 bg-blue-600 text-white text-sm rounded hover:bg-blue-700 transition-colors"
|
className="px-3 py-1 bg-blue-600 text-white text-sm rounded hover:bg-blue-700 transition-colors"
|
||||||
>
|
>
|
||||||
Als erledigt markieren
|
Als erledigt markieren
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowRescheduleModal(booking.id);
|
||||||
|
setRescheduleFormData({ appointmentDate: '', appointmentTime: '' });
|
||||||
|
setRescheduleError('');
|
||||||
|
}}
|
||||||
|
className="px-3 py-1 bg-orange-600 text-white text-sm rounded hover:bg-orange-700 transition-colors"
|
||||||
|
>
|
||||||
|
Umbuchen
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
{(() => {
|
||||||
|
const isPastDate = booking.appointmentDate < today;
|
||||||
|
const isCompleted = booking.status === 'completed';
|
||||||
|
const shouldDisableDelete = isPastDate || isCompleted;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (!shouldDisableDelete) {
|
||||||
|
setDeleteActionType('delete');
|
||||||
|
setShowDeleteConfirm(booking.id);
|
||||||
|
setSendDeleteEmail(isPastDate || isCompleted ? false : false); // Disable email for past/completed
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={shouldDisableDelete}
|
||||||
|
className={`px-3 py-1 text-sm rounded transition-colors ${
|
||||||
|
shouldDisableDelete
|
||||||
|
? 'bg-gray-400 text-gray-200 cursor-not-allowed'
|
||||||
|
: 'bg-red-600 text-white hover:bg-red-700'
|
||||||
|
}`}
|
||||||
|
title={shouldDisableDelete ? 'Löschen für vergangene/abgeschlossene Termine nicht verfügbar' : ''}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -317,6 +661,289 @@ export function AdminCalendar() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{showDeleteConfirm !== null && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl p-6 w-full max-w-md">
|
||||||
|
<h4 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
|
{deleteActionType === 'cancel' ? 'Termin stornieren?' : 'Termin löschen?'}
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-gray-600 mb-4">
|
||||||
|
{deleteActionType === 'cancel'
|
||||||
|
? 'Dieser Termin wird als storniert markiert. Der Zeitslot wird wieder freigegeben.'
|
||||||
|
: 'Dieser Termin wird als storniert markiert. Der Zeitslot wird wieder freigegeben.'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
{deleteActionType === 'delete' && (
|
||||||
|
<label className="flex items-center space-x-2 mb-4">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={sendDeleteEmail}
|
||||||
|
onChange={(e) => setSendDeleteEmail(e.target.checked)}
|
||||||
|
className="h-4 w-4 text-pink-600 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700">Stornierungsmail an Kunde senden</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:space-x-3 space-y-2 sm:space-y-0">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowDeleteConfirm(null);
|
||||||
|
setSendDeleteEmail(false);
|
||||||
|
setDeleteActionType('delete');
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 rounded bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors w-full"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDeleteBooking}
|
||||||
|
className="px-4 py-2 rounded bg-red-600 text-white hover:bg-red-700 transition-colors w-full"
|
||||||
|
>
|
||||||
|
{deleteActionType === 'cancel' ? 'Stornieren' : 'Löschen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Manual Booking Modal */}
|
||||||
|
{showCreateModal && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl p-6 w-full max-w-md max-h-[90vh] overflow-y-auto">
|
||||||
|
<h4 className="text-lg font-semibold text-gray-900 mb-4">Termin erstellen</h4>
|
||||||
|
|
||||||
|
{createError && (
|
||||||
|
<div className="mb-4 bg-red-50 border border-red-200 text-red-700 px-3 py-2 rounded-md text-sm">
|
||||||
|
{createError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Customer Name */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Kundenname *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={createFormData.customerName}
|
||||||
|
onChange={(e) => handleFormChange('customerName', e.target.value)}
|
||||||
|
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Treatment */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Behandlung *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={createFormData.treatmentId}
|
||||||
|
onChange={(e) => handleFormChange('treatmentId', e.target.value)}
|
||||||
|
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Behandlung wählen</option>
|
||||||
|
{treatments?.map(treatment => (
|
||||||
|
<option key={treatment.id} value={treatment.id}>
|
||||||
|
{treatment.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Datum *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={createFormData.appointmentDate}
|
||||||
|
onChange={(e) => handleFormChange('appointmentDate', e.target.value)}
|
||||||
|
min={today}
|
||||||
|
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Uhrzeit *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={createFormData.appointmentTime}
|
||||||
|
onChange={(e) => handleFormChange('appointmentTime', e.target.value)}
|
||||||
|
disabled={!createFormData.treatmentId || !createFormData.appointmentDate}
|
||||||
|
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500 disabled:bg-gray-100"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Zeit wählen</option>
|
||||||
|
{availableTimes?.map(time => (
|
||||||
|
<option key={time} value={time}>
|
||||||
|
{time}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{(!createFormData.treatmentId || !createFormData.appointmentDate) && (
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Wähle zuerst Behandlung und Datum
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
E-Mail (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={createFormData.customerEmail}
|
||||||
|
onChange={(e) => handleFormChange('customerEmail', e.target.value)}
|
||||||
|
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phone */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Telefon (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={createFormData.customerPhone}
|
||||||
|
onChange={(e) => handleFormChange('customerPhone', e.target.value)}
|
||||||
|
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Notizen (optional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={createFormData.notes}
|
||||||
|
onChange={(e) => handleFormChange('notes', e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex space-x-3 mt-6">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowCreateModal(false);
|
||||||
|
setCreateFormData({
|
||||||
|
customerName: '',
|
||||||
|
treatmentId: '',
|
||||||
|
appointmentDate: '',
|
||||||
|
appointmentTime: '',
|
||||||
|
customerEmail: '',
|
||||||
|
customerPhone: '',
|
||||||
|
notes: ''
|
||||||
|
});
|
||||||
|
setCreateError('');
|
||||||
|
}}
|
||||||
|
className="flex-1 px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCreateBooking}
|
||||||
|
disabled={!createFormData.customerName || !createFormData.treatmentId || !createFormData.appointmentDate || !createFormData.appointmentTime}
|
||||||
|
className="flex-1 px-4 py-2 bg-pink-600 text-white rounded-md hover:bg-pink-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
Termin erstellen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reschedule Modal */}
|
||||||
|
{showRescheduleModal && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl p-6 w-full max-w-md max-h-[90vh] overflow-y-auto">
|
||||||
|
<h4 className="text-lg font-semibold text-gray-900 mb-4">Termin umbuchen</h4>
|
||||||
|
|
||||||
|
{rescheduleError && (
|
||||||
|
<div className="mb-4 bg-red-50 border border-red-200 text-red-700 px-3 py-2 rounded-md text-sm">
|
||||||
|
{rescheduleError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
const booking = bookings?.find(b => b.id === showRescheduleModal);
|
||||||
|
const treatmentName = booking ? getTreatmentNames(booking) : '';
|
||||||
|
return booking ? (
|
||||||
|
<div className="mb-4 text-sm text-gray-700">
|
||||||
|
<div className="mb-2"><strong>Kunde:</strong> {booking.customerName}</div>
|
||||||
|
<div className="mb-2"><strong>Aktueller Termin:</strong> {booking.appointmentDate} um {booking.appointmentTime} Uhr</div>
|
||||||
|
<div className="mb-2"><strong>Behandlung:</strong> {treatmentName}</div>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Neues Datum</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={rescheduleFormData.appointmentDate}
|
||||||
|
onChange={(e) => handleRescheduleFormChange('appointmentDate', e.target.value)}
|
||||||
|
min={today}
|
||||||
|
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Neue Uhrzeit</label>
|
||||||
|
<select
|
||||||
|
value={rescheduleFormData.appointmentTime}
|
||||||
|
onChange={(e) => handleRescheduleFormChange('appointmentTime', e.target.value)}
|
||||||
|
disabled={!rescheduleFormData.appointmentDate}
|
||||||
|
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-orange-500 focus:border-orange-500 disabled:bg-gray-100"
|
||||||
|
>
|
||||||
|
<option value="">Zeit wählen</option>
|
||||||
|
{rescheduleAvailableTimes?.map(time => (
|
||||||
|
<option key={time} value={time}>{time}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{!rescheduleFormData.appointmentDate && (
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Wähle zuerst ein Datum</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="bg-amber-50 border border-amber-200 text-amber-800 px-3 py-2 rounded-md text-sm">
|
||||||
|
Der Kunde erhält eine E-Mail mit dem Vorschlag. Er hat 48 Stunden Zeit zu antworten.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex space-x-3 mt-6">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowRescheduleModal(null);
|
||||||
|
setRescheduleFormData({ appointmentDate: '', appointmentTime: '' });
|
||||||
|
setRescheduleError('');
|
||||||
|
}}
|
||||||
|
className="flex-1 px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleRescheduleBooking}
|
||||||
|
disabled={!rescheduleFormData.appointmentDate || !rescheduleFormData.appointmentTime || isProposingReschedule}
|
||||||
|
className="flex-1 px-4 py-2 bg-orange-600 text-white rounded-md hover:bg-orange-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{isProposingReschedule ? 'Senden...' : 'Vorschlag senden'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
464
src/client/components/admin-gallery.tsx
Normal file
464
src/client/components/admin-gallery.tsx
Normal file
@@ -0,0 +1,464 @@
|
|||||||
|
import { useState, useEffect, useMemo } from "react";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { queryClient } from "@/client/rpc-client";
|
||||||
|
|
||||||
|
export function AdminGallery() {
|
||||||
|
// Component state
|
||||||
|
const [photoTitle, setPhotoTitle] = useState<string>("");
|
||||||
|
const [photoPreview, setPhotoPreview] = useState<string>("");
|
||||||
|
const [photoBase64, setPhotoBase64] = useState<string>("");
|
||||||
|
const [errorMsg, setErrorMsg] = useState<string>("");
|
||||||
|
const [successMsg, setSuccessMsg] = useState<string>("");
|
||||||
|
const [draggedPhotoId, setDraggedPhotoId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Data fetching with live query
|
||||||
|
const { data: photos, refetch: refetchPhotos } = useQuery(
|
||||||
|
queryClient.gallery.live.adminListPhotos.experimental_liveOptions({
|
||||||
|
input: { sessionId: localStorage.getItem("sessionId") || "" }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Memoized sorted photos to avoid re-sorting on every render and prevent cache mutation
|
||||||
|
const sortedPhotos = useMemo(() => [...(photos || [])].sort((a, b) => a.order - b.order), [photos]);
|
||||||
|
|
||||||
|
// Mutations
|
||||||
|
const { mutate: uploadPhoto, isPending: isUploading } = useMutation(
|
||||||
|
queryClient.gallery.uploadPhoto.mutationOptions()
|
||||||
|
);
|
||||||
|
const { mutate: deletePhoto } = useMutation(
|
||||||
|
queryClient.gallery.deletePhoto.mutationOptions()
|
||||||
|
);
|
||||||
|
const { mutate: updatePhotoOrder } = useMutation(
|
||||||
|
queryClient.gallery.updatePhotoOrder.mutationOptions()
|
||||||
|
);
|
||||||
|
const { mutate: setCoverPhoto } = useMutation(
|
||||||
|
queryClient.gallery.setCoverPhoto.mutationOptions()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auto-clear messages after 5 seconds
|
||||||
|
useEffect(() => {
|
||||||
|
if (errorMsg) {
|
||||||
|
const timer = setTimeout(() => setErrorMsg(""), 5000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [errorMsg]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (successMsg) {
|
||||||
|
const timer = setTimeout(() => setSuccessMsg(""), 5000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [successMsg]);
|
||||||
|
|
||||||
|
// Image compression function (adapted from booking-form.tsx)
|
||||||
|
const compressImage = (file: File, maxWidth: number = 800, quality: number = 0.8): Promise<string> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const img = new Image();
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
// Calculate new dimensions
|
||||||
|
let { width, height } = img;
|
||||||
|
if (width > maxWidth) {
|
||||||
|
height = (height * maxWidth) / width;
|
||||||
|
width = maxWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
|
||||||
|
// Draw and compress
|
||||||
|
ctx?.drawImage(img, 0, 0, width, height);
|
||||||
|
const compressedDataUrl = canvas.toDataURL('image/jpeg', quality);
|
||||||
|
resolve(compressedDataUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onerror = reject;
|
||||||
|
img.src = URL.createObjectURL(file);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// File upload handler
|
||||||
|
const handlePhotoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// Validate file type
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
setErrorMsg("Bitte wähle nur Bilddateien aus.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file size (max 1MB)
|
||||||
|
if (file.size > 1 * 1024 * 1024) {
|
||||||
|
setErrorMsg("Das Foto ist zu groß. Bitte wähle ein Bild unter 1MB.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Helper: measure DataURL size via Blob
|
||||||
|
const getDataUrlSizeBytes = async (dataUrl: string): Promise<number> => {
|
||||||
|
const res = await fetch(dataUrl);
|
||||||
|
const blob = await res.blob();
|
||||||
|
return blob.size;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try compressing with decreasing quality until <= 1MB or give up
|
||||||
|
const qualitySteps = [0.8, 0.6, 0.4];
|
||||||
|
let finalDataUrl = "";
|
||||||
|
for (const q of qualitySteps) {
|
||||||
|
const candidate = await compressImage(file, 800, q);
|
||||||
|
const sizeBytes = await getDataUrlSizeBytes(candidate);
|
||||||
|
if (sizeBytes <= 1 * 1024 * 1024) {
|
||||||
|
finalDataUrl = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!finalDataUrl) {
|
||||||
|
setErrorMsg("Das komprimierte Bild ist weiterhin größer als 1MB. Bitte wähle ein kleineres Bild.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPhotoBase64(finalDataUrl);
|
||||||
|
setPhotoPreview(finalDataUrl);
|
||||||
|
setErrorMsg("");
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Photo compression failed:', error);
|
||||||
|
setErrorMsg('Fehler beim Verarbeiten des Bildes. Bitte versuche es mit einem anderen Bild.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePhoto = () => {
|
||||||
|
setPhotoBase64("");
|
||||||
|
setPhotoPreview("");
|
||||||
|
// Reset file input
|
||||||
|
const fileInput = document.getElementById('gallery-photo-upload') as HTMLInputElement;
|
||||||
|
if (fileInput) fileInput.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Drag and drop handlers
|
||||||
|
const handleDragStart = (e: React.DragEvent, photoId: string) => {
|
||||||
|
setDraggedPhotoId(photoId);
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent, targetPhotoId: string) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!draggedPhotoId || draggedPhotoId === targetPhotoId) {
|
||||||
|
setDraggedPhotoId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const draggedPhoto = photos?.find(p => p.id === draggedPhotoId);
|
||||||
|
const targetPhoto = photos?.find(p => p.id === targetPhotoId);
|
||||||
|
|
||||||
|
if (!draggedPhoto || !targetPhoto) {
|
||||||
|
setDraggedPhotoId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = localStorage.getItem("sessionId");
|
||||||
|
if (!sessionId) {
|
||||||
|
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
|
||||||
|
setDraggedPhotoId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a fully reordered list based on the current sorted order
|
||||||
|
const sorted = [...(photos || [])].sort((a, b) => a.order - b.order);
|
||||||
|
const fromIndex = sorted.findIndex(p => p.id === draggedPhotoId);
|
||||||
|
const toIndex = sorted.findIndex(p => p.id === targetPhotoId);
|
||||||
|
if (fromIndex === -1 || toIndex === -1) {
|
||||||
|
setDraggedPhotoId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reordered = [...sorted];
|
||||||
|
const [moved] = reordered.splice(fromIndex, 1);
|
||||||
|
reordered.splice(toIndex, 0, moved);
|
||||||
|
|
||||||
|
// Reindex orders 0..n-1
|
||||||
|
const photoOrders = reordered.map((p, idx) => ({ id: p.id, order: idx }));
|
||||||
|
|
||||||
|
updatePhotoOrder(
|
||||||
|
{
|
||||||
|
sessionId,
|
||||||
|
photoOrders
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setSuccessMsg("Foto-Reihenfolge aktualisiert.");
|
||||||
|
// Sofortige Aktualisierung der Thumbnails in korrekter Reihenfolge
|
||||||
|
refetchPhotos();
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
setErrorMsg(err?.message || "Fehler beim Aktualisieren der Reihenfolge.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
setDraggedPhotoId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = () => {
|
||||||
|
setDraggedPhotoId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl mx-auto space-y-6">
|
||||||
|
{/* Error and Success Messages */}
|
||||||
|
{errorMsg && (
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<svg className="w-5 h-5 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">{errorMsg}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{successMsg && (
|
||||||
|
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-md">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<svg className="w-5 h-5 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">{successMsg}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Upload Form Section */}
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Neues Foto hochladen</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Foto auswählen *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="gallery-photo-upload"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handlePhotoUpload}
|
||||||
|
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-pink-50 file:text-pink-700 hover:file:bg-pink-100"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Max. 1MB, alle Bildformate erlaubt
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{photoPreview && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Vorschau
|
||||||
|
</label>
|
||||||
|
<div className="relative inline-block">
|
||||||
|
<img
|
||||||
|
src={photoPreview}
|
||||||
|
alt="Foto Vorschau"
|
||||||
|
className="w-32 h-32 object-cover rounded-lg border border-gray-200"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={removePhoto}
|
||||||
|
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-sm hover:bg-red-600"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Titel (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={photoTitle}
|
||||||
|
onChange={(e) => setPhotoTitle(e.target.value)}
|
||||||
|
placeholder="z.B. French Manicure, Gel Nails..."
|
||||||
|
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-pink-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setErrorMsg("");
|
||||||
|
setSuccessMsg("");
|
||||||
|
|
||||||
|
if (!photoBase64) {
|
||||||
|
setErrorMsg("Bitte wähle zuerst ein Foto aus.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = localStorage.getItem("sessionId") || "";
|
||||||
|
if (!sessionId) {
|
||||||
|
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadPhoto(
|
||||||
|
{
|
||||||
|
sessionId,
|
||||||
|
base64Data: photoBase64,
|
||||||
|
title: photoTitle || undefined
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setSuccessMsg("Foto erfolgreich hochgeladen.");
|
||||||
|
setPhotoTitle("");
|
||||||
|
setPhotoPreview("");
|
||||||
|
setPhotoBase64("");
|
||||||
|
// Reset file input
|
||||||
|
const fileInput = document.getElementById('gallery-photo-upload') as HTMLInputElement;
|
||||||
|
if (fileInput) fileInput.value = '';
|
||||||
|
// Sofortige Aktualisierung der Liste
|
||||||
|
refetchPhotos();
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
setErrorMsg(err?.message || "Fehler beim Hochladen des Fotos.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
disabled={isUploading}
|
||||||
|
className={`bg-pink-600 text-white px-4 py-2 rounded-md font-medium transition-colors ${isUploading ? 'opacity-60 cursor-not-allowed' : 'hover:bg-pink-700'}`}
|
||||||
|
>
|
||||||
|
{isUploading ? 'Lädt hoch…' : 'Foto hochladen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Photo Grid Section */}
|
||||||
|
<div className="bg-white rounded-lg shadow">
|
||||||
|
<div className="p-4 border-b">
|
||||||
|
<h3 className="text-lg font-semibold">Foto-Galerie verwalten</h3>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
|
Ziehe Fotos per Drag & Drop, um die Reihenfolge zu ändern. Klicke auf das X, um ein Foto zu löschen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
{photos?.length === 0 && (
|
||||||
|
<div className="text-center text-gray-500 py-8">
|
||||||
|
<div className="text-lg font-medium mb-2">Noch keine Fotos hochgeladen</div>
|
||||||
|
<div className="text-sm">Lade dein erstes Foto hoch, um deine Galerie zu starten.</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sortedPhotos && sortedPhotos.length > 0 && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{sortedPhotos.map((photo) => (
|
||||||
|
<div
|
||||||
|
key={photo.id}
|
||||||
|
draggable={true}
|
||||||
|
onDragStart={(e) => handleDragStart(e, photo.id)}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDrop={(e) => handleDrop(e, photo.id)}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
className={`relative bg-gray-50 rounded-lg overflow-hidden border-2 transition-all duration-200 hover:shadow-md cursor-move ${
|
||||||
|
draggedPhotoId === photo.id ? 'opacity-50 border-pink-300' : 'border-transparent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={photo.base64Data}
|
||||||
|
alt={photo.title || "Galerie Foto"}
|
||||||
|
className="w-full h-48 object-cover"
|
||||||
|
/>
|
||||||
|
<div className="p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
{photo.title && (
|
||||||
|
<div className="font-medium text-gray-900 text-sm mb-1">
|
||||||
|
{photo.title}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
Reihenfolge: {photo.order}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">
|
||||||
|
{new Date(photo.createdAt).toLocaleDateString('de-DE')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm("Möchtest du dieses Foto wirklich löschen?")) {
|
||||||
|
const sessionId = localStorage.getItem("sessionId");
|
||||||
|
if (!sessionId) {
|
||||||
|
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deletePhoto(
|
||||||
|
{ sessionId, id: photo.id },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setSuccessMsg("Foto gelöscht.");
|
||||||
|
// Sofortige Aktualisierung der Liste
|
||||||
|
refetchPhotos();
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
setErrorMsg(err?.message || "Fehler beim Löschen des Fotos.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="ml-2 text-red-600 hover:text-red-800 hover:bg-red-50 rounded-full p-1 transition-colors"
|
||||||
|
title="Foto löschen"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const sessionId = localStorage.getItem("sessionId");
|
||||||
|
if (!sessionId) {
|
||||||
|
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCoverPhoto(
|
||||||
|
{ sessionId, id: photo.id },
|
||||||
|
{
|
||||||
|
onSuccess: () => setSuccessMsg("Als Cover-Bild gesetzt."),
|
||||||
|
onError: (err: any) => setErrorMsg(err?.message || "Fehler beim Setzen des Cover-Bildes."),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className={`ml-2 ${photo.cover ? 'text-green-700 hover:text-green-800 hover:bg-green-50' : 'text-gray-600 hover:text-gray-800 hover:bg-gray-50'} rounded-full p-1 transition-colors`}
|
||||||
|
title={photo.cover ? "Cover-Bild (aktiv)" : "Als Cover-Bild setzen"}
|
||||||
|
>
|
||||||
|
{photo.cover ? (
|
||||||
|
<svg className="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-7.25 7.25a1 1 0 01-1.414 0l-3-3a1 1 0 111.414-1.414L8.5 11.086l6.543-6.543a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l6-6 4 4 6-6" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
356
src/client/components/admin-reviews.tsx
Normal file
356
src/client/components/admin-reviews.tsx
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { queryClient } from "@/client/rpc-client";
|
||||||
|
|
||||||
|
type ReviewStatus = "pending" | "approved" | "rejected";
|
||||||
|
|
||||||
|
type Review = {
|
||||||
|
id: string;
|
||||||
|
bookingId: string;
|
||||||
|
customerName: string;
|
||||||
|
customerEmail?: string;
|
||||||
|
rating: number;
|
||||||
|
comment: string;
|
||||||
|
status: "pending" | "approved" | "rejected";
|
||||||
|
createdAt: string;
|
||||||
|
reviewedAt?: string;
|
||||||
|
reviewedBy?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getStatusText(status: string) {
|
||||||
|
switch (status) {
|
||||||
|
case "pending":
|
||||||
|
return "Ausstehend";
|
||||||
|
case "approved":
|
||||||
|
return "Genehmigt";
|
||||||
|
case "rejected":
|
||||||
|
return "Abgelehnt";
|
||||||
|
default:
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusColor(status: string) {
|
||||||
|
switch (status) {
|
||||||
|
case "pending":
|
||||||
|
return "bg-yellow-100 text-yellow-800";
|
||||||
|
case "approved":
|
||||||
|
return "bg-green-100 text-green-800";
|
||||||
|
case "rejected":
|
||||||
|
return "bg-red-100 text-red-800";
|
||||||
|
default:
|
||||||
|
return "bg-gray-100 text-gray-800";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStars(rating: number) {
|
||||||
|
const stars = [] as React.ReactElement[];
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
const filled = i <= rating;
|
||||||
|
stars.push(
|
||||||
|
<span key={i} className={filled ? "text-yellow-500" : "text-gray-300"}>
|
||||||
|
{filled ? "★" : "☆"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <div className="text-lg">{stars}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(isoString?: string) {
|
||||||
|
if (!isoString) return "";
|
||||||
|
try {
|
||||||
|
return new Date(isoString).toLocaleDateString("de-DE", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return isoString || "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminReviews() {
|
||||||
|
const [activeStatusTab, setActiveStatusTab] = useState<ReviewStatus>("pending");
|
||||||
|
const [successMsg, setSuccessMsg] = useState<string>("");
|
||||||
|
const [errorMsg, setErrorMsg] = useState<string>("");
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (errorMsg) {
|
||||||
|
const t = setTimeout(() => setErrorMsg(""), 5000);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}
|
||||||
|
}, [errorMsg]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (successMsg) {
|
||||||
|
const t = setTimeout(() => setSuccessMsg(""), 5000);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}
|
||||||
|
}, [successMsg]);
|
||||||
|
|
||||||
|
const sessionId = typeof window !== "undefined" ? localStorage.getItem("sessionId") || "" : "";
|
||||||
|
|
||||||
|
const { data: reviews } = useQuery(
|
||||||
|
queryClient.reviews.live.adminListReviews.experimental_liveOptions({
|
||||||
|
input: { sessionId, statusFilter: activeStatusTab },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Separate queries for quick stats calculation
|
||||||
|
const { data: allReviews } = useQuery(
|
||||||
|
queryClient.reviews.live.adminListReviews.experimental_liveOptions({
|
||||||
|
input: { sessionId },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const { mutate: approveReview } = useMutation(
|
||||||
|
queryClient.reviews.approveReview.mutationOptions({
|
||||||
|
onSuccess: () => {
|
||||||
|
setSuccessMsg("Bewertung wurde genehmigt.");
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
setErrorMsg(err?.message || "Fehler beim Genehmigen der Bewertung.");
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const { mutate: rejectReview } = useMutation(
|
||||||
|
queryClient.reviews.rejectReview.mutationOptions({
|
||||||
|
onSuccess: () => {
|
||||||
|
setSuccessMsg("Bewertung wurde abgelehnt.");
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
setErrorMsg(err?.message || "Fehler beim Ablehnen der Bewertung.");
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const { mutate: deleteReview } = useMutation(
|
||||||
|
queryClient.reviews.deleteReview.mutationOptions({
|
||||||
|
onSuccess: () => {
|
||||||
|
setSuccessMsg("Bewertung wurde gelöscht.");
|
||||||
|
setShowDeleteConfirm(null);
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
setErrorMsg(err?.message || "Fehler beim Löschen der Bewertung.");
|
||||||
|
setShowDeleteConfirm(null);
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate quick stats from full dataset
|
||||||
|
const pendingCount = allReviews?.filter((r: Review) => r.status === "pending").length || 0;
|
||||||
|
const approvedCount = allReviews?.filter((r: Review) => r.status === "approved").length || 0;
|
||||||
|
const rejectedCount = allReviews?.filter((r: Review) => r.status === "rejected").length || 0;
|
||||||
|
const totalCount = allReviews?.length || 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl mx-auto space-y-6">
|
||||||
|
{(successMsg || errorMsg) && (
|
||||||
|
<div className="mb-4">
|
||||||
|
{errorMsg && (
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">Fehler:</span>
|
||||||
|
<span className="ml-1">{errorMsg}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{successMsg && (
|
||||||
|
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-md">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">Erfolg:</span>
|
||||||
|
<span className="ml-1">{successMsg}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div className="bg-white rounded-lg shadow p-4">
|
||||||
|
<div className="text-2xl font-bold text-yellow-600">{pendingCount}</div>
|
||||||
|
<div className="text-sm text-gray-600">Ausstehend</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-lg shadow p-4">
|
||||||
|
<div className="text-2xl font-bold text-green-600">{approvedCount}</div>
|
||||||
|
<div className="text-sm text-gray-600">Genehmigt</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-lg shadow p-4">
|
||||||
|
<div className="text-2xl font-bold text-red-600">{rejectedCount}</div>
|
||||||
|
<div className="text-sm text-gray-600">Abgelehnt</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-lg shadow p-4">
|
||||||
|
<div className="text-2xl font-bold text-gray-600">{totalCount}</div>
|
||||||
|
<div className="text-sm text-gray-600">Summe</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow">
|
||||||
|
<div className="border-b border-gray-200">
|
||||||
|
<nav className="-mb-px flex space-x-8 px-6">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveStatusTab("pending")}
|
||||||
|
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||||
|
activeStatusTab === "pending"
|
||||||
|
? "border-pink-500 text-pink-600"
|
||||||
|
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
⏳ Ausstehend
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveStatusTab("approved")}
|
||||||
|
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||||
|
activeStatusTab === "approved"
|
||||||
|
? "border-pink-500 text-pink-600"
|
||||||
|
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
✅ Genehmigt
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveStatusTab("rejected")}
|
||||||
|
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||||
|
activeStatusTab === "rejected"
|
||||||
|
? "border-pink-500 text-pink-600"
|
||||||
|
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
❌ Abgelehnt
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow">
|
||||||
|
{(!reviews || reviews.length === 0) && (
|
||||||
|
<div className="p-8 text-center text-gray-500">
|
||||||
|
<div className="text-lg font-medium mb-2">Keine Bewertungen gefunden</div>
|
||||||
|
<div className="text-sm">Es liegen aktuell keine Bewertungen in dieser Ansicht vor.</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="divide-y">
|
||||||
|
{reviews?.map((review: Review) => (
|
||||||
|
<div key={review.id} className="p-6 hover:bg-gray-50 transition-colors">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-semibold text-gray-900">{review.customerName}</div>
|
||||||
|
<div className="text-sm text-gray-500">{review.customerEmail || "—"}</div>
|
||||||
|
</div>
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(review.status)}`}>
|
||||||
|
{getStatusText(review.status)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2">{renderStars(review.rating)}</div>
|
||||||
|
<div className="mt-2 text-gray-700 whitespace-pre-wrap break-words">{review.comment}</div>
|
||||||
|
|
||||||
|
<div className="mt-2 text-sm text-gray-500 space-x-4">
|
||||||
|
<span>Buchung: {review.bookingId}</span>
|
||||||
|
<span>Eingereicht am: {formatDate(review.createdAt)}</span>
|
||||||
|
{review.reviewedAt && <span>Geprüft am: {formatDate(review.reviewedAt)}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center gap-2">
|
||||||
|
{review.status === "pending" && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => approveReview({ sessionId, id: review.id })}
|
||||||
|
className="bg-green-600 hover:bg-green-700 text-white px-3 py-1.5 rounded-md text-sm"
|
||||||
|
>
|
||||||
|
Genehmigen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => rejectReview({ sessionId, id: review.id })}
|
||||||
|
className="bg-red-600 hover:bg-red-700 text-white px-3 py-1.5 rounded-md text-sm"
|
||||||
|
>
|
||||||
|
Ablehnen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteConfirm(review.id)}
|
||||||
|
className="bg-gray-600 hover:bg-gray-700 text-white px-3 py-1.5 rounded-md text-sm"
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{review.status === "approved" && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => rejectReview({ sessionId, id: review.id })}
|
||||||
|
className="bg-red-600 hover:bg-red-700 text-white px-3 py-1.5 rounded-md text-sm"
|
||||||
|
>
|
||||||
|
Ablehnen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteConfirm(review.id)}
|
||||||
|
className="bg-gray-600 hover:bg-gray-700 text-white px-3 py-1.5 rounded-md text-sm"
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{review.status === "rejected" && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => approveReview({ sessionId, id: review.id })}
|
||||||
|
className="bg-green-600 hover:bg-green-700 text-white px-3 py-1.5 rounded-md text-sm"
|
||||||
|
>
|
||||||
|
Genehmigen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteConfirm(review.id)}
|
||||||
|
className="bg-gray-600 hover:bg-gray-700 text-white px-3 py-1.5 rounded-md text-sm"
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showDeleteConfirm && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-4">Bewertung löschen</h3>
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
Bist du sicher, dass du diese Bewertung löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.
|
||||||
|
</p>
|
||||||
|
<div className="flex space-x-3">
|
||||||
|
<button
|
||||||
|
onClick={() => deleteReview({ sessionId, id: showDeleteConfirm })}
|
||||||
|
className="flex-1 bg-red-600 text-white py-2 px-4 rounded-md hover:bg-red-700 transition-colors"
|
||||||
|
>
|
||||||
|
Ja, löschen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteConfirm(null)}
|
||||||
|
className="flex-1 bg-gray-200 text-gray-800 py-2 px-4 rounded-md hover:bg-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -197,23 +197,23 @@ export function AdminTreatments() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
|
<div className="bg-white rounded-lg shadow-lg overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full table-fixed">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="w-2/5 px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Behandlung
|
Behandlung
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="w-1/6 px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Kategorie
|
Kategorie
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="w-1/12 px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Dauer
|
Dauer
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="w-1/12 px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Preis
|
Preis
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="w-1/6 px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Aktionen
|
Aktionen
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -221,22 +221,26 @@ export function AdminTreatments() {
|
|||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
{treatments?.map((treatment) => (
|
{treatments?.map((treatment) => (
|
||||||
<tr key={treatment.id}>
|
<tr key={treatment.id}>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-gray-900">{treatment.name}</div>
|
<div className="text-sm font-medium text-gray-900 truncate">{treatment.name}</div>
|
||||||
<div className="text-sm text-gray-500">{treatment.description}</div>
|
<div className="text-sm text-gray-500 truncate" title={treatment.description}>
|
||||||
|
{treatment.description.length > 50
|
||||||
|
? `${treatment.description.substring(0, 50)}...`
|
||||||
|
: treatment.description}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
<td className="px-6 py-4 text-sm text-gray-900 truncate">
|
||||||
{treatment.category}
|
{treatment.category}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
<td className="px-6 py-4 text-sm text-gray-900">
|
||||||
{treatment.duration} Min
|
{treatment.duration} Min
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
<td className="px-6 py-4 text-sm text-gray-900">
|
||||||
{(treatment.price / 100).toFixed(2)} €
|
{(treatment.price / 100).toFixed(2)} €
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
|
<td className="px-6 py-4 text-sm font-medium space-x-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEdit(treatment)}
|
onClick={() => handleEdit(treatment)}
|
||||||
className="text-pink-600 hover:text-pink-900"
|
className="text-pink-600 hover:text-pink-900"
|
||||||
|
|||||||
@@ -1,76 +1,159 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { queryClient } from "@/client/rpc-client";
|
import { queryClient } from "@/client/rpc-client";
|
||||||
|
|
||||||
|
// Feature flag for multi-treatments availability API compatibility
|
||||||
|
const USE_MULTI_TREATMENTS_AVAILABILITY = true;
|
||||||
|
|
||||||
export function BookingForm() {
|
export function BookingForm() {
|
||||||
const [selectedTreatment, setSelectedTreatment] = useState("");
|
const [selectedTreatments, setSelectedTreatments] = useState<Array<{id: string, name: string, duration: number, price: number}>>([]);
|
||||||
const [customerName, setCustomerName] = useState("");
|
const [customerName, setCustomerName] = useState("");
|
||||||
const [customerEmail, setCustomerEmail] = useState("");
|
const [customerEmail, setCustomerEmail] = useState("");
|
||||||
const [customerPhone, setCustomerPhone] = useState("");
|
const [customerPhone, setCustomerPhone] = useState("");
|
||||||
const [appointmentDate, setAppointmentDate] = useState("");
|
const [appointmentDate, setAppointmentDate] = useState("");
|
||||||
const [selectedSlotId, setSelectedSlotId] = useState<string>("");
|
const [selectedTime, setSelectedTime] = useState("");
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [agbAccepted, setAgbAccepted] = useState(false);
|
const [agbAccepted, setAgbAccepted] = useState(false);
|
||||||
|
const [ageConfirmed, setAgeConfirmed] = useState(false);
|
||||||
const [inspirationPhoto, setInspirationPhoto] = useState<string>("");
|
const [inspirationPhoto, setInspirationPhoto] = useState<string>("");
|
||||||
const [photoPreview, setPhotoPreview] = useState<string>("");
|
const [photoPreview, setPhotoPreview] = useState<string>("");
|
||||||
const [errorMessage, setErrorMessage] = useState<string>("");
|
const [errorMessage, setErrorMessage] = useState<string>("");
|
||||||
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
|
|
||||||
|
// Load saved customer data from localStorage on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const savedName = localStorage.getItem("bookingForm_customerName");
|
||||||
|
const savedEmail = localStorage.getItem("bookingForm_customerEmail");
|
||||||
|
const savedPhone = localStorage.getItem("bookingForm_customerPhone");
|
||||||
|
|
||||||
|
if (savedName) setCustomerName(savedName);
|
||||||
|
if (savedEmail) setCustomerEmail(savedEmail);
|
||||||
|
if (savedPhone) setCustomerPhone(savedPhone);
|
||||||
|
|
||||||
|
setIsInitialized(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Save customer data to localStorage when it changes (after initial load)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isInitialized) return;
|
||||||
|
if (customerName) {
|
||||||
|
localStorage.setItem("bookingForm_customerName", customerName);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem("bookingForm_customerName");
|
||||||
|
}
|
||||||
|
}, [customerName, isInitialized]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isInitialized) return;
|
||||||
|
if (customerEmail) {
|
||||||
|
localStorage.setItem("bookingForm_customerEmail", customerEmail);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem("bookingForm_customerEmail");
|
||||||
|
}
|
||||||
|
}, [customerEmail, isInitialized]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isInitialized) return;
|
||||||
|
if (customerPhone) {
|
||||||
|
localStorage.setItem("bookingForm_customerPhone", customerPhone);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem("bookingForm_customerPhone");
|
||||||
|
}
|
||||||
|
}, [customerPhone, isInitialized]);
|
||||||
|
|
||||||
const { data: treatments } = useQuery(
|
const { data: treatments } = useQuery(
|
||||||
queryClient.treatments.live.list.experimental_liveOptions()
|
queryClient.treatments.live.list.experimental_liveOptions()
|
||||||
);
|
);
|
||||||
|
|
||||||
// Lade alle Slots live und filtere freie Slots
|
// Comment 3: Compute total duration and price once per render
|
||||||
const { data: allSlots } = useQuery(
|
const totalDuration = useMemo(
|
||||||
queryClient.availability.live.list.experimental_liveOptions()
|
() => selectedTreatments.reduce((sum, t) => sum + t.duration, 0),
|
||||||
|
[selectedTreatments]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filtere freie Slots und entferne vergangene Termine
|
const totalPrice = useMemo(
|
||||||
const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
|
() => selectedTreatments.reduce((sum, t) => sum + t.price, 0),
|
||||||
const freeSlots = (allSlots || []).filter((s) => {
|
[selectedTreatments]
|
||||||
// Nur freie Slots
|
);
|
||||||
if (s.status !== "free") return false;
|
|
||||||
|
|
||||||
// Nur zukünftige oder heutige Termine
|
// Comment 1: Dynamische Verfügbarkeitsabfrage mit Kompatibilitäts-Fallback
|
||||||
if (s.date < today) return false;
|
const availabilityQueryInput = USE_MULTI_TREATMENTS_AVAILABILITY
|
||||||
|
? { date: appointmentDate, treatmentIds: selectedTreatments.map(t => t.id) }
|
||||||
|
: { date: appointmentDate, treatmentId: selectedTreatments[0]?.id ?? "" };
|
||||||
|
|
||||||
// Für heute: nur zukünftige Uhrzeiten
|
const availabilityQueryEnabled = USE_MULTI_TREATMENTS_AVAILABILITY
|
||||||
if (s.date === today) {
|
? !!appointmentDate && selectedTreatments.length > 0
|
||||||
const now = new Date();
|
: !!appointmentDate && selectedTreatments.length > 0;
|
||||||
const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
|
||||||
if (s.time <= currentTime) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
const { data: availableTimes, isLoading, isFetching, error } = useQuery({
|
||||||
|
...queryClient.recurringAvailability.getAvailableTimes.queryOptions({
|
||||||
|
input: availabilityQueryInput as any
|
||||||
|
}),
|
||||||
|
enabled: availabilityQueryEnabled
|
||||||
});
|
});
|
||||||
|
|
||||||
const availableDates = Array.from(new Set(freeSlots.map((s) => s.date))).sort();
|
|
||||||
const slotsByDate = appointmentDate
|
|
||||||
? freeSlots.filter((s) => s.date === appointmentDate)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const { mutate: createBooking, isPending } = useMutation(
|
const { mutate: createBooking, isPending } = useMutation(
|
||||||
queryClient.bookings.create.mutationOptions()
|
queryClient.bookings.create.mutationOptions()
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedTreatmentData = treatments?.find((t) => t.id === selectedTreatment);
|
// Comment 2: Handle treatment checkbox toggle with functional state updates
|
||||||
const availableSlots = slotsByDate || []; // Slots sind bereits gefiltert
|
const handleTreatmentToggle = (treatment: {id: string, name: string, duration: number, price: number}) => {
|
||||||
|
setSelectedTreatments((prev) => {
|
||||||
|
const isSelected = prev.some(t => t.id === treatment.id);
|
||||||
|
|
||||||
// Debug logging (commented out - uncomment if needed)
|
if (isSelected) {
|
||||||
// console.log("Debug - All slots:", allSlots);
|
// Remove from selection
|
||||||
// console.log("Debug - Free slots:", freeSlots);
|
return prev.filter(t => t.id !== treatment.id);
|
||||||
// console.log("Debug - Available dates:", availableDates);
|
} else if (prev.length < 3) {
|
||||||
// console.log("Debug - Selected date:", appointmentDate);
|
// Add to selection (only if limit not reached)
|
||||||
// console.log("Debug - Slots by date:", slotsByDate);
|
return [...prev, {
|
||||||
// console.log("Debug - Available slots:", availableSlots);
|
id: treatment.id,
|
||||||
|
name: treatment.name,
|
||||||
|
duration: treatment.duration,
|
||||||
|
price: treatment.price
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
// Additional debugging for slot status
|
// Return unchanged if limit reached
|
||||||
// if (allSlots && allSlots.length > 0) {
|
return prev;
|
||||||
// const statusCounts = allSlots.reduce((acc, slot) => {
|
});
|
||||||
// acc[slot.status] = (acc[slot.status] || 0) + 1;
|
|
||||||
// return acc;
|
// Clear selected time when treatments change
|
||||||
// }, {} as Record<string, number>);
|
setSelectedTime("");
|
||||||
// console.log("Debug - Slot status counts:", statusCounts);
|
};
|
||||||
// }
|
|
||||||
|
// Comment 4: Reconcile selectedTreatments when treatments list changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!treatments) return;
|
||||||
|
|
||||||
|
setSelectedTreatments((prev) => {
|
||||||
|
const validTreatments = prev.filter((selected) =>
|
||||||
|
treatments.some((t) => t.id === selected.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Only update state if something changed to avoid unnecessary re-renders
|
||||||
|
if (validTreatments.length !== prev.length) {
|
||||||
|
return validTreatments;
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
}, [treatments]);
|
||||||
|
|
||||||
|
// Clear selectedTime when it becomes invalid
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedTime && availableTimes && !availableTimes.includes(selectedTime)) {
|
||||||
|
setSelectedTime("");
|
||||||
|
}
|
||||||
|
}, [availableTimes, selectedTime]);
|
||||||
|
|
||||||
|
// Helper function for local date in YYYY-MM-DD format
|
||||||
|
const getLocalYmd = () => {
|
||||||
|
const now = new Date();
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(now.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
};
|
||||||
|
|
||||||
const handlePhotoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handlePhotoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
@@ -138,12 +221,13 @@ export function BookingForm() {
|
|||||||
if (fileInput) fileInput.value = '';
|
if (fileInput) fileInput.value = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setErrorMessage(""); // Clear any previous error messages
|
setErrorMessage(""); // Clear any previous error messages
|
||||||
|
|
||||||
// console.log("Form submitted with data:", {
|
// console.log("Form submitted with data:", {
|
||||||
// selectedTreatment,
|
// selectedTreatments,
|
||||||
// customerName,
|
// customerName,
|
||||||
// customerEmail,
|
// customerEmail,
|
||||||
// customerPhone,
|
// customerPhone,
|
||||||
@@ -152,30 +236,38 @@ export function BookingForm() {
|
|||||||
// agbAccepted
|
// agbAccepted
|
||||||
// });
|
// });
|
||||||
|
|
||||||
if (!selectedTreatment || !customerName || !customerEmail || !customerPhone || !appointmentDate || !selectedSlotId) {
|
if (selectedTreatments.length === 0 || !customerName || !customerEmail || !customerPhone || !appointmentDate || !selectedTime) {
|
||||||
|
if (selectedTreatments.length === 0) {
|
||||||
|
setErrorMessage("Bitte wähle mindestens eine Behandlung aus.");
|
||||||
|
} else {
|
||||||
setErrorMessage("Bitte fülle alle erforderlichen Felder aus.");
|
setErrorMessage("Bitte fülle alle erforderlichen Felder aus.");
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!agbAccepted) {
|
if (!agbAccepted) {
|
||||||
setErrorMessage("Bitte bestätige die Kenntnisnahme der Allgemeinen Geschäftsbedingungen.");
|
setErrorMessage("Bitte bestätige die Kenntnisnahme der Allgemeinen Geschäftsbedingungen.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const slot = availableSlots.find((s) => s.id === selectedSlotId);
|
if (!ageConfirmed) {
|
||||||
const appointmentTime = slot?.time || "";
|
setErrorMessage("Bitte bestätige, dass du mindestens 16 Jahre alt bist.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email validation now handled in backend before booking creation
|
||||||
|
const appointmentTime = selectedTime;
|
||||||
// console.log("Creating booking with data:", {
|
// console.log("Creating booking with data:", {
|
||||||
// treatmentId: selectedTreatment,
|
// treatments: selectedTreatments,
|
||||||
// customerName,
|
// customerName,
|
||||||
// customerEmail,
|
// customerEmail,
|
||||||
// customerPhone,
|
// customerPhone,
|
||||||
// appointmentDate,
|
// appointmentDate,
|
||||||
// appointmentTime,
|
// appointmentTime,
|
||||||
// notes,
|
// notes,
|
||||||
// inspirationPhoto,
|
// inspirationPhoto
|
||||||
// slotId: selectedSlotId,
|
|
||||||
// });
|
// });
|
||||||
createBooking(
|
createBooking(
|
||||||
{
|
{
|
||||||
treatmentId: selectedTreatment,
|
treatments: selectedTreatments,
|
||||||
customerName,
|
customerName,
|
||||||
customerEmail,
|
customerEmail,
|
||||||
customerPhone,
|
customerPhone,
|
||||||
@@ -183,18 +275,18 @@ export function BookingForm() {
|
|||||||
appointmentTime,
|
appointmentTime,
|
||||||
notes,
|
notes,
|
||||||
inspirationPhoto,
|
inspirationPhoto,
|
||||||
slotId: selectedSlotId,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setSelectedTreatment("");
|
setSelectedTreatments([]);
|
||||||
setCustomerName("");
|
setCustomerName("");
|
||||||
setCustomerEmail("");
|
setCustomerEmail("");
|
||||||
setCustomerPhone("");
|
setCustomerPhone("");
|
||||||
setAppointmentDate("");
|
setAppointmentDate("");
|
||||||
setSelectedSlotId("");
|
setSelectedTime("");
|
||||||
setNotes("");
|
setNotes("");
|
||||||
setAgbAccepted(false);
|
setAgbAccepted(false);
|
||||||
|
setAgeConfirmed(false);
|
||||||
setInspirationPhoto("");
|
setInspirationPhoto("");
|
||||||
setPhotoPreview("");
|
setPhotoPreview("");
|
||||||
setErrorMessage("");
|
setErrorMessage("");
|
||||||
@@ -205,14 +297,24 @@ export function BookingForm() {
|
|||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
console.error("Booking error:", error);
|
console.error("Booking error:", error);
|
||||||
const errorText = error?.message || error?.toString() || "Ein unbekannter Fehler ist aufgetreten.";
|
|
||||||
|
// Simple error handling for oRPC errors
|
||||||
|
let errorText = "Ein unbekannter Fehler ist aufgetreten.";
|
||||||
|
|
||||||
|
if (error?.cause?.message) {
|
||||||
|
errorText = error.cause.message;
|
||||||
|
} else if (error?.message && error.message !== "Internal server error") {
|
||||||
|
errorText = error.message;
|
||||||
|
}
|
||||||
|
|
||||||
setErrorMessage(errorText);
|
setErrorMessage(errorText);
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get minimum date (today) – nicht mehr genutzt, Datumsauswahl erfolgt aus freien Slots
|
// Dynamische Zeitauswahl: Kunde wählt beliebiges zukünftiges Datum,
|
||||||
|
// System berechnet verfügbare Zeiten in 15-Minuten-Intervallen basierend auf wiederkehrenden Regeln
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto bg-white rounded-lg shadow-lg p-6">
|
<div className="max-w-2xl mx-auto bg-white rounded-lg shadow-lg p-6">
|
||||||
@@ -221,24 +323,65 @@ export function BookingForm() {
|
|||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
{/* Treatment Selection */}
|
{/* Treatment Selection */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<div className="flex justify-between items-center mb-2">
|
||||||
Behandlung auswählen *
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
Behandlungen auswählen (1-3) *
|
||||||
</label>
|
</label>
|
||||||
<select
|
<span className="text-sm text-gray-600">
|
||||||
value={selectedTreatment}
|
{selectedTreatments.length} von 3 ausgewählt
|
||||||
onChange={(e) => setSelectedTreatment(e.target.value)}
|
</span>
|
||||||
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
|
</div>
|
||||||
required
|
|
||||||
>
|
{/* Checkbox List Container */}
|
||||||
<option value="">Wähle eine Behandlung</option>
|
<div className="max-h-96 overflow-y-auto border border-gray-300 rounded-md p-3 space-y-2" aria-label="Wähle bis zu 3 Behandlungen">
|
||||||
{treatments?.map((treatment) => (
|
{treatments?.map((treatment) => {
|
||||||
<option key={treatment.id} value={treatment.id}>
|
const isSelected = selectedTreatments.some(t => t.id === treatment.id);
|
||||||
{treatment.name} - {(treatment.price / 100).toFixed(2)} € ({treatment.duration} Min)
|
const isDisabled = selectedTreatments.length >= 3 && !isSelected;
|
||||||
</option>
|
|
||||||
))}
|
return (
|
||||||
</select>
|
<div key={treatment.id} className="flex items-start space-x-3">
|
||||||
{selectedTreatmentData && (
|
<input
|
||||||
<p className="mt-2 text-sm text-gray-600">{selectedTreatmentData.description}</p>
|
type="checkbox"
|
||||||
|
id={`treatment-${treatment.id}`}
|
||||||
|
checked={isSelected}
|
||||||
|
disabled={isDisabled}
|
||||||
|
onChange={() => handleTreatmentToggle({
|
||||||
|
id: treatment.id,
|
||||||
|
name: treatment.name,
|
||||||
|
duration: treatment.duration,
|
||||||
|
price: treatment.price
|
||||||
|
})}
|
||||||
|
className="h-4 w-4 text-pink-600 border-gray-300 rounded flex-shrink-0 mt-1"
|
||||||
|
/>
|
||||||
|
<label htmlFor={`treatment-${treatment.id}`} className={`flex-1 text-sm cursor-pointer ${isDisabled ? 'text-gray-400' : 'text-gray-700'}`}>
|
||||||
|
{treatment.name} - {treatment.duration} Min - {(treatment.price / 100).toFixed(2)} €
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Treatment Descriptions */}
|
||||||
|
{selectedTreatments.length > 0 && (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{selectedTreatments.map((selectedTreatment) => {
|
||||||
|
const fullTreatment = treatments?.find(t => t.id === selectedTreatment.id);
|
||||||
|
return fullTreatment ? (
|
||||||
|
<p key={selectedTreatment.id} className="text-sm text-gray-600">
|
||||||
|
<span className="font-medium">{fullTreatment.name}:</span> {fullTreatment.description}
|
||||||
|
</p>
|
||||||
|
) : null;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Live Calculation Display */}
|
||||||
|
{selectedTreatments.length > 0 && (
|
||||||
|
<div className="mt-3 bg-pink-50 border border-pink-200 rounded-lg p-4">
|
||||||
|
<p className="font-semibold text-pink-700">
|
||||||
|
📊 Gesamt: {totalDuration} Min | {(totalPrice / 100).toFixed(2)} €
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -287,48 +430,53 @@ export function BookingForm() {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Datum (nur freie Termine) *
|
Wunschdatum *
|
||||||
</label>
|
</label>
|
||||||
<select
|
<input
|
||||||
|
type="date"
|
||||||
value={appointmentDate}
|
value={appointmentDate}
|
||||||
onChange={(e) => { setAppointmentDate(e.target.value); setSelectedSlotId(""); }}
|
onChange={(e) => { setAppointmentDate(e.target.value); setSelectedTime(""); }}
|
||||||
|
min={getLocalYmd()}
|
||||||
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
|
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
|
||||||
required
|
required
|
||||||
>
|
/>
|
||||||
<option value="">Datum auswählen</option>
|
|
||||||
{availableDates.map((d) => (
|
|
||||||
<option key={d} value={d}>{d}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{availableDates.length === 0 && (
|
|
||||||
<p className="mt-2 text-sm text-gray-500">Aktuell keine freien Termine verfügbar.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Verfügbare Uhrzeit *
|
Verfügbare Uhrzeit (15-Min-Raster) *
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={selectedSlotId}
|
value={selectedTime}
|
||||||
onChange={(e) => setSelectedSlotId(e.target.value)}
|
onChange={(e) => setSelectedTime(e.target.value)}
|
||||||
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
|
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
|
||||||
disabled={!appointmentDate || !selectedTreatment}
|
disabled={!appointmentDate || selectedTreatments.length === 0 || isLoading || isFetching}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<option value="">Zeit auswählen</option>
|
<option value="">Zeit auswählen</option>
|
||||||
{availableSlots
|
{availableTimes?.map((time) => (
|
||||||
.sort((a, b) => a.time.localeCompare(b.time))
|
<option key={time} value={time}>
|
||||||
.map((slot) => (
|
{time}
|
||||||
<option key={slot.id} value={slot.id}>
|
|
||||||
{slot.time} ({slot.durationMinutes} min)
|
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{appointmentDate && availableSlots.length === 0 && (
|
{appointmentDate && selectedTreatments.length > 0 && isLoading && (
|
||||||
<p className="mt-2 text-sm text-gray-500">
|
<p className="mt-2 text-sm text-gray-500">
|
||||||
Keine freien Zeitslots für {appointmentDate} verfügbar.
|
Lade verfügbare Zeiten...
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{appointmentDate && selectedTreatments.length > 0 && error && (
|
||||||
|
<p className="mt-2 text-sm text-red-500">
|
||||||
|
Fehler beim Laden der verfügbaren Zeiten. Bitte versuche es erneut.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{appointmentDate && selectedTreatments.length > 0 && !isLoading && !isFetching && !error && (!availableTimes || availableTimes.length === 0) && (
|
||||||
|
<p className="mt-2 text-sm text-gray-500">
|
||||||
|
Keine verfügbaren Zeiten für dieses Datum. Bitte wähle ein anderes Datum.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{selectedTreatments.length > 0 && (
|
||||||
|
<p className="mt-1 text-xs text-gray-500">Gesamtdauer: {totalDuration} Minuten</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -342,7 +490,7 @@ export function BookingForm() {
|
|||||||
onChange={(e) => setNotes(e.target.value)}
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
rows={3}
|
rows={3}
|
||||||
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
|
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
|
||||||
placeholder="Besondere Wünsche oder Informationen..."
|
placeholder="Besondere Wünsche oder Informationen, Allergien, etc..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -382,7 +530,7 @@ export function BookingForm() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AGB Acceptance */}
|
{/* AGB Acceptance */}
|
||||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 space-y-4">
|
||||||
<div className="flex items-start space-x-3">
|
<div className="flex items-start space-x-3">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -408,6 +556,22 @@ export function BookingForm() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="age-confirmation"
|
||||||
|
checked={ageConfirmed}
|
||||||
|
onChange={(e) => setAgeConfirmed(e.target.checked)}
|
||||||
|
className="mt-1 h-4 w-4 text-pink-600 focus:ring-pink-500 border-gray-300 rounded"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<label htmlFor="age-confirmation" className="text-sm font-medium text-gray-700 cursor-pointer">
|
||||||
|
Ich bestätige, dass ich mindestens 16 Jahre alt bin *
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error Message */}
|
{/* Error Message */}
|
||||||
|
|||||||
733
src/client/components/booking-status-page.tsx
Normal file
733
src/client/components/booking-status-page.tsx
Normal file
@@ -0,0 +1,733 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
|
import { queryClient } from "@/client/rpc-client";
|
||||||
|
|
||||||
|
interface BookingStatusPageProps {
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type BookingStatus = "pending" | "confirmed" | "cancelled" | "completed";
|
||||||
|
|
||||||
|
interface Treatment {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
duration: number;
|
||||||
|
price: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BookingDetails {
|
||||||
|
id: string;
|
||||||
|
customerName: string;
|
||||||
|
customerEmail?: string;
|
||||||
|
customerPhone?: string;
|
||||||
|
appointmentDate: string;
|
||||||
|
appointmentTime: string;
|
||||||
|
treatments: Treatment[];
|
||||||
|
totalDuration: number;
|
||||||
|
totalPrice: number;
|
||||||
|
status: BookingStatus;
|
||||||
|
notes?: string;
|
||||||
|
formattedDate: string;
|
||||||
|
createdAt: string;
|
||||||
|
canCancel: boolean;
|
||||||
|
hoursUntilAppointment: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RescheduleProposalDetails {
|
||||||
|
booking: {
|
||||||
|
id: string;
|
||||||
|
customerName: string;
|
||||||
|
customerEmail?: string;
|
||||||
|
customerPhone?: string;
|
||||||
|
status: BookingStatus;
|
||||||
|
treatments: Treatment[];
|
||||||
|
totalDuration: number;
|
||||||
|
totalPrice: number;
|
||||||
|
};
|
||||||
|
original: { date: string; time: string };
|
||||||
|
proposed: { date?: string; time?: string };
|
||||||
|
expiresAt: string;
|
||||||
|
hoursUntilExpiry: number;
|
||||||
|
isExpired: boolean;
|
||||||
|
canRespond: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusInfo(status: BookingStatus) {
|
||||||
|
switch (status) {
|
||||||
|
case "pending":
|
||||||
|
return {
|
||||||
|
label: "Wartet auf Bestätigung",
|
||||||
|
color: "yellow",
|
||||||
|
icon: "⏳",
|
||||||
|
bgColor: "bg-yellow-50",
|
||||||
|
borderColor: "border-yellow-200",
|
||||||
|
textColor: "text-yellow-800",
|
||||||
|
badgeColor: "bg-yellow-100 text-yellow-800",
|
||||||
|
};
|
||||||
|
case "confirmed":
|
||||||
|
return {
|
||||||
|
label: "Bestätigt",
|
||||||
|
color: "green",
|
||||||
|
icon: "✓",
|
||||||
|
bgColor: "bg-green-50",
|
||||||
|
borderColor: "border-green-200",
|
||||||
|
textColor: "text-green-800",
|
||||||
|
badgeColor: "bg-green-100 text-green-800",
|
||||||
|
};
|
||||||
|
case "cancelled":
|
||||||
|
return {
|
||||||
|
label: "Storniert",
|
||||||
|
color: "red",
|
||||||
|
icon: "✕",
|
||||||
|
bgColor: "bg-red-50",
|
||||||
|
borderColor: "border-red-200",
|
||||||
|
textColor: "text-red-800",
|
||||||
|
badgeColor: "bg-red-100 text-red-800",
|
||||||
|
};
|
||||||
|
case "completed":
|
||||||
|
return {
|
||||||
|
label: "Abgeschlossen",
|
||||||
|
color: "gray",
|
||||||
|
icon: "✓",
|
||||||
|
bgColor: "bg-gray-50",
|
||||||
|
borderColor: "border-gray-200",
|
||||||
|
textColor: "text-gray-800",
|
||||||
|
badgeColor: "bg-gray-100 text-gray-800",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BookingStatusPage({ token }: BookingStatusPageProps) {
|
||||||
|
const [showCancelConfirm, setShowCancelConfirm] = useState(false);
|
||||||
|
const [isCancelling, setIsCancelling] = useState(false);
|
||||||
|
const [cancellationResult, setCancellationResult] = useState<{ success: boolean; message: string; formattedDate?: string } | null>(null);
|
||||||
|
const [rescheduleProposal, setRescheduleProposal] = useState<RescheduleProposalDetails | null>(null);
|
||||||
|
const [rescheduleResult, setRescheduleResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||||
|
const [isAccepting, setIsAccepting] = useState(false);
|
||||||
|
const [isDeclining, setIsDeclining] = useState(false);
|
||||||
|
const [showDeclineConfirm, setShowDeclineConfirm] = useState(false);
|
||||||
|
const [oneClickAction, setOneClickAction] = useState<string | null>(null);
|
||||||
|
const [oneClickLoading, setOneClickLoading] = useState(false);
|
||||||
|
|
||||||
|
// Fetch booking details
|
||||||
|
const { data: booking, isLoading, error, refetch, error: bookingError } = useQuery(
|
||||||
|
queryClient.cancellation.getBookingByToken.queryOptions({ input: { token } })
|
||||||
|
);
|
||||||
|
|
||||||
|
// Try fetching reschedule proposal if booking not found or error
|
||||||
|
const rescheduleQuery = useQuery<RescheduleProposalDetails>({
|
||||||
|
...queryClient.cancellation.getRescheduleProposal.queryOptions({ input: { token } }),
|
||||||
|
enabled: !!token && (!!bookingError || !booking),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle reschedule proposal data
|
||||||
|
useEffect(() => {
|
||||||
|
if (rescheduleQuery.data) {
|
||||||
|
setRescheduleProposal(rescheduleQuery.data);
|
||||||
|
} else if (rescheduleQuery.error) {
|
||||||
|
setRescheduleProposal(null);
|
||||||
|
}
|
||||||
|
}, [rescheduleQuery.data, rescheduleQuery.error]);
|
||||||
|
|
||||||
|
// Cancellation mutation
|
||||||
|
const cancelMutation = useMutation({
|
||||||
|
...queryClient.cancellation.cancelByToken.mutationOptions(),
|
||||||
|
onSuccess: (result: any) => {
|
||||||
|
setCancellationResult({
|
||||||
|
success: true,
|
||||||
|
message: result.message,
|
||||||
|
formattedDate: result.formattedDate,
|
||||||
|
});
|
||||||
|
setIsCancelling(false);
|
||||||
|
setShowCancelConfirm(false);
|
||||||
|
refetch(); // Refresh booking data
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
setCancellationResult({
|
||||||
|
success: false,
|
||||||
|
message: error?.message || "Ein Fehler ist aufgetreten.",
|
||||||
|
});
|
||||||
|
setIsCancelling(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const acceptMutation = useMutation({
|
||||||
|
...queryClient.bookings.acceptReschedule.mutationOptions(),
|
||||||
|
onSuccess: (result: any) => {
|
||||||
|
setRescheduleResult({ success: true, message: result.message });
|
||||||
|
setIsAccepting(false);
|
||||||
|
setShowDeclineConfirm(false);
|
||||||
|
refetch();
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
setRescheduleResult({ success: false, message: error?.message || 'Ein Fehler ist aufgetreten.' });
|
||||||
|
setIsAccepting(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const declineMutation = useMutation({
|
||||||
|
...queryClient.bookings.declineReschedule.mutationOptions(),
|
||||||
|
onSuccess: (result: any) => {
|
||||||
|
setRescheduleResult({ success: true, message: result.message });
|
||||||
|
setIsDeclining(false);
|
||||||
|
setShowDeclineConfirm(false);
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
setRescheduleResult({ success: false, message: error?.message || 'Ein Fehler ist aufgetreten.' });
|
||||||
|
setIsDeclining(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setIsCancelling(true);
|
||||||
|
setCancellationResult(null);
|
||||||
|
cancelMutation.mutate({ token });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle one-click actions from URL parameters
|
||||||
|
useEffect(() => {
|
||||||
|
if (rescheduleProposal && !oneClickAction) {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const action = urlParams.get('action');
|
||||||
|
|
||||||
|
if (action === 'accept' || action === 'decline') {
|
||||||
|
setOneClickAction(action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [rescheduleProposal, oneClickAction]);
|
||||||
|
|
||||||
|
// Auto-execute one-click action
|
||||||
|
useEffect(() => {
|
||||||
|
if (oneClickAction && rescheduleProposal && !oneClickLoading && !rescheduleResult) {
|
||||||
|
setOneClickLoading(true);
|
||||||
|
|
||||||
|
if (oneClickAction === 'accept') {
|
||||||
|
const confirmAccept = window.confirm(
|
||||||
|
`Möchtest du den neuen Termin am ${rescheduleProposal.proposed.date || 'TBD'} um ${rescheduleProposal.proposed.time || 'TBD'} Uhr akzeptieren?`
|
||||||
|
);
|
||||||
|
if (confirmAccept) {
|
||||||
|
acceptMutation.mutate({ token });
|
||||||
|
} else {
|
||||||
|
setOneClickLoading(false);
|
||||||
|
setOneClickAction(null);
|
||||||
|
}
|
||||||
|
} else if (oneClickAction === 'decline') {
|
||||||
|
const confirmDecline = window.confirm(
|
||||||
|
`Möchtest du den Vorschlag ablehnen? Dein ursprünglicher Termin am ${rescheduleProposal.original.date} um ${rescheduleProposal.original.time} Uhr bleibt dann bestehen.`
|
||||||
|
);
|
||||||
|
if (confirmDecline) {
|
||||||
|
declineMutation.mutate({ token });
|
||||||
|
} else {
|
||||||
|
setOneClickLoading(false);
|
||||||
|
setOneClickAction(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [oneClickAction, rescheduleProposal, oneClickLoading, rescheduleResult, acceptMutation, declineMutation, token]);
|
||||||
|
|
||||||
|
// Reset one-click loading when mutations complete
|
||||||
|
useEffect(() => {
|
||||||
|
if (rescheduleResult) {
|
||||||
|
setOneClickLoading(false);
|
||||||
|
setOneClickAction(null);
|
||||||
|
}
|
||||||
|
}, [rescheduleResult]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-white p-8 rounded-lg shadow-lg max-w-2xl w-full">
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-pink-500"></div>
|
||||||
|
<span className="ml-3 text-gray-600">Buchung wird geladen...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !rescheduleProposal) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-16 h-16 mx-auto mb-4 bg-red-100 rounded-full flex items-center justify-center">
|
||||||
|
<svg className="w-8 h-8 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 mb-2">Link nicht verfügbar</h2>
|
||||||
|
<p className="text-gray-600 mb-4">
|
||||||
|
Dieser Buchungslink ist nicht mehr verfügbar. Mögliche Gründe:
|
||||||
|
</p>
|
||||||
|
<ul className="text-sm text-gray-600 text-left mb-6 space-y-2">
|
||||||
|
<li>• Der Link ist abgelaufen</li>
|
||||||
|
<li>• Die Buchung wurde bereits storniert</li>
|
||||||
|
<li>• Der Link wurde bereits verwendet</li>
|
||||||
|
</ul>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
className="inline-flex items-center px-4 py-2 bg-pink-600 text-white rounded-lg hover:bg-pink-700 transition-colors"
|
||||||
|
>
|
||||||
|
Neue Buchung erstellen
|
||||||
|
</a>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Bei Fragen wende dich direkt an uns.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!booking && !rescheduleProposal) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full">
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 mb-2">Buchung nicht gefunden</h2>
|
||||||
|
<p className="text-gray-600 mb-4">
|
||||||
|
Die angeforderte Buchung konnte nicht gefunden werden.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
className="inline-flex items-center px-4 py-2 bg-pink-600 text-white rounded-lg hover:bg-pink-700 transition-colors"
|
||||||
|
>
|
||||||
|
Zur Startseite
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rescheduleProposal) {
|
||||||
|
const isExpired = rescheduleProposal.isExpired;
|
||||||
|
const handleAccept = () => {
|
||||||
|
setIsAccepting(true);
|
||||||
|
setRescheduleResult(null);
|
||||||
|
acceptMutation.mutate({ token });
|
||||||
|
};
|
||||||
|
const handleDecline = () => {
|
||||||
|
setIsDeclining(true);
|
||||||
|
setRescheduleResult(null);
|
||||||
|
declineMutation.mutate({ token });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 py-8 px-4">
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<img src="/assets/stargilnails_logo_transparent_112.png" alt="Stargil Nails Logo" className="w-16 h-16 object-contain" />
|
||||||
|
<span className={`px-4 py-2 rounded-full text-sm font-semibold bg-orange-100 text-orange-800`}>
|
||||||
|
⚠️ Terminänderung vorgeschlagen
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Vorschlag zur Terminänderung</h1>
|
||||||
|
<p className="text-gray-600 mt-1">Bitte bestätige, ob der neue Termin für dich passt.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{oneClickLoading && (
|
||||||
|
<div className="mb-6 p-4 rounded-lg bg-blue-50 border border-blue-200">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-500 mr-3"></div>
|
||||||
|
<p className="text-blue-800">
|
||||||
|
{oneClickAction === 'accept' ? 'Akzeptiere Termin...' : 'Lehne Termin ab...'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rescheduleResult && (
|
||||||
|
<div className={`mb-6 p-4 rounded-lg ${rescheduleResult.success ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'}`}>
|
||||||
|
<p className={rescheduleResult.success ? 'text-green-800' : 'text-red-800'}>
|
||||||
|
{rescheduleResult.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">Vergleich</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="border rounded-lg p-4 bg-gray-50">
|
||||||
|
<div className="text-sm text-gray-500 font-semibold mb-1">Aktueller Termin</div>
|
||||||
|
<div className="text-gray-900 font-medium">{rescheduleProposal.original.date} um {rescheduleProposal.original.time} Uhr</div>
|
||||||
|
<div className="text-gray-700 text-sm mt-2">
|
||||||
|
{rescheduleProposal.booking.treatments && rescheduleProposal.booking.treatments.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{rescheduleProposal.booking.treatments.length <= 2 ? (
|
||||||
|
rescheduleProposal.booking.treatments.map((t, i) => (
|
||||||
|
<div key={i}>{t.name}</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{rescheduleProposal.booking.treatments.slice(0, 2).map((t, i) => (
|
||||||
|
<div key={i}>{t.name}</div>
|
||||||
|
))}
|
||||||
|
<div className="text-gray-500 italic">+{rescheduleProposal.booking.treatments.length - 2} weitere</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="text-gray-600 mt-1 text-xs">
|
||||||
|
{rescheduleProposal.booking.totalDuration} Min
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 italic">Keine Behandlungen</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border rounded-lg p-4 bg-orange-50">
|
||||||
|
<div className="text-sm text-orange-700 font-semibold mb-1">Neuer Vorschlag</div>
|
||||||
|
<div className="text-gray-900 font-medium">{rescheduleProposal.proposed.date || 'TBD'} um {rescheduleProposal.proposed.time || 'TBD'} Uhr</div>
|
||||||
|
<div className="text-gray-700 text-sm mt-2">
|
||||||
|
{rescheduleProposal.booking.treatments && rescheduleProposal.booking.treatments.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{rescheduleProposal.booking.treatments.length <= 2 ? (
|
||||||
|
rescheduleProposal.booking.treatments.map((t, i) => (
|
||||||
|
<div key={i}>{t.name}</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{rescheduleProposal.booking.treatments.slice(0, 2).map((t, i) => (
|
||||||
|
<div key={i}>{t.name}</div>
|
||||||
|
))}
|
||||||
|
<div className="text-gray-500 italic">+{rescheduleProposal.booking.treatments.length - 2} weitere</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="text-gray-600 mt-1 text-xs">
|
||||||
|
{rescheduleProposal.booking.totalDuration} Min
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 italic">Keine Behandlungen</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 bg-yellow-50 border border-yellow-200 rounded-lg p-3 text-sm text-yellow-800">
|
||||||
|
Bitte antworte bis {new Date(rescheduleProposal.expiresAt).toLocaleDateString('de-DE')} {new Date(rescheduleProposal.expiresAt).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} ({rescheduleProposal.hoursUntilExpiry} Stunden).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isExpired && !rescheduleResult && !oneClickLoading && (
|
||||||
|
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleAccept}
|
||||||
|
disabled={isAccepting}
|
||||||
|
className="flex-1 bg-green-600 text-white py-3 px-4 rounded-lg font-medium hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{isAccepting ? 'Akzeptiere...' : 'Neuen Termin akzeptieren'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeclineConfirm(true)}
|
||||||
|
disabled={isDeclining}
|
||||||
|
className="flex-1 bg-red-600 text-white py-3 px-4 rounded-lg font-medium hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
Vorschlag ablehnen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-sm text-gray-600">Wenn du ablehnst, bleibt dein ursprünglicher Termin bestehen.</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isExpired && (
|
||||||
|
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
|
||||||
|
<p className="text-yellow-800 text-sm">
|
||||||
|
Diese Terminänderung ist abgelaufen. Dein ursprünglicher Termin bleibt bestehen. Bei Fragen kontaktiere uns bitte.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showDeclineConfirm && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl p-6 w-full max-w-md">
|
||||||
|
<h4 className="text-lg font-semibold text-gray-900 mb-2">Vorschlag ablehnen?</h4>
|
||||||
|
<p className="text-sm text-gray-700 mb-4">
|
||||||
|
Bist du sicher, dass du den neuen Terminvorschlag ablehnen möchtest?<br />
|
||||||
|
Dein ursprünglicher Termin am {rescheduleProposal.original.date} um {rescheduleProposal.original.time} bleibt dann bestehen.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeclineConfirm(false)}
|
||||||
|
className="flex-1 bg-gray-100 text-gray-700 py-2 rounded-lg hover:bg-gray-200"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDecline}
|
||||||
|
disabled={isDeclining}
|
||||||
|
className="flex-1 bg-red-600 text-white py-2 rounded-lg hover:bg-red-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isDeclining ? 'Lehne ab...' : 'Ja, ablehnen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<a href="/" className="inline-flex items-center text-pink-600 hover:text-pink-700 font-medium">
|
||||||
|
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
|
</svg>
|
||||||
|
Zurück zur Startseite
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusInfo = getStatusInfo(booking?.status || "pending");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Treatments List */}
|
||||||
|
<div className="py-2 border-b border-gray-100">
|
||||||
|
<div className="text-gray-600 mb-2">Behandlungen:</div>
|
||||||
|
{booking?.treatments && booking.treatments.length > 0 ? (
|
||||||
|
<div className="bg-gray-50 rounded-lg p-3 space-y-2">
|
||||||
|
{booking.treatments.map((treatment, index) => (
|
||||||
|
<div key={index} className="flex justify-between items-center text-sm">
|
||||||
|
<span className="font-medium text-gray-900">• {treatment.name}</span>
|
||||||
|
<span className="text-gray-600">
|
||||||
|
{treatment.duration} Min - {treatment.price.toFixed(2)} €
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex justify-between items-center pt-2 mt-2 border-t border-gray-200 font-semibold">
|
||||||
|
<span className="text-gray-900">Gesamt:</span>
|
||||||
|
<span className="text-gray-900">
|
||||||
|
{booking.totalDuration} Min - {booking.totalPrice.toFixed(2)} €
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<span className="text-gray-400 text-sm italic">Keine Behandlungen angegeben</span>
|
||||||
|
{((booking?.totalDuration ?? 0) > 0 || (booking?.totalPrice ?? 0) > 0) && (
|
||||||
|
<div className="bg-gray-50 rounded-lg p-3">
|
||||||
|
<div className="flex justify-between items-center font-semibold text-sm">
|
||||||
|
<span className="text-gray-900">Gesamt:</span>
|
||||||
|
<span className="text-gray-900">
|
||||||
|
{booking?.totalDuration ?? 0} Min - {(booking?.totalPrice ?? 0).toFixed(2)} €
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{booking?.hoursUntilAppointment && booking.hoursUntilAppointment > 0 && booking.status !== "cancelled" && booking.status !== "completed" && (
|
||||||
|
<div className="flex justify-between py-2">
|
||||||
|
<span className="text-gray-600">Verbleibende Zeit:</span>
|
||||||
|
<span className="font-medium text-pink-600">
|
||||||
|
{booking.hoursUntilAppointment} Stunde{booking.hoursUntilAppointment !== 1 ? 'n' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Customer Details */}
|
||||||
|
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
|
||||||
|
<svg className="w-5 h-5 mr-2 text-pink-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||||
|
</svg>
|
||||||
|
Deine Daten
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-between py-2 border-b border-gray-100">
|
||||||
|
<span className="text-gray-600">Name:</span>
|
||||||
|
<span className="font-medium text-gray-900">{booking?.customerName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between py-2 border-b border-gray-100">
|
||||||
|
<span className="text-gray-600">E-Mail:</span>
|
||||||
|
<span className="font-medium text-gray-900">{booking?.customerEmail || '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between py-2">
|
||||||
|
<span className="text-gray-600">Telefon:</span>
|
||||||
|
<span className="font-medium text-gray-900">{booking?.customerPhone || '—'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{booking?.notes && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 mb-2">Notizen:</h3>
|
||||||
|
<p className="text-gray-600 text-sm">{booking.notes}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cancellation Section */}
|
||||||
|
{booking?.canCancel && !cancellationResult?.success && (
|
||||||
|
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
|
||||||
|
<svg className="w-5 h-5 mr-2 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
Termin stornieren
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{!showCancelConfirm ? (
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-600 mb-4">
|
||||||
|
Du kannst diesen Termin noch bis {parseInt(process.env.MIN_STORNO_TIMESPAN || "24")} Stunden vor dem Termin kostenlos stornieren.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCancelConfirm(true)}
|
||||||
|
className="w-full bg-red-600 text-white py-3 px-4 rounded-lg font-medium hover:bg-red-700 transition-colors flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
Termin stornieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
|
||||||
|
<p className="text-red-800 font-semibold mb-2">Bist du sicher?</p>
|
||||||
|
<p className="text-red-700 text-sm">
|
||||||
|
Diese Aktion kann nicht rückgängig gemacht werden. Der Termin wird storniert und der Slot wird wieder für andere Kunden verfügbar.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={isCancelling}
|
||||||
|
className="flex-1 bg-red-600 text-white py-3 px-4 rounded-lg font-medium hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center"
|
||||||
|
>
|
||||||
|
{isCancelling ? (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||||
|
Storniere...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>Ja, stornieren</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCancelConfirm(false)}
|
||||||
|
disabled={isCancelling}
|
||||||
|
className="flex-1 bg-gray-200 text-gray-800 py-3 px-4 rounded-lg font-medium hover:bg-gray-300 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!booking?.canCancel && booking?.status !== "cancelled" && booking?.status !== "completed" && (
|
||||||
|
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
|
||||||
|
<p className="text-yellow-800 text-sm">
|
||||||
|
<strong>ℹ️ Stornierungsfrist abgelaufen:</strong> Dieser Termin liegt weniger als {parseInt(process.env.MIN_STORNO_TIMESPAN || "24")} Stunden in der Zukunft und kann nicht mehr online storniert werden. Bitte kontaktiere uns direkt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="text-center">
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
className="inline-flex items-center text-pink-600 hover:text-pink-700 font-medium"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
|
</svg>
|
||||||
|
Zurück zur Startseite
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,21 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
// Helper function to generate map coordinates based on address
|
||||||
|
function generateMapUrl(address: any) {
|
||||||
|
// Use coordinates from config or default to Kiel coordinates
|
||||||
|
const lat = address.latitude || 54.3233;
|
||||||
|
const lon = address.longitude || 10.1228;
|
||||||
|
|
||||||
|
// Generate bounding box around the coordinates (0.05 degrees ≈ 5km radius)
|
||||||
|
const bbox = `${lon - 0.05},${lat - 0.05},${lon + 0.05},${lat + 0.05}`;
|
||||||
|
|
||||||
|
const embedUrl = `https://www.openstreetmap.org/export/embed.html?bbox=${bbox}&layer=mapnik&marker=${lat},${lon}`;
|
||||||
|
const fullUrl = `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}#map=16/${lat}/${lon}`;
|
||||||
|
|
||||||
|
return { embedUrl, fullUrl };
|
||||||
|
}
|
||||||
|
|
||||||
export default function LegalPage() {
|
export default function LegalPage() {
|
||||||
const [activeSection, setActiveSection] = useState<"impressum" | "datenschutz">("impressum");
|
const [activeSection, setActiveSection] = useState<"impressum" | "datenschutz">("impressum");
|
||||||
|
|
||||||
@@ -137,6 +152,37 @@ export default function LegalPage() {
|
|||||||
{legalConfig.address.postalCode} {legalConfig.address.city}<br />
|
{legalConfig.address.postalCode} {legalConfig.address.city}<br />
|
||||||
{legalConfig.address.country}
|
{legalConfig.address.country}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* Map */}
|
||||||
|
<div className="mt-4">
|
||||||
|
{(() => {
|
||||||
|
const { embedUrl, fullUrl } = generateMapUrl(legalConfig.address);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<iframe
|
||||||
|
src={embedUrl}
|
||||||
|
width="100%"
|
||||||
|
height="300"
|
||||||
|
style={{ border: 0 }}
|
||||||
|
allowFullScreen
|
||||||
|
loading="lazy"
|
||||||
|
referrerPolicy="no-referrer-when-downgrade"
|
||||||
|
title={`Karte der Umgebung von ${legalConfig.companyName}`}
|
||||||
|
></iframe>
|
||||||
|
<div className="mt-2">
|
||||||
|
<a
|
||||||
|
href={fullUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm text-blue-600 hover:text-blue-800 underline"
|
||||||
|
>
|
||||||
|
Größere Karte anzeigen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useAuth } from "@/client/components/auth-provider";
|
import { useAuth } from "@/client/components/auth-provider";
|
||||||
|
|
||||||
export function LoginForm() {
|
export function LoginForm() {
|
||||||
@@ -6,9 +6,27 @@ export function LoginForm() {
|
|||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
|
|
||||||
const { login } = useAuth();
|
const { login } = useAuth();
|
||||||
|
|
||||||
|
// Load saved username from localStorage on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const savedUsername = localStorage.getItem("loginForm_username");
|
||||||
|
if (savedUsername) setUsername(savedUsername);
|
||||||
|
setIsInitialized(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Save username to localStorage when it changes (after initial load)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isInitialized) return;
|
||||||
|
if (username) {
|
||||||
|
localStorage.setItem("loginForm_username", username);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem("loginForm_username");
|
||||||
|
}
|
||||||
|
}, [username, isInitialized]);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError("");
|
setError("");
|
||||||
@@ -74,6 +92,19 @@ export function LoginForm() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{/* Back to Home Link */}
|
||||||
|
<div className="text-center mt-6">
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
className="inline-flex items-center text-pink-600 hover:text-pink-700 text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
|
</svg>
|
||||||
|
Zurück zur Startseite
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
262
src/client/components/profile-landing.tsx
Normal file
262
src/client/components/profile-landing.tsx
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { queryClient } from "@/client/rpc-client";
|
||||||
|
|
||||||
|
interface ProfileLandingProps {
|
||||||
|
onNavigateToBooking: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StarRating({ rating }: { rating: number }) {
|
||||||
|
return (
|
||||||
|
<div className="flex space-x-1">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<span
|
||||||
|
key={star}
|
||||||
|
className={star <= rating ? "text-yellow-400" : "text-gray-300"}
|
||||||
|
>
|
||||||
|
★
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDayName(dayOfWeek: number): string {
|
||||||
|
const days = [
|
||||||
|
"Sonntag",
|
||||||
|
"Montag",
|
||||||
|
"Dienstag",
|
||||||
|
"Mittwoch",
|
||||||
|
"Donnerstag",
|
||||||
|
"Freitag",
|
||||||
|
"Samstag",
|
||||||
|
];
|
||||||
|
return days[dayOfWeek];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date: Date): string {
|
||||||
|
return date.toLocaleDateString("de-DE", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfileLanding({ onNavigateToBooking }: ProfileLandingProps) {
|
||||||
|
// Data fetching with live queries
|
||||||
|
const { data: galleryPhotos = [] } = useQuery(
|
||||||
|
queryClient.gallery.live.listPhotos.experimental_liveOptions()
|
||||||
|
);
|
||||||
|
const sortedPhotos = ([...galleryPhotos] as any[]).sort((a, b) => (a.order || 0) - (b.order || 0));
|
||||||
|
const featuredPhoto = sortedPhotos[0];
|
||||||
|
|
||||||
|
const { data: reviews = [] } = useQuery(
|
||||||
|
queryClient.reviews.live.listPublishedReviews.experimental_liveOptions()
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: recurringRules = [] } = useQuery(
|
||||||
|
queryClient.recurringAvailability.live.listRules.experimental_liveOptions()
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: socialMedia } = useQuery(
|
||||||
|
queryClient.social.getSocialMedia.queryOptions()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate next 7 days for opening hours
|
||||||
|
const getNext7Days = () => {
|
||||||
|
const days: Date[] = [];
|
||||||
|
const today = new Date();
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
const date = new Date(today);
|
||||||
|
date.setDate(today.getDate() + i);
|
||||||
|
days.push(date);
|
||||||
|
}
|
||||||
|
return days;
|
||||||
|
};
|
||||||
|
|
||||||
|
const next7Days = getNext7Days();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl mx-auto space-y-12 py-8">
|
||||||
|
{/* Hero Section */}
|
||||||
|
<div className="bg-gradient-to-r from-pink-500 to-purple-600 rounded-lg shadow-lg p-8 text-white">
|
||||||
|
<h1 className="text-4xl md:text-5xl font-bold mb-4">
|
||||||
|
Stargirlnails Kiel
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl mb-2">Professionelles Nageldesign und -Pflege in Kiel</p>
|
||||||
|
<p className="text-lg mb-8 opacity-90">
|
||||||
|
Lass dich von mir verwöhnen und genieße hochwertige Nail Art und Pflegebehandlungen.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={onNavigateToBooking}
|
||||||
|
className="bg-[#790dc6] text-white py-4 px-8 rounded-lg hover:bg-[#6609ad] text-lg font-semibold shadow-lg transition-colors w-full md:w-auto"
|
||||||
|
>
|
||||||
|
Termin buchen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Social Media Badges */}
|
||||||
|
{((socialMedia as any)?.tiktokProfile || (socialMedia as any)?.instagramProfile) && (
|
||||||
|
<div className="flex justify-center items-center gap-4">
|
||||||
|
{(socialMedia as any)?.instagramProfile && (
|
||||||
|
<a
|
||||||
|
href={(socialMedia as any).instagramProfile}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-2 bg-gradient-to-r from-purple-500 via-pink-500 to-orange-500 text-white px-6 py-3 rounded-full hover:shadow-lg transition-all hover:scale-105 font-semibold"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/>
|
||||||
|
</svg>
|
||||||
|
Instagram
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{(socialMedia as any)?.tiktokProfile && (
|
||||||
|
<a
|
||||||
|
href={(socialMedia as any).tiktokProfile}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-2 bg-black text-white px-6 py-3 rounded-full hover:shadow-lg transition-all hover:scale-105 font-semibold"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1-.1z"/>
|
||||||
|
</svg>
|
||||||
|
TikTok
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Featured Section: Erstes Foto (Reihenfolge 0) */}
|
||||||
|
{featuredPhoto && (
|
||||||
|
<div className="bg-white rounded-lg shadow-lg p-0 overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={(featuredPhoto as any).base64Data}
|
||||||
|
alt={(featuredPhoto as any).title || "Featured"}
|
||||||
|
className="w-full h-auto object-contain"
|
||||||
|
loading="eager"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
{(featuredPhoto as any).title && (
|
||||||
|
<div className="p-4 border-t">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900">{(featuredPhoto as any).title}</h2>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Photo Gallery Section */}
|
||||||
|
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-6">Unsere Arbeiten</h2>
|
||||||
|
{galleryPhotos.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
{(sortedPhotos as typeof galleryPhotos)
|
||||||
|
.filter((p) => (featuredPhoto ? (p as any).id !== (featuredPhoto as any).id : true))
|
||||||
|
.slice(0, 9)
|
||||||
|
.map((photo, index) => (
|
||||||
|
<img
|
||||||
|
key={photo.id || index}
|
||||||
|
src={photo.base64Data}
|
||||||
|
alt={photo.title || "Gallery"}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
className="w-full h-48 object-cover rounded-lg shadow-md"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-600 text-center py-8">
|
||||||
|
Galerie wird bald aktualisiert
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Opening Hours Section */}
|
||||||
|
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-6">
|
||||||
|
Öffnungszeiten (Nächste 7 Tage)
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{next7Days.map((date, index) => {
|
||||||
|
const dayOfWeek = date.getDay();
|
||||||
|
const dayRules = recurringRules.filter(
|
||||||
|
(rule) => rule.isActive && rule.dayOfWeek === dayOfWeek
|
||||||
|
);
|
||||||
|
const sorted = [...dayRules].sort((a, b) =>
|
||||||
|
a.startTime.localeCompare(b.startTime)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`p-4 rounded-lg ${
|
||||||
|
index % 2 === 0 ? "bg-gray-50" : "bg-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="font-semibold text-gray-900">
|
||||||
|
{getDayName(dayOfWeek)}, {formatDate(date)}
|
||||||
|
</span>
|
||||||
|
<div className="text-gray-700">
|
||||||
|
{dayRules.length > 0 ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{sorted.map((rule) => (
|
||||||
|
<div
|
||||||
|
key={`${rule.dayOfWeek}-${rule.startTime}-${rule.endTime}`}
|
||||||
|
>
|
||||||
|
{rule.startTime} - {rule.endTime} Uhr
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-500">Geschlossen</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Customer Reviews Section */}
|
||||||
|
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-6">
|
||||||
|
Kundenbewertungen
|
||||||
|
</h2>
|
||||||
|
{reviews.length > 0 ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{reviews.slice(0, 10).map((review) => (
|
||||||
|
<div
|
||||||
|
key={
|
||||||
|
(review as any).id ||
|
||||||
|
(review as any).bookingId ||
|
||||||
|
`${(review as any).createdAt}-${(review as any).customerName}`
|
||||||
|
}
|
||||||
|
className="bg-gray-50 p-4 rounded-lg shadow-md"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900">
|
||||||
|
{review.customerName}
|
||||||
|
</h3>
|
||||||
|
<StarRating rating={review.rating} />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
{new Date(review.createdAt).toLocaleDateString("de-DE")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{review.comment && (
|
||||||
|
<p className="text-gray-700 mt-2">{review.comment}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-600 text-center py-8">
|
||||||
|
Noch keine Bewertungen vorhanden
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
187
src/client/components/pwa-install-prompt.tsx
Normal file
187
src/client/components/pwa-install-prompt.tsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
interface BeforeInstallPromptEvent extends Event {
|
||||||
|
prompt: () => Promise<void>;
|
||||||
|
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Navigator { standalone?: boolean }
|
||||||
|
interface WindowEventMap {
|
||||||
|
beforeinstallprompt: BeforeInstallPromptEvent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const LAST_SHOWN_KEY = 'pwaInstallPrompt_lastShown';
|
||||||
|
const ANDROID_DISMISSED_KEY = 'pwaInstallPrompt_androidDismissed';
|
||||||
|
|
||||||
|
function isIOS(): boolean {
|
||||||
|
if (typeof navigator === 'undefined') return false;
|
||||||
|
const ua = navigator.userAgent || '';
|
||||||
|
const iOS = /iPhone|iPad|iPod/i.test(ua);
|
||||||
|
const iPadOS13Plus = /Macintosh/.test(ua) && 'ontouchend' in document;
|
||||||
|
return iOS || iPadOS13Plus;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSafari(): boolean {
|
||||||
|
const ua = navigator.userAgent || '';
|
||||||
|
const isSafari = /Safari/i.test(ua) && !/CriOS|FxiOS|EdgiOS/i.test(ua);
|
||||||
|
return isSafari;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStandalone(): boolean {
|
||||||
|
const navStandalone = (navigator as any)?.standalone === true;
|
||||||
|
const mm = typeof window !== 'undefined' && window.matchMedia
|
||||||
|
? window.matchMedia('(display-mode: standalone)').matches
|
||||||
|
: false;
|
||||||
|
return Boolean(navStandalone || mm);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PWAInstallPrompt({ hidden = false }: { hidden?: boolean }) {
|
||||||
|
if (hidden) return null;
|
||||||
|
const [show, setShow] = useState(false);
|
||||||
|
const [promptType, setPromptType] = useState<'ios' | 'android' | null>(null);
|
||||||
|
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
|
||||||
|
const [initialized, setInitialized] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Only run on client
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
// Check if already in standalone mode
|
||||||
|
if (isStandalone()) {
|
||||||
|
setInitialized(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle iOS
|
||||||
|
const isIOSDevice = isIOS() && isSafari();
|
||||||
|
if (isIOSDevice) {
|
||||||
|
let lastShown = 0;
|
||||||
|
try {
|
||||||
|
lastShown = Number(localStorage.getItem(LAST_SHOWN_KEY) || 0);
|
||||||
|
} catch {}
|
||||||
|
const oneWeek = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
const shouldShow = !lastShown || Date.now() - lastShown > oneWeek;
|
||||||
|
|
||||||
|
if (shouldShow) {
|
||||||
|
setPromptType('ios');
|
||||||
|
setShow(true);
|
||||||
|
}
|
||||||
|
setInitialized(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Android (beforeinstallprompt)
|
||||||
|
const handleBeforeInstall = (e: BeforeInstallPromptEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Check if user has dismissed Android prompt before
|
||||||
|
let dismissed = false;
|
||||||
|
try {
|
||||||
|
dismissed = localStorage.getItem(ANDROID_DISMISSED_KEY) === 'true';
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
if (!dismissed) {
|
||||||
|
setDeferredPrompt(e);
|
||||||
|
setPromptType('android');
|
||||||
|
setShow(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('beforeinstallprompt', handleBeforeInstall);
|
||||||
|
setInitialized(true);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('beforeinstallprompt', handleBeforeInstall);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dismiss = () => {
|
||||||
|
if (promptType === 'ios') {
|
||||||
|
try { localStorage.setItem(LAST_SHOWN_KEY, String(Date.now())); } catch {}
|
||||||
|
} else if (promptType === 'android') {
|
||||||
|
try { localStorage.setItem(ANDROID_DISMISSED_KEY, 'true'); } catch {}
|
||||||
|
}
|
||||||
|
setShow(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAndroidInstall = async () => {
|
||||||
|
if (!deferredPrompt) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deferredPrompt.prompt();
|
||||||
|
const choiceResult = await deferredPrompt.userChoice;
|
||||||
|
|
||||||
|
if (choiceResult.outcome === 'accepted') {
|
||||||
|
console.log('PWA installation accepted');
|
||||||
|
}
|
||||||
|
|
||||||
|
setDeferredPrompt(null);
|
||||||
|
setShow(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during PWA installation:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!initialized || !show || !promptType) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-label={promptType === 'android' ? 'PWA Installation' : 'PWA Installation Anleitung'}
|
||||||
|
className="fixed bottom-0 left-0 right-0 z-50 px-4 pb-4"
|
||||||
|
style={{ paddingBottom: `max(env(safe-area-inset-bottom), 1rem)` }}
|
||||||
|
>
|
||||||
|
<div className="relative max-w-3xl mx-auto rounded-xl shadow-lg bg-gradient-to-r from-pink-500 to-purple-600 text-white p-4 sm:p-6">
|
||||||
|
<button aria-label="Hinweis schließen" onClick={dismiss} className="absolute top-2 right-2 text-white/90 hover:text-white text-2xl leading-none">×</button>
|
||||||
|
|
||||||
|
{promptType === 'android' ? (
|
||||||
|
// Android: Direct install button
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="text-3xl">📱</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-lg sm:text-xl font-bold mb-2">App installieren</h3>
|
||||||
|
<p className="text-white/90 mb-3">
|
||||||
|
Installiere Stargirlnails als App für schnellen Zugriff direkt vom Startbildschirm!
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleAndroidInstall}
|
||||||
|
className="bg-white text-pink-600 px-4 py-2 rounded-lg font-semibold hover:bg-pink-50 transition-colors"
|
||||||
|
>
|
||||||
|
Jetzt installieren
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={dismiss}
|
||||||
|
className="bg-white/20 text-white px-4 py-2 rounded-lg font-semibold hover:bg-white/30 transition-colors"
|
||||||
|
>
|
||||||
|
Später
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// iOS: Manual instructions
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="text-2xl">📱</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-lg sm:text-xl font-bold mb-2">App installieren</h3>
|
||||||
|
<p className="text-white/90 mb-2">So installierst du Stargirlnails als App auf deinem iPhone/iPad:</p>
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-white/95">
|
||||||
|
<li>Öffne diese Seite in Safari (nicht Chrome oder andere Browser).</li>
|
||||||
|
<li>Tippe auf das Teilen-Symbol (□↑) unten in der Mitte.</li>
|
||||||
|
<li>Scrolle nach unten und wähle „Zum Home-Bildschirm".</li>
|
||||||
|
<li>Tippe auf „Hinzufügen".</li>
|
||||||
|
</ol>
|
||||||
|
<p className="mt-3 text-sm text-white/90">✨ Schneller Zugriff, keine App-Store-Installation nötig, automatische Updates.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PWAInstallPrompt;
|
||||||
|
|
||||||
226
src/client/components/review-submission-page.tsx
Normal file
226
src/client/components/review-submission-page.tsx
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import React, { useMemo, useState } from "react";
|
||||||
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
|
import { queryClient } from "@/client/rpc-client";
|
||||||
|
|
||||||
|
interface ReviewSubmissionPageProps {
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReviewSubmissionPage({ token }: ReviewSubmissionPageProps) {
|
||||||
|
const [rating, setRating] = useState<number | null>(null);
|
||||||
|
const [hoverRating, setHoverRating] = useState<number | null>(null);
|
||||||
|
const [comment, setComment] = useState("");
|
||||||
|
const [submitResult, setSubmitResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||||
|
|
||||||
|
// Fetch booking info by token
|
||||||
|
const bookingQuery = useQuery({
|
||||||
|
...queryClient.cancellation.getBookingByToken.queryOptions({ input: { token } }),
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isCompleted = bookingQuery.data?.status === "completed";
|
||||||
|
|
||||||
|
const submitMutation = useMutation({
|
||||||
|
...queryClient.reviews.submitReview.mutationOptions(),
|
||||||
|
onSuccess: () => {
|
||||||
|
setSubmitResult({ success: true, message: "Danke für deine Bewertung! Sie wird nach Prüfung veröffentlicht." });
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
setSubmitResult({ success: false, message: error?.message || "Ein Fehler ist aufgetreten." });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const canSubmit = useMemo(() => {
|
||||||
|
return !!rating && comment.trim().length >= 10 && isCompleted && !submitMutation.isPending;
|
||||||
|
}, [rating, comment, isCompleted, submitMutation.isPending]);
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
setSubmitResult(null);
|
||||||
|
const trimmedComment = comment.trim();
|
||||||
|
if (rating == null || rating < 1 || rating > 5) {
|
||||||
|
setSubmitResult({ success: false, message: "Bitte wähle eine Bewertung von 1 bis 5 Sternen." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (trimmedComment.length < 10) {
|
||||||
|
setSubmitResult({ success: false, message: "Der Kommentar muss mindestens 10 Zeichen enthalten." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isCompleted) {
|
||||||
|
setSubmitResult({ success: false, message: "Bewertungen sind nur für abgeschlossene Termine möglich." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitMutation.mutate({ bookingToken: token, rating, comment: trimmedComment });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (bookingQuery.isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full">
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-pink-500"></div>
|
||||||
|
<span className="ml-3 text-gray-600">Lade Buchung...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bookingQuery.error || !bookingQuery.data) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center">
|
||||||
|
<div className="w-16 h-16 mx-auto mb-4 bg-red-100 rounded-full flex items-center justify-center">
|
||||||
|
<svg className="w-8 h-8 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 mb-2">Link nicht verfügbar</h2>
|
||||||
|
<p className="text-gray-600 mb-4">Dieser Link ist ungültig oder abgelaufen.</p>
|
||||||
|
<a href="/" className="inline-flex items-center px-4 py-2 bg-pink-600 text-white rounded-lg hover:bg-pink-700 transition-colors">Zur Startseite</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard: Only allow reviews for completed bookings
|
||||||
|
if (!isCompleted) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-white p-8 rounded-lg shadow-lg max-w-lg w-full">
|
||||||
|
<div className="text-center">
|
||||||
|
<img src="/assets/stargilnails_logo_transparent_112.png" alt="Stargil Nails Logo" className="w-16 h-16 mx-auto mb-4 object-contain" />
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Bewertung abgeben</h1>
|
||||||
|
<p className="text-gray-600 mt-2">Bewertungen sind nur für abgeschlossene Termine möglich.</p>
|
||||||
|
<div className="mt-6">
|
||||||
|
<a href="/" className="inline-flex items-center text-pink-600 hover:text-pink-700 font-medium">
|
||||||
|
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
|
</svg>
|
||||||
|
Zurück zur Startseite
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const booking = bookingQuery.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 py-8 px-4">
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<img src="/assets/stargilnails_logo_transparent_112.png" alt="Stargil Nails Logo" className="w-16 h-16 object-contain" />
|
||||||
|
<span className="px-4 py-2 rounded-full text-sm font-semibold bg-pink-100 text-pink-800">⭐ Bewertung</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Bewertung abgeben</h1>
|
||||||
|
<p className="text-gray-600 mt-1">Teile deine Erfahrung mit uns – das hilft anderen Kundinnen!</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Booking Details */}
|
||||||
|
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
|
||||||
|
<svg className="w-5 h-5 mr-2 text-pink-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
Termin-Details
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<div className="flex justify-between py-2 border-b border-gray-100">
|
||||||
|
<span className="text-gray-600">Datum:</span>
|
||||||
|
<span className="font-medium text-gray-900">{booking.formattedDate}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between py-2 border-b border-gray-100">
|
||||||
|
<span className="text-gray-600">Uhrzeit:</span>
|
||||||
|
<span className="font-medium text-gray-900">{booking.appointmentTime} Uhr</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between py-2 border-b border-gray-100">
|
||||||
|
<span className="text-gray-600">Behandlung:</span>
|
||||||
|
<span className="font-medium text-gray-900">
|
||||||
|
{booking.treatments && booking.treatments.length > 0
|
||||||
|
? booking.treatments.map((t: any) => t.name).join(", ")
|
||||||
|
: "Keine Behandlung"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between py-2">
|
||||||
|
<span className="text-gray-600">Name:</span>
|
||||||
|
<span className="font-medium text-gray-900">{booking.customerName}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Result Banner */}
|
||||||
|
{submitResult && (
|
||||||
|
<div className={`mb-6 p-4 rounded-lg ${submitResult.success ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'}`}>
|
||||||
|
<p className={submitResult.success ? 'text-green-800' : 'text-red-800'}>{submitResult.message}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Review Form */}
|
||||||
|
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">Deine Bewertung</h2>
|
||||||
|
|
||||||
|
{/* Stars */}
|
||||||
|
<div className="flex items-center mb-4">
|
||||||
|
{[1,2,3,4,5].map((star) => {
|
||||||
|
const isActive = (hoverRating ?? rating ?? 0) >= star;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={star}
|
||||||
|
type="button"
|
||||||
|
className={`text-3xl mr-2 transition-colors ${isActive ? 'text-yellow-400' : 'text-gray-300'} ${submitMutation.isPending ? 'cursor-not-allowed' : 'cursor-pointer'}`}
|
||||||
|
onMouseEnter={() => !submitMutation.isPending && setHoverRating(star)}
|
||||||
|
onMouseLeave={() => !submitMutation.isPending && setHoverRating(null)}
|
||||||
|
onClick={() => !submitMutation.isPending && setRating(star)}
|
||||||
|
aria-label={`${star} Sterne`}
|
||||||
|
>
|
||||||
|
★
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{!rating && <p className="text-sm text-red-600 mb-2">Bitte wähle eine Bewertung von 1 bis 5 Sternen.</p>}
|
||||||
|
|
||||||
|
{/* Comment */}
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Kommentar</label>
|
||||||
|
<textarea
|
||||||
|
value={comment}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
placeholder="Teile deine Erfahrung mit uns..."
|
||||||
|
rows={5}
|
||||||
|
className="w-full p-3 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-pink-500"
|
||||||
|
disabled={submitMutation.isPending || !!submitResult?.success}
|
||||||
|
/>
|
||||||
|
{comment.trim().length > 0 && comment.trim().length < 10 && (
|
||||||
|
<p className="text-sm text-red-600 mt-1">Der Kommentar muss mindestens 10 Zeichen enthalten.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!canSubmit || !!submitResult?.success}
|
||||||
|
className="mt-4 w-full bg-pink-600 text-white py-3 px-4 rounded-lg font-medium hover:bg-pink-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{submitMutation.isPending ? 'Sende Bewertung...' : 'Bewertung absenden'}
|
||||||
|
</button>
|
||||||
|
<p className="text-xs text-gray-500 mt-3">Mit dem Absenden stimmst du der Veröffentlichung deiner Bewertung nach Prüfung zu.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="text-center">
|
||||||
|
<a href="/" className="inline-flex items-center text-pink-600 hover:text-pink-700 font-medium">
|
||||||
|
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
|
</svg>
|
||||||
|
Zurück zur Startseite
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
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 { caldavApp } from "./routes/caldav.js";
|
||||||
|
import { clientEntry } from "./routes/client-entry.js";
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
@@ -19,7 +22,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) {
|
||||||
@@ -28,7 +31,56 @@ app.get("/api/legal-config", async (c) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Security.txt endpoint (RFC 9116)
|
||||||
|
app.get("/.well-known/security.txt", (c) => {
|
||||||
|
const securityContact = process.env.SECURITY_CONTACT || "security@example.com";
|
||||||
|
const securityText = `Contact: ${securityContact}
|
||||||
|
Expires: 2025-12-31T23:59:59.000Z
|
||||||
|
Preferred-Languages: de, en
|
||||||
|
Canonical: https://${process.env.DOMAIN || 'localhost:5173'}/.well-known/security.txt
|
||||||
|
|
||||||
|
# Security Policy
|
||||||
|
# Please report security vulnerabilities responsibly by contacting us via email.
|
||||||
|
# We will respond to security reports within 48 hours.
|
||||||
|
#
|
||||||
|
# Scope: This security policy applies to the Stargirlnails booking system.
|
||||||
|
#
|
||||||
|
# Rewards: We appreciate security researchers who help us improve our security.
|
||||||
|
# While we don't have a formal bug bounty program, we may offer recognition
|
||||||
|
# for significant security improvements.
|
||||||
|
`;
|
||||||
|
|
||||||
|
return c.text(securityText, 200, {
|
||||||
|
"Content-Type": "text/plain; charset=utf-8",
|
||||||
|
"Cache-Control": "public, max-age=86400", // Cache for 24 hours
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serve static files (only in production)
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
app.use('/static/*', serveStatic({ root: './dist' }));
|
||||||
|
app.use('/assets/*', serveStatic({ root: './dist' }));
|
||||||
|
}
|
||||||
|
app.use('/favicon.png', serveStatic({ path: './public/favicon.png' }));
|
||||||
|
app.use('/AGB.pdf', serveStatic({ path: './public/AGB.pdf' }));
|
||||||
|
app.use('/icons/*', serveStatic({ root: './public' }));
|
||||||
|
app.use('/manifest.json', serveStatic({ path: './public/manifest.json' }));
|
||||||
|
|
||||||
app.route("/rpc", rpcApp);
|
app.route("/rpc", rpcApp);
|
||||||
|
app.route("/caldav", caldavApp);
|
||||||
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;
|
||||||
|
|||||||
17
src/server/lib/auth.ts
Normal file
17
src/server/lib/auth.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { createKV } from "./create-kv.js";
|
||||||
|
|
||||||
|
type Session = { id: string; userId: string; expiresAt: string; createdAt: string };
|
||||||
|
type User = { id: string; username: string; email: string; passwordHash: string; role: "customer" | "owner"; createdAt: string };
|
||||||
|
|
||||||
|
export const sessionsKV = createKV<Session>("sessions");
|
||||||
|
export const usersKV = createKV<User>("users");
|
||||||
|
|
||||||
|
export async function assertOwner(sessionId: string): Promise<void> {
|
||||||
|
const session = await sessionsKV.getItem(sessionId);
|
||||||
|
if (!session) throw new Error("Invalid session");
|
||||||
|
if (new Date(session.expiresAt) < new Date()) throw new Error("Session expired");
|
||||||
|
const user = await usersKV.getItem(session.userId);
|
||||||
|
if (!user || user.role !== "owner") throw new Error("Forbidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -8,6 +8,27 @@ function formatDateGerman(dateString: string): string {
|
|||||||
return `${day}.${month}.${year}`;
|
return `${day}.${month}.${year}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper function to render treatment list HTML
|
||||||
|
function renderTreatmentList(
|
||||||
|
treatments: Array<{id: string; name: string; duration: number; price: number}>,
|
||||||
|
options: { showPrices: boolean } = { showPrices: true }
|
||||||
|
): string {
|
||||||
|
const totalDuration = treatments.reduce((sum, t) => sum + t.duration, 0);
|
||||||
|
const totalPrice = treatments.reduce((sum, t) => sum + (t.price / 100), 0);
|
||||||
|
|
||||||
|
const treatmentItems = treatments.map(t =>
|
||||||
|
options.showPrices
|
||||||
|
? `<li><strong>${t.name}</strong> - ${t.duration} Min - ${(t.price / 100).toFixed(2)} €</li>`
|
||||||
|
: `<li>${t.name} - ${t.duration} Min - ${(t.price / 100).toFixed(2)} €</li>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
const totalLine = options.showPrices
|
||||||
|
? `<li style="border-top: 1px solid #e2e8f0; margin-top: 8px; padding-top: 8px;"><strong>Gesamt:</strong> ${totalDuration} Min - ${totalPrice.toFixed(2)} €</li>`
|
||||||
|
: `<li style="font-weight: 600; margin-top: 4px;">Gesamt: ${totalDuration} Min - ${totalPrice.toFixed(2)} €</li>`;
|
||||||
|
|
||||||
|
return `${treatmentItems}${totalLine}`;
|
||||||
|
}
|
||||||
|
|
||||||
let cachedLogoDataUrl: string | null = null;
|
let cachedLogoDataUrl: string | null = null;
|
||||||
|
|
||||||
async function getLogoDataUrl(): Promise<string | null> {
|
async function getLogoDataUrl(): Promise<string | null> {
|
||||||
@@ -31,13 +52,18 @@ async function renderBrandedEmail(title: string, bodyHtml: string): Promise<stri
|
|||||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||||
const homepageUrl = `${protocol}://${domain}`;
|
const homepageUrl = `${protocol}://${domain}`;
|
||||||
|
|
||||||
|
const instagramProfile = process.env.INSTAGRAM_PROFILE;
|
||||||
|
const tiktokProfile = process.env.TIKTOK_PROFILE;
|
||||||
|
const companyName = process.env.COMPANY_NAME || 'Stargirlnails Kiel';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div style="font-family: Arial, sans-serif; color: #0f172a; background:#fdf2f8; padding:24px;">
|
<div style="font-family: Arial, sans-serif; color: #0f172a; background:#fdf2f8; padding:24px;">
|
||||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:640px; margin:0 auto; background:#ffffff; border-radius:12px; overflow:hidden; box-shadow:0 1px 3px rgba(0,0,0,0.06)">
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:640px; margin:0 auto; background:#ffffff; border-radius:12px; overflow:hidden; box-shadow:0 1px 3px rgba(0,0,0,0.06)">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:24px 24px 0 24px; text-align:center;">
|
<td style="padding:24px 24px 0 24px; text-align:center;">
|
||||||
${logo ? `<img src="${logo}" alt="Stargirlnails" style="width:120px; height:auto; display:inline-block;" />` : `<div style=\"font-size:24px\">💅</div>`}
|
${logo ? `<img src="${logo}" alt="${companyName}" style="width:120px; height:auto; display:inline-block;" />` : `<div style=\"font-size:24px\">💅</div>`}
|
||||||
<h1 style="margin:16px 0 0 0; font-size:22px; color:#db2777;">${title}</h1>
|
<div style="margin:16px 0 4px 0; font-size:16px; font-weight:600; color:#64748b;">${companyName}</div>
|
||||||
|
<h1 style="margin:0; font-size:22px; color:#db2777;">${title}</h1>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -49,6 +75,29 @@ async function renderBrandedEmail(title: string, bodyHtml: string): Promise<stri
|
|||||||
<div style="text-align:center; margin-bottom:16px;">
|
<div style="text-align:center; margin-bottom:16px;">
|
||||||
<a href="${homepageUrl}" style="display: inline-block; background-color: #db2777; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 14px;">Zur Website</a>
|
<a href="${homepageUrl}" style="display: inline-block; background-color: #db2777; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 14px;">Zur Website</a>
|
||||||
</div>
|
</div>
|
||||||
|
${(instagramProfile || tiktokProfile) ? `
|
||||||
|
<div style="text-align:center; margin-bottom:16px;">
|
||||||
|
<p style="font-size:14px; color:#64748b; margin:0 0 8px 0;">Folge uns auf Social Media:</p>
|
||||||
|
<div style="display:inline-block;">
|
||||||
|
${instagramProfile ? `
|
||||||
|
<a href="${instagramProfile}" target="_blank" rel="noopener noreferrer" style="display:inline-block; margin:0 6px; background:linear-gradient(45deg, #f09433 0%,#e6683c 25%,#dc2743 50%,#cc2366 75%,#bc1888 100%); color:white; padding:10px 20px; text-decoration:none; border-radius:20px; font-size:14px; font-weight:600;">
|
||||||
|
<svg width="16" height="16" fill="currentColor" viewBox="0 0 24 24" style="vertical-align:middle; margin-right:6px;">
|
||||||
|
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/>
|
||||||
|
</svg>
|
||||||
|
Instagram
|
||||||
|
</a>
|
||||||
|
` : ''}
|
||||||
|
${tiktokProfile ? `
|
||||||
|
<a href="${tiktokProfile}" target="_blank" rel="noopener noreferrer" style="display:inline-block; margin:0 6px; background:#000000; color:white; padding:10px 20px; text-decoration:none; border-radius:20px; font-size:14px; font-weight:600;">
|
||||||
|
<svg width="16" height="16" fill="currentColor" viewBox="0 0 24 24" style="vertical-align:middle; margin-right:6px;">
|
||||||
|
<path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1-.1z"/>
|
||||||
|
</svg>
|
||||||
|
TikTok
|
||||||
|
</a>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
<div style="font-size:12px; color:#64748b; text-align:center;">
|
<div style="font-size:12px; color:#64748b; text-align:center;">
|
||||||
© ${new Date().getFullYear()} Stargirlnails Kiel • Professional Nail Care
|
© ${new Date().getFullYear()} Stargirlnails Kiel • Professional Nail Care
|
||||||
</div>
|
</div>
|
||||||
@@ -58,8 +107,8 @@ async function renderBrandedEmail(title: string, bodyHtml: string): Promise<stri
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function renderBookingPendingHTML(params: { name: string; date: string; time: string }) {
|
export async function renderBookingPendingHTML(params: { name: string; date: string; time: string; statusUrl?: string; treatments: Array<{id: string; name: string; duration: number; price: number}> }) {
|
||||||
const { name, date, time } = params;
|
const { name, date, time, statusUrl, treatments } = params;
|
||||||
const formattedDate = formatDateGerman(date);
|
const formattedDate = formatDateGerman(date);
|
||||||
const domain = process.env.DOMAIN || 'localhost:5173';
|
const domain = process.env.DOMAIN || 'localhost:5173';
|
||||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||||
@@ -68,7 +117,20 @@ export async function renderBookingPendingHTML(params: { name: string; date: str
|
|||||||
const inner = `
|
const inner = `
|
||||||
<p>Hallo ${name},</p>
|
<p>Hallo ${name},</p>
|
||||||
<p>wir haben deine Anfrage für <strong>${formattedDate}</strong> um <strong>${time}</strong> erhalten.</p>
|
<p>wir haben deine Anfrage für <strong>${formattedDate}</strong> um <strong>${time}</strong> erhalten.</p>
|
||||||
|
<div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||||
|
<p style="margin: 0 0 8px 0; font-weight: 600; color: #db2777;">💅 Deine Behandlungen:</p>
|
||||||
|
<ul style="margin: 0; color: #475569; list-style: none; padding: 0;">
|
||||||
|
${renderTreatmentList(treatments, { showPrices: true })}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
<p>Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.</p>
|
<p>Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.</p>
|
||||||
|
${statusUrl ? `
|
||||||
|
<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>
|
||||||
@@ -78,8 +140,8 @@ export async function renderBookingPendingHTML(params: { name: string; date: str
|
|||||||
return renderBrandedEmail("Deine Terminanfrage ist eingegangen", inner);
|
return renderBrandedEmail("Deine Terminanfrage ist eingegangen", inner);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function renderBookingConfirmedHTML(params: { name: string; date: string; time: string; cancellationUrl?: string }) {
|
export async function renderBookingConfirmedHTML(params: { name: string; date: string; time: string; cancellationUrl?: string; reviewUrl?: string; treatments: Array<{id: string; name: string; duration: number; price: number}> }) {
|
||||||
const { name, date, time, cancellationUrl } = params;
|
const { name, date, time, cancellationUrl, reviewUrl, treatments } = params;
|
||||||
const formattedDate = formatDateGerman(date);
|
const formattedDate = formatDateGerman(date);
|
||||||
const domain = process.env.DOMAIN || 'localhost:5173';
|
const domain = process.env.DOMAIN || 'localhost:5173';
|
||||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||||
@@ -88,16 +150,30 @@ export async function renderBookingConfirmedHTML(params: { name: string; date: s
|
|||||||
const inner = `
|
const inner = `
|
||||||
<p>Hallo ${name},</p>
|
<p>Hallo ${name},</p>
|
||||||
<p>wir haben deinen Termin am <strong>${formattedDate}</strong> um <strong>${time}</strong> bestätigt.</p>
|
<p>wir haben deinen Termin am <strong>${formattedDate}</strong> um <strong>${time}</strong> bestätigt.</p>
|
||||||
|
<div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||||
|
<p style="margin: 0 0 8px 0; font-weight: 600; color: #db2777;">💅 Deine Behandlungen:</p>
|
||||||
|
<ul style="margin: 0; color: #475569; list-style: none; padding: 0;">
|
||||||
|
${renderTreatmentList(treatments, { showPrices: true })}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
<p>Wir freuen uns auf dich!</p>
|
<p>Wir freuen uns auf dich!</p>
|
||||||
<div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
<div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||||
<p style="margin: 0; font-weight: 600; color: #db2777;">📋 Wichtiger Hinweis:</p>
|
<p style="margin: 0; font-weight: 600; color: #db2777;">📋 Wichtiger Hinweis:</p>
|
||||||
<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>
|
||||||
|
` : ''}
|
||||||
|
${reviewUrl ? `
|
||||||
|
<div style="background-color: #eff6ff; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||||
|
<p style="margin: 0; font-weight: 600; color: #3b82f6;">⭐ Bewertung abgeben:</p>
|
||||||
|
<p style="margin: 8px 0 12px 0; color: #475569;">Nach deinem Termin würden wir uns über deine Bewertung freuen!</p>
|
||||||
|
<a href="${reviewUrl}" style="display: inline-block; background-color: #3b82f6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">Bewertung schreiben</a>
|
||||||
|
<p style="margin: 12px 0 0 0; color: #64748b; font-size: 13px;">Du kannst deine Bewertung nach dem Termin über diesen Link abgeben.</p>
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
<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;">
|
||||||
@@ -109,8 +185,8 @@ export async function renderBookingConfirmedHTML(params: { name: string; date: s
|
|||||||
return renderBrandedEmail("Termin bestätigt", inner);
|
return renderBrandedEmail("Termin bestätigt", inner);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function renderBookingCancelledHTML(params: { name: string; date: string; time: string }) {
|
export async function renderBookingCancelledHTML(params: { name: string; date: string; time: string; treatments: Array<{id: string; name: string; duration: number; price: number}> }) {
|
||||||
const { name, date, time } = params;
|
const { name, date, time, treatments } = params;
|
||||||
const formattedDate = formatDateGerman(date);
|
const formattedDate = formatDateGerman(date);
|
||||||
const domain = process.env.DOMAIN || 'localhost:5173';
|
const domain = process.env.DOMAIN || 'localhost:5173';
|
||||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||||
@@ -119,6 +195,12 @@ export async function renderBookingCancelledHTML(params: { name: string; date: s
|
|||||||
const inner = `
|
const inner = `
|
||||||
<p>Hallo ${name},</p>
|
<p>Hallo ${name},</p>
|
||||||
<p>dein Termin am <strong>${formattedDate}</strong> um <strong>${time}</strong> wurde abgesagt.</p>
|
<p>dein Termin am <strong>${formattedDate}</strong> um <strong>${time}</strong> wurde abgesagt.</p>
|
||||||
|
<div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||||
|
<p style="margin: 0 0 8px 0; font-weight: 600; color: #db2777;">💅 Abgesagte Behandlungen:</p>
|
||||||
|
<ul style="margin: 0; color: #475569; list-style: none; padding: 0;">
|
||||||
|
${renderTreatmentList(treatments, { showPrices: true })}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
<p>Bitte buche einen neuen Termin. Bei Fragen helfen wir dir gerne weiter.</p>
|
<p>Bitte buche einen neuen Termin. Bei Fragen helfen wir dir gerne weiter.</p>
|
||||||
<div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
<div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||||
<p style="margin: 0; font-weight: 600; color: #3b82f6;">📋 Rechtliche Informationen:</p>
|
<p style="margin: 0; font-weight: 600; color: #3b82f6;">📋 Rechtliche Informationen:</p>
|
||||||
@@ -133,13 +215,14 @@ export async function renderAdminBookingNotificationHTML(params: {
|
|||||||
name: string;
|
name: string;
|
||||||
date: string;
|
date: string;
|
||||||
time: string;
|
time: string;
|
||||||
treatment: string;
|
treatments: Array<{id: string; name: string; duration: number; price: number}>;
|
||||||
phone: string;
|
phone: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
hasInspirationPhoto: boolean;
|
hasInspirationPhoto: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { name, date, time, treatment, phone, notes, hasInspirationPhoto } = params;
|
const { name, date, time, treatments, phone, notes, hasInspirationPhoto } = params;
|
||||||
const formattedDate = formatDateGerman(date);
|
const formattedDate = formatDateGerman(date);
|
||||||
|
|
||||||
const inner = `
|
const inner = `
|
||||||
<p>Hallo Admin,</p>
|
<p>Hallo Admin,</p>
|
||||||
<p>eine neue Buchungsanfrage ist eingegangen:</p>
|
<p>eine neue Buchungsanfrage ist eingegangen:</p>
|
||||||
@@ -148,7 +231,11 @@ export async function renderAdminBookingNotificationHTML(params: {
|
|||||||
<ul style="margin: 8px 0 0 0; color: #475569; list-style: none; padding: 0;">
|
<ul style="margin: 8px 0 0 0; color: #475569; list-style: none; padding: 0;">
|
||||||
<li><strong>Name:</strong> ${name}</li>
|
<li><strong>Name:</strong> ${name}</li>
|
||||||
<li><strong>Telefon:</strong> ${phone}</li>
|
<li><strong>Telefon:</strong> ${phone}</li>
|
||||||
<li><strong>Behandlung:</strong> ${treatment}</li>
|
<li><strong>Behandlungen:</strong>
|
||||||
|
<ul style="margin: 4px 0 0 0; list-style: none; padding: 0 0 0 16px;">
|
||||||
|
${renderTreatmentList(treatments, { showPrices: false })}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
<li><strong>Datum:</strong> ${formattedDate}</li>
|
<li><strong>Datum:</strong> ${formattedDate}</li>
|
||||||
<li><strong>Uhrzeit:</strong> ${time}</li>
|
<li><strong>Uhrzeit:</strong> ${time}</li>
|
||||||
${notes ? `<li><strong>Notizen:</strong> ${notes}</li>` : ''}
|
${notes ? `<li><strong>Notizen:</strong> ${notes}</li>` : ''}
|
||||||
@@ -162,3 +249,185 @@ export async function renderAdminBookingNotificationHTML(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function renderBookingRescheduleProposalHTML(params: {
|
||||||
|
name: string;
|
||||||
|
originalDate: string;
|
||||||
|
originalTime: string;
|
||||||
|
proposedDate: string;
|
||||||
|
proposedTime: string;
|
||||||
|
treatmentName: string;
|
||||||
|
acceptUrl: string;
|
||||||
|
declineUrl: string;
|
||||||
|
expiresAt: string;
|
||||||
|
}) {
|
||||||
|
const formattedOriginalDate = formatDateGerman(params.originalDate);
|
||||||
|
const formattedProposedDate = formatDateGerman(params.proposedDate);
|
||||||
|
const expiryDate = new Date(params.expiresAt);
|
||||||
|
const formattedExpiry = `${expiryDate.toLocaleDateString('de-DE')} ${expiryDate.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`;
|
||||||
|
|
||||||
|
const inner = `
|
||||||
|
<p>Hallo ${params.name},</p>
|
||||||
|
<p>wir müssen deinen Termin leider verschieben. Hier ist unser Vorschlag:</p>
|
||||||
|
<div style="background-color: #f8fafc; border-left: 4px solid #f59e0b; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||||
|
<p style="margin: 0; font-weight: 600; color: #92400e;">📅 Übersicht</p>
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" style="width:100%; margin-top:8px; font-size:14px; color:#475569;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:6px 0; width:45%"><strong>Alter Termin</strong></td>
|
||||||
|
<td style="padding:6px 0;">${formattedOriginalDate} um ${params.originalTime} Uhr</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:6px 0; width:45%"><strong>Neuer Vorschlag</strong></td>
|
||||||
|
<td style="padding:6px 0; color:#b45309;"><strong>${formattedProposedDate} um ${params.proposedTime} Uhr</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:6px 0; width:45%"><strong>Behandlung</strong></td>
|
||||||
|
<td style="padding:6px 0;">${params.treatmentName}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div style="background-color: #fffbeb; border-left: 4px solid #f59e0b; padding: 12px; margin: 16px 0; border-radius: 4px; color:#92400e;">
|
||||||
|
⏰ Bitte antworte bis ${formattedExpiry}.
|
||||||
|
</div>
|
||||||
|
<div style="text-align:center; margin: 20px 0;">
|
||||||
|
<a href="${params.acceptUrl}" style="display:inline-block; background-color:#16a34a; color:#ffffff; padding:12px 18px; border-radius:8px; text-decoration:none; font-weight:700; margin-right:8px;">Neuen Termin akzeptieren</a>
|
||||||
|
<a href="${params.declineUrl}" style="display:inline-block; background-color:#dc2626; color:#ffffff; padding:12px 18px; border-radius:8px; text-decoration:none; font-weight:700;">Termin ablehnen</a>
|
||||||
|
</div>
|
||||||
|
<div style="background-color: #f8fafc; border-left: 4px solid #10b981; padding: 12px; margin: 16px 0; border-radius: 4px; color:#065f46;">
|
||||||
|
Wenn du den Vorschlag ablehnst, bleibt dein ursprünglicher Termin bestehen und wir kontaktieren dich für eine alternative Lösung.
|
||||||
|
</div>
|
||||||
|
<p>Falls du einen komplett neuen Termin buchen möchtest, kannst du deinen aktuellen Termin stornieren und einen neuen Termin auf unserer Website buchen.</p>
|
||||||
|
<p>Liebe Grüße,<br/>Stargirlnails Kiel</p>
|
||||||
|
`;
|
||||||
|
return renderBrandedEmail("Terminänderung vorgeschlagen", inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderAdminRescheduleDeclinedHTML(params: {
|
||||||
|
customerName: string;
|
||||||
|
originalDate: string;
|
||||||
|
originalTime: string;
|
||||||
|
proposedDate: string;
|
||||||
|
proposedTime: string;
|
||||||
|
treatmentName: string;
|
||||||
|
customerEmail?: string;
|
||||||
|
customerPhone?: string;
|
||||||
|
}) {
|
||||||
|
const inner = `
|
||||||
|
<p>Hallo Admin,</p>
|
||||||
|
<p>der Kunde <strong>${params.customerName}</strong> hat den Terminänderungsvorschlag abgelehnt.</p>
|
||||||
|
<div style="background-color:#f8fafc; border-left:4px solid #ef4444; padding:16px; margin:16px 0; border-radius:4px;">
|
||||||
|
<ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:14px;">
|
||||||
|
<li><strong>Kunde:</strong> ${params.customerName}</li>
|
||||||
|
${params.customerEmail ? `<li><strong>E-Mail:</strong> ${params.customerEmail}</li>` : ''}
|
||||||
|
${params.customerPhone ? `<li><strong>Telefon:</strong> ${params.customerPhone}</li>` : ''}
|
||||||
|
<li><strong>Behandlung:</strong> ${params.treatmentName}</li>
|
||||||
|
<li><strong>Ursprünglicher Termin:</strong> ${formatDateGerman(params.originalDate)} um ${params.originalTime} Uhr (bleibt bestehen)</li>
|
||||||
|
<li><strong>Abgelehnter Vorschlag:</strong> ${formatDateGerman(params.proposedDate)} um ${params.proposedTime} Uhr</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<p>Bitte kontaktiere den Kunden, um eine alternative Lösung zu finden.</p>
|
||||||
|
`;
|
||||||
|
return renderBrandedEmail("Kunde hat Terminänderung abgelehnt", inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderAdminRescheduleAcceptedHTML(params: {
|
||||||
|
customerName: string;
|
||||||
|
originalDate: string;
|
||||||
|
originalTime: string;
|
||||||
|
newDate: string;
|
||||||
|
newTime: string;
|
||||||
|
treatmentName: string;
|
||||||
|
}) {
|
||||||
|
const inner = `
|
||||||
|
<p>Hallo Admin,</p>
|
||||||
|
<p>der Kunde <strong>${params.customerName}</strong> hat den Terminänderungsvorschlag akzeptiert.</p>
|
||||||
|
<div style="background-color:#ecfeff; border-left:4px solid #10b981; padding:16px; margin:16px 0; border-radius:4px;">
|
||||||
|
<ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:14px;">
|
||||||
|
<li><strong>Kunde:</strong> ${params.customerName}</li>
|
||||||
|
<li><strong>Behandlung:</strong> ${params.treatmentName}</li>
|
||||||
|
<li><strong>Alter Termin:</strong> ${formatDateGerman(params.originalDate)} um ${params.originalTime} Uhr</li>
|
||||||
|
<li><strong>Neuer Termin:</strong> ${formatDateGerman(params.newDate)} um ${params.newTime} Uhr ✅</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<p>Der Termin wurde automatisch aktualisiert.</p>
|
||||||
|
`;
|
||||||
|
return renderBrandedEmail("Kunde hat Terminänderung akzeptiert", inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderAdminRescheduleExpiredHTML(params: {
|
||||||
|
expiredProposals: Array<{
|
||||||
|
customerName: string;
|
||||||
|
originalDate: string;
|
||||||
|
originalTime: string;
|
||||||
|
proposedDate: string;
|
||||||
|
proposedTime: string;
|
||||||
|
treatmentName: string;
|
||||||
|
customerEmail?: string;
|
||||||
|
customerPhone?: string;
|
||||||
|
expiredAt: string;
|
||||||
|
}>;
|
||||||
|
}) {
|
||||||
|
const inner = `
|
||||||
|
<p>Hallo Admin,</p>
|
||||||
|
<p><strong>${params.expiredProposals.length} Terminänderungsvorschlag${params.expiredProposals.length > 1 ? 'e' : ''} ${params.expiredProposals.length > 1 ? 'sind' : 'ist'} abgelaufen</strong> und wurde${params.expiredProposals.length > 1 ? 'n' : ''} automatisch entfernt.</p>
|
||||||
|
<div style="background-color:#fef2f2; border-left:4px solid #ef4444; padding:16px; margin:16px 0; border-radius:4px;">
|
||||||
|
<p style="margin:0 0 12px 0; font-weight:600; color:#dc2626;">⚠️ Abgelaufene Vorschläge:</p>
|
||||||
|
${params.expiredProposals.map(proposal => `
|
||||||
|
<div style="background-color:#ffffff; border:1px solid #fecaca; border-radius:4px; padding:12px; margin:8px 0;">
|
||||||
|
<ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:13px;">
|
||||||
|
<li><strong>Kunde:</strong> ${proposal.customerName}</li>
|
||||||
|
${proposal.customerEmail ? `<li><strong>E-Mail:</strong> ${proposal.customerEmail}</li>` : ''}
|
||||||
|
${proposal.customerPhone ? `<li><strong>Telefon:</strong> ${proposal.customerPhone}</li>` : ''}
|
||||||
|
<li><strong>Behandlung:</strong> ${proposal.treatmentName}</li>
|
||||||
|
<li><strong>Ursprünglicher Termin:</strong> ${formatDateGerman(proposal.originalDate)} um ${proposal.originalTime} Uhr</li>
|
||||||
|
<li><strong>Vorgeschlagener Termin:</strong> ${formatDateGerman(proposal.proposedDate)} um ${proposal.proposedTime} Uhr</li>
|
||||||
|
<li><strong>Abgelaufen am:</strong> ${new Date(proposal.expiredAt).toLocaleString('de-DE')}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
<p style="color:#dc2626; font-weight:600;">Bitte kontaktiere die Kunden, um eine alternative Lösung zu finden.</p>
|
||||||
|
<p>Die ursprünglichen Termine bleiben bestehen.</p>
|
||||||
|
`;
|
||||||
|
return renderBrandedEmail("Abgelaufene Terminänderungsvorschläge", inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderCustomerMessageHTML(params: {
|
||||||
|
customerName: string;
|
||||||
|
message: string;
|
||||||
|
appointmentDate?: string;
|
||||||
|
appointmentTime?: string;
|
||||||
|
treatmentName?: string;
|
||||||
|
}) {
|
||||||
|
const { customerName, message, appointmentDate, appointmentTime, treatmentName } = params;
|
||||||
|
const formattedDate = appointmentDate ? formatDateGerman(appointmentDate) : null;
|
||||||
|
const domain = process.env.DOMAIN || 'localhost:5173';
|
||||||
|
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||||
|
const legalUrl = `${protocol}://${domain}/legal`;
|
||||||
|
const ownerName = process.env.OWNER_NAME || 'Stargirlnails Kiel';
|
||||||
|
|
||||||
|
const inner = `
|
||||||
|
<p>Hallo ${customerName},</p>
|
||||||
|
${(appointmentDate && appointmentTime && treatmentName) ? `
|
||||||
|
<div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||||
|
<p style="margin: 0; font-weight: 600; color: #db2777;">📅 Zu deinem Termin:</p>
|
||||||
|
<ul style="margin: 8px 0 0 0; color: #475569; list-style: none; padding: 0;">
|
||||||
|
<li><strong>Behandlung:</strong> ${treatmentName}</li>
|
||||||
|
<li><strong>Datum:</strong> ${formattedDate}</li>
|
||||||
|
<li><strong>Uhrzeit:</strong> ${appointmentTime}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
<div style="background-color: #fef9f5; border-left: 4px solid #f59e0b; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||||
|
<p style="margin: 0; font-weight: 600; color: #f59e0b;">💬 Nachricht von ${ownerName}:</p>
|
||||||
|
<div style="margin: 12px 0 0 0; color: #475569; white-space: pre-wrap; line-height: 1.6;">${message.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')}</div>
|
||||||
|
</div>
|
||||||
|
<p>Bei Fragen oder Anliegen kannst du einfach auf diese E-Mail antworten – wir helfen dir gerne weiter!</p>
|
||||||
|
<div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||||
|
<p style="margin: 0; font-weight: 600; color: #3b82f6;">📋 Rechtliche Informationen:</p>
|
||||||
|
<p style="margin: 8px 0 12px 0; color: #475569;">Weitere Informationen findest du in unserem <a href="${legalUrl}" style="color: #3b82f6; text-decoration: underline;">Impressum und Datenschutz</a>.</p>
|
||||||
|
</div>
|
||||||
|
<p>Liebe Grüße,<br/>${ownerName}</p>
|
||||||
|
`;
|
||||||
|
return renderBrandedEmail("Nachricht zu deinem Termin", inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
127
src/server/lib/email-validator.ts
Normal file
127
src/server/lib/email-validator.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
// Email validation using Rapid Email Validator API
|
||||||
|
// API: https://rapid-email-verifier.fly.dev/
|
||||||
|
// Privacy-focused, no data storage, completely free
|
||||||
|
|
||||||
|
type EmailValidationResult = {
|
||||||
|
email: string;
|
||||||
|
validations: {
|
||||||
|
syntax: boolean;
|
||||||
|
domain_exists: boolean;
|
||||||
|
mx_records: boolean;
|
||||||
|
mailbox_exists: boolean;
|
||||||
|
is_disposable: boolean;
|
||||||
|
is_role_based: boolean;
|
||||||
|
};
|
||||||
|
score: number;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate email address using Rapid Email Validator API
|
||||||
|
* Returns true if email is valid, false otherwise
|
||||||
|
*/
|
||||||
|
export async function validateEmail(email: string): Promise<{
|
||||||
|
valid: boolean;
|
||||||
|
reason?: string;
|
||||||
|
suggestion?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
// Call Rapid Email Validator API
|
||||||
|
const response = await fetch(`https://rapid-email-verifier.fly.dev/api/validate?email=${encodeURIComponent(email)}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error(`Email validation API error: ${response.status}`);
|
||||||
|
// If API is down, reject the email with error message
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: 'E-Mail-Validierung ist derzeit nicht verfügbar. Bitte überprüfe deine E-Mail-Adresse und versuche es erneut.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: EmailValidationResult = await response.json();
|
||||||
|
|
||||||
|
// Check if email is disposable/temporary
|
||||||
|
if (data.validations.is_disposable) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: 'Temporäre oder Wegwerf-E-Mail-Adressen sind nicht erlaubt. Bitte verwende eine echte E-Mail-Adresse.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if MX records exist (deliverable)
|
||||||
|
if (!data.validations.mx_records) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: 'Diese E-Mail-Adresse kann keine E-Mails empfangen. Bitte überprüfe deine E-Mail-Adresse.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if domain exists
|
||||||
|
if (!data.validations.domain_exists) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: 'Die E-Mail-Domain existiert nicht. Bitte überprüfe deine E-Mail-Adresse.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if email syntax is valid
|
||||||
|
if (!data.validations.syntax) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: 'Ungültige E-Mail-Adresse. Bitte überprüfe die Schreibweise.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email is valid
|
||||||
|
return { valid: true };
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Email validation error:', error);
|
||||||
|
// If validation fails, reject the email with error message
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: 'E-Mail-Validierung ist derzeit nicht verfügbar. Bitte überprüfe deine E-Mail-Adresse und versuche es erneut.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch validate multiple emails
|
||||||
|
* @param emails Array of email addresses to validate
|
||||||
|
* @returns Array of validation results
|
||||||
|
*/
|
||||||
|
export async function validateEmailBatch(emails: string[]): Promise<Map<string, {
|
||||||
|
valid: boolean;
|
||||||
|
reason?: string;
|
||||||
|
suggestion?: string;
|
||||||
|
}>> {
|
||||||
|
const results = new Map<string, { valid: boolean; reason?: string; suggestion?: string }>();
|
||||||
|
|
||||||
|
// Validate up to 100 emails at once (API limit)
|
||||||
|
const batchSize = 100;
|
||||||
|
for (let i = 0; i < emails.length; i += batchSize) {
|
||||||
|
const batch = emails.slice(i, i + batchSize);
|
||||||
|
|
||||||
|
// Call each validation in parallel for better performance
|
||||||
|
const validations = await Promise.all(
|
||||||
|
batch.map(async (email) => {
|
||||||
|
const result = await validateEmail(email);
|
||||||
|
return { email, result };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store results
|
||||||
|
validations.forEach(({ email, result }) => {
|
||||||
|
results.set(email, result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -6,6 +6,7 @@ type SendEmailParams = {
|
|||||||
from?: string;
|
from?: string;
|
||||||
cc?: string | string[];
|
cc?: string | string[];
|
||||||
bcc?: string | string[];
|
bcc?: string | string[];
|
||||||
|
replyTo?: string | string[];
|
||||||
attachments?: Array<{
|
attachments?: Array<{
|
||||||
filename: string;
|
filename: string;
|
||||||
content: string; // base64 encoded
|
content: string; // base64 encoded
|
||||||
@@ -20,6 +21,113 @@ import { dirname, resolve } from "node:path";
|
|||||||
const RESEND_API_KEY = process.env.RESEND_API_KEY;
|
const RESEND_API_KEY = process.env.RESEND_API_KEY;
|
||||||
const DEFAULT_FROM = process.env.EMAIL_FROM || "Stargirlnails <no-reply@stargirlnails.de>";
|
const DEFAULT_FROM = process.env.EMAIL_FROM || "Stargirlnails <no-reply@stargirlnails.de>";
|
||||||
|
|
||||||
|
// Helper function to format dates for ICS files (YYYYMMDDTHHMMSS)
|
||||||
|
function formatDateForICS(date: string, time: string): string {
|
||||||
|
// date is in YYYY-MM-DD format, time is in HH:MM format
|
||||||
|
const [year, month, day] = date.split('-');
|
||||||
|
const [hours, minutes] = time.split(':');
|
||||||
|
return `${year}${month}${day}T${hours}${minutes}00`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to escape text values for ICS files (RFC 5545)
|
||||||
|
function icsEscape(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/\\/g, '\\\\') // Backslash must be escaped first
|
||||||
|
.replace(/;/g, '\\;') // Semicolon
|
||||||
|
.replace(/,/g, '\\,') // Comma
|
||||||
|
.replace(/\n/g, '\\n'); // Newline
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to create ICS (iCalendar) file content
|
||||||
|
function createICSFile(params: {
|
||||||
|
date: string; // YYYY-MM-DD
|
||||||
|
time: string; // HH:MM
|
||||||
|
customerName: string;
|
||||||
|
customerEmail?: string;
|
||||||
|
treatments: Array<{id: string; name: string; duration: number; price: number}>;
|
||||||
|
}): string {
|
||||||
|
const { date, time, customerName, customerEmail, treatments } = params;
|
||||||
|
|
||||||
|
// Calculate duration from treatments
|
||||||
|
const durationMinutes = treatments.reduce((sum, t) => sum + t.duration, 0);
|
||||||
|
|
||||||
|
// Calculate start and end times in Europe/Berlin timezone
|
||||||
|
const dtStart = formatDateForICS(date, time);
|
||||||
|
|
||||||
|
// Calculate end time
|
||||||
|
const [hours, minutes] = time.split(':').map(Number);
|
||||||
|
const startDate = new Date(`${date}T${time}:00`);
|
||||||
|
const endDate = new Date(startDate.getTime() + durationMinutes * 60000);
|
||||||
|
const endHours = String(endDate.getHours()).padStart(2, '0');
|
||||||
|
const endMinutes = String(endDate.getMinutes()).padStart(2, '0');
|
||||||
|
const dtEnd = formatDateForICS(date, `${endHours}:${endMinutes}`);
|
||||||
|
|
||||||
|
// Create unique ID for this event
|
||||||
|
const uid = `booking-${Date.now()}-${Math.random().toString(36).substr(2, 9)}@stargirlnails.de`;
|
||||||
|
|
||||||
|
// Current timestamp for DTSTAMP
|
||||||
|
const now = new Date();
|
||||||
|
const dtstamp = now.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
|
||||||
|
|
||||||
|
// Build treatments list for SUMMARY and DESCRIPTION
|
||||||
|
const treatmentNames = icsEscape(treatments.map(t => t.name).join(', '));
|
||||||
|
const totalDuration = treatments.reduce((sum, t) => sum + t.duration, 0);
|
||||||
|
const totalPrice = treatments.reduce((sum, t) => sum + (t.price / 100), 0);
|
||||||
|
|
||||||
|
const treatmentDetails = treatments.map(t =>
|
||||||
|
`${icsEscape(t.name)} (${t.duration} Min, ${(t.price / 100).toFixed(2)} EUR)`
|
||||||
|
).join('\\n');
|
||||||
|
|
||||||
|
const description = `Behandlungen:\\n${treatmentDetails}\\n\\nGesamt: ${totalDuration} Min, ${totalPrice.toFixed(2)} EUR\\n\\nTermin bei Stargirlnails Kiel`;
|
||||||
|
|
||||||
|
// ICS content
|
||||||
|
const icsContent = [
|
||||||
|
'BEGIN:VCALENDAR',
|
||||||
|
'VERSION:2.0',
|
||||||
|
'PRODID:-//Stargirlnails Kiel//Booking System//DE',
|
||||||
|
'CALSCALE:GREGORIAN',
|
||||||
|
'METHOD:REQUEST',
|
||||||
|
'BEGIN:VEVENT',
|
||||||
|
`UID:${uid}`,
|
||||||
|
`DTSTAMP:${dtstamp}`,
|
||||||
|
`DTSTART;TZID=Europe/Berlin:${dtStart}`,
|
||||||
|
`DTEND;TZID=Europe/Berlin:${dtEnd}`,
|
||||||
|
`SUMMARY:${treatmentNames} - Stargirlnails Kiel`,
|
||||||
|
`DESCRIPTION:${description}`,
|
||||||
|
'LOCATION:Stargirlnails Kiel',
|
||||||
|
`ORGANIZER;CN=Stargirlnails Kiel:mailto:${process.env.EMAIL_FROM?.match(/<(.+)>/)?.[1] || 'no-reply@stargirlnails.de'}`,
|
||||||
|
...(customerEmail ? [`ATTENDEE;CN=${customerName};RSVP=TRUE:mailto:${customerEmail}`] : []),
|
||||||
|
'STATUS:CONFIRMED',
|
||||||
|
'SEQUENCE:0',
|
||||||
|
'BEGIN:VALARM',
|
||||||
|
'TRIGGER:-PT24H',
|
||||||
|
'ACTION:DISPLAY',
|
||||||
|
'DESCRIPTION:Erinnerung: Termin morgen bei Stargirlnails Kiel',
|
||||||
|
'END:VALARM',
|
||||||
|
'END:VEVENT',
|
||||||
|
'BEGIN:VTIMEZONE',
|
||||||
|
'TZID:Europe/Berlin',
|
||||||
|
'BEGIN:DAYLIGHT',
|
||||||
|
'TZOFFSETFROM:+0100',
|
||||||
|
'TZOFFSETTO:+0200',
|
||||||
|
'TZNAME:CEST',
|
||||||
|
'DTSTART:19700329T020000',
|
||||||
|
'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU',
|
||||||
|
'END:DAYLIGHT',
|
||||||
|
'BEGIN:STANDARD',
|
||||||
|
'TZOFFSETFROM:+0200',
|
||||||
|
'TZOFFSETTO:+0100',
|
||||||
|
'TZNAME:CET',
|
||||||
|
'DTSTART:19701025T030000',
|
||||||
|
'RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU',
|
||||||
|
'END:STANDARD',
|
||||||
|
'END:VTIMEZONE',
|
||||||
|
'END:VCALENDAR'
|
||||||
|
].join('\r\n');
|
||||||
|
|
||||||
|
return icsContent;
|
||||||
|
}
|
||||||
|
|
||||||
// Cache for AGB PDF to avoid reading it multiple times
|
// Cache for AGB PDF to avoid reading it multiple times
|
||||||
let cachedAGBPDF: string | null = null;
|
let cachedAGBPDF: string | null = null;
|
||||||
|
|
||||||
@@ -46,13 +154,7 @@ export async function sendEmail(params: SendEmailParams): Promise<{ success: boo
|
|||||||
return { success: false };
|
return { success: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch("https://api.resend.com/emails", {
|
const payload = {
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Authorization": `Bearer ${RESEND_API_KEY}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
from: params.from || DEFAULT_FROM,
|
from: params.from || DEFAULT_FROM,
|
||||||
to: Array.isArray(params.to) ? params.to : [params.to],
|
to: Array.isArray(params.to) ? params.to : [params.to],
|
||||||
subject: params.subject,
|
subject: params.subject,
|
||||||
@@ -60,8 +162,19 @@ export async function sendEmail(params: SendEmailParams): Promise<{ success: boo
|
|||||||
html: params.html,
|
html: params.html,
|
||||||
cc: params.cc ? (Array.isArray(params.cc) ? params.cc : [params.cc]) : undefined,
|
cc: params.cc ? (Array.isArray(params.cc) ? params.cc : [params.cc]) : undefined,
|
||||||
bcc: params.bcc ? (Array.isArray(params.bcc) ? params.bcc : [params.bcc]) : undefined,
|
bcc: params.bcc ? (Array.isArray(params.bcc) ? params.bcc : [params.bcc]) : undefined,
|
||||||
|
reply_to: params.replyTo ? (Array.isArray(params.replyTo) ? params.replyTo : [params.replyTo]) : undefined,
|
||||||
attachments: params.attachments,
|
attachments: params.attachments,
|
||||||
}),
|
};
|
||||||
|
|
||||||
|
console.log(`Sending email via Resend: to=${JSON.stringify(payload.to)}, subject="${params.subject}"`);
|
||||||
|
|
||||||
|
const response = await fetch("https://api.resend.com/emails", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${RESEND_API_KEY}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -69,6 +182,9 @@ export async function sendEmail(params: SendEmailParams): Promise<{ success: boo
|
|||||||
console.error("Resend send error:", response.status, body);
|
console.error("Resend send error:", response.status, body);
|
||||||
return { success: false };
|
return { success: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const responseData = await response.json().catch(() => ({}));
|
||||||
|
console.log("Resend email sent successfully:", responseData);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +205,42 @@ export async function sendEmailWithAGB(params: SendEmailParams): Promise<{ succe
|
|||||||
return sendEmail(params);
|
return sendEmail(params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function sendEmailWithAGBAndCalendar(
|
||||||
|
params: SendEmailParams,
|
||||||
|
calendarParams: {
|
||||||
|
date: string;
|
||||||
|
time: string;
|
||||||
|
customerName: string;
|
||||||
|
customerEmail?: string;
|
||||||
|
treatments: Array<{id: string; name: string; duration: number; price: number}>;
|
||||||
|
}
|
||||||
|
): Promise<{ success: boolean }> {
|
||||||
|
const agbBase64 = await getAGBPDFBase64();
|
||||||
|
|
||||||
|
// Create ICS file content
|
||||||
|
const icsContent = createICSFile(calendarParams);
|
||||||
|
const icsBase64 = Buffer.from(icsContent, 'utf-8').toString('base64');
|
||||||
|
|
||||||
|
// Attach both AGB and ICS file
|
||||||
|
params.attachments = [...(params.attachments || [])];
|
||||||
|
|
||||||
|
if (agbBase64) {
|
||||||
|
params.attachments.push({
|
||||||
|
filename: "AGB_Stargirlnails_Kiel.pdf",
|
||||||
|
content: agbBase64,
|
||||||
|
type: "application/pdf"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
params.attachments.push({
|
||||||
|
filename: "Termin_Stargirlnails.ics",
|
||||||
|
content: icsBase64,
|
||||||
|
type: "text/calendar"
|
||||||
|
});
|
||||||
|
|
||||||
|
return sendEmail(params);
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendEmailWithInspirationPhoto(
|
export async function sendEmailWithInspirationPhoto(
|
||||||
params: SendEmailParams,
|
params: SendEmailParams,
|
||||||
photoData: string,
|
photoData: string,
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export interface LegalConfig {
|
|||||||
city: string;
|
city: string;
|
||||||
postalCode: string;
|
postalCode: string;
|
||||||
country: string;
|
country: string;
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
};
|
};
|
||||||
contact: {
|
contact: {
|
||||||
phone: string;
|
phone: string;
|
||||||
@@ -41,10 +43,12 @@ export const defaultLegalConfig: LegalConfig = {
|
|||||||
companyName: process.env.COMPANY_NAME || "Stargirlnails Kiel",
|
companyName: process.env.COMPANY_NAME || "Stargirlnails Kiel",
|
||||||
ownerName: process.env.OWNER_NAME || "Inhaber Name",
|
ownerName: process.env.OWNER_NAME || "Inhaber Name",
|
||||||
address: {
|
address: {
|
||||||
street: process.env.ADDRESS_STREET || "Musterstraße 123",
|
street: process.env.ADDRESS_STREET || "Liebigstr. 15",
|
||||||
city: process.env.ADDRESS_CITY || "Kiel",
|
city: process.env.ADDRESS_CITY || "Kiel",
|
||||||
postalCode: process.env.ADDRESS_POSTAL_CODE || "24103",
|
postalCode: process.env.ADDRESS_POSTAL_CODE || "24145",
|
||||||
country: process.env.ADDRESS_COUNTRY || "Deutschland",
|
country: process.env.ADDRESS_COUNTRY || "Deutschland",
|
||||||
|
latitude: process.env.ADDRESS_LATITUDE ? parseFloat(process.env.ADDRESS_LATITUDE) : 54.3233,
|
||||||
|
longitude: process.env.ADDRESS_LONGITUDE ? parseFloat(process.env.ADDRESS_LONGITUDE) : 10.1228,
|
||||||
},
|
},
|
||||||
contact: {
|
contact: {
|
||||||
phone: process.env.CONTACT_PHONE || "+49 431 123456",
|
phone: process.env.CONTACT_PHONE || "+49 431 123456",
|
||||||
|
|||||||
162
src/server/lib/rate-limiter.ts
Normal file
162
src/server/lib/rate-limiter.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
// Simple in-memory rate limiter for IP and email-based requests
|
||||||
|
// For production with multiple instances, consider using Redis
|
||||||
|
|
||||||
|
type RateLimitEntry = {
|
||||||
|
count: number;
|
||||||
|
resetAt: number; // Unix timestamp in ms
|
||||||
|
};
|
||||||
|
|
||||||
|
const rateLimitStore = new Map<string, RateLimitEntry>();
|
||||||
|
|
||||||
|
// Cleanup old entries every 10 minutes to prevent memory leaks
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, entry] of rateLimitStore.entries()) {
|
||||||
|
if (entry.resetAt < now) {
|
||||||
|
rateLimitStore.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 10 * 60 * 1000);
|
||||||
|
|
||||||
|
export type RateLimitConfig = {
|
||||||
|
maxRequests: number;
|
||||||
|
windowMs: number; // Time window in milliseconds
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RateLimitResult = {
|
||||||
|
allowed: boolean;
|
||||||
|
remaining: number;
|
||||||
|
resetAt: number;
|
||||||
|
retryAfterSeconds?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a request is allowed based on rate limiting
|
||||||
|
* @param key - Unique identifier (IP, email, or combination)
|
||||||
|
* @param config - Rate limit configuration
|
||||||
|
* @returns RateLimitResult with allow status and metadata
|
||||||
|
*/
|
||||||
|
export function checkRateLimit(
|
||||||
|
key: string,
|
||||||
|
config: RateLimitConfig
|
||||||
|
): RateLimitResult {
|
||||||
|
const now = Date.now();
|
||||||
|
const entry = rateLimitStore.get(key);
|
||||||
|
|
||||||
|
// No existing entry or window expired - allow and create new entry
|
||||||
|
if (!entry || entry.resetAt < now) {
|
||||||
|
rateLimitStore.set(key, {
|
||||||
|
count: 1,
|
||||||
|
resetAt: now + config.windowMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
remaining: config.maxRequests - 1,
|
||||||
|
resetAt: now + config.windowMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Existing entry within window
|
||||||
|
if (entry.count >= config.maxRequests) {
|
||||||
|
// Rate limit exceeded
|
||||||
|
const retryAfterSeconds = Math.ceil((entry.resetAt - now) / 1000);
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
remaining: 0,
|
||||||
|
resetAt: entry.resetAt,
|
||||||
|
retryAfterSeconds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment count and allow
|
||||||
|
entry.count++;
|
||||||
|
rateLimitStore.set(key, entry);
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
remaining: config.maxRequests - entry.count,
|
||||||
|
resetAt: entry.resetAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check rate limit for booking creation
|
||||||
|
* Applies multiple checks: per IP and per email
|
||||||
|
*/
|
||||||
|
export function checkBookingRateLimit(params: {
|
||||||
|
ip?: string;
|
||||||
|
email: string;
|
||||||
|
}): RateLimitResult {
|
||||||
|
const { ip, email } = params;
|
||||||
|
|
||||||
|
// Config: max 3 bookings per email per hour
|
||||||
|
const emailConfig: RateLimitConfig = {
|
||||||
|
maxRequests: 3,
|
||||||
|
windowMs: 60 * 60 * 1000, // 1 hour
|
||||||
|
};
|
||||||
|
|
||||||
|
// Config: max 5 bookings per IP per 10 minutes
|
||||||
|
const ipConfig: RateLimitConfig = {
|
||||||
|
maxRequests: 5,
|
||||||
|
windowMs: 10 * 60 * 1000, // 10 minutes
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check email rate limit
|
||||||
|
const emailKey = `booking:email:${email.toLowerCase()}`;
|
||||||
|
const emailResult = checkRateLimit(emailKey, emailConfig);
|
||||||
|
|
||||||
|
if (!emailResult.allowed) {
|
||||||
|
return {
|
||||||
|
...emailResult,
|
||||||
|
allowed: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check IP rate limit (if IP is available)
|
||||||
|
if (ip) {
|
||||||
|
const ipKey = `booking:ip:${ip}`;
|
||||||
|
const ipResult = checkRateLimit(ipKey, ipConfig);
|
||||||
|
|
||||||
|
if (!ipResult.allowed) {
|
||||||
|
return {
|
||||||
|
...ipResult,
|
||||||
|
allowed: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both checks passed
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
remaining: Math.min(emailResult.remaining, ip ? Infinity : emailResult.remaining),
|
||||||
|
resetAt: emailResult.resetAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get client IP from various headers (for proxy/load balancer support)
|
||||||
|
*/
|
||||||
|
export function getClientIP(headers: Record<string, string | undefined>): string | undefined {
|
||||||
|
// Check common proxy headers
|
||||||
|
const forwardedFor = headers['x-forwarded-for'];
|
||||||
|
if (forwardedFor) {
|
||||||
|
// x-forwarded-for can contain multiple IPs, take the first one
|
||||||
|
return forwardedFor.split(',')[0].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const realIP = headers['x-real-ip'];
|
||||||
|
if (realIP) {
|
||||||
|
return realIP;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cfConnectingIP = headers['cf-connecting-ip']; // Cloudflare
|
||||||
|
if (cfConnectingIP) {
|
||||||
|
return cfConnectingIP;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No IP found
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
270
src/server/routes/caldav.ts
Normal file
270
src/server/routes/caldav.ts
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { createKV } from "../lib/create-kv.js";
|
||||||
|
import { assertOwner } from "../lib/auth.js";
|
||||||
|
|
||||||
|
// Types für Buchungen (vereinfacht für CalDAV)
|
||||||
|
type Booking = {
|
||||||
|
id: string;
|
||||||
|
treatments?: Array<{id: string, name: string, duration: number, price: number}>;
|
||||||
|
customerName: string;
|
||||||
|
customerEmail?: string;
|
||||||
|
customerPhone?: string;
|
||||||
|
appointmentDate: string; // YYYY-MM-DD
|
||||||
|
appointmentTime: string; // HH:MM
|
||||||
|
status: "pending" | "confirmed" | "cancelled" | "completed";
|
||||||
|
notes?: string;
|
||||||
|
// Deprecated fields for backward compatibility
|
||||||
|
treatmentId?: string;
|
||||||
|
bookedDurationMinutes?: number;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Treatment = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
price: number;
|
||||||
|
duration: number;
|
||||||
|
category: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// KV-Stores
|
||||||
|
const bookingsKV = createKV<Booking>("bookings");
|
||||||
|
const treatmentsKV = createKV<Treatment>("treatments");
|
||||||
|
const sessionsKV = createKV<any>("sessions");
|
||||||
|
|
||||||
|
export const caldavApp = new Hono();
|
||||||
|
|
||||||
|
// Helper-Funktionen für ICS-Format
|
||||||
|
function formatDateTime(dateStr: string, timeStr: string): string {
|
||||||
|
// Konvertiere YYYY-MM-DD HH:MM zu UTC-Format für ICS
|
||||||
|
const [year, month, day] = dateStr.split('-').map(Number);
|
||||||
|
const [hours, minutes] = timeStr.split(':').map(Number);
|
||||||
|
|
||||||
|
const date = new Date(year, month - 1, day, hours, minutes);
|
||||||
|
return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMinutesToTime(timeStr: string, minutesToAdd: number): string {
|
||||||
|
const [hours, minutes] = timeStr.split(':').map(Number);
|
||||||
|
const totalMinutes = hours * 60 + minutes + minutesToAdd;
|
||||||
|
const newHours = Math.floor(totalMinutes / 60);
|
||||||
|
const newMinutes = totalMinutes % 60;
|
||||||
|
return `${String(newHours).padStart(2, '0')}:${String(newMinutes).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateICSContent(bookings: Booking[], treatments: Treatment[]): string {
|
||||||
|
const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||||||
|
|
||||||
|
let ics = `BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//Stargirlnails//Booking Calendar//DE
|
||||||
|
CALSCALE:GREGORIAN
|
||||||
|
METHOD:PUBLISH
|
||||||
|
X-WR-CALNAME:Stargirlnails Termine
|
||||||
|
X-WR-CALDESC:Terminkalender für Stargirlnails
|
||||||
|
X-WR-TIMEZONE:Europe/Berlin
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Nur bestätigte und ausstehende Termine in den Kalender aufnehmen
|
||||||
|
const activeBookings = bookings.filter(b =>
|
||||||
|
b.status === 'confirmed' || b.status === 'pending'
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const booking of activeBookings) {
|
||||||
|
// Handle new treatments array structure
|
||||||
|
let treatmentNames: string;
|
||||||
|
let duration: number;
|
||||||
|
let treatmentDetails: string;
|
||||||
|
let totalPrice = 0;
|
||||||
|
|
||||||
|
if (booking.treatments && Array.isArray(booking.treatments) && booking.treatments.length > 0) {
|
||||||
|
// Use new treatments array
|
||||||
|
treatmentNames = booking.treatments.map(t => t.name).join(', ');
|
||||||
|
|
||||||
|
duration = booking.treatments.reduce((sum, t) => sum + (t.duration || 0), 0);
|
||||||
|
totalPrice = booking.treatments.reduce((sum, t) => sum + ((t.price || 0) / 100), 0);
|
||||||
|
|
||||||
|
// Build detailed treatment list for description
|
||||||
|
treatmentDetails = booking.treatments
|
||||||
|
.map(t => `- ${t.name} (${t.duration} Min., ${(t.price / 100).toFixed(2)}€)`)
|
||||||
|
.join('\\n');
|
||||||
|
|
||||||
|
if (booking.treatments.length > 1) {
|
||||||
|
treatmentDetails += `\\n\\nGesamt: ${duration} Min., ${totalPrice.toFixed(2)}€`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback to deprecated treatmentId for backward compatibility
|
||||||
|
const treatment = booking.treatmentId ? treatments.find(t => t.id === booking.treatmentId) : null;
|
||||||
|
treatmentNames = treatment?.name || 'Unbekannte Behandlung';
|
||||||
|
duration = booking.bookedDurationMinutes || treatment?.duration || 60;
|
||||||
|
treatmentDetails = `Behandlung: ${treatmentNames}`;
|
||||||
|
if (treatment?.price) {
|
||||||
|
treatmentDetails += ` (${duration} Min., ${(treatment.price / 100).toFixed(2)}€)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = formatDateTime(booking.appointmentDate, booking.appointmentTime);
|
||||||
|
const endTimeStr = addMinutesToTime(booking.appointmentTime, duration);
|
||||||
|
const endTime = formatDateTime(booking.appointmentDate, endTimeStr);
|
||||||
|
|
||||||
|
// UID für jeden Termin (eindeutig)
|
||||||
|
const uid = `booking-${booking.id}@stargirlnails.de`;
|
||||||
|
|
||||||
|
// Status für ICS
|
||||||
|
const status = booking.status === 'confirmed' ? 'CONFIRMED' : 'TENTATIVE';
|
||||||
|
|
||||||
|
ics += `BEGIN:VEVENT
|
||||||
|
UID:${uid}
|
||||||
|
DTSTAMP:${now}
|
||||||
|
DTSTART:${startTime}
|
||||||
|
DTEND:${endTime}
|
||||||
|
SUMMARY:${treatmentNames} - ${booking.customerName}
|
||||||
|
DESCRIPTION:${treatmentDetails}\\n\\nKunde: ${booking.customerName}${booking.customerPhone ? `\\nTelefon: ${booking.customerPhone}` : ''}${booking.notes ? `\\nNotizen: ${booking.notes}` : ''}
|
||||||
|
STATUS:${status}
|
||||||
|
TRANSP:OPAQUE
|
||||||
|
END:VEVENT
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
ics += `END:VCALENDAR`;
|
||||||
|
|
||||||
|
return ics;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalDAV Discovery (PROPFIND auf Root)
|
||||||
|
caldavApp.all("/", async (c) => {
|
||||||
|
if (c.req.method !== 'PROPFIND') {
|
||||||
|
return c.text('Method Not Allowed', 405);
|
||||||
|
}
|
||||||
|
const response = `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||||
|
<D:response>
|
||||||
|
<D:href>/caldav/</D:href>
|
||||||
|
<D:propstat>
|
||||||
|
<D:prop>
|
||||||
|
<D:displayname>Stargirlnails Terminkalender</D:displayname>
|
||||||
|
<C:calendar-description>Termine für Stargirlnails</C:calendar-description>
|
||||||
|
<C:supported-calendar-component-set>
|
||||||
|
<C:comp name="VEVENT"/>
|
||||||
|
</C:supported-calendar-component-set>
|
||||||
|
<C:calendar-timezone>Europe/Berlin</C:calendar-timezone>
|
||||||
|
</D:prop>
|
||||||
|
<D:status>HTTP/1.1 200 OK</D:status>
|
||||||
|
</D:propstat>
|
||||||
|
</D:response>
|
||||||
|
</D:multistatus>`;
|
||||||
|
|
||||||
|
return c.text(response, 207, {
|
||||||
|
"Content-Type": "application/xml; charset=utf-8",
|
||||||
|
"DAV": "1, 3, calendar-access, calendar-schedule",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calendar Collection PROPFIND
|
||||||
|
caldavApp.all("/calendar/", async (c) => {
|
||||||
|
if (c.req.method !== 'PROPFIND') {
|
||||||
|
return c.text('Method Not Allowed', 405);
|
||||||
|
}
|
||||||
|
const response = `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/">
|
||||||
|
<D:response>
|
||||||
|
<D:href>/caldav/calendar/</D:href>
|
||||||
|
<D:propstat>
|
||||||
|
<D:prop>
|
||||||
|
<D:displayname>Stargirlnails Termine</D:displayname>
|
||||||
|
<C:calendar-description>Alle Termine von Stargirlnails</C:calendar-description>
|
||||||
|
<C:supported-calendar-component-set>
|
||||||
|
<C:comp name="VEVENT"/>
|
||||||
|
</C:supported-calendar-component-set>
|
||||||
|
<C:calendar-timezone>Europe/Berlin</C:calendar-timezone>
|
||||||
|
<CS:getctag>${Date.now()}</CS:getctag>
|
||||||
|
<D:sync-token>${Date.now()}</D:sync-token>
|
||||||
|
</D:prop>
|
||||||
|
<D:status>HTTP/1.1 200 OK</D:status>
|
||||||
|
</D:propstat>
|
||||||
|
</D:response>
|
||||||
|
</D:multistatus>`;
|
||||||
|
|
||||||
|
return c.text(response, 207, {
|
||||||
|
"Content-Type": "application/xml; charset=utf-8",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calendar Events PROPFIND
|
||||||
|
caldavApp.all("/calendar/events.ics", async (c) => {
|
||||||
|
if (c.req.method !== 'PROPFIND') {
|
||||||
|
return c.text('Method Not Allowed', 405);
|
||||||
|
}
|
||||||
|
const response = `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/">
|
||||||
|
<D:response>
|
||||||
|
<D:href>/caldav/calendar/events.ics</D:href>
|
||||||
|
<D:propstat>
|
||||||
|
<D:prop>
|
||||||
|
<D:getcontenttype>text/calendar; charset=utf-8</D:getcontenttype>
|
||||||
|
<D:getetag>"${Date.now()}"</D:getetag>
|
||||||
|
<D:displayname>Stargirlnails Termine</D:displayname>
|
||||||
|
<C:calendar-data>BEGIN:VCALENDAR\\nVERSION:2.0\\nEND:VCALENDAR</C:calendar-data>
|
||||||
|
</D:prop>
|
||||||
|
<D:status>HTTP/1.1 200 OK</D:status>
|
||||||
|
</D:propstat>
|
||||||
|
</D:response>
|
||||||
|
</D:multistatus>`;
|
||||||
|
|
||||||
|
return c.text(response, 207, {
|
||||||
|
"Content-Type": "application/xml; charset=utf-8",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET Calendar Data (ICS-Datei)
|
||||||
|
caldavApp.get("/calendar/events.ics", async (c) => {
|
||||||
|
try {
|
||||||
|
// Authentifizierung über Token im Query-Parameter
|
||||||
|
const token = c.req.query('token');
|
||||||
|
if (!token) {
|
||||||
|
return c.text('Unauthorized - Token required', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token validieren
|
||||||
|
const tokenData = await sessionsKV.getItem(token);
|
||||||
|
if (!tokenData) {
|
||||||
|
return c.text('Unauthorized - Invalid token', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfe, ob es ein CalDAV-Token ist (durch Ablaufzeit und fehlende type-Eigenschaft erkennbar)
|
||||||
|
// CalDAV-Tokens haben eine kürzere Ablaufzeit (24h) als normale Sessions
|
||||||
|
const tokenAge = Date.now() - new Date(tokenData.createdAt).getTime();
|
||||||
|
if (tokenAge > 24 * 60 * 60 * 1000) { // 24 Stunden
|
||||||
|
return c.text('Unauthorized - Token expired', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token-Ablaufzeit prüfen
|
||||||
|
if (new Date(tokenData.expiresAt) < new Date()) {
|
||||||
|
return c.text('Unauthorized - Token expired', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookings = await bookingsKV.getAllItems();
|
||||||
|
const treatments = await treatmentsKV.getAllItems();
|
||||||
|
|
||||||
|
const icsContent = generateICSContent(bookings, treatments);
|
||||||
|
|
||||||
|
return c.text(icsContent, 200, {
|
||||||
|
"Content-Type": "text/calendar; charset=utf-8",
|
||||||
|
"Content-Disposition": "inline; filename=\"stargirlnails-termine.ics\"",
|
||||||
|
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||||
|
"Pragma": "no-cache",
|
||||||
|
"Expires": "0",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("CalDAV GET error:", error);
|
||||||
|
return c.text('Internal Server Error', 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fallback für andere CalDAV-Requests
|
||||||
|
caldavApp.all("*", async (c) => {
|
||||||
|
console.log(`CalDAV: Unhandled ${c.req.method} request to ${c.req.url}`);
|
||||||
|
return c.text('Not Found', 404);
|
||||||
|
});
|
||||||
@@ -1,28 +1,56 @@
|
|||||||
/** @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['index.html'];
|
||||||
|
if (entry) {
|
||||||
|
jsFile = `/${entry.file}`;
|
||||||
|
if (entry.css) {
|
||||||
|
cssFiles = entry.css.map((css: string) => `/${css}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Could not read Vite manifest, using fallback:', error);
|
||||||
|
// Fallback to a generic path
|
||||||
|
jsFile = "/assets/index-Ccx6A0bN.js";
|
||||||
|
cssFiles = ["/assets/index-RdX4PbOO.css"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return c.html(
|
return c.html(
|
||||||
<html lang="en">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charSet="utf-8" />
|
<meta charSet="utf-8" />
|
||||||
<meta content="width=device-width, initial-scale=1" name="viewport" />
|
<meta content="width=device-width, initial-scale=1" name="viewport" />
|
||||||
|
<meta name="theme-color" content="#ec4899" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Stargirlnails" />
|
||||||
<title>Stargirlnails Kiel</title>
|
<title>Stargirlnails Kiel</title>
|
||||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
{import.meta.env.PROD ? (
|
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
||||||
<script src="/static/main.js" type="module" />
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
{cssFiles && cssFiles.map((css: string) => (
|
||||||
|
<link key={css} rel="stylesheet" href={css} />
|
||||||
|
))}
|
||||||
|
{process.env.NODE_ENV === 'production' ? (
|
||||||
|
<script src={jsFile} type="module" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<script
|
<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>
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
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();
|
||||||
|
|
||||||
const handler = new RPCHandler(router);
|
const handler = new RPCHandler(router);
|
||||||
|
|
||||||
rpcApp.use("/*", async (c, next) => {
|
rpcApp.all("/*", async (c) => {
|
||||||
|
try {
|
||||||
const { matched, response } = await handler.handle(c.req.raw, {
|
const { matched, response } = await handler.handle(c.req.raw, {
|
||||||
prefix: "/rpc",
|
prefix: "/rpc",
|
||||||
});
|
});
|
||||||
@@ -16,6 +17,11 @@ rpcApp.use("/*", async (c, next) => {
|
|||||||
return c.newResponse(response.body, response);
|
return c.newResponse(response.body, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
await next();
|
return c.json({ error: "Not found" }, 404);
|
||||||
return;
|
} catch (error) {
|
||||||
|
console.error("RPC Handler error:", error);
|
||||||
|
|
||||||
|
// Let oRPC handle errors properly
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -9,8 +9,8 @@ config();
|
|||||||
|
|
||||||
const UserSchema = z.object({
|
const UserSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
username: z.string(),
|
username: z.string().min(3, "Benutzername muss mindestens 3 Zeichen lang sein"),
|
||||||
email: z.string(),
|
email: z.string().email("Ungültige E-Mail-Adresse"),
|
||||||
passwordHash: z.string(),
|
passwordHash: z.string(),
|
||||||
role: z.enum(["customer", "owner"]),
|
role: z.enum(["customer", "owner"]),
|
||||||
createdAt: z.string(),
|
createdAt: z.string(),
|
||||||
|
|||||||
@@ -1,156 +0,0 @@
|
|||||||
import { call, os } from "@orpc/server";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { randomUUID } from "crypto";
|
|
||||||
import { createKV } from "@/server/lib/create-kv";
|
|
||||||
|
|
||||||
const AvailabilitySchema = z.object({
|
|
||||||
id: z.string(),
|
|
||||||
date: z.string(), // YYYY-MM-DD
|
|
||||||
time: z.string(), // HH:MM
|
|
||||||
durationMinutes: z.number().int().positive(),
|
|
||||||
status: z.enum(["free", "reserved"]),
|
|
||||||
reservedByBookingId: z.string().optional(),
|
|
||||||
createdAt: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type Availability = z.output<typeof AvailabilitySchema>;
|
|
||||||
|
|
||||||
const kv = createKV<Availability>("availability");
|
|
||||||
|
|
||||||
// Minimal Owner-Prüfung über Sessions/Users KV
|
|
||||||
type Session = { id: string; userId: string; expiresAt: string; createdAt: string };
|
|
||||||
type User = { id: string; username: string; email: string; passwordHash: string; role: "customer" | "owner"; createdAt: string };
|
|
||||||
const sessionsKV = createKV<Session>("sessions");
|
|
||||||
const usersKV = createKV<User>("users");
|
|
||||||
|
|
||||||
async function assertOwner(sessionId: string): Promise<void> {
|
|
||||||
const session = await sessionsKV.getItem(sessionId);
|
|
||||||
if (!session) throw new Error("Invalid session");
|
|
||||||
if (new Date(session.expiresAt) < new Date()) throw new Error("Session expired");
|
|
||||||
const user = await usersKV.getItem(session.userId);
|
|
||||||
if (!user || user.role !== "owner") throw new Error("Forbidden");
|
|
||||||
}
|
|
||||||
|
|
||||||
const create = os
|
|
||||||
.input(
|
|
||||||
z.object({
|
|
||||||
sessionId: z.string(),
|
|
||||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
|
||||||
time: z.string().regex(/^\d{2}:\d{2}$/),
|
|
||||||
durationMinutes: z.number().int().positive(),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.handler(async ({ input }) => {
|
|
||||||
try {
|
|
||||||
await assertOwner(input.sessionId);
|
|
||||||
// Prevent duplicate slot on same date+time
|
|
||||||
const existing = await kv.getAllItems();
|
|
||||||
const conflict = existing.some((s) => s.date === input.date && s.time === input.time);
|
|
||||||
if (conflict) {
|
|
||||||
throw new Error("Es existiert bereits ein Slot zu diesem Datum und dieser Uhrzeit.");
|
|
||||||
}
|
|
||||||
const id = randomUUID();
|
|
||||||
const slot: Availability = {
|
|
||||||
id,
|
|
||||||
date: input.date,
|
|
||||||
time: input.time,
|
|
||||||
durationMinutes: input.durationMinutes,
|
|
||||||
status: "free",
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
await kv.setItem(id, slot);
|
|
||||||
return slot;
|
|
||||||
} catch (err) {
|
|
||||||
console.error("availability.create error", err);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const update = os
|
|
||||||
.input(AvailabilitySchema.extend({ sessionId: z.string() }))
|
|
||||||
.handler(async ({ input }) => {
|
|
||||||
await assertOwner(input.sessionId);
|
|
||||||
const { sessionId, ...rest } = input as any;
|
|
||||||
await kv.setItem(rest.id, rest as Availability);
|
|
||||||
return rest as Availability;
|
|
||||||
});
|
|
||||||
|
|
||||||
const remove = os
|
|
||||||
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
|
||||||
.handler(async ({ input }) => {
|
|
||||||
await assertOwner(input.sessionId);
|
|
||||||
const slot = await kv.getItem(input.id);
|
|
||||||
if (slot && slot.status === "reserved") throw new Error("Cannot delete reserved slot");
|
|
||||||
await kv.removeItem(input.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
const list = os.handler(async () => {
|
|
||||||
const allSlots = await kv.getAllItems();
|
|
||||||
|
|
||||||
// Filter out past slots automatically
|
|
||||||
const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
|
|
||||||
const now = new Date();
|
|
||||||
const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
|
||||||
|
|
||||||
const filteredSlots = allSlots.filter(slot => {
|
|
||||||
// Keep slots for future dates
|
|
||||||
if (slot.date > today) return true;
|
|
||||||
|
|
||||||
// For today: only keep future time slots
|
|
||||||
if (slot.date === today) {
|
|
||||||
return slot.time > currentTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove past slots
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Debug logging (commented out - uncomment if needed)
|
|
||||||
// const statusCounts = filteredSlots.reduce((acc, slot) => {
|
|
||||||
// acc[slot.status] = (acc[slot.status] || 0) + 1;
|
|
||||||
// return acc;
|
|
||||||
// }, {} as Record<string, number>);
|
|
||||||
// console.log(`Availability list: ${filteredSlots.length} slots (${JSON.stringify(statusCounts)})`);
|
|
||||||
|
|
||||||
return filteredSlots;
|
|
||||||
});
|
|
||||||
|
|
||||||
const get = os.input(z.string()).handler(async ({ input }) => {
|
|
||||||
return kv.getItem(input);
|
|
||||||
});
|
|
||||||
|
|
||||||
const getByDate = os
|
|
||||||
.input(z.string()) // YYYY-MM-DD
|
|
||||||
.handler(async ({ input }) => {
|
|
||||||
const all = await kv.getAllItems();
|
|
||||||
return all.filter((s) => s.date === input);
|
|
||||||
});
|
|
||||||
|
|
||||||
const live = {
|
|
||||||
list: os.handler(async function* ({ signal }) {
|
|
||||||
yield call(list, {}, { signal });
|
|
||||||
for await (const _ of kv.subscribe()) {
|
|
||||||
yield call(list, {}, { signal });
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
byDate: os
|
|
||||||
.input(z.string())
|
|
||||||
.handler(async function* ({ input, signal }) {
|
|
||||||
yield call(getByDate, input, { signal });
|
|
||||||
for await (const _ of kv.subscribe()) {
|
|
||||||
yield call(getByDate, input, { signal });
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const router = {
|
|
||||||
create,
|
|
||||||
update,
|
|
||||||
remove,
|
|
||||||
list,
|
|
||||||
get,
|
|
||||||
getByDate,
|
|
||||||
live,
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,29 +1,45 @@
|
|||||||
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", "reschedule_proposal"]), // Extended for reschedule proposals
|
||||||
|
// Optional metadata for reschedule proposals
|
||||||
|
proposedDate: z.string().optional(),
|
||||||
|
proposedTime: z.string().optional(),
|
||||||
|
originalDate: z.string().optional(),
|
||||||
|
originalTime: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type CancellationToken = z.output<typeof CancellationTokenSchema>;
|
type BookingAccessToken = z.output<typeof BookingAccessTokenSchema>;
|
||||||
|
// Backwards compatibility alias
|
||||||
|
type CancellationToken = BookingAccessToken;
|
||||||
|
|
||||||
const cancellationKV = createKV<CancellationToken>("cancellation_tokens");
|
const cancellationKV = createKV<BookingAccessToken>("cancellation_tokens");
|
||||||
|
|
||||||
// Types for booking and availability
|
// Types for booking and availability
|
||||||
type Booking = {
|
type Booking = {
|
||||||
id: string;
|
id: string;
|
||||||
treatmentId: string;
|
treatments: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
duration: number;
|
||||||
|
price: number;
|
||||||
|
}>;
|
||||||
|
// Deprecated fields for backward compatibility
|
||||||
|
treatmentId?: string;
|
||||||
|
bookedDurationMinutes?: number;
|
||||||
customerName: string;
|
customerName: string;
|
||||||
customerEmail: string;
|
customerEmail?: string;
|
||||||
customerPhone: string;
|
customerPhone?: string;
|
||||||
appointmentDate: string;
|
appointmentDate: string;
|
||||||
appointmentTime: string;
|
appointmentTime: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
@@ -52,6 +68,15 @@ function formatDateGerman(dateString: string): string {
|
|||||||
return `${day}.${month}.${year}`;
|
return `${day}.${month}.${year}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper to invalidate all reschedule tokens for a specific booking
|
||||||
|
async function invalidateRescheduleTokensForBooking(bookingId: string): Promise<void> {
|
||||||
|
const tokens = await cancellationKV.getAllItems();
|
||||||
|
const related = tokens.filter(t => t.bookingId === bookingId && t.purpose === "reschedule_proposal");
|
||||||
|
for (const tok of related) {
|
||||||
|
await cancellationKV.removeItem(tok.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create cancellation token for a booking
|
// Create cancellation token for a booking
|
||||||
const createToken = os
|
const createToken = os
|
||||||
.input(z.object({ bookingId: z.string() }))
|
.input(z.object({ bookingId: z.string() }))
|
||||||
@@ -70,12 +95,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);
|
||||||
@@ -89,7 +115,8 @@ const getBookingByToken = os
|
|||||||
const tokens = await cancellationKV.getAllItems();
|
const tokens = await cancellationKV.getAllItems();
|
||||||
const validToken = tokens.find(t =>
|
const validToken = tokens.find(t =>
|
||||||
t.token === input.token &&
|
t.token === input.token &&
|
||||||
new Date(t.expiresAt) > new Date()
|
new Date(t.expiresAt) > new Date() &&
|
||||||
|
t.purpose === 'booking_access'
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!validToken) {
|
if (!validToken) {
|
||||||
@@ -101,19 +128,68 @@ const getBookingByToken = os
|
|||||||
throw new Error("Booking not found");
|
throw new Error("Booking not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get treatment details
|
// Handle treatments array
|
||||||
|
let treatments: Array<{id: string; name: string; duration: number; price: number}>;
|
||||||
|
let totalDuration: number;
|
||||||
|
let totalPrice: number;
|
||||||
|
|
||||||
|
if (booking.treatments && booking.treatments.length > 0) {
|
||||||
|
// New bookings with treatments array
|
||||||
|
treatments = booking.treatments;
|
||||||
|
totalDuration = treatments.reduce((sum, t) => sum + t.duration, 0);
|
||||||
|
totalPrice = treatments.reduce((sum, t) => sum + (t.price / 100), 0);
|
||||||
|
} else if (booking.treatmentId) {
|
||||||
|
// Old bookings with single treatmentId (backward compatibility)
|
||||||
const treatmentsKV = createKV<any>("treatments");
|
const treatmentsKV = createKV<any>("treatments");
|
||||||
const treatment = await treatmentsKV.getItem(booking.treatmentId);
|
const treatment = await treatmentsKV.getItem(booking.treatmentId);
|
||||||
|
|
||||||
|
if (treatment) {
|
||||||
|
treatments = [{
|
||||||
|
id: treatment.id,
|
||||||
|
name: treatment.name,
|
||||||
|
duration: treatment.duration,
|
||||||
|
price: treatment.price,
|
||||||
|
}];
|
||||||
|
totalDuration = treatment.duration;
|
||||||
|
totalPrice = treatment.price / 100;
|
||||||
|
} else {
|
||||||
|
// Fallback if treatment not found
|
||||||
|
treatments = [];
|
||||||
|
totalDuration = booking.bookedDurationMinutes || 60;
|
||||||
|
totalPrice = 0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Edge case: no treatments and no treatmentId
|
||||||
|
treatments = [];
|
||||||
|
totalDuration = 0;
|
||||||
|
totalPrice = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate if cancellation is still possible
|
||||||
|
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,
|
treatments,
|
||||||
treatmentName: treatment?.name || "Unbekannte Behandlung",
|
totalDuration,
|
||||||
|
totalPrice,
|
||||||
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)),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -196,4 +272,214 @@ export const router = {
|
|||||||
createToken,
|
createToken,
|
||||||
getBookingByToken,
|
getBookingByToken,
|
||||||
cancelByToken,
|
cancelByToken,
|
||||||
|
// Create a reschedule proposal token (48h expiry)
|
||||||
|
createRescheduleToken: os
|
||||||
|
.input(z.object({ bookingId: z.string(), proposedDate: z.string(), proposedTime: z.string() }))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
const booking = await bookingsKV.getItem(input.bookingId);
|
||||||
|
if (!booking) {
|
||||||
|
throw new Error("Booking not found");
|
||||||
|
}
|
||||||
|
if (booking.status === "cancelled" || booking.status === "completed") {
|
||||||
|
throw new Error("Reschedule not allowed for this booking");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate existing reschedule proposals for this booking
|
||||||
|
await invalidateRescheduleTokensForBooking(input.bookingId);
|
||||||
|
|
||||||
|
// Create token that expires in 48 hours
|
||||||
|
const expiresAt = new Date();
|
||||||
|
expiresAt.setHours(expiresAt.getHours() + 48);
|
||||||
|
|
||||||
|
const token = randomUUID();
|
||||||
|
const rescheduleToken: BookingAccessToken = {
|
||||||
|
id: randomUUID(),
|
||||||
|
bookingId: input.bookingId,
|
||||||
|
token,
|
||||||
|
expiresAt: expiresAt.toISOString(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
purpose: "reschedule_proposal",
|
||||||
|
proposedDate: input.proposedDate,
|
||||||
|
proposedTime: input.proposedTime,
|
||||||
|
originalDate: booking.appointmentDate,
|
||||||
|
originalTime: booking.appointmentTime,
|
||||||
|
};
|
||||||
|
|
||||||
|
await cancellationKV.setItem(rescheduleToken.id, rescheduleToken);
|
||||||
|
return { token, expiresAt: expiresAt.toISOString() };
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Get reschedule proposal details by token
|
||||||
|
getRescheduleProposal: os
|
||||||
|
.input(z.object({ token: z.string() }))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
const tokens = await cancellationKV.getAllItems();
|
||||||
|
const proposal = tokens.find(t => t.token === input.token && t.purpose === "reschedule_proposal");
|
||||||
|
if (!proposal) {
|
||||||
|
throw new Error("Ungültiger Reschedule-Token");
|
||||||
|
}
|
||||||
|
|
||||||
|
const booking = await bookingsKV.getItem(proposal.bookingId);
|
||||||
|
if (!booking) {
|
||||||
|
throw new Error("Booking not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle treatments array
|
||||||
|
let treatments: Array<{id: string; name: string; duration: number; price: number}>;
|
||||||
|
let totalDuration: number;
|
||||||
|
let totalPrice: number;
|
||||||
|
|
||||||
|
if (booking.treatments && booking.treatments.length > 0) {
|
||||||
|
// New bookings with treatments array
|
||||||
|
treatments = booking.treatments;
|
||||||
|
totalDuration = treatments.reduce((sum, t) => sum + t.duration, 0);
|
||||||
|
totalPrice = treatments.reduce((sum, t) => sum + (t.price / 100), 0);
|
||||||
|
} else if (booking.treatmentId) {
|
||||||
|
// Old bookings with single treatmentId (backward compatibility)
|
||||||
|
const treatmentsKV = createKV<any>("treatments");
|
||||||
|
const treatment = await treatmentsKV.getItem(booking.treatmentId);
|
||||||
|
|
||||||
|
if (treatment) {
|
||||||
|
treatments = [{
|
||||||
|
id: treatment.id,
|
||||||
|
name: treatment.name,
|
||||||
|
duration: treatment.duration,
|
||||||
|
price: treatment.price,
|
||||||
|
}];
|
||||||
|
totalDuration = treatment.duration;
|
||||||
|
totalPrice = treatment.price / 100;
|
||||||
|
} else {
|
||||||
|
// Fallback if treatment not found
|
||||||
|
treatments = [];
|
||||||
|
totalDuration = booking.bookedDurationMinutes || 60;
|
||||||
|
totalPrice = 0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Edge case: no treatments and no treatmentId
|
||||||
|
treatments = [];
|
||||||
|
totalDuration = 0;
|
||||||
|
totalPrice = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const isExpired = new Date(proposal.expiresAt) <= now;
|
||||||
|
const hoursUntilExpiry = isExpired ? 0 : Math.max(0, Math.round((new Date(proposal.expiresAt).getTime() - now.getTime()) / (1000 * 60 * 60)));
|
||||||
|
|
||||||
|
return {
|
||||||
|
booking: {
|
||||||
|
id: booking.id,
|
||||||
|
customerName: booking.customerName,
|
||||||
|
customerEmail: booking.customerEmail,
|
||||||
|
customerPhone: booking.customerPhone,
|
||||||
|
status: booking.status,
|
||||||
|
treatments,
|
||||||
|
totalDuration,
|
||||||
|
totalPrice,
|
||||||
|
},
|
||||||
|
original: {
|
||||||
|
date: proposal.originalDate || booking.appointmentDate,
|
||||||
|
time: proposal.originalTime || booking.appointmentTime,
|
||||||
|
},
|
||||||
|
proposed: {
|
||||||
|
date: proposal.proposedDate,
|
||||||
|
time: proposal.proposedTime,
|
||||||
|
},
|
||||||
|
expiresAt: proposal.expiresAt,
|
||||||
|
hoursUntilExpiry,
|
||||||
|
isExpired,
|
||||||
|
canRespond: booking.status === "confirmed" && !isExpired,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Helper endpoint to remove a reschedule token by value (used after accept/decline)
|
||||||
|
removeRescheduleToken: os
|
||||||
|
.input(z.object({ token: z.string() }))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
const tokens = await cancellationKV.getAllItems();
|
||||||
|
const proposal = tokens.find(t => t.token === input.token && t.purpose === "reschedule_proposal");
|
||||||
|
if (proposal) {
|
||||||
|
await cancellationKV.removeItem(proposal.id);
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Clean up expired reschedule proposals and notify admin
|
||||||
|
sweepExpiredRescheduleProposals: os
|
||||||
|
.handler(async () => {
|
||||||
|
const tokens = await cancellationKV.getAllItems();
|
||||||
|
const now = new Date();
|
||||||
|
const expiredProposals = tokens.filter(t =>
|
||||||
|
t.purpose === "reschedule_proposal" &&
|
||||||
|
new Date(t.expiresAt) <= now
|
||||||
|
);
|
||||||
|
|
||||||
|
if (expiredProposals.length === 0) {
|
||||||
|
return { success: true, expiredCount: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get booking details for each expired proposal
|
||||||
|
const expiredDetails: Array<{
|
||||||
|
customerName: string;
|
||||||
|
originalDate: string;
|
||||||
|
originalTime: string;
|
||||||
|
proposedDate: string;
|
||||||
|
proposedTime: string;
|
||||||
|
treatmentName: string;
|
||||||
|
customerEmail?: string;
|
||||||
|
customerPhone?: string;
|
||||||
|
expiredAt: string;
|
||||||
|
}> = [];
|
||||||
|
for (const proposal of expiredProposals) {
|
||||||
|
const booking = await bookingsKV.getItem(proposal.bookingId);
|
||||||
|
if (booking) {
|
||||||
|
const treatmentsKV = createKV<any>("treatments");
|
||||||
|
// Get treatment name(s) from new treatments array or fallback to deprecated treatmentId
|
||||||
|
let treatmentName = "Unbekannte Behandlung";
|
||||||
|
if (booking.treatments && Array.isArray(booking.treatments) && booking.treatments.length > 0) {
|
||||||
|
treatmentName = booking.treatments.map((t: any) => t.name).join(", ");
|
||||||
|
} else if (booking.treatmentId) {
|
||||||
|
const treatment = await treatmentsKV.getItem(booking.treatmentId);
|
||||||
|
treatmentName = treatment?.name || "Unbekannte Behandlung";
|
||||||
|
}
|
||||||
|
|
||||||
|
expiredDetails.push({
|
||||||
|
customerName: booking.customerName,
|
||||||
|
originalDate: proposal.originalDate || booking.appointmentDate,
|
||||||
|
originalTime: proposal.originalTime || booking.appointmentTime,
|
||||||
|
proposedDate: proposal.proposedDate!,
|
||||||
|
proposedTime: proposal.proposedTime!,
|
||||||
|
treatmentName: treatmentName,
|
||||||
|
customerEmail: booking.customerEmail,
|
||||||
|
customerPhone: booking.customerPhone,
|
||||||
|
expiredAt: proposal.expiresAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the expired token
|
||||||
|
await cancellationKV.removeItem(proposal.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify admin if there are expired proposals
|
||||||
|
if (expiredDetails.length > 0 && process.env.ADMIN_EMAIL) {
|
||||||
|
try {
|
||||||
|
const { renderAdminRescheduleExpiredHTML } = await import("../lib/email-templates.js");
|
||||||
|
const { sendEmail } = await import("../lib/email.js");
|
||||||
|
|
||||||
|
const html = await renderAdminRescheduleExpiredHTML({
|
||||||
|
expiredProposals: expiredDetails,
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendEmail({
|
||||||
|
to: process.env.ADMIN_EMAIL,
|
||||||
|
subject: `${expiredDetails.length} abgelaufene Terminänderungsvorschläge`,
|
||||||
|
text: `Es sind ${expiredDetails.length} Terminänderungsvorschläge abgelaufen. Details in der HTML-Version.`,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to send admin notification for expired proposals:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, expiredCount: expiredDetails.length };
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
150
src/server/rpc/gallery.ts
Normal file
150
src/server/rpc/gallery.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { call, os } from "@orpc/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import { createKV } from "../lib/create-kv.js";
|
||||||
|
import { assertOwner } from "../lib/auth.js";
|
||||||
|
|
||||||
|
// Schema Definition
|
||||||
|
const GalleryPhotoSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
base64Data: z.string(),
|
||||||
|
title: z.string().optional().default(""),
|
||||||
|
order: z.number().int(),
|
||||||
|
createdAt: z.string(),
|
||||||
|
cover: z.boolean().optional().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type GalleryPhoto = z.output<typeof GalleryPhotoSchema>;
|
||||||
|
|
||||||
|
// KV Storage
|
||||||
|
const galleryPhotosKV = createKV<GalleryPhoto>("galleryPhotos");
|
||||||
|
|
||||||
|
// Authentication centralized in ../lib/auth.ts
|
||||||
|
|
||||||
|
// CRUD Endpoints
|
||||||
|
const uploadPhoto = os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
sessionId: z.string(),
|
||||||
|
base64Data: z
|
||||||
|
.string()
|
||||||
|
.regex(/^data:image\/(png|jpe?g|webp|gif);base64,/i, 'Unsupported image format'),
|
||||||
|
title: z.string().optional().default(""),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
const id = randomUUID();
|
||||||
|
const existing = await galleryPhotosKV.getAllItems();
|
||||||
|
const maxOrder = existing.length > 0 ? Math.max(...existing.map((p) => p.order)) : -1;
|
||||||
|
const nextOrder = maxOrder + 1;
|
||||||
|
|
||||||
|
const photo: GalleryPhoto = {
|
||||||
|
id,
|
||||||
|
base64Data: input.base64Data,
|
||||||
|
title: input.title ?? "",
|
||||||
|
order: nextOrder,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
cover: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
await galleryPhotosKV.setItem(id, photo);
|
||||||
|
return photo;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("gallery.uploadPhoto error", err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const setCoverPhoto = os
|
||||||
|
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
const all = await galleryPhotosKV.getAllItems();
|
||||||
|
let updatedCover: GalleryPhoto | null = null;
|
||||||
|
for (const p of all) {
|
||||||
|
const isCover = p.id === input.id;
|
||||||
|
const next: GalleryPhoto = { ...p, cover: isCover };
|
||||||
|
await galleryPhotosKV.setItem(p.id, next);
|
||||||
|
if (isCover) updatedCover = next;
|
||||||
|
}
|
||||||
|
return updatedCover;
|
||||||
|
});
|
||||||
|
|
||||||
|
const deletePhoto = os
|
||||||
|
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
await galleryPhotosKV.removeItem(input.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatePhotoOrder = os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
sessionId: z.string(),
|
||||||
|
photoOrders: z.array(z.object({ id: z.string(), order: z.number().int() })),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
const updated: GalleryPhoto[] = [];
|
||||||
|
for (const { id, order } of input.photoOrders) {
|
||||||
|
const existing = await galleryPhotosKV.getItem(id);
|
||||||
|
if (!existing) continue;
|
||||||
|
const updatedPhoto: GalleryPhoto = { ...existing, order };
|
||||||
|
await galleryPhotosKV.setItem(id, updatedPhoto);
|
||||||
|
updated.push(updatedPhoto);
|
||||||
|
}
|
||||||
|
const all = await galleryPhotosKV.getAllItems();
|
||||||
|
return all.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
const listPhotos = os.handler(async () => {
|
||||||
|
const all = await galleryPhotosKV.getAllItems();
|
||||||
|
return all.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
const adminListPhotos = os
|
||||||
|
.input(z.object({ sessionId: z.string() }))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
const all = await galleryPhotosKV.getAllItems();
|
||||||
|
return all.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Live Queries
|
||||||
|
const live = {
|
||||||
|
listPhotos: os.handler(async function* ({ signal }) {
|
||||||
|
yield call(listPhotos, {}, { signal });
|
||||||
|
for await (const _ of galleryPhotosKV.subscribe()) {
|
||||||
|
yield call(listPhotos, {}, { signal });
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
adminListPhotos: os
|
||||||
|
.input(z.object({ sessionId: z.string() }))
|
||||||
|
.handler(async function* ({ input, signal }) {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
const all = await galleryPhotosKV.getAllItems();
|
||||||
|
const sorted = all.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
||||||
|
yield sorted;
|
||||||
|
for await (const _ of galleryPhotosKV.subscribe()) {
|
||||||
|
const updated = await galleryPhotosKV.getAllItems();
|
||||||
|
const sortedUpdated = updated.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
||||||
|
yield sortedUpdated;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const router = {
|
||||||
|
uploadPhoto,
|
||||||
|
deletePhoto,
|
||||||
|
updatePhotoOrder,
|
||||||
|
listPhotos,
|
||||||
|
adminListPhotos,
|
||||||
|
setCoverPhoto,
|
||||||
|
live,
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -1,17 +1,23 @@
|
|||||||
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 recurringAvailability } from "./recurring-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";
|
||||||
|
import { router as gallery } from "./gallery.js";
|
||||||
|
import { router as reviews } from "./reviews.js";
|
||||||
|
import { router as social } from "./social.js";
|
||||||
|
|
||||||
export const router = {
|
export const router = {
|
||||||
demo,
|
demo,
|
||||||
treatments,
|
treatments,
|
||||||
bookings,
|
bookings,
|
||||||
auth,
|
auth,
|
||||||
availability,
|
recurringAvailability,
|
||||||
cancellation,
|
cancellation,
|
||||||
legal,
|
legal,
|
||||||
|
gallery,
|
||||||
|
reviews,
|
||||||
|
social,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
492
src/server/rpc/recurring-availability.ts
Normal file
492
src/server/rpc/recurring-availability.ts
Normal file
@@ -0,0 +1,492 @@
|
|||||||
|
import { call, os } from "@orpc/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import { createKV } from "../lib/create-kv.js";
|
||||||
|
import { assertOwner } from "../lib/auth.js";
|
||||||
|
|
||||||
|
// Datenmodelle
|
||||||
|
const RecurringRuleSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
dayOfWeek: z.number().int().min(0).max(6), // 0=Sonntag, 1=Montag, ..., 6=Samstag
|
||||||
|
startTime: z.string().regex(/^\d{2}:\d{2}$/), // HH:MM Format
|
||||||
|
endTime: z.string().regex(/^\d{2}:\d{2}$/), // HH:MM Format
|
||||||
|
isActive: z.boolean(),
|
||||||
|
createdAt: z.string(),
|
||||||
|
// LEGACY: slotDurationMinutes - deprecated field for generateSlots only, will be removed
|
||||||
|
slotDurationMinutes: z.number().int().min(1).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const TimeOffPeriodSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), // YYYY-MM-DD
|
||||||
|
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), // YYYY-MM-DD
|
||||||
|
reason: z.string(),
|
||||||
|
createdAt: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RecurringRule = z.output<typeof RecurringRuleSchema>;
|
||||||
|
export type TimeOffPeriod = z.output<typeof TimeOffPeriodSchema>;
|
||||||
|
|
||||||
|
// KV-Stores
|
||||||
|
const recurringRulesKV = createKV<RecurringRule>("recurringRules");
|
||||||
|
const timeOffPeriodsKV = createKV<TimeOffPeriod>("timeOffPeriods");
|
||||||
|
|
||||||
|
|
||||||
|
// Import bookings and treatments KV stores for getAvailableTimes endpoint
|
||||||
|
const bookingsKV = createKV<any>("bookings");
|
||||||
|
const treatmentsKV = createKV<any>("treatments");
|
||||||
|
|
||||||
|
// Owner-Authentifizierung zentralisiert in ../lib/auth.ts
|
||||||
|
|
||||||
|
// Helper-Funktionen
|
||||||
|
function parseTime(timeStr: string): number {
|
||||||
|
const [hours, minutes] = timeStr.split(':').map(Number);
|
||||||
|
return hours * 60 + minutes; // Minuten seit Mitternacht
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(minutes: number): string {
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const mins = minutes % 60;
|
||||||
|
return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(date: Date, days: number): Date {
|
||||||
|
const result = new Date(date);
|
||||||
|
result.setDate(result.getDate() + days);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date: Date): string {
|
||||||
|
return date.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDateInTimeOffPeriod(date: string, periods: TimeOffPeriod[]): boolean {
|
||||||
|
return periods.some(period => date >= period.startDate && date <= period.endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper-Funktion zur Erkennung überlappender Regeln
|
||||||
|
function detectOverlappingRules(newRule: { dayOfWeek: number; startTime: string; endTime: string; id?: string }, existingRules: RecurringRule[]): RecurringRule[] {
|
||||||
|
const newStart = parseTime(newRule.startTime);
|
||||||
|
const newEnd = parseTime(newRule.endTime);
|
||||||
|
|
||||||
|
return existingRules.filter(rule => {
|
||||||
|
// Gleicher Wochentag und nicht dieselbe Regel (bei Updates)
|
||||||
|
if (rule.dayOfWeek !== newRule.dayOfWeek || rule.id === newRule.id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingStart = parseTime(rule.startTime);
|
||||||
|
const existingEnd = parseTime(rule.endTime);
|
||||||
|
|
||||||
|
// Überlappung wenn: neue Startzeit < bestehende Endzeit UND neue Endzeit > bestehende Startzeit
|
||||||
|
return newStart < existingEnd && newEnd > existingStart;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRUD-Endpoints für Recurring Rules
|
||||||
|
const createRule = os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
sessionId: z.string(),
|
||||||
|
dayOfWeek: z.number().int().min(0).max(6),
|
||||||
|
startTime: z.string().regex(/^\d{2}:\d{2}$/),
|
||||||
|
endTime: z.string().regex(/^\d{2}:\d{2}$/),
|
||||||
|
}).passthrough()
|
||||||
|
)
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
|
||||||
|
// Validierung: startTime < endTime
|
||||||
|
const startMinutes = parseTime(input.startTime);
|
||||||
|
const endMinutes = parseTime(input.endTime);
|
||||||
|
if (startMinutes >= endMinutes) {
|
||||||
|
throw new Error("Startzeit muss vor der Endzeit liegen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Überlappungsprüfung
|
||||||
|
const allRules = await recurringRulesKV.getAllItems();
|
||||||
|
const overlappingRules = detectOverlappingRules(input, allRules);
|
||||||
|
|
||||||
|
if (overlappingRules.length > 0) {
|
||||||
|
const overlappingTimes = overlappingRules.map(rule => `${rule.startTime}-${rule.endTime}`).join(", ");
|
||||||
|
throw new Error(`Überlappung mit bestehenden Regeln erkannt: ${overlappingTimes}. Bitte Zeitfenster anpassen.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = randomUUID();
|
||||||
|
const rule: RecurringRule = {
|
||||||
|
id,
|
||||||
|
dayOfWeek: input.dayOfWeek,
|
||||||
|
startTime: input.startTime,
|
||||||
|
endTime: input.endTime,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await recurringRulesKV.setItem(id, rule);
|
||||||
|
return rule;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("recurring-availability.createRule error", err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateRule = os
|
||||||
|
.input(RecurringRuleSchema.extend({ sessionId: z.string() }).passthrough())
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
|
||||||
|
// Validierung: startTime < endTime
|
||||||
|
const startMinutes = parseTime(input.startTime);
|
||||||
|
const endMinutes = parseTime(input.endTime);
|
||||||
|
if (startMinutes >= endMinutes) {
|
||||||
|
throw new Error("Startzeit muss vor der Endzeit liegen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Überlappungsprüfung
|
||||||
|
const allRules = await recurringRulesKV.getAllItems();
|
||||||
|
const overlappingRules = detectOverlappingRules(input, allRules);
|
||||||
|
|
||||||
|
if (overlappingRules.length > 0) {
|
||||||
|
const overlappingTimes = overlappingRules.map(rule => `${rule.startTime}-${rule.endTime}`).join(", ");
|
||||||
|
throw new Error(`Überlappung mit bestehenden Regeln erkannt: ${overlappingTimes}. Bitte Zeitfenster anpassen.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sessionId, ...rule } = input as any;
|
||||||
|
await recurringRulesKV.setItem(rule.id, rule as RecurringRule);
|
||||||
|
return rule as RecurringRule;
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteRule = os
|
||||||
|
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
await recurringRulesKV.removeItem(input.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleRuleActive = os
|
||||||
|
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
const rule = await recurringRulesKV.getItem(input.id);
|
||||||
|
if (!rule) throw new Error("Regel nicht gefunden.");
|
||||||
|
|
||||||
|
rule.isActive = !rule.isActive;
|
||||||
|
await recurringRulesKV.setItem(input.id, rule);
|
||||||
|
return rule;
|
||||||
|
});
|
||||||
|
|
||||||
|
const listRules = os.handler(async () => {
|
||||||
|
const allRules = await recurringRulesKV.getAllItems();
|
||||||
|
return allRules.sort((a, b) => {
|
||||||
|
if (a.dayOfWeek !== b.dayOfWeek) return a.dayOfWeek - b.dayOfWeek;
|
||||||
|
return a.startTime.localeCompare(b.startTime);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const adminListRules = os
|
||||||
|
.input(z.object({ sessionId: z.string() }))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
const allRules = await recurringRulesKV.getAllItems();
|
||||||
|
return allRules.sort((a, b) => {
|
||||||
|
if (a.dayOfWeek !== b.dayOfWeek) return a.dayOfWeek - b.dayOfWeek;
|
||||||
|
return a.startTime.localeCompare(b.startTime);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// CRUD-Endpoints für Time-Off Periods
|
||||||
|
const createTimeOff = os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
sessionId: z.string(),
|
||||||
|
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
reason: z.string(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
|
||||||
|
// Validierung: startDate <= endDate
|
||||||
|
if (input.startDate > input.endDate) {
|
||||||
|
throw new Error("Startdatum muss vor oder am Enddatum liegen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = randomUUID();
|
||||||
|
const timeOff: TimeOffPeriod = {
|
||||||
|
id,
|
||||||
|
startDate: input.startDate,
|
||||||
|
endDate: input.endDate,
|
||||||
|
reason: input.reason,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await timeOffPeriodsKV.setItem(id, timeOff);
|
||||||
|
return timeOff;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("recurring-availability.createTimeOff error", err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateTimeOff = os
|
||||||
|
.input(TimeOffPeriodSchema.extend({ sessionId: z.string() }).passthrough())
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
|
||||||
|
// Validierung: startDate <= endDate
|
||||||
|
if (input.startDate > input.endDate) {
|
||||||
|
throw new Error("Startdatum muss vor oder am Enddatum liegen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sessionId, ...timeOff } = input as any;
|
||||||
|
await timeOffPeriodsKV.setItem(timeOff.id, timeOff as TimeOffPeriod);
|
||||||
|
return timeOff as TimeOffPeriod;
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteTimeOff = os
|
||||||
|
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
await timeOffPeriodsKV.removeItem(input.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
const listTimeOff = os.handler(async () => {
|
||||||
|
const allTimeOff = await timeOffPeriodsKV.getAllItems();
|
||||||
|
return allTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate));
|
||||||
|
});
|
||||||
|
|
||||||
|
const adminListTimeOff = os
|
||||||
|
.input(z.object({ sessionId: z.string() }))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
const allTimeOff = await timeOffPeriodsKV.getAllItems();
|
||||||
|
return allTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate));
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Get Available Times Endpoint
|
||||||
|
const getAvailableTimes = os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
treatmentIds: z.array(z.string())
|
||||||
|
.min(1, "Mindestens eine Behandlung muss ausgewählt werden")
|
||||||
|
.max(3, "Maximal 3 Behandlungen können ausgewählt werden")
|
||||||
|
.refine(list => {
|
||||||
|
return list.length === new Set(list).size;
|
||||||
|
}, { message: "Doppelte Behandlungen sind nicht erlaubt" }),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
// Validate that the date is not in the past
|
||||||
|
const today = new Date();
|
||||||
|
const inputDate = new Date(input.date);
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
inputDate.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
if (inputDate < today) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get multiple treatments and calculate total duration
|
||||||
|
const treatments = await Promise.all(
|
||||||
|
input.treatmentIds.map(id => treatmentsKV.getItem(id))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Validate that all treatments exist
|
||||||
|
const missingTreatments = treatments
|
||||||
|
.map((t, i) => t ? null : input.treatmentIds[i])
|
||||||
|
.filter(id => id !== null);
|
||||||
|
|
||||||
|
if (missingTreatments.length > 0) {
|
||||||
|
throw new Error(`Behandlung(en) nicht gefunden: ${missingTreatments.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate total duration by summing all treatment durations
|
||||||
|
const treatmentDuration = treatments.reduce((sum, t) => sum + (t?.duration || 0), 0);
|
||||||
|
|
||||||
|
// Parse the date to get day of week
|
||||||
|
const [year, month, day] = input.date.split('-').map(Number);
|
||||||
|
const localDate = new Date(year, month - 1, day);
|
||||||
|
const dayOfWeek = localDate.getDay(); // 0=Sonntag, 1=Montag, ...
|
||||||
|
|
||||||
|
// Find matching recurring rules
|
||||||
|
const allRules = await recurringRulesKV.getAllItems();
|
||||||
|
const matchingRules = allRules.filter(rule =>
|
||||||
|
rule.isActive === true && rule.dayOfWeek === dayOfWeek
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matchingRules.length === 0) {
|
||||||
|
return []; // No rules for this day of week
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check time-off periods
|
||||||
|
const timeOffPeriods = await timeOffPeriodsKV.getAllItems();
|
||||||
|
if (isDateInTimeOffPeriod(input.date, timeOffPeriods)) {
|
||||||
|
return []; // Date is blocked by time-off period
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate 15-minute intervals with boundary alignment
|
||||||
|
const availableTimes: string[] = [];
|
||||||
|
|
||||||
|
// Helper functions for 15-minute boundary alignment
|
||||||
|
const ceilTo15 = (m: number) => m % 15 === 0 ? m : m + (15 - (m % 15));
|
||||||
|
const floorTo15 = (m: number) => m - (m % 15);
|
||||||
|
|
||||||
|
for (const rule of matchingRules) {
|
||||||
|
const startMinutes = parseTime(rule.startTime);
|
||||||
|
const endMinutes = parseTime(rule.endTime);
|
||||||
|
|
||||||
|
let currentMinutes = ceilTo15(startMinutes);
|
||||||
|
const endBound = floorTo15(endMinutes);
|
||||||
|
|
||||||
|
while (currentMinutes + treatmentDuration <= endBound) {
|
||||||
|
const timeStr = formatTime(currentMinutes);
|
||||||
|
availableTimes.push(timeStr);
|
||||||
|
currentMinutes += 15; // 15-minute intervals
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all bookings for this date and their treatments
|
||||||
|
const allBookings = await bookingsKV.getAllItems();
|
||||||
|
const dateBookings = allBookings.filter(booking =>
|
||||||
|
booking.appointmentDate === input.date &&
|
||||||
|
['pending', 'confirmed', 'completed'].includes(booking.status)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build cache only for legacy treatmentId bookings
|
||||||
|
const legacyTreatmentIds = [...new Set(dateBookings.filter(b => b.treatmentId).map(b => b.treatmentId as string))];
|
||||||
|
const treatmentDurationMap = new Map<string, number>();
|
||||||
|
|
||||||
|
// Only build cache if there are legacy bookings
|
||||||
|
if (legacyTreatmentIds.length > 0) {
|
||||||
|
for (const id of legacyTreatmentIds) {
|
||||||
|
const t = await treatmentsKV.getItem(id);
|
||||||
|
treatmentDurationMap.set(id, t?.duration || 60);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out booking conflicts
|
||||||
|
const availableTimesFiltered = availableTimes.filter(slotTime => {
|
||||||
|
const slotStartMinutes = parseTime(slotTime);
|
||||||
|
const slotEndMinutes = slotStartMinutes + treatmentDuration; // total from selected treatments
|
||||||
|
|
||||||
|
const hasConflict = dateBookings.some(booking => {
|
||||||
|
let bookingDuration: number;
|
||||||
|
if (booking.treatments && booking.treatments.length > 0) {
|
||||||
|
bookingDuration = booking.treatments.reduce((sum: number, t: { duration: number }) => sum + t.duration, 0);
|
||||||
|
} else if (booking.bookedDurationMinutes) {
|
||||||
|
bookingDuration = booking.bookedDurationMinutes;
|
||||||
|
} else if (booking.treatmentId) {
|
||||||
|
bookingDuration = treatmentDurationMap.get(booking.treatmentId) || 60;
|
||||||
|
} else {
|
||||||
|
bookingDuration = 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookingStart = parseTime(booking.appointmentTime);
|
||||||
|
const bookingEnd = bookingStart + bookingDuration;
|
||||||
|
return slotStartMinutes < bookingEnd && slotEndMinutes > bookingStart;
|
||||||
|
});
|
||||||
|
|
||||||
|
return !hasConflict;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter out past times for today
|
||||||
|
const now = new Date();
|
||||||
|
const isToday = inputDate.getTime() === today.getTime();
|
||||||
|
|
||||||
|
const finalAvailableTimes = isToday
|
||||||
|
? availableTimesFiltered.filter(timeStr => {
|
||||||
|
const slotTime = parseTime(timeStr);
|
||||||
|
const currentTime = now.getHours() * 60 + now.getMinutes();
|
||||||
|
return slotTime > currentTime;
|
||||||
|
})
|
||||||
|
: availableTimesFiltered;
|
||||||
|
|
||||||
|
// Deduplicate and sort chronologically
|
||||||
|
const unique = Array.from(new Set(finalAvailableTimes));
|
||||||
|
return unique.sort((a, b) => a.localeCompare(b));
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("recurring-availability.getAvailableTimes error", err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Live-Queries
|
||||||
|
const live = {
|
||||||
|
listRules: os.handler(async function* ({ signal }) {
|
||||||
|
yield call(listRules, {}, { signal });
|
||||||
|
for await (const _ of recurringRulesKV.subscribe()) {
|
||||||
|
yield call(listRules, {}, { signal });
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
listTimeOff: os.handler(async function* ({ signal }) {
|
||||||
|
yield call(listTimeOff, {}, { signal });
|
||||||
|
for await (const _ of timeOffPeriodsKV.subscribe()) {
|
||||||
|
yield call(listTimeOff, {}, { signal });
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
adminListRules: os
|
||||||
|
.input(z.object({ sessionId: z.string() }))
|
||||||
|
.handler(async function* ({ input, signal }) {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
const allRules = await recurringRulesKV.getAllItems();
|
||||||
|
const sortedRules = allRules.sort((a, b) => {
|
||||||
|
if (a.dayOfWeek !== b.dayOfWeek) return a.dayOfWeek - b.dayOfWeek;
|
||||||
|
return a.startTime.localeCompare(b.startTime);
|
||||||
|
});
|
||||||
|
yield sortedRules;
|
||||||
|
for await (const _ of recurringRulesKV.subscribe()) {
|
||||||
|
const updatedRules = await recurringRulesKV.getAllItems();
|
||||||
|
const sortedUpdatedRules = updatedRules.sort((a, b) => {
|
||||||
|
if (a.dayOfWeek !== b.dayOfWeek) return a.dayOfWeek - b.dayOfWeek;
|
||||||
|
return a.startTime.localeCompare(b.startTime);
|
||||||
|
});
|
||||||
|
yield sortedUpdatedRules;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
adminListTimeOff: os
|
||||||
|
.input(z.object({ sessionId: z.string() }))
|
||||||
|
.handler(async function* ({ input, signal }) {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
const allTimeOff = await timeOffPeriodsKV.getAllItems();
|
||||||
|
const sortedTimeOff = allTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate));
|
||||||
|
yield sortedTimeOff;
|
||||||
|
for await (const _ of timeOffPeriodsKV.subscribe()) {
|
||||||
|
const updatedTimeOff = await timeOffPeriodsKV.getAllItems();
|
||||||
|
const sortedUpdatedTimeOff = updatedTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate));
|
||||||
|
yield sortedUpdatedTimeOff;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const router = {
|
||||||
|
// Recurring Rules
|
||||||
|
createRule,
|
||||||
|
updateRule,
|
||||||
|
deleteRule,
|
||||||
|
toggleRuleActive,
|
||||||
|
listRules,
|
||||||
|
adminListRules,
|
||||||
|
|
||||||
|
// Time-Off Periods
|
||||||
|
createTimeOff,
|
||||||
|
updateTimeOff,
|
||||||
|
deleteTimeOff,
|
||||||
|
listTimeOff,
|
||||||
|
adminListTimeOff,
|
||||||
|
|
||||||
|
// Availability
|
||||||
|
getAvailableTimes,
|
||||||
|
|
||||||
|
// Live queries
|
||||||
|
live,
|
||||||
|
};
|
||||||
294
src/server/rpc/reviews.ts
Normal file
294
src/server/rpc/reviews.ts
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
import { call, os } from "@orpc/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import { createKV } from "../lib/create-kv.js";
|
||||||
|
import { assertOwner, sessionsKV } from "../lib/auth.js";
|
||||||
|
|
||||||
|
// Schema Definition
|
||||||
|
const ReviewSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
bookingId: z.string(),
|
||||||
|
customerName: z.string().min(2, "Kundenname muss mindestens 2 Zeichen lang sein"),
|
||||||
|
customerEmail: z.string().email("Ungültige E-Mail-Adresse").optional(),
|
||||||
|
rating: z.number().int().min(1).max(5),
|
||||||
|
comment: z.string().min(10, "Kommentar muss mindestens 10 Zeichen lang sein"),
|
||||||
|
status: z.enum(["pending", "approved", "rejected"]),
|
||||||
|
createdAt: z.string(),
|
||||||
|
reviewedAt: z.string().optional(),
|
||||||
|
reviewedBy: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Review = z.output<typeof ReviewSchema>;
|
||||||
|
|
||||||
|
// Public-safe review type for listings on the website
|
||||||
|
export type PublicReview = {
|
||||||
|
customerName: string;
|
||||||
|
rating: number;
|
||||||
|
comment: string;
|
||||||
|
status: "pending" | "approved" | "rejected";
|
||||||
|
bookingId: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// KV Storage
|
||||||
|
const reviewsKV = createKV<Review>("reviews");
|
||||||
|
|
||||||
|
// References to other KV stores needed for validation with strong typing
|
||||||
|
type BookingAccessToken = {
|
||||||
|
id: string;
|
||||||
|
bookingId: string;
|
||||||
|
token: string;
|
||||||
|
expiresAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
purpose: "booking_access" | "reschedule_proposal";
|
||||||
|
proposedDate?: string;
|
||||||
|
proposedTime?: string;
|
||||||
|
originalDate?: string;
|
||||||
|
originalTime?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Booking = {
|
||||||
|
id: string;
|
||||||
|
treatmentId: string;
|
||||||
|
customerName: string;
|
||||||
|
customerEmail?: string;
|
||||||
|
customerPhone?: string;
|
||||||
|
appointmentDate: string;
|
||||||
|
appointmentTime: string;
|
||||||
|
notes?: string;
|
||||||
|
inspirationPhoto?: string;
|
||||||
|
slotId?: string;
|
||||||
|
status: "pending" | "confirmed" | "cancelled" | "completed";
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancellationKV = createKV<BookingAccessToken>("cancellation_tokens");
|
||||||
|
const bookingsKV = createKV<Booking>("bookings");
|
||||||
|
|
||||||
|
// Helper Function: validateBookingToken
|
||||||
|
async function validateBookingToken(token: string) {
|
||||||
|
const tokens = await cancellationKV.getAllItems();
|
||||||
|
const validToken = tokens.find(t =>
|
||||||
|
t.token === token &&
|
||||||
|
new Date(t.expiresAt) > new Date() &&
|
||||||
|
t.purpose === 'booking_access'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!validToken) {
|
||||||
|
throw new Error("Ungültiger oder abgelaufener Buchungs-Token");
|
||||||
|
}
|
||||||
|
|
||||||
|
const booking = await bookingsKV.getItem(validToken.bookingId);
|
||||||
|
if (!booking) {
|
||||||
|
throw new Error("Buchung nicht gefunden");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow reviews for completed appointments
|
||||||
|
if (!(booking.status === "completed")) {
|
||||||
|
throw new Error("Bewertungen sind nur für abgeschlossene Termine möglich");
|
||||||
|
}
|
||||||
|
|
||||||
|
return booking;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public Endpoint: submitReview
|
||||||
|
const submitReview = os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
bookingToken: z.string(),
|
||||||
|
rating: z.number().int().min(1).max(5),
|
||||||
|
comment: z.string().min(10, "Kommentar muss mindestens 10 Zeichen lang sein"),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
// Validate bookingToken
|
||||||
|
const booking = await validateBookingToken(input.bookingToken);
|
||||||
|
|
||||||
|
// Enforce uniqueness by using booking.id as the KV key
|
||||||
|
const existing = await reviewsKV.getItem(booking.id);
|
||||||
|
if (existing) {
|
||||||
|
throw new Error("Für diese Buchung wurde bereits eine Bewertung abgegeben");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create review object
|
||||||
|
const review: Review = {
|
||||||
|
id: booking.id,
|
||||||
|
bookingId: booking.id,
|
||||||
|
customerName: booking.customerName,
|
||||||
|
customerEmail: booking.customerEmail,
|
||||||
|
rating: input.rating,
|
||||||
|
comment: input.comment,
|
||||||
|
status: "pending",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await reviewsKV.setItem(booking.id, review);
|
||||||
|
return review;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("reviews.submitReview error", err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Admin Endpoint: approveReview
|
||||||
|
const approveReview = os
|
||||||
|
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
|
||||||
|
const review = await reviewsKV.getItem(input.id);
|
||||||
|
if (!review) {
|
||||||
|
throw new Error("Bewertung nicht gefunden");
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await sessionsKV.getItem(input.sessionId).catch(() => undefined);
|
||||||
|
const updatedReview = {
|
||||||
|
...review,
|
||||||
|
status: "approved" as const,
|
||||||
|
reviewedAt: new Date().toISOString(),
|
||||||
|
reviewedBy: session?.userId || review.reviewedBy,
|
||||||
|
};
|
||||||
|
|
||||||
|
await reviewsKV.setItem(input.id, updatedReview);
|
||||||
|
return updatedReview;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("reviews.approveReview error", err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Admin Endpoint: rejectReview
|
||||||
|
const rejectReview = os
|
||||||
|
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
|
||||||
|
const review = await reviewsKV.getItem(input.id);
|
||||||
|
if (!review) {
|
||||||
|
throw new Error("Bewertung nicht gefunden");
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await sessionsKV.getItem(input.sessionId).catch(() => undefined);
|
||||||
|
const updatedReview = {
|
||||||
|
...review,
|
||||||
|
status: "rejected" as const,
|
||||||
|
reviewedAt: new Date().toISOString(),
|
||||||
|
reviewedBy: session?.userId || review.reviewedBy,
|
||||||
|
};
|
||||||
|
|
||||||
|
await reviewsKV.setItem(input.id, updatedReview);
|
||||||
|
return updatedReview;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("reviews.rejectReview error", err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Admin Endpoint: deleteReview
|
||||||
|
const deleteReview = os
|
||||||
|
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
await reviewsKV.removeItem(input.id);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("reviews.deleteReview error", err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Public Endpoint: listPublishedReviews
|
||||||
|
const listPublishedReviews = os.handler(async (): Promise<PublicReview[]> => {
|
||||||
|
try {
|
||||||
|
const allReviews = await reviewsKV.getAllItems();
|
||||||
|
const published = allReviews.filter(r => r.status === "approved");
|
||||||
|
const sorted = published.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||||
|
const publicSafe: PublicReview[] = sorted.map(r => ({
|
||||||
|
customerName: r.customerName,
|
||||||
|
rating: r.rating,
|
||||||
|
comment: r.comment,
|
||||||
|
status: r.status,
|
||||||
|
bookingId: r.bookingId,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
}));
|
||||||
|
return publicSafe;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("reviews.listPublishedReviews error", err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Admin Endpoint: adminListReviews
|
||||||
|
const adminListReviews = os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
sessionId: z.string(),
|
||||||
|
statusFilter: z.enum(["all", "pending", "approved", "rejected"]).optional().default("all"),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
|
||||||
|
const allReviews = await reviewsKV.getAllItems();
|
||||||
|
const filtered = input.statusFilter === "all"
|
||||||
|
? allReviews
|
||||||
|
: allReviews.filter(r => r.status === input.statusFilter);
|
||||||
|
|
||||||
|
const sorted = filtered.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||||
|
return sorted;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("reviews.adminListReviews error", err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Live Queries
|
||||||
|
const live = {
|
||||||
|
listPublishedReviews: os.handler(async function* ({ signal }) {
|
||||||
|
yield call(listPublishedReviews, {}, { signal });
|
||||||
|
for await (const _ of reviewsKV.subscribe()) {
|
||||||
|
yield call(listPublishedReviews, {}, { signal });
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
adminListReviews: os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
sessionId: z.string(),
|
||||||
|
statusFilter: z.enum(["all", "pending", "approved", "rejected"]).optional().default("all"),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.handler(async function* ({ input, signal }) {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
|
||||||
|
const allReviews = await reviewsKV.getAllItems();
|
||||||
|
const filtered = input.statusFilter === "all"
|
||||||
|
? allReviews
|
||||||
|
: allReviews.filter(r => r.status === input.statusFilter);
|
||||||
|
const sorted = filtered.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||||
|
yield sorted;
|
||||||
|
|
||||||
|
for await (const _ of reviewsKV.subscribe()) {
|
||||||
|
const updated = await reviewsKV.getAllItems();
|
||||||
|
const filteredUpdated = input.statusFilter === "all"
|
||||||
|
? updated
|
||||||
|
: updated.filter(r => r.status === input.statusFilter);
|
||||||
|
const sortedUpdated = filteredUpdated.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||||
|
yield sortedUpdated;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const router = {
|
||||||
|
submitReview,
|
||||||
|
approveReview,
|
||||||
|
rejectReview,
|
||||||
|
deleteReview,
|
||||||
|
listPublishedReviews,
|
||||||
|
adminListReviews,
|
||||||
|
live,
|
||||||
|
};
|
||||||
13
src/server/rpc/social.ts
Normal file
13
src/server/rpc/social.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { os } from "@orpc/server";
|
||||||
|
|
||||||
|
const getSocialMedia = os.handler(async () => {
|
||||||
|
return {
|
||||||
|
tiktokProfile: process.env.TIKTOK_PROFILE,
|
||||||
|
instagramProfile: process.env.INSTAGRAM_PROFILE,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const router = os.router({
|
||||||
|
getSocialMedia,
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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
14
start.sh
Normal 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
|
||||||
22
tsconfig.server.build.json
Normal file
22
tsconfig.server.build.json
Normal 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
19
tsconfig.server.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -12,15 +12,22 @@ 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",
|
||||||
|
manifest: true,
|
||||||
|
rollupOptions: {
|
||||||
|
input: "index.html"
|
||||||
|
}
|
||||||
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
tsconfigPaths(),
|
tsconfigPaths(),
|
||||||
react(),
|
react(),
|
||||||
@@ -30,8 +37,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"],
|
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user