diff --git a/package.json b/package.json index d1f223bb..24d44713 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "next-themes": "^0.2.1", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-error-boundary": "^4.0.11", "react-hook-form": "^7.47.0", "react-lottie-player": "^1.5.5", "sharp": "^0.32.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f02f745..5ed303dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,6 +77,9 @@ dependencies: react-dom: specifier: ^18.2.0 version: 18.2.0(react@18.2.0) + react-error-boundary: + specifier: ^4.0.11 + version: 4.0.11(react@18.2.0) react-hook-form: specifier: ^7.47.0 version: 7.47.0(react@18.2.0) @@ -11428,6 +11431,15 @@ packages: react-is: 18.1.0 dev: true + /react-error-boundary@4.0.11(react@18.2.0): + resolution: {integrity: sha512-U13ul67aP5DOSPNSCWQ/eO0AQEYzEFkVljULQIjMV0KlffTAhxuDoBKdO0pb/JZ8mDhMKFZ9NZi0BmLGUiNphw==} + peerDependencies: + react: '>=16.13.1' + dependencies: + '@babel/runtime': 7.23.2 + react: 18.2.0 + dev: false + /react-hook-form@7.47.0(react@18.2.0): resolution: {integrity: sha512-F/TroLjTICipmHeFlMrLtNLceO2xr1jU3CyiNla5zdwsGUGu2UOxxR4UyJgLlhMwLW/Wzp4cpJ7CPfgJIeKdSg==} engines: {node: '>=12.22.0'} diff --git a/public/images/chevron-left-gray.svg b/public/images/chevron-left-gray.svg new file mode 100644 index 00000000..91d57b90 --- /dev/null +++ b/public/images/chevron-left-gray.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/app/(root)/(routes)/(auth)/login/components/RouteCallback.tsx b/src/app/(root)/(routes)/(auth)/login/components/RouteCallback.tsx index de6469f0..b9ba6fbe 100644 --- a/src/app/(root)/(routes)/(auth)/login/components/RouteCallback.tsx +++ b/src/app/(root)/(routes)/(auth)/login/components/RouteCallback.tsx @@ -26,11 +26,13 @@ const RouteCallback = ({ tokenResponse }: RouteCallbackProps) => { useEffect(() => { if (tokenResponse?.data) { + let inHour = new Date() + inHour.setHours(inHour.getHours() + 1) Cookies.set( Environment.tokenName(), tokenResponse?.data?.token?.accessToken, { - expires: 60 * 60 * 24 * 7, + expires: inHour, }, ) window.location.href = AppPath.home() diff --git a/src/app/(root)/(routes)/(home)/components/CategorySection.tsx b/src/app/(root)/(routes)/(home)/components/CategorySection.tsx index 064732c8..94adc34c 100644 --- a/src/app/(root)/(routes)/(home)/components/CategorySection.tsx +++ b/src/app/(root)/(routes)/(home)/components/CategorySection.tsx @@ -1,35 +1,25 @@ -'use client' - import Image from 'next/image' -import { useRouter } from 'next/navigation' -import Button from '@/components/ui/button' +import Link from 'next/link' import AppPath from '@/config/appPath' import { CATEGORY_BUTTON_LIST } from '@/constants/card' import { TYPOGRAPHY } from '@/styles/sizes' const CategorySection = () => { - const router = useRouter() - - const handleClick = (name: string) => { - if (name === 'ALL_CARD') { - router.push(`${AppPath.cards()}`) - } else { - router.push(`${AppPath.cards()}?category=${name}`) - } - } return (
{CATEGORY_BUTTON_LIST.map((v) => ( - + ))}
) diff --git a/src/app/(root)/(routes)/(home)/page.tsx b/src/app/(root)/(routes)/(home)/page.tsx index b3257151..8ee18f11 100644 --- a/src/app/(root)/(routes)/(home)/page.tsx +++ b/src/app/(root)/(routes)/(home)/page.tsx @@ -5,7 +5,7 @@ import PopularCardSection from './components/PopularCardSection' function HomePage() { return ( -
+
diff --git a/src/app/(root)/(routes)/cards/[cardId]/components/ProfileSection/ProfileSection.tsx b/src/app/(root)/(routes)/cards/[cardId]/components/ProfileSection/ProfileSection.tsx index d0b51eb9..aeb65417 100644 --- a/src/app/(root)/(routes)/cards/[cardId]/components/ProfileSection/ProfileSection.tsx +++ b/src/app/(root)/(routes)/cards/[cardId]/components/ProfileSection/ProfileSection.tsx @@ -12,7 +12,7 @@ const ProfileSection = ({ profileImg, userName }: ProfileSectionProps) => { -
{userName}
+
{userName}
) } diff --git a/src/app/(root)/(routes)/cards/[cardId]/components/description-section/DescriptionSection.tsx b/src/app/(root)/(routes)/cards/[cardId]/components/description-section/DescriptionSection.tsx index 574ef132..e69f9a7c 100644 --- a/src/app/(root)/(routes)/cards/[cardId]/components/description-section/DescriptionSection.tsx +++ b/src/app/(root)/(routes)/cards/[cardId]/components/description-section/DescriptionSection.tsx @@ -1,11 +1,14 @@ import formatDistanceToNow from 'date-fns/formatDistanceToNow' import koLocale from 'date-fns/locale/ko' +import { useRouter } from 'next/navigation' import Badge from '@/components/ui/badge' +import AppPath from '@/config/appPath' import { CATEGORY_OBJS, TRADE_STATUS_OBJS } from '@/constants/card' import { useAuth } from '@/contexts/AuthProvider' import { TYPOGRAPHY } from '@/styles/sizes' import { CardDetail } from '@/types/card' import { cn } from '@/utils' +import { getQueryParams } from '@/utils/getQueryParams' import { getValueByKey } from '@/utils/getValueByKey' import Dibs from './Dibs' import MoreButton from './MoreButton' @@ -36,6 +39,7 @@ const DescriptionSection = ({ }: DescriptionSectionProps) => { const { isLoggedIn } = useAuth() const { currentUser } = useAuth() + const router = useRouter() const isMyItem = currentUser?.userId === authorId @@ -68,7 +72,14 @@ const DescriptionSection = ({ TYPOGRAPHY.description, )} > - {getValueByKey(CATEGORY_OBJS, category)} + + router.push(`${AppPath.cards()}?${getQueryParams({ category })}`) + } + > + {getValueByKey(CATEGORY_OBJS, category)} +

{formatDistanceToNow(new Date(createdAt), { diff --git a/src/app/(root)/(routes)/cards/[cardId]/components/trade-section/SuggestList.tsx b/src/app/(root)/(routes)/cards/[cardId]/components/trade-section/SuggestList.tsx index 192b496b..9e1b5cfb 100644 --- a/src/app/(root)/(routes)/cards/[cardId]/components/trade-section/SuggestList.tsx +++ b/src/app/(root)/(routes)/cards/[cardId]/components/trade-section/SuggestList.tsx @@ -1,4 +1,6 @@ +import { Suspense } from 'react' import { useRouter } from 'next/navigation' +import Loading from '@/app/loading' import SuggestCard from '@/components/domain/card/suggest-card' import NoData from '@/components/domain/no-data' import { Tabs, TabsTrigger, TabsList, TabsContent } from '@/components/ui/tabs' @@ -51,19 +53,21 @@ const SuggestList = ({ pokeAvailable, toCardId }: SuggestListProps) => { 오퍼하기 찔러보기 - {['OFFER', 'POKE'].map((type) => ( - - {!pokeAvailable && type === 'POKE' ? ( - - ) : ( - filterData(type) - )} - - ))} + }> + {['OFFER', 'POKE'].map((type) => ( + + {!pokeAvailable && type === 'POKE' ? ( + + ) : ( + filterData(type) + )} + + ))} + ) } diff --git a/src/app/(root)/(routes)/cards/[cardId]/components/trade-section/TradeInfo.tsx b/src/app/(root)/(routes)/cards/[cardId]/components/trade-section/TradeInfo.tsx index e32035cf..748a0f95 100644 --- a/src/app/(root)/(routes)/cards/[cardId]/components/trade-section/TradeInfo.tsx +++ b/src/app/(root)/(routes)/cards/[cardId]/components/trade-section/TradeInfo.tsx @@ -14,7 +14,7 @@ const TradeInfo = ({ title, content, variant, icon }: TradeInfoProps) => { infoImg

{title}
- {content} + {content === '' ? '미입력' : content} ) diff --git a/src/app/(root)/(routes)/cards/[cardId]/modify/CardModifyTemplate.tsx b/src/app/(root)/(routes)/cards/[cardId]/modify/CardModifyTemplate.tsx index 4fc8f57a..ea8da4ab 100644 --- a/src/app/(root)/(routes)/cards/[cardId]/modify/CardModifyTemplate.tsx +++ b/src/app/(root)/(routes)/cards/[cardId]/modify/CardModifyTemplate.tsx @@ -68,7 +68,7 @@ const CardModifyTemplate = ({ cardInfo, cardId }: CardModifyTemplateProps) => { variant={'gradation'} disabled={isSubmitting} > - 등록하기 + 수정하기 diff --git a/src/app/(root)/(routes)/cards/[cardId]/page.tsx b/src/app/(root)/(routes)/cards/[cardId]/page.tsx index 30cf736c..f49b8ae1 100644 --- a/src/app/(root)/(routes)/cards/[cardId]/page.tsx +++ b/src/app/(root)/(routes)/cards/[cardId]/page.tsx @@ -1,6 +1,10 @@ 'use client' +import Image from 'next/image' +import { useRouter } from 'next/navigation' import Slider from '@/components/domain/slider' +import Button from '@/components/ui/button' +import Assets from '@/config/assets' import { useAuth } from '@/contexts/AuthProvider' import useCardInfoQuery from '@/hooks/api/queries/useCardInfoQuery' import ProfileSection from './components/ProfileSection' @@ -15,6 +19,7 @@ type CardPageProps = { const CardPage = ({ params }: CardPageProps) => { const { isLoggedIn } = useAuth() + const router = useRouter() const { data } = useCardInfoQuery(Number(params.cardId), isLoggedIn) const cardData = data?.data @@ -22,6 +27,18 @@ const CardPage = ({ params }: CardPageProps) => {
{cardData && ( <> + { const searchParams = useSearchParams() // TODO: 현재 API 명세에 status에 어떤 값을 줘야하는지에 대한 정의가 되어 있지 않기 때문에 임시로 상수 값을 전달함 => 추후에 실제 동작 값으로 고치기 - const { data, fetchNextPage, isError, isFetchingNextPage, isLoading } = - useCardsQuery({ - category: - (searchParams.get('category') as CategoryObjs['key']) || undefined, - priceRange: - (searchParams.get('priceRange') as PriceRangeObjs['key']) || undefined, - cardTitle: searchParams.get('cardTitle' as string) || '', - }) + const { + data, + fetchNextPage, + isError, + isFetchingNextPage, + isLoading, + hasNextPage, + } = useCardsQuery({ + category: + (searchParams.get('category') as CategoryObjs['key']) || undefined, + priceRange: + (searchParams.get('priceRange') as PriceRangeObjs['key']) || undefined, + cardTitle: searchParams.get('cardTitle' as string) || '', + }) const lastElementRef = useRef(null) const entry = useIntersectionObserver(lastElementRef, { threshold: 1.0 }) useEffect(() => { - if (isFetchingNextPage) { + if (isFetchingNextPage || !hasNextPage) { return } if (entry?.isIntersecting) { fetchNextPage() } - }, [entry?.isIntersecting, fetchNextPage, isFetchingNextPage]) + }, [entry?.isIntersecting, fetchNextPage, isFetchingNextPage, hasNextPage]) // TODO: 아이템이 없을시 어떤 UI를 보여줄지 차후에 결정 @@ -48,7 +55,9 @@ const CardListContent = () => { isEmpty={isEmpty} isFetchingNextPage={isFetchingNextPage} > - + 렌더링 중 문제가 발생했습니다.}> + +
diff --git a/src/app/(root)/(routes)/cards/components/filter-form-dialog/FilterFormDialog.tsx b/src/app/(root)/(routes)/cards/components/filter-form-dialog/FilterFormDialog.tsx index 5ff1a368..863d0dec 100644 --- a/src/app/(root)/(routes)/cards/components/filter-form-dialog/FilterFormDialog.tsx +++ b/src/app/(root)/(routes)/cards/components/filter-form-dialog/FilterFormDialog.tsx @@ -6,7 +6,6 @@ import { Dialog, DialogContent, DialogDescription, - DialogFooter, DialogHeader, DialogTitle, DialogTrigger, @@ -21,11 +20,17 @@ import { import AppPath from '@/config/appPath' import Assets from '@/config/assets' import { CATEGORY_OBJS, PRICE_RANGE_OBJS } from '@/constants/card' -import useCreateQueryString from '@/hooks/useCreateQueryString' import { CategoryObjs, PriceRangeObjs } from '@/types/card' +import { getQueryParams } from '@/utils/getQueryParams' import { getValueByKey } from '@/utils/getValueByKey' const FilterFormDialog = () => { + const router = useRouter() + const searchParams = useSearchParams() + const priceRange = searchParams.get('priceRange') || undefined + const category = searchParams.get('category') || undefined + const [priceRangeState, setPriceRangeState] = useState(priceRange) + const [categoryState, setCategoryState] = useState(category) const [isOpen, setIsOpen] = useState(false) const openModal = () => { setIsOpen(true) @@ -33,12 +38,19 @@ const FilterFormDialog = () => { const closeModal = () => { setIsOpen(false) } - const searchParams = useSearchParams() - const router = useRouter() - - const { createQueryString } = useCreateQueryString() - const priceRange = searchParams.get('priceRange') || undefined - const category = searchParams.get('category') || undefined + const handleApplyFilter = () => { + router.push( + `${AppPath.cards()}?${getQueryParams({ + priceRange: priceRangeState, + category: categoryState, + })}`, + ) + closeModal() + } + const handleResetFilter = () => { + setPriceRangeState(undefined) + setCategoryState(undefined) + } const hasNoFilter = priceRange !== undefined || category !== undefined @@ -70,15 +82,13 @@ const FilterFormDialog = () => { 가격대
@@ -31,20 +32,31 @@ const MenuButton = () => { > 전체 물건 보기 - { - router.push(AppPath.newCard()) - }} - > - 상품 등록 - - { - router.push(AppPath.chatRooms()) - }} - > - 채팅방 조회 - + {isLoggedIn && ( + <> + { + router.push(AppPath.myCards()) + }} + > + 내 물건 목록 + + { + router.push(AppPath.myDibs()) + }} + > + 내 찜 목록 + + { + router.push(AppPath.chatRooms()) + }} + > + 채팅 목록 + + + )} diff --git a/src/components/domain/header/sections/RightSide.tsx b/src/components/domain/header/sections/RightSide.tsx index a2d2b663..f1e859b9 100644 --- a/src/components/domain/header/sections/RightSide.tsx +++ b/src/components/domain/header/sections/RightSide.tsx @@ -1,11 +1,11 @@ +'use client' + import React from 'react' import Link from 'next/link' import Button from '@/components/ui/button' -import ApiEndPoint from '@/config/apiEndPoint' import AppPath from '@/config/appPath' -import apiClient from '@/services/apiClient' +import useNotificationCountQuery from '@/hooks/api/queries/useNotificationCountQuery' import { User } from '@/types/user' -import { getServerCookie } from '@/utils/getServerCookie' import { AvatarWithDropdown, NotificationButton } from '../components' type RightSideProps = { @@ -13,30 +13,30 @@ type RightSideProps = { currentUser: User | null } -const getUserNotificationCount = async () => { - try { - const token = getServerCookie() - const res = await apiClient.get( - ApiEndPoint.getNotificationCount(), - { - next: { revalidate: 60 }, - }, - { - Authorization: `${token}`, - }, - ) - return res.data.unReadCount - } catch (e) { - return 0 - } -} +// const getUserNotificationCount = async () => { +// try { +// const token = getServerCookie() +// const res = await apiClient.get( +// ApiEndPoint.getNotificationCount(), +// { +// cache: 'no-store', +// }, +// { +// Authorization: `${token}`, +// }, +// ) +// return res.data.unReadCount +// } catch (e) { +// return 0 +// } +// } -const RightSide = async ({ isLoggedIn, currentUser }: RightSideProps) => { - const count = await getUserNotificationCount() +const RightSide = ({ isLoggedIn, currentUser }: RightSideProps) => { + const { data } = useNotificationCountQuery({ isLoggedIn }) return isLoggedIn ? ( <> - + ) : ( diff --git a/src/components/domain/logo/Logo.tsx b/src/components/domain/logo/Logo.tsx index 0cabb623..99808dcd 100644 --- a/src/components/domain/logo/Logo.tsx +++ b/src/components/domain/logo/Logo.tsx @@ -8,7 +8,12 @@ const Logo = () => { return ( ) diff --git a/src/config/assets.ts b/src/config/assets.ts index 4fc261df..042a1536 100644 --- a/src/config/assets.ts +++ b/src/config/assets.ts @@ -16,6 +16,7 @@ import ArrowLeftIcon from '/public/images/arrow-left.svg' import AlarmIcon from '/public/images/bell.svg' import ChatIcon from '/public/images/chat.svg' import CheckCircle from '/public/images/check-circle.svg' +import ChevronLeftGray from '/public/images/chevron-left-gray.svg' import ArrowRightIcon from '/public/images/chevron-right.svg' import FilterActiveIcon from '/public/images/filter-active.svg' import FilterIcon from '/public/images/filter.svg' @@ -100,6 +101,7 @@ const Assets = { heartIcon: HeartIcon, shoppingIcon: ShoppingIcon, arrowRightIcon: ArrowRightIcon, + chevronLeftGray: ChevronLeftGray, } as const export default Assets diff --git a/src/constants/staleTime.ts b/src/constants/staleTime.ts index 1de3192b..67746ff3 100644 --- a/src/constants/staleTime.ts +++ b/src/constants/staleTime.ts @@ -1 +1 @@ -export const LIST_STALE_TIME = 60000 +export const LIST_STALE_TIME = 1000 diff --git a/src/contexts/TanstackQueryContext.tsx b/src/contexts/TanstackQueryContext.tsx index 64af7e12..328aa752 100644 --- a/src/contexts/TanstackQueryContext.tsx +++ b/src/contexts/TanstackQueryContext.tsx @@ -14,6 +14,7 @@ const TanstackQueryGlobalConfig = { retry: 3, retryDelay: 1000, suspense: true, + useErrorBoundary: true, }, }, } diff --git a/src/hooks/api/mutations/useNotificationUpdateMutation.ts b/src/hooks/api/mutations/useNotificationUpdateMutation.ts new file mode 100644 index 00000000..caa09a77 --- /dev/null +++ b/src/hooks/api/mutations/useNotificationUpdateMutation.ts @@ -0,0 +1,60 @@ +import { + InfiniteData, + useMutation, + useQueryClient, +} from '@tanstack/react-query' +import { useRouter } from 'next/navigation' +import AppPath from '@/config/appPath' +import { toast } from '@/hooks/useToast' +import { + GetNotificationListRes, + putNotificationList, +} from '@/services/notification/notification' + +export const useNotificationUpdateMutation = () => { + const router = useRouter() + const queryClient = useQueryClient() + const queryKey = ['notifications', false] + return useMutation({ + mutationFn: async ({ + notificationId, + }: { + notificationId: number + cardId: number + }) => { + let notificationIds + if (notificationId) { + notificationIds = [notificationId] + } else { + const data: InfiniteData | undefined = + queryClient.getQueryData(queryKey) + notificationIds = data?.pages.flatMap((page) => + page.data.notificationList.map( + (notification) => notification.notificationId, + ), + ) + } + await putNotificationList({ notificationIds }) + }, + + onSettled: () => { + queryClient.invalidateQueries({ + queryKey, + }) + }, + onSuccess: (data, { cardId }) => { + router.push(AppPath.mySuggestions(cardId)) + toast({ + title: '알림이 읽음 처리 되었습니다.', + duration: 1000, + }) + }, + onError: (error, _, context) => { + toast({ + title: '알림 읽음 처리 중 문제가 발생하였습니다.', + duration: 2000, + variant: 'destructive', + }) + }, + }) +} diff --git a/src/hooks/api/queries/useCardsQuery.ts b/src/hooks/api/queries/useCardsQuery.ts index 0abf3968..eb8dde15 100644 --- a/src/hooks/api/queries/useCardsQuery.ts +++ b/src/hooks/api/queries/useCardsQuery.ts @@ -29,5 +29,6 @@ export const useCardsQuery = ({ return lastPage.data.nextCursorId }, staleTime: LIST_STALE_TIME, + gcTime: 0, }) } diff --git a/src/hooks/api/queries/useNotificationCountQuery.ts b/src/hooks/api/queries/useNotificationCountQuery.ts index 6de8c65c..3f506a80 100644 --- a/src/hooks/api/queries/useNotificationCountQuery.ts +++ b/src/hooks/api/queries/useNotificationCountQuery.ts @@ -1,13 +1,12 @@ import { useQuery } from '@tanstack/react-query' import { getNotificationCount } from '@/services/notification/notification' -const useNotificationCountQuery = () => { +const useNotificationCountQuery = ({ isLoggedIn }: { isLoggedIn: boolean }) => { return useQuery({ queryKey: ['notificationCount'] as const, - queryFn: () => { - const res = getNotificationCount() - return res - }, + queryFn: () => getNotificationCount(), + refetchInterval: 1000 * 60 * 3, + enabled: isLoggedIn, }) } diff --git a/src/hooks/useDibs.ts b/src/hooks/useDibs.ts index 3d0004eb..ab8c1e69 100644 --- a/src/hooks/useDibs.ts +++ b/src/hooks/useDibs.ts @@ -1,10 +1,15 @@ 'use client' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { deleteCardDibs, postCardDibs } from '@/services/card/card' import { toast } from './useToast' const useDibs = (isMyDib: boolean, count: number) => { + useEffect(() => { + setIsDibsActive(isMyDib) + setDibsCount(count) + }, [count, isMyDib]) + const [isDibsActive, setIsDibsActive] = useState(isMyDib) const [dibsCount, setDibsCount] = useState(count) diff --git a/src/hooks/useValidate.ts b/src/hooks/useValidate.ts index 3e30613e..841fe594 100644 --- a/src/hooks/useValidate.ts +++ b/src/hooks/useValidate.ts @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react' import { useQuery } from '@tanstack/react-query' import Cookies from 'js-cookie' import { usePathname, useRouter } from 'next/navigation' +import AppPath from '@/config/appPath' import { Environment } from '@/config/environment' import apiClient from '@/services/apiClient' import { getValidateUser } from '@/services/auth/auth' @@ -26,27 +27,27 @@ const useValidate = () => { const res = await getValidateUser() return res }, + retry: 1, enabled: !!token, + throwOnError: false, }) useEffect(() => { if (isError) { - // Cookies.remove(Environment.tokenName()) - // setIsLoggedIn(() => false) - // setCurrentUser(() => null) - // router.push(AppPath.login(), { scroll: false }) - // toast({ - // title: '인증 에러', - // description: '인증에 실패하였습니다. 다시 시도하거나 로그인해주세요.', - // variant: 'destructive', - // duration: 3000, - // }) + Cookies.remove(Environment.tokenName()) + router.push(AppPath.login(), { scroll: false }) + toast({ + title: '인증 에러', + description: '만료되거나 잘못된 토큰입니다. 다시 로그인해주세요.', + variant: 'destructive', + duration: 3000, + }) } if (data) { setIsLoggedIn(() => true) setCurrentUser(() => data?.data?.userInfo) } - }, [currentUser, data, isError, isLoggedIn, pathname, router, token]) + }, [currentUser, data, isError, isLoggedIn, pathname, router, toast, token]) return { isLoggedIn, currentUser } } diff --git a/tailwind.config.js b/tailwind.config.js index 62cc0421..75684089 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -14,6 +14,10 @@ module.exports = { ], darkMode: ['class'], theme: { + screens: { + xs: { max: '480px' }, + ...require('tailwindcss/defaultConfig').theme.screens, + }, extend: { backgroundImage: () => ({ 'gradient-primary':