From 93278a5f248e63695927f5c3db96193f0affbfac Mon Sep 17 00:00:00 2001
From: "gpt-engineer-app[bot]"
<159125892+gpt-engineer-app[bot]@users.noreply.github.com>
Date: Sun, 28 Sep 2025 21:20:01 +0000
Subject: [PATCH] feat: Allow half stars for reviews
---
src/components/reviews/ReviewForm.tsx | 35 +++-----
src/components/reviews/ReviewsList.tsx | 15 ++--
src/components/reviews/StarRating.tsx | 110 +++++++++++++++++++++++++
3 files changed, 125 insertions(+), 35 deletions(-)
create mode 100644 src/components/reviews/StarRating.tsx
diff --git a/src/components/reviews/ReviewForm.tsx b/src/components/reviews/ReviewForm.tsx
index f1e2ecfc..e68ff437 100644
--- a/src/components/reviews/ReviewForm.tsx
+++ b/src/components/reviews/ReviewForm.tsx
@@ -12,9 +12,10 @@ import { useAuth } from '@/hooks/useAuth';
import { supabase } from '@/integrations/supabase/client';
import { toast } from '@/hooks/use-toast';
import { PhotoUpload } from '@/components/upload/PhotoUpload';
+import { StarRating } from './StarRating';
const reviewSchema = z.object({
- rating: z.number().min(1).max(5),
+ rating: z.number().min(0.5).max(5).multipleOf(0.5),
title: z.string().optional(),
content: z.string().min(10, 'Review must be at least 10 characters long'),
visit_date: z.string().optional(),
@@ -47,7 +48,7 @@ export function ReviewForm({ entityType, entityId, entityName, onReviewSubmitted
resolver: zodResolver(reviewSchema)
});
- const handleRatingClick = (selectedRating: number) => {
+ const handleRatingChange = (selectedRating: number) => {
setRating(selectedRating);
setValue('rating', selectedRating);
};
@@ -130,29 +131,13 @@ export function ReviewForm({ entityType, entityId, entityName, onReviewSubmitted
{/* Rating */}
-
- {Array.from({ length: 5 }, (_, i) => (
-
- ))}
- {rating > 0 && (
-
- {rating} star{rating !== 1 ? 's' : ''}
-
- )}
-
+
{errors.rating && (
Please select a rating
)}
diff --git a/src/components/reviews/ReviewsList.tsx b/src/components/reviews/ReviewsList.tsx
index 80f8fc01..6d2441e3 100644
--- a/src/components/reviews/ReviewsList.tsx
+++ b/src/components/reviews/ReviewsList.tsx
@@ -5,6 +5,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Star, ThumbsUp, Calendar, MapPin } from 'lucide-react';
import { supabase } from '@/integrations/supabase/client';
import { ReportButton } from '@/components/moderation/ReportButton';
+import { StarRating } from './StarRating';
interface ReviewWithProfile {
id: string;
@@ -69,16 +70,7 @@ export function ReviewsList({ entityType, entityId, entityName }: ReviewsListPro
};
const renderStars = (rating: number) => {
- return Array.from({ length: 5 }, (_, i) => (
-
- ));
+ return
;
};
const formatDate = (dateString: string) => {
@@ -161,6 +153,9 @@ export function ReviewsList({ entityType, entityId, entityName }: ReviewsListPro
{renderStars(review.rating)}
+
+ {review.rating}
+
diff --git a/src/components/reviews/StarRating.tsx b/src/components/reviews/StarRating.tsx
new file mode 100644
index 00000000..fecfc199
--- /dev/null
+++ b/src/components/reviews/StarRating.tsx
@@ -0,0 +1,110 @@
+import { useState } from 'react';
+import { Star } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+interface StarRatingProps {
+ rating: number;
+ onChange?: (rating: number) => void;
+ size?: 'sm' | 'md' | 'lg';
+ interactive?: boolean;
+ showLabel?: boolean;
+ className?: string;
+}
+
+export function StarRating({
+ rating,
+ onChange,
+ size = 'md',
+ interactive = false,
+ showLabel = false,
+ className
+}: StarRatingProps) {
+ const [hoverRating, setHoverRating] = useState(0);
+
+ const sizes = {
+ sm: 'w-4 h-4',
+ md: 'w-6 h-6',
+ lg: 'w-8 h-8'
+ };
+
+ const starSize = sizes[size];
+ const displayRating = hoverRating || rating;
+
+ const handleStarClick = (starIndex: number, isHalf: boolean) => {
+ if (!interactive || !onChange) return;
+ const newRating = starIndex + (isHalf ? 0.5 : 1);
+ onChange(newRating);
+ };
+
+ const handleStarHover = (starIndex: number, isHalf: boolean) => {
+ if (!interactive) return;
+ const newHoverRating = starIndex + (isHalf ? 0.5 : 1);
+ setHoverRating(newHoverRating);
+ };
+
+ const handleMouseLeave = () => {
+ if (!interactive) return;
+ setHoverRating(0);
+ };
+
+ const renderStar = (index: number) => {
+ const starValue = index + 1;
+ const isFullStar = displayRating >= starValue;
+ const isHalfStar = displayRating >= starValue - 0.5 && displayRating < starValue;
+
+ const getStarFill = () => {
+ if (isFullStar) return '100%';
+ if (isHalfStar) return '50%';
+ return '0%';
+ };
+
+ return (
+
+ {/* Background unfilled star */}
+
+
+ {/* Filled star overlay */}
+
+
+
+
+ {/* Interactive click areas */}
+ {interactive && (
+ <>
+ {/* Left half click area */}
+
handleStarClick(index, true)}
+ onMouseEnter={() => handleStarHover(index, true)}
+ onMouseLeave={handleMouseLeave}
+ />
+
+ {/* Right half click area */}
+
handleStarClick(index, false)}
+ onMouseEnter={() => handleStarHover(index, false)}
+ onMouseLeave={handleMouseLeave}
+ />
+ >
+ )}
+
+ );
+ };
+
+ return (
+
+
+ {Array.from({ length: 5 }, (_, i) => renderStar(i))}
+
+ {showLabel && (
+
+ {displayRating} star{displayRating !== 1 ? 's' : ''}
+
+ )}
+
+ );
+}
\ No newline at end of file