-
Notifications
You must be signed in to change notification settings - Fork 5
Feat: TRI-83 결제 서비스 구현 #74
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
…/TRI-77-activities-page
…/TRI-77-activities-page
There was a problem hiding this 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'; |
Copilot
AI
Sep 13, 2025
There was a problem hiding this comment.
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.
| const CLIENT_KEY = 'test_gck_docs_Ovk5rk1EwkEbP0W43n07xlzm'; | |
| const CLIENT_KEY = process.env.REACT_APP_TOSS_PAYMENTS_CLIENT_KEY as string; |
| customerEmail: 'customer123@gmail.com', | ||
| customerName: user?.nickname || '홍길동', | ||
| customerMobilePhone: '01012341234', |
Copilot
AI
Sep 13, 2025
There was a problem hiding this comment.
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.
| customerEmail: 'customer123@gmail.com', | |
| customerName: user?.nickname || '홍길동', | |
| customerMobilePhone: '01012341234', | |
| customerEmail: user?.email || 'noemail@unknown.com', | |
| customerName: user?.nickname || '홍길동', | |
| customerMobilePhone: user?.phone || '00000000000', |
| customerEmail: 'customer123@gmail.com', | ||
| customerName: user?.nickname || '홍길동', | ||
| customerMobilePhone: '01012341234', |
Copilot
AI
Sep 13, 2025
There was a problem hiding this comment.
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.
| customerEmail: 'customer123@gmail.com', | |
| customerName: user?.nickname || '홍길동', | |
| customerMobilePhone: '01012341234', | |
| customerEmail: user?.email || '', | |
| customerName: user?.nickname || '홍길동', | |
| customerMobilePhone: user?.phone || '', |
| // 로딩 중일 때는 5점을 기본값으로 표시 | ||
| const averageRating = isLoading ? 5 : firstPage?.averageRating || 0; |
Copilot
AI
Sep 13, 2025
There was a problem hiding this comment.
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.
| // 로딩 중일 때는 5점을 기본값으로 표시 | |
| const averageRating = isLoading ? 5 : firstPage?.averageRating || 0; | |
| // 로딩 중일 때는 평점을 표시하지 않음 | |
| const averageRating = isLoading ? null : firstPage?.averageRating || 0; |
| 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]; |
Copilot
AI
Sep 13, 2025
There was a problem hiding this comment.
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.
| 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]; |
| if (year && month && day) { | ||
| const dateString = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`; |
Copilot
AI
Sep 13, 2025
There was a problem hiding this comment.
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.
| 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')}`; |
| // 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시간 메모리 보관 | ||
| }); | ||
|
|
Copilot
AI
Sep 13, 2025
There was a problem hiding this comment.
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.
| // 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]); |
| // 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로 불필요한 리렌더링 방지) |
Copilot
AI
Sep 13, 2025
There was a problem hiding this comment.
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.
| // 스크롤 위치 저장 훅 사용 - parentRef를 스크롤 대상으로 전달 | ||
| const { restoreScrollPosition } = useScrollPosition(scrollKey, enableScrollPosition, parentRef); | ||
|
|
||
| useEffect(() => { | ||
| // 최대 아이템의 길이가 초과되면 잘라냅니다.(최적화) | ||
| // 최대 아이템의 길이가 초과되면 잘라냅니다. | ||
| if (items.length > maxItems) { | ||
| setDisplayItems(items.slice(items.length - maxItems)); | ||
| } else { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
머지된 후 무한스크롤 리스트에서 스크롤 위치 복원되는지 확인해보겠습니다!
(프리뷰 링크 테스트) 예약내역 리스트에서 확인해보았는데 위치가 살짝 달라지는 것 같아요
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
스크롤 위치 복원은 약간 문제가 있었던거 같아요 수정하겠습니다!
| onSuccess: async (data) => { | ||
| console.log('🎫 [BookingConfirmModal] 예약 성공:', data); | ||
| onClose(); | ||
| successToast.run('예약이 완료되었습니다!'); | ||
| router.push('/mypage/reservation-list'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
예약완료 후 예약내역으로 이동 후 예약한 내역 반영되는 점 확인하였습니다.
📦 Pull Request
📝 요약(Summary)
/mypage/reservation-list마이페이지 예약 내역에서 확인 가능합니다.Payments.Modal모달이 열립니다.💬 공유사항 to 리뷰어
✨ 새로운 기능
🔧 기술적 개선사항
🎨 UI/UX 개선