diff --git a/jobdri/README.md b/jobdri/README.md index e215bc4..de72287 100644 --- a/jobdri/README.md +++ b/jobdri/README.md @@ -2,16 +2,11 @@ This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next- ## Getting Started -First, run the development server: +First, install dependencies and run the development server: ```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev +corepack pnpm install +corepack pnpm dev ``` Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. diff --git a/jobdri/app/globals.css b/jobdri/app/globals.css index 13e58c8..cb54312 100644 --- a/jobdri/app/globals.css +++ b/jobdri/app/globals.css @@ -10,3 +10,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/app/layout.tsx b/jobdri/app/layout.tsx index ca60aa7..9034c22 100644 --- a/jobdri/app/layout.tsx +++ b/jobdri/app/layout.tsx @@ -1,7 +1,6 @@ import type { Metadata } from "next"; import "./globals.css"; -import Lnb from "@/components/common/lnb/Lnb"; -import PageHeader from "@/components/common/PageHeader"; +import AppShell from "@/components/common/AppShell"; export const metadata: Metadata = { title: "JobDri", @@ -15,16 +14,8 @@ export default function RootLayout({ }) { 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/app/page.tsx b/jobdri/app/page.tsx index 5713ace..f468944 100644 --- a/jobdri/app/page.tsx +++ b/jobdri/app/page.tsx @@ -1,7 +1,6 @@ import IconBox from "@/components/common/icons/IconBox"; import CheckBox from "@/components/common/icons/CheckBox"; import Header from "@/components/common/header/Header"; -import { Lnb } from "@/components/common/lnb"; import { Button, ButtonCta, @@ -9,9 +8,14 @@ import { IconButton, IconOnlyButton, TextButton, + TextOnlyButton, } from "@/components/common/buttons"; import { Toast, ToastFrame } from "@/components/common/toast"; -import { ChipRound, ChipRoundSelected, ChipQnumber } from "@/components/common/chips"; +import { + ChipRound, + ChipRoundSelected, + ChipQnumber, +} from "@/components/common/chips"; import ChipMainDemo from "@/components/common/chips/ChipMainDemo"; import ModalLinkInputDemo from "@/components/common/modal/ModalLinkInputDemo"; import { CompleteBadge } from "@/components/common/badges"; @@ -211,6 +215,14 @@ export default function Home() { +
+
+ + + + +
+
diff --git a/jobdri/components/common/AppShell.tsx b/jobdri/components/common/AppShell.tsx new file mode 100644 index 0000000..f5a4c07 --- /dev/null +++ b/jobdri/components/common/AppShell.tsx @@ -0,0 +1,30 @@ +"use client"; + +import type { ReactNode } from "react"; +import { usePathname } from "next/navigation"; +import Lnb from "@/components/common/lnb/Lnb"; +import PageHeader from "@/components/common/PageHeader"; + +const standaloneRoutes = new Set(["/login"]); + +export default function AppShell({ children }: { children: ReactNode }) { + const pathname = usePathname(); + + if (standaloneRoutes.has(pathname)) { + return <>{children}; + } + + return ( +
+ +
+
+ + {children} +
+
+
+ ); +} diff --git a/jobdri/components/common/buttons/Button.tsx b/jobdri/components/common/buttons/Button.tsx index 22a8bcc..b7e71a4 100644 --- a/jobdri/components/common/buttons/Button.tsx +++ b/jobdri/components/common/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/common/buttons/ButtonCtaModal.tsx b/jobdri/components/common/buttons/ButtonCtaModal.tsx index b39e137..09f664e 100644 --- a/jobdri/components/common/buttons/ButtonCtaModal.tsx +++ b/jobdri/components/common/buttons/ButtonCtaModal.tsx @@ -31,7 +31,7 @@ function ModalIconButton({ label, iconType }: Stack3Item) { return ( + ); +} diff --git a/jobdri/components/common/buttons/index.ts b/jobdri/components/common/buttons/index.ts index 7b8813c..0b8afd7 100644 --- a/jobdri/components/common/buttons/index.ts +++ b/jobdri/components/common/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/common/input/InputMain.tsx b/jobdri/components/common/input/InputMain.tsx index 1f661b6..024952b 100644 --- a/jobdri/components/common/input/InputMain.tsx +++ b/jobdri/components/common/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/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/icons/Icon.tsx b/jobdri/components/icons/Icon.tsx new file mode 100644 index 0000000..a12e86e --- /dev/null +++ b/jobdri/components/icons/Icon.tsx @@ -0,0 +1,2 @@ +export { default } from "@/components/common/icons/Icon"; +export type { IconType } from "@/components/common/icons/Icon"; diff --git a/jobdri/components/input/InputMain.tsx b/jobdri/components/input/InputMain.tsx new file mode 100644 index 0000000..1b87bd0 --- /dev/null +++ b/jobdri/components/input/InputMain.tsx @@ -0,0 +1 @@ +export { InputMain } from "@/components/common/input/InputMain"; diff --git a/jobdri/components/input/index.ts b/jobdri/components/input/index.ts new file mode 100644 index 0000000..bd4a5a8 --- /dev/null +++ b/jobdri/components/input/index.ts @@ -0,0 +1 @@ +export * from "@/components/common/input"; diff --git a/jobdri/components/input/inputStyles.ts b/jobdri/components/input/inputStyles.ts new file mode 100644 index 0000000..1f64660 --- /dev/null +++ b/jobdri/components/input/inputStyles.ts @@ -0,0 +1 @@ +export * from "@/components/common/input/inputStyles"; diff --git a/jobdri/components/login/EmailLoginScreen.tsx b/jobdri/components/login/EmailLoginScreen.tsx new file mode 100644 index 0000000..aa22af3 --- /dev/null +++ b/jobdri/components/login/EmailLoginScreen.tsx @@ -0,0 +1,679 @@ +"use client"; + +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@]+$/; +const passwordPattern = /^(?=.*[A-Za-z])(?=.*\d).{8,20}$/; +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" | "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 = + authMode === "signup" && email.length > 0 && !emailPattern.test(email); + const hasPasswordMaxLengthError = password.length > 20; + const hasPasswordValidationError = + password.length > 0 && + !hasPasswordMaxLengthError && + !passwordPattern.test(password); + const passwordError = hasPasswordMaxLengthError + ? passwordMaxLengthMessage + : hasPasswordValidationError + ? passwordValidationMessage + : undefined; + const hasPasswordMismatchError = + passwordConfirm.length > 0 && passwordConfirm !== password; + const isSignupReady = + emailPattern.test(email) && + passwordPattern.test(password) && + passwordConfirm === password; + const isVerificationReady = + !hasVerificationError && verificationCode.every(Boolean); + const displayedVerificationEmail = email || "example@gmail.com"; + + 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) => { + handleInputChange(value, setPassword); + + if (value.length === 0) { + setPasswordConfirm(""); + } + }; + + const handlePasswordConfirmChange = (value: string) => { + 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(); + + if ( + !isLoginReady || + !emailPattern.test(email) || + !passwordPattern.test(password) + ) { + setLoginError(true); + return; + } + + setLoginError(true); + }; + + const handleSignupSubmit = (event: FormEvent) => { + event.preventDefault(); + + 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") => { + setAuthMode(mode); + setLoginError(false); + 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 +

+ +
+

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

+

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

+
+
+ + {authMode === "login" ? ( + <> +
+
+
+ + handleInputChange(value, setEmail) + } + /> + +
+ +
+ + + +
+ +
+ + handleModeChange("signup")} + /> +
+ + ) : ( + <> +
+
+
+ + handleInputChange(value, setEmail) + } + /> + + +
+ +
+ + + +
+ +
+ + 이미 계정이 있으신가요? + + handleModeChange("login")} + /> +
+ + )} + + {authMode === "login" && showCreditTooltip && ( +
+ +
+ )} + + )} + +
+
+
+ ); +} + +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회원가입이 완료되었습니다!"} +

+
+ +