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();