diff --git a/public/assets/not-found.png b/public/assets/not-found.png new file mode 100644 index 00000000..36d459df Binary files /dev/null and b/public/assets/not-found.png differ diff --git a/public/assets/notFound.png b/public/assets/notFound.png deleted file mode 100644 index 659dfa83..00000000 Binary files a/public/assets/notFound.png and /dev/null differ diff --git a/src/api/apiServer.ts b/src/api/apiServer.ts new file mode 100644 index 00000000..fd00e0d9 --- /dev/null +++ b/src/api/apiServer.ts @@ -0,0 +1,117 @@ +import axios, { AxiosError, AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios'; +import { GetServerSidePropsContext } from 'next'; + +export interface RetryRequestConfig extends InternalAxiosRequestConfig { + _retry?: boolean; + _refreshToken?: string; + _context?: GetServerSidePropsContext; +} + +export const createSeverApiInstance = (accessToken: string | undefined) => { + const instance = axios.create({ + baseURL: process.env.NEXT_PUBLIC_BASE_URL, + }); + + instance.interceptors.request.use((config) => { + if (accessToken && config.headers) { + config.headers.Authorization = `Bearer ${accessToken}`; + } + return config; + }); + + instance.interceptors.response.use( + (res) => res, + (error) => handleAxiosResponseError(error, instance), + ); + + return instance; +}; + +function handleAxiosResponseError(error: AxiosError, instance: AxiosInstance) { + const status = error.response?.status; + const originalRequest = error.config as RetryRequestConfig; + + /* _retry값 조회로 무한 요청x */ + if (originalRequest._retry) return handleCommonError(error); + /* 없으면 재시도 플래그 설정 */ + originalRequest._retry = true; + + const refreshToken = originalRequest._refreshToken; + const context = originalRequest._context; + + /* 401 에러가 아니거나 리프레시 토큰이 없거나 SSR 컨텍스트가 없으면 에러 */ + if (status !== 401 || !refreshToken || !context) return handleCommonError(error); + + try { + /* 401 에러면 리프레시 토큰 갱신 */ + return handleRetryReuquest(originalRequest, refreshToken, context, instance); + } catch (refreshTokenError) { + console.error('리프레시 토큰 갱신 실패:', refreshTokenError); + /*쿠키 삭제 */ + context.res.setHeader('Set-Cookie', [ + `accessToken=; Path=/; Max-Age=0; SameSite=Lax`, + `refreshToken=; Path=/; Max-Age=0; SameSite=Lax`, + ]); + + return handleCommonError(refreshTokenError as AxiosError); + } +} + +function handleCommonError(error: AxiosError) { + if (!error.response) { + return Promise.reject(new Error('네트워크 오류가 발생. 인터넷 상태를 확인해주세요.')); + } + + const { status, data } = error.response; + let errorMessage = (data as { message?: string })?.message ?? '서버에서 오류가 발생했습니다.'; + + console.error('API 에러 발생:', { status, errorMessage, data }); + return Promise.reject(error); +} + +export async function fetchNewRefreshTokens(refreshToken: string) { + if (!refreshToken) throw new Error('리프레쉬토큰이 없어요'); + const response = await axios.post( + `${process.env.NEXT_PUBLIC_BASE_URL}/${process.env.NEXT_PUBLIC_TEAM}/auth/refresh-token`, + { + refreshToken, + }, + ); + + return { + newAccessToken: response.data.accessToken, + newRefreshToken: response.data.refreshToken, + }; +} + +async function handleRetryReuquest( + originalRequest: RetryRequestConfig, + refreshToken: string, + context: GetServerSidePropsContext, + instance: AxiosInstance, +): Promise { + const refreshResult = await fetchNewRefreshTokens(refreshToken); + const { newAccessToken, newRefreshToken } = refreshResult; + + /* 클라이언트 쿠키에 반영되게 setCookie 옵션 넣어서 주기 */ + setAuthCookies(context.res, newAccessToken, newRefreshToken); + + /* 새 토큰으로 원래 요청의 헤더 수정 */ + if (originalRequest.headers) { + originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; + } + + /* 토큰 갱신 후 원래 요청을 재시도 */ + return instance(originalRequest); +} + +function setAuthCookies( + res: GetServerSidePropsContext['res'], + accessToken: string, + refreshToken: string, +) { + res.setHeader('Set-Cookie', [ + `accessToken=${accessToken}; Path=/; Max-Age=${60 * 5}; SameSite=Lax;`, + `refreshToken=${refreshToken}; Path=/; Max-Age=${60 * 60 * 24 * 7}; SameSite=Lax;`, + ]); +} diff --git a/src/api/editreview.ts b/src/api/editreview.ts index c9a5aabe..781112f0 100644 --- a/src/api/editreview.ts +++ b/src/api/editreview.ts @@ -1,7 +1,7 @@ import apiClient from '@/api/apiClient'; interface UpdateReviewRequest { - reviewId: number; + id: number; rating: number; lightBold: number; smoothTannic: number; @@ -17,11 +17,11 @@ interface UpdateReviewResponse { } export const updateReview = async ({ - reviewId, + id, ...body }: UpdateReviewRequest): Promise => { const response = await apiClient.patch( - `/${process.env.NEXT_PUBLIC_TEAM}/reviews/${reviewId}`, + `/${process.env.NEXT_PUBLIC_TEAM}/reviews/${id}`, body, ); return response.data; diff --git a/src/api/wineid.ts b/src/api/getWineInfo.ts similarity index 77% rename from src/api/wineid.ts rename to src/api/getWineInfo.ts index 5d0f8f3f..d991ca3f 100644 --- a/src/api/wineid.ts +++ b/src/api/getWineInfo.ts @@ -1,4 +1,3 @@ -// import { createSeverApiInstance, RetryRequestConfig } from './apiServer'; import { GetWineInfoResponse } from '@/types/WineTypes'; import apiClient from './apiClient'; diff --git a/src/api/handleLikeRequest.ts b/src/api/handleLikeRequest.ts new file mode 100644 index 00000000..ac6daaac --- /dev/null +++ b/src/api/handleLikeRequest.ts @@ -0,0 +1,9 @@ +import apiClient from '@/api/apiClient'; + +export async function postLike(reviewId: number) { + return apiClient.post(`${process.env.NEXT_PUBLIC_TEAM}/reviews/${reviewId}/like`); +} + +export async function deleteLike(reviewId: number) { + return apiClient.delete(`${process.env.NEXT_PUBLIC_TEAM}/reviews/${reviewId}/like`); +} diff --git a/src/components/Modal/DeleteModal/DeleteModal.tsx b/src/components/Modal/DeleteModal/DeleteModal.tsx index ea6ff8bb..ddb2a121 100644 --- a/src/components/Modal/DeleteModal/DeleteModal.tsx +++ b/src/components/Modal/DeleteModal/DeleteModal.tsx @@ -17,9 +17,11 @@ const DeleteModal = ({ type, id, showDeleteModal, setShowDeleteModal }: DeleteMo const deleteWineMutation = useMutation({ mutationFn: (id) => deleteWine(id), + throwOnError: true, }); const deleteReviewMutation = useMutation({ mutationFn: (id) => deleteReview(id), + throwOnError: true, }); const handleDelete = () => { @@ -38,6 +40,7 @@ const DeleteModal = ({ type, id, showDeleteModal, setShowDeleteModal }: DeleteMo deleteReviewMutation.mutate(id, { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['reviews'] }); + queryClient.invalidateQueries({ queryKey: ['wineDetail'] }); console.log('리뷰 삭제 성공'); setShowDeleteModal(false); }, diff --git a/src/components/Modal/ReviewModal/EditReviewModal.tsx b/src/components/Modal/ReviewModal/EditReviewModal.tsx index 6815fe9a..63a0f627 100644 --- a/src/components/Modal/ReviewModal/EditReviewModal.tsx +++ b/src/components/Modal/ReviewModal/EditReviewModal.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import Image from 'next/image'; @@ -15,21 +15,21 @@ import { Button } from '../../ui/button'; interface ReviewForm { rating: number; - sliderLightBold: number; - sliderSmoothTanic: number; - sliderdrySweet: number; - slidersoftAcidic: number; + lightBold: number; + smoothTannic: number; + drySweet: number; + softAcidic: number; aroma: Array; content: string; } interface ReviewData { - reviewId: number; + id: number; rating: number; - sliderLightBold: number; - sliderSmoothTanic: number; - sliderdrySweet: number; - slidersoftAcidic: number; + lightBold: number; + smoothTannic: number; + drySweet: number; + softAcidic: number; aroma: string[]; content: string; } @@ -56,7 +56,7 @@ const aromaOptions = [ '가죽', ]; -const aromaMap: Record = { +export const aromaMap: Record = { 체리: 'CHERRY', 베리: 'BERRY', 오크: 'OAK', @@ -81,18 +81,23 @@ const aromaMap: Record = { const EditReviewModal = ({ wineName, reviewData, + showEditModal, + setShowEditModal, }: { wineName: string; reviewData: ReviewData; + showEditModal: boolean; + setShowEditModal: (state: boolean) => void; }) => { - const [showEditModal, setShowEditModal] = useState(false); const queryClient = useQueryClient(); const updateReviewMutation = useMutation({ mutationFn: updateReview, + throwOnError: true, onSuccess: () => { console.log('리뷰 수정 완료'); queryClient.invalidateQueries({ queryKey: ['reviews'] }); + queryClient.invalidateQueries({ queryKey: ['wineDetail'] }); setShowEditModal(false); }, onError: (error) => { @@ -112,10 +117,10 @@ const EditReviewModal = ({ } = useForm({ defaultValues: { rating: reviewData.rating, - sliderLightBold: reviewData.sliderLightBold, - sliderSmoothTanic: reviewData.sliderSmoothTanic, - sliderdrySweet: reviewData.sliderdrySweet, - slidersoftAcidic: reviewData.slidersoftAcidic, + lightBold: reviewData.lightBold, + smoothTannic: reviewData.smoothTannic, + drySweet: reviewData.drySweet, + softAcidic: reviewData.softAcidic, content: reviewData.content, aroma: reviewData.aroma.map((eng) => { const kor = Object.keys(aromaMap).find((key) => aromaMap[key] === eng); @@ -146,12 +151,12 @@ const EditReviewModal = ({ return; } const fullData = { - reviewId: reviewData.reviewId, + id: reviewData.id, rating: data.rating, - lightBold: data.sliderLightBold, - smoothTannic: data.sliderSmoothTanic, - drySweet: data.sliderdrySweet, - softAcidic: data.slidersoftAcidic, + lightBold: data.lightBold, + smoothTannic: data.smoothTannic, + drySweet: data.drySweet, + softAcidic: data.softAcidic, aroma: data.aroma.map((a) => aromaMap[a]).filter(Boolean), content: data.content, }; @@ -167,9 +172,6 @@ const EditReviewModal = ({ return (
- setValue('sliderLightBold', val)} + onChange={(val) => setValue('lightBold', val)} labelLeft='가벼워요' labelRight='진해요' badgeLabel='바디감' /> setValue('sliderSmoothTanic', val)} + onChange={(val) => setValue('smoothTannic', val)} labelLeft='부드러워요' labelRight='떫어요' badgeLabel='타닌' /> setValue('sliderdrySweet', val)} + onChange={(val) => setValue('drySweet', val)} labelLeft='드라이해요' labelRight='달아요' badgeLabel='당도' /> setValue('slidersoftAcidic', val)} + onChange={(val) => setValue('softAcidic', val)} labelLeft='안 셔요' labelRight='많이 셔요' badgeLabel='산미' diff --git a/src/components/common/ErrorBoundary.tsx b/src/components/common/ErrorBoundary.tsx new file mode 100644 index 00000000..88ca9aef --- /dev/null +++ b/src/components/common/ErrorBoundary.tsx @@ -0,0 +1,102 @@ +import { Component, ErrorInfo, ReactNode } from 'react'; + +import { isAxiosError } from 'axios'; +import { NextRouter } from 'next/router'; + +import ErrorModal from './Modal/ErrorModal'; + +interface ErrorBoundaryProps { + children: ReactNode; + fallback?: ReactNode; + onError?: (error: Error, errorInfo: ErrorInfo) => void; + router: NextRouter; +} + +interface ErrorBoundaryState { + hasError: boolean; // 에러 발생 여부 + error: Error | null; + isOpen: boolean; + errorMessageToDisplay: string; +} + +class ErrorBoundary extends Component { + // 초기 상태 설정: 에러가 없다고 가정 + public state: ErrorBoundaryState = { + hasError: false, + error: null, + isOpen: false, + errorMessageToDisplay: '', + }; + + public static getDerivedStateFromError(error: Error): ErrorBoundaryState { + let errorMessage = '알 수 없는 에러가 발생했습니다.'; + + if (isAxiosError(error)) { + switch (error.response?.status) { + case 404: { + errorMessage = '해당 페이지를 찾을 수 없습니다.'; + break; + } + case 403: { + const apiMessage = error.response?.data?.message; + if (apiMessage === '본인이 작성한 리뷰에는 좋아요를 할 수 없습니다.') { + errorMessage = '본인이 작성한 리뷰에는 좋아요를 할 수 없습니다.'; + } else { + errorMessage = '권한이 없습니다.'; + } + break; + } + case 401: { + const apiMessage = error.response?.data?.message; + if (apiMessage === 'Unauthorized') { + errorMessage = '로그인이 필요합니다.'; + } + break; + } + default: { + errorMessage = `${error.response?.status || ''} - ${error.response?.data?.message || error.message}`; + break; + } + } + } else { + errorMessage = `${error.message}`; + } + + return { hasError: true, error, isOpen: true, errorMessageToDisplay: errorMessage }; + } + + public componentDidCatch(error: Error, errorInfo: ErrorInfo) { + const { onError } = this.props; + console.error('에러 바운더리에서 오류가 감지되었습니다:', error, errorInfo); + + if (onError) { + onError(error, errorInfo); + } + } + + // 컴포넌트 렌더링 로직 + public render() { + const { hasError, isOpen, errorMessageToDisplay } = this.state; + const { children, router } = this.props; + + if (hasError) { + return ( + {}} + onConfirm={() => { + this.setState({ isOpen: false, hasError: false }); + router.back(); + }} + > +
{errorMessageToDisplay}
+
+ ); + } + + // 에러가 없다면 자식 컴포넌트들을 그대로 렌더링 + return children; + } +} + +export default ErrorBoundary; diff --git a/src/components/common/Gnb.tsx b/src/components/common/Gnb.tsx index 7ffbc602..81194c0a 100644 --- a/src/components/common/Gnb.tsx +++ b/src/components/common/Gnb.tsx @@ -1,13 +1,12 @@ -import React, { useEffect } from 'react'; +import React from 'react'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; import Image from 'next/image'; import Link from 'next/link'; import { useRouter } from 'next/router'; -import { getUser } from '@/api/user'; +import apiClient from '@/api/apiClient'; +import { useUser } from '@/hooks/useUser'; import { cn } from '@/lib/utils'; -import { GetUserResponse } from '@/types/UserTypes'; import MenuDropdown from './dropdown/MenuDropdown'; import Logo from './Logo'; @@ -40,22 +39,16 @@ export default Gnb; function AuthMenu() { const { pathname } = useRouter(); + const { user } = useUser(); - const hasAccessToken = - typeof window === 'undefined' ? false : !!localStorage.getItem('accessToken'); + /** + 1)로그인 -> User스토어에 바로 저장이되나?(확인하고) + 2)바로 저장이 되는데 안보인다? -> user다시 조회하는 동작 추가 필요 + 3)바로 저장이 되면 보인다? -> 그냥 넘어가기 + 4) 로딩스피너 적용되는 거 보고 오버레이(뒤에 비치니까) 만약 새로고침 시 유저상태 변하는 게 보인다? -> ssr고려하기? 하 + - //아이디:abc@123.com 비번:12345678 - const { data: user, isError } = useQuery({ - queryKey: ['currentUser'], - queryFn: getUser, - staleTime: 5 * 60 * 1000, // 5분 동안 캐시 유지 - retry: false, - enabled: hasAccessToken, //로컬스토리지에 엑세스 토큰 있을 때만 요청 보내서 유효한 토큰인지 확인해봐 - }); - - useEffect(() => { - if (isError) alert('사용자 정보를 불러오지 못했습니다. 다시 로그인 해주세요.'); - }, [isError]); //요청 자체가 실패했을 때만 //초기 마운트 시에는 false일테니 if문에서 거르기 + */ return user ? ( @@ -68,23 +61,22 @@ function AuthMenu() { } interface Props { - userImage: string; + userImage: string | null; } function UserDropdown({ userImage }: Props) { const router = useRouter(); - const queryClient = useQueryClient(); function onSelect(value: string) { if (value === 'myprofile') router.push('/my-profile'); if (value === 'logout') handleLogout(); } - function handleLogout() { - localStorage.removeItem('accessToken'); - localStorage.removeItem('refreshToken'); - queryClient.removeQueries({ queryKey: ['currentUser'] }); - //-> removeQueries vs invalidateQueries :리무브는 아예 삭제 인밸리데이트는 stale상태로 + const { clearUser } = useUser(); + + async function handleLogout() { + await apiClient.get(`${process.env.NEXT_PUBLIC_API_URL}/api/auth/logout`); + clearUser(); router.push('/'); } @@ -96,8 +88,12 @@ function UserDropdown({ userImage }: Props) { ]} onSelect={onSelect} trigger={ -
- {userImage ? 유저의 프로필 사진 : } +
+ {userImage ? ( + 유저의 프로필 사진 + ) : ( + + )}
} > diff --git a/src/components/common/card/ReviewCard.tsx b/src/components/common/card/ReviewCard.tsx index 85e96ac7..7c4094c5 100644 --- a/src/components/common/card/ReviewCard.tsx +++ b/src/components/common/card/ReviewCard.tsx @@ -3,6 +3,7 @@ import Star from '@/assets/icons/star.svg'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { calculateRelativeTime } from '@/lib/calculateRelativeTime'; +import { getAromaToKr } from '@/lib/getAromaToKr'; import { cn } from '@/lib/utils'; import useReviewCardStore from '@/stores/reviewCardStore'; @@ -54,6 +55,7 @@ ReviewCard.UserHeader = function UserHeader({ userIcon, reviewId, children }: Us ReviewCard.TagAndRating = function TagAndRating({ reviewId }: TagAndRatingProps) { const tags = useReviewCardStore((state) => state.allReviews[reviewId]?.aroma ?? []); const rating = useReviewCardStore((state) => state.allReviews[reviewId]?.rating); + return (
@@ -63,7 +65,7 @@ ReviewCard.TagAndRating = function TagAndRating({ reviewId }: TagAndRatingProps) className='mt-4 rounded-full bg-white border-gray-300 px-[10px] py-[6px] md:px-[15px] md:py-2 custom-text-lg-regular text-gray-700' variant='flavor' > - {tag} + {getAromaToKr(tag)} ))}
diff --git a/src/components/common/winelist/WineFilter.tsx b/src/components/common/winelist/WineFilter.tsx index 7adeb3bf..4771719a 100644 --- a/src/components/common/winelist/WineFilter.tsx +++ b/src/components/common/winelist/WineFilter.tsx @@ -5,7 +5,6 @@ import WineTypeFilter from '@/components/common/Filter/WineTypeFilter'; import Input from '@/components/common/Input'; import WineListCard from '@/components/common/winelist/WineListCard'; import FilterModal from '@/components/Modal/FilterModal/FilterModal'; - import AddWineModal from '@/components/Modal/WineModal/AddWineModal'; import { Button } from '@/components/ui/button'; import useWineSearchKeywordStore from '@/stores/searchStore'; diff --git a/src/components/common/winelist/WineListCard.tsx b/src/components/common/winelist/WineListCard.tsx index 341fb4ca..229cbd6e 100644 --- a/src/components/common/winelist/WineListCard.tsx +++ b/src/components/common/winelist/WineListCard.tsx @@ -6,7 +6,6 @@ import { ImageCard } from '@/components/common/card/ImageCard'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; import useFilterStore from '@/stores/filterStore'; - import useWineSearchKeywordStore from '@/stores/searchStore'; import useWineStore from '@/stores/wineAddStore'; @@ -20,7 +19,6 @@ export default function WineListCard() { const { searchTerm } = useWineSearchKeywordStore(); - /* 별점 범위 필터 */ const ratingRangeMap: Record = { all: [0, 5], @@ -30,9 +28,7 @@ export default function WineListCard() { '3.1': [3.0, 3.5], }; - const filteredWines = wines.filter((wine) => { - /* 종류 필터 */ if (type && wine.type !== type) return false; /* 가격 범위 필터 */ diff --git a/src/components/wineDetail/Kebab.tsx b/src/components/wineDetail/Kebab.tsx index f158b14a..69e33bd8 100644 --- a/src/components/wineDetail/Kebab.tsx +++ b/src/components/wineDetail/Kebab.tsx @@ -1,32 +1,91 @@ +import { useState } from 'react'; + import KebabIcon from '@/assets/icons/kebab.svg'; import { Button } from '@/components/ui/button'; +import { useUser } from '@/hooks/useUser'; +import useReviewCardStore from '@/stores/reviewCardStore'; +import useWineStore from '@/stores/wineStore'; import MenuDropdown from '../common/dropdown/MenuDropdown'; +import ErrorModal from '../common/Modal/ErrorModal'; +import DeleteModal from '../Modal/DeleteModal/DeleteModal'; +import EditReviewModal from '../Modal/ReviewModal/EditReviewModal'; + +interface Props { + reviewId: number; +} + +function Kebab({ reviewId }: Props) { + const nowWine = useWineStore((state) => state.nowWine); + const reviewData = useReviewCardStore((state) => state.allReviews[reviewId]); + const [openEditModal, setOepnEditModal] = useState(false); + const [openDeleteModal, setOpenDeleteModal] = useState(false); + const [openAlertModal, setOpenAlertModal] = useState(false); + + const { user } = useUser(); -function Kebab() { - //유진님이 만든 거랑 겹치는 거 같은데 나중에 합쳐지면 그걸로 수정해두겠습니다. function onSelect(value: string) { - //-> 요거 혹시 저번에 멘토님께서 제네릭 관련 피드백 해주신 거 반영되어 있을까요??? - if (value === 'update') alert('수정하기 모달 호출'); - if (value === 'delete') alert('정말 삭제하겠습니다 alert 호출'); + switch (value) { + case 'update': { + if (user?.id !== reviewData.user.id) setOpenAlertModal(true); + else { + setOepnEditModal(true); + } + break; + } + case 'delete': { + if (user?.id !== reviewData.user.id) setOpenAlertModal(true); + else { + setOpenDeleteModal(true); + } + break; + } + } } return ( - + + + + } + > + {nowWine && reviewData && ( + + )} + {openDeleteModal && ( + + )} + {openAlertModal && ( + {}} + onConfirm={() => setOpenAlertModal(false)} > - - - } - > +
권한이 없습니다.
+ + )} + ); } diff --git a/src/components/wineDetail/LikeButton.tsx b/src/components/wineDetail/LikeButton.tsx index 3c99d077..60d20c69 100644 --- a/src/components/wineDetail/LikeButton.tsx +++ b/src/components/wineDetail/LikeButton.tsx @@ -1,20 +1,14 @@ import { useState } from 'react'; -import apiClient from '@/api/apiClient'; +import { postLike, deleteLike } from '@/api/handleLikeRequest'; import FullLikeIcon from '@/assets/icons/fullLike.svg'; import LikeIcon from '@/assets/icons/like.svg'; import { Button } from '@/components/ui/button'; +import { useUser } from '@/hooks/useUser'; import { cn } from '@/lib/utils'; +import useReviewCardStore from '@/stores/reviewCardStore'; -async function postLike(reviewId: number) { - console.log('좋아요!'); - return apiClient.post(`${process.env.NEXT_PUBLIC_TEAM}/reviews/${reviewId}/like`); -} - -async function deleteLike(reviewId: number) { - console.log('싫어요!'); - return apiClient.delete(`${process.env.NEXT_PUBLIC_TEAM}/reviews/${reviewId}/like`); -} +import ErrorModal from '../common/Modal/ErrorModal'; interface Props { isLike?: boolean; @@ -23,32 +17,47 @@ interface Props { function LikeButton({ isLike, reviewId }: Props) { const [isClicked, setIsClicked] = useState(isLike); + const [openAlertModal, setOpenAlertModal] = useState(false); - async function handleToggle() { - setIsClicked((prev) => !prev); //미리 업데이트 - //좋아요 api 요청 보내기 - // /{teamId}/reviews/{id}/like + const { user } = useUser(); + const id = useReviewCardStore((state) => state.allReviews[reviewId]?.user.id); - try { - isClicked === true ? await deleteLike(reviewId) : await postLike(reviewId); - } catch (err) { - //모달 호출 후 집어 넣기 - - setIsClicked((prev) => !prev); //실패하면 업데이트 했던 거 취소 - } + async function handleToggle() { + if (user?.id !== id) { + setIsClicked((prev) => !prev); + try { + isClicked ? await deleteLike(reviewId) : await postLike(reviewId); + } catch (err) { + setIsClicked((prev) => !prev); //실패하면 업데이트 했던 거 취소 + } + } else { + setOpenAlertModal(true); + } //미리 업데이트 } - return ( - + {openAlertModal && ( + {}} + onConfirm={() => setOpenAlertModal(false)} + > +
+ 본인이 작성한 리뷰에는 좋아요를 할 수 없습니다.{' '} +
+
)} - > - {isClicked ? : } - + ); } diff --git a/src/components/wineDetail/NoReviews.tsx b/src/components/wineDetail/NoReviews.tsx index 3125c831..8f775ca3 100644 --- a/src/components/wineDetail/NoReviews.tsx +++ b/src/components/wineDetail/NoReviews.tsx @@ -1,26 +1,28 @@ import React from 'react'; import NoReviewIcon from '@/assets/icons/noReview.svg'; +import useWineStore from '@/stores/wineStore'; -import { Button } from '../ui/button'; +import AddReviewModal from '../Modal/ReviewModal/AddReviewModal'; interface Props { className: string; } function NoReviews({ className }: Props) { + const nowWine = useWineStore((state) => state.nowWine); + + if (!nowWine) return <>에러; + const { id, name } = nowWine; + return (
- +
+ +
); } diff --git a/src/components/wineDetail/Reviews.tsx b/src/components/wineDetail/Reviews.tsx index eb669ec6..9dc695fc 100644 --- a/src/components/wineDetail/Reviews.tsx +++ b/src/components/wineDetail/Reviews.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { WineReview } from '@/types/WineTypes'; +import { GetWineInfoResponse, WineReview } from '@/types/WineTypes'; import NoReviews from './NoReviews'; import WineReviewCard from './WineReviewCard'; @@ -8,21 +8,19 @@ import WineReviewCard from './WineReviewCard'; interface Props { reviews: WineReview[]; reviewCount: number; + wine: GetWineInfoResponse; } function Reviews({ reviews, reviewCount }: Props) { + if (reviewCount <= 0) return ; return (
    {/* 추후 리뷰 타입 넣기 */} - {reviewCount > 0 ? ( - reviews.map((review: WineReview) => ( -
  • - -
  • - )) - ) : ( - - )} + {reviews.map((review: WineReview) => ( +
  • + +
  • + ))}
); } diff --git a/src/components/wineDetail/WineRating.tsx b/src/components/wineDetail/WineRating.tsx index ff5137e4..cb485ffb 100644 --- a/src/components/wineDetail/WineRating.tsx +++ b/src/components/wineDetail/WineRating.tsx @@ -1,9 +1,10 @@ import React from 'react'; -import { Button } from '@/components/ui/button'; import { Progress } from '@/components/ui/progress'; +import useWineStore from '@/stores/wineStore'; import AverageStar from './AverageStar'; +import AddReviewModal from '../Modal/ReviewModal/AddReviewModal'; interface Props { rating: number; @@ -12,6 +13,11 @@ interface Props { } function WineRating({ rating, reviewCount, ratings }: Props) { + const nowWine = useWineStore((state) => state.nowWine); + + if (!nowWine) return <>에러; + const { id, name } = nowWine; + return (
- {rating} + {rating.toFixed(1)}
{reviewCount}개의 후기
- +
+ +
- {ratings.map((rating, i) => ( + {[...ratings].reverse().map((rating, i) => (
{5 - i}점 diff --git a/src/components/wineDetail/WineReviewCard.tsx b/src/components/wineDetail/WineReviewCard.tsx index e8d850b1..6c0d2a58 100644 --- a/src/components/wineDetail/WineReviewCard.tsx +++ b/src/components/wineDetail/WineReviewCard.tsx @@ -29,7 +29,7 @@ function WineReviewCard({ review }: Props) { reviewId={id} > - + new QueryClient({ +// defaultOptions: { +// queries: { +// staleTime: 1000 * 60 * 5, // 기본 staleTime을 5분으로 설정 (선택 사항) +// }, +// }, +// })); +// } +// {/* 서버에서 전달된 dehydratedState를 Hydrate 컴포넌트에 전달 */} +// +// + +// /*2 리스트 페이지 내부에 */ +// //-> 이건 csr기준(useEffect) 버려 겟 서버사이드 프롭스로 ㄱㄱ +// //출력되고 있는 와인 정보 다 요청 미리 때려 -> +// // useEffect(()=>{ +// // wines.forEach(wine => { +// // const key = ['wineDetail', wine.id];//쿼리 키로 wineDetail 나중에 값 찾아올 때 이거 쓸거임 +// // if (!queryClient.getQueryData(key)) {//기존에 프리패치 했던 건 빼고-> 굳이 불필요하긴 함. 리액트 쿼리는 이미 캐싱되어있는데이터가 프레쉬 상태면 다시 안하기 때문 +// // queryClient.prefetchQuery(key, () => //인자 3개 받음(키, 콜백, staletime) +// // axios.get(`/wines/${wine.id}`).then(res => res.data), +// // {staleTime: 1000 * 60 * 5}, //이미 프리페칭한 것도 5분 뒤에는 그냥 새로 패치해주 +// // )} +// // }); +// // }, [wines, queryClient]); + +// /*3. 리스트 페이지 컴포넌트 외부에 */ + +// export const getServerSideProps = async () => { +// const queryClient = new QueryClient(); // 서버에서 사용할 새로운 QueryClient 인스턴스 생성 + +// await queryClient.prefetchQuery({ +// queryKey: ['wineList'], +// queryFn: fetchWineList, +// staleTime: 1000 * 60 * 1, +// }); + +// const initialWineList = queryClient.getQueryData(['wineList']); + +// // 모든 와인의 상세 정보를 프리페칭하는 대신, 처음 몇 개만 하거나 +// // 사용자에게 보여질 가능성이 높은 와인만 프리페칭하는 전략을 고려할 수 있습니다. +// const prefetchPromises = initialWineList.map(wine => { +// const key = ['wineDetail', wine.id];//쿼리 키로 wineDetail 나중에 값 찾아올 때 이거 쓸거임 +// if (!queryClient.getQueryData(key)) {//기존에 프리패치 했던 건 빼고-> 굳이 불필요하긴 함. 리액트 쿼리는 이미 캐싱되어있는데이터가 프레쉬 상태면 다시 안하기 때문 +// return queryClient.prefetchQuery(key, () => //인자 3개 받음(키, 콜백, staletime) +// axios.get(`/wines/${wine.id}`).then(res => res.data), +// { staleTime: 1000 * 60 * 5 }, //이미 프리페칭한 것도 5분 뒤에는 그냥 새로 패치해주 +// ) +// } +// }); + +// await Promise.all(prefetchPromises); // 모든 프리페칭이 완료될 때까지 기다림 + +// return { +// props: { +// // dehydrate 함수를 사용하여 QueryClient의 캐시를 직렬화(serialize)하여 props로 반환합니다. +// // 이 dehydratedState는 _app.tsx의 Hydrate 컴포넌트로 전달됩니다. +// dehydratedState: dehydrate(queryClient), +// }, +// }; +// }; + +// /*4. 사용하는 곳에서 이렇게 쓰는데 */ +// // const { data, isLoading, error } = useQuery( +// // ['wineDetail', wineId], +// // () => axios.get(`/wines/${wineId}`).then(res => res.data) +// // ); + +// // if (isLoading) return

로딩중...

; +// // if (error) return

에러 발생

; + +// //data로 html태그에 뿌려주기 + +// /*5. 목록페이지 안 거칠 껄 생각해서 여기도 겟 서버사이드 프롭 */ +// // export const getServerSideProps = async (context) => +// { +// const { id } = context.param; // URL 파라미터에서 와인 ID 추출 + +// const queryClient = new QueryClient(); // 서버에서 사용할 새로운 QueryClient 인스턴스 + +// // 해당 와인 ID의 상세 정보를 서버에서 프리페칭 +// await queryClient.prefetchQuery({ +// queryKey: ['wineDetail', id], // 이 페이지에 필요한 특정 상세 정보만 프리페칭 +// queryFn: () => fetchWineDetail(id), +// staleTime: 1000 * 60 * 5, +// }); + +// return { +// props: { +// // 서버에서 채워진 QueryClient의 캐시를 직렬화하여 클라이언트로 전달 +// dehydratedState: dehydrate(queryClient), +// }, +// }; +// }; diff --git a/src/lib/calculateRelativeTime.ts b/src/lib/calculateRelativeTime.ts index 7e19961c..db3fe624 100644 --- a/src/lib/calculateRelativeTime.ts +++ b/src/lib/calculateRelativeTime.ts @@ -24,7 +24,14 @@ export function calculateRelativeTime(time: string) { const targetDate = new Date(time).getTime(); const nowDate = new Date().getTime(); - const difference = nowDate - targetDate; + let difference = nowDate - targetDate; + + if (difference < 0) { + return '지금'; + } + if (difference < timeObj.second) { + return '지금'; + } const timeArr = Object.values(timeObj); const timeKeys = Object.keys(timeObj) as (keyof typeof timeObj)[]; diff --git a/src/lib/getAromaToKr.ts b/src/lib/getAromaToKr.ts new file mode 100644 index 00000000..572c6341 --- /dev/null +++ b/src/lib/getAromaToKr.ts @@ -0,0 +1,7 @@ +import { aromaMap } from '@/components/Modal/ReviewModal/EditReviewModal'; + +export function getAromaToKr(tag: string) { + for (const key in aromaMap) { + if (aromaMap[key] === tag) return key; + } +} diff --git a/src/pages/404.tsx b/src/pages/404.tsx index 860d831e..17329e00 100644 --- a/src/pages/404.tsx +++ b/src/pages/404.tsx @@ -11,17 +11,27 @@ export default function Custom404() { }; return ( -
+

404 Error

-
- 404 에러 이미지 +
+ 404 에러 이미지

페이지를 찾을 수 없습니다

-
+
diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index a8003e0a..bb5a742d 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -1,30 +1,35 @@ import '@/styles/globals.css'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import React from 'react'; + +import { HydrationBoundary, QueryClient, QueryClientProvider } from '@tanstack/react-query'; import clsx from 'clsx'; import Head from 'next/head'; import { useRouter } from 'next/router'; +import ErrorBoundary from '@/components/common/ErrorBoundary'; import Gnb from '@/components/common/Gnb'; import { useInitUser } from '@/hooks/useInitUser'; import type { AppProps } from 'next/app'; -const queryClient = new QueryClient(); +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); export default function App({ Component, pageProps }: AppProps) { useInitUser(); + const router = useRouter(); + const { pathname } = useRouter(); - const pagesWithoutGnb = [ - '/signup', - '/signin', - '/oauth/kakao', - '/oauth/signup/kakao', - '/_error', - '/404', - ]; + const pagesWithoutGnb = ['/signup', '/signin', '/oauth/kakao', '/oauth/signup/kakao']; const hideHeader = pagesWithoutGnb.includes(pathname); const isLanding = pathname === '/'; + const is404 = pathname === '/404'; return ( <> @@ -33,15 +38,19 @@ export default function App({ Component, pageProps }: AppProps) { - {!hideHeader && } -
- -
+ + {!hideHeader && } + } router={router}> +
+ +
+
+
); diff --git a/src/pages/api/auth/logout.ts b/src/pages/api/auth/logout.ts new file mode 100644 index 00000000..8d4a7ac2 --- /dev/null +++ b/src/pages/api/auth/logout.ts @@ -0,0 +1,12 @@ +import { NextApiRequest, NextApiResponse } from 'next'; + +import { clearAuthCookies } from '@/lib/cookie'; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + clearAuthCookies(res); + + res.status(200).json({ + message: '토큰 삭제 완료', + success: true, + }); +} diff --git a/src/pages/api/wines/[wineid].ts b/src/pages/api/wines/[wineid].ts new file mode 100644 index 00000000..8084d2ad --- /dev/null +++ b/src/pages/api/wines/[wineid].ts @@ -0,0 +1,68 @@ +import axios, { AxiosError } from 'axios'; +import { NextApiRequest, NextApiResponse } from 'next/types'; + +import { clearAuthCookies, setAuthCookies } from '@/lib/cookie'; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const { wineid } = req.query as { wineid: string }; + + const accessToken = parseCookie(req.headers.cookie, 'accessToken'); + const refreshToken = parseCookie(req.headers.cookie, 'refreshToken'); + + const fetchWineData = async (token: string) => { + return await axios.get( + `${process.env.NEXT_PUBLIC_BASE_URL}/${process.env.NEXT_PUBLIC_TEAM}/wines/${wineid}`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + }; + try { + const backendResponse = await fetchWineData(accessToken as string); + + return res.status(backendResponse.status).json(backendResponse.data); + } catch (err) { + const axiosError = err as AxiosError; + if (axiosError.response?.status === 401 && accessToken) { + //401오류면 -> + //리프레쉬 토큰 추가해서 요청 보내기 + try { + const refreshResponse = await axios.post( + `${process.env.NEXT_PUBLIC_BASE_URL}/${process.env.NEXT_PUBLIC_TEAM}/auth/refresh-token`, + { + refreshToken, + }, + ); + const newAccessToken = refreshResponse.data.accessToken; + const newRefreshToken = refreshResponse.data.refreshToken; + + setAuthCookies(res, newAccessToken, newRefreshToken); + const retryResponse = await fetchWineData(newAccessToken); + return res.status(retryResponse.status).json(retryResponse.data); + } catch (refreshErr) { + clearAuthCookies(res); + return res.status(401).json({ message: '인증에 실패했습니다. 다시 로그인해주세요.' }); + } + } + if (axiosError.response) { + return res.status(axiosError.response.status).json(axiosError.response.data); + } + } +} + +function parseCookie(cookieHeader: string | undefined, name: string): string | undefined { + if (!cookieHeader) { + return undefined; + } + const cookies = cookieHeader.split(';'); + for (const cookie of cookies) { + const [cookieName, cookieValue] = cookie.trim().split('='); + if (cookieName === name) { + // 쿠키 값은 URL 인코딩 되어 있을 수 있으므로 디코딩 + return decodeURIComponent(cookieValue); + } + } + return undefined; +} diff --git a/src/pages/wines/[wineid].tsx b/src/pages/wines/[wineid].tsx index 6ce92d65..bbb99645 100644 --- a/src/pages/wines/[wineid].tsx +++ b/src/pages/wines/[wineid].tsx @@ -1,30 +1,52 @@ -import React from 'react'; +import React, { useEffect } from 'react'; -import { useQuery } from '@tanstack/react-query'; -// import { GetServerSideProps } from 'next'; +import { dehydrate, QueryClient, useQuery } from '@tanstack/react-query'; +import axios from 'axios'; +import { GetServerSideProps } from 'next'; +import dynamic from 'next/dynamic'; import { useRouter } from 'next/router'; -import { getWineInfoForClient } from '@/api/wineid'; +import { getWineInfoForClient } from '@/api/getWineInfo'; import { ImageCard } from '@/components/common/card/ImageCard'; -import Reviews from '@/components/wineDetail/Reviews'; import WineContent from '@/components/wineDetail/WineContent'; import WineRating from '@/components/wineDetail/WineRating'; import { cn } from '@/lib/utils'; +import useWineStore from '@/stores/wineStore'; +import { GetWineInfoResponse } from '@/types/WineTypes'; -export default function WineInfoById() { +interface WinePageProps { + wineData?: GetWineInfoResponse; // getWineInfo가 반환하는 WineInfo 타입을 사용 + error?: string; + dehydratedState: any; + parsedWineId: number; +} + +const Reviews = dynamic(() => import('@/components/wineDetail/Reviews'), { ssr: false }); + +export default function WineInfoById(props: WinePageProps) { const router = useRouter(); - const parsedWineId = Number(router.query.wineid); + const { parsedWineId: id } = props; + // 주소로 직접 들어왔을 때(SSR) //목록에서 링크로 들어왔을 때(CSR) + const parsedWineId = id ? id : Number(router.query.wineid); + const setNowWine = useWineStore((state) => state.setNowWine); //서버든 목록(클라이언트든) 캐싱된 데이터 사용 const { data, isLoading } = useQuery({ queryKey: ['wineDetail', parsedWineId], queryFn: () => getWineInfoForClient(parsedWineId), staleTime: 1000 * 60 * 5, + throwOnError: true, }); + useEffect(() => { + if (data) setNowWine(data); + }, [data]); + if (isLoading) return
123
; //테스트용 - if (!data) return <>; //테스트용 + if (!data) { + throw new Error('존재하지 않는 와인입니다.'); + } return (
@@ -42,9 +64,7 @@ export default function WineInfoById() {

리뷰 목록

-
    - -
+
= async (context) => { + const { params } = context; + const wineid = params?.wineid; + const parsedWineId = Number(wineid); + const queryClient = new QueryClient(); + const cookies = context.req?.headers.cookie || ''; + + try { + await queryClient.prefetchQuery({ + queryKey: ['wineDetail', parsedWineId], + queryFn: async () => { + //추후 배포용 주소로 변경 + const res = await axios.get( + `${process.env.NEXT_PUBLIC_API_URL}/api/wines/${parsedWineId}`, + { + headers: { + Cookie: cookies, // API Route에 쿠키 전달 + }, + }, + ); + return res.data; + }, + staleTime: 0, + retry: false, + }); + console.log(`[getServerSideProps] 와인 상세 정보 프리패치 성공 (ID: ${parsedWineId})`); + } catch (error: any) { + console.error(`[SSR] 와인 상세 정보 로딩 중 최종 에러:`, error.message || error); + } + + // prefetch한 queryClient의 캐시를 직렬화, 클라이언트에 전달. + return { + props: { + dehydratedState: dehydrate(queryClient), + parsedWineId, + }, + }; +}; diff --git a/src/pages/wines/index.tsx b/src/pages/wines/index.tsx deleted file mode 100644 index 4461d227..00000000 --- a/src/pages/wines/index.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import WineFilter from '@/components/common/winelist/WineFilter'; -import WineListCard from '@/components/common/winelist/WineListCard'; -import WineSlider from '@/components/common/winelist/WineSlider'; - -export default function Wines() { - return ( -
- - -
- -
-
- ); -} diff --git a/src/stores/reviewCardStore.ts b/src/stores/reviewCardStore.ts index b73a8ef9..46704fab 100644 --- a/src/stores/reviewCardStore.ts +++ b/src/stores/reviewCardStore.ts @@ -19,7 +19,7 @@ interface ReviewStates { toggleReviewOpen: (reviewId: number) => void; } -export const reviewStore = create((set) => ({ +const reviewStore = create((set) => ({ allReviews: {}, setReviews: (reviewData) => { set((state) => { @@ -28,10 +28,7 @@ export const reviewStore = create((set) => ({ ...state.allReviews, [reviewData.id]: { ...reviewData, - isOpen: - Object.keys(state.allReviews).length === 0 //처음에 allReviews비어있으면 isOpen :true - ? true - : (state.allReviews[reviewData.id]?.isOpen ?? false), + isOpen: state.allReviews[reviewData.id]?.isOpen ?? false, }, }, }; diff --git a/src/stores/searchStore.ts b/src/stores/searchStore.ts index 74cf3156..69d20428 100644 --- a/src/stores/searchStore.ts +++ b/src/stores/searchStore.ts @@ -6,10 +6,8 @@ type SearchState = { }; const useWineSearchKeywordStore = create((set) => ({ - searchTerm: '', setSearchTerm: (term) => set({ searchTerm: term }), })); export default useWineSearchKeywordStore; - diff --git a/src/stores/wineStore.ts b/src/stores/wineStore.ts new file mode 100644 index 00000000..8e77d579 --- /dev/null +++ b/src/stores/wineStore.ts @@ -0,0 +1,23 @@ +import { create } from 'zustand'; +import { shallow } from 'zustand/shallow'; +import { useStoreWithEqualityFn } from 'zustand/traditional'; + +import { GetWineInfoResponse } from '@/types/WineTypes'; + +interface WineStates { + nowWine: GetWineInfoResponse | null; + setNowWine: (wine: GetWineInfoResponse) => void; +} + +const wineStore = create((set) => ({ + nowWine: null, + + setNowWine: (wine) => { + set({ nowWine: wine }); + }, +})); + +const useWineStore = (selector: (state: WineStates) => T): T => + useStoreWithEqualityFn(wineStore, selector, shallow); + +export default useWineStore;