diff --git a/src/apis/apiHooks/usePromiseInvite.ts b/src/apis/apiHooks/usePromiseInvite.ts new file mode 100644 index 0000000..aadc5ae --- /dev/null +++ b/src/apis/apiHooks/usePromiseInvite.ts @@ -0,0 +1,38 @@ +import { useMutation } from "@tanstack/react-query"; +import { postJoinPromise, postPromiseInviteCode } from "../promise/invite"; + +// 약속 초대 코드 생성 +export const usePostPromiseInviteCode = () => { + return useMutation({ + mutationFn: async (promiseId: string) => { + const response = await postPromiseInviteCode(promiseId); + return response.data; + }, + onSuccess: response => { + const inviteCode = response.inviteCode; + const promiseId = response.promiseId; + const invitationLink = `${window.location.origin}/map/${promiseId}?inviteCode=${inviteCode}`; + navigator.clipboard.writeText(invitationLink); + alert("초대 링크가 복사되었습니다."); + }, + onError: error => { + console.error('약속 초대 코드 생성 실패: ', error); + alert("초대 링크 복사에 실패했습니다."); + } + }); +}; + +// 초대 코드를 통한 약속 참여 요청 +export const usePostJoinPromise = () => { + return useMutation({ + mutationFn: (inviteCode: string) => postJoinPromise(inviteCode), + onSuccess: () => { + console.log('약속 참여 성공'); + sessionStorage.removeItem('inviteCode'); + sessionStorage.removeItem('redirectPage'); + }, + onError: (error) => { + console.error('약속 참여 실패: ', error); + } + }); +}; diff --git a/src/apis/promise/invite.ts b/src/apis/promise/invite.ts new file mode 100644 index 0000000..5772701 --- /dev/null +++ b/src/apis/promise/invite.ts @@ -0,0 +1,15 @@ +import { instance } from '@/apis/instance'; +import type { ApiEnvelope, ApiEnvelopeNullable } from '@/types/api.type'; +import type { PostPromiseInviteCodeResponse } from '@/types/promise/invite.type'; + +// 약속 초대 코드 생성 +export const postPromiseInviteCode = async (promiseId: string): Promise> => { + const response = await instance.post(`/promises/${promiseId}/invite`); + return response.data; +}; + +// 초대 코드를 통한 약속 참여 요청 +export const postJoinPromise = async (inviteCode: string): Promise> => { + const response = await instance.post(`/promises/invite/${inviteCode}`); + return response.data; +}; diff --git a/src/pages/home/Home.tsx b/src/pages/home/Home.tsx index eefb629..d0abf65 100644 --- a/src/pages/home/Home.tsx +++ b/src/pages/home/Home.tsx @@ -42,10 +42,12 @@ const Home = () => { {activePromises.map(promise => ( navigate(`/map/${promise.id}`, { state: { promise } }) } @@ -62,10 +64,12 @@ const Home = () => { {pastPromises.map(promise => ( navigate(`/map/${promise.id}`, { state: { promise } }) } diff --git a/src/pages/home/components/PromiseCard.tsx b/src/pages/home/components/PromiseCard.tsx index afbfa2c..396f61c 100644 --- a/src/pages/home/components/PromiseCard.tsx +++ b/src/pages/home/components/PromiseCard.tsx @@ -1,24 +1,33 @@ +import { usePostPromiseInviteCode } from '@/apis/apiHooks/usePromiseInvite'; import CardMemberIcon from '@/assets/images/cardMemberIcon.svg'; import CardDetailButtonIcon from '@/assets/images/home/cardDetailButtonIcon.svg'; import MemberInvitePlusIcon from '@/assets/images/home/memberInvitePlusIcon.svg'; import PromiseStatusBadge from '@/components/common/PromiseStatusBadge'; interface PromiseCardProps { + promiseId: number; promiseStatus: string; title: string; date: string; memberCount: number; + isLeader?: boolean; + isPast?: boolean; onClick?: () => void; } const PromiseCard = ({ + promiseId, promiseStatus, title, date, memberCount, + isLeader, + isPast, onClick, }: PromiseCardProps) => { - const isCompleted = promiseStatus === '확정 완료'; + const isCompleted = promiseStatus === '확정 완료' || isPast; + + const { mutate: postPromiseInviteCodeHandler } = usePostPromiseInviteCode(); return (
-
- -

- 친구를 초대하기 -

-
+ {isLeader && ( +
{e.stopPropagation(); postPromiseInviteCodeHandler(promiseId.toString())}} className="flex items-center p-1.5 bg-[#EAF2FF] border border-[#C0D7FD] rounded-[10px]"> + +

+ 친구를 초대하기 +

+
+ )}
@@ -63,6 +74,19 @@ const PromiseCard = ({
))} + {isPast && ( +
+
+ +

+ {memberCount}명 +

+
+
+ +
+
+ )} ); }; diff --git a/src/pages/login/components/OAuthCallback.tsx b/src/pages/login/components/OAuthCallback.tsx index eba3972..d7da184 100644 --- a/src/pages/login/components/OAuthCallback.tsx +++ b/src/pages/login/components/OAuthCallback.tsx @@ -17,6 +17,15 @@ const OAuthCallback = () => { } login(accessToken); + + // 초대 링크로 왔었으면 + const inviteCode = sessionStorage.getItem('inviteCode'); + const redirectPage = sessionStorage.getItem('redirectPage'); + + if (inviteCode && redirectPage) { + navigate(`${redirectPage}?inviteCode=${inviteCode}`, { replace: true }); + return; + } navigate('/home', { replace: true }); }, []); diff --git a/src/pages/map/PromiseMap.tsx b/src/pages/map/PromiseMap.tsx index 460d8c7..7288f1a 100644 --- a/src/pages/map/PromiseMap.tsx +++ b/src/pages/map/PromiseMap.tsx @@ -1,5 +1,5 @@ -import { useEffect, useState } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useEffect, useRef, useState } from 'react'; +import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { CustomOverlayMap, Map, @@ -20,6 +20,8 @@ import CustomMarkerIcon from '@/assets/images/map/customMarkerIcon.svg'; import CommentIcon from '@/assets/images/map/commentIcon.svg'; import { useGetCandidatePlaces } from './services/useVotePalce'; import { usePromiseDetail } from './services/usePromiseDetail'; +import { useAuthStore } from '@/stores/authStore'; +import { usePostJoinPromise } from '@/apis/apiHooks/usePromiseInvite'; const PromiseMap = () => { const [bounds, setBounds] = useState({ @@ -29,11 +31,31 @@ const PromiseMap = () => { maxLng: 127.04, }); + const [searchParams] = useSearchParams(); + const inviteCode = searchParams.get('inviteCode'); const navigate = useNavigate(); const { promiseId } = useParams(); const parsedPromiseId = Number(promiseId); const { data: promise } = usePromiseDetail(parsedPromiseId); + const {accessToken} = useAuthStore(); + + const { mutate: joinPromise } = usePostJoinPromise(); + + useEffect(() => { + if (!inviteCode) return; // inviteCode 없으면 그냥 일반 접근 + + if (!accessToken) { + // 로그인 안 됐으면 저장하고 로그인 페이지로 + sessionStorage.setItem('inviteCode', inviteCode); + sessionStorage.setItem('redirectPage', `/map/${promiseId}`); + navigate('/login'); + return; + } + + joinPromise(inviteCode); + }, [inviteCode, accessToken]); + const { center, isOnline } = useMapLocation(); const isMultipleVoting = promise?.isMultipleVoting ?? false; @@ -70,19 +92,34 @@ const PromiseMap = () => { openCommentId, }); - const [showToast, setShowToast] = useState(true); + const [showToast, setShowToast] = useState(false); + const hasCheckedInitial = useRef(false); const [isCardExpanded, setIsCardExpanded] = useState(false); - useEffect(() => { - const timer = setTimeout(() => setShowToast(false), 2000); - return () => clearTimeout(timer); - }, []); - const isConfirmed = false; // 확정된 장소 예정 const { data: candidatePlacesResponse } = useGetCandidatePlaces(promiseId); const candidatePlaces = candidatePlacesResponse?.data.candidates; - const candidatePlacesCount = candidatePlacesResponse?.data.count; + const candidatePlacesCount = candidatePlacesResponse?.data.candidateCount; + + console.log('candidatePlacesResponse: ', candidatePlacesResponse); + + useEffect(() => { + if (!candidatePlacesResponse) return; + if (hasCheckedInitial.current) return; + + hasCheckedInitial.current = true; + + if (candidatePlacesCount === 0) { + setShowToast(true); + } + }, [candidatePlacesResponse, candidatePlacesCount]); + + useEffect(() => { + if (!showToast) return; + const timer = setTimeout(() => setShowToast(false), 2000); + return () => clearTimeout(timer); + }, [showToast]); const handleGoVoteResult = () => { navigate(`/map/${promiseId}/vote`, { @@ -96,6 +133,7 @@ const PromiseMap = () => { }; if (!promise) return null; + const isLeader = promise.isLeader; return ( @@ -319,12 +357,12 @@ const PromiseMap = () => { )} {/* 마커 없을 때 안내 토스트 */} - {showToast && isOnline && !isCommentMode && ( + {showToast && isOnline && candidatePlacesCount === 0 && ( <>
diff --git a/src/pages/map/VoteResult.tsx b/src/pages/map/VoteResult.tsx index 618beb2..bbd4c7c 100644 --- a/src/pages/map/VoteResult.tsx +++ b/src/pages/map/VoteResult.tsx @@ -10,14 +10,13 @@ import CandidateVoteMemberIcon from '@/assets/images/candidateVoteMemberIcon.svg import RevoteMessageIcon from '@/assets/images/revoteMessageIcon.svg'; const VoteResult = () => { - const isCreator = false; // 실제 생성자 구분 교체 예정 - const navigate = useNavigate(); const { state } = useLocation(); const promise = state?.promise; - const markers = state?.markers ?? []; + const markers = state?.candidatesPlaces ?? []; const votedPlaces: string[] = state?.votedPlaces ?? []; const votedPlace: string | null = state?.votedPlace ?? null; + const isCreator = promise?.isLeader ?? false; const { sortedCandidates, diff --git a/src/pages/promise/Promise.tsx b/src/pages/promise/Promise.tsx index 1be21e2..38cbc58 100644 --- a/src/pages/promise/Promise.tsx +++ b/src/pages/promise/Promise.tsx @@ -32,6 +32,7 @@ const Promise = () => { {upcomingConfirmedPromises.map(promise => ( ()( set => ({ accessToken: null, login: accessToken => set({ accessToken }), - logout: () => set({ accessToken: null }), + logout: () => { + set({ accessToken: null }); + sessionStorage.removeItem('auth'); + }, }), { name: 'auth', diff --git a/src/types/map/votePlace.type.ts b/src/types/map/votePlace.type.ts index 677c8c7..1a7bfad 100644 --- a/src/types/map/votePlace.type.ts +++ b/src/types/map/votePlace.type.ts @@ -12,7 +12,7 @@ export interface CandidatePlace { export interface GetCandidatePlacesResponse { candidates: CandidatePlace[]; - count: number; + candidateCount: number; }; // 투표 후보지 추가 diff --git a/src/types/promise/invite.type.ts b/src/types/promise/invite.type.ts new file mode 100644 index 0000000..db32cc2 --- /dev/null +++ b/src/types/promise/invite.type.ts @@ -0,0 +1,5 @@ +// 초대 코드 생성 응답 데이터 +export interface PostPromiseInviteCodeResponse { + promiseId: number; + inviteCode: string; +} diff --git a/src/types/promise/promise.type.ts b/src/types/promise/promise.type.ts index 0e3eda5..024d0a4 100644 --- a/src/types/promise/promise.type.ts +++ b/src/types/promise/promise.type.ts @@ -15,6 +15,7 @@ export interface PromiseItem { promisedAt: string; dayOfWeek: string; memberCount: number; + isLeader: boolean; } // 약속 목록 조회 요청 파라미터