diff --git a/src/app/(with-header)/myprofile/_components/MyProfile.tsx b/src/app/(with-header)/myprofile/_components/MyProfile.tsx index 001f780..46ea280 100644 --- a/src/app/(with-header)/myprofile/_components/MyProfile.tsx +++ b/src/app/(with-header)/myprofile/_components/MyProfile.tsx @@ -2,6 +2,7 @@ import Image from 'next/image'; import { ChangeEvent, useRef, useState } from 'react'; +import { toast } from 'react-toastify'; import photoIcon from '@/assets/icons/photo.svg'; import ProfileImg from '@/components/ProfileImg'; import Button from '@/components/Button'; @@ -45,7 +46,11 @@ export function MyProfile({ profileData, upLoadImgFile, upLoadUserData }: MuProf const onClickUploadButton = async () => { if (nickNameValue === '') { - alert('닉네임을 입력해 주세요.'); + toast.error('닉네임을 입력해 주세요.'); + return; + } + if (nickNameValue.length > 30) { + toast.error('닉네임은 최대 30글자입니다.'); return; } if (!fileInput && nickNameValue === preNickName) { @@ -93,7 +98,7 @@ export function MyProfile({ profileData, upLoadImgFile, upLoadUserData }: MuProf )}
-

{preNickName}

+

{preNickName}

@@ -122,7 +127,7 @@ export function MyProfile({ profileData, upLoadImgFile, upLoadUserData }: MuProf text='적용하기' onClick={onClickUploadButton} disabled={nickNameValue === ''} - className={`rounded-[12px] px-[20px] py-[8px] text-lg tablet:px-[30px] tablet:py-[11px] mobile:px-[20px] mobile:py-[9px] mobile:text-md ${nickNameValue === '' ? 'bg-gray-400' : ''}`} + className={`rounded-[12px] px-[20px] py-[8px] text-lg disabled:bg-gray-400 tablet:px-[30px] tablet:py-[11px] mobile:px-[20px] mobile:py-[9px] mobile:text-md`} /> )}
diff --git a/src/app/(with-header)/myprofile/_components/MyProfileContainer.tsx b/src/app/(with-header)/myprofile/_components/MyProfileContainer.tsx index f541656..5b2c1fc 100644 --- a/src/app/(with-header)/myprofile/_components/MyProfileContainer.tsx +++ b/src/app/(with-header)/myprofile/_components/MyProfileContainer.tsx @@ -1,10 +1,12 @@ 'use client'; import { useEffect, useState } from 'react'; -import { fetchWithAuth } from '@/lib/auth'; -import { MyProfile, MyProfileData } from './MyProfile'; -import LoadingSpinner from '@/components/LoadingSpinner'; +import { toast } from 'react-toastify'; +import { MyProfile, MyProfileData } from '@/app/(with-header)/myprofile/_components/MyProfile'; import Refresh from '@/components/Refresh'; +import { fetchUploadUser, fetchUser } from '@/lib/fetchUser'; +import { fetchImage } from '@/lib/fetchImage'; +import MyProfileSkeleton from './skeleton/MyProfileSkeleton'; export default function MyProfileContainer() { const [profileData, setProfileData] = useState(); @@ -13,20 +15,13 @@ export default function MyProfileContainer() { const getUserData = async () => { setError(''); + setIsLoading(true); try { - setIsLoading(true); - const response = await fetchWithAuth(`${process.env.NEXT_PUBLIC_BASE_URL}/users/me`); - - if (!response?.ok || response === null) { - setError('유저 데이터를 불러오는데 실패했습니다.'); - return; - } - - const data: MyProfileData = await response.json(); + const data = await fetchUser(); setProfileData(data); - } catch (error) { - if (error instanceof Error) { - setError(error.message); + } catch (e) { + if (e instanceof Error) { + setError(e.message); } else { setError('알 수 없는 오류가 발생했습니다.'); } @@ -36,56 +31,25 @@ export default function MyProfileContainer() { }; const upLoadImgFile = async (formData: FormData): Promise => { - setError(''); try { - const response = await fetchWithAuth(`${process.env.NEXT_PUBLIC_BASE_URL}/images/upload`, { - method: 'POST', - body: formData, - }); - - if (!response?.ok || response === null) { - setError('이미지를 업로드하는데 실패했습니다.'); - return; - } - - const data = await response.json(); - return data.url as string; - } catch (error) { - if (error instanceof Error) { - setError(error.message); - } else { - setError('알 수 없는 오류가 발생했습니다.'); + const data = await fetchImage(formData); + return data; + } catch (e) { + if (e) { + toast.error('이미지 업로드에 실패했습니다.'); } } }; const upLoadUserData = async (image: string, nickname: string): Promise => { - setError(''); try { - const response = await fetchWithAuth(`${process.env.NEXT_PUBLIC_BASE_URL}/users/me`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ image: image, nickname: nickname }), - }); - - if (response?.status === 400) { - alert('동일한 닉네임이 있습니다.'); - } - - if (!response?.ok || response === null) { - setError('데이터를 업데이트 하는데 실패했습니다.'); - return; - } - - const result = await response.json(); + const result = await fetchUploadUser(image, nickname); + if (!result) return; + toast.success('프로필 수정에 성공했습니다.'); return result; - } catch (error) { - if (error instanceof Error) { - setError(error.message); - } else { - setError('알 수 없는 오류가 발생했습니다.'); + } catch (e) { + if (e) { + toast.error('프로필 수정에 실패했습니다.'); } } }; @@ -94,7 +58,7 @@ export default function MyProfileContainer() { getUserData(); }, []); - if (isLoading) return ; + if (isLoading) return ; if (!profileData || error) return ( diff --git a/src/app/(with-header)/myprofile/_components/MyReviewItem.tsx b/src/app/(with-header)/myprofile/_components/MyReviewItem.tsx index cb182d0..6902eb7 100644 --- a/src/app/(with-header)/myprofile/_components/MyReviewItem.tsx +++ b/src/app/(with-header)/myprofile/_components/MyReviewItem.tsx @@ -4,19 +4,19 @@ import elapsedTime from '@/utils/formatDate'; import like from '@/assets/icons/star_hover.svg'; import Image from 'next/image'; import { useRouter } from 'next/navigation'; -import MyReviewKebabDropDown from './MyReviewKebabDropDown'; +import MyReviewKebabDropDown from '@/app/(with-header)/myprofile/_components/MyReviewKebabDropDown'; import { MyReview } from '@/types/review-data'; -import { EditReviewData } from './MyReviewListContainer'; +import { EditReviewData } from '@/app/(with-header)/myprofile/_components/MyReviewListContainer'; +import { forwardRef } from 'react'; -export function MyReviewItem({ - reviewInitialData, - editMyReview, - deleteMyReview, -}: { +interface MyReviewItemProps { reviewInitialData: MyReview; editMyReview: (id: number, editReviewData: EditReviewData, updatedAt: string) => void; deleteMyReview: (id: number) => void; -}) { + setDataCount: React.Dispatch>; +} + +const MyReviewItem = forwardRef(({ reviewInitialData, editMyReview, deleteMyReview, setDataCount }, ref) => { const router = useRouter(); const reviewElapsedTime = elapsedTime(reviewInitialData.updatedAt); @@ -29,14 +29,14 @@ export function MyReviewItem({ }; return ( -
+
별점 아이콘 {reviewInitialData.rating.toFixed(1)}
- + {reviewElapsedTime}
@@ -46,4 +46,8 @@ export function MyReviewItem({
); -} +}); + +MyReviewItem.displayName = 'MyReviewItem'; + +export default MyReviewItem; diff --git a/src/app/(with-header)/myprofile/_components/MyReviewKebabDropDown.tsx b/src/app/(with-header)/myprofile/_components/MyReviewKebabDropDown.tsx index 00a0aa3..7c14af9 100644 --- a/src/app/(with-header)/myprofile/_components/MyReviewKebabDropDown.tsx +++ b/src/app/(with-header)/myprofile/_components/MyReviewKebabDropDown.tsx @@ -2,25 +2,28 @@ import { useState } from 'react'; import Image from 'next/image'; +import { toast } from 'react-toastify'; import Dropdown from '@/components/Dropdown'; import kebab from '@/assets/icons/menu.svg'; import Modal from '@/components/modal/Modal'; import DeleteWineForm from '@/components/modal/DeleteWineModal'; -import { fetchWithAuth } from '@/lib/auth'; import PatchReviewForm from '@/components/modal/PatchReviewForm'; import { MyReview } from '@/types/review-data'; -import { EditReviewData } from './MyReviewListContainer'; +import { fetchDeleteReview } from '@/lib/fetchMyReivew'; +import { EditReviewData } from '@/app/(with-header)/myprofile/_components/MyReviewListContainer'; export default function MyReviewKebabDropDown({ reviewInitialData, id, editMyReview, deleteMyReview, + setDataCount, }: { reviewInitialData: MyReview; id: number; editMyReview: (id: number, editReviewData: EditReviewData, updatedAt: string) => void; deleteMyReview: (id: number) => void; + setDataCount: React.Dispatch>; }) { const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); @@ -48,23 +51,20 @@ export default function MyReviewKebabDropDown({ const handleDeleteWine = async () => { try { - const response = await fetchWithAuth(`${process.env.NEXT_PUBLIC_BASE_URL}/reviews/${id}`, { - method: 'DELETE', - }); - - if (!response?.ok || response === null) { - throw new Error('리뷰 삭제에 실패했습니다'); - } - - const body = await response.json(); + const body = await fetchDeleteReview(id); if (body) { if (deleteMyReview) { deleteMyReview(id); + toast.success('리뷰 삭제에 성공했습니다.'); + setDataCount((pre) => pre - 1); } closeDeleteModal(); } - } catch (error) { - console.error('리뷰 삭제 에러:', error); + } catch (e) { + if (e) { + toast.error('리뷰 삭제에 실패했습니다.'); + closeDeleteModal(); + } } }; diff --git a/src/app/(with-header)/myprofile/_components/MyReviewListContainer.tsx b/src/app/(with-header)/myprofile/_components/MyReviewListContainer.tsx index 8aedc41..6f71048 100644 --- a/src/app/(with-header)/myprofile/_components/MyReviewListContainer.tsx +++ b/src/app/(with-header)/myprofile/_components/MyReviewListContainer.tsx @@ -1,13 +1,14 @@ 'use client'; import Image from 'next/image'; -import { useCallback, useEffect, useState } from 'react'; -import { fetchWithAuth } from '@/lib/auth'; +import { useCallback, useEffect, useRef, useState } from 'react'; import emptyData from '@/assets/icons/empty_review.svg'; -import { MyReview, MyReviewResponse } from '@/types/review-data'; -import { MyReviewItem } from '@/app/(with-header)/myprofile/_components/MyReviewItem'; -import LoadingSpinner from '@/components/LoadingSpinner'; +import { MyReview } from '@/types/review-data'; +import MyReviewItem from '@/app/(with-header)/myprofile/_components/MyReviewItem'; import Refresh from '@/components/Refresh'; +import { fetchMyReview } from '@/lib/fetchMyReivew'; +import MyReviewItemSkeleton from './skeleton/MyReviewItemSkeleton'; +import LoadingSpinner from '@/components/LoadingSpinner'; export interface EditReviewData { rating: number; @@ -20,25 +21,22 @@ export interface EditReviewData { wineId: number; } -export default function MyReviewListContainer({ setDataCount }: { setDataCount: (value: number) => void }) { +export default function MyReviewListContainer({ setDataCount }: { setDataCount: React.Dispatch> }) { const [myReviewData, setMyReviewData] = useState([]); const [isLoading, setIsloading] = useState(true); const [error, setError] = useState(''); + const [nextCursor, setNextCursor] = useState(null); + const [isMoreLoading, setIsMoreLoading] = useState(false); + const lastReviewRef = useRef(null); const getMyReview = useCallback(async () => { setError(''); + setIsloading(true); try { - setIsloading(true); - const response = await fetchWithAuth(`${process.env.NEXT_PUBLIC_BASE_URL}/users/me/reviews?limit=30`); - - if (!response?.ok || response === null) { - setError('리뷰 데이터를 불러오는데 실패했습니다.'); - return; - } - - const data: MyReviewResponse = await response.json(); + const data = await fetchMyReview(5); setMyReviewData(data.list); setDataCount(data.totalCount); + setNextCursor(data.nextCursor); } catch (error) { if (error instanceof Error) { setError(error.message); @@ -50,6 +48,26 @@ export default function MyReviewListContainer({ setDataCount }: { setDataCount: } }, [setDataCount]); + const getMoreMyReview = useCallback(async () => { + if (nextCursor === null) return; + setError(''); + setIsMoreLoading(true); + try { + const data = await fetchMyReview(5, nextCursor || undefined); + setMyReviewData((prev) => [...prev, ...data.list]); + setDataCount(data.totalCount); + setNextCursor(data.nextCursor); + } catch (error) { + if (error instanceof Error) { + setError(error.message); + } else { + setError('알 수 없는 오류가 발생했습니다.'); + } + } finally { + setIsMoreLoading(false); + } + }, [setDataCount, nextCursor]); + const deleteMyReview = (id: number) => { const updatedReviewList = myReviewData.filter((value) => value.id !== id); setMyReviewData(updatedReviewList); @@ -69,7 +87,31 @@ export default function MyReviewListContainer({ setDataCount }: { setDataCount: getMyReview(); }, [getMyReview]); - if (isLoading) return ; + useEffect(() => { + if (!lastReviewRef.current) return; + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) { + getMoreMyReview(); + } + }, + { + root: null, + threshold: 0.5, + }, + ); + observer.observe(lastReviewRef.current); + return () => observer.disconnect(); + }, [getMoreMyReview]); + + if (isLoading) + return ( +
+ {[...Array(3)].map((_, index) => ( + + ))} +
+ ); if (error) return ( @@ -84,17 +126,25 @@ export default function MyReviewListContainer({ setDataCount }: { setDataCount: if (myReviewData.length === 0) return ( -
+
데이터 없음

내가 등록한 후기가 없어요

); return ( -
- {myReviewData.map((value) => ( - +
+ {myReviewData.map((value, index) => ( + ))} + {isMoreLoading && }
); } diff --git a/src/app/(with-header)/myprofile/_components/MyWIneKebabDropDown .tsx b/src/app/(with-header)/myprofile/_components/MyWIneKebabDropDown .tsx index 781dc2c..6ef3073 100644 --- a/src/app/(with-header)/myprofile/_components/MyWIneKebabDropDown .tsx +++ b/src/app/(with-header)/myprofile/_components/MyWIneKebabDropDown .tsx @@ -2,12 +2,13 @@ import { useState } from 'react'; import Image from 'next/image'; +import { toast } from 'react-toastify'; import Dropdown from '@/components/Dropdown'; import kebab from '@/assets/icons/menu.svg'; import Modal from '@/components/modal/Modal'; import PatchWineForm from '@/components/modal/PatchWineForm'; import DeleteWineForm from '@/components/modal/DeleteWineModal'; -import { fetchWithAuth } from '@/lib/auth'; +import { fetchDeleteWine } from '@/lib/fetchWines'; export interface WineDataProps { name: string; @@ -22,11 +23,13 @@ export default function MyWIneKebabDropDown({ wineInitialData, editMyWine, deleteMyWine, + setDataCount, }: { id: number; wineInitialData: WineDataProps; editMyWine: (id: number, editWineData: WineDataProps) => void; deleteMyWine: (id: number) => void; + setDataCount?: React.Dispatch>; }) { const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); @@ -54,19 +57,18 @@ export default function MyWIneKebabDropDown({ const handleDeleteWine = async () => { try { - const response = await fetchWithAuth(`${process.env.NEXT_PUBLIC_BASE_URL}/wines/${id}`, { - method: 'DELETE', - }); - - if (!response?.ok || response === null) return alert('와인 삭제에 실패했습니다'); - - const body = await response.json(); - if (body) { + const data = await fetchDeleteWine(id); + if (data && setDataCount) { deleteMyWine(id); + toast.success('와인 삭제에 성공했습니다.'); + setDataCount((value) => value - 1); + closeDeleteModal(); + } + } catch (e) { + if (e) { + toast.error('와인 삭제에 실패했습니다.'); closeDeleteModal(); } - } catch (error) { - console.error('와인 삭제 에러:', error); } }; diff --git a/src/app/(with-header)/myprofile/_components/MyWineListContainer.tsx b/src/app/(with-header)/myprofile/_components/MyWineListContainer.tsx index a6671fe..5ec48be 100644 --- a/src/app/(with-header)/myprofile/_components/MyWineListContainer.tsx +++ b/src/app/(with-header)/myprofile/_components/MyWineListContainer.tsx @@ -1,34 +1,32 @@ 'use client'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import Image from 'next/image'; -import { fetchWithAuth } from '@/lib/auth'; -import { WineListResponse, WineDetails } from '@/types/wine'; +import { WineDetails } from '@/types/wine'; import emptyData from '@/assets/icons/empty_review.svg'; import WineCard from '@/components/WineCard'; -import { WineDataProps } from './MyWIneKebabDropDown '; -import LoadingSpinner from '@/components/LoadingSpinner'; +import { WineDataProps } from '@/app/(with-header)/myprofile/_components/MyWIneKebabDropDown '; import Refresh from '@/components/Refresh'; +import { fetchMyWine } from '@/lib/fetchMyWine'; +import MyWineItemSkeleton from './skeleton/MyWineItemSkeleton'; +import LoadingSpinner from '@/components/LoadingSpinner'; -export default function MyWineListContainer({ setDataCount }: { setDataCount: (value: number) => void }) { +export default function MyWineListContainer({ setDataCount }: { setDataCount: React.Dispatch> }) { const [myWineData, setMyWineData] = useState([]); const [isLoading, setIsloading] = useState(true); const [error, setError] = useState(''); + const [nextCursor, setNextCursor] = useState(null); + const [isMoreLoading, setIsMoreLoading] = useState(false); + const lastWineRef = useRef(null); const getMyWine = useCallback(async () => { setError(''); + setIsloading(true); try { - setIsloading(true); - const response = await fetchWithAuth(`${process.env.NEXT_PUBLIC_BASE_URL}/users/me/wines?limit=30`); - - if (!response?.ok || response === null) { - setError('와인 데이터를 불러오는데 실패했습니다.'); - return; - } - - const data: WineListResponse = await response.json(); + const data = await fetchMyWine(5); setMyWineData(data.list); setDataCount(data.totalCount); + setNextCursor(data.nextCursor); } catch (error) { if (error instanceof Error) { setError(error.message); @@ -40,6 +38,26 @@ export default function MyWineListContainer({ setDataCount }: { setDataCount: (v } }, [setDataCount]); + const getMoreMyWine = useCallback(async () => { + if (nextCursor === null) return; + setError(''); + setIsMoreLoading(true); + try { + const data = await fetchMyWine(5, nextCursor || undefined); + setMyWineData((prev) => [...prev, ...data.list]); + setDataCount(data.totalCount); + setNextCursor(data.nextCursor); + } catch (error) { + if (error instanceof Error) { + setError(error.message); + } else { + setError('알 수 없는 오류가 발생했습니다.'); + } + } finally { + setIsMoreLoading(false); + } + }, [setDataCount, nextCursor]); + const deleteMyWine = (id: number) => { const updatedWineList = myWineData.filter((value) => value.id !== id); setMyWineData(updatedWineList); @@ -59,7 +77,31 @@ export default function MyWineListContainer({ setDataCount }: { setDataCount: (v getMyWine(); }, [getMyWine]); - if (isLoading) return ; + useEffect(() => { + if (!lastWineRef.current) return; + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) { + getMoreMyWine(); + } + }, + { + root: null, + threshold: 0.5, + }, + ); + observer.observe(lastWineRef.current); + return () => observer.disconnect(); + }, [getMoreMyWine]); + + if (isLoading) + return ( +
+ {[...Array(3)].map((_, index) => ( + + ))} +
+ ); if (error) return ( @@ -81,8 +123,8 @@ export default function MyWineListContainer({ setDataCount }: { setDataCount: (v ); return ( -
- {myWineData.map((value) => ( +
+ {myWineData.map((value, index) => ( ))} + {isMoreLoading && }
); } diff --git a/src/app/(with-header)/myprofile/_components/skeleton/MyProfileSkeleton.tsx b/src/app/(with-header)/myprofile/_components/skeleton/MyProfileSkeleton.tsx new file mode 100644 index 0000000..5e2f6ce --- /dev/null +++ b/src/app/(with-header)/myprofile/_components/skeleton/MyProfileSkeleton.tsx @@ -0,0 +1,31 @@ +export default function MyProfileSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +} diff --git a/src/app/(with-header)/myprofile/_components/skeleton/MyReviewItemSkeleton.tsx b/src/app/(with-header)/myprofile/_components/skeleton/MyReviewItemSkeleton.tsx new file mode 100644 index 0000000..c5d5d41 --- /dev/null +++ b/src/app/(with-header)/myprofile/_components/skeleton/MyReviewItemSkeleton.tsx @@ -0,0 +1,21 @@ +export default function MyReviewItemSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +} diff --git a/src/app/(with-header)/myprofile/_components/skeleton/MyWineItemSkeleton.tsx b/src/app/(with-header)/myprofile/_components/skeleton/MyWineItemSkeleton.tsx new file mode 100644 index 0000000..663d554 --- /dev/null +++ b/src/app/(with-header)/myprofile/_components/skeleton/MyWineItemSkeleton.tsx @@ -0,0 +1,24 @@ +export default function MyWineItemSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +} diff --git a/src/app/(with-header)/myprofile/page.tsx b/src/app/(with-header)/myprofile/page.tsx index 841c684..f60b655 100644 --- a/src/app/(with-header)/myprofile/page.tsx +++ b/src/app/(with-header)/myprofile/page.tsx @@ -6,6 +6,7 @@ import MyProfileContainer from './_components/MyProfileContainer'; import MyReviewListContainer from './_components/MyReviewListContainer'; import MyWineListContainer from './_components/MyWineListContainer'; import { getAccessToken } from '@/lib/auth'; +import { toast } from 'react-toastify'; export default function Page() { const [category, setCategory] = useState('내가 쓴 후기'); @@ -24,8 +25,8 @@ export default function Page() { useEffect(() => { const token = getAccessToken(); if (!token) { + toast.error('로그인 후 이용 가능합니다.'); router.push('/signin'); - alert('로그인 후 이용 가능합니다.'); return; } }, [router]); diff --git a/src/components/WineCard.tsx b/src/components/WineCard.tsx index d3da9e0..84ab4d5 100644 --- a/src/components/WineCard.tsx +++ b/src/components/WineCard.tsx @@ -3,6 +3,7 @@ import Image from 'next/image'; import { useRouter } from 'next/navigation'; import MyWIneKebabDropDown, { WineDataProps } from '@/app/(with-header)/myprofile/_components/MyWIneKebabDropDown '; +import { forwardRef } from 'react'; export interface WineCardProps { id: number; @@ -16,50 +17,64 @@ export interface WineCardProps { type?: 'RED' | 'WHITE' | 'SPARKLING'; editMyWine?: (id: number, editWineData: WineDataProps) => void; deleteMyWine?: (id: number) => void; + setDataCount?: React.Dispatch>; } -export default function WineCard({ id, name, region, image, price, isKebab = false, size = 'large', onClick = false, type = 'RED', editMyWine, deleteMyWine }: WineCardProps) { - const router = useRouter(); +const WineCard = forwardRef( + ({ id, name, region, image, price, isKebab = false, size = 'large', onClick = false, type = 'RED', editMyWine, deleteMyWine, setDataCount }, ref) => { + const router = useRouter(); - const cardWrapperStyle = - size === 'large' - ? 'max-w-[1140px] h-[260px] gap-[86px] pt-[52px] pb-[40px] pl-[100px] pr-[40px] tablet:pl-[60px] tablet:gap-[60px] mobile:pl-[40px] mobile:pt-[33px] mobile:pb-[29.5px] mobile:h-[190px]' - : 'pc:w-[800px] h-[228px] gap-[40px] py-[30px] pl-[60px] pr-[40px] tablet:w-full mobile:w-full mobile:pb-[16.5px] mobile:pt-[20px] mobile:h-[164px] mobile:pl-[40px]'; + const cardWrapperStyle = + size === 'large' + ? 'max-w-[1140px] h-[260px] gap-[86px] pt-[52px] pb-[40px] pl-[100px] pr-[40px] tablet:pl-[60px] tablet:gap-[60px] mobile:pl-[40px] mobile:pt-[33px] mobile:pb-[29.5px] mobile:h-[190px]' + : 'flex-shrink-0 pc:w-[800px] h-[228px] gap-[40px] py-[30px] pl-[60px] pr-[40px] tablet:w-full mobile:w-full mobile:pb-[16.5px] mobile:pt-[20px] mobile:h-[164px] mobile:pl-[40px]'; - const wineImgSize = size === 'large' ? 'h-[208px] mobile:h-[155px] mobile:w-[50px]' : 'h-[198px] mobile:h-[144px] mobile:w-[50px]'; + const wineImgSize = size === 'large' ? 'h-[208px] mobile:h-[155px] mobile:w-[50px]' : 'h-[198px] mobile:h-[144px] mobile:w-[50px]'; - const wineClamp = size === 'large' ? 'max-w-[850px] mobile:max-w-[550px]' : 'max-w-[600px] tablet:max-w-[800px] mobile:max-w-[550px] pr-[24px]'; + const wineClamp = size === 'large' ? 'max-w-[850px] mobile:max-w-[550px]' : 'max-w-[600px] tablet:max-w-[800px] mobile:max-w-[550px] pr-[24px]'; - const toWineDetailPage = (event: React.MouseEvent) => { - if ((event.target as HTMLElement).closest('.ignore-click')) { - event.stopPropagation(); - return; - } - router.push(`/wines/${id}`); - }; + const toWineDetailPage = (event: React.MouseEvent) => { + if ((event.target as HTMLElement).closest('.ignore-click')) { + event.stopPropagation(); + return; + } + router.push(`/wines/${id}`); + }; - const onClickWineCard = onClick ? (event: React.MouseEvent) => toWineDetailPage(event) : undefined; + const onClickWineCard = onClick ? (event: React.MouseEvent) => toWineDetailPage(event) : undefined; - return ( -
-
- 와인 이미지 -
-
-
-

{name}

-

{region}

+ return ( +
+
+ 와인 이미지 +
+
+
+

{name}

+

{region}

+
+ {isKebab && editMyWine && deleteMyWine && ( + + )} + + ₩ {Number(price).toLocaleString()} +
- {isKebab && editMyWine && deleteMyWine && ( - - )} - - ₩ {Number(price).toLocaleString()} -
-
- ); -} + ); + }, +); + +WineCard.displayName = 'WineCard'; + +export default WineCard; diff --git a/src/components/modal/PatchReviewForm.tsx b/src/components/modal/PatchReviewForm.tsx index de0e270..64cbca6 100644 --- a/src/components/modal/PatchReviewForm.tsx +++ b/src/components/modal/PatchReviewForm.tsx @@ -3,6 +3,7 @@ import { useState, useEffect } from 'react'; import Image from 'next/image'; import { useForm, SubmitHandler } from 'react-hook-form'; +import { toast } from 'react-toastify'; import { fetchWithAuth } from '@/lib/auth'; import Button from '@/components/Button'; import close from '@/assets/icons/close.svg'; @@ -10,7 +11,6 @@ import wineIcon from '@/assets/icons/wine.svg'; import InteractiveRating from '../InteractiveRating'; import ControlBar from '../ControlBar'; import { MyReview } from '@/types/review-data'; -import { toast } from 'react-toastify'; export interface EditReviewData { rating: number; @@ -126,9 +126,10 @@ export default function PatchReviewForm({ name, id, onClose, reviewInitialData, onClose(); } } catch (error) { - onClose(); - toast.error('리뷰 수정에 실패했습니다.'); - console.error('리뷰 수정 에러:', error); + if (error) { + onClose(); + toast.error('리뷰 수정에 실패했습니다.'); + } } }; diff --git a/src/components/modal/PatchWineForm.tsx b/src/components/modal/PatchWineForm.tsx index d2507c4..854e05a 100644 --- a/src/components/modal/PatchWineForm.tsx +++ b/src/components/modal/PatchWineForm.tsx @@ -4,6 +4,7 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; import Image from 'next/image'; import { useForm, SubmitHandler } from 'react-hook-form'; +import { toast } from 'react-toastify'; import { fetchWithAuth } from '@/lib/auth'; import Dropdown from '../Dropdown'; import Button from '../Button'; @@ -49,14 +50,19 @@ export default function PatchWineForm({ onClose, id, wineInitialData, editMyWine body: JSON.stringify({ name, region, image, price: Number(price), type }), }); - if (!response?.ok || response === null) return alert('와인 수정에 실패했습니다'); + if (!response?.ok || response === null) { + throw new Error('와인 수정에 실패했습니다.'); + } const body = await response.json(); editMyWine(body.id, { ...data, type: data.type as 'RED' | 'WHITE' | 'SPARKLING' }); + toast.success('와인 수정에 성공했습니다.'); router.push(`/wines/${body.id}`); } catch (error) { - console.error('와인 수정 에러:', error); - console.log(data); + if (error) { + onClose(); + toast.error('와인 수정에 실패했습니다.'); + } } }; diff --git a/src/lib/fetchImage.ts b/src/lib/fetchImage.ts new file mode 100644 index 0000000..3e554a4 --- /dev/null +++ b/src/lib/fetchImage.ts @@ -0,0 +1,21 @@ +import { fetchWithAuth } from './auth'; + +export async function fetchImage(formData: FormData): Promise { + try { + const response = await fetchWithAuth(`${process.env.NEXT_PUBLIC_BASE_URL}/images/upload`, { + method: 'POST', + body: formData, + }); + + if (!response?.ok || response === null) { + throw new Error('이미지를 업로드하는 데 실패했습니다.'); + } + const data = await response.json(); + return data.url as string; + } catch (error: unknown) { + if (error instanceof Error) { + throw new Error(`이미지 데이터 로드 실패: ${error.message}`); + } + throw new Error('알 수 없는 오류가 발생했습니다.'); + } +} diff --git a/src/lib/fetchMyReivew.ts b/src/lib/fetchMyReivew.ts new file mode 100644 index 0000000..f113f96 --- /dev/null +++ b/src/lib/fetchMyReivew.ts @@ -0,0 +1,44 @@ +import { MyReviewResponse } from '@/types/review-data'; +import { fetchWithAuth } from './auth'; + +export async function fetchMyReview(limit: number, cursor?: number): Promise { + const params = new URLSearchParams(); + params.append('limit', limit.toString()); + if (cursor) params.append('cursor', cursor.toString()); + + try { + const response = await fetchWithAuth(`${process.env.NEXT_PUBLIC_BASE_URL}/users/me/reviews?${params.toString()}`); + + if (!response?.ok || response === null) { + throw new Error('리뷰 데이터를 불러오는 데 실패했습니다.'); + } + + const data = await response.json(); + return data; + } catch (error: unknown) { + if (error instanceof Error) { + throw new Error(`리뷰 데이터 로드 실패: ${error.message}`); + } + throw new Error('알 수 없는 오류가 발생했습니다.'); + } +} + +export async function fetchDeleteReview(id: number): Promise { + try { + const response = await fetchWithAuth(`${process.env.NEXT_PUBLIC_BASE_URL}/reviews/${id}`, { + method: 'DELETE', + }); + + if (!response?.ok || response === null) { + throw new Error('리뷰 삭제에 실패했습니다'); + } + + const body = await response.json(); + return body.id; + } catch (error: unknown) { + if (error instanceof Error) { + throw new Error(`리뷰 데이터 로드 실패: ${error.message}`); + } + throw new Error('알 수 없는 오류가 발생했습니다.'); + } +} diff --git a/src/lib/fetchMyWine.ts b/src/lib/fetchMyWine.ts new file mode 100644 index 0000000..8cbc793 --- /dev/null +++ b/src/lib/fetchMyWine.ts @@ -0,0 +1,23 @@ +import { WineListResponse } from '@/types/wine'; +import { fetchWithAuth } from './auth'; + +export async function fetchMyWine(limit: number, cursor?: number): Promise { + const params = new URLSearchParams(); + params.append('limit', limit.toString()); + if (cursor) params.append('cursor', cursor.toString()); + try { + const response = await fetchWithAuth(`${process.env.NEXT_PUBLIC_BASE_URL}/users/me/wines?${params.toString()}`); + + if (!response?.ok || response === null) { + throw new Error('와인 데이터를 불러오는 데 실패했습니다.'); + } + + const data = await response.json(); + return data; + } catch (error: unknown) { + if (error instanceof Error) { + throw new Error(`와인 데이터 로드 실패: ${error.message}`); + } + throw new Error('알 수 없는 오류가 발생했습니다.'); + } +} diff --git a/src/lib/fetchUser.ts b/src/lib/fetchUser.ts new file mode 100644 index 0000000..de2eeaf --- /dev/null +++ b/src/lib/fetchUser.ts @@ -0,0 +1,50 @@ +import { toast } from 'react-toastify'; +import { MyProfileData } from '@/app/(with-header)/myprofile/_components/MyProfile'; +import { fetchWithAuth } from './auth'; + +export async function fetchUser(): Promise { + try { + const response = await fetchWithAuth(`${process.env.NEXT_PUBLIC_BASE_URL}/users/me`); + + if (!response?.ok || response === null) { + throw new Error('유저 데이터를 불러오는 데 실패했습니다.'); + } + + const data = await response.json(); + return data; + } catch (error: unknown) { + if (error instanceof Error) { + throw new Error(`유저 데이터 로드 실패: ${error.message}`); + } + throw new Error('알 수 없는 오류가 발생했습니다.'); + } +} + +export async function fetchUploadUser(image: string, nickname: string): Promise { + try { + const response = await fetchWithAuth(`${process.env.NEXT_PUBLIC_BASE_URL}/users/me`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ image: image, nickname: nickname }), + }); + + if (response?.status === 400) { + toast.error('동일한 닉네임이 있습니다.'); + return undefined; + } + + if (!response?.ok || response === null) { + throw new Error('데이터를 업데이트 하는 데 실패했습니다.'); + } + + const result = await response.json(); + return result; + } catch (error: unknown) { + if (error instanceof Error) { + throw new Error(`유저 데이터 로드 실패: ${error.message}`); + } + throw new Error('알 수 없는 오류가 발생했습니다.'); + } +} diff --git a/src/lib/fetchWines.ts b/src/lib/fetchWines.ts index 7ca069c..728a27e 100644 --- a/src/lib/fetchWines.ts +++ b/src/lib/fetchWines.ts @@ -1,4 +1,5 @@ import { WineListResponse } from '@/types/wine'; +import { fetchWithAuth } from './auth'; interface FetchWinesParams { limit: number; @@ -28,3 +29,23 @@ export async function fetchWines({ limit, cursor, type, minPrice, maxPrice, rati return res.json(); } + +export async function fetchDeleteWine(id: number): Promise { + try { + const response = await fetchWithAuth(`${process.env.NEXT_PUBLIC_BASE_URL}/wines/${id}`, { + method: 'DELETE', + }); + + if (!response?.ok || response === null) { + throw new Error('와인 삭제에 실패했습니다'); + } + + const body = await response.json(); + return body.id; + } catch (error: unknown) { + if (error instanceof Error) { + throw new Error(`이미지 데이터 로드 실패: ${error.message}`); + } + throw new Error('알 수 없는 오류가 발생했습니다.'); + } +}