diff --git a/next.config.js b/next.config.js index b7c6b5d..ed030da 100644 --- a/next.config.js +++ b/next.config.js @@ -12,7 +12,6 @@ const nextConfig = { }, ], }, - webpack(config) { config.module.rules = config.module.rules.filter( (rule) => !(rule.test && rule.test.test && rule.test.test('.svg')), diff --git a/src/api/myReviews.ts b/src/api/myReviews.ts new file mode 100644 index 0000000..960b083 --- /dev/null +++ b/src/api/myReviews.ts @@ -0,0 +1,49 @@ +import apiClient from '@/api/apiClient'; + +import type { MyReviewsResponse } from '@/types/MyReviewsTypes'; + +const DEFAULT_LIMIT = 10; +const BASE_PATH = '/users/me/reviews'; + +/** + * 내 리뷰 조회 옵션 + */ +export interface FetchMyReviewsOptions { + /** 조회 시작 커서 (기본: 0) */ + cursor?: number | null; + /** 한 페이지당 아이템 수 (기본: DEFAULT_LIMIT) */ + limit?: number; +} + +/** + * 내 리뷰 목록 가져오기 + * + * @param options.cursor 시작 커서 (기본 0) + * @param options.limit 페이지 크기 (기본 DEFAULT_LIMIT) + * @returns Promise + * @throws {Error} NEXT_PUBLIC_TEAM 환경변수가 없으면 예외 발생 + */ +export const getMyReviews = async ( + options: FetchMyReviewsOptions = {}, +): Promise => { + const { cursor = 0, limit = DEFAULT_LIMIT } = options; + + const teamId = process.env.NEXT_PUBLIC_TEAM; + if (!teamId) { + throw new Error('환경변수 NEXT_PUBLIC_TEAM이 설정되지 않았습니다. 빌드 환경을 확인해주세요.'); + } + + const url = `/${teamId}${BASE_PATH}`; + + // API 호출 + const response = await apiClient.get(url, { + params: { cursor, limit }, + }); + + // 요청 디버그 로그 (개발 환경에서만 활성화 권장) + if (process.env.NODE_ENV === 'development') { + console.debug('[API] getMyReviews', { url, cursor, limit, response }); + } + + return response; +}; diff --git a/src/api/myWines.ts b/src/api/myWines.ts new file mode 100644 index 0000000..f26a06c --- /dev/null +++ b/src/api/myWines.ts @@ -0,0 +1,47 @@ +import apiClient from '@/api/apiClient'; + +import type { MyWinesResponse } from '@/types/MyWinesTypes'; + +const DEFAULT_LIMIT = 10; +const BASE_PATH = '/users/me/wines'; + +/** + * 내 와인 조회 옵션 + */ +export interface FetchMyWinesOptions { + /** 조회 시작 커서 (기본: 0) */ + cursor?: number | null; + /** 한 페이지당 아이템 수 (기본: DEFAULT_LIMIT) */ + limit?: number; +} + +/** + * 내 와인 목록 가져오기 + * + * @param options.cursor 시작 커서 (기본 0) + * @param options.limit 페이지 크기 (기본 DEFAULT_LIMIT) + * @returns Promise + * @throws {Error} NEXT_PUBLIC_TEAM 환경변수가 없으면 예외 발생 + */ +export const getMyWines = async (options: FetchMyWinesOptions = {}): Promise => { + const { cursor = 0, limit = DEFAULT_LIMIT } = options; + + const teamId = process.env.NEXT_PUBLIC_TEAM; + if (!teamId) { + throw new Error('환경변수 NEXT_PUBLIC_TEAM이 설정되지 않았습니다. 빌드 환경을 확인해주세요.'); + } + + const url = `/${teamId}${BASE_PATH}`; + + // API 호출 + const response = await apiClient.get(url, { + params: { cursor, limit }, + }); + + // 요청 디버그 로그 + if (process.env.NODE_ENV === 'development') { + console.debug('[API] getMyWines', { url, cursor, limit, response }); + } + + return response; +}; diff --git a/src/components/common/Gnb.tsx b/src/components/common/Gnb.tsx index e7f2245..7ffbc60 100644 --- a/src/components/common/Gnb.tsx +++ b/src/components/common/Gnb.tsx @@ -76,7 +76,7 @@ function UserDropdown({ userImage }: Props) { const queryClient = useQueryClient(); function onSelect(value: string) { - if (value === 'myprofile') router.push('/myprofile'); + if (value === 'myprofile') router.push('/my-profile'); if (value === 'logout') handleLogout(); } diff --git a/src/components/myprofile/Profile.tsx b/src/components/my-profile/Profile.tsx similarity index 100% rename from src/components/myprofile/Profile.tsx rename to src/components/my-profile/Profile.tsx diff --git a/src/components/my-profile/ReviewList.tsx b/src/components/my-profile/ReviewList.tsx new file mode 100644 index 0000000..21b5525 --- /dev/null +++ b/src/components/my-profile/ReviewList.tsx @@ -0,0 +1,94 @@ +import React, { useEffect, useRef } from 'react'; + +import { useInfiniteQuery } from '@tanstack/react-query'; + +import { getMyReviews } from '@/api/myReviews'; +import DotIcon from '@/assets/icons/dot.svg'; +import { MyCard } from '@/components/common/card/MyCard'; +import MenuDropdown from '@/components/common/dropdown/MenuDropdown'; +import { Badge } from '@/components/ui/badge'; +import { useInfiniteScroll } from '@/hooks/useInfiniteScroll'; +import { MyReview } from '@/types/MyReviewsTypes'; + +const PAGE_LIMIT = 10; + +interface ReviewListProps { + setTotalCount: (count: number) => void; +} +/** + * ReviewList 컴포넌트 + * + * 무한 스크롤을 통해 사용자의 리뷰 목록을 페이징하여 불러옴 + * IntersectionObserver로 스크롤 끝에 도달 시 다음 페이지를 자동으로 로드 + * + */ +export function ReviewList({ setTotalCount }: ReviewListProps) { + const observerRef = useRef(null); + + // useInfiniteQuery 훅으로 리뷰 데이터를 무한 스크롤 형태로 조회 + const { data, isLoading, isError, fetchNextPage, hasNextPage, isFetchingNextPage } = + useInfiniteQuery({ + queryKey: ['myReviews'], + queryFn: ({ pageParam = 0 }) => getMyReviews({ cursor: pageParam, limit: PAGE_LIMIT }), + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage.nextCursor ?? null, + }); + // xhx + useEffect(() => { + if (data?.pages?.[0]?.totalCount != null) { + setTotalCount(data.pages[0].totalCount); + } + }, [data, setTotalCount]); + + // IntersectionObserver 훅 적용으로 스크롤 끝 감지 + useInfiniteScroll({ + targetRef: observerRef, + hasNextPage, + fetchNextPage, + isFetching: isFetchingNextPage, + }); + + // 로딩 및 에러 상태 처리 (임시) + if (isLoading) return

불러오는 중…

; + if (isError) return

불러오기 실패

; + if (!data) return

리뷰 데이터가 없습니다.

; + + // 리뮤 목록 평탄화 + const reviews: MyReview[] = data?.pages?.flatMap((page) => page.list ?? []) ?? []; + + return ( +
+ {reviews.map((review) => ( + + + ★ {review.rating.toFixed(1)} + + + } + timeAgo={new Date(review.createdAt).toLocaleDateString()} + title={review.user.nickname} + review={review.content} + rightSlot={ + + + + } + options={[ + { label: '수정하기', value: 'edit' }, + { label: '삭제하기', value: 'delete' }, + ]} + onSelect={(value) => console.log(`${value} clicked: review id=${review.id}`)} + /> + } + /> + ))} + {/* 옵저버 감지 요소 */} +
+
+ ); +} diff --git a/src/components/myprofile/Tab.tsx b/src/components/my-profile/Tab.tsx similarity index 100% rename from src/components/myprofile/Tab.tsx rename to src/components/my-profile/Tab.tsx diff --git a/src/components/my-profile/WineList.tsx b/src/components/my-profile/WineList.tsx new file mode 100644 index 0000000..2652a07 --- /dev/null +++ b/src/components/my-profile/WineList.tsx @@ -0,0 +1,100 @@ +import React, { useEffect, useRef } from 'react'; + +import { useInfiniteQuery } from '@tanstack/react-query'; + +import { getMyWines } from '@/api/myWines'; +import DotIcon from '@/assets/icons/dot.svg'; +import { ImageCard } from '@/components/common/card/ImageCard'; +import MenuDropdown from '@/components/common/dropdown/MenuDropdown'; +import { Badge } from '@/components/ui/badge'; +import { useInfiniteScroll } from '@/hooks/useInfiniteScroll'; + +import type { MyWine, MyWinesResponse } from '@/types/MyWinesTypes'; + +const PAGE_LIMIT = 10; +interface WineListProps { + setTotalCount: (count: number) => void; +} +/** + * WineList 컴포넌트 + * + * 무한 스크롤을 통해 사용자의 와인 목록을 페이징하여 불러옴 + * IntersectionObserver로 스크롤 끝에 도달 시 추가 페이지를 자동으로 로드 + * + */ +export function WineList({ setTotalCount }: WineListProps) { + const observerRef = useRef(null); + + //useInfiniteQuery 훅으로 와인 데이터를 무한 스크롤 형태로 조회 + const { data, isLoading, isError, fetchNextPage, hasNextPage, isFetchingNextPage } = + useInfiniteQuery({ + queryKey: ['myWines'], + queryFn: ({ pageParam = 0 }) => getMyWines({ cursor: pageParam, limit: PAGE_LIMIT }), + initialPageParam: 0, + getNextPageParam: (lastPage: MyWinesResponse | undefined) => lastPage?.nextCursor ?? null, + }); + + useEffect(() => { + if (data?.pages?.[0]?.totalCount != null) { + setTotalCount(data.pages[0].totalCount); + } + }, [data, setTotalCount]); + + // IntersectionObserver 훅 적용으로 스크롤 끝 감지 + useInfiniteScroll({ + targetRef: observerRef, + hasNextPage: !!hasNextPage, + fetchNextPage, + isFetching: isFetchingNextPage, + }); + + // 로딩 및 에러 상태 처리 (임시) + if (isLoading) return

와인 불러오는 중…

; + if (isError || !data) return

와인 불러오기 실패

; + + // 와인 목록 평탄화 + const wines: MyWine[] = data?.pages?.flatMap((page) => page?.list ?? []) ?? []; + + return ( +
+ {wines.map((wine) => ( + + + + } + options={[ + { label: '수정하기', value: 'edit' }, + { label: '삭제하기', value: 'delete' }, + ]} + onSelect={(value) => console.log(`${value} clicked for wine id: ${wine.id}`)} + /> + } + > +
+

+ {wine.name} +

+

+ {wine.region} +

+ + + ₩ {wine.price.toLocaleString()} + + +
+
+ ))} + {/* 옵저버 감지 요소 */} +
+
+ ); +} diff --git a/src/components/myprofile/ReviewList.tsx b/src/components/myprofile/ReviewList.tsx deleted file mode 100644 index 89cf7b7..0000000 --- a/src/components/myprofile/ReviewList.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import React from 'react'; - -import { useQuery } from '@tanstack/react-query'; - -import DotIcon from '@/assets/icons/dot.svg'; -import { MyCard } from '@/components/common/card/MyCard'; -import MenuDropdown from '@/components/common/dropdown/MenuDropdown'; -import { Badge } from '@/components/ui/badge'; - -import { mockMyReviewsPage1 } from './mockUser'; - -/** - * Review 타입 정의 (mock 데이터에서 추론) - */ -type Review = (typeof mockMyReviewsPage1.list)[number]; - -/** - * 데이터 가져오는 함수 (현재는 mock, 추후 API 호출로 교체) - * 데이터 패치 내용은 무한스크롤 훅 구현 후 수정될 예정입니다 - */ -async function fetchReviews(): Promise { - return mockMyReviewsPage1.list; -} - -/** - * ReviewList 컴포넌트 - * - React Query의 useQuery 훅을 사용해 리뷰 데이터를 패칭 - * - 로딩 및 에러 상태를 처리한 뒤, MyCard 컴포넌트로 리스트를 렌더링 - */ -export function ReviewList() { - // React Query로 리뷰 데이터 요청 - const { - data: items = [], - isLoading, - isError, - } = useQuery({ - queryKey: ['myReviews'], - queryFn: fetchReviews, - }); - - // 로딩 중 표시 - if (isLoading) { - return

리뷰 불러오는 중…

; - } - - // 에러 시 표시 - if (isError) { - return

리뷰 불러오기 실패

; - } - - // 실제 리뷰 리스트 렌더링 - return ( -
- {items.map((review) => ( - - - ★ {review.rating.toFixed(1)} - - - } - // 작성일 - timeAgo={new Date(review.createdAt).toLocaleDateString()} - // 작성자 닉네임 - title={review.user.nickname} - // 리뷰 내용 - review={review.content} - // dot 아이콘 클릭 시 드롭다운 오픈 - rightSlot={ - - - - } - options={[ - { label: '수정하기', value: 'edit' }, - { label: '삭제하기', value: 'delete' }, - ]} - onSelect={(value) => console.log(`${value} clicked for review id: ${review.id}`)} - /> - } - /> - ))} -
- ); -} diff --git a/src/components/myprofile/WineList.tsx b/src/components/myprofile/WineList.tsx deleted file mode 100644 index 5b8c155..0000000 --- a/src/components/myprofile/WineList.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import React from 'react'; - -import { useQuery } from '@tanstack/react-query'; - -import DotIcon from '@/assets/icons/dot.svg'; -import { ImageCard } from '@/components/common/card/ImageCard'; -import MenuDropdown from '@/components/common/dropdown/MenuDropdown'; -import { Badge } from '@/components/ui/badge'; - -import { mockMyWinesPage1 } from './mockUser'; - -/** - * Wine 타입 정의 (mock 데이터에서 추론) - */ -type Wine = (typeof mockMyWinesPage1.list)[number]; - -/** - * 데이터 가져오는 함수 (현재는 mock, 추후 API 호출로 교체) - * 데이터 패치 내용은 무한스크롤 훅 구현 후 수정될 예정입니다 - */ -async function fetchWines(): Promise { - return mockMyWinesPage1.list; -} - -/** - * WineList 컴포넌트 - * - React Query의 useQuery 훅을 사용해 리뷰 데이터를 패칭 - * - 로딩 및 에러 상태를 처리한 뒤, ImageCard 컴포넌트로 리스트를 렌더링 - */ -export function WineList() { - // React Query로 와인 목록 패칭 - const { - data: items = [], - isLoading, - isError, - } = useQuery({ - queryKey: ['myWines'], - queryFn: fetchWines, - }); - - // 로딩 중 표시 - if (isLoading) { - return

와인 불러오는 중…

; - } - - // 에러 시 표시 - if (isError) { - return

와인 불러오기 실패

; - } - - return ( -
- {items.map((w) => ( - - - - } - options={[ - { label: '수정하기', value: 'edit' }, - { label: '삭제하기', value: 'delete' }, - ]} - onSelect={(value) => console.log(`${value} clicked for wine id: ${w.id}`)} - /> - } - > - {/* 카드 내부: 와인 정보 */} -
-

- {w.name} {/* 와인 이름 */} -

-

- {w.region} {/* 생산 지역 */} -

- - - {/* 가격 표시 */}₩ {w.price.toLocaleString()} - - -
-
- ))} -
- ); -} diff --git a/src/components/myprofile/mockUser.ts b/src/components/myprofile/mockUser.ts deleted file mode 100644 index 10d36ce..0000000 --- a/src/components/myprofile/mockUser.ts +++ /dev/null @@ -1,217 +0,0 @@ -// get으로 응답받는 responese -// 초기에 불러올 데이터 -export const mockMyReviewsPage1 = { - totalCount: 4, - nextCursor: 2, - list: [ - { - id: 101, - rating: 4.8, - lightBold: 2, - smoothTannic: 4, - drySweet: 1, - softAcidic: 3, - aroma: ['CHERRY', 'OAK'], - content: '진한 체리향과 부드러운 탄닌이 좋았어요.', - createdAt: '2025-07-25T02:50:33.040669Z', - updatedAt: '2025-07-25T02:50:33.040669Z', - user: { - id: 1, - nickname: '와인러버', - image: 'https://picsum.photos/seed/me1/32', - }, - isLiked: false, - }, - { - id: 102, - rating: 4.2, - lightBold: 3, - smoothTannic: 2, - drySweet: 2, - softAcidic: 2, - aroma: ['BLACKCURRANT'], - content: '깔끔하고 가볍게 마시기 좋은 와인이에요.', - createdAt: '2025-07-25T02:50:33.040669Z', - updatedAt: '2025-07-25T02:50:33.040669Z', - user: { - id: 1, - nickname: '와인러버', - image: 'https://picsum.photos/seed/me1/32', - }, - isLiked: true, - }, - ], -}; - -export const mockMyReviewsPage2 = { - totalCount: 4, - nextCursor: null, - list: [ - { - id: 103, - rating: 4.9, - lightBold: 4, - smoothTannic: 5, - drySweet: 1, - softAcidic: 4, - aroma: ['SPICE', 'PLUM'], - content: '풍미 깊고 고급스러운 맛이에요.', - createdAt: '2025-07-25T02:50:33.040669Z', - updatedAt: '2025-07-25T02:50:33.040669Z', - user: { - id: 1, - nickname: '와인러버', - image: 'https://picsum.photos/seed/me1/32', - }, - isLiked: false, - }, - { - id: 104, - rating: 3.8, - lightBold: 2, - smoothTannic: 1, - drySweet: 3, - softAcidic: 2, - aroma: ['FLORAL'], - content: '꽃향이 강하게 나서 인상 깊었어요.', - createdAt: '2025-07-25T02:50:33.040669Z', - updatedAt: '2025-07-25T02:50:33.040669Z', - user: { - id: 1, - nickname: '와인러버', - image: 'https://picsum.photos/seed/me1/32', - }, - isLiked: true, - }, - ], -}; - -export const mockMyWinesPage1 = { - totalCount: 4, - nextCursor: 2, - list: [ - { - id: 201, - name: 'Opus One 2019', - region: 'Napa Valley, USA', - image: '/wine.png', - price: 350000, - type: 'Red', - avgRating: 4.6, - reviewCount: 8, - userId: 1, - recentReview: { - id: 101, - rating: 4.8, - lightBold: 2, - smoothTannic: 4, - drySweet: 1, - softAcidic: 3, - aroma: ['CHERRY', 'OAK'], - content: '진한 체리향과 부드러운 탄닌이 좋았어요.', - createdAt: '2025-07-25T02:50:33.040669Z', - updatedAt: '2025-07-25T02:50:33.040669Z', - user: { - id: 1, - nickname: '와인러버', - image: 'https://picsum.photos/seed/me1/32', - }, - isLiked: false, - }, - }, - { - id: 202, - name: 'Sassicaia 2018', - region: 'Tuscany, Italy', - image: '/wine.png', - price: 290000, - type: 'Red', - avgRating: 4.7, - reviewCount: 10, - userId: 1, - recentReview: { - id: 102, - rating: 4.2, - lightBold: 3, - smoothTannic: 2, - drySweet: 2, - softAcidic: 2, - aroma: ['BLACKCURRANT'], - content: '깔끔하고 가볍게 마시기 좋은 와인이에요.', - createdAt: '2025-07-25T02:50:33.040669Z', - updatedAt: '2025-07-25T02:50:33.040669Z', - user: { - id: 1, - nickname: '와인러버', - image: 'https://picsum.photos/seed/me1/32', - }, - isLiked: true, - }, - }, - ], -}; - -export const mockMyWinesPage2 = { - totalCount: 4, - nextCursor: null, - list: [ - { - id: 203, - name: 'Château Latour 2010', - region: 'Bordeaux, France', - image: '/wine.png', - price: 410000, - type: 'Red', - avgRating: 4.9, - reviewCount: 12, - userId: 1, - recentReview: { - id: 103, - rating: 4.9, - lightBold: 4, - smoothTannic: 5, - drySweet: 1, - softAcidic: 4, - aroma: ['SPICE', 'PLUM'], - content: '풍미 깊고 고급스러운 맛이에요.', - createdAt: '2025-07-25T02:50:33.040669Z', - updatedAt: '2025-07-25T02:50:33.040669Z', - user: { - id: 1, - nickname: '와인러버', - image: 'https://picsum.photos/seed/me1/32', - }, - isLiked: false, - }, - }, - { - id: 204, - name: 'Cloudy Bay Sauvignon Blanc 2021', - region: 'Marlborough, New Zealand', - image: '/wine.png', - price: 65000, - type: 'White', - avgRating: 4.3, - reviewCount: 6, - userId: 1, - recentReview: { - id: 104, - rating: 3.8, - lightBold: 2, - smoothTannic: 1, - drySweet: 3, - softAcidic: 2, - aroma: ['FLORAL'], - content: '꽃향이 강하게 나서 인상 깊었어요.', - createdAt: '2025-07-25T02:50:33.040669Z', - updatedAt: '2025-07-25T02:50:33.040669Z', - user: { - id: 1, - nickname: '와인러버', - image: 'https://picsum.photos/seed/me1/32', - }, - isLiked: true, - }, - }, - ], -}; diff --git a/src/hooks/useInfiniteScroll.tsx b/src/hooks/useInfiniteScroll.tsx new file mode 100644 index 0000000..5f86638 --- /dev/null +++ b/src/hooks/useInfiniteScroll.tsx @@ -0,0 +1,47 @@ +import React, { useEffect } from 'react'; + +interface UseInfiniteScrollProps { + targetRef: React.RefObject; // 관찰할 DOM 요소의 ref + hasNextPage: boolean; // 다음 페이지가 있는지 여부 + fetchNextPage: () => void; // 다음 페이지를 불러오는 함수 + isFetching?: boolean; // 현재 데이터 요청 중인지 여부 (중복 요청 방지용) + threshold?: number; // 뷰포트에 걸리는 비율 (0~1) +} + +/** + * useInfiniteScroll 훅 + * + * - targetRef로 전달된 요소가 뷰포트에 나타나면 fetchNextPage()를 호출합니다. + * - hasNextPage가 true이고 isFetching이 false일 때만 동작합니다. + * - IntersectionObserver API를 활용하여 구현됩니다. + */ +export function useInfiniteScroll({ + targetRef, + hasNextPage, + fetchNextPage, + isFetching = false, + threshold = 0.5, +}: UseInfiniteScrollProps) { + useEffect(() => { + const element = targetRef.current; + + // 조건 확인: 요소가 없거나, 다음 페이지가 없거나, 로딩 중이면 종료 + if (!element || !hasNextPage || isFetching) return; + + const observer = new IntersectionObserver( + (entries) => { + const [entry] = entries; + if (entry.isIntersecting) { + fetchNextPage(); // 요소가 화면에 보이면 다음 페이지 로드 + } + }, + { threshold }, + ); + + observer.observe(element); // 옵저버 등록 + + return () => { + observer.disconnect(); // 언마운트 시 옵저버 해제 + }; + }, [targetRef.current, hasNextPage, isFetching, fetchNextPage, threshold]); +} diff --git a/src/pages/my-profile/index.tsx b/src/pages/my-profile/index.tsx new file mode 100644 index 0000000..980f08f --- /dev/null +++ b/src/pages/my-profile/index.tsx @@ -0,0 +1,41 @@ +import React, { useState } from 'react'; + +import Profile from '@/components/my-profile/Profile'; +import { ReviewList } from '@/components/my-profile/ReviewList'; +import { TabNav } from '@/components/my-profile/Tab'; +import { WineList } from '@/components/my-profile/WineList'; + +export default function MyProfile() { + // 탭 상태: 'reviews' | 'wines' + const [tab, setTab] = useState<'reviews' | 'wines'>('reviews'); + + // 각각의 totalCount를 상태로 관리 + const [reviewsCount, setReviewsCount] = useState(0); + const [winesCount, setWinesCount] = useState(0); + + return ( +
+
+ {/* 프로필 섹션 */} + + + {/* 탭 & 리스트 섹션 */} +
+ + + {/* 탭에 따라 ReviewList 또는 WineList 렌더링 */} + {tab === 'reviews' ? ( + + ) : ( + + )} +
+
+
+ ); +} diff --git a/src/pages/myprofile/index.tsx b/src/pages/myprofile/index.tsx deleted file mode 100644 index 026b2c9..0000000 --- a/src/pages/myprofile/index.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React, { useState } from 'react'; - -import { mockMyReviewsPage1, mockMyWinesPage1 } from '@/components/myprofile/mockUser'; -import Profile from '@/components/myprofile/Profile'; -import { ReviewList } from '@/components/myprofile/ReviewList'; -import { TabNav } from '@/components/myprofile/Tab'; -import { WineList } from '@/components/myprofile/WineList'; - -export default function MyProfile() { - // 탭 상태: 'reviews' | 'wines' - const [tab, setTab] = useState<'reviews' | 'wines'>('reviews'); - - return ( -
-
- {/* 프로필 섹션 */} - - - {/* 탭 & 리스트 섹션 */} -
- - - {/* 탭에 따라 ReviewList 또는 WineList에 props 전달 */} - {tab === 'reviews' ? : } -
-
-
- ); -} diff --git a/src/types/MyReviewsTypes.ts b/src/types/MyReviewsTypes.ts new file mode 100644 index 0000000..8ce9956 --- /dev/null +++ b/src/types/MyReviewsTypes.ts @@ -0,0 +1,33 @@ +export interface MyReview { + id: number; + rating: number; + lightBold: number; + smoothTannic: number; + drySweet: number; + softAcidic: number; + aroma: string[]; + content: string; + createdAt: string; + updatedAt: string; + user: { + id: number; + nickname: string; + image: string; + }; + isLiked?: Record; + wine: { + id: number; + name: string; + region: string; + type: string; + image: string; + price: number; + avgRating: number; + }; +} + +export interface MyReviewsResponse { + totalCount: number; + nextCursor: number | null; + list: MyReview[]; +} diff --git a/src/types/MyWinesTypes.ts b/src/types/MyWinesTypes.ts new file mode 100644 index 0000000..1da1008 --- /dev/null +++ b/src/types/MyWinesTypes.ts @@ -0,0 +1,30 @@ +export type MyWine = { + id: number; + name: string; + region: string; + image: string; + price: number; + type: string; + avgRating: number; + reviewCount: number; + recentReview: { + id: number; + rating: number; + content: string; + createdAt: string; + updatedAt: string; + aroma: string[]; + user: { + id: number; + nickname: string; + image: string; + }; + isLiked: Record; + } | null; +}; + +export type MyWinesResponse = { + totalCount: number; + nextCursor: number | null; + list: MyWine[]; +};