Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
14 changes: 14 additions & 0 deletions src/api/inquiry.api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { httpClient } from './http.api';

export const postInquiry = async (formData: FormData) => {
try {
const response = await httpClient.post('/inquiry', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
console.log(response);
} catch (e) {
console.log('문의하기 에러', e);
}
};
Comment on lines +3 to +14
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

에러 처리 및 반환 값 개선이 필요합니다

현재 API 함수는 console.log를 사용하여 응답과 오류를 기록하고 있으며, 반환 값이 없습니다. 이는 호출하는 컴포넌트에서 성공/실패 여부를 확인하기 어렵게 만듭니다.

다음과 같이 개선하는 것을 추천합니다:

-export const postInquiry = async (formData: FormData) => {
+export const postInquiry = async (formData: FormData): Promise<any> => {
  try {
    const response = await httpClient.post('/inquiry', formData, {
      headers: {
        'Content-Type': 'multipart/form-data',
      },
    });
-    console.log(response);
+    return response.data;
  } catch (e) {
-    console.log('문의하기 에러', e);
+    console.error('문의하기 에러', e);
+    throw e;
  }
};

이렇게 수정하면:

  1. 함수가 응답 데이터를 반환하게 되어 호출하는 측에서 사용할 수 있습니다.
  2. 에러가 발생하면 더 명확하게 로깅하고 상위로 전파하여 호출하는 측에서 처리할 수 있게 됩니다.
  3. Promise의 반환 타입을 명시하여 타입 안전성이 향상됩니다.
📝 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
export const postInquiry = async (formData: FormData) => {
try {
const response = await httpClient.post('/inquiry', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
console.log(response);
} catch (e) {
console.log('문의하기 에러', e);
}
};
export const postInquiry = async (formData: FormData): Promise<any> => {
try {
const response = await httpClient.post('/inquiry', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
} catch (e) {
console.error('문의하기 에러', e);
throw e;
}
};

191 changes: 191 additions & 0 deletions src/components/common/customerService/inquiry/Inquiry.styled.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { ChevronDownIcon } from '@heroicons/react/24/outline';
import styled, { css } from 'styled-components';

export const Container = styled.main`
width: 100%;
display: flex;
justify-content: center;
flex-direction: column;
`;

export const Header = styled.header`
margin: 1rem 0;
display: flex;
justify-content: center;
`;

export const HeaderTitle = styled.h1``;

export const InquiryForm = styled.form`
width: 100%;
display: flex;
justify-content: center;
`;

export const InquiryWrapper = styled.div`
width: 49rem;
`;

export const Nav = styled.nav`
width: 100%;
display: flex;
gap: 0.5rem;
`;

export const CategoryWrapper = styled.div`
position: relative;
`;

export const CategorySelect = styled.button<{ $isOpen: boolean }>`
padding: 0.3rem 0.5rem;
width: 9rem;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 1.3rem;
border: 1px solid ${({ theme }) => theme.color.border};
border-radius: ${({ theme }) => theme.borderRadius.primary};

svg {
width: 1.3rem;
height: 1.3rem;
transition: transform 300ms ease-in-out;
transform: rotate(0deg);
${({ $isOpen }) =>
$isOpen &&
css`
transform: rotate(180deg);
`}
}
`;

export const CategoryValueInput = styled.input`
position: absolute;
width: 0;
height: 0;
overflow: hidden;
`;

export const CategoryButtonWrapper = styled.div`
width: 9rem;
position: absolute;
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid ${({ theme }) => theme.color.border};
border-radius: ${({ theme }) => theme.borderRadius.primary};
background: ${({ theme }) => theme.color.white};
`;

export const CategoryButton = styled.button`
font-size: 1.3rem;
padding: 0.5rem;
display: flex;
justify-content: start;
align-items: center;

&:hover {
background: ${({ theme }) => theme.color.navy};
color: ${({ theme }) => theme.color.white};
}
`;

export const InputInquiryTitle = styled.input`
padding: 0.2rem 0.8rem;
width: calc(100% - 8rem);
font-size: 1.3rem;
border: 1px solid ${({ theme }) => theme.color.border};
border-radius: ${({ theme }) => theme.borderRadius.primary};
`;

export const ContentWrapper = styled.section`
width: 100%;
`;

export const Content = styled.textarea`
resize: none;
margin: 0.5rem 0;
padding: 1rem;
height: 55vh;
width: 100%;
border: 1px solid ${({ theme }) => theme.color.border};
border-radius: ${({ theme }) => theme.borderRadius.primary};
font-size: 1rem;
`;

export const InquiryFileWrapper = styled.div`
display: flex;
height: 40px;
`;

export const InquiryFileLabel = styled.label`
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
font-size: 1rem;
width: 6rem;
background: ${({ theme }) => theme.color.navy};
color: ${({ theme }) => theme.color.white};
border: 1px solid ${({ theme }) => theme.color.navy};
border-radius: ${({ theme }) => theme.borderRadius.primary} 0 0
${({ theme }) => theme.borderRadius.primary};

&:hover {
background: ${({ theme }) => theme.color.lightgrey};
color: ${({ theme }) => theme.color.navy};
border: 1px solid ${({ theme }) => theme.color.navy};
transition: all 0.3s ease-in-out;
}
`;

export const InquiryShowFile = styled.span`
display: flex;
justify-content: start;
align-items: center;
padding: 0.5rem;
border: 1px solid ${({ theme }) => theme.color.border};
width: 40%;
color: ${({ theme }) => theme.color.navy};
border-radius: 0 ${({ theme }) => theme.borderRadius.primary}
${({ theme }) => theme.borderRadius.primary} 0;
`;

export const InquiryFile = styled.input`
position: absolute;
width: 0;
height: 0;
overflow: hidden;
`;

export const FileImg = styled.img`
margin-left: 0.5rem;
width: 60px;
height: 40px;
`;

export const SendButtonWrapper = styled.div`
width: 100%;
display: flex;
justify-content: end;
`;

export const SendButton = styled.button`
display: flex;
justify-content: center;
align-items: center;
font-size: 1rem;
width: 6rem;
background: ${({ theme }) => theme.color.navy};
border-radius: ${({ theme }) => theme.borderRadius.large};
color: ${({ theme }) => theme.color.white};
border: 1px solid ${({ theme }) => theme.color.navy};
padding: 0.5em;

&:hover {
background: ${({ theme }) => theme.color.lightgrey};
color: ${({ theme }) => theme.color.navy};
border: 1px solid ${({ theme }) => theme.color.navy};
transition: all 0.3s ease-in-out;
}
`;
159 changes: 159 additions & 0 deletions src/components/common/customerService/inquiry/Inquiry.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { Fragment } from 'react/jsx-runtime';
import {
EMPTY_IMAGE,
INQUIRY_CATEGORY,
INQUIRY_MESSAGE,
} from '../../../../constants/customerService';
import * as S from './Inquiry.styled';
import { ChevronDownIcon } from '@heroicons/react/24/outline';
import React, { useState } from 'react';
import type { InquiryFormData } from '../../../../models/inquiry';
import { usePostInquiry } from '../../../../hooks/usePostInquiry';

interface FormStateType {
category: string;
title: string;
content: string;
fileValue: string;
fileImage: string | null;
}

export default function Inquiry() {
const { mutate: postInquiry } = usePostInquiry();
const [isOpen, setIsOpen] = useState<boolean>(false);
const [form, setForm] = useState<FormStateType>({
category: INQUIRY_MESSAGE.categoryDefault,
title: '',
content: '',
fileValue: INQUIRY_MESSAGE.fileDefault,
fileImage: null,
});

const handleSubmitInquiry = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();

const formData = new FormData(e.currentTarget as HTMLFormElement);

const formDataObj: InquiryFormData = {
category: formData.get('category') as string,
title: formData.get('title') as string,
content: formData.get('content') as string,
};

const data = new Blob([JSON.stringify(formDataObj)], {
type: 'application/json',
});

formData.append('inquiryDto', data);

// 모달처리하기
let isValid = true;

Array.from(formData.entries()).forEach(([key, value]) => {
if (key === 'category' && value === INQUIRY_MESSAGE.categoryDefault)
return (isValid = false);
if (key === 'title' && value === '') return (isValid = false);
if (key === 'content' && value === '') return (isValid = false);
});

if (isValid) {
postInquiry(formData);
setForm({
category: INQUIRY_MESSAGE.categoryDefault,
title: '',
content: '',
fileValue: INQUIRY_MESSAGE.fileDefault,
fileImage: null,
});
}
};
Copy link
Collaborator

Choose a reason for hiding this comment

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

이 부분도 const handleFormChange = (key: keyof typeof form, value: string) => { setForm(prev => ({ ...prev, [key]: value })); };
상태를 객체로 관리하면 이런식으로도 표현해볼 수 있을 것 같습니다!


const handleClickCategoryValue = (category: string) => {
setForm((prev) => ({ ...prev, category }));
setIsOpen((prev) => !prev);
};
const handleChangeFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const fileValue = e.target.value;
const image = e.target.files?.[0];
const fileImage = image ? URL.createObjectURL(image) : null;
setForm((prev) => ({ ...prev, fileValue, fileImage }));
};
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

메모리 누수 방지를 위해 객체 URL을 관리하세요.

URL.createObjectURL로 생성된 URL은 사용이 끝나면 명시적으로 해제해야 메모리 누수를 방지할 수 있습니다.

import React, { useState, useEffect } from 'react';
// ...

const handleChangeFile = (e: React.ChangeEvent<HTMLInputElement>) => {
  const fileValue = e.target.value;
  const image = e.target.files?.[0];
  const fileImage = image ? URL.createObjectURL(image) : null;
  setForm((prev) => ({ ...prev, fileValue, fileImage }));
};

+ // 컴포넌트 언마운트 시 객체 URL 해제
+ useEffect(() => {
+   return () => {
+     if (form.fileImage) {
+       URL.revokeObjectURL(form.fileImage);
+     }
+   };
+ }, [form.fileImage]);
📝 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
const handleChangeFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const fileValue = e.target.value;
const image = e.target.files?.[0];
const fileImage = image ? URL.createObjectURL(image) : null;
setForm((prev) => ({ ...prev, fileValue, fileImage }));
};
// --- at the top of the file ---
-import React, { useState } from 'react';
+import React, { useState, useEffect } from 'react';
// ...
export default function Inquiry() {
// existing state, including something like:
// const [form, setForm] = useState({ ..., fileImage: null });
const handleChangeFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const fileValue = e.target.value;
const image = e.target.files?.[0];
const fileImage = image ? URL.createObjectURL(image) : null;
setForm((prev) => ({ ...prev, fileValue, fileImage }));
};
// 컴포넌트 언마운트 시 및 fileImage 변경 시 이전 객체 URL 해제
useEffect(() => {
return () => {
if (form.fileImage) {
URL.revokeObjectURL(form.fileImage);
}
};
}, [form.fileImage]);
// ...rest of your component
}


return (
<S.Container>
<S.Header>
<S.HeaderTitle>DevPals 문의하기</S.HeaderTitle>
</S.Header>
<S.InquiryForm
onSubmit={handleSubmitInquiry}
method='post'
encType='multipart/form-data'
>
<S.InquiryWrapper>
<S.Nav>
<S.CategoryWrapper>
<S.CategorySelect
onClick={() => setIsOpen((prev) => !prev)}
$isOpen={isOpen}
>
{form.category} <ChevronDownIcon />
<S.CategoryValueInput
type='hidden'
name='category'
value={form.category}
/>
</S.CategorySelect>
{isOpen && (
<S.CategoryButtonWrapper>
{INQUIRY_CATEGORY.map((category) => (
<Fragment key={category.title}>
<S.CategoryButton
onClick={() => handleClickCategoryValue(category.title)}
>
{category.title}
</S.CategoryButton>
</Fragment>
))}
</S.CategoryButtonWrapper>
)}
</S.CategoryWrapper>
<S.InputInquiryTitle
name='title'
type='text'
placeholder='제목을 입력하세요.'
value={form.title}
onChange={(e) =>
setForm((prev) => ({ ...prev, title: e.target.value }))
}
/>
</S.Nav>
<S.ContentWrapper>
<S.Content
as='textarea'
name='content'
value={form.content}
onChange={(e) =>
setForm((prev) => ({ ...prev, content: e.target.value }))
}
></S.Content>
<S.InquiryFileWrapper>
<S.InquiryFileLabel htmlFor='upload'>파일찾기</S.InquiryFileLabel>
<S.InquiryShowFile>{form.fileValue}</S.InquiryShowFile>
<S.InquiryFile
name='images'
type='file'
accept='.jpg, .jpeg, .png'
id='upload'
onChange={(e) => handleChangeFile(e)}
/>
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

파일 업로드 크기 제한을 추가하세요.

현재 이미지 파일 형식은 제한되어 있지만, 파일 크기에 대한 제한이 없습니다. 대용량 이미지 파일은 성능 문제를 일으킬 수 있으므로 크기 제한을 추가하는 것이 좋습니다.

const handleChangeFile = (e: React.ChangeEvent<HTMLInputElement>) => {
  const fileValue = e.target.value;
  const image = e.target.files?.[0];
+ 
+ // 파일 크기 제한 (예: 5MB)
+ const MAX_FILE_SIZE = 5 * 1024 * 1024;
+ if (image && image.size > MAX_FILE_SIZE) {
+   alert('파일 크기는 5MB 이하여야 합니다.');
+   e.target.value = '';
+   return;
+ }
+ 
  const fileImage = image ? URL.createObjectURL(image) : null;
  setForm((prev) => ({ ...prev, fileValue, fileImage }));
};
📝 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
<S.InquiryFile
name='images'
type='file'
accept='.jpg, .jpeg, .png'
id='upload'
onChange={(e) => handleChangeFile(e)}
/>
const handleChangeFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const fileValue = e.target.value;
const image = e.target.files?.[0];
// 파일 크기 제한 (예: 5MB)
const MAX_FILE_SIZE = 5 * 1024 * 1024;
if (image && image.size > MAX_FILE_SIZE) {
alert('파일 크기는 5MB 이하여야 합니다.');
e.target.value = '';
return;
}
const fileImage = image ? URL.createObjectURL(image) : null;
setForm((prev) => ({ ...prev, fileValue, fileImage }));
};

{form.fileImage && <S.FileImg src={form.fileImage || ''} />}
</S.InquiryFileWrapper>
</S.ContentWrapper>
<S.SendButtonWrapper>
<S.SendButton type='submit'>제출</S.SendButton>
</S.SendButtonWrapper>
</S.InquiryWrapper>
</S.InquiryForm>
</S.Container>
);
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

customerService폴더가 common에 들어가 있는데, 잘 못 넣으신 것 같아요
확인 한번 해주세요~

Copy link
Collaborator Author

@YouD0313 YouD0313 Apr 29, 2025

Choose a reason for hiding this comment

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

문의하기가 헤더 유저 토스트부분이랑 마이페이지 내 문의글, 그리고 FAQ 공지사항 각각 들어가서 common으로 했습니다.
FAQ, 공지사항도 그렇게 생각했는데 다르게 생각해야하나요??

2 changes: 1 addition & 1 deletion src/components/common/header/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ function Header() {
<Link to={ROUTES.manageProjectsRoot}>
<S.Item>공고관리</S.Item>
</Link>
<Link to={ROUTES.support}>
<Link to={ROUTES.inquiry}>
<S.Item>문의하기</S.Item>
</Link>
<Link to='#' onClick={(e) => e.preventDefault()}>
Expand Down
Loading