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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
259
frontend/src/app/reviews/[id]/page.tsx
Normal file
259
frontend/src/app/reviews/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
239
frontend/src/app/reviews/page.tsx
Normal file
239
frontend/src/app/reviews/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
333
frontend/src/app/reviews/write/page.tsx
Normal file
333
frontend/src/app/reviews/write/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user