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:
AutonetSellCar Deploy
2026-02-18 08:42:35 +09:00
parent 30888c1434
commit 8e230c537c
12 changed files with 1674 additions and 3 deletions

View File

@@ -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: '⚙️' },
];

View 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>
);
}