diff --git a/eslint.config.mjs b/eslint.config.mjs index cba43aa..06a5005 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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', }, }, diff --git a/public/check-circle.svg b/public/check-circle.svg new file mode 100644 index 0000000..bd22117 --- /dev/null +++ b/public/check-circle.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/letter-x-circle.svg b/public/letter-x-circle.svg new file mode 100644 index 0000000..2edd993 --- /dev/null +++ b/public/letter-x-circle.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/api/member.ts b/src/api/member/member.ts similarity index 64% rename from src/api/member.ts rename to src/api/member/member.ts index 6b171ff..28a8dbb 100644 --- a/src/api/member.ts +++ b/src/api/member/member.ts @@ -1,7 +1,13 @@ -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) { @@ -9,17 +15,29 @@ export const createMemberProfile = async (profile: Profile) => { } } -export const fetchMemberProfile = async (): Promise => { +export const fetchMemberProfile = async (): Promise< + FetchMemberProfileResponse['profile'] +> => { try { - const response = await authApi.get<{ profile: Profile | null }>( - '/member/profile', - ) + const response = + await authApi.get('/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 @@ -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( diff --git a/src/api/member/types.ts b/src/api/member/types.ts new file mode 100644 index 0000000..6f1a49c --- /dev/null +++ b/src/api/member/types.ts @@ -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' + role: 'USER' | 'ADMIN' + } +} + +export type UpdateMemberProfileRequest = CreateMemberProfileRequest diff --git a/src/api/pages/valanse/trendinVoteApi.ts b/src/api/pages/valanse/trendingVoteApi.ts similarity index 87% rename from src/api/pages/valanse/trendinVoteApi.ts rename to src/api/pages/valanse/trendingVoteApi.ts index c31c685..f0cf477 100644 --- a/src/api/pages/valanse/trendinVoteApi.ts +++ b/src/api/pages/valanse/trendingVoteApi.ts @@ -1,4 +1,5 @@ import { authApi } from '../../instance/authApi' +import { PinType } from '@/types/balanse/vote' export type TrendingVoteResponse = { voteId: number @@ -8,6 +9,7 @@ export type TrendingVoteResponse = { totalParticipants: number createdBy: string createdAt: string + pinType: PinType options: { optionId: number content: string diff --git a/src/api/votes.ts b/src/api/votes.ts index 4521c1e..ebaa213 100644 --- a/src/api/votes.ts +++ b/src/api/votes.ts @@ -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' @@ -30,6 +31,7 @@ export interface BestVoteResponse { createdBy: string createdAt: string options: VoteOption[] + pinType: PinType } // 투표 API 응답 타입 @@ -105,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 + } +} diff --git a/src/app/authBootstrap.tsx b/src/app/authBootstrap.tsx new file mode 100644 index 0000000..84db00f --- /dev/null +++ b/src/app/authBootstrap.tsx @@ -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 +} diff --git a/src/app/poll/[id]/page.tsx b/src/app/poll/[id]/page.tsx index 0361b14..65772a8 100644 --- a/src/app/poll/[id]/page.tsx +++ b/src/app/poll/[id]/page.tsx @@ -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 @@ -49,9 +55,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') { @@ -63,7 +75,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') @@ -136,6 +150,21 @@ export default function PollDetailPage() { } } + const handlePinButtonClick = () => { + setShowConfirmModal(true) + } + + // 고정 해제 + const handleUnpin = async () => { + try { + await pinVote(Number(id), 'NONE') + router.replace('/poll/hot') + } catch (error) { + console.error('Failed to unpin vote:', error) + alert('고정 해제에 실패했습니다.') + } + } + if (loading) return if (error) return ( @@ -157,6 +186,21 @@ export default function PollDetailPage() { ) if (!data) return null + // 관리자 여부 판단 + if (!profile) return + const isAdmin = profile.role === 'ADMIN' + + const handleDelete = async () => { + try { + await deleteVote(data.voteId) + setDeleteModalOpen(false) + router.back() + } catch (error) { + console.error('게시글 삭제 실패:', error) + alert('게시글 삭제에 실패했습니다.') + } + } + return (
+ {/* 관리자 계정이면 섹션 헤더에 고정 버튼 표시 */} + {isAdmin && ( + + )} + + {/* 고정 해제 확인 모달 */} + setShowConfirmModal(false)} + onConfirm={() => { + handleUnpin() + setShowConfirmModal(false) + }} + /> + {data && ( {(source === 'hot' || isFromHot) && } + {isAdmin && ( + setDeleteModalOpen(true)} /> + )} + setDeleteModalOpen(false)} + onConfirm={handleDelete} + />
) } diff --git a/src/app/providers.tsx b/src/app/providers.tsx index f387121..ca19191 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -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 {children} + return ( + + {/* 앱 시작 시 또는 새로고침 시 인증/프로필 복구 로직 */} + + {children} + + ) } export default Providers diff --git a/src/components/pages/balanse/balanseList.tsx b/src/components/pages/balanse/balanse-list-section/balanseList.tsx similarity index 74% rename from src/components/pages/balanse/balanseList.tsx rename to src/components/pages/balanse/balanse-list-section/balanseList.tsx index 75d47a7..576cb38 100644 --- a/src/components/pages/balanse/balanseList.tsx +++ b/src/components/pages/balanse/balanse-list-section/balanseList.tsx @@ -2,6 +2,7 @@ import { Card, CardHeader, CardContent } from '@/components/ui/card' import { UserCircle, Share2, MessageCircle } from 'lucide-react' import { Vote } from '@/types/balanse/vote' import Link from 'next/link' +import { PinMenu } from './pinMenu' const categoryMap: Record = { FOOD: '음식', @@ -10,16 +11,24 @@ const categoryMap: Record = { ALL: '전체', } -export default function BalanceList({ data }: { data: Vote }) { +type Props = { + data: Vote + onPinChange?: () => void +} + +export default function BalanceList({ data, onPinChange }: Props) { return ( - - - - {data.nickname} • {data.created_at} - + +
+ + + {data.nickname} • {data.created_at} + +
+

{data.title}

diff --git a/src/components/pages/balanse/balanse-list-section/pinMenu.tsx b/src/components/pages/balanse/balanse-list-section/pinMenu.tsx new file mode 100644 index 0000000..063ec3b --- /dev/null +++ b/src/components/pages/balanse/balanse-list-section/pinMenu.tsx @@ -0,0 +1,101 @@ +import { useState, useEffect, useRef } from 'react' +import { MoreVertical, Flame, TrendingUp, PinOff } from 'lucide-react' +import { pinVote } from '@/api/votes' +import { PinType } from '@/types/balanse/vote' + +type Props = { + onPinChange?: () => void + voteId: number +} + +export const PinMenu = ({ onPinChange, voteId }: Props) => { + const [isOpen, setIsOpen] = useState(false) + const menuRef = useRef(null) + + // 메뉴 바깥 클릭 시 닫기 로직 + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setIsOpen(false) + } + } + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside) + } + return () => { + document.removeEventListener('mousedown', handleClickOutside) + } + }, [isOpen]) + + // 메뉴 열기/닫기 토글 (이벤트 전파 방지 필수) + const toggleMenu = (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + setIsOpen(!isOpen) + } + + const handleItemClick = async (e: React.MouseEvent, type: PinType) => { + e.preventDefault() + e.stopPropagation() + + try { + await pinVote(voteId, type) + if (onPinChange) onPinChange() + } catch (error) { + console.error('Failed to pin vote:', error) + return + } + + setIsOpen(false) // 선택 후 닫기 + } + + return ( +
+ {/* 트리거 버튼 */} + + + {/* 드롭다운 메뉴 본체 */} + {isOpen && ( +
    e.stopPropagation()} // 메뉴 내부 클릭 시 부모 Link 작동 방지 + > +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+ )} +
+ ) +} diff --git a/src/components/pages/balanse/balansePage.tsx b/src/components/pages/balanse/balansePage.tsx index 332c814..1cdecd8 100644 --- a/src/components/pages/balanse/balansePage.tsx +++ b/src/components/pages/balanse/balansePage.tsx @@ -1,8 +1,8 @@ 'use client' import Header from './header' -import MockPollCard from './mockPollCard' +import MockPollCard from './trending-section/mockPollCard' import FilterTabs from './filtertabs' -import BalanceList from './balanseList' +import BalanceList from './balanse-list-section/balanseList' import { fetchVotes } from '../../../api/pages/valanse/balanseListapi' import { useEffect, useState, Suspense, useCallback, useRef } from 'react' import { Vote } from '@/types/balanse/vote' @@ -10,6 +10,13 @@ import { useRouter, useSearchParams } from 'next/navigation' import BottomNavBar from '@/components/_shared/nav/bottomNavBar' import Loading from '@/components/_shared/loading' import React from 'react' +import { SectionHeader } from './trending-section/sectionHeader' +import { PinButton } from './trending-section/pinButton' +import { fetchTrendingVotes } from '@/api/pages/valanse/trendingVoteApi' +import { TrendingVoteResponse } from '@/api/pages/valanse/trendingVoteApi' +import ConfirmModal from '@/components/ui/modal/confirmModal' +import { pinVote } from '@/api/votes' +import { useAppSelector } from '@/hooks/utils/useAppSelector' const sortOptions = [ { label: '최신순', value: 'latest' }, @@ -20,6 +27,7 @@ function BalancePageContent() { const router = useRouter() const searchParams = useSearchParams() const [votes, setVotes] = useState([]) + const [trendingVote, setTrendingVote] = useState() const [error, setError] = useState(null) const [loading, setLoading] = useState(false) const [hasNextPage, setHasNextPage] = useState(false) @@ -27,6 +35,12 @@ function BalancePageContent() { const [isLoadingMore, setIsLoadingMore] = useState(false) const observerRef = useRef(null) const loadingRef = useRef(null) + const [isRefreshing, setIsRefreshing] = useState(false) + const [showConfirmModal, setShowConfirmModal] = useState(false) + + // 관리자 여부 판단 + const profile = useAppSelector((state) => state.member.profile) + const isAdmin = profile?.role === 'ADMIN' // URL에서 카테고리와 정렬 옵션 가져오기 const category = searchParams.get('category') || 'ALL' @@ -130,17 +144,69 @@ function BalancePageContent() { getVotes() }, [category, sort]) + // 인기 급상승 토픽 불러오기 + useEffect(() => { + const getTrendingVote = async () => { + try { + setLoading(true) + setError(null) + + const data = await fetchTrendingVotes() + setTrendingVote(data) + } catch (_) { + setError('불러오기 실패') + } finally { + setLoading(false) + } + } + getTrendingVote() + setIsRefreshing(false) + }, [isRefreshing]) + // 초기 로딩 중일 때는 전체 화면 로딩 - if (loading && votes.length === 0) { + if (loading || votes.length === 0 || !trendingVote) { return } + if (!profile) return + + // 고정 해제 + const handleUnpin = async () => { + await pinVote(trendingVote.voteId, 'NONE') + } + return (
+ + {/* 인기 급상승 토픽 섹션 */}
- +
+ + {isAdmin && ( + setShowConfirmModal(true)} + /> + )} +
+
+ + {/* 고정 해제 확인 모달 */} + setShowConfirmModal(false)} + onConfirm={() => { + handleUnpin() + setShowConfirmModal(false) + setIsRefreshing(true) + }} + /> + + {/* 투표 목록 섹션 */}
( - + setIsRefreshing(true)} + /> {idx !== votes.length - 1 && (
)} diff --git a/src/components/pages/balanse/header.tsx b/src/components/pages/balanse/header.tsx index e08591f..e2e855a 100644 --- a/src/components/pages/balanse/header.tsx +++ b/src/components/pages/balanse/header.tsx @@ -1,13 +1,7 @@ -import { Flame } from 'lucide-react' - export default function Header() { return (

밸런스 게임

-
- - 인기 급상승 토픽 -
) } diff --git a/src/components/pages/balanse/mockPollCard.tsx b/src/components/pages/balanse/trending-section/mockPollCard.tsx similarity index 59% rename from src/components/pages/balanse/mockPollCard.tsx rename to src/components/pages/balanse/trending-section/mockPollCard.tsx index feb87cc..733f6ea 100644 --- a/src/components/pages/balanse/mockPollCard.tsx +++ b/src/components/pages/balanse/trending-section/mockPollCard.tsx @@ -1,11 +1,6 @@ 'use client' -import { useState, useEffect } from 'react' -import { - fetchTrendingVotes, - type TrendingVoteResponse, -} from '@/api/pages/valanse/trendinVoteApi' +import { type TrendingVoteResponse } from '@/api/pages/valanse/trendingVoteApi' import Link from 'next/link' -import InlineLoading from '@/components/_shared/inlineLoading' const categoryMap: Record = { ETC: '기타', @@ -14,33 +9,11 @@ const categoryMap: Record = { ALL: '전체', } -function MockPollCard() { - const [data, setData] = useState(null) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - - useEffect(() => { - const getData = async () => { - try { - setLoading(true) - const res = await fetchTrendingVotes() - setData(res) - } catch { - setError('불러오기 실패') - } finally { - setLoading(false) - } - } - getData() - }, []) +type Props = { + data: TrendingVoteResponse +} - if (loading) - return ( -
- -
- ) - if (error) return
{error}
+function MockPollCard({ data }: Props) { if (!data) return null return ( @@ -49,7 +22,9 @@ function MockPollCard() {
{data.createdBy}
-
{data.title}
+
+
{data.title}
+
{data.options.map((option, idx) => { diff --git a/src/components/pages/balanse/trending-section/pinButton.tsx b/src/components/pages/balanse/trending-section/pinButton.tsx new file mode 100644 index 0000000..0275108 --- /dev/null +++ b/src/components/pages/balanse/trending-section/pinButton.tsx @@ -0,0 +1,35 @@ +import { PinType } from '@/types/balanse/vote' + +type Props = { + pinType: PinType + onClick?: () => void +} + +const PIN_LABELS: Record = { + HOT: '비었음', + TRENDING: '고정됨', + NONE: '비었음', +} + +export const PinButton = ({ pinType, onClick }: Props) => { + if (!pinType) return null + + const isPinned = pinType === 'TRENDING' + + return ( + + ) +} diff --git a/src/components/pages/balanse/trending-section/sectionHeader.tsx b/src/components/pages/balanse/trending-section/sectionHeader.tsx new file mode 100644 index 0000000..3ff93a7 --- /dev/null +++ b/src/components/pages/balanse/trending-section/sectionHeader.tsx @@ -0,0 +1,10 @@ +import { Flame } from 'lucide-react' + +export const SectionHeader = () => { + return ( +
+ + 인기 급상승 토픽 +
+ ) +} diff --git a/src/components/pages/my/edit/editPage.tsx b/src/components/pages/my/edit/editPage.tsx index 05cb2be..4b2b2ee 100644 --- a/src/components/pages/my/edit/editPage.tsx +++ b/src/components/pages/my/edit/editPage.tsx @@ -4,12 +4,11 @@ import { useState, useEffect } from 'react' import MBTIBottomSheet from '@/components/pages/onboarding/mbtiBottomSheet' -import { MBTI, mbtiIe, mbtiTf, Age, Gender } from '@/types/_shared/profile' -import { Profile } from '@/types/_shared/profile' +import { Profile, MBTI, mbtiIe, mbtiTf, Age, Gender } from '@/types/member' import { useRouter } from 'next/navigation' import Image from 'next/image' import { useAppSelector } from '@/hooks/utils/useAppSelector' -import { checkNickname } from '@/api/member' +import { checkNickname } from '@/api/member/member' import { fetchMypageDataThunk, updateProfileThunk, @@ -49,19 +48,24 @@ const genderMap = (label: string) => { const EditPage = () => { const router = useRouter() + const dispatch = useAppDispatch() const myPageData = useAppSelector((state) => state.member.mypageData) - const [nickname, setNickname] = useState(myPageData?.nickname) - const debouncedNickname = useDebounce(nickname || '', 500) + + // 로컬 상태 관리 + const [nickname, setNickname] = useState( + myPageData?.nickname || '', + ) + const [gender, setGender] = useState( + myPageData?.gender as Gender, + ) + const [age, setAge] = useState(myPageData?.age as Age) + const [mbti, setMbti] = useState(myPageData?.mbti as MBTI) + const [isDirty, setIsDirty] = useState(false) - const [nickNameMessage, setNickNameMessage] = useState(null) const [isNicknameEditing, setIsNicknameEditing] = useState(false) - const [gender, setGender] = useState( - myPageData?.gender as string, - ) - const [age, setAge] = useState(myPageData?.age as string) + const [nickNameMessage, setNickNameMessage] = useState(null) const [mbtiBottomSheetOpen, setMbtiBottomSheetOpen] = useState(false) - const [mbti, setMbti] = useState(myPageData?.mbti as MBTI) - const dispatch = useAppDispatch() + const debouncedNickname = useDebounce(nickname || '', 500) useEffect(() => { if (debouncedNickname && debouncedNickname.length > 0) { @@ -90,8 +94,8 @@ const EditPage = () => { console.log('로컬 상태 nickname', nickname) if (myPageData) { setNickname(myPageData.nickname) - setGender(myPageData.gender) - setAge(myPageData.age) + setGender(myPageData.gender as Gender) + setAge(myPageData.age as Age) setMbti(myPageData.mbti as MBTI) } else { dispatch(fetchMypageDataThunk()) @@ -115,6 +119,7 @@ const EditPage = () => { mbtiIe: mbtiIe, mbtiTf: mbtiTf, mbti: mbti, + role: 'USER', } } @@ -166,7 +171,7 @@ const EditPage = () => { <> setNickname(e.target.value)} className="text-md text-[#1D1D1D] flex-1" /> @@ -211,7 +216,7 @@ const EditPage = () => { {genderOptions.map((option) => ( +
+ )} + + {/* 플로팅 버튼 */} + +
+ ) +} diff --git a/src/components/pages/poll/pinButton.tsx b/src/components/pages/poll/pinButton.tsx new file mode 100644 index 0000000..06fd57d --- /dev/null +++ b/src/components/pages/poll/pinButton.tsx @@ -0,0 +1,35 @@ +import { PinType } from '@/types/balanse/vote' + +type Props = { + pinType: PinType + onClick?: () => void +} + +const PIN_LABELS: Record = { + HOT: '고정됨', + TRENDING: '비었음', + NONE: '비었음', +} + +export const PinButton = ({ pinType, onClick }: Props) => { + if (!pinType) return null + + const isPinned = pinType === 'HOT' + + return ( + + ) +} diff --git a/src/components/pages/poll/sectionHeader.tsx b/src/components/pages/poll/sectionHeader.tsx new file mode 100644 index 0000000..4edc46c --- /dev/null +++ b/src/components/pages/poll/sectionHeader.tsx @@ -0,0 +1,15 @@ +import { PinType } from '@/types/balanse/vote' +import { PinButton } from './pinButton' + +type Props = { + pinType: PinType + handlePinButtonClick?: () => void +} + +export const SectionHeader = ({ pinType, handlePinButtonClick }: Props) => { + return ( +
+ +
+ ) +} diff --git a/src/components/ui/modal/confirmModal.tsx b/src/components/ui/modal/confirmModal.tsx new file mode 100644 index 0000000..95da488 --- /dev/null +++ b/src/components/ui/modal/confirmModal.tsx @@ -0,0 +1,55 @@ +'use client' + +import { + Modal, + ModalOverlay, + ModalHeader, + ModalTitle, + ModalDescription, + ModalBody, + ModalFooter, + ModalCloseButton, +} from '@/components/ui/modal' +import { Button } from '@/components/ui/button' + +interface ConfirmModalProps { + open: boolean + onClose: () => void + onConfirm: () => void + title?: string + description?: string +} + +export default function ConfirmModal({ + open, + onClose, + onConfirm, + title = '진행하시겠습니까?', + description = '이 작업은 되돌릴 수 없습니다.', +}: ConfirmModalProps) { + if (!open) return null + + return ( + + + + {title} + + + + + {description} + + + + + + + + + ) +} diff --git a/src/components/ui/modal/deleteConfirmModal.tsx b/src/components/ui/modal/deleteConfirmModal.tsx new file mode 100644 index 0000000..6a1afa4 --- /dev/null +++ b/src/components/ui/modal/deleteConfirmModal.tsx @@ -0,0 +1,55 @@ +'use client' + +import { + Modal, + ModalOverlay, + ModalHeader, + ModalTitle, + ModalDescription, + ModalBody, + ModalFooter, + ModalCloseButton, +} from '@/components/ui/modal' +import { Button } from '@/components/ui/button' + +interface DeleteConfirmModalProps { + open: boolean + onClose: () => void + onConfirm: () => void + title?: string + description?: string +} + +export default function DeleteConfirmModal({ + open, + onClose, + onConfirm, + title = '삭제하시겠습니까?', + description = '이 작업은 되돌릴 수 없습니다. 정말 삭제하시겠습니까?', +}: DeleteConfirmModalProps) { + if (!open) return null + + return ( + + + + {title} + + + + + {description} + + + + + + + + + ) +} diff --git a/src/store/slices/memberSlice.ts b/src/store/slices/memberSlice.ts index 113349c..cb629f5 100644 --- a/src/store/slices/memberSlice.ts +++ b/src/store/slices/memberSlice.ts @@ -1,5 +1,5 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' -import { Profile } from '@/types/_shared/profile' +import { Profile } from '@/types/member' import { MypageData } from '@/types/_shared/mypageData' interface MemberState { @@ -16,7 +16,7 @@ const memberSlice = createSlice({ name: 'member', initialState, reducers: { - setProfile(state, action: PayloadAction) { + setProfile(state, action: PayloadAction) { state.profile = action.payload }, setMypageData(state, action: PayloadAction) { diff --git a/src/store/thunks/memberThunks.ts b/src/store/thunks/memberThunks.ts index 2eaf8e0..a3e06fc 100644 --- a/src/store/thunks/memberThunks.ts +++ b/src/store/thunks/memberThunks.ts @@ -2,10 +2,10 @@ import { fetchMemberMypage, fetchMemberProfile, updateMemberProfile, -} from '@/api/member' +} from '@/api/member/member' import { setProfile, setMypageData } from '../slices/memberSlice' import { AppDispatch } from '../store' -import { Profile } from '@/types/_shared/profile' +import { Profile } from '@/types/member' // 프로필을 가져오고 store에 저장 export const fetchProfileThunk = () => async (dispatch: AppDispatch) => { diff --git a/src/types/balanse/vote.ts b/src/types/balanse/vote.ts index f35bac7..9d8a5a0 100644 --- a/src/types/balanse/vote.ts +++ b/src/types/balanse/vote.ts @@ -21,3 +21,5 @@ export interface VoteListResponse { has_next_page: boolean next_cursor: string } + +export type PinType = 'HOT' | 'TRENDING' | 'NONE' diff --git a/src/types/_shared/profile.ts b/src/types/member/index.ts similarity index 90% rename from src/types/_shared/profile.ts rename to src/types/member/index.ts index 207cb06..e7c16a5 100644 --- a/src/types/_shared/profile.ts +++ b/src/types/member/index.ts @@ -5,10 +5,18 @@ export type Profile = { mbtiIe: mbtiIe mbtiTf: mbtiTf mbti: MBTI + role: UserRole } -export type Age = 'TEN' | 'TWENTY' | 'THIRTY' | 'OVER_FOURTY' export type Gender = 'FEMALE' | 'MALE' + +export type Age = 'TEN' | 'TWENTY' | 'THIRTY' | 'OVER_FOURTY' + +export type mbtiIe = 'I' | 'E' +export type mbtiNs = 'N' | 'S' +export type mbtiTf = 'T' | 'F' +export type mbtiPj = 'P' | 'J' + export type MBTI = | 'ISTJ' | 'ISTP' @@ -27,7 +35,4 @@ export type MBTI = | 'ENFJ' | 'ENFP' -export type mbtiIe = 'I' | 'E' -export type mbtiNs = 'N' | 'S' -export type mbtiTf = 'T' | 'F' -export type mbtiPj = 'P' | 'J' +export type UserRole = 'USER' | 'ADMIN' diff --git a/tsconfig.json b/tsconfig.json index c133409..0f0ae73 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,7 @@ "isolatedModules": true, "jsx": "preserve", "incremental": true, + "noUnusedLocals": false, "plugins": [ { "name": "next"