586 lines
22 KiB
TypeScript
586 lines
22 KiB
TypeScript
"use client"
|
||
|
||
import { useState, useEffect, useCallback } from "react"
|
||
import { useAuth } from "@/contexts/auth-context"
|
||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||
import { Button } from "@/components/ui/button"
|
||
import { Textarea } from "@/components/ui/textarea"
|
||
import { Badge } from "@/components/ui/badge"
|
||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||
import { Separator } from "@/components/ui/separator"
|
||
import { Star, MessageSquare, ThumbsUp, ThumbsDown, Edit, Trash2, MoreHorizontal } from "lucide-react"
|
||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||
|
||
interface Review {
|
||
id: string
|
||
userId: string
|
||
userName: string
|
||
userAvatar?: string
|
||
userDepartment: string
|
||
rating: number
|
||
comment: string
|
||
createdAt: string
|
||
updatedAt?: string
|
||
helpful: number
|
||
notHelpful: number
|
||
userHelpfulVotes: string[] // user IDs who voted helpful
|
||
userNotHelpfulVotes: string[] // user IDs who voted not helpful
|
||
userVote?: boolean // true = 有幫助, false = 沒幫助, undefined = 未投票
|
||
}
|
||
|
||
interface ReviewSystemProps {
|
||
appId: string
|
||
appName: string
|
||
currentRating: number
|
||
onRatingUpdate: (newRating: number, reviewCount: number) => void
|
||
}
|
||
|
||
export function ReviewSystem({ appId, appName, currentRating, onRatingUpdate }: ReviewSystemProps) {
|
||
const { user, updateAppRating } = useAuth()
|
||
|
||
// Load reviews from database
|
||
const [reviews, setReviews] = useState<Review[]>([])
|
||
const [isLoadingReviews, setIsLoadingReviews] = useState(true)
|
||
const [pagination, setPagination] = useState({
|
||
page: 1,
|
||
limit: 5,
|
||
total: 0,
|
||
totalPages: 0,
|
||
hasNext: false,
|
||
hasPrev: false
|
||
})
|
||
|
||
const [showReviewForm, setShowReviewForm] = useState(false)
|
||
const [newRating, setNewRating] = useState(5)
|
||
const [newComment, setNewComment] = useState("")
|
||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||
const [editingReview, setEditingReview] = useState<string | null>(null)
|
||
const [sortBy, setSortBy] = useState<"newest" | "oldest" | "helpful">("newest")
|
||
|
||
// Load reviews from database
|
||
const loadReviews = useCallback(async (page: number = 1) => {
|
||
try {
|
||
setIsLoadingReviews(true)
|
||
const response = await fetch(`/api/apps/${appId}/reviews?page=${page}&limit=100`)
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
if (data.success) {
|
||
// Transform database format to component format
|
||
const transformedReviews = data.data.reviews.map((review: any) => ({
|
||
id: review.id,
|
||
userId: review.user_id || 'unknown',
|
||
userName: review.userName || review.user_name || '未知用戶',
|
||
userAvatar: review.userAvatar || review.user_avatar,
|
||
userDepartment: review.userDepartment || review.user_department || '未知部門',
|
||
rating: Number(review.rating) || 0, // 確保 rating 是數字
|
||
comment: review.review || review.comment || '', // 使用 review 字段
|
||
createdAt: review.ratedAt || review.rated_at || new Date().toISOString(),
|
||
helpful: 0,
|
||
notHelpful: 0,
|
||
userHelpfulVotes: [],
|
||
userNotHelpfulVotes: [],
|
||
}))
|
||
|
||
// 載入每個評論的投票統計
|
||
for (const review of transformedReviews) {
|
||
try {
|
||
const voteUrl = `/api/reviews/${review.id}/votes${user ? `?userId=${user.id}` : ''}`
|
||
|
||
const voteResponse = await fetch(voteUrl)
|
||
if (voteResponse.ok) {
|
||
const voteData = await voteResponse.json()
|
||
if (voteData.success) {
|
||
review.helpful = voteData.data.helpful
|
||
review.notHelpful = voteData.data.notHelpful
|
||
review.userVote = voteData.data.userVote // 用戶的投票狀態
|
||
}
|
||
} else {
|
||
console.error('投票 API 響應失敗:', voteResponse.status, voteResponse.statusText)
|
||
}
|
||
} catch (error) {
|
||
console.error('載入評論投票錯誤:', error)
|
||
}
|
||
}
|
||
|
||
setReviews(transformedReviews)
|
||
setPagination(data.data.pagination)
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('載入評論錯誤:', error)
|
||
} finally {
|
||
setIsLoadingReviews(false)
|
||
}
|
||
}, [appId, user])
|
||
|
||
// Load reviews on component mount
|
||
useEffect(() => {
|
||
loadReviews()
|
||
}, [loadReviews])
|
||
|
||
const userReview = reviews.find((review) => review.userId === user?.id)
|
||
const canReview = user // 允許用戶多次評論,移除 !userReview 限制
|
||
|
||
// Update rating when reviews change
|
||
useEffect(() => {
|
||
if (reviews.length > 0) {
|
||
const avgRating = reviews.reduce((sum, r) => sum + r.rating, 0) / reviews.length
|
||
const newAvgRating = Number(avgRating.toFixed(1))
|
||
updateAppRating(appId, newAvgRating)
|
||
onRatingUpdate(newAvgRating, reviews.length)
|
||
} else {
|
||
updateAppRating(appId, 0)
|
||
onRatingUpdate(0, 0)
|
||
}
|
||
}, [reviews, appId]) // 移除函數依賴項以避免無限循環
|
||
|
||
const handleSubmitReview = async () => {
|
||
if (!user || !newComment.trim()) return
|
||
|
||
setIsSubmitting(true)
|
||
|
||
try {
|
||
const response = await fetch(`/api/apps/${appId}/reviews`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
userId: user.id,
|
||
rating: newRating,
|
||
comment: newComment.trim(),
|
||
}),
|
||
})
|
||
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
if (data.success) {
|
||
// Reload reviews from database
|
||
await loadReviews()
|
||
setNewComment("")
|
||
setNewRating(5)
|
||
setShowReviewForm(false)
|
||
} else {
|
||
console.error('提交評論失敗:', data.error)
|
||
alert('提交評論失敗,請稍後再試')
|
||
}
|
||
} else {
|
||
console.error('提交評論失敗:', response.statusText)
|
||
alert('提交評論失敗,請稍後再試')
|
||
}
|
||
} catch (error) {
|
||
console.error('提交評論錯誤:', error)
|
||
alert('提交評論時發生錯誤,請稍後再試')
|
||
} finally {
|
||
setIsSubmitting(false)
|
||
}
|
||
}
|
||
|
||
const handleEditReview = async (reviewId: string) => {
|
||
if (!user || !newComment.trim()) return
|
||
|
||
setIsSubmitting(true)
|
||
|
||
try {
|
||
const response = await fetch(`/api/apps/${appId}/reviews`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
userId: user.id,
|
||
rating: newRating,
|
||
comment: newComment.trim(),
|
||
reviewId: reviewId, // 傳遞 reviewId 用於更新
|
||
}),
|
||
})
|
||
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
if (data.success) {
|
||
// Reload reviews from database
|
||
await loadReviews()
|
||
setEditingReview(null)
|
||
setNewComment("")
|
||
setNewRating(5)
|
||
} else {
|
||
console.error('更新評論失敗:', data.error)
|
||
alert('更新評論失敗,請稍後再試')
|
||
}
|
||
} else {
|
||
console.error('更新評論失敗:', response.statusText)
|
||
alert('更新評論失敗,請稍後再試')
|
||
}
|
||
} catch (error) {
|
||
console.error('更新評論錯誤:', error)
|
||
alert('更新評論時發生錯誤,請稍後再試')
|
||
} finally {
|
||
setIsSubmitting(false)
|
||
}
|
||
}
|
||
|
||
const handleDeleteReview = async (reviewId: string) => {
|
||
// For now, we'll just reload reviews since we don't have a delete API yet
|
||
// In a real implementation, you would call a DELETE API endpoint
|
||
await loadReviews()
|
||
}
|
||
|
||
const handleHelpfulVote = async (reviewId: string, isHelpful: boolean) => {
|
||
if (!user) return
|
||
|
||
try {
|
||
const response = await fetch(`/api/reviews/${reviewId}/votes`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
userId: user.id,
|
||
isHelpful: isHelpful,
|
||
}),
|
||
})
|
||
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
if (data.success) {
|
||
// 更新本地評論數據
|
||
setReviews(prevReviews =>
|
||
prevReviews.map(review =>
|
||
review.id === reviewId
|
||
? {
|
||
...review,
|
||
helpful: data.data.helpful,
|
||
notHelpful: data.data.notHelpful,
|
||
userVote: data.data.userVote
|
||
}
|
||
: review
|
||
)
|
||
)
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('投票錯誤:', error)
|
||
}
|
||
}
|
||
|
||
const startEdit = (review: Review) => {
|
||
setEditingReview(review.id)
|
||
setNewRating(review.rating)
|
||
setNewComment(review.comment)
|
||
setShowReviewForm(true)
|
||
}
|
||
|
||
const cancelEdit = () => {
|
||
setEditingReview(null)
|
||
setNewComment("")
|
||
setNewRating(5)
|
||
setShowReviewForm(false)
|
||
}
|
||
|
||
const getInitials = (name: string) => {
|
||
return name.split("").slice(0, 2).join("").toUpperCase()
|
||
}
|
||
|
||
const formatDate = (dateString: string) => {
|
||
return new Date(dateString).toLocaleDateString("zh-TW", {
|
||
year: "numeric",
|
||
month: "long",
|
||
day: "numeric",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
})
|
||
}
|
||
|
||
const renderStars = (rating: number, interactive = false, onRate?: (rating: number) => void) => {
|
||
return (
|
||
<div className="flex items-center space-x-1">
|
||
{[1, 2, 3, 4, 5].map((star) => (
|
||
<Star
|
||
key={star}
|
||
className={`w-4 h-4 ${
|
||
star <= rating ? "text-yellow-400 fill-current" : "text-gray-300"
|
||
} ${interactive ? "cursor-pointer hover:text-yellow-400" : ""}`}
|
||
onClick={() => interactive && onRate && onRate(star)}
|
||
/>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const sortedReviews = [...reviews].sort((a, b) => {
|
||
switch (sortBy) {
|
||
case "oldest":
|
||
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||
case "helpful":
|
||
return b.helpful - a.helpful
|
||
case "newest":
|
||
default:
|
||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||
}
|
||
})
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* Review Summary */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center space-x-2">
|
||
<MessageSquare className="w-5 h-5" />
|
||
<span>用戶評價</span>
|
||
</CardTitle>
|
||
<CardDescription>
|
||
{isLoadingReviews ? (
|
||
"載入評價中..."
|
||
) : reviews.length > 0 ? (
|
||
<div className="flex items-center space-x-4">
|
||
<div className="flex items-center space-x-2">
|
||
{renderStars(Math.round(currentRating))}
|
||
<span className="font-semibold">{Number(currentRating).toFixed(1)}</span>
|
||
<span className="text-gray-500">({reviews.length} 則評價)</span>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
"尚無評價,成為第一個評價的用戶!"
|
||
)}
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{/* Rating Distribution */}
|
||
{reviews.length > 0 && (
|
||
<div className="space-y-2 mb-6">
|
||
{[5, 4, 3, 2, 1].map((rating) => {
|
||
const count = reviews.filter((r) => r.rating === rating).length
|
||
const percentage = (count / reviews.length) * 100
|
||
console.log(`評分 ${rating}: count=${count}, percentage=${percentage}%`) // 調試信息
|
||
return (
|
||
<div key={rating} className="flex items-center space-x-3">
|
||
<div className="flex items-center space-x-1 w-12">
|
||
<span className="text-sm">{rating}</span>
|
||
<Star className="w-3 h-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 duration-300"
|
||
style={{ width: `${percentage}%` }}
|
||
/>
|
||
</div>
|
||
<span className="text-sm text-gray-500 w-8">{count}</span>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{/* Add Review Button */}
|
||
{canReview && (
|
||
<Button
|
||
onClick={() => setShowReviewForm(true)}
|
||
className="w-full bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700"
|
||
>
|
||
<Star className="w-4 h-4 mr-2" />
|
||
撰寫評價
|
||
</Button>
|
||
)}
|
||
|
||
{/* 移除「已評價過」的警告,允許用戶多次評論 */}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Review Form */}
|
||
<Dialog open={showReviewForm} onOpenChange={setShowReviewForm}>
|
||
<DialogContent className="sm:max-w-md">
|
||
<DialogHeader>
|
||
<DialogTitle>{editingReview ? "編輯評價" : "撰寫評價"}</DialogTitle>
|
||
<DialogDescription>分享您對 {appName} 的使用體驗</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className="text-sm font-medium mb-2 block">評分</label>
|
||
{renderStars(newRating, true, setNewRating)}
|
||
</div>
|
||
|
||
<div>
|
||
<label className="text-sm font-medium mb-2 block">評價內容</label>
|
||
<Textarea
|
||
placeholder="請分享您的使用體驗..."
|
||
value={newComment}
|
||
onChange={(e) => setNewComment(e.target.value)}
|
||
rows={4}
|
||
maxLength={500}
|
||
/>
|
||
<div className="text-xs text-gray-500 mt-1">{newComment.length}/500 字元</div>
|
||
</div>
|
||
|
||
<div className="flex space-x-3">
|
||
<Button
|
||
onClick={editingReview ? () => handleEditReview(editingReview) : handleSubmitReview}
|
||
disabled={isSubmitting || !newComment.trim()}
|
||
className="flex-1"
|
||
>
|
||
{isSubmitting ? "提交中..." : editingReview ? "更新評價" : "提交評價"}
|
||
</Button>
|
||
<Button variant="outline" onClick={cancelEdit}>
|
||
取消
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* Reviews List */}
|
||
{isLoadingReviews ? (
|
||
<Card>
|
||
<CardContent className="p-6">
|
||
<div className="text-center text-gray-500">載入評價中...</div>
|
||
</CardContent>
|
||
</Card>
|
||
) : reviews.length > 0 ? (
|
||
<Card>
|
||
<CardHeader>
|
||
<div className="flex items-center justify-between">
|
||
<CardTitle>所有評價 ({pagination.total})</CardTitle>
|
||
<div className="flex items-center space-x-2">
|
||
<span className="text-sm text-gray-500">排序:</span>
|
||
<select
|
||
value={sortBy}
|
||
onChange={(e) => setSortBy(e.target.value as any)}
|
||
className="text-sm border rounded px-2 py-1"
|
||
>
|
||
<option value="newest">最新</option>
|
||
<option value="oldest">最舊</option>
|
||
<option value="helpful">最有幫助</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-6">
|
||
{sortedReviews.map((review, index) => (
|
||
<div key={review.id}>
|
||
<div className="flex items-start space-x-4">
|
||
<Avatar className="w-10 h-10">
|
||
<AvatarImage src={review.userAvatar || "/placeholder.svg"} alt={review.userName} />
|
||
<AvatarFallback className="bg-gradient-to-r from-blue-600 to-purple-600 text-white">
|
||
{getInitials(review.userName)}
|
||
</AvatarFallback>
|
||
</Avatar>
|
||
|
||
<div className="flex-1 space-y-2">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center space-x-3">
|
||
<span className="font-medium">{review.userName}</span>
|
||
<Badge variant="secondary" className="text-xs">
|
||
{review.userDepartment}
|
||
</Badge>
|
||
{renderStars(review.rating)}
|
||
</div>
|
||
|
||
{user?.id === review.userId && (
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button variant="ghost" size="sm">
|
||
<MoreHorizontal className="w-4 h-4" />
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end">
|
||
<DropdownMenuItem onClick={() => startEdit(review)}>
|
||
<Edit className="w-4 h-4 mr-2" />
|
||
編輯
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem onClick={() => handleDeleteReview(review.id)} className="text-red-600">
|
||
<Trash2 className="w-4 h-4 mr-2" />
|
||
刪除
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
)}
|
||
</div>
|
||
|
||
<p className="text-gray-700">{review.comment}</p>
|
||
|
||
<div className="flex items-center justify-between text-sm text-gray-500">
|
||
<div className="flex items-center space-x-4">
|
||
<span>
|
||
{formatDate(review.createdAt)}
|
||
{review.updatedAt && " (已編輯)"}
|
||
</span>
|
||
</div>
|
||
|
||
{user && user.id !== review.userId && (
|
||
<div className="flex items-center space-x-2">
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => handleHelpfulVote(review.id, true)}
|
||
className={`text-xs ${
|
||
review.userVote === true
|
||
? "text-green-600 bg-green-50 border-green-200"
|
||
: "text-gray-500 hover:text-green-600"
|
||
}`}
|
||
>
|
||
<ThumbsUp className={`w-3 h-3 mr-1 ${review.userVote === true ? "fill-current" : ""}`} />
|
||
有幫助 ({review.helpful})
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => handleHelpfulVote(review.id, false)}
|
||
className={`text-xs ${
|
||
review.userVote === false
|
||
? "text-red-600 bg-red-50 border-red-200"
|
||
: "text-gray-500 hover:text-red-600"
|
||
}`}
|
||
>
|
||
<ThumbsDown className={`w-3 h-3 mr-1 ${review.userVote === false ? "fill-current" : ""}`} />
|
||
沒幫助 ({review.notHelpful})
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{index < sortedReviews.length - 1 && <Separator className="mt-6" />}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Pagination */}
|
||
{pagination.totalPages > 1 && (
|
||
<div className="flex items-center justify-between mt-6 pt-4 border-t">
|
||
<div className="text-sm text-gray-500">
|
||
顯示第 {((pagination.page - 1) * pagination.limit) + 1} - {Math.min(pagination.page * pagination.limit, pagination.total)} 筆,共 {pagination.total} 筆
|
||
</div>
|
||
<div className="flex items-center space-x-2">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => loadReviews(pagination.page - 1)}
|
||
disabled={!pagination.hasPrev || isLoadingReviews}
|
||
>
|
||
上一頁
|
||
</Button>
|
||
<span className="text-sm text-gray-500">
|
||
{pagination.page} / {pagination.totalPages}
|
||
</span>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => loadReviews(pagination.page + 1)}
|
||
disabled={!pagination.hasNext || isLoadingReviews}
|
||
>
|
||
下一頁
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
) : null}
|
||
</div>
|
||
)
|
||
}
|