From 3ee61467700d1aadd11ce474236be61e26955108 Mon Sep 17 00:00:00 2001 From: mingo Date: Mon, 11 May 2026 10:56:14 +0900 Subject: [PATCH 1/4] =?UTF-8?q?Feat:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=ED=99=94=EB=A9=B4=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20[JDDEV-26]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jobdri/app/(dashboard)/layout.tsx | 7 +- jobdri/app/(main)/layout.tsx | 4 +- jobdri/app/layout.tsx | 4 +- jobdri/app/login/page.tsx | 5 + jobdri/components/input/InputMain.tsx | 52 ++++-- jobdri/components/login/EmailLoginScreen.tsx | 174 +++++++++++++++++++ jobdri/components/login/index.ts | 1 + jobdri/styles/grid.css | 10 +- 8 files changed, 227 insertions(+), 30 deletions(-) create mode 100644 jobdri/app/login/page.tsx create mode 100644 jobdri/components/login/EmailLoginScreen.tsx create mode 100644 jobdri/components/login/index.ts diff --git a/jobdri/app/(dashboard)/layout.tsx b/jobdri/app/(dashboard)/layout.tsx index 6070416..93033e3 100644 --- a/jobdri/app/(dashboard)/layout.tsx +++ b/jobdri/app/(dashboard)/layout.tsx @@ -4,10 +4,9 @@ export default function DashboardLayout({ children: React.ReactNode; }) { return ( -
- -
- {/* 콘텐츠 영역에만 960px 그리드 적용 */} +
+ +
{children}
diff --git a/jobdri/app/(main)/layout.tsx b/jobdri/app/(main)/layout.tsx index a2d39d7..eb1ce72 100644 --- a/jobdri/app/(main)/layout.tsx +++ b/jobdri/app/(main)/layout.tsx @@ -1,4 +1,3 @@ -// layout.tsx 수정 예시 import type { Metadata } from "next"; export const metadata: Metadata = { title: "JobDri", @@ -11,8 +10,7 @@ export default function RootLayout({ children: React.ReactNode; }) { return ( - - {/* 폰트를 CSS에서 body에 직접 적용했으므로 클래스는 비워둬도 됩니다 */} + {children} ); diff --git a/jobdri/app/layout.tsx b/jobdri/app/layout.tsx index 6470ed1..344edb0 100644 --- a/jobdri/app/layout.tsx +++ b/jobdri/app/layout.tsx @@ -12,8 +12,8 @@ export default function RootLayout({ children: React.ReactNode; }) { return ( - - {children} + + {children} ); } diff --git a/jobdri/app/login/page.tsx b/jobdri/app/login/page.tsx new file mode 100644 index 0000000..b40fe56 --- /dev/null +++ b/jobdri/app/login/page.tsx @@ -0,0 +1,5 @@ +import { EmailLoginScreen } from "@/components/login"; + +export default function LoginPage() { + return ; +} diff --git a/jobdri/components/input/InputMain.tsx b/jobdri/components/input/InputMain.tsx index 5a6827d..37cf790 100644 --- a/jobdri/components/input/InputMain.tsx +++ b/jobdri/components/input/InputMain.tsx @@ -11,7 +11,12 @@ interface InputMainProps { placeholder?: string; value?: string; onChange?: (value: string) => void; + inputType?: React.HTMLInputTypeAttribute; + name?: string; + autoComplete?: string; + maxLength?: number; disabled?: boolean; + hasError?: boolean; error?: string; rightContent?: React.ReactNode; className?: string; @@ -24,7 +29,12 @@ export function InputMain({ placeholder, value: externalValue, onChange, + inputType, + name, + autoComplete, + maxLength, disabled = false, + hasError = false, error, rightContent, className, @@ -34,6 +44,10 @@ export function InputMain({ const [focused, setFocused] = useState(false); const value = externalValue ?? internalValue; + const iconType = type === "PASSWORD" ? "PASSWORD" : "PROFILE"; + const resolvedInputType = + inputType ?? (type === "PASSWORD" ? "password" : "text"); + const isError = hasError || !!error; const handleChange = (e: React.ChangeEvent) => { setInternalValue(e.target.value); @@ -42,30 +56,30 @@ export function InputMain({ return (
- - {label} - {required && } - + {label && ( + + {label} + {required && } + + )} -
+
- {!focused && - !value && - (disabled && type === "PASSWORD" ? ( - - ) : ( - - ))} + {!focused && !value && ( + + )} {rightContent} diff --git a/jobdri/components/login/EmailLoginScreen.tsx b/jobdri/components/login/EmailLoginScreen.tsx new file mode 100644 index 0000000..de6f11b --- /dev/null +++ b/jobdri/components/login/EmailLoginScreen.tsx @@ -0,0 +1,174 @@ +"use client"; + +import type { Dispatch, FormEvent, SetStateAction } from "react"; +import { useEffect, useState } from "react"; +import { Button, TextButton } from "@/components/buttons"; +import { InputMain } from "@/components/input"; +import { Tooltip } from "@/components/tooltip"; + +const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const passwordPattern = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,20}$/; + +export default function EmailLoginScreen() { + const [showCreditTooltip, setShowCreditTooltip] = useState(true); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [loginError, setLoginError] = useState(false); + + const isLoginReady = email.length > 0 && password.length > 0; + + useEffect(() => { + if (!showCreditTooltip) { + return; + } + + const timerId = window.setTimeout(() => { + setShowCreditTooltip(false); + }, 5000); + + return () => { + window.clearTimeout(timerId); + }; + }, [showCreditTooltip]); + + const hideCreditTooltip = () => { + setShowCreditTooltip(false); + }; + + const handleInputChange = ( + value: string, + setter: Dispatch>, + ) => { + setter(value); + setLoginError(false); + hideCreditTooltip(); + }; + + const handlePasswordChange = (value: string) => { + if (value.length > 20) { + return; + } + + handleInputChange(value, setPassword); + }; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + + if ( + !isLoginReady || + !emailPattern.test(email) || + !passwordPattern.test(password) + ) { + setLoginError(true); + return; + } + + setLoginError(true); + }; + + return ( +
+
+
+
+
+

+ JobDri +

+ +
+

+ 인사담당자가 보는 내 자소서는 몇점? +

+

+ 내 경험을 살린 합격 자소서를 완성해보세요 +

+
+
+ +
+
+
+ handleInputChange(value, setEmail)} + /> + +
+ +
+ +
+ + + 또는 + + +
+ +
+ +
+ + +
+ + {showCreditTooltip && ( +
+ +
+ )} +
+
+
+
+ ); +} diff --git a/jobdri/components/login/index.ts b/jobdri/components/login/index.ts new file mode 100644 index 0000000..08c0d85 --- /dev/null +++ b/jobdri/components/login/index.ts @@ -0,0 +1 @@ +export { default as EmailLoginScreen } from "./EmailLoginScreen"; diff --git a/jobdri/styles/grid.css b/jobdri/styles/grid.css index ccdc3e3..c1d5143 100644 --- a/jobdri/styles/grid.css +++ b/jobdri/styles/grid.css @@ -8,8 +8,7 @@ --width-default: 1116px; /* 2. WEB_LNB (사이드바 있을 때) */ - /* (69px * 12) + (12px * 11) = 960px */ - --width-lnb: 960px; + --width-lnb: 1060px; /* 모바일 */ --mobile-margin: 16px; @@ -35,6 +34,13 @@ max-width: var(--width-lnb); } +/* LNB가 있는 페이지의 메인 콘텐츠 여백 */ +.main-content-frame { + width: 100%; + min-width: 0; + padding: 44px 40px; +} + /* 모바일 대응 */ @media (max-width: 767px) { .grid-base { From fd1600b955f40c0d7bb741a2ff1e1303516eeb1e Mon Sep 17 00:00:00 2001 From: mingo Date: Mon, 11 May 2026 11:07:59 +0900 Subject: [PATCH 2/4] =?UTF-8?q?Feat:=20text=20only=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80=20[JD?= =?UTF-8?q?DEV-26]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jobdri/app/page.tsx | 9 ++++ jobdri/components/buttons/TextOnlyButton.tsx | 55 ++++++++++++++++++++ jobdri/components/buttons/index.ts | 5 ++ jobdri/components/login/EmailLoginScreen.tsx | 8 ++- 4 files changed, 72 insertions(+), 5 deletions(-) create mode 100644 jobdri/components/buttons/TextOnlyButton.tsx diff --git a/jobdri/app/page.tsx b/jobdri/app/page.tsx index 232c10d..03af139 100644 --- a/jobdri/app/page.tsx +++ b/jobdri/app/page.tsx @@ -9,6 +9,7 @@ import { IconButton, IconOnlyButton, TextButton, + TextOnlyButton, } from "@/components/buttons"; import { Toast, ToastFrame } from "@/components/toast"; import { ChipRound, ChipRoundSelected, ChipQnumber } from "@/components/chips"; @@ -211,6 +212,14 @@ export default function Home() {
+
+
+ + + + +
+
diff --git a/jobdri/components/buttons/TextOnlyButton.tsx b/jobdri/components/buttons/TextOnlyButton.tsx new file mode 100644 index 0000000..78d3aa0 --- /dev/null +++ b/jobdri/components/buttons/TextOnlyButton.tsx @@ -0,0 +1,55 @@ +import type { ButtonHTMLAttributes } from "react"; +import clsx from "clsx"; + +export type TextOnlyButtonSize = "small" | "large"; +export type TextOnlyButtonStyle = "primary" | "secondary"; + +interface TextOnlyButtonProps extends ButtonHTMLAttributes { + label?: string; + size?: TextOnlyButtonSize; + styleType?: TextOnlyButtonStyle; +} + +const typographyStyles: Record< + TextOnlyButtonSize, + Record +> = { + small: { + primary: "text-sub14-med", + secondary: "text-sub14-reg", + }, + large: { + primary: "text-b16-med", + secondary: "text-b16-med", + }, +}; + +const colorStyles: Record = { + primary: "text-text-primary-default hover:text-fill-primary-pressed-default", + secondary: + "text-text-neutral-caption hover:text-fill-tertiary-default-pressed", +}; + +export default function TextOnlyButton({ + label = "전체보기", + size = "small", + styleType = "primary", + className, + type = "button", + ...buttonProps +}: TextOnlyButtonProps) { + return ( + + ); +} diff --git a/jobdri/components/buttons/index.ts b/jobdri/components/buttons/index.ts index 7b8813c..0b8afd7 100644 --- a/jobdri/components/buttons/index.ts +++ b/jobdri/components/buttons/index.ts @@ -4,5 +4,10 @@ export { default as ButtonCtaModal } from "./ButtonCtaModal"; export { default as IconButton } from "./IconButton"; export { default as IconOnlyButton } from "./IconOnlyButton"; export { default as TextButton } from "./TextButton"; +export { default as TextOnlyButton } from "./TextOnlyButton"; export type { ButtonSize, ButtonStyle } from "./Button"; export type { TextButtonSize, TextButtonStyle } from "./TextButton"; +export type { + TextOnlyButtonSize, + TextOnlyButtonStyle, +} from "./TextOnlyButton"; diff --git a/jobdri/components/login/EmailLoginScreen.tsx b/jobdri/components/login/EmailLoginScreen.tsx index de6f11b..bfee5c2 100644 --- a/jobdri/components/login/EmailLoginScreen.tsx +++ b/jobdri/components/login/EmailLoginScreen.tsx @@ -2,7 +2,7 @@ import type { Dispatch, FormEvent, SetStateAction } from "react"; import { useEffect, useState } from "react"; -import { Button, TextButton } from "@/components/buttons"; +import { Button, TextOnlyButton } from "@/components/buttons"; import { InputMain } from "@/components/input"; import { Tooltip } from "@/components/tooltip"; @@ -147,17 +147,15 @@ export default function EmailLoginScreen() {
- -
From fc0508671fd29611e4b5aefb06523c5ad633a3e5 Mon Sep 17 00:00:00 2001 From: mingo Date: Mon, 11 May 2026 13:04:08 +0900 Subject: [PATCH 3/4] =?UTF-8?q?Design:=20=ED=81=B4=EB=A6=AD=20=EA=B0=80?= =?UTF-8?q?=EB=8A=A5=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=EC=97=90=20?= =?UTF-8?q?=EC=BB=A4=EC=84=9C=20=ED=8F=AC=EC=9D=B8=ED=84=B0=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20[JDDEV-30]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jobdri/app/globals.css | 24 ++ jobdri/components/buttons/Button.tsx | 6 +- jobdri/components/buttons/ButtonCtaModal.tsx | 2 +- jobdri/components/buttons/IconButton.tsx | 1 + jobdri/components/buttons/IconOnlyButton.tsx | 1 + jobdri/components/buttons/TextButton.tsx | 1 + jobdri/components/buttons/TextOnlyButton.tsx | 1 + jobdri/components/login/EmailLoginScreen.tsx | 287 ++++++++++++++----- 8 files changed, 252 insertions(+), 71 deletions(-) diff --git a/jobdri/app/globals.css b/jobdri/app/globals.css index a089fc5..ab02393 100644 --- a/jobdri/app/globals.css +++ b/jobdri/app/globals.css @@ -9,3 +9,27 @@ font-family: "Pretendard-Variable", sans-serif; } } + +:is( + button, + a[href], + summary, + label[for], + [role="button"], + input[type="button"], + input[type="submit"], + input[type="reset"] +):not(:disabled):not([aria-disabled="true"]) { + cursor: pointer !important; +} + +:is( + button, + [role="button"], + input[type="button"], + input[type="submit"], + input[type="reset"] +):disabled, +[aria-disabled="true"] { + cursor: not-allowed !important; +} diff --git a/jobdri/components/buttons/Button.tsx b/jobdri/components/buttons/Button.tsx index 7ffa5fe..d36c1eb 100644 --- a/jobdri/components/buttons/Button.tsx +++ b/jobdri/components/buttons/Button.tsx @@ -48,7 +48,7 @@ const styleTypeStyles: Record = { }; const inactiveStyle = - "bg-fill-disabled text-text-neutral-disabled hover:bg-fill-disabled"; + "cursor-not-allowed bg-fill-disabled text-text-neutral-disabled hover:bg-fill-disabled"; const iconColorStyles: Record = { primary: "text-icon-neutral-white", @@ -80,7 +80,9 @@ export default function Button({ : "gap-1", sizeStyles[size], radiusStyles[size], - isInactive ? inactiveStyle : styleTypeStyles[resolvedStyleType], + isInactive + ? inactiveStyle + : clsx("cursor-pointer", styleTypeStyles[resolvedStyleType]), className, )} aria-disabled={isInactive || undefined} diff --git a/jobdri/components/buttons/ButtonCtaModal.tsx b/jobdri/components/buttons/ButtonCtaModal.tsx index 4aab42b..0833730 100644 --- a/jobdri/components/buttons/ButtonCtaModal.tsx +++ b/jobdri/components/buttons/ButtonCtaModal.tsx @@ -31,7 +31,7 @@ function ModalIconButton({ label, iconType }: Stack3Item) { return (
-
-
-
- +
+
+
+ + handleInputChange(value, setEmail) + } + /> + +
+ +
+ + + +
+ + + +
-
- -
- - - 또는 - - -
+
+ handleModeChange("login")} + /> + handleModeChange("login")} + /> +
+ + )} -
- -
- - -
- - {showCreditTooltip && ( + {authMode === "login" && showCreditTooltip && (
@@ -170,3 +309,15 @@ export default function EmailLoginScreen() {
); } + +function AuthDivider() { + return ( +
+ + + 또는 + + +
+ ); +} From aabd6220d8c2bd550d33c142f31bc9d2d27919c6 Mon Sep 17 00:00:00 2001 From: mingo Date: Mon, 11 May 2026 18:39:35 +0900 Subject: [PATCH 4/4] =?UTF-8?q?Feat:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=ED=95=98=EA=B8=B0=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20[JDDEV-30]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/input/InputSingleLine.tsx | 74 +++- jobdri/components/common/input/inputStyles.ts | 3 +- jobdri/components/login/EmailLoginScreen.tsx | 384 +++++++++++++++++- 3 files changed, 430 insertions(+), 31 deletions(-) diff --git a/jobdri/components/common/input/InputSingleLine.tsx b/jobdri/components/common/input/InputSingleLine.tsx index 7965a44..8628c4e 100644 --- a/jobdri/components/common/input/InputSingleLine.tsx +++ b/jobdri/components/common/input/InputSingleLine.tsx @@ -1,26 +1,50 @@ "use client"; -import { useState } from "react"; +import type { FocusEvent, InputHTMLAttributes } from "react"; +import { forwardRef, useState } from "react"; import clsx from "clsx"; import { getWrapperClass, getFieldClass } from "./inputStyles"; -interface InputSingleLineProps { +interface InputSingleLineProps + extends Omit< + InputHTMLAttributes, + "value" | "onChange" | "disabled" | "className" + > { placeholder?: string; value?: string; onChange?: (value: string) => void; disabled?: boolean; + hasError?: boolean; error?: string; className?: string; + wrapperClassName?: string; + inputClassName?: string; + focusedBorder?: string; + paddingClass?: string; + radiusClass?: string; } -export function InputSingleLine({ +export const InputSingleLine = forwardRef( + function InputSingleLine( + { placeholder, value: externalValue, onChange, - disabled = false, - error, - className, -}: InputSingleLineProps) { + disabled = false, + hasError = false, + error, + className, + wrapperClassName, + inputClassName, + focusedBorder = "border-line-neutral-strong", + paddingClass, + radiusClass, + onFocus, + onBlur, + ...inputProps + }, + ref, +) { const [internalValue, setInternalValue] = useState(""); const [focused, setFocused] = useState(false); @@ -31,28 +55,46 @@ export function InputSingleLine({ onChange?.(e.target.value); }; + const handleFocus = (event: FocusEvent) => { + setFocused(true); + onFocus?.(event); + }; + + const handleBlur = (event: FocusEvent) => { + setFocused(false); + onBlur?.(event); + }; + return (
setFocused(true)} - onBlur={() => setFocused(false)} + onFocus={handleFocus} + onBlur={handleBlur} disabled={disabled} + {...inputProps} />
{error && {error}}
); -} +}, +); diff --git a/jobdri/components/common/input/inputStyles.ts b/jobdri/components/common/input/inputStyles.ts index 445a8f9..99f686a 100644 --- a/jobdri/components/common/input/inputStyles.ts +++ b/jobdri/components/common/input/inputStyles.ts @@ -6,9 +6,10 @@ export function getWrapperClass( isError: boolean, focusedBorder = "border-line-primary-default", paddingClass = "px-4 py-3", + radiusClass = "rounded-lg", ) { return clsx( - `border rounded-lg ${paddingClass} transition-colors`, + `border ${radiusClass} ${paddingClass} transition-colors`, disabled ? "bg-transparent border-line-neutral-default" : isError diff --git a/jobdri/components/login/EmailLoginScreen.tsx b/jobdri/components/login/EmailLoginScreen.tsx index da142eb..aa22af3 100644 --- a/jobdri/components/login/EmailLoginScreen.tsx +++ b/jobdri/components/login/EmailLoginScreen.tsx @@ -1,9 +1,21 @@ "use client"; -import type { Dispatch, FormEvent, SetStateAction } from "react"; -import { useEffect, useState } from "react"; -import { Button, TextOnlyButton } from "@/components/common/buttons"; -import { InputMain } from "@/components/common/input"; +import type { + ClipboardEvent, + Dispatch, + FormEvent, + KeyboardEvent, + MutableRefObject, + SetStateAction, +} from "react"; +import { useEffect, useRef, useState } from "react"; +import clsx from "clsx"; +import { + Button, + IconOnlyButton, + TextOnlyButton, +} from "@/components/common/buttons"; +import { InputMain, InputSingleLine } from "@/components/common/input"; import { Tooltip } from "@/components/common/tooltip"; const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; @@ -12,14 +24,25 @@ const passwordValidationMessage = "영문, 숫자 조합 8자 이상인지 확인해주세요"; const passwordMaxLengthMessage = "비밀번호는 최대 20자까지만 가능합니다"; const passwordMismatchMessage = "비밀번호가 일치하지 않습니다"; +const verificationCodeLength = 6; +const initialVerificationCode = Array(verificationCodeLength).fill(""); +const verificationErrorMessage = "인증번호를 다시 확인해주세요."; +const mockVerificationSuccessCode = "123456"; export default function EmailLoginScreen() { - const [authMode, setAuthMode] = useState<"login" | "signup">("login"); + const [authMode, setAuthMode] = useState< + "login" | "signup" | "verify" | "success" + >("login"); const [showCreditTooltip, setShowCreditTooltip] = useState(true); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [passwordConfirm, setPasswordConfirm] = useState(""); + const [verificationCode, setVerificationCode] = useState( + initialVerificationCode, + ); + const [hasVerificationError, setHasVerificationError] = useState(false); const [loginError, setLoginError] = useState(false); + const verificationInputRefs = useRef>([]); const isLoginReady = email.length > 0 && password.length > 0; const hasSignupEmailValidationError = @@ -40,6 +63,9 @@ export default function EmailLoginScreen() { emailPattern.test(email) && passwordPattern.test(password) && passwordConfirm === password; + const isVerificationReady = + !hasVerificationError && verificationCode.every(Boolean); + const displayedVerificationEmail = email || "example@gmail.com"; useEffect(() => { if (!showCreditTooltip) { @@ -80,6 +106,100 @@ export default function EmailLoginScreen() { handleInputChange(value, setPasswordConfirm); }; + const focusVerificationInput = (index: number) => { + verificationInputRefs.current[index]?.focus(); + }; + + useEffect(() => { + if (authMode !== "verify" || hasVerificationError) { + return; + } + + window.requestAnimationFrame(() => { + focusVerificationInput(0); + }); + }, [authMode, hasVerificationError]); + + const resetVerificationToInitial = () => { + setVerificationCode([...initialVerificationCode]); + setHasVerificationError(false); + window.requestAnimationFrame(() => { + focusVerificationInput(0); + }); + }; + + const fillVerificationCode = (startIndex: number, value: string) => { + const digits = value.replace(/\D/g, ""); + + if (!digits) { + setVerificationCode((prevCode) => + prevCode.map((digit, index) => (index === startIndex ? "" : digit)), + ); + return; + } + + const nextCode = [...verificationCode]; + const slicedDigits = digits.slice(0, verificationCodeLength - startIndex); + + slicedDigits.split("").forEach((digit, offset) => { + nextCode[startIndex + offset] = digit; + }); + + setVerificationCode(nextCode); + + const nextIndex = Math.min( + startIndex + slicedDigits.length, + verificationCodeLength - 1, + ); + window.requestAnimationFrame(() => { + focusVerificationInput(nextIndex); + }); + }; + + const handleVerificationCodeChange = (index: number, value: string) => { + if (hasVerificationError) { + resetVerificationToInitial(); + return; + } + + fillVerificationCode(index, value); + }; + + const handleVerificationCodeFocus = () => { + if (hasVerificationError) { + resetVerificationToInitial(); + } + }; + + const handleVerificationCodeKeyDown = ( + index: number, + event: KeyboardEvent, + ) => { + if (event.key === "Backspace" && !verificationCode[index] && index > 0) { + focusVerificationInput(index - 1); + return; + } + + if (event.key === "ArrowLeft" && index > 0) { + event.preventDefault(); + focusVerificationInput(index - 1); + return; + } + + if (event.key === "ArrowRight" && index < verificationCodeLength - 1) { + event.preventDefault(); + focusVerificationInput(index + 1); + } + }; + + const handleVerificationCodePaste = ( + index: number, + event: ClipboardEvent, + ) => { + event.preventDefault(); + fillVerificationCode(index, event.clipboardData.getData("text")); + }; + const handleLoginSubmit = (event: FormEvent) => { event.preventDefault(); @@ -101,6 +221,31 @@ export default function EmailLoginScreen() { if (!isSignupReady || !emailPattern.test(email)) { return; } + + setVerificationCode([...initialVerificationCode]); + setHasVerificationError(false); + setAuthMode("verify"); + }; + + const handleVerificationSubmit = (event: FormEvent) => { + event.preventDefault(); + + if (!isVerificationReady) { + return; + } + + if (verificationCode.join("") === mockVerificationSuccessCode) { + setAuthMode("success"); + setVerificationCode([...initialVerificationCode]); + setHasVerificationError(false); + return; + } + + setVerificationCode([...initialVerificationCode]); + setHasVerificationError(true); + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } }; const handleModeChange = (mode: "login" | "signup") => { @@ -109,21 +254,71 @@ export default function EmailLoginScreen() { setEmail(""); setPassword(""); setPasswordConfirm(""); + setVerificationCode([...initialVerificationCode]); + setHasVerificationError(false); hideCreditTooltip(); }; + const handleVerificationSuccessConfirm = () => { + handleModeChange("login"); + }; + + const handleBackToSignup = () => { + setAuthMode("signup"); + setVerificationCode([...initialVerificationCode]); + setHasVerificationError(false); + hideCreditTooltip(); + }; + + const handleResendVerificationCode = () => { + resetVerificationToInitial(); + }; + return (
-
+ {authMode === "verify" ? ( + + ) : authMode === "success" ? ( + + ) : ( + <> +

JobDri

@@ -282,12 +477,9 @@ export default function EmailLoginScreen() {
- handleModeChange("login")} - /> + + 이미 계정이 있으신가요? +
)} + + )} @@ -310,6 +504,168 @@ export default function EmailLoginScreen() { ); } +interface EmailVerificationContentProps { + email: string; + verificationCode: string[]; + verificationInputRefs: MutableRefObject>; + hasVerificationError: boolean; + isVerificationReady: boolean; + onBack: () => void; + onCodeChange: (index: number, value: string) => void; + onCodeFocus: () => void; + onCodeKeyDown: ( + index: number, + event: KeyboardEvent, + ) => void; + onCodePaste: ( + index: number, + event: ClipboardEvent, + ) => void; + onResend: () => void; +} + +function EmailVerificationContent({ + email, + verificationCode, + verificationInputRefs, + hasVerificationError, + isVerificationReady, + onBack, + onCodeChange, + onCodeFocus, + onCodeKeyDown, + onCodePaste, + onResend, +}: EmailVerificationContentProps) { + return ( + <> +
+ +
+ +
+
+

+ JobDri +

+ +
+

+ 이메일 인증하기 +

+
+

+ {email} +

+

+ (으)로 전송한 6자리 코드를 입력해주세요 +

+
+
+
+ +
+
+
+ {verificationCode.map((digit, index) => ( + { + verificationInputRefs.current[index] = input; + }} + value={digit} + onChange={(value) => onCodeChange(index, value)} + onFocus={onCodeFocus} + onKeyDown={(event) => onCodeKeyDown(index, event)} + onPaste={(event) => onCodePaste(index, event)} + inputMode="numeric" + autoComplete={index === 0 ? "one-time-code" : "off"} + maxLength={1} + aria-label={`인증번호 ${index + 1}번째 자리`} + className="!w-[47px] gap-0" + wrapperClassName="h-[63px] w-[47px]" + inputClassName="h-full text-center !text-[24px] !leading-[130%] !font-medium !tracking-[-0.02em] !text-text-neutral-description [font-feature-settings:'liga'_off,'clig'_off]" + paddingClass="p-0" + radiusClass="rounded-card-s" + focusedBorder="border-line-primary-default" + hasError={hasVerificationError} + /> + ))} +
+ + {hasVerificationError && ( +

+ {verificationErrorMessage} +

+ )} +
+ +
+ + 인증코드가 오지 않았나요? + + +
+
+ +
+ + ); +} + +interface EmailVerificationSuccessContentProps { + onConfirm: () => void; +} + +function EmailVerificationSuccessContent({ + onConfirm, +}: EmailVerificationSuccessContentProps) { + return ( + <> +
+

+ JobDri +

+ +

+ {"환영합니다.\n회원가입이 완료되었습니다!"} +

+
+ +