diff --git a/.github/workflows/notify-pr-author-on-review.yml b/.github/workflows/notify-pr-author-on-review.yml index d0efcbfb..9f60a07c 100644 --- a/.github/workflows/notify-pr-author-on-review.yml +++ b/.github/workflows/notify-pr-author-on-review.yml @@ -49,22 +49,10 @@ jobs: - name: Send Slack DM to PR Author if: steps.extract_info.outputs.skip != 'true' - env: - SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} - AUTHOR_SLACK_ID: ${{ steps.extract_info.outputs.author_slack_id }} - TEXT: ${{ steps.extract_info.outputs.text }} - run: | - RESPONSE=$(curl -s -X POST https://slack.com/api/chat.postMessage \ - -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "channel": "'"$AUTHOR_SLACK_ID"'", - "text": "'"$TEXT"'" - }') - - echo "Slack DM 전송 응답: $RESPONSE" - - if ! echo "$RESPONSE" | jq -e '.ok' | grep -q true; then - echo "❌ Slack 메시지 전송 실패" - exit 1 - fi + uses: slackapi/slack-github-action@v2.1.0 + with: + method: chat.postMessage + token: ${{ secrets.SLACK_BOT_TOKEN }} + payload: | + channel: ${{ steps.extract_info.outputs.author_slack_id }} + text: ${{ steps.extract_info.outputs.text }} diff --git a/app/redirection/page.tsx b/app/redirection/page.tsx index 554bf01a..a7caa192 100644 --- a/app/redirection/page.tsx +++ b/app/redirection/page.tsx @@ -31,11 +31,6 @@ function RedirectionContent() { if (isGuest === 'true') { router.push('/sign-up'); - sendGTMEvent({ - event: 'custom_member_join', - dl_timestamp: new Date().toISOString(), - dl_member_id: hashValue(memberId), - }); } else { router.push('/'); router.refresh(); diff --git a/next.config.ts b/next.config.ts index 284e3b98..e1dcedae 100644 --- a/next.config.ts +++ b/next.config.ts @@ -15,6 +15,11 @@ const nextConfig: NextConfig = { hostname: 'test-api.zeroone.it.kr', pathname: '/profile-image/**', }, + { + protocol: 'https', + hostname: 'lh3.googleusercontent.com', + pathname: '/**', // 구글 이미지 전체 허용 + }, ], }, diff --git a/src/features/auth/api/auth.ts b/src/features/auth/api/auth.ts index 3e9cc8b2..a055640c 100644 --- a/src/features/auth/api/auth.ts +++ b/src/features/auth/api/auth.ts @@ -38,14 +38,6 @@ export async function getMemberId() { return res.data; } -// 프로필 조회 API -export async function getProfile(memberId: number) { - const res = await axiosInstance.get(`/members/${memberId}/profile`); - console.log('getProfile res', res); - - return res.data; -} - // 로그아웃 API export const logout = async (): Promise => { const res = await axiosInstance.post('/auth/logout'); diff --git a/src/features/auth/model/types.ts b/src/features/auth/model/types.ts index b234f67e..f0881c17 100644 --- a/src/features/auth/model/types.ts +++ b/src/features/auth/model/types.ts @@ -1,20 +1,3 @@ -// 사용자 정보 조회 응답 타입 -export interface MemberInfoResponse { - isLogin: boolean; - content: { - memberProfile: { - memberName: string; - profileImage: { - resizedImages: { - resizedImageUrl: string; - }[]; - }; - }; - }; - statusCode: number; - message: string; -} - // 회원가입 응답 타입 export interface SignUpResponse { content: { diff --git a/src/features/auth/model/use-auth.ts b/src/features/auth/model/use-auth.ts index 20e5a92e..efa7f8dc 100644 --- a/src/features/auth/model/use-auth.ts +++ b/src/features/auth/model/use-auth.ts @@ -4,9 +4,8 @@ // 한편, prefetchQuery 는 서버컴포넌트에서 사용하는 함수로 데이터를 미리 가져와서 캐시에 저장함 import { useQuery } from '@tanstack/react-query'; -import { getMemberId, getProfile } from '@/features/auth/api/auth'; +import { getMemberId } from '@/features/auth/api/auth'; import { getCookie } from '@/shared/tanstack-query/cookie'; -import { MemberInfoResponse } from './types'; // 회원 Id 조회 export const useMemberId = () => { @@ -16,35 +15,3 @@ export const useMemberId = () => { enabled: !!getCookie('accessToken'), // 토큰이 있을 때만 실행 }); }; - -// 회원 프로필 조회 -export const useProfile = (memberId?: string) => { - return useQuery({ - queryKey: ['profile', memberId], - queryFn: () => getProfile(Number(memberId)), - enabled: !!memberId, // memberId가 있을 때만 실행 - // staleTime으로 캐시 유효 기간 설정 - staleTime: 5 * 60 * 1000, // 5분 - }); -}; - -// 회원 Id 기반 회원 정보 조회 -export const useMemberInfo = () => { - const memberId = getCookie('memberId'); - - return useQuery({ - queryKey: ['memberInfo', memberId], - queryFn: async () => { - if (!memberId) return { isLogin: false }; - - try { - const profileData = await getProfile(Number(memberId)); - - return { isLogin: true, ...profileData }; - } catch (error) { - return { isLogin: false }; - } - }, - enabled: !!memberId, - }); -}; diff --git a/src/features/auth/ui/header-user-dropdown.tsx b/src/features/auth/ui/header-user-dropdown.tsx new file mode 100644 index 00000000..94292a47 --- /dev/null +++ b/src/features/auth/ui/header-user-dropdown.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { sendGTMEvent } from '@next/third-parties/google'; +import { useQueryClient } from '@tanstack/react-query'; +import { useRouter } from 'next/navigation'; +import { hashValue } from '@/shared/lib/hash'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/shared/shadcn/ui/dropdown-menu'; +import { deleteCookie, getCookie } from '@/shared/tanstack-query/cookie'; +import UserAvatar from '@/shared/ui/avatar'; +import { logout } from '../api/auth'; + +export default function HeaderUserDropdown({ userImg }: { userImg: string }) { + const queryClient = useQueryClient(); + const router = useRouter(); + + const handleLogout = async () => { + try { + // 1. 서버에 로그아웃 요청 (refresh token 삭제) + await logout(); + + const memberId = getCookie('memberId'); + sendGTMEvent({ + event: 'custom_member_logout', + dl_timestamp: new Date().toISOString(), + dl_member_id: hashValue(memberId), + }); + + // 2. 클라이언트의 access token 삭제 + deleteCookie('accessToken'); + deleteCookie('memberId'); + + // 3. React Query 캐시 초기화 + queryClient.clear(); + + // 4. 홈으로 리다이렉트 + router.push('/'); + router.refresh(); // 전체 페이지 리프레시 + } catch (error) { + console.error('로그아웃 실패:', error); + } + }; + + return ( + + +
+ +
+
+ + + {[ + { + label: '내 정보 수정', + value: '/my-page', + onMenuClick: () => router.push('/my-page'), + }, + { + label: '로그아웃', + value: 'logout', + onMenuClick: handleLogout, + }, + ].map((option) => ( + + + {option.label} + + + ))} + +
+ ); +} diff --git a/src/features/auth/ui/landing.tsx b/src/features/auth/ui/landing.tsx index 11d15831..df785b52 100644 --- a/src/features/auth/ui/landing.tsx +++ b/src/features/auth/ui/landing.tsx @@ -7,7 +7,6 @@ import SignupModal from '@/features/auth/ui/sign-up-modal'; import Button from '@/shared/ui/button'; export default function Landing({ isSignupPage }: { isSignupPage: boolean }) { - const [loginOpen, setLoginOpen] = useState(false); const [signupOpen, setSignupOpen] = useState(false); useEffect(() => { @@ -27,18 +26,17 @@ export default function Landing({ isSignupPage }: { isSignupPage: boolean }) { 개발자 면접 준비, 이제 ZERO-ONE에서
매주 실전처럼 연습해보세요.

- - setLoginOpen(false)} /> + + + 시작하기 + + } + /> setSignupOpen(false)} /> -
+
Graphic Area void; + openTrigger: ReactNode; }) { const [state, setState] = useState(null); @@ -20,13 +18,12 @@ export default function LoginModal({ }, []); if (!state) { - return
로딩중...
; + return <>; } const isLocal = origin.includes('localhost') || origin.includes('127.0.0.1'); // 로컬환경 테스트용 localStorage.setItem('isLocal', JSON.stringify(isLocal)); - console.log("로컬 환경 여부", isLocal); const API_BASE_URL = isLocal ? 'https://test-api.zeroone.it.kr' : process.env.NEXT_PUBLIC_API_BASE_URL; @@ -44,7 +41,8 @@ export default function LoginModal({ const GOOGLE_LOGIN_URL = `https://accounts.google.com/o/oauth2/v2/auth?scope=openid%20profile&access_type=offline&prompt=consent&include_granted_scopes=true&response_type=code&redirect_uri=${API_BASE_URL}/api/v1/auth/google/redirect-uri&client_id=${GOOGLE_CLIENT_ID}&state=${state}`; return ( - + + {openTrigger} @@ -65,7 +63,7 @@ export default function LoginModal({
{/* */} - - - - - ); } + +function ReadyForm({ + refetch, + data, +}: { + refetch: () => void; + data: DailyStudyDetail; +}) { + const [interviewTopic, setInterviewTopic] = useState< + PutStudyDailyRequest['subject'] + >(data.subject ?? ''); + + const [referenceLink, setReferenceLink] = useState< + PutStudyDailyRequest['link'] + >(data.link ?? ''); + + const handleSubmit = async (e: React.MouseEvent) => { + if (!interviewTopic) { + e.preventDefault(); + + return; + } + + try { + await putStudyDaily(data.dailyStudyId, { + subject: interviewTopic, + description: '', + link: referenceLink, + }); + await refetch(); + } catch (err) { + e.preventDefault(); + console.error(err); + alert('요청 처리에 실패했습니다. 다시 시도해주세요.'); + } + }; + + return ( + <> +
+ + + 이번 스터디에서 다룰 면접 주제를 입력하세요 + + setInterviewTopic(e.target.value)} + /> +
+ +
+ + + 참고할 링크나 자료가 있다면 입력해 주세요 + + setReferenceLink(e.target.value)} + /> +
+ + + + + + + + + + + ); +} + +function DoneForm({ + data, + refetch, +}: { + data: DailyStudyDetail; + refetch: () => void; +}) { + const [feedback, setFeedback] = useState( + data.description ?? '', + ); + + const [progressStatus, setProgressStatus] = useState( + data.progressStatus ?? 'PENDING', + ); + + const handleSubmit = async (e: React.MouseEvent) => { + if (!feedback || !progressStatus) { + e.preventDefault(); + + return; + } + + try { + await patchStudyStatus(data.dailyStudyId, progressStatus); + + await putRetrospect(data.dailyStudyId, { + description: feedback, + }); + + await refetch(); + } catch (err) { + e.preventDefault(); + console.error(err); + alert('요청 처리에 실패했습니다. 다시 시도해주세요.'); + } + }; + + return ( + <> +
+ + + 면접 완료 후 해당 지원자의 상태를 업데이트해 주세요. + + setProgressStatus(e as StudyProgressStatus)} + /> +
+ +
+ + + 면접 결과에 대한 간단한 피드백을 입력해 주세요. + + setFeedback(e)} + /> +
+ + + + + + + + + + + ); +} diff --git a/src/shared/lib/get-login-user.ts b/src/shared/lib/get-login-user.ts index 74b13368..58110f4b 100644 --- a/src/shared/lib/get-login-user.ts +++ b/src/shared/lib/get-login-user.ts @@ -1,11 +1,68 @@ -import { getServerCookie } from './server-cookie'; +import { getServerCookie, setServerCookie } from './server-cookie'; +import axios from 'axios'; +import { redirect } from 'next/navigation'; export async function getLoginUserId(): Promise { + // memberId 쿠키 우선 확인 (기존 로직) const memberIdStr = await getServerCookie('memberId'); if (!memberIdStr) return null; const memberId = Number(memberIdStr); if (isNaN(memberId) || memberId <= 0) return null; - return memberId; + /* + 이하 accessToken 최신화를 위한 로직 + */ + + // 1. accessToken 쿠키 확인 + let accessToken = await getServerCookie('accessToken'); + if (!accessToken) return null; + + // 2. accessToken으로 /auth/me 호출 + try { + // (SSR 이므로 useQuery 를 쓰지 않고 직접 API 호출) + const res = await axios.get( + `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/auth/me`, + { + headers: { Authorization: `Bearer ${accessToken}` }, + withCredentials: true, + } + ); + // 3. memberId 쿠키와 /auth/me의 memberId가 다르면 null 반환 + if (Number(res.data.content) !== memberId) return null; + + return memberId; + } catch (error: any) { + + // 4. 401이면 만료된 토큰이므로 토큰 갱신 시도 + if (error.response?.status === 401) { + try { + const refreshRes = await axios.get( + `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/auth/access-token/refresh`, + { withCredentials: true } + ); + accessToken = refreshRes.data.accessToken; + // 5. SSR에서 최신화된 AccessToken을 쿠키등록 + await setServerCookie('accessToken', accessToken, { path: '/' }); + + // 6. 갱신된 토큰으로 다시 /auth/me 호출 + const res2 = await axios.get( + `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/auth/me`, + { + headers: { Authorization: `Bearer ${accessToken}` }, + withCredentials: true, + } + ); + if (Number(res2.data.memberId) !== memberId) return null; + + return memberId; + } catch (refreshError) { + // 갱신 실패 → 로그인 페이지로 리다이렉트 + redirect('/login'); + } + } else { + // 기타 에러 → 로그인 페이지로 리다이렉트 + redirect('/login'); + } + } } diff --git a/src/shared/lib/server-cookie.ts b/src/shared/lib/server-cookie.ts index c78766bd..1f422428 100644 --- a/src/shared/lib/server-cookie.ts +++ b/src/shared/lib/server-cookie.ts @@ -8,3 +8,12 @@ export const getServerCookie = async ( return value ?? undefined; }; + +export const setServerCookie = async( + name: string, + value: string, + options: { path?: string } = {}, +): Promise => { + const cookieStore = await cookies(); + cookieStore.set(name, value, { path: '/', ...options }); +}; \ No newline at end of file diff --git a/src/shared/ui/dropdown/header.tsx b/src/shared/ui/dropdown/header.tsx deleted file mode 100644 index ab10bebd..00000000 --- a/src/shared/ui/dropdown/header.tsx +++ /dev/null @@ -1,55 +0,0 @@ -// header 에서 사용하는 dropdown 컴포넌트 -// - 테두리, Chevron 없음 -// - dropwdown 메뉴 클릭시에도 Trigger의 placeholder가 유지되어야함 - -import { ReactNode } from 'react'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/shared/shadcn/ui/dropdown-menu'; - -interface Option { - label: string; - value: string | number; -} - -interface HeaderDropdownProps { - options: Option[]; - defaultValue?: string | number; - placeholder?: ReactNode; - onChange: (value: string | number) => void; -} - -function HeaderDropdown({ - placeholder, - options, - onChange, -}: HeaderDropdownProps) { - return ( - - -
{placeholder}
-
- - - {options.map((option) => ( - { - onChange(option.value); - }} - className="active:bg-fill-neutral-subtle-pressed rounded-100 h-[48px] w-full cursor-pointer p-150" - > - - {option.label} - - - ))} - -
- ); -} - -export default HeaderDropdown; diff --git a/src/shared/ui/dropdown/index.ts b/src/shared/ui/dropdown/index.ts index 65816c01..9e67c020 100644 --- a/src/shared/ui/dropdown/index.ts +++ b/src/shared/ui/dropdown/index.ts @@ -1,3 +1,2 @@ export { default as SingleDropdown } from './single'; export { default as MultiDropdown } from './multi'; -export { default as HeaderDropdown } from './header'; diff --git a/src/widgets/home/calendar.tsx b/src/widgets/home/calendar.tsx index ca3ba97e..1d4769a0 100644 --- a/src/widgets/home/calendar.tsx +++ b/src/widgets/home/calendar.tsx @@ -70,14 +70,12 @@ const Calendar = (props: React.ComponentProps) => { const { data, isLoading } = useMonthlyStudyCalendarQuery({ year, month }); const completedDays = React.useMemo(() => { - if (!data?.calender) return []; + if (!data?.calendar) return []; - return Object.entries(data.calender) - .filter(([, timeSlots]) => Object.values(timeSlots).includes('COMPLETE')) - .map(([day]) => new Date(Number(day))); - }, [data]); - - const monthlyCompletedCount = completedDays.length; + return data.calendar + .filter((entry) => entry.hasStudy && entry.status === 'COMPLETE') + .map((entry) => new Date(year, month - 1, entry.day)); + }, [data, year, month]); if (isLoading) return
로딩 중...
; @@ -115,10 +113,10 @@ const Calendar = (props: React.ComponentProps) => { footer={
- {month}월은 {monthlyCompletedCount}번의 스터디를 완료했어요. + {month}월은 {data.monthlyCompletedCount}번의 스터디를 완료했어요.
- 총 {monthlyCompletedCount}번의 스터디를 완료했어요. + 총 {data.totalCompletedCount}번의 스터디를 완료했어요.
} diff --git a/src/widgets/home/header.tsx b/src/widgets/home/header.tsx index 85a76796..bf25eca6 100644 --- a/src/widgets/home/header.tsx +++ b/src/widgets/home/header.tsx @@ -1,57 +1,21 @@ -'use client'; - -import { sendGTMEvent } from '@next/third-parties/google'; -import { useQueryClient } from '@tanstack/react-query'; import Link from 'next/link'; -import { useRouter } from 'next/navigation'; -import { logout } from '@/features/auth/api/auth'; -import { useMemberInfo } from '@/features/auth/model/use-auth'; -import { hashValue } from '@/shared/lib/hash'; -import { deleteCookie, getCookie } from '@/shared/tanstack-query/cookie'; -import UserAvatar from '@/shared/ui/avatar'; -import Button from '@/shared/ui/button'; -import { HeaderDropdown } from '@/shared/ui/dropdown'; -import NotiIcon from 'public/icons/notifications_none.svg'; - -export default function Header() { - const queryClient = useQueryClient(); - const router = useRouter(); - const memberInfo = useMemberInfo(); - - // 로컬 테스트에서 사용시 주석 제거 - // console.log('요청 주소', process.env.API_BASE_URL); - // console.log( - // '프로필 이미지 주소', - // memberInfo.data?.content?.memberProfile?.profileImage?.resizedImages[0] - // ?.resizedImageUrl, - // ); +import { getUserProfile } from '@/entities/user/api/get-user-profile'; - const handleLogout = async () => { - try { - // 1. 서버에 로그아웃 요청 (refresh token 삭제) - await logout(); - - const memberId = getCookie('memberId'); - sendGTMEvent({ - event: 'custom_member_logout', - dl_timestamp: new Date().toISOString(), - dl_member_id: hashValue(memberId), - }); +import HeaderUserDropdown from '@/features/auth/ui/header-user-dropdown'; +import LoginModal from '@/features/auth/ui/login-modal'; +import { getServerCookie } from '@/shared/lib/server-cookie'; +import Button from '@/shared/ui/button'; - // 2. 클라이언트의 access token 삭제 - deleteCookie('accessToken'); - deleteCookie('memberId'); +// import NotiIcon from 'public/icons/notifications_none.svg'; - // 3. React Query 캐시 초기화 - queryClient.clear(); +export default async function Header() { + const memberId = await getServerCookie('memberId'); + const isLogin = /^\d+$/.test(memberId || ''); - // 4. 홈으로 리다이렉트 - router.push('/'); - router.refresh(); // 전체 페이지 리프레시 - } catch (error) { - console.error('로그아웃 실패:', error); - } - }; + const userInfo = isLogin ? await getUserProfile(Number(memberId)) : null; + const userImg = isLogin + ? userInfo.memberProfile.profileImage?.resizedImages[0].resizedImageUrl + : 'profile-default.svg'; return (
@@ -67,42 +31,14 @@ export default function Header() { */}
-
+ {/* 알림 기능을 구현하지 못해 주석 처리 */} + {/*
-
- - } - options={[ - { - label: '내 정보 수정', - value: '/my-page', - }, - { - label: '로그아웃', - value: 'logout', - }, - ]} - onChange={async (value) => { - if (value === '/my-page') { - await router.push(value); - } else if (value === 'logout') { - await handleLogout(); - } - }} - /> - {!memberInfo.data?.isLogin && ( - - - +
*/} + + {isLogin && } + {!isLogin && ( + 로그인 / 회원가입} /> )}
diff --git a/src/widgets/home/sidebar.tsx b/src/widgets/home/sidebar.tsx index 3d83620f..cd342461 100644 --- a/src/widgets/home/sidebar.tsx +++ b/src/widgets/home/sidebar.tsx @@ -10,8 +10,6 @@ export default async function Sidebar() { const userProfile = await getUserProfile(memberId); - const hasTodo = false; // 나중에 스터디 참여 유무로 변경할 예정 - return (