feat: Add Review (사용후기) board system with CC reward
- Backend: Review model, full API (public/user/admin endpoints) - Frontend: list, detail, write/edit pages, admin management - 1 CC reward for writing a review on completed vehicle requests - Navigation updates (Header + admin sidebar) - "Write Review" button on my-request page for completed requests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@ const menuItems = [
|
||||
{ href: '/admin/users', label: 'Users', icon: '👥' },
|
||||
{ href: '/admin/inquiries', label: 'Inquiries', icon: '💬' },
|
||||
{ href: '/admin/board', label: 'Board', icon: '📌' },
|
||||
{ href: '/admin/reviews', label: 'Reviews', icon: '⭐' },
|
||||
{ href: '/admin/settings', label: 'Settings', icon: '⚙️' },
|
||||
];
|
||||
|
||||
|
||||
179
frontend/src/app/admin/reviews/page.tsx
Normal file
179
frontend/src/app/admin/reviews/page.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { reviewsApi, AdminReviewListItem } from '@/lib/api';
|
||||
|
||||
export default function AdminReviewsPage() {
|
||||
const [reviews, setReviews] = useState<AdminReviewListItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const fetchReviews = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await reviewsApi.adminGetReviews();
|
||||
setReviews(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch reviews:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchReviews();
|
||||
}, []);
|
||||
|
||||
const handleTogglePublish = async (reviewId: number) => {
|
||||
try {
|
||||
const result = await reviewsApi.adminTogglePublish(reviewId);
|
||||
setReviews(reviews.map(r =>
|
||||
r.id === reviewId ? { ...r, is_published: result.is_published } : r
|
||||
));
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle publish:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (reviewId: number) => {
|
||||
if (!window.confirm('Are you sure you want to delete this review?')) return;
|
||||
|
||||
try {
|
||||
await reviewsApi.adminDeleteReview(reviewId);
|
||||
setReviews(reviews.filter(r => r.id !== reviewId));
|
||||
} catch (error) {
|
||||
console.error('Failed to delete review:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const filteredReviews = search
|
||||
? reviews.filter(r =>
|
||||
r.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||
r.author_name?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
r.author_email?.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
: reviews;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Reviews Management</h1>
|
||||
<span className="text-sm text-gray-500">{reviews.length} total</span>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="mb-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by title, author name or email..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full max-w-md px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-4 border-primary-600 border-t-transparent"></div>
|
||||
</div>
|
||||
) : filteredReviews.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
No reviews found.
|
||||
</div>
|
||||
) : (
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase w-12">ID</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Title</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase w-40">Author</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase w-20">Rating</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase w-24">Published</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase w-20">CC</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase w-20">Views</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase w-28">Date</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase w-28">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredReviews.map((review) => (
|
||||
<tr key={review.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-sm text-gray-500">{review.id}</td>
|
||||
<td className="px-4 py-3">
|
||||
<a
|
||||
href={`/reviews/${review.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-medium text-gray-900 hover:text-primary-600"
|
||||
>
|
||||
{review.title}
|
||||
</a>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="text-sm text-gray-900">{review.author_name || '-'}</div>
|
||||
<div className="text-xs text-gray-500">{review.author_email}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<div className="flex items-center justify-center gap-0.5">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<svg
|
||||
key={star}
|
||||
className={`w-3.5 h-3.5 ${star <= review.rating ? 'text-yellow-400' : 'text-gray-300'}`}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<button
|
||||
onClick={() => handleTogglePublish(review.id)}
|
||||
className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${
|
||||
review.is_published
|
||||
? 'bg-green-100 text-green-800 hover:bg-green-200'
|
||||
: 'bg-red-100 text-red-800 hover:bg-red-200'
|
||||
}`}
|
||||
>
|
||||
{review.is_published ? 'Published' : 'Hidden'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{review.cc_rewarded ? (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800">
|
||||
1 CC
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400 text-xs">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center text-sm text-gray-500">{review.view_count}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">{formatDate(review.created_at)}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<button
|
||||
onClick={() => handleDelete(review.id)}
|
||||
className="text-red-600 hover:text-red-800 text-sm font-medium"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user