Skip to content

Feat&Api: 마이페이지 postcard get api#121

Merged
jjangminii merged 23 commits intodevelopfrom
api/#119/postcard-get-api
Nov 14, 2025
Merged

Feat&Api: 마이페이지 postcard get api#121
jjangminii merged 23 commits intodevelopfrom
api/#119/postcard-get-api

Conversation

@jjangminii
Copy link
Copy Markdown
Contributor

@jjangminii jjangminii commented Nov 13, 2025

🔥 작업 내용

  • 마이페이지 postcard get api 연결 및 레이아웃 수정

🤔 추후 작업 사항

  • 메인의.. 포스트카드

🔗 이슈

PR Point (To Reviewer)

  • ex) 로그인 입력 검증 로직 적절한지 확인 부탁드립니다.

📸 피그마 스크린샷 or 기능 GIF

(작업 내역 스크린샷)

Summary by CodeRabbit

  • New Features

    • 서버에서 엽서 상세를 동적으로 불러오고, 엽서에 저장·공유 버튼을 추가했습니다.
  • Refactor

    • 이미지 방향(세로/가로/정사각)을 감지해 레이아웃을 자동 조정하고 접근성을 개선했습니다.
    • 저장·공유 동작을 별도 액션 컴포넌트로 분리했습니다.
  • Style

    • 빈 슬롯 플레이스홀더 색상과 그리드 간격, 일부 색상톤(mint) 조정을 적용했습니다.
  • Chores

    • 로컬 저장소에 엽서 데이터를 보관하는 기능과 공유/저장 시 동적 파일명 및 개선된 오류처리를 도입했습니다.
  • Infrastructure

    • 이미지 허용 도메인(example.com)을 업데이트했습니다.

@jjangminii jjangminii self-assigned this Nov 13, 2025
@jjangminii jjangminii linked an issue Nov 13, 2025 that may be closed by this pull request
@vercel
Copy link
Copy Markdown

vercel bot commented Nov 13, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Comments Updated (UTC)
af-fe Ready Ready Preview Comment Nov 13, 2025 8:15pm

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Nov 13, 2025

Walkthrough

엽서 상세를 API 기반으로 전환하고, 이미지 방향 인식 훅, 액션 컴포넌트, 로컬 스토리지 유틸을 추가했으며 공유·저장 유틸 시그니처와 일부 레이아웃·스타일을 변경했습니다.

Changes

코호트 / 파일(s) 변경 요약
설정
next.config.ts
이미지 도메인에 example.com 추가
멤버 API & 훅
src/shared/api/member.ts
getPostCardDetail(postcardId) 함수 및 useGetPostCardDetail(postcardId?) React Query 훅 추가; 타입 import 확장
타입 정의
src/shared/types/membertypes.ts
PostCardDetailResponse 인터페이스 추가 (postcardId, imageUrl, placeName, placeDescription)
이미지 방향 훅
src/shared/hooks/useImageOrientation.ts
useImageOrientation(src?) 훅 추가: 이미지 로드로 orientation(landscape/portrait/square) 및 size 계산
메인 엽서 UI
src/pages/main/PostCard.tsx
클라이언트 전환, 로컬스토리지 기반 엽서 로드, 로딩 상태, orientation 기반 FlipCard·이미지 렌더링 및 PostCardActions 사용
엽서 상세 페이지
src/pages/mypage/postcard/[id].tsx
정적 데이터 제거 → useGetPostCardDetail 사용, FlipCard·orientation 적용, 컴포넌트명 PostCardSavePagePostCard로 변경 및 default export 갱신
액션 컴포넌트
src/shared/components/main/components/PostCardActions/PostCardActions.tsx
PostCardActions 추가: Save/Share 버튼, imageUrl·placeName props 및 클릭 핸들러 연결
액션 유틸
src/shared/utils/postcardActions.ts
handleShare(imageUrl, title): Promise<void>handleSave(imageUrl, placeName) 시그니처로 변경; 이미지 fetch→File 변환, navigator.canShare/try-catch, 동적 파일명 사용
로컬 스토리지 유틸
src/shared/utils/storage.ts
savePostcard, getPostcard, clearPostcard 추가 (브라우저 가드 및 에러 처리)
MyPage 카드 컨테이너
src/pages/mypage/components/PostcardContainer.tsx
postcards ≤ 8일 때 gap-[1.2rem] 추가, 빈 슬롯 배경 bg-gray-200bg-pink-100, Image className 표기 변경(스타일 영향만)
스타일 토큰 조정
src/shared/components/button/AddressCopy.tsx, src/shared/components/container/LocationCard.tsx
색상 토큰(mint 계열) 조정 및 LocationCard의 큰 카드에서 좋아요 아이콘/핸들러 비활성화(주석 처리)
패키지 메타
package.json
매니페스트 변경 표기(minor_updates)

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant Page as PostCard Page
    participant Storage as localStorage util
    participant API as useGetPostCardDetail
    participant Hook as useImageOrientation
    participant UI as FlipCard / PostCardActions
    Note right of Storage `#d5f5e3`: main 페이지에서 로컬 엽서 조회
    User->>Page: 접근 (main or /mypage/postcard/[id])
    alt main 페이지
        Page->>Storage: getPostcard()
        Storage-->>Page: PostcardData | null
    end
    alt id가 있는 경우
        Page->>API: getPostCardDetail(postcardId)
        API-->>Page: { imageUrl, placeName, placeDescription }
    end
    Page->>Hook: imageUrl 전달
    Hook-->>Page: { orientation, size }
    Page->>UI: FlipCard 렌더(front/back, orientation)
    User->>UI: 저장 클릭
    UI->>UI: PostCardActions.handleSave(imageUrl, placeName)
    UI->>Storage: savePostcard(data) (필요 시)
    User->>UI: 공유 클릭
    UI->>UI: PostCardActions.handleShare(imageUrl, placeName)
    UI->>Navigator: navigator.canShare / navigator.share 또는 fallback
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

  • 주의가 필요한 파일/영역:
    • src/pages/mypage/postcard/[id].tsx: 기본 export명 변경 및 API 응답 필드 매핑 검증
    • src/shared/hooks/useImageOrientation.ts: 이미지 로드/언로드(메모리·이벤트 정리) 처리 확인
    • src/shared/utils/postcardActions.ts: navigator.share/canShare 호환성 및 에러 처리 경로 확인
    • src/pages/main/PostCard.tsx: 로컬스토리지 연동, 클라이언트 전환과 로딩/접근성 속성 확인

Possibly related PRs

Suggested labels

feat

Suggested reviewers

  • skyblue1232
  • KongMezu

Poem

🐰 깡충, 엽서 한 장 도착했네
앞뒤로 돌려보며 방향을 재보네
저장 누르면 찰칵, 공유면 뿌듯
작은 훅과 버튼으로 반짝이는 변화
토끼가 기뻐하는 코드 한 점, 냠냠 🥕

Pre-merge checks and finishing touches

❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
Out of Scope Changes check ❓ Inconclusive PR에는 핵심 API 기능 외에도 색상 토큰 업데이트, 컴포넌트 스타일링, 저장소 유틸리티 등 관련 but 추가적인 변경사항들이 포함되어 있음. Issue #119에서 요구하는 범위와 포함된 모든 변경사항의 관계를 명확히 하기 위해 추가 컨텍스트 또는 이슈 업데이트가 필요함.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목은 마이페이지 postcard get api 구현이라는 주요 변경사항과 관련이 있으나, 전체 changeset에서 수행된 광범위한 변경사항을 완전히 반영하지 못함.
Description check ✅ Passed PR 설명에는 주요 섹션(작업 내용, 이슈 연결)이 포함되어 있으나, 리뷰 포인트와 스크린샷은 미작성 상태로 남아있음.
Linked Issues check ✅ Passed Issue #119는 postcard GET API 구현을 목표로 하며, PR의 getPostCardDetail API 추가, useGetPostCardDetail 훅, 관련 UI 컴포넌트 개발이 이를 충족함.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch api/#119/postcard-get-api

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions bot added the api api 연결 label Nov 13, 2025
@github-actions
Copy link
Copy Markdown

🏷️ Labeler has automatically applied labels based on your PR title, branch name, or commit message.
Please verify that they are correct before merging.

@github-actions github-actions bot added the comment 필요한 주석 추가 및 변경 label Nov 13, 2025
@github-actions
Copy link
Copy Markdown

🏷️ Labeler has automatically applied labels based on your PR title, branch name, or commit message.
Please verify that they are correct before merging.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 9

🧹 Nitpick comments (9)
src/shared/utils/postcardActions.ts (1)

2-12: handleShare도 실제 엽서 정보를 전달하도록 개선이 필요합니다.

현재는 현재 페이지 URL만 공유하지만, 엽서 제목이나 장소 정보를 함께 공유하면 더 나은 사용자 경험을 제공할 수 있습니다.

-export const handleShare = () => {
+export const handleShare = (placeName?: string) => {
   if (navigator.share) {
     navigator.share({
       title: '엽서 공유',
-      text: '글다',
+      text: placeName ? `${placeName}의 엽서 - 글다` : '글다',
       url: window.location.href,
     });
   } else {
     alert('이 브라우저에서는 공유 기능을 지원하지 않습니다.');
   }
 };
src/shared/hooks/useImageOrientation.ts (1)

4-10: 로딩 상태 추가를 고려해보세요.

현재는 이미지 로드 중인지 완료되었는지 구분할 수 없습니다. 로딩 인디케이터 표시가 필요한 경우 isLoading 상태를 추가하면 UX가 개선됩니다.

  const [orientation, setOrientation] = useState<
    'portrait' | 'landscape' | 'square'
  >('square');
  const [size, setSize] = useState<{ width: number; height: number } | null>(
    null,
  );
+ const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    if (!src) return;
+   setIsLoading(true);
    
    // ... existing code ...
    
    img.onload = () => {
      // ... existing code ...
+     setIsLoading(false);
    };
  }, [src]);

- return { orientation, size };
+ return { orientation, size, isLoading };
src/pages/main/PostCard.tsx (1)

25-25: className 문자열에 불필요한 공백이 있습니다.

조건부 className 끝에 여러 공백이 포함되어 있어 코드 가독성을 떨어뜨립니다.

-        orientation === 'portrait' ? 'mb-[5rem]   ' : 'mb-[0rem]  ',
+        orientation === 'portrait' ? 'mb-[5rem]' : 'mb-[0rem]',

동일한 패턴이 31번, 40번, 55번 라인에도 반복되니 모두 수정하세요.

Also applies to: 31-31, 40-40, 55-55

src/shared/api/member.ts (2)

47-53: staleTime 설정을 추가하여 일관성을 유지하세요.

다른 쿼리들(useMyPageQuery, useMyInfoQuery)은 모두 5분의 staleTime을 설정하고 있으나, 새로운 쿼리에는 설정이 누락되었습니다.

  return useQuery({
    queryKey: ['postcardDetail', postcardId],
    queryFn: () => getPostCardDetail(postcardId!),
    enabled: !!postcardId,
+   staleTime: 1000 * 60 * 5,
  });

40-45: API 반환 타입의 일관성을 고려하세요.

fetchMyPage는 data.data를 반환하는 반면, getPostCardDetail은 전체 ApiResponse를 반환합니다. 일관성을 위해 언래핑된 데이터만 반환하는 것을 고려하세요.

 export const getPostCardDetail = async (postcardId: number) => {
   const { data } = await apiWithToken.get<ApiResponse<PostCardDetailResponse>>(
     `/api/postcards/${postcardId}`,
   );
-  return data;
+  return data.data;
 };

이렇게 수정하면 사용처에서 data.data.imageUrl 대신 data.imageUrl로 접근할 수 있어 더 간결합니다.

src/shared/components/main/components/PostCardActions/PostCardActions.tsx (1)

11-11: className 조합에 cn 유틸리티를 사용하세요.

문자열 템플릿 대신 프로젝트 전반에서 사용하는 cn 유틸리티를 사용하면 더 일관되고 안전합니다.

+import { cn } from '@/shared/lib';

-    <div className={`w-full  flex justify-center ${className ?? ''}`}>
+    <div className={cn('w-full flex justify-center', className)}>

이렇게 하면 이중 공백 문제도 자동으로 해결됩니다.

src/pages/mypage/postcard/[id].tsx (3)

17-22: 로딩 상태에 접근성 속성을 추가하세요.

스크린 리더 사용자를 위해 로딩 상태에 role="status"aria-live="polite" 속성을 추가하는 것이 좋습니다.

   if (isLoading)
     return (
-      <div className='flex items-center justify-center h-screen text-gray-500'>
+      <div 
+        role='status' 
+        aria-live='polite' 
+        className='flex items-center justify-center h-screen text-gray-500'
+      >
         엽서 정보를 불러오는 중입니다...
       </div>
     );

24-29: 에러 상태에 접근성 속성을 추가하세요.

사용자에게 에러 상태를 명확히 전달하기 위해 role="alert"aria-live="assertive" 속성을 추가하는 것이 좋습니다.

   if (isError || !data)
     return (
-      <div className='flex items-center justify-center h-screen text-red-500'>
+      <div 
+        role='alert' 
+        aria-live='assertive' 
+        className='flex items-center justify-center h-screen text-red-500'
+      >
         데이터를 불러오지 못했습니다 😢
       </div>
     );

14-14: 폴백 이미지 경로를 상수로 분리하는 것을 고려하세요.

하드코딩된 폴백 이미지 경로('/assets/letter_card_backh.png')를 상수로 분리하면 유지보수성이 향상됩니다.

파일 상단에 상수를 정의하세요:

const DEFAULT_POSTCARD_IMAGE = '/assets/letter_card_backh.png';

그리고 사용:

-  const imageUrl = data?.data?.imageUrl ?? '/assets/letter_card_backh.png';
+  const imageUrl = data?.data?.imageUrl ?? DEFAULT_POSTCARD_IMAGE;
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6773c93 and 5a5347f.

⛔ Files ignored due to path filters (4)
  • public/assets/card2.png is excluded by !**/*.png
  • public/assets/card3.png is excluded by !**/*.png
  • public/assets/letter_card_backh.png is excluded by !**/*.png
  • public/assets/letter_card_backv.png is excluded by !**/*.png
📒 Files selected for processing (9)
  • next.config.ts (1 hunks)
  • src/pages/main/PostCard.tsx (1 hunks)
  • src/pages/mypage/components/PostcardContainer.tsx (3 hunks)
  • src/pages/mypage/postcard/[id].tsx (1 hunks)
  • src/shared/api/member.ts (3 hunks)
  • src/shared/components/main/components/PostCardActions/PostCardActions.tsx (1 hunks)
  • src/shared/hooks/useImageOrientation.ts (1 hunks)
  • src/shared/types/membertypes.ts (1 hunks)
  • src/shared/utils/postcardActions.ts (2 hunks)
🧰 Additional context used
🧬 Code graph analysis (4)
src/shared/components/main/components/PostCardActions/PostCardActions.tsx (1)
src/shared/utils/postcardActions.ts (2)
  • handleSave (15-21)
  • handleShare (2-12)
src/pages/main/PostCard.tsx (3)
src/shared/hooks/useImageOrientation.ts (1)
  • useImageOrientation (4-29)
src/shared/lib/utils.ts (1)
  • cn (71-73)
src/shared/components/main/components/PostCardActions/PostCardActions.tsx (1)
  • PostCardActions (9-29)
src/shared/api/member.ts (3)
src/shared/api/instance.ts (1)
  • apiWithToken (14-17)
src/shared/types/authtypes.ts (1)
  • ApiResponse (1-6)
src/shared/types/membertypes.ts (1)
  • PostCardDetailResponse (28-33)
src/pages/mypage/postcard/[id].tsx (3)
src/shared/api/member.ts (1)
  • useGetPostCardDetail (47-53)
src/shared/hooks/useImageOrientation.ts (1)
  • useImageOrientation (4-29)
src/shared/lib/utils.ts (1)
  • cn (71-73)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build-and-deploy
🔇 Additional comments (9)
src/shared/types/membertypes.ts (1)

28-33: 타입 정의가 명확하고 적절합니다.

PostCardDetailResponse 인터페이스가 엽서 상세 정보를 잘 표현하고 있습니다. 필드명도 직관적이고 API 응답 구조와 잘 매칭됩니다.

src/pages/mypage/components/PostcardContainer.tsx (2)

82-82: 간격 조정으로 레이아웃 일관성이 향상되었습니다.

8개 이하의 엽서일 때 gap-[1.2rem]을 추가하여 시각적 균형을 개선했습니다.


93-93: 테마 일관성을 위한 색상 변경이 적절합니다.

빈 슬롯의 배경색을 bg-pink-100으로 변경하여 전체 핑크 테마와 통일감을 높였습니다.

src/pages/main/PostCard.tsx (3)

11-17: 방향 감지 로직과 동적 props 구성이 잘 구현되었습니다.

useImageOrientation 훅을 활용하여 세로/가로 이미지에 따라 적절한 크기와 뒷면 이미지를 선택하는 로직이 명확합니다.


8-9: 하드코딩된 데이터를 API 연동으로 교체 필요.

현재 label과 frontSrc가 하드코딩되어 있습니다. PR 설명에 "추후 작업 사항: 메인의 포스트카드 관련 추가 작업 예정"이라고 명시되어 있으므로, 다음 작업 시 API에서 받아온 데이터로 교체해야 합니다.

다음 단계에서는 다음과 같이 API 데이터를 사용하도록 수정이 필요합니다:

const { data: postcardData } = useGetPostCardDetail(postcardId);
const label = postcardData?.placeName || '장소명';
const frontSrc = postcardData?.imageUrl || '/assets/card3.png';

16-17: 이미지 파일이 정상적으로 존재합니다.

검증 결과, 두 파일 모두 저장소에 존재하는 것으로 확인되었습니다:

  • public/assets/letter_card_backv.png
  • public/assets/letter_card_backh.png

코드의 이미지 경로가 올바르게 지정되어 있으며, 추가 조치가 필요하지 않습니다.

src/pages/mypage/postcard/[id].tsx (3)

33-36: 이미지 속성 설정 로직이 명확합니다.

오리엔테이션에 따라 적절한 이미지 크기와 뒷면 이미지를 설정하는 로직이 잘 구현되어 있습니다.


60-65: FlipCard 컴포넌트 통합이 적절합니다.

이미지 오리엔테이션에 따라 동적으로 계산된 props를 FlipCard에 전달하는 방식이 깔끔합니다.


74-74: API 응답에는 주소 필드가 포함되어 있지 않습니다. 이 리뷰는 부정확합니다.

검증 결과, PostCardDetailResponsepostcardId, imageUrl, placeName, placeDescription만 포함하며 주소 필드가 존재하지 않습니다. 따라서 현재 address='주소 정보 없음'으로 하드코딩한 것은 불가용한 API 데이터를 대체하는 것이 아니라 API에서 애초에 제공하지 않는 정보에 대한 의도된 폴백입니다. 리뷰의 전제가 API에 주소 정보가 있을 수 있다는 가정을 기반으로 하고 있으나, 이는 타입 정의와 맞지 않습니다.

Likely an incorrect or invalid review comment.


images: {
domains: ['maps.googleapis.com'],
domains: ['maps.googleapis.com', 'example.com'],
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

'example.com'은 실제 프로덕션 도메인으로 교체해야 합니다.

'example.com'은 RFC 2606에 정의된 예시용 도메인으로, 실제 이미지 로딩이 불가능합니다. 엽서 이미지가 로드될 실제 CDN 또는 API 서버의 도메인으로 교체해야 합니다.

실제 사용할 이미지 도메인을 확인하여 다음과 같이 수정하세요:

  images: {
-    domains: ['maps.googleapis.com', 'example.com'],
+    domains: ['maps.googleapis.com', 'your-actual-cdn-domain.com'],
  },

백엔드 API 문서 또는 이미지 URL 응답 예시를 확인하여 실제 도메인을 파악하시기 바랍니다.


🏁 Script executed:

#!/bin/bash
# Examine next.config.ts file content and context
echo "=== next.config.ts content ===" && cat next.config.ts 2>/dev/null || echo "File not found"

Length of output: 1059


🏁 Script executed:

#!/bin/bash
# Search for image domain references across codebase
echo "=== Search for image domain references ===" && \
rg -n "domains|example\.com|cdn" --type=ts --type=js --type=json -C3 || echo "No matches found"

Length of output: 563


🏁 Script executed:

#!/bin/bash
# Look for environment configuration files
echo "=== Search for env files ===" && \
fd -e "env" -e "env.example" -e "env.local" -e ".env*" || echo "No env files found"

Length of output: 28


🏁 Script executed:

#!/bin/bash
# Search for API documentation or backend references
echo "=== Search for backend/API references ===" && \
rg -n "api|API|backend|cdn|CDN" next.config.ts -C5 || echo "No matches found"

Length of output: 258


🏁 Script executed:

#!/bin/bash
# Check if there are any comments or documentation about image domains
echo "=== Search for image-related documentation ===" && \
rg -n "image|Image|IMAGE" next.config.ts -C10 || echo "No matches found"

Length of output: 529


🏁 Script executed:

#!/bin/bash
# Look for test or example markers in config
echo "=== Search for test/example indicators ===" && \
rg -n "TODO|FIXME|test|example|temporary|placeholder" next.config.ts -i || echo "No markers found"

Length of output: 197



🏁 Script executed:

#!/bin/bash
# Search for Image component usage from Next.js
echo "=== Search for Image component usage ===" && \
rg -n "from 'next/image'|import.*Image|<Image" --type=ts --type=tsx --type=js --type=jsx -C2 || echo "No Image component usage found"

Length of output: 99


🏁 Script executed:

#!/bin/bash
# Search for image URLs or domain patterns in code
echo "=== Search for image URLs and domain patterns ===" && \
rg -n "example\.com|maps\.googleapis\.com|\.jpg|\.png|\.webp|image.*url|img.*src" --type=ts --type=tsx --type=js --type=jsx -C2 || echo "No matches found"

Length of output: 94


🏁 Script executed:

#!/bin/bash
# Search for API calls or fetch patterns that might load images
echo "=== Search for API/fetch patterns ===" && \
rg -n "fetch|axios|api|API|image" --type=ts --type=tsx --type=js --type=jsx -g "!node_modules" -C2 | head -50 || echo "No matches found"

Length of output: 65


🏁 Script executed:

#!/bin/bash
# Check if there's a README or documentation explaining image configuration
echo "=== Search for README or documentation ===" && \
fd -e "README*" -e "SETUP*" -e "CONFIG*" -e "docs" | xargs cat 2>/dev/null || echo "No documentation found"

Length of output: 42


🏁 Script executed:

#!/bin/bash
# Search for any postcard/image related feature or component
echo "=== Search for postcard/image components ===" && \
rg -n "postcard|Postcard|image.*load|Image.*load" --type=ts --type=tsx --type=js --type=jsx -C3 || echo "No matches found"

Length of output: 89


🏁 Script executed:

#!/bin/bash
# Search for Image component without type restriction
echo "=== Search for Image/img elements ===" && \
rg -n "Image|<img" -C2 src/ || echo "No matches found"

Length of output: 40778


🏁 Script executed:

#!/bin/bash
# Search for all references to example.com or image domains
echo "=== Search for domain references ===" && \
rg -n "example\.com|maps\.googleapis" -C3 || echo "No matches found"

Length of output: 264


🏁 Script executed:

#!/bin/bash
# Check package.json for image-related dependencies
echo "=== Check package.json ===" && \
cat package.json | grep -i "image\|next" || echo "Not found"

Length of output: 161


🏁 Script executed:

#!/bin/bash
# Find all source files to understand structure
echo "=== Directory structure ===" && \
find src -type f -name "*.ts" -o -name "*.js" | head -20 || echo "No files found"

Length of output: 763


🏁 Script executed:

#!/bin/bash
# Search for any image URL patterns or API calls broadly
echo "=== Search for image loading patterns ===" && \
rg -n "src=|url|URL|http" src/ -C2 | head -40 || echo "No matches found"

Length of output: 30352


🏁 Script executed:

#!/bin/bash
# Search for any configuration or documentation about actual image domains/CDN
echo "=== Search for CDN/domain configuration ===" && \
rg -n "CDN|cdn|domain|DOMAIN|imageUrl|imageSrc" --type=ts -A2 -B2 src/shared/api/ || echo "No API config found"

Length of output: 63


🏁 Script executed:

#!/bin/bash
# Check if there's any backend integration hints in constants or API files
echo "=== Search for backend/API structure ===" && \
cat src/shared/api/instance.ts 2>/dev/null || echo "Not found"

Length of output: 2802


🏁 Script executed:

#!/bin/bash
# Look for any environment or configuration that might specify real image domains
echo "=== Search for .env or configuration examples ===" && \
find . -maxdepth 2 -type f \( -name "*.md" -o -name ".env*" -o -name "*config*" \) ! -path "./node_modules/*" 2>/dev/null | head -20

Length of output: 201


🏁 Script executed:

#!/bin/bash
# Check API response types to understand what domains might be returned
echo "=== Check API/type definitions ===" && \
cat src/shared/types/membertypes.ts 2>/dev/null | head -30

Length of output: 600


실제 이미지 도메인 확인 필요 - 백엔드 팀과 조율하여 실제 프로덕션 도메인으로 교체하세요.

백엔드 API에서 반환하는 profileImageUrl, eventImageUrl, imageUrl이 Next.js Image 최적화를 제대로 사용하려면 해당 도메인이 next.config.tsdomains 배열에 등록되어야 합니다. 현재 example.com은 RFC 2606에 정의된 예시용 도메인으로 실제 이미지를 제공할 수 없습니다.

백엔드 API가 제공하는 실제 이미지 도메인(S3, CloudFlare CDN, 또는 별도 호스팅 서버)을 확인하여 다음과 같이 수정하세요:

  images: {
-    domains: ['maps.googleapis.com', 'example.com'],
+    domains: ['maps.googleapis.com', 'your-actual-image-domain.com'],
  },

백엔드 팀에 이미지 호스팅 도메인을 문의하거나 백엔드 환경 설정 문서를 확인하세요.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
domains: ['maps.googleapis.com', 'example.com'],
domains: ['maps.googleapis.com', 'your-actual-image-domain.com'],
🤖 Prompt for AI Agents
In next.config.ts around line 20, the domains array contains the placeholder
"example.com" which cannot serve production images; contact the backend team or
check backend env/docs to get the actual image hostnames used for
profileImageUrl, eventImageUrl, and imageUrl (e.g. S3 bucket domain,
Cloudflare/CDN host, or custom asset domain), then replace "example.com" with
those real domains in the domains array, ensure all image hostnames returned by
the API are listed, save and redeploy Next.js and verify Image optimization
works for the listed URLs.

@@ -1,72 +1,84 @@
import { Header, EventCard } from '@/shared/components';
'use client';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Pages Router에서 'use client' 지시문은 불필요합니다.

현재 파일은 src/pages/ 디렉토리에 위치한 Pages Router 컴포넌트입니다. Pages Router에서는 모든 페이지 컴포넌트가 기본적으로 클라이언트 컴포넌트이므로 'use client' 지시문이 불필요합니다. 이 지시문은 App Router(app/ 디렉토리)에서만 필요합니다.

다음과 같이 수정하세요:

-'use client';
 import { Header, FlipCard, LocationCard } from '@/shared/components';
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
'use client';
import { Header, FlipCard, LocationCard } from '@/shared/components';
🤖 Prompt for AI Agents
In src/pages/mypage/postcard/[id].tsx around lines 1 to 1, remove the
unnecessary 'use client' directive at the top of the file because Pages Router
pages are already client-side; simply delete that line so the file no longer
includes the directive.

Comment on lines +11 to +12
const postcardId = Number(id);
const { data, isLoading, isError } = useGetPostCardDetail(postcardId);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Number(id)가 NaN을 생성할 수 있어 쿼리가 잘못 실행될 수 있습니다.

idundefined이거나 배열일 때 Number(id)NaN을 반환하는데, NaN은 truthy 값이므로 useGetPostCardDetail 훅의 enabled: !!postcardId 조건을 통과하여 잘못된 API 호출이 발생할 수 있습니다.

다음과 같이 안전하게 처리하세요:

-  const postcardId = Number(id);
+  const postcardId = typeof id === 'string' ? Number(id) : undefined;
   const { data, isLoading, isError } = useGetPostCardDetail(postcardId);

또는 더 명시적으로:

-  const postcardId = Number(id);
+  const postcardId = 
+    typeof id === 'string' && !isNaN(Number(id)) ? Number(id) : undefined;
   const { data, isLoading, isError } = useGetPostCardDetail(postcardId);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const postcardId = Number(id);
const { data, isLoading, isError } = useGetPostCardDetail(postcardId);
const postcardId = typeof id === 'string' ? Number(id) : undefined;
const { data, isLoading, isError } = useGetPostCardDetail(postcardId);
🤖 Prompt for AI Agents
In src/pages/mypage/postcard/[id].tsx around lines 11-12, the current const
postcardId = Number(id) can produce NaN (e.g., when id is undefined or an array)
which is truthy and can incorrectly enable the query; change parsing to safely
handle undefined/array by first checking Array.isArray(id) or typeof id !==
'string', then parse with parseInt(id, 10) or Number(id) and validate with
Number.isFinite or !Number.isNaN; set postcardId to undefined (or null) when
invalid and pass that into useGetPostCardDetail, and ensure the hook uses
enabled: typeof postcardId === 'number' && Number.isFinite(postcardId) so the
API call only runs for a valid numeric id.

Comment on lines +47 to +53
export const useGetPostCardDetail = (postcardId?: number) => {
return useQuery({
queryKey: ['postcardDetail', postcardId],
queryFn: () => getPostCardDetail(postcardId!),
enabled: !!postcardId,
});
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

non-null assertion 연산자 사용이 안전하지 않습니다.

Line 50에서 postcardId!를 사용하고 있지만, enabled 조건만으로는 TypeScript가 postcardId의 non-null을 보장하지 못합니다. 이론상 타입 안전성 문제가 있습니다.

더 안전한 방식으로 수정하세요:

-export const useGetPostCardDetail = (postcardId?: number) => {
+export const useGetPostCardDetail = (postcardId: number | undefined) => {
   return useQuery({
     queryKey: ['postcardDetail', postcardId],
-    queryFn: () => getPostCardDetail(postcardId!),
+    queryFn: () => {
+      if (!postcardId) throw new Error('postcardId is required');
+      return getPostCardDetail(postcardId);
+    },
     enabled: !!postcardId,
   });
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const useGetPostCardDetail = (postcardId?: number) => {
return useQuery({
queryKey: ['postcardDetail', postcardId],
queryFn: () => getPostCardDetail(postcardId!),
enabled: !!postcardId,
});
};
export const useGetPostCardDetail = (postcardId: number | undefined) => {
return useQuery({
queryKey: ['postcardDetail', postcardId],
queryFn: () => {
if (!postcardId) throw new Error('postcardId is required');
return getPostCardDetail(postcardId);
},
enabled: !!postcardId,
});
};
🤖 Prompt for AI Agents
In src/shared/api/member.ts around lines 47 to 53, remove the unsafe non-null
assertion on postcardId; instead make queryFn explicitly guard postcardId (e.g.,
check if postcardId == null and throw or return a rejected promise) before
calling getPostCardDetail so TypeScript knows the value is present and you get
runtime safety; keep enabled: !!postcardId as the opt-in, but perform the null
check inside queryFn to avoid using `!`.

Comment on lines +12 to +18
<button
className='flex items-center justify-center w-[4.8rem] h-[4.8rem]'
onClick={handleSave}
aria-label='엽서 이미지 저장'
>
<Icon name='Save' color='gray-400' size={28} aria-hidden='true' />
</button>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

버튼 타입을 명시하세요.

type 속성이 없는 버튼은 기본적으로 "submit"으로 동작하여, form 내부에서 사용될 경우 의도하지 않은 제출이 발생할 수 있습니다.

       <button
+        type='button'
         className='flex items-center justify-center w-[4.8rem] h-[4.8rem]'
         onClick={handleSave}
         aria-label='엽서 이미지 저장'
       >

두 버튼 모두 type='button'을 추가하세요.

Also applies to: 20-26

🤖 Prompt for AI Agents
In src/shared/components/main/components/PostCardActions/PostCardActions.tsx
around lines 12-18 (and similarly lines 20-26), the <button> elements lack an
explicit type so they default to "submit" and can accidentally submit
surrounding forms; update both buttons to include type='button' (i.e., add the
type attribute to each button element) to prevent unintended form submissions
while preserving existing props and handlers.

Comment on lines +12 to +26
useEffect(() => {
if (!src) return;

const img = new Image();
img.src = src;
img.onload = () => {
const { width, height } = img;
setSize({ width, height });

const ratio = width / height;
if (ratio > 1) setOrientation('landscape');
else if (ratio < 1) setOrientation('portrait');
else setOrientation('square');
};
}, [src]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

이미지 로딩 실패 시 에러 핸들링이 누락되었습니다.

img.onerror 핸들러가 없어서 이미지 로드 실패 시 'square' 상태로 남게 되며, 사용자는 실패 여부를 알 수 없습니다.

다음과 같이 에러 처리를 추가하세요:

  useEffect(() => {
    if (!src) return;

    const img = new Image();
    img.src = src;
    img.onload = () => {
      const { width, height } = img;
      setSize({ width, height });

      const ratio = width / height;
      if (ratio > 1) setOrientation('landscape');
      else if (ratio < 1) setOrientation('portrait');
      else setOrientation('square');
    };
+   img.onerror = () => {
+     console.error('Failed to load image:', src);
+     setOrientation('square');
+     setSize(null);
+   };
  }, [src]);
🤖 Prompt for AI Agents
In src/shared/hooks/useImageOrientation.ts around lines 12 to 26, the Image
object lacks an onerror handler so failed loads leave orientation as 'square'
and provide no failure signal; add an img.onerror handler that sets an explicit
error state (e.g., setOrientation('error') or a separate setError(true)), clears
or resets size (setSize(null) or { width: 0, height: 0 }), and optionally logs
the error; also clean up handlers in the effect cleanup to avoid leaks (nullify
onload/onerror) and ensure the effect still returns that cleanup.

⚠️ Potential issue | 🟠 Major

경쟁 조건(race condition) 가능성이 있습니다.

src가 빠르게 변경될 경우, 이전 이미지의 onload가 나중에 실행되어 잘못된 orientation을 설정할 수 있습니다.

cleanup 함수를 추가하여 해결하세요:

  useEffect(() => {
    if (!src) return;

+   let cancelled = false;
    const img = new Image();
    img.src = src;
    img.onload = () => {
+     if (cancelled) return;
      const { width, height } = img;
      setSize({ width, height });

      const ratio = width / height;
      if (ratio > 1) setOrientation('landscape');
      else if (ratio < 1) setOrientation('portrait');
      else setOrientation('square');
    };
+   img.onerror = () => {
+     if (cancelled) return;
+     console.error('Failed to load image:', src);
+   };
+
+   return () => {
+     cancelled = true;
+   };
  }, [src]);
🤖 Prompt for AI Agents
In src/shared/hooks/useImageOrientation.ts around lines 12 to 26, the effect can
suffer a race condition where a previous Image's onload fires after src has
changed; add a cleanup to prevent stale onload handling by tracking the current
image: create the Image inside the effect, assign img.onload as now, and in the
returned cleanup set img.onload = null (and optionally img.src = ''), or use a
mounted/currentSrc flag to ignore onload if src no longer matches; ensure the
cleanup runs on each src change to prevent setting state from an outdated load
event.

Comment on lines 15 to 21
export const handleSave = () => {
const imageUrl = '/assets/Card.svg';
const imageUrl = '/assets/Card.png';
const link = document.createElement('a');
link.href = imageUrl;
link.download = 'Card.svg';
link.download = '글다.png';
link.click();
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

handleSave 함수가 실제 엽서 이미지를 저장하지 않습니다.

현재 구현은 하드코딩된 '/assets/Card.png'만 저장하므로, 사용자가 획득한 실제 엽서가 아닌 항상 동일한 이미지만 다운로드됩니다.

handleSave와 handleShare는 현재 표시 중인 엽서의 실제 이미지 URL을 매개변수로 받아야 합니다.

다음과 같이 리팩토링을 권장합니다:

-export const handleSave = () => {
-  const imageUrl = '/assets/Card.png';
+export const handleSave = (imageUrl: string, fileName?: string) => {
   const link = document.createElement('a');
   link.href = imageUrl;
-  link.download = '글다.png';
+  link.download = fileName || '글다.png';
   link.click();
 };

그리고 PostCardActions 컴포넌트에서 실제 이미지 URL과 파일명을 props로 받아 전달하도록 수정해야 합니다.

🤖 Prompt for AI Agents
In src/shared/utils/postcardActions.ts around lines 15 to 21, handleSave is
hardcoded to '/assets/Card.png' and a fixed filename so it never saves the
currently displayed postcard; change handleSave to accept parameters (imageUrl:
string, filename?: string) and use those values to set link.href and
link.download (fallback filename if none provided), and export the updated
signature so PostCardActions component can pass the actual image URL and desired
filename as props when invoking handleSave/handleShare.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (2)
src/pages/main/PostCard.tsx (2)

11-17: 이미지 방향 감지 중 로딩 상태 처리를 고려하세요.

useImageOrientation 훅은 이미지 로드 전까지 초기값으로 'square'를 반환합니다. 이로 인해 이미지 로드 완료 후 레이아웃이 변경되면서 시각적 깜빡임(layout shift)이 발생할 수 있습니다.

다음과 같이 로딩 상태를 추가하는 것을 권장합니다:

const { orientation, size } = useImageOrientation(frontSrc);

// 이미지 크기가 결정되기 전에는 로딩 표시
if (!size) {
  return <LoadingSpinner />;
}

const imageProps = 
  orientation === 'portrait'
    ? { width: 220, height: 354, backSrc: '/assets/letter_card_backv.png' }
    : { width: 354, height: 220, backSrc: '/assets/letter_card_backh.png' };

25-26: className 문자열의 불필요한 공백을 제거하세요.

여러 className 문자열에 후행 공백이 있습니다. 기능에는 영향을 주지 않지만 코드 품질을 위해 제거하는 것이 좋습니다.

다음과 같이 수정하세요:

-        orientation === 'portrait' ? 'mb-[5rem]   ' : 'mb-[0rem]  ',
+        orientation === 'portrait' ? 'mb-[5rem]' : 'mb-[0rem]',
-          orientation === 'portrait' ? 'mt-[6rem]   ' : 'mt-[10rem]  ',
+          orientation === 'portrait' ? 'mt-[6rem]' : 'mt-[10rem]',

Also applies to: 31-32

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5a5347f and 7ecaa56.

📒 Files selected for processing (3)
  • src/pages/main/PostCard.tsx (1 hunks)
  • src/shared/components/main/components/PostCardActions/PostCardActions.tsx (1 hunks)
  • src/shared/utils/postcardActions.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/shared/components/main/components/PostCardActions/PostCardActions.tsx
🧰 Additional context used
🧬 Code graph analysis (1)
src/pages/main/PostCard.tsx (3)
src/shared/hooks/useImageOrientation.ts (1)
  • useImageOrientation (4-29)
src/shared/lib/utils.ts (1)
  • cn (71-73)
src/shared/components/main/components/PostCardActions/PostCardActions.tsx (1)
  • PostCardActions (10-33)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build-and-deploy

Comment on lines +8 to +9
const label = '가톨릭대';
const frontSrc = '/assets/card3.png';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

API 통합이 누락되었습니다.

PR 목표는 "postcard GET API 연결"이지만, 현재 코드에서는 모든 데이터가 하드코딩되어 있습니다. labelfrontSrc를 API에서 가져와야 합니다.

다음과 같은 방식으로 API를 통합하는 것을 권장합니다:

const PostCard = () => {
  const { data: postcardData, isLoading, error } = useGetPostCardDetail();
  
  if (isLoading) return <LoadingSpinner />;
  if (error) return <ErrorMessage />;
  
  const label = postcardData?.placeName ?? '';
  const frontSrc = postcardData?.imageUrl ?? '';
  // ... rest of logic
🤖 Prompt for AI Agents
In src/pages/main/PostCard.tsx around lines 8-9 the label and frontSrc are
hardcoded; replace them by calling the postcard GET API via
useGetPostCardDetail(), handle isLoading and error states (return a loading or
error UI), then derive label as postcardData?.placeName ?? '' and frontSrc as
postcardData?.imageUrl ?? '' so the component uses API data instead of static
values.

Comment on lines +15 to 24
export const handleSave = (imageUrl: string, placeName: string) => {
try {
const link = document.createElement('a');
link.href = imageUrl;
link.download = `${placeName}.png`;
link.click();
} catch (err) {
console.error('이미지 저장 실패:', err);
}
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Cross-origin 이미지 처리 및 파일명 검증 필요

현재 구현에는 다음 문제들이 있습니다:

  1. CORS 이슈: imageUrl이 cross-origin이고 적절한 CORS 헤더가 없으면, 다운로드가 실패하거나 에러 페이지가 다운로드될 수 있습니다.
  2. 파일명 검증 누락: placeName에 파일명으로 사용할 수 없는 문자(/, \, :, *, ?, ", <, >, |)가 포함되어 있을 수 있습니다.
  3. 확장자 하드코딩: 모든 이미지를 .png로 가정하지만, 실제 이미지 형식과 다를 수 있습니다.

다음과 같이 개선하세요:

+// 파일명에서 사용할 수 없는 문자 제거
+const sanitizeFilename = (filename: string): string => {
+  return filename.replace(/[/\\:*?"<>|]/g, '_');
+};
+
+// 이미지 URL에서 확장자 추출
+const getImageExtension = (url: string): string => {
+  const match = url.match(/\.(png|jpg|jpeg|gif|webp)$/i);
+  return match ? match[1] : 'png';
+};
+
 export const handleSave = (imageUrl: string, placeName: string) => {
   try {
+    const sanitizedName = sanitizeFilename(placeName);
+    const extension = getImageExtension(imageUrl);
     const link = document.createElement('a');
     link.href = imageUrl;
-    link.download = `${placeName}.png`;
+    link.download = `${sanitizedName}.${extension}`;
     link.click();
   } catch (err) {
     console.error('이미지 저장 실패:', err);
   }
 };

추가 고려사항: Cross-origin 이미지의 경우, fetch API로 blob을 가져와서 blob URL로 다운로드하는 방식이 더 안정적입니다:

export const handleSave = async (imageUrl: string, placeName: string) => {
  try {
    const sanitizedName = sanitizeFilename(placeName);
    const extension = getImageExtension(imageUrl);
    
    const response = await fetch(imageUrl);
    const blob = await response.blob();
    const blobUrl = URL.createObjectURL(blob);
    
    const link = document.createElement('a');
    link.href = blobUrl;
    link.download = `${sanitizedName}.${extension}`;
    link.click();
    
    // 메모리 정리
    URL.revokeObjectURL(blobUrl);
  } catch (err) {
    console.error('이미지 저장 실패:', err);
    alert('이미지를 저장할 수 없습니다. CORS 정책을 확인해주세요.');
  }
};
🤖 Prompt for AI Agents
In src/shared/utils/postcardActions.ts around lines 15 to 24, the current
handleSave implementation risks CORS failures, uses a hardcoded .png extension,
and doesn't sanitize placeName; modify handleSave to be async, fetch the image
as a blob (so cross-origin images that allow fetch succeed and you avoid
downloading error pages), derive the extension from the response Content-Type
(fall back to parsing the URL or a default like png), sanitize placeName by
removing/replacing invalid filename characters (\/:*?"<>| and trimming), create
an object URL from the blob, set link.href to that blob URL and link.download to
`${sanitizedName}.${extension}`, trigger link.click(), revoke the object URL
after use, and in the catch block log the error and surface a user-friendly
message about possible CORS issues.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

♻️ Duplicate comments (1)
src/shared/utils/postcardActions.ts (1)

21-30: Cross-origin 이미지 처리 및 파일명 검증이 필요합니다.

이전 리뷰에서 지적된 다음 문제들이 여전히 존재합니다:

  1. CORS 이슈: cross-origin 이미지에서 적절한 CORS 헤더가 없으면 다운로드가 실패하거나 에러 페이지가 다운로드될 수 있습니다.
  2. 파일명 검증 누락: placeName에 파일명으로 사용할 수 없는 문자(/, \, :, *, ?, ", <, >, |)가 포함되어 있을 수 있습니다.
  3. 확장자 하드코딩: 모든 이미지를 .png로 가정하지만, 실제 이미지 형식과 다를 수 있습니다.

다음과 같이 개선하세요:

+// 파일명에서 사용할 수 없는 문자 제거
+const sanitizeFilename = (filename: string): string => {
+  return filename.replace(/[/\\:*?"<>|]/g, '_').trim();
+};
+
+// 이미지 URL에서 확장자 추출
+const getImageExtension = (url: string): string => {
+  const match = url.match(/\.(png|jpg|jpeg|gif|webp)$/i);
+  return match ? match[1] : 'png';
+};
+
-export const handleSave = (imageUrl: string, placeName: string) => {
+export const handleSave = async (imageUrl: string, placeName: string) => {
   try {
+    const sanitizedName = sanitizeFilename(placeName);
+    const extension = getImageExtension(imageUrl);
+    
+    const response = await fetch(imageUrl);
+    const blob = await response.blob();
+    const blobUrl = URL.createObjectURL(blob);
+    
     const link = document.createElement('a');
-    link.href = imageUrl;
-    link.download = `${placeName}.png`;
+    link.href = blobUrl;
+    link.download = `${sanitizedName}.${extension}`;
     link.click();
+    
+    // 메모리 정리
+    URL.revokeObjectURL(blobUrl);
   } catch (err) {
     console.error('이미지 저장 실패:', err);
+    alert('이미지를 저장할 수 없습니다. CORS 정책을 확인해주세요.');
   }
 };
🧹 Nitpick comments (1)
src/pages/main/PostCard.tsx (1)

31-37: 로딩 메시지가 부정확합니다.

현재 메시지는 "엽서 데이터를 불러오는 중..."이지만, getPostcard()는 localStorage에서 동기적으로 데이터를 가져오므로 네트워크 로딩이 아닙니다. 더 정확한 메시지로 변경하는 것이 좋습니다.

다음과 같이 수정하세요:

   if (!postcard) {
     return (
       <div className='flex items-center justify-center h-screen'>
-        <p className='text-gray-500 text-lg'>엽서 데이터를 불러오는 중...</p>
+        <p className='text-gray-500 text-lg'>엽서 데이터가 없습니다.</p>
       </div>
     );
   }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c3cd214 and 6f8eda0.

📒 Files selected for processing (4)
  • src/pages/main/PostCard.tsx (1 hunks)
  • src/pages/main/node/[placeId].tsx (2 hunks)
  • src/shared/utils/postcardActions.ts (1 hunks)
  • src/shared/utils/storage.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (3)
src/shared/utils/storage.ts (2)
src/shared/utils/token.ts (4)
  • isBrowser (17-21)
  • accessToken (11-15)
  • isBrowser (6-7)
  • isBrowser (8-9)
src/shared/hooks/useRecentLogin.ts (2)
  • useRecentLogin (6-21)
  • platform (15-18)
src/pages/main/PostCard.tsx (7)
src/shared/api/main/node/types/stampAcquireTypes.ts (1)
  • PostcardData (6-12)
src/shared/utils/storage.ts (1)
  • getPostcard (20-29)
src/shared/hooks/useImageOrientation.ts (1)
  • useImageOrientation (4-29)
src/shared/lib/utils.ts (1)
  • cn (71-73)
src/shared/components/main/components/PostCardActions/PostCardActions.tsx (1)
  • PostCardActions (10-33)
src/shared/components/flipCard/FlipCard.tsx (2)
  • FlipCardProps (12-62)
  • FlipCardProps (5-10)
src/shared/components/container/LocationCard.tsx (1)
  • LocationCardProps (16-119)
src/pages/main/node/[placeId].tsx (2)
src/shared/api/main/node/queries/useStampAcquire.ts (1)
  • useStampAcquire (5-9)
src/shared/utils/storage.ts (1)
  • savePostcard (10-17)
🪛 Biome (2.1.2)
src/pages/main/PostCard.tsx

[error] 45-45: This hook is being called conditionally, but all hooks must be called in the exact same order in every component render.

Hooks should not be called after an early return.

For React to preserve state between calls, hooks needs to be called unconditionally and always in the same order.
See https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level

(lint/correctness/useHookAtTopLevel)

🔇 Additional comments (2)
src/pages/main/node/[placeId].tsx (2)

16-17: LGTM! 스탬프 획득 후 데이터 영속화 로직이 잘 구현되었습니다.

import 구조 정리와 savePostcard 추가로 획득한 엽서 데이터를 로컬스토리지에 저장하는 흐름이 명확합니다.


63-70: LGTM! 엽서 데이터 저장 및 네비게이션 로직이 적절합니다.

스탬프 획득 성공 시 엽서 데이터를 저장하고, 필요한 데이터만 쿼리 파라미터로 전달하는 방식이 깔끔합니다.

Comment on lines +1 to 19
export const handleShare = async (imageUrl: string, title: string) => {
if (navigator.canShare && navigator.canShare({ files: [] })) {
const response = await fetch(imageUrl);
const blob = await response.blob();
const file = new File([blob], `${title}.png`, { type: blob.type });

try {
await navigator.share({
title: `${title} 엽서`,
text: '내가 획득한 엽서를 공유합니다!',
files: [file],
});
} catch (err) {
console.error('공유 실패:', err);
}
} else {
alert('이 브라우저에서는 공유 기능을 지원하지 않습니다.');
alert('이 브라우저에서는 이미지 공유를 지원하지 않습니다.');
}
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

fetch 에러 처리가 누락되었습니다.

현재 fetchblob 생성 로직이 try/catch 블록 밖에 있어 네트워크 에러나 CORS 에러 시 처리되지 않습니다.

다음과 같이 전체 로직을 try/catch로 감싸세요:

 export const handleShare = async (imageUrl: string, title: string) => {
+  try {
     if (navigator.canShare && navigator.canShare({ files: [] })) {
       const response = await fetch(imageUrl);
       const blob = await response.blob();
       const file = new File([blob], `${title}.png`, { type: blob.type });
 
-      try {
-        await navigator.share({
-          title: `${title} 엽서`,
-          text: '내가 획득한 엽서를 공유합니다!',
-          files: [file],
-        });
-      } catch (err) {
-        console.error('공유 실패:', err);
-      }
+      await navigator.share({
+        title: `${title} 엽서`,
+        text: '내가 획득한 엽서를 공유합니다!',
+        files: [file],
+      });
     } else {
       alert('이 브라우저에서는 이미지 공유를 지원하지 않습니다.');
     }
+  } catch (err) {
+    console.error('이미지 공유 실패:', err);
+    alert('이미지를 공유할 수 없습니다. 네트워크 연결이나 CORS 정책을 확인해주세요.');
+  }
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const handleShare = async (imageUrl: string, title: string) => {
if (navigator.canShare && navigator.canShare({ files: [] })) {
const response = await fetch(imageUrl);
const blob = await response.blob();
const file = new File([blob], `${title}.png`, { type: blob.type });
try {
await navigator.share({
title: `${title} 엽서`,
text: '내가 획득한 엽서를 공유합니다!',
files: [file],
});
} catch (err) {
console.error('공유 실패:', err);
}
} else {
alert('이 브라우저에서는 공유 기능을 지원하지 않습니다.');
alert('이 브라우저에서는 이미지 공유를 지원하지 않습니다.');
}
};
export const handleShare = async (imageUrl: string, title: string) => {
try {
if (navigator.canShare && navigator.canShare({ files: [] })) {
const response = await fetch(imageUrl);
const blob = await response.blob();
const file = new File([blob], `${title}.png`, { type: blob.type });
await navigator.share({
title: `${title} 엽서`,
text: '내가 획득한 엽서를 공유합니다!',
files: [file],
});
} else {
alert('이 브라우저에서는 이미지 공유를 지원하지 않습니다.');
}
} catch (err) {
console.error('이미지 공유 실패:', err);
alert('이미지를 공유할 수 없습니다. 네트워크 연결이나 CORS 정책을 확인해주세요.');
}
};
🤖 Prompt for AI Agents
In src/shared/utils/postcardActions.ts around lines 1 to 19, the fetch/blob
creation is not inside a try/catch so network/CORS or non-OK responses will
throw unhandled; wrap the async flow (fetch, response.ok check, response.blob,
File creation and navigator.share) in a try/catch, check response.ok and throw
or handle non-2xx responses before calling blob(), and in the catch log the
error and show a user-facing message (alert or toast) instead of letting the
exception bubble; keep the existing fallback for unsupported browsers.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/shared/components/container/LocationCard.tsx (1)

24-28: 사용되지 않는 liked 상태와 주석 처리된 핸들러 정리 제안

지금은 liked 상태가 어디에서도 사용되지 않고, handleLikeClick만 주석으로 남아 있습니다.
좋아요 기능을 당분간 노출하지 않는 것이 의도라면, 상태와 관련 주석을 함께 제거하거나 TODO 코멘트로 의도를 남겨 두면 코드가 더 깔끔해질 것 같습니다.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6f8eda0 and db596d0.

⛔ Files ignored due to path filters (2)
  • public/assets/bannerMain.svg is excluded by !**/*.svg
  • public/assets/bannerMap.svg is excluded by !**/*.svg
📒 Files selected for processing (4)
  • src/pages/main/PostCard.tsx (1 hunks)
  • src/shared/components/button/AddressCopy.tsx (1 hunks)
  • src/shared/components/container/LocationCard.tsx (4 hunks)
  • src/shared/utils/storage.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/shared/utils/storage.ts
🧰 Additional context used
🧬 Code graph analysis (2)
src/pages/main/PostCard.tsx (5)
src/shared/api/main/node/types/stampAcquireTypes.ts (1)
  • PostcardData (6-12)
src/shared/utils/storage.ts (1)
  • getPostcard (16-25)
src/shared/hooks/useImageOrientation.ts (1)
  • useImageOrientation (4-29)
src/shared/lib/utils.ts (1)
  • cn (71-73)
src/shared/components/main/components/PostCardActions/PostCardActions.tsx (1)
  • PostCardActions (10-33)
src/shared/components/button/AddressCopy.tsx (1)
src/shared/lib/utils.ts (1)
  • cn (71-73)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build-and-deploy
🔇 Additional comments (3)
src/shared/components/button/AddressCopy.tsx (1)

65-71: 민트 variant 색상 토큰 변경 잘 반영되었습니다.

아이콘/텍스트를 mint-600으로 올리면서 다른 컴포넌트의 민트 계열(예: LocationCard)과 톤이 맞고 대비도 더 좋아 보입니다. 로직 변경은 없어서 동작 측면 문제는 없어 보입니다.

src/shared/components/container/LocationCard.tsx (1)

60-63: mint variant 색상 계열 통일 👍

주소/설명 텍스트와 MapPin 아이콘을 mint-600 계열로 맞추면서, 다른 컴포넌트(예: AddressCopy)의 민트 스타일과 일관성이 생겼고 가독성도 좋아 보입니다. 기능 변경 없이 시각적 토큰만 조정된 부분이라 동작 측면 이슈는 없어 보입니다.

Also applies to: 73-94

src/pages/main/PostCard.tsx (1)

18-21: 이미지 방향 기반 레이아웃 및 액션/LocationCard 연동 구조가 깔끔합니다

orientation으로 카드 사이즈와 여백을 일관되게 분기하고, FlipCard / PostCardActions / LocationCardpostcard 데이터만 흘려보내는 구조가 명확해서 이후 확장/수정 시에도 이해하기 쉬울 것 같습니다. 훅 호출 위치도 모두 컴포넌트 최상단에 배치되어 현재 구조에서는 특별한 동작 이슈는 없어 보입니다.

Also applies to: 30-81, 84-91

Comment on lines +13 to +28
useEffect(() => {
const stored = getPostcard();
if (stored) setPostcard(stored);
}, []);

const { orientation } = useImageOrientation(
postcard?.imageUrl || '/assets/card_placeholder.png',
);

if (!postcard) {
return (
<div className='flex items-center justify-center h-screen'>
<p>엽서 데이터를 불러오는 중...</p>
</div>
);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

로컬스토리지에 엽서가 없을 때 무한 로딩 상태가 됩니다

useEffect에서 getPostcard()null을 반환하는 경우 setPostcard가 한 번도 호출되지 않아 postcard가 계속 null로 남고, 그 결과 이 컴포넌트는 영구히 "엽서 데이터를 불러오는 중..." 화면만 보여주게 됩니다.

사용자가 직접 URL로 진입하거나, 로컬스토리지가 초기화된 상태에서는 엽서를 볼 수 있는 방법이 없어져 UX 상 문제가 될 수 있습니다.

isLoading/hasLoaded 같은 플래그를 추가해 “로딩 중”과 “데이터 없음”을 분리하거나, 초기 로딩 이후에도 postcard === null인 경우에는 별도의 빈 상태(예: “엽서 데이터를 찾을 수 없습니다”)를 보여주는 분기 처리를 추가하는 것을 권장드립니다.

🤖 Prompt for AI Agents
In src/pages/main/PostCard.tsx around lines 13-28, the effect only sets postcard
when getPostcard() returns a value so postcard stays null and the component
shows a perpetual "loading" state; add an explicit loading flag (e.g. isLoading)
that starts true, set it to false after getPostcard() completes regardless of
result, and update the render to branch: while isLoading show the existing
loading UI, when not loading and postcard is null show a clear empty state
message (e.g. "엽서 데이터를 찾을 수 없습니다") with an action or link to create/return,
otherwise render the postcard; ensure useImageOrientation receives a fallback
only after confirming postcard exists or guard its call behind the
loaded/postcard check.

Copy link
Copy Markdown
Contributor

@KongMezu KongMezu left a comment

Choose a reason for hiding this comment

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

큰 수정사항 없어서 s3 연결만 하면 될 거 같습니다.. 수고하셨습니다.
마이페이지 감사합니다..

Comment on lines +7 to +9
const PostCard = () => {
const label = '가톨릭대'; // 임시
const label = '가톨릭대';
const frontSrc = '/assets/card3.png';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

여기 하드 코딩 의도 하신건가요..?
(정말 포스트 카드 쪽은 거의 안봐서 물어봅니다.)

@jjangminii jjangminii merged commit 9ebde28 into develop Nov 14, 2025
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api api 연결 comment 필요한 주석 추가 및 변경

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[api&feat] 엽서 get

2 participants