| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227 |
- 'use client';
- 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 { TossCreateOrderRequest } from '@/types/request/payment/charge';
- import { TossCreateOrderResponse } from '@/types/response/payment/charge';
- const QUICK_AMOUNTS = [10000, 50000, 100000, 1000000];
- export default function ChargePage()
- {
- // 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 isTossTestMode = config?.toss?.tossPayMode === 'test';
- const widgetsRef = useRef<TossPaymentsWidgets|null>(null);
- useEffect(() => {
- setIsPopup(!!window.opener);
- }, []);
- // 주문 생성 후 토스 위젯 초기화 — 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;
- }
- const handleAmountChange = (value: string) => {
- const num = parseInt(value.replace(/[^0-9]/g, ''), 10);
- setCashInput(isNaN(num) ? 0 : num);
- };
- const addAmount = (add: number) => {
- setCashInput(prev => prev + add);
- };
- // VAT 10% 별도 가산 (표시용). 최종 결제 금액은 서버 주문(order.amount)이 확정한다.
- const vatAmount = Math.round(cashInput * 0.1);
- const totalAmount = cashInput + vatAmount;
- // 1단계: 서버에 주문 생성 → 토스 위젯 초기화
- const handleCreateOrder = async () => {
- if (cashInput < 1000) {
- alert('최소 충전 금액은 1,000원입니다.');
- return;
- }
- setLoading(true);
- setError(null);
- try {
- const res = await fetchApi<TossCreateOrderResponse>('/api/payments/orders', {
- method: 'POST',
- body: { amount: cashInput } as TossCreateOrderRequest
- });
- if (!res.success || !res.data) {
- setError(res.message || '주문 생성에 실패했습니다.');
- setLoading(false);
- return;
- }
- 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,
- orderName: order.orderName,
- successUrl: `${origin}/charge/success`,
- failUrl: `${origin}/charge/fail`,
- customerEmail: member.email ?? undefined,
- customerName: member.name ?? undefined
- });
- } catch (e) {
- // 사용자가 결제창을 닫거나 요청이 실패한 경우 — 페이지에 머물러 재시도 가능
- console.error('결제 요청 실패:', e);
- 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">
- {isTossTestMode && (
- <div className="charge-page__test-banner" role="alert">
- 현재 토스 충전은 테스트 환경입니다.
- </div>
- )}
- <div className="charge-page__card">
- <div className="charge-page__header">
- <h1>캐시 충전</h1>
- {isPopup && (
- <button type="button" className="charge-page__close" onClick={() => window.close()}>×</button>
- )}
- </div>
- <div className="charge-page__body">
- {!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 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>
- );
- }
|