diff --git a/app/create-post/page.tsx b/app/create-post/page.tsx index a0ddf81..5c8b354 100644 --- a/app/create-post/page.tsx +++ b/app/create-post/page.tsx @@ -48,6 +48,10 @@ const genderOptions = [ { id: "FEMALE", name: "여성만" }, ] +export const MAX_COST = 1_000_000; +export const krFormat = new Intl.NumberFormat("ko-KR"); +export const digitsOnly = (s: string) => s.replace(/[^\d]/g, ""); + // Google Maps API 타입 선언 declare global { interface Window { @@ -228,7 +232,7 @@ export default function CreatePostPage() { const [isLoadingPlaces, setIsLoadingPlaces] = useState(false) const locationInputRef = useRef(null) const predictionsRef = useRef(null) - const GOOGLE_PLACES_API_KEY = process.env.NEXT_PUBLIC_GOOGLE_PLACES_API_KEY || "" + const GOOGLE_PLACES_API_KEY = process.env.NEXT_PUBLIC_GOOGLE_PLACES_API_KEY || "AIzaSyAEdB2-APCc2ml50ipMsoTdtKEGOvT6Flc" // Google Places Autocomplete Service 초기화 useEffect(() => { @@ -363,6 +367,14 @@ export default function CreatePostPage() { setToasts(prev => prev.filter(toast => toast.id !== id)) } + const pad2 = (n: number) => String(n).padStart(2, "0"); + + // 로컬 시간 기준 "YYYY-MM-DD" + const getTodayStr = () => { + const now = new Date(); + return `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())}`; + }; + const handleParticipantChange = (increment: boolean) => { setFormData((prev) => ({ ...prev, @@ -375,11 +387,14 @@ export default function CreatePostPage() { const file = e.target.files?.[0] if (!file) return - // 파일 타입 체크 - if (!file.type.startsWith('image/')) { - addToast("이미지 파일만 업로드 가능합니다.", 'error') - return - } + const allowedTypes = ['image/jpeg', 'image/png'] + + if (!allowedTypes.includes(file.type)) { + addToast("이미지는 JPG, PNG만 가능합니다.", 'error') + + e.currentTarget.value = "" + return + } setSelectedImage(file) setError("") @@ -411,6 +426,43 @@ export default function CreatePostPage() { setError("") setSuccessMessage("") + let hasError = false + if (!formData.title) { + addToast("제목을 입력하세요.", "error") + hasError = true + } + if (!formData.sport) { + addToast("운동 종목을 선택하세요.", "error") + hasError = true + } + if (!formData.location) { + addToast("상세 위치를 입력하세요.", "error") + hasError = true + } + if (hasError) { + setLoading(false) + return + } + + if (!formData.date || !formData.time) { + setLoading(false) + setError("날짜와 시간을 입력해 주세요.") + addToast("날짜와 시간을 입력해 주세요.", "error") + return + } + + // "YYYY-MM-DDTHH:mm(:ss)" → 로컬 기준 Date + const timeStr = formData.time.length === 5 ? formData.time + ":00" : formData.time + const selected = new Date(`${formData.date}T${timeStr}`) + const now = new Date() + // '이후'만 허용 → 같거나 과거면 막기 + if (selected <= now) { + setLoading(false) + setError("현재 시간 이후로 설정해야 됩니다.") + addToast("현재 시간 이후로 설정해야 됩니다.", "error") + return + } + try { const isoDateTime = `${formData.date}T${formData.time}` @@ -441,7 +493,7 @@ export default function CreatePostPage() { } // 토큰 가져오기 - const token = localStorage.getItem('auth_token') + const token = sessionStorage.getItem('auth_token') console.log('사용 중인 토큰:', token ? '토큰 있음' : '토큰 없음') const headers: HeadersInit = {} @@ -546,7 +598,6 @@ export default function CreatePostPage() { value={formData.title} onChange={(e) => setFormData((prev) => ({ ...prev, title: e.target.value }))} className="h-14 text-lg border-2 border-gray-200 rounded-2xl focus:border-black focus:ring-0 bg-gray-50" - required /> @@ -593,7 +644,6 @@ export default function CreatePostPage() { } }} className="h-14 text-lg border-2 border-gray-200 rounded-2xl focus:border-black focus:ring-0 bg-gray-50 pr-12" - required /> @@ -649,7 +699,6 @@ export default function CreatePostPage() { value={townOptions.find(opt => 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 /> @@ -666,9 +715,23 @@ export default function CreatePostPage() { setFormData((prev) => ({ ...prev, date: e.target.value }))} + min={getTodayStr()} + onChange={(e) => { + const today = getTodayStr(); + const selected = e.target.value; + + // 오늘 이전이면 강제로 오늘로 맞추고 에러/토스트 + if (selected < today) { + setError("오늘 이후 날짜만 선택 가능합니다."); + addToast?.("오늘 이후 날짜만 선택 가능합니다.", "error"); + setFormData((prev) => ({ ...prev, date: today })); + return; + } else { + setError(""); + setFormData((prev) => ({ ...prev, date: selected })); + } + }} className="h-14 text-lg border-2 border-gray-200 rounded-2xl focus:border-black focus:ring-0 bg-gray-50" - required />
@@ -678,7 +741,6 @@ export default function CreatePostPage() { value={formData.time} onChange={(e) => setFormData((prev) => ({ ...prev, time: e.target.value }))} className="h-14 text-lg border-2 border-gray-200 rounded-2xl focus:border-black focus:ring-0 bg-gray-50" - required />
@@ -738,15 +800,27 @@ export default function CreatePostPage() {
setFormData((prev) => ({ ...prev, cost: e.target.value }))} - className="h-14 text-lg border-2 border-gray-200 rounded-2xl focus:border-black focus:ring-0 bg-gray-50 pr-12" - /> + value={formData.cost ? krFormat.format(Number(formData.cost)) : ""} + onChange={(e) => { + // 입력값에서 숫자만 추출 + const digits = e.target.value.replace(/[^\d]/g, "") + if (digits === "") { + setFormData(prev => ({ ...prev, cost: "" })) + return + } + // 상한선 적용 + const clamped = Math.min(parseInt(digits, 10), MAX_COST) + // 상태엔 "숫자 문자열"로만 저장 (콤마 없음) + setFormData(prev => ({ ...prev, cost: String(clamped) })) + }} + className="h-14 text-lg border-2 border-gray-200 rounded-2xl focus:border-black focus:ring-0 bg-gray-50 pr-12" + />
- +
diff --git a/app/login/find-email/page.tsx b/app/login/find-email/page.tsx new file mode 100644 index 0000000..af586d7 --- /dev/null +++ b/app/login/find-email/page.tsx @@ -0,0 +1,75 @@ +"use client" + +import { useState } from "react" +import { apiClient } from "@/lib/api-client" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import Link from "next/link" + +export default function FindEmailPage() { + const [nickname, setNickname] = useState("") + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [maskedEmail, setMaskedEmail] = useState(null) + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true); setError(null); setMaskedEmail(null) + try { + const res = await apiClient.findEmail(nickname.trim()) + const email = (res as any)?.data?.email ?? (res as any)?.email + if (!email) { + setError("일치하는 계정이 없습니다.") + } else { + setMaskedEmail(email) // 마스킹된 이메일이 내려옴 + } + } catch (e: any) { + setError(e?.message || "조회 중 오류가 발생했습니다.") + } finally { + setLoading(false) + } + } + + return ( +
+
+

이메일 찾기

+ + {error &&
{error}
} + {maskedEmail && ( +
+ 이메일: {maskedEmail} +
+ )} + +
+
+ + setNickname(e.target.value)} + placeholder="닉네임을 입력하세요" + required + /> +
+ +
+ +
+ 비밀번호가 기억나지 않나요?{" "} + + 비밀번호 재설정 + +
+ +
+ ← 로그인으로 돌아가기 +
+
+
+ ) +} diff --git a/app/login/forgot-password/page.tsx b/app/login/forgot-password/page.tsx new file mode 100644 index 0000000..8298359 --- /dev/null +++ b/app/login/forgot-password/page.tsx @@ -0,0 +1,79 @@ +"use client" + +import { useState } from "react" +import { apiClient } from "@/lib/api-client" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import Link from "next/link" + +export default function ForgotPasswordPage() { + const [email, setEmail] = useState("") + const [loading, setLoading] = useState(false) + const [notice, setNotice] = useState(null) + const [error, setError] = useState(null) + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true); setError(null); setNotice(null) + try { + const res = await apiClient.requestPasswordReset(email.trim()) + + if (res?.code === "USER208") { + setNotice("비밀번호 재설정 링크를 이메일로 보냈습니다. 받은 편지함/스팸함을 확인하세요.") + return + } + + if (res?.code === "USER408") { + setError("가입된 이메일이 아닙니다.") + return + } + + } catch (err: any) { + const code = + err?.code || + err?.response?.data?.code || + err?.data?.code + + if (code === "USRE208") { + setNotice("비밀번호 재설정 링크를 이메일로 보냈습니다. 받은 편지함/스팸함을 확인하세요.") + } else if (code === "USER408") { + setError("가입된 이메일이 아닙니다.") + } + else { + setError(err?.response?.data?.message || err?.message || "비밀번호 재설정 요청 중 오류가 발생했습니다.") + } + } finally { + setLoading(false) + } +} + + + return ( +
+
+

비밀번호 재설정

+ + {error &&
{error}
} + {notice &&
{notice}
} + +
+
+ + setEmail(e.target.value)} placeholder="email@example.com" required /> +
+ +
+ +
+ + ← 로그인으로 돌아가기 + +
+
+
+ ) +} diff --git a/app/login/page.tsx b/app/login/page.tsx index a2bfb1e..4d5a64f 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -40,6 +40,7 @@ export default function LoginPage() { password: formData.password, }) + const token = response?.data?.token; if (response.data?.token && response.code === "USER201") { router.push("/") } else if (response.code === "USER404") { @@ -63,7 +64,8 @@ export default function LoginPage() { console.log('kakaoToken:', kakaoToken); if (kakaoToken) { // 토큰 저장 - localStorage.setItem("auth_token", kakaoToken) + sessionStorage.setItem("auth_token", kakaoToken) + //localStorage.setItem("auth_token", kakaoToken) // 메인페이지로 이동 window.location.replace("/") // router.replace("/")도 가능, 그러나 확실히 새로고침할 땐 window.location 추천 } @@ -71,7 +73,7 @@ export default function LoginPage() { useEffect(() => { - const savedEmail = localStorage.getItem("rememberedEmail") + const savedEmail = sessionStorage.getItem("rememberedEmail") if (savedEmail) { setFormData((prev) => ({ ...prev, email: savedEmail })) setRememberEmail(true) @@ -177,28 +179,40 @@ export default function LoginPage() {
-
- { - const keep = !!checked - setFormData((prev) => ({ ...prev, keepLoggedIn: keep })) - - if (keep) { - localStorage.setItem("rememberedEmail", formData.email) - } else { - localStorage.removeItem("rememberedEmail") - setFormData((prev) => ({ ...prev, email: "" })) - } - }} - className="w-5 h-5 border-2 border-gray-300 rounded data-[state=checked]:bg-blue-500 data-[state=checked]:border-blue-500" - /> - + {/* 로그인 상태 유지 + 오른쪽 링크 */} +
+ {/* 왼쪽: 로그인 상태 유지 */} +
+ { + const keep = !!checked + setFormData((prev) => ({ ...prev, keepLoggedIn: keep })) + + if (keep) { + sessionStorage.setItem("rememberedEmail", formData.email) + } else { + sessionStorage.removeItem("rememberedEmail") + setFormData((prev) => ({ ...prev, email: "" })) + } + }} + className="w-5 h-5 border-2 border-gray-300 rounded data-[state=checked]:bg-blue-500 data-[state=checked]:border-blue-500" + /> + +
+ + {/* 오른쪽: 아이디/비번 찾기 링크 */} +
+ 이메일 찾기 + | + 비밀번호 재설정 +
+ {/* 로그인 버튼 */} +
+ +
    +
  • + {hasLen ? "✓" : "○"} 8자 이상 +
  • +
  • + {hasLetter ? "✓" : "○"} 영문 포함 +
  • +
  • + {hasDigit ? "✓" : "○"} 숫자 포함 +
  • +
  • + {hasSpecial ? "✓" : "○"} 특수문자 포함 +
  • +
+ + + {/* Confirm Password */} +
+ +
+ + setFormData((prev) => ({ ...prev, confirmPassword: e.target.value })) + } + className="pr-10" + required + /> + +
+ + {formData.confirmPassword.length > 0 && ( +

+ {passwordsMatch ? "비밀번호가 일치합니다." : "비밀번호가 일치하지 않습니다."} +

+ )} +
+ + + + )} + +
+ + ← 로그인으로 돌아가기 + +
+ + + ) +} diff --git a/app/mypage/applications/page.tsx b/app/mypage/applications/page.tsx index ba92f3a..aadf008 100644 --- a/app/mypage/applications/page.tsx +++ b/app/mypage/applications/page.tsx @@ -78,7 +78,7 @@ export default function ApplicationsPage() { const getAuthToken = () => { if (typeof window === 'undefined') return null - return localStorage.getItem("auth_token") || localStorage.getItem("accessToken") + return sessionStorage.getItem('auth_token') } // 토스트 메시지 추가 diff --git a/app/mypage/favorites/page.tsx b/app/mypage/favorites/page.tsx index 520c360..7e7db41 100644 --- a/app/mypage/favorites/page.tsx +++ b/app/mypage/favorites/page.tsx @@ -111,7 +111,7 @@ export default function FavoritesPage() { const getToken = () => { if (typeof window === 'undefined') return null - return localStorage.getItem('auth_token') || sessionStorage.getItem('auth_token') + return sessionStorage.getItem('auth_token') } const addToast = (message: string, type: 'success' | 'error') => { diff --git a/app/mypage/my-posts/page.tsx b/app/mypage/my-posts/page.tsx index 90c90f4..ac9626d 100644 --- a/app/mypage/my-posts/page.tsx +++ b/app/mypage/my-posts/page.tsx @@ -111,9 +111,12 @@ function MyPostsContentComponent() { setMyPosts(posts) } + const isPostFull = (post: MyPost) => + post.status === 'CLOSED' || post.currentPeople >= post.maxPeople + const getAuthToken = () => { if (typeof window === 'undefined') return null - return localStorage.getItem("auth_token") + return sessionStorage.getItem('auth_token') } const addToast = (message: string, type: 'success' | 'error') => { @@ -143,7 +146,7 @@ function MyPostsContentComponent() { }) if (response.status === 401 || response.status === 403) { - localStorage.removeItem('auth_token') + sessionStorage.removeItem('auth_token') localStorage.removeItem('accessToken') router.push('/login') throw new Error("인증이 만료되었습니다. 다시 로그인해주세요.") @@ -225,6 +228,7 @@ function MyPostsContentComponent() { case 'REJECTED': return '거절됨' case 'OPEN': return '모집중' case 'CLOSED': return '모집완료' + case 'EXPIRED': return '모집만료' default: return status } } @@ -319,6 +323,13 @@ function MyPostsContentComponent() { } const handleApprove = async (postIndex: number, applicantId: number) => { + const post = myPosts[postIndex] + if (!post) return + if (isPostFull(post)) { + addToast('이미 모집완료 되었습니다.', 'error') + return + } + try { const postId = myPosts[postIndex]?.postId || (postIndex + 1) const result = await manageApplicant(postId, applicantId, 'ACCEPT') @@ -332,19 +343,16 @@ function MyPostsContentComponent() { ) || [] })) - setMyPosts(prev => prev.map((post, index) => { - if (index === postIndex) { - const newCurrentPeople = post.currentPeople + 1 - const newStatus = newCurrentPeople >= post.maxPeople ? 'CLOSED' : post.status - return { - ...post, - currentPeople: newCurrentPeople, - status: newStatus as "OPEN" | "CLOSED" - } + setMyPosts(prev => prev.map((p, i) => { + if (i !== postIndex) return p + const newCurrent = p.currentPeople + 1 + const reachedMax = newCurrent >= p.maxPeople + return { + ...p, + currentPeople: newCurrent, + status: reachedMax ? 'CLOSED' : p.status } - return post })) - } catch (err) { addToast(err instanceof Error ? err.message : '승인에 실패했습니다.', 'error') } @@ -387,6 +395,34 @@ function MyPostsContentComponent() { } } + const handleDelete = async (postId: number) => { + const confirmed = confirm('정말 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.') + if (!confirmed) return + + // 상태 백업 (실패 시 롤백용) + const prev = myPosts + setMyPosts((p) => (p ? p.filter((x) => x.postId !== postId) : p)) + + console.log("모집글 아이디는" + postId); + + try { + const response = await makeAuthenticatedRequest(`${API_BASE_URL}/posts/${postId}`, { method: 'DELETE', credentials: 'include' }) + if (!response.ok) { + const text = await response.text() + throw new Error(text || '삭제 실패') + } + // 성공 메시지 (원하면 toast로 대체) + alert('삭제되었습니다.') + } catch (err) { + console.error('delete error', err) + alert('삭제에 실패했습니다. 다시 시도해주세요.') + // 롤백 + setMyPosts(prev) + } + } + + + if (!mounted || loading) { return (
@@ -442,6 +478,8 @@ function MyPostsContentComponent() { className={ post.status === "OPEN" ? "bg-green-500 text-white" + : post.status === "CLOSED" + ? "bg-red-100 text-red-700" : "bg-gray-500 text-white" } > @@ -456,6 +494,14 @@ function MyPostsContentComponent() { 수정 + + {/* 삭제 버튼 -> handleDelete 호출 */} +

{post.title}

@@ -527,8 +573,11 @@ function MyPostsContentComponent() {
diff --git a/app/mypage/page.tsx b/app/mypage/page.tsx index 54a5da8..326bdf9 100644 --- a/app/mypage/page.tsx +++ b/app/mypage/page.tsx @@ -115,7 +115,7 @@ export default function MyPage() { const getToken = () => { if (typeof window === 'undefined') return null - return localStorage.getItem('auth_token') || sessionStorage.getItem('auth_token') + return sessionStorage.getItem('auth_token') } const makeAuthenticatedRequest = async (url: string, options?: RequestInit) => { diff --git a/app/mypage/profile-edit/page.tsx b/app/mypage/profile-edit/page.tsx index bb1b86b..b905cec 100644 --- a/app/mypage/profile-edit/page.tsx +++ b/app/mypage/profile-edit/page.tsx @@ -42,7 +42,7 @@ export default function ProfileEditPage() { message: "" }) - const getAuthToken = () => localStorage.getItem("auth_token") || localStorage.getItem("accessToken") + const getAuthToken = () => sessionStorage.getItem('auth_token') const makeAuthenticatedRequest = async (url: string, options: RequestInit = {}) => { const token = getAuthToken() diff --git a/app/page.tsx b/app/page.tsx index 8f86a1e..0162a25 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -54,11 +54,11 @@ function extractMainRegion(town: string): string | undefined { return regions.find(region => town.startsWith(region)); } -const getAuthToken = () => localStorage.getItem("auth_token"); +const getAuthToken = () => sessionStorage.getItem("auth_token"); export default function MainPage() { const router = useRouter(); - const [sortBy, setSortBy] = useState("recent") + const [sortType, setSortType] = useState("DATE") const [selectedSport, setSelectedSport] = useState("전체") const [searchQuery, setSearchQuery] = useState("") const [viewMode, setViewMode] = useState<"list" | "calendar">("list"); @@ -76,14 +76,15 @@ export default function MainPage() { const [loading, setLoading] = useState(true) const [error, setError] = useState("") - const [modalOpen, setModalOpen] = useState(false); - const [selectedPostId, setSelectedPostId] = useState(null); - // pagination states (추가) const [page, setPage] = useState(0) const [size] = useState(10) // 기본 10개 const [totalElements, setTotalElements] = useState(0); // 총 게시물 수 const [totalPages, setTotalPages] = useState(1); // 총 페이지 수 - + + + const [modalOpen, setModalOpen] = useState(false); + const [selectedPostId, setSelectedPostId] = useState(null); + const handleCreatePost = () => { const token = getAuthToken(); if (!token) { @@ -122,8 +123,13 @@ export default function MainPage() { }, []); useEffect(() => { - apiClient.getPosts().then(setPosts); - }, []); + apiClient.getPosts() + .then(response => setPosts(response.posts)) // posts 배열만 setPosts에 전달 + .catch(error => { + console.error(error); + setPosts([]); + }); +}, []); useEffect(() => { const fetchMyFollows = async () => { @@ -140,21 +146,40 @@ export default function MainPage() { useEffect(() => { fetchPosts() fetchFavorites() - }, [selectedSport, sortBy, searchQuery, selectedRegion, selectedGender, selectedDate]) + }, [selectedSport, sortType, searchQuery, selectedRegion, selectedGender, selectedDate, page]) + useEffect(() => { + setPage(0) // <-- 추가: 필터/정렬 바뀌면 1페이지로 + }, [selectedSport, sortType, searchQuery, selectedRegion, selectedGender, selectedDate]) + const fetchPosts = async () => { try { setLoading(true) setError("") const params = { sports: selectedSport !== "전체" ? selectedSport : undefined, - sortBy, + sortType, 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("게시글을 불러오는데 실패했습니다.") @@ -164,6 +189,12 @@ export default function MainPage() { } } + const handlePageChange = (newPage: number) => { + if (newPage >= 0 && newPage < totalPages) { + setPage(newPage); + } + }; + useEffect(() => { console.log(posts); if (posts.length > 0) { @@ -233,15 +264,47 @@ export default function MainPage() { return true; }); - const sortedPosts = (() => { - if (sortBy === "popular") { - return [...filteredPosts].sort((a, b) => (b.viewCount || 0) - (a.viewCount || 0)); + // const sortedPosts = (() => { + // if (sortBy === "popular") { + // return [...filteredPosts].sort((a, b) => (b.viewCount || 0) - (a.viewCount || 0)); + // } + // if (sortBy === "recent") { + // return [...filteredPosts].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + // } + // return filteredPosts; + // })(); + + // NEW: 보정 - 필터 변경 등으로 현재 page가 초과하면 마지막 페이지로 이동 + useEffect(() => { + if (page >= totalPages) { + setPage(Math.max(0, totalPages - 1)); } - if (sortBy === "recent") { - return [...filteredPosts].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + + }, [totalPages, page]); + + // NEW: 현재 페이지에 해당하는 slice + const fromIndex = page * size; + const toIndex = Math.min(fromIndex + size, totalElements); + // const pagedPosts = sortedPosts.slice(fromIndex, toIndex); + const pagedPosts = posts; + + + + + // 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); } - return filteredPosts; - })(); + const range: number[] = []; + for (let i = start; i <= end; i++) range.push(i); + return range; + } + const pageRange = getPageRange(page, totalPages, 5); + return (
@@ -584,9 +647,9 @@ export default function MainPage() { {loading ? "로딩중..." : ""}
@@ -756,7 +820,6 @@ export default function MainPage() { ))} {modalOpen && selectedPostId !== null && ( - router.push('/login')} /> )} -
+
+ )} - {/* ---------- Pagination controls ---------- */} + {/* ---------- Pagination controls ---------- */}
@@ -832,4 +894,4 @@ export default function MainPage() {
) -} +} \ No newline at end of file diff --git a/components/edit-post.tsx b/components/edit-post.tsx index ec93dda..afed51a 100644 --- a/components/edit-post.tsx +++ b/components/edit-post.tsx @@ -48,6 +48,105 @@ const genderOptions = [ { id: "FEMALE", name: "여성만" }, ] +export const MAX_COST = 1_000_000; +export const krFormat = new Intl.NumberFormat("ko-KR"); +export const digitsOnly = (s: string) => s.replace(/[^\d]/g, ""); + + +// 지역 매핑 함수 +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(); +}; + // Google Maps API 타입 선언 declare global { interface Window { @@ -164,7 +263,6 @@ export default function EditPostModal({ isOpen, postId, onClose }: EditPostModal const predictionsRef = useRef(null) const GOOGLE_PLACES_API_KEY = process.env.NEXT_PUBLIC_GOOGLE_PLACES_API_KEY || "" - // Google Places Autocomplete Service 초기화 useEffect(() => { const loadGoogleMapsScript = () => { @@ -272,8 +370,14 @@ export default function EditPostModal({ isOpen, postId, onClose }: EditPostModal // 예측 결과 선택 처리 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([]) } @@ -293,7 +397,7 @@ export default function EditPostModal({ isOpen, postId, onClose }: EditPostModal } // 인증 토큰 가져오기 - const getAuthToken = () => localStorage.getItem("auth_token") + const getAuthToken = () => sessionStorage.getItem("auth_token") // JWT 토큰에서 이메일 추출 const getEmailFromToken = () => { @@ -480,6 +584,14 @@ export default function EditPostModal({ isOpen, postId, onClose }: EditPostModal } }, [isOpen, postId, router]) + const pad2 = (n: number) => String(n).padStart(2, "0"); + + // 로컬 시간 기준 "YYYY-MM-DD" + const getTodayStr = () => { + const now = new Date(); + return `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())}`; + }; + const handleParticipantChange = (increment: boolean) => { setFormData((prev) => ({ ...prev, @@ -492,10 +604,13 @@ export default function EditPostModal({ isOpen, postId, onClose }: EditPostModal const file = e.target.files?.[0] if (!file) return - // 파일 타입 체크 - if (!file.type.startsWith('image/')) { - addToast("이미지 파일만 업로드 가능합니다.", 'error') - return + const allowedTypes = ['image/jpeg', 'image/png'] + + if (!allowedTypes.includes(file.type)) { + addToast("이미지는 JPG, PNG만 가능합니다.", 'error') + + e.currentTarget.value = "" + return } setSelectedImage(file) @@ -531,6 +646,44 @@ export default function EditPostModal({ isOpen, postId, onClose }: EditPostModal setError("") setSuccessMessage("") + let hasError = false + if (!formData.title) { + addToast("제목을 입력하세요.", "error") + hasError = true + } + if (!formData.sport) { + addToast("운동 종목을 선택하세요.", "error") + hasError = true + } + if (!formData.location) { + addToast("상세 위치를 입력하세요.", "error") + hasError = true + } + if (hasError) { + setLoading(false) + return + } + + if (!formData.date || !formData.time) { + setLoading(false) + setError("날짜와 시간을 입력해 주세요.") + addToast("날짜와 시간을 입력해 주세요.", "error") + return + } + + // "YYYY-MM-DDTHH:mm(:ss)" → 로컬 기준 Date + const timeStr = formData.time.length === 5 ? formData.time + ":00" : formData.time + const selected = new Date(`${formData.date}T${timeStr}`) + const now = new Date() + // '이후'만 허용 → 같거나 과거면 막기 + if (selected <= now) { + setLoading(false) + setError("현재 시간 이후로 설정해야 됩니다.") + addToast("현재 시간 이후로 설정해야 됩니다.", "error") + return + } + + try { const isoDateTime = `${formData.date}T${formData.time}` @@ -628,359 +781,352 @@ export default function EditPostModal({ isOpen, postId, onClose }: EditPostModal /> ))} -
- {/* 닫기 버튼 */} - - - {/* Header */} -
- ✏️ -

모집글 수정

-
- - {loadingData ? ( -
-
-

모집글 정보를 불러오는 중...

-

권한을 확인하고 데이터를 로딩하고 있습니다

-
- ) : ( -
-
- - setFormData((prev) => ({ ...prev, title: e.target.value }))} - className="h-14 text-lg border-2 border-gray-200 rounded-2xl focus:border-black focus:ring-0 bg-gray-50" - required - /> -
- -
- -
- {sports.map((sport) => ( - - ))} +
+ {/* 고정된 헤더 */} +
+
+
+
+ ✏️
+ 모집글 수정
- -
- -
- 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) => ( - - ))} -
- -
-
- )} + +
+
+ + {/* 스크롤 가능한 콘텐츠 영역 */} +
+ {loadingData ? ( +
+
+

모집글 정보를 불러오는 중...

+

권한을 확인하고 데이터를 로딩하고 있습니다

- - {/* Google Places API 자동완성이 적용된 상세 위치 입력 */} -
- -
+ ) : ( + +
+ { - setFormData((prev) => ({ ...prev, location: e.target.value })) - }} - onFocus={() => { - if (predictions.length > 0) { - setShowPredictions(true) - } - }} - className="h-14 text-lg border-2 border-gray-200 rounded-2xl focus:border-black focus:ring-0 bg-gray-50 pr-12" - required + placeholder="어떤 운동을 함께 할지 간단히 적어주세요" + value={formData.title} + onChange={(e) => setFormData((prev) => ({ ...prev, title: e.target.value }))} + className="h-14 text-lg border-2 border-gray-200 rounded-2xl focus:border-black focus:ring-0 bg-gray-50" /> - - - {/* 로딩 인디케이터 */} - {isLoadingPlaces && ( -
-
-
- )}
- {/* 자동완성 예측 결과 */} - {showPredictions && predictions.length > 0 && ( -
- {predictions.map((prediction, index) => ( +
+ +
+ {sports.map((sport) => ( ))}
- )} -
+
-
- -
-
- +
+ +
setFormData((prev) => ({ ...prev, date: e.target.value }))} - className="h-14 text-lg border-2 border-gray-200 rounded-2xl focus:border-black focus:ring-0 bg-gray-50" - required + ref={locationInputRef} + placeholder="장소명을 입력하세요 (예: 강남구 스포츠센터)" + value={formData.location} + onChange={(e) => { + setFormData((prev) => ({ ...prev, location: e.target.value })) + }} + onFocus={() => { + if (predictions.length > 0) { + setShowPredictions(true) + } + }} + className="h-14 text-lg border-2 border-gray-200 rounded-2xl focus:border-black focus:ring-0 bg-gray-50 pr-12" /> + + + {/* 로딩 인디케이터 */} + {isLoadingPlaces && ( +
+
+
+ )}
-
- + + {/* 자동완성 예측 결과 */} + {showPredictions && predictions.length > 0 && ( +
+ {predictions.map((prediction, index) => ( + + ))} +
+ )} +
+ + {/* 지역 선택 (자동으로 설정됨) */} +
+ +
setFormData((prev) => ({ ...prev, time: e.target.value }))} - className="h-14 text-lg border-2 border-gray-200 rounded-2xl focus:border-black focus:ring-0 bg-gray-50" - required + value={townOptions.find(opt => 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" />
-
-
- -
-
- -
-
{formData.maxParticipants}
-
+
+ +
+
+ + { + const today = getTodayStr(); + const selected = e.target.value; + + // 오늘 이전이면 강제로 오늘로 맞추고 에러/토스트 + if (selected < today) { + setError("오늘 이후 날짜만 선택 가능합니다."); + addToast?.("오늘 이후 날짜만 선택 가능합니다.", "error"); + setFormData((prev) => ({ ...prev, date: today })); + return; + } else { + setError(""); + setFormData((prev) => ({ ...prev, date: selected })); + } + }} + className="h-14 text-lg border-2 border-gray-200 rounded-2xl focus:border-black focus:ring-0 bg-gray-50" + /> +
+ +
+ + setFormData((prev) => ({ ...prev, time: e.target.value }))} + className="h-14 text-lg border-2 border-gray-200 rounded-2xl focus:border-black focus:ring-0 bg-gray-50" + />
-
-
-
- -
- {genderOptions.map((option) => ( - - ))} +
+ +
+
+ +
+
{formData.maxParticipants}
+
+
+ +
+
-
-
- -
- setFormData((prev) => ({ ...prev, cost: e.target.value }))} - className="h-14 text-lg border-2 border-gray-200 rounded-2xl focus:border-black focus:ring-0 bg-gray-50 pr-12" - /> - +
+ +
+ {genderOptions.map((option) => ( + + ))} +
-
-
- - {/* 이미지 미리보기 */} - {imagePreview && ( -
- 구장 이미지 미리보기 { - const img = e.target as HTMLImageElement - img.style.display = "none" - addToast("이미지를 불러올 수 없습니다.", "error") - }} +
+ +
+ {/* setFormData((prev) => ({ ...prev, cost: e.target.value }))} + className="h-14 text-lg border-2 border-gray-200 rounded-2xl focus:border-black focus:ring-0 bg-gray-50 pr-12" + /> */} + { + // 입력값에서 숫자만 추출 + const digits = e.target.value.replace(/[^\d]/g, "") + if (digits === "") { + setFormData(prev => ({ ...prev, cost: "" })) + return + } + // 상한선 적용 + const clamped = Math.min(parseInt(digits, 10), MAX_COST) + // 상태엔 "숫자 문자열"로만 저장 (콤마 없음) + setFormData(prev => ({ ...prev, cost: String(clamped) })) + }} + className="h-14 text-lg border-2 border-gray-200 rounded-2xl focus:border-black focus:ring-0 bg-gray-50 pr-12" + /> + +
+
+ +
+ + {/* 이미지 미리보기 */} + {imagePreview && ( +
+ 구장 이미지 미리보기 { + const img = e.target as HTMLImageElement + img.style.display = "none" + addToast("이미지를 불러올 수 없습니다.", "error") + }} + /> + +
+ )} + {/* 파일 업로드 버튼 */} +
+ +

JPG, PNG 파일만 업로드 가능합니다

- )} - {/* 파일 업로드 버튼 */} -
- - -

JPG, PNG 파일만 업로드 가능합니다

-
-
- -
-