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..76fdafa07a --- /dev/null +++ b/apps/frontend/app/(client)/(main)/settings/_components/AccountLinkingSection.tsx @@ -0,0 +1,170 @@ +'use client' + +import { safeFetcherWithAuth } from '@/libs/utils' +import githubIcon from '@/public/icons/github.svg' +import googleIcon from '@/public/icons/google.svg' +import kakaoIcon from '@/public/icons/kakao.svg' +import naverIcon from '@/public/icons/naver.svg' +import Image from 'next/image' +import { useRouter, useSearchParams } from 'next/navigation' +import { useEffect, useRef, useState } from 'react' +import { toast } from 'sonner' +import { useSettingsContext } from './context' + +const PROVIDERS = [ + { + id: 'google', + name: 'Google', + icon: googleIcon, + iconSize: 32, + bgColor: 'bg-white border border-line' + }, + { + id: 'kakao', + name: '카카오톡', + icon: kakaoIcon, + iconSize: 28, + bgColor: 'bg-[#fbe300]' + }, + { + id: 'github', + name: 'GitHub', + icon: githubIcon, + iconSize: 28, + bgColor: 'bg-black' + }, + { + id: 'naver', + name: 'Naver', + icon: naverIcon, + iconSize: 28, + bgColor: 'bg-[#03cf5d]' + } +] + +export function AccountLinkingSection() { + const { defaultProfileValues } = useSettingsContext() + const router = useRouter() + const searchParams = useSearchParams() + const [loadingProvider, setLoadingProvider] = useState(null) + const [locallyLinked, setLocallyLinked] = useState>(new Set()) + const [locallyUnlinked, setLocallyUnlinked] = useState>(new Set()) + const hasProcessed = useRef(false) + + const connectedProviders = new Set( + [...(defaultProfileValues.linkedProviders ?? []), ...locallyLinked].filter( + (p) => !locallyUnlinked.has(p) + ) + ) + + const oauthToken = searchParams.get('oauthToken') + const error = searchParams.get('error') + + useEffect(() => { + if (hasProcessed.current) { + return + } + if (oauthToken) { + hasProcessed.current = true + const link = async () => { + try { + await safeFetcherWithAuth.post('auth/social-link', { + json: { oauthToken } + }) + toast.success('카카오 계정이 연결되었습니다') + setLocallyLinked((prev) => new Set([...prev, 'kakao'])) + } catch { + toast.error('계정 연결에 실패했습니다. 다시 시도해주세요') + } finally { + router.replace('/settings') + } + } + link() + } else if (error === 'already-linked') { + hasProcessed.current = true + toast.error('이미 다른 계정에 연결된 카카오 계정입니다') + router.replace('/settings') + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const handleConnect = (providerId: string) => { + if (providerId === 'kakao') { + window.location.href = `${process.env.NEXT_PUBLIC_BASEURL}/auth/kakao/link` + return + } + toast.info(`${providerId} 연결 기능은 준비 중입니다.`) + } + + const handleDisconnect = async (providerId: string) => { + if (providerId !== 'kakao') { + toast.info(`${providerId} 연결 해제 기능은 준비 중입니다.`) + return + } + setLoadingProvider(providerId) + try { + await safeFetcherWithAuth.delete(`auth/social-link/${providerId}`) + toast.success('카카오 계정 연결이 해제되었습니다') + setLocallyUnlinked((prev) => new Set([...prev, providerId])) + setLocallyLinked((prev) => { + const next = new Set(prev) + next.delete(providerId) + return next + }) + } catch { + toast.error('연결 해제에 실패했습니다. 다시 시도해주세요') + } finally { + setLoadingProvider(null) + } + } + + return ( +
+ {PROVIDERS.map((provider) => { + const isConnected = connectedProviders.has(provider.id) + return ( +
+
+
+
+
+ {provider.name} +
+
+ + {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..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..3988acd14f --- /dev/null +++ b/apps/frontend/app/(client)/(main)/settings/_components/ProfilePhotoSection.tsx @@ -0,0 +1,61 @@ +'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 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/components/auth/SignUpPage/SignUpPage.tsx b/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx index 62ce288a68..ff0511a58a 100644 --- a/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx +++ b/apps/frontend/components/auth/SignUpPage/SignUpPage.tsx @@ -219,7 +219,12 @@ export function SignUpPage() { }, [watchUserId]) useEffect(() => { - const fullEmail = isSKKU ? `${emailLocal}@skku.edu` : emailLocal + let fullEmail = emailLocal + if (!emailLocal) { + fullEmail = '' + } else if (isSKKU) { + fullEmail = `${emailLocal}@skku.edu` + } setValue('email', fullEmail, { shouldValidate: emailLocal.length > 0 }) }, [emailLocal, isSKKU, setValue]) @@ -431,6 +436,10 @@ export function SignUpPage() { setError('major', { message: '소속 학과를 선택해주세요' }) return } + if (isSKKU && !data.studentId) { + setError('studentId', { message: '학번을 입력해주세요' }) + return + } try { await safeFetcher.post('user/sign-up', { headers: { 'email-auth': emailAuthToken }, @@ -472,7 +481,6 @@ export function SignUpPage() {
아이디
@@ -668,7 +677,6 @@ export function SignUpPage() {
{ @@ -731,7 +739,6 @@ export function SignUpPage() {
{ @@ -791,7 +798,6 @@ export function SignUpPage() {
setEmailLocal(e.target.value)} @@ -836,7 +841,6 @@ export function SignUpPage() {
) : ( setEmailLocal(e.target.value)} @@ -873,7 +877,6 @@ export function SignUpPage() {
{ 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 {