diff --git a/app/create-post/page.tsx b/app/create-post/page.tsx index 006b523..a0ddf81 100644 --- a/app/create-post/page.tsx +++ b/app/create-post/page.tsx @@ -106,6 +106,100 @@ const Toast = ({ message, type, onClose }: { message: string; type: 'success' | ) +// 지역 매핑 함수 +const getRegionFromAddress = (address: string): string => { + const addressLower = address.toLowerCase(); + + // 1단계: 도/광역시명이 직접 포함된 경우 우선 매칭 + const primaryMapping = [ + { keywords: ['서울특별시', '서울시', '서울'], value: 'SEOUL' }, + { keywords: ['경기도', '경기'], value: 'GYEONGGI' }, + { keywords: ['강원도', '강원특별자치도', '강원'], value: 'GANGWON' }, + { keywords: ['대전광역시', '대전시'], value: 'DAEJEON' }, + { keywords: ['대구광역시', '대구시'], value: 'DAEGU' }, + { keywords: ['인천광역시', '인천시'], value: 'INCHEON' }, + { keywords: ['광주광역시', '광주시'], value: 'GWANGJU' }, + { keywords: ['울산광역시', '울산시'], value: 'ULSAN' }, + { keywords: ['부산광역시', '부산시'], value: 'BUSAN' }, + { keywords: ['세종특별자치시', '세종시'], value: 'SEJONG' }, + { keywords: ['충청남도', '충남'], value: 'CHUNGNAM' }, + { keywords: ['충청북도', '충북'], value: 'CHUNGBUK' }, + { keywords: ['전라북도', '전북'], value: 'JEONBUK' }, + { keywords: ['전라남도', '전남'], value: 'JEONNAM' }, + { keywords: ['경상북도', '경북'], value: 'GYEONGBUK' }, + { keywords: ['경상남도', '경남'], value: 'GYEONGNAM' }, + { keywords: ['제주특별자치도', '제주도'], value: 'JEJU' }, + ]; + + // 1단계 매칭 시도 + for (const region of primaryMapping) { + for (const keyword of region.keywords) { + if (addressLower.includes(keyword)) { + return region.value; + } + } + } + + // 2단계: 고유한 시/군명으로 매칭 (중복되지 않는 것들만) + const uniqueCityMapping = [ + // 경기도 고유 시/군 + { keywords: ['수원시', '성남시', '고양시', '용인시', '부천시', '안산시', '안양시', '남양주시', '화성시', '평택시', '의정부시', '시흥시', '파주시', '광명시', '김포시', '군포시', '이천시', '양주시', '오산시', '구리시', '안성시', '포천시', '의왕시', '하남시', '여주시', '여주군', '양평군', '동두천시', '과천시', '가평군', '연천군'], value: 'GYEONGGI' }, + + // 강원도 고유 시/군 + { keywords: ['춘천시', '원주시', '강릉시', '동해시', '태백시', '속초시', '삼척시', '홍천군', '횡성군', '영월군', '평창군', '정선군', '철원군', '화천군', '양구군', '인제군', '고성군', '양양군'], value: 'GANGWON' }, + + // 충청남도 고유 시/군 + { keywords: ['천안시', '공주시', '보령시', '아산시', '서산시', '논산시', '계룡시', '당진시', '금산군', '부여군', '서천군', '청양군', '홍성군', '예산군', '태안군'], value: 'CHUNGNAM' }, + + // 충청북도 고유 시/군 + { keywords: ['청주시', '충주시', '제천시', '보은군', '옥천군', '영동군', '증평군', '진천군', '괴산군', '음성군', '단양군'], value: 'CHUNGBUK' }, + + // 전라북도 고유 시/군 + { keywords: ['전주시', '군산시', '익산시', '정읍시', '남원시', '김제시', '완주군', '진안군', '무주군', '장수군', '임실군', '순창군', '고창군', '부안군'], value: 'JEONBUK' }, + + // 전라남도 고유 시/군 + { keywords: ['목포시', '여수시', '순천시', '나주시', '광양시', '담양군', '곡성군', '구례군', '고흥군', '보성군', '화순군', '장흥군', '강진군', '해남군', '영암군', '무안군', '함평군', '영광군', '장성군', '완도군', '진도군', '신안군'], value: 'JEONNAM' }, + + // 경상북도 고유 시/군 + { keywords: ['포항시', '경주시', '김천시', '안동시', '구미시', '영주시', '영천시', '상주시', '문경시', '경산시', '군위군', '의성군', '청송군', '영양군', '영덕군', '청도군', '고령군', '성주군', '칠곡군', '예천군', '봉화군', '울진군', '울릉군'], value: 'GYEONGBUK' }, + + // 경상남도 고유 시/군 + { keywords: ['창원시', '진주시', '통영시', '사천시', '김해시', '밀양시', '거제시', '양산시', '의령군', '함안군', '창녕군', '남해군', '하동군', '산청군', '함양군', '거창군', '합천군'], value: 'GYEONGNAM' }, + + // 제주도 고유 시 + { keywords: ['제주시', '서귀포시'], value: 'JEJU' }, + ]; + + // 2단계 매칭 시도 + for (const region of uniqueCityMapping) { + for (const keyword of region.keywords) { + if (addressLower.includes(keyword)) { + return region.value; + } + } + } + + // 3단계: 특별한 경우 처리 (대전, 대구, 광주의 경우 시명만으로도 매칭) + if (addressLower.includes('대전')) return 'DAEJEON'; + if (addressLower.includes('대구')) return 'DAEGU'; + if (addressLower.includes('광주')) return 'GWANGJU'; + if (addressLower.includes('울산')) return 'ULSAN'; + if (addressLower.includes('부산')) return 'BUSAN'; + if (addressLower.includes('인천')) return 'INCHEON'; + if (addressLower.includes('세종')) return 'SEJONG'; + if (addressLower.includes('제주')) return 'JEJU'; + + return ''; +}; + +const cleanAddress = (address: string): string => { + return address + .replace(/대한민국\s*/, '') + .replace(/Republic of Korea\s*/, '') + .replace(/South Korea\s*/, '') + .trim(); +}; + export default function CreatePostPage() { const router = useRouter() const [formData, setFormData] = useState({ @@ -135,7 +229,7 @@ export default function CreatePostPage() { const locationInputRef = useRef(null) const predictionsRef = useRef(null) const GOOGLE_PLACES_API_KEY = process.env.NEXT_PUBLIC_GOOGLE_PLACES_API_KEY || "" - + // Google Places Autocomplete Service 초기화 useEffect(() => { const loadGoogleMapsScript = () => { @@ -157,7 +251,6 @@ export default function CreatePostPage() { loadGoogleMapsScript().catch(console.error) }, []) - // Google Places Autocomplete Service를 사용한 장소 예측 const fetchPlacePredictions = async (input: string) => { if (!input.trim() || input.length < 1) { setPredictions([]) @@ -219,7 +312,7 @@ export default function CreatePostPage() { setPredictions([]) setShowPredictions(false) } - }, 200) // 200ms로 줄여서 더 빠른 반응 + }, 200) return () => clearTimeout(timeoutId) }, [formData.location]) @@ -241,10 +334,17 @@ export default function CreatePostPage() { return () => document.removeEventListener('mousedown', handleClickOutside) }, []) - // 예측 결과 선택 처리 const handlePredictionSelect = (prediction: PlacePrediction) => { - // 주요 장소명만 설정 (주소는 제외) - setFormData(prev => ({ ...prev, location: prediction.structured_formatting.main_text })) + + const cleanedAddress = cleanAddress(prediction.description); + const detectedRegion = getRegionFromAddress(prediction.description); + + setFormData(prev => ({ + ...prev, + location: cleanedAddress, + town: detectedRegion + })); + setShowPredictions(false) setPredictions([]) } @@ -475,65 +575,6 @@ export default function CreatePostPage() { -
- -
- opt.value === formData.town)?.label || ""} - readOnly - className="h-14 text-lg border-2 border-gray-200 rounded-2xl focus:border-black focus:ring-0 bg-gray-50 pr-14 cursor-pointer" - onClick={() => settownModalOpen(true)} - required - /> - -
- - {/* 지역 선택 모달 */} - {townModalOpen && ( -
-
-

지역 선택

-
- {townOptions.map((option) => ( - - ))} -
- -
-
- )} -
- - {/* Google Places API 자동완성이 적용된 상세 위치 입력 */}
+ {/* 지역 선택 (자동으로 설정됨) */} +
+ +
+ opt.value === formData.town)?.label || ""} + readOnly + className="h-14 text-lg border-2 border-gray-200 rounded-2xl focus:border-black focus:ring-0 bg-gray-100 pr-14 cursor-not-allowed" + required + /> + +
+ +
+
-
+
+) + export default function ApplicationsPage() { const router = useRouter() const [activeTab, setActiveTab] = useState("all") @@ -50,29 +73,49 @@ export default function ApplicationsPage() { const [mounted, setMounted] = useState(false) const [modalOpen, setModalOpen] = useState(false); const [selectedPostId, setSelectedPostId] = useState(null); + const [toasts, setToasts] = useState([]) + const [cancelingPostId, setCancelingPostId] = useState(null) const getAuthToken = () => { if (typeof window === 'undefined') return null return localStorage.getItem("auth_token") || localStorage.getItem("accessToken") } - const makeAuthenticatedRequest = async (url: string) => { + // 토스트 메시지 추가 + const addToast = (message: string, type: 'success' | 'error') => { + const id = Date.now() + setToasts(prev => [...prev, { id, message, type }]) + + setTimeout(() => { + setToasts(prev => prev.filter(toast => toast.id !== id)) + }, 3000) + } + + const removeToast = (id: number) => { + setToasts(prev => prev.filter(toast => toast.id !== id)) + } + + const makeAuthenticatedRequest = async (url: string, options: RequestInit = {}) => { const token = getAuthToken() if (!token) throw new Error("인증 토큰이 없습니다.") const response = await fetch(url, { + ...options, headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', + ...options.headers, }, }) if (response.status === 403) { await new Promise(resolve => setTimeout(resolve, 500)) return fetch(url, { + ...options, headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', + ...options.headers, }, }) } @@ -121,6 +164,73 @@ export default function ApplicationsPage() { } } + // 참가신청 취소 + const handleCancelApplication = async (postId: number, event: React.MouseEvent) => { + event.stopPropagation() + + try { + setCancelingPostId(postId) + + const response = await makeAuthenticatedRequest(`${API_BASE_URL}/posts/${postId}/apply`, { + method: 'DELETE' + }) + + if (response.ok) { + let data = null + const contentType = response.headers.get('content-type') + + if (contentType && contentType.includes('application/json')) { + try { + data = await response.json() + } catch (jsonError) { + data = { message: '참가 신청이 취소되었습니다!' } + } + } else { + data = { message: '참가 신청이 취소되었습니다!' } + } + + addToast(data.message || '참가 신청이 취소되었습니다!', 'success') + + // 취소 후 목록 다시 가져오기 + await fetchApplications() + } else { + let errorData = null + const contentType = response.headers.get('content-type') + + try { + if (contentType && contentType.includes('application/json')) { + errorData = await response.json() + } else { + const textResponse = await response.text() + errorData = { message: textResponse || '요청 처리 중 오류가 발생했습니다.' } + } + } catch (parseError) { + errorData = { message: '요청 처리 중 오류가 발생했습니다.' } + } + + if (response.status === 400 && errorData?.code === "PARTICIPATION400") { + addToast(errorData.message, 'error') + } else if (response.status === 401) { + router.push('/login') + } else { + const errorMessage = (errorData?.message && errorData.message !== '요청 처리 중 오류가 발생했습니다.') + ? errorData.message + : '참가 신청 취소에 실패했습니다.' + addToast(errorMessage, 'error') + } + } + } catch (error) { + if (error instanceof Error && error.message.includes('인증')) { + router.push('/login') + } else { + const errorMessage = error instanceof Error ? error.message : '참가 신청 취소에 실패했습니다.' + addToast(errorMessage, 'error') + } + } finally { + setCancelingPostId(null) + } + } + useEffect(() => { setMounted(true) }, []) @@ -197,6 +307,16 @@ export default function ApplicationsPage() { return (
+ {/* 토스트 메시지들 */} + {toasts.map((toast) => ( + removeToast(toast.id)} + /> + ))} + {/* Header */}
@@ -279,55 +399,71 @@ export default function ApplicationsPage() {
{filteredApplications.map((application) => ( - // - // 모달 추가 부분 283-290 - { - setSelectedPostId(application.id) - setModalOpen(true) - }} - > - -
-
- - {application.postStatus} - -
- - {application.status} + { + setSelectedPostId(application.id) + setModalOpen(true) + }} + > + +
+
+ + {application.postStatus}
+ + {application.status} + +
-

{application.title}

- -
-
- - {application.location} -
-
- - {application.time} -
-
- - {application.participants} -
+

{application.title}

+ +
+
+ + {application.location} +
+
+ + {application.time}
+
+ + {application.participants} +
+
-
-
-

{application.cost}

-
+
+
+

{application.cost}

- - - // + + {/* 신청취소 버튼 - 거절된 상태가 아닐 때만 표시 */} + {application.status !== "거절" && ( + + )} +
+ + ))} - {/* 모달 추가 부분 330-339 */} + + {/* 모달 */} {modalOpen && selectedPostId !== null && ( { setModalOpen(false); setSelectedPostId(null); + // 모달 닫힐 때 목록 새로고침 + fetchApplications(); }} /> )} @@ -359,4 +497,4 @@ export default function ApplicationsPage() {
) -} \ No newline at end of file +} diff --git a/app/mypage/page.tsx b/app/mypage/page.tsx index d53c39c..54a5da8 100644 --- a/app/mypage/page.tsx +++ b/app/mypage/page.tsx @@ -241,7 +241,7 @@ export default function MyPage() { localStorage.removeItem('auth_token') sessionStorage.removeItem('auth_token') } - router.push("/login") + window.location.replace("/") } catch (error) { console.error("Logout error:", error) } diff --git a/app/page.tsx b/app/page.tsx index 267a8d5..6c95ff9 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Badge } from "@/components/ui/badge" import { Card, CardContent } from "@/components/ui/card" -import { Search, MapPin, Clock, Users, Bell, User, Heart, Calendar, List, ChevronDown, Plus, Filter, ArrowRight, Play, Star, Trophy, Target } from "lucide-react" +import { Search, MapPin, Clock, Users, Bell, User, Heart, Calendar, List, ChevronDown, Plus, Filter, ArrowRight, Play, Star, Trophy, Target, Eye } from "lucide-react" import Link from "next/link" import { useRouter } from "next/navigation" import CalendarView from "@/components/calendar-view" @@ -32,7 +32,6 @@ const genderMap = { "여자": "FEMALE", }; - function formatTimeToKorean12Hour(dateString: string) { if (!dateString) return ""; let fixedDateString = dateString.replace(" ", "T"); @@ -59,7 +58,7 @@ const getAuthToken = () => localStorage.getItem("auth_token"); export default function MainPage() { const router = useRouter(); - const [sortType, setSortType] = useState("DATE") + const [sortBy, setSortBy] = useState("recent") const [selectedSport, setSelectedSport] = useState("전체") const [searchQuery, setSearchQuery] = useState("") const [viewMode, setViewMode] = useState<"list" | "calendar">("list"); @@ -76,7 +75,7 @@ export default function MainPage() { const [posts, setPosts] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState("") - + const [modalOpen, setModalOpen] = useState(false); const [selectedPostId, setSelectedPostId] = useState(null); @@ -95,6 +94,12 @@ export default function MainPage() { router.push('/create-post'); }; + + const handleRefresh = async () => { + await fetchPosts(); + await fetchFavorites(); + }; + useEffect(() => { setSelectedSport("전체"); setSelectedRegion("모든 지역"); @@ -118,13 +123,8 @@ export default function MainPage() { }, []); useEffect(() => { - apiClient.getPosts() - .then(response => setPosts(response.posts)) // posts 배열만 setPosts에 전달 - .catch(error => { - console.error(error); - setPosts([]); - }); -}, []); + apiClient.getPosts().then(setPosts); + }, []); useEffect(() => { const fetchMyFollows = async () => { @@ -141,12 +141,7 @@ export default function MainPage() { useEffect(() => { fetchPosts() fetchFavorites() - }, [selectedSport, sortType, searchQuery, selectedRegion, selectedGender, selectedDate, page]) - - useEffect(() => { - setPage(0) // <-- 추가: 필터/정렬 바뀌면 1페이지로 - }, [selectedSport, sortType, searchQuery, selectedRegion, selectedGender, selectedDate]) - + }, [selectedSport, sortBy, searchQuery, selectedRegion, selectedGender, selectedDate]) const fetchPosts = async () => { try { @@ -154,28 +149,13 @@ export default function MainPage() { setError("") const params = { sports: selectedSport !== "전체" ? selectedSport : undefined, - sortType: sortType, + sortBy, search: searchQuery || undefined, gender: genderMap[selectedGender as keyof typeof genderMap], date: selectedDate || undefined, - page, - size, - } - const res = await apiClient.getPosts(params); - console.log('API response:', res); - // 서버가 { posts, page, size, totalElements, totalPages } 형태로 응답하면 posts 추출 - if (res && typeof res === "object" && Array.isArray((res as any).posts)) { - setPosts((res).posts); - // setPage(res.page); - setTotalElements(res.totalElements); - setTotalPages(res.totalPages); - // (옵션) 서버 페이지 정보를 사용하려면 setPage((res as any).page || 0) 등으로 처리 - } else if (Array.isArray(res)) { - // 기존 방식: 배열 바로 사용 - setPosts(res); - } else { - setPosts([]); } + const posts = await apiClient.getPosts(params); + setPosts(posts); } catch (error) { console.error("Failed to fetch posts:", error) setError("게시글을 불러오는데 실패했습니다.") @@ -185,12 +165,6 @@ export default function MainPage() { } } - const handlePageChange = (newPage: number) => { - if (newPage >= 0 && newPage < totalPages) { - setPage(newPage); - } -}; - useEffect(() => { console.log(posts); if (posts.length > 0) { @@ -232,41 +206,43 @@ export default function MainPage() { const now = new Date(); const myMainRegion = extractMainRegion(myRegion); + // tempRegion이 selectedRegion으로 + const filteredPosts = posts.filter(post => { + // 1. 지역 필터 + const regionMatch = selectedRegion === "모든 지역" + ? true + : selectedRegion === "내 지역" + ? extractMainRegion(post.town) === myMainRegion + : post.town === selectedRegion; + + if (!regionMatch) return false; + + // 2. 현재 시각 이후 모집글만 (항상 적용) + if (post.date) { + const postDateTime = new Date(post.date.replace(" ", "T")); + // 현재 시각 이후만 남김 + if (postDateTime <= now) return false; + } - // NEW: 보정 - 필터 변경 등으로 현재 page가 초과하면 마지막 페이지로 이동 - useEffect(() => { - if (page >= totalPages) { - setPage(Math.max(0, totalPages - 1)); + // 3. 특정 날짜가 선택된 경우 해당 날짜만 필터링 + if (selectedDate) { + const postDateStr = post.date?.split("T")[0]; + if (postDateStr !== selectedDate) return false; } - - }, [totalPages, page]); - // NEW: 현재 페이지에 해당하는 slice - const fromIndex = page * size; - const toIndex = Math.min(fromIndex + size, totalElements); - // const pagedPosts = sortedPosts.slice(fromIndex, toIndex); - const pagedPosts = posts; + // selectedDate가 없으면 모두 통과 + return true; + }); - - console.log('Current page:', page); - console.log('fromIndex, toIndex:', fromIndex, toIndex); - console.log('pagedPosts:', pagedPosts); - - // NEW: 페이지 번호 창(최대 5개) 계산 헬퍼 - const getPageRange = (current: number, last: number, maxShown = 5) => { - const half = Math.floor(maxShown / 2); - let start = Math.max(0, current - half); - let end = Math.min(last - 1, start + maxShown - 1); - if (end - start + 1 < maxShown) { - start = Math.max(0, end - maxShown + 1); + const sortedPosts = (() => { + if (sortBy === "popular") { + return [...filteredPosts].sort((a, b) => (b.viewCount || 0) - (a.viewCount || 0)); } - const range: number[] = []; - for (let i = start; i <= end; i++) range.push(i); - return range; - } - const pageRange = getPageRange(page, totalPages, 5); - - + if (sortBy === "recent") { + return [...filteredPosts].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + } + return filteredPosts; + })(); return (
@@ -408,8 +384,6 @@ export default function MainPage() {
- -
@@ -585,7 +559,7 @@ export default function MainPage() { {selectedDate ? `${selectedDate} 모집글` : "모집글 목록"}

- 총 {posts.length}개의 모집글 + 총 {filteredPosts.length}개의 모집글

@@ -601,9 +575,19 @@ export default function MainPage() { )} +
)} - {!loading && !error && posts.length === 0 && ( + {!loading && !error && filteredPosts.length === 0 && (
@@ -663,87 +647,88 @@ export default function MainPage() {
)} - {!loading && !error && posts.length > 0 && ( - <> -
- {pagedPosts.map((post) => ( - - -
-
- - {post.sports} - - - {post.status} - -
- -
- -

- {post.title} -

- -
-
-
- + {post.status} + + {(post.viewCount ?? 0) > 0 && ( +
+ + + {post.viewCount}명 조회중 +
- {post.town} + )} +
+ +
+ +

+ {post.title} +

+ +
+
+
+
-
-
- -
- - {post.date?.split("T")[0]} {post.date && formatTimeToKorean12Hour(post.date)} - + {post.town} +
+
+
+
-
-
- -
- - {post.currentPeople}/{post.maxPeople}명 참여 - + + {post.date?.split("T")[0]} {post.date && formatTimeToKorean12Hour(post.date)} + +
+
+
+
+ + {post.currentPeople}/{post.maxPeople}명 참여 +
- -
-
-
- {post.participants?.slice(0, 3).map((participant, idx) => ( -
- {participant.nickName?.charAt(0) || "?"} -
- ))} - {post.currentPeople > 3 && ( -
- +{post.currentPeople - 3} -
- )} -
+
+ +
+
+
+ {post.participants?.slice(0, 3).map((participant, idx) => ( +
+ {participant.nickName?.charAt(0) || "?"} +
+ ))}
@@ -765,13 +750,23 @@ export default function MainPage() { 상세보기 -
+
- - - ))} - {modalOpen && selectedPostId !== null && ( +
+ + + ))} + {modalOpen && selectedPostId !== null && (
- +
+
+

+ 운동 메이트를 모집해보세요 +

+ +
+
) -} \ No newline at end of file +} diff --git a/app/signup/page.tsx b/app/signup/page.tsx index c9a519c..00aba93 100644 --- a/app/signup/page.tsx +++ b/app/signup/page.tsx @@ -100,6 +100,11 @@ export default function SignupPage() { window.location.href = kakaoAuthUrl; }; + const validateNickname = (nickname: string) => /^[가-힣a-zA-Z0-9]{2,10}$/.test(nickname) + + const isInvalidNickname = + !!formData.nickname && !validateNickname(formData.nickname); + const handleSportToggle = (sportId: string) => { setFormData((prev) => ({ ...prev, @@ -205,6 +210,18 @@ export default function SignupPage() { } } + if (isInvalidNickname) { + setError("닉네임을 확인하세요"); + setLoading(false); + return; + } + + if (nicknameChecked !== true) { + setError("닉네임 중복 확인을 해주세요"); + setLoading(false); + return; + } + if (!formData.gender) { setError("성별을 선택해주세요.") setLoading(false) @@ -223,6 +240,12 @@ export default function SignupPage() { return } + if (!formData.sports){ + setError("선호 종목을 선택해주세요.") + setLoading(false) + return + } + try { const response = await apiClient.signup({ email: formData.email, @@ -407,17 +430,42 @@ export default function SignupPage() { className="flex-1" required /> -
- {nicknameChecked === true && ( + {formData.nickname && + !validateNickname(formData.nickname) && + ( +
+
+

+ 2-10자의 한글, 영문, 숫자만 사용할 수 있어요 +

+
+ )} + + {!isInvalidNickname && nicknameChecked === true && (

✓ 사용 가능한 닉네임입니다.

)} - {nicknameChecked === false && ( + {!isInvalidNickname && nicknameChecked === false && (

이미 사용 중인 닉네임입니다.

)} - {nicknameError && ( + {!isInvalidNickname && nicknameError && (

{nicknameError}

)}
diff --git a/components/post-detail.tsx b/components/post-detail.tsx index 4c74859..25e156f 100644 --- a/components/post-detail.tsx +++ b/components/post-detail.tsx @@ -25,6 +25,18 @@ interface PostData { userEmail?: string } +interface MyApplication { + postId: number + title: string + date: string + currentPeople: number + maxPeople: number + location: string + cost: number + status: "PENDING" | "APPROVED" | "REJECTED" + postStatus: "OPEN" | "CLOSED" +} + interface ToastMessage { id: number message: string @@ -62,6 +74,8 @@ export default function EventDetailModal({ postId, isOpen, onClose, onLogin }: E const [isNotifying, setIsNotifying] = useState(false) const [isFavorited, setIsFavorited] = useState(false) const [isJoined, setIsJoined] = useState(false) + const [myApplications, setMyApplications] = useState([]) + const [currentApplication, setCurrentApplication] = useState(null) const [toasts, setToasts] = useState([]) const [currentUserId, setCurrentUserId] = useState(null) const [currentUserEmail, setCurrentUserEmail] = useState(null) @@ -155,6 +169,29 @@ export default function EventDetailModal({ postId, isOpen, onClose, onLogin }: E }) } + // 내가 신청한 모집글 목록 가져오기 + const fetchMyApplications = async () => { + if (!isLoggedIn) return + + try { + const response = await makeAuthenticatedRequest(`${API_BASE_URL}/posts/apply`) + + if (response.ok) { + const data = await response.json() + if (data && data.code === "POST208" && data.data) { + setMyApplications(data.data) + + // 현재 게시글에 대한 신청 상태 찾기 + const application = data.data.find((app: MyApplication) => app.postId === postId) + setCurrentApplication(application || null) + setIsJoined(!!application) + } + } + } catch (error) { + console.error("내 신청 목록 가져오기 실패:", error) + } + } + // 로그인 상태 확인 useEffect(() => { const token = getAuthToken() @@ -166,61 +203,69 @@ export default function EventDetailModal({ postId, isOpen, onClose, onLogin }: E } }, []) - // 포스트 데이터 로딩 + // 내 신청 목록 가져오기 useEffect(() => { - const fetchPost = async () => { - if (!postId || !isOpen) return + if (isLoggedIn && isOpen) { + fetchMyApplications() + } + }, [isLoggedIn, isOpen, postId]) - try { - setLoading(true) - setError("") - - let response - const token = getAuthToken() - - // 로그인한 사용자는 인증된 요청, 비로그인은 게스트 요청 - if (token) { - try { - response = await makeAuthenticatedRequest(`${API_BASE_URL}/posts/${postId}`) - } catch (authError) { - // 인증 실패 시 게스트 요청으로 대체 - response = await makeGuestRequest(`${API_BASE_URL}/posts/${postId}`) - } - } else { + // 포스트 데이터 가져오기 함수 + const fetchPostData = async () => { + if (!postId) return + + try { + setLoading(true) + setError("") + + let response + const token = getAuthToken() + + // 로그인한 사용자는 인증된 요청, 비로그인은 게스트 요청 + if (token) { + try { + response = await makeAuthenticatedRequest(`${API_BASE_URL}/posts/${postId}`) + } catch (authError) { + // 인증 실패 시 게스트 요청으로 대체 response = await makeGuestRequest(`${API_BASE_URL}/posts/${postId}`) } + } else { + response = await makeGuestRequest(`${API_BASE_URL}/posts/${postId}`) + } + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + const data = await response.json() + + if (data && data.code === "POST201" && data.data) { + setPost(data.data) - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`) - } - - const data = await response.json() - - if (data && data.code === "POST201" && data.data) { - setPost(data.data) + if (isLoggedIn) { + setIsFavorited(data.data.bookmarked || false) - if (isLoggedIn) { - setIsFavorited(data.data.bookmarked || false) - - // 작성자 확인 - JWT 이메일과 모집글 작성자 이메일 비교 - const userEmail = getEmailFromToken() - if (userEmail && data.data.userEmail) { - setIsAuthor(userEmail === data.data.userEmail) - } + // 작성자 확인 - JWT 이메일과 모집글 작성자 이메일 비교 + const userEmail = getEmailFromToken() + if (userEmail && data.data.userEmail) { + setIsAuthor(userEmail === data.data.userEmail) } - } else { - throw new Error("게시글을 찾을 수 없습니다.") } - } catch (error) { - console.error("API 호출 실패:", error) - setError("게시글을 불러오는데 실패했습니다.") - } finally { - setLoading(false) + } else { + throw new Error("게시글을 찾을 수 없습니다.") } + } catch (error) { + console.error("API 호출 실패:", error) + setError("게시글을 불러오는데 실패했습니다.") + } finally { + setLoading(false) } + } + // 포스트 데이터 로딩 + useEffect(() => { if (isOpen && postId) { - fetchPost() + fetchPostData() } }, [postId, isOpen, isLoggedIn]) @@ -228,30 +273,12 @@ export default function EventDetailModal({ postId, isOpen, onClose, onLogin }: E setPost(null) setError("") setToasts([]) + setMyApplications([]) + setCurrentApplication(null) + setIsJoined(false) onClose() } - // const handleShare = async () => { - // if (navigator.share) { - // try { - // await navigator.share({ - // title: post?.title || '스포츠 메이트 모집', - // text: `${formatTimeToKorean12Hour(post?.date || '')} ${getSportName(post?.sports || '')} 모집`, - // url: window.location.href - // }) - // } catch (error) { - // console.error('공유 실패:', error) - // } - // } else { - // try { - // await navigator.clipboard.writeText(window.location.href) - // addToast('링크가 복사되었습니다!', 'success') - // } catch (error) { - // console.error('클립보드 복사 실패:', error) - // } - // } - // } - const toggleFavorite = async () => { if (!isLoggedIn) { handleClose() @@ -366,6 +393,12 @@ export default function EventDetailModal({ postId, isOpen, onClose, onLogin }: E setIsJoined(true) addToast(data.message || '참가 신청이 완료되었습니다!', 'success') + + // 신청 후 내 신청 목록 다시 가져오기 + await fetchMyApplications() + + // 게시글 정보 다시 가져오기 (인원수 변경 반영) + await fetchPostData() } else { let errorData = null const contentType = response.headers.get('content-type') @@ -414,6 +447,86 @@ export default function EventDetailModal({ postId, isOpen, onClose, onLogin }: E } } + const handleCancelApplication = async () => { + if (!isLoggedIn) { + handleClose() + if (onLogin) onLogin() + return + } + + const token = getAuthToken() + if (!token) { + handleClose() + if (onLogin) onLogin() + return + } + + try { + const response = await makeAuthenticatedRequest(`${API_BASE_URL}/posts/${postId}/apply`, { + method: 'DELETE' + }) + + if (response.ok) { + let data = null + const contentType = response.headers.get('content-type') + + if (contentType && contentType.includes('application/json')) { + try { + data = await response.json() + } catch (jsonError) { + data = { message: '참가 신청이 취소되었습니다!' } + } + } else { + data = { message: '참가 신청이 취소되었습니다!' } + } + + setIsJoined(false) + setCurrentApplication(null) + addToast(data.message || '참가 신청이 취소되었습니다!', 'success') + + // 취소 후 내 신청 목록 다시 가져오기 + await fetchMyApplications() + + // 게시글 정보 다시 가져오기 + await fetchPostData() + } else { + let errorData = null + const contentType = response.headers.get('content-type') + + try { + if (contentType && contentType.includes('application/json')) { + errorData = await response.json() + } else { + const textResponse = await response.text() + errorData = { message: textResponse || '요청 처리 중 오류가 발생했습니다.' } + } + } catch (parseError) { + errorData = { message: '요청 처리 중 오류가 발생했습니다.' } + } + + if (response.status === 400 && errorData?.code === "PARTICIPATION400") { + addToast(errorData.message, 'error') + } else if (response.status === 401) { + handleClose() + if (onLogin) onLogin() + } else { + const errorMessage = (errorData?.message && errorData.message !== '요청 처리 중 오류가 발생했습니다.') + ? errorData.message + : '참가 신청 취소에 실패했습니다.' + addToast(errorMessage, 'error') + } + } + } catch (error) { + if (error instanceof Error && error.message.includes('인증')) { + handleClose() + if (onLogin) onLogin() + } else { + const errorMessage = error instanceof Error ? error.message : '참가 신청 취소에 실패했습니다.' + addToast(errorMessage, 'error') + } + } + } + const formatTimeToKorean12Hour = (dateString: string) => { if (!dateString) return "" const dateObj = new Date(dateString) @@ -486,6 +599,24 @@ export default function EventDetailModal({ postId, isOpen, onClose, onLogin }: E return colorMap[status] || 'bg-gray-500' } + const getApplicationStatusText = (status: string) => { + const statusMap: { [key: string]: string } = { + 'PENDING': '승인 대기', + 'APPROVED': '승인됨', + 'REJECTED': '거절됨' + } + return statusMap[status] || status + } + + const getApplicationStatusColor = (status: string) => { + const colorMap: { [key: string]: string } = { + 'PENDING': 'bg-yellow-500', + 'APPROVED': 'bg-green-500', + 'REJECTED': 'bg-red-500' + } + return colorMap[status] || 'bg-gray-500' + } + if (!isOpen) return null return ( @@ -514,12 +645,6 @@ export default function EventDetailModal({ postId, isOpen, onClose, onLogin }: E MatchFit
- {/* */} + {/* 신청 버튼 - 신청 상태에 따라 다른 버튼 표시 */} + {isLoggedIn && currentApplication ? ( + currentApplication.status === "REJECTED" ? ( +
+ 참가 신청이 거절되었습니다 +
+ ) : ( + + ) + ) : ( + + )}