mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 09:51:13 -05:00
feat: Allow half stars for reviews
This commit is contained in:
@@ -12,9 +12,10 @@ import { useAuth } from '@/hooks/useAuth';
|
|||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { PhotoUpload } from '@/components/upload/PhotoUpload';
|
import { PhotoUpload } from '@/components/upload/PhotoUpload';
|
||||||
|
import { StarRating } from './StarRating';
|
||||||
|
|
||||||
const reviewSchema = z.object({
|
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(),
|
title: z.string().optional(),
|
||||||
content: z.string().min(10, 'Review must be at least 10 characters long'),
|
content: z.string().min(10, 'Review must be at least 10 characters long'),
|
||||||
visit_date: z.string().optional(),
|
visit_date: z.string().optional(),
|
||||||
@@ -47,7 +48,7 @@ export function ReviewForm({ entityType, entityId, entityName, onReviewSubmitted
|
|||||||
resolver: zodResolver(reviewSchema)
|
resolver: zodResolver(reviewSchema)
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleRatingClick = (selectedRating: number) => {
|
const handleRatingChange = (selectedRating: number) => {
|
||||||
setRating(selectedRating);
|
setRating(selectedRating);
|
||||||
setValue('rating', selectedRating);
|
setValue('rating', selectedRating);
|
||||||
};
|
};
|
||||||
@@ -130,29 +131,13 @@ export function ReviewForm({ entityType, entityId, entityName, onReviewSubmitted
|
|||||||
{/* Rating */}
|
{/* Rating */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Rating *</Label>
|
<Label>Rating *</Label>
|
||||||
<div className="flex items-center gap-1">
|
<StarRating
|
||||||
{Array.from({ length: 5 }, (_, i) => (
|
rating={rating}
|
||||||
<button
|
onChange={handleRatingChange}
|
||||||
key={i}
|
interactive={true}
|
||||||
type="button"
|
showLabel={true}
|
||||||
onClick={() => handleRatingClick(i + 1)}
|
size="lg"
|
||||||
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'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{rating > 0 && (
|
|
||||||
<span className="ml-2 text-sm text-muted-foreground">
|
|
||||||
{rating} star{rating !== 1 ? 's' : ''}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{errors.rating && (
|
{errors.rating && (
|
||||||
<p className="text-sm text-destructive">Please select a rating</p>
|
<p className="text-sm text-destructive">Please select a rating</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|||||||
import { Star, ThumbsUp, Calendar, MapPin } from 'lucide-react';
|
import { Star, ThumbsUp, Calendar, MapPin } from 'lucide-react';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { ReportButton } from '@/components/moderation/ReportButton';
|
import { ReportButton } from '@/components/moderation/ReportButton';
|
||||||
|
import { StarRating } from './StarRating';
|
||||||
|
|
||||||
interface ReviewWithProfile {
|
interface ReviewWithProfile {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -69,16 +70,7 @@ export function ReviewsList({ entityType, entityId, entityName }: ReviewsListPro
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderStars = (rating: number) => {
|
const renderStars = (rating: number) => {
|
||||||
return Array.from({ length: 5 }, (_, i) => (
|
return <StarRating rating={rating} size="sm" />;
|
||||||
<Star
|
|
||||||
key={i}
|
|
||||||
className={`w-4 h-4 ${
|
|
||||||
i < rating
|
|
||||||
? 'fill-yellow-400 text-yellow-400'
|
|
||||||
: 'text-muted-foreground'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
@@ -161,6 +153,9 @@ export function ReviewsList({ entityType, entityId, entityName }: ReviewsListPro
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{renderStars(review.rating)}
|
{renderStars(review.rating)}
|
||||||
|
<span className="ml-1 text-sm font-medium">
|
||||||
|
{review.rating}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
110
src/components/reviews/StarRating.tsx
Normal file
110
src/components/reviews/StarRating.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user