Compare commits
53 Commits
8ee2a2b3b6
...
6e826922f6
Author | SHA1 | Date | |
---|---|---|---|
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 |
49
Caddyfile
Normal file
49
Caddyfile
Normal file
@@ -0,0 +1,49 @@
|
||||
# Caddyfile für Stargirlnails Kiel
|
||||
# Automatisches SSL mit Let's Encrypt
|
||||
|
||||
stargirlnails.de {
|
||||
# Reverse Proxy zur Anwendung
|
||||
reverse_proxy stargirlnails:3000 {
|
||||
# Health Check
|
||||
health_uri /health
|
||||
health_interval 30s
|
||||
health_timeout 5s
|
||||
}
|
||||
|
||||
# Sicherheits-Header
|
||||
header {
|
||||
# Sicherheits-Header
|
||||
X-Frame-Options "SAMEORIGIN"
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-XSS-Protection "1; mode=block"
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self';"
|
||||
|
||||
# HSTS (wird automatisch von Caddy gesetzt)
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||
}
|
||||
|
||||
# Gzip-Kompression
|
||||
encode gzip
|
||||
|
||||
# Logging
|
||||
log {
|
||||
output file /var/log/caddy/access.log
|
||||
format json
|
||||
}
|
||||
}
|
||||
|
||||
# HTTP zu HTTPS Redirect (automatisch von Caddy)
|
||||
http://stargirlnails.de {
|
||||
redir https://stargirlnails.de{uri} permanent
|
||||
}
|
||||
|
||||
# Favicon-Konfiguration
|
||||
favicon.ico {
|
||||
redir /favicon.png 301
|
||||
}
|
||||
|
||||
favicon.png {
|
||||
root * /app/dist
|
||||
file favicon.png
|
||||
}
|
22
Dockerfile
22
Dockerfile
@@ -22,8 +22,8 @@ RUN pnpm build
|
||||
# Production stage
|
||||
FROM node:22-alpine AS production
|
||||
|
||||
# Install pnpm
|
||||
RUN npm install -g pnpm
|
||||
# Install pnpm and su-exec
|
||||
RUN npm install -g pnpm ts-node && apk add --no-cache su-exec
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
@@ -36,20 +36,30 @@ RUN pnpm install --frozen-lockfile --prod
|
||||
|
||||
# Copy built application from base stage
|
||||
COPY --from=base /app/dist ./dist
|
||||
COPY --from=base /app/server-dist ./server-dist
|
||||
COPY --from=base /app/public ./public
|
||||
|
||||
# Copy necessary files for runtime
|
||||
COPY --from=base /app/src/server/index.ts ./src/server/index.ts
|
||||
COPY --from=base /app/src/server/routes ./src/server/routes
|
||||
COPY --from=base /app/src/server/rpc ./src/server/rpc
|
||||
COPY --from=base /app/src/server/lib ./src/server/lib
|
||||
COPY --from=base /app/tsconfig.server.json ./tsconfig.server.json
|
||||
COPY start.sh ./start.sh
|
||||
|
||||
# Create non-root user for security
|
||||
RUN addgroup -g 1001 -S nodejs
|
||||
RUN adduser -S nextjs -u 1001
|
||||
|
||||
# Change ownership of the app directory
|
||||
# Make start script executable
|
||||
RUN chmod +x /app/start.sh
|
||||
|
||||
# Change ownership of the app directory (but keep root for .storage)
|
||||
RUN chown -R nextjs:nodejs /app
|
||||
USER nextjs
|
||||
RUN chown root:root /app/.storage 2>/dev/null || true
|
||||
|
||||
# Don't switch to nextjs user here - the start script will handle it
|
||||
# USER nextjs
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
@@ -58,5 +68,5 @@ EXPOSE 3000
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:3000/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) })" || exit 1
|
||||
|
||||
# Start the application
|
||||
CMD ["node", "--loader", "ts-node/esm", "src/server/index.ts"]
|
||||
# Start the application with startup script
|
||||
CMD ["/app/start.sh"]
|
||||
|
18
README.md
18
README.md
@@ -154,25 +154,21 @@ docker-compose down
|
||||
|
||||
### Produktions-Deployment
|
||||
|
||||
Für den produktiven Einsatz:
|
||||
Für den produktiven Einsatz mit automatischem SSL:
|
||||
|
||||
```bash
|
||||
# Mit Docker Compose
|
||||
docker-compose -f docker-compose.yml up -d
|
||||
# Mit Docker Compose (empfohlen)
|
||||
docker-compose -f docker-compose-prod.yml up -d
|
||||
|
||||
# Oder direkt mit Docker
|
||||
docker run -d \
|
||||
--name stargirlnails-prod \
|
||||
-p 80:3000 \
|
||||
--restart unless-stopped \
|
||||
--env-file .env.production \
|
||||
stargirlnails-booking
|
||||
# Oder mit dem Caddy-Setup-Script
|
||||
chmod +x scripts/setup-caddy.sh
|
||||
./scripts/setup-caddy.sh
|
||||
```
|
||||
|
||||
**Wichtige Produktions-Hinweise:**
|
||||
- Verwende eine `.env.production` Datei mit Produktions-Konfiguration
|
||||
- Setze `NODE_ENV=production` in der Umgebungsdatei
|
||||
- Verwende einen Reverse Proxy (nginx, Traefik) für HTTPS
|
||||
- **Automatisches SSL**: Caddy erstellt und verwaltet automatisch Let's Encrypt-Zertifikate
|
||||
- Überwache Container mit Health Checks
|
||||
- **Persistente Daten**: Der `.storage` Ordner wird als Volume gemountet, um Buchungen und Einstellungen zu erhalten
|
||||
|
||||
|
55
docker-compose-prod.yml
Normal file
55
docker-compose-prod.yml
Normal file
@@ -0,0 +1,55 @@
|
||||
# Production Docker Compose für Stargirlnails Kiel
|
||||
# Mit Nginx Reverse Proxy und Let's Encrypt SSL-Zertifikaten
|
||||
|
||||
services:
|
||||
# Hauptanwendung
|
||||
stargirlnails:
|
||||
build: .
|
||||
container_name: stargirlnails-app
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- storage-data:/app/.storage
|
||||
networks:
|
||||
- stargirlnails-network
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
# Keine Abhängigkeit zu nginx, um Dependency-Zyklen zu vermeiden
|
||||
|
||||
# Caddy Reverse Proxy mit automatischem SSL
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
container_name: stargirlnails-caddy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy-data:/data
|
||||
- caddy-config:/config
|
||||
networks:
|
||||
- stargirlnails-network
|
||||
depends_on:
|
||||
- stargirlnails
|
||||
|
||||
# Volumes für persistente Daten
|
||||
volumes:
|
||||
storage-data:
|
||||
driver: local
|
||||
caddy-data:
|
||||
driver: local
|
||||
caddy-config:
|
||||
driver: local
|
||||
|
||||
# Netzwerk für interne Kommunikation
|
||||
networks:
|
||||
stargirlnails-network:
|
||||
driver: bridge
|
@@ -9,10 +9,10 @@
|
||||
|
||||
### Sicherheit & Qualität
|
||||
- ~~Rate‑Limiting (IP/E‑Mail) für Formularspam~~
|
||||
- E‑Mail‑Verifizierung (Double‑Opt‑In) optional
|
||||
- ~~E‑Mail‑Verifizierung (Double‑Opt‑In) optional~~
|
||||
- Audit‑Log (wer/was/wann)
|
||||
- DSGVO: Einwilligungstexte, Löschkonzept
|
||||
- Impressum
|
||||
- ~~DSGVO: Einwilligungstexte, Löschkonzept~~
|
||||
- ~~Impressum~~
|
||||
|
||||
### E‑Mail & Infrastruktur
|
||||
- Retry/Backoff + Fallback‑Queue bei Resend‑Fehlern
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
### UX/UI
|
||||
- ~~Mobiler Kalender mit klarer Slot‑Visualisierung~~
|
||||
- Kunden‑Statusseite (pending/confirmed)
|
||||
- ~~Kunden‑Statusseite (pending/confirmed)~~
|
||||
- Prominente Fehlerzustände inkl. Hinweise bei Doppelbuchung
|
||||
|
||||
### 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`.
|
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)
|
||||
```
|
||||
|
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Stargirlnails Kiel - Terminbuchung</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/client/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
@@ -6,12 +6,13 @@
|
||||
"scripts": {
|
||||
"check:types": "tsc --noEmit",
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"build": "tsc -b && vite build && tsc -p tsconfig.server.build.json",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.5",
|
||||
"@orpc/client": "^1.8.8",
|
||||
"@orpc/server": "^1.8.8",
|
||||
"@orpc/tanstack-query": "^1.8.8",
|
||||
@@ -24,6 +25,7 @@
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"tailwindcss": "^4",
|
||||
"ts-node": "^10.9.2",
|
||||
"unstorage": "^1.16.1",
|
||||
"zod": "^4.0.17"
|
||||
},
|
||||
|
122
pnpm-lock.yaml
generated
122
pnpm-lock.yaml
generated
@@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@hono/node-server':
|
||||
specifier: ^1.19.5
|
||||
version: 1.19.5(hono@4.9.4)
|
||||
'@orpc/client':
|
||||
specifier: ^1.8.8
|
||||
version: 1.8.8(@opentelemetry/api@1.9.0)
|
||||
@@ -44,6 +47,9 @@ importers:
|
||||
tailwindcss:
|
||||
specifier: ^4
|
||||
version: 4.1.12
|
||||
ts-node:
|
||||
specifier: ^10.9.2
|
||||
version: 10.9.2(@types/node@22.17.2)(typescript@5.9.2)
|
||||
unstorage:
|
||||
specifier: ^1.16.1
|
||||
version: 1.17.0
|
||||
@@ -217,6 +223,10 @@ packages:
|
||||
resolution: {integrity: sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@cspotcode/source-map-support@0.8.1':
|
||||
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@esbuild/aix-ppc64@0.25.5':
|
||||
resolution: {integrity: sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -410,8 +420,8 @@ packages:
|
||||
resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@hono/node-server@1.14.4':
|
||||
resolution: {integrity: sha512-DnxpshhYewr2q9ZN8ez/M5mmc3sucr8CT1sIgIy1bkeUXut9XWDkqHoFHRhWIQgkYnKpVRxunyhK7WzpJeJ6qQ==}
|
||||
'@hono/node-server@1.19.5':
|
||||
resolution: {integrity: sha512-iBuhh+uaaggeAuf+TftcjZyWh2GEgZcVGXkNtskLVoWaXhnJtC5HLHrU8W1KHDoucqO1MswwglmkWLFyiDn4WQ==}
|
||||
engines: {node: '>=18.14.1'}
|
||||
peerDependencies:
|
||||
hono: ^4
|
||||
@@ -469,6 +479,9 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.30':
|
||||
resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.9':
|
||||
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -738,6 +751,18 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^18 || ^19
|
||||
|
||||
'@tsconfig/node10@1.0.11':
|
||||
resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==}
|
||||
|
||||
'@tsconfig/node12@1.0.11':
|
||||
resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==}
|
||||
|
||||
'@tsconfig/node14@1.0.3':
|
||||
resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==}
|
||||
|
||||
'@tsconfig/node16@1.0.4':
|
||||
resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==}
|
||||
|
||||
'@types/babel__core@7.20.5':
|
||||
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
|
||||
|
||||
@@ -837,6 +862,10 @@ packages:
|
||||
peerDependencies:
|
||||
acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
|
||||
acorn-walk@8.3.4:
|
||||
resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
|
||||
acorn@8.15.0:
|
||||
resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
@@ -853,6 +882,9 @@ packages:
|
||||
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
arg@4.1.3:
|
||||
resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==}
|
||||
|
||||
argparse@2.0.1:
|
||||
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||
|
||||
@@ -913,6 +945,9 @@ packages:
|
||||
resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
create-require@1.1.1:
|
||||
resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -945,6 +980,10 @@ packages:
|
||||
resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
diff@4.0.2:
|
||||
resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==}
|
||||
engines: {node: '>=0.3.1'}
|
||||
|
||||
dotenv@17.2.3:
|
||||
resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -1273,6 +1312,9 @@ packages:
|
||||
magic-string@0.30.17:
|
||||
resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
|
||||
|
||||
make-error@1.3.6:
|
||||
resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
|
||||
|
||||
merge2@1.4.1:
|
||||
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -1490,6 +1532,20 @@ packages:
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4'
|
||||
|
||||
ts-node@10.9.2:
|
||||
resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@swc/core': '>=1.2.50'
|
||||
'@swc/wasm': '>=1.2.50'
|
||||
'@types/node': '*'
|
||||
typescript: '>=2.7'
|
||||
peerDependenciesMeta:
|
||||
'@swc/core':
|
||||
optional: true
|
||||
'@swc/wasm':
|
||||
optional: true
|
||||
|
||||
tsconfck@3.1.6:
|
||||
resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==}
|
||||
engines: {node: ^18 || >=20}
|
||||
@@ -1600,6 +1656,9 @@ packages:
|
||||
uri-js@4.4.1:
|
||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
||||
|
||||
v8-compile-cache-lib@3.0.1:
|
||||
resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==}
|
||||
|
||||
vite-tsconfig-paths@5.1.4:
|
||||
resolution: {integrity: sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==}
|
||||
peerDependencies:
|
||||
@@ -1664,6 +1723,10 @@ packages:
|
||||
resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
yn@3.1.1:
|
||||
resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
yocto-queue@0.1.0:
|
||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1851,6 +1914,10 @@ snapshots:
|
||||
'@babel/helper-string-parser': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.27.1
|
||||
|
||||
'@cspotcode/source-map-support@0.8.1':
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.9
|
||||
|
||||
'@esbuild/aix-ppc64@0.25.5':
|
||||
optional: true
|
||||
|
||||
@@ -1972,13 +2039,13 @@ snapshots:
|
||||
'@eslint/core': 0.15.2
|
||||
levn: 0.4.1
|
||||
|
||||
'@hono/node-server@1.14.4(hono@4.9.4)':
|
||||
'@hono/node-server@1.19.5(hono@4.9.4)':
|
||||
dependencies:
|
||||
hono: 4.9.4
|
||||
|
||||
'@hono/vite-dev-server@0.20.1(hono@4.9.4)':
|
||||
dependencies:
|
||||
'@hono/node-server': 1.14.4(hono@4.9.4)
|
||||
'@hono/node-server': 1.19.5(hono@4.9.4)
|
||||
hono: 4.9.4
|
||||
minimatch: 9.0.5
|
||||
|
||||
@@ -2018,6 +2085,11 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.0
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.9':
|
||||
dependencies:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.0
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
dependencies:
|
||||
'@nodelib/fs.stat': 2.0.5
|
||||
@@ -2264,6 +2336,14 @@ snapshots:
|
||||
'@tanstack/query-core': 5.85.5
|
||||
react: 19.1.1
|
||||
|
||||
'@tsconfig/node10@1.0.11': {}
|
||||
|
||||
'@tsconfig/node12@1.0.11': {}
|
||||
|
||||
'@tsconfig/node14@1.0.3': {}
|
||||
|
||||
'@tsconfig/node16@1.0.4': {}
|
||||
|
||||
'@types/babel__core@7.20.5':
|
||||
dependencies:
|
||||
'@babel/parser': 7.28.3
|
||||
@@ -2410,6 +2490,10 @@ snapshots:
|
||||
dependencies:
|
||||
acorn: 8.15.0
|
||||
|
||||
acorn-walk@8.3.4:
|
||||
dependencies:
|
||||
acorn: 8.15.0
|
||||
|
||||
acorn@8.15.0: {}
|
||||
|
||||
ajv@6.12.6:
|
||||
@@ -2428,6 +2512,8 @@ snapshots:
|
||||
normalize-path: 3.0.0
|
||||
picomatch: 2.3.1
|
||||
|
||||
arg@4.1.3: {}
|
||||
|
||||
argparse@2.0.1: {}
|
||||
|
||||
balanced-match@1.0.2: {}
|
||||
@@ -2481,6 +2567,8 @@ snapshots:
|
||||
|
||||
cookie@1.0.2: {}
|
||||
|
||||
create-require@1.1.1: {}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
@@ -2505,6 +2593,8 @@ snapshots:
|
||||
|
||||
detect-libc@2.0.4: {}
|
||||
|
||||
diff@4.0.2: {}
|
||||
|
||||
dotenv@17.2.3: {}
|
||||
|
||||
electron-to-chromium@1.5.171: {}
|
||||
@@ -2831,6 +2921,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.0
|
||||
|
||||
make-error@1.3.6: {}
|
||||
|
||||
merge2@1.4.1: {}
|
||||
|
||||
micromatch@4.0.8:
|
||||
@@ -3018,6 +3110,24 @@ snapshots:
|
||||
dependencies:
|
||||
typescript: 5.9.2
|
||||
|
||||
ts-node@10.9.2(@types/node@22.17.2)(typescript@5.9.2):
|
||||
dependencies:
|
||||
'@cspotcode/source-map-support': 0.8.1
|
||||
'@tsconfig/node10': 1.0.11
|
||||
'@tsconfig/node12': 1.0.11
|
||||
'@tsconfig/node14': 1.0.3
|
||||
'@tsconfig/node16': 1.0.4
|
||||
'@types/node': 22.17.2
|
||||
acorn: 8.15.0
|
||||
acorn-walk: 8.3.4
|
||||
arg: 4.1.3
|
||||
create-require: 1.1.1
|
||||
diff: 4.0.2
|
||||
make-error: 1.3.6
|
||||
typescript: 5.9.2
|
||||
v8-compile-cache-lib: 3.0.1
|
||||
yn: 3.1.1
|
||||
|
||||
tsconfck@3.1.6(typescript@5.9.2):
|
||||
optionalDependencies:
|
||||
typescript: 5.9.2
|
||||
@@ -3068,6 +3178,8 @@ snapshots:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
v8-compile-cache-lib@3.0.1: {}
|
||||
|
||||
vite-tsconfig-paths@5.1.4(typescript@5.9.2)(vite@7.1.3(@types/node@22.17.2)(jiti@2.5.1)(lightningcss@1.30.1)):
|
||||
dependencies:
|
||||
debug: 4.4.1
|
||||
@@ -3103,6 +3215,8 @@ snapshots:
|
||||
|
||||
yallist@5.0.0: {}
|
||||
|
||||
yn@3.1.1: {}
|
||||
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
zod-validation-error@3.5.3(zod@3.25.76):
|
||||
|
6
scripts/rebuild-prod.sh
Normal file
6
scripts/rebuild-prod.sh
Normal file
@@ -0,0 +1,6 @@
|
||||
#! /bin/bash
|
||||
sudo docker compose -f docker-compose-prod.yml down
|
||||
git pull
|
||||
sudo docker compose -f docker-compose-prod.yml build --no-cache
|
||||
sudo docker compose -f docker-compose-prod.yml up -d
|
||||
sudo docker compose -f docker-compose-prod.yml logs -f stargirlnails
|
103
scripts/setup-caddy.sh
Normal file
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}"
|
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { useAuth } from "@/client/components/auth-provider";
|
||||
import { LoginForm } from "@/client/components/login-form";
|
||||
import { UserProfile } from "@/client/components/user-profile";
|
||||
@@ -8,32 +8,19 @@ import { AdminBookings } from "@/client/components/admin-bookings";
|
||||
import { AdminCalendar } from "@/client/components/admin-calendar";
|
||||
import { InitialDataLoader } from "@/client/components/initial-data-loader";
|
||||
import { AdminAvailability } from "@/client/components/admin-availability";
|
||||
import CancellationPage from "@/client/components/cancellation-page";
|
||||
import BookingStatusPage from "@/client/components/booking-status-page";
|
||||
import LegalPage from "@/client/components/legal-page";
|
||||
|
||||
function App() {
|
||||
const { user, isLoading, isOwner } = useAuth();
|
||||
const [activeTab, setActiveTab] = useState<"booking" | "admin-treatments" | "admin-bookings" | "admin-calendar" | "admin-availability" | "profile" | "legal">("booking");
|
||||
|
||||
// Check for cancellation token in URL
|
||||
useEffect(() => {
|
||||
const path = window.location.pathname;
|
||||
if (path.startsWith('/cancel/')) {
|
||||
const token = path.split('/cancel/')[1];
|
||||
if (token) {
|
||||
// Set a special state to show cancellation page
|
||||
setActiveTab("cancellation" as any);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle cancellation page
|
||||
// Handle booking status page
|
||||
const path = window.location.pathname;
|
||||
if (path.startsWith('/cancel/')) {
|
||||
const token = path.split('/cancel/')[1];
|
||||
if (path.startsWith('/booking/')) {
|
||||
const token = path.split('/booking/')[1];
|
||||
if (token) {
|
||||
return <CancellationPage token={token} />;
|
||||
return <BookingStatusPage token={token} />;
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -69,8 +69,8 @@ export function AdminCalendar() {
|
||||
const startDate = new Date(firstDay);
|
||||
startDate.setDate(startDate.getDate() - firstDay.getDay());
|
||||
|
||||
const calendarDays = [];
|
||||
const currentDate = new Date(startDate);
|
||||
const calendarDays: Date[] = [];
|
||||
const currentDate: Date = new Date(startDate);
|
||||
|
||||
for (let i = 0; i < 42; i++) {
|
||||
calendarDays.push(new Date(currentDate));
|
||||
|
378
src/client/components/booking-status-page.tsx
Normal file
378
src/client/components/booking-status-page.tsx
Normal file
@@ -0,0 +1,378 @@
|
||||
import React, { useState } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { queryClient } from "@/client/rpc-client";
|
||||
|
||||
interface BookingStatusPageProps {
|
||||
token: string;
|
||||
}
|
||||
|
||||
type BookingStatus = "pending" | "confirmed" | "cancelled" | "completed";
|
||||
|
||||
function getStatusInfo(status: BookingStatus) {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return {
|
||||
label: "Wartet auf Bestätigung",
|
||||
color: "yellow",
|
||||
icon: "⏳",
|
||||
bgColor: "bg-yellow-50",
|
||||
borderColor: "border-yellow-200",
|
||||
textColor: "text-yellow-800",
|
||||
badgeColor: "bg-yellow-100 text-yellow-800",
|
||||
};
|
||||
case "confirmed":
|
||||
return {
|
||||
label: "Bestätigt",
|
||||
color: "green",
|
||||
icon: "✓",
|
||||
bgColor: "bg-green-50",
|
||||
borderColor: "border-green-200",
|
||||
textColor: "text-green-800",
|
||||
badgeColor: "bg-green-100 text-green-800",
|
||||
};
|
||||
case "cancelled":
|
||||
return {
|
||||
label: "Storniert",
|
||||
color: "red",
|
||||
icon: "✕",
|
||||
bgColor: "bg-red-50",
|
||||
borderColor: "border-red-200",
|
||||
textColor: "text-red-800",
|
||||
badgeColor: "bg-red-100 text-red-800",
|
||||
};
|
||||
case "completed":
|
||||
return {
|
||||
label: "Abgeschlossen",
|
||||
color: "gray",
|
||||
icon: "✓",
|
||||
bgColor: "bg-gray-50",
|
||||
borderColor: "border-gray-200",
|
||||
textColor: "text-gray-800",
|
||||
badgeColor: "bg-gray-100 text-gray-800",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default function BookingStatusPage({ token }: BookingStatusPageProps) {
|
||||
const [showCancelConfirm, setShowCancelConfirm] = useState(false);
|
||||
const [isCancelling, setIsCancelling] = useState(false);
|
||||
const [cancellationResult, setCancellationResult] = useState<{ success: boolean; message: string; formattedDate?: string } | null>(null);
|
||||
|
||||
// Fetch booking details
|
||||
const { data: booking, isLoading, error, refetch } = useQuery(
|
||||
queryClient.cancellation.getBookingByToken.queryOptions({ input: { token } })
|
||||
);
|
||||
|
||||
// Cancellation mutation
|
||||
const cancelMutation = useMutation({
|
||||
...queryClient.cancellation.cancelByToken.mutationOptions(),
|
||||
onSuccess: (result: any) => {
|
||||
setCancellationResult({
|
||||
success: true,
|
||||
message: result.message,
|
||||
formattedDate: result.formattedDate,
|
||||
});
|
||||
setIsCancelling(false);
|
||||
setShowCancelConfirm(false);
|
||||
refetch(); // Refresh booking data
|
||||
},
|
||||
onError: (error: any) => {
|
||||
setCancellationResult({
|
||||
success: false,
|
||||
message: error?.message || "Ein Fehler ist aufgetreten.",
|
||||
});
|
||||
setIsCancelling(false);
|
||||
},
|
||||
});
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsCancelling(true);
|
||||
setCancellationResult(null);
|
||||
cancelMutation.mutate({ token });
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white p-8 rounded-lg shadow-lg max-w-2xl w-full">
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-pink-500"></div>
|
||||
<span className="ml-3 text-gray-600">Buchung wird geladen...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-2">Fehler</h2>
|
||||
<p className="text-gray-600 mb-4">
|
||||
{error?.message || "Der Link ist ungültig oder abgelaufen."}
|
||||
</p>
|
||||
<a
|
||||
href="/"
|
||||
className="inline-flex items-center px-4 py-2 bg-pink-600 text-white rounded-lg hover:bg-pink-700 transition-colors"
|
||||
>
|
||||
Zur Startseite
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!booking) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full">
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-2">Buchung nicht gefunden</h2>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Die angeforderte Buchung konnte nicht gefunden werden.
|
||||
</p>
|
||||
<a
|
||||
href="/"
|
||||
className="inline-flex items-center px-4 py-2 bg-pink-600 text-white rounded-lg hover:bg-pink-700 transition-colors"
|
||||
>
|
||||
Zur Startseite
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const statusInfo = getStatusInfo(booking.status);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 py-8 px-4">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<img
|
||||
src="/assets/stargilnails_logo_transparent_112.png"
|
||||
alt="Stargil Nails Logo"
|
||||
className="w-16 h-16 object-contain"
|
||||
/>
|
||||
<span className={`px-4 py-2 rounded-full text-sm font-semibold ${statusInfo.badgeColor}`}>
|
||||
{statusInfo.icon} {statusInfo.label}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Buchungsübersicht</h1>
|
||||
<p className="text-gray-600 mt-1">Hier findest du alle Details zu deinem Termin</p>
|
||||
</div>
|
||||
|
||||
{/* Cancellation Result */}
|
||||
{cancellationResult && (
|
||||
<div className={`mb-6 p-4 rounded-lg ${
|
||||
cancellationResult.success ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'
|
||||
}`}>
|
||||
<p className={cancellationResult.success ? 'text-green-800' : 'text-red-800'}>
|
||||
{cancellationResult.message}
|
||||
{cancellationResult.formattedDate && (
|
||||
<><br />Stornierter Termin: {cancellationResult.formattedDate}</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Banner */}
|
||||
<div className={`${statusInfo.bgColor} border ${statusInfo.borderColor} rounded-lg p-6 mb-6`}>
|
||||
<div className="flex items-start">
|
||||
<div className={`text-4xl mr-4 ${statusInfo.textColor}`}>{statusInfo.icon}</div>
|
||||
<div className="flex-1">
|
||||
<h2 className={`text-xl font-bold ${statusInfo.textColor} mb-2`}>
|
||||
Status: {statusInfo.label}
|
||||
</h2>
|
||||
{booking.status === "pending" && (
|
||||
<p className={statusInfo.textColor}>
|
||||
Wir haben deine Terminanfrage erhalten und werden sie in Kürze prüfen. Du erhältst eine E-Mail, sobald dein Termin bestätigt wurde.
|
||||
</p>
|
||||
)}
|
||||
{booking.status === "confirmed" && (
|
||||
<p className={statusInfo.textColor}>
|
||||
Dein Termin wurde bestätigt! Wir freuen uns auf dich. Du hast eine Bestätigungs-E-Mail mit Kalendereintrag erhalten.
|
||||
</p>
|
||||
)}
|
||||
{booking.status === "cancelled" && (
|
||||
<p className={statusInfo.textColor}>
|
||||
Dieser Termin wurde storniert. Du kannst jederzeit einen neuen Termin buchen.
|
||||
</p>
|
||||
)}
|
||||
{booking.status === "completed" && (
|
||||
<p className={statusInfo.textColor}>
|
||||
Dieser Termin wurde erfolgreich abgeschlossen. Vielen Dank für deinen Besuch!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Appointment Details */}
|
||||
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
|
||||
<svg className="w-5 h-5 mr-2 text-pink-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Termin-Details
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between py-2 border-b border-gray-100">
|
||||
<span className="text-gray-600">Datum:</span>
|
||||
<span className="font-medium text-gray-900">{booking.formattedDate}</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2 border-b border-gray-100">
|
||||
<span className="text-gray-600">Uhrzeit:</span>
|
||||
<span className="font-medium text-gray-900">{booking.appointmentTime} Uhr</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2 border-b border-gray-100">
|
||||
<span className="text-gray-600">Behandlung:</span>
|
||||
<span className="font-medium text-gray-900">{booking.treatmentName}</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2 border-b border-gray-100">
|
||||
<span className="text-gray-600">Dauer:</span>
|
||||
<span className="font-medium text-gray-900">{booking.treatmentDuration} Minuten</span>
|
||||
</div>
|
||||
{booking.treatmentPrice > 0 && (
|
||||
<div className="flex justify-between py-2 border-b border-gray-100">
|
||||
<span className="text-gray-600">Preis:</span>
|
||||
<span className="font-medium text-gray-900">{booking.treatmentPrice.toFixed(2)} €</span>
|
||||
</div>
|
||||
)}
|
||||
{booking.hoursUntilAppointment > 0 && booking.status !== "cancelled" && booking.status !== "completed" && (
|
||||
<div className="flex justify-between py-2">
|
||||
<span className="text-gray-600">Verbleibende Zeit:</span>
|
||||
<span className="font-medium text-pink-600">
|
||||
{booking.hoursUntilAppointment} Stunde{booking.hoursUntilAppointment !== 1 ? 'n' : ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Customer Details */}
|
||||
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
|
||||
<svg className="w-5 h-5 mr-2 text-pink-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
Deine Daten
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between py-2 border-b border-gray-100">
|
||||
<span className="text-gray-600">Name:</span>
|
||||
<span className="font-medium text-gray-900">{booking.customerName}</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2 border-b border-gray-100">
|
||||
<span className="text-gray-600">E-Mail:</span>
|
||||
<span className="font-medium text-gray-900">{booking.customerEmail}</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2">
|
||||
<span className="text-gray-600">Telefon:</span>
|
||||
<span className="font-medium text-gray-900">{booking.customerPhone}</span>
|
||||
</div>
|
||||
</div>
|
||||
{booking.notes && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-2">Notizen:</h3>
|
||||
<p className="text-gray-600 text-sm">{booking.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cancellation Section */}
|
||||
{booking.canCancel && !cancellationResult?.success && (
|
||||
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
|
||||
<svg className="w-5 h-5 mr-2 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Termin stornieren
|
||||
</h2>
|
||||
|
||||
{!showCancelConfirm ? (
|
||||
<div>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Du kannst diesen Termin noch bis {parseInt(process.env.MIN_STORNO_TIMESPAN || "24")} Stunden vor dem Termin kostenlos stornieren.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowCancelConfirm(true)}
|
||||
className="w-full bg-red-600 text-white py-3 px-4 rounded-lg font-medium hover:bg-red-700 transition-colors flex items-center justify-center"
|
||||
>
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Termin stornieren
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
|
||||
<p className="text-red-800 font-semibold mb-2">Bist du sicher?</p>
|
||||
<p className="text-red-700 text-sm">
|
||||
Diese Aktion kann nicht rückgängig gemacht werden. Der Termin wird storniert und der Slot wird wieder für andere Kunden verfügbar.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
disabled={isCancelling}
|
||||
className="flex-1 bg-red-600 text-white py-3 px-4 rounded-lg font-medium hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center"
|
||||
>
|
||||
{isCancelling ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Storniere...
|
||||
</>
|
||||
) : (
|
||||
<>Ja, stornieren</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCancelConfirm(false)}
|
||||
disabled={isCancelling}
|
||||
className="flex-1 bg-gray-200 text-gray-800 py-3 px-4 rounded-lg font-medium hover:bg-gray-300 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!booking.canCancel && booking.status !== "cancelled" && booking.status !== "completed" && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
|
||||
<p className="text-yellow-800 text-sm">
|
||||
<strong>ℹ️ Stornierungsfrist abgelaufen:</strong> Dieser Termin liegt weniger als {parseInt(process.env.MIN_STORNO_TIMESPAN || "24")} Stunden in der Zukunft und kann nicht mehr online storniert werden. Bitte kontaktiere uns direkt.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="text-center">
|
||||
<a
|
||||
href="/"
|
||||
className="inline-flex items-center text-pink-600 hover:text-pink-700 font-medium"
|
||||
>
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Zurück zur Startseite
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -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,7 +1,9 @@
|
||||
import { Hono } from "hono";
|
||||
import { serve } from '@hono/node-server';
|
||||
import { serveStatic } from '@hono/node-server/serve-static';
|
||||
|
||||
import { rpcApp } from "./routes/rpc";
|
||||
import { clientEntry } from "./routes/client-entry";
|
||||
import { rpcApp } from "./routes/rpc.js";
|
||||
import { clientEntry } from "./routes/client-entry.js";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
@@ -19,7 +21,7 @@ app.get("/health", (c) => {
|
||||
// Legal config endpoint (temporary fix for RPC issue)
|
||||
app.get("/api/legal-config", async (c) => {
|
||||
try {
|
||||
const { getLegalConfig } = await import("./lib/legal-config");
|
||||
const { getLegalConfig } = await import("./lib/legal-config.js");
|
||||
const config = getLegalConfig();
|
||||
return c.json(config);
|
||||
} catch (error) {
|
||||
@@ -53,7 +55,25 @@ Canonical: https://${process.env.DOMAIN || 'localhost:5173'}/.well-known/securit
|
||||
});
|
||||
});
|
||||
|
||||
// Serve static files
|
||||
app.use('/static/*', serveStatic({ root: './dist' }));
|
||||
app.use('/assets/*', serveStatic({ root: './dist' }));
|
||||
app.use('/favicon.png', serveStatic({ path: './public/favicon.png' }));
|
||||
|
||||
app.route("/rpc", rpcApp);
|
||||
app.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;
|
||||
|
@@ -58,8 +58,8 @@ async function renderBrandedEmail(title: string, bodyHtml: string): Promise<stri
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export async function renderBookingPendingHTML(params: { name: string; date: string; time: string }) {
|
||||
const { name, date, time } = params;
|
||||
export async function renderBookingPendingHTML(params: { name: string; date: string; time: string; statusUrl?: string }) {
|
||||
const { name, date, time, statusUrl } = params;
|
||||
const formattedDate = formatDateGerman(date);
|
||||
const domain = process.env.DOMAIN || 'localhost:5173';
|
||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||
@@ -69,6 +69,13 @@ export async function renderBookingPendingHTML(params: { name: string; date: str
|
||||
<p>Hallo ${name},</p>
|
||||
<p>wir haben deine Anfrage für <strong>${formattedDate}</strong> um <strong>${time}</strong> erhalten.</p>
|
||||
<p>Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.</p>
|
||||
${statusUrl ? `
|
||||
<div style="background-color: #fef9f5; border-left: 4px solid #f59e0b; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0; font-weight: 600; color: #f59e0b;">⏳ Termin-Status ansehen:</p>
|
||||
<p style="margin: 8px 0 12px 0; color: #475569;">Du kannst den aktuellen Status deiner Buchung jederzeit einsehen:</p>
|
||||
<a href="${statusUrl}" style="display: inline-block; background-color: #f59e0b; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">Status ansehen</a>
|
||||
</div>
|
||||
` : ''}
|
||||
<div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0; font-weight: 600; color: #3b82f6;">📋 Rechtliche Informationen:</p>
|
||||
<p style="margin: 8px 0 12px 0; color: #475569;">Weitere Informationen findest du in unserem <a href="${legalUrl}" style="color: #3b82f6; text-decoration: underline;">Impressum und Datenschutz</a>.</p>
|
||||
@@ -94,10 +101,10 @@ export async function renderBookingConfirmedHTML(params: { name: string; date: s
|
||||
<p style="margin: 8px 0 0 0; color: #475569;">Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.</p>
|
||||
</div>
|
||||
${cancellationUrl ? `
|
||||
<div style="background-color: #fef3f2; border-left: 4px solid #ef4444; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0; font-weight: 600; color: #ef4444;">❌ Termin stornieren:</p>
|
||||
<p style="margin: 8px 0 12px 0; color: #475569;">Falls du den Termin stornieren möchtest, kannst du das hier tun:</p>
|
||||
<a href="${cancellationUrl}" style="display: inline-block; background-color: #ef4444; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">Termin stornieren</a>
|
||||
<div style="background-color: #fef9f5; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0; font-weight: 600; color: #db2777;">📅 Termin verwalten:</p>
|
||||
<p style="margin: 8px 0 12px 0; color: #475569;">Du kannst deinen Termin-Status einsehen und bei Bedarf stornieren:</p>
|
||||
<a href="${cancellationUrl}" style="display: inline-block; background-color: #db2777; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">Termin ansehen & verwalten</a>
|
||||
</div>
|
||||
` : ''}
|
||||
<div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||
|
@@ -1,10 +1,34 @@
|
||||
/** @jsxImportSource hono/jsx */
|
||||
import type { Context } from "hono";
|
||||
import viteReact from "@vitejs/plugin-react";
|
||||
import { readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
import type { BlankEnv } from "hono/types";
|
||||
|
||||
export function clientEntry(c: Context<BlankEnv>) {
|
||||
let jsFile = "/src/client/main.tsx";
|
||||
let cssFiles: string[] | null = null;
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
try {
|
||||
// Read Vite manifest to get the correct file names
|
||||
const manifestPath = join(process.cwd(), 'dist', '.vite', 'manifest.json');
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
||||
const entry = manifest['src/client/main.tsx'];
|
||||
if (entry) {
|
||||
jsFile = `/assets/${entry.file}`;
|
||||
if (entry.css) {
|
||||
cssFiles = entry.css.map((css: string) => `/assets/${css}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not read Vite manifest, using fallback:', error);
|
||||
// Fallback to a generic path
|
||||
jsFile = "/assets/index-Ccx6A0bN.js";
|
||||
cssFiles = ["/assets/index-RdX4PbOO.css"];
|
||||
}
|
||||
}
|
||||
|
||||
return c.html(
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -12,17 +36,15 @@ export function clientEntry(c: Context<BlankEnv>) {
|
||||
<meta content="width=device-width, initial-scale=1" name="viewport" />
|
||||
<title>Stargirlnails Kiel</title>
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
{import.meta.env.PROD ? (
|
||||
<script src="/static/main.js" type="module" />
|
||||
{cssFiles && cssFiles.map((css: string) => (
|
||||
<link key={css} rel="stylesheet" href={css} />
|
||||
))}
|
||||
{process.env.NODE_ENV === 'production' ? (
|
||||
<script src={jsFile} type="module" />
|
||||
) : (
|
||||
<>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: viteReact.preambleCode.replace("__BASE__", "/"),
|
||||
}}
|
||||
type="module"
|
||||
/>
|
||||
<script src="/src/client/main.tsx" type="module" />
|
||||
<script src="/@vite/client" type="module" />
|
||||
<script src={jsFile} type="module" />
|
||||
</>
|
||||
)}
|
||||
</head>
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import { RPCHandler } from "@orpc/server/fetch";
|
||||
|
||||
import { router } from "@/server/rpc";
|
||||
import { router } from "../rpc/index.js";
|
||||
import { Hono } from "hono";
|
||||
|
||||
export const rpcApp = new Hono();
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import { call, os } from "@orpc/server";
|
||||
import { z } from "zod";
|
||||
import { randomUUID } from "crypto";
|
||||
import { createKV } from "@/server/lib/create-kv";
|
||||
import { createKV } from "../lib/create-kv.js";
|
||||
import { config } from "dotenv";
|
||||
|
||||
// Load environment variables from .env file
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import { call, os } from "@orpc/server";
|
||||
import { z } from "zod";
|
||||
import { randomUUID } from "crypto";
|
||||
import { createKV } from "@/server/lib/create-kv";
|
||||
import { createKV } from "../lib/create-kv.js";
|
||||
|
||||
const AvailabilitySchema = z.object({
|
||||
id: z.string(),
|
||||
|
@@ -1,19 +1,20 @@
|
||||
import { call, os } from "@orpc/server";
|
||||
import { z } from "zod";
|
||||
import { randomUUID } from "crypto";
|
||||
import { createKV } from "@/server/lib/create-kv";
|
||||
import { createKV as createAvailabilityKV } from "@/server/lib/create-kv";
|
||||
import { sendEmail, sendEmailWithAGB, sendEmailWithAGBAndCalendar, sendEmailWithInspirationPhoto } from "@/server/lib/email";
|
||||
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML } from "@/server/lib/email-templates";
|
||||
import { router } from "@/server/rpc";
|
||||
import { createKV } from "../lib/create-kv.js";
|
||||
import { createKV as createAvailabilityKV } from "../lib/create-kv.js";
|
||||
import { sendEmail, sendEmailWithAGB, sendEmailWithAGBAndCalendar, sendEmailWithInspirationPhoto } from "../lib/email.js";
|
||||
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML } from "../lib/email-templates.js";
|
||||
import { router as rootRouter } from "./index.js";
|
||||
import { createORPCClient } from "@orpc/client";
|
||||
import { RPCLink } from "@orpc/client/fetch";
|
||||
import { checkBookingRateLimit, getClientIP } from "@/server/lib/rate-limiter";
|
||||
import { validateEmail } from "@/server/lib/email-validator";
|
||||
import { checkBookingRateLimit, getClientIP } from "../lib/rate-limiter.js";
|
||||
import { validateEmail } from "../lib/email-validator.js";
|
||||
|
||||
// Create a server-side client to call other RPC endpoints
|
||||
const link = new RPCLink({ url: "http://localhost:5173/rpc" });
|
||||
const queryClient = createORPCClient<typeof router>(link);
|
||||
// Typisierung über any, um Build-Inkompatibilität mit NestedClient zu vermeiden (nur für interne Server-Calls)
|
||||
const queryClient = createORPCClient<any>(link);
|
||||
|
||||
// Helper function to convert date from yyyy-mm-dd to dd.mm.yyyy
|
||||
function formatDateGerman(dateString: string): string {
|
||||
@@ -58,7 +59,7 @@ type Availability = {
|
||||
const availabilityKV = createAvailabilityKV<Availability>("availability");
|
||||
|
||||
// Import treatments KV for admin notifications
|
||||
import { createKV as createTreatmentsKV } from "@/server/lib/create-kv";
|
||||
import { createKV as createTreatmentsKV } from "../lib/create-kv.js";
|
||||
type Treatment = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -72,26 +73,16 @@ const treatmentsKV = createTreatmentsKV<Treatment>("treatments");
|
||||
|
||||
const create = os
|
||||
.input(BookingSchema.omit({ id: true, createdAt: true, status: true }))
|
||||
.handler(async ({ input, context }) => {
|
||||
.handler(async ({ input }) => {
|
||||
// console.log("Booking create called with input:", {
|
||||
// ...input,
|
||||
// inspirationPhoto: input.inspirationPhoto ? `[${input.inspirationPhoto.length} chars]` : null
|
||||
// });
|
||||
|
||||
try {
|
||||
// Rate limiting check
|
||||
const headers = context.request?.headers || {};
|
||||
const headersObj: Record<string, string | undefined> = {};
|
||||
if (headers) {
|
||||
// Convert Headers object to plain object
|
||||
headers.forEach((value: string, key: string) => {
|
||||
headersObj[key.toLowerCase()] = value;
|
||||
});
|
||||
}
|
||||
const clientIP = getClientIP(headersObj);
|
||||
|
||||
// Rate limiting check (ohne IP, falls Context-Header im Build nicht verfügbar sind)
|
||||
const rateLimitResult = checkBookingRateLimit({
|
||||
ip: clientIP,
|
||||
ip: undefined,
|
||||
email: input.customerEmail,
|
||||
});
|
||||
|
||||
@@ -158,13 +149,22 @@ const create = os
|
||||
|
||||
// Notify customer: request received (pending)
|
||||
void (async () => {
|
||||
// Create booking access token for status viewing
|
||||
const bookingAccessToken = await queryClient.cancellation.createToken({ input: { bookingId: id } });
|
||||
const bookingUrl = generateUrl(`/booking/${bookingAccessToken.token}`);
|
||||
|
||||
const formattedDate = formatDateGerman(input.appointmentDate);
|
||||
const homepageUrl = generateUrl();
|
||||
const html = await renderBookingPendingHTML({ name: input.customerName, date: input.appointmentDate, time: input.appointmentTime });
|
||||
const html = await renderBookingPendingHTML({
|
||||
name: input.customerName,
|
||||
date: input.appointmentDate,
|
||||
time: input.appointmentTime,
|
||||
statusUrl: bookingUrl
|
||||
});
|
||||
await sendEmail({
|
||||
to: input.customerEmail,
|
||||
subject: "Deine Terminanfrage ist eingegangen",
|
||||
text: `Hallo ${input.customerName},\n\nwir haben deine Anfrage für ${formattedDate} um ${input.appointmentTime} erhalten. Wir bestätigen deinen Termin in Kürze.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
|
||||
text: `Hallo ${input.customerName},\n\nwir haben deine Anfrage für ${formattedDate} um ${input.appointmentTime} erhalten. Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.\n\nTermin-Status ansehen: ${bookingUrl}\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
|
||||
html,
|
||||
}).catch(() => {});
|
||||
})();
|
||||
@@ -292,18 +292,18 @@ const updateStatus = os
|
||||
// Email notifications on status changes
|
||||
try {
|
||||
if (input.status === "confirmed") {
|
||||
// Create cancellation token for this booking
|
||||
const cancellationToken = await queryClient.cancellation.createToken({ bookingId: booking.id });
|
||||
// Create booking access token for this booking (status + cancellation)
|
||||
const bookingAccessToken = await queryClient.cancellation.createToken({ input: { bookingId: booking.id } });
|
||||
|
||||
const formattedDate = formatDateGerman(booking.appointmentDate);
|
||||
const cancellationUrl = generateUrl(`/cancel/${cancellationToken.token}`);
|
||||
const bookingUrl = generateUrl(`/booking/${bookingAccessToken.token}`);
|
||||
const homepageUrl = generateUrl();
|
||||
|
||||
const html = await renderBookingConfirmedHTML({
|
||||
name: booking.customerName,
|
||||
date: booking.appointmentDate,
|
||||
time: booking.appointmentTime,
|
||||
cancellationUrl
|
||||
cancellationUrl: bookingUrl // Now points to booking status page
|
||||
});
|
||||
|
||||
// Get treatment information for ICS file
|
||||
@@ -315,7 +315,7 @@ const updateStatus = os
|
||||
await sendEmailWithAGBAndCalendar({
|
||||
to: booking.customerEmail,
|
||||
subject: "Dein Termin wurde bestätigt - AGB im Anhang",
|
||||
text: `Hallo ${booking.customerName},\n\nwir haben deinen Termin am ${formattedDate} um ${booking.appointmentTime} bestätigt.\n\nWichtiger Hinweis: Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.\n\nFalls du den Termin stornieren möchtest, kannst du das hier tun: ${cancellationUrl}\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nBis bald!\nStargirlnails Kiel`,
|
||||
text: `Hallo ${booking.customerName},\n\nwir haben deinen Termin am ${formattedDate} um ${booking.appointmentTime} bestätigt.\n\nWichtiger Hinweis: Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.\n\nTermin-Status ansehen und verwalten: ${bookingUrl}\nFalls du den Termin stornieren möchtest, kannst du das über den obigen Link tun.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nBis bald!\nStargirlnails Kiel`,
|
||||
html,
|
||||
cc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
||||
}, {
|
||||
|
@@ -1,21 +1,24 @@
|
||||
import { call, os } from "@orpc/server";
|
||||
import { z } from "zod";
|
||||
import { createKV } from "@/server/lib/create-kv";
|
||||
import { createKV as createAvailabilityKV } from "@/server/lib/create-kv";
|
||||
import { createKV } from "../lib/create-kv.js";
|
||||
import { createKV as createAvailabilityKV } from "../lib/create-kv.js";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
// Schema for cancellation token
|
||||
const CancellationTokenSchema = z.object({
|
||||
// Schema for booking access token (used for both status viewing and cancellation)
|
||||
const BookingAccessTokenSchema = z.object({
|
||||
id: z.string(),
|
||||
bookingId: z.string(),
|
||||
token: z.string(),
|
||||
expiresAt: z.string(),
|
||||
createdAt: z.string(),
|
||||
purpose: z.enum(["booking_access"]), // For future extensibility
|
||||
});
|
||||
|
||||
type CancellationToken = z.output<typeof CancellationTokenSchema>;
|
||||
type BookingAccessToken = z.output<typeof BookingAccessTokenSchema>;
|
||||
// Backwards compatibility alias
|
||||
type CancellationToken = BookingAccessToken;
|
||||
|
||||
const cancellationKV = createKV<CancellationToken>("cancellation_tokens");
|
||||
const cancellationKV = createKV<BookingAccessToken>("cancellation_tokens");
|
||||
|
||||
// Types for booking and availability
|
||||
type Booking = {
|
||||
@@ -70,12 +73,13 @@ const createToken = os
|
||||
expiresAt.setDate(expiresAt.getDate() + 30);
|
||||
|
||||
const token = randomUUID();
|
||||
const cancellationToken: CancellationToken = {
|
||||
const cancellationToken: BookingAccessToken = {
|
||||
id: randomUUID(),
|
||||
bookingId: input.bookingId,
|
||||
token,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
purpose: "booking_access",
|
||||
};
|
||||
|
||||
await cancellationKV.setItem(cancellationToken.id, cancellationToken);
|
||||
@@ -105,15 +109,32 @@ const getBookingByToken = os
|
||||
const treatmentsKV = createKV<any>("treatments");
|
||||
const treatment = await treatmentsKV.getItem(booking.treatmentId);
|
||||
|
||||
// Calculate if cancellation is still possible
|
||||
const minStornoTimespan = parseInt(process.env.MIN_STORNO_TIMESPAN || "24");
|
||||
const appointmentDateTime = new Date(`${booking.appointmentDate}T${booking.appointmentTime}:00`);
|
||||
const now = new Date();
|
||||
const timeDifferenceHours = (appointmentDateTime.getTime() - now.getTime()) / (1000 * 60 * 60);
|
||||
const canCancel = timeDifferenceHours >= minStornoTimespan &&
|
||||
booking.status !== "cancelled" &&
|
||||
booking.status !== "completed";
|
||||
|
||||
return {
|
||||
id: booking.id,
|
||||
customerName: booking.customerName,
|
||||
customerEmail: booking.customerEmail,
|
||||
customerPhone: booking.customerPhone,
|
||||
appointmentDate: booking.appointmentDate,
|
||||
appointmentTime: booking.appointmentTime,
|
||||
treatmentId: booking.treatmentId,
|
||||
treatmentName: treatment?.name || "Unbekannte Behandlung",
|
||||
treatmentDuration: treatment?.duration || 60,
|
||||
treatmentPrice: treatment?.price || 0,
|
||||
status: booking.status,
|
||||
notes: booking.notes,
|
||||
formattedDate: formatDateGerman(booking.appointmentDate),
|
||||
createdAt: booking.createdAt,
|
||||
canCancel,
|
||||
hoursUntilAppointment: Math.max(0, Math.round(timeDifferenceHours)),
|
||||
};
|
||||
});
|
||||
|
||||
|
@@ -2,7 +2,7 @@ import OpenAI from "openai";
|
||||
import { os } from "@orpc/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { zodResponseFormat } from "@/server/lib/openai";
|
||||
import { zodResponseFormat } from "../../lib/openai";
|
||||
|
||||
if (!process.env.OPENAI_BASE_URL) {
|
||||
throw new Error("OPENAI_BASE_URL is not set");
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { router as storageRouter } from "./storage";
|
||||
import { router as storageRouter } from "./storage.js";
|
||||
|
||||
export const demo = {
|
||||
storage: storageRouter,
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import { call, os } from "@orpc/server";
|
||||
import { z } from "zod";
|
||||
import { randomUUID } from "crypto";
|
||||
import { createKV } from "@/server/lib/create-kv";
|
||||
import { createKV } from "../../lib/create-kv.js";
|
||||
|
||||
const DemoSchema = z.object({
|
||||
id: z.string(),
|
||||
|
@@ -1,10 +1,10 @@
|
||||
import { demo } from "./demo";
|
||||
import { router as treatments } from "./treatments";
|
||||
import { router as bookings } from "./bookings";
|
||||
import { router as auth } from "./auth";
|
||||
import { router as availability } from "./availability";
|
||||
import { router as cancellation } from "./cancellation";
|
||||
import { router as legal } from "./legal";
|
||||
import { demo } from "./demo/index.js";
|
||||
import { router as treatments } from "./treatments.js";
|
||||
import { router as bookings } from "./bookings.js";
|
||||
import { router as auth } from "./auth.js";
|
||||
import { router as availability } from "./availability.js";
|
||||
import { router as cancellation } from "./cancellation.js";
|
||||
import { router as legal } from "./legal.js";
|
||||
|
||||
export const router = {
|
||||
demo,
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { os } from "@orpc/server";
|
||||
import { getLegalConfig } from "@/server/lib/legal-config";
|
||||
import { getLegalConfig } from "../lib/legal-config.js";
|
||||
|
||||
export const router = {
|
||||
getConfig: os.handler(async () => {
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import { call, os } from "@orpc/server";
|
||||
import { z } from "zod";
|
||||
import { randomUUID } from "crypto";
|
||||
import { createKV } from "@/server/lib/create-kv";
|
||||
import { createKV } from "../lib/create-kv.js";
|
||||
|
||||
const TreatmentSchema = z.object({
|
||||
id: z.string(),
|
||||
|
14
start.sh
Normal file
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,21 @@ export default defineConfig(({ mode }) => {
|
||||
process.env = env;
|
||||
}
|
||||
return {
|
||||
root: ".",
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
port: 5173,
|
||||
// Erlaube Zugriffe von beliebigen Hosts (lokal + Proxy/Funnel)
|
||||
allowedHosts: ["localhost", "127.0.0.1", "master11.warbler-bearded.ts.net", ".ts.net"],
|
||||
cors: true,
|
||||
// Keine explizite HMR/Origin-Konfiguration, Vite-Defaults für localhost funktionieren am stabilsten
|
||||
},
|
||||
publicDir: "public",
|
||||
build: {
|
||||
outDir: "dist",
|
||||
rollupOptions: {
|
||||
input: "index.html"
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
tsconfigPaths(),
|
||||
react(),
|
||||
@@ -30,8 +36,6 @@ export default defineConfig(({ mode }) => {
|
||||
// it interferes with image imports.
|
||||
exclude: [/src\/client\/.*/, ...defaultOptions.exclude],
|
||||
entry: "./src/server/index.ts",
|
||||
// Allow all hosts for Tailscale Funnel
|
||||
allowedHosts: ["localhost", "127.0.0.1", "master11.warbler-bearded.ts.net", ".ts.net"],
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
Reference in New Issue
Block a user