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 && (
+
+ )}
+
+ {successMsg && (
+
+ )}
+
+ {/* Upload Form Section */}
+
+
Neues Foto hochladen
+
+
+
+
+
+
+ Max. 1MB, alle Bildformate erlaubt
+
+
+
+ {photoPreview && (
+
+
+
+

+
+
+
+ )}
+
+
+
+ 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}
+
+ )}
+
+ 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
+
+
+
+
+
+
+
+
+
+
+
+ {(!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 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) => (
+

+ ))}
+
+ ) : (
+
+ 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 (
+
+ );
+ }
+
+ 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 (
+
+
+
+

+
Bewertung abgeben
+
Bewertungen sind nur für abgeschlossene Termine möglich.
+
+
+
+
+ );
+ }
+
+ const booking = bookingQuery.data;
+
+ return (
+
+
+ {/* Header */}
+
+
+

+
⭐ 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 */}
+
+
+
+ {/* Footer */}
+
+
+
+ );
+}
+
+
diff --git a/src/server/lib/auth.ts b/src/server/lib/auth.ts
new file mode 100644
index 0000000..42f1681
--- /dev/null
+++ b/src/server/lib/auth.ts
@@ -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("sessions");
+export const usersKV = createKV("users");
+
+export async function assertOwner(sessionId: string): Promise {
+ 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");
+}
+
+
diff --git a/src/server/lib/email-templates.ts b/src/server/lib/email-templates.ts
index 8c18feb..8b214f9 100644
--- a/src/server/lib/email-templates.ts
+++ b/src/server/lib/email-templates.ts
@@ -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
Termin ansehen & verwalten
` : ''}
+ ${reviewUrl ? `
+
+
⭐ Bewertung abgeben:
+
Nach deinem Termin würden wir uns über deine Bewertung freuen!
+
Bewertung schreiben
+
Du kannst deine Bewertung nach dem Termin über diesen Link abgeben.
+
+ ` : ''}
📋 Rechtliche Informationen:
Weitere Informationen findest du in unserem Impressum und Datenschutz.
diff --git a/src/server/rpc/bookings.ts b/src/server/rpc/bookings.ts
index 347b04d..9b4dd04 100644
--- a/src/server/rpc/bookings.ts
+++ b/src/server/rpc/bookings.ts
@@ -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(() => {});
}
diff --git a/src/server/rpc/gallery.ts b/src/server/rpc/gallery.ts
new file mode 100644
index 0000000..7d7e188
--- /dev/null
+++ b/src/server/rpc/gallery.ts
@@ -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
;
+
+// KV Storage
+const galleryPhotosKV = createKV("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,
+};
+
+
diff --git a/src/server/rpc/index.ts b/src/server/rpc/index.ts
index 5feea1e..1f9b9da 100644
--- a/src/server/rpc/index.ts
+++ b/src/server/rpc/index.ts
@@ -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,
};
diff --git a/src/server/rpc/recurring-availability.ts b/src/server/rpc/recurring-availability.ts
index f3b344c..73e7b9a 100644
--- a/src/server/rpc/recurring-availability.ts
+++ b/src/server/rpc/recurring-availability.ts
@@ -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("timeOffPeriods");
const bookingsKV = createKV("bookings");
const treatmentsKV = createKV("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("sessions");
-const usersKV = createKV("users");
-
-async function assertOwner(sessionId: string): Promise {
- 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 {
diff --git a/src/server/rpc/reviews.ts b/src/server/rpc/reviews.ts
new file mode 100644
index 0000000..132eabe
--- /dev/null
+++ b/src/server/rpc/reviews.ts
@@ -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;
+
+// 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("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("cancellation_tokens");
+const bookingsKV = createKV("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 => {
+ 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,
+};