diff --git a/src/api/like/like.api.ts b/src/api/like/like.api.ts index 78cb195..9c9be75 100644 --- a/src/api/like/like.api.ts +++ b/src/api/like/like.api.ts @@ -19,6 +19,11 @@ interface LikePlaceResponse { updatedAt: string; } +export interface LikeStatusResponse { + contentId: string; + like: boolean; +} + export const likePlace = (body: LikePlaceRequest) => api.post>('/places/like', body); @@ -26,3 +31,8 @@ export const unlikePlace = (contentId: string) => api.delete>('/places/like', { params: { contentId }, }); + +export const getLikeStatus = (contentId: string) => + api.get>('places/like/status', { + params: { contentId }, + }); diff --git a/src/component/Header.tsx b/src/component/Header.tsx index 9f8cc0a..ee83c71 100644 --- a/src/component/Header.tsx +++ b/src/component/Header.tsx @@ -1,7 +1,7 @@ -import { Menu } from "react-feather"; -import { useNavigate } from "react-router-dom"; -import Logo from "../image/Logo.png"; -import ProfileIconUrl from "../image/Profile.svg"; +import { Menu } from 'react-feather'; +import { useNavigate } from 'react-router-dom'; +import Logo from '../image/Logo.png'; +import ProfileIconUrl from '../image/Profile.svg'; type HeaderProps = { onMenuClick: () => void; @@ -10,26 +10,18 @@ export default function Header({ onMenuClick }: HeaderProps) { const navigate = useNavigate(); const handleUserClick = () => { - const token = localStorage.getItem("accessToken"); - if (token) { - navigate("/mypage"); - } else { - navigate("/login"); - } + navigate('/mypage'); }; return (
- +
logo navigate("/")} + onClick={() => navigate('/')} />
= ({ isOpen, onClose, position = 'right' } const isLeft = position === 'left'; const navigate = useNavigate(); - const isLoggedIn = useCallback(() => { - const access = localStorage.getItem('accessToken'); - return !!access; - }, []); - const go = useCallback( (path: string) => { navigate(path); @@ -24,19 +19,10 @@ const Sidebar: React.FC = ({ isOpen, onClose, position = 'right' } [navigate, onClose], ); - const goProtectedMyTravel = useCallback(() => { - if (isLoggedIn()) { - navigate('/mytravel'); - } else { - navigate('/login', { state: { from: '/mytravel' } }); - } - onClose(); - }, [isLoggedIn, navigate, onClose]); - return ( <>
@@ -50,7 +36,7 @@ const Sidebar: React.FC = ({ isOpen, onClose, position = 'right' }
@@ -69,10 +55,10 @@ const Sidebar: React.FC = ({ isOpen, onClose, position = 'right' }
- diff --git a/src/libs/kakaoLoader.ts b/src/libs/kakaoLoader.ts new file mode 100644 index 0000000..fef4316 --- /dev/null +++ b/src/libs/kakaoLoader.ts @@ -0,0 +1,24 @@ +declare global { + interface Window { + kakao: any; + } +} + +let kakaoReady: Promise | null = null; + +export function ensureKakaoLoaded(): Promise { + if (typeof window === 'undefined') { + return Promise.reject(new Error('Window is not available')); + } + const { kakao } = window as any; + if (!kakao?.maps) { + return Promise.reject(new Error('Kakao SDK script not found')); + } + if (!kakaoReady) { + kakaoReady = new Promise((resolve) => { + // autoload=false 환경에서 반드시 load 호출 + kakao.maps.load(() => resolve()); + }); + } + return kakaoReady; +} diff --git a/src/pages/ai/TravelSpotDetail.tsx b/src/pages/ai/TravelSpotDetail.tsx index c56ee21..bc9f826 100644 --- a/src/pages/ai/TravelSpotDetail.tsx +++ b/src/pages/ai/TravelSpotDetail.tsx @@ -1,4 +1,4 @@ -import { useNavigate, useParams } from 'react-router-dom'; +import { useNavigate, useParams, useLocation } from 'react-router-dom'; import { ArrowLeft, EnergyIcon, @@ -12,10 +12,11 @@ import type { PlaceDetail } from '@/types/Detail'; import { getPlaceDetail, type IntegratedPlace } from '@/api/Detail/detail.api'; import { useEffect, useState } from 'react'; import { Badge, Image, Loader, ParkingTable } from '@/component'; -import { likePlace, unlikePlace } from '@/api/like/like.api'; +import { likePlace, unlikePlace, getLikeStatus } from '@/api/like/like.api'; const TravelSpotDetail = () => { const navigate = useNavigate(); + const location = useLocation(); const { contentId = '' } = useParams<{ contentId: string }>(); const formatCount = (n: number, cap = 999) => (n > cap ? `${cap}+` : `${n}`); @@ -27,12 +28,40 @@ const TravelSpotDetail = () => { const [likeCount, setLikeCount] = useState(0); const [bookmarked, setBookmarked] = useState(false); + const isAuthed = !!localStorage.getItem('accessToken'); + const handleToggleLike = async (e?: React.MouseEvent) => { e?.preventDefault(); e?.stopPropagation(); if (!data) return; + if (!isAuthed) { + const here = `${location.pathname}${location.search}${location.hash}`; + sessionStorage.setItem('postLoginRedirect', here); + + sessionStorage.setItem( + 'postLoginAction', + JSON.stringify({ + kind: 'LIKE_PLACE', + contentId, + payload: { + regionName: data.regionTag ?? '정보없음', + themeName: data.themeName ?? '여행지', + cnctrLevel: data.serenity ?? 0, + }, + }), + ); + + navigate( + `/login?redirect=${encodeURIComponent(here)}&action=like_place&cid=${encodeURIComponent( + contentId, + )}`, + { replace: true }, + ); + return; + } + try { if (liked) { await unlikePlace(contentId); @@ -121,6 +150,17 @@ const TravelSpotDetail = () => { setLiked(mapped.liked); setLikeCount(mapped.likeCount); setBookmarked(mapped.bookmarked); + + if (isAuthed) { + try { + const { data: resp } = await getLikeStatus(contentId); + if (alive && resp?.success && resp.data) { + setLiked(!!resp.data.like); + } + } catch (e) { + console.debug('좋아요 상태 조회 실패:', e); + } + } } } catch (e: any) { setErrMsg(e?.message || '여행지 정보를 불러오지 못했습니다.'); diff --git a/src/pages/home/KakaoCallbackPage.tsx b/src/pages/home/KakaoCallbackPage.tsx index 1b720f3..d8be772 100644 --- a/src/pages/home/KakaoCallbackPage.tsx +++ b/src/pages/home/KakaoCallbackPage.tsx @@ -1,6 +1,14 @@ import { useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { loginWithKakaoAccessToken } from '@/api/user/auth.api'; +import { likePlace } from '@/api/like/like.api'; + +function pickSafeInternalPath(p?: string | null) { + if (!p) return undefined; + if (!p.startsWith('/')) return undefined; // 오픈 리다이렉트 방지 + if (p.startsWith('/login')) return undefined; // 로그인 페이지로 복귀 방지 + return p; +} export default function KakaoCallbackPage() { const navigate = useNavigate(); @@ -19,7 +27,7 @@ export default function KakaoCallbackPage() { if (!code) throw new Error('인가 코드가 없습니다.'); const REST_KEY = import.meta.env.VITE_KAKAO_REST_KEY!; - const REDIRECT_URI = import.meta.env.VITE_KAKAO_REDIRECT_URI!; // 인가 때와 "완전히" 동일해야 함 + const REDIRECT_URI = import.meta.env.VITE_KAKAO_REDIRECT_URI!; // 인가 때와 동일 const body = new URLSearchParams({ grant_type: 'authorization_code', @@ -41,11 +49,57 @@ export default function KakaoCallbackPage() { } const kakaoAccessToken = tokenJson.access_token as string; + // 1) 우리 서버 로그인 const result = await loginWithKakaoAccessToken(kakaoAccessToken); - //홈/온보딩으로 - window.history.replaceState(null, '', '/'); - navigate(result.newUser ? '/register/2' : '/', { replace: true }); + // 2) state/세션에서 복귀 경로와 액션 꺼내기 + const rawState = qs.get('state'); + let redirectFromState: string | undefined; + let actionFromState: any; + try { + const parsed = rawState ? JSON.parse(rawState) : null; + redirectFromState = parsed?.redirect; + actionFromState = parsed?.action; + } catch { + // 만약 state를 그냥 경로 문자열로 쓴 경우 대비 + redirectFromState = rawState || undefined; + } + + const fromStorage = sessionStorage.getItem('postLoginRedirect'); + const actionStorage = sessionStorage.getItem('postLoginAction'); + + const redirectTo = + pickSafeInternalPath(redirectFromState) || pickSafeInternalPath(fromStorage) || '/'; + + const action = actionFromState || (actionStorage ? JSON.parse(actionStorage) : null); + + // 한번 쓰면 비워두기 + sessionStorage.removeItem('postLoginRedirect'); + sessionStorage.removeItem('postLoginAction'); + + // 3) 신규 유저는 온보딩으로 + if (result.newUser) { + navigate('/register/2', { replace: true }); + return; + } + + // 4) 액션 재생 (예: 상세에서 비로그인으로 누른 '좋아요') + if (action?.kind === 'LIKE_PLACE' && action.contentId) { + try { + await likePlace({ + contentId: action.contentId, + ...(action.payload ?? {}), + }); + // 선택: 돌아간 페이지에서 토스트 띄우고 싶으면 플래그 남기기 + sessionStorage.setItem('postLoginToast', '좋아요 완료!'); + } catch (e) { + console.error('post-login like failed', e); + sessionStorage.setItem('postLoginToast', '좋아요에 실패했어요.'); + } + } + + // 5) 원래 페이지로 replace 이동 (history에 콜백 안 남김) + navigate(redirectTo, { replace: true }); } catch (e) { console.error(e); alert('로그인에 실패했습니다. 다시 시도해주세요.'); diff --git a/src/pages/home/Login.tsx b/src/pages/home/Login.tsx index 00797de..1d66775 100644 --- a/src/pages/home/Login.tsx +++ b/src/pages/home/Login.tsx @@ -1,18 +1,58 @@ import { KakaoLogin, LoginImage } from '@/assets'; +import { useLocation } from 'react-router-dom'; const REST_KEY = import.meta.env.VITE_KAKAO_REST_KEY as string; const KAKAO_REDIRECT_URI = import.meta.env.VITE_KAKAO_REDIRECT_URI as string; -const params = new URLSearchParams({ - client_id: REST_KEY, - redirect_uri: KAKAO_REDIRECT_URI, - response_type: 'code', - state: 'from=login', -}); +function buildAuthorizeUrl(stateObj: unknown) { + const params = new URLSearchParams({ + client_id: REST_KEY, + redirect_uri: KAKAO_REDIRECT_URI, + response_type: 'code', + state: JSON.stringify(stateObj), + }); + return `https://kauth.kakao.com/oauth/authorize?${params.toString()}`; +} -const authorizeUrl = `https://kauth.kakao.com/oauth/authorize?${params.toString()}`; +function pickSafeInternalPath(p?: string | null) { + if (!p) return undefined; + if (!p.startsWith('/')) return undefined; + if (p.startsWith('/login')) return undefined; + return p; +} export default function LoginPage() { + const location = useLocation(); + + function handleKakaoLogin() { + const qs = new URLSearchParams(location.search); + + //1)복귀 경로 결정 + const candidate = + qs.get('redirect') || `${location.pathname}${location.search}${location.hash}`; + const backup = sessionStorage.getItem('postLoginRedirect'); + const redirect = pickSafeInternalPath(candidate) || pickSafeInternalPath(backup) || '/'; + + //2)액션 힌트 + let action: any = null; + const stored = sessionStorage.getItem('postLoginAction'); + if (stored) { + try { + action = JSON.parse(stored); + } catch { + action = null; + } + } else if (qs.get('action') === 'like_place' && qs.get('cid')) { + action = { kind: 'LIKE_PLACE', contentId: qs.get('cid') }; + } + + sessionStorage.setItem('postLoginRedirect', redirect); + if (action) sessionStorage.setItem('postLoginAction', JSON.stringify(action)); + + const authorizeUrl = buildAuthorizeUrl({ redirect, action }); + window.location.href = authorizeUrl; + } + return (
@@ -24,11 +64,10 @@ export default function LoginPage() {
+