|
|
@@ -1,84 +1,48 @@
|
|
|
'use client';
|
|
|
|
|
|
-import { useState, useEffect, useMemo } from 'react';
|
|
|
+import './style.scss';
|
|
|
+import { useState, useEffect, useRef, useCallback } from 'react';
|
|
|
+import { loadTossPayments } from '@tosspayments/tosspayments-sdk';
|
|
|
+import type { TossPaymentsWidgets } from '@tosspayments/tosspayments-sdk';
|
|
|
import { fetchApi } from '@/lib/utils/client';
|
|
|
import useAuth from '@/hooks/useAuth';
|
|
|
import { useConfigContext } from '@/contexts/configProvider';
|
|
|
-import { DanalCreateOrderRequest, PaymentMethodType } from '@/types/request/payment/charge';
|
|
|
-import { DanalCreateOrderResponse } from '@/types/response/payment/charge';
|
|
|
-import type { PaymentForm } from '@/types/config';
|
|
|
-import CreditCardIcon from '@/public/icons/payment/credit-card.svg';
|
|
|
-import KakaoIcon from '@/public/icons/payment/kakao.svg';
|
|
|
-import NaverIcon from '@/public/icons/payment/naver.svg';
|
|
|
-import BankIcon from '@/public/icons/payment/bank.svg';
|
|
|
-import PhoneIcon from '@/public/icons/payment/phone.svg';
|
|
|
-import { Landmark as VBankIcon } from 'lucide-react';
|
|
|
-
|
|
|
-type PaymentMethodOption = {
|
|
|
- value: PaymentMethodType;
|
|
|
- label: string;
|
|
|
- danalMethod: string;
|
|
|
- danalKey: string;
|
|
|
- Icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
|
|
- enabledKey: keyof PaymentForm;
|
|
|
-};
|
|
|
-
|
|
|
-const PAYMENT_METHODS: PaymentMethodOption[] = [
|
|
|
- { value: 'Card', label: '신용카드', danalMethod: 'CARD', danalKey: 'card', Icon: CreditCardIcon, enabledKey: 'isCardEnabled' },
|
|
|
- { value: 'KakaoPay', label: '카카오페이', danalMethod: 'KAKAOPAY', danalKey: 'kakaoPay', Icon: KakaoIcon, enabledKey: 'isKakaoPayEnabled' },
|
|
|
- { value: 'NaverPay', label: '네이버페이', danalMethod: 'NAVERPAY', danalKey: 'naverPay', Icon: NaverIcon, enabledKey: 'isNaverPayEnabled' },
|
|
|
- { value: 'Transfer', label: '계좌이체', danalMethod: 'TRANSFER', danalKey: 'transfer', Icon: BankIcon, enabledKey: 'isTransferEnabled' },
|
|
|
- { value: 'Mobile', label: '휴대폰', danalMethod: 'MOBILE', danalKey: 'mobile', Icon: PhoneIcon, enabledKey: 'isMobileEnabled' },
|
|
|
- { value: 'VirtualAccount', label: '가상계좌', danalMethod: 'VIRTUAL_ACCOUNT', danalKey: 'virtualAccount', Icon: VBankIcon, enabledKey: 'isVirtualAccountEnabled' },
|
|
|
-];
|
|
|
-
|
|
|
-// 결제수단별 다날 SDK methods 설정
|
|
|
-const METHOD_CONFIGS: Record<string, object> = {
|
|
|
- card: {},
|
|
|
- kakaoPay: {},
|
|
|
- naverPay: {},
|
|
|
- payco: {},
|
|
|
- transfer: {},
|
|
|
- mobile: { itemCode: '1270000000', itemType: '1' },
|
|
|
- virtualAccount: { notiUrl: `${process.env.NEXT_PUBLIC_API_URL}/api/payment/noti/vaccount` }
|
|
|
-};
|
|
|
+import { TossCreateOrderRequest } from '@/types/request/payment/charge';
|
|
|
+import { TossCreateOrderResponse } from '@/types/response/payment/charge';
|
|
|
|
|
|
const QUICK_AMOUNTS = [10000, 50000, 100000, 1000000];
|
|
|
|
|
|
export default function ChargePage()
|
|
|
{
|
|
|
- // pointInput = 사용자가 충전하려는 포인트 (VAT 제외). VAT 10%는 별도 가산.
|
|
|
- const [pointInput, setPointInput] = useState<number>(0);
|
|
|
- const [paymentMethod, setPaymentMethod] = useState<PaymentMethodType>('Card');
|
|
|
+ // cashInput = 사용자가 충전하려는 캐시 (VAT 제외). VAT 10%는 서버에서 별도 가산.
|
|
|
+ const [cashInput, setCashInput] = useState<number>(0);
|
|
|
+ const [order, setOrder] = useState<TossCreateOrderResponse|null>(null);
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
+ const [paying, setPaying] = useState(false);
|
|
|
const [isPopup, setIsPopup] = useState(false);
|
|
|
+ const [error, setError] = useState<string|null>(null);
|
|
|
const { member, loginCheck } = useAuth();
|
|
|
const config = useConfigContext();
|
|
|
- const isDanalTestMode = config?.external?.danalPayMode === 'test';
|
|
|
-
|
|
|
- // 결제 수단별 활성 여부 매핑
|
|
|
- const methodEnabled = useMemo<Record<PaymentMethodType, boolean>>(() => {
|
|
|
- const payment = config?.payment;
|
|
|
- const map = {} as Record<PaymentMethodType, boolean>;
|
|
|
- for (const m of PAYMENT_METHODS) {
|
|
|
- map[m.value] = payment ? Boolean(payment[m.enabledKey]) : false;
|
|
|
- }
|
|
|
- return map;
|
|
|
- }, [config?.payment]);
|
|
|
+ const isTossTestMode = config?.toss?.tossPayMode === 'test';
|
|
|
+
|
|
|
+ const widgetsRef = useRef<TossPaymentsWidgets|null>(null);
|
|
|
|
|
|
useEffect(() => {
|
|
|
setIsPopup(!!window.opener);
|
|
|
}, []);
|
|
|
|
|
|
- // 초기 선택된 결제 수단이 비활성이면 첫 번째 활성 수단으로 자동 전환
|
|
|
- useEffect(() => {
|
|
|
- if (!methodEnabled[paymentMethod]) {
|
|
|
- const firstEnabled = PAYMENT_METHODS.find(m => methodEnabled[m.value]);
|
|
|
- if (firstEnabled) {
|
|
|
- setPaymentMethod(firstEnabled.value);
|
|
|
- }
|
|
|
- }
|
|
|
- }, [methodEnabled, paymentMethod]);
|
|
|
+ // 주문 생성 후 토스 위젯 초기화 — clientKey/customerKey 로 widgets 생성, 금액 설정, UI 렌더링
|
|
|
+ const initWidgets = useCallback(async (created: TossCreateOrderResponse) => {
|
|
|
+ const tossPayments = await loadTossPayments(created.clientKey);
|
|
|
+ const widgets = tossPayments.widgets({ customerKey: created.customerKey });
|
|
|
+ widgetsRef.current = widgets;
|
|
|
+
|
|
|
+ await widgets.setAmount({ value: created.amount, currency: 'KRW' });
|
|
|
+ await Promise.all([
|
|
|
+ widgets.renderPaymentMethods({ selector: '#toss-payment-methods' }),
|
|
|
+ widgets.renderAgreement({ selector: '#toss-agreement' })
|
|
|
+ ]);
|
|
|
+ }, []);
|
|
|
|
|
|
if (!member || !loginCheck()) {
|
|
|
return null;
|
|
|
@@ -86,157 +50,178 @@ export default function ChargePage()
|
|
|
|
|
|
const handleAmountChange = (value: string) => {
|
|
|
const num = parseInt(value.replace(/[^0-9]/g, ''), 10);
|
|
|
- setPointInput(isNaN(num) ? 0 : num);
|
|
|
+ setCashInput(isNaN(num) ? 0 : num);
|
|
|
};
|
|
|
|
|
|
const addAmount = (add: number) => {
|
|
|
- setPointInput(prev => prev + add);
|
|
|
+ setCashInput(prev => prev + add);
|
|
|
};
|
|
|
|
|
|
- // VAT 10% 별도 가산. 최종 결제 금액 = pointInput + vat.
|
|
|
- const vatAmount = Math.round(pointInput * 0.1);
|
|
|
- const totalAmount = pointInput + vatAmount;
|
|
|
+ // VAT 10% 별도 가산 (표시용). 최종 결제 금액은 서버 주문(order.amount)이 확정한다.
|
|
|
+ const vatAmount = Math.round(cashInput * 0.1);
|
|
|
+ const totalAmount = cashInput + vatAmount;
|
|
|
|
|
|
- const handleSubmit = async () => {
|
|
|
- if (pointInput < 1000) {
|
|
|
+ // 1단계: 서버에 주문 생성 → 토스 위젯 초기화
|
|
|
+ const handleCreateOrder = async () => {
|
|
|
+ if (cashInput < 1000) {
|
|
|
alert('최소 충전 금액은 1,000원입니다.');
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
setLoading(true);
|
|
|
+ setError(null);
|
|
|
|
|
|
try {
|
|
|
- const res = await fetchApi<DanalCreateOrderResponse>('/api/payment/order', {
|
|
|
+ const res = await fetchApi<TossCreateOrderResponse>('/api/payments/orders', {
|
|
|
method: 'POST',
|
|
|
- body: {
|
|
|
- amount: pointInput,
|
|
|
- paymentMethod
|
|
|
- } as DanalCreateOrderRequest
|
|
|
+ body: { amount: cashInput } as TossCreateOrderRequest
|
|
|
});
|
|
|
|
|
|
- if (!res.data) {
|
|
|
- alert(res.message || '주문 생성에 실패했습니다.');
|
|
|
- return setLoading(false);
|
|
|
+ if (!res.success || !res.data) {
|
|
|
+ setError(res.message || '주문 생성에 실패했습니다.');
|
|
|
+ setLoading(false);
|
|
|
+ return;
|
|
|
}
|
|
|
|
|
|
- const order = res.data;
|
|
|
- const selected = PAYMENT_METHODS.find(m => m.value === paymentMethod)!;
|
|
|
-
|
|
|
- const { loadDanalPaymentsSDK } = await import('@danalpay/javascript-sdk');
|
|
|
- const danalPayments = await loadDanalPaymentsSDK({ clientKey: order.clientKey });
|
|
|
-
|
|
|
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
|
- const paymentParams: any = {
|
|
|
- paymentsMethod: selected.danalMethod,
|
|
|
- methods: {
|
|
|
- [selected.danalKey]: METHOD_CONFIGS[selected.danalKey]
|
|
|
- },
|
|
|
- orderName: `개미투자 ${pointInput.toLocaleString()}(P) 충전`,
|
|
|
- amount: order.amount,
|
|
|
- merchantId: order.merchantID,
|
|
|
+ const created = res.data;
|
|
|
+ setOrder(created);
|
|
|
+ await initWidgets(created);
|
|
|
+ } catch (e) {
|
|
|
+ console.error('주문 생성/위젯 초기화 실패:', e);
|
|
|
+ setError('결제 준비 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.');
|
|
|
+ } finally {
|
|
|
+ setLoading(false);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // 2단계: 결제 요청 (redirect 방식) — successUrl/failUrl 로 이동
|
|
|
+ const handleRequestPayment = async () => {
|
|
|
+ const widgets = widgetsRef.current;
|
|
|
+ if (!widgets || !order) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ setPaying(true);
|
|
|
+ setError(null);
|
|
|
+
|
|
|
+ try {
|
|
|
+ const origin = window.location.origin;
|
|
|
+ await widgets.requestPayment({
|
|
|
orderId: order.orderID,
|
|
|
- userId: member.id,
|
|
|
- userEmail: member.email,
|
|
|
- userName: member.name,
|
|
|
- successUrl: order.successUrl,
|
|
|
- failUrl: order.failUrl
|
|
|
- };
|
|
|
-
|
|
|
- danalPayments.requestPayment(paymentParams);
|
|
|
+ orderName: order.orderName,
|
|
|
+ successUrl: `${origin}/charge/success`,
|
|
|
+ failUrl: `${origin}/charge/fail`,
|
|
|
+ customerEmail: member.email ?? undefined,
|
|
|
+ customerName: member.name ?? undefined
|
|
|
+ });
|
|
|
} catch (e) {
|
|
|
+ // 사용자가 결제창을 닫거나 요청이 실패한 경우 — 페이지에 머물러 재시도 가능
|
|
|
console.error('결제 요청 실패:', e);
|
|
|
- alert('결제 요청에 실패했습니다.');
|
|
|
- setLoading(false);
|
|
|
+ const message = e instanceof Error ? e.message : '결제 요청에 실패했습니다.';
|
|
|
+ setError(message);
|
|
|
+ setPaying(false);
|
|
|
}
|
|
|
};
|
|
|
|
|
|
+ // 금액 재선택 — 위젯을 닫고 1단계로 복귀 (금액이 바뀌면 새 주문이 필요)
|
|
|
+ const handleReset = () => {
|
|
|
+ widgetsRef.current = null;
|
|
|
+ setOrder(null);
|
|
|
+ setError(null);
|
|
|
+ };
|
|
|
+
|
|
|
return (
|
|
|
<div className="charge-page">
|
|
|
- {isDanalTestMode && (
|
|
|
+ {isTossTestMode && (
|
|
|
<div className="charge-page__test-banner" role="alert">
|
|
|
- 현재 다날 충전은 테스트 환경입니다.
|
|
|
+ 현재 토스 충전은 테스트 환경입니다.
|
|
|
</div>
|
|
|
)}
|
|
|
<div className="charge-page__card">
|
|
|
<div className="charge-page__header">
|
|
|
- <h1>포인트 충전</h1>
|
|
|
+ <h1>캐시 충전</h1>
|
|
|
{isPopup && (
|
|
|
<button type="button" className="charge-page__close" onClick={() => window.close()}>×</button>
|
|
|
)}
|
|
|
</div>
|
|
|
|
|
|
<div className="charge-page__body">
|
|
|
- <label className="charge-page__label">충전 포인트 (P)</label>
|
|
|
- <input
|
|
|
- type="text"
|
|
|
- className="charge-page__amount-input"
|
|
|
- value={pointInput > 0 ? pointInput.toLocaleString() : ''}
|
|
|
- onChange={e => handleAmountChange(e.target.value)}
|
|
|
- placeholder="0"
|
|
|
- inputMode="numeric"
|
|
|
- autoFocus
|
|
|
- />
|
|
|
-
|
|
|
- <div className="charge-page__quick-btns">
|
|
|
- {QUICK_AMOUNTS.map(v => (
|
|
|
- <button type="button" key={v} className="charge-page__quick-btn" onClick={() => addAmount(v)}>
|
|
|
- +{v >= 10000 ? `${v / 10000}만` : v.toLocaleString()}
|
|
|
- </button>
|
|
|
- ))}
|
|
|
- </div>
|
|
|
-
|
|
|
- <div className="charge-page__method-section">
|
|
|
- <label className="charge-page__label">결제 수단</label>
|
|
|
- <div className="charge-page__method-grid">
|
|
|
- {PAYMENT_METHODS.map(m => {
|
|
|
- const enabled = methodEnabled[m.value];
|
|
|
- const isActive = enabled && paymentMethod === m.value;
|
|
|
- const cls = [
|
|
|
- 'charge-page__method-btn',
|
|
|
- isActive ? 'charge-page__method-btn--active' : '',
|
|
|
- !enabled ? 'charge-page__method-btn--disabled' : ''
|
|
|
- ].filter(Boolean).join(' ');
|
|
|
-
|
|
|
- return (
|
|
|
- <button
|
|
|
- type="button"
|
|
|
- key={m.value}
|
|
|
- className={cls}
|
|
|
- onClick={() => enabled && setPaymentMethod(m.value)}
|
|
|
- disabled={!enabled}
|
|
|
- aria-disabled={!enabled}
|
|
|
- title={!enabled ? `${m.label} 결제는 현재 사용할 수 없습니다.` : undefined}
|
|
|
- >
|
|
|
- <span className="charge-page__method-icon">
|
|
|
- <m.Icon width={24} height={24} aria-label={m.label} />
|
|
|
- </span>
|
|
|
- <span>{m.label}</span>
|
|
|
+ {!order && (
|
|
|
+ <>
|
|
|
+ <label className="charge-page__label">충전 캐시 (원)</label>
|
|
|
+ <input
|
|
|
+ type="text"
|
|
|
+ className="charge-page__amount-input"
|
|
|
+ value={cashInput > 0 ? cashInput.toLocaleString() : ''}
|
|
|
+ onChange={e => handleAmountChange(e.target.value)}
|
|
|
+ placeholder="0"
|
|
|
+ inputMode="numeric"
|
|
|
+ autoFocus
|
|
|
+ />
|
|
|
+
|
|
|
+ <div className="charge-page__quick-btns">
|
|
|
+ {QUICK_AMOUNTS.map(v => (
|
|
|
+ <button type="button" key={v} className="charge-page__quick-btn" onClick={() => addAmount(v)}>
|
|
|
+ +{v >= 10000 ? `${v / 10000}만` : v.toLocaleString()}
|
|
|
</button>
|
|
|
- );
|
|
|
- })}
|
|
|
- </div>
|
|
|
- </div>
|
|
|
-
|
|
|
- <div className="charge-page__receipt">
|
|
|
- <div className="charge-page__receipt-row">
|
|
|
- <span>충전 (P)</span>
|
|
|
- <span>{pointInput.toLocaleString()} P</span>
|
|
|
- </div>
|
|
|
- <div className="charge-page__receipt-row">
|
|
|
- <span>부가세 (10%)</span>
|
|
|
- <span>{vatAmount.toLocaleString()} 원</span>
|
|
|
- </div>
|
|
|
- <div className="charge-page__receipt-row charge-page__receipt-row--total">
|
|
|
- <span>총 결제 금액</span>
|
|
|
- <span>{totalAmount.toLocaleString()} 원</span>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
-
|
|
|
- <button type="button" className="charge-page__submit" onClick={handleSubmit} disabled={loading || pointInput < 1000}>
|
|
|
- {loading ? '처리 중...' : '충전하기'}
|
|
|
- </button>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="charge-page__receipt">
|
|
|
+ <div className="charge-page__receipt-row">
|
|
|
+ <span>충전 캐시</span>
|
|
|
+ <span>{cashInput.toLocaleString()} 원</span>
|
|
|
+ </div>
|
|
|
+ <div className="charge-page__receipt-row">
|
|
|
+ <span>부가세 (10%)</span>
|
|
|
+ <span>{vatAmount.toLocaleString()} 원</span>
|
|
|
+ </div>
|
|
|
+ <div className="charge-page__receipt-row charge-page__receipt-row--total">
|
|
|
+ <span>총 결제 금액</span>
|
|
|
+ <span>{totalAmount.toLocaleString()} 원</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {error && <p className="charge-page__error" role="alert">{error}</p>}
|
|
|
+
|
|
|
+ <button type="button" className="charge-page__submit" onClick={handleCreateOrder} disabled={loading || cashInput < 1000}>
|
|
|
+ {loading ? '결제 준비 중...' : '결제 진행'}
|
|
|
+ </button>
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {order && (
|
|
|
+ <>
|
|
|
+ <div className="charge-page__receipt">
|
|
|
+ <div className="charge-page__receipt-row">
|
|
|
+ <span>충전 캐시</span>
|
|
|
+ <span>{order.pointAmount.toLocaleString()} 원</span>
|
|
|
+ </div>
|
|
|
+ <div className="charge-page__receipt-row">
|
|
|
+ <span>부가세 (10%)</span>
|
|
|
+ <span>{order.vatAmount.toLocaleString()} 원</span>
|
|
|
+ </div>
|
|
|
+ <div className="charge-page__receipt-row charge-page__receipt-row--total">
|
|
|
+ <span>총 결제 금액</span>
|
|
|
+ <span>{order.amount.toLocaleString()} 원</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div id="toss-payment-methods" className="charge-page__toss-methods" />
|
|
|
+ <div id="toss-agreement" className="charge-page__toss-agreement" />
|
|
|
+
|
|
|
+ {error && <p className="charge-page__error" role="alert">{error}</p>}
|
|
|
+
|
|
|
+ <button type="button" className="charge-page__submit" onClick={handleRequestPayment} disabled={paying}>
|
|
|
+ {paying ? '결제 요청 중...' : `${order.amount.toLocaleString()}원 결제하기`}
|
|
|
+ </button>
|
|
|
+ <button type="button" className="charge-page__reset" onClick={handleReset} disabled={paying}>
|
|
|
+ 금액 다시 선택
|
|
|
+ </button>
|
|
|
+ </>
|
|
|
+ )}
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
);
|
|
|
-}
|
|
|
+}
|