diff --git a/src/client/app.tsx b/src/client/app.tsx index a19d4de..fc28bd5 100644 --- a/src/client/app.tsx +++ b/src/client/app.tsx @@ -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 ; + } + } + // 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 ; } @@ -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() {
setActiveTab("booking")} + onClick={() => setActiveTab("profile-landing")} > + {activeTab === "profile-landing" && ( + setActiveTab("booking")} /> + )} + {activeTab === "booking" && (
@@ -311,6 +330,34 @@ function App() {
)} + {activeTab === "admin-gallery" && isOwner && ( +
+
+

+ Photo-Wall verwalten +

+

+ Lade Fotos hoch und verwalte deine Galerie. +

+
+ +
+ )} + + {activeTab === "admin-reviews" && isOwner && ( +
+
+

+ Bewertungen verwalten +

+

+ Prüfe und verwalte Kundenbewertungen. +

+
+ +
+ )} + {activeTab === "profile" && user && (
diff --git a/src/client/components/admin-availability.tsx b/src/client/components/admin-availability.tsx index 4dbbb4d..73e6194 100644 --- a/src/client/components/admin-availability.tsx +++ b/src/client/components/admin-availability.tsx @@ -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."); diff --git a/src/client/components/admin-gallery.tsx b/src/client/components/admin-gallery.tsx new file mode 100644 index 0000000..0a556d4 --- /dev/null +++ b/src/client/components/admin-gallery.tsx @@ -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(""); + const [photoPreview, setPhotoPreview] = useState(""); + const [photoBase64, setPhotoBase64] = useState(""); + const [errorMsg, setErrorMsg] = useState(""); + const [successMsg, setSuccessMsg] = useState(""); + const [draggedPhotoId, setDraggedPhotoId] = useState(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 => { + 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) => { + 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 => { + 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 ( +
+ {/* Error and Success Messages */} + {errorMsg && ( +
+
+ + + + {errorMsg} +
+
+ )} + + {successMsg && ( +
+
+ + + + {successMsg} +
+
+ )} + + {/* Upload Form Section */} +
+

Neues Foto hochladen

+ +
+
+ + +

+ Max. 1MB, alle Bildformate erlaubt +

+
+ + {photoPreview && ( +
+ +
+ Foto Vorschau + +
+
+ )} + +
+ + 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" + /> +
+ + +
+
+ + {/* Photo Grid Section */} +
+
+

Foto-Galerie verwalten

+

+ Ziehe Fotos per Drag & Drop, um die Reihenfolge zu ändern. Klicke auf das X, um ein Foto zu löschen. +

+
+ +
+ {photos?.length === 0 && ( +
+
Noch keine Fotos hochgeladen
+
Lade dein erstes Foto hoch, um deine Galerie zu starten.
+
+ )} + + {sortedPhotos && sortedPhotos.length > 0 && ( +
+ {sortedPhotos.map((photo) => ( +
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' + }`} + > + {photo.title +
+
+
+ {photo.title && ( +
+ {photo.title} +
+ )} +
+ Reihenfolge: {photo.order} +
+
+ {new Date(photo.createdAt).toLocaleDateString('de-DE')} +
+
+ + +
+
+
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/src/client/components/admin-reviews.tsx b/src/client/components/admin-reviews.tsx new file mode 100644 index 0000000..f17bacf --- /dev/null +++ b/src/client/components/admin-reviews.tsx @@ -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( + + {filled ? "★" : "☆"} + + ); + } + return
{stars}
; +} + +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("pending"); + const [successMsg, setSuccessMsg] = useState(""); + const [errorMsg, setErrorMsg] = useState(""); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(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 ( +
+ {(successMsg || errorMsg) && ( +
+ {errorMsg && ( +
+
+ + + + Fehler: + {errorMsg} +
+
+ )} + {successMsg && ( +
+
+ + + + Erfolg: + {successMsg} +
+
+ )} +
+ )} + +
+
+
{pendingCount}
+
Ausstehend
+
+
+
{approvedCount}
+
Genehmigt
+
+
+
{rejectedCount}
+
Abgelehnt
+
+
+
{totalCount}
+
Summe
+
+
+ +
+
+ +
+
+ +
+ {(!reviews || reviews.length === 0) && ( +
+
Keine Bewertungen gefunden
+
Es liegen aktuell keine Bewertungen in dieser Ansicht vor.
+
+ )} + +
+ {reviews?.map((review: Review) => ( +
+
+
+
{review.customerName}
+
{review.customerEmail || "—"}
+
+ + {getStatusText(review.status)} + +
+ +
{renderStars(review.rating)}
+
{review.comment}
+ +
+ Buchung: {review.bookingId} + Eingereicht am: {formatDate(review.createdAt)} + {review.reviewedAt && Geprüft am: {formatDate(review.reviewedAt)}} +
+ +
+ {review.status === "pending" && ( + <> + + + + + )} + + {review.status === "approved" && ( + <> + + + + )} + + {review.status === "rejected" && ( + <> + + + + )} +
+
+ ))} +
+
+ + {showDeleteConfirm && ( +
+
+

Bewertung löschen

+

+ Bist du sicher, dass du diese Bewertung löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden. +

+
+ + +
+
+
+ )} +
+ ); +} + + diff --git a/src/client/components/profile-landing.tsx b/src/client/components/profile-landing.tsx new file mode 100644 index 0000000..c64974d --- /dev/null +++ b/src/client/components/profile-landing.tsx @@ -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 ( +
+ {[1, 2, 3, 4, 5].map((star) => ( + + ★ + + ))} +
+ ); +} + +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 ( +
+ {/* Hero Section */} +
+

+ Stargirlnails Kiel +

+

Professionelles Nageldesign und -Pflege in Kiel

+

+ Lass dich von mir verwöhnen und genieße hochwertige Nail Art und Pflegebehandlungen. +

+ +
+ + {/* Featured Section: Erstes Foto (Reihenfolge 0) */} + {featuredPhoto && ( +
+ {(featuredPhoto + {(featuredPhoto as any).title && ( +
+

{(featuredPhoto as any).title}

+
+ )} +
+ )} + + {/* Photo Gallery Section */} +
+

Unsere Arbeiten

+ {galleryPhotos.length > 0 ? ( +
+ {(sortedPhotos as typeof galleryPhotos) + .filter((p) => (featuredPhoto ? (p as any).id !== (featuredPhoto as any).id : true)) + .slice(0, 9) + .map((photo, index) => ( + {photo.title + ))} +
+ ) : ( +

+ Galerie wird bald aktualisiert +

+ )} +
+ + {/* Opening Hours Section */} +
+

+ Öffnungszeiten (Nächste 7 Tage) +

+
+ {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 ( +
+
+ + {getDayName(dayOfWeek)}, {formatDate(date)} + +
+ {dayRules.length > 0 ? ( +
+ {sorted.map((rule) => ( +
+ {rule.startTime} - {rule.endTime} Uhr +
+ ))} +
+ ) : ( + Geschlossen + )} +
+
+
+ ); + })} +
+
+ + {/* Customer Reviews Section */} +
+

+ Kundenbewertungen +

+ {reviews.length > 0 ? ( +
+ {reviews.slice(0, 10).map((review) => ( +
+
+
+

+ {review.customerName} +

+ +
+ + {new Date(review.createdAt).toLocaleDateString("de-DE")} + +
+ {review.comment && ( +

{review.comment}

+ )} +
+ ))} +
+ ) : ( +

+ Noch keine Bewertungen vorhanden +

+ )} +
+
+ ); +} diff --git a/src/client/components/review-submission-page.tsx b/src/client/components/review-submission-page.tsx new file mode 100644 index 0000000..5fdab93 --- /dev/null +++ b/src/client/components/review-submission-page.tsx @@ -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(null); + const [hoverRating, setHoverRating] = useState(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 ( +
+
+
+
+ Lade Buchung... +
+
+
+ ); + } + + if (bookingQuery.error || !bookingQuery.data) { + return ( +
+
+
+ + + +
+

Link nicht verfügbar

+

Dieser Link ist ungültig oder abgelaufen.

+ Zur Startseite +
+
+ ); + } + + // Guard: Only allow reviews for completed bookings + if (!isCompleted) { + return ( +
+
+
+ Stargil Nails Logo +

Bewertung abgeben

+

Bewertungen sind nur für abgeschlossene Termine möglich.

+ +
+
+
+ ); + } + + const booking = bookingQuery.data; + + return ( +
+
+ {/* Header */} +
+
+ Stargil Nails Logo + ⭐ Bewertung +
+

Bewertung abgeben

+

Teile deine Erfahrung mit uns – das hilft anderen Kundinnen!

+
+ + {/* Booking Details */} +
+

+ + + + Termin-Details +

+
+
+ Datum: + {booking.formattedDate} +
+
+ Uhrzeit: + {booking.appointmentTime} Uhr +
+
+ Behandlung: + {booking.treatmentName} +
+
+ Name: + {booking.customerName} +
+
+
+ + {/* Result Banner */} + {submitResult && ( +
+

{submitResult.message}

+
+ )} + + {/* Review Form */} +
+

Deine Bewertung

+ + {/* Stars */} +
+ {[1,2,3,4,5].map((star) => { + const isActive = (hoverRating ?? rating ?? 0) >= star; + return ( + + ); + })} +
+ {!rating &&

Bitte wähle eine Bewertung von 1 bis 5 Sternen.

} + + {/* Comment */} + +