diff --git a/backend/app/api/reviews.py b/backend/app/api/reviews.py new file mode 100644 index 0000000..09c3f45 --- /dev/null +++ b/backend/app/api/reviews.py @@ -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"} diff --git a/backend/app/main.py b/backend/app/main.py index 159b5ab..cc4bafa 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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("/") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 61ef2b8..2fd895b 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", ] diff --git a/backend/app/models/review.py b/backend/app/models/review.py new file mode 100644 index 0000000..8f86ffb --- /dev/null +++ b/backend/app/models/review.py @@ -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") diff --git a/frontend/src/app/admin/layout.tsx b/frontend/src/app/admin/layout.tsx index b9b0f7c..4e9301b 100644 --- a/frontend/src/app/admin/layout.tsx +++ b/frontend/src/app/admin/layout.tsx @@ -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: '⚙️' }, ]; diff --git a/frontend/src/app/admin/reviews/page.tsx b/frontend/src/app/admin/reviews/page.tsx new file mode 100644 index 0000000..91e05f9 --- /dev/null +++ b/frontend/src/app/admin/reviews/page.tsx @@ -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([]); + 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 ( +
+
+

Reviews Management

+ {reviews.length} total +
+ + {/* Search */} +
+ 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" + /> +
+ + {/* Table */} +
+ {loading ? ( +
+
+
+ ) : filteredReviews.length === 0 ? ( +
+ No reviews found. +
+ ) : ( + + + + + + + + + + + + + + + + {filteredReviews.map((review) => ( + + + + + + + + + + + + ))} + +
IDTitleAuthorRatingPublishedCCViewsDateActions
{review.id} + + {review.title} + + +
{review.author_name || '-'}
+
{review.author_email}
+
+
+ {[1, 2, 3, 4, 5].map((star) => ( + + + + ))} +
+
+ + + {review.cc_rewarded ? ( + + 1 CC + + ) : ( + - + )} + {review.view_count}{formatDate(review.created_at)} + +
+ )} +
+
+ ); +} diff --git a/frontend/src/app/my-request/page.tsx b/frontend/src/app/my-request/page.tsx index 65e61ee..49b29c6 100644 --- a/frontend/src/app/my-request/page.tsx +++ b/frontend/src/app/my-request/page.tsx @@ -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(null); const [expandedRequest, setExpandedRequest] = useState(null); const [showDirectPurchases, setShowDirectPurchases] = useState(true); + const [reviewableRequestIds, setReviewableRequestIds] = useState>(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() {
+ {item.request.status === 'completed' && reviewableRequestIds.has(item.request.id) && ( + 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" + > + + + + {language === 'ko' ? '후기 쓰기' : 'Write Review'} + + )} {item.approved_vehicles.length > 0 && ( {item.approved_vehicles.length} {t.approvedVehicles} diff --git a/frontend/src/app/reviews/[id]/page.tsx b/frontend/src/app/reviews/[id]/page.tsx new file mode 100644 index 0000000..852bffb --- /dev/null +++ b/frontend/src/app/reviews/[id]/page.tsx @@ -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 ( +
+ {[1, 2, 3, 4, 5].map((star) => ( + + + + ))} +
+ ); +}; + +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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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) => obj[language] || obj.en; + + if (loading) { + return ( +
+
+
+ ); + } + + if (error || !review) { + return ( +
+
+
+

{error || l(labels.notFound)}

+ + {l(labels.backToList)} + +
+
+
+ ); + } + + const isAuthor = user && user.id === review.author_id; + + return ( +
+
+ {/* Back button */} + + + + + {l(labels.backToList)} + + + {/* Review Content */} +
+ {/* Header */} +
+
+
+

{review.title}

+
+ + {review.author_name || 'Anonymous'} + {formatDate(review.created_at)} + {l(labels.views)} {review.view_count} +
+
+ {isAuthor && ( +
+ + {l(labels.edit)} + + +
+ )} +
+
+ + {/* Body */} +
+
+ {review.content} +
+
+ + {/* Recommended Vehicles */} + {review.vehicles.length > 0 && ( +
+

{l(labels.recommendedVehicles)}

+
+ {review.vehicles.map((vehicle, idx) => { + const priceInfo = formatPriceWithCurrency(vehicle.final_price, language); + return ( +
+ {/* Vehicle Image */} +
+ {vehicle.main_image ? ( + {vehicle.car_name + ) : ( +
+ + + +
+ )} +
+ + {/* Vehicle Info */} +
+
+ {translateCarName(vehicle.car_name, language)} +
+
+ {vehicle.year && ( +
+ {l(labels.year)} + {vehicle.year} +
+ )} + {vehicle.mileage && ( +
+ {l(labels.mileage)} + {vehicle.mileage.toLocaleString()} km +
+ )} + {vehicle.fuel && ( +
+ {l(labels.fuel)} + {translateCarName(vehicle.fuel, language)} +
+ )} +
+ {vehicle.final_price && ( +
+
{priceInfo.usdt}
+
+ )} + {vehicle.car_id && ( + + {l(labels.viewDetail)} + + )} +
+
+ ); + })} +
+
+ )} +
+
+
+ ); +} diff --git a/frontend/src/app/reviews/page.tsx b/frontend/src/app/reviews/page.tsx new file mode 100644 index 0000000..fdbb256 --- /dev/null +++ b/frontend/src/app/reviews/page.tsx @@ -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 }) => ( +
+ {[1, 2, 3, 4, 5].map((star) => ( + + + + ))} +
+); + +export default function ReviewsPage() { + const router = useRouter(); + const { user, token } = useAuthStore(); + const isLoggedIn = !!token && !!user; + const { t, language } = useTranslation(); + + const [reviews, setReviews] = useState([]); + const [total, setTotal] = useState(0); + const [totalPages, setTotalPages] = useState(1); + const [page, setPage] = useState(1); + const [ratingFilter, setRatingFilter] = useState(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) => obj[language] || obj.en; + + return ( +
+
+ {/* Header */} +
+

{l(labels.title)}

+ {isLoggedIn && ( + + + + + {l(labels.writeReview)} + + )} +
+ + {/* Rating Filter */} +
+
+ + {[5, 4, 3, 2, 1].map((r) => ( + + ))} +
+
+ + {/* Loading */} + {loading && ( +
+
+
+ )} + + {/* Empty State */} + {!loading && reviews.length === 0 && ( +
+ + + +

{l(labels.noReviews)}

+

{l(labels.beFirst)}

+
+ )} + + {/* Reviews Grid */} + {!loading && reviews.length > 0 && ( +
+ {reviews.map((review) => ( + + {/* Thumbnail */} +
+ {review.main_image ? ( + {review.title} + ) : ( +
+ + + +
+ )} + {/* Rating Badge */} +
+ +
+
+ + {/* Content */} +
+

{review.title}

+

{review.content_preview}

+
+ {review.author_name || 'Anonymous'} +
+ {l(labels.views)} {review.view_count} + {formatDate(review.created_at)} +
+
+
+ + ))} +
+ )} + + {/* Pagination */} + {totalPages > 1 && ( +
+ +
+ )} + + {/* Login prompt */} + {!isLoggedIn && ( +
+

+ {l(labels.loginToWrite)}{' '} + + {language === 'ko' ? '로그인' : 'Login'} + +

+
+ )} +
+
+ ); +} diff --git a/frontend/src/app/reviews/write/page.tsx b/frontend/src/app/reviews/write/page.tsx new file mode 100644 index 0000000..42cef3c --- /dev/null +++ b/frontend/src/app/reviews/write/page.tsx @@ -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([]); + const [selectedRequest, setSelectedRequest] = useState(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(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) => obj[language] || obj.en; + + if (!user) return null; + + if (loading) { + return ( +
+
+
+ ); + } + + // No selected request and not editing: show list of reviewable requests + if (!selectedRequest && !editId) { + return ( +
+
+

{l(labels.pageTitle)}

+ + {/* CC Reward Banner */} +
+ 🎁 +

{l(labels.ccReward)}

+
+ + {reviewableRequests.length === 0 ? ( +
+ + + +

{l(labels.noRequests)}

+

{l(labels.completeFirst)}

+ + {language === 'ko' ? '차량 추천 요청하기' : 'Request Vehicle Recommendation'} + +
+ ) : ( +
+

{l(labels.selectRequest)}

+ {reviewableRequests.map((req) => ( + + ))} +
+ )} +
+
+ ); + } + + // Review form (new or edit) + return ( +
+
+

+ {editId ? l(labels.editTitle) : l(labels.pageTitle)} +

+ + {/* CC Reward Banner (only for new reviews) */} + {!editId && ( +
+ 🎁 +

{l(labels.ccReward)}

+
+ )} + + {error && ( +
+

{error}

+
+ )} + +
+ {/* Rating */} +
+ +
+ {[1, 2, 3, 4, 5].map((star) => ( + + ))} + {rating}/5 +
+
+ + {/* Title */} +
+ + 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" + /> +
+ + {/* Content */} +
+ +