diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 6120a69c..3f991a75 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -21,7 +21,8 @@ "react-dom": "^18.3.1", "react-router-dom": "^6.28.0", "socket.io-client": "^4.8.1", - "tailwind-merge": "^2.5.4" + "tailwind-merge": "^2.5.4", + "zod": "^3.23.8" }, "devDependencies": { "@chromatic-com/storybook": "3.2.2", diff --git a/packages/frontend/src/apis/queries/auth/schema.ts b/packages/frontend/src/apis/queries/auth/schema.ts new file mode 100644 index 00000000..eb1f186a --- /dev/null +++ b/packages/frontend/src/apis/queries/auth/schema.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const GetLoginStatusSchema = z.object({ + message: z.enum(['Authenticated', 'Not Authenticated']), +}); + +export type GetLoginStatus = z.infer; diff --git a/packages/frontend/src/apis/queries/auth/types.ts b/packages/frontend/src/apis/queries/auth/types.ts deleted file mode 100644 index 2ec8c5a4..00000000 --- a/packages/frontend/src/apis/queries/auth/types.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface GetLoginStatusResponse { - message: 'Authenticated' | 'Not Authenticated'; -} diff --git a/packages/frontend/src/apis/queries/auth/useGetLoginStatus.ts b/packages/frontend/src/apis/queries/auth/useGetLoginStatus.ts index 4d68f335..2e9c5966 100644 --- a/packages/frontend/src/apis/queries/auth/useGetLoginStatus.ts +++ b/packages/frontend/src/apis/queries/auth/useGetLoginStatus.ts @@ -1,15 +1,16 @@ import { useQuery } from '@tanstack/react-query'; -import { GetLoginStatusResponse } from './types'; -import { instance } from '@/apis/config'; +import { GetLoginStatusSchema, type GetLoginStatus } from './schema'; +import { get } from '@/apis/utils/get'; -const getLoginStatus = async (): Promise => { - const { data } = await instance.get('/api/auth/google/status'); - return data; -}; +const getLoginStatus = () => + get({ + schema: GetLoginStatusSchema, + url: '/api/auth/google/status', + }); export const useGetLoginStatus = () => { - return useQuery({ - queryKey: ['login_status'], + return useQuery({ + queryKey: ['loginStatus'], queryFn: getLoginStatus, }); }; diff --git a/packages/frontend/src/apis/queries/stock-detail/index.ts b/packages/frontend/src/apis/queries/stock-detail/index.ts index 1ec21def..031ffa0d 100644 --- a/packages/frontend/src/apis/queries/stock-detail/index.ts +++ b/packages/frontend/src/apis/queries/stock-detail/index.ts @@ -1 +1,2 @@ +export * from './useGetStockDetail'; export * from './usePostStockView'; diff --git a/packages/frontend/src/apis/queries/stock-detail/schema.ts b/packages/frontend/src/apis/queries/stock-detail/schema.ts new file mode 100644 index 00000000..7bc7e881 --- /dev/null +++ b/packages/frontend/src/apis/queries/stock-detail/schema.ts @@ -0,0 +1,32 @@ +import { z } from 'zod'; + +export const GetStockRequestSchema = z.object({ + stockId: z.string(), +}); + +export type GetStockRequest = z.infer; + +export const GetStockResponseSchema = z.object({ + marketCap: z.number(), + name: z.string(), + eps: z.number(), + per: z.number(), + high52w: z.number(), + low52w: z.number(), +}); + +export type GetStockResponse = z.infer; + +export const PostStockViewRequestSchema = z.object({ + stockId: z.string(), +}); + +export type PostStockViewRequest = z.infer; + +export const PostViewResponseSchema = z.object({ + id: z.string(), + message: z.string(), + date: z.date(), +}); + +export type PostViewResponse = z.infer; diff --git a/packages/frontend/src/apis/queries/stock-detail/types.ts b/packages/frontend/src/apis/queries/stock-detail/types.ts deleted file mode 100644 index 1a5eb831..00000000 --- a/packages/frontend/src/apis/queries/stock-detail/types.ts +++ /dev/null @@ -1,9 +0,0 @@ -export interface PostStockViewRequest { - stockId: string; -} - -export interface PostStockViewResponse { - id: string; - message: string; - date: Date; -} diff --git a/packages/frontend/src/apis/queries/stock-detail/useGetStockDetail.ts b/packages/frontend/src/apis/queries/stock-detail/useGetStockDetail.ts new file mode 100644 index 00000000..3704dfe0 --- /dev/null +++ b/packages/frontend/src/apis/queries/stock-detail/useGetStockDetail.ts @@ -0,0 +1,21 @@ +import { useQuery } from '@tanstack/react-query'; +import { + GetStockResponseSchema, + type GetStockRequest, + type GetStockResponse, +} from './schema'; +import { get } from '@/apis/utils/get'; + +const getStockDetail = ({ stockId }: GetStockRequest) => + get({ + schema: GetStockResponseSchema, + url: `/api/stock/${stockId}/detail`, + }); + +export const useGetStockDetail = ({ stockId }: GetStockRequest) => { + return useQuery({ + queryKey: ['stockDetail'], + queryFn: () => getStockDetail({ stockId }), + enabled: !!stockId, + }); +}; diff --git a/packages/frontend/src/apis/queries/stock-detail/usePostStockView.ts b/packages/frontend/src/apis/queries/stock-detail/usePostStockView.ts index 0c63cfff..6c6e8ede 100644 --- a/packages/frontend/src/apis/queries/stock-detail/usePostStockView.ts +++ b/packages/frontend/src/apis/queries/stock-detail/usePostStockView.ts @@ -1,20 +1,22 @@ -import type { PostStockViewRequest, PostStockViewResponse } from './types'; -import { useMutation, type UseMutationOptions } from '@tanstack/react-query'; -import { instance } from '@/apis/config'; +import { useMutation } from '@tanstack/react-query'; +import { + PostViewResponseSchema, + type PostStockViewRequest, + type PostViewResponse, +} from './schema'; +import { post } from '@/apis/utils/post'; -const postStockView = async ({ - stockId, -}: PostStockViewRequest): Promise => { - const { data } = await instance.post('/api/stock/view', { stockId }); - return data; -}; +const postStockView = async ({ stockId }: PostStockViewRequest) => + post({ + params: stockId, + schema: PostViewResponseSchema, + url: 'api/stock/view', + }); -export const usePostStockView = ( - options?: UseMutationOptions, -) => { - return useMutation({ - mutationKey: ['stock_view'], - mutationFn: (stockId) => postStockView({ stockId }), - ...options, +export const usePostStockView = () => { + return useMutation({ + mutationKey: ['stockView'], + mutationFn: ({ stockId }: PostStockViewRequest) => + postStockView({ stockId }), }); }; diff --git a/packages/frontend/src/apis/queries/stocks/index.ts b/packages/frontend/src/apis/queries/stocks/index.ts index 645eecca..9337de4e 100644 --- a/packages/frontend/src/apis/queries/stocks/index.ts +++ b/packages/frontend/src/apis/queries/stocks/index.ts @@ -1,3 +1,3 @@ -export * from './types'; +export * from './schema'; export * from './useGetTopViews'; export * from './useGetStocksByPrice'; diff --git a/packages/frontend/src/apis/queries/stocks/schema.ts b/packages/frontend/src/apis/queries/stocks/schema.ts new file mode 100644 index 00000000..a857bd08 --- /dev/null +++ b/packages/frontend/src/apis/queries/stocks/schema.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; + +export const GetStockListRequestSchema = z.object({ + limit: z.number(), + sortType: z.enum(['increase', 'decrease']), +}); + +export type GetStockListRequest = z.infer; + +export const GetStockListResponseSchema = z.object({ + id: z.string(), + name: z.string(), + currentPrice: z.number(), + changeRate: z.number(), + volume: z.number(), + marketCap: z.string(), +}); + +export type GetStockListResponse = z.infer; diff --git a/packages/frontend/src/apis/queries/stocks/types.ts b/packages/frontend/src/apis/queries/stocks/types.ts deleted file mode 100644 index 528603db..00000000 --- a/packages/frontend/src/apis/queries/stocks/types.ts +++ /dev/null @@ -1,12 +0,0 @@ -export interface GetStockListRequest { - limit: number; -} - -export interface GetStockListResponse { - id: string; - name: string; - currentPrice: number; - changeRate: number; - volume: number; - marketCap: string; -} diff --git a/packages/frontend/src/apis/queries/stocks/useGetStocksByPrice.ts b/packages/frontend/src/apis/queries/stocks/useGetStocksByPrice.ts index 7d6fa284..a14f0269 100644 --- a/packages/frontend/src/apis/queries/stocks/useGetStocksByPrice.ts +++ b/packages/frontend/src/apis/queries/stocks/useGetStocksByPrice.ts @@ -1,29 +1,32 @@ -import type { GetStockListRequest, GetStockListResponse } from './types'; import { useQuery } from '@tanstack/react-query'; -import { instance } from '@/apis/config'; +import { + GetStockListResponseSchema, + type GetStockListRequest, + type GetStockListResponse, +} from './schema'; +import { get } from '@/apis/utils/get'; -const getTopGainers = async ({ - limit, -}: GetStockListRequest): Promise => { - const { data } = await instance.get(`/api/stock/topGainers?limit=${limit}`); - return data; -}; +const getTopGainers = ({ limit }: Partial) => + get({ + schema: GetStockListResponseSchema, + url: `/api/stock/topGainers?limit=${limit}`, + }); -const getTopLosers = async ({ - limit, -}: GetStockListRequest): Promise => { - const { data } = await instance.get(`/api/stock/topLosers?limit=${limit}`); - return data; -}; +const getTopLosers = ({ limit }: Partial) => + get({ + schema: GetStockListResponseSchema, + url: `/api/stock/topLosers?limit=${limit}`, + }); export const useGetStocksByPrice = ({ limit, - isGaining, -}: GetStockListRequest & { isGaining: boolean }) => { - return useQuery({ - queryKey: ['stocks', isGaining], - queryFn: isGaining - ? () => getTopGainers({ limit }) - : () => getTopLosers({ limit }), + sortType, +}: GetStockListRequest) => { + return useQuery({ + queryKey: ['stocks', sortType], + queryFn: + sortType === 'increase' + ? () => getTopGainers({ limit }) + : () => getTopLosers({ limit }), }); }; diff --git a/packages/frontend/src/apis/queries/stocks/useGetTopViews.ts b/packages/frontend/src/apis/queries/stocks/useGetTopViews.ts index 64b48a65..9ba48219 100644 --- a/packages/frontend/src/apis/queries/stocks/useGetTopViews.ts +++ b/packages/frontend/src/apis/queries/stocks/useGetTopViews.ts @@ -1,16 +1,19 @@ -import type { GetStockListRequest, GetStockListResponse } from './types'; import { useQuery } from '@tanstack/react-query'; -import { instance } from '@/apis/config'; +import { + GetStockListResponseSchema, + type GetStockListRequest, + type GetStockListResponse, +} from './schema'; +import { get } from '@/apis/utils/get'; -const getTopViews = async ({ - limit, -}: GetStockListRequest): Promise => { - const { data } = await instance.get(`/api/stock/topViews?limit=${limit}`); - return data; -}; +const getTopViews = ({ limit }: Partial) => + get({ + schema: GetStockListResponseSchema, + url: `/api/stock/topViews?limit=${limit}`, + }); -export const useGetTopViews = ({ limit }: GetStockListRequest) => { - return useQuery({ +export const useGetTopViews = ({ limit }: Partial) => { + return useQuery({ queryKey: ['stocks', 'topViews'], queryFn: () => getTopViews({ limit }), }); diff --git a/packages/frontend/src/apis/utils/formatZodError.ts b/packages/frontend/src/apis/utils/formatZodError.ts new file mode 100644 index 00000000..616b951d --- /dev/null +++ b/packages/frontend/src/apis/utils/formatZodError.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const formatZodError = (error: z.ZodError): string => { + return error.errors + .map((err) => `${err.path.join('.')}: ${err.message}`) + .join(', '); +}; diff --git a/packages/frontend/src/apis/utils/get.ts b/packages/frontend/src/apis/utils/get.ts new file mode 100644 index 00000000..6f894f59 --- /dev/null +++ b/packages/frontend/src/apis/utils/get.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; +import { instance } from '../config'; +import { formatZodError } from './formatZodError'; + +interface GetParams { + schema: z.ZodType; + url: string; +} + +export const get = async ({ schema, url }: GetParams): Promise => { + try { + const { data } = await instance.get(url); + const result = schema.safeParse(data); + + if (!result.success) { + throw new Error(formatZodError(result.error)); + } + + return data; + } catch (error) { + if (process.env.NODE_ENV === 'development') { + console.error('API error:', error); + } + return null; + } +}; diff --git a/packages/frontend/src/apis/utils/post.ts b/packages/frontend/src/apis/utils/post.ts new file mode 100644 index 00000000..9f7c71c2 --- /dev/null +++ b/packages/frontend/src/apis/utils/post.ts @@ -0,0 +1,32 @@ +import { AxiosRequestConfig } from 'axios'; +import { z } from 'zod'; +import { instance } from '../config'; +import { formatZodError } from './formatZodError'; + +interface PostParams { + params: AxiosRequestConfig['params']; + schema: z.ZodType; + url: string; +} + +export const post = async ({ + params, + schema, + url, +}: PostParams): Promise => { + try { + const { data } = await instance.post(url, { params }); + const result = schema.safeParse(data); + + if (!result.success) { + throw new Error(formatZodError(result.error)); + } + + return data; + } catch (error) { + if (process.env.NODE_ENV === 'development') { + console.error('API error:', error); + } + return null; + } +}; diff --git a/packages/frontend/src/pages/stock-detail/ChatPanel.tsx b/packages/frontend/src/pages/stock-detail/ChatPanel.tsx index 5a2d5765..6404c9bc 100644 --- a/packages/frontend/src/pages/stock-detail/ChatPanel.tsx +++ b/packages/frontend/src/pages/stock-detail/ChatPanel.tsx @@ -1,14 +1,18 @@ -import type { ChatDataType, ChatDataResponse } from '@/sockets/types'; import { useEffect, useMemo, useState } from 'react'; import { TextArea } from './components'; import { ChatMessage } from './components/ChatMessage'; import DownArrow from '@/assets/down-arrow.svg?react'; import { socketChat } from '@/sockets/config'; +import { + ChatDataSchema, + type ChatData, + type ChatDataResponse, +} from '@/sockets/schema'; import { useWebsocket } from '@/sockets/useWebsocket'; export const ChatPanel = () => { const STOCK_ID = '005930'; - const [chatData, setChatData] = useState(); + const [chatData, setChatData] = useState(); const socket = useMemo(() => { return socketChat({ stockId: STOCK_ID }); @@ -27,7 +31,9 @@ export const ChatPanel = () => { useEffect(() => { const handleChat = (message: ChatDataResponse) => { - if (message?.chats) { + const validatedChatData = ChatDataSchema.safeParse(message?.chats); + + if (validatedChatData.success && message?.chats) { setChatData(message.chats); } }; diff --git a/packages/frontend/src/pages/stock-detail/StockDetail.tsx b/packages/frontend/src/pages/stock-detail/StockDetail.tsx index 1f74e492..ba67e5f7 100644 --- a/packages/frontend/src/pages/stock-detail/StockDetail.tsx +++ b/packages/frontend/src/pages/stock-detail/StockDetail.tsx @@ -6,19 +6,20 @@ import { NotificationPanel, StockMetricsPanel, } from '.'; +import { useGetStockDetail } from '@/apis/queries/stock-detail'; import Plus from '@/assets/plus.svg?react'; import { Button } from '@/components/ui/button'; import stockData from '@/mocks/stock.json'; export const StockDetail = () => { - const { stockId = '' } = useParams(); + const { stockId } = useParams(); + const { data } = useGetStockDetail({ stockId: stockId ?? '' }); - const detailData = stockData.data.find((data) => data.id === +stockId); return (
-

{detailData?.name}

+

{data?.name}

diff --git a/packages/frontend/src/pages/stocks/StockRankingTable.tsx b/packages/frontend/src/pages/stocks/StockRankingTable.tsx index b8464a70..42ea0715 100644 --- a/packages/frontend/src/pages/stocks/StockRankingTable.tsx +++ b/packages/frontend/src/pages/stocks/StockRankingTable.tsx @@ -1,17 +1,20 @@ import { useState } from 'react'; import { Link } from 'react-router-dom'; import { usePostStockView } from '@/apis/queries/stock-detail'; -// import { useGetStocksByPrice } from '@/apis/queries/stocks'; +import { + type GetStockListRequest, + useGetStocksByPrice, +} from '@/apis/queries/stocks'; import DownArrow from '@/assets/down-arrow.svg?react'; -import stockData from '@/mocks/stock.json'; import { cn } from '@/utils/cn'; -// const LIMIT = 20; +const LIMIT = 20; export const StockRankingTable = () => { - const [isGaining, setIsGaining] = useState(true); + const [sortType, setSortType] = + useState('increase'); - // const { data } = useGetStocksByPrice({ limit: LIMIT, isGaining }); + const { data } = useGetStocksByPrice({ limit: LIMIT, sortType }); const { mutate } = usePostStockView(); return ( @@ -29,13 +32,18 @@ export const StockRankingTable = () => { 종목 현재가 -

등락률({isGaining ? '상승순' : '하락순'})

+

등락률({sortType === 'increase' ? '상승순' : '하락순'})

setIsGaining((prev) => !prev)} + onClick={() => + setSortType((prev) => { + if (prev === 'increase') return 'decrease'; + return 'increase'; + }) + } /> 거래대금 @@ -43,7 +51,7 @@ export const StockRankingTable = () => { - {stockData.data?.map((stock, index) => ( + {data?.map((stock, index) => ( { {index + 1} mutate(stock.id.toString())} + onClick={() => mutate({ stockId: stock.id.toString() })} className="display-bold14 hover:text-orange cursor-pointer text-ellipsis hover:underline" aria-label={stock.name} > diff --git a/packages/frontend/src/pages/stocks/Stocks.tsx b/packages/frontend/src/pages/stocks/Stocks.tsx index 8bdcc01c..d5fda3ca 100644 --- a/packages/frontend/src/pages/stocks/Stocks.tsx +++ b/packages/frontend/src/pages/stocks/Stocks.tsx @@ -1,11 +1,10 @@ import { StockIndexCard } from './components/StockIndexCard'; import { StockInfoCard } from './components/StockInfoCard'; import { StockRankingTable } from './StockRankingTable'; -// import { useGetTopViews } from '@/apis/queries/stocks'; +import { useGetTopViews } from '@/apis/queries/stocks'; import marketData from '@/mocks/market.json'; -import stock from '@/mocks/stock.json'; -// const LIMIT = 5; +const LIMIT = 5; export const Stocks = () => { const kospi = marketData.data.filter((value) => value.name === '코스피')[0]; @@ -14,7 +13,7 @@ export const Stocks = () => { (value) => value.name === '달러환율', )[0]; - // const { data: topViews } = useGetTopViews({ limit: LIMIT }); + const { data: topViews } = useGetTopViews({ limit: LIMIT }); return (
@@ -52,17 +51,15 @@ export const Stocks = () => { 이 종목은 어떠신가요?
- {stock.data - .slice(0, 5) - ?.map((stock, index) => ( - - ))} + {topViews?.map((stock, index) => ( + + ))}
diff --git a/packages/frontend/src/sockets/schema.ts b/packages/frontend/src/sockets/schema.ts new file mode 100644 index 00000000..7495d1ed --- /dev/null +++ b/packages/frontend/src/sockets/schema.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; + +export const ChatDataSchema = z.object({ + id: z.number(), + likeCount: z.number(), + message: z.string(), + type: z.string(), + createdAt: z.date(), + liked: z.boolean(), + nickname: z.string(), +}); + +export type ChatData = z.infer; + +export const ChatDataResponseSchema = z.object({ + chats: z.array(ChatDataSchema), + hasMore: z.boolean(), +}); + +export type ChatDataResponse = z.infer; diff --git a/packages/frontend/src/sockets/types.ts b/packages/frontend/src/sockets/types.ts deleted file mode 100644 index 323f0b4a..00000000 --- a/packages/frontend/src/sockets/types.ts +++ /dev/null @@ -1,14 +0,0 @@ -export interface ChatDataType { - id: number; - likeCount: number; - message: string; - type: string; - createdAt: Date; - liked: boolean; - nickname: string; -} - -export interface ChatDataResponse { - chats?: ChatDataType[]; - hasMore: boolean; -} diff --git a/yarn.lock b/yarn.lock index f3d6ca8c..621f108a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8318,3 +8318,8 @@ yocto-queue@^1.0.0: version "1.1.1" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.1.1.tgz#fef65ce3ac9f8a32ceac5a634f74e17e5b232110" integrity sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g== + +zod@^3.23.8: + version "3.23.8" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d" + integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==