page.tsx 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. 'use client';
  2. import { useCallback, useEffect, useMemo, useState } from 'react';
  3. import Link from 'next/link';
  4. import { Search } from 'lucide-react';
  5. import { fetchApi, getDateTime } from '@/lib/utils/client';
  6. import Loading from '@/app/component/Loading';
  7. import Pagination from '@/app/component/Pagination';
  8. import NavTabs from '../navTabs';
  9. import {
  10. ORDER_STATUS_LABEL,
  11. SHIPMENT_STATUS_LABEL,
  12. type OrderListResponse,
  13. type OrderListRow,
  14. type OrderStatus
  15. } from '@/types/store';
  16. const PER_PAGE = 20;
  17. type FilterMode = 'recent6m' | number;
  18. function statusBadgeClass(status: OrderStatus): string
  19. {
  20. if (status === 5) {
  21. return 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300';
  22. }
  23. if (status === 6) {
  24. return 'bg-neutral-200 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-300';
  25. }
  26. if (status === 7 || status === 8) {
  27. return 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300';
  28. }
  29. return 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300';
  30. }
  31. export default function OrdersPage()
  32. {
  33. const [orders, setOrders] = useState<OrderListRow[]>([]);
  34. const [total, setTotal] = useState(0);
  35. const [page, setPage] = useState(1);
  36. const [loading, setLoading] = useState(true);
  37. const [filterMode, setFilterMode] = useState<FilterMode>('recent6m');
  38. const [keywordInput, setKeywordInput] = useState('');
  39. const [appliedKeyword, setAppliedKeyword] = useState('');
  40. const yearChips = useMemo(() => {
  41. const current = new Date().getFullYear();
  42. return Array.from({ length: 6 }, (_, i) => current - i);
  43. }, []);
  44. const load = useCallback(async () => {
  45. setLoading(true);
  46. const params = new URLSearchParams();
  47. params.set('page', String(page));
  48. params.set('perPage', String(PER_PAGE));
  49. if (typeof filterMode === 'number') {
  50. params.set('year', String(filterMode));
  51. }
  52. if (appliedKeyword) {
  53. params.set('keyword', appliedKeyword);
  54. }
  55. const res = await fetchApi<OrderListResponse>(`/api/store/orders?${params.toString()}`, { silent: true });
  56. if (res.success && res.data) {
  57. setOrders(res.data.list);
  58. setTotal(res.data.total);
  59. }
  60. else {
  61. setOrders([]);
  62. setTotal(0);
  63. }
  64. setLoading(false);
  65. }, [filterMode, appliedKeyword, page]);
  66. useEffect(() => {
  67. load();
  68. }, [load]);
  69. useEffect(() => {
  70. setPage(1);
  71. }, [filterMode, appliedKeyword]);
  72. const handleSearch = () => {
  73. setAppliedKeyword(keywordInput.trim());
  74. };
  75. const handleKeywordKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
  76. if (e.key === 'Enter') {
  77. e.preventDefault();
  78. handleSearch();
  79. }
  80. };
  81. const handleChipClick = (mode: FilterMode) => {
  82. setFilterMode(mode);
  83. };
  84. const chipClass = (active: boolean): string => active
  85. ? 'border border-blue-600 bg-blue-600 text-white'
  86. : 'border border-neutral-300 text-neutral-700 hover:bg-neutral-50 dark:border-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-800';
  87. return (
  88. <>
  89. <NavTabs />
  90. <div className="px-4 pb-8 sm:px-8">
  91. <h1 className="text-2xl font-bold mb-4">주문목록</h1>
  92. {/* 상품 검색 */}
  93. <div className="relative mb-3 w-full sm:max-w-md">
  94. <input
  95. type="search"
  96. value={keywordInput}
  97. onChange={(e) => { setKeywordInput(e.target.value); }}
  98. onKeyDown={handleKeywordKeyDown}
  99. placeholder="주문한 상품을 검색할 수 있어요!"
  100. className="w-full border border-neutral-300 dark:border-neutral-700 rounded-md px-4 py-2.5 pr-10 text-sm bg-white dark:bg-neutral-900"
  101. maxLength={100}
  102. />
  103. <button
  104. type="button"
  105. onClick={handleSearch}
  106. aria-label="검색"
  107. className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 text-neutral-400 hover:text-blue-600"
  108. >
  109. <Search className="size-5" />
  110. </button>
  111. </div>
  112. {/* 기간 필터 칩 */}
  113. <div className="flex flex-wrap gap-1.5 mb-4">
  114. <button
  115. type="button"
  116. onClick={() => { handleChipClick('recent6m'); }}
  117. className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${chipClass(filterMode === 'recent6m')}`}
  118. >
  119. 최근 6개월
  120. </button>
  121. {yearChips.map((y) => (
  122. <button
  123. key={y}
  124. type="button"
  125. onClick={() => { handleChipClick(y); }}
  126. className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${chipClass(filterMode === y)}`}
  127. >
  128. {y}
  129. </button>
  130. ))}
  131. </div>
  132. <div className="text-sm text-neutral-500 mb-3">총 {total.toLocaleString()}건</div>
  133. {loading ? (
  134. <Loading />
  135. ) : orders.length === 0 ? (
  136. <div className="text-center py-20 text-neutral-500">주문 내역이 없습니다.</div>
  137. ) : (
  138. <>
  139. <div className="space-y-3">
  140. {orders.map((o) => (
  141. <div key={o.id} className="border border-neutral-200 dark:border-neutral-800 rounded-lg p-4 bg-white dark:bg-neutral-900">
  142. <div className="flex items-center justify-between mb-3 pb-2 border-b border-neutral-100 dark:border-neutral-800">
  143. <Link
  144. href={`/orders/${o.id}`}
  145. className="text-xs font-mono font-semibold text-blue-600 dark:text-blue-400 hover:underline"
  146. >
  147. {o.orderNumber}
  148. </Link>
  149. <div className="flex items-center gap-1.5">
  150. <span className={`px-2 py-0.5 rounded text-xs font-semibold ${statusBadgeClass(o.status)}`}>
  151. {ORDER_STATUS_LABEL[o.status]}
  152. </span>
  153. {o.shipmentStatus !== null && o.shipmentStatus !== undefined && (
  154. <span className="px-2 py-0.5 rounded text-xs bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300">
  155. 배송 {SHIPMENT_STATUS_LABEL[o.shipmentStatus]}
  156. </span>
  157. )}
  158. {o.hasActiveRefundRequest && (
  159. <span className="px-2 py-0.5 rounded text-xs bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300">
  160. 환불 요청 진행중
  161. </span>
  162. )}
  163. </div>
  164. </div>
  165. <Link href={`/orders/${o.id}`} className="flex items-center gap-4 hover:bg-neutral-50 dark:hover:bg-neutral-800/40 rounded p-1 -m-1">
  166. <div className="w-20 h-20 bg-neutral-100 dark:bg-neutral-800 rounded overflow-hidden flex items-center justify-center flex-shrink-0">
  167. {o.firstItemThumbnail ? (
  168. // eslint-disable-next-line @next/next/no-img-element
  169. <img src={o.firstItemThumbnail} alt="" className="w-full h-full object-cover" />
  170. ) : (
  171. <span className="text-neutral-400 text-xs">no img</span>
  172. )}
  173. </div>
  174. <div className="flex-1 min-w-0">
  175. <div className="text-sm font-semibold truncate">
  176. {o.firstItemName}
  177. {o.itemCount > 1 && <span className="text-neutral-500 font-normal"> 외 {o.itemCount - 1}건</span>}
  178. </div>
  179. <div className="text-xs text-neutral-500 mt-1">
  180. 총 {o.itemCount}개 상품
  181. {o.channelName && <span className="ml-2">• 후원 {o.channelName}</span>}
  182. </div>
  183. <div className="text-xs text-neutral-500 mt-1">
  184. 주문 {getDateTime(o.createdAt)}
  185. </div>
  186. </div>
  187. <div className="text-right flex-shrink-0">
  188. <div className="text-xs text-neutral-500">결제 금액</div>
  189. <div className="text-lg font-bold text-red-600 dark:text-red-400">
  190. {o.totalAmount.toLocaleString()}P
  191. </div>
  192. </div>
  193. </Link>
  194. </div>
  195. ))}
  196. </div>
  197. <Pagination total={total} page={page} perPage={PER_PAGE} onChange={setPage} />
  198. </>
  199. )}
  200. </div>
  201. </>
  202. );
  203. }