Initial commit: Kalender, Buchungen mit Slot-Reservierung, Resend-E-Mails, Admin-UI, Startscript
This commit is contained in:
BIN
assets/stargilnails_logo.png
Normal file
BIN
assets/stargilnails_logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 528 KiB |
BIN
assets/stargilnails_logo_transparent.png
Normal file
BIN
assets/stargilnails_logo_transparent.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 553 KiB |
40
docs/backlog.md
Normal file
40
docs/backlog.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
## Backlog – Terminplanung & Infrastruktur
|
||||||
|
|
||||||
|
### Kalender & Workflow
|
||||||
|
- ICS-Anhang/Link in E‑Mails (Kalendereintrag)
|
||||||
|
- Erinnerungsmails (24h/3h vor Termin)
|
||||||
|
- Umbuchen/Stornieren per sicherem Kundenlink (Token)
|
||||||
|
- Pufferzeiten und Sperrtage/Feiertage konfigurierbar
|
||||||
|
- Slots dynamisch an Behandlungsdauer anpassen; Überschneidungen verhindern
|
||||||
|
|
||||||
|
### Sicherheit & Qualität
|
||||||
|
- Rate‑Limiting (IP/E‑Mail) für Formularspam
|
||||||
|
- CAPTCHA im Buchungsformular
|
||||||
|
- E‑Mail‑Verifizierung (Double‑Opt‑In) optional
|
||||||
|
- Audit‑Log (wer/was/wann)
|
||||||
|
- DSGVO: Einwilligungstexte, Löschkonzept
|
||||||
|
|
||||||
|
### E‑Mail & Infrastruktur
|
||||||
|
- Retry/Backoff + Fallback‑Queue bei Resend‑Fehlern
|
||||||
|
- Health‑Check für Resend‑Erreichbarkeit
|
||||||
|
- Transaktionale Template‑IDs (anbieteraustauschbar)
|
||||||
|
- Admin‑Digest (tägliche Übersicht)
|
||||||
|
|
||||||
|
### UX/UI
|
||||||
|
- Mobiler Kalender mit klarer Slot‑Visualisierung
|
||||||
|
- Kunden‑Statusseite (pending/confirmed)
|
||||||
|
- Prominente Fehlerzustände inkl. Hinweise bei Doppelbuchung
|
||||||
|
|
||||||
|
### Internationalisierung & Zeitzonen
|
||||||
|
- Zeitzonenfest (UTC intern, lokale Anzeige, Sommerzeittests)
|
||||||
|
- String‑Bündelung für spätere Lokalisierung
|
||||||
|
|
||||||
|
### Admin & Export
|
||||||
|
- CSV‑Export von Buchungen
|
||||||
|
- Filter (Status/Behandlung/Zeitraum), Schnellaktionen (Batch‑Bestätigen)
|
||||||
|
|
||||||
|
### DevOps & Setup
|
||||||
|
- .env.local‑Unterstützung und Validierung (zod‑based)
|
||||||
|
- PowerShell‑Verbesserungen: pnpm‑Check, optionales Schreiben in .env.local, sichere Eingabe
|
||||||
|
|
||||||
|
|
22
scripts/start-with-email.ps1
Normal file
22
scripts/start-with-email.ps1
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$ResendApiKey,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[string]$EmailFrom = "Stargirlnails <no-reply@stargirlnails.de>",
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[string]$AdminEmail
|
||||||
|
)
|
||||||
|
|
||||||
|
Write-Host "Setting environment variables for Resend..."
|
||||||
|
$env:RESEND_API_KEY = $ResendApiKey
|
||||||
|
$env:EMAIL_FROM = $EmailFrom
|
||||||
|
if ($AdminEmail) { $env:ADMIN_EMAIL = $AdminEmail }
|
||||||
|
|
||||||
|
Write-Host "Starting app with pnpm dev..."
|
||||||
|
pnpm dev
|
||||||
|
|
||||||
|
|
@@ -6,10 +6,11 @@ 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 { InitialDataLoader } from "@/client/components/initial-data-loader";
|
import { InitialDataLoader } from "@/client/components/initial-data-loader";
|
||||||
|
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" | "profile">("booking");
|
const [activeTab, setActiveTab] = useState<"booking" | "admin-treatments" | "admin-bookings" | "admin-availability" | "profile">("booking");
|
||||||
|
|
||||||
// Show loading spinner while checking authentication
|
// Show loading spinner while checking authentication
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -24,7 +25,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 === "profile");
|
const needsAuth = !user && (activeTab === "admin-treatments" || activeTab === "admin-bookings" || activeTab === "admin-availability" || activeTab === "profile");
|
||||||
if (needsAuth) {
|
if (needsAuth) {
|
||||||
return <LoginForm />;
|
return <LoginForm />;
|
||||||
}
|
}
|
||||||
@@ -33,6 +34,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-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;
|
||||||
|
|
||||||
@@ -159,6 +161,20 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === "admin-availability" && isOwner && (
|
||||||
|
<div>
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h2 className="text-3xl font-bold text-gray-900 mb-4">
|
||||||
|
Verfügbarkeiten verwalten
|
||||||
|
</h2>
|
||||||
|
<p className="text-lg text-gray-600">
|
||||||
|
Lege freie Slots an und entferne sie bei Bedarf.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<AdminAvailability />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === "profile" && user && (
|
{activeTab === "profile" && user && (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
|
94
src/client/components/admin-availability.tsx
Normal file
94
src/client/components/admin-availability.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { queryClient } from "@/client/rpc-client";
|
||||||
|
|
||||||
|
export function AdminAvailability() {
|
||||||
|
const today = new Date().toISOString().split("T")[0];
|
||||||
|
const [selectedDate, setSelectedDate] = useState<string>(today);
|
||||||
|
const [time, setTime] = useState<string>("09:00");
|
||||||
|
const [duration, setDuration] = useState<number>(30);
|
||||||
|
|
||||||
|
const { data: slots } = useQuery(
|
||||||
|
queryClient.availability.live.byDate.experimental_liveOptions(selectedDate)
|
||||||
|
);
|
||||||
|
|
||||||
|
const { mutate: createSlot, isPending: isCreating } = useMutation(
|
||||||
|
queryClient.availability.create.mutationOptions()
|
||||||
|
);
|
||||||
|
const { mutate: removeSlot } = useMutation(
|
||||||
|
queryClient.availability.remove.mutationOptions()
|
||||||
|
);
|
||||||
|
|
||||||
|
const addSlot = () => {
|
||||||
|
if (!selectedDate || !time || !duration) return;
|
||||||
|
createSlot({ sessionId: localStorage.getItem("sessionId") || "", date: selectedDate, time, durationMinutes: duration });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto space-y-6">
|
||||||
|
<h2 className="text-xl font-semibold">Verfügbarkeiten verwalten</h2>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={selectedDate}
|
||||||
|
onChange={(e) => setSelectedDate(e.target.value)}
|
||||||
|
className="border rounded px-3 py-2"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={time}
|
||||||
|
onChange={(e) => setTime(e.target.value)}
|
||||||
|
className="border rounded px-3 py-2"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={5}
|
||||||
|
step={5}
|
||||||
|
value={duration}
|
||||||
|
onChange={(e) => setDuration(Number(e.target.value))}
|
||||||
|
className="border rounded px-3 py-2 w-28"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={addSlot}
|
||||||
|
disabled={isCreating}
|
||||||
|
className="bg-black text-white px-4 py-2 rounded"
|
||||||
|
>
|
||||||
|
{isCreating ? "Anlegen..." : "Slot hinzufügen"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="font-medium">Slots am {selectedDate}</h3>
|
||||||
|
<div className="grid grid-cols-1 gap-2">
|
||||||
|
{slots?.sort((a, b) => a.time.localeCompare(b.time)).map((slot) => (
|
||||||
|
<div key={slot.id} className="flex items-center justify-between border rounded px-3 py-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="font-mono">{slot.time}</span>
|
||||||
|
<span className="text-sm text-gray-600">{slot.durationMinutes} Min</span>
|
||||||
|
<span className={`text-xs px-2 py-1 rounded ${slot.status === "free" ? "bg-green-100 text-green-800" : "bg-yellow-100 text-yellow-800"}`}>
|
||||||
|
{slot.status === "free" ? "frei" : "reserviert"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => removeSlot({ sessionId: localStorage.getItem("sessionId") || "", id: slot.id })}
|
||||||
|
className="text-red-600 hover:underline"
|
||||||
|
disabled={slot.status === "reserved"}
|
||||||
|
title={slot.status === "reserved" ? "Slot ist reserviert" : "Slot löschen"}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{slots?.length === 0 && (
|
||||||
|
<div className="text-sm text-gray-600">Keine Slots vorhanden.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@@ -144,6 +144,9 @@ export function AdminBookings() {
|
|||||||
<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">
|
||||||
<div>{new Date(booking.appointmentDate).toLocaleDateString()}</div>
|
<div>{new Date(booking.appointmentDate).toLocaleDateString()}</div>
|
||||||
<div>{booking.appointmentTime}</div>
|
<div>{booking.appointmentTime}</div>
|
||||||
|
{booking.slotId && (
|
||||||
|
<div className="text-xs text-gray-500">Slot-ID: {booking.slotId}</div>
|
||||||
|
)}
|
||||||
</td>
|
</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)}`}>
|
||||||
@@ -155,13 +158,13 @@ export function AdminBookings() {
|
|||||||
{booking.status === "pending" && (
|
{booking.status === "pending" && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => updateBookingStatus({ id: booking.id, status: "confirmed" })}
|
onClick={() => updateBookingStatus({ sessionId: localStorage.getItem("sessionId") || "", id: booking.id, status: "confirmed" })}
|
||||||
className="text-green-600 hover:text-green-900"
|
className="text-green-600 hover:text-green-900"
|
||||||
>
|
>
|
||||||
Confirm
|
Confirm
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => updateBookingStatus({ id: booking.id, status: "cancelled" })}
|
onClick={() => updateBookingStatus({ sessionId: localStorage.getItem("sessionId") || "", id: booking.id, status: "cancelled" })}
|
||||||
className="text-red-600 hover:text-red-900"
|
className="text-red-600 hover:text-red-900"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
@@ -171,13 +174,13 @@ export function AdminBookings() {
|
|||||||
{booking.status === "confirmed" && (
|
{booking.status === "confirmed" && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => updateBookingStatus({ id: booking.id, status: "completed" })}
|
onClick={() => updateBookingStatus({ sessionId: localStorage.getItem("sessionId") || "", id: booking.id, status: "completed" })}
|
||||||
className="text-blue-600 hover:text-blue-900"
|
className="text-blue-600 hover:text-blue-900"
|
||||||
>
|
>
|
||||||
Complete
|
Complete
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => updateBookingStatus({ id: booking.id, status: "cancelled" })}
|
onClick={() => updateBookingStatus({ sessionId: localStorage.getItem("sessionId") || "", id: booking.id, status: "cancelled" })}
|
||||||
className="text-red-600 hover:text-red-900"
|
className="text-red-600 hover:text-red-900"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
@@ -186,7 +189,7 @@ export function AdminBookings() {
|
|||||||
)}
|
)}
|
||||||
{(booking.status === "cancelled" || booking.status === "completed") && (
|
{(booking.status === "cancelled" || booking.status === "completed") && (
|
||||||
<button
|
<button
|
||||||
onClick={() => updateBookingStatus({ id: booking.id, status: "confirmed" })}
|
onClick={() => updateBookingStatus({ sessionId: localStorage.getItem("sessionId") || "", id: booking.id, status: "confirmed" })}
|
||||||
className="text-green-600 hover:text-green-900"
|
className="text-green-600 hover:text-green-900"
|
||||||
>
|
>
|
||||||
Reactivate
|
Reactivate
|
||||||
|
@@ -8,66 +8,33 @@ export function BookingForm() {
|
|||||||
const [customerEmail, setCustomerEmail] = useState("");
|
const [customerEmail, setCustomerEmail] = useState("");
|
||||||
const [customerPhone, setCustomerPhone] = useState("");
|
const [customerPhone, setCustomerPhone] = useState("");
|
||||||
const [appointmentDate, setAppointmentDate] = useState("");
|
const [appointmentDate, setAppointmentDate] = useState("");
|
||||||
const [appointmentTime, setAppointmentTime] = useState("");
|
const [selectedSlotId, setSelectedSlotId] = useState<string>("");
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
|
|
||||||
const { data: treatments } = useQuery(
|
const { data: treatments } = useQuery(
|
||||||
queryClient.treatments.live.list.experimental_liveOptions()
|
queryClient.treatments.live.list.experimental_liveOptions()
|
||||||
);
|
);
|
||||||
|
const { data: slotsByDate } = useQuery(
|
||||||
const { data: allBookings } = useQuery(
|
appointmentDate
|
||||||
queryClient.bookings.live.list.experimental_liveOptions()
|
? queryClient.availability.live.byDate.experimental_liveOptions(appointmentDate)
|
||||||
|
: queryClient.availability.live.byDate.experimental_liveOptions("")
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedDateBookings = allBookings?.filter(booking =>
|
|
||||||
booking.appointmentDate === 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");
|
||||||
// Generate available time slots (9 AM to 6 PM, 30-minute intervals)
|
|
||||||
const generateTimeSlots = () => {
|
|
||||||
const slots: string[] = [];
|
|
||||||
for (let hour = 9; hour < 18; hour++) {
|
|
||||||
for (let minute = 0; minute < 60; minute += 30) {
|
|
||||||
const time = `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
|
|
||||||
slots.push(time);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return slots;
|
|
||||||
};
|
|
||||||
|
|
||||||
const availableTimeSlots = generateTimeSlots().filter(time => {
|
|
||||||
if (!selectedDateBookings || !selectedTreatmentData) return true;
|
|
||||||
|
|
||||||
// Check if this time slot conflicts with existing bookings
|
|
||||||
const timeSlotStart = new Date(`${appointmentDate}T${time}:00`);
|
|
||||||
const timeSlotEnd = new Date(timeSlotStart.getTime() + selectedTreatmentData.duration * 60000);
|
|
||||||
|
|
||||||
return !selectedDateBookings.some(booking => {
|
|
||||||
if (booking.status === 'cancelled') return false;
|
|
||||||
|
|
||||||
const bookingStart = new Date(`${booking.appointmentDate}T${booking.appointmentTime}:00`);
|
|
||||||
const treatment = treatments?.find(t => t.id === booking.treatmentId);
|
|
||||||
if (!treatment) return false;
|
|
||||||
|
|
||||||
const bookingEnd = new Date(bookingStart.getTime() + treatment.duration * 60000);
|
|
||||||
|
|
||||||
return (timeSlotStart < bookingEnd && timeSlotEnd > bookingStart);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!selectedTreatment || !customerName || !customerEmail || !customerPhone || !appointmentDate || !appointmentTime) {
|
if (!selectedTreatment || !customerName || !customerEmail || !customerPhone || !appointmentDate || !selectedSlotId) {
|
||||||
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);
|
||||||
|
const appointmentTime = slot?.time || "";
|
||||||
createBooking({
|
createBooking({
|
||||||
treatmentId: selectedTreatment,
|
treatmentId: selectedTreatment,
|
||||||
customerName,
|
customerName,
|
||||||
@@ -76,6 +43,7 @@ export function BookingForm() {
|
|||||||
appointmentDate,
|
appointmentDate,
|
||||||
appointmentTime,
|
appointmentTime,
|
||||||
notes,
|
notes,
|
||||||
|
slotId: selectedSlotId,
|
||||||
}, {
|
}, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setSelectedTreatment("");
|
setSelectedTreatment("");
|
||||||
@@ -83,7 +51,7 @@ export function BookingForm() {
|
|||||||
setCustomerEmail("");
|
setCustomerEmail("");
|
||||||
setCustomerPhone("");
|
setCustomerPhone("");
|
||||||
setAppointmentDate("");
|
setAppointmentDate("");
|
||||||
setAppointmentTime("");
|
setSelectedSlotId("");
|
||||||
setNotes("");
|
setNotes("");
|
||||||
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.");
|
||||||
}
|
}
|
||||||
@@ -179,21 +147,23 @@ export function BookingForm() {
|
|||||||
</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">
|
||||||
Gewünschte Uhrzeit *
|
Verfügbare Uhrzeit *
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={appointmentTime}
|
value={selectedSlotId}
|
||||||
onChange={(e) => setAppointmentTime(e.target.value)}
|
onChange={(e) => setSelectedSlotId(e.target.value)}
|
||||||
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
|
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
|
||||||
disabled={!appointmentDate || !selectedTreatment}
|
disabled={!appointmentDate || !selectedTreatment}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<option value="">Zeit auswählen</option>
|
<option value="">Zeit auswählen</option>
|
||||||
{availableTimeSlots.map((time) => (
|
{availableSlots
|
||||||
<option key={time} value={time}>
|
.sort((a, b) => a.time.localeCompare(b.time))
|
||||||
{time}
|
.map((slot) => (
|
||||||
</option>
|
<option key={slot.id} value={slot.id}>
|
||||||
))}
|
{slot.time} ({slot.durationMinutes} min)
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
81
src/server/lib/email-templates.ts
Normal file
81
src/server/lib/email-templates.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
|
||||||
|
let cachedLogoDataUrl: string | null = null;
|
||||||
|
|
||||||
|
async function getLogoDataUrl(): Promise<string | null> {
|
||||||
|
if (cachedLogoDataUrl) return cachedLogoDataUrl;
|
||||||
|
try {
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
const logoPath = resolve(__dirname, "../../../assets/stargilnails_logo_transparent.png");
|
||||||
|
const buf = await readFile(logoPath);
|
||||||
|
const base64 = buf.toString("base64");
|
||||||
|
cachedLogoDataUrl = `data:image/png;base64,${base64}`;
|
||||||
|
return cachedLogoDataUrl;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderBrandedEmail(title: string, bodyHtml: string): Promise<string> {
|
||||||
|
const logo = await getLogoDataUrl();
|
||||||
|
return `
|
||||||
|
<div style="font-family: Arial, sans-serif; color: #0f172a; background:#fdf2f8; padding:24px;">
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:640px; margin:0 auto; background:#ffffff; border-radius:12px; overflow:hidden; box-shadow:0 1px 3px rgba(0,0,0,0.06)">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:24px 24px 0 24px; text-align:center;">
|
||||||
|
${logo ? `<img src="${logo}" alt="Stargirlnails" style="width:120px; height:auto; display:inline-block;" />` : `<div style=\"font-size:24px\">💅</div>`}
|
||||||
|
<h1 style="margin:16px 0 0 0; font-size:22px; color:#db2777;">${title}</h1>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:16px 24px 24px 24px;">
|
||||||
|
<div style="font-size:16px; line-height:1.6; color:#334155;">
|
||||||
|
${bodyHtml}
|
||||||
|
</div>
|
||||||
|
<hr style="border:none; border-top:1px solid #f1f5f9; margin:24px 0" />
|
||||||
|
<div style="font-size:12px; color:#64748b; text-align:center;">
|
||||||
|
© ${new Date().getFullYear()} Stargirlnails Kiel • Professional Nail Care
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderBookingPendingHTML(params: { name: string; date: string; time: string }) {
|
||||||
|
const { name, date, time } = params;
|
||||||
|
const inner = `
|
||||||
|
<p>Hallo ${name},</p>
|
||||||
|
<p>wir haben deine Anfrage für <strong>${date}</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>Liebe Grüße,<br/>Stargirlnails Kiel</p>
|
||||||
|
`;
|
||||||
|
return renderBrandedEmail("Deine Terminanfrage ist eingegangen", inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderBookingConfirmedHTML(params: { name: string; date: string; time: string }) {
|
||||||
|
const { name, date, time } = params;
|
||||||
|
const inner = `
|
||||||
|
<p>Hallo ${name},</p>
|
||||||
|
<p>wir haben deinen Termin am <strong>${date}</strong> um <strong>${time}</strong> bestätigt.</p>
|
||||||
|
<p>Wir freuen uns auf dich!</p>
|
||||||
|
<p>Liebe Grüße,<br/>Stargirlnails Kiel</p>
|
||||||
|
`;
|
||||||
|
return renderBrandedEmail("Termin bestätigt", inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderBookingCancelledHTML(params: { name: string; date: string; time: string }) {
|
||||||
|
const { name, date, time } = params;
|
||||||
|
const inner = `
|
||||||
|
<p>Hallo ${name},</p>
|
||||||
|
<p>dein Termin am <strong>${date}</strong> um <strong>${time}</strong> wurde abgesagt.</p>
|
||||||
|
<p>Bitte buche einen neuen Termin. Bei Fragen helfen wir dir gerne weiter.</p>
|
||||||
|
<p>Liebe Grüße,<br/>Stargirlnails Kiel</p>
|
||||||
|
`;
|
||||||
|
return renderBrandedEmail("Termin abgesagt", inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
46
src/server/lib/email.ts
Normal file
46
src/server/lib/email.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
type SendEmailParams = {
|
||||||
|
to: string | string[];
|
||||||
|
subject: string;
|
||||||
|
text?: string;
|
||||||
|
html?: string;
|
||||||
|
from?: string;
|
||||||
|
cc?: string | string[];
|
||||||
|
bcc?: string | string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const RESEND_API_KEY = process.env.RESEND_API_KEY;
|
||||||
|
const DEFAULT_FROM = process.env.EMAIL_FROM || "Stargirlnails <no-reply@stargirlnails.de>";
|
||||||
|
|
||||||
|
export async function sendEmail(params: SendEmailParams): Promise<{ success: boolean }> {
|
||||||
|
if (!RESEND_API_KEY) {
|
||||||
|
// In development or if not configured, skip sending but don't fail the flow
|
||||||
|
console.warn("Resend API key not configured. Skipping email send.");
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch("https://api.resend.com/emails", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${RESEND_API_KEY}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
from: params.from || DEFAULT_FROM,
|
||||||
|
to: Array.isArray(params.to) ? params.to : [params.to],
|
||||||
|
subject: params.subject,
|
||||||
|
text: params.text,
|
||||||
|
html: params.html,
|
||||||
|
cc: params.cc ? (Array.isArray(params.cc) ? params.cc : [params.cc]) : undefined,
|
||||||
|
bcc: params.bcc ? (Array.isArray(params.bcc) ? params.bcc : [params.bcc]) : undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.text().catch(() => "");
|
||||||
|
console.error("Resend send error:", response.status, body);
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
118
src/server/rpc/availability.ts
Normal file
118
src/server/rpc/availability.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { call, os } from "@orpc/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import { createKV } from "@/server/lib/create-kv";
|
||||||
|
|
||||||
|
const AvailabilitySchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
date: z.string(), // YYYY-MM-DD
|
||||||
|
time: z.string(), // HH:MM
|
||||||
|
durationMinutes: z.number().int().positive(),
|
||||||
|
status: z.enum(["free", "reserved"]),
|
||||||
|
reservedByBookingId: z.string().optional(),
|
||||||
|
createdAt: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Availability = z.output<typeof AvailabilitySchema>;
|
||||||
|
|
||||||
|
const kv = createKV<Availability>("availability");
|
||||||
|
|
||||||
|
// Minimal Owner-Prüfung über Sessions/Users KV
|
||||||
|
type Session = { id: string; userId: string; expiresAt: string; createdAt: string };
|
||||||
|
type User = { id: string; username: string; email: string; passwordHash: string; role: "customer" | "owner"; createdAt: string };
|
||||||
|
const sessionsKV = createKV<Session>("sessions");
|
||||||
|
const usersKV = createKV<User>("users");
|
||||||
|
|
||||||
|
async function assertOwner(sessionId: string): Promise<void> {
|
||||||
|
const session = await sessionsKV.getItem(sessionId);
|
||||||
|
if (!session) throw new Error("Invalid session");
|
||||||
|
if (new Date(session.expiresAt) < new Date()) throw new Error("Session expired");
|
||||||
|
const user = await usersKV.getItem(session.userId);
|
||||||
|
if (!user || user.role !== "owner") throw new Error("Forbidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
const create = os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
sessionId: z.string(),
|
||||||
|
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
time: z.string().regex(/^\d{2}:\d{2}$/),
|
||||||
|
durationMinutes: z.number().int().positive(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
const id = randomUUID();
|
||||||
|
const slot: Availability = {
|
||||||
|
id,
|
||||||
|
date: input.date,
|
||||||
|
time: input.time,
|
||||||
|
durationMinutes: input.durationMinutes,
|
||||||
|
status: "free",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await kv.setItem(id, slot);
|
||||||
|
return slot;
|
||||||
|
});
|
||||||
|
|
||||||
|
const update = os
|
||||||
|
.input(AvailabilitySchema.extend({ sessionId: z.string() }))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
const { sessionId, ...rest } = input as any;
|
||||||
|
await kv.setItem(rest.id, rest as Availability);
|
||||||
|
return rest as Availability;
|
||||||
|
});
|
||||||
|
|
||||||
|
const remove = os
|
||||||
|
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
|
const slot = await kv.getItem(input.id);
|
||||||
|
if (slot && slot.status === "reserved") throw new Error("Cannot delete reserved slot");
|
||||||
|
await kv.removeItem(input.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
const list = os.handler(async () => {
|
||||||
|
return kv.getAllItems();
|
||||||
|
});
|
||||||
|
|
||||||
|
const get = os.input(z.string()).handler(async ({ input }) => {
|
||||||
|
return kv.getItem(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
const getByDate = os
|
||||||
|
.input(z.string()) // YYYY-MM-DD
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
const all = await kv.getAllItems();
|
||||||
|
return all.filter((s) => s.date === input);
|
||||||
|
});
|
||||||
|
|
||||||
|
const live = {
|
||||||
|
list: os.handler(async function* ({ signal }) {
|
||||||
|
yield call(list, {}, { signal });
|
||||||
|
for await (const _ of kv.subscribe()) {
|
||||||
|
yield call(list, {}, { signal });
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
byDate: os
|
||||||
|
.input(z.string())
|
||||||
|
.handler(async function* ({ input, signal }) {
|
||||||
|
yield call(getByDate, input, { signal });
|
||||||
|
for await (const _ of kv.subscribe()) {
|
||||||
|
yield call(getByDate, input, { signal });
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const router = {
|
||||||
|
create,
|
||||||
|
update,
|
||||||
|
remove,
|
||||||
|
list,
|
||||||
|
get,
|
||||||
|
getByDate,
|
||||||
|
live,
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@@ -2,6 +2,9 @@ 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 { createKV as createAvailabilityKV } from "@/server/lib/create-kv";
|
||||||
|
import { sendEmail } from "@/server/lib/email";
|
||||||
|
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML } from "@/server/lib/email-templates";
|
||||||
|
|
||||||
const BookingSchema = z.object({
|
const BookingSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
@@ -14,15 +17,36 @@ const BookingSchema = z.object({
|
|||||||
status: z.enum(["pending", "confirmed", "cancelled", "completed"]),
|
status: z.enum(["pending", "confirmed", "cancelled", "completed"]),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
createdAt: z.string(),
|
createdAt: z.string(),
|
||||||
|
slotId: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type Booking = z.output<typeof BookingSchema>;
|
type Booking = z.output<typeof BookingSchema>;
|
||||||
|
|
||||||
const kv = createKV<Booking>("bookings");
|
const kv = createKV<Booking>("bookings");
|
||||||
|
type Availability = {
|
||||||
|
id: string;
|
||||||
|
date: string;
|
||||||
|
time: string;
|
||||||
|
durationMinutes: number;
|
||||||
|
status: "free" | "reserved";
|
||||||
|
reservedByBookingId?: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
const availabilityKV = createAvailabilityKV<Availability>("availability");
|
||||||
|
|
||||||
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 }) => {
|
||||||
|
// Prevent double booking: same customer email with pending/confirmed on same date
|
||||||
|
const existing = await kv.getAllItems();
|
||||||
|
const hasConflict = existing.some(b =>
|
||||||
|
b.customerEmail.toLowerCase() === input.customerEmail.toLowerCase() &&
|
||||||
|
b.appointmentDate === input.appointmentDate &&
|
||||||
|
(b.status === "pending" || b.status === "confirmed")
|
||||||
|
);
|
||||||
|
if (hasConflict) {
|
||||||
|
throw new Error("Du hast bereits eine Buchung für dieses Datum. Bitte wähle einen anderen Tag oder storniere zuerst.");
|
||||||
|
}
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
const booking = {
|
const booking = {
|
||||||
id,
|
id,
|
||||||
@@ -30,21 +54,118 @@ const create = os
|
|||||||
status: "pending" as const,
|
status: "pending" as const,
|
||||||
createdAt: new Date().toISOString()
|
createdAt: new Date().toISOString()
|
||||||
};
|
};
|
||||||
|
// If a slotId is provided, tentatively reserve the slot (mark reserved but pending)
|
||||||
|
if (booking.slotId) {
|
||||||
|
const slot = await availabilityKV.getItem(booking.slotId);
|
||||||
|
if (!slot) throw new Error("Availability slot not found");
|
||||||
|
if (slot.status !== "free") throw new Error("Slot not available");
|
||||||
|
const updatedSlot: Availability = {
|
||||||
|
...slot,
|
||||||
|
status: "reserved",
|
||||||
|
reservedByBookingId: id,
|
||||||
|
};
|
||||||
|
await availabilityKV.setItem(slot.id, updatedSlot);
|
||||||
|
}
|
||||||
await kv.setItem(id, booking);
|
await kv.setItem(id, booking);
|
||||||
|
|
||||||
|
// Notify customer: request received (pending)
|
||||||
|
void (async () => {
|
||||||
|
const html = await renderBookingPendingHTML({ name: input.customerName, date: input.appointmentDate, time: input.appointmentTime });
|
||||||
|
await sendEmail({
|
||||||
|
to: input.customerEmail,
|
||||||
|
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`,
|
||||||
|
html,
|
||||||
|
cc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
||||||
|
}).catch(() => {});
|
||||||
|
})();
|
||||||
return booking;
|
return booking;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Owner check reuse (simple inline version)
|
||||||
|
type Session = { id: string; userId: string; expiresAt: string; createdAt: string };
|
||||||
|
type User = { id: string; username: string; email: string; passwordHash: string; role: "customer" | "owner"; createdAt: string };
|
||||||
|
const sessionsKV = createAvailabilityKV<Session>("sessions");
|
||||||
|
const usersKV = createAvailabilityKV<User>("users");
|
||||||
|
async function assertOwner(sessionId: string): Promise<void> {
|
||||||
|
const session = await sessionsKV.getItem(sessionId);
|
||||||
|
if (!session) throw new Error("Invalid session");
|
||||||
|
if (new Date(session.expiresAt) < new Date()) throw new Error("Session expired");
|
||||||
|
const user = await usersKV.getItem(session.userId);
|
||||||
|
if (!user || user.role !== "owner") throw new Error("Forbidden");
|
||||||
|
}
|
||||||
|
|
||||||
const updateStatus = os
|
const updateStatus = os
|
||||||
.input(z.object({
|
.input(z.object({
|
||||||
|
sessionId: z.string(),
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
status: z.enum(["pending", "confirmed", "cancelled", "completed"])
|
status: z.enum(["pending", "confirmed", "cancelled", "completed"])
|
||||||
}))
|
}))
|
||||||
.handler(async ({ input }) => {
|
.handler(async ({ input }) => {
|
||||||
|
await assertOwner(input.sessionId);
|
||||||
const booking = await kv.getItem(input.id);
|
const booking = await kv.getItem(input.id);
|
||||||
if (!booking) throw new Error("Booking not found");
|
if (!booking) throw new Error("Booking not found");
|
||||||
|
|
||||||
|
const previousStatus = booking.status;
|
||||||
const updatedBooking = { ...booking, status: input.status };
|
const updatedBooking = { ...booking, status: input.status };
|
||||||
await kv.setItem(input.id, updatedBooking);
|
await kv.setItem(input.id, updatedBooking);
|
||||||
|
|
||||||
|
// Manage availability slot state transitions
|
||||||
|
if (booking.slotId) {
|
||||||
|
const slot = await availabilityKV.getItem(booking.slotId);
|
||||||
|
if (slot) {
|
||||||
|
if (input.status === "cancelled") {
|
||||||
|
// Free the slot again
|
||||||
|
await availabilityKV.setItem(slot.id, {
|
||||||
|
...slot,
|
||||||
|
status: "free",
|
||||||
|
reservedByBookingId: undefined,
|
||||||
|
});
|
||||||
|
} else if (input.status === "pending") {
|
||||||
|
// keep reserved as pending
|
||||||
|
if (slot.status !== "reserved") {
|
||||||
|
await availabilityKV.setItem(slot.id, {
|
||||||
|
...slot,
|
||||||
|
status: "reserved",
|
||||||
|
reservedByBookingId: booking.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (input.status === "confirmed" || input.status === "completed") {
|
||||||
|
// keep reserved; optionally noop
|
||||||
|
if (slot.status !== "reserved" || slot.reservedByBookingId !== booking.id) {
|
||||||
|
await availabilityKV.setItem(slot.id, {
|
||||||
|
...slot,
|
||||||
|
status: "reserved",
|
||||||
|
reservedByBookingId: booking.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Email notifications on status changes
|
||||||
|
try {
|
||||||
|
if (input.status === "confirmed") {
|
||||||
|
const html = await renderBookingConfirmedHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime });
|
||||||
|
await sendEmail({
|
||||||
|
to: booking.customerEmail,
|
||||||
|
subject: "Dein Termin wurde bestätigt",
|
||||||
|
text: `Hallo ${booking.customerName},\n\nwir haben deinen Termin am ${booking.appointmentDate} um ${booking.appointmentTime} bestätigt.\n\nBis bald!\nStargirlnails Kiel`,
|
||||||
|
html,
|
||||||
|
cc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
||||||
|
});
|
||||||
|
} else if (input.status === "cancelled") {
|
||||||
|
const html = await renderBookingCancelledHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime });
|
||||||
|
await sendEmail({
|
||||||
|
to: booking.customerEmail,
|
||||||
|
subject: "Dein Termin wurde abgesagt",
|
||||||
|
text: `Hallo ${booking.customerName},\n\nleider wurde dein Termin am ${booking.appointmentDate} um ${booking.appointmentTime} abgesagt. Bitte buche einen neuen Termin.\n\nLiebe Grüße\nStargirlnails Kiel`,
|
||||||
|
html,
|
||||||
|
cc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Email send failed:", e);
|
||||||
|
}
|
||||||
return updatedBooking;
|
return updatedBooking;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@@ -2,10 +2,12 @@ import { demo } from "./demo";
|
|||||||
import { router as treatments } from "./treatments";
|
import { router as treatments } from "./treatments";
|
||||||
import { router as bookings } from "./bookings";
|
import { router as bookings } from "./bookings";
|
||||||
import { router as auth } from "./auth";
|
import { router as auth } from "./auth";
|
||||||
|
import { router as availability } from "./availability";
|
||||||
|
|
||||||
export const router = {
|
export const router = {
|
||||||
demo,
|
demo,
|
||||||
treatments,
|
treatments,
|
||||||
bookings,
|
bookings,
|
||||||
auth,
|
auth,
|
||||||
|
availability,
|
||||||
};
|
};
|
||||||
|
Reference in New Issue
Block a user