Initial commit: AutonetSellCar platform with deployment system
- Frontend: Next.js 14 with TypeScript - Backend: FastAPI with SQLAlchemy - Agent: Carmodoo sync agent - Deployment: Docker Compose based staging/production setup - Scripts: Automated deployment with rollback support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
287
frontend/src/app/about/page.tsx
Normal file
287
frontend/src/app/about/page.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function AboutPage() {
|
||||
const { t, language } = useTranslation();
|
||||
|
||||
const services = [
|
||||
{
|
||||
icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z',
|
||||
title: t.dealerLicense,
|
||||
description: language === 'ko'
|
||||
? '공인 자동차 딜러 자격을 보유하고 있어 안전하고 신뢰할 수 있는 거래를 보장합니다.'
|
||||
: language === 'mn'
|
||||
? 'Бид албан ёсны автомашины дилерийн лицензтэй бөгөөд найдвартай, аюулгүй арилжаа хийх боломжтой.'
|
||||
: language === 'ru'
|
||||
? 'Мы имеем официальную лицензию автомобильного дилера, обеспечивая безопасные и надёжные сделки.'
|
||||
: 'We hold an official car dealer license, ensuring safe and reliable transactions.',
|
||||
},
|
||||
{
|
||||
icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01',
|
||||
title: t.exportRegistration,
|
||||
description: language === 'ko'
|
||||
? '정식 수출업 등록을 완료하여 합법적인 차량 수출 서비스를 제공합니다.'
|
||||
: language === 'mn'
|
||||
? 'Бид албан ёсоор бүртгэлтэй экспортын компани бөгөөд хууль ёсны дагуу машин экспортлох үйлчилгээ үзүүлдэг.'
|
||||
: language === 'ru'
|
||||
? 'Мы официально зарегистрированы как экспортёр и предоставляем легальные услуги по экспорту автомобилей.'
|
||||
: 'We are officially registered as an exporter and provide legal vehicle export services.',
|
||||
},
|
||||
{
|
||||
icon: 'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4',
|
||||
title: t.oneStopService,
|
||||
description: language === 'ko'
|
||||
? '차량 선택부터 구매, 운송, 통관까지 모든 과정을 원스톱으로 처리해 드립니다.'
|
||||
: language === 'mn'
|
||||
? 'Машин сонгохоос эхлээд худалдан авах, тээвэрлэх, гаалийн бүрдүүлэлт хүртэл бүх үйл явцыг нэг дор шийдвэрлэж өгнө.'
|
||||
: language === 'ru'
|
||||
? 'Мы обеспечиваем полный цикл услуг: от выбора автомобиля до покупки, доставки и таможенного оформления.'
|
||||
: 'We provide comprehensive service from vehicle selection to purchase, shipping, and customs clearance.',
|
||||
},
|
||||
];
|
||||
|
||||
const stats = [
|
||||
{
|
||||
value: '10+',
|
||||
label: language === 'ko' ? '년 경력' : language === 'mn' ? 'Жилийн туршлага' : language === 'ru' ? 'Лет опыта' : 'Years Experience',
|
||||
},
|
||||
{
|
||||
value: '500+',
|
||||
label: language === 'ko' ? '수출 실적' : language === 'mn' ? 'Экспортлосон машин' : language === 'ru' ? 'Экспортировано авто' : 'Vehicles Exported',
|
||||
},
|
||||
{
|
||||
value: '98%',
|
||||
label: language === 'ko' ? '고객 만족도' : language === 'mn' ? 'Үйлчлүүлэгчийн сэтгэл ханамж' : language === 'ru' ? 'Удовлетворённость' : 'Customer Satisfaction',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Hero Section */}
|
||||
<div className="bg-gradient-to-r from-primary-700 to-primary-900 text-white">
|
||||
<div className="container mx-auto px-4 py-16">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<h1 className="text-4xl md:text-5xl font-bold mb-4">{t.aboutTitle}</h1>
|
||||
<p className="text-xl text-primary-100">{t.aboutSubtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Company Introduction */}
|
||||
<div className="container mx-auto px-4 py-16">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="bg-white rounded-lg shadow-md p-8 md:p-12">
|
||||
<h2 className="text-2xl font-bold text-gray-800 mb-6">{t.companyIntro}</h2>
|
||||
|
||||
<div className="prose max-w-none text-gray-600">
|
||||
{language === 'ko' ? (
|
||||
<>
|
||||
<p className="mb-4">
|
||||
<strong>주식회사 그란테크</strong>는 IT 기술과 자동차 수출 사업을 결합한 혁신적인 기업입니다.
|
||||
우리는 한국에서 몽골로의 중고차 수출에 특화되어 있으며, 고객에게 최상의 서비스를 제공하기 위해
|
||||
노력하고 있습니다.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
그란테크는 공인 자동차 딜러 자격과 수출업 등록을 보유하고 있어, 합법적이고 투명한 거래를
|
||||
보장합니다. IT 기술을 활용하여 차량 검색부터 구매, 운송, 통관까지 모든 과정을 효율적으로
|
||||
관리하며, 고객에게 실시간으로 진행 상황을 제공합니다.
|
||||
</p>
|
||||
<p>
|
||||
우리의 목표는 한국 중고차 시장의 우수한 품질을 몽골 시장에 연결하여, 양국 간의 자동차 무역을
|
||||
활성화하는 것입니다. 신뢰와 품질을 바탕으로 최고의 파트너가 되겠습니다.
|
||||
</p>
|
||||
</>
|
||||
) : language === 'mn' ? (
|
||||
<>
|
||||
<p className="mb-4">
|
||||
<strong>Грантек ХХК</strong> нь мэдээллийн технологи болон автомашины экспортын бизнесийг хослуулсан
|
||||
шинэлэг компани юм. Бид Солонгосоос Монгол руу хуучин машин экспортлоход мэргэшсэн бөгөөд
|
||||
үйлчлүүлэгчдэд хамгийн сайн үйлчилгээ үзүүлэхийг зорьдог.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
Грантек нь албан ёсны автомашины дилерийн лицензтэй, экспортлогчоор бүртгэлтэй учир хууль ёсны,
|
||||
ил тод арилжаа хийх баталгаа болдог. Мэдээллийн технологийг ашиглан машин хайхаас эхлээд худалдан
|
||||
авах, тээвэрлэх, гаалийн бүрдүүлэлт хүртэлх бүх үйл явцыг үр ашигтай удирдаж, үйлчлүүлэгчдэд
|
||||
бодит цагийн мэдээлэл өгдөг.
|
||||
</p>
|
||||
<p>
|
||||
Бидний зорилго бол Солонгосын хуучин машины зах зээлийн өндөр чанарыг Монголын зах зээлтэй
|
||||
холбож, хоёр орны хоорондын автомашины худалдааг идэвхжүүлэх явдал юм. Итгэл найдвар, чанарт
|
||||
тулгуурлан шилдэг түнш болохыг зорьж байна.
|
||||
</p>
|
||||
</>
|
||||
) : language === 'ru' ? (
|
||||
<>
|
||||
<p className="mb-4">
|
||||
<strong>Grantech Co., LTD</strong> — инновационная компания, объединяющая IT-технологии и бизнес
|
||||
по экспорту автомобилей. Мы специализируемся на экспорте подержанных автомобилей из Кореи
|
||||
в Монголию и стремимся предоставлять клиентам услуги высочайшего качества.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
Grantech имеет официальную лицензию автомобильного дилера и регистрацию экспортёра, что
|
||||
гарантирует законные и прозрачные сделки. Используя IT-технологии, мы эффективно управляем
|
||||
всеми процессами — от поиска автомобиля до покупки, доставки и таможенного оформления,
|
||||
предоставляя клиентам информацию в режиме реального времени.
|
||||
</p>
|
||||
<p>
|
||||
Наша цель — связать высокое качество корейского рынка подержанных автомобилей с монгольским
|
||||
рынком, активизируя торговлю автомобилями между двумя странами. Мы стремимся стать лучшим
|
||||
партнёром, основываясь на доверии и качестве.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-4">
|
||||
<strong>Grantech Co., LTD</strong> is an innovative company combining IT technology with the
|
||||
automobile export business. We specialize in exporting used cars from Korea to Mongolia and
|
||||
strive to provide the best service to our customers.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
Grantech holds an official car dealer license and export registration, ensuring legal and
|
||||
transparent transactions. Utilizing IT technology, we efficiently manage all processes from
|
||||
vehicle search to purchase, shipping, and customs clearance, providing customers with
|
||||
real-time progress updates.
|
||||
</p>
|
||||
<p>
|
||||
Our goal is to connect the excellent quality of the Korean used car market with the Mongolian
|
||||
market, activating automobile trade between the two countries. We aim to be the best partner
|
||||
based on trust and quality.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Section */}
|
||||
<div className="bg-primary-600 text-white py-12">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-4xl mx-auto grid grid-cols-3 gap-8">
|
||||
{stats.map((stat, index) => (
|
||||
<div key={index} className="text-center">
|
||||
<div className="text-4xl md:text-5xl font-bold mb-2">{stat.value}</div>
|
||||
<div className="text-primary-100">{stat.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Services Section */}
|
||||
<div className="container mx-auto px-4 py-16">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h2 className="text-2xl font-bold text-gray-800 text-center mb-12">{t.ourServices}</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{services.map((service, index) => (
|
||||
<div key={index} className="bg-white rounded-lg shadow-md p-6 text-center">
|
||||
<div className="w-16 h-16 bg-primary-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={service.icon} />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-3">{service.title}</h3>
|
||||
<p className="text-gray-600 text-sm">{service.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Process Section */}
|
||||
<div className="bg-gray-100 py-16">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h2 className="text-2xl font-bold text-gray-800 text-center mb-12">{t.processFlow}</h2>
|
||||
|
||||
<div className="relative">
|
||||
{/* Timeline Line */}
|
||||
<div className="hidden md:block absolute left-1/2 top-0 bottom-0 w-1 bg-primary-200 -translate-x-1/2"></div>
|
||||
|
||||
{/* Process Steps */}
|
||||
<div className="space-y-8">
|
||||
{[
|
||||
{
|
||||
step: 1,
|
||||
title: language === 'ko' ? '차량 검색 요청' : language === 'mn' ? 'Машин хайх хүсэлт' : language === 'ru' ? 'Запрос на поиск' : 'Request Search',
|
||||
desc: language === 'ko' ? '원하시는 차량 조건을 알려주세요' : language === 'mn' ? 'Хүссэн машины шалгуураа хэлнэ үү' : language === 'ru' ? 'Укажите параметры желаемого автомобиля' : 'Tell us your desired vehicle criteria',
|
||||
},
|
||||
{
|
||||
step: 2,
|
||||
title: language === 'ko' ? '전문가 검토' : language === 'mn' ? 'Мэргэжилтний хяналт' : language === 'ru' ? 'Экспертная проверка' : 'Expert Review',
|
||||
desc: language === 'ko' ? '24시간 내에 맞춤 차량을 찾아드립니다' : language === 'mn' ? '24 цагийн дотор тохирох машин олж өгнө' : language === 'ru' ? 'Найдём подходящие варианты в течение 24 часов' : 'We find matching vehicles within 24 hours',
|
||||
},
|
||||
{
|
||||
step: 3,
|
||||
title: language === 'ko' ? '구매 및 결제' : language === 'mn' ? 'Худалдан авалт' : language === 'ru' ? 'Покупка и оплата' : 'Purchase & Payment',
|
||||
desc: language === 'ko' ? '선택하신 차량을 안전하게 구매합니다' : language === 'mn' ? 'Сонгосон машинаа аюулгүй худалдаж авна' : language === 'ru' ? 'Безопасная покупка выбранного автомобиля' : 'Safely purchase your selected vehicle',
|
||||
},
|
||||
{
|
||||
step: 4,
|
||||
title: language === 'ko' ? '운송 및 통관' : language === 'mn' ? 'Тээвэр ба гааль' : language === 'ru' ? 'Доставка и таможня' : 'Shipping & Customs',
|
||||
desc: language === 'ko' ? '인천에서 울란바토르까지 안전하게 운송합니다' : language === 'mn' ? 'Инчоноос Улаанбаатар хүртэл аюулгүй тээвэрлэнэ' : language === 'ru' ? 'Безопасная доставка из Инчхона в Улан-Батор' : 'Safe shipping from Incheon to Ulaanbaatar',
|
||||
},
|
||||
{
|
||||
step: 5,
|
||||
title: language === 'ko' ? '인수 완료' : language === 'mn' ? 'Хүлээн авалт' : language === 'ru' ? 'Получение' : 'Delivery Complete',
|
||||
desc: language === 'ko' ? '몽골에서 차량을 인수하세요' : language === 'mn' ? 'Монголд машинаа хүлээн аваарай' : language === 'ru' ? 'Получите автомобиль в Монголии' : 'Receive your vehicle in Mongolia',
|
||||
},
|
||||
].map((item, index) => (
|
||||
<div key={item.step} className={`flex items-center gap-6 ${index % 2 === 1 ? 'md:flex-row-reverse' : ''}`}>
|
||||
<div className="flex-1 text-right md:text-left">
|
||||
{index % 2 === 0 && (
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="text-primary-600 font-bold mb-2">Step {item.step}</div>
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-2">{item.title}</h3>
|
||||
<p className="text-gray-600 text-sm">{item.desc}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-10 h-10 bg-primary-600 rounded-full flex items-center justify-center text-white font-bold z-10">
|
||||
{item.step}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
{index % 2 === 1 && (
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="text-primary-600 font-bold mb-2">Step {item.step}</div>
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-2">{item.title}</h3>
|
||||
<p className="text-gray-600 text-sm">{item.desc}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA Section */}
|
||||
<div className="container mx-auto px-4 py-16">
|
||||
<div className="max-w-2xl mx-auto text-center">
|
||||
<h2 className="text-2xl font-bold text-gray-800 mb-4">{t.readyToFindYourCar}</h2>
|
||||
<p className="text-gray-600 mb-8">{t.browseOurCollection}</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link
|
||||
href="/request"
|
||||
className="bg-primary-600 text-white px-8 py-3 rounded-lg hover:bg-primary-700 transition font-medium"
|
||||
>
|
||||
{t.requestVehicle}
|
||||
</Link>
|
||||
<Link
|
||||
href="/contact"
|
||||
className="bg-gray-200 text-gray-800 px-8 py-3 rounded-lg hover:bg-gray-300 transition font-medium"
|
||||
>
|
||||
{t.contactUs}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2330
frontend/src/app/admin/cars/page.tsx
Normal file
2330
frontend/src/app/admin/cars/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
396
frontend/src/app/admin/dealer-translations/page.tsx
Normal file
396
frontend/src/app/admin/dealer-translations/page.tsx
Normal file
@@ -0,0 +1,396 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { carmodooApi } from '@/lib/api';
|
||||
|
||||
interface CarTranslation {
|
||||
id: number;
|
||||
car_name: string;
|
||||
dealer_description: string;
|
||||
has_en: boolean;
|
||||
has_mn: boolean;
|
||||
has_ru: boolean;
|
||||
}
|
||||
|
||||
interface CarTranslationDetail {
|
||||
car_id: number;
|
||||
car_name: string;
|
||||
dealer_description: string | null;
|
||||
translations: {
|
||||
en: string | null;
|
||||
mn: string | null;
|
||||
ru: string | null;
|
||||
};
|
||||
has_translations: boolean;
|
||||
papago_configured: boolean;
|
||||
}
|
||||
|
||||
export default function DealerTranslationsPage() {
|
||||
const [untranslatedCars, setUntranslatedCars] = useState<CarTranslation[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedCar, setSelectedCar] = useState<CarTranslationDetail | null>(null);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [editData, setEditData] = useState({
|
||||
dealer_description: '', // 한국어 원문
|
||||
dealer_description_en: '',
|
||||
dealer_description_mn: '',
|
||||
dealer_description_ru: '',
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [regenerating, setRegenerating] = useState(false);
|
||||
const [batchTranslating, setBatchTranslating] = useState(false);
|
||||
const [batchResult, setBatchResult] = useState<{
|
||||
total: number;
|
||||
success: number;
|
||||
failed: number;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadUntranslatedCars();
|
||||
}, []);
|
||||
|
||||
const loadUntranslatedCars = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await carmodooApi.getUntranslatedCars(50);
|
||||
setUntranslatedCars(data.cars);
|
||||
} catch (err) {
|
||||
console.error('Failed to load untranslated cars:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadCarTranslations = async (carId: number) => {
|
||||
try {
|
||||
const data = await carmodooApi.getCarTranslations(carId);
|
||||
setSelectedCar(data);
|
||||
setEditData({
|
||||
dealer_description: data.dealer_description || '',
|
||||
dealer_description_en: data.translations.en || '',
|
||||
dealer_description_mn: data.translations.mn || '',
|
||||
dealer_description_ru: data.translations.ru || '',
|
||||
});
|
||||
setEditMode(false);
|
||||
} catch (err) {
|
||||
console.error('Failed to load car translations:', err);
|
||||
alert('Failed to load translations');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!selectedCar) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await carmodooApi.updateCarTranslations(selectedCar.car_id, editData);
|
||||
await loadCarTranslations(selectedCar.car_id);
|
||||
await loadUntranslatedCars();
|
||||
setEditMode(false);
|
||||
alert('Translations saved successfully');
|
||||
} catch (err) {
|
||||
console.error('Failed to save translations:', err);
|
||||
alert('Failed to save translations');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegenerate = async () => {
|
||||
if (!selectedCar) return;
|
||||
if (!confirm('Regenerate translations using Papago API? This will overwrite existing translations.')) return;
|
||||
|
||||
setRegenerating(true);
|
||||
try {
|
||||
const result = await carmodooApi.regenerateTranslations(selectedCar.car_id);
|
||||
setSelectedCar({
|
||||
...selectedCar,
|
||||
translations: result.translations,
|
||||
has_translations: true,
|
||||
});
|
||||
setEditData({
|
||||
dealer_description: selectedCar.dealer_description || '',
|
||||
dealer_description_en: result.translations.en || '',
|
||||
dealer_description_mn: result.translations.mn || '',
|
||||
dealer_description_ru: result.translations.ru || '',
|
||||
});
|
||||
await loadUntranslatedCars();
|
||||
alert('Translations regenerated successfully');
|
||||
} catch (err: any) {
|
||||
console.error('Failed to regenerate translations:', err);
|
||||
alert(err.response?.data?.detail || 'Failed to regenerate translations');
|
||||
} finally {
|
||||
setRegenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchTranslate = async () => {
|
||||
if (!confirm('Translate all pending cars? This may take a while.')) return;
|
||||
|
||||
setBatchTranslating(true);
|
||||
setBatchResult(null);
|
||||
try {
|
||||
const result = await carmodooApi.translateAllPending();
|
||||
setBatchResult({
|
||||
total: result.total,
|
||||
success: result.success,
|
||||
failed: result.failed,
|
||||
});
|
||||
await loadUntranslatedCars();
|
||||
} catch (err: any) {
|
||||
console.error('Failed to batch translate:', err);
|
||||
alert(err.response?.data?.detail || 'Failed to batch translate');
|
||||
} finally {
|
||||
setBatchTranslating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-800">Dealer Description Translations</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Manage translations for dealer descriptions before displaying to users
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleBatchTranslate}
|
||||
disabled={batchTranslating || untranslatedCars.length === 0}
|
||||
className="bg-purple-600 text-white px-4 py-2 rounded-lg hover:bg-purple-700 disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{batchTranslating ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
Translating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />
|
||||
</svg>
|
||||
Translate All Pending
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Batch Result */}
|
||||
{batchResult && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
|
||||
<h3 className="font-medium text-green-800 mb-2">Batch Translation Complete</h3>
|
||||
<div className="flex gap-6">
|
||||
<div>
|
||||
<span className="text-gray-600">Total:</span> <span className="font-bold">{batchResult.total}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600">Success:</span> <span className="font-bold text-green-600">{batchResult.success}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600">Failed:</span> <span className="font-bold text-red-600">{batchResult.failed}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Untranslated Cars List */}
|
||||
<div className="bg-white rounded-xl shadow-sm">
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<h2 className="font-semibold text-gray-800">
|
||||
Cars Without Translations ({untranslatedCars.length})
|
||||
</h2>
|
||||
</div>
|
||||
<div className="max-h-[600px] overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="w-8 h-8 border-4 border-primary-600 border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
) : untranslatedCars.length === 0 ? (
|
||||
<div className="py-8 text-center text-gray-500">
|
||||
All cars have translations
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{untranslatedCars.map((car) => (
|
||||
<button
|
||||
key={car.id}
|
||||
onClick={() => loadCarTranslations(car.id)}
|
||||
className={`w-full p-4 text-left hover:bg-gray-50 transition ${
|
||||
selectedCar?.car_id === car.id ? 'bg-blue-50' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-gray-800 truncate">{car.car_name}</div>
|
||||
<div className="text-sm text-gray-500 truncate mt-1">{car.dealer_description}</div>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${car.has_en ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>
|
||||
EN {car.has_en ? '✓' : '✗'}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${car.has_mn ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>
|
||||
MN {car.has_mn ? '✓' : '✗'}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${car.has_ru ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>
|
||||
RU {car.has_ru ? '✓' : '✗'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Translation Editor */}
|
||||
<div className="bg-white rounded-xl shadow-sm">
|
||||
<div className="p-4 border-b border-gray-200 flex justify-between items-center">
|
||||
<h2 className="font-semibold text-gray-800">Translation Editor</h2>
|
||||
{selectedCar && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleRegenerate}
|
||||
disabled={regenerating || !selectedCar.dealer_description}
|
||||
className="px-3 py-1.5 bg-purple-100 text-purple-700 rounded hover:bg-purple-200 disabled:opacity-50 text-sm flex items-center gap-1"
|
||||
>
|
||||
{regenerating ? (
|
||||
<div className="w-4 h-4 border-2 border-purple-600 border-t-transparent rounded-full animate-spin"></div>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
)}
|
||||
Regenerate
|
||||
</button>
|
||||
{!editMode ? (
|
||||
<button
|
||||
onClick={() => setEditMode(true)}
|
||||
className="px-3 py-1.5 bg-blue-100 text-blue-700 rounded hover:bg-blue-200 text-sm"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-3 py-1.5 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 text-sm"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditMode(false);
|
||||
if (selectedCar) {
|
||||
setEditData({
|
||||
dealer_description: selectedCar.dealer_description || '',
|
||||
dealer_description_en: selectedCar.translations.en || '',
|
||||
dealer_description_mn: selectedCar.translations.mn || '',
|
||||
dealer_description_ru: selectedCar.translations.ru || '',
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="px-3 py-1.5 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedCar ? (
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="text-sm text-gray-500 mb-2">
|
||||
Car: <span className="font-medium text-gray-800">{selectedCar.car_name}</span>
|
||||
{!selectedCar.papago_configured && (
|
||||
<span className="ml-2 px-2 py-0.5 bg-yellow-100 text-yellow-700 rounded text-xs">
|
||||
Papago API not configured
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Korean Original */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Korean Original (한국어 원문)
|
||||
</label>
|
||||
{editMode ? (
|
||||
<textarea
|
||||
value={editData.dealer_description}
|
||||
onChange={(e) => setEditData({ ...editData, dealer_description: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg p-3 text-sm min-h-[100px]"
|
||||
placeholder="Enter Korean description..."
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-gray-50 rounded-lg p-3 text-sm text-gray-700 whitespace-pre-wrap max-h-40 overflow-y-auto">
|
||||
{selectedCar.dealer_description || <span className="text-gray-400 italic">No description</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* English */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
English Translation
|
||||
</label>
|
||||
{editMode ? (
|
||||
<textarea
|
||||
value={editData.dealer_description_en}
|
||||
onChange={(e) => setEditData({ ...editData, dealer_description_en: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg p-3 text-sm min-h-[100px]"
|
||||
placeholder="Enter English translation..."
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-blue-50 rounded-lg p-3 text-sm text-gray-700 whitespace-pre-wrap min-h-[60px]">
|
||||
{selectedCar.translations.en || <span className="text-gray-400 italic">Not translated</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mongolian */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Mongolian Translation (Монгол)
|
||||
</label>
|
||||
{editMode ? (
|
||||
<textarea
|
||||
value={editData.dealer_description_mn}
|
||||
onChange={(e) => setEditData({ ...editData, dealer_description_mn: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg p-3 text-sm min-h-[100px]"
|
||||
placeholder="Enter Mongolian translation..."
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-green-50 rounded-lg p-3 text-sm text-gray-700 whitespace-pre-wrap min-h-[60px]">
|
||||
{selectedCar.translations.mn || <span className="text-gray-400 italic">Not translated (Using English)</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Russian */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Russian Translation (Русский)
|
||||
</label>
|
||||
{editMode ? (
|
||||
<textarea
|
||||
value={editData.dealer_description_ru}
|
||||
onChange={(e) => setEditData({ ...editData, dealer_description_ru: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg p-3 text-sm min-h-[100px]"
|
||||
placeholder="Enter Russian translation..."
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-red-50 rounded-lg p-3 text-sm text-gray-700 whitespace-pre-wrap min-h-[60px]">
|
||||
{selectedCar.translations.ru || <span className="text-gray-400 italic">Not translated</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
Select a car from the list to view and edit translations
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
405
frontend/src/app/admin/dealers/page.tsx
Normal file
405
frontend/src/app/admin/dealers/page.tsx
Normal file
@@ -0,0 +1,405 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
||||
|
||||
interface DealerApplication {
|
||||
id: number;
|
||||
user_id: number;
|
||||
business_name: string;
|
||||
business_number: string | null;
|
||||
real_name: string;
|
||||
phone: string;
|
||||
bank_name: string;
|
||||
bank_account: string;
|
||||
account_holder: string;
|
||||
photo_url: string | null;
|
||||
status: string;
|
||||
rejected_reason: string | null;
|
||||
applied_at: string;
|
||||
approved_at: string | null;
|
||||
}
|
||||
|
||||
interface DealerInfo {
|
||||
id: number;
|
||||
user_id: number;
|
||||
dealer_code: string;
|
||||
business_name: string;
|
||||
real_name: string;
|
||||
phone: string;
|
||||
total_commission_earned: number;
|
||||
total_withdrawn: number;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export default function AdminDealersPage() {
|
||||
const { t, language } = useTranslation();
|
||||
const { user, token } = useAuthStore();
|
||||
const router = useRouter();
|
||||
|
||||
const [tab, setTab] = useState<'applications' | 'dealers'>('applications');
|
||||
const [applications, setApplications] = useState<DealerApplication[]>([]);
|
||||
const [dealers, setDealers] = useState<DealerInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState<number | null>(null);
|
||||
const [rejectModal, setRejectModal] = useState<{ id: number; reason: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.is_admin) {
|
||||
router.push('/');
|
||||
return;
|
||||
}
|
||||
|
||||
fetchData();
|
||||
}, [user, router, tab]);
|
||||
|
||||
const fetchData = async () => {
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
if (tab === 'applications') {
|
||||
const response = await fetch(`${API_BASE_URL}/api/dealer/admin/applications`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
if (response.ok) {
|
||||
setApplications(await response.json());
|
||||
}
|
||||
} else {
|
||||
const response = await fetch(`${API_BASE_URL}/api/dealer/admin/dealers`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
if (response.ok) {
|
||||
setDealers(await response.json());
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApprove = async (applicationId: number) => {
|
||||
if (!token) return;
|
||||
setActionLoading(applicationId);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/dealer/admin/applications/${applicationId}/approve`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
fetchData();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.detail || 'Failed to approve');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Approve failed:', error);
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async () => {
|
||||
if (!token || !rejectModal) return;
|
||||
setActionLoading(rejectModal.id);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/dealer/admin/applications/${rejectModal.id}/reject`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ reason: rejectModal.reason }),
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
setRejectModal(null);
|
||||
fetchData();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.detail || 'Failed to reject');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Reject failed:', error);
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleActive = async (dealerId: number) => {
|
||||
if (!token) return;
|
||||
setActionLoading(dealerId);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/dealer/admin/dealers/${dealerId}/toggle-active`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
fetchData();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Toggle failed:', error);
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('ko-KR').format(amount);
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return <span className="px-2 py-1 bg-yellow-100 text-yellow-700 rounded text-sm">대기중</span>;
|
||||
case 'approved':
|
||||
return <span className="px-2 py-1 bg-green-100 text-green-700 rounded text-sm">승인됨</span>;
|
||||
case 'rejected':
|
||||
return <span className="px-2 py-1 bg-red-100 text-red-700 rounded text-sm">거부됨</span>;
|
||||
default:
|
||||
return <span className="px-2 py-1 bg-gray-100 text-gray-700 rounded text-sm">{status}</span>;
|
||||
}
|
||||
};
|
||||
|
||||
if (!user?.is_admin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 py-8">
|
||||
<div className="container mx-auto px-4">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800">
|
||||
{language === 'ko' ? '딜러 관리' : 'Dealer Management'}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 mb-6">
|
||||
<button
|
||||
onClick={() => setTab('applications')}
|
||||
className={`px-4 py-2 rounded-lg transition ${
|
||||
tab === 'applications'
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{language === 'ko' ? '신청 목록' : 'Applications'}
|
||||
{applications.filter(a => a.status === 'pending').length > 0 && (
|
||||
<span className="ml-2 px-2 py-0.5 bg-red-500 text-white text-xs rounded-full">
|
||||
{applications.filter(a => a.status === 'pending').length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab('dealers')}
|
||||
className={`px-4 py-2 rounded-lg transition ${
|
||||
tab === 'dealers'
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{language === 'ko' ? '딜러 목록' : 'Dealers'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
) : tab === 'applications' ? (
|
||||
/* Applications Table */
|
||||
<div className="bg-white rounded-xl shadow-lg overflow-hidden">
|
||||
<table className="min-w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">ID</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">상호명</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">실명</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">연락처</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">은행/계좌</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">신청일</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">상태</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{applications.map((app) => (
|
||||
<tr key={app.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-sm">{app.id}</td>
|
||||
<td className="px-4 py-3 text-sm font-medium">{app.business_name}</td>
|
||||
<td className="px-4 py-3 text-sm">{app.real_name}</td>
|
||||
<td className="px-4 py-3 text-sm">{app.phone}</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<div>{app.bank_name}</div>
|
||||
<div className="text-gray-500 text-xs font-mono">{app.bank_account}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">
|
||||
{new Date(app.applied_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">{getStatusBadge(app.status)}</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{app.status === 'pending' && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleApprove(app.id)}
|
||||
disabled={actionLoading === app.id}
|
||||
className="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === app.id ? '...' : '승인'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRejectModal({ id: app.id, reason: '' })}
|
||||
disabled={actionLoading === app.id}
|
||||
className="px-3 py-1 bg-red-600 text-white text-sm rounded hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
거부
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{app.status === 'rejected' && app.rejected_reason && (
|
||||
<span className="text-red-600 text-xs" title={app.rejected_reason}>
|
||||
사유: {app.rejected_reason.substring(0, 20)}...
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{applications.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-8 text-center text-gray-500">
|
||||
{language === 'ko' ? '신청이 없습니다' : 'No applications'}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
/* Dealers Table */
|
||||
<div className="bg-white rounded-xl shadow-lg overflow-hidden">
|
||||
<table className="min-w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">딜러코드</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">상호명</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">실명</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">연락처</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">총 수수료</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">출금액</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">등록일</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">상태</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{dealers.map((dealer) => (
|
||||
<tr key={dealer.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-sm font-mono font-bold">{dealer.dealer_code}</td>
|
||||
<td className="px-4 py-3 text-sm font-medium">{dealer.business_name}</td>
|
||||
<td className="px-4 py-3 text-sm">{dealer.real_name}</td>
|
||||
<td className="px-4 py-3 text-sm">{dealer.phone}</td>
|
||||
<td className="px-4 py-3 text-sm text-green-600">
|
||||
{formatCurrency(dealer.total_commission_earned)}원
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-blue-600">
|
||||
{formatCurrency(dealer.total_withdrawn)}원
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">
|
||||
{new Date(dealer.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{dealer.is_active ? (
|
||||
<span className="px-2 py-1 bg-green-100 text-green-700 rounded text-sm">활성</span>
|
||||
) : (
|
||||
<span className="px-2 py-1 bg-red-100 text-red-700 rounded text-sm">비활성</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<button
|
||||
onClick={() => handleToggleActive(dealer.id)}
|
||||
disabled={actionLoading === dealer.id}
|
||||
className={`px-3 py-1 text-white text-sm rounded disabled:opacity-50 ${
|
||||
dealer.is_active
|
||||
? 'bg-red-600 hover:bg-red-700'
|
||||
: 'bg-green-600 hover:bg-green-700'
|
||||
}`}
|
||||
>
|
||||
{actionLoading === dealer.id
|
||||
? '...'
|
||||
: dealer.is_active
|
||||
? '비활성화'
|
||||
: '활성화'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{dealers.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={9} className="px-4 py-8 text-center text-gray-500">
|
||||
{language === 'ko' ? '딜러가 없습니다' : 'No dealers'}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reject Modal */}
|
||||
{rejectModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl p-6 max-w-md w-full mx-4">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{language === 'ko' ? '신청 거부' : 'Reject Application'}
|
||||
</h3>
|
||||
<textarea
|
||||
value={rejectModal.reason}
|
||||
onChange={(e) => setRejectModal({ ...rejectModal, reason: e.target.value })}
|
||||
placeholder={language === 'ko' ? '거부 사유를 입력하세요...' : 'Enter rejection reason...'}
|
||||
className="w-full p-3 border border-gray-300 rounded-lg mb-4 h-32 resize-none"
|
||||
/>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
onClick={() => setRejectModal(null)}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
{language === 'ko' ? '취소' : 'Cancel'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReject}
|
||||
disabled={!rejectModal.reason || actionLoading === rejectModal.id}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === rejectModal.id ? '...' : language === 'ko' ? '거부' : 'Reject'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
543
frontend/src/app/admin/hero-banners/page.tsx
Normal file
543
frontend/src/app/admin/hero-banners/page.tsx
Normal file
@@ -0,0 +1,543 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { heroBannersApi, adminPdfApi } from '@/lib/api';
|
||||
import { HeroBannerSettings } from '@/types';
|
||||
|
||||
interface BannerFormData {
|
||||
title_ko: string;
|
||||
title_en: string;
|
||||
title_mn: string;
|
||||
subtitle_ko: string;
|
||||
subtitle_en: string;
|
||||
subtitle_mn: string;
|
||||
image_url: string;
|
||||
link_url: string;
|
||||
is_active: boolean;
|
||||
display_order: number;
|
||||
car_id?: number | null; // 연결된 차량 ID (샘플 차량용)
|
||||
}
|
||||
|
||||
const defaultFormData: BannerFormData = {
|
||||
title_ko: '',
|
||||
title_en: '',
|
||||
title_mn: '',
|
||||
subtitle_ko: '',
|
||||
subtitle_en: '',
|
||||
subtitle_mn: '',
|
||||
image_url: '',
|
||||
link_url: '',
|
||||
is_active: true,
|
||||
display_order: 0,
|
||||
car_id: null,
|
||||
};
|
||||
|
||||
// 이미지 URL 변환 (로컬 경로는 백엔드 URL 추가)
|
||||
const getImageUrl = (url: string | undefined): string => {
|
||||
if (!url) return '';
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url;
|
||||
}
|
||||
return `http://localhost:8000${url}`;
|
||||
};
|
||||
|
||||
export default function HeroBannersPage() {
|
||||
const [banners, setBanners] = useState<any[]>([]);
|
||||
const [settings, setSettings] = useState<HeroBannerSettings | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [formData, setFormData] = useState<BannerFormData>(defaultFormData);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [retryingPdfs, setRetryingPdfs] = useState(false);
|
||||
const [pdfStatus, setPdfStatus] = useState<Record<number, boolean>>({});
|
||||
|
||||
// PDF 재시도 함수
|
||||
const handleRetryFailedPdfs = async () => {
|
||||
if (!confirm('PDF가 없는 모든 차량에 대해 PDF 생성을 재시도합니다. 계속하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRetryingPdfs(true);
|
||||
try {
|
||||
const result = await adminPdfApi.retryAllFailed();
|
||||
if (result.total === 0) {
|
||||
alert('PDF가 없는 차량이 없습니다.');
|
||||
} else {
|
||||
alert(`PDF 재시도 완료!\n\n총: ${result.total}개\n성공: ${result.success}개\n실패: ${result.failed}개`);
|
||||
// Reload data to update PDF status
|
||||
await loadData();
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('PDF retry failed:', error);
|
||||
alert('PDF 재시도 중 오류가 발생했습니다: ' + (error.response?.data?.detail || error.message));
|
||||
} finally {
|
||||
setRetryingPdfs(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [bannersData, settingsData] = await Promise.all([
|
||||
heroBannersApi.adminGetList(),
|
||||
heroBannersApi.getSettings(),
|
||||
]);
|
||||
setBanners(bannersData);
|
||||
setSettings(settingsData);
|
||||
|
||||
// Fetch PDF status for all banner cars
|
||||
const carIds = bannersData
|
||||
.filter((b: any) => b.car_id)
|
||||
.map((b: any) => b.car_id);
|
||||
if (carIds.length > 0) {
|
||||
try {
|
||||
const response = await fetch('http://localhost:8000/api/carmodoo/pdf-status', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(carIds),
|
||||
});
|
||||
if (response.ok) {
|
||||
const status = await response.json();
|
||||
setPdfStatus(status);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch PDF status:', err);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingId(null);
|
||||
setFormData(defaultFormData);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleEdit = async (id: number) => {
|
||||
try {
|
||||
const banner = await heroBannersApi.adminGetById(id);
|
||||
setFormData({
|
||||
title_ko: banner.title_ko || '',
|
||||
title_en: banner.title_en || '',
|
||||
title_mn: banner.title_mn || '',
|
||||
subtitle_ko: banner.subtitle_ko || '',
|
||||
subtitle_en: banner.subtitle_en || '',
|
||||
subtitle_mn: banner.subtitle_mn || '',
|
||||
image_url: banner.image_url || '',
|
||||
link_url: banner.link_url || '',
|
||||
is_active: banner.is_active ?? true,
|
||||
display_order: banner.display_order || 0,
|
||||
});
|
||||
setEditingId(id);
|
||||
setShowModal(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to load banner:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('Are you sure you want to delete this banner?')) return;
|
||||
|
||||
try {
|
||||
await heroBannersApi.adminDelete(id);
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete banner:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
if (editingId) {
|
||||
await heroBannersApi.adminUpdate(editingId, formData);
|
||||
} else {
|
||||
await heroBannersApi.adminCreate(formData);
|
||||
}
|
||||
setShowModal(false);
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to save banner:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const result = await heroBannersApi.adminUploadImage(file);
|
||||
setFormData({ ...formData, image_url: result.image_url });
|
||||
} catch (error) {
|
||||
console.error('Failed to upload image:', error);
|
||||
alert('Failed to upload image');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSettingsUpdate = async (newSettings: Partial<HeroBannerSettings>) => {
|
||||
try {
|
||||
const updated = await heroBannersApi.adminUpdateSettings(newSettings);
|
||||
setSettings(updated);
|
||||
} catch (error) {
|
||||
console.error('Failed to update settings:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800">Hero Banners</h1>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleRetryFailedPdfs}
|
||||
disabled={retryingPdfs}
|
||||
className="bg-orange-500 text-white px-4 py-2 rounded-lg hover:bg-orange-600 transition-colors flex items-center gap-2 disabled:opacity-50"
|
||||
title="PDF가 없는 차량들의 PDF를 재생성합니다"
|
||||
>
|
||||
{retryingPdfs ? (
|
||||
<>
|
||||
<span className="animate-spin">⏳</span>
|
||||
<span>PDF 생성중...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>📄</span>
|
||||
<span>PDF 재시도</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<Link
|
||||
href="/admin/cars"
|
||||
className="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<span>🚗</span>
|
||||
<span>Add from Cars Page</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className="bg-primary-600 text-white px-4 py-2 rounded-lg hover:bg-primary-700 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<span>+</span>
|
||||
<span>Add Banner</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Slider Settings */}
|
||||
{settings && (
|
||||
<div className="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-4">Slider Settings</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Slide Interval (ms)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.slide_interval}
|
||||
onChange={(e) => handleSettingsUpdate({ slide_interval: parseInt(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Image Width (px)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.image_width}
|
||||
onChange={(e) => handleSettingsUpdate({ image_width: parseInt(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Image Height (px)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.image_height}
|
||||
onChange={(e) => handleSettingsUpdate({ image_height: parseInt(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Banners Table */}
|
||||
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Image</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Title</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Order</th>
|
||||
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase">PDF</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{banners.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-12 text-center text-gray-500">
|
||||
No banners yet. Click "Add Banner" or "Add from Car Search" to create one.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
banners.map((banner) => (
|
||||
<tr key={banner.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4">
|
||||
<div className="w-24 h-14 relative rounded overflow-hidden bg-gray-200">
|
||||
{banner.image_url ? (
|
||||
<img
|
||||
src={getImageUrl(banner.image_url)}
|
||||
alt={banner.title_en || 'Banner'}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-400 text-xs">
|
||||
No image
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<p className="font-medium text-gray-800">{banner.title_en || banner.title_ko || 'Untitled'}</p>
|
||||
{banner.subtitle_en && <p className="text-sm text-gray-500">{banner.subtitle_en}</p>}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-gray-600">{banner.display_order}</td>
|
||||
<td className="px-6 py-4 text-center">
|
||||
{banner.car_id ? (
|
||||
pdfStatus[banner.car_id] ? (
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-green-100 text-green-700" title="PDF Available">
|
||||
✓ PDF
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-red-100 text-red-700" title="PDF Not Available">
|
||||
✗ PDF
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span className="text-gray-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${banner.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}`}>
|
||||
{banner.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right space-x-2">
|
||||
<button onClick={() => handleEdit(banner.id)} className="text-primary-600 hover:text-primary-800">Edit</button>
|
||||
<button onClick={() => handleDelete(banner.id)} className="text-red-600 hover:text-red-800">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Banner Edit Modal */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<h2 className="text-xl font-bold text-gray-800">
|
||||
{editingId ? 'Edit Banner' : 'Add New Banner'}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||
{/* Image Upload */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Banner Image (500x300 recommended)
|
||||
</label>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-40 h-24 bg-gray-200 rounded-lg overflow-hidden flex-shrink-0">
|
||||
{formData.image_url ? (
|
||||
<img
|
||||
src={getImageUrl(formData.image_url)}
|
||||
alt="Preview"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-400">
|
||||
No image
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleImageUpload}
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
{uploading ? 'Uploading...' : 'Upload Image'}
|
||||
</button>
|
||||
<p className="text-sm text-gray-500 mt-2">Or enter URL directly:</p>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.image_url}
|
||||
onChange={(e) => setFormData({ ...formData, image_url: e.target.value })}
|
||||
placeholder="https://..."
|
||||
className="mt-1 w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Titles */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Title (Korean)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title_ko}
|
||||
onChange={(e) => setFormData({ ...formData, title_ko: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Title (English)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title_en}
|
||||
onChange={(e) => setFormData({ ...formData, title_en: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Title (Mongolian)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title_mn}
|
||||
onChange={(e) => setFormData({ ...formData, title_mn: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subtitles */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Subtitle (Korean)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.subtitle_ko}
|
||||
onChange={(e) => setFormData({ ...formData, subtitle_ko: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Subtitle (English)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.subtitle_en}
|
||||
onChange={(e) => setFormData({ ...formData, subtitle_en: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Subtitle (Mongolian)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.subtitle_mn}
|
||||
onChange={(e) => setFormData({ ...formData, subtitle_mn: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Link URL */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Link URL (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.link_url}
|
||||
onChange={(e) => setFormData({ ...formData, link_url: e.target.value })}
|
||||
placeholder="/cars or https://..."
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Order & Status */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Display Order</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.display_order}
|
||||
onChange={(e) => setFormData({ ...formData, display_order: parseInt(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
||||
<label className="flex items-center gap-2 mt-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_active}
|
||||
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
|
||||
className="w-5 h-5 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span className="text-gray-700">Active</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModal(false)}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
|
||||
>
|
||||
{editingId ? 'Update' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
432
frontend/src/app/admin/inquiries/page.tsx
Normal file
432
frontend/src/app/admin/inquiries/page.tsx
Normal file
@@ -0,0 +1,432 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { inquiryApi, Inquiry, InquiryWithMessages, InquiryStats } from '@/lib/api';
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
pending: '대기중',
|
||||
in_progress: '처리중',
|
||||
resolved: '해결됨',
|
||||
closed: '종료',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
in_progress: 'bg-blue-100 text-blue-800',
|
||||
resolved: 'bg-green-100 text-green-800',
|
||||
closed: 'bg-gray-100 text-gray-800',
|
||||
};
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
general: '일반',
|
||||
vehicle: '차량',
|
||||
payment: '결제',
|
||||
shipping: '배송',
|
||||
dealer: '딜러',
|
||||
account: '계정',
|
||||
other: '기타',
|
||||
};
|
||||
|
||||
export default function AdminInquiriesPage() {
|
||||
const [inquiries, setInquiries] = useState<Inquiry[]>([]);
|
||||
const [stats, setStats] = useState<InquiryStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('');
|
||||
|
||||
// Modal state
|
||||
const [selectedInquiry, setSelectedInquiry] = useState<InquiryWithMessages | null>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [responseMessage, setResponseMessage] = useState('');
|
||||
const [responseStatus, setResponseStatus] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
useEffect(() => {
|
||||
fetchInquiries();
|
||||
fetchStats();
|
||||
}, [page, statusFilter, categoryFilter]);
|
||||
|
||||
const fetchInquiries = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await inquiryApi.adminGetInquiries(page, pageSize, statusFilter || undefined, categoryFilter || undefined);
|
||||
setInquiries(response.inquiries);
|
||||
setTotal(response.total);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch inquiries:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const response = await inquiryApi.adminGetStats();
|
||||
setStats(response);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch stats:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const openInquiryDetail = async (inquiry: Inquiry) => {
|
||||
try {
|
||||
const detail = await inquiryApi.adminGetInquiryDetail(inquiry.id);
|
||||
setSelectedInquiry(detail);
|
||||
setResponseStatus(detail.inquiry.status);
|
||||
setShowModal(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch inquiry detail:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRespond = async () => {
|
||||
if (!selectedInquiry || !responseMessage.trim()) return;
|
||||
|
||||
setSending(true);
|
||||
try {
|
||||
await inquiryApi.adminRespond(selectedInquiry.inquiry.id, {
|
||||
message: responseMessage.trim(),
|
||||
status: responseStatus || undefined
|
||||
});
|
||||
|
||||
// Refresh
|
||||
const detail = await inquiryApi.adminGetInquiryDetail(selectedInquiry.inquiry.id);
|
||||
setSelectedInquiry(detail);
|
||||
setResponseMessage('');
|
||||
fetchInquiries();
|
||||
fetchStats();
|
||||
} catch (error) {
|
||||
console.error('Failed to respond:', error);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = async (status: string) => {
|
||||
if (!selectedInquiry) return;
|
||||
|
||||
try {
|
||||
await inquiryApi.adminUpdateStatus(selectedInquiry.inquiry.id, status);
|
||||
const detail = await inquiryApi.adminGetInquiryDetail(selectedInquiry.inquiry.id);
|
||||
setSelectedInquiry(detail);
|
||||
setResponseStatus(status);
|
||||
fetchInquiries();
|
||||
fetchStats();
|
||||
} catch (error) {
|
||||
console.error('Failed to update status:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-800">문의 관리</h1>
|
||||
<p className="text-gray-600 mt-1">고객 문의를 관리하고 응답합니다.</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm">
|
||||
<p className="text-gray-500 text-sm">전체</p>
|
||||
<p className="text-2xl font-bold">{stats.total}</p>
|
||||
</div>
|
||||
<div className="bg-yellow-50 p-4 rounded-lg shadow-sm">
|
||||
<p className="text-yellow-600 text-sm">대기중</p>
|
||||
<p className="text-2xl font-bold text-yellow-700">{stats.pending}</p>
|
||||
</div>
|
||||
<div className="bg-blue-50 p-4 rounded-lg shadow-sm">
|
||||
<p className="text-blue-600 text-sm">처리중</p>
|
||||
<p className="text-2xl font-bold text-blue-700">{stats.in_progress}</p>
|
||||
</div>
|
||||
<div className="bg-green-50 p-4 rounded-lg shadow-sm">
|
||||
<p className="text-green-600 text-sm">해결됨</p>
|
||||
<p className="text-2xl font-bold text-green-700">{stats.resolved}</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-4 rounded-lg shadow-sm">
|
||||
<p className="text-gray-600 text-sm">종료</p>
|
||||
<p className="text-2xl font-bold text-gray-700">{stats.closed}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white p-4 rounded-lg shadow-md">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">상태</label>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
|
||||
className="border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="">전체</option>
|
||||
<option value="pending">대기중</option>
|
||||
<option value="in_progress">처리중</option>
|
||||
<option value="resolved">해결됨</option>
|
||||
<option value="closed">종료</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">카테고리</label>
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={(e) => { setCategoryFilter(e.target.value); setPage(1); }}
|
||||
className="border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="">전체</option>
|
||||
<option value="general">일반</option>
|
||||
<option value="vehicle">차량</option>
|
||||
<option value="payment">결제</option>
|
||||
<option value="shipping">배송</option>
|
||||
<option value="dealer">딜러</option>
|
||||
<option value="account">계정</option>
|
||||
<option value="other">기타</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inquiry List */}
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-primary-600 border-t-transparent rounded-full mx-auto"></div>
|
||||
</div>
|
||||
) : inquiries.length === 0 ? (
|
||||
<div className="p-12 text-center text-gray-500">
|
||||
문의가 없습니다.
|
||||
</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">ID</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">카테고리</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">제목</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">상태</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">등록일</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{inquiries.map((inquiry) => (
|
||||
<tr key={inquiry.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-sm text-gray-500">#{inquiry.id}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-sm bg-gray-100 px-2 py-1 rounded">
|
||||
{CATEGORY_LABELS[inquiry.category] || inquiry.category}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-sm font-medium text-gray-900 truncate max-w-xs">
|
||||
{inquiry.subject || '제목 없음'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 truncate max-w-xs">
|
||||
{inquiry.message.substring(0, 50)}...
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${STATUS_COLORS[inquiry.status]}`}>
|
||||
{STATUS_LABELS[inquiry.status] || inquiry.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">
|
||||
{formatDate(inquiry.created_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => openInquiryDetail(inquiry)}
|
||||
className="text-primary-600 hover:text-primary-700 text-sm font-medium"
|
||||
>
|
||||
상세보기
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-4 py-2 bg-white rounded-lg shadow disabled:opacity-50"
|
||||
>
|
||||
이전
|
||||
</button>
|
||||
<span className="px-4 py-2 text-gray-600">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="px-4 py-2 bg-white rounded-lg shadow disabled:opacity-50"
|
||||
>
|
||||
다음
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detail Modal */}
|
||||
{showModal && selectedInquiry && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg max-w-3xl w-full max-h-[90vh] overflow-y-auto">
|
||||
{/* Modal Header */}
|
||||
<div className="p-6 border-b sticky top-0 bg-white">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold">문의 상세 #{selectedInquiry.inquiry.id}</h2>
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
className="p-2 hover:bg-gray-100 rounded-full"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Inquiry Info */}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">카테고리:</span>
|
||||
<span className="ml-2 font-medium">{CATEGORY_LABELS[selectedInquiry.inquiry.category] || selectedInquiry.inquiry.category}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">상태:</span>
|
||||
<span className={`ml-2 px-2 py-0.5 text-xs rounded-full ${STATUS_COLORS[selectedInquiry.inquiry.status]}`}>
|
||||
{STATUS_LABELS[selectedInquiry.inquiry.status] || selectedInquiry.inquiry.status}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">이메일:</span>
|
||||
<span className="ml-2 font-medium">{selectedInquiry.inquiry.contact_email || '-'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">전화번호:</span>
|
||||
<span className="ml-2 font-medium">{selectedInquiry.inquiry.contact_phone || '-'}</span>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span className="text-gray-500">등록일:</span>
|
||||
<span className="ml-2 font-medium">{formatDate(selectedInquiry.inquiry.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Original Message */}
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-700 mb-2">문의 내용</h3>
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<p className="font-medium mb-2">{selectedInquiry.inquiry.subject || '제목 없음'}</p>
|
||||
<p className="text-gray-700 whitespace-pre-wrap">{selectedInquiry.inquiry.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages Thread */}
|
||||
{selectedInquiry.messages.length > 0 && (
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-700 mb-2">대화 내역</h3>
|
||||
<div className="space-y-3">
|
||||
{selectedInquiry.messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`p-3 rounded-lg ${
|
||||
msg.is_admin
|
||||
? 'bg-primary-50 border-l-4 border-primary-500'
|
||||
: 'bg-gray-50 border-l-4 border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className={`text-xs font-medium ${msg.is_admin ? 'text-primary-600' : 'text-gray-600'}`}>
|
||||
{msg.is_admin ? '관리자' : '고객'}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">{formatDate(msg.created_at)}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-700 whitespace-pre-wrap">{msg.message}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Update */}
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-700 mb-2">상태 변경</h3>
|
||||
<div className="flex gap-2">
|
||||
{['pending', 'in_progress', 'resolved', 'closed'].map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => handleStatusChange(status)}
|
||||
className={`px-3 py-1 rounded-lg text-sm transition ${
|
||||
selectedInquiry.inquiry.status === status
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{STATUS_LABELS[status]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Response Form */}
|
||||
{selectedInquiry.inquiry.status !== 'closed' && (
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-700 mb-2">답변 작성</h3>
|
||||
<textarea
|
||||
value={responseMessage}
|
||||
onChange={(e) => setResponseMessage(e.target.value)}
|
||||
rows={4}
|
||||
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="답변 내용을 입력하세요..."
|
||||
/>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<select
|
||||
value={responseStatus}
|
||||
onChange={(e) => setResponseStatus(e.target.value)}
|
||||
className="border border-gray-300 rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">상태 유지</option>
|
||||
<option value="in_progress">처리중으로 변경</option>
|
||||
<option value="resolved">해결됨으로 변경</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={handleRespond}
|
||||
disabled={sending || !responseMessage.trim()}
|
||||
className="bg-primary-600 text-white px-6 py-2 rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{sending ? '전송중...' : '답변 전송'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
195
frontend/src/app/admin/layout.tsx
Normal file
195
frontend/src/app/admin/layout.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
|
||||
const menuItems = [
|
||||
{ href: '/admin', label: 'Dashboard', icon: '📊' },
|
||||
{ href: '/admin/visitor-stats', label: 'Visitor Stats', icon: '👁️' },
|
||||
{ href: '/admin/hero-banners', label: 'Hero Banners', icon: '🖼️' },
|
||||
{ href: '/admin/cars', label: 'Cars', icon: '🚗' },
|
||||
{ href: '/admin/vehicle-requests', label: 'Vehicle Requests', icon: '📋' },
|
||||
{ href: '/admin/purchased', label: 'Purchased Vehicles', icon: '📦' },
|
||||
{ href: '/admin/dealers', label: 'Dealers', icon: '🤝' },
|
||||
{ href: '/admin/payments', label: 'Payments', icon: '💳' },
|
||||
{ href: '/admin/withdrawals', label: 'Withdrawals', icon: '💸' },
|
||||
{ href: '/admin/notifications', label: 'Notifications', icon: '🔔' },
|
||||
{ href: '/admin/translations', label: 'Translations', icon: '🌐' },
|
||||
{ href: '/admin/dealer-translations', label: 'Dealer Descriptions', icon: '📝' },
|
||||
{ href: '/admin/users', label: 'Users', icon: '👥' },
|
||||
{ href: '/admin/inquiries', label: 'Inquiries', icon: '💬' },
|
||||
{ href: '/admin/settings', label: 'Settings', icon: '⚙️' },
|
||||
];
|
||||
|
||||
export default function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { user, token, logout, isLoading: authLoading } = useAuthStore();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
|
||||
// 디버그 로그
|
||||
useEffect(() => {
|
||||
console.log('Admin Layout Debug:', {
|
||||
pathname,
|
||||
token: token ? 'exists' : 'null',
|
||||
user: user ? { id: user.id, email: user.email, is_admin: user.is_admin } : null,
|
||||
authLoading
|
||||
});
|
||||
}, [pathname, token, user, authLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
// 로그인 페이지는 체크 필요 없음
|
||||
if (pathname === '/admin/login') {
|
||||
return;
|
||||
}
|
||||
|
||||
// 아직 로딩 중이면 대기
|
||||
if (authLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 토큰이 없으면 로그인 페이지로
|
||||
if (!token) {
|
||||
router.push('/admin/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// user 정보가 없으면 로그인 페이지로
|
||||
if (!user) {
|
||||
router.push('/admin/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// 관리자가 아니면 홈으로
|
||||
if (!user.is_admin) {
|
||||
console.log('User is not admin, redirecting to home');
|
||||
router.push('/');
|
||||
return;
|
||||
}
|
||||
}, [pathname, router, token, user, authLoading]);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
router.push('/admin/login');
|
||||
};
|
||||
|
||||
// 로그인 페이지는 레이아웃 없이 렌더링
|
||||
if (pathname === '/admin/login') {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// 로딩 중
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 토큰 없음 또는 유저 없음 또는 관리자 아님 -> 빈 화면 (리다이렉트 될 예정)
|
||||
if (!token || !user || !user.is_admin) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 flex">
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`${
|
||||
sidebarOpen ? 'w-64' : 'w-20'
|
||||
} bg-gray-900 text-white transition-all duration-300 flex flex-col`}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="p-4 border-b border-gray-800">
|
||||
<Link href="/admin" className="flex items-center gap-3">
|
||||
<span className="text-2xl">🚗</span>
|
||||
{sidebarOpen && (
|
||||
<span className="font-bold text-lg">AutonetSellCar</span>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 py-4">
|
||||
{menuItems.map((item) => {
|
||||
const isActive = pathname === item.href ||
|
||||
(item.href !== '/admin' && pathname.startsWith(item.href));
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center gap-3 px-4 py-3 transition-colors ${
|
||||
isActive
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'text-gray-300 hover:bg-gray-800 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span className="text-xl">{item.icon}</span>
|
||||
{sidebarOpen && <span>{item.label}</span>}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-gray-800">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-3 text-gray-300 hover:text-white transition-colors w-full"
|
||||
>
|
||||
<span className="text-xl">🚪</span>
|
||||
{sidebarOpen && <span>Logout</span>}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Top Bar */}
|
||||
<header className="bg-white shadow-sm h-16 flex items-center justify-between px-6">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/"
|
||||
target="_blank"
|
||||
className="text-sm text-gray-600 hover:text-primary-600 flex items-center gap-1"
|
||||
>
|
||||
<span>View Site</span>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</Link>
|
||||
<div className="w-8 h-8 rounded-full bg-primary-600 text-white flex items-center justify-center font-bold">
|
||||
{user?.name?.charAt(0).toUpperCase() || user?.email?.charAt(0).toUpperCase() || 'A'}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page Content */}
|
||||
<main className="flex-1 p-6 overflow-auto">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
frontend/src/app/admin/login/page.tsx
Normal file
139
frontend/src/app/admin/login/page.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { authApi } from '@/lib/api';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
const router = useRouter();
|
||||
const { setToken, setUser } = useAuthStore();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// 1. 로그인 및 토큰 저장
|
||||
const { access_token } = await authApi.login(email, password);
|
||||
setToken(access_token); // store와 localStorage 모두 업데이트
|
||||
|
||||
// 2. 사용자 정보 로드
|
||||
const user = await authApi.getMe();
|
||||
setUser(user);
|
||||
|
||||
// 3. 관리자 권한 확인
|
||||
if (!user.is_admin) {
|
||||
setError('Admin access required. You are not an administrator.');
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push('/admin');
|
||||
} catch (err: any) {
|
||||
console.error('Login failed:', err);
|
||||
setError(err.response?.data?.detail || 'Invalid email or password');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-700 to-primary-900 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md p-8">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-primary-100 rounded-full mb-4">
|
||||
<span className="text-3xl">🚗</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-800">AutonetSellCar</h1>
|
||||
<p className="text-gray-500 mt-1">Admin Panel</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-colors"
|
||||
placeholder="admin@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-4 py-3 pr-12 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-colors"
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
{showPassword ? (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-primary-600 text-white font-semibold py-3 rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
<span>Signing in...</span>
|
||||
</>
|
||||
) : (
|
||||
'Sign In'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-8 text-center text-sm text-gray-500">
|
||||
<a href="/" className="text-primary-600 hover:text-primary-700">
|
||||
← Back to Website
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
199
frontend/src/app/admin/notifications/page.tsx
Normal file
199
frontend/src/app/admin/notifications/page.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
import { notificationApi } from '@/lib/api';
|
||||
|
||||
export default function AdminNotificationsPage() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Form state
|
||||
const [title, setTitle] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [link, setLink] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [result, setResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
|
||||
const handleSendToAll = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!title.trim() || !message.trim()) {
|
||||
setResult({ success: false, message: '제목과 내용을 입력해주세요.' });
|
||||
return;
|
||||
}
|
||||
|
||||
setSending(true);
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
const response = await notificationApi.adminSendToAll(
|
||||
title.trim(),
|
||||
message.trim(),
|
||||
link.trim() || undefined
|
||||
);
|
||||
setResult({ success: true, message: response.message });
|
||||
setTitle('');
|
||||
setMessage('');
|
||||
setLink('');
|
||||
} catch (error) {
|
||||
console.error('Failed to send notification:', error);
|
||||
setResult({ success: false, message: '알림 발송에 실패했습니다.' });
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-800">알림 관리</h1>
|
||||
<p className="text-gray-600 mt-1">전체 사용자에게 알림을 보낼 수 있습니다.</p>
|
||||
</div>
|
||||
|
||||
{/* Send Notification Form */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-4">전체 알림 발송</h2>
|
||||
|
||||
<form onSubmit={handleSendToAll} className="space-y-4">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
제목 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="알림 제목"
|
||||
maxLength={200}
|
||||
disabled={sending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
내용 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="알림 내용"
|
||||
rows={4}
|
||||
disabled={sending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Link (optional) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
링크 (선택사항)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={link}
|
||||
onChange={(e) => setLink(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="/page-path (클릭 시 이동할 페이지)"
|
||||
disabled={sending}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
예: /my-request, /charge, /cost
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Result Message */}
|
||||
{result && (
|
||||
<div className={`p-4 rounded-lg ${result.success ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'}`}>
|
||||
{result.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={sending}
|
||||
className="w-full bg-primary-600 text-white py-3 rounded-lg hover:bg-primary-700 transition disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{sending ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
발송 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
||||
</svg>
|
||||
전체 사용자에게 발송
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Notification Types Info */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-4">알림 유형 안내</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
|
||||
<span className="text-2xl">🚗</span>
|
||||
<div>
|
||||
<p className="font-medium">차량 추천</p>
|
||||
<p className="text-xs text-gray-500">사용자 요청에 차량 추천 시</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
|
||||
<span className="text-2xl">🚚</span>
|
||||
<div>
|
||||
<p className="font-medium">배송 업데이트</p>
|
||||
<p className="text-xs text-gray-500">배송 상태 변경 시</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
|
||||
<span className="text-2xl">💰</span>
|
||||
<div>
|
||||
<p className="font-medium">출금 처리</p>
|
||||
<p className="text-xs text-gray-500">출금 신청 처리 시</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
|
||||
<span className="text-2xl">🎁</span>
|
||||
<div>
|
||||
<p className="font-medium">레퍼럴 보상</p>
|
||||
<p className="text-xs text-gray-500">추천인 보상 적립 시</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
|
||||
<span className="text-2xl">✅</span>
|
||||
<div>
|
||||
<p className="font-medium">딜러 승인</p>
|
||||
<p className="text-xs text-gray-500">딜러 신청 승인/거부 시</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
|
||||
<span className="text-2xl">🎉</span>
|
||||
<div>
|
||||
<p className="font-medium">공유 판매</p>
|
||||
<p className="text-xs text-gray-500">공유 차량 판매 시</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-sm text-gray-600">
|
||||
위 알림은 관련 이벤트 발생 시 자동으로 발송됩니다.
|
||||
이 페이지에서는 시스템 공지사항을 전체 사용자에게 수동으로 보낼 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
408
frontend/src/app/admin/page.tsx
Normal file
408
frontend/src/app/admin/page.tsx
Normal file
@@ -0,0 +1,408 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
dashboardApi,
|
||||
DashboardStats,
|
||||
RevenueStats,
|
||||
ChartData,
|
||||
RecentActivity,
|
||||
TopDealer,
|
||||
PendingActions,
|
||||
} from '@/lib/api';
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [revenue, setRevenue] = useState<RevenueStats | null>(null);
|
||||
const [userChart, setUserChart] = useState<ChartData | null>(null);
|
||||
const [requestChart, setRequestChart] = useState<ChartData | null>(null);
|
||||
const [recentActivities, setRecentActivities] = useState<RecentActivity[]>([]);
|
||||
const [topDealers, setTopDealers] = useState<TopDealer[]>([]);
|
||||
const [pendingActions, setPendingActions] = useState<PendingActions | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [chartDays, setChartDays] = useState(14);
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboardData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadChartData();
|
||||
}, [chartDays]);
|
||||
|
||||
const loadDashboardData = async () => {
|
||||
try {
|
||||
const [statsData, revenueData, activitiesData, dealersData, pendingData] = await Promise.all([
|
||||
dashboardApi.getStats(),
|
||||
dashboardApi.getRevenue(),
|
||||
dashboardApi.getRecentActivities(10),
|
||||
dashboardApi.getTopDealers(5),
|
||||
dashboardApi.getPendingActions(),
|
||||
]);
|
||||
setStats(statsData);
|
||||
setRevenue(revenueData);
|
||||
setRecentActivities(activitiesData);
|
||||
setTopDealers(dealersData);
|
||||
setPendingActions(pendingData);
|
||||
} catch (error) {
|
||||
console.error('Failed to load dashboard data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadChartData = async () => {
|
||||
try {
|
||||
const [userData, requestData] = await Promise.all([
|
||||
dashboardApi.getUserChart(chartDays),
|
||||
dashboardApi.getRequestChart(chartDays),
|
||||
]);
|
||||
setUserChart(userData);
|
||||
setRequestChart(requestData);
|
||||
} catch (error) {
|
||||
console.error('Failed to load chart data:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const formatNumber = (num: number) => {
|
||||
return new Intl.NumberFormat('ko-KR').format(num);
|
||||
};
|
||||
|
||||
const formatCurrency = (num: number) => {
|
||||
return new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW' }).format(num);
|
||||
};
|
||||
|
||||
const getActivityIcon = (icon: string) => {
|
||||
switch (icon) {
|
||||
case 'user': return '👤';
|
||||
case 'car': return '🚗';
|
||||
case 'message': return '💬';
|
||||
case 'badge': return '🎫';
|
||||
case 'wallet': return '💰';
|
||||
default: return '📌';
|
||||
}
|
||||
};
|
||||
|
||||
const getTimeAgo = (time: string) => {
|
||||
if (!time) return '';
|
||||
const now = new Date();
|
||||
const then = new Date(time);
|
||||
const diff = Math.floor((now.getTime() - then.getTime()) / 1000);
|
||||
|
||||
if (diff < 60) return `${diff}초 전`;
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}분 전`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}시간 전`;
|
||||
return `${Math.floor(diff / 86400)}일 전`;
|
||||
};
|
||||
|
||||
const SimpleBarChart = ({ data, color = 'bg-primary-500', height = 100 }: { data: ChartData | null; color?: string; height?: number }) => {
|
||||
if (!data || data.values.length === 0) return <div className="text-gray-400 text-center py-4">No data</div>;
|
||||
|
||||
const maxValue = Math.max(...data.values, 1);
|
||||
|
||||
return (
|
||||
<div className="flex items-end gap-1" style={{ height }}>
|
||||
{data.values.map((value, index) => (
|
||||
<div key={index} className="flex-1 flex flex-col items-center group">
|
||||
<div className="hidden group-hover:block absolute -mt-6 bg-gray-800 text-white text-xs px-2 py-1 rounded">
|
||||
{value}
|
||||
</div>
|
||||
<div
|
||||
className={`w-full ${color} rounded-t transition-all hover:opacity-80`}
|
||||
style={{ height: `${(value / maxValue) * 100}%`, minHeight: value > 0 ? '4px' : '0' }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-800">Dashboard</h1>
|
||||
<button
|
||||
onClick={loadDashboardData}
|
||||
className="px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm flex items-center gap-2"
|
||||
>
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Pending Actions Alert */}
|
||||
{pendingActions && pendingActions.total_pending > 0 && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 text-amber-700 font-medium mb-3">
|
||||
<span className="text-xl">⚠️</span>
|
||||
<span>Pending Actions Required ({pendingActions.total_pending})</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{pendingActions.pending_requests > 0 && (
|
||||
<Link href="/admin/vehicle-requests" className="flex items-center gap-2 p-2 bg-white rounded-lg hover:shadow">
|
||||
<span className="text-lg">🚗</span>
|
||||
<span className="text-sm">Vehicle Requests: {pendingActions.pending_requests}</span>
|
||||
</Link>
|
||||
)}
|
||||
{pendingActions.pending_inquiries > 0 && (
|
||||
<Link href="/admin/inquiries" className="flex items-center gap-2 p-2 bg-white rounded-lg hover:shadow">
|
||||
<span className="text-lg">💬</span>
|
||||
<span className="text-sm">Inquiries: {pendingActions.pending_inquiries}</span>
|
||||
</Link>
|
||||
)}
|
||||
{pendingActions.pending_dealer_applications > 0 && (
|
||||
<Link href="/admin/dealers" className="flex items-center gap-2 p-2 bg-white rounded-lg hover:shadow">
|
||||
<span className="text-lg">🎫</span>
|
||||
<span className="text-sm">Dealer Apps: {pendingActions.pending_dealer_applications}</span>
|
||||
</Link>
|
||||
)}
|
||||
{pendingActions.pending_withdrawals > 0 && (
|
||||
<Link href="/admin/withdrawals" className="flex items-center gap-2 p-2 bg-white rounded-lg hover:shadow">
|
||||
<span className="text-lg">💰</span>
|
||||
<span className="text-sm">Withdrawals: {pendingActions.pending_withdrawals}</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Link href="/admin/users" className="bg-white rounded-xl shadow-sm p-5 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-500 text-sm">Total Users</p>
|
||||
<p className="text-2xl font-bold text-gray-800 mt-1">{formatNumber(stats?.total_users || 0)}</p>
|
||||
<p className="text-xs text-green-600 mt-1">+{stats?.new_users_this_week || 0} this week</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center text-2xl">
|
||||
👥
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link href="/admin/dealers" className="bg-white rounded-xl shadow-sm p-5 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-500 text-sm">Active Dealers</p>
|
||||
<p className="text-2xl font-bold text-gray-800 mt-1">{formatNumber(stats?.total_dealers || 0)}</p>
|
||||
{(stats?.pending_dealer_applications || 0) > 0 && (
|
||||
<p className="text-xs text-amber-600 mt-1">{stats?.pending_dealer_applications} pending</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center text-2xl">
|
||||
🎫
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link href="/admin/vehicle-requests" className="bg-white rounded-xl shadow-sm p-5 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-500 text-sm">Vehicle Requests</p>
|
||||
<p className="text-2xl font-bold text-gray-800 mt-1">{formatNumber(stats?.total_vehicle_requests || 0)}</p>
|
||||
{(stats?.pending_requests || 0) > 0 && (
|
||||
<p className="text-xs text-amber-600 mt-1">{stats?.pending_requests} pending</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center text-2xl">
|
||||
🚗
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link href="/admin/purchased" className="bg-white rounded-xl shadow-sm p-5 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-500 text-sm">Purchased Vehicles</p>
|
||||
<p className="text-2xl font-bold text-gray-800 mt-1">{formatNumber(stats?.total_purchased_vehicles || 0)}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-amber-100 rounded-lg flex items-center justify-center text-2xl">
|
||||
📦
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Revenue & CC Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-gradient-to-r from-blue-500 to-blue-600 rounded-xl p-5 text-white">
|
||||
<p className="text-blue-100 text-sm">Total CC Charged</p>
|
||||
<p className="text-3xl font-bold mt-1">{formatNumber(stats?.total_cc_charged || 0)} CC</p>
|
||||
<div className="mt-2 flex items-center gap-2 text-sm text-blue-100">
|
||||
<span>Revenue this month:</span>
|
||||
<span className="font-semibold text-white">{formatCurrency(revenue?.revenue_this_month || 0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-r from-green-500 to-green-600 rounded-xl p-5 text-white">
|
||||
<p className="text-green-100 text-sm">Share Rewards</p>
|
||||
<p className="text-3xl font-bold mt-1">{formatNumber(stats?.total_shares || 0)}</p>
|
||||
<div className="mt-2 flex items-center gap-2 text-sm text-green-100">
|
||||
<span>Purchased:</span>
|
||||
<span className="font-semibold text-white">{formatNumber(stats?.purchased_shares || 0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link href="/admin/withdrawals" className="bg-gradient-to-r from-purple-500 to-purple-600 rounded-xl p-5 text-white hover:opacity-90">
|
||||
<p className="text-purple-100 text-sm">Total Withdrawals</p>
|
||||
<p className="text-3xl font-bold mt-1">{formatCurrency(stats?.total_withdrawal_amount || 0)}</p>
|
||||
{(stats?.pending_withdrawals || 0) > 0 && (
|
||||
<div className="mt-2 flex items-center gap-2 text-sm">
|
||||
<span className="bg-white/20 px-2 py-0.5 rounded">{stats?.pending_withdrawals} pending</span>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-white rounded-xl shadow-sm p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-gray-800">User Registrations</h3>
|
||||
<select
|
||||
value={chartDays}
|
||||
onChange={(e) => setChartDays(Number(e.target.value))}
|
||||
className="text-sm border rounded px-2 py-1"
|
||||
>
|
||||
<option value={7}>7 days</option>
|
||||
<option value={14}>14 days</option>
|
||||
<option value={30}>30 days</option>
|
||||
</select>
|
||||
</div>
|
||||
<SimpleBarChart data={userChart} color="bg-blue-500" height={120} />
|
||||
{userChart && (
|
||||
<div className="flex justify-between mt-2 text-xs text-gray-400 overflow-hidden">
|
||||
<span>{userChart.labels[0]}</span>
|
||||
<span>{userChart.labels[userChart.labels.length - 1]}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-gray-800">Vehicle Requests</h3>
|
||||
</div>
|
||||
<SimpleBarChart data={requestChart} color="bg-purple-500" height={120} />
|
||||
{requestChart && (
|
||||
<div className="flex justify-between mt-2 text-xs text-gray-400 overflow-hidden">
|
||||
<span>{requestChart.labels[0]}</span>
|
||||
<span>{requestChart.labels[requestChart.labels.length - 1]}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Two Column Layout */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Recent Activity */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-5">
|
||||
<h3 className="font-semibold text-gray-800 mb-4">Recent Activity</h3>
|
||||
<div className="space-y-3">
|
||||
{recentActivities.length === 0 ? (
|
||||
<p className="text-gray-400 text-center py-4">No recent activity</p>
|
||||
) : (
|
||||
recentActivities.map((activity, index) => (
|
||||
<div key={index} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
|
||||
<span className="text-xl">{getActivityIcon(activity.icon)}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-800 truncate">{activity.title}</p>
|
||||
<p className="text-xs text-gray-500 truncate">{activity.description}</p>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 whitespace-nowrap">{getTimeAgo(activity.time)}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Dealers */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-gray-800">Top Dealers</h3>
|
||||
<Link href="/admin/dealers" className="text-sm text-primary-600 hover:underline">View all</Link>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{topDealers.length === 0 ? (
|
||||
<p className="text-gray-400 text-center py-4">No dealers yet</p>
|
||||
) : (
|
||||
topDealers.map((dealer, index) => (
|
||||
<div key={dealer.id} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
|
||||
<div className="w-8 h-8 bg-gradient-to-r from-amber-400 to-amber-500 rounded-full flex items-center justify-center text-white font-bold text-sm">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-800">{dealer.name}</p>
|
||||
<p className="text-xs text-gray-500">{dealer.dealer_code}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-semibold text-gray-800">{dealer.total_sales} sales</p>
|
||||
<p className="text-xs text-green-600">{formatCurrency(dealer.total_commission)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-5">
|
||||
<h3 className="font-semibold text-gray-800 mb-4">Quick Actions</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Link
|
||||
href="/admin/hero-banners"
|
||||
className="flex items-center gap-3 p-4 border border-gray-200 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-colors"
|
||||
>
|
||||
<span className="text-2xl">🖼️</span>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800">Hero Banners</p>
|
||||
<p className="text-xs text-gray-500">Manage slider</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/admin/cars"
|
||||
className="flex items-center gap-3 p-4 border border-gray-200 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-colors"
|
||||
>
|
||||
<span className="text-2xl">🚙</span>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800">Car Listings</p>
|
||||
<p className="text-xs text-gray-500">{formatNumber(stats?.total_cars || 0)} cars</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/admin/settings"
|
||||
className="flex items-center gap-3 p-4 border border-gray-200 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-colors"
|
||||
>
|
||||
<span className="text-2xl">⚙️</span>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800">Settings</p>
|
||||
<p className="text-xs text-gray-500">System config</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/admin/notifications"
|
||||
className="flex items-center gap-3 p-4 border border-gray-200 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-colors"
|
||||
>
|
||||
<span className="text-2xl">🔔</span>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800">Notifications</p>
|
||||
<p className="text-xs text-gray-500">Send alerts</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
403
frontend/src/app/admin/payments/page.tsx
Normal file
403
frontend/src/app/admin/payments/page.tsx
Normal file
@@ -0,0 +1,403 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ccApi, AdminPayment } from '@/lib/api';
|
||||
|
||||
export default function AdminPaymentsPage() {
|
||||
const [payments, setPayments] = useState<AdminPayment[]>([]);
|
||||
const [pendingPayments, setPendingPayments] = useState<AdminPayment[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(20);
|
||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedPayment, setSelectedPayment] = useState<AdminPayment | null>(null);
|
||||
const [adminNote, setAdminNote] = useState('');
|
||||
const [processing, setProcessing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [page, statusFilter]);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [pendingData, allData] = await Promise.all([
|
||||
ccApi.adminGetPendingPayments(),
|
||||
ccApi.adminGetAllPayments({ status: statusFilter || undefined, page, page_size: pageSize }),
|
||||
]);
|
||||
setPendingPayments(pendingData);
|
||||
setPayments(allData.payments);
|
||||
setTotal(allData.total);
|
||||
} catch (error) {
|
||||
console.error('Failed to load payments:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerify = async (approved: boolean) => {
|
||||
if (!selectedPayment) return;
|
||||
|
||||
setProcessing(true);
|
||||
try {
|
||||
await ccApi.adminVerifyPayment(selectedPayment.id, approved, adminNote);
|
||||
setSelectedPayment(null);
|
||||
setAdminNote('');
|
||||
loadData();
|
||||
} catch (error: any) {
|
||||
alert(error.response?.data?.detail || 'Failed to process payment');
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <span className="px-2 py-1 bg-green-100 text-green-700 rounded-full text-xs">Completed</span>;
|
||||
case 'pending':
|
||||
return <span className="px-2 py-1 bg-yellow-100 text-yellow-700 rounded-full text-xs">Pending</span>;
|
||||
case 'rejected':
|
||||
return <span className="px-2 py-1 bg-red-100 text-red-700 rounded-full text-xs">Rejected</span>;
|
||||
default:
|
||||
return <span className="px-2 py-1 bg-gray-100 text-gray-700 rounded-full text-xs">{status}</span>;
|
||||
}
|
||||
};
|
||||
|
||||
const getMethodBadge = (method: string) => {
|
||||
switch (method) {
|
||||
case 'usdc':
|
||||
return <span className="px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs">USDC</span>;
|
||||
case 'bank_transfer':
|
||||
return <span className="px-2 py-1 bg-green-100 text-green-700 rounded text-xs">Bank</span>;
|
||||
case 'card':
|
||||
return <span className="px-2 py-1 bg-purple-100 text-purple-700 rounded text-xs">Card</span>;
|
||||
default:
|
||||
return <span className="px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs">{method}</span>;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr?: string) => {
|
||||
if (!dateStr) return '-';
|
||||
return new Date(dateStr).toLocaleString('ko-KR');
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-800">Payment Management</h1>
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Pending Payments Alert */}
|
||||
{pendingPayments.length > 0 && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
|
||||
<h3 className="font-semibold text-amber-800 mb-3">
|
||||
Pending Payments ({pendingPayments.length})
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{pendingPayments.slice(0, 5).map((payment) => (
|
||||
<div
|
||||
key={payment.id}
|
||||
className="flex items-center justify-between p-3 bg-white rounded-lg cursor-pointer hover:shadow"
|
||||
onClick={() => setSelectedPayment(payment)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{getMethodBadge(payment.payment_method)}
|
||||
<div>
|
||||
<p className="font-medium">{payment.user_email}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{payment.amount} {payment.currency} = {payment.cc_amount} CC
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-gray-500">{formatDate(payment.created_at)}</p>
|
||||
<button className="text-primary-600 text-sm hover:underline">Review</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-4">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => {
|
||||
setStatusFilter(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
</select>
|
||||
<span className="text-gray-500 self-center">Total: {total} payments</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payments Table */}
|
||||
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-500"></div>
|
||||
</div>
|
||||
) : payments.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">No payments found</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">ID</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">User</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Amount</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">CC</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Method</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">TX Hash</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Date</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{payments.map((payment) => (
|
||||
<tr key={payment.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-sm text-gray-800">{payment.id}</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<div>{payment.user_email}</div>
|
||||
<div className="text-xs text-gray-500">{payment.user_name || '-'}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm font-medium">
|
||||
{payment.amount} {payment.currency}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-blue-600 font-semibold">
|
||||
+{payment.cc_amount} CC
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">{getMethodBadge(payment.payment_method)}</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{payment.transaction_id ? (
|
||||
<code className="text-xs bg-gray-100 px-2 py-1 rounded truncate max-w-[100px] block">
|
||||
{payment.transaction_id.slice(0, 10)}...
|
||||
</code>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">{getStatusBadge(payment.status)}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">{formatDate(payment.created_at)}</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{payment.status === 'pending' ? (
|
||||
<button
|
||||
onClick={() => setSelectedPayment(payment)}
|
||||
className="px-3 py-1 bg-primary-500 text-white rounded hover:bg-primary-600 text-xs"
|
||||
>
|
||||
Review
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setSelectedPayment(payment)}
|
||||
className="px-3 py-1 border rounded hover:bg-gray-50 text-xs"
|
||||
>
|
||||
View
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t">
|
||||
<div className="text-sm text-gray-500">
|
||||
Page {page} of {totalPages}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-3 py-1 border rounded hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="px-3 py-1 border rounded hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Payment Detail Modal */}
|
||||
{selectedPayment && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl p-6 w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">Payment Details</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedPayment(null);
|
||||
setAdminNote('');
|
||||
}}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Payment ID</p>
|
||||
<p className="font-medium">{selectedPayment.id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Status</p>
|
||||
<p>{getStatusBadge(selectedPayment.status)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">User</p>
|
||||
<p className="font-medium">{selectedPayment.user_email}</p>
|
||||
<p className="text-sm text-gray-500">{selectedPayment.user_name || 'No name'}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Amount</p>
|
||||
<p className="font-medium text-lg">
|
||||
{selectedPayment.amount} {selectedPayment.currency}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">CC to Credit</p>
|
||||
<p className="font-medium text-lg text-blue-600">+{selectedPayment.cc_amount} CC</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Payment Method</p>
|
||||
<p>{getMethodBadge(selectedPayment.payment_method)}</p>
|
||||
</div>
|
||||
|
||||
{selectedPayment.transaction_id && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Transaction Hash</p>
|
||||
<code className="text-xs bg-gray-100 px-2 py-1 rounded block break-all">
|
||||
{selectedPayment.transaction_id}
|
||||
</code>
|
||||
{selectedPayment.payment_method === 'usdc' && (
|
||||
<a
|
||||
href={`https://polygonscan.com/tx/${selectedPayment.transaction_id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary-600 hover:underline mt-1 inline-block"
|
||||
>
|
||||
View on PolygonScan
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedPayment.wallet_address && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">User Wallet (for refund)</p>
|
||||
<code className="text-xs bg-gray-100 px-2 py-1 rounded block break-all">
|
||||
{selectedPayment.wallet_address}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Submitted At</p>
|
||||
<p>{formatDate(selectedPayment.created_at)}</p>
|
||||
</div>
|
||||
|
||||
{selectedPayment.verified_at && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Verified At</p>
|
||||
<p>{formatDate(selectedPayment.verified_at)}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedPayment.admin_note && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Admin Note</p>
|
||||
<p className="bg-gray-50 p-2 rounded">{selectedPayment.admin_note}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedPayment.status === 'pending' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Admin Note (optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={adminNote}
|
||||
onChange={(e) => setAdminNote(e.target.value)}
|
||||
placeholder="Add a note..."
|
||||
className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 resize-none"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => handleVerify(false)}
|
||||
disabled={processing}
|
||||
className="flex-1 px-4 py-2 border border-red-500 text-red-600 rounded-lg hover:bg-red-50 disabled:opacity-50"
|
||||
>
|
||||
{processing ? 'Processing...' : 'Reject'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleVerify(true)}
|
||||
disabled={processing}
|
||||
className="flex-1 px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 disabled:opacity-50"
|
||||
>
|
||||
{processing ? 'Processing...' : 'Approve'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedPayment.status !== 'pending' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedPayment(null);
|
||||
setAdminNote('');
|
||||
}}
|
||||
className="w-full px-4 py-2 border rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
521
frontend/src/app/admin/purchased/page.tsx
Normal file
521
frontend/src/app/admin/purchased/page.tsx
Normal file
@@ -0,0 +1,521 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { vehicleRequestsApi, PurchasedVehicle } from '@/lib/api';
|
||||
import { useExchangeRateStore } from '@/lib/exchangeRateStore';
|
||||
|
||||
const SHIPPING_STEPS = [
|
||||
{ step: 1, label: 'Purchased', labelKo: '구매 완료' },
|
||||
{ step: 2, label: 'Incheon Port', labelKo: '인천항' },
|
||||
{ step: 3, label: 'Tianjin Port', labelKo: '텐진항 (중국)' },
|
||||
{ step: 4, label: 'Zamyn-Uud', labelKo: '자먼우드 (몽골)' },
|
||||
{ step: 5, label: 'Ulaanbaatar', labelKo: '울란바토르' },
|
||||
{ step: 6, label: 'Customs', labelKo: '통관' },
|
||||
{ step: 7, label: 'Delivered', labelKo: '배송 완료' },
|
||||
];
|
||||
|
||||
export default function AdminPurchasedPage() {
|
||||
const [vehicles, setVehicles] = useState<PurchasedVehicle[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [selectedVehicle, setSelectedVehicle] = useState<PurchasedVehicle | null>(null);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
|
||||
// Edit form state
|
||||
const [editStatus, setEditStatus] = useState<number>(1);
|
||||
const [editLocation, setEditLocation] = useState<string>('');
|
||||
const [editArrival, setEditArrival] = useState<string>('');
|
||||
|
||||
// Create form state
|
||||
const [createUserId, setCreateUserId] = useState<string>('');
|
||||
const [createCarName, setCreateCarName] = useState<string>('');
|
||||
const [createCarImage, setCreateCarImage] = useState<string>('');
|
||||
const [createVehiclePrice, setCreateVehiclePrice] = useState<string>('');
|
||||
const [createDomesticCost, setCreateDomesticCost] = useState<string>('1207500'); // 1,150,000 + 5%
|
||||
const [createShippingCost, setCreateShippingCost] = useState<string>('1000');
|
||||
const [createCarType, setCreateCarType] = useState<string>('small');
|
||||
|
||||
// Load vehicles
|
||||
useEffect(() => {
|
||||
loadVehicles();
|
||||
}, []);
|
||||
|
||||
const loadVehicles = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data = await vehicleRequestsApi.adminGetAllPurchased();
|
||||
setVehicles(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load vehicles:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Open edit modal
|
||||
const openEditModal = (vehicle: PurchasedVehicle) => {
|
||||
setSelectedVehicle(vehicle);
|
||||
setEditStatus(vehicle.shipping_status);
|
||||
setEditLocation(vehicle.current_location || '');
|
||||
setEditArrival(vehicle.estimated_arrival ? vehicle.estimated_arrival.split('T')[0] : '');
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
// Update shipping status
|
||||
const updateShippingStatus = async () => {
|
||||
if (!selectedVehicle) return;
|
||||
|
||||
try {
|
||||
await vehicleRequestsApi.adminUpdateShippingStatus(selectedVehicle.id, {
|
||||
shipping_status: editStatus,
|
||||
current_location: editLocation || undefined,
|
||||
estimated_arrival: editArrival || undefined,
|
||||
});
|
||||
|
||||
setShowEditModal(false);
|
||||
loadVehicles();
|
||||
} catch (error) {
|
||||
console.error('Failed to update status:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Create purchased vehicle
|
||||
const createPurchasedVehicle = async () => {
|
||||
if (!createUserId || !createCarName || !createVehiclePrice) {
|
||||
alert('Please fill in all required fields');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const vehiclePrice = parseInt(createVehiclePrice) * 10000;
|
||||
const domesticCost = parseInt(createDomesticCost);
|
||||
const shippingCostUsd = parseInt(createShippingCost);
|
||||
const usdToKrw = useExchangeRateStore.getState().rates.USD?.rate || 1483;
|
||||
const shippingCostKrw = shippingCostUsd * usdToKrw;
|
||||
|
||||
// Calculate total with 5% Mongolian margin
|
||||
const subtotal = vehiclePrice + domesticCost + shippingCostKrw;
|
||||
const mongolianMargin = subtotal * 0.05;
|
||||
const totalCost = Math.round(subtotal + mongolianMargin);
|
||||
|
||||
await vehicleRequestsApi.adminCreatePurchased(parseInt(createUserId), {
|
||||
car_name: createCarName,
|
||||
car_image: createCarImage || undefined,
|
||||
vehicle_price_krw: vehiclePrice,
|
||||
domestic_cost_krw: domesticCost,
|
||||
shipping_cost_usd: shippingCostUsd,
|
||||
total_cost_krw: totalCost,
|
||||
car_type: createCarType,
|
||||
});
|
||||
|
||||
setShowCreateModal(false);
|
||||
resetCreateForm();
|
||||
loadVehicles();
|
||||
} catch (error) {
|
||||
console.error('Failed to create vehicle:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const resetCreateForm = () => {
|
||||
setCreateUserId('');
|
||||
setCreateCarName('');
|
||||
setCreateCarImage('');
|
||||
setCreateVehiclePrice('');
|
||||
setCreateDomesticCost('1207500');
|
||||
setCreateShippingCost('1000');
|
||||
setCreateCarType('small');
|
||||
};
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateString: string | null | undefined) => {
|
||||
if (!dateString) return '-';
|
||||
return new Date(dateString).toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
// Format price
|
||||
const formatPrice = (price: number | null | undefined) => {
|
||||
if (!price) return '-';
|
||||
return `₩${price.toLocaleString()}`;
|
||||
};
|
||||
|
||||
// Get status badge
|
||||
const getStatusBadge = (status: number) => {
|
||||
const step = SHIPPING_STEPS.find(s => s.step === status);
|
||||
const colors = [
|
||||
'',
|
||||
'bg-yellow-100 text-yellow-800', // 1: Purchased
|
||||
'bg-blue-100 text-blue-800', // 2: Incheon
|
||||
'bg-cyan-100 text-cyan-800', // 3: Tianjin
|
||||
'bg-indigo-100 text-indigo-800', // 4: Zamyn-Uud
|
||||
'bg-purple-100 text-purple-800', // 5: Ulaanbaatar
|
||||
'bg-orange-100 text-orange-800', // 6: Customs
|
||||
'bg-green-100 text-green-800', // 7: Delivered
|
||||
];
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${colors[status] || 'bg-gray-100 text-gray-800'}`}>
|
||||
{step?.label || `Step ${status}`}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold text-gray-800">Purchased Vehicles</h1>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="bg-primary-600 text-white px-4 py-2 rounded-lg hover:bg-primary-700"
|
||||
>
|
||||
+ Add Purchased Vehicle
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-7 gap-3">
|
||||
{SHIPPING_STEPS.map((step) => {
|
||||
const count = vehicles.filter(v => v.shipping_status === step.step).length;
|
||||
return (
|
||||
<div key={step.step} className="bg-white rounded-lg shadow p-4 text-center">
|
||||
<div className="text-2xl font-bold text-gray-800">{count}</div>
|
||||
<div className="text-sm text-gray-500">{step.label}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Vehicles Table */}
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600 mx-auto"></div>
|
||||
</div>
|
||||
) : vehicles.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">No purchased vehicles</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<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">ID</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Vehicle</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">User ID</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Type</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Total Cost</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Location</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Est. Arrival</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{vehicles.map((vehicle) => (
|
||||
<tr key={vehicle.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-sm text-gray-500">{vehicle.id}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{vehicle.car_image && (
|
||||
<img src={vehicle.car_image} alt="" className="w-12 h-9 object-cover rounded" />
|
||||
)}
|
||||
<span className="text-sm font-medium text-gray-800 max-w-[150px] truncate">
|
||||
{vehicle.car_name}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">{vehicle.user_id}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500 capitalize">{vehicle.car_type}</td>
|
||||
<td className="px-4 py-3 text-sm font-medium text-gray-800">
|
||||
{formatPrice(vehicle.total_cost_krw)}
|
||||
</td>
|
||||
<td className="px-4 py-3">{getStatusBadge(vehicle.shipping_status)}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500 max-w-[100px] truncate">
|
||||
{vehicle.current_location || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">
|
||||
{formatDate(vehicle.estimated_arrival)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => openEditModal(vehicle)}
|
||||
className="text-primary-600 hover:text-primary-800 text-sm font-medium"
|
||||
>
|
||||
Update Status
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edit Modal */}
|
||||
{showEditModal && selectedVehicle && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-lg max-w-md w-full">
|
||||
<div className="p-4 border-b flex items-center justify-between">
|
||||
<h3 className="font-semibold text-gray-800">Update Shipping Status</h3>
|
||||
<button onClick={() => setShowEditModal(false)} className="text-gray-500 hover:text-gray-700">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="bg-gray-50 rounded p-3">
|
||||
<p className="font-medium text-sm">{selectedVehicle.car_name}</p>
|
||||
<p className="text-xs text-gray-500">User ID: {selectedVehicle.user_id}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Shipping Status
|
||||
</label>
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{SHIPPING_STEPS.map((step) => (
|
||||
<button
|
||||
key={step.step}
|
||||
onClick={() => setEditStatus(step.step)}
|
||||
className={`p-2 rounded text-xs text-center ${
|
||||
editStatus === step.step
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{step.step}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Selected: {SHIPPING_STEPS.find(s => s.step === editStatus)?.label}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Current Location
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editLocation}
|
||||
onChange={(e) => setEditLocation(e.target.value)}
|
||||
placeholder="e.g., Incheon Port, On Ship, Ulaanbaatar"
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Estimated Arrival Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={editArrival}
|
||||
onChange={(e) => setEditArrival(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<button
|
||||
onClick={() => setShowEditModal(false)}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={updateShippingStatus}
|
||||
className="flex-1 px-4 py-2 bg-primary-600 text-white rounded hover:bg-primary-700"
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Modal */}
|
||||
{showCreateModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-lg max-w-md w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-4 border-b flex items-center justify-between">
|
||||
<h3 className="font-semibold text-gray-800">Add Purchased Vehicle</h3>
|
||||
<button onClick={() => { setShowCreateModal(false); resetCreateForm(); }} className="text-gray-500 hover:text-gray-700">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
User ID <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={createUserId}
|
||||
onChange={(e) => setCreateUserId(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Car Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={createCarName}
|
||||
onChange={(e) => setCreateCarName(e.target.value)}
|
||||
placeholder="e.g., 2022 Hyundai Sonata"
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Car Image URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={createCarImage}
|
||||
onChange={(e) => setCreateCarImage(e.target.value)}
|
||||
placeholder="https://..."
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Vehicle Price (만원) <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={createVehiclePrice}
|
||||
onChange={(e) => setCreateVehiclePrice(e.target.value)}
|
||||
placeholder="2000"
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 text-sm"
|
||||
/>
|
||||
{createVehiclePrice && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
= ₩{(parseInt(createVehiclePrice) * 10000).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Domestic Cost (원)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={createDomesticCost}
|
||||
onChange={(e) => setCreateDomesticCost(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 text-sm"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Default: ₩1,150,000 + 5% margin = ₩1,207,500
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Car Type
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => { setCreateCarType('small'); setCreateShippingCost('1000'); }}
|
||||
className={`p-3 rounded border-2 ${
|
||||
createCarType === 'small'
|
||||
? 'border-primary-500 bg-primary-50'
|
||||
: 'border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">Small Car</div>
|
||||
<div className="text-sm text-gray-500">$1,000</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setCreateCarType('compact'); setCreateShippingCost('800'); }}
|
||||
className={`p-3 rounded border-2 ${
|
||||
createCarType === 'compact'
|
||||
? 'border-primary-500 bg-primary-50'
|
||||
: 'border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">Compact Car</div>
|
||||
<div className="text-sm text-gray-500">$800</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Shipping Cost (USD)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={createShippingCost}
|
||||
onChange={(e) => setCreateShippingCost(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Cost Preview */}
|
||||
{createVehiclePrice && (
|
||||
<div className="bg-gray-50 rounded p-3 text-sm">
|
||||
<h4 className="font-medium mb-2">Cost Preview</h4>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span>Vehicle:</span>
|
||||
<span>₩{(parseInt(createVehiclePrice || '0') * 10000).toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Domestic:</span>
|
||||
<span>₩{parseInt(createDomesticCost || '0').toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Shipping:</span>
|
||||
<span>${parseInt(createShippingCost || '0').toLocaleString()} (≈₩{(parseInt(createShippingCost || '0') * (useExchangeRateStore.getState().rates.USD?.rate || 1483)).toLocaleString()})</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-t pt-1 font-medium">
|
||||
<span>Total (+ 5% margin):</span>
|
||||
<span>
|
||||
₩{(() => {
|
||||
const vp = parseInt(createVehiclePrice || '0') * 10000;
|
||||
const dc = parseInt(createDomesticCost || '0');
|
||||
const usdRate = useExchangeRateStore.getState().rates.USD?.rate || 1483;
|
||||
const sc = parseInt(createShippingCost || '0') * usdRate;
|
||||
const subtotal = vp + dc + sc;
|
||||
const total = Math.round(subtotal * 1.05);
|
||||
return total.toLocaleString();
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<button
|
||||
onClick={() => { setShowCreateModal(false); resetCreateForm(); }}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={createPurchasedVehicle}
|
||||
className="flex-1 px-4 py-2 bg-primary-600 text-white rounded hover:bg-primary-700"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
589
frontend/src/app/admin/settings/page.tsx
Normal file
589
frontend/src/app/admin/settings/page.tsx
Normal file
@@ -0,0 +1,589 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface SystemSettings {
|
||||
id: number;
|
||||
search_page_size: number;
|
||||
korea_margin_percent: number;
|
||||
mongolia_margin_percent: number;
|
||||
cc_per_usdc: number;
|
||||
cc_per_view: number;
|
||||
cc_signup_bonus: number;
|
||||
cars_per_cc: number;
|
||||
cache_ttl_hours: number;
|
||||
container_logistics_usd: number;
|
||||
shoring_cost_usd: number;
|
||||
referral_reward_enabled: boolean;
|
||||
referral_reward_percent: number;
|
||||
referral_reward_type: string;
|
||||
exchange_rate_weight_usd: number;
|
||||
exchange_rate_weight_mnt: number;
|
||||
exchange_rate_weight_rub: number;
|
||||
exchange_rate_weight_cny: number;
|
||||
}
|
||||
|
||||
interface ExchangeRateWeights {
|
||||
usd: number;
|
||||
mnt: number;
|
||||
rub: number;
|
||||
cny: number;
|
||||
}
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [settings, setSettings] = useState<SystemSettings | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [savingExchangeRates, setSavingExchangeRates] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const [exchangeRateWeights, setExchangeRateWeights] = useState<ExchangeRateWeights>({
|
||||
usd: 0,
|
||||
mnt: 0,
|
||||
rub: 0,
|
||||
cny: 0,
|
||||
});
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState({
|
||||
search_page_size: 20,
|
||||
korea_margin_percent: 5.0,
|
||||
mongolia_margin_percent: 5.0,
|
||||
cc_per_usdc: 1,
|
||||
cc_per_view: 1,
|
||||
cc_signup_bonus: 3,
|
||||
cars_per_cc: 3,
|
||||
cache_ttl_hours: 2,
|
||||
container_logistics_usd: 3600,
|
||||
shoring_cost_usd: 300,
|
||||
referral_reward_enabled: true,
|
||||
referral_reward_percent: 10.0,
|
||||
referral_reward_type: 'one_time',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
fetchExchangeRateWeights();
|
||||
}, []);
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/settings/`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSettings(data);
|
||||
setFormData({
|
||||
search_page_size: data.search_page_size,
|
||||
korea_margin_percent: data.korea_margin_percent,
|
||||
mongolia_margin_percent: data.mongolia_margin_percent,
|
||||
cc_per_usdc: data.cc_per_usdc,
|
||||
cc_per_view: data.cc_per_view,
|
||||
cc_signup_bonus: data.cc_signup_bonus,
|
||||
cars_per_cc: data.cars_per_cc || 3,
|
||||
cache_ttl_hours: data.cache_ttl_hours,
|
||||
container_logistics_usd: data.container_logistics_usd || 3600,
|
||||
shoring_cost_usd: data.shoring_cost_usd || 300,
|
||||
referral_reward_enabled: data.referral_reward_enabled ?? true,
|
||||
referral_reward_percent: data.referral_reward_percent ?? 10.0,
|
||||
referral_reward_type: data.referral_reward_type || 'one_time',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch settings:', error);
|
||||
setMessage({ type: 'error', text: 'Failed to load settings' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchExchangeRateWeights = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/exchange-rate/weights`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setExchangeRateWeights(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch exchange rate weights:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const saveExchangeRateWeights = async () => {
|
||||
setSavingExchangeRates(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch(`${API_BASE_URL}/api/exchange-rate/weights`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(exchangeRateWeights),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setMessage({ type: 'success', text: 'Exchange rate weights saved successfully!' });
|
||||
} else {
|
||||
const error = await response.json();
|
||||
setMessage({ type: 'error', text: error.detail || 'Failed to save exchange rate weights' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save exchange rate weights:', error);
|
||||
setMessage({ type: 'error', text: 'Failed to save exchange rate weights' });
|
||||
} finally {
|
||||
setSavingExchangeRates(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch(`${API_BASE_URL}/api/settings/`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSettings(data);
|
||||
setMessage({ type: 'success', text: 'Settings saved successfully!' });
|
||||
} else {
|
||||
const error = await response.json();
|
||||
setMessage({ type: 'error', text: error.detail || 'Failed to save settings' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save settings:', error);
|
||||
setMessage({ type: 'error', text: 'Failed to save settings' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (field: keyof typeof formData, value: number) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl">
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-6">System Settings</h1>
|
||||
|
||||
{message && (
|
||||
<div className={`mb-6 p-4 rounded-lg ${
|
||||
message.type === 'success' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Search Settings */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-4 flex items-center gap-2">
|
||||
<span>Search Settings</span>
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Search Results Per Page
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="5"
|
||||
max="100"
|
||||
value={formData.search_page_size}
|
||||
onChange={(e) => handleChange('search_page_size', parseInt(e.target.value) || 20)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">Number of cars displayed per page in search results (5-100)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Cache TTL (Hours)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="24"
|
||||
value={formData.cache_ttl_hours}
|
||||
onChange={(e) => handleChange('cache_ttl_hours', parseInt(e.target.value) || 2)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">How long to cache search results (1-24 hours)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Margin Settings */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-4 flex items-center gap-2">
|
||||
<span>Margin Settings</span>
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Korea Margin (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="50"
|
||||
step="0.1"
|
||||
value={formData.korea_margin_percent}
|
||||
onChange={(e) => handleChange('korea_margin_percent', parseFloat(e.target.value) || 5)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">Margin percentage for Korea sales</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Mongolia Margin (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="50"
|
||||
step="0.1"
|
||||
value={formData.mongolia_margin_percent}
|
||||
onChange={(e) => handleChange('mongolia_margin_percent', parseFloat(e.target.value) || 5)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">Margin percentage for Mongolia exports</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CC Coin Settings */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-4 flex items-center gap-2">
|
||||
<span>CC Coin Settings</span>
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
CC per USD
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={formData.cc_per_usdc}
|
||||
onChange={(e) => handleChange('cc_per_usdc', parseInt(e.target.value) || 10)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">1 USD = X CC</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
CC per Request
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="10"
|
||||
value={formData.cc_per_view}
|
||||
onChange={(e) => handleChange('cc_per_view', parseInt(e.target.value) || 1)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">CC consumed per vehicle request</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Cars per CC (추천 대수)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="50"
|
||||
value={formData.cars_per_cc}
|
||||
onChange={(e) => handleChange('cars_per_cc', parseInt(e.target.value) || 3)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">1 CC = {formData.cars_per_cc} recommended vehicles</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Signup Bonus CC
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
value={formData.cc_signup_bonus}
|
||||
onChange={(e) => handleChange('cc_signup_bonus', parseInt(e.target.value) || 3)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">Free CC for new users</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-4 bg-blue-50 rounded-lg">
|
||||
<h3 className="font-medium text-blue-800 mb-2">CC Value Preview</h3>
|
||||
<div className="text-sm text-blue-700 space-y-1">
|
||||
<p>1 CC = {formData.cars_per_cc} recommended vehicles</p>
|
||||
<p>Signup Bonus ({formData.cc_signup_bonus} CC) = {formData.cc_signup_bonus * formData.cars_per_cc} vehicles</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Container Logistics Settings */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-4 flex items-center gap-2">
|
||||
<span>🚢 Container Logistics Settings</span>
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Container Logistics Cost (USD)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="10000"
|
||||
value={formData.container_logistics_usd}
|
||||
onChange={(e) => handleChange('container_logistics_usd', parseInt(e.target.value) || 3600)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">컨테이너 물류비 (기본값: $3,600)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Shoring Cost (USD)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="1000"
|
||||
value={formData.shoring_cost_usd}
|
||||
onChange={(e) => handleChange('shoring_cost_usd', parseInt(e.target.value) || 300)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">쇼링비 - 컨테이너 고정 비용 (기본값: $300)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-4 bg-blue-50 rounded-lg">
|
||||
<h3 className="font-medium text-blue-800 mb-2">Cost Calculation Preview</h3>
|
||||
<div className="text-sm text-blue-700 space-y-1">
|
||||
<p>Total Container Cost: ${formData.container_logistics_usd + formData.shoring_cost_usd}</p>
|
||||
<p>Small Car (5.5/10): ${((formData.container_logistics_usd + formData.shoring_cost_usd) * 0.275).toFixed(0)} per car</p>
|
||||
<p>Compact Car (4.5/10): ${((formData.container_logistics_usd + formData.shoring_cost_usd) * 0.225).toFixed(0)} per car</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Referral Settings */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-4 flex items-center gap-2">
|
||||
<span>👥 Referral Reward Settings</span>
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.referral_reward_enabled}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, referral_reward_enabled: e.target.checked }))}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-600"></div>
|
||||
</label>
|
||||
<span className="text-sm font-medium text-gray-700">Enable Referral Rewards</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Reward Percentage (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="50"
|
||||
step="0.1"
|
||||
value={formData.referral_reward_percent}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, referral_reward_percent: parseFloat(e.target.value) || 10 }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">Percentage of payment given as referral reward</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Reward Type
|
||||
</label>
|
||||
<select
|
||||
value={formData.referral_reward_type}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, referral_reward_type: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<option value="one_time">One-time (First payment only)</option>
|
||||
<option value="recurring">Recurring (Every payment)</option>
|
||||
</select>
|
||||
<p className="mt-1 text-sm text-gray-500">When to give referral rewards</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-green-50 rounded-lg">
|
||||
<h3 className="font-medium text-green-800 mb-2">Example Calculation</h3>
|
||||
<div className="text-sm text-green-700 space-y-1">
|
||||
<p>If a referred user charges $100 USD:</p>
|
||||
<p>Referrer receives: ${(100 * formData.referral_reward_percent / 100).toFixed(2)} USD</p>
|
||||
<p>Type: {formData.referral_reward_type === 'one_time' ? 'Only on first payment' : 'Every time they pay'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="px-6 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{saving && (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
)}
|
||||
{saving ? 'Saving...' : 'Save Settings'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Exchange Rate Weight Settings - Separate Section */}
|
||||
<div className="mt-8 bg-white rounded-xl shadow-sm p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-4 flex items-center gap-2">
|
||||
<span>💱 Exchange Rate Weight Settings</span>
|
||||
</h2>
|
||||
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
환율에 가중치를 적용하여 실제 표시되는 환율을 조정할 수 있습니다. 양수 값은 가격 인상, 음수 값은 가격 인하를 의미합니다.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
🇺🇸 USD (미국 달러) Weight (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="-10"
|
||||
max="10"
|
||||
step="0.1"
|
||||
value={exchangeRateWeights.usd}
|
||||
onChange={(e) => setExchangeRateWeights(prev => ({ ...prev, usd: parseFloat(e.target.value) || 0 }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
🇲🇳 MNT (몽골 투그릭) Weight (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="-10"
|
||||
max="10"
|
||||
step="0.1"
|
||||
value={exchangeRateWeights.mnt}
|
||||
onChange={(e) => setExchangeRateWeights(prev => ({ ...prev, mnt: parseFloat(e.target.value) || 0 }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
🇷🇺 RUB (러시아 루블) Weight (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="-10"
|
||||
max="10"
|
||||
step="0.1"
|
||||
value={exchangeRateWeights.rub}
|
||||
onChange={(e) => setExchangeRateWeights(prev => ({ ...prev, rub: parseFloat(e.target.value) || 0 }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
🇨🇳 CNY (중국 위안) Weight (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="-10"
|
||||
max="10"
|
||||
step="0.1"
|
||||
value={exchangeRateWeights.cny}
|
||||
onChange={(e) => setExchangeRateWeights(prev => ({ ...prev, cny: parseFloat(e.target.value) || 0 }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-4 bg-amber-50 rounded-lg">
|
||||
<h3 className="font-medium text-amber-800 mb-2">Weight Preview (예시: +2%)</h3>
|
||||
<div className="text-sm text-amber-700 space-y-2">
|
||||
<p className="font-medium">예: 기준 환율이 1 USD = 1,400 KRW 일 때</p>
|
||||
<div className="bg-white rounded p-3 space-y-1">
|
||||
<p>USD 가중치: +2%</p>
|
||||
<p>계산식: 1,400 × (1 + 2/100) = 1,400 × 1.0200</p>
|
||||
<p className="font-bold text-lg">표시 환율: 1 USD = 1,428 KRW</p>
|
||||
<p className="text-xs text-amber-600">(+28 KRW 증가)</p>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
현재 설정된 가중치 - USD: {exchangeRateWeights.usd >= 0 ? '+' : ''}{exchangeRateWeights.usd}%,
|
||||
MNT: {exchangeRateWeights.mnt >= 0 ? '+' : ''}{exchangeRateWeights.mnt}%,
|
||||
RUB: {exchangeRateWeights.rub >= 0 ? '+' : ''}{exchangeRateWeights.rub}%,
|
||||
CNY: {exchangeRateWeights.cny >= 0 ? '+' : ''}{exchangeRateWeights.cny}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveExchangeRateWeights}
|
||||
disabled={savingExchangeRates}
|
||||
className="px-6 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{savingExchangeRates && (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
)}
|
||||
{savingExchangeRates ? 'Saving...' : 'Save Exchange Rate Weights'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
742
frontend/src/app/admin/translations/page.tsx
Normal file
742
frontend/src/app/admin/translations/page.tsx
Normal file
@@ -0,0 +1,742 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { translationsApi, Translation, TranslationListResponse } from '@/lib/api';
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
maker: 'Maker (제조사)',
|
||||
model: 'Model (모델)',
|
||||
fuel: 'Fuel (연료)',
|
||||
transmission: 'Transmission (변속기)',
|
||||
color: 'Color (색상)',
|
||||
car_name: 'Car Name (차량명)',
|
||||
general: 'General (일반)',
|
||||
};
|
||||
|
||||
interface TranslationStats {
|
||||
total_entries: number;
|
||||
by_category: Record<string, number>;
|
||||
translation_coverage: {
|
||||
english: { translated: number; total: number; percentage: number };
|
||||
mongolian: { translated: number; total: number; percentage: number };
|
||||
russian: { translated: number; total: number; percentage: number };
|
||||
};
|
||||
}
|
||||
|
||||
export default function TranslationsPage() {
|
||||
const [translations, setTranslations] = useState<Translation[]>([]);
|
||||
const [categories, setCategories] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [editData, setEditData] = useState<Partial<Translation>>({});
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [showBatchModal, setShowBatchModal] = useState(false);
|
||||
const [translatingId, setTranslatingId] = useState<number | null>(null);
|
||||
const [batchTranslating, setBatchTranslating] = useState(false);
|
||||
const [stats, setStats] = useState<TranslationStats | null>(null);
|
||||
const [batchOptions, setBatchOptions] = useState({
|
||||
category: '',
|
||||
overwriteExisting: false,
|
||||
targetLangs: ['en', 'mn', 'ru'] as string[],
|
||||
});
|
||||
const [batchResult, setBatchResult] = useState<{
|
||||
total_processed: number;
|
||||
successful: number;
|
||||
failed: number;
|
||||
} | null>(null);
|
||||
const [newTranslation, setNewTranslation] = useState({
|
||||
source_text: '',
|
||||
category: 'general',
|
||||
text_en: '',
|
||||
text_mn: '',
|
||||
text_ru: '',
|
||||
});
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
useEffect(() => {
|
||||
loadCategories();
|
||||
loadStats();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadTranslations();
|
||||
}, [page, selectedCategory, searchTerm]);
|
||||
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
const data = await translationsApi.getCategories();
|
||||
setCategories(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load categories:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const data = await translationsApi.getStats();
|
||||
setStats(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load stats:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const loadTranslations = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await translationsApi.getList({
|
||||
page,
|
||||
page_size: pageSize,
|
||||
category: selectedCategory || undefined,
|
||||
search: searchTerm || undefined,
|
||||
});
|
||||
setTranslations(data.translations);
|
||||
setTotal(data.total);
|
||||
} catch (err) {
|
||||
console.error('Failed to load translations:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAutoExtract = async () => {
|
||||
try {
|
||||
const result = await translationsApi.autoExtract();
|
||||
alert(result.message);
|
||||
loadTranslations();
|
||||
loadStats();
|
||||
} catch (err) {
|
||||
console.error('Failed to auto-extract:', err);
|
||||
alert('Failed to auto-extract translations');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAutoTranslate = async (translation: Translation) => {
|
||||
setTranslatingId(translation.id);
|
||||
try {
|
||||
const result = await translationsApi.autoTranslate(translation.id);
|
||||
alert(`Auto-translated: ${result.message}`);
|
||||
loadTranslations();
|
||||
loadStats();
|
||||
} catch (err: any) {
|
||||
console.error('Failed to auto-translate:', err);
|
||||
alert(err.response?.data?.detail || 'Failed to auto-translate');
|
||||
} finally {
|
||||
setTranslatingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchTranslate = async () => {
|
||||
setBatchTranslating(true);
|
||||
setBatchResult(null);
|
||||
try {
|
||||
const result = await translationsApi.autoTranslateBatch(
|
||||
batchOptions.targetLangs,
|
||||
batchOptions.category || undefined,
|
||||
batchOptions.overwriteExisting
|
||||
);
|
||||
setBatchResult({
|
||||
total_processed: result.total_processed,
|
||||
successful: result.successful,
|
||||
failed: result.failed,
|
||||
});
|
||||
loadTranslations();
|
||||
loadStats();
|
||||
} catch (err: any) {
|
||||
console.error('Failed to batch translate:', err);
|
||||
alert(err.response?.data?.detail || 'Failed to batch translate');
|
||||
} finally {
|
||||
setBatchTranslating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (translation: Translation) => {
|
||||
setEditingId(translation.id);
|
||||
setEditData({
|
||||
text_en: translation.text_en || '',
|
||||
text_mn: translation.text_mn || '',
|
||||
text_ru: translation.text_ru || '',
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async (id: number) => {
|
||||
try {
|
||||
await translationsApi.update(id, editData);
|
||||
setEditingId(null);
|
||||
loadTranslations();
|
||||
loadStats();
|
||||
} catch (err) {
|
||||
console.error('Failed to save:', err);
|
||||
alert('Failed to save translation');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('Delete this translation?')) return;
|
||||
try {
|
||||
await translationsApi.delete(id);
|
||||
loadTranslations();
|
||||
loadStats();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete:', err);
|
||||
alert('Failed to delete translation');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!newTranslation.source_text.trim()) {
|
||||
alert('Source text is required');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await translationsApi.create(newTranslation);
|
||||
setShowAddModal(false);
|
||||
setNewTranslation({
|
||||
source_text: '',
|
||||
category: 'general',
|
||||
text_en: '',
|
||||
text_mn: '',
|
||||
text_ru: '',
|
||||
});
|
||||
loadTranslations();
|
||||
loadStats();
|
||||
} catch (err: any) {
|
||||
console.error('Failed to add:', err);
|
||||
alert(err.response?.data?.detail || 'Failed to add translation');
|
||||
}
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800">Translations Management</h1>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleAutoExtract}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Auto Extract
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowBatchModal(true)}
|
||||
className="bg-purple-600 text-white px-4 py-2 rounded-lg hover:bg-purple-700 flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />
|
||||
</svg>
|
||||
Batch Translate
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add New
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Translation Statistics */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-white rounded-xl shadow-sm p-4">
|
||||
<div className="text-sm text-gray-500">Total Entries</div>
|
||||
<div className="text-2xl font-bold text-gray-800">{stats.total_entries}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-sm p-4">
|
||||
<div className="text-sm text-gray-500">English Coverage</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-2xl font-bold text-blue-600">{stats.translation_coverage.english.percentage.toFixed(1)}%</div>
|
||||
<div className="text-xs text-gray-400">({stats.translation_coverage.english.translated}/{stats.translation_coverage.english.total})</div>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 mt-2">
|
||||
<div className="bg-blue-600 h-2 rounded-full" style={{ width: `${stats.translation_coverage.english.percentage}%` }}></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-sm p-4">
|
||||
<div className="text-sm text-gray-500">Mongolian Coverage</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-2xl font-bold text-green-600">{stats.translation_coverage.mongolian.percentage.toFixed(1)}%</div>
|
||||
<div className="text-xs text-gray-400">({stats.translation_coverage.mongolian.translated}/{stats.translation_coverage.mongolian.total})</div>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 mt-2">
|
||||
<div className="bg-green-600 h-2 rounded-full" style={{ width: `${stats.translation_coverage.mongolian.percentage}%` }}></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-sm p-4">
|
||||
<div className="text-sm text-gray-500">Russian Coverage</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-2xl font-bold text-red-600">{stats.translation_coverage.russian.percentage.toFixed(1)}%</div>
|
||||
<div className="text-xs text-gray-400">({stats.translation_coverage.russian.translated}/{stats.translation_coverage.russian.total})</div>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 mt-2">
|
||||
<div className="bg-red-600 h-2 rounded-full" style={{ width: `${stats.translation_coverage.russian.percentage}%` }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Category Stats */}
|
||||
{stats && Object.keys(stats.by_category).length > 0 && (
|
||||
<div className="bg-white rounded-xl shadow-sm p-4 mb-6">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">Entries by Category</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(stats.by_category).map(([cat, count]) => (
|
||||
<span key={cat} className="px-3 py-1 bg-gray-100 text-gray-700 rounded-full text-sm">
|
||||
{CATEGORY_LABELS[cat] || cat}: {count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-4 mb-6">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Search</label>
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="Search translations..."
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-48">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Category</label>
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => {
|
||||
setSelectedCategory(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat} value={cat}>
|
||||
{CATEGORY_LABELS[cat] || cat}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200">
|
||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Category</th>
|
||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Korean (Source)</th>
|
||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">English</th>
|
||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Mongolian</th>
|
||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Russian</th>
|
||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600 w-40">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-8 text-center">
|
||||
<div className="flex justify-center">
|
||||
<div className="w-8 h-8 border-4 border-primary-600 border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : translations.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-8 text-center text-gray-500">
|
||||
No translations found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
translations.map((trans) => (
|
||||
<tr key={trans.id} className="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td className="py-3 px-4">
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs">
|
||||
{CATEGORY_LABELS[trans.category] || trans.category}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 font-medium text-gray-800">{trans.source_text}</td>
|
||||
<td className="py-3 px-4">
|
||||
{editingId === trans.id ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editData.text_en || ''}
|
||||
onChange={(e) => setEditData({ ...editData, text_en: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-2 py-1 text-sm"
|
||||
/>
|
||||
) : (
|
||||
<span className={trans.text_en ? 'text-gray-700' : 'text-gray-400 italic'}>
|
||||
{trans.text_en || 'Not set'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
{editingId === trans.id ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editData.text_mn || ''}
|
||||
onChange={(e) => setEditData({ ...editData, text_mn: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-2 py-1 text-sm"
|
||||
/>
|
||||
) : (
|
||||
<span className={trans.text_mn ? 'text-gray-700' : 'text-gray-400 italic'}>
|
||||
{trans.text_mn || 'Not set'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
{editingId === trans.id ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editData.text_ru || ''}
|
||||
onChange={(e) => setEditData({ ...editData, text_ru: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-2 py-1 text-sm"
|
||||
/>
|
||||
) : (
|
||||
<span className={trans.text_ru ? 'text-gray-700' : 'text-gray-400 italic'}>
|
||||
{trans.text_ru || 'Not set'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
{editingId === trans.id ? (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleSave(trans.id)}
|
||||
className="text-green-600 hover:text-green-700"
|
||||
title="Save"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingId(null)}
|
||||
className="text-gray-600 hover:text-gray-700"
|
||||
title="Cancel"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleAutoTranslate(trans)}
|
||||
disabled={translatingId === trans.id}
|
||||
className={`text-purple-600 hover:text-purple-700 ${translatingId === trans.id ? 'opacity-50' : ''}`}
|
||||
title="Auto Translate"
|
||||
>
|
||||
{translatingId === trans.id ? (
|
||||
<div className="w-5 h-5 border-2 border-purple-600 border-t-transparent rounded-full animate-spin"></div>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEdit(trans)}
|
||||
className="text-blue-600 hover:text-blue-700"
|
||||
title="Edit"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(trans.id)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
title="Delete"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center items-center gap-2 py-4 border-t border-gray-200">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<span className="px-4 text-gray-600">
|
||||
Page {page} of {totalPages} ({total} total)
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add Modal */}
|
||||
{showAddModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl max-w-lg w-full p-6">
|
||||
<h2 className="text-xl font-bold text-gray-800 mb-4">Add Translation</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Korean (Source Text) *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newTranslation.source_text}
|
||||
onChange={(e) => setNewTranslation({ ...newTranslation, source_text: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
placeholder="Enter Korean text"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Category</label>
|
||||
<select
|
||||
value={newTranslation.category}
|
||||
onChange={(e) => setNewTranslation({ ...newTranslation, category: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat} value={cat}>
|
||||
{CATEGORY_LABELS[cat] || cat}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">English</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newTranslation.text_en}
|
||||
onChange={(e) => setNewTranslation({ ...newTranslation, text_en: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
placeholder="English translation"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Mongolian</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newTranslation.text_mn}
|
||||
onChange={(e) => setNewTranslation({ ...newTranslation, text_mn: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
placeholder="Mongolian translation"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Russian</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newTranslation.text_ru}
|
||||
onChange={(e) => setNewTranslation({ ...newTranslation, text_ru: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
placeholder="Russian translation"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<button
|
||||
onClick={() => setShowAddModal(false)}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Batch Translate Modal */}
|
||||
{showBatchModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl max-w-lg w-full p-6">
|
||||
<h2 className="text-xl font-bold text-gray-800 mb-4">Batch Auto-Translate</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Category (Optional)</label>
|
||||
<select
|
||||
value={batchOptions.category}
|
||||
onChange={(e) => setBatchOptions({ ...batchOptions, category: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat} value={cat}>
|
||||
{CATEGORY_LABELS[cat] || cat}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-gray-500 mt-1">Leave empty to translate all categories</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Target Languages</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={batchOptions.targetLangs.includes('en')}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setBatchOptions({ ...batchOptions, targetLangs: [...batchOptions.targetLangs, 'en'] });
|
||||
} else {
|
||||
setBatchOptions({ ...batchOptions, targetLangs: batchOptions.targetLangs.filter(l => l !== 'en') });
|
||||
}
|
||||
}}
|
||||
className="rounded"
|
||||
/>
|
||||
<span>English</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={batchOptions.targetLangs.includes('mn')}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setBatchOptions({ ...batchOptions, targetLangs: [...batchOptions.targetLangs, 'mn'] });
|
||||
} else {
|
||||
setBatchOptions({ ...batchOptions, targetLangs: batchOptions.targetLangs.filter(l => l !== 'mn') });
|
||||
}
|
||||
}}
|
||||
className="rounded"
|
||||
/>
|
||||
<span>Mongolian</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={batchOptions.targetLangs.includes('ru')}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setBatchOptions({ ...batchOptions, targetLangs: [...batchOptions.targetLangs, 'ru'] });
|
||||
} else {
|
||||
setBatchOptions({ ...batchOptions, targetLangs: batchOptions.targetLangs.filter(l => l !== 'ru') });
|
||||
}
|
||||
}}
|
||||
className="rounded"
|
||||
/>
|
||||
<span>Russian</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={batchOptions.overwriteExisting}
|
||||
onChange={(e) => setBatchOptions({ ...batchOptions, overwriteExisting: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Overwrite existing translations</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mt-1">If unchecked, only empty translations will be filled</p>
|
||||
</div>
|
||||
|
||||
{batchResult && (
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h3 className="font-medium text-gray-700 mb-2">Translation Results</h3>
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-800">{batchResult.total_processed}</div>
|
||||
<div className="text-xs text-gray-500">Processed</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-green-600">{batchResult.successful}</div>
|
||||
<div className="text-xs text-gray-500">Successful</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-red-600">{batchResult.failed}</div>
|
||||
<div className="text-xs text-gray-500">Failed</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowBatchModal(false);
|
||||
setBatchResult(null);
|
||||
}}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBatchTranslate}
|
||||
disabled={batchTranslating || batchOptions.targetLangs.length === 0}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{batchTranslating ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
Translating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />
|
||||
</svg>
|
||||
Start Batch Translate
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
616
frontend/src/app/admin/users/page.tsx
Normal file
616
frontend/src/app/admin/users/page.tsx
Normal file
@@ -0,0 +1,616 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { adminUserApi, AdminUser } from '@/lib/api';
|
||||
|
||||
type TabType = 'active' | 'deleted';
|
||||
|
||||
interface DeletedUser extends AdminUser {
|
||||
deleted_at?: string;
|
||||
withdrawal_reason?: string;
|
||||
}
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('active');
|
||||
|
||||
// Active users state
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(20);
|
||||
const [search, setSearch] = useState('');
|
||||
const [filterDealer, setFilterDealer] = useState<boolean | undefined>(undefined);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Deleted users state
|
||||
const [deletedUsers, setDeletedUsers] = useState<DeletedUser[]>([]);
|
||||
const [deletedTotal, setDeletedTotal] = useState(0);
|
||||
const [deletedPage, setDeletedPage] = useState(1);
|
||||
const [deletedSearch, setDeletedSearch] = useState('');
|
||||
const [deletedLoading, setDeletedLoading] = useState(true);
|
||||
|
||||
// Modal state
|
||||
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null);
|
||||
const [showCCModal, setShowCCModal] = useState(false);
|
||||
const [ccAmount, setCCAmount] = useState('');
|
||||
const [ccReason, setCCReason] = useState('');
|
||||
|
||||
// Delete modal state
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [deleteUser, setDeleteUser] = useState<AdminUser | null>(null);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const [hardDelete, setHardDelete] = useState(false);
|
||||
|
||||
// Restore state
|
||||
const [restoreLoading, setRestoreLoading] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'active') {
|
||||
loadUsers();
|
||||
} else {
|
||||
loadDeletedUsers();
|
||||
}
|
||||
}, [page, filterDealer, activeTab, deletedPage]);
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await adminUserApi.getUsers({
|
||||
page,
|
||||
page_size: pageSize,
|
||||
search: search || undefined,
|
||||
is_dealer: filterDealer,
|
||||
});
|
||||
setUsers(response.users);
|
||||
setTotal(response.total);
|
||||
} catch (error) {
|
||||
console.error('Failed to load users:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadDeletedUsers = async () => {
|
||||
try {
|
||||
setDeletedLoading(true);
|
||||
const response = await adminUserApi.getDeletedUsers({
|
||||
page: deletedPage,
|
||||
page_size: pageSize,
|
||||
search: deletedSearch || undefined,
|
||||
});
|
||||
setDeletedUsers(response.users);
|
||||
setDeletedTotal(response.total);
|
||||
} catch (error) {
|
||||
console.error('Failed to load deleted users:', error);
|
||||
} finally {
|
||||
setDeletedLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setPage(1);
|
||||
loadUsers();
|
||||
};
|
||||
|
||||
const handleDeletedSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setDeletedPage(1);
|
||||
loadDeletedUsers();
|
||||
};
|
||||
|
||||
const handleAdjustCC = async () => {
|
||||
if (!selectedUser || !ccAmount) return;
|
||||
|
||||
try {
|
||||
const result = await adminUserApi.adjustCC(
|
||||
selectedUser.id,
|
||||
parseFloat(ccAmount),
|
||||
ccReason || 'Admin adjustment'
|
||||
);
|
||||
alert(`CC adjusted! New balance: ${result.new_balance}`);
|
||||
setShowCCModal(false);
|
||||
setCCAmount('');
|
||||
setCCReason('');
|
||||
loadUsers();
|
||||
} catch (error) {
|
||||
console.error('Failed to adjust CC:', error);
|
||||
alert('Failed to adjust CC');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteUser = async () => {
|
||||
if (!deleteUser) return;
|
||||
|
||||
setDeleteLoading(true);
|
||||
try {
|
||||
const result = await adminUserApi.deleteUser(deleteUser.id, hardDelete);
|
||||
alert(result.message);
|
||||
setShowDeleteModal(false);
|
||||
setDeleteUser(null);
|
||||
setHardDelete(false);
|
||||
loadUsers();
|
||||
loadDeletedUsers();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to delete user:', error);
|
||||
alert(error.response?.data?.detail || 'Failed to delete user');
|
||||
} finally {
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreUser = async (userId: number) => {
|
||||
if (!confirm('Are you sure you want to restore this user?')) return;
|
||||
|
||||
setRestoreLoading(userId);
|
||||
try {
|
||||
const result = await adminUserApi.restoreUser(userId);
|
||||
alert(result.message);
|
||||
loadDeletedUsers();
|
||||
loadUsers();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to restore user:', error);
|
||||
alert(error.response?.data?.detail || 'Failed to restore user');
|
||||
} finally {
|
||||
setRestoreLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePermanentDelete = async (user: DeletedUser) => {
|
||||
if (!confirm(`Are you sure you want to PERMANENTLY delete ${user.email}? This cannot be undone!`)) return;
|
||||
|
||||
setRestoreLoading(user.id);
|
||||
try {
|
||||
const result = await adminUserApi.deleteUser(user.id, true);
|
||||
alert(result.message);
|
||||
loadDeletedUsers();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to permanently delete user:', error);
|
||||
alert(error.response?.data?.detail || 'Failed to delete user');
|
||||
} finally {
|
||||
setRestoreLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr?: string) => {
|
||||
if (!dateStr) return '-';
|
||||
return new Date(dateStr).toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
const deletedTotalPages = Math.ceil(deletedTotal / pageSize);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-800">User Management</h1>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
<button
|
||||
onClick={() => { setActiveTab('active'); setPage(1); }}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'active'
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
Active Users
|
||||
<span className={`ml-2 py-0.5 px-2 rounded-full text-xs ${
|
||||
activeTab === 'active' ? 'bg-primary-100 text-primary-600' : 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{total}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setActiveTab('deleted'); setDeletedPage(1); }}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'deleted'
|
||||
? 'border-red-500 text-red-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
Deleted Users
|
||||
<span className={`ml-2 py-0.5 px-2 rounded-full text-xs ${
|
||||
activeTab === 'deleted' ? 'bg-red-100 text-red-600' : 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{deletedTotal}
|
||||
</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Active Users Tab */}
|
||||
{activeTab === 'active' && (
|
||||
<>
|
||||
{/* Search & Filter */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-4">
|
||||
<form onSubmit={handleSearch} className="flex flex-wrap gap-4">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search by email, name, or phone..."
|
||||
className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={filterDealer === undefined ? '' : filterDealer ? 'true' : 'false'}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === '') setFilterDealer(undefined);
|
||||
else setFilterDealer(e.target.value === 'true');
|
||||
setPage(1);
|
||||
}}
|
||||
className="px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="">All Users</option>
|
||||
<option value="true">Dealers Only</option>
|
||||
<option value="false">Non-Dealers</option>
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-6 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Users Table */}
|
||||
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-500"></div>
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
No users found
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">ID</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Email</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Phone</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Country</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">CC Balance</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Type</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Referral</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Registered</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-sm text-gray-800">{user.id}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-800">{user.email}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-800">{user.name || '-'}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-800">{user.phone || '-'}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-800">{user.country || '-'}</td>
|
||||
<td className="px-4 py-3 text-sm font-semibold text-blue-600">
|
||||
{user.cc_balance.toLocaleString()} CC
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{user.is_dealer ? (
|
||||
<span className="px-2 py-1 bg-green-100 text-green-700 rounded-full text-xs">Dealer</span>
|
||||
) : (
|
||||
<span className="px-2 py-1 bg-gray-100 text-gray-600 rounded-full text-xs">User</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">
|
||||
<div className="text-xs">
|
||||
<div>Code: {user.referral_code || '-'}</div>
|
||||
{user.referred_by && <div className="text-blue-500">By: {user.referred_by}</div>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">
|
||||
{formatDate(user.created_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedUser(user);
|
||||
setShowCCModal(true);
|
||||
}}
|
||||
className="px-3 py-1 text-xs bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||
>
|
||||
Adjust CC
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setDeleteUser(user);
|
||||
setShowDeleteModal(true);
|
||||
}}
|
||||
className="px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t">
|
||||
<div className="text-sm text-gray-500">
|
||||
Page {page} of {totalPages}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-3 py-1 border rounded hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="px-3 py-1 border rounded hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Deleted Users Tab */}
|
||||
{activeTab === 'deleted' && (
|
||||
<>
|
||||
{/* Search */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-4">
|
||||
<form onSubmit={handleDeletedSearch} className="flex flex-wrap gap-4">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<input
|
||||
type="text"
|
||||
value={deletedSearch}
|
||||
onChange={(e) => setDeletedSearch(e.target.value)}
|
||||
placeholder="Search deleted users by email, name, or phone..."
|
||||
className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-6 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Deleted Users Table */}
|
||||
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
|
||||
{deletedLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-red-500"></div>
|
||||
</div>
|
||||
) : deletedUsers.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
No deleted users found
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-red-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-red-600 uppercase">ID</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-red-600 uppercase">Email</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-red-600 uppercase">Name</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-red-600 uppercase">Phone</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-red-600 uppercase">CC Balance</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-red-600 uppercase">Deleted At</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-red-600 uppercase">Reason</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-red-600 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{deletedUsers.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-red-50">
|
||||
<td className="px-4 py-3 text-sm text-gray-800">{user.id}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-800">{user.email}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-800">{user.name || '-'}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-800">{user.phone || '-'}</td>
|
||||
<td className="px-4 py-3 text-sm font-semibold text-blue-600">
|
||||
{user.cc_balance.toLocaleString()} CC
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-red-600">
|
||||
{formatDate(user.deleted_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500 max-w-[200px] truncate">
|
||||
{user.withdrawal_reason || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleRestoreUser(user.id)}
|
||||
disabled={restoreLoading === user.id}
|
||||
className="px-3 py-1 text-xs bg-green-500 text-white rounded hover:bg-green-600 disabled:opacity-50"
|
||||
>
|
||||
{restoreLoading === user.id ? 'Restoring...' : 'Restore'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePermanentDelete(user)}
|
||||
disabled={restoreLoading === user.id}
|
||||
className="px-3 py-1 text-xs bg-red-700 text-white rounded hover:bg-red-800 disabled:opacity-50"
|
||||
>
|
||||
Permanent Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{deletedTotalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t">
|
||||
<div className="text-sm text-gray-500">
|
||||
Page {deletedPage} of {deletedTotalPages}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setDeletedPage(p => Math.max(1, p - 1))}
|
||||
disabled={deletedPage === 1}
|
||||
className="px-3 py-1 border rounded hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeletedPage(p => Math.min(deletedTotalPages, p + 1))}
|
||||
disabled={deletedPage === deletedTotalPages}
|
||||
className="px-3 py-1 border rounded hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* CC Adjustment Modal */}
|
||||
{showCCModal && selectedUser && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl p-6 w-full max-w-md mx-4">
|
||||
<h3 className="text-lg font-semibold mb-4">Adjust CC Balance</h3>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-gray-600">User: {selectedUser.email}</p>
|
||||
<p className="text-sm text-gray-600">Current Balance: {selectedUser.cc_balance.toLocaleString()} CC</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Amount (positive to add, negative to subtract)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={ccAmount}
|
||||
onChange={(e) => setCCAmount(e.target.value)}
|
||||
placeholder="e.g., 100 or -50"
|
||||
className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Reason (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={ccReason}
|
||||
onChange={(e) => setCCReason(e.target.value)}
|
||||
placeholder="e.g., Bonus credit, Refund, etc."
|
||||
className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 mt-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowCCModal(false);
|
||||
setCCAmount('');
|
||||
setCCReason('');
|
||||
}}
|
||||
className="flex-1 px-4 py-2 border rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAdjustCC}
|
||||
disabled={!ccAmount}
|
||||
className="flex-1 px-4 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600 disabled:opacity-50"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete User Modal */}
|
||||
{showDeleteModal && deleteUser && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl p-6 w-full max-w-md mx-4">
|
||||
<h3 className="text-lg font-semibold text-red-600 mb-4">Delete User</h3>
|
||||
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
|
||||
<p className="text-red-700 text-sm">
|
||||
Are you sure you want to delete this user?
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 space-y-2">
|
||||
<p className="text-sm text-gray-600"><span className="font-medium">Email:</span> {deleteUser.email}</p>
|
||||
<p className="text-sm text-gray-600"><span className="font-medium">Name:</span> {deleteUser.name || '-'}</p>
|
||||
<p className="text-sm text-gray-600"><span className="font-medium">CC Balance:</span> {deleteUser.cc_balance.toLocaleString()} CC</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hardDelete}
|
||||
onChange={(e) => setHardDelete(e.target.checked)}
|
||||
className="w-4 h-4 text-red-600 border-gray-300 rounded focus:ring-red-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">
|
||||
Permanently delete (cannot be recovered)
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mt-1 ml-6">
|
||||
{hardDelete
|
||||
? 'User and all related data will be permanently deleted from the database.'
|
||||
: 'User will be soft deleted (can be restored from Deleted Users tab).'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 mt-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowDeleteModal(false);
|
||||
setDeleteUser(null);
|
||||
setHardDelete(false);
|
||||
}}
|
||||
className="flex-1 px-4 py-2 border rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteUser}
|
||||
disabled={deleteLoading}
|
||||
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{deleteLoading ? 'Deleting...' : (hardDelete ? 'Delete Permanently' : 'Delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
525
frontend/src/app/admin/vehicle-requests/page.tsx
Normal file
525
frontend/src/app/admin/vehicle-requests/page.tsx
Normal file
@@ -0,0 +1,525 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { vehicleRequestsApi, carmodooApi, VehicleRequest, VehicleRequestWithVehicles, CarmodooSearchResult } from '@/lib/api';
|
||||
|
||||
const ITEMS_PER_PAGE = 12;
|
||||
|
||||
export default function AdminVehicleRequestsPage() {
|
||||
const [requests, setRequests] = useState<VehicleRequest[]>([]);
|
||||
const [selectedRequest, setSelectedRequest] = useState<VehicleRequestWithVehicles | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<CarmodooSearchResult[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [deletingVehicleId, setDeletingVehicleId] = useState<number | null>(null);
|
||||
|
||||
// Pagination state
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalResults, setTotalResults] = useState(0);
|
||||
|
||||
// Load requests
|
||||
useEffect(() => {
|
||||
loadRequests();
|
||||
}, [statusFilter]);
|
||||
|
||||
const loadRequests = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data = await vehicleRequestsApi.adminGetAllRequests(statusFilter || undefined);
|
||||
setRequests(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load requests:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load request detail
|
||||
const loadRequestDetail = async (requestId: number) => {
|
||||
try {
|
||||
const data = await vehicleRequestsApi.adminGetRequestDetail(requestId);
|
||||
setSelectedRequest(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load request detail:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Search for vehicles based on request criteria
|
||||
const searchVehicles = async (page: number = 1) => {
|
||||
if (!selectedRequest) return;
|
||||
|
||||
try {
|
||||
setIsSearching(true);
|
||||
const params: any = {
|
||||
page: page,
|
||||
page_size: 50, // Fetch more results
|
||||
};
|
||||
|
||||
if (selectedRequest.request.maker_code) params.maker_code = selectedRequest.request.maker_code;
|
||||
if (selectedRequest.request.model_code) params.model_code = selectedRequest.request.model_code;
|
||||
if (selectedRequest.request.grade_code) params.grade = selectedRequest.request.grade_code;
|
||||
if (selectedRequest.request.year_from) params.year_min = selectedRequest.request.year_from;
|
||||
if (selectedRequest.request.year_to) params.year_max = selectedRequest.request.year_to;
|
||||
if (selectedRequest.request.mileage_min) params.mileage_min = selectedRequest.request.mileage_min;
|
||||
if (selectedRequest.request.mileage_max) params.mileage_max = selectedRequest.request.mileage_max;
|
||||
if (selectedRequest.request.fuel) params.fuel = selectedRequest.request.fuel;
|
||||
|
||||
const result = await carmodooApi.requestSearch(params);
|
||||
setSearchResults(result.cars);
|
||||
setTotalResults(result.cars.length);
|
||||
setCurrentPage(page);
|
||||
} catch (error) {
|
||||
console.error('Failed to search vehicles:', error);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Get paginated results
|
||||
const getPaginatedResults = () => {
|
||||
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||
const endIndex = startIndex + ITEMS_PER_PAGE;
|
||||
return searchResults.slice(startIndex, endIndex);
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(searchResults.length / ITEMS_PER_PAGE);
|
||||
|
||||
// Add vehicle to request
|
||||
const addVehicleToRequest = async (car: CarmodooSearchResult) => {
|
||||
if (!selectedRequest) return;
|
||||
|
||||
try {
|
||||
await vehicleRequestsApi.adminAddVehicle(selectedRequest.request.id, {
|
||||
request_id: selectedRequest.request.id,
|
||||
car_data: car,
|
||||
is_approved: true, // Auto-approve when adding
|
||||
});
|
||||
|
||||
// Reload request detail
|
||||
await loadRequestDetail(selectedRequest.request.id);
|
||||
|
||||
// Remove from search results
|
||||
setSearchResults(prev => prev.filter(c => c.id !== car.id));
|
||||
} catch (error) {
|
||||
console.error('Failed to add vehicle:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Delete vehicle from request
|
||||
const deleteVehicleFromRequest = async (vehicleId: number) => {
|
||||
if (!selectedRequest) return;
|
||||
|
||||
if (!confirm('Are you sure you want to remove this vehicle from the recommendation list?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setDeletingVehicleId(vehicleId);
|
||||
await vehicleRequestsApi.adminDeleteVehicle(selectedRequest.request.id, vehicleId);
|
||||
|
||||
// Reload request detail
|
||||
await loadRequestDetail(selectedRequest.request.id);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete vehicle:', error);
|
||||
} finally {
|
||||
setDeletingVehicleId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Update request status
|
||||
const updateStatus = async (requestId: number, newStatus: string) => {
|
||||
try {
|
||||
await vehicleRequestsApi.adminUpdateRequestStatus(requestId, newStatus);
|
||||
loadRequests();
|
||||
if (selectedRequest?.request.id === requestId) {
|
||||
loadRequestDetail(requestId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update status:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
// Get status badge
|
||||
const getStatusBadge = (status: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
reviewed: 'bg-blue-100 text-blue-800',
|
||||
completed: 'bg-green-100 text-green-800',
|
||||
};
|
||||
const labels: Record<string, string> = {
|
||||
pending: 'Pending',
|
||||
reviewed: 'Reviewed',
|
||||
completed: 'Completed',
|
||||
};
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${colors[status] || 'bg-gray-100 text-gray-800'}`}>
|
||||
{labels[status] || status}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold text-gray-800">Vehicle Requests</h1>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="border border-gray-300 rounded-lg px-4 py-2"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="reviewed">Reviewed</option>
|
||||
<option value="completed">Completed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Requests List */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="p-4 border-b">
|
||||
<h2 className="font-semibold text-gray-700">Requests ({requests.length})</h2>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600 mx-auto"></div>
|
||||
</div>
|
||||
) : requests.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">No requests found</div>
|
||||
) : (
|
||||
<div className="divide-y max-h-[600px] overflow-y-auto">
|
||||
{requests.map((request) => (
|
||||
<div
|
||||
key={request.id}
|
||||
onClick={() => loadRequestDetail(request.id)}
|
||||
className={`p-4 cursor-pointer hover:bg-gray-50 transition ${
|
||||
selectedRequest?.request.id === request.id ? 'bg-primary-50' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-800">
|
||||
{request.maker_name} - {request.model_name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
User ID: {request.user_id}
|
||||
</p>
|
||||
</div>
|
||||
{getStatusBadge(request.status)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{request.year_from && request.year_to && (
|
||||
<span className="mr-4">Year: {request.year_from}-{request.year_to}</span>
|
||||
)}
|
||||
{request.mileage_max && (
|
||||
<span>Max Mileage: {Math.round(request.mileage_max / 10000)}만km</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{formatDate(request.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Request Detail */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="p-4 border-b">
|
||||
<h2 className="font-semibold text-gray-700">Request Detail</h2>
|
||||
</div>
|
||||
|
||||
{!selectedRequest ? (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
Select a request to view details
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Request Info */}
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h3 className="font-semibold text-gray-700 mb-3">Search Criteria</h3>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">Maker:</span>
|
||||
<span className="ml-2 font-medium">{selectedRequest.request.maker_name}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Model:</span>
|
||||
<span className="ml-2 font-medium">{selectedRequest.request.model_name}</span>
|
||||
</div>
|
||||
{selectedRequest.request.grade_name && (
|
||||
<div>
|
||||
<span className="text-gray-500">Grade:</span>
|
||||
<span className="ml-2 font-medium">{selectedRequest.request.grade_name}</span>
|
||||
</div>
|
||||
)}
|
||||
{(selectedRequest.request.year_from || selectedRequest.request.year_to) && (
|
||||
<div>
|
||||
<span className="text-gray-500">Year:</span>
|
||||
<span className="ml-2 font-medium">
|
||||
{selectedRequest.request.year_from || '-'} ~ {selectedRequest.request.year_to || '-'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{(selectedRequest.request.mileage_min || selectedRequest.request.mileage_max) && (
|
||||
<div>
|
||||
<span className="text-gray-500">Mileage:</span>
|
||||
<span className="ml-2 font-medium">
|
||||
{selectedRequest.request.mileage_min ? Math.round(selectedRequest.request.mileage_min / 10000) : '-'} ~{' '}
|
||||
{selectedRequest.request.mileage_max ? Math.round(selectedRequest.request.mileage_max / 10000) : '-'} 만km
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{selectedRequest.request.fuel && (
|
||||
<div>
|
||||
<span className="text-gray-500">Fuel:</span>
|
||||
<span className="ml-2 font-medium">{selectedRequest.request.fuel}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500">Status:</span>
|
||||
{getStatusBadge(selectedRequest.request.status)}
|
||||
<select
|
||||
value={selectedRequest.request.status}
|
||||
onChange={(e) => updateStatus(selectedRequest.request.id, e.target.value)}
|
||||
className="ml-auto border border-gray-300 rounded px-3 py-1 text-sm"
|
||||
>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="reviewed">Reviewed</option>
|
||||
<option value="completed">Completed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Approved Vehicles */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-semibold text-gray-700">
|
||||
Recommended Vehicles ({selectedRequest.approved_vehicles.length})
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAddModal(true);
|
||||
setCurrentPage(1);
|
||||
searchVehicles(1);
|
||||
}}
|
||||
className="bg-primary-600 text-white px-3 py-1 rounded text-sm hover:bg-primary-700"
|
||||
>
|
||||
+ Add Vehicle
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{selectedRequest.approved_vehicles.length === 0 ? (
|
||||
<div className="bg-gray-50 rounded-lg p-4 text-center text-gray-500 text-sm">
|
||||
No vehicles added yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-[300px] overflow-y-auto">
|
||||
{selectedRequest.approved_vehicles.map((vehicle) => (
|
||||
<div key={vehicle.id} className="bg-gray-50 rounded-lg p-3 flex items-center gap-3">
|
||||
{vehicle.car_data.main_image && (
|
||||
<img
|
||||
src={vehicle.car_data.main_image}
|
||||
alt=""
|
||||
className="w-16 h-12 object-cover rounded"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm truncate">{vehicle.car_data.car_name}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{vehicle.car_data.year} | {vehicle.car_data.mileage?.toLocaleString()}km
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-primary-600">
|
||||
{vehicle.car_data.final_price?.toLocaleString()}만원
|
||||
</p>
|
||||
<button
|
||||
onClick={() => deleteVehicleFromRequest(vehicle.id)}
|
||||
disabled={deletingVehicleId === vehicle.id}
|
||||
className="text-red-500 hover:text-red-700 p-1 disabled:opacity-50"
|
||||
title="Remove from list"
|
||||
>
|
||||
{deletingVehicleId === vehicle.id ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-red-500"></div>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Vehicle Modal */}
|
||||
{showAddModal && selectedRequest && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-lg max-w-5xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div className="p-4 border-b flex items-center justify-between shrink-0">
|
||||
<h3 className="font-semibold text-gray-800">Search & Add Vehicles</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAddModal(false);
|
||||
setSearchResults([]);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="text-gray-500 hover:text-gray-700 text-2xl"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-b shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">
|
||||
Searching for: <span className="font-medium">{selectedRequest.request.maker_name} {selectedRequest.request.model_name}</span>
|
||||
</p>
|
||||
{searchResults.length > 0 && (
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Found {searchResults.length} vehicles (showing {Math.min((currentPage - 1) * ITEMS_PER_PAGE + 1, searchResults.length)}-{Math.min(currentPage * ITEMS_PER_PAGE, searchResults.length)})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => searchVehicles(1)}
|
||||
disabled={isSearching}
|
||||
className="bg-primary-600 text-white px-4 py-2 rounded hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{isSearching ? 'Searching...' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{isSearching ? (
|
||||
<div className="p-8 text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600 mx-auto"></div>
|
||||
<p className="mt-2 text-gray-500">Searching from Carmodoo...</p>
|
||||
</div>
|
||||
) : searchResults.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">No vehicles found</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{getPaginatedResults().map((car) => (
|
||||
<div key={car.id} className="border rounded-lg p-3 hover:shadow-md transition">
|
||||
{car.main_image && (
|
||||
<img
|
||||
src={car.main_image}
|
||||
alt=""
|
||||
className="w-full h-32 object-cover rounded mb-2"
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium text-sm truncate" title={car.car_name}>{car.car_name}</p>
|
||||
<div className="text-xs text-gray-500 space-y-0.5">
|
||||
<p>{car.year}년 | {car.mileage?.toLocaleString()}km</p>
|
||||
<p>{car.fuel} | {car.transmission}</p>
|
||||
{car.color && <p>Color: {car.color}</p>}
|
||||
</div>
|
||||
<p className="text-sm font-bold text-primary-600">
|
||||
{car.final_price?.toLocaleString()}만원
|
||||
</p>
|
||||
<button
|
||||
onClick={() => addVehicleToRequest(car)}
|
||||
className="w-full bg-green-600 text-white px-3 py-1.5 rounded text-sm hover:bg-green-700 mt-2"
|
||||
>
|
||||
+ Add to List
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{searchResults.length > ITEMS_PER_PAGE && (
|
||||
<div className="p-4 border-t shrink-0 flex items-center justify-center gap-2">
|
||||
<button
|
||||
onClick={() => setCurrentPage(1)}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-1 border rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
|
||||
>
|
||||
«
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-1 border rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
|
||||
{/* Page numbers */}
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||
.filter(page => {
|
||||
// Show first, last, current, and pages near current
|
||||
return page === 1 || page === totalPages || Math.abs(page - currentPage) <= 2;
|
||||
})
|
||||
.map((page, index, array) => (
|
||||
<span key={page}>
|
||||
{index > 0 && array[index - 1] !== page - 1 && (
|
||||
<span className="px-2 text-gray-400">...</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setCurrentPage(page)}
|
||||
className={`px-3 py-1 border rounded ${
|
||||
currentPage === page
|
||||
? 'bg-primary-600 text-white border-primary-600'
|
||||
: 'hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-3 py-1 border rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage(totalPages)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-3 py-1 border rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
|
||||
>
|
||||
»
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
410
frontend/src/app/admin/visitor-stats/page.tsx
Normal file
410
frontend/src/app/admin/visitor-stats/page.tsx
Normal file
@@ -0,0 +1,410 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { visitorApi, VisitorStatsOverview, TopPage, TopReferrer, ChartData, RealtimeStats } from '@/lib/api';
|
||||
|
||||
// Country code to name mapping
|
||||
const countryNames: Record<string, string> = {
|
||||
MN: 'Mongolia',
|
||||
RU: 'Russia',
|
||||
KR: 'Korea',
|
||||
US: 'United States',
|
||||
CN: 'China',
|
||||
JP: 'Japan',
|
||||
DE: 'Germany',
|
||||
FR: 'France',
|
||||
GB: 'United Kingdom',
|
||||
AU: 'Australia',
|
||||
unknown: 'Unknown',
|
||||
LO: 'Local',
|
||||
};
|
||||
|
||||
// Simple bar chart component
|
||||
const SimpleBarChart = ({ data, color = 'bg-primary-500', height = 120 }: {
|
||||
data: ChartData | null;
|
||||
color?: string;
|
||||
height?: number;
|
||||
}) => {
|
||||
if (!data || data.values.length === 0) {
|
||||
return <div className="text-gray-400 text-center py-4">No data</div>;
|
||||
}
|
||||
|
||||
const maxValue = Math.max(...data.values, 1);
|
||||
|
||||
return (
|
||||
<div className="flex items-end gap-1" style={{ height }}>
|
||||
{data.values.map((value, index) => (
|
||||
<div key={index} className="flex-1 flex flex-col items-center group relative">
|
||||
<div className="hidden group-hover:block absolute -top-8 bg-gray-800 text-white text-xs px-2 py-1 rounded z-10 whitespace-nowrap">
|
||||
{data.labels[index]}: {value}
|
||||
</div>
|
||||
<div
|
||||
className={`w-full ${color} rounded-t transition-all hover:opacity-80`}
|
||||
style={{ height: `${(value / maxValue) * 100}%`, minHeight: value > 0 ? '4px' : '0' }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Breakdown card component
|
||||
const BreakdownCard = ({ title, data, icon, nameMap }: {
|
||||
title: string;
|
||||
data: Record<string, number>;
|
||||
icon: string;
|
||||
nameMap?: Record<string, string>;
|
||||
}) => {
|
||||
const total = Object.values(data).reduce((a, b) => a + b, 0);
|
||||
const sortedEntries = Object.entries(data).sort((a, b) => b[1] - a[1]).slice(0, 5);
|
||||
|
||||
const colors = ['bg-blue-500', 'bg-green-500', 'bg-purple-500', 'bg-amber-500', 'bg-red-500'];
|
||||
|
||||
if (total === 0) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="text-xl">{icon}</span>
|
||||
<h3 className="font-semibold text-gray-800">{title}</h3>
|
||||
</div>
|
||||
<div className="text-gray-400 text-center py-4">No data</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="text-xl">{icon}</span>
|
||||
<h3 className="font-semibold text-gray-800">{title}</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{sortedEntries.map(([key, value], index) => {
|
||||
const displayName = nameMap ? (nameMap[key] || key) : key;
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-3">
|
||||
<div className={`w-3 h-3 rounded-full ${colors[index] || 'bg-gray-400'}`} />
|
||||
<div className="flex-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-700">{displayName || 'Unknown'}</span>
|
||||
<span className="text-gray-500">{value} ({((value / total) * 100).toFixed(1)}%)</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-1.5 mt-1">
|
||||
<div
|
||||
className={`h-1.5 rounded-full ${colors[index] || 'bg-gray-400'}`}
|
||||
style={{ width: `${(value / total) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function VisitorStatsPage() {
|
||||
const [overview, setOverview] = useState<VisitorStatsOverview | null>(null);
|
||||
const [visitsChart, setVisitsChart] = useState<ChartData | null>(null);
|
||||
const [uniqueChart, setUniqueChart] = useState<ChartData | null>(null);
|
||||
const [topPages, setTopPages] = useState<TopPage[]>([]);
|
||||
const [topReferrers, setTopReferrers] = useState<TopReferrer[]>([]);
|
||||
const [realtime, setRealtime] = useState<RealtimeStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [days, setDays] = useState(30);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [days]);
|
||||
|
||||
// Realtime refresh every 30 seconds
|
||||
useEffect(() => {
|
||||
const interval = setInterval(loadRealtime, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const loadRealtime = async () => {
|
||||
try {
|
||||
const realtimeData = await visitorApi.getRealtime(5);
|
||||
setRealtime(realtimeData);
|
||||
} catch (error) {
|
||||
console.error('Failed to load realtime stats:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [overviewData, visitsData, uniqueData, pagesData, referrersData, realtimeData] = await Promise.all([
|
||||
visitorApi.getOverview(days),
|
||||
visitorApi.getVisitsChart(days),
|
||||
visitorApi.getUniqueVisitorsChart(days),
|
||||
visitorApi.getTopPages(days),
|
||||
visitorApi.getTopReferrers(days),
|
||||
visitorApi.getRealtime(5),
|
||||
]);
|
||||
setOverview(overviewData);
|
||||
setVisitsChart(visitsData);
|
||||
setUniqueChart(uniqueData);
|
||||
setTopPages(pagesData);
|
||||
setTopReferrers(referrersData);
|
||||
setRealtime(realtimeData);
|
||||
} catch (error) {
|
||||
console.error('Failed to load visitor stats:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatNumber = (num: number) => new Intl.NumberFormat('ko-KR').format(num);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-800">Visitor Statistics</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<select
|
||||
value={days}
|
||||
onChange={(e) => setDays(Number(e.target.value))}
|
||||
className="px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value={7}>Last 7 days</option>
|
||||
<option value={14}>Last 14 days</option>
|
||||
<option value={30}>Last 30 days</option>
|
||||
<option value={90}>Last 90 days</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm transition-colors"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Realtime Stats */}
|
||||
{realtime && (
|
||||
<div className="bg-gradient-to-r from-green-500 to-emerald-600 rounded-xl shadow-sm p-5 text-white">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-3 h-3 bg-white rounded-full animate-pulse"></div>
|
||||
<span className="font-semibold">Real-time Visitors</span>
|
||||
</div>
|
||||
<span className="text-3xl font-bold">{realtime.active_visitors}</span>
|
||||
</div>
|
||||
<p className="text-green-100 text-sm mt-1">Active in the last {realtime.minutes} minutes</p>
|
||||
{realtime.recent_pages.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-green-400/30">
|
||||
<p className="text-green-100 text-xs mb-2">Active Pages:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{realtime.recent_pages.slice(0, 3).map((page, i) => (
|
||||
<span key={i} className="bg-white/20 px-2 py-1 rounded text-xs">
|
||||
{page.path} ({page.views})
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overview Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl shadow-sm p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-500 text-sm">Total Page Views</p>
|
||||
<p className="text-2xl font-bold text-gray-800 mt-1">
|
||||
{formatNumber(overview?.total_visits || 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center text-2xl">
|
||||
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-500 text-sm">Unique Visitors</p>
|
||||
<p className="text-2xl font-bold text-gray-800 mt-1">
|
||||
{formatNumber(overview?.unique_visitors || 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center text-2xl">
|
||||
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-500 text-sm">Pages per Visit</p>
|
||||
<p className="text-2xl font-bold text-gray-800 mt-1">
|
||||
{overview && overview.unique_visitors > 0
|
||||
? (overview.total_visits / overview.unique_visitors).toFixed(1)
|
||||
: '0'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center text-2xl">
|
||||
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-500 text-sm">Mobile Visitors</p>
|
||||
<p className="text-2xl font-bold text-gray-800 mt-1">
|
||||
{overview?.device_breakdown?.mobile && overview.total_visits > 0
|
||||
? `${((overview.device_breakdown.mobile / overview.total_visits) * 100).toFixed(0)}%`
|
||||
: '0%'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-amber-100 rounded-lg flex items-center justify-center text-2xl">
|
||||
<svg className="w-6 h-6 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-white rounded-xl shadow-sm p-5">
|
||||
<h3 className="font-semibold text-gray-800 mb-4">Page Views</h3>
|
||||
<SimpleBarChart data={visitsChart} color="bg-blue-500" height={120} />
|
||||
{visitsChart && visitsChart.labels.length > 0 && (
|
||||
<div className="flex justify-between mt-2 text-xs text-gray-400">
|
||||
<span>{visitsChart.labels[0]}</span>
|
||||
<span>{visitsChart.labels[visitsChart.labels.length - 1]}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm p-5">
|
||||
<h3 className="font-semibold text-gray-800 mb-4">Unique Visitors</h3>
|
||||
<SimpleBarChart data={uniqueChart} color="bg-green-500" height={120} />
|
||||
{uniqueChart && uniqueChart.labels.length > 0 && (
|
||||
<div className="flex justify-between mt-2 text-xs text-gray-400">
|
||||
<span>{uniqueChart.labels[0]}</span>
|
||||
<span>{uniqueChart.labels[uniqueChart.labels.length - 1]}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Breakdowns */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<BreakdownCard
|
||||
title="By Device"
|
||||
data={overview?.device_breakdown || {}}
|
||||
icon="💻"
|
||||
/>
|
||||
<BreakdownCard
|
||||
title="By Browser"
|
||||
data={overview?.browser_breakdown || {}}
|
||||
icon="🌐"
|
||||
/>
|
||||
<BreakdownCard
|
||||
title="By Country"
|
||||
data={overview?.country_breakdown || {}}
|
||||
icon="🌍"
|
||||
nameMap={countryNames}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tables */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Top Pages */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-5">
|
||||
<h3 className="font-semibold text-gray-800 mb-4">Top Pages</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-gray-500 uppercase border-b">
|
||||
<th className="pb-2 pr-2">#</th>
|
||||
<th className="pb-2">Page</th>
|
||||
<th className="pb-2 text-right">Views</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{topPages.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={3} className="py-4 text-center text-gray-400">
|
||||
No data
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
topPages.slice(0, 10).map((page, index) => (
|
||||
<tr key={index} className="text-sm hover:bg-gray-50">
|
||||
<td className="py-2 pr-2 text-gray-400">{index + 1}</td>
|
||||
<td className="py-2 text-gray-700 truncate max-w-[250px]" title={page.path}>
|
||||
{page.path}
|
||||
</td>
|
||||
<td className="py-2 text-right text-gray-600 font-medium">{formatNumber(page.views)}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Referrers */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-5">
|
||||
<h3 className="font-semibold text-gray-800 mb-4">Top Referrers</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-gray-500 uppercase border-b">
|
||||
<th className="pb-2 pr-2">#</th>
|
||||
<th className="pb-2">Source</th>
|
||||
<th className="pb-2 text-right">Visits</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{topReferrers.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={3} className="py-4 text-center text-gray-400">
|
||||
No referrer data (direct visits)
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
topReferrers.map((ref, index) => (
|
||||
<tr key={index} className="text-sm hover:bg-gray-50">
|
||||
<td className="py-2 pr-2 text-gray-400">{index + 1}</td>
|
||||
<td className="py-2 text-gray-700">{ref.domain}</td>
|
||||
<td className="py-2 text-right text-gray-600 font-medium">{formatNumber(ref.visits)}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
356
frontend/src/app/admin/withdrawals/page.tsx
Normal file
356
frontend/src/app/admin/withdrawals/page.tsx
Normal file
@@ -0,0 +1,356 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
import { withdrawalApi, WithdrawalRequest } from '@/lib/api';
|
||||
|
||||
export default function AdminWithdrawalsPage() {
|
||||
const { t, language } = useTranslation();
|
||||
const { user, token } = useAuthStore();
|
||||
const router = useRouter();
|
||||
|
||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||
const [requests, setRequests] = useState<WithdrawalRequest[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState<number | null>(null);
|
||||
const [processModal, setProcessModal] = useState<{
|
||||
id: number;
|
||||
status: string;
|
||||
note: string;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.is_admin) {
|
||||
router.push('/');
|
||||
return;
|
||||
}
|
||||
|
||||
fetchData();
|
||||
}, [user, router, statusFilter]);
|
||||
|
||||
const fetchData = async () => {
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const data = await withdrawalApi.adminGetAllRequests(statusFilter || undefined);
|
||||
setRequests(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProcess = async () => {
|
||||
if (!token || !processModal) return;
|
||||
setActionLoading(processModal.id);
|
||||
|
||||
try {
|
||||
await withdrawalApi.adminProcessRequest(processModal.id, {
|
||||
status: processModal.status,
|
||||
admin_note: processModal.note || undefined,
|
||||
});
|
||||
|
||||
setProcessModal(null);
|
||||
fetchData();
|
||||
} catch (error: any) {
|
||||
alert(error.response?.data?.detail || 'Failed to process');
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('ko-KR').format(amount);
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return <span className="px-2 py-1 bg-yellow-100 text-yellow-700 rounded text-sm">{t.withdrawalPending}</span>;
|
||||
case 'approved':
|
||||
return <span className="px-2 py-1 bg-blue-100 text-blue-700 rounded text-sm">{t.withdrawalApproved}</span>;
|
||||
case 'completed':
|
||||
return <span className="px-2 py-1 bg-green-100 text-green-700 rounded text-sm">{t.withdrawalCompleted}</span>;
|
||||
case 'rejected':
|
||||
return <span className="px-2 py-1 bg-red-100 text-red-700 rounded text-sm">{t.withdrawalRejected}</span>;
|
||||
default:
|
||||
return <span className="px-2 py-1 bg-gray-100 text-gray-700 rounded text-sm">{status}</span>;
|
||||
}
|
||||
};
|
||||
|
||||
const pendingCount = requests.filter(r => r.status === 'pending').length;
|
||||
|
||||
if (!user?.is_admin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 py-8">
|
||||
<div className="container mx-auto px-4">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-800">
|
||||
{language === 'ko' ? '출금 관리' : 'Withdrawal Management'}
|
||||
</h1>
|
||||
{pendingCount > 0 && (
|
||||
<p className="text-orange-600 mt-1">
|
||||
{language === 'ko'
|
||||
? `${pendingCount}건의 대기 중인 출금 요청이 있습니다`
|
||||
: `${pendingCount} pending withdrawal request(s)`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Tabs */}
|
||||
<div className="flex gap-2 mb-6 flex-wrap">
|
||||
<button
|
||||
onClick={() => setStatusFilter('')}
|
||||
className={`px-4 py-2 rounded-lg transition ${
|
||||
statusFilter === ''
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{language === 'ko' ? '전체' : 'All'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('pending')}
|
||||
className={`px-4 py-2 rounded-lg transition ${
|
||||
statusFilter === 'pending'
|
||||
? 'bg-yellow-500 text-white'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{t.withdrawalPending}
|
||||
{pendingCount > 0 && (
|
||||
<span className="ml-2 px-2 py-0.5 bg-red-500 text-white text-xs rounded-full">
|
||||
{pendingCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('approved')}
|
||||
className={`px-4 py-2 rounded-lg transition ${
|
||||
statusFilter === 'approved'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{t.withdrawalApproved}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('completed')}
|
||||
className={`px-4 py-2 rounded-lg transition ${
|
||||
statusFilter === 'completed'
|
||||
? 'bg-green-500 text-white'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{t.withdrawalCompleted}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('rejected')}
|
||||
className={`px-4 py-2 rounded-lg transition ${
|
||||
statusFilter === 'rejected'
|
||||
? 'bg-red-500 text-white'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{t.withdrawalRejected}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-xl shadow-lg overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">ID</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">
|
||||
{language === 'ko' ? '유저ID' : 'User ID'}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">
|
||||
{t.withdrawalAmount}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">
|
||||
{t.taxWithheld}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">
|
||||
{t.netAmount}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">
|
||||
{language === 'ko' ? '은행/계좌' : 'Bank/Account'}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">
|
||||
{language === 'ko' ? '신청일' : 'Requested'}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">
|
||||
{t.status}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">
|
||||
{language === 'ko' ? '액션' : 'Action'}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{requests.map((request) => (
|
||||
<tr key={request.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-sm">{request.id}</td>
|
||||
<td className="px-4 py-3 text-sm">{request.user_id}</td>
|
||||
<td className="px-4 py-3 text-sm font-medium">
|
||||
{formatCurrency(request.amount)}원
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-red-600">
|
||||
-{formatCurrency(request.tax_withheld)}원
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm font-bold text-primary-600">
|
||||
{formatCurrency(request.net_amount)}원
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<div className="font-medium">{request.bank_name}</div>
|
||||
<div className="text-gray-500 text-xs font-mono">{request.bank_account}</div>
|
||||
<div className="text-gray-500 text-xs">{request.account_holder}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">
|
||||
{new Date(request.requested_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{getStatusBadge(request.status)}
|
||||
{request.admin_note && (
|
||||
<div className="text-xs text-gray-500 mt-1" title={request.admin_note}>
|
||||
{request.admin_note.substring(0, 20)}...
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{request.status === 'pending' && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setProcessModal({
|
||||
id: request.id,
|
||||
status: 'approved',
|
||||
note: '',
|
||||
})}
|
||||
className="px-3 py-1 bg-blue-600 text-white text-sm rounded hover:bg-blue-700"
|
||||
>
|
||||
{language === 'ko' ? '승인' : 'Approve'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setProcessModal({
|
||||
id: request.id,
|
||||
status: 'rejected',
|
||||
note: '',
|
||||
})}
|
||||
className="px-3 py-1 bg-red-600 text-white text-sm rounded hover:bg-red-700"
|
||||
>
|
||||
{language === 'ko' ? '거부' : 'Reject'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{request.status === 'approved' && (
|
||||
<button
|
||||
onClick={() => setProcessModal({
|
||||
id: request.id,
|
||||
status: 'completed',
|
||||
note: '',
|
||||
})}
|
||||
className="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700"
|
||||
>
|
||||
{language === 'ko' ? '완료 처리' : 'Complete'}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{requests.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={9} className="px-4 py-8 text-center text-gray-500">
|
||||
{t.noWithdrawalHistory}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Process Modal */}
|
||||
{processModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl p-6 max-w-md w-full mx-4">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{processModal.status === 'approved' && (language === 'ko' ? '출금 승인' : 'Approve Withdrawal')}
|
||||
{processModal.status === 'completed' && (language === 'ko' ? '출금 완료 처리' : 'Complete Withdrawal')}
|
||||
{processModal.status === 'rejected' && (language === 'ko' ? '출금 거부' : 'Reject Withdrawal')}
|
||||
</h3>
|
||||
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-gray-600 mb-2">
|
||||
{language === 'ko' ? '관리자 메모 (선택사항)' : 'Admin note (optional)'}
|
||||
</p>
|
||||
<textarea
|
||||
value={processModal.note}
|
||||
onChange={(e) => setProcessModal({ ...processModal, note: e.target.value })}
|
||||
placeholder={
|
||||
processModal.status === 'rejected'
|
||||
? (language === 'ko' ? '거부 사유를 입력하세요...' : 'Enter rejection reason...')
|
||||
: (language === 'ko' ? '메모를 입력하세요...' : 'Enter note...')
|
||||
}
|
||||
className="w-full p-3 border border-gray-300 rounded-lg h-24 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{processModal.status === 'completed' && (
|
||||
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg text-sm text-yellow-700">
|
||||
{language === 'ko'
|
||||
? '완료 처리 후에는 취소할 수 없습니다. 실제 입금 후 처리해주세요.'
|
||||
: 'This action cannot be undone. Please confirm payment has been made.'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
onClick={() => setProcessModal(null)}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
{language === 'ko' ? '취소' : 'Cancel'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleProcess}
|
||||
disabled={actionLoading === processModal.id}
|
||||
className={`px-4 py-2 text-white rounded-lg disabled:opacity-50 ${
|
||||
processModal.status === 'rejected'
|
||||
? 'bg-red-600 hover:bg-red-700'
|
||||
: processModal.status === 'completed'
|
||||
? 'bg-green-600 hover:bg-green-700'
|
||||
: 'bg-blue-600 hover:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
{actionLoading === processModal.id
|
||||
? '...'
|
||||
: processModal.status === 'approved'
|
||||
? (language === 'ko' ? '승인' : 'Approve')
|
||||
: processModal.status === 'completed'
|
||||
? (language === 'ko' ? '완료' : 'Complete')
|
||||
: (language === 'ko' ? '거부' : 'Reject')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1073
frontend/src/app/cars/[id]/page.tsx
Normal file
1073
frontend/src/app/cars/[id]/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
46
frontend/src/app/cars/page.tsx
Normal file
46
frontend/src/app/cars/page.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
|
||||
/**
|
||||
* /cars 페이지 - 관리자 전용
|
||||
*
|
||||
* 비즈니스 로직:
|
||||
* - 일반 유저는 Cars 페이지 접근 불가
|
||||
* - 일반 유저는 배너에 등록된 프로모션 차량만 볼 수 있음
|
||||
* - 관리자만 Carmodoo에서 차량 검색 가능
|
||||
*
|
||||
* 이 페이지는 관리자를 /admin/cars로 리다이렉트합니다.
|
||||
*/
|
||||
export default function CarsPage() {
|
||||
const router = useRouter();
|
||||
const { user, token } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
// 로그인하지 않은 경우 로그인 페이지로
|
||||
if (!token) {
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// 관리자인 경우 /admin/cars로 리다이렉트
|
||||
if (user?.is_admin) {
|
||||
router.push('/admin/cars');
|
||||
return;
|
||||
}
|
||||
|
||||
// 일반 유저인 경우 홈으로 리다이렉트
|
||||
router.push('/');
|
||||
}, [user, token, router]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 border-4 border-primary-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">Redirecting...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
416
frontend/src/app/cc/page.tsx
Normal file
416
frontend/src/app/cc/page.tsx
Normal file
@@ -0,0 +1,416 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
import SidebarLayout from '@/components/SidebarLayout';
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
||||
|
||||
interface CCPackage {
|
||||
id: number;
|
||||
name: string;
|
||||
price_usd: number;
|
||||
cc_amount: number;
|
||||
bonus_cc: number;
|
||||
total_cc: number;
|
||||
discount_percent: number;
|
||||
recommendations: number;
|
||||
cars_per_cc: number;
|
||||
}
|
||||
|
||||
interface ChargeHistory {
|
||||
id: number;
|
||||
amount: number;
|
||||
currency: string;
|
||||
cc_amount: number;
|
||||
bonus_cc?: number;
|
||||
payment_method: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export default function CCPurchasePage() {
|
||||
const { language } = useTranslation();
|
||||
const { user, token, isLoading: authLoading } = useAuthStore();
|
||||
const router = useRouter();
|
||||
|
||||
const [packages, setPackages] = useState<CCPackage[]>([]);
|
||||
const [ccBalance, setCcBalance] = useState<number>(0);
|
||||
const [chargeHistory, setChargeHistory] = useState<ChargeHistory[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [purchasing, setPurchasing] = useState<number | null>(null);
|
||||
const [selectedPackage, setSelectedPackage] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading) return; // Wait for auth state to load
|
||||
if (!user) {
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
loadData();
|
||||
}, [user, router, authLoading]);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const [packagesRes, balanceRes, historyRes] = await Promise.all([
|
||||
fetch(`${API_BASE_URL}/api/cc/packages`),
|
||||
fetch(`${API_BASE_URL}/api/cc/balance`, { headers }),
|
||||
fetch(`${API_BASE_URL}/api/cc/charge-history`, { headers }),
|
||||
]);
|
||||
|
||||
if (packagesRes.ok) {
|
||||
const pkgs = await packagesRes.json();
|
||||
setPackages(pkgs);
|
||||
if (pkgs.length > 0) {
|
||||
setSelectedPackage(pkgs[1]?.id || pkgs[0]?.id); // Default to Standard
|
||||
}
|
||||
}
|
||||
|
||||
if (balanceRes.ok) {
|
||||
const balance = await balanceRes.json();
|
||||
setCcBalance(balance.cc_balance || 0);
|
||||
}
|
||||
|
||||
if (historyRes.ok) {
|
||||
const history = await historyRes.json();
|
||||
setChargeHistory(history);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePurchase = async (packageId: number) => {
|
||||
if (!token) {
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
setPurchasing(packageId);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/cc/create-checkout-session`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ package_id: packageId }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// Redirect to Stripe Checkout
|
||||
window.location.href = data.checkout_url;
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.detail || 'Failed to create checkout session');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Purchase error:', error);
|
||||
alert('Failed to process payment');
|
||||
} finally {
|
||||
setPurchasing(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleManualRequest = async (packageId: number) => {
|
||||
if (!token) {
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
const note = prompt(
|
||||
language === 'ko'
|
||||
? '결제 메모를 입력하세요 (예: 몽골 파트너 계좌로 입금)'
|
||||
: 'Enter payment note (e.g., Paid via Mongolian partner account)'
|
||||
);
|
||||
|
||||
if (note === null) return; // User cancelled
|
||||
|
||||
setPurchasing(packageId);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/cc/manual-request`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ package_id: packageId, payment_note: note }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
alert(
|
||||
language === 'ko'
|
||||
? '요청이 제출되었습니다. 관리자 확인 후 CC가 충전됩니다.'
|
||||
: 'Request submitted. CC will be credited after admin verification.'
|
||||
);
|
||||
loadData();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.detail || 'Failed to submit request');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Manual request error:', error);
|
||||
alert('Failed to submit request');
|
||||
} finally {
|
||||
setPurchasing(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return (
|
||||
<span className="px-2 py-1 bg-green-100 text-green-700 rounded-full text-xs">
|
||||
{language === 'ko' ? '완료' : 'Completed'}
|
||||
</span>
|
||||
);
|
||||
case 'pending':
|
||||
return (
|
||||
<span className="px-2 py-1 bg-yellow-100 text-yellow-700 rounded-full text-xs">
|
||||
{language === 'ko' ? '대기' : 'Pending'}
|
||||
</span>
|
||||
);
|
||||
case 'cancelled':
|
||||
return (
|
||||
<span className="px-2 py-1 bg-gray-100 text-gray-700 rounded-full text-xs">
|
||||
{language === 'ko' ? '취소됨' : 'Cancelled'}
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<span className="px-2 py-1 bg-gray-100 text-gray-700 rounded-full text-xs">
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (authLoading || !user) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarLayout groupKey="billing">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-800">
|
||||
{language === 'ko' ? 'CC 충전' : 'Purchase CC'}
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-2">
|
||||
{language === 'ko'
|
||||
? 'CC를 충전하여 차량 추천 서비스를 이용하세요'
|
||||
: 'Purchase CC to use car recommendation services'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Balance Card */}
|
||||
<div className="bg-gradient-to-r from-primary-600 to-primary-700 rounded-xl p-6 text-white mb-8 shadow-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-primary-100">
|
||||
{language === 'ko' ? '현재 잔액' : 'Current Balance'}
|
||||
</p>
|
||||
<p className="text-4xl font-bold mt-2">{ccBalance.toFixed(1)} CC</p>
|
||||
<p className="text-primary-200 mt-2 text-sm">
|
||||
{language === 'ko'
|
||||
? `1 CC = ${packages[0]?.cars_per_cc || 3}대 차량 추천`
|
||||
: language === 'mn'
|
||||
? `1 CC = ${packages[0]?.cars_per_cc || 3} машины санал`
|
||||
: language === 'ru'
|
||||
? `1 CC = ${packages[0]?.cars_per_cc || 3} рекомендаций авто`
|
||||
: `1 CC = ${packages[0]?.cars_per_cc || 3} car recommendations`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-6xl opacity-50">💳</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Package Cards */}
|
||||
<div className="mb-8">
|
||||
<h2 className="text-xl font-semibold mb-4">
|
||||
{language === 'ko' ? '패키지 선택' : 'Select Package'}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{packages.map((pkg) => (
|
||||
<div
|
||||
key={pkg.id}
|
||||
onClick={() => setSelectedPackage(pkg.id)}
|
||||
className={`relative bg-white rounded-xl shadow-lg p-6 cursor-pointer transition-all ${
|
||||
selectedPackage === pkg.id
|
||||
? 'ring-2 ring-primary-500 transform scale-[1.02]'
|
||||
: 'hover:shadow-xl'
|
||||
}`}
|
||||
>
|
||||
{pkg.discount_percent > 0 && (
|
||||
<div className="absolute -top-3 -right-3 bg-red-500 text-white px-3 py-1 rounded-full text-sm font-bold">
|
||||
{pkg.discount_percent}% OFF
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-center mb-4">
|
||||
<h3 className="text-xl font-bold text-gray-800">{pkg.name}</h3>
|
||||
<p className="text-3xl font-bold text-primary-600 mt-2">${pkg.price_usd}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 mb-6">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">CC</span>
|
||||
<span className="font-semibold">{pkg.cc_amount} CC</span>
|
||||
</div>
|
||||
{pkg.bonus_cc > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">
|
||||
{language === 'ko' ? '보너스' : 'Bonus'}
|
||||
</span>
|
||||
<span className="font-semibold text-green-600">+{pkg.bonus_cc} CC</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between text-sm border-t pt-3">
|
||||
<span className="text-gray-600">
|
||||
{language === 'ko' ? '총 CC' : 'Total CC'}
|
||||
</span>
|
||||
<span className="font-bold text-lg">{pkg.total_cc} CC</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm text-gray-500">
|
||||
<span>{language === 'ko' ? '추천 가능' : 'Recommendations'}</span>
|
||||
<span>
|
||||
{pkg.recommendations}
|
||||
{language === 'ko' ? '대' : language === 'mn' ? ' машин' : language === 'ru' ? ' авто' : ' cars'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePurchase(pkg.id);
|
||||
}}
|
||||
disabled={purchasing !== null}
|
||||
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 disabled:opacity-50 transition"
|
||||
>
|
||||
{purchasing === pkg.id ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
{language === 'ko' ? '처리 중...' : 'Processing...'}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
💳 {language === 'ko' ? '카드 결제' : 'Pay with Card'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleManualRequest(pkg.id);
|
||||
}}
|
||||
disabled={purchasing !== null}
|
||||
className="w-full py-2 border border-gray-300 text-gray-700 rounded-lg text-sm hover:bg-gray-50 disabled:opacity-50 transition"
|
||||
>
|
||||
{language === 'ko' ? '수동 결제 요청' : 'Manual Payment Request'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment Info */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 mb-8">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-2xl">ℹ️</span>
|
||||
<div>
|
||||
<p className="font-semibold text-blue-800 mb-1">
|
||||
{language === 'ko' ? '결제 안내' : 'Payment Information'}
|
||||
</p>
|
||||
<ul className="text-sm text-blue-700 space-y-1">
|
||||
<li>
|
||||
•{' '}
|
||||
{language === 'ko'
|
||||
? '카드 결제: Visa, Mastercard 지원 (Stripe 보안 결제)'
|
||||
: 'Card Payment: Visa, Mastercard supported (Stripe secure payment)'}
|
||||
</li>
|
||||
<li>
|
||||
•{' '}
|
||||
{language === 'ko'
|
||||
? '수동 결제: 러시아 사용자는 몽골 파트너 계좌로 입금 후 요청해주세요'
|
||||
: 'Manual Payment: Russian users can pay via Mongolian partner account'}
|
||||
</li>
|
||||
<li>
|
||||
•{' '}
|
||||
{language === 'ko'
|
||||
? '결제 완료 후 즉시 CC가 충전됩니다'
|
||||
: 'CC will be credited immediately after payment'}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charge History */}
|
||||
<div className="bg-white rounded-xl shadow-lg p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">
|
||||
{language === 'ko' ? '충전 내역' : 'Purchase History'}
|
||||
</h2>
|
||||
|
||||
{chargeHistory.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<div className="text-4xl mb-2">📋</div>
|
||||
<p>{language === 'ko' ? '충전 내역이 없습니다' : 'No purchase history'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-[400px] overflow-y-auto">
|
||||
{chargeHistory.map((item) => (
|
||||
<div key={item.id} className="p-4 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-semibold">
|
||||
${item.amount} {item.currency}
|
||||
</span>
|
||||
{getStatusBadge(item.status)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
<div>
|
||||
CC: +{item.cc_amount}
|
||||
{item.bonus_cc ? ` (+${item.bonus_cc} bonus)` : ''}
|
||||
</div>
|
||||
<div>
|
||||
{language === 'ko' ? '방법' : 'Method'}: {item.payment_method}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
{item.created_at ? new Date(item.created_at).toLocaleString() : '-'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SidebarLayout>
|
||||
);
|
||||
}
|
||||
205
frontend/src/app/cc/success/page.tsx
Normal file
205
frontend/src/app/cc/success/page.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, Suspense } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
||||
|
||||
interface CheckoutResult {
|
||||
status: string;
|
||||
cc_amount: number;
|
||||
bonus_cc: number;
|
||||
total_cc: number;
|
||||
cc_balance: number;
|
||||
}
|
||||
|
||||
function SuccessContent() {
|
||||
const { language } = useTranslation();
|
||||
const { token } = useAuthStore();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
const [result, setResult] = useState<CheckoutResult | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const sessionId = searchParams.get('session_id');
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) {
|
||||
setError('Invalid session');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
verifyPayment();
|
||||
}, [sessionId, token]);
|
||||
|
||||
const verifyPayment = async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/cc/checkout-success?session_id=${sessionId}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setResult(data);
|
||||
} else {
|
||||
const err = await response.json();
|
||||
setError(err.detail || 'Failed to verify payment');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Verification error:', error);
|
||||
setError('Failed to verify payment');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-primary-500 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">
|
||||
{language === 'ko' ? '결제 확인 중...' : 'Verifying payment...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-xl shadow-lg p-8 max-w-md w-full text-center">
|
||||
<div className="text-6xl mb-4">❌</div>
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-2">
|
||||
{language === 'ko' ? '결제 확인 실패' : 'Payment Verification Failed'}
|
||||
</h1>
|
||||
<p className="text-gray-600 mb-6">{error}</p>
|
||||
<Link
|
||||
href="/cc"
|
||||
className="inline-block px-6 py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 transition"
|
||||
>
|
||||
{language === 'ko' ? '다시 시도' : 'Try Again'}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (result?.status === 'completed') {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-xl shadow-lg p-8 max-w-md w-full text-center">
|
||||
<div className="text-6xl mb-4">🎉</div>
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-2">
|
||||
{language === 'ko' ? '결제 완료!' : 'Payment Successful!'}
|
||||
</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
{language === 'ko'
|
||||
? 'CC가 성공적으로 충전되었습니다'
|
||||
: 'CC has been successfully credited to your account'}
|
||||
</p>
|
||||
|
||||
<div className="bg-green-50 rounded-xl p-6 mb-6">
|
||||
<div className="text-sm text-green-600 mb-2">
|
||||
{language === 'ko' ? '충전된 CC' : 'CC Credited'}
|
||||
</div>
|
||||
<div className="text-4xl font-bold text-green-700">
|
||||
+{result.total_cc} CC
|
||||
</div>
|
||||
{result.bonus_cc > 0 && (
|
||||
<div className="text-sm text-green-600 mt-1">
|
||||
({result.cc_amount} + {result.bonus_cc} bonus)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-xl p-4 mb-6">
|
||||
<div className="text-sm text-gray-500 mb-1">
|
||||
{language === 'ko' ? '현재 잔액' : 'Current Balance'}
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-gray-800">
|
||||
{result.cc_balance.toFixed(1)} CC
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Link
|
||||
href="/find-my-car"
|
||||
className="block w-full px-6 py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 transition"
|
||||
>
|
||||
{language === 'ko' ? '차량 추천 받기' : 'Get Car Recommendations'}
|
||||
</Link>
|
||||
<Link
|
||||
href="/cc"
|
||||
className="block w-full px-6 py-3 border border-gray-300 text-gray-700 rounded-lg font-semibold hover:bg-gray-50 transition"
|
||||
>
|
||||
{language === 'ko' ? '더 충전하기' : 'Purchase More CC'}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Pending or other status
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-xl shadow-lg p-8 max-w-md w-full text-center">
|
||||
<div className="text-6xl mb-4">⏳</div>
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-2">
|
||||
{language === 'ko' ? '결제 처리 중' : 'Payment Processing'}
|
||||
</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
{language === 'ko'
|
||||
? '결제가 처리 중입니다. 잠시 후 다시 확인해주세요.'
|
||||
: 'Your payment is being processed. Please check back shortly.'}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => verifyPayment()}
|
||||
className="w-full px-6 py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 transition"
|
||||
>
|
||||
{language === 'ko' ? '다시 확인' : 'Check Again'}
|
||||
</button>
|
||||
<Link
|
||||
href="/cc"
|
||||
className="block w-full px-6 py-3 border border-gray-300 text-gray-700 rounded-lg font-semibold hover:bg-gray-50 transition"
|
||||
>
|
||||
{language === 'ko' ? 'CC 충전 페이지로' : 'Back to CC Purchase'}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CCSuccessPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-500"></div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SuccessContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
386
frontend/src/app/charge/page.tsx
Normal file
386
frontend/src/app/charge/page.tsx
Normal file
@@ -0,0 +1,386 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
import { ccApi, PaymentInfo, ChargeHistory } from '@/lib/api';
|
||||
import SidebarLayout from '@/components/SidebarLayout';
|
||||
|
||||
export default function ChargePage() {
|
||||
const { t, language } = useTranslation();
|
||||
const { user } = useAuthStore();
|
||||
const router = useRouter();
|
||||
|
||||
const [paymentInfo, setPaymentInfo] = useState<PaymentInfo | null>(null);
|
||||
const [ccBalance, setCcBalance] = useState<number>(0);
|
||||
const [chargeHistory, setChargeHistory] = useState<ChargeHistory[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [paymentMethod, setPaymentMethod] = useState<'usdc' | 'bank_transfer'>('usdc');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [transactionHash, setTransactionHash] = useState('');
|
||||
const [walletAddress, setWalletAddress] = useState('');
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
|
||||
// Preset amounts
|
||||
const presetAmounts = [10, 20, 50, 100, 200, 500];
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
loadData();
|
||||
}, [user, router]);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [infoData, balanceData, historyData] = await Promise.all([
|
||||
ccApi.getPaymentInfo(),
|
||||
ccApi.getBalance(),
|
||||
ccApi.getChargeHistory(),
|
||||
]);
|
||||
setPaymentInfo(infoData);
|
||||
setCcBalance(balanceData.cc_balance);
|
||||
setChargeHistory(historyData);
|
||||
} catch (error) {
|
||||
console.error('Failed to load data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const amountNum = parseInt(amount);
|
||||
if (!amountNum || amountNum < (paymentInfo?.min_charge_usd || 10)) {
|
||||
alert(`Minimum charge amount is ${paymentInfo?.min_charge_usd || 10} USD`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (paymentMethod === 'usdc' && !transactionHash) {
|
||||
alert('Please enter the transaction hash');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (paymentMethod === 'usdc') {
|
||||
await ccApi.chargeUSDC({
|
||||
amount_usdc: amountNum,
|
||||
transaction_hash: transactionHash,
|
||||
wallet_address: walletAddress,
|
||||
network: paymentInfo?.usdc_network || 'Polygon',
|
||||
});
|
||||
} else {
|
||||
await ccApi.chargeCC({
|
||||
amount: amountNum,
|
||||
currency: 'USD',
|
||||
payment_method: paymentMethod,
|
||||
wallet_address: walletAddress,
|
||||
});
|
||||
}
|
||||
setShowSuccess(true);
|
||||
setAmount('');
|
||||
setTransactionHash('');
|
||||
setWalletAddress('');
|
||||
loadData();
|
||||
} catch (error: any) {
|
||||
alert(error.response?.data?.detail || 'Failed to submit payment');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
alert(language === 'ko' ? '복사되었습니다!' : 'Copied to clipboard!');
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <span className="px-2 py-1 bg-green-100 text-green-700 rounded-full text-xs">{language === 'ko' ? '완료' : 'Completed'}</span>;
|
||||
case 'pending':
|
||||
return <span className="px-2 py-1 bg-yellow-100 text-yellow-700 rounded-full text-xs">{language === 'ko' ? '대기' : 'Pending'}</span>;
|
||||
case 'rejected':
|
||||
return <span className="px-2 py-1 bg-red-100 text-red-700 rounded-full text-xs">{language === 'ko' ? '거부됨' : 'Rejected'}</span>;
|
||||
default:
|
||||
return <span className="px-2 py-1 bg-gray-100 text-gray-700 rounded-full text-xs">{status}</span>;
|
||||
}
|
||||
};
|
||||
|
||||
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-12 w-12 border-b-2 border-primary-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarLayout groupKey="billing">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-800">{t.chargeTitle}</h1>
|
||||
<p className="text-gray-600 mt-2">{t.chargeSubtitle}</p>
|
||||
</div>
|
||||
|
||||
{/* Balance Card */}
|
||||
<div className="bg-gradient-to-r from-primary-600 to-primary-700 rounded-xl p-6 text-white mb-8 shadow-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-primary-100">{t.currentBalance}</p>
|
||||
<p className="text-4xl font-bold mt-2">{ccBalance.toLocaleString()} CC</p>
|
||||
<p className="text-primary-200 mt-2 text-sm">{paymentInfo?.rate}</p>
|
||||
</div>
|
||||
<div className="text-6xl opacity-50">💰</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showSuccess && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-xl p-4 mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">✓</span>
|
||||
<div>
|
||||
<p className="font-semibold text-green-800">
|
||||
{language === 'ko' ? '결제가 제출되었습니다!' : 'Payment Submitted Successfully!'}
|
||||
</p>
|
||||
<p className="text-sm text-green-600">
|
||||
{language === 'ko' ? '결제 확인 후 CC가 충전됩니다.' : 'CC will be credited once verified.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => setShowSuccess(false)} className="mt-3 text-sm text-green-700 hover:underline">
|
||||
{language === 'ko' ? '닫기' : 'Dismiss'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Payment Form */}
|
||||
<div className="bg-white rounded-xl shadow-lg p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">{language === 'ko' ? '결제하기' : 'Make a Payment'}</h2>
|
||||
|
||||
{/* Payment Method Selection */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{language === 'ko' ? '결제 방법' : 'Payment Method'}
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPaymentMethod('usdc')}
|
||||
className={`p-4 border-2 rounded-lg text-left transition-colors ${
|
||||
paymentMethod === 'usdc'
|
||||
? 'border-primary-500 bg-primary-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="font-semibold">USDC</div>
|
||||
<div className="text-xs text-gray-500">{language === 'ko' ? '암호화폐' : 'Cryptocurrency'}</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPaymentMethod('bank_transfer')}
|
||||
className={`p-4 border-2 rounded-lg text-left transition-colors ${
|
||||
paymentMethod === 'bank_transfer'
|
||||
? 'border-primary-500 bg-primary-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="font-semibold">{language === 'ko' ? '계좌이체' : 'Bank Transfer'}</div>
|
||||
<div className="text-xs text-gray-500">{language === 'ko' ? '송금' : 'Wire Transfer'}</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* USDC Payment Info */}
|
||||
{paymentMethod === 'usdc' && paymentInfo && (
|
||||
<div className="mb-6 p-4 bg-blue-50 rounded-lg">
|
||||
<p className="text-sm font-medium text-blue-800 mb-2">
|
||||
{language === 'ko' ? 'USDC를 아래 주소로 보내주세요:' : 'Send USDC to this address:'}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 p-2 bg-white rounded text-xs break-all border">
|
||||
{paymentInfo.usdc_wallet_address}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(paymentInfo.usdc_wallet_address)}
|
||||
className="px-3 py-2 bg-blue-500 text-white rounded text-sm hover:bg-blue-600"
|
||||
>
|
||||
{language === 'ko' ? '복사' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-blue-600 mt-2">Network: {paymentInfo.usdc_network}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bank Transfer Info */}
|
||||
{paymentMethod === 'bank_transfer' && (
|
||||
<div className="mb-6 p-4 bg-green-50 rounded-lg">
|
||||
<p className="text-sm font-medium text-green-800 mb-2">
|
||||
{language === 'ko' ? '계좌 정보:' : 'Bank Account Info:'}
|
||||
</p>
|
||||
<div className="text-sm text-green-700 space-y-1">
|
||||
<p>{language === 'ko' ? '은행: 신한은행' : 'Bank: Shinhan Bank'}</p>
|
||||
<p>{language === 'ko' ? '계좌번호: 110-XXX-XXXXXX' : 'Account: 110-XXX-XXXXXX'}</p>
|
||||
<p>{language === 'ko' ? '예금주: AutonetSellCar' : 'Name: AutonetSellCar'}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Preset Amounts */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{language === 'ko' ? '금액 선택' : 'Select Amount'}
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2 mb-2">
|
||||
{presetAmounts.map((preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
onClick={() => setAmount(preset.toString())}
|
||||
className={`py-2 px-3 border rounded-lg text-sm font-medium transition-colors ${
|
||||
amount === preset.toString()
|
||||
? 'border-primary-500 bg-primary-50 text-primary-700'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
${preset}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
min={paymentInfo?.min_charge_usd || 10}
|
||||
max={paymentInfo?.max_charge_usd || 10000}
|
||||
placeholder={`${language === 'ko' ? '직접 입력 (최소' : 'Enter amount (min'} $${paymentInfo?.min_charge_usd || 10})`}
|
||||
className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{language === 'ko' ? `충전될 CC: ${amount || 0} CC` : `You will receive ${amount || 0} CC`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{paymentMethod === 'usdc' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{language === 'ko' ? '트랜잭션 해시' : 'Transaction Hash'}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={transactionHash}
|
||||
onChange={(e) => setTransactionHash(e.target.value)}
|
||||
placeholder="0x..."
|
||||
className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{language === 'ko' ? 'USDC 전송 후 트랜잭션 해시를 입력하세요' : 'Enter the transaction hash after sending USDC'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{language === 'ko' ? '지갑 주소 (환불용, 선택)' : 'Wallet Address (for refunds, optional)'}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={walletAddress}
|
||||
onChange={(e) => setWalletAddress(e.target.value)}
|
||||
placeholder="0x..."
|
||||
className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !amount}
|
||||
className="w-full py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50 font-semibold transition"
|
||||
>
|
||||
{submitting ? (language === 'ko' ? '제출 중...' : 'Submitting...') : (language === 'ko' ? '결제 제출' : 'Submit Payment')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Payment History */}
|
||||
<div className="bg-white rounded-xl shadow-lg p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">{t.chargeHistory}</h2>
|
||||
|
||||
{chargeHistory.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<div className="text-4xl mb-2">📋</div>
|
||||
<p>{t.noChargeHistory}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-[500px] overflow-y-auto">
|
||||
{chargeHistory.map((payment) => (
|
||||
<div key={payment.id} className="p-4 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-semibold">
|
||||
{payment.amount} {payment.currency}
|
||||
</span>
|
||||
{getStatusBadge(payment.status)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
<div>CC: +{payment.cc_amount}</div>
|
||||
<div>{language === 'ko' ? '방법' : 'Method'}: {payment.payment_method}</div>
|
||||
{payment.transaction_id && (
|
||||
<div className="truncate text-xs">TX: {payment.transaction_id}</div>
|
||||
)}
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
{payment.created_at ? new Date(payment.created_at).toLocaleString() : '-'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="mt-8 bg-white rounded-xl shadow-lg p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">{language === 'ko' ? 'CC 충전 방법' : 'How to Charge CC'}</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="flex gap-4">
|
||||
<div className="w-8 h-8 bg-primary-100 text-primary-600 rounded-full flex items-center justify-center font-bold flex-shrink-0">1</div>
|
||||
<div>
|
||||
<p className="font-medium">{language === 'ko' ? '결제 방법 선택' : 'Choose Payment Method'}</p>
|
||||
<p className="text-sm text-gray-500">{language === 'ko' ? 'USDC 또는 계좌이체' : 'Select USDC or Bank Transfer'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<div className="w-8 h-8 bg-primary-100 text-primary-600 rounded-full flex items-center justify-center font-bold flex-shrink-0">2</div>
|
||||
<div>
|
||||
<p className="font-medium">{language === 'ko' ? '결제 진행' : 'Send Payment'}</p>
|
||||
<p className="text-sm text-gray-500">{language === 'ko' ? '표시된 주소/계좌로 송금' : 'Transfer to our account/wallet'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<div className="w-8 h-8 bg-primary-100 text-primary-600 rounded-full flex items-center justify-center font-bold flex-shrink-0">3</div>
|
||||
<div>
|
||||
<p className="font-medium">{language === 'ko' ? '제출 후 대기' : 'Submit & Wait'}</p>
|
||||
<p className="text-sm text-gray-500">{language === 'ko' ? '확인 후 CC 충전' : 'CC credited after verification'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarLayout>
|
||||
);
|
||||
}
|
||||
610
frontend/src/app/contact/page.tsx
Normal file
610
frontend/src/app/contact/page.tsx
Normal file
@@ -0,0 +1,610 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { inquiryApi, Inquiry, InquiryWithMessages } from '@/lib/api';
|
||||
|
||||
const CATEGORY_LABELS: Record<string, Record<string, string>> = {
|
||||
ko: {
|
||||
general: '일반 문의',
|
||||
vehicle: '차량 문의',
|
||||
payment: '결제 문의',
|
||||
shipping: '배송 문의',
|
||||
dealer: '딜러 문의',
|
||||
account: '계정 문의',
|
||||
other: '기타',
|
||||
},
|
||||
en: {
|
||||
general: 'General',
|
||||
vehicle: 'Vehicle',
|
||||
payment: 'Payment',
|
||||
shipping: 'Shipping',
|
||||
dealer: 'Dealer',
|
||||
account: 'Account',
|
||||
other: 'Other',
|
||||
},
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, Record<string, string>> = {
|
||||
ko: {
|
||||
pending: '대기중',
|
||||
in_progress: '처리중',
|
||||
resolved: '해결됨',
|
||||
closed: '종료',
|
||||
},
|
||||
en: {
|
||||
pending: 'Pending',
|
||||
in_progress: 'In Progress',
|
||||
resolved: 'Resolved',
|
||||
closed: 'Closed',
|
||||
},
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
in_progress: 'bg-blue-100 text-blue-800',
|
||||
resolved: 'bg-green-100 text-green-800',
|
||||
closed: 'bg-gray-100 text-gray-800',
|
||||
};
|
||||
|
||||
export default function ContactPage() {
|
||||
const { t, language } = useTranslation();
|
||||
const { user } = useAuthStore();
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
category: 'general',
|
||||
subject: '',
|
||||
message: '',
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Inquiry history state
|
||||
const [myInquiries, setMyInquiries] = useState<Inquiry[]>([]);
|
||||
const [loadingInquiries, setLoadingInquiries] = useState(false);
|
||||
const [selectedInquiry, setSelectedInquiry] = useState<InquiryWithMessages | null>(null);
|
||||
const [showDetailModal, setShowDetailModal] = useState(false);
|
||||
const [replyMessage, setReplyMessage] = useState('');
|
||||
const [sendingReply, setSendingReply] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'form' | 'history'>('form');
|
||||
|
||||
// Fetch user's inquiries
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
fetchMyInquiries();
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const fetchMyInquiries = async () => {
|
||||
setLoadingInquiries(true);
|
||||
try {
|
||||
const response = await inquiryApi.getMyInquiries(1, 50);
|
||||
setMyInquiries(response.inquiries);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch inquiries:', error);
|
||||
} finally {
|
||||
setLoadingInquiries(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await inquiryApi.createInquiry({
|
||||
category: formData.category,
|
||||
subject: formData.subject || `${CATEGORY_LABELS[language]?.[formData.category] || formData.category} ${language === 'ko' ? '문의' : 'Inquiry'}`,
|
||||
message: formData.message,
|
||||
contact_email: formData.email || user?.email,
|
||||
contact_phone: formData.phone || user?.phone,
|
||||
});
|
||||
|
||||
setIsSubmitted(true);
|
||||
setFormData({ name: '', email: '', phone: '', category: 'general', subject: '', message: '' });
|
||||
|
||||
if (user) {
|
||||
fetchMyInquiries();
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to submit inquiry:', err);
|
||||
setError(language === 'ko' ? '문의 등록에 실패했습니다. 다시 시도해주세요.' : 'Failed to submit inquiry. Please try again.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openInquiryDetail = async (inquiry: Inquiry) => {
|
||||
try {
|
||||
const detail = await inquiryApi.getInquiryDetail(inquiry.id);
|
||||
setSelectedInquiry(detail);
|
||||
setShowDetailModal(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch inquiry detail:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendReply = async () => {
|
||||
if (!selectedInquiry || !replyMessage.trim()) return;
|
||||
|
||||
setSendingReply(true);
|
||||
try {
|
||||
await inquiryApi.addMessage(selectedInquiry.inquiry.id, replyMessage.trim());
|
||||
const detail = await inquiryApi.getInquiryDetail(selectedInquiry.inquiry.id);
|
||||
setSelectedInquiry(detail);
|
||||
setReplyMessage('');
|
||||
} catch (error) {
|
||||
console.error('Failed to send reply:', error);
|
||||
} finally {
|
||||
setSendingReply(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString(language === 'ko' ? 'ko-KR' : 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
const categoryLabels = CATEGORY_LABELS[language] || CATEGORY_LABELS.en;
|
||||
const statusLabels = STATUS_LABELS[language] || STATUS_LABELS.en;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Hero Section */}
|
||||
<section className="bg-gradient-to-r from-primary-700 to-primary-900 text-white py-16">
|
||||
<div className="container mx-auto px-4 text-center">
|
||||
<h1 className="text-4xl md:text-5xl font-bold mb-4">{t.contactUs}</h1>
|
||||
<p className="text-xl text-primary-100 max-w-2xl mx-auto">
|
||||
{language === 'ko' ? '궁금한 점이 있으시면 언제든지 문의해 주세요' : 'Feel free to contact us anytime'}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="container mx-auto px-4 py-12">
|
||||
{/* Tabs for logged in users */}
|
||||
{user && (
|
||||
<div className="flex gap-2 mb-6">
|
||||
<button
|
||||
onClick={() => setActiveTab('form')}
|
||||
className={`px-6 py-3 rounded-lg font-medium transition ${
|
||||
activeTab === 'form'
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-white text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{language === 'ko' ? '새 문의' : 'New Inquiry'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('history')}
|
||||
className={`px-6 py-3 rounded-lg font-medium transition ${
|
||||
activeTab === 'history'
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-white text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{language === 'ko' ? '내 문의 내역' : 'My Inquiries'} ({myInquiries.length})
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inquiry History Tab */}
|
||||
{user && activeTab === 'history' && (
|
||||
<div className="bg-white rounded-2xl shadow-lg p-6">
|
||||
<h2 className="text-xl font-bold text-gray-800 mb-6">
|
||||
{language === 'ko' ? '내 문의 내역' : 'My Inquiries'}
|
||||
</h2>
|
||||
|
||||
{loadingInquiries ? (
|
||||
<div className="p-8 text-center">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-primary-600 border-t-transparent rounded-full mx-auto"></div>
|
||||
</div>
|
||||
) : myInquiries.length === 0 ? (
|
||||
<div className="p-12 text-center text-gray-500">
|
||||
{language === 'ko' ? '문의 내역이 없습니다.' : 'No inquiries yet.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{myInquiries.map((inquiry) => (
|
||||
<div
|
||||
key={inquiry.id}
|
||||
onClick={() => openInquiryDetail(inquiry)}
|
||||
className="border rounded-lg p-4 hover:bg-gray-50 cursor-pointer transition"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm bg-gray-100 px-2 py-0.5 rounded">
|
||||
{categoryLabels[inquiry.category] || inquiry.category}
|
||||
</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_COLORS[inquiry.status]}`}>
|
||||
{statusLabels[inquiry.status] || inquiry.status}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">{formatDate(inquiry.created_at)}</span>
|
||||
</div>
|
||||
<h3 className="font-medium text-gray-800">{inquiry.subject || (language === 'ko' ? '제목 없음' : 'No subject')}</h3>
|
||||
<p className="text-sm text-gray-500 truncate">{inquiry.message}</p>
|
||||
{inquiry.admin_response && (
|
||||
<p className="text-xs text-green-600 mt-2">
|
||||
{language === 'ko' ? '답변 완료' : 'Answered'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form Tab */}
|
||||
{(!user || activeTab === 'form') && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||
{/* Left Column - Company Info */}
|
||||
<div className="space-y-8">
|
||||
<div className="bg-white rounded-2xl shadow-lg p-8">
|
||||
<h2 className="text-2xl font-bold text-gray-800 mb-6 flex items-center gap-3">
|
||||
<span className="w-10 h-10 bg-primary-100 text-primary-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="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
</span>
|
||||
{t.getInTouch}
|
||||
</h2>
|
||||
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-1">{language === 'ko' ? '주소' : 'Address'}</p>
|
||||
<p className="font-semibold text-gray-800">{t.companyAddress}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-1">{t.telephone}</p>
|
||||
<p className="font-semibold text-gray-800">+82-2-552-0773</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-1">{t.emailAddress}</p>
|
||||
<a href="mailto:sshong@grantech.kr" className="font-semibold text-primary-600 hover:text-primary-700">
|
||||
sshong@grantech.kr
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-1">{t.businessHours}</p>
|
||||
<p className="font-semibold text-gray-800">{t.businessHoursValue}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Map */}
|
||||
<div className="bg-white rounded-2xl shadow-lg overflow-hidden">
|
||||
<div className="h-64">
|
||||
<iframe
|
||||
src="https://maps.google.com/maps?q=경기도+안산시+단원구+별망로+453+광양프런티어밸리&t=&z=16&ie=UTF8&iwloc=&output=embed"
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{ border: 0 }}
|
||||
allowFullScreen
|
||||
loading="lazy"
|
||||
title="Location"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Form */}
|
||||
<div className="bg-white rounded-2xl shadow-lg p-8">
|
||||
<h2 className="text-2xl font-bold text-gray-800 mb-6 flex items-center gap-3">
|
||||
<span className="w-10 h-10 bg-primary-100 text-primary-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="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
|
||||
</svg>
|
||||
</span>
|
||||
{t.sendUsMessage}
|
||||
</h2>
|
||||
|
||||
{isSubmitted ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<svg className="w-10 h-10 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-gray-800 mb-3">{t.messageSent}</h3>
|
||||
<p className="text-gray-600 mb-6">{t.messageSentDesc}</p>
|
||||
<button
|
||||
onClick={() => setIsSubmitted(false)}
|
||||
className="text-primary-600 hover:text-primary-700 font-medium"
|
||||
>
|
||||
{language === 'ko' ? '새 문의 작성' : 'Send another message'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-700 p-4 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{language === 'ko' ? '문의 유형' : 'Category'} *
|
||||
</label>
|
||||
<select
|
||||
required
|
||||
value={formData.category}
|
||||
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
{Object.entries(categoryLabels).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Subject */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{language === 'ko' ? '제목' : 'Subject'}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.subject}
|
||||
onChange={(e) => setFormData({ ...formData, subject: e.target.value })}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
placeholder={language === 'ko' ? '문의 제목' : 'Subject'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Email & Phone */}
|
||||
{!user && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t.yourEmail} *</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="example@email.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t.yourPhone}</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="+82-10-1234-5678"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t.message} *</label>
|
||||
<textarea
|
||||
required
|
||||
rows={6}
|
||||
value={formData.message}
|
||||
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 resize-none"
|
||||
placeholder={language === 'ko' ? '문의 내용을 입력해 주세요...' : 'Enter your message...'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full bg-primary-600 text-white font-semibold py-4 rounded-lg hover:bg-primary-700 transition disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<svg className="animate-spin h-5 w-5" 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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
<span>{t.submitting}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
||||
</svg>
|
||||
<span>{t.send}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Detail Modal */}
|
||||
{showDetailModal && selectedInquiry && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b sticky top-0 bg-white">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold">{language === 'ko' ? '문의 상세' : 'Inquiry Detail'}</h2>
|
||||
<button
|
||||
onClick={() => setShowDetailModal(false)}
|
||||
className="p-2 hover:bg-gray-100 rounded-full"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Info */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm bg-gray-100 px-2 py-1 rounded">
|
||||
{categoryLabels[selectedInquiry.inquiry.category] || selectedInquiry.inquiry.category}
|
||||
</span>
|
||||
<span className={`text-xs px-2 py-1 rounded-full ${STATUS_COLORS[selectedInquiry.inquiry.status]}`}>
|
||||
{statusLabels[selectedInquiry.inquiry.status] || selectedInquiry.inquiry.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Original Message */}
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<p className="font-medium mb-2">{selectedInquiry.inquiry.subject || (language === 'ko' ? '제목 없음' : 'No subject')}</p>
|
||||
<p className="text-gray-700 whitespace-pre-wrap">{selectedInquiry.inquiry.message}</p>
|
||||
<p className="text-xs text-gray-400 mt-2">{formatDate(selectedInquiry.inquiry.created_at)}</p>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
{selectedInquiry.messages.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold text-gray-700">{language === 'ko' ? '대화 내역' : 'Messages'}</h3>
|
||||
{selectedInquiry.messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`p-3 rounded-lg ${
|
||||
msg.is_admin
|
||||
? 'bg-primary-50 border-l-4 border-primary-500'
|
||||
: 'bg-gray-50 border-l-4 border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className={`text-xs font-medium ${msg.is_admin ? 'text-primary-600' : 'text-gray-600'}`}>
|
||||
{msg.is_admin ? (language === 'ko' ? '관리자' : 'Admin') : (language === 'ko' ? '나' : 'Me')}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">{formatDate(msg.created_at)}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-700 whitespace-pre-wrap">{msg.message}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reply Form */}
|
||||
{selectedInquiry.inquiry.status !== 'closed' && (
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-700 mb-2">{language === 'ko' ? '추가 문의' : 'Reply'}</h3>
|
||||
<textarea
|
||||
value={replyMessage}
|
||||
onChange={(e) => setReplyMessage(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-primary-500"
|
||||
placeholder={language === 'ko' ? '추가 문의 내용을 입력하세요...' : 'Enter your message...'}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSendReply}
|
||||
disabled={sendingReply || !replyMessage.trim()}
|
||||
className="mt-2 bg-primary-600 text-white px-6 py-2 rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{sendingReply ? (language === 'ko' ? '전송중...' : 'Sending...') : (language === 'ko' ? '전송' : 'Send')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Business Partnership Section */}
|
||||
<section className="bg-gradient-to-r from-primary-800 to-primary-900 py-16">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<div className="flex items-center justify-center gap-3 mb-4">
|
||||
<svg className="w-8 h-8 text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2L15.09 8.26L22 9.27L17 14.14L18.18 21.02L12 17.77L5.82 21.02L7 14.14L2 9.27L8.91 8.26L12 2Z" />
|
||||
</svg>
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-white">
|
||||
{language === 'ko' ? '비즈니스 파트너십' :
|
||||
language === 'mn' ? 'Бизнесийн түншлэл' :
|
||||
language === 'ru' ? 'Деловое партнёрство' :
|
||||
'Business Partnership'}
|
||||
</h2>
|
||||
<svg className="w-8 h-8 text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2L15.09 8.26L22 9.27L17 14.14L18.18 21.02L12 17.77L5.82 21.02L7 14.14L2 9.27L8.91 8.26L12 2Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-lg text-primary-100 leading-relaxed mb-8">
|
||||
{language === 'ko'
|
||||
? '차량 거래뿐만 아니라, 물류, 수출입, 딜러십, 기술 협력 등 다양한 비즈니스 협업 기회를 열린 마음으로 환영합니다. 함께 성장할 파트너를 찾고 있습니다.'
|
||||
: language === 'mn'
|
||||
? 'Бид зөвхөн автомашины худалдаа төдийгүй логистик, экспорт-импорт, дилер, техникийн хамтын ажиллагаа зэрэг бизнесийн бүх төрлийн санал, хамтын ажиллагааг хүлээн авахад бэлэн байна.'
|
||||
: language === 'ru'
|
||||
? 'Мы открыты не только для сделок с автомобилями, но и для любых бизнес-предложений: логистика, экспорт-импорт, дилерство, техническое сотрудничество. Мы ищем партнёров для совместного развития.'
|
||||
: 'Beyond vehicle transactions, we warmly welcome all business inquiries — logistics, import-export, dealership opportunities, and technical partnerships. We are seeking partners to grow together.'}
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-4">
|
||||
<div className="bg-white/10 backdrop-blur-sm rounded-lg px-6 py-3 border border-white/20">
|
||||
<span className="text-white font-medium">
|
||||
{language === 'ko' ? '🚚 물류 협력' : language === 'mn' ? '🚚 Логистик' : language === 'ru' ? '🚚 Логистика' : '🚚 Logistics'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-white/10 backdrop-blur-sm rounded-lg px-6 py-3 border border-white/20">
|
||||
<span className="text-white font-medium">
|
||||
{language === 'ko' ? '🌍 수출입' : language === 'mn' ? '🌍 Экспорт-Импорт' : language === 'ru' ? '🌍 Экспорт-Импорт' : '🌍 Import-Export'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-white/10 backdrop-blur-sm rounded-lg px-6 py-3 border border-white/20">
|
||||
<span className="text-white font-medium">
|
||||
{language === 'ko' ? '🤝 딜러십' : language === 'mn' ? '🤝 Дилерийн эрх' : language === 'ru' ? '🤝 Дилерство' : '🤝 Dealership'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-white/10 backdrop-blur-sm rounded-lg px-6 py-3 border border-white/20">
|
||||
<span className="text-white font-medium">
|
||||
{language === 'ko' ? '💡 기술 협력' : language === 'mn' ? '💡 Техникийн хамтын ажиллагаа' : language === 'ru' ? '💡 Техническое сотрудничество' : '💡 Tech Partnership'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
687
frontend/src/app/cost/page.tsx
Normal file
687
frontend/src/app/cost/page.tsx
Normal file
@@ -0,0 +1,687 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation, formatPriceWithCurrency } from '@/lib/i18n';
|
||||
import { useExchangeRateStore } from '@/lib/exchangeRateStore';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import SidebarLayout from '@/components/SidebarLayout';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
|
||||
// Cost constants
|
||||
const DOMESTIC_COST_KRW = 1150000; // ₩1,150,000
|
||||
const KOREAN_FEE_PERCENT = 5; // 5% of vehicle price
|
||||
const MONGOLIAN_FEE_PERCENT = 5; // 5% of vehicle price
|
||||
const CUSTOMS_FEE_USD = 200; // $200
|
||||
// Exchange rate is now fetched dynamically from useExchangeRateStore
|
||||
|
||||
// Car type ratio: Small:Compact = 5.5:4.5 (total 10)
|
||||
// For 4 cars (2 small + 2 compact): 2.75 + 2.75 + 2.25 + 2.25 = 10
|
||||
const SMALL_CAR_RATIO = 2.75; // 27.5% of total
|
||||
const COMPACT_CAR_RATIO = 2.25; // 22.5% of total
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
||||
|
||||
// Container matching simulation
|
||||
interface ContainerSlot {
|
||||
id: number;
|
||||
type: 'small' | 'compact';
|
||||
status: 'empty' | 'waiting' | 'matched';
|
||||
buyerName?: string;
|
||||
cost: number;
|
||||
ratio: number;
|
||||
}
|
||||
|
||||
interface Settings {
|
||||
container_logistics_usd: number;
|
||||
shoring_cost_usd: number;
|
||||
}
|
||||
|
||||
export default function CostPage() {
|
||||
const { t, language } = useTranslation();
|
||||
const { user, isLoading } = useAuthStore();
|
||||
const router = useRouter();
|
||||
|
||||
// If not logged in, show login prompt
|
||||
if (!isLoading && !user) {
|
||||
return (
|
||||
<SidebarLayout groupKey="billing">
|
||||
<div className="min-h-[60vh] flex items-center justify-center">
|
||||
<div className="bg-white rounded-lg shadow-lg p-8 max-w-md text-center">
|
||||
<div className="text-6xl mb-6">🔒</div>
|
||||
<h2 className="text-2xl font-bold text-gray-800 mb-4">
|
||||
{language === 'ko' ? '회원 전용 서비스' :
|
||||
language === 'mn' ? 'Зөвхөн гишүүдэд' :
|
||||
language === 'ru' ? 'Только для участников' :
|
||||
'Members Only'}
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
{language === 'ko' ? '비용 계산 기능은 가입된 회원만 이용하실 수 있습니다.' :
|
||||
language === 'mn' ? 'Зардал тооцоо нь зөвхөн бүртгэлтэй гишүүдэд боломжтой.' :
|
||||
language === 'ru' ? 'Калькулятор стоимости доступен только зарегистрированным пользователям.' :
|
||||
'Cost calculator is available only to registered members.'}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<Link
|
||||
href="/register"
|
||||
className="block w-full bg-primary-600 text-white py-3 px-6 rounded-lg font-medium hover:bg-primary-700 transition"
|
||||
>
|
||||
{language === 'ko' ? '회원가입' :
|
||||
language === 'mn' ? 'Бүртгүүлэх' :
|
||||
language === 'ru' ? 'Регистрация' :
|
||||
'Sign Up'}
|
||||
</Link>
|
||||
<Link
|
||||
href="/login"
|
||||
className="block w-full bg-gray-100 text-gray-700 py-3 px-6 rounded-lg font-medium hover:bg-gray-200 transition"
|
||||
>
|
||||
{language === 'ko' ? '이미 계정이 있으신가요? 로그인' :
|
||||
language === 'mn' ? 'Бүртгэлтэй юу? Нэвтрэх' :
|
||||
language === 'ru' ? 'Уже есть аккаунт? Войти' :
|
||||
'Already have an account? Login'}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// Settings from API
|
||||
const [settings, setSettings] = useState<Settings>({
|
||||
container_logistics_usd: 3600,
|
||||
shoring_cost_usd: 300,
|
||||
});
|
||||
|
||||
// Calculator state
|
||||
const [vehiclePrice, setVehiclePrice] = useState<string>('2000');
|
||||
const [carType, setCarType] = useState<'small' | 'compact'>('small');
|
||||
|
||||
// Total container cost
|
||||
const totalContainerCost = settings.container_logistics_usd + settings.shoring_cost_usd;
|
||||
|
||||
// Calculate shipping costs based on ratio
|
||||
const smallCarCost = Math.round(totalContainerCost * (SMALL_CAR_RATIO / 10));
|
||||
const compactCarCost = Math.round(totalContainerCost * (COMPACT_CAR_RATIO / 10));
|
||||
|
||||
// Container matching demo state
|
||||
const [containerSlots, setContainerSlots] = useState<ContainerSlot[]>([
|
||||
{ id: 1, type: 'small', status: 'empty', cost: 0, ratio: SMALL_CAR_RATIO },
|
||||
{ id: 2, type: 'small', status: 'empty', cost: 0, ratio: SMALL_CAR_RATIO },
|
||||
{ id: 3, type: 'compact', status: 'empty', cost: 0, ratio: COMPACT_CAR_RATIO },
|
||||
{ id: 4, type: 'compact', status: 'empty', cost: 0, ratio: COMPACT_CAR_RATIO },
|
||||
]);
|
||||
|
||||
// Fetch settings from API
|
||||
useEffect(() => {
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/settings/`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSettings({
|
||||
container_logistics_usd: data.container_logistics_usd || 3600,
|
||||
shoring_cost_usd: data.shoring_cost_usd || 300,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch settings:', error);
|
||||
}
|
||||
};
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
// Calculate cost for a single slot based on current matched slots
|
||||
const calculateSlotCosts = (slots: ContainerSlot[]): ContainerSlot[] => {
|
||||
const filledSlots = slots.filter(s => s.status !== 'empty');
|
||||
if (filledSlots.length === 0) return slots;
|
||||
|
||||
const totalRatio = filledSlots.reduce((sum, s) => sum + s.ratio, 0);
|
||||
|
||||
return slots.map(slot => {
|
||||
if (slot.status === 'empty') {
|
||||
return { ...slot, cost: 0 };
|
||||
}
|
||||
// Distribute cost proportionally based on ratio
|
||||
const cost = Math.round(totalContainerCost * (slot.ratio / totalRatio));
|
||||
return { ...slot, cost };
|
||||
});
|
||||
};
|
||||
|
||||
// Add a buyer to a specific slot
|
||||
const addBuyerToSlot = (slotIndex: number) => {
|
||||
const buyerNames = language === 'ko'
|
||||
? ['김철수', '이영희', '박민수', '최지연']
|
||||
: ['Kim', 'Lee', 'Park', 'Choi'];
|
||||
|
||||
setContainerSlots(prev => {
|
||||
const newSlots = [...prev];
|
||||
const filledCount = newSlots.filter(s => s.status !== 'empty').length;
|
||||
|
||||
if (newSlots[slotIndex].status === 'empty') {
|
||||
newSlots[slotIndex] = {
|
||||
...newSlots[slotIndex],
|
||||
status: filledCount === 3 ? 'matched' : 'waiting',
|
||||
buyerName: buyerNames[filledCount],
|
||||
};
|
||||
|
||||
// If this is the 4th car, mark all as matched
|
||||
if (filledCount === 3) {
|
||||
return calculateSlotCosts(newSlots.map(s =>
|
||||
s.status === 'waiting' ? { ...s, status: 'matched' as const } : s
|
||||
));
|
||||
}
|
||||
|
||||
return calculateSlotCosts(newSlots);
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
};
|
||||
|
||||
// Toggle slot car type
|
||||
const toggleSlotType = (slotIndex: number) => {
|
||||
setContainerSlots(prev => {
|
||||
const newSlots = [...prev];
|
||||
if (newSlots[slotIndex].status === 'empty') {
|
||||
const newType = newSlots[slotIndex].type === 'small' ? 'compact' : 'small';
|
||||
newSlots[slotIndex] = {
|
||||
...newSlots[slotIndex],
|
||||
type: newType,
|
||||
ratio: newType === 'small' ? SMALL_CAR_RATIO : COMPACT_CAR_RATIO,
|
||||
};
|
||||
}
|
||||
return calculateSlotCosts(newSlots);
|
||||
});
|
||||
};
|
||||
|
||||
// Reset container
|
||||
const resetContainer = () => {
|
||||
setContainerSlots([
|
||||
{ id: 1, type: 'small', status: 'empty', cost: 0, ratio: SMALL_CAR_RATIO },
|
||||
{ id: 2, type: 'small', status: 'empty', cost: 0, ratio: SMALL_CAR_RATIO },
|
||||
{ id: 3, type: 'compact', status: 'empty', cost: 0, ratio: COMPACT_CAR_RATIO },
|
||||
{ id: 4, type: 'compact', status: 'empty', cost: 0, ratio: COMPACT_CAR_RATIO },
|
||||
]);
|
||||
};
|
||||
|
||||
// Calculate costs
|
||||
const calculateCosts = () => {
|
||||
const priceKrw = parseInt(vehiclePrice) * 10000; // 만원 to 원
|
||||
if (isNaN(priceKrw) || priceKrw <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get dynamic USD to KRW rate from store
|
||||
const usdToKrw = useExchangeRateStore.getState().rates.USD?.rate || 1483;
|
||||
|
||||
// Korean domestic cost
|
||||
const domesticCost = DOMESTIC_COST_KRW;
|
||||
|
||||
// Korean fee (5% of vehicle price)
|
||||
const koreanFee = priceKrw * (KOREAN_FEE_PERCENT / 100);
|
||||
|
||||
// Mongolian fee (5% of vehicle price)
|
||||
const mongolianFee = priceKrw * (MONGOLIAN_FEE_PERCENT / 100);
|
||||
|
||||
// Shipping cost (USD) based on ratio
|
||||
const shippingCostUsd = carType === 'small' ? smallCarCost : compactCarCost;
|
||||
const shippingCostKrw = shippingCostUsd * usdToKrw;
|
||||
|
||||
// Customs fee
|
||||
const customsFeeKrw = CUSTOMS_FEE_USD * usdToKrw;
|
||||
|
||||
// Total cost
|
||||
const totalCost = priceKrw + domesticCost + koreanFee + mongolianFee + shippingCostKrw + customsFeeKrw;
|
||||
|
||||
return {
|
||||
vehiclePrice: priceKrw,
|
||||
domesticCost,
|
||||
koreanFee,
|
||||
mongolianFee,
|
||||
shippingCostUsd,
|
||||
shippingCostKrw,
|
||||
customsFeeUsd: CUSTOMS_FEE_USD,
|
||||
customsFeeKrw,
|
||||
totalCost,
|
||||
};
|
||||
};
|
||||
|
||||
const costs = calculateCosts();
|
||||
|
||||
// Get exchange rates
|
||||
const rates = useExchangeRateStore.getState().rates;
|
||||
const usdRate = rates.USD?.rate || 1483;
|
||||
const mntRate = rates.MNT?.rate || 0.43;
|
||||
const rubRate = rates.RUB?.rate || 14.5;
|
||||
|
||||
// Format currency based on language
|
||||
const formatKRW = (amount: number) => {
|
||||
return `₩${amount.toLocaleString()}`;
|
||||
};
|
||||
|
||||
const formatUSD = (amount: number) => {
|
||||
return `$${amount.toLocaleString()}`;
|
||||
};
|
||||
|
||||
// Format local currency based on language (MNT for Mongolian, RUB for Russian, KRW for others)
|
||||
const formatLocalCurrency = (krwAmount: number) => {
|
||||
if (language === 'mn') {
|
||||
// Convert KRW to MNT
|
||||
const mnt = Math.round(krwAmount / mntRate);
|
||||
return `${mnt.toLocaleString()} MNT`;
|
||||
} else if (language === 'ru') {
|
||||
// Convert KRW to RUB
|
||||
const rub = Math.round(krwAmount / rubRate);
|
||||
return `${rub.toLocaleString()} RUB`;
|
||||
} else {
|
||||
return formatKRW(krwAmount);
|
||||
}
|
||||
};
|
||||
|
||||
// Format USD to local currency
|
||||
const formatUSDToLocal = (usdAmount: number) => {
|
||||
const krwAmount = usdAmount * usdRate;
|
||||
if (language === 'mn') {
|
||||
const mnt = Math.round(krwAmount / mntRate);
|
||||
return `${mnt.toLocaleString()} MNT`;
|
||||
} else if (language === 'ru') {
|
||||
const rub = Math.round(krwAmount / rubRate);
|
||||
return `${rub.toLocaleString()} RUB`;
|
||||
} else {
|
||||
return formatKRW(krwAmount);
|
||||
}
|
||||
};
|
||||
|
||||
const formatToUSD = (krwAmount: number) => {
|
||||
const usd = krwAmount / usdRate;
|
||||
return `${usd.toLocaleString('en-US', { maximumFractionDigits: 0 })} USD`;
|
||||
};
|
||||
|
||||
// Format total in local currency
|
||||
const formatTotalLocal = (krwAmount: number) => {
|
||||
if (language === 'mn') {
|
||||
const mnt = Math.round(krwAmount / mntRate);
|
||||
return `${mnt.toLocaleString()} MNT`;
|
||||
} else if (language === 'ru') {
|
||||
const rub = Math.round(krwAmount / rubRate);
|
||||
return `${rub.toLocaleString()} RUB`;
|
||||
} else {
|
||||
return formatKRW(krwAmount);
|
||||
}
|
||||
};
|
||||
|
||||
// Get matched count
|
||||
const matchedCount = containerSlots.filter(s => s.status !== 'empty').length;
|
||||
const currentContainerCost = containerSlots.reduce((sum, s) => sum + s.cost, 0);
|
||||
|
||||
return (
|
||||
<SidebarLayout groupKey="billing">
|
||||
{/* Hero Section */}
|
||||
<div className="bg-gradient-to-r from-primary-700 to-primary-900 text-white rounded-lg -mx-6 -mt-6 mb-6">
|
||||
<div className="container mx-auto px-4 py-16">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<h1 className="text-4xl md:text-5xl font-bold mb-4">{t.costTitle}</h1>
|
||||
<p className="text-xl text-primary-100">{t.costSubtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cost Structure */}
|
||||
<div className="bg-white py-12">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h2 className="text-2xl font-bold text-gray-800 text-center mb-8">{t.costTitle}</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{/* Domestic Costs */}
|
||||
<div className="bg-gray-50 rounded-lg p-6">
|
||||
<h3 className="text-xl font-bold text-gray-800 mb-4 flex items-center">
|
||||
<span className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white text-sm mr-3">1</span>
|
||||
{t.domesticCosts}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center py-2 border-b">
|
||||
<span className="text-gray-600">{t.vehiclePrice}</span>
|
||||
<span className="font-medium">{language === 'ko' ? '차량가격' : 'Vehicle Price'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2 border-b">
|
||||
<span className="text-gray-600">{t.koreanDomesticCost}</span>
|
||||
<span className="font-medium text-primary-600">{formatLocalCurrency(DOMESTIC_COST_KRW)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2 border-b">
|
||||
<span className="text-gray-600">{t.koreanMargin}</span>
|
||||
<span className="font-medium text-primary-600">{KOREAN_FEE_PERCENT}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* International Logistics */}
|
||||
<div className="bg-gray-50 rounded-lg p-6">
|
||||
<h3 className="text-xl font-bold text-gray-800 mb-4 flex items-center">
|
||||
<span className="w-8 h-8 bg-green-500 rounded-full flex items-center justify-center text-white text-sm mr-3">2</span>
|
||||
{t.internationalLogistics}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center py-2 border-b">
|
||||
<span className="text-gray-600">{t.shippingCost} ({t.smallCar})</span>
|
||||
<span className="font-medium text-green-600">
|
||||
{formatUSD(smallCarCost)} <span className="text-gray-400 text-sm">({formatUSDToLocal(smallCarCost)})</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2 border-b">
|
||||
<span className="text-gray-600">{t.shippingCost} ({t.compactCar})</span>
|
||||
<span className="font-medium text-green-600">
|
||||
{formatUSD(compactCarCost)} <span className="text-gray-400 text-sm">({formatUSDToLocal(compactCarCost)})</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2 border-b">
|
||||
<span className="text-gray-600">{t.customsFee}</span>
|
||||
<span className="font-medium text-green-600">
|
||||
{formatUSD(CUSTOMS_FEE_USD)} <span className="text-gray-400 text-sm">({formatUSDToLocal(CUSTOMS_FEE_USD)})</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2 border-b">
|
||||
<span className="text-gray-600">{t.mongolianMargin}</span>
|
||||
<span className="font-medium text-green-600">{MONGOLIAN_FEE_PERCENT}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Container Matching System */}
|
||||
<div className="container mx-auto px-4 py-12">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="bg-white rounded-lg shadow-lg p-6 md:p-8">
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl font-bold text-gray-800 mb-2">{t.containerMatching}</h2>
|
||||
<p className="text-gray-600">{t.containerMatchingDesc}</p>
|
||||
</div>
|
||||
|
||||
{/* Container Info Banner */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="font-semibold text-blue-800">
|
||||
{language === 'ko' ? '컨테이너 물류비 + 쇼링' : 'Container Logistics + Shoring'}: {formatUSD(totalContainerCost)}
|
||||
</p>
|
||||
<p className="text-blue-600 text-sm">
|
||||
{language === 'ko'
|
||||
? `물류비 ${formatUSD(settings.container_logistics_usd)} + 쇼링(고정) ${formatUSD(settings.shoring_cost_usd)}`
|
||||
: `Logistics ${formatUSD(settings.container_logistics_usd)} + Shoring ${formatUSD(settings.shoring_cost_usd)}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-blue-600">{t.smallCar} (5.5): {formatUSD(smallCarCost)} ({formatUSDToLocal(smallCarCost)})</p>
|
||||
<p className="text-sm text-blue-600">{t.compactCar} (4.5): {formatUSD(compactCarCost)} ({formatUSDToLocal(compactCarCost)})</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Container Visualization */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-gray-700">Container #A-2024-001</h3>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-500">
|
||||
{matchedCount}/4 {t.matched} ({formatUSD(currentContainerCost)}/{formatUSD(totalContainerCost)})
|
||||
</span>
|
||||
<button
|
||||
onClick={resetContainer}
|
||||
className="text-sm text-primary-600 hover:text-primary-800"
|
||||
>
|
||||
{language === 'ko' ? '초기화' : 'Reset'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Container Slots */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
{containerSlots.map((slot, index) => (
|
||||
<div
|
||||
key={slot.id}
|
||||
className={`rounded-lg p-4 border-2 transition-all ${
|
||||
slot.status === 'empty'
|
||||
? 'border-dashed border-gray-300 bg-gray-50 cursor-pointer hover:border-primary-400 hover:bg-primary-50'
|
||||
: slot.status === 'waiting'
|
||||
? 'border-yellow-400 bg-yellow-50'
|
||||
: 'border-green-400 bg-green-50'
|
||||
}`}
|
||||
onClick={() => slot.status === 'empty' && addBuyerToSlot(index)}
|
||||
>
|
||||
<div className="text-center">
|
||||
{/* Car Icon - clickable to toggle type when empty */}
|
||||
<div
|
||||
className={`text-3xl mb-2 ${slot.status === 'empty' ? 'opacity-50 cursor-pointer' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (slot.status === 'empty') toggleSlotType(index);
|
||||
}}
|
||||
title={slot.status === 'empty' ? (language === 'ko' ? '클릭하여 차종 변경' : 'Click to change type') : ''}
|
||||
>
|
||||
{slot.type === 'small' ? '🚗' : '🚙'}
|
||||
</div>
|
||||
|
||||
{/* Slot Type */}
|
||||
<p className={`text-sm font-medium ${slot.status === 'empty' ? 'text-gray-400' : 'text-gray-700'}`}>
|
||||
{slot.type === 'small' ? t.smallCar : t.compactCar}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
({language === 'ko' ? '비율' : 'Ratio'}: {slot.ratio})
|
||||
</p>
|
||||
|
||||
{/* Status */}
|
||||
{slot.status === 'empty' ? (
|
||||
<p className="text-xs text-primary-500 mt-2 font-medium">
|
||||
{language === 'ko' ? '클릭하여 추가' : 'Click to add'}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm font-medium text-gray-800 mt-1">{slot.buyerName}</p>
|
||||
<p className={`text-xs mt-1 ${slot.status === 'waiting' ? 'text-yellow-600' : 'text-green-600'}`}>
|
||||
{slot.status === 'waiting' ? t.waitingForMatch : t.matched}
|
||||
</p>
|
||||
<p className="text-sm font-bold text-primary-600 mt-1">
|
||||
{formatUSD(slot.cost)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
({formatUSDToLocal(slot.cost)})
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Explanation - Cost Calculation Method */}
|
||||
<div className="mt-6 bg-gray-50 rounded-lg p-4">
|
||||
<h4 className="font-medium text-gray-700 mb-3">
|
||||
{language === 'ko' ? '📊 물류비 산출 방식' : '📊 How Logistics Cost is Calculated'}
|
||||
</h4>
|
||||
<div className="text-sm text-gray-600 space-y-2">
|
||||
<div className="bg-white rounded p-3 border">
|
||||
<p className="font-medium text-gray-700 mb-2">
|
||||
{language === 'ko' ? '1. 총 컨테이너 비용' : '1. Total Container Cost'}
|
||||
</p>
|
||||
<p className="text-gray-600">
|
||||
{language === 'ko'
|
||||
? `컨테이너 물류비 (${formatUSD(settings.container_logistics_usd)}) + 쇼링비 (${formatUSD(settings.shoring_cost_usd)}) = ${formatUSD(totalContainerCost)}`
|
||||
: `Container Logistics (${formatUSD(settings.container_logistics_usd)}) + Shoring (${formatUSD(settings.shoring_cost_usd)}) = ${formatUSD(totalContainerCost)}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded p-3 border">
|
||||
<p className="font-medium text-gray-700 mb-2">
|
||||
{language === 'ko' ? '2. 차종별 비율 (소형:경차 = 5.5:4.5)' : '2. Car Type Ratio (Small:Compact = 5.5:4.5)'}
|
||||
</p>
|
||||
<ul className="space-y-1 ml-4">
|
||||
<li>• {language === 'ko' ? '소형차 1대' : 'Small car'}: 2.75 / 10 = 27.5% → {formatUSD(smallCarCost)} ({formatUSDToLocal(smallCarCost)})</li>
|
||||
<li>• {language === 'ko' ? '경차 1대' : 'Compact car'}: 2.25 / 10 = 22.5% → {formatUSD(compactCarCost)} ({formatUSDToLocal(compactCarCost)})</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded p-3 border">
|
||||
<p className="font-medium text-gray-700 mb-2">
|
||||
{language === 'ko' ? '3. 매칭 진행 방식' : '3. How Matching Works'}
|
||||
</p>
|
||||
<ul className="space-y-1 ml-4">
|
||||
<li>• {language === 'ko'
|
||||
? '구매자가 컨테이너에 합류하면 현재 참여자 수에 따라 비용이 재분배됩니다.'
|
||||
: 'When a buyer joins, costs are redistributed based on current participants.'}</li>
|
||||
<li>• {language === 'ko'
|
||||
? '4명이 모두 합류하면 최종 비율대로 확정됩니다.'
|
||||
: 'When all 4 join, final costs are set according to the ratio.'}</li>
|
||||
<li>• {language === 'ko'
|
||||
? '위 슬롯을 클릭하여 시뮬레이션해 보세요!'
|
||||
: 'Click on the slots above to simulate!'}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-primary-50 rounded p-3 border border-primary-200">
|
||||
<p className="font-medium text-primary-700">
|
||||
{language === 'ko' ? '💡 계산 예시 (소형 2대 + 경차 2대)' : '💡 Example (2 Small + 2 Compact)'}
|
||||
</p>
|
||||
<p className="text-primary-600 mt-1">
|
||||
({formatUSD(smallCarCost)} × 2) + ({formatUSD(compactCarCost)} × 2) = {formatUSD(smallCarCost * 2 + compactCarCost * 2)}
|
||||
</p>
|
||||
<p className="text-primary-500 text-sm mt-1">
|
||||
= {formatUSDToLocal((smallCarCost + compactCarCost) * 2)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cost Calculator */}
|
||||
<div className="container mx-auto px-4 py-12">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="bg-white rounded-lg shadow-lg p-8">
|
||||
<h2 className="text-2xl font-bold text-gray-800 text-center mb-8">{t.costCalculator}</h2>
|
||||
|
||||
{/* Input Fields */}
|
||||
<div className="space-y-6">
|
||||
{/* Vehicle Price */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{t.vehiclePrice} ({language === 'ko' ? '만원' : '10,000 KRW'})
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="number"
|
||||
value={vehiclePrice}
|
||||
onChange={(e) => setVehiclePrice(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-4 py-3 text-lg focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="2000"
|
||||
/>
|
||||
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-500">
|
||||
{language === 'ko' ? '만원' : 'x 10,000 KRW'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
= {formatLocalCurrency(parseInt(vehiclePrice || '0') * 10000)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Car Type */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{t.selectCarType}
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<button
|
||||
onClick={() => setCarType('small')}
|
||||
className={`p-4 rounded-lg border-2 transition ${
|
||||
carType === 'small'
|
||||
? 'border-primary-500 bg-primary-50 text-primary-700'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="text-2xl mb-1">🚗</div>
|
||||
<div className="font-semibold">{t.smallCar}</div>
|
||||
<div className="text-sm text-gray-500">{formatUSD(smallCarCost)}</div>
|
||||
<div className="text-xs text-gray-400">({formatUSDToLocal(smallCarCost)})</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCarType('compact')}
|
||||
className={`p-4 rounded-lg border-2 transition ${
|
||||
carType === 'compact'
|
||||
? 'border-primary-500 bg-primary-50 text-primary-700'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="text-2xl mb-1">🚙</div>
|
||||
<div className="font-semibold">{t.compactCar}</div>
|
||||
<div className="text-sm text-gray-500">{formatUSD(compactCarCost)}</div>
|
||||
<div className="text-xs text-gray-400">({formatUSDToLocal(compactCarCost)})</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{costs && (
|
||||
<div className="mt-8 pt-8 border-t">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">{t.totalCost}</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center py-2">
|
||||
<span className="text-gray-600">{t.vehiclePrice}</span>
|
||||
<span className="font-medium">{formatLocalCurrency(costs.vehiclePrice)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2">
|
||||
<span className="text-gray-600">{t.koreanDomesticCost}</span>
|
||||
<span className="font-medium">{formatLocalCurrency(costs.domesticCost)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2">
|
||||
<span className="text-gray-600">{t.koreanMargin}</span>
|
||||
<span className="font-medium">{formatLocalCurrency(costs.koreanFee)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2">
|
||||
<span className="text-gray-600">{t.shippingCost} ({carType === 'small' ? t.smallCar : t.compactCar})</span>
|
||||
<span className="font-medium">{formatUSD(costs.shippingCostUsd)} ({formatLocalCurrency(costs.shippingCostKrw)})</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2">
|
||||
<span className="text-gray-600">{t.customsFee}</span>
|
||||
<span className="font-medium">{formatUSD(costs.customsFeeUsd)} ({formatLocalCurrency(costs.customsFeeKrw)})</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2">
|
||||
<span className="text-gray-600">{t.mongolianMargin}</span>
|
||||
<span className="font-medium">{formatLocalCurrency(costs.mongolianFee)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center py-4 border-t-2 border-primary-200">
|
||||
<span className="text-lg font-bold text-gray-800">{t.totalCost}</span>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-primary-600">{formatToUSD(costs.totalCost)}</div>
|
||||
<div className="text-gray-500">{formatTotalLocal(costs.totalCost)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA Section */}
|
||||
<div className="bg-primary-600 text-white py-12 rounded-lg -mx-6 mb-0 mt-6">
|
||||
<div className="container mx-auto px-4 text-center">
|
||||
<h2 className="text-2xl font-bold mb-4">{t.readyToFindYourCar}</h2>
|
||||
<p className="text-primary-100 mb-8">{t.quoteWithin24Hours}</p>
|
||||
<Link
|
||||
href="/vehicle-request"
|
||||
className="inline-block bg-white text-primary-600 px-8 py-3 rounded-lg font-medium hover:bg-gray-100 transition"
|
||||
>
|
||||
{t.requestVehicle}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarLayout>
|
||||
);
|
||||
}
|
||||
373
frontend/src/app/dealer/apply/page.tsx
Normal file
373
frontend/src/app/dealer/apply/page.tsx
Normal file
@@ -0,0 +1,373 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
||||
|
||||
interface DealerApplication {
|
||||
id: number;
|
||||
user_id: number;
|
||||
business_name: string;
|
||||
business_number: string | null;
|
||||
real_name: string;
|
||||
phone: string;
|
||||
bank_name: string;
|
||||
bank_account: string;
|
||||
account_holder: string;
|
||||
photo_url: string | null;
|
||||
status: string;
|
||||
rejected_reason: string | null;
|
||||
applied_at: string;
|
||||
approved_at: string | null;
|
||||
}
|
||||
|
||||
export default function DealerApplyPage() {
|
||||
const { t, language } = useTranslation();
|
||||
const { user, token } = useAuthStore();
|
||||
const router = useRouter();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [checkingApplication, setCheckingApplication] = useState(true);
|
||||
const [existingApplication, setExistingApplication] = useState<DealerApplication | null>(null);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
business_name: '',
|
||||
business_number: '',
|
||||
real_name: '',
|
||||
id_number: '',
|
||||
phone: '',
|
||||
bank_name: '',
|
||||
bank_account: '',
|
||||
account_holder: '',
|
||||
photo_url: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
if (user.is_dealer) {
|
||||
router.push('/dealer/my-card');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for existing application
|
||||
checkExistingApplication();
|
||||
}, [user, router]);
|
||||
|
||||
const checkExistingApplication = async () => {
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/dealer/my-application`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setExistingApplication(data);
|
||||
}
|
||||
} catch (error) {
|
||||
// No existing application
|
||||
} finally {
|
||||
setCheckingApplication(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!token) return;
|
||||
|
||||
setLoading(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/dealer/apply`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setExistingApplication(data);
|
||||
setMessage({ type: 'success', text: t.applicationSubmitted });
|
||||
} else {
|
||||
const error = await response.json();
|
||||
setMessage({ type: 'error', text: error.detail || t.applicationFailed });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Application failed:', error);
|
||||
setMessage({ type: 'error', text: t.applicationFailed });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!user || checkingApplication) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show existing application status
|
||||
if (existingApplication) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-12">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-2">{t.dealerApplication}</h1>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-lg p-6">
|
||||
{existingApplication.status === 'pending' && (
|
||||
<div className="text-center">
|
||||
<div className="text-6xl mb-4">⏳</div>
|
||||
<h2 className="text-xl font-semibold text-yellow-600 mb-2">{t.applicationPending}</h2>
|
||||
<p className="text-gray-600 mb-4">{t.pendingApplicationMessage}</p>
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 text-left">
|
||||
<p className="text-sm text-yellow-700">
|
||||
{language === 'ko' ? '신청일: ' : 'Applied: '}
|
||||
{new Date(existingApplication.applied_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{existingApplication.status === 'approved' && (
|
||||
<div className="text-center">
|
||||
<div className="text-6xl mb-4">✅</div>
|
||||
<h2 className="text-xl font-semibold text-green-600 mb-2">{t.applicationApproved}</h2>
|
||||
<Link
|
||||
href="/dealer/my-card"
|
||||
className="inline-block mt-4 px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition"
|
||||
>
|
||||
{t.myDealerCard}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{existingApplication.status === 'rejected' && (
|
||||
<div className="text-center">
|
||||
<div className="text-6xl mb-4">❌</div>
|
||||
<h2 className="text-xl font-semibold text-red-600 mb-2">{t.applicationRejected}</h2>
|
||||
<p className="text-gray-600 mb-4">{t.rejectedApplicationMessage}</p>
|
||||
{existingApplication.rejected_reason && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-left">
|
||||
<p className="text-sm font-medium text-red-700">{t.rejectReason}:</p>
|
||||
<p className="text-sm text-red-600">{existingApplication.rejected_reason}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-12">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-2">{t.dealerApplication}</h1>
|
||||
<p className="text-gray-600">{t.dealerApplicationSubtitle}</p>
|
||||
</div>
|
||||
|
||||
{/* Benefits Card */}
|
||||
<div className="bg-gradient-to-r from-primary-600 to-primary-700 text-white rounded-xl p-6 mb-8">
|
||||
<h2 className="text-xl font-semibold mb-4">
|
||||
{language === 'ko' ? '딜러 혜택' : 'Dealer Benefits'}
|
||||
</h2>
|
||||
<ul className="space-y-2">
|
||||
<li className="flex items-center gap-2">
|
||||
<span>💰</span>
|
||||
<span>{language === 'ko' ? '차량 판매 시 수수료 50% 수익' : '50% commission on vehicle sales'}</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span>🎫</span>
|
||||
<span>{language === 'ko' ? '공식 딜러증 발급' : 'Official dealer card issued'}</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span>📈</span>
|
||||
<span>{language === 'ko' ? '수익 관리 대시보드' : 'Earnings management dashboard'}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
{message && (
|
||||
<div className={`mb-4 p-4 rounded-lg ${
|
||||
message.type === 'success' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Application Form */}
|
||||
<form onSubmit={handleSubmit} className="bg-white rounded-xl shadow-lg p-6">
|
||||
<div className="space-y-4">
|
||||
{/* Business Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t.businessName} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.business_name}
|
||||
onChange={(e) => setFormData({ ...formData, business_name: e.target.value })}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder={language === 'ko' ? '예: 홍길동 자동차' : 'e.g., ABC Motors'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Business Number (Optional) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t.businessNumber} ({language === 'ko' ? '선택' : 'Optional'})
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.business_number}
|
||||
onChange={(e) => setFormData({ ...formData, business_number: e.target.value })}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder={language === 'ko' ? '000-00-00000' : '000-00-00000'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Real Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t.realName} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.real_name}
|
||||
onChange={(e) => setFormData({ ...formData, real_name: e.target.value })}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Phone */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t.phone} *
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
required
|
||||
value={formData.phone}
|
||||
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="010-1234-5678"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<hr className="my-6" />
|
||||
|
||||
<h3 className="font-semibold text-gray-800">
|
||||
{language === 'ko' ? '출금 계좌 정보' : 'Withdrawal Bank Account'}
|
||||
</h3>
|
||||
|
||||
{/* Bank Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t.bankName} *
|
||||
</label>
|
||||
<select
|
||||
required
|
||||
value={formData.bank_name}
|
||||
onChange={(e) => setFormData({ ...formData, bank_name: e.target.value })}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">{language === 'ko' ? '은행 선택' : 'Select Bank'}</option>
|
||||
<option value="KB국민은행">KB국민은행</option>
|
||||
<option value="신한은행">신한은행</option>
|
||||
<option value="하나은행">하나은행</option>
|
||||
<option value="우리은행">우리은행</option>
|
||||
<option value="NH농협은행">NH농협은행</option>
|
||||
<option value="카카오뱅크">카카오뱅크</option>
|
||||
<option value="토스뱅크">토스뱅크</option>
|
||||
<option value="IBK기업은행">IBK기업은행</option>
|
||||
<option value="케이뱅크">케이뱅크</option>
|
||||
<option value="Other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Bank Account */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t.bankAccount} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.bank_account}
|
||||
onChange={(e) => setFormData({ ...formData, bank_account: e.target.value })}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="1234567890123"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Account Holder */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t.accountHolder} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.account_holder}
|
||||
onChange={(e) => setFormData({ ...formData, account_holder: e.target.value })}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full mt-6 px-4 py-4 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed transition font-semibold"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
|
||||
{t.loading}
|
||||
</span>
|
||||
) : (
|
||||
t.submitApplication
|
||||
)}
|
||||
</button>
|
||||
|
||||
<p className="text-xs text-gray-500 text-center mt-4">
|
||||
{language === 'ko'
|
||||
? '* 신청 후 관리자 승인이 필요합니다. 승인까지 1-2일 소요될 수 있습니다.'
|
||||
: '* Admin approval required. May take 1-2 days to process.'}
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
254
frontend/src/app/dealer/my-card/page.tsx
Normal file
254
frontend/src/app/dealer/my-card/page.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
||||
|
||||
interface DealerInfo {
|
||||
id: number;
|
||||
user_id: number;
|
||||
dealer_code: string;
|
||||
dealer_card_url: string | null;
|
||||
business_name: string;
|
||||
real_name: string;
|
||||
phone: string;
|
||||
photo_url: string | null;
|
||||
bank_name: string;
|
||||
bank_account: string;
|
||||
account_holder: string;
|
||||
total_commission_earned: number;
|
||||
total_withdrawn: number;
|
||||
pending_withdrawal: number;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export default function DealerCardPage() {
|
||||
const { t, language } = useTranslation();
|
||||
const { user, token } = useAuthStore();
|
||||
const router = useRouter();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dealerInfo, setDealerInfo] = useState<DealerInfo | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user.is_dealer) {
|
||||
router.push('/dealer/apply');
|
||||
return;
|
||||
}
|
||||
|
||||
fetchDealerInfo();
|
||||
}, [user, router]);
|
||||
|
||||
const fetchDealerInfo = async () => {
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/dealer/my-info`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setDealerInfo(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch dealer info:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('ko-KR').format(amount);
|
||||
};
|
||||
|
||||
if (!user || loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!dealerInfo) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-12">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-2xl mx-auto text-center">
|
||||
<div className="text-6xl mb-4">❌</div>
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-4">
|
||||
{language === 'ko' ? '딜러 정보를 찾을 수 없습니다' : 'Dealer info not found'}
|
||||
</h1>
|
||||
<Link
|
||||
href="/dealer/apply"
|
||||
className="inline-block px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition"
|
||||
>
|
||||
{t.becomeDealer}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-12">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-2">{t.myDealerCard}</h1>
|
||||
<p className="text-gray-600">
|
||||
{dealerInfo.is_active ? (
|
||||
<span className="inline-flex items-center gap-1 text-green-600">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full"></span>
|
||||
{t.active}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-red-600">
|
||||
<span className="w-2 h-2 bg-red-500 rounded-full"></span>
|
||||
{t.inactive}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Dealer Card */}
|
||||
<div className="bg-gradient-to-br from-gray-800 to-gray-900 text-white rounded-2xl p-6 mb-6 shadow-xl relative overflow-hidden">
|
||||
{/* Background Pattern */}
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-white/5 rounded-full -translate-y-1/2 translate-x-1/2"></div>
|
||||
<div className="absolute bottom-0 left-0 w-32 h-32 bg-white/5 rounded-full translate-y-1/2 -translate-x-1/2"></div>
|
||||
|
||||
<div className="relative">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<p className="text-gray-400 text-sm">AutonetSellCar</p>
|
||||
<p className="text-lg font-semibold">DEALER CARD</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-gray-400 text-xs">{t.dealerCode}</p>
|
||||
<p className="text-2xl font-mono font-bold tracking-wider">{dealerInfo.dealer_code}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<p className="text-gray-400 text-xs mb-1">{t.businessName}</p>
|
||||
<p className="text-xl font-semibold mb-4">{dealerInfo.business_name}</p>
|
||||
|
||||
<p className="text-gray-400 text-xs mb-1">{t.realName}</p>
|
||||
<p className="font-medium">{dealerInfo.real_name}</p>
|
||||
</div>
|
||||
|
||||
{/* Photo placeholder */}
|
||||
<div className="w-20 h-24 bg-gray-700 rounded-lg flex items-center justify-center border-2 border-gray-600">
|
||||
{dealerInfo.photo_url ? (
|
||||
<img src={dealerInfo.photo_url} alt="Photo" className="w-full h-full object-cover rounded-lg" />
|
||||
) : (
|
||||
<span className="text-3xl">👤</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-6 pt-4 border-t border-gray-700">
|
||||
<p className="text-gray-400 text-xs">
|
||||
{language === 'ko' ? '발급일: ' : 'Issued: '}
|
||||
{new Date(dealerInfo.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Earnings Summary */}
|
||||
<div className="bg-white rounded-xl shadow-lg p-6 mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">
|
||||
{language === 'ko' ? '수익 현황' : 'Earnings Summary'}
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<p className="text-sm text-green-600">{t.totalCommission}</p>
|
||||
<p className="text-2xl font-bold text-green-700">
|
||||
{formatCurrency(dealerInfo.total_commission_earned)} {language === 'ko' ? '원' : 'KRW'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<p className="text-sm text-blue-600">{t.totalWithdrawn}</p>
|
||||
<p className="text-2xl font-bold text-blue-700">
|
||||
{formatCurrency(dealerInfo.total_withdrawn)} {language === 'ko' ? '원' : 'KRW'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<p className="text-sm text-yellow-600">{t.pendingWithdrawal}</p>
|
||||
<p className="text-2xl font-bold text-yellow-700">
|
||||
{formatCurrency(dealerInfo.pending_withdrawal)} {language === 'ko' ? '원' : 'KRW'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Available Balance */}
|
||||
<div className="mt-4 p-4 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">
|
||||
{language === 'ko' ? '출금 가능 잔액' : 'Available for Withdrawal'}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-gray-800">
|
||||
{formatCurrency(
|
||||
dealerInfo.total_commission_earned - dealerInfo.total_withdrawn - dealerInfo.pending_withdrawal
|
||||
)} {language === 'ko' ? '원' : 'KRW'}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/withdrawal"
|
||||
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition"
|
||||
>
|
||||
{language === 'ko' ? '출금 신청' : 'Request Withdrawal'}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bank Account Info */}
|
||||
<div className="bg-white rounded-xl shadow-lg p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">
|
||||
{language === 'ko' ? '출금 계좌 정보' : 'Withdrawal Bank Account'}
|
||||
</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between py-2 border-b border-gray-100">
|
||||
<span className="text-gray-600">{t.bankName}</span>
|
||||
<span className="font-medium">{dealerInfo.bank_name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2 border-b border-gray-100">
|
||||
<span className="text-gray-600">{t.bankAccount}</span>
|
||||
<span className="font-medium font-mono">{dealerInfo.bank_account}</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2">
|
||||
<span className="text-gray-600">{t.accountHolder}</span>
|
||||
<span className="font-medium">{dealerInfo.account_holder}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
487
frontend/src/app/exchange-rate/page.tsx
Normal file
487
frontend/src/app/exchange-rate/page.tsx
Normal file
@@ -0,0 +1,487 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
||||
|
||||
interface ExchangeRateData {
|
||||
currency_code: string;
|
||||
currency_name: string;
|
||||
symbol: string;
|
||||
deal_base_rate: number;
|
||||
ttb_rate: number;
|
||||
tts_rate: number;
|
||||
weight_percent: number;
|
||||
adjusted_rate: number;
|
||||
source_date: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface ExchangeRatesResponse {
|
||||
base_currency: string;
|
||||
rates: ExchangeRateData[];
|
||||
last_updated: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
interface CustomPair {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
// All supported currencies
|
||||
const ALL_CURRENCIES = [
|
||||
{ code: 'KRW', name: { ko: '한국 원', en: 'Korean Won', mn: 'Солонгос вон', ru: 'Корейская вона' }, symbol: '₩', flag: '🇰🇷' },
|
||||
{ code: 'USD', name: { ko: '미국 달러', en: 'US Dollar', mn: 'АНУ-ын доллар', ru: 'Доллар США' }, symbol: '$', flag: '🇺🇸' },
|
||||
{ code: 'MNT', name: { ko: '몽골 투그릭', en: 'Mongolian Tugrik', mn: 'Монгол төгрөг', ru: 'Монгольский тугрик' }, symbol: '₮', flag: '🇲🇳' },
|
||||
{ code: 'RUB', name: { ko: '러시아 루블', en: 'Russian Ruble', mn: 'Оросын рубль', ru: 'Российский рубль' }, symbol: '₽', flag: '🇷🇺' },
|
||||
{ code: 'CNY', name: { ko: '중국 위안', en: 'Chinese Yuan', mn: 'Хятадын юань', ru: 'Китайский юань' }, symbol: '¥', flag: '🇨🇳' },
|
||||
{ code: 'JPY', name: { ko: '일본 엔', en: 'Japanese Yen', mn: 'Японы иен', ru: 'Японская иена' }, symbol: '¥', flag: '🇯🇵' },
|
||||
{ code: 'EUR', name: { ko: '유로', en: 'Euro', mn: 'Евро', ru: 'Евро' }, symbol: '€', flag: '🇪🇺' },
|
||||
{ code: 'GBP', name: { ko: '영국 파운드', en: 'British Pound', mn: 'Британийн фунт', ru: 'Британский фунт' }, symbol: '£', flag: '🇬🇧' },
|
||||
];
|
||||
|
||||
// Exchange rates against KRW (1 KRW = X foreign currency, will be updated from API)
|
||||
// These are inverse rates: 1 KRW = 1/adjusted_rate
|
||||
const DEFAULT_RATES: Record<string, number> = {
|
||||
KRW: 1,
|
||||
USD: 1 / 1483, // 1 KRW = 0.000674 USD
|
||||
MNT: 1 / 0.43, // 1 KRW = 2.33 MNT
|
||||
RUB: 1 / 14.5, // 1 KRW = 0.069 RUB
|
||||
CNY: 1 / 203, // 1 KRW = 0.0049 CNY
|
||||
JPY: 1 / 9.5, // 1 KRW = 0.105 JPY
|
||||
EUR: 1 / 1750, // 1 KRW = 0.00057 EUR
|
||||
GBP: 1 / 1880, // 1 KRW = 0.00053 GBP
|
||||
};
|
||||
|
||||
export default function ExchangeRatePage() {
|
||||
const { language } = useTranslation();
|
||||
const [rates, setRates] = useState<ExchangeRateData[]>([]);
|
||||
const [allRates, setAllRates] = useState<Record<string, number>>(DEFAULT_RATES);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [lastUpdated, setLastUpdated] = useState<string>('');
|
||||
const [source, setSource] = useState<string>('');
|
||||
|
||||
// Get default toCurrency based on language
|
||||
const getDefaultToCurrency = (lang: string) => {
|
||||
switch (lang) {
|
||||
case 'ko': return 'KRW';
|
||||
case 'en': return 'KRW'; // English defaults to KRW
|
||||
case 'mn': return 'MNT';
|
||||
case 'ru': return 'RUB';
|
||||
default: return 'KRW';
|
||||
}
|
||||
};
|
||||
|
||||
// Converter state
|
||||
const [amount, setAmount] = useState<string>('1000');
|
||||
const [fromCurrency, setFromCurrency] = useState<string>('USD');
|
||||
const [toCurrency, setToCurrency] = useState<string>(getDefaultToCurrency(language));
|
||||
|
||||
// Custom pairs (saved in localStorage)
|
||||
const [customPairs, setCustomPairs] = useState<CustomPair[]>([
|
||||
{ from: 'KRW', to: 'USD' },
|
||||
{ from: 'MNT', to: 'USD' },
|
||||
]);
|
||||
const [editingPair, setEditingPair] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRates();
|
||||
loadCustomPairs();
|
||||
}, []);
|
||||
|
||||
const loadCustomPairs = () => {
|
||||
try {
|
||||
const saved = localStorage.getItem('exchangeRateCustomPairs');
|
||||
if (saved) {
|
||||
setCustomPairs(JSON.parse(saved));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load custom pairs:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const saveCustomPairs = (pairs: CustomPair[]) => {
|
||||
setCustomPairs(pairs);
|
||||
localStorage.setItem('exchangeRateCustomPairs', JSON.stringify(pairs));
|
||||
};
|
||||
|
||||
const fetchRates = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`${API_BASE_URL}/api/exchange-rate`);
|
||||
if (response.ok) {
|
||||
const data: ExchangeRatesResponse = await response.json();
|
||||
setRates(data.rates);
|
||||
setLastUpdated(data.last_updated);
|
||||
setSource(data.source);
|
||||
|
||||
// Update all rates - adjusted_rate is KRW per 1 unit of foreign currency
|
||||
// For conversion, we need the inverse (1 KRW = X foreign currency)
|
||||
const newRates: Record<string, number> = { KRW: 1 };
|
||||
data.rates.forEach(rate => {
|
||||
// 1 KRW = 1/adjusted_rate foreign currency
|
||||
newRates[rate.currency_code] = 1 / rate.adjusted_rate;
|
||||
});
|
||||
setAllRates({ ...DEFAULT_RATES, ...newRates });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch exchange rates:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatNumber = (num: number, decimals: number = 2) => {
|
||||
return new Intl.NumberFormat('ko-KR', {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
}).format(num);
|
||||
};
|
||||
|
||||
const formatDateTime = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString(language === 'ko' ? 'ko-KR' : 'en-US');
|
||||
};
|
||||
|
||||
const getCurrencyInfo = (code: string) => {
|
||||
return ALL_CURRENCIES.find(c => c.code === code) || ALL_CURRENCIES[0];
|
||||
};
|
||||
|
||||
const getCurrencyName = (code: string) => {
|
||||
const info = getCurrencyInfo(code);
|
||||
return info.name[language as keyof typeof info.name] || info.name.en;
|
||||
};
|
||||
|
||||
// Convert between any two currencies
|
||||
const convert = (amount: number, from: string, to: string): number => {
|
||||
if (from === to) return amount;
|
||||
|
||||
// Convert to KRW first, then to target currency
|
||||
const inKRW = from === 'KRW' ? amount : amount / allRates[from];
|
||||
const result = to === 'KRW' ? inKRW : inKRW * allRates[to];
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const getExchangeRate = (from: string, to: string): number => {
|
||||
return convert(1, from, to);
|
||||
};
|
||||
|
||||
// Format number with commas for display
|
||||
const formatWithCommas = (value: string): string => {
|
||||
const num = value.replace(/[^\d]/g, '');
|
||||
return num.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
};
|
||||
|
||||
// Handle amount input with comma formatting
|
||||
const handleAmountChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const rawValue = e.target.value.replace(/[^\d]/g, '');
|
||||
setAmount(rawValue);
|
||||
};
|
||||
|
||||
// Get display value with commas
|
||||
const displayAmount = formatWithCommas(amount);
|
||||
|
||||
const handlePairUpdate = (index: number, field: 'from' | 'to', value: string) => {
|
||||
const newPairs = [...customPairs];
|
||||
newPairs[index] = { ...newPairs[index], [field]: value };
|
||||
saveCustomPairs(newPairs);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-2">
|
||||
{language === 'ko' ? '환율 정보' : language === 'mn' ? 'Ханш мэдээлэл' : language === 'ru' ? 'Курс валют' : 'Exchange Rates'}
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
{language === 'ko'
|
||||
? '실시간 환율 정보를 확인하세요'
|
||||
: 'Check real-time exchange rates'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Main Converter Card */}
|
||||
<div className="bg-gradient-to-r from-primary-600 to-primary-700 rounded-xl p-6 text-white mb-8 shadow-lg">
|
||||
<h2 className="text-lg font-semibold mb-4">
|
||||
{language === 'ko' ? '환율 계산기' : 'Currency Converter'}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
|
||||
{/* From */}
|
||||
<div>
|
||||
<label className="block text-primary-100 text-sm mb-1">
|
||||
{language === 'ko' ? '변환할 금액' : 'Amount'}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl">{getCurrencyInfo(fromCurrency).flag}</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={displayAmount}
|
||||
onChange={handleAmountChange}
|
||||
className="flex-1 px-4 py-3 rounded-lg text-gray-800 text-lg font-semibold focus:ring-2 focus:ring-white"
|
||||
placeholder="1,000"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={fromCurrency}
|
||||
onChange={(e) => setFromCurrency(e.target.value)}
|
||||
className="w-full mt-2 px-3 py-2 rounded-lg text-gray-800"
|
||||
>
|
||||
{ALL_CURRENCIES.map(curr => (
|
||||
<option key={curr.code} value={curr.code}>
|
||||
{curr.flag} {curr.code} - {getCurrencyName(curr.code)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Arrow */}
|
||||
<div className="flex justify-center items-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
setFromCurrency(toCurrency);
|
||||
setToCurrency(fromCurrency);
|
||||
}}
|
||||
className="p-2 bg-white/20 rounded-full hover:bg-white/30 transition"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* To */}
|
||||
<div>
|
||||
<label className="block text-primary-100 text-sm mb-1">
|
||||
{language === 'ko' ? '변환 결과' : 'Result'}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl">{getCurrencyInfo(toCurrency).flag}</span>
|
||||
<div className="flex-1 px-4 py-3 rounded-lg bg-white/20 text-lg font-semibold">
|
||||
{formatNumber(convert(parseFloat(amount) || 0, fromCurrency, toCurrency), toCurrency === 'KRW' || toCurrency === 'MNT' || toCurrency === 'JPY' ? 0 : 2)}
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
value={toCurrency}
|
||||
onChange={(e) => setToCurrency(e.target.value)}
|
||||
className="w-full mt-2 px-3 py-2 rounded-lg text-gray-800"
|
||||
>
|
||||
{ALL_CURRENCIES.map(curr => (
|
||||
<option key={curr.code} value={curr.code}>
|
||||
{curr.flag} {curr.code} - {getCurrencyName(curr.code)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Exchange Rate Display */}
|
||||
<div className="mt-4 text-center text-primary-100 text-sm">
|
||||
1 {fromCurrency} = {formatNumber(getExchangeRate(fromCurrency, toCurrency), 6)} {toCurrency}
|
||||
{' • '}
|
||||
1 {toCurrency} = {formatNumber(getExchangeRate(toCurrency, fromCurrency), 6)} {fromCurrency}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Pairs */}
|
||||
<div className="bg-white rounded-xl shadow-lg p-6 mb-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-800">
|
||||
{language === 'ko' ? '나의 환율 쌍' : 'My Currency Pairs'}
|
||||
</h2>
|
||||
<span className="text-xs text-gray-500">
|
||||
{language === 'ko' ? '클릭하여 수정' : 'Click to edit'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{customPairs.map((pair, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="border-2 border-dashed border-gray-200 rounded-lg p-4 hover:border-primary-400 transition cursor-pointer"
|
||||
onClick={() => setEditingPair(editingPair === index ? null : index)}
|
||||
>
|
||||
{editingPair === index ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={pair.from}
|
||||
onChange={(e) => handlePairUpdate(index, 'from', e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex-1 px-2 py-1 border rounded text-sm"
|
||||
>
|
||||
{ALL_CURRENCIES.map(curr => (
|
||||
<option key={curr.code} value={curr.code}>{curr.flag} {curr.code}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-gray-400">→</span>
|
||||
<select
|
||||
value={pair.to}
|
||||
onChange={(e) => handlePairUpdate(index, 'to', e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex-1 px-2 py-1 border rounded text-sm"
|
||||
>
|
||||
{ALL_CURRENCIES.map(curr => (
|
||||
<option key={curr.code} value={curr.code}>{curr.flag} {curr.code}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<p className="text-xs text-primary-600 text-center">
|
||||
{language === 'ko' ? '클릭하여 저장' : 'Click to save'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{getCurrencyInfo(pair.from).flag}</span>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800">{pair.from}/{pair.to}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{getCurrencyName(pair.from)} → {getCurrencyName(pair.to)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-bold text-lg text-primary-600">
|
||||
{getCurrencyInfo(pair.to).symbol}{formatNumber(getExchangeRate(pair.from, pair.to), pair.to === 'KRW' || pair.to === 'MNT' ? 0 : 4)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
1 {pair.from}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* All Exchange Rates Table */}
|
||||
<div className="bg-white rounded-xl shadow-lg overflow-hidden">
|
||||
<div className="p-4 bg-gray-50 border-b flex items-center justify-between">
|
||||
<h2 className="font-semibold text-gray-800">
|
||||
{language === 'ko' ? '모든 통화 환율 (기준: 원화)' : 'All Currency Rates (Base: KRW)'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={fetchRates}
|
||||
className="text-sm text-primary-600 hover:text-primary-700 flex items-center gap-1"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
{language === 'ko' ? '새로고침' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="divide-y">
|
||||
{ALL_CURRENCIES.filter(c => c.code !== 'KRW').map((currency) => {
|
||||
const rate = rates.find(r => r.currency_code === currency.code);
|
||||
const inverseRate = rate?.adjusted_rate || (1 / (allRates[currency.code] || 1));
|
||||
const weightPercent = rate?.weight_percent || 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={currency.code}
|
||||
className="p-4 hover:bg-gray-50 transition cursor-pointer"
|
||||
onClick={() => {
|
||||
setFromCurrency('KRW');
|
||||
setToCurrency(currency.code);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-3xl">{currency.flag}</span>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-gray-800">{currency.code}</span>
|
||||
<span className="text-gray-500 text-sm">
|
||||
{getCurrencyName(currency.code)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
1 {currency.code} = ₩{formatNumber(inverseRate, 0)} KRW
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-bold text-lg text-gray-800">
|
||||
₩{formatNumber(inverseRate, 0)}
|
||||
</p>
|
||||
{weightPercent !== 0 && (
|
||||
<p className={`text-xs ${weightPercent > 0 ? 'text-red-500' : 'text-green-500'}`}>
|
||||
{weightPercent > 0 ? '+' : ''}{weightPercent}%
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 bg-gray-50 border-t text-xs text-gray-500 flex items-center justify-between">
|
||||
<div>
|
||||
<span>{language === 'ko' ? '기준: ' : 'Base: '}KRW (한국 원화)</span>
|
||||
{source && (
|
||||
<span className="ml-3 px-2 py-0.5 bg-gray-200 rounded text-gray-600">
|
||||
{source === 'api' ? 'Live' : source === 'cache' ? 'Cached' : 'Fallback'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{lastUpdated && (
|
||||
<span>
|
||||
{language === 'ko' ? '업데이트: ' : 'Updated: '}
|
||||
{formatDateTime(lastUpdated)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Card */}
|
||||
<div className="mt-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-xl">💡</span>
|
||||
<div className="text-sm text-blue-700">
|
||||
<p className="font-medium mb-1">
|
||||
{language === 'ko' ? '환율 안내' : 'Exchange Rate Info'}
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>
|
||||
{language === 'ko'
|
||||
? '환율은 실시간으로 변동되며, 실제 거래 시 환율과 다를 수 있습니다'
|
||||
: 'Exchange rates fluctuate in real-time and may differ from actual transaction rates'}
|
||||
</li>
|
||||
<li>
|
||||
{language === 'ko'
|
||||
? '차량 가격은 결제 시점의 환율이 적용됩니다'
|
||||
: 'Vehicle prices will be calculated using the exchange rate at the time of payment'}
|
||||
</li>
|
||||
<li>
|
||||
{language === 'ko'
|
||||
? '"나의 환율 쌍"은 브라우저에 저장됩니다'
|
||||
: 'Custom currency pairs are saved in your browser'}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
331
frontend/src/app/find-my-car/page.tsx
Normal file
331
frontend/src/app/find-my-car/page.tsx
Normal file
@@ -0,0 +1,331 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { useTranslation, formatPriceWithCurrency } from '@/lib/i18n';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { vehicleRequestsApi, PurchasedVehicle } from '@/lib/api';
|
||||
import SidebarLayout from '@/components/SidebarLayout';
|
||||
|
||||
// Shipping steps: 구매완료 - 인천항 - 텐진항(중국) - 자먼우드(몽골) - 울란바토르(몽골) - 통관 - 배송완료
|
||||
const SHIPPING_STEPS = [
|
||||
{ step: 1, key: 'step1Purchased', icon: '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' },
|
||||
{ step: 2, key: 'step2IncheonPort', icon: 'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4' },
|
||||
{ step: 3, key: 'step3TianjinPort', icon: 'M13 10V3L4 14h7v7l9-11h-7z' },
|
||||
{ step: 4, key: 'step4ZamynUud', icon: 'M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7' },
|
||||
{ step: 5, key: 'step5Ulaanbaatar', icon: 'M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z M15 11a3 3 0 11-6 0 3 3 0 016 0z' },
|
||||
{ step: 6, key: 'step6CustomsClearance', icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z' },
|
||||
{ step: 7, key: 'step7Delivered', icon: 'M5 13l4 4L19 7' },
|
||||
];
|
||||
|
||||
export default function FindMyCarPage() {
|
||||
const router = useRouter();
|
||||
const { t, language } = useTranslation();
|
||||
const { user } = useAuthStore();
|
||||
|
||||
const [vehicles, setVehicles] = useState<PurchasedVehicle[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Redirect if not logged in
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
router.push('/login?redirect=/find-my-car');
|
||||
}
|
||||
}, [user, router]);
|
||||
|
||||
// Load purchased vehicles
|
||||
useEffect(() => {
|
||||
const loadVehicles = async () => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data = await vehicleRequestsApi.getPurchasedVehicles();
|
||||
setVehicles(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load vehicles:', err);
|
||||
setError(language === 'ko' ? '차량 정보를 불러오는데 실패했습니다.' : 'Failed to load vehicles.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadVehicles();
|
||||
}, [user, language]);
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateString: string | null | undefined) => {
|
||||
if (!dateString) return '-';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString(language === 'ko' ? 'ko-KR' : language === 'mn' ? 'mn-MN' : language === 'ru' ? 'ru-RU' : 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
// Format price
|
||||
const formatPrice = (priceKrw: number | undefined) => {
|
||||
return formatPriceWithCurrency(priceKrw, language);
|
||||
};
|
||||
|
||||
// Get step label
|
||||
const getStepLabel = (step: { key: string }) => {
|
||||
return (t as any)[step.key] || step.key;
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-gray-800 mb-4">{t.loginRequired}</h2>
|
||||
<p className="text-gray-600 mb-6">{t.loginToRequest}</p>
|
||||
<Link
|
||||
href="/login?redirect=/find-my-car"
|
||||
className="bg-primary-600 text-white px-6 py-3 rounded-lg hover:bg-primary-700 transition"
|
||||
>
|
||||
{t.login}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarLayout groupKey="quote">
|
||||
<div className="container mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-2">{t.findMyCarTitle}</h1>
|
||||
<p className="text-gray-600">{t.trackYourVehicle}</p>
|
||||
</div>
|
||||
|
||||
{/* Route Visualization */}
|
||||
<div className="max-w-4xl mx-auto mb-8">
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="flex items-center justify-between relative">
|
||||
{/* Background Line */}
|
||||
<div className="absolute left-0 right-0 top-1/2 -translate-y-1/2 h-1 bg-gray-200 z-0"></div>
|
||||
|
||||
{/* Steps */}
|
||||
{SHIPPING_STEPS.map((step, index) => (
|
||||
<div key={step.step} className="relative z-10 flex flex-col items-center">
|
||||
<div className={`w-12 h-12 rounded-full flex items-center justify-center ${
|
||||
index === 0 ? 'bg-primary-600 text-white' : 'bg-gray-200 text-gray-500'
|
||||
}`}>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={step.icon} />
|
||||
</svg>
|
||||
</div>
|
||||
<span className={`mt-2 text-xs text-center max-w-[80px] ${
|
||||
index === 0 ? 'text-primary-600 font-semibold' : 'text-gray-500'
|
||||
}`}>
|
||||
{getStepLabel(step)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Route Labels */}
|
||||
<div className="mt-6 flex justify-between text-sm text-gray-600">
|
||||
<div className="text-center">
|
||||
<p className="font-medium">{language === 'ko' ? '한국' : 'Korea'}</p>
|
||||
<p className="text-xs text-gray-400">{language === 'ko' ? '인천항' : 'Incheon'}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="font-medium">{language === 'ko' ? '중국' : 'China'}</p>
|
||||
<p className="text-xs text-gray-400">{language === 'ko' ? '텐진항' : 'Tianjin'}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="font-medium">{language === 'ko' ? '몽골' : 'Mongolia'}</p>
|
||||
<p className="text-xs text-gray-400">{language === 'ko' ? '울란바토르' : 'Ulaanbaatar'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading && (
|
||||
<div className="flex justify-center items-center py-16">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6 max-w-4xl mx-auto">
|
||||
<p className="text-red-600">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No Vehicles */}
|
||||
{!isLoading && !error && vehicles.length === 0 && (
|
||||
<div className="max-w-2xl mx-auto 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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-700 mb-2">{t.noPurchasedVehicles}</h3>
|
||||
<Link
|
||||
href="/request"
|
||||
className="inline-block mt-4 bg-primary-600 text-white px-6 py-3 rounded-lg hover:bg-primary-700 transition font-medium"
|
||||
>
|
||||
{t.requestVehicle}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Vehicles List */}
|
||||
{!isLoading && !error && vehicles.length > 0 && (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{vehicles.map((vehicle) => (
|
||||
<div key={vehicle.id} className="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<div className="md:flex">
|
||||
{/* Vehicle Image */}
|
||||
<div className="md:w-1/3 relative h-48 md:h-auto bg-gray-200">
|
||||
{vehicle.car_image ? (
|
||||
<Image
|
||||
src={vehicle.car_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-16 h-16" 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="md:w-2/3 p-6">
|
||||
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4 mb-4">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-gray-800 mb-1">{vehicle.car_name}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t.purchasedOn}: {formatDate(vehicle.purchased_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-bold text-primary-600">
|
||||
{formatPrice(vehicle.total_cost_krw).usdt}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{formatPrice(vehicle.total_cost_krw).local}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shipping Progress */}
|
||||
<div className="mb-4">
|
||||
<h4 className="text-sm font-semibold text-gray-700 mb-3">{t.shippingProgress}</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
{SHIPPING_STEPS.map((step, index) => {
|
||||
const isCompleted = vehicle.shipping_status >= step.step;
|
||||
const isCurrent = vehicle.shipping_status === step.step;
|
||||
|
||||
return (
|
||||
<div key={step.step} className="flex items-center">
|
||||
{index > 0 && (
|
||||
<div className={`w-8 h-1 ${isCompleted ? 'bg-green-500' : 'bg-gray-200'}`}></div>
|
||||
)}
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium ${
|
||||
isCompleted
|
||||
? isCurrent
|
||||
? 'bg-green-500 text-white ring-4 ring-green-200'
|
||||
: 'bg-green-500 text-white'
|
||||
: 'bg-gray-200 text-gray-500'
|
||||
}`}
|
||||
title={getStepLabel(step)}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
step.step
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-green-600 font-medium">
|
||||
{getStepLabel(SHIPPING_STEPS[vehicle.shipping_status - 1] || SHIPPING_STEPS[0])}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Details Grid */}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
{vehicle.current_location && (
|
||||
<div>
|
||||
<span className="text-gray-500">{t.currentLocation}:</span>
|
||||
<span className="ml-2 font-medium text-gray-800">{vehicle.current_location}</span>
|
||||
</div>
|
||||
)}
|
||||
{vehicle.estimated_arrival && (
|
||||
<div>
|
||||
<span className="text-gray-500">{t.estimatedArrival}:</span>
|
||||
<span className="ml-2 font-medium text-gray-800">{formatDate(vehicle.estimated_arrival)}</span>
|
||||
</div>
|
||||
)}
|
||||
{vehicle.car_type && (
|
||||
<div>
|
||||
<span className="text-gray-500">{language === 'ko' ? '차종' : 'Type'}:</span>
|
||||
<span className="ml-2 font-medium text-gray-800">
|
||||
{vehicle.car_type === 'small' ? t.smallCar : t.compactCar}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{vehicle.delivered_at && (
|
||||
<div>
|
||||
<span className="text-gray-500">{t.step7Delivered}:</span>
|
||||
<span className="ml-2 font-medium text-green-600">{formatDate(vehicle.delivered_at)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cost Breakdown */}
|
||||
{(vehicle.vehicle_price_krw || vehicle.domestic_cost_krw || vehicle.shipping_cost_usd) && (
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<h4 className="text-sm font-semibold text-gray-700 mb-2">{t.costTitle}</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-sm">
|
||||
{vehicle.vehicle_price_krw && (
|
||||
<div>
|
||||
<span className="text-gray-500">{t.vehiclePrice}:</span>
|
||||
<p className="font-medium">{formatPrice(vehicle.vehicle_price_krw).local}</p>
|
||||
</div>
|
||||
)}
|
||||
{vehicle.domestic_cost_krw && (
|
||||
<div>
|
||||
<span className="text-gray-500">{t.domesticCosts}:</span>
|
||||
<p className="font-medium">{formatPrice(vehicle.domestic_cost_krw).local}</p>
|
||||
</div>
|
||||
)}
|
||||
{vehicle.shipping_cost_usd && (
|
||||
<div>
|
||||
<span className="text-gray-500">{t.shippingCost}:</span>
|
||||
<p className="font-medium">${vehicle.shipping_cost_usd.toLocaleString()}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SidebarLayout>
|
||||
);
|
||||
}
|
||||
19
frontend/src/app/globals.css
Normal file
19
frontend/src/app/globals.css
Normal file
@@ -0,0 +1,19 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--foreground-rgb: 0, 0, 0;
|
||||
--background-rgb: 255, 255, 255;
|
||||
}
|
||||
|
||||
body {
|
||||
color: rgb(var(--foreground-rgb));
|
||||
background: rgb(var(--background-rgb));
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
}
|
||||
231
frontend/src/app/inquiry/page.tsx
Normal file
231
frontend/src/app/inquiry/page.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
import { inquiryApi } from '@/lib/api';
|
||||
import SidebarLayout from '@/components/SidebarLayout';
|
||||
|
||||
export default function InquiryPage() {
|
||||
const { t, language } = useTranslation();
|
||||
const { user } = useAuthStore();
|
||||
const router = useRouter();
|
||||
|
||||
const [category, setCategory] = useState('general');
|
||||
const [subject, setSubject] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [contactEmail, setContactEmail] = useState('');
|
||||
const [contactPhone, setContactPhone] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
router.push('/login?redirect=/inquiry');
|
||||
} else {
|
||||
setContactEmail(user.email || '');
|
||||
}
|
||||
}, [user, router]);
|
||||
|
||||
const categories = [
|
||||
{ value: 'general', label: { ko: '일반 문의', en: 'General', mn: 'Ерөнхий', ru: 'Общий' } },
|
||||
{ value: 'vehicle', label: { ko: '차량 관련', en: 'Vehicle', mn: 'Машин', ru: 'Авто' } },
|
||||
{ value: 'payment', label: { ko: '결제/충전', en: 'Payment', mn: 'Төлбөр', ru: 'Оплата' } },
|
||||
{ value: 'shipping', label: { ko: '배송', en: 'Shipping', mn: 'Хүргэлт', ru: 'Доставка' } },
|
||||
{ value: 'account', label: { ko: '계정', en: 'Account', mn: 'Бүртгэл', ru: 'Аккаунт' } },
|
||||
{ value: 'other', label: { ko: '기타', en: 'Other', mn: 'Бусад', ru: 'Другое' } },
|
||||
];
|
||||
|
||||
const getLabel = (labelObj: { ko: string; en: string; mn: string; ru: string }) => {
|
||||
return labelObj[language as keyof typeof labelObj] || labelObj.en;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!subject.trim() || !message.trim()) {
|
||||
alert(language === 'ko' ? '제목과 내용을 입력해주세요' : 'Please enter subject and message');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await inquiryApi.createInquiry({
|
||||
category,
|
||||
subject,
|
||||
message,
|
||||
contact_email: contactEmail,
|
||||
contact_phone: contactPhone,
|
||||
});
|
||||
setSuccess(true);
|
||||
setSubject('');
|
||||
setMessage('');
|
||||
setCategory('general');
|
||||
} catch (error: any) {
|
||||
alert(error.response?.data?.detail || (language === 'ko' ? '문의 제출에 실패했습니다' : 'Failed to submit inquiry'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarLayout groupKey="inquiry">
|
||||
<div className="container mx-auto">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800">
|
||||
{language === 'ko' ? '문의하기' : language === 'mn' ? 'Асуулт илгээх' : language === 'ru' ? 'Отправить запрос' : 'New Inquiry'}
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
{language === 'ko' ? '궁금한 점을 문의해주세요' : 'Submit your question or concern'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Success Message */}
|
||||
{success && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-xl p-6 mb-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<span className="text-3xl">✓</span>
|
||||
<div>
|
||||
<h3 className="font-semibold text-green-800">
|
||||
{language === 'ko' ? '문의가 접수되었습니다!' : 'Inquiry Submitted!'}
|
||||
</h3>
|
||||
<p className="text-green-600 text-sm mt-1">
|
||||
{language === 'ko'
|
||||
? '빠른 시일 내에 답변 드리겠습니다.'
|
||||
: 'We will respond as soon as possible.'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push('/my-inquiries')}
|
||||
className="mt-3 text-sm text-green-700 hover:underline"
|
||||
>
|
||||
{language === 'ko' ? '내 문의 목록 보기 →' : 'View my inquiries →'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inquiry Form */}
|
||||
<div className="bg-white rounded-xl shadow-lg p-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{language === 'ko' ? '문의 유형' : 'Category'}
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat.value}
|
||||
type="button"
|
||||
onClick={() => setCategory(cat.value)}
|
||||
className={`py-2 px-3 border rounded-lg text-sm transition-colors ${
|
||||
category === cat.value
|
||||
? 'border-primary-500 bg-primary-50 text-primary-700'
|
||||
: 'border-gray-200 hover:border-gray-300 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{getLabel(cat.label)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subject */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{language === 'ko' ? '제목' : 'Subject'} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder={language === 'ko' ? '문의 제목을 입력하세요' : 'Enter inquiry subject'}
|
||||
className="w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{language === 'ko' ? '문의 내용' : 'Message'} *
|
||||
</label>
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder={language === 'ko' ? '문의 내용을 상세히 입력해주세요' : 'Please describe your inquiry in detail'}
|
||||
rows={6}
|
||||
className="w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 resize-none"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Contact Info */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{language === 'ko' ? '연락 이메일' : 'Contact Email'}
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={contactEmail}
|
||||
onChange={(e) => setContactEmail(e.target.value)}
|
||||
placeholder="email@example.com"
|
||||
className="w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{language === 'ko' ? '연락 전화번호' : 'Contact Phone'}
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={contactPhone}
|
||||
onChange={(e) => setContactPhone(e.target.value)}
|
||||
placeholder={language === 'ko' ? '전화번호 (선택)' : 'Phone number (optional)'}
|
||||
className="w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="w-full py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition font-semibold disabled:opacity-50"
|
||||
>
|
||||
{submitting
|
||||
? (language === 'ko' ? '제출 중...' : 'Submitting...')
|
||||
: (language === 'ko' ? '문의 제출' : 'Submit Inquiry')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="mt-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-xl">💡</span>
|
||||
<div className="text-sm text-blue-700">
|
||||
<p className="font-medium mb-1">
|
||||
{language === 'ko' ? '답변 안내' : 'Response Info'}
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>{language === 'ko' ? '영업일 기준 1-2일 내 답변 드립니다' : 'We will respond within 1-2 business days'}</li>
|
||||
<li>{language === 'ko' ? '긴급 문의는 카카오톡을 이용해주세요' : 'For urgent inquiries, please use KakaoTalk'}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarLayout>
|
||||
);
|
||||
}
|
||||
27
frontend/src/app/layout.tsx
Normal file
27
frontend/src/app/layout.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
import './globals.css'
|
||||
import ClientLayout from '@/components/ClientLayout'
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] })
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'AutonetSellCar - Korea Used Car Export',
|
||||
description: 'Premium Korean used cars for Mongolia',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={inter.className}>
|
||||
<ClientLayout>
|
||||
{children}
|
||||
</ClientLayout>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
119
frontend/src/app/login/page.tsx
Normal file
119
frontend/src/app/login/page.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { authApi } from '@/lib/api';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const { setToken, setUser } = useAuthStore();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const { access_token } = await authApi.login(email, password);
|
||||
setToken(access_token);
|
||||
|
||||
const user = await authApi.getMe();
|
||||
setUser(user);
|
||||
|
||||
router.push('/');
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Login failed. Please check your credentials.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center py-12 px-4">
|
||||
<div className="max-w-md w-full">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-800">Welcome Back</h1>
|
||||
<p className="text-gray-600 mt-2">Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md p-8">
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-600 p-4 rounded-md mb-6">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-md px-4 py-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="your@email.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-md px-4 py-2 pr-10 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
{showPassword ? (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-primary-600 text-white py-3 rounded-md hover:bg-primary-700 transition disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-gray-600">
|
||||
Don't have an account?{' '}
|
||||
<Link href="/register" className="text-primary-600 hover:underline">
|
||||
Register here
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
231
frontend/src/app/my-inquiries/[id]/page.tsx
Normal file
231
frontend/src/app/my-inquiries/[id]/page.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
import { inquiryApi, InquiryWithMessages, InquiryMessage } from '@/lib/api';
|
||||
import SidebarLayout from '@/components/SidebarLayout';
|
||||
|
||||
export default function InquiryDetailPage() {
|
||||
const { language } = useTranslation();
|
||||
const { user } = useAuthStore();
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const inquiryId = Number(params.id);
|
||||
|
||||
const [inquiry, setInquiry] = useState<InquiryWithMessages | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [newMessage, setNewMessage] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
if (inquiryId) {
|
||||
fetchInquiry();
|
||||
}
|
||||
}, [user, router, inquiryId]);
|
||||
|
||||
const fetchInquiry = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await inquiryApi.getInquiryDetail(inquiryId);
|
||||
setInquiry(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch inquiry:', error);
|
||||
router.push('/my-inquiries');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendMessage = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newMessage.trim()) return;
|
||||
|
||||
setSending(true);
|
||||
try {
|
||||
const message = await inquiryApi.addMessage(inquiryId, newMessage);
|
||||
setInquiry((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
messages: [...prev.messages, message],
|
||||
};
|
||||
});
|
||||
setNewMessage('');
|
||||
} catch (error: any) {
|
||||
alert(error.response?.data?.detail || (language === 'ko' ? '메시지 전송 실패' : 'Failed to send message'));
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return <span className="px-3 py-1 bg-yellow-100 text-yellow-700 rounded-full text-sm">{language === 'ko' ? '대기중' : 'Pending'}</span>;
|
||||
case 'in_progress':
|
||||
return <span className="px-3 py-1 bg-blue-100 text-blue-700 rounded-full text-sm">{language === 'ko' ? '처리중' : 'In Progress'}</span>;
|
||||
case 'resolved':
|
||||
return <span className="px-3 py-1 bg-green-100 text-green-700 rounded-full text-sm">{language === 'ko' ? '해결됨' : 'Resolved'}</span>;
|
||||
case 'closed':
|
||||
return <span className="px-3 py-1 bg-gray-100 text-gray-700 rounded-full text-sm">{language === 'ko' ? '종료' : 'Closed'}</span>;
|
||||
default:
|
||||
return <span className="px-3 py-1 bg-gray-100 text-gray-700 rounded-full text-sm">{status}</span>;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDateTime = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString(language === 'ko' ? 'ko-KR' : 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<SidebarLayout groupKey="inquiry">
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
</SidebarLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!inquiry) {
|
||||
return (
|
||||
<SidebarLayout groupKey="inquiry">
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500">{language === 'ko' ? '문의를 찾을 수 없습니다' : 'Inquiry not found'}</p>
|
||||
</div>
|
||||
</SidebarLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarLayout groupKey="inquiry">
|
||||
<div className="container mx-auto">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
{/* Back Button */}
|
||||
<Link
|
||||
href="/my-inquiries"
|
||||
className="inline-flex items-center text-gray-600 hover:text-gray-800 mb-4"
|
||||
>
|
||||
<svg className="w-5 h-5 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>
|
||||
{language === 'ko' ? '목록으로' : 'Back to list'}
|
||||
</Link>
|
||||
|
||||
{/* Inquiry Header */}
|
||||
<div className="bg-white rounded-xl shadow-lg p-6 mb-6">
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<div className="flex-1">
|
||||
<h1 className="text-xl font-bold text-gray-800">
|
||||
{inquiry.inquiry.subject || (language === 'ko' ? '제목 없음' : 'No subject')}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{formatDateTime(inquiry.inquiry.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
{getStatusBadge(inquiry.inquiry.status)}
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<p className="text-gray-700 whitespace-pre-wrap">{inquiry.inquiry.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages Thread */}
|
||||
<div className="bg-white rounded-xl shadow-lg overflow-hidden">
|
||||
<div className="p-4 bg-gray-50 border-b">
|
||||
<h2 className="font-semibold text-gray-800">
|
||||
{language === 'ko' ? '대화 내역' : 'Conversation'}
|
||||
{inquiry.messages.length > 0 && ` (${inquiry.messages.length})`}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="p-4 space-y-4 max-h-[400px] overflow-y-auto">
|
||||
{inquiry.messages.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<div className="text-4xl mb-2">💬</div>
|
||||
<p>{language === 'ko' ? '아직 답변이 없습니다' : 'No replies yet'}</p>
|
||||
</div>
|
||||
) : (
|
||||
inquiry.messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex ${msg.is_admin ? 'justify-start' : 'justify-end'}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[80%] rounded-lg p-4 ${
|
||||
msg.is_admin
|
||||
? 'bg-gray-100 text-gray-800'
|
||||
: 'bg-primary-600 text-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-sm">
|
||||
{msg.is_admin
|
||||
? (language === 'ko' ? '관리자' : 'Admin')
|
||||
: (language === 'ko' ? '나' : 'Me')}
|
||||
</span>
|
||||
<span className={`text-xs ${msg.is_admin ? 'text-gray-500' : 'text-primary-200'}`}>
|
||||
{formatDateTime(msg.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap">{msg.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reply Form */}
|
||||
{inquiry.inquiry.status !== 'closed' && (
|
||||
<div className="p-4 border-t bg-gray-50">
|
||||
<form onSubmit={handleSendMessage} className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={newMessage}
|
||||
onChange={(e) => setNewMessage(e.target.value)}
|
||||
placeholder={language === 'ko' ? '메시지를 입력하세요...' : 'Type your message...'}
|
||||
className="flex-1 px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={sending || !newMessage.trim()}
|
||||
className="px-6 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition disabled:opacity-50 font-medium"
|
||||
>
|
||||
{sending
|
||||
? (language === 'ko' ? '전송중...' : 'Sending...')
|
||||
: (language === 'ko' ? '전송' : 'Send')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inquiry.inquiry.status === 'closed' && (
|
||||
<div className="p-4 border-t bg-gray-100 text-center text-gray-500">
|
||||
{language === 'ko' ? '이 문의는 종료되었습니다' : 'This inquiry is closed'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarLayout>
|
||||
);
|
||||
}
|
||||
219
frontend/src/app/my-inquiries/page.tsx
Normal file
219
frontend/src/app/my-inquiries/page.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
import { inquiryApi, Inquiry } from '@/lib/api';
|
||||
import SidebarLayout from '@/components/SidebarLayout';
|
||||
|
||||
export default function MyInquiriesPage() {
|
||||
const { t, language } = useTranslation();
|
||||
const { user } = useAuthStore();
|
||||
const router = useRouter();
|
||||
|
||||
const [inquiries, setInquiries] = useState<Inquiry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
router.push('/login?redirect=/my-inquiries');
|
||||
return;
|
||||
}
|
||||
|
||||
fetchInquiries();
|
||||
}, [user, router, page]);
|
||||
|
||||
const fetchInquiries = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await inquiryApi.getMyInquiries(page, 10);
|
||||
setInquiries(response.inquiries || []);
|
||||
setTotalPages(response.total_pages || 1);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch inquiries:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return (
|
||||
<span className="px-2 py-1 bg-yellow-100 text-yellow-700 rounded-full text-xs">
|
||||
{language === 'ko' ? '대기중' : 'Pending'}
|
||||
</span>
|
||||
);
|
||||
case 'in_progress':
|
||||
return (
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-700 rounded-full text-xs">
|
||||
{language === 'ko' ? '처리중' : 'In Progress'}
|
||||
</span>
|
||||
);
|
||||
case 'resolved':
|
||||
return (
|
||||
<span className="px-2 py-1 bg-green-100 text-green-700 rounded-full text-xs">
|
||||
{language === 'ko' ? '해결됨' : 'Resolved'}
|
||||
</span>
|
||||
);
|
||||
case 'closed':
|
||||
return (
|
||||
<span className="px-2 py-1 bg-gray-100 text-gray-700 rounded-full text-xs">
|
||||
{language === 'ko' ? '종료' : 'Closed'}
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<span className="px-2 py-1 bg-gray-100 text-gray-700 rounded-full text-xs">
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoryLabel = (category: string) => {
|
||||
const categories: Record<string, { ko: string; en: string }> = {
|
||||
general: { ko: '일반', en: 'General' },
|
||||
vehicle: { ko: '차량', en: 'Vehicle' },
|
||||
payment: { ko: '결제', en: 'Payment' },
|
||||
shipping: { ko: '배송', en: 'Shipping' },
|
||||
account: { ko: '계정', en: 'Account' },
|
||||
other: { ko: '기타', en: 'Other' },
|
||||
};
|
||||
return categories[category]?.[language === 'ko' ? 'ko' : 'en'] || category;
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString(language === 'ko' ? 'ko-KR' : 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarLayout groupKey="inquiry">
|
||||
<div className="container mx-auto">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-800">
|
||||
{language === 'ko' ? '내 문의 목록' : 'My Inquiries'}
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
{language === 'ko' ? '문의 내역을 확인하세요' : 'Check your inquiry history'}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/inquiry"
|
||||
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition font-medium"
|
||||
>
|
||||
{language === 'ko' ? '새 문의' : 'New Inquiry'}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{loading && (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!loading && inquiries.length === 0 && (
|
||||
<div className="bg-white rounded-xl shadow p-12 text-center">
|
||||
<div className="text-5xl mb-4">📭</div>
|
||||
<h3 className="text-xl font-semibold text-gray-700 mb-2">
|
||||
{language === 'ko' ? '문의 내역이 없습니다' : 'No inquiries yet'}
|
||||
</h3>
|
||||
<p className="text-gray-500 mb-6">
|
||||
{language === 'ko'
|
||||
? '궁금한 점이 있으시면 문의해주세요'
|
||||
: 'If you have any questions, please submit an inquiry'}
|
||||
</p>
|
||||
<Link
|
||||
href="/inquiry"
|
||||
className="inline-block px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition font-medium"
|
||||
>
|
||||
{language === 'ko' ? '문의하기' : 'Submit Inquiry'}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inquiries List */}
|
||||
{!loading && inquiries.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{inquiries.map((inquiry) => (
|
||||
<Link
|
||||
key={inquiry.id}
|
||||
href={`/my-inquiries/${inquiry.id}`}
|
||||
className="block bg-white rounded-xl shadow hover:shadow-md transition p-5"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xs px-2 py-0.5 bg-gray-100 text-gray-600 rounded">
|
||||
{getCategoryLabel(inquiry.category || 'general')}
|
||||
</span>
|
||||
{getStatusBadge(inquiry.status)}
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-800 truncate">
|
||||
{inquiry.subject || (language === 'ko' ? '제목 없음' : 'No subject')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-1 line-clamp-2">
|
||||
{inquiry.message}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0">
|
||||
<p className="text-xs text-gray-400">
|
||||
{formatDate(inquiry.created_at)}
|
||||
</p>
|
||||
{inquiry.admin_response && (
|
||||
<span className="inline-block mt-2 text-xs px-2 py-1 bg-green-50 text-green-600 rounded">
|
||||
{language === 'ko' ? '답변 있음' : 'Replied'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center gap-2 mt-8">
|
||||
<button
|
||||
onClick={() => setPage(Math.max(1, page - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-4 py-2 border rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
|
||||
>
|
||||
{language === 'ko' ? '이전' : 'Previous'}
|
||||
</button>
|
||||
<span className="px-4 py-2 text-gray-600">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(Math.min(totalPages, page + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="px-4 py-2 border rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
|
||||
>
|
||||
{language === 'ko' ? '다음' : 'Next'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SidebarLayout>
|
||||
);
|
||||
}
|
||||
291
frontend/src/app/my-request/page.tsx
Normal file
291
frontend/src/app/my-request/page.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
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 SidebarLayout from '@/components/SidebarLayout';
|
||||
|
||||
export default function MyRequestPage() {
|
||||
const router = useRouter();
|
||||
const { t, language } = useTranslation();
|
||||
const { user } = useAuthStore();
|
||||
|
||||
const [requests, setRequests] = useState<VehicleRequestWithVehicles[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expandedRequest, setExpandedRequest] = useState<number | null>(null);
|
||||
|
||||
// Redirect if not logged in
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
router.push('/login?redirect=/my-request');
|
||||
}
|
||||
}, [user, router]);
|
||||
|
||||
// Load requests
|
||||
useEffect(() => {
|
||||
const loadRequests = async () => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data = await vehicleRequestsApi.getMyRequests();
|
||||
setRequests(data);
|
||||
// Auto-expand first request if it has approved vehicles
|
||||
if (data.length > 0 && data[0].approved_vehicles.length > 0) {
|
||||
setExpandedRequest(data[0].request.id);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load requests:', err);
|
||||
setError(language === 'ko' ? '요청 목록을 불러오는데 실패했습니다.' : 'Failed to load requests.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadRequests();
|
||||
}, [user, language]);
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString(language === 'ko' ? 'ko-KR' : language === 'mn' ? 'mn-MN' : language === 'ru' ? 'ru-RU' : 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
// Get status badge
|
||||
const getStatusBadge = (status: string) => {
|
||||
const statusConfig: Record<string, { color: string; label: string }> = {
|
||||
pending: { color: 'bg-yellow-100 text-yellow-800', label: t.pendingReview },
|
||||
reviewed: { color: 'bg-blue-100 text-blue-800', label: language === 'ko' ? '검토됨' : 'Reviewed' },
|
||||
completed: { color: 'bg-green-100 text-green-800', label: t.adminApproved },
|
||||
};
|
||||
|
||||
const config = statusConfig[status] || statusConfig.pending;
|
||||
return (
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium ${config.color}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// Format price
|
||||
const formatPrice = (priceKrw: number | undefined) => {
|
||||
return formatPriceWithCurrency(priceKrw, language);
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-gray-800 mb-4">{t.loginRequired}</h2>
|
||||
<p className="text-gray-600 mb-6">{t.loginToRequest}</p>
|
||||
<Link
|
||||
href="/login?redirect=/my-request"
|
||||
className="bg-primary-600 text-white px-6 py-3 rounded-lg hover:bg-primary-700 transition"
|
||||
>
|
||||
{t.login}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarLayout groupKey="quote">
|
||||
<div className="container mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-8 gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-800">{t.myRequestTitle}</h1>
|
||||
<p className="text-gray-600 mt-1">{t.trackYourVehicle}</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/vehicle-request"
|
||||
className="bg-primary-600 text-white px-6 py-2 rounded-lg hover:bg-primary-700 transition font-medium"
|
||||
>
|
||||
{t.newRequest}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading && (
|
||||
<div className="flex justify-center items-center py-16">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
|
||||
<p className="text-red-600">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No Requests */}
|
||||
{!isLoading && !error && requests.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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-700 mb-2">{t.noRequestsYet}</h3>
|
||||
<Link
|
||||
href="/request"
|
||||
className="inline-block mt-4 bg-primary-600 text-white px-6 py-3 rounded-lg hover:bg-primary-700 transition font-medium"
|
||||
>
|
||||
{t.requestVehicle}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Requests List */}
|
||||
{!isLoading && !error && requests.length > 0 && (
|
||||
<div className="space-y-6">
|
||||
{requests.map((item) => (
|
||||
<div key={item.request.id} className="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
{/* Request Header */}
|
||||
<div
|
||||
className="p-6 cursor-pointer hover:bg-gray-50 transition"
|
||||
onClick={() => setExpandedRequest(expandedRequest === item.request.id ? null : item.request.id)}
|
||||
>
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-800">
|
||||
{translateCarName(item.request.maker_name, language)} - {translateCarName(item.request.model_name, language)}
|
||||
{item.request.grade_name && ` (${translateCarName(item.request.grade_name, language)})`}
|
||||
</h3>
|
||||
{getStatusBadge(item.request.status)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 space-y-1">
|
||||
<p>
|
||||
<span className="font-medium">{t.requestDate}:</span> {formatDate(item.request.created_at)}
|
||||
</p>
|
||||
{(item.request.year_from || item.request.year_to) && (
|
||||
<p>
|
||||
<span className="font-medium">{t.yearRange}:</span> {item.request.year_from || '-'} ~ {item.request.year_to || '-'}
|
||||
</p>
|
||||
)}
|
||||
{(item.request.mileage_min || item.request.mileage_max) && (
|
||||
<p>
|
||||
<span className="font-medium">{t.mileageRange}:</span>{' '}
|
||||
{item.request.mileage_min ? `${Math.round(item.request.mileage_min / 10000)}${t.tenThousandKm}` : '-'} ~{' '}
|
||||
{item.request.mileage_max ? `${Math.round(item.request.mileage_max / 10000)}${t.tenThousandKm}` : '-'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{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}
|
||||
</span>
|
||||
)}
|
||||
<svg
|
||||
className={`w-6 h-6 text-gray-400 transition-transform ${expandedRequest === item.request.id ? '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>
|
||||
|
||||
{/* Approved Vehicles */}
|
||||
{expandedRequest === item.request.id && item.approved_vehicles.length > 0 && (
|
||||
<div className="border-t px-6 py-4 bg-gray-50">
|
||||
<h4 className="text-md font-semibold text-gray-700 mb-4">{t.approvedVehicles}</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{item.approved_vehicles.map((vehicle) => {
|
||||
const carData = vehicle.car_data;
|
||||
const priceInfo = formatPrice(carData?.final_price);
|
||||
|
||||
return (
|
||||
<div key={vehicle.id} className="bg-white rounded-lg shadow-sm overflow-hidden border">
|
||||
{/* 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"
|
||||
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>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No approved vehicles message */}
|
||||
{expandedRequest === item.request.id && item.approved_vehicles.length === 0 && (
|
||||
<div className="border-t px-6 py-8 bg-gray-50 text-center">
|
||||
<div className="text-gray-400 mb-2">
|
||||
<svg className="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-gray-600">{t.waitingForQuote}</p>
|
||||
<p className="text-sm text-gray-500 mt-1">{t.quoteWithin24Hours}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SidebarLayout>
|
||||
);
|
||||
}
|
||||
310
frontend/src/app/my-shares/page.tsx
Normal file
310
frontend/src/app/my-shares/page.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
import SidebarLayout from '@/components/SidebarLayout';
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
||||
|
||||
interface VehicleShare {
|
||||
id: number;
|
||||
user_id: number;
|
||||
request_vehicle_id: number;
|
||||
share_code: string;
|
||||
original_price_krw: number;
|
||||
markup_amount_krw: number;
|
||||
shared_price_krw: number;
|
||||
view_count: number;
|
||||
is_purchased: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface ShareReward {
|
||||
id: number;
|
||||
vehicle_share_id: number;
|
||||
markup_amount: number;
|
||||
reward_amount: number;
|
||||
tax_amount: number;
|
||||
net_amount: number;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface RewardSummary {
|
||||
total_rewards: number;
|
||||
total_withdrawn: number;
|
||||
pending_amount: number;
|
||||
available_for_withdrawal: number;
|
||||
reward_count: number;
|
||||
}
|
||||
|
||||
export default function MySharesPage() {
|
||||
const { t, language } = useTranslation();
|
||||
const { user, token } = useAuthStore();
|
||||
const router = useRouter();
|
||||
|
||||
const [tab, setTab] = useState<'shares' | 'rewards'>('shares');
|
||||
const [shares, setShares] = useState<VehicleShare[]>([]);
|
||||
const [rewards, setRewards] = useState<ShareReward[]>([]);
|
||||
const [summary, setSummary] = useState<RewardSummary | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [copiedId, setCopiedId] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
fetchData();
|
||||
}, [user, router]);
|
||||
|
||||
const fetchData = async () => {
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
const [sharesRes, rewardsRes, summaryRes] = await Promise.all([
|
||||
fetch(`${API_BASE_URL}/api/share/my-shares`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
}),
|
||||
fetch(`${API_BASE_URL}/api/share/my-rewards`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
}),
|
||||
fetch(`${API_BASE_URL}/api/share/my-rewards/summary`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (sharesRes.ok) setShares(await sharesRes.json());
|
||||
if (rewardsRes.ok) setRewards(await rewardsRes.json());
|
||||
if (summaryRes.ok) setSummary(await summaryRes.json());
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyShareLink = (shareCode: string, shareId: number) => {
|
||||
const link = `${window.location.origin}/share/${shareCode}`;
|
||||
navigator.clipboard.writeText(link).then(() => {
|
||||
setCopiedId(shareId);
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
const formatPrice = (price: number) => {
|
||||
return new Intl.NumberFormat('ko-KR').format(price);
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return <span className="px-2 py-1 bg-yellow-100 text-yellow-700 rounded text-xs">{language === 'ko' ? '대기' : 'Pending'}</span>;
|
||||
case 'approved':
|
||||
return <span className="px-2 py-1 bg-green-100 text-green-700 rounded text-xs">{language === 'ko' ? '승인' : 'Approved'}</span>;
|
||||
case 'withdrawn':
|
||||
return <span className="px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs">{language === 'ko' ? '출금완료' : 'Withdrawn'}</span>;
|
||||
default:
|
||||
return <span className="px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs">{status}</span>;
|
||||
}
|
||||
};
|
||||
|
||||
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-12 w-12 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarLayout groupKey="quote">
|
||||
<div className="container mx-auto">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800">{t.myShares}</h1>
|
||||
<p className="text-gray-600">{t.shareWithFriends}</p>
|
||||
</div>
|
||||
|
||||
{/* Summary Card */}
|
||||
{summary && (
|
||||
<div className="bg-gradient-to-r from-green-600 to-emerald-600 text-white rounded-xl p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">{t.myRewards}</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p className="text-green-100 text-sm">{language === 'ko' ? '총 리워드' : 'Total Rewards'}</p>
|
||||
<p className="text-2xl font-bold">{formatPrice(summary.total_rewards)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-green-100 text-sm">{language === 'ko' ? '출금 가능' : 'Available'}</p>
|
||||
<p className="text-2xl font-bold">{formatPrice(summary.available_for_withdrawal)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-green-100 text-sm">{language === 'ko' ? '대기 중' : 'Pending'}</p>
|
||||
<p className="text-2xl font-bold">{formatPrice(summary.pending_amount)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-green-100 text-sm">{language === 'ko' ? '출금 완료' : 'Withdrawn'}</p>
|
||||
<p className="text-2xl font-bold">{formatPrice(summary.total_withdrawn)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info Card */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-2xl">💡</span>
|
||||
<p className="text-sm text-blue-700">{t.shareRewardInfo}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 mb-6">
|
||||
<button
|
||||
onClick={() => setTab('shares')}
|
||||
className={`px-4 py-2 rounded-lg transition ${
|
||||
tab === 'shares'
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{t.myShares} ({shares.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab('rewards')}
|
||||
className={`px-4 py-2 rounded-lg transition ${
|
||||
tab === 'rewards'
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{t.myRewards} ({rewards.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{tab === 'shares' ? (
|
||||
<div className="space-y-4">
|
||||
{shares.length > 0 ? (
|
||||
shares.map((share) => (
|
||||
<div key={share.id} className="bg-white rounded-xl shadow p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">🔗</span>
|
||||
<span className="font-mono text-sm bg-gray-100 px-2 py-1 rounded">
|
||||
{share.share_code}
|
||||
</span>
|
||||
</div>
|
||||
{share.is_purchased ? (
|
||||
<span className="px-3 py-1 bg-green-100 text-green-700 rounded-full text-sm font-medium">
|
||||
{t.purchased}
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-3 py-1 bg-gray-100 text-gray-600 rounded-full text-sm">
|
||||
{t.notPurchased}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4 text-sm">
|
||||
<div>
|
||||
<p className="text-gray-500">{t.originalPrice}</p>
|
||||
<p className="font-medium">{formatPrice(share.original_price_krw)}원</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">{t.markupAmount}</p>
|
||||
<p className="font-medium text-green-600">+{formatPrice(share.markup_amount_krw)}원</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">{t.sharedPrice}</p>
|
||||
<p className="font-medium text-primary-600">{formatPrice(share.shared_price_krw)}원</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">{t.viewCount}</p>
|
||||
<p className="font-medium">{share.view_count}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-3 border-t">
|
||||
<span className="text-xs text-gray-500">
|
||||
{new Date(share.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => copyShareLink(share.share_code, share.id)}
|
||||
className="px-4 py-2 bg-primary-600 text-white text-sm rounded-lg hover:bg-primary-700 transition"
|
||||
>
|
||||
{copiedId === share.id ? t.copied : t.copyShareLink}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="bg-white rounded-xl shadow p-8 text-center">
|
||||
<div className="text-4xl mb-4">🔗</div>
|
||||
<p className="text-gray-500">{t.noShares}</p>
|
||||
<p className="text-sm text-gray-400 mt-2">
|
||||
{language === 'ko'
|
||||
? '내 요청 페이지에서 승인된 차량을 공유할 수 있습니다'
|
||||
: 'You can share approved vehicles from My Requests page'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{rewards.length > 0 ? (
|
||||
rewards.map((reward) => (
|
||||
<div key={reward.id} className="bg-white rounded-xl shadow p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-lg">💰</span>
|
||||
{getStatusBadge(reward.status)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-gray-500">{t.markupAmount}</p>
|
||||
<p className="font-medium">{formatPrice(reward.markup_amount)}원</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">{t.rewardAmount} (90%)</p>
|
||||
<p className="font-medium text-green-600">{formatPrice(reward.reward_amount)}원</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">{t.taxWithheld}</p>
|
||||
<p className="font-medium text-red-600">-{formatPrice(reward.tax_amount)}원</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">{t.netAmount}</p>
|
||||
<p className="font-bold text-primary-600">{formatPrice(reward.net_amount)}원</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 pt-3 border-t">
|
||||
<span className="text-xs text-gray-500">
|
||||
{new Date(reward.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="bg-white rounded-xl shadow p-8 text-center">
|
||||
<div className="text-4xl mb-4">💰</div>
|
||||
<p className="text-gray-500">{t.noRewards}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SidebarLayout>
|
||||
);
|
||||
}
|
||||
317
frontend/src/app/notifications/page.tsx
Normal file
317
frontend/src/app/notifications/page.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { notificationApi, Notification } from '@/lib/api';
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuthStore();
|
||||
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [filter, setFilter] = useState<'all' | 'unread'>('all');
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
// Redirect if not logged in
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
router.push('/login?redirect=/notifications');
|
||||
}
|
||||
}, [user, router]);
|
||||
|
||||
// Fetch notifications
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await notificationApi.getNotifications(
|
||||
page,
|
||||
pageSize,
|
||||
filter === 'unread'
|
||||
);
|
||||
setNotifications(response.notifications);
|
||||
setTotal(response.total);
|
||||
setUnreadCount(response.unread_count);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch notifications:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchNotifications();
|
||||
}, [user, page, filter]);
|
||||
|
||||
// Mark as read and navigate
|
||||
const handleNotificationClick = async (notification: Notification) => {
|
||||
if (!notification.is_read) {
|
||||
try {
|
||||
await notificationApi.markAsRead([notification.id]);
|
||||
setNotifications(prev =>
|
||||
prev.map(n => n.id === notification.id ? { ...n, is_read: true } : n)
|
||||
);
|
||||
setUnreadCount(prev => Math.max(0, prev - 1));
|
||||
} catch (error) {
|
||||
console.error('Failed to mark as read:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (notification.link) {
|
||||
router.push(notification.link);
|
||||
}
|
||||
};
|
||||
|
||||
// Mark all as read
|
||||
const handleMarkAllRead = async () => {
|
||||
try {
|
||||
await notificationApi.markAllAsRead();
|
||||
setNotifications(prev => prev.map(n => ({ ...n, is_read: true })));
|
||||
setUnreadCount(0);
|
||||
} catch (error) {
|
||||
console.error('Failed to mark all as read:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Delete notification
|
||||
const handleDelete = async (e: React.MouseEvent, notificationId: number) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await notificationApi.deleteNotification(notificationId);
|
||||
setNotifications(prev => prev.filter(n => n.id !== notificationId));
|
||||
setTotal(prev => prev - 1);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete notification:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Format time
|
||||
const formatTime = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffMins < 1) return '방금 전';
|
||||
if (diffMins < 60) return `${diffMins}분 전`;
|
||||
if (diffHours < 24) return `${diffHours}시간 전`;
|
||||
if (diffDays < 7) return `${diffDays}일 전`;
|
||||
return date.toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
// Get notification icon
|
||||
const getNotificationIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'vehicle_recommended': return '🚗';
|
||||
case 'shipping_update': return '🚚';
|
||||
case 'withdrawal_processed': return '💰';
|
||||
case 'referral_reward': return '🎁';
|
||||
case 'dealer_approved': return '✅';
|
||||
case 'dealer_rejected': return '❌';
|
||||
case 'share_purchased': return '🎉';
|
||||
case 'system': return '📢';
|
||||
default: return '🔔';
|
||||
}
|
||||
};
|
||||
|
||||
// Get notification type label
|
||||
const getTypeLabel = (type: string) => {
|
||||
switch (type) {
|
||||
case 'vehicle_recommended': return '차량 추천';
|
||||
case 'shipping_update': return '배송 업데이트';
|
||||
case 'withdrawal_processed': return '출금 처리';
|
||||
case 'referral_reward': return '레퍼럴 보상';
|
||||
case 'dealer_approved': return '딜러 승인';
|
||||
case 'dealer_rejected': return '딜러 거부';
|
||||
case 'share_purchased': return '공유 판매';
|
||||
case 'system': return '시스템 알림';
|
||||
default: return '알림';
|
||||
}
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="container mx-auto px-4 max-w-3xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-800">
|
||||
{t.notifications || '알림'}
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
{unreadCount > 0 ? (
|
||||
<span className="text-primary-600 font-medium">
|
||||
읽지 않은 알림 {unreadCount}개
|
||||
</span>
|
||||
) : (
|
||||
'모든 알림을 확인했습니다'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={handleMarkAllRead}
|
||||
className="text-primary-600 hover:text-primary-700 text-sm font-medium"
|
||||
>
|
||||
{t.markAllRead || '모두 읽음 처리'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter Tabs */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button
|
||||
onClick={() => { setFilter('all'); setPage(1); }}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition ${
|
||||
filter === 'all'
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-white text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
전체
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setFilter('unread'); setPage(1); }}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition ${
|
||||
filter === 'unread'
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-white text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
읽지 않음 ({unreadCount})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Notification List */}
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-primary-600 border-t-transparent rounded-full mx-auto"></div>
|
||||
<p className="mt-4 text-gray-500">알림을 불러오는 중...</p>
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<div className="text-6xl mb-4">🔔</div>
|
||||
<p className="text-gray-500 text-lg">
|
||||
{filter === 'unread' ? '읽지 않은 알림이 없습니다' : '알림이 없습니다'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{notifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
onClick={() => handleNotificationClick(notification)}
|
||||
className={`p-4 hover:bg-gray-50 cursor-pointer transition ${
|
||||
!notification.is_read ? 'bg-blue-50' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Icon */}
|
||||
<div className="text-3xl flex-shrink-0">
|
||||
{getNotificationIcon(notification.notification_type)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs text-gray-500 bg-gray-100 px-2 py-0.5 rounded">
|
||||
{getTypeLabel(notification.notification_type)}
|
||||
</span>
|
||||
{!notification.is_read && (
|
||||
<span className="w-2 h-2 bg-blue-500 rounded-full"></span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className={`text-base ${!notification.is_read ? 'font-semibold' : 'font-medium'} text-gray-800`}>
|
||||
{notification.title}
|
||||
</h3>
|
||||
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{notification.message}
|
||||
</p>
|
||||
|
||||
<p className="text-xs text-gray-400 mt-2">
|
||||
{formatTime(notification.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex-shrink-0">
|
||||
<button
|
||||
onClick={(e) => handleDelete(e, notification.id)}
|
||||
className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-full transition"
|
||||
title="삭제"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center items-center gap-2 mt-6">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-4 py-2 bg-white rounded-lg shadow disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
|
||||
>
|
||||
이전
|
||||
</button>
|
||||
|
||||
<span className="px-4 py-2 text-gray-600">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="px-4 py-2 bg-white rounded-lg shadow disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
|
||||
>
|
||||
다음
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Back Link */}
|
||||
<div className="mt-8 text-center">
|
||||
<Link href="/" className="text-primary-600 hover:text-primary-700">
|
||||
← 홈으로 돌아가기
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
frontend/src/app/page.tsx
Normal file
120
frontend/src/app/page.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import FilmStripSlider from '@/components/FilmStripSlider';
|
||||
import { HeroBanner, HeroBannerSettings } from '@/types';
|
||||
import { heroBannersApi } from '@/lib/api';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
export default function Home() {
|
||||
const { t, language } = useTranslation();
|
||||
const [banners, setBanners] = useState<HeroBanner[]>([]);
|
||||
const [bannerSettings, setBannerSettings] = useState<HeroBannerSettings | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
loadBanners();
|
||||
}, []);
|
||||
|
||||
const loadBanners = async () => {
|
||||
try {
|
||||
const [bannersData, settingsData] = await Promise.all([
|
||||
heroBannersApi.getList(language),
|
||||
heroBannersApi.getSettings(),
|
||||
]);
|
||||
setBanners(bannersData);
|
||||
setBannerSettings(settingsData);
|
||||
} catch (error) {
|
||||
console.error('Failed to load banners:', error);
|
||||
// 에러 시 샘플 배너 사용 (FilmStripSlider 내부에서 처리)
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Hero Section with Film Strip Slider */}
|
||||
<section className="bg-gradient-to-r from-primary-700 to-primary-900 text-white">
|
||||
<div className="container mx-auto px-4 pt-12 pb-4 text-center">
|
||||
<h1 className="text-4xl md:text-5xl font-bold mb-4">
|
||||
{t.premiumKoreanUsedCars}
|
||||
</h1>
|
||||
<p className="text-xl md:text-2xl text-primary-100 mb-6">
|
||||
{t.qualityVehiclesExported}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Film Strip Slider */}
|
||||
<FilmStripSlider banners={banners} settings={bannerSettings} />
|
||||
|
||||
<div className="container mx-auto px-4 py-8 text-center">
|
||||
<Link
|
||||
href="/vehicle-request"
|
||||
className="inline-block bg-yellow-500 text-white font-semibold px-8 py-3 rounded-lg hover:bg-yellow-600 transition shadow-lg"
|
||||
>
|
||||
{t.requestVehicle}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features */}
|
||||
<section className="py-16 bg-gray-50">
|
||||
<div className="container mx-auto px-4">
|
||||
<h2 className="text-3xl font-bold text-center mb-12">{t.whyChooseUs}</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div className="text-center p-6">
|
||||
<div className="w-16 h-16 bg-primary-100 text-primary-600 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-2">{t.qualityAssured}</h3>
|
||||
<p className="text-gray-600">{t.qualityAssuredDesc}</p>
|
||||
</div>
|
||||
<div className="text-center p-6">
|
||||
<div className="w-16 h-16 bg-primary-100 text-primary-600 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-2">{t.bestPrices}</h3>
|
||||
<p className="text-gray-600">{t.bestPricesDesc}</p>
|
||||
</div>
|
||||
<div className="text-center p-6">
|
||||
<div className="w-16 h-16 bg-primary-100 text-primary-600 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M3 14h18m-9-4v8m-7 0h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-2">{t.fullSupport}</h3>
|
||||
<p className="text-gray-600">{t.fullSupportDesc}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className="bg-primary-700 text-white py-16">
|
||||
<div className="container mx-auto px-4 text-center">
|
||||
<h2 className="text-3xl font-bold mb-4">{t.readyToFindYourCar}</h2>
|
||||
<p className="text-primary-100 mb-8 max-w-2xl mx-auto">
|
||||
{t.browseOurCollection}
|
||||
</p>
|
||||
<div className="flex justify-center space-x-4">
|
||||
<Link
|
||||
href="/cars"
|
||||
className="bg-white text-primary-700 font-semibold px-6 py-3 rounded-lg hover:bg-primary-100 transition"
|
||||
>
|
||||
{t.browseCars}
|
||||
</Link>
|
||||
<Link
|
||||
href="/contact"
|
||||
className="border-2 border-white text-white font-semibold px-6 py-3 rounded-lg hover:bg-white hover:text-primary-700 transition"
|
||||
>
|
||||
{t.contactUs}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
390
frontend/src/app/profile/page.tsx
Normal file
390
frontend/src/app/profile/page.tsx
Normal file
@@ -0,0 +1,390 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
import { authApi } from '@/lib/api';
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { t, language } = useTranslation();
|
||||
const { user, token, setUser } = useAuthStore();
|
||||
const router = useRouter();
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// 탈퇴 관련 state
|
||||
const [showWithdrawModal, setShowWithdrawModal] = useState(false);
|
||||
const [withdrawPassword, setWithdrawPassword] = useState('');
|
||||
const [withdrawReason, setWithdrawReason] = useState('');
|
||||
const [withdrawLoading, setWithdrawLoading] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
phone: '',
|
||||
country: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
setFormData({
|
||||
name: user.name || '',
|
||||
phone: user.phone || '',
|
||||
country: user.country || 'Mongolia',
|
||||
});
|
||||
}, [user, router]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!token) return;
|
||||
|
||||
setLoading(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/auth/me`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const updatedUser = await response.json();
|
||||
setUser(updatedUser);
|
||||
setMessage({ type: 'success', text: t.profileUpdated });
|
||||
setIsEditing(false);
|
||||
} else {
|
||||
const error = await response.json();
|
||||
setMessage({ type: 'error', text: error.detail || t.updateFailed });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Update failed:', error);
|
||||
setMessage({ type: 'error', text: t.updateFailed });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyReferralLink = () => {
|
||||
if (!user?.referral_code) return;
|
||||
|
||||
const referralLink = `${window.location.origin}/register?ref=${user.referral_code}`;
|
||||
navigator.clipboard.writeText(referralLink).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
const handleWithdraw = async () => {
|
||||
if (!withdrawPassword) {
|
||||
setMessage({ type: 'error', text: language === 'ko' ? '비밀번호를 입력해주세요.' : 'Please enter your password.' });
|
||||
return;
|
||||
}
|
||||
|
||||
setWithdrawLoading(true);
|
||||
try {
|
||||
await authApi.requestWithdrawal(withdrawPassword, withdrawReason || undefined);
|
||||
setShowWithdrawModal(false);
|
||||
// 로그아웃 처리
|
||||
localStorage.removeItem('token');
|
||||
setUser(null);
|
||||
router.push('/');
|
||||
alert(language === 'ko' ? '탈퇴 요청이 완료되었습니다.' : 'Withdrawal request completed.');
|
||||
} catch (error: any) {
|
||||
const detail = error.response?.data?.detail || (language === 'ko' ? '탈퇴 요청 실패' : 'Withdrawal request failed');
|
||||
setMessage({ type: 'error', text: detail });
|
||||
} finally {
|
||||
setWithdrawLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-12">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-2">{t.profileTitle}</h1>
|
||||
<p className="text-gray-600">{t.profileSubtitle}</p>
|
||||
</div>
|
||||
|
||||
{/* Profile Card */}
|
||||
<div className="bg-white rounded-xl shadow-lg p-6 mb-6">
|
||||
{/* CC Balance Display */}
|
||||
<div className="bg-gradient-to-r from-primary-600 to-primary-700 text-white rounded-lg p-4 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-primary-100 text-sm">{t.ccBalance}</p>
|
||||
<p className="text-3xl font-bold">{user.cc_balance} CC</p>
|
||||
</div>
|
||||
<div className="text-5xl opacity-50">💰</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
{message && (
|
||||
<div className={`mb-4 p-4 rounded-lg ${
|
||||
message.type === 'success' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Profile Form */}
|
||||
<div className="space-y-4">
|
||||
{/* Email (non-editable) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t.email}</label>
|
||||
<div className="w-full px-4 py-3 bg-gray-100 border border-gray-200 rounded-lg text-gray-600">
|
||||
{user.email}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t.name}</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder={t.name}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-lg">
|
||||
{user.name || '-'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Phone */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t.phone}</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder={t.phone}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-lg">
|
||||
{user.phone || '-'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Country */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t.country}</label>
|
||||
{isEditing ? (
|
||||
<select
|
||||
value={formData.country}
|
||||
onChange={(e) => setFormData({ ...formData, country: e.target.value })}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
>
|
||||
<option value="Mongolia">Mongolia</option>
|
||||
<option value="Korea">Korea</option>
|
||||
<option value="Russia">Russia</option>
|
||||
<option value="China">China</option>
|
||||
<option value="Other">Other</option>
|
||||
</select>
|
||||
) : (
|
||||
<div className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-lg">
|
||||
{user.country || 'Mongolia'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit/Save Buttons */}
|
||||
<div className="mt-6 flex gap-3">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsEditing(false);
|
||||
setFormData({
|
||||
name: user.name || '',
|
||||
phone: user.phone || '',
|
||||
country: user.country || 'Mongolia',
|
||||
});
|
||||
}}
|
||||
className="flex-1 px-4 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition"
|
||||
>
|
||||
{t.close}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={loading}
|
||||
className="flex-1 px-4 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50 transition"
|
||||
>
|
||||
{loading ? t.saving : t.saveChanges}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="w-full px-4 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition"
|
||||
>
|
||||
{t.editProfile}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Referral Code Card */}
|
||||
<div className="bg-white rounded-xl shadow-lg p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t.myReferralCode}</h2>
|
||||
|
||||
<div className="bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 rounded-lg p-4 mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-green-600 mb-1">
|
||||
{language === 'ko' ? '친구를 초대하고 보상을 받으세요!' : 'Invite friends and get rewards!'}
|
||||
</p>
|
||||
<p className="text-2xl font-mono font-bold text-green-700">
|
||||
{user.referral_code || '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-4xl">🎁</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={copyReferralLink}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<span>✓</span>
|
||||
<span>{t.copied}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>📋</span>
|
||||
<span>{t.copyReferralCode}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{copied && (
|
||||
<p className="mt-2 text-sm text-green-600 text-center">{t.referralLinkCopied}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Account Info */}
|
||||
<div className="mt-6 text-center text-sm text-gray-500">
|
||||
<p>
|
||||
{language === 'ko' ? '가입일: ' : 'Member since: '}
|
||||
{new Date(user.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Danger Zone - Account Withdrawal */}
|
||||
<div className="mt-8 bg-white rounded-xl shadow-lg p-6 border border-red-100">
|
||||
<h2 className="text-xl font-semibold text-red-600 mb-4">
|
||||
{language === 'ko' ? '계정 탈퇴' : 'Account Withdrawal'}
|
||||
</h2>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
{language === 'ko'
|
||||
? '계정을 탈퇴하면 모든 데이터가 삭제됩니다. 이 작업은 되돌릴 수 없습니다.'
|
||||
: 'Once you withdraw your account, all your data will be deleted. This action cannot be undone.'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowWithdrawModal(true)}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition"
|
||||
>
|
||||
{language === 'ko' ? '계정 탈퇴 요청' : 'Request Account Withdrawal'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Withdrawal Modal */}
|
||||
{showWithdrawModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl max-w-md w-full p-6">
|
||||
<h3 className="text-xl font-bold text-red-600 mb-4">
|
||||
{language === 'ko' ? '계정 탈퇴' : 'Account Withdrawal'}
|
||||
</h3>
|
||||
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
|
||||
<p className="text-red-700 text-sm">
|
||||
{language === 'ko'
|
||||
? '⚠️ 주의: 탈퇴 후 모든 데이터(CC 잔액, 구매 기록 등)가 삭제됩니다.'
|
||||
: '⚠️ Warning: After withdrawal, all data (CC balance, purchase history, etc.) will be deleted.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{language === 'ko' ? '비밀번호 확인' : 'Confirm Password'} *
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={withdrawPassword}
|
||||
onChange={(e) => setWithdrawPassword(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500"
|
||||
placeholder={language === 'ko' ? '비밀번호를 입력하세요' : 'Enter your password'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{language === 'ko' ? '탈퇴 사유 (선택)' : 'Reason for withdrawal (optional)'}
|
||||
</label>
|
||||
<textarea
|
||||
value={withdrawReason}
|
||||
onChange={(e) => setWithdrawReason(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 resize-none"
|
||||
rows={3}
|
||||
placeholder={language === 'ko' ? '탈퇴 사유를 입력해주세요' : 'Please enter your reason'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 mt-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowWithdrawModal(false);
|
||||
setWithdrawPassword('');
|
||||
setWithdrawReason('');
|
||||
}}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition"
|
||||
>
|
||||
{language === 'ko' ? '취소' : 'Cancel'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleWithdraw}
|
||||
disabled={withdrawLoading}
|
||||
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 transition"
|
||||
>
|
||||
{withdrawLoading
|
||||
? (language === 'ko' ? '처리 중...' : 'Processing...')
|
||||
: (language === 'ko' ? '탈퇴하기' : 'Withdraw')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
411
frontend/src/app/register/page.tsx
Normal file
411
frontend/src/app/register/page.tsx
Normal file
@@ -0,0 +1,411 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { authApi, verificationApi } from '@/lib/api';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
type RegistrationStep = 'email' | 'verify' | 'details';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
const { language, t } = useTranslation();
|
||||
|
||||
const [step, setStep] = useState<RegistrationStep>('email');
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
verificationCode: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
name: '',
|
||||
phone: '',
|
||||
country: 'Mongolia',
|
||||
});
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
|
||||
// Countdown timer for resend
|
||||
useEffect(() => {
|
||||
if (countdown > 0) {
|
||||
const timer = setTimeout(() => setCountdown(countdown - 1), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [countdown]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
setFormData({ ...formData, [e.target.name]: e.target.value });
|
||||
setError('');
|
||||
};
|
||||
|
||||
const handleSendCode = async () => {
|
||||
if (!formData.email) {
|
||||
setError(t.enterEmail || 'Please enter your email');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await verificationApi.sendEmailCodePreregister(formData.email, language);
|
||||
setSuccess(t.verificationCodeSent || 'Verification code sent to your email');
|
||||
setStep('verify');
|
||||
setCountdown(60);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || t.failedToSendCode || 'Failed to send verification code');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyCode = async () => {
|
||||
if (!formData.verificationCode) {
|
||||
setError(t.enterVerificationCode || 'Please enter the verification code');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await verificationApi.verifyEmailCodePreregister(formData.email, formData.verificationCode);
|
||||
setSuccess(t.emailVerified || 'Email verified successfully');
|
||||
setStep('details');
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || t.invalidVerificationCode || 'Invalid verification code');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResendCode = async () => {
|
||||
if (countdown > 0) return;
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await verificationApi.sendEmailCodePreregister(formData.email, language);
|
||||
setSuccess(t.verificationCodeResent || 'New verification code sent');
|
||||
setCountdown(60);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || t.failedToSendCode || 'Failed to send verification code');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
setError(t.passwordsDoNotMatch || 'Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.password.length < 6) {
|
||||
setError(t.passwordTooShort || 'Password must be at least 6 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await authApi.register({
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
name: formData.name,
|
||||
phone: formData.phone,
|
||||
country: formData.country,
|
||||
});
|
||||
|
||||
router.push('/login?registered=true');
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || t.registrationFailed || 'Registration failed. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center py-12 px-4">
|
||||
<div className="max-w-md w-full">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-800">{t.createAccount || 'Create Account'}</h1>
|
||||
<p className="text-gray-600 mt-2">{t.joinAutonet || 'Join AutonetSellCar today'}</p>
|
||||
</div>
|
||||
|
||||
{/* Progress Steps */}
|
||||
<div className="flex items-center justify-center mb-8">
|
||||
<div className={`flex items-center ${step === 'email' ? 'text-primary-600' : 'text-gray-400'}`}>
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${step === 'email' ? 'bg-primary-600 text-white' : step === 'verify' || step === 'details' ? 'bg-green-500 text-white' : 'bg-gray-300'}`}>
|
||||
{step === 'verify' || step === 'details' ? '✓' : '1'}
|
||||
</div>
|
||||
<span className="ml-2 text-sm font-medium">{t.email || 'Email'}</span>
|
||||
</div>
|
||||
<div className="w-12 h-0.5 bg-gray-300 mx-2"></div>
|
||||
<div className={`flex items-center ${step === 'verify' ? 'text-primary-600' : 'text-gray-400'}`}>
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${step === 'verify' ? 'bg-primary-600 text-white' : step === 'details' ? 'bg-green-500 text-white' : 'bg-gray-300'}`}>
|
||||
{step === 'details' ? '✓' : '2'}
|
||||
</div>
|
||||
<span className="ml-2 text-sm font-medium">{t.verify || 'Verify'}</span>
|
||||
</div>
|
||||
<div className="w-12 h-0.5 bg-gray-300 mx-2"></div>
|
||||
<div className={`flex items-center ${step === 'details' ? 'text-primary-600' : 'text-gray-400'}`}>
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${step === 'details' ? 'bg-primary-600 text-white' : 'bg-gray-300'}`}>
|
||||
3
|
||||
</div>
|
||||
<span className="ml-2 text-sm font-medium">{t.details || 'Details'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md p-8">
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-600 p-4 rounded-md mb-6">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="bg-green-50 text-green-600 p-4 rounded-md mb-6">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 1: Email */}
|
||||
{step === 'email' && (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t.email || 'Email'} *
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
className="w-full border border-gray-300 rounded-md px-4 py-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="your@email.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSendCode}
|
||||
disabled={loading}
|
||||
className="w-full bg-primary-600 text-white py-3 rounded-md hover:bg-primary-700 transition disabled:opacity-50"
|
||||
>
|
||||
{loading ? (t.sending || 'Sending...') : (t.sendVerificationCode || 'Send Verification Code')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Verify Email */}
|
||||
{step === 'verify' && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
{t.verificationCodeSentTo || 'We sent a verification code to'} <strong>{formData.email}</strong>
|
||||
</p>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t.verificationCode || 'Verification Code'} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="verificationCode"
|
||||
value={formData.verificationCode}
|
||||
onChange={handleChange}
|
||||
className="w-full border border-gray-300 rounded-md px-4 py-2 focus:ring-primary-500 focus:border-primary-500 text-center text-2xl tracking-widest"
|
||||
placeholder="000000"
|
||||
maxLength={6}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleVerifyCode}
|
||||
disabled={loading}
|
||||
className="w-full bg-primary-600 text-white py-3 rounded-md hover:bg-primary-700 transition disabled:opacity-50 mb-4"
|
||||
>
|
||||
{loading ? (t.verifying || 'Verifying...') : (t.verifyCode || 'Verify Code')}
|
||||
</button>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep('email')}
|
||||
className="text-gray-600 hover:underline text-sm"
|
||||
>
|
||||
← {t.changeEmail || 'Change Email'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResendCode}
|
||||
disabled={countdown > 0 || loading}
|
||||
className="text-primary-600 hover:underline text-sm disabled:text-gray-400"
|
||||
>
|
||||
{countdown > 0 ? `${t.resendIn || 'Resend in'} ${countdown}s` : (t.resendCode || 'Resend Code')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Account Details */}
|
||||
{step === 'details' && (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t.email || 'Email'}
|
||||
</label>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
disabled
|
||||
className="w-full border border-gray-300 rounded-md px-4 py-2 bg-gray-50 text-gray-500"
|
||||
/>
|
||||
<span className="ml-2 text-green-500">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t.name || 'Name'}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
className="w-full border border-gray-300 rounded-md px-4 py-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder={t.yourName || 'Your name'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t.phone || 'Phone'}
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
name="phone"
|
||||
value={formData.phone}
|
||||
onChange={handleChange}
|
||||
className="w-full border border-gray-300 rounded-md px-4 py-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="+976 XXXX XXXX"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t.country || 'Country'}
|
||||
</label>
|
||||
<select
|
||||
name="country"
|
||||
value={formData.country}
|
||||
onChange={handleChange}
|
||||
className="w-full border border-gray-300 rounded-md px-4 py-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<option value="Mongolia">Mongolia</option>
|
||||
<option value="Russia">Russia</option>
|
||||
<option value="Kazakhstan">Kazakhstan</option>
|
||||
<option value="Other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t.password || 'Password'} *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
className="w-full border border-gray-300 rounded-md px-4 py-2 pr-10 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
{showPassword ? (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t.confirmPassword || 'Confirm Password'} *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
name="confirmPassword"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleChange}
|
||||
className="w-full border border-gray-300 rounded-md px-4 py-2 pr-10 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-primary-600 text-white py-3 rounded-md hover:bg-primary-700 transition disabled:opacity-50"
|
||||
>
|
||||
{loading ? (t.creatingAccount || 'Creating account...') : (t.createAccount || 'Create Account')}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<p className="mt-6 text-center text-gray-600">
|
||||
{t.alreadyHaveAccount || 'Already have an account?'}{' '}
|
||||
<Link href="/login" className="text-primary-600 hover:underline">
|
||||
{t.signIn || 'Sign in'}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
520
frontend/src/app/request/page.tsx
Normal file
520
frontend/src/app/request/page.tsx
Normal file
@@ -0,0 +1,520 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useTranslation, translateCarName } from '@/lib/i18n';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { carmodooApi, vehicleRequestsApi, CarmodooMaker, CarmodooModel } from '@/lib/api';
|
||||
|
||||
export default function VehicleRequestPage() {
|
||||
const router = useRouter();
|
||||
const { t, language } = useTranslation();
|
||||
const { user } = useAuthStore();
|
||||
|
||||
// Form state
|
||||
const [selectedMaker, setSelectedMaker] = useState<string>('');
|
||||
const [selectedMakerName, setSelectedMakerName] = useState<string>('');
|
||||
const [selectedModel, setSelectedModel] = useState<string>('');
|
||||
const [selectedModelName, setSelectedModelName] = useState<string>('');
|
||||
const [selectedGrade, setSelectedGrade] = useState<string>('');
|
||||
const [selectedGradeName, setSelectedGradeName] = useState<string>('');
|
||||
const [yearFrom, setYearFrom] = useState<string>('');
|
||||
const [yearTo, setYearTo] = useState<string>('');
|
||||
const [mileageMin, setMileageMin] = useState<string>('');
|
||||
const [mileageMax, setMileageMax] = useState<string>('');
|
||||
|
||||
// Data state
|
||||
const [makers, setMakers] = useState<CarmodooMaker[]>([]);
|
||||
const [models, setModels] = useState<CarmodooModel[]>([]);
|
||||
const [grades, setGrades] = useState<{ code: string; name: string }[]>([]);
|
||||
|
||||
// UI state
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [loadingMakers, setLoadingMakers] = useState(true);
|
||||
const [loadingModels, setLoadingModels] = useState(false);
|
||||
const [loadingGrades, setLoadingGrades] = useState(false);
|
||||
const [validationError, setValidationError] = useState<string>('');
|
||||
const [submitSuccess, setSubmitSuccess] = useState(false);
|
||||
|
||||
// Redirect if not logged in
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
router.push('/login?redirect=/request');
|
||||
}
|
||||
}, [user, router]);
|
||||
|
||||
// Load makers on mount
|
||||
useEffect(() => {
|
||||
const loadMakers = async () => {
|
||||
try {
|
||||
const data = await carmodooApi.getMakers();
|
||||
setMakers(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load makers:', error);
|
||||
} finally {
|
||||
setLoadingMakers(false);
|
||||
}
|
||||
};
|
||||
loadMakers();
|
||||
}, []);
|
||||
|
||||
// Load models when maker changes
|
||||
useEffect(() => {
|
||||
if (selectedMaker) {
|
||||
setLoadingModels(true);
|
||||
setSelectedModel('');
|
||||
setSelectedModelName('');
|
||||
setSelectedGrade('');
|
||||
setSelectedGradeName('');
|
||||
carmodooApi.getModels(selectedMaker)
|
||||
.then(setModels)
|
||||
.catch(console.error)
|
||||
.finally(() => setLoadingModels(false));
|
||||
} else {
|
||||
setModels([]);
|
||||
setSelectedModel('');
|
||||
setSelectedModelName('');
|
||||
}
|
||||
}, [selectedMaker]);
|
||||
|
||||
// Load grades when model changes
|
||||
useEffect(() => {
|
||||
if (selectedMaker && selectedModel) {
|
||||
setLoadingGrades(true);
|
||||
setSelectedGrade('');
|
||||
setSelectedGradeName('');
|
||||
carmodooApi.getGrades(selectedMaker, selectedModel)
|
||||
.then(setGrades)
|
||||
.catch((error) => {
|
||||
console.error('Failed to load grades:', error);
|
||||
setGrades([]);
|
||||
})
|
||||
.finally(() => setLoadingGrades(false));
|
||||
} else {
|
||||
setGrades([]);
|
||||
setSelectedGrade('');
|
||||
setSelectedGradeName('');
|
||||
}
|
||||
}, [selectedMaker, selectedModel]);
|
||||
|
||||
// Handle maker selection
|
||||
const handleMakerChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const code = e.target.value;
|
||||
setSelectedMaker(code);
|
||||
const maker = makers.find(m => m.code === code);
|
||||
setSelectedMakerName(maker?.name || '');
|
||||
};
|
||||
|
||||
// Handle model selection
|
||||
const handleModelChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const code = e.target.value;
|
||||
setSelectedModel(code);
|
||||
const model = models.find(m => m.code === code);
|
||||
setSelectedModelName(model?.name || '');
|
||||
};
|
||||
|
||||
// Handle grade selection
|
||||
const handleGradeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const code = e.target.value;
|
||||
setSelectedGrade(code);
|
||||
const grade = grades.find(g => g.code === code);
|
||||
setSelectedGradeName(grade?.name || '');
|
||||
};
|
||||
|
||||
// Check if required fields are filled
|
||||
const isFormValid = () => {
|
||||
return selectedMaker && selectedModel;
|
||||
};
|
||||
|
||||
// Handle form submission
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validate required fields
|
||||
if (!selectedMaker) {
|
||||
setValidationError(language === 'ko' ? '제조사를 선택해주세요.' : 'Please select a maker.');
|
||||
return;
|
||||
}
|
||||
if (!selectedModel) {
|
||||
setValidationError(language === 'ko' ? '모델을 선택해주세요.' : 'Please select a model.');
|
||||
return;
|
||||
}
|
||||
|
||||
setValidationError('');
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await vehicleRequestsApi.createRequest({
|
||||
maker_code: selectedMaker,
|
||||
maker_name: selectedMakerName,
|
||||
model_code: selectedModel,
|
||||
model_name: selectedModelName,
|
||||
grade_code: selectedGrade || undefined,
|
||||
grade_name: selectedGradeName || undefined,
|
||||
year_from: yearFrom ? parseInt(yearFrom) : undefined,
|
||||
year_to: yearTo ? parseInt(yearTo) : undefined,
|
||||
mileage_min: mileageMin ? parseInt(mileageMin) * 10000 : undefined,
|
||||
mileage_max: mileageMax ? parseInt(mileageMax) * 10000 : undefined,
|
||||
});
|
||||
|
||||
setSubmitSuccess(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to submit request:', error);
|
||||
setValidationError(language === 'ko' ? '요청 제출에 실패했습니다. 다시 시도해주세요.' : 'Failed to submit request. Please try again.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Reset form
|
||||
const handleNewRequest = () => {
|
||||
setSelectedMaker('');
|
||||
setSelectedMakerName('');
|
||||
setSelectedModel('');
|
||||
setSelectedModelName('');
|
||||
setSelectedGrade('');
|
||||
setSelectedGradeName('');
|
||||
setYearFrom('');
|
||||
setYearTo('');
|
||||
setMileageMin('');
|
||||
setMileageMax('');
|
||||
setSubmitSuccess(false);
|
||||
setValidationError('');
|
||||
};
|
||||
|
||||
// Generate year options (2010 ~ current year)
|
||||
const currentYear = new Date().getFullYear();
|
||||
const yearOptions = Array.from({ length: currentYear - 2009 }, (_, i) => currentYear - i);
|
||||
|
||||
// Mileage options (만km)
|
||||
const mileageOptions = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 15, 20];
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-gray-800 mb-4">{t.loginRequired}</h2>
|
||||
<p className="text-gray-600 mb-6">{t.loginToRequest}</p>
|
||||
<Link
|
||||
href="/login?redirect=/request"
|
||||
className="bg-primary-600 text-white px-6 py-3 rounded-lg hover:bg-primary-700 transition"
|
||||
>
|
||||
{t.login}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Success screen
|
||||
if (submitSuccess) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-2xl mx-auto bg-white rounded-lg shadow-md p-8 text-center">
|
||||
{/* Success Icon */}
|
||||
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<svg className="w-10 h-10 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Success Title */}
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-4">
|
||||
{t.requestSubmitted}
|
||||
</h1>
|
||||
|
||||
{/* 24 Hour Promise */}
|
||||
<div className="bg-primary-50 border border-primary-200 rounded-lg p-6 mb-6">
|
||||
<div className="flex items-center justify-center mb-3">
|
||||
<svg className="w-6 h-6 text-primary-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-lg font-semibold text-primary-700">
|
||||
{t.quoteWithin24Hours}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm">
|
||||
{t.reviewingRequest}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="bg-gray-50 rounded-lg p-4 mb-6 text-left">
|
||||
<h3 className="font-semibold text-gray-700 mb-3">{t.requestSummary}</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">{t.maker}</span>
|
||||
<span className="font-medium">{translateCarName(selectedMakerName, language)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">{t.model}</span>
|
||||
<span className="font-medium">{translateCarName(selectedModelName, language)}</span>
|
||||
</div>
|
||||
{selectedGradeName && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">{t.grade}</span>
|
||||
<span className="font-medium">{translateCarName(selectedGradeName, language)}</span>
|
||||
</div>
|
||||
)}
|
||||
{(yearFrom || yearTo) && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">{t.yearRange}</span>
|
||||
<span className="font-medium">
|
||||
{yearFrom || '-'} ~ {yearTo || '-'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{(mileageMin || mileageMax) && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">{t.mileageRange}</span>
|
||||
<span className="font-medium">
|
||||
{mileageMin ? `${mileageMin}${t.tenThousandKm}` : '-'} ~ {mileageMax ? `${mileageMax}${t.tenThousandKm}` : '-'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link
|
||||
href="/my-request"
|
||||
className="bg-primary-600 text-white px-6 py-3 rounded-lg hover:bg-primary-700 transition font-medium"
|
||||
>
|
||||
{t.viewMyRequests}
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleNewRequest}
|
||||
className="bg-gray-100 text-gray-700 px-6 py-3 rounded-lg hover:bg-gray-200 transition font-medium"
|
||||
>
|
||||
{t.newRequest}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="container mx-auto px-4">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-2">{t.requestVehicle}</h1>
|
||||
<p className="text-gray-600">{t.findYourDreamCar}</p>
|
||||
</div>
|
||||
|
||||
{/* 24 Hour Promise Banner */}
|
||||
<div className="max-w-4xl mx-auto bg-primary-50 border border-primary-200 rounded-lg p-4 mb-6">
|
||||
<div className="flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-primary-600 mr-2 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-primary-700 font-semibold">
|
||||
{t.quoteWithin24Hours}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Form */}
|
||||
<form onSubmit={handleSubmit} className="max-w-4xl mx-auto bg-white rounded-lg shadow-md p-6 mb-8">
|
||||
{/* Row 1: Maker, Model, Grade */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
{/* Maker */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t.maker} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={selectedMaker}
|
||||
onChange={handleMakerChange}
|
||||
disabled={loadingMakers || isSubmitting}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500 disabled:bg-gray-100"
|
||||
>
|
||||
<option value="">{t.selectMaker}</option>
|
||||
{makers.map((maker) => (
|
||||
<option key={maker.code} value={maker.code}>
|
||||
{translateCarName(maker.name, language)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Model */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t.model} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={handleModelChange}
|
||||
disabled={!selectedMaker || loadingModels || isSubmitting}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500 disabled:bg-gray-100"
|
||||
>
|
||||
<option value="">{t.selectModel}</option>
|
||||
{models.map((model) => (
|
||||
<option key={model.code} value={model.code}>
|
||||
{translateCarName(model.name, language)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Grade */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t.grade}
|
||||
</label>
|
||||
<select
|
||||
value={selectedGrade}
|
||||
onChange={handleGradeChange}
|
||||
disabled={!selectedModel || loadingGrades || isSubmitting}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500 disabled:bg-gray-100"
|
||||
>
|
||||
<option value="">{loadingGrades ? (language === 'ko' ? '로딩중...' : 'Loading...') : t.allGrades}</option>
|
||||
{grades.map((grade) => (
|
||||
<option key={grade.code} value={grade.code}>
|
||||
{translateCarName(grade.name, language)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Year Range, Mileage Range */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
{/* Year Range */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t.yearRange}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={yearFrom}
|
||||
onChange={(e) => setYearFrom(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500 disabled:bg-gray-100"
|
||||
>
|
||||
<option value="">{t.yearFrom}</option>
|
||||
{yearOptions.map((year) => (
|
||||
<option key={year} value={year}>{year}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-gray-400">~</span>
|
||||
<select
|
||||
value={yearTo}
|
||||
onChange={(e) => setYearTo(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500 disabled:bg-gray-100"
|
||||
>
|
||||
<option value="">{t.yearTo}</option>
|
||||
{yearOptions.map((year) => (
|
||||
<option key={year} value={year}>{year}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mileage Range */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t.mileageRange} ({t.tenThousandKm})
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={mileageMin}
|
||||
onChange={(e) => setMileageMin(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500 disabled:bg-gray-100"
|
||||
>
|
||||
<option value="">{t.mileageFrom}</option>
|
||||
{mileageOptions.map((m) => (
|
||||
<option key={m} value={m}>{m}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-gray-400">~</span>
|
||||
<select
|
||||
value={mileageMax}
|
||||
onChange={(e) => setMileageMax(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500 disabled:bg-gray-100"
|
||||
>
|
||||
<option value="">{t.mileageTo}</option>
|
||||
{mileageOptions.map((m) => (
|
||||
<option key={m} value={m}>{m}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Validation Error */}
|
||||
{validationError && (
|
||||
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-md">
|
||||
<p className="text-red-600 text-sm">{validationError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Required Fields Notice */}
|
||||
<div className="mt-4 text-sm text-gray-500">
|
||||
<span className="text-red-500">*</span> {language === 'ko' ? '필수 입력 항목입니다.' : 'Required fields'}
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="mt-6">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !isFormValid()}
|
||||
className="w-full bg-primary-600 text-white py-3 rounded-md hover:bg-primary-700 transition disabled:opacity-50 disabled:cursor-not-allowed font-medium flex items-center justify-center"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{t.submitting}
|
||||
</>
|
||||
) : (
|
||||
t.submitRequest
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Info Cards */}
|
||||
<div className="max-w-4xl mx-auto grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Step 1 */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6 text-center">
|
||||
<div className="w-12 h-12 bg-primary-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<span className="text-primary-600 font-bold text-lg">1</span>
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-800 mb-2">{t.step1Title}</h3>
|
||||
<p className="text-gray-600 text-sm">{t.step1Desc}</p>
|
||||
</div>
|
||||
|
||||
{/* Step 2 */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6 text-center">
|
||||
<div className="w-12 h-12 bg-primary-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<span className="text-primary-600 font-bold text-lg">2</span>
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-800 mb-2">{t.step2Title}</h3>
|
||||
<p className="text-gray-600 text-sm">{t.step2Desc}</p>
|
||||
</div>
|
||||
|
||||
{/* Step 3 */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6 text-center">
|
||||
<div className="w-12 h-12 bg-primary-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<span className="text-primary-600 font-bold text-lg">3</span>
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-800 mb-2">{t.step3Title}</h3>
|
||||
<p className="text-gray-600 text-sm">{t.step3Desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
270
frontend/src/app/settings/notifications/page.tsx
Normal file
270
frontend/src/app/settings/notifications/page.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
import { pushApi, NotificationPreferences } from '@/lib/api';
|
||||
|
||||
// Helper to convert base64 URL to Uint8Array
|
||||
function urlBase64ToUint8Array(base64String: string) {
|
||||
const padding = '='.repeat((4 - base64String.length % 4) % 4);
|
||||
const base64 = (base64String + padding)
|
||||
.replace(/-/g, '+')
|
||||
.replace(/_/g, '/');
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
export default function NotificationSettingsPage() {
|
||||
const { user } = useAuthStore();
|
||||
const { language } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [pushSupported, setPushSupported] = useState(false);
|
||||
const [pushPermission, setPushPermission] = useState<NotificationPermission>('default');
|
||||
const [isSubscribed, setIsSubscribed] = useState(false);
|
||||
const [preferences, setPreferences] = useState<NotificationPreferences | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
checkPushSupport();
|
||||
loadPreferences();
|
||||
}, [user, router]);
|
||||
|
||||
const checkPushSupport = async () => {
|
||||
if ('serviceWorker' in navigator && 'PushManager' in window) {
|
||||
setPushSupported(true);
|
||||
setPushPermission(Notification.permission);
|
||||
|
||||
// Check if already subscribed
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
setIsSubscribed(!!subscription);
|
||||
}
|
||||
};
|
||||
|
||||
const loadPreferences = async () => {
|
||||
try {
|
||||
const prefs = await pushApi.getPreferences();
|
||||
setPreferences(prefs);
|
||||
} catch (error) {
|
||||
console.error('Failed to load preferences:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const requestPermission = async () => {
|
||||
const permission = await Notification.requestPermission();
|
||||
setPushPermission(permission);
|
||||
return permission;
|
||||
};
|
||||
|
||||
const subscribeToPush = async () => {
|
||||
try {
|
||||
// Request permission if needed
|
||||
if (pushPermission !== 'granted') {
|
||||
const permission = await requestPermission();
|
||||
if (permission !== 'granted') {
|
||||
alert(language === 'ko' ? '알림 권한이 필요합니다.' : 'Notification permission is required.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Register service worker
|
||||
const registration = await navigator.serviceWorker.register('/sw.js');
|
||||
await navigator.serviceWorker.ready;
|
||||
|
||||
// Get VAPID public key
|
||||
const { public_key } = await pushApi.getVapidKey();
|
||||
|
||||
// Subscribe to push
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(public_key)
|
||||
});
|
||||
|
||||
// Send subscription to server
|
||||
const json = subscription.toJSON();
|
||||
await pushApi.subscribe({
|
||||
endpoint: json.endpoint!,
|
||||
p256dh_key: json.keys!.p256dh,
|
||||
auth_key: json.keys!.auth,
|
||||
device_info: navigator.userAgent.slice(0, 100)
|
||||
});
|
||||
|
||||
setIsSubscribed(true);
|
||||
alert(language === 'ko' ? '푸시 알림이 활성화되었습니다.' : 'Push notifications enabled.');
|
||||
} catch (error) {
|
||||
console.error('Failed to subscribe:', error);
|
||||
alert(language === 'ko' ? '푸시 알림 등록에 실패했습니다.' : 'Failed to enable push notifications.');
|
||||
}
|
||||
};
|
||||
|
||||
const unsubscribeFromPush = async () => {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
|
||||
if (subscription) {
|
||||
await subscription.unsubscribe();
|
||||
await pushApi.unsubscribe(subscription.endpoint);
|
||||
}
|
||||
|
||||
setIsSubscribed(false);
|
||||
alert(language === 'ko' ? '푸시 알림이 비활성화되었습니다.' : 'Push notifications disabled.');
|
||||
} catch (error) {
|
||||
console.error('Failed to unsubscribe:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const updatePreference = async (key: keyof NotificationPreferences, value: boolean) => {
|
||||
if (!preferences) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await pushApi.updatePreferences({ [key]: value });
|
||||
setPreferences({ ...preferences, [key]: value });
|
||||
} catch (error) {
|
||||
console.error('Failed to update preference:', error);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
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-12 w-12 border-b-2 border-primary-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const notificationTypes = [
|
||||
{ key: 'vehicle_recommended', label: language === 'ko' ? '차량 추천' : 'Vehicle Recommendations', desc: language === 'ko' ? '추천 차량이 등록되었을 때' : 'When recommended vehicles are added' },
|
||||
{ key: 'shipping_update', label: language === 'ko' ? '배송 업데이트' : 'Shipping Updates', desc: language === 'ko' ? '배송 상태가 변경되었을 때' : 'When shipping status changes' },
|
||||
{ key: 'payment_confirmed', label: language === 'ko' ? '결제 확인' : 'Payment Confirmed', desc: language === 'ko' ? '결제가 확인되었을 때' : 'When payment is confirmed' },
|
||||
{ key: 'withdrawal_processed', label: language === 'ko' ? '출금 처리' : 'Withdrawal Processed', desc: language === 'ko' ? '출금이 처리되었을 때' : 'When withdrawal is processed' },
|
||||
{ key: 'dealer_status', label: language === 'ko' ? '딜러 상태' : 'Dealer Status', desc: language === 'ko' ? '딜러 신청 결과' : 'Dealer application result' },
|
||||
{ key: 'share_purchased', label: language === 'ko' ? '공유 구매' : 'Share Purchased', desc: language === 'ko' ? '공유한 차량이 구매되었을 때' : 'When shared vehicle is purchased' },
|
||||
{ key: 'referral_reward', label: language === 'ko' ? '추천 보상' : 'Referral Reward', desc: language === 'ko' ? '추천 보상을 받았을 때' : 'When you receive referral reward' },
|
||||
{ key: 'inquiry_reply', label: language === 'ko' ? '문의 답변' : 'Inquiry Reply', desc: language === 'ko' ? '문의에 답변이 달렸을 때' : 'When your inquiry is replied' },
|
||||
{ key: 'system_announcements', label: language === 'ko' ? '시스템 공지' : 'System Announcements', desc: language === 'ko' ? '중요 공지사항' : 'Important announcements' },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-2xl mx-auto px-4">
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-6">
|
||||
{language === 'ko' ? '알림 설정' : 'Notification Settings'}
|
||||
</h1>
|
||||
|
||||
{/* Push Notification Toggle */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">
|
||||
{language === 'ko' ? '푸시 알림' : 'Push Notifications'}
|
||||
</h2>
|
||||
|
||||
{!pushSupported ? (
|
||||
<div className="text-gray-500">
|
||||
{language === 'ko' ? '이 브라우저는 푸시 알림을 지원하지 않습니다.' : 'This browser does not support push notifications.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{language === 'ko' ? '브라우저 푸시 알림' : 'Browser Push Notifications'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{pushPermission === 'granted'
|
||||
? (language === 'ko' ? '알림 권한 허용됨' : 'Permission granted')
|
||||
: pushPermission === 'denied'
|
||||
? (language === 'ko' ? '알림 권한 거부됨 (브라우저 설정에서 변경 필요)' : 'Permission denied (change in browser settings)')
|
||||
: (language === 'ko' ? '알림 권한 필요' : 'Permission required')}
|
||||
</p>
|
||||
</div>
|
||||
{pushPermission !== 'denied' && (
|
||||
<button
|
||||
onClick={isSubscribed ? unsubscribeFromPush : subscribeToPush}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition ${
|
||||
isSubscribed
|
||||
? 'bg-red-100 text-red-600 hover:bg-red-200'
|
||||
: 'bg-primary-500 text-white hover:bg-primary-600'
|
||||
}`}
|
||||
>
|
||||
{isSubscribed
|
||||
? (language === 'ko' ? '비활성화' : 'Disable')
|
||||
: (language === 'ko' ? '활성화' : 'Enable')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isSubscribed && (
|
||||
<div className="text-sm text-green-600 flex items-center gap-2">
|
||||
<span>✓</span>
|
||||
<span>{language === 'ko' ? '푸시 알림이 활성화되어 있습니다.' : 'Push notifications are enabled.'}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notification Type Preferences */}
|
||||
{preferences && (
|
||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">
|
||||
{language === 'ko' ? '알림 유형' : 'Notification Types'}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
{language === 'ko' ? '받고 싶은 알림 유형을 선택하세요.' : 'Choose which notifications you want to receive.'}
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{notificationTypes.map(({ key, label, desc }) => (
|
||||
<div key={key} className="flex items-center justify-between py-3 border-b last:border-b-0">
|
||||
<div>
|
||||
<p className="font-medium">{label}</p>
|
||||
<p className="text-sm text-gray-500">{desc}</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences[key]}
|
||||
onChange={(e) => updatePreference(key, e.target.checked)}
|
||||
disabled={saving}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-500"></div>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Back button */}
|
||||
<div className="mt-6">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
← {language === 'ko' ? '뒤로' : 'Back'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
295
frontend/src/app/share/[code]/page.tsx
Normal file
295
frontend/src/app/share/[code]/page.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { useTranslation, translateCarName } from '@/lib/i18n';
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
||||
|
||||
interface SharedVehicleData {
|
||||
share: {
|
||||
id: number;
|
||||
share_code: string;
|
||||
shared_price_krw: number;
|
||||
original_price_krw: number;
|
||||
markup_amount_krw: number;
|
||||
view_count: number;
|
||||
is_purchased: boolean;
|
||||
created_at: string;
|
||||
};
|
||||
vehicle: {
|
||||
id: number;
|
||||
car_id: number;
|
||||
maker: string;
|
||||
model: string;
|
||||
year: number;
|
||||
mileage: number;
|
||||
fuel_type: string;
|
||||
color: string;
|
||||
grade: string;
|
||||
image_url: string;
|
||||
performance_check_url: string;
|
||||
dealer_name: string;
|
||||
dealer_phone: string;
|
||||
};
|
||||
sharer: {
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function SharedVehiclePage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const { t, language } = useTranslation();
|
||||
const { user, token } = useAuthStore();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [data, setData] = useState<SharedVehicleData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [purchasing, setPurchasing] = useState(false);
|
||||
|
||||
const shareCode = params.code as string;
|
||||
|
||||
useEffect(() => {
|
||||
if (shareCode) {
|
||||
fetchSharedVehicle();
|
||||
}
|
||||
}, [shareCode]);
|
||||
|
||||
const fetchSharedVehicle = async () => {
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/api/share/${shareCode}`, {
|
||||
headers,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
setData(result);
|
||||
} else {
|
||||
setError('Vehicle not found');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch:', err);
|
||||
setError('Failed to load vehicle');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePurchase = async () => {
|
||||
if (!user) {
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
setPurchasing(true);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/share/${shareCode}/purchase`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
alert(language === 'ko' ? '구매 요청이 완료되었습니다!' : 'Purchase request submitted!');
|
||||
router.push('/my-request');
|
||||
} else {
|
||||
const err = await response.json();
|
||||
alert(err.detail || 'Purchase failed');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Purchase failed:', err);
|
||||
alert('Purchase failed');
|
||||
} finally {
|
||||
setPurchasing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatPrice = (price: number) => {
|
||||
return new Intl.NumberFormat('ko-KR').format(price);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-6xl mb-4">🚗</div>
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-2">
|
||||
{language === 'ko' ? '차량을 찾을 수 없습니다' : 'Vehicle not found'}
|
||||
</h1>
|
||||
<Link href="/" className="text-primary-600 hover:underline">
|
||||
{language === 'ko' ? '홈으로 돌아가기' : 'Go to Home'}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { share, vehicle, sharer } = data;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Shared by badge */}
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl">🎁</span>
|
||||
<div>
|
||||
<p className="font-medium text-green-800">
|
||||
{language === 'ko' ? `${sharer.name}님이 공유한 차량` : `Shared by ${sharer.name}`}
|
||||
</p>
|
||||
<p className="text-sm text-green-600">
|
||||
{language === 'ko' ? '조회수: ' : 'Views: '}{share.view_count}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vehicle Card */}
|
||||
<div className="bg-white rounded-xl shadow-lg overflow-hidden">
|
||||
{/* Image */}
|
||||
<div className="relative h-64 md:h-96 bg-gray-200">
|
||||
{vehicle.image_url ? (
|
||||
<img
|
||||
src={vehicle.image_url}
|
||||
alt={`${translateCarName(vehicle.maker, language)} ${translateCarName(vehicle.model, language)}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-400">
|
||||
<span className="text-6xl">🚗</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{share.is_purchased && (
|
||||
<div className="absolute top-4 right-4 bg-red-600 text-white px-4 py-2 rounded-lg font-bold">
|
||||
{t.purchased}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-2">
|
||||
{translateCarName(vehicle.maker, language)} {translateCarName(vehicle.model, language)}
|
||||
</h1>
|
||||
<p className="text-gray-500 mb-4">{translateCarName(vehicle.grade, language)}</p>
|
||||
|
||||
{/* Specs */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<p className="text-sm text-gray-500">{t.year}</p>
|
||||
<p className="font-semibold">{vehicle.year}</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<p className="text-sm text-gray-500">{t.mileage}</p>
|
||||
<p className="font-semibold">{formatPrice(vehicle.mileage)} km</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<p className="text-sm text-gray-500">{t.fuel}</p>
|
||||
<p className="font-semibold">{translateCarName(vehicle.fuel_type, language)}</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<p className="text-sm text-gray-500">{t.color}</p>
|
||||
<p className="font-semibold">{translateCarName(vehicle.color, language)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price */}
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">{t.sharedPrice}</p>
|
||||
<p className="text-3xl font-bold text-primary-600">
|
||||
{formatPrice(share.shared_price_krw)} {language === 'ko' ? '원' : 'KRW'}
|
||||
</p>
|
||||
</div>
|
||||
{share.markup_amount_krw > 0 && (
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-gray-500 line-through">
|
||||
{t.originalPrice}: {formatPrice(share.original_price_krw)}원
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{!share.is_purchased ? (
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={handlePurchase}
|
||||
disabled={purchasing}
|
||||
className="w-full py-4 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 disabled:opacity-50 transition"
|
||||
>
|
||||
{purchasing ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
|
||||
{t.loading}
|
||||
</span>
|
||||
) : (
|
||||
language === 'ko' ? '구매 신청하기' : 'Request Purchase'
|
||||
)}
|
||||
</button>
|
||||
|
||||
{!user && (
|
||||
<p className="text-sm text-center text-gray-500">
|
||||
{language === 'ko' ? '구매하려면 로그인이 필요합니다' : 'Login required to purchase'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-gray-100 rounded-lg p-4 text-center text-gray-600">
|
||||
{language === 'ko' ? '이미 판매된 차량입니다' : 'This vehicle has been sold'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dealer Info */}
|
||||
{vehicle.dealer_name && (
|
||||
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||
<h3 className="font-semibold text-gray-800 mb-2">{t.dealerContact}</h3>
|
||||
<p className="text-gray-600">{vehicle.dealer_name}</p>
|
||||
{vehicle.dealer_phone && (
|
||||
<p className="text-gray-600">{vehicle.dealer_phone}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Performance Check */}
|
||||
{vehicle.performance_check_url && (
|
||||
<div className="mt-4">
|
||||
<a
|
||||
href={vehicle.performance_check_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-primary-600 hover:underline"
|
||||
>
|
||||
<span>📋</span>
|
||||
{t.viewPerformanceReport}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
771
frontend/src/app/vehicle-request/page.tsx
Normal file
771
frontend/src/app/vehicle-request/page.tsx
Normal file
@@ -0,0 +1,771 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useTranslation, translateCarName } from '@/lib/i18n';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { carmodooApi, vehicleRequestsApi, ccApi, CarmodooMaker, CarmodooModel } from '@/lib/api';
|
||||
import SidebarLayout from '@/components/SidebarLayout';
|
||||
|
||||
const QUOTE_REQUEST_COST = 1; // 1 CC for quote request
|
||||
|
||||
export default function VehicleRequestPage() {
|
||||
const router = useRouter();
|
||||
const { t, language } = useTranslation();
|
||||
const { user } = useAuthStore();
|
||||
|
||||
// Form state
|
||||
const [selectedMaker, setSelectedMaker] = useState<string>('');
|
||||
const [selectedMakerName, setSelectedMakerName] = useState<string>('');
|
||||
const [selectedModel, setSelectedModel] = useState<string>('');
|
||||
const [selectedModelName, setSelectedModelName] = useState<string>('');
|
||||
const [selectedGrade, setSelectedGrade] = useState<string>('');
|
||||
const [selectedGradeName, setSelectedGradeName] = useState<string>('');
|
||||
const [yearFrom, setYearFrom] = useState<string>('');
|
||||
const [yearTo, setYearTo] = useState<string>('');
|
||||
const [mileageMin, setMileageMin] = useState<string>('');
|
||||
const [mileageMax, setMileageMax] = useState<string>('');
|
||||
const [selectedFuel, setSelectedFuel] = useState<string>('');
|
||||
const [displacementMin, setDisplacementMin] = useState<string>('');
|
||||
const [displacementMax, setDisplacementMax] = useState<string>('');
|
||||
|
||||
// Data state
|
||||
const [makers, setMakers] = useState<CarmodooMaker[]>([]);
|
||||
const [models, setModels] = useState<CarmodooModel[]>([]);
|
||||
const [grades, setGrades] = useState<{ code: string; name: string }[]>([]);
|
||||
|
||||
// UI state
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [loadingMakers, setLoadingMakers] = useState(true);
|
||||
const [loadingModels, setLoadingModels] = useState(false);
|
||||
const [loadingGrades, setLoadingGrades] = useState(false);
|
||||
const [validationError, setValidationError] = useState<string>('');
|
||||
const [submitSuccess, setSubmitSuccess] = useState(false);
|
||||
const [ccBalance, setCcBalance] = useState<number>(0);
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||
|
||||
// Redirect if not logged in
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
router.push('/login?redirect=/vehicle-request');
|
||||
}
|
||||
}, [user, router]);
|
||||
|
||||
// Load CC balance
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
ccApi.getBalance()
|
||||
.then((data) => setCcBalance(data.cc_balance || 0))
|
||||
.catch(console.error);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
// Load makers on mount
|
||||
useEffect(() => {
|
||||
const loadMakers = async () => {
|
||||
try {
|
||||
const data = await carmodooApi.getMakers();
|
||||
setMakers(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load makers:', error);
|
||||
} finally {
|
||||
setLoadingMakers(false);
|
||||
}
|
||||
};
|
||||
loadMakers();
|
||||
}, []);
|
||||
|
||||
// Load models when maker changes
|
||||
useEffect(() => {
|
||||
if (selectedMaker) {
|
||||
setLoadingModels(true);
|
||||
setSelectedModel('');
|
||||
setSelectedModelName('');
|
||||
setSelectedGrade('');
|
||||
setSelectedGradeName('');
|
||||
carmodooApi.getModels(selectedMaker)
|
||||
.then(setModels)
|
||||
.catch(console.error)
|
||||
.finally(() => setLoadingModels(false));
|
||||
} else {
|
||||
setModels([]);
|
||||
setSelectedModel('');
|
||||
setSelectedModelName('');
|
||||
}
|
||||
}, [selectedMaker]);
|
||||
|
||||
// Load grades when model changes
|
||||
useEffect(() => {
|
||||
if (selectedMaker && selectedModel) {
|
||||
setLoadingGrades(true);
|
||||
setSelectedGrade('');
|
||||
setSelectedGradeName('');
|
||||
carmodooApi.getGrades(selectedMaker, selectedModel)
|
||||
.then(setGrades)
|
||||
.catch((error) => {
|
||||
console.error('Failed to load grades:', error);
|
||||
setGrades([]);
|
||||
})
|
||||
.finally(() => setLoadingGrades(false));
|
||||
} else {
|
||||
setGrades([]);
|
||||
setSelectedGrade('');
|
||||
setSelectedGradeName('');
|
||||
}
|
||||
}, [selectedMaker, selectedModel]);
|
||||
|
||||
// Handle maker selection
|
||||
const handleMakerChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const code = e.target.value;
|
||||
setSelectedMaker(code);
|
||||
const maker = makers.find(m => m.code === code);
|
||||
setSelectedMakerName(maker?.name || '');
|
||||
};
|
||||
|
||||
// Handle model selection
|
||||
const handleModelChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const code = e.target.value;
|
||||
setSelectedModel(code);
|
||||
const model = models.find(m => m.code === code);
|
||||
setSelectedModelName(model?.name || '');
|
||||
};
|
||||
|
||||
// Handle grade selection
|
||||
const handleGradeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const code = e.target.value;
|
||||
setSelectedGrade(code);
|
||||
const grade = grades.find(g => g.code === code);
|
||||
setSelectedGradeName(grade?.name || '');
|
||||
};
|
||||
|
||||
// Check if required fields are filled
|
||||
const isFormValid = () => {
|
||||
return selectedMaker && selectedModel;
|
||||
};
|
||||
|
||||
// Handle form submission - show confirmation modal
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validate required fields
|
||||
if (!selectedMaker) {
|
||||
setValidationError(language === 'ko' ? '제조사를 선택해주세요.' : 'Please select a maker.');
|
||||
return;
|
||||
}
|
||||
if (!selectedModel) {
|
||||
setValidationError(language === 'ko' ? '모델을 선택해주세요.' : 'Please select a model.');
|
||||
return;
|
||||
}
|
||||
|
||||
setValidationError('');
|
||||
// Show confirmation modal instead of submitting directly
|
||||
setShowConfirmModal(true);
|
||||
};
|
||||
|
||||
// Confirm and submit the request
|
||||
const handleConfirmSubmit = async () => {
|
||||
setShowConfirmModal(false);
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await vehicleRequestsApi.createRequest({
|
||||
maker_code: selectedMaker,
|
||||
maker_name: selectedMakerName,
|
||||
model_code: selectedModel,
|
||||
model_name: selectedModelName,
|
||||
grade_code: selectedGrade || undefined,
|
||||
grade_name: selectedGradeName || undefined,
|
||||
year_from: yearFrom ? parseInt(yearFrom) : undefined,
|
||||
year_to: yearTo ? parseInt(yearTo) : undefined,
|
||||
mileage_min: mileageMin ? parseInt(mileageMin) * 10000 : undefined,
|
||||
mileage_max: mileageMax ? parseInt(mileageMax) * 10000 : undefined,
|
||||
fuel: selectedFuel || undefined,
|
||||
displacement_min: displacementMin ? parseInt(displacementMin) : undefined,
|
||||
displacement_max: displacementMax ? parseInt(displacementMax) : undefined,
|
||||
});
|
||||
|
||||
// Update CC balance after successful submission
|
||||
setCcBalance((prev) => prev - QUOTE_REQUEST_COST);
|
||||
setSubmitSuccess(true);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to submit request:', error);
|
||||
const errorMessage = error?.response?.data?.detail || error?.message || 'Unknown error';
|
||||
console.error('Error details:', errorMessage);
|
||||
setValidationError(
|
||||
language === 'ko'
|
||||
? `요청 제출에 실패했습니다: ${errorMessage}`
|
||||
: `Failed to submit request: ${errorMessage}`
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Reset form
|
||||
const handleNewRequest = async () => {
|
||||
setSelectedMaker('');
|
||||
setSelectedMakerName('');
|
||||
setSelectedModel('');
|
||||
setSelectedModelName('');
|
||||
setSelectedGrade('');
|
||||
setSelectedGradeName('');
|
||||
setYearFrom('');
|
||||
setYearTo('');
|
||||
setMileageMin('');
|
||||
setMileageMax('');
|
||||
setSelectedFuel('');
|
||||
setDisplacementMin('');
|
||||
setDisplacementMax('');
|
||||
setSubmitSuccess(false);
|
||||
setValidationError('');
|
||||
|
||||
// Refresh CC balance
|
||||
try {
|
||||
const data = await ccApi.getBalance();
|
||||
setCcBalance(data.cc_balance || 0);
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh CC balance:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Generate year options (2010 ~ current year)
|
||||
const currentYear = new Date().getFullYear();
|
||||
const yearOptions = Array.from({ length: currentYear - 2009 }, (_, i) => currentYear - i);
|
||||
|
||||
// Mileage options (만km)
|
||||
const mileageOptions = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 15, 20];
|
||||
|
||||
// Fuel type options
|
||||
const fuelOptions = [
|
||||
{ value: '휘발유', label: { ko: '휘발유', en: 'Gasoline', mn: 'Бензин', ru: 'Бензин' } },
|
||||
{ value: '경유', label: { ko: '디젤', en: 'Diesel', mn: 'Дизель', ru: 'Дизель' } },
|
||||
{ value: '하이브리드', label: { ko: '하이브리드', en: 'Hybrid', mn: 'Гибрид', ru: 'Гибрид' } },
|
||||
{ value: 'LPG', label: { ko: 'LPG', en: 'LPG', mn: 'LPG', ru: 'LPG' } },
|
||||
{ value: '전기', label: { ko: '전기', en: 'Electric', mn: 'Цахилгаан', ru: 'Электро' } },
|
||||
];
|
||||
|
||||
// Displacement options (cc)
|
||||
const displacementOptions = [1000, 1500, 2000, 2500, 3000, 3500, 4000, 5000];
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<SidebarLayout groupKey="quote">
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-gray-800 mb-4">{t.loginRequired}</h2>
|
||||
<p className="text-gray-600 mb-6">{t.loginToRequest}</p>
|
||||
<Link
|
||||
href="/login?redirect=/vehicle-request"
|
||||
className="bg-primary-600 text-white px-6 py-3 rounded-lg hover:bg-primary-700 transition"
|
||||
>
|
||||
{t.login}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// Success screen
|
||||
if (submitSuccess) {
|
||||
return (
|
||||
<SidebarLayout groupKey="quote">
|
||||
<div className="max-w-2xl mx-auto bg-white rounded-lg shadow-md p-8 text-center">
|
||||
{/* Success Icon */}
|
||||
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<svg className="w-10 h-10 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Success Title */}
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-4">
|
||||
{t.requestSubmitted}
|
||||
</h1>
|
||||
|
||||
{/* 24 Hour Promise */}
|
||||
<div className="bg-primary-50 border border-primary-200 rounded-lg p-6 mb-6">
|
||||
<div className="flex items-center justify-center mb-3">
|
||||
<svg className="w-6 h-6 text-primary-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-lg font-semibold text-primary-700">
|
||||
{t.quoteWithin24Hours}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm">
|
||||
{t.reviewingRequest}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="bg-gray-50 rounded-lg p-4 mb-6 text-left">
|
||||
<h3 className="font-semibold text-gray-700 mb-3">{t.requestSummary}</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">{t.maker}</span>
|
||||
<span className="font-medium">{translateCarName(selectedMakerName, language)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">{t.model}</span>
|
||||
<span className="font-medium">{translateCarName(selectedModelName, language)}</span>
|
||||
</div>
|
||||
{selectedGradeName && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">{t.grade}</span>
|
||||
<span className="font-medium">{translateCarName(selectedGradeName, language)}</span>
|
||||
</div>
|
||||
)}
|
||||
{(yearFrom || yearTo) && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">{t.yearRange}</span>
|
||||
<span className="font-medium">
|
||||
{yearFrom || '-'} ~ {yearTo || '-'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{(mileageMin || mileageMax) && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">{t.mileageRange}</span>
|
||||
<span className="font-medium">
|
||||
{mileageMin ? `${mileageMin}${t.tenThousandKm}` : '-'} ~ {mileageMax ? `${mileageMax}${t.tenThousandKm}` : '-'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link
|
||||
href="/my-request"
|
||||
className="bg-primary-600 text-white px-6 py-3 rounded-lg hover:bg-primary-700 transition font-medium"
|
||||
>
|
||||
{t.viewMyRequests}
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleNewRequest}
|
||||
className="bg-gray-100 text-gray-700 px-6 py-3 rounded-lg hover:bg-gray-200 transition font-medium"
|
||||
>
|
||||
{t.newRequest}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarLayout groupKey="quote">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-2">{t.requestVehicle}</h1>
|
||||
<p className="text-gray-600">{t.findYourDreamCar}</p>
|
||||
</div>
|
||||
|
||||
{/* 24 Hour Promise Banner */}
|
||||
<div className="bg-primary-50 border border-primary-200 rounded-lg p-4 mb-6">
|
||||
<div className="flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-primary-600 mr-2 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-primary-700 font-semibold">
|
||||
{t.quoteWithin24Hours}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Form */}
|
||||
<form onSubmit={handleSubmit} className="bg-white rounded-lg shadow-md p-6 mb-8">
|
||||
{/* Row 1: Maker, Model, Grade */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
{/* Maker */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t.maker} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={selectedMaker}
|
||||
onChange={handleMakerChange}
|
||||
disabled={loadingMakers || isSubmitting}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500 disabled:bg-gray-100"
|
||||
>
|
||||
<option value="">{t.selectMaker}</option>
|
||||
{makers.map((maker) => (
|
||||
<option key={maker.code} value={maker.code}>
|
||||
{translateCarName(maker.name, language)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Model */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t.model} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={handleModelChange}
|
||||
disabled={!selectedMaker || loadingModels || isSubmitting}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500 disabled:bg-gray-100"
|
||||
>
|
||||
<option value="">{t.selectModel}</option>
|
||||
{models.map((model) => (
|
||||
<option key={model.code} value={model.code}>
|
||||
{translateCarName(model.name, language)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Grade */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t.grade}
|
||||
</label>
|
||||
<select
|
||||
value={selectedGrade}
|
||||
onChange={handleGradeChange}
|
||||
disabled={!selectedModel || loadingGrades || isSubmitting}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500 disabled:bg-gray-100"
|
||||
>
|
||||
<option value="">{loadingGrades ? (language === 'ko' ? '로딩중...' : 'Loading...') : t.allGrades}</option>
|
||||
{grades.map((grade) => (
|
||||
<option key={grade.code} value={grade.code}>
|
||||
{translateCarName(grade.name, language)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Year Range, Mileage Range */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
{/* Year Range */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t.yearRange}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={yearFrom}
|
||||
onChange={(e) => setYearFrom(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500 disabled:bg-gray-100"
|
||||
>
|
||||
<option value="">{t.yearFrom}</option>
|
||||
{yearOptions.map((year) => (
|
||||
<option key={year} value={year}>{year}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-gray-400">~</span>
|
||||
<select
|
||||
value={yearTo}
|
||||
onChange={(e) => setYearTo(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500 disabled:bg-gray-100"
|
||||
>
|
||||
<option value="">{t.yearTo}</option>
|
||||
{yearOptions.map((year) => (
|
||||
<option key={year} value={year}>{year}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mileage Range */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t.mileageRange} ({t.tenThousandKm})
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={mileageMin}
|
||||
onChange={(e) => setMileageMin(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500 disabled:bg-gray-100"
|
||||
>
|
||||
<option value="">{t.mileageFrom}</option>
|
||||
{mileageOptions.map((m) => (
|
||||
<option key={m} value={m}>{m}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-gray-400">~</span>
|
||||
<select
|
||||
value={mileageMax}
|
||||
onChange={(e) => setMileageMax(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500 disabled:bg-gray-100"
|
||||
>
|
||||
<option value="">{t.mileageTo}</option>
|
||||
{mileageOptions.map((m) => (
|
||||
<option key={m} value={m}>{m}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3: Fuel Type, Displacement */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
{/* Fuel Type */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t.fuel}
|
||||
</label>
|
||||
<select
|
||||
value={selectedFuel}
|
||||
onChange={(e) => setSelectedFuel(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500 disabled:bg-gray-100"
|
||||
>
|
||||
<option value="">{t.allFuelTypes}</option>
|
||||
{fuelOptions.map((fuel) => (
|
||||
<option key={fuel.value} value={fuel.value}>
|
||||
{fuel.label[language as keyof typeof fuel.label] || fuel.label.en}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Displacement */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t.displacement} (cc)
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={displacementMin}
|
||||
onChange={(e) => setDisplacementMin(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500 disabled:bg-gray-100"
|
||||
>
|
||||
<option value="">{language === 'ko' ? '최소' : 'Min'}</option>
|
||||
{displacementOptions.map((d) => (
|
||||
<option key={d} value={d}>{d.toLocaleString()}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-gray-400">~</span>
|
||||
<select
|
||||
value={displacementMax}
|
||||
onChange={(e) => setDisplacementMax(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500 disabled:bg-gray-100"
|
||||
>
|
||||
<option value="">{language === 'ko' ? '최대' : 'Max'}</option>
|
||||
{displacementOptions.map((d) => (
|
||||
<option key={d} value={d}>{d.toLocaleString()}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Validation Error */}
|
||||
{validationError && (
|
||||
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-md">
|
||||
<p className="text-red-600 text-sm">{validationError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Required Fields Notice */}
|
||||
<div className="mt-4 text-sm text-gray-500">
|
||||
<span className="text-red-500">*</span> {language === 'ko' ? '필수 입력 항목입니다.' : 'Required fields'}
|
||||
</div>
|
||||
|
||||
{/* CC Cost Notice */}
|
||||
<div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<svg className="w-5 h-5 text-blue-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-blue-700 font-medium">{t.requestCost}</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-sm text-gray-600">{t.ccBalance}: </span>
|
||||
<span className={`font-bold ${ccBalance >= QUOTE_REQUEST_COST ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{ccBalance} CC
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{ccBalance < QUOTE_REQUEST_COST && (
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<p className="text-sm text-red-600">{t.insufficientCC}</p>
|
||||
<Link href="/cc" className="text-sm text-blue-600 hover:underline font-medium">
|
||||
{t.charge} →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="mt-6">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !isFormValid()}
|
||||
className="w-full bg-primary-600 text-white py-3 rounded-md hover:bg-primary-700 transition disabled:opacity-50 disabled:cursor-not-allowed font-medium flex items-center justify-center"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{t.submitting}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{t.submitRequest} ({QUOTE_REQUEST_COST} CC)
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Info Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Step 1 */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6 text-center">
|
||||
<div className="w-12 h-12 bg-primary-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<span className="text-primary-600 font-bold text-lg">1</span>
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-800 mb-2">{t.step1Title}</h3>
|
||||
<p className="text-gray-600 text-sm">{t.step1Desc}</p>
|
||||
</div>
|
||||
|
||||
{/* Step 2 */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6 text-center">
|
||||
<div className="w-12 h-12 bg-primary-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<span className="text-primary-600 font-bold text-lg">2</span>
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-800 mb-2">{t.step2Title}</h3>
|
||||
<p className="text-gray-600 text-sm">{t.step2Desc}</p>
|
||||
</div>
|
||||
|
||||
{/* Step 3 */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6 text-center">
|
||||
<div className="w-12 h-12 bg-primary-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<span className="text-primary-600 font-bold text-lg">3</span>
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-800 mb-2">{t.step3Title}</h3>
|
||||
<p className="text-gray-600 text-sm">{t.step3Desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CC Confirmation Modal */}
|
||||
{showConfirmModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4 overflow-hidden">
|
||||
{/* Modal Header */}
|
||||
<div className="bg-primary-600 text-white px-6 py-4">
|
||||
<h3 className="text-lg font-bold flex items-center">
|
||||
<svg className="w-6 h-6 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{t.confirmPayment || 'Confirm Payment'}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Modal Body */}
|
||||
<div className="px-6 py-6">
|
||||
{ccBalance >= QUOTE_REQUEST_COST ? (
|
||||
<>
|
||||
{/* Sufficient CC */}
|
||||
<div className="text-center mb-6">
|
||||
<div className="w-16 h-16 bg-yellow-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<span className="text-3xl font-bold text-yellow-600">CC</span>
|
||||
</div>
|
||||
<p className="text-gray-700 text-lg mb-2">
|
||||
{t.requestRequiresCC || 'This request requires'}
|
||||
</p>
|
||||
<p className="text-3xl font-bold text-primary-600">{QUOTE_REQUEST_COST} CC</p>
|
||||
</div>
|
||||
|
||||
{/* Balance Info */}
|
||||
<div className="bg-gray-50 rounded-lg p-4 mb-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">{t.currentBalance || 'Current Balance'}</span>
|
||||
<span className="font-bold text-green-600">{ccBalance} CC</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mt-2 pt-2 border-t">
|
||||
<span className="text-gray-600">{t.afterPayment || 'After Payment'}</span>
|
||||
<span className="font-bold text-gray-800">{ccBalance - QUOTE_REQUEST_COST} CC</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmModal(false)}
|
||||
className="flex-1 px-4 py-3 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition font-medium"
|
||||
>
|
||||
{t.cancel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirmSubmit}
|
||||
className="flex-1 px-4 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition font-medium"
|
||||
>
|
||||
{t.confirmAndPay || 'Confirm & Pay'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Insufficient CC */}
|
||||
<div className="text-center mb-6">
|
||||
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-gray-700 text-lg mb-2">{t.insufficientCC}</p>
|
||||
<p className="text-gray-500 text-sm">
|
||||
{t.needMoreCC || 'You need'} <span className="font-bold text-red-600">{QUOTE_REQUEST_COST - ccBalance} CC</span> {t.more || 'more'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Balance Info */}
|
||||
<div className="bg-red-50 rounded-lg p-4 mb-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">{t.required || 'Required'}</span>
|
||||
<span className="font-bold text-gray-800">{QUOTE_REQUEST_COST} CC</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mt-2 pt-2 border-t border-red-200">
|
||||
<span className="text-gray-600">{t.currentBalance || 'Current Balance'}</span>
|
||||
<span className="font-bold text-red-600">{ccBalance} CC</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmModal(false)}
|
||||
className="flex-1 px-4 py-3 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition font-medium"
|
||||
>
|
||||
{t.cancel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/cc')}
|
||||
className="flex-1 px-4 py-3 bg-yellow-500 text-white rounded-lg hover:bg-yellow-600 transition font-medium"
|
||||
>
|
||||
{t.chargeCC || 'Charge CC'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SidebarLayout>
|
||||
);
|
||||
}
|
||||
370
frontend/src/app/withdrawal/page.tsx
Normal file
370
frontend/src/app/withdrawal/page.tsx
Normal file
@@ -0,0 +1,370 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
import { withdrawalApi, WithdrawalBalance, WithdrawalRequest } from '@/lib/api';
|
||||
import SidebarLayout from '@/components/SidebarLayout';
|
||||
|
||||
export default function WithdrawalPage() {
|
||||
const { t, language } = useTranslation();
|
||||
const { user, token } = useAuthStore();
|
||||
const router = useRouter();
|
||||
|
||||
const [balance, setBalance] = useState<WithdrawalBalance | null>(null);
|
||||
const [requests, setRequests] = useState<WithdrawalRequest[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
// Form state
|
||||
const [amount, setAmount] = useState('');
|
||||
const [bankName, setBankName] = useState('');
|
||||
const [bankAccount, setBankAccount] = useState('');
|
||||
const [accountHolder, setAccountHolder] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
fetchData();
|
||||
}, [user, router]);
|
||||
|
||||
const fetchData = async () => {
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
const [balanceData, requestsData] = await Promise.all([
|
||||
withdrawalApi.getBalance(),
|
||||
withdrawalApi.getMyRequests(),
|
||||
]);
|
||||
|
||||
setBalance(balanceData);
|
||||
setRequests(requestsData);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!balance) return;
|
||||
|
||||
const amountNum = parseFloat(amount);
|
||||
if (isNaN(amountNum) || amountNum <= 0) {
|
||||
setMessage({ type: 'error', text: language === 'ko' ? '유효한 금액을 입력해주세요' : 'Please enter a valid amount' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (amountNum < 10) {
|
||||
setMessage({ type: 'error', text: t.minWithdrawal });
|
||||
return;
|
||||
}
|
||||
|
||||
if (amountNum > balance.available_balance) {
|
||||
setMessage({ type: 'error', text: language === 'ko' ? '잔액이 부족합니다' : 'Insufficient balance' });
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
await withdrawalApi.createRequest({
|
||||
amount: amountNum,
|
||||
bank_name: bankName,
|
||||
bank_account: bankAccount,
|
||||
account_holder: accountHolder,
|
||||
});
|
||||
|
||||
setMessage({ type: 'success', text: t.withdrawalRequested });
|
||||
setShowForm(false);
|
||||
setAmount('');
|
||||
setBankName('');
|
||||
setBankAccount('');
|
||||
setAccountHolder('');
|
||||
|
||||
// Refresh data
|
||||
fetchData();
|
||||
} catch (error: any) {
|
||||
const errorMsg = error.response?.data?.detail || t.withdrawalFailed;
|
||||
setMessage({ type: 'error', text: errorMsg });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatPrice = (price: number) => {
|
||||
return new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(price);
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return <span className="px-2 py-1 bg-yellow-100 text-yellow-700 rounded text-xs">{t.withdrawalPending}</span>;
|
||||
case 'approved':
|
||||
return <span className="px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs">{t.withdrawalApproved}</span>;
|
||||
case 'completed':
|
||||
return <span className="px-2 py-1 bg-green-100 text-green-700 rounded text-xs">{t.withdrawalCompleted}</span>;
|
||||
case 'rejected':
|
||||
return <span className="px-2 py-1 bg-red-100 text-red-700 rounded text-xs">{t.withdrawalRejected}</span>;
|
||||
default:
|
||||
return <span className="px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs">{status}</span>;
|
||||
}
|
||||
};
|
||||
|
||||
const calculateNetAmount = (amt: string) => {
|
||||
const amountNum = parseFloat(amt);
|
||||
if (isNaN(amountNum) || amountNum <= 0) return 0;
|
||||
const tax = amountNum * 0.033;
|
||||
return amountNum - tax;
|
||||
};
|
||||
|
||||
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-12 w-12 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarLayout groupKey="billing">
|
||||
<div className="container mx-auto">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800">{t.withdrawal}</h1>
|
||||
<p className="text-gray-600">{t.withdrawalRequest}</p>
|
||||
</div>
|
||||
|
||||
{/* Balance Card */}
|
||||
{balance && (
|
||||
<div className="bg-gradient-to-r from-primary-600 to-primary-700 text-white rounded-xl p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">{t.availableBalance}</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-primary-100 text-sm">{language === 'ko' ? '총 수익' : 'Total Earned'}</p>
|
||||
<p className="text-xl font-bold">${formatPrice(balance.total_earned)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-primary-100 text-sm">{t.availableBalance}</p>
|
||||
<p className="text-2xl font-bold">${formatPrice(balance.available_balance)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-primary-100 text-sm">{language === 'ko' ? '출금 완료' : 'Withdrawn'}</p>
|
||||
<p className="text-xl font-bold">${formatPrice(balance.total_withdrawn)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-primary-100 text-sm">{language === 'ko' ? '대기 중' : 'Pending'}</p>
|
||||
<p className="text-xl font-bold">${formatPrice(balance.pending_withdrawal)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message */}
|
||||
{message && (
|
||||
<div className={`p-4 rounded-lg mb-6 ${
|
||||
message.type === 'success' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Withdrawal Form */}
|
||||
{!showForm ? (
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
disabled={!balance || balance.available_balance < 10}
|
||||
className={`w-full py-4 rounded-xl font-semibold transition mb-6 ${
|
||||
balance && balance.available_balance >= 10
|
||||
? 'bg-primary-600 text-white hover:bg-primary-700'
|
||||
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{t.requestWithdrawal}
|
||||
</button>
|
||||
) : (
|
||||
<div className="bg-white rounded-xl shadow p-6 mb-6">
|
||||
<h3 className="text-lg font-semibold mb-4">{t.withdrawalRequest}</h3>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t.withdrawalAmount}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
placeholder="0"
|
||||
className="w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
required
|
||||
min="10"
|
||||
step="0.01"
|
||||
max={balance?.available_balance || 0}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">{t.minWithdrawal}</p>
|
||||
{amount && parseFloat(amount) > 0 && (
|
||||
<div className="mt-2 p-3 bg-gray-50 rounded-lg text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">{t.withdrawalAmount}</span>
|
||||
<span>${formatPrice(parseFloat(amount))}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-red-600">
|
||||
<span>{t.taxWithheld}</span>
|
||||
<span>-${formatPrice(parseFloat(amount) * 0.033)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between font-bold mt-2 pt-2 border-t">
|
||||
<span>{t.netAmount}</span>
|
||||
<span className="text-primary-600">${formatPrice(calculateNetAmount(amount))}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t.bankName}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={bankName}
|
||||
onChange={(e) => setBankName(e.target.value)}
|
||||
placeholder={language === 'ko' ? '국민은행' : 'Bank Name'}
|
||||
className="w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t.bankAccount}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={bankAccount}
|
||||
onChange={(e) => setBankAccount(e.target.value)}
|
||||
placeholder={language === 'ko' ? '계좌번호 입력' : 'Account Number'}
|
||||
className="w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t.accountHolder}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={accountHolder}
|
||||
onChange={(e) => setAccountHolder(e.target.value)}
|
||||
placeholder={language === 'ko' ? '예금주명' : 'Account Holder Name'}
|
||||
className="w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowForm(false)}
|
||||
className="flex-1 py-3 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition"
|
||||
>
|
||||
{language === 'ko' ? '취소' : 'Cancel'}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="flex-1 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition disabled:opacity-50"
|
||||
>
|
||||
{submitting ? t.loading : t.requestWithdrawal}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info Card */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-2xl">💡</span>
|
||||
<div className="text-sm text-blue-700">
|
||||
<p className="font-medium mb-1">{language === 'ko' ? '출금 안내' : 'Withdrawal Info'}</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>{t.minWithdrawal}</li>
|
||||
<li>{language === 'ko' ? '원천징수 3.3%가 적용됩니다' : '3.3% tax withholding applies'}</li>
|
||||
<li>{language === 'ko' ? '출금은 영업일 기준 2-3일 내 처리됩니다' : 'Withdrawals are processed within 2-3 business days'}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Withdrawal History */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-4">{t.withdrawalHistory}</h2>
|
||||
{requests.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{requests.map((request) => (
|
||||
<div key={request.id} className="bg-white rounded-xl shadow p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-lg">💸</span>
|
||||
{getStatusBadge(request.status)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-gray-500">{t.withdrawalAmount}</p>
|
||||
<p className="font-medium">${formatPrice(request.amount)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">{t.taxWithheld}</p>
|
||||
<p className="font-medium text-red-600">-${formatPrice(request.tax_withheld)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">{t.netAmount}</p>
|
||||
<p className="font-bold text-primary-600">${formatPrice(request.net_amount)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">{t.bankName}</p>
|
||||
<p className="font-medium">{request.bank_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{request.admin_note && (
|
||||
<div className="mt-3 p-2 bg-gray-50 rounded text-sm">
|
||||
<p className="text-gray-600">{request.admin_note}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 pt-3 border-t flex justify-between text-xs text-gray-500">
|
||||
<span>{language === 'ko' ? '신청일' : 'Requested'}: {new Date(request.requested_at).toLocaleDateString()}</span>
|
||||
{request.processed_at && (
|
||||
<span>{language === 'ko' ? '처리일' : 'Processed'}: {new Date(request.processed_at).toLocaleDateString()}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-xl shadow p-8 text-center">
|
||||
<div className="text-4xl mb-4">💸</div>
|
||||
<p className="text-gray-500">{t.noWithdrawalHistory}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarLayout>
|
||||
);
|
||||
}
|
||||
33
frontend/src/components/AuthProvider.tsx
Normal file
33
frontend/src/components/AuthProvider.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { authApi } from '@/lib/api';
|
||||
|
||||
export default function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const { token, setUser } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
const loadUser = async () => {
|
||||
if (!token) {
|
||||
console.log('AuthProvider: No token, setting user to null');
|
||||
setUser(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('AuthProvider: Loading user...');
|
||||
const user = await authApi.getMe();
|
||||
console.log('AuthProvider: User loaded:', user);
|
||||
setUser(user);
|
||||
} catch (error) {
|
||||
console.error('AuthProvider: Failed to load user:', error);
|
||||
setUser(null);
|
||||
}
|
||||
};
|
||||
|
||||
loadUser();
|
||||
}, [token, setUser]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
87
frontend/src/components/CarCard.tsx
Normal file
87
frontend/src/components/CarCard.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Car } from '@/types';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
import { useTranslate } from '@/lib/useTranslate';
|
||||
|
||||
interface CarCardProps {
|
||||
car: Car;
|
||||
}
|
||||
|
||||
export default function CarCard({ car }: CarCardProps) {
|
||||
const { t, formatPrice, language } = useTranslation();
|
||||
const { translate } = useTranslate();
|
||||
const mainImage = car.images?.find((img) => img.is_main) || car.images?.[0];
|
||||
const imageUrl = mainImage?.url || '/placeholder-car.jpg';
|
||||
|
||||
const formatMileage = (mileage?: number) => {
|
||||
if (!mileage) return '-';
|
||||
return new Intl.NumberFormat('en-US').format(mileage) + ' km';
|
||||
};
|
||||
|
||||
const price = formatPrice(car.final_price_krw || car.price_krw);
|
||||
|
||||
// Translate car fields
|
||||
const carName = translate(car.car_name) || `${translate(car.maker?.name)} ${translate(car.model?.name)}`.trim();
|
||||
const fuel = translate(car.fuel);
|
||||
|
||||
return (
|
||||
<Link href={`/cars/${car.id}`}>
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-xl transition-shadow duration-300">
|
||||
{/* Image */}
|
||||
<div className="relative h-48 bg-gray-200">
|
||||
{mainImage?.url ? (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={carName || 'Car'}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-400">
|
||||
No Image
|
||||
</div>
|
||||
)}
|
||||
{/* Status Badge */}
|
||||
{car.status !== 'active' && (
|
||||
<span className="absolute top-2 right-2 bg-red-500 text-white text-xs px-2 py-1 rounded">
|
||||
{car.status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="p-4">
|
||||
<h3 className="text-lg font-semibold text-gray-800 truncate">
|
||||
{carName || '-'}
|
||||
</h3>
|
||||
|
||||
<div className="mt-2 text-sm text-gray-600 space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span>{t.year}</span>
|
||||
<span className="font-medium">{car.year || '-'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t.mileage}</span>
|
||||
<span className="font-medium">{formatMileage(car.mileage)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t.fuel}</span>
|
||||
<span className="font-medium">{fuel || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-3 border-t">
|
||||
<p className="text-lg font-bold text-primary-600">
|
||||
{price.usdt}
|
||||
</p>
|
||||
{/* Show local currency for all languages */}
|
||||
<p className="text-sm text-gray-500">
|
||||
({price.local})
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
191
frontend/src/components/CarSearchTable.tsx
Normal file
191
frontend/src/components/CarSearchTable.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
export interface CarSearchItem {
|
||||
id: string;
|
||||
car_name?: string;
|
||||
maker_name?: string;
|
||||
model_name?: string;
|
||||
year?: number;
|
||||
month?: number;
|
||||
mileage?: number;
|
||||
price?: number; // For compatibility
|
||||
original_price?: number; // From Carmodoo
|
||||
final_price?: number; // From Carmodoo (calculated with margins)
|
||||
fuel?: string;
|
||||
transmission?: string;
|
||||
color?: string;
|
||||
displacement?: number;
|
||||
main_image?: string;
|
||||
check_num?: string;
|
||||
}
|
||||
|
||||
interface CarSearchTableProps {
|
||||
cars: CarSearchItem[];
|
||||
selectedCars: Set<string>;
|
||||
onSelectCar: (carId: string) => void;
|
||||
onSelectAll: () => void;
|
||||
loading?: boolean;
|
||||
showCheckNum?: boolean;
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
// 차량명 번역 함수
|
||||
const translateCarName = (koreanName: string | undefined): string => {
|
||||
if (!koreanName) return '-';
|
||||
|
||||
const translations: Record<string, string> = {
|
||||
'현대': 'Hyundai', '제네시스': 'Genesis', '기아': 'Kia',
|
||||
'쉐보레(대우)': 'Chevrolet', '쉐보레': 'Chevrolet',
|
||||
'르노(삼성)': 'Renault', 'KG모빌리티(쌍용)': 'KG Mobility',
|
||||
'닛산': 'Nissan', '렉서스': 'Lexus', '토요타': 'Toyota', '혼다': 'Honda',
|
||||
'쏘렌토': 'Sorento', '스포티지': 'Sportage', '셀토스': 'Seltos',
|
||||
'카니발': 'Carnival', '모닝': 'Morning', '레이': 'Ray',
|
||||
'아반떼': 'Avante', '쏘나타': 'Sonata', '그랜저': 'Grandeur',
|
||||
'투싼': 'Tucson', '싼타페': 'Santa Fe', '팰리세이드': 'Palisade',
|
||||
'코나': 'Kona', '스타리아': 'Staria', '캐스퍼': 'Casper',
|
||||
'GV70': 'GV70', 'GV80': 'GV80', 'G70': 'G70', 'G80': 'G80', 'G90': 'G90',
|
||||
'K5': 'K5', 'K7': 'K7', 'K8': 'K8', 'K9': 'K9',
|
||||
'스팅어': 'Stinger', 'EV6': 'EV6', 'EV9': 'EV9',
|
||||
'아이오닉': 'Ioniq', '넥쏘': 'Nexo',
|
||||
};
|
||||
|
||||
let result = koreanName;
|
||||
const sortedKeys = Object.keys(translations).sort((a, b) => b.length - a.length);
|
||||
for (const korean of sortedKeys) {
|
||||
result = result.replace(new RegExp(korean, 'g'), translations[korean]);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export default function CarSearchTable({
|
||||
cars,
|
||||
selectedCars,
|
||||
onSelectCar,
|
||||
onSelectAll,
|
||||
loading = false,
|
||||
showCheckNum = true,
|
||||
emptyMessage = 'No cars found. Try adjusting your search criteria.',
|
||||
}: CarSearchTableProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="w-8 h-8 border-4 border-primary-600 border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (cars.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<div className="text-4xl mb-4">🔍</div>
|
||||
<p>{emptyMessage}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 bg-gray-50">
|
||||
<th className="py-3 px-2 text-center text-sm font-medium text-gray-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedCars.size === cars.length && cars.length > 0}
|
||||
onChange={onSelectAll}
|
||||
className="w-5 h-5 rounded border-gray-300 text-primary-600"
|
||||
/>
|
||||
</th>
|
||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Image</th>
|
||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Car Name</th>
|
||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Year</th>
|
||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Mileage</th>
|
||||
<th className="py-3 px-4 text-right text-sm font-medium text-gray-600">Price (KRW)</th>
|
||||
<th className="py-3 px-4 text-right text-sm font-medium text-gray-600">Price (USD)</th>
|
||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Fuel</th>
|
||||
{showCheckNum && (
|
||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Check#</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{cars.map((car) => (
|
||||
<tr
|
||||
key={car.id}
|
||||
onClick={() => onSelectCar(car.id)}
|
||||
className={`border-b border-gray-100 hover:bg-gray-50 cursor-pointer ${
|
||||
selectedCars.has(car.id) ? 'bg-primary-50' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="py-3 px-2 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedCars.has(car.id)}
|
||||
onChange={() => onSelectCar(car.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-5 h-5 rounded border-gray-300 text-primary-600"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="w-20 h-14 bg-gray-200 rounded overflow-hidden">
|
||||
{car.main_image ? (
|
||||
<img
|
||||
src={car.main_image}
|
||||
alt={car.car_name || 'Car'}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-400 text-xs">
|
||||
No Image
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="font-medium text-gray-800">{translateCarName(car.car_name)}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{car.maker_name} {car.model_name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-gray-600">
|
||||
{car.year}년{car.month ? ` ${car.month}월` : ''}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-gray-600">
|
||||
{car.mileage?.toLocaleString()} km
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right text-gray-600">
|
||||
{(() => {
|
||||
const priceKrw = car.original_price || car.price || 0;
|
||||
return priceKrw > 0 ? `₩${priceKrw.toLocaleString()}` : '-';
|
||||
})()}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right font-semibold text-primary-600">
|
||||
{(() => {
|
||||
const finalPrice = car.final_price || car.original_price || car.price || 0;
|
||||
return finalPrice > 0 ? `$${Math.round(finalPrice / 1400).toLocaleString()}` : '-';
|
||||
})()}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-gray-600">{car.fuel || '-'}</td>
|
||||
{showCheckNum && (
|
||||
<td className="py-3 px-4 text-gray-600">
|
||||
{car.check_num ? (
|
||||
<span className="text-green-600 text-xs font-medium">
|
||||
✓ {car.check_num.slice(-4)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400 text-xs">-</span>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
391
frontend/src/components/CarmodooSearchFilters.tsx
Normal file
391
frontend/src/components/CarmodooSearchFilters.tsx
Normal file
@@ -0,0 +1,391 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { carmodooApi, CarmodooMaker, CarmodooModel, CarmodooSearchResult } from '@/lib/api';
|
||||
|
||||
export interface CarmodooSearchParams {
|
||||
maker_code: string;
|
||||
model_code: string;
|
||||
grade?: string;
|
||||
year_min: number;
|
||||
year_max: number;
|
||||
fuel?: string;
|
||||
displacement_min?: number;
|
||||
displacement_max?: number;
|
||||
mileage_min?: number;
|
||||
mileage_max?: number;
|
||||
}
|
||||
|
||||
interface CarmodooSearchFiltersProps {
|
||||
onSearchStart?: () => void;
|
||||
onSearchComplete?: (results: CarmodooSearchResult[], params: CarmodooSearchParams) => void;
|
||||
onSearchError?: (error: any) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// Fuel type options
|
||||
const fuelOptions = [
|
||||
{ value: '휘발유', label: '휘발유 (가솔린)' },
|
||||
{ value: '경유', label: '경유 (디젤)' },
|
||||
{ value: 'LPG', label: 'LPG' },
|
||||
{ value: '하이브리드', label: '하이브리드' },
|
||||
{ value: '전기', label: '전기' },
|
||||
];
|
||||
|
||||
// Displacement options (cc)
|
||||
const displacementOptions = [1000, 1500, 2000, 2500, 3000, 3500, 4000, 5000];
|
||||
|
||||
// Mileage options (만km)
|
||||
const mileageOptions = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 15, 20];
|
||||
|
||||
export default function CarmodooSearchFilters({
|
||||
onSearchStart,
|
||||
onSearchComplete,
|
||||
onSearchError,
|
||||
disabled = false,
|
||||
}: CarmodooSearchFiltersProps) {
|
||||
// Data states
|
||||
const [makers, setMakers] = useState<CarmodooMaker[]>([]);
|
||||
const [models, setModels] = useState<CarmodooModel[]>([]);
|
||||
const [grades, setGrades] = useState<{ code: string; name: string }[]>([]);
|
||||
|
||||
// Filter states
|
||||
const [selectedMaker, setSelectedMaker] = useState('');
|
||||
const [selectedModel, setSelectedModel] = useState('');
|
||||
const [selectedGrade, setSelectedGrade] = useState('');
|
||||
const [yearFrom, setYearFrom] = useState('');
|
||||
const [yearTo, setYearTo] = useState('');
|
||||
const [selectedFuel, setSelectedFuel] = useState('');
|
||||
const [displacementMin, setDisplacementMin] = useState('');
|
||||
const [displacementMax, setDisplacementMax] = useState('');
|
||||
const [mileageMin, setMileageMin] = useState('');
|
||||
const [mileageMax, setMileageMax] = useState('');
|
||||
|
||||
// Loading states
|
||||
const [loadingMakers, setLoadingMakers] = useState(false);
|
||||
const [loadingModels, setLoadingModels] = useState(false);
|
||||
const [loadingGrades, setLoadingGrades] = useState(false);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [searchProgress, setSearchProgress] = useState(0);
|
||||
|
||||
// Year options
|
||||
const currentYear = new Date().getFullYear();
|
||||
const yearOptions = Array.from({ length: currentYear - 2009 }, (_, i) => currentYear - i);
|
||||
|
||||
// Load makers on mount
|
||||
useEffect(() => {
|
||||
loadMakers();
|
||||
}, []);
|
||||
|
||||
// Load models when maker changes
|
||||
useEffect(() => {
|
||||
if (selectedMaker) {
|
||||
setLoadingModels(true);
|
||||
setSelectedModel('');
|
||||
setSelectedGrade('');
|
||||
setModels([]);
|
||||
setGrades([]);
|
||||
carmodooApi.getModels(selectedMaker)
|
||||
.then(setModels)
|
||||
.catch(console.error)
|
||||
.finally(() => setLoadingModels(false));
|
||||
}
|
||||
}, [selectedMaker]);
|
||||
|
||||
// Load grades when model changes
|
||||
useEffect(() => {
|
||||
if (selectedMaker && selectedModel) {
|
||||
setLoadingGrades(true);
|
||||
setSelectedGrade('');
|
||||
setGrades([]);
|
||||
carmodooApi.getGrades(selectedMaker, selectedModel)
|
||||
.then(setGrades)
|
||||
.catch(() => setGrades([]))
|
||||
.finally(() => setLoadingGrades(false));
|
||||
}
|
||||
}, [selectedMaker, selectedModel]);
|
||||
|
||||
const loadMakers = async () => {
|
||||
setLoadingMakers(true);
|
||||
try {
|
||||
const data = await carmodooApi.getMakers();
|
||||
setMakers(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load makers:', error);
|
||||
} finally {
|
||||
setLoadingMakers(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!selectedMaker || !selectedModel || !yearFrom || !yearTo) {
|
||||
alert('제조사, 모델, 연식을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSearching(true);
|
||||
setSearchProgress(0);
|
||||
onSearchStart?.();
|
||||
|
||||
// Progress animation
|
||||
let progressInterval: NodeJS.Timeout | null = null;
|
||||
let currentProgress = 0;
|
||||
|
||||
progressInterval = setInterval(() => {
|
||||
if (currentProgress < 90) {
|
||||
currentProgress += (90 - currentProgress) * 0.05;
|
||||
setSearchProgress(currentProgress);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
try {
|
||||
const params: any = {
|
||||
maker_code: selectedMaker,
|
||||
model_code: selectedModel,
|
||||
year_min: parseInt(yearFrom),
|
||||
year_max: parseInt(yearTo),
|
||||
page_size: 50,
|
||||
};
|
||||
if (selectedGrade) params.grade = selectedGrade;
|
||||
if (selectedFuel) params.fuel = selectedFuel;
|
||||
if (displacementMin) params.displacement_min = parseInt(displacementMin);
|
||||
if (displacementMax) params.displacement_max = parseInt(displacementMax);
|
||||
if (mileageMin) params.mileage_min = parseInt(mileageMin) * 10000;
|
||||
if (mileageMax) params.mileage_max = parseInt(mileageMax) * 10000;
|
||||
|
||||
const result = await carmodooApi.requestSearch(params);
|
||||
|
||||
if (progressInterval) clearInterval(progressInterval);
|
||||
setSearchProgress(100);
|
||||
|
||||
onSearchComplete?.(result.cars || [], params as CarmodooSearchParams);
|
||||
} catch (error) {
|
||||
console.error('Failed to search cars:', error);
|
||||
onSearchError?.(error);
|
||||
alert('검색에 실패했습니다. 다시 시도해주세요.');
|
||||
} finally {
|
||||
if (progressInterval) clearInterval(progressInterval);
|
||||
setSearchProgress(100);
|
||||
setTimeout(() => {
|
||||
setIsSearching(false);
|
||||
setSearchProgress(0);
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setSelectedMaker('');
|
||||
setSelectedModel('');
|
||||
setSelectedGrade('');
|
||||
setYearFrom('');
|
||||
setYearTo('');
|
||||
setSelectedFuel('');
|
||||
setDisplacementMin('');
|
||||
setDisplacementMax('');
|
||||
setMileageMin('');
|
||||
setMileageMax('');
|
||||
setModels([]);
|
||||
setGrades([]);
|
||||
};
|
||||
|
||||
const isDisabled = disabled || isSearching;
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-6">
|
||||
{/* Row 1: Maker, Model, Grade, Year, Search Button */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4 mb-4">
|
||||
{/* Maker */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">제조사 *</label>
|
||||
<select
|
||||
value={selectedMaker}
|
||||
onChange={(e) => setSelectedMaker(e.target.value)}
|
||||
disabled={loadingMakers || isDisabled}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-primary-500 focus:border-primary-500 disabled:bg-gray-100"
|
||||
>
|
||||
<option value="">{loadingMakers ? '로딩...' : '선택'}</option>
|
||||
{makers.map((maker) => (
|
||||
<option key={maker.code} value={maker.code}>{maker.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Model */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">모델 *</label>
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
disabled={!selectedMaker || loadingModels || isDisabled}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-primary-500 focus:border-primary-500 disabled:bg-gray-100"
|
||||
>
|
||||
<option value="">{loadingModels ? '로딩...' : '선택'}</option>
|
||||
{models.map((model) => (
|
||||
<option key={model.code} value={model.code}>{model.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Grade */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">등급</label>
|
||||
<select
|
||||
value={selectedGrade}
|
||||
onChange={(e) => setSelectedGrade(e.target.value)}
|
||||
disabled={!selectedModel || loadingGrades || isDisabled}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-primary-500 focus:border-primary-500 disabled:bg-gray-100"
|
||||
>
|
||||
<option value="">{loadingGrades ? '로딩...' : '전체'}</option>
|
||||
{grades.map((grade) => (
|
||||
<option key={grade.code} value={grade.code}>{grade.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Year Range */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">연식 *</label>
|
||||
<div className="flex gap-1 items-center">
|
||||
<select
|
||||
value={yearFrom}
|
||||
onChange={(e) => setYearFrom(e.target.value)}
|
||||
disabled={isDisabled}
|
||||
className="flex-1 border border-gray-300 rounded-md px-2 py-2 text-sm focus:ring-primary-500 focus:border-primary-500 disabled:bg-gray-100"
|
||||
>
|
||||
<option value="">시작</option>
|
||||
{yearOptions.map((year) => (
|
||||
<option key={year} value={year}>{year}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-gray-500">~</span>
|
||||
<select
|
||||
value={yearTo}
|
||||
onChange={(e) => setYearTo(e.target.value)}
|
||||
disabled={isDisabled}
|
||||
className="flex-1 border border-gray-300 rounded-md px-2 py-2 text-sm focus:ring-primary-500 focus:border-primary-500 disabled:bg-gray-100"
|
||||
>
|
||||
<option value="">종료</option>
|
||||
{yearOptions.map((year) => (
|
||||
<option key={year} value={year}>{year}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Button */}
|
||||
<div className="flex items-end gap-2">
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={isDisabled || !selectedMaker || !selectedModel || !yearFrom || !yearTo}
|
||||
className="flex-1 bg-primary-600 text-white py-2 rounded-md hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium transition-colors"
|
||||
>
|
||||
{isSearching ? '검색중...' : '검색'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
disabled={isDisabled}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md text-gray-600 hover:bg-gray-100 disabled:opacity-50 text-sm transition-colors"
|
||||
title="초기화"
|
||||
>
|
||||
↻
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Fuel, Displacement, Mileage */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{/* Fuel */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">연료</label>
|
||||
<select
|
||||
value={selectedFuel}
|
||||
onChange={(e) => setSelectedFuel(e.target.value)}
|
||||
disabled={isDisabled}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-primary-500 focus:border-primary-500 disabled:bg-gray-100"
|
||||
>
|
||||
<option value="">전체</option>
|
||||
{fuelOptions.map((fuel) => (
|
||||
<option key={fuel.value} value={fuel.value}>{fuel.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Displacement */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">배기량 (cc)</label>
|
||||
<div className="flex gap-1 items-center">
|
||||
<select
|
||||
value={displacementMin}
|
||||
onChange={(e) => setDisplacementMin(e.target.value)}
|
||||
disabled={isDisabled}
|
||||
className="flex-1 border border-gray-300 rounded-md px-2 py-2 text-sm focus:ring-primary-500 focus:border-primary-500 disabled:bg-gray-100"
|
||||
>
|
||||
<option value="">최소</option>
|
||||
{displacementOptions.map((d) => (
|
||||
<option key={d} value={d}>{d.toLocaleString()}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-gray-500">~</span>
|
||||
<select
|
||||
value={displacementMax}
|
||||
onChange={(e) => setDisplacementMax(e.target.value)}
|
||||
disabled={isDisabled}
|
||||
className="flex-1 border border-gray-300 rounded-md px-2 py-2 text-sm focus:ring-primary-500 focus:border-primary-500 disabled:bg-gray-100"
|
||||
>
|
||||
<option value="">최대</option>
|
||||
{displacementOptions.map((d) => (
|
||||
<option key={d} value={d}>{d.toLocaleString()}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mileage */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">주행거리 (만km)</label>
|
||||
<div className="flex gap-1 items-center">
|
||||
<select
|
||||
value={mileageMin}
|
||||
onChange={(e) => setMileageMin(e.target.value)}
|
||||
disabled={isDisabled}
|
||||
className="flex-1 border border-gray-300 rounded-md px-2 py-2 text-sm focus:ring-primary-500 focus:border-primary-500 disabled:bg-gray-100"
|
||||
>
|
||||
<option value="">최소</option>
|
||||
{mileageOptions.map((m) => (
|
||||
<option key={m} value={m}>{m}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-gray-500">~</span>
|
||||
<select
|
||||
value={mileageMax}
|
||||
onChange={(e) => setMileageMax(e.target.value)}
|
||||
disabled={isDisabled}
|
||||
className="flex-1 border border-gray-300 rounded-md px-2 py-2 text-sm focus:ring-primary-500 focus:border-primary-500 disabled:bg-gray-100"
|
||||
>
|
||||
<option value="">최대</option>
|
||||
{mileageOptions.map((m) => (
|
||||
<option key={m} value={m}>{m}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{isSearching && (
|
||||
<div className="mt-4">
|
||||
<div className="flex justify-between text-sm text-gray-600 mb-1">
|
||||
<span>Carmodoo에서 검색 중...</span>
|
||||
<span>{Math.round(searchProgress)}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary-600 h-2 rounded-full transition-all duration-200"
|
||||
style={{ width: `${searchProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
frontend/src/components/ClientLayout.tsx
Normal file
21
frontend/src/components/ClientLayout.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import AuthProvider from './AuthProvider';
|
||||
import Header from './Header';
|
||||
import Footer from './Footer';
|
||||
import { VisitorTracker } from '@/lib/useVisitorTracking';
|
||||
|
||||
export default function ClientLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<VisitorTracker />
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Header />
|
||||
<main className="flex-grow">
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
408
frontend/src/components/FilmStripSlider.tsx
Normal file
408
frontend/src/components/FilmStripSlider.tsx
Normal file
@@ -0,0 +1,408 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { motion, useAnimationControls } from 'framer-motion';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { HeroBanner, HeroBannerSettings } from '@/types';
|
||||
import { useLanguageStore, Language } from '@/lib/i18n';
|
||||
|
||||
interface FilmStripSliderProps {
|
||||
banners: HeroBanner[];
|
||||
settings?: HeroBannerSettings;
|
||||
}
|
||||
|
||||
// 기본 설정
|
||||
const defaultSettings: HeroBannerSettings = {
|
||||
id: 0,
|
||||
slide_interval: 3000,
|
||||
animation_type: 'film-strip',
|
||||
image_width: 500,
|
||||
image_height: 300,
|
||||
auto_play: true,
|
||||
};
|
||||
|
||||
// 샘플 배너 데이터 (API 데이터가 없을 때 사용)
|
||||
const sampleBanners: HeroBanner[] = [
|
||||
{
|
||||
id: 1,
|
||||
title_en: 'Hyundai Sonata 2023',
|
||||
title_ko: '현대 소나타 2023',
|
||||
title_mn: 'Хёндай Соната 2023',
|
||||
subtitle_en: 'Premium Sedan',
|
||||
subtitle_ko: '프리미엄 세단',
|
||||
subtitle_mn: 'Премиум седан',
|
||||
image_url: 'https://images.unsplash.com/photo-1605559424843-9e4c228bf1c2?w=500&h=300&fit=crop',
|
||||
link_url: '/cars',
|
||||
is_active: true,
|
||||
display_order: 0,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title_en: 'Kia Sportage 2022',
|
||||
title_ko: '기아 스포티지 2022',
|
||||
title_mn: 'Киа Спортаж 2022',
|
||||
subtitle_en: 'Compact SUV',
|
||||
subtitle_ko: '컴팩트 SUV',
|
||||
subtitle_mn: 'Компакт SUV',
|
||||
image_url: 'https://images.unsplash.com/photo-1494976388531-d1058494cdd8?w=500&h=300&fit=crop',
|
||||
link_url: '/cars',
|
||||
is_active: true,
|
||||
display_order: 1,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title_en: 'Genesis G80 2023',
|
||||
title_ko: '제네시스 G80 2023',
|
||||
title_mn: 'Женезис G80 2023',
|
||||
subtitle_en: 'Luxury Sedan',
|
||||
subtitle_ko: '럭셔리 세단',
|
||||
subtitle_mn: 'Люкс седан',
|
||||
image_url: 'https://images.unsplash.com/photo-1503376780353-7e6692767b70?w=500&h=300&fit=crop',
|
||||
link_url: '/cars',
|
||||
is_active: true,
|
||||
display_order: 2,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title_en: 'Hyundai Tucson 2023',
|
||||
title_ko: '현대 투싼 2023',
|
||||
title_mn: 'Хёндай Туксон 2023',
|
||||
subtitle_en: 'Family SUV',
|
||||
subtitle_ko: '패밀리 SUV',
|
||||
subtitle_mn: 'Гэр бүлийн SUV',
|
||||
image_url: 'https://images.unsplash.com/photo-1552519507-da3b142c6e3d?w=500&h=300&fit=crop',
|
||||
link_url: '/cars',
|
||||
is_active: true,
|
||||
display_order: 3,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title_en: 'Kia EV6 2023',
|
||||
title_ko: '기아 EV6 2023',
|
||||
title_mn: 'Киа EV6 2023',
|
||||
subtitle_en: 'Electric Vehicle',
|
||||
subtitle_ko: '전기차',
|
||||
subtitle_mn: 'Цахилгаан машин',
|
||||
image_url: 'https://images.unsplash.com/photo-1617788138017-80ad40651399?w=500&h=300&fit=crop',
|
||||
link_url: '/cars',
|
||||
is_active: true,
|
||||
display_order: 4,
|
||||
},
|
||||
];
|
||||
|
||||
export default function FilmStripSlider({ banners, settings }: FilmStripSliderProps) {
|
||||
const effectiveSettings = settings || defaultSettings;
|
||||
const effectiveBanners = banners.length > 0 ? banners : sampleBanners;
|
||||
|
||||
// 무한 루프를 위해 배너를 3배로 복제
|
||||
const duplicatedBanners = [...effectiveBanners, ...effectiveBanners, ...effectiveBanners];
|
||||
|
||||
const controls = useAnimationControls();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const currentXRef = useRef(0);
|
||||
const animationStartTimeRef = useRef(0);
|
||||
|
||||
const imageWidth = effectiveSettings.image_width;
|
||||
const imageHeight = effectiveSettings.image_height;
|
||||
const gap = 16; // gap between images
|
||||
const singleSetWidth = (imageWidth + gap) * effectiveBanners.length;
|
||||
const totalDuration = effectiveBanners.length * (effectiveSettings.slide_interval / 1000);
|
||||
|
||||
// 무한 슬라이드 애니메이션
|
||||
useEffect(() => {
|
||||
if (!effectiveSettings.auto_play) return;
|
||||
|
||||
if (isPaused) {
|
||||
// 일시정지 시 현재 위치 계산 및 저장
|
||||
const elapsed = (Date.now() - animationStartTimeRef.current) / 1000;
|
||||
const progress = (elapsed % totalDuration) / totalDuration;
|
||||
currentXRef.current = -singleSetWidth * progress;
|
||||
controls.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
// 현재 위치에서 남은 거리 계산
|
||||
const currentX = currentXRef.current;
|
||||
const remainingDistance = -singleSetWidth - currentX;
|
||||
const remainingDuration = (remainingDistance / -singleSetWidth) * totalDuration;
|
||||
|
||||
const animate = async () => {
|
||||
// 먼저 남은 거리를 완료
|
||||
if (remainingDistance < 0) {
|
||||
await controls.start({
|
||||
x: -singleSetWidth,
|
||||
transition: {
|
||||
duration: remainingDuration,
|
||||
ease: 'linear',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 그 후 무한 루프 시작
|
||||
animationStartTimeRef.current = Date.now();
|
||||
currentXRef.current = 0;
|
||||
controls.set({ x: 0 });
|
||||
|
||||
await controls.start({
|
||||
x: -singleSetWidth,
|
||||
transition: {
|
||||
duration: totalDuration,
|
||||
ease: 'linear',
|
||||
repeat: Infinity,
|
||||
repeatType: 'loop',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 현재 위치 설정 후 애니메이션 시작
|
||||
controls.set({ x: currentX });
|
||||
animationStartTimeRef.current = Date.now() - ((-currentX / singleSetWidth) * totalDuration * 1000);
|
||||
animate();
|
||||
|
||||
return () => {
|
||||
controls.stop();
|
||||
};
|
||||
}, [controls, effectiveBanners.length, singleSetWidth, effectiveSettings.auto_play, totalDuration, isPaused]);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setIsPaused(true);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setIsPaused(false);
|
||||
};
|
||||
|
||||
// 현재 X 위치 계산
|
||||
const getCurrentX = () => {
|
||||
if (isPaused) {
|
||||
return currentXRef.current;
|
||||
}
|
||||
const elapsed = (Date.now() - animationStartTimeRef.current) / 1000;
|
||||
const progress = (elapsed % totalDuration) / totalDuration;
|
||||
return -singleSetWidth * progress;
|
||||
};
|
||||
|
||||
// 다음/이전 차량으로 빠르게 이동
|
||||
const handleNext = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
const itemWidth = imageWidth + gap;
|
||||
const currentX = getCurrentX();
|
||||
|
||||
// 현재 위치에서 다음 아이템 위치 계산
|
||||
const currentItem = Math.floor(-currentX / itemWidth);
|
||||
const nextX = -((currentItem + 1) * itemWidth);
|
||||
|
||||
// 범위 체크 (singleSetWidth를 넘으면 처음으로)
|
||||
const targetX = nextX <= -singleSetWidth ? 0 : nextX;
|
||||
|
||||
// 애니메이션 중지 후 위치 설정
|
||||
controls.stop();
|
||||
currentXRef.current = targetX;
|
||||
animationStartTimeRef.current = Date.now() - ((-targetX / singleSetWidth) * totalDuration * 1000);
|
||||
|
||||
// 부드러운 이동 애니메이션
|
||||
await controls.start({
|
||||
x: targetX,
|
||||
transition: { duration: 0.3, ease: 'easeOut' }
|
||||
});
|
||||
|
||||
// 일시정지 상태가 아니면 자동 재생 트리거
|
||||
if (!isPaused) {
|
||||
setIsPaused(true);
|
||||
setTimeout(() => setIsPaused(false), 50);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrev = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
const itemWidth = imageWidth + gap;
|
||||
const currentX = getCurrentX();
|
||||
|
||||
// 현재 위치에서 이전 아이템 위치 계산
|
||||
const currentItem = Math.floor(-currentX / itemWidth);
|
||||
const prevX = -((currentItem - 1) * itemWidth);
|
||||
|
||||
// 범위 체크 (0보다 크면 끝으로)
|
||||
const targetX = prevX > 0 ? -(singleSetWidth - itemWidth) : prevX;
|
||||
|
||||
// 애니메이션 중지 후 위치 설정
|
||||
controls.stop();
|
||||
currentXRef.current = targetX;
|
||||
animationStartTimeRef.current = Date.now() - ((-targetX / singleSetWidth) * totalDuration * 1000);
|
||||
|
||||
// 부드러운 이동 애니메이션
|
||||
await controls.start({
|
||||
x: targetX,
|
||||
transition: { duration: 0.3, ease: 'easeOut' }
|
||||
});
|
||||
|
||||
// 일시정지 상태가 아니면 자동 재생 트리거
|
||||
if (!isPaused) {
|
||||
setIsPaused(true);
|
||||
setTimeout(() => setIsPaused(false), 50);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative w-full overflow-hidden bg-gradient-to-b from-gray-900 to-gray-800 py-8">
|
||||
{/* Film strip effect - top perforation */}
|
||||
<div className="absolute top-0 left-0 right-0 h-4 bg-gray-900 flex items-center justify-around">
|
||||
{Array.from({ length: 30 }).map((_, i) => (
|
||||
<div key={i} className="w-3 h-2 bg-gray-700 rounded-sm" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Main slider container */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative overflow-hidden mx-auto"
|
||||
style={{ height: imageHeight + 40 }}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<motion.div
|
||||
className="flex gap-4 absolute"
|
||||
animate={controls}
|
||||
style={{ paddingLeft: gap }}
|
||||
>
|
||||
{duplicatedBanners.map((banner, index) => (
|
||||
<BannerCard
|
||||
key={`${banner.id}-${index}`}
|
||||
banner={banner}
|
||||
width={imageWidth}
|
||||
height={imageHeight}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Film strip effect - bottom perforation */}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-4 bg-gray-900 flex items-center justify-around">
|
||||
{Array.from({ length: 30 }).map((_, i) => (
|
||||
<div key={i} className="w-3 h-2 bg-gray-700 rounded-sm" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Gradient overlays for fade effect at edges */}
|
||||
<div className="absolute top-4 bottom-4 left-0 w-24 bg-gradient-to-r from-gray-900 to-transparent pointer-events-none z-10" />
|
||||
<div className="absolute top-4 bottom-4 right-0 w-24 bg-gradient-to-l from-gray-900 to-transparent pointer-events-none z-10" />
|
||||
|
||||
{/* Navigation Arrows */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePrev}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 z-20 bg-white/20 hover:bg-white/40 text-white rounded-full p-3 backdrop-blur-sm transition-all duration-200 hover:scale-110"
|
||||
aria-label="Previous"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNext}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 z-20 bg-white/20 hover:bg-white/40 text-white rounded-full p-3 backdrop-blur-sm transition-all duration-200 hover:scale-110"
|
||||
aria-label="Next"
|
||||
>
|
||||
<svg className="w-6 h-6" 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>
|
||||
);
|
||||
}
|
||||
|
||||
interface BannerCardProps {
|
||||
banner: HeroBanner;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
// 이미지 URL 변환 (로컬 경로는 백엔드 URL 추가)
|
||||
const getImageUrl = (url: string): string => {
|
||||
if (!url) return '';
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url;
|
||||
}
|
||||
// 로컬 경로인 경우 백엔드 URL 추가
|
||||
return `http://localhost:8000${url}`;
|
||||
};
|
||||
|
||||
// Helper to get localized title/subtitle based on language
|
||||
function getLocalizedText(banner: HeroBanner, field: 'title' | 'subtitle', language: Language): string {
|
||||
// Public API returns localized single field (title, subtitle)
|
||||
const directField = banner[field as keyof HeroBanner] as string | undefined;
|
||||
if (directField) {
|
||||
return directField;
|
||||
}
|
||||
// Admin API returns multi-language fields (title_ko, title_en, etc.)
|
||||
const langKey = `${field}_${language}` as keyof HeroBanner;
|
||||
const enKey = `${field}_en` as keyof HeroBanner;
|
||||
return (banner[langKey] as string) || (banner[enKey] as string) || '';
|
||||
}
|
||||
|
||||
function BannerCard({ banner, width, height }: BannerCardProps) {
|
||||
const { language } = useLanguageStore();
|
||||
const imageUrl = getImageUrl(banner.image_url);
|
||||
|
||||
// 언어별 제목과 부제목 가져오기
|
||||
const title = getLocalizedText(banner, 'title', language);
|
||||
const subtitle = getLocalizedText(banner, 'subtitle', language);
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className="relative group cursor-pointer flex-shrink-0 rounded-lg overflow-hidden shadow-2xl transform transition-transform duration-300 hover:scale-105"
|
||||
style={{ width, height }}
|
||||
>
|
||||
{/* Image */}
|
||||
<div className="relative w-full h-full">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={title || 'Car image'}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Overlay on hover */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
|
||||
{/* Text overlay */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4 text-white transform translate-y-full group-hover:translate-y-0 transition-transform duration-300">
|
||||
{title && (
|
||||
<h3 className="text-lg font-bold truncate">{title}</h3>
|
||||
)}
|
||||
{subtitle && (
|
||||
<p className="text-sm text-gray-300 truncate">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Film frame border effect */}
|
||||
<div className="absolute inset-0 border-4 border-gray-800 rounded-lg pointer-events-none" />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (banner.link_url) {
|
||||
return (
|
||||
<Link href={banner.link_url}>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
if (banner.car_id) {
|
||||
return (
|
||||
<Link href={`/cars/${banner.car_id}`}>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
48
frontend/src/components/Footer.tsx
Normal file
48
frontend/src/components/Footer.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
export default function Footer() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<footer className="bg-gray-800 text-white py-8">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{/* Company Info */}
|
||||
<div>
|
||||
<h3 className="text-xl font-bold mb-4">AutonetSellCar</h3>
|
||||
<p className="text-gray-400">
|
||||
Premium Korean used cars exported to Mongolia.
|
||||
Quality vehicles at competitive prices.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">Quick Links</h3>
|
||||
<ul className="space-y-2 text-gray-400">
|
||||
<li><a href="/about" className="hover:text-white transition">{t.about}</a></li>
|
||||
<li><a href="/contact" className="hover:text-white transition">{t.contact}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Contact */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">{t.contactUs}</h3>
|
||||
<ul className="space-y-2 text-gray-400">
|
||||
<li>DamonHong</li>
|
||||
<li>Phone: 82-2-552-0773</li>
|
||||
<li>Location: Seoul, South Korea</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-700 mt-8 pt-8 text-center text-gray-400">
|
||||
<p className="text-xs mb-2">{t.exchangeRateInfo}</p>
|
||||
<p>© 2025 AutonetSellCar. {t.allRightsReserved}.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
398
frontend/src/components/Header.tsx
Normal file
398
frontend/src/components/Header.tsx
Normal file
@@ -0,0 +1,398 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
import { notificationApi, Notification } from '@/lib/api';
|
||||
import LanguageSelector from './LanguageSelector';
|
||||
|
||||
// Menu groups for sidebar
|
||||
export const MENU_GROUPS = {
|
||||
quote: {
|
||||
label: { ko: '견적요청', en: 'Quote', mn: 'Үнийн санал', ru: 'Запрос' },
|
||||
basePath: '/vehicle-request',
|
||||
items: [
|
||||
{ path: '/vehicle-request', label: { ko: '차량요청하기', en: 'Request Vehicle', mn: 'Машин захиалах', ru: 'Запрос авто' } },
|
||||
{ path: '/my-request', label: { ko: '내요청', en: 'My Requests', mn: 'Миний хүсэлт', ru: 'Мои запросы' } },
|
||||
{ path: '/my-shares', label: { ko: '내공유목록', en: 'My Shares', mn: 'Миний хуваалцсан', ru: 'Мои ссылки' } },
|
||||
{ path: '/find-my-car', label: { ko: '내차찾기', en: 'Find My Car', mn: 'Машинаа олох', ru: 'Найти мою машину' } },
|
||||
],
|
||||
},
|
||||
billing: {
|
||||
label: { ko: '비용', en: 'Billing', mn: 'Төлбөр', ru: 'Оплата' },
|
||||
basePath: '/cost',
|
||||
items: [
|
||||
{ path: '/cost', label: { ko: '비용계산', en: 'Cost Calculator', mn: 'Зардал тооцоо', ru: 'Калькулятор' } },
|
||||
{ path: '/cc', label: { ko: 'CC 충전', en: 'Buy CC', mn: 'CC худалдан авах', ru: 'Купить CC' } },
|
||||
{ path: '/charge', label: { ko: 'USDC/계좌', en: 'USDC/Bank', mn: 'USDC/Банк', ru: 'USDC/Банк' } },
|
||||
{ path: '/withdrawal', label: { ko: '출금', en: 'Withdraw', mn: 'Татах', ru: 'Вывод' } },
|
||||
],
|
||||
},
|
||||
inquiry: {
|
||||
label: { ko: '문의', en: 'Inquiry', mn: 'Асуулт', ru: 'Запрос' },
|
||||
basePath: '/my-inquiries',
|
||||
items: [
|
||||
{ path: '/inquiry', label: { ko: '문의하기', en: 'New Inquiry', mn: 'Асуулт илгээх', ru: 'Новый запрос' } },
|
||||
{ path: '/my-inquiries', label: { ko: '내 문의목록', en: 'My Inquiries', mn: 'Миний асуултууд', ru: 'Мои запросы' } },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// Helper to check if current path belongs to a menu group
|
||||
export function getActiveMenuGroup(pathname: string): string | null {
|
||||
for (const [key, group] of Object.entries(MENU_GROUPS)) {
|
||||
if (group.items.some(item => pathname === item.path || pathname.startsWith(item.path + '/'))) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function Header() {
|
||||
const { user, logout } = useAuthStore();
|
||||
const { t, language } = useTranslation();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
// Notification state
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [showNotifications, setShowNotifications] = useState(false);
|
||||
const [loadingNotifications, setLoadingNotifications] = useState(false);
|
||||
const notificationRef = useRef<HTMLDivElement>(null);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
const activeGroup = getActiveMenuGroup(pathname);
|
||||
|
||||
// Fetch unread count periodically
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
||||
const fetchUnreadCount = async () => {
|
||||
try {
|
||||
const { unread_count } = await notificationApi.getUnreadCount();
|
||||
setUnreadCount(unread_count);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch unread count:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUnreadCount();
|
||||
const interval = setInterval(fetchUnreadCount, 30000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [user]);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (notificationRef.current && !notificationRef.current.contains(event.target as Node)) {
|
||||
setShowNotifications(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Fetch notifications when dropdown opens
|
||||
const handleNotificationClick = async () => {
|
||||
if (!showNotifications) {
|
||||
setLoadingNotifications(true);
|
||||
try {
|
||||
const response = await notificationApi.getNotifications(1, 10);
|
||||
setNotifications(response.notifications);
|
||||
setUnreadCount(response.unread_count);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch notifications:', error);
|
||||
} finally {
|
||||
setLoadingNotifications(false);
|
||||
}
|
||||
}
|
||||
setShowNotifications(!showNotifications);
|
||||
};
|
||||
|
||||
// Mark notification as read and navigate
|
||||
const handleNotificationItemClick = async (notification: Notification) => {
|
||||
if (!notification.is_read) {
|
||||
try {
|
||||
await notificationApi.markAsRead([notification.id]);
|
||||
setUnreadCount(prev => Math.max(0, prev - 1));
|
||||
setNotifications(prev =>
|
||||
prev.map(n => n.id === notification.id ? { ...n, is_read: true } : n)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to mark as read:', error);
|
||||
}
|
||||
}
|
||||
|
||||
setShowNotifications(false);
|
||||
if (notification.link) {
|
||||
router.push(notification.link);
|
||||
}
|
||||
};
|
||||
|
||||
// Mark all as read
|
||||
const handleMarkAllRead = async () => {
|
||||
try {
|
||||
await notificationApi.markAllAsRead();
|
||||
setUnreadCount(0);
|
||||
setNotifications(prev => prev.map(n => ({ ...n, is_read: true })));
|
||||
} catch (error) {
|
||||
console.error('Failed to mark all as read:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Format time ago
|
||||
const formatTimeAgo = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffMins < 1) return '방금 전';
|
||||
if (diffMins < 60) return `${diffMins}분 전`;
|
||||
if (diffHours < 24) return `${diffHours}시간 전`;
|
||||
if (diffDays < 7) return `${diffDays}일 전`;
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
// Get notification icon based on type
|
||||
const getNotificationIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'vehicle_recommended': return '🚗';
|
||||
case 'shipping_update': return '🚚';
|
||||
case 'withdrawal_processed': return '💰';
|
||||
case 'referral_reward': return '🎁';
|
||||
case 'dealer_approved': return '✅';
|
||||
case 'dealer_rejected': return '❌';
|
||||
case 'share_purchased': return '🎉';
|
||||
case 'system': return '📢';
|
||||
default: return '🔔';
|
||||
}
|
||||
};
|
||||
|
||||
// Get label based on language
|
||||
const getLabel = (labelObj: { ko: string; en: string; mn: string; ru: string }) => {
|
||||
return labelObj[language as keyof typeof labelObj] || labelObj.en;
|
||||
};
|
||||
|
||||
// Navigation menu items
|
||||
const navItems = [
|
||||
{ href: '/about', label: { ko: '소개', en: 'About', mn: 'Тухай', ru: 'О нас' } },
|
||||
{ href: MENU_GROUPS.quote.basePath, label: MENU_GROUPS.quote.label, group: 'quote' },
|
||||
{ href: MENU_GROUPS.billing.basePath, label: MENU_GROUPS.billing.label, group: 'billing' },
|
||||
{ href: '/exchange-rate', label: { ko: '환율', en: 'Exchange', mn: 'Ханш', ru: 'Курс' } },
|
||||
{ href: MENU_GROUPS.inquiry.basePath, label: MENU_GROUPS.inquiry.label, group: 'inquiry' },
|
||||
{ href: '/contact', label: { ko: '연락처', en: 'Contact Us', mn: 'Холбоо барих', ru: 'Контакты' } },
|
||||
];
|
||||
|
||||
return (
|
||||
<header className="bg-primary-700 text-white shadow-lg">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
{/* Logo */}
|
||||
<Link href="/" className="text-2xl font-bold">
|
||||
AutonetSellCar
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<nav className="hidden md:flex space-x-10">
|
||||
{navItems.map((item) => {
|
||||
const isActive = item.group ? activeGroup === item.group : pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`hover:text-primary-200 transition ${isActive ? 'text-white font-semibold border-b-2 border-white pb-1' : ''}`}
|
||||
>
|
||||
{getLabel(item.label)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Right side: Notifications + Language + Auth */}
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* Notification Bell */}
|
||||
{user && (
|
||||
<div className="relative" ref={notificationRef}>
|
||||
<button
|
||||
onClick={handleNotificationClick}
|
||||
className="relative p-2 hover:bg-primary-600 rounded-full transition"
|
||||
title={t.notifications || '알림'}
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||
/>
|
||||
</svg>
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Notification Dropdown */}
|
||||
{showNotifications && (
|
||||
<div className="absolute right-0 mt-2 w-80 bg-white rounded-lg shadow-xl z-50 text-gray-800 max-h-96 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b bg-gray-50">
|
||||
<span className="font-semibold">{t.notifications || '알림'}</span>
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={handleMarkAllRead}
|
||||
className="text-sm text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
{t.markAllRead || '모두 읽음'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notification List */}
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{loadingNotifications ? (
|
||||
<div className="p-4 text-center text-gray-500">
|
||||
<div className="animate-spin w-6 h-6 border-2 border-primary-600 border-t-transparent rounded-full mx-auto"></div>
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
{t.noNotifications || '알림이 없습니다'}
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
onClick={() => handleNotificationItemClick(notification)}
|
||||
className={`px-4 py-3 border-b hover:bg-gray-50 cursor-pointer transition ${
|
||||
!notification.is_read ? 'bg-blue-50' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-xl">
|
||||
{getNotificationIcon(notification.notification_type)}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm ${!notification.is_read ? 'font-semibold' : ''}`}>
|
||||
{notification.title}
|
||||
</p>
|
||||
<p className="text-xs text-gray-600 mt-1 line-clamp-2">
|
||||
{notification.message}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{formatTimeAgo(notification.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
{!notification.is_read && (
|
||||
<span className="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 mt-2"></span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{notifications.length > 0 && (
|
||||
<div className="px-4 py-2 border-t bg-gray-50 text-center">
|
||||
<Link
|
||||
href="/notifications"
|
||||
className="text-sm text-primary-600 hover:text-primary-700"
|
||||
onClick={() => setShowNotifications(false)}
|
||||
>
|
||||
{t.viewAllNotifications || '모든 알림 보기'}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Language Selector */}
|
||||
<LanguageSelector />
|
||||
|
||||
{/* Auth */}
|
||||
{user ? (
|
||||
<>
|
||||
<Link
|
||||
href="/profile"
|
||||
className="text-sm hidden sm:inline hover:text-primary-200 transition cursor-pointer"
|
||||
>
|
||||
Hello, {user.name || user.email}
|
||||
</Link>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="bg-primary-600 hover:bg-primary-500 px-4 py-2 rounded transition"
|
||||
>
|
||||
{t.logout}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
href="/login"
|
||||
className="hover:text-primary-200 transition hidden sm:inline"
|
||||
>
|
||||
{t.login}
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="bg-white text-primary-700 hover:bg-primary-100 px-4 py-2 rounded transition"
|
||||
>
|
||||
{t.register}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Mobile menu button */}
|
||||
<button
|
||||
className="md:hidden p-2 hover:bg-primary-600 rounded"
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{mobileMenuOpen ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
) : (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Navigation */}
|
||||
{mobileMenuOpen && (
|
||||
<nav className="md:hidden py-4 border-t border-primary-600">
|
||||
{navItems.map((item) => {
|
||||
const isActive = item.group ? activeGroup === item.group : pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`block py-2 hover:text-primary-200 transition ${isActive ? 'text-white font-semibold' : ''}`}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
{getLabel(item.label)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
93
frontend/src/components/LanguageSelector.tsx
Normal file
93
frontend/src/components/LanguageSelector.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { useTranslation, LANGUAGE_OPTIONS, Language } from '@/lib/i18n';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
|
||||
export default function LanguageSelector() {
|
||||
const { language, setLanguage } = useTranslation();
|
||||
const { user } = useAuthStore();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Filter languages: Korean only visible to admins
|
||||
const isAdmin = user?.is_admin;
|
||||
const availableLanguages = useMemo(() =>
|
||||
LANGUAGE_OPTIONS.filter((lang) => lang.value !== 'ko' || isAdmin),
|
||||
[isAdmin]
|
||||
);
|
||||
|
||||
// If current language is not available (e.g., 'ko' for non-admin), reset to first available
|
||||
useEffect(() => {
|
||||
const isCurrentLangAvailable = availableLanguages.some((l) => l.value === language);
|
||||
if (!isCurrentLangAvailable && availableLanguages.length > 0) {
|
||||
setLanguage(availableLanguages[0].value);
|
||||
}
|
||||
}, [language, availableLanguages, setLanguage]);
|
||||
|
||||
const currentLang = availableLanguages.find((l) => l.value === language) || availableLanguages[0];
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleSelect = (lang: Language) => {
|
||||
setLanguage(lang);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<span className="text-lg">{currentLang.flag}</span>
|
||||
<span className="text-sm font-medium text-gray-700 hidden sm:inline">
|
||||
{currentLang.label}
|
||||
</span>
|
||||
<svg
|
||||
className={`w-4 h-4 text-gray-500 transition-transform ${isOpen ? '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>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 mt-2 w-40 bg-white border border-gray-200 rounded-lg shadow-lg z-50">
|
||||
{availableLanguages.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => handleSelect(option.value)}
|
||||
className={`w-full flex items-center gap-3 px-4 py-2.5 text-left hover:bg-gray-50 first:rounded-t-lg last:rounded-b-lg ${
|
||||
language === option.value ? 'bg-primary-50 text-primary-600' : 'text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg">{option.flag}</span>
|
||||
<span className="text-sm font-medium">{option.label}</span>
|
||||
{language === option.value && (
|
||||
<svg className="w-4 h-4 ml-auto" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
210
frontend/src/components/PhoneVerificationModal.tsx
Normal file
210
frontend/src/components/PhoneVerificationModal.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { verificationApi } from '@/lib/api';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
interface PhoneVerificationModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onVerified: () => void;
|
||||
currentPhone?: string;
|
||||
}
|
||||
|
||||
export default function PhoneVerificationModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onVerified,
|
||||
currentPhone
|
||||
}: PhoneVerificationModalProps) {
|
||||
const { language, t } = useTranslation();
|
||||
|
||||
const [step, setStep] = useState<'phone' | 'verify'>('phone');
|
||||
const [phone, setPhone] = useState(currentPhone || '');
|
||||
const [code, setCode] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
|
||||
// Reset state when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setStep('phone');
|
||||
setPhone(currentPhone || '');
|
||||
setCode('');
|
||||
setError('');
|
||||
setSuccess('');
|
||||
setCountdown(0);
|
||||
}
|
||||
}, [isOpen, currentPhone]);
|
||||
|
||||
// Countdown timer
|
||||
useEffect(() => {
|
||||
if (countdown > 0) {
|
||||
const timer = setTimeout(() => setCountdown(countdown - 1), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [countdown]);
|
||||
|
||||
const handleSendCode = async () => {
|
||||
if (!phone) {
|
||||
setError(t.enterPhoneNumber || 'Please enter your phone number');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await verificationApi.sendPhoneCode(phone, language);
|
||||
setSuccess(t.verificationCodeSent || 'Verification code sent');
|
||||
setStep('verify');
|
||||
setCountdown(60);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || t.failedToSendCode || 'Failed to send code');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyCode = async () => {
|
||||
if (!code) {
|
||||
setError(t.enterVerificationCode || 'Please enter the verification code');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await verificationApi.verifyPhoneCode(phone, code);
|
||||
setSuccess(t.phoneVerifiedSuccess || 'Phone verified successfully');
|
||||
setTimeout(() => {
|
||||
onVerified();
|
||||
onClose();
|
||||
}, 1000);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || t.invalidVerificationCode || 'Invalid code');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResendCode = async () => {
|
||||
if (countdown > 0) return;
|
||||
await handleSendCode();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg max-w-md w-full p-6 shadow-xl">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-xl font-bold text-gray-800">
|
||||
{t.phoneVerification || 'Phone Verification'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-600 p-3 rounded-md mb-4 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="bg-green-50 text-green-600 p-3 rounded-md mb-4 text-sm">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'phone' && (
|
||||
<div>
|
||||
<p className="text-gray-600 mb-4 text-sm">
|
||||
{t.phoneVerificationDesc || 'Please verify your phone number to continue with CC charging.'}
|
||||
</p>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t.phone || 'Phone Number'}
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-md px-4 py-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="+976 XXXX XXXX"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Include country code (e.g., +976 for Mongolia, +7 for Russia)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSendCode}
|
||||
disabled={loading}
|
||||
className="w-full bg-primary-600 text-white py-3 rounded-md hover:bg-primary-700 transition disabled:opacity-50"
|
||||
>
|
||||
{loading ? (t.sending || 'Sending...') : (t.sendVerificationCode || 'Send Verification Code')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'verify' && (
|
||||
<div>
|
||||
<p className="text-gray-600 mb-4 text-sm">
|
||||
{t.verificationCodeSentTo || 'We sent a verification code to'} <strong>{phone}</strong>
|
||||
</p>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t.verificationCode || 'Verification Code'}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-md px-4 py-2 focus:ring-primary-500 focus:border-primary-500 text-center text-2xl tracking-widest"
|
||||
placeholder="000000"
|
||||
maxLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleVerifyCode}
|
||||
disabled={loading}
|
||||
className="w-full bg-primary-600 text-white py-3 rounded-md hover:bg-primary-700 transition disabled:opacity-50 mb-4"
|
||||
>
|
||||
{loading ? (t.verifying || 'Verifying...') : (t.verifyPhone || 'Verify Phone')}
|
||||
</button>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<button
|
||||
onClick={() => setStep('phone')}
|
||||
className="text-gray-600 hover:underline text-sm"
|
||||
>
|
||||
← {t.changeEmail?.replace('Email', 'Phone') || 'Change Phone'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleResendCode}
|
||||
disabled={countdown > 0 || loading}
|
||||
className="text-primary-600 hover:underline text-sm disabled:text-gray-400"
|
||||
>
|
||||
{countdown > 0 ? `${t.resendIn || 'Resend in'} ${countdown}s` : (t.resendCode || 'Resend Code')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
209
frontend/src/components/SearchFilters.tsx
Normal file
209
frontend/src/components/SearchFilters.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { CarMaker, CarModel, SearchFilters } from '@/types';
|
||||
import { carsApi } from '@/lib/api';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
import { useExchangeRateStore } from '@/lib/exchangeRateStore';
|
||||
|
||||
interface SearchFiltersProps {
|
||||
onSearch: (filters: SearchFilters) => void;
|
||||
}
|
||||
|
||||
export default function SearchFiltersComponent({ onSearch }: SearchFiltersProps) {
|
||||
const { t, language } = useTranslation();
|
||||
const [makers, setMakers] = useState<CarMaker[]>([]);
|
||||
const [models, setModels] = useState<CarModel[]>([]);
|
||||
const [filters, setFilters] = useState<SearchFilters>({});
|
||||
|
||||
useEffect(() => {
|
||||
loadMakers();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (filters.maker_id) {
|
||||
loadModels(filters.maker_id);
|
||||
} else {
|
||||
setModels([]);
|
||||
}
|
||||
}, [filters.maker_id]);
|
||||
|
||||
const loadMakers = async () => {
|
||||
try {
|
||||
const data = await carsApi.getMakers();
|
||||
setMakers(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load makers:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadModels = async (makerId: number) => {
|
||||
try {
|
||||
const data = await carsApi.getModels(makerId);
|
||||
setModels(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load models:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (field: keyof SearchFilters, value: any) => {
|
||||
const newFilters = { ...filters, [field]: value || undefined };
|
||||
if (field === 'maker_id') {
|
||||
newFilters.model_id = undefined;
|
||||
}
|
||||
setFilters(newFilters);
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSearch(filters);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setFilters({});
|
||||
onSearch({});
|
||||
};
|
||||
|
||||
// Format price options with USD - using dynamic exchange rate
|
||||
const formatPriceOption = (krw: number) => {
|
||||
const usdRate = useExchangeRateStore.getState().rates.USD?.rate || 1483;
|
||||
const usd = Math.round(krw / usdRate);
|
||||
return `${usd.toLocaleString()} USD`;
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="bg-white p-6 rounded-lg shadow-md">
|
||||
<h2 className="text-xl font-semibold mb-4">{t.filter}</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{/* Maker */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t.maker}
|
||||
</label>
|
||||
<select
|
||||
value={filters.maker_id || ''}
|
||||
onChange={(e) => handleChange('maker_id', e.target.value ? Number(e.target.value) : undefined)}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<option value="">{t.allMakers}</option>
|
||||
{makers.map((maker) => (
|
||||
<option key={maker.id} value={maker.id}>
|
||||
{maker.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Model */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t.model}
|
||||
</label>
|
||||
<select
|
||||
value={filters.model_id || ''}
|
||||
onChange={(e) => handleChange('model_id', e.target.value ? Number(e.target.value) : undefined)}
|
||||
disabled={!filters.maker_id}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500 disabled:bg-gray-100"
|
||||
>
|
||||
<option value="">{t.allModels}</option>
|
||||
{models.map((model) => (
|
||||
<option key={model.id} value={model.id}>
|
||||
{model.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Year Range */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t.minYear}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={filters.year_min || ''}
|
||||
onChange={(e) => handleChange('year_min', e.target.value ? Number(e.target.value) : undefined)}
|
||||
placeholder="2015"
|
||||
min="2000"
|
||||
max="2025"
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Price Range (USD) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t.price} Max (USD)
|
||||
</label>
|
||||
<select
|
||||
value={filters.price_max || ''}
|
||||
onChange={(e) => handleChange('price_max', e.target.value ? Number(e.target.value) : undefined)}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<option value="">No Limit</option>
|
||||
<option value="13333333">{formatPriceOption(13333333)}</option>
|
||||
<option value="26666666">{formatPriceOption(26666666)}</option>
|
||||
<option value="40000000">{formatPriceOption(40000000)}</option>
|
||||
<option value="66666666">{formatPriceOption(66666666)}</option>
|
||||
<option value="133333333">{formatPriceOption(133333333)}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Mileage */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t.maxMileage} (km)
|
||||
</label>
|
||||
<select
|
||||
value={filters.mileage_max || ''}
|
||||
onChange={(e) => handleChange('mileage_max', e.target.value ? Number(e.target.value) : undefined)}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<option value="">No Limit</option>
|
||||
<option value="50000">50,000 km</option>
|
||||
<option value="100000">100,000 km</option>
|
||||
<option value="150000">150,000 km</option>
|
||||
<option value="200000">200,000 km</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Fuel */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t.fuel}
|
||||
</label>
|
||||
<select
|
||||
value={filters.fuel || ''}
|
||||
onChange={(e) => handleChange('fuel', e.target.value || undefined)}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<option value="">{t.allFuelTypes}</option>
|
||||
<option value="가솔린">{t.gasoline}</option>
|
||||
<option value="디젤">{t.diesel}</option>
|
||||
<option value="LPG">{t.lpg}</option>
|
||||
<option value="하이브리드">{t.hybrid}</option>
|
||||
<option value="전기">{t.electric}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="mt-6 flex justify-end space-x-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50 transition"
|
||||
>
|
||||
{t.reset}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-6 py-2 bg-primary-600 text-white rounded-md hover:bg-primary-700 transition"
|
||||
>
|
||||
{t.search}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
82
frontend/src/components/SidebarLayout.tsx
Normal file
82
frontend/src/components/SidebarLayout.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
import { MENU_GROUPS } from './Header';
|
||||
|
||||
interface SidebarLayoutProps {
|
||||
children: React.ReactNode;
|
||||
groupKey: 'quote' | 'billing' | 'inquiry';
|
||||
}
|
||||
|
||||
export default function SidebarLayout({ children, groupKey }: SidebarLayoutProps) {
|
||||
const pathname = usePathname();
|
||||
const { language } = useTranslation();
|
||||
|
||||
const group = MENU_GROUPS[groupKey];
|
||||
|
||||
const getLabel = (labelObj: { ko: string; en: string; mn: string; ru: string }) => {
|
||||
return labelObj[language as keyof typeof labelObj] || labelObj.en;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="flex">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-56 bg-white shadow-md min-h-[calc(100vh-4rem)] hidden md:block">
|
||||
<div className="p-4">
|
||||
<h2 className="text-lg font-bold text-gray-800 mb-4 pb-2 border-b">
|
||||
{getLabel(group.label)}
|
||||
</h2>
|
||||
<nav className="space-y-1">
|
||||
{group.items.map((item) => {
|
||||
const isActive = pathname === item.path || pathname.startsWith(item.path + '/');
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
href={item.path}
|
||||
className={`block px-3 py-2 rounded-lg transition ${
|
||||
isActive
|
||||
? 'bg-primary-500 text-white font-medium'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{getLabel(item.label)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Mobile Sidebar - horizontal tabs */}
|
||||
<div className="md:hidden w-full bg-white border-b shadow-sm">
|
||||
<div className="flex overflow-x-auto">
|
||||
{group.items.map((item) => {
|
||||
const isActive = pathname === item.path || pathname.startsWith(item.path + '/');
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
href={item.path}
|
||||
className={`flex-shrink-0 px-4 py-3 text-sm border-b-2 transition ${
|
||||
isActive
|
||||
? 'border-primary-500 text-primary-600 font-medium'
|
||||
: 'border-transparent text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{getLabel(item.label)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 p-6">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1609
frontend/src/lib/api.ts
Normal file
1609
frontend/src/lib/api.ts
Normal file
File diff suppressed because it is too large
Load Diff
114
frontend/src/lib/exchangeRateStore.ts
Normal file
114
frontend/src/lib/exchangeRateStore.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Exchange Rate Store
|
||||
* 한국수출입은행 API에서 가져온 환율 정보를 저장하고 관리
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
import { exchangeRateApi, ExchangeRateSimple } from './api';
|
||||
|
||||
// 기본 환율 (API 실패 시 사용) - 2024년 12월 기준
|
||||
const DEFAULT_RATES: ExchangeRateSimple = {
|
||||
USD: { rate: 1483, symbol: '$', name: '미국 달러' },
|
||||
MNT: { rate: 0.43, symbol: '₮', name: '몽골 투그릭' },
|
||||
RUB: { rate: 14.5, symbol: '₽', name: '러시아 루블' },
|
||||
CNY: { rate: 203, symbol: '¥', name: '중국 위안' },
|
||||
EUR: { rate: 1750, symbol: '€', name: '유로' },
|
||||
JPY: { rate: 9.5, symbol: '¥', name: '일본 엔' },
|
||||
};
|
||||
|
||||
interface ExchangeRateState {
|
||||
rates: ExchangeRateSimple;
|
||||
isLoading: boolean;
|
||||
lastUpdated: Date | null;
|
||||
error: string | null;
|
||||
fetchRates: () => Promise<void>;
|
||||
getKrwToUsd: () => number; // KRW를 USD로 변환하는 비율 (1 KRW = X USD)
|
||||
getUsdToMnt: () => number; // USD를 MNT로 변환하는 비율 (1 USD = X MNT)
|
||||
getUsdToRub: () => number; // USD를 RUB로 변환하는 비율 (1 USD = X RUB)
|
||||
convertKrwTo: (krwAmount: number, currency: string) => number;
|
||||
}
|
||||
|
||||
export const useExchangeRateStore = create<ExchangeRateState>((set, get) => ({
|
||||
rates: DEFAULT_RATES,
|
||||
isLoading: false,
|
||||
lastUpdated: null,
|
||||
error: null,
|
||||
|
||||
fetchRates: async () => {
|
||||
// 이미 로딩 중이면 스킵
|
||||
if (get().isLoading) return;
|
||||
|
||||
// 30분 이내에 업데이트했으면 스킵
|
||||
const lastUpdated = get().lastUpdated;
|
||||
if (lastUpdated && Date.now() - lastUpdated.getTime() < 30 * 60 * 1000) {
|
||||
return;
|
||||
}
|
||||
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const rates = await exchangeRateApi.getSimpleRates();
|
||||
set({
|
||||
rates: { ...DEFAULT_RATES, ...rates },
|
||||
isLoading: false,
|
||||
lastUpdated: new Date(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch exchange rates:', error);
|
||||
set({
|
||||
isLoading: false,
|
||||
error: 'Failed to fetch exchange rates',
|
||||
});
|
||||
// 실패해도 기본값 사용
|
||||
}
|
||||
},
|
||||
|
||||
getKrwToUsd: () => {
|
||||
const { rates } = get();
|
||||
const usdRate = rates.USD?.rate || DEFAULT_RATES.USD.rate;
|
||||
return 1 / usdRate; // 1 KRW = 1/1450 USD
|
||||
},
|
||||
|
||||
getUsdToMnt: () => {
|
||||
const { rates } = get();
|
||||
const usdRate = rates.USD?.rate || DEFAULT_RATES.USD.rate;
|
||||
const mntRate = rates.MNT?.rate || DEFAULT_RATES.MNT.rate;
|
||||
// MNT rate is already in KRW per MNT
|
||||
// So 1 USD = (USD_KRW / MNT_KRW) MNT
|
||||
return usdRate / mntRate;
|
||||
},
|
||||
|
||||
getUsdToRub: () => {
|
||||
const { rates } = get();
|
||||
const usdRate = rates.USD?.rate || DEFAULT_RATES.USD.rate;
|
||||
const rubRate = rates.RUB?.rate || DEFAULT_RATES.RUB.rate;
|
||||
return usdRate / rubRate;
|
||||
},
|
||||
|
||||
convertKrwTo: (krwAmount: number, currency: string) => {
|
||||
const { rates } = get();
|
||||
const rate = rates[currency]?.rate || DEFAULT_RATES[currency]?.rate;
|
||||
if (!rate) return 0;
|
||||
return krwAmount / rate;
|
||||
},
|
||||
}));
|
||||
|
||||
// 환율 초기화 함수 (앱 시작 시 호출)
|
||||
export async function initExchangeRates() {
|
||||
const store = useExchangeRateStore.getState();
|
||||
await store.fetchRates();
|
||||
}
|
||||
|
||||
// 환율 변환 헬퍼 함수들
|
||||
export function formatWithExchangeRate(
|
||||
krwAmount: number,
|
||||
currency: 'USD' | 'MNT' | 'RUB' | 'CNY' | 'EUR'
|
||||
): string {
|
||||
const store = useExchangeRateStore.getState();
|
||||
const converted = store.convertKrwTo(krwAmount, currency);
|
||||
const symbol = store.rates[currency]?.symbol || DEFAULT_RATES[currency]?.symbol || '';
|
||||
|
||||
return `${symbol}${converted.toLocaleString('en-US', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: currency === 'USD' ? 0 : 0,
|
||||
})}`;
|
||||
}
|
||||
3021
frontend/src/lib/i18n.ts
Normal file
3021
frontend/src/lib/i18n.ts
Normal file
File diff suppressed because it is too large
Load Diff
30
frontend/src/lib/store.ts
Normal file
30
frontend/src/lib/store.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { create } from 'zustand';
|
||||
import { User } from '@/types';
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
isLoading: boolean;
|
||||
setUser: (user: User | null) => void;
|
||||
setToken: (token: string | null) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
user: null,
|
||||
token: typeof window !== 'undefined' ? localStorage.getItem('token') : null,
|
||||
isLoading: true,
|
||||
setUser: (user) => set({ user, isLoading: false }),
|
||||
setToken: (token) => {
|
||||
if (token) {
|
||||
localStorage.setItem('token', token);
|
||||
} else {
|
||||
localStorage.removeItem('token');
|
||||
}
|
||||
set({ token });
|
||||
},
|
||||
logout: () => {
|
||||
localStorage.removeItem('token');
|
||||
set({ user: null, token: null });
|
||||
},
|
||||
}));
|
||||
134
frontend/src/lib/useTranslate.ts
Normal file
134
frontend/src/lib/useTranslate.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { translationsApi } from './api';
|
||||
import { useLanguageStore, translateCarName, Language } from './i18n';
|
||||
|
||||
// Cache for translations to avoid repeated API calls
|
||||
const translationCache: Record<string, Record<string, string>> = {};
|
||||
|
||||
export function useTranslate() {
|
||||
const { language } = useLanguageStore();
|
||||
const [translations, setTranslations] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Get cache key for current language
|
||||
const cacheKey = `trans_${language}`;
|
||||
|
||||
// Load translations from cache on mount
|
||||
useEffect(() => {
|
||||
if (translationCache[cacheKey]) {
|
||||
setTranslations(translationCache[cacheKey]);
|
||||
}
|
||||
}, [cacheKey]);
|
||||
|
||||
// Translate a single text
|
||||
const translate = useCallback((text: string | undefined | null): string => {
|
||||
if (!text) return '';
|
||||
if (language === 'ko') return text; // Korean is source, no translation needed
|
||||
|
||||
// Try static translations FIRST (for fuel, transmission, car names, etc.)
|
||||
const staticTranslation = translateCarName(text, language as Language);
|
||||
if (staticTranslation !== text) {
|
||||
return staticTranslation;
|
||||
}
|
||||
|
||||
// Then check API cache for other translations
|
||||
const cached = translationCache[cacheKey]?.[text];
|
||||
if (cached) return cached;
|
||||
|
||||
return text; // Fallback to original if no translation found
|
||||
}, [language, cacheKey]);
|
||||
|
||||
// Bulk load translations for multiple texts
|
||||
const loadTranslations = useCallback(async (texts: string[], category?: string) => {
|
||||
if (language === 'ko') return; // No need to translate Korean
|
||||
|
||||
// Filter out already cached texts
|
||||
const uncachedTexts = texts.filter(
|
||||
t => t && !translationCache[cacheKey]?.[t]
|
||||
);
|
||||
|
||||
if (uncachedTexts.length === 0) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Map language code to API expected format
|
||||
const langCode = language === 'mn' ? 'mn' : language === 'ru' ? 'ru' : 'en';
|
||||
|
||||
const result = await translationsApi.bulkLookup(uncachedTexts, langCode, category);
|
||||
|
||||
// Update cache
|
||||
if (!translationCache[cacheKey]) {
|
||||
translationCache[cacheKey] = {};
|
||||
}
|
||||
|
||||
Object.assign(translationCache[cacheKey], result.translations);
|
||||
setTranslations({ ...translationCache[cacheKey] });
|
||||
} catch (err) {
|
||||
console.error('Failed to load translations:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [language, cacheKey]);
|
||||
|
||||
// Translate car object fields
|
||||
const translateCar = useCallback((car: {
|
||||
car_name?: string;
|
||||
fuel?: string;
|
||||
transmission?: string;
|
||||
color?: string;
|
||||
maker?: { name: string };
|
||||
model?: { name: string };
|
||||
}) => {
|
||||
return {
|
||||
car_name: translate(car.car_name),
|
||||
fuel: translate(car.fuel),
|
||||
transmission: translate(car.transmission),
|
||||
color: translate(car.color),
|
||||
maker_name: translate(car.maker?.name),
|
||||
model_name: translate(car.model?.name),
|
||||
};
|
||||
}, [translate]);
|
||||
|
||||
// Preload translations for a list of cars
|
||||
const preloadCarTranslations = useCallback(async (cars: Array<{
|
||||
car_name?: string;
|
||||
fuel?: string;
|
||||
transmission?: string;
|
||||
color?: string;
|
||||
maker?: { name: string };
|
||||
model?: { name: string };
|
||||
}>) => {
|
||||
const textsToTranslate: string[] = [];
|
||||
|
||||
cars.forEach(car => {
|
||||
if (car.car_name) textsToTranslate.push(car.car_name);
|
||||
if (car.fuel) textsToTranslate.push(car.fuel);
|
||||
if (car.transmission) textsToTranslate.push(car.transmission);
|
||||
if (car.color) textsToTranslate.push(car.color);
|
||||
if (car.maker?.name) textsToTranslate.push(car.maker.name);
|
||||
if (car.model?.name) textsToTranslate.push(car.model.name);
|
||||
});
|
||||
|
||||
// Remove duplicates
|
||||
const uniqueTexts = Array.from(new Set(textsToTranslate));
|
||||
|
||||
if (uniqueTexts.length > 0) {
|
||||
await loadTranslations(uniqueTexts);
|
||||
}
|
||||
}, [loadTranslations]);
|
||||
|
||||
return {
|
||||
translate,
|
||||
translateCar,
|
||||
loadTranslations,
|
||||
preloadCarTranslations,
|
||||
loading,
|
||||
};
|
||||
}
|
||||
|
||||
// Clear translation cache (useful when translations are updated)
|
||||
export function clearTranslationCache() {
|
||||
Object.keys(translationCache).forEach(key => {
|
||||
delete translationCache[key];
|
||||
});
|
||||
}
|
||||
91
frontend/src/lib/useVisitorTracking.ts
Normal file
91
frontend/src/lib/useVisitorTracking.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
||||
|
||||
// Generate a simple session ID
|
||||
const generateSessionId = (): string => {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
};
|
||||
|
||||
// Get or create session ID
|
||||
const getSessionId = (): string => {
|
||||
if (typeof window === 'undefined') return '';
|
||||
|
||||
let sessionId = sessionStorage.getItem('visitor_session_id');
|
||||
if (!sessionId) {
|
||||
sessionId = generateSessionId();
|
||||
sessionStorage.setItem('visitor_session_id', sessionId);
|
||||
}
|
||||
return sessionId;
|
||||
};
|
||||
|
||||
// Extract UTM parameters from URL
|
||||
const getUtmParams = () => {
|
||||
if (typeof window === 'undefined') return {};
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return {
|
||||
utm_source: params.get('utm_source') || undefined,
|
||||
utm_medium: params.get('utm_medium') || undefined,
|
||||
utm_campaign: params.get('utm_campaign') || undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export function useVisitorTracking() {
|
||||
const pathname = usePathname();
|
||||
const lastPath = useRef<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
// Avoid duplicate tracking on same path
|
||||
if (pathname === lastPath.current) return;
|
||||
lastPath.current = pathname;
|
||||
|
||||
// Skip admin pages from tracking
|
||||
if (pathname.startsWith('/admin')) return;
|
||||
|
||||
const trackVisit = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const utmParams = getUtmParams();
|
||||
|
||||
await fetch(`${API_URL}/api/visitor/log`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
page_path: pathname,
|
||||
page_title: document.title,
|
||||
referrer: document.referrer || null,
|
||||
session_id: getSessionId(),
|
||||
...utmParams,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
// Silent fail - don't disrupt user experience
|
||||
console.debug('Visitor tracking failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Small delay to ensure page title is set
|
||||
setTimeout(trackVisit, 100);
|
||||
}, [pathname]);
|
||||
}
|
||||
|
||||
// Component wrapper for use in layout
|
||||
export function VisitorTracker() {
|
||||
useVisitorTracking();
|
||||
return null;
|
||||
}
|
||||
165
frontend/src/types/index.ts
Normal file
165
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
export interface CarMaker {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
name_en?: string;
|
||||
}
|
||||
|
||||
export interface CarModel {
|
||||
id: number;
|
||||
code: string;
|
||||
maker_id: number;
|
||||
name: string;
|
||||
name_en?: string;
|
||||
}
|
||||
|
||||
export interface CarImage {
|
||||
id: number;
|
||||
url?: string;
|
||||
local_path?: string;
|
||||
is_main: boolean;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface Car {
|
||||
id: number;
|
||||
source: string;
|
||||
source_id: string;
|
||||
car_name?: string;
|
||||
year?: number;
|
||||
month?: number;
|
||||
mileage?: number;
|
||||
price_krw?: number;
|
||||
margin_krw?: number;
|
||||
final_price_krw?: number;
|
||||
price_usd?: number;
|
||||
is_displayed?: boolean;
|
||||
fuel?: string;
|
||||
transmission?: string;
|
||||
color?: string;
|
||||
displacement?: number;
|
||||
car_number?: string;
|
||||
seize_count: number;
|
||||
collateral_count: number;
|
||||
check_num?: string;
|
||||
dealer_name?: string;
|
||||
dealer_description?: string;
|
||||
dealer_description_en?: string;
|
||||
dealer_description_mn?: string;
|
||||
dealer_description_ru?: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
maker?: CarMaker;
|
||||
model?: CarModel;
|
||||
images: CarImage[];
|
||||
specification?: CarSpecification;
|
||||
}
|
||||
|
||||
export interface CarSpecification {
|
||||
id: number;
|
||||
car_id: number;
|
||||
manufacturer?: string;
|
||||
model_name?: string;
|
||||
grade?: string;
|
||||
model_year?: string;
|
||||
fuel_type?: string;
|
||||
transmission?: string;
|
||||
drive_type?: string;
|
||||
max_power?: string;
|
||||
max_torque?: string;
|
||||
fuel_efficiency?: string;
|
||||
body_type?: string;
|
||||
door_count?: number;
|
||||
seating_capacity?: number;
|
||||
length?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
wheelbase?: number;
|
||||
curb_weight?: number;
|
||||
displacement?: number;
|
||||
safety_options?: string[];
|
||||
comfort_options?: string[];
|
||||
exterior_options?: string[];
|
||||
interior_options?: string[];
|
||||
raw_data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CarListResponse {
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
cars: Car[];
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
email: string;
|
||||
name?: string;
|
||||
phone?: string;
|
||||
country: string;
|
||||
is_active: boolean;
|
||||
is_admin: boolean;
|
||||
is_dealer: boolean;
|
||||
cc_balance: number;
|
||||
referral_code?: string;
|
||||
email_verified: boolean;
|
||||
phone_verified: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CarView {
|
||||
id: number;
|
||||
user_id: number;
|
||||
car_id: number;
|
||||
cc_paid: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SearchFilters {
|
||||
maker_id?: number;
|
||||
model_id?: number;
|
||||
year_min?: number;
|
||||
year_max?: number;
|
||||
price_min?: number;
|
||||
price_max?: number;
|
||||
mileage_max?: number;
|
||||
fuel?: string;
|
||||
}
|
||||
|
||||
// Hero Banner Types
|
||||
export interface HeroBanner {
|
||||
id: number;
|
||||
// 다국어 제목 (Admin API 응답)
|
||||
title_ko?: string;
|
||||
title_en?: string;
|
||||
title_mn?: string;
|
||||
// 다국어 서브타이틀 (Admin API 응답)
|
||||
subtitle_ko?: string;
|
||||
subtitle_en?: string;
|
||||
subtitle_mn?: string;
|
||||
// 로컬라이즈된 필드 (Public API 응답)
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
// 이미지 및 링크
|
||||
image_url: string;
|
||||
link_url?: string;
|
||||
car_id?: number | null;
|
||||
// 상태
|
||||
is_active?: boolean;
|
||||
display_order?: number;
|
||||
// 타임스탬프
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
// 관계
|
||||
car?: Car;
|
||||
}
|
||||
|
||||
export interface HeroBannerSettings {
|
||||
id: number;
|
||||
slide_interval: number;
|
||||
animation_type: string;
|
||||
image_width: number;
|
||||
image_height: number;
|
||||
auto_play: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user