diff --git a/public/correct.png b/public/correct.png new file mode 100644 index 0000000..858035d Binary files /dev/null and b/public/correct.png differ diff --git a/public/learningFinish.png b/public/learningFinish.png new file mode 100644 index 0000000..d803975 Binary files /dev/null and b/public/learningFinish.png differ diff --git a/public/onboardingFinish.png b/public/onboardingFinish.png new file mode 100644 index 0000000..6027df3 Binary files /dev/null and b/public/onboardingFinish.png differ diff --git a/public/wrong.png b/public/wrong.png new file mode 100644 index 0000000..b02fd62 Binary files /dev/null and b/public/wrong.png differ diff --git a/src/components/common/FinishScreen.tsx b/src/components/common/FinishScreen.tsx new file mode 100644 index 0000000..68def70 --- /dev/null +++ b/src/components/common/FinishScreen.tsx @@ -0,0 +1,5 @@ +const FinishScreen = () => { + return
FinishScreen
; +}; + +export default FinishScreen; diff --git a/src/components/common/NextBtn.tsx b/src/components/common/NextBtn.tsx index 1368211..7b113bf 100644 --- a/src/components/common/NextBtn.tsx +++ b/src/components/common/NextBtn.tsx @@ -3,14 +3,18 @@ import { CommonBtn } from "./common.styled"; interface NextBtnProps { isActive: boolean; text: string; - width: string; + width?: string; handleBtn: () => void; } -const NextBtn = ({ width, isActive, text, handleBtn }: NextBtnProps) => { +const NextBtn = (props: NextBtnProps) => { return ( - - {text} + + {props.text} ); }; diff --git a/src/components/common/progressBar/ProgressBar.styeld.ts b/src/components/common/progressBar/ProgressBar.styeld.ts new file mode 100644 index 0000000..22c6d92 --- /dev/null +++ b/src/components/common/progressBar/ProgressBar.styeld.ts @@ -0,0 +1,25 @@ +import styled from "styled-components"; + +export const ProgressBarWrapper = styled.div` + display: flex; + align-items: center; + width: 100%; + height: 14px; + border-radius: 20px; + background: #ededed; + margin-top: 2rem; + position: relative; +`; + +export const Progress = styled.div<{ $percentage: number }>` + background-color: #ffd600; + border-top-left-radius: 20px; + border-bottom-left-radius: 20px; + border-top-right-radius: ${(props) => (props.$percentage >= 99 ? 20 : 0)}px; + border-bottom-right-radius: ${(props) => + props.$percentage >= 99 ? 20 : 0}px; + height: 100%; + width: ${(props) => props.$percentage}%; + transition: width 0.5s ease-in-out; + position: relative; +`; diff --git a/src/components/common/progressBar/ProgressBar.tsx b/src/components/common/progressBar/ProgressBar.tsx new file mode 100644 index 0000000..e0a601c --- /dev/null +++ b/src/components/common/progressBar/ProgressBar.tsx @@ -0,0 +1,15 @@ +import * as S from "./ProgressBar.styeld"; + +interface ProgressBarProps { + percentage: number; +} + +const ProgressBar = (props: ProgressBarProps) => { + return ( + + + + ); +}; + +export default ProgressBar; diff --git a/src/components/common/selectOption/SelectBtn.tsx b/src/components/common/selectOption/SelectBtn.tsx new file mode 100644 index 0000000..20961a2 --- /dev/null +++ b/src/components/common/selectOption/SelectBtn.tsx @@ -0,0 +1,16 @@ +import { SelectBtnProps } from "@type/selectList"; +import * as S from "./SelectOptionList.styled"; +import { colorSets } from "@utils/defaultData"; + +const SelectBtn = (props: SelectBtnProps) => { + const colorSet = colorSets[props.colorName]; + + return ( + + {props.text} + {props.imgURL && } + + ); +}; + +export default SelectBtn; diff --git a/src/components/common/selectOption/SelectOptionList.styled.ts b/src/components/common/selectOption/SelectOptionList.styled.ts new file mode 100644 index 0000000..3762fb1 --- /dev/null +++ b/src/components/common/selectOption/SelectOptionList.styled.ts @@ -0,0 +1,35 @@ +import { ColorSet } from "@type/selectList"; +import styled from "styled-components"; + +export const SelectOptionContainer = styled.div<{ width?: string }>` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + position: relative; + gap: 1rem; + width: ${({ width }) => (width ? width : "100%")}; +`; + +export const SelectBtnWrapper = styled.div<{ $colorSet: ColorSet }>` + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 1.5rem; + box-sizing: border-box; + width: 100%; + height: 5rem; + font-size: 2rem; + font-weight: 700; + background-color: ${({ $colorSet }) => $colorSet.background}; + color: ${({ $colorSet }) => $colorSet.color}; + border: 1px solid ${({ $colorSet }) => $colorSet.border}; + border-radius: 6px; +`; + +export const SelectIcon = styled.img` + width: 2.3rem; + height: 2.3rem; + margin-right: 10px; + vertical-align: middle; +`; diff --git a/src/components/common/selectOption/SelectOptionList.tsx b/src/components/common/selectOption/SelectOptionList.tsx new file mode 100644 index 0000000..525fbc0 --- /dev/null +++ b/src/components/common/selectOption/SelectOptionList.tsx @@ -0,0 +1,52 @@ +import { SelectListProps } from "@type/selectList"; +import SelectBtn from "./SelectBtn"; +import * as S from "./SelectOptionList.styled"; + +const SelectOptionList = (props: SelectListProps) => { + const getColorName = (state: string) => { + switch (state) { + case "correct": + return "green"; + case "wrong": + return "red"; + case "selected": + return "yellow"; + case "default": + default: + return "gray"; + } + }; + + const getImgURL = (state: string) => { + switch (state) { + case "correct": + return "/correct.png"; + case "wrong": + return "/wrong.png"; + case "selected": + return ""; + default: + return ""; + } + }; + + const handleSelect = (value: string | number | null) => { + props.setter(value); + }; + + return ( + + {props.selectList.map((element) => ( + handleSelect(element.value)} + /> + ))} + + ); +}; + +export default SelectOptionList; diff --git a/src/components/login/Login.tsx b/src/components/login/Login.tsx index be19cec..9165b49 100644 --- a/src/components/login/Login.tsx +++ b/src/components/login/Login.tsx @@ -1,5 +1,5 @@ import { CommonWrapper } from "@common/common.styled"; -import { LoginBtn, LogoBox, Title } from "./Login.styled"; +import * as S from "./Login.styled"; import { kakaoURL } from "./kakaoLoginConfig"; const Login = () => { @@ -9,9 +9,9 @@ const Login = () => { return ( - {`{메인소개 멘트}`} - {`{로고이미지}`} - + {`{메인소개 멘트}`} + {`{로고이미지}`} + 3초 로그인 { alt="카카오 로그인 버튼" onClick={toLogin} /> - + ); }; diff --git a/src/components/onboarding/AgeInput.tsx b/src/components/onboarding/AgeInput.tsx new file mode 100644 index 0000000..e2c08eb --- /dev/null +++ b/src/components/onboarding/AgeInput.tsx @@ -0,0 +1,88 @@ +import { colorSets } from "@utils/defaultData"; +import React, { ChangeEvent, KeyboardEvent } from "react"; +import styled from "styled-components"; + +interface AgeInputProps { + age: string | number | null; + setter: (value: number | null) => void; +} + +const AgeInput = ({ age, setter }: AgeInputProps) => { + const [isEditing, setIsEditing] = React.useState(false); + + const handleChange = (event: ChangeEvent) => { + const value = event.target.value; + const newValue = value === "" ? null : Number(value); + setter(newValue); + }; + + const handleBlur = () => { + setIsEditing(false); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Enter") { + setIsEditing(false); + } + }; + + return ( + + {isEditing ? ( + + ) : ( + setIsEditing(true)}> + {age !== null ? `만 ${age}세` : "만"} + + )} + + ); +}; + +export default AgeInput; + +const Container = styled.div<{ $age?: any }>` + display: flex; + align-items: center; + padding: 0 1rem; + box-sizing: border-box; + width: 50%; + height: 4.5rem; + font-size: 2rem; + font-weight: 700; + border: 1px solid + ${({ $age }) => + $age ? colorSets["yellow"].border : colorSets["gray"].border}; + background-color: ${({ $age }) => + $age ? colorSets["yellow"].background : "none"}; + border-radius: 6px; + position: relative; +`; + +const Text = styled.div` + cursor: pointer; + width: 100%; + line-height: 4.5rem; + padding: 0 1rem; +`; + +const Input = styled.input` + font-size: 2rem; + width: 100%; + box-sizing: border-box; + padding: 0 1.5rem; + border: none; + border-radius: 6px; + position: absolute; + top: 0; + left: 0; + height: 100%; + background-color: transparent; +`; diff --git a/src/components/onboarding/SelectAge.tsx b/src/components/onboarding/SelectAge.tsx new file mode 100644 index 0000000..a4bca1e --- /dev/null +++ b/src/components/onboarding/SelectAge.tsx @@ -0,0 +1,21 @@ +import AgeInput from "./AgeInput"; +import * as S from "./onboarding.styled"; + +interface SelectAgeProps { + age: string | number | null; + setter: (value: string | number | null) => void; +} + +const SelectAge = ({ age, setter }: SelectAgeProps) => { + return ( + + 학습자의 나이는 몇살인가요? + + 만 나이 + + + + ); +}; + +export default SelectAge; diff --git a/src/components/onboarding/SelectLanguage.tsx b/src/components/onboarding/SelectLanguage.tsx new file mode 100644 index 0000000..a0b08f0 --- /dev/null +++ b/src/components/onboarding/SelectLanguage.tsx @@ -0,0 +1,21 @@ +import Dropdown from "@components/common/dropDown/Dropdown"; +import * as S from "./onboarding.styled"; +import { nationElements } from "@utils/defaultData"; + +interface SelectLanguageProps { + setter: (value: string | number | null) => void; +} + +const SelectLanguage = ({ setter }: SelectLanguageProps) => { + return ( + + 어떤 언어를 배우시나요? + + 언어 + + + + ); +}; + +export default SelectLanguage; diff --git a/src/components/onboarding/SelectLevel.tsx b/src/components/onboarding/SelectLevel.tsx new file mode 100644 index 0000000..52a5b5e --- /dev/null +++ b/src/components/onboarding/SelectLevel.tsx @@ -0,0 +1,39 @@ +import * as S from "./onboarding.styled"; +import useSelectLevel from "@hooks/useSelectLevel"; +import { nationElements } from "@utils/defaultData"; +import SelectOptionList from "@common/selectOption/SelectOptionList"; + +interface SelectLevelProps { + languageId: number | string; + setter: (value: string | number | null) => void; +} + +const SelectLevel = ({ languageId, setter }: SelectLevelProps) => { + const { selectedLevel, setSelectedLevel, levelOptions } = useSelectLevel(); + const language = nationElements.find( + (element) => element.value === languageId + ); + + const handleOptionChange = (value: string | number | null) => { + setSelectedLevel(value); + setter(value); + }; + + return ( + + {language?.text}를 얼마나 알고 계신가요? + + ({ + text: option.text, + value: option.value, + state: selectedLevel === option.value ? "selected" : "default", + }))} + setter={handleOptionChange} + /> + + + ); +}; + +export default SelectLevel; diff --git a/src/components/onboarding/onboarding.styled.ts b/src/components/onboarding/onboarding.styled.ts new file mode 100644 index 0000000..14bbc11 --- /dev/null +++ b/src/components/onboarding/onboarding.styled.ts @@ -0,0 +1,30 @@ +import styled from "styled-components"; + +export const Container = styled.div` + display: flex; + flex-direction: column; + width: 100%; + min-height: 70%; + margin-top: 6rem; + gap: 4rem; +`; + +export const Title = styled.div` + font-size: 2.5rem; + font-weight: 800; +`; + +export const SubContainer = styled.div` + display: flex; + flex-direction: column; + width: 100%; + gap: 2rem; +`; + +export const SubTitle = styled.div` + font-size: 2rem; + font-weight: 700; + color: #b1b1b1; +`; + + diff --git a/src/components/tales/readTale/ReadTale.tsx b/src/components/tales/readTale/ReadTale.tsx index d345b52..c64b07a 100644 --- a/src/components/tales/readTale/ReadTale.tsx +++ b/src/components/tales/readTale/ReadTale.tsx @@ -1,12 +1,12 @@ import Header from "@components/common/header/Header"; import * as S from "./ReadTale.styled"; import Dropdown from "@components/common/dropDown/Dropdown"; -import { nationElements } from "@pages/OnboardingPage"; import { useEffect, useState } from "react"; import NextBtn from "@components/common/NextBtn"; import LoadingScreen from "@components/common/spinner/LoadingScreen"; import { createTale } from "@apis/createTales"; import { useLocation } from "react-router-dom"; +import { nationElements } from "@utils/defaultData"; const ReadTale = () => { const location = useLocation(); diff --git a/src/components/tales/taleDetail/TaleDetail.tsx b/src/components/tales/taleDetail/TaleDetail.tsx index fbe1047..114835b 100644 --- a/src/components/tales/taleDetail/TaleDetail.tsx +++ b/src/components/tales/taleDetail/TaleDetail.tsx @@ -1,11 +1,12 @@ import Header from "@components/common/header/Header"; import * as S from "./TaleDetail.styled"; import Dropdown from "@components/common/dropDown/Dropdown"; -import { nationElements } from "@pages/OnboardingPage"; import { useEffect, useState } from "react"; import NextBtn from "@components/common/NextBtn"; +import { nationElements } from "@utils/defaultData"; import { useLocation, useNavigate } from "react-router-dom"; + const TaleDetail = () => { const location = useLocation(); const { selectKeywords } = location.state || {}; diff --git a/src/hooks/test.tsx b/src/hooks/test.tsx deleted file mode 100644 index e69de29..0000000 diff --git a/src/hooks/useAgeInput.ts b/src/hooks/useAgeInput.ts new file mode 100644 index 0000000..033fd68 --- /dev/null +++ b/src/hooks/useAgeInput.ts @@ -0,0 +1,17 @@ +import { useState } from "react"; + +const useAgeInput = () => { + const [age, setAge] = useState(null); + + const updateAge = (value: number | null) => { + setAge(value); + }; + + const getAgeText = () => { + return age !== null ? `만 ${age}세` : "만"; + }; + + return { age, updateAge, getAgeText }; +}; + +export default useAgeInput; diff --git a/src/hooks/useOnboarding.ts b/src/hooks/useOnboarding.ts new file mode 100644 index 0000000..082aff6 --- /dev/null +++ b/src/hooks/useOnboarding.ts @@ -0,0 +1,51 @@ +import { useState, useEffect } from "react"; + +const useOnboarding = () => { + const [languageId, setLanguageId] = useState(null); + const [learningLevel, setLearningLevel] = useState( + null + ); + const [age, setAge] = useState(null); + const [currentStep, setCurrentStep] = useState(0); + const [isStepCompleted, setIsStepCompleted] = useState([false, false, false]); + + const handleNextStep = () => { + if (currentStep < 2) { + setCurrentStep(currentStep + 1); + } else { + console.log(languageId, learningLevel, age); + } + }; + + const checkStepCompletion = () => { + const stepsCompletion = [false, false, false]; + if (languageId) stepsCompletion[0] = true; + if (learningLevel) stepsCompletion[1] = true; + if (age !== null) stepsCompletion[2] = true; + + setIsStepCompleted(stepsCompletion); + }; + + useEffect(() => { + checkStepCompletion(); + }, [languageId, learningLevel, age]); + + const isLastStep = currentStep === 2; + const isNextBtnActive = isStepCompleted[currentStep]; + + return { + languageId, + setLanguageId, + learningLevel, + setLearningLevel, + age, + setAge, + currentStep, + setCurrentStep, + isLastStep, + isNextBtnActive, + handleNextStep, + }; +}; + +export default useOnboarding; diff --git a/src/hooks/useResult.ts b/src/hooks/useResult.ts new file mode 100644 index 0000000..f3c5173 --- /dev/null +++ b/src/hooks/useResult.ts @@ -0,0 +1,11 @@ +import { useState, useCallback } from "react"; + +export const useResult = () => { + const [result, setResult] = useState(null); + + const updateResult = useCallback((value: string | number | null) => { + setResult(value); + }, []); + + return [result, updateResult] as const; +}; diff --git a/src/hooks/useSelectLevel.ts b/src/hooks/useSelectLevel.ts new file mode 100644 index 0000000..2b0ea28 --- /dev/null +++ b/src/hooks/useSelectLevel.ts @@ -0,0 +1,23 @@ +import { useState } from "react"; + +const useSelectLevel = () => { + const [selectedLevel, setSelectedLevel] = useState( + null + ); + + const levelOptions = [ + { text: "처음 배워요", value: "1000" }, + { text: "자주 사용되는 단어 몇 개를 알고 있어요", value: "2000" }, + { text: "기본적인 대화를 할 수 있어요", value: "3000" }, + { text: "다양한 주제에 대해 이야기할 수 있어요", value: "4000" }, + { text: "대부분의 주제에 대해 상세하게 논의할 수 있어요", value: "5000" }, + ]; + + return { + selectedLevel, + setSelectedLevel, + levelOptions, + }; +}; + +export default useSelectLevel; diff --git a/src/pages/OnboardingPage.tsx b/src/pages/OnboardingPage.tsx index ce4f6fa..466b469 100644 --- a/src/pages/OnboardingPage.tsx +++ b/src/pages/OnboardingPage.tsx @@ -1,45 +1,40 @@ -import Dropdown from "@common/dropDown/Dropdown"; -import { DropdownElement } from "@type/dropdown"; -import { useEffect, useState } from "react"; import { CommonWrapper } from "@common/common.styled"; - -export const nationElements: DropdownElement[] = [ - { - text: "선택해주세요", - value: null, - }, - { - imgURL: `/america.png`, - text: "영어", - value: "영어", - }, - { - imgURL: `/china.png`, - text: "중국어", - value: "중국어", - }, - { - imgURL: `/korea.png`, - text: "한국어", - value: "한국어", - }, - { - imgURL: `/japan.png`, - text: "일본어", - value: "일본어", - }, -]; +import ProgressBar from "@components/common/progressBar/ProgressBar"; +import SelectLanguage from "@components/onboarding/SelectLanguage"; +import NextBtn from "@components/common/NextBtn"; +import SelectLevel from "@components/onboarding/SelectLevel"; +import SelectAge from "@components/onboarding/SelectAge"; +import useOnboarding from "@hooks/useOnboarding"; const OnboardingPage = () => { - const [result, setResult] = useState(); - - useEffect(() => { - console.log(result); - }, [result]); + const { + languageId, + setLanguageId, + learningLevel, + setLearningLevel, + age, + setAge, + currentStep, + isLastStep, + isNextBtnActive, + handleNextStep, + } = useOnboarding(); return ( - + + {currentStep === 0 && } + {currentStep === 1 && languageId && ( + + )} + {currentStep === 2 && languageId && learningLevel && ( + + )} + ); }; diff --git a/src/stories/Dropdown.stories.ts b/src/stories/Dropdown.stories.ts index d82cc24..a9a1c2d 100644 --- a/src/stories/Dropdown.stories.ts +++ b/src/stories/Dropdown.stories.ts @@ -3,7 +3,7 @@ import Dropdown from "@common/dropDown/Dropdown"; import { DropdownProps } from "@type/dropdown"; import { Meta, StoryObj } from "@storybook/react"; import { fn } from "@storybook/test"; -import { nationElements } from "@pages/OnboardingPage"; +import { nationElements } from "@utils/defaultData"; const meta: Meta = { title: "Components/Dropdown", diff --git a/src/type/selectList.d.ts b/src/type/selectList.d.ts new file mode 100644 index 0000000..62e5112 --- /dev/null +++ b/src/type/selectList.d.ts @@ -0,0 +1,24 @@ +export interface ColorSet { + background: string; + border: string; + color: string; +} + +export interface SelectListProps { + selectList: SelectOptionElement[]; + setter: (value: string | number | null) => void; + width?: string; +} + +export interface SelectListElement { + text: string; + state: "correct" | "wrong" | "selected" | "default"; + value: string | number; +} + +export interface SelectBtnProps { + text: string; + colorName: string; + imgURL?: string; + onClick: () => void; +} diff --git a/src/utils/defaultData.ts b/src/utils/defaultData.ts new file mode 100644 index 0000000..3d535b4 --- /dev/null +++ b/src/utils/defaultData.ts @@ -0,0 +1,52 @@ +import { DropdownElement } from "@type/dropdown"; +import { ColorSet } from "@type/selectList"; + +export const nationElements: DropdownElement[] = [ + { + text: "선택해주세요", + value: null, + }, + { + imgURL: `/america.png`, + text: "영어", + value: 1, + }, + { + imgURL: `/china.png`, + text: "중국어", + value: 2, + }, + { + imgURL: `/korea.png`, + text: "한국어", + value: 3, + }, + { + imgURL: `/japan.png`, + text: "일본어", + value: 4, + }, +]; + +export const colorSets: { [key: string]: ColorSet } = { + red: { + background: "#FFDADA", + border: "#FF5757", + color: "black", + }, + green: { + background: "#E6FFE5", + border: "#6CE368", + color: "black", + }, + yellow: { + background: "#FFF9E5", + border: "#FFC300", + color: "black", + }, + gray: { + background: "none", + border: "#D8D8D8", + color: "#909090", + }, +}; \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 2d14fa1..7af903b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,8 @@ "@components/*": ["src/components/*"], "@common/*": ["src/components/common/*"], "@utils/*": ["src/utils/*"], - "@apis/*": ["src/apis/*"] + "@apis/*": ["src/apis/*"], + "@hooks/*": ["src/hooks/*"] } }, "files": [], diff --git a/vite.config.ts b/vite.config.ts index 791aef7..37099fa 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -22,6 +22,7 @@ export default defineConfig({ }, { find: "@utils", replacement: path.resolve(__dirname, "src/utils") }, { find: "@apis", replacement: path.resolve(__dirname, "src/apis") }, + { find: "@hooks", replacement: path.resolve(__dirname, "src/hooks") }, ], }, });