212 lines
9.1 KiB
TypeScript
212 lines
9.1 KiB
TypeScript
import { useState, useEffect } from 'react'
|
|
import { useAuthStore } from '../store/authStore'
|
|
import { API_BASE } from '../types'
|
|
import { Star } from 'lucide-react'
|
|
import { useTranslation } from 'react-i18next'
|
|
|
|
interface Review {
|
|
id: number
|
|
rating: number
|
|
comment: string | null
|
|
customerName: string | null
|
|
createdAt: string
|
|
customer?: {
|
|
pictureUrl: string | null
|
|
}
|
|
}
|
|
|
|
export default function ProductReviews({ productId }: { productId: number }) {
|
|
const { t } = useTranslation()
|
|
const [reviews, setReviews] = useState<Review[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const { customer } = useAuthStore()
|
|
const [rating, setRating] = useState(5)
|
|
const [comment, setComment] = useState('')
|
|
const [submitting, setSubmitting] = useState(false)
|
|
const [filterRating, setFilterRating] = useState<number | null>(null)
|
|
const [visibleCount, setVisibleCount] = useState(3)
|
|
|
|
const fetchReviews = () => {
|
|
fetch(`${API_BASE}/reviews/product/${productId}`)
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
if (Array.isArray(data)) setReviews(data)
|
|
})
|
|
.catch(console.error)
|
|
.finally(() => setLoading(false))
|
|
}
|
|
|
|
useEffect(() => {
|
|
fetchReviews()
|
|
}, [productId])
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
if (!customer) return
|
|
setSubmitting(true)
|
|
try {
|
|
const res = await fetch(`${API_BASE}/reviews`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
productId,
|
|
rating,
|
|
comment,
|
|
customerId: customer.id,
|
|
customerName: customer.displayName
|
|
})
|
|
})
|
|
if (res.ok) {
|
|
setComment('')
|
|
setRating(5)
|
|
fetchReviews()
|
|
}
|
|
} catch (err) {
|
|
console.error(err)
|
|
} finally {
|
|
setSubmitting(false)
|
|
}
|
|
}
|
|
|
|
const avgRating = reviews.length > 0
|
|
? (reviews.reduce((acc, r) => acc + r.rating, 0) / reviews.length).toFixed(1)
|
|
: '0.0'
|
|
|
|
const filteredReviews = filterRating
|
|
? reviews.filter(r => r.rating === filterRating)
|
|
: reviews
|
|
|
|
const visibleReviews = filteredReviews.slice(0, visibleCount)
|
|
|
|
return (
|
|
<div className="pt-16 border-t border-pop-black/10 space-y-12">
|
|
<div className="flex flex-col md:flex-row gap-12 items-start">
|
|
{/* Summary */}
|
|
<div className="w-full md:w-1/3 bg-pop-bg p-8 rounded-3xl text-center space-y-4">
|
|
<h2 className="text-3xl font-bold text-pop-black">{t('product.reviews')}</h2>
|
|
<div className="text-6xl font-black text-pop-pink">{avgRating}</div>
|
|
<div className="flex justify-center gap-1">
|
|
{[1, 2, 3, 4, 5].map(star => (
|
|
<Star key={star} className={`w-5 h-5 ${star <= Number(avgRating) ? 'fill-orange-400 text-orange-400' : 'text-pop-black/30 fill-pop-black/10'}`} />
|
|
))}
|
|
</div>
|
|
<p className="text-pop-black/60 text-sm">{reviews.length} reviews</p>
|
|
</div>
|
|
|
|
{/* List & Form */}
|
|
<div className="w-full md:w-2/3 space-y-8">
|
|
{customer ? (
|
|
<form onSubmit={handleSubmit} className="bg-pop-bg p-6 rounded-3xl border border-pop-black/10 shadow-sm space-y-4">
|
|
<h3 className="font-bold text-pop-black">Write a Review</h3>
|
|
<div className="flex gap-2">
|
|
{[1, 2, 3, 4, 5].map(star => (
|
|
<button type="button" key={star} onClick={() => setRating(star)} className="focus:outline-none">
|
|
<Star className={`w-8 h-8 ${star <= rating ? 'fill-orange-400 text-orange-400' : 'text-pop-black/30 fill-pop-black/10 hover:text-orange-300'}`} />
|
|
</button>
|
|
))}
|
|
</div>
|
|
<textarea
|
|
value={comment}
|
|
onChange={e => setComment(e.target.value)}
|
|
placeholder="Share your experience with this scent..."
|
|
className="w-full bg-pop-bg/50 border border-pop-black/20 rounded-xl p-4 text-sm focus:outline-none focus:border-pop-pink/50 focus:ring-2 focus:ring-pop-pink/20 transition-all text-pop-black placeholder:text-pop-black/50"
|
|
rows={3}
|
|
/>
|
|
<button
|
|
type="submit"
|
|
disabled={submitting}
|
|
className="px-6 py-2.5 bg-pop-pink text-pop-bg text-sm font-bold tracking-widest uppercase rounded-full hover:bg-pop-black transition-all disabled:opacity-50"
|
|
>
|
|
{submitting ? 'Submitting...' : 'Submit Review'}
|
|
</button>
|
|
</form>
|
|
) : (
|
|
<div className="bg-pop-bg p-6 rounded-3xl text-center border border-dashed border-pop-black/30">
|
|
<p className="text-pop-black/60 text-sm mb-2">Login to write a review</p>
|
|
<button onClick={() => window.location.href='/track'} className="text-pop-pink font-bold text-sm tracking-widest uppercase hover:underline">Go to Login</button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-6">
|
|
{/* Filter Bar */}
|
|
{reviews.length > 0 && !loading && (
|
|
<div className="flex gap-2 overflow-x-auto pb-2" style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}>
|
|
<button
|
|
onClick={() => { setFilterRating(null); setVisibleCount(3); }}
|
|
className={`flex-shrink-0 px-4 py-1.5 rounded-full text-xs font-bold transition-all border ${
|
|
filterRating === null
|
|
? 'bg-pop-black text-pop-bg border-pop-black'
|
|
: 'bg-pop-bg text-pop-black border-pop-black/20 hover:border-pop-black/50'
|
|
}`}
|
|
>
|
|
All ({reviews.length})
|
|
</button>
|
|
{[5, 4, 3, 2, 1].map(star => {
|
|
const count = reviews.filter(r => r.rating === star).length;
|
|
if (count === 0) return null;
|
|
return (
|
|
<button
|
|
key={`filter-${star}`}
|
|
onClick={() => { setFilterRating(star); setVisibleCount(3); }}
|
|
className={`flex-shrink-0 flex items-center gap-1 px-4 py-1.5 rounded-full text-xs font-bold transition-all border ${
|
|
filterRating === star
|
|
? 'bg-pop-black text-pop-bg border-pop-black'
|
|
: 'bg-pop-bg text-pop-black border-pop-black/20 hover:border-pop-black/50'
|
|
}`}
|
|
>
|
|
{star} <Star className={`w-3 h-3 ${filterRating === star ? 'fill-white text-pop-bg' : 'fill-orange-400 text-orange-400'}`} /> ({count})
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{loading ? (
|
|
<p className="text-pop-black/60 animate-pulse text-sm">Loading reviews...</p>
|
|
) : reviews.length === 0 ? (
|
|
<p className="text-pop-black/60 text-sm italic">No reviews yet. Be the first to share your thoughts!</p>
|
|
) : visibleReviews.length === 0 ? (
|
|
<p className="text-pop-black/60 text-sm italic">No reviews found for this rating.</p>
|
|
) : (
|
|
visibleReviews.map(review => (
|
|
<div key={review.id} className="border-b border-pop-black/10 pb-6 last:border-0">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
{review.customer?.pictureUrl ? (
|
|
<img src={review.customer.pictureUrl} className="w-10 h-10 rounded-full object-cover" />
|
|
) : (
|
|
<div className="w-10 h-10 rounded-full bg-pop-black/20 flex items-center justify-center text-pop-bg font-bold">
|
|
{(review.customerName || 'A')[0].toUpperCase()}
|
|
</div>
|
|
)}
|
|
<div>
|
|
<p className="font-bold text-pop-black text-sm">{review.customerName}</p>
|
|
<div className="flex gap-0.5">
|
|
{[1, 2, 3, 4, 5].map(star => (
|
|
<Star key={star} className={`w-3 h-3 ${star <= review.rating ? 'fill-orange-400 text-orange-400' : 'text-pop-black/30'}`} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
<span className="ml-auto text-xs text-pop-black/50">{new Date(review.createdAt).toLocaleDateString()}</span>
|
|
</div>
|
|
{review.comment && <p className="text-pop-black/60 text-sm ml-13 leading-relaxed">{review.comment}</p>}
|
|
</div>
|
|
))
|
|
)}
|
|
|
|
{visibleCount < filteredReviews.length && (
|
|
<div className="text-center pt-4">
|
|
<button
|
|
onClick={() => setVisibleCount(prev => prev + 5)}
|
|
className="px-6 py-2.5 border border-pop-black/20 text-pop-black/60 text-xs font-bold tracking-widest uppercase rounded-full hover:border-pop-black hover:text-pop-black transition-all bg-pop-bg"
|
|
>
|
|
Show More Reviews
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|