diff --git a/apps/frontend/app/(client)/layout.tsx b/apps/frontend/app/(client)/layout.tsx index 98282049cc..a46faf9852 100644 --- a/apps/frontend/app/(client)/layout.tsx +++ b/apps/frontend/app/(client)/layout.tsx @@ -1,5 +1,6 @@ 'use client' +import { SocialLoginHandler } from '@/components/auth/SocialLoginHandler' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { ReactQueryDevtools } from '@tanstack/react-query-devtools' @@ -18,6 +19,7 @@ export default function MainLayout({ return ( + {children} {process.env.NODE_ENV === 'development' && ( diff --git a/apps/frontend/app/(client)/login/layout.tsx b/apps/frontend/app/(client)/login/layout.tsx new file mode 100644 index 0000000000..2460751b89 --- /dev/null +++ b/apps/frontend/app/(client)/login/layout.tsx @@ -0,0 +1,45 @@ +import { SignUpHeader } from '@/components/auth/SignUpPage/SignUpHeader' +import codedangLogoWhite from '@/public/logos/codedang-with-text-white.svg' +import Image from 'next/image' +import { HeaderTitleProvider } from '../(main)/_contexts/HeaderTitleContext' + +export default function LogInLayout({ + children +}: { + children: React.ReactNode +}) { + return ( + +
+ +
+
+
+
+
+ Codedang +

+ 온라인 코딩 교육 & 대회 플랫폼 +

+
+
+ +
+ {children} +
+
+
+
+
+ ) +} diff --git a/apps/frontend/app/(client)/login/page.tsx b/apps/frontend/app/(client)/login/page.tsx index e2d02694e8..a4a1680d7b 100644 --- a/apps/frontend/app/(client)/login/page.tsx +++ b/apps/frontend/app/(client)/login/page.tsx @@ -1,61 +1,12 @@ 'use client' -import { AuthModal } from '@/components/auth/AuthModal' -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle -} from '@/components/shadcn/dialog' -import { useSession } from '@/libs/hooks/useSession' -import { useAuthModalStore } from '@/stores/authModal' -import type { Route } from 'next' -import { useRouter, useSearchParams } from 'next/navigation' -import { useEffect } from 'react' - -export default function LoginPage() { - const { currentModal, hideModal, showSignIn } = useAuthModalStore( - (state) => state - ) - const session = useSession() - const router = useRouter() - const searchParams = useSearchParams() - - useEffect(() => { - showSignIn() - }, [showSignIn]) - - useEffect(() => { - if (session) { - const redirectUrl = searchParams.get('redirectUrl') - if (redirectUrl) { - router.push(redirectUrl as Route) - } else { - router.push('/') - } - } - }, [session, router, searchParams]) +import { LogInPage } from '@/components/auth/LogInPage/LogInPage' +import { Suspense } from 'react' +export default function Page() { return ( - - { - e.preventDefault() - }} - onInteractOutside={(e) => { - e.preventDefault() - }} - onEscapeKeyDown={(e) => { - e.preventDefault() - }} - hideCloseButton={true} - className="!h-[620px] !w-[380px] rounded-[10px]" - > - - - - - - + + + ) } diff --git a/apps/frontend/app/(client)/signup/layout.tsx b/apps/frontend/app/(client)/signup/layout.tsx index 6845043930..1b7a18da2b 100644 --- a/apps/frontend/app/(client)/signup/layout.tsx +++ b/apps/frontend/app/(client)/signup/layout.tsx @@ -18,8 +18,8 @@ export default function SignUpLayout({ style={{ backgroundImage: "url('/signup/background.png')" }} />
-
-
+
+
Codedang
-
+
{children}
diff --git a/apps/frontend/components/auth/LogInPage/InfoModal.tsx b/apps/frontend/components/auth/LogInPage/InfoModal.tsx new file mode 100644 index 0000000000..bf589710cc --- /dev/null +++ b/apps/frontend/components/auth/LogInPage/InfoModal.tsx @@ -0,0 +1,120 @@ +'use client' + +import { Button } from '@/components/shadcn/button' +import { Checkbox } from '@/components/shadcn/checkbox' +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/shadcn/dialog' +import { cn } from '@/libs/utils' +import Image from 'next/image' +import { useState } from 'react' + +interface InfoModalButton { + text: string + onClick: () => void +} + +interface InfoModalProps { + open: boolean + onOpenChange: (open: boolean) => void + title: string + description?: string + primaryButton: InfoModalButton + secondaryButton?: InfoModalButton + className?: string + dismissForTodayLabel?: string + onDismissForToday?: () => void +} + +export function InfoModal({ + open, + onOpenChange, + title, + description, + primaryButton, + secondaryButton, + className, + dismissForTodayLabel, + onDismissForToday +}: InfoModalProps) { + const [hideToday, setHideToday] = useState(false) + + const withDismissCheck = (onClick: () => void) => () => { + if (hideToday) { + onDismissForToday?.() + } + onClick() + } + return ( + + event.preventDefault()} + onEscapeKeyDown={(event) => event.preventDefault()} + onOpenAutoFocus={(event) => event.preventDefault()} + > + + info + +
+ + {title} + + + {description && ( +

+ {description} +

+ )} +
+
+ + +
+ {secondaryButton && ( + + )} + + +
+ + {dismissForTodayLabel && onDismissForToday && ( + + )} +
+
+
+ ) +} diff --git a/apps/frontend/components/auth/LogInPage/LogInPage.tsx b/apps/frontend/components/auth/LogInPage/LogInPage.tsx new file mode 100644 index 0000000000..7122c907d0 --- /dev/null +++ b/apps/frontend/components/auth/LogInPage/LogInPage.tsx @@ -0,0 +1,333 @@ +'use client' + +import { InfoModal } from '@/components/auth/LogInPage/InfoModal' +import { Input } from '@/components/shadcn/input' +import { + dismissSocialLoginPromoForToday, + isSocialLoginPromoDismissedToday +} from '@/libs/auth/socialLoginPromoDismissal' +import { baseUrl } from '@/libs/constants' +import { safeFetcherWithAuth } from '@/libs/utils' +import codedangLogo from '@/public/logos/codedang-editor.svg' +import { useSocialAuthStore } from '@/stores/socialAuth' +import type { Route } from 'next' +import { signIn } from 'next-auth/react' +import Image from 'next/image' +import Link from 'next/link' +import { useRouter, useSearchParams } from 'next/navigation' +import { usePostHog } from 'posthog-js/react' +import { useEffect, useState } from 'react' +import { useForm } from 'react-hook-form' +import type { SubmitHandler } from 'react-hook-form' +import { FaEyeSlash } from 'react-icons/fa' +import { toast } from 'sonner' +import { RecoverAccount } from '../RecoverAccount/RecoverAccount' + +interface SignInInput { + nickname: string + password: string +} + +export function LogInPage() { + const [authView, setAuthView] = useState<'login' | 'recover'>('login') + const [isSignInDisabled, setIsSignInDisabled] = useState(false) + const [isPasswordVisible, setIsPasswordVisible] = useState(false) + const [isSocialPromoModalOpen, setIsSocialPromoModalOpen] = useState(false) + const router = useRouter() + const posthog = usePostHog() + const searchParams = useSearchParams() + const isSocialUnlinkedModalOpen = + searchParams.get('modal') === 'social-unlinked' + const { oauthToken, setOauthToken, clearOauthToken } = useSocialAuthStore( + (state) => state + ) + const { register, handleSubmit } = useForm() + + useEffect(() => { + const oauthToken = searchParams.get('oauthToken') + if (oauthToken) { + setOauthToken(oauthToken) + } + }, [searchParams, setOauthToken]) + + const handleKakaoLogin = () => { + window.location.href = `${baseUrl}/auth/kakao` + } + + const handleComingSoonLogin = () => { + toast.info('준비 중인 기능입니다') + } + + const handleSocialPromoLater = () => { + setIsSocialPromoModalOpen(false) + router.push('/') + router.refresh() + } + + const handleSocialPromoGoToSettings = () => { + setIsSocialPromoModalOpen(false) + router.push('/settings' as Route) // 추후 변경 + router.refresh() + } + + const onSubmit: SubmitHandler = async (data) => { + setIsSignInDisabled(true) + try { + const res = await signIn('credentials', { + username: data.nickname, + password: data.password, + redirect: false + }) + if (!res?.error) { + posthog.identify(data.nickname) + + let justLinkedSocial = false + if (oauthToken) { + try { + await safeFetcherWithAuth.post('auth/social-link', { + json: { oauthToken } + }) + clearOauthToken() + justLinkedSocial = true + } catch (error) { + console.error('Failed to link social account:', error) + } + } + + toast.success(`Welcome back, ${data.nickname}!`, { + style: { + transform: 'translateY(30px)' + } + }) + + if (!justLinkedSocial && !isSocialLoginPromoDismissedToday()) { + setIsSocialPromoModalOpen(true) + } else { + router.push('/') + router.refresh() + } + } else { + toast.error('Failed to log in') + } + } catch (error) { + console.error('Error during login:', error) + toast.error('An unexpected error occurred') + } finally { + setIsSignInDisabled(false) + } + } + + return ( + <> +
+ {authView === 'login' ? ( + <> +
+ codedang +

+ 코드당에 어서오세요! +

+
+ +
+
+ +
+ + +
+
+ +
+ +
+
+
+ + + + 회원가입 + +
+ +
+ +
+

+ SNS 계정으로 시작하기 +

+ +
+ {/* Google */} + + + {/* Kakao */} + + + {/* GitHub */} + + + {/* Naver */} + +
+
+ + ) : ( +
+ setAuthView('login')} /> +
+ )} +
+ { + if (!open) { + router.replace('/login' as Route) + } + }} + title={'SNS 정보를 확인했어요.\n코드당에 처음 오시네요!'} + description="기존 회원은 로그인 후 소셜 연동이 가능해요" + secondaryButton={{ + text: '로그인', + onClick: () => router.replace('/login' as Route) + }} + primaryButton={{ + text: '회원가입 이어서하기', + onClick: () => router.replace('/signup' as Route) + }} + /> + { + if (!open) { + handleSocialPromoLater() + } + }} + title="소셜 로그인이 도입되었어요!" + description="설정에서 SNS를 연동해서 편하게 로그인 할 수 있어요" + secondaryButton={{ + text: '나중에 하기', + onClick: handleSocialPromoLater + }} + primaryButton={{ + text: '로그인하러 가기', + onClick: handleSocialPromoGoToSettings + }} + dismissForTodayLabel="오늘 하루 동안 안보기" + onDismissForToday={dismissSocialLoginPromoForToday} + /> + + ) +} diff --git a/apps/frontend/components/auth/RecoverAccount/FindUserId.tsx b/apps/frontend/components/auth/RecoverAccount/FindUserId.tsx index 8eae95a02f..a2acaa6658 100644 --- a/apps/frontend/components/auth/RecoverAccount/FindUserId.tsx +++ b/apps/frontend/components/auth/RecoverAccount/FindUserId.tsx @@ -24,7 +24,7 @@ export function FindUserId() { const { nextModal, setFormData } = useRecoverAccountModalStore( (state) => state ) - const { showSignIn, showSignUp } = useAuthModalStore((state) => state) + const { showSignIn } = useAuthModalStore((state) => state) const { handleSubmit, @@ -89,84 +89,73 @@ export function FindUserId() { } return ( - <> -
-
-

- Find User ID -

- trigger('email') - })} - onFocus={() => setInputFocused(true)} - onBlur={() => trigger('email')} - disabled={Boolean(userId)} - /> - {errors.email && ( -

{errors.email?.message}

- )} - {emailError && getValues('email') === wrongEmail && ( -

{emailError}

+ +
+

+ Find User ID +

+ - Your user ID is{' '} - {userId} -

- )} -
+ placeholder="Email Address" + {...register('email', { + onChange: () => trigger('email') + })} + onFocus={() => setInputFocused(true)} + onBlur={() => trigger('email')} + disabled={Boolean(userId)} + /> + {errors.email && ( +

{errors.email?.message}

+ )} + {emailError && getValues('email') === wrongEmail && ( +

{emailError}

+ )} + {userId && ( +

+ Your user ID is{' '} + {userId} +

+ )} +
-
- {userId ? ( - - ) : ( - - )} +
+ {userId ? ( -
- -
+ ) : ( + + )}
- + ) } diff --git a/apps/frontend/components/auth/RecoverAccount/RecoverAccount.tsx b/apps/frontend/components/auth/RecoverAccount/RecoverAccount.tsx index 01fccbc2b4..fd00eb50b4 100644 --- a/apps/frontend/components/auth/RecoverAccount/RecoverAccount.tsx +++ b/apps/frontend/components/auth/RecoverAccount/RecoverAccount.tsx @@ -1,7 +1,6 @@ 'use client' import codedangLogo from '@/public/logos/codedang-with-text.svg' -import { useAuthModalStore } from '@/stores/authModal' import { useRecoverAccountModalStore } from '@/stores/recoverAccountModal' import Image from 'next/image' import { IoMdArrowBack } from 'react-icons/io' @@ -9,14 +8,18 @@ import { FindUserId } from './FindUserId' import { ResetPassword } from './ResetPassword' import { ResetPasswordEmailVerify } from './ResetPasswordEmailVerify' -export function RecoverAccount() { - const { showSignIn } = useAuthModalStore((state) => state) +interface RecoverAccountProps { + onBackToSignIn: () => void +} + +export function RecoverAccount({ onBackToSignIn }: RecoverAccountProps) { const { modalPage, backModal } = useRecoverAccountModalStore((state) => state) return ( -
+