Email: Review-Link auf /review/:token umgestellt; Token-Erzeugung konsolidiert. Reviews: Client-Validation hinzugefügt. Verfügbarkeiten: Auto-Update nach Regelanlage. Galerie: Cover-Foto-Flag + Setzen im Admin, sofortige Aktualisierung nach Upload/Löschen/Reihenfolge-Änderung. Startseite: Featured-Foto = Reihenfolge 0, Seitenverhältnis beibehalten, Texte aktualisiert.

This commit is contained in:
2025-10-05 20:09:12 +02:00
parent 6d7e8eceba
commit 53aca01131
13 changed files with 1807 additions and 23 deletions

View File

@@ -8,12 +8,16 @@ import { AdminBookings } from "@/client/components/admin-bookings";
import { AdminCalendar } from "@/client/components/admin-calendar";
import { InitialDataLoader } from "@/client/components/initial-data-loader";
import { AdminAvailability } from "@/client/components/admin-availability";
import { AdminGallery } from "@/client/components/admin-gallery";
import { AdminReviews } from "@/client/components/admin-reviews";
import BookingStatusPage from "@/client/components/booking-status-page";
import ReviewSubmissionPage from "@/client/components/review-submission-page";
import LegalPage from "@/client/components/legal-page";
import { ProfileLanding } from "@/client/components/profile-landing";
function App() {
const { user, isLoading, isOwner } = useAuth();
const [activeTab, setActiveTab] = useState<"booking" | "admin-treatments" | "admin-bookings" | "admin-calendar" | "admin-availability" | "profile" | "legal">("booking");
const [activeTab, setActiveTab] = useState<"profile-landing" | "booking" | "admin-treatments" | "admin-bookings" | "admin-calendar" | "admin-availability" | "admin-gallery" | "admin-reviews" | "profile" | "legal">("profile-landing");
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
// Prevent background scroll when menu is open
@@ -31,6 +35,14 @@ function App() {
}
}
// Handle review submission page
if (path.startsWith('/review/')) {
const token = path.split('/review/')[1];
if (token) {
return <ReviewSubmissionPage token={token} />;
}
}
// Show loading spinner while checking authentication
if (isLoading) {
return (
@@ -48,7 +60,7 @@ function App() {
}
// Show login form if user is not authenticated and trying to access admin features
const needsAuth = !user && (activeTab === "admin-treatments" || activeTab === "admin-bookings" || activeTab === "admin-calendar" || activeTab === "admin-availability" || activeTab === "profile");
const needsAuth = !user && (activeTab === "admin-treatments" || activeTab === "admin-bookings" || activeTab === "admin-calendar" || activeTab === "admin-availability" || activeTab === "admin-gallery" || activeTab === "admin-reviews" || activeTab === "profile");
if (needsAuth) {
return <LoginForm />;
}
@@ -59,12 +71,15 @@ function App() {
}
const tabs = [
{ id: "profile-landing", label: "Startseite", icon: "🏠", requiresAuth: false },
{ id: "booking", label: "Termin buchen", icon: "📅", requiresAuth: false },
{ id: "legal", label: "Impressum/Datenschutz", icon: "📋", requiresAuth: false },
{ id: "admin-treatments", label: "Behandlungen 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-gallery", label: "Photo-Wall", icon: "📸", requiresAuth: true },
{ id: "admin-reviews", label: "Bewertungen", icon: "⭐", requiresAuth: true },
...(user ? [{ id: "profile", label: "Profil", icon: "👤", requiresAuth: true }] : []),
] as const;
@@ -78,7 +93,7 @@ function App() {
<div className="flex justify-between items-center py-6">
<div
className="flex items-center space-x-3 cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => setActiveTab("booking")}
onClick={() => setActiveTab("profile-landing")}
>
<img
src="/assets/stargilnails_logo_transparent_112.png"
@@ -240,6 +255,10 @@ function App() {
{/* Main Content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{activeTab === "profile-landing" && (
<ProfileLanding onNavigateToBooking={() => setActiveTab("booking")} />
)}
{activeTab === "booking" && (
<div>
<div className="text-center mb-8">
@@ -311,6 +330,34 @@ function App() {
</div>
)}
{activeTab === "admin-gallery" && isOwner && (
<div>
<div className="text-center mb-8">
<h2 className="text-3xl font-bold text-gray-900 mb-4">
Photo-Wall verwalten
</h2>
<p className="text-lg text-gray-600">
Lade Fotos hoch und verwalte deine Galerie.
</p>
</div>
<AdminGallery />
</div>
)}
{activeTab === "admin-reviews" && isOwner && (
<div>
<div className="text-center mb-8">
<h2 className="text-3xl font-bold text-gray-900 mb-4">
Bewertungen verwalten
</h2>
<p className="text-lg text-gray-600">
Prüfe und verwalte Kundenbewertungen.
</p>
</div>
<AdminReviews />
</div>
)}
{activeTab === "profile" && user && (
<div>
<div className="text-center mb-8">

View File

@@ -22,7 +22,7 @@ export function AdminAvailability() {
// Neue Queries für wiederkehrende Verfügbarkeiten (mit Authentifizierung)
const { data: recurringRules } = useQuery(
const { data: recurringRules, refetch: refetchRecurringRules } = useQuery(
queryClient.recurringAvailability.live.adminListRules.experimental_liveOptions({
input: { sessionId: localStorage.getItem("sessionId") || "" }
})
@@ -188,6 +188,8 @@ export function AdminAvailability() {
{
onSuccess: () => {
setSuccessMsg(`Regel für ${getDayName(selectedDayOfWeek)} erstellt.`);
// Sofort aktualisieren (zusätzlich zur Live-Subscription), damit Nutzer den Eintrag direkt sieht
refetchRecurringRules();
},
onError: (err: any) => {
setErrorMsg(err?.message || "Fehler beim Erstellen der Regel.");

View File

@@ -0,0 +1,464 @@
import { useState, useEffect, useMemo } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { queryClient } from "@/client/rpc-client";
export function AdminGallery() {
// Component state
const [photoTitle, setPhotoTitle] = useState<string>("");
const [photoPreview, setPhotoPreview] = useState<string>("");
const [photoBase64, setPhotoBase64] = useState<string>("");
const [errorMsg, setErrorMsg] = useState<string>("");
const [successMsg, setSuccessMsg] = useState<string>("");
const [draggedPhotoId, setDraggedPhotoId] = useState<string | null>(null);
// Data fetching with live query
const { data: photos, refetch: refetchPhotos } = useQuery(
queryClient.gallery.live.adminListPhotos.experimental_liveOptions({
input: { sessionId: localStorage.getItem("sessionId") || "" }
})
);
// Memoized sorted photos to avoid re-sorting on every render and prevent cache mutation
const sortedPhotos = useMemo(() => [...(photos || [])].sort((a, b) => a.order - b.order), [photos]);
// Mutations
const { mutate: uploadPhoto, isPending: isUploading } = useMutation(
queryClient.gallery.uploadPhoto.mutationOptions()
);
const { mutate: deletePhoto } = useMutation(
queryClient.gallery.deletePhoto.mutationOptions()
);
const { mutate: updatePhotoOrder } = useMutation(
queryClient.gallery.updatePhotoOrder.mutationOptions()
);
const { mutate: setCoverPhoto } = useMutation(
queryClient.gallery.setCoverPhoto.mutationOptions()
);
// Auto-clear messages after 5 seconds
useEffect(() => {
if (errorMsg) {
const timer = setTimeout(() => setErrorMsg(""), 5000);
return () => clearTimeout(timer);
}
}, [errorMsg]);
useEffect(() => {
if (successMsg) {
const timer = setTimeout(() => setSuccessMsg(""), 5000);
return () => clearTimeout(timer);
}
}, [successMsg]);
// Image compression function (adapted from booking-form.tsx)
const compressImage = (file: File, maxWidth: number = 800, quality: number = 0.8): Promise<string> => {
return new Promise((resolve, reject) => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const img = new Image();
img.onload = () => {
// Calculate new dimensions
let { width, height } = img;
if (width > maxWidth) {
height = (height * maxWidth) / width;
width = maxWidth;
}
canvas.width = width;
canvas.height = height;
// Draw and compress
ctx?.drawImage(img, 0, 0, width, height);
const compressedDataUrl = canvas.toDataURL('image/jpeg', quality);
resolve(compressedDataUrl);
};
img.onerror = reject;
img.src = URL.createObjectURL(file);
});
};
// File upload handler
const handlePhotoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Validate file type
if (!file.type.startsWith('image/')) {
setErrorMsg("Bitte wähle nur Bilddateien aus.");
return;
}
// Check file size (max 1MB)
if (file.size > 1 * 1024 * 1024) {
setErrorMsg("Das Foto ist zu groß. Bitte wähle ein Bild unter 1MB.");
return;
}
try {
// Helper: measure DataURL size via Blob
const getDataUrlSizeBytes = async (dataUrl: string): Promise<number> => {
const res = await fetch(dataUrl);
const blob = await res.blob();
return blob.size;
};
// Try compressing with decreasing quality until <= 1MB or give up
const qualitySteps = [0.8, 0.6, 0.4];
let finalDataUrl = "";
for (const q of qualitySteps) {
const candidate = await compressImage(file, 800, q);
const sizeBytes = await getDataUrlSizeBytes(candidate);
if (sizeBytes <= 1 * 1024 * 1024) {
finalDataUrl = candidate;
break;
}
}
if (!finalDataUrl) {
setErrorMsg("Das komprimierte Bild ist weiterhin größer als 1MB. Bitte wähle ein kleineres Bild.");
return;
}
setPhotoBase64(finalDataUrl);
setPhotoPreview(finalDataUrl);
setErrorMsg("");
} catch (error) {
console.error('Photo compression failed:', error);
setErrorMsg('Fehler beim Verarbeiten des Bildes. Bitte versuche es mit einem anderen Bild.');
return;
}
};
const removePhoto = () => {
setPhotoBase64("");
setPhotoPreview("");
// Reset file input
const fileInput = document.getElementById('gallery-photo-upload') as HTMLInputElement;
if (fileInput) fileInput.value = '';
};
// Drag and drop handlers
const handleDragStart = (e: React.DragEvent, photoId: string) => {
setDraggedPhotoId(photoId);
e.dataTransfer.effectAllowed = 'move';
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
};
const handleDrop = (e: React.DragEvent, targetPhotoId: string) => {
e.preventDefault();
if (!draggedPhotoId || draggedPhotoId === targetPhotoId) {
setDraggedPhotoId(null);
return;
}
const draggedPhoto = photos?.find(p => p.id === draggedPhotoId);
const targetPhoto = photos?.find(p => p.id === targetPhotoId);
if (!draggedPhoto || !targetPhoto) {
setDraggedPhotoId(null);
return;
}
const sessionId = localStorage.getItem("sessionId");
if (!sessionId) {
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
setDraggedPhotoId(null);
return;
}
// Build a fully reordered list based on the current sorted order
const sorted = [...(photos || [])].sort((a, b) => a.order - b.order);
const fromIndex = sorted.findIndex(p => p.id === draggedPhotoId);
const toIndex = sorted.findIndex(p => p.id === targetPhotoId);
if (fromIndex === -1 || toIndex === -1) {
setDraggedPhotoId(null);
return;
}
const reordered = [...sorted];
const [moved] = reordered.splice(fromIndex, 1);
reordered.splice(toIndex, 0, moved);
// Reindex orders 0..n-1
const photoOrders = reordered.map((p, idx) => ({ id: p.id, order: idx }));
updatePhotoOrder(
{
sessionId,
photoOrders
},
{
onSuccess: () => {
setSuccessMsg("Foto-Reihenfolge aktualisiert.");
// Sofortige Aktualisierung der Thumbnails in korrekter Reihenfolge
refetchPhotos();
},
onError: (err: any) => {
setErrorMsg(err?.message || "Fehler beim Aktualisieren der Reihenfolge.");
}
}
);
setDraggedPhotoId(null);
};
const handleDragEnd = () => {
setDraggedPhotoId(null);
};
return (
<div className="max-w-6xl mx-auto space-y-6">
{/* Error and Success Messages */}
{errorMsg && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
<div className="flex items-center">
<svg className="w-5 h-5 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
<span className="font-medium">{errorMsg}</span>
</div>
</div>
)}
{successMsg && (
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-md">
<div className="flex items-center">
<svg className="w-5 h-5 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
<span className="font-medium">{successMsg}</span>
</div>
</div>
)}
{/* Upload Form Section */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold mb-4">Neues Foto hochladen</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Foto auswählen *
</label>
<input
id="gallery-photo-upload"
type="file"
accept="image/*"
onChange={handlePhotoUpload}
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-pink-50 file:text-pink-700 hover:file:bg-pink-100"
/>
<p className="text-xs text-gray-500 mt-1">
Max. 1MB, alle Bildformate erlaubt
</p>
</div>
{photoPreview && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Vorschau
</label>
<div className="relative inline-block">
<img
src={photoPreview}
alt="Foto Vorschau"
className="w-32 h-32 object-cover rounded-lg border border-gray-200"
/>
<button
type="button"
onClick={removePhoto}
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-sm hover:bg-red-600"
>
×
</button>
</div>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Titel (optional)
</label>
<input
type="text"
value={photoTitle}
onChange={(e) => setPhotoTitle(e.target.value)}
placeholder="z.B. French Manicure, Gel Nails..."
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-pink-500"
/>
</div>
<button
onClick={() => {
setErrorMsg("");
setSuccessMsg("");
if (!photoBase64) {
setErrorMsg("Bitte wähle zuerst ein Foto aus.");
return;
}
const sessionId = localStorage.getItem("sessionId") || "";
if (!sessionId) {
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
return;
}
uploadPhoto(
{
sessionId,
base64Data: photoBase64,
title: photoTitle || undefined
},
{
onSuccess: () => {
setSuccessMsg("Foto erfolgreich hochgeladen.");
setPhotoTitle("");
setPhotoPreview("");
setPhotoBase64("");
// Reset file input
const fileInput = document.getElementById('gallery-photo-upload') as HTMLInputElement;
if (fileInput) fileInput.value = '';
// Sofortige Aktualisierung der Liste
refetchPhotos();
},
onError: (err: any) => {
setErrorMsg(err?.message || "Fehler beim Hochladen des Fotos.");
}
}
);
}}
disabled={isUploading}
className={`bg-pink-600 text-white px-4 py-2 rounded-md font-medium transition-colors ${isUploading ? 'opacity-60 cursor-not-allowed' : 'hover:bg-pink-700'}`}
>
{isUploading ? 'Lädt hoch…' : 'Foto hochladen'}
</button>
</div>
</div>
{/* Photo Grid Section */}
<div className="bg-white rounded-lg shadow">
<div className="p-4 border-b">
<h3 className="text-lg font-semibold">Foto-Galerie verwalten</h3>
<p className="text-sm text-gray-600 mt-1">
Ziehe Fotos per Drag & Drop, um die Reihenfolge zu ändern. Klicke auf das X, um ein Foto zu löschen.
</p>
</div>
<div className="p-6">
{photos?.length === 0 && (
<div className="text-center text-gray-500 py-8">
<div className="text-lg font-medium mb-2">Noch keine Fotos hochgeladen</div>
<div className="text-sm">Lade dein erstes Foto hoch, um deine Galerie zu starten.</div>
</div>
)}
{sortedPhotos && sortedPhotos.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{sortedPhotos.map((photo) => (
<div
key={photo.id}
draggable={true}
onDragStart={(e) => handleDragStart(e, photo.id)}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, photo.id)}
onDragEnd={handleDragEnd}
className={`relative bg-gray-50 rounded-lg overflow-hidden border-2 transition-all duration-200 hover:shadow-md cursor-move ${
draggedPhotoId === photo.id ? 'opacity-50 border-pink-300' : 'border-transparent'
}`}
>
<img
src={photo.base64Data}
alt={photo.title || "Galerie Foto"}
className="w-full h-48 object-cover"
/>
<div className="p-3">
<div className="flex items-center justify-between">
<div className="flex-1">
{photo.title && (
<div className="font-medium text-gray-900 text-sm mb-1">
{photo.title}
</div>
)}
<div className="text-xs text-gray-500">
Reihenfolge: {photo.order}
</div>
<div className="text-xs text-gray-400">
{new Date(photo.createdAt).toLocaleDateString('de-DE')}
</div>
</div>
<button
onClick={() => {
if (confirm("Möchtest du dieses Foto wirklich löschen?")) {
const sessionId = localStorage.getItem("sessionId");
if (!sessionId) {
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
return;
}
deletePhoto(
{ sessionId, id: photo.id },
{
onSuccess: () => {
setSuccessMsg("Foto gelöscht.");
// Sofortige Aktualisierung der Liste
refetchPhotos();
},
onError: (err: any) => {
setErrorMsg(err?.message || "Fehler beim Löschen des Fotos.");
}
}
);
}
}}
className="ml-2 text-red-600 hover:text-red-800 hover:bg-red-50 rounded-full p-1 transition-colors"
title="Foto löschen"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<button
onClick={() => {
const sessionId = localStorage.getItem("sessionId");
if (!sessionId) {
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
return;
}
setCoverPhoto(
{ sessionId, id: photo.id },
{
onSuccess: () => setSuccessMsg("Als Cover-Bild gesetzt."),
onError: (err: any) => setErrorMsg(err?.message || "Fehler beim Setzen des Cover-Bildes."),
}
);
}}
className={`ml-2 ${photo.cover ? 'text-green-700 hover:text-green-800 hover:bg-green-50' : 'text-gray-600 hover:text-gray-800 hover:bg-gray-50'} rounded-full p-1 transition-colors`}
title={photo.cover ? "Cover-Bild (aktiv)" : "Als Cover-Bild setzen"}
>
{photo.cover ? (
<svg className="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-7.25 7.25a1 1 0 01-1.414 0l-3-3a1 1 0 111.414-1.414L8.5 11.086l6.543-6.543a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l6-6 4 4 6-6" />
</svg>
)}
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,356 @@
import React, { useState, useEffect } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { queryClient } from "@/client/rpc-client";
type ReviewStatus = "pending" | "approved" | "rejected";
type Review = {
id: string;
bookingId: string;
customerName: string;
customerEmail?: string;
rating: number;
comment: string;
status: "pending" | "approved" | "rejected";
createdAt: string;
reviewedAt?: string;
reviewedBy?: string;
};
function getStatusText(status: string) {
switch (status) {
case "pending":
return "Ausstehend";
case "approved":
return "Genehmigt";
case "rejected":
return "Abgelehnt";
default:
return status;
}
}
function getStatusColor(status: string) {
switch (status) {
case "pending":
return "bg-yellow-100 text-yellow-800";
case "approved":
return "bg-green-100 text-green-800";
case "rejected":
return "bg-red-100 text-red-800";
default:
return "bg-gray-100 text-gray-800";
}
}
function renderStars(rating: number) {
const stars = [] as React.ReactElement[];
for (let i = 1; i <= 5; i++) {
const filled = i <= rating;
stars.push(
<span key={i} className={filled ? "text-yellow-500" : "text-gray-300"}>
{filled ? "★" : "☆"}
</span>
);
}
return <div className="text-lg">{stars}</div>;
}
function formatDate(isoString?: string) {
if (!isoString) return "";
try {
return new Date(isoString).toLocaleDateString("de-DE", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return isoString || "";
}
}
export function AdminReviews() {
const [activeStatusTab, setActiveStatusTab] = useState<ReviewStatus>("pending");
const [successMsg, setSuccessMsg] = useState<string>("");
const [errorMsg, setErrorMsg] = useState<string>("");
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(null);
useEffect(() => {
if (errorMsg) {
const t = setTimeout(() => setErrorMsg(""), 5000);
return () => clearTimeout(t);
}
}, [errorMsg]);
useEffect(() => {
if (successMsg) {
const t = setTimeout(() => setSuccessMsg(""), 5000);
return () => clearTimeout(t);
}
}, [successMsg]);
const sessionId = typeof window !== "undefined" ? localStorage.getItem("sessionId") || "" : "";
const { data: reviews } = useQuery(
queryClient.reviews.live.adminListReviews.experimental_liveOptions({
input: { sessionId, statusFilter: activeStatusTab },
})
);
// Separate queries for quick stats calculation
const { data: allReviews } = useQuery(
queryClient.reviews.live.adminListReviews.experimental_liveOptions({
input: { sessionId },
})
);
const { mutate: approveReview } = useMutation(
queryClient.reviews.approveReview.mutationOptions({
onSuccess: () => {
setSuccessMsg("Bewertung wurde genehmigt.");
},
onError: (err: any) => {
setErrorMsg(err?.message || "Fehler beim Genehmigen der Bewertung.");
},
})
);
const { mutate: rejectReview } = useMutation(
queryClient.reviews.rejectReview.mutationOptions({
onSuccess: () => {
setSuccessMsg("Bewertung wurde abgelehnt.");
},
onError: (err: any) => {
setErrorMsg(err?.message || "Fehler beim Ablehnen der Bewertung.");
},
})
);
const { mutate: deleteReview } = useMutation(
queryClient.reviews.deleteReview.mutationOptions({
onSuccess: () => {
setSuccessMsg("Bewertung wurde gelöscht.");
setShowDeleteConfirm(null);
},
onError: (err: any) => {
setErrorMsg(err?.message || "Fehler beim Löschen der Bewertung.");
setShowDeleteConfirm(null);
},
})
);
// Calculate quick stats from full dataset
const pendingCount = allReviews?.filter((r: Review) => r.status === "pending").length || 0;
const approvedCount = allReviews?.filter((r: Review) => r.status === "approved").length || 0;
const rejectedCount = allReviews?.filter((r: Review) => r.status === "rejected").length || 0;
const totalCount = allReviews?.length || 0;
return (
<div className="max-w-6xl mx-auto space-y-6">
{(successMsg || errorMsg) && (
<div className="mb-4">
{errorMsg && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
<div className="flex items-center">
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
<span className="font-medium">Fehler:</span>
<span className="ml-1">{errorMsg}</span>
</div>
</div>
)}
{successMsg && (
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-md">
<div className="flex items-center">
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
<span className="font-medium">Erfolg:</span>
<span className="ml-1">{successMsg}</span>
</div>
</div>
)}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-lg shadow p-4">
<div className="text-2xl font-bold text-yellow-600">{pendingCount}</div>
<div className="text-sm text-gray-600">Ausstehend</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-2xl font-bold text-green-600">{approvedCount}</div>
<div className="text-sm text-gray-600">Genehmigt</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-2xl font-bold text-red-600">{rejectedCount}</div>
<div className="text-sm text-gray-600">Abgelehnt</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-2xl font-bold text-gray-600">{totalCount}</div>
<div className="text-sm text-gray-600">Summe</div>
</div>
</div>
<div className="bg-white rounded-lg shadow">
<div className="border-b border-gray-200">
<nav className="-mb-px flex space-x-8 px-6">
<button
onClick={() => setActiveStatusTab("pending")}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeStatusTab === "pending"
? "border-pink-500 text-pink-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
Ausstehend
</button>
<button
onClick={() => setActiveStatusTab("approved")}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeStatusTab === "approved"
? "border-pink-500 text-pink-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
Genehmigt
</button>
<button
onClick={() => setActiveStatusTab("rejected")}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeStatusTab === "rejected"
? "border-pink-500 text-pink-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
Abgelehnt
</button>
</nav>
</div>
</div>
<div className="bg-white rounded-lg shadow">
{(!reviews || reviews.length === 0) && (
<div className="p-8 text-center text-gray-500">
<div className="text-lg font-medium mb-2">Keine Bewertungen gefunden</div>
<div className="text-sm">Es liegen aktuell keine Bewertungen in dieser Ansicht vor.</div>
</div>
)}
<div className="divide-y">
{reviews?.map((review: Review) => (
<div key={review.id} className="p-6 hover:bg-gray-50 transition-colors">
<div className="flex items-start justify-between">
<div>
<div className="text-lg font-semibold text-gray-900">{review.customerName}</div>
<div className="text-sm text-gray-500">{review.customerEmail || "—"}</div>
</div>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(review.status)}`}>
{getStatusText(review.status)}
</span>
</div>
<div className="mt-2">{renderStars(review.rating)}</div>
<div className="mt-2 text-gray-700 whitespace-pre-wrap break-words">{review.comment}</div>
<div className="mt-2 text-sm text-gray-500 space-x-4">
<span>Buchung: {review.bookingId}</span>
<span>Eingereicht am: {formatDate(review.createdAt)}</span>
{review.reviewedAt && <span>Geprüft am: {formatDate(review.reviewedAt)}</span>}
</div>
<div className="mt-4 flex items-center gap-2">
{review.status === "pending" && (
<>
<button
onClick={() => approveReview({ sessionId, id: review.id })}
className="bg-green-600 hover:bg-green-700 text-white px-3 py-1.5 rounded-md text-sm"
>
Genehmigen
</button>
<button
onClick={() => rejectReview({ sessionId, id: review.id })}
className="bg-red-600 hover:bg-red-700 text-white px-3 py-1.5 rounded-md text-sm"
>
Ablehnen
</button>
<button
onClick={() => setShowDeleteConfirm(review.id)}
className="bg-gray-600 hover:bg-gray-700 text-white px-3 py-1.5 rounded-md text-sm"
>
Löschen
</button>
</>
)}
{review.status === "approved" && (
<>
<button
onClick={() => rejectReview({ sessionId, id: review.id })}
className="bg-red-600 hover:bg-red-700 text-white px-3 py-1.5 rounded-md text-sm"
>
Ablehnen
</button>
<button
onClick={() => setShowDeleteConfirm(review.id)}
className="bg-gray-600 hover:bg-gray-700 text-white px-3 py-1.5 rounded-md text-sm"
>
Löschen
</button>
</>
)}
{review.status === "rejected" && (
<>
<button
onClick={() => approveReview({ sessionId, id: review.id })}
className="bg-green-600 hover:bg-green-700 text-white px-3 py-1.5 rounded-md text-sm"
>
Genehmigen
</button>
<button
onClick={() => setShowDeleteConfirm(review.id)}
className="bg-gray-600 hover:bg-gray-700 text-white px-3 py-1.5 rounded-md text-sm"
>
Löschen
</button>
</>
)}
</div>
</div>
))}
</div>
</div>
{showDeleteConfirm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<h3 className="text-lg font-medium text-gray-900 mb-4">Bewertung löschen</h3>
<p className="text-gray-600 mb-6">
Bist du sicher, dass du diese Bewertung löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.
</p>
<div className="flex space-x-3">
<button
onClick={() => deleteReview({ sessionId, id: showDeleteConfirm })}
className="flex-1 bg-red-600 text-white py-2 px-4 rounded-md hover:bg-red-700 transition-colors"
>
Ja, löschen
</button>
<button
onClick={() => setShowDeleteConfirm(null)}
className="flex-1 bg-gray-200 text-gray-800 py-2 px-4 rounded-md hover:bg-gray-300 transition-colors"
>
Abbrechen
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,226 @@
import { useQuery } from "@tanstack/react-query";
import { queryClient } from "@/client/rpc-client";
interface ProfileLandingProps {
onNavigateToBooking: () => void;
}
function StarRating({ rating }: { rating: number }) {
return (
<div className="flex space-x-1">
{[1, 2, 3, 4, 5].map((star) => (
<span
key={star}
className={star <= rating ? "text-yellow-400" : "text-gray-300"}
>
</span>
))}
</div>
);
}
function getDayName(dayOfWeek: number): string {
const days = [
"Sonntag",
"Montag",
"Dienstag",
"Mittwoch",
"Donnerstag",
"Freitag",
"Samstag",
];
return days[dayOfWeek];
}
function formatDate(date: Date): string {
return date.toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
export function ProfileLanding({ onNavigateToBooking }: ProfileLandingProps) {
// Data fetching with live queries
const { data: galleryPhotos = [] } = useQuery(
queryClient.gallery.live.listPhotos.experimental_liveOptions()
);
const sortedPhotos = ([...galleryPhotos] as any[]).sort((a, b) => (a.order || 0) - (b.order || 0));
const featuredPhoto = sortedPhotos[0];
const { data: reviews = [] } = useQuery(
queryClient.reviews.live.listPublishedReviews.experimental_liveOptions()
);
const { data: recurringRules = [] } = useQuery(
queryClient.recurringAvailability.live.listRules.experimental_liveOptions()
);
// Calculate next 7 days for opening hours
const getNext7Days = () => {
const days: Date[] = [];
const today = new Date();
for (let i = 0; i < 7; i++) {
const date = new Date(today);
date.setDate(today.getDate() + i);
days.push(date);
}
return days;
};
const next7Days = getNext7Days();
return (
<div className="max-w-6xl mx-auto space-y-12 py-8">
{/* Hero Section */}
<div className="bg-gradient-to-r from-pink-500 to-purple-600 rounded-lg shadow-lg p-8 text-white">
<h1 className="text-4xl md:text-5xl font-bold mb-4">
Stargirlnails Kiel
</h1>
<p className="text-xl mb-2">Professionelles Nageldesign und -Pflege in Kiel</p>
<p className="text-lg mb-8 opacity-90">
Lass dich von mir verwöhnen und genieße hochwertige Nail Art und Pflegebehandlungen.
</p>
<button
onClick={onNavigateToBooking}
className="bg-pink-600 text-white py-4 px-8 rounded-lg hover:bg-pink-700 text-lg font-semibold shadow-lg transition-colors w-full md:w-auto"
>
Termin buchen
</button>
</div>
{/* Featured Section: Erstes Foto (Reihenfolge 0) */}
{featuredPhoto && (
<div className="bg-white rounded-lg shadow-lg p-0 overflow-hidden">
<img
src={(featuredPhoto as any).base64Data}
alt={(featuredPhoto as any).title || "Featured"}
className="w-full h-auto object-contain"
loading="eager"
decoding="async"
/>
{(featuredPhoto as any).title && (
<div className="p-4 border-t">
<h2 className="text-xl font-semibold text-gray-900">{(featuredPhoto as any).title}</h2>
</div>
)}
</div>
)}
{/* Photo Gallery Section */}
<div className="bg-white rounded-lg shadow-lg p-6">
<h2 className="text-2xl font-bold text-gray-900 mb-6">Unsere Arbeiten</h2>
{galleryPhotos.length > 0 ? (
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{(sortedPhotos as typeof galleryPhotos)
.filter((p) => (featuredPhoto ? (p as any).id !== (featuredPhoto as any).id : true))
.slice(0, 9)
.map((photo, index) => (
<img
key={photo.id || index}
src={photo.base64Data}
alt={photo.title || "Gallery"}
loading="lazy"
decoding="async"
className="w-full h-48 object-cover rounded-lg shadow-md"
/>
))}
</div>
) : (
<p className="text-gray-600 text-center py-8">
Galerie wird bald aktualisiert
</p>
)}
</div>
{/* Opening Hours Section */}
<div className="bg-white rounded-lg shadow-lg p-6">
<h2 className="text-2xl font-bold text-gray-900 mb-6">
Öffnungszeiten (Nächste 7 Tage)
</h2>
<div className="space-y-2">
{next7Days.map((date, index) => {
const dayOfWeek = date.getDay();
const dayRules = recurringRules.filter(
(rule) => rule.isActive && rule.dayOfWeek === dayOfWeek
);
const sorted = [...dayRules].sort((a, b) =>
a.startTime.localeCompare(b.startTime)
);
return (
<div
key={index}
className={`p-4 rounded-lg ${
index % 2 === 0 ? "bg-gray-50" : "bg-white"
}`}
>
<div className="flex justify-between items-center">
<span className="font-semibold text-gray-900">
{getDayName(dayOfWeek)}, {formatDate(date)}
</span>
<div className="text-gray-700">
{dayRules.length > 0 ? (
<div className="space-y-1">
{sorted.map((rule) => (
<div
key={`${rule.dayOfWeek}-${rule.startTime}-${rule.endTime}`}
>
{rule.startTime} - {rule.endTime} Uhr
</div>
))}
</div>
) : (
<span className="text-gray-500">Geschlossen</span>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
{/* Customer Reviews Section */}
<div className="bg-white rounded-lg shadow-lg p-6">
<h2 className="text-2xl font-bold text-gray-900 mb-6">
Kundenbewertungen
</h2>
{reviews.length > 0 ? (
<div className="space-y-4">
{reviews.slice(0, 10).map((review) => (
<div
key={
(review as any).id ||
(review as any).bookingId ||
`${(review as any).createdAt}-${(review as any).customerName}`
}
className="bg-gray-50 p-4 rounded-lg shadow-md"
>
<div className="flex justify-between items-start mb-2">
<div>
<h3 className="font-semibold text-gray-900">
{review.customerName}
</h3>
<StarRating rating={review.rating} />
</div>
<span className="text-sm text-gray-500">
{new Date(review.createdAt).toLocaleDateString("de-DE")}
</span>
</div>
{review.comment && (
<p className="text-gray-700 mt-2">{review.comment}</p>
)}
</div>
))}
</div>
) : (
<p className="text-gray-600 text-center py-8">
Noch keine Bewertungen vorhanden
</p>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,222 @@
import React, { useMemo, useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { queryClient } from "@/client/rpc-client";
interface ReviewSubmissionPageProps {
token: string;
}
export default function ReviewSubmissionPage({ token }: ReviewSubmissionPageProps) {
const [rating, setRating] = useState<number | null>(null);
const [hoverRating, setHoverRating] = useState<number | null>(null);
const [comment, setComment] = useState("");
const [submitResult, setSubmitResult] = useState<{ success: boolean; message: string } | null>(null);
// Fetch booking info by token
const bookingQuery = useQuery({
...queryClient.cancellation.getBookingByToken.queryOptions({ input: { token } }),
staleTime: 0,
});
const isCompleted = bookingQuery.data?.status === "completed";
const submitMutation = useMutation({
...queryClient.reviews.submitReview.mutationOptions(),
onSuccess: () => {
setSubmitResult({ success: true, message: "Danke für deine Bewertung! Sie wird nach Prüfung veröffentlicht." });
},
onError: (error: any) => {
setSubmitResult({ success: false, message: error?.message || "Ein Fehler ist aufgetreten." });
},
});
const canSubmit = useMemo(() => {
return !!rating && comment.trim().length >= 10 && isCompleted && !submitMutation.isPending;
}, [rating, comment, isCompleted, submitMutation.isPending]);
const handleSubmit = () => {
setSubmitResult(null);
const trimmedComment = comment.trim();
if (rating == null || rating < 1 || rating > 5) {
setSubmitResult({ success: false, message: "Bitte wähle eine Bewertung von 1 bis 5 Sternen." });
return;
}
if (trimmedComment.length < 10) {
setSubmitResult({ success: false, message: "Der Kommentar muss mindestens 10 Zeichen enthalten." });
return;
}
if (!isCompleted) {
setSubmitResult({ success: false, message: "Bewertungen sind nur für abgeschlossene Termine möglich." });
return;
}
submitMutation.mutate({ bookingToken: token, rating, comment: trimmedComment });
};
if (bookingQuery.isLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 flex items-center justify-center p-4">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full">
<div className="flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-pink-500"></div>
<span className="ml-3 text-gray-600">Lade Buchung...</span>
</div>
</div>
</div>
);
}
if (bookingQuery.error || !bookingQuery.data) {
return (
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 flex items-center justify-center p-4">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center">
<div className="w-16 h-16 mx-auto mb-4 bg-red-100 rounded-full flex items-center justify-center">
<svg className="w-8 h-8 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<h2 className="text-xl font-bold text-gray-900 mb-2">Link nicht verfügbar</h2>
<p className="text-gray-600 mb-4">Dieser Link ist ungültig oder abgelaufen.</p>
<a href="/" className="inline-flex items-center px-4 py-2 bg-pink-600 text-white rounded-lg hover:bg-pink-700 transition-colors">Zur Startseite</a>
</div>
</div>
);
}
// Guard: Only allow reviews for completed bookings
if (!isCompleted) {
return (
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 flex items-center justify-center p-4">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-lg w-full">
<div className="text-center">
<img src="/assets/stargilnails_logo_transparent_112.png" alt="Stargil Nails Logo" className="w-16 h-16 mx-auto mb-4 object-contain" />
<h1 className="text-2xl font-bold text-gray-900">Bewertung abgeben</h1>
<p className="text-gray-600 mt-2">Bewertungen sind nur für abgeschlossene Termine möglich.</p>
<div className="mt-6">
<a href="/" className="inline-flex items-center text-pink-600 hover:text-pink-700 font-medium">
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Zurück zur Startseite
</a>
</div>
</div>
</div>
</div>
);
}
const booking = bookingQuery.data;
return (
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 py-8 px-4">
<div className="max-w-2xl mx-auto">
{/* Header */}
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<img src="/assets/stargilnails_logo_transparent_112.png" alt="Stargil Nails Logo" className="w-16 h-16 object-contain" />
<span className="px-4 py-2 rounded-full text-sm font-semibold bg-pink-100 text-pink-800"> Bewertung</span>
</div>
<h1 className="text-2xl font-bold text-gray-900">Bewertung abgeben</h1>
<p className="text-gray-600 mt-1">Teile deine Erfahrung mit uns das hilft anderen Kundinnen!</p>
</div>
{/* Booking Details */}
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<svg className="w-5 h-5 mr-2 text-pink-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
Termin-Details
</h2>
<div className="space-y-3 text-sm">
<div className="flex justify-between py-2 border-b border-gray-100">
<span className="text-gray-600">Datum:</span>
<span className="font-medium text-gray-900">{booking.formattedDate}</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-100">
<span className="text-gray-600">Uhrzeit:</span>
<span className="font-medium text-gray-900">{booking.appointmentTime} Uhr</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-100">
<span className="text-gray-600">Behandlung:</span>
<span className="font-medium text-gray-900">{booking.treatmentName}</span>
</div>
<div className="flex justify-between py-2">
<span className="text-gray-600">Name:</span>
<span className="font-medium text-gray-900">{booking.customerName}</span>
</div>
</div>
</div>
{/* Result Banner */}
{submitResult && (
<div className={`mb-6 p-4 rounded-lg ${submitResult.success ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'}`}>
<p className={submitResult.success ? 'text-green-800' : 'text-red-800'}>{submitResult.message}</p>
</div>
)}
{/* Review Form */}
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Deine Bewertung</h2>
{/* Stars */}
<div className="flex items-center mb-4">
{[1,2,3,4,5].map((star) => {
const isActive = (hoverRating ?? rating ?? 0) >= star;
return (
<button
key={star}
type="button"
className={`text-3xl mr-2 transition-colors ${isActive ? 'text-yellow-400' : 'text-gray-300'} ${submitMutation.isPending ? 'cursor-not-allowed' : 'cursor-pointer'}`}
onMouseEnter={() => !submitMutation.isPending && setHoverRating(star)}
onMouseLeave={() => !submitMutation.isPending && setHoverRating(null)}
onClick={() => !submitMutation.isPending && setRating(star)}
aria-label={`${star} Sterne`}
>
</button>
);
})}
</div>
{!rating && <p className="text-sm text-red-600 mb-2">Bitte wähle eine Bewertung von 1 bis 5 Sternen.</p>}
{/* Comment */}
<label className="block text-sm font-medium text-gray-700 mb-1">Kommentar</label>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Teile deine Erfahrung mit uns..."
rows={5}
className="w-full p-3 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-pink-500"
disabled={submitMutation.isPending || !!submitResult?.success}
/>
{comment.trim().length > 0 && comment.trim().length < 10 && (
<p className="text-sm text-red-600 mt-1">Der Kommentar muss mindestens 10 Zeichen enthalten.</p>
)}
{/* Submit */}
<button
onClick={handleSubmit}
disabled={!canSubmit || !!submitResult?.success}
className="mt-4 w-full bg-pink-600 text-white py-3 px-4 rounded-lg font-medium hover:bg-pink-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{submitMutation.isPending ? 'Sende Bewertung...' : 'Bewertung absenden'}
</button>
<p className="text-xs text-gray-500 mt-3">Mit dem Absenden stimmst du der Veröffentlichung deiner Bewertung nach Prüfung zu.</p>
</div>
{/* Footer */}
<div className="text-center">
<a href="/" className="inline-flex items-center text-pink-600 hover:text-pink-700 font-medium">
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Zurück zur Startseite
</a>
</div>
</div>
</div>
);
}

17
src/server/lib/auth.ts Normal file
View File

@@ -0,0 +1,17 @@
import { createKV } from "./create-kv.js";
type Session = { id: string; userId: string; expiresAt: string; createdAt: string };
type User = { id: string; username: string; email: string; passwordHash: string; role: "customer" | "owner"; createdAt: string };
export const sessionsKV = createKV<Session>("sessions");
export const usersKV = createKV<User>("users");
export async function assertOwner(sessionId: string): Promise<void> {
const session = await sessionsKV.getItem(sessionId);
if (!session) throw new Error("Invalid session");
if (new Date(session.expiresAt) < new Date()) throw new Error("Session expired");
const user = await usersKV.getItem(session.userId);
if (!user || user.role !== "owner") throw new Error("Forbidden");
}

View File

@@ -85,8 +85,8 @@ export async function renderBookingPendingHTML(params: { name: string; date: str
return renderBrandedEmail("Deine Terminanfrage ist eingegangen", inner);
}
export async function renderBookingConfirmedHTML(params: { name: string; date: string; time: string; cancellationUrl?: string }) {
const { name, date, time, cancellationUrl } = params;
export async function renderBookingConfirmedHTML(params: { name: string; date: string; time: string; cancellationUrl?: string; reviewUrl?: string }) {
const { name, date, time, cancellationUrl, reviewUrl } = params;
const formattedDate = formatDateGerman(date);
const domain = process.env.DOMAIN || 'localhost:5173';
const protocol = domain.includes('localhost') ? 'http' : 'https';
@@ -107,6 +107,14 @@ export async function renderBookingConfirmedHTML(params: { name: string; date: s
<a href="${cancellationUrl}" style="display: inline-block; background-color: #db2777; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">Termin ansehen & verwalten</a>
</div>
` : ''}
${reviewUrl ? `
<div style="background-color: #eff6ff; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #3b82f6;">⭐ Bewertung abgeben:</p>
<p style="margin: 8px 0 12px 0; color: #475569;">Nach deinem Termin würden wir uns über deine Bewertung freuen!</p>
<a href="${reviewUrl}" style="display: inline-block; background-color: #3b82f6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">Bewertung schreiben</a>
<p style="margin: 12px 0 0 0; color: #64748b; font-size: 13px;">Du kannst deine Bewertung nach dem Termin über diesen Link abgeben.</p>
</div>
` : ''}
<div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #3b82f6;">📋 Rechtliche Informationen:</p>
<p style="margin: 8px 0 12px 0; color: #475569;">Weitere Informationen findest du in unserem <a href="${legalUrl}" style="color: #3b82f6; text-decoration: underline;">Impressum und Datenschutz</a>.</p>

View File

@@ -426,7 +426,8 @@ const updateStatus = os
name: booking.customerName,
date: booking.appointmentDate,
time: booking.appointmentTime,
cancellationUrl: bookingUrl // Now points to booking status page
cancellationUrl: bookingUrl, // Now points to booking status page
reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`)
});
// Get treatment information for ICS file
@@ -609,7 +610,8 @@ const createManual = os
name: input.customerName,
date: input.appointmentDate,
time: input.appointmentTime,
cancellationUrl: bookingUrl
cancellationUrl: bookingUrl,
reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`)
});
await sendEmailWithAGBAndCalendar({
@@ -774,11 +776,13 @@ export const router = {
// Send confirmation to customer (no BCC to avoid duplicate admin emails)
if (updated.customerEmail) {
const bookingAccessToken = await queryClient.cancellation.createToken({ bookingId: updated.id });
const html = await renderBookingConfirmedHTML({
name: updated.customerName,
date: updated.appointmentDate,
time: updated.appointmentTime,
cancellationUrl: generateUrl(`/booking/${(await queryClient.cancellation.createToken({ bookingId: updated.id })).token}`),
cancellationUrl: generateUrl(`/booking/${bookingAccessToken.token}`),
reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`),
});
await sendEmailWithAGBAndCalendar({
to: updated.customerEmail,
@@ -827,11 +831,12 @@ export const router = {
// Notify customer that original stays
if (booking.customerEmail) {
const bookingAccessToken = await queryClient.cancellation.createToken({ bookingId: booking.id });
await sendEmail({
to: booking.customerEmail,
subject: "Terminänderung abgelehnt",
text: `Du hast den Vorschlag zur Terminänderung abgelehnt. Dein ursprünglicher Termin am ${formatDateGerman(booking.appointmentDate)} um ${booking.appointmentTime} bleibt bestehen.`,
html: await renderBookingConfirmedHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime, cancellationUrl: generateUrl(`/booking/${(await queryClient.cancellation.createToken({ bookingId: booking.id })).token}`) }),
html: await renderBookingConfirmedHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime, cancellationUrl: generateUrl(`/booking/${bookingAccessToken.token}`), reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`) }),
}).catch(() => {});
}

150
src/server/rpc/gallery.ts Normal file
View File

@@ -0,0 +1,150 @@
import { call, os } from "@orpc/server";
import { z } from "zod";
import { randomUUID } from "crypto";
import { createKV } from "../lib/create-kv.js";
import { assertOwner } from "../lib/auth.js";
// Schema Definition
const GalleryPhotoSchema = z.object({
id: z.string(),
base64Data: z.string(),
title: z.string().optional().default(""),
order: z.number().int(),
createdAt: z.string(),
cover: z.boolean().optional().default(false),
});
export type GalleryPhoto = z.output<typeof GalleryPhotoSchema>;
// KV Storage
const galleryPhotosKV = createKV<GalleryPhoto>("galleryPhotos");
// Authentication centralized in ../lib/auth.ts
// CRUD Endpoints
const uploadPhoto = os
.input(
z.object({
sessionId: z.string(),
base64Data: z
.string()
.regex(/^data:image\/(png|jpe?g|webp|gif);base64,/i, 'Unsupported image format'),
title: z.string().optional().default(""),
})
)
.handler(async ({ input }) => {
try {
await assertOwner(input.sessionId);
const id = randomUUID();
const existing = await galleryPhotosKV.getAllItems();
const maxOrder = existing.length > 0 ? Math.max(...existing.map((p) => p.order)) : -1;
const nextOrder = maxOrder + 1;
const photo: GalleryPhoto = {
id,
base64Data: input.base64Data,
title: input.title ?? "",
order: nextOrder,
createdAt: new Date().toISOString(),
cover: false,
};
await galleryPhotosKV.setItem(id, photo);
return photo;
} catch (err) {
console.error("gallery.uploadPhoto error", err);
throw err;
}
});
const setCoverPhoto = os
.input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const all = await galleryPhotosKV.getAllItems();
let updatedCover: GalleryPhoto | null = null;
for (const p of all) {
const isCover = p.id === input.id;
const next: GalleryPhoto = { ...p, cover: isCover };
await galleryPhotosKV.setItem(p.id, next);
if (isCover) updatedCover = next;
}
return updatedCover;
});
const deletePhoto = os
.input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
await galleryPhotosKV.removeItem(input.id);
});
const updatePhotoOrder = os
.input(
z.object({
sessionId: z.string(),
photoOrders: z.array(z.object({ id: z.string(), order: z.number().int() })),
})
)
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const updated: GalleryPhoto[] = [];
for (const { id, order } of input.photoOrders) {
const existing = await galleryPhotosKV.getItem(id);
if (!existing) continue;
const updatedPhoto: GalleryPhoto = { ...existing, order };
await galleryPhotosKV.setItem(id, updatedPhoto);
updated.push(updatedPhoto);
}
const all = await galleryPhotosKV.getAllItems();
return all.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
});
const listPhotos = os.handler(async () => {
const all = await galleryPhotosKV.getAllItems();
return all.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
});
const adminListPhotos = os
.input(z.object({ sessionId: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const all = await galleryPhotosKV.getAllItems();
return all.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
});
// Live Queries
const live = {
listPhotos: os.handler(async function* ({ signal }) {
yield call(listPhotos, {}, { signal });
for await (const _ of galleryPhotosKV.subscribe()) {
yield call(listPhotos, {}, { signal });
}
}),
adminListPhotos: os
.input(z.object({ sessionId: z.string() }))
.handler(async function* ({ input, signal }) {
await assertOwner(input.sessionId);
const all = await galleryPhotosKV.getAllItems();
const sorted = all.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
yield sorted;
for await (const _ of galleryPhotosKV.subscribe()) {
const updated = await galleryPhotosKV.getAllItems();
const sortedUpdated = updated.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
yield sortedUpdated;
}
}),
};
export const router = {
uploadPhoto,
deletePhoto,
updatePhotoOrder,
listPhotos,
adminListPhotos,
setCoverPhoto,
live,
};

View File

@@ -5,6 +5,8 @@ import { router as auth } from "./auth.js";
import { router as recurringAvailability } from "./recurring-availability.js";
import { router as cancellation } from "./cancellation.js";
import { router as legal } from "./legal.js";
import { router as gallery } from "./gallery.js";
import { router as reviews } from "./reviews.js";
export const router = {
demo,
@@ -14,4 +16,6 @@ export const router = {
recurringAvailability,
cancellation,
legal,
gallery,
reviews,
};

View File

@@ -2,6 +2,7 @@ import { call, os } from "@orpc/server";
import { z } from "zod";
import { randomUUID } from "crypto";
import { createKV } from "../lib/create-kv.js";
import { assertOwner } from "../lib/auth.js";
// Datenmodelle
const RecurringRuleSchema = z.object({
@@ -35,19 +36,7 @@ const timeOffPeriodsKV = createKV<TimeOffPeriod>("timeOffPeriods");
const bookingsKV = createKV<any>("bookings");
const treatmentsKV = createKV<any>("treatments");
// Owner-Authentifizierung
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");
}
// Owner-Authentifizierung zentralisiert in ../lib/auth.ts
// Helper-Funktionen
function parseTime(timeStr: string): number {

294
src/server/rpc/reviews.ts Normal file
View File

@@ -0,0 +1,294 @@
import { call, os } from "@orpc/server";
import { z } from "zod";
import { randomUUID } from "crypto";
import { createKV } from "../lib/create-kv.js";
import { assertOwner, sessionsKV } from "../lib/auth.js";
// Schema Definition
const ReviewSchema = z.object({
id: z.string(),
bookingId: z.string(),
customerName: z.string().min(2, "Kundenname muss mindestens 2 Zeichen lang sein"),
customerEmail: z.string().email("Ungültige E-Mail-Adresse").optional(),
rating: z.number().int().min(1).max(5),
comment: z.string().min(10, "Kommentar muss mindestens 10 Zeichen lang sein"),
status: z.enum(["pending", "approved", "rejected"]),
createdAt: z.string(),
reviewedAt: z.string().optional(),
reviewedBy: z.string().optional(),
});
export type Review = z.output<typeof ReviewSchema>;
// Public-safe review type for listings on the website
export type PublicReview = {
customerName: string;
rating: number;
comment: string;
status: "pending" | "approved" | "rejected";
bookingId: string;
createdAt: string;
};
// KV Storage
const reviewsKV = createKV<Review>("reviews");
// References to other KV stores needed for validation with strong typing
type BookingAccessToken = {
id: string;
bookingId: string;
token: string;
expiresAt: string;
createdAt: string;
purpose: "booking_access" | "reschedule_proposal";
proposedDate?: string;
proposedTime?: string;
originalDate?: string;
originalTime?: string;
};
type Booking = {
id: string;
treatmentId: string;
customerName: string;
customerEmail?: string;
customerPhone?: string;
appointmentDate: string;
appointmentTime: string;
notes?: string;
inspirationPhoto?: string;
slotId?: string;
status: "pending" | "confirmed" | "cancelled" | "completed";
createdAt: string;
};
const cancellationKV = createKV<BookingAccessToken>("cancellation_tokens");
const bookingsKV = createKV<Booking>("bookings");
// Helper Function: validateBookingToken
async function validateBookingToken(token: string) {
const tokens = await cancellationKV.getAllItems();
const validToken = tokens.find(t =>
t.token === token &&
new Date(t.expiresAt) > new Date() &&
t.purpose === 'booking_access'
);
if (!validToken) {
throw new Error("Ungültiger oder abgelaufener Buchungs-Token");
}
const booking = await bookingsKV.getItem(validToken.bookingId);
if (!booking) {
throw new Error("Buchung nicht gefunden");
}
// Only allow reviews for completed appointments
if (!(booking.status === "completed")) {
throw new Error("Bewertungen sind nur für abgeschlossene Termine möglich");
}
return booking;
}
// Public Endpoint: submitReview
const submitReview = os
.input(
z.object({
bookingToken: z.string(),
rating: z.number().int().min(1).max(5),
comment: z.string().min(10, "Kommentar muss mindestens 10 Zeichen lang sein"),
})
)
.handler(async ({ input }) => {
try {
// Validate bookingToken
const booking = await validateBookingToken(input.bookingToken);
// Enforce uniqueness by using booking.id as the KV key
const existing = await reviewsKV.getItem(booking.id);
if (existing) {
throw new Error("Für diese Buchung wurde bereits eine Bewertung abgegeben");
}
// Create review object
const review: Review = {
id: booking.id,
bookingId: booking.id,
customerName: booking.customerName,
customerEmail: booking.customerEmail,
rating: input.rating,
comment: input.comment,
status: "pending",
createdAt: new Date().toISOString(),
};
await reviewsKV.setItem(booking.id, review);
return review;
} catch (err) {
console.error("reviews.submitReview error", err);
throw err;
}
});
// Admin Endpoint: approveReview
const approveReview = os
.input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input }) => {
try {
await assertOwner(input.sessionId);
const review = await reviewsKV.getItem(input.id);
if (!review) {
throw new Error("Bewertung nicht gefunden");
}
const session = await sessionsKV.getItem(input.sessionId).catch(() => undefined);
const updatedReview = {
...review,
status: "approved" as const,
reviewedAt: new Date().toISOString(),
reviewedBy: session?.userId || review.reviewedBy,
};
await reviewsKV.setItem(input.id, updatedReview);
return updatedReview;
} catch (err) {
console.error("reviews.approveReview error", err);
throw err;
}
});
// Admin Endpoint: rejectReview
const rejectReview = os
.input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input }) => {
try {
await assertOwner(input.sessionId);
const review = await reviewsKV.getItem(input.id);
if (!review) {
throw new Error("Bewertung nicht gefunden");
}
const session = await sessionsKV.getItem(input.sessionId).catch(() => undefined);
const updatedReview = {
...review,
status: "rejected" as const,
reviewedAt: new Date().toISOString(),
reviewedBy: session?.userId || review.reviewedBy,
};
await reviewsKV.setItem(input.id, updatedReview);
return updatedReview;
} catch (err) {
console.error("reviews.rejectReview error", err);
throw err;
}
});
// Admin Endpoint: deleteReview
const deleteReview = os
.input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input }) => {
try {
await assertOwner(input.sessionId);
await reviewsKV.removeItem(input.id);
} catch (err) {
console.error("reviews.deleteReview error", err);
throw err;
}
});
// Public Endpoint: listPublishedReviews
const listPublishedReviews = os.handler(async (): Promise<PublicReview[]> => {
try {
const allReviews = await reviewsKV.getAllItems();
const published = allReviews.filter(r => r.status === "approved");
const sorted = published.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
const publicSafe: PublicReview[] = sorted.map(r => ({
customerName: r.customerName,
rating: r.rating,
comment: r.comment,
status: r.status,
bookingId: r.bookingId,
createdAt: r.createdAt,
}));
return publicSafe;
} catch (err) {
console.error("reviews.listPublishedReviews error", err);
throw err;
}
});
// Admin Endpoint: adminListReviews
const adminListReviews = os
.input(
z.object({
sessionId: z.string(),
statusFilter: z.enum(["all", "pending", "approved", "rejected"]).optional().default("all"),
})
)
.handler(async ({ input }) => {
try {
await assertOwner(input.sessionId);
const allReviews = await reviewsKV.getAllItems();
const filtered = input.statusFilter === "all"
? allReviews
: allReviews.filter(r => r.status === input.statusFilter);
const sorted = filtered.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
return sorted;
} catch (err) {
console.error("reviews.adminListReviews error", err);
throw err;
}
});
// Live Queries
const live = {
listPublishedReviews: os.handler(async function* ({ signal }) {
yield call(listPublishedReviews, {}, { signal });
for await (const _ of reviewsKV.subscribe()) {
yield call(listPublishedReviews, {}, { signal });
}
}),
adminListReviews: os
.input(
z.object({
sessionId: z.string(),
statusFilter: z.enum(["all", "pending", "approved", "rejected"]).optional().default("all"),
})
)
.handler(async function* ({ input, signal }) {
await assertOwner(input.sessionId);
const allReviews = await reviewsKV.getAllItems();
const filtered = input.statusFilter === "all"
? allReviews
: allReviews.filter(r => r.status === input.statusFilter);
const sorted = filtered.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
yield sorted;
for await (const _ of reviewsKV.subscribe()) {
const updated = await reviewsKV.getAllItems();
const filteredUpdated = input.statusFilter === "all"
? updated
: updated.filter(r => r.status === input.statusFilter);
const sortedUpdated = filteredUpdated.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
yield sortedUpdated;
}
}),
};
export const router = {
submitReview,
approveReview,
rejectReview,
deleteReview,
listPublishedReviews,
adminListReviews,
live,
};