From 6f1f42024b43e3b94b2785778518c102100de95c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=EC=98=A4=EC=A2=85=ED=9D=AC?=
Date: Sat, 9 May 2026 22:41:34 +0900
Subject: [PATCH 01/18] feat(fe): impl verification logic for whitelist removal
---
.../components/auth/SignUpPage/SignUpPage.tsx | 855 ++++++++++++++++--
.../auth/SignUpPage/signup.schema.ts | 21 +-
.../components/auth/SignUpPage/signup.type.ts | 2 +
apps/frontend/package.json | 1 +
apps/frontend/public/icons/reset-gray.svg | 3 +
pnpm-lock.yaml | 8 +
6 files changed, 804 insertions(+), 86 deletions(-)
create mode 100644 apps/frontend/public/icons/reset-gray.svg
diff --git a/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx b/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx
index ecbcac6c60..b08c1f5b9e 100644
--- a/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx
+++ b/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx
@@ -1,14 +1,57 @@
'use client'
-import asteriskGray from '@/public/icons/asterisk-gray.svg'
+import { allMajors } from '@/libs/constants'
+import { cn, safeFetcher } from '@/libs/utils'
+import resetGray from '@/public/icons/reset-gray.svg'
import { valibotResolver } from '@hookform/resolvers/valibot'
+import { searchUniversities } from 'korea-universities'
import Image from 'next/image'
-import { useState } from 'react'
+import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
-import { FaEye, FaEyeSlash } from 'react-icons/fa6'
+import { FaChevronDown, FaChevronUp, FaEye, FaEyeSlash } from 'react-icons/fa6'
+import { IoSearchOutline } from 'react-icons/io5'
import { signupSchema } from './signup.schema'
import type { SignUpFormValues } from './signup.type'
+const JOB_OPTIONS = [
+ '고등학생 이하',
+ '대학생',
+ '직장인',
+ '무직',
+ '기타'
+] as const
+
+const NICKNAME_ADJECTIVES = [
+ '신나는',
+ '빠른',
+ '멋진',
+ '귀여운',
+ '용감한',
+ '활발한',
+ '똑똑한',
+ '재미있는',
+ '행복한',
+ '따뜻한',
+ '씩씩한',
+ '유쾌한'
+]
+const NICKNAME_NOUNS = [
+ '청사과',
+ '고양이',
+ '펭귄',
+ '토끼',
+ '고래',
+ '독수리',
+ '호랑이',
+ '다람쥐',
+ '여우',
+ '늑대',
+ '곰',
+ '사자'
+]
+
+const PIN_EXPIRE_SECONDS = 300
+
interface AgreementCheckboxProps {
checked: boolean
onChange: (checked: boolean) => void
@@ -74,7 +117,10 @@ export function SignUpPage() {
handleSubmit,
watch,
setValue,
- formState: { errors, isValid }
+ trigger,
+ setError,
+ clearErrors,
+ formState: { errors, touchedFields }
} = useForm({
resolver: valibotResolver(signupSchema),
mode: 'onChange',
@@ -86,6 +132,8 @@ export function SignUpPage() {
passwordConfirm: '',
nickname: '',
job: '',
+ university: '',
+ major: '',
email: '',
terms: false,
privacy: false,
@@ -96,9 +144,42 @@ export function SignUpPage() {
const [isPasswordVisible, setIsPasswordVisible] = useState(false)
+ // 아이디 중복 확인
+ const [isUserIdAvailable, setIsUserIdAvailable] = useState(false)
+
+ // 닉네임 중복 확인
+ const [isNicknameAvailable, setIsNicknameAvailable] = useState(false)
+ const [nicknameChecked, setNicknameChecked] = useState(false)
+
+ // 이메일 인증
+ const [emailLocal, setEmailLocal] = useState('')
+ const [emailSent, setEmailSent] = useState(false)
+ const [emailVerified, setEmailVerified] = useState(false)
+ const [emailAuthToken, setEmailAuthToken] = useState('')
+ const [verificationCode, setVerificationCode] = useState('')
+ const [pinError, setPinError] = useState('')
+ const [codeExpired, setCodeExpired] = useState(false)
+ const [remaining, setRemaining] = useState(PIN_EXPIRE_SECONDS)
+ const [endTime, setEndTime] = useState(0)
+
+ // 직업 드롭다운
+ const [jobOpen, setJobOpen] = useState(false)
+
+ // 대학교 검색 드롭다운
+ const [universityQuery, setUniversityQuery] = useState('')
+ const [universityOpen, setUniversityOpen] = useState(false)
+
+ // 학과 검색 드롭다운 (성균관대학교만)
+ const [majorQuery, setMajorQuery] = useState('')
+ const [majorOpen, setMajorOpen] = useState(false)
+
const watchPassword = watch('password')
const watchPasswordConfirm = watch('passwordConfirm')
const watchBirth = watch('birth')
+ const watchJob = watch('job')
+ const watchUserId = watch('userId')
+ const watchUniversity = watch('university')
+ const watchNickname = watch('nickname')
const isPasswordMismatch =
watchPasswordConfirm.length > 0 && watchPassword !== watchPasswordConfirm
@@ -108,40 +189,312 @@ export function SignUpPage() {
watchPassword === watchPasswordConfirm &&
!errors.password
+ const isSKKU = watchUniversity.startsWith('성균관대학교')
+
const isAllChecked =
agreements.terms &&
agreements.privacy &&
agreements.minorPrivacy &&
agreements.marketing
+ // 타이머
+ useEffect(() => {
+ if (!emailSent || codeExpired || emailVerified) {
+ return
+ }
+ const id = setInterval(() => {
+ const rem = Math.max(0, Math.round((endTime - Date.now()) / 1000))
+ setRemaining(rem)
+ if (rem === 0) {
+ setCodeExpired(true)
+ }
+ }, 1000)
+ return () => clearInterval(id)
+ }, [emailSent, endTime, codeExpired, emailVerified])
+
+ // 아이디 변경 시 중복 확인 초기화
+ useEffect(() => {
+ setIsUserIdAvailable(false)
+ }, [watchUserId])
+
+ // 닉네임 변경 시 중복 확인 초기화
+ useEffect(() => {
+ setIsNicknameAvailable(false)
+ setNicknameChecked(false)
+ }, [watchNickname])
+
+ // emailLocal 변경 시 form email 동기화
+ useEffect(() => {
+ const fullEmail = isSKKU ? `${emailLocal}@skku.edu` : emailLocal
+ setValue('email', fullEmail, { shouldValidate: emailLocal.length > 0 })
+ }, [emailLocal, isSKKU, setValue])
+
+ // 이메일 변경 시 인증 초기화
+ useEffect(() => {
+ setEmailSent(false)
+ setEmailVerified(false)
+ setVerificationCode('')
+ setPinError('')
+ setCodeExpired(false)
+ }, [emailLocal])
+
+ // 대학교 구분 변경 시 이메일·학과 초기화
+ useEffect(() => {
+ setEmailLocal('')
+ setEmailSent(false)
+ setEmailVerified(false)
+ setVerificationCode('')
+ setPinError('')
+ setCodeExpired(false)
+ setValue('major', '')
+ setMajorQuery('')
+ }, [isSKKU, setValue])
+
+ const formatTimer = () => {
+ const min = Math.floor(remaining / 60)
+ const sec = remaining % 60
+ return `${min}:${sec < 10 ? '0' : ''}${sec}`
+ }
+
+ const sendEmail = async () => {
+ if (!emailLocal) {
+ return
+ }
+ try {
+ const fullEmail = isSKKU ? `${emailLocal}@skku.edu` : emailLocal
+ await safeFetcher.post('email-auth/send-email/register-new', {
+ json: { email: fullEmail }
+ })
+ const now = Date.now()
+ setEndTime(now + PIN_EXPIRE_SECONDS * 1000)
+ setRemaining(PIN_EXPIRE_SECONDS)
+ setEmailSent(true)
+ setCodeExpired(false)
+ setEmailVerified(false)
+ setVerificationCode('')
+ setPinError('')
+ } catch {
+ setError('email', {
+ message: '이메일 전송에 실패했습니다. 다시 시도해주세요.'
+ })
+ }
+ }
+
+ const verifyPin = async (code: string) => {
+ if (code.length !== 6) {
+ return
+ }
+ try {
+ const response = await safeFetcher.post('email-auth/verify-pin', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({
+ pin: code,
+ email: isSKKU ? `${emailLocal}@skku.edu` : emailLocal
+ })
+ })
+ if (response.status === 201) {
+ setEmailVerified(true)
+ setEmailAuthToken(response.headers.get('email-auth') || '')
+ setPinError('')
+ } else {
+ setPinError('인증 코드가 올바르지 않습니다.')
+ setEmailVerified(false)
+ }
+ } catch {
+ setPinError('인증 코드가 올바르지 않습니다.')
+ setEmailVerified(false)
+ }
+ }
+
+ const checkUserId = async () => {
+ const valid = await trigger('userId')
+ if (!valid) {
+ return
+ }
+ try {
+ await safeFetcher.get(`user/username-check?username=${watchUserId}`)
+ clearErrors('userId')
+ setIsUserIdAvailable(true)
+ } catch {
+ setError('userId', { message: '중복된 아이디입니다.' })
+ setIsUserIdAvailable(false)
+ }
+ }
+
+ const generateNickname = () => {
+ const adj =
+ NICKNAME_ADJECTIVES[
+ Math.floor(Math.random() * NICKNAME_ADJECTIVES.length)
+ ]
+ const noun =
+ NICKNAME_NOUNS[Math.floor(Math.random() * NICKNAME_NOUNS.length)]
+ setValue('nickname', `${adj} ${noun}`, { shouldValidate: true })
+ }
+
+ const checkNickname = async () => {
+ if (!watchNickname) {
+ return
+ }
+ try {
+ await safeFetcher.get(
+ `user/nickname-check?nickname=${encodeURIComponent(watchNickname)}`
+ )
+ setIsNicknameAvailable(true)
+ setNicknameChecked(true)
+ } catch {
+ setIsNicknameAvailable(false)
+ setNicknameChecked(true)
+ }
+ }
+
const handleAllAgreementChange = (checked: boolean) => {
- const nextAgreements = {
+ const next = {
terms: checked,
privacy: checked,
minorPrivacy: checked,
marketing: checked
}
-
- setAgreements(nextAgreements)
- setValue('terms', checked, { shouldValidate: true })
- setValue('privacy', checked, { shouldValidate: true })
- setValue('minorPrivacy', checked, { shouldValidate: true })
- setValue('marketing', checked, { shouldValidate: true })
+ setAgreements(next)
+ setValue('terms', checked)
+ setValue('privacy', checked)
+ setValue('minorPrivacy', checked)
+ setValue('marketing', checked)
}
const handleAgreementChange = (
key: 'terms' | 'privacy' | 'minorPrivacy' | 'marketing',
checked: boolean
) => {
- setAgreements((prev) => ({
- ...prev,
- [key]: checked
- }))
+ setAgreements((prev) => ({ ...prev, [key]: checked }))
+ setValue(key, checked)
+ }
+
+ const filteredUniversities =
+ universityQuery.length > 0 ? searchUniversities(universityQuery) : []
+
+ const filteredMajors =
+ majorQuery.length > 0
+ ? allMajors.filter((m) =>
+ m.toLowerCase().includes(majorQuery.toLowerCase())
+ )
+ : []
+
+ const getKoreanMajorName = (major: string) =>
+ major
+ .split(/\s*\/\s*/)
+ .at(-1)
+ ?.trim() ?? major
+
+ const CAMPUS_OVERRIDES: Record> = {
+ 성균관대학교: { 제1캠퍼스: '서울캠퍼스', 제2캠퍼스: '수원캠퍼스' },
+ 연세대학교: { 제1캠퍼스: '신촌캠퍼스', 제2캠퍼스: '국제캠퍼스' },
+ 경희대학교: { 제1캠퍼스: '서울캠퍼스', 제2캠퍼스: '국제캠퍼스' },
+ 중앙대학교: { 제1캠퍼스: '서울캠퍼스', 제2캠퍼스: '안성캠퍼스' },
+ 한국외국어대학교: { 제1캠퍼스: '서울캠퍼스', 제2캠퍼스: '글로벌캠퍼스' },
+ 단국대학교: { 제1캠퍼스: '죽전캠퍼스', 제2캠퍼스: '천안캠퍼스' },
+ 부산대학교: {
+ 제1캠퍼스: '부산캠퍼스',
+ 제2캠퍼스: '밀양캠퍼스',
+ 제3캠퍼스: '양산캠퍼스'
+ },
+ 강원대학교: { 제1캠퍼스: '춘천캠퍼스', 제2캠퍼스: '삼척캠퍼스' }
+ }
+
+ const REGION_SHORT: Record = {
+ 서울특별시: '서울',
+ 경기도: '경기',
+ 인천광역시: '인천',
+ 부산광역시: '부산',
+ 대구광역시: '대구',
+ 광주광역시: '광주',
+ 대전광역시: '대전',
+ 울산광역시: '울산',
+ 세종특별자치시: '세종',
+ 강원특별자치도: '강원',
+ 충청북도: '충북',
+ 충청남도: '충남',
+ 전라남도: '전남',
+ 전북특별자치도: '전북',
+ 경상북도: '경북',
+ 경상남도: '경남',
+ 제주특별자치도: '제주'
+ }
+
+ const getUniversityDisplayName = (
+ uni: (typeof filteredUniversities)[number]
+ ) => {
+ const hasDuplicate =
+ filteredUniversities.filter((u) => u.nameKr === uni.nameKr).length > 1
+ if (!hasDuplicate) {
+ return uni.nameKr
+ }
- setValue(key, checked, { shouldValidate: true })
+ const override = CAMPUS_OVERRIDES[uni.nameKr]?.[uni.campus ?? '']
+ if (override) {
+ return `${uni.nameKr} ${override}`
+ }
+
+ const sameRegion =
+ filteredUniversities.filter(
+ (u) => u.nameKr === uni.nameKr && u.region === uni.region
+ ).length > 1
+ if (sameRegion) {
+ return `${uni.nameKr} ${uni.campus}`
+ }
+
+ const short = REGION_SHORT[uni.region] ?? uni.region
+ return `${uni.nameKr} ${short}캠퍼스`
+ }
+
+ const canSubmit =
+ isUserIdAvailable &&
+ emailVerified &&
+ agreements.terms &&
+ agreements.privacy &&
+ agreements.minorPrivacy
+
+ const getUserIdBorderClass = () => {
+ if (errors.userId) {
+ return 'border-error focus:border-error'
+ }
+ if (isUserIdAvailable) {
+ return 'border-[#3581FA] focus:border-[#3581FA]'
+ }
+ return 'focus:border-primary border-[#D8D8D8]'
+ }
+
+ const getEmailBorderClass = () => {
+ if (errors.email) {
+ return 'border-error focus:border-error'
+ }
+ if (emailVerified) {
+ return 'border-[#3581FA] focus:border-[#3581FA]'
+ }
+ return 'focus:border-primary border-[#D8D8D8]'
}
- const onSubmit = async (_data: SignUpFormValues) => {}
+ const onSubmit = async (data: SignUpFormValues) => {
+ if (!canSubmit) {
+ return
+ }
+ try {
+ await safeFetcher.post('user/sign-up', {
+ headers: { 'email-auth': emailAuthToken },
+ json: {
+ username: data.userId,
+ password: data.password,
+ email: data.email,
+ realName: data.name,
+ college: data.university || undefined,
+ major: data.major || undefined
+ }
+ })
+ } catch {
+ // handle error
+ }
+ }
return (
+ {/* 이름 */}
이름
{errors.name?.message && (
@@ -172,17 +527,19 @@ export function SignUpPage() {
)}
+ {/* 생년월일 */}
생년월일 6자리
{watchBirth && errors.birth?.message && (
@@ -192,38 +549,62 @@ export function SignUpPage() {
)}
+ {/* 아이디 + 중복 확인 */}
아이디
-
+
+
+
+ 중복 확인
+
+
{errors.userId?.message && (
{errors.userId.message}
)}
+ {!errors.userId &&
+ touchedFields.userId &&
+ !isUserIdAvailable &&
+ watchUserId.length >= 3 && (
+
+ 아이디 중복 확인을 해주세요.
+
+ )}
+ {!errors.userId && isUserIdAvailable && (
+
+ 사용 가능한 아이디입니다.
+
+ )}
+ {/* 비밀번호 */}
비밀번호
-
-
-
-
닉네임
-
+ {/* 닉네임 */}
+
+
닉네임
+
-
-
-
-
- 닉네임 미입력시, 코드당이 자동으로 닉네임을 추천해드려요!
+ {nicknameChecked && isNicknameAvailable && (
+
+ 사용 가능한 닉네임입니다.
-
+ )}
+ {nicknameChecked && !isNicknameAvailable && (
+
+ 이미 사용 중인 닉네임입니다.
+
+ )}
- {/*
+ {/* 직업 드롭다운 */}
+
직업
-
- {errors.job && (
-
+
{
+ if (!e.currentTarget.contains(e.relatedTarget as Node)) {
+ setJobOpen(false)
+ }
+ }}
+ tabIndex={-1}
+ >
+
setJobOpen((prev) => !prev)}
+ className={cn(
+ 'placeholder:text-body1_m_16 flex h-[46px] w-full items-center justify-between rounded-[12px] border bg-white px-5 py-[11px] outline-none',
+ errors.job
+ ? 'border-error'
+ : 'focus:border-primary border-[#D8D8D8]'
+ )}
+ >
+
+ {watchJob || '직업'}
+
+ {jobOpen ? (
+
+ ) : (
+
+ )}
+
+ {jobOpen && (
+
+ {JOB_OPTIONS.map((option) => (
+ e.preventDefault()}
+ onClick={() => {
+ setValue('job', option, { shouldValidate: true })
+ if (option !== '대학생') {
+ setValue('university', '')
+ setValue('major', '')
+ setUniversityQuery('')
+ }
+ setJobOpen(false)
+ }}
+ className={cn(
+ 'text-body1_m_16 cursor-pointer px-5 py-[13px] hover:bg-[#F5F5F5]',
+ watchJob === option && 'bg-[#F0F5FF]'
+ )}
+ >
+ {option}
+
+ ))}
+
+ )}
+
+ {errors.job?.message && (
+
{errors.job.message}
)}
-
*/}
+
-
+ {/* 대학교 & 학과 (직업이 대학생일 때만) */}
+ {watchJob === '대학생' && (
+
+ {/* 대학교 검색 */}
+
+
대학교
+
{
+ if (!e.currentTarget.contains(e.relatedTarget as Node)) {
+ setUniversityOpen(false)
+ }
+ }}
+ tabIndex={-1}
+ >
+
+ {
+ setUniversityQuery(e.target.value)
+ setUniversityOpen(true)
+ if (watchUniversity) {
+ setValue('university', '', { shouldValidate: true })
+ }
+ }}
+ onFocus={() => setUniversityOpen(true)}
+ className="placeholder:text-body1_m_16 focus:border-primary h-[46px] w-full rounded-[12px] border border-[#D8D8D8] bg-white px-5 py-[11px] pr-11 outline-none placeholder:text-[#C4C4C4]"
+ />
+
+
+ {universityOpen && universityQuery.length > 0 && (
+
+ {filteredUniversities.length > 0 ? (
+ filteredUniversities.map((uni) => {
+ const displayName = getUniversityDisplayName(uni)
+ return (
+ e.preventDefault()}
+ onClick={() => {
+ setValue('university', displayName, {
+ shouldValidate: true
+ })
+ setUniversityQuery(displayName)
+ setUniversityOpen(false)
+ }}
+ className={cn(
+ 'text-body1_m_16 cursor-pointer px-5 py-[13px] hover:bg-[#F5F5F5]',
+ watchUniversity === displayName &&
+ 'bg-[#F0F5FF]'
+ )}
+ >
+ {displayName}
+
+ )
+ })
+ ) : (
+
+ 검색 결과가 없습니다.
+
+ )}
+
+ )}
+
+
+
+ {/* 학과 (성균관대학교만) */}
+ {isSKKU && (
+
+
학과
+
{
+ if (
+ !e.currentTarget.contains(e.relatedTarget as Node)
+ ) {
+ setMajorOpen(false)
+ }
+ }}
+ tabIndex={-1}
+ >
+
+ {
+ setMajorQuery(e.target.value)
+ setMajorOpen(true)
+ setValue('major', '', { shouldValidate: true })
+ }}
+ onFocus={() => setMajorOpen(true)}
+ className="placeholder:text-body1_m_16 focus:border-primary h-[46px] w-full rounded-[12px] border border-[#D8D8D8] bg-white px-5 py-[11px] pr-11 outline-none placeholder:text-[#C4C4C4]"
+ />
+
+
+ {majorOpen && majorQuery.length > 0 && (
+
+ {filteredMajors.length > 0 ? (
+ filteredMajors.map((major) => {
+ const displayName = getKoreanMajorName(major)
+ return (
+ e.preventDefault()}
+ onClick={() => {
+ setValue('major', displayName, {
+ shouldValidate: true
+ })
+ setMajorQuery(displayName)
+ setMajorOpen(false)
+ }}
+ className={cn(
+ 'text-body1_m_16 cursor-pointer px-5 py-[13px] hover:bg-[#F5F5F5]',
+ majorQuery === displayName && 'bg-[#F0F5FF]'
+ )}
+ >
+ {displayName}
+
+ )
+ })
+ ) : (
+
+ 검색 결과가 없습니다.
+
+ )}
+
+ )}
+
+
+ )}
+
+ )}
+
+ {/* 이메일 + 인증 */}
+
이메일
-
+
{errors.email?.message && (
{errors.email.message}
)}
+ {emailVerified && (
+
+ 이메일 인증이 완료되었습니다.
+
+ )}
+
+ {/* PIN 입력 */}
+ {emailSent && !emailVerified && (
+
+
+ {
+ const val = e.target.value
+ .replace(/\D/g, '')
+ .slice(0, 6)
+ setVerificationCode(val)
+ setPinError('')
+ }}
+ disabled={codeExpired}
+ className={cn(
+ 'placeholder:text-body1_m_16 h-[46px] w-full rounded-[12px] border bg-white px-5 py-[11px] pr-20 outline-none placeholder:text-[#C4C4C4]',
+ pinError || codeExpired
+ ? 'border-error focus:border-error'
+ : 'focus:border-primary border-[#D8D8D8]'
+ )}
+ />
+ {!codeExpired && (
+
+ {formatTimer()}
+
+ )}
+
+
verifyPin(verificationCode)}
+ disabled={verificationCode.length !== 6 || codeExpired}
+ className="text-caption2_m_12 h-[46px] shrink-0 rounded-[12px] bg-[#3581FA] px-4 text-white disabled:cursor-not-allowed disabled:opacity-40"
+ >
+ 인증 확인
+
+
+ )}
+ {codeExpired && (
+
+ 인증 코드가 만료되었습니다. 재발송 버튼을 눌러주세요.
+
+ )}
+ {pinError && (
+
{pinError}
+ )}
+ {/* 약관 동의 */}
가입하기
diff --git a/apps/frontend/components/auth/SignUpPage/signup.schema.ts b/apps/frontend/components/auth/SignUpPage/signup.schema.ts
index d58af1a02c..13faa6d822 100644
--- a/apps/frontend/components/auth/SignUpPage/signup.schema.ts
+++ b/apps/frontend/components/auth/SignUpPage/signup.schema.ts
@@ -22,11 +22,11 @@ export const signupSchema = v.pipe(
password: v.pipe(
v.string(),
- v.minLength(8, '비밀번호는 8자 이상이어야 합니다.'),
- v.maxLength(20, '비밀번호는 20자 이하여야 합니다.'),
+ v.minLength(8, '영문자, 숫자, 특수문자 포함 8-20자를 입력해주세요.'),
+ v.maxLength(20, '영문자, 숫자, 특수문자 포함 8-20자를 입력해주세요.'),
v.regex(
- /^(?=.*[A-Za-z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).*$/,
- '비밀번호는 영문자, 숫자, 특수문자를 포함해야 합니다.'
+ /^(?=.*[a-z])(?=.*[A-Z])|(?=.*[a-z])(?=.*\d)|(?=.*[A-Z])(?=.*\d)/,
+ '영문자, 숫자, 특수문자 포함 8-20자를 입력해주세요.'
)
),
@@ -37,13 +37,22 @@ export const signupSchema = v.pipe(
nickname: v.string(),
- job: v.pipe(v.string(), v.minLength(1, '직업을 입력해주세요.')),
+ job: v.pipe(v.string(), v.minLength(1, '직업을 선택해주세요.')),
+
+ university: v.string(),
+
+ major: v.string(),
email: v.pipe(
v.string(),
v.minLength(1, '이메일을 입력해주세요.'),
v.email('올바른 이메일 형식이 아닙니다.')
- )
+ ),
+
+ terms: v.boolean(),
+ privacy: v.boolean(),
+ minorPrivacy: v.boolean(),
+ marketing: v.boolean()
}),
v.forward(
v.check(
diff --git a/apps/frontend/components/auth/SignUpPage/signup.type.ts b/apps/frontend/components/auth/SignUpPage/signup.type.ts
index 5a33bd8dbf..155b56b824 100644
--- a/apps/frontend/components/auth/SignUpPage/signup.type.ts
+++ b/apps/frontend/components/auth/SignUpPage/signup.type.ts
@@ -6,6 +6,8 @@ export interface SignUpFormValues {
passwordConfirm: string
nickname: string
job: string
+ university: string
+ major: string
email: string
terms: boolean
privacy: boolean
diff --git a/apps/frontend/package.json b/apps/frontend/package.json
index b0e04ed812..a85a277051 100644
--- a/apps/frontend/package.json
+++ b/apps/frontend/package.json
@@ -106,6 +106,7 @@
"jszip": "^3.10.1",
"jwt-decode": "^4.0.0",
"katex": "^0.16.22",
+ "korea-universities": "^1.0.1",
"ky": "^1.8.2",
"lowlight": "^3.3.0",
"next": "15.4.10",
diff --git a/apps/frontend/public/icons/reset-gray.svg b/apps/frontend/public/icons/reset-gray.svg
new file mode 100644
index 0000000000..5dedd099ec
--- /dev/null
+++ b/apps/frontend/public/icons/reset-gray.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a508602688..0c172919e6 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -713,6 +713,9 @@ importers:
katex:
specifier: ^0.16.22
version: 0.16.22
+ korea-universities:
+ specifier: ^1.0.1
+ version: 1.0.1
ky:
specifier: ^1.8.2
version: 1.8.2
@@ -9139,6 +9142,9 @@ packages:
'@types/node': '>=18'
typescript: '>=5.0.4'
+ korea-universities@1.0.1:
+ resolution: {integrity: sha512-OK/CE5sEBxGA1JowRpSfA1C6gCwP4LYYJlPAeSj14fc2WNr7HRwzk7nmB85hkyZAzBLX2N4Ezq0z0SmxCvAeTQ==}
+
ky@1.8.2:
resolution: {integrity: sha512-XybQJ3d4Ea1kI27DoelE5ZCT3bSJlibYTtQuMsyzKox3TMyayw1asgQdl54WroAm+fIA3ZCr8zXW2RpR7qWVpA==}
engines: {node: '>=18'}
@@ -22885,6 +22891,8 @@ snapshots:
zod: 3.25.76
zod-validation-error: 3.5.3(zod@3.25.76)
+ korea-universities@1.0.1: {}
+
ky@1.8.2: {}
language-subtag-registry@0.3.23: {}
From fd37c8a347cfc36abd09c81eb287d7c56c162b28 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=EC=98=A4=EC=A2=85=ED=9D=AC?=
Date: Sat, 9 May 2026 22:50:49 +0900
Subject: [PATCH 02/18] fix(fe): add studentId field, remove comments, and fix
error message
---
.../components/auth/SignUpPage/SignUpPage.tsx | 84 +++++++++++--------
.../auth/SignUpPage/signup.schema.ts | 12 ++-
.../components/auth/SignUpPage/signup.type.ts | 1 +
3 files changed, 60 insertions(+), 37 deletions(-)
diff --git a/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx b/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx
index b08c1f5b9e..1d986b0b2e 100644
--- a/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx
+++ b/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx
@@ -134,6 +134,7 @@ export function SignUpPage() {
job: '',
university: '',
major: '',
+ studentId: '',
email: '',
terms: false,
privacy: false,
@@ -143,15 +144,9 @@ export function SignUpPage() {
})
const [isPasswordVisible, setIsPasswordVisible] = useState(false)
-
- // 아이디 중복 확인
const [isUserIdAvailable, setIsUserIdAvailable] = useState(false)
-
- // 닉네임 중복 확인
const [isNicknameAvailable, setIsNicknameAvailable] = useState(false)
const [nicknameChecked, setNicknameChecked] = useState(false)
-
- // 이메일 인증
const [emailLocal, setEmailLocal] = useState('')
const [emailSent, setEmailSent] = useState(false)
const [emailVerified, setEmailVerified] = useState(false)
@@ -161,15 +156,9 @@ export function SignUpPage() {
const [codeExpired, setCodeExpired] = useState(false)
const [remaining, setRemaining] = useState(PIN_EXPIRE_SECONDS)
const [endTime, setEndTime] = useState(0)
-
- // 직업 드롭다운
const [jobOpen, setJobOpen] = useState(false)
-
- // 대학교 검색 드롭다운
const [universityQuery, setUniversityQuery] = useState('')
const [universityOpen, setUniversityOpen] = useState(false)
-
- // 학과 검색 드롭다운 (성균관대학교만)
const [majorQuery, setMajorQuery] = useState('')
const [majorOpen, setMajorOpen] = useState(false)
@@ -197,7 +186,6 @@ export function SignUpPage() {
agreements.minorPrivacy &&
agreements.marketing
- // 타이머
useEffect(() => {
if (!emailSent || codeExpired || emailVerified) {
return
@@ -212,24 +200,20 @@ export function SignUpPage() {
return () => clearInterval(id)
}, [emailSent, endTime, codeExpired, emailVerified])
- // 아이디 변경 시 중복 확인 초기화
useEffect(() => {
setIsUserIdAvailable(false)
}, [watchUserId])
- // 닉네임 변경 시 중복 확인 초기화
useEffect(() => {
setIsNicknameAvailable(false)
setNicknameChecked(false)
}, [watchNickname])
- // emailLocal 변경 시 form email 동기화
useEffect(() => {
const fullEmail = isSKKU ? `${emailLocal}@skku.edu` : emailLocal
setValue('email', fullEmail, { shouldValidate: emailLocal.length > 0 })
}, [emailLocal, isSKKU, setValue])
- // 이메일 변경 시 인증 초기화
useEffect(() => {
setEmailSent(false)
setEmailVerified(false)
@@ -238,7 +222,6 @@ export function SignUpPage() {
setCodeExpired(false)
}, [emailLocal])
- // 대학교 구분 변경 시 이메일·학과 초기화
useEffect(() => {
setEmailLocal('')
setEmailSent(false)
@@ -248,6 +231,7 @@ export function SignUpPage() {
setCodeExpired(false)
setValue('major', '')
setMajorQuery('')
+ setValue('studentId', '')
}, [isSKKU, setValue])
const formatTimer = () => {
@@ -299,11 +283,11 @@ export function SignUpPage() {
setEmailAuthToken(response.headers.get('email-auth') || '')
setPinError('')
} else {
- setPinError('인증 코드가 올바르지 않습니다.')
+ setPinError('인증 번호가 일치하지 않습니다.')
setEmailVerified(false)
}
} catch {
- setPinError('인증 코드가 올바르지 않습니다.')
+ setPinError('인증 번호가 일치하지 않습니다.')
setEmailVerified(false)
}
}
@@ -479,6 +463,14 @@ export function SignUpPage() {
if (!canSubmit) {
return
}
+ if (data.job === '대학생' && !data.university) {
+ setError('university', { message: '대학교를 선택해주세요.' })
+ return
+ }
+ if (isSKKU && !data.major) {
+ setError('major', { message: '소속 학과를 선택해주세요.' })
+ return
+ }
try {
await safeFetcher.post('user/sign-up', {
headers: { 'email-auth': emailAuthToken },
@@ -488,11 +480,12 @@ export function SignUpPage() {
email: data.email,
realName: data.name,
college: data.university || undefined,
- major: data.major || undefined
+ major: data.major || undefined,
+ studentId: data.studentId || undefined
}
})
} catch {
- // handle error
+ /* empty */
}
}
@@ -506,7 +499,6 @@ export function SignUpPage() {
회원가입
- {/* 이름 */}
이름
- {/* 생년월일 */}
생년월일 6자리
- {/* 아이디 + 중복 확인 */}
아이디
@@ -591,7 +581,6 @@ export function SignUpPage() {
)}
- {/* 비밀번호 */}
비밀번호
@@ -647,7 +636,6 @@ export function SignUpPage() {
- {/* 닉네임 */}
닉네임
@@ -688,12 +676,11 @@ export function SignUpPage() {
)}
{nicknameChecked && !isNicknameAvailable && (
- 이미 사용 중인 닉네임입니다.
+ 중복된 닉네임입니다.
)}
- {/* 직업 드롭다운 */}
직업
- {/* 대학교 & 학과 (직업이 대학생일 때만) */}
{watchJob === '대학생' && (
- {/* 대학교 검색 */}
대학교
)}
+ {errors.university?.message && (
+
+ {errors.university.message}
+
+ )}
- {/* 학과 (성균관대학교만) */}
{isSKKU && (
학과
@@ -897,12 +886,39 @@ export function SignUpPage() {
)}
+ {errors.major?.message && (
+
+ {errors.major.message}
+
+ )}
+
+ )}
+
+ {isSKKU && (
+
+
학번
+
+ {errors.studentId?.message && (
+
+ {errors.studentId.message}
+
+ )}
)}
)}
- {/* 이메일 + 인증 */}
이메일
@@ -960,7 +976,6 @@ export function SignUpPage() {
)}
- {/* PIN 입력 */}
{emailSent && !emailVerified && (
@@ -1013,7 +1028,6 @@ export function SignUpPage() {
- {/* 약관 동의 */}
val === '' || /^\d{10}$/.test(val),
+ '학번 10자리를 입력해주세요.'
+ )
+ ),
+
email: v.pipe(
v.string(),
v.minLength(1, '이메일을 입력해주세요.'),
diff --git a/apps/frontend/components/auth/SignUpPage/signup.type.ts b/apps/frontend/components/auth/SignUpPage/signup.type.ts
index 155b56b824..e95b30c954 100644
--- a/apps/frontend/components/auth/SignUpPage/signup.type.ts
+++ b/apps/frontend/components/auth/SignUpPage/signup.type.ts
@@ -8,6 +8,7 @@ export interface SignUpFormValues {
job: string
university: string
major: string
+ studentId: string
email: string
terms: boolean
privacy: boolean
From aac821964b1d12f0b8f9fcd3206268d10dac4441 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=EC=98=A4=EC=A2=85=ED=9D=AC?=
Date: Sun, 10 May 2026 08:51:22 +0900
Subject: [PATCH 03/18] fix(fe): apply feedback
---
.../components/auth/SignUpPage/SignUpPage.tsx | 11 +++++------
.../components/auth/SignUpPage/signup.schema.ts | 14 ++++++++++----
2 files changed, 15 insertions(+), 10 deletions(-)
diff --git a/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx b/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx
index 1d986b0b2e..38b37573cf 100644
--- a/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx
+++ b/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx
@@ -10,6 +10,7 @@ import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { FaChevronDown, FaChevronUp, FaEye, FaEyeSlash } from 'react-icons/fa6'
import { IoSearchOutline } from 'react-icons/io5'
+import { toast } from 'sonner'
import { signupSchema } from './signup.schema'
import type { SignUpFormValues } from './signup.type'
@@ -270,13 +271,11 @@ export function SignUpPage() {
}
try {
const response = await safeFetcher.post('email-auth/verify-pin', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
credentials: 'include',
- body: JSON.stringify({
+ json: {
pin: code,
email: isSKKU ? `${emailLocal}@skku.edu` : emailLocal
- })
+ }
})
if (response.status === 201) {
setEmailVerified(true)
@@ -485,7 +484,7 @@ export function SignUpPage() {
}
})
} catch {
- /* empty */
+ toast.error('회원가입에 실패했습니다. 다시 시도해주세요.')
}
}
@@ -587,7 +586,7 @@ export function SignUpPage() {
Date: Mon, 11 May 2026 20:48:39 +0900
Subject: [PATCH 04/18] fix(fe): cleanup code and refine design
---
.../components/auth/SignUpPage/SignUpPage.tsx | 190 ++++++++++--------
.../auth/SignUpPage/signup.schema.ts | 33 ++-
2 files changed, 118 insertions(+), 105 deletions(-)
diff --git a/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx b/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx
index 38b37573cf..dca41272ae 100644
--- a/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx
+++ b/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx
@@ -70,7 +70,7 @@ function AgreementCheckbox({
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
- className="h-[20px] w-[20px] shrink-0 appearance-none rounded-[3px] border border-[#C4C4C4] bg-white bg-center bg-no-repeat checked:border-transparent checked:bg-[#3581FA]"
+ className="border-color-neutral-90 checked:bg-primary h-[20px] w-[20px] shrink-0 appearance-none rounded-[3px] border bg-white bg-center bg-no-repeat checked:border-transparent"
style={{
backgroundImage: checked
? `url("data:image/svg+xml;utf8,
")`
@@ -93,7 +93,7 @@ function VisibleButton({ isVisible, setIsVisible }: VisibleButtonProps) {
type="button"
tabIndex={-1}
onClick={() => setIsVisible(!isVisible)}
- className="absolute inset-y-0 right-[21.67px] flex items-center text-[#909799]"
+ className="text-color-cool-neutral-60 absolute inset-y-0 right-[21.67px] flex items-center"
aria-label={isVisible ? '비밀번호 숨기기' : '비밀번호 보기'}
>
{isVisible ? (
@@ -260,7 +260,7 @@ export function SignUpPage() {
setPinError('')
} catch {
setError('email', {
- message: '이메일 전송에 실패했습니다. 다시 시도해주세요.'
+ message: '이메일 전송에 실패했습니다. 다시 시도해주세요'
})
}
}
@@ -282,11 +282,11 @@ export function SignUpPage() {
setEmailAuthToken(response.headers.get('email-auth') || '')
setPinError('')
} else {
- setPinError('인증 번호가 일치하지 않습니다.')
+ setPinError('인증 번호가 일치하지 않습니다')
setEmailVerified(false)
}
} catch {
- setPinError('인증 번호가 일치하지 않습니다.')
+ setPinError('인증 번호가 일치하지 않습니다')
setEmailVerified(false)
}
}
@@ -301,7 +301,7 @@ export function SignUpPage() {
clearErrors('userId')
setIsUserIdAvailable(true)
} catch {
- setError('userId', { message: '중복된 아이디입니다.' })
+ setError('userId', { message: '중복된 아이디입니다' })
setIsUserIdAvailable(false)
}
}
@@ -443,9 +443,9 @@ export function SignUpPage() {
return 'border-error focus:border-error'
}
if (isUserIdAvailable) {
- return 'border-[#3581FA] focus:border-[#3581FA]'
+ return 'border-primary focus:border-primary'
}
- return 'focus:border-primary border-[#D8D8D8]'
+ return 'focus:border-primary border-line'
}
const getEmailBorderClass = () => {
@@ -453,9 +453,9 @@ export function SignUpPage() {
return 'border-error focus:border-error'
}
if (emailVerified) {
- return 'border-[#3581FA] focus:border-[#3581FA]'
+ return 'border-primary focus:border-primary'
}
- return 'focus:border-primary border-[#D8D8D8]'
+ return 'focus:border-primary border-line'
}
const onSubmit = async (data: SignUpFormValues) => {
@@ -463,11 +463,11 @@ export function SignUpPage() {
return
}
if (data.job === '대학생' && !data.university) {
- setError('university', { message: '대학교를 선택해주세요.' })
+ setError('university', { message: '대학교를 선택해주세요' })
return
}
if (isSKKU && !data.major) {
- setError('major', { message: '소속 학과를 선택해주세요.' })
+ setError('major', { message: '소속 학과를 선택해주세요' })
return
}
try {
@@ -484,14 +484,14 @@ export function SignUpPage() {
}
})
} catch {
- toast.error('회원가입에 실패했습니다. 다시 시도해주세요.')
+ toast.error('회원가입에 실패했습니다. 다시 시도해주세요')
}
}
return (
{isPasswordMismatch && (
-
- 비밀번호가 일치하지 않습니다.
+
+ 비밀번호가 일치하지 않습니다
)}
{isPasswordAvailable && (
-
- 사용 가능한 비밀번호입니다.
+
+ 사용 가능한 비밀번호입니다
)}
@@ -642,13 +642,13 @@ export function SignUpPage() {
중복 확인
+ {!watchNickname && (
+
+ * 닉네임 미입력시, 코드당이 자동으로 닉네임을 추천해드려요!
+
+ )}
{nicknameChecked && isNicknameAvailable && (
-
- 사용 가능한 닉네임입니다.
+
+ 사용 가능한 닉네임입니다
)}
{nicknameChecked && !isNicknameAvailable && (
-
- 중복된 닉네임입니다.
+
+ 중복된 닉네임입니다
)}
@@ -695,23 +700,33 @@ export function SignUpPage() {
type="button"
onClick={() => setJobOpen((prev) => !prev)}
className={cn(
- 'placeholder:text-body1_m_16 flex h-[46px] w-full items-center justify-between rounded-[12px] border bg-white px-5 py-[11px] outline-none',
+ 'placeholder:text-body1_m_16 flex h-[46px] w-full items-center justify-between rounded-[12px] border bg-white py-[11px] pl-5 pr-4 outline-none',
errors.job
? 'border-error'
- : 'focus:border-primary border-[#D8D8D8]'
+ : 'focus:border-primary border-line'
)}
>
-
+
{watchJob || '직업'}
{jobOpen ? (
-
+
) : (
-
+
)}
{jobOpen && (
-
+
{JOB_OPTIONS.map((option) => (
{option}
@@ -738,7 +753,7 @@ export function SignUpPage() {
)}
{errors.job?.message && (
-
+
{errors.job.message}
)}
@@ -770,15 +785,15 @@ export function SignUpPage() {
}
}}
onFocus={() => setUniversityOpen(true)}
- className="placeholder:text-body1_m_16 focus:border-primary h-[46px] w-full rounded-[12px] border border-[#D8D8D8] bg-white px-5 py-[11px] pr-11 outline-none placeholder:text-[#C4C4C4]"
+ className="placeholder:text-body1_m_16 focus:border-primary border-line placeholder:text-color-neutral-90 h-[46px] w-full rounded-[12px] border bg-white px-5 py-[11px] pr-11 outline-none"
/>
{universityOpen && universityQuery.length > 0 && (
-
+
{filteredUniversities.length > 0 ? (
filteredUniversities.map((uni) => {
const displayName = getUniversityDisplayName(uni)
@@ -795,9 +810,8 @@ export function SignUpPage() {
setUniversityOpen(false)
}}
className={cn(
- 'text-body1_m_16 cursor-pointer px-5 py-[13px] hover:bg-[#F5F5F5]',
- watchUniversity === displayName &&
- 'bg-[#F0F5FF]'
+ 'text-body1_m_16 hover:bg-color-neutral-99 cursor-pointer px-5 py-[13px]',
+ watchUniversity === displayName && 'bg-fill'
)}
>
{displayName}
@@ -805,15 +819,15 @@ export function SignUpPage() {
)
})
) : (
-
- 검색 결과가 없습니다.
+
+ 검색 결과가 없습니다
)}
)}
{errors.university?.message && (
-
+
{errors.university.message}
)}
@@ -844,15 +858,15 @@ export function SignUpPage() {
setValue('major', '', { shouldValidate: true })
}}
onFocus={() => setMajorOpen(true)}
- className="placeholder:text-body1_m_16 focus:border-primary h-[46px] w-full rounded-[12px] border border-[#D8D8D8] bg-white px-5 py-[11px] pr-11 outline-none placeholder:text-[#C4C4C4]"
+ className="placeholder:text-body1_m_16 focus:border-primary border-line placeholder:text-color-neutral-90 h-[46px] w-full rounded-[12px] border bg-white px-5 py-[11px] pr-11 outline-none"
/>
{majorOpen && majorQuery.length > 0 && (
-
+
{filteredMajors.length > 0 ? (
filteredMajors.map((major) => {
const displayName = getKoreanMajorName(major)
@@ -869,8 +883,8 @@ export function SignUpPage() {
setMajorOpen(false)
}}
className={cn(
- 'text-body1_m_16 cursor-pointer px-5 py-[13px] hover:bg-[#F5F5F5]',
- majorQuery === displayName && 'bg-[#F0F5FF]'
+ 'text-body1_m_16 hover:bg-color-neutral-99 cursor-pointer px-5 py-[13px]',
+ majorQuery === displayName && 'bg-fill'
)}
>
{displayName}
@@ -878,15 +892,15 @@ export function SignUpPage() {
)
})
) : (
-
- 검색 결과가 없습니다.
+
+ 검색 결과가 없습니다
)}
)}
{errors.major?.message && (
-
+
{errors.major.message}
)}
@@ -901,15 +915,15 @@ export function SignUpPage() {
inputMode="numeric"
placeholder="학번 입력"
className={cn(
- 'placeholder:text-body1_m_16 h-[46px] w-full rounded-[12px] border bg-white px-5 py-[11px] outline-none placeholder:text-[#C4C4C4]',
+ 'placeholder:text-body1_m_16 placeholder:text-color-neutral-90 h-[46px] w-full rounded-[12px] border bg-white px-5 py-[11px] outline-none',
errors.studentId
? 'border-error focus:border-error'
- : 'focus:border-primary border-[#D8D8D8]'
+ : 'focus:border-primary border-line'
)}
{...register('studentId')}
/>
{errors.studentId?.message && (
-
+
{errors.studentId.message}
)}
@@ -934,9 +948,9 @@ export function SignUpPage() {
value={emailLocal}
onChange={(e) => setEmailLocal(e.target.value)}
disabled={emailVerified}
- className="placeholder:text-body1_m_16 min-w-0 flex-1 bg-transparent outline-none placeholder:text-[#C4C4C4]"
+ className="placeholder:text-body1_m_16 placeholder:text-color-neutral-90 min-w-0 flex-1 bg-transparent outline-none"
/>
-
+
@skku.edu
@@ -948,7 +962,7 @@ export function SignUpPage() {
onChange={(e) => setEmailLocal(e.target.value)}
disabled={emailVerified}
className={cn(
- 'placeholder:text-body1_m_16 h-[46px] flex-1 rounded-[12px] border bg-white px-5 py-[11px] outline-none placeholder:text-[#C4C4C4]',
+ 'placeholder:text-body1_m_16 placeholder:text-color-neutral-90 h-[46px] flex-1 rounded-[12px] border bg-white px-5 py-[11px] outline-none',
getEmailBorderClass()
)}
/>
@@ -965,13 +979,13 @@ export function SignUpPage() {
{errors.email?.message && (
-
+
{errors.email.message}
)}
{emailVerified && (
-
- 이메일 인증이 완료되었습니다.
+
+ 이메일 인증이 완료되었습니다
)}
@@ -993,14 +1007,14 @@ export function SignUpPage() {
}}
disabled={codeExpired}
className={cn(
- 'placeholder:text-body1_m_16 h-[46px] w-full rounded-[12px] border bg-white px-5 py-[11px] pr-20 outline-none placeholder:text-[#C4C4C4]',
+ 'placeholder:text-body1_m_16 placeholder:text-color-neutral-90 h-[46px] w-full rounded-[12px] border bg-white px-5 py-[11px] pr-20 outline-none',
pinError || codeExpired
? 'border-error focus:border-error'
- : 'focus:border-primary border-[#D8D8D8]'
+ : 'focus:border-primary border-line'
)}
/>
{!codeExpired && (
-
+
{formatTimer()}
)}
@@ -1009,19 +1023,21 @@ export function SignUpPage() {
type="button"
onClick={() => verifyPin(verificationCode)}
disabled={verificationCode.length !== 6 || codeExpired}
- className="text-caption2_m_12 h-[46px] shrink-0 rounded-[12px] bg-[#3581FA] px-4 text-white disabled:cursor-not-allowed disabled:opacity-40"
+ className="text-caption2_m_12 bg-primary h-[46px] shrink-0 rounded-[12px] px-4 text-white disabled:cursor-not-allowed disabled:opacity-40"
>
인증 확인
)}
{codeExpired && (
-
- 인증 코드가 만료되었습니다. 재발송 버튼을 눌러주세요.
+
+ 인증 코드가 만료되었습니다. 재발송 버튼을 눌러주세요
)}
{pinError && (
-
{pinError}
+
+ {pinError}
+
)}
@@ -1036,7 +1052,7 @@ export function SignUpPage() {
전체동의
-
+
가입하기
diff --git a/apps/frontend/components/auth/SignUpPage/signup.schema.ts b/apps/frontend/components/auth/SignUpPage/signup.schema.ts
index 3a842287c8..125e69e5cb 100644
--- a/apps/frontend/components/auth/SignUpPage/signup.schema.ts
+++ b/apps/frontend/components/auth/SignUpPage/signup.schema.ts
@@ -2,48 +2,45 @@ import * as v from 'valibot'
export const signupSchema = v.pipe(
v.object({
- name: v.pipe(v.string(), v.minLength(1, '이름을 입력해주세요.')),
+ name: v.pipe(v.string(), v.minLength(1, '이름을 입력해주세요')),
birth: v.pipe(
v.string(),
- v.length(6, '생년월일 6자리를 입력해 주세요.'),
- v.regex(/^\d{6}$/, '생년월일 6자리를 입력해 주세요.')
+ v.length(6, '생년월일 6자리를 입력해 주세요'),
+ v.regex(/^\d{6}$/, '생년월일 6자리를 입력해 주세요')
),
userId: v.pipe(
v.string(),
- v.minLength(3, '아이디는 3자 이상이어야 합니다.'),
- v.maxLength(10, '아이디는 10자 이하여야 합니다.'),
- v.regex(
- /^[a-z0-9]+$/,
- '아이디는 영문 소문자와 숫자만 사용할 수 있습니다.'
- )
+ v.minLength(3, '아이디는 3자 이상이어야 합니다'),
+ v.maxLength(10, '아이디는 10자 이하여야 합니다'),
+ v.regex(/^[a-z0-9]+$/, '아이디는 영문 소문자와 숫자만 사용할 수 있습니다')
),
password: v.pipe(
v.string(),
v.minLength(
8,
- '대문자, 소문자, 숫자 중 2종류 이상 포함 8-20자를 입력해주세요.'
+ '대문자, 소문자, 숫자 중 2종류 이상 포함 8-20자를 입력해주세요'
),
v.maxLength(
20,
- '대문자, 소문자, 숫자 중 2종류 이상 포함 8-20자를 입력해주세요.'
+ '대문자, 소문자, 숫자 중 2종류 이상 포함 8-20자를 입력해주세요'
),
v.regex(
/^(?:(?=.*[a-z])(?=.*[A-Z])|(?=.*[a-z])(?=.*\d)|(?=.*[A-Z])(?=.*\d)).+$/,
- '대문자, 소문자, 숫자 중 2종류 이상 포함 8-20자를 입력해주세요.'
+ '대문자, 소문자, 숫자 중 2종류 이상 포함 8-20자를 입력해주세요'
)
),
passwordConfirm: v.pipe(
v.string(),
- v.minLength(1, '비밀번호 확인을 입력해주세요.')
+ v.minLength(1, '비밀번호 확인을 입력해주세요')
),
nickname: v.string(),
- job: v.pipe(v.string(), v.minLength(1, '직업을 선택해주세요.')),
+ job: v.pipe(v.string(), v.minLength(1, '직업을 선택해주세요')),
university: v.string(),
@@ -53,14 +50,14 @@ export const signupSchema = v.pipe(
v.string(),
v.check(
(val) => val === '' || /^\d{10}$/.test(val),
- '학번 10자리를 입력해주세요.'
+ '학번 10자리를 입력해주세요'
)
),
email: v.pipe(
v.string(),
- v.minLength(1, '이메일을 입력해주세요.'),
- v.email('올바른 이메일 형식이 아닙니다.')
+ v.minLength(1, '이메일을 입력해주세요'),
+ v.email('올바른 이메일 형식이 아닙니다')
),
terms: v.boolean(),
@@ -71,7 +68,7 @@ export const signupSchema = v.pipe(
v.forward(
v.check(
({ password, passwordConfirm }) => password === passwordConfirm,
- '비밀번호가 일치하지 않습니다.'
+ '비밀번호가 일치하지 않습니다'
),
['passwordConfirm']
)
From 29a0a44cc2ac2a254023aad6e4b29de3ad0fb792 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=EC=98=A4=EC=A2=85=ED=9D=AC?=
Date: Wed, 20 May 2026 15:34:52 +0900
Subject: [PATCH 05/18] fix(fe): update signup form fields and UI
---
.../components/auth/SignUpPage/SignUpPage.tsx | 43 ++++---------------
.../auth/SignUpPage/signup.schema.ts | 6 ---
2 files changed, 8 insertions(+), 41 deletions(-)
diff --git a/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx b/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx
index dca41272ae..2f8ed3075b 100644
--- a/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx
+++ b/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx
@@ -14,13 +14,7 @@ import { toast } from 'sonner'
import { signupSchema } from './signup.schema'
import type { SignUpFormValues } from './signup.type'
-const JOB_OPTIONS = [
- '고등학생 이하',
- '대학생',
- '직장인',
- '무직',
- '기타'
-] as const
+const JOB_OPTIONS = ['고등학생', '대학생', '직장인', '기타'] as const
const NICKNAME_ADJECTIVES = [
'신나는',
@@ -127,7 +121,6 @@ export function SignUpPage() {
mode: 'onChange',
defaultValues: {
name: '',
- birth: '',
userId: '',
password: '',
passwordConfirm: '',
@@ -146,6 +139,7 @@ export function SignUpPage() {
const [isPasswordVisible, setIsPasswordVisible] = useState(false)
const [isUserIdAvailable, setIsUserIdAvailable] = useState(false)
+ const [isCheckingUserId, setIsCheckingUserId] = useState(false)
const [isNicknameAvailable, setIsNicknameAvailable] = useState(false)
const [nicknameChecked, setNicknameChecked] = useState(false)
const [emailLocal, setEmailLocal] = useState('')
@@ -165,7 +159,6 @@ export function SignUpPage() {
const watchPassword = watch('password')
const watchPasswordConfirm = watch('passwordConfirm')
- const watchBirth = watch('birth')
const watchJob = watch('job')
const watchUserId = watch('userId')
const watchUniversity = watch('university')
@@ -296,6 +289,7 @@ export function SignUpPage() {
if (!valid) {
return
}
+ setIsCheckingUserId(true)
try {
await safeFetcher.get(`user/username-check?username=${watchUserId}`)
clearErrors('userId')
@@ -303,6 +297,8 @@ export function SignUpPage() {
} catch {
setError('userId', { message: '중복된 아이디입니다' })
setIsUserIdAvailable(false)
+ } finally {
+ setIsCheckingUserId(false)
}
}
@@ -442,9 +438,6 @@ export function SignUpPage() {
if (errors.userId) {
return 'border-error focus:border-error'
}
- if (isUserIdAvailable) {
- return 'border-primary focus:border-primary'
- }
return 'focus:border-primary border-line'
}
@@ -518,27 +511,6 @@ export function SignUpPage() {
)}
-
-
생년월일 6자리
-
- {watchBirth && errors.birth?.message && (
-
- {errors.birth.message}
-
- )}
-
-
아이디
@@ -566,6 +538,7 @@ export function SignUpPage() {
)}
{!errors.userId &&
+ !isCheckingUserId &&
touchedFields.userId &&
!isUserIdAvailable &&
watchUserId.length >= 3 && (
@@ -975,7 +948,7 @@ export function SignUpPage() {
>
{emailSent && !codeExpired && !emailVerified
? '재발송'
- : '인증 하기'}
+ : '인증하기'}
{errors.email?.message && (
@@ -1023,7 +996,7 @@ export function SignUpPage() {
type="button"
onClick={() => verifyPin(verificationCode)}
disabled={verificationCode.length !== 6 || codeExpired}
- className="text-caption2_m_12 bg-primary h-[46px] shrink-0 rounded-[12px] px-4 text-white disabled:cursor-not-allowed disabled:opacity-40"
+ className="text-sub3_sb_16 bg-primary h-[46px] shrink-0 rounded-[12px] px-4 text-white disabled:cursor-not-allowed disabled:opacity-40"
>
인증 확인
diff --git a/apps/frontend/components/auth/SignUpPage/signup.schema.ts b/apps/frontend/components/auth/SignUpPage/signup.schema.ts
index 125e69e5cb..83accd1af3 100644
--- a/apps/frontend/components/auth/SignUpPage/signup.schema.ts
+++ b/apps/frontend/components/auth/SignUpPage/signup.schema.ts
@@ -4,12 +4,6 @@ export const signupSchema = v.pipe(
v.object({
name: v.pipe(v.string(), v.minLength(1, '이름을 입력해주세요')),
- birth: v.pipe(
- v.string(),
- v.length(6, '생년월일 6자리를 입력해 주세요'),
- v.regex(/^\d{6}$/, '생년월일 6자리를 입력해 주세요')
- ),
-
userId: v.pipe(
v.string(),
v.minLength(3, '아이디는 3자 이상이어야 합니다'),
From 1bf753b84856c5c1ad3d0775900282590c27a843 Mon Sep 17 00:00:00 2001
From: Jonghee Oh
Date: Sat, 23 May 2026 11:04:51 +0900
Subject: [PATCH 06/18] chore(fe): fix email input overflow in signup form
---
apps/frontend/components/auth/SignUpPage/SignUpPage.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx b/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx
index 2f8ed3075b..ac77aa55b8 100644
--- a/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx
+++ b/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx
@@ -911,7 +911,7 @@ export function SignUpPage() {
{isSKKU ? (
@@ -935,7 +935,7 @@ export function SignUpPage() {
onChange={(e) => setEmailLocal(e.target.value)}
disabled={emailVerified}
className={cn(
- 'placeholder:text-body1_m_16 placeholder:text-color-neutral-90 h-[46px] flex-1 rounded-[12px] border bg-white px-5 py-[11px] outline-none',
+ 'placeholder:text-body1_m_16 placeholder:text-color-neutral-90 h-[46px] min-w-0 flex-1 rounded-[12px] border bg-white px-5 py-[11px] outline-none',
getEmailBorderClass()
)}
/>
From 8afe637d6cfb23c9ab9edd3925abdad284818eca Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=EC=98=A4=EC=A2=85=ED=9D=AC?=
Date: Wed, 27 May 2026 18:28:42 +0900
Subject: [PATCH 07/18] fix(fe): apply changes and feedback
---
.../components/auth/SignUpPage/SignUpPage.tsx | 127 +++---------------
.../auth/SignUpPage/signup.schema.ts | 3 +-
.../components/auth/SignUpPage/signup.type.ts | 2 -
apps/frontend/package.json | 1 +
pnpm-lock.yaml | 9 ++
5 files changed, 29 insertions(+), 113 deletions(-)
diff --git a/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx b/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx
index ac77aa55b8..50d07d3b91 100644
--- a/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx
+++ b/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx
@@ -1,10 +1,12 @@
'use client'
import { allMajors } from '@/libs/constants'
-import { cn, safeFetcher } from '@/libs/utils'
+import { cn, isHttpError, safeFetcher } from '@/libs/utils'
import resetGray from '@/public/icons/reset-gray.svg'
import { valibotResolver } from '@hookform/resolvers/valibot'
import { searchUniversities } from 'korea-universities'
+// @ts-expect-error: no type declarations for this package
+import randomNameGenerator from 'korean-random-names-generator'
import Image from 'next/image'
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
@@ -16,35 +18,6 @@ import type { SignUpFormValues } from './signup.type'
const JOB_OPTIONS = ['고등학생', '대학생', '직장인', '기타'] as const
-const NICKNAME_ADJECTIVES = [
- '신나는',
- '빠른',
- '멋진',
- '귀여운',
- '용감한',
- '활발한',
- '똑똑한',
- '재미있는',
- '행복한',
- '따뜻한',
- '씩씩한',
- '유쾌한'
-]
-const NICKNAME_NOUNS = [
- '청사과',
- '고양이',
- '펭귄',
- '토끼',
- '고래',
- '독수리',
- '호랑이',
- '다람쥐',
- '여우',
- '늑대',
- '곰',
- '사자'
-]
-
const PIN_EXPIRE_SECONDS = 300
interface AgreementCheckboxProps {
@@ -103,8 +76,7 @@ export function SignUpPage() {
const [agreements, setAgreements] = useState({
terms: false,
privacy: false,
- minorPrivacy: false,
- marketing: false
+ minorPrivacy: false
})
const {
@@ -132,16 +104,13 @@ export function SignUpPage() {
email: '',
terms: false,
privacy: false,
- minorPrivacy: false,
- marketing: false
+ minorPrivacy: false
}
})
const [isPasswordVisible, setIsPasswordVisible] = useState(false)
const [isUserIdAvailable, setIsUserIdAvailable] = useState(false)
const [isCheckingUserId, setIsCheckingUserId] = useState(false)
- const [isNicknameAvailable, setIsNicknameAvailable] = useState(false)
- const [nicknameChecked, setNicknameChecked] = useState(false)
const [emailLocal, setEmailLocal] = useState('')
const [emailSent, setEmailSent] = useState(false)
const [emailVerified, setEmailVerified] = useState(false)
@@ -175,10 +144,7 @@ export function SignUpPage() {
const isSKKU = watchUniversity.startsWith('성균관대학교')
const isAllChecked =
- agreements.terms &&
- agreements.privacy &&
- agreements.minorPrivacy &&
- agreements.marketing
+ agreements.terms && agreements.privacy && agreements.minorPrivacy
useEffect(() => {
if (!emailSent || codeExpired || emailVerified) {
@@ -198,11 +164,6 @@ export function SignUpPage() {
setIsUserIdAvailable(false)
}, [watchUserId])
- useEffect(() => {
- setIsNicknameAvailable(false)
- setNicknameChecked(false)
- }, [watchNickname])
-
useEffect(() => {
const fullEmail = isSKKU ? `${emailLocal}@skku.edu` : emailLocal
setValue('email', fullEmail, { shouldValidate: emailLocal.length > 0 })
@@ -251,10 +212,14 @@ export function SignUpPage() {
setEmailVerified(false)
setVerificationCode('')
setPinError('')
- } catch {
- setError('email', {
- message: '이메일 전송에 실패했습니다. 다시 시도해주세요'
- })
+ } catch (error) {
+ if (isHttpError(error) && error.response.status === 409) {
+ setError('email', { message: '이미 사용 중인 이메일입니다' })
+ } else {
+ setError('email', {
+ message: '이메일 전송에 실패했습니다. 다시 시도해주세요'
+ })
+ }
}
}
@@ -303,47 +268,18 @@ export function SignUpPage() {
}
const generateNickname = () => {
- const adj =
- NICKNAME_ADJECTIVES[
- Math.floor(Math.random() * NICKNAME_ADJECTIVES.length)
- ]
- const noun =
- NICKNAME_NOUNS[Math.floor(Math.random() * NICKNAME_NOUNS.length)]
- setValue('nickname', `${adj} ${noun}`, { shouldValidate: true })
- }
-
- const checkNickname = async () => {
- if (!watchNickname) {
- return
- }
- try {
- await safeFetcher.get(
- `user/nickname-check?nickname=${encodeURIComponent(watchNickname)}`
- )
- setIsNicknameAvailable(true)
- setNicknameChecked(true)
- } catch {
- setIsNicknameAvailable(false)
- setNicknameChecked(true)
- }
+ setValue('nickname', randomNameGenerator(), { shouldValidate: true })
}
const handleAllAgreementChange = (checked: boolean) => {
- const next = {
- terms: checked,
- privacy: checked,
- minorPrivacy: checked,
- marketing: checked
- }
- setAgreements(next)
+ setAgreements({ terms: checked, privacy: checked, minorPrivacy: checked })
setValue('terms', checked)
setValue('privacy', checked)
setValue('minorPrivacy', checked)
- setValue('marketing', checked)
}
const handleAgreementChange = (
- key: 'terms' | 'privacy' | 'minorPrivacy' | 'marketing',
+ key: 'terms' | 'privacy' | 'minorPrivacy',
checked: boolean
) => {
setAgreements((prev) => ({ ...prev, [key]: checked }))
@@ -614,7 +550,7 @@ export function SignUpPage() {
@@ -632,30 +568,12 @@ export function SignUpPage() {
/>
-
- 중복 확인
-
{!watchNickname && (
* 닉네임 미입력시, 코드당이 자동으로 닉네임을 추천해드려요!
)}
- {nicknameChecked && isNicknameAvailable && (
-
- 사용 가능한 닉네임입니다
-
- )}
- {nicknameChecked && !isNicknameAvailable && (
-
- 중복된 닉네임입니다
-
- )}
@@ -1049,15 +967,6 @@ export function SignUpPage() {
>
14세미만 개인정보 이용 보호
-
-
- handleAgreementChange('marketing', checked)
- }
- >
- [선택] 마케팅 활용 동의 및 광고 수신 동의
-
=18'}
@@ -11003,6 +11009,7 @@ packages:
recharts@2.15.4:
resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==}
engines: {node: '>=14'}
+ deprecated: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide
peerDependencies:
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
@@ -22893,6 +22900,8 @@ snapshots:
korea-universities@1.0.1: {}
+ korean-random-names-generator@0.0.0: {}
+
ky@1.8.2: {}
language-subtag-registry@0.3.23: {}
From c7d24200716bdc6b24713b14a896a33c74e1e2a6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=EC=98=A4=EC=A2=85=ED=9D=AC?=
Date: Sun, 21 Jun 2026 08:19:59 +0900
Subject: [PATCH 08/18] feat(fe): redesign signup page and connect
nickname/jobType to API
---
.../components/auth/SignUpPage/SignUpPage.tsx | 48 +++++++++++++------
1 file changed, 34 insertions(+), 14 deletions(-)
diff --git a/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx b/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx
index 50d07d3b91..f4b3608f34 100644
--- a/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx
+++ b/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx
@@ -8,6 +8,8 @@ import { searchUniversities } from 'korea-universities'
// @ts-expect-error: no type declarations for this package
import randomNameGenerator from 'korean-random-names-generator'
import Image from 'next/image'
+import Link from 'next/link'
+import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { FaChevronDown, FaChevronUp, FaEye, FaEyeSlash } from 'react-icons/fa6'
@@ -73,6 +75,7 @@ function VisibleButton({ isVisible, setIsVisible }: VisibleButtonProps) {
}
export function SignUpPage() {
+ const router = useRouter()
const [agreements, setAgreements] = useState({
terms: false,
privacy: false,
@@ -387,6 +390,13 @@ export function SignUpPage() {
return 'focus:border-primary border-line'
}
+ const JOB_TYPE_MAP: Record = {
+ 대학생: 'CollegeStudent',
+ 고등학생: 'HighSchoolStudent',
+ 직장인: 'Employee',
+ 기타: 'Other'
+ }
+
const onSubmit = async (data: SignUpFormValues) => {
if (!canSubmit) {
return
@@ -407,11 +417,15 @@ export function SignUpPage() {
password: data.password,
email: data.email,
realName: data.name,
+ nickname: data.nickname || randomNameGenerator(),
+ jobType: JOB_TYPE_MAP[data.job],
college: data.university || undefined,
major: data.major || undefined,
studentId: data.studentId || undefined
}
})
+ toast.success('회원가입이 완료됐습니다!')
+ router.push('/login')
} catch {
toast.error('회원가입에 실패했습니다. 다시 시도해주세요')
}
@@ -420,11 +434,17 @@ export function SignUpPage() {
return (