Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -62,6 +63,8 @@
},
"packageManager": "[email protected]+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e",
"msw": {
"workerDirectory": ["public"]
"workerDirectory": [
"public"
]
}
}
60 changes: 60 additions & 0 deletions src/app/provider/UserInfoProvider.tsx
Original file line number Diff line number Diff line change
@@ -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<UserInfoContextType | null>(null);

export default function UserIdProvider({ children }: UserIdProviderProps) {
const [userId, setUserId] = useState<UserInfoContextType['userId'] | null>(
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 (
<UserIdContext.Provider value={{ userId, setUserId }}>
{children}
</UserIdContext.Provider>
);
}

export function useUserId() {
const context = useContext(UserIdContext);

if (!context) {
throw new Error(
'useUserId hook은 UserIdProvider 내부에서 사용해야 합니다.',
);
}

return context;
}
39 changes: 22 additions & 17 deletions src/app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -76,23 +77,27 @@ export function Layout({ children }: { children: React.ReactNode }) {

export default function App() {
return (
<StompProvider brokerURL={`${import.meta.env.VITE_STOMP_URL}/api/coin/min`}>
<Outlet />
<ToastContainer
position="top-center"
autoClose={5000}
hideProgressBar={false}
newestOnTop={false}
closeOnClick={false}
rtl={false}
pauseOnFocusLoss
draggable
pauseOnHover
theme="light"
transition={Slide}
stacked
/>
</StompProvider>
<UserIdProvider>
<StompProvider
brokerURL={`${import.meta.env.VITE_STOMP_URL}/api/coin/min`}
>
<Outlet />
<ToastContainer
position="top-center"
autoClose={5000}
hideProgressBar={false}
newestOnTop={false}
closeOnClick={false}
rtl={false}
pauseOnFocusLoss
draggable
pauseOnHover
theme="light"
transition={Slide}
stacked
/>
</StompProvider>
</UserIdProvider>
);
}

Expand Down
35 changes: 25 additions & 10 deletions src/app/routes/callback.tsx
Original file line number Diff line number Diff line change
@@ -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');
Expand All @@ -10,17 +16,26 @@ export async function loader({ request }: LoaderFunctionArgs) {
return redirect('/trade/BTC/login');
}

return redirect('/trade');
const response = await ApiClient.get<UserInfoResponse>('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;
}
2 changes: 1 addition & 1 deletion src/app/routes/trade.$ticker.login.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { LoginModal } from '~/widgets/auth';

export default function LoginRouteComponent() {
return <LoginModal />;
return <LoginModal key="login-modal" />;
}
70 changes: 49 additions & 21 deletions src/app/routes/trade.$ticker.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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) {
Expand Down Expand Up @@ -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: <span>🪙</span>,
to: `/trade/${coinInfo.ticker}`,
}));

const handleOpenMenu = () => {
setIsMenuOpen(true);
};

const handleCloseMenu = () => {
setIsMenuOpen(false);
};

return (
<div className="relative h-full bg-gray-100">
<div className="relative min-h-screen bg-gray-100">
<NavBar
to="/"
serviceName="IF"
isBlack
isLoggedIn={isLoggedIn}
ticker={coinInfo?.ticker}
onClickMenuButton={handleOpenMenu}
/>
{coinInfo && (
<CoinPriceWithName name={coinInfo?.name} ticker={coinInfo?.ticker} />
)}
<div className="grid h-[calc(100dvh-116px)] grid-cols-4 grid-rows-2 gap-4 p-4">
<div className="col-span-2 col-start-2 row-start-2">
<div className="relative flex h-[calc(100dvh-116px)] flex-col gap-4 overflow-x-scroll p-4 md:grid md:grid-cols-2 md:grid-rows-5 xl:grid-cols-3 xl:grid-rows-2 2xl:grid-cols-4 2xl:grid-rows-2">
<div className="h-auto md:col-span-full md:row-span-2 md:row-start-1 xl:col-span-full xl:row-span-1 xl:row-start-1 2xl:col-span-2 2xl:col-start-2 2xl:row-start-1">
<Container>
<ContainerTitle>실시간 체결 목록</ContainerTitle>
{coinInfo && <ExecutionList ticker={coinInfo.ticker} />}
<ContainerTitle>실시간 차트</ContainerTitle>
{coinInfo && (
<StockChart
key={`chart-${coinInfo.ticker}`}
ticker={coinInfo.ticker}
/>
)}
</Container>
</div>
<div className="col-start-4 row-span-1 row-start-1">
<div className="md:col-span-1 md:col-start-2 md:row-span-2 md:row-start-3 xl:col-span-1 xl:col-start-3 xl:row-span-1 xl:row-start2 2xl:col-start-4 2xl:row-span-1 2xl:row-start-1">
<Container>
<ContainerTitle>주문 하기</ContainerTitle>
{isLoggedIn && coinInfo ? (
Expand All @@ -77,29 +96,38 @@ export default function TradeRouteComponent({
)}
</Container>
</div>
<div className="col-start-4 row-span-full row-start-2">
<div className="md:col-span-1 md:col-start-1 md:row-span-2 md:row-start-3 xl:col-span-1 xl:col-start-2 xl:row-span-1 xl:row-start-2 2xl:col-start-4 2xl:row-span-full 2xl:row-start-2">
<Container>
<ContainerTitle>실시간 호가</ContainerTitle>
{coinInfo && <Orderbook ticker={coinInfo.ticker} />}
</Container>
</div>
<div className="col-start-1 row-span-2 row-start-1">
<div className="md:col-span-full md:row-span-1 md:row-start-5 xl:col-span-1 xl:col-start-1 xl:row-span-1 xl:row-start-2 2xl:col-span-2 2xl:col-start-2 2xl:row-start-2">
<Container>
<ContainerTitle>가상화폐 리스트</ContainerTitle>
<CoinListWithSearchBar coinList={coinListWithIcon} />
<ContainerTitle>실시간 체결 목록</ContainerTitle>
{coinInfo && (
<ExecutionList ticker={coinInfo.ticker} key={coinInfo.ticker} />
)}
</Container>
</div>
<div className="col-span-2 col-start-2 row-start-1">
<div className="hidden 2xl:col-start-1 2xl:row-span-2 2xl:row-start-1 2xl:block">
<Container>
<ContainerTitle>실시간 차트</ContainerTitle>
{coinInfo && (
<StockChart key={coinInfo.ticker} ticker={coinInfo.ticker} />
)}
<ContainerTitle>가상화폐 리스트</ContainerTitle>
<CoinListWithSearchBar coinList={coinListWithIcon} />
</Container>
</div>
</div>
<Outlet />
<AIChatBot />
<AnimatePresence>
{isMenuOpen && (
<SideBar
coinListWithIcon={coinListWithIcon}
onClose={handleCloseMenu}
key="sidebar"
/>
)}
<Outlet />
<AIChatBot key="ai-chatbot" />
</AnimatePresence>
</div>
);
}
1 change: 1 addition & 0 deletions src/assets/svgs/bars-solid.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions src/assets/svgs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
1 change: 1 addition & 0 deletions src/assets/svgs/xmark-solid.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 10 additions & 4 deletions src/features/chat/ui/AIChatBot/AIChatBot.test.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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();
});
Expand All @@ -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();
});
});
3 changes: 2 additions & 1 deletion src/features/chat/ui/AIChatBot/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,15 @@ export default function AIChatBot() {
};

return (
<AnimatePresence mode="wait">
<AnimatePresence mode="wait" propagate>
{isOpen ? (
<ChatWindow
handleClose={handleCloseChatWindow}
inputValue={state.context.question}
handleSubmit={handleSubmitQuestion}
handleInputValueChange={handleQuestionFieldChange}
state={state.context.state}
key="chat-window"
>
{state.context.messageList.map((message, index) => {
const key = `msg-${index}-${message.isMine ? 'user' : 'ai'}`;
Expand Down
2 changes: 1 addition & 1 deletion src/features/chat/ui/ChatButton/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export default function ChatButton({ isOpen, handleClick }: ChatButtonProps) {
<motion.button
key="chat-button"
type="button"
className="absolute right-4 bottom-4 z-50 aspect-square w-12 cursor-pointer rounded-4xl bg-white p-2 shadow-sm"
className="fixed right-4 bottom-4 z-20 aspect-square w-12 cursor-pointer rounded-4xl bg-white p-2 shadow-sm"
onClick={handleClick}
initial={false}
whileHover={{ scale: 1.1 }}
Expand Down
Loading
Loading