Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/pages/edit-profile/constants/edit-profile.ts
Original file line number Diff line number Diff line change
@@ -1 +1,16 @@
export const PROFILE_SYNC_MATE = ['같은 팀 메이트', '상관없어요'];

export const PROFILE_VIEWING_STYLE = [
{
id: 1,
label: '열정 응원러',
},
{
id: 2,
label: '경기 집중러',
},
{
id: 3,
label: '직관 먹방러',
},
];
199 changes: 136 additions & 63 deletions src/pages/edit-profile/edit-profile.tsx
Original file line number Diff line number Diff line change
@@ -1,89 +1,165 @@
import { userMutations } from '@apis/user/user-mutations';
import { userQueries } from '@apis/user/user-queries';
import Button from '@components/button/button/button';
import Divider from '@components/divider/divider';
import Input from '@components/input/input';
import { zodResolver } from '@hookform/resolvers/zod';
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';
PROFILE_SYNC_MATE,
PROFILE_VIEWING_STYLE,
} from '@pages/edit-profile/constants/edit-profile';
import {
EditProfileSchema,
type EditProfileValues,
} from '@pages/edit-profile/schema/EditProfileSchema';
import { GENDER, NO_TEAM_OPTION, TEAMS } from '@pages/onboarding/constants/onboarding';
import { INTRODUCTION_RULE_MESSAGE, NICKNAME_RULE_MESSAGE } from '@pages/sign-up/constants/NOTICE';
import {
INTRODUCTION_PLACEHOLDER,
NICKNAME_PLACEHOLDER,
} from '@pages/sign-up/constants/validation';
import { useMemo, useRef, useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { Controller, useForm } from 'react-hook-form';

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 { data } = useQuery(userQueries.MATCH_CONDITION());

const [team, setTeam] = useState<string | undefined>(undefined);
const [gender, setGender] = useState<string | undefined>(undefined);
const [mateTeam, setMateTeam] = useState<string | undefined>(undefined);
const [viewStyle, setViewStyle] = useState<string | undefined>(undefined);
const [isSubmit, setIsSubmit] = useState(false);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

저장 버튼이 한 번 클릭 후 영구 비활성화되는 버그

isSubmittrue로 바꾼 뒤 다시 false로 복구하지 않아 버튼이 계속 비활성화됩니다. 뮤테이션의 isPending을 사용해 상태를 추적하거나 onSettled에서 복구하세요. 아래는 isPending을 쓰는 간단한 수정안입니다.

-  const [isSubmit, setIsSubmit] = useState(false);
+  // isSubmit 로컬 상태 대신 뮤테이션 pending 상태 사용

-  const { mutate: editMatchCondition } = useMutation(userMutations.EDIT_MATCH_CONDITION());
+  const { mutate: editMatchCondition, isPending: isEditMatchPending } =
+    useMutation(userMutations.EDIT_MATCH_CONDITION());

-  const isSubmitDisabled = !isMatchDirty || isSubmit;
+  const isSubmitDisabled = !isMatchDirty || isEditMatchPending;

   const handleSaveClick = () => {
     if (!isMatchDirty) return;
-    setIsSubmit(true);
-
-    editMatchCondition({
-      team: teamValue,
-      genderPreference: genderValue,
-      style: viewStyleValue,
-      teamAllowed: teamValue === NO_TEAM_OPTION ? null : mateTeamValue || null,
-    });
+    editMatchCondition(
+      {
+        team: teamValue,
+        genderPreference: genderValue,
+        style: viewStyleValue,
+        teamAllowed: teamValue === NO_TEAM_OPTION ? null : mateTeamValue || null,
+      }
+      // 필요 시 onError/onSuccess에서 토스트 노출 등 처리
+    );
   };

Also applies to: 33-35, 81-82, 83-93

🤖 Prompt for AI Agents
In src/pages/edit-profile/edit-profile.tsx around lines 31 (and related 33-35,
81-82, 83-93), the local state isSubmit is set true on submit but never reset,
causing the save button to remain disabled; replace the manual boolean toggle
with the mutation's isPending (or at minimum call setIsSubmit(false) in the
mutation's onSettled callback) so the disabled state reflects the actual request
lifecycle — update button/disabled checks to use mutation.isPending (or
setIsSubmit(false) in onSettled), remove any permanent setIsSubmit(true) paths,
and ensure all submit-related branches (lines noted) use the same
lifecycle-driven flag.


const initialValue = useRef({
team: mockEditData.team,
gender: mockEditData.genderPreference,
mateTeam: mockEditData.teamAllowed,
viewStyle: mockEditData.style,
const { mutate: editProfile } = useMutation(userMutations.EDIT_PROFILE());
const { mutate: editMatchCondition } = useMutation(userMutations.EDIT_MATCH_CONDITION());

const {
control,
formState: { errors, isSubmitting },
trigger,
getValues,
watch,
} = useForm<EditProfileValues>({
resolver: zodResolver(EditProfileSchema),
mode: 'onChange',
defaultValues: { nickname: '', introduction: '' },
});

const isDirty = useMemo(() => {
const init = initialValue.current;
const nicknameVal = watch('nickname', '');
const introductionVal = watch('introduction', '');

return (
team !== init.team ||
gender !== init.gender ||
mateTeam !== init.mateTeam ||
viewStyle !== init.viewStyle
);
}, [team, gender, mateTeam, viewStyle]);
const submitNickname = async () => {
const ok = await trigger('nickname');
if (!ok) return;
editProfile({ field: '닉네임', value: getValues('nickname').trim() });
};

const isSubmitDisabled = !isDirty || isSubmit;
const submitInformation = async () => {
const ok = await trigger('introduction');
if (!ok) return;
editProfile({ field: '소개', value: getValues('introduction').trim() });
};

const handleSaveClick = () => {
if (!isDirty) return;
const initial = {
team: data?.team ?? '',
gender: data?.genderPreference ?? '',
mateTeam: data?.teamAllowed ?? '',
viewStyle: data?.style ?? '',
};

const teamValue = team ?? initial.team;
const genderValue = gender ?? initial.gender;
const viewStyleValue = viewStyle ?? initial.viewStyle;
const mateTeamValue = (teamValue === NO_TEAM_OPTION ? '' : (mateTeam ?? initial.mateTeam)) ?? '';

const isMatchDirty =
teamValue !== initial.team ||
genderValue !== initial.gender ||
mateTeamValue !== initial.mateTeam ||
viewStyleValue !== initial.viewStyle;

const isSubmitDisabled = !isMatchDirty || isSubmit;

const handleSaveClick = () => {
if (!isMatchDirty) return;
setIsSubmit(true);

// TODO: 실제 API 호출
editMatchCondition({
team: teamValue,
genderPreference: genderValue,
style: viewStyleValue,
teamAllowed: teamValue === NO_TEAM_OPTION ? null : mateTeamValue || null,
});
};

return (
<div className="h-full bg-gray-white px-[1.6rem] pt-[1.6rem] pb-[4rem]">
<h2 className="subhead_18_sb mb-[1.6rem]">프로필 수정</h2>

{/* 닉네임 */}
<section>
<Input
placeholder={NICKNAME_PLACEHOLDER}
label="닉네임"
defaultMessage={NICKNAME_RULE_MESSAGE}
<Controller
name="nickname"
control={control}
render={({ field, fieldState }) => (
<Input
{...field}
placeholder={NICKNAME_PLACEHOLDER}
label="닉네임"
defaultMessage={NICKNAME_RULE_MESSAGE}
validationMessage={fieldState.error?.message}
isError={!!fieldState.error}
isValid={!fieldState.error && field.value.trim().length > 0}
/>
)}
/>
<div className="mb-[2.5rem] flex justify-end">
<Button label="수정" className="cap_14_sb mt-[0.8rem] w-auto px-[1.6rem] py-[0.6rem]" />
<Button
type="button"
label="수정"
onClick={submitNickname}
disabled={!!errors.nickname || nicknameVal.trim().length === 0 || isSubmitting}
className="cap_14_sb mt-[0.8rem] w-auto px-[1.6rem] py-[0.6rem]"
/>
</div>

<Input
placeholder={INTRODUCTION_PLACEHOLDER}
defaultMessage={INTRODUCTION_RULE_MESSAGE}
length={0}
hasLength
className="h-[10.4rem]"
label="한 줄 소개"
multiline
<Controller
name="introduction"
control={control}
render={({ field, fieldState }) => (
<Input
{...field}
placeholder={INTRODUCTION_PLACEHOLDER}
defaultMessage={INTRODUCTION_RULE_MESSAGE}
isError={!!fieldState.error}
isValid={!fieldState.error && field.value.trim().length > 0}
length={introductionVal.length}
hasLength
className="h-[10.4rem]"
label="한 줄 소개"
multiline
/>
)}
/>
<div className="flex justify-end">
<Button label="수정" className="cap_14_sb mt-[0.8rem] w-auto px-[1.6rem] py-[0.6rem]" />
<Button
type="button"
label="수정"
onClick={submitInformation}
disabled={!!errors.introduction || introductionVal.trim().length === 0 || isSubmitting}
className="cap_14_sb mt-[0.8rem] w-auto px-[1.6rem] py-[0.6rem]"
/>
</div>
</section>

<div className="-mx-[1.6rem] my-[3.2rem]">
<Divider thickness={0.4} />
</div>

{/* 매칭 조건 */}
<section className="flex-col pb-[5.6rem]">
<h2 className="subhead_18_sb mb-[0.4rem]">매칭 조건 수정</h2>
<p className="cap_12_m mb-[1.6rem] text-gray-500">
Expand All @@ -94,22 +170,19 @@ const EditProfile = () => {
<div className="flex-col gap-[1.6rem]">
<p className="body_16_m">응원팀</p>
<div className="flex flex-wrap gap-[0.8rem]">
{TEAMS.map((option) => {
const selected = team === option;
return (
<Button
key={option}
label={option}
variant={selected ? 'skyblue' : 'gray2'}
className="cap_14_sb w-auto px-[1.6rem] py-[0.6rem] text-gray-900"
onClick={() => setTeam(option)}
/>
);
})}
{TEAMS.map((option) => (
<Button
key={option}
label={option}
variant={teamValue === option ? 'skyblue' : 'gray2'}
className="cap_14_sb w-auto px-[1.6rem] py-[0.6rem]"
onClick={() => setTeam(option)}
/>
))}
<Button
label={NO_TEAM_OPTION}
variant={team === NO_TEAM_OPTION ? 'skyblue' : 'gray2'}
className="cap_14_sb w-fit px-[1.6rem] py-[0.6rem] "
variant={teamValue === NO_TEAM_OPTION ? 'skyblue' : 'gray2'}
className="cap_14_sb w-fit px-[1.6rem] py-[0.6rem]"
onClick={() => {
setTeam(NO_TEAM_OPTION);
setMateTeam('상관없어요');
Expand All @@ -121,22 +194,22 @@ const EditProfile = () => {
<SelectionGroup
title="직관 메이트의 응원팀"
options={PROFILE_SYNC_MATE}
selectedValue={mateTeam}
selectedValue={mateTeamValue}
onSelect={setMateTeam}
disabled={team === NO_TEAM_OPTION}
disabled={teamValue === NO_TEAM_OPTION}
/>

<SelectionGroup
title="관람 스타일"
options={VIEWING_STYLE}
selectedValue={viewStyle}
options={PROFILE_VIEWING_STYLE}
selectedValue={viewStyleValue}
onSelect={setViewStyle}
/>

<SelectionGroup
title="선호 성별"
options={GENDER}
selectedValue={gender}
selectedValue={genderValue}
onSelect={setGender}
/>
</div>
Expand Down
9 changes: 9 additions & 0 deletions src/pages/edit-profile/schema/EditProfileSchema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { UserInfoSchema } from '@pages/sign-up/schema/validation-schema';
import type { z } from 'zod';

export const EditProfileSchema = UserInfoSchema.pick({
nickname: true,
introduction: true,
});

export type EditProfileValues = z.infer<typeof EditProfileSchema>;
4 changes: 4 additions & 0 deletions src/shared/apis/base/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,7 @@ export function post<T>(...args: Parameters<typeof instance.post>): Promise<T> {
export function patch<T>(...args: Parameters<typeof instance.patch>): Promise<T> {
return instance.patch<T>(...args).then((res) => res.data);
}

export function put<T>(...args: Parameters<typeof instance.put>): Promise<T> {
return instance.put<T>(...args).then((res) => res.data);
}
29 changes: 27 additions & 2 deletions src/shared/apis/user/user-mutations.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { post } from '@apis/base/http';
import { patch, post, put } from '@apis/base/http';
import { END_POINT } from '@constants/api';
import { USER_KEY } from '@constants/query-key';
import queryClient from '@libs/query-client';
import { router } from '@routes/router';
import { ROUTES } from '@routes/routes-config';
import { mutationOptions } from '@tanstack/react-query';
import type { responseTypes } from '@/shared/types/base-types';
import type { postAgreementInfoRequest, postUserInfoRequest } from '@/shared/types/user-types';
import type {
postAgreementInfoRequest,
postEditProfileRequest,
postMatchConditionRequest,
postUserInfoRequest,
} from '@/shared/types/user-types';

export const userMutations = {
USER_INFO: () =>
Expand Down Expand Up @@ -37,6 +42,26 @@ export const userMutations = {
},
}),

EDIT_PROFILE: () =>
mutationOptions<responseTypes, Error, postEditProfileRequest>({
mutationKey: USER_KEY.EDIT_PROFILE(),
mutationFn: ({ field, value }) => put(END_POINT.POST_EDIT_PROFILE, { field, value }),
onSuccess: async () => {
queryClient.invalidateQueries({ queryKey: USER_KEY.ALL });
window.location.reload();
},
onError: (err) => {
console.error('수정에 실패했어요', err);
},
}),

EDIT_MATCH_CONDITION: () =>
mutationOptions<postMatchConditionRequest, Error, postMatchConditionRequest>({
mutationKey: USER_KEY.MATCH_CONDITION(),
mutationFn: ({ team, teamAllowed, style, genderPreference }) =>
patch(END_POINT.MATCH_CONDITION, { team, teamAllowed, style, genderPreference }),
}),
Comment on lines +58 to +63
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

EDIT_MATCH_CONDITION: 반환 타입/캐시 무효화 보완

  • mutationOptionsTData가 요청 바디 타입(postMatchConditionRequest)로 지정되어 있어 타입 안정성이 떨어집니다. 다른 뮤테이션과 동일하게 responseTypes로 맞추는 것을 권장합니다.
  • 성공 시 MATCH_CONDITION 쿼리를 무효화하지 않아 화면 데이터가 갱신되지 않습니다.

아래처럼 수정하세요:

-  EDIT_MATCH_CONDITION: () =>
-    mutationOptions<postMatchConditionRequest, Error, postMatchConditionRequest>({
+  EDIT_MATCH_CONDITION: () =>
+    mutationOptions<responseTypes, Error, postMatchConditionRequest>({
       mutationKey: USER_KEY.MATCH_CONDITION(),
-      mutationFn: ({ team, teamAllowed, style, genderPreference }) =>
-        patch(END_POINT.MATCH_CONDITION, { team, teamAllowed, style, genderPreference }),
+      mutationFn: ({ team, teamAllowed, style, genderPreference }) =>
+        patch<responseTypes>(END_POINT.MATCH_CONDITION, {
+          team,
+          teamAllowed,
+          style,
+          genderPreference,
+        }),
+      onSuccess: () => {
+        queryClient.invalidateQueries({ queryKey: USER_KEY.MATCH_CONDITION() });
+      },
     }),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
EDIT_MATCH_CONDITION: () =>
mutationOptions<postMatchConditionRequest, Error, postMatchConditionRequest>({
mutationKey: USER_KEY.MATCH_CONDITION(),
mutationFn: ({ team, teamAllowed, style, genderPreference }) =>
patch(END_POINT.MATCH_CONDITION, { team, teamAllowed, style, genderPreference }),
}),
EDIT_MATCH_CONDITION: () =>
mutationOptions<responseTypes, Error, postMatchConditionRequest>({
mutationKey: USER_KEY.MATCH_CONDITION(),
mutationFn: ({ team, teamAllowed, style, genderPreference }) =>
patch<responseTypes>(END_POINT.MATCH_CONDITION, {
team,
teamAllowed,
style,
genderPreference,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: USER_KEY.MATCH_CONDITION() });
},
}),
🤖 Prompt for AI Agents
In src/shared/apis/user/user-mutations.ts around lines 53 to 58, change the
mutation generic TData from the request body type to the API response type
(e.g., mutationOptions<postMatchConditionResponse, Error,
postMatchConditionRequest>) and add an onSuccess handler that invalidates the
MATCH_CONDITION cache (e.g., call
queryClient.invalidateQueries(USER_KEY.MATCH_CONDITION()) or equivalent) so the
UI refreshes after a successful edit.


AGREEMENT_INFO: () =>
mutationOptions<responseTypes, Error, postAgreementInfoRequest>({
mutationKey: USER_KEY.AGREEMENT(),
Expand Down
10 changes: 8 additions & 2 deletions src/shared/apis/user/user-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { get } from '@apis/base/http';
import { END_POINT } from '@constants/api';
import { USER_KEY } from '@constants/query-key';
import { queryOptions } from '@tanstack/react-query';
import type { getUserInfoResponse } from '@/shared/types/user-types';
import type { getMatchConditionResponse, getUserInfoResponse } from '@/shared/types/user-types';

export const userQueries = {
ALL: () => queryOptions({ queryKey: USER_KEY.ALL }),
Expand All @@ -16,6 +16,12 @@ export const userQueries = {
USER_INFO: () =>
queryOptions<getUserInfoResponse>({
queryKey: USER_KEY.INFO(),
queryFn: () => get(END_POINT.USER_INFO),
queryFn: () => get(END_POINT.GET_USER_INFO),
}),

MATCH_CONDITION: () =>
queryOptions<getMatchConditionResponse>({
queryKey: USER_KEY.MATCH_CONDITION(),
queryFn: () => get<getMatchConditionResponse>(END_POINT.MATCH_CONDITION),
}),
};
4 changes: 4 additions & 0 deletions src/shared/constants/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ export const END_POINT = {
GET_KAKAO_INFO: '/v1/users/kakao/info',
AGREEMENT_INFO: '/v2/users/consent',
USER_INFO: '/v2/users/info',
GET_USER_INFO: '/v1/users/info',
POST_INFO_NICKNAME: '/v1/users/info/nickname',
POST_EDIT_PROFILE: '/v2/users/info',
MATCH_CONDITION: '/v2/users/match-condition',

// 경기 관련
GET_GAME_SCHEDULE: (date: string) => `/v1/users/game/schedule?date=${date}`,
Expand Down
2 changes: 2 additions & 0 deletions src/shared/constants/query-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export const USER_KEY = {
INFO: () => [...USER_KEY.ALL, 'info'] as const,
NICKNAME: () => [...USER_KEY.ALL, 'nickname'] as const,
LOGOUT: () => [...USER_KEY.ALL, 'logout'] as const,
EDIT_PROFILE: () => [...USER_KEY.ALL, 'edit'] as const,
MATCH_CONDITION: () => [...USER_KEY.ALL, 'match_condition'] as const,
} as const;

export const AUTH_KEY = {
Expand Down
Loading