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:
@@ -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">
|
||||
|
@@ -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.");
|
||||
|
464
src/client/components/admin-gallery.tsx
Normal file
464
src/client/components/admin-gallery.tsx
Normal 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>
|
||||
);
|
||||
}
|
356
src/client/components/admin-reviews.tsx
Normal file
356
src/client/components/admin-reviews.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
226
src/client/components/profile-landing.tsx
Normal file
226
src/client/components/profile-landing.tsx
Normal 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>
|
||||
);
|
||||
}
|
222
src/client/components/review-submission-page.tsx
Normal file
222
src/client/components/review-submission-page.tsx
Normal 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
17
src/server/lib/auth.ts
Normal 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");
|
||||
}
|
||||
|
||||
|
@@ -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>
|
||||
|
@@ -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
150
src/server/rpc/gallery.ts
Normal 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,
|
||||
};
|
||||
|
||||
|
@@ -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,
|
||||
};
|
||||
|
@@ -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
294
src/server/rpc/reviews.ts
Normal 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,
|
||||
};
|
Reference in New Issue
Block a user