diff --git a/src/api/inquiry.api.ts b/src/api/inquiry.api.ts new file mode 100644 index 00000000..0c96ee99 --- /dev/null +++ b/src/api/inquiry.api.ts @@ -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); + } +}; diff --git a/src/components/common/header/Header.tsx b/src/components/common/header/Header.tsx index b8523038..5a63f191 100644 --- a/src/components/common/header/Header.tsx +++ b/src/components/common/header/Header.tsx @@ -74,7 +74,7 @@ function Header() { 공고관리 - + 문의하기 e.preventDefault()}> diff --git a/src/components/home/searchFiltering/filteringContents/FilteringContents.styled.ts b/src/components/home/searchFiltering/filteringContents/FilteringContents.styled.ts index f20ae36d..2e02dcc9 100644 --- a/src/components/home/searchFiltering/filteringContents/FilteringContents.styled.ts +++ b/src/components/home/searchFiltering/filteringContents/FilteringContents.styled.ts @@ -1,4 +1,4 @@ -import styled from 'styled-components'; +import styled, { css } from 'styled-components'; export const Container = styled.div` width: 100%; @@ -35,7 +35,7 @@ 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%; @@ -43,6 +43,16 @@ export const SkillTagButton = styled.button` 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` diff --git a/src/components/home/searchFiltering/filteringContents/FilteringContents.tsx b/src/components/home/searchFiltering/filteringContents/FilteringContents.tsx index 32dad24f..827d19ae 100644 --- a/src/components/home/searchFiltering/filteringContents/FilteringContents.tsx +++ b/src/components/home/searchFiltering/filteringContents/FilteringContents.tsx @@ -40,7 +40,10 @@ export default function FilteringContents() { return ( - + 언어선택 diff --git a/src/components/home/searchFiltering/filteringContents/filtering/Filtering.styled.ts b/src/components/home/searchFiltering/filteringContents/filtering/Filtering.styled.ts index 47edbe94..07b813b3 100644 --- a/src/components/home/searchFiltering/filteringContents/filtering/Filtering.styled.ts +++ b/src/components/home/searchFiltering/filteringContents/filtering/Filtering.styled.ts @@ -1,4 +1,4 @@ -import styled from 'styled-components'; +import styled, { css } from 'styled-components'; export const Container = styled.div``; @@ -16,7 +16,7 @@ export const RefWrapper = styled.div` border-radius: 1.5rem; `; -export const DefaultValueButton = styled.button` +export const DefaultValueButton = styled.button<{ $isOpen: boolean }>` width: 100%; height: 100%; display: flex; @@ -24,6 +24,16 @@ export const DefaultValueButton = styled.button` align-items: center; padding: 0 1rem; border-radius: 1.5rem; + + svg { + transition: transform 300ms ease-in-out; + transform: rotate(0deg); + ${({ $isOpen }) => + $isOpen && + css` + transform: rotate(180deg); + `} + } `; export const SelectWrapper = styled.div` diff --git a/src/components/home/searchFiltering/filteringContents/filtering/Filtering.tsx b/src/components/home/searchFiltering/filteringContents/filtering/Filtering.tsx index 28edb79a..585c7f94 100644 --- a/src/components/home/searchFiltering/filteringContents/filtering/Filtering.tsx +++ b/src/components/home/searchFiltering/filteringContents/filtering/Filtering.tsx @@ -73,7 +73,10 @@ export default function Filtering({ selects, defaultValue }: FilteringProps) { - + {changeValue} diff --git a/src/components/mypage/ContentTab.tsx b/src/components/mypage/ContentTab.tsx index e99abce3..9a2ab23e 100644 --- a/src/components/mypage/ContentTab.tsx +++ b/src/components/mypage/ContentTab.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import * as S from './ContentTab.styled'; import { Link, Outlet, useLocation } from 'react-router-dom'; import { ROUTES } from '../../constants/routes'; -import MovedInquiredLink from '../common/customerService/MoveInquiredLink'; +import MovedInquiredLink from '../../pages/customerService/MoveInquiredLink'; interface Filter { title: string; diff --git a/src/components/mypage/activityLog/ActivityLog.tsx b/src/components/mypage/activityLog/ActivityLog.tsx index 50fab35f..3bf257a7 100644 --- a/src/components/mypage/activityLog/ActivityLog.tsx +++ b/src/components/mypage/activityLog/ActivityLog.tsx @@ -1,10 +1,10 @@ -import { activityFilter } from '../../../constants/myPageFilter'; +import { ACTIVITY_FILTER } from '../../../constants/myPageFilter'; import ContentTab from '../ContentTab'; export default function ActivityLog() { return ( <> - + ); } diff --git a/src/components/mypage/notifications/Notifications.tsx b/src/components/mypage/notifications/Notifications.tsx index 32fe1b85..dd0ed37d 100644 --- a/src/components/mypage/notifications/Notifications.tsx +++ b/src/components/mypage/notifications/Notifications.tsx @@ -1,8 +1,8 @@ -import { notificationFilter } from '../../../constants/myPageFilter'; +import { NOTIFICATION_FILTER } from '../../../constants/myPageFilter'; import ContentTab from '../ContentTab'; export default function Notifications() { return ( - + ); } diff --git a/src/components/userPage/joinedProject/UserJoinProject.tsx b/src/components/userPage/joinedProject/UserJoinProject.tsx index ba46642c..05acafbd 100644 --- a/src/components/userPage/joinedProject/UserJoinProject.tsx +++ b/src/components/userPage/joinedProject/UserJoinProject.tsx @@ -22,9 +22,9 @@ const UserJoinProject = () => { return ( - - 참여한 프로젝트 리스트 - + + 참여한 프로젝트 리스트 + {userJoinedProjectListData?.acceptedProjects && userJoinedProjectListData?.acceptedProjects?.length > 0 ? ( @@ -44,9 +44,9 @@ const UserJoinProject = () => { )} - - 기획한 프로젝트 리스트 - + + 기획한 프로젝트 리스트 + {userJoinedProjectListData?.ownProjects && userJoinedProjectListData?.ownProjects?.length > 0 ? ( diff --git a/src/constants/customerService.ts b/src/constants/customerService.ts new file mode 100644 index 00000000..295003d1 --- /dev/null +++ b/src/constants/customerService.ts @@ -0,0 +1,14 @@ +export const INQUIRY_CATEGORY = [ + { title: '사이트 오류' }, + { title: '공고모집' }, + { title: '제안하기' }, + { title: '기타' }, +] as const; + +export const EMPTY_IMAGE = + 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' as const; + +export const INQUIRY_MESSAGE = { + categoryDefault: '카테고리', + fileDefault: '선택된 파일이 없습니다.', +}; diff --git a/src/constants/myPageFilter.ts b/src/constants/myPageFilter.ts index a31a4dcb..67c6edbe 100644 --- a/src/constants/myPageFilter.ts +++ b/src/constants/myPageFilter.ts @@ -1,6 +1,6 @@ import { ROUTES } from './routes'; -export const notificationFilter = [ +export const NOTIFICATION_FILTER = [ { title: '전체', url: ``, id: 0 }, { title: '지원한 프로젝트', @@ -22,7 +22,7 @@ export const notificationFilter = [ }, ] as const; -export const activityFilter = [ +export const ACTIVITY_FILTER = [ { title: '내 댓글', url: ROUTES.comments, id: 0 }, { title: '내 문의글', url: ROUTES.activityInquiries, id: 1 }, ] as const; diff --git a/src/hooks/usePostInquiry.ts b/src/hooks/usePostInquiry.ts new file mode 100644 index 00000000..23520ae9 --- /dev/null +++ b/src/hooks/usePostInquiry.ts @@ -0,0 +1,16 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { postInquiry } from '../api/inquiry.api'; +import { AxiosError } from 'axios'; + +export const usePostInquiry = () => { + const queryClient = useQueryClient(); + + const mutate = useMutation({ + mutationFn: (formData) => postInquiry(formData), + onSuccess: () => { + queryClient.invalidateQueries(); + }, + }); + + return mutate; +}; diff --git a/src/models/inquiry.ts b/src/models/inquiry.ts new file mode 100644 index 00000000..6899e943 --- /dev/null +++ b/src/models/inquiry.ts @@ -0,0 +1,5 @@ +export interface InquiryFormData { + category: string; + title: string; + content: string; +} diff --git a/src/models/userProject.ts b/src/models/userProject.ts index f843a084..e1aa1ab0 100644 --- a/src/models/userProject.ts +++ b/src/models/userProject.ts @@ -27,7 +27,7 @@ export interface ApiAppliedProject extends ApiCommonType { export interface SelectUserProject { acceptedProjects: JoinedProject[]; - ownProjects: AppliedProject[]; + ownProjects: JoinedProject[]; } export interface ApiSelectUserProject extends ApiCommonType { diff --git a/src/components/common/customerService/CustomerServiceHeader.styled.ts b/src/pages/customerService/CustomerServiceHeader.styled.ts similarity index 100% rename from src/components/common/customerService/CustomerServiceHeader.styled.ts rename to src/pages/customerService/CustomerServiceHeader.styled.ts diff --git a/src/components/common/customerService/CustomerServiceHeader.tsx b/src/pages/customerService/CustomerServiceHeader.tsx similarity index 100% rename from src/components/common/customerService/CustomerServiceHeader.tsx rename to src/pages/customerService/CustomerServiceHeader.tsx diff --git a/src/components/common/customerService/MoveInquiredLink.styled.ts b/src/pages/customerService/MoveInquiredLink.styled.ts similarity index 100% rename from src/components/common/customerService/MoveInquiredLink.styled.ts rename to src/pages/customerService/MoveInquiredLink.styled.ts diff --git a/src/components/common/customerService/MoveInquiredLink.tsx b/src/pages/customerService/MoveInquiredLink.tsx similarity index 77% rename from src/components/common/customerService/MoveInquiredLink.tsx rename to src/pages/customerService/MoveInquiredLink.tsx index 7361b8aa..331ea4ee 100644 --- a/src/components/common/customerService/MoveInquiredLink.tsx +++ b/src/pages/customerService/MoveInquiredLink.tsx @@ -1,4 +1,4 @@ -import { ROUTES } from '../../../constants/routes'; +import { ROUTES } from '../../constants/routes'; import * as S from './MoveInquiredLink.styled'; export default function MovedInquiredLink() { diff --git a/src/components/common/customerService/faq/FAQ.styled.ts b/src/pages/customerService/faq/FAQ.styled.ts similarity index 100% rename from src/components/common/customerService/faq/FAQ.styled.ts rename to src/pages/customerService/faq/FAQ.styled.ts diff --git a/src/components/common/customerService/faq/FAQ.tsx b/src/pages/customerService/faq/FAQ.tsx similarity index 100% rename from src/components/common/customerService/faq/FAQ.tsx rename to src/pages/customerService/faq/FAQ.tsx diff --git a/src/pages/customerService/inquiry/Inquiry.styled.ts b/src/pages/customerService/inquiry/Inquiry.styled.ts new file mode 100644 index 00000000..ba8f1e47 --- /dev/null +++ b/src/pages/customerService/inquiry/Inquiry.styled.ts @@ -0,0 +1,190 @@ +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; + } +`; diff --git a/src/pages/customerService/inquiry/Inquiry.tsx b/src/pages/customerService/inquiry/Inquiry.tsx new file mode 100644 index 00000000..6fc9322e --- /dev/null +++ b/src/pages/customerService/inquiry/Inquiry.tsx @@ -0,0 +1,175 @@ +import { Fragment } from 'react/jsx-runtime'; +import { + INQUIRY_CATEGORY, + INQUIRY_MESSAGE, +} from '../../../constants/customerService'; +import * as S from './Inquiry.styled'; +import { ChevronDownIcon } from '@heroicons/react/24/outline'; +import React, { useEffect, 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(false); + const [form, setForm] = useState({ + category: INQUIRY_MESSAGE.categoryDefault, + title: '', + content: '', + fileValue: INQUIRY_MESSAGE.fileDefault, + fileImage: null, + }); + + const handleSubmitInquiry = (e: React.FormEvent) => { + 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, + }); + } + }; + + const handleClickCategoryValue = (category: string) => { + setForm((prev) => ({ ...prev, category })); + setIsOpen((prev) => !prev); + }; + const handleChangeFile = (e: React.ChangeEvent) => { + 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 })); + }; + + useEffect(() => { + return () => { + if (form.fileImage) { + URL.revokeObjectURL(form.fileImage); + } + }; + }, [form.fileImage]); + + return ( + + + DevPals 문의하기 + + + + + + setIsOpen((prev) => !prev)} + $isOpen={isOpen} + > + {form.category} + + + {isOpen && ( + + {INQUIRY_CATEGORY.map((category) => ( + + handleClickCategoryValue(category.title)} + > + {category.title} + + + ))} + + )} + + + setForm((prev) => ({ ...prev, title: e.target.value })) + } + /> + + + + setForm((prev) => ({ ...prev, content: e.target.value })) + } + > + + 파일찾기 + {form.fileValue} + handleChangeFile(e)} + /> + {form.fileImage && } + + + + 제출 + + + + + ); +} diff --git a/src/components/common/customerService/notice/Notice.styled.ts b/src/pages/customerService/notice/Notice.styled.ts similarity index 100% rename from src/components/common/customerService/notice/Notice.styled.ts rename to src/pages/customerService/notice/Notice.styled.ts diff --git a/src/components/common/customerService/notice/Notice.tsx b/src/pages/customerService/notice/Notice.tsx similarity index 100% rename from src/components/common/customerService/notice/Notice.tsx rename to src/pages/customerService/notice/Notice.tsx diff --git a/src/routes/AppRoutes.tsx b/src/routes/AppRoutes.tsx index f4ea88fd..c4f80484 100644 --- a/src/routes/AppRoutes.tsx +++ b/src/routes/AppRoutes.tsx @@ -19,10 +19,9 @@ const ChangePassword = lazy( const Main = lazy(() => import('../pages/main/Index')); const Layout = lazy(() => import('../components/common/layout/Layout')); const Home = lazy(() => import('../pages/home/Home')); -const FAQ = lazy(() => import('../components/common/customerService/faq/FAQ')); -const Notice = lazy( - () => import('../components/common/customerService/notice/Notice') -); +const FAQ = lazy(() => import('../pages/customerService/faq/FAQ')); +const Notice = lazy(() => import('../pages/customerService/notice/Notice')); +const Inquiry = lazy(() => import('../pages/customerService/inquiry/Inquiry')); const MyPage = lazy(() => import('../pages/mypage/MyPage')); const UserPage = lazy(() => import('../pages/userpage/UserPage')); const Apply = lazy(() => import('../pages/apply/ApplyStep')); @@ -132,6 +131,18 @@ const AppRoutes = () => { ), }, + { + path: ROUTES.inquiry, + element: ( + + + + + + + + ), + }, { path: ROUTES.createProject, element: (