diff --git a/apps/web/src/app/_components/SplashClient.tsx b/apps/web/src/app/_components/SplashClient.tsx index 4517dd6e..3a218599 100644 --- a/apps/web/src/app/_components/SplashClient.tsx +++ b/apps/web/src/app/_components/SplashClient.tsx @@ -102,7 +102,14 @@ function SplashClient() { return (
{/** * 로그인 페이지와 동일한 레이아웃 앵커. 보이지 않지만 로고가 이동할 최종 좌표를 측정한다. diff --git a/apps/web/src/app/_components/splash.css b/apps/web/src/app/_components/splash.css index 8b728cc9..a9259ac4 100644 --- a/apps/web/src/app/_components/splash.css +++ b/apps/web/src/app/_components/splash.css @@ -13,18 +13,9 @@ animation: splash-fade-in 1.5s ease-in forwards; } -/** SplashClient SPLASH_SHRINK_MS(700)와 동기화 */ -.splash-bg-shift { - transition: background-color 0.7s ease-in-out; -} - @media (prefers-reduced-motion: reduce) { .splash-logo { opacity: 1; animation: none; } - - .splash-bg-shift { - transition: none; - } } diff --git a/apps/web/src/app/archive/wish/_hooks/useShareIntentWish.ts b/apps/web/src/app/archive/wish/_hooks/useShareIntentWish.ts index 80368a03..f5f7d963 100644 --- a/apps/web/src/app/archive/wish/_hooks/useShareIntentWish.ts +++ b/apps/web/src/app/archive/wish/_hooks/useShareIntentWish.ts @@ -45,9 +45,9 @@ export const useShareIntentWish = () => { if (processedUrlsRef.current.has(url)) return; processedUrlsRef.current.add(url); - /** 실패한 URL은 잠금 해제해 재공유 시 다시 시도되도록 */ + /** 처리 완료 후 잠금 해제 — in-flight 중복만 차단하고 재공유는 허용 */ postWishLinkMutation(url, { - onError: () => processedUrlsRef.current.delete(url), + onSettled: () => processedUrlsRef.current.delete(url), }); }, [postWishLinkMutation] diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 4eac8752..007de3ab 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,31 +1,12 @@ +import { GoogleAnalytics } from '@next/third-parties/google'; import { WEBVIEW_UA_TOKEN } from '@piki/core'; import type { Metadata, Viewport } from 'next'; -import localFont from 'next/font/local'; import { headers } from 'next/headers'; -import { GoogleAnalytics } from '@next/third-parties/google'; import React from 'react'; import Providers from '../components/Providers'; import '../styles/globals.css'; -const pretendard = localFont({ - src: '../assets/fonts/PretendardVariable.woff2', - display: 'swap', - weight: '45 920', - preload: true, - fallback: [ - '-apple-system', - 'BlinkMacSystemFont', - 'Segoe UI', - 'Roboto', - 'Oxygen', - 'Ubuntu', - 'Cantarell', - 'Helvetica Neue', - 'sans-serif', - ], -}); - export const metadata: Metadata = { title: 'PiKi - 같이 고르는 쇼핑 토너먼트', description: '흩어진 위시를 한곳에 모아 토너먼트로 결정해보세요.', @@ -54,10 +35,18 @@ async function RootLayout({ return ( + {/** Pretendard Dynamic Subset CSS */} + + + {process.env.NODE_ENV === 'development' && !isWebview && ( <> {/* eslint-disable-next-line @next/next/no-sync-scripts */} diff --git a/apps/web/src/app/login/_components/LoginButtons.tsx b/apps/web/src/app/login/_components/LoginButtons.tsx index 65e2596e..ad432cc2 100644 --- a/apps/web/src/app/login/_components/LoginButtons.tsx +++ b/apps/web/src/app/login/_components/LoginButtons.tsx @@ -28,18 +28,11 @@ import SocialLoginButton from './SocialLoginButton'; type LoginButtonsProps = { redirect: string | null; action: string | null; - /** 살아있는 게스트 세션 보유 여부 — 서버에서 httpOnly 쿠키 확인 후 전달 */ - canReuseGuestSession: boolean; /** Android 웹뷰에서는 false — 네이티브 Apple 로그인이 iOS 전용이라 미노출 */ showAppleLogin: boolean; }; -function LoginButtons({ - redirect, - action, - canReuseGuestSession, - showAppleLogin, -}: LoginButtonsProps) { +function LoginButtons({ redirect, action, showAppleLogin }: LoginButtonsProps) { const router = useRouter(); const validRedirect = isValidLoginRedirectPath(redirect) ? redirect : null; @@ -106,21 +99,24 @@ function LoginButtons({ const handleGoogleLogin = () => handleSocialLogin('google'); const handleAppleLogin = () => handleSocialLogin('apple'); + /** + * 게스트 로그인 + * + * - 기존 게스트 세션 재활용 시도 + * - 재활용 불가 시 새 게스트 발급 + */ const handleGuestLogin = async () => { setLoginRedirectPath(validRedirect); - /** 살아있는 게스트 세션이면 토큰만 갱신 */ - if (canReuseGuestSession) { - setIsGuestRefreshing(true); - try { - await refreshClientToken(); - router.replace(getPostLoginRedirectPath()); - return; - } catch { - /** 갱신 실패 시 새 게스트 발급 */ - } finally { - setIsGuestRefreshing(false); - } + setIsGuestRefreshing(true); + try { + await refreshClientToken(); + router.replace(getPostLoginRedirectPath()); + return; + } catch { + /** 세션 재활용 불가 */ + } finally { + setIsGuestRefreshing(false); } postGuestLoginMutation(); diff --git a/apps/web/src/app/login/layout.tsx b/apps/web/src/app/login/layout.tsx deleted file mode 100644 index 62796a52..00000000 --- a/apps/web/src/app/login/layout.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; -import { redirect } from 'next/navigation'; - -import { getMe } from '@/apis/getMe'; -import { ROUTES } from '@/consts/route'; -import { getQueryClient } from '@/utils/queryClient'; - -async function LoginLayout({ children }: { children: React.ReactNode }) { - const queryClient = getQueryClient(); - const user = await queryClient - .fetchQuery({ - queryKey: ['me'], - queryFn: getMe, - }) - .catch(() => null); - - /** 로그인 페이지에 유효한 멤버 접근 시 홈으로 리다이렉트 */ - if (user?.identityType === 'MEMBER') redirect(ROUTES.HOME); - - return {children}; -} - -export default LoginLayout; diff --git a/apps/web/src/app/login/page.tsx b/apps/web/src/app/login/page.tsx index 965d12a7..b265bc77 100644 --- a/apps/web/src/app/login/page.tsx +++ b/apps/web/src/app/login/page.tsx @@ -1,11 +1,12 @@ import { WEBVIEW_UA_TOKEN } from '@piki/core'; -import { headers } from 'next/headers'; +import { cookies, headers } from 'next/headers'; import Link from 'next/link'; +import { redirect } from 'next/navigation'; -import { getMe } from '@/apis/getMe'; import PikiLogo from '@/assets/images/piki-logo.svg'; +import { QUERY_ACTION } from '@/consts/queryAction'; import { ROUTES } from '@/consts/route'; -import { getQueryClient } from '@/utils/queryClient'; +import { getRoleFromToken } from '@/utils/auth'; import LoginButtons from './_components/LoginButtons'; @@ -16,11 +17,10 @@ type LoginPageProps = { async function LoginPage({ searchParams }: LoginPageProps) { const { redirect: redirectParam, action } = await searchParams; - /** 게스트 세션 재활용 가능 여부 판단 */ - const user = await getQueryClient() - .fetchQuery({ queryKey: ['me'], queryFn: getMe }) - .catch(() => null); - const canReuseGuestSession = user?.identityType === 'GUEST'; + /** 멤버가 로그인 페이지 직접 진입 시 홈으로 리다이렉트 */ + const accessToken = (await cookies()).get('access_token')?.value; + const role = getRoleFromToken(accessToken); + if (role === 'MEMBER' && action !== QUERY_ACTION.VALUE.SESSION_EXPIRED) redirect(ROUTES.HOME); /** * Android 웹뷰에서는 Apple 로그인 미노출. @@ -34,16 +34,15 @@ async function LoginPage({ searchParams }: LoginPageProps) {
-

+

{'매일 쌓여만 가던\n위시리스트가 오늘의 결정으로'}

-
+
diff --git a/apps/web/src/assets/fonts/PretendardVariable.woff2 b/apps/web/src/assets/fonts/PretendardVariable.woff2 deleted file mode 100644 index 49c54b51..00000000 Binary files a/apps/web/src/assets/fonts/PretendardVariable.woff2 and /dev/null differ diff --git a/apps/web/src/components/Providers.tsx b/apps/web/src/components/Providers.tsx index 3b742199..d96806e5 100644 --- a/apps/web/src/components/Providers.tsx +++ b/apps/web/src/components/Providers.tsx @@ -1,7 +1,7 @@ 'use client'; import { QueryClientProvider } from '@tanstack/react-query'; -import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import dynamic from 'next/dynamic'; import { type ReactNode } from 'react'; import NavigationOverlay from '@/components/navigation-overlay'; @@ -12,6 +12,18 @@ import { useDeepLink } from '@/hooks/useDeepLink'; import { useFcmTokenSync } from '@/hooks/useFcmTokenSync'; import { getQueryClient } from '@/utils/queryClient'; +/** Production 빌드에서는 React Query Devtools 미포함 */ +const ReactQueryDevtools = + process.env.NODE_ENV === 'development' + ? dynamic( + () => + import('@tanstack/react-query-devtools').then(mod => ({ + default: mod.ReactQueryDevtools, + })), + { ssr: false } + ) + : null; + function FcmTokenSyncer() { useFcmTokenSync(); return null; @@ -38,7 +50,7 @@ function Providers({ children }: Readonly<{ children: ReactNode }>) { {children} - + {ReactQueryDevtools && } ); } diff --git a/apps/web/src/styles/globals.css b/apps/web/src/styles/globals.css index 611ebef9..5fc08ff3 100644 --- a/apps/web/src/styles/globals.css +++ b/apps/web/src/styles/globals.css @@ -165,6 +165,17 @@ html, body { background-color: var(--color-bg-layer-basement); + font-family: + 'Pretendard Variable', + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Roboto, + Oxygen, + Ubuntu, + Cantarell, + 'Helvetica Neue', + sans-serif; } /** diff --git a/apps/web/src/utils/auth.ts b/apps/web/src/utils/auth.ts index fd8f8936..40482606 100644 --- a/apps/web/src/utils/auth.ts +++ b/apps/web/src/utils/auth.ts @@ -1,3 +1,5 @@ +import type { UserIdentityTypeT } from '@/types/user'; + /** JWT 토큰 페이로드 추출 */ const decodeJwt = (token: string) => { try { @@ -29,3 +31,11 @@ export const isTokenValid = (token: string) => { return currentTime < expiryTime; }; + +/** access token 의 role(GUEST/MEMBER) 추출 — 만료·손상 토큰은 null */ +export const getRoleFromToken = (token?: string): UserIdentityTypeT | null => { + if (!token || !isTokenValid(token)) return null; + + const role = decodeJwt(token)?.role; + return role === 'GUEST' || role === 'MEMBER' ? role : null; +};