diff --git a/.github/workflows/frontend_dev_cd.yml b/.github/workflows/frontend_dev_cd.yml index 8cdf3f92f..e3c1ddd70 100644 --- a/.github/workflows/frontend_dev_cd.yml +++ b/.github/workflows/frontend_dev_cd.yml @@ -29,7 +29,9 @@ jobs: - name: Setup environment variables run: | - echo "REACT_APP_BASE_URL=${{ secrets.REACT_APP_DEV_BASE_URL }}" >> .env + echo "REACT_APP_BASE_URL=${{ secrets.REACT_APP_DEV_BASE_URL }} + REACT_APP_CHANNELTALK_KEY=${{ secrets.REACT_APP_CHANNELTALK_KEY }} + " >> .env - name: Install Dependancies run: npm install @@ -45,7 +47,7 @@ jobs: deploy: needs: build-and-upload - runs-on: [self-hosted, Linux, ARM64] + runs-on: [self-hosted, Linux, ARM64, dev] steps: - name: Remove previous version app diff --git a/.github/workflows/frontend_prod_cd.yml b/.github/workflows/frontend_prod_cd.yml index 5aa97e89a..f8c8bc0af 100644 --- a/.github/workflows/frontend_prod_cd.yml +++ b/.github/workflows/frontend_prod_cd.yml @@ -29,7 +29,9 @@ jobs: - name: Setup environment variables run: | - echo "REACT_APP_BASE_URL=${{ secrets.REACT_APP_PROD_BASE_URL }}" >> .env + echo "REACT_APP_BASE_URL=${{ secrets.REACT_APP_PROD_BASE_URL }} + REACT_APP_CHANNELTALK_KEY=${{ secrets.REACT_APP_CHANNELTALK_KEY }} + " >> .env - name: Install Dependancies run: npm install diff --git a/frontend/.gitignore b/frontend/.gitignore index 0771922dd..ba755ef8a 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -1,2 +1,4 @@ node_modules -/dist \ No newline at end of file +/dist + +.env \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b0156f3c9..18ac2cb8d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,6 +1,6 @@ { "name": "baton", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { @@ -37,6 +37,7 @@ "@types/jest": "^29.5.3", "@types/styled-components": "^5.1.26", "babel-loader": "^9.1.2", + "chromatic": "^7.2.0", "eslint": "^8.44.0", "eslint-config-prettier": "^8.8.0", "eslint-plugin-prettier": "^4.2.1", @@ -9498,6 +9499,17 @@ "node": ">=10" } }, + "node_modules/chromatic": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/chromatic/-/chromatic-7.2.0.tgz", + "integrity": "sha512-EbuvmsM6XAVFC4EQpqR2AT2PaXY4IS8qWxxg6N10AhpRulfX2b2AtW1hUc88cCosRyztd6esxkBdj3FSKR7zVw==", + "dev": true, + "bin": { + "chroma": "dist/bin.js", + "chromatic": "dist/bin.js", + "chromatic-cli": "dist/bin.js" + } + }, "node_modules/chrome-trace-event": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 67a3565b3..69c102ed4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "baton", - "version": "1.0.0", + "version": "1.1.0", "description": "코드 리뷰를 주고 받을 수 있는 서비스", "main": "index.js", "scripts": { @@ -8,7 +8,8 @@ "build": "webpack --config webpack/webpack.prod.js", "test": "jest", "storybook": "storybook dev -p 6006", - "build-storybook": "storybook build" + "build-storybook": "storybook build", + "chromatic": "npx chromatic --project-token=chpt_2be44b6342a5cae" }, "keywords": [], "author": "", @@ -42,6 +43,7 @@ "@types/jest": "^29.5.3", "@types/styled-components": "^5.1.26", "babel-loader": "^9.1.2", + "chromatic": "^7.2.0", "eslint": "^8.44.0", "eslint-config-prettier": "^8.8.0", "eslint-plugin-prettier": "^4.2.1", @@ -65,5 +67,7 @@ "extends": [ "plugin:storybook/recommended" ] - } + }, + "readme": "ERROR: No README data found!", + "_id": "baton@1.0.0" } diff --git a/frontend/public/favicon.png b/frontend/public/favicon.png new file mode 100644 index 000000000..b0b840ab4 Binary files /dev/null and b/frontend/public/favicon.png differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ff27f2011..ca912bd7b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,15 +1,31 @@ -import React from 'react'; +import React, { useState } from 'react'; import { styled } from 'styled-components'; import { Outlet } from 'react-router-dom'; -import { useToken } from './hooks/useToken'; import ToastProvider from './contexts/ToastContext'; +import ChannelService from './ChannelService'; +import { CHANNEL_SERVICE_KEY } from './constants'; +import { useLogin } from './hooks/useLogin'; +import LoadingPage from './pages/LoadingPage'; const App = () => { - const { validateToken } = useToken(); + const { checkLoginToken } = useLogin(); + const [isLoading, setIsLoading] = useState(true); - validateToken(); + checkLoginToken().finally(() => { + setIsLoading(false); + }); - return ( + ChannelService.loadScript(); + + if (CHANNEL_SERVICE_KEY) { + ChannelService.boot({ + pluginKey: CHANNEL_SERVICE_KEY, + }); + } + + return isLoading ? ( + + ) : ( diff --git a/frontend/src/ChannelService.ts b/frontend/src/ChannelService.ts new file mode 100644 index 000000000..30a6f220b --- /dev/null +++ b/frontend/src/ChannelService.ts @@ -0,0 +1,201 @@ +declare global { + interface Window { + ChannelIO?: IChannelIO; + ChannelIOInitialized?: boolean; + } +} + +interface IChannelIO { + c?: (...args: any) => void; + q?: [methodName: string, ...args: any[]][]; + (...args: any): void; +} + +interface BootOption { + appearance?: string; + customLauncherSelector?: string; + hideChannelButtonOnBoot?: boolean; + hidePopup?: boolean; + language?: string; + memberHash?: string; + memberId?: string; + pluginKey: string; + profile?: Profile; + trackDefaultEvent?: boolean; + trackUtmSource?: boolean; + unsubscribe?: boolean; + unsubscribeEmail?: boolean; + unsubscribeTexting?: boolean; + zIndex?: number; +} + +interface Callback { + (error: Error | null, user: CallbackUser | null): void; +} + +interface CallbackUser { + alert: number; + avatarUrl: string; + id: string; + language: string; + memberId: string; + name?: string; + profile?: Profile | null; + tags?: string[] | null; + unsubscribeEmail: boolean; + unsubscribeTexting: boolean; +} + +interface UpdateUserInfo { + language?: string; + profile?: Profile | null; + profileOnce?: Profile; + tags?: string[] | null; + unsubscribeEmail?: boolean; + unsubscribeTexting?: boolean; +} + +interface Profile { + [key: string]: string | number | boolean | null | undefined; +} + +interface FollowUpProfile { + name?: string | null; + mobileNumber?: string | null; + email?: string | null; +} + +interface EventProperty { + [key: string]: string | number | boolean | null | undefined; +} + +type Appearance = 'light' | 'dark' | 'system' | null; + +class ChannelService { + constructor() { + this.loadScript(); + } + + loadScript() { + (function () { + var w = window; + if (w.ChannelIO) { + return w.console.error('ChannelIO script included twice.'); + } + var ch: IChannelIO = function () { + ch.c?.(arguments); + }; + ch.q = []; + ch.c = function (args) { + ch.q?.push(args); + }; + w.ChannelIO = ch; + function l() { + if (w.ChannelIOInitialized) { + return; + } + w.ChannelIOInitialized = true; + var s = document.createElement('script'); + s.type = 'text/javascript'; + s.async = true; + s.src = 'https://cdn.channel.io/plugin/ch-plugin-web.js'; + var x = document.getElementsByTagName('script')[0]; + if (x.parentNode) { + x.parentNode.insertBefore(s, x); + } + } + if (document.readyState === 'complete') { + l(); + } else { + w.addEventListener('DOMContentLoaded', l); + w.addEventListener('load', l); + } + })(); + } + + boot(option: BootOption, callback?: Callback) { + window.ChannelIO?.('boot', option, callback); + } + + shutdown() { + window.ChannelIO?.('shutdown'); + } + + showMessenger() { + window.ChannelIO?.('showMessenger'); + } + + hideMessenger() { + window.ChannelIO?.('hideMessenger'); + } + + openChat(chatId?: string | number, message?: string) { + window.ChannelIO?.('openChat', chatId, message); + } + + track(eventName: string, eventProperty?: EventProperty) { + window.ChannelIO?.('track', eventName, eventProperty); + } + + onShowMessenger(callback: () => void) { + window.ChannelIO?.('onShowMessenger', callback); + } + + onHideMessenger(callback: () => void) { + window.ChannelIO?.('onHideMessenger', callback); + } + + onBadgeChanged(callback: (unread: number, alert: number) => void) { + window.ChannelIO?.('onBadgeChanged', callback); + } + + onChatCreated(callback: () => void) { + window.ChannelIO?.('onChatCreated', callback); + } + + onFollowUpChanged(callback: (profile: FollowUpProfile) => void) { + window.ChannelIO?.('onFollowUpChanged', callback); + } + + onUrlClicked(callback: (url: string) => void) { + window.ChannelIO?.('onUrlClicked', callback); + } + + clearCallbacks() { + window.ChannelIO?.('clearCallbacks'); + } + + updateUser(userInfo: UpdateUserInfo, callback?: Callback) { + window.ChannelIO?.('updateUser', userInfo, callback); + } + + addTags(tags: string[], callback?: Callback) { + window.ChannelIO?.('addTags', tags, callback); + } + + removeTags(tags: string[], callback?: Callback) { + window.ChannelIO?.('removeTags', tags, callback); + } + + setPage(page: string) { + window.ChannelIO?.('setPage', page); + } + + resetPage() { + window.ChannelIO?.('resetPage'); + } + + showChannelButton() { + window.ChannelIO?.('showChannelButton'); + } + + hideChannelButton() { + window.ChannelIO?.('hideChannelButton'); + } + + setAppearance(appearance: Appearance) { + window.ChannelIO?.('setAppearance', appearance); + } +} + +export default new ChannelService(); diff --git a/frontend/src/api/fetch.ts b/frontend/src/api/fetch.ts index eb14e0913..a3da0602b 100644 --- a/frontend/src/api/fetch.ts +++ b/frontend/src/api/fetch.ts @@ -1,17 +1,15 @@ import { BATON_BASE_URL } from '@/constants'; -interface customError { - errorCode: string; - message: string; -} +import { APIError } from '@/types/error'; const fetchAPI = async (url: string, options: RequestInit) => { const response = await fetch(`${BATON_BASE_URL}${url}`, options); if (!response.ok) { - const error: customError = await response.json(); + const error: APIError = await response.json(); - throw new Error(error.message); + throw error; } + return response; }; @@ -33,7 +31,7 @@ export const getRequest = async (url: string, token?: string) => { return response; }; -export const postRequest = async (url: string, token: string, body: BodyInit) => { +export const postRequest = async (url: string, token: string, body?: BodyInit) => { const response = await fetchAPI(url, { method: 'POST', headers: { @@ -46,6 +44,19 @@ export const postRequest = async (url: string, token: string, body: BodyInit) => return response; }; +export const postRequestWithCookie = async (url: string, token: string) => { + const response = await fetchAPI(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + credentials: 'include', + }, + }); + + return response; +}; + export const deleteRequest = async (url: string, token: string) => { const response = await fetchAPI(url, { method: 'DELETE', diff --git a/frontend/src/assets/arrow-icon.tsx b/frontend/src/assets/arrow-icon.tsx new file mode 100644 index 000000000..20062008f --- /dev/null +++ b/frontend/src/assets/arrow-icon.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +export const UpArrowIcon = () => { + return ( + + + + ); +}; + +export const DownArrowIcon = () => { + return ( + + + + ); +}; diff --git a/frontend/src/assets/banner/banner_background.png b/frontend/src/assets/banner/banner_background.png new file mode 100644 index 000000000..b2abef495 Binary files /dev/null and b/frontend/src/assets/banner/banner_background.png differ diff --git a/frontend/src/assets/banner/event_banner.webp b/frontend/src/assets/banner/event_banner.webp new file mode 100644 index 000000000..1927db1af Binary files /dev/null and b/frontend/src/assets/banner/event_banner.webp differ diff --git a/frontend/src/assets/banner/event_banner_post.png b/frontend/src/assets/banner/event_banner_post.png new file mode 100644 index 000000000..9aa2874ae Binary files /dev/null and b/frontend/src/assets/banner/event_banner_post.png differ diff --git a/frontend/src/assets/filter-icon.svg b/frontend/src/assets/filter-icon.svg new file mode 100644 index 000000000..4e8fce603 --- /dev/null +++ b/frontend/src/assets/filter-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/assets/logo-image-mobile.svg b/frontend/src/assets/logo-image-mobile.svg new file mode 100644 index 000000000..e02f00d0f --- /dev/null +++ b/frontend/src/assets/logo-image-mobile.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/src/assets/tag-icon.svg b/frontend/src/assets/tag-icon.svg new file mode 100644 index 000000000..1769dbc61 --- /dev/null +++ b/frontend/src/assets/tag-icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/src/assets/technicalLabelIcon.tsx b/frontend/src/assets/technicalLabelIcon.tsx index c19fae7ae..38895d685 100644 --- a/frontend/src/assets/technicalLabelIcon.tsx +++ b/frontend/src/assets/technicalLabelIcon.tsx @@ -88,7 +88,7 @@ export const SpringIcon = (props: React.SVGProps) => { export const JavaIconWhite = (props: React.SVGProps) => { return ( - + { + const { goToNoticePage } = usePageRouter(); + + const handleBannerButton = () => { + goToNoticePage(); + }; + + return ( + + + + ); +}; + +export default Banner; + +const S = { + BannerBackground: styled.div` + display: flex; + justify-content: center; + align-items: center; + + width: 100%; + height: 292px; + + background-image: url(${bannerBackground}); + + @media (max-width: 768px) { + height: 120px; + } + `, + + BannerContents: styled.img` + width: 904px; + height: 240px; + + cursor: pointer; + + @media (max-width: 768px) { + width: 340px; + height: 90px; + } + `, +}; diff --git a/frontend/src/components/ConfirmModal/ConfirmModal.tsx b/frontend/src/components/ConfirmModal/ConfirmModal.tsx index f040ecb61..65da9f97e 100644 --- a/frontend/src/components/ConfirmModal/ConfirmModal.tsx +++ b/frontend/src/components/ConfirmModal/ConfirmModal.tsx @@ -2,9 +2,10 @@ import React, { useEffect } from 'react'; import Modal from '../common/Modal/Modal'; import Button from '../common/Button/Button'; import { styled } from 'styled-components'; +import useViewport from '@/hooks/useViewport'; interface Props { - contents: string; + contents: React.ReactNode; closeModal: () => void; handleClickConfirmButton: () => void; confirmText?: string; @@ -12,6 +13,8 @@ interface Props { } const ConfirmModal = ({ contents, closeModal, handleClickConfirmButton, confirmText, cancelText }: Props) => { + const { isMobile } = useViewport(); + useEffect(() => { const handleEscapeKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') closeModal(); @@ -25,7 +28,7 @@ const ConfirmModal = ({ contents, closeModal, handleClickConfirmButton, confirmT }, []); return ( - + {contents} @@ -36,7 +39,7 @@ const ConfirmModal = ({ contents, closeModal, handleClickConfirmButton, confirmT colorTheme="WHITE" width="134px" height="35px" - fontSize="16px" + fontSize={isMobile ? '12px' : '14px'} fontWeight={700} onClick={handleClickConfirmButton} > @@ -59,12 +62,18 @@ const S = { width: 100%; height: 100%; + padding: 20px 0; + + @media (max-width: 768px) { + padding: 10px 20px; + } `, ConfirmMessage: styled.p` margin-bottom: 40px; - font-size: 18px; + white-space: pre-wrap; + line-height: 1.5; `, ButtonContainer: styled.div` diff --git a/frontend/src/components/ErrorBoundary/LoginErrorBoundary.tsx b/frontend/src/components/ErrorBoundary/LoginErrorBoundary.tsx new file mode 100644 index 000000000..642827522 --- /dev/null +++ b/frontend/src/components/ErrorBoundary/LoginErrorBoundary.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { CustomError } from '@/types/error'; +import LoginError from '../LoginError'; + +interface Props { + children: React.ReactNode; +} + +interface State { + hasError: boolean; + error: Error | CustomError | null; +} + +class LoginErrorBoundary extends React.Component { + constructor(props: Props) { + super(props); + this.state = { + hasError: false, + error: null, + }; + } + + static getDerivedStateFromError(error: Error | CustomError) { + return { hasError: true, error: error }; + } + + render() { + const { hasError, error } = this.state; + + if (!hasError) return this.props.children; + + return {this.props.children}; + } +} + +export default LoginErrorBoundary; diff --git a/frontend/src/components/GuideContents/GuideContents.tsx b/frontend/src/components/GuideContents/GuideContents.tsx new file mode 100644 index 000000000..47aa14fc0 --- /dev/null +++ b/frontend/src/components/GuideContents/GuideContents.tsx @@ -0,0 +1,187 @@ +import React, { useState } from 'react'; +import { css, keyframes, styled } from 'styled-components'; +import useViewport from '@/hooks/useViewport'; + +interface Props extends React.HTMLProps { + contents: string; + title: string; +} + +const GuideContents = ({ contents, title, ...rest }: Props) => { + const { isMobile } = useViewport(); + + return ( + + + + {title} + + + {contents} + + ); +}; + +export default GuideContents; + +const S = { + Container: styled.div` + display: flex; + justify-content: center; + flex-direction: column; + gap: 10px; + `, + + HeaderContainer: styled.div` + display: flex; + justify-content: space-between; + + padding: 5px 10px; + `, + + GuideButton: styled.button` + display: flex; + justify-content: center; + align-items: center; + + width: 50px; + height: 26px; + padding: 0 10px; + border: 1px solid var(--baton-red); + border-radius: 12px; + + background-color: transparent; + + color: var(--baton-red); + font-size: 14px; + + cursor: pointer; + + @media (max-width: 768px) { + width: 45px; + height: 23px; + + font-size: 12px; + } + `, + + TitleContainer: styled.div` + display: flex; + align-items: center; + gap: 8px; + + @media (max-width: 768px) { + gap: 4px; + } + `, + + Title: styled.div` + position: relative; + + margin-left: 10px; + + font-size: 20px; + font-weight: 700; + + &::before { + position: absolute; + content: ''; + + bottom: 2.5px; + left: -18px; + height: 100%; + width: 4.5px; + border-radius: 2px; + + background-color: var(--baton-red); + } + + @media (max-width: 768px) { + font-size: 18px; + + height: 20px; + } + + @media (max-width: 400px) { + font-size: 16px; + } + `, + + RequiredIcon: styled.div` + display: flex; + justify-content: center; + + color: var(--baton-red); + font-size: 20px; + + @media (max-width: 768px) { + font-size: 16px; + } + `, + + TextContainer: styled.div` + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 15px; + + width: 100%; + border: var(--gray-500) 1px solid; + border-radius: 10px; + + overflow: hidden; + `, + + Contents: styled.div` + flex: 1; + + border: transparent 1px solid; + + line-height: 2; + font-size: 18px; + + white-space: pre-line; + + overflow: hidden; + + &::placeholder { + font-size: 18px; + } + + &:focus { + outline: 0; + } + + @media (max-width: 768px) { + font-size: 16px; + + &::placeholder { + font-size: 16px; + } + } + `, + + InputConfig: styled.div` + display: flex; + justify-content: space-between; + + padding: 8px 10px; + `, + + ErrorMessage: styled.div` + color: red; + font-size: 14px; + `, + + InputTextLength: styled.div<{ $isError: boolean }>` + display: flex; + flex-direction: column; + + font-size: 14px; + color: ${({ $isError }) => ($isError ? 'red' : 'var(--gray-400)')}; + `, + + Empty: styled.div` + width: 1px; + `, +}; diff --git a/frontend/src/components/GuideTextarea/GuideTextarea.tsx b/frontend/src/components/GuideTextarea/GuideTextarea.tsx new file mode 100644 index 000000000..8137fc814 --- /dev/null +++ b/frontend/src/components/GuideTextarea/GuideTextarea.tsx @@ -0,0 +1,445 @@ +import { DownArrowIcon, UpArrowIcon } from '@/assets/arrow-icon'; +import React, { useState } from 'react'; +import { css, keyframes, styled } from 'styled-components'; +import Modal from '../common/Modal/Modal'; +import useViewport from '@/hooks/useViewport'; + +interface Props extends React.HTMLProps { + inputTextState: string; + handleInputTextState: (e: React.ChangeEvent) => void; + title: string; + guideTexts?: readonly string[]; + isOptional?: boolean; + isErrorOnSubmit?: boolean; +} + +const ArrowIcon = (isOpen: boolean) => { + return isOpen ? ( + + + + ) : ( + + + + ); +}; + +const GuideTextarea = ({ + inputTextState, + handleInputTextState, + maxLength, + minLength = 20, + title, + guideTexts, + isOptional = false, + isErrorOnSubmit = false, + ...rest +}: Props) => { + const [isError, setIsError] = useState(isErrorOnSubmit); + const [isTextareaOpen, setIsTextareaOpen] = useState(!isOptional); + const [isModalOpen, setIsModalOpen] = useState(false); + const [IsAnimated, setIsAnimated] = useState(false); + + const { isMobile } = useViewport(); + + const handleBlur = () => { + if (isOptional) return; + + if (minLength > inputTextState.length) { + setIsError(true); + } + }; + + const handleFocus = () => { + setIsError(false); + }; + + const openTextarea = () => { + setIsTextareaOpen(true); + }; + + const closeTextarea = () => { + setIsTextareaOpen(false); + }; + + const toggleTextarea = () => { + if (!isOptional) return; + + if (!IsAnimated) { + setIsAnimated(true); + } + + isTextareaOpen ? closeTextarea() : openTextarea(); + }; + + const openModal = () => { + setIsModalOpen(true); + }; + + const closeModal = () => { + setIsModalOpen(false); + }; + + const handleKeyDownTitle = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + toggleTextarea(); + } + }; + + const handleClickGuideButton = (e: React.MouseEvent) => { + e.preventDefault(); + + openModal(); + }; + + return ( + + + + {title} + {isOptional ? ArrowIcon(isTextareaOpen) : *} + + {guideTexts && 예시}{' '} + + + + + {minLength && isError ? {minLength}자 이상 입력해주세요 : } + {maxLength && ( + + {inputTextState?.length ?? 0} / {maxLength} + + )} + + + {isModalOpen && ( + + + {title} + + {guideTexts?.map((text) => ( + {text} + ))} + + 확인 + + + )} + + ); +}; + +export default GuideTextarea; + +const open = keyframes` + 0% { + padding: 0 20px; + border: 1px solid var(--gray-300); + } + 100% { + height: 300px; + padding: 8px 20px; + border: 1px solid var(--gray-500); +} +`; + +const close = keyframes` + 0% { + height: 300px; + padding: 8px 20px; + order: 1px solid var(--gray-300); + } + 100% { + height: 0; + padding: 0px 20px; + border: 1px solid var(--gray-300); +} +`; + +const getAnimation = (isOpen: boolean, isAnimated: boolean) => { + if (!isAnimated) { + return css` + animation: none; + `; + } + + if (isOpen) { + return css` + animation: 0.3s ease-in ${open} both; + `; + } + + if (!isOpen) { + return css` + animation: 0.3s ease-in ${close} both; + `; + } +}; + +const S = { + Container: styled.div` + display: flex; + justify-content: center; + flex-direction: column; + gap: 10px; + `, + + HeaderContainer: styled.div` + display: flex; + justify-content: space-between; + + padding: 5px 10px; + `, + + GuideButton: styled.button` + display: flex; + justify-content: center; + align-items: center; + + width: 50px; + height: 26px; + padding: 0 10px; + border: 1px solid var(--baton-red); + border-radius: 12px; + + background-color: transparent; + + color: var(--baton-red); + font-size: 14px; + + cursor: pointer; + + @media (max-width: 768px) { + width: 45px; + height: 23px; + + font-size: 12px; + } + `, + + TitleContainer: styled.div<{ $isOptional: boolean }>` + display: flex; + align-items: end; + gap: 8px; + + cursor: ${({ $isOptional }) => ($isOptional ? 'pointer' : '')}; + + @media (max-width: 768px) { + gap: 4px; + } + `, + + Title: styled.div` + font-size: 20px; + font-weight: 700; + + @media (max-width: 768px) { + font-size: 18px; + + height: 20px; + } + + @media (max-width: 400px) { + font-size: 16px; + } + `, + + RequiredIcon: styled.div` + display: flex; + justify-content: center; + + color: var(--baton-red); + font-size: 20px; + + @media (max-width: 768px) { + font-size: 16px; + } + `, + + TextareaContainer: styled.div<{ + $isError: boolean; + $isAnimated: boolean; + $isOpen: boolean; + $isOptional: boolean; + }>` + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 15px; + + width: 100%; + height: ${({ $isOptional }) => ($isOptional ? 0 : '300px')}; + padding: ${({ $isOptional }) => ($isOptional ? '' : '8px 20px')}; + border: ${({ $isError, $isOpen }) => + $isError ? `var(--baton-red)` : $isOpen ? `var(--gray-500)` : `var(--gray-300)`} + 1px solid; + border-radius: 10px; + + box-shadow: ${({ $isError }) => + $isError && + `-3px -3px 3px rgba(56, 38, 38, 0.03), inset -3px -3px 3px rgba(220, 0, 0, 0.03), inset 3px 3px 3px rgba(220, 0, 0, 0.03), 3px 3px 3px rgba(220, 0, 0, 0.03)`}; + + overflow: hidden; + + ${({ $isOpen, $isAnimated }) => getAnimation($isOpen, $isAnimated)}; + `, + + Textarea: styled.textarea` + flex: 1; + + border: transparent 1px solid; + + line-height: 2; + font-size: 18px; + + resize: none; + + overflow: hidden; + + &::placeholder { + font-size: 18px; + } + + &:focus { + outline: 0; + } + + @media (max-width: 768px) { + font-size: 16px; + + &::placeholder { + font-size: 16px; + } + } + `, + + InputConfig: styled.div` + display: flex; + justify-content: space-between; + + padding: 8px 10px; + `, + + ErrorMessage: styled.div` + color: red; + font-size: 14px; + `, + + InputTextLength: styled.div<{ $isError: boolean }>` + display: flex; + flex-direction: column; + + font-size: 14px; + color: ${({ $isError }) => ($isError ? 'red' : 'var(--gray-400)')}; + `, + + Empty: styled.div` + width: 1px; + `, + + ModalContainer: styled.div` + display: flex; + flex-direction: column; + `, + + ModalTitle: styled.div` + display: flex; + justify-content: center; + + width: 100%; + padding: 15px; + padding-bottom: 25px; + border-bottom: 1px solid var(--gray-400); + + font-size: 22px; + font-weight: 700; + + @media (max-width: 768px) { + font-size: 20px; + } + `, + + ModalContents: styled.div` + display: flex; + flex-direction: column; + gap: 25px; + + height: 480px; + padding: 30px 10px; + + @media (max-width: 768px) { + gap: 20px; + + padding: 20px 0; + } + `, + + GuideText: styled.div` + position: relative; + display: flex; + flex-wrap: wrap; + + margin-left: 30px; + margin-right: 10px; + + font-size: 19px; + line-height: 1.4; + color: var(--gray-700); + + @media (max-width: 768px) { + font-size: 18px; + } + + &::before { + content: '•'; + display: block; + position: absolute; + left: -30px; + top: -3px; + + height: 100%; + + color: var(--gray-600); + font-size: 24px; + font-weight: 700; + } + `, + + ConfirmButton: styled.button` + display: flex; + justify-content: end; + + width: 100%; + padding: 0 20px; + + background-color: transparent; + + font-size: 18px; + color: var(--baton-red); + + cursor: pointer; + + @media (max-width: 768px) { + font-size: 16px; + } + `, +}; diff --git a/frontend/src/components/InputBox/InputBox.tsx b/frontend/src/components/InputBox/InputBox.tsx index 127747fd4..ca6d711cc 100644 --- a/frontend/src/components/InputBox/InputBox.tsx +++ b/frontend/src/components/InputBox/InputBox.tsx @@ -26,7 +26,7 @@ const InputBox = ({ {maxLength && ( - + {inputTextState.length ?? 0} / {maxLength} )} @@ -54,11 +54,11 @@ const S = { font-weight: ${({ $fontWeight }) => $fontWeight || '400'}; `, - InputTextLength: styled.div<{ $fontsize?: string | number }>` + InputTextLength: styled.div<{ $maxLengthFontSize?: string | number }>` display: flex; align-items: center; - font-size: ${({ $fontsize }) => $fontsize || '18px'}; + font-size: ${({ $maxLengthFontSize }) => $maxLengthFontSize || '18px'}; color: var(--gray-400); `, diff --git a/frontend/src/components/ListFilter/ListFilter.tsx b/frontend/src/components/ListFilter/ListFilter.tsx index 52264aef2..b86824dc8 100644 --- a/frontend/src/components/ListFilter/ListFilter.tsx +++ b/frontend/src/components/ListFilter/ListFilter.tsx @@ -6,9 +6,10 @@ interface Props { options: ListSelectOption[]; selectOption: (value: string | number) => void; width?: string; + fontSize?: string; } -const ListFilter = ({ options, selectOption, width }: Props) => { +const ListFilter = ({ options, selectOption, width, fontSize }: Props) => { const makeHandleClickOption = (value: string | number) => () => { if (options.filter((option) => option.value === value).length === 0) return; @@ -19,11 +20,11 @@ const ListFilter = ({ options, selectOption, width }: Props) => { {options.map((option) => ( - + {option.label} @@ -67,20 +68,31 @@ const S = { justify-content: space-between; width: ${({ $width }) => $width ?? '920px'}; + min-width: 320px; + padding: 0 15px; + + @media (max-width: 468px) { + padding: 0; + } `, FilterItem: styled.li` - width: 150px; + display: flex; + justify-content: center; + + @media (max-width: 768px) { + flex: 1; + } `, - FilterButton: styled.button<{ $isSelected: boolean }>` + FilterButton: styled.button<{ $isSelected: boolean; $fontSize?: string }>` display: flex; flex-direction: column; align-items: center; background-color: transparent; - font-size: 26px; + font-size: ${({ $fontSize }) => $fontSize ?? '26px'}; font-weight: 700; color: ${({ $isSelected }) => ($isSelected ? 'var(--baton-red)' : 'var(--gray-700)')}; diff --git a/frontend/src/components/LoginError.tsx b/frontend/src/components/LoginError.tsx new file mode 100644 index 000000000..64b65d05c --- /dev/null +++ b/frontend/src/components/LoginError.tsx @@ -0,0 +1,50 @@ +import { ERROR_DESCRIPTION, ERROR_TITLE } from '@/constants/message'; +import { ToastContext } from '@/contexts/ToastContext'; +import { useLogin } from '@/hooks/useLogin'; +import { usePageRouter } from '@/hooks/usePageRouter'; +import { CustomError } from '@/types/error'; +import React, { useContext } from 'react'; + +interface Props { + children: React.ReactNode; + error: Error | CustomError; +} + +const LoginError = ({ children, error }: Props) => { + const { showErrorToast } = useContext(ToastContext); + const { silentLogin } = useLogin(); + const { goToLoginPage } = usePageRouter(); + + if (error instanceof CustomError) { + // 기간이 만료된 JWT + if (error.errorCode === 'JW005') { + silentLogin(); + + return children; + } + + // 기간이 만료된 Refresh Token + if (error.errorCode === 'JW009') { + showErrorToast({ title: ERROR_TITLE.NO_PERMISSION, description: ERROR_DESCRIPTION.TOKEN_EXPIRATION }); + goToLoginPage(); + + return; + } + + // 이외 모든 로그인 관련 오류 + if (error.errorCode.includes('JW') || error.errorCode.includes('OA')) { + console.log('여기?', children?.toString()); + + showErrorToast({ title: ERROR_TITLE.NO_PERMISSION, description: ERROR_DESCRIPTION.NO_TOKEN }); + goToLoginPage(); + + return; + } + } + + showErrorToast({ title: ERROR_TITLE.ERROR, description: error?.message ?? '알 수 없는 오류' }); + + return children; +}; + +export default LoginError; diff --git a/frontend/src/components/MyPage/MyPagePostButton/MyPagePostButton.tsx b/frontend/src/components/MyPage/MyPagePostButton/MyPagePostButton.tsx index 3feb1de2a..066014baa 100644 --- a/frontend/src/components/MyPage/MyPagePostButton/MyPagePostButton.tsx +++ b/frontend/src/components/MyPage/MyPagePostButton/MyPagePostButton.tsx @@ -1,53 +1,52 @@ -import { patchRequest } from '@/api/fetch'; import Button from '@/components/common/Button/Button'; -import { ERROR_DESCRIPTION, ERROR_TITLE, TOAST_COMPLETION_MESSAGE, TOAST_ERROR_MESSAGE } from '@/constants/message'; +import { ERROR_DESCRIPTION, ERROR_TITLE, TOAST_COMPLETION_MESSAGE } from '@/constants/message'; import { ToastContext } from '@/contexts/ToastContext'; +import { useFetch } from '@/hooks/useFetch'; import { usePageRouter } from '@/hooks/usePageRouter'; -import { useToken } from '@/hooks/useToken'; +import useViewport from '@/hooks/useViewport'; + import { ReviewStatus } from '@/types/runnerPost'; -import React, { useContext, useMemo } from 'react'; +import React, { useContext } from 'react'; +import styled from 'styled-components'; interface Props { runnerPostId: number; reviewStatus: ReviewStatus; isRunner: boolean; supporterId?: number; + applicantCount: number; + handleDeletePost: (handleDeletePost: number) => void; } -const MyPagePostButton = ({ runnerPostId, reviewStatus, isRunner, supporterId }: Props) => { - const { goToMyPage, goToSupportSelectPage, goToSupporterFeedbackPage } = usePageRouter(); - const { getToken } = useToken(); - const { showCompletionToast, showErrorToast } = useContext(ToastContext); +const MyPagePostButton = ({ + runnerPostId, + reviewStatus, + isRunner, + supporterId, + applicantCount, + handleDeletePost, +}: Props) => { + const { goToSupportSelectPage, goToSupporterFeedbackPage } = usePageRouter(); - const token = useMemo(() => getToken()?.value, [getToken]); + const { isMobile } = useViewport(); - const cancelReview = () => { - if (!token) { - showErrorToast(TOAST_ERROR_MESSAGE.NO_TOKEN); - return; - } + const { patchRequestWithAuth } = useFetch(); + const { showCompletionToast, showErrorToast } = useContext(ToastContext); - patchRequest(`/posts/runner/${runnerPostId}/cancelation`, token) - .then(() => { - showCompletionToast(TOAST_COMPLETION_MESSAGE.REVIEW_CANCEL); + const cancelReview = () => { + patchRequestWithAuth(`/posts/runner/${runnerPostId}/cancelation`, async (response) => { + showCompletionToast(TOAST_COMPLETION_MESSAGE.REVIEW_CANCEL); - setTimeout(window.location.reload, 2000); - }) - .catch((error: Error) => showErrorToast({ description: error.message, title: ERROR_TITLE.REQUEST })); + handleDeletePost(runnerPostId); + }); }; const finishReview = () => { - if (!token) { - showErrorToast(TOAST_ERROR_MESSAGE.NO_TOKEN); - return; - } + patchRequestWithAuth(`/posts/runner/${runnerPostId}/done`, async (response) => { + showCompletionToast(TOAST_COMPLETION_MESSAGE.REVIEW_COMPLETE); - patchRequest(`/posts/runner/${runnerPostId}/done`, token) - .then(() => { - showCompletionToast(TOAST_COMPLETION_MESSAGE.REVIEW_COMPETE); - setTimeout(window.location.reload, 2000); - }) - .catch((error: Error) => showErrorToast({ description: error.message, title: ERROR_TITLE.REQUEST })); + handleDeletePost(runnerPostId); + }); }; const handleClickCancelReviewButton = (e: React.MouseEvent) => { @@ -80,33 +79,49 @@ const MyPagePostButton = ({ runnerPostId, reviewStatus, isRunner, supporterId }: switch (reviewStatus) { case 'NOT_STARTED': return ( - + + + ); case 'IN_PROGRESS': return isRunner ? null : ( - + + + ); case 'DONE': return isRunner ? ( - + + + ) : null; default: return null; @@ -114,3 +129,14 @@ const MyPagePostButton = ({ runnerPostId, reviewStatus, isRunner, supporterId }: }; export default MyPagePostButton; + +const S = { + PostButtonWrapper: styled.div` + width: 180px; + + @media (max-width: 768px) { + width: 100%; + margin-top: 18px; + } + `, +}; diff --git a/frontend/src/components/MyPage/MyPagePostItem/MyPagePostItem.tsx b/frontend/src/components/MyPage/MyPagePostItem/MyPagePostItem.tsx index 894ccae8c..e25d1c176 100644 --- a/frontend/src/components/MyPage/MyPagePostItem/MyPagePostItem.tsx +++ b/frontend/src/components/MyPage/MyPagePostItem/MyPagePostItem.tsx @@ -7,9 +7,11 @@ import eyeIcon from '@/assets/eye-icon.svg'; import applicantIcon from '@/assets/applicant-icon.svg'; import { MyPagePost } from '@/types/myPage'; import MyPagePostButton from '../MyPagePostButton/MyPagePostButton'; +import useViewport from '@/hooks/useViewport'; interface Props extends MyPagePost { isRunner: boolean; + handleDeletePost: (handleDeletePost: number) => void; } const MyPagePostItem = ({ @@ -22,47 +24,54 @@ const MyPagePostItem = ({ applicantCount, isRunner, supporterId, + handleDeletePost, }: Props) => { const { goToRunnerPostPage } = usePageRouter(); + const { isMobile } = useViewport(); + const handlePostClick = () => { goToRunnerPostPage(runnerPostId); }; return ( - + {title} - - {deadline} 까지 - - + + + {watchedCount} + + {applicantCount} + + + + {deadline.replace('T', ' ')} 까지 + + + + {tags.map((tag, index) => ( #{tag} ))} - - - - - - {watchedCount} - - {applicantCount} - - + - + ); }; @@ -72,10 +81,11 @@ export default MyPagePostItem; const S = { RunnerPostItemContainer: styled.li` display: flex; - justify-content: space-between; + flex-direction: column; + gap: 20px; - width: 1200px; - height: 206px; + min-width: 340px; + width: 100%; padding: 35px 40px; border: 0.5px solid var(--gray-500); @@ -83,6 +93,25 @@ const S = { box-shadow: 1px 2px 3px rgba(0, 0, 0, 0.2); cursor: pointer; + + @media (max-width: 768px) { + padding: 25px 27px; + gap: 12px; + } + + & button:hover { + transition: all 0.3s ease; + background-color: var(--baton-red); + color: var(--white-color); + } + `, + + SideContainer: styled.div` + display: flex; + justify-content: space-between; + align-items: end; + + gap: 12px; `, PostTitle: styled.p` @@ -90,6 +119,12 @@ const S = { font-size: 28px; font-weight: 700; + + @media (max-width: 768px) { + margin-bottom: 0; + + font-size: 16px; + } `, DeadLineContainer: styled.div` @@ -99,43 +134,39 @@ const S = { `, DeadLine: styled.p` - margin-bottom: 60px; + margin-bottom: 40px; color: var(--gray-600); - `, - TagContainer: styled.div` - & span { - margin-right: 10px; + @media (max-width: 768px) { + margin-bottom: 15px; - font-size: 14px; - color: var(--gray-600); + font-size: 12px; } `, - Tag: styled.span``, - LeftSideContainer: styled.div``, + TagContainer: styled.div``, - RightSideContainer: styled.div` - display: flex; - flex-direction: column; - justify-content: end; - gap: 15px; + Tag: styled.span` + margin-right: 10px; - & button:hover { - transition: all 0.3s ease; - background-color: var(--baton-red); - color: var(--white-color); + font-size: 14px; + color: var(--gray-600); + + @media (max-width: 768px) { + font-size: 14px; } `, - ChatViewContainer: styled.div` + BottomContainer: styled.div` display: flex; - justify-content: end; - - gap: 10px; + justify-content: space-between; + align-items: end; - font-size: 12px; + @media (max-width: 768px) { + flex-direction: column; + align-items: start; + } `, statisticsContainer: styled.div` @@ -154,9 +185,18 @@ const S = { width: 20px; margin-left: 8px; + + @media (max-width: 768px) { + width: 15px; + margin-left: 4px; + } `, statisticsText: styled.p` font-size: 14px; + + @media (max-width: 768px) { + font-size: 12px; + } `, }; diff --git a/frontend/src/components/MyPage/MyPagePostList/MyPagePostList.tsx b/frontend/src/components/MyPage/MyPagePostList/MyPagePostList.tsx index 4fc163b73..d0a2cf687 100644 --- a/frontend/src/components/MyPage/MyPagePostList/MyPagePostList.tsx +++ b/frontend/src/components/MyPage/MyPagePostList/MyPagePostList.tsx @@ -6,15 +6,16 @@ import MyPagePostItem from '../MyPagePostItem/MyPagePostItem'; interface Props { filteredPostList: MyPagePost[]; isRunner: boolean; + handleDeletePost: (handleDeletePost: number) => void; } -const MyPagePostList = ({ filteredPostList, isRunner }: Props) => { +const MyPagePostList = ({ filteredPostList, isRunner, handleDeletePost }: Props) => { if (filteredPostList?.length === 0) return

게시글 정보가 없습니다.

; return ( {filteredPostList?.map((item: MyPagePost) => ( - + ))} ); @@ -27,7 +28,12 @@ const S = { display: flex; flex-direction: column; align-items: center; - gap: 30px; + + width: 100%; + + @media (max-width: 768px) { + gap: 20px; + } `, }; diff --git a/frontend/src/components/PostTag/PostTag.tsx b/frontend/src/components/PostTag/PostTag.tsx index efbb6d986..4fe3e9c95 100644 --- a/frontend/src/components/PostTag/PostTag.tsx +++ b/frontend/src/components/PostTag/PostTag.tsx @@ -17,5 +17,9 @@ const S = { font-size: 18px; color: var(--gray-500); + + @media (max-width: 768px) { + font-size: 12px; + } `, }; diff --git a/frontend/src/components/PostTagList/PostTagList.tsx b/frontend/src/components/PostTagList/PostTagList.tsx index 37411b90a..461d99338 100644 --- a/frontend/src/components/PostTagList/PostTagList.tsx +++ b/frontend/src/components/PostTagList/PostTagList.tsx @@ -24,6 +24,10 @@ const S = { li:not(:last-child) { margin-right: 10px; + + @media (max-width: 768px) { + margin-right: 5px; + } } `, }; diff --git a/frontend/src/components/RunnerPost/RunnerPostFilter/RunnerPostFilter.tsx b/frontend/src/components/RunnerPost/RunnerPostFilter/RunnerPostFilter.tsx new file mode 100644 index 000000000..a79fe7c3f --- /dev/null +++ b/frontend/src/components/RunnerPost/RunnerPostFilter/RunnerPostFilter.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import { css, keyframes, styled } from 'styled-components'; +import { REVIEW_STATUS_LABEL_TEXT } from '@/constants'; + +interface Props { + reviewStatus: string; + handleClickRadioButton: (e: React.ChangeEvent) => void; +} + +const RunnerPostFilter = ({ reviewStatus, handleClickRadioButton }: Props) => { + return ( + + + {Object.entries(REVIEW_STATUS_LABEL_TEXT).map(([value, text]) => ( + + + {text} + + ))} + + + ); +}; + +export default RunnerPostFilter; + +const appear = keyframes` + 0% { + transform-origin:left; + transform: scaleX(0); + } + 100% { + transform-origin:left; + transform: scaleX(1); + } +`; + +const underLine = css` + content: ''; + margin-top: 5px; + height: 3px; + width: calc(100% + 4px); + border-radius: 1px; + + background-color: var(--baton-red); + + animation: 0.2s ease-in ${appear}; +`; + +const S = { + FilterContainer: styled.div``, + + LabelList: styled.li` + display: flex; + gap: 20px; + + list-style: none; + + @media (max-width: 768px) { + gap: 12px; + } + `, + + StatusLabel: styled.label` + display: flex; + + :hover { + cursor: pointer; + } + `, + + RadioButton: styled.input` + appearance: none; + `, + + Label: styled.div<{ $isSelected: boolean }>` + display: flex; + flex-direction: column; + align-items: center; + + height: 30px; + + background-color: transparent; + + font-size: 18px; + font-weight: 700; + color: ${({ $isSelected }) => ($isSelected ? 'var(--baton-red)' : 'var(--gray-600)')}; + + &::after { + ${({ $isSelected }) => ($isSelected ? underLine : null)} + } + + @media (max-width: 768px) { + height: 22px; + + font-size: 14px; + } + `, +}; diff --git a/frontend/src/components/RunnerPost/RunnerPostItem/RunnerPostItem.tsx b/frontend/src/components/RunnerPost/RunnerPostItem/RunnerPostItem.tsx index 23d14787a..bbf15c434 100644 --- a/frontend/src/components/RunnerPost/RunnerPostItem/RunnerPostItem.tsx +++ b/frontend/src/components/RunnerPost/RunnerPostItem/RunnerPostItem.tsx @@ -7,12 +7,15 @@ import Label from '@/components/common/Label/Label'; import { REVIEW_STATUS_LABEL_TEXT } from '@/constants'; import eyeIcon from '@/assets/eye-icon.svg'; import applicantIcon from '@/assets/applicant-icon.svg'; +import useViewport from '@/hooks/useViewport'; const RunnerPostItem = ({ runnerPostData: { runnerPostId, title, deadline, tags, runnerProfile, watchedCount, applicantCount, reviewStatus }, }: { runnerPostData: RunnerPost; }) => { + const { isMobile } = useViewport(); + const { goToRunnerPostPage } = usePageRouter(); const handlePostClick = () => { @@ -26,7 +29,9 @@ const RunnerPostItem = ({ {deadline.replace('T', ' ')} 까지 @@ -41,7 +46,11 @@ const RunnerPostItem = ({ {runnerProfile ? ( <> - + {runnerProfile.name} @@ -66,8 +75,9 @@ const S = { display: flex; justify-content: space-between; - width: 1200px; - height: 206px; + min-width: 340px; + width: 100%; + height: max-content; padding: 35px 40px; border: 0.5px solid var(--gray-500); @@ -81,6 +91,10 @@ const S = { transform: scale(1.015); outline: 1.5px solid var(--baton-red); } + + @media (max-width: 768px) { + padding: 25px 30px; + } `, PostTitle: styled.p` @@ -88,6 +102,10 @@ const S = { font-size: 28px; font-weight: 700; + + @media (max-width: 768px) { + font-size: 16px; + } `, DeadLineContainer: styled.div` @@ -100,6 +118,12 @@ const S = { margin-bottom: 60px; color: var(--gray-600); + + @media (max-width: 768px) { + margin-bottom: 40px; + + font-size: 12px; + } `, TagContainer: styled.div` @@ -110,9 +134,11 @@ const S = { color: var(--gray-600); } `, + Tag: styled.span``, LeftSideContainer: styled.div``, + RightSideContainer: styled.div` display: flex; flex-direction: column; @@ -134,6 +160,12 @@ const S = { font-size: 14px; text-align: center; + + @media (max-width: 768px) { + min-width: 30px; + + font-size: 12px; + } `, ChatViewContainer: styled.div` @@ -154,15 +186,27 @@ const S = { & > p { color: #a4a4a4; } + + @media (max-width: 768px) { + gap: 2px; + } `, statisticsImage: styled.img` width: 20px; margin-left: 8px; + + @media (max-width: 768px) { + width: 15px; + } `, statisticsText: styled.p` font-size: 14px; + + @media (max-width: 768px) { + font-size: 10px; + } `, }; diff --git a/frontend/src/components/RunnerPost/RunnerPostList/RunnerPostList.tsx b/frontend/src/components/RunnerPost/RunnerPostList/RunnerPostList.tsx index 36b0c992d..7e93cfeff 100644 --- a/frontend/src/components/RunnerPost/RunnerPostList/RunnerPostList.tsx +++ b/frontend/src/components/RunnerPost/RunnerPostList/RunnerPostList.tsx @@ -24,7 +24,12 @@ const S = { display: flex; flex-direction: column; align-items: center; - gap: 30px; + + width: 100%; + + @media (max-width: 768px) { + gap: 20px; + } `, }; diff --git a/frontend/src/components/RunnerPost/RunnerPostSearchBox/RunnerPostSearchBox.tsx b/frontend/src/components/RunnerPost/RunnerPostSearchBox/RunnerPostSearchBox.tsx new file mode 100644 index 000000000..9387ce656 --- /dev/null +++ b/frontend/src/components/RunnerPost/RunnerPostSearchBox/RunnerPostSearchBox.tsx @@ -0,0 +1,242 @@ +import React, { useRef, useState } from 'react'; +import TagIcon from '@/assets/tag-icon.svg'; +import { styled } from 'styled-components'; +import RunnerPostFilter from '../RunnerPostFilter/RunnerPostFilter'; +import { ReviewStatus } from '@/types/runnerPost'; +import { GetSearchTagResponse, Tag } from '@/types/tags'; +import { useFetch } from '@/hooks/useFetch'; + +interface Props { + reviewStatus: ReviewStatus; + setReviewStatus: React.Dispatch>; + tag: string; + setTag: React.Dispatch>; + searchedTags: Tag[]; + setSearchedTags: React.Dispatch>; + searchPosts: (reviewStatus: ReviewStatus, tag?: string) => void; +} + +const RunnerPostSearchBox = ({ + reviewStatus, + setReviewStatus, + tag, + setTag, + searchedTags, + setSearchedTags, + searchPosts, +}: Props) => { + const [isInputFocused, setIsInputFocused] = useState(false); + const [inputIndex, setInputIndex] = useState(0); + const [inputBuffer, setInputBuffer] = useState(''); + + const inputRefs = useRef([]); + const timer = useRef(null); + + const { getRequest } = useFetch(); + + const handleChangeInput = (e: React.ChangeEvent) => { + setTag(e.target.value); + setInputBuffer(e.target.value); + + if (timer.current) window.clearTimeout(timer.current); + + timer.current = window.setTimeout(() => { + searchTags(e.target.value); + }, 500); + }; + + const handleClickRadioButton = (e: React.ChangeEvent) => { + const clickedStatus = e.target.value as ReviewStatus; + + if (clickedStatus === reviewStatus) return; + + searchPosts(clickedStatus, tag); + setReviewStatus(clickedStatus); + }; + + const handleInputFocus = () => { + setIsInputFocused(true); + }; + + const handleInputBlur = () => { + setIsInputFocused(false); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + searchPosts(reviewStatus, tag); + + (document.activeElement as HTMLElement).blur(); + }; + + const handleClickSearchedTag = (e: React.MouseEvent) => { + searchPosts(reviewStatus, e.currentTarget.id); + + (document.activeElement as HTMLElement).blur(); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!['ArrowUp', 'ArrowDown', 'Enter'].includes(e.key)) { + inputRefs.current[0].focus(); + setInputIndex(0); + + return; + } + + if (e.nativeEvent.isComposing) return; + e.preventDefault(); + + switch (e.key) { + case 'ArrowUp': + handleArrowUp(); + break; + case 'ArrowDown': + handleArrowDown(); + break; + case 'Enter': + handleEnter(); + } + }; + + const handleArrowUp = () => { + const nextIndex = inputIndex >= 1 ? inputIndex - 1 : searchedTags.length; + + setTag(nextIndex === 0 ? inputBuffer : searchedTags[nextIndex - 1].tagName); + setInputIndex(nextIndex); + + inputRefs.current[nextIndex].focus(); + }; + + const handleArrowDown = () => { + const nextIndex = inputIndex < searchedTags.length ? inputIndex + 1 : 0; + + setTag(nextIndex === 0 ? inputBuffer : searchedTags[nextIndex - 1].tagName); + setInputIndex(nextIndex); + + inputRefs.current[nextIndex].focus(); + }; + + const handleEnter = () => { + searchPosts(reviewStatus, tag); + + (document.activeElement as HTMLElement).blur(); + }; + + const searchTags = (keyword: string) => { + getRequest(`/tags/search?tagName=${keyword}`, async (response) => { + const data: GetSearchTagResponse = await response.json(); + + setSearchedTags(data.data); + }); + }; + + return ( + + + + { + if (element) inputRefs.current[0] = element; + }} + onChange={handleChangeInput} + /> + 0 && isInputFocused}> + {searchedTags.map((tag, idx) => ( + { + if (element) inputRefs.current[idx + 1] = element; + }} + onMouseDown={handleClickSearchedTag} + > + {tag.tagName} + + ))} + + + + ); +}; + +export default RunnerPostSearchBox; + +const S = { + SearchBoxContainer: styled.form` + display: flex; + flex-direction: column; + gap: 18px; + `, + + InputContainer: styled.div` + position: relative; + display: flex; + flex-direction: column; + `, + + TagInput: styled.input` + width: 320px; + height: 40px; + + background-image: url(${TagIcon}); + background-position: 6px center; + background-repeat: no-repeat; + + border: 1px solid var(--gray-400); + border-radius: 5px; + padding: 6px 31px; + + font-size: 18px; + + @media (max-width: 768px) { + width: 280px; + height: 36px; + + font-size: 16px; + } + `, + + SearchedTagList: styled.ul<{ $isVisible: boolean }>` + display: flex; + visibility: ${({ $isVisible }) => ($isVisible ? 'visible' : 'hidden')}; + + flex-direction: column; + position: absolute; + gap: 5px; + top: 40px; + z-index: 100; + + width: 320px; + + border: 1px solid var(--gray-400); + border-top: none; + border-radius: 5px; + background: white; + + font-size: 18px; + + @media (max-width: 768px) { + width: 280px; + + font-size: 16px; + } + `, + + searchedTagItem: styled.li` + padding: 6px 8px; + + &:hover { + background: var(--gray-200); + } + + &:focus { + background: var(--gray-200); + + outline: none; + } + `, +}; diff --git a/frontend/src/components/SendMessageModal/SendMessageModal.tsx b/frontend/src/components/SendMessageModal/SendMessageModal.tsx index a2f14c4d7..d81c9c54b 100644 --- a/frontend/src/components/SendMessageModal/SendMessageModal.tsx +++ b/frontend/src/components/SendMessageModal/SendMessageModal.tsx @@ -3,6 +3,7 @@ import { styled } from 'styled-components'; import Modal from '../common/Modal/Modal'; import Button from '../common/Button/Button'; import TextArea from '../Textarea/Textarea'; +import useViewport from '@/hooks/useViewport'; interface Props { messageState: string; @@ -19,13 +20,15 @@ const SendMessageModal = ({ closeModal, handleClickSendButton, }: Props) => { + const { isMobile } = useViewport(); + return ( - +