實作個人收藏、個人活動紀錄
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
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"
|
||||
@@ -27,6 +27,7 @@ interface Review {
|
||||
notHelpful: number
|
||||
userHelpfulVotes: string[] // user IDs who voted helpful
|
||||
userNotHelpfulVotes: string[] // user IDs who voted not helpful
|
||||
userVote?: boolean // true = 有幫助, false = 沒幫助, undefined = 未投票
|
||||
}
|
||||
|
||||
interface ReviewSystemProps {
|
||||
@@ -39,13 +40,16 @@ interface ReviewSystemProps {
|
||||
export function ReviewSystem({ appId, appName, currentRating, onRatingUpdate }: ReviewSystemProps) {
|
||||
const { user, updateAppRating } = useAuth()
|
||||
|
||||
// Load reviews from localStorage
|
||||
const [reviews, setReviews] = useState<Review[]>(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const saved = localStorage.getItem(`reviews_${appId}`)
|
||||
return saved ? JSON.parse(saved) : []
|
||||
}
|
||||
return []
|
||||
// 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)
|
||||
@@ -55,55 +59,123 @@ export function ReviewSystem({ appId, appName, currentRating, onRatingUpdate }:
|
||||
const [editingReview, setEditingReview] = useState<string | null>(null)
|
||||
const [sortBy, setSortBy] = useState<"newest" | "oldest" | "helpful">("newest")
|
||||
|
||||
const userReview = reviews.find((review) => review.userId === user?.id)
|
||||
const canReview = user && !userReview
|
||||
|
||||
// Save reviews to localStorage and update app rating
|
||||
const saveReviews = (updatedReviews: Review[]) => {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(`reviews_${appId}`, JSON.stringify(updatedReviews))
|
||||
// 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)
|
||||
}
|
||||
setReviews(updatedReviews)
|
||||
}, [appId, user])
|
||||
|
||||
// Calculate new average rating and update in context
|
||||
if (updatedReviews.length > 0) {
|
||||
const avgRating = updatedReviews.reduce((sum, r) => sum + r.rating, 0) / updatedReviews.length
|
||||
// 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, updatedReviews.length)
|
||||
onRatingUpdate(newAvgRating, reviews.length)
|
||||
} else {
|
||||
updateAppRating(appId, 0)
|
||||
onRatingUpdate(0, 0)
|
||||
}
|
||||
}
|
||||
}, [reviews, appId]) // 移除函數依賴項以避免無限循環
|
||||
|
||||
const handleSubmitReview = async () => {
|
||||
if (!user || !newComment.trim()) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
|
||||
const review: Review = {
|
||||
id: `r${Date.now()}`,
|
||||
userId: user.id,
|
||||
userName: user.name,
|
||||
userAvatar: user.avatar,
|
||||
userDepartment: user.department,
|
||||
rating: newRating,
|
||||
comment: newComment.trim(),
|
||||
createdAt: new Date().toISOString(),
|
||||
helpful: 0,
|
||||
notHelpful: 0,
|
||||
userHelpfulVotes: [],
|
||||
userNotHelpfulVotes: [],
|
||||
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 updatedReviews = [...reviews, review]
|
||||
saveReviews(updatedReviews)
|
||||
|
||||
setNewComment("")
|
||||
setNewRating(5)
|
||||
setShowReviewForm(false)
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
const handleEditReview = async (reviewId: string) => {
|
||||
@@ -111,77 +183,86 @@ export function ReviewSystem({ appId, appName, currentRating, onRatingUpdate }:
|
||||
|
||||
setIsSubmitting(true)
|
||||
|
||||
const updatedReviews = reviews.map((review) =>
|
||||
review.id === reviewId
|
||||
? {
|
||||
...review,
|
||||
rating: newRating,
|
||||
comment: newComment.trim(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
: review,
|
||||
)
|
||||
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 用於更新
|
||||
}),
|
||||
})
|
||||
|
||||
saveReviews(updatedReviews)
|
||||
|
||||
setEditingReview(null)
|
||||
setNewComment("")
|
||||
setNewRating(5)
|
||||
setIsSubmitting(false)
|
||||
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) => {
|
||||
const updatedReviews = reviews.filter((review) => review.id !== reviewId)
|
||||
saveReviews(updatedReviews)
|
||||
// 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 = (reviewId: string, isHelpful: boolean) => {
|
||||
const handleHelpfulVote = async (reviewId: string, isHelpful: boolean) => {
|
||||
if (!user) return
|
||||
|
||||
const updatedReviews = reviews.map((review) => {
|
||||
if (review.id !== reviewId) return review
|
||||
try {
|
||||
const response = await fetch(`/api/reviews/${reviewId}/votes`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
userId: user.id,
|
||||
isHelpful: isHelpful,
|
||||
}),
|
||||
})
|
||||
|
||||
const helpfulVotes = [...review.userHelpfulVotes]
|
||||
const notHelpfulVotes = [...review.userNotHelpfulVotes]
|
||||
|
||||
if (isHelpful) {
|
||||
if (helpfulVotes.includes(user.id)) {
|
||||
// Remove helpful vote
|
||||
const index = helpfulVotes.indexOf(user.id)
|
||||
helpfulVotes.splice(index, 1)
|
||||
} else {
|
||||
// Add helpful vote and remove not helpful if exists
|
||||
helpfulVotes.push(user.id)
|
||||
const notHelpfulIndex = notHelpfulVotes.indexOf(user.id)
|
||||
if (notHelpfulIndex > -1) {
|
||||
notHelpfulVotes.splice(notHelpfulIndex, 1)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (notHelpfulVotes.includes(user.id)) {
|
||||
// Remove not helpful vote
|
||||
const index = notHelpfulVotes.indexOf(user.id)
|
||||
notHelpfulVotes.splice(index, 1)
|
||||
} else {
|
||||
// Add not helpful vote and remove helpful if exists
|
||||
notHelpfulVotes.push(user.id)
|
||||
const helpfulIndex = helpfulVotes.indexOf(user.id)
|
||||
if (helpfulIndex > -1) {
|
||||
helpfulVotes.splice(helpfulIndex, 1)
|
||||
}
|
||||
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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...review,
|
||||
helpful: helpfulVotes.length,
|
||||
notHelpful: notHelpfulVotes.length,
|
||||
userHelpfulVotes: helpfulVotes,
|
||||
userNotHelpfulVotes: notHelpfulVotes,
|
||||
}
|
||||
})
|
||||
|
||||
saveReviews(updatedReviews)
|
||||
} catch (error) {
|
||||
console.error('投票錯誤:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const startEdit = (review: Review) => {
|
||||
@@ -198,18 +279,6 @@ export function ReviewSystem({ appId, appName, currentRating, onRatingUpdate }:
|
||||
setShowReviewForm(false)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
})
|
||||
|
||||
const getInitials = (name: string) => {
|
||||
return name.split("").slice(0, 2).join("").toUpperCase()
|
||||
}
|
||||
@@ -240,6 +309,18 @@ export function ReviewSystem({ appId, appName, currentRating, onRatingUpdate }:
|
||||
)
|
||||
}
|
||||
|
||||
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 */}
|
||||
@@ -250,11 +331,13 @@ export function ReviewSystem({ appId, appName, currentRating, onRatingUpdate }:
|
||||
<span>用戶評價</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{reviews.length > 0 ? (
|
||||
{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">{currentRating}</span>
|
||||
<span className="font-semibold">{Number(currentRating).toFixed(1)}</span>
|
||||
<span className="text-gray-500">({reviews.length} 則評價)</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -270,6 +353,7 @@ export function ReviewSystem({ appId, appName, currentRating, onRatingUpdate }:
|
||||
{[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">
|
||||
@@ -300,12 +384,7 @@ export function ReviewSystem({ appId, appName, currentRating, onRatingUpdate }:
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{userReview && (
|
||||
<Alert>
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
<AlertDescription>您已經評價過此應用。您可以編輯或刪除您的評價。</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{/* 移除「已評價過」的警告,允許用戶多次評論 */}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -352,11 +431,17 @@ export function ReviewSystem({ appId, appName, currentRating, onRatingUpdate }:
|
||||
</Dialog>
|
||||
|
||||
{/* Reviews List */}
|
||||
{reviews.length > 0 && (
|
||||
{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>所有評價 ({reviews.length})</CardTitle>
|
||||
<CardTitle>所有評價 ({pagination.total})</CardTitle>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-gray-500">排序:</span>
|
||||
<select
|
||||
@@ -431,12 +516,12 @@ export function ReviewSystem({ appId, appName, currentRating, onRatingUpdate }:
|
||||
size="sm"
|
||||
onClick={() => handleHelpfulVote(review.id, true)}
|
||||
className={`text-xs ${
|
||||
review.userHelpfulVotes.includes(user.id)
|
||||
? "text-green-600 bg-green-50"
|
||||
: "text-gray-500"
|
||||
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" />
|
||||
<ThumbsUp className={`w-3 h-3 mr-1 ${review.userVote === true ? "fill-current" : ""}`} />
|
||||
有幫助 ({review.helpful})
|
||||
</Button>
|
||||
<Button
|
||||
@@ -444,12 +529,12 @@ export function ReviewSystem({ appId, appName, currentRating, onRatingUpdate }:
|
||||
size="sm"
|
||||
onClick={() => handleHelpfulVote(review.id, false)}
|
||||
className={`text-xs ${
|
||||
review.userNotHelpfulVotes.includes(user.id)
|
||||
? "text-red-600 bg-red-50"
|
||||
: "text-gray-500"
|
||||
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" />
|
||||
<ThumbsDown className={`w-3 h-3 mr-1 ${review.userVote === false ? "fill-current" : ""}`} />
|
||||
沒幫助 ({review.notHelpful})
|
||||
</Button>
|
||||
</div>
|
||||
@@ -462,9 +547,39 @@ export function ReviewSystem({ appId, appName, currentRating, onRatingUpdate }:
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
Reference in New Issue
Block a user