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