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:
AutonetSellCar Deploy
2025-12-30 13:24:39 +09:00
commit 1f0dcb1ddb
224 changed files with 55119 additions and 0 deletions

46
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,46 @@
FROM node:20-alpine AS base
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED 1
RUN npm run build
# Production image
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
RUN mkdir .next
RUN chown nextjs:nodejs .next
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
CMD ["node", "server.js"]

5
frontend/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

38
frontend/next.config.js Normal file
View File

@@ -0,0 +1,38 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
images: {
remotePatterns: [
{
protocol: 'http',
hostname: 'dealer.carmodoo.com',
pathname: '/data/**',
},
{
protocol: 'https',
hostname: 'dealer.carmodoo.com',
pathname: '/data/**',
},
{
protocol: 'https',
hostname: 'images.unsplash.com',
pathname: '/**',
},
{
protocol: 'http',
hostname: 'localhost',
pathname: '/uploads/**',
},
{
protocol: 'http',
hostname: '192.168.0.202',
pathname: '/uploads/**',
},
],
},
env: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://192.168.0.202:8000',
},
}
module.exports = nextConfig

2031
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
frontend/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "autonet-frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start -p 3000",
"lint": "next lint"
},
"dependencies": {
"@heroicons/react": "^2.1.1",
"@stripe/react-stripe-js": "^5.4.1",
"@stripe/stripe-js": "^8.6.0",
"axios": "^1.6.5",
"framer-motion": "^12.23.25",
"next": "14.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.49.3",
"zustand": "^4.5.0"
},
"devDependencies": {
"@types/node": "^20.11.5",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"autoprefixer": "^10.4.17",
"postcss": "^8.4.33",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

92
frontend/public/sw.js Normal file
View File

@@ -0,0 +1,92 @@
// Service Worker for Push Notifications
self.addEventListener('install', (event) => {
console.log('Service Worker installing.');
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
console.log('Service Worker activated.');
event.waitUntil(clients.claim());
});
self.addEventListener('push', (event) => {
console.log('Push notification received:', event);
let data = {
title: 'AutonetSellCar',
body: 'You have a new notification',
icon: '/icon-192.png',
badge: '/badge-72.png',
url: '/'
};
if (event.data) {
try {
const payload = event.data.json();
data = {
...data,
...payload
};
} catch (e) {
data.body = event.data.text();
}
}
const options = {
body: data.body,
icon: data.icon || '/icon-192.png',
badge: data.badge || '/badge-72.png',
vibrate: [100, 50, 100],
data: {
url: data.url || '/',
dateOfArrival: Date.now()
},
actions: [
{
action: 'open',
title: 'View'
},
{
action: 'close',
title: 'Close'
}
]
};
event.waitUntil(
self.registration.showNotification(data.title, options)
);
});
self.addEventListener('notificationclick', (event) => {
console.log('Notification clicked:', event);
event.notification.close();
if (event.action === 'close') {
return;
}
const url = event.notification.data?.url || '/';
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
// Check if there's already a window open
for (const client of clientList) {
if (client.url.includes(self.location.origin) && 'focus' in client) {
client.navigate(url);
return client.focus();
}
}
// Open a new window if none exists
if (clients.openWindow) {
return clients.openWindow(url);
}
})
);
});
self.addEventListener('notificationclose', (event) => {
console.log('Notification closed:', event);
});

View 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>
);
}

File diff suppressed because it is too large Load Diff

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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">
&times;
</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">
&times;
</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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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"
>
&times;
</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"
>
&laquo;
</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"
>
&lsaquo;
</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"
>
&rsaquo;
</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"
>
&raquo;
</button>
</div>
)}
</div>
</div>
)}
</div>
);
}

View 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="&#128187;"
/>
<BreakdownCard
title="By Browser"
data={overview?.browser_breakdown || {}}
icon="&#127760;"
/>
<BreakdownCard
title="By Country"
data={overview?.country_breakdown || {}}
icon="&#127757;"
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>
);
}

View 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>
);
}

File diff suppressed because it is too large Load Diff

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}
}

View 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>
);
}

View 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>
)
}

View 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&apos;t have an account?{' '}
<Link href="/register" className="text-primary-600 hover:underline">
Register here
</Link>
</p>
</div>
</div>
</div>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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}</>;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}

View 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>&copy; 2025 AutonetSellCar. {t.allRightsReserved}.</p>
</div>
</div>
</footer>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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

File diff suppressed because it is too large Load Diff

View 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

File diff suppressed because it is too large Load Diff

30
frontend/src/lib/store.ts Normal file
View 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 });
},
}));

View 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];
});
}

View 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
View 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;
}

View File

@@ -0,0 +1,29 @@
import type { Config } from 'tailwindcss'
const config: Config = {
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
},
},
},
},
plugins: [],
}
export default config

26
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}