diff --git a/package.json b/package.json index 05c9eac..6432c70 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "jsdom": "^26.1.0", "lint-staged": "^15.5.1", "msw": "^2.8.2", + "rollup-plugin-visualizer": "^6.0.1", "tailwindcss": "^4.1.5", "typescript": "~5.7.2", "vite": "^6.3.1", @@ -62,6 +63,8 @@ }, "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e", "msw": { - "workerDirectory": ["public"] + "workerDirectory": [ + "public" + ] } } diff --git a/src/app/provider/UserInfoProvider.tsx b/src/app/provider/UserInfoProvider.tsx new file mode 100644 index 0000000..105f07c --- /dev/null +++ b/src/app/provider/UserInfoProvider.tsx @@ -0,0 +1,60 @@ +import { + type ReactNode, + createContext, + useContext, + useEffect, + useState, +} from 'react'; +import type { UserInfoResponse } from '~/entities/user/types/user.type'; + +type UserInfoContextType = { + userId: UserInfoResponse['data']['userId'] | null; + setUserId: (userId: UserInfoResponse['data']['userId'] | null) => void; +}; + +type UserIdProviderProps = { + children: ReactNode; +}; + +export const UserIdContext = createContext(null); + +export default function UserIdProvider({ children }: UserIdProviderProps) { + const [userId, setUserId] = useState( + null, + ); + + useEffect(() => { + const storedUserId = window.localStorage.getItem('userId'); + + if (!storedUserId) return; + + setUserId(Number(storedUserId)); + }, []); + + useEffect(() => { + if (!userId) { + window.localStorage.removeItem('userId'); + return; + } + + window.localStorage.setItem('userId', String(userId)); + }, [userId]); + + return ( + + {children} + + ); +} + +export function useUserId() { + const context = useContext(UserIdContext); + + if (!context) { + throw new Error( + 'useUserId hook은 UserIdProvider 내부에서 사용해야 합니다.', + ); + } + + return context; +} diff --git a/src/app/root.tsx b/src/app/root.tsx index 19755ac..59e6163 100644 --- a/src/app/root.tsx +++ b/src/app/root.tsx @@ -13,6 +13,7 @@ import type { Route } from './+types/root'; import './app.css'; import { Slide } from 'react-toastify'; import StompProvider from './provider/StompProvider'; +import UserIdProvider from './provider/UserInfoProvider'; export const links: Route.LinksFunction = () => [ { rel: 'preconnect', href: 'https://fonts.googleapis.com' }, @@ -76,23 +77,27 @@ export function Layout({ children }: { children: React.ReactNode }) { export default function App() { return ( - - - - + + + + + + ); } diff --git a/src/app/routes/callback.tsx b/src/app/routes/callback.tsx index 7df2d3e..f55355b 100644 --- a/src/app/routes/callback.tsx +++ b/src/app/routes/callback.tsx @@ -1,5 +1,11 @@ import * as cookie from 'cookie'; -import { type LoaderFunctionArgs, redirect } from 'react-router'; +import { type LoaderFunctionArgs, redirect, useNavigate } from 'react-router'; +import type { Route } from './+types/callback'; + +import { useEffect } from 'react'; +import type { UserInfoResponse } from '~/entities/user/types/user.type'; +import ApiClient from '~/shared/api/httpClient'; +import { useUserId } from '../provider/UserInfoProvider'; export async function loader({ request }: LoaderFunctionArgs) { const rawCookie = request.headers.get('Cookie'); @@ -10,17 +16,26 @@ export async function loader({ request }: LoaderFunctionArgs) { return redirect('/trade/BTC/login'); } - return redirect('/trade'); + const response = await ApiClient.get('api/userinfo', { + headers: { + Cookie: rawCookie || '', + }, + }); + + const { data } = await response.json(); - // 이전로직: - // try { - // await api.checkToken(); - // } catch (error) { - // return redirect('/trade/login'); - // } - // return redirect('/trade'); + return data.userId; } -export default function CallbackRoutes() { +export default function CallbackRoutes({ loaderData }: Route.ComponentProps) { + const navigate = useNavigate(); + const { userId, setUserId } = useUserId(); + setUserId(loaderData); + + useEffect(() => { + if (!userId) return; + navigate('/trade/BTC'); + }, [userId, navigate]); + return null; } diff --git a/src/app/routes/trade.$ticker.login.tsx b/src/app/routes/trade.$ticker.login.tsx index 23e0042..6b67006 100644 --- a/src/app/routes/trade.$ticker.login.tsx +++ b/src/app/routes/trade.$ticker.login.tsx @@ -1,5 +1,5 @@ import { LoginModal } from '~/widgets/auth'; export default function LoginRouteComponent() { - return ; + return ; } diff --git a/src/app/routes/trade.$ticker.tsx b/src/app/routes/trade.$ticker.tsx index 1f05905..1325a3f 100644 --- a/src/app/routes/trade.$ticker.tsx +++ b/src/app/routes/trade.$ticker.tsx @@ -1,4 +1,6 @@ import * as cookie from 'cookie'; +import { AnimatePresence } from 'motion/react'; +import { useState } from 'react'; import { Outlet, redirect } from 'react-router'; import { CoinPriceWithName, api as coinApi } from '~/entities/coin'; @@ -7,10 +9,12 @@ import { AIChatBot } from '~/features/chat'; import { CoinListWithSearchBar } from '~/features/coin-search-list'; import { OrderForm, OrderFormFallback } from '~/features/order'; import { ExecutionList } from '~/features/order-execution-list'; +import useTradeNotification from '~/features/trade/hooks/useTradeNotification'; import { Orderbook, StockChart } from '~/features/tradeview'; import Container from '~/shared/ui/Container'; import ContainerTitle from '~/shared/ui/ContainerTitle'; -import { NavBar } from '~/widgets/navbar'; +import { NavBar, SideBar } from '~/widgets/navbar'; +import { useUserId } from '../provider/UserInfoProvider'; import type { Route } from './+types/trade.$ticker'; export async function loader({ request, params }: Route.LoaderArgs) { @@ -39,35 +43,50 @@ export async function clientAction() { export default function TradeRouteComponent({ loaderData, }: Route.ComponentProps) { - const coinInfo = loaderData.coinInfo; - const isLoggedIn = loaderData.isLoggedIn; - const coinList = loaderData.coinList; + const { userId } = useUserId(); + useTradeNotification(userId || 0); + const [isMenuOpen, setIsMenuOpen] = useState(false); + const { coinInfo, coinList, isLoggedIn } = loaderData; const coinListWithIcon = coinList.map((coinInfo) => ({ ...coinInfo, coinIcon: 🪙, to: `/trade/${coinInfo.ticker}`, })); + const handleOpenMenu = () => { + setIsMenuOpen(true); + }; + + const handleCloseMenu = () => { + setIsMenuOpen(false); + }; + return ( -
+
{coinInfo && ( )} -
-
+
+
- 실시간 체결 목록 - {coinInfo && } + 실시간 차트 + {coinInfo && ( + + )}
-
+
주문 하기 {isLoggedIn && coinInfo ? ( @@ -77,29 +96,38 @@ export default function TradeRouteComponent({ )}
-
+
실시간 호가 {coinInfo && }
-
+
- 가상화폐 리스트 - + 실시간 체결 목록 + {coinInfo && ( + + )}
-
+
- 실시간 차트 - {coinInfo && ( - - )} + 가상화폐 리스트 +
- - + + {isMenuOpen && ( + + )} + + +
); } diff --git a/src/assets/svgs/bars-solid.svg b/src/assets/svgs/bars-solid.svg new file mode 100644 index 0000000..98f5de2 --- /dev/null +++ b/src/assets/svgs/bars-solid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/svgs/index.ts b/src/assets/svgs/index.ts index 0848700..1cb6b38 100644 --- a/src/assets/svgs/index.ts +++ b/src/assets/svgs/index.ts @@ -4,3 +4,5 @@ export { ReactComponent as IconMagnifying } from './magnifying.svg'; export { ReactComponent as IconPlus } from './plus-solid.svg'; export { ReactComponent as IconMinus } from './minus-solid.svg'; export { ReactComponent as IconHeadset } from './headset-solid.svg'; +export { ReactComponent as IconBars } from './bars-solid.svg'; +export { ReactComponent as IconXmark } from './xmark-solid.svg'; diff --git a/src/assets/svgs/xmark-solid.svg b/src/assets/svgs/xmark-solid.svg new file mode 100644 index 0000000..db6aca6 --- /dev/null +++ b/src/assets/svgs/xmark-solid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/features/chat/ui/AIChatBot/AIChatBot.test.tsx b/src/features/chat/ui/AIChatBot/AIChatBot.test.tsx index 87c6b78..7426543 100644 --- a/src/features/chat/ui/AIChatBot/AIChatBot.test.tsx +++ b/src/features/chat/ui/AIChatBot/AIChatBot.test.tsx @@ -1,4 +1,8 @@ -import { render, screen } from '@testing-library/react'; +import { + render, + screen, + waitForElementToBeRemoved, +} from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import userEvent from '@testing-library/user-event'; @@ -27,7 +31,7 @@ describe('AIChatBot 컴포넌트 테스트', () => { await user.click(chatButton); - const chatWindow = screen.getByTestId('chat-window'); + const chatWindow = await screen.findByTestId('chat-window'); expect(chatWindow).toBeInTheDocument(); }); @@ -42,13 +46,15 @@ describe('AIChatBot 컴포넌트 테스트', () => { await user.click(chatButton); - const chatWindow = screen.getByTestId('chat-window'); - const closeButton = screen.getByTestId('chat-window-close-button'); + const chatWindow = await screen.findByTestId('chat-window'); + const closeButton = await screen.findByTestId('chat-window-close-button'); expect(chatWindow).toBeInTheDocument(); await user.click(closeButton); + await waitForElementToBeRemoved(() => screen.queryByTestId('chat-window')); + expect(chatWindow).not.toBeInTheDocument(); }); }); diff --git a/src/features/chat/ui/AIChatBot/index.tsx b/src/features/chat/ui/AIChatBot/index.tsx index aee31ca..b2d6c95 100644 --- a/src/features/chat/ui/AIChatBot/index.tsx +++ b/src/features/chat/ui/AIChatBot/index.tsx @@ -31,7 +31,7 @@ export default function AIChatBot() { }; return ( - + {isOpen ? ( {state.context.messageList.map((message, index) => { const key = `msg-${index}-${message.isMine ? 'user' : 'ai'}`; diff --git a/src/features/chat/ui/ChatButton/index.tsx b/src/features/chat/ui/ChatButton/index.tsx index 9f29b6a..1cf9b88 100644 --- a/src/features/chat/ui/ChatButton/index.tsx +++ b/src/features/chat/ui/ChatButton/index.tsx @@ -22,7 +22,7 @@ export default function ChatButton({ isOpen, handleClick }: ChatButtonProps) { 0; + const formatedPrice = `${formatCurrencyKR(+(currentPriceData?.currentPrice || 0).toFixed(2))}원`; return ( @@ -34,7 +36,7 @@ export default function CoinListItem({
- {currentPriceData?.currentPrice} + {formatedPrice}
diff --git a/src/features/order-execution-list/ui/ExecutionItem/ExecutionItem.test.tsx b/src/features/order-execution-list/ui/ExecutionItem/ExecutionItem.test.tsx index a87beac..ece15b3 100644 --- a/src/features/order-execution-list/ui/ExecutionItem/ExecutionItem.test.tsx +++ b/src/features/order-execution-list/ui/ExecutionItem/ExecutionItem.test.tsx @@ -18,8 +18,14 @@ describe('ExecutionItem 컴포넌트 테스트', () => { render(); const price = screen.getByText('1,000원'); - const size = screen.getByText('1'); - const timestamp = screen.getByText('1'); + const size = screen.getByText(String(props.size.toFixed(6))); + const timestamp = screen.getByText( + Intl.DateTimeFormat('ko-KR', { + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + }).format(new Date(props.timestamp)), + ); const changeRate = screen.getByText('3.00%'); expect(price).toBeInTheDocument(); diff --git a/src/features/order-execution-list/ui/ExecutionItem/index.tsx b/src/features/order-execution-list/ui/ExecutionItem/index.tsx index b771130..2b61492 100644 --- a/src/features/order-execution-list/ui/ExecutionItem/index.tsx +++ b/src/features/order-execution-list/ui/ExecutionItem/index.tsx @@ -30,7 +30,7 @@ export default function ExecutionItem({ {formatCurrencyKR(price)}원
- {size} + {size.toFixed(6)}
{changeRate.toFixed(2)}% diff --git a/src/features/trade/hooks/useTradeNotification.tsx b/src/features/trade/hooks/useTradeNotification.tsx new file mode 100644 index 0000000..38ac6ae --- /dev/null +++ b/src/features/trade/hooks/useTradeNotification.tsx @@ -0,0 +1,34 @@ +import { useEffect } from 'react'; +import { toast } from 'react-toastify/unstyled'; + +import { useStompClient } from '~/app/provider/StompProvider'; + +type TradeNotification = { + ticker: string; + price: number; + size: number; + type: 'ask' | 'bid'; + tradedTime: string; +}; + +export default function useTradeNotification(userId: number) { + const { client, connected } = useStompClient(); + + useEffect(() => { + if (!client || !connected || !userId) return; + + const subscription = client.subscribe( + `/topic/tradeNotification/${userId}`, + (message) => { + const parsedData = JSON.parse(message.body) as TradeNotification; + const tradeType = parsedData.type === 'ask' ? '매도' : '매수'; + const toastMessage = `${parsedData.ticker} ${tradeType} 체결 완료 - 가격: ${parsedData.price}, 수량: ${parsedData.size}`; + toast.success(toastMessage); + }, + ); + + return () => { + subscription.unsubscribe(); + }; + }, [client, connected, userId]); +} diff --git a/src/features/tradeview/ui/StockChart/index.tsx b/src/features/tradeview/ui/StockChart/index.tsx index 5bfa575..27cdec2 100644 --- a/src/features/tradeview/ui/StockChart/index.tsx +++ b/src/features/tradeview/ui/StockChart/index.tsx @@ -503,7 +503,7 @@ export default function StockChart({ ticker }: StockChartProps) { }, []); return ( -
+
diff --git a/src/shared/ui/Backdrop/index.tsx b/src/shared/ui/Backdrop/index.tsx index 06e6324..68b3a05 100644 --- a/src/shared/ui/Backdrop/index.tsx +++ b/src/shared/ui/Backdrop/index.tsx @@ -1,14 +1,25 @@ +import { motion } from 'motion/react'; import type { HTMLAttributes } from 'react'; export type BackdropProps = HTMLAttributes; -export default function Backdrop({ children, ...props }: BackdropProps) { +const backdropVariant = { + initial: { opacity: 0 }, + animate: { opacity: 1 }, + exit: { opacity: 0 }, +}; + +export default function Backdrop({ children }: BackdropProps) { return ( -
{children} -
+ ); } diff --git a/src/shared/ui/CloseButton/index.tsx b/src/shared/ui/CloseButton/index.tsx new file mode 100644 index 0000000..a31441a --- /dev/null +++ b/src/shared/ui/CloseButton/index.tsx @@ -0,0 +1,18 @@ +import { IconXmark } from '~/assets/svgs'; + +type CloseButtonProps = { + onClick: () => void; +}; + +export default function CloseButton({ onClick }: CloseButtonProps) { + return ( + + ); +} diff --git a/src/shared/ui/Container/index.tsx b/src/shared/ui/Container/index.tsx index a3b54ff..9e78b27 100644 --- a/src/shared/ui/Container/index.tsx +++ b/src/shared/ui/Container/index.tsx @@ -6,7 +6,7 @@ type ContainerProps = { export default function Container({ children }: ContainerProps) { return ( -
+
{children}
); diff --git a/src/shared/ui/MenuButton/index.tsx b/src/shared/ui/MenuButton/index.tsx new file mode 100644 index 0000000..5545a33 --- /dev/null +++ b/src/shared/ui/MenuButton/index.tsx @@ -0,0 +1,18 @@ +import { IconBars } from '~/assets/svgs'; + +export type MenuButtonProps = { + onClick: () => void; +}; + +export default function MenuButton({ onClick }: MenuButtonProps) { + return ( + + ); +} diff --git a/src/widgets/auth/index.ts b/src/widgets/auth/index.ts index f1919cc..1482ab3 100644 --- a/src/widgets/auth/index.ts +++ b/src/widgets/auth/index.ts @@ -1 +1,3 @@ +/* v8 ignore start */ export { default as LoginModal } from './ui/LoginModal'; +/* v8 ignore end */ diff --git a/src/widgets/auth/ui/LoginModal/LoginModal.test.tsx b/src/widgets/auth/ui/LoginModal/LoginModal.test.tsx new file mode 100644 index 0000000..992e311 --- /dev/null +++ b/src/widgets/auth/ui/LoginModal/LoginModal.test.tsx @@ -0,0 +1,32 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import userEvent from '@testing-library/user-event'; +import LoginModal from '.'; + +const navigate = vi.fn(); + +vi.mock('react-router', () => ({ + useNavigate: () => navigate, +})); + +describe('LoginModal 컴포넌트 테스트', () => { + it('LoginModal이 렌더링된다.', () => { + render(); + + const loginModal = screen.getByRole('dialog'); + + expect(loginModal).toBeInTheDocument(); + }); + + it('Backdrop을 클릭하면 navigate가 호출된다.', async () => { + const user = userEvent.setup(); + render(); + + const backdrop = screen.getByTestId('backdrop'); + + await user.click(backdrop); + + expect(navigate).toHaveBeenNthCalledWith(1, -1); + }); +}); diff --git a/src/widgets/navbar/index.ts b/src/widgets/navbar/index.ts index 7d9eb33..e454122 100644 --- a/src/widgets/navbar/index.ts +++ b/src/widgets/navbar/index.ts @@ -1 +1,4 @@ +/* v8 ignore start */ export { default as NavBar } from './ui/NavBar'; +export { default as SideBar } from './ui/SideBar'; +/* v8 ignore end */ diff --git a/src/widgets/navbar/ui/NavBar/NavBar.test.tsx b/src/widgets/navbar/ui/NavBar/NavBar.test.tsx new file mode 100644 index 0000000..eb2fef0 --- /dev/null +++ b/src/widgets/navbar/ui/NavBar/NavBar.test.tsx @@ -0,0 +1,102 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import UserIdProvider from '~/app/provider/UserInfoProvider'; +import type { NavBarProps } from '.'; +import NavBar from '.'; + +const props: NavBarProps = { + to: '/', + serviceName: 'IF', + ticker: 'BTC', + isLoggedIn: true, + onClickMenuButton: vi.fn(), +}; + +const mockSubmit = vi.fn(); + +vi.mock('react-router', () => ({ + useSubmit: () => mockSubmit, + Link: ({ children, to }: { children: ReactNode; to: string }) => ( + {children} + ), + NavLink: ({ children, to }: { children: ReactNode; to: string }) => ( + {children} + ), +})); + +describe('NavBar 컴포넌트 테스트', () => { + it('초기 상태에서 NavBar가 보여진다.', () => { + render(, { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }); + + const navBar = screen.getByRole('navigation'); + + expect(navBar).toBeInTheDocument(); + }); + + it('로그인이 되어있으면 로그아웃 버튼이 보여진다.', () => { + render(, { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }); + + const logoutButton = screen.getByRole('button', { name: '로그아웃' }); + + expect(logoutButton).toBeInTheDocument(); + }); + + it('로그아웃이 되어있으면 로그인 버튼이 보인다.', () => { + render(, { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }); + + const loginButton = screen.getByRole('button', { name: '로그인' }); + + expect(loginButton).toBeInTheDocument(); + }); + + it('로그아웃 버튼을 누르면 submit으로 액션을 발생시킨다.', async () => { + const user = userEvent.setup(); + render(, { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }); + + const logoutButton = screen.getByRole('button', { name: '로그아웃' }); + + await user.click(logoutButton); + + expect(mockSubmit).toHaveBeenNthCalledWith( + 1, + null, + expect.objectContaining({ + action: '/trade/BTC', + method: 'post', + }), + ); + }); + + it('메뉴 버튼을 누르면 onClickMenuButton이 호출된다.', async () => { + const user = userEvent.setup(); + render(, { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }); + + const menuButton = screen.getByTestId('menu-button'); + + await user.click(menuButton); + + expect(props.onClickMenuButton).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/widgets/navbar/ui/NavBar/index.tsx b/src/widgets/navbar/ui/NavBar/index.tsx index be0eeaf..9459ea7 100644 --- a/src/widgets/navbar/ui/NavBar/index.tsx +++ b/src/widgets/navbar/ui/NavBar/index.tsx @@ -1,14 +1,18 @@ import { Link, type LinkProps, NavLink, useSubmit } from 'react-router'; +import { useUserId } from '~/app/provider/UserInfoProvider'; + import type { CoinTicker } from '~/entities/coin'; import Button from '~/shared/ui/Button'; import LogoWithTitle, { type LogoWithTitleProps, } from '~/shared/ui/LogoWithTitle'; +import MenuButton from '~/shared/ui/MenuButton'; export type NavBarProps = { to: LinkProps['to']; isLoggedIn?: boolean; ticker?: CoinTicker; + onClickMenuButton: () => void; } & LogoWithTitleProps; export default function NavBar({ @@ -17,10 +21,13 @@ export default function NavBar({ isBlack, isLoggedIn, ticker, + onClickMenuButton, }: NavBarProps) { const submit = useSubmit(); + const { setUserId } = useUserId(); const handleLogout = () => { + setUserId(null); submit(null, { action: `/trade/${ticker}`, method: 'post' }); }; @@ -34,7 +41,10 @@ export default function NavBar({ return ( <> -