first commit
This commit is contained in:
224
components/reviews/ReviewCard.tsx
Normal file
224
components/reviews/ReviewCard.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Star, ThumbsUp, Flag, MoreHorizontal, Edit, Trash2, Verified } from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import Image from 'next/image'
|
||||
import { toast } from 'sonner'
|
||||
import { useSession } from 'next-auth/react'
|
||||
|
||||
interface Review {
|
||||
id: string
|
||||
rating: number
|
||||
title?: string
|
||||
comment?: string
|
||||
images: string[]
|
||||
isVerified: boolean
|
||||
helpfulVotes: number
|
||||
createdAt: string
|
||||
user: {
|
||||
id: string
|
||||
name: string
|
||||
image?: string
|
||||
}
|
||||
_count?: {
|
||||
helpfulVotedBy: number
|
||||
}
|
||||
}
|
||||
|
||||
interface ReviewCardProps {
|
||||
review: Review
|
||||
onEdit?: (review: Review) => void
|
||||
onDelete?: (reviewId: string) => void
|
||||
onHelpfulVote?: (reviewId: string) => void
|
||||
onReport?: (reviewId: string) => void
|
||||
userHasVoted?: boolean
|
||||
}
|
||||
|
||||
export default function ReviewCard({
|
||||
review,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onHelpfulVote,
|
||||
onReport,
|
||||
userHasVoted = false
|
||||
}: ReviewCardProps) {
|
||||
const { data: session } = useSession()
|
||||
const [isVoting, setIsVoting] = useState(false)
|
||||
const [hasVoted, setHasVoted] = useState(userHasVoted)
|
||||
const [voteCount, setVoteCount] = useState(review.helpfulVotes)
|
||||
|
||||
const isOwner = session?.user?.id === review.user.id
|
||||
|
||||
const handleHelpfulVote = async () => {
|
||||
if (!session) {
|
||||
toast.error('Please sign in to vote on reviews')
|
||||
return
|
||||
}
|
||||
|
||||
setIsVoting(true)
|
||||
try {
|
||||
const response = await fetch(`/api/reviews/${review.id}/helpful`, {
|
||||
method: 'POST'
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (response.ok) {
|
||||
setHasVoted(data.voted)
|
||||
setVoteCount(prev => data.voted ? prev + 1 : prev - 1)
|
||||
toast.success(data.message)
|
||||
onHelpfulVote?.(review.id)
|
||||
} else {
|
||||
toast.error(data.error || 'Failed to vote')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to vote on review')
|
||||
} finally {
|
||||
setIsVoting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReport = () => {
|
||||
if (!session) {
|
||||
toast.error('Please sign in to report reviews')
|
||||
return
|
||||
}
|
||||
onReport?.(review.id)
|
||||
}
|
||||
|
||||
const renderStars = (rating: number) => {
|
||||
return Array.from({ length: 5 }, (_, index) => (
|
||||
<Star
|
||||
key={index}
|
||||
className={`h-4 w-4 ${
|
||||
index < rating
|
||||
? 'text-yellow-400 fill-current'
|
||||
: 'text-gray-300'
|
||||
}`}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="mb-4">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage src={review.user.image} alt={review.user.name} />
|
||||
<AvatarFallback>
|
||||
{review.user.name?.split(' ').map(n => n[0]).join('') || 'U'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="font-semibold">{review.user.name}</span>
|
||||
{review.isVerified && (
|
||||
<Badge variant="outline" className="text-green-600 border-green-600">
|
||||
<Verified className="h-3 w-3 mr-1" />
|
||||
Verified Purchase
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-sm text-gray-600">
|
||||
<div className="flex items-center space-x-1">
|
||||
{renderStars(review.rating)}
|
||||
</div>
|
||||
<span>•</span>
|
||||
<span>{formatDistanceToNow(new Date(review.createdAt), { addSuffix: true })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
{isOwner && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onEdit?.(review)}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Edit Review
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete?.(review.id)}
|
||||
className="text-red-600"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete Review
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pt-0">
|
||||
{review.title && (
|
||||
<h4 className="font-semibold mb-2">{review.title}</h4>
|
||||
)}
|
||||
|
||||
{review.comment && (
|
||||
<p className="text-gray-700 mb-3 leading-relaxed">{review.comment}</p>
|
||||
)}
|
||||
|
||||
{review.images.length > 0 && (
|
||||
<div className="flex space-x-2 mb-3 overflow-x-auto">
|
||||
{review.images.map((image, index) => (
|
||||
<div key={index} className="relative w-20 h-20 flex-shrink-0">
|
||||
<Image
|
||||
src={image}
|
||||
alt={`Review image ${index + 1}`}
|
||||
fill
|
||||
className="object-cover rounded-md"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pt-3 border-t">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleHelpfulVote}
|
||||
disabled={isVoting}
|
||||
className={`${hasVoted ? 'text-green-600' : 'text-gray-600'}`}
|
||||
>
|
||||
<ThumbsUp className={`h-4 w-4 mr-1 ${hasVoted ? 'fill-current' : ''}`} />
|
||||
Helpful ({voteCount})
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!isOwner && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleReport}
|
||||
className="text-gray-600 hover:text-red-600"
|
||||
>
|
||||
<Flag className="h-4 w-4 mr-1" />
|
||||
Report
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
300
components/reviews/ReviewForm.tsx
Normal file
300
components/reviews/ReviewForm.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Star, Upload, X } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import Image from 'next/image'
|
||||
|
||||
interface ReviewFormProps {
|
||||
productId: string
|
||||
productName: string
|
||||
onSubmit?: (review: any) => void
|
||||
onCancel?: () => void
|
||||
disableImageUpload?: boolean
|
||||
existingReview?: {
|
||||
id: string
|
||||
rating: number
|
||||
title?: string
|
||||
comment?: string
|
||||
images: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export default function ReviewForm({
|
||||
productId,
|
||||
productName,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
disableImageUpload = false,
|
||||
existingReview
|
||||
}: ReviewFormProps) {
|
||||
const { data: session } = useSession()
|
||||
const [rating, setRating] = useState(existingReview?.rating || 5)
|
||||
const [title, setTitle] = useState(existingReview?.title || '')
|
||||
const [comment, setComment] = useState(existingReview?.comment || '')
|
||||
const [images, setImages] = useState<string[]>(existingReview?.images || [])
|
||||
const [hoveredStar, setHoveredStar] = useState(0)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [uploadingImage, setUploadingImage] = useState(false)
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="text-center py-8">
|
||||
<p className="text-gray-600 mb-4">Please sign in to write a review</p>
|
||||
<Button>Sign In</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const handleImageUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
if (images.length >= 5) {
|
||||
toast.error('Maximum 5 images allowed')
|
||||
return
|
||||
}
|
||||
|
||||
setUploadingImage(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('folder', 'reviews')
|
||||
|
||||
const response = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setImages(prev => [...prev, data.url])
|
||||
toast.success('Image uploaded successfully')
|
||||
} else {
|
||||
toast.error('Failed to upload image')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to upload image')
|
||||
} finally {
|
||||
setUploadingImage(false)
|
||||
}
|
||||
}
|
||||
|
||||
const removeImage = (index: number) => {
|
||||
setImages(prev => prev.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!rating) {
|
||||
toast.error('Please select a rating')
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const url = existingReview
|
||||
? `/api/reviews/${existingReview.id}`
|
||||
: '/api/reviews'
|
||||
|
||||
const method = existingReview ? 'PUT' : 'POST'
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
productId,
|
||||
rating,
|
||||
title: title.trim() || undefined,
|
||||
comment: comment.trim() || undefined,
|
||||
images
|
||||
})
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (response.ok) {
|
||||
toast.success(data.message || 'Review submitted successfully')
|
||||
onSubmit?.(data.review)
|
||||
|
||||
// Reset form if creating new review
|
||||
if (!existingReview) {
|
||||
setRating(5)
|
||||
setTitle('')
|
||||
setComment('')
|
||||
setImages([])
|
||||
}
|
||||
} else {
|
||||
toast.error(data.error || 'Failed to submit review')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to submit review')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const renderStars = () => {
|
||||
return Array.from({ length: 5 }, (_, index) => {
|
||||
const starValue = index + 1
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={() => setRating(starValue)}
|
||||
onMouseEnter={() => setHoveredStar(starValue)}
|
||||
onMouseLeave={() => setHoveredStar(0)}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<Star
|
||||
className={`h-8 w-8 transition-colors ${
|
||||
starValue <= (hoveredStar || rating)
|
||||
? 'text-yellow-400 fill-current'
|
||||
: 'text-gray-300'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{existingReview ? 'Edit Your Review' : 'Write a Review'}
|
||||
</CardTitle>
|
||||
<p className="text-sm text-gray-600">for {productName}</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Rating */}
|
||||
<div className="space-y-2">
|
||||
<Label>Rating *</Label>
|
||||
<div className="flex items-center space-x-1">
|
||||
{renderStars()}
|
||||
<span className="ml-2 text-sm text-gray-600">
|
||||
{rating}/5 stars
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Review Title</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Summarize your experience"
|
||||
maxLength={100}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">{title.length}/100 characters</p>
|
||||
</div>
|
||||
|
||||
{/* Comment */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="comment">Your Review</Label>
|
||||
<Textarea
|
||||
id="comment"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="Share your thoughts about this product..."
|
||||
rows={4}
|
||||
maxLength={1000}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">{comment.length}/1000 characters</p>
|
||||
</div>
|
||||
|
||||
{/* Images */}
|
||||
{!disableImageUpload && (
|
||||
<div className="space-y-2">
|
||||
<Label>Photos (Optional)</Label>
|
||||
<div className="space-y-3">
|
||||
{images.length > 0 && (
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{images.map((image, index) => (
|
||||
<div key={index} className="relative group">
|
||||
<Image
|
||||
src={image}
|
||||
alt={`Review image ${index + 1}`}
|
||||
width={80}
|
||||
height={80}
|
||||
className="object-cover rounded-md"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeImage(index)}
|
||||
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{images.length < 5 && (
|
||||
<div>
|
||||
<label className="cursor-pointer">
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-md p-4 text-center hover:border-gray-400 transition-colors">
|
||||
<Upload className="h-6 w-6 mx-auto mb-2 text-gray-400" />
|
||||
<p className="text-sm text-gray-600">
|
||||
{uploadingImage ? 'Uploading...' : 'Click to upload photos'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Max 5 images, 5MB each
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageUpload}
|
||||
disabled={uploadingImage}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Buttons */}
|
||||
<div className="flex items-center space-x-3 pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !rating}
|
||||
>
|
||||
{isSubmitting
|
||||
? (existingReview ? 'Updating...' : 'Submitting...')
|
||||
: (existingReview ? 'Update Review' : 'Submit Review')
|
||||
}
|
||||
</Button>
|
||||
{onCancel && (
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-500">
|
||||
Your review will be visible after admin approval.
|
||||
Reviews from verified purchases are marked as verified.
|
||||
</p>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
447
components/reviews/ReviewsList.tsx
Normal file
447
components/reviews/ReviewsList.tsx
Normal file
@@ -0,0 +1,447 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Star, Filter, Plus } from 'lucide-react'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import ReviewCard from './ReviewCard'
|
||||
import ReviewForm from './ReviewForm'
|
||||
import { toast } from 'sonner'
|
||||
import { useSession } from 'next-auth/react'
|
||||
|
||||
interface Review {
|
||||
id: string
|
||||
rating: number
|
||||
title?: string
|
||||
comment?: string
|
||||
images: string[]
|
||||
isVerified: boolean
|
||||
helpfulVotes: number
|
||||
createdAt: string
|
||||
userHasVoted?: boolean // Track if current user has voted this review as helpful
|
||||
user: {
|
||||
id: string
|
||||
name: string
|
||||
image?: string
|
||||
}
|
||||
_count?: {
|
||||
helpfulVotedBy: number
|
||||
}
|
||||
}
|
||||
|
||||
interface ReviewsListProps {
|
||||
productId: string
|
||||
productName: string
|
||||
averageRating?: number
|
||||
totalReviews?: number
|
||||
ratingBreakdown?: { [key: number]: number }
|
||||
disableImageUpload?: boolean
|
||||
}
|
||||
|
||||
export default function ReviewsList({
|
||||
productId,
|
||||
productName,
|
||||
averageRating = 0,
|
||||
totalReviews = 0,
|
||||
ratingBreakdown = {},
|
||||
disableImageUpload = false
|
||||
}: ReviewsListProps) {
|
||||
const { data: session } = useSession()
|
||||
const [reviews, setReviews] = useState<Review[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [ratingFilter, setRatingFilter] = useState<string>('all')
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingReview, setEditingReview] = useState<Review | null>(null)
|
||||
const [userReview, setUserReview] = useState<Review | null>(null)
|
||||
const [allReviews, setAllReviews] = useState<Review[]>([])
|
||||
const [calculatedStats, setCalculatedStats] = useState({
|
||||
averageRating: 0,
|
||||
totalReviews: 0,
|
||||
ratingBreakdown: {} as { [key: number]: number }
|
||||
})
|
||||
|
||||
const scrollToReviews = () => {
|
||||
const reviewsSection = document.getElementById('reviews-section')
|
||||
if (reviewsSection) {
|
||||
reviewsSection.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start'
|
||||
})
|
||||
// If there are no reviews, automatically show the form
|
||||
if (calculatedStats.totalReviews === 0) {
|
||||
setTimeout(() => setShowForm(true), 500)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const calculateStats = useCallback((allReviewsData: Review[]) => {
|
||||
const total = allReviewsData.length
|
||||
if (total === 0) {
|
||||
setCalculatedStats({
|
||||
averageRating: 0,
|
||||
totalReviews: 0,
|
||||
ratingBreakdown: {}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const sum = allReviewsData.reduce((acc, review) => acc + review.rating, 0)
|
||||
const avg = sum / total
|
||||
|
||||
const breakdown: { [key: number]: number } = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 }
|
||||
allReviewsData.forEach(review => {
|
||||
breakdown[review.rating] = (breakdown[review.rating] || 0) + 1
|
||||
})
|
||||
|
||||
setCalculatedStats({
|
||||
averageRating: avg,
|
||||
totalReviews: total,
|
||||
ratingBreakdown: breakdown
|
||||
})
|
||||
}, [])
|
||||
|
||||
const fetchAllReviewsForStats = useCallback(async () => {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
productId,
|
||||
limit: '1000' // Get all reviews for stats
|
||||
})
|
||||
|
||||
const response = await fetch(`/api/reviews?${params}`)
|
||||
const data = await response.json()
|
||||
|
||||
if (response.ok) {
|
||||
setAllReviews(data.reviews)
|
||||
calculateStats(data.reviews)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch all reviews for stats:', error)
|
||||
}
|
||||
}, [productId, calculateStats])
|
||||
|
||||
const fetchReviews = useCallback(async (resetList = false) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const params = new URLSearchParams({
|
||||
productId,
|
||||
page: resetList ? '1' : page.toString(),
|
||||
limit: '10'
|
||||
})
|
||||
|
||||
if (ratingFilter !== 'all') {
|
||||
params.append('rating', ratingFilter)
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/reviews?${params}`)
|
||||
const data = await response.json()
|
||||
|
||||
if (response.ok) {
|
||||
if (resetList) {
|
||||
setReviews(data.reviews)
|
||||
setPage(1)
|
||||
} else {
|
||||
setReviews(prev => [...prev, ...data.reviews])
|
||||
}
|
||||
setHasMore(data.pagination.page < data.pagination.pages)
|
||||
|
||||
// Check if current user has a review
|
||||
if (session?.user) {
|
||||
const userReviewInList = data.reviews.find(
|
||||
(review: Review) => review.user.id === session.user.id
|
||||
)
|
||||
setUserReview(userReviewInList || null)
|
||||
}
|
||||
|
||||
// If this is the first page, also fetch all reviews for stats
|
||||
if (resetList) {
|
||||
fetchAllReviewsForStats()
|
||||
}
|
||||
} else {
|
||||
toast.error('Failed to load reviews')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to load reviews')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [productId, page, ratingFilter, session?.user, fetchAllReviewsForStats])
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
fetchReviews(true)
|
||||
}, [fetchReviews])
|
||||
|
||||
const loadMore = () => {
|
||||
setPage(prev => prev + 1)
|
||||
fetchReviews()
|
||||
}
|
||||
|
||||
const handleReviewSubmit = (newReview: Review) => {
|
||||
setShowForm(false)
|
||||
setEditingReview(null)
|
||||
setUserReview(newReview)
|
||||
fetchReviews(true) // Refresh the list
|
||||
}
|
||||
|
||||
const handleEdit = (review: Review) => {
|
||||
setEditingReview(review)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const handleDelete = async (reviewId: string) => {
|
||||
if (!confirm('Are you sure you want to delete this review?')) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/reviews/${reviewId}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
toast.success('Review deleted successfully')
|
||||
setReviews(prev => prev.filter(review => review.id !== reviewId))
|
||||
setUserReview(null)
|
||||
} else {
|
||||
const data = await response.json()
|
||||
toast.error(data.error || 'Failed to delete review')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete review')
|
||||
}
|
||||
}
|
||||
|
||||
const handleHelpfulVote = async (reviewId: string) => {
|
||||
if (!session?.user) {
|
||||
toast.error('Please sign in to vote')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/reviews/${reviewId}/helpful`, {
|
||||
method: 'POST'
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
|
||||
// Update the review in the list with new vote status and count
|
||||
setReviews(prev => prev.map(review =>
|
||||
review.id === reviewId
|
||||
? {
|
||||
...review,
|
||||
userHasVoted: data.hasVoted,
|
||||
helpfulVotes: data.totalVotes,
|
||||
_count: {
|
||||
...review._count,
|
||||
helpfulVotedBy: data.totalVotes
|
||||
}
|
||||
}
|
||||
: review
|
||||
))
|
||||
|
||||
toast.success(data.hasVoted ? 'Marked as helpful' : 'Removed helpful vote')
|
||||
} else {
|
||||
const data = await response.json()
|
||||
toast.error(data.error || 'Failed to vote')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to vote')
|
||||
}
|
||||
}
|
||||
|
||||
const renderRatingBreakdown = () => {
|
||||
if (!calculatedStats.totalReviews) return null
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{[5, 4, 3, 2, 1].map(rating => {
|
||||
const count = calculatedStats.ratingBreakdown[rating] || 0
|
||||
const percentage = calculatedStats.totalReviews > 0 ? (count / calculatedStats.totalReviews) * 100 : 0
|
||||
|
||||
return (
|
||||
<div key={rating} className="flex items-center space-x-2 text-sm">
|
||||
<div className="flex items-center space-x-1 w-16">
|
||||
<span>{rating}</span>
|
||||
<Star className="h-3 w-3 text-yellow-400 fill-current" />
|
||||
</div>
|
||||
<div className="flex-1 bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-yellow-400 h-2 rounded-full transition-all"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-8 text-right">{count}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="reviews-section" className="space-y-6">
|
||||
{/* Reviews Summary - Only show if there are reviews */}
|
||||
{calculatedStats.totalReviews > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>Customer Reviews</span>
|
||||
{session && !userReview && !showForm && (
|
||||
<Button onClick={scrollToReviews}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Write Review
|
||||
</Button>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Overall Rating */}
|
||||
<div className="text-center">
|
||||
<div className="text-4xl font-bold mb-2">{calculatedStats.averageRating.toFixed(1)}</div>
|
||||
<div className="flex items-center justify-center mb-2">
|
||||
{Array.from({ length: 5 }, (_, index) => (
|
||||
<Star
|
||||
key={index}
|
||||
className={`h-5 w-5 ${
|
||||
index < Math.round(calculatedStats.averageRating)
|
||||
? 'text-yellow-400 fill-current'
|
||||
: 'text-gray-300'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-gray-600">{calculatedStats.totalReviews} reviews</p>
|
||||
</div>
|
||||
|
||||
{/* Rating Breakdown */}
|
||||
<div>
|
||||
<h4 className="font-semibold mb-3">Rating Breakdown</h4>
|
||||
{renderRatingBreakdown()}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* No Reviews State - Show when there are no reviews */}
|
||||
{calculatedStats.totalReviews === 0 && !loading && (
|
||||
<Card>
|
||||
<CardContent className="text-center py-12">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-center">
|
||||
{Array.from({ length: 5 }, (_, index) => (
|
||||
<Star
|
||||
key={index}
|
||||
className="h-8 w-8 text-gray-300"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">No reviews yet</h3>
|
||||
<p className="text-gray-600 mb-4">Be the first to review this product</p>
|
||||
{session ? (
|
||||
<Button onClick={() => setShowForm(true)} className="bg-emerald-600 hover:bg-emerald-700">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Write First Review
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">Sign in to write a review</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Review Form */}
|
||||
{showForm && (
|
||||
<ReviewForm
|
||||
productId={productId}
|
||||
productName={productName}
|
||||
existingReview={editingReview || undefined}
|
||||
disableImageUpload={disableImageUpload}
|
||||
onSubmit={handleReviewSubmit}
|
||||
onCancel={() => {
|
||||
setShowForm(false)
|
||||
setEditingReview(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Filters and Reviews List */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">All Reviews ({calculatedStats.totalReviews})</h3>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Filter className="h-4 w-4 text-gray-500" />
|
||||
<Select value={ratingFilter} onValueChange={setRatingFilter}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue placeholder="Filter" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Ratings</SelectItem>
|
||||
<SelectItem value="5">5 Stars</SelectItem>
|
||||
<SelectItem value="4">4 Stars</SelectItem>
|
||||
<SelectItem value="3">3 Stars</SelectItem>
|
||||
<SelectItem value="2">2 Stars</SelectItem>
|
||||
<SelectItem value="1">1 Star</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reviews */}
|
||||
{loading && reviews.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-600">Loading reviews...</p>
|
||||
</div>
|
||||
) : reviews.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-600 mb-4">No reviews yet</p>
|
||||
{session && !userReview && (
|
||||
<Button onClick={() => setShowForm(true)}>
|
||||
Be the first to review
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{reviews.map((review) => (
|
||||
<ReviewCard
|
||||
key={review.id}
|
||||
review={review}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onHelpfulVote={handleHelpfulVote}
|
||||
userHasVoted={review.userHasVoted || false}
|
||||
/>
|
||||
))}
|
||||
|
||||
{hasMore && (
|
||||
<div className="text-center pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={loadMore}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Loading...' : 'Load More Reviews'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user