From 498ad1dfd62119baddacd5d03280ed40d8a5e884 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=A2=85=ED=9D=AC?= Date: Sun, 28 Jun 2026 14:59:17 +0900 Subject: [PATCH 01/19] feat(fe): redesign settings page for whitelist removal --- .../_components/AccountLinkingSection.tsx | 137 ++++ .../settings/_components/CollegeSection.tsx | 159 +++++ .../settings/_components/CurrentPwSection.tsx | 88 --- .../_components/EmailNotificationSection.tsx | 53 ++ .../settings/_components/EmailSection.tsx | 23 - .../_components/EmailVerificationSection.tsx | 153 +++++ .../(main)/settings/_components/IdSection.tsx | 17 - .../settings/_components/LogoSection.tsx | 22 - .../settings/_components/MajorSection.tsx | 266 +++----- .../settings/_components/NameSection.tsx | 40 -- .../settings/_components/NewPwSection.tsx | 74 --- .../settings/_components/NicknameSection.tsx | 38 ++ .../_components/ProfilePhotoSection.tsx | 58 ++ .../_components/PushNotificationSection.tsx | 66 -- .../_components/ReEnterNewPwSection.tsx | 68 -- .../settings/_components/SaveButton.tsx | 29 - .../settings/_components/StudentIdSection.tsx | 48 -- .../settings/_components/TopicSection.tsx | 16 - .../(main)/settings/_components/context.ts | 34 +- .../settings/_libs/hooks/useCheckPassword.ts | 7 +- .../(client)/(main)/settings/_libs/schemas.ts | 13 +- .../app/(client)/(main)/settings/page.tsx | 603 +++++++++++++----- .../app/(client)/_libs/apis/profile.ts | 4 + apps/frontend/types/type.ts | 1 + 24 files changed, 1155 insertions(+), 862 deletions(-) create mode 100644 apps/frontend/app/(client)/(main)/settings/_components/AccountLinkingSection.tsx create mode 100644 apps/frontend/app/(client)/(main)/settings/_components/CollegeSection.tsx delete mode 100644 apps/frontend/app/(client)/(main)/settings/_components/CurrentPwSection.tsx create mode 100644 apps/frontend/app/(client)/(main)/settings/_components/EmailNotificationSection.tsx delete mode 100644 apps/frontend/app/(client)/(main)/settings/_components/EmailSection.tsx create mode 100644 apps/frontend/app/(client)/(main)/settings/_components/EmailVerificationSection.tsx delete mode 100644 apps/frontend/app/(client)/(main)/settings/_components/IdSection.tsx delete mode 100644 apps/frontend/app/(client)/(main)/settings/_components/LogoSection.tsx delete mode 100644 apps/frontend/app/(client)/(main)/settings/_components/NameSection.tsx delete mode 100644 apps/frontend/app/(client)/(main)/settings/_components/NewPwSection.tsx create mode 100644 apps/frontend/app/(client)/(main)/settings/_components/NicknameSection.tsx create mode 100644 apps/frontend/app/(client)/(main)/settings/_components/ProfilePhotoSection.tsx delete mode 100644 apps/frontend/app/(client)/(main)/settings/_components/PushNotificationSection.tsx delete mode 100644 apps/frontend/app/(client)/(main)/settings/_components/ReEnterNewPwSection.tsx delete mode 100644 apps/frontend/app/(client)/(main)/settings/_components/SaveButton.tsx delete mode 100644 apps/frontend/app/(client)/(main)/settings/_components/StudentIdSection.tsx delete mode 100644 apps/frontend/app/(client)/(main)/settings/_components/TopicSection.tsx diff --git a/apps/frontend/app/(client)/(main)/settings/_components/AccountLinkingSection.tsx b/apps/frontend/app/(client)/(main)/settings/_components/AccountLinkingSection.tsx new file mode 100644 index 0000000000..08aa1baca0 --- /dev/null +++ b/apps/frontend/app/(client)/(main)/settings/_components/AccountLinkingSection.tsx @@ -0,0 +1,137 @@ +'use client' + +import { toast } from 'sonner' +import { useSettingsContext } from './context' + +const PROVIDERS = [ + { + id: 'google', + name: 'Google', + icon: ( + + + + + + + ), + bgColor: 'bg-white border border-[#d8d8d8]' + }, + { + id: 'kakao', + name: '카카오톡', + icon: ( + + + + ), + bgColor: 'bg-[#fbe300]' + }, + { + id: 'github', + name: 'GitHub', + icon: ( + + + + ), + bgColor: 'bg-black' + }, + { + id: 'naver', + name: 'Naver', + icon: ( + + + + ), + bgColor: 'bg-[#03cf5d]' + } +] + +export function AccountLinkingSection() { + const { defaultProfileValues } = useSettingsContext() + + const connectedProviders = new Set( + defaultProfileValues.userOauth?.map((o) => o.provider) ?? [] + ) + + const handleConnect = (providerId: string) => { + toast.info(`${providerId} 연결 기능은 준비 중입니다.`) + } + + const handleDisconnect = (providerId: string) => { + toast.info(`${providerId} 연결 해제 기능은 준비 중입니다.`) + } + + return ( +
+ {PROVIDERS.map((provider) => { + const isConnected = connectedProviders.has(provider.id) + return ( +
+
+
+
+
+ {provider.icon} +
+
+ + {provider.name} + +
+ + {isConnected ? ( +
+ + 연결됨 + + +
+ ) : ( + + )} +
+ ) + })} +
+ ) +} 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..3d35a4067c --- /dev/null +++ b/apps/frontend/app/(client)/(main)/settings/_components/CollegeSection.tsx @@ -0,0 +1,159 @@ +'use client' + +import { cn } from '@/libs/utils' +// @ts-expect-error: no type declarations for this package +import { searchUniversities } from 'korea-universities' +import { useState } from 'react' +import { IoSearchOutline } from 'react-icons/io5' +import { useSettingsContext } from './context' + +type University = ReturnType[number] + +const CAMPUS_OVERRIDES: Record> = { + 성균관대학교: { 제1캠퍼스: '서울캠퍼스', 제2캠퍼스: '수원캠퍼스' }, + 연세대학교: { 제1캠퍼스: '신촌캠퍼스', 제2캠퍼스: '국제캠퍼스' }, + 경희대학교: { 제1캠퍼스: '서울캠퍼스', 제2캠퍼스: '국제캠퍼스' }, + 중앙대학교: { 제1캠퍼스: '서울캠퍼스', 제2캠퍼스: '안성캠퍼스' }, + 한국외국어대학교: { 제1캠퍼스: '서울캠퍼스', 제2캠퍼스: '글로벌캠퍼스' }, + 단국대학교: { 제1캠퍼스: '죽전캠퍼스', 제2캠퍼스: '천안캠퍼스' }, + 부산대학교: { 제1캠퍼스: '부산캠퍼스', 제2캠퍼스: '양산캠퍼스' } +} + +const REGION_SHORT: Record = { + 서울특별시: '서울', + 부산광역시: '부산', + 대구광역시: '대구', + 인천광역시: '인천', + 광주광역시: '광주', + 대전광역시: '대전', + 울산광역시: '울산', + 세종특별자치시: '세종', + 경기도: '경기', + 강원도: '강원', + 충청북도: '충북', + 충청남도: '충남', + 전라북도: '전북', + 전라남도: '전남', + 경상북도: '경북', + 경상남도: '경남', + 제주특별자치도: '제주' +} + +export function CollegeSection() { + const { + isLoading, + defaultProfileValues, + collegeState: { collegeValue, setCollegeValue }, + majorState: { setMajorValue } + } = useSettingsContext() + + const getInitialDisplay = () => { + if (collegeValue && collegeValue !== 'none') { + return collegeValue + } + if ( + defaultProfileValues.college && + defaultProfileValues.college !== 'none' + ) { + return defaultProfileValues.college + } + return '' + } + const initialDisplay = getInitialDisplay() + + const [query, setQuery] = useState(initialDisplay) + const [open, setOpen] = useState(false) + + const filteredUniversities: University[] = + query.length > 0 ? searchUniversities(query) : [] + + const getDisplayName = (uni: University): string => { + const hasDuplicate = + filteredUniversities.filter((u: University) => 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: University) => 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}캠퍼스` + } + + return ( +
{ + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + setOpen(false) + } + }} + tabIndex={-1} + > + +
+ { + setQuery(e.target.value) + setOpen(true) + if (collegeValue) { + setCollegeValue('') + } + }} + onFocus={() => setOpen(true)} + className="focus:border-primary h-[46px] w-full rounded-xl border border-[#d8d8d8] bg-white px-5 py-[11px] pr-11 text-base font-medium tracking-[-0.48px] text-[#474747] outline-none placeholder:text-[#c4c4c4]" + /> + +
+ {open && query.length > 0 && ( +
    + {filteredUniversities.length > 0 ? ( + filteredUniversities.map((uni: University) => { + const displayName = getDisplayName(uni) + return ( +
  • e.preventDefault()} + onClick={() => { + setCollegeValue(displayName) + setMajorValue('') + setQuery(displayName) + setOpen(false) + }} + className={cn( + 'cursor-pointer px-5 py-[13px] text-base font-medium tracking-[-0.48px] hover:bg-gray-50', + collegeValue === displayName && 'bg-gray-50' + )} + > + {displayName} +
  • + ) + }) + ) : ( +
  • + 검색 결과가 없습니다 +
  • + )} +
+ )} +
+ ) +} 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/EmailNotificationSection.tsx b/apps/frontend/app/(client)/(main)/settings/_components/EmailNotificationSection.tsx new file mode 100644 index 0000000000..902d43fbb1 --- /dev/null +++ b/apps/frontend/app/(client)/(main)/settings/_components/EmailNotificationSection.tsx @@ -0,0 +1,53 @@ +'use client' + +import { Switch } from '@/components/shadcn/switch' +import { safeFetcherWithAuth } from '@/libs/utils' +import { useEffect, useState } from 'react' +import { toast } from 'sonner' + +export function EmailNotificationSection() { + const [isSubscribed, setIsSubscribed] = useState(false) + + useEffect(() => { + const fetchStatus = async () => { + try { + const data = await safeFetcherWithAuth + .get('notification/email-subscription') + .json<{ isSubscribed: boolean }>() + setIsSubscribed(data.isSubscribed) + } catch { + // Not subscribed or endpoint not available + } + } + fetchStatus() + }, []) + + const handleToggle = async (checked: boolean) => { + try { + if (checked) { + await safeFetcherWithAuth.post('notification/email-subscription').json() + setIsSubscribed(true) + toast.success('이메일 알림이 활성화되었습니다.') + } else { + await safeFetcherWithAuth + .delete('notification/email-subscription') + .json() + setIsSubscribed(false) + toast.success('이메일 알림이 비활성화되었습니다.') + } + } catch { + toast.error('알림 설정 변경에 실패했습니다.') + } + } + + return ( +
+
+

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

+ +
+
+ ) +} 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..a357aa0967 --- /dev/null +++ b/apps/frontend/app/(client)/(main)/settings/_components/EmailVerificationSection.tsx @@ -0,0 +1,153 @@ +'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 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 handleSendVerification = async () => { + if (!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('이메일이 변경되었습니다.') + setPinSent(false) + setNewEmail('') + setVerificationCode('') + } catch { + toast.error('인증에 실패했습니다. 인증 번호를 확인해주세요.') + } finally { + setIsVerifying(false) + } + } + + if (isSkkuStudent) { + return ( +
+ +
+ + {isLoading ? 'Loading...' : emailUser} + + + {isLoading ? '' : emailDomain} + +
+
+ ) + } + + return ( +
+ {/* Current email + new email input */} +
+
+ +
+ + {isLoading ? 'Loading...' : emailUser} + + + {isLoading ? '' : emailDomain} + +
+
+ +
+
+ + setNewEmail(e.target.value)} + placeholder="변경할 이메일을 입력해주세요" + className="focus:border-primary h-[46px] w-full rounded-xl border border-[#d8d8d8] bg-white px-5 py-[11px] text-base font-medium tracking-[-0.48px] text-[#474747] outline-none placeholder:text-[#c4c4c4]" + /> +
+ +
+
+ + {/* PIN input + confirm button */} +
+ setVerificationCode(e.target.value)} + placeholder="메일로 도착한 인증 번호를 입력해주세요" + disabled={!pinSent} + className="focus:border-primary h-[46px] min-w-0 flex-1 rounded-xl border border-[#d8d8d8] bg-white px-5 py-[11px] text-base font-medium tracking-[-0.48px] text-[#474747] outline-none placeholder:text-[#c4c4c4] disabled:bg-[#e5e5e5] disabled:text-[#9b9b9b]" + /> + +
+
+ ) +} 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..acf0ecc7bc 100644 --- a/apps/frontend/app/(client)/(main)/settings/_components/MajorSection.tsx +++ b/apps/frontend/app/(client)/(main)/settings/_components/MajorSection.tsx @@ -1,182 +1,120 @@ -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' +'use client' + +import { allMajors } from '@/libs/constants' import { cn } from '@/libs/utils' -import { FaCheck, FaChevronDown } from 'react-icons/fa6' +import { useState } from 'react' +import { IoSearchOutline } from 'react-icons/io5' 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 - } + const isSKKU = ( + collegeValue || + defaultProfileValues.college || + '' + ).startsWith('성균관대학교') - return majorValue || defaultProfileValues.major + const getInitialDisplay = () => { + if (majorValue && majorValue !== 'none') { + return getKoreanMajorName(majorValue) + } + if (defaultProfileValues.major && defaultProfileValues.major !== 'none') { + return getKoreanMajorName(defaultProfileValues.major) + } + return '' } + const initialDisplay = getInitialDisplay() - const getCollegeDisplayValue = () => { - if (updateNow) { - return collegeValue === 'none' - ? 'Department Information Unavailable / 학과 정보 없음' - : collegeValue - } + const [query, setQuery] = useState(initialDisplay) + const [open, setOpen] = useState(false) + + const filteredMajors = + query.length > 0 + ? allMajors.filter((m: string) => + getKoreanMajorName(m).toLowerCase().includes(query.toLowerCase()) + ) + : [] - 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} - - ))} - - - - - - +
{ + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + setOpen(false) + } + }} + tabIndex={-1} + > + +
+ { + setQuery(e.target.value) + setOpen(true) + if (majorValue) { + setMajorValue('') + } + }} + onFocus={() => setOpen(true)} + className="focus:border-primary h-[46px] w-full rounded-xl border border-[#d8d8d8] bg-white px-5 py-[11px] pr-11 text-base font-medium tracking-[-0.48px] text-[#474747] outline-none placeholder:text-[#c4c4c4]" + /> +
- + {open && query.length > 0 && ( +
    + {filteredMajors.length > 0 ? ( + filteredMajors.map((major: string) => { + const displayName = getKoreanMajorName(major) + return ( +
  • e.preventDefault()} + onClick={() => { + setMajorValue(displayName) + setQuery(displayName) + setOpen(false) + }} + className={cn( + 'cursor-pointer px-5 py-[13px] text-base font-medium tracking-[-0.48px] hover:bg-gray-50', + majorValue === displayName && 'bg-gray-50' + )} + > + {displayName} +
  • + ) + }) + ) : ( +
  • + 검색 결과가 없습니다 +
  • + )} +
+ )} +
) } 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..4b08e68512 --- /dev/null +++ b/apps/frontend/app/(client)/(main)/settings/_components/NicknameSection.tsx @@ -0,0 +1,38 @@ +'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, defaultProfileValues } = 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..418e824218 --- /dev/null +++ b/apps/frontend/app/(client)/(main)/settings/_components/ProfilePhotoSection.tsx @@ -0,0 +1,58 @@ +'use client' + +import { Camera } from 'lucide-react' +import Image from 'next/image' +import { useRef } from 'react' +import { useSettingsContext } from './context' + +export function ProfilePhotoSection() { + const { defaultProfileValues, isLoading } = useSettingsContext() + const fileInputRef = useRef(null) + + const profileImageUrl = defaultProfileValues.userProfile?.profileImageUrl + + 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..d65237e7ab 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> + userOauth?: { provider: 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..8e5a4f8dc6 100644 --- a/apps/frontend/app/(client)/(main)/settings/_libs/schemas.ts +++ b/apps/frontend/app/(client)/(main)/settings/_libs/schemas.ts @@ -1,6 +1,6 @@ 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'))), newPassword: v.optional( @@ -21,7 +21,12 @@ 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.minLength(1, '닉네임을 입력해주세요'), + 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..5b84f11c9e 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 Image from 'next/image' import { 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 { EmailNotificationSection } from './_components/EmailNotificationSection' +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,33 @@ 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 || initialized.current) { + return + } + initialized.current = true + reset({ + currentPassword: '', + newPassword: '', + confirmPassword: '', + realName: defaultProfileValues.userProfile?.realName ?? '', + studentId: defaultProfileValues.studentId ?? '', + nickname: defaultProfileValues.nickname ?? '' + }) + if (defaultProfileValues.major) { + setMajorValue(defaultProfileValues.major) + } + if (defaultProfileValues.college) { + setCollegeValue(defaultProfileValues.college) + } + }, [isLoading, reset, defaultProfileValues]) const { isConfirmModalOpen, setIsConfirmModalOpen, confirmAction } = - useConfirmNavigation(bypassConfirmation, Boolean(updateNow)) + useConfirmNavigation(bypassConfirmation, false) const { isPasswordCorrect, @@ -104,35 +150,19 @@ 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 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) || + Boolean(realName) || + majorValue !== defaultProfileValues.major || + collegeValue !== defaultProfileValues.college || + Boolean(nickname && nickname !== defaultProfileValues.nickname) useEffect(() => { if (isPasswordsMatch) { @@ -145,27 +175,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 } @@ -181,124 +202,362 @@ export default function Page() { if (data.newPassword !== 'tmppassword1') { updatePayload.newPassword = data.newPassword } - if (updateNow && data.studentId !== '0000000000') { - updatePayload.studentId = data.studentId + if (data.nickname && 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 = () => { - resetToSubmittableValue( + const reset = ( + field: 'realName' | 'currentPassword' | 'newPassword' | 'confirmPassword', + current: string | undefined, + fallback: string + ) => { + if (current === '') { + setValue(field, fallback) + } + } + reset( 'realName', realName, defaultProfileValues.userProfile?.realName ?? '' ) - resetToSubmittableValue('currentPassword', currentPassword, 'tmppassword1') - resetToSubmittableValue('newPassword', newPassword, 'tmppassword1') - resetToSubmittableValue('confirmPassword', confirmPassword, 'tmppassword1') + reset('currentPassword', currentPassword, 'tmppassword1') + reset('newPassword', newPassword, 'tmppassword1') + reset('confirmPassword', confirmPassword, 'tmppassword1') } - 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 */} - + const inputBase = + 'h-[46px] w-full rounded-xl border border-[#d8d8d8] px-5 py-[11px] text-base font-medium tracking-[-0.48px] outline-none' + const labelBase = + 'text-xs font-medium leading-[1.4] tracking-[-0.36px] text-[#1c1c1c]' - - {/* Form */} + return ( +
+
- {/* 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 + ? '비밀번호가 일치합니다.' + : '비밀번호가 일치하지 않습니다.'} +

+ )} +
+ + {/* 새 비밀번호 */} +
+ +
+ + +
+ {errors.newPassword && newPasswordAble && newPassword && ( +
    +
  • 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..528a9d4585 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 + userOauth?: { provider: string }[] } export const fetchUserProfile = async (): Promise => { 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 { From c86b4df82a8d518efac0fd20f29e3894c9f1a8fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=A2=85=ED=9D=AC?= Date: Thu, 2 Jul 2026 13:10:45 +0900 Subject: [PATCH 02/19] fix(fe): guard settings page and refine email validation --- .../_components/EmailNotificationSection.tsx | 41 ++----------------- .../_components/EmailVerificationSection.tsx | 9 +++- .../app/(client)/(main)/settings/page.tsx | 13 ------ apps/frontend/middleware.ts | 3 +- 4 files changed, 13 insertions(+), 53 deletions(-) diff --git a/apps/frontend/app/(client)/(main)/settings/_components/EmailNotificationSection.tsx b/apps/frontend/app/(client)/(main)/settings/_components/EmailNotificationSection.tsx index 902d43fbb1..44596d5b90 100644 --- a/apps/frontend/app/(client)/(main)/settings/_components/EmailNotificationSection.tsx +++ b/apps/frontend/app/(client)/(main)/settings/_components/EmailNotificationSection.tsx @@ -1,52 +1,19 @@ 'use client' import { Switch } from '@/components/shadcn/switch' -import { safeFetcherWithAuth } from '@/libs/utils' -import { useEffect, useState } from 'react' import { toast } from 'sonner' export function EmailNotificationSection() { - const [isSubscribed, setIsSubscribed] = useState(false) - - useEffect(() => { - const fetchStatus = async () => { - try { - const data = await safeFetcherWithAuth - .get('notification/email-subscription') - .json<{ isSubscribed: boolean }>() - setIsSubscribed(data.isSubscribed) - } catch { - // Not subscribed or endpoint not available - } - } - fetchStatus() - }, []) - - const handleToggle = async (checked: boolean) => { - try { - if (checked) { - await safeFetcherWithAuth.post('notification/email-subscription').json() - setIsSubscribed(true) - toast.success('이메일 알림이 활성화되었습니다.') - } else { - await safeFetcherWithAuth - .delete('notification/email-subscription') - .json() - setIsSubscribed(false) - toast.success('이메일 알림이 비활성화되었습니다.') - } - } catch { - toast.error('알림 설정 변경에 실패했습니다.') - } - } - return (

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

- + toast.info('준비 중인 기능입니다.')} + />
) diff --git a/apps/frontend/app/(client)/(main)/settings/_components/EmailVerificationSection.tsx b/apps/frontend/app/(client)/(main)/settings/_components/EmailVerificationSection.tsx index a357aa0967..b5d6eb5653 100644 --- a/apps/frontend/app/(client)/(main)/settings/_components/EmailVerificationSection.tsx +++ b/apps/frontend/app/(client)/(main)/settings/_components/EmailVerificationSection.tsx @@ -24,11 +24,18 @@ export function EmailVerificationSection() { 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', { @@ -89,7 +96,6 @@ export function EmailVerificationSection() { return (
- {/* Current email + new email input */}
- {/* PIN input + confirm button */}
- {/* 프로필 정보 */}

프로필 정보

- {/* 이름 + 아이디 */}
@@ -284,7 +282,6 @@ export default function Page() {
- {/* 닉네임 + 직업 */}
@@ -304,7 +301,6 @@ export default function Page() { {isSKKU ? ( <> - {/* 성대생: 학교/학과/학번 회색 고정 */}
@@ -350,7 +346,6 @@ export default function Page() {
) : ( - /* 비성대생: 학교만 편집 가능 */
@@ -358,7 +353,6 @@ export default function Page() {
- {/* 이메일 인증 */}

이메일 인증 @@ -366,7 +360,6 @@ export default function Page() {

- {/* 이메일 알림 */}

이메일 알림 @@ -374,13 +367,11 @@ export default function Page() {

- {/* 비밀번호 변경 */}

비밀번호 변경

- {/* 현재 비밀번호 */}
@@ -435,7 +426,6 @@ export default function Page() { )}
- {/* 새 비밀번호 */}
@@ -475,7 +465,6 @@ export default function Page() { )}
- {/* 새 비밀번호 확인 */}
@@ -521,7 +510,6 @@ export default function Page() {
- {/* 계정 연동 */}

계정 연동 @@ -529,7 +517,6 @@ export default function Page() {

- {/* 버튼 */}
@@ -90,7 +90,7 @@ export function AccountLinkingSection() { diff --git a/apps/frontend/app/(client)/(main)/settings/_components/CollegeSection.tsx b/apps/frontend/app/(client)/(main)/settings/_components/CollegeSection.tsx index 18cf8f6017..344a7193d9 100644 --- a/apps/frontend/app/(client)/(main)/settings/_components/CollegeSection.tsx +++ b/apps/frontend/app/(client)/(main)/settings/_components/CollegeSection.tsx @@ -98,9 +98,7 @@ export function CollegeSection() { }} tabIndex={-1} > - +
setOpen(true)} - className="focus:border-primary h-[46px] w-full rounded-xl border border-[#d8d8d8] bg-white px-5 py-[11px] pr-11 text-base font-medium tracking-[-0.48px] text-[#474747] outline-none placeholder:text-[#c4c4c4]" + 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] pr-11 outline-none" />
{open && query.length > 0 && ( -
    +
      {filteredUniversities.length > 0 ? ( filteredUniversities.map((uni: University) => { const displayName = getDisplayName(uni) @@ -138,7 +136,7 @@ export function CollegeSection() { setOpen(false) }} className={cn( - 'cursor-pointer px-5 py-[13px] text-base font-medium tracking-[-0.48px] hover:bg-gray-50', + 'text-body1_m_16 cursor-pointer px-5 py-[13px] hover:bg-gray-50', collegeValue === displayName && 'bg-gray-50' )} > @@ -147,7 +145,7 @@ export function CollegeSection() { ) }) ) : ( -
    • +
    • 검색 결과가 없습니다
    • )} diff --git a/apps/frontend/app/(client)/(main)/settings/_components/EmailNotificationSection.tsx b/apps/frontend/app/(client)/(main)/settings/_components/EmailNotificationSection.tsx index 44596d5b90..0d631e51af 100644 --- a/apps/frontend/app/(client)/(main)/settings/_components/EmailNotificationSection.tsx +++ b/apps/frontend/app/(client)/(main)/settings/_components/EmailNotificationSection.tsx @@ -5,9 +5,9 @@ import { toast } from 'sonner' export function EmailNotificationSection() { return ( -
      +
      -

      +

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

      -