page.tsx 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. 'use client';
  2. import { useState, useEffect, useMemo } from 'react';
  3. import { fetchApi } from '@/lib/utils/client';
  4. import useAuth from '@/hooks/useAuth';
  5. import { useConfigContext } from '@/contexts/configProvider';
  6. import { DanalCreateOrderRequest, PaymentMethodType } from '@/types/request/payment/charge';
  7. import { DanalCreateOrderResponse } from '@/types/response/payment/charge';
  8. import type { PaymentForm } from '@/types/config';
  9. import CreditCardIcon from '@/public/icons/payment/credit-card.svg';
  10. import KakaoIcon from '@/public/icons/payment/kakao.svg';
  11. import NaverIcon from '@/public/icons/payment/naver.svg';
  12. import BankIcon from '@/public/icons/payment/bank.svg';
  13. import PhoneIcon from '@/public/icons/payment/phone.svg';
  14. import { Landmark as VBankIcon } from 'lucide-react';
  15. type PaymentMethodOption = {
  16. value: PaymentMethodType;
  17. label: string;
  18. danalMethod: string;
  19. danalKey: string;
  20. Icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
  21. enabledKey: keyof PaymentForm;
  22. };
  23. const PAYMENT_METHODS: PaymentMethodOption[] = [
  24. { value: 'Card', label: '신용카드', danalMethod: 'CARD', danalKey: 'card', Icon: CreditCardIcon, enabledKey: 'isCardEnabled' },
  25. { value: 'KakaoPay', label: '카카오페이', danalMethod: 'KAKAOPAY', danalKey: 'kakaoPay', Icon: KakaoIcon, enabledKey: 'isKakaoPayEnabled' },
  26. { value: 'NaverPay', label: '네이버페이', danalMethod: 'NAVERPAY', danalKey: 'naverPay', Icon: NaverIcon, enabledKey: 'isNaverPayEnabled' },
  27. { value: 'Transfer', label: '계좌이체', danalMethod: 'TRANSFER', danalKey: 'transfer', Icon: BankIcon, enabledKey: 'isTransferEnabled' },
  28. { value: 'Mobile', label: '휴대폰', danalMethod: 'MOBILE', danalKey: 'mobile', Icon: PhoneIcon, enabledKey: 'isMobileEnabled' },
  29. { value: 'VirtualAccount', label: '가상계좌', danalMethod: 'VIRTUAL_ACCOUNT', danalKey: 'virtualAccount', Icon: VBankIcon, enabledKey: 'isVirtualAccountEnabled' },
  30. ];
  31. // 결제수단별 다날 SDK methods 설정
  32. const METHOD_CONFIGS: Record<string, object> = {
  33. card: {},
  34. kakaoPay: {},
  35. naverPay: {},
  36. payco: {},
  37. transfer: {},
  38. mobile: { itemCode: '1270000000', itemType: '1' },
  39. virtualAccount: { notiUrl: `${process.env.NEXT_PUBLIC_API_URL}/api/payment/noti/vaccount` }
  40. };
  41. const QUICK_AMOUNTS = [10000, 50000, 100000, 1000000];
  42. export default function ChargePage()
  43. {
  44. // pointInput = 사용자가 충전하려는 포인트 (VAT 제외). VAT 10%는 별도 가산.
  45. const [pointInput, setPointInput] = useState<number>(0);
  46. const [paymentMethod, setPaymentMethod] = useState<PaymentMethodType>('Card');
  47. const [loading, setLoading] = useState(false);
  48. const [isPopup, setIsPopup] = useState(false);
  49. const { member, loginCheck } = useAuth();
  50. const config = useConfigContext();
  51. const isDanalTestMode = config?.external?.danalPayMode === 'test';
  52. // 결제 수단별 활성 여부 매핑
  53. const methodEnabled = useMemo<Record<PaymentMethodType, boolean>>(() => {
  54. const payment = config?.payment;
  55. const map = {} as Record<PaymentMethodType, boolean>;
  56. for (const m of PAYMENT_METHODS) {
  57. map[m.value] = payment ? Boolean(payment[m.enabledKey]) : false;
  58. }
  59. return map;
  60. }, [config?.payment]);
  61. useEffect(() => {
  62. setIsPopup(!!window.opener);
  63. }, []);
  64. // 초기 선택된 결제 수단이 비활성이면 첫 번째 활성 수단으로 자동 전환
  65. useEffect(() => {
  66. if (!methodEnabled[paymentMethod]) {
  67. const firstEnabled = PAYMENT_METHODS.find(m => methodEnabled[m.value]);
  68. if (firstEnabled) {
  69. setPaymentMethod(firstEnabled.value);
  70. }
  71. }
  72. }, [methodEnabled, paymentMethod]);
  73. if (!member || !loginCheck()) {
  74. return null;
  75. }
  76. const handleAmountChange = (value: string) => {
  77. const num = parseInt(value.replace(/[^0-9]/g, ''), 10);
  78. setPointInput(isNaN(num) ? 0 : num);
  79. };
  80. const addAmount = (add: number) => {
  81. setPointInput(prev => prev + add);
  82. };
  83. // VAT 10% 별도 가산. 최종 결제 금액 = pointInput + vat.
  84. const vatAmount = Math.round(pointInput * 0.1);
  85. const totalAmount = pointInput + vatAmount;
  86. const handleSubmit = async () => {
  87. if (pointInput < 1000) {
  88. alert('최소 충전 금액은 1,000원입니다.');
  89. return;
  90. }
  91. setLoading(true);
  92. try {
  93. const res = await fetchApi<DanalCreateOrderResponse>('/api/payment/order', {
  94. method: 'POST',
  95. body: {
  96. amount: pointInput,
  97. paymentMethod
  98. } as DanalCreateOrderRequest
  99. });
  100. if (!res.data) {
  101. alert(res.message || '주문 생성에 실패했습니다.');
  102. return setLoading(false);
  103. }
  104. const order = res.data;
  105. const selected = PAYMENT_METHODS.find(m => m.value === paymentMethod)!;
  106. const { loadDanalPaymentsSDK } = await import('@danalpay/javascript-sdk');
  107. const danalPayments = await loadDanalPaymentsSDK({ clientKey: order.clientKey });
  108. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  109. const paymentParams: any = {
  110. paymentsMethod: selected.danalMethod,
  111. methods: {
  112. [selected.danalKey]: METHOD_CONFIGS[selected.danalKey]
  113. },
  114. orderName: `개미투자 ${pointInput.toLocaleString()}(P) 충전`,
  115. amount: order.amount,
  116. merchantId: order.merchantID,
  117. orderId: order.orderID,
  118. userId: member.id,
  119. userEmail: member.email,
  120. userName: member.name,
  121. successUrl: order.successUrl,
  122. failUrl: order.failUrl
  123. };
  124. danalPayments.requestPayment(paymentParams);
  125. } catch (e) {
  126. console.error('결제 요청 실패:', e);
  127. alert('결제 요청에 실패했습니다.');
  128. setLoading(false);
  129. }
  130. };
  131. return (
  132. <div className="charge-page">
  133. {isDanalTestMode && (
  134. <div className="charge-page__test-banner" role="alert">
  135. 현재 다날 충전은 테스트 환경입니다.
  136. </div>
  137. )}
  138. <div className="charge-page__card">
  139. <div className="charge-page__header">
  140. <h1>포인트 충전</h1>
  141. {isPopup && (
  142. <button type="button" className="charge-page__close" onClick={() => window.close()}>×</button>
  143. )}
  144. </div>
  145. <div className="charge-page__body">
  146. <label className="charge-page__label">충전 포인트 (P)</label>
  147. <input
  148. type="text"
  149. className="charge-page__amount-input"
  150. value={pointInput > 0 ? pointInput.toLocaleString() : ''}
  151. onChange={e => handleAmountChange(e.target.value)}
  152. placeholder="0"
  153. inputMode="numeric"
  154. autoFocus
  155. />
  156. <div className="charge-page__quick-btns">
  157. {QUICK_AMOUNTS.map(v => (
  158. <button type="button" key={v} className="charge-page__quick-btn" onClick={() => addAmount(v)}>
  159. +{v >= 10000 ? `${v / 10000}만` : v.toLocaleString()}
  160. </button>
  161. ))}
  162. </div>
  163. <div className="charge-page__method-section">
  164. <label className="charge-page__label">결제 수단</label>
  165. <div className="charge-page__method-grid">
  166. {PAYMENT_METHODS.map(m => {
  167. const enabled = methodEnabled[m.value];
  168. const isActive = enabled && paymentMethod === m.value;
  169. const cls = [
  170. 'charge-page__method-btn',
  171. isActive ? 'charge-page__method-btn--active' : '',
  172. !enabled ? 'charge-page__method-btn--disabled' : ''
  173. ].filter(Boolean).join(' ');
  174. return (
  175. <button
  176. type="button"
  177. key={m.value}
  178. className={cls}
  179. onClick={() => enabled && setPaymentMethod(m.value)}
  180. disabled={!enabled}
  181. aria-disabled={!enabled}
  182. title={!enabled ? `${m.label} 결제는 현재 사용할 수 없습니다.` : undefined}
  183. >
  184. <span className="charge-page__method-icon">
  185. <m.Icon width={24} height={24} aria-label={m.label} />
  186. </span>
  187. <span>{m.label}</span>
  188. </button>
  189. );
  190. })}
  191. </div>
  192. </div>
  193. <div className="charge-page__receipt">
  194. <div className="charge-page__receipt-row">
  195. <span>충전 (P)</span>
  196. <span>{pointInput.toLocaleString()} P</span>
  197. </div>
  198. <div className="charge-page__receipt-row">
  199. <span>부가세 (10%)</span>
  200. <span>{vatAmount.toLocaleString()} 원</span>
  201. </div>
  202. <div className="charge-page__receipt-row charge-page__receipt-row--total">
  203. <span>총 결제 금액</span>
  204. <span>{totalAmount.toLocaleString()} 원</span>
  205. </div>
  206. </div>
  207. <button type="button" className="charge-page__submit" onClick={handleSubmit} disabled={loading || pointInput < 1000}>
  208. {loading ? '처리 중...' : '충전하기'}
  209. </button>
  210. </div>
  211. </div>
  212. </div>
  213. );
  214. }