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:
AutonetSellCar Deploy
2026-01-03 13:10:00 +09:00
parent ef729ee384
commit b0a78abeb3
4 changed files with 310 additions and 14 deletions

View File

@@ -4,12 +4,12 @@ from typing import List
from datetime import datetime, timedelta from datetime import datetime, timedelta
from ..database import get_db 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 ( from ..schemas import (
VehicleRequestCreate, VehicleRequestResponse, VehicleRequestCreate, VehicleRequestResponse,
RequestVehicleCreate, RequestVehicleResponse, RequestVehicleApprove, RequestVehicleCreate, RequestVehicleResponse, RequestVehicleApprove,
PurchasedVehicleCreate, PurchasedVehicleResponse, PurchasedVehicleUpdateStatus, PurchasedVehicleCreate, PurchasedVehicleResponse, PurchasedVehicleUpdateStatus,
VehicleRequestWithVehicles, VehicleRequestWithVehicles, DirectPurchasedCarResponse, MyVehiclesResponse,
) )
from .auth import get_current_user from .auth import get_current_user
from .notification import notify_vehicle_recommended, notify_shipping_update from .notification import notify_vehicle_recommended, notify_shipping_update
@@ -120,6 +120,97 @@ def get_my_requests(
return result 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) # Purchased Vehicles (Find My Car)
# ===================== # =====================

View File

@@ -123,3 +123,21 @@ class VehicleRequestWithVehicles(BaseModel):
class Config: class Config:
from_attributes = True 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

View File

@@ -6,7 +6,7 @@ import Link from 'next/link';
import Image from 'next/image'; import Image from 'next/image';
import { useTranslation, formatPriceWithCurrency, translateCarName } from '@/lib/i18n'; import { useTranslation, formatPriceWithCurrency, translateCarName } from '@/lib/i18n';
import { useAuthStore } from '@/lib/store'; import { useAuthStore } from '@/lib/store';
import { vehicleRequestsApi, VehicleRequestWithVehicles } from '@/lib/api'; import { vehicleRequestsApi, VehicleRequestWithVehicles, DirectPurchasedCar } from '@/lib/api';
import SidebarLayout from '@/components/SidebarLayout'; import SidebarLayout from '@/components/SidebarLayout';
export default function MyRequestPage() { export default function MyRequestPage() {
@@ -15,9 +15,11 @@ export default function MyRequestPage() {
const { user } = useAuthStore(); const { user } = useAuthStore();
const [requests, setRequests] = useState<VehicleRequestWithVehicles[]>([]); const [requests, setRequests] = useState<VehicleRequestWithVehicles[]>([]);
const [directPurchases, setDirectPurchases] = useState<DirectPurchasedCar[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [expandedRequest, setExpandedRequest] = useState<number | null>(null); const [expandedRequest, setExpandedRequest] = useState<number | null>(null);
const [showDirectPurchases, setShowDirectPurchases] = useState(true);
// Redirect if not logged in // Redirect if not logged in
useEffect(() => { useEffect(() => {
@@ -26,28 +28,29 @@ export default function MyRequestPage() {
} }
}, [user, router]); }, [user, router]);
// Load requests // Load all vehicles (recommended + directly purchased)
useEffect(() => { useEffect(() => {
const loadRequests = async () => { const loadVehicles = async () => {
if (!user) return; if (!user) return;
try { try {
setIsLoading(true); setIsLoading(true);
const data = await vehicleRequestsApi.getMyRequests(); const data = await vehicleRequestsApi.getMyVehicles();
setRequests(data); setRequests(data.vehicle_requests);
setDirectPurchases(data.direct_purchases);
// Auto-expand first request if it has approved vehicles // Auto-expand first request if it has approved vehicles
if (data.length > 0 && data[0].approved_vehicles.length > 0) { if (data.vehicle_requests.length > 0 && data.vehicle_requests[0].approved_vehicles.length > 0) {
setExpandedRequest(data[0].request.id); setExpandedRequest(data.vehicle_requests[0].request.id);
} }
} catch (err) { } catch (err) {
console.error('Failed to load requests:', err); console.error('Failed to load vehicles:', err);
setError(language === 'ko' ? '요청 목록을 불러오는데 실패했습니다.' : 'Failed to load requests.'); setError(language === 'ko' ? '차량 목록을 불러오는데 실패했습니다.' : 'Failed to load vehicles.');
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
loadRequests(); loadVehicles();
}, [user, language]); }, [user, language]);
// Format date (mn uses en-US as mn-MN is not supported in most browsers) // Format date (mn uses en-US as mn-MN is not supported in most browsers)
@@ -130,8 +133,8 @@ export default function MyRequestPage() {
</div> </div>
)} )}
{/* No Requests */} {/* No Vehicles at all */}
{!isLoading && !error && requests.length === 0 && ( {!isLoading && !error && requests.length === 0 && directPurchases.length === 0 && (
<div className="bg-white rounded-lg shadow-md p-12 text-center"> <div className="bg-white rounded-lg shadow-md p-12 text-center">
<div className="text-gray-400 mb-4"> <div className="text-gray-400 mb-4">
<svg className="w-16 h-16 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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> </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> </div>
</SidebarLayout> </SidebarLayout>
); );

View File

@@ -630,6 +630,21 @@ export interface VehicleRequestWithVehicles {
approved_vehicles: RequestVehicle[]; 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 = { export const vehicleRequestsApi = {
// User endpoints // User endpoints
createRequest: async (requestData: { createRequest: async (requestData: {
@@ -656,6 +671,12 @@ export const vehicleRequestsApi = {
return data; 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[]> => { getPurchasedVehicles: async (): Promise<PurchasedVehicle[]> => {
const { data } = await api.get('/vehicle-requests/purchased'); const { data } = await api.get('/vehicle-requests/purchased');
return data; return data;