| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211 |
- 'use client';
- import { useEffect, useState } from 'react';
- import { useRouter } from 'next/navigation';
- import { ArrowLeft, CreditCard } from 'lucide-react';
- import { fetchApi } from '@/lib/utils/client';
- import PayButton, { type PayButtonState } from '@/app/component/PayButton';
- import useAuth from '@/hooks/useAuth';
- import useCart from '@/hooks/useCart';
- import type { PlaceOrderResponse } from '@/types/store';
- function formatPrice(n: number): string
- {
- return n.toLocaleString('ko-KR');
- }
- function pad(n: number): string
- {
- return String(n).padStart(2, '0');
- }
- function formatNow(): string
- {
- const d = new Date();
- return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
- }
- export default function CheckoutPage()
- {
- const router = useRouter();
- const { isAuthenticated } = useAuth();
- const { items, channel, totalCount, totalPrice } = useCart();
- const [hydrated, setHydrated] = useState(false);
- const [payState, setPayState] = useState<PayButtonState>('idle');
- const [error, setError] = useState<string|null>(null);
- const [issuedAt] = useState<string>(() => formatNow());
- useEffect(() => {
- setHydrated(true);
- }, []);
- // 인증 / 빈 cart 가드 — hydration 이후에만 판단
- useEffect(() => {
- if (!hydrated) {
- return;
- }
- if (!isAuthenticated) {
- // 동의를 받고 이동 (취소 시 장바구니로 복귀)
- if (confirm('로그인 후 이용 가능합니다.\n로그인하시겠습니까?')) {
- router.replace('/login?returnUrl=/checkout');
- } else {
- router.replace('/cart');
- }
- return;
- }
- if (items.length === 0) {
- router.replace('/cart');
- }
- }, [hydrated, isAuthenticated, items.length, router]);
- const handleConfirmPayment = async () => {
- if (items.length === 0 || payState !== 'idle') {
- return;
- }
- setPayState('loading');
- setError(null);
- const res = await fetchApi<PlaceOrderResponse>('/api/store/orders', {
- method: 'POST',
- body: {
- channelID: channel?.id ?? null,
- items: items.map(i => ({ productID: i.productID, quantity: i.quantity }))
- },
- silent: true
- });
- if (res.success && res.data) {
- setPayState('success');
- // cart clear 는 /order-complete 페이지 진입 시 수행 (이중 이동 시 데이터 안정성)
- router.push(`/order-complete/${res.data.orderID}`);
- return;
- }
- setPayState('idle');
- setError(res.message ?? '결제에 실패했습니다.');
- };
- if (!hydrated || items.length === 0) {
- return null;
- }
- return (
- <div className='container mx-auto max-w-2xl px-4 py-6'>
- {/* 인보이스 카드 */}
- <div className='border border-neutral-300 dark:border-neutral-700 rounded-lg bg-white dark:bg-neutral-900 overflow-hidden'>
- {/* 헤더: 페이지 제목 + 소제목 */}
- <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'>
- <div>
- <h1 className='text-xl sm:text-2xl font-bold'>구매 확인</h1>
- <p className='text-xs sm:text-sm text-neutral-500 mt-1'>
- 아래 영수증을 확인하신 후 구매하기를 눌러주세요.
- </p>
- </div>
- <div className='text-xs text-neutral-500 font-mono whitespace-nowrap'>{issuedAt}</div>
- </div>
- {/* 상품 테이블 */}
- <table className='w-full text-sm'>
- <thead className='border-b border-neutral-200 dark:border-neutral-800'>
- <tr className='text-xs text-neutral-500'>
- <th className='text-left px-4 sm:px-6 py-3 font-semibold'>상품</th>
- <th className='text-right px-2 sm:px-3 py-3 font-semibold w-14 sm:w-16'>수량</th>
- <th className='text-right px-4 sm:px-6 py-3 font-semibold w-28 sm:w-32'>가격</th>
- </tr>
- </thead>
- <tbody>
- {items.map((item) => (
- <tr key={item.productID} className='border-b border-neutral-100 dark:border-neutral-800/60'>
- <td className='px-4 sm:px-6 py-3 sm:py-4 align-top'>
- <div className='flex items-start gap-3'>
- <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'>
- {item.thumbnail ? (
- // eslint-disable-next-line @next/next/no-img-element
- <img
- src={item.thumbnail}
- alt={item.name}
- className='w-full h-full object-contain'
- />
- ) : (
- <span className='text-neutral-400 text-[10px]'>이미지 없음</span>
- )}
- </div>
- <div className='min-w-0 flex-1'>
- <div className='font-medium line-clamp-2 text-sm'>{item.name}</div>
- <div className='text-xs text-neutral-500 mt-0.5'>
- {item.gameName} · {item.type === 1 ? '실물' : '쿠폰'}
- </div>
- </div>
- </div>
- </td>
- <td className='text-right px-2 sm:px-3 py-3 sm:py-4 align-top text-sm'>{item.quantity}</td>
- <td className='text-right px-4 sm:px-6 py-3 sm:py-4 align-top font-mono text-sm whitespace-nowrap'>
- {formatPrice(item.price * item.quantity)}P
- </td>
- </tr>
- ))}
- </tbody>
- </table>
- {/* 합계 */}
- <div className='px-4 sm:px-6 py-4 border-b border-neutral-200 dark:border-neutral-800 text-sm'>
- <div className='ml-auto max-w-xs space-y-1.5'>
- <div className='flex justify-between text-neutral-500'>
- <span>소계</span>
- <span className='font-mono'>{formatPrice(totalPrice)}P</span>
- </div>
- <div className='flex justify-between text-neutral-500'>
- <span>할인</span>
- <span className='font-mono'>0P</span>
- </div>
- <div className='border-t border-neutral-300 dark:border-neutral-700 pt-1.5 flex justify-between font-bold text-base'>
- <span>합계</span>
- <span className='font-mono text-red-600 dark:text-red-400'>{formatPrice(totalPrice)}P</span>
- </div>
- <div className='text-xs text-neutral-500 text-right'>
- 총 {totalCount.toLocaleString()}개 상품
- </div>
- </div>
- </div>
- {/* 결제수단 */}
- <div className='px-4 sm:px-6 py-4 text-xs text-neutral-600 dark:text-neutral-400 space-y-1'>
- <div>결제수단: <span className='font-semibold'>캐시 잔액</span></div>
- </div>
- </div>
- {error && (
- <div className='mt-3 text-sm text-red-600 dark:text-red-400'>{error}</div>
- )}
- <div className='mt-5 flex gap-2 flex-col sm:flex-row'>
- <button
- type='button'
- onClick={() => router.push('/cart')}
- disabled={payState !== 'idle'}
- 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'
- >
- <ArrowLeft className='size-4' aria-hidden />
- 장바구니로
- </button>
- <PayButton
- state={payState}
- label='구매하기'
- icon={<CreditCard className='size-5' aria-hidden />}
- onClick={handleConfirmPayment}
- className='sm:flex-1'
- />
- </div>
- <p className='text-xs text-neutral-500 mt-3 text-center'>
- 결제 시 POINT 잔액에서 차감됩니다. 결제 후 취소/환불은 주문 목록에서 신청할 수 있습니다.
- </p>
- </div>
- );
- }
|