diff --git a/public/images/kakao-qr.png b/public/images/kakao-qr.png new file mode 100644 index 00000000..585691b7 Binary files /dev/null and b/public/images/kakao-qr.png differ diff --git a/src/components/GNB/MobileMenu.tsx b/src/components/GNB/MobileMenu.tsx index 71c2f422..7ba1f356 100644 --- a/src/components/GNB/MobileMenu.tsx +++ b/src/components/GNB/MobileMenu.tsx @@ -25,7 +25,7 @@ export function MobileMenu({ onClickMenu }: MobileMenuProps) { return ( diff --git a/src/components/GuideModalButton.tsx b/src/components/GuideModalButton.tsx new file mode 100644 index 00000000..2d7d04d7 --- /dev/null +++ b/src/components/GuideModalButton.tsx @@ -0,0 +1,100 @@ +import React, { useState } from 'react'; +import Image from 'next/image'; +import { css, Theme } from '@emotion/react'; + +import { Button } from '~/components/Button'; +import CloseIcon from '~/components/Icons/CloseIcon'; +import Modal from '~/components/Modal/Modal'; +import { mediaQuery } from '~/styles/media'; + +function GuideModalButton() { + const [isAlertOpen, setIsAlertOpen] = useState(false); + + const onAlertClose = () => setIsAlertOpen(false); + + const onAlertButtonClick = () => { + setIsAlertOpen(true); + }; + return ( + <> + + + + + + + ); +} + +export default GuideModalButton; + +const buttonCss = css` + max-width: 443px; + margin: 0 auto; + + ${mediaQuery('mobile')} { + max-width: 266px; + } +`; + +const buttonWrapperCss = css` + margin-top: 39px; + margin-bottom: 24px; + padding: 0 24px; + width: 100%; + + button { + height: 46px; + width: 100%; + + ${mediaQuery('mobile')} { + height: 46px; + } + } +`; + +const modalCss = (theme: Theme) => css` + background-color: ${theme.colors.black400}; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + text-align: center; + width: 320px; + position: relative; + + h2 { + ${theme.typos.pretendard.body1}; + color: ${theme.colors.white}; + margin-bottom: 8px; + margin-top: 47px; + } + + p { + ${theme.typos.pretendard.body2}; + color: ${theme.colors.gray100}; + margin-bottom: 39px; + max-width: 221px; + } + + .close-icon { + position: absolute; + top: 16px; + right: 16px; + cursor: pointer; + } +`; diff --git a/src/components/Icons/CloseIcon.tsx b/src/components/Icons/CloseIcon.tsx new file mode 100644 index 00000000..a6ff57ec --- /dev/null +++ b/src/components/Icons/CloseIcon.tsx @@ -0,0 +1,24 @@ +import { Props, Svg } from '~/components/Icons/Svg'; + +function CloseIcon({ color = '#606475', ...props }: Props) { + return ( + + + + ); +} + +export default CloseIcon; diff --git a/src/components/Modal/AnimatePortal.tsx b/src/components/Modal/AnimatePortal.tsx new file mode 100644 index 00000000..fa8408f3 --- /dev/null +++ b/src/components/Modal/AnimatePortal.tsx @@ -0,0 +1,29 @@ +import { type ComponentProps } from 'react'; +import { AnimatePresence } from 'framer-motion'; + +import Portal from '~/components/Modal/Portal'; + +interface Props extends ComponentProps { + /** + * children의 렌더링 여부 + */ + isShowing: boolean; + /** + * framer-motion AnimatePresence의 mode + * @default 'wait' + */ + mode?: ComponentProps['mode']; +} + +/** + * @description Portal을 AnimatePresence와 함께 사용합니다 + */ +const AnimatePortal = ({ children, isShowing, mode = 'wait' }: Props) => { + return ( + + {isShowing && children} + + ); +}; + +export default AnimatePortal; diff --git a/src/components/Modal/Modal.tsx b/src/components/Modal/Modal.tsx new file mode 100644 index 00000000..e2749167 --- /dev/null +++ b/src/components/Modal/Modal.tsx @@ -0,0 +1,121 @@ +import { type ComponentProps, type MouseEvent, type PropsWithChildren } from 'react'; +import { css } from '@emotion/react'; +import { m, Variants } from 'framer-motion'; + +import AnimatePortal from '~/components/Modal/AnimatePortal'; +import useScrollLock from '~/components/Modal/useScrollLock'; + +interface Props { + /** + * 외부영역 클릭시 호출될 함수 + */ + onClickOutside?: VoidFunction; +} + +/** + * + * @param isShowing 열림/닫힘 상태 + * @param mode AnimatePresence mode + * @param onClickOutside 외부영역 클릭시 호출될 함수 + */ +const Modal = ({ + isShowing, + mode, + onClickOutside, + children, +}: PropsWithChildren & ComponentProps) => { + useScrollLock({ lock: isShowing }); + + return ( + +
+ + + {children} + +
+
+ ); +}; +const defaultEasing = [0.6, -0.05, 0.01, 0.99]; + +const defaultFadeInVariants: Variants = { + initial: { + opacity: 0, + transition: { duration: 0.3, ease: defaultEasing }, + willChange: 'opacity', + }, + animate: { + opacity: 1, + transition: { duration: 0.3, ease: defaultEasing }, + willChange: 'opacity', + }, + exit: { + opacity: 0, + transition: { duration: 0.3, ease: defaultEasing }, + willChange: 'opacity', + }, +}; + +const dialogPositionCss = css` + position: absolute; + top: 0; + left: 0; + + display: flex; + align-items: center; + justify-content: center; + + width: 100%; + height: 100vh; +`; + +const ModalBlur = ({ onClickOutside }: Pick) => { + const onClickOutsideDefault = (e: MouseEvent) => { + if (e.target !== e.currentTarget) return; + if (onClickOutside) onClickOutside(); + }; + + return ( + + ); +}; + +const blurCss = css` + position: fixed; + z-index: 9999; + top: 0; + left: 0; + + width: 100%; + height: 100%; + background: var(--DIM-70, rgba(19, 28, 40, 0.7)); +`; + +const containerCss = css` + position: fixed; + z-index: 10000; + + display: flex; + flex-direction: column; + + border-radius: 16px; +`; + +export default Modal; diff --git a/src/components/Modal/Portal.tsx b/src/components/Modal/Portal.tsx new file mode 100644 index 00000000..8f3caee5 --- /dev/null +++ b/src/components/Modal/Portal.tsx @@ -0,0 +1,21 @@ +import { type PropsWithChildren, useEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; + +/** + * @description react.createPortal을 이용해 document.body에 children을 렌더링합니다 + */ +const Portal = ({ children }: PropsWithChildren) => { + const [container, setContainer] = useState(null); + + useEffect(() => { + if (document) { + setContainer(document.body); + } + }, []); + + if (!container) return null; + + return createPortal(children, container); +}; + +export default Portal; diff --git a/src/components/Modal/useScrollLock.tsx b/src/components/Modal/useScrollLock.tsx new file mode 100644 index 00000000..b62c0463 --- /dev/null +++ b/src/components/Modal/useScrollLock.tsx @@ -0,0 +1,14 @@ +import { useEffect } from 'react'; + +const useScrollLock = ({ lock }: { lock: boolean }): void => { + useEffect((): (() => void) => { + const originalStyle: string = window.getComputedStyle(document.body).overflow; + if (lock) { + document.body.style.overflow = 'hidden'; + } + + return () => (document.body.style.overflow = originalStyle); + }, [lock]); +}; + +export default useScrollLock; diff --git a/src/components/Positions/PositionsItem.tsx b/src/components/Positions/PositionsItem.tsx index 55a56c51..bebbdf5a 100644 --- a/src/components/Positions/PositionsItem.tsx +++ b/src/components/Positions/PositionsItem.tsx @@ -1,10 +1,7 @@ import Image from 'next/image'; -import { useRouter } from 'next/router'; import { css, Theme } from '@emotion/react'; -import { ArrowIcon } from '~/components/Icons'; import { POSITION_BASE } from '~/constant/image'; -import useIsInProgress from '~/hooks/useIsInProgress'; import { mediaQuery } from '~/styles/media'; type Position = 'aos' | 'design' | 'ios' | 'server' | 'web'; @@ -15,28 +12,12 @@ interface PositionsItemProps { link: string; } -export function PositionsItem({ type, title, link }: PositionsItemProps) { - const { isInProgress } = useIsInProgress(); - - const router = useRouter(); - - const onClick = () => { - if (isInProgress) { - router.push(link); - } - }; - +export function PositionsItem({ type, title }: PositionsItemProps) { return (
{title}

{title}

-
); @@ -87,40 +68,3 @@ const titleCss = (theme: Theme) => css` font-size: 16px; } `; - -const linkCss = (theme: Theme) => css` - ${theme.typos.pretendard.body1}; - color: ${theme.colors.blue400}; - display: flex; - align-items: center; - margin-top: 8px; - > svg { - margin-left: 4px; - } - - &:disabled { - cursor: not-allowed; - color: ${theme.colors.gray200}; - } - - ${mediaQuery('mobile')} { - font-size: 14px; - } -`; - -const arrowIconCss = (theme: Theme, disabled: boolean) => css` - width: 24px; - height: 24px; - - > path { - stroke: ${theme.colors.blue400}; - stroke-width: 4; - } - - ${disabled && - css` - > path { - stroke: ${theme.colors.gray200}; - } - `} -`; diff --git a/src/components/TimerContainer/TimerContainer.tsx b/src/components/TimerContainer/TimerContainer.tsx index e3b3082f..0dbcff6c 100644 --- a/src/components/TimerContainer/TimerContainer.tsx +++ b/src/components/TimerContainer/TimerContainer.tsx @@ -1,26 +1,11 @@ import Image from 'next/image'; -import { useRouter } from 'next/router'; import { css, Theme } from '@emotion/react'; -import { Button } from '~/components/Button'; -import { END_DATE } from '~/constant/common'; -import useIsInProgress from '~/hooks/useIsInProgress'; +import GuideModalButton from '~/components/GuideModalButton'; import { commonLayoutCss } from '~/styles/layout'; import { mediaQuery } from '~/styles/media'; -import { Timer } from './Timer'; -import useDiffDay from './useDiffDay'; - export function TimerContainer() { - const { isInProgress, progressState } = useIsInProgress(); - const time = useDiffDay(END_DATE); - - const router = useRouter(); - - const onButtonClick = () => { - router.push('/apply'); - }; - return (
@@ -40,23 +25,14 @@ export function TimerContainer() {

완성하며 성장하는 IT 커뮤니티입니다

- - + +
); } -const FINISH_TIME_OBJ = { - day: '00', - hour: '00', - min: '00', - sec: '00', -}; - const containerCss = css` position: relative; padding: 30px 0; diff --git a/src/constant/gnb.ts b/src/constant/gnb.ts index d3070842..982aa4e9 100644 --- a/src/constant/gnb.ts +++ b/src/constant/gnb.ts @@ -3,7 +3,7 @@ export type GNBMenu = { href: '/about' | '/recruit' | '/project' | '/apply'; type: 'text' | 'button'; }; -// TODO: 지원하기 url 넣기 + export const GNB_MENU_NAME: GNBMenu[] = [ { name: 'About', @@ -20,9 +20,4 @@ export const GNB_MENU_NAME: GNBMenu[] = [ href: '/project', type: 'text', }, - { - name: '지원하기', - href: '/apply', - type: 'button', - }, ];