Compare commits
10 Commits
b33036300f
...
f98d74a85f
Author | SHA1 | Date | |
---|---|---|---|
f98d74a85f | |||
180a5b88b8 | |||
bcfc481578 | |||
aeb32da6c2 | |||
a1935aae02 | |||
bb04e5a118 | |||
af0502baa6 | |||
072c7985c7 | |||
2e5bfdd879 | |||
ab96114295 |
17
.env.example
Normal file
17
.env.example
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Email Configuration
|
||||||
|
RESEND_API_KEY=your_resend_api_key_here
|
||||||
|
EMAIL_FROM=noreply@yourdomain.com
|
||||||
|
ADMIN_EMAIL=admin@yourdomain.com
|
||||||
|
|
||||||
|
# Admin Account Configuration
|
||||||
|
ADMIN_USERNAME=owner
|
||||||
|
ADMIN_PASSWORD_HASH=YWRtaW4xMjM=
|
||||||
|
|
||||||
|
# OpenAI Configuration (optional)
|
||||||
|
OPENAI_API_KEY=your_openai_api_key_here
|
||||||
|
|
||||||
|
# AWS Configuration (optional)
|
||||||
|
AWS_SECRET_ACCESS_KEY=your_aws_secret_key_here
|
||||||
|
|
||||||
|
# Other API Keys (optional)
|
||||||
|
BW_CLIENTSECRET=your_bw_client_secret_here
|
13
.gitignore
vendored
13
.gitignore
vendored
@@ -7,13 +7,16 @@ yarn-error.log*
|
|||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
|
|
||||||
node_modules
|
node_modules/
|
||||||
dist
|
dist/
|
||||||
dist-ssr
|
dist-ssr/
|
||||||
|
.vite/
|
||||||
|
coverage/
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.idea
|
.idea
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
*.suo
|
*.suo
|
||||||
*.ntvs*
|
*.ntvs*
|
||||||
*.njsproj
|
*.njsproj
|
||||||
@@ -37,8 +40,10 @@ dist-ssr
|
|||||||
etilqs_*
|
etilqs_*
|
||||||
|
|
||||||
# Environment variables
|
# Environment variables
|
||||||
.env*
|
.env
|
||||||
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
.env.local
|
||||||
|
|
||||||
# TypeScript cache
|
# TypeScript cache
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
65
AGB.md
Normal file
65
AGB.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# Allgemeine Geschäftsbedingungen (AGB) & Infos - Stargirlnails Kiel
|
||||||
|
|
||||||
|
Willkommen bei Stargirlnails Kiel! Um einen reibungslosen Ablauf zu gewährleisten, bitten wir dich, die folgenden Allgemeinen Geschäftsbedingungen und wichtigen Informationen zu beachten.
|
||||||
|
|
||||||
|
## 📅 Terminbuchung & Stornierung
|
||||||
|
|
||||||
|
1. **Terminvereinbarung:** Termine sind erforderlich und können bequem über unsere Online-Buchungsplattform gebucht werden.
|
||||||
|
|
||||||
|
2. **Absagen:** Bei Absagen bitten wir um rechtzeitige Mitteilung, mindestens 48 Stunden vor dem vereinbarten Termin.
|
||||||
|
|
||||||
|
3. **Verspätete Absagen / Nichterscheinen:** Bei verspäteter Absage oder Nichterscheinen wird die Buchungsgebühr einbehalten und eine Ausfallgebühr von 50% des erwarteten Preises der Dienstleistung eingefordert.
|
||||||
|
|
||||||
|
## 💰 Zahlungsmodalitäten
|
||||||
|
|
||||||
|
4. **Buchungsgebühr:** Die Buchungsgebühr von 10€ muss innerhalb von 5 Stunden nach Terminvereinbarung überwiesen worden sein, um den Termin zu bestätigen.
|
||||||
|
|
||||||
|
5. **Bezahlung der Dienstleistung:** Die Bezahlung der Dienstleistung kann in Bar, per PayPal, Überweisung oder direkt über unsere Buchungsplattform nach der Behandlung erfolgen.
|
||||||
|
|
||||||
|
## 🎯 Qualität & Zufriedenheit
|
||||||
|
|
||||||
|
6. **Rückerstattung:** Nach Verlassen des Studios geben wir keine Rückerstattung aufgrund von Unzufriedenheit. Bitte teile uns eventuelle Anliegen direkt während deines Besuchs mit.
|
||||||
|
|
||||||
|
7. **Haftung:** Wir haften nicht für Schäden, die durch unsachgemäße Pflege der Nägel nach der Behandlung entstehen.
|
||||||
|
|
||||||
|
8. **Gewährleistung:** Bei richtiger Pflege geben wir eine Gewährleistung von 7 Tagen auf unsere Behandlungen.
|
||||||
|
|
||||||
|
## 🏥 Gesundheit & Sicherheit
|
||||||
|
|
||||||
|
9. **Allergien:** Bei Allergien und Hautunverträglichkeiten bitten wir um Mitteilung vor der Behandlung.
|
||||||
|
|
||||||
|
10. **Gesundheit:** Bitte komme nicht krank zu deinem Termin, um die Gesundheit aller zu schützen.
|
||||||
|
|
||||||
|
## ⏰ Terminablauf
|
||||||
|
|
||||||
|
11. **Pünktlichkeit:** Bitte komme pünktlich. Nicht zu früh und nicht zu spät.
|
||||||
|
|
||||||
|
12. **Verspätung:** Text uns, wenn du zu früh oder zu spät da bist.
|
||||||
|
|
||||||
|
## 👥 Besucherregeln
|
||||||
|
|
||||||
|
13. **Begleitung:** Bitte bringe keine Begleitung mit zu deinem Termin.
|
||||||
|
|
||||||
|
## 👶 Altersbeschränkung
|
||||||
|
|
||||||
|
14. **Mindestalter:** Einen Termin bekommst du ab 16 Jahren.
|
||||||
|
|
||||||
|
## 🔒 Datenschutz
|
||||||
|
|
||||||
|
15. **Datenschutz:** Persönliche Daten werden vertraulich behandelt.
|
||||||
|
|
||||||
|
## ✅ Zustimmung
|
||||||
|
|
||||||
|
16. **AGB-Anerkennung:** Mit der Zusage zu deinem Termin bist du mit den AGB einverstanden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Vielen Dank für dein Verständnis!**
|
||||||
|
|
||||||
|
Dein Team von **Stargirlnails Kiel** 💅
|
||||||
|
|
||||||
|
📱 **Social Media:** [@stargirlnailskiel](https://instagram.com/stargirlnailskiel)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Stand: Januar 2024*
|
90
README.md
90
README.md
@@ -1,4 +1,6 @@
|
|||||||
# Quests Base Template
|
# Stargirlnails Kiel - Nail Salon Booking System
|
||||||
|
|
||||||
|
Ein vollständiges Buchungssystem für Nagelstudios mit Admin-Panel, Kalender und E-Mail-Benachrichtigungen.
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
@@ -9,3 +11,89 @@
|
|||||||
- [oRPC](https://orpc.unnoq.com/)
|
- [oRPC](https://orpc.unnoq.com/)
|
||||||
- [Hono](https://hono.dev/)
|
- [Hono](https://hono.dev/)
|
||||||
- [Zod](https://zod.dev/)
|
- [Zod](https://zod.dev/)
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### 1. Umgebungsvariablen konfigurieren
|
||||||
|
|
||||||
|
Kopiere die `.env.example` Datei zu `.env` und konfiguriere deine Umgebungsvariablen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Admin-Passwort Hash generieren
|
||||||
|
|
||||||
|
Das Admin-Passwort wird als Base64-Hash in der `.env` Datei gespeichert. Hier sind verschiedene Methoden, um einen Hash zu generieren:
|
||||||
|
|
||||||
|
#### PowerShell (Windows)
|
||||||
|
```powershell
|
||||||
|
# Einfache Methode mit Base64-Encoding
|
||||||
|
$password = "dein_sicheres_passwort"
|
||||||
|
$bytes = [System.Text.Encoding]::UTF8.GetBytes($password)
|
||||||
|
$hash = [System.Convert]::ToBase64String($bytes)
|
||||||
|
Write-Host "Password Hash: $hash"
|
||||||
|
|
||||||
|
# Alternative mit PowerShell 7+ (kürzer)
|
||||||
|
$password = "dein_sicheres_passwort"
|
||||||
|
[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($password))
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Node.js (falls verfügbar)
|
||||||
|
```javascript
|
||||||
|
// In der Node.js Konsole oder als separates Script
|
||||||
|
const password = "dein_sicheres_passwort";
|
||||||
|
const hash = Buffer.from(password).toString('base64');
|
||||||
|
console.log("Password Hash:", hash);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Online-Tools (nur für Entwicklung)
|
||||||
|
- Verwende einen Base64-Encoder wie [base64encode.org](https://www.base64encode.org/)
|
||||||
|
|
||||||
|
### 3. .env Datei konfigurieren
|
||||||
|
|
||||||
|
Bearbeite deine `.env` Datei und setze die generierten Werte:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Admin Account Configuration
|
||||||
|
ADMIN_USERNAME=owner
|
||||||
|
ADMIN_PASSWORD_HASH=ZGVpbl9zaWNoZXJlc19wYXNzd29ydA== # Dein generierter Hash
|
||||||
|
|
||||||
|
# Email Configuration
|
||||||
|
RESEND_API_KEY=your_resend_api_key_here
|
||||||
|
EMAIL_FROM=noreply@yourdomain.com
|
||||||
|
ADMIN_EMAIL=admin@yourdomain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Anwendung starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Dependencies installieren
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# Entwicklungsserver starten
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 📅 **Terminbuchung**: Kunden können online Termine buchen
|
||||||
|
- 💅 **Behandlungsverwaltung**: Admin kann Behandlungen hinzufügen/bearbeiten
|
||||||
|
- 📆 **Kalender-Ansicht**: Übersichtliche Darstellung aller Termine
|
||||||
|
- ⏰ **Verfügbarkeits-Slots**: Flexible Slot-Verwaltung mit behandlungsspezifischen Dauern
|
||||||
|
- 📧 **E-Mail-Benachrichtigungen**: Automatische Benachrichtigungen bei Buchungen
|
||||||
|
- 🔐 **Admin-Panel**: Geschützter Bereich für Inhaber
|
||||||
|
|
||||||
|
## Admin-Zugang
|
||||||
|
|
||||||
|
Nach dem Setup kannst du dich mit den in der `.env` konfigurierten Admin-Credentials anmelden:
|
||||||
|
- **Benutzername**: Wert aus `ADMIN_USERNAME`
|
||||||
|
- **Passwort**: Das ursprüngliche Passwort (nicht der Hash)
|
||||||
|
|
||||||
|
## Sicherheit
|
||||||
|
|
||||||
|
⚠️ **Wichtige Hinweise:**
|
||||||
|
- Ändere das Standard-Passwort vor dem Produktionseinsatz
|
||||||
|
- Die aktuelle Passwort-Hashing-Methode (Base64) ist nur für Entwicklung geeignet
|
||||||
|
- Für Produktion sollte bcrypt oder ein ähnliches sicheres Hashing-Verfahren verwendet werden
|
||||||
|
- Speichere niemals echte Passwörter in der `.env` Datei, nur die Hashes
|
||||||
|
BIN
assets/stargilnails_logo_transparent_112.png
Normal file
BIN
assets/stargilnails_logo_transparent_112.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.3 KiB |
@@ -17,6 +17,7 @@
|
|||||||
"@orpc/tanstack-query": "^1.8.8",
|
"@orpc/tanstack-query": "^1.8.8",
|
||||||
"@tailwindcss/vite": "^4.1.12",
|
"@tailwindcss/vite": "^4.1.12",
|
||||||
"@tanstack/react-query": "^5.85.5",
|
"@tanstack/react-query": "^5.85.5",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
"hono": "^4.9.4",
|
"hono": "^4.9.4",
|
||||||
"jsonrepair": "^3.13.0",
|
"jsonrepair": "^3.13.0",
|
||||||
"openai": "^5.17.0",
|
"openai": "^5.17.0",
|
||||||
|
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@@ -23,6 +23,9 @@ importers:
|
|||||||
'@tanstack/react-query':
|
'@tanstack/react-query':
|
||||||
specifier: ^5.85.5
|
specifier: ^5.85.5
|
||||||
version: 5.85.5(react@19.1.1)
|
version: 5.85.5(react@19.1.1)
|
||||||
|
dotenv:
|
||||||
|
specifier: ^17.2.3
|
||||||
|
version: 17.2.3
|
||||||
hono:
|
hono:
|
||||||
specifier: ^4.9.4
|
specifier: ^4.9.4
|
||||||
version: 4.9.4
|
version: 4.9.4
|
||||||
@@ -942,6 +945,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==}
|
resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
dotenv@17.2.3:
|
||||||
|
resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
electron-to-chromium@1.5.171:
|
electron-to-chromium@1.5.171:
|
||||||
resolution: {integrity: sha512-scWpzXEJEMrGJa4Y6m/tVotb0WuvNmasv3wWVzUAeCgKU0ToFOhUW6Z+xWnRQANMYGxN4ngJXIThgBJOqzVPCQ==}
|
resolution: {integrity: sha512-scWpzXEJEMrGJa4Y6m/tVotb0WuvNmasv3wWVzUAeCgKU0ToFOhUW6Z+xWnRQANMYGxN4ngJXIThgBJOqzVPCQ==}
|
||||||
|
|
||||||
@@ -2498,6 +2505,8 @@ snapshots:
|
|||||||
|
|
||||||
detect-libc@2.0.4: {}
|
detect-libc@2.0.4: {}
|
||||||
|
|
||||||
|
dotenv@17.2.3: {}
|
||||||
|
|
||||||
electron-to-chromium@1.5.171: {}
|
electron-to-chromium@1.5.171: {}
|
||||||
|
|
||||||
enhanced-resolve@5.18.3:
|
enhanced-resolve@5.18.3:
|
||||||
|
BIN
public/AGB.pdf
Normal file
BIN
public/AGB.pdf
Normal file
Binary file not shown.
BIN
public/assets/stargilnails_logo_transparent_112.png
Normal file
BIN
public/assets/stargilnails_logo_transparent_112.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.3 KiB |
BIN
public/favicon.png
Normal file
BIN
public/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.3 KiB |
@@ -1,20 +1,22 @@
|
|||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
param(
|
# Lade .env Datei automatisch
|
||||||
[Parameter(Mandatory = $true)]
|
Write-Host "Loading .env file..."
|
||||||
[string]$ResendApiKey,
|
if (Test-Path ".env") {
|
||||||
|
Get-Content ".env" | ForEach-Object {
|
||||||
[Parameter(Mandatory = $false)]
|
if ($_ -match '^([^#][^=]+)=(.*)$') {
|
||||||
[string]$EmailFrom = "Stargirlnails <no-reply@stargirlnails.de>",
|
$name = $matches[1].Trim()
|
||||||
|
$value = $matches[2].Trim()
|
||||||
[Parameter(Mandatory = $false)]
|
[Environment]::SetEnvironmentVariable($name, $value, 'Process')
|
||||||
[string]$AdminEmail
|
Write-Host "✓ Loaded from .env: $name" -ForegroundColor Green
|
||||||
)
|
}
|
||||||
|
}
|
||||||
Write-Host "Setting environment variables for Resend..."
|
Write-Host "Environment variables loaded from .env file" -ForegroundColor Cyan
|
||||||
$env:RESEND_API_KEY = $ResendApiKey
|
} else {
|
||||||
$env:EMAIL_FROM = $EmailFrom
|
Write-Warning ".env file not found!"
|
||||||
if ($AdminEmail) { $env:ADMIN_EMAIL = $AdminEmail }
|
Write-Host "Please create a .env file with your environment variables." -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
Write-Host "Starting app with pnpm dev..."
|
Write-Host "Starting app with pnpm dev..."
|
||||||
pnpm dev
|
pnpm dev
|
||||||
|
@@ -5,19 +5,24 @@ import { UserProfile } from "@/client/components/user-profile";
|
|||||||
import { BookingForm } from "@/client/components/booking-form";
|
import { BookingForm } from "@/client/components/booking-form";
|
||||||
import { AdminTreatments } from "@/client/components/admin-treatments";
|
import { AdminTreatments } from "@/client/components/admin-treatments";
|
||||||
import { AdminBookings } from "@/client/components/admin-bookings";
|
import { AdminBookings } from "@/client/components/admin-bookings";
|
||||||
|
import { AdminCalendar } from "@/client/components/admin-calendar";
|
||||||
import { InitialDataLoader } from "@/client/components/initial-data-loader";
|
import { InitialDataLoader } from "@/client/components/initial-data-loader";
|
||||||
import { AdminAvailability } from "@/client/components/admin-availability";
|
import { AdminAvailability } from "@/client/components/admin-availability";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { user, isLoading, isOwner } = useAuth();
|
const { user, isLoading, isOwner } = useAuth();
|
||||||
const [activeTab, setActiveTab] = useState<"booking" | "admin-treatments" | "admin-bookings" | "admin-availability" | "profile">("booking");
|
const [activeTab, setActiveTab] = useState<"booking" | "admin-treatments" | "admin-bookings" | "admin-calendar" | "admin-availability" | "profile">("booking");
|
||||||
|
|
||||||
// Show loading spinner while checking authentication
|
// Show loading spinner while checking authentication
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-pink-50 to-purple-50">
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-pink-50 to-purple-50">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-6xl mb-4">💅</div>
|
<img
|
||||||
|
src="/assets/stargilnails_logo_transparent_112.png"
|
||||||
|
alt="Stargil Nails Logo"
|
||||||
|
className="w-16 h-16 mx-auto mb-4 object-contain animate-pulse"
|
||||||
|
/>
|
||||||
<div className="text-lg text-gray-600">Lade...</div>
|
<div className="text-lg text-gray-600">Lade...</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -25,7 +30,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Show login form if user is not authenticated and trying to access admin features
|
// Show login form if user is not authenticated and trying to access admin features
|
||||||
const needsAuth = !user && (activeTab === "admin-treatments" || activeTab === "admin-bookings" || activeTab === "admin-availability" || activeTab === "profile");
|
const needsAuth = !user && (activeTab === "admin-treatments" || activeTab === "admin-bookings" || activeTab === "admin-calendar" || activeTab === "admin-availability" || activeTab === "profile");
|
||||||
if (needsAuth) {
|
if (needsAuth) {
|
||||||
return <LoginForm />;
|
return <LoginForm />;
|
||||||
}
|
}
|
||||||
@@ -34,6 +39,7 @@ function App() {
|
|||||||
{ id: "booking", label: "Termin buchen", icon: "📅", requiresAuth: false },
|
{ id: "booking", label: "Termin buchen", icon: "📅", requiresAuth: false },
|
||||||
{ id: "admin-treatments", label: "Behandlungen verwalten", icon: "💅", requiresAuth: true },
|
{ id: "admin-treatments", label: "Behandlungen verwalten", icon: "💅", requiresAuth: true },
|
||||||
{ id: "admin-bookings", label: "Buchungen verwalten", icon: "📋", requiresAuth: true },
|
{ id: "admin-bookings", label: "Buchungen verwalten", icon: "📋", requiresAuth: true },
|
||||||
|
{ id: "admin-calendar", label: "Kalender", icon: "📆", requiresAuth: true },
|
||||||
{ id: "admin-availability", label: "Verfügbarkeiten", icon: "⏰", requiresAuth: true },
|
{ id: "admin-availability", label: "Verfügbarkeiten", icon: "⏰", requiresAuth: true },
|
||||||
...(user ? [{ id: "profile", label: "Profil", icon: "👤", requiresAuth: true }] : []),
|
...(user ? [{ id: "profile", label: "Profil", icon: "👤", requiresAuth: true }] : []),
|
||||||
] as const;
|
] as const;
|
||||||
@@ -46,8 +52,15 @@ function App() {
|
|||||||
<header className="bg-white shadow-sm border-b border-pink-100">
|
<header className="bg-white shadow-sm border-b border-pink-100">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex justify-between items-center py-6">
|
<div className="flex justify-between items-center py-6">
|
||||||
<div className="flex items-center space-x-3">
|
<div
|
||||||
<div className="text-3xl">💅</div>
|
className="flex items-center space-x-3 cursor-pointer hover:opacity-80 transition-opacity"
|
||||||
|
onClick={() => setActiveTab("booking")}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/assets/stargilnails_logo_transparent_112.png"
|
||||||
|
alt="Stargil Nails Logo"
|
||||||
|
className="w-12 h-12 object-contain"
|
||||||
|
/>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Stargirlnails Kiel</h1>
|
<h1 className="text-2xl font-bold text-gray-900">Stargirlnails Kiel</h1>
|
||||||
<p className="text-sm text-gray-600">Professional Nail Design & Care</p>
|
<p className="text-sm text-gray-600">Professional Nail Design & Care</p>
|
||||||
@@ -161,6 +174,20 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === "admin-calendar" && isOwner && (
|
||||||
|
<div>
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h2 className="text-3xl font-bold text-gray-900 mb-4">
|
||||||
|
Kalender - Bevorstehende Buchungen
|
||||||
|
</h2>
|
||||||
|
<p className="text-lg text-gray-600">
|
||||||
|
Übersichtliche Darstellung aller bevorstehenden Termine im Kalenderformat.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<AdminCalendar />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === "admin-availability" && isOwner && (
|
{activeTab === "admin-availability" && isOwner && (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { queryClient } from "@/client/rpc-client";
|
import { queryClient } from "@/client/rpc-client";
|
||||||
|
|
||||||
@@ -7,11 +7,35 @@ export function AdminAvailability() {
|
|||||||
const [selectedDate, setSelectedDate] = useState<string>(today);
|
const [selectedDate, setSelectedDate] = useState<string>(today);
|
||||||
const [time, setTime] = useState<string>("09:00");
|
const [time, setTime] = useState<string>("09:00");
|
||||||
const [duration, setDuration] = useState<number>(30);
|
const [duration, setDuration] = useState<number>(30);
|
||||||
|
const [selectedTreatmentId, setSelectedTreatmentId] = useState<string>("");
|
||||||
|
const [slotType, setSlotType] = useState<"treatment" | "manual">("treatment");
|
||||||
|
|
||||||
const { data: slots } = useQuery(
|
const { data: allSlots } = useQuery(
|
||||||
queryClient.availability.live.byDate.experimental_liveOptions(selectedDate)
|
queryClient.availability.live.list.experimental_liveOptions()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { data: treatments } = useQuery(
|
||||||
|
queryClient.treatments.live.list.experimental_liveOptions()
|
||||||
|
);
|
||||||
|
|
||||||
|
const [errorMsg, setErrorMsg] = useState<string>("");
|
||||||
|
const [successMsg, setSuccessMsg] = useState<string>("");
|
||||||
|
|
||||||
|
// Auto-clear messages after 5 seconds
|
||||||
|
useEffect(() => {
|
||||||
|
if (errorMsg) {
|
||||||
|
const timer = setTimeout(() => setErrorMsg(""), 5000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [errorMsg]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (successMsg) {
|
||||||
|
const timer = setTimeout(() => setSuccessMsg(""), 5000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [successMsg]);
|
||||||
|
|
||||||
const { mutate: createSlot, isPending: isCreating } = useMutation(
|
const { mutate: createSlot, isPending: isCreating } = useMutation(
|
||||||
queryClient.availability.create.mutationOptions()
|
queryClient.availability.create.mutationOptions()
|
||||||
);
|
);
|
||||||
@@ -19,61 +43,311 @@ export function AdminAvailability() {
|
|||||||
queryClient.availability.remove.mutationOptions()
|
queryClient.availability.remove.mutationOptions()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Auto-update duration when treatment is selected
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedTreatmentId && treatments) {
|
||||||
|
const treatment = treatments.find(t => t.id === selectedTreatmentId);
|
||||||
|
if (treatment) {
|
||||||
|
setDuration(treatment.duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [selectedTreatmentId, treatments]);
|
||||||
|
|
||||||
|
// Get selected treatment details
|
||||||
|
const selectedTreatment = treatments?.find(t => t.id === selectedTreatmentId);
|
||||||
|
|
||||||
|
// Get treatment name for display
|
||||||
|
const getTreatmentName = (treatmentId: string) => {
|
||||||
|
return treatments?.find(t => t.id === treatmentId)?.name || "Unbekannte Behandlung";
|
||||||
|
};
|
||||||
|
|
||||||
const addSlot = () => {
|
const addSlot = () => {
|
||||||
if (!selectedDate || !time || !duration) return;
|
setErrorMsg("");
|
||||||
createSlot({ sessionId: localStorage.getItem("sessionId") || "", date: selectedDate, time, durationMinutes: duration });
|
setSuccessMsg("");
|
||||||
|
|
||||||
|
// Validation based on slot type
|
||||||
|
if (slotType === "treatment" && !selectedTreatmentId) {
|
||||||
|
setErrorMsg("Bitte eine Behandlung auswählen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectedDate || !time || !duration) {
|
||||||
|
setErrorMsg("Bitte Datum, Uhrzeit und Dauer angeben.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = localStorage.getItem("sessionId") || "";
|
||||||
|
if (!sessionId) {
|
||||||
|
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
createSlot(
|
||||||
|
{ sessionId, date: selectedDate, time, durationMinutes: duration },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
const slotDescription = slotType === "treatment"
|
||||||
|
? `${getTreatmentName(selectedTreatmentId)} (${duration} Min)`
|
||||||
|
: `Manueller Slot (${duration} Min)`;
|
||||||
|
setSuccessMsg(`${slotDescription} angelegt.`);
|
||||||
|
|
||||||
|
// advance time by the duration of the slot
|
||||||
|
const [hStr, mStr] = time.split(":");
|
||||||
|
let h = parseInt(hStr, 10);
|
||||||
|
let m = parseInt(mStr, 10);
|
||||||
|
m += duration;
|
||||||
|
if (m >= 60) { h += Math.floor(m / 60); m = m % 60; }
|
||||||
|
if (h >= 24) { h = h % 24; }
|
||||||
|
const next = `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
||||||
|
setTime(next);
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
const msg = (err && (err.message || (err as any).toString())) || "Fehler beim Anlegen.";
|
||||||
|
setErrorMsg(msg);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-3xl mx-auto space-y-6">
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
<h2 className="text-xl font-semibold">Verfügbarkeiten verwalten</h2>
|
<h2 className="text-2xl font-bold text-gray-900">Verfügbarkeiten verwalten</h2>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
{/* Slot Type Selection */}
|
||||||
|
<div className="bg-white rounded-lg shadow p-4">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Neuen Slot anlegen</h3>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setSlotType("treatment")}
|
||||||
|
className={`px-4 py-2 rounded-md font-medium transition-colors ${
|
||||||
|
slotType === "treatment"
|
||||||
|
? "bg-pink-600 text-white"
|
||||||
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
💅 Behandlungs-Slot
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSlotType("manual")}
|
||||||
|
className={`px-4 py-2 rounded-md font-medium transition-colors ${
|
||||||
|
slotType === "manual"
|
||||||
|
? "bg-pink-600 text-white"
|
||||||
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
⚙️ Manueller Slot
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Datum
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={selectedDate}
|
value={selectedDate}
|
||||||
onChange={(e) => setSelectedDate(e.target.value)}
|
onChange={(e) => setSelectedDate(e.target.value)}
|
||||||
className="border rounded px-3 py-2"
|
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-pink-500"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Startzeit
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="time"
|
type="time"
|
||||||
value={time}
|
value={time}
|
||||||
onChange={(e) => setTime(e.target.value)}
|
onChange={(e) => setTime(e.target.value)}
|
||||||
className="border rounded px-3 py-2"
|
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-pink-500"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{slotType === "treatment" ? (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Behandlung
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedTreatmentId}
|
||||||
|
onChange={(e) => setSelectedTreatmentId(e.target.value)}
|
||||||
|
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-pink-500"
|
||||||
|
>
|
||||||
|
<option value="">Behandlung wählen...</option>
|
||||||
|
{treatments?.map((treatment) => (
|
||||||
|
<option key={treatment.id} value={treatment.id}>
|
||||||
|
{treatment.name} ({treatment.duration} Min)
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Dauer (Minuten)
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={5}
|
min={5}
|
||||||
step={5}
|
step={5}
|
||||||
value={duration}
|
value={duration}
|
||||||
onChange={(e) => setDuration(Number(e.target.value))}
|
onChange={(e) => setDuration(Number(e.target.value))}
|
||||||
className="border rounded px-3 py-2 w-28"
|
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-pink-500"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-end">
|
||||||
<button
|
<button
|
||||||
onClick={addSlot}
|
onClick={addSlot}
|
||||||
disabled={isCreating}
|
disabled={isCreating || (slotType === "treatment" && !selectedTreatmentId)}
|
||||||
className="bg-black text-white px-4 py-2 rounded"
|
className="w-full bg-pink-600 text-white px-4 py-2 rounded-md hover:bg-pink-700 disabled:bg-gray-400 disabled:cursor-not-allowed font-medium transition-colors"
|
||||||
>
|
>
|
||||||
{isCreating ? "Anlegen..." : "Slot hinzufügen"}
|
{isCreating ? "Anlegen..." : "Slot hinzufügen"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
{/* Treatment Info Display */}
|
||||||
<h3 className="font-medium">Slots am {selectedDate}</h3>
|
{slotType === "treatment" && selectedTreatment && (
|
||||||
<div className="grid grid-cols-1 gap-2">
|
<div className="mt-4 p-3 bg-pink-50 rounded-md border border-pink-200">
|
||||||
{slots?.sort((a, b) => a.time.localeCompare(b.time)).map((slot) => (
|
<div className="flex items-center justify-between">
|
||||||
<div key={slot.id} className="flex items-center justify-between border rounded px-3 py-2">
|
<div>
|
||||||
<div className="flex items-center gap-3">
|
<h4 className="font-medium text-pink-900">{selectedTreatment.name}</h4>
|
||||||
<span className="font-mono">{slot.time}</span>
|
<p className="text-sm text-pink-700">{selectedTreatment.description}</p>
|
||||||
<span className="text-sm text-gray-600">{slot.durationMinutes} Min</span>
|
</div>
|
||||||
<span className={`text-xs px-2 py-1 rounded ${slot.status === "free" ? "bg-green-100 text-green-800" : "bg-yellow-100 text-yellow-800"}`}>
|
<div className="text-right">
|
||||||
{slot.status === "free" ? "frei" : "reserviert"}
|
<div className="text-lg font-semibold text-pink-900">
|
||||||
|
{(selectedTreatment.price / 100).toFixed(2)} €
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-pink-700">
|
||||||
|
{selectedTreatment.duration} Minuten
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(errorMsg || successMsg) && (
|
||||||
|
<div className="fixed top-4 right-4 z-50 max-w-md">
|
||||||
|
{errorMsg && (
|
||||||
|
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded-lg shadow-lg mb-2">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">Fehler:</span>
|
||||||
|
<span className="ml-1">{errorMsg}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{successMsg && (
|
||||||
|
<div className="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded-lg shadow-lg">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">Erfolg:</span>
|
||||||
|
<span className="ml-1">{successMsg}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quick Stats */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="bg-white rounded-lg shadow p-4">
|
||||||
|
<div className="text-2xl font-bold text-green-600">
|
||||||
|
{allSlots?.filter(s => s.status === "free").length || 0}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600">Freie Slots</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-lg shadow p-4">
|
||||||
|
<div className="text-2xl font-bold text-yellow-600">
|
||||||
|
{allSlots?.filter(s => s.status === "reserved").length || 0}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600">Reservierte Slots</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-lg shadow p-4">
|
||||||
|
<div className="text-2xl font-bold text-blue-600">
|
||||||
|
{allSlots?.length || 0}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600">Slots gesamt</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* All Slots List */}
|
||||||
|
<div className="bg-white rounded-lg shadow">
|
||||||
|
<div className="p-4 border-b">
|
||||||
|
<h3 className="text-lg font-semibold">Alle Slots</h3>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y">
|
||||||
|
{allSlots
|
||||||
|
?.sort((a, b) => (a.date === b.date ? a.time.localeCompare(b.time) : a.date.localeCompare(b.date)))
|
||||||
|
.map((slot) => {
|
||||||
|
// Try to find matching treatment based on duration
|
||||||
|
const matchingTreatments = treatments?.filter(t => t.duration === slot.durationMinutes) || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={slot.id} className="p-4 hover:bg-gray-50 transition-colors">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-sm text-gray-500">Datum</div>
|
||||||
|
<div className="font-medium">{new Date(slot.date).toLocaleDateString('de-DE')}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-sm text-gray-500">Zeit</div>
|
||||||
|
<div className="font-mono text-lg">{slot.time}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-sm text-gray-500">Dauer</div>
|
||||||
|
<div className="font-medium">{slot.durationMinutes} Min</div>
|
||||||
|
</div>
|
||||||
|
{matchingTreatments.length > 0 && (
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-sm text-gray-500">Passende Behandlungen</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
{matchingTreatments.length === 1
|
||||||
|
? matchingTreatments[0].name
|
||||||
|
: `${matchingTreatments.length} Behandlungen`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
|
||||||
|
slot.status === "free"
|
||||||
|
? "bg-green-100 text-green-800"
|
||||||
|
: "bg-yellow-100 text-yellow-800"
|
||||||
|
}`}>
|
||||||
|
{slot.status === "free" ? "Frei" : "Reserviert"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => removeSlot({ sessionId: localStorage.getItem("sessionId") || "", id: slot.id })}
|
onClick={() => {
|
||||||
className="text-red-600 hover:underline"
|
const sessionId = localStorage.getItem("sessionId");
|
||||||
|
if (!sessionId) {
|
||||||
|
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
removeSlot(
|
||||||
|
{ sessionId, id: slot.id },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setSuccessMsg("Slot erfolgreich gelöscht.");
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
const msg = (err && (err.message || (err as any).toString())) || "Fehler beim Löschen des Slots.";
|
||||||
|
setErrorMsg(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="px-3 py-1 text-red-600 hover:bg-red-50 rounded-md transition-colors text-sm"
|
||||||
disabled={slot.status === "reserved"}
|
disabled={slot.status === "reserved"}
|
||||||
title={slot.status === "reserved" ? "Slot ist reserviert" : "Slot löschen"}
|
title={slot.status === "reserved" ? "Slot ist reserviert" : "Slot löschen"}
|
||||||
>
|
>
|
||||||
@@ -81,9 +355,28 @@ export function AdminAvailability() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Show matching treatments if multiple */}
|
||||||
|
{matchingTreatments.length > 1 && (
|
||||||
|
<div className="mt-2 ml-20">
|
||||||
|
<div className="text-xs text-gray-500 mb-1">Passende Behandlungen:</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{matchingTreatments.map(treatment => (
|
||||||
|
<span key={treatment.id} className="px-2 py-1 bg-pink-100 text-pink-700 rounded text-xs">
|
||||||
|
{treatment.name}
|
||||||
|
</span>
|
||||||
))}
|
))}
|
||||||
{slots?.length === 0 && (
|
</div>
|
||||||
<div className="text-sm text-gray-600">Keine Slots vorhanden.</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{allSlots?.length === 0 && (
|
||||||
|
<div className="p-8 text-center text-gray-500">
|
||||||
|
<div className="text-lg font-medium mb-2">Keine Slots vorhanden</div>
|
||||||
|
<div className="text-sm">Legen Sie den ersten Slot an, um zu beginnen.</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -4,6 +4,8 @@ import { queryClient } from "@/client/rpc-client";
|
|||||||
|
|
||||||
export function AdminBookings() {
|
export function AdminBookings() {
|
||||||
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
|
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
|
||||||
|
const [selectedPhoto, setSelectedPhoto] = useState<string>("");
|
||||||
|
const [showPhotoModal, setShowPhotoModal] = useState(false);
|
||||||
|
|
||||||
const { data: bookings } = useQuery(
|
const { data: bookings } = useQuery(
|
||||||
queryClient.bookings.live.list.experimental_liveOptions()
|
queryClient.bookings.live.list.experimental_liveOptions()
|
||||||
@@ -31,6 +33,16 @@ export function AdminBookings() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openPhotoModal = (photoData: string) => {
|
||||||
|
setSelectedPhoto(photoData);
|
||||||
|
setShowPhotoModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closePhotoModal = () => {
|
||||||
|
setShowPhotoModal(false);
|
||||||
|
setSelectedPhoto("");
|
||||||
|
};
|
||||||
|
|
||||||
const filteredBookings = bookings?.filter(booking =>
|
const filteredBookings = bookings?.filter(booking =>
|
||||||
selectedDate ? booking.appointmentDate === selectedDate : true
|
selectedDate ? booking.appointmentDate === selectedDate : true
|
||||||
).sort((a, b) => {
|
).sort((a, b) => {
|
||||||
@@ -117,6 +129,9 @@ export function AdminBookings() {
|
|||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Date & Time
|
Date & Time
|
||||||
</th>
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Inspiration
|
||||||
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Status
|
Status
|
||||||
</th>
|
</th>
|
||||||
@@ -148,6 +163,22 @@ export function AdminBookings() {
|
|||||||
<div className="text-xs text-gray-500">Slot-ID: {booking.slotId}</div>
|
<div className="text-xs text-gray-500">Slot-ID: {booking.slotId}</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
{booking.inspirationPhoto ? (
|
||||||
|
<button
|
||||||
|
onClick={() => openPhotoModal(booking.inspirationPhoto!)}
|
||||||
|
className="block"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={booking.inspirationPhoto}
|
||||||
|
alt="Inspiration"
|
||||||
|
className="w-12 h-12 object-cover rounded-lg border border-gray-200 hover:border-pink-300 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 text-sm">Kein Foto</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(booking.status)}`}>
|
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(booking.status)}`}>
|
||||||
{booking.status}
|
{booking.status}
|
||||||
@@ -211,6 +242,38 @@ export function AdminBookings() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Photo Modal */}
|
||||||
|
{showPhotoModal && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg p-6 max-w-2xl max-h-[90vh] overflow-auto">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">Inspiration Foto</h3>
|
||||||
|
<button
|
||||||
|
onClick={closePhotoModal}
|
||||||
|
className="text-gray-400 hover:text-gray-600 text-2xl"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<img
|
||||||
|
src={selectedPhoto}
|
||||||
|
alt="Inspiration Foto"
|
||||||
|
className="max-w-full max-h-[70vh] object-contain rounded-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 text-center">
|
||||||
|
<button
|
||||||
|
onClick={closePhotoModal}
|
||||||
|
className="bg-pink-600 text-white px-4 py-2 rounded-md hover:bg-pink-700"
|
||||||
|
>
|
||||||
|
Schließen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
322
src/client/components/admin-calendar.tsx
Normal file
322
src/client/components/admin-calendar.tsx
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { queryClient } from "@/client/rpc-client";
|
||||||
|
|
||||||
|
export function AdminCalendar() {
|
||||||
|
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||||
|
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: bookings } = useQuery(
|
||||||
|
queryClient.bookings.live.list.experimental_liveOptions()
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: treatments } = useQuery(
|
||||||
|
queryClient.treatments.live.list.experimental_liveOptions()
|
||||||
|
);
|
||||||
|
|
||||||
|
const { mutate: updateBookingStatus } = useMutation(
|
||||||
|
queryClient.bookings.updateStatus.mutationOptions()
|
||||||
|
);
|
||||||
|
|
||||||
|
const getTreatmentName = (treatmentId: string) => {
|
||||||
|
return treatments?.find(t => t.id === treatmentId)?.name || "Unbekannte Behandlung";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case "pending": return "bg-yellow-100 text-yellow-800 border-yellow-200";
|
||||||
|
case "confirmed": return "bg-green-100 text-green-800 border-green-200";
|
||||||
|
case "cancelled": return "bg-red-100 text-red-800 border-red-200";
|
||||||
|
case "completed": return "bg-blue-100 text-blue-800 border-blue-200";
|
||||||
|
default: return "bg-gray-100 text-gray-800 border-gray-200";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusText = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case "pending": return "Ausstehend";
|
||||||
|
case "confirmed": return "Bestätigt";
|
||||||
|
case "cancelled": return "Storniert";
|
||||||
|
case "completed": return "Abgeschlossen";
|
||||||
|
default: return status;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter upcoming bookings (today and future, not cancelled)
|
||||||
|
const upcomingBookings = bookings?.filter(booking => {
|
||||||
|
const bookingDate = new Date(booking.appointmentDate);
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
return bookingDate >= today && booking.status !== "cancelled";
|
||||||
|
}).sort((a, b) => {
|
||||||
|
if (a.appointmentDate === b.appointmentDate) {
|
||||||
|
return a.appointmentTime.localeCompare(b.appointmentTime);
|
||||||
|
}
|
||||||
|
return a.appointmentDate.localeCompare(b.appointmentDate);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get bookings for a specific date
|
||||||
|
const getBookingsForDate = (date: string) => {
|
||||||
|
return upcomingBookings?.filter(booking => booking.appointmentDate === date) || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calendar generation
|
||||||
|
const year = currentMonth.getFullYear();
|
||||||
|
const month = currentMonth.getMonth();
|
||||||
|
|
||||||
|
const firstDay = new Date(year, month, 1);
|
||||||
|
const lastDay = new Date(year, month + 1, 0);
|
||||||
|
const startDate = new Date(firstDay);
|
||||||
|
startDate.setDate(startDate.getDate() - firstDay.getDay());
|
||||||
|
|
||||||
|
const calendarDays = [];
|
||||||
|
const currentDate = new Date(startDate);
|
||||||
|
|
||||||
|
for (let i = 0; i < 42; i++) {
|
||||||
|
calendarDays.push(new Date(currentDate));
|
||||||
|
currentDate.setDate(currentDate.getDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthNames = [
|
||||||
|
"Januar", "Februar", "März", "April", "Mai", "Juni",
|
||||||
|
"Juli", "August", "September", "Oktober", "November", "Dezember"
|
||||||
|
];
|
||||||
|
|
||||||
|
const dayNames = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"];
|
||||||
|
|
||||||
|
const navigateMonth = (direction: 'prev' | 'next') => {
|
||||||
|
const newMonth = new Date(currentMonth);
|
||||||
|
if (direction === 'prev') {
|
||||||
|
newMonth.setMonth(month - 1);
|
||||||
|
} else {
|
||||||
|
newMonth.setMonth(month + 1);
|
||||||
|
}
|
||||||
|
setCurrentMonth(newMonth);
|
||||||
|
setSelectedDate(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStatusUpdate = (bookingId: string, newStatus: string) => {
|
||||||
|
const sessionId = localStorage.getItem('sessionId');
|
||||||
|
if (!sessionId) return;
|
||||||
|
|
||||||
|
updateBookingStatus({
|
||||||
|
sessionId,
|
||||||
|
id: bookingId,
|
||||||
|
status: newStatus as "pending" | "confirmed" | "cancelled" | "completed"
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-6">Kalender - Bevorstehende Buchungen</h2>
|
||||||
|
|
||||||
|
{/* Quick Stats */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||||
|
<div className="bg-white rounded-lg shadow p-4">
|
||||||
|
<div className="text-2xl font-bold text-yellow-600">
|
||||||
|
{upcomingBookings?.filter(b => b.status === "pending").length || 0}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600">Ausstehende Bestätigungen</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-lg shadow p-4">
|
||||||
|
<div className="text-2xl font-bold text-green-600">
|
||||||
|
{upcomingBookings?.filter(b => b.status === "confirmed").length || 0}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600">Bestätigte Termine</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-lg shadow p-4">
|
||||||
|
<div className="text-2xl font-bold text-blue-600">
|
||||||
|
{upcomingBookings?.filter(b => b.appointmentDate === today).length || 0}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600">Heute</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-lg shadow p-4">
|
||||||
|
<div className="text-2xl font-bold text-purple-600">
|
||||||
|
{upcomingBookings?.length || 0}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600">Bevorstehende Termine</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar */}
|
||||||
|
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
|
||||||
|
{/* Calendar Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 bg-pink-50 border-b">
|
||||||
|
<button
|
||||||
|
onClick={() => navigateMonth('prev')}
|
||||||
|
className="p-2 hover:bg-pink-100 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900">
|
||||||
|
{monthNames[month]} {year}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => navigateMonth('next')}
|
||||||
|
className="p-2 hover:bg-pink-100 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar Grid */}
|
||||||
|
<div className="grid grid-cols-7">
|
||||||
|
{/* Day headers */}
|
||||||
|
{dayNames.map(day => (
|
||||||
|
<div key={day} className="p-3 text-center font-semibold text-gray-600 bg-gray-50 border-b">
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Calendar days */}
|
||||||
|
{calendarDays.map((date, index) => {
|
||||||
|
const dateStr = date.toISOString().split('T')[0];
|
||||||
|
const isCurrentMonth = date.getMonth() === month;
|
||||||
|
const isToday = dateStr === today;
|
||||||
|
const isSelected = selectedDate === dateStr;
|
||||||
|
const dayBookings = getBookingsForDate(dateStr);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`min-h-[120px] p-2 border-r border-b cursor-pointer hover:bg-gray-50 transition-colors ${
|
||||||
|
!isCurrentMonth ? 'bg-gray-50 text-gray-400' : 'bg-white'
|
||||||
|
} ${isToday ? 'bg-pink-50' : ''} ${isSelected ? 'bg-pink-100' : ''}`}
|
||||||
|
onClick={() => setSelectedDate(isSelected ? null : dateStr)}
|
||||||
|
>
|
||||||
|
<div className={`text-sm font-medium mb-1 ${
|
||||||
|
isToday ? 'text-pink-600' : isCurrentMonth ? 'text-gray-900' : 'text-gray-400'
|
||||||
|
}`}>
|
||||||
|
{date.getDate()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Show bookings for this day */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{dayBookings.slice(0, 2).map(booking => (
|
||||||
|
<div
|
||||||
|
key={booking.id}
|
||||||
|
className={`text-xs p-1 rounded border-l-2 ${getStatusColor(booking.status)} truncate`}
|
||||||
|
title={`${booking.customerName} - ${getTreatmentName(booking.treatmentId)} (${booking.appointmentTime})`}
|
||||||
|
>
|
||||||
|
<div className="font-medium">{booking.appointmentTime}</div>
|
||||||
|
<div className="truncate">{booking.customerName}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{dayBookings.length > 2 && (
|
||||||
|
<div className="text-xs text-gray-500 font-medium">
|
||||||
|
+{dayBookings.length - 2} weitere
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected Date Details */}
|
||||||
|
{selectedDate && (
|
||||||
|
<div className="mt-6 bg-white rounded-lg shadow-lg p-6">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">
|
||||||
|
Termine für {new Date(selectedDate).toLocaleDateString('de-DE', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
})}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedDate(null)}
|
||||||
|
className="text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{getBookingsForDate(selectedDate).length === 0 ? (
|
||||||
|
<p className="text-gray-500 text-center py-8">Keine Termine für diesen Tag</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{getBookingsForDate(selectedDate).map(booking => (
|
||||||
|
<div key={booking.id} className="border rounded-lg p-4 hover:shadow-md transition-shadow">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center space-x-3 mb-2">
|
||||||
|
<h4 className="font-semibold text-gray-900">{booking.customerName}</h4>
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium border ${getStatusColor(booking.status)}`}>
|
||||||
|
{getStatusText(booking.status)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm text-gray-600">
|
||||||
|
<div>
|
||||||
|
<strong>Behandlung:</strong> {getTreatmentName(booking.treatmentId)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Uhrzeit:</strong> {booking.appointmentTime}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>E-Mail:</strong> {booking.customerEmail}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Telefon:</strong> {booking.customerPhone}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{booking.notes && (
|
||||||
|
<div className="mt-3 text-sm">
|
||||||
|
<strong>Notizen:</strong>
|
||||||
|
<p className="text-gray-600 mt-1">{booking.notes}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Update Buttons */}
|
||||||
|
<div className="flex flex-col space-y-2 ml-4">
|
||||||
|
{booking.status === "pending" && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => handleStatusUpdate(booking.id, "confirmed")}
|
||||||
|
className="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700 transition-colors"
|
||||||
|
>
|
||||||
|
Bestätigen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleStatusUpdate(booking.id, "cancelled")}
|
||||||
|
className="px-3 py-1 bg-red-600 text-white text-sm rounded hover:bg-red-700 transition-colors"
|
||||||
|
>
|
||||||
|
Stornieren
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{booking.status === "confirmed" && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleStatusUpdate(booking.id, "completed")}
|
||||||
|
className="px-3 py-1 bg-blue-600 text-white text-sm rounded hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
Als erledigt markieren
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@@ -9,8 +9,8 @@ export function AdminTreatments() {
|
|||||||
name: "",
|
name: "",
|
||||||
description: "",
|
description: "",
|
||||||
duration: 60,
|
duration: 60,
|
||||||
price: 5000, // $50.00 in cents
|
price: 5000, // 50,00 € in Cent
|
||||||
category: "Manicure",
|
category: "Maniküre",
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: treatments } = useQuery(
|
const { data: treatments } = useQuery(
|
||||||
@@ -29,7 +29,7 @@ export function AdminTreatments() {
|
|||||||
queryClient.treatments.remove.mutationOptions()
|
queryClient.treatments.remove.mutationOptions()
|
||||||
);
|
);
|
||||||
|
|
||||||
const categories = ["Manicure", "Pedicure", "Nail Art", "Extensions", "Other"];
|
const categories = ["Maniküre", "Pediküre", "Nageldesign", "Verlängerungen", "Sonstiges"];
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -61,7 +61,7 @@ export function AdminTreatments() {
|
|||||||
description: "",
|
description: "",
|
||||||
duration: 60,
|
duration: 60,
|
||||||
price: 5000,
|
price: 5000,
|
||||||
category: "Manicure",
|
category: "Maniküre",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -86,26 +86,26 @@ export function AdminTreatments() {
|
|||||||
return (
|
return (
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<h2 className="text-2xl font-bold text-gray-900">Manage Treatments</h2>
|
<h2 className="text-2xl font-bold text-gray-900">Behandlungen verwalten</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowForm(true)}
|
onClick={() => setShowForm(true)}
|
||||||
className="bg-pink-600 text-white px-4 py-2 rounded-md hover:bg-pink-700 font-medium"
|
className="bg-pink-600 text-white px-4 py-2 rounded-md hover:bg-pink-700 font-medium"
|
||||||
>
|
>
|
||||||
Add Treatment
|
Behandlung hinzufügen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
|
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
|
||||||
<h3 className="text-lg font-semibold mb-4">
|
<h3 className="text-lg font-semibold mb-4">
|
||||||
{editingTreatment ? "Edit Treatment" : "Add New Treatment"}
|
{editingTreatment ? "Behandlung bearbeiten" : "Neue Behandlung hinzufügen"}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Treatment Name *
|
Behandlungsname *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -117,7 +117,7 @@ export function AdminTreatments() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Category *
|
Kategorie *
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={formData.category}
|
value={formData.category}
|
||||||
@@ -134,7 +134,7 @@ export function AdminTreatments() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Description *
|
Beschreibung *
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={formData.description}
|
value={formData.description}
|
||||||
@@ -148,7 +148,7 @@ export function AdminTreatments() {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Duration (minutes) *
|
Dauer (Minuten) *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -163,7 +163,7 @@ export function AdminTreatments() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Price ($) *
|
Preis (€) *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -183,14 +183,14 @@ export function AdminTreatments() {
|
|||||||
disabled={isCreating || isUpdating}
|
disabled={isCreating || isUpdating}
|
||||||
className="bg-pink-600 text-white px-4 py-2 rounded-md hover:bg-pink-700 disabled:opacity-50 font-medium"
|
className="bg-pink-600 text-white px-4 py-2 rounded-md hover:bg-pink-700 disabled:opacity-50 font-medium"
|
||||||
>
|
>
|
||||||
{isCreating || isUpdating ? "Saving..." : (editingTreatment ? "Update" : "Create")}
|
{isCreating || isUpdating ? "Speichern..." : (editingTreatment ? "Aktualisieren" : "Erstellen")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCancel}
|
onClick={handleCancel}
|
||||||
className="bg-gray-300 text-gray-700 px-4 py-2 rounded-md hover:bg-gray-400 font-medium"
|
className="bg-gray-300 text-gray-700 px-4 py-2 rounded-md hover:bg-gray-400 font-medium"
|
||||||
>
|
>
|
||||||
Cancel
|
Abbrechen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -202,19 +202,19 @@ export function AdminTreatments() {
|
|||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Treatment
|
Behandlung
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Category
|
Kategorie
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Duration
|
Dauer
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Price
|
Preis
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Actions
|
Aktionen
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -231,17 +231,17 @@ export function AdminTreatments() {
|
|||||||
{treatment.category}
|
{treatment.category}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
{treatment.duration} min
|
{treatment.duration} Min
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
${(treatment.price / 100).toFixed(2)}
|
{(treatment.price / 100).toFixed(2)} €
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEdit(treatment)}
|
onClick={() => handleEdit(treatment)}
|
||||||
className="text-pink-600 hover:text-pink-900"
|
className="text-pink-600 hover:text-pink-900"
|
||||||
>
|
>
|
||||||
Edit
|
Bearbeiten
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -251,7 +251,7 @@ export function AdminTreatments() {
|
|||||||
}}
|
}}
|
||||||
className="text-red-600 hover:text-red-900"
|
className="text-red-600 hover:text-red-900"
|
||||||
>
|
>
|
||||||
Delete
|
Löschen
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@@ -10,22 +10,63 @@ export function BookingForm() {
|
|||||||
const [appointmentDate, setAppointmentDate] = useState("");
|
const [appointmentDate, setAppointmentDate] = useState("");
|
||||||
const [selectedSlotId, setSelectedSlotId] = useState<string>("");
|
const [selectedSlotId, setSelectedSlotId] = useState<string>("");
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
|
const [agbAccepted, setAgbAccepted] = useState(false);
|
||||||
|
const [inspirationPhoto, setInspirationPhoto] = useState<string>("");
|
||||||
|
const [photoPreview, setPhotoPreview] = useState<string>("");
|
||||||
|
|
||||||
const { data: treatments } = useQuery(
|
const { data: treatments } = useQuery(
|
||||||
queryClient.treatments.live.list.experimental_liveOptions()
|
queryClient.treatments.live.list.experimental_liveOptions()
|
||||||
);
|
);
|
||||||
const { data: slotsByDate } = useQuery(
|
|
||||||
appointmentDate
|
// Lade alle Slots live und filtere freie Slots
|
||||||
? queryClient.availability.live.byDate.experimental_liveOptions(appointmentDate)
|
const { data: allSlots } = useQuery(
|
||||||
: queryClient.availability.live.byDate.experimental_liveOptions("")
|
queryClient.availability.live.list.experimental_liveOptions()
|
||||||
);
|
);
|
||||||
|
const freeSlots = (allSlots || []).filter((s) => s.status === "free");
|
||||||
|
const availableDates = Array.from(new Set(freeSlots.map((s) => s.date))).sort();
|
||||||
|
const slotsByDate = appointmentDate
|
||||||
|
? freeSlots.filter((s) => s.date === appointmentDate)
|
||||||
|
: [];
|
||||||
|
|
||||||
const { mutate: createBooking, isPending } = useMutation(
|
const { mutate: createBooking, isPending } = useMutation(
|
||||||
queryClient.bookings.create.mutationOptions()
|
queryClient.bookings.create.mutationOptions()
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedTreatmentData = treatments?.find(t => t.id === selectedTreatment);
|
const selectedTreatmentData = treatments?.find((t) => t.id === selectedTreatment);
|
||||||
const availableSlots = (slotsByDate || []).filter(s => s.status === "free");
|
const availableSlots = (slotsByDate || []).filter((s) => s.status === "free");
|
||||||
|
|
||||||
|
const handlePhotoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// Check file size (max 5MB)
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
alert("Das Foto ist zu groß. Bitte wähle ein Bild unter 5MB.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file type
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
alert("Bitte wähle nur Bilddateien aus.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event) => {
|
||||||
|
const result = event.target?.result as string;
|
||||||
|
setInspirationPhoto(result);
|
||||||
|
setPhotoPreview(result);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePhoto = () => {
|
||||||
|
setInspirationPhoto("");
|
||||||
|
setPhotoPreview("");
|
||||||
|
// Reset file input
|
||||||
|
const fileInput = document.getElementById('photo-upload') as HTMLInputElement;
|
||||||
|
if (fileInput) fileInput.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -33,9 +74,14 @@ export function BookingForm() {
|
|||||||
alert("Bitte fülle alle erforderlichen Felder aus");
|
alert("Bitte fülle alle erforderlichen Felder aus");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const slot = availableSlots.find(s => s.id === selectedSlotId);
|
if (!agbAccepted) {
|
||||||
|
alert("Bitte bestätige die Kenntnisnahme der Allgemeinen Geschäftsbedingungen");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const slot = availableSlots.find((s) => s.id === selectedSlotId);
|
||||||
const appointmentTime = slot?.time || "";
|
const appointmentTime = slot?.time || "";
|
||||||
createBooking({
|
createBooking(
|
||||||
|
{
|
||||||
treatmentId: selectedTreatment,
|
treatmentId: selectedTreatment,
|
||||||
customerName,
|
customerName,
|
||||||
customerEmail,
|
customerEmail,
|
||||||
@@ -43,8 +89,10 @@ export function BookingForm() {
|
|||||||
appointmentDate,
|
appointmentDate,
|
||||||
appointmentTime,
|
appointmentTime,
|
||||||
notes,
|
notes,
|
||||||
|
inspirationPhoto,
|
||||||
slotId: selectedSlotId,
|
slotId: selectedSlotId,
|
||||||
}, {
|
},
|
||||||
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setSelectedTreatment("");
|
setSelectedTreatment("");
|
||||||
setCustomerName("");
|
setCustomerName("");
|
||||||
@@ -53,13 +101,20 @@ export function BookingForm() {
|
|||||||
setAppointmentDate("");
|
setAppointmentDate("");
|
||||||
setSelectedSlotId("");
|
setSelectedSlotId("");
|
||||||
setNotes("");
|
setNotes("");
|
||||||
|
setAgbAccepted(false);
|
||||||
|
setInspirationPhoto("");
|
||||||
|
setPhotoPreview("");
|
||||||
|
// Reset file input
|
||||||
|
const fileInput = document.getElementById('photo-upload') as HTMLInputElement;
|
||||||
|
if (fileInput) fileInput.value = '';
|
||||||
alert("Buchung erfolgreich erstellt! Wir werden dich kontaktieren, um deinen Termin zu bestätigen.");
|
alert("Buchung erfolgreich erstellt! Wir werden dich kontaktieren, um deinen Termin zu bestätigen.");
|
||||||
|
},
|
||||||
}
|
}
|
||||||
});
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get minimum date (today)
|
// Get minimum date (today) – nicht mehr genutzt, Datumsauswahl erfolgt aus freien Slots
|
||||||
const today = new Date().toISOString().split('T')[0];
|
const today = new Date().toISOString().split("T")[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto bg-white rounded-lg shadow-lg p-6">
|
<div className="max-w-2xl mx-auto bg-white rounded-lg shadow-lg p-6">
|
||||||
@@ -80,7 +135,7 @@ export function BookingForm() {
|
|||||||
<option value="">Wähle eine Behandlung</option>
|
<option value="">Wähle eine Behandlung</option>
|
||||||
{treatments?.map((treatment) => (
|
{treatments?.map((treatment) => (
|
||||||
<option key={treatment.id} value={treatment.id}>
|
<option key={treatment.id} value={treatment.id}>
|
||||||
{treatment.name} - ${(treatment.price / 100).toFixed(2)} ({treatment.duration} min)
|
{treatment.name} - {(treatment.price / 100).toFixed(2)} € ({treatment.duration} Min)
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -134,16 +189,22 @@ export function BookingForm() {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Gewünschtes Datum *
|
Datum (nur freie Termine) *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<select
|
||||||
type="date"
|
|
||||||
value={appointmentDate}
|
value={appointmentDate}
|
||||||
onChange={(e) => setAppointmentDate(e.target.value)}
|
onChange={(e) => { setAppointmentDate(e.target.value); setSelectedSlotId(""); }}
|
||||||
min={today}
|
|
||||||
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
|
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
|
||||||
required
|
required
|
||||||
/>
|
>
|
||||||
|
<option value="">Datum auswählen</option>
|
||||||
|
{availableDates.map((d) => (
|
||||||
|
<option key={d} value={d}>{d}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{availableDates.length === 0 && (
|
||||||
|
<p className="mt-2 text-sm text-gray-500">Aktuell keine freien Termine verfügbar.</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
@@ -182,6 +243,70 @@ export function BookingForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Inspiration Photo Upload */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Inspiration/Beispiel-Foto (optional)
|
||||||
|
</label>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<input
|
||||||
|
id="photo-upload"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handlePhotoUpload}
|
||||||
|
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-pink-50 file:text-pink-700 hover:file:bg-pink-100"
|
||||||
|
/>
|
||||||
|
{photoPreview && (
|
||||||
|
<div className="relative inline-block">
|
||||||
|
<img
|
||||||
|
src={photoPreview}
|
||||||
|
alt="Inspiration Preview"
|
||||||
|
className="w-32 h-32 object-cover rounded-lg border border-gray-200"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={removePhoto}
|
||||||
|
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-sm hover:bg-red-600"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
📸 Lade ein Foto hoch, das als Inspiration für deine Nagelbehandlung dienen soll. Max. 5MB, alle Bildformate erlaubt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AGB Acceptance */}
|
||||||
|
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="agb-acceptance"
|
||||||
|
checked={agbAccepted}
|
||||||
|
onChange={(e) => setAgbAccepted(e.target.checked)}
|
||||||
|
className="mt-1 h-4 w-4 text-pink-600 focus:ring-pink-500 border-gray-300 rounded"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<label htmlFor="agb-acceptance" className="text-sm font-medium text-gray-700 cursor-pointer">
|
||||||
|
Ich habe die <a
|
||||||
|
href="/AGB.pdf"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-pink-600 hover:text-pink-700 underline font-semibold"
|
||||||
|
>
|
||||||
|
Allgemeinen Geschäftsbedingungen (AGB)
|
||||||
|
</a> gelesen und akzeptiere diese *
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
📋 Die AGB enthalten wichtige Informationen zu Buchungsgebühren, Stornierungsregeln und unseren Serviceleistungen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
|
@@ -5,6 +5,12 @@ import { clientEntry } from "./routes/client-entry";
|
|||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
|
// Allow all hosts for Tailscale Funnel
|
||||||
|
app.use("*", async (c, next) => {
|
||||||
|
// Accept requests from any host
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
|
||||||
app.route("/rpc", rpcApp);
|
app.route("/rpc", rpcApp);
|
||||||
app.get("/*", clientEntry);
|
app.get("/*", clientEntry);
|
||||||
|
|
||||||
|
@@ -2,6 +2,12 @@ import { readFile } from "node:fs/promises";
|
|||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { dirname, resolve } from "node:path";
|
import { dirname, resolve } from "node:path";
|
||||||
|
|
||||||
|
// Helper function to convert date from yyyy-mm-dd to dd.mm.yyyy
|
||||||
|
function formatDateGerman(dateString: string): string {
|
||||||
|
const [year, month, day] = dateString.split('-');
|
||||||
|
return `${day}.${month}.${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
let cachedLogoDataUrl: string | null = null;
|
let cachedLogoDataUrl: string | null = null;
|
||||||
|
|
||||||
async function getLogoDataUrl(): Promise<string | null> {
|
async function getLogoDataUrl(): Promise<string | null> {
|
||||||
@@ -47,9 +53,10 @@ async function renderBrandedEmail(title: string, bodyHtml: string): Promise<stri
|
|||||||
|
|
||||||
export async function renderBookingPendingHTML(params: { name: string; date: string; time: string }) {
|
export async function renderBookingPendingHTML(params: { name: string; date: string; time: string }) {
|
||||||
const { name, date, time } = params;
|
const { name, date, time } = params;
|
||||||
|
const formattedDate = formatDateGerman(date);
|
||||||
const inner = `
|
const inner = `
|
||||||
<p>Hallo ${name},</p>
|
<p>Hallo ${name},</p>
|
||||||
<p>wir haben deine Anfrage für <strong>${date}</strong> um <strong>${time}</strong> erhalten.</p>
|
<p>wir haben deine Anfrage für <strong>${formattedDate}</strong> um <strong>${time}</strong> erhalten.</p>
|
||||||
<p>Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.</p>
|
<p>Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.</p>
|
||||||
<p>Liebe Grüße,<br/>Stargirlnails Kiel</p>
|
<p>Liebe Grüße,<br/>Stargirlnails Kiel</p>
|
||||||
`;
|
`;
|
||||||
@@ -58,10 +65,15 @@ export async function renderBookingPendingHTML(params: { name: string; date: str
|
|||||||
|
|
||||||
export async function renderBookingConfirmedHTML(params: { name: string; date: string; time: string }) {
|
export async function renderBookingConfirmedHTML(params: { name: string; date: string; time: string }) {
|
||||||
const { name, date, time } = params;
|
const { name, date, time } = params;
|
||||||
|
const formattedDate = formatDateGerman(date);
|
||||||
const inner = `
|
const inner = `
|
||||||
<p>Hallo ${name},</p>
|
<p>Hallo ${name},</p>
|
||||||
<p>wir haben deinen Termin am <strong>${date}</strong> um <strong>${time}</strong> bestätigt.</p>
|
<p>wir haben deinen Termin am <strong>${formattedDate}</strong> um <strong>${time}</strong> bestätigt.</p>
|
||||||
<p>Wir freuen uns auf dich!</p>
|
<p>Wir freuen uns auf dich!</p>
|
||||||
|
<div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||||
|
<p style="margin: 0; font-weight: 600; color: #db2777;">📋 Wichtiger Hinweis:</p>
|
||||||
|
<p style="margin: 8px 0 0 0; color: #475569;">Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.</p>
|
||||||
|
</div>
|
||||||
<p>Liebe Grüße,<br/>Stargirlnails Kiel</p>
|
<p>Liebe Grüße,<br/>Stargirlnails Kiel</p>
|
||||||
`;
|
`;
|
||||||
return renderBrandedEmail("Termin bestätigt", inner);
|
return renderBrandedEmail("Termin bestätigt", inner);
|
||||||
@@ -69,13 +81,46 @@ export async function renderBookingConfirmedHTML(params: { name: string; date: s
|
|||||||
|
|
||||||
export async function renderBookingCancelledHTML(params: { name: string; date: string; time: string }) {
|
export async function renderBookingCancelledHTML(params: { name: string; date: string; time: string }) {
|
||||||
const { name, date, time } = params;
|
const { name, date, time } = params;
|
||||||
|
const formattedDate = formatDateGerman(date);
|
||||||
const inner = `
|
const inner = `
|
||||||
<p>Hallo ${name},</p>
|
<p>Hallo ${name},</p>
|
||||||
<p>dein Termin am <strong>${date}</strong> um <strong>${time}</strong> wurde abgesagt.</p>
|
<p>dein Termin am <strong>${formattedDate}</strong> um <strong>${time}</strong> wurde abgesagt.</p>
|
||||||
<p>Bitte buche einen neuen Termin. Bei Fragen helfen wir dir gerne weiter.</p>
|
<p>Bitte buche einen neuen Termin. Bei Fragen helfen wir dir gerne weiter.</p>
|
||||||
<p>Liebe Grüße,<br/>Stargirlnails Kiel</p>
|
<p>Liebe Grüße,<br/>Stargirlnails Kiel</p>
|
||||||
`;
|
`;
|
||||||
return renderBrandedEmail("Termin abgesagt", inner);
|
return renderBrandedEmail("Termin abgesagt", inner);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function renderAdminBookingNotificationHTML(params: {
|
||||||
|
name: string;
|
||||||
|
date: string;
|
||||||
|
time: string;
|
||||||
|
treatment: string;
|
||||||
|
phone: string;
|
||||||
|
notes?: string;
|
||||||
|
hasInspirationPhoto: boolean;
|
||||||
|
}) {
|
||||||
|
const { name, date, time, treatment, phone, notes, hasInspirationPhoto } = params;
|
||||||
|
const formattedDate = formatDateGerman(date);
|
||||||
|
const inner = `
|
||||||
|
<p>Hallo Admin,</p>
|
||||||
|
<p>eine neue Buchungsanfrage ist eingegangen:</p>
|
||||||
|
<div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||||
|
<p style="margin: 0; font-weight: 600; color: #db2777;">📅 Buchungsdetails:</p>
|
||||||
|
<ul style="margin: 8px 0 0 0; color: #475569; list-style: none; padding: 0;">
|
||||||
|
<li><strong>Name:</strong> ${name}</li>
|
||||||
|
<li><strong>Telefon:</strong> ${phone}</li>
|
||||||
|
<li><strong>Behandlung:</strong> ${treatment}</li>
|
||||||
|
<li><strong>Datum:</strong> ${formattedDate}</li>
|
||||||
|
<li><strong>Uhrzeit:</strong> ${time}</li>
|
||||||
|
${notes ? `<li><strong>Notizen:</strong> ${notes}</li>` : ''}
|
||||||
|
<li><strong>Inspiration-Foto:</strong> ${hasInspirationPhoto ? '✅ Im Anhang verfügbar' : '❌ Kein Foto hochgeladen'}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<p>Bitte logge dich in das Admin-Panel ein, um die Buchung zu bestätigen oder abzulehnen.</p>
|
||||||
|
<p>Liebe Grüße,<br/>Stargirlnails System</p>
|
||||||
|
`;
|
||||||
|
return renderBrandedEmail("Neue Buchungsanfrage - Admin-Benachrichtigung", inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@@ -6,11 +6,39 @@ type SendEmailParams = {
|
|||||||
from?: string;
|
from?: string;
|
||||||
cc?: string | string[];
|
cc?: string | string[];
|
||||||
bcc?: string | string[];
|
bcc?: string | string[];
|
||||||
|
attachments?: Array<{
|
||||||
|
filename: string;
|
||||||
|
content: string; // base64 encoded
|
||||||
|
type?: string;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
|
||||||
const RESEND_API_KEY = process.env.RESEND_API_KEY;
|
const RESEND_API_KEY = process.env.RESEND_API_KEY;
|
||||||
const DEFAULT_FROM = process.env.EMAIL_FROM || "Stargirlnails <no-reply@stargirlnails.de>";
|
const DEFAULT_FROM = process.env.EMAIL_FROM || "Stargirlnails <no-reply@stargirlnails.de>";
|
||||||
|
|
||||||
|
// Cache for AGB PDF to avoid reading it multiple times
|
||||||
|
let cachedAGBPDF: string | null = null;
|
||||||
|
|
||||||
|
async function getAGBPDFBase64(): Promise<string | null> {
|
||||||
|
if (cachedAGBPDF) return cachedAGBPDF;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
const agbPath = resolve(__dirname, "../../../AGB.pdf");
|
||||||
|
const buf = await readFile(agbPath);
|
||||||
|
cachedAGBPDF = buf.toString('base64');
|
||||||
|
return cachedAGBPDF;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Could not read AGB.pdf:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendEmail(params: SendEmailParams): Promise<{ success: boolean }> {
|
export async function sendEmail(params: SendEmailParams): Promise<{ success: boolean }> {
|
||||||
if (!RESEND_API_KEY) {
|
if (!RESEND_API_KEY) {
|
||||||
// In development or if not configured, skip sending but don't fail the flow
|
// In development or if not configured, skip sending but don't fail the flow
|
||||||
@@ -32,6 +60,7 @@ export async function sendEmail(params: SendEmailParams): Promise<{ success: boo
|
|||||||
html: params.html,
|
html: params.html,
|
||||||
cc: params.cc ? (Array.isArray(params.cc) ? params.cc : [params.cc]) : undefined,
|
cc: params.cc ? (Array.isArray(params.cc) ? params.cc : [params.cc]) : undefined,
|
||||||
bcc: params.bcc ? (Array.isArray(params.bcc) ? params.bcc : [params.bcc]) : undefined,
|
bcc: params.bcc ? (Array.isArray(params.bcc) ? params.bcc : [params.bcc]) : undefined,
|
||||||
|
attachments: params.attachments,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -43,4 +72,52 @@ export async function sendEmail(params: SendEmailParams): Promise<{ success: boo
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function sendEmailWithAGB(params: SendEmailParams): Promise<{ success: boolean }> {
|
||||||
|
const agbBase64 = await getAGBPDFBase64();
|
||||||
|
|
||||||
|
if (agbBase64) {
|
||||||
|
params.attachments = [
|
||||||
|
...(params.attachments || []),
|
||||||
|
{
|
||||||
|
filename: "AGB_Stargirlnails_Kiel.pdf",
|
||||||
|
content: agbBase64,
|
||||||
|
type: "application/pdf"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendEmail(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendEmailWithInspirationPhoto(
|
||||||
|
params: SendEmailParams,
|
||||||
|
photoData: string,
|
||||||
|
customerName: string
|
||||||
|
): Promise<{ success: boolean }> {
|
||||||
|
if (!photoData) {
|
||||||
|
return sendEmail(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract file extension from base64 data URL
|
||||||
|
const match = photoData.match(/data:image\/([^;]+);base64,(.+)/);
|
||||||
|
if (!match) {
|
||||||
|
console.warn("Invalid photo data format");
|
||||||
|
return sendEmail(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, extension, base64Content] = match;
|
||||||
|
const filename = `inspiration_${customerName.replace(/[^a-zA-Z0-9]/g, '_')}_${Date.now()}.${extension}`;
|
||||||
|
|
||||||
|
params.attachments = [
|
||||||
|
...(params.attachments || []),
|
||||||
|
{
|
||||||
|
filename,
|
||||||
|
content: base64Content,
|
||||||
|
type: `image/${extension}`
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return sendEmail(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@@ -10,7 +10,8 @@ export function clientEntry(c: Context<BlankEnv>) {
|
|||||||
<head>
|
<head>
|
||||||
<meta charSet="utf-8" />
|
<meta charSet="utf-8" />
|
||||||
<meta content="width=device-width, initial-scale=1" name="viewport" />
|
<meta content="width=device-width, initial-scale=1" name="viewport" />
|
||||||
<title>New Quest</title>
|
<title>Stargirlnails Kiel</title>
|
||||||
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
{import.meta.env.PROD ? (
|
{import.meta.env.PROD ? (
|
||||||
<script src="/static/main.js" type="module" />
|
<script src="/static/main.js" type="module" />
|
||||||
) : (
|
) : (
|
||||||
|
@@ -2,6 +2,10 @@ import { call, os } from "@orpc/server";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
import { createKV } from "@/server/lib/create-kv";
|
import { createKV } from "@/server/lib/create-kv";
|
||||||
|
import { config } from "dotenv";
|
||||||
|
|
||||||
|
// Load environment variables from .env file
|
||||||
|
config();
|
||||||
|
|
||||||
const UserSchema = z.object({
|
const UserSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
@@ -34,20 +38,31 @@ const verifyPassword = (password: string, hash: string): boolean => {
|
|||||||
return hashPassword(password) === hash;
|
return hashPassword(password) === hash;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Export hashPassword for external use (e.g., generating hashes for .env)
|
||||||
|
export const generatePasswordHash = hashPassword;
|
||||||
|
|
||||||
// Initialize default owner account
|
// Initialize default owner account
|
||||||
const initializeOwner = async () => {
|
const initializeOwner = async () => {
|
||||||
const existingUsers = await usersKV.getAllItems();
|
const existingUsers = await usersKV.getAllItems();
|
||||||
if (existingUsers.length === 0) {
|
if (existingUsers.length === 0) {
|
||||||
const ownerId = randomUUID();
|
const ownerId = randomUUID();
|
||||||
|
|
||||||
|
// Get admin credentials from environment variables
|
||||||
|
const adminUsername = process.env.ADMIN_USERNAME || "owner";
|
||||||
|
const adminPasswordHash = process.env.ADMIN_PASSWORD_HASH || hashPassword("admin123");
|
||||||
|
const adminEmail = process.env.ADMIN_EMAIL || "owner@stargirlnails.de";
|
||||||
|
|
||||||
const owner: User = {
|
const owner: User = {
|
||||||
id: ownerId,
|
id: ownerId,
|
||||||
username: "owner",
|
username: adminUsername,
|
||||||
email: "owner@stargirlnails.de",
|
email: adminEmail,
|
||||||
passwordHash: hashPassword("admin123"), // Default password
|
passwordHash: adminPasswordHash,
|
||||||
role: "owner",
|
role: "owner",
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
await usersKV.setItem(ownerId, owner);
|
await usersKV.setItem(ownerId, owner);
|
||||||
|
|
||||||
|
console.log(`✅ Admin account created: username="${adminUsername}", email="${adminEmail}"`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -41,7 +41,14 @@ const create = os
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.handler(async ({ input }) => {
|
.handler(async ({ input }) => {
|
||||||
|
try {
|
||||||
await assertOwner(input.sessionId);
|
await assertOwner(input.sessionId);
|
||||||
|
// Prevent duplicate slot on same date+time
|
||||||
|
const existing = await kv.getAllItems();
|
||||||
|
const conflict = existing.some((s) => s.date === input.date && s.time === input.time);
|
||||||
|
if (conflict) {
|
||||||
|
throw new Error("Es existiert bereits ein Slot zu diesem Datum und dieser Uhrzeit.");
|
||||||
|
}
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
const slot: Availability = {
|
const slot: Availability = {
|
||||||
id,
|
id,
|
||||||
@@ -53,6 +60,10 @@ const create = os
|
|||||||
};
|
};
|
||||||
await kv.setItem(id, slot);
|
await kv.setItem(id, slot);
|
||||||
return slot;
|
return slot;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("availability.create error", err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const update = os
|
const update = os
|
||||||
|
@@ -3,8 +3,14 @@ import { z } from "zod";
|
|||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
import { createKV } from "@/server/lib/create-kv";
|
import { createKV } from "@/server/lib/create-kv";
|
||||||
import { createKV as createAvailabilityKV } from "@/server/lib/create-kv";
|
import { createKV as createAvailabilityKV } from "@/server/lib/create-kv";
|
||||||
import { sendEmail } from "@/server/lib/email";
|
import { sendEmail, sendEmailWithAGB, sendEmailWithInspirationPhoto } from "@/server/lib/email";
|
||||||
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML } from "@/server/lib/email-templates";
|
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML } from "@/server/lib/email-templates";
|
||||||
|
|
||||||
|
// Helper function to convert date from yyyy-mm-dd to dd.mm.yyyy
|
||||||
|
function formatDateGerman(dateString: string): string {
|
||||||
|
const [year, month, day] = dateString.split('-');
|
||||||
|
return `${day}.${month}.${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
const BookingSchema = z.object({
|
const BookingSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
@@ -16,6 +22,7 @@ const BookingSchema = z.object({
|
|||||||
appointmentTime: z.string(), // HH:MM format
|
appointmentTime: z.string(), // HH:MM format
|
||||||
status: z.enum(["pending", "confirmed", "cancelled", "completed"]),
|
status: z.enum(["pending", "confirmed", "cancelled", "completed"]),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
|
inspirationPhoto: z.string().optional(), // Base64 encoded image data
|
||||||
createdAt: z.string(),
|
createdAt: z.string(),
|
||||||
slotId: z.string().optional(),
|
slotId: z.string().optional(),
|
||||||
});
|
});
|
||||||
@@ -34,6 +41,19 @@ type Availability = {
|
|||||||
};
|
};
|
||||||
const availabilityKV = createAvailabilityKV<Availability>("availability");
|
const availabilityKV = createAvailabilityKV<Availability>("availability");
|
||||||
|
|
||||||
|
// Import treatments KV for admin notifications
|
||||||
|
import { createKV as createTreatmentsKV } from "@/server/lib/create-kv";
|
||||||
|
type Treatment = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
price: number;
|
||||||
|
duration: number;
|
||||||
|
category: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
const treatmentsKV = createTreatmentsKV<Treatment>("treatments");
|
||||||
|
|
||||||
const create = os
|
const create = os
|
||||||
.input(BookingSchema.omit({ id: true, createdAt: true, status: true }))
|
.input(BookingSchema.omit({ id: true, createdAt: true, status: true }))
|
||||||
.handler(async ({ input }) => {
|
.handler(async ({ input }) => {
|
||||||
@@ -70,15 +90,61 @@ const create = os
|
|||||||
|
|
||||||
// Notify customer: request received (pending)
|
// Notify customer: request received (pending)
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
const formattedDate = formatDateGerman(input.appointmentDate);
|
||||||
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 });
|
||||||
await sendEmail({
|
await sendEmail({
|
||||||
to: input.customerEmail,
|
to: input.customerEmail,
|
||||||
subject: "Deine Terminanfrage ist eingegangen",
|
subject: "Deine Terminanfrage ist eingegangen",
|
||||||
text: `Hallo ${input.customerName},\n\nwir haben deine Anfrage für ${input.appointmentDate} um ${input.appointmentTime} erhalten. Wir bestätigen deinen Termin in Kürze.\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.\n\nLiebe Grüße\nStargirlnails Kiel`,
|
||||||
html,
|
html,
|
||||||
cc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// Notify admin: new booking request (with photo if available)
|
||||||
|
void (async () => {
|
||||||
|
if (!process.env.ADMIN_EMAIL) return;
|
||||||
|
|
||||||
|
// Get treatment name from KV
|
||||||
|
const allTreatments = await treatmentsKV.getAllItems();
|
||||||
|
const treatment = allTreatments.find(t => t.id === input.treatmentId);
|
||||||
|
const treatmentName = treatment?.name || "Unbekannte Behandlung";
|
||||||
|
|
||||||
|
const adminHtml = await renderAdminBookingNotificationHTML({
|
||||||
|
name: input.customerName,
|
||||||
|
date: input.appointmentDate,
|
||||||
|
time: input.appointmentTime,
|
||||||
|
treatment: treatmentName,
|
||||||
|
phone: input.customerPhone,
|
||||||
|
notes: input.notes,
|
||||||
|
hasInspirationPhoto: !!input.inspirationPhoto
|
||||||
|
});
|
||||||
|
|
||||||
|
const adminText = `Neue Buchungsanfrage eingegangen:\n\n` +
|
||||||
|
`Name: ${input.customerName}\n` +
|
||||||
|
`Telefon: ${input.customerPhone}\n` +
|
||||||
|
`Behandlung: ${treatmentName}\n` +
|
||||||
|
`Datum: ${formatDateGerman(input.appointmentDate)}\n` +
|
||||||
|
`Uhrzeit: ${input.appointmentTime}\n` +
|
||||||
|
`${input.notes ? `Notizen: ${input.notes}\n` : ''}` +
|
||||||
|
`Inspiration-Foto: ${input.inspirationPhoto ? 'Im Anhang verfügbar' : 'Kein Foto hochgeladen'}\n\n` +
|
||||||
|
`Bitte logge dich in das Admin-Panel ein, um die Buchung zu bearbeiten.`;
|
||||||
|
|
||||||
|
if (input.inspirationPhoto) {
|
||||||
|
await sendEmailWithInspirationPhoto({
|
||||||
|
to: process.env.ADMIN_EMAIL,
|
||||||
|
subject: `Neue Buchungsanfrage - ${input.customerName}`,
|
||||||
|
text: adminText,
|
||||||
|
html: adminHtml,
|
||||||
|
}, input.inspirationPhoto, input.customerName).catch(() => {});
|
||||||
|
} else {
|
||||||
|
await sendEmail({
|
||||||
|
to: process.env.ADMIN_EMAIL,
|
||||||
|
subject: `Neue Buchungsanfrage - ${input.customerName}`,
|
||||||
|
text: adminText,
|
||||||
|
html: adminHtml,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
})();
|
||||||
return booking;
|
return booking;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -145,20 +211,22 @@ const updateStatus = os
|
|||||||
// Email notifications on status changes
|
// Email notifications on status changes
|
||||||
try {
|
try {
|
||||||
if (input.status === "confirmed") {
|
if (input.status === "confirmed") {
|
||||||
|
const formattedDate = formatDateGerman(booking.appointmentDate);
|
||||||
const html = await renderBookingConfirmedHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime });
|
const html = await renderBookingConfirmedHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime });
|
||||||
await sendEmail({
|
await sendEmailWithAGB({
|
||||||
to: booking.customerEmail,
|
to: booking.customerEmail,
|
||||||
subject: "Dein Termin wurde bestätigt",
|
subject: "Dein Termin wurde bestätigt - AGB im Anhang",
|
||||||
text: `Hallo ${booking.customerName},\n\nwir haben deinen Termin am ${booking.appointmentDate} um ${booking.appointmentTime} bestätigt.\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\nBis bald!\nStargirlnails Kiel`,
|
||||||
html,
|
html,
|
||||||
cc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
cc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
||||||
});
|
});
|
||||||
} else if (input.status === "cancelled") {
|
} else if (input.status === "cancelled") {
|
||||||
|
const formattedDate = formatDateGerman(booking.appointmentDate);
|
||||||
const html = await renderBookingCancelledHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime });
|
const html = await renderBookingCancelledHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime });
|
||||||
await sendEmail({
|
await sendEmail({
|
||||||
to: booking.customerEmail,
|
to: booking.customerEmail,
|
||||||
subject: "Dein Termin wurde abgesagt",
|
subject: "Dein Termin wurde abgesagt",
|
||||||
text: `Hallo ${booking.customerName},\n\nleider wurde dein Termin am ${booking.appointmentDate} um ${booking.appointmentTime} abgesagt. Bitte buche einen neuen Termin.\n\nLiebe Grüße\nStargirlnails Kiel`,
|
text: `Hallo ${booking.customerName},\n\nleider wurde dein Termin am ${formattedDate} um ${booking.appointmentTime} abgesagt. Bitte buche einen neuen Termin.\n\nLiebe Grüße\nStargirlnails Kiel`,
|
||||||
html,
|
html,
|
||||||
cc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
cc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
||||||
});
|
});
|
||||||
|
@@ -15,8 +15,12 @@ export default defineConfig(({ mode }) => {
|
|||||||
server: {
|
server: {
|
||||||
host: "0.0.0.0",
|
host: "0.0.0.0",
|
||||||
port: 5173,
|
port: 5173,
|
||||||
allowedHosts: "all",
|
// 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",
|
||||||
plugins: [
|
plugins: [
|
||||||
tsconfigPaths(),
|
tsconfigPaths(),
|
||||||
react(),
|
react(),
|
||||||
@@ -26,6 +30,8 @@ export default defineConfig(({ mode }) => {
|
|||||||
// it interferes with image imports.
|
// it interferes with image imports.
|
||||||
exclude: [/src\/client\/.*/, ...defaultOptions.exclude],
|
exclude: [/src\/client\/.*/, ...defaultOptions.exclude],
|
||||||
entry: "./src/server/index.ts",
|
entry: "./src/server/index.ts",
|
||||||
|
// Allow all hosts for Tailscale Funnel
|
||||||
|
allowedHosts: ["localhost", "127.0.0.1", "master11.warbler-bearded.ts.net", ".ts.net"],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
Reference in New Issue
Block a user