diff --git a/prettier.config.js b/prettier.config.js index dcce19d..353db18 100644 --- a/prettier.config.js +++ b/prettier.config.js @@ -16,11 +16,13 @@ module.exports = { tailwindFunctions: ['clsx', 'twMerge'], importOrder: [ '^next(.*)$', // Next 관련 import를 최상단으로 + '^react(.*)$', // React 관련 import를 최상단으로 '', // 외부 모듈 '^@/app/api/(.*)$', // api 폴더 '^@/components/(.*)$', // components 폴더를 '^@/queries/(.*)$', // queries 폴더 '^@/hooks/(.*)$', // hooks 폴더 + '^@/stores/(.*)$', // stores 폴더 '^@/services/(.*)$', // services 폴더 '^@/utils/(.*)$', // utils 폴더 '^[./]', // 상대 경로 diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 86cdbb9..d076f83 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,8 +1,8 @@ -import Head from 'next/head' - import Providers from '@/app/providers' import '@/styles/globals.css' +import { Modal } from '@/components/common/popup' + const RootLayout = ({ children, }: Readonly<{ @@ -10,16 +10,20 @@ const RootLayout = ({ }>): JSX.Element => { return ( - + - + - {children} + + {children} +
+ +
) diff --git a/src/components/auth/SignUpSuccessModalContent.tsx b/src/components/auth/SignUpSuccessModalContent.tsx new file mode 100644 index 0000000..e0dac44 --- /dev/null +++ b/src/components/auth/SignUpSuccessModalContent.tsx @@ -0,0 +1,28 @@ +import { ModalContent } from '@/components/shared/modalContent' + +import celebrateImage from '/public/assets/images/img-celebration.png' + +export const SignUpSuccessModalContent = (): JSX.Element => { + return ( + + + + 홍길동님의 회원가입이 +
+ 성공적으로 완료되었습니다. + + } + /> + 프로필'} + lastLabel={'에서 가능합니다.'} + to={'/'} + /> + +
+ ) +} diff --git a/src/components/common/button/Button.tsx b/src/components/common/button/Button.tsx index dcd864d..1ffe949 100644 --- a/src/components/common/button/Button.tsx +++ b/src/components/common/button/Button.tsx @@ -1,6 +1,8 @@ +import clsx from 'clsx' + import { Clickable, ClickableProps } from './Clickable' -interface ButtonProps +export interface ButtonProps extends ClickableProps, React.ButtonHTMLAttributes {} @@ -8,9 +10,15 @@ export const Button = ({ onClick, type = 'button', disabled = false, + fullWidth, ...props }: ButtonProps): JSX.Element => ( - ) diff --git a/src/components/common/button/Clickable.tsx b/src/components/common/button/Clickable.tsx index 7885c05..56032a1 100644 --- a/src/components/common/button/Clickable.tsx +++ b/src/components/common/button/Clickable.tsx @@ -1,5 +1,4 @@ import clsx from 'clsx' -import { twMerge } from 'tailwind-merge' export interface ClickableProps { label?: string @@ -83,18 +82,18 @@ export const Clickable = ({ : '' const textColorClass = textColor ? styleByTextColor[textColor] : '' - const clickableStyle = twMerge( + const clickableStyle = clsx( baseStyle, styleByVariant[variant], styleBySize[size], textColorClass, - clsx({ + { [borderColorClass]: variant === 'outlined', [backgroundColorClass]: variant !== 'text', [disabledStyle]: disabled, 'w-full': fullWidth, 'justify-start': leftAlign, - }), + }, className ) diff --git a/src/components/common/button/Link.tsx b/src/components/common/button/Link.tsx index b36afa2..23de51e 100644 --- a/src/components/common/button/Link.tsx +++ b/src/components/common/button/Link.tsx @@ -1,14 +1,18 @@ import NextLink from 'next/link' +import clsx from 'clsx' + import { Clickable, ClickableProps } from './Clickable' -interface LinkProps +export interface LinkProps extends ClickableProps, React.AnchorHTMLAttributes {} export const Link = ({ href = '#', disabled, + onClick = () => {}, + fullWidth, ...props }: LinkProps): JSX.Element => ( { if (disabled) { e.preventDefault() + } else { + onClick(e) } }} + className={clsx({ 'w-full': fullWidth })} > - + ) diff --git a/src/components/common/button/index.ts b/src/components/common/button/index.ts index 8725dcb..9cd94db 100644 --- a/src/components/common/button/index.ts +++ b/src/components/common/button/index.ts @@ -1,4 +1,4 @@ -import { Button } from './Button' -import { Link } from './Link' +import { Button, type ButtonProps } from './Button' +import { Link, type LinkProps } from './Link' -export { Button, Link } +export { Button, ButtonProps, Link, LinkProps } diff --git a/src/components/common/containers/Box.tsx b/src/components/common/containers/Box.tsx index 6da9e09..91577d1 100644 --- a/src/components/common/containers/Box.tsx +++ b/src/components/common/containers/Box.tsx @@ -2,18 +2,16 @@ import { twMerge } from 'tailwind-merge' type Variant = 'contained' | 'outlined' type Rounded = 8 | 12 -type Padding = 0 | 10 | 20 | 30 | 40 +type Padding = 0 | 10 | 20 | 30 | 32 | 40 type Margin = 0 | 10 | 20 | 30 | 40 type Color = 'primary' | 'secondary' | 'tertiary' -interface BoxProps { - children: React.ReactNode +interface BoxProps extends React.HTMLAttributes { variant?: Variant rounded?: Rounded padding?: Padding margin?: Margin color?: Color - className?: string } const BaseStyle = 'flex items-center justify-center w-full' @@ -33,6 +31,7 @@ const styleByPadding: Record = { 10: 'p-10', 20: 'p-20', 30: 'p-30', + 32: 'p-32', 40: 'p-40', } @@ -54,8 +53,8 @@ export const Box = ({ children, variant = 'outlined', rounded = 12, - padding = 20, - margin = 10, + padding = 0, + margin = 0, color = 'primary', className = '', }: BoxProps): JSX.Element => { diff --git a/src/components/common/containers/index.tsx b/src/components/common/containers/index.ts similarity index 100% rename from src/components/common/containers/index.tsx rename to src/components/common/containers/index.ts diff --git a/src/components/common/logo/index.tsx b/src/components/common/logo/Logo.tsx similarity index 100% rename from src/components/common/logo/index.tsx rename to src/components/common/logo/Logo.tsx diff --git a/src/components/common/logo/index.ts b/src/components/common/logo/index.ts new file mode 100644 index 0000000..8d81e67 --- /dev/null +++ b/src/components/common/logo/index.ts @@ -0,0 +1,3 @@ +import { Logo } from './Logo' + +export { Logo } diff --git a/src/components/common/popup/Modal.tsx b/src/components/common/popup/Modal.tsx new file mode 100644 index 0000000..b75c539 --- /dev/null +++ b/src/components/common/popup/Modal.tsx @@ -0,0 +1,90 @@ +'use client' + +import { useEffect, useState } from 'react' + +import clsx from 'clsx' + +import { Box } from '@/components/common/containers' + +import useModalStore from '@/stores/useModalStore' + +import { Portal } from './Portal' + +const baseOverlayStyle = + 'fixed inset-0 z-50 flex h-screen w-screen items-center justify-center bg-common-white bg-opacity-80 transition-opacity duration-300 ease-out' + +const baseBoxStyle = + ' w-440 transform shadow-level4 transition-all duration-300 ease-out' + +export const Modal = (): JSX.Element | null => { + const { isOpen, content, closeModal } = useModalStore() + const [isVisible, setIsVisible] = useState(false) + + useEffect(() => { + let openTimer: NodeJS.Timeout | undefined + let closeTimer: NodeJS.Timeout | undefined + + if (isOpen) { + openTimer = setTimeout(() => setIsVisible(true), 50) + document.body.style.overflow = 'hidden' + + const handleEsc = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + closeModal() + } + } + + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as HTMLElement + if (target.getAttribute('aria-label') === 'modal overlay') { + closeModal() + } + } + + window.addEventListener('keydown', handleEsc) + window.addEventListener('mousedown', handleClickOutside) + + return () => { + window.removeEventListener('keydown', handleEsc) + window.removeEventListener('mousedown', handleClickOutside) + } + } else { + document.body.style.overflow = '' + closeTimer = setTimeout(() => setIsVisible(false), 500) + } + + return () => { + if (openTimer) clearTimeout(openTimer) + if (closeTimer) clearTimeout(closeTimer) + } + }, [isOpen, closeModal]) + + if (!isVisible) return null + + const overlayStyle = clsx(baseOverlayStyle, { + 'opacity-100': isOpen, + 'opacity-0': !isOpen, + }) + + const boxStyle = clsx(baseBoxStyle, { + 'translate-y-0 scale-100 opacity-100': isOpen, + '-translate-y-50 scale-95 opacity-0': !isOpen, + }) + + return ( + +
+ e.stopPropagation()} + > + {content} + +
+
+ ) +} diff --git a/src/components/common/popup/Portal.tsx b/src/components/common/popup/Portal.tsx new file mode 100644 index 0000000..35538f7 --- /dev/null +++ b/src/components/common/popup/Portal.tsx @@ -0,0 +1,22 @@ +'use client' + +import { ReactNode, useEffect, useState } from 'react' +import ReactDOM from 'react-dom' + +interface PortalProps { + children: ReactNode +} + +export const Portal = ({ children }: PortalProps): JSX.Element | null => { + const [mounted, setMounted] = useState(false) + + useEffect(() => { + const portalRoot = document.getElementById('portal-root') + if (portalRoot) setMounted(true) + }, []) + + const portalRoot = document.getElementById('portal-root') + if (!mounted || !portalRoot) return null + + return ReactDOM.createPortal(children, portalRoot) +} diff --git a/src/components/common/popup/index.ts b/src/components/common/popup/index.ts new file mode 100644 index 0000000..49dba50 --- /dev/null +++ b/src/components/common/popup/index.ts @@ -0,0 +1,3 @@ +import { Modal } from './Modal' + +export { Modal } diff --git a/src/components/common/text/Highlight.tsx b/src/components/common/text/Highlight.tsx new file mode 100644 index 0000000..4bda0b6 --- /dev/null +++ b/src/components/common/text/Highlight.tsx @@ -0,0 +1,5 @@ +export const Highlight = ({ + children, +}: React.PropsWithChildren): JSX.Element => { + return {children} +} diff --git a/src/components/common/text/Text.tsx b/src/components/common/text/Text.tsx index b123c86..e7ca030 100644 --- a/src/components/common/text/Text.tsx +++ b/src/components/common/text/Text.tsx @@ -1,5 +1,4 @@ import clsx from 'clsx' -import React from 'react' type Variant = | 'heading1' diff --git a/src/components/common/text/index.tsx b/src/components/common/text/index.tsx index 6ca9e8e..66237a0 100644 --- a/src/components/common/text/index.tsx +++ b/src/components/common/text/index.tsx @@ -1,3 +1,4 @@ +import { Highlight } from './Highlight' import { Text } from './Text' -export { Text } +export { Text, Highlight } diff --git a/src/components/shared/modalContent/ModalContent.tsx b/src/components/shared/modalContent/ModalContent.tsx new file mode 100644 index 0000000..f486ceb --- /dev/null +++ b/src/components/shared/modalContent/ModalContent.tsx @@ -0,0 +1,134 @@ +import Image, { StaticImageData } from 'next/image' +import NextLink from 'next/link' + +import { + Button, + ButtonProps, + Link, + LinkProps, +} from '@/components/common/button' +import { Box } from '@/components/common/containers' +import { Highlight, Text } from '@/components/common/text' + +import useModalStore from '@/stores/useModalStore' + +export const ModalContent = ({ + children, +}: React.PropsWithChildren): JSX.Element => { + return ( +
+ {children} +
+ ) +} + +interface ModalImageProps { + src: StaticImageData + alt: string +} + +const ModalImage = ({ src, alt }: ModalImageProps) => { + return ( +
+ {alt} +
+ ) +} + +interface ModalHeaderProps { + title: string + subTitle?: React.ReactNode +} + +const ModalHeader = ({ title, subTitle }: ModalHeaderProps) => { + return ( +
+ + {title} + + {subTitle && ( + + {subTitle} + + )} +
+ ) +} + +interface ModalInfoBoxProps { + firstLabel: string + lastLabel: string + linkLabel?: string + to?: string +} + +const ModalInfoBox = ({ + firstLabel, + lastLabel, + linkLabel, + to, +}: ModalInfoBoxProps) => { + const { closeModal } = useModalStore() + + return ( + + + *{firstLabel} + {to && ( + + {linkLabel} + + )} + {lastLabel} + + + ) +} + +const ModalButton = ({ + onClick = () => {}, + ...props +}: ButtonProps): JSX.Element => { + const { closeModal } = useModalStore() + const handleClick = (e: React.MouseEvent) => { + onClick(e) + closeModal() + } + + return ( +