diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index c7a9c2e..4d9c985 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -4,6 +4,7 @@ on: push: branches: - BVFH-93-develop + - BVFH-361-mypage env: AWS_REGION: ap-northeast-2 diff --git a/package.json b/package.json index 3f93b77..58bc229 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,8 @@ "@tanstack/react-query-devtools": "^5.77.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "date-fns-tz": "^3.2.0", "embla-carousel": "^8.6.0", "embla-carousel-react": "^8.6.0", "framer-motion": "^12.12.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dd5275d..d64ab5f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,12 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + date-fns: + specifier: ^4.1.0 + version: 4.1.0 + date-fns-tz: + specifier: ^3.2.0 + version: 3.2.0(date-fns@4.1.0) embla-carousel: specifier: ^8.6.0 version: 8.6.0 @@ -108,7 +114,6 @@ importers: '@types/react-dom': specifier: ^19 version: 19.1.5(@types/react@19.1.4) - commitizen: specifier: ^4.3.1 version: 4.3.1(@types/node@20.17.46)(typescript@5.8.3) @@ -2114,6 +2119,14 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} + date-fns-tz@3.2.0: + resolution: {integrity: sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==} + peerDependencies: + date-fns: ^3.0.0 || ^4.0.0 + + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + debounce-fn@6.0.0: resolution: {integrity: sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ==} engines: {node: '>=18'} @@ -2482,10 +2495,6 @@ packages: resolution: {integrity: sha512-VARTJ9CYeuQYb0pZEPbzi740OWFgpHe7AYJ2WFZVnUDUQp5Dk2yJUgF36YsZ81cOyxT0QxmXD2EQpapAouzWVA==} engines: {node: '>=18.0.0'} - eventsource@2.0.2: - resolution: {integrity: sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==} - engines: {node: '>=12.0.0'} - eventsource@3.0.7: resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} engines: {node: '>=18.0.0'} @@ -2538,10 +2547,6 @@ packages: fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} - faye-websocket@0.11.4: - resolution: {integrity: sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==} - engines: {node: '>=0.8.0'} - fdir@6.4.4: resolution: {integrity: sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==} peerDependencies: @@ -2794,9 +2799,6 @@ packages: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} - http-parser-js@0.5.10: - resolution: {integrity: sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==} - http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -3733,9 +3735,6 @@ packages: resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} engines: {node: '>=0.6'} - querystringify@2.2.0: - resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} - queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -3820,9 +3819,6 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} - requires-port@1.0.0: - resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} - resolve-dir@1.0.1: resolution: {integrity: sha512-R7uiTjECzvOsWSfdM0QKFNBVFcK27aHOUwdvK53BcW8zqnGdYp0Fbj82cy54+2A4P2tFM22J5kRfe1R+lM/1yg==} engines: {node: '>=0.10.0'} @@ -4313,9 +4309,6 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - url-parse@1.5.10: - resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} - user-home@2.0.0: resolution: {integrity: sha512-KMWqdlOcjCYdtIJpicDSFBQ8nFwS2i9sslAd6f4+CBGcU4gist2REnr2fxj2YocvJFxSF3ZOHLYLVZnUxv4BZQ==} engines: {node: '>=0.10.0'} @@ -4338,14 +4331,6 @@ packages: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} - websocket-driver@0.7.4: - resolution: {integrity: sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==} - engines: {node: '>=0.8.0'} - - websocket-extensions@0.1.4: - resolution: {integrity: sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==} - engines: {node: '>=0.8.0'} - when-exit@2.1.4: resolution: {integrity: sha512-4rnvd3A1t16PWzrBUcSDZqcAmsUIy4minDXT/CZ8F2mVDgd65i4Aalimgz1aQkRGU0iH5eT5+6Rx2TK8o443Pg==} @@ -5912,7 +5897,6 @@ snapshots: dependencies: csstype: 3.1.3 - '@typescript-eslint/eslint-plugin@8.32.1(@typescript-eslint/parser@8.32.1(eslint@9.26.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.26.0(jiti@2.4.2))(typescript@5.8.3)': dependencies: '@eslint-community/regexpp': 4.12.1 @@ -6629,6 +6613,12 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 + date-fns-tz@3.2.0(date-fns@4.1.0): + dependencies: + date-fns: 4.1.0 + + date-fns@4.1.0: {} + debounce-fn@6.0.0: dependencies: mimic-function: 5.0.1 @@ -7101,8 +7091,6 @@ snapshots: eventsource-parser@3.0.1: {} - eventsource@2.0.2: {} - eventsource@3.0.7: dependencies: eventsource-parser: 3.0.1 @@ -7195,10 +7183,6 @@ snapshots: dependencies: reusify: 1.1.0 - faye-websocket@0.11.4: - dependencies: - websocket-driver: 0.7.4 - fdir@6.4.4(picomatch@4.0.2): optionalDependencies: picomatch: 4.0.2 @@ -7479,8 +7463,6 @@ snapshots: statuses: 2.0.1 toidentifier: 1.0.1 - http-parser-js@0.5.10: {} - http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.3 @@ -8379,8 +8361,6 @@ snapshots: dependencies: side-channel: 1.1.0 - querystringify@2.2.0: {} - queue-microtask@1.2.3: {} range-parser@1.2.1: {} @@ -8476,8 +8456,6 @@ snapshots: require-from-string@2.0.2: {} - requires-port@1.0.0: {} - resolve-dir@1.0.1: dependencies: expand-tilde: 2.0.2 @@ -8716,8 +8694,6 @@ snapshots: dot-case: 3.0.4 tslib: 2.8.1 - - socks-proxy-agent@8.0.5: dependencies: agent-base: 7.1.3 @@ -9064,11 +9040,6 @@ snapshots: dependencies: punycode: 2.3.1 - url-parse@1.5.10: - dependencies: - querystringify: 2.2.0 - requires-port: 1.0.0 - user-home@2.0.0: dependencies: os-homedir: 1.0.2 @@ -9085,14 +9056,6 @@ snapshots: web-streams-polyfill@3.3.3: {} - websocket-driver@0.7.4: - dependencies: - http-parser-js: 0.5.10 - safe-buffer: 5.2.1 - websocket-extensions: 0.1.4 - - websocket-extensions@0.1.4: {} - when-exit@2.1.4: {} which-boxed-primitive@1.1.1: diff --git a/src/actions/auction-service/create-bid.ts b/src/actions/auction-service/create-bid.ts index 6e3257f..8502d1a 100644 --- a/src/actions/auction-service/create-bid.ts +++ b/src/actions/auction-service/create-bid.ts @@ -12,15 +12,16 @@ export async function createBid(auctionUuid: string, bidAmount: number) { // ); // return response; + const requestId = crypto.randomUUID(); try { const response = await instance.post( `/auction-service/api/v2/auctions/${auctionUuid}/bidders`, { bidAmount, + requestId, }, ); - console.log(response, 'response'); return response; } catch (error) { return error as ErrorResponse; diff --git a/src/actions/auth-service/sign-out.ts b/src/actions/auth-service/sign-out.ts new file mode 100644 index 0000000..c128e2f --- /dev/null +++ b/src/actions/auth-service/sign-out.ts @@ -0,0 +1,18 @@ +'use server'; + +import { cookies } from 'next/headers'; + +export async function signOut() { + const cookieStore = await cookies(); + + // try { + // await instance.get('/auth-service/api/v1/auth/sign-out'); + // } catch (error) { + // console.error('Sign out API failed:', error); + // } + // console.log('signOut 성공'); + + cookieStore.delete('accessToken'); + cookieStore.delete('refreshToken'); + cookieStore.delete('memberUuid'); +} diff --git a/src/actions/chat-service/get-unread-chat-count.ts b/src/actions/chat-service/get-unread-chat-count.ts new file mode 100644 index 0000000..793367b --- /dev/null +++ b/src/actions/chat-service/get-unread-chat-count.ts @@ -0,0 +1,10 @@ +'use server'; + +import { instance } from '@/actions/instance'; + +export async function getUnreadChatCount(): Promise { + const response = await instance.get( + '/chat-service/api/v1/chatroom-summary/unread-count', + ); + return response; +} diff --git a/src/actions/grade-service/get-grade-info.ts b/src/actions/grade-service/get-grade-info.ts index 6932afe..d7cea7e 100644 --- a/src/actions/grade-service/get-grade-info.ts +++ b/src/actions/grade-service/get-grade-info.ts @@ -10,6 +10,7 @@ export async function getGradeInfo(gradeUuid: string): Promise { next: { revalidate: Infinity, }, + cache: 'force-cache', }, ); return response; diff --git a/src/actions/member-service/get-my-info.ts b/src/actions/member-service/get-my-info.ts index 50711b8..11d3ec6 100644 --- a/src/actions/member-service/get-my-info.ts +++ b/src/actions/member-service/get-my-info.ts @@ -4,9 +4,5 @@ import { MemberInfo } from '@/types/member'; import { instance } from '@/actions/instance'; export async function getMyInfo(): Promise { - return await instance.get('/member-service/api/v1/member', { - next: { - revalidate: 60 * 60, - }, - }); + return await instance.get('/member-service/api/v1/member'); } diff --git a/src/actions/product-service/complete-product-transaction.ts b/src/actions/product-service/complete-product-transaction.ts new file mode 100644 index 0000000..b30c328 --- /dev/null +++ b/src/actions/product-service/complete-product-transaction.ts @@ -0,0 +1,10 @@ +'use server'; + +import { instance } from '@/actions/instance'; +import { CompleteProductTransactionType } from '@/types/product/complete-product-transaction-type'; + +export async function completeProductTransaction( + data: CompleteProductTransactionType, +) { + return await instance.post(`/api/v1/product/complete`, data); +} diff --git a/src/actions/review-service/index.ts b/src/actions/review-service/index.ts new file mode 100644 index 0000000..ece0512 --- /dev/null +++ b/src/actions/review-service/index.ts @@ -0,0 +1,2 @@ +export * from './send-review-buyer-to-seller'; +export * from './send-review-seller-to-buyer'; diff --git a/src/actions/review-service/send-review-buyer-to-seller.ts b/src/actions/review-service/send-review-buyer-to-seller.ts new file mode 100644 index 0000000..1dde6da --- /dev/null +++ b/src/actions/review-service/send-review-buyer-to-seller.ts @@ -0,0 +1,8 @@ +'use server'; + +import { instance } from '@/actions/instance'; +import { ReviewData } from '@/types/review'; + +export async function sendReviewBuyerToSeller(reviewData: ReviewData) { + await instance.post('/review-service/api/v1/buyer', reviewData); +} diff --git a/src/actions/review-service/send-review-seller-to-buyer.ts b/src/actions/review-service/send-review-seller-to-buyer.ts new file mode 100644 index 0000000..cb9e58e --- /dev/null +++ b/src/actions/review-service/send-review-seller-to-buyer.ts @@ -0,0 +1,8 @@ +'use server'; + +import { instance } from '@/actions/instance'; +import { ReviewData } from '@/types/review'; + +export async function sendReviewSellerToBuyer(reviewData: ReviewData) { + await instance.post('/review-service/api/v1/seller', reviewData); +} diff --git a/src/actions/search-service/get-products.ts b/src/actions/search-service/get-products.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/actions/search-service/index.ts b/src/actions/search-service/index.ts index 5d67d8e..ac0a731 100644 --- a/src/actions/search-service/index.ts +++ b/src/actions/search-service/index.ts @@ -1,3 +1,4 @@ export { getAuctions } from './get-auctions'; export { getSearchSuggestions } from './get-search-suggestions'; export { searchAuctions } from './search-auctions'; +export { searchProducts } from './search-products'; diff --git a/src/actions/search-service/search-products.ts b/src/actions/search-service/search-products.ts new file mode 100644 index 0000000..8a2f8f1 --- /dev/null +++ b/src/actions/search-service/search-products.ts @@ -0,0 +1,82 @@ +'use server'; + +import { instance } from '@/actions/instance'; +import { + SearchProductRequest, + SearchProductResponse, + SearchAfterProductCursor, +} from '@/types/product/search-product-type'; + +export async function searchProducts( + request: SearchProductRequest, +): Promise { + const queryParams = buildSearchQueryParams(request); + + const response = await instance.get( + `/search-service/search-service/api/v1/product/search?${queryParams.toString()}`, + ); + return response; +} + +function appendSearchAfterParams( + queryParams: URLSearchParams, + cursor: SearchAfterProductCursor, + sortBy: string, +) { + if (sortBy === 'priceHigh' || sortBy === 'priceLow') { + if (cursor.lastProductPrice !== undefined) { + queryParams.append('searchAfter1', cursor.lastProductPrice.toString()); + } + } else if (sortBy === 'recommended') { + if (cursor.lastProductViewCount !== undefined) { + queryParams.append( + 'searchAfter1', + cursor.lastProductViewCount.toString(), + ); + } + } else { + if (cursor.lastProductCreatedAt) { + queryParams.append('searchAfter1', cursor.lastProductCreatedAt); + } + } + + if (cursor.lastProductUuid) { + queryParams.append('searchAfter2', cursor.lastProductUuid); + } +} + +function buildSearchQueryParams( + request: SearchProductRequest, +): URLSearchParams { + const queryParams = new URLSearchParams(); + + if (request.productTitle) { + queryParams.append('productTitle', request.productTitle); + } + if (request.categoryName) { + queryParams.append('categoryName', request.categoryName); + } + if (request.tagNames && request.tagNames.length > 0) { + request.tagNames.forEach((tag) => queryParams.append('tagNames', tag)); + } + if (request.productCondition) { + queryParams.append('productCondition', request.productCondition); + } + + const sortBy = request.sortBy || 'latest'; + queryParams.append('sortBy', sortBy); + + if (request.directDeal !== undefined) { + queryParams.append('directDeal', request.directDeal.toString()); + } + + if (request.searchAfter1 || request.searchAfter2) { + const cursor: SearchAfterProductCursor = { + lastProductCreatedAt: request.searchAfter1, + lastProductUuid: request.searchAfter2, + }; + appendSearchAfterParams(queryParams, cursor, sortBy); + } + + return queryParams; +} diff --git a/src/app/(auth)/sign-in/page.tsx b/src/app/(auth)/sign-in/page.tsx index b0e5ada..8f1f5a6 100644 --- a/src/app/(auth)/sign-in/page.tsx +++ b/src/app/(auth)/sign-in/page.tsx @@ -4,7 +4,7 @@ import { SignupLink } from '@/components/sign-in/SignupLink'; export default function SignInPage() { return ( -
+
diff --git a/src/app/(mainLayout)/auctions/AuctionContent.tsx b/src/app/(mainLayout)/auctions/AuctionContent.tsx index 74ff0db..e2fd99c 100644 --- a/src/app/(mainLayout)/auctions/AuctionContent.tsx +++ b/src/app/(mainLayout)/auctions/AuctionContent.tsx @@ -27,7 +27,7 @@ export default function AuctionContent({ categories }: AuctionContentProps) { error, loadMoreRef, } = useSearchInfiniteScroll({ - searchQuery: '', // 빈 문자열로 모든 경매 조회 + searchQuery: '', filters: { sortBy: sortBy ? sortBy : 'latest', categoryName: categoryName ? decodeURIComponent(categoryName) : undefined, @@ -46,7 +46,6 @@ export default function AuctionContent({ categories }: AuctionContentProps) { return ( <> - {/* 카테고리 필터 슬라이더 */} {categoryName && ( @@ -87,8 +86,7 @@ export default function AuctionContent({ categories }: AuctionContentProps) { status: auction.status || 'active', viewCount: auction.viewCount, thumbnailUrl: auction.thumbnailUrl, - likes: 0, // API에서 제공하지 않으므로 기본값 - bidderCount: 0, // API에서 제공하지 않으므로 기본값 + bidAmount: auction.currentBid, }))} LikeButtonComponent={LikeButton} diff --git a/src/app/(mainLayout)/layout.tsx b/src/app/(mainLayout)/layout.tsx index bf19d24..ddf97cc 100644 --- a/src/app/(mainLayout)/layout.tsx +++ b/src/app/(mainLayout)/layout.tsx @@ -4,7 +4,7 @@ export default function layout({ children }: { children: React.ReactNode }) { return (
-
{children}
+
{children}
); diff --git a/src/app/(mainLayout)/products/ProductContent.tsx b/src/app/(mainLayout)/products/ProductContent.tsx new file mode 100644 index 0000000..3c39086 --- /dev/null +++ b/src/app/(mainLayout)/products/ProductContent.tsx @@ -0,0 +1,103 @@ +'use client'; + +import { useSearchParams } from 'next/navigation'; +import { ProductGrid, ProductCardSkeleton } from '@/components/product'; +import { CategoryFilterSlider } from '@/components/category'; +import useSearchProductInfiniteScroll from '@/hooks/use-search-product-infinite-scroll'; +import { CategoryType } from '@/actions/category-service/get-categories'; + +interface ProductContentProps { + categories: CategoryType[]; +} + +export default function ProductContent({ categories }: ProductContentProps) { + const searchParams = useSearchParams(); + const categoryName = searchParams.get('categoryName'); + const sortBy = searchParams.get('sortBy'); + + const { + data: products, + isLoading, + isLoadingMore, + hasNextPage, + error, + loadMoreRef, + } = useSearchProductInfiniteScroll({ + searchQuery: '', + filters: { + sortBy: sortBy ? sortBy : 'latest', + categoryName: categoryName ? decodeURIComponent(categoryName) : undefined, + }, + enabled: true, + }); + + if (error) { + return ( +
+
오류가 발생했습니다
+
{error}
+
+ ); + } + + return ( + <> + + + {categoryName && ( +
+

+ {decodeURIComponent(categoryName)} +

+
+ )} + + {isLoading && products.length === 0 ? ( +
+ {Array.from({ length: 8 }).map((_, index) => ( + + ))} +
+ ) : ( + <> + {products.length === 0 && !isLoading ? ( +
+
+ {categoryName + ? `${decodeURIComponent(categoryName)} 카테고리에 상품이 없습니다` + : '상품이 없습니다'} +
+
+ 나중에 다시 확인해주세요 +
+
+ ) : ( + ({ + product: product, + }))} + /> + )} + + )} + + {hasNextPage && ( +
+ {isLoadingMore && ( +
+ )} +
+ )} + + {!hasNextPage && !isLoadingMore && products.length > 0 && ( +
+

+ {categoryName + ? `${decodeURIComponent(categoryName)} 카테고리의 모든 상품을 불러왔습니다` + : '모든 상품을 불러왔습니다'} +

+
+ )} + + ); +} diff --git a/src/app/(mainLayout)/products/page.tsx b/src/app/(mainLayout)/products/page.tsx index 9a71620..2c1a44d 100644 --- a/src/app/(mainLayout)/products/page.tsx +++ b/src/app/(mainLayout)/products/page.tsx @@ -1,3 +1,8 @@ +import { getCategories } from '@/actions/category-service/get-categories'; +import ProductContent from './ProductContent'; + export default async function ProductsPage() { - return
; + const categories = await getCategories(); + + return ; } diff --git a/src/app/(minimalLayout)/chat/[id]/page.tsx b/src/app/(minimalLayout)/chat/[id]/page.tsx index b3eaee7..f7babb6 100644 --- a/src/app/(minimalLayout)/chat/[id]/page.tsx +++ b/src/app/(minimalLayout)/chat/[id]/page.tsx @@ -25,7 +25,11 @@ export default async function ChatRoomPage({ return (
- +
+
-
+
{children}
- +
); } diff --git a/src/app/(minimalLayout)/search/[result]/page.tsx b/src/app/(minimalLayout)/search/[result]/page.tsx index 479d78b..821ba2f 100644 --- a/src/app/(minimalLayout)/search/[result]/page.tsx +++ b/src/app/(minimalLayout)/search/[result]/page.tsx @@ -145,8 +145,6 @@ export default function SearchResultPage() { status={auction.status || 'active'} viewCount={auction.viewCount} thumbnailUrl={auction.thumbnailUrl} - likes={0} - bidderCount={0} bidAmount={auction.currentBid} LikeButtonComponent={LikeButton} /> diff --git a/src/components/auction/detail/AuctionTags.tsx b/src/app/(subLayout)/auctions/[id]/components/AuctionTags.tsx similarity index 100% rename from src/components/auction/detail/AuctionTags.tsx rename to src/app/(subLayout)/auctions/[id]/components/AuctionTags.tsx diff --git a/src/components/auction/detail/BidderForm.tsx b/src/app/(subLayout)/auctions/[id]/components/BidderForm.tsx similarity index 54% rename from src/components/auction/detail/BidderForm.tsx rename to src/app/(subLayout)/auctions/[id]/components/BidderForm.tsx index 95f9bd2..e1c6402 100644 --- a/src/components/auction/detail/BidderForm.tsx +++ b/src/app/(subLayout)/auctions/[id]/components/BidderForm.tsx @@ -1,11 +1,11 @@ 'use client'; -import { useState, useRef } from 'react'; +import { useState } from 'react'; import { Button } from '@/components/ui'; import { Dialog } from '@/components/ui/dialog'; -import { createBid } from '@/actions/auction-service/create-bid'; import { motion, AnimatePresence } from 'framer-motion'; import { useRouter } from 'next/navigation'; -import { isErrorResponse } from '@/utils/type-guards'; +import { useAlert } from '@/contexts/AlertContext'; +import { useBidderForm } from '../hooks'; import { BidderDialogHeader, BidderAgreementStep, @@ -25,84 +25,56 @@ export function BidderForm({ status, }: BidderFormProps) { const router = useRouter(); + const { showConfirm } = useAlert(); const [open, setOpen] = useState(false); const [step, setStep] = useState(1); - const [inputAmount, setInputAmount] = useState(String(bidAmount)); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); const [success, setSuccess] = useState(false); - const inputRef = useRef(null); + + const { + inputAmount, + loading, + error, + inputRef, + isAmountInvalid, + handleInputChange, + handlePercent, + handleClear, + submitBid, + reset, + } = useBidderForm({ + auctionUuid, + bidAmount, + onSuccess: () => { + setSuccess(true); + router.refresh(); + }, + }); const handleClose = () => { + reset(); setOpen(false); setStep(1); - setInputAmount(String(bidAmount)); - setError(''); setSuccess(false); }; const handleBid = async () => { const numAmount = parseInt(inputAmount, 10); - if (numAmount < bidAmount) { - setError('입찰 금액은 현재 입찰가 이상이어야 합니다.'); - return; - } - if (numAmount > Math.floor(bidAmount * 1.3)) { - setError('입찰 금액은 현재 입찰가의 30%를 초과할 수 없습니다.'); - return; - } - if (!window.confirm('입찰하시겠습니까?')) return; - setLoading(true); - setError(''); - try { - const response = await createBid(auctionUuid, numAmount); - if (isErrorResponse(response)) { - setError(response.message); - return; - } - setSuccess(true); - router.refresh(); - } catch (error) { - setError( - isErrorResponse(error) - ? error.message - : '입찰에 실패했습니다. 다시 시도해주세요.', - ); - } finally { - setLoading(false); - } - }; - const handlePercent = (percent: number) => { - const newAmount = Math.floor(bidAmount * (1 + percent / 100)); - setInputAmount(String(newAmount)); - setTimeout(() => { - inputRef.current?.blur(); - }, 0); - }; + const confirmed = await showConfirm( + 'info', + '입찰하기', + `입찰 후 취소가 불가능 합니다.`, + { + confirmText: '확인했습니다', + cancelText: '나중에 하기', + }, + ); - const handleInputChange = (e: React.ChangeEvent) => { - let value = e.target.value.replace(/^0+/, ''); - if (!value) value = '0'; - let num = parseInt(value, 10); - if (isNaN(num) || num < 0) num = 0; - setInputAmount(String(num)); - setError(''); - }; + if (!confirmed) return; - const handleClear = () => { - setInputAmount('0'); - setTimeout(() => { - inputRef.current?.focus(); - }, 0); + await submitBid(numAmount); }; - const numAmount = parseInt(inputAmount, 10); - const isAmountInvalid = - inputAmount === '' || - isNaN(numAmount) || - numAmount < bidAmount || - numAmount > Math.floor(bidAmount * 1.3); const bidButtonDisabled = loading || isAmountInvalid; if (status !== 'active') { @@ -110,9 +82,14 @@ export function BidderForm({ } return ( -
+
-
diff --git a/src/components/auction/detail/BiddersSection.tsx b/src/app/(subLayout)/auctions/[id]/components/BiddersSection.tsx similarity index 85% rename from src/components/auction/detail/BiddersSection.tsx rename to src/app/(subLayout)/auctions/[id]/components/BiddersSection.tsx index 1a345a9..fbc6fec 100644 --- a/src/components/auction/detail/BiddersSection.tsx +++ b/src/app/(subLayout)/auctions/[id]/components/BiddersSection.tsx @@ -1,9 +1,9 @@ import { AuctionBidders } from '@/types/auction'; -import { BidderList } from './BidderList'; +import { BidderList } from './bidder-section/BidderList'; export function BiddersSection({ bidders }: { bidders: AuctionBidders[] }) { return ( -
+

입찰 현황

diff --git a/src/components/auction/detail/bidder-form/BidderAgreementStep.tsx b/src/app/(subLayout)/auctions/[id]/components/bidder-form/BidderAgreementStep.tsx similarity index 100% rename from src/components/auction/detail/bidder-form/BidderAgreementStep.tsx rename to src/app/(subLayout)/auctions/[id]/components/bidder-form/BidderAgreementStep.tsx diff --git a/src/components/auction/detail/bidder-form/BidderDialogHeader.tsx b/src/app/(subLayout)/auctions/[id]/components/bidder-form/BidderDialogHeader.tsx similarity index 100% rename from src/components/auction/detail/bidder-form/BidderDialogHeader.tsx rename to src/app/(subLayout)/auctions/[id]/components/bidder-form/BidderDialogHeader.tsx diff --git a/src/components/auction/detail/bidder-form/BidderFormStep.tsx b/src/app/(subLayout)/auctions/[id]/components/bidder-form/BidderFormStep.tsx similarity index 92% rename from src/components/auction/detail/bidder-form/BidderFormStep.tsx rename to src/app/(subLayout)/auctions/[id]/components/bidder-form/BidderFormStep.tsx index 0efea3b..52c70b7 100644 --- a/src/components/auction/detail/bidder-form/BidderFormStep.tsx +++ b/src/app/(subLayout)/auctions/[id]/components/bidder-form/BidderFormStep.tsx @@ -3,6 +3,7 @@ import { motion } from 'framer-motion'; import { Button } from '@/components/ui'; import { FilledInput } from '@/components/ui/filled-input'; +import { AuctionLoading } from '@/components/common/AuctionLoading'; import { formatNumber } from '@/utils/format'; type BidderFormStepProps = { @@ -32,6 +33,14 @@ export function BidderFormStep({ bidButtonText, bidButtonDisabled, }: BidderFormStepProps) { + if (loading) { + return ( +
+ +
+ ); + } + return (
diff --git a/src/components/auction/detail/BidderList.tsx b/src/app/(subLayout)/auctions/[id]/components/bidder-section/BidderList.tsx similarity index 93% rename from src/components/auction/detail/BidderList.tsx rename to src/app/(subLayout)/auctions/[id]/components/bidder-section/BidderList.tsx index ba2597a..bf53921 100644 --- a/src/components/auction/detail/BidderList.tsx +++ b/src/app/(subLayout)/auctions/[id]/components/bidder-section/BidderList.tsx @@ -2,8 +2,6 @@ import { AuctionBidders } from '@/types/auction'; import { BidderCard } from './BidderCard'; export function BidderList({ bidders }: { bidders: AuctionBidders[] }) { - console.log(bidders); - return (
    {bidders.map((bidder) => ( diff --git a/src/app/(subLayout)/auctions/[id]/components/bidder-section/index.ts b/src/app/(subLayout)/auctions/[id]/components/bidder-section/index.ts new file mode 100644 index 0000000..d2ad3ed --- /dev/null +++ b/src/app/(subLayout)/auctions/[id]/components/bidder-section/index.ts @@ -0,0 +1,2 @@ +export * from './BidderCard'; +export * from './BidderList'; diff --git a/src/app/(subLayout)/auctions/[id]/components/index.ts b/src/app/(subLayout)/auctions/[id]/components/index.ts new file mode 100644 index 0000000..61655fc --- /dev/null +++ b/src/app/(subLayout)/auctions/[id]/components/index.ts @@ -0,0 +1,3 @@ +export { BidderForm } from './BidderForm'; +export { BiddersSection } from './BiddersSection'; +export { AuctionTags } from './AuctionTags'; diff --git a/src/app/(subLayout)/auctions/[id]/error.tsx b/src/app/(subLayout)/auctions/[id]/error.tsx new file mode 100644 index 0000000..9d3a8ac --- /dev/null +++ b/src/app/(subLayout)/auctions/[id]/error.tsx @@ -0,0 +1,97 @@ +'use client'; + +import { useEffect } from 'react'; +import { motion } from 'framer-motion'; +import Penguin from '@/assets/icons/common/penguin.svg'; + +interface AuctionErrorProps { + error: Error & { digest?: string }; +} + +export default function AuctionError({ error }: AuctionErrorProps) { + useEffect(() => { + console.error('Auction Page Error:', error); + }, [error]); + + const getErrorContent = (error: Error) => { + const errorMessage = error.message.toLowerCase(); + + if (errorMessage.includes('404') || errorMessage.includes('not found')) { + return { + title: '경매를 찾을 수 없어요', + message: + '요청하신 경매가 존재하지 않거나 삭제되었을 수 있어요.\n다른 경매를 찾아보시는 건 어떨까요?', + showRetry: false, + }; + } + + if ( + errorMessage.includes('500') || + errorMessage.includes('internal server') + ) { + return { + title: '경매 정보를 불러올 수 없어요', + message: + '서버에서 일시적인 문제가 발생했어요.\n잠시 후 다시 시도해주세요.', + showRetry: true, + }; + } + + if (errorMessage.includes('timeout')) { + return { + title: '요청 시간이 초과되었어요', + message: '네트워크 상태를 확인하고\n다시 시도해주세요.', + showRetry: true, + }; + } + + if (errorMessage.includes('network')) { + return { + title: '네트워크 연결에 문제가 있어요', + message: '인터넷 연결을 확인하고\n다시 시도해주세요.', + showRetry: true, + }; + } + + return { + title: '경매 정보를 불러올 수 없어요', + message: '예상치 못한 문제가 발생했어요.\n잠시 후 다시 시도해주세요.', + showRetry: true, + }; + }; + + const errorContent = getErrorContent(error); + + return ( +
    +
    +
    +
    + + + +
    + +

    + {errorContent.title} +

    + +

    + {errorContent.message} +

    +
    +
    +
    + ); +} diff --git a/src/app/(subLayout)/auctions/[id]/hooks/index.ts b/src/app/(subLayout)/auctions/[id]/hooks/index.ts new file mode 100644 index 0000000..33d4c75 --- /dev/null +++ b/src/app/(subLayout)/auctions/[id]/hooks/index.ts @@ -0,0 +1,2 @@ +export { useBidderForm } from './use-bidder-form'; +export { useBidderSSE } from './use-bidder-sse'; diff --git a/src/app/(subLayout)/auctions/[id]/hooks/use-bidder-form.ts b/src/app/(subLayout)/auctions/[id]/hooks/use-bidder-form.ts new file mode 100644 index 0000000..0d9f497 --- /dev/null +++ b/src/app/(subLayout)/auctions/[id]/hooks/use-bidder-form.ts @@ -0,0 +1,126 @@ +'use client'; + +import { useState, useRef } from 'react'; +import { createBid } from '@/actions/auction-service'; +import { isErrorResponse } from '@/utils/type-guards'; +import { useBidderSSE } from './use-bidder-sse'; + +interface UseBidderFormProps { + auctionUuid: string; + bidAmount: number; + onSuccess: () => void; +} + +export function useBidderForm({ + auctionUuid, + bidAmount, + onSuccess, +}: UseBidderFormProps) { + const [inputAmount, setInputAmount] = useState(String(bidAmount)); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const inputRef = useRef(null); + + const { startWaiting, stopWaiting } = useBidderSSE({ + onSuccess: () => { + setLoading(false); + onSuccess(); + }, + onError: (message) => { + setLoading(false); + setError(message); + }, + }); + + const validateBidAmount = (amount: number): string | null => { + if (amount <= bidAmount) { + return '입찰 금액은 현재 입찰가 이상이어야 합니다.'; + } + if (amount > Math.floor(bidAmount * 1.3)) { + return '입찰 금액은 현재 입찰가의 30%를 초과할 수 없습니다.'; + } + return null; + }; + + const submitBid = async (amount: number) => { + const validationError = validateBidAmount(amount); + if (validationError) { + setError(validationError); + return; + } + + setLoading(true); + setError(''); + startWaiting(); + + try { + const response = await createBid(auctionUuid, amount); + if (isErrorResponse(response)) { + stopWaiting(); + setError(response.message); + setLoading(false); + return; + } + // API 호출 성공 시 SSE 메시지를 기다림 + } catch (error) { + stopWaiting(); + setError( + isErrorResponse(error) + ? error.message + : '입찰에 실패했습니다. 다시 시도해주세요.', + ); + setLoading(false); + } + }; + + const handleInputChange = (e: React.ChangeEvent) => { + let value = e.target.value.replace(/^0+/, ''); + if (!value) value = '0'; + let num = parseInt(value, 10); + if (isNaN(num) || num < 0) num = 0; + setInputAmount(String(num)); + setError(''); + }; + + const handlePercent = (percent: number) => { + const newAmount = Math.floor(bidAmount * (1 + percent / 100)); + setInputAmount(String(newAmount)); + setTimeout(() => { + inputRef.current?.blur(); + }, 0); + }; + + const handleClear = () => { + setInputAmount('0'); + setTimeout(() => { + inputRef.current?.focus(); + }, 0); + }; + + const reset = () => { + stopWaiting(); + setInputAmount(String(bidAmount)); + setError(''); + setLoading(false); + }; + + const numAmount = parseInt(inputAmount, 10); + const isAmountInvalid = + inputAmount === '' || + isNaN(numAmount) || + numAmount <= bidAmount || + numAmount > Math.floor(bidAmount * 1.3); + + return { + inputAmount, + loading, + error, + inputRef, + isAmountInvalid, + handleInputChange, + handlePercent, + handleClear, + submitBid, + reset, + }; +} diff --git a/src/app/(subLayout)/auctions/[id]/hooks/use-bidder-sse.ts b/src/app/(subLayout)/auctions/[id]/hooks/use-bidder-sse.ts new file mode 100644 index 0000000..1393f09 --- /dev/null +++ b/src/app/(subLayout)/auctions/[id]/hooks/use-bidder-sse.ts @@ -0,0 +1,68 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { useConnectSSE } from '@/hooks/use-connect-sse'; + +interface UseBidderSSEProps { + onSuccess: () => void; + onError: (message: string) => void; + timeoutMs?: number; +} + +export function useBidderSSE({ + onSuccess, + onError, + timeoutMs = 10000, +}: UseBidderSSEProps) { + const { sseMessage } = useConnectSSE(); + const [isWaiting, setIsWaiting] = useState(false); + const timeoutRef = useRef(null); + + useEffect(() => { + if (!isWaiting || !sseMessage) return; + + if (sseMessage.type === 'auction-bidder-awarded') { + clearTimeoutAndReset(); + onSuccess(); + } else { + clearTimeoutAndReset(); + onError(sseMessage.message); + } + }, [sseMessage, isWaiting, onSuccess, onError]); + + const clearTimeoutAndReset = () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + setIsWaiting(false); + }; + + const startWaiting = () => { + setIsWaiting(true); + + timeoutRef.current = setTimeout(() => { + setIsWaiting(false); + onError('입찰 처리 시간이 초과되었습니다. 다시 시도해주세요.'); + timeoutRef.current = null; + }, timeoutMs); + }; + + const stopWaiting = () => { + clearTimeoutAndReset(); + }; + + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + return { + isWaiting, + startWaiting, + stopWaiting, + }; +} diff --git a/src/app/(subLayout)/auctions/[id]/page.tsx b/src/app/(subLayout)/auctions/[id]/page.tsx index 97f6b9c..9c8b2be 100644 --- a/src/app/(subLayout)/auctions/[id]/page.tsx +++ b/src/app/(subLayout)/auctions/[id]/page.tsx @@ -1,7 +1,5 @@ import { getAuctionBidders, getAuctionDetail } from '@/actions/auction-service'; import { getMemberInfo } from '@/actions/member-service'; -import { BiddersSection, AuctionTags } from '@/components/auction/detail'; -import { BidderForm } from '@/components/auction/detail/BidderForm'; import { ItemImages } from '@/components/images'; import { AuctionTimer, @@ -10,6 +8,7 @@ import { } from '@/components/items'; import ErrorText from '@/components/ui/error-text'; import { isErrorResponse } from '@/utils/type-guards'; +import { BiddersSection, AuctionTags, BidderForm } from './components'; export default async function AuctionPage({ params, @@ -40,8 +39,8 @@ export default async function AuctionPage({ })); return ( - <> -
    +
    +
    @@ -60,6 +59,6 @@ export default async function AuctionPage({ } status={auction.status} /> - +
    ); } diff --git a/src/app/(subLayout)/layout.tsx b/src/app/(subLayout)/layout.tsx index 0fb557c..050ee05 100644 --- a/src/app/(subLayout)/layout.tsx +++ b/src/app/(subLayout)/layout.tsx @@ -5,7 +5,7 @@ export default function layout({ children }: { children: React.ReactNode }) { return (
    -
    +
    {children}
    diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx new file mode 100644 index 0000000..0d8e4a3 --- /dev/null +++ b/src/app/global-error.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { AlertTriangle, RefreshCw } from 'lucide-react'; +import { logError } from '@/utils/error-handler'; + +interface GlobalErrorProps { + error: Error & { digest?: string }; + reset: () => void; +} + +export default function GlobalError({ error, reset }: GlobalErrorProps) { + useEffect(() => { + logError(error, 'GlobalError'); + }, [error]); + + const getErrorTitle = (error: Error) => { + if (error.message.includes('ChunkLoadError')) { + return '애플리케이션 로딩 오류'; + } + if (error.message.includes('Network')) { + return '네트워크 연결 오류'; + } + return '시스템 오류'; + }; + + const getErrorMessage = (error: Error) => { + if (error.message.includes('ChunkLoadError')) { + return '새로운 업데이트가 있습니다. 페이지를 새로고침해주세요.'; + } + if (error.message.includes('Network')) { + return '네트워크 연결에 문제가 있습니다. 인터넷 연결을 확인해주세요.'; + } + return '시스템에서 예상치 못한 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'; + }; + + const handleReload = () => { + window.location.reload(); + }; + + return ( + + +
    +
    +
    +
    + +
    + +

    + {getErrorTitle(error)} +

    + +

    {getErrorMessage(error)}

    + +
    + + + +
    +
    +
    +
    + + + ); +} diff --git a/src/app/globals.css b/src/app/globals.css index ba80ac0..a1285f9 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -221,4 +221,13 @@ textarea:focus { filter: blur(0); } } + + /* 모바일 중심 레이아웃 공통 클래스 */ + .mobile-container { + @apply w-full min-w-[320px] max-w-[480px] mx-auto; + } + + .mobile-fixed { + @apply fixed left-1/2 transform -translate-x-1/2 w-full min-w-[320px] max-w-[480px]; + } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index d74f001..4124525 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -51,7 +51,7 @@ export default async function RootLayout({ return ( - + diff --git a/src/app/test/bidder-form-test/page.tsx b/src/app/test/bidder-form-test/page.tsx deleted file mode 100644 index 2a63bb6..0000000 --- a/src/app/test/bidder-form-test/page.tsx +++ /dev/null @@ -1,17 +0,0 @@ -'use client'; -import { BidderForm } from '@/components/auction/detail/BidderForm'; - -export default function BidderFormTestPage() { - return ( -
    -

    BidderForm 테스트

    -
    - -
    -
    - ); -} diff --git a/src/components/auction/AuctionPreviewCard.tsx b/src/components/auction/AuctionPreviewCard.tsx index 53a4726..3bdd589 100644 --- a/src/components/auction/AuctionPreviewCard.tsx +++ b/src/components/auction/AuctionPreviewCard.tsx @@ -5,7 +5,6 @@ import { MinimalAuctionTimer } from '@/components/auction/MinimalAuctionTimer'; import { formatNumber } from '@/utils/format'; import { SearchAuctionItem } from '@/types/auction/search-products'; -// 경매 미리보기 타입 정의 export interface AuctionPreview { auctionUuid: string; title: string; @@ -18,165 +17,51 @@ export interface AuctionPreview { isLiked?: boolean; } -// 더미 데이터 예시 -const dummyAuction: AuctionPreview = { - auctionUuid: '1', - title: '하트모양 가방', - imageUrl: '/images/dummy/dummy1.png', - minimumBid: 17000, - currentPrice: 29000, - startAt: new Date(Date.now() - 1000 * 60 * 60 * 2).toISOString(), // 2시간 전 시작 - endAt: new Date(Date.now() + 1000 * 60 * 60 * 2 + 1000 * 36).toISOString(), // 2시간 36초 후 종료 - status: 'active', - isLiked: true, -}; - -const dummyAuction2: AuctionPreview = { - auctionUuid: '2', - title: '인형 세트(낱개 판매 가능)', - imageUrl: '/images/dummy/dummy2.png', - minimumBid: 215000, - currentPrice: 700000, - startAt: new Date(Date.now() - 1000 * 60 * 60 * 2).toISOString(), // 2시간 전 시작 - endAt: new Date(Date.now() + 1000 * 60 * 60 + 1000 * 36).toISOString(), // 2시간 36초 후 종료 - status: 'active', - isLiked: true, -}; -const dummyAuction3: AuctionPreview = { - auctionUuid: '3', - title: '쿠로미 응원봉', - imageUrl: '/images/dummy/dummy3.png', - minimumBid: 15000, - currentPrice: 34500, - startAt: new Date(Date.now() - 1000 * 60 * 60 * 2).toISOString(), // 2시간 전 시작 - endAt: new Date(Date.now() + 1000 * 60 * 60 + 1000 * 36).toISOString(), // 2시간 36초 후 종료 - status: 'active', - isLiked: true, -}; -const dummyAuction4: AuctionPreview = { - auctionUuid: '4', - title: '에어팟 3세대', - imageUrl: '/images/dummy/dummy4.png', - minimumBid: 100000, - currentPrice: 150000, - startAt: new Date(Date.now() - 1000 * 60 * 60 * 2).toISOString(), // 2시간 전 시작 - endAt: new Date(Date.now() + 1000 * 60 * 60 * 2 + 1000 * 36).toISOString(), // 2시간 36초 후 종료 - status: 'active', - isLiked: true, -}; - export function AuctionPreviewCard({ auction, - number, }: { - auction?: SearchAuctionItem; - number?: number; + auction: SearchAuctionItem; }) { - // 실제 데이터가 있으면 사용, 없으면 더미 데이터 사용 - if (auction) { - const auctionData = { - auctionUuid: auction.auctionUuid, - title: auction.auctionTitle, - imageUrl: auction.thumbnailUrl, - minimumBid: auction.minimumBid, - currentPrice: auction.currentBid, - startAt: auction.startAt, - endAt: auction.endAt, - status: auction.status || 'active', - }; + const countUpFrom = auction.currentBid === 0 ? 0 : auction.minimumBid; + const countUpTo = + auction.currentBid === 0 ? auction.minimumBid : auction.currentBid; - return ( - -
    + return ( + +
    +
    {auctionData.title} -
    -
    -
    - {auctionData.title} -
    + +
    +

    + {auction.auctionTitle} +

    - {formatNumber(auctionData.minimumBid)}원 + {formatNumber(auction.minimumBid)}원 - +
    -
    +
    -
    -
    - - ); - } - - // 더미 데이터 사용 (기존 로직) - let selectedDummyAuction = dummyAuction; - if (number === 1) { - selectedDummyAuction = dummyAuction; - } else if (number === 2) { - selectedDummyAuction = dummyAuction2; - } else if (number === 3) { - selectedDummyAuction = dummyAuction3; - } else if (number === 4) { - selectedDummyAuction = dummyAuction4; - } - - return ( - -
    - {selectedDummyAuction.title} -
    -
    -
    - {selectedDummyAuction.title} -
    -
    - - {formatNumber(selectedDummyAuction.minimumBid)}원 - - - - -
    -
    - -
    -
    + + + ); } diff --git a/src/components/auction/AuctionProductCard.tsx b/src/components/auction/AuctionProductCard.tsx index b6614ab..9322497 100644 --- a/src/components/auction/AuctionProductCard.tsx +++ b/src/components/auction/AuctionProductCard.tsx @@ -1,4 +1,4 @@ -import { Eye, Heart, Users } from 'lucide-react'; +import { Eye } from 'lucide-react'; import Image from 'next/image'; import Link from 'next/link'; @@ -15,8 +15,7 @@ interface AuctionProductCardProps { status: string; viewCount: number; thumbnailUrl: string; - likes: number; - bidderCount: number; + bidAmount?: number; LikeButtonComponent: React.ComponentType<{ auctionUuid: string; @@ -35,8 +34,7 @@ export function AuctionProductCard({ status, viewCount, thumbnailUrl, - likes, - bidderCount, + bidAmount, LikeButtonComponent, onLike, @@ -91,9 +89,15 @@ export function AuctionProductCard({

    {formatNumber(currentPrice)}원

    -

    - {formatNumber(minimumBid)}원 -

    + +

    + {formatNumber(minimumBid)}원 +

    +
    + + {formatNumber(viewCount)} +
    +
    - -
    - - {formatNumber(bidderCount)} - - {formatNumber(likes)} - - {formatNumber(viewCount)} -
    ); } diff --git a/src/components/auction/AuctionProductGrid.tsx b/src/components/auction/AuctionProductGrid.tsx index d9034e5..e9a562d 100644 --- a/src/components/auction/AuctionProductGrid.tsx +++ b/src/components/auction/AuctionProductGrid.tsx @@ -10,8 +10,7 @@ interface AuctionProduct { status: string; viewCount: number; thumbnailUrl: string; - likes: number; - bidderCount: number; + bidAmount?: number; // 현재 입찰가 (없으면 minimumBid와 동일) } @@ -45,13 +44,7 @@ export function AuctionProductGrid({ } return ( -
    +
    {products.map((product) => ( { const now = new Date(); const nextHour = new Date(now.getTime() + 60 * 60 * 1000); @@ -12,9 +5,6 @@ export const getMinDateTime = (): Date => { return nextHour; }; -/** - * 경매 기간 옵션들 - */ export const durationOptions = [ { hours: 1, label: '1시간' }, { hours: 3, label: '3시간' }, @@ -26,16 +16,10 @@ export const durationOptions = [ { hours: 0, label: '직접입력' }, ]; -/** - * 숫자만 추출하는 헬퍼 함수 - */ export const extractNumbers = (value: string): string => { return value.replace(/[^0-9]/g, ''); }; -/** - * 경매 종료 시간 계산 - */ export const calculateEndTime = (startAt: Date, duration: number): Date => { return new Date(startAt.getTime() + duration * 60 * 60 * 1000); }; diff --git a/src/components/auction/create/utils/preview-utils.ts b/src/components/auction/create/utils/preview-utils.ts index d9a837e..9715cdc 100644 --- a/src/components/auction/create/utils/preview-utils.ts +++ b/src/components/auction/create/utils/preview-utils.ts @@ -1,12 +1,5 @@ -/** - * 미리보기 관련 유틸리티 함수들 - */ - import { ProductCondition } from '@/stores/use-create-auction-store'; -/** - * 상품 상태 라벨 매핑 - */ export const productConditionLabels: Record = { unopened: '미개봉 상품', new: '새상품', @@ -14,9 +7,6 @@ export const productConditionLabels: Record = { '': '선택 안됨', }; -/** - * 한국어 날짜 포맷팅 옵션 - */ export const koreanDateFormatOptions: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'long', @@ -24,9 +14,6 @@ export const koreanDateFormatOptions: Intl.DateTimeFormatOptions = { hour: 'numeric', }; -/** - * Step3 컴포넌트에 handleSubmit 함수를 노출하는 함수 - */ export const exposeHandleSubmit = (handleSubmit: () => void) => { const element = document.querySelector('[data-step="3"]') as HTMLElement & { handleSubmit?: () => void; diff --git a/src/components/auction/detail/bidder-form/index.ts b/src/components/auction/detail/bidder-form/index.ts deleted file mode 100644 index d887c3b..0000000 --- a/src/components/auction/detail/bidder-form/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { BidderDialogHeader } from './BidderDialogHeader'; -export { BidderAgreementStep } from './BidderAgreementStep'; -export { BidderFormStep } from './BidderFormStep'; -export { BidderSuccessStep } from './BidderSuccessStep'; diff --git a/src/components/auction/detail/index.ts b/src/components/auction/detail/index.ts deleted file mode 100644 index b61ec76..0000000 --- a/src/components/auction/detail/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from './BiddersSection'; -export * from './BidderCard'; -export * from './BidderForm'; -export * from './BidderList'; -export * from './AuctionTags'; diff --git a/src/components/auction/index.ts b/src/components/auction/index.ts index 066d316..42ae8e2 100644 --- a/src/components/auction/index.ts +++ b/src/components/auction/index.ts @@ -10,4 +10,4 @@ export { MinimalAuctionTimer } from './MinimalAuctionTimer'; export * from './create'; // 상세 페이지 관련 컴포넌트 -export * from './detail'; +export * from '../../app/(subLayout)/auctions/[id]/components/bidder-section'; diff --git a/src/components/category/CategoryFilterSlider.tsx b/src/components/category/CategoryFilterSlider.tsx index 683e897..6a7f7ad 100644 --- a/src/components/category/CategoryFilterSlider.tsx +++ b/src/components/category/CategoryFilterSlider.tsx @@ -7,12 +7,19 @@ import { CategoryType } from '@/actions/category-service/get-categories'; interface CategoryFilterSliderProps { categories: CategoryType[]; onCategorySelect?: (categoryName: string | null) => void; + isProduct?: boolean; } export function CategoryFilterSlider({ categories, onCategorySelect, + isProduct = false, }: CategoryFilterSliderProps) { + let url = '/auctions'; + if (isProduct) { + url = '/products'; + } + const router = useRouter(); const searchParams = useSearchParams(); const currentCategory = searchParams.get('categoryName'); @@ -29,7 +36,7 @@ export function CategoryFilterSlider({ // 기본 동작: auctions 페이지로 이동 const params = new URLSearchParams(searchParams); params.set('categoryName', trimmedName); - router.replace(`/auctions?${params.toString()}`); + router.replace(`${url}?${params.toString()}`); } }; @@ -42,7 +49,7 @@ export function CategoryFilterSlider({ // 기본 동작: auctions 페이지로 이동 const params = new URLSearchParams(searchParams); params.delete('categoryName'); - router.replace(`/auctions?${params.toString()}`); + router.replace(`${url}?${params.toString()}`); } }; diff --git a/src/components/chat/ChatRoom.tsx b/src/components/chat/ChatRoom.tsx index 0ee6f2b..32dc008 100644 --- a/src/components/chat/ChatRoom.tsx +++ b/src/components/chat/ChatRoom.tsx @@ -3,6 +3,8 @@ import React, { useRef, useEffect } from 'react'; import { ChatInput, MessageList } from '@/components/chat'; import { useChat, useOptimizedMessages } from '@/hooks/chat'; +import { useChatUnreadStore } from '@/stores/use-chat-unread-store'; +import { getUnreadChatCount } from '@/actions/chat-service/get-unread-chat-count'; import type { ChatMessageResponseType, ChatroomInfoResponse, @@ -26,6 +28,7 @@ export const ChatRoom = ({ memberUuid, }: ChatRoomProps) => { const chatWindowRef = useRef(null); + const { setUnreadCount } = useChatUnreadStore(); const { messageInput, @@ -53,6 +56,25 @@ export const ChatRoom = ({ autoConnect: true, }); + // 채팅방 입장/퇴장 시 안읽은 메시지 수 동기화 + useEffect(() => { + // 채팅방 입장 시 안읽은 메시지 수 동기화 + getUnreadChatCount().then((count) => { + if (typeof count === 'number') { + setUnreadCount(count); + } + }); + + return () => { + // 채팅방 퇴장 시 안읽은 메시지 수 동기화 + getUnreadChatCount().then((count) => { + if (typeof count === 'number') { + setUnreadCount(count); + } + }); + }; + }, [setUnreadCount]); + useEffect(() => { if (isConnected && initialChat.length > 0) { const latestMessage = initialChat[0]; diff --git a/src/components/chat/ChatRoomHeader.tsx b/src/components/chat/ChatRoomHeader.tsx index 864aba7..50dec0b 100644 --- a/src/components/chat/ChatRoomHeader.tsx +++ b/src/components/chat/ChatRoomHeader.tsx @@ -5,13 +5,16 @@ import { formatNumber } from '@/utils/format'; import { ChatroomInfoResponse } from '@/types/chat'; import { MemberSummary } from '@/types/member'; import { getChatRoomHeaderData } from '@/utils/chat-room-header'; +import { ReviewButton } from './ReviewButton'; export async function ChatRoomHeader({ chatroomInfo, opponentInfo, + currentUserUuid, }: { chatroomInfo: ChatroomInfoResponse; opponentInfo: MemberSummary; + currentUserUuid: string; }) { const { href, product, price, imageUrl, status, sellerInfo } = await getChatRoomHeaderData(chatroomInfo); @@ -27,7 +30,23 @@ export async function ChatRoomHeader({

    {opponentInfo.nickname}

    -
    +
    + diff --git a/src/components/chat/ChatSummary.tsx b/src/components/chat/ChatSummary.tsx index 7d3ae1a..e050155 100644 --- a/src/components/chat/ChatSummary.tsx +++ b/src/components/chat/ChatSummary.tsx @@ -26,6 +26,7 @@ export function ChatSummary({ chat, memberInfo }: ChatSummaryProps) { alt={nickname} width={56} height={56} + priority className='object-cover w-full h-full' /> @@ -58,8 +59,8 @@ export function ChatSummary({ chat, memberInfo }: ChatSummaryProps) { 상품 썸네일 diff --git a/src/components/chat/ReviewButton.tsx b/src/components/chat/ReviewButton.tsx new file mode 100644 index 0000000..ca28711 --- /dev/null +++ b/src/components/chat/ReviewButton.tsx @@ -0,0 +1,81 @@ +'use client'; +import { useState } from 'react'; +import { ReviewDialog } from '@/components/common'; +import { MemberSummary } from '@/types/member'; +import { ReviewData, ReviewType } from '@/types/review'; +import { StatusBadge } from '../icons'; +import { + sendReviewBuyerToSeller, + sendReviewSellerToBuyer, +} from '@/actions/review-service'; + +interface ReviewButtonProps { + currentUserUuid: string; + opponentInfo: MemberSummary; + productInfo: { + uuid: string; + title: string; + sellerUuid: string; + type: 'PRODUCT' | 'AUCTION'; + imageUrl: string; + price: number; + status: string; + }; +} + +export function ReviewButton({ + currentUserUuid, + opponentInfo, + productInfo, +}: ReviewButtonProps) { + const [isReviewDialogOpen, setIsReviewDialogOpen] = useState(false); + + const isCurrentUserSeller = currentUserUuid === productInfo.sellerUuid; + const reviewType: ReviewType = isCurrentUserSeller + ? 'seller-to-buyer' + : 'buyer-to-seller'; + + const handleReviewSubmit = async (reviewData: ReviewData) => { + try { + if (reviewType === 'buyer-to-seller') { + await sendReviewBuyerToSeller(reviewData); + } else { + await sendReviewSellerToBuyer(reviewData); + } + + setIsReviewDialogOpen(false); + } catch (error) { + console.error('리뷰 전송 실패:', error); + } + }; + + return ( + <> + + + setIsReviewDialogOpen(false)} + onSubmit={handleReviewSubmit} + reviewType={reviewType} + targetUuid={opponentInfo.memberUuid} + postUuid={productInfo.uuid} + targetName={opponentInfo.nickname} + productInfo={{ + title: productInfo.title, + imageUrl: productInfo.imageUrl, + price: productInfo.price, + status: productInfo.status, + }} + /> + + ); +} diff --git a/src/components/chat/index.ts b/src/components/chat/index.ts index b544e84..5301534 100644 --- a/src/components/chat/index.ts +++ b/src/components/chat/index.ts @@ -13,3 +13,4 @@ export * from './MessageList'; export * from './SystemMessage'; export * from './ProfileImage'; export * from './ImageModal'; +export * from './ReviewButton'; diff --git a/src/components/common/AuctionLoading.tsx b/src/components/common/AuctionLoading.tsx index 8bf2f5b..ef4d5d4 100644 --- a/src/components/common/AuctionLoading.tsx +++ b/src/components/common/AuctionLoading.tsx @@ -7,7 +7,7 @@ const icons = [Gavel, ShoppingBag, Coins, Tag]; const iconNames = [ '경매 준비 중...', '상품 등록 중...', - '입찰 정보 저장 중...', + '입찰 중...', '태그 처리 중...', ]; @@ -15,26 +15,30 @@ export function AuctionLoading({ size = 48, interval = 1000, text = '로딩 중...', + showIcon = true, }: { size?: number; interval?: number; text?: string; + showIcon?: boolean; }) { const [index, setIndex] = useState(0); useEffect(() => { + if (!showIcon) return; + const timer = setInterval(() => { setIndex((prev) => (prev + 1) % icons.length); }, interval); return () => clearInterval(timer); - }, [interval]); + }, [interval, showIcon]); const Icon = icons[index]; - const label = iconNames[index] || text; + const label = showIcon ? iconNames[index] || text : text; return (
    - + {showIcon && } {label} diff --git a/src/components/common/CreateProductFAB.tsx b/src/components/common/CreateProductFAB.tsx index 234aac8..221f672 100644 --- a/src/components/common/CreateProductFAB.tsx +++ b/src/components/common/CreateProductFAB.tsx @@ -50,7 +50,12 @@ export function CreateProductFAB() { aria-hidden='true' /> )} -
    +
    {menuItems.map((item, i) => ( void; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +export class ErrorBoundary extends Component { + public state: State = { + hasError: false, + error: null, + }; + + public static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + public componentDidCatch(error: Error, errorInfo: ErrorInfo) { + logError(error, 'ErrorBoundary'); + + if (this.props.onError) { + this.props.onError(error, errorInfo); + } + } + + private handleReset = () => { + this.setState({ hasError: false, error: null }); + }; + + private handleReload = () => { + window.location.reload(); + }; + + public render() { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } + + return ( +
    +
    +
    +
    + +
    + +

    + 문제가 발생했습니다 +

    + +

    + 컴포넌트를 로딩하는 중 오류가 발생했습니다. +

    + +
    + + + +
    + + {process.env.NODE_ENV === 'development' && this.state.error && ( +
    + + 개발자 정보 + +
    +                    {this.state.error.stack}
    +                  
    +
    + )} +
    +
    +
    + ); + } + + return this.props.children; + } +} + +// 함수형 컴포넌트로 래핑하여 사용하기 쉽게 만든 버전 +interface ErrorBoundaryWrapperProps { + children: ReactNode; + fallback?: ReactNode; + onError?: (error: Error, errorInfo: ErrorInfo) => void; +} + +export const ErrorBoundaryWrapper = ({ + children, + fallback, + onError, +}: ErrorBoundaryWrapperProps) => { + return ( + + {children} + + ); +}; diff --git a/src/components/common/ReviewDialog.tsx b/src/components/common/ReviewDialog.tsx new file mode 100644 index 0000000..ed2f396 --- /dev/null +++ b/src/components/common/ReviewDialog.tsx @@ -0,0 +1,246 @@ +'use client'; +import { motion, AnimatePresence } from 'framer-motion'; +import { useState } from 'react'; +import Image from 'next/image'; + +import { Dialog } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import Penguin from '@/assets/icons/common/penguin.svg'; +import { + ReviewData, + ReviewType, + SellerReviewData, + BuyerReviewData, +} from '@/types/review'; +import { formatNumber } from '@/utils/format'; + +interface StarRatingProps { + score: number; + onScoreChange: (score: number) => void; + label: string; +} + +function StarRating({ score, onScoreChange, label }: StarRatingProps) { + return ( +
    +
    {label}
    +
    + {[1, 2, 3, 4, 5].map((star) => ( + + ))} +
    +
    + ); +} + +export function ReviewDialog({ + isOpen, + onClose, + onSubmit, + reviewType, + targetUuid, + postUuid, + targetName, + productInfo, +}: { + isOpen: boolean; + onClose: () => void; + onSubmit: (reviewData: ReviewData) => void; + reviewType: ReviewType; + targetUuid: string; + postUuid: string; + targetName?: string; + productInfo?: { + title: string; + imageUrl: string; + price: number; + status: string; + }; +}) { + const [mannerScore, setMannerScore] = useState(5); + const [timeScore, setTimeScore] = useState(5); + const [replyScore, setReplyScore] = useState(5); + const [statusScore, setStatusScore] = useState(5); + + const handleSubmit = () => { + if (reviewType === 'seller-to-buyer') { + const reviewData: SellerReviewData = { + buyerUuid: targetUuid, + postUuid, + postType: 'AUCTION', + mannerScore, + timeScore, + replyScore, + }; + onSubmit(reviewData); + } else { + const reviewData: BuyerReviewData = { + sellerUuid: targetUuid, + postUuid, + postType: 'AUCTION', + mannerScore, + timeScore, + replyScore, + statusScore, + }; + onSubmit(reviewData); + } + }; + + const sectionVariants = { + initial: { opacity: 0, scale: 0.95, y: 40 }, + animate: { opacity: 1, scale: 1, y: 0 }, + exit: { opacity: 0, scale: 0.95, y: 40 }, + }; + + const penguinVariants = { + initial: { scale: 0 }, + animate: { scale: 1 }, + exit: { scale: 0 }, + }; + + return ( + + + {isOpen && ( + + + + + + {/* 상품 정보 섹션 */} + {productInfo && ( + +
    +
    + {productInfo.title} +
    +
    +
    + {productInfo.status} +
    +
    + {productInfo.title} +
    +
    + {formatNumber(productInfo.price)}원 +
    +
    +
    +
    + )} + + + {targetName}님과의 거래는 어떠셨나요? +
    + 평점을 남겨주세요 +
    + + + + + + {reviewType === 'buyer-to-seller' && ( + + )} + + + + + + +
    + )} +
    +
    + ); +} diff --git a/src/components/common/index.ts b/src/components/common/index.ts index 8916942..d933f21 100644 --- a/src/components/common/index.ts +++ b/src/components/common/index.ts @@ -1,5 +1,7 @@ -export * from './ProductLoading'; -export * from './ProductSuccessDialog'; -export * from './AuctionSuccessDialog'; export * from './AuctionLoading'; +export * from './AuctionSuccessDialog'; export * from './CreateProductFAB'; +export * from './ProductLoading'; +export * from './ProductSuccessDialog'; +export * from './ErrorBoundary'; +export * from './ReviewDialog'; diff --git a/src/components/icons/StatusBadge.tsx b/src/components/icons/StatusBadge.tsx index 208f96a..09d1878 100644 --- a/src/components/icons/StatusBadge.tsx +++ b/src/components/icons/StatusBadge.tsx @@ -3,7 +3,7 @@ import type React from 'react'; interface StatusBadgeProps { children: React.ReactNode; - variant?: 'red' | 'orange' | 'default'; + variant?: 'red' | 'orange' | 'default' | 'primary'; size?: 'sm' | 'md' | 'lg'; className?: string; } @@ -11,6 +11,7 @@ interface StatusBadgeProps { const variantStyles = { red: 'text-red-100 border-red-100 ', orange: 'text-yellow-300 border-yellow-300 ', + primary: 'text-primary-200 border-primary-200 bg-primary-200/10', default: 'text-gray-600 border-gray-600 bg-gray-50', }; diff --git a/src/components/items/ItemInfoSection.tsx b/src/components/items/ItemInfoSection.tsx index 23c460d..093c7ce 100644 --- a/src/components/items/ItemInfoSection.tsx +++ b/src/components/items/ItemInfoSection.tsx @@ -26,6 +26,7 @@ export function ItemInfoSection({ } alt={`${auction.seller.nickname} 프로필`} fill + sizes='48px' className='object-cover' />
    @@ -43,8 +44,7 @@ export function ItemInfoSection({ className=' items-center gap-1 text-red-500 touch-manipulation' aria-label='좋아요' > - - {auction.likes} +
    diff --git a/src/components/layouts/BottomNavigation.tsx b/src/components/layouts/BottomNavigation.tsx index c4bc00d..3ee24fe 100644 --- a/src/components/layouts/BottomNavigation.tsx +++ b/src/components/layouts/BottomNavigation.tsx @@ -4,6 +4,7 @@ import Link from 'next/link'; import { Home, Gavel, ShoppingBag, MessageCircle, User } from 'lucide-react'; import { cn } from '@/libs/cn'; +import { useChatUnreadStore } from '@/stores/use-chat-unread-store'; interface NavItem { href: string; @@ -17,16 +18,16 @@ const navItems: NavItem[] = [ label: '홈', icon: Home, }, - { - href: '/auctions', - label: '경매', - icon: Gavel, - }, { href: '/products', label: '일반', icon: ShoppingBag, }, + { + href: '/auctions', + label: '경매', + icon: Gavel, + }, { href: '/chat', label: '채팅', @@ -41,25 +42,24 @@ const navItems: NavItem[] = [ export function BottomNavigation() { const pathname = usePathname(); - + const { unreadCount } = useChatUnreadStore(); return (