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
42 changes: 23 additions & 19 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
57 changes: 49 additions & 8 deletions src/pages/edit-profile/edit-profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand All @@ -32,6 +38,7 @@ const EditProfile = () => {
const [mateTeam, setMateTeam] = useState<string | undefined>(undefined);
const [viewStyle, setViewStyle] = useState<string | undefined>(undefined);
const [isSubmit, setIsSubmit] = useState(false);
const [nicknameStatus, setNicknameStatus] = useState<NicknameStatus>('idle');

const { mutate: editProfile } = useMutation(userMutations.EDIT_PROFILE());
const { mutate: editMatchCondition } = useMutation(userMutations.EDIT_MATCH_CONDITION());
Expand All @@ -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;
Expand Down Expand Up @@ -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 (
<div className="h-full bg-gray-white px-[1.6rem] pt-[1.6rem] pb-[4rem]">
<h2 className="subhead_18_sb mb-[1.6rem]">프로필 수정</h2>
Expand All @@ -104,24 +129,40 @@ const EditProfile = () => {
<Controller
name="nickname"
control={control}
render={({ field, fieldState }) => (
render={({ field }) => (
<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}
validationMessage={
nicknameStatus === 'duplicate'
? NICKNAME_DUPLICATED
: nicknameStatus === 'available'
? NICKNAME_SUCCESS_MESSAGE
: undefined
}
isError={nicknameStatus === 'duplicate'}
isValid={nicknameStatus === 'available'}
/>
)}
/>
<div className="mb-[2.5rem] flex justify-end">
<div className="mb-[2.5rem] flex justify-end gap-[0.8rem]">
<Button
type="button"
variant="skyblue"
label="중복 확인"
onClick={handleCheckNickname}
disabled={!!errors.nickname || nicknameVal.trim().length === 0 || isSubmitting}
className="cap_14_sb mt-[0.8rem] w-auto px-[1.6rem] py-[0.6rem] disabled:text-white"
/>
<Button
type="button"
label="수정"
onClick={submitNickname}
disabled={!!errors.nickname || nicknameVal.trim().length === 0 || isSubmitting}
disabled={
nicknameStatus !== 'available' || nicknameVal.trim().length === 0 || isSubmitting
}
className="cap_14_sb mt-[0.8rem] w-auto px-[1.6rem] py-[0.6rem]"
/>
</div>
Expand Down
3 changes: 1 addition & 2 deletions src/pages/match/utils/match-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
64 changes: 51 additions & 13 deletions src/pages/sign-up/components/signup-step.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
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';
import {
BIRTHYEAR_RULE_MESSAGE,
BIRTHYEAR_SUCCESS_MESSAGE,
INTRODUCTION_RULE_MESSAGE,
NICKNAME_DUPLICATED,
NICKNAME_RULE_MESSAGE,
NICKNAME_SUCCESS_MESSAGE,
NICKNAME_TITLE,
Expand All @@ -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';

Expand All @@ -39,12 +43,15 @@ const SignupStep = () => {
const genderValue = watch('gender');
const informationValue = watch('introduction');

const [nicknameStatus, setNicknameStatus] = useState<NicknameStatus>('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;

Expand Down Expand Up @@ -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 (
<form
onSubmit={handleSubmit(onSubmit)}
Expand All @@ -90,17 +113,32 @@ const SignupStep = () => {
<div className="w-full flex-col gap-[4rem]">
<h1 className="title_24_sb whitespace-pre-line">{NICKNAME_TITLE}</h1>
<div className=" flex-col gap-[2.4rem]">
<Input
placeholder={NICKNAME_PLACEHOLDER}
label="닉네임"
defaultMessage={isNicknameValid ? NICKNAME_SUCCESS_MESSAGE : NICKNAME_RULE_MESSAGE}
validationMessage={errors.nickname?.message}
isError={!!errors.nickname}
isValid={isNicknameValid}
onBlur={onNicknameBlur}
ref={nicknameRef}
{...nicknameInputProps}
/>
<div className="flex-col gap-[0.8rem]">
<Input
placeholder={NICKNAME_PLACEHOLDER}
label="닉네임"
defaultMessage={NICKNAME_RULE_MESSAGE}
validationMessage={
nicknameStatus === 'duplicate'
? NICKNAME_DUPLICATED
: nicknameStatus === 'available'
? NICKNAME_SUCCESS_MESSAGE
: undefined
}
isError={nicknameStatus === 'duplicate'}
isValid={nicknameStatus === 'available'}
onBlur={onNicknameBlur}
ref={nicknameRef}
{...nicknameInputProps}
/>
<Button
label="중복 확인"
type="button"
className="cap_14_sb ml-auto w-fit px-[1.6rem] py-[0.6rem]"
onClick={handleCheckNickname}
disabled={!isNicknameValid}
/>
</div>
<Input
placeholder={INTRODUCTION_PLACEHOLDER}
className="h-[10.4rem]"
Expand Down Expand Up @@ -156,7 +194,7 @@ const SignupStep = () => {
className="w-full"
ariaLabel="가입하기"
type="submit"
disabled={!isValid}
disabled={!isValid || nicknameStatus !== 'available'}
/>
</form>
);
Expand Down
2 changes: 2 additions & 0 deletions src/pages/sign-up/constants/NOTICE.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,5 @@ export const BIRTHYEAR_RULE_MESSAGE = '4자리 숫자만 입력 가능';
export const BIRTHYEAR_SUCCESS_MESSAGE = '올바른 입력 값이에요.';

export const INTRODUCTION_RULE_MESSAGE = '비방, 욕설 등 불쾌감을 줄 수 있는 내용 제한';

export const NICKNAME_DUPLICATED = '이미 존재하는 닉네임이에요';
2 changes: 2 additions & 0 deletions src/pages/sign-up/types/nickname-types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export interface postNicknameRequest {
nickname: string;
}

export type NicknameStatus = 'idle' | 'checking' | 'available' | 'duplicate';
24 changes: 24 additions & 0 deletions src/shared/apis/user/user-queries.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -24,4 +26,26 @@ export const userQueries = {
queryKey: USER_KEY.MATCH_CONDITION(),
queryFn: () => get<getMatchConditionResponse>(END_POINT.MATCH_CONDITION),
}),

NICKNAME_CHECK: (nickname: string) =>
queryOptions<boolean>({
queryKey: USER_KEY.NICKNAME_CHECK(nickname),
enabled: false,
queryFn: async () => {
try {
await get<void>(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;
},
}),
};
2 changes: 2 additions & 0 deletions src/shared/constants/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/shared/constants/query-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion tsconfig.app.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down