實作個人收藏、個人活動紀錄

This commit is contained in:
2025-09-11 17:40:07 +08:00
parent bc2104d374
commit 9c5dceb001
29 changed files with 3781 additions and 601 deletions

View File

@@ -0,0 +1,496 @@
"use client"
import { useState, useEffect } 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
}
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 [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 = async () => {
try {
setIsLoadingReviews(true)
const response = await fetch(`/api/apps/${appId}/reviews?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: review.rating,
comment: review.review || review.comment || '',
createdAt: review.ratedAt || review.rated_at || new Date().toISOString(),
helpful: 0,
notHelpful: 0,
userHelpfulVotes: [],
userNotHelpfulVotes: [],
}))
setReviews(transformedReviews)
}
}
} catch (error) {
console.error('載入評論錯誤:', error)
} finally {
setIsLoadingReviews(false)
}
}
// Load reviews on component mount
useEffect(() => {
loadReviews()
}, [appId])
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, updateAppRating, onRatingUpdate])
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(),
}),
})
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 = (reviewId: string, isHelpful: boolean) => {
if (!user) return
// For now, we'll just reload reviews since we don't have a helpful vote API yet
// In a real implementation, you would call an API endpoint to handle helpful votes
loadReviews()
}
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
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>
)}
{userReview && (
<Alert>
<MessageSquare className="h-4 w-4" />
<AlertDescription></AlertDescription>
</Alert>
)}
</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> ({reviews.length})</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.userHelpfulVotes.includes(user.id)
? "text-green-600 bg-green-50"
: "text-gray-500"
}`}
>
<ThumbsUp className="w-3 h-3 mr-1" />
({review.helpful})
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleHelpfulVote(review.id, false)}
className={`text-xs ${
review.userNotHelpfulVotes.includes(user.id)
? "text-red-600 bg-red-50"
: "text-gray-500"
}`}
>
<ThumbsDown className="w-3 h-3 mr-1" />
({review.notHelpful})
</Button>
</div>
)}
</div>
</div>
</div>
{index < sortedReviews.length - 1 && <Separator className="mt-6" />}
</div>
))}
</div>
</CardContent>
</Card>
) : null}
</div>
)
}

View File

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