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

474
backend/app/api/reviews.py Normal file
View File

@@ -0,0 +1,474 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import desc
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
from ..database import get_db
from ..models import User, VehicleRequest, Review, RequestVehicle
from .auth import get_current_user
from .notification import notify_system
router = APIRouter(prefix="/reviews", tags=["reviews"])
# =====================
# Pydantic Schemas
# =====================
class ReviewCreate(BaseModel):
request_id: int
rating: int = Field(..., ge=1, le=5)
title: str = Field(..., max_length=255)
content: str
class ReviewUpdate(BaseModel):
rating: Optional[int] = Field(None, ge=1, le=5)
title: Optional[str] = Field(None, max_length=255)
content: Optional[str] = None
class VehicleInfo(BaseModel):
car_id: Optional[int] = None
car_name: Optional[str] = None
main_image: Optional[str] = None
year: Optional[int] = None
mileage: Optional[int] = None
fuel: Optional[str] = None
final_price: Optional[int] = None
class ReviewListItem(BaseModel):
id: int
rating: int
title: str
content_preview: str
author_name: Optional[str] = None
main_image: Optional[str] = None
view_count: int
created_at: datetime
class Config:
from_attributes = True
class ReviewDetail(BaseModel):
id: int
request_id: int
rating: int
title: str
content: str
author_id: int
author_name: Optional[str] = None
author_email: Optional[str] = None
cc_rewarded: bool
is_published: bool
view_count: int
vehicles: List[VehicleInfo] = []
created_at: datetime
updated_at: Optional[datetime] = None
class ReviewListResponse(BaseModel):
reviews: List[ReviewListItem]
total: int
page: int
page_size: int
total_pages: int
class ReviewableRequestItem(BaseModel):
request_id: int
maker_name: Optional[str] = None
model_name: Optional[str] = None
grade_name: Optional[str] = None
status: str
completed_at: Optional[datetime] = None
vehicle_count: int = 0
main_image: Optional[str] = None
class AdminReviewListItem(BaseModel):
id: int
rating: int
title: str
author_name: Optional[str] = None
author_email: Optional[str] = None
is_published: bool
cc_rewarded: bool
view_count: int
created_at: datetime
# =====================
# Helper Functions
# =====================
def _extract_vehicles(request: VehicleRequest) -> List[VehicleInfo]:
"""Extract vehicle info from a request's recommended vehicles"""
vehicles = []
for rv in request.recommended_vehicles:
if not rv.is_approved:
continue
car_data = rv.car_data or {}
vehicles.append(VehicleInfo(
car_id=rv.car_id or car_data.get("local_car_id"),
car_name=car_data.get("car_name"),
main_image=car_data.get("main_image"),
year=car_data.get("year"),
mileage=car_data.get("mileage"),
fuel=car_data.get("fuel"),
final_price=car_data.get("final_price"),
))
return vehicles
def _get_main_image(request: VehicleRequest) -> Optional[str]:
"""Get the first approved vehicle's main image"""
for rv in request.recommended_vehicles:
if rv.is_approved and rv.car_data:
img = rv.car_data.get("main_image")
if img:
return img
return None
# =====================
# Public Endpoints
# =====================
@router.get("/", response_model=ReviewListResponse)
def get_reviews(
page: int = Query(1, ge=1),
page_size: int = Query(12, ge=1, le=50),
rating: Optional[int] = Query(None, ge=1, le=5),
db: Session = Depends(get_db),
):
"""Get published reviews list"""
query = db.query(Review).filter(Review.is_published == True)
if rating:
query = query.filter(Review.rating == rating)
total = query.count()
total_pages = (total + page_size - 1) // page_size
reviews = query.order_by(desc(Review.created_at)) \
.offset((page - 1) * page_size) \
.limit(page_size) \
.all()
items = []
for r in reviews:
main_image = _get_main_image(r.request) if r.request else None
items.append(ReviewListItem(
id=r.id,
rating=r.rating,
title=r.title,
content_preview=r.content[:150] + ("..." if len(r.content) > 150 else ""),
author_name=r.author.name if r.author else None,
main_image=main_image,
view_count=r.view_count,
created_at=r.created_at,
))
return ReviewListResponse(
reviews=items,
total=total,
page=page,
page_size=page_size,
total_pages=total_pages,
)
@router.get("/my/reviewable-requests", response_model=List[ReviewableRequestItem])
def get_reviewable_requests(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Get user's completed requests that don't have a review yet"""
requests = db.query(VehicleRequest).filter(
VehicleRequest.user_id == current_user.id,
VehicleRequest.status == "completed",
).all()
# Filter out requests that already have a review
existing_review_request_ids = set(
r.request_id for r in db.query(Review.request_id).filter(
Review.author_id == current_user.id
).all()
)
items = []
for req in requests:
if req.id in existing_review_request_ids:
continue
approved_vehicles = [v for v in req.recommended_vehicles if v.is_approved]
main_image = None
for v in approved_vehicles:
if v.car_data and v.car_data.get("main_image"):
main_image = v.car_data["main_image"]
break
items.append(ReviewableRequestItem(
request_id=req.id,
maker_name=req.maker_name,
model_name=req.model_name,
grade_name=req.grade_name,
status=req.status,
completed_at=req.admin_reviewed_at,
vehicle_count=len(approved_vehicles),
main_image=main_image,
))
return items
@router.get("/admin/list", response_model=List[AdminReviewListItem])
def admin_get_reviews(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Admin: Get all reviews including unpublished"""
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
reviews = db.query(Review).order_by(desc(Review.created_at)).all()
return [
AdminReviewListItem(
id=r.id,
rating=r.rating,
title=r.title,
author_name=r.author.name if r.author else None,
author_email=r.author.email if r.author else None,
is_published=r.is_published,
cc_rewarded=r.cc_rewarded,
view_count=r.view_count,
created_at=r.created_at,
)
for r in reviews
]
@router.put("/admin/{review_id}/toggle-publish")
def admin_toggle_publish(
review_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Admin: Toggle review published status"""
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
review = db.query(Review).filter(Review.id == review_id).first()
if not review:
raise HTTPException(status_code=404, detail="Review not found")
review.is_published = not review.is_published
db.commit()
return {"message": f"Review {'published' if review.is_published else 'unpublished'}", "is_published": review.is_published}
@router.delete("/admin/{review_id}")
def admin_delete_review(
review_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Admin: Delete a review"""
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
review = db.query(Review).filter(Review.id == review_id).first()
if not review:
raise HTTPException(status_code=404, detail="Review not found")
db.delete(review)
db.commit()
return {"message": "Review deleted"}
@router.get("/{review_id}", response_model=ReviewDetail)
def get_review(
review_id: int,
db: Session = Depends(get_db),
):
"""Get review detail (increments view count)"""
review = db.query(Review).filter(Review.id == review_id).first()
if not review:
raise HTTPException(status_code=404, detail="Review not found")
if not review.is_published:
raise HTTPException(status_code=404, detail="Review not found")
# Increment view count
review.view_count = (review.view_count or 0) + 1
db.commit()
vehicles = _extract_vehicles(review.request) if review.request else []
return ReviewDetail(
id=review.id,
request_id=review.request_id,
rating=review.rating,
title=review.title,
content=review.content,
author_id=review.author_id,
author_name=review.author.name if review.author else None,
author_email=review.author.email if review.author else None,
cc_rewarded=review.cc_rewarded,
is_published=review.is_published,
view_count=review.view_count,
vehicles=vehicles,
created_at=review.created_at,
updated_at=review.updated_at,
)
# =====================
# User Endpoints (CRUD)
# =====================
@router.post("/", response_model=ReviewDetail)
def create_review(
review_data: ReviewCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Create a review for a completed vehicle request (rewards 1 CC)"""
# Verify the request exists and belongs to user
request = db.query(VehicleRequest).filter(
VehicleRequest.id == review_data.request_id
).first()
if not request:
raise HTTPException(status_code=404, detail="Vehicle request not found")
if request.user_id != current_user.id:
raise HTTPException(status_code=403, detail="You can only review your own requests")
if request.status != "completed":
raise HTTPException(status_code=400, detail="Can only review completed requests")
# Check for existing review
existing = db.query(Review).filter(Review.request_id == review_data.request_id).first()
if existing:
raise HTTPException(status_code=400, detail="You have already written a review for this request")
# Create review
review = Review(
request_id=review_data.request_id,
author_id=current_user.id,
rating=review_data.rating,
title=review_data.title,
content=review_data.content,
cc_rewarded=True,
is_published=True,
)
db.add(review)
# Reward 1 CC
current_user.cc_balance = (current_user.cc_balance or 0) + 1.0
db.commit()
db.refresh(review)
# Send notification about CC reward
try:
notify_system(
db,
current_user.id,
"Review Reward",
"You earned 1 CC for writing a review! Thank you for your feedback.",
link="/reviews",
)
except Exception:
pass # Don't fail review creation if notification fails
vehicles = _extract_vehicles(review.request) if review.request else []
return ReviewDetail(
id=review.id,
request_id=review.request_id,
rating=review.rating,
title=review.title,
content=review.content,
author_id=review.author_id,
author_name=review.author.name if review.author else None,
author_email=review.author.email if review.author else None,
cc_rewarded=review.cc_rewarded,
is_published=review.is_published,
view_count=review.view_count,
vehicles=vehicles,
created_at=review.created_at,
updated_at=review.updated_at,
)
@router.put("/{review_id}", response_model=ReviewDetail)
def update_review(
review_id: int,
review_data: ReviewUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Update own review"""
review = db.query(Review).filter(Review.id == review_id).first()
if not review:
raise HTTPException(status_code=404, detail="Review not found")
if review.author_id != current_user.id:
raise HTTPException(status_code=403, detail="You can only edit your own reviews")
if review_data.rating is not None:
review.rating = review_data.rating
if review_data.title is not None:
review.title = review_data.title
if review_data.content is not None:
review.content = review_data.content
review.updated_at = datetime.utcnow()
db.commit()
db.refresh(review)
vehicles = _extract_vehicles(review.request) if review.request else []
return ReviewDetail(
id=review.id,
request_id=review.request_id,
rating=review.rating,
title=review.title,
content=review.content,
author_id=review.author_id,
author_name=review.author.name if review.author else None,
author_email=review.author.email if review.author else None,
cc_rewarded=review.cc_rewarded,
is_published=review.is_published,
view_count=review.view_count,
vehicles=vehicles,
created_at=review.created_at,
updated_at=review.updated_at,
)
@router.delete("/{review_id}")
def delete_review(
review_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Delete own review"""
review = db.query(Review).filter(Review.id == review_id).first()
if not review:
raise HTTPException(status_code=404, detail="Review not found")
if review.author_id != current_user.id:
raise HTTPException(status_code=403, detail="You can only delete your own reviews")
db.delete(review)
db.commit()
return {"message": "Review deleted"}

View File

@@ -9,7 +9,7 @@ import asyncio
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from .database import engine, Base, SessionLocal
from .api import cars, auth, inquiries, hero_banners, carmodoo, translations, cc, settings, vehicle_requests, dealer, vehicle_share, withdrawal, referral, notification, dashboard, push, exchange_rate, verification, visitor, sns_share, bulletin
from .api import cars, auth, inquiries, hero_banners, carmodoo, translations, cc, settings, vehicle_requests, dealer, vehicle_share, withdrawal, referral, notification, dashboard, push, exchange_rate, verification, visitor, sns_share, bulletin, reviews
from .config import get_settings
from .services.exchange_rate_service import update_exchange_rates
from .services.visitor_service import aggregate_daily_stats, cleanup_old_visitor_logs
@@ -239,6 +239,7 @@ app.include_router(verification.router, prefix="/api")
app.include_router(visitor.router, prefix="/api")
app.include_router(sns_share.router, prefix="/api")
app.include_router(bulletin.router, prefix="/api")
app.include_router(reviews.router, prefix="/api")
@app.get("/")

View File

@@ -19,6 +19,7 @@ from .exchange_rate import ExchangeRate, ExchangeRateHistory
from .cc_package import CCPackage, DEFAULT_CC_PACKAGES
from .visitor import VisitorLog, VisitorDailyStats, VisitorSession
from .bulletin import BoardCategory, BoardPost
from .review import Review
__all__ = [
"CarMaker",
@@ -66,4 +67,5 @@ __all__ = [
"VisitorSession",
"BoardCategory",
"BoardPost",
"Review",
]

View File

@@ -0,0 +1,29 @@
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, CheckConstraint
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from ..database import Base
class Review(Base):
"""User reviews for completed vehicle requests"""
__tablename__ = "reviews"
__table_args__ = (
CheckConstraint('rating >= 1 AND rating <= 5', name='check_rating_range'),
)
id = Column(Integer, primary_key=True, index=True)
request_id = Column(Integer, ForeignKey("vehicle_requests.id"), nullable=False, unique=True)
author_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
rating = Column(Integer, nullable=False)
title = Column(String(255), nullable=False)
content = Column(Text, nullable=False)
cc_rewarded = Column(Boolean, default=False)
is_published = Column(Boolean, default=True, index=True)
view_count = Column(Integer, default=0)
created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships
request = relationship("VehicleRequest", backref="review")
author = relationship("User", backref="reviews")

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

View File

@@ -203,6 +203,7 @@ export default function Header() {
{ href: '/exchange-rate', label: { ko: '환율', en: 'Exchange', mn: 'Ханш', ru: 'Курс' } },
{ href: MENU_GROUPS.inquiry.basePath, label: MENU_GROUPS.inquiry.label, group: 'inquiry' },
...(boardEnabled ? [{ href: '/board', label: { ko: '게시판', en: 'Board', mn: 'Самбар', ru: 'Доска' } }] : []),
{ href: '/reviews', label: { ko: '사용후기', en: 'Reviews', mn: 'Сэтгэгдэл', ru: 'Отзывы' } },
{ href: '/contact', label: { ko: '연락처', en: 'Contact Us', mn: 'Холбоо барих', ru: 'Контакты' } },
];

View File

@@ -1951,4 +1951,140 @@ export const boardApi = {
},
};
// =====================
// Reviews API
// =====================
export interface ReviewVehicleInfo {
car_id?: number;
car_name?: string;
main_image?: string;
year?: number;
mileage?: number;
fuel?: string;
final_price?: number;
}
export interface ReviewListItem {
id: number;
rating: number;
title: string;
content_preview: string;
author_name?: string;
main_image?: string;
view_count: number;
created_at: string;
}
export interface ReviewDetail {
id: number;
request_id: number;
rating: number;
title: string;
content: string;
author_id: number;
author_name?: string;
author_email?: string;
cc_rewarded: boolean;
is_published: boolean;
view_count: number;
vehicles: ReviewVehicleInfo[];
created_at: string;
updated_at?: string;
}
export interface ReviewListResponse {
reviews: ReviewListItem[];
total: number;
page: number;
page_size: number;
total_pages: number;
}
export interface ReviewableRequest {
request_id: number;
maker_name?: string;
model_name?: string;
grade_name?: string;
status: string;
completed_at?: string;
vehicle_count: number;
main_image?: string;
}
export interface AdminReviewListItem {
id: number;
rating: number;
title: string;
author_name?: string;
author_email?: string;
is_published: boolean;
cc_rewarded: boolean;
view_count: number;
created_at: string;
}
export const reviewsApi = {
// Public
getReviews: async (params: {
page?: number;
page_size?: number;
rating?: number;
}): Promise<ReviewListResponse> => {
const { data } = await api.get('/reviews/', { params });
return data;
},
getReview: async (reviewId: number): Promise<ReviewDetail> => {
const { data } = await api.get(`/reviews/${reviewId}`);
return data;
},
// User
getReviewableRequests: async (): Promise<ReviewableRequest[]> => {
const { data } = await api.get('/reviews/my/reviewable-requests');
return data;
},
createReview: async (reviewData: {
request_id: number;
rating: number;
title: string;
content: string;
}): Promise<ReviewDetail> => {
const { data } = await api.post('/reviews/', reviewData);
return data;
},
updateReview: async (reviewId: number, reviewData: {
rating?: number;
title?: string;
content?: string;
}): Promise<ReviewDetail> => {
const { data } = await api.put(`/reviews/${reviewId}`, reviewData);
return data;
},
deleteReview: async (reviewId: number): Promise<{ message: string }> => {
const { data } = await api.delete(`/reviews/${reviewId}`);
return data;
},
// Admin
adminGetReviews: async (): Promise<AdminReviewListItem[]> => {
const { data } = await api.get('/reviews/admin/list');
return data;
},
adminTogglePublish: async (reviewId: number): Promise<{ message: string; is_published: boolean }> => {
const { data } = await api.put(`/reviews/admin/${reviewId}/toggle-publish`);
return data;
},
adminDeleteReview: async (reviewId: number): Promise<{ message: string }> => {
const { data } = await api.delete(`/reviews/admin/${reviewId}`);
return data;
},
};
export default api;