first commit

This commit is contained in:
2026-01-17 14:17:42 +05:30
commit 0f194eb9e7
328 changed files with 73544 additions and 0 deletions

View 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>
)
}

View 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>
)
}

View 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>
)
}