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
7 changes: 6 additions & 1 deletion apps/web/e2e/helpers/fakeJwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ const toBase64Url = (value: object) => Buffer.from(JSON.stringify(value)).toStri
export const createFakeJwt = (expiresInSeconds = 60 * 60) =>
[
toBase64Url({ alg: 'HS256', typ: 'JWT' }),
toBase64Url({ sub: 'e2e-guest', exp: Math.floor(Date.now() / 1000) + expiresInSeconds }),
toBase64Url({
sub: 'e2e-user',
/** role 클레임 기반 gating(getRoleFromToken) 통과용 — SSR 목의 MOCK_MEMBER_ME(회원 고정)와 정합 */
role: 'MEMBER',
exp: Math.floor(Date.now() / 1000) + expiresInSeconds,
}),
'e2e-fake-signature',
].join('.');
9 changes: 8 additions & 1 deletion apps/web/src/apis/getMe.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { environmentManager } from '@tanstack/react-query';
import { environmentManager, queryOptions } from '@tanstack/react-query';

import { QUERY_KEYS } from '@/consts/queryKeys';
import type { ApiResponseT } from '@/types/api';
import type { UserT } from '@/types/user';

Expand All @@ -16,3 +17,9 @@ export const getMe = async () => {
const { data } = await clientApi.get<ApiResponseT<UserT>>('/api/v1/users/me');
return data.data;
};

export const getMeQueryOptions = queryOptions({
queryKey: QUERY_KEYS.USER.ME,
queryFn: getMe,
staleTime: 5 * 60 * 1000,
});
33 changes: 9 additions & 24 deletions apps/web/src/app/archive/wish/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,10 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { isAxiosError } from 'axios';
import { headers } from 'next/headers';
import { cookies, headers } from 'next/headers';
import { redirect } from 'next/navigation';

import { getMe } from '@/apis/getMe';
import WishLoginRequired from '@/components/common/wish-login-required';
import { QUERY_ACTION } from '@/consts/queryAction';
import type { ApiErrorResponseT } from '@/types/api';
import type { UserT } from '@/types/user';
import { getRoleFromToken } from '@/utils/auth';
import { getLoginPath } from '@/utils/loginRedirect';
import { getQueryClient } from '@/utils/queryClient';

type WishArchiveLayoutProps = {
children: React.ReactNode;
Expand All @@ -18,28 +13,18 @@ type WishArchiveLayoutProps = {
async function WishArchiveLayout({ children }: WishArchiveLayoutProps) {
const headerStore = await headers();
const redirectPath = headerStore.get('x-redirect-path');
const queryClient = getQueryClient();

/** MEMBER 권한 조회 - 멤버 권한 없으면 로그인 페이지로 리다이렉트 */
let user: UserT;
try {
user = await queryClient.fetchQuery({
queryKey: ['me'],
queryFn: getMe,
});
} catch (error) {
if (!isAxiosError<ApiErrorResponseT>(error)) throw error;
/** MEMBER 권한 판별 */
const accessToken = (await cookies()).get('access_token')?.value;
const role = getRoleFromToken(accessToken);

if (error.response?.status === 401 || error.response?.status === 404)
redirect(getLoginPath(redirectPath, QUERY_ACTION.VALUE.SESSION_EXPIRED));

throw error;
}
/** 토큰이 유효하지 않은 경우 세션 만료 처리 */
if (role === null) redirect(getLoginPath(redirectPath, QUERY_ACTION.VALUE.SESSION_EXPIRED));

/** 위시 페이지는 멤버가 아니면 로그인 유도 화면을 렌더 */
if (user.identityType !== 'MEMBER') return <WishLoginRequired />;
if (role !== 'MEMBER') return <WishLoginRequired />;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return <HydrationBoundary state={dehydrate(queryClient)}>{children}</HydrationBoundary>;
return children;
}

export default WishArchiveLayout;
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';

import { ANALYTICS_EVENT } from '@/consts/analytics';
import { QUERY_ACTION } from '@/consts/queryAction';
import { QUERY_KEYS } from '@/consts/queryKeys';
import type { ApiErrorResponseT } from '@/types/api';
import { logAnalyticsEvent } from '@/utils/analytics';
import { isServerOrNetworkError, isWithdrawnAccountError } from '@/utils/apiError';
Expand All @@ -30,7 +31,7 @@ export const usePostSocialLogin = (provider: SocialProviderT) => {
}) => postSocialLogin(provider, { code, redirectUri, state }),
onSuccess: (_, variables) => {
logAnalyticsEvent(ANALYTICS_EVENT.SIGN_UP_COMPLETE, { provider });
queryClient.invalidateQueries({ queryKey: ['me'] });
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.USER.ME });
window.location.replace(getLoginRedirectPath(variables.redirect));
},
onError: (error, variables) => {
Expand Down
74 changes: 23 additions & 51 deletions apps/web/src/app/home/page.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,7 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';

import { getMe } from '@/apis/getMe';
import { getTournamentList } from '@/apis/getTournamentList';
import PiKiLogo from '@/assets/images/piki-logo-text.svg';
import { Header, HeaderIcon } from '@/components/header';
import Spacing from '@/components/spacing';
import { QUERY_KEYS } from '@/consts/queryKeys';
import { getIsGuest } from '@/utils/getIsGuest';
import { getQueryClient } from '@/utils/queryClient';
import { serverPrefetch } from '@/utils/serverPrefetch';

import AddWishHomeDialog from './_components/AddWishHomeDialog';
import CreateTournamentDialog from './_components/CreateTournamentDialog';
Expand All @@ -18,53 +11,32 @@ import HomeOnboarding from './_components/home-onboarding';
import TournamentList from './_components/tournament-list';

async function HomePage() {
const queryClient = getQueryClient();

await serverPrefetch(() =>
queryClient.fetchQuery({
queryKey: ['me'],
queryFn: getMe,
})
);

queryClient.prefetchQuery({
queryKey: QUERY_KEYS.TOURNAMENT.LIST.BY_PARAMS({ limit: 3 }),
queryFn: () => getTournamentList({ limit: 3 }),
});

const isGuest = await getIsGuest();

return (
<HydrationBoundary state={dehydrate(queryClient)}>
<div className="to-bg-gray-50 relative flex min-h-dvh flex-col bg-linear-to-b from-bg-layer-default px-5 pt-padding-top pb-32">
{/* 상단 헤더 */}
<Header left={<PiKiLogo />} right={<HeaderIcon name="ALARM" />} />

<Spacing size={isGuest ? 12 : 24} />

{/* 메인 컨텐츠 */}
<main className="flex w-full flex-1 flex-col gap-8">
{/**
* 배너 + 그리드 영역.
* Figma 스펙: flex-direction: column; gap: 12px (배너↔그리드 사이).
* 배너 자체는 padding: 16.4px 61.5px 12.23px 0 을 가짐.
*/}
<div className="flex flex-col gap-3">
{isGuest && <HomeGuestBannerClient />}
<section className="grid grid-cols-2 gap-3">
<AddWishHomeDialog />
<CreateTournamentDialog />
<InviteTournamentDialog />
</section>
</div>

{/* 최근 생성한 토너먼트 */}
<TournamentList isGuest={isGuest} />
</main>

<HomeOnboarding />
</div>
</HydrationBoundary>
<div className="to-bg-gray-50 relative flex min-h-dvh flex-col bg-linear-to-b from-bg-layer-default px-5 pt-padding-top pb-32">
{/* 상단 헤더 */}
<Header left={<PiKiLogo />} right={<HeaderIcon name="ALARM" />} />

<Spacing size={isGuest ? 12 : 24} />

{/* 메인 컨텐츠 */}
<main className="flex w-full flex-1 flex-col gap-8">
<div className="flex flex-col gap-3">
{isGuest && <HomeGuestBannerClient />}
<section className="grid grid-cols-2 gap-3">
<AddWishHomeDialog />
<CreateTournamentDialog />
<InviteTournamentDialog />
</section>
</div>

{/* 최근 생성한 토너먼트 */}
<TournamentList isGuest={isGuest} />
</main>

<HomeOnboarding />
</div>
);
}

Expand Down
14 changes: 12 additions & 2 deletions apps/web/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { GoogleAnalytics } from '@next/third-parties/google';
import { isTokenUnexpired } from '@piki/core';
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import type { Metadata, Viewport } from 'next';
import { headers } from 'next/headers';
import { cookies, headers } from 'next/headers';
import React from 'react';

import { getMeQueryOptions } from '@/apis/getMe';
import BottomTabBar from '@/components/bottom-tab-bar';
import AppUpdateDialog from '@/components/common/app-update-dialog';
import { APP_UPDATE_PROMPT } from '@/consts/appUpdate';
import { SCROLL_CONTAINER_ID } from '@/consts/layout';
import { getAppVersion, isAppVersionSupported } from '@/utils/appVersion';
import { getQueryClient } from '@/utils/queryClient';
import { isWebview as _isWebView } from '@/utils/webBridge';

import Providers from '../components/Providers';
Expand Down Expand Up @@ -38,6 +42,12 @@ async function RootLayout({
const userAgent = headerStore.get('user-agent') ?? '';
const isWebview = _isWebView(userAgent);

const queryClient = getQueryClient();
const accessToken = (await cookies()).get('access_token')?.value;
if (isTokenUnexpired(accessToken ?? null)) {
queryClient.prefetchQuery(getMeQueryOptions);
}

const shouldUpdateApp =
isWebview && !isAppVersionSupported(getAppVersion(userAgent), APP_UPDATE_PROMPT.targetVersion);

Expand Down Expand Up @@ -77,7 +87,7 @@ async function RootLayout({
id={SCROLL_CONTAINER_ID}
className="mx-auto hide-scrollbar h-full max-w-120 overflow-y-auto [scrollbar-gutter:stable]"
>
{children}
<HydrationBoundary state={dehydrate(queryClient)}>{children}</HydrationBoundary>
</div>

{/* NOTE: 전환 애니메이션이 끊기지 않게 하기 위해 탭바를 레이아웃에 렌더 */}
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/app/login/_hooks/usePostGuestLogin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { WEBBRIDGE_MESSAGE_TYPE } from '@piki/core';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';

import { QUERY_KEYS } from '@/consts/queryKeys';
import { setCookie } from '@/utils/cookie';
import { getPostLoginRedirectPath } from '@/utils/loginRedirect';
import { WebBridge, isWebview } from '@/utils/webBridge';
Expand All @@ -15,7 +16,7 @@ export const usePostGuestLogin = () => {
const { mutate: postGuestLoginMutation, isPending: isPostGuestLoginPending } = useMutation({
mutationFn: postGuestLogin,
onSuccess: data => {
queryClient.invalidateQueries({ queryKey: ['me'] });
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.USER.ME });

if (isWebview() && data.accessToken && data.refreshToken) {
setCookie('access_token', data.accessToken, { minutes: 15 });
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/app/mypage/edit/_hooks/usePatchMe.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';

import { QUERY_KEYS } from '@/consts/queryKeys';
import { isGlobalNetError } from '@/utils/apiError';
import { getApiErrorMessage } from '@/utils/getApiErrorMessage';

Expand All @@ -18,7 +19,7 @@ export const usePatchMe = () => {
return patchMe(formData);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['me'] });
await queryClient.invalidateQueries({ queryKey: QUERY_KEYS.USER.ME });
},
onError: error => {
if (isGlobalNetError(error)) return;
Expand Down
14 changes: 1 addition & 13 deletions apps/web/src/app/mypage/edit/page.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,9 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';

import { getMe } from '@/apis/getMe';
import { Header, HeaderIcon } from '@/components/header';
import Spacing from '@/components/spacing';
import { getQueryClient } from '@/utils/queryClient';

import EditForm from './_components/EditForm';

function MypageEditPage() {
const queryClient = getQueryClient();
queryClient.prefetchQuery({
queryKey: ['me'],
queryFn: getMe,
});

return (
<div className="flex h-dvh flex-col bg-bg-layer-basement px-5 pt-padding-top">
<Header
Expand All @@ -23,9 +13,7 @@ function MypageEditPage() {

<Spacing size={60} />

<HydrationBoundary state={dehydrate(queryClient)}>
<EditForm />
</HydrationBoundary>
<EditForm />
</div>
);
}
Expand Down
14 changes: 1 addition & 13 deletions apps/web/src/app/mypage/page.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,13 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';

import { getMe } from '@/apis/getMe';
import { Header, HeaderIcon } from '@/components/header';
import Spacing from '@/components/spacing';
import { getIsGuest } from '@/utils/getIsGuest';
import { getQueryClient } from '@/utils/queryClient';

import AccountInfoSection from './_components/AccountInfoSection';
import AppVersionFooter from './_components/AppVersionFooter';
import MypageGuestBanner from './_components/MypageGuestBanner';
import ProfileSection from './_components/ProfileSection';

async function MypagePage() {
const queryClient = getQueryClient();
queryClient.prefetchQuery({
queryKey: ['me'],
queryFn: getMe,
});

const isGuest = await getIsGuest();

return (
Expand All @@ -33,9 +23,7 @@ async function MypagePage() {
</>
)}

<HydrationBoundary state={dehydrate(queryClient)}>
<ProfileSection />
</HydrationBoundary>
<ProfileSection />

<Spacing size={32} />

Expand Down
32 changes: 10 additions & 22 deletions apps/web/src/app/mypage/withdraw/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,38 +1,26 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { isAxiosError } from 'axios';
import { headers } from 'next/headers';
import { cookies, headers } from 'next/headers';
import { redirect } from 'next/navigation';

import { getMe } from '@/apis/getMe';
import { QUERY_ACTION } from '@/consts/queryAction';
import { ROUTES } from '@/consts/route';
import type { ApiErrorResponseT } from '@/types/api';
import { getRoleFromToken } from '@/utils/auth';
import { getLoginPath } from '@/utils/loginRedirect';
import { getQueryClient } from '@/utils/queryClient';

async function MyPageMemberOnlyLayout({ children }: { children: React.ReactNode }) {
const headerStore = await headers();
const redirectPath = headerStore.get('x-redirect-path');
const queryClient = getQueryClient();

/** 유저 정보 조회 */
try {
const userData = await queryClient.fetchQuery({
queryKey: ['me'],
queryFn: getMe,
});
/** MEMBER 권한 판별 */
const accessToken = (await cookies()).get('access_token')?.value;
const role = getRoleFromToken(accessToken);

if (userData.identityType !== 'MEMBER') redirect(ROUTES.LOGIN);
} catch (error) {
if (!isAxiosError<ApiErrorResponseT>(error)) throw error;
/** 토큰이 유효하지 않은 경우 세션 만료 처리 */
if (role === null) redirect(getLoginPath(redirectPath, QUERY_ACTION.VALUE.SESSION_EXPIRED));

if (error.response?.status === 401 || error.response?.status === 404)
redirect(getLoginPath(redirectPath, QUERY_ACTION.VALUE.SESSION_EXPIRED));
/** 멤버가 아닌 경우 마이페이지로 리다이렉트 */
if (role !== 'MEMBER') redirect(ROUTES.MYPAGE);

throw error;
}

return <HydrationBoundary state={dehydrate(queryClient)}>{children}</HydrationBoundary>;
return children;
}

export default MyPageMemberOnlyLayout;
Loading
Loading