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() { )}
+ {/* 생년월일 */}
{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 && ( +

+ 사용 가능한 아이디입니다. +

+ )}
+ {/* 비밀번호 */}
-
)} -
)} - {isPasswordAvailable && (

- 사용할 수 있는 비밀번호입니다. + 사용 가능한 비밀번호입니다.

)}
-
-
- - + {/* 닉네임 */} +
+ +
+
+ + +
+
- -
- -

- 닉네임 미입력시, 코드당이 자동으로 닉네임을 추천해드려요! + {nicknameChecked && isNicknameAvailable && ( +

+ 사용 가능한 닉네임입니다.

-
+ )} + {nicknameChecked && !isNicknameAvailable && ( +

+ 이미 사용 중인 닉네임입니다. +

+ )}
- {/*
+ {/* 직업 드롭다운 */} +
- - {errors.job && ( -

+

{ + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + setJobOpen(false) + } + }} + tabIndex={-1} + > + + {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} +
  • + ) + }) + ) : ( +
  • + 검색 결과가 없습니다. +
  • + )} +
+ )} +
+
+ )} +
+ )} + + {/* 이메일 + 인증 */} +
- +
+ {isSKKU ? ( +
+ setEmailLocal(e.target.value)} + disabled={emailVerified} + className="placeholder:text-body1_m_16 min-w-0 flex-1 bg-transparent outline-none placeholder:text-[#C4C4C4]" + /> + + @skku.edu + +
+ ) : ( + 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]', + getEmailBorderClass() + )} + /> + )} + +
{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()} + + )} +
+ +
+ )} + {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() {

회원가입

- {/* 이름 */}
- {/* 생년월일 */}
- {/* 아이디 + 중복 확인 */}
@@ -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 (
@@ -504,15 +504,15 @@ export function SignUpPage() { type="text" 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.name ? 'border-error focus:border-error' - : 'focus:border-primary border-[#D8D8D8]' + : 'focus:border-primary border-line' )} {...register('name')} /> {errors.name?.message && ( -

+

{errors.name.message}

)} @@ -525,15 +525,15 @@ export function SignUpPage() { placeholder="YYMMDD" maxLength={6} 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.birth ? 'border-error focus:border-error' - : 'focus:border-primary border-[#D8D8D8]' + : 'focus:border-primary border-line' )} {...register('birth')} /> {watchBirth && errors.birth?.message && ( -

+

{errors.birth.message}

)} @@ -546,7 +546,7 @@ export function SignUpPage() { type="text" 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', getUserIdBorderClass() )} {...register('userId')} @@ -561,7 +561,7 @@ export function SignUpPage() {
{errors.userId?.message && ( -

+

{errors.userId.message}

)} @@ -569,13 +569,13 @@ export function SignUpPage() { touchedFields.userId && !isUserIdAvailable && watchUserId.length >= 3 && ( -

- 아이디 중복 확인을 해주세요. +

+ 아이디 중복 확인을 해주세요

)} {!errors.userId && isUserIdAvailable && (

- 사용 가능한 아이디입니다. + 사용 가능한 아이디입니다

)}
@@ -586,12 +586,12 @@ export function SignUpPage() {
@@ -601,7 +601,7 @@ export function SignUpPage() { />
{errors.password?.message && ( -

+

{errors.password.message}

)} @@ -610,10 +610,10 @@ export function SignUpPage() { type={isPasswordVisible ? 'text' : 'password'} 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', isPasswordMismatch ? 'border-error focus:border-error' - : 'focus:border-primary border-[#D8D8D8]' + : 'focus:border-primary border-line' )} {...register('passwordConfirm')} /> @@ -623,13 +623,13 @@ export function SignUpPage() { />
{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() { )}
-
- - - {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) - } - > - [선택] 마케팅 활용 동의 및 광고 수신 동의 -
{errors.email?.message && ( @@ -887,7 +907,7 @@ export function SignUpPage() { type="text" inputMode="numeric" maxLength={6} - placeholder="인증 코드 6자리" + placeholder="메일로 도착한 인증 번호를 입력해주세요" value={verificationCode} onChange={(e) => { const val = e.target.value @@ -936,14 +956,14 @@ export function SignUpPage() {
- - 전체동의 - - -
+
+ + 전체동의 + +
Date: Sun, 5 Jul 2026 15:42:12 +0900 Subject: [PATCH 09/18] fix(fe): show update information modal only for SKKU students --- apps/frontend/components/auth/HeaderAuthPanel.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/frontend/components/auth/HeaderAuthPanel.tsx b/apps/frontend/components/auth/HeaderAuthPanel.tsx index 728dd8286c..4cc8e4d75d 100644 --- a/apps/frontend/components/auth/HeaderAuthPanel.tsx +++ b/apps/frontend/components/auth/HeaderAuthPanel.tsx @@ -36,6 +36,8 @@ interface HeaderAuthUser { role: string studentId: string major: string + jobType: string + college: string canCreateCourse: boolean canCreateContest: boolean } @@ -68,8 +70,12 @@ export function HeaderAuthPanel({ .get('user') .json() + const isSKKUStudent = + user.jobType === 'CollegeStudent' && + user.college?.includes('성균관대학교') const isUserInfoIncomplete = user.role === 'User' && + isSKKUStudent && (user.studentId === '0000000000' || user.major === 'none') setIsUserInfoIncomplete(isUserInfoIncomplete) From 643e37f2d47f68503ffe9a7abcbe29b3c56a9e9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=A2=85=ED=9D=AC?= Date: Mon, 6 Jul 2026 12:45:16 +0900 Subject: [PATCH 10/18] refactor(fe): replace job dropdown with Select and simplify outside-click handling --- .../components/auth/SignUpPage/SignUpPage.tsx | 221 ++++++++---------- 1 file changed, 95 insertions(+), 126 deletions(-) diff --git a/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx b/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx index f4b3608f34..62ce288a68 100644 --- a/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx +++ b/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx @@ -1,5 +1,12 @@ 'use client' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/shadcn/select' import { allMajors } from '@/libs/constants' import { cn, isHttpError, safeFetcher } from '@/libs/utils' import resetGray from '@/public/icons/reset-gray.svg' @@ -10,9 +17,9 @@ 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 { useEffect, useRef, useState } from 'react' import { useForm } from 'react-hook-form' -import { FaChevronDown, FaChevronUp, FaEye, FaEyeSlash } from 'react-icons/fa6' +import { FaEye, FaEyeSlash } from 'react-icons/fa6' import { IoSearchOutline } from 'react-icons/io5' import { toast } from 'sonner' import { signupSchema } from './signup.schema' @@ -20,6 +27,48 @@ import type { SignUpFormValues } from './signup.type' const JOB_OPTIONS = ['고등학생', '대학생', '직장인', '기타'] as const +const JOB_TYPE_MAP: Record = { + 대학생: 'CollegeStudent', + 고등학생: 'HighSchoolStudent', + 직장인: 'Employee', + 기타: 'Other' +} + +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 PIN_EXPIRE_SECONDS = 300 interface AgreementCheckboxProps { @@ -123,12 +172,14 @@ 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) + const universityRef = useRef(null) + const majorRef = useRef(null) + const watchPassword = watch('password') const watchPasswordConfirm = watch('passwordConfirm') const watchJob = watch('job') @@ -192,6 +243,19 @@ export function SignUpPage() { setValue('studentId', '') }, [isSKKU, setValue]) + useEffect(() => { + const handler = (e: MouseEvent) => { + if (!universityRef.current?.contains(e.target as Node)) { + setUniversityOpen(false) + } + if (!majorRef.current?.contains(e.target as Node)) { + setMajorOpen(false) + } + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, []) + const formatTimer = () => { const min = Math.floor(remaining / 60) const sec = remaining % 60 @@ -305,41 +369,6 @@ export function SignUpPage() { .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] ) => { @@ -390,13 +419,6 @@ 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 @@ -598,71 +620,40 @@ export function SignUpPage() {
-
{ - if (!e.currentTarget.contains(e.relatedTarget as Node)) { - setJobOpen(false) + {errors.job?.message && (

{errors.job.message} @@ -674,15 +665,7 @@ export function SignUpPage() {

-
{ - if (!e.currentTarget.contains(e.relatedTarget as Node)) { - setUniversityOpen(false) - } - }} - tabIndex={-1} - > +
e.preventDefault()} onClick={() => { setValue('university', displayName, { shouldValidate: true @@ -747,17 +728,7 @@ export function SignUpPage() { {isSKKU && (
-
{ - if ( - !e.currentTarget.contains(e.relatedTarget as Node) - ) { - setMajorOpen(false) - } - }} - tabIndex={-1} - > +
e.preventDefault()} onClick={() => { setValue('major', displayName, { shouldValidate: true From 2f45fad793d22916bf1ba4e4ffc96976de258d57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=A2=85=ED=9D=AC?= Date: Tue, 7 Jul 2026 13:21:22 +0900 Subject: [PATCH 11/18] fix(fe): fix transparent selectcontent and refactor signup dropdowns --- .../components/auth/SignUpPage/SignUpPage.tsx | 190 ++++++++++-------- apps/frontend/components/shadcn/select.tsx | 2 +- .../frontend/public/icons/checkmark-white.svg | 3 + 3 files changed, 109 insertions(+), 86 deletions(-) create mode 100644 apps/frontend/public/icons/checkmark-white.svg diff --git a/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx b/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx index 62ce288a68..8290bf0409 100644 --- a/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx +++ b/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx @@ -1,5 +1,12 @@ 'use client' +import { + Command, + CommandEmpty, + CommandGroup, + CommandItem, + CommandList +} from '@/components/shadcn/command' import { Select, SelectContent, @@ -91,7 +98,7 @@ function AgreementCheckbox({ 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,")` + ? "url('/icons/checkmark-white.svg')" : 'none' }} /> @@ -244,6 +251,9 @@ export function SignUpPage() { }, [isSKKU, setValue]) useEffect(() => { + if (!universityOpen && !majorOpen) { + return + } const handler = (e: MouseEvent) => { if (!universityRef.current?.contains(e.target as Node)) { setUniversityOpen(false) @@ -254,7 +264,7 @@ export function SignUpPage() { } document.addEventListener('mousedown', handler) return () => document.removeEventListener('mousedown', handler) - }, []) + }, [universityOpen, majorOpen]) const formatTimer = () => { const min = Math.floor(remaining / 60) @@ -354,7 +364,18 @@ export function SignUpPage() { } const filteredUniversities = - universityQuery.length > 0 ? searchUniversities(universityQuery) : [] + universityQuery.length > 0 + ? searchUniversities(universityQuery).filter((uni, idx, arr) => { + if (CAMPUS_OVERRIDES[uni.nameKr]) { + return true + } + return ( + arr.findIndex( + (u) => u.nameKr === uni.nameKr && u.region === uni.region + ) === idx + ) + }) + : [] const filteredMajors = majorQuery.length > 0 @@ -395,19 +416,7 @@ export function SignUpPage() { 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' - } - return 'focus:border-primary border-line' - } + const canSubmit = isUserIdAvailable && emailVerified && isAllChecked const getEmailBorderClass = () => { if (errors.email) { @@ -472,7 +481,6 @@ export function SignUpPage() {
아이디
@@ -591,7 +600,6 @@ export function SignUpPage() {
{option} @@ -668,7 +676,6 @@ export function SignUpPage() {
{ @@ -687,35 +694,45 @@ export function SignUpPage() { />
{universityOpen && universityQuery.length > 0 && ( -
    - {filteredUniversities.length > 0 ? ( - filteredUniversities.map((uni) => { - const displayName = getUniversityDisplayName(uni) - return ( -
  • { - setValue('university', displayName, { - shouldValidate: true - }) - setUniversityQuery(displayName) - setUniversityOpen(false) - }} - className={cn( - 'text-body1_m_16 hover:bg-color-neutral-99 cursor-pointer px-5 py-[13px]', - watchUniversity === displayName && 'bg-fill' - )} - > - {displayName} -
  • - ) - }) - ) : ( -
  • - 검색 결과가 없습니다 -
  • - )} -
+ + + {filteredUniversities.length > 0 ? ( + + {filteredUniversities.map((uni) => { + const displayName = + getUniversityDisplayName(uni) + return ( + { + setValue('university', displayName, { + shouldValidate: true + }) + setUniversityQuery(displayName) + setUniversityOpen(false) + }} + className={cn( + 'text-body1_m_16 cursor-pointer px-5 py-[13px] aria-selected:bg-gray-100/80', + watchUniversity === displayName && + 'bg-fill' + )} + > + {displayName} + + ) + })} + + ) : ( + + 검색 결과가 없습니다 + + )} + + )}
{errors.university?.message && ( @@ -731,7 +748,6 @@ export function SignUpPage() {
{ @@ -748,35 +764,43 @@ export function SignUpPage() { />
{majorOpen && majorQuery.length > 0 && ( -
    - {filteredMajors.length > 0 ? ( - filteredMajors.map((major) => { - const displayName = getKoreanMajorName(major) - return ( -
  • { - setValue('major', displayName, { - shouldValidate: true - }) - setMajorQuery(displayName) - setMajorOpen(false) - }} - className={cn( - 'text-body1_m_16 hover:bg-color-neutral-99 cursor-pointer px-5 py-[13px]', - majorQuery === displayName && 'bg-fill' - )} - > - {displayName} -
  • - ) - }) - ) : ( -
  • - 검색 결과가 없습니다 -
  • - )} -
+ + + {filteredMajors.length > 0 ? ( + + {filteredMajors.map((major) => { + const displayName = getKoreanMajorName(major) + return ( + { + setValue('major', displayName, { + shouldValidate: true + }) + setMajorQuery(displayName) + setMajorOpen(false) + }} + className={cn( + 'text-body1_m_16 cursor-pointer px-5 py-[13px] aria-selected:bg-gray-100/80', + majorQuery === displayName && 'bg-fill' + )} + > + {displayName} + + ) + })} + + ) : ( + + 검색 결과가 없습니다 + + )} + + )}
{errors.major?.message && ( @@ -791,7 +815,6 @@ export function SignUpPage() {
setEmailLocal(e.target.value)} @@ -836,7 +858,6 @@ export function SignUpPage() {
) : ( setEmailLocal(e.target.value)} @@ -873,7 +894,6 @@ export function SignUpPage() {
+ + From 7e7239741c3189f695a50dec252b4c842d803db8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=A2=85=ED=9D=AC?= Date: Thu, 16 Jul 2026 13:58:37 +0900 Subject: [PATCH 12/18] fix(fe): center CODEDANG logo horizontally in signup page --- apps/frontend/app/(client)/signup/layout.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/frontend/app/(client)/signup/layout.tsx b/apps/frontend/app/(client)/signup/layout.tsx index 6845043930..ceafac1172 100644 --- a/apps/frontend/app/(client)/signup/layout.tsx +++ b/apps/frontend/app/(client)/signup/layout.tsx @@ -18,8 +18,8 @@ export default function SignUpLayout({ style={{ backgroundImage: "url('/signup/background.png')" }} />
-
-
+
+
Codedang Date: Thu, 16 Jul 2026 14:06:39 +0900 Subject: [PATCH 13/18] refactor(fe): extract shared university and major search components --- apps/frontend/components/MajorSearchInput.tsx | 109 +++++++ .../components/UniversitySearchInput.tsx | 138 +++++++++ .../components/auth/SignUpPage/SignUpPage.tsx | 272 ++---------------- apps/frontend/libs/constants.ts | 35 +++ 4 files changed, 299 insertions(+), 255 deletions(-) create mode 100644 apps/frontend/components/MajorSearchInput.tsx create mode 100644 apps/frontend/components/UniversitySearchInput.tsx diff --git a/apps/frontend/components/MajorSearchInput.tsx b/apps/frontend/components/MajorSearchInput.tsx new file mode 100644 index 0000000000..6ae16fe4f6 --- /dev/null +++ b/apps/frontend/components/MajorSearchInput.tsx @@ -0,0 +1,109 @@ +'use client' + +import { allMajors } from '@/libs/constants' +import { cn } from '@/libs/utils' +import { useState } from 'react' +import { IoSearchOutline } from 'react-icons/io5' + +const getKoreanMajorName = (major: string) => + major + .split(/\s*\/\s*/) + .at(-1) + ?.trim() ?? major + +interface MajorSearchInputProps { + value: string + onChange: (value: string) => void + placeholder?: string + disabled?: boolean + error?: string + inputClassName?: string +} + +export function MajorSearchInput({ + value, + onChange, + placeholder = '학과 검색', + disabled, + error, + inputClassName +}: MajorSearchInputProps) { + const [query, setQuery] = useState(value) + const [open, setOpen] = useState(false) + + const filtered = + query.length > 0 + ? allMajors.filter((m) => + getKoreanMajorName(m).toLowerCase().includes(query.toLowerCase()) + ) + : [] + + return ( +
{ + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + setOpen(false) + } + }} + tabIndex={-1} + > +
+ { + setQuery(e.target.value) + setOpen(true) + if (value) { + onChange('') + } + }} + onFocus={() => setOpen(true)} + className={cn( + 'focus:border-primary border-line h-[46px] w-full rounded-xl border bg-white px-5 py-[11px] pr-11 outline-none', + error && 'border-error focus:border-error', + inputClassName + )} + /> + +
+ {open && query.length > 0 && ( +
    + {filtered.length > 0 ? ( + filtered.map((major) => { + const displayName = getKoreanMajorName(major) + return ( +
  • e.preventDefault()} + onClick={() => { + onChange(displayName) + setQuery(displayName) + setOpen(false) + }} + className={cn( + 'text-body1_m_16 cursor-pointer px-5 py-[13px] hover:bg-gray-50', + value === displayName && 'bg-gray-50' + )} + > + {displayName} +
  • + ) + }) + ) : ( +
  • + 검색 결과가 없습니다 +
  • + )} +
+ )} + {error &&

{error}

} +
+ ) +} diff --git a/apps/frontend/components/UniversitySearchInput.tsx b/apps/frontend/components/UniversitySearchInput.tsx new file mode 100644 index 0000000000..de54cd0c62 --- /dev/null +++ b/apps/frontend/components/UniversitySearchInput.tsx @@ -0,0 +1,138 @@ +'use client' + +import { CAMPUS_OVERRIDES, REGION_SHORT } from '@/libs/constants' +import { cn } from '@/libs/utils' +import { searchUniversities } from 'korea-universities' +import { useState } from 'react' +import { IoSearchOutline } from 'react-icons/io5' + +type University = ReturnType[number] + +interface UniversitySearchInputProps { + value: string + onChange: (value: string) => void + placeholder?: string + disabled?: boolean + excludeNames?: string[] + error?: string + inputClassName?: string +} + +const getDisplayName = (uni: University, allResults: University[]): string => { + const hasDuplicate = + allResults.filter((u) => u.nameKr === uni.nameKr).length > 1 + if (!hasDuplicate) { + return uni.nameKr + } + const override = CAMPUS_OVERRIDES[uni.nameKr]?.[uni.campus ?? ''] + if (override) { + return `${uni.nameKr} ${override}` + } + const sameRegion = + allResults.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}캠퍼스` +} + +export function UniversitySearchInput({ + value, + onChange, + placeholder = '대학교 검색', + disabled, + excludeNames = [], + error, + inputClassName +}: UniversitySearchInputProps) { + const [query, setQuery] = useState(value) + const [open, setOpen] = useState(false) + + const filtered = + query.length > 0 + ? searchUniversities(query).filter((uni, idx, arr) => { + if (excludeNames.includes(uni.nameKr)) { + return false + } + if (CAMPUS_OVERRIDES[uni.nameKr]) { + return true + } + return ( + arr.findIndex( + (u) => u.nameKr === uni.nameKr && u.region === uni.region + ) === idx + ) + }) + : [] + + return ( +
{ + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + setOpen(false) + } + }} + tabIndex={-1} + > +
+ { + setQuery(e.target.value) + setOpen(true) + if (value) { + onChange('') + } + }} + onFocus={() => setOpen(true)} + className={cn( + 'focus:border-primary border-line h-[46px] w-full rounded-xl border bg-white px-5 py-[11px] pr-11 outline-none', + error && 'border-error focus:border-error', + inputClassName + )} + /> + +
+ {open && query.length > 0 && ( +
    + {filtered.length > 0 ? ( + filtered.map((uni) => { + const displayName = getDisplayName(uni, filtered) + return ( +
  • e.preventDefault()} + onClick={() => { + onChange(displayName) + setQuery(displayName) + setOpen(false) + }} + className={cn( + 'text-body1_m_16 cursor-pointer px-5 py-[13px] hover:bg-gray-50', + value === displayName && 'bg-gray-50' + )} + > + {displayName} +
  • + ) + }) + ) : ( +
  • + 검색 결과가 없습니다 +
  • + )} +
+ )} + {error &&

{error}

} +
+ ) +} diff --git a/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx b/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx index 8290bf0409..04debb0afe 100644 --- a/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx +++ b/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx @@ -1,12 +1,7 @@ 'use client' -import { - Command, - CommandEmpty, - CommandGroup, - CommandItem, - CommandList -} from '@/components/shadcn/command' +import { MajorSearchInput } from '@/components/MajorSearchInput' +import { UniversitySearchInput } from '@/components/UniversitySearchInput' import { Select, SelectContent, @@ -14,20 +9,17 @@ import { SelectTrigger, SelectValue } from '@/components/shadcn/select' -import { allMajors } from '@/libs/constants' 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 Link from 'next/link' import { useRouter } from 'next/navigation' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useState } from 'react' import { useForm } from 'react-hook-form' import { 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' @@ -41,41 +33,6 @@ const JOB_TYPE_MAP: Record = { 기타: 'Other' } -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 PIN_EXPIRE_SECONDS = 300 interface AgreementCheckboxProps { @@ -179,14 +136,6 @@ export function SignUpPage() { const [codeExpired, setCodeExpired] = useState(false) const [remaining, setRemaining] = useState(PIN_EXPIRE_SECONDS) const [endTime, setEndTime] = useState(0) - const [universityQuery, setUniversityQuery] = useState('') - const [universityOpen, setUniversityOpen] = useState(false) - const [majorQuery, setMajorQuery] = useState('') - const [majorOpen, setMajorOpen] = useState(false) - - const universityRef = useRef(null) - const majorRef = useRef(null) - const watchPassword = watch('password') const watchPasswordConfirm = watch('passwordConfirm') const watchJob = watch('job') @@ -246,26 +195,9 @@ export function SignUpPage() { setPinError('') setCodeExpired(false) setValue('major', '') - setMajorQuery('') setValue('studentId', '') }, [isSKKU, setValue]) - useEffect(() => { - if (!universityOpen && !majorOpen) { - return - } - const handler = (e: MouseEvent) => { - if (!universityRef.current?.contains(e.target as Node)) { - setUniversityOpen(false) - } - if (!majorRef.current?.contains(e.target as Node)) { - setMajorOpen(false) - } - } - document.addEventListener('mousedown', handler) - return () => document.removeEventListener('mousedown', handler) - }, [universityOpen, majorOpen]) - const formatTimer = () => { const min = Math.floor(remaining / 60) const sec = remaining % 60 @@ -363,59 +295,6 @@ export function SignUpPage() { setValue(key, checked) } - const filteredUniversities = - universityQuery.length > 0 - ? searchUniversities(universityQuery).filter((uni, idx, arr) => { - if (CAMPUS_OVERRIDES[uni.nameKr]) { - return true - } - return ( - arr.findIndex( - (u) => u.nameKr === uni.nameKr && u.region === uni.region - ) === idx - ) - }) - : [] - - 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 getUniversityDisplayName = ( - uni: (typeof filteredUniversities)[number] - ) => { - const hasDuplicate = - filteredUniversities.filter((u) => u.nameKr === uni.nameKr).length > 1 - if (!hasDuplicate) { - return uni.nameKr - } - - 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 && isAllChecked const getEmailBorderClass = () => { @@ -635,7 +514,6 @@ export function SignUpPage() { if (value !== '대학생') { setValue('university', '') setValue('major', '') - setUniversityQuery('') } }} > @@ -673,141 +551,25 @@ export function SignUpPage() {
-
-
- { - setUniversityQuery(e.target.value) - setUniversityOpen(true) - if (watchUniversity) { - setValue('university', '', { shouldValidate: true }) - } - }} - onFocus={() => setUniversityOpen(true)} - 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) - return ( - { - setValue('university', displayName, { - shouldValidate: true - }) - setUniversityQuery(displayName) - setUniversityOpen(false) - }} - className={cn( - 'text-body1_m_16 cursor-pointer px-5 py-[13px] aria-selected:bg-gray-100/80', - watchUniversity === displayName && - 'bg-fill' - )} - > - {displayName} - - ) - })} - - ) : ( - - 검색 결과가 없습니다 - - )} - - - )} -
- {errors.university?.message && ( -

- {errors.university.message} -

- )} + + setValue('university', v, { shouldValidate: true }) + } + error={errors.university?.message} + />
{isSKKU && (
-
-
- { - setMajorQuery(e.target.value) - setMajorOpen(true) - setValue('major', '', { shouldValidate: true }) - }} - onFocus={() => setMajorOpen(true)} - 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) - return ( - { - setValue('major', displayName, { - shouldValidate: true - }) - setMajorQuery(displayName) - setMajorOpen(false) - }} - className={cn( - 'text-body1_m_16 cursor-pointer px-5 py-[13px] aria-selected:bg-gray-100/80', - majorQuery === displayName && 'bg-fill' - )} - > - {displayName} - - ) - })} - - ) : ( - - 검색 결과가 없습니다 - - )} - - - )} -
- {errors.major?.message && ( -

- {errors.major.message} -

- )} + + setValue('major', v, { shouldValidate: true }) + } + error={errors.major?.message} + />
)} diff --git a/apps/frontend/libs/constants.ts b/apps/frontend/libs/constants.ts index a31be1dd66..6e17a61213 100644 --- a/apps/frontend/libs/constants.ts +++ b/apps/frontend/libs/constants.ts @@ -311,6 +311,41 @@ export const colleges = [ */ export const allMajors = colleges.flatMap((c) => c.majors) +export const CAMPUS_OVERRIDES: Record> = { + 성균관대학교: { 제1캠퍼스: '서울캠퍼스', 제2캠퍼스: '수원캠퍼스' }, + 연세대학교: { 제1캠퍼스: '신촌캠퍼스', 제2캠퍼스: '국제캠퍼스' }, + 경희대학교: { 제1캠퍼스: '서울캠퍼스', 제2캠퍼스: '국제캠퍼스' }, + 중앙대학교: { 제1캠퍼스: '서울캠퍼스', 제2캠퍼스: '안성캠퍼스' }, + 한국외국어대학교: { 제1캠퍼스: '서울캠퍼스', 제2캠퍼스: '글로벌캠퍼스' }, + 단국대학교: { 제1캠퍼스: '죽전캠퍼스', 제2캠퍼스: '천안캠퍼스' }, + 부산대학교: { + 제1캠퍼스: '부산캠퍼스', + 제2캠퍼스: '밀양캠퍼스', + 제3캠퍼스: '양산캠퍼스' + }, + 강원대학교: { 제1캠퍼스: '춘천캠퍼스', 제2캠퍼스: '삼척캠퍼스' } +} + +export const REGION_SHORT: Record = { + 서울특별시: '서울', + 경기도: '경기', + 인천광역시: '인천', + 부산광역시: '부산', + 대구광역시: '대구', + 광주광역시: '광주', + 대전광역시: '대전', + 울산광역시: '울산', + 세종특별자치시: '세종', + 강원특별자치도: '강원', + 충청북도: '충북', + 충청남도: '충남', + 전라남도: '전남', + 전북특별자치도: '전북', + 경상북도: '경북', + 경상남도: '경남', + 제주특별자치도: '제주' +} + /** * Statuses of the contests. * @constant From 9560b00ac6b7dafb5af0117e9b297850c945c3ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=A2=85=ED=9D=AC?= Date: Sat, 18 Jul 2026 16:54:47 +0900 Subject: [PATCH 14/18] feat(fe): add welcome modal after signup --- .../components/auth/SignUpPage/SignUpPage.tsx | 698 +++++++++--------- .../auth/SignUpPage/WelcomeModal.tsx | 66 ++ 2 files changed, 417 insertions(+), 347 deletions(-) create mode 100644 apps/frontend/components/auth/SignUpPage/WelcomeModal.tsx diff --git a/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx b/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx index 04debb0afe..89a1a58a9b 100644 --- a/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx +++ b/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx @@ -16,11 +16,11 @@ import { valibotResolver } from '@hookform/resolvers/valibot' 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 { FaEye, FaEyeSlash } from 'react-icons/fa6' import { toast } from 'sonner' +import { WelcomeModal } from './WelcomeModal' import { signupSchema } from './signup.schema' import type { SignUpFormValues } from './signup.type' @@ -88,12 +88,12 @@ function VisibleButton({ isVisible, setIsVisible }: VisibleButtonProps) { } export function SignUpPage() { - const router = useRouter() const [agreements, setAgreements] = useState({ terms: false, privacy: false, minorPrivacy: false }) + const [showWelcome, setShowWelcome] = useState(false) const { register, @@ -334,426 +334,430 @@ export function SignUpPage() { studentId: data.studentId || undefined } }) - toast.success('회원가입이 완료됐습니다!') - router.push('/login') + setShowWelcome(true) } catch { toast.error('회원가입에 실패했습니다. 다시 시도해주세요') } } return ( - -
-
-
-

회원가입

- - 기존 회원 - 이신가요? - -
- -
-
- - - {errors.name?.message && ( -

- {errors.name.message} -

- )} + <> + setShowWelcome(false)} /> + +
+
+
+

회원가입

+ + 기존 회원 + 이신가요? +
-
- -
+
+
+ - -
- {errors.userId?.message && ( -

- {errors.userId.message} -

- )} - {!errors.userId && - !isCheckingUserId && - touchedFields.userId && - !isUserIdAvailable && - watchUserId.length >= 3 && ( + {errors.name?.message && (

- 아이디 중복 확인을 해주세요 + {errors.name.message}

)} - {!errors.userId && isUserIdAvailable && ( -

- 사용 가능한 아이디입니다 -

- )} -
+
-
- -
-
+
+ +
- -
- {errors.password?.message && ( -

- {errors.password.message} -

- )} -
- - +
- {isPasswordMismatch && ( + {errors.userId?.message && (

- 비밀번호가 일치하지 않습니다 + {errors.userId.message}

)} - {isPasswordAvailable && ( -

- 사용 가능한 비밀번호입니다 + {!errors.userId && + !isCheckingUserId && + touchedFields.userId && + !isUserIdAvailable && + watchUserId.length >= 3 && ( +

+ 아이디 중복 확인을 해주세요 +

+ )} + {!errors.userId && isUserIdAvailable && ( +

+ 사용 가능한 아이디입니다

)}
-
-
- -
-
- - -
-
- {!watchNickname && ( -

- * 닉네임 미입력시, 코드당이 자동으로 닉네임을 추천해드려요! -

- )} -
- -
- - - {errors.job?.message && ( -

- {errors.job.message} -

- )} -
- - {watchJob === '대학생' && ( -
-
- - - setValue('university', v, { shouldValidate: true }) - } - error={errors.university?.message} - /> -
- - {isSKKU && ( -
- - - setValue('major', v, { shouldValidate: true }) - } - error={errors.major?.message} +
- )} - - {isSKKU && ( -
- + {errors.password?.message && ( +

+ {errors.password.message} +

+ )} +
+ - {errors.studentId?.message && ( -

- {errors.studentId.message} -

- )}
- )} + {isPasswordMismatch && ( +

+ 비밀번호가 일치하지 않습니다 +

+ )} + {isPasswordAvailable && ( +

+ 사용 가능한 비밀번호입니다 +

+ )} +
- )} -
- -
- {isSKKU ? ( -
+
+ +
+
setEmailLocal(e.target.value)} - disabled={emailVerified} - className="placeholder:text-body1_m_16 placeholder:text-color-neutral-90 min-w-0 flex-1 bg-transparent outline-none" + placeholder="희망찬 올빼미" + 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-[52px] outline-none" + {...register('nickname')} /> - - @skku.edu - +
- ) : ( - setEmailLocal(e.target.value)} - disabled={emailVerified} +
+ {!watchNickname && ( +

+ * 닉네임 미입력시, 코드당이 자동으로 닉네임을 추천해드려요! +

+ )} +
+ +
+ + + {errors.job?.message && ( +

+ {errors.job.message} +

)} -
- {errors.email?.message && ( -

- {errors.email.message} -

- )} - {emailVerified && ( -

- 이메일 인증이 완료되었습니다 -

+ + {watchJob === '대학생' && ( +
+
+ + + setValue('university', v, { shouldValidate: true }) + } + error={errors.university?.message} + /> +
+ + {isSKKU && ( +
+ + + setValue('major', v, { shouldValidate: true }) + } + error={errors.major?.message} + /> +
+ )} + + {isSKKU && ( +
+ + + {errors.studentId?.message && ( +

+ {errors.studentId.message} +

+ )} +
+ )} +
)} - {emailSent && !emailVerified && ( -
-
+
+ +
+ {isSKKU ? ( +
+ setEmailLocal(e.target.value)} + disabled={emailVerified} + className="placeholder:text-body1_m_16 placeholder:text-color-neutral-90 min-w-0 flex-1 bg-transparent outline-none" + /> + + @skku.edu + +
+ ) : ( { - const val = e.target.value - .replace(/\D/g, '') - .slice(0, 6) - setVerificationCode(val) - setPinError('') - }} - disabled={codeExpired} + placeholder="이메일을 입력해주세요" + value={emailLocal} + onChange={(e) => setEmailLocal(e.target.value)} + disabled={emailVerified} className={cn( - '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-line' + '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() )} /> - {!codeExpired && ( - - {formatTimer()} - - )} -
+ )}
- )} - {codeExpired && ( -

- 인증 코드가 만료되었습니다. 재발송 버튼을 눌러주세요 -

- )} - {pinError && ( -

- {pinError} -

- )} + {errors.email?.message && ( +

+ {errors.email.message} +

+ )} + {emailVerified && ( +

+ 이메일 인증이 완료되었습니다 +

+ )} + + {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 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-line' + )} + /> + {!codeExpired && ( + + {formatTimer()} + + )} +
+ +
+ )} + {codeExpired && ( +

+ 인증 코드가 만료되었습니다. 재발송 버튼을 눌러주세요 +

+ )} + {pinError && ( +

+ {pinError} +

+ )} +
-
-
-
-
+
+
+
+ + 전체동의 + +
+ handleAgreementChange('terms', checked)} > - 전체동의 + 이용약관 동의 -
- handleAgreementChange('terms', checked)} - > - 이용약관 동의 - + + handleAgreementChange('privacy', checked) + } + > + 코드당 개인정보 수집 및 이용 동의 + - handleAgreementChange('privacy', checked)} - > - 코드당 개인정보 수집 및 이용 동의 - - - - handleAgreementChange('minorPrivacy', checked) - } + + handleAgreementChange('minorPrivacy', checked) + } + > + 14세미만 개인정보 이용 보호 + +
+ +
- -
-
- + + ) } diff --git a/apps/frontend/components/auth/SignUpPage/WelcomeModal.tsx b/apps/frontend/components/auth/SignUpPage/WelcomeModal.tsx new file mode 100644 index 0000000000..07d4671ab6 --- /dev/null +++ b/apps/frontend/components/auth/SignUpPage/WelcomeModal.tsx @@ -0,0 +1,66 @@ +'use client' + +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle +} from '@/components/shadcn/dialog' +import infoIcon from '@/public/icons/info-icon.svg' +import Image from 'next/image' +import { useRouter } from 'next/navigation' + +interface WelcomeModalProps { + open: boolean + onClose: () => void +} + +export function WelcomeModal({ open, onClose }: WelcomeModalProps) { + const router = useRouter() + + const handleLater = () => { + onClose() + router.push('/login') + } + + const handleLinkSns = () => { + onClose() + router.push('/settings') + } + + return ( + !o && handleLater()}> + +
+
+ info +
+
+ + 어서오세요, 코드당에! + + + 설정에서 SNS를 연동해서 간편하게 로그인 할 수 있어요 + +
+
+
+ + +
+
+
+ ) +} From d59cf8f90cdd05312639949d9c5b77cf96279357 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=A2=85=ED=9D=AC?= Date: Sun, 19 Jul 2026 14:57:13 +0900 Subject: [PATCH 15/18] fix(fe): prevent userId check message flash during duplicate check --- apps/frontend/components/auth/SignUpPage/SignUpPage.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx b/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx index 89a1a58a9b..ab6ce76448 100644 --- a/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx +++ b/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx @@ -259,11 +259,12 @@ export function SignUpPage() { } const checkUserId = async () => { + setIsCheckingUserId(true) const valid = await trigger('userId') if (!valid) { + setIsCheckingUserId(false) return } - setIsCheckingUserId(true) try { await safeFetcher.get(`user/username-check?username=${watchUserId}`) clearErrors('userId') From 0d89efe34932a70f8dc540593fb53cf87b8a1747 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=A2=85=ED=9D=AC?= Date: Sun, 19 Jul 2026 15:45:30 +0900 Subject: [PATCH 16/18] fix(fe): prevent userId check message flash during duplicate check --- apps/frontend/components/auth/SignUpPage/SignUpPage.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx b/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx index ab6ce76448..d314c82a64 100644 --- a/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx +++ b/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx @@ -269,10 +269,10 @@ export function SignUpPage() { await safeFetcher.get(`user/username-check?username=${watchUserId}`) clearErrors('userId') setIsUserIdAvailable(true) + setIsCheckingUserId(false) } catch { setError('userId', { message: '중복된 아이디입니다' }) setIsUserIdAvailable(false) - } finally { setIsCheckingUserId(false) } } @@ -393,6 +393,7 @@ export function SignUpPage() { /> +
+ ) : ( + + )} +
+ ) + })} +
+ ) +} diff --git a/apps/frontend/app/(client)/(main)/settings/_components/CollegeSection.tsx b/apps/frontend/app/(client)/(main)/settings/_components/CollegeSection.tsx new file mode 100644 index 0000000000..54e97c390b --- /dev/null +++ b/apps/frontend/app/(client)/(main)/settings/_components/CollegeSection.tsx @@ -0,0 +1,37 @@ +'use client' + +import { UniversitySearchInput } from '@/components/UniversitySearchInput' +import { useSettingsContext } from './context' + +export function CollegeSection() { + const { + isLoading, + defaultProfileValues, + collegeState: { collegeValue, setCollegeValue }, + majorState: { setMajorValue } + } = useSettingsContext() + + const effectiveValue = + (collegeValue && collegeValue !== 'none' ? collegeValue : null) || + (defaultProfileValues.college && defaultProfileValues.college !== 'none' + ? defaultProfileValues.college + : null) || + '' + + return ( +
+ + { + setCollegeValue(v) + setMajorValue('') + }} + excludeNames={['성균관대학교']} + placeholder={isLoading ? 'Loading...' : '대학교 검색'} + inputClassName="text-body1_m_16 text-color-neutral-30 placeholder:text-color-neutral-90" + /> +
+ ) +} diff --git a/apps/frontend/app/(client)/(main)/settings/_components/CurrentPwSection.tsx b/apps/frontend/app/(client)/(main)/settings/_components/CurrentPwSection.tsx deleted file mode 100644 index b1484f224c..0000000000 --- a/apps/frontend/app/(client)/(main)/settings/_components/CurrentPwSection.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { Button } from '@/components/shadcn/button' -import { Input } from '@/components/shadcn/input' -import { cn } from '@/libs/utils' -import invisibleIcon from '@/public/icons/invisible.svg' -import visibleIcon from '@/public/icons/visible.svg' -import Image from 'next/image' -import React from 'react' -import { FaCheck } from 'react-icons/fa6' -import { useSettingsContext } from './context' - -interface CurrentPwSectionProps { - currentPassword: string - isCheckButtonClicked: boolean - isPasswordCorrect: boolean - checkPassword: () => Promise -} - -export function CurrentPwSection({ - currentPassword, - isCheckButtonClicked, - isPasswordCorrect, - checkPassword -}: CurrentPwSectionProps) { - const { - passwordState: { passwordShow, setPasswordShow }, - updateNow, - formState: { register, errors } - } = useSettingsContext() - - return ( - <> - -
-
- - setPasswordShow(!passwordShow)} - > - {passwordShow - -
- -
- {errors.currentPassword && - errors.currentPassword.message === 'Required' && ( -
- Required -
- )} - {!errors.currentPassword && - isCheckButtonClicked && - (isPasswordCorrect ? ( -
- Correct -
- ) : ( -
- Incorrect -
- ))} - - ) -} diff --git a/apps/frontend/app/(client)/(main)/settings/_components/DeleteAccountSection.tsx b/apps/frontend/app/(client)/(main)/settings/_components/DeleteAccountSection.tsx new file mode 100644 index 0000000000..380f8ec879 --- /dev/null +++ b/apps/frontend/app/(client)/(main)/settings/_components/DeleteAccountSection.tsx @@ -0,0 +1,108 @@ +'use client' + +import { AlertModal } from '@/components/AlertModal' +import { isHttpError, safeFetcherWithAuth } from '@/libs/utils' +import { signOut } from 'next-auth/react' +import { useState } from 'react' +import { toast } from 'sonner' + +export function DeleteAccountSection() { + const [modalOpen, setModalOpen] = useState(false) + const [password, setPassword] = useState('') + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState('') + + const handleOpen = () => { + setPassword('') + setError('') + setModalOpen(true) + } + + const handleClose = () => { + setModalOpen(false) + setPassword('') + setError('') + } + + const handleWithdraw = async () => { + if (!password) { + setError('비밀번호를 입력해주세요') + return + } + setIsLoading(true) + setError('') + try { + await safeFetcherWithAuth.delete('user', { + json: { password } + }) + toast.success('탈퇴가 완료되었습니다.') + await signOut({ callbackUrl: '/login', redirect: true }) + } catch (err) { + if (isHttpError(err)) { + if (err.response.status === 401) { + setError('비밀번호가 올바르지 않습니다') + } else if (err.response.status === 409) { + const body = await err.response.json().catch(() => ({})) + setError( + body?.message ?? '그룹의 유일한 리더인 경우 탈퇴할 수 없습니다' + ) + } else { + toast.error('탈퇴에 실패했습니다. 다시 시도해주세요.') + handleClose() + } + } else { + toast.error('탈퇴에 실패했습니다. 다시 시도해주세요.') + handleClose() + } + } finally { + setIsLoading(false) + } + } + + return ( + <> + + + !open && handleClose()} + type="warning" + title="정말 탈퇴하시겠어요?" + description="탈퇴 시 모든 데이터가 삭제되며 복구할 수 없습니다." + cancelText="취소" + closeOnAction={false} + primaryButton={{ + text: isLoading ? '처리 중...' : '탈퇴하기', + onClick: handleWithdraw + }} + onClose={handleClose} + > +
+ { + setPassword(e.target.value) + setError('') + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleWithdraw() + } + }} + className="border-line focus:border-primary h-[46px] w-full rounded-xl border bg-white px-5 outline-none" + autoComplete="current-password" + /> + {error &&

{error}

} +
+
+ + ) +} diff --git a/apps/frontend/app/(client)/(main)/settings/_components/EmailNotificationSection.tsx b/apps/frontend/app/(client)/(main)/settings/_components/EmailNotificationSection.tsx new file mode 100644 index 0000000000..0d631e51af --- /dev/null +++ b/apps/frontend/app/(client)/(main)/settings/_components/EmailNotificationSection.tsx @@ -0,0 +1,20 @@ +'use client' + +import { Switch } from '@/components/shadcn/switch' +import { toast } from 'sonner' + +export function EmailNotificationSection() { + return ( +
+
+

+ 내 질문에 답변이 등록 되면 이메일로 알림을 받겠습니다. +

+ toast.info('준비 중인 기능입니다.')} + /> +
+
+ ) +} diff --git a/apps/frontend/app/(client)/(main)/settings/_components/EmailSection.tsx b/apps/frontend/app/(client)/(main)/settings/_components/EmailSection.tsx deleted file mode 100644 index f03fa55dbe..0000000000 --- a/apps/frontend/app/(client)/(main)/settings/_components/EmailSection.tsx +++ /dev/null @@ -1,23 +0,0 @@ -'use client' - -import { Input } from '@/components/shadcn/input' -import { useSettingsContext } from './context' - -export function EmailSection() { - const { isLoading, defaultProfileValues } = useSettingsContext() - - return ( - <> - - - - ) -} diff --git a/apps/frontend/app/(client)/(main)/settings/_components/EmailVerificationSection.tsx b/apps/frontend/app/(client)/(main)/settings/_components/EmailVerificationSection.tsx new file mode 100644 index 0000000000..edfe98cbac --- /dev/null +++ b/apps/frontend/app/(client)/(main)/settings/_components/EmailVerificationSection.tsx @@ -0,0 +1,158 @@ +'use client' + +import { ALLOWED_DOMAINS } from '@/libs/constants' +import { safeFetcher, safeFetcherWithAuth } from '@/libs/utils' +import { useState } from 'react' +import { toast } from 'sonner' +import { useSettingsContext } from './context' + +export function EmailVerificationSection() { + const { defaultProfileValues, isLoading } = useSettingsContext() + + const [newEmail, setNewEmail] = useState('') + const [verificationCode, setVerificationCode] = useState('') + const [isSending, setIsSending] = useState(false) + const [isVerifying, setIsVerifying] = useState(false) + const [pinSent, setPinSent] = useState(false) + const [emailReadOnly, setEmailReadOnly] = useState(true) + + const email = defaultProfileValues.email ?? '' + const atIndex = email.indexOf('@') + const emailUser = atIndex >= 0 ? email.slice(0, atIndex) : email + const emailDomain = atIndex >= 0 ? email.slice(atIndex) : '' + + const isSkkuStudent = ALLOWED_DOMAINS.some((domain) => + email.endsWith(`@${domain}`) + ) + + const isValidEmail = (value: string) => + /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) + + const handleSendVerification = async () => { + if (!newEmail) { + toast.error('새 이메일을 입력해주세요.') + return + } + if (!isValidEmail(newEmail)) { + toast.error('올바른 이메일 형식으로 입력해주세요.') + return + } + setIsSending(true) + try { + await safeFetcher.post('email-auth/send-email/register-new', { + json: { email: newEmail } + }) + setPinSent(true) + toast.success('인증 메일이 발송되었습니다.') + } catch { + toast.error('인증 메일 발송에 실패했습니다.') + } finally { + setIsSending(false) + } + } + + const handleVerify = async () => { + if (!verificationCode) { + toast.error('인증 번호를 입력해주세요.') + return + } + setIsVerifying(true) + try { + const response = await safeFetcher.post('email-auth/verify-pin', { + json: { pin: verificationCode, email: newEmail } + }) + const token = response.headers.get('email-auth') ?? '' + await safeFetcherWithAuth.patch('user/email', { + json: { email: newEmail }, + headers: { 'email-auth': token } + }) + toast.success('이메일이 변경되었습니다.') + setTimeout(() => window.location.reload(), 1500) + } catch { + toast.error('인증에 실패했습니다. 인증 번호를 확인해주세요.') + } finally { + setIsVerifying(false) + } + } + + if (isSkkuStudent) { + return ( +
+ +
+ + {isLoading ? 'Loading...' : emailUser} + + + {isLoading ? '' : emailDomain} + +
+
+ ) + } + + return ( +
+
+
+ +
+ + {isLoading ? 'Loading...' : emailUser} + + + {isLoading ? '' : emailDomain} + +
+
+ +
+
+ + setEmailReadOnly(false)} + onChange={(e) => setNewEmail(e.target.value)} + placeholder="변경할 이메일을 입력해주세요" + className="focus:border-primary border-line text-body1_m_16 text-color-neutral-30 placeholder:text-color-neutral-90 h-[46px] w-full rounded-xl border bg-white px-5 py-[11px] outline-none" + /> +
+ +
+
+ +
+ setVerificationCode(e.target.value)} + placeholder="메일로 도착한 인증 번호를 입력해주세요" + disabled={!pinSent} + className="focus:border-primary border-line text-body1_m_16 text-color-neutral-30 placeholder:text-color-neutral-90 disabled:bg-fill-neutral disabled:text-color-neutral-70 h-[46px] min-w-0 flex-1 rounded-xl border bg-white px-5 py-[11px] outline-none" + /> + +
+
+ ) +} diff --git a/apps/frontend/app/(client)/(main)/settings/_components/IdSection.tsx b/apps/frontend/app/(client)/(main)/settings/_components/IdSection.tsx deleted file mode 100644 index 919e774de6..0000000000 --- a/apps/frontend/app/(client)/(main)/settings/_components/IdSection.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Input } from '@/components/shadcn/input' -import { useSettingsContext } from './context' - -export function IdSection() { - const { isLoading, defaultProfileValues } = useSettingsContext() - - return ( - <> - - - - ) -} diff --git a/apps/frontend/app/(client)/(main)/settings/_components/LogoSection.tsx b/apps/frontend/app/(client)/(main)/settings/_components/LogoSection.tsx deleted file mode 100644 index 921ffbfd4e..0000000000 --- a/apps/frontend/app/(client)/(main)/settings/_components/LogoSection.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import codedangSymbol from '@/public/logos/codedang-editor.svg' -import Image from 'next/image' - -export function LogoSection() { - return ( -
-
- codedang -

CODEDANG

-
-

Online Judge Platform for SKKU

-
- ) -} diff --git a/apps/frontend/app/(client)/(main)/settings/_components/MajorSection.tsx b/apps/frontend/app/(client)/(main)/settings/_components/MajorSection.tsx index 0860f3a1c3..366b0a57b2 100644 --- a/apps/frontend/app/(client)/(main)/settings/_components/MajorSection.tsx +++ b/apps/frontend/app/(client)/(main)/settings/_components/MajorSection.tsx @@ -1,182 +1,51 @@ -import { Button } from '@/components/shadcn/button' -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList -} from '@/components/shadcn/command' -import { - Popover, - PopoverContent, - PopoverTrigger -} from '@/components/shadcn/popover' -import { ScrollArea } from '@/components/shadcn/scroll-area' -import { colleges } from '@/libs/constants' -import { cn } from '@/libs/utils' -import { FaCheck, FaChevronDown } from 'react-icons/fa6' +'use client' + +import { MajorSearchInput } from '@/components/MajorSearchInput' import { useSettingsContext } from './context' +const getKoreanMajorName = (major: string) => + major + .split(/\s*\/\s*/) + .at(-1) + ?.trim() ?? major + export function MajorSection() { const { isLoading, - updateNow, - majorState: { majorOpen, setMajorOpen, majorValue, setMajorValue }, - collegeState: { - collegeOpen, - setCollegeOpen, - collegeValue, - setCollegeValue - }, - defaultProfileValues + defaultProfileValues, + majorState: { majorValue, setMajorValue }, + collegeState: { collegeValue } } = useSettingsContext() - const getMajorDisplayValue = () => { - if (updateNow) { - return majorValue === 'none' - ? 'Department Information Unavailable / 학과 정보 없음' - : majorValue - } - - return majorValue || defaultProfileValues.major - } + const isSKKU = ( + collegeValue || + defaultProfileValues.college || + '' + ).startsWith('성균관대학교') - const getCollegeDisplayValue = () => { - if (updateNow) { - return collegeValue === 'none' - ? 'Department Information Unavailable / 학과 정보 없음' - : collegeValue - } + const effectiveValue = + (majorValue && majorValue !== 'none' + ? getKoreanMajorName(majorValue) + : null) || + (defaultProfileValues.major && defaultProfileValues.major !== 'none' + ? getKoreanMajorName(defaultProfileValues.major) + : null) || + '' - return collegeValue || defaultProfileValues.college + if (!isSKKU) { + return null } return ( - <> - -
- - - - - - - - - No affiliation found. - - - {colleges?.map((college) => ( - { - setCollegeValue(currentValue) - setMajorValue('none') - setCollegeOpen(false) - }} - > - - {college.name} - - ))} - - - - - - - - - - - - - - - No major found. - - - {colleges - ?.filter((college) => college.name === collegeValue) - .flatMap((college) => college.majors) - .map((major) => ( - { - setMajorValue(currentValue) - setMajorOpen(false) - }} - > - - {major} - - ))} - - - - - - -
- +
+ + +
) } diff --git a/apps/frontend/app/(client)/(main)/settings/_components/NameSection.tsx b/apps/frontend/app/(client)/(main)/settings/_components/NameSection.tsx deleted file mode 100644 index 243d625460..0000000000 --- a/apps/frontend/app/(client)/(main)/settings/_components/NameSection.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { Input } from '@/components/shadcn/input' -import { cn } from '@/libs/utils' -import { useSettingsContext } from './context' - -interface NameSectionProps { - realName: string -} - -export function NameSection({ realName }: NameSectionProps) { - const { - isLoading, - updateNow, - defaultProfileValues, - formState: { register, errors } - } = useSettingsContext() - - return ( - <> - - - {realName && errors.realName && ( -
- {errors.realName.message} -
- )} - - ) -} diff --git a/apps/frontend/app/(client)/(main)/settings/_components/NewPwSection.tsx b/apps/frontend/app/(client)/(main)/settings/_components/NewPwSection.tsx deleted file mode 100644 index 32f2d643f6..0000000000 --- a/apps/frontend/app/(client)/(main)/settings/_components/NewPwSection.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { Input } from '@/components/shadcn/input' -import { cn } from '@/libs/utils' -import invisibleIcon from '@/public/icons/invisible.svg' -import visibleIcon from '@/public/icons/visible.svg' -import Image from 'next/image' -import React from 'react' -import { useSettingsContext } from './context' - -interface NewPwSectionProps { - newPasswordAble: boolean - isPasswordsMatch: boolean - newPassword: string - confirmPassword: string -} - -export function NewPwSection({ - newPasswordAble, - isPasswordsMatch, - newPassword, - confirmPassword -}: NewPwSectionProps) { - const { - passwordState: { newPasswordShow, setNewPasswordShow }, - updateNow, - formState: { register, errors } - } = useSettingsContext() - - return ( - <> -
-
- - setNewPasswordShow(!newPasswordShow)} - > - {newPasswordShow - -
-
- {errors.newPassword && newPasswordAble && ( -
-
    -
  • 8-20 characters
  • -
  • - Include two of the following: capital letters, small letters, - numbers -
  • -
-
- )} - - ) -} diff --git a/apps/frontend/app/(client)/(main)/settings/_components/NicknameSection.tsx b/apps/frontend/app/(client)/(main)/settings/_components/NicknameSection.tsx new file mode 100644 index 0000000000..a5e1caf604 --- /dev/null +++ b/apps/frontend/app/(client)/(main)/settings/_components/NicknameSection.tsx @@ -0,0 +1,34 @@ +'use client' + +import { cn } from '@/libs/utils' +import type { SettingsFormat } from '@/types/type' +import type { FieldErrors, UseFormRegister } from 'react-hook-form' +import { useSettingsContext } from './context' + +interface NicknameSectionProps { + register: UseFormRegister + errors: FieldErrors +} + +export function NicknameSection({ register, errors }: NicknameSectionProps) { + const { isLoading } = useSettingsContext() + + return ( +
+ + + {errors.nickname && ( +

+ {errors.nickname.message} +

+ )} +
+ ) +} diff --git a/apps/frontend/app/(client)/(main)/settings/_components/ProfilePhotoSection.tsx b/apps/frontend/app/(client)/(main)/settings/_components/ProfilePhotoSection.tsx new file mode 100644 index 0000000000..96acdbb498 --- /dev/null +++ b/apps/frontend/app/(client)/(main)/settings/_components/ProfilePhotoSection.tsx @@ -0,0 +1,45 @@ +'use client' + +import Image from 'next/image' +import { useSettingsContext } from './context' + +export function ProfilePhotoSection() { + const { defaultProfileValues, isLoading } = useSettingsContext() + + const rawUrl = defaultProfileValues.userProfile?.profileImageUrl + const profileImageUrl = rawUrl?.startsWith('dicebear:') + ? `https://api.dicebear.com/9.x/notionists/svg?seed=${rawUrl.slice('dicebear:'.length)}` + : rawUrl + + return ( +
+
+ {!isLoading && profileImageUrl ? ( + 프로필 사진 + ) : ( +
+ + + + +
+ )} +
+
+ ) +} diff --git a/apps/frontend/app/(client)/(main)/settings/_components/PushNotificationSection.tsx b/apps/frontend/app/(client)/(main)/settings/_components/PushNotificationSection.tsx deleted file mode 100644 index 5b08b00e61..0000000000 --- a/apps/frontend/app/(client)/(main)/settings/_components/PushNotificationSection.tsx +++ /dev/null @@ -1,66 +0,0 @@ -'use client' - -import { AlertModal } from '@/components/AlertModal' -import { Switch } from '@/components/shadcn/switch' -import { - fetchIsSubscribed, - handleRequestPermissionAndSubscribe -} from '@/libs/push-subscription' -import { safeFetcherWithAuth } from '@/libs/utils' -import { useState, useEffect } from 'react' -import { toast } from 'sonner' - -export function PushNotificationSection() { - const [isSubscribed, setIsSubscribed] = useState(false) - const [showDisableModal, setShowDisableModal] = useState(false) - - useEffect(() => { - fetchIsSubscribed(setIsSubscribed) - }, []) - - const handleToggle = async (checked: boolean) => { - if (checked) { - if (!('Notification' in window) || !('serviceWorker' in navigator)) { - setIsSubscribed(false) - window.dispatchEvent(new CustomEvent('push:unsupported')) - return - } - await handleRequestPermissionAndSubscribe(isSubscribed, setIsSubscribed) - } else { - setShowDisableModal(true) - } - } - - const handleDisablePushNotifications = async () => { - try { - await safeFetcherWithAuth.delete('notification/push-subscription').json() - setIsSubscribed(false) - setShowDisableModal(false) - toast.success('Push notifications are disabled.') - } catch { - toast.error('Failed to disable push notifications.') - } - } - - return ( - <> -
- - -
- - - - ) -} diff --git a/apps/frontend/app/(client)/(main)/settings/_components/ReEnterNewPwSection.tsx b/apps/frontend/app/(client)/(main)/settings/_components/ReEnterNewPwSection.tsx deleted file mode 100644 index b77f5ee8cf..0000000000 --- a/apps/frontend/app/(client)/(main)/settings/_components/ReEnterNewPwSection.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { Input } from '@/components/shadcn/input' -import invisibleIcon from '@/public/icons/invisible.svg' -import visibleIcon from '@/public/icons/visible.svg' -import type { SettingsFormat } from '@/types/type' -import Image from 'next/image' -import React from 'react' -import type { UseFormGetValues } from 'react-hook-form' -import { useSettingsContext } from './context' - -interface ReEnterNewPwSectionProps { - newPasswordAble: boolean - getValues: UseFormGetValues - confirmPassword: string - isPasswordsMatch: boolean -} - -export function ReEnterNewPwSection({ - newPasswordAble, - getValues, - confirmPassword, - isPasswordsMatch -}: ReEnterNewPwSectionProps) { - const { - updateNow, - passwordState: { confirmPasswordShow, setConfirmPasswordShow }, - formState: { register } - } = useSettingsContext() - - return ( - <> - {/* Re-enter new password */} -
-
- - setConfirmPasswordShow(!confirmPasswordShow)} - > - {confirmPasswordShow - -
-
- {getValues('confirmPassword') && - (isPasswordsMatch ? ( -
- Correct -
- ) : ( -
- Incorrect -
- ))} - - ) -} diff --git a/apps/frontend/app/(client)/(main)/settings/_components/SaveButton.tsx b/apps/frontend/app/(client)/(main)/settings/_components/SaveButton.tsx deleted file mode 100644 index 46e81a10dc..0000000000 --- a/apps/frontend/app/(client)/(main)/settings/_components/SaveButton.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Button } from '@/components/shadcn/button' -import { useSettingsContext } from './context' - -interface SaveButtonProps { - saveAbleUpdateNow: boolean - saveAble: boolean - onSubmitClick: () => void -} - -export function SaveButton({ - saveAbleUpdateNow, - saveAble, - onSubmitClick -}: SaveButtonProps) { - const { updateNow } = useSettingsContext() - - return ( -
- -
- ) -} diff --git a/apps/frontend/app/(client)/(main)/settings/_components/StudentIdSection.tsx b/apps/frontend/app/(client)/(main)/settings/_components/StudentIdSection.tsx deleted file mode 100644 index 228002890c..0000000000 --- a/apps/frontend/app/(client)/(main)/settings/_components/StudentIdSection.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { Input } from '@/components/shadcn/input' -import { cn } from '@/libs/utils' -import { useSettingsContext } from './context' - -interface StudentIdSectionProps { - studentId: string -} - -export function StudentIdSection({ studentId }: StudentIdSectionProps) { - const { - isLoading, - updateNow, - defaultProfileValues, - formState: { register, errors } - } = useSettingsContext() - - return ( - <> - - { - if (updateNow) { - return '2024123456' - } - return isLoading ? 'Loading...' : defaultProfileValues.studentId - })()} - disabled={!updateNow} - {...register('studentId')} - className={cn( - 'text-neutral-600 placeholder:text-neutral-400 focus-visible:ring-0', - (() => { - if (updateNow) { - return errors.studentId || !studentId - ? 'border-red-500' - : 'border-primary' - } - return 'border-neutral-300 disabled:bg-neutral-200' - })() - )} - /> - {errors.studentId && ( -
- {errors.studentId.message} -
- )} - - ) -} diff --git a/apps/frontend/app/(client)/(main)/settings/_components/TopicSection.tsx b/apps/frontend/app/(client)/(main)/settings/_components/TopicSection.tsx deleted file mode 100644 index ddddc7dd17..0000000000 --- a/apps/frontend/app/(client)/(main)/settings/_components/TopicSection.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { useSettingsContext } from './context' - -export function TopicSection() { - const { updateNow } = useSettingsContext() - - return ( - <> -

Settings

-

- {updateNow - ? 'You must update your information' - : 'You can change your information'} -

- - ) -} diff --git a/apps/frontend/app/(client)/(main)/settings/_components/context.ts b/apps/frontend/app/(client)/(main)/settings/_components/context.ts index de9a2ad3fa..09a6c02be3 100644 --- a/apps/frontend/app/(client)/(main)/settings/_components/context.ts +++ b/apps/frontend/app/(client)/(main)/settings/_components/context.ts @@ -1,53 +1,34 @@ -import type { SettingsFormat } from '@/types/type' import { createContext, useContext } from 'react' -import type { FieldErrors, UseFormRegister } from 'react-hook-form' export interface Profile { username: string // ID + nickname?: string + jobType?: string email: string userProfile: { realName: string + profileImageUrl?: string } studentId: string college: string major: string -} - -interface PasswordState { - passwordShow: boolean - setPasswordShow: React.Dispatch> - newPasswordShow: boolean - setNewPasswordShow: React.Dispatch> - confirmPasswordShow: boolean - setConfirmPasswordShow: React.Dispatch> -} - -interface MajorState { - majorOpen: boolean - setMajorOpen: React.Dispatch> - majorValue: string - setMajorValue: React.Dispatch> + linkedProviders?: string[] } interface CollegeState { - collegeOpen: boolean - setCollegeOpen: React.Dispatch> collegeValue: string setCollegeValue: React.Dispatch> } -interface FormState { - register: UseFormRegister - errors: FieldErrors +interface MajorState { + majorValue: string + setMajorValue: React.Dispatch> } export type SettingsContextType = { defaultProfileValues: Profile - passwordState: PasswordState collegeState: CollegeState majorState: MajorState - formState: FormState - updateNow: boolean isLoading: boolean } @@ -56,7 +37,6 @@ const SettingsContext = createContext( ) export const SettingsProvider = SettingsContext.Provider -// useSettingsContext custom hook export const useSettingsContext = () => { const context = useContext(SettingsContext) if (!context) { diff --git a/apps/frontend/app/(client)/(main)/settings/_libs/hooks/useCheckPassword.ts b/apps/frontend/app/(client)/(main)/settings/_libs/hooks/useCheckPassword.ts index 80f46baa69..d7cac679c9 100644 --- a/apps/frontend/app/(client)/(main)/settings/_libs/hooks/useCheckPassword.ts +++ b/apps/frontend/app/(client)/(main)/settings/_libs/hooks/useCheckPassword.ts @@ -1,6 +1,5 @@ import { safeFetcher } from '@/libs/utils' import { useState } from 'react' -import { toast } from 'sonner' interface UseCheckPasswordResult { isPasswordCorrect: boolean @@ -23,7 +22,6 @@ export const useCheckPassword = ( const [isCheckButtonClicked, setIsCheckButtonClicked] = useState(false) const checkPassword = async () => { - setIsCheckButtonClicked(true) try { await safeFetcher.post('auth/login', { json: { @@ -35,8 +33,9 @@ export const useCheckPassword = ( setIsPasswordCorrect(true) setNewPasswordAble(true) } catch { - toast.error('Failed to check password') - console.error('Failed to check password') + setIsPasswordCorrect(false) + } finally { + setIsCheckButtonClicked(true) } } diff --git a/apps/frontend/app/(client)/(main)/settings/_libs/schemas.ts b/apps/frontend/app/(client)/(main)/settings/_libs/schemas.ts index 239921bcb1..be97b0fd6b 100644 --- a/apps/frontend/app/(client)/(main)/settings/_libs/schemas.ts +++ b/apps/frontend/app/(client)/(main)/settings/_libs/schemas.ts @@ -1,18 +1,21 @@ import * as v from 'valibot' -export const getSchema = (updateNow: boolean) => +export const getSchema = () => v.object({ - currentPassword: v.optional(v.pipe(v.string(), v.minLength(1, 'Required'))), + currentPassword: v.optional(v.string()), newPassword: v.optional( - v.pipe( - v.string(), - v.minLength(8, 'Password must be at least 8 characters'), - v.maxLength(20, 'Password must be at most 20 characters'), - v.regex( - /^(?=.*[a-z])(?=.*[A-Z])|(?=.*[a-z])(?=.*\d)|(?=.*[A-Z])(?=.*\d)/, - 'Password must use 2 of: uppercase, lowercase, number' + v.union([ + v.literal(''), + v.pipe( + v.string(), + v.minLength(8, 'Password must be at least 8 characters'), + v.maxLength(20, 'Password must be at most 20 characters'), + v.regex( + /^(?=.*[a-z])(?=.*[A-Z])|(?=.*[a-z])(?=.*\d)|(?=.*[A-Z])(?=.*\d)/, + 'Password must use 2 of: uppercase, lowercase, number' + ) ) - ) + ]) ), confirmPassword: v.optional(v.string()), realName: v.optional( @@ -21,7 +24,8 @@ export const getSchema = (updateNow: boolean) => v.regex(/^[가-힣a-zA-Z ]*$/, 'only English and Korean supported') ) ), - studentId: updateNow - ? v.pipe(v.string(), v.regex(/^\d{10}$/, 'Only 10 numbers')) - : v.optional(v.string()) + studentId: v.optional(v.string()), + nickname: v.optional( + v.pipe(v.string(), v.maxLength(20, '닉네임은 20자 이하로 입력해주세요')) + ) }) diff --git a/apps/frontend/app/(client)/(main)/settings/page.tsx b/apps/frontend/app/(client)/(main)/settings/page.tsx index 37b889493d..d23fbf7874 100644 --- a/apps/frontend/app/(client)/(main)/settings/page.tsx +++ b/apps/frontend/app/(client)/(main)/settings/page.tsx @@ -1,54 +1,76 @@ 'use client' +import { allMajors } from '@/libs/constants' +import { cn } from '@/libs/utils' +import invisibleIcon from '@/public/icons/invisible.svg' +import visibleIcon from '@/public/icons/visible.svg' import type { SettingsFormat } from '@/types/type' import { valibotResolver } from '@hookform/resolvers/valibot' import { useMutation, useQuery } from '@tanstack/react-query' -import { useRouter, useSearchParams } from 'next/navigation' -import { useEffect, useRef, useState } from 'react' +import Image from 'next/image' +import { Suspense, useEffect, useRef, useState } from 'react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' import { updateUserProfile } from '../../_libs/apis/profile' import { profileQueries } from '../../_libs/queries/profile' +import { AccountLinkingSection } from './_components/AccountLinkingSection' +import { CollegeSection } from './_components/CollegeSection' import { ConfirmModal } from './_components/ConfirmModal' -import { CurrentPwSection } from './_components/CurrentPwSection' -import { EmailSection } from './_components/EmailSection' -import { IdSection } from './_components/IdSection' -import { LogoSection } from './_components/LogoSection' -import { MajorSection } from './_components/MajorSection' -import { NameSection } from './_components/NameSection' -import { NewPwSection } from './_components/NewPwSection' -import { PushNotificationSection } from './_components/PushNotificationSection' -import { ReEnterNewPwSection } from './_components/ReEnterNewPwSection' -import { SaveButton } from './_components/SaveButton' -import { StudentIdSection } from './_components/StudentIdSection' -import { TopicSection } from './_components/TopicSection' +import { DeleteAccountSection } from './_components/DeleteAccountSection' +import { EmailVerificationSection } from './_components/EmailVerificationSection' +import { NicknameSection } from './_components/NicknameSection' +import { ProfilePhotoSection } from './_components/ProfilePhotoSection' import { SettingsProvider } from './_components/context' import { useCheckPassword } from './_libs/hooks/useCheckPassword' import { getSchema } from './_libs/schemas' import { useConfirmNavigation } from './_libs/utils' +const JOB_TYPE_LABELS: Record = { + CollegeStudent: '대학생', + HighSchoolStudent: '고등학생', + Employee: '직장인', + Other: '기타' +} + +const findMajorKoreanName = (storedMajor?: string) => { + if (!storedMajor) { + return '' + } + const entry = + allMajors.find((m: string) => m.includes(storedMajor)) ?? storedMajor + return ( + entry + .split(/\s*\/\s*/) + .at(-1) + ?.trim() ?? entry + ) +} + +const getJobLabel = (jobType?: string) => { + if (!jobType) { + return '직업' + } + return JOB_TYPE_LABELS[jobType] ?? jobType +} + type UpdatePayload = Partial<{ password: string newPassword: string realName: string - studentId: string college: string major: string + nickname: string }> export default function Page() { - const searchParams = useSearchParams() - const updateNow = searchParams.get('updateNow') - const router = useRouter() const bypassConfirmation = useRef(false) const { data: defaultProfileValues, isLoading } = useQuery({ ...profileQueries.fetch(), initialData: { username: '', - userProfile: { - realName: '' - }, + nickname: '', + userProfile: { realName: '' }, studentId: '', college: '', major: '', @@ -60,14 +82,13 @@ export default function Page() { const [majorValue, setMajorValue] = useState(defaultProfileValues.major) const [collegeValue, setCollegeValue] = useState(defaultProfileValues.college) - useEffect(() => { - if (defaultProfileValues.major) { - setMajorValue(defaultProfileValues.major) - } - if (defaultProfileValues.college) { - setCollegeValue(defaultProfileValues.college) - } - }, [defaultProfileValues.major, defaultProfileValues.college]) + const isSKKU = + Boolean(defaultProfileValues.studentId) && + defaultProfileValues.studentId !== '0000000000' + + const [passwordShow, setPasswordShow] = useState(false) + const [newPasswordShow, setNewPasswordShow] = useState(false) + const [confirmPasswordShow, setConfirmPasswordShow] = useState(false) const { register, @@ -75,16 +96,18 @@ export default function Page() { getValues, setValue, watch, + reset, formState: { errors } } = useForm({ - resolver: valibotResolver(getSchema(Boolean(updateNow))), + resolver: valibotResolver(getSchema()), mode: 'onChange', defaultValues: { currentPassword: '', newPassword: '', confirmPassword: '', - realName: defaultProfileValues.userProfile?.realName ?? '', - studentId: defaultProfileValues.studentId + realName: '', + studentId: '', + nickname: '' } }) @@ -92,10 +115,35 @@ export default function Page() { const newPassword = watch('newPassword') const confirmPassword = watch('confirmPassword') const realName = watch('realName') - const studentId = watch('studentId') + const nickname = watch('nickname') + + const initialized = useRef(false) + + useEffect(() => { + if (isLoading || !defaultProfileValues.username) { + return + } + if (!initialized.current) { + initialized.current = true + reset({ + currentPassword: '', + newPassword: '', + confirmPassword: '', + realName: defaultProfileValues.userProfile?.realName ?? '', + studentId: defaultProfileValues.studentId ?? '', + nickname: defaultProfileValues.nickname ?? '' + }) + } + if (defaultProfileValues.college) { + setCollegeValue(defaultProfileValues.college) + } + if (defaultProfileValues.major) { + setMajorValue(defaultProfileValues.major) + } + }, [isLoading, reset, defaultProfileValues]) const { isConfirmModalOpen, setIsConfirmModalOpen, confirmAction } = - useConfirmNavigation(bypassConfirmation, Boolean(updateNow)) + useConfirmNavigation(bypassConfirmation, false) const { isPasswordCorrect, @@ -104,35 +152,23 @@ export default function Page() { checkPassword } = useCheckPassword(defaultProfileValues, currentPassword) - const [passwordShow, setPasswordShow] = useState(false) - const [newPasswordShow, setNewPasswordShow] = useState(false) - const [confirmPasswordShow, setConfirmPasswordShow] = useState(false) - const [majorOpen, setMajorOpen] = useState(false) - const [collegeOpen, setCollegeOpen] = useState(false) - const isPasswordsMatch = newPassword === confirmPassword && newPassword !== '' - const saveAblePassword: boolean = - Boolean(currentPassword) && - Boolean(newPassword) && - Boolean(confirmPassword) && - isPasswordCorrect && - newPasswordAble && - isPasswordsMatch - const saveAbleOthers: boolean = - Boolean(realName) || - Boolean(majorValue !== defaultProfileValues.major) || - Boolean(collegeValue !== defaultProfileValues.college) + + const isSamePassword = newPasswordAble && newPassword === currentPassword + const saveAble = - (saveAblePassword || saveAbleOthers) && - ((isPasswordsMatch && !errors.newPassword) || - (!newPassword && !confirmPassword)) && - majorValue !== 'none' && - collegeValue !== 'none' - const saveAbleUpdateNow = - Boolean(studentId) && - majorValue !== 'none' && - collegeValue !== 'none' && - !errors.studentId + (Boolean(currentPassword) && + Boolean(newPassword) && + Boolean(confirmPassword) && + isPasswordCorrect && + newPasswordAble && + isPasswordsMatch && + !isSamePassword) || + (Boolean(realName) && + realName !== (defaultProfileValues.userProfile?.realName ?? '')) || + majorValue !== defaultProfileValues.major || + collegeValue !== defaultProfileValues.college || + Boolean(nickname && nickname !== defaultProfileValues.nickname) useEffect(() => { if (isPasswordsMatch) { @@ -145,27 +181,18 @@ export default function Page() { mutationFn: updateUserProfile, onError: (error) => { console.error(error) - toast.error('Failed to update your information, Please try again') - setTimeout(() => { - window.location.reload() - }, 1500) + toast.error('정보 업데이트에 실패했습니다. 다시 시도해주세요.') + setTimeout(() => window.location.reload(), 1500) }, onSuccess: () => { - toast.success('Successfully updated your information') + toast.success('정보가 성공적으로 업데이트되었습니다.') bypassConfirmation.current = true - setTimeout(() => { - if (updateNow) { - router.push('/') - } else { - window.location.reload() - } - }, 1500) + setTimeout(() => window.location.reload(), 1500) } }) const onSubmit = (data: SettingsFormat) => { const updatePayload: UpdatePayload = {} - if (data.realName !== defaultProfileValues.userProfile?.realName) { updatePayload.realName = data.realName } @@ -175,130 +202,349 @@ export default function Page() { if (collegeValue !== defaultProfileValues.college) { updatePayload.college = collegeValue } - if (data.currentPassword !== 'tmppassword1') { + if (isPasswordCorrect && isPasswordsMatch && !isSamePassword) { updatePayload.password = data.currentPassword - } - if (data.newPassword !== 'tmppassword1') { updatePayload.newPassword = data.newPassword } - if (updateNow && data.studentId !== '0000000000') { - updatePayload.studentId = data.studentId + if ( + data.nickname !== undefined && + data.nickname !== defaultProfileValues.nickname + ) { + updatePayload.nickname = data.nickname } - mutate(updatePayload) } - const resetToSubmittableValue = ( - field: 'realName' | 'currentPassword' | 'newPassword' | 'confirmPassword', - value: string | undefined, - defaultValue: string - ) => { - if (value === '') { - setValue(field, defaultValue) + const onSubmitClick = () => { + if (realName === '') { + setValue('realName', defaultProfileValues.userProfile?.realName ?? '') } } - const onSubmitClick = () => { - resetToSubmittableValue( - 'realName', - realName, - defaultProfileValues.userProfile?.realName ?? '' - ) - resetToSubmittableValue('currentPassword', currentPassword, 'tmppassword1') - resetToSubmittableValue('newPassword', newPassword, 'tmppassword1') - resetToSubmittableValue('confirmPassword', confirmPassword, 'tmppassword1') - } + const inputBase = + 'h-[46px] w-full rounded-xl border border-line px-5 py-[11px] text-body1_m_16 outline-none' + const labelBase = 'text-caption2_m_12 text-color-neutral-15' - const settingsContextValue = { - defaultProfileValues, - isLoading, - passwordState: { - passwordShow, - setPasswordShow, - newPasswordShow, - setNewPasswordShow, - confirmPasswordShow, - setConfirmPasswordShow - }, - majorState: { - majorOpen, - setMajorOpen, - majorValue, - setMajorValue - }, - collegeState: { - collegeOpen, - setCollegeOpen, - collegeValue, - setCollegeValue - }, - formState: { - register, - errors - }, - updateNow: Boolean(updateNow) - } return ( -
- {/* Logo */} - - - - {/* Form */} -
- {/* Topic */} - - {/* ID */} - - {/* Email */} - - {/* Current password */} - - {/* New password */} - - {/* Re-enter new password */} - - -
- - {/* Name */} - - {/* Student ID */} - - {/* Major */} - - {/* Push Notifications */} - - {/* Save Button */} - - +
+ +
+
+
+

+ 프로필 정보 +

+ +
+
+
+ +
+ {isLoading + ? 'Loading...' + : defaultProfileValues.userProfile?.realName || '이름'} +
+
+
+ +
+ {isLoading + ? 'Loading...' + : defaultProfileValues.username || '아이디'} +
+
+
+ +
+ +
+ +
+ {isLoading + ? 'Loading...' + : getJobLabel(defaultProfileValues.jobType)} +
+
+
+ + {isSKKU ? ( + <> +
+
+ +
+ {isLoading ? 'Loading...' : '성균관대학교'} +
+
+
+
+
+ +
+ {isLoading + ? 'Loading...' + : findMajorKoreanName( + majorValue || defaultProfileValues.major + ) || '학과'} +
+
+
+ +
+ {isLoading + ? 'Loading...' + : defaultProfileValues.studentId || '학번'} +
+
+
+ + ) : ( +
+ +
+ )} +
+
+ +
+

+ 이메일 인증 +

+ +
+ + {/*
+

+ 이메일 알림 +

+ +
*/} + +
+

+ 비밀번호 변경 +

+
+
+ +
+
+ + +
+ +
+ {!errors.currentPassword && isCheckButtonClicked && ( +

+ {isPasswordCorrect + ? '비밀번호가 일치합니다.' + : '비밀번호가 일치하지 않습니다.'} +

+ )} +
+ +
+ +
+ + +
+ {isSamePassword && ( +

+ 현재 비밀번호와 동일합니다. +

+ )} + {errors.newPassword && + newPasswordAble && + newPassword && + !isSamePassword && ( +
    +
  • 8-20자리 이하
  • +
  • 영문 대/소문자, 숫자 중 2가지 이상 포함
  • +
+ )} +
+ +
+ +
+ + +
+ {getValues('confirmPassword') && ( +

+ {isPasswordsMatch + ? '비밀번호가 일치합니다.' + : '비밀번호가 일치하지 않습니다.'} +

+ )} +
+
+
+
+ +
+

+ 계정 연동 +

+ + + +
+ +
+ + +
+
setIsConfirmModalOpen(true)} handleClose={() => setIsConfirmModalOpen(false)} diff --git a/apps/frontend/app/(client)/_libs/apis/profile.ts b/apps/frontend/app/(client)/_libs/apis/profile.ts index 59264ec155..ca8f194f55 100644 --- a/apps/frontend/app/(client)/_libs/apis/profile.ts +++ b/apps/frontend/app/(client)/_libs/apis/profile.ts @@ -2,13 +2,17 @@ import { safeFetcherWithAuth } from '@/libs/utils' export interface Profile { username: string // ID + nickname?: string + jobType?: string userProfile: { realName: string + profileImageUrl?: string } studentId: string college: string major: string email: string + linkedProviders?: string[] } export const fetchUserProfile = async (): Promise => { diff --git a/apps/frontend/components/AlertModal.tsx b/apps/frontend/components/AlertModal.tsx index 1599538382..8c4c971603 100644 --- a/apps/frontend/components/AlertModal.tsx +++ b/apps/frontend/components/AlertModal.tsx @@ -32,9 +32,11 @@ interface AlertModalProps { type: 'confirm' | 'warning' showIcon?: boolean showCancelButton?: boolean + cancelText?: string title: string description?: string primaryButton: ButtonProps + closeOnAction?: boolean children?: React.ReactNode onClose?: () => void } @@ -53,6 +55,8 @@ export function AlertModal({ type, showIcon = true, showCancelButton = true, + cancelText = 'Cancel', + closeOnAction = true, title, description, primaryButton, @@ -107,10 +111,23 @@ export function AlertModal({ {showCancelButton && ( - Cancel + {cancelText} )} - + {closeOnAction ? ( + + + + ) : ( - + )} diff --git a/apps/frontend/middleware.ts b/apps/frontend/middleware.ts index 014740b333..6054744dd9 100644 --- a/apps/frontend/middleware.ts +++ b/apps/frontend/middleware.ts @@ -16,8 +16,9 @@ export const middleware = async (req: NextRequest) => { const { pathname } = req.nextUrl const isCourseDetailPath = /^\/course\/.+/.test(pathname) + const isSettingsPath = pathname === '/settings' - if (isCourseDetailPath && !token) { + if ((isCourseDetailPath || isSettingsPath) && !token) { const loginUrl = new URL('/login', req.url) loginUrl.searchParams.set('redirectUrl', pathname) return NextResponse.redirect(loginUrl) diff --git a/apps/frontend/next.config.ts b/apps/frontend/next.config.ts index 5ceade8718..39959506d4 100644 --- a/apps/frontend/next.config.ts +++ b/apps/frontend/next.config.ts @@ -21,6 +21,9 @@ const BUCKET_NAME = process.env.MEDIA_BUCKET_NAME const nextConfig = { images: { + dangerouslyAllowSVG: true, + contentDispositionType: 'attachment', + contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;", remotePatterns: [ { protocol: 'https', @@ -33,6 +36,10 @@ const nextConfig = { { protocol: 'https', hostname: '**.cdninstagram.com' + }, + { + protocol: 'https', + hostname: 'api.dicebear.com' } ] }, diff --git a/apps/frontend/public/icons/github.svg b/apps/frontend/public/icons/github.svg new file mode 100644 index 0000000000..998c14c457 --- /dev/null +++ b/apps/frontend/public/icons/github.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/frontend/public/icons/google.svg b/apps/frontend/public/icons/google.svg new file mode 100644 index 0000000000..cf100ddfd8 --- /dev/null +++ b/apps/frontend/public/icons/google.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/frontend/public/icons/kakao.svg b/apps/frontend/public/icons/kakao.svg new file mode 100644 index 0000000000..3daa18e9dc --- /dev/null +++ b/apps/frontend/public/icons/kakao.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/frontend/public/icons/naver.svg b/apps/frontend/public/icons/naver.svg new file mode 100644 index 0000000000..e63b5a027e --- /dev/null +++ b/apps/frontend/public/icons/naver.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/frontend/types/type.ts b/apps/frontend/types/type.ts index 4c23f93103..add5b1ff08 100644 --- a/apps/frontend/types/type.ts +++ b/apps/frontend/types/type.ts @@ -385,6 +385,7 @@ export interface SettingsFormat { realName: string studentId: string email: string + nickname: string } export interface CourseInfo {