Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')),
Expand Down
49 changes: 49 additions & 0 deletions src/api/myReviews.ts
Original file line number Diff line number Diff line change
@@ -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<MyReviewsResponse>
* @throws {Error} NEXT_PUBLIC_TEAM 환경변수가 없으면 예외 발생
*/
export const getMyReviews = async (
options: FetchMyReviewsOptions = {},
): Promise<MyReviewsResponse> => {
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<MyReviewsResponse, MyReviewsResponse>(url, {
params: { cursor, limit },
});

// 요청 디버그 로그 (개발 환경에서만 활성화 권장)
if (process.env.NODE_ENV === 'development') {
console.debug('[API] getMyReviews', { url, cursor, limit, response });
}

return response;
};
47 changes: 47 additions & 0 deletions src/api/myWines.ts
Original file line number Diff line number Diff line change
@@ -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<MyWinesResponse>
* @throws {Error} NEXT_PUBLIC_TEAM 환경변수가 없으면 예외 발생
*/
export const getMyWines = async (options: FetchMyWinesOptions = {}): Promise<MyWinesResponse> => {
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<MyWinesResponse, MyWinesResponse>(url, {
params: { cursor, limit },
});

// 요청 디버그 로그
if (process.env.NODE_ENV === 'development') {
console.debug('[API] getMyWines', { url, cursor, limit, response });
}

return response;
};
2 changes: 1 addition & 1 deletion src/components/common/Gnb.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down
94 changes: 94 additions & 0 deletions src/components/my-profile/ReviewList.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement | null>(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 <p>불러오는 중…</p>;
if (isError) return <p>불러오기 실패</p>;
if (!data) return <p>리뷰 데이터가 없습니다.</p>;

// 리뮤 목록 평탄화
const reviews: MyReview[] = data?.pages?.flatMap((page) => page.list ?? []) ?? [];

return (
<div className='space-y-4 mt-4'>
{reviews.map((review) => (
<MyCard
key={review.id}
rating={
<Badge variant='star'>
<span className='inline-block w-full h-full pt-[2px]'>
★ {review.rating.toFixed(1)}
</span>
</Badge>
}
timeAgo={new Date(review.createdAt).toLocaleDateString()}
title={review.user.nickname}
review={review.content}
rightSlot={
<MenuDropdown
trigger={
<button className='w-6 h-6 text-gray-500 hover:text-primary transition-colors'>
<DotIcon />
</button>
}
options={[
{ label: '수정하기', value: 'edit' },
{ label: '삭제하기', value: 'delete' },
]}
onSelect={(value) => console.log(`${value} clicked: review id=${review.id}`)}
/>
}
/>
))}
{/* 옵저버 감지 요소 */}
<div ref={observerRef} className='w-1 h-1' />
</div>
);
}
File renamed without changes.
100 changes: 100 additions & 0 deletions src/components/my-profile/WineList.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement | null>(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,
});
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

인피니티 쿼리를 쓰면 이렇게 쓰는 거군요.
패칭함수를 따로 콜백으로 넘겨줘야할 것 같다고 생각하고 있었는데
인피티니 쿼리랑 같이 쓰니까 간결하고 좋네요


// 로딩 및 에러 상태 처리 (임시)
if (isLoading) return <p className='text-center py-4'>와인 불러오는 중…</p>;
if (isError || !data) return <p className='text-center py-4'>와인 불러오기 실패</p>;

// 와인 목록 평탄화
const wines: MyWine[] = data?.pages?.flatMap((page) => page?.list ?? []) ?? [];

return (
<div className='flex flex-col mt-9 space-y-9 md:space-y-16 md:mt-16'>
{wines.map((wine) => (
<ImageCard
key={wine.id}
className='relative pl-24 min-h-[164px] md:min-h-[228px] md:pl-44 md:pt-10'
imageSrc={wine.image}
imageClassName='object-contain absolute left-3 bottom-0 h-[185px] md:h-[270px] md:left-12'
rightSlot={
<MenuDropdown
trigger={
<button className='w-6 h-6 text-gray-500 hover:text-primary transition-colors'>
<DotIcon />
</button>
}
options={[
{ label: '수정하기', value: 'edit' },
{ label: '삭제하기', value: 'delete' },
]}
onSelect={(value) => console.log(`${value} clicked for wine id: ${wine.id}`)}
/>
}
>
<div className='flex flex-col items-start justify-center h-full'>
<h4 className='text-xl/6 font-semibold text-gray-800 mb-4 md:text-3xl md:mb-5'>
{wine.name}
</h4>
<p className='custom-text-md-legular text-gray-500 mb-2 md:custom-text-lg-legular md:mb-4'>
{wine.region}
</p>
<Badge variant='priceBadge'>
<span className='inline-block w-full h-full pt-[3px]'>
₩ {wine.price.toLocaleString()}
</span>
</Badge>
</div>
</ImageCard>
))}
{/* 옵저버 감지 요소 */}
<div ref={observerRef} className='w-1 h-1' />
</div>
);
}
Loading