diff --git a/FE/src/components/Header.tsx b/FE/src/components/Header.tsx index 68d276d5..85de5b6e 100644 --- a/FE/src/components/Header.tsx +++ b/FE/src/components/Header.tsx @@ -4,13 +4,26 @@ import useLoginModalStore from 'store/useLoginModalStore'; import useSearchModalStore from '../store/useSearchModalStore.ts'; import useSearchInputStore from '../store/useSearchInputStore.ts'; import logo from 'assets/Logo.png'; +import { deleteCookie } from 'utils/common.ts'; +import { checkAuth } from 'service/auth.ts'; +import { useEffect } from 'react'; export default function Header() { const { toggleModal } = useLoginModalStore(); - const { isLogin, resetToken } = useAuthStore(); + const { isLogin, setIsLogin } = useAuthStore(); const { toggleSearchModal } = useSearchModalStore(); const { searchInput } = useSearchInputStore(); + useEffect(() => { + const check = async () => { + const res = await checkAuth(); + if (res.ok) setIsLogin(true); + else setIsLogin(false); + }; + + check(); + }, [setIsLogin]); + return (
@@ -24,7 +37,6 @@ export default function Header() { 랭킹 마이페이지 -
{ + setIsLogin(false); + deleteCookie('accessToken'); + }} > 로그아웃 diff --git a/FE/src/components/Login/index.tsx b/FE/src/components/Login/index.tsx index 8f6bbb35..91dec9f2 100644 --- a/FE/src/components/Login/index.tsx +++ b/FE/src/components/Login/index.tsx @@ -10,7 +10,7 @@ export default function Login() { const { isOpen, toggleModal } = useLoginModalStore(); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); - const { setAccessToken } = useAuthStore(); + const { setIsLogin } = useAuthStore(); const [errorCode, setErrorCode] = useState(200); useEffect(() => { @@ -29,7 +29,7 @@ export default function Login() { return; } - setAccessToken(res.accessToken); + setIsLogin(true); toggleModal(); }; @@ -46,6 +46,7 @@ export default function Login() { } document.cookie = `accessToken=${res.accessToken}; path=/;`; + setIsLogin(true); toggleModal(); return; } diff --git a/FE/src/components/Mypage/AccountCondition.tsx b/FE/src/components/Mypage/AccountCondition.tsx index 64841d59..6e6aae33 100644 --- a/FE/src/components/Mypage/AccountCondition.tsx +++ b/FE/src/components/Mypage/AccountCondition.tsx @@ -12,6 +12,7 @@ export default function AccountCondition({ asset }: AccountConditionProps) { total_asset, total_profit, total_profit_rate, + is_positive, } = asset; return ( @@ -19,17 +20,17 @@ export default function AccountCondition({ asset }: AccountConditionProps) {

자산 현황

-

총 자산

+

총 자산

{stringToLocaleString(total_asset)}원

-

가용 자산

+

가용 자산

{stringToLocaleString(cash_balance)}원

-

주식 자산

+

주식 자산

{stringToLocaleString(stock_balance)}원

@@ -39,12 +40,20 @@ export default function AccountCondition({ asset }: AccountConditionProps) {

투자 성과

-

투자 손익

-

{stringToLocaleString(total_profit)}원

+

투자 손익

+

+ {stringToLocaleString(total_profit)}원 +

-

수익률

-

{total_profit_rate}%

+

수익률

+

+ {total_profit_rate}% +

diff --git a/FE/src/components/Mypage/Nav.tsx b/FE/src/components/Mypage/Nav.tsx index fda936e0..a50c272b 100644 --- a/FE/src/components/Mypage/Nav.tsx +++ b/FE/src/components/Mypage/Nav.tsx @@ -3,9 +3,10 @@ import { MypageSectionType } from 'types'; const mapping = { account: '보유 자산 현황', + order: '주문 요청 현황', info: '내 정보', }; -const sections: MypageSectionType[] = ['account', 'info']; +const sections: MypageSectionType[] = ['account', 'order', 'info']; export default function Nav() { const [searchParams, setSearchParams] = useSearchParams(); diff --git a/FE/src/components/Mypage/Order.tsx b/FE/src/components/Mypage/Order.tsx new file mode 100644 index 00000000..5f337d36 --- /dev/null +++ b/FE/src/components/Mypage/Order.tsx @@ -0,0 +1,68 @@ +import useOrders from 'hooks/useOrder'; +import { parseTimestamp } from 'utils/common'; + +export default function Order() { + const { orderQuery, removeOrder } = useOrders(); + + const { data, isLoading, isError } = orderQuery; + + if (isLoading) return
loading
; + if (!data) return
No data
; + if (isError) return
error
; + + const handleCancelOrder = (id: number) => { + removeOrder.mutate(id); + }; + + return ( +
+
+

종목

+

요청 유형

+

수량

+

요청 가격

+

요청 시간

+

+
+ +
    + {data.map((order) => { + const { + id, + stock_code, + stock_name, + price, + amount, + trade_type, + created_at, + } = order; + + return ( +
  • +
    +

    {stock_name}

    +

    {stock_code}

    +
    +

    + {trade_type === 'BUY' ? '매수' : '매도'} +

    +

    {amount}

    +

    {price.toLocaleString()}원

    +

    + {parseTimestamp(created_at)} +

    +

    + +

    +
  • + ); + })} +
+
+ ); +} diff --git a/FE/src/components/StocksDetail/BuySection.tsx b/FE/src/components/StocksDetail/BuySection.tsx new file mode 100644 index 00000000..375f29c3 --- /dev/null +++ b/FE/src/components/StocksDetail/BuySection.tsx @@ -0,0 +1,145 @@ +import { ChangeEvent, FocusEvent, FormEvent, useRef, useState } from 'react'; +import useTradeAlertModalStore from 'store/tradeAlertModalStore'; +import { StockDetailType } from 'types'; +import { isNumericString } from 'utils/common'; +import TradeAlertModal from './TradeAlertModal'; +const MyAsset = 10000000; + +type BuySectionProps = { + code: string; + data: StockDetailType; +}; + +export default function BuySection({ code, data }: BuySectionProps) { + const { stck_prpr, stck_mxpr, stck_llam } = data; + + const [currPrice, setCurrPrice] = useState(stck_prpr); + + const { isOpen, toggleModal } = useTradeAlertModalStore(); + + const [count, setCount] = useState(0); + + const [upperLimitFlag, setUpperLimitFlag] = useState(false); + const [lowerLimitFlag, setLowerLimitFlag] = useState(false); + const [lackAssetFlag, setLackAssetFlag] = useState(false); + const timerRef = useRef(null); + + const handlePriceChange = (e: ChangeEvent) => { + if (!isNumericString(e.target.value)) return; + + setCurrPrice(e.target.value); + }; + + const handlePriceInputBlur = (e: FocusEvent) => { + const n = +e.target.value; + if (n > +stck_mxpr) { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + setCurrPrice(stck_mxpr); + + setUpperLimitFlag(true); + timerRef.current = window.setTimeout(() => { + setUpperLimitFlag(false); + }, 2000); + return; + } + + if (n < +stck_llam) { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + setCurrPrice(stck_llam); + + setLowerLimitFlag(true); + timerRef.current = window.setTimeout(() => { + setLowerLimitFlag(false); + }, 2000); + return; + } + }; + + const handleBuy = async (e: FormEvent) => { + e.preventDefault(); + + const price = +currPrice * count; + + if (price > MyAsset) { + setLackAssetFlag(true); + timerRef.current = window.setTimeout(() => { + setLackAssetFlag(false); + }, 2000); + return; + } + toggleModal(); + }; + + return ( + <> +
+
+
+

매수 가격

+ +
+ {lowerLimitFlag && ( +
+ 이 주식의 최소 가격은 {(+stck_llam).toLocaleString()}입니다. +
+ )} + {upperLimitFlag && ( +
+ 이 주식의 최대 가격은 {(+stck_mxpr).toLocaleString()}입니다. +
+ )} +
+

수량

+ setCount(+e.target.value)} + className='flex-1 py-1 rounded-lg' + min={1} + /> +
+
+ +
+ +
+
+

매수 가능 금액

+

0원

+
+
+

총 주문 금액

+

{(+currPrice * count).toLocaleString()}원

+
+
+ +
+ {lackAssetFlag && ( +

잔액이 부족해요!

+ )} +
+ +
+ {isOpen && ( + + )} + + ); +} diff --git a/FE/src/components/StocksDetail/SellSection.tsx b/FE/src/components/StocksDetail/SellSection.tsx new file mode 100644 index 00000000..b34136ee --- /dev/null +++ b/FE/src/components/StocksDetail/SellSection.tsx @@ -0,0 +1,15 @@ +import Lottie from 'lottie-react'; +import emptyAnimation from 'assets/emptyAnimation.json'; + +export default function SellSection() { + return ( +
+ +

매도할 주식이 없어요

+
+ ); +} diff --git a/FE/src/components/StocksDetail/TradeAlertModal.tsx b/FE/src/components/StocksDetail/TradeAlertModal.tsx index 28db30f1..d3fcc272 100644 --- a/FE/src/components/StocksDetail/TradeAlertModal.tsx +++ b/FE/src/components/StocksDetail/TradeAlertModal.tsx @@ -1,6 +1,5 @@ import Overay from 'components/ModalOveray'; -import { buyStock } from 'service/stocks'; -// import useAuthStore from 'store/authStore'; +import { orderBuyStock } from 'service/orders'; import useTradeAlertModalStore from 'store/tradeAlertModalStore'; type TradeAlertModalProps = { @@ -17,17 +16,11 @@ export default function TradeAlertModal({ count, }: TradeAlertModalProps) { const { toggleModal } = useTradeAlertModalStore(); - // const { accessToken } = useAuthStore(); - - // if (!accessToken) { - // console.log('accessToken 없음!'); - // return; - // } const charge = 55; // 수수료 임시 const handleBuy = async () => { - const res = await buyStock(code, +price, count); + const res = await orderBuyStock(code, +price, count); if (res.ok) toggleModal(); }; @@ -46,7 +39,7 @@ export default function TradeAlertModal({

예상 수수료

{charge}원

-
{' '} +

총 주문 금액

{(+price + charge).toLocaleString()}원

diff --git a/FE/src/components/StocksDetail/TradeSection.tsx b/FE/src/components/StocksDetail/TradeSection.tsx index cf8d322d..d30188c9 100644 --- a/FE/src/components/StocksDetail/TradeSection.tsx +++ b/FE/src/components/StocksDetail/TradeSection.tsx @@ -1,39 +1,18 @@ -import Lottie from 'lottie-react'; -import { - ChangeEvent, - FocusEvent, - FormEvent, - useEffect, - useRef, - useState, -} from 'react'; -import emptyAnimation from 'assets/emptyAnimation.json'; +import { useEffect, useRef, useState } from 'react'; import { StockDetailType } from 'types'; -import useTradeAlertModalStore from 'store/tradeAlertModalStore'; -import TradeAlertModal from './TradeAlertModal'; +import SellSection from './SellSection'; +import BuySection from './BuySection'; type TradeSectionProps = { code: string; data: StockDetailType; }; -const MyAsset = 10000000; - export default function TradeSection({ code, data }: TradeSectionProps) { - const { stck_prpr, stck_mxpr, stck_llam } = data; - const [category, setCategory] = useState<'buy' | 'sell'>('buy'); - const [currPrice, setCurrPrice] = useState(stck_prpr); - const [upperLimitFlag, setUpperLimitFlag] = useState(false); - const [lowerLimitFlag, setLowerLimitFlag] = useState(false); - const [lackAssetFlag, setLackAssetFlag] = useState(false); - const { isOpen, toggleModal } = useTradeAlertModalStore(); - - const [count, setCount] = useState(0); const indicatorRef = useRef(null); const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]); - const timerRef = useRef(null); useEffect(() => { const idx = category === 'buy' ? 0 : 1; @@ -46,56 +25,6 @@ export default function TradeSection({ code, data }: TradeSectionProps) { } }, [category]); - const handlePriceChange = (e: ChangeEvent) => { - if (!isNumericString(e.target.value)) return; - - setCurrPrice(e.target.value); - }; - - const handlePriceInputBlur = (e: FocusEvent) => { - const n = +e.target.value; - if (n > +stck_mxpr) { - if (timerRef.current) { - clearTimeout(timerRef.current); - } - setCurrPrice(stck_mxpr); - - setUpperLimitFlag(true); - timerRef.current = window.setTimeout(() => { - setUpperLimitFlag(false); - }, 2000); - return; - } - - if (n < +stck_llam) { - if (timerRef.current) { - clearTimeout(timerRef.current); - } - setCurrPrice(stck_llam); - - setLowerLimitFlag(true); - timerRef.current = window.setTimeout(() => { - setLowerLimitFlag(false); - }, 2000); - return; - } - }; - - const handleBuy = async (e: FormEvent) => { - e.preventDefault(); - - const price = +currPrice * count; - - if (price > MyAsset) { - setLackAssetFlag(true); - timerRef.current = window.setTimeout(() => { - setLackAssetFlag(false); - }, 2000); - return; - } - toggleModal(); - }; - return ( <>
@@ -130,85 +59,11 @@ export default function TradeSection({ code, data }: TradeSectionProps) {
{category === 'buy' ? ( -
-
-
-

매수 가격

- -
- {lowerLimitFlag && ( -
- 이 주식의 최소 가격은 {(+stck_llam).toLocaleString()}입니다. -
- )} - {upperLimitFlag && ( -
- 이 주식의 최대 가격은 {(+stck_mxpr).toLocaleString()}입니다. -
- )} -
-

수량

- setCount(+e.target.value)} - className='flex-1 py-1 rounded-lg' - min={1} - /> -
-
- -
- -
-
-

매수 가능 금액

-

0원

-
-
-

총 주문 금액

-

{(+currPrice * count).toLocaleString()}원

-
-
- -
- {lackAssetFlag && ( -

잔액이 부족해요!

- )} -
- -
+ ) : ( -
- -

매도할 주식이 없어요

-
+ )} - {isOpen && ( - - )} ); } - -function isNumericString(str: string) { - return str.length === 0 || /^[0-9]+$/.test(str); -} diff --git a/FE/src/hooks/useOrder.ts b/FE/src/hooks/useOrder.ts new file mode 100644 index 00000000..0c23d661 --- /dev/null +++ b/FE/src/hooks/useOrder.ts @@ -0,0 +1,14 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { deleteOrder, getOrders } from 'service/orders'; + +export default function useOrders() { + const queryClient = useQueryClient(); + + const orderQuery = useQuery(['account', 'order'], () => getOrders()); + + const removeOrder = useMutation((id: number) => deleteOrder(id), { + onSuccess: () => queryClient.invalidateQueries(['account', 'order']), + }); + + return { orderQuery, removeOrder }; +} diff --git a/FE/src/page/MyPage.tsx b/FE/src/page/MyPage.tsx index 5a9b2540..a524ffda 100644 --- a/FE/src/page/MyPage.tsx +++ b/FE/src/page/MyPage.tsx @@ -1,5 +1,6 @@ import Account from 'components/Mypage/Account'; import Nav from 'components/Mypage/Nav'; +import Order from 'components/Mypage/Order'; import { useSearchParams } from 'react-router-dom'; export default function MyPage() { @@ -10,7 +11,13 @@ export default function MyPage() {
); diff --git a/FE/src/service/auth.ts b/FE/src/service/auth.ts index fd0c555f..566dd5f9 100644 --- a/FE/src/service/auth.ts +++ b/FE/src/service/auth.ts @@ -13,3 +13,14 @@ export async function login( }), }).then((res) => res.json()); } + +export async function checkAuth() { + const url = import.meta.env.PROD + ? `${import.meta.env.VITE_API_URL}/auth/check` + : '/api/auth/check'; + + return fetch(url, { + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + }); +} diff --git a/FE/src/service/orders.ts b/FE/src/service/orders.ts new file mode 100644 index 00000000..381b225f --- /dev/null +++ b/FE/src/service/orders.ts @@ -0,0 +1,51 @@ +import { Order } from 'types'; + +export async function orderBuyStock( + code: string, + price: number, + amount: number, +) { + const url = import.meta.env.PROD + ? `${import.meta.env.VITE_API_URL}/stocks/order/buy` + : '/api/stocks/order/buy'; + + return fetch(url, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + stock_code: code, + price, + amount, + }), + }); +} + +export async function getOrders(): Promise { + const url = import.meta.env.PROD + ? `${import.meta.env.VITE_API_URL}/stocks/order/list` + : '/api/stocks/order/list'; + + return fetch(url, { + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + }).then((res) => res.json()); +} + +export async function deleteOrder(id: number) { + const url = import.meta.env.PROD + ? `${import.meta.env.VITE_API_URL}/stocks/order/${id}` + : `/api/stocks/order/${id}`; + + return fetch(url, { + method: 'DELETE', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + }); +} diff --git a/FE/src/service/stocks.ts b/FE/src/service/stocks.ts index 070a72e0..9f5d4bb4 100644 --- a/FE/src/service/stocks.ts +++ b/FE/src/service/stocks.ts @@ -22,22 +22,3 @@ export async function getStocksChartDataByCode( }), }).then((res) => res.json()); } - -export async function buyStock(code: string, price: number, amount: number) { - const url = import.meta.env.PROD - ? `${import.meta.env.VITE_API_URL}/stocks/trade/buy` - : '/api/stocks/trade/buy'; - - return fetch(url, { - method: 'POST', - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - stock_code: code, - price, - amount, - }), - }); -} diff --git a/FE/src/store/authStore.ts b/FE/src/store/authStore.ts index 1a8e8453..0e9a9494 100644 --- a/FE/src/store/authStore.ts +++ b/FE/src/store/authStore.ts @@ -4,12 +4,16 @@ type AuthStore = { accessToken: string | null; isLogin: boolean; setAccessToken: (token: string) => void; + setIsLogin: (isLogin: boolean) => void; resetToken: () => void; }; const useAuthStore = create((set) => ({ accessToken: null, isLogin: false, + setIsLogin: (isLogin: boolean) => { + set({ isLogin }); + }, setAccessToken: (token: string) => { set({ accessToken: token, isLogin: token !== null }); }, diff --git a/FE/src/types.ts b/FE/src/types.ts index 2dd0a8a8..1c61149e 100644 --- a/FE/src/types.ts +++ b/FE/src/types.ts @@ -40,8 +40,7 @@ export type StockChartUnit = { prdy_vrss_sign: string; }; - -export type MypageSectionType = 'account' | 'info'; +export type MypageSectionType = 'account' | 'order' | 'info'; export type Asset = { cash_balance: string; @@ -49,6 +48,7 @@ export type Asset = { total_asset: string; total_profit: string; total_profit_rate: string; + is_positive: boolean; }; export type MyStockListUnit = { @@ -62,10 +62,21 @@ export type AssetsResponse = { asset: Asset; stocks: MyStockListUnit[]; }; + +export type Order = { + id: number; + stock_code: string; + stock_name: string; + amount: number; + price: number; + trade_type: 'BUY' | 'SELL'; + created_at: string; +}; + export type ChartSizeConfigType = { upperHeight: number; lowerHeight: number; chartWidth: number; yAxisWidth: number; xAxisHeight: number; -}; \ No newline at end of file +}; diff --git a/FE/src/utils/common.ts b/FE/src/utils/common.ts index 5e09b33b..6e8408ea 100644 --- a/FE/src/utils/common.ts +++ b/FE/src/utils/common.ts @@ -1,3 +1,26 @@ export function stringToLocaleString(s: string) { return (+s).toLocaleString(); } + +export function deleteCookie(name: string) { + document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`; + window.location.reload(); +} + +export function parseTimestamp(timestamp: string) { + const date = new Date(timestamp); + + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); // 월은 0부터 시작하므로 +1 + const day = String(date.getDate()).padStart(2, '0'); + + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + const seconds = String(date.getSeconds()).padStart(2, '0'); + + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; +} + +export function isNumericString(str: string) { + return str.length === 0 || /^[0-9]+$/.test(str); +}