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:
474
backend/app/api/reviews.py
Normal file
474
backend/app/api/reviews.py
Normal 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"}
|
||||
@@ -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("/")
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
29
backend/app/models/review.py
Normal file
29
backend/app/models/review.py
Normal 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")
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: 'Контакты' } },
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user