Skip to content
Merged
2 changes: 1 addition & 1 deletion app/login/_components/LoginForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export const LoginForm = () => {
/>
{!state.success && state.message && (
<span className="typo-12-400 text-color-flame-700 absolute bottom-[-25px] left-0">
* 이메일 혹은 비밀번호가 틀립니다
* {state.message}
</span>
)}
</div>
Expand Down
21 changes: 19 additions & 2 deletions app/onboarding/_components/StartOnBoarding.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
"use client";
import Button from "@/components/ui/Button";
import React, { useEffect, useState } from "react";
import { useRouter } from "next/navigation";

const StartOnBoarding = () => {
const router = useRouter();
const [showFirst, setShowFirst] = useState(false);
const [showSecond, setShowSecond] = useState(false);
const [showThird, setShowThird] = useState(false);
const [buttonDisabled, setButtonDisabled] = useState(true);

useEffect(() => {
const t1 = setTimeout(() => setShowFirst(true), 100);
const t2 = setTimeout(() => setShowSecond(true), 2100);
const t3 = setTimeout(() => setShowThird(true), 4100);
return () => {
clearTimeout(t1);
clearTimeout(t2);
clearTimeout(t3);
};
}, []);

Expand Down Expand Up @@ -43,12 +48,24 @@ const StartOnBoarding = () => {
opacity: showSecond ? 1 : 0,
transition: "opacity 0.7s ease",
}}
onTransitionEnd={() => setButtonDisabled(false)}
>
원활한 매칭을 위해 여러분의 정보가 필요해요!
</span>
<span
style={{
opacity: showThird ? 1 : 0,
transition: "opacity 0.7s ease",
}}
onTransitionEnd={() => setButtonDisabled(false)}
>
다음 설문을 빠르게 진행해 볼까요?
</span>
</div>
<Button disabled={buttonDisabled} shadow={true}>
<Button
disabled={buttonDisabled}
shadow={true}
onClick={() => router.push("/profile-builder")}
>
네, 좋아요!
</Button>
</section>
Expand Down
37 changes: 28 additions & 9 deletions app/register/_components/PasswordStep.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import Button from "@/components/ui/Button";
import Button from "@/components/ui/Button";
import FormInput from "@/components/ui/FormInput";
import {
validatePasswordLength,
Expand All @@ -22,6 +22,7 @@ export const PasswordStep = ({

const isLengthValid = validatePasswordLength(password);
const isPatternValid = validatePasswordPattern(password);
const isPasswordValid = isLengthValid && isPatternValid;

return (
<section className="mt-10 flex w-full flex-1 flex-col items-start gap-8">
Expand Down Expand Up @@ -79,23 +80,41 @@ export const PasswordStep = ({
)}
</div>
</div>
<div className="typo-14-400 flex gap-[13px]">
<div className="typo-14-400 flex flex-col gap-[8px]">
<span
className={`flex items-center gap-1 ${!password || isLengthValid ? "text-color-text-caption2" : "text-color-text-highlight"}`}
className={`flex items-center gap-1 ${password && isLengthValid ? "text-color-text-highlight" : "text-color-text-caption2"}`}
>
<Check width={14} height={14} strokeWidth={2.5} />
8~20자 이내
<Check
width={14}
height={14}
strokeWidth={2.5}
className={
password && isLengthValid
? "stroke-color-text-highlight"
: "stroke-color-text-caption2"
}
/>
8자 이상 20자 이하
</span>
<span
className={`flex items-center gap-1 ${!password || isPatternValid ? "text-color-text-caption2" : "text-color-text-highlight"}`}
className={`flex items-center gap-1 ${password && isPatternValid ? "text-color-text-highlight" : "text-color-text-caption2"}`}
>
<Check width={14} height={14} strokeWidth={2.5} />
영문 대소문자, 숫자 포함
<Check
width={14}
height={14}
strokeWidth={2.5}
className={
password && isPatternValid
? "stroke-color-text-highlight"
: "stroke-color-text-caption2"
}
/>
영문, 숫자, 특수문자(@$!%*#?&) 포함
</span>
</div>

<div className="mt-auto">
<Button shadow={true} type="submit">
<Button shadow={true} type="submit" disabled={!isPasswordValid}>
완료
</Button>
</div>
Expand Down
14 changes: 4 additions & 10 deletions app/register/_components/ScreenRegister.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
const [password, setPassword] = useState("");

const { mutate: sendEmail, isPending: isSendingEmail } = useSendEmail();
const { mutate: verifyEmail, isPending: isVerifyingEmail } = useVerifyEmail();
const { verify, isPending: isVerifyingEmail } = useVerifyEmail();
const { mutate: signUp, isPending: isSigningUp } = useSignUp();

Check warning on line 19 in app/register/_components/ScreenRegister.tsx

View workflow job for this annotation

GitHub Actions / lint

'isSigningUp' is assigned a value but never used

const handleEmailSubmit = (e: React.SyntheticEvent<HTMLFormElement>) => {
e.preventDefault();
Expand All @@ -25,17 +25,11 @@
});
};

const handleVerify = (code: string, onError: () => void) => {
verifyEmail(
const handleVerify = (code: string, onError: (msg?: string) => void) => {
verify(
{ email, code },
{
onSuccess: (data) => {
if (data.status === 200) {
setStep(3);
} else {
onError();
}
},
onSuccess: () => setStep(3),
onError,
},
);
Expand Down
20 changes: 11 additions & 9 deletions app/register/_components/VerificationStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ type VerificationStepProps = {
email: string;
verificationCode: string;
onVerificationCodeChange: (code: string) => void;
onVerify: (code: string, onError: () => void) => void;
onVerify: (code: string, onError: (msg?: string) => void) => void;
onResend: () => void;
isVerifying?: boolean;
};
Expand All @@ -20,7 +20,7 @@ export const VerificationStep = ({
isVerifying = false,
}: VerificationStepProps) => {
const [timeLeft, setTimeLeft] = useState(300); // 5분 = 300초
const [isWrong, setIsWrong] = useState(false);
const [errorMessage, setErrorMessage] = useState("");

useEffect(() => {
if (timeLeft <= 0) return;
Expand All @@ -40,19 +40,21 @@ export const VerificationStep = ({

const handleVerificationCodeChange = (value: string) => {
onVerificationCodeChange(value);
if (isWrong && value) {
setIsWrong(false);
if (errorMessage && value) {
setErrorMessage("");
}
};

const handleSubmit = (e: React.SyntheticEvent<HTMLFormElement>) => {
e.preventDefault();
onVerify(verificationCode, () => setIsWrong(true));
onVerify(verificationCode, (msg?: string) =>
setErrorMessage(msg || "인증번호를 다시 확인해 주세요"),
);
};

const handleResend = () => {
setTimeLeft(300); // 타이머 리셋
setIsWrong(false); // 에러 상태 초기화
setErrorMessage(""); // 에러 상태 초기화
onResend();
};

Expand Down Expand Up @@ -96,7 +98,7 @@ export const VerificationStep = ({
required
value={verificationCode}
onChange={(e) => handleVerificationCodeChange(e.target.value)}
error={isWrong}
error={!!errorMessage}
maxLength={6}
inputMode="numeric"
className="flex-1"
Expand All @@ -110,9 +112,9 @@ export const VerificationStep = ({
재전송
</Button>
</div>
{isWrong && (
{errorMessage && (
<span className="typo-12-400 text-color-text-highlight">
*인증번호를 다시 확인해 주세요
*{errorMessage}
</span>
)}
</div>
Expand Down
2 changes: 1 addition & 1 deletion components/ui/FormInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const INPUT_STYLE = {
"linear-gradient(180deg, rgba(248, 248, 248, 0.03) 0%, rgba(248, 248, 248, 0.24) 100%)",
};
const INPUT_CLASSNAME =
"all:unset box-border w-full border-b border-gray-300 px-2 py-[14.5px] leading-[19px] typo-16-500 placeholder:text-[#B3B3B3] text-color-gray-900 outline-none";
"all:unset box-border w-full border-b border-gray-300 px-2 py-[14.5px] leading-[19px] typo-16-500 placeholder:text-color-text-caption2 text-color-gray-900 outline-none";

// 안전한 속성 화이트리스트 (XSS 방지)
const SAFE_INPUT_ATTRIBUTES = [
Expand Down
103 changes: 103 additions & 0 deletions components/ui/FormSelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import React from "react";
import { cn } from "@/lib/utils";
import { ChevronDown } from "lucide-react";

// 옵션 타입 정의 (일반적인 단일 옵션)
export type SelectOption = {
value: string;
label: string;
};

// 옵션 그룹 타입 정의 (대학 - 계열 - 학과 처럼 묶여있는 경우)
export type SelectOptionGroup = {
label: string;
options: SelectOption[];
};

interface FormSelectProps extends Omit<
React.SelectHTMLAttributes<HTMLSelectElement>,
"id" | "name"
> {
id: string;
name: string;
options: (SelectOption | SelectOptionGroup)[];
placeholder?: string;
error?: boolean;
}

const SELECT_CONTAINER_STYLE = {
background:
"linear-gradient(180deg, rgba(248, 248, 248, 0.03) 0%, rgba(248, 248, 248, 0.24) 100%)",
};

const SELECT_CLASSNAME =
"all:unset box-border w-full border-b border-gray-300 px-2 py-[14.5px] pr-8 leading-[19px] typo-16-500 text-color-gray-900 outline-none appearance-none cursor-pointer";

// 타입 가드: 옵션 그룹인지 확인
function isOptionGroup(
option: SelectOption | SelectOptionGroup,
): option is SelectOptionGroup {
return "options" in option;
}

const FormSelect = ({
id,
name,
options,
placeholder,
className = "",
style = {},
error = false,
...rest
}: FormSelectProps) => {
return (
<div className="relative w-full">
<select
id={id}
name={name}
className={cn(
SELECT_CLASSNAME,
error && "border-color-flame-500",
!rest.value && "text-color-text-caption2", // placeholder 색상 처리
className,
)}
style={{ ...SELECT_CONTAINER_STYLE, ...style }}
defaultValue={rest.value === undefined ? "" : undefined}
{...rest}
>
{placeholder && (
<option value="" disabled hidden>
{placeholder}
</option>
)}

{options.map((item, index) => {
if (isOptionGroup(item)) {
return (
<optgroup key={`${item.label}-${index}`} label={item.label}>
{item.options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</optgroup>
);
}

return (
<option key={item.value} value={item.value}>
{item.label}
</option>
);
})}
</select>

{/* 오른쪽에 화살표 아이콘 (포인터 이벤트 무시하여 클릭 방해 안 함) */}
<div className="pointer-events-none absolute top-1/2 right-2 -translate-y-1/2 text-gray-400">
<ChevronDown size={20} />
</div>
</div>
);
};

export default FormSelect;
27 changes: 26 additions & 1 deletion hooks/useVerifyEmail.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { api } from "@/lib/axios";
import { useMutation } from "@tanstack/react-query";
import { isAxiosError } from "axios";

type VerifyEmailRequest = {
email: string;
Expand All @@ -23,8 +24,32 @@ const verifyEmail = async (
};

export const useVerifyEmail = () => {
return useMutation({
const mutation = useMutation({
mutationFn: verifyEmail,
retry: 1,
});

const verify = (
payload: VerifyEmailRequest,
options: { onSuccess: () => void; onError: (msg?: string) => void },
) => {
mutation.mutate(payload, {
onSuccess: (data) => {
if (data.status === 200) {
options.onSuccess();
} else {
options.onError(data.message);
}
},
onError: (error) => {
if (isAxiosError(error) && error.response?.data?.message) {
options.onError(error.response.data.message);
} else {
options.onError();
}
},
});
};

return { ...mutation, verify };
};
Loading
Loading