diff --git a/src/@types/styles/icon.ts b/src/@types/styles/icon.ts index 9daadbcf..c3ee9766 100644 --- a/src/@types/styles/icon.ts +++ b/src/@types/styles/icon.ts @@ -27,5 +27,8 @@ export type IconKind = | 'warning' | 'account' | 'language' + | 'keyboard' + | 'exclamation' + | 'bell' | 'myLocation' | 'location'; diff --git a/src/App.tsx b/src/App.tsx index 01c9c664..c35b9048 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import Header from '@components/Header'; import Announcement from '@pages/Announcement'; import FAQPage from '@pages/FAQ'; import Home from '@pages/Home'; +import KeywordSubscribe from '@pages/KeywordSubscribe'; import MajorDecision from '@pages/MajorDecision'; import MapPage from '@pages/Map'; import My from '@pages/My'; @@ -24,6 +25,7 @@ const App = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/apis/subscribe/subscribeKeyword.ts b/src/apis/subscribe/subscribeKeyword.ts new file mode 100644 index 00000000..a9dd9740 --- /dev/null +++ b/src/apis/subscribe/subscribeKeyword.ts @@ -0,0 +1,40 @@ +import http from '@apis/http'; + +export const fetchSubscribeKeyword = async () => { + const userToken = localStorage.getItem('subscribe'); + if (!userToken) return; + + const res = await http.get( + '/api/subscription/keyword?userToken=' + userToken, + ); + + return res.data; +}; + +export const postSubscribeKeyword = async (keyword: string) => { + const userToken = localStorage.getItem('subscribe'); + if (!userToken) return; + + const res = await http.post('/api/subscription/keyword', { + data: { + subscription: JSON.parse(userToken), + keyword, + }, + }); + + return res; +}; + +export const deleteSubscribeKeyword = async (keyword: string) => { + const userToken = localStorage.getItem('subscribe'); + if (!userToken) return; + + const res = await http.delete('/api/subscription/keyword', { + data: { + subscription: JSON.parse(userToken), + keyword, + }, + }); + + return res; +}; diff --git a/src/components/Common/Icon/index.tsx b/src/components/Common/Icon/index.tsx index a2e26e92..f8fc140f 100644 --- a/src/components/Common/Icon/index.tsx +++ b/src/components/Common/Icon/index.tsx @@ -30,6 +30,9 @@ import { MdOutlineKeyboardArrowDown, MdAssignmentInd, MdLanguage, + MdKeyboard, + MdError, + MdDoorbell, MdOutlineLocationOn, } from 'react-icons/md'; @@ -62,6 +65,9 @@ const ICON: { [key in IconKind]: IconType } = { warning: MdOutlineError, account: MdAssignmentInd, language: MdLanguage, + keyboard: MdKeyboard, + exclamation: MdError, + bell: MdDoorbell, myLocation: MdOutlineMyLocation, location: MdOutlineLocationOn, }; diff --git a/src/constants/keyword.ts b/src/constants/keyword.ts new file mode 100644 index 00000000..be839f42 --- /dev/null +++ b/src/constants/keyword.ts @@ -0,0 +1,7 @@ +export const KEYWORD_PAGE = { + TITLE: '키워드 알림 설정', + SUB_TITLE: + '키워드를 설정하시면 키워드가 포함된 공지사항이 올라올 때마다 알림을 보내드려요.', + PLACEHOLDER: '키워드를 입력해주세요. (예 : 장학금)', + REGISTERED_KEYWORD: '등록한 키워드', +}; diff --git a/src/constants/toast-message.ts b/src/constants/toast-message.ts index 08f04ef3..d3c00065 100644 --- a/src/constants/toast-message.ts +++ b/src/constants/toast-message.ts @@ -3,6 +3,11 @@ const TOAST_MESSAGES = { OUT_OF_SHOOL: '학교 밖에서는 내 위치 정보를 제공하지 않아요!', SHARE_LOCATION: '위치 정보를 공유해 주세요', SEARCH_KEYWORD: '검색어를 입력해주세요', + NEED_SUBSCRIBE: '알림 설정을 먼저 해주세요', + NEED_MORE_TEXT: '2글자 이상 입력해주세요', + DUPLICATE_KEYWORD: '중복된 키워드가 설정되어있습니다', + EXCEED_SUBSCRIBE_MAX_COUNT: '키워드는 최대 5개까지 설정 가능합니다', + ERROR_MESSAGE: '일시적인 에러발생', } as const; export default TOAST_MESSAGES; diff --git a/src/hooks/useInput.ts b/src/hooks/useInput.ts new file mode 100644 index 00000000..ae807bda --- /dev/null +++ b/src/hooks/useInput.ts @@ -0,0 +1,19 @@ +import { ChangeEvent, useCallback, useState } from 'react'; + +const useInput = () => { + const [inputValue, setInputValue] = useState(''); + + const handleValue = useCallback((e: ChangeEvent) => { + const { value } = e.target; + + setInputValue(value); + }, []); + + const resetValue = useCallback(() => { + setInputValue(''); + }, []); + + return [inputValue, handleValue, resetValue] as const; +}; + +export default useInput; diff --git a/src/mocks/handlers/subscribeHandler.ts b/src/mocks/handlers/subscribeHandler.ts index 03542204..8bd5dcb2 100644 --- a/src/mocks/handlers/subscribeHandler.ts +++ b/src/mocks/handlers/subscribeHandler.ts @@ -2,10 +2,21 @@ import { SERVER_URL } from '@config/index'; import { RequestHandler, rest } from 'msw'; export const subscribeHandler: RequestHandler[] = [ - rest.post(SERVER_URL + '/api/subscription', (req, res, ctx) => { + rest.post(SERVER_URL + '/api/subscription/major', (req, res, ctx) => { return res(ctx.status(200)); }), - rest.delete(SERVER_URL + '/api/subscription', (req, res, ctx) => { + rest.delete(SERVER_URL + '/api/subscription/major', (req, res, ctx) => { + return res(ctx.status(200)); + }), + rest.get(SERVER_URL + '/api/subscription/keyword', (req, res, ctx) => { + const mockData = ['장학', '등록', '네이버']; + + return res(ctx.status(200), ctx.json(mockData)); + }), + rest.post(SERVER_URL + '/api/subscription/keyword', (req, res, ctx) => { + return res(ctx.status(200)); + }), + rest.delete(SERVER_URL + '/api/subscription/keyword', (req, res, ctx) => { return res(ctx.status(200)); }), ]; diff --git a/src/pages/KeywordSubscribe/components/RegisteredKeywordList.tsx b/src/pages/KeywordSubscribe/components/RegisteredKeywordList.tsx new file mode 100644 index 00000000..d70515f0 --- /dev/null +++ b/src/pages/KeywordSubscribe/components/RegisteredKeywordList.tsx @@ -0,0 +1,62 @@ +import { KEYWORD_PAGE } from '@constants/keyword'; +import styled from '@emotion/styled'; +import { THEME } from '@styles/ThemeProvider/theme'; + +interface RegisteredKeywordListProps { + keywords: string[]; + deleteKeyword: (keyword: string) => void; +} + +const RegisteredKeywordList = ({ + keywords, + deleteKeyword, +}: RegisteredKeywordListProps) => { + return ( + + {KEYWORD_PAGE.REGISTERED_KEYWORD} + {keywords && ( + + {keywords.map((keyword, index) => ( + + {keyword} + deleteKeyword(keyword)}> + X + + + ))} + + )} + + ); +}; + +export default RegisteredKeywordList; + +const Container = styled.div` + display: flex; + flex-direction: column; + padding: 5% 20px 0 20px; +`; + +const KeywordContainer = styled.div` + display: flex; + padding-top: 10px; + flex-wrap: wrap; +`; + +const KeywordWrapper = styled.div` + padding: 7px 7px 7px 14px; + border: 1px solid ${THEME.TEXT.GRAY}; + border-radius: 15px; + margin: 5px; + display: flex; + align-items: center; + font-size: 0.9rem; + box-shadow: rgba(0, 0, 0, 0.12) 0px 1px 3px, rgba(0, 0, 0, 0.24) 0px 1px 2px; +`; + +const KeywordCancel = styled.button` + background-color: transparent; + border: none; + line-height: 1px; +`; diff --git a/src/pages/KeywordSubscribe/index.tsx b/src/pages/KeywordSubscribe/index.tsx new file mode 100644 index 00000000..3514ee29 --- /dev/null +++ b/src/pages/KeywordSubscribe/index.tsx @@ -0,0 +1,143 @@ +import { + deleteSubscribeKeyword, + fetchSubscribeKeyword, + postSubscribeKeyword, +} from '@apis/subscribe/subscribeKeyword'; +import Button from '@components/Common/Button'; +import InformUpperLayout from '@components/InformUpperLayout'; +import { KEYWORD_PAGE } from '@constants/keyword'; +import TOAST_MESSAGES from '@constants/toast-message'; +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; +import useInput from '@hooks/useInput'; +import useToasts from '@hooks/useToast'; +import RegisteredKeywordList from '@pages/KeywordSubscribe/components/RegisteredKeywordList'; +import { THEME } from '@styles/ThemeProvider/theme'; +import { FormEvent, FormEventHandler, useEffect, useState } from 'react'; + +const KeywordSubscribe = () => { + const [keywords, setKeywords] = useState([]); + const [inputKeyword, setInputKeyword, resetKeywords] = useInput(); + const { addToast } = useToasts(); + + const fetchKeywords = async () => { + const data = await fetchSubscribeKeyword(); + if (data) setKeywords(data); + }; + + const deleteKeyword = async (keyword: string) => { + const res = await deleteSubscribeKeyword(keyword); + + if (res?.status !== 200) { + addToast(TOAST_MESSAGES.ERROR_MESSAGE); + return; + } + + setKeywords((prevKeyword) => prevKeyword.filter((key) => key !== keyword)); + }; + + const handleSubmit: FormEventHandler = ( + e: FormEvent, + ) => { + e.preventDefault(); + onClickSubmit(); + }; + + const onClickSubmit = async () => { + if (keywords.length > 4) { + addToast(TOAST_MESSAGES.EXCEED_SUBSCRIBE_MAX_COUNT); + resetKeywords(); + return; + } + + if (inputKeyword.length < 2) { + addToast(TOAST_MESSAGES.NEED_MORE_TEXT); + resetKeywords(); + return; + } + + if (keywords.includes(inputKeyword)) { + addToast(TOAST_MESSAGES.DUPLICATE_KEYWORD); + resetKeywords(); + return; + } + + const res = await postSubscribeKeyword(inputKeyword); + if (res && res.status === 200) { + setKeywords((prevKeywords) => [...prevKeywords, inputKeyword]); + resetKeywords(); + return; + } + + addToast(TOAST_MESSAGES.ERROR_MESSAGE); + }; + + useEffect(() => { + fetchKeywords(); + }, []); + + const checkIsAvailable = () => inputKeyword.length > 1; + + return ( + <> + + + + + + + + + + + + + ); +}; + +export default KeywordSubscribe; + +const InputWrapper = styled.form` + padding: 0 20px 0 20px; + position: relative; +`; + +const KeywordInput = styled.input` + width: 90%; + border: none; + border-bottom: 1.5px solid; + padding: 10px; + + &:focus { + border: none; + outline: none; + border-bottom: 1.5px solid; + } +`; diff --git a/src/pages/My/index.test.tsx b/src/pages/My/index.test.tsx index b92eb2d8..dcd787b0 100644 --- a/src/pages/My/index.test.tsx +++ b/src/pages/My/index.test.tsx @@ -1,6 +1,7 @@ import Modal from '@components/Common/Modal'; import MajorProvider from '@components/Providers/MajorProvider'; import ModalsProvider from '@components/Providers/ModalsProvider'; +import ToastsProvider from '@components/Providers/ToastsProvider'; import { MODAL_BUTTON_MESSAGE, MODAL_MESSAGE } from '@constants/modal-messages'; import useModals from '@hooks/useModals'; import { render, screen } from '@testing-library/react'; @@ -64,7 +65,9 @@ describe('마이 페이지 동작 테스트', () => { render( - + + + , { wrapper: MemoryRouter }, @@ -72,6 +75,7 @@ describe('마이 페이지 동작 테스트', () => { const majorEditButton = screen.getByText('학과 선택하러가기'); await userEvent.click(majorEditButton); + expect(mockRouterTo).toHaveBeenCalledWith('/major-decision'); }); }); diff --git a/src/pages/My/index.tsx b/src/pages/My/index.tsx index 567dae49..ed04630e 100644 --- a/src/pages/My/index.tsx +++ b/src/pages/My/index.tsx @@ -5,12 +5,14 @@ import Icon from '@components/Common/Icon'; import Modal from '@components/Common/Modal'; import { SERVER_URL } from '@config/index'; import { MODAL_BUTTON_MESSAGE, MODAL_MESSAGE } from '@constants/modal-messages'; +import TOAST_MESSAGES from '@constants/toast-message'; import { css } from '@emotion/react'; import styled from '@emotion/styled'; import urlBase64ToUint8Array from '@hooks/urlBase64ToUint8Array'; import useMajor from '@hooks/useMajor'; import useModals from '@hooks/useModals'; import useRouter from '@hooks/useRouter'; +import useToasts from '@hooks/useToast'; import { THEME } from '@styles/ThemeProvider/theme'; import { MouseEventHandler, useEffect, useState } from 'react'; @@ -20,6 +22,7 @@ const My = () => { const { major } = useMajor(); const { routerTo } = useRouter(); const { openModal } = useModals(); + const { addToast } = useToasts(); const routerToMajorDecision = () => routerTo('/major-decision'); @@ -149,42 +152,63 @@ const My = () => { return ( <> - 마이페이지 - - - - {major ? ( - <> -
{major}
- {' '} - - ) : ( -
routerToMajorDecision()} - css={css` - opacity: 0.5; - width: 100%; - `} - > - 학과 선택하러가기 -
- )} -
- - 학과 공지사항 알림받기 - - -
-
+ + 마이페이지 + + {major ? ( + <> +
{major}
+ {' '} + + ) : ( +
routerToMajorDecision()} + css={css` + opacity: 0.5; + width: 100%; + `} + > + 학과 선택하러가기 +
+ )} +
+ + 알림 설정 + + +
+ + 관리 + + + 알림 설정 + + {/* 토스트 메시지는 실제 사용되는 기능은 아니지만 혹시 구독을 안한 유저가 접근하려고 할 때 접근을 막고 토스트메시지를 보여주기 위해 존재 */} + + subscribe + ? routerTo('/keyword') + : addToast(TOAST_MESSAGES.NEED_SUBSCRIBE) + } + > + + 키워드 알림 설정 + + routerTo('/faq')}> + + 자주 묻는 질문 + +