diff --git a/index.html b/index.html index 88ef76d..7ffce1f 100644 --- a/index.html +++ b/index.html @@ -2,9 +2,9 @@ - + - 모두의 중간 + ASAP
diff --git a/package-lock.json b/package-lock.json index 4a057bb..61ef8f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "@heroicons/react": "^2.1.3", + "@hookform/resolvers": "^3.9.0", "@tanstack/react-query": "^5.29.2", "axios": "^1.6.8", "framer-motion": "^11.2.10", @@ -21,6 +22,7 @@ "styled-components": "^6.1.8", "styled-reset": "^4.5.2", "vite-plugin-svgr": "^4.2.0", + "yup": "^1.4.0", "zustand": "^4.5.2" }, "devDependencies": { @@ -2396,6 +2398,14 @@ "react": ">= 16" } }, + "node_modules/@hookform/resolvers": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.9.0.tgz", + "integrity": "sha512-bU0Gr4EepJ/EQsH/IwEzYLsT/PEj5C0ynLQ4m+GSHS+xKH4TfSelhluTgOaoc4kA5s7eCsQbM4wvZLzELmWzUg==", + "peerDependencies": { + "react-hook-form": "^7.0.0" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", @@ -11448,6 +11458,11 @@ "react-is": "^16.13.1" } }, + "node_modules/property-expr": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", + "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==" + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -12652,6 +12667,11 @@ "node": ">=0.8" } }, + "node_modules/tiny-case": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", + "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==" + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -12678,6 +12698,11 @@ "node": ">=8.0" } }, + "node_modules/toposort": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==" + }, "node_modules/tough-cookie": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", @@ -13687,6 +13712,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yup": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/yup/-/yup-1.4.0.tgz", + "integrity": "sha512-wPbgkJRCqIf+OHyiTBQoJiP5PFuAXaWiJK6AmYkzQAh5/c2K9hzSApBZG5wV9KoKSePF7sAxmNSvh/13YHkFDg==", + "dependencies": { + "property-expr": "^2.0.5", + "tiny-case": "^1.0.3", + "toposort": "^2.0.2", + "type-fest": "^2.19.0" + } + }, + "node_modules/yup/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zustand": { "version": "4.5.2", "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.2.tgz", diff --git a/package.json b/package.json index c2cdaa5..1c92860 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@heroicons/react": "^2.1.3", + "@hookform/resolvers": "^3.9.0", "@tanstack/react-query": "^5.29.2", "axios": "^1.6.8", "framer-motion": "^11.2.10", @@ -24,6 +25,7 @@ "styled-components": "^6.1.8", "styled-reset": "^4.5.2", "vite-plugin-svgr": "^4.2.0", + "yup": "^1.4.0", "zustand": "^4.5.2" }, "devDependencies": { diff --git a/src/apis/existence.ts b/src/apis/existence.ts new file mode 100644 index 0000000..a9a3366 --- /dev/null +++ b/src/apis/existence.ts @@ -0,0 +1,7 @@ +import axios from 'axios'; +import { BACKEND_URL } from '.'; + +export const fetchExistence = async (roomId: string) => { + const { data } = await axios.get(`${BACKEND_URL}/api/rooms/${roomId}/existence`); + return data.data; +}; diff --git a/src/apis/index.ts b/src/apis/index.ts index a29f1e8..81bbd42 100644 --- a/src/apis/index.ts +++ b/src/apis/index.ts @@ -2,15 +2,16 @@ import axios from 'axios'; -const REFRESH_URL = ''; - +export const BACKEND_URL = import.meta.env.VITE_BACKEND_URI; export const axiosInstance = axios.create({ baseURL: '', }); +const REFRESH_URL = ''; // 로그 아웃 함수 const logout = () => { localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); }; // accessToken, refreshToken 재발급하는 함수 diff --git a/src/apis/login.ts b/src/apis/login.ts index b3e2cce..902aca6 100644 --- a/src/apis/login.ts +++ b/src/apis/login.ts @@ -1,6 +1,5 @@ import axios from 'axios'; - -const BACKEND_URL = import.meta.env.VITE_BACKEND_URI; +import { BACKEND_URL } from '.'; interface ILoginPayload { readonly roomId: string; diff --git a/src/assets/imgs/Navbar/copyLogo.svg b/src/assets/imgs/Navbar/copyLogo.svg new file mode 100644 index 0000000..c09e68c --- /dev/null +++ b/src/assets/imgs/Navbar/copyLogo.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/imgs/Navbar/mainLogo.svg b/src/assets/imgs/Navbar/mainLogo.svg new file mode 100644 index 0000000..d117457 --- /dev/null +++ b/src/assets/imgs/Navbar/mainLogo.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/imgs/loginLogo.svg b/src/assets/imgs/loginLogo.svg new file mode 100644 index 0000000..d4cb19c --- /dev/null +++ b/src/assets/imgs/loginLogo.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/components/BannerMessage.tsx b/src/components/BannerMessage.tsx new file mode 100644 index 0000000..0e314ad --- /dev/null +++ b/src/components/BannerMessage.tsx @@ -0,0 +1,65 @@ +import { useState } from 'react'; +import styled from 'styled-components'; +import { motion, AnimatePresence } from 'framer-motion'; +import CopyLogo from '@/assets/imgs/Navbar/copyLogo.svg?react'; + +export default function BannerMessage() { + const [copied, setCopied] = useState(false); + + const handleCopy = () => { + const url = window.location.href; + navigator.clipboard.writeText(url).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1000); + }); + }; + + return ( + + + + + {copied && ( + + 링크가 복사되었습니다 + + )} + + + + 상대방에게 ASAP 링크를 공유해 위치를 입력 + 하고 중간 지점을 찾아보세요! + + + ); +} + +const Textbox = styled.div` + width: 80%; + background: ${(props) => props.theme.bgColor}; +`; + +const CopyWrapper = styled.div` + position: relative; +`; + +const CopiedMessage = styled(motion.div)` + position: absolute; + bottom: 150%; + left: 50%; + transform: translateX(-50%); + background-color: ${(props) => props.theme.mainColor}; + color: white; + padding: 5px 10px; + border-radius: 5px; + font-size: 14px; + font-weight: 600; + display: flex; + justify-content: center; + width: max-content; +`; diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx new file mode 100644 index 0000000..4c994d8 --- /dev/null +++ b/src/components/Navbar.tsx @@ -0,0 +1,28 @@ +import MainLogo from '@/assets/imgs/Navbar/mainLogo.svg?react'; +import { Link } from 'react-router-dom'; +import { useAtomValue } from 'jotai'; +import { loginAtom } from '@/stores/login-state'; + +export default function Navbar() { + const isLogin = useAtomValue(loginAtom); + + return ( +
+ +
+ ); +} diff --git a/src/components/login.tsx b/src/components/login.tsx index 233ba16..775b981 100644 --- a/src/components/login.tsx +++ b/src/components/login.tsx @@ -1,11 +1,13 @@ import { useForm } from 'react-hook-form'; import { useNavigate, useParams } from 'react-router-dom'; -import Button from './button'; import { useState } from 'react'; import { useSetAtom } from 'jotai'; import { loginAtom } from '@/stores/login-state'; import { useMutation } from '@tanstack/react-query'; import { fetchLogin } from '@/apis/login'; +import LoginLogo from '@/assets/imgs/loginLogo.svg?react'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { schema } from '@/types/Login'; interface IForm { name: string; @@ -14,10 +16,32 @@ interface IForm { export default function Login() { const { roomId } = useParams<{ roomId: string }>(); + + //roomId 유효성 확인 후 유효하지 않다면 notFound출력하는 과정 + // const { + // data: exists, + // isLoading: isPending, + // isError, + // } = useQuery({ + // queryKey: ['existence', roomId], + // queryFn: () => fetchExistence(roomId!), + // enabled: !!roomId, // roomId가 있을 때만 쿼리 실행 + // }); + + // if (isPending) return ; + // if (isError || !exists?.existence || !Boolean(roomId)) return ; + const [formLoading, setFormLoading] = useState(false); const navigate = useNavigate(); const setLoginState = useSetAtom(loginAtom); - const { register, handleSubmit } = useForm(); + const { + register, + handleSubmit, + formState: { errors, isValid }, + } = useForm({ + mode: 'onChange', + resolver: yupResolver(schema), + }); // 사용자 로그인을 위한 과정 const { mutate: userLogin } = useMutation({ @@ -26,7 +50,7 @@ export default function Login() { localStorage.setItem('accessToken', data.data.data.accessToken); localStorage.setItem('refreshToken', data.data.data.refreshToken); setLoginState(true); - navigate(`/enter-location/${roomId}`); + navigate(`/place/alone/${roomId}`); }, onError: (error) => { console.log('로그인 과정 에러', error); @@ -37,6 +61,7 @@ export default function Login() { setFormLoading(true); const { name, pw } = data; + if (roomId === undefined) { alert('login.tsx roomId오류!'); setFormLoading(false); @@ -56,28 +81,38 @@ export default function Login() { }; return ( - <> -
-
-
- 아이디 - - 비밀번호 - -
-
+
+ +

ASAP 로그인

+

+ 번거롭기만 한 회원 가입은 이제 그만! + 일회용 비밀번호를 사용해 편하게 로그인할 수 있어요! +

+ + + {errors.name && {errors.name.message}} + + + {errors.pw && {errors.pw.message}} + + -
); } diff --git a/src/index.css b/src/index.css index ac4d392..a451492 100644 --- a/src/index.css +++ b/src/index.css @@ -17,6 +17,6 @@ @layer components { .primary-btn { - @apply w-full bg-[#5142FF] text-white font-medium rounded-lg text-center hover:bg-indigo-500 transition-colors; + @apply w-full bg-[#5786FF] text-white font-medium rounded-lg text-center hover:bg-blue-400 transition-colors; } } diff --git a/src/pages/Layout.tsx b/src/pages/Layout.tsx new file mode 100644 index 0000000..719e9b6 --- /dev/null +++ b/src/pages/Layout.tsx @@ -0,0 +1,36 @@ +import Navbar from '@/components/Navbar'; +import { Outlet } from 'react-router-dom'; +import styled from 'styled-components'; +import BannerMessage from '@/components/BannerMessage'; + +export default function Layout() { + return ( + + + + + + + + ); +} + +const Container = styled.div` + display: flex; + flex-direction: column; + align-items: center; + margin: 0 auto; + width: 100vw; + max-width: 1920px; + min-width: 1024px; + padding-top: 30px; +`; + +const Content = styled.div` + width: 80%; + display: flex; + flex-direction: column; + align-items: center; + gap: 30px; + border: 1px solid red; +`; diff --git a/src/pages/enter-location.tsx b/src/pages/enter-location.tsx deleted file mode 100644 index f803463..0000000 --- a/src/pages/enter-location.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import SideBar from '@/components/Sidebar'; -import { Outlet, useMatch } from 'react-router-dom'; -import styled from 'styled-components'; - -export default function EnterLocation() { - const locationAloneMatch = useMatch('/enter-location'); - - return ( - - - -

모두의 중간

- {!locationAloneMatch && ( - -

상대방에게 링크를 공유하여 주소를 입력하게 하고 중간 지점을 찾아보세요!

-
- )} - -
-
- ); -} - -const Container = styled.div` - display: flex; - margin: 0 auto; - width: 100vw; - position: relative; - padding-left: 100px; // Sidebar의 너비가 100px이므로 100px로 수정하였고, Sidebar의 width 변경시 동일하게 변경하면됨 - padding-top: 10px; -`; - -const Content = styled.div` - width: 80%; - min-width: 1200px; - margin: 0 auto; - display: flex; - flex-direction: column; - align-items: center; - gap: 20px; -`; - -const Textbox = styled.div` - width: 55%; - min-width: 700px; - background: rgba(81, 66, 255, 0.1); - color: ${(props) => props.theme.mainColor}; - padding: 10px 5px; - font-size: 18px; - text-align: center; -`; diff --git a/src/pages/location-alone.tsx b/src/pages/location-alone.tsx index d2f9ad5..39fc166 100644 --- a/src/pages/location-alone.tsx +++ b/src/pages/location-alone.tsx @@ -1,7 +1,10 @@ import Button from '@/components/button'; import KakaoMap from '@/components/kakao-map'; +import Login from '@/components/login'; +import { loginAtom } from '@/stores/login-state'; import { useMutation } from '@tanstack/react-query'; import axios from 'axios'; +import { useAtomValue } from 'jotai'; import { useState } from 'react'; import { useFieldArray, useForm } from 'react-hook-form'; @@ -39,7 +42,8 @@ const default_format: IFriendList = { }; export default function LocationAlone() { - const [isLoading, setIsLoading] = useState(false); // form제출 상태 + const isLogin = useAtomValue(loginAtom); // 로그인 여부 확인을 위한 변수 + const [isLoading, setIsLoading] = useState(false); // login form제출 상태 const [coordinates, setCoordinates] = useState<{ lat: number; lng: number }[]>([]); // 사용자들의 좌표 목록 const { control, register, handleSubmit, setValue, watch } = useForm({ defaultValues: { @@ -140,66 +144,70 @@ export default function LocationAlone() { }; return ( -
-
-
- {fields.map((field, index) => ( -
-

친구 {index + 1}

-
- -
- -
- - - -
-
-
-
openAddressSearch(index)} - > - {watch(`friendList.${index}.roadNameAddress`) || '주소 입력'} -
+
+ {!isLogin ? ( + + ) : ( + <> + + {fields.map((field, index) => ( +
+

친구 {index + 1}

+
+
+ +
+ + + +
+
+
+
openAddressSearch(index)} + > + {watch(`friendList.${index}.roadNameAddress`) || '주소 입력'} +
+ +
-
- ))} - - -
-
- -
+ ))} + + +
); } diff --git a/src/pages/location-each.tsx b/src/pages/location-each.tsx index 250faff..b2f0394 100644 --- a/src/pages/location-each.tsx +++ b/src/pages/location-each.tsx @@ -51,12 +51,18 @@ export default function LocationEach() { return ( <> -
-
- {!isLogin ? : } -
-
- +
+
+ {isLogin ? ( + <> + +
+ +
+ + ) : ( + + )}
diff --git a/src/routes/router.tsx b/src/routes/router.tsx index 2bebce7..69fcb8e 100644 --- a/src/routes/router.tsx +++ b/src/routes/router.tsx @@ -1,12 +1,12 @@ -import EnterLocation from '@/pages/enter-location'; import Home from '@/pages/home'; import LocationAlone from '@/pages/location-alone'; -import LocationEach from '@/pages/location-each'; import NotFound from '@/pages/not-found'; import Midpoint from '@/pages/midpoint'; import Vote from '@/pages/vote'; import Time from '@/pages/time'; import { createBrowserRouter } from 'react-router-dom'; +import LocationEach from '@/pages/location-each'; +import Layout from '@/pages/Layout'; export const router = createBrowserRouter([ { @@ -14,15 +14,15 @@ export const router = createBrowserRouter([ element: , }, { - path: '/enter-location', - element: , + path: '/page', + element: , children: [ { - path: '', + path: 'alone/:roomId', element: , }, { - path: ':roomId', + path: 'each/:roomId', element: , }, ], diff --git a/src/styled.d.ts b/src/styled.d.ts index faa468b..0b760de 100644 --- a/src/styled.d.ts +++ b/src/styled.d.ts @@ -2,7 +2,7 @@ import 'styled-components'; declare module 'styled-components' { export interface DefaultTheme { - lightPurple: string; + bgColor: string; mainColor: string; } } diff --git a/src/styles/shared/theme.ts b/src/styles/shared/theme.ts index 73fca58..ef63f67 100644 --- a/src/styles/shared/theme.ts +++ b/src/styles/shared/theme.ts @@ -1,6 +1,6 @@ import { DefaultTheme } from 'styled-components'; export const theme: DefaultTheme = { - lightPurple: '#F6F7FB', - mainColor: '#5142FF', + bgColor: '#EFF3FF', + mainColor: '#5786FF', }; diff --git a/src/types/Login/index.ts b/src/types/Login/index.ts new file mode 100644 index 0000000..eb16269 --- /dev/null +++ b/src/types/Login/index.ts @@ -0,0 +1,13 @@ +import * as yup from 'yup'; + +// 비밀번호 유효성 검사 스키마 +export const schema = yup.object().shape({ + name: yup.string().required('이름을 입력해주세요'), + pw: yup + .string() + .required('비밀번호를 입력해주세요') + .matches( + /^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!%*#?&]{8,}$/, + '비밀번호는 영문 대/소문자, 숫자, 특수문자가 포함되어야 합니다', + ), +});