diff --git a/index.html b/index.html index b71fbad..00121a5 100644 --- a/index.html +++ b/index.html @@ -21,6 +21,11 @@
+ diff --git a/src/apis/client.ts b/src/apis/client.ts index 8f582dc..e3da7b4 100644 --- a/src/apis/client.ts +++ b/src/apis/client.ts @@ -14,14 +14,15 @@ export const authClient: AxiosInstance = axios.create({ }); authClient.interceptors.request.use((config) => { - if (!authClient.defaults.headers.common['Authorization']) { - if (userService.getUser() === '') { - // register token이 만료된 상황 - userService.removeUser(); - window.location.href = '/auth'; + if (!config.headers.common || !config.headers.common['Authorization']) { + if (userService.getUser().nickname === '') { + // register token이 없는 상황 (새로고침) + authService.onSetRegisterToken(); + config.headers['Authorization'] = authClient.defaults.headers.common['Authorization']; } else if (userService.getUser()) { // access token이 만료된 상황 authService.onRefreshToken(); + config.headers['Authorization'] = authClient.defaults.headers.common['Authorization']; } } diff --git a/src/apis/personaAPI.ts b/src/apis/personaAPI.ts new file mode 100644 index 0000000..edeeb7d --- /dev/null +++ b/src/apis/personaAPI.ts @@ -0,0 +1,27 @@ +import { authClient, noAuthClient } from '@/apis/client'; +import { DefineRequest } from '@/types/test.type'; + +export const personaAPI = { + // 페르소나 생성 + register: async (member: boolean, userInfo: DefineRequest) => { + if (member) { + const response = await authClient.post('/api/personas/define', userInfo); + return response.data; + } + + const response = await noAuthClient.post('/api/personas/define', userInfo); + return response.data; + }, + // 비로그인 유저 페르소나 조회 + getPersona: async (personaId: string) => { + const response = await noAuthClient.get( + `/api/personas/define/sharing?define_persona_id=${personaId}` + ); + return response.data; + }, + // 로그인 유저 페르소나 조회 + getPersonaMember: async () => { + const response = await authClient.get('/api/personas/define'); + return response.data; + }, +}; diff --git a/src/apis/userAPI.ts b/src/apis/userAPI.ts index b98082b..484a707 100644 --- a/src/apis/userAPI.ts +++ b/src/apis/userAPI.ts @@ -1,4 +1,4 @@ -import { authClient } from '@/apis/client'; +import { authClient, noAuthClient } from '@/apis/client'; import { RegisterRequest } from '@/types/userAPI.type'; export const userAPI = { @@ -7,4 +7,9 @@ export const userAPI = { const response = await authClient.post('/api/users/register', userInfo); return response.data; }, + // 닉네임 중복 체크 + duplicateCheck: async (nickname: string) => { + const response = await noAuthClient.get(`/api/users/check-nickname/${nickname}`); + return response.data; + }, }; diff --git a/src/assets/backgrounds/defineBackground.png b/src/assets/backgrounds/defineBackground.png new file mode 100644 index 0000000..dc5e1c6 Binary files /dev/null and b/src/assets/backgrounds/defineBackground.png differ diff --git a/src/assets/backgrounds/loginBackground.png b/src/assets/backgrounds/loginBackground.png index b0118dc..718fea2 100644 Binary files a/src/assets/backgrounds/loginBackground.png and b/src/assets/backgrounds/loginBackground.png differ diff --git a/src/assets/backgrounds/onboardingBackground.png b/src/assets/backgrounds/onboardingBackground.png new file mode 100644 index 0000000..5abbe99 Binary files /dev/null and b/src/assets/backgrounds/onboardingBackground.png differ diff --git a/src/assets/icons/check.svg b/src/assets/icons/check.svg new file mode 100644 index 0000000..b21f75c --- /dev/null +++ b/src/assets/icons/check.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/assets/icons/close.svg b/src/assets/icons/close.svg index c43942b..927fae0 100644 --- a/src/assets/icons/close.svg +++ b/src/assets/icons/close.svg @@ -1,3 +1,3 @@ - + \ No newline at end of file diff --git a/src/components/DefineResultPage/Button.tsx b/src/components/DefineResultPage/Button.tsx new file mode 100644 index 0000000..0e513ca --- /dev/null +++ b/src/components/DefineResultPage/Button.tsx @@ -0,0 +1,86 @@ +import styled, { css } from 'styled-components'; + +import { ReactComponent as DownloadIcon } from '@/assets/icons/download.svg'; +import { ReactComponent as KakaoIcon } from '@/assets/icons/kakaoIcon.svg'; + +interface ButtonProps { + onClick?: () => void; +} + +interface ShareButtonProps extends ButtonProps { + desktop?: boolean; +} + +export const KakaoShareButton = ({ onClick }: ButtonProps) => { + return ( + + + Kakao로 공유 +
+ + ); +}; + +export const DownloadButton = ({ onClick, desktop = false }: ShareButtonProps) => { + return ( + + + 이미지로 저장 +
+ + ); +}; + +const StyledButton = styled.button` + display: flex; + justify-content: space-between; + align-items: center; + + width: 100%; + height: 48px; + padding: 8px 16px; + border-radius: 8px; + + span { + ${({ theme }) => theme.font.desktop.label1m}; + } +`; + +const StyledShareButton = styled(StyledButton)` + background: #fee500; + + span { + color: #191600; + } + + &:hover { + filter: brightness(80%); + } +`; + +const StyledDownloadButton = styled(StyledButton)<{ $desktop: boolean }>` + ${({ $desktop }) => + $desktop + ? css` + background: ${({ theme }) => theme.color.primary50}; + + span { + color: ${({ theme }) => theme.color.primary700}; + } + + &:hover { + background: ${({ theme }) => theme.color.primary100}; + } + ` + : css` + background: ${({ theme }) => theme.color.gray800}; + + span { + color: ${({ theme }) => theme.color.white}; + } + + svg path { + fill: ${({ theme }) => theme.color.white}; + } + `} +`; diff --git a/src/components/DefineResultPage/CardSection.tsx b/src/components/DefineResultPage/CardSection.tsx index 9f8aa9d..94d1b1d 100644 --- a/src/components/DefineResultPage/CardSection.tsx +++ b/src/components/DefineResultPage/CardSection.tsx @@ -1,22 +1,20 @@ import { useEffect, useRef, useState } from 'react'; -import html2canvas from 'html2canvas'; import styled, { css } from 'styled-components'; import { ReactComponent as ChangeIcon } from '@/assets/icons/change.svg'; -import { ReactComponent as DownloadIcon } from '@/assets/icons/download.svg'; -import { ReactComponent as KakaoIcon } from '@/assets/icons/kakaoIcon.svg'; -import { CARD_IMAGE } from '@/constants/card'; +import { DownloadButton, KakaoShareButton } from '@/components/DefineResultPage/Button'; import { deviceSizes } from '@/styles/theme/device'; +import { DefineResult } from '@/types/test.type'; +import { kakaoShare } from '@/utils/kakaoShare'; interface CardSectionProps { - piece: string; + result: DefineResult; } -export const CardSection = ({ piece }: CardSectionProps) => { +export const CardSection = ({ result }: CardSectionProps) => { const [isFront, setIsFront] = useState(true); const [windowWidth, setWindowWidth] = useState(window.innerWidth); - const [isHover, setIsHover] = useState(false); const captureRef = useRef(null); useEffect(() => { @@ -30,97 +28,75 @@ export const CardSection = ({ piece }: CardSectionProps) => { }; }, []); - const handleSaveImage = () => { - if (!captureRef.current) return; - - const capture = captureRef.current; - - html2canvas(capture, { scrollY: -window.scrollY }).then((canvas) => { - const image = canvas.toDataURL('image/png').replace('image/png', 'image/octet-stream'); - const link = document.createElement('a'); - link.href = image; - link.download = `define-result-${isFront ? 'front' : 'back'}.jpg`; - link.click(); + const handleDownloadImage = async () => { + const imageUrls = [result.front_img_url, result.back_img_url]; + const fileNames = [`${result.name}-front`, `${result.name}-back`]; + + imageUrls.forEach((imageUrl, index) => { + fetch(imageUrl, { mode: 'cors' }) + .then((res) => res.blob()) + .then((blob) => { + const blobUrl = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = blobUrl; + link.download = fileNames[index]; + document.body.appendChild(link); + link.click(); + setTimeout(() => { + window.URL.revokeObjectURL(blobUrl); + document.body.removeChild(link); + }, 1000); + }) + .catch((err) => { + console.error('err', err); + }); }); }; + const handleShareResult = () => { + kakaoShare(result.name, result.define_persona_id); + }; + const handleClickImage = () => { if (windowWidth < deviceSizes.desktop) { - setIsHover((prev) => !prev); + setIsFront((prev) => !prev); } }; return ( +
+ {windowWidth >= deviceSizes.desktop + ? '카드에 마우스를 가져가 보세요!' + : '카드를 클릭해 보세요!'} +
= deviceSizes.desktop} onClick={handleClickImage} > card.name === piece.toLowerCase())?.[ - isFront ? 'front' : 'back' - ] || '' - } + src={isFront ? result.front_img_url : result.back_img_url} alt="card" ref={captureRef} /> - {windowWidth < deviceSizes.desktop && isHover && ( -
- setIsFront((prev) => !prev)}> - - - - - - -
- )} {windowWidth >= deviceSizes.desktop && (
setIsFront((prev) => !prev)}> - - + +
)}
-
- {windowWidth >= deviceSizes.desktop - ? '카드에 마우스를 가져가 보세요!' - : '카드를 클릭해 보세요!'} -
+ {windowWidth < deviceSizes.desktop && ( + + + + + )}
); }; @@ -128,20 +104,18 @@ export const CardSection = ({ piece }: CardSectionProps) => { const StyledCardSection = styled.section` display: flex; flex-direction: column; - justify-content: space-between; + align-items: center; + gap: 24px; + + width: 100%; .notice { text-align: center; - ${({ theme }) => theme.font.desktop.label1m}; + ${({ theme }) => theme.font.mobile.body1m}; color: ${({ theme }) => theme.color.primary700}; - margin-top: 12px; - - @media ${({ theme }) => theme.device.tablet} { - ${({ theme }) => theme.font.mobile.label1m}; - } - @media ${({ theme }) => theme.device.mobile} { - ${({ theme }) => theme.font.mobile.label1m}; + @media ${({ theme }) => theme.device.desktop} { + ${({ theme }) => theme.font.mobile.title2}; } } `; @@ -155,6 +129,7 @@ const StyledImageContainer = styled.div<{ $desktop: boolean }>` overflow: hidden; position: relative; + cursor: pointer; img { width: 100%; @@ -218,46 +193,18 @@ const StyleButtonContainer = styled.div` display: flex; flex-direction: column; gap: 8px; +`; - button { - display: flex; - justify-content: space-between; - align-items: center; - - height: 48px; - padding: 8px 16px; - border-radius: 8px; - - span { - ${({ theme }) => theme.font.desktop.label1m}; - } - } - - .download-button { - background: ${({ theme }) => theme.color.primary50}; - - span { - color: ${({ theme }) => theme.color.primary700}; - } - - &:hover { - background: ${({ theme }) => theme.color.primary100}; - } - } - - .share-button { - background: #fee500; - - span { - color: #191600; - } +const StyledMobileButtonContainer = styled.div` + width: 100%; + max-width: 764px; - &:hover { - filter: brightness(80%); - } - } + display: flex; + justify-content: space-between; + gap: 12px; - div { - width: 24px; + @media ${({ theme }) => theme.device.mobile} { + flex-direction: column-reverse; + gap: 8px; } `; diff --git a/src/components/DefineResultPage/DescriptionSection.tsx b/src/components/DefineResultPage/DescriptionSection.tsx index e082268..12c9152 100644 --- a/src/components/DefineResultPage/DescriptionSection.tsx +++ b/src/components/DefineResultPage/DescriptionSection.tsx @@ -1,7 +1,6 @@ import styled from 'styled-components'; -import Scrollbar from '@/components/Scrollbar'; -import { Chip } from '@/components/common/Chip/Chip'; +import { PlainChip } from '@/components/common/Chip/PlainChip'; import { DefineResult } from '@/types/test.type'; interface DescriptionSectionProps { @@ -11,66 +10,64 @@ interface DescriptionSectionProps { export const DescriptionSection = ({ result }: DescriptionSectionProps) => { return ( -
- -

- 셀피스의 조각카드는, 홀랜드 검사를 기반으로 - 구성되어있어요 :) -

-

- 홀랜드 검사는 나의 성격에 적합한 직무의 유형을 파악하는, 직업적 성격 유형 검사랍니다. -

-
- -
주요 능력
-

{result.ability}

-
- -
가치
-
- {result.values.map((value) => ( - - {value} - - ))} -
-
- -
이런 부분에서 강점을 보여요!
-

{result.strength}

-
- -
이러한 특성의 직업을 선호하는 경향이 있어요!
-

{result.preference}

-
- -
나의 유형 키워드
-
- {result.types.map((type) => ( - - {type} - - ))} -
-
- -
내가 선택한 유형 키워드
-
- {result.define_persona_keywords.map((keyword) => ( - - {keyword} - - ))} -
-
-
+ +

+ 셀피스의 조각카드는, 홀랜드 검사를 기반으로 + 구성되어있어요 :) +

+

+ 홀랜드 검사는 나의 성격에 적합한 직무의 유형을 파악하는, 직업적 성격 유형 검사랍니다. +

+
+ +
주요 능력
+

{result.ability}

+
+ +
가치
+
+ {result.values.map((value) => ( + + {value} + + ))} +
+
+ +
이런 부분에서 강점을 보여요!
+

{result.strength}

+
+ +
이러한 특성의 직업을 선호하는 경향이 있어요!
+

{result.preference}

+
+ +
나의 유형 키워드
+
+ {result.types.map((type) => ( + + {type} + + ))} +
+
+ +
내가 선택한 유형 키워드
+
+ {result.define_persona_keywords.map((keyword) => ( + + {keyword} + + ))} +
+
); }; const StyledContainer = styled.section` - width: 852px; - padding: 20px 0px 20px 24px; + width: 100%; + padding: 20px 24px; border-radius: 16px; border: 2px solid ${({ theme }) => theme.color.primary50}; @@ -78,22 +75,17 @@ const StyledContainer = styled.section` filter: drop-shadow(0px 0px 10px rgba(0, 0, 0, 0.3)); - .inner-container { - height: 100%; - display: flex; - flex-direction: column; - gap: 24px; - - overflow-y: scroll; - ${Scrollbar} - } + height: 100%; + display: flex; + flex-direction: column; + gap: 24px; @media ${({ theme }) => theme.device.tablet} { - width: auto; + padding: 20px 24px; } @media ${({ theme }) => theme.device.mobile} { - width: auto; + padding: 20px 24px; } `; @@ -109,7 +101,7 @@ const StyledHollandExplanation = styled.div` color: ${({ theme }) => theme.color.gray700}; .selpiece { - ${({ theme }) => theme.font.desktop.label1m}; + ${({ theme }) => theme.font.desktop.body1m}; margin-bottom: 4px; .highlight { @@ -118,7 +110,7 @@ const StyledHollandExplanation = styled.div` } .holland { - ${({ theme }) => theme.font.desktop.label2}; + ${({ theme }) => theme.font.desktop.label1r}; } @media ${({ theme }) => theme.device.tablet} { @@ -149,8 +141,8 @@ const StyledDescriptionSection = styled.div` margin-bottom: 8px; } - .DescriptionSection { - ${({ theme }) => theme.font.desktop.label1r}; + .description { + ${({ theme }) => theme.font.desktop.body2m}; color: ${({ theme }) => theme.color.gray700}; white-space: pre-wrap; word-break: break-all; diff --git a/src/components/DefineStartPage/DefineDesktopView.tsx b/src/components/DefineStartPage/DefineDesktopView.tsx new file mode 100644 index 0000000..c7f502f --- /dev/null +++ b/src/components/DefineStartPage/DefineDesktopView.tsx @@ -0,0 +1,121 @@ +import { useNavigate } from 'react-router-dom'; +import { styled } from 'styled-components'; + +import backgroundImg from '@/assets/backgrounds/defineBackground.png'; +import { PlainButton } from '@/components/common/Button/PlainButton'; + +export const DefineDesktopView = () => { + const navigate = useNavigate(); + + const handleClick = () => { + navigate('/test/define/2'); + }; + return ( +
+ + + + + +
+ 현재 당신은 어떤 사람인가요? +
+ + 문항은 총 3문항으로, 홀랜드 검사 이론을 기반으로 + 구성되어 있어요. +
+ 정의하기 테스트를 통해 나의 조각 유형을 도출하고,{' '} + 결과 카드를 받아보세요! +
+
+ + 테스트 시작하기 + +
+ + + + 홀랜드 검사란, +
+ 성격 유형과 커리어의 특성을 6개의 유형으로 분류하여 육각형으로 보여주는 검사로, +
+ 셀피스의 정의하기 테스트는 해당 이론에서 착안하여 고안되었습니다. +
+
+
+
+
+ ); +}; + +const TitleTextContainer = styled.div` + align-self: stretch; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + gap: 16px; + display: flex; +`; + +const TitleContainer = styled.div` + width: 100%; + color: ${({ theme }) => `${theme.color.gray900}`}; + ${({ theme }) => theme.font.desktop.h2}; +`; + +const SubTitleContainer = styled.div` + width: 100%; + color: ${({ theme }) => `${theme.color.gray700}`}; + ${({ theme }) => theme.font.desktop.body1m}; + word-wrap: break-word; + + .highlight { + color: ${({ theme }) => `${theme.color.primary700}`}; + } +`; + +const TopContainer = styled.div` + width: 1152px; + height: 544px; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + gap: 48px; + display: flex; +`; + +const SubTextContainer = styled.div` + width: 100%; + color: ${({ theme }) => `${theme.color.gray700}`}; + ${({ theme }) => theme.font.desktop.label2}; + word-wrap: break-word; + + .highlight { + color: ${({ theme }) => `${theme.color.primary600}`}; + } +`; + +const Styled1Container = styled.div` + width: 100%; + height: 100%; + padding: 64px; + padding-top: 118px; + flex-direction: column; + justify-content: space-between; + align-items: flex-start; + gap: 24px; + display: inline-flex; +`; +const Styled2Container = styled.div` + align-self: stretch; +`; + +export const ViewContainer = styled.div` + height: 100vh; + + background-image: url(${backgroundImg}); + background-size: cover; + background-position: right; + display: flex; + overflow-x: auto; +`; diff --git a/src/components/DefineStartPage/DefineMobileView.tsx b/src/components/DefineStartPage/DefineMobileView.tsx new file mode 100644 index 0000000..be9634a --- /dev/null +++ b/src/components/DefineStartPage/DefineMobileView.tsx @@ -0,0 +1,137 @@ +import { useNavigate } from 'react-router-dom'; +import { styled } from 'styled-components'; + +import backgroundImg from '@/assets/backgrounds/defineBackground.png'; +import { PlainButton } from '@/components/common/Button/PlainButton'; + +export const DefineMobileView = () => { + const navigate = useNavigate(); + + const handleClick = () => { + navigate('/test/define/2'); + }; + return ( +
+ + + + + 현재 당신은 어떤 사람인가요? + + 문항은 총 3문항으로, +
홀랜드 검사 이론을 기반으로 구성되어 있어요. +
+ 정의하기 테스트를 통해 나의 조각 유형을 도출하고,{' '} + 결과 카드를 받아보세요! +
+
+ + + 홀랜드 검사란, +
+ 성격 유형과 커리어의 특성을 6개의 유형으로 분류하여 육각형으로 보여주는 검사로, + 셀피스의 정의하기 테스트는 해당 이론에서 착안하여 고안되었습니다. +
+ + 테스트 시작하기 + +
+
+
+
+
+ ); +}; + +const TitleTextContainer = styled.div` + align-self: stretch; + height: 132px; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + gap: 16px; + display: flex; + @media ${({ theme }) => theme.device.mobile} { + height: 152px; + } +`; + +const TitleContainer = styled.div` + align-self: stretch; + color: ${({ theme }) => `${theme.color.gray900}`}; + ${({ theme }) => theme.font.mobile.title1}; +`; + +const SubTitleContainer = styled.div` + align-self: stretch; + color: ${({ theme }) => `${theme.color.gray700}`}; + ${({ theme }) => theme.font.mobile.body2m}; + word-wrap: break-word; + + .highlight { + color: ${({ theme }) => `${theme.color.primary700}`}; + } +`; + +const TopContainer = styled.div` + align-self: stretch; + flex: 1 1 0; + flex-direction: column; + justify-content: space-between; + align-items: flex-start; + display: flex; +`; +const BottomContainer = styled.div` + align-self: stretch; + height: 120px; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + gap: 12px; + display: flex; + @media ${({ theme }) => theme.device.mobile} { + height: 140px; + } +`; +const SubTextContainer = styled.div` + align-self: stretch; + color: ${({ theme }) => `${theme.color.gray700}`}; + ${({ theme }) => theme.font.mobile.body2m}; + word-wrap: break-word; + + .highlight { + color: ${({ theme }) => `${theme.color.primary600}`}; + } + @media ${({ theme }) => theme.device.mobile} { + ${({ theme }) => theme.font.mobile.label2}; + } +`; + +const StyledContainer = styled.div` + width: 100%; + height: 100%; + padding: 24px; + padding-top: 100px; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + gap: 16px; + display: inline-flex; + + @media ${({ theme }) => theme.device.mobile} { + padding: 20px; + padding-top: 96px; + } +`; + +const ViewContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100vh; + min-height: 700px; + background-image: url(${backgroundImg}); + background-size: cover; + background-position: center; +`; diff --git a/src/components/DefineTest/DefineButtonView.tsx b/src/components/DefineTest/DefineButtonView.tsx index e8f1495..a2ae9d6 100644 --- a/src/components/DefineTest/DefineButtonView.tsx +++ b/src/components/DefineTest/DefineButtonView.tsx @@ -4,10 +4,11 @@ import { useNavigate } from 'react-router-dom'; import { useRecoilState, useSetRecoilState } from 'recoil'; import styled from 'styled-components'; -import { authClient } from '@/apis/client'; +import { personaAPI } from '@/apis/personaAPI'; import { PlainButton } from '@/components/common/Button/PlainButton'; -import { defineState } from '@/recoil/defineState'; +import { loadingHandlerState } from '@/recoil/loadingHandlerState'; import { loadingState } from '@/recoil/loadingState'; +import { userService } from '@/services/UserService'; interface Props { warning?: boolean; @@ -46,6 +47,7 @@ const ChipContainer = styled.div` transform: translate(-50%, -100%); + width: max-content; padding: 8px 20px; border-radius: 8px; border: 1px solid ${({ theme }) => `${theme.color.secondary600}`}; @@ -66,7 +68,7 @@ export const DefineButtonView1 = ({ warning, warningMessage }: Props) => { const [showWarn, setShowWarn] = useState(false); const handleButtonClick = () => { - navigate('/test/define/2'); + navigate('/test/define/3'); }; useEffect(() => { @@ -104,11 +106,11 @@ export const DefineButtonView2 = ({ warning, warningMessage }: Props) => { const [showWarn, setShowWarn] = useState(false); const handleButton1Click = () => { - navigate('/test/define/1'); + navigate('/test/define/2'); }; const handleButton2Click = () => { - navigate('/test/define/3'); + navigate('/test/define/4'); }; useEffect(() => { @@ -149,11 +151,11 @@ export const DefineButtonView2 = ({ warning, warningMessage }: Props) => { export const DefineButtonView3 = ({ warning, warningMessage }: Props) => { const navigate = useNavigate(); const [showWarn, setShowWarn] = useState(false); - const [loading, setLoading] = useRecoilState(loadingState); - const setDefineResult = useSetRecoilState(defineState); + const setLoading = useSetRecoilState(loadingState); + const [loadingHandler, setLoadingHandler] = useRecoilState(loadingHandlerState); const handleButton1Click = () => { - navigate('/test/define/2'); + navigate('/test/define/3'); }; const handleButton2Click = () => { @@ -167,21 +169,21 @@ export const DefineButtonView3 = ({ warning, warningMessage }: Props) => { stage_three_keywords: selectedChips3, }; - setLoading({ - ...loading, - showLoading: true, - handleCompleted: () => { - navigate('/test/define/result'); - }, - }); + setLoading(true); - authClient - .post('/api/personas/define', requestData) + personaAPI + .register(userService.getUserState() === 'MEMBER', requestData) .then((response) => { - const { code, message } = response.data; + const { code, message, payload } = response; + if (code === '201') { console.log('페르소나 생성 성공'); - setDefineResult(response.data.payload); + setLoadingHandler({ + ...loadingHandler, + handleCompleted: () => { + navigate(`/test/define/${payload.define_persona_id}`); + }, + }); } else { console.error('페르소나 생성 실패:', message); } @@ -189,7 +191,6 @@ export const DefineButtonView3 = ({ warning, warningMessage }: Props) => { .catch((error) => { console.error('페르소나 생성 요청 실패:', error); window.alert('페르소나 생성 요청 실패'); - navigate('/test/define/1'); }); }; diff --git a/src/components/DefineTest/DefineChip.tsx b/src/components/DefineTest/DefineChip.tsx index c45464a..ce07874 100644 --- a/src/components/DefineTest/DefineChip.tsx +++ b/src/components/DefineTest/DefineChip.tsx @@ -7,7 +7,7 @@ import { DefineButtonView2, DefineButtonView3, } from '@/components/DefineTest/DefineButtonView'; -import TestChip from '@/components/common/Chip/TestChip'; +import { KeywordChip } from '@/components/common/Chip/KeywordChip'; import { CHIP_DATA1, CHIP_DATA2, CHIP_DATA3 } from '@/constants/defineChip'; export const DefineChips1 = () => { @@ -23,11 +23,10 @@ export const DefineChips1 = () => { const handleToggle = (index: number) => { const newChipStates = [...chipStates]; - const currentCount = newChipStates.filter((state) => state === 2).length; if (newChipStates[index] === 2) { newChipStates[index] = 1; - } else if (currentCount < 6) { + } else { newChipStates[index] = 2; } @@ -46,12 +45,13 @@ export const DefineChips1 = () => { {CHIP_DATA1.map((text, index) => ( - handleToggle(index)} - /> + selected={chipStates[index] !== 1} + toggleHandler={() => handleToggle(index)} + > + {text} + ))} @@ -71,11 +71,10 @@ export const DefineChips2 = () => { const handleToggle = (index: number) => { const newChipStates = [...chipStates]; - const currentCount = newChipStates.filter((state) => state === 2).length; if (newChipStates[index] === 2) { newChipStates[index] = 1; - } else if (currentCount < 6) { + } else { newChipStates[index] = 2; } @@ -95,12 +94,13 @@ export const DefineChips2 = () => { {CHIP_DATA2.map((text, index) => ( - handleToggle(index)} - /> + selected={chipStates[index] !== 1} + toggleHandler={() => handleToggle(index)} + > + {text} + ))} @@ -121,11 +121,10 @@ export const DefineChips3 = () => { const handleToggle = (index: number) => { const newChipStates = [...chipStates]; - const currentCount = newChipStates.filter((state) => state === 2).length; if (newChipStates[index] === 2) { newChipStates[index] = 1; - } else if (currentCount < 6) { + } else { newChipStates[index] = 2; } @@ -145,12 +144,13 @@ export const DefineChips3 = () => { {CHIP_DATA3.map((text, index) => ( - handleToggle(index)} - /> + selected={chipStates[index] !== 1} + toggleHandler={() => handleToggle(index)} + > + {text} + ))} diff --git a/src/components/DesignTest/DesignButtonView.tsx b/src/components/DesignTest/DesignButtonView.tsx index b9c3656..22ab164 100644 --- a/src/components/DesignTest/DesignButtonView.tsx +++ b/src/components/DesignTest/DesignButtonView.tsx @@ -1,7 +1,7 @@ import { useNavigate } from 'react-router-dom'; import styled from 'styled-components'; -import { PlainButton } from '../common/Button/PlainButton'; +import { PlainButton } from '@/components/common/Button/PlainButton'; const Container = styled.div` width: 100%; diff --git a/src/components/DesignTest/DesignChip.tsx b/src/components/DesignTest/DesignChip.tsx index e8d911d..d8a7a7d 100644 --- a/src/components/DesignTest/DesignChip.tsx +++ b/src/components/DesignTest/DesignChip.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'; import styled from 'styled-components'; -import TestChip from '@/components/common/Chip/TestChip'; +import { KeywordChip } from '@/components/common/Chip/KeywordChip'; const chipData1 = [ '남성적임', @@ -64,12 +64,13 @@ export const DesignChips1 = () => { return ( {chipData1.map((text, index) => ( - handleToggle(index)} - /> + selected={chipStates[index] !== 1} + toggleHandler={() => handleToggle(index)} + > + {text} + ))} {warning &&
키워드를 5개만 선택해 주세요!
}
diff --git a/src/components/DesignTest/DesignTextView.tsx b/src/components/DesignTest/DesignTextView.tsx index 5c41e6f..090715c 100644 --- a/src/components/DesignTest/DesignTextView.tsx +++ b/src/components/DesignTest/DesignTextView.tsx @@ -1,6 +1,6 @@ import styled from 'styled-components'; -import ProgressBar from '../common/ProgressBar'; +import ProgressBar from '@/components/common/ProgressBar'; const TextContainer = styled.div` width: 100%; diff --git a/src/components/HomePage/PieceSection.tsx b/src/components/HomePage/PieceSection.tsx index a91718e..0cae31f 100644 --- a/src/components/HomePage/PieceSection.tsx +++ b/src/components/HomePage/PieceSection.tsx @@ -1,7 +1,7 @@ import styled, { keyframes } from 'styled-components'; import { ReactComponent as ArrowIcon } from '@/assets/icons/arrowDown.svg'; -import { Chip } from '@/components/common/Chip/Chip'; +import { PlainChip } from '@/components/common/Chip/PlainChip'; import { CARD_IMAGE } from '@/constants/card'; import { SectionContainer } from '@/styles'; import { UserInformation } from '@/types/user.type'; @@ -35,9 +35,9 @@ export const PieceSection = ({ userInformation }: PieceSectionProps) => {
{userInformation.chips.map((chip) => ( - + {chip.content} - + ))}
@@ -224,6 +224,7 @@ const StyledContents = styled.div` .chips { display: flex; gap: 16px; + flex-wrap: wrap; padding-bottom: 38px; } diff --git a/src/components/HomePage/RecommendSectionTemplate.tsx b/src/components/HomePage/RecommendSectionTemplate.tsx index 7058426..c839ffc 100644 --- a/src/components/HomePage/RecommendSectionTemplate.tsx +++ b/src/components/HomePage/RecommendSectionTemplate.tsx @@ -2,7 +2,7 @@ import styled from 'styled-components'; import { PreviewCard } from '@/components/common/Card/PreviewCard'; import { Carousel } from '@/components/common/Carousel'; -import { Dropdown } from '@/components/common/Dropdown'; +/* import { Dropdown } from '@/components/common/Dropdown/Dropdown'; */ import { SectionContainer } from '@/styles'; import { FilterItems, RecommendItems } from '@/types/recommend.type'; @@ -19,7 +19,6 @@ export const RecommendSectionTemplate = ({ subTitle, backgroundColor, recommendItems, - filters, }: RecommendSectionTemplateProps) => { return ( @@ -28,7 +27,7 @@ export const RecommendSectionTemplate = ({
{title}
{subTitle}
- + {/* {filters.map((filter) => ( 새로고침 - + */} {recommendItems.map((item) => ( void; + Funnel: React.ComponentType; + Step: React.ComponentType; +} + +export const ProfileSetup = ({ steps, nextClickHandler, Funnel, Step }: ProfileSetupProps) => { + return ( + + + nextClickHandler(steps[1])} /> + + + + + + ); +}; diff --git a/src/components/OnboardingPage/ScoreRangeBar.tsx b/src/components/OnboardingPage/ScoreRangeBar.tsx new file mode 100644 index 0000000..8e0eb3c --- /dev/null +++ b/src/components/OnboardingPage/ScoreRangeBar.tsx @@ -0,0 +1,98 @@ +import { useRecoilState } from 'recoil'; +import styled from 'styled-components'; + +import { onboardingState } from '@/recoil/onboardingState'; + +interface RangeBarProps { + score: number; +} + +export const ScoreRangeBar = ({ score }: RangeBarProps) => { + const [onboarding, setOnboarding] = useRecoilState(onboardingState); + + return ( + +
+ {score}점 +
+ + setOnboarding({ ...onboarding, understanding_score: event.target.valueAsNumber }) + } + /> +
+ ); +}; + +const StyledContainer = styled.div` + padding: 10px 0 7px 0; + position: relative; + + .score { + position: absolute; + transform: translateY(-17%); + z-index: 1; + + ${({ theme }) => theme.font.desktop.body1m}; + color: ${({ theme }) => theme.color.white}; + background: transparent; + text-align: center; + width: 71px; + + pointer-events: none; + } +`; + +const StyledInput = styled.input` + & { + -webkit-appearance: none; + appearance: none; + outline: none; + cursor: pointer; + + width: 100%; + height: 16px; + + border-radius: 15px; + background: ${({ theme }) => + `linear-gradient(-90deg, ${theme.color.primary500} 0%, ${theme.color.primary50} 100%)`}; + } + + &::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + + height: 38px; + width: 71px; + + background: ${({ theme }) => theme.color.primary500}; + border-radius: 24px; + + transition: 0.2s ease-in-out; + transform: rotateZ(0deg); + } + + &::-moz-range-thumb { + height: 38px; + width: 71px; + background: ${({ theme }) => theme.color.primary500}; + border: none; + border-radius: 24px; + transform: rotateZ(0deg); + transition: 0.2s ease-in-out; + } +`; diff --git a/src/components/OnboardingPage/Setup.style.ts b/src/components/OnboardingPage/Setup.style.ts new file mode 100644 index 0000000..7f98399 --- /dev/null +++ b/src/components/OnboardingPage/Setup.style.ts @@ -0,0 +1,41 @@ +import styled from 'styled-components'; + +import { PlainButton } from '@/components/common/Button/PlainButton'; + +export const StyledContainer = styled.section` + display: flex; + flex-direction: column; + justify-content: space-between; + height: 100%; + + .field { + display: flex; + flex-direction: column; + gap: 32px; + } +`; + +export const StyledQuestionContainer = styled.div` + display: flex; + flex-direction: column; + gap: 8px; +`; + +export const StyledQuestion = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + + ${({ theme }) => theme.font.desktop.body1m}; + color: ${({ theme }) => theme.color.gray900}; + + .highlight { + ${({ theme }) => theme.font.desktop.label2}; + color: ${({ theme }) => theme.color.primary500}; + } +`; + +export const StyledPlainButton = styled(PlainButton)` + margin-top: 20px; + height: 48px; +`; diff --git a/src/components/OnboardingPage/SetupBasicInfo.tsx b/src/components/OnboardingPage/SetupBasicInfo.tsx new file mode 100644 index 0000000..410d480 --- /dev/null +++ b/src/components/OnboardingPage/SetupBasicInfo.tsx @@ -0,0 +1,137 @@ +import { useState } from 'react'; + +import { useRecoilState } from 'recoil'; +import styled from 'styled-components'; + +import { userAPI } from '@/apis/userAPI'; +import { + StyledContainer, + StyledPlainButton, + StyledQuestion, + StyledQuestionContainer, +} from '@/components/OnboardingPage/Setup.style'; +import { Dropdown } from '@/components/common/Dropdown/Dropdown'; +import { ButtonInput } from '@/components/common/Input/ButtonInput'; +import { INTEREST_LIST, JOB_LIST } from '@/constants/onboarding'; +import { onboardingState } from '@/recoil/onboardingState'; + +interface SetupBasicInfoProps { + onNext: () => void; +} + +export const SetupBasicInfo = ({ onNext }: SetupBasicInfoProps) => { + const [checkDuplicate, setCheckDuplicate] = useState(false); + const [isDuplicate, setIsDuplicate] = useState(false); + const [isSpecialCharacter, setIsSpecialCharacter] = useState(false); + const [onboarding, setOnboarding] = useRecoilState(onboardingState); + + const handleInputChange = (e: React.ChangeEvent) => { + const { value } = e.target; + setOnboarding({ ...onboarding, nickname: value }); + + setIsDuplicate(false); + setCheckDuplicate(false); + + if (value.match(/[^a-zA-Z0-9ㄱ-ㅎ가-힣ㅏ-ㅣ\s]/g) !== null) { + setIsSpecialCharacter(true); + } else { + setIsSpecialCharacter(false); + } + }; + + const handleCheckDuplicate = () => { + userAPI + .duplicateCheck(onboarding.nickname) + .then(() => { + setIsDuplicate(false); + setCheckDuplicate(true); + }) + .catch(() => { + setIsDuplicate(true); + setCheckDuplicate(false); + }); + }; + + return ( + +
+ + 당신의 닉네임이 궁금해요. 어떻게 불러드릴까요? +
+ + {!isSpecialCharacter && !isDuplicate && !checkDuplicate && ( + 중복된 이름·특수문자 사용불가 + )} + {isSpecialCharacter && ( + 특수문자 사용 불가 + )} + {!isSpecialCharacter && isDuplicate && !checkDuplicate && ( + 닉네임 중복으로 사용 불가 + )} + {!isSpecialCharacter && !isDuplicate && checkDuplicate && ( + 중복 확인 완료 + )} +
+
+ + 당신의 직업이 궁금해요. 어떤 일을 하고 계신가요? + + setOnboarding({ ...onboarding, job: newSelected }) + } + /> + + + + 관심 분야를 선택해주세요. 어떤 경험을 즐기시나요? + 최대 2개 선택 + + { + if (onboarding.interest_list.includes(newSelected)) + setOnboarding({ + ...onboarding, + interest_list: onboarding.interest_list.filter((item) => item !== newSelected), + }); + else if (onboarding.interest_list.length < 2) + setOnboarding({ + ...onboarding, + interest_list: [...onboarding.interest_list, newSelected], + }); + }} + /> + +
+ + 다음으로 + +
+ ); +}; + +const StyledCondition = styled.div<{ $warning?: boolean }>` + margin-top: 4px; + ${({ theme }) => theme.font.desktop.label2}; + + color: ${({ $warning, theme }) => ($warning ? theme.color.secondary500 : theme.color.gray600)}; +`; diff --git a/src/components/OnboardingPage/SetupBranding.tsx b/src/components/OnboardingPage/SetupBranding.tsx new file mode 100644 index 0000000..16aad2f --- /dev/null +++ b/src/components/OnboardingPage/SetupBranding.tsx @@ -0,0 +1,106 @@ +import { useNavigate } from 'react-router-dom'; +import { useRecoilState } from 'recoil'; +import styled from 'styled-components'; + +import { userAPI } from '@/apis/userAPI'; +import { ScoreRangeBar } from '@/components/OnboardingPage/ScoreRangeBar'; +import { + StyledContainer, + StyledPlainButton, + StyledQuestion, + StyledQuestionContainer, +} from '@/components/OnboardingPage/Setup.style'; +import { KeywordChip } from '@/components/common/Chip/KeywordChip'; +import { KEYWORD_LIST } from '@/constants/onboarding'; +import { onboardingState } from '@/recoil/onboardingState'; +import { authService } from '@/services/AuthService'; +import { userService } from '@/services/UserService'; + +export const SetupBranding = () => { + const [onboarding, setOnboarding] = useRecoilState(onboardingState); + const navigate = useNavigate(); + + const handleSubmit = () => { + userAPI + .register(onboarding) + .then((res) => { + authService.onLoginSuccess(res.payload.access_token); + userService.setUser({ nickname: res.payload.nickname }); + authService.onRemoveRegisterToken(); + navigate('/'); + }) + .catch(() => { + window.alert('다시 시도해주세요'); + }); + }; + + return ( + +
+ +
+ {`셀피스와 함께할 ${onboarding.nickname}님, 스스로에 대해 얼마나 알고 있나요?`} + + {`셀피스는 자기이해와 PR에 어려움을 겪고 있는 사용자를 위한 퍼스널 브랜딩 서비스에요.\n스스로에 대한 이해도를 1점부터 100점까지 매겨주세요.`} + +
+ +
+ +
+ + {`${onboarding.nickname}님을 표현하는 키워드를 골라주세요.`} + 최대 5개 선택 + + + 고른 키워드를 기반으로 셀피스가 경험을 추천해드려요. + +
+ + {KEYWORD_LIST.map((keyword) => ( + { + if (onboarding.keyword_list.length < 5) { + setOnboarding({ + ...onboarding, + keyword_list: [...onboarding.keyword_list, newKeyword], + }); + } + }} + deleteHandler={(newKeyword) => + setOnboarding({ + ...onboarding, + keyword_list: onboarding.keyword_list.filter( + (keyword) => keyword !== newKeyword + ), + }) + } + > + {keyword} + + ))} + +
+
+ + 셀피스 시작하기 + +
+ ); +}; + +const StyledDescription = styled.div` + ${({ theme }) => theme.font.desktop.label2}; + color: ${({ theme }) => theme.color.gray500}; + white-space: pre-line; +`; + +const StyledChipsContainer = styled.div` + width: 100%; + + display: flex; + gap: 12px; + flex-wrap: wrap; +`; diff --git a/src/components/SelfUnderstandPage/SelfUnderstandView.tsx b/src/components/SelfUnderstandPage/SelfUnderstandView.tsx index c93426b..5cd329c 100644 --- a/src/components/SelfUnderstandPage/SelfUnderstandView.tsx +++ b/src/components/SelfUnderstandPage/SelfUnderstandView.tsx @@ -32,6 +32,7 @@ const CardInnerContainer = styled.div` `; const StyledContainer = styled.section` + min-width: 1280px; background: ${({ theme }) => `linear-gradient( 180deg, rgba(255, 255, 255, 0) 27%, diff --git a/src/components/common/Button/PlainButton.tsx b/src/components/common/Button/PlainButton.tsx index 50bc551..01a909a 100644 --- a/src/components/common/Button/PlainButton.tsx +++ b/src/components/common/Button/PlainButton.tsx @@ -1,14 +1,12 @@ import styled, { css } from 'styled-components'; -type PlainButtonVariant = 'primary' | 'gray' | 'primary2'; +type PlainButtonVariant = 'primary' | 'primary2' | 'gray'; -interface PlainButtonProps { +interface PlainButtonProps extends React.ButtonHTMLAttributes { children: React.ReactNode; - variant: PlainButtonVariant; + variant?: PlainButtonVariant; width?: string | null; height?: string | null; - onClick?: () => void; - disabled?: boolean; } interface StyledButtonProps { @@ -19,20 +17,13 @@ interface StyledButtonProps { export const PlainButton = ({ children, - variant, + variant = 'gray', width = null, height = null, - onClick, - disabled = false, + ...props }: PlainButtonProps) => { return ( - + {children} ); @@ -75,7 +66,7 @@ const getVariantStyle = ($variant: PlainButtonVariant) => { const StyledButton = styled.button` ${({ theme }) => theme.font.desktop.label1m}; - width: ${({ $width }) => $width}; + width: ${({ $width }) => ($width ? $width : '100%')}; height: ${({ $height }) => $height}; padding: 8px 24px; border-radius: 8px; @@ -85,6 +76,5 @@ const StyledButton = styled.button` &:disabled { color: ${(props) => props.theme.color.gray700}; background: ${(props) => props.theme.color.gray200}; - cursor: not-allowed; } `; diff --git a/src/components/common/Card/PreviewCard.tsx b/src/components/common/Card/PreviewCard.tsx index 8e7d135..7dbd0f4 100644 --- a/src/components/common/Card/PreviewCard.tsx +++ b/src/components/common/Card/PreviewCard.tsx @@ -1,6 +1,6 @@ import styled from 'styled-components'; -import { Chip } from '@/components/common/Chip/Chip'; +import { PlainChip } from '@/components/common/Chip/PlainChip'; interface PreviewCardProps { imageUrl: string; @@ -14,7 +14,7 @@ export const PreviewCard = ({ imageUrl, title, keywords, hot = false }: PreviewC {hot && (
- 요즘 핫한 + 요즘 핫한
)} @@ -22,9 +22,9 @@ export const PreviewCard = ({ imageUrl, title, keywords, hot = false }: PreviewC

{title}

{keywords.map((keyword) => ( - + {keyword} - + ))}
diff --git a/src/components/common/Chip/KeywordChip.tsx b/src/components/common/Chip/KeywordChip.tsx new file mode 100644 index 0000000..112957d --- /dev/null +++ b/src/components/common/Chip/KeywordChip.tsx @@ -0,0 +1,67 @@ +import styled, { css } from 'styled-components'; + +interface KeywordChipProps { + selected?: boolean; + children: string; + selectHandler?: (selectedKeyword: string) => void; + deleteHandler?: (selectedKeyword: string) => void; + toggleHandler?: () => void; +} + +export const KeywordChip = ({ + selected = false, + children, + selectHandler, + deleteHandler, + toggleHandler, +}: KeywordChipProps) => { + return ( + { + toggleHandler && toggleHandler(); + if (!selected) selectHandler && selectHandler(children); + else deleteHandler && deleteHandler(children); + }} + > + {children} + + ); +}; + +const StyledKeywordChip = styled.button<{ $selected: boolean }>` + display: flex; + align-items: center; + gap: 6px; + + width: fit-content; + padding: 7px 16px; + + ${({ theme }) => theme.font.desktop.label1m}; + + text-align: center; + border-radius: 8px; + + ${({ $selected, theme }) => + $selected + ? css` + background: ${theme.color.primary500}; + color: ${theme.color.white}; + + @media ${({ theme }) => theme.device.desktop} { + &:hover { + background: ${theme.color.primary600}; + } + } + ` + : css` + background: ${({ theme }) => theme.color.white}; + color: ${({ theme }) => theme.color.primary700}; + + @media ${({ theme }) => theme.device.desktop} { + &:hover { + background: ${({ theme }) => theme.color.primary100}; + } + } + `}; +`; diff --git a/src/components/common/Chip/Chip.tsx b/src/components/common/Chip/PlainChip.tsx similarity index 90% rename from src/components/common/Chip/Chip.tsx rename to src/components/common/Chip/PlainChip.tsx index 3f93c7c..09fee0c 100644 --- a/src/components/common/Chip/Chip.tsx +++ b/src/components/common/Chip/PlainChip.tsx @@ -1,11 +1,11 @@ import styled, { css } from 'styled-components'; -interface ChipProps { +interface PlainChipProps { primary?: boolean; children: React.ReactNode; } -export const Chip = ({ primary = false, children }: ChipProps) => { +export const PlainChip = ({ primary = false, children }: PlainChipProps) => { return {children}; }; diff --git a/src/components/common/Dropdown.tsx b/src/components/common/Dropdown.tsx deleted file mode 100644 index 476b1bf..0000000 --- a/src/components/common/Dropdown.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; - -import styled from 'styled-components'; - -import { ReactComponent as ArrowDownIcon } from '@/assets/icons/arrowDown.svg'; -import { ReactComponent as ArrowUpIcon } from '@/assets/icons/arrowUp.svg'; - -interface DropdownProps { - title: string; - contents: string[]; - selectedContents: string[]; - setSelectedContents: (selected: string[]) => void; -} - -export const Dropdown = ({ - title, - contents, - selectedContents, - setSelectedContents, -}: DropdownProps) => { - const [isOpen, setIsOpen] = useState(false); - const dropdownRef = useRef(null); - - useEffect(() => { - const handleClickOutside = (e: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { - setIsOpen(false); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, []); - - return ( - - setIsOpen((prev) => !prev)}> - - {title} - {selectedContents.length === 0 ? ( - 키워드 추가 - ) : ( - - {`${selectedContents[0]}${selectedContents.length > 1 ? ` 외 ${selectedContents.length - 1}` : ''}`} - - )} - - {isOpen ? : } - - {isOpen && ( - - {contents.map((content) => ( -
  • { - // TODO: 순서정렬 로직 필요? - if (!selectedContents.includes(content)) - setSelectedContents([...selectedContents, content]); - }} - > - {content} -
  • - ))} -
    - )} -
    - ); -}; - -const StyledContainer = styled.div` - position: relative; - - display: flex; - flex-direction: column; - gap: 12px; - - width: fit-content; -`; - -const StyledContent = styled.div` - padding: 12px 10px 12px 18px; - - display: flex; - align-items: center; - gap: 10px; - - border-radius: 8px; - background: ${({ theme }) => theme.color.white}; - cursor: pointer; - - box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.3); - - .icon { - width: 24px; - cursor: pointer; - } -`; - -const StyledLabel = styled.span` - ${({ theme }) => theme.font.desktop.body1m}; - - display: flex; - gap: 10px; - - .filter-title { - color: ${({ theme }) => theme.color.gray700}; - } - - .no-filter { - color: ${({ theme }) => theme.color.gray300}; - } - - .selected { - color: ${({ theme }) => theme.color.secondary500}; - } -`; - -const StyledExpandedContent = styled.ul` - position: absolute; - top: 62px; - left: 0; - z-index: 2; - - display: flex; - flex-direction: column; - - width: 100%; - border-radius: 8px; - background: ${({ theme }) => theme.color.white}; - box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.3); - - overflow: hidden; - - .content { - ${({ theme }) => theme.font.desktop.body1m}; - color: ${({ theme }) => theme.color.gray800}; - background: ${({ theme }) => theme.color.white}; - - text-align: center; - padding: 12px 24px; - - cursor: pointer; - - &:hover { - background: ${({ theme }) => theme.color.gray100}; - } - } - - .active { - // TODO: 선택된 항목 디자인 구현 - } -`; diff --git a/src/components/common/Dropdown/Dropdown.tsx b/src/components/common/Dropdown/Dropdown.tsx new file mode 100644 index 0000000..49624bf --- /dev/null +++ b/src/components/common/Dropdown/Dropdown.tsx @@ -0,0 +1,72 @@ +import { useEffect, useRef, useState } from 'react'; + +import styled from 'styled-components'; + +import { DropdownButton } from '@/components/common/Dropdown/DropdownButton'; +import { DropdownContent } from '@/components/common/Dropdown/DropdownContent'; + +interface DropdownProps { + title?: string; + placeholder: string; + contents: string[]; + selected: string[] | string; + multiple?: boolean; + clickContentHandler?: (content: string) => void; + contentMaxHeight?: number; +} + +export const Dropdown = ({ + title, + placeholder, + contents, + selected, + multiple = false, + clickContentHandler, + contentMaxHeight, +}: DropdownProps) => { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + return ( + + setIsOpen((prev) => !prev)} + /> + {isOpen && ( + { + clickContentHandler && clickContentHandler(...args); + if (!multiple) setIsOpen(false); + }} + {...(contentMaxHeight ? { maxHeight: contentMaxHeight } : {})} + /> + )} + + ); +}; + +const StyledContainer = styled.div` + position: relative; + //width: fit-content; +`; diff --git a/src/components/common/Dropdown/DropdownButton.tsx b/src/components/common/Dropdown/DropdownButton.tsx new file mode 100644 index 0000000..2cada60 --- /dev/null +++ b/src/components/common/Dropdown/DropdownButton.tsx @@ -0,0 +1,101 @@ +import styled from 'styled-components'; + +import { ReactComponent as ArrowDownIcon } from '@/assets/icons/arrowDown.svg'; +import { ReactComponent as ArrowUpIcon } from '@/assets/icons/arrowUp.svg'; + +interface DropdownButtonProps { + title?: string; + placeholder: string; + selected: string[] | string; + active: boolean; + clickHandler?: () => void; +} + +export const DropdownButton = ({ + title, + placeholder, + selected, + active, + clickHandler, +}: DropdownButtonProps) => { + const selectedArray = Array.isArray(selected) ? selected : selected === '' ? [] : [selected]; + + if (title) + return ( + + {title} + + {selectedArray.length === 0 ? ( + {placeholder} + ) : ( + + {`${selectedArray[0]}${selectedArray.length > 1 ? ` 외 ${selectedArray.length - 1}` : ''}`} + + )} + {active ? : } + + + ); + + return ( + + {selectedArray.length === 0 ? ( + {placeholder} + ) : ( + {selectedArray.join(', ')} + )} + {active ? : } + + ); +}; + +const StyledContainer = styled.button` + width: 100%; + height: 48px; + padding: 0px 16px; + + display: flex; + align-items: center; + justify-content: space-between; + + border-radius: 8px; + background: ${({ theme }) => theme.color.white}; + box-shadow: 0px 0px 5px 0px rgba(0, 0, 0, 0.13); + + ${({ theme }) => theme.font.desktop.body2r}; + + &:hover { + background: ${({ theme }) => theme.color.gray50}; + } + + .content-placeholder { + color: ${({ theme }) => theme.color.gray400}; + } + + .title-placeholder { + color: ${({ theme }) => theme.color.gray300}; + } + + .content-selected { + color: ${({ theme }) => theme.color.primary500}; + } + + .title-selected { + color: ${({ theme }) => theme.color.gray700}; + } + + .icon { + width: 24px; + height: 24px; + } +`; + +const StyledTitle = styled.div` + color: ${({ theme }) => theme.color.gray700}; +`; + +const StyledContent = styled.div` + display: flex; + align-items: center; + gap: 10px; +`; diff --git a/src/components/common/Dropdown/DropdownContent.tsx b/src/components/common/Dropdown/DropdownContent.tsx new file mode 100644 index 0000000..7990b88 --- /dev/null +++ b/src/components/common/Dropdown/DropdownContent.tsx @@ -0,0 +1,78 @@ +import styled from 'styled-components'; + +import { ReactComponent as CheckIcon } from '@/assets/icons/check.svg'; +import Scrollbar from '@/components/Scrollbar'; + +interface DropdownContentProps { + contents: string[]; + selected: string[] | string; + multiple?: boolean; + clickHandler?: (content: string) => void; + maxHeight?: number; +} + +export const DropdownContent = ({ + contents, + selected, + multiple = false, + clickHandler, + maxHeight, +}: DropdownContentProps) => { + const selectedArray = Array.isArray(selected) ? selected : selected === '' ? [] : [selected]; + + return ( + + {contents.map((content) => ( +
  • clickHandler && clickHandler(content)} + className={selectedArray.includes(content) && multiple ? 'active' : ''} + > + {multiple && } + {content} +
  • + ))} +
    + ); +}; + +const StyledContainer = styled.ul<{ $maxHeight?: number }>` + position: absolute; + bottom: -12px; + left: 0; + transform: translate(0%, 100%); + z-index: 2; + + width: 100%; + height: ${({ $maxHeight }) => $maxHeight}px; + + border-radius: 8px; + background: ${({ theme }) => theme.color.white}; + box-shadow: 0px 0px 5px 0px rgba(0, 0, 0, 0.13); + + overflow-y: scroll; + ${Scrollbar} + + li { + display: flex; + gap: 12px; + + padding: 12px 24px; + background: ${({ theme }) => theme.color.white}; + ${({ theme }) => theme.font.desktop.body2r}; + + cursor: pointer; + + &:hover { + background: ${({ theme }) => theme.color.gray100}; + } + } + + .active { + color: ${({ theme }) => theme.color.primary500}; + + svg path { + fill: ${({ theme }) => theme.color.primary500}; + } + } +`; diff --git a/src/components/common/Input/ButtonInput.tsx b/src/components/common/Input/ButtonInput.tsx new file mode 100644 index 0000000..7cb5c6b --- /dev/null +++ b/src/components/common/Input/ButtonInput.tsx @@ -0,0 +1,43 @@ +import styled from 'styled-components'; + +import { DefaultInput } from '@/components/common/Input/DefaultInput'; + +interface ButtonInputProps extends React.InputHTMLAttributes { + warning?: boolean; + buttonDisabled?: boolean; + inputDisabled?: boolean; + buttonText: string; + buttonClickHandler?: () => void; +} + +export const ButtonInput = ({ + warning = false, + buttonDisabled = false, + inputDisabled = false, + buttonText, + buttonClickHandler, + ...props +}: ButtonInputProps) => { + return ( + + + {buttonText} + + + ); +}; + +const StyledButton = styled.button` + padding: 6px 16px; + + background: ${({ theme }) => theme.color.gray800}; + border-radius: 8px; + + ${({ theme }) => theme.font.desktop.label2}; + color: ${({ theme }) => theme.color.white}; + + &:disabled { + background: ${({ theme }) => theme.color.gray250}; + cursor: default; + } +`; diff --git a/src/components/common/Input/DefaultInput.tsx b/src/components/common/Input/DefaultInput.tsx new file mode 100644 index 0000000..49dc1d6 --- /dev/null +++ b/src/components/common/Input/DefaultInput.tsx @@ -0,0 +1,69 @@ +import { useState } from 'react'; + +import styled, { css } from 'styled-components'; + +interface DefaultInputProps extends React.InputHTMLAttributes { + warning?: boolean; + children?: React.ReactNode; +} + +export const DefaultInput = ({ warning = false, children, ...props }: DefaultInputProps) => { + const [isFocused, setIsFocused] = useState(false); + + const handleFocus = () => { + setIsFocused(true); + }; + + const handleBlur = () => { + setIsFocused(false); + }; + + return ( + + + {children} + + ); +}; + +const StyledContainer = styled.div<{ $warning: boolean; $focused: boolean }>` + display: flex; + align-items: center; + gap: 12px; + + padding: 8px 12px; + + border: 1px solid transparent; + border-radius: 8px; + background: ${({ theme }) => theme.color.white}; + box-shadow: 0px 0px 5px 0px rgba(0, 0, 0, 0.13); + + input { + flex: 1 0 0; + background: transparent; + + ${({ theme }) => theme.font.desktop.body2r}; + color: ${({ theme }) => theme.color.gray700}; + + &::placeholder { + color: ${({ theme }) => theme.color.gray300}; + } + } + + ${({ $focused }) => + $focused && + css` + background: ${({ theme }) => theme.color.gray100}; + `} + + ${({ $warning }) => + $warning && + css` + border-color: ${({ theme }) => theme.color.secondary500}; + background: ${({ theme }) => theme.color.gray100}; + + input { + color: ${({ theme }) => theme.color.secondary500}; + } + `} +`; diff --git a/src/components/common/Layout/MainLayout.tsx b/src/components/common/Layout/MainLayout.tsx index 350acc8..322f3b2 100644 --- a/src/components/common/Layout/MainLayout.tsx +++ b/src/components/common/Layout/MainLayout.tsx @@ -1,5 +1,4 @@ import { Outlet } from 'react-router-dom'; -import styled from 'styled-components'; import { Footer } from '@/components/common/Footer'; import { TopNavigation } from '@/components/common/Navigation/TopNavigation'; @@ -8,15 +7,9 @@ export const MainLayout = () => { return ( <> - - - + {/* TODO: 홈 / 진단홈 / 경험홈 / 마이페이지홈에서만 Footer가 보이도록 수정 */} {true &&