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:
@@ -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