diff --git a/apps/web/src/app/community/[boardCode]/create/PostForm.tsx b/apps/web/src/app/community/[boardCode]/create/PostForm.tsx index 537524df..43221481 100644 --- a/apps/web/src/app/community/[boardCode]/create/PostForm.tsx +++ b/apps/web/src/app/community/[boardCode]/create/PostForm.tsx @@ -10,7 +10,7 @@ import CloudSpinnerPage from "@/components/ui/CloudSpinnerPage"; import { showIconToast } from "@/lib/toast/showIconToast"; import useAuthStore from "@/lib/zustand/useAuthStore"; import { IconImage } from "@/public/svgs"; -import { buildLoginPathWithRedirect } from "@/utils/authRedirect"; +import { buildLoginPathWithRedirect, LOGIN_REQUIRED_MESSAGE } from "@/utils/authRedirect"; type PostFormProps = { boardCode: string; @@ -50,6 +50,8 @@ const PostForm = ({ boardCode }: PostFormProps) => { } if (!isAuthenticated || !accessToken) { + // SPA 이동이라 토스트가 로그인 페이지까지 유지된다. + showIconToast("logo", LOGIN_REQUIRED_MESSAGE); router.replace(buildLoginPathWithRedirect(createPath)); } }, [accessToken, createPath, isAuthenticated, isInitialized, refreshStatus, router]); diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index c4e8bd18..001a281e 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -7,6 +7,7 @@ import GlobalLayout from "@/components/layout/GlobalLayout"; import ReissueProvider from "@/components/layout/ReissueProvider"; import QueryProvider from "@/lib/react-query/QueryProvider"; import AppleScriptLoader from "@/lib/ScriptLoader/AppleScriptLoader"; +import PendingToastPresenter from "@/lib/toast/PendingToastPresenter"; import "@/styles/globals.css"; import { GoogleAnalytics } from "@next/third-parties/google"; import { SpeedInsights } from "@vercel/speed-insights/next"; @@ -85,6 +86,7 @@ const RootLayout = ({ children }: { children: ReactNode }) => ( duration: 3000, }} /> + diff --git a/apps/web/src/app/mentor/_ui/MentorClient/index.tsx b/apps/web/src/app/mentor/_ui/MentorClient/index.tsx index 2d44a936..7e46583a 100644 --- a/apps/web/src/app/mentor/_ui/MentorClient/index.tsx +++ b/apps/web/src/app/mentor/_ui/MentorClient/index.tsx @@ -5,8 +5,10 @@ import { useRouter } from "next/navigation"; import { useEffect } from "react"; import { useGetMyInfo } from "@/apis/MyPage"; import CloudSpinnerPage from "@/components/ui/CloudSpinnerPage"; +import { showIconToast } from "@/lib/toast/showIconToast"; import useAuthStore from "@/lib/zustand/useAuthStore"; import { UserRole } from "@/types/mentor"; +import { LOGIN_REQUIRED_MESSAGE } from "@/utils/authRedirect"; import MentorPageSkeleton from "../MentorPageSkeleton"; import MenteePage from "./_ui/MenteePage"; import MentorPage from "./_ui/MentorPage"; @@ -23,6 +25,8 @@ const MentorClient = () => { useEffect(() => { if (isAuthResolving) return; if (isUnauthorized || (!isError && !role)) { + // SPA 이동이라 토스트가 로그인 페이지까지 유지된다. + showIconToast("logo", LOGIN_REQUIRED_MESSAGE); router.replace("/login"); } }, [isAuthResolving, isUnauthorized, isError, role, router]); diff --git a/apps/web/src/app/my/_ui/MyProfileContent/index.tsx b/apps/web/src/app/my/_ui/MyProfileContent/index.tsx index 0f7df112..47b032d6 100644 --- a/apps/web/src/app/my/_ui/MyProfileContent/index.tsx +++ b/apps/web/src/app/my/_ui/MyProfileContent/index.tsx @@ -21,6 +21,7 @@ import { IconUniversity, } from "@/public/svgs/my"; import { UserRole } from "@/types/mentor"; +import { LOGIN_REQUIRED_MESSAGE } from "@/utils/authRedirect"; import { openKakaoOpenChat } from "@/utils/openKakaoOpenChat"; const NEXT_PUBLIC_CONTACT_LINK = process.env.NEXT_PUBLIC_CONTACT_LINK; @@ -38,6 +39,8 @@ const MyProfileContent = () => { useEffect(() => { if (!isInitialized || isAuthenticated) return; + // SPA 이동이라 토스트가 로그인 페이지까지 유지된다. + showIconToast("logo", LOGIN_REQUIRED_MESSAGE); router.replace("/login"); }, [isInitialized, isAuthenticated, router]); diff --git a/apps/web/src/lib/toast/PendingToastPresenter.tsx b/apps/web/src/lib/toast/PendingToastPresenter.tsx new file mode 100644 index 00000000..2ce78d89 --- /dev/null +++ b/apps/web/src/lib/toast/PendingToastPresenter.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { useEffect } from "react"; + +import { consumePendingToast } from "./pendingToast"; +import { showIconToast } from "./showIconToast"; + +/** + * 이전 페이지에서 예약해 둔 토스트를 페이지 진입 시 한 번 띄운다. + * (하드 내비게이션으로 사라졌을 토스트를 도착 페이지에서 대신 보여주는 역할) + */ +const PendingToastPresenter = () => { + useEffect(() => { + const pendingToast = consumePendingToast(); + if (!pendingToast) return; + + showIconToast(pendingToast.icon, pendingToast.message); + }, []); + + return null; +}; + +export default PendingToastPresenter; diff --git a/apps/web/src/lib/toast/pendingToast.ts b/apps/web/src/lib/toast/pendingToast.ts new file mode 100644 index 00000000..2bd76a11 --- /dev/null +++ b/apps/web/src/lib/toast/pendingToast.ts @@ -0,0 +1,47 @@ +import type { ToastIconKey } from "./showIconToast"; + +const PENDING_TOAST_STORAGE_KEY = "pendingToast"; + +type PendingToast = { + icon: ToastIconKey; + message: string; +}; + +/** + * 다음 페이지 로드 때 보여줄 토스트를 예약한다. + * + * `window.location.replace()` 같은 하드 내비게이션은 React 트리를 통째로 버리기 때문에, + * 이동 직전에 띄운 토스트는 화면에 나타나기도 전에 사라진다. + * 그래서 메시지를 sessionStorage 에 넘겨두고 도착한 페이지에서 대신 띄운다. + * + * SPA 이동(next/navigation 의 router.push/replace)은 트리가 유지되므로 + * 이 함수가 아니라 showIconToast 를 그대로 쓰면 된다. + */ +export const setPendingToast = (icon: ToastIconKey, message: string) => { + if (typeof window === "undefined") return; + + try { + sessionStorage.setItem(PENDING_TOAST_STORAGE_KEY, JSON.stringify({ icon, message } satisfies PendingToast)); + } catch { + // sessionStorage 를 못 쓰는 환경(프라이빗 모드 등)에서는 토스트를 포기한다. + } +}; + +/** 예약된 토스트를 읽고 즉시 비운다. (같은 메시지가 다음 이동에서 또 뜨지 않도록) */ +export const consumePendingToast = (): PendingToast | null => { + if (typeof window === "undefined") return null; + + try { + const raw = sessionStorage.getItem(PENDING_TOAST_STORAGE_KEY); + if (!raw) return null; + + sessionStorage.removeItem(PENDING_TOAST_STORAGE_KEY); + + const parsed = JSON.parse(raw) as Partial; + if (!parsed?.message || !parsed?.icon) return null; + + return { icon: parsed.icon, message: parsed.message }; + } catch { + return null; + } +}; diff --git a/apps/web/src/utils/authRedirect.ts b/apps/web/src/utils/authRedirect.ts index f7e9476e..6cdbf573 100644 --- a/apps/web/src/utils/authRedirect.ts +++ b/apps/web/src/utils/authRedirect.ts @@ -1,5 +1,8 @@ export const AUTH_REDIRECT_PARAM = "redirect"; +/** 로그인이 필요해 로그인 페이지로 보낼 때 사용자에게 안내할 메시지 */ +export const LOGIN_REQUIRED_MESSAGE = "로그인이 필요한 페이지입니다."; + const FALLBACK_REDIRECT_PATH = "/"; const COMMUNITY_PATH_PREFIX = "/community/"; diff --git a/apps/web/src/utils/axiosInstance.ts b/apps/web/src/utils/axiosInstance.ts index 0b9631f8..1c1629e7 100644 --- a/apps/web/src/utils/axiosInstance.ts +++ b/apps/web/src/utils/axiosInstance.ts @@ -2,7 +2,7 @@ import axios, { type AxiosError, type AxiosInstance } from "axios"; import { postReissueToken } from "@/apis/Auth/server"; import { QueryKeys } from "@/apis/queryKeys"; import queryClient from "@/lib/react-query/queryClient"; -import { showIconToast } from "@/lib/toast/showIconToast"; +import { setPendingToast } from "@/lib/toast/pendingToast"; import useAuthStore from "@/lib/zustand/useAuthStore"; import { isTokenExpired } from "@/utils/jwtUtils"; @@ -34,7 +34,9 @@ const redirectToLogin = (message: string) => { try { // 쿠키 유틸이 클라이언트에서만 동작하므로 window 가드 내에서 호출 } catch {} - showIconToast("logo", message); + // location.replace 는 하드 내비게이션이라 여기서 토스트를 띄우면 화면에 뜨기 전에 사라진다. + // 로그인 페이지에 도착한 뒤 PendingToastPresenter 가 대신 띄우도록 넘긴다. + setPendingToast("logo", message); window.location.replace("/login"); } };