page.tsx 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  1. 'use client';
  2. import './style.scss';
  3. import { useState, useEffect, useRef, useCallback } from 'react';
  4. import { loadTossPayments } from '@tosspayments/tosspayments-sdk';
  5. import type { TossPaymentsWidgets } from '@tosspayments/tosspayments-sdk';
  6. import { fetchApi } from '@/lib/utils/client';
  7. import useAuth from '@/hooks/useAuth';
  8. import { useConfigContext } from '@/contexts/configProvider';
  9. import { TossCreateOrderRequest } from '@/types/request/payment/charge';
  10. import { TossCreateOrderResponse } from '@/types/response/payment/charge';
  11. const QUICK_AMOUNTS = [10000, 50000, 100000, 1000000];
  12. export default function ChargePage()
  13. {
  14. // cashInput = 사용자가 충전하려는 캐시 (VAT 제외). VAT 10%는 서버에서 별도 가산.
  15. const [cashInput, setCashInput] = useState<number>(0);
  16. const [order, setOrder] = useState<TossCreateOrderResponse|null>(null);
  17. const [loading, setLoading] = useState(false);
  18. const [paying, setPaying] = useState(false);
  19. const [isPopup, setIsPopup] = useState(false);
  20. const [error, setError] = useState<string|null>(null);
  21. const { member, loginCheck } = useAuth();
  22. const config = useConfigContext();
  23. const isTossTestMode = config?.toss?.tossPayMode === 'test';
  24. const widgetsRef = useRef<TossPaymentsWidgets|null>(null);
  25. useEffect(() => {
  26. setIsPopup(!!window.opener);
  27. }, []);
  28. // 주문 생성 후 토스 위젯 초기화 — clientKey/customerKey 로 widgets 생성, 금액 설정, UI 렌더링
  29. const initWidgets = useCallback(async (created: TossCreateOrderResponse) => {
  30. const tossPayments = await loadTossPayments(created.clientKey);
  31. const widgets = tossPayments.widgets({ customerKey: created.customerKey });
  32. widgetsRef.current = widgets;
  33. await widgets.setAmount({ value: created.amount, currency: 'KRW' });
  34. await Promise.all([
  35. widgets.renderPaymentMethods({ selector: '#toss-payment-methods' }),
  36. widgets.renderAgreement({ selector: '#toss-agreement' })
  37. ]);
  38. }, []);
  39. if (!member || !loginCheck()) {
  40. return null;
  41. }
  42. const handleAmountChange = (value: string) => {
  43. const num = parseInt(value.replace(/[^0-9]/g, ''), 10);
  44. setCashInput(isNaN(num) ? 0 : num);
  45. };
  46. const addAmount = (add: number) => {
  47. setCashInput(prev => prev + add);
  48. };
  49. // VAT 10% 별도 가산 (표시용). 최종 결제 금액은 서버 주문(order.amount)이 확정한다.
  50. const vatAmount = Math.round(cashInput * 0.1);
  51. const totalAmount = cashInput + vatAmount;
  52. // 1단계: 서버에 주문 생성 → 토스 위젯 초기화
  53. const handleCreateOrder = async () => {
  54. if (cashInput < 1000) {
  55. alert('최소 충전 금액은 1,000원입니다.');
  56. return;
  57. }
  58. setLoading(true);
  59. setError(null);
  60. try {
  61. const res = await fetchApi<TossCreateOrderResponse>('/api/payments/orders', {
  62. method: 'POST',
  63. body: { amount: cashInput } as TossCreateOrderRequest
  64. });
  65. if (!res.success || !res.data) {
  66. setError(res.message || '주문 생성에 실패했습니다.');
  67. setLoading(false);
  68. return;
  69. }
  70. const created = res.data;
  71. setOrder(created);
  72. await initWidgets(created);
  73. } catch (e) {
  74. console.error('주문 생성/위젯 초기화 실패:', e);
  75. setError('결제 준비 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.');
  76. } finally {
  77. setLoading(false);
  78. }
  79. };
  80. // 2단계: 결제 요청 (redirect 방식) — successUrl/failUrl 로 이동
  81. const handleRequestPayment = async () => {
  82. const widgets = widgetsRef.current;
  83. if (!widgets || !order) {
  84. return;
  85. }
  86. setPaying(true);
  87. setError(null);
  88. try {
  89. const origin = window.location.origin;
  90. await widgets.requestPayment({
  91. orderId: order.orderID,
  92. orderName: order.orderName,
  93. successUrl: `${origin}/charge/success`,
  94. failUrl: `${origin}/charge/fail`,
  95. customerEmail: member.email ?? undefined,
  96. customerName: member.name ?? undefined
  97. });
  98. } catch (e) {
  99. // 사용자가 결제창을 닫거나 요청이 실패한 경우 — 페이지에 머물러 재시도 가능
  100. console.error('결제 요청 실패:', e);
  101. const message = e instanceof Error ? e.message : '결제 요청에 실패했습니다.';
  102. setError(message);
  103. setPaying(false);
  104. }
  105. };
  106. // 금액 재선택 — 위젯을 닫고 1단계로 복귀 (금액이 바뀌면 새 주문이 필요)
  107. const handleReset = () => {
  108. widgetsRef.current = null;
  109. setOrder(null);
  110. setError(null);
  111. };
  112. return (
  113. <div className="charge-page">
  114. {isTossTestMode && (
  115. <div className="charge-page__test-banner" role="alert">
  116. 현재 토스 충전은 테스트 환경입니다.
  117. </div>
  118. )}
  119. <div className="charge-page__card">
  120. <div className="charge-page__header">
  121. <h1>캐시 충전</h1>
  122. {isPopup && (
  123. <button type="button" className="charge-page__close" onClick={() => window.close()}>×</button>
  124. )}
  125. </div>
  126. <div className="charge-page__body">
  127. {!order && (
  128. <>
  129. <label className="charge-page__label">충전 캐시 (원)</label>
  130. <input
  131. type="text"
  132. className="charge-page__amount-input"
  133. value={cashInput > 0 ? cashInput.toLocaleString() : ''}
  134. onChange={e => handleAmountChange(e.target.value)}
  135. placeholder="0"
  136. inputMode="numeric"
  137. autoFocus
  138. />
  139. <div className="charge-page__quick-btns">
  140. {QUICK_AMOUNTS.map(v => (
  141. <button type="button" key={v} className="charge-page__quick-btn" onClick={() => addAmount(v)}>
  142. +{v >= 10000 ? `${v / 10000}만` : v.toLocaleString()}
  143. </button>
  144. ))}
  145. </div>
  146. <div className="charge-page__receipt">
  147. <div className="charge-page__receipt-row">
  148. <span>충전 캐시</span>
  149. <span>{cashInput.toLocaleString()} 원</span>
  150. </div>
  151. <div className="charge-page__receipt-row">
  152. <span>부가세 (10%)</span>
  153. <span>{vatAmount.toLocaleString()} 원</span>
  154. </div>
  155. <div className="charge-page__receipt-row charge-page__receipt-row--total">
  156. <span>총 결제 금액</span>
  157. <span>{totalAmount.toLocaleString()} 원</span>
  158. </div>
  159. </div>
  160. {error && <p className="charge-page__error" role="alert">{error}</p>}
  161. <button type="button" className="charge-page__submit" onClick={handleCreateOrder} disabled={loading || cashInput < 1000}>
  162. {loading ? '결제 준비 중...' : '결제 진행'}
  163. </button>
  164. </>
  165. )}
  166. {order && (
  167. <>
  168. <div className="charge-page__receipt">
  169. <div className="charge-page__receipt-row">
  170. <span>충전 캐시</span>
  171. <span>{order.pointAmount.toLocaleString()} 원</span>
  172. </div>
  173. <div className="charge-page__receipt-row">
  174. <span>부가세 (10%)</span>
  175. <span>{order.vatAmount.toLocaleString()} 원</span>
  176. </div>
  177. <div className="charge-page__receipt-row charge-page__receipt-row--total">
  178. <span>총 결제 금액</span>
  179. <span>{order.amount.toLocaleString()} 원</span>
  180. </div>
  181. </div>
  182. <div id="toss-payment-methods" className="charge-page__toss-methods" />
  183. <div id="toss-agreement" className="charge-page__toss-agreement" />
  184. {error && <p className="charge-page__error" role="alert">{error}</p>}
  185. <button type="button" className="charge-page__submit" onClick={handleRequestPayment} disabled={paying}>
  186. {paying ? '결제 요청 중...' : `${order.amount.toLocaleString()}원 결제하기`}
  187. </button>
  188. <button type="button" className="charge-page__reset" onClick={handleReset} disabled={paying}>
  189. 금액 다시 선택
  190. </button>
  191. </>
  192. )}
  193. </div>
  194. </div>
  195. </div>
  196. );
  197. }