diff --git a/.vscode/settings.json b/.vscode/settings.json index 5ba0ae4b..5ec03364 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,23 +1,27 @@ { - "editor.formatOnSave": true, - "editor.defaultFormatter": "biomejs.biome", + "editor.formatOnSave": true, - "[typescript]": { - "editor.defaultFormatter": "biomejs.biome" - }, - "[typescriptreact]": { - "editor.defaultFormatter": "biomejs.biome" - }, + // 기본 포매터 Biome + "editor.defaultFormatter": "biomejs.biome", - "tailwindCSS.classFunctions": ["clsx", "cn", "cva", "tw"], - "tailwindCSS.experimental.classRegex": [ - ["clsx\\(.*?\\)(?!\\])", "(?:'|\"|`)([^\"'`]*)(?:'|\"|`)"], - ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"], - ["(?:twMerge|twJoin)\\(([^;]*)[\\);]", "[`'\"`]([^'\"`;]*)[`'\"`]"] - ], - "editor.codeActionsOnSave": { - "quickfix.biome": "explicit", - "source.fixAll": "explicit", - "source.organizeImports.biome": "explicit" - } + // 언어별로도 Biome 고정 + "[typescript]": { + "editor.defaultFormatter": "biomejs.biome", + "editor.formatOnSave": true + }, + "[typescriptreact]": { + "editor.defaultFormatter": "biomejs.biome", + "editor.formatOnSave": true + }, + "[javascript]": { + "editor.defaultFormatter": "biomejs.biome", + "editor.formatOnSave": true + }, + "[javascriptreact]": { + "editor.defaultFormatter": "biomejs.biome", + "editor.formatOnSave": true +}, +"[jsonc]": { + "editor.defaultFormatter": "vscode.json-language-features" +} } diff --git a/src/pages/edit-profile/edit-profile.tsx b/src/pages/edit-profile/edit-profile.tsx index cdfa95e6..26b22307 100644 --- a/src/pages/edit-profile/edit-profile.tsx +++ b/src/pages/edit-profile/edit-profile.tsx @@ -15,13 +15,19 @@ import { 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_RULE_MESSAGE, + NICKNAME_DUPLICATED, + NICKNAME_RULE_MESSAGE, + NICKNAME_SUCCESS_MESSAGE, +} from '@pages/sign-up/constants/NOTICE'; import { INTRODUCTION_PLACEHOLDER, NICKNAME_PLACEHOLDER, } from '@pages/sign-up/constants/validation'; +import type { NicknameStatus } from '@pages/sign-up/types/nickname-types'; import { useMutation, useQuery } from '@tanstack/react-query'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Controller, useForm } from 'react-hook-form'; const EditProfile = () => { @@ -32,6 +38,7 @@ const EditProfile = () => { const [mateTeam, setMateTeam] = useState(undefined); const [viewStyle, setViewStyle] = useState(undefined); const [isSubmit, setIsSubmit] = useState(false); + const [nicknameStatus, setNicknameStatus] = useState('idle'); const { mutate: editProfile } = useMutation(userMutations.EDIT_PROFILE()); const { mutate: editMatchCondition } = useMutation(userMutations.EDIT_MATCH_CONDITION()); @@ -51,6 +58,8 @@ const EditProfile = () => { const nicknameVal = watch('nickname', ''); const introductionVal = watch('introduction', ''); + const { refetch: refetchNicknameCheck } = useQuery(userQueries.NICKNAME_CHECK(nicknameVal)); + const submitNickname = async () => { const ok = await trigger('nickname'); if (!ok) return; @@ -95,6 +104,22 @@ const EditProfile = () => { }); }; + // biome-ignore lint/correctness/useExhaustiveDependencies: reset nickname status whenever value changes + useEffect(() => { + setNicknameStatus('idle'); + }, [nicknameVal]); + + const handleCheckNickname = async () => { + if (errors.nickname || nicknameVal.trim().length < 2) return; + setNicknameStatus('checking'); + try { + const { data } = await refetchNicknameCheck(); + setNicknameStatus(data ? 'available' : 'duplicate'); + } catch { + setNicknameStatus('idle'); + } + }; + return (

프로필 수정

@@ -104,24 +129,40 @@ const EditProfile = () => { ( + render={({ field }) => ( 0} + validationMessage={ + nicknameStatus === 'duplicate' + ? NICKNAME_DUPLICATED + : nicknameStatus === 'available' + ? NICKNAME_SUCCESS_MESSAGE + : undefined + } + isError={nicknameStatus === 'duplicate'} + isValid={nicknameStatus === 'available'} /> )} /> -
+
+
diff --git a/src/pages/match/utils/match-status.ts b/src/pages/match/utils/match-status.ts index 83c87999..5b4aec08 100644 --- a/src/pages/match/utils/match-status.ts +++ b/src/pages/match/utils/match-status.ts @@ -42,5 +42,4 @@ export const isClickable = (status?: string) => Boolean(CLICKABLE_STATUS_MAP[sta export const normalizeChipKey = (v?: string) => (v ?? '').replace(/\s/g, ''); export const isChipColor = (k: string): k is ChipColor => - // eslint-disable-next-line no-prototype-builtins - Object.prototype.hasOwnProperty.call(chipVariantOptions.bgColor, k); + Object.hasOwn(chipVariantOptions.bgColor, k as PropertyKey); diff --git a/src/pages/sign-up/components/signup-step.tsx b/src/pages/sign-up/components/signup-step.tsx index 3932a8e4..6b305c0a 100644 --- a/src/pages/sign-up/components/signup-step.tsx +++ b/src/pages/sign-up/components/signup-step.tsx @@ -1,4 +1,5 @@ import { userMutations } from '@apis/user/user-mutations'; +import { userQueries } from '@apis/user/user-queries'; import Button from '@components/button/button/button'; import Input from '@components/input/input'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -6,6 +7,7 @@ import { BIRTHYEAR_RULE_MESSAGE, BIRTHYEAR_SUCCESS_MESSAGE, INTRODUCTION_RULE_MESSAGE, + NICKNAME_DUPLICATED, NICKNAME_RULE_MESSAGE, NICKNAME_SUCCESS_MESSAGE, NICKNAME_TITLE, @@ -17,7 +19,9 @@ import { NICKNAME_PLACEHOLDER, } from '@pages/sign-up/constants/validation'; import { type UserInfoFormValues, UserInfoSchema } from '@pages/sign-up/schema/validation-schema'; -import { useMutation } from '@tanstack/react-query'; +import type { NicknameStatus } from '@pages/sign-up/types/nickname-types'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; import type { postUserInfoRequest } from '@/shared/types/user-types'; @@ -39,12 +43,15 @@ const SignupStep = () => { const genderValue = watch('gender'); const informationValue = watch('introduction'); + const [nicknameStatus, setNicknameStatus] = useState('idle'); + const isNicknameValid = !errors.nickname && nicknameValue.length > 0; const isBirthYearValid = !errors.birthYear && birthYearValue.length > 0; const isInformationValid = !errors.introduction && informationValue.length > 0; const userInfoMutation = useMutation(userMutations.USER_INFO()); const agreementInfoMutaion = useMutation(userMutations.AGREEMENT_INFO()); + const { refetch: refetchNicknameCheck } = useQuery(userQueries.NICKNAME_CHECK(nicknameValue)); const informationLength = informationValue.length ?? 0; @@ -82,6 +89,22 @@ const SignupStep = () => { setValue('gender', gender, { shouldValidate: true, shouldDirty: true }); }; + const handleCheckNickname = async () => { + if (!isNicknameValid) return; + setNicknameStatus('checking'); + try { + const { data } = await refetchNicknameCheck(); + setNicknameStatus(data ? 'available' : 'duplicate'); + } catch { + setNicknameStatus('idle'); + } + }; + + // biome-ignore lint/correctness/useExhaustiveDependencies: reset nickname status whenever value changes + useEffect(() => { + setNicknameStatus('idle'); + }, [nicknameValue]); + return (
{

{NICKNAME_TITLE}

- +
+ +
{ className="w-full" ariaLabel="가입하기" type="submit" - disabled={!isValid} + disabled={!isValid || nicknameStatus !== 'available'} /> ); diff --git a/src/pages/sign-up/constants/NOTICE.ts b/src/pages/sign-up/constants/NOTICE.ts index 2db0b7d6..5eccc23b 100644 --- a/src/pages/sign-up/constants/NOTICE.ts +++ b/src/pages/sign-up/constants/NOTICE.ts @@ -15,3 +15,5 @@ export const BIRTHYEAR_RULE_MESSAGE = '4자리 숫자만 입력 가능'; export const BIRTHYEAR_SUCCESS_MESSAGE = '올바른 입력 값이에요.'; export const INTRODUCTION_RULE_MESSAGE = '비방, 욕설 등 불쾌감을 줄 수 있는 내용 제한'; + +export const NICKNAME_DUPLICATED = '이미 존재하는 닉네임이에요'; diff --git a/src/pages/sign-up/types/nickname-types.ts b/src/pages/sign-up/types/nickname-types.ts index 6454c4d7..3b59744c 100644 --- a/src/pages/sign-up/types/nickname-types.ts +++ b/src/pages/sign-up/types/nickname-types.ts @@ -1,3 +1,5 @@ export interface postNicknameRequest { nickname: string; } + +export type NicknameStatus = 'idle' | 'checking' | 'available' | 'duplicate'; diff --git a/src/shared/apis/user/user-queries.ts b/src/shared/apis/user/user-queries.ts index 1f4ace60..3a549f43 100644 --- a/src/shared/apis/user/user-queries.ts +++ b/src/shared/apis/user/user-queries.ts @@ -1,7 +1,9 @@ import { get } from '@apis/base/http'; import { END_POINT } from '@constants/api'; import { USER_KEY } from '@constants/query-key'; +import { HTTP_STATUS } from '@constants/response'; import { queryOptions } from '@tanstack/react-query'; +import axios from 'axios'; import type { getMatchConditionResponse, getUserInfoResponse } from '@/shared/types/user-types'; export const userQueries = { @@ -24,4 +26,26 @@ export const userQueries = { queryKey: USER_KEY.MATCH_CONDITION(), queryFn: () => get(END_POINT.MATCH_CONDITION), }), + + NICKNAME_CHECK: (nickname: string) => + queryOptions({ + queryKey: USER_KEY.NICKNAME_CHECK(nickname), + enabled: false, + queryFn: async () => { + try { + await get(END_POINT.GET_NICKNAME_CHECK(nickname)); + return true; + } catch (e) { + if (axios.isAxiosError(e) && e.response?.status === HTTP_STATUS.CONFLICT) { + return false; + } + throw e; + } + }, + retry: (failureCount, error) => { + if (axios.isAxiosError(error) && error.response?.status === HTTP_STATUS.CONFLICT) + return false; + return failureCount < 2; + }, + }), }; diff --git a/src/shared/constants/api.ts b/src/shared/constants/api.ts index b97193a3..5679824d 100644 --- a/src/shared/constants/api.ts +++ b/src/shared/constants/api.ts @@ -12,6 +12,8 @@ export const END_POINT = { GET_KAKAO_INFO: '/v1/users/kakao/info', AGREEMENT_INFO: '/v2/users/consent', USER_INFO: '/v2/users/info', + GET_NICKNAME_CHECK: (nickname: string) => + `/v2/users/info?nickname=${encodeURIComponent(nickname)}`, GET_USER_INFO: '/v1/users/info', POST_INFO_NICKNAME: '/v1/users/info/nickname', POST_EDIT_PROFILE: '/v2/users/info', diff --git a/src/shared/constants/query-key.ts b/src/shared/constants/query-key.ts index afcdeab4..9c0a1852 100644 --- a/src/shared/constants/query-key.ts +++ b/src/shared/constants/query-key.ts @@ -5,6 +5,7 @@ export const USER_KEY = { KAKAO: () => [...USER_KEY.ALL, 'kakao'] as const, AGREEMENT: () => [...USER_KEY.ALL, 'agreement'] as const, INFO: () => [...USER_KEY.ALL, 'info'] as const, + NICKNAME_CHECK: (nickname: string) => [...USER_KEY.ALL, 'nickname-check', nickname] as const, NICKNAME: () => [...USER_KEY.ALL, 'nickname'] as const, LOGOUT: () => [...USER_KEY.ALL, 'logout'] as const, EDIT_PROFILE: () => [...USER_KEY.ALL, 'edit'] as const, diff --git a/tsconfig.app.json b/tsconfig.app.json index 98effffe..773e1b9b 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -3,7 +3,7 @@ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "target": "ES2020", "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], + "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true,