Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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;
}
`;
147 changes: 147 additions & 0 deletions src/components/common/customerService/inquiry/Inquiry.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
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';

export default function Inquiry() {
const { mutate: postInquiry } = usePostInquiry();
const [isOpen, setIsOpen] = useState<boolean>(false);
const [categoryValue, setCategoryValue] = useState<string>(
INQUIRY_MESSAGE.categoryDefault
);
const [title, setTitle] = useState<string>('');
const [content, setContent] = useState<string>('');
const [fileValue, setFileValue] = useState<string>(
INQUIRY_MESSAGE.fileDefault
);
const [fileImage, setFileImage] = useState<string | null>(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);
setCategoryValue(INQUIRY_MESSAGE.categoryDefault);
setFileValue(INQUIRY_MESSAGE.fileDefault);
setFileImage(EMPTY_IMAGE);
setTitle('');
setContent('');
}
};
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 = (value: string) => {
setCategoryValue(value);
setIsOpen((prev) => !prev);
};
const handleChangeFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
const file = e.target.files?.[0];
setFileValue(value);
setFileImage(file ? URL.createObjectURL(file) : EMPTY_IMAGE);
};

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}
>
{categoryValue} <ChevronDownIcon />
<S.CategoryValueInput
type='hidden'
name='category'
value={categoryValue}
/>
</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={title}
onChange={(e) => setTitle(e.target.value)}
/>
</S.Nav>
<S.ContentWrapper>
<S.Content
as='textarea'
name='content'
value={content}
onChange={(e) => setContent(e.target.value)}
></S.Content>
<S.InquiryFileWrapper>
<S.InquiryFileLabel htmlFor='upload'>파일찾기</S.InquiryFileLabel>
<S.InquiryShowFile>{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 }));
};

{fileImage && <S.FileImg src={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
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import styled from 'styled-components';
import styled, { css } from 'styled-components';

export const Container = styled.div`
width: 100%;
Expand Down Expand Up @@ -35,14 +35,24 @@ export const SkillTagButtonWrapper = styled.div`
z-index: 1000;
`;

export const SkillTagButton = styled.button`
export const SkillTagButton = styled.button<{ $isOpen: boolean }>`
border-radius: 1.5rem;
width: 100%;
height: 100%;
padding: 0 1rem;
display: flex;
justify-content: space-between;
align-items: center;

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

export const SkillTagBoxWrapper = styled.div`
Expand Down
Loading