From 7021d6c7fd01c6da69c3c37bfcb0219a292ed6d0 Mon Sep 17 00:00:00 2001 From: Hyeonjun0527 Date: Mon, 9 Feb 2026 00:59:56 +0900 Subject: [PATCH] =?UTF-8?q?fix:-=20Fixes:=20Hydration=20mismatch=20?= =?UTF-8?q?=EC=BD=98=EC=86=94=20=EA=B2=BD=EA=B3=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fixes: 계정 간 본인인증 상태 오염 - Improves: Toast z-index 및 애니메이션 - Improves: Voting 공유 신뢰성 fix:typecheck fix --- src/app/(admin)/layout.tsx | 7 +- src/app/(landing)/layout.tsx | 6 +- src/app/(service)/(my)/layout.tsx | 2 + src/app/(service)/(my)/my-study/page.tsx | 6 +- src/app/(service)/group-study/layout.tsx | 14 ++ src/app/(service)/home/page.tsx | 2 + src/app/(service)/layout.tsx | 8 +- src/app/(service)/premium-study/layout.tsx | 14 ++ src/app/global.css | 32 +++ src/components/home/start-study-button.tsx | 7 +- src/components/home/study-matching-toggle.tsx | 24 ++- src/components/home/tab-navigation.tsx | 15 +- .../layout/sidebar/admin-sidebar.tsx | 6 +- src/components/lists/group-study-list.tsx | 11 +- src/components/lists/study-member-list.tsx | 6 +- .../pages/group-study-list-page.tsx | 6 +- src/components/pages/mentoring-list-page.tsx | 8 +- .../pages/premium-study-list-page.tsx | 6 +- src/components/premium/premium-study-list.tsx | 11 +- .../section/group-study-info-section.tsx | 13 +- .../my-participating-studies-section.tsx | 5 +- .../section/premium-study-info-section.tsx | 13 +- src/components/summary/study-info-summary.tsx | 4 - src/components/ui/toast.tsx | 66 +++--- src/components/voting/voting-detail-view.tsx | 174 +++++++++------- .../user/model/use-user-profile-query.ts | 2 +- src/features/auth/model/use-auth-mutation.ts | 6 + src/features/auth/ui/header-user-dropdown.tsx | 6 +- src/features/my-page/ui/profile.tsx | 26 +-- .../phone-verification/model/store.ts | 35 +++- .../model/use-phone-auth-mutation.ts | 7 +- .../model/use-phone-verification-status.ts | 196 ++++++++++++------ .../ui/phone-verification-modal.tsx | 6 +- .../group/channel/ui/comment-section.tsx | 1 + .../group/ui/apply-group-study-modal.tsx | 12 +- .../study/group/ui/group-study-form-modal.tsx | 26 ++- .../group/ui/group-study-member-item.tsx | 11 +- .../archive/model/use-archive-actions.ts | 26 +-- .../one-to-one/archive/ui/archive-tab.tsx | 9 +- .../balance-game/ui/community-tab-client.tsx | 7 +- .../balance-game/ui/community-tab.tsx | 17 +- .../hall-of-fame/ui/hall-of-fame-tab.tsx | 5 +- .../one-to-one/schedule/ui/study-card.tsx | 6 +- .../schedule/ui/today-study-card.tsx | 6 +- .../participation/ui/reservation-list.tsx | 32 ++- .../participation/ui/start-study-modal.tsx | 27 ++- src/hooks/common/auth-hydration-context.tsx | 27 +++ src/hooks/common/use-auth.ts | 42 +++- src/hooks/queries/group-study-member-api.ts | 7 +- src/hooks/queries/notification-api.ts | 14 +- src/providers/index.tsx | 32 +-- src/utils/safe-server-prefetch.ts | 22 ++ src/widgets/home/todo-list.tsx | 22 +- src/widgets/my-page/sidebar.tsx | 6 +- 54 files changed, 743 insertions(+), 364 deletions(-) create mode 100644 src/app/(service)/group-study/layout.tsx create mode 100644 src/app/(service)/premium-study/layout.tsx create mode 100644 src/hooks/common/auth-hydration-context.tsx create mode 100644 src/utils/safe-server-prefetch.ts diff --git a/src/app/(admin)/layout.tsx b/src/app/(admin)/layout.tsx index cb417f62..29dd1c52 100644 --- a/src/app/(admin)/layout.tsx +++ b/src/app/(admin)/layout.tsx @@ -7,6 +7,7 @@ import localFont from 'next/font/local'; import PageViewTracker from '@/components/analytics/page-view-tracker'; import AdminSideBar from '@/components/layout/sidebar/admin-sidebar'; import MainProvider from '@/providers'; +import { getServerCookie } from '@/utils/server-cookie'; export const metadata: Metadata = { title: '관리자 - ZERO-ONE', @@ -28,16 +29,18 @@ const pretendard = localFont({ const GTM_ID = process.env.NEXT_PUBLIC_GTM_ID; -export default function AdminLayout({ +export default async function AdminLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { + const initialAccessToken = await getServerCookie('accessToken'); + return ( {GTM_ID && } - +
diff --git a/src/app/(landing)/layout.tsx b/src/app/(landing)/layout.tsx index 82baed81..e59c25bd 100644 --- a/src/app/(landing)/layout.tsx +++ b/src/app/(landing)/layout.tsx @@ -8,6 +8,7 @@ import localFont from 'next/font/local'; import PageViewTracker from '@/components/analytics/page-view-tracker'; import MainProvider from '@/providers'; import { getOrganizationSchema, getWebsiteSchema } from '@/utils/seo'; +import { getServerCookie } from '@/utils/server-cookie'; import Header from '@/widgets/home/header'; export const metadata: Metadata = { @@ -55,11 +56,12 @@ const pretendard = localFont({ const GTM_ID = process.env.NEXT_PUBLIC_GTM_ID; const CLARITY_PROJECT_ID = process.env.NEXT_PUBLIC_CLARITY_PROJECT_ID; -export default function LandingPageLayout({ +export default async function LandingPageLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { + const initialAccessToken = await getServerCookie('accessToken'); if (typeof window !== 'undefined' && CLARITY_PROJECT_ID) { Clarity.init(CLARITY_PROJECT_ID); } @@ -91,7 +93,7 @@ export default function LandingPageLayout({ - +
{/** 1400 + 48*2 패딩 양옆 48로 임의적용 */} diff --git a/src/app/(service)/(my)/layout.tsx b/src/app/(service)/(my)/layout.tsx index 2c5c2b20..062a26cf 100644 --- a/src/app/(service)/(my)/layout.tsx +++ b/src/app/(service)/(my)/layout.tsx @@ -1,4 +1,5 @@ import { Metadata } from 'next'; +import GlobalToast from '@/components/ui/global-toast'; import Sidebar from '@/widgets/my-page/sidebar'; export const metadata: Metadata = { @@ -13,6 +14,7 @@ export default function MyLayout({ }>) { return (
+
{children}
diff --git a/src/app/(service)/(my)/my-study/page.tsx b/src/app/(service)/(my)/my-study/page.tsx index 4303b0de..46bef3c2 100644 --- a/src/app/(service)/(my)/my-study/page.tsx +++ b/src/app/(service)/(my)/my-study/page.tsx @@ -8,17 +8,17 @@ import { useMemberStudyListQuery } from '@/features/study/group/model/use-member import CompletedGroupStudyList from '@/features/study/group/ui/completed-group-study-list'; import GroupStudyFormModal from '@/features/study/group/ui/group-study-form-modal'; import NotCompletedGroupStudyList from '@/features/study/group/ui/not-completed-group-study-list'; -import { useAuth } from '@/hooks/common/use-auth'; +import { useAuthReady } from '@/hooks/common/use-auth'; interface MemberGroupStudyList extends MemberStudyItem { type: 'GROUP_STUDY'; } export default function MyStudy() { - const { data: authData } = useAuth(); + const { memberId } = useAuthReady(); const { data, isLoading } = useMemberStudyListQuery({ - memberId: authData?.memberId, + memberId, studyType: 'GROUP_STUDY', studyStatus: 'BOTH', }); diff --git a/src/app/(service)/group-study/layout.tsx b/src/app/(service)/group-study/layout.tsx new file mode 100644 index 00000000..f38c40d2 --- /dev/null +++ b/src/app/(service)/group-study/layout.tsx @@ -0,0 +1,14 @@ +import GlobalToast from '@/components/ui/global-toast'; + +export default function GroupStudyLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + <> + + {children} + + ); +} diff --git a/src/app/(service)/home/page.tsx b/src/app/(service)/home/page.tsx index 41451263..94efe87f 100644 --- a/src/app/(service)/home/page.tsx +++ b/src/app/(service)/home/page.tsx @@ -1,5 +1,6 @@ import { Metadata } from 'next'; import StartStudyButton from '@/components/home/start-study-button'; +import GlobalToast from '@/components/ui/global-toast'; import { generateMetadata as generateSEOMetadata } from '@/utils/seo'; import Banner from '@/widgets/home/banner'; import FeedbackLink from '@/widgets/home/feedback-link'; @@ -24,6 +25,7 @@ export default async function Home({ return (
+ diff --git a/src/app/(service)/layout.tsx b/src/app/(service)/layout.tsx index 0a3e3da3..fa8b7e2b 100644 --- a/src/app/(service)/layout.tsx +++ b/src/app/(service)/layout.tsx @@ -7,8 +7,8 @@ import type { Metadata } from 'next'; import localFont from 'next/font/local'; import React from 'react'; import PageViewTracker from '@/components/analytics/page-view-tracker'; -import GlobalToast from '@/components/ui/global-toast'; import MainProvider from '@/providers'; +import { getServerCookie } from '@/utils/server-cookie'; import Header from '@/widgets/home/header'; export const metadata: Metadata = { @@ -28,11 +28,12 @@ const pretendard = localFont({ const GTM_ID = process.env.NEXT_PUBLIC_GTM_ID; const CLARITY_PROJECT_ID = process.env.NEXT_PUBLIC_CLARITY_PROJECT_ID; -export default function ServiceLayout({ +export default async function ServiceLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { + const initialAccessToken = await getServerCookie('accessToken'); if (typeof window !== 'undefined' && CLARITY_PROJECT_ID) { Clarity.init(CLARITY_PROJECT_ID); } @@ -41,8 +42,7 @@ export default function ServiceLayout({ {GTM_ID && } - - +
diff --git a/src/app/(service)/premium-study/layout.tsx b/src/app/(service)/premium-study/layout.tsx new file mode 100644 index 00000000..9afd9a60 --- /dev/null +++ b/src/app/(service)/premium-study/layout.tsx @@ -0,0 +1,14 @@ +import GlobalToast from '@/components/ui/global-toast'; + +export default function PremiumStudyLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + <> + + {children} + + ); +} diff --git a/src/app/global.css b/src/app/global.css index 8a73143c..e7dbee97 100644 --- a/src/app/global.css +++ b/src/app/global.css @@ -781,6 +781,38 @@ https://velog.io/@oneook/tailwindcss-4.0-%EB%AC%B4%EC%97%87%EC%9D%B4-%EB%8B%AC%E } } +@keyframes toast-enter { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes toast-exit { + from { + opacity: 1; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(-8px); + } +} + +@layer utilities { + .toast-enter { + animation: toast-enter 300ms ease-out forwards; + } + + .toast-exit { + animation: toast-exit 200ms ease-in forwards; + } +} + /* 자동완성이 안되는 문제가 있어 잠시 주석처리 합니다 */ /* @layer utilities { /* typography */ diff --git a/src/components/home/start-study-button.tsx b/src/components/home/start-study-button.tsx index bff323d9..e49a2ebf 100644 --- a/src/components/home/start-study-button.tsx +++ b/src/components/home/start-study-button.tsx @@ -3,12 +3,11 @@ import Image from 'next/image'; import { useUserProfileQuery } from '@/entities/user/model/use-user-profile-query'; import StartStudyModal from '@/features/study/participation/ui/start-study-modal'; -import { useAuth } from '@/hooks/common/use-auth'; +import { useAuthReady } from '@/hooks/common/use-auth'; export default function StartStudyButton() { - const { data: authData } = useAuth(); - const memberId = authData?.memberId ?? null; - const isLoggedIn = !!memberId; + const { memberId, isAuthReady } = useAuthReady(); + const isLoggedIn = isAuthReady && !!memberId; const { data: userProfile } = useUserProfileQuery(memberId ?? 0); diff --git a/src/components/home/study-matching-toggle.tsx b/src/components/home/study-matching-toggle.tsx index 52164ea5..b117e12a 100644 --- a/src/components/home/study-matching-toggle.tsx +++ b/src/components/home/study-matching-toggle.tsx @@ -9,20 +9,22 @@ import { import { usePhoneVerificationStatus } from '@/features/phone-verification/model/use-phone-verification-status'; import PhoneVerificationModal from '@/features/phone-verification/ui/phone-verification-modal'; import StartStudyModal from '@/features/study/participation/ui/start-study-modal'; -import { useAuth } from '@/hooks/common/use-auth'; +import { useAuthReady } from '@/hooks/common/use-auth'; export default function StudyMatchingToggle() { - const { data: authData } = useAuth(); - const memberId = authData?.memberId ?? null; - const isLoggedIn = !!memberId; + const { memberId, isAuthReady } = useAuthReady(); + const isLoggedIn = isAuthReady && !!memberId; const { data: userProfile } = useUserProfileQuery(memberId ?? 0); const { mutate: patchAutoMatching, isPending } = usePatchAutoMatchingMutation(); - const { isVerified, setVerified } = usePhoneVerificationStatus( - memberId ?? undefined, - ); + const { + isVerified, + isLoading: isVerificationLoading, + isError: isVerificationError, + setVerified, + } = usePhoneVerificationStatus(memberId ?? undefined); const [isVerificationModalOpen, setIsVerificationModalOpen] = useState(false); const [enabled, setEnabled] = useState(false); @@ -60,6 +62,12 @@ export default function StudyMatchingToggle() { }; const handleToggleChange = (checked: boolean) => { + if (isVerificationLoading) return; + if (isVerificationError) { + alert('인증 상태를 확인할 수 없습니다. 잠시 후 다시 시도해주세요.'); + + return; + } // 토글을 켤 때만 본인인증 체크 (끌 때는 체크 안 함) if (checked && !isVerified) { setIsVerificationModalOpen(true); @@ -105,7 +113,7 @@ export default function StudyMatchingToggle() { size="md" checked={enabled} onCheckedChange={handleToggleChange} - disabled={isPending} + disabled={isPending || isVerificationLoading} />
diff --git a/src/components/home/tab-navigation.tsx b/src/components/home/tab-navigation.tsx index f651fac3..12e5abc2 100644 --- a/src/components/home/tab-navigation.tsx +++ b/src/components/home/tab-navigation.tsx @@ -12,7 +12,7 @@ import { useEffect, useState } from 'react'; import { getCookie } from '@/api/client/cookie'; import { cn } from '@/components/ui/(shadcn)/lib/utils'; import Button from '@/components/ui/button'; -import { useAuth } from '@/hooks/common/use-auth'; +import { useAuthReady } from '@/hooks/common/use-auth'; import { useScrollToHomeContent } from '@/hooks/use-scroll-to-home-content'; interface TabNavigationProps { @@ -55,16 +55,21 @@ const TABS = [ export default function TabNavigation({ activeTab }: TabNavigationProps) { const router = useRouter(); const searchParams = useSearchParams(); - const { isAuthenticated } = useAuth(); + const { isAuthReady, isHydrated, memberId } = useAuthReady(); const [canViewHistory, setCanViewHistory] = useState(false); const visibleTabs = canViewHistory ? TABS : TABS.filter((tab) => tab.id !== 'history'); useEffect(() => { - const hasMemberId = !!getCookie('memberId'); - setCanViewHistory(isAuthenticated && hasMemberId); - }, [isAuthenticated]); + if (!isHydrated) { + setCanViewHistory(false); + + return; + } + const hasMemberId = !!memberId || !!getCookie('memberId'); + setCanViewHistory(isAuthReady && hasMemberId); + }, [isAuthReady, isHydrated, memberId]); const scrollToHomeContent = useScrollToHomeContent(); diff --git a/src/components/layout/sidebar/admin-sidebar.tsx b/src/components/layout/sidebar/admin-sidebar.tsx index d41045fb..72068d80 100644 --- a/src/components/layout/sidebar/admin-sidebar.tsx +++ b/src/components/layout/sidebar/admin-sidebar.tsx @@ -5,12 +5,12 @@ import { usePathname } from 'next/navigation'; import UserAvatar from '@/components/ui/avatar'; import TabMenu from '@/components/ui/tab-menu'; import { useUserProfileQuery } from '@/entities/user/model/use-user-profile-query'; -import { useAuth } from '@/hooks/common/use-auth'; +import { useAuthReady } from '@/hooks/common/use-auth'; import OutIcon from 'public/icons/out.svg'; export default function AdminSideBar() { - const { data: authData } = useAuth(); - const { data: profile } = useUserProfileQuery(authData?.memberId ?? 0); + const { memberId } = useAuthReady(); + const { data: profile } = useUserProfileQuery(memberId ?? 0); const pathname = usePathname(); diff --git a/src/components/lists/group-study-list.tsx b/src/components/lists/group-study-list.tsx index 9349c40a..316ba79a 100644 --- a/src/components/lists/group-study-list.tsx +++ b/src/components/lists/group-study-list.tsx @@ -4,7 +4,7 @@ import { sendGTMEvent } from '@next/third-parties/google'; import Image from 'next/image'; import { GroupStudyListItemDto } from '@/api/openapi'; -import { useAuth } from '@/hooks/common/use-auth'; +import { useAuthReady } from '@/hooks/common/use-auth'; import { hashValue } from '@/utils/hash'; import StudyCard from '../card/study-card'; @@ -14,15 +14,16 @@ interface GroupStudyListProps { } export default function GroupStudyList({ studies }: GroupStudyListProps) { - const { data: authData } = useAuth(); + const { memberId, isAuthReady } = useAuthReady(); const handleStudyClick = (study: GroupStudyListItemDto) => { sendGTMEvent({ event: 'group_study_detail_view', dl_timestamp: new Date().toISOString(), - ...(authData?.memberId && { - dl_member_id: hashValue(String(authData.memberId)), - }), + ...(isAuthReady && + memberId && { + dl_member_id: hashValue(String(memberId)), + }), dl_study_id: String(study.basicInfo?.groupStudyId), dl_study_title: study.simpleDetailInfo?.title, }); diff --git a/src/components/lists/study-member-list.tsx b/src/components/lists/study-member-list.tsx index ed6125b5..26b0db36 100644 --- a/src/components/lists/study-member-list.tsx +++ b/src/components/lists/study-member-list.tsx @@ -4,7 +4,7 @@ import Image from 'next/image'; import { useState } from 'react'; import { GetGroupStudyMemberStatusResponseContent } from '@/api/openapi'; import Pagination from '@/components/ui/pagination'; -import { useAuth } from '@/hooks/common/use-auth'; +import { useAuthReady } from '@/hooks/common/use-auth'; import { useGetGroupStudyMembers } from '@/hooks/queries/group-study-member-api'; import type { GroupStudyMember } from '../../features/study/group/api/group-study-types'; @@ -32,7 +32,7 @@ export default function StudyMemberList({ pageNumber: pageNumber, pageSize: PAGE_SIZE, }); - const { data: authData } = useAuth(); + const { memberId, isAuthReady } = useAuthReady(); if (isLoading) { return null; @@ -43,7 +43,7 @@ export default function StudyMemberList({ // memberList의 첫 번째 요소는 내 정보 const myInfo = memberList[0]; - const isLeader = leaderId === authData?.memberId; + const isLeader = isAuthReady && leaderId === memberId; const totalPages = Math.ceil((data?.totalMemberCount || 0) / PAGE_SIZE) || 1; return ( diff --git a/src/components/pages/group-study-list-page.tsx b/src/components/pages/group-study-list-page.tsx index e9117aab..98bee3c3 100644 --- a/src/components/pages/group-study-list-page.tsx +++ b/src/components/pages/group-study-list-page.tsx @@ -15,7 +15,7 @@ import StudyFilter, { import StudySearch from '@/components/filtering/study-search'; import PageContainer from '@/components/layout/page-container'; import Button from '@/components/ui/button'; -import { useAuth } from '@/hooks/common/use-auth'; +import { useAuthReady } from '@/hooks/common/use-auth'; import { useGetStudies } from '@/hooks/queries/study-query'; import GroupStudyFormModal from '../../features/study/group/ui/group-study-form-modal'; import GroupStudyPagination from '../../features/study/group/ui/group-study-pagination'; @@ -30,7 +30,7 @@ const Banner = dynamic(() => import('@/widgets/home/banner'), { const PAGE_SIZE = 15; export default function GroupStudyListPage() { - const { isAuthenticated } = useAuth(); + const { isAuthReady } = useAuthReady(); const router = useRouter(); const searchParams = useSearchParams(); @@ -193,7 +193,7 @@ export default function GroupStudyListPage() { size="small" icon={} iconPosition="left" - disabled={!isAuthenticated} + disabled={!isAuthReady} > 스터디 개설하기 diff --git a/src/components/pages/mentoring-list-page.tsx b/src/components/pages/mentoring-list-page.tsx index f34ec149..5c0effe4 100644 --- a/src/components/pages/mentoring-list-page.tsx +++ b/src/components/pages/mentoring-list-page.tsx @@ -7,7 +7,7 @@ import { useState } from 'react'; import MentorProfileList from '@/components/mentoring/mentor-profile-list'; import Button from '@/components/ui/button'; import { Modal } from '@/components/ui/modal'; -import { useAuth } from '@/hooks/common/use-auth'; +import { useAuthReady } from '@/hooks/common/use-auth'; // Carousel이 클라이언트 전용이므로 dynamic import로 로드 const Banner = dynamic(() => import('@/widgets/home/banner'), { @@ -15,7 +15,7 @@ const Banner = dynamic(() => import('@/widgets/home/banner'), { }); export default function MentoringListPage() { - const { isAuthenticated } = useAuth(); + const { isAuthReady } = useAuthReady(); const [isComingSoonModalOpen, setIsComingSoonModalOpen] = useState(false); return ( @@ -33,13 +33,13 @@ export default function MentoringListPage() { size="small" icon={} iconPosition="left" - disabled={!isAuthenticated} + disabled={!isAuthReady} onClick={() => { // GA4 이벤트 전송 sendGTMEvent({ event: 'mentor_register_click', location: 'mentoring_page', - is_authenticated: isAuthenticated, + is_authenticated: isAuthReady, }); setIsComingSoonModalOpen(true); diff --git a/src/components/pages/premium-study-list-page.tsx b/src/components/pages/premium-study-list-page.tsx index a4cb5676..a6a90312 100644 --- a/src/components/pages/premium-study-list-page.tsx +++ b/src/components/pages/premium-study-list-page.tsx @@ -18,7 +18,7 @@ import PremiumStudyList from '@/components/premium/premium-study-list'; import PremiumStudyPagination from '@/components/premium/premium-study-pagination'; import Button from '@/components/ui/button'; import GroupStudyFormModal from '@/features/study/group/ui/group-study-form-modal'; -import { useAuth } from '@/hooks/common/use-auth'; +import { useAuthReady } from '@/hooks/common/use-auth'; import { useGetStudies } from '@/hooks/queries/study-query'; import MyParticipatingStudiesSection from '../section/my-participating-studies-section'; @@ -30,7 +30,7 @@ const Banner = dynamic(() => import('@/widgets/home/banner'), { const PAGE_SIZE = 15; export default function PremiumStudyListPage() { - const { isAuthenticated } = useAuth(); + const { isAuthReady } = useAuthReady(); const router = useRouter(); const searchParams = useSearchParams(); @@ -190,7 +190,7 @@ export default function PremiumStudyListPage() { size="small" icon={} iconPosition="left" - disabled={!isAuthenticated} + disabled={!isAuthReady} > 스터디 개설하기 diff --git a/src/components/premium/premium-study-list.tsx b/src/components/premium/premium-study-list.tsx index 5ccb546b..174f2ab4 100644 --- a/src/components/premium/premium-study-list.tsx +++ b/src/components/premium/premium-study-list.tsx @@ -5,7 +5,7 @@ import Image from 'next/image'; import { GroupStudyListItemDto } from '@/api/openapi'; import { GroupStudyData } from '@/features/study/group/api/group-study-types'; -import { useAuth } from '@/hooks/common/use-auth'; +import { useAuthReady } from '@/hooks/common/use-auth'; import { hashValue } from '@/utils/hash'; import StudyCard from '../card/study-card'; @@ -15,15 +15,16 @@ interface PremiumStudyListProps { } export default function PremiumStudyList({ studies }: PremiumStudyListProps) { - const { data: authData } = useAuth(); + const { memberId, isAuthReady } = useAuthReady(); const handleStudyClick = (study: GroupStudyListItemDto) => { sendGTMEvent({ event: 'premium_study_detail_view', dl_timestamp: new Date().toISOString(), - ...(authData?.memberId && { - dl_member_id: hashValue(String(authData.memberId)), - }), + ...(isAuthReady && + memberId && { + dl_member_id: hashValue(String(memberId)), + }), dl_study_id: String(study.basicInfo?.groupStudyId), dl_study_title: study.simpleDetailInfo?.title, }); diff --git a/src/components/section/group-study-info-section.tsx b/src/components/section/group-study-info-section.tsx index 876391b2..221c5cb4 100644 --- a/src/components/section/group-study-info-section.tsx +++ b/src/components/section/group-study-info-section.tsx @@ -10,7 +10,7 @@ import Button from '@/components/ui/button'; import { getSincerityPresetByLevelName } from '@/config/sincerity-temp-presets'; import UserProfileModal from '@/entities/user/ui/user-profile-modal'; import { useApplicantsByStatusQuery } from '@/features/study/group/application/model/use-applicant-qeury'; -import { useAuth } from '@/hooks/common/use-auth'; +import { useAuthReady } from '@/hooks/common/use-auth'; import { useIsLeader } from '@/stores/useLeaderStore'; import { useUserStore } from '@/stores/useUserStore'; import { hashValue } from '@/utils/hash'; @@ -26,7 +26,7 @@ export default function StudyInfoSection({ }: StudyInfoSectionProps) { const router = useRouter(); const params = useParams(); - const { data: authData } = useAuth(); + const { memberId: authMemberId, isAuthReady } = useAuthReady(); const memberId = useUserStore((state) => state.memberId); const isLeader = useIsLeader(memberId); @@ -160,11 +160,10 @@ export default function StudyInfoSection({ sendGTMEvent({ event: 'group_study_member_profile_click', dl_timestamp: new Date().toISOString(), - ...(authData?.memberId && { - dl_member_id: hashValue( - String(authData.memberId), - ), - }), + ...(isAuthReady && + authMemberId && { + dl_member_id: hashValue(String(authMemberId)), + }), dl_target_member_id: String( data.applicantInfo.memberId, ), diff --git a/src/components/section/my-participating-studies-section.tsx b/src/components/section/my-participating-studies-section.tsx index d2f52fb8..3bc4e815 100644 --- a/src/components/section/my-participating-studies-section.tsx +++ b/src/components/section/my-participating-studies-section.tsx @@ -6,7 +6,7 @@ import Link from 'next/link'; import { useMemo } from 'react'; import StudyCard from '@/components/card/study-card'; import { useMemberStudyListQuery } from '@/features/study/group/model/use-member-study-list-query'; -import { useAuth } from '@/hooks/common/use-auth'; +import { useAuthReady } from '@/hooks/common/use-auth'; import { useGetStudies } from '@/hooks/queries/study-query'; import { hashValue } from '@/utils/hash'; @@ -17,8 +17,7 @@ interface MyParticipatingStudiesSectionProps { export default function MyParticipatingStudiesSection({ classification, }: MyParticipatingStudiesSectionProps) { - const { data: authData } = useAuth(); - const memberId = authData?.memberId; + const { memberId } = useAuthReady(); /** * TODO : PREMIUM_STUDY 에서도 동작확인 필요 (BE 미구현) diff --git a/src/components/section/premium-study-info-section.tsx b/src/components/section/premium-study-info-section.tsx index 6fa5a355..21967fc9 100644 --- a/src/components/section/premium-study-info-section.tsx +++ b/src/components/section/premium-study-info-section.tsx @@ -8,7 +8,7 @@ import UserAvatar from '@/components/ui/avatar'; import Button from '@/components/ui/button'; import { getSincerityPresetByLevelName } from '@/config/sincerity-temp-presets'; import UserProfileModal from '@/entities/user/ui/user-profile-modal'; -import { useAuth } from '@/hooks/common/use-auth'; +import { useAuthReady } from '@/hooks/common/use-auth'; import { useIsLeader } from '@/stores/useLeaderStore'; import { useUserStore } from '@/stores/useUserStore'; import { hashValue } from '@/utils/hash'; @@ -33,7 +33,7 @@ export default function PremiumStudyInfoSection({ }: PremiumStudyInfoSectionProps) { const router = useRouter(); const params = useParams(); - const { data: authData } = useAuth(); + const { memberId: authMemberId, isAuthReady } = useAuthReady(); const memberId = useUserStore((state) => state.memberId); const isLeader = useIsLeader(memberId); @@ -165,11 +165,10 @@ export default function PremiumStudyInfoSection({ sendGTMEvent({ event: 'premium_study_member_profile_click', dl_timestamp: new Date().toISOString(), - ...(authData?.memberId && { - dl_member_id: hashValue( - String(authData.memberId), - ), - }), + ...(isAuthReady && + authMemberId && { + dl_member_id: hashValue(String(authMemberId)), + }), dl_target_member_id: String( data.applicantInfo.memberId, ), diff --git a/src/components/summary/study-info-summary.tsx b/src/components/summary/study-info-summary.tsx index 530b950e..af5fcb08 100644 --- a/src/components/summary/study-info-summary.tsx +++ b/src/components/summary/study-info-summary.tsx @@ -116,10 +116,6 @@ export default function SummaryStudyInfo({ data }: Props) { label: '정기모임 유무', value: `${REGULAR_MEETING_LABELS[regularMeeting]}${location ? `, ${location}` : ''}`, }, - { - label: '모집인원', - value: `${maxMembersCount}명`, - }, ]; const visibleItems = isExpanded ? infoItems : infoItems.slice(0, 4); diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx index ce1335e8..ffc932de 100644 --- a/src/components/ui/toast.tsx +++ b/src/components/ui/toast.tsx @@ -1,7 +1,8 @@ 'use client'; import { CheckCircle2, XCircle } from 'lucide-react'; -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; import { cn } from '@/components/ui/(shadcn)/lib/utils'; interface ToastProps { @@ -19,43 +20,58 @@ export default function Toast({ duration = 3000, variant = 'success', }: ToastProps) { + const [isRendered, setIsRendered] = useState(isVisible); + const [isExiting, setIsExiting] = useState(false); + const exitDuration = 200; + useEffect(() => { if (isVisible) { + setIsRendered(true); + setIsExiting(false); const timer = setTimeout(() => { onClose(); }, duration); return () => clearTimeout(timer); } + + if (isRendered) { + setIsExiting(true); + const timer = setTimeout(() => { + setIsRendered(false); + setIsExiting(false); + }, exitDuration); + + return () => clearTimeout(timer); + } }, [isVisible, duration, onClose]); - if (!isVisible) return null; + if (!isRendered) return null; + if (typeof document === 'undefined') return null; const isSuccess = variant === 'success'; - return ( -
- {isSuccess ? ( - - ) : ( - - )} - {message} + const toast = ( +
+
+ {isSuccess ? ( + + ) : ( + + )} + {message} +
); + + return createPortal(toast, document.body); } diff --git a/src/components/voting/voting-detail-view.tsx b/src/components/voting/voting-detail-view.tsx index c4889cac..4117c446 100644 --- a/src/components/voting/voting-detail-view.tsx +++ b/src/components/voting/voting-detail-view.tsx @@ -37,7 +37,7 @@ import { useBalanceGameDetailQuery, useBalanceGameCommentsQuery, } from '@/features/study/one-to-one/balance-game/model/use-balance-game-query'; -import { useAuth } from '@/hooks/common/use-auth'; +import { useAuthReady } from '@/hooks/common/use-auth'; import { useUserStore } from '@/stores/useUserStore'; import { BalanceGameComment } from '@/types/balance-game'; import { @@ -64,10 +64,12 @@ export default function VotingDetailView({ const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false); const [showShareToast, setShowShareToast] = useState(false); + const [shareToastMessage, setShareToastMessage] = + useState('링크가 복사되었습니다.'); // User Info const memberId = useUserStore((state) => state.memberId); - const { isAuthenticated } = useAuth(); + const { isAuthReady } = useAuthReady(); // Queries const { @@ -215,7 +217,19 @@ export default function VotingDetailView({ } }; - const fallbackShare = (shareUrl: string) => { + const openShareToast = (message: string) => { + setShareToastMessage(message); + setShowShareToast(false); + requestAnimationFrame(() => setShowShareToast(true)); + }; + + const openManualCopyPrompt = (shareUrl: string) => { + const result = window.prompt('링크를 복사해 공유하세요.', shareUrl); + + return typeof result === 'string'; + }; + + const fallbackCopy = (shareUrl: string) => { try { const textarea = document.createElement('textarea'); textarea.value = shareUrl; @@ -226,48 +240,40 @@ export default function VotingDetailView({ textarea.select(); const copied = document.execCommand('copy'); document.body.removeChild(textarea); - if (copied) { - setShowShareToast(true); - - return; - } + if (copied) return true; } catch (error) { // ignore and fallback } - window.prompt('링크를 복사해 공유하세요.', shareUrl); + return false; }; - const handleShare = () => { + const handleShare = async () => { if (!voting) return; const shareUrl = window.location.href; - if (navigator.share) { - navigator - .share({ - title: voting.title, - url: shareUrl, - }) - .catch((error) => { - if (error instanceof DOMException && error.name === 'AbortError') { - return; - } - fallbackShare(shareUrl); - }); + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(shareUrl); + openShareToast('링크가 복사되었습니다.'); - return; + return; + } catch (error) { + // continue to fallback + } } - if (navigator.clipboard?.writeText) { - navigator.clipboard - .writeText(shareUrl) - .then(() => setShowShareToast(true)) - .catch(() => fallbackShare(shareUrl)); + const copied = fallbackCopy(shareUrl); + if (copied) { + openShareToast('링크가 복사되었습니다.'); return; } - fallbackShare(shareUrl); + const manualCopy = openManualCopyPrompt(shareUrl); + if (manualCopy) { + openShareToast('링크가 복사되었습니다.'); + } }; // Check if current user is author @@ -325,7 +331,7 @@ export default function VotingDetailView({ isActive, }); - const showVoteOptions = isAuthenticated && !hasVoted && isActive; + const showVoteOptions = isAuthReady && !hasVoted && isActive; return (
@@ -370,7 +376,7 @@ export default function VotingDetailView({
-
+
- {!isAuthenticated && ( + {!isAuthReady && (
{/* 일별 통계 (투표 후에만 표시) */} - {isAuthenticated && + {isAuthReady && hasVoted && voting.dailyStats && voting.dailyStats.length > 0 && ( @@ -638,47 +644,65 @@ export default function VotingDetailView({ 댓글 {commentTotalCount}
- {/* 댓글 목록 (항상 표시) */} -
- - - {hasNextPage && ( - - )} -
- - {/* 댓글 작성 폼 */} - {isAuthenticated && isActive && ( + {!isAuthReady ? ( +
+ +

+ 회원가입 후 댓글을 확인할 수 있습니다 +

+ + 회원가입 후 댓글 보기 + + } + /> +
+ ) : ( <> - {!hasVoted ? ( -
-

- 투표 후 댓글을 작성할 수 있습니다 -

-
- ) : editingCommentId === null ? ( -
- -
- ) : null} + {/* 댓글 목록 */} +
+ + + {hasNextPage && ( + + )} +
+ + {/* 댓글 작성 폼 */} + {isActive && ( + <> + {!hasVoted ? ( +
+

+ 투표 후 댓글을 작성할 수 있습니다 +

+
+ ) : editingCommentId === null ? ( +
+ +
+ ) : null} + + )} )}
@@ -728,7 +752,7 @@ export default function VotingDetailView({ setShowShareToast(false)} /> diff --git a/src/entities/user/model/use-user-profile-query.ts b/src/entities/user/model/use-user-profile-query.ts index 2837f0f5..050e8933 100644 --- a/src/entities/user/model/use-user-profile-query.ts +++ b/src/entities/user/model/use-user-profile-query.ts @@ -12,7 +12,7 @@ export const useUserProfileQuery = (memberId: number) => { queryKey: ['userProfile', memberId], queryFn: () => getUserProfile(memberId), enabled: !!memberId, - staleTime: 1000 * 60 * 5, + staleTime: 0, }); }; diff --git a/src/features/auth/model/use-auth-mutation.ts b/src/features/auth/model/use-auth-mutation.ts index 4506540b..61e36167 100644 --- a/src/features/auth/model/use-auth-mutation.ts +++ b/src/features/auth/model/use-auth-mutation.ts @@ -5,6 +5,8 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useRouter } from 'next/navigation'; import { deleteCookie, getCookie } from '@/api/client/cookie'; import { logout, signUp, uploadProfileImage } from '@/features/auth/api/auth'; +// 로그아웃 시 인증 상태 리셋을 위해 store 직접 사용 (mutation 내부 사용) +import { usePhoneVerificationStore } from '@/features/phone-verification/model/store'; import { hashValue } from '@/utils/hash'; import { SignUpRequest, SignUpResponse } from './types'; import { useUserStore } from '../../../stores/useUserStore'; @@ -31,6 +33,9 @@ export const useLogoutMutation = () => { const queryClient = useQueryClient(); const router = useRouter(); const resetUserStore = useUserStore((state) => state.reset); + const resetPhoneVerification = usePhoneVerificationStore( + (state) => state.reset, + ); return useMutation({ mutationFn: logout, @@ -49,6 +54,7 @@ export const useLogoutMutation = () => { deleteCookie('socialImageURL'); resetUserStore(); + resetPhoneVerification(); queryClient.clear(); router.push('/home'); diff --git a/src/features/auth/ui/header-user-dropdown.tsx b/src/features/auth/ui/header-user-dropdown.tsx index e6f6fc0a..66616cb0 100644 --- a/src/features/auth/ui/header-user-dropdown.tsx +++ b/src/features/auth/ui/header-user-dropdown.tsx @@ -8,14 +8,14 @@ import { DropdownMenuTrigger, } from '@/components/ui/(shadcn)/ui/dropdown-menu'; import UserAvatar from '@/components/ui/avatar'; -import { useAuth } from '@/hooks/common/use-auth'; +import { useAuthReady } from '@/hooks/common/use-auth'; import { useLogoutMutation } from '../model/use-auth-mutation'; export default function HeaderUserDropdown({ userImg }: { userImg: string }) { const { mutateAsync: logout } = useLogoutMutation(); - const { data: authData } = useAuth(); + const { data: authData, isAuthReady } = useAuthReady(); - const hasAdminRole = authData?.roleIds.includes('ROLE_ADMIN'); + const hasAdminRole = isAuthReady && authData?.roleIds.includes('ROLE_ADMIN'); const router = useRouter(); diff --git a/src/features/my-page/ui/profile.tsx b/src/features/my-page/ui/profile.tsx index 6fc1a189..d629951a 100644 --- a/src/features/my-page/ui/profile.tsx +++ b/src/features/my-page/ui/profile.tsx @@ -1,7 +1,7 @@ 'use client'; import Image from 'next/image'; -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import { cn } from '@/components/ui/(shadcn)/lib/utils'; import Badge from '@/components/ui/badge'; import Progress from '@/components/ui/progress'; @@ -15,7 +15,7 @@ import PhoneIcon from '@/features/my-page/ui/icon/phone.svg'; import TechStackIcon from '@/features/my-page/ui/icon/tech-stack.svg'; import VerifiedCheckIcon from '@/features/my-page/ui/icon/verified-check.svg'; import ProfileEditModal from '@/features/my-page/ui/profile-edit-modal'; -import { usePhoneVerificationStore } from '@/features/phone-verification/model/store'; +import { usePhoneVerificationStatus } from '@/features/phone-verification/model/use-phone-verification-status'; import PhoneVerificationModal from '@/features/phone-verification/ui/phone-verification-modal'; import { formatPhoneNumber } from '@/utils/format'; @@ -34,24 +34,10 @@ export default function Profile({ }: ProfileProps) { const temperPreset = getSincerityPresetByLevelName(sincerityTemp.levelName); - // Zustand 스토어 사용 - const { isVerified, phoneNumber, setVerified, reset } = - usePhoneVerificationStore(); + const { isVerified, isLoading, isError, phoneNumber, setVerified } = + usePhoneVerificationStatus(memberId); const [isVerificationModalOpen, setIsVerificationModalOpen] = useState(false); - // 서버 데이터를 우선시: 서버에 전화번호가 있으면 인증된 것으로 간주, 없으면 스토어 초기화 - useEffect(() => { - if (memberProfile.tel) { - // 서버에 전화번호가 있으면 인증된 것으로 간주 - if (!isVerified || phoneNumber !== memberProfile.tel) { - setVerified(memberProfile.tel); - } - } else { - // 서버에 전화번호가 없으면 스토어 초기화 (로컬 스토리지에 남아있는 이전 값 제거) - reset(); - } - }, [memberProfile.tel, isVerified, phoneNumber, setVerified, reset]); - const handleVerificationComplete = (phone: string) => { setVerified(phone); }; @@ -87,7 +73,7 @@ export default function Profile({
{memberProfile.nickname ?? '닉네임을 입력해주세요'} {/* 본인 인증 배지 (트위터 스타일) */} - {!hidePhoneNumber && (isVerified || !!memberProfile.tel) && ( + {!hidePhoneNumber && !isLoading && !isError && isVerified && ( )}
@@ -202,7 +188,7 @@ export default function Profile({
{/* 하단 영역: 전화번호 미인증 시에만 인증 요청 블록 표시 (hidePhoneNumber가 true면 숨김) */} - {!hidePhoneNumber && !isVerified && ( + {!hidePhoneNumber && !isLoading && !isError && !isVerified && (
diff --git a/src/features/phone-verification/model/store.ts b/src/features/phone-verification/model/store.ts index 44cd92ac..32eb3afd 100644 --- a/src/features/phone-verification/model/store.ts +++ b/src/features/phone-verification/model/store.ts @@ -4,32 +4,49 @@ import { persist } from 'zustand/middleware'; interface PhoneVerificationState { isVerified: boolean; phoneNumber: string | null; - verifiedAt: Date | null; - setVerified: (phoneNumber: string) => void; + verifiedAt: string | null; + memberId: number | null; + hasHydrated: boolean; + setVerified: (phoneNumber: string, memberId?: number) => void; reset: () => void; + setHasHydrated: (hasHydrated: boolean) => void; } export const usePhoneVerificationStore = create()( persist( (set) => ({ isVerified: false, - phoneNumber: '', - verifiedAt: new Date(), - setVerified: (phoneNumber) => + phoneNumber: null as string | null, + verifiedAt: null as string | null, + memberId: null as number | null, + hasHydrated: false, + setVerified: (phoneNumber, memberId) => set({ isVerified: true, phoneNumber, - verifiedAt: new Date(), + verifiedAt: new Date().toISOString(), + ...(memberId !== undefined ? { memberId } : {}), }), reset: () => set({ isVerified: false, - phoneNumber: null, - verifiedAt: null, + phoneNumber: null as string | null, + verifiedAt: null as string | null, + memberId: null as number | null, }), + setHasHydrated: (hasHydrated) => set({ hasHydrated }), }), { - name: 'phone-verification-storage', // 로컬 스토리지에 저장 + name: 'phone-verification-storage', + partialize: (state) => ({ + isVerified: state.isVerified, + phoneNumber: state.phoneNumber, + verifiedAt: state.verifiedAt, + memberId: state.memberId, + }), + onRehydrateStorage: () => (state) => { + state?.setHasHydrated(true); + }, }, ), ); diff --git a/src/features/phone-verification/model/use-phone-auth-mutation.ts b/src/features/phone-verification/model/use-phone-auth-mutation.ts index d36f3072..2e0b0a9b 100644 --- a/src/features/phone-verification/model/use-phone-auth-mutation.ts +++ b/src/features/phone-verification/model/use-phone-auth-mutation.ts @@ -1,6 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useRouter } from 'next/navigation'; import { getCookie } from '@/api/client/cookie'; +// mutation 내부에서는 store 직접 사용 허용 (API 성공 시 즉시 업데이트 목적) import { usePhoneVerificationStore } from './store'; import { sendPhoneVerificationCode, verifyPhoneCode } from '../api/phone-auth'; import type { @@ -38,12 +39,12 @@ export const useVerifyPhoneCodeMutation = (memberId?: number) => { mutationFn: verifyPhoneCode, onSuccess: async (data, variables) => { if (data.success) { - // 인증 상태 저장 - setVerified(variables.phoneNumber); - // 현재 사용자의 memberId 가져오기 (쿠키에서) const currentMemberId = memberId ?? Number(getCookie('memberId')); + // 인증 상태 저장 (memberId 포함) + setVerified(variables.phoneNumber, currentMemberId || undefined); + // 프로필 정보 쿼리키 갱신 if (currentMemberId) { await queryClient.invalidateQueries({ diff --git a/src/features/phone-verification/model/use-phone-verification-status.ts b/src/features/phone-verification/model/use-phone-verification-status.ts index d6f2916c..c542c2e4 100644 --- a/src/features/phone-verification/model/use-phone-verification-status.ts +++ b/src/features/phone-verification/model/use-phone-verification-status.ts @@ -1,8 +1,9 @@ 'use client'; -import { useEffect, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useUserProfileQuery } from '@/entities/user/model/use-user-profile-query'; -import { useAuth } from '@/hooks/common/use-auth'; +import { useAuthReady } from '@/hooks/common/use-auth'; +// 내부 구현에서는 store 직접 사용 허용 (서버 상태와 동기화 목적) import { usePhoneVerificationStore } from './store'; /** @@ -17,6 +18,19 @@ import { usePhoneVerificationStore } from './store'; */ export { usePhoneVerificationStore } from './store'; +interface UsePhoneVerificationStatusReturn { + /** 서버 기준 인증 여부 (로딩 중에만 로컬 캐시 fallback) */ + isVerified: boolean; + /** 인증된 전화번호 */ + phoneNumber: string | null; + /** 프로필 쿼리/스토어 하이드레이션 대기 여부 */ + isLoading: boolean; + /** 프로필 쿼리 실패 여부 */ + isError: boolean; + /** 인증 완료 시 스토어 즉시 업데이트용 */ + setVerified: (phoneNumber: string) => void; +} + /** * 본인인증 상태를 서버 데이터와 동기화하여 반환하는 훅 * @@ -29,100 +43,160 @@ export { usePhoneVerificationStore } from './store'; * - 서버의 userProfile.isVerified 또는 memberProfile.tel을 단일 진실 공급원으로 사용 * - Zustand 상태는 UI 반응성을 위한 캐시로만 활용 * - 서버 데이터로 Zustand 상태를 자동 동기화 + * - memberId 변경(계정 전환) 시 로컬 스토어 자동 리셋 + * + * 사용법: + * ```tsx + * const { isVerified, isLoading, isError, setVerified } = usePhoneVerificationStatus(memberId); + * + * // 로딩 중 처리 + * if (isLoading) return ; + * + * // 에러 처리 + * if (isError) return ; * - * @param overrideMemberId - 특정 memberId로 조회할 때 사용 (선택) + * // 인증 여부에 따른 UI + * if (!isVerified) { + * return ; + * } + * + * // 인증 완료 후 동작 + * return ; + * ``` + * + * @param overrideMemberId - 특정 memberId로 조회할 때 사용 (선택). 미지정 시 현재 로그인 유저 */ -export function usePhoneVerificationStatus(overrideMemberId?: number) { - const { data: authData } = useAuth(); - const memberId = overrideMemberId ?? authData?.memberId ?? null; - - const { data: userProfile, isLoading: isProfileLoading } = - useUserProfileQuery(memberId ?? 0); +export function usePhoneVerificationStatus( + overrideMemberId?: number, +): UsePhoneVerificationStatusReturn { + const { memberId: authMemberId } = useAuthReady(); + const memberId = overrideMemberId ?? authMemberId ?? null; const { - isVerified: zustandIsVerified, - phoneNumber: zustandPhoneNumber, - setVerified, - reset, - } = usePhoneVerificationStore(); + data: userProfile, + isLoading: isProfileLoading, + isError: isProfileError, + isSuccess: isProfileSuccess, + } = useUserProfileQuery(memberId ?? 0); + + const store = usePhoneVerificationStore(); // 서버 데이터 기반 인증 상태 계산 - const serverIsVerified = userProfile?.isVerified ?? false; + const serverHasIsVerified = typeof userProfile?.isVerified === 'boolean'; + const serverIsVerified = serverHasIsVerified + ? userProfile!.isVerified + : false; const serverPhoneNumber = userProfile?.memberProfile?.tel ?? null; + // 스토어가 현재 유저 소유인지 확인 (계정 전환 대응) + const storeMatchesUser = memberId !== null && store.memberId === memberId; + const canUseStoreVerified = + storeMatchesUser && store.hasHydrated && store.isVerified; + const hasServerProfile = isProfileSuccess && !!userProfile; + // 서버 데이터를 우선시하여 최종 인증 상태 결정 // 서버에 tel이 있으면 인증된 것으로 간주 (isVerified보다 tel 존재 여부가 더 확실한 지표) - const isVerified = useMemo(() => { - // 프로필 로딩 중일 때는 Zustand 상태를 임시로 사용 (UI 깜빡임 방지) - if (isProfileLoading && !userProfile) { - return zustandIsVerified; - } + const resolvedServerIsVerified = serverHasIsVerified + ? serverIsVerified + : !!serverPhoneNumber; - // 서버에 전화번호가 있으면 인증 완료 - if (serverPhoneNumber) { - return true; - } + // 프로필 로딩 중일 때는 Zustand 상태를 임시로 사용 (UI 깜빡임 방지) + const isLoading = + !!memberId && !isProfileError && !hasServerProfile && !canUseStoreVerified; + + const isVerified = useMemo(() => { + // 비로그인 상태 + if (!memberId) return false; - // 서버의 isVerified 플래그 확인 - if (serverIsVerified) { - return true; + // 서버 데이터 도착 → 서버 기준 (단일 진실 공급원) + if (hasServerProfile) { + return resolvedServerIsVerified; } - // 서버 데이터가 없으면 미인증 - return false; + // 로딩 중 → 같은 유저의 인증 완료 캐시만 fallback (UI 깜빡임 방지) + return canUseStoreVerified; }, [ - isProfileLoading, - userProfile, - zustandIsVerified, - serverPhoneNumber, - serverIsVerified, + memberId, + hasServerProfile, + resolvedServerIsVerified, + canUseStoreVerified, ]); const phoneNumber = useMemo(() => { - if (isProfileLoading && !userProfile) { - return zustandPhoneNumber; + if (!memberId) return null; + + // 서버 데이터 우선 + if (hasServerProfile) { + return serverPhoneNumber; } - return serverPhoneNumber ?? null; - }, [isProfileLoading, userProfile, zustandPhoneNumber, serverPhoneNumber]); + // 로딩 중 캐시 fallback + return canUseStoreVerified ? store.phoneNumber : null; + }, [ + memberId, + hasServerProfile, + serverPhoneNumber, + canUseStoreVerified, + store.phoneNumber, + ]); - // 서버 데이터와 Zustand 상태 동기화 + // 서버 → 스토어 동기화 useEffect(() => { - if (isProfileLoading || !userProfile) return; + if (isProfileLoading || !userProfile || !memberId) return; - if (serverPhoneNumber) { - // 서버에 전화번호가 있으면 Zustand도 동기화 - if (!zustandIsVerified || zustandPhoneNumber !== serverPhoneNumber) { - setVerified(serverPhoneNumber); + const current = usePhoneVerificationStore.getState(); + + if (resolvedServerIsVerified) { + // 서버가 인증됨 → 스토어 갱신 (memberId 포함) + if ( + !current.isVerified || + current.memberId !== memberId || + current.phoneNumber !== serverPhoneNumber + ) { + store.setVerified(serverPhoneNumber ?? '', memberId); } - } else if (!serverIsVerified) { - // 서버에서 인증이 해제되었으면 Zustand도 초기화 - if (zustandIsVerified) { - reset(); + } else { + // 서버가 미인증 → 같은 유저 캐시 제거 + if (current.isVerified && current.memberId === memberId) { + store.reset(); } } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ isProfileLoading, userProfile, - serverPhoneNumber, + memberId, serverIsVerified, - zustandIsVerified, - zustandPhoneNumber, - setVerified, - reset, + serverPhoneNumber, + resolvedServerIsVerified, ]); + // memberId 변경(계정 전환 / 로그아웃) 시 스토어 리셋 + const prevMemberIdRef = useRef(memberId); + useEffect(() => { + const prev = prevMemberIdRef.current; + prevMemberIdRef.current = memberId; + + if (prev !== null && prev !== memberId) { + store.reset(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [memberId]); + + // 인증 완료 시 호출 — memberId를 자동 주입 + const setVerified = useCallback( + (phone: string) => { + store.setVerified(phone, memberId ?? undefined); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [memberId], + ); + return { isVerified, phoneNumber, - isLoading: isProfileLoading, - // 원본 상태들 (디버깅용) - _serverIsVerified: serverIsVerified, - _serverPhoneNumber: serverPhoneNumber, - _zustandIsVerified: zustandIsVerified, - _zustandPhoneNumber: zustandPhoneNumber, - // Zustand 액션 (인증 완료 시 호출용) + isLoading, + isError: isProfileError, setVerified, - reset, }; } diff --git a/src/features/phone-verification/ui/phone-verification-modal.tsx b/src/features/phone-verification/ui/phone-verification-modal.tsx index e918e608..0e06dc3d 100644 --- a/src/features/phone-verification/ui/phone-verification-modal.tsx +++ b/src/features/phone-verification/ui/phone-verification-modal.tsx @@ -59,11 +59,15 @@ export default function PhoneVerificationModal({ if (!open) { setTimeout(() => { setStep('input'); - setName(''); // 이름 초기화 + setName(''); setPhoneNumber(''); setCode(''); setError(null); setTimer(180); + setFailCount(0); + setIsResending(false); + setResendMessage(null); + setIsShaking(false); }, 300); } }, [open]); diff --git a/src/features/study/group/channel/ui/comment-section.tsx b/src/features/study/group/channel/ui/comment-section.tsx index c7191503..f5df6ade 100644 --- a/src/features/study/group/channel/ui/comment-section.tsx +++ b/src/features/study/group/channel/ui/comment-section.tsx @@ -47,6 +47,7 @@ export default function CommentSection({ groupStudyId }: CommentProps) { (sum, q) => sum + (q.data?.totalElements ?? 0), 0, ); + const totalCommentCount = (data?.totalElements ?? 0) + totalReplyCount; const { mutate: createThread } = usePostThreadMutation(); diff --git a/src/features/study/group/ui/apply-group-study-modal.tsx b/src/features/study/group/ui/apply-group-study-modal.tsx index d8a25e60..257e1339 100644 --- a/src/features/study/group/ui/apply-group-study-modal.tsx +++ b/src/features/study/group/ui/apply-group-study-modal.tsx @@ -10,6 +10,7 @@ import Checkbox from '@/components/ui/checkbox'; import { Modal } from '@/components/ui/modal'; import { usePhoneVerificationStatus } from '@/features/phone-verification/model/use-phone-verification-status'; import PhoneVerificationModal from '@/features/phone-verification/ui/phone-verification-modal'; +import { useAuthReady } from '@/hooks/common/use-auth'; import { useToastStore } from '@/stores/use-toast-store'; import { GroupStudyDetailResponse } from '../api/group-study-types'; import { @@ -34,11 +35,16 @@ export default function ApplyGroupStudyModal({ onSuccess, }: ApplyGroupStudyModalProps) { const [open, setOpen] = useState(false); - // 서버 상태와 동기화된 인증 상태 사용 - const { isVerified, setVerified } = usePhoneVerificationStatus(); + const { memberId } = useAuthReady(); + const { isVerified, isLoading, setVerified } = usePhoneVerificationStatus( + memberId ?? undefined, + ); const [isVerificationModalOpen, setIsVerificationModalOpen] = useState(false); const handleOpenChange = (isOpen: boolean) => { + if (isOpen && isLoading) { + return; + } if (isOpen && !isVerified) { setIsVerificationModalOpen(true); setOpen(false); @@ -50,6 +56,7 @@ export default function ApplyGroupStudyModal({ const handleVerificationComplete = (phoneNumber: string) => { setVerified(phoneNumber); + setIsVerificationModalOpen(false); setOpen(true); }; @@ -95,6 +102,7 @@ export default function ApplyGroupStudyModal({ open={isVerificationModalOpen} onOpenChange={setIsVerificationModalOpen} onVerificationComplete={handleVerificationComplete} + memberId={memberId ?? undefined} /> ); diff --git a/src/features/study/group/ui/group-study-form-modal.tsx b/src/features/study/group/ui/group-study-form-modal.tsx index 9df04ea6..f38a8e63 100644 --- a/src/features/study/group/ui/group-study-form-modal.tsx +++ b/src/features/study/group/ui/group-study-form-modal.tsx @@ -10,6 +10,7 @@ import { GroupStudyFullResponseDto } from '@/api/openapi'; import { Modal } from '@/components/ui/modal'; import { usePhoneVerificationStatus } from '@/features/phone-verification/model/use-phone-verification-status'; import PhoneVerificationModal from '@/features/phone-verification/ui/phone-verification-modal'; +import { useAuthReady } from '@/hooks/common/use-auth'; import GroupStudyForm from './group-study-form'; import { @@ -47,30 +48,42 @@ export default function GroupStudyFormModal({ const router = useRouter(); const qc = useQueryClient(); const [open, setOpen] = useState(false); + const { memberId } = useAuthReady(); const { mutateAsync: createGroupStudy } = useCreateGroupStudyMutation(); const { mutateAsync: updateGroupStudy } = useUpdateGroupStudyMutation( groupStudyId!, ); const { data: groupStudyInfo, - isLoading, + isLoading: isGroupStudyLoading, refetch: refetchGroupStudyInfo, } = useGroupStudyDetailQuery(groupStudyId!); - // 서버 상태와 동기화된 인증 상태 사용 - const { isVerified, setVerified } = usePhoneVerificationStatus(); + const { + isVerified, + isLoading: isVerificationLoading, + isError: isVerificationError, + setVerified, + } = usePhoneVerificationStatus(memberId ?? undefined); const [isVerificationModalOpen, setIsVerificationModalOpen] = useState(false); const handleVerificationComplete = (phoneNumber: string) => { setVerified(phoneNumber); - // 인증 완료 후 모달 열기 + setIsVerificationModalOpen(false); if (mode === 'create') { setOpen(true); } - // edit 모드일 때는 외부 제어라 호출자가 처리해야 함 (보통 edit는 이미 인증된 유저) }; const handleOpenChange = (isOpen: boolean) => { + if (mode === 'create' && isOpen && isVerificationLoading) { + return; + } + if (mode === 'create' && isOpen && isVerificationError) { + alert('인증 상태를 확인할 수 없습니다. 잠시 후 다시 시도해주세요.'); + + return; + } if (isOpen && mode === 'create' && !isVerified) { setIsVerificationModalOpen(true); setOpen(false); // 스터디 모달은 닫힘 유지 @@ -86,7 +99,7 @@ export default function GroupStudyFormModal({ }; const refineStudyDetail = (value: GroupStudyFullResponseDto) => { - if (isLoading) return; + if (isGroupStudyLoading) return; const refinedClassification = value.basicInfo?.classification ?? classification; @@ -253,6 +266,7 @@ export default function GroupStudyFormModal({ open={isVerificationModalOpen} onOpenChange={setIsVerificationModalOpen} onVerificationComplete={handleVerificationComplete} + memberId={memberId ?? undefined} /> ); diff --git a/src/features/study/group/ui/group-study-member-item.tsx b/src/features/study/group/ui/group-study-member-item.tsx index 73bbd527..836f6832 100644 --- a/src/features/study/group/ui/group-study-member-item.tsx +++ b/src/features/study/group/ui/group-study-member-item.tsx @@ -9,7 +9,7 @@ import UserAvatar from '@/components/ui/avatar'; import Button from '@/components/ui/button'; import UserProfileModal from '@/entities/user/ui/user-profile-modal'; -import { useAuth } from '@/hooks/common/use-auth'; +import { useAuthReady } from '@/hooks/common/use-auth'; import BronzeRankIcon from 'public/icons/bronze-rank.svg'; import CaretDownIcon from 'public/icons/caret-down.svg'; import CaretUpIcon from 'public/icons/caret-up.svg'; @@ -31,8 +31,7 @@ export default function GroupStudyMemberItem({ leaderId, ...member }: GroupStudyMemberItemProps) { - const { data: authData } = useAuth(); - const myId = authData?.memberId; + const { memberId: myId, isAuthReady } = useAuthReady(); const [isProgressHistoryOpen, setIsProgressHistoryOpen] = useState(false); @@ -40,7 +39,7 @@ export default function GroupStudyMemberItem({ useState(false); const isMe = member.id === myId; - const isLeader = leaderId === myId; + const isLeader = isAuthReady && leaderId === myId; // 재량 평가 받은 횟수 (3번까지만 받을 수 있음) const discretionCount = member.progress.discretionGradeHistory.length; @@ -212,7 +211,7 @@ function GreetingBox({ }: Pick & { groupStudyId: number; }) { - const { data: authData } = useAuth(); + const { memberId: authMemberId, isAuthReady } = useAuthReady(); // 가입인사를 작성한 경우 if (greeting) { @@ -220,7 +219,7 @@ function GreetingBox({ } // 가입인사를 작성하지 못한 경우 - const isMe = id === authData?.memberId; + const isMe = isAuthReady && id === authMemberId; if (isMe) { return ( diff --git a/src/features/study/one-to-one/archive/model/use-archive-actions.ts b/src/features/study/one-to-one/archive/model/use-archive-actions.ts index fa6ab8e0..ade3a8ad 100644 --- a/src/features/study/one-to-one/archive/model/use-archive-actions.ts +++ b/src/features/study/one-to-one/archive/model/use-archive-actions.ts @@ -5,14 +5,14 @@ import { useToggleArchiveLikeMutation } from '@/features/study/one-to-one/archiv import { useUpdateArchiveMutation } from '@/features/study/one-to-one/archive/model/use-update-archive-mutation'; import { useRecordArchiveViewMutation } from '@/features/study/one-to-one/archive/model/use-view-mutation'; import { useToggleArchiveVisibilityMutation } from '@/features/study/one-to-one/archive/model/use-visibility-mutation'; -import { useAuth } from '@/hooks/common/use-auth'; +import { useAuthReady } from '@/hooks/common/use-auth'; interface ArchiveViewTarget { id: number; link: string; } export const useArchiveActions = () => { - const { isAuthenticated } = useAuth(); + const { isAuthReady } = useAuthReady(); const { mutate: toggleBookmark } = useToggleArchiveBookmarkMutation(); const { mutate: toggleLike } = useToggleArchiveLikeMutation(); const { mutate: recordView } = useRecordArchiveViewMutation(); @@ -21,43 +21,43 @@ export const useArchiveActions = () => { const handleToggleBookmark = useCallback( (id: number) => { - if (!isAuthenticated) return; + if (!isAuthReady) return; toggleBookmark(id); }, - [isAuthenticated, toggleBookmark], + [isAuthReady, toggleBookmark], ); const handleToggleLike = useCallback( (id: number) => { - if (!isAuthenticated) return; + if (!isAuthReady) return; toggleLike(id); }, - [isAuthenticated, toggleLike], + [isAuthReady, toggleLike], ); const openAndRecordView = useCallback( (target: ArchiveViewTarget) => { window.open(target.link, '_blank'); - if (!isAuthenticated) return; + if (!isAuthReady) return; recordView(target.id); }, - [isAuthenticated, recordView], + [isAuthReady, recordView], ); const handleToggleVisibility = useCallback( (id: number, isPrivate: boolean) => { - if (!isAuthenticated) return; + if (!isAuthReady) return; toggleVisibility({ id, isPrivate }); }, - [isAuthenticated, toggleVisibility], + [isAuthReady, toggleVisibility], ); const handleUpdateArchive = useCallback( (id: number, request: UpdateArchiveRequest) => { - if (!isAuthenticated) return; + if (!isAuthReady) return; updateArchive({ id, request }); }, - [isAuthenticated, updateArchive], + [isAuthReady, updateArchive], ); return { @@ -66,6 +66,6 @@ export const useArchiveActions = () => { toggleVisibility: handleToggleVisibility, updateArchive: handleUpdateArchive, openAndRecordView, - isAuthenticated, + isAuthenticated: isAuthReady, }; }; diff --git a/src/features/study/one-to-one/archive/ui/archive-tab.tsx b/src/features/study/one-to-one/archive/ui/archive-tab.tsx index ef75951d..38652f55 100644 --- a/src/features/study/one-to-one/archive/ui/archive-tab.tsx +++ b/src/features/study/one-to-one/archive/ui/archive-tab.tsx @@ -1,6 +1,8 @@ import { getArchiveServer } from '@/features/study/one-to-one/archive/api/get-archive.server'; import { ARCHIVE_PAGE_SIZE } from '@/features/study/one-to-one/archive/const/archive'; import { GetArchiveParams } from '@/types/archive'; +import { safeServerPrefetch } from '@/utils/safe-server-prefetch'; +import { getServerCookie } from '@/utils/server-cookie'; import ArchiveTabClient from './archive-tab-client'; export default async function ArchiveTab() { @@ -10,7 +12,12 @@ export default async function ArchiveTab() { sort: 'LATEST', }; - const initialData = await getArchiveServer(initialParams); + const accessToken = await getServerCookie('accessToken'); + const initialData = accessToken + ? await safeServerPrefetch(() => getArchiveServer(initialParams), { + logLabel: 'Archive prefetch', + }) + : undefined; return ( diff --git a/src/features/study/one-to-one/balance-game/ui/community-tab-client.tsx b/src/features/study/one-to-one/balance-game/ui/community-tab-client.tsx index dbd5fbad..2f11a6e3 100644 --- a/src/features/study/one-to-one/balance-game/ui/community-tab-client.tsx +++ b/src/features/study/one-to-one/balance-game/ui/community-tab-client.tsx @@ -15,7 +15,7 @@ import { useBalanceGameListQuery, useBalanceGameTagSuggestionsQuery, } from '@/features/study/one-to-one/balance-game/model/use-balance-game-query'; -import { useAuth } from '@/hooks/common/use-auth'; +import { useAuthReady } from '@/hooks/common/use-auth'; import { useDebounce } from '@/hooks/use-debounce'; import { useScrollToHomeContentOnChange, @@ -41,7 +41,7 @@ export default function CommunityTabClient({ const router = useRouter(); const searchParams = useSearchParams(); const scrollToHomeContent = useScrollToHomeContentWithStabilize(); - const { isAuthenticated } = useAuth(); + const { isAuthReady } = useAuthReady(); // 상태 관리 const { statusFilter, @@ -224,6 +224,7 @@ export default function CommunityTabClient({ title="밸런스게임" icon={} description="다양한 주제에 투표하고 댓글로 자유롭게 토론할 수 있습니다." + descriptionClassName="font-designer-15r" /> setIsCreateModalOpen(true)} className="rounded-100 bg-fill-brand-default-default font-designer-13b text-text-inverse shadow-1 hover:bg-fill-brand-default-hover hover:shadow-2 flex items-center gap-100 px-400 py-200 transition-all hover:scale-105" diff --git a/src/features/study/one-to-one/balance-game/ui/community-tab.tsx b/src/features/study/one-to-one/balance-game/ui/community-tab.tsx index 6988f14b..620ecc16 100644 --- a/src/features/study/one-to-one/balance-game/ui/community-tab.tsx +++ b/src/features/study/one-to-one/balance-game/ui/community-tab.tsx @@ -1,13 +1,18 @@ import { getBalanceGameListServer } from '@/features/study/one-to-one/balance-game/api/balance-game-api.server'; +import { safeServerPrefetch } from '@/utils/safe-server-prefetch'; import CommunityTabClient from './community-tab-client'; export default async function CommunityTab() { - const initialList = await getBalanceGameListServer({ - page: 1, - size: 10, - sort: 'latest', - status: 'active', - }); + const initialList = await safeServerPrefetch( + () => + getBalanceGameListServer({ + page: 1, + size: 10, + sort: 'latest', + status: 'active', + }), + { logLabel: 'Balance game prefetch' }, + ); return ; } diff --git a/src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-tab.tsx b/src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-tab.tsx index 792724ae..af15302e 100644 --- a/src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-tab.tsx +++ b/src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-tab.tsx @@ -1,8 +1,11 @@ import { getHallOfFameServer } from '@/features/study/one-to-one/hall-of-fame/api/hall-of-fame-api.server'; +import { safeServerPrefetch } from '@/utils/safe-server-prefetch'; import HallOfFameTabClient from './hall-of-fame-tab-client'; export default async function HallOfFameTab() { - const initialData = await getHallOfFameServer(); + const initialData = await safeServerPrefetch(() => getHallOfFameServer(), { + logLabel: 'Hall of fame prefetch', + }); return ; } diff --git a/src/features/study/one-to-one/schedule/ui/study-card.tsx b/src/features/study/one-to-one/schedule/ui/study-card.tsx index efcc75dc..7af15eac 100644 --- a/src/features/study/one-to-one/schedule/ui/study-card.tsx +++ b/src/features/study/one-to-one/schedule/ui/study-card.tsx @@ -10,7 +10,7 @@ import { import DateSelector from '@/features/study/one-to-one/schedule/ui/data-selector'; import TodayStudyCard from '@/features/study/one-to-one/schedule/ui/today-study-card'; import ReservationList from '@/features/study/participation/ui/reservation-list'; -import { useAuth } from '@/hooks/common/use-auth'; +import { useAuthReady } from '@/hooks/common/use-auth'; import { formatKoreaYMD, getKoreaDate, @@ -82,8 +82,8 @@ export default function StudyCard({ const studyDate = formatKoreaYMD(selectedDate); // 로그인 여부 확인 - const { data: authData } = useAuth(); - const isLoggedIn = !!authData?.memberId; + const { isAuthReady, memberId } = useAuthReady(); + const isLoggedIn = isAuthReady && !!memberId; // 공개 API const { data: status } = useStudyStatusQuery(); diff --git a/src/features/study/one-to-one/schedule/ui/today-study-card.tsx b/src/features/study/one-to-one/schedule/ui/today-study-card.tsx index 9cacf278..b9bdaa6c 100644 --- a/src/features/study/one-to-one/schedule/ui/today-study-card.tsx +++ b/src/features/study/one-to-one/schedule/ui/today-study-card.tsx @@ -14,7 +14,7 @@ import { getStatusBadge } from '@/features/study/interview/ui/status-badge-map'; import StudyDoneModal from '@/features/study/interview/ui/study-done-modal'; import StudyReadyModal from '@/features/study/interview/ui/study-ready-modal'; import { TUTORIAL_DAILY_STUDY_MOCK } from '@/features/study/one-to-one/schedule/model/tutorial-mock'; -import { useAuth } from '@/hooks/common/use-auth'; +import { useAuthReady } from '@/hooks/common/use-auth'; interface TodayStudyCardProps { studyDate: string; @@ -31,12 +31,12 @@ export default function TodayStudyCard({ forceOpenReadyModal, forceOpenDoneModal, }: TodayStudyCardProps) { - const { data: authData } = useAuth(); + const { memberId: authMemberId } = useAuthReady(); const memberId = tutorialMode ? forcedRole === 'INTERVIEWER' ? TUTORIAL_DAILY_STUDY_MOCK.interviewerId : TUTORIAL_DAILY_STUDY_MOCK.intervieweeId - : (authData?.memberId ?? null); + : (authMemberId ?? null); const queryStudyDate = tutorialMode ? '' : studyDate; const { data: todayStudyData } = useDailyStudyDetailQuery(queryStudyDate); diff --git a/src/features/study/participation/ui/reservation-list.tsx b/src/features/study/participation/ui/reservation-list.tsx index f6bda17a..b98acf97 100644 --- a/src/features/study/participation/ui/reservation-list.tsx +++ b/src/features/study/participation/ui/reservation-list.tsx @@ -12,7 +12,7 @@ import { usePhoneVerificationStatus } from '@/features/phone-verification/model/ import PhoneVerificationModal from '@/features/phone-verification/ui/phone-verification-modal'; import ReservationCard from '@/features/study/participation/ui/reservation-user-card'; import StartStudyModal from '@/features/study/participation/ui/start-study-modal'; -import { useAuth } from '@/hooks/common/use-auth'; +import { useAuthReady } from '@/hooks/common/use-auth'; import { useInfiniteReservation } from '../model/use-participation-query'; interface ReservationListProps { @@ -29,17 +29,18 @@ export default function ReservationList({ week, }: ReservationListProps) { const sentinelRef = useRef(null); - const { data: authData } = useAuth(); - const memberId = authData?.memberId ?? null; + const { memberId } = useAuthReady(); const [isModalOpen, setIsModalOpen] = useState(false); const { data: userProfile } = useUserProfileQuery(memberId ?? 0); const autoMatching = userProfile?.autoMatching ?? false; - // 서버 상태와 동기화된 인증 상태 사용 - const { isVerified, setVerified } = usePhoneVerificationStatus( - memberId ?? undefined, - ); + const { + isVerified, + isLoading: isVerificationLoading, + isError: isVerificationError, + setVerified, + } = usePhoneVerificationStatus(memberId ?? undefined); const [isVerificationModalOpen, setIsVerificationModalOpen] = useState(false); const firstMemberId = useMemo( @@ -47,8 +48,13 @@ export default function ReservationList({ [autoMatching, memberId], ); - const { data, isLoading, isFetchingNextPage, fetchNextPage, hasNextPage } = - useInfiniteReservation(firstMemberId ?? undefined, pageSize); + const { + data, + isLoading: isReservationLoading, + isFetchingNextPage, + fetchNextPage, + hasNextPage, + } = useInfiniteReservation(firstMemberId ?? undefined, pageSize); const { mutate: patchAutoMatching, isPending } = usePatchAutoMatchingMutation(); @@ -92,6 +98,12 @@ export default function ReservationList({ const handleApplyClick = () => { if (!memberId) return; + if (isVerificationLoading) return; + if (isVerificationError) { + alert('인증 상태를 확인할 수 없습니다. 잠시 후 다시 시도해주세요.'); + + return; + } // 본인인증이 안되어 있으면 본인인증 모달 먼저 열기 if (!isVerified) { @@ -108,7 +120,7 @@ export default function ReservationList({ } }; - if (isLoading) { + if (isReservationLoading) { return (
불러오는 중… diff --git a/src/features/study/participation/ui/start-study-modal.tsx b/src/features/study/participation/ui/start-study-modal.tsx index afe63d90..7f4a8c7f 100644 --- a/src/features/study/participation/ui/start-study-modal.tsx +++ b/src/features/study/participation/ui/start-study-modal.tsx @@ -100,14 +100,22 @@ export default function StartStudyModal({ open, onOpenChange, }: StartStudyModalProps) { - // 서버 상태와 동기화된 인증 상태 사용 - const { isVerified, setVerified } = usePhoneVerificationStatus(memberId); + const { isVerified, isLoading, isError, setVerified } = + usePhoneVerificationStatus(memberId); const [isVerificationModalOpen, setIsVerificationModalOpen] = useState(false); const [internalOpen, setInternalOpen] = useState(false); const isModalOpen = open ?? internalOpen; const handleOpenChange = (isOpen: boolean) => { + if (isOpen && isLoading) { + return; + } + if (isOpen && isError) { + alert('인증 상태를 확인할 수 없습니다. 잠시 후 다시 시도해주세요.'); + + return; + } if (isOpen && !isVerified) { setIsVerificationModalOpen(true); if (onOpenChange) onOpenChange(false); @@ -132,7 +140,18 @@ export default function StartStudyModal({ // 모달이 열릴 때 인증 여부 체크 (외부 제어 open prop 대응) useEffect(() => { - if (isModalOpen && !isVerified) { + if (!isModalOpen || isLoading) return; + if (isError) { + alert('인증 상태를 확인할 수 없습니다. 잠시 후 다시 시도해주세요.'); + if (onOpenChange) { + onOpenChange(false); + } else { + setInternalOpen(false); + } + + return; + } + if (!isVerified) { setIsVerificationModalOpen(true); if (onOpenChange) { onOpenChange(false); @@ -140,7 +159,7 @@ export default function StartStudyModal({ setInternalOpen(false); } } - }, [isModalOpen, isVerified, onOpenChange]); + }, [isModalOpen, isVerified, isLoading, isError, onOpenChange]); const handleVerificationComplete = (phoneNumber: string) => { setVerified(phoneNumber); diff --git a/src/hooks/common/auth-hydration-context.tsx b/src/hooks/common/auth-hydration-context.tsx new file mode 100644 index 00000000..6a4b3f9c --- /dev/null +++ b/src/hooks/common/auth-hydration-context.tsx @@ -0,0 +1,27 @@ +'use client'; + +import React, { createContext, useContext } from 'react'; + +interface AuthHydrationContextValue { + initialAccessToken?: string; +} + +const AuthHydrationContext = createContext({}); + +export function AuthHydrationProvider({ + initialAccessToken, + children, +}: { + initialAccessToken?: string; + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} + +export function useAuthHydration() { + return useContext(AuthHydrationContext); +} diff --git a/src/hooks/common/use-auth.ts b/src/hooks/common/use-auth.ts index 9af3fd96..2bdcb2d2 100644 --- a/src/hooks/common/use-auth.ts +++ b/src/hooks/common/use-auth.ts @@ -4,10 +4,11 @@ // 한편, prefetchQuery 는 서버컴포넌트에서 사용하는 함수로 데이터를 미리 가져와서 캐시에 저장함 import { useQuery } from '@tanstack/react-query'; -import { useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { getCookie } from '@/api/client/cookie'; import { getMemberId } from '@/features/auth/api/auth'; import { decodeJwt } from '@/utils/jwt'; +import { useAuthHydration } from './auth-hydration-context'; // 회원 Id 조회 export const useMemberId = () => { @@ -35,6 +36,7 @@ interface UseAuthReturn { accessToken: string | undefined; data: DecodedToken | undefined; isAuthenticated: boolean; + isHydrated: boolean; } function isDecodedToken(value: unknown): value is DecodedToken { @@ -64,9 +66,17 @@ function isDecodedToken(value: unknown): value is DecodedToken { } export function useAuth(): UseAuthReturn { + const { initialAccessToken } = useAuthHydration(); + const [accessToken, setAccessToken] = useState( + initialAccessToken, + ); + const [isHydrated, setIsHydrated] = useState(!!initialAccessToken); + // getCookie는 클라이언트 사이드에서만 실행되어야 함 - const accessToken = - typeof window !== 'undefined' ? getCookie('accessToken') : undefined; + useEffect(() => { + setAccessToken(getCookie('accessToken')); + setIsHydrated(true); + }, []); const decodedToken: DecodedToken | undefined = useMemo(() => { if (!accessToken) return undefined; @@ -94,6 +104,30 @@ export function useAuth(): UseAuthReturn { return { accessToken, data: decodedToken, - isAuthenticated: !!accessToken && !!decodedToken && !isGuest, + isAuthenticated: isHydrated && !!accessToken && !!decodedToken && !isGuest, + isHydrated, + }; +} + +export interface UseAuthReadyReturn { + isHydrated: boolean; + isAuthenticated: boolean; + isAuthReady: boolean; + memberId?: number; + data?: DecodedToken; + accessToken?: string; +} + +export function useAuthReady(): UseAuthReadyReturn { + const { accessToken, data, isAuthenticated, isHydrated } = useAuth(); + const isAuthReady = isHydrated && isAuthenticated; + + return { + accessToken, + data, + isAuthenticated, + isHydrated, + isAuthReady, + memberId: isHydrated ? data?.memberId : undefined, }; } diff --git a/src/hooks/queries/group-study-member-api.ts b/src/hooks/queries/group-study-member-api.ts index 7f3ee3c5..65f57fdc 100644 --- a/src/hooks/queries/group-study-member-api.ts +++ b/src/hooks/queries/group-study-member-api.ts @@ -7,7 +7,7 @@ import type { } from '@/api/openapi/models'; // TEMPORARY: Keep until OpenAPI types fixed import type { GroupStudyMembersResponse } from '@/features/study/group/api/group-study-types'; -import { useAuth } from '../common/use-auth'; +import { useAuthReady } from '../common/use-auth'; const groupStudyMemberApi = createApiInstance(GroupStudyMemberApi); @@ -58,7 +58,7 @@ export const useGetGroupStudyMyStatus = ({ groupStudyId: number; isLeader: boolean; }) => { - const { data } = useAuth(); + const { memberId, isAuthReady } = useAuthReady(); return useQuery({ queryKey: ['groupStudyMemberStatus', groupStudyId], @@ -67,7 +67,8 @@ export const useGetGroupStudyMyStatus = ({ return data.content; }, - enabled: !!groupStudyId && !isLeader && data?.memberId !== undefined, + enabled: + !!groupStudyId && !isLeader && isAuthReady && memberId !== undefined, }); }; diff --git a/src/hooks/queries/notification-api.ts b/src/hooks/queries/notification-api.ts index 07cd6b5a..38d5be2d 100644 --- a/src/hooks/queries/notification-api.ts +++ b/src/hooks/queries/notification-api.ts @@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { createApiInstance } from '@/api/client/open-api-instance'; import { NotificationApi } from '@/api/openapi'; import type { GetMemberNotificationsTopicTypeEnum } from '@/api/openapi/api/notification-api'; -import { useAuth } from '../common/use-auth'; +import { useAuthReady } from '../common/use-auth'; const notificationApi = createApiInstance(NotificationApi); @@ -19,7 +19,7 @@ export const useGetNotifications = ({ hasRead, topicType, }: NotificationsParams = {}) => { - const { isAuthenticated } = useAuth(); + const { isAuthReady } = useAuthReady(); return useQuery({ queryKey: ['notifications', page, size, hasRead, topicType], @@ -34,12 +34,12 @@ export const useGetNotifications = ({ return data.content; }, refetchInterval: 60000, // 1 minute - enabled: !!isAuthenticated, + enabled: isAuthReady, }); }; export const useGetNotificationCategories = () => { - const { isAuthenticated } = useAuth(); + const { isAuthReady } = useAuthReady(); return useQuery({ queryKey: ['notificationCategories'], @@ -48,12 +48,12 @@ export const useGetNotificationCategories = () => { return data.content; }, - enabled: !!isAuthenticated, + enabled: isAuthReady, }); }; export const useHasNewNotification = () => { - const { isAuthenticated } = useAuth(); + const { isAuthReady } = useAuthReady(); return useQuery({ queryKey: ['hasNewNotification'], @@ -63,7 +63,7 @@ export const useHasNewNotification = () => { return data.content; }, refetchInterval: 60000, // 1 minute - enabled: !!isAuthenticated, + enabled: isAuthReady, }); }; diff --git a/src/providers/index.tsx b/src/providers/index.tsx index 89cf3cfd..3968e5a6 100644 --- a/src/providers/index.tsx +++ b/src/providers/index.tsx @@ -2,37 +2,41 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { useEffect } from 'react'; -import { useAuth } from '@/hooks/common/use-auth'; +import { AuthHydrationProvider } from '@/hooks/common/auth-hydration-context'; +import { useAuthReady } from '@/hooks/common/use-auth'; import QueryProvider from '@/providers/query-provider'; import { useUserStore } from '@/stores/useUserStore'; interface ProviderProps { children: React.ReactNode; + initialAccessToken?: string; } function UserInitializer({ children }: ProviderProps) { - const { data: authData, isAuthenticated } = useAuth(); + const { memberId: authMemberId, isAuthReady } = useAuthReady(); const { memberId, fetchAndSetUser } = useUserStore(); useEffect(() => { - if (isAuthenticated && authData?.memberId && !memberId) { - fetchAndSetUser(authData.memberId).catch(console.error); + if (isAuthReady && authMemberId && !memberId) { + fetchAndSetUser(authMemberId).catch(console.error); } - }, [isAuthenticated, authData?.memberId, memberId, fetchAndSetUser]); + }, [isAuthReady, authMemberId, memberId, fetchAndSetUser]); return <>{children}; } -function MainProvider({ children }: ProviderProps) { +function MainProvider({ children, initialAccessToken }: ProviderProps) { return ( - - - {children} - {process.env.NODE_ENV === 'development' && ( - - )} - - + + + + {children} + {process.env.NODE_ENV === 'development' && ( + + )} + + + ); } diff --git a/src/utils/safe-server-prefetch.ts b/src/utils/safe-server-prefetch.ts new file mode 100644 index 00000000..012caa38 --- /dev/null +++ b/src/utils/safe-server-prefetch.ts @@ -0,0 +1,22 @@ +type AsyncFn = () => Promise; + +interface SafeServerPrefetchOptions { + logLabel?: string; +} + +export async function safeServerPrefetch( + fn: AsyncFn, + options: SafeServerPrefetchOptions = {}, +): Promise { + try { + return await fn(); + } catch (error) { + if (options.logLabel) { + console.warn(`${options.logLabel} failed on server.`, error); + } else { + console.warn('Server prefetch failed.', error); + } + + return undefined; + } +} diff --git a/src/widgets/home/todo-list.tsx b/src/widgets/home/todo-list.tsx index 333e4e8c..47519cc8 100644 --- a/src/widgets/home/todo-list.tsx +++ b/src/widgets/home/todo-list.tsx @@ -4,6 +4,7 @@ import { useState } from 'react'; import Button from '@/components/ui/button'; import { usePhoneVerificationStatus } from '@/features/phone-verification/model/use-phone-verification-status'; import PhoneVerificationModal from '@/features/phone-verification/ui/phone-verification-modal'; +import { useAuthReady } from '@/hooks/common/use-auth'; import CheckIcon from 'public/icons/check.svg'; interface TodoListProps { @@ -17,7 +18,9 @@ const todoItems = [ ] as const; export default function TodoList({ statusList }: TodoListProps) { - const { isVerified, setVerified } = usePhoneVerificationStatus(); + const { memberId } = useAuthReady(); + const { isVerified, isLoading, isError, setVerified } = + usePhoneVerificationStatus(memberId); const [isVerificationModalOpen, setIsVerificationModalOpen] = useState(false); const handleVerificationComplete = (phoneNumber: string) => { @@ -28,7 +31,21 @@ export default function TodoList({ statusList }: TodoListProps) {

오늘 할 일

- {isVerified ? ( + {isLoading ? ( +
+

+ 인증 상태를 확인하고 있어요 +

+
+ ) : isError ? ( +
+

+ 인증 상태를 불러오지 못했어요. +
+ 잠시 후 다시 시도해주세요. +

+
+ ) : isVerified ? (
    {todoItems.map((item, idx) => { const done = statusList[idx]; @@ -77,6 +94,7 @@ export default function TodoList({ statusList }: TodoListProps) { open={isVerificationModalOpen} onOpenChange={setIsVerificationModalOpen} onVerificationComplete={handleVerificationComplete} + memberId={memberId ?? undefined} />
); diff --git a/src/widgets/my-page/sidebar.tsx b/src/widgets/my-page/sidebar.tsx index 69a014ee..5e00567a 100644 --- a/src/widgets/my-page/sidebar.tsx +++ b/src/widgets/my-page/sidebar.tsx @@ -4,16 +4,16 @@ import { usePathname, useRouter } from 'next/navigation'; import { cn } from '@/components/ui/(shadcn)/lib/utils'; import { useUserProfileQuery } from '@/entities/user/model/use-user-profile-query'; import { useLogoutMutation } from '@/features/auth/model/use-auth-mutation'; -import { useAuth } from '@/hooks/common/use-auth'; +import { useAuthReady } from '@/hooks/common/use-auth'; export default function Sidebar() { const router = useRouter(); const pathname = usePathname(); - const { data: authData } = useAuth(); + const { memberId } = useAuthReady(); const { mutateAsync: logout } = useLogoutMutation(); - const { data: profile } = useUserProfileQuery(authData?.memberId || 0); + const { data: profile } = useUserProfileQuery(memberId ?? 0); const handleLogout = async () => { await logout();