page.tsx 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. 'use client';
  2. import { useEffect, useState } from 'react';
  3. import { useRouter } from 'next/navigation';
  4. import { ArrowLeft, CreditCard } from 'lucide-react';
  5. import { fetchApi } from '@/lib/utils/client';
  6. import PayButton, { type PayButtonState } from '@/app/component/PayButton';
  7. import useAuth from '@/hooks/useAuth';
  8. import useCart from '@/hooks/useCart';
  9. import type { PlaceOrderResponse } from '@/types/store';
  10. function formatPrice(n: number): string
  11. {
  12. return n.toLocaleString('ko-KR');
  13. }
  14. function pad(n: number): string
  15. {
  16. return String(n).padStart(2, '0');
  17. }
  18. function formatNow(): string
  19. {
  20. const d = new Date();
  21. return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
  22. }
  23. export default function CheckoutPage()
  24. {
  25. const router = useRouter();
  26. const { isAuthenticated } = useAuth();
  27. const { items, channel, totalCount, totalPrice } = useCart();
  28. const [hydrated, setHydrated] = useState(false);
  29. const [payState, setPayState] = useState<PayButtonState>('idle');
  30. const [error, setError] = useState<string|null>(null);
  31. const [issuedAt] = useState<string>(() => formatNow());
  32. useEffect(() => {
  33. setHydrated(true);
  34. }, []);
  35. // 인증 / 빈 cart 가드 — hydration 이후에만 판단
  36. useEffect(() => {
  37. if (!hydrated) {
  38. return;
  39. }
  40. if (!isAuthenticated) {
  41. // 동의를 받고 이동 (취소 시 장바구니로 복귀)
  42. if (confirm('로그인 후 이용 가능합니다.\n로그인하시겠습니까?')) {
  43. router.replace('/login?returnUrl=/checkout');
  44. } else {
  45. router.replace('/cart');
  46. }
  47. return;
  48. }
  49. if (items.length === 0) {
  50. router.replace('/cart');
  51. }
  52. }, [hydrated, isAuthenticated, items.length, router]);
  53. const handleConfirmPayment = async () => {
  54. if (items.length === 0 || payState !== 'idle') {
  55. return;
  56. }
  57. setPayState('loading');
  58. setError(null);
  59. const res = await fetchApi<PlaceOrderResponse>('/api/store/orders', {
  60. method: 'POST',
  61. body: {
  62. channelID: channel?.id ?? null,
  63. items: items.map(i => ({ productID: i.productID, quantity: i.quantity }))
  64. },
  65. silent: true
  66. });
  67. if (res.success && res.data) {
  68. setPayState('success');
  69. // cart clear 는 /order-complete 페이지 진입 시 수행 (이중 이동 시 데이터 안정성)
  70. router.push(`/order-complete/${res.data.orderID}`);
  71. return;
  72. }
  73. setPayState('idle');
  74. setError(res.message ?? '결제에 실패했습니다.');
  75. };
  76. if (!hydrated || items.length === 0) {
  77. return null;
  78. }
  79. return (
  80. <div className='container mx-auto max-w-2xl px-4 py-6'>
  81. {/* 인보이스 카드 */}
  82. <div className='border border-neutral-300 dark:border-neutral-700 rounded-lg bg-white dark:bg-neutral-900 overflow-hidden'>
  83. {/* 헤더: 페이지 제목 + 소제목 */}
  84. <div className='px-4 sm:px-6 py-5 border-b border-neutral-200 dark:border-neutral-800 flex items-baseline justify-between flex-wrap gap-2'>
  85. <div>
  86. <h1 className='text-xl sm:text-2xl font-bold'>구매 확인</h1>
  87. <p className='text-xs sm:text-sm text-neutral-500 mt-1'>
  88. 아래 영수증을 확인하신 후 구매하기를 눌러주세요.
  89. </p>
  90. </div>
  91. <div className='text-xs text-neutral-500 font-mono whitespace-nowrap'>{issuedAt}</div>
  92. </div>
  93. {/* 상품 테이블 */}
  94. <table className='w-full text-sm'>
  95. <thead className='border-b border-neutral-200 dark:border-neutral-800'>
  96. <tr className='text-xs text-neutral-500'>
  97. <th className='text-left px-4 sm:px-6 py-3 font-semibold'>상품</th>
  98. <th className='text-right px-2 sm:px-3 py-3 font-semibold w-14 sm:w-16'>수량</th>
  99. <th className='text-right px-4 sm:px-6 py-3 font-semibold w-28 sm:w-32'>가격</th>
  100. </tr>
  101. </thead>
  102. <tbody>
  103. {items.map((item) => (
  104. <tr key={item.productID} className='border-b border-neutral-100 dark:border-neutral-800/60'>
  105. <td className='px-4 sm:px-6 py-3 sm:py-4 align-top'>
  106. <div className='flex items-start gap-3'>
  107. <div className='w-14 h-14 sm:w-16 sm:h-16 bg-neutral-100 dark:bg-neutral-800 rounded overflow-hidden flex items-center justify-center shrink-0'>
  108. {item.thumbnail ? (
  109. // eslint-disable-next-line @next/next/no-img-element
  110. <img
  111. src={item.thumbnail}
  112. alt={item.name}
  113. className='w-full h-full object-contain'
  114. />
  115. ) : (
  116. <span className='text-neutral-400 text-[10px]'>이미지 없음</span>
  117. )}
  118. </div>
  119. <div className='min-w-0 flex-1'>
  120. <div className='font-medium line-clamp-2 text-sm'>{item.name}</div>
  121. <div className='text-xs text-neutral-500 mt-0.5'>
  122. {item.gameName} · {item.type === 1 ? '실물' : '쿠폰'}
  123. </div>
  124. </div>
  125. </div>
  126. </td>
  127. <td className='text-right px-2 sm:px-3 py-3 sm:py-4 align-top text-sm'>{item.quantity}</td>
  128. <td className='text-right px-4 sm:px-6 py-3 sm:py-4 align-top font-mono text-sm whitespace-nowrap'>
  129. {formatPrice(item.price * item.quantity)}P
  130. </td>
  131. </tr>
  132. ))}
  133. </tbody>
  134. </table>
  135. {/* 합계 */}
  136. <div className='px-4 sm:px-6 py-4 border-b border-neutral-200 dark:border-neutral-800 text-sm'>
  137. <div className='ml-auto max-w-xs space-y-1.5'>
  138. <div className='flex justify-between text-neutral-500'>
  139. <span>소계</span>
  140. <span className='font-mono'>{formatPrice(totalPrice)}P</span>
  141. </div>
  142. <div className='flex justify-between text-neutral-500'>
  143. <span>할인</span>
  144. <span className='font-mono'>0P</span>
  145. </div>
  146. <div className='border-t border-neutral-300 dark:border-neutral-700 pt-1.5 flex justify-between font-bold text-base'>
  147. <span>합계</span>
  148. <span className='font-mono text-red-600 dark:text-red-400'>{formatPrice(totalPrice)}P</span>
  149. </div>
  150. <div className='text-xs text-neutral-500 text-right'>
  151. 총 {totalCount.toLocaleString()}개 상품
  152. </div>
  153. </div>
  154. </div>
  155. {/* 결제수단 */}
  156. <div className='px-4 sm:px-6 py-4 text-xs text-neutral-600 dark:text-neutral-400 space-y-1'>
  157. <div>결제수단: <span className='font-semibold'>캐시 잔액</span></div>
  158. </div>
  159. </div>
  160. {error && (
  161. <div className='mt-3 text-sm text-red-600 dark:text-red-400'>{error}</div>
  162. )}
  163. <div className='mt-5 flex gap-2 flex-col sm:flex-row'>
  164. <button
  165. type='button'
  166. onClick={() => router.push('/cart')}
  167. disabled={payState !== 'idle'}
  168. className='sm:flex-1 inline-flex items-center justify-center gap-1.5 px-4 py-3 rounded border border-neutral-300 dark:border-neutral-700 text-sm hover:bg-neutral-50 dark:hover:bg-neutral-800 disabled:opacity-50'
  169. >
  170. <ArrowLeft className='size-4' aria-hidden />
  171. 장바구니로
  172. </button>
  173. <PayButton
  174. state={payState}
  175. label='구매하기'
  176. icon={<CreditCard className='size-5' aria-hidden />}
  177. onClick={handleConfirmPayment}
  178. className='sm:flex-1'
  179. />
  180. </div>
  181. <p className='text-xs text-neutral-500 mt-3 text-center'>
  182. 결제 시 POINT 잔액에서 차감됩니다. 결제 후 취소/환불은 주문 목록에서 신청할 수 있습니다.
  183. </p>
  184. </div>
  185. );
  186. }