238 lines
8.1 KiB
TypeScript
238 lines
8.1 KiB
TypeScript
|
|
'use client';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 환율 위젯 컴포넌트
|
||
|
|
* - 실시간 환율 정보를 표시
|
||
|
|
* - 한국은행(BOK) API 연동
|
||
|
|
*/
|
||
|
|
|
||
|
|
import React, { useEffect, useState } from 'react';
|
||
|
|
import { getExchangeRate, ExchangeRateData } from '@/lib/api/openApi';
|
||
|
|
import { TrendingUp, TrendingDown, RefreshCw, ArrowRightLeft } from 'lucide-react';
|
||
|
|
import { Button } from '@/components/ui/button';
|
||
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||
|
|
|
||
|
|
interface ExchangeWidgetProps {
|
||
|
|
baseCurrency?: string;
|
||
|
|
targetCurrency?: string;
|
||
|
|
refreshInterval?: number; // 새로고침 간격 (ms), 기본값: 600000 (10분)
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function ExchangeWidget({
|
||
|
|
baseCurrency = 'KRW',
|
||
|
|
targetCurrency = 'USD',
|
||
|
|
refreshInterval = 600000,
|
||
|
|
}: ExchangeWidgetProps) {
|
||
|
|
const [base, setBase] = useState(baseCurrency);
|
||
|
|
const [target, setTarget] = useState(targetCurrency);
|
||
|
|
const [exchangeRate, setExchangeRate] = useState<ExchangeRateData | null>(null);
|
||
|
|
const [loading, setLoading] = useState(true);
|
||
|
|
const [error, setError] = useState<string | null>(null);
|
||
|
|
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||
|
|
|
||
|
|
// 지원 통화 목록
|
||
|
|
const currencies = [
|
||
|
|
{ value: 'KRW', label: '🇰🇷 KRW (원)', symbol: '₩' },
|
||
|
|
{ value: 'USD', label: '🇺🇸 USD (달러)', symbol: '$' },
|
||
|
|
{ value: 'EUR', label: '🇪🇺 EUR (유로)', symbol: '€' },
|
||
|
|
{ value: 'JPY', label: '🇯🇵 JPY (엔)', symbol: '¥' },
|
||
|
|
{ value: 'CNY', label: '🇨🇳 CNY (위안)', symbol: '¥' },
|
||
|
|
{ value: 'GBP', label: '🇬🇧 GBP (파운드)', symbol: '£' },
|
||
|
|
];
|
||
|
|
|
||
|
|
// 환율 조회
|
||
|
|
const fetchExchangeRate = async () => {
|
||
|
|
try {
|
||
|
|
setError(null);
|
||
|
|
setLoading(true);
|
||
|
|
|
||
|
|
const data = await getExchangeRate(base, target);
|
||
|
|
setExchangeRate(data);
|
||
|
|
setLastUpdated(new Date());
|
||
|
|
} catch (err: any) {
|
||
|
|
console.error('환율 조회 실패:', err);
|
||
|
|
|
||
|
|
let errorMessage = '환율 정보를 가져오는 중 오류가 발생했습니다.';
|
||
|
|
|
||
|
|
if (err.response?.status === 503) {
|
||
|
|
errorMessage = 'API 키가 설정되지 않았습니다. 관리자에게 문의하세요.';
|
||
|
|
} else if (err.response?.status === 401) {
|
||
|
|
errorMessage = 'API 키가 유효하지 않습니다.';
|
||
|
|
} else if (err.response?.data?.message) {
|
||
|
|
errorMessage = err.response.data.message;
|
||
|
|
}
|
||
|
|
|
||
|
|
setError(errorMessage);
|
||
|
|
} finally {
|
||
|
|
setLoading(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// 초기 로딩 및 자동 새로고침
|
||
|
|
useEffect(() => {
|
||
|
|
fetchExchangeRate();
|
||
|
|
const interval = setInterval(fetchExchangeRate, refreshInterval);
|
||
|
|
return () => clearInterval(interval);
|
||
|
|
}, [base, target, refreshInterval]);
|
||
|
|
|
||
|
|
// 통화 스왑
|
||
|
|
const handleSwap = () => {
|
||
|
|
setBase(target);
|
||
|
|
setTarget(base);
|
||
|
|
};
|
||
|
|
|
||
|
|
// 통화 기호 가져오기
|
||
|
|
const getCurrencySymbol = (currency: string) => {
|
||
|
|
return currencies.find((c) => c.value === currency)?.symbol || currency;
|
||
|
|
};
|
||
|
|
|
||
|
|
// 로딩 상태
|
||
|
|
if (loading && !exchangeRate) {
|
||
|
|
return (
|
||
|
|
<div className="flex h-full items-center justify-center bg-gradient-to-br from-green-50 to-emerald-50 rounded-lg border p-6">
|
||
|
|
<div className="flex flex-col items-center gap-2">
|
||
|
|
<RefreshCw className="h-8 w-8 animate-spin text-green-500" />
|
||
|
|
<p className="text-sm text-gray-600">환율 정보 불러오는 중...</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 에러 상태
|
||
|
|
if (error || !exchangeRate) {
|
||
|
|
return (
|
||
|
|
<div className="flex h-full flex-col items-center justify-center bg-gradient-to-br from-red-50 to-orange-50 rounded-lg border p-6">
|
||
|
|
<TrendingDown className="h-12 w-12 text-gray-400 mb-2" />
|
||
|
|
<p className="text-sm text-gray-600 text-center mb-3">{error || '환율 정보를 불러올 수 없습니다.'}</p>
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
size="sm"
|
||
|
|
onClick={fetchExchangeRate}
|
||
|
|
className="gap-1"
|
||
|
|
>
|
||
|
|
<RefreshCw className="h-3 w-3" />
|
||
|
|
다시 시도
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="h-full bg-gradient-to-br from-green-50 to-emerald-50 rounded-lg border p-6">
|
||
|
|
{/* 헤더 */}
|
||
|
|
<div className="flex items-center justify-between mb-4">
|
||
|
|
<div className="flex-1">
|
||
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-1">💱 환율</h3>
|
||
|
|
<p className="text-xs text-gray-500">
|
||
|
|
{lastUpdated
|
||
|
|
? `업데이트: ${lastUpdated.toLocaleTimeString('ko-KR', {
|
||
|
|
hour: '2-digit',
|
||
|
|
minute: '2-digit',
|
||
|
|
})}`
|
||
|
|
: ''}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
size="sm"
|
||
|
|
onClick={fetchExchangeRate}
|
||
|
|
disabled={loading}
|
||
|
|
className="h-8 w-8 p-0"
|
||
|
|
>
|
||
|
|
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 통화 선택 */}
|
||
|
|
<div className="flex items-center gap-2 mb-6">
|
||
|
|
<Select value={base} onValueChange={setBase}>
|
||
|
|
<SelectTrigger className="flex-1 bg-white">
|
||
|
|
<SelectValue />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
{currencies.map((currency) => (
|
||
|
|
<SelectItem key={currency.value} value={currency.value}>
|
||
|
|
{currency.label}
|
||
|
|
</SelectItem>
|
||
|
|
))}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
size="sm"
|
||
|
|
onClick={handleSwap}
|
||
|
|
className="h-10 w-10 p-0 rounded-full hover:bg-white"
|
||
|
|
>
|
||
|
|
<ArrowRightLeft className="h-4 w-4" />
|
||
|
|
</Button>
|
||
|
|
|
||
|
|
<Select value={target} onValueChange={setTarget}>
|
||
|
|
<SelectTrigger className="flex-1 bg-white">
|
||
|
|
<SelectValue />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
{currencies.map((currency) => (
|
||
|
|
<SelectItem key={currency.value} value={currency.value}>
|
||
|
|
{currency.label}
|
||
|
|
</SelectItem>
|
||
|
|
))}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 환율 표시 */}
|
||
|
|
<div className="bg-white rounded-lg border p-4 mb-4">
|
||
|
|
<div className="text-center">
|
||
|
|
<div className="text-sm text-gray-600 mb-2">
|
||
|
|
{exchangeRate.base === 'KRW' ? '1,000' : '1'} {getCurrencySymbol(exchangeRate.base)} =
|
||
|
|
</div>
|
||
|
|
<div className="text-3xl font-bold text-gray-900 mb-1">
|
||
|
|
{exchangeRate.base === 'KRW'
|
||
|
|
? (exchangeRate.rate * 1000).toLocaleString('ko-KR', {
|
||
|
|
minimumFractionDigits: 2,
|
||
|
|
maximumFractionDigits: 2,
|
||
|
|
})
|
||
|
|
: exchangeRate.rate.toLocaleString('ko-KR', {
|
||
|
|
minimumFractionDigits: 2,
|
||
|
|
maximumFractionDigits: 4,
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
<div className="text-sm text-gray-600">{getCurrencySymbol(exchangeRate.target)}</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 계산 예시 */}
|
||
|
|
<div className="grid grid-cols-2 gap-3">
|
||
|
|
<div className="bg-white rounded-lg border p-3">
|
||
|
|
<div className="text-xs text-gray-500 mb-1">10,000 {base}</div>
|
||
|
|
<div className="text-lg font-semibold text-gray-900">
|
||
|
|
{(10000 * (base === 'KRW' ? exchangeRate.rate : 1 / exchangeRate.rate)).toLocaleString('ko-KR', {
|
||
|
|
minimumFractionDigits: 0,
|
||
|
|
maximumFractionDigits: 2,
|
||
|
|
})}{' '}
|
||
|
|
{target}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="bg-white rounded-lg border p-3">
|
||
|
|
<div className="text-xs text-gray-500 mb-1">100,000 {base}</div>
|
||
|
|
<div className="text-lg font-semibold text-gray-900">
|
||
|
|
{(100000 * (base === 'KRW' ? exchangeRate.rate : 1 / exchangeRate.rate)).toLocaleString('ko-KR', {
|
||
|
|
minimumFractionDigits: 0,
|
||
|
|
maximumFractionDigits: 2,
|
||
|
|
})}{' '}
|
||
|
|
{target}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 데이터 출처 */}
|
||
|
|
<div className="mt-4 pt-3 border-t text-center">
|
||
|
|
<p className="text-xs text-gray-400">출처: {exchangeRate.source}</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|