From 07717df0b03bf4d5699b42b3c8860fb1299c6902 Mon Sep 17 00:00:00 2001 From: yeyounging <133792082+yeyounging@users.noreply.github.com> Date: Wed, 27 Nov 2024 01:25:05 +0900 Subject: [PATCH 1/2] =?UTF-8?q?refactor:=20=EB=B9=84=EC=A7=80=EB=8B=88?= =?UTF-8?q?=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/start/components/Step1.tsx | 69 +++---------------- src/app/start/components/Step3.tsx | 27 +------- src/app/start/hooks/index.tsx | 2 + src/app/start/hooks/useNicknameValidator.ts | 74 +++++++++++++++++++++ src/app/start/hooks/useProfileSelector.ts | 44 ++++++++++++ 5 files changed, 134 insertions(+), 82 deletions(-) create mode 100644 src/app/start/hooks/index.tsx create mode 100644 src/app/start/hooks/useNicknameValidator.ts create mode 100644 src/app/start/hooks/useProfileSelector.ts diff --git a/src/app/start/components/Step1.tsx b/src/app/start/components/Step1.tsx index 9088e01..6359256 100644 --- a/src/app/start/components/Step1.tsx +++ b/src/app/start/components/Step1.tsx @@ -2,8 +2,7 @@ import { Input } from '@/components/common' import useUserInfo from '@/store/useUserInfo' -import { useEffect, useRef, useState } from 'react' -import { getNicknamePossible } from '../api/api' +import { useNicknameValidator } from '../hooks' interface Step1Props { setError: (error: boolean) => void @@ -11,64 +10,18 @@ interface Step1Props { export default function Step1({ setError }: Step1Props) { const { userInfo, setUserInfo } = useUserInfo() - const [username, setUsername] = useState(userInfo.nickname) - const [errorMessage, setErrorMessage] = useState() - const inputTimeout = useRef(null) - - const validateName = (name: string) => { - const regex = /^[가-힣a-zA-Z0-9]{0,6}$/ - return regex.test(name) - } - - const checkNickname = async (nickname: string) => { - try { - const response = await getNicknamePossible(nickname) - - if (response.data === false) { - setErrorMessage('이미 사용 중인 닉네임입니다.') - setError(true) - } else { - setErrorMessage('') - setError(false) - } - } catch (error) { - setErrorMessage('닉네임 확인 중 오류가 발생했습니다.') - setError(true) - } - } - - const handleChangeName = async (e: React.ChangeEvent) => { + const { nickname, errorMessage, handleNicknameChange } = useNicknameValidator( + { + initialNickname: userInfo.nickname, + setError, + }, + ) + const handleChangeName = (e: React.ChangeEvent) => { const newName = e.target.value - setUsername(newName) - - if (!validateName(newName)) { - setErrorMessage('닉네임은 한글/영문/숫자를 포함한 6자 이내만 가능해요.') - } else { - setError(false) - setErrorMessage('') - setUserInfo({ ...userInfo, nickname: newName }) - - if (inputTimeout.current) { - clearTimeout(inputTimeout.current) - } - - inputTimeout.current = setTimeout(() => { - if (newName.trim() !== '') { - checkNickname(newName) - } - }, 500) - } + handleNicknameChange(newName) + setUserInfo({ ...userInfo, nickname: newName }) } - // 컴포넌트 언마운트 시 타이머 정리 - useEffect(() => { - return () => { - if (inputTimeout.current) { - clearTimeout(inputTimeout.current) - } - } - }, []) - return (

@@ -80,7 +33,7 @@ export default function Step1({ setError }: Step1Props) { { - const match = profileImage.match(/profile(\d+)\.svg$/) - return match ? match[1] : '1' - } - - const [selectedProfileID, setSelectedProfileID] = useState( - extractProfileID(userInfo.profileImage), - ) - - const handleProfileSelect = (profile: string) => { - setSelectedProfileID(profile) - setUserInfo({ - ...userInfo, - profileImage: `https://kr.object.ncloudstorage.com/cnergy-bucket/front_image/profile/profile${profile}.svg`, - }) - } + const { profiles, selectedProfileID, handleProfileSelect } = + useProfileSelector() return (
diff --git a/src/app/start/hooks/index.tsx b/src/app/start/hooks/index.tsx new file mode 100644 index 0000000..bfd543b --- /dev/null +++ b/src/app/start/hooks/index.tsx @@ -0,0 +1,2 @@ +export { useNicknameValidator } from './useNicknameValidator' +export { useProfileSelector } from './useProfileSelector' diff --git a/src/app/start/hooks/useNicknameValidator.ts b/src/app/start/hooks/useNicknameValidator.ts new file mode 100644 index 0000000..ab936d3 --- /dev/null +++ b/src/app/start/hooks/useNicknameValidator.ts @@ -0,0 +1,74 @@ +import { useState, useRef, useEffect } from 'react' +import { getNicknamePossible } from '../api/api' + +interface UseNicknameValidatorProps { + initialNickname: string + setError: (error: boolean) => void +} + +export const useNicknameValidator = ({ + initialNickname, + setError, +}: UseNicknameValidatorProps) => { + const [nickname, setNickname] = useState(initialNickname) + const [errorMessage, setErrorMessage] = useState() + const inputTimeout = useRef(null) + + const validateName = (name: string) => { + const regex = /^[가-힣a-zA-Z0-9]{0,6}$/ + return regex.test(name) + } + + const checkNickname = async (name: string) => { + try { + const response = await getNicknamePossible(name) + if (response.data === false) { + setErrorMessage('이미 사용 중인 닉네임입니다.') + setError(true) + } else { + setErrorMessage('') + setError(false) + } + } catch (error) { + setErrorMessage('닉네임 확인 중 오류가 발생했습니다.') + setError(true) + } + } + + const handleNicknameChange = (newNickname: string) => { + setNickname(newNickname) + + if (!validateName(newNickname)) { + setErrorMessage('닉네임은 한글/영문/숫자를 포함한 6자 이내만 가능해요.') + setError(true) + } else { + setErrorMessage('') + setError(false) + + if (inputTimeout.current) { + clearTimeout(inputTimeout.current) + } + + inputTimeout.current = setTimeout(() => { + if (newNickname.trim() !== '') { + checkNickname(newNickname) + } + }, 500) + } + } + + // 타이머 정리 + useEffect(() => { + return () => { + if (inputTimeout.current) { + clearTimeout(inputTimeout.current) + } + } + }, []) + + return { + nickname, + errorMessage, + handleNicknameChange, + } +} diff --git a/src/app/start/hooks/useProfileSelector.ts b/src/app/start/hooks/useProfileSelector.ts new file mode 100644 index 0000000..44b9586 --- /dev/null +++ b/src/app/start/hooks/useProfileSelector.ts @@ -0,0 +1,44 @@ +import { useCallback, useState } from 'react' +import useUserInfo from '@/store/useUserInfo' + +interface UseProfileSelectorReturn { + profiles: string[] + selectedProfileID: string + handleProfileSelect: (profile: string) => void + profileUrl: string +} + +export const useProfileSelector = (): UseProfileSelectorReturn => { + const PROFILE_BASE_URL = + 'https://kr.object.ncloudstorage.com/cnergy-bucket/front_image/profile' + + const { userInfo, setUserInfo } = useUserInfo() + const profiles = ['1', '2', '3', '4', '5', '6'] + + const extractProfileID = (profileImage: string) => { + const match = profileImage.match(/profile(\d+)\.svg$/) + return match ? match[1] : '1' + } + + const [selectedProfileID, setSelectedProfileID] = useState( + extractProfileID(userInfo.profileImage), + ) + + const handleProfileSelect = useCallback( + (profile: string) => { + setSelectedProfileID(profile) + setUserInfo({ + ...userInfo, + profileImage: `${PROFILE_BASE_URL}/profile${profile}.svg`, + }) + }, + [userInfo, setUserInfo], + ) + + return { + profiles, + selectedProfileID, + profileUrl: `${PROFILE_BASE_URL}/profile${selectedProfileID}.svg`, + handleProfileSelect, + } +} From 97922ab416b09f19b6572bb29eb31997cc108e78 Mon Sep 17 00:00:00 2001 From: yeyounging <133792082+yeyounging@users.noreply.github.com> Date: Wed, 27 Nov 2024 01:25:14 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/mypage/api/api.ts | 21 +++++ src/app/mypage/api/queries.ts | 24 ++++++ src/app/mypage/api/types.ts | 9 +++ src/app/mypage/components/fetcher.tsx | 17 ++++ src/app/mypage/edit/page.tsx | 107 +++++++++++++++++++++++++ src/app/mypage/layout.tsx | 20 +++++ src/app/mypage/page.tsx | 103 ++++++++++++++++++++++++ src/components/common/Switch/index.tsx | 47 +++++++++++ src/components/common/index.tsx | 1 + tailwind.config.ts | 1 + 10 files changed, 350 insertions(+) create mode 100644 src/app/mypage/api/api.ts create mode 100644 src/app/mypage/api/queries.ts create mode 100644 src/app/mypage/api/types.ts create mode 100644 src/app/mypage/components/fetcher.tsx create mode 100644 src/app/mypage/edit/page.tsx create mode 100644 src/app/mypage/layout.tsx create mode 100644 src/app/mypage/page.tsx create mode 100644 src/components/common/Switch/index.tsx diff --git a/src/app/mypage/api/api.ts b/src/app/mypage/api/api.ts new file mode 100644 index 0000000..1922e2c --- /dev/null +++ b/src/app/mypage/api/api.ts @@ -0,0 +1,21 @@ +import { http } from '@/api' +import { MyPageResponse, PatchMyPageRequest } from './types' + +export const getMypage = () => { + return http.get({ + url: '/profile', + }) +} + +export const patchAlarm = () => { + return http.patch({ + url: '/members/email-notification', + }) +} + +export const patchMyPage = (data: PatchMyPageRequest) => { + return http.patch({ + url: '/member/profile', + data, + }) +} diff --git a/src/app/mypage/api/queries.ts b/src/app/mypage/api/queries.ts new file mode 100644 index 0000000..288cdc9 --- /dev/null +++ b/src/app/mypage/api/queries.ts @@ -0,0 +1,24 @@ +import { useMutation, useSuspenseQuery } from '@tanstack/react-query' +import { getMypage, patchAlarm, patchMyPage } from './api' +import { PatchMyPageRequest } from './types' + +export const useGetMyPage = () => + useSuspenseQuery({ + queryKey: ['mypage'], + queryFn: () => getMypage(), + select: (data) => data.data, + }) + +export const usePatchAlarm = () => { + return useMutation({ + mutationKey: ['alarm'], + mutationFn: () => patchAlarm(), + }) +} + +export const usePatchMyPage = () => { + return useMutation({ + mutationKey: ['mypage'], + mutationFn: (data: PatchMyPageRequest) => patchMyPage(data), + }) +} diff --git a/src/app/mypage/api/types.ts b/src/app/mypage/api/types.ts new file mode 100644 index 0000000..9cc8b21 --- /dev/null +++ b/src/app/mypage/api/types.ts @@ -0,0 +1,9 @@ +export interface MyPageResponse { + email: string + isEmailNotificationEnabled: boolean +} + +export interface PatchMyPageRequest { + nickname: string + profileImage: string +} diff --git a/src/app/mypage/components/fetcher.tsx b/src/app/mypage/components/fetcher.tsx new file mode 100644 index 0000000..3c737e5 --- /dev/null +++ b/src/app/mypage/components/fetcher.tsx @@ -0,0 +1,17 @@ +'use client' + +import { generateContext } from '@/react-utils' +import { StrictPropsWithChildren } from '@/types' +import { MyPageResponse } from '../api/types' +import { useGetMyPage } from '../api/queries' + +export const [MyPageProvider, useMyPageContext] = + generateContext({ + name: 'mypage-context', + }) + +export function MyPageFetcher({ children }: StrictPropsWithChildren) { + const { data } = useGetMyPage() + + return {children} +} diff --git a/src/app/mypage/edit/page.tsx b/src/app/mypage/edit/page.tsx new file mode 100644 index 0000000..6845672 --- /dev/null +++ b/src/app/mypage/edit/page.tsx @@ -0,0 +1,107 @@ +'use client' + +import { useNicknameValidator, useProfileSelector } from '@/app/start/hooks' +import { Button, HeaderWithBack, Input, Left } from '@/components' +import useUserInfo from '@/store/useUserInfo' +import { cn } from '@/util' +import Image from 'next/image' +import { useRouter } from 'next/navigation' +import { ChangeEvent, useState } from 'react' +import { usePatchMyPage } from '../api/queries' + +export default function MyPageEdit() { + const { push } = useRouter() + const { userInfo, setUserInfo } = useUserInfo() + const { mutate } = usePatchMyPage() + const [error, setError] = useState(true) + const { nickname, errorMessage, handleNicknameChange } = useNicknameValidator( + { + initialNickname: userInfo.nickname, + setError, + }, + ) + const { profiles, profileUrl, selectedProfileID, handleProfileSelect } = + useProfileSelector() + + const handleSaveClick = () => { + mutate( + { + nickname, + profileImage: selectedProfileID, + }, + { + onSuccess: () => { + setUserInfo({ + ...userInfo, + nickname, + profileImage: profileUrl, + }) + push('/mypage') + }, + }, + ) + } + + return ( + push('/mypage')}> +
+

+ 닉네임 수정하기 +

+ ) => + handleNicknameChange(e.target.value) + } + /> + +

+ 프로필 이미지 수정하기 +

+ +
+ {profiles.map((id) => ( + + ))} +
+
+
+ +
+
+ ) +} diff --git a/src/app/mypage/layout.tsx b/src/app/mypage/layout.tsx new file mode 100644 index 0000000..160cc0a --- /dev/null +++ b/src/app/mypage/layout.tsx @@ -0,0 +1,20 @@ +import { AsyncBoundaryWithQuery } from '@/react-utils' +import { StrictPropsWithChildren } from '@/types' +import type { Metadata } from 'next' +import { MyPageFetcher } from './components/fetcher' + +export const metadata: Metadata = { + title: '나의 시간조각을 모아, 조각조각', + description: '자투리 시간 앱', +} + +export default function MyPageLayout({ children }: StrictPropsWithChildren) { + return ( + Loading...} + errorFallback={<>error..} + > + {children} + + ) +} diff --git a/src/app/mypage/page.tsx b/src/app/mypage/page.tsx new file mode 100644 index 0000000..cdee6d4 --- /dev/null +++ b/src/app/mypage/page.tsx @@ -0,0 +1,103 @@ +'use client' + +import { Button, HeaderWithBack, IconRight, Pencil, Switch } from '@/components' +import useUserInfo from '@/store/useUserInfo' +import { useRouter } from 'next/navigation' +import Image from 'next/image' +import { useState } from 'react' +import { useMyPageContext } from './components/fetcher' +import { usePatchAlarm } from './api/queries' + +export default function MyPage() { + const { nickname, profileImage } = useUserInfo().userInfo + const { push } = useRouter() + + const { isEmailNotificationEnabled, email } = useMyPageContext() + const { mutate } = usePatchAlarm() + + const [isEmailAlert, setIsEmailAlert] = useState(isEmailNotificationEnabled) + + const handleAlarmSwitch = () => { + setIsEmailAlert(!isEmailAlert) + mutate(undefined, { + onError: () => { + setIsEmailAlert(isEmailAlert) + alert('알림 설정을 변경하는 데 실패했습니다. 다시 시도해 주세요.') + }, + }) + } + + return ( + push('/home')} title="마이페이지"> +
+
+
+
+
+ + {nickname} + + 님의 프로필 +
+ +
+ {email} +
+ +
+ 프로필 이미지 +
+
+
+ +
+

+ 환경 설정 +

+
+
+ + 이메일 알림 + + handleAlarmSwitch()} + /> +
+ + + + +
+
+
+ + ) +} diff --git a/src/components/common/Switch/index.tsx b/src/components/common/Switch/index.tsx new file mode 100644 index 0000000..02c453e --- /dev/null +++ b/src/components/common/Switch/index.tsx @@ -0,0 +1,47 @@ +'use client' + +import { useEffect, useState } from 'react' +import { cn } from '@/util' + +interface SwitchProps { + isOn: boolean + onSwitch: (isOn: boolean) => void +} + +export default function Switch({ isOn, onSwitch }: SwitchProps) { + const [switchState, setSwitchState] = useState(isOn) + + const handleClick = () => { + setSwitchState(!switchState) + onSwitch(!switchState) + } + + useEffect(() => { + setSwitchState(isOn) + }, [isOn]) + + return ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + handleClick() + } + }} + > +
+
+ ) +} diff --git a/src/components/common/index.tsx b/src/components/common/index.tsx index 5fbf0dd..7b0a3f8 100644 --- a/src/components/common/index.tsx +++ b/src/components/common/index.tsx @@ -6,3 +6,4 @@ export { default as Badge } from './Badge' export { default as Div } from './Div' export { default as TabList } from './TabList' export { default as CheckboxWithLabel } from './CheckBox' +export { default as Switch } from './Switch' diff --git a/tailwind.config.ts b/tailwind.config.ts index 2829dd3..f4766c5 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -36,6 +36,7 @@ const config: Config = { 5: ' #FFF6F5', 10: '#FFEEEC', 20: '#FFDCD7', + 30: '#FFCBC4', 50: '#FFA89C', 100: '#FF4F38', },