Skip to content
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
c8b579c
feat: 설정 버튼 플로터 UI 개발
Emithen Dec 12, 2025
ed6ee15
feat: 게시글 삭제 모달
Emithen Dec 12, 2025
a19317c
feat: profile 타입 재정의 / 앱 마운트 시 인증 및 프로필 정보 복구 로직 추가
Emithen Dec 15, 2025
a3ae980
feat: mock data 대체 및 테스트용 흐름 삭제
Emithen Dec 15, 2025
7be5fe1
feat: 인기 급상승 게시물 고정 여부 확인 UI
Emithen Dec 15, 2025
1d859a4
refactor: 메시지 변수로 받는 확인 모달 작업
Emithen Dec 22, 2025
e558c5b
refactor: 컴포넌트 트리 구조 조정
Emithen Dec 22, 2025
120f098
fix: role 타입 변경 사항 처리 중간 커밋
Emithen Dec 22, 2025
1980db2
refactor: member 쪽 타입 정리
Emithen Dec 23, 2025
a1c5117
refactor: edit page 타입 변경 사항 적용
Emithen Dec 23, 2025
fe96f36
refactor: profile 타입 도메인 기반으로 변경
Emithen Dec 23, 2025
3119937
feat: 인기 급상승 고정 버튼 UI
Emithen Dec 23, 2025
e823b59
feat: 고정 선택 드롭다운 메뉴 UI 개발
Emithen Dec 23, 2025
c5b9e30
feat: 고정 선택 변경 API 연동
Emithen Dec 23, 2025
dc7c3fd
feat: 서버 상태 변경 후 새로고침
Emithen Dec 23, 2025
94f3bbd
feat: 고정 해제 로직
Emithen Dec 23, 2025
0837ac9
feat: UI 노출 관리자 사용자에 대해서만 허용
Emithen Dec 23, 2025
06311e5
feat: 핫이슈 페이지에서 고정 타입 확인
Emithen Jan 2, 2026
d53a464
feat: 핫이슈 페이지 고정 해제 기능
Emithen Jan 3, 2026
0441e4b
feat: 핀 버튼 디자인 수정
Emithen Jan 3, 2026
52edce5
fix: mbti 타입 일치
Emithen Jan 3, 2026
5cc791b
fix: 고정 해제 api 호출 예외처리
Emithen Jan 3, 2026
e255407
feat: react hooks 규칙
Emithen Jan 3, 2026
4ad7d75
feat: 고정 기능 예외 처리 추가
Emithen Jan 3, 2026
f85f4e7
fix: early return 제거
Emithen Jan 3, 2026
8d0fcd4
fix: dev 머지
Emithen Jan 3, 2026
a904a5f
fix: 핫이슈 페이지 데이터 리페치
Emithen Jan 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export default defineConfig([
'react/react-in-jsx-scope': 'off',
'react/jsx-pascal-case': 'error',
'no-useless-catch': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'no-unused-vars': 'off',
},
},

Expand Down
4 changes: 4 additions & 0 deletions public/check-circle.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions public/letter-x-circle.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
41 changes: 26 additions & 15 deletions src/api/member.ts → src/api/member/member.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,43 @@
import { authApi } from './instance/authApi'
import { Profile } from '@/types/_shared/profile'
import { authApi } from '../instance/authApi'
import {
CreateMemberProfileRequest,
FetchMemberProfileResponse,
UpdateMemberProfileRequest,
} from './types'

export const createMemberProfile = async (profile: Profile) => {
export const createMemberProfile = async (
profile: CreateMemberProfileRequest,
) => {
try {
await authApi.post('/member/profile', profile)
} catch (error) {
throw error
}
}

export const fetchMemberProfile = async (): Promise<Profile | null> => {
export const fetchMemberProfile = async (): Promise<
FetchMemberProfileResponse['profile']
> => {
try {
const response = await authApi.get<{ profile: Profile | null }>(
'/member/profile',
)
const response =
await authApi.get<FetchMemberProfileResponse>('/member/profile')
return response.data.profile
} catch (error) {
throw error
}
}

export const updateMemberProfile = async (
profile: UpdateMemberProfileRequest,
) => {
try {
await authApi.post('/member/profile', profile)
} catch (error) {
throw error
}
}

// TODO : 마이페이지 타입 정리
export type fetchMemberMypageResponse = {
profile: {
profile_image_url: string
Expand All @@ -42,14 +60,7 @@ export const fetchMemberMypage = async () => {
}
}

export const updateMemberProfile = async (profile: Profile) => {
try {
await authApi.post('/member/profile', profile)
} catch (error) {
throw error
}
}

// TODO : 닉네임 체크 응답 타입 정리
export const checkNickname = async (nickname: string) => {
try {
const response = await authApi.get<checkNicknameResponse>(
Expand Down
55 changes: 55 additions & 0 deletions src/api/member/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
export type CreateMemberProfileRequest = {
nickname: string
gender: 'FEMALE' | 'MALE'
age: 'TEN' | 'TWENTY' | 'THIRTY' | 'OVER_FOURTY'
mbtiIe: 'I' | 'E'
mbtiTf: 'T' | 'F'
mbti:
| 'ISTJ'
| 'ISTP'
| 'ISFJ'
| 'ISFP'
| 'INTJ'
| 'INTP'
| 'INFJ'
| 'INFP'
| 'ESTJ'
| 'ESTP'
| 'ESFJ'
| 'ESFP'
| 'ENTJ'
| 'ENTP'
| 'ENFJ'
| 'ENFP'
role: 'USER' | 'ADMIN'
}

export type FetchMemberProfileResponse = {
profile: {
nickname: string
gender: 'FEMALE' | 'MALE'
age: 'TEN' | 'TWENTY' | 'THIRTY' | 'OVER_FOURTY'
mbtiIe: 'I' | 'E'
mbtiTf: 'T' | 'F'
mbti:
| 'ISTJ'
| 'ISTP'
| 'ISFJ'
| 'ISFP'
| 'INTJ'
| 'INTP'
| 'INFJ'
| 'INFP'
| 'ESTJ'
| 'ESTP'
| 'ESFJ'
| 'ESFP'
| 'ENTJ'
| 'ENTP'
| 'ENFJ'
| 'ENFP'
Copy link
Collaborator

Choose a reason for hiding this comment

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

상수로 한번 뺴주시죵

role: 'USER' | 'ADMIN'
}
}

export type UpdateMemberProfileRequest = CreateMemberProfileRequest
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { authApi } from '../../instance/authApi'
import { PinType } from '@/types/balanse/vote'

export type TrendingVoteResponse = {
voteId: number
Expand All @@ -8,6 +9,7 @@ export type TrendingVoteResponse = {
totalParticipants: number
createdBy: string
createdAt: string
pinType: PinType
options: {
optionId: number
content: string
Expand Down
22 changes: 22 additions & 0 deletions src/api/votes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { CreateVoteData, MineVotesResponse } from '@/types/api/votes'
import type { PinType } from '@/types/balanse/vote'
import { authApi } from './instance/authApi'
import { VoteCategory } from '@/types/_shared/vote'
import { isAxiosError } from 'axios'
Expand Down Expand Up @@ -29,6 +30,8 @@ export interface BestVoteResponse {
createdBy: string
createdAt: string
options: VoteOption[]
content: string
pinType: PinType
}

// 투표 API 응답 타입
Expand Down Expand Up @@ -104,3 +107,22 @@ export const fetchMineVotesVoted = async (
throw error
}
}

// 투표 삭제 API
export const deleteVote = async (voteId: number) => {
try {
await authApi.delete(`/votes/${voteId}`)
} catch (error) {
throw error
}
}

// 투표 고정 API
export const pinVote = async (voteId: number, pinType: PinType) => {
try {
const response = await authApi.patch(`/votes/${voteId}/pin`, { pinType })
return response.data
} catch (error) {
throw error
}
}
22 changes: 22 additions & 0 deletions src/app/authBootstrap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// app/authBootstrap.tsx
'use client'

import { useEffect } from 'react'
import { useAppDispatch } from '@/hooks/utils/useAppDispatch'
import { fetchProfileThunk } from '@/store/thunks/memberThunks'
import { getAccessToken } from '@/utils/tokenUtils'

export default function AuthBootstrap() {
const dispatch = useAppDispatch()

useEffect(() => {
// 새로고침 시 profile 가져오기
if (getAccessToken()) {
// access token 이 없으면 로그인 페이지로 이동하기 때문에 아무것도 할 필요없음
// access token 이 있으면 해당 token 으로 profile 조회 시도
dispatch(fetchProfileThunk())
}
}, [dispatch])

return null
}
76 changes: 74 additions & 2 deletions src/app/poll/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,16 @@ import {
Comment,
} from '@/api/comment/commentApi'
import VoteChart from '@/components/pages/poll/statistics/statisics'
import { fetchBestVote } from '@/api/votes'
import { deleteVote, fetchBestVote, pinVote } from '@/api/votes'
import Header from '@/components/_shared/header'
import BottomNavBar from '@/components/_shared/nav/bottomNavBar'
import Loading from '@/components/_shared/loading'
import AdminFloatingButton from '@/components/pages/poll/_admin/AdminFloatingButton'
import DeleteConfirmModal from '@/components/ui/modal/deleteConfirmModal'
import { useAppSelector } from '@/hooks/utils/useAppSelector'
import { SectionHeader } from '@/components/pages/poll/sectionHeader'
import { PinType } from '@/types/balanse/vote'
import ConfirmModal from '@/components/ui/modal/confirmModal'

interface PollOption {
optionId: number
Expand Down Expand Up @@ -48,9 +54,15 @@ export default function PollDetailPage() {
const [showStats, setShowStats] = useState(false)
const [isFromHot, setIsFromHot] = useState(false)
const router = useRouter()
const [deleteModalOpen, setDeleteModalOpen] = useState(false)
const [showConfirmModal, setShowConfirmModal] = useState(false)

// 관리자 여부 파악을 위한 profile 조회
const profile = useAppSelector((state) => state.member.profile)

// URL 파라미터에서 출처 확인
const source = searchParams.get('source')
const pin = searchParams.get('pin') as PinType

useEffect(() => {
if (id === 'hot') {
Expand All @@ -62,7 +74,9 @@ export default function PollDetailPage() {
// fetchBestVote 호출 결과로 불러올 데이터가 없을 경우 404 에러 발생
// -> 404 발생 여부를 반환값이 null 인지 여부로 판정해서 임시로 렌더링 취소하도록 조치함
// 이후 세부 기획이 변경되면 이 부분에서 끌어올린 데이터를 기반으로 렌더링하는 로직을 구현하면 됨
router.replace(`/poll/${response.voteId}?source=hot`)
router.replace(
`/poll/${response.voteId}?source=hot&pin=${response.pinType}`,
)
} catch (error) {
console.error('Failed to fetch best vote:', error)
router.replace('/main')
Expand Down Expand Up @@ -135,6 +149,21 @@ export default function PollDetailPage() {
}
}

const handlePinButtonClick = () => {
setShowConfirmModal(true)
}

// 고정 해제
const handleUnpin = async () => {
try {
await pinVote(Number(id), 'NONE')
router.replace(`/poll/${id}?source=hot&pin=$NONE`)
} catch (error) {
console.error('Failed to unpin vote:', error)
alert('고정 해제에 실패했습니다.')
}
}

if (loading) return <Loading />
if (error)
return (
Expand All @@ -156,6 +185,21 @@ export default function PollDetailPage() {
)
if (!data) return null

// 관리자 여부 판단
if (!profile) return <Loading />
const isAdmin = profile.role === 'ADMIN'

const handleDelete = async () => {
try {
await deleteVote(data.voteId)
setDeleteModalOpen(false)
router.back()
} catch (error) {
console.error('게시글 삭제 실패:', error)
alert('게시글 삭제에 실패했습니다.')
}
}

return (
<div>
<Header
Expand All @@ -165,6 +209,26 @@ export default function PollDetailPage() {
onBackClick={handleBackClick}
/>
<div className="max-w-xl mx-auto p-4 pb-24">
{/* 관리자 계정이면 섹션 헤더에 고정 버튼 표시 */}
{isAdmin && (
<SectionHeader
pinType={pin as PinType}
handlePinButtonClick={handlePinButtonClick}
/>
)}

{/* 고정 해제 확인 모달 */}
<ConfirmModal
title="고정 해제"
description="정말로 고정을 해제하시겠습니까?"
open={showConfirmModal}
onClose={() => setShowConfirmModal(false)}
onConfirm={() => {
handleUnpin()
setShowConfirmModal(false)
}}
/>

{data && (
<PollCard
voteId={data.voteId}
Expand Down Expand Up @@ -204,6 +268,14 @@ export default function PollDetailPage() {
)}
</div>
{(source === 'hot' || isFromHot) && <BottomNavBar />}
{isAdmin && (
<AdminFloatingButton onDelete={() => setDeleteModalOpen(true)} />
)}
<DeleteConfirmModal
open={deleteModalOpen}
onClose={() => setDeleteModalOpen(false)}
onConfirm={handleDelete}
/>
</div>
)
}
9 changes: 8 additions & 1 deletion src/app/providers.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
'use client'
import { Provider } from 'react-redux'
import { store } from '../store/store'
import AuthBootstrap from './authBootstrap'

const Providers = ({ children }: { children: React.ReactNode }) => {
return <Provider store={store}>{children}</Provider>
return (
<Provider store={store}>
{/* 앱 시작 시 또는 새로고침 시 인증/프로필 복구 로직 */}
<AuthBootstrap />
{children}
</Provider>
)
}
export default Providers
Loading