Skip to content
2 changes: 1 addition & 1 deletion src/app/design-system/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ function Page() {
<div className="flex gap-2">
<button
className="px-4 py-2 bg-green-300 text-black rounded"
onClick={() => customToast.success('성공 메시지')}
onClick={() => customToast.success('성공 메시지 \n 줄바꿈은 이렇게')}
>
Success Toast
</button>
Expand Down
11 changes: 3 additions & 8 deletions src/app/login/SocialLogin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,10 @@ import Naver from '@/shared/assets/icons/naver.svg';
import Kakao from '@/shared/assets/icons/kakao.svg';
import Google from '@/shared/assets/icons/google.svg';
import tw from '@/shared/utills/tw';
import Welcome from './Welcome';
import { useState } from 'react';
import { useAuthStore } from '@/shared/@store/auth';

function SocialLogin() {
const [isModalOpen, setIsModalOpen] = useState(false);
const { loginWithProvider } = useAuthStore();

const socialButtons = [
{
Expand All @@ -33,8 +32,7 @@ function SocialLogin() {

// TODO: 백엔드 연동 로직 구현 필요
const handleLogin = (id: string) => {
console.log(id);
setIsModalOpen(true);
loginWithProvider(id as 'naver' | 'kakao' | 'google');
};

return (
Expand All @@ -52,9 +50,6 @@ function SocialLogin() {
</button>
))}
</div>

{/* 웰컴 모달 (임시) */}
<Welcome open={isModalOpen} onClose={() => setIsModalOpen(false)} />
</>
);
}
Expand Down
7 changes: 7 additions & 0 deletions src/app/login/first-user/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import LoginRedirectHandler from '@/shared/components/auth/LoginRedirectHandler';

function Page() {
return <LoginRedirectHandler />;
}

export default Page;
8 changes: 7 additions & 1 deletion src/app/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,13 @@ function Page() {
className="absolute bottom-0 left-1/2 -translate-x-1/2 w-full max-w-[75rem] h-full -z-10"
aria-hidden
>
<Image src={loginBg} alt="" fill className="object-cover md:object-contain object-bottom" />
<Image
src={loginBg}
alt=""
fill
priority
className="object-cover md:object-contain object-bottom"
/>
</div>

<div className="flex flex-col gap-3 text-center">
Expand Down
6 changes: 6 additions & 0 deletions src/app/login/success/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import LoginRedirectHandler from '@/shared/components/auth/LoginRedirectHandler';

function Page() {
return <LoginRedirectHandler />;
}
export default Page;
83 changes: 83 additions & 0 deletions src/shared/@store/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { create } from 'zustand';
import { customToast } from '../components/toast/CustomToastUtils';

interface User {
id: string;
email: string;
nickname: string;
isFirstLogin: boolean;
abv_degree?: number;
provider?: 'naver' | 'kakao' | 'google';
}

interface AuthState {
user: User | null;
accessToken: string | null;
isLoggedIn: boolean;
setUser: (user: User, token: string) => void;
logout: () => Promise<void>;
loginWithProvider: (provider: User['provider']) => void;

updateUser: () => Promise<User | null>;
}

export const useAuthStore = create<AuthState>((set) => ({
user: null,
accessToken: null,
isLoggedIn: false,

loginWithProvider: (provider) => {
window.location.href = `http://localhost:8080/oauth2/authorization/${provider}`;
},

setUser: (user, token) => {
const updatedUser = { ...user, abv_degree: 5.0 };
set({ user: updatedUser, accessToken: token, isLoggedIn: true });

customToast.success(`${updatedUser.nickname}님, 로그인 성공 🎉`);
},

logout: async () => {
try {
await fetch('http://localhost:8080/user/auth/logout', {
method: 'POST',
credentials: 'include',
});

customToast.success('로그아웃 되었습니다.');
set({ user: null, accessToken: null, isLoggedIn: false });
} catch (err) {
customToast.error('로그아웃 실패❌ \n 다시 시도해주세요.');
console.error('로그아웃 실패', err);
}
},

updateUser: async () => {
try {
const res = await fetch('http://localhost:8080/user/auth/refresh', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
});

if (!res.ok) throw new Error('토큰 갱신 실패');
const data = await res.json();

console.log('updateUser response:', data);
const userInfo = data?.data?.user;
const accessToken = data?.data?.accessToken;

if (userInfo && accessToken) {
set({ user: userInfo, accessToken, isLoggedIn: true });
console.log('토큰 및 유저 정보 갱신 완료:', userInfo);
return userInfo;
}

return null;
} catch (err) {
console.error('updateUser 실패', err);
set({ accessToken: null, user: null, isLoggedIn: false });
return null;
}
},
}));
33 changes: 33 additions & 0 deletions src/shared/@store/modal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { create } from 'zustand';

interface WelcomeModalData {
open: boolean;
nickname: string;
}

interface LogoutConfirmModalData {
open: boolean;
}

interface ModalStore {
welcomeModal: WelcomeModalData;
logoutConfirmModal: LogoutConfirmModalData;

openWelcomeModal: (nickname: string) => void;
closeWelcomeModal: () => void;

openLogoutConfirmModal: () => void;
closeLogoutConfirmModal: () => void;
}

export const useModalStore = create<ModalStore>((set) => ({
welcomeModal: { open: false, nickname: '' },
logoutConfirmModal: { open: false },

openWelcomeModal: (nickname: string) => set({ welcomeModal: { open: true, nickname } }),

closeWelcomeModal: () => set({ welcomeModal: { open: false, nickname: '' } }),

openLogoutConfirmModal: () => set({ logoutConfirmModal: { open: true } }),
closeLogoutConfirmModal: () => set({ logoutConfirmModal: { open: false } }),
}));
4 changes: 4 additions & 0 deletions src/shared/components/auth/LoginConfirm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
function LoginConfirm() {
return <div>LoginConfirm</div>;
}
export default LoginConfirm;
55 changes: 55 additions & 0 deletions src/shared/components/auth/LoginRedirectHandler.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
'use client';

import { useEffect, useState } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import { useAuthStore } from '@/shared/@store/auth';
import { useModalStore } from '@/shared/@store/modal';
import { customToast } from '@/shared/components/toast/CustomToastUtils';
import Spinner from '../spinner/Spinner';

function LoginRedirectHandler() {
const pathname = usePathname();
const router = useRouter();
const { user, updateUser } = useAuthStore();
const { openWelcomeModal } = useModalStore();
const [loading, setLoading] = useState(true);

useEffect(() => {
if (!user && loading) {
updateUser()
.then((fetchedUser) => {
if (!fetchedUser) {
router.replace('/login');
}
})
.catch(() => {
router.replace('/login');
})
.finally(() => setLoading(false));
}
}, [user, loading, updateUser, router]);

useEffect(() => {
if (!user || loading) return;

const preLoginPath = sessionStorage.getItem('preLoginPath') || '/';

if (pathname.startsWith('/login/first-user')) {
openWelcomeModal(user.nickname);
} else if (pathname.startsWith('/login/success')) {
customToast.success(`${user.nickname}님 \n 로그인 성공 🎉`);
router.replace(preLoginPath);
}
}, [pathname, user, router, openWelcomeModal, loading]);

if (loading) {
return (
<div className="page-layout max-w-824 flex-center">
<Spinner />
</div>
);
}

return null;
}
export default LoginRedirectHandler;
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,29 @@ import Button from '@/shared/components/button/Button';
import ModalLayout from '@/shared/components/modalPop/ModalLayout';
import Ssury from '@/shared/assets/ssury/ssury_jump.webp';
import { useRouter } from 'next/navigation';
import { useModalStore } from '@/shared/@store/modal';
import { useAuthStore } from '@/shared/@store/auth';

interface Props {
open: boolean;
onClose: () => void;
}

function Welcome({ open, onClose }: Props) {
function Welcome() {
const router = useRouter();
const { user } = useAuthStore();
const { welcomeModal, closeWelcomeModal } = useModalStore();

if (!welcomeModal.open || !user) return null;

return (
<ModalLayout
open={open}
onClose={onClose}
title="환영합니다!"
open={welcomeModal.open}
onClose={closeWelcomeModal}
title={`환영합니다, ${user.nickname}님!`}
description="바텐더 쑤리가 안내해드릴게요"
buttons={
<>
<Button
type="button"
color="purple"
onClick={() => {
onClose();
closeWelcomeModal();
router.push('/recipe');
}}
>
Expand All @@ -36,7 +37,7 @@ function Welcome({ open, onClose }: Props) {
<Button
type="button"
onClick={() => {
onClose();
closeWelcomeModal();
router.push('/recommend');
}}
>
Expand Down
40 changes: 29 additions & 11 deletions src/shared/components/header/DropdownMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useEffect, useRef } from 'react';
import gsap from 'gsap';
import { useAuthStore } from '@/shared/@store/auth';

interface Props {
isClicked: boolean;
Expand All @@ -17,6 +18,8 @@ function DropdownMenu({ isClicked, setIsClicked }: Props) {
const menuRef = useRef<HTMLDivElement>(null);
const textRef = useRef<(HTMLSpanElement | null)[]>([]);

const { isLoggedIn, logout } = useAuthStore();

useEffect(() => {
if (!menuRef.current) return;

Expand Down Expand Up @@ -100,25 +103,40 @@ function DropdownMenu({ isClicked, setIsClicked }: Props) {
))}
</ul>

<section
aria-label="로그인 로그아웃"
className="border border-t-[1px] border-t-gray flex items-center py-[32px] gap-2"
>
<User color="var(--color-primary)" />
<button type="button" className="text-black font-light text-xl hover:text-black/70">
로그인/회원가입
</button>
</section>
<div className="border border-t-[1px] border-t-gray flex items-center py-[32px] gap-2">
{isLoggedIn ? (
<button
type="button"
onClick={logout}
className="flex items-center gap-2 text-black font-light text-xl hover:text-black/70"
>
<User color="var(--color-primary)" aria-hidden />
<span>로그아웃</span>
</button>
) : (
<Link
href="/login"
onNavigate={() => {
setIsClicked(false);
sessionStorage.setItem('preLoginPath', window.location.pathname);
}}
className="flex items-center gap-2 text-black font-light text-xl hover:text-black/70"
>
<User color="var(--color-primary)" aria-hidden />
<span>로그인/회원가입</span>
</Link>
)}
</div>

<div className="absolute top-1.5 left-3">
<button
type="button"
aria-label="모바일 메뉴 닫기"
aria-label="메인 네비게이션 메뉴 닫기"
onClick={() => {
setIsClicked(false);
}}
>
<Close color="var(--color-primary)" className="w-8 h-8" />
<Close color="var(--color-primary)" className="w-8 h-8" aria-hidden />
</button>
</div>
</nav>
Expand Down
2 changes: 1 addition & 1 deletion src/shared/components/header/HamburgerMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ function HamburgerMenu() {
<>
<button
type="button"
className="sm:hidden block stroke-white hover:stroke-white/80 w-[63px]"
className="sm:hidden block stroke-white hover:stroke-white/80"
onClick={(e) => handleClick(e)}
aria-label="메뉴 열기"
aria-expanded={isClicked}
Expand Down
Loading