diff --git a/src/pages/edit-profile/components/selection-group.tsx b/src/pages/edit-profile/components/selection-group.tsx new file mode 100644 index 00000000..9e8d2b42 --- /dev/null +++ b/src/pages/edit-profile/components/selection-group.tsx @@ -0,0 +1,49 @@ +import Button from '@components/button/button/button'; +import { cn } from '@libs/cn'; + +interface SelectionGroupProps { + title: string; + options: { id: number; label: string }[] | string[]; + selectedValue: string; + onSelect: (value: string) => void; + disabled?: boolean; +} + +const SelectionGroup = ({ + title, + options, + selectedValue, + onSelect, + disabled, +}: SelectionGroupProps) => { + return ( +
+

{title}

+
+ {options.map((option) => { + const key = typeof option === 'string' ? option : option.id; + const label = typeof option === 'string' ? option : option.label; + const isSelected = selectedValue === label; + + return ( +
+
+ ); +}; + +export default SelectionGroup; diff --git a/src/pages/edit-profile/constants/edit-profile.ts b/src/pages/edit-profile/constants/edit-profile.ts new file mode 100644 index 00000000..2b62bf94 --- /dev/null +++ b/src/pages/edit-profile/constants/edit-profile.ts @@ -0,0 +1 @@ +export const PROFILE_SYNC_MATE = ['같은 팀 메이트', '상관없어요']; diff --git a/src/pages/edit-profile/edit-profile.tsx b/src/pages/edit-profile/edit-profile.tsx new file mode 100644 index 00000000..eaa1d87c --- /dev/null +++ b/src/pages/edit-profile/edit-profile.tsx @@ -0,0 +1,153 @@ +import Button from '@components/button/button/button'; +import Divider from '@components/divider/divider'; +import Input from '@components/input/input'; +import { cn } from '@libs/cn'; +import SelectionGroup from '@pages/edit-profile/components/selection-group'; +import { PROFILE_SYNC_MATE } from '@pages/edit-profile/constants/edit-profile'; +import { mockEditData } from '@pages/edit-profile/mocks/mockEditData'; +import { + GENDER, + NO_TEAM_OPTION, + TEAMS, + VIEWING_STYLE, +} from '@pages/onboarding/constants/onboarding'; +import { INFORMATION_RULE_MESSAGE, NICKNAME_RULE_MESSAGE } from '@pages/sign-up/constants/NOTICE'; +import { INFORMATION_PLACEHOLDER, NICKNAME_PLACEHOLDER } from '@pages/sign-up/constants/validation'; +import { useMemo, useRef, useState } from 'react'; + +const EditProfile = () => { + const [team, setTeam] = useState(mockEditData.team); + const [gender, setGender] = useState(mockEditData.genderPreference); + const [mateTeam, setMateTeam] = useState(mockEditData.teamAllowed || '상관없어요'); + const [viewStyle, setViewStyle] = useState(mockEditData.style); + const [isSubmit, setIsSubmit] = useState(false); + + const initialValue = useRef({ + team: mockEditData.team, + gender: mockEditData.genderPreference, + mateTeam: mockEditData.teamAllowed, + viewStyle: mockEditData.style, + }); + + const isDirty = useMemo(() => { + const init = initialValue.current; + + return ( + team !== init.team || + gender !== init.gender || + mateTeam !== init.mateTeam || + viewStyle !== init.viewStyle + ); + }, [team, gender, mateTeam, viewStyle]); + + const isSubmitDisabled = !isDirty || isSubmit; + + const handleSaveClick = () => { + if (!isDirty) return; + + setIsSubmit(true); + + // TODO: 실제 API 호출 + }; + + return ( +
+

프로필 수정

+
+ +
+
+ + +
+
+
+ +
+ +
+ +
+

매칭 조건 수정

+

+ 수정한 조건을 기반으로 새로운 메이트를 추천해드려요! +

+ +
+
+

응원팀

+
+ {TEAMS.map((option) => { + const selected = team === option; + return ( +
+
+ + + + + + +
+
+ +
+ ); +}; + +export default EditProfile; diff --git a/src/pages/edit-profile/mocks/mockEditData.ts b/src/pages/edit-profile/mocks/mockEditData.ts new file mode 100644 index 00000000..0cbdfcb5 --- /dev/null +++ b/src/pages/edit-profile/mocks/mockEditData.ts @@ -0,0 +1,6 @@ +export const mockEditData = { + team: '응원하는 팀이 없어요.', + teamAllowed: '상관없어요', + style: '직관먹방러', + genderPreference: '여성', +}; diff --git a/src/pages/profile/constants/link.ts b/src/pages/profile/constants/link.ts new file mode 100644 index 00000000..e09b4f05 --- /dev/null +++ b/src/pages/profile/constants/link.ts @@ -0,0 +1,3 @@ +export const REQUEST_LINK = 'https://open.kakao.com/o/su2KJENh'; + +export const FEEDBACK_LINK = 'https://smore.im/form/jlMGj3m0v3'; diff --git a/src/pages/profile/edit-profile/edit-profile.tsx b/src/pages/profile/edit-profile/edit-profile.tsx deleted file mode 100644 index b87ba431..00000000 --- a/src/pages/profile/edit-profile/edit-profile.tsx +++ /dev/null @@ -1,5 +0,0 @@ -const EditProfile = () => { - return
내정보수정페이지
; -}; - -export default EditProfile; diff --git a/src/pages/profile/profile.tsx b/src/pages/profile/profile.tsx index 9fd80483..ef7b0e83 100644 --- a/src/pages/profile/profile.tsx +++ b/src/pages/profile/profile.tsx @@ -1,12 +1,20 @@ +import { userMutations } from '@apis/user/user-mutations'; import { userQueries } from '@apis/user/user-queries'; import Button from '@components/button/button/button'; import Card from '@components/card/match-card/card'; import type { ChipColor } from '@components/chip/chip-list'; +import Divider from '@components/divider/divider'; import Footer from '@components/footer/footer'; -import { useQuery } from '@tanstack/react-query'; +import { FEEDBACK_LINK, REQUEST_LINK } from '@pages/profile/constants/link'; +import { ROUTES } from '@routes/routes-config'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; const Profile = () => { + const navigate = useNavigate(); + const { data } = useQuery(userQueries.USER_INFO()); + const { mutate: logout } = useMutation(userMutations.LOGOUT()); if (!data) return null; @@ -14,6 +22,7 @@ const Profile = () => {
{ introduction={data.introduction ?? ''} chips={[(data.team ?? '') as ChipColor, (data.style ?? '') as ChipColor]} /> -
+ +
+ + 문의하기 + + + 의견 보내기 + + + +
); diff --git a/src/pages/sign-up/components/agreement-step.tsx b/src/pages/sign-up/components/agreement-step.tsx new file mode 100644 index 00000000..5212ce3b --- /dev/null +++ b/src/pages/sign-up/components/agreement-step.tsx @@ -0,0 +1,71 @@ +import Button from '@components/button/button/button'; +import Icon from '@components/icon/icon'; +import CheckboxRow from '@pages/sign-up/components/checkbox-row'; +import { useState } from 'react'; + +interface AgreementStepProps { + next: () => void; +} + +const AgreementStep = ({ next }: AgreementStepProps) => { + const [terms, setTerms] = useState(false); + const [privacy, setPrivacy] = useState(false); + + const isAllChecked = terms && privacy; + + const handleCheckAll = () => { + const next = !isAllChecked; + setTerms(next); + setPrivacy(next); + }; + + const handleCheckTerms = () => { + const next = !terms; + setTerms(next); + }; + + const handleCheckPrivacy = () => { + const next = !privacy; + setPrivacy(next); + }; + + return ( +
+
+

서비스 이용약관

+

+ 서비스 가입을 위해
아래 항목에 동의해주세요. +

+
+ +
+
+ + } + /> + + } + /> +
+
+
+
+
+ ); +}; + +export default AgreementStep; diff --git a/src/pages/sign-up/components/checkbox-row.tsx b/src/pages/sign-up/components/checkbox-row.tsx new file mode 100644 index 00000000..3de727d2 --- /dev/null +++ b/src/pages/sign-up/components/checkbox-row.tsx @@ -0,0 +1,35 @@ +import Icon from '@components/icon/icon'; +import { cn } from '@libs/cn'; +import type { ReactNode } from 'react'; + +interface CheckboxProps { + label: ReactNode; + checked: boolean; + onClick: () => void; + svg?: ReactNode; + divider?: boolean; + className?: string; +} + +const CheckboxRow = ({ label, checked, onClick, svg, divider, className }: CheckboxProps) => { + return ( + + {label} + + {svg} + + ); +}; + +export default CheckboxRow; diff --git a/src/pages/sign-up/components/signup-step.tsx b/src/pages/sign-up/components/signup-step.tsx index 483f5d87..3f9fb96d 100644 --- a/src/pages/sign-up/components/signup-step.tsx +++ b/src/pages/sign-up/components/signup-step.tsx @@ -7,11 +7,17 @@ import queryClient from '@libs/query-client'; import { BIRTHYEAR_RULE_MESSAGE, BIRTHYEAR_SUCCESS_MESSAGE, + INFORMATION_RULE_MESSAGE, NICKNAME_RULE_MESSAGE, NICKNAME_SUCCESS_MESSAGE, NICKNAME_TITLE, } from '@pages/sign-up/constants/NOTICE'; -import { BIRTH_PLACEHOLDER, NICKNAME_PLACEHOLDER } from '@pages/sign-up/constants/validation'; +import { + BIRTH_PLACEHOLDER, + INFORMATION_MAX_LENGTH, + INFORMATION_PLACEHOLDER, + NICKNAME_PLACEHOLDER, +} from '@pages/sign-up/constants/validation'; import { type NicknameFormValues, NicknameSchema } from '@pages/sign-up/schema/validation-schema'; import { ROUTES } from '@routes/routes-config'; import { useMutation } from '@tanstack/react-query'; @@ -28,7 +34,7 @@ const SignupStep = () => { } = useForm({ mode: 'onChange', resolver: zodResolver(NicknameSchema), - defaultValues: { nickname: '', gender: undefined, birthYear: '' }, + defaultValues: { nickname: '', gender: undefined, birthYear: '', information: '' }, }); const navigate = useNavigate(); @@ -36,13 +42,17 @@ const SignupStep = () => { const nicknameValue = watch('nickname'); const birthYearValue = watch('birthYear'); const genderValue = watch('gender'); + const informationValue = watch('information'); const isNicknameValid = !errors.nickname && nicknameValue.length > 0; const isBirthYearValid = !errors.birthYear && birthYearValue.length > 0; + const isInformationValid = !errors.information && informationValue.length > 0; const nicknameMutation = useMutation(userMutations.NICKNAME()); const userInfoMutation = useMutation(userMutations.USER_INFO()); + const informationLength = informationValue.length ?? 0; + const onSubmit = (data: NicknameFormValues) => { nicknameMutation.mutate( { nickname: data.nickname }, @@ -79,6 +89,12 @@ const SignupStep = () => { ...birthYearInputProps } = register('birthYear'); + const { + onBlur: onInformationBlur, + ref: informationRef, + ...informationInputProps + } = register('information'); + const handleGenderClick = (gender: '여성' | '남성') => { setValue('gender', gender, { shouldValidate: true, shouldDirty: true }); }; @@ -86,7 +102,7 @@ const SignupStep = () => { return (

{NICKNAME_TITLE}

@@ -102,9 +118,24 @@ const SignupStep = () => { ref={nicknameRef} {...nicknameInputProps} /> + ; diff --git a/src/pages/sign-up/sign-up.tsx b/src/pages/sign-up/sign-up.tsx index 0482d870..e9db80ed 100644 --- a/src/pages/sign-up/sign-up.tsx +++ b/src/pages/sign-up/sign-up.tsx @@ -1,9 +1,24 @@ +import { useFunnel } from '@hooks/use-funnel'; +import AgreementStep from '@pages/sign-up/components/agreement-step'; import SignupStep from '@pages/sign-up/components/signup-step'; +import { SIGNUP_STEPS } from '@pages/sign-up/constants/validation'; +import { ROUTES } from '@routes/routes-config'; const SignUp = () => { + const { Funnel, Step, goNext } = useFunnel(SIGNUP_STEPS, ROUTES.HOME); + return ( -
- +
+
+ + + + + + + + +
); }; diff --git a/src/shared/apis/user/user-mutations.ts b/src/shared/apis/user/user-mutations.ts index 349866e6..c282ea20 100644 --- a/src/shared/apis/user/user-mutations.ts +++ b/src/shared/apis/user/user-mutations.ts @@ -1,6 +1,7 @@ import { post } from '@apis/base/http'; import { END_POINT } from '@constants/api'; import { USER_KEY } from '@constants/query-key'; +import queryClient from '@libs/query-client'; import { mutationOptions } from '@tanstack/react-query'; import type { responseTypes } from '@/shared/types/base-types'; import type { postUserInfoNicknameRequest, postUserInfoRequest } from '@/shared/types/user-types'; @@ -17,4 +18,18 @@ export const userMutations = { mutationKey: USER_KEY.INFO(), mutationFn: ({ gender, birthYear }) => post(END_POINT.USER_INFO, { gender, birthYear }), }), + + LOGOUT: () => + mutationOptions({ + mutationKey: USER_KEY.LOGOUT(), + mutationFn: () => post(END_POINT.POST_AUTH_LOGOUT), + onSuccess: async () => { + await queryClient.cancelQueries({ queryKey: USER_KEY.ALL }); + + queryClient.removeQueries({ queryKey: USER_KEY.ALL }); + }, + onError: (err) => { + console.error('로그아웃 실패', err); + }, + }), }; diff --git a/src/shared/assets/svgs/arrow-right-18.svg b/src/shared/assets/svgs/arrow-right-18.svg new file mode 100644 index 00000000..b693a897 --- /dev/null +++ b/src/shared/assets/svgs/arrow-right-18.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/shared/assets/svgs/check-filled.svg b/src/shared/assets/svgs/check-filled.svg index 0f508292..a1400074 100644 --- a/src/shared/assets/svgs/check-filled.svg +++ b/src/shared/assets/svgs/check-filled.svg @@ -4,7 +4,7 @@ - + diff --git a/src/shared/components/button/button/styles/button-variants.ts b/src/shared/components/button/button/styles/button-variants.ts index b9e43209..c93c44b5 100644 --- a/src/shared/components/button/button/styles/button-variants.ts +++ b/src/shared/components/button/button/styles/button-variants.ts @@ -11,6 +11,7 @@ export const buttonVariants = cva( white: 'bg-white text-gray-700', skyblueBorder: 'bg-main-200 text-main-900 outline outline-main-900', gray2: 'bg-background text-gray-700', + disabled: 'bg-gray-100 text-gray-400', }, size: { M: 'w-full px-[0.8rem] py-[1.2rem]', diff --git a/src/shared/components/card/match-card/styles/card-variants.ts b/src/shared/components/card/match-card/styles/card-variants.ts index b38e760d..4b116528 100644 --- a/src/shared/components/card/match-card/styles/card-variants.ts +++ b/src/shared/components/card/match-card/styles/card-variants.ts @@ -9,7 +9,7 @@ export const cardVariants = cva('relative w-full rounded-[12px] bg-white', { user: 'p-[2rem] shadow-1', }, color: { - active: 'bg-main-200 outline outline-[1px] outline-main-600', + active: 'bg-main-200 outline outline-main-600', inactive: 'bg-white', }, }, diff --git a/src/shared/components/divider/divider.tsx b/src/shared/components/divider/divider.tsx new file mode 100644 index 00000000..09f26fb3 --- /dev/null +++ b/src/shared/components/divider/divider.tsx @@ -0,0 +1,32 @@ +import clsx from 'clsx'; + +interface DividerProps { + direction?: 'horizontal' | 'vertical'; + color?: string; // tailwind 클래스 ex: bg-gray-200 + thickness?: number; // rem 단위 숫자 ex: 0.1 -> 0.1rem + margin?: string; // tailwind 마진 클래스 + className?: string; +} + +const Divider = ({ + direction = 'horizontal', + color = 'bg-gray-200', + thickness = 0.1, + margin, + className, +}: DividerProps) => { + const isHorizontal = direction === 'horizontal'; + + return ( +
+ ); +}; + +export default Divider; diff --git a/src/shared/components/footer/constants/legal.ts b/src/shared/components/footer/constants/legal.ts index 85306767..fd764410 100644 --- a/src/shared/components/footer/constants/legal.ts +++ b/src/shared/components/footer/constants/legal.ts @@ -1,6 +1 @@ -export const MATCHING_PLATFORM_NOTICE = `※ 메잇볼은 고객 간의 야구 직관 메이트 매칭을 중개하는 플랫폼으로, -직접적인 만남이나 거래에 개입하지 않으며, 이에 대한 책임을 지지 -않습니다. -이용자 간 약속 및 행동에 대한 주의가 필요합니다.`; - export const COPYRIGHT_NOTICE = '© 2025 MateBall. All rights reserved.'; diff --git a/src/shared/components/footer/footer.tsx b/src/shared/components/footer/footer.tsx index 472d45ba..70cb5d04 100644 --- a/src/shared/components/footer/footer.tsx +++ b/src/shared/components/footer/footer.tsx @@ -1,4 +1,4 @@ -import { COPYRIGHT_NOTICE, MATCHING_PLATFORM_NOTICE } from '@components/footer/constants/legal'; +import { COPYRIGHT_NOTICE } from '@components/footer/constants/legal'; import Icon from '@components/icon/icon'; import { EXTERNAL_LINKS } from '@constants/links'; import { ROUTES } from '@routes/routes-config'; @@ -12,7 +12,7 @@ const Footer = () => { return (
@@ -24,7 +24,6 @@ const Footer = () => {
-

{MATCHING_PLATFORM_NOTICE}

{ const isFail = urlParams.get('type') === 'fail'; const isSignUp = pathname.includes(ROUTES.SIGNUP); const isHome = pathname === ROUTES.HOME; - const isMatch = location.pathname === ROUTES.MATCH; + const isMatch = pathname === ROUTES.MATCH; const isChatRoom = pathname === ROUTES.CHAT_ROOM; + const isEditProfile = pathname === ROUTES.PROFILE_EDIT; return (
diff --git a/src/shared/components/header/utils/get-header.tsx b/src/shared/components/header/utils/get-header.tsx index 28337ab0..af6bb8cb 100644 --- a/src/shared/components/header/utils/get-header.tsx +++ b/src/shared/components/header/utils/get-header.tsx @@ -60,7 +60,7 @@ export const getHeaderContent = ( } if (pathname === ROUTES.PROFILE) { - return

내 정보

; + return

마이페이지

; } if (pathname === ROUTES.CHAT) { diff --git a/src/shared/components/input/input.tsx b/src/shared/components/input/input.tsx index 0fe866ce..d62c0f05 100644 --- a/src/shared/components/input/input.tsx +++ b/src/shared/components/input/input.tsx @@ -1,17 +1,24 @@ import Icon from '@components/icon/icon'; import { iconColorMap, inputClassMap } from '@components/input/styles/input-variants'; import { cn } from '@libs/cn'; +import type React from 'react'; import type { InputHTMLAttributes } from 'react'; import { useState } from 'react'; import { defineInputState } from '@/shared/utils/define-input-state'; -interface InputProps extends InputHTMLAttributes { +interface InputProps extends Omit, 'onBlur'> { label?: string; isError?: boolean; isValid?: boolean; + hasLength?: boolean; + maxLength?: number; defaultMessage?: string; validationMessage?: string; ref?: React.Ref; + className?: string; + multiline?: boolean; + length?: number; + onBlur?: (e: React.FocusEvent | React.FocusEvent) => void; } const Input = ({ @@ -21,8 +28,13 @@ const Input = ({ label, validationMessage, defaultMessage, + length, + maxLength = 50, onBlur, ref, + className, + hasLength = false, + multiline = false, ...props }: InputProps) => { const [isFocused, setIsFocused] = useState(false); @@ -41,31 +53,59 @@ const Input = ({ )}
- setIsFocused(true)} - onBlur={(e) => { - setIsFocused(false); - onBlur?.(e); - }} - {...props} - /> + {multiline ? ( +