feat: Allow half stars for reviews

This commit is contained in:
gpt-engineer-app[bot]
2025-09-28 21:20:01 +00:00
parent ec664a1669
commit 93278a5f24
3 changed files with 125 additions and 35 deletions

View File

@@ -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 */}
<div className="space-y-2">
<Label>Rating *</Label>
<div className="flex items-center gap-1">
{Array.from({ length: 5 }, (_, i) => (
<button
key={i}
type="button"
onClick={() => handleRatingClick(i + 1)}
className="text-2xl hover:scale-110 transition-transform"
>
<Star
className={`w-8 h-8 ${
i < rating
? 'fill-yellow-400 text-yellow-400'
: 'text-muted-foreground hover:text-yellow-400'
}`}
<StarRating
rating={rating}
onChange={handleRatingChange}
interactive={true}
showLabel={true}
size="lg"
/>
</button>
))}
{rating > 0 && (
<span className="ml-2 text-sm text-muted-foreground">
{rating} star{rating !== 1 ? 's' : ''}
</span>
)}
</div>
{errors.rating && (
<p className="text-sm text-destructive">Please select a rating</p>
)}

View File

@@ -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) => (
<Star
key={i}
className={`w-4 h-4 ${
i < rating
? 'fill-yellow-400 text-yellow-400'
: 'text-muted-foreground'
}`}
/>
));
return <StarRating rating={rating} size="sm" />;
};
const formatDate = (dateString: string) => {
@@ -161,6 +153,9 @@ export function ReviewsList({ entityType, entityId, entityName }: ReviewsListPro
</div>
<div className="flex items-center gap-1">
{renderStars(review.rating)}
<span className="ml-1 text-sm font-medium">
{review.rating}
</span>
</div>
</div>

View File

@@ -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 (
<div key={index} className="relative inline-block">
{/* Background unfilled star */}
<Star className={cn(starSize, 'text-muted-foreground')} />
{/* Filled star overlay */}
<div
className="absolute inset-0 overflow-hidden"
style={{ width: getStarFill() }}
>
<Star className={cn(starSize, 'fill-yellow-400 text-yellow-400')} />
</div>
{/* Interactive click areas */}
{interactive && (
<>
{/* Left half click area */}
<div
className="absolute top-0 left-0 w-1/2 h-full cursor-pointer z-10"
onClick={() => handleStarClick(index, true)}
onMouseEnter={() => handleStarHover(index, true)}
onMouseLeave={handleMouseLeave}
/>
{/* Right half click area */}
<div
className="absolute top-0 right-0 w-1/2 h-full cursor-pointer z-10"
onClick={() => handleStarClick(index, false)}
onMouseEnter={() => handleStarHover(index, false)}
onMouseLeave={handleMouseLeave}
/>
</>
)}
</div>
);
};
return (
<div className={cn('flex items-center gap-1', className)}>
<div className="flex items-center">
{Array.from({ length: 5 }, (_, i) => renderStar(i))}
</div>
{showLabel && (
<span className="ml-2 text-sm text-muted-foreground">
{displayRating} star{displayRating !== 1 ? 's' : ''}
</span>
)}
</div>
);
}