Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 10 additions & 0 deletions src/api/like/like.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,20 @@ interface LikePlaceResponse {
updatedAt: string;
}

export interface LikeStatusResponse {
contentId: string;
like: boolean;
}

export const likePlace = (body: LikePlaceRequest) =>
api.post<ApiResponse<LikePlaceResponse>>('/places/like', body);

export const unlikePlace = (contentId: string) =>
api.delete<ApiResponse<LikePlaceResponse>>('/places/like', {
params: { contentId },
});

export const getLikeStatus = (contentId: string) =>
api.get<ApiResponse<LikeStatusResponse>>('places/like/status', {
params: { contentId },
});
22 changes: 7 additions & 15 deletions src/component/Header.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 (
<header className="bg-beige3 fixed top-0 right-0 left-0 z-50 mx-auto flex h-14 w-full max-w-[430px] items-center justify-between">
<div className="flex w-full flex-row items-center justify-between px-4 py-3">
<Menu
className="h-7 w-7 cursor-pointer text-green2"
onClick={onMenuClick}
/>
<Menu className="text-green2 h-7 w-7 cursor-pointer" onClick={onMenuClick} />
<div className="flex flex-1 justify-center">
<img
src={Logo}
alt="logo"
className="h-8 cursor-pointer object-contain"
onClick={() => navigate("/")}
onClick={() => navigate('/')}
/>
</div>
<img
Expand Down
22 changes: 4 additions & 18 deletions src/component/SideBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,6 @@ const Sidebar: React.FC<SidebarProps> = ({ 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);
Expand All @@ -24,19 +19,10 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onClose, position = 'right' }
[navigate, onClose],
);

const goProtectedMyTravel = useCallback(() => {
if (isLoggedIn()) {
navigate('/mytravel');
} else {
navigate('/login', { state: { from: '/mytravel' } });
}
onClose();
}, [isLoggedIn, navigate, onClose]);

return (
<>
<div
className={`fixed top-0 left-[max(0px,calc(50vw-221px))] z-[60] h-[100dvh] w-[min(100vw,430px)] ${
className={`fixed top-0 left-[max(0px,calc(50vw-218px))] z-[60] h-[100dvh] w-[min(100vw,433px)] ${
isOpen ? 'pointer-events-auto' : 'pointer-events-none'
} overflow-hidden`}
>
Expand All @@ -50,7 +36,7 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onClose, position = 'right' }
<div
role="dialog"
aria-modal="true"
className={`absolute top-0 ${isLeft ? 'left-0' : 'right-0'} bg-beige3 z-[70] h-full w-50 transform transition-transform duration-300 ${
className={`absolute top-0 ${isLeft ? 'left-0' : 'right-0'} bg-beige3 z-[70] h-full w-52 transform transition-transform duration-300 ${
isOpen ? 'translate-x-0' : isLeft ? '-translate-x-full' : 'translate-x-full'
} `}
>
Expand All @@ -69,10 +55,10 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onClose, position = 'right' }
</button>
<div className="border-gray1 mr-13 border-b" />
<button onClick={() => go('/explore/Filter')} className="cursor-pointer py-2 text-left">
여행지 검색
여행지 탐색
</button>
<div className="border-gray1 mr-13 border-b" />
<button onClick={goProtectedMyTravel} className="cursor-pointer py-2 text-left">
<button onClick={() => go('/mytravel')} className="cursor-pointer py-2 text-left">
나의 여행지
</button>
</nav>
Expand Down
24 changes: 24 additions & 0 deletions src/libs/kakaoLoader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
declare global {
interface Window {
kakao: any;
}
}

let kakaoReady: Promise<void> | null = null;

export function ensureKakaoLoaded(): Promise<void> {
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<void>((resolve) => {
// autoload=false 환경에서 반드시 load 호출
kakao.maps.load(() => resolve());
});
}
return kakaoReady;
}
44 changes: 42 additions & 2 deletions src/pages/ai/TravelSpotDetail.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useNavigate, useParams } from 'react-router-dom';
import { useNavigate, useParams, useLocation } from 'react-router-dom';
import {
ArrowLeft,
EnergyIcon,
Expand All @@ -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}`);

Expand All @@ -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);
Expand Down Expand Up @@ -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 || '여행지 정보를 불러오지 못했습니다.');
Expand Down
62 changes: 58 additions & 4 deletions src/pages/home/KakaoCallbackPage.tsx
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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',
Expand All @@ -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('로그인에 실패했습니다. 다시 시도해주세요.');
Expand Down
59 changes: 49 additions & 10 deletions src/pages/home/Login.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="min-h-screen">
<div className="mx-auto w-full px-8 pt-16 pb-12">
Expand All @@ -24,11 +64,10 @@ export default function LoginPage() {
<div className="mt-6 flex justify-center">
<LoginImage />
</div>

<div className="mt-20">
<button
onClick={() => {
window.location.href = authorizeUrl;
}}
onClick={handleKakaoLogin}
className="transition-color cursor-pointer active:scale-95"
>
<img src={KakaoLogin} />
Expand Down
1 change: 1 addition & 0 deletions src/pages/home/MyPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export default function MyPage() {
"프로필 조회 중 오류가 발생했습니다."
);
} finally {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
!cancelled && setLoading(false);
}
}
Expand Down
Loading