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

View File

@@ -6,7 +6,7 @@ import Link from 'next/link';
import Image from 'next/image';
import { useTranslation, formatPriceWithCurrency, translateCarName } from '@/lib/i18n';
import { useAuthStore } from '@/lib/store';
import { vehicleRequestsApi, VehicleRequestWithVehicles, DirectPurchasedCar } from '@/lib/api';
import { vehicleRequestsApi, VehicleRequestWithVehicles, DirectPurchasedCar, reviewsApi } from '@/lib/api';
import SidebarLayout from '@/components/SidebarLayout';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || '';
@@ -32,6 +32,7 @@ export default function MyRequestPage() {
const [error, setError] = useState<string | null>(null);
const [expandedRequest, setExpandedRequest] = useState<number | null>(null);
const [showDirectPurchases, setShowDirectPurchases] = useState(true);
const [reviewableRequestIds, setReviewableRequestIds] = useState<Set<number>>(new Set());
// Redirect if not logged in
useEffect(() => {
@@ -47,9 +48,13 @@ export default function MyRequestPage() {
try {
setIsLoading(true);
const data = await vehicleRequestsApi.getMyVehicles();
const [data, reviewable] = await Promise.all([
vehicleRequestsApi.getMyVehicles(),
reviewsApi.getReviewableRequests().catch(() => []),
]);
setRequests(data.vehicle_requests);
setDirectPurchases(data.direct_purchases);
setReviewableRequestIds(new Set(reviewable.map(r => r.request_id)));
// Auto-expand first request if it has approved vehicles
if (data.vehicle_requests.length > 0 && data.vehicle_requests[0].approved_vehicles.length > 0) {
setExpandedRequest(data.vehicle_requests[0].request.id);
@@ -201,6 +206,18 @@ export default function MyRequestPage() {
</div>
</div>
<div className="flex items-center gap-4">
{item.request.status === 'completed' && reviewableRequestIds.has(item.request.id) && (
<Link
href={`/reviews/write?request_id=${item.request.id}`}
onClick={(e) => e.stopPropagation()}
className="bg-amber-500 text-white px-3 py-1 rounded-full text-sm font-medium hover:bg-amber-600 transition-colors flex items-center gap-1"
>
<svg className="w-3.5 h-3.5" 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>
{language === 'ko' ? '후기 쓰기' : 'Write Review'}
</Link>
)}
{item.approved_vehicles.length > 0 && (
<span className="bg-primary-100 text-primary-700 px-3 py-1 rounded-full text-sm font-medium">
{item.approved_vehicles.length} {t.approvedVehicles}

View File

@@ -0,0 +1,259 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter, useParams } from 'next/navigation';
import Link from 'next/link';
import Image from 'next/image';
import { reviewsApi, ReviewDetail } from '@/lib/api';
import { useAuthStore } from '@/lib/store';
import { useTranslation, translateCarName, formatPriceWithCurrency } from '@/lib/i18n';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || '';
const getImageUrl = (url: string | undefined): string => {
if (!url) return '';
if (url.startsWith('http://') || url.startsWith('https://')) return url;
return `${API_BASE_URL}${url}`;
};
const StarRating = ({ rating, size = 'md' }: { rating: number; size?: 'sm' | 'md' | 'lg' }) => {
const sizeClass = size === 'lg' ? 'w-6 h-6' : size === 'md' ? 'w-5 h-5' : 'w-4 h-4';
return (
<div className="flex items-center gap-0.5">
{[1, 2, 3, 4, 5].map((star) => (
<svg
key={star}
className={`${sizeClass} ${star <= 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>
);
};
export default function ReviewDetailPage() {
const router = useRouter();
const params = useParams();
const reviewId = Number(params.id);
const { user } = useAuthStore();
const { t, language } = useTranslation();
const [review, setReview] = useState<ReviewDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
useEffect(() => {
const fetchReview = async () => {
try {
setLoading(true);
const data = await reviewsApi.getReview(reviewId);
setReview(data);
} catch (err: any) {
setError(err.response?.status === 404 ? 'Review not found' : 'Failed to load review');
} finally {
setLoading(false);
}
};
if (reviewId) fetchReview();
}, [reviewId]);
const handleDelete = async () => {
if (!review) return;
const confirmMsg = language === 'ko' ? '이 후기를 삭제하시겠습니까?' : 'Are you sure you want to delete this review?';
if (!window.confirm(confirmMsg)) return;
try {
setDeleting(true);
await reviewsApi.deleteReview(review.id);
router.push('/reviews');
} catch (err) {
console.error('Failed to delete review:', err);
alert(language === 'ko' ? '삭제에 실패했습니다.' : 'Failed to delete review.');
} finally {
setDeleting(false);
}
};
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
const locale = language === 'ko' ? 'ko-KR' : language === 'ru' ? 'ru-RU' : 'en-US';
return date.toLocaleDateString(locale, { year: 'numeric', month: 'long', day: 'numeric' });
};
const labels = {
backToList: { ko: '목록으로', en: 'Back to List', mn: 'Жагсаалт руу', ru: 'К списку' },
recommendedVehicles: { ko: '추천받은 차량', en: 'Recommended Vehicles', mn: 'Санал болгосон тээврийн хэрэгсэл', ru: 'Рекомендованные автомобили' },
views: { ko: '조회', en: 'views', mn: 'үзсэн', ru: 'просм.' },
edit: { ko: '수정', en: 'Edit', mn: 'Засах', ru: 'Редактировать' },
delete: { ko: '삭제', en: 'Delete', mn: 'Устгах', ru: 'Удалить' },
year: { ko: '연식', en: 'Year', mn: 'Он', ru: 'Год' },
mileage: { ko: '주행거리', en: 'Mileage', mn: 'Гүйлт', ru: 'Пробег' },
fuel: { ko: '연료', en: 'Fuel', mn: 'Түлш', ru: 'Топливо' },
viewDetail: { ko: '상세보기', en: 'View Detail', mn: 'Дэлгэрэнгүй', ru: 'Подробнее' },
notFound: { ko: '후기를 찾을 수 없습니다.', en: 'Review not found.', mn: 'Сэтгэгдэл олдсонгүй.', ru: 'Отзыв не найден.' },
};
const l = (obj: Record<string, string>) => obj[language] || obj.en;
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="animate-spin rounded-full h-10 w-10 border-4 border-primary-600 border-t-transparent"></div>
</div>
);
}
if (error || !review) {
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="bg-white rounded-lg shadow-sm p-12 text-center">
<p className="text-gray-500 text-lg mb-4">{error || l(labels.notFound)}</p>
<Link href="/reviews" className="text-primary-600 hover:underline">
{l(labels.backToList)}
</Link>
</div>
</div>
</div>
);
}
const isAuthor = user && user.id === review.author_id;
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-4xl mx-auto px-4 py-8">
{/* Back button */}
<Link
href="/reviews"
className="inline-flex items-center text-sm text-gray-600 hover:text-primary-600 mb-6"
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
{l(labels.backToList)}
</Link>
{/* Review Content */}
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
{/* Header */}
<div className="p-6 border-b">
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">{review.title}</h1>
<div className="flex items-center gap-4 text-sm text-gray-500">
<StarRating rating={review.rating} size="md" />
<span>{review.author_name || 'Anonymous'}</span>
<span>{formatDate(review.created_at)}</span>
<span>{l(labels.views)} {review.view_count}</span>
</div>
</div>
{isAuthor && (
<div className="flex gap-2">
<Link
href={`/reviews/write?edit=${review.id}`}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
{l(labels.edit)}
</Link>
<button
onClick={handleDelete}
disabled={deleting}
className="px-3 py-1.5 text-sm border border-red-300 text-red-600 rounded-lg hover:bg-red-50 transition-colors disabled:opacity-50"
>
{l(labels.delete)}
</button>
</div>
)}
</div>
</div>
{/* Body */}
<div className="p-6">
<div className="prose max-w-none text-gray-700 whitespace-pre-wrap">
{review.content}
</div>
</div>
{/* Recommended Vehicles */}
{review.vehicles.length > 0 && (
<div className="border-t p-6 bg-gray-50">
<h3 className="text-lg font-semibold text-gray-800 mb-4">{l(labels.recommendedVehicles)}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{review.vehicles.map((vehicle, idx) => {
const priceInfo = formatPriceWithCurrency(vehicle.final_price, language);
return (
<div key={idx} className="bg-white rounded-lg shadow-sm overflow-hidden border">
{/* Vehicle Image */}
<div className="relative h-36 bg-gray-200">
{vehicle.main_image ? (
<Image
src={getImageUrl(vehicle.main_image)}
alt={vehicle.car_name || 'Vehicle'}
fill
className="object-cover"
unoptimized
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-gray-400">
<svg className="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
)}
</div>
{/* Vehicle Info */}
<div className="p-3">
<h5 className="font-semibold text-gray-800 text-sm mb-2 line-clamp-1">
{translateCarName(vehicle.car_name, language)}
</h5>
<div className="text-xs text-gray-600 space-y-1 mb-2">
{vehicle.year && (
<div className="flex justify-between">
<span>{l(labels.year)}</span>
<span>{vehicle.year}</span>
</div>
)}
{vehicle.mileage && (
<div className="flex justify-between">
<span>{l(labels.mileage)}</span>
<span>{vehicle.mileage.toLocaleString()} km</span>
</div>
)}
{vehicle.fuel && (
<div className="flex justify-between">
<span>{l(labels.fuel)}</span>
<span>{translateCarName(vehicle.fuel, language)}</span>
</div>
)}
</div>
{vehicle.final_price && (
<div className="border-t pt-2">
<div className="text-primary-600 font-bold text-sm">{priceInfo.usdt}</div>
</div>
)}
{vehicle.car_id && (
<Link
href={`/cars/${vehicle.car_id}`}
className="mt-2 block text-center text-xs text-primary-600 hover:underline"
>
{l(labels.viewDetail)}
</Link>
)}
</div>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,239 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import Image from 'next/image';
import { reviewsApi, ReviewListItem } from '@/lib/api';
import { useAuthStore } from '@/lib/store';
import { useTranslation, translateCarName } from '@/lib/i18n';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || '';
const getImageUrl = (url: string | undefined): string => {
if (!url) return '';
if (url.startsWith('http://') || url.startsWith('https://')) return url;
return `${API_BASE_URL}${url}`;
};
const StarRating = ({ rating }: { rating: number }) => (
<div className="flex items-center gap-0.5">
{[1, 2, 3, 4, 5].map((star) => (
<svg
key={star}
className={`w-4 h-4 ${star <= 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>
);
export default function ReviewsPage() {
const router = useRouter();
const { user, token } = useAuthStore();
const isLoggedIn = !!token && !!user;
const { t, language } = useTranslation();
const [reviews, setReviews] = useState<ReviewListItem[]>([]);
const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(1);
const [page, setPage] = useState(1);
const [ratingFilter, setRatingFilter] = useState<number | undefined>(undefined);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchReviews = async () => {
setLoading(true);
try {
const res = await reviewsApi.getReviews({
page,
page_size: 12,
rating: ratingFilter,
});
setReviews(res.reviews);
setTotal(res.total);
setTotalPages(res.total_pages);
} catch (error) {
console.error('Failed to fetch reviews:', error);
} finally {
setLoading(false);
}
};
fetchReviews();
}, [page, ratingFilter]);
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
const locale = language === 'ko' ? 'ko-KR' : language === 'ru' ? 'ru-RU' : 'en-US';
return date.toLocaleDateString(locale, { year: 'numeric', month: 'short', day: 'numeric' });
};
const labels = {
title: { ko: '사용후기', en: 'Reviews', mn: 'Сэтгэгдэл', ru: 'Отзывы' },
writeReview: { ko: '후기 작성', en: 'Write Review', mn: 'Сэтгэгдэл бичих', ru: 'Написать отзыв' },
noReviews: { ko: '아직 후기가 없습니다.', en: 'No reviews yet.', mn: 'Сэтгэгдэл байхгүй байна.', ru: 'Отзывов пока нет.' },
beFirst: { ko: '첫 번째 후기를 작성해보세요!', en: 'Be the first to write a review!', mn: 'Та эхний сэтгэгдэл бичээрэй!', ru: 'Будьте первым, кто напишет отзыв!' },
allRatings: { ko: '전체', en: 'All', mn: 'Бүгд', ru: 'Все' },
views: { ko: '조회', en: 'views', mn: 'үзсэн', ru: 'просм.' },
loginToWrite: { ko: '로그인 후 후기를 작성할 수 있습니다.', en: 'Please login to write a review.', mn: 'Сэтгэгдэл бичихийн тулд нэвтэрнэ үү.', ru: 'Войдите, чтобы написать отзыв.' },
};
const l = (obj: Record<string, string>) => obj[language] || obj.en;
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-6xl mx-auto px-4 py-8">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900">{l(labels.title)}</h1>
{isLoggedIn && (
<Link
href="/reviews/write"
className="inline-flex items-center px-4 py-2 bg-primary-600 text-white text-sm font-medium rounded-lg hover:bg-primary-700 transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
{l(labels.writeReview)}
</Link>
)}
</div>
{/* Rating Filter */}
<div className="bg-white rounded-lg shadow-sm p-4 mb-6">
<div className="flex flex-wrap gap-2">
<button
onClick={() => { setRatingFilter(undefined); setPage(1); }}
className={`px-3 py-1.5 text-sm rounded-full transition-colors ${
!ratingFilter ? 'bg-primary-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{l(labels.allRatings)}
</button>
{[5, 4, 3, 2, 1].map((r) => (
<button
key={r}
onClick={() => { setRatingFilter(r); setPage(1); }}
className={`px-3 py-1.5 text-sm rounded-full transition-colors flex items-center gap-1 ${
ratingFilter === r ? 'bg-primary-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
<svg className={`w-3.5 h-3.5 ${ratingFilter === r ? 'text-yellow-300' : 'text-yellow-400'}`} 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>
{r}
</button>
))}
</div>
</div>
{/* Loading */}
{loading && (
<div className="flex justify-center py-16">
<div className="animate-spin rounded-full h-10 w-10 border-4 border-primary-600 border-t-transparent"></div>
</div>
)}
{/* Empty State */}
{!loading && reviews.length === 0 && (
<div className="bg-white rounded-lg shadow-sm p-12 text-center">
<svg className="w-16 h-16 mx-auto text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
</svg>
<p className="text-gray-500 text-lg mb-1">{l(labels.noReviews)}</p>
<p className="text-gray-400 text-sm">{l(labels.beFirst)}</p>
</div>
)}
{/* Reviews Grid */}
{!loading && reviews.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{reviews.map((review) => (
<Link
key={review.id}
href={`/reviews/${review.id}`}
className="bg-white rounded-lg shadow-sm overflow-hidden hover:shadow-md transition-shadow"
>
{/* Thumbnail */}
<div className="relative h-44 bg-gray-200">
{review.main_image ? (
<Image
src={getImageUrl(review.main_image)}
alt={review.title}
fill
className="object-cover"
unoptimized
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-gray-400">
<svg className="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
)}
{/* Rating Badge */}
<div className="absolute top-2 right-2 bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1">
<StarRating rating={review.rating} />
</div>
</div>
{/* Content */}
<div className="p-4">
<h3 className="font-semibold text-gray-900 mb-1 line-clamp-1">{review.title}</h3>
<p className="text-sm text-gray-600 line-clamp-2 mb-3">{review.content_preview}</p>
<div className="flex items-center justify-between text-xs text-gray-400">
<span>{review.author_name || 'Anonymous'}</span>
<div className="flex items-center gap-3">
<span>{l(labels.views)} {review.view_count}</span>
<span>{formatDate(review.created_at)}</span>
</div>
</div>
</div>
</Link>
))}
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-8 flex justify-center">
<nav className="flex items-center gap-1">
<button
onClick={() => setPage(Math.max(1, page - 1))}
disabled={page <= 1}
className="px-3 py-2 text-sm text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Prev
</button>
<span className="px-4 py-2 text-sm text-gray-700">
{page} / {totalPages}
</span>
<button
onClick={() => setPage(Math.min(totalPages, page + 1))}
disabled={page >= totalPages}
className="px-3 py-2 text-sm text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</nav>
</div>
)}
{/* Login prompt */}
{!isLoggedIn && (
<div className="mt-6 p-4 bg-blue-50 rounded-lg text-center">
<p className="text-sm text-blue-800">
{l(labels.loginToWrite)}{' '}
<Link href="/login" className="font-medium underline">
{language === 'ko' ? '로그인' : 'Login'}
</Link>
</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,333 @@
'use client';
import { useState, useEffect, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import Image from 'next/image';
import { reviewsApi, ReviewableRequest, ReviewDetail } from '@/lib/api';
import { useAuthStore } from '@/lib/store';
import { useTranslation, translateCarName } from '@/lib/i18n';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || '';
const getImageUrl = (url: string | undefined): string => {
if (!url) return '';
if (url.startsWith('http://') || url.startsWith('https://')) return url;
return `${API_BASE_URL}${url}`;
};
function WriteReviewContent() {
const router = useRouter();
const searchParams = useSearchParams();
const requestId = searchParams.get('request_id') ? Number(searchParams.get('request_id')) : null;
const editId = searchParams.get('edit') ? Number(searchParams.get('edit')) : null;
const { user } = useAuthStore();
const { t, language } = useTranslation();
const [reviewableRequests, setReviewableRequests] = useState<ReviewableRequest[]>([]);
const [selectedRequest, setSelectedRequest] = useState<number | null>(requestId);
const [rating, setRating] = useState(5);
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [submitting, setSubmitting] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [hoverRating, setHoverRating] = useState(0);
// Load reviewable requests or existing review for edit
useEffect(() => {
if (!user) {
router.push('/login?redirect=/reviews/write');
return;
}
const loadData = async () => {
try {
setLoading(true);
if (editId) {
// Edit mode: load existing review
const review = await reviewsApi.getReview(editId);
if (review.author_id !== user.id) {
router.push('/reviews');
return;
}
setRating(review.rating);
setTitle(review.title);
setContent(review.content);
setSelectedRequest(review.request_id);
} else {
// New mode: load reviewable requests
const requests = await reviewsApi.getReviewableRequests();
setReviewableRequests(requests);
if (requestId && requests.some(r => r.request_id === requestId)) {
setSelectedRequest(requestId);
}
}
} catch (err) {
console.error('Failed to load data:', err);
setError('Failed to load data');
} finally {
setLoading(false);
}
};
loadData();
}, [user, editId, requestId, router]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedRequest && !editId) return;
try {
setSubmitting(true);
setError(null);
if (editId) {
await reviewsApi.updateReview(editId, { rating, title, content });
router.push(`/reviews/${editId}`);
} else {
const review = await reviewsApi.createReview({
request_id: selectedRequest!,
rating,
title,
content,
});
router.push(`/reviews/${review.id}`);
}
} catch (err: any) {
const detail = err.response?.data?.detail || 'Failed to submit review';
setError(typeof detail === 'string' ? detail : 'Failed to submit review');
} finally {
setSubmitting(false);
}
};
const labels = {
pageTitle: { ko: '후기 작성', en: 'Write Review', mn: 'Сэтгэгдэл бичих', ru: 'Написать отзыв' },
editTitle: { ko: '후기 수정', en: 'Edit Review', mn: 'Сэтгэгдэл засах', ru: 'Редактировать отзыв' },
selectRequest: { ko: '후기를 작성할 요청을 선택하세요', en: 'Select a request to review', mn: 'Сэтгэгдэл бичих хүсэлтээ сонгоно уу', ru: 'Выберите запрос для отзыва' },
noRequests: { ko: '후기를 작성할 수 있는 요청이 없습니다.', en: 'No requests available for review.', mn: 'Сэтгэгдэл бичих хүсэлт алга.', ru: 'Нет запросов для отзыва.' },
completeFirst: { ko: '차량 추천이 완료된 요청에만 후기를 작성할 수 있습니다.', en: 'You can only write reviews for completed requests.', mn: 'Зөвхөн дууссан хүсэлтэд сэтгэгдэл бичих боломжтой.', ru: 'Отзывы можно писать только для завершённых запросов.' },
rating: { ko: '평점', en: 'Rating', mn: 'Үнэлгээ', ru: 'Оценка' },
titleLabel: { ko: '제목', en: 'Title', mn: 'Гарчиг', ru: 'Заголовок' },
titlePlaceholder: { ko: '후기 제목을 입력하세요', en: 'Enter review title', mn: 'Сэтгэгдлийн гарчиг оруулна уу', ru: 'Введите заголовок отзыва' },
contentLabel: { ko: '내용', en: 'Content', mn: 'Агуулга', ru: 'Содержание' },
contentPlaceholder: { ko: '차량 추천 서비스 이용 후기를 자유롭게 작성해주세요.', en: 'Share your experience with our vehicle recommendation service.', mn: 'Тээврийн хэрэгсэл санал болгох үйлчилгээний талаар сэтгэгдлээ бичнэ үү.', ru: 'Поделитесь впечатлениями о нашем сервисе рекомендации автомобилей.' },
submit: { ko: '후기 등록', en: 'Submit Review', mn: 'Сэтгэгдэл илгээх', ru: 'Отправить отзыв' },
update: { ko: '후기 수정', en: 'Update Review', mn: 'Сэтгэгдэл шинэчлэх', ru: 'Обновить отзыв' },
ccReward: { ko: '후기 작성 시 1 CC가 보상으로 지급됩니다!', en: 'You will receive 1 CC reward for writing a review!', mn: 'Сэтгэгдэл бичсэний шагнал 1 CC!', ru: 'Вы получите награду 1 CC за написание отзыва!' },
vehicles: { ko: '대', en: 'vehicles', mn: 'тээврийн хэрэгсэл', ru: 'авто' },
cancel: { ko: '취소', en: 'Cancel', mn: 'Цуцлах', ru: 'Отмена' },
};
const l = (obj: Record<string, string>) => obj[language] || obj.en;
if (!user) return null;
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="animate-spin rounded-full h-10 w-10 border-4 border-primary-600 border-t-transparent"></div>
</div>
);
}
// No selected request and not editing: show list of reviewable requests
if (!selectedRequest && !editId) {
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-3xl mx-auto px-4 py-8">
<h1 className="text-2xl font-bold text-gray-900 mb-6">{l(labels.pageTitle)}</h1>
{/* CC Reward Banner */}
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6 flex items-center gap-3">
<span className="text-2xl">🎁</span>
<p className="text-amber-800 font-medium">{l(labels.ccReward)}</p>
</div>
{reviewableRequests.length === 0 ? (
<div className="bg-white rounded-lg shadow-sm p-8 text-center">
<svg className="w-12 h-12 mx-auto text-gray-300 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p className="text-gray-500 mb-1">{l(labels.noRequests)}</p>
<p className="text-gray-400 text-sm">{l(labels.completeFirst)}</p>
<Link href="/vehicle-request" className="mt-4 inline-block text-primary-600 hover:underline text-sm">
{language === 'ko' ? '차량 추천 요청하기' : 'Request Vehicle Recommendation'}
</Link>
</div>
) : (
<div className="space-y-3">
<h2 className="text-lg font-medium text-gray-700">{l(labels.selectRequest)}</h2>
{reviewableRequests.map((req) => (
<button
key={req.request_id}
onClick={() => setSelectedRequest(req.request_id)}
className="w-full bg-white rounded-lg shadow-sm p-4 hover:shadow-md transition-shadow text-left flex items-center gap-4 border border-transparent hover:border-primary-300"
>
{/* Thumbnail */}
<div className="w-20 h-16 bg-gray-200 rounded overflow-hidden flex-shrink-0">
{req.main_image ? (
<Image
src={getImageUrl(req.main_image)}
alt="Vehicle"
width={80}
height={64}
className="w-full h-full object-cover"
unoptimized
/>
) : (
<div className="w-full h-full flex items-center justify-center text-gray-400">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
)}
</div>
{/* Info */}
<div className="flex-1">
<h3 className="font-semibold text-gray-800">
{translateCarName(req.maker_name, language)} - {translateCarName(req.model_name, language)}
{req.grade_name && ` (${translateCarName(req.grade_name, language)})`}
</h3>
<p className="text-sm text-gray-500">
{req.vehicle_count} {l(labels.vehicles)}
</p>
</div>
{/* Arrow */}
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
))}
</div>
)}
</div>
</div>
);
}
// Review form (new or edit)
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-3xl mx-auto px-4 py-8">
<h1 className="text-2xl font-bold text-gray-900 mb-6">
{editId ? l(labels.editTitle) : l(labels.pageTitle)}
</h1>
{/* CC Reward Banner (only for new reviews) */}
{!editId && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6 flex items-center gap-3">
<span className="text-2xl">🎁</span>
<p className="text-amber-800 font-medium">{l(labels.ccReward)}</p>
</div>
)}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<p className="text-red-600">{error}</p>
</div>
)}
<form onSubmit={handleSubmit} className="bg-white rounded-lg shadow-sm p-6 space-y-6">
{/* Rating */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">{l(labels.rating)}</label>
<div className="flex items-center gap-1">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
type="button"
onClick={() => setRating(star)}
onMouseEnter={() => setHoverRating(star)}
onMouseLeave={() => setHoverRating(0)}
className="p-0.5 focus:outline-none"
>
<svg
className={`w-8 h-8 transition-colors ${
star <= (hoverRating || 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>
</button>
))}
<span className="ml-2 text-sm text-gray-500">{rating}/5</span>
</div>
</div>
{/* Title */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">{l(labels.titleLabel)}</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={l(labels.titlePlaceholder)}
required
maxLength={255}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
{/* Content */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">{l(labels.contentLabel)}</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={l(labels.contentPlaceholder)}
required
rows={8}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 resize-vertical"
/>
</div>
{/* Actions */}
<div className="flex items-center gap-3 pt-2">
<button
type="submit"
disabled={submitting || !title.trim() || !content.trim()}
className="px-6 py-2.5 bg-primary-600 text-white font-medium rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting ? (
<span className="flex items-center gap-2">
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
{editId ? l(labels.update) : l(labels.submit)}
</span>
) : (
editId ? l(labels.update) : l(labels.submit)
)}
</button>
<Link
href="/reviews"
className="px-6 py-2.5 border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition-colors"
>
{l(labels.cancel)}
</Link>
</div>
</form>
</div>
</div>
);
}
export default function WriteReviewPage() {
return (
<Suspense fallback={
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-4 border-primary-600 border-t-transparent"></div>
</div>
}>
<WriteReviewContent />
</Suspense>
);
}