Skip to content

Conversation

@DreamPaste
Copy link
Member

📦 Pull Request

📝 요약(Summary)

  • 토스페이먼츠 SDK를 활용한 결제 시스템을 구현했습니다.
  • /mypage/reservation-list 마이페이지 예약 내역에서 확인 가능합니다.
image
  • 결제하기 버튼을 누르면 Payments.Modal 모달이 열립니다.
image
  • 결제를 성공할 경우와 실패할 경우 모달로 확인 할 수 있습니다.(카카오페이가 제일 확인하기 쉬워요)

💬 공유사항 to 리뷰어

✨ 새로운 기능

  • 토스페이먼츠 결제 모달 구현 (Payments.Modal.tsx)
    • 결제 위젯 렌더링 및 금액 설정
    • 결제 성공/실패 처리
    • 에러 바운더리 및 상태 관리
  • 결제 에러 처리 컴포넌트 (Payments.Error.tsx)
    • 미니멀한 에러 모달 디자인
    • 사용자 친화적 에러 메시지

🔧 기술적 개선사항

  • 결제 위젯 중복 렌더링 방지
    • DOM 정리 로직 구현
    • 위젯 상태 관리 최적화
    • React Fast Refresh 호환성 개선
  • 비동기 에러 처리 강화
    • 상태 기반 에러 관리
    • 결제 실패 시 사용자 피드백 개선

🎨 UI/UX 개선

  • 드롭다운 애니메이션 최적화
    • Framer Motion oklab 색상 경고 해결
    • 중복 hover 효과 제거

@linear
Copy link

linear bot commented Sep 13, 2025

@DreamPaste DreamPaste added feat ✨ 새로운 기능 추가 휘건 labels Sep 13, 2025
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull Request Overview

This PR implements a payment system using Toss Payments SDK and includes various performance optimizations and UI/UX improvements.

  • Toss Payments integration with payment modal and error handling
  • Image loading performance improvements with blur placeholders and LQIP generation
  • UI enhancements including animations, dropdown improvements, and booking interface refinements

Reviewed Changes

Copilot reviewed 28 out of 29 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
src/components/pages/activities/payments/Payments.Modal.tsx Payment modal implementation with Toss Payments SDK integration
src/components/pages/activities/payments/Payments.Error.tsx Error handling component for payment failures
src/lib/utils/blur.ts Utility for generating blur placeholder images
src/components/pages/activities/ActivityImageViewer.tsx Image viewer with blur placeholder integration
src/components/pages/myPage/ReservationListCard.tsx Payment button integration in reservation cards
src/hooks/useScrollPosition.ts Enhanced scroll position management for various elements
package.json Added payment SDK and image processing dependencies

Tip: Customize your code reviews with copilot-instructions.md. Create the file or learn how to get started.

totalPrice: number;
}

const CLIENT_KEY = 'test_gck_docs_Ovk5rk1EwkEbP0W43n07xlzm';
Copy link

Copilot AI Sep 13, 2025

Choose a reason for hiding this comment

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

The Toss Payments client key is hardcoded in the component. This test key should be moved to environment variables to prevent accidental exposure in production builds.

Suggested change
const CLIENT_KEY = 'test_gck_docs_Ovk5rk1EwkEbP0W43n07xlzm';
const CLIENT_KEY = process.env.REACT_APP_TOSS_PAYMENTS_CLIENT_KEY as string;

Copilot uses AI. Check for mistakes.
Comment on lines +56 to +58
customerEmail: 'customer123@gmail.com',
customerName: user?.nickname || '홍길동',
customerMobilePhone: '01012341234',
Copy link

Copilot AI Sep 13, 2025

Choose a reason for hiding this comment

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

Customer email and phone number are hardcoded with dummy values. These should use actual user data from the user store or props to ensure proper payment processing.

Suggested change
customerEmail: 'customer123@gmail.com',
customerName: user?.nickname || '홍길동',
customerMobilePhone: '01012341234',
customerEmail: user?.email || 'noemail@unknown.com',
customerName: user?.nickname || '홍길동',
customerMobilePhone: user?.phone || '00000000000',

Copilot uses AI. Check for mistakes.
Comment on lines +56 to +58
customerEmail: 'customer123@gmail.com',
customerName: user?.nickname || '홍길동',
customerMobilePhone: '01012341234',
Copy link

Copilot AI Sep 13, 2025

Choose a reason for hiding this comment

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

Customer email and phone number are hardcoded with dummy values. These should use actual user data from the user store or props to ensure proper payment processing.

Suggested change
customerEmail: 'customer123@gmail.com',
customerName: user?.nickname || '홍길동',
customerMobilePhone: '01012341234',
customerEmail: user?.email || '',
customerName: user?.nickname || '홍길동',
customerMobilePhone: user?.phone || '',

Copilot uses AI. Check for mistakes.
Comment on lines +65 to +66
// 로딩 중일 때는 5점을 기본값으로 표시
const averageRating = isLoading ? 5 : firstPage?.averageRating || 0;
Copy link

Copilot AI Sep 13, 2025

Choose a reason for hiding this comment

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

Showing a 5-star rating while loading is misleading to users. Consider showing a loading state or no rating instead of defaulting to the highest possible rating.

Suggested change
// 로딩 중일 때는 5점을 기본값으로 표시
const averageRating = isLoading ? 5 : firstPage?.averageRating || 0;
// 로딩 중일 때는 평점을 표시하지 않음
const averageRating = isLoading ? null : firstPage?.averageRating || 0;

Copilot uses AI. Check for mistakes.
Comment on lines +10 to 14
const LABELS = ['평점 없음', '보통', '다소 만족', '만족', '매우 만족'] as const;

export function ReviewHero({ reviewCount = 1000, rating = 5 }: ReviewHeroProps) {
const level = Math.max(0, Math.min(4, Math.floor(rating))) as Level;
const summary = LABELS[level];
Copy link

Copilot AI Sep 13, 2025

Choose a reason for hiding this comment

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

The label mapping is inconsistent with the rating calculation. A rating of 0 would show '평점 없음' but ratings 0-1 should both map to the first meaningful rating level.

Suggested change
const LABELS = ['평점 없음', '보통', '다소 만족', '만족', '매우 만족'] as const;
export function ReviewHero({ reviewCount = 1000, rating = 5 }: ReviewHeroProps) {
const level = Math.max(0, Math.min(4, Math.floor(rating))) as Level;
const summary = LABELS[level];
const LABELS = ['보통', '다소 만족', '만족', '매우 만족', '최고'] as const;
export function ReviewHero({ reviewCount = 1000, rating = 5 }: ReviewHeroProps) {
const level = rating == null ? null : Math.max(0, Math.min(4, Math.floor(rating === 0 ? 0 : rating - 1))) as Level | null;
const summary = level === null ? '평점 없음' : LABELS[level];

Copilot uses AI. Check for mistakes.
Comment on lines +48 to +49
if (year && month && day) {
const dateString = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
Copy link

Copilot AI Sep 13, 2025

Choose a reason for hiding this comment

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

The padStart method is called on the month and day parameters, but these are strings from input values that might not need padding or could be invalid. Consider validating the input values first or using parseInt to ensure they are numbers.

Suggested change
if (year && month && day) {
const dateString = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
// Validate year, month, and day are numeric and in valid ranges
const yearNum = parseInt(year, 10);
const monthNum = parseInt(month, 10);
const dayNum = parseInt(day, 10);
if (
!isNaN(yearNum) &&
!isNaN(monthNum) &&
!isNaN(dayNum) &&
year.length === 4 &&
monthNum >= 1 && monthNum <= 12 &&
dayNum >= 1 && dayNum <= 31
) {
const dateString = `${yearNum}-${monthNum.toString().padStart(2, '0')}-${dayNum.toString().padStart(2, '0')}`;

Copilot uses AI. Check for mistakes.
Comment on lines +30 to +49
// 1. 정적 데이터 (이미지, 주소, 제목, 설명) - 긴 캐시
const { data: staticInfo } = useSuspenseQuery({
queryKey: [...activityQueryKeys.detail(activityId), 'static'],
queryFn: () => getActivityDetail(Number(activityId)),
select: (data) => ({
id: data.id,
title: data.title,
description: data.description,
address: data.address,
bannerImageUrl: data.bannerImageUrl,
subImages: data.subImages,
category: data.category,
userId: data.userId,
createdAt: data.createdAt,
updatedAt: data.updatedAt,
}),
staleTime: 30 * 60 * 1000, // 30분 캐시 (정적 정보)
gcTime: 60 * 60 * 1000, // 1시간 메모리 보관
});

Copy link

Copilot AI Sep 13, 2025

Choose a reason for hiding this comment

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

Making two separate API calls to the same endpoint with different query keys creates unnecessary network requests. Consider restructuring to use a single query with proper cache management or implement data splitting at the API level.

Suggested change
// 1. 정적 데이터 (이미지, 주소, 제목, 설명) - 긴 캐시
const { data: staticInfo } = useSuspenseQuery({
queryKey: [...activityQueryKeys.detail(activityId), 'static'],
queryFn: () => getActivityDetail(Number(activityId)),
select: (data) => ({
id: data.id,
title: data.title,
description: data.description,
address: data.address,
bannerImageUrl: data.bannerImageUrl,
subImages: data.subImages,
category: data.category,
userId: data.userId,
createdAt: data.createdAt,
updatedAt: data.updatedAt,
}),
staleTime: 30 * 60 * 1000, // 30분 캐시 (정적 정보)
gcTime: 60 * 60 * 1000, // 1시간 메모리 보관
});
// 1. 활동 상세 데이터 (정적 + 동적) - 캐시 관리 필요
const { data: activityDetail } = useSuspenseQuery({
queryKey: activityQueryKeys.detail(activityId),
queryFn: () => getActivityDetail(Number(activityId)),
staleTime: 5 * 60 * 1000, // 5분 캐시 (동적 정보 포함)
gcTime: 30 * 60 * 1000, // 30분 메모리 보관
});
// 정적 데이터 분리
const staticInfo = useMemo(() => activityDetail && {
id: activityDetail.id,
title: activityDetail.title,
description: activityDetail.description,
address: activityDetail.address,
bannerImageUrl: activityDetail.bannerImageUrl,
subImages: activityDetail.subImages,
category: activityDetail.category,
userId: activityDetail.userId,
createdAt: activityDetail.createdAt,
updatedAt: activityDetail.updatedAt,
}, [activityDetail]);

Copilot uses AI. Check for mistakes.
Comment on lines +30 to +65
// 1. 정적 데이터 (이미지, 주소, 제목, 설명) - 긴 캐시
const { data: staticInfo } = useSuspenseQuery({
queryKey: [...activityQueryKeys.detail(activityId), 'static'],
queryFn: () => getActivityDetail(Number(activityId)),
select: (data) => ({
id: data.id,
title: data.title,
description: data.description,
address: data.address,
bannerImageUrl: data.bannerImageUrl,
subImages: data.subImages,
category: data.category,
userId: data.userId,
createdAt: data.createdAt,
updatedAt: data.updatedAt,
}),
staleTime: 30 * 60 * 1000, // 30분 캐시 (정적 정보)
gcTime: 60 * 60 * 1000, // 1시간 메모리 보관
});

// 2. 동적 데이터 (가격, 스케줄, 평점) - 짧은 캐시
const { data: dynamicInfo } = useSuspenseQuery({
queryKey: [...activityQueryKeys.detail(activityId), 'dynamic'],
queryFn: () => getActivityDetail(Number(activityId)),
staleTime: 5 * 60 * 1000, // 5분 캐시 (기본 정보)
gcTime: 30 * 60 * 1000, // 30분 메모리 보관
select: (data) => ({
price: data.price,
schedules: data.schedules,
rating: data.rating,
reviewCount: data.reviewCount,
}),
staleTime: 1 * 60 * 1000, // 1분 캐시 (동적 정보)
gcTime: 5 * 60 * 1000, // 5분 메모리 보관
refetchInterval: 2 * 60 * 1000, // 2분마다 자동 갱신
});

// 3. 합성된 activity 객체 (useMemo로 불필요한 리렌더링 방지)
Copy link

Copilot AI Sep 13, 2025

Choose a reason for hiding this comment

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

Making two separate API calls to the same endpoint with different query keys creates unnecessary network requests. Consider restructuring to use a single query with proper cache management or implement data splitting at the API level.

Copilot uses AI. Check for mistakes.
Comment on lines +87 to 94
// 스크롤 위치 저장 훅 사용 - parentRef를 스크롤 대상으로 전달
const { restoreScrollPosition } = useScrollPosition(scrollKey, enableScrollPosition, parentRef);

useEffect(() => {
// 최대 아이템의 길이가 초과되면 잘라냅니다.(최적화)
// 최대 아이템의 길이가 초과되면 잘라냅니다.
if (items.length > maxItems) {
setDisplayItems(items.slice(items.length - maxItems));
} else {
Copy link
Collaborator

@summerDev96 summerDev96 Sep 14, 2025

Choose a reason for hiding this comment

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

머지된 후 무한스크롤 리스트에서 스크롤 위치 복원되는지 확인해보겠습니다!
(프리뷰 링크 테스트) 예약내역 리스트에서 확인해보았는데 위치가 살짝 달라지는 것 같아요

Copy link
Member Author

Choose a reason for hiding this comment

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

스크롤 위치 복원은 약간 문제가 있었던거 같아요 수정하겠습니다!

Comment on lines +49 to +53
onSuccess: async (data) => {
console.log('🎫 [BookingConfirmModal] 예약 성공:', data);
onClose();
successToast.run('예약이 완료되었습니다!');
router.push('/mypage/reservation-list');
Copy link
Collaborator

Choose a reason for hiding this comment

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

예약완료 후 예약내역으로 이동 후 예약한 내역 반영되는 점 확인하였습니다.

@DreamPaste DreamPaste merged commit e18760e into main Sep 14, 2025
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feat ✨ 새로운 기능 추가 휘건

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants