diff --git a/package.json b/package.json index f446cb27..c989803c 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "@tanem/react-nprogress": "^5.0.51", "@tanstack/react-query": "^5.14.2", "@yourssu/design-system-react": "^1.1.2", - "@yourssu/logging-system-react": "^1.0.0", + "@yourssu/logging-system-react": "^1.1.0", + "@yourssu/utils": "^0.2.1", "axios": "^1.6.2", "date-fns": "^3.6.0", "react": "^18.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9fe00bfc..c6af6a0c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,8 +24,11 @@ importers: specifier: ^1.1.2 version: 1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.1.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) '@yourssu/logging-system-react': - specifier: ^1.0.0 - version: 1.0.0(axios@1.6.8)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.23.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + specifier: ^1.1.0 + version: 1.1.0(axios@1.6.8)(react-router-dom@6.23.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) + '@yourssu/utils': + specifier: ^0.2.1 + version: 0.2.1 axios: specifier: ^1.6.2 version: 1.6.8 @@ -950,14 +953,15 @@ packages: react-dom: ^18.2.0 styled-components: ^6.0.7 - '@yourssu/logging-system-react@1.0.0': - resolution: {integrity: sha512-DXu7ZiamqbNT78DnEac3sjJEEc7yQ30KtbHBl3MY/Y0CW3EybAEkwaun0EagPaI3UXbD0LGwUYsO/rJjYp5m4w==} + '@yourssu/logging-system-react@1.1.0': + resolution: {integrity: sha512-LhhIW4w4/UvnHK2kJv6JAm++g6zr8t84OqANqdWA8X0JCe4qGUC1zfVBtuhlaI31bouHLGzi1j0iVjTpPaWD+A==} peerDependencies: axios: ^1.6.4 - react: ^18.2.0 - react-dom: ^18.2.0 react-router-dom: ^6.21.3 + '@yourssu/utils@0.2.1': + resolution: {integrity: sha512-zxeHQNasbOyw2bNVoCpU0+pVSs3FXaC7CggPYOUT6ujLyKBhPMsJRo5kNWJN3HEc/r9GgN3AeUtoD5h9+AniGA==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -3297,13 +3301,13 @@ snapshots: react-dom: 18.3.1(react@18.3.1) styled-components: 6.1.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@yourssu/logging-system-react@1.0.0(axios@1.6.8)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.23.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': + '@yourssu/logging-system-react@1.1.0(axios@1.6.8)(react-router-dom@6.23.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))': dependencies: axios: 1.6.8 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) react-router-dom: 6.23.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@yourssu/utils@0.2.1': {} + acorn-jsx@5.3.2(acorn@8.11.3): dependencies: acorn: 8.11.3 diff --git a/src/App.tsx b/src/App.tsx index e6f3536b..4dd3a4c2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,7 @@ import { BrowserRouter } from 'react-router-dom'; import { RecoilRoot } from 'recoil'; +import { CustomDialog } from './components/Dialog/CustomDialog'; import { Router } from './router'; export const App = () => { @@ -9,6 +10,7 @@ export const App = () => { + diff --git a/src/assets/home/defaultProfile.png b/src/assets/defaultProfile.png similarity index 100% rename from src/assets/home/defaultProfile.png rename to src/assets/defaultProfile.png diff --git a/src/assets/home/profile.svg b/src/assets/home/profile.svg deleted file mode 100644 index b786e29a..00000000 --- a/src/assets/home/profile.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/src/components/Card/Card.style.ts b/src/components/Card/Card.style.ts index bd8e2e62..0bb8c01f 100644 --- a/src/components/Card/Card.style.ts +++ b/src/components/Card/Card.style.ts @@ -42,7 +42,7 @@ export const StyledBookmarkContainer = styled.div` align-items: center; `; -export const StyledSettingIconContainer = styled.div` - display: flex; +export const StyledSettingIconContainer = styled.button` + display: inline-flex; align-items: center; `; diff --git a/src/components/Card/Card.tsx b/src/components/Card/Card.tsx index a1a1c584..d14527bc 100644 --- a/src/components/Card/Card.tsx +++ b/src/components/Card/Card.tsx @@ -1,8 +1,10 @@ +import React from 'react'; + import { - IconContext, + IcDotsVerticalLine, IcStarFilled, IcStarLine, - IcDotsVerticalLine, + IconContext, } from '@yourssu/design-system-react'; import { useNavigate } from 'react-router-dom'; import { useTheme } from 'styled-components'; @@ -12,12 +14,12 @@ import SmallThumbnail from '@/assets/smallThumbnail.png'; import { FlexGrowItem } from '@/components/FlexContainer/FlexContainer'; import { + StyledBookmarkContainer, StyledContainer, + StyledSettingIconContainer, StyledText, - StyledTitle, StyledThumbnail, - StyledBookmarkContainer, - StyledSettingIconContainer, + StyledTitle, } from './Card.style'; import { CardContainerProps, @@ -83,18 +85,16 @@ const CardContent = ({ title, body, bookmarkCount, isBookmarked }: CardContentPr ); }; -const CardSetting = ({ onClick }: CardSettingProps) => { - const theme = useTheme(); - return ( - - - - - - ); -}; +const CardSetting = React.forwardRef( + ({ onClick, ...props }, ref) => { + const theme = useTheme(); + return ( + + + + ); + } +); export const Card = Object.assign(CardContainer, { BigThumbnail: CardBigThumbnail, diff --git a/src/components/Dialog/CustomDialog.style.ts b/src/components/Dialog/CustomDialog.style.ts new file mode 100644 index 00000000..c7be70e6 --- /dev/null +++ b/src/components/Dialog/CustomDialog.style.ts @@ -0,0 +1,31 @@ +import * as Dialog from '@radix-ui/react-dialog'; +import styled, { keyframes } from 'styled-components'; + +import { Z_INDEX } from '@/constants/zIndex.constant'; + +const overlayShow = keyframes({ + from: { opacity: 0 }, + to: { opacity: 1 }, +}); + +export const StyledDialogOverlay = styled(Dialog.Overlay)` + background-color: rgba(0, 0, 0, 0.4); + position: fixed; + inset: 0; + animation: ${overlayShow} 150ms cubic-bezier(0.16, 1, 0.3, 1); + z-index: ${Z_INDEX.dialog}; +`; + +export const StyledDialogContent = styled(Dialog.Content)` + background-color: ${({ theme }) => theme.color.buttonBright}; + box-shadow: 0rem 0rem 0.375rem 0rem rgba(61, 61, 61, 0.2); + border-radius: 0.5rem; + + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + + padding: 1.5rem; + z-index: ${Z_INDEX.dialog}; +`; diff --git a/src/components/Dialog/CustomDialog.tsx b/src/components/Dialog/CustomDialog.tsx new file mode 100644 index 00000000..0c54204e --- /dev/null +++ b/src/components/Dialog/CustomDialog.tsx @@ -0,0 +1,28 @@ +import * as Dialog from '@radix-ui/react-dialog'; +import { useRecoilState } from 'recoil'; + +import { DIALOG } from '@/constants/dialog.constant.tsx'; +import { DialogState } from '@/recoil/DialogState'; + +import { StyledDialogContent, StyledDialogOverlay } from './CustomDialog.style'; + +export const CustomDialog = () => { + const [dialog, setDialog] = useRecoilState(DialogState); + + const handleClose = () => { + setDialog(null); + }; + + return ( + <> + {dialog && ( + + + + {DIALOG[dialog.type]} + + + )} + + ); +}; diff --git a/src/components/Dialog/LoginDialog/LoginDialog.style.ts b/src/components/Dialog/LoginDialog/LoginDialog.style.ts new file mode 100644 index 00000000..7e5ff321 --- /dev/null +++ b/src/components/Dialog/LoginDialog/LoginDialog.style.ts @@ -0,0 +1,20 @@ +import styled from 'styled-components'; + +export const StyledContainer = styled.div` + display: flex; + flex-direction: column; + gap: 8px; + align-items: center; +`; + +export const StyledTitle = styled.p` + white-space: pre-line; + text-align: center; + ${({ theme }) => theme.typo.body1}; + color: ${({ theme }) => theme.color.textPrimary}; +`; + +export const StyledButtonContainer = styled.div` + display: flex; + gap: 8px; +`; diff --git a/src/components/Dialog/LoginDialog/LoginDialog.tsx b/src/components/Dialog/LoginDialog/LoginDialog.tsx new file mode 100644 index 00000000..a5d4a508 --- /dev/null +++ b/src/components/Dialog/LoginDialog/LoginDialog.tsx @@ -0,0 +1,39 @@ +import { DialogClose } from '@radix-ui/react-dialog'; +import { BoxButton } from '@yourssu/design-system-react'; +import { useNavigate } from 'react-router-dom'; + +import Ppussung from '@/assets/defaultProfile.png'; +import { ProfileSvg } from '@/components/ProfileSvg/ProfileSVG'; + +import { StyledContainer, StyledButtonContainer, StyledTitle } from './LoginDialog.style'; + +export const LoginDialog = () => { + const navigate = useNavigate(); + + return ( + + + {'로그인이 필요한 서비스입니다.\n로그인하시겠어요?'} + + + { + navigate('/login'); + }} + > + 예 + + + + + 아니오 + + + + + ); +}; diff --git a/src/components/Dropdown/Dropdown.style.ts b/src/components/Dropdown/Dropdown.style.ts deleted file mode 100644 index 9ceaa0dc..00000000 --- a/src/components/Dropdown/Dropdown.style.ts +++ /dev/null @@ -1,24 +0,0 @@ -import styled from 'styled-components'; - -import { Z_INDEX } from '@/constants/zIndex.constant'; - -interface StyledDropDownProps { - $padding: string; - $bottom: string; - $right?: string; - $left?: string; -} - -export const StyledContainer = styled.div` - padding: ${(props) => props.$padding}; - left: ${(props) => props.$left}; - display: flex; - flex-direction: column; - position: absolute; - z-index: ${Z_INDEX.dropdown}; - bottom: ${(props) => props.$bottom}; - right: ${(props) => props.$right}; - border-radius: 0.25rem; - background: ${({ theme }) => theme.color.bgNormal}; - box-shadow: 0px 1px 3px 0px rgba(107, 114, 128, 0.4); -`; diff --git a/src/components/Dropdown/Dropdown.tsx b/src/components/Dropdown/Dropdown.tsx deleted file mode 100644 index 7a24a3b4..00000000 --- a/src/components/Dropdown/Dropdown.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { forwardRef } from 'react'; - -import { StyledContainer } from './Dropdown.style'; - -export interface DropdownProps extends React.HTMLAttributes { - children: React.ReactNode; - padding: string; - bottom: string; - right?: string; - left?: string; -} - -export const Dropdown = forwardRef( - ({ children, padding, bottom, right, left, ...props }, ref) => { - return ( - - {children} - - ); - } -); diff --git a/src/components/ProfileDropdownMenu/ProfileDropdownMenu.style.ts b/src/components/ProfileDropdownMenu/ProfileDropdownMenu.style.ts new file mode 100644 index 00000000..35e2b1a3 --- /dev/null +++ b/src/components/ProfileDropdownMenu/ProfileDropdownMenu.style.ts @@ -0,0 +1,44 @@ +import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; +import styled, { css } from 'styled-components'; + +import { Z_INDEX } from '@/constants/zIndex.constant'; + +export const StyledProfileDropdownContent = styled(DropdownMenu.Content)` + display: flex; + flex-direction: column; + align-items: flex-start; + + width: fit-content; + padding: 1rem 0.75rem; + gap: 1rem; + z-index: ${Z_INDEX.dropdown}; + + border-radius: 0.25rem; + border: 1px solid ${({ theme }) => theme.color.borderThin}; + background-color: ${({ theme }) => theme.color.bgNormal}; + box-shadow: 0px 1px 3px 0px rgba(107, 114, 128, 0.4); +`; + +export const StyledProfileDropdownNickname = styled(DropdownMenu.Item)` + ${({ theme }) => css` + ${theme.typo.body1} + color: ${theme.color.textPrimary}; + `} +`; + +export const StyledProfileDropdownEmail = styled(DropdownMenu.Item)` + ${({ theme }) => css` + ${theme.typo.caption0} + color: ${theme.color.textDisabled}; + `} +`; + +export const StyledProfileDropdownItem = styled(DropdownMenu.Item)` + ${({ theme }) => css` + ${theme.typo.body2} + color: ${theme.color.textPrimary}; + &: hover { + color: ${theme.color.textPointed}; + } + `} +`; diff --git a/src/components/ProfileDropdownMenu/ProfileDropdownMenu.tsx b/src/components/ProfileDropdownMenu/ProfileDropdownMenu.tsx new file mode 100644 index 00000000..8715da4c --- /dev/null +++ b/src/components/ProfileDropdownMenu/ProfileDropdownMenu.tsx @@ -0,0 +1,47 @@ +import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; +import { Link } from 'react-router-dom'; + +import { + StyledProfileDropdownContent, + StyledProfileDropdownEmail, + StyledProfileDropdownItem, + StyledProfileDropdownNickname, +} from './ProfileDropdownMenu.style'; + +interface ProfileDropdownMenuProps { + open: boolean; + onOpenChange: (open: boolean) => void; + nickname: string; + email: string; + children: React.ReactNode; +} + +export const ProfileDropdownMenu = ({ + open, + onOpenChange, + nickname, + email, + children, +}: ProfileDropdownMenuProps) => { + return ( + + {children} + + +
+ {nickname} + {email} +
+ + 마이페이지 + + + + +
+
+
+ ); +}; + +ProfileDropdownMenu.Trigger = DropdownMenu.Trigger; diff --git a/src/components/ProfileIconButton/ProfileIconButton.style.ts b/src/components/ProfileIconButton/ProfileIconButton.style.ts new file mode 100644 index 00000000..b9647e13 --- /dev/null +++ b/src/components/ProfileIconButton/ProfileIconButton.style.ts @@ -0,0 +1,7 @@ +import { styled } from 'styled-components'; + +export const StyledIconButton = styled.button` + display: flex; + justify-content: center; + align-items: center; +`; diff --git a/src/components/ProfileIconButton/ProfileIconButton.tsx b/src/components/ProfileIconButton/ProfileIconButton.tsx new file mode 100644 index 00000000..2acf0d0e --- /dev/null +++ b/src/components/ProfileIconButton/ProfileIconButton.tsx @@ -0,0 +1,40 @@ +import React, { useState } from 'react'; + +import { IcPersoncircleLine } from '@yourssu/design-system-react'; + +import { useGetUserData } from '@/home/hooks/useGetUserData'; + +import { ProfileDropdownMenu } from '../ProfileDropdownMenu/ProfileDropdownMenu'; + +import { StyledIconButton } from './ProfileIconButton.style'; + +interface ProfileIconButtonProps extends React.ButtonHTMLAttributes { + color: string; + size: string; +} + +export const ProfileIconButton = ({ color, size, ...props }: ProfileIconButtonProps) => { + const [openDropdown, setOpenDropdown] = useState(false); + const { data: currentUser } = useGetUserData(); + + const handleClickProfile = () => { + setOpenDropdown((prev) => !prev); + }; + + return ( + + {currentUser && ( + + + + + + )} + + ); +}; diff --git a/src/home/components/UserInformationCard/ProfileSVG.tsx b/src/components/ProfileSvg/ProfileSVG.tsx similarity index 100% rename from src/home/components/UserInformationCard/ProfileSVG.tsx rename to src/components/ProfileSvg/ProfileSVG.tsx diff --git a/src/constants/dialog.constant.tsx b/src/constants/dialog.constant.tsx new file mode 100644 index 00000000..682a2872 --- /dev/null +++ b/src/constants/dialog.constant.tsx @@ -0,0 +1,5 @@ +import { LoginDialog } from '@/components/Dialog/LoginDialog/LoginDialog'; + +export const DIALOG = { + login: , +}; diff --git a/src/drawer/components/DrawerCard/CardSettingDropdownMenu/CardSettingDropdownMenu.style.ts b/src/drawer/components/DrawerCard/CardSettingDropdownMenu/CardSettingDropdownMenu.style.ts new file mode 100644 index 00000000..fec4a6a7 --- /dev/null +++ b/src/drawer/components/DrawerCard/CardSettingDropdownMenu/CardSettingDropdownMenu.style.ts @@ -0,0 +1,26 @@ +import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; +import { css, styled } from 'styled-components'; + +export const StyledSettingDropdownContent = styled(DropdownMenu.Content)` + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + + width: 6.5rem; + padding: 1rem 0.75rem; + + border: 1px solid ${({ theme }) => theme.color.borderThin}; + border-radius: 0.25rem; + box-shadow: 0px 1px 3px 0px rgba(107, 114, 128, 0.4); +`; + +export const StyledSettingDropdownItem = styled(DropdownMenu.Item)` + ${({ theme }) => css` + ${theme.typo.button4} + color: ${theme.color.textSecondary}; + &:hover { + color: ${theme.color.textPointed}; + } + `} +`; diff --git a/src/drawer/components/DrawerCard/CardSettingDropdownMenu/CardSettingDropdownMenu.tsx b/src/drawer/components/DrawerCard/CardSettingDropdownMenu/CardSettingDropdownMenu.tsx new file mode 100644 index 00000000..3a6473e6 --- /dev/null +++ b/src/drawer/components/DrawerCard/CardSettingDropdownMenu/CardSettingDropdownMenu.tsx @@ -0,0 +1,43 @@ +import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; +import { Link } from 'react-router-dom'; + +import { + StyledSettingDropdownContent, + StyledSettingDropdownItem, +} from './CardSettingDropdownMenu.style'; + +interface CardSettingDropdownMenuProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onClickRemoveButton: (event: React.MouseEvent) => void; + children: React.ReactNode; +} + +export const CardSettingDropdownMenu = ({ + open, + onOpenChange, + onClickRemoveButton, + children, +}: CardSettingDropdownMenuProps) => { + return ( + + {children} + + + + event.stopPropagation()}> + 서비스 수정 + + + + + + + + + ); +}; + +CardSettingDropdownMenu.Trigger = DropdownMenu.Trigger; diff --git a/src/drawer/components/DrawerCard/UserDrawerCard/UserDrawerCard.tsx b/src/drawer/components/DrawerCard/UserDrawerCard/UserDrawerCard.tsx index 3945751d..3b2360f3 100644 --- a/src/drawer/components/DrawerCard/UserDrawerCard/UserDrawerCard.tsx +++ b/src/drawer/components/DrawerCard/UserDrawerCard/UserDrawerCard.tsx @@ -1,17 +1,12 @@ -import { useEffect, useRef, useState } from 'react'; +import { useState } from 'react'; import { Card } from '@/components/Card/Card'; -import { Dropdown } from '@/components/Dropdown/Dropdown'; import { ServiceRemoveModal } from '../../Dialog/ServiceRemoveModal'; +import { CardSettingDropdownMenu } from '../CardSettingDropdownMenu/CardSettingDropdownMenu'; import { DrawerCardProps } from '../DrawerCard.type'; -import { - StyledCardContainer, - StyledServiceModify, - StyledServiceText, - StyledServiceTextContainer, -} from './UserDrawerCard.style'; +import { StyledCardContainer } from './UserDrawerCard.style'; interface UserDrawerCardProps extends DrawerCardProps { productNo: number; @@ -29,27 +24,14 @@ export const UserDrawerCard = ({ }: UserDrawerCardProps) => { const [isCardSettingClicked, setIsCardSettingClicked] = useState(false); const [openRemoveModal, setOpenRemoveModal] = useState(false); - const dropdownRef = useRef(null); - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { - setIsCardSettingClicked(false); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, []); - - const handleClickSetting = () => { + const handleClickSetting = (event: React.MouseEvent) => { + event.stopPropagation(); setIsCardSettingClicked((prev) => !prev); }; - const handleClickRemoveButton = () => { + const handleClickRemoveButton = (event: React.MouseEvent) => { + event.stopPropagation(); setIsCardSettingClicked(false); setOpenRemoveModal(true); }; @@ -65,27 +47,16 @@ export const UserDrawerCard = ({ bookmarkCount={bookmarkCount} isBookmarked={isBookmarked} /> - { - event.stopPropagation(); - handleClickSetting(); - }} - /> + + + + + - {isCardSettingClicked && ( - - { - event.stopPropagation(); - }} - > - 서비스 수정 - - - - )} { + const isLoggedIn = useRecoilValue(LogInState); + const setDialog = useSetRecoilState(DialogState); + const theme = useTheme(); + const handleClickAuthTab = (event: React.MouseEvent) => { + if (isLoggedIn) return; + + setDialog({ open: true, type: 'login' }); + event.preventDefault(); + }; + return ( @@ -23,31 +37,19 @@ export const Header = () => { 랭킹 - 서비스 등록 - 내 서랍장 + + 서비스 등록 + + + 내 서랍장 + - - - + - + ); }; diff --git a/src/drawer/components/ServiceDetailContents/ServiceAction/ServiceAction.tsx b/src/drawer/components/ServiceDetailContents/ServiceAction/ServiceAction.tsx index 1968f16c..c71b67b5 100644 --- a/src/drawer/components/ServiceDetailContents/ServiceAction/ServiceAction.tsx +++ b/src/drawer/components/ServiceDetailContents/ServiceAction/ServiceAction.tsx @@ -8,11 +8,14 @@ import { useToast, Toast, } from '@yourssu/design-system-react'; +import { useRecoilValue, useSetRecoilState } from 'recoil'; import { useTheme } from 'styled-components'; import { deleteBookmarked } from '@/drawer/apis/deleteBookmarked'; import { postBookmarked } from '@/drawer/apis/postBookmarked'; import { ProductDetailResult } from '@/drawer/types/product.type'; +import { LogInState } from '@/home/recoil/LogInState'; +import { DialogState } from '@/recoil/DialogState'; import { StyledIconButtonContainer, @@ -28,6 +31,9 @@ export const ServiceAction = ({ product }: { product: ProductDetailResult }) => { url: product.githubUrl, text: 'GitHub' }, ]; + const isLoggedIn = useRecoilValue(LogInState); + const setDialog = useSetRecoilState(DialogState); + const theme = useTheme(); const queryClient = useQueryClient(); @@ -38,15 +44,8 @@ export const ServiceAction = ({ product }: { product: ProductDetailResult }) => duration: 'short', } as const; - const addBookmarkMutation = useMutation({ - mutationFn: postBookmarked, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: ['productDetail', product.productNo] }); - }, - }); - - const deleteBookmarkMutation = useMutation({ - mutationFn: deleteBookmarked, + const bookmarkMutation = useMutation({ + mutationFn: product.isBookmarked ? deleteBookmarked : postBookmarked, onSettled: () => { queryClient.invalidateQueries({ queryKey: ['productDetail', product.productNo] }); }, @@ -57,6 +56,14 @@ export const ServiceAction = ({ product }: { product: ProductDetailResult }) => showToast(toastProps.duration); }; + const handleClickBookmark = () => { + if (isLoggedIn) { + bookmarkMutation.mutate(product.productBookmarkKey); + return; + } + setDialog({ open: true, type: 'login' }); + }; + return ( <> @@ -98,17 +105,10 @@ export const ServiceAction = ({ product }: { product: ProductDetailResult }) => value={{ color: theme.color.pointYellow, size: '1.5rem', + onClick: handleClickBookmark, }} > - {product.isBookmarked ? ( - deleteBookmarkMutation.mutate(product.productBookmarkKey)} - /> - ) : ( - addBookmarkMutation.mutate(product.productBookmarkKey)} - /> - )} + {product.isBookmarked ? : } Recommend diff --git a/src/home/apis/postAuthSignIn.ts b/src/home/apis/postAuthSignIn.ts index 30e52681..64982449 100644 --- a/src/home/apis/postAuthSignIn.ts +++ b/src/home/apis/postAuthSignIn.ts @@ -1,8 +1,6 @@ -import { AxiosError } from 'axios'; - import { authClient } from '@/apis'; -import { PostAuthResponse } from '../types/Auth.type'; +import { PostAuthSignInData } from '../types/Auth.type'; interface LoginProps { email: string; @@ -12,22 +10,19 @@ interface LoginProps { export const postAuthSignIn = async ({ email, password, -}: LoginProps): Promise => { - try { - const res = await authClient.post( - `/auth/sign-in`, - { - email: email, - password: password, +}: LoginProps): Promise => { + const res = await authClient.post( + `/auth/sign-in`, + { + email: email, + password: password, + }, + { + headers: { + 'Content-Type': 'application/json', }, - { - headers: { - 'Content-Type': 'application/json', - }, - } - ); - return { data: res.data }; - } catch (error: unknown) { - return { error: error as AxiosError }; - } + } + ); + + return res.data; }; diff --git a/src/home/apis/postWithdraw.ts b/src/home/apis/postWithdraw.ts index e63284d6..402e5a25 100644 --- a/src/home/apis/postWithdraw.ts +++ b/src/home/apis/postWithdraw.ts @@ -1,22 +1,9 @@ -import { isAxiosError } from 'axios'; - import { authClient } from '@/apis'; -import { PostWithdrawResponse, AuthErrorData } from '@/home/types/Auth.type'; import { api } from '@/service/TokenService'; -export const postWithdraw = async (): Promise => { - try { - await authClient.post( - '/auth/withdraw', - {}, - { - headers: api.headers, - } - ); - api.logout(); - return { success: true }; - } catch (error) { - if (isAxiosError(error)) return { success: false, error }; - return Promise.reject(error); - } +export const postWithdraw = async (data: object) => { + const res = await authClient.post('/auth/withdraw', data, { + headers: api.headers, + }); + return res.data; }; diff --git a/src/home/components/ChangePasswordContents/NewPasswordForm/NewPasswordForm.tsx b/src/home/components/ChangePasswordContents/NewPasswordForm/NewPasswordForm.tsx index 5b80b9f2..903f993e 100644 --- a/src/home/components/ChangePasswordContents/NewPasswordForm/NewPasswordForm.tsx +++ b/src/home/components/ChangePasswordContents/NewPasswordForm/NewPasswordForm.tsx @@ -38,12 +38,14 @@ export const NewPasswordForm = ({ sessionToken }: NewPasswordFormProps) => { 새로운 비밀번호를 입력해주세요. handleNewPasswordChange(e.target.value)} isNegative={isNewPasswordFieldNegative} helperLabel={ - isNewPasswordFieldNegative ? '숫자와 영문자 조합으로 8자 이상 입력해주세요.' : '' + isNewPasswordFieldNegative + ? '숫자, 영문자, 특수문자 조합으로 8자 이상 입력해주세요' + : '' } /> theme.baseColor.logoViolet}; `; -export const StyledNonLoginText = styled(NavLink)` +export const StyledNonLoginText = styled(Link)` ${({ theme }) => theme.typo.body1}; color: ${({ theme }) => theme.baseColor.logoViolet}; `; - -export const StyledDropDownName = styled.div` - ${({ theme }) => theme.typo.body1}; - color: ${({ theme }) => theme.color.textPrimary}; -`; - -export const StyledDropDownEmail = styled.div` - ${({ theme }) => theme.typo.caption1}; - color: ${({ theme }) => theme.color.textDisabled}; -`; - -export const StyledDropDownMyPage = styled(NavLink)` - ${({ theme }) => theme.typo.body2}; - color: ${({ theme }) => theme.color.textPrimary}; - padding-top: 1.25rem; -`; - -export const StyledDropDownLogout = styled.div` - ${({ theme }) => theme.typo.body2}; - padding-top: 0.75rem; - cursor: pointer; -`; diff --git a/src/home/components/Nav/Nav.tsx b/src/home/components/Nav/Nav.tsx index 5ba3abc6..364104c5 100644 --- a/src/home/components/Nav/Nav.tsx +++ b/src/home/components/Nav/Nav.tsx @@ -1,69 +1,31 @@ -import { useEffect, useRef, useState } from 'react'; - import { useRecoilValue } from 'recoil'; +import { useTheme } from 'styled-components'; -import Profile from '@/assets/home/profile.svg'; -import { Dropdown } from '@/components/Dropdown/Dropdown'; -import { UserState } from '@/home/recoil/UserState'; +import { ProfileIconButton } from '@/components/ProfileIconButton/ProfileIconButton'; +import { LogInState } from '@/home/recoil/LogInState'; import { - StyledContainer, - StyledProfileContainer, StyledColDivider, + StyledContainer, StyledNonLoginContainer, StyledNonLoginText, - StyledDropDownEmail, - StyledDropDownLogout, - StyledDropDownMyPage, - StyledDropDownName, } from './Nav.style'; -interface NavProps { - isLoggedIn: boolean; -} -export const Nav = ({ isLoggedIn }: NavProps) => { - const dropdownRef = useRef(null); - const [isProfileClicked, setIsProfileClicked] = useState(false); - const currentUser = useRecoilValue(UserState); +export const Nav = () => { + const isLoggedIn = useRecoilValue(LogInState); + const theme = useTheme(); - const handleProfileClick = () => { - setIsProfileClicked((prev) => !prev); - }; - //Todo: 로그아웃 기능 추가 + // Todo: 로그아웃 기능 추가 (ProfileDropdownMenu) // const handleLogout = () => { // sessionStorage.removeItem('accessExpiredIn'); // api.logout(); // return; // }; - useEffect(() => { - const handleClickOutside = (e: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { - setIsProfileClicked(false); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, []); - return ( {isLoggedIn ? ( -
- - {isProfileClicked && currentUser && ( - - {currentUser.nickName} - {currentUser.email} - 마이페이지 - {/* TODO: 로그아웃 기능 구현 */} - 로그아웃 - - )} -
+ ) : ( 로그인 diff --git a/src/home/components/Notification/Notification.tsx b/src/home/components/Notification/Notification.tsx index bd652db9..3c80a872 100644 --- a/src/home/components/Notification/Notification.tsx +++ b/src/home/components/Notification/Notification.tsx @@ -1,7 +1,9 @@ -import { useRef, useState } from 'react'; +import { Suspense, useEffect, useRef, useState } from 'react'; import { IcNoticeLine } from '@yourssu/design-system-react'; +import { ErrorBoundary } from 'react-error-boundary'; +import { useGetAnnouncement } from '@/home/hooks/useGetAnnouncement'; import { useInterval } from '@/hooks/useInterval'; import { @@ -12,65 +14,75 @@ import { StyledNotificationContainer, } from './Notification.style'; -const Dummy = [ - '그거 아시나요?? 지금은 2월이라는 거? 당연함 2월임', - '안녕하세요~~ 공지입니다.', - '가나다라마바사아자차카타파하갸냐댜랴먀뱌샤야쟈챠캬탸퍄햐거너더러머버서어저처커터퍼허겨녀뎌려며벼셔여져쳐켜텨펴혀', - '이거슨 테스트여', -]; - -// TODO: 추후 공지사항 API 연결 확인 필요 -export const Notification = () => { - const [currentIndex, setCurrentIndex] = useState(1); +const NotificationContent = () => { + const { data: announcements } = useGetAnnouncement(); + const [currentIndex, setCurrentIndex] = useState(0); const [activeTransition, setActiveTransition] = useState(true); const slideRef = useRef(null); - const notificationArray = [Dummy[Dummy.length - 1], ...Dummy, Dummy[0]].map( - (notification, index) => ({ - id: index + 1, - notification, - }) - ); + + const notificationArray = + announcements.length > 0 + ? [...announcements, announcements[0]].map((announcement, index) => ({ + id: index + 1, + notification: announcement.title, + })) + : []; useInterval(() => setCurrentIndex((prev) => prev + 1), 5000); - const InfiniteSlideHandler = (nextIndex: number) => { - setTimeout(() => { - if (slideRef.current) { - setActiveTransition(false); - } - setCurrentIndex(nextIndex); + useEffect(() => { + const handleInfiniteSlide = (nextIndex: number) => { setTimeout(() => { if (slideRef.current) { - setActiveTransition(true); + setActiveTransition(false); } - }, 100); - }, 500); - }; - - if (currentIndex === notificationArray.length - 1) { - InfiniteSlideHandler(1); - } + setCurrentIndex(nextIndex); + setTimeout(() => { + if (slideRef.current) { + setActiveTransition(true); + } + }, 100); + }, 500); + }; + if (currentIndex === notificationArray.length - 1) { + handleInfiniteSlide(0); + } + }, [currentIndex, notificationArray.length]); - if (currentIndex === 0) { - InfiniteSlideHandler(Dummy.length); - } + return ( + + {announcements.length === 0 ? ( + 공지사항이 없습니다. + ) : ( + + {notificationArray.map((item) => ( + {item.notification} + ))} + + )} + + ); +}; +export const Notification = () => { return ( - - - {notificationArray.map((item) => ( - {item.notification} - ))} - - + ( + Error: {error.message} + )} + > + 로딩중...}> + + + ); diff --git a/src/home/components/SignupContents/EmailForm/EmailForm.tsx b/src/home/components/SignupContents/EmailForm/EmailForm.tsx index 654e5e56..2ccc4cb6 100644 --- a/src/home/components/SignupContents/EmailForm/EmailForm.tsx +++ b/src/home/components/SignupContents/EmailForm/EmailForm.tsx @@ -3,6 +3,7 @@ import { BoxButton, PlainButton, SuffixTextField } from '@yourssu/design-system- import { EMAIL_DOMAIN } from '@/constants/email.constant'; import { EmailFormProps } from '@/home/components/SignupContents/EmailForm/EmailForm.type.ts'; import { useEmailForm } from '@/home/components/SignupContents/EmailForm/useEmailForm.ts'; +import { usePreventDuplicateClick } from '@/hooks/usePreventDuplicateClick.ts'; import { StyledSignupButtonText, @@ -18,7 +19,8 @@ import { } from './EmailForm.style'; export const EmailForm = ({ onConfirm }: EmailFormProps) => { - const { email, emailSending, emailError, onEmailSubmit, onChange } = useEmailForm({ onConfirm }); + const { email, emailError, onEmailSubmit, onChange } = useEmailForm({ onConfirm }); + const { disabled, handleClick } = usePreventDuplicateClick(); return ( @@ -47,8 +49,8 @@ export const EmailForm = ({ onConfirm }: EmailFormProps) => { size="large" variant="filled" rounding={8} - disabled={email === '' || emailSending} - onClick={onEmailSubmit} + disabled={email === '' || disabled} + onClick={() => handleClick(onEmailSubmit)} > 인증 메일 받기 diff --git a/src/home/components/SignupContents/EmailForm/useEmailForm.ts b/src/home/components/SignupContents/EmailForm/useEmailForm.ts index 04768bf3..57bdde54 100644 --- a/src/home/components/SignupContents/EmailForm/useEmailForm.ts +++ b/src/home/components/SignupContents/EmailForm/useEmailForm.ts @@ -7,7 +7,6 @@ import { useFullEmail } from '@/hooks/useFullEmail'; export const useEmailForm = ({ onConfirm }: EmailFormProps) => { const [email, setEmail] = useState(''); const [emailError, setEmailError] = useState(undefined); - const [emailSending, setEmailSending] = useState(false); const fullEmail = useFullEmail(email); const onChange = (e: React.ChangeEvent) => { @@ -15,17 +14,13 @@ export const useEmailForm = ({ onConfirm }: EmailFormProps) => { }; const onEmailSubmit = async () => { - setEmailSending(true); - const res = await postAuthVerificationEmail({ email: fullEmail, verificationType: 'SIGN_UP' }); if (res.data) { onConfirm(fullEmail); } else { setEmailError(res.error?.response?.data.message || '이메일을 다시 확인해주세요.'); } - - setEmailSending(false); }; - return { email, emailError, emailSending, onChange, onEmailSubmit }; + return { email, emailError, onChange, onEmailSubmit }; }; diff --git a/src/home/components/SignupContents/SignupForm/SignupForm.tsx b/src/home/components/SignupContents/SignupForm/SignupForm.tsx index b004f428..750ccc04 100644 --- a/src/home/components/SignupContents/SignupForm/SignupForm.tsx +++ b/src/home/components/SignupContents/SignupForm/SignupForm.tsx @@ -2,6 +2,7 @@ import { BoxButton, PasswordTextField, SimpleTextField } from '@yourssu/design-s import { SignupFormProps } from '@/home/components/SignupContents/SignupForm/SignUpForm.type.ts'; import { useSignUpForm } from '@/home/components/SignupContents/SignupForm/useSignUpForm.ts'; +import { usePreventDuplicateClick } from '@/hooks/usePreventDuplicateClick.ts'; import { useSignupFormValidation } from '@/hooks/useSignupFormValidator.ts'; import { @@ -19,6 +20,8 @@ export const SignupForm = ({ email, onConfirm }: SignupFormProps) => { const { nicknameValidOnce, passwordValidOnce, isFormValid, isNicknameValid, isPasswordValid } = useSignupFormValidation(nickname, password); + const { disabled, handleClick } = usePreventDuplicateClick(); + return ( 회원가입 @@ -39,7 +42,7 @@ export const SignupForm = ({ email, onConfirm }: SignupFormProps) => { /> { @@ -51,8 +54,8 @@ export const SignupForm = ({ email, onConfirm }: SignupFormProps) => { rounding={8} size="large" variant="filled" - onClick={onFormConfirm} - disabled={!isFormValid} + onClick={() => handleClick(onFormConfirm)} + disabled={!isFormValid || disabled} > 회원가입 diff --git a/src/home/components/UserInformationCard/UserInformationCard.tsx b/src/home/components/UserInformationCard/UserInformationCard.tsx index fa6a18f5..3587d73a 100644 --- a/src/home/components/UserInformationCard/UserInformationCard.tsx +++ b/src/home/components/UserInformationCard/UserInformationCard.tsx @@ -3,8 +3,8 @@ import { useState } from 'react'; import { BoxButton, IcSettingLine, SimpleTextField } from '@yourssu/design-system-react'; import { useTheme } from 'styled-components'; -import Ppussung from '@/assets/home/defaultProfile.png'; -import { ProfileSvg } from '@/home/components/UserInformationCard/ProfileSVG'; +import Ppussung from '@/assets/defaultProfile.png'; +import { ProfileSvg } from '@/components/ProfileSvg/ProfileSVG'; import { StyledButtonContainer, diff --git a/src/home/hooks/useGetAnnouncement.ts b/src/home/hooks/useGetAnnouncement.ts index d5dad54e..f160c99f 100644 --- a/src/home/hooks/useGetAnnouncement.ts +++ b/src/home/hooks/useGetAnnouncement.ts @@ -1,12 +1,11 @@ -import { useQuery } from '@tanstack/react-query'; +import { useSuspenseQuery } from '@tanstack/react-query'; import { getAnnouncement } from '@/home/apis/getAnnouncement'; export const useGetAnnouncement = () => { - return useQuery({ + return useSuspenseQuery({ queryKey: ['announcement'], - queryFn: () => { - return getAnnouncement(); - }, + queryFn: getAnnouncement, + select: (data) => data.announcementList, }); }; diff --git a/src/home/hooks/useGetUserData.ts b/src/home/hooks/useGetUserData.ts index 6233a2fe..c9eb309a 100644 --- a/src/home/hooks/useGetUserData.ts +++ b/src/home/hooks/useGetUserData.ts @@ -1,19 +1,17 @@ import { useQuery } from '@tanstack/react-query'; -import { useSetRecoilState } from 'recoil'; +import { useRecoilValue } from 'recoil'; import { getUserData } from '@/home/apis/getUserData'; -import { UserState } from '@/home/recoil/UserState'; + +import { LogInState } from '../recoil/LogInState'; export const useGetUserData = () => { - const setUserData = useSetRecoilState(UserState); + const isLoggedIn = useRecoilValue(LogInState); + return useQuery({ queryKey: ['userData'], - queryFn: async () => { - const data = await getUserData(); - setUserData(data); - - return data; - }, - enabled: false, + queryFn: getUserData, + enabled: isLoggedIn, + staleTime: Infinity, }); }; diff --git a/src/home/hooks/usePostLogin.ts b/src/home/hooks/usePostLogin.ts new file mode 100644 index 00000000..51692cf3 --- /dev/null +++ b/src/home/hooks/usePostLogin.ts @@ -0,0 +1,31 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import axios from 'axios'; +import { useSetRecoilState } from 'recoil'; + +import { api } from '@/service/TokenService'; + +import { postAuthSignIn } from '../apis/postAuthSignIn'; +import { LogInState } from '../recoil/LogInState'; + +export const usePostLogin = () => { + const queryClient = useQueryClient(); + const setIsLoggedIn = useSetRecoilState(LogInState); + + return useMutation({ + mutationFn: postAuthSignIn, + onSuccess: (data) => { + api.setAccessToken(data.accessToken, data.accessTokenExpiredIn); + api.setRefreshToken(data.refreshToken, data.refreshTokenExpiredIn); + setIsLoggedIn(true); + queryClient.invalidateQueries({ queryKey: ['userData'] }); + }, + throwOnError: (error) => { + // response status code가 401일 경우 에러가 ErrorBoundary로 전달되지 않도록 함 + if (axios.isAxiosError(error)) { + return error.response?.status != 401; + } + + return true; + }, + }); +}; diff --git a/src/home/pages/Home/Home.tsx b/src/home/pages/Home/Home.tsx index f3c6953d..139545c2 100644 --- a/src/home/pages/Home/Home.tsx +++ b/src/home/pages/Home/Home.tsx @@ -1,12 +1,9 @@ -import { useRecoilValue } from 'recoil'; - import { RealTimeKeyword } from '@/components/RealTimeKeyword/RealTimeKeyword.tsx'; import { DrawerRanking } from '@/home/components/DrawerRanking/DrawerRanking'; import { Header } from '@/home/components/Header/Header'; import { Nav } from '@/home/components/Nav/Nav'; import { Notification } from '@/home/components/Notification/Notification'; import { SocialNetworkService } from '@/home/components/SocialNetworkService/SocialNetworkService'; -import { LogInState } from '@/home/recoil/LogInState'; import { StyledComponentContainer, @@ -15,11 +12,9 @@ import { } from './Home.style'; export const Home = () => { - const isLoggedIn = useRecoilValue(LogInState); - return ( -