| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416 |
- 'use client';
- import { useEffect, useState } from 'react';
- import Link from 'next/link';
- import { useRouter } from 'next/navigation';
- import { CreditCard, Gamepad2, Package, Ticket, Trash2 } 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 {
- ProductType,
- ChannelSearchResponse,
- ChannelSearchRow
- } from '@/types/store';
- const TYPE_BADGE_CLASS: Record<ProductType, string> = {
- 1: 'bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-300',
- 2: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300'
- };
- const TYPE_LABEL: Record<ProductType, string> = { 1: '실물', 2: '쿠폰' };
- function TypeBadge({ type }: { type: ProductType })
- {
- const Icon = type === 2 ? Ticket : Package;
- return (
- <span
- title={TYPE_LABEL[type]}
- aria-label={TYPE_LABEL[type]}
- className={`inline-flex items-center justify-center p-1 rounded ${TYPE_BADGE_CLASS[type]}`}
- >
- <Icon className="size-3" />
- </span>
- );
- }
- function formatPrice(n: number): string
- {
- return n.toLocaleString('ko-KR');
- }
- export default function CartPage()
- {
- const router = useRouter();
- const { loginCheck } = useAuth();
- const { items, channel, updateQuantity, removeItem, setChannel, clear, totalCount, totalPrice } = useCart();
- const [payState] = useState<PayButtonState>('idle');
- const [submitError, setSubmitError] = useState<string|null>(null);
- const [channelModalOpen, setChannelModalOpen] = useState(false);
- const [optedOut, setOptedOut] = useState(false);
- const [pendingCheckout, setPendingCheckout] = useState(false);
- const requireChannel = items.some(i => i.requireDonationChannel);
- const handleCheckout = () => {
- if (!loginCheck()) {
- return;
- }
- if (items.length === 0) {
- setSubmitError('장바구니가 비어있습니다.');
- return;
- }
- setSubmitError(null);
- // 후원 채널 확인 절차 강제 — 채널 미선택 시 (필수이거나 아직 선택안함 미체크면) 모달 먼저 오픈
- if (!channel && (requireChannel || !optedOut)) {
- setPendingCheckout(true);
- setChannelModalOpen(true);
- return;
- }
- // 실제 결제는 /checkout 영수증 페이지에서 진행
- router.push('/checkout');
- };
- return (
- <div className='container mx-auto max-w-5xl px-4 py-6'>
- <h1 className='text-2xl font-bold mb-4'>
- 장바구니{' '}
- <span className='text-red-600 dark:text-red-400 text-xl font-extrabold'>
- ({totalCount.toLocaleString()}개)
- </span>
- </h1>
- {items.length === 0 ? (
- <div className='text-center py-20'>
- <p className='text-neutral-500 mb-4'>장바구니가 비어있습니다.</p>
- <Link href='/store' className='inline-block bg-blue-600 text-white px-5 py-2 rounded hover:bg-blue-700 text-sm'>
- 상점 둘러보기
- </Link>
- </div>
- ) : (
- <div className='grid grid-cols-1 lg:grid-cols-[1fr_360px] gap-6'>
- {/* 좌측: 상품 라인 리스트 */}
- <div className='space-y-3'>
- {items.map((item) => (
- <div
- key={item.productID}
- className='flex gap-3 p-3 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900'
- >
- <Link href={`/store/${item.productID}`} className='shrink-0'>
- <div className='w-20 h-20 sm:w-24 sm:h-24 bg-neutral-100 dark:bg-neutral-800 rounded overflow-hidden flex items-center justify-center'>
- {item.thumbnail ? (
- // eslint-disable-next-line @next/next/no-img-element
- <img src={item.thumbnail} alt={item.name} className='w-full h-full object-cover' />
- ) : (
- <span className='text-neutral-400 text-[10px]'>이미지 없음</span>
- )}
- </div>
- </Link>
- <div className='flex-1 min-w-0 flex flex-col justify-between'>
- <div>
- <div className='flex items-center gap-1.5 mb-1'>
- <TypeBadge type={item.type} />
- <Gamepad2 className='size-3.5 shrink-0 text-purple-600 dark:text-purple-400' />
- <span className='text-xs font-semibold text-purple-600 dark:text-purple-400 truncate'>
- {item.gameName}
- </span>
- </div>
- <Link
- href={`/store/${item.productID}`}
- className='text-sm font-semibold line-clamp-2 hover:underline'
- >
- {item.name}
- </Link>
- </div>
- <div className='flex items-end justify-between gap-2 mt-2 flex-wrap'>
- <div className='flex items-stretch border border-neutral-300 dark:border-neutral-700 rounded overflow-hidden text-sm'>
- <button
- type='button'
- onClick={() => { updateQuantity(item.productID, item.quantity - 1); }}
- className='px-2 hover:bg-neutral-100 dark:hover:bg-neutral-800'
- aria-label='수량 감소'
- >
- -
- </button>
- <input
- type='number'
- min={1}
- max={999}
- value={item.quantity}
- onChange={(e) => {
- const v = parseInt(e.target.value || '1', 10);
- updateQuantity(item.productID, isNaN(v) ? 1 : v);
- }}
- className='w-12 text-center bg-white dark:bg-neutral-900 outline-none border-0 appearance-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none'
- />
- <button
- type='button'
- onClick={() => { updateQuantity(item.productID, item.quantity + 1); }}
- className='px-2 hover:bg-neutral-100 dark:hover:bg-neutral-800'
- aria-label='수량 증가'
- >
- +
- </button>
- </div>
- <div className='text-right'>
- <div className='text-xs text-neutral-500'>{formatPrice(item.price)}P × {item.quantity}</div>
- <div className='text-base font-bold text-red-600 dark:text-red-400'>
- {formatPrice(item.price * item.quantity)}P
- </div>
- </div>
- <button
- type='button'
- onClick={() => { removeItem(item.productID); }}
- className='text-xs text-neutral-500 hover:text-red-600 underline'
- >
- 삭제
- </button>
- </div>
- </div>
- </div>
- ))}
- <div className='flex justify-between items-center pt-2'>
- <button
- type='button'
- onClick={() => {
- if (confirm('장바구니를 비우시겠습니까?')) {
- clear();
- }
- }}
- className='inline-flex items-center gap-1 text-xs text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
- >
- <Trash2 className='size-3.5' aria-hidden />
- <span className='underline'>장바구니 비우기</span>
- </button>
- <Link href='/store' className='text-xs text-blue-600 dark:text-blue-400 hover:underline'>
- 쇼핑 계속하기 〉
- </Link>
- </div>
- </div>
- {/* 우측: 결제 패널 */}
- <div className='lg:sticky lg:top-4 lg:self-start'>
- <div className='border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 p-4'>
- <h2 className='text-sm font-bold mb-3'>주문 요약</h2>
- <div className='space-y-2 mb-4'>
- <div className='flex justify-between text-sm'>
- <span className='text-neutral-500'>상품 수</span>
- <span>{totalCount}개</span>
- </div>
- <div className='flex justify-between text-sm'>
- <span className='text-neutral-500'>상품 금액</span>
- <span>{formatPrice(totalPrice)}P</span>
- </div>
- </div>
- <div className='border-t border-neutral-200 dark:border-neutral-800 pt-3 mb-3'>
- <label className='block text-xs font-semibold mb-1.5'>후원 채널 {requireChannel ? <span className='text-amber-600 dark:text-amber-400'>(필수)</span> : '(선택)'}</label>
- <button
- type='button'
- onClick={() => { if (loginCheck()) { setChannelModalOpen(true); } }}
- className='w-full border border-neutral-300 dark:border-neutral-700 rounded px-3 py-2 text-sm text-left bg-white dark:bg-neutral-900 hover:bg-neutral-50 dark:hover:bg-neutral-800 truncate'
- >
- {channel ? (
- <span className='font-semibold'>{channel.name}</span>
- ) : (
- <span className='text-neutral-400'>후원 채널 선택</span>
- )}
- </button>
- {channel && (
- <button
- type='button'
- onClick={() => { setChannel(null); }}
- className='text-[11px] text-neutral-500 underline mt-1'
- >
- 채널 해제
- </button>
- )}
- </div>
- <div className='border-t border-neutral-200 dark:border-neutral-800 pt-3 mb-4'>
- <div className='flex justify-between font-bold text-lg'>
- <span>결제 금액</span>
- <span className='text-red-600 dark:text-red-400'>{formatPrice(totalPrice)}P</span>
- </div>
- </div>
- {submitError && (
- <div className='text-red-600 text-sm mb-2'>{submitError}</div>
- )}
- <PayButton
- state={payState}
- label='전체 구매하기'
- icon={<CreditCard className='size-5' aria-hidden />}
- onClick={handleCheckout}
- disabled={items.length === 0}
- />
- <p className='text-xs text-neutral-500 mt-2'>
- * 결제 시 캐시 잔액에서 차감됩니다. (토큰 사용 불가)
- </p>
- </div>
- </div>
- </div>
- )}
- {channelModalOpen && (
- <ChannelSelectModal
- requireChannel={requireChannel}
- onClose={() => { setChannelModalOpen(false); setPendingCheckout(false); }}
- onSelect={(ch) => {
- setChannel(ch);
- setOptedOut(false);
- setChannelModalOpen(false);
- if (pendingCheckout) {
- setPendingCheckout(false);
- router.push('/checkout');
- }
- }}
- onSkip={() => {
- setChannel(null);
- setOptedOut(true);
- setChannelModalOpen(false);
- if (pendingCheckout) {
- setPendingCheckout(false);
- router.push('/checkout');
- }
- }}
- />
- )}
- </div>
- );
- }
- function ChannelSelectModal({ onClose, onSelect, onSkip, requireChannel }: {
- onClose: () => void;
- onSelect: (ch: ChannelSearchRow) => void;
- onSkip: () => void;
- requireChannel: boolean;
- })
- {
- const [keyword, setKeyword] = useState('');
- const [channels, setChannels] = useState<ChannelSearchRow[]>([]);
- const [loading, setLoading] = useState(false);
- useEffect(() => {
- const handle = setTimeout(async () => {
- setLoading(true);
- const params = new URLSearchParams();
- if (keyword.trim()) {
- params.set('keyword', keyword.trim());
- }
- else {
- params.set('random', 'true');
- }
- params.set('page', '1');
- params.set('perPage', '5');
- const res = await fetchApi<ChannelSearchResponse>(`/api/store/channels/search?${params.toString()}`, { silent: true });
- if (res.success && res.data) {
- setChannels(res.data.list);
- }
- setLoading(false);
- }, 300);
- return () => { clearTimeout(handle); };
- }, [keyword]);
- return (
- <div
- className='fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4'
- onClick={onClose}
- >
- <div
- className='bg-white dark:bg-neutral-900 rounded-lg w-full max-w-md max-h-[80vh] flex flex-col'
- onClick={(e) => { e.stopPropagation(); }}
- >
- <div className='p-4 border-b border-neutral-200 dark:border-neutral-800'>
- <div className='flex justify-between items-center mb-3'>
- <h2 className='font-bold'>후원 채널 선택</h2>
- <button type='button' onClick={onClose} className='text-neutral-500 hover:text-neutral-700'>✕</button>
- </div>
- <input
- type='search'
- value={keyword}
- onChange={(e) => { setKeyword(e.target.value); }}
- placeholder='채널명, 핸들, 후원 코드 검색'
- className='w-full border border-neutral-300 dark:border-neutral-700 rounded px-3 py-2 text-sm bg-white dark:bg-neutral-900'
- maxLength={100}
- />
- {!keyword.trim() ? (
- <p className='text-[11px] text-neutral-400 mt-1'>* 채널이 무작위 순서로 표시됩니다.</p>
- ) : (
- <p className='text-[11px] text-neutral-400 mt-1'>* 후원 코드는 정확히 입력해야 합니다.</p>
- )}
- </div>
- <div className='flex-1 overflow-y-auto p-2'>
- {loading ? (
- <div className='text-center py-8 text-neutral-500 text-sm'>검색 중...</div>
- ) : channels.length === 0 ? (
- <div className='text-center py-8 text-neutral-500 text-sm'>채널이 없습니다.</div>
- ) : (
- channels.map((ch) => (
- <button
- key={ch.id}
- type='button'
- onClick={() => { onSelect(ch); }}
- className='w-full text-left flex items-center gap-3 p-3 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded'
- >
- {ch.thumbnailUrl ? (
- // eslint-disable-next-line @next/next/no-img-element
- <img src={ch.thumbnailUrl} alt='' className='w-10 h-10 rounded-full object-cover' />
- ) : (
- <div className='w-10 h-10 rounded-full bg-neutral-300 dark:bg-neutral-700' />
- )}
- <div className='flex-1 min-w-0'>
- <div className='font-semibold truncate flex items-center gap-1'>
- {ch.name}
- {ch.isVerified && (
- <span className='text-[10px] bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300 rounded px-1'>✓</span>
- )}
- {ch.donationCode && (
- <span className='text-[10px] font-mono bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 rounded px-1 tracking-wider'>{ch.donationCode}</span>
- )}
- </div>
- {ch.handle && (
- <div className='text-xs text-neutral-500 truncate'>{ch.handle}</div>
- )}
- </div>
- </button>
- ))
- )}
- </div>
- {requireChannel ? (
- <div className='p-3 border-t border-neutral-200 dark:border-neutral-800 text-xs text-amber-600 dark:text-amber-400'>
- 후원 채널 선택이 필수인 상품입니다.
- </div>
- ) : (
- <div className='p-3 border-t border-neutral-200 dark:border-neutral-800'>
- <label className='flex items-center gap-2 text-sm cursor-pointer text-neutral-600 dark:text-neutral-300'>
- <input type='checkbox' onChange={(e) => { if (e.target.checked) { onSkip(); } }} className='size-4' />
- <span>선택 안함</span>
- </label>
- </div>
- )}
- </div>
- </div>
- );
- }
|