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:
487
frontend/src/app/exchange-rate/page.tsx
Normal file
487
frontend/src/app/exchange-rate/page.tsx
Normal file
@@ -0,0 +1,487 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
||||
|
||||
interface ExchangeRateData {
|
||||
currency_code: string;
|
||||
currency_name: string;
|
||||
symbol: string;
|
||||
deal_base_rate: number;
|
||||
ttb_rate: number;
|
||||
tts_rate: number;
|
||||
weight_percent: number;
|
||||
adjusted_rate: number;
|
||||
source_date: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface ExchangeRatesResponse {
|
||||
base_currency: string;
|
||||
rates: ExchangeRateData[];
|
||||
last_updated: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
interface CustomPair {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
// All supported currencies
|
||||
const ALL_CURRENCIES = [
|
||||
{ code: 'KRW', name: { ko: '한국 원', en: 'Korean Won', mn: 'Солонгос вон', ru: 'Корейская вона' }, symbol: '₩', flag: '🇰🇷' },
|
||||
{ code: 'USD', name: { ko: '미국 달러', en: 'US Dollar', mn: 'АНУ-ын доллар', ru: 'Доллар США' }, symbol: '$', flag: '🇺🇸' },
|
||||
{ code: 'MNT', name: { ko: '몽골 투그릭', en: 'Mongolian Tugrik', mn: 'Монгол төгрөг', ru: 'Монгольский тугрик' }, symbol: '₮', flag: '🇲🇳' },
|
||||
{ code: 'RUB', name: { ko: '러시아 루블', en: 'Russian Ruble', mn: 'Оросын рубль', ru: 'Российский рубль' }, symbol: '₽', flag: '🇷🇺' },
|
||||
{ code: 'CNY', name: { ko: '중국 위안', en: 'Chinese Yuan', mn: 'Хятадын юань', ru: 'Китайский юань' }, symbol: '¥', flag: '🇨🇳' },
|
||||
{ code: 'JPY', name: { ko: '일본 엔', en: 'Japanese Yen', mn: 'Японы иен', ru: 'Японская иена' }, symbol: '¥', flag: '🇯🇵' },
|
||||
{ code: 'EUR', name: { ko: '유로', en: 'Euro', mn: 'Евро', ru: 'Евро' }, symbol: '€', flag: '🇪🇺' },
|
||||
{ code: 'GBP', name: { ko: '영국 파운드', en: 'British Pound', mn: 'Британийн фунт', ru: 'Британский фунт' }, symbol: '£', flag: '🇬🇧' },
|
||||
];
|
||||
|
||||
// Exchange rates against KRW (1 KRW = X foreign currency, will be updated from API)
|
||||
// These are inverse rates: 1 KRW = 1/adjusted_rate
|
||||
const DEFAULT_RATES: Record<string, number> = {
|
||||
KRW: 1,
|
||||
USD: 1 / 1483, // 1 KRW = 0.000674 USD
|
||||
MNT: 1 / 0.43, // 1 KRW = 2.33 MNT
|
||||
RUB: 1 / 14.5, // 1 KRW = 0.069 RUB
|
||||
CNY: 1 / 203, // 1 KRW = 0.0049 CNY
|
||||
JPY: 1 / 9.5, // 1 KRW = 0.105 JPY
|
||||
EUR: 1 / 1750, // 1 KRW = 0.00057 EUR
|
||||
GBP: 1 / 1880, // 1 KRW = 0.00053 GBP
|
||||
};
|
||||
|
||||
export default function ExchangeRatePage() {
|
||||
const { language } = useTranslation();
|
||||
const [rates, setRates] = useState<ExchangeRateData[]>([]);
|
||||
const [allRates, setAllRates] = useState<Record<string, number>>(DEFAULT_RATES);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [lastUpdated, setLastUpdated] = useState<string>('');
|
||||
const [source, setSource] = useState<string>('');
|
||||
|
||||
// Get default toCurrency based on language
|
||||
const getDefaultToCurrency = (lang: string) => {
|
||||
switch (lang) {
|
||||
case 'ko': return 'KRW';
|
||||
case 'en': return 'KRW'; // English defaults to KRW
|
||||
case 'mn': return 'MNT';
|
||||
case 'ru': return 'RUB';
|
||||
default: return 'KRW';
|
||||
}
|
||||
};
|
||||
|
||||
// Converter state
|
||||
const [amount, setAmount] = useState<string>('1000');
|
||||
const [fromCurrency, setFromCurrency] = useState<string>('USD');
|
||||
const [toCurrency, setToCurrency] = useState<string>(getDefaultToCurrency(language));
|
||||
|
||||
// Custom pairs (saved in localStorage)
|
||||
const [customPairs, setCustomPairs] = useState<CustomPair[]>([
|
||||
{ from: 'KRW', to: 'USD' },
|
||||
{ from: 'MNT', to: 'USD' },
|
||||
]);
|
||||
const [editingPair, setEditingPair] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRates();
|
||||
loadCustomPairs();
|
||||
}, []);
|
||||
|
||||
const loadCustomPairs = () => {
|
||||
try {
|
||||
const saved = localStorage.getItem('exchangeRateCustomPairs');
|
||||
if (saved) {
|
||||
setCustomPairs(JSON.parse(saved));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load custom pairs:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const saveCustomPairs = (pairs: CustomPair[]) => {
|
||||
setCustomPairs(pairs);
|
||||
localStorage.setItem('exchangeRateCustomPairs', JSON.stringify(pairs));
|
||||
};
|
||||
|
||||
const fetchRates = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`${API_BASE_URL}/api/exchange-rate`);
|
||||
if (response.ok) {
|
||||
const data: ExchangeRatesResponse = await response.json();
|
||||
setRates(data.rates);
|
||||
setLastUpdated(data.last_updated);
|
||||
setSource(data.source);
|
||||
|
||||
// Update all rates - adjusted_rate is KRW per 1 unit of foreign currency
|
||||
// For conversion, we need the inverse (1 KRW = X foreign currency)
|
||||
const newRates: Record<string, number> = { KRW: 1 };
|
||||
data.rates.forEach(rate => {
|
||||
// 1 KRW = 1/adjusted_rate foreign currency
|
||||
newRates[rate.currency_code] = 1 / rate.adjusted_rate;
|
||||
});
|
||||
setAllRates({ ...DEFAULT_RATES, ...newRates });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch exchange rates:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatNumber = (num: number, decimals: number = 2) => {
|
||||
return new Intl.NumberFormat('ko-KR', {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
}).format(num);
|
||||
};
|
||||
|
||||
const formatDateTime = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString(language === 'ko' ? 'ko-KR' : 'en-US');
|
||||
};
|
||||
|
||||
const getCurrencyInfo = (code: string) => {
|
||||
return ALL_CURRENCIES.find(c => c.code === code) || ALL_CURRENCIES[0];
|
||||
};
|
||||
|
||||
const getCurrencyName = (code: string) => {
|
||||
const info = getCurrencyInfo(code);
|
||||
return info.name[language as keyof typeof info.name] || info.name.en;
|
||||
};
|
||||
|
||||
// Convert between any two currencies
|
||||
const convert = (amount: number, from: string, to: string): number => {
|
||||
if (from === to) return amount;
|
||||
|
||||
// Convert to KRW first, then to target currency
|
||||
const inKRW = from === 'KRW' ? amount : amount / allRates[from];
|
||||
const result = to === 'KRW' ? inKRW : inKRW * allRates[to];
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const getExchangeRate = (from: string, to: string): number => {
|
||||
return convert(1, from, to);
|
||||
};
|
||||
|
||||
// Format number with commas for display
|
||||
const formatWithCommas = (value: string): string => {
|
||||
const num = value.replace(/[^\d]/g, '');
|
||||
return num.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
};
|
||||
|
||||
// Handle amount input with comma formatting
|
||||
const handleAmountChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const rawValue = e.target.value.replace(/[^\d]/g, '');
|
||||
setAmount(rawValue);
|
||||
};
|
||||
|
||||
// Get display value with commas
|
||||
const displayAmount = formatWithCommas(amount);
|
||||
|
||||
const handlePairUpdate = (index: number, field: 'from' | 'to', value: string) => {
|
||||
const newPairs = [...customPairs];
|
||||
newPairs[index] = { ...newPairs[index], [field]: value };
|
||||
saveCustomPairs(newPairs);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-2">
|
||||
{language === 'ko' ? '환율 정보' : language === 'mn' ? 'Ханш мэдээлэл' : language === 'ru' ? 'Курс валют' : 'Exchange Rates'}
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
{language === 'ko'
|
||||
? '실시간 환율 정보를 확인하세요'
|
||||
: 'Check real-time exchange rates'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Main Converter Card */}
|
||||
<div className="bg-gradient-to-r from-primary-600 to-primary-700 rounded-xl p-6 text-white mb-8 shadow-lg">
|
||||
<h2 className="text-lg font-semibold mb-4">
|
||||
{language === 'ko' ? '환율 계산기' : 'Currency Converter'}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
|
||||
{/* From */}
|
||||
<div>
|
||||
<label className="block text-primary-100 text-sm mb-1">
|
||||
{language === 'ko' ? '변환할 금액' : 'Amount'}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl">{getCurrencyInfo(fromCurrency).flag}</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={displayAmount}
|
||||
onChange={handleAmountChange}
|
||||
className="flex-1 px-4 py-3 rounded-lg text-gray-800 text-lg font-semibold focus:ring-2 focus:ring-white"
|
||||
placeholder="1,000"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={fromCurrency}
|
||||
onChange={(e) => setFromCurrency(e.target.value)}
|
||||
className="w-full mt-2 px-3 py-2 rounded-lg text-gray-800"
|
||||
>
|
||||
{ALL_CURRENCIES.map(curr => (
|
||||
<option key={curr.code} value={curr.code}>
|
||||
{curr.flag} {curr.code} - {getCurrencyName(curr.code)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Arrow */}
|
||||
<div className="flex justify-center items-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
setFromCurrency(toCurrency);
|
||||
setToCurrency(fromCurrency);
|
||||
}}
|
||||
className="p-2 bg-white/20 rounded-full hover:bg-white/30 transition"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* To */}
|
||||
<div>
|
||||
<label className="block text-primary-100 text-sm mb-1">
|
||||
{language === 'ko' ? '변환 결과' : 'Result'}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl">{getCurrencyInfo(toCurrency).flag}</span>
|
||||
<div className="flex-1 px-4 py-3 rounded-lg bg-white/20 text-lg font-semibold">
|
||||
{formatNumber(convert(parseFloat(amount) || 0, fromCurrency, toCurrency), toCurrency === 'KRW' || toCurrency === 'MNT' || toCurrency === 'JPY' ? 0 : 2)}
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
value={toCurrency}
|
||||
onChange={(e) => setToCurrency(e.target.value)}
|
||||
className="w-full mt-2 px-3 py-2 rounded-lg text-gray-800"
|
||||
>
|
||||
{ALL_CURRENCIES.map(curr => (
|
||||
<option key={curr.code} value={curr.code}>
|
||||
{curr.flag} {curr.code} - {getCurrencyName(curr.code)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Exchange Rate Display */}
|
||||
<div className="mt-4 text-center text-primary-100 text-sm">
|
||||
1 {fromCurrency} = {formatNumber(getExchangeRate(fromCurrency, toCurrency), 6)} {toCurrency}
|
||||
{' • '}
|
||||
1 {toCurrency} = {formatNumber(getExchangeRate(toCurrency, fromCurrency), 6)} {fromCurrency}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Pairs */}
|
||||
<div className="bg-white rounded-xl shadow-lg p-6 mb-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-800">
|
||||
{language === 'ko' ? '나의 환율 쌍' : 'My Currency Pairs'}
|
||||
</h2>
|
||||
<span className="text-xs text-gray-500">
|
||||
{language === 'ko' ? '클릭하여 수정' : 'Click to edit'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{customPairs.map((pair, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="border-2 border-dashed border-gray-200 rounded-lg p-4 hover:border-primary-400 transition cursor-pointer"
|
||||
onClick={() => setEditingPair(editingPair === index ? null : index)}
|
||||
>
|
||||
{editingPair === index ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={pair.from}
|
||||
onChange={(e) => handlePairUpdate(index, 'from', e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex-1 px-2 py-1 border rounded text-sm"
|
||||
>
|
||||
{ALL_CURRENCIES.map(curr => (
|
||||
<option key={curr.code} value={curr.code}>{curr.flag} {curr.code}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-gray-400">→</span>
|
||||
<select
|
||||
value={pair.to}
|
||||
onChange={(e) => handlePairUpdate(index, 'to', e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex-1 px-2 py-1 border rounded text-sm"
|
||||
>
|
||||
{ALL_CURRENCIES.map(curr => (
|
||||
<option key={curr.code} value={curr.code}>{curr.flag} {curr.code}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<p className="text-xs text-primary-600 text-center">
|
||||
{language === 'ko' ? '클릭하여 저장' : 'Click to save'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{getCurrencyInfo(pair.from).flag}</span>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800">{pair.from}/{pair.to}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{getCurrencyName(pair.from)} → {getCurrencyName(pair.to)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-bold text-lg text-primary-600">
|
||||
{getCurrencyInfo(pair.to).symbol}{formatNumber(getExchangeRate(pair.from, pair.to), pair.to === 'KRW' || pair.to === 'MNT' ? 0 : 4)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
1 {pair.from}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* All Exchange Rates Table */}
|
||||
<div className="bg-white rounded-xl shadow-lg overflow-hidden">
|
||||
<div className="p-4 bg-gray-50 border-b flex items-center justify-between">
|
||||
<h2 className="font-semibold text-gray-800">
|
||||
{language === 'ko' ? '모든 통화 환율 (기준: 원화)' : 'All Currency Rates (Base: KRW)'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={fetchRates}
|
||||
className="text-sm text-primary-600 hover:text-primary-700 flex items-center gap-1"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
{language === 'ko' ? '새로고침' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="divide-y">
|
||||
{ALL_CURRENCIES.filter(c => c.code !== 'KRW').map((currency) => {
|
||||
const rate = rates.find(r => r.currency_code === currency.code);
|
||||
const inverseRate = rate?.adjusted_rate || (1 / (allRates[currency.code] || 1));
|
||||
const weightPercent = rate?.weight_percent || 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={currency.code}
|
||||
className="p-4 hover:bg-gray-50 transition cursor-pointer"
|
||||
onClick={() => {
|
||||
setFromCurrency('KRW');
|
||||
setToCurrency(currency.code);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-3xl">{currency.flag}</span>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-gray-800">{currency.code}</span>
|
||||
<span className="text-gray-500 text-sm">
|
||||
{getCurrencyName(currency.code)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
1 {currency.code} = ₩{formatNumber(inverseRate, 0)} KRW
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-bold text-lg text-gray-800">
|
||||
₩{formatNumber(inverseRate, 0)}
|
||||
</p>
|
||||
{weightPercent !== 0 && (
|
||||
<p className={`text-xs ${weightPercent > 0 ? 'text-red-500' : 'text-green-500'}`}>
|
||||
{weightPercent > 0 ? '+' : ''}{weightPercent}%
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 bg-gray-50 border-t text-xs text-gray-500 flex items-center justify-between">
|
||||
<div>
|
||||
<span>{language === 'ko' ? '기준: ' : 'Base: '}KRW (한국 원화)</span>
|
||||
{source && (
|
||||
<span className="ml-3 px-2 py-0.5 bg-gray-200 rounded text-gray-600">
|
||||
{source === 'api' ? 'Live' : source === 'cache' ? 'Cached' : 'Fallback'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{lastUpdated && (
|
||||
<span>
|
||||
{language === 'ko' ? '업데이트: ' : 'Updated: '}
|
||||
{formatDateTime(lastUpdated)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Card */}
|
||||
<div className="mt-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-xl">💡</span>
|
||||
<div className="text-sm text-blue-700">
|
||||
<p className="font-medium mb-1">
|
||||
{language === 'ko' ? '환율 안내' : 'Exchange Rate Info'}
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>
|
||||
{language === 'ko'
|
||||
? '환율은 실시간으로 변동되며, 실제 거래 시 환율과 다를 수 있습니다'
|
||||
: 'Exchange rates fluctuate in real-time and may differ from actual transaction rates'}
|
||||
</li>
|
||||
<li>
|
||||
{language === 'ko'
|
||||
? '차량 가격은 결제 시점의 환율이 적용됩니다'
|
||||
: 'Vehicle prices will be calculated using the exchange rate at the time of payment'}
|
||||
</li>
|
||||
<li>
|
||||
{language === 'ko'
|
||||
? '"나의 환율 쌍"은 브라우저에 저장됩니다'
|
||||
: 'Custom currency pairs are saved in your browser'}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user