diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ff232dc..60f1d6e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -3,11 +3,10 @@ name: Deploy to Netlify on: push: branches: - - main - - feat/kakao-map + - release pull_request: branches: - - main + - release jobs: build-and-deploy: @@ -35,7 +34,7 @@ jobs: uses: nwtgck/actions-netlify@v3.0 with: publish-dir: './dist' - production-branch: main + production-branch: release github-token: ${{ secrets.GITHUB_TOKEN }} deploy-message: 'Deploy from GitHub Actions' enable-pull-request-comment: true diff --git a/src/components/badge/BadgeForm.jsx b/src/components/badge/BadgeForm.jsx index fc57947..d18c72e 100644 --- a/src/components/badge/BadgeForm.jsx +++ b/src/components/badge/BadgeForm.jsx @@ -2,6 +2,7 @@ import React, { useState, useRef } from 'react'; import { registerBadge } from '../../api/badgeApi'; import { uploadImageToFirebase } from '../../util/imageUpload'; import { Upload, X } from 'lucide-react'; +import MessageModal from '../common/MessageModal'; // 뱃지 카테고리 키워드 (챌린지와 동일) const VALID_CATEGORIES = [ @@ -15,7 +16,6 @@ const VALID_CATEGORIES = [ const BadgeForm = () => { const [isLoading, setIsLoading] = useState(false); const [isUploading, setIsUploading] = useState(false); - const [error, setError] = useState(''); const [name, setName] = useState(''); const [description, setDescription] = useState(''); const [icon, setIcon] = useState(''); @@ -24,6 +24,9 @@ const BadgeForm = () => { const [previewImage, setPreviewImage] = useState(''); const [selectedFile, setSelectedFile] = useState(null); const fileInputRef = useRef(null); + const [modalMessage, setModalMessage] = useState(''); + const [modalType, setModalType] = useState('error'); + const [showModal, setShowModal] = useState(false); // 파일 선택 핸들러 const handleFileSelect = (e) => { @@ -32,18 +35,21 @@ const BadgeForm = () => { // 파일 크기 검증 (5MB) if (file.size > 5 * 1024 * 1024) { - setError('이미지 파일은 5MB 이하여야 합니다.'); + setModalMessage('이미지 파일은 5MB 이하여야 합니다.'); + setModalType('error'); + setShowModal(true); return; } // 파일 타입 검증 if (!file.type.startsWith('image/')) { - setError('이미지 파일만 업로드 가능합니다.'); + setModalMessage('이미지 파일만 업로드 가능합니다.'); + setModalType('error'); + setShowModal(true); return; } setSelectedFile(file); - setError(''); // 미리보기 생성 const reader = new FileReader(); @@ -56,12 +62,13 @@ const BadgeForm = () => { // 이미지 업로드 핸들러 const handleImageUpload = async () => { if (!selectedFile) { - setError('이미지 파일을 선택해주세요.'); + setModalMessage('이미지 파일을 선택해주세요.'); + setModalType('error'); + setShowModal(true); return; } setIsUploading(true); - setError(''); try { // Firebase Storage에 업로드하고 URL 받기 @@ -73,10 +80,16 @@ const BadgeForm = () => { // 받은 URL을 icon state에 저장 (이 URL이 서버로 전달됨!) setIcon(imageUrl); - alert('✅ 이미지가 성공적으로 업로드되었습니다!'); + setModalMessage('이미지가 성공적으로 업로드되었습니다!'); + setModalType('success'); + setShowModal(true); } catch (err) { console.error('이미지 업로드 실패', err); - setError('❌ 이미지 업로드 중 오류가 발생했습니다: ' + err.message); + setModalMessage( + '이미지 업로드 중 오류가 발생했습니다: ' + err.message + ); + setModalType('error'); + setShowModal(true); setSelectedFile(null); setPreviewImage(''); if (fileInputRef.current) { @@ -99,11 +112,12 @@ const BadgeForm = () => { const handleSubmit = async (e) => { e.preventDefault(); - setError(''); // 필수 필드 검사 if (!name || !icon || !category || !requirement) { - setError('비어있는 칸이 있습니다. 칸을 모두 채워주세요.'); + setModalMessage('비어있는 칸이 있습니다. 칸을 모두 채워주세요.'); + setModalType('error'); + setShowModal(true); return; } @@ -111,7 +125,9 @@ const BadgeForm = () => { const requirementNum = parseInt(requirement, 10); if (isNaN(requirementNum) || requirementNum <= 0) { - setError('요구 포인트는 양수여야 합니다.'); + setModalMessage('획득 기준은 양수여야 합니다.'); + setModalType('error'); + setShowModal(true); return; } @@ -128,7 +144,9 @@ const BadgeForm = () => { const res = await registerBadge(badgeData); console.log('뱃지 추가 응답:', res); - alert('✅ 뱃지가 성공적으로 등록되었습니다!'); + setModalMessage('뱃지가 성공적으로 등록되었습니다!'); + setModalType('success'); + setShowModal(true); // 폼 초기화 setName(''); setDescription(''); @@ -143,272 +161,280 @@ const BadgeForm = () => { } catch (err) { console.error('뱃지 추가 실패', err.response || err); + let errorMessage = '뱃지 추가 중 오류가 발생했습니다.'; if (err.response?.status === 401) { - setError('❌ 인증이 필요합니다. 다시 로그인해주세요.'); + errorMessage = '인증이 필요합니다. 다시 로그인해주세요.'; } else if (err.response?.status === 400) { - setError('❌ 입력 형식이 올바르지 않습니다.'); + errorMessage = '입력 형식이 올바르지 않습니다.'; } else if (err.response?.data?.message) { - setError(`❌ ${err.response.data.message}`); - } else { - setError('❌ 뱃지 추가 중 오류가 발생했습니다.'); + errorMessage = err.response.data.message; } + + setModalMessage(errorMessage); + setModalType('error'); + setShowModal(true); } finally { setIsLoading(false); } }; return ( -
-

- 뱃지 작성 -

- - {/* 전역 에러 메시지 */} - {error && ( -
- {error} -
+ <> + {showModal && ( + setShowModal(false)} + /> )} +
+

+ 뱃지 작성 +

+ +
+
+ + +

+ 뱃지의 카테고리를 선택하세요. +

+
- -
- - -

- 뱃지의 카테고리를 선택하세요. -

-
- -
- - setName(e.target.value)} - required - disabled={isLoading} - className='w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-400 disabled:bg-gray-100 disabled:cursor-not-allowed' - placeholder='예: 초록이 뱃지' - /> -
- -
- - setDescription(e.target.value)} - disabled={isLoading} - className='w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-400 disabled:bg-gray-100 disabled:cursor-not-allowed' - placeholder='예: 누적 따릉이 100km (선택사항)' - /> -
-
- - setRequirement(e.target.value)} - min='1' - required - disabled={isLoading} - className='w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-400 disabled:bg-gray-100 disabled:cursor-not-allowed' - placeholder='1000' - /> -

- 이 뱃지를 획득하기 위해 필요한 포인트를 입력하세요. -

-
- - {/* 이미지 업로드 섹션 */} -
- +
+ + setName(e.target.value)} + required + disabled={isLoading} + className='w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-400 disabled:bg-gray-100 disabled:cursor-not-allowed' + placeholder='예: 초록이 뱃지' + /> +
-
- {/* 파일 선택 */} -
- - -
+
+ + setDescription(e.target.value)} + disabled={isLoading} + className='w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-400 disabled:bg-gray-100 disabled:cursor-not-allowed' + placeholder='예: 누적 따릉이 100km (선택사항)' + /> +
+
+ + setRequirement(e.target.value)} + min='1' + required + disabled={isLoading} + className='w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-400 disabled:bg-gray-100 disabled:cursor-not-allowed' + placeholder='1000' + /> +

+ 이 뱃지를 획득하기 위해 필요한 획득 기준을 + 입력하세요. +

+
- {/* 업로드 버튼 (파일 선택 후) */} - {selectedFile && !icon && ( - - )} - - {/* 이미지 미리보기 */} - {(previewImage || icon) && ( -
- 미리보기 + + +
+ {/* 파일 선택 */} +
+ + +
+ + {/* 업로드 버튼 (파일 선택 후) */} + {selectedFile && !icon && ( -
- )} - - {/* 업로드 완료 메시지 */} - {icon && - icon.startsWith( - 'https://firebasestorage.googleapis.com' - ) && ( -
- ✅ Firebase Storage에 업로드 완료 + )} + + {/* 이미지 미리보기 */} + {(previewImage || icon) && ( +
+ 미리보기 +
)} - {/* 또는 URL 직접 입력 */} -
-
-
-
-
- - 또는 URL 직접 입력 - + {/* 업로드 완료 메시지 */} + {icon && + icon.startsWith( + 'https://firebasestorage.googleapis.com' + ) && ( +
+ ✅ Firebase Storage에 업로드 완료 +
+ )} + + {/* 또는 URL 직접 입력 */} +
+
+
+
+
+ + 또는 URL 직접 입력 + +
+ + setIcon(e.target.value)} + disabled={isLoading || isUploading} + className='w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-400 disabled:bg-gray-100 disabled:cursor-not-allowed' + placeholder='https://firebasestorage.googleapis.com/... 또는 다른 URL' + />
- setIcon(e.target.value)} - disabled={isLoading || isUploading} - className='w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-400 disabled:bg-gray-100 disabled:cursor-not-allowed' - placeholder='https://firebasestorage.googleapis.com/... 또는 다른 URL' - /> +

+ 이미지 파일을 업로드하거나 URL을 직접 입력하세요. + (최대 5MB) +

-

- 이미지 파일을 업로드하거나 URL을 직접 입력하세요. (최대 - 5MB) -

-
- -
- +
+ +
+ + + {/* 카테고리 안내 */} +
+

+ 📋 카테고리 안내 +

+
    +
  • + • 따릉이: 자전거 이용 관련 뱃지 +
  • +
  • + • 전기차: 전기차 충전 관련 뱃지 +
  • +
  • + • 수소차: 수소차 충전 관련 뱃지 +
  • +
  • + • 재활용센터: 재활용센터 방문 관련 + 뱃지 +
  • +
  • + • 제로웨이스트: 제로웨이스트 상점 + 이용 관련 뱃지 +
  • +
- - - {/* 카테고리 안내 */} -
-

- 📋 카테고리 안내 -

-
    -
  • - • 따릉이: 자전거 이용 관련 뱃지 -
  • -
  • - • 전기차: 전기차 충전 관련 뱃지 -
  • -
  • - • 수소차: 수소차 충전 관련 뱃지 -
  • -
  • - • 재활용센터: 재활용센터 방문 관련 뱃지 -
  • -
  • - • 제로웨이스트: 제로웨이스트 상점 이용 - 관련 뱃지 -
  • -
-
+ ); }; diff --git a/src/components/cert/CertModal.jsx b/src/components/cert/CertModal.jsx index 13828d8..0dc81e9 100644 --- a/src/components/cert/CertModal.jsx +++ b/src/components/cert/CertModal.jsx @@ -19,6 +19,7 @@ import { verifyHCar, verifyShop, } from '../../util/certApi'; + function Modal({ message, type = 'info', onClose, onSuccess }) { const handleClick = () => { onClose(); @@ -27,15 +28,17 @@ function Modal({ message, type = 'info', onClose, onSuccess }) { } }; + const isSuccess = type === 'success' || type === 'info-success'; + return (
- {type === 'success' ? '🌳' : '🍂'} + {isSuccess ? '🌳' : '🍂'}

@@ -46,8 +49,7 @@ function Modal({ message, type = 'info', onClose, onSuccess }) { onClick={handleClick} className='w-full py-2 rounded-xl font-bold text-white' style={{ - background: - type === 'success' ? '#96cb6f' : '#e63e3eff', + background: isSuccess ? '#96cb6f' : '#e63e3eff', }} > 확인 @@ -66,16 +68,13 @@ export default function CertModal({ const dispatch = useDispatch(); const { isLoggedIn } = useSelector((state) => state.user); - // 모달이 열릴 때 토큰이 있으면 로그인 상태 확인 useEffect(() => { const token = localStorage.getItem('token'); if (token && !isLoggedIn) { - // 토큰이 있지만 Redux 상태가 업데이트되지 않은 경우 api.get('/member/me', { headers: { Authorization: `Bearer ${token}` }, }) .then((res) => { - // Redux 상태 업데이트 dispatch(login({ token })); dispatch( updateProfile({ @@ -86,11 +85,9 @@ export default function CertModal({ memberId: res.data.data.memberId, }) ); - // 포인트 정보 가져오기 dispatch(fetchPointInfo()); }) .catch(() => { - // 토큰이 유효하지 않으면 제거 localStorage.removeItem('token'); localStorage.removeItem('memberId'); }); @@ -191,10 +188,10 @@ export default function CertModal({ if (hasRecycleKeyword) { setDetectedCategory('recycle'); - showModal('재활용센터로 인식되었습니다', 'info'); + showModal('재활용센터로 인식되었습니다', 'info-success'); } else if (hasZeroKeyword) { setDetectedCategory('zero'); - showModal('제로웨이스트로 인식되었습니다', 'info'); + showModal('제로웨이스트로 인식되었습니다', 'info-success'); } else { showModal( '키워드를 인식하지 못했습니다. 영수증을 다시 확인해주세요.', @@ -209,7 +206,7 @@ export default function CertModal({ if (hasKeyword) { showModal( '인식 완료! 값을 확인 후 인증 요청을 눌러주세요', - 'info' + 'info-success' ); } else { showModal( @@ -241,9 +238,7 @@ export default function CertModal({ if (file) processImageWithOCR(file); } - // 버튼 비활성화 조건 계산 const isButtonDisabled = () => { - // type이 없으면 비활성화 if (!type || !type.id) { return true; } @@ -368,7 +363,6 @@ export default function CertModal({ const successMessage = `인증 성공! ${result.message}\n\n획득 포인트: ${result.data.point}P\n탄소 감소량: ${carbonAmount}kg`; showModal(successMessage, 'success'); - // onClose(); } else { let userMessage = result.message || '인증에 실패했습니다.'; if ( @@ -392,12 +386,12 @@ export default function CertModal({ }; return ( -

+
{/* 상단 헤더 */} @@ -421,12 +415,11 @@ export default function CertModal({
{/* 내부 내용 */} -
+
{/* 파일 업로드 */} diff --git a/src/components/cert/CertTypeCard.jsx b/src/components/cert/CertTypeCard.jsx index a0704f5..b61bffd 100644 --- a/src/components/cert/CertTypeCard.jsx +++ b/src/components/cert/CertTypeCard.jsx @@ -1,7 +1,6 @@ import React from 'react'; import { ChevronRight } from 'lucide-react'; -// 인증 타입 카드 컴포넌트 export default function CertTypeCard({ type, onClick }) { return ( +
+ -
- + {/* 챌린지 타입 안내 */} +
+

+ 📋 챌린지 타입 안내 +

+
    +
  • + • 따릉이: 자전거 이용 챌린지 (거리 + 기준, km 단위) +
  • +
  • + • 전기차: 전기차 충전 챌린지 + (충전비용 기준, 원 단위) +
  • +
  • + • 수소차: 수소차 충전 챌린지 + (충전비용 기준, 원 단위) +
  • +
  • + • 재활용센터: 재활용센터 방문 + 챌린지 (구매금액 기준, 원 단위) +
  • +
  • + • 제로웨이스트: 제로웨이스트 상점 + 이용 챌린지 (구매금액 기준, 원 단위) +
  • +
+

+ 💡 사용자가 인증을 완료하면 백엔드에서 자동으로 챌린지 + 진행률이 업데이트됩니다. +

- - - {/* 챌린지 타입 안내 */} -
-

- 📋 챌린지 타입 안내 -

-
    -
  • - • 따릉이: 자전거 이용 챌린지 (거리 - 기준, km 단위) -
  • -
  • - • 전기차: 전기차 충전 챌린지 (충전비용 - 기준, 원 단위) -
  • -
  • - • 수소차: 수소차 충전 챌린지 (충전비용 - 기준, 원 단위) -
  • -
  • - • 재활용센터: 재활용센터 방문 챌린지 - (구매금액 기준, 원 단위) -
  • -
  • - • 제로웨이스트: 제로웨이스트 상점 이용 - 챌린지 (구매금액 기준, 원 단위) -
  • -
-

- 💡 사용자가 인증을 완료하면 백엔드에서 자동으로 챌린지 - 진행률이 업데이트됩니다. -

-
+ ); }; diff --git a/src/components/common/MessageModal.jsx b/src/components/common/MessageModal.jsx new file mode 100644 index 0000000..7a12035 --- /dev/null +++ b/src/components/common/MessageModal.jsx @@ -0,0 +1,36 @@ +import React from 'react'; + +function MessageModal({ message, type = 'info', onClose }) { + const handleClick = () => { + onClose(); + }; + + return ( +
+
+
+ {type === 'success' ? '🌳' : '🍂'} +
+

+ {message} +

+ +
+
+ ); +} + +export default MessageModal; diff --git a/src/components/map/FilterBar.jsx b/src/components/map/FilterBar.jsx index f0e51bd..2260047 100644 --- a/src/components/map/FilterBar.jsx +++ b/src/components/map/FilterBar.jsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; const FILTER_OPTIONS = [ { key: 'all', label: '전체' }, @@ -7,15 +7,30 @@ const FILTER_OPTIONS = [ { key: 'hcar', label: '수소차' }, { key: 'store', label: '제로웨이스트' }, { key: 'bike', label: '따릉이' }, - { key: 'bookmark', label: '북마크' }, + { key: 'bookmark', label: '북마크', requiresLogin: true }, ]; export default function FilterBar({ selectedFilter, onFilterChange }) { + // 로그인 상태 확인 + const [isLoggedIn, setIsLoggedIn] = useState(false); + + useEffect(() => { + const token = localStorage.getItem('token'); + setIsLoggedIn(!!token); + }, []); + + const visibleFilters = FILTER_OPTIONS.filter((filter) => { + if (filter.requiresLogin && !isLoggedIn) { + return false; + } + return true; + }); + return (
- {FILTER_OPTIONS.map((filter) => ( + {visibleFilters.map((filter) => (
-
- - - + {/* 탭 네비게이션 */} +
+
+ + + +
{activeTab === 'badge' ? ( diff --git a/src/components/screens/BadgeScreen.jsx b/src/components/screens/BadgeScreen.jsx index 0ce6ba4..c3477d9 100644 --- a/src/components/screens/BadgeScreen.jsx +++ b/src/components/screens/BadgeScreen.jsx @@ -14,15 +14,16 @@ export default function BadgeScreen({ onBack, navigation, onNavigate }) { const [isSelecting, setIsSelecting] = useState(false); const handleGoBack = () => { - if (onBack) { - onBack(); - } else if (navigation) { - navigation.goBack(); - } else if (onNavigate) { - onNavigate('mypage'); - } else if (window.history.length > 1) { - window.history.back(); - } + // if (onBack) { + // onBack(); + // } else if (navigation) { + // navigation.goBack(); + // } else if (onNavigate) { + // onNavigate('mypage'); + // } else if (window.history.length > 1) { + // window.history.back(); + // } + window.history.back(); }; const fetchBadges = async () => { @@ -146,15 +147,15 @@ export default function BadgeScreen({ onBack, navigation, onNavigate }) {
-
+
-
+

뱃지 컬렉션

diff --git a/src/components/screens/HomeScreen.jsx b/src/components/screens/HomeScreen.jsx index 1d214c5..e36b7aa 100644 --- a/src/components/screens/HomeScreen.jsx +++ b/src/components/screens/HomeScreen.jsx @@ -151,7 +151,7 @@ export default function HomeScreen({ onNavigate }) { const placeholderSvg = encodeURIComponent( "" + - "이미지" + "이미지" ); const placeholder = `data:image/svg+xml;charset=UTF-8,${placeholderSvg}`; @@ -294,12 +294,12 @@ export default function HomeScreen({ onNavigate }) { {place.categoryId === 1 ? '🚲' : place.categoryId === 2 - ? '🛍️' - : place.categoryId === 3 - ? '⚡' - : place.categoryId === 5 - ? '♻️' - : '📍'} + ? '🛍️' + : place.categoryId === 3 + ? '⚡' + : place.categoryId === 5 + ? '♻️' + : '📍'}
@@ -389,28 +389,28 @@ export default function HomeScreen({ onNavigate }) { {/* 뱃지 이미지 - 프로필 이미지 오른쪽 하단 */} {(profile.badgeUrl || profile.image?.imageUrl) && ( -
- + 뱃지 { + // 이미지 로드 실패 시 기본 이미지로 설정 + if ( + e.target.src !== DEFAULT_BADGE_IMAGE + ) { + e.target.src = + DEFAULT_BADGE_IMAGE; } - alt='뱃지' - className='w-full h-full object-cover rounded-full' - onError={(e) => { - // 이미지 로드 실패 시 기본 이미지로 설정 - if ( - e.target.src !== - DEFAULT_BADGE_IMAGE - ) { - e.target.src = - DEFAULT_BADGE_IMAGE; - } - }} - /> -
- )} + }} + /> +
+ )}
{/* 닉네임 */} @@ -511,7 +511,7 @@ export default function HomeScreen({ onNavigate }) { +
+
+ ); +} + export default function MapScreen() { const dispatch = useDispatch(); const bookmarkedIds = useSelector((s) => s.facility.bookmarkedIds || []); @@ -35,6 +73,10 @@ export default function MapScreen() { const [selectedFilter, setSelectedFilter] = useState('all'); const [selectedFacility, setSelectedFacility] = useState(null); + // 모달 상태 추가 + const [showModal, setShowModal] = useState(false); + const [modalMessage, setModalMessage] = useState(''); + // Map refs const mapRef = useRef(null); const bottomSheetRef = useRef(null); @@ -42,7 +84,7 @@ export default function MapScreen() { const currentLocationOverlayRef = useRef(null); const KAKAO_KEY = import.meta.env.VITE_KAKAO_MAP_KEY || ''; - // Current location hook - useMemo보다 먼저 호출 + // Current location hook const { currentLocation, isLoading: isLocationLoading, @@ -54,7 +96,6 @@ export default function MapScreen() { if (places.length === 0) return []; const facilities = places.map(convertPlaceToFacility); - // 현재 위치가 있으면 거리 계산 if (currentLocation) { return calculateDistancesForFacilities(facilities, currentLocation); } @@ -71,12 +112,10 @@ export default function MapScreen() { const closeDetail = useCallback(() => { setSelectedFacility(null); - // BottomSheet 축소 if (bottomSheetRef.current) { bottomSheetRef.current.collapse(); } - // Close infowindow if (currentInfoWindowRef.current) { currentInfoWindowRef.current.close(); currentInfoWindowRef.current = null; @@ -103,20 +142,17 @@ export default function MapScreen() { try { const token = localStorage.getItem('token'); if (!token) { - // 로그인하지 않은 경우 북마크 목록 초기화 dispatch(setBookmarkedIds([])); return; } const bookmarks = await getMyBookmarks(); - // placeId를 facility id 형식으로 변환 (place-${placeId}) const bookmarkIds = bookmarks.map( (bookmark) => `place-${bookmark.placeId}` ); dispatch(setBookmarkedIds(bookmarkIds)); } catch (error) { console.error('북마크 목록 로드 실패:', error); - // 에러 발생 시 빈 배열로 설정 dispatch(setBookmarkedIds([])); } }; @@ -127,7 +163,6 @@ export default function MapScreen() { // 장소 데이터 로드 useEffect(() => { const loadPlaces = async () => { - // 현재 위치 또는 기본 위치 사용 (강남역 근처) const location = currentLocation || { lat: 37.4979, lng: 127.0276 }; try { @@ -144,18 +179,16 @@ export default function MapScreen() { loadPlaces(); }, [currentLocation]); - // showDetail 콜백 - useMarkers보다 먼저 정의 + // showDetail 콜백 const showDetail = useCallback( (facility) => { setSelectedFacility(facility); - // BottomSheet 확장 if (bottomSheetRef.current) { bottomSheetRef.current.expand(); } - // Focus on map marker - let offsetLat = 0.002; // 위로 약간 이동 (값은 지도의 줌 레벨에 따라 조정) + let offsetLat = 0.002; if (mapInstance) { const zoomLevel = mapInstance.getLevel(); console.log('Current zoom level:', zoomLevel); @@ -195,7 +228,7 @@ export default function MapScreen() { currentInfoWindowRef, selectedFilter, bookmarkedIds, - showDetail // 마커 클릭 콜백 전달 + showDetail ); // 검색에서 선택된 시설로 자동 포커스 @@ -208,16 +241,13 @@ export default function MapScreen() { if (selectedFacilityData) { const facility = JSON.parse(selectedFacilityData); - // sessionStorage 클리어 sessionStorage.removeItem('selectedFacility'); - // 해당 시설 찾기 const targetFacility = allFacilities.find( (f) => f.placeId === facility.placeId ); if (targetFacility) { - // 지도 중심 이동 및 줌 setTimeout(() => { if (mapInstance) { mapInstance.setCenter( @@ -226,14 +256,12 @@ export default function MapScreen() { targetFacility.lng ) ); - mapInstance.setLevel(3); // 줌인 + mapInstance.setLevel(3); - // 마커 애니메이션 적용 if (updateSelectedMarker) { updateSelectedMarker(targetFacility.id); } - // 시설 상세 표시 showDetail(targetFacility); } }, 300); @@ -259,10 +287,9 @@ export default function MapScreen() { return () => window.removeEventListener('resize', onResize); }, [mapInstance]); - // Cleanup on unmount - 즉시 반환하여 페이지 전환 속도 향상 + // Cleanup on unmount useEffect(() => { return () => { - // 백그라운드에서 정리 (페이지 전환을 블로킹하지 않음) if (window.requestIdleCallback) { window.requestIdleCallback(() => { if (currentInfoWindowRef.current) { @@ -274,7 +301,6 @@ export default function MapScreen() { }); } - // 참조는 즉시 초기화 currentInfoWindowRef.current = null; currentLocationOverlayRef.current = null; }; @@ -284,12 +310,10 @@ export default function MapScreen() { useEffect(() => { if (!mapInstance || !currentLocation || !window.kakao) return; - // Remove old overlay if (currentLocationOverlayRef.current) { currentLocationOverlayRef.current.setMap(null); } - // Create and add new overlay const overlay = createCurrentLocationOverlay( window.kakao, currentLocation @@ -306,7 +330,7 @@ export default function MapScreen() { mapInstance.setCenter( new window.kakao.maps.LatLng(location.lat, location.lng) ); - mapInstance.setLevel(3); // Zoom in to level 3 + mapInstance.setLevel(3); } } catch (error) { console.error('Failed to get current location:', error); @@ -314,7 +338,7 @@ export default function MapScreen() { } }; - // facility.id에서 placeId 추출 (place-${placeId} 형식) + // facility.id에서 placeId 추출 const getPlaceIdFromFacilityId = (facilityId) => { if (typeof facilityId === 'string' && facilityId.startsWith('place-')) { return parseInt(facilityId.replace('place-', ''), 10); @@ -322,12 +346,16 @@ export default function MapScreen() { return null; }; + // 북마크 토글 함수 (모달 추가) const toggleBookmarkLocal = async (facilityId) => { try { - // 로그인 체크 + // 로그인 체크 - 모달 표시 const token = localStorage.getItem('token'); if (!token) { - alert('북마크 기능을 사용하려면 로그인이 필요합니다.'); + setModalMessage( + '북마크 기능을 사용하려면\n로그인이 필요합니다.' + ); + setShowModal(true); return; } @@ -347,31 +375,24 @@ export default function MapScreen() { } } catch (error) { console.error('북마크 토글 실패:', error); - // 로그인 에러인 경우 특별 처리 + // 로그인 에러인 경우 모달 표시 if (error.message && error.message.includes('로그인')) { - alert('로그인이 필요합니다.'); + setModalMessage('로그인이 필요합니다.'); + setShowModal(true); } else { - alert(error.message || '북마크 처리에 실패했습니다.'); + setModalMessage(error.message || '북마크 처리에 실패했습니다.'); + setShowModal(true); } } }; + // 모달 닫기 함수 + const handleCloseModal = () => { + setShowModal(false); + setModalMessage(''); + }; + return ( - /** - * 🎨 MapScreen 최상위 컨테이너 레이아웃 설정 - * - * height: calc(100vh - var(--bottom-nav-inset)) - * - 화면 전체 높이(100vh)에서 BottomNavigation 영역(--bottom-nav-inset)을 뺀 높이 - * - 이렇게 하면 지도가 BottomNavigation과 겹치지 않음 - * - --bottom-nav-inset는 index.css에서 정의 (기본값: 96px) - * - * 조정 방법: - * - 지도 영역을 더 크게: index.css에서 --bottom-nav-inset 값을 줄임 - * - 지도 영역을 더 작게: index.css에서 --bottom-nav-inset 값을 늘림 - * - * relative: 내부의 absolute 요소들(FilterBar, CurrentLocationButton, BottomSheet)의 기준점 - * overflow-hidden: 지도가 컨테이너 밖으로 넘치지 않도록 제한 - */
) : ( <> - {/** - * 🗺️ 카카오 지도 컨테이너 - * - * w-full h-full: 부모 컨테이너의 너비와 높이를 100% 채움 - * - w-full (width: 100%): 좌우 여백 없이 전체 너비 사용 - * - h-full (height: 100%): 상하 여백 없이 전체 높이 사용 - * - * z-0: 다른 UI 요소들(FilterBar, BottomSheet) 아래에 배치 - * - * ⚠️ 주의: absolute inset-0 대신 w-full h-full 사용 - * - absolute inset-0을 사용하면 좌측에 여백이 생김 - * - w-full h-full은 부모의 크기를 그대로 따라감 - */}
- {/* 지도 로딩 인디케이터 */} {!mapLoaded && (
- {/* 회전하는 원형 로더 */}
@@ -455,6 +461,15 @@ export default function MapScreen() { /> )} + + {/* 모달 표시 */} + {showModal && ( + + )} )}
diff --git a/src/components/screens/PointExchangeScreen.jsx b/src/components/screens/PointExchangeScreen.jsx index 80d28e5..2eab753 100644 --- a/src/components/screens/PointExchangeScreen.jsx +++ b/src/components/screens/PointExchangeScreen.jsx @@ -83,7 +83,9 @@ export default function PointExchangeScreen({ onNavigate }) { dispatch(fetchUsedPointLogs()); } catch (error) { console.error('포인트 사용 실패:', error); - alert('구매에 실패했습니다: ' + (error.message || '알 수 없는 오류')); + alert( + '구매에 실패했습니다: ' + (error.message || '알 수 없는 오류') + ); } }; @@ -206,7 +208,7 @@ export default function PointExchangeScreen({ onNavigate }) { console.error('포인트전환 실패:', error); alert( '포인트전환 신청에 실패했습니다: ' + - (error.message || '알 수 없는 오류') + (error.message || '알 수 없는 오류') ); } }; @@ -264,20 +266,22 @@ export default function PointExchangeScreen({ onNavigate }) {
@@ -437,10 +443,11 @@ export default function PointExchangeScreen({ onNavigate }) { ) } disabled={!canAfford} - className={`w-full py-2.5 px-3 rounded-lg text-sm font-bold transition-all ${canAfford + className={`w-full py-2.5 px-3 rounded-lg text-sm font-bold transition-all ${ + canAfford ? 'bg-[#4CAF50] text-white hover:bg-[#45a049] shadow-sm' : 'bg-gray-100 text-gray-400 cursor-not-allowed' - }`} + }`} > {canAfford ? '구매하기' @@ -582,20 +589,48 @@ export default function PointExchangeScreen({ onNavigate }) { />
- {/* 계좌번호 */}
setAccountNumber(e.target.value) } - placeholder="'-' 없이 입력" + placeholder=" '-' 없이 숫자만 입력" className='w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#4CAF50] focus:border-transparent' /> + {/* 숫자만 입력하세요 경고 */} + {accountNumber.length > 0 && + !/^\d+$/.test(accountNumber) && ( +

+ + 계좌번호에는 숫자만 입력해 + 주세요. (예: 1234567890) +

+ )} + {/* 최소 자릿수 경고 */} + {accountNumber.length > 0 && + /^\d+$/.test(accountNumber) && + accountNumber.length < 7 && ( +

+ + 계좌번호는 최소 7자리 이상이어야 + 합니다. (현재:{' '} + {accountNumber.length}자리) +

+ )} + {/* 최대 자릿수 경고 */} + {accountNumber.length > 15 && ( +

+ + 계좌번호는 최대 15자리입니다. (현재:{' '} + {accountNumber.length}자리) +

+ )}
{/* 예금주 */} @@ -609,11 +644,19 @@ export default function PointExchangeScreen({ onNavigate }) { onChange={(e) => setAccountHolder(e.target.value) } - placeholder='예금주명' + placeholder='예금주명 (숫자 입력 불가)' className='w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#4CAF50] focus:border-transparent' /> + {/* 숫자 포함 경고 */} + {accountHolder.length > 0 && + /\d/.test(accountHolder) && ( +

+ + 예금주명에는 숫자를 포함할 수 + 없습니다. +

+ )}
- {/* 신청 버튼 */}
) : usedLogs && - usedLogs.filter((item) => item.pointAmount < 0).length > 0 ? ( + usedLogs.filter((item) => item.pointAmount < 0).length > 0 ? (
{usedLogs .filter((item) => item.pointAmount < 0) // 사용(음수)만 필터링 @@ -708,12 +751,13 @@ export default function PointExchangeScreen({ onNavigate }) {
{isVoucher ? ( @@ -828,7 +872,6 @@ export default function PointExchangeScreen({ onNavigate }) { > 구매하기 -
@@ -842,7 +885,7 @@ export default function PointExchangeScreen({ onNavigate }) { initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} - className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4" + className='fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4' onClick={() => setShowPhoneModal(false)} > e.stopPropagation()} - className="bg-white rounded-3xl p-6 max-w-sm w-full text-center" + className='bg-white rounded-3xl p-6 max-w-sm w-full text-center' > - + -

수령자 정보 입력

-

+

+ 수령자 정보 입력 +

+

기프티콘을 받을 휴대폰 번호를 입력해주세요 📱

setPhoneNumber(e.target.value)} - placeholder="010-1234-5678" - className={`w-full border rounded-xl px-3 py-2 text-center text-gray-700 focus:outline-none focus:ring-2 ${phoneError + placeholder='010-1234-5678' + className={`w-full border rounded-xl px-3 py-2 text-center text-gray-700 focus:outline-none focus:ring-2 ${ + phoneError ? 'border-red-400 focus:ring-red-300' : 'border-gray-300 focus:ring-[#4CAF50]' - }`} + }`} /> {phoneError && ( -

+

번호를 입력해주세요.

)} -

+

※ 입력된 번호는 저장되지 않습니다.

-
+
@@ -909,7 +955,6 @@ export default function PointExchangeScreen({ onNavigate }) { )} - {/* 포인트전환 확인 모달 */} {showTransferModal && ( @@ -969,7 +1014,7 @@ export default function PointExchangeScreen({ onNavigate }) { {Math.ceil( parseInt(transferAmount || 0) * - 1.05 + 1.05 ).toLocaleString()} P @@ -1016,7 +1061,7 @@ export default function PointExchangeScreen({ onNavigate }) { initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} - className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4" + className='fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4' onClick={() => setShowSuccessModal(false)} > e.stopPropagation()} - className="bg-white rounded-3xl p-6 max-w-sm w-full text-center" + className='bg-white rounded-3xl p-6 max-w-sm w-full text-center' > - + -

신청 완료!

+

+ 신청 완료! +

{/* {activeTab === 'gifticon' && (
@@ -1059,8 +1106,7 @@ export default function PointExchangeScreen({ onNavigate }) {
)} */} - -

+

{activeTab === 'gifticon' ? '번호 입력 후 해당 번호로 기프티콘이 발송됩니다. ( 영업일 기준 1~3일 내 )' : '포인트전환 신청이 완료되었습니다. 영업일 기준 1~3일 내 입금됩니다.'} @@ -1068,7 +1114,7 @@ export default function PointExchangeScreen({ onNavigate }) { @@ -1076,7 +1122,6 @@ export default function PointExchangeScreen({ onNavigate }) { )} -

); } diff --git a/src/components/screens/PointHistoryScreen.jsx b/src/components/screens/PointHistoryScreen.jsx index 0ee2610..4b46d42 100644 --- a/src/components/screens/PointHistoryScreen.jsx +++ b/src/components/screens/PointHistoryScreen.jsx @@ -164,7 +164,7 @@ export default function PointHistoryScreen({ onNavigate }) { ) : (
{logs.map((log, index) => { - const isEarned = log.pointAmount > 0; + const isEarned = log.pointAmount >= 0; return (
랭킹
-

- +

+ 랭킹은 매월 1일에 초기화됩니다.

- {/* 내 랭킹 표시 (Top 10 이내면 상단 고정 카드 숨김) - 목적: 상위권에 있을 때 중복 노출을 피하고, 리스트/카드에서 자연스럽게 강조 - 조건: myRank가 1~10이면 상단 카드 숨김, 11위 이상이면 상단 카드 노출 */} @@ -147,7 +146,10 @@ export default function RankingScreen({ onNavigate, onBack, navigation }) {
{ranks[1]?.imageUrl ? ( 2위 프로필
- {ranks[1]?.nickname || '익명'} + {ranks[1]?.nickname || + '익명'}
{( @@ -180,7 +186,8 @@ export default function RankingScreen({ onNavigate, onBack, navigation }) {
탄소{' '} {( - ranks[1]?.carbonSave || 0 + ranks[1]?.carbonSave || + 0 ).toFixed(1)} kg
@@ -189,9 +196,18 @@ export default function RankingScreen({ onNavigate, onBack, navigation }) { {/* 1위 (👑 중심 강조) */}
@@ -202,7 +218,10 @@ export default function RankingScreen({ onNavigate, onBack, navigation }) {
{ranks[0]?.imageUrl ? ( 1위 프로필
- {(ranks[0]?.memberPoint || ranks[0]?.point || 0).toLocaleString()}P + {( + ranks[0]?.memberPoint || + ranks[0]?.point || + 0 + ).toLocaleString()} + P
- 탄소 {(ranks[0]?.carbonSave || 0).toFixed(1)}kg + 탄소{' '} + {( + ranks[0]?.carbonSave || 0 + ).toFixed(1)} + kg
- {/* 3위 */}
@@ -248,7 +278,10 @@ export default function RankingScreen({ onNavigate, onBack, navigation }) {
{ranks[2]?.imageUrl ? ( 3위 프로필
- {ranks[2]?.nickname || '익명'} + {ranks[2]?.nickname || + '익명'}
{( @@ -281,7 +318,8 @@ export default function RankingScreen({ onNavigate, onBack, navigation }) {
탄소{' '} {( - ranks[2]?.carbonSave || 0 + ranks[2]?.carbonSave || + 0 ).toFixed(1)} kg
@@ -322,18 +360,20 @@ export default function RankingScreen({ onNavigate, onBack, navigation }) { transition={{ delay: index * 0.05, }} - className={`bg-white rounded-xl p-4 shadow-sm hover:shadow-md transition-all duration-200 flex items-center justify-between border ${isMe - ? 'border-[#4CAF50]/60' - : 'border-gray-100' - } hover:border-[#4CAF50]/30 group`} + className={`bg-white rounded-xl p-4 shadow-sm hover:shadow-md transition-all duration-200 flex items-center justify-between border ${ + isMe + ? 'border-[3px] border-[#4CAF50]/60' + : 'border-gray-100' + } hover:border-[#4CAF50]/30 group`} >
{/* 순위 배지 */}
{currentRank}
@@ -399,7 +439,8 @@ export default function RankingScreen({ onNavigate, onBack, navigation }) { - 내 항목(isMe)은 경계선 색으로 은은하게 강조 */}
- {currentPoint.toLocaleString()}P + {currentPoint.toLocaleString()} + P
diff --git a/src/components/shop/ShopForm.jsx b/src/components/shop/ShopForm.jsx index 1c88ce3..7af3aba 100644 --- a/src/components/shop/ShopForm.jsx +++ b/src/components/shop/ShopForm.jsx @@ -2,6 +2,7 @@ import React, { useState, useRef } from 'react'; import { addShopVoucher } from '../../util/pointApi'; import { uploadImageToFirebase } from '../../util/imageUpload'; import { Upload, X } from 'lucide-react'; +import MessageModal from '../common/MessageModal'; // 챌린지 타입 키워드 (백엔드 자동 인증 연동용) const VALID_VOUCHER_TYPES = [ @@ -18,7 +19,6 @@ const VALID_VOUCHER_TYPES = [ const ShopForm = () => { const [isLoading, setIsLoading] = useState(false); const [isUploading, setIsUploading] = useState(false); - const [error, setError] = useState(''); const [imageUrl, setImageUrl] = useState(''); const [price, setPrice] = useState(''); const [name, setName] = useState(''); @@ -28,6 +28,9 @@ const ShopForm = () => { const [previewImage, setPreviewImage] = useState(''); const [selectedFile, setSelectedFile] = useState(null); const fileInputRef = useRef(null); + const [modalMessage, setModalMessage] = useState(''); + const [modalType, setModalType] = useState('error'); + const [showModal, setShowModal] = useState(false); // 파일 선택 핸들러 const handleFileSelect = (e) => { @@ -36,18 +39,21 @@ const ShopForm = () => { // 파일 크기 검증 (5MB) if (file.size > 5 * 1024 * 1024) { - setError('이미지 파일은 5MB 이하여야 합니다.'); + setModalMessage('이미지 파일은 5MB 이하여야 합니다.'); + setModalType('error'); + setShowModal(true); return; } // 파일 타입 검증 if (!file.type.startsWith('image/')) { - setError('이미지 파일만 업로드 가능합니다.'); + setModalMessage('이미지 파일만 업로드 가능합니다.'); + setModalType('error'); + setShowModal(true); return; } setSelectedFile(file); - setError(''); // 미리보기 생성 const reader = new FileReader(); @@ -60,24 +66,34 @@ const ShopForm = () => { // 이미지 업로드 핸들러 const handleImageUpload = async () => { if (!selectedFile) { - setError('이미지 파일을 선택해주세요.'); + setModalMessage('이미지 파일을 선택해주세요.'); + setModalType('error'); + setShowModal(true); return; } setIsUploading(true); - setError(''); try { // Firebase Storage에 업로드하고 URL 받기 - const uploadedUrl = await uploadImageToFirebase(selectedFile, 'shop'); - + const uploadedUrl = await uploadImageToFirebase( + selectedFile, + 'shop' + ); + // 받은 URL을 imageUrl state에 저장 (이 URL이 서버로 전달됨!) setImageUrl(uploadedUrl); - - alert('✅ 이미지가 성공적으로 업로드되었습니다!'); + + setModalMessage('이미지가 성공적으로 업로드되었습니다!'); + setModalType('success'); + setShowModal(true); } catch (err) { console.error('이미지 업로드 실패', err); - setError('❌ 이미지 업로드 중 오류가 발생했습니다: ' + err.message); + setModalMessage( + '이미지 업로드 중 오류가 발생했습니다: ' + err.message + ); + setModalType('error'); + setShowModal(true); setSelectedFile(null); setPreviewImage(''); if (fileInputRef.current) { @@ -100,18 +116,23 @@ const ShopForm = () => { const handleSubmit = async (e) => { e.preventDefault(); - setError(''); // 필수 필드 검사 - if (!name || !price) { - setError('상품 이름과 가격은 필수 입력 항목입니다.'); + if (!name || !price || !category || !brand || !imageUrl) { + setModalMessage( + '상품 이름, 가격, 카테고리, 브랜드, 이미지는 필수 입력 항목입니다.' + ); + setModalType('error'); + setShowModal(true); return; } // 가격 검증 (정수로 변환) const priceNum = parseInt(price, 10); if (isNaN(priceNum) || priceNum < 0) { - setError('가격은 0 이상의 정수여야 합니다.'); + setModalMessage('가격은 0 이상의 정수여야 합니다.'); + setModalType('error'); + setShowModal(true); return; } @@ -130,7 +151,9 @@ const ShopForm = () => { const res = await addShopVoucher(shopData); console.log('상품 추가 응답:', res); - alert('✅ 상품이 성공적으로 등록되었습니다!'); + setModalMessage('상품이 성공적으로 등록되었습니다!'); + setModalType('success'); + setShowModal(true); // 폼 초기화 setImageUrl(''); setPrice(''); @@ -146,262 +169,278 @@ const ShopForm = () => { } catch (err) { console.error('상품 추가 실패', err); + let errorMessage = '상품 추가 중 오류가 발생했습니다.'; if (err.message) { - setError(`❌ ${err.message}`); + errorMessage = err.message; } else if (err.response?.status === 401) { - setError('❌ 인증이 필요합니다. 다시 로그인해주세요.'); + errorMessage = '인증이 필요합니다. 다시 로그인해주세요.'; } else if (err.response?.status === 400) { - setError('❌ 입력 형식이 올바르지 않습니다.'); + errorMessage = '입력 형식이 올바르지 않습니다.'; } else if (err.response?.data?.message) { - setError(`❌ ${err.response.data.message}`); - } else { - setError('❌ 상품 추가 중 오류가 발생했습니다.'); + errorMessage = err.response.data.message; } + + setModalMessage(errorMessage); + setModalType('error'); + setShowModal(true); } finally { setIsLoading(false); } }; return ( -
-

- 상품 작성 -

- - {/* 전역 에러 메시지 */} - {error && ( -
- {error} -
+ <> + {showModal && ( + setShowModal(false)} + /> )} +
+

+ 상품 작성 +

+ +
+
+ + setName(e.target.value)} + required + disabled={isLoading} + className='w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-400 disabled:bg-gray-100 disabled:cursor-not-allowed' + placeholder='예: 에코 텀블러' + /> +
- -
- - setName(e.target.value)} - required - disabled={isLoading} - className='w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-400 disabled:bg-gray-100 disabled:cursor-not-allowed' - placeholder='예: 에코 텀블러' - /> -
- -
- - setPrice(e.target.value)} - min='0' - step='1' - required - disabled={isLoading} - className='w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-400 disabled:bg-gray-100 disabled:cursor-not-allowed' - placeholder='1000' - /> -

- 상품의 포인트 가격을 입력하세요. -

-
- - {/* 이미지 업로드 섹션 */} -
- - -
- {/* 파일 선택 */} -
- - -
+
+ + setPrice(e.target.value)} + min='0' + step='1' + required + disabled={isLoading} + className='w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-400 disabled:bg-gray-100 disabled:cursor-not-allowed' + placeholder='1000' + /> +

+ 상품의 포인트 가격을 입력하세요. +

+
- {/* 업로드 버튼 (파일 선택 후) */} - {selectedFile && !imageUrl && ( - - )} - - {/* 이미지 미리보기 */} - {(previewImage || imageUrl) && ( -
- 미리보기 + + +
+ {/* 파일 선택 */} +
+ + +
+ + {/* 업로드 버튼 (파일 선택 후) */} + {selectedFile && !imageUrl && ( + )} + + {/* 이미지 미리보기 */} + {(previewImage || imageUrl) && ( +
+ 미리보기 + +
+ )} + + {/* 업로드 완료 메시지 */} + {imageUrl && + imageUrl.startsWith( + 'https://firebasestorage.googleapis.com' + ) && ( +
+ ✅ Firebase Storage에 업로드 완료 +
+ )} + + {/* 또는 URL 직접 입력 */} +
+
+
+
+
+ + 또는 URL 직접 입력 + +
- )} - - {/* 업로드 완료 메시지 */} - {imageUrl && imageUrl.startsWith('https://firebasestorage.googleapis.com') && ( -
- ✅ Firebase Storage에 업로드 완료 -
- )} - {/* 또는 URL 직접 입력 */} -
-
-
-
-
- 또는 URL 직접 입력 -
+ setImageUrl(e.target.value)} + disabled={isLoading || isUploading} + className='w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-400 disabled:bg-gray-100 disabled:cursor-not-allowed' + placeholder='https://firebasestorage.googleapis.com/... 또는 다른 URL' + />
- setImageUrl(e.target.value)} - disabled={isLoading || isUploading} +

+ 상품 이미지를 업로드하거나 URL을 직접 입력하세요. + (최대 5MB) +

+
+ +
+ + +

+ 상품 카테고리를 입력하세요. +

- -

- 상품 이미지를 업로드하거나 URL을 직접 입력하세요. (최대 5MB, 선택사항) -

-
- -
- - -

- 상품 카테고리를 입력하세요. (선택사항) -

-
- -
- - setBrand(e.target.value)} - disabled={isLoading} - className='w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-400 disabled:bg-gray-100 disabled:cursor-not-allowed' - placeholder='예: GreenBrand' - /> -

- 상품 브랜드를 입력하세요. (선택사항) -

-
- -
-
+ +

+ 인기 상품으로 표시할지 선택하세요. +

+
+ +
+ +
+ +
+ ); }; diff --git a/src/util/imageUpload.js b/src/util/imageUpload.js index 750b9e2..b20cb54 100644 --- a/src/util/imageUpload.js +++ b/src/util/imageUpload.js @@ -4,7 +4,7 @@ import { storage } from '../config/firebase'; /** * 이미지를 Firebase Storage에 업로드하고 URL을 반환합니다. * @param {File} file - 업로드할 이미지 파일 - * @param {string} folder - 저장할 폴더 경로 (예: 'badges', 'challenges') + * @param {string} folder - 저장할 폴더 경로 (예: 'badges', 'shop') * @returns {Promise} 업로드된 이미지의 다운로드 URL */ export async function uploadImageToFirebase(file, folder = 'badges') { @@ -13,16 +13,16 @@ export async function uploadImageToFirebase(file, folder = 'badges') { const timestamp = Date.now(); const randomString = Math.random().toString(36).substring(2, 9); const fileName = `${folder}/${timestamp}_${randomString}_${file.name}`; - + // Storage 참조 생성 const storageRef = ref(storage, fileName); - + // 파일 업로드 const snapshot = await uploadBytes(storageRef, file); - + // 업로드된 파일의 다운로드 URL 가져오기 const downloadURL = await getDownloadURL(snapshot.ref); - + console.log('✅ 이미지 업로드 성공:', downloadURL); return downloadURL; } catch (error) { @@ -30,4 +30,3 @@ export async function uploadImageToFirebase(file, folder = 'badges') { throw new Error('이미지 업로드에 실패했습니다: ' + error.message); } } -