Add directly purchased cars to My Requests page
- Add new /my-vehicles API endpoint returning both recommended and direct purchases - Add DirectPurchasedCarResponse and MyVehiclesResponse schemas - Update frontend to display directly purchased cars (from banners with 1CC) - Show separate collapsible section for direct purchases 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,12 +4,12 @@ from typing import List
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import VehicleRequest, RequestVehicle, PurchasedVehicle, User, DealerInfo, SystemSettings, Car
|
||||
from ..models import VehicleRequest, RequestVehicle, PurchasedVehicle, User, DealerInfo, SystemSettings, Car, CarView
|
||||
from ..schemas import (
|
||||
VehicleRequestCreate, VehicleRequestResponse,
|
||||
RequestVehicleCreate, RequestVehicleResponse, RequestVehicleApprove,
|
||||
PurchasedVehicleCreate, PurchasedVehicleResponse, PurchasedVehicleUpdateStatus,
|
||||
VehicleRequestWithVehicles,
|
||||
VehicleRequestWithVehicles, DirectPurchasedCarResponse, MyVehiclesResponse,
|
||||
)
|
||||
from .auth import get_current_user
|
||||
from .notification import notify_vehicle_recommended, notify_shipping_update
|
||||
@@ -120,6 +120,97 @@ def get_my_requests(
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/my-vehicles", response_model=MyVehiclesResponse)
|
||||
def get_my_vehicles(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get all vehicles accessible to user: recommended + directly purchased from banners"""
|
||||
|
||||
# 1. Get vehicle requests with recommended vehicles (existing logic)
|
||||
requests = db.query(VehicleRequest).filter(
|
||||
VehicleRequest.user_id == current_user.id
|
||||
).order_by(VehicleRequest.created_at.desc()).all()
|
||||
|
||||
vehicle_requests = []
|
||||
for req in requests:
|
||||
if DEV_MODE or (req.created_at and datetime.utcnow() - req.created_at > timedelta(hours=24)):
|
||||
approved_vehicles = [v for v in req.recommended_vehicles if v.is_approved]
|
||||
else:
|
||||
approved_vehicles = []
|
||||
|
||||
enriched_vehicles = []
|
||||
for v in approved_vehicles:
|
||||
vehicle_response = RequestVehicleResponse.model_validate(v)
|
||||
if v.car_id:
|
||||
car = db.query(Car).filter(Car.id == v.car_id).first()
|
||||
if car:
|
||||
vehicle_response.car_data = {**vehicle_response.car_data, "soldout": car.soldout}
|
||||
enriched_vehicles.append(vehicle_response)
|
||||
|
||||
vehicle_requests.append(VehicleRequestWithVehicles(
|
||||
request=VehicleRequestResponse.model_validate(req),
|
||||
approved_vehicles=enriched_vehicles
|
||||
))
|
||||
|
||||
# 2. Get directly purchased cars (from CarView - paid 1CC on banner)
|
||||
car_views = db.query(CarView).filter(
|
||||
CarView.user_id == current_user.id
|
||||
).order_by(CarView.created_at.desc()).all()
|
||||
|
||||
# Get the car_ids that are already in recommended vehicles (to avoid duplicates)
|
||||
recommended_car_ids = set()
|
||||
for vr in vehicle_requests:
|
||||
for v in vr.approved_vehicles:
|
||||
if v.car_id:
|
||||
recommended_car_ids.add(v.car_id)
|
||||
|
||||
direct_purchases = []
|
||||
for cv in car_views:
|
||||
# Skip if already in recommended (user got recommendation first, then it's not a "direct" purchase)
|
||||
if cv.car_id in recommended_car_ids:
|
||||
continue
|
||||
|
||||
car = db.query(Car).filter(Car.id == cv.car_id).first()
|
||||
if car:
|
||||
# Build car_data similar to recommended vehicles
|
||||
main_image = None
|
||||
if car.images:
|
||||
main_img = next((img for img in car.images if img.is_main), None)
|
||||
if main_img:
|
||||
main_image = main_img.url
|
||||
elif car.images:
|
||||
main_image = car.images[0].url
|
||||
|
||||
car_data = {
|
||||
"id": str(car.source_id) if car.source_id else str(car.id),
|
||||
"car_name": car.car_name,
|
||||
"maker_name": car.maker.name if car.maker else None,
|
||||
"year": car.year,
|
||||
"mileage": car.mileage,
|
||||
"final_price": car.final_price_krw,
|
||||
"fuel": car.fuel,
|
||||
"transmission": car.transmission,
|
||||
"color": car.color,
|
||||
"main_image": main_image,
|
||||
"soldout": car.soldout,
|
||||
"local_car_id": car.id,
|
||||
}
|
||||
|
||||
direct_purchases.append(DirectPurchasedCarResponse(
|
||||
id=cv.id,
|
||||
car_id=cv.car_id,
|
||||
car_data=car_data,
|
||||
cc_paid=cv.cc_paid,
|
||||
purchased_at=cv.created_at
|
||||
))
|
||||
|
||||
return MyVehiclesResponse(
|
||||
vehicle_requests=vehicle_requests,
|
||||
direct_purchases=direct_purchases
|
||||
)
|
||||
|
||||
|
||||
# =====================
|
||||
# Purchased Vehicles (Find My Car)
|
||||
# =====================
|
||||
|
||||
@@ -123,3 +123,21 @@ class VehicleRequestWithVehicles(BaseModel):
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Directly purchased car (from CarView - when user pays 1CC on banner car)
|
||||
class DirectPurchasedCarResponse(BaseModel):
|
||||
id: int # CarView id
|
||||
car_id: int
|
||||
car_data: dict # Car details for display
|
||||
cc_paid: float
|
||||
purchased_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Full response including both recommended and directly purchased cars
|
||||
class MyVehiclesResponse(BaseModel):
|
||||
vehicle_requests: List[VehicleRequestWithVehicles]
|
||||
direct_purchases: List[DirectPurchasedCarResponse] # Cars purchased directly from banner
|
||||
|
||||
@@ -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 } from '@/lib/api';
|
||||
import { vehicleRequestsApi, VehicleRequestWithVehicles, DirectPurchasedCar } from '@/lib/api';
|
||||
import SidebarLayout from '@/components/SidebarLayout';
|
||||
|
||||
export default function MyRequestPage() {
|
||||
@@ -15,9 +15,11 @@ export default function MyRequestPage() {
|
||||
const { user } = useAuthStore();
|
||||
|
||||
const [requests, setRequests] = useState<VehicleRequestWithVehicles[]>([]);
|
||||
const [directPurchases, setDirectPurchases] = useState<DirectPurchasedCar[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expandedRequest, setExpandedRequest] = useState<number | null>(null);
|
||||
const [showDirectPurchases, setShowDirectPurchases] = useState(true);
|
||||
|
||||
// Redirect if not logged in
|
||||
useEffect(() => {
|
||||
@@ -26,28 +28,29 @@ export default function MyRequestPage() {
|
||||
}
|
||||
}, [user, router]);
|
||||
|
||||
// Load requests
|
||||
// Load all vehicles (recommended + directly purchased)
|
||||
useEffect(() => {
|
||||
const loadRequests = async () => {
|
||||
const loadVehicles = async () => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data = await vehicleRequestsApi.getMyRequests();
|
||||
setRequests(data);
|
||||
const data = await vehicleRequestsApi.getMyVehicles();
|
||||
setRequests(data.vehicle_requests);
|
||||
setDirectPurchases(data.direct_purchases);
|
||||
// Auto-expand first request if it has approved vehicles
|
||||
if (data.length > 0 && data[0].approved_vehicles.length > 0) {
|
||||
setExpandedRequest(data[0].request.id);
|
||||
if (data.vehicle_requests.length > 0 && data.vehicle_requests[0].approved_vehicles.length > 0) {
|
||||
setExpandedRequest(data.vehicle_requests[0].request.id);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load requests:', err);
|
||||
setError(language === 'ko' ? '요청 목록을 불러오는데 실패했습니다.' : 'Failed to load requests.');
|
||||
console.error('Failed to load vehicles:', err);
|
||||
setError(language === 'ko' ? '차량 목록을 불러오는데 실패했습니다.' : 'Failed to load vehicles.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadRequests();
|
||||
loadVehicles();
|
||||
}, [user, language]);
|
||||
|
||||
// Format date (mn uses en-US as mn-MN is not supported in most browsers)
|
||||
@@ -130,8 +133,8 @@ export default function MyRequestPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No Requests */}
|
||||
{!isLoading && !error && requests.length === 0 && (
|
||||
{/* No Vehicles at all */}
|
||||
{!isLoading && !error && requests.length === 0 && directPurchases.length === 0 && (
|
||||
<div className="bg-white rounded-lg shadow-md p-12 text-center">
|
||||
<div className="text-gray-400 mb-4">
|
||||
<svg className="w-16 h-16 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -338,6 +341,169 @@ export default function MyRequestPage() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Directly Purchased Cars Section */}
|
||||
{!isLoading && !error && directPurchases.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
{/* Section Header */}
|
||||
<div
|
||||
className="p-6 cursor-pointer hover:bg-gray-50 transition border-b"
|
||||
onClick={() => setShowDirectPurchases(!showDirectPurchases)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-800">
|
||||
{language === 'ko' ? '직접 구매한 차량' : 'Directly Purchased Cars'}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{language === 'ko' ? '배너에서 1CC로 정보를 구매한 차량' : 'Cars purchased with 1CC from banners'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="bg-blue-100 text-blue-700 px-3 py-1 rounded-full text-sm font-medium">
|
||||
{directPurchases.length} {language === 'ko' ? '대' : 'cars'}
|
||||
</span>
|
||||
<svg
|
||||
className={`w-6 h-6 text-gray-400 transition-transform ${showDirectPurchases ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Direct Purchases Grid */}
|
||||
{showDirectPurchases && (
|
||||
<div className="p-6 bg-gray-50">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{directPurchases.map((purchase) => {
|
||||
const carData = purchase.car_data;
|
||||
const priceInfo = formatPrice(carData?.final_price);
|
||||
const isSoldout = carData?.soldout === true;
|
||||
const carId = purchase.car_id;
|
||||
|
||||
return (
|
||||
<div key={purchase.id} className={`bg-white rounded-lg shadow-sm overflow-hidden border transition-shadow ${isSoldout ? 'opacity-75' : 'hover:shadow-md'}`}>
|
||||
{/* Clickable Vehicle Card */}
|
||||
<Link href={`/cars/${carId}`} className="block">
|
||||
{/* Vehicle Image */}
|
||||
<div className="relative h-40 bg-gray-200">
|
||||
{carData?.main_image ? (
|
||||
<Image
|
||||
src={carData.main_image}
|
||||
alt={carData.car_name || 'Vehicle'}
|
||||
fill
|
||||
className={`object-cover ${isSoldout ? 'grayscale' : ''}`}
|
||||
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>
|
||||
)}
|
||||
{/* Soldout Badge */}
|
||||
{isSoldout && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
|
||||
<span className="bg-red-600 text-white px-4 py-2 rounded-lg font-bold text-lg transform -rotate-12 shadow-lg">
|
||||
{language === 'ko' ? '판매완료' : 'SOLD OUT'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Direct Purchase Badge */}
|
||||
<div className="absolute top-2 left-2">
|
||||
<span className="bg-blue-600 text-white px-2 py-1 rounded text-xs font-medium">
|
||||
{purchase.cc_paid} CC
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vehicle Info */}
|
||||
<div className="p-4">
|
||||
<h5 className="font-semibold text-gray-800 mb-2 line-clamp-2">
|
||||
{translateCarName(carData?.car_name, language)}
|
||||
</h5>
|
||||
|
||||
<div className="text-sm text-gray-600 space-y-1 mb-3">
|
||||
<div className="flex justify-between">
|
||||
<span>{t.year}</span>
|
||||
<span>{carData?.year || '-'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t.mileage}</span>
|
||||
<span>{carData?.mileage?.toLocaleString()} km</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t.fuel}</span>
|
||||
<span>{translateCarName(carData?.fuel, language) || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-3">
|
||||
<div className="text-primary-600 font-bold text-lg">
|
||||
{priceInfo.usdt}
|
||||
</div>
|
||||
<div className="text-gray-500 text-sm">
|
||||
{priceInfo.local}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="px-4 pb-4 flex gap-2">
|
||||
{isSoldout ? (
|
||||
<div className="flex-1 bg-gray-400 text-white text-sm py-2 px-3 rounded-lg text-center font-medium">
|
||||
{language === 'ko' ? '판매완료' : 'Sold Out'}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push(`/cars/${carId}?action=purchase`)}
|
||||
className="flex-1 bg-primary-600 text-white text-sm py-2 px-3 rounded-lg hover:bg-primary-700 transition font-medium"
|
||||
>
|
||||
{language === 'ko' ? '구매하기' : 'Purchase'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (user?.is_dealer) {
|
||||
router.push(`/cars/${carId}?action=recommend`);
|
||||
} else {
|
||||
alert(language === 'ko'
|
||||
? '딜러 회원만 지인 추천 기능을 사용할 수 있습니다.'
|
||||
: 'Only dealer members can use the referral feature.');
|
||||
}
|
||||
}}
|
||||
className="flex-1 bg-green-600 text-white text-sm py-2 px-3 rounded-lg hover:bg-green-700 transition font-medium"
|
||||
>
|
||||
{language === 'ko' ? '지인에게 추천' : 'Recommend'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SidebarLayout>
|
||||
);
|
||||
|
||||
@@ -630,6 +630,21 @@ export interface VehicleRequestWithVehicles {
|
||||
approved_vehicles: RequestVehicle[];
|
||||
}
|
||||
|
||||
// Directly purchased car (from banner with 1CC)
|
||||
export interface DirectPurchasedCar {
|
||||
id: number;
|
||||
car_id: number;
|
||||
car_data: Record<string, any>;
|
||||
cc_paid: number;
|
||||
purchased_at: string;
|
||||
}
|
||||
|
||||
// Full response including both recommended and directly purchased cars
|
||||
export interface MyVehiclesResponse {
|
||||
vehicle_requests: VehicleRequestWithVehicles[];
|
||||
direct_purchases: DirectPurchasedCar[];
|
||||
}
|
||||
|
||||
export const vehicleRequestsApi = {
|
||||
// User endpoints
|
||||
createRequest: async (requestData: {
|
||||
@@ -656,6 +671,12 @@ export const vehicleRequestsApi = {
|
||||
return data;
|
||||
},
|
||||
|
||||
// Get all vehicles: recommended + directly purchased from banners
|
||||
getMyVehicles: async (): Promise<MyVehiclesResponse> => {
|
||||
const { data } = await api.get('/vehicle-requests/my-vehicles');
|
||||
return data;
|
||||
},
|
||||
|
||||
getPurchasedVehicles: async (): Promise<PurchasedVehicle[]> => {
|
||||
const { data } = await api.get('/vehicle-requests/purchased');
|
||||
return data;
|
||||
|
||||
Reference in New Issue
Block a user