page.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416
  1. 'use client';
  2. import { useEffect, useState } from 'react';
  3. import Link from 'next/link';
  4. import { useRouter } from 'next/navigation';
  5. import { CreditCard, Gamepad2, Package, Ticket, Trash2 } from 'lucide-react';
  6. import { fetchApi } from '@/lib/utils/client';
  7. import PayButton, { type PayButtonState } from '@/app/component/PayButton';
  8. import useAuth from '@/hooks/useAuth';
  9. import useCart from '@/hooks/useCart';
  10. import type {
  11. ProductType,
  12. ChannelSearchResponse,
  13. ChannelSearchRow
  14. } from '@/types/store';
  15. const TYPE_BADGE_CLASS: Record<ProductType, string> = {
  16. 1: 'bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-300',
  17. 2: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300'
  18. };
  19. const TYPE_LABEL: Record<ProductType, string> = { 1: '실물', 2: '쿠폰' };
  20. function TypeBadge({ type }: { type: ProductType })
  21. {
  22. const Icon = type === 2 ? Ticket : Package;
  23. return (
  24. <span
  25. title={TYPE_LABEL[type]}
  26. aria-label={TYPE_LABEL[type]}
  27. className={`inline-flex items-center justify-center p-1 rounded ${TYPE_BADGE_CLASS[type]}`}
  28. >
  29. <Icon className="size-3" />
  30. </span>
  31. );
  32. }
  33. function formatPrice(n: number): string
  34. {
  35. return n.toLocaleString('ko-KR');
  36. }
  37. export default function CartPage()
  38. {
  39. const router = useRouter();
  40. const { loginCheck } = useAuth();
  41. const { items, channel, updateQuantity, removeItem, setChannel, clear, totalCount, totalPrice } = useCart();
  42. const [payState] = useState<PayButtonState>('idle');
  43. const [submitError, setSubmitError] = useState<string|null>(null);
  44. const [channelModalOpen, setChannelModalOpen] = useState(false);
  45. const [optedOut, setOptedOut] = useState(false);
  46. const [pendingCheckout, setPendingCheckout] = useState(false);
  47. const requireChannel = items.some(i => i.requireDonationChannel);
  48. const handleCheckout = () => {
  49. if (!loginCheck()) {
  50. return;
  51. }
  52. if (items.length === 0) {
  53. setSubmitError('장바구니가 비어있습니다.');
  54. return;
  55. }
  56. setSubmitError(null);
  57. // 후원 채널 확인 절차 강제 — 채널 미선택 시 (필수이거나 아직 선택안함 미체크면) 모달 먼저 오픈
  58. if (!channel && (requireChannel || !optedOut)) {
  59. setPendingCheckout(true);
  60. setChannelModalOpen(true);
  61. return;
  62. }
  63. // 실제 결제는 /checkout 영수증 페이지에서 진행
  64. router.push('/checkout');
  65. };
  66. return (
  67. <div className='container mx-auto max-w-5xl px-4 py-6'>
  68. <h1 className='text-2xl font-bold mb-4'>
  69. 장바구니{' '}
  70. <span className='text-red-600 dark:text-red-400 text-xl font-extrabold'>
  71. ({totalCount.toLocaleString()}개)
  72. </span>
  73. </h1>
  74. {items.length === 0 ? (
  75. <div className='text-center py-20'>
  76. <p className='text-neutral-500 mb-4'>장바구니가 비어있습니다.</p>
  77. <Link href='/store' className='inline-block bg-blue-600 text-white px-5 py-2 rounded hover:bg-blue-700 text-sm'>
  78. 상점 둘러보기
  79. </Link>
  80. </div>
  81. ) : (
  82. <div className='grid grid-cols-1 lg:grid-cols-[1fr_360px] gap-6'>
  83. {/* 좌측: 상품 라인 리스트 */}
  84. <div className='space-y-3'>
  85. {items.map((item) => (
  86. <div
  87. key={item.productID}
  88. className='flex gap-3 p-3 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900'
  89. >
  90. <Link href={`/store/${item.productID}`} className='shrink-0'>
  91. <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'>
  92. {item.thumbnail ? (
  93. // eslint-disable-next-line @next/next/no-img-element
  94. <img src={item.thumbnail} alt={item.name} className='w-full h-full object-cover' />
  95. ) : (
  96. <span className='text-neutral-400 text-[10px]'>이미지 없음</span>
  97. )}
  98. </div>
  99. </Link>
  100. <div className='flex-1 min-w-0 flex flex-col justify-between'>
  101. <div>
  102. <div className='flex items-center gap-1.5 mb-1'>
  103. <TypeBadge type={item.type} />
  104. <Gamepad2 className='size-3.5 shrink-0 text-purple-600 dark:text-purple-400' />
  105. <span className='text-xs font-semibold text-purple-600 dark:text-purple-400 truncate'>
  106. {item.gameName}
  107. </span>
  108. </div>
  109. <Link
  110. href={`/store/${item.productID}`}
  111. className='text-sm font-semibold line-clamp-2 hover:underline'
  112. >
  113. {item.name}
  114. </Link>
  115. </div>
  116. <div className='flex items-end justify-between gap-2 mt-2 flex-wrap'>
  117. <div className='flex items-stretch border border-neutral-300 dark:border-neutral-700 rounded overflow-hidden text-sm'>
  118. <button
  119. type='button'
  120. onClick={() => { updateQuantity(item.productID, item.quantity - 1); }}
  121. className='px-2 hover:bg-neutral-100 dark:hover:bg-neutral-800'
  122. aria-label='수량 감소'
  123. >
  124. -
  125. </button>
  126. <input
  127. type='number'
  128. min={1}
  129. max={999}
  130. value={item.quantity}
  131. onChange={(e) => {
  132. const v = parseInt(e.target.value || '1', 10);
  133. updateQuantity(item.productID, isNaN(v) ? 1 : v);
  134. }}
  135. 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'
  136. />
  137. <button
  138. type='button'
  139. onClick={() => { updateQuantity(item.productID, item.quantity + 1); }}
  140. className='px-2 hover:bg-neutral-100 dark:hover:bg-neutral-800'
  141. aria-label='수량 증가'
  142. >
  143. +
  144. </button>
  145. </div>
  146. <div className='text-right'>
  147. <div className='text-xs text-neutral-500'>{formatPrice(item.price)}P × {item.quantity}</div>
  148. <div className='text-base font-bold text-red-600 dark:text-red-400'>
  149. {formatPrice(item.price * item.quantity)}P
  150. </div>
  151. </div>
  152. <button
  153. type='button'
  154. onClick={() => { removeItem(item.productID); }}
  155. className='text-xs text-neutral-500 hover:text-red-600 underline'
  156. >
  157. 삭제
  158. </button>
  159. </div>
  160. </div>
  161. </div>
  162. ))}
  163. <div className='flex justify-between items-center pt-2'>
  164. <button
  165. type='button'
  166. onClick={() => {
  167. if (confirm('장바구니를 비우시겠습니까?')) {
  168. clear();
  169. }
  170. }}
  171. className='inline-flex items-center gap-1 text-xs text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
  172. >
  173. <Trash2 className='size-3.5' aria-hidden />
  174. <span className='underline'>장바구니 비우기</span>
  175. </button>
  176. <Link href='/store' className='text-xs text-blue-600 dark:text-blue-400 hover:underline'>
  177. 쇼핑 계속하기 〉
  178. </Link>
  179. </div>
  180. </div>
  181. {/* 우측: 결제 패널 */}
  182. <div className='lg:sticky lg:top-4 lg:self-start'>
  183. <div className='border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 p-4'>
  184. <h2 className='text-sm font-bold mb-3'>주문 요약</h2>
  185. <div className='space-y-2 mb-4'>
  186. <div className='flex justify-between text-sm'>
  187. <span className='text-neutral-500'>상품 수</span>
  188. <span>{totalCount}개</span>
  189. </div>
  190. <div className='flex justify-between text-sm'>
  191. <span className='text-neutral-500'>상품 금액</span>
  192. <span>{formatPrice(totalPrice)}P</span>
  193. </div>
  194. </div>
  195. <div className='border-t border-neutral-200 dark:border-neutral-800 pt-3 mb-3'>
  196. <label className='block text-xs font-semibold mb-1.5'>후원 채널 {requireChannel ? <span className='text-amber-600 dark:text-amber-400'>(필수)</span> : '(선택)'}</label>
  197. <button
  198. type='button'
  199. onClick={() => { if (loginCheck()) { setChannelModalOpen(true); } }}
  200. 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'
  201. >
  202. {channel ? (
  203. <span className='font-semibold'>{channel.name}</span>
  204. ) : (
  205. <span className='text-neutral-400'>후원 채널 선택</span>
  206. )}
  207. </button>
  208. {channel && (
  209. <button
  210. type='button'
  211. onClick={() => { setChannel(null); }}
  212. className='text-[11px] text-neutral-500 underline mt-1'
  213. >
  214. 채널 해제
  215. </button>
  216. )}
  217. </div>
  218. <div className='border-t border-neutral-200 dark:border-neutral-800 pt-3 mb-4'>
  219. <div className='flex justify-between font-bold text-lg'>
  220. <span>결제 금액</span>
  221. <span className='text-red-600 dark:text-red-400'>{formatPrice(totalPrice)}P</span>
  222. </div>
  223. </div>
  224. {submitError && (
  225. <div className='text-red-600 text-sm mb-2'>{submitError}</div>
  226. )}
  227. <PayButton
  228. state={payState}
  229. label='전체 구매하기'
  230. icon={<CreditCard className='size-5' aria-hidden />}
  231. onClick={handleCheckout}
  232. disabled={items.length === 0}
  233. />
  234. <p className='text-xs text-neutral-500 mt-2'>
  235. * 결제 시 캐시 잔액에서 차감됩니다. (토큰 사용 불가)
  236. </p>
  237. </div>
  238. </div>
  239. </div>
  240. )}
  241. {channelModalOpen && (
  242. <ChannelSelectModal
  243. requireChannel={requireChannel}
  244. onClose={() => { setChannelModalOpen(false); setPendingCheckout(false); }}
  245. onSelect={(ch) => {
  246. setChannel(ch);
  247. setOptedOut(false);
  248. setChannelModalOpen(false);
  249. if (pendingCheckout) {
  250. setPendingCheckout(false);
  251. router.push('/checkout');
  252. }
  253. }}
  254. onSkip={() => {
  255. setChannel(null);
  256. setOptedOut(true);
  257. setChannelModalOpen(false);
  258. if (pendingCheckout) {
  259. setPendingCheckout(false);
  260. router.push('/checkout');
  261. }
  262. }}
  263. />
  264. )}
  265. </div>
  266. );
  267. }
  268. function ChannelSelectModal({ onClose, onSelect, onSkip, requireChannel }: {
  269. onClose: () => void;
  270. onSelect: (ch: ChannelSearchRow) => void;
  271. onSkip: () => void;
  272. requireChannel: boolean;
  273. })
  274. {
  275. const [keyword, setKeyword] = useState('');
  276. const [channels, setChannels] = useState<ChannelSearchRow[]>([]);
  277. const [loading, setLoading] = useState(false);
  278. useEffect(() => {
  279. const handle = setTimeout(async () => {
  280. setLoading(true);
  281. const params = new URLSearchParams();
  282. if (keyword.trim()) {
  283. params.set('keyword', keyword.trim());
  284. }
  285. else {
  286. params.set('random', 'true');
  287. }
  288. params.set('page', '1');
  289. params.set('perPage', '5');
  290. const res = await fetchApi<ChannelSearchResponse>(`/api/store/channels/search?${params.toString()}`, { silent: true });
  291. if (res.success && res.data) {
  292. setChannels(res.data.list);
  293. }
  294. setLoading(false);
  295. }, 300);
  296. return () => { clearTimeout(handle); };
  297. }, [keyword]);
  298. return (
  299. <div
  300. className='fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4'
  301. onClick={onClose}
  302. >
  303. <div
  304. className='bg-white dark:bg-neutral-900 rounded-lg w-full max-w-md max-h-[80vh] flex flex-col'
  305. onClick={(e) => { e.stopPropagation(); }}
  306. >
  307. <div className='p-4 border-b border-neutral-200 dark:border-neutral-800'>
  308. <div className='flex justify-between items-center mb-3'>
  309. <h2 className='font-bold'>후원 채널 선택</h2>
  310. <button type='button' onClick={onClose} className='text-neutral-500 hover:text-neutral-700'>✕</button>
  311. </div>
  312. <input
  313. type='search'
  314. value={keyword}
  315. onChange={(e) => { setKeyword(e.target.value); }}
  316. placeholder='채널명, 핸들, 후원 코드 검색'
  317. className='w-full border border-neutral-300 dark:border-neutral-700 rounded px-3 py-2 text-sm bg-white dark:bg-neutral-900'
  318. maxLength={100}
  319. />
  320. {!keyword.trim() ? (
  321. <p className='text-[11px] text-neutral-400 mt-1'>* 채널이 무작위 순서로 표시됩니다.</p>
  322. ) : (
  323. <p className='text-[11px] text-neutral-400 mt-1'>* 후원 코드는 정확히 입력해야 합니다.</p>
  324. )}
  325. </div>
  326. <div className='flex-1 overflow-y-auto p-2'>
  327. {loading ? (
  328. <div className='text-center py-8 text-neutral-500 text-sm'>검색 중...</div>
  329. ) : channels.length === 0 ? (
  330. <div className='text-center py-8 text-neutral-500 text-sm'>채널이 없습니다.</div>
  331. ) : (
  332. channels.map((ch) => (
  333. <button
  334. key={ch.id}
  335. type='button'
  336. onClick={() => { onSelect(ch); }}
  337. className='w-full text-left flex items-center gap-3 p-3 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded'
  338. >
  339. {ch.thumbnailUrl ? (
  340. // eslint-disable-next-line @next/next/no-img-element
  341. <img src={ch.thumbnailUrl} alt='' className='w-10 h-10 rounded-full object-cover' />
  342. ) : (
  343. <div className='w-10 h-10 rounded-full bg-neutral-300 dark:bg-neutral-700' />
  344. )}
  345. <div className='flex-1 min-w-0'>
  346. <div className='font-semibold truncate flex items-center gap-1'>
  347. {ch.name}
  348. {ch.isVerified && (
  349. <span className='text-[10px] bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300 rounded px-1'>✓</span>
  350. )}
  351. {ch.donationCode && (
  352. <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>
  353. )}
  354. </div>
  355. {ch.handle && (
  356. <div className='text-xs text-neutral-500 truncate'>{ch.handle}</div>
  357. )}
  358. </div>
  359. </button>
  360. ))
  361. )}
  362. </div>
  363. {requireChannel ? (
  364. <div className='p-3 border-t border-neutral-200 dark:border-neutral-800 text-xs text-amber-600 dark:text-amber-400'>
  365. 후원 채널 선택이 필수인 상품입니다.
  366. </div>
  367. ) : (
  368. <div className='p-3 border-t border-neutral-200 dark:border-neutral-800'>
  369. <label className='flex items-center gap-2 text-sm cursor-pointer text-neutral-600 dark:text-neutral-300'>
  370. <input type='checkbox' onChange={(e) => { if (e.target.checked) { onSkip(); } }} className='size-4' />
  371. <span>선택 안함</span>
  372. </label>
  373. </div>
  374. )}
  375. </div>
  376. </div>
  377. );
  378. }