diff --git a/FE/.prettierrc.json b/FE/.prettierrc.json new file mode 100644 index 00000000..a22a75fa --- /dev/null +++ b/FE/.prettierrc.json @@ -0,0 +1 @@ +{"semi": true,"singleQuote": true,"trailingComma": "all","printWidth": 80,"tabWidth": 2,"bracketSpacing": true,"jsxSingleQuote": true,"jsxBracketSameLine": false,"arrowParens": "always","plugins": ["prettier-plugin-tailwindcss"]} \ No newline at end of file diff --git a/FE/eslint.config.js b/FE/eslint.config.js index 092408a9..4a1ba46b 100644 --- a/FE/eslint.config.js +++ b/FE/eslint.config.js @@ -1,28 +1,29 @@ -import js from '@eslint/js' -import globals from 'globals' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' -import tseslint from 'typescript-eslint' +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; export default tseslint.config( - { ignores: ['dist'] }, + { ignores: ["dist"] }, { extends: [js.configs.recommended, ...tseslint.configs.recommended], - files: ['**/*.{ts,tsx}'], + files: ["**/*.{ts,tsx}"], languageOptions: { ecmaVersion: 2020, globals: globals.browser, }, plugins: { - 'react-hooks': reactHooks, - 'react-refresh': reactRefresh, + "react-hooks": reactHooks, + "react-refresh": reactRefresh, }, rules: { ...reactHooks.configs.recommended.rules, - 'react-refresh/only-export-components': [ - 'warn', + "react-refresh/only-export-components": [ + "warn", { allowConstantExport: true }, ], + quotes: ["error", "single"], }, }, -) +); diff --git a/FE/package-lock.json b/FE/package-lock.json index 7395e6fe..11fde835 100644 --- a/FE/package-lock.json +++ b/FE/package-lock.json @@ -8,6 +8,7 @@ "name": "fe", "version": "0.0.0", "dependencies": { + "@heroicons/react": "^2.1.5", "@tanstack/react-query": "^4.36.1", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -26,6 +27,8 @@ "eslint-plugin-react-refresh": "^0.4.14", "globals": "^15.11.0", "postcss": "^8.4.47", + "prettier": "^3.3.3", + "prettier-plugin-tailwindcss": "^0.6.8", "tailwindcss": "^3.4.14", "typescript": "~5.6.2", "typescript-eslint": "^8.11.0", @@ -815,6 +818,14 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@heroicons/react": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.1.5.tgz", + "integrity": "sha512-FuzFN+BsHa+7OxbvAERtgBTNeZpUjgM/MIizfVkSCL2/edriN0Hx/DWRCR//aPYwO5QX/YlgLGXk+E3PcfZwjA==", + "peerDependencies": { + "react": ">= 16" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -3212,6 +3223,99 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", + "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-tailwindcss": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.8.tgz", + "integrity": "sha512-dGu3kdm7SXPkiW4nzeWKCl3uoImdd5CTZEJGxyypEPL37Wj0HT2pLqjrvSei1nTeuQfO4PUfjeW5cTUNRLZ4sA==", + "dev": true, + "engines": { + "node": ">=14.21.3" + }, + "peerDependencies": { + "@ianvs/prettier-plugin-sort-imports": "*", + "@prettier/plugin-pug": "*", + "@shopify/prettier-plugin-liquid": "*", + "@trivago/prettier-plugin-sort-imports": "*", + "@zackad/prettier-plugin-twig-melody": "*", + "prettier": "^3.0", + "prettier-plugin-astro": "*", + "prettier-plugin-css-order": "*", + "prettier-plugin-import-sort": "*", + "prettier-plugin-jsdoc": "*", + "prettier-plugin-marko": "*", + "prettier-plugin-multiline-arrays": "*", + "prettier-plugin-organize-attributes": "*", + "prettier-plugin-organize-imports": "*", + "prettier-plugin-sort-imports": "*", + "prettier-plugin-style-order": "*", + "prettier-plugin-svelte": "*" + }, + "peerDependenciesMeta": { + "@ianvs/prettier-plugin-sort-imports": { + "optional": true + }, + "@prettier/plugin-pug": { + "optional": true + }, + "@shopify/prettier-plugin-liquid": { + "optional": true + }, + "@trivago/prettier-plugin-sort-imports": { + "optional": true + }, + "@zackad/prettier-plugin-twig-melody": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + }, + "prettier-plugin-css-order": { + "optional": true + }, + "prettier-plugin-import-sort": { + "optional": true + }, + "prettier-plugin-jsdoc": { + "optional": true + }, + "prettier-plugin-marko": { + "optional": true + }, + "prettier-plugin-multiline-arrays": { + "optional": true + }, + "prettier-plugin-organize-attributes": { + "optional": true + }, + "prettier-plugin-organize-imports": { + "optional": true + }, + "prettier-plugin-sort-imports": { + "optional": true + }, + "prettier-plugin-style-order": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + } + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", diff --git a/FE/package.json b/FE/package.json index ad0edbc5..0cdda5d9 100644 --- a/FE/package.json +++ b/FE/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@heroicons/react": "^2.1.5", "@tanstack/react-query": "^4.36.1", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -28,6 +29,8 @@ "eslint-plugin-react-refresh": "^0.4.14", "globals": "^15.11.0", "postcss": "^8.4.47", + "prettier": "^3.3.3", + "prettier-plugin-tailwindcss": "^0.6.8", "tailwindcss": "^3.4.14", "typescript": "~5.6.2", "typescript-eslint": "^8.11.0", diff --git a/FE/public/Logo.png b/FE/public/Logo.png new file mode 100644 index 00000000..7708651b Binary files /dev/null and b/FE/public/Logo.png differ diff --git a/FE/src/App.tsx b/FE/src/App.tsx index 1858facc..59645bba 100644 --- a/FE/src/App.tsx +++ b/FE/src/App.tsx @@ -6,7 +6,7 @@ function App() { return ( - } /> + } /> ); diff --git a/FE/src/components/Header.tsx b/FE/src/components/Header.tsx index 4d58efc9..b2b294fb 100644 --- a/FE/src/components/Header.tsx +++ b/FE/src/components/Header.tsx @@ -1,3 +1,55 @@ +import useAuthStore from 'store/authStore'; +import useLoginModalStore from 'store/useLoginModalStore'; + export default function Header() { - return
header
; + const { toggleModal } = useLoginModalStore(); + const { isLogin, resetToken } = useAuthStore(); + + return ( +
+
+
+ +

JuGa

+
+ +
+ +
+ +
+
+
+ {isLogin ? ( + + ) : ( + <> + + {/* */} + + )} +
+
+
+ ); } diff --git a/FE/src/components/Login/Input.tsx b/FE/src/components/Login/Input.tsx new file mode 100644 index 00000000..b6b78ab1 --- /dev/null +++ b/FE/src/components/Login/Input.tsx @@ -0,0 +1,12 @@ +import { ComponentProps } from 'react'; + +type LoginInputProps = ComponentProps<'input'>; + +export default function Input({ ...props }: LoginInputProps) { + return ( + + ); +} diff --git a/FE/src/components/Login/index.tsx b/FE/src/components/Login/index.tsx new file mode 100644 index 00000000..fbe4e418 --- /dev/null +++ b/FE/src/components/Login/index.tsx @@ -0,0 +1,72 @@ +import useLoginModalStore from 'store/useLoginModalStore'; +import Input from './Input'; +import { ChatBubbleOvalLeftIcon } from '@heroicons/react/16/solid'; +import { FormEvent, useEffect, useState } from 'react'; +import { login } from 'service/auth'; +import useAuthStore from 'store/authStore'; + +export default function Login() { + const { isOpen, toggleModal } = useLoginModalStore(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const { setAccessToken } = useAuthStore(); + + useEffect(() => { + setEmail(''); + setPassword(''); + }, [isOpen]); + + if (!isOpen) return; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + const res = await login(email, password); + + if ('error' in res) { + return; + } + + setAccessToken(res.accessToken); + toggleModal(); + }; + + return ( + <> + toggleModal()} /> +
+

JuGa

+
+
+ setEmail(e.target.value)} + autoComplete='username' + /> + setPassword(e.target.value)} + autoComplete='current-password' + /> +
+ +
+ +
+ + ); +} + +function Overay({ onClick }: { onClick: () => void }) { + return ( +
+ ); +} diff --git a/FE/src/components/StockIndex/Chart.tsx b/FE/src/components/StockIndex/Chart.tsx new file mode 100644 index 00000000..2ab43205 --- /dev/null +++ b/FE/src/components/StockIndex/Chart.tsx @@ -0,0 +1,49 @@ +import { useEffect, useRef, useState } from 'react'; +import { drawChart } from 'utils/chart'; + +const X_LENGTH = 79; + +type StockIndexChartProps = { + name: string; +}; + +export function Chart({ name }: StockIndexChartProps) { + const [prices, setPrices] = useState([50, 54]); + const canvasRef = useRef(null); + + useEffect(() => { + const interval = setInterval(() => { + if (prices.length === X_LENGTH) { + clearInterval(interval); + return; + } + setPrices((prev) => [...prev, Math.floor(Math.random() * 50) + 25]); + }, 500); + + return () => clearInterval(interval); + }, [prices.length]); + + useEffect(() => { + const canvas = canvasRef.current; + const ctx = canvas?.getContext('2d'); + if (!ctx) return; + + drawChart(ctx, prices); + }, [prices]); + + return ( +
+
+

{name}

+

2562.4

+

-31.55(-1.2%)

+
+ +
+ ); +} diff --git a/FE/src/components/StockIndex/index.tsx b/FE/src/components/StockIndex/index.tsx new file mode 100644 index 00000000..a5fead6b --- /dev/null +++ b/FE/src/components/StockIndex/index.tsx @@ -0,0 +1,10 @@ +import { Chart } from './Chart'; + +export default function StockIndex() { + return ( +
+ + +
+ ); +} diff --git a/FE/src/components/TopFive/Card.tsx b/FE/src/components/TopFive/Card.tsx new file mode 100644 index 00000000..3ca284e1 --- /dev/null +++ b/FE/src/components/TopFive/Card.tsx @@ -0,0 +1,43 @@ +type CardProps = { + name: string; + price: string; + changePercentage: string; + changePrice: string; + index: number; +}; + +export default function Card({ + name, + price, + changePercentage, + changePrice, + index, +}: CardProps) { + const changeValue = + typeof changePercentage === 'string' + ? Number(changePercentage) + : changePercentage; + const changeColor = + changeValue > 0 ? 'text-juga-red-60' : 'text-juga-blue-50'; + + return ( +
+
{index + 1}
+
+

{name}

+
+
+

+ {price?.toLocaleString()} +

+
+
+

+ {changeValue > 0 + ? `${changePrice}(${changeValue}%)` + : `${changePrice}(${Math.abs(changeValue)}%)`} +

+
+
+ ); +} diff --git a/FE/src/components/TopFive/List.tsx b/FE/src/components/TopFive/List.tsx new file mode 100644 index 00000000..cc53b801 --- /dev/null +++ b/FE/src/components/TopFive/List.tsx @@ -0,0 +1,42 @@ +import Card from './Card'; +import { SkeletonCard } from './SkeletonCard.tsx'; +import { StockData } from './type.ts'; + +type ListProps = { + listTitle: string; + data: StockData[]; + isLoading: boolean; +}; + +export default function List({ listTitle, data, isLoading }: ListProps) { + return ( +
+
+ {listTitle} +
+
+
종목
+
현재가
+
등락
+
+ +
    + {isLoading + ? Array.from({ length: 5 }).map((_, index) => ( + + )) + : data.map((stock: StockData, index) => ( +
  • + +
  • + ))} +
+
+ ); +} diff --git a/FE/src/components/TopFive/Nav.tsx b/FE/src/components/TopFive/Nav.tsx new file mode 100644 index 00000000..941c8249 --- /dev/null +++ b/FE/src/components/TopFive/Nav.tsx @@ -0,0 +1,53 @@ +import { useSearchParams } from 'react-router-dom'; +import { useEffect, useRef } from 'react'; +import { MarketType } from './type.ts'; + +export default function Nav() { + const [searchParams, setSearchParams] = useSearchParams(); + const currentMarket = searchParams.get('top') || '전체'; + const indicatorRef = useRef(null); + const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]); + + const markets: MarketType[] = ['전체', '코스피', '코스닥', '코스피200']; + + const handleMarketChange = (market: MarketType) => { + if (market === '전체') { + searchParams.delete('top'); + setSearchParams(searchParams); + } else { + setSearchParams({ top: market }); + } + }; + + useEffect(() => { + const currentButton = + buttonRefs.current[markets.indexOf(currentMarket as MarketType)]; + const indicator = indicatorRef.current; + + if (currentButton && indicator) { + indicator.style.left = `${currentButton.offsetLeft}px`; + indicator.style.width = `${currentButton.offsetWidth}px`; + } + }, [currentMarket]); + + return ( +
+
+ + {markets.map((market, index) => ( + + ))} +
+ ); +} diff --git a/FE/src/components/TopFive/SkeletonCard.tsx b/FE/src/components/TopFive/SkeletonCard.tsx new file mode 100644 index 00000000..79b9066c --- /dev/null +++ b/FE/src/components/TopFive/SkeletonCard.tsx @@ -0,0 +1,12 @@ +export function SkeletonCard() { + return ( +
  • +
    +
    +
    +
    +
    +
    +
  • + ); +} diff --git a/FE/src/components/TopFive/TopFive.tsx b/FE/src/components/TopFive/TopFive.tsx new file mode 100644 index 00000000..90f54a63 --- /dev/null +++ b/FE/src/components/TopFive/TopFive.tsx @@ -0,0 +1,43 @@ +import List from './List'; +import Nav from './Nav'; +import { useSearchParams } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { MarketType } from './type.ts'; + +const paramsMap = { + 전체: 'ALL', + 코스피: 'KOSPI', + 코스닥: 'KOSDAQ', + 코스피200: 'KOSPI200', +}; + +export default function TopFive() { + const [searchParams] = useSearchParams(); + const currentMarket = (searchParams.get('top') || '전체') as MarketType; + + const { data, isLoading } = useQuery({ + queryKey: ['topfive', currentMarket], + queryFn: () => + fetch( + `http://223.130.151.42:3000/api/stocks/topfive?market=${paramsMap[currentMarket]}`, + ).then((res) => res.json()), + keepPreviousData: true, + }); + return ( +
    +
    + ); +} diff --git a/FE/src/components/TopFive/type.ts b/FE/src/components/TopFive/type.ts new file mode 100644 index 00000000..3f72aee4 --- /dev/null +++ b/FE/src/components/TopFive/type.ts @@ -0,0 +1,9 @@ +export type StockData = { + hts_kor_isnm: string; + stck_prpr: string; + prdy_vrss: string; + prdy_vrss_sign: string; + prdy_ctrt: string; +}; + +export type MarketType = '전체' | '코스피' | '코스닥' | '코스피200'; diff --git a/FE/src/main.tsx b/FE/src/main.tsx index df655eae..94fb016c 100644 --- a/FE/src/main.tsx +++ b/FE/src/main.tsx @@ -2,9 +2,14 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import './index.css'; import App from './App.tsx'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +const queryClient = new QueryClient(); createRoot(document.getElementById('root')!).render( - - + + + + , ); diff --git a/FE/src/page/Home.tsx b/FE/src/page/Home.tsx index 6078bdcb..4d5954c0 100644 --- a/FE/src/page/Home.tsx +++ b/FE/src/page/Home.tsx @@ -1,9 +1,17 @@ import Header from 'components/Header'; +import Login from 'components/Login'; +import TopFive from 'components/TopFive/TopFive'; +import StockIndex from 'components/StockIndex/index.tsx'; export default function Home() { return ( <>
    +
    + + +
    + ); } diff --git a/FE/src/service/auth.ts b/FE/src/service/auth.ts new file mode 100644 index 00000000..e4ecb640 --- /dev/null +++ b/FE/src/service/auth.ts @@ -0,0 +1,15 @@ +import { LoginFailResponse, LoginSuccessResponse } from 'types'; + +export async function login( + email: string, + password: string, +): Promise { + return fetch('http://223.130.151.42:3000/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email, + password, + }), + }).then((res) => res.json()); +} diff --git a/FE/src/store/authStore.ts b/FE/src/store/authStore.ts new file mode 100644 index 00000000..1a8e8453 --- /dev/null +++ b/FE/src/store/authStore.ts @@ -0,0 +1,21 @@ +import { create } from 'zustand'; + +type AuthStore = { + accessToken: string | null; + isLogin: boolean; + setAccessToken: (token: string) => void; + resetToken: () => void; +}; + +const useAuthStore = create((set) => ({ + accessToken: null, + isLogin: false, + setAccessToken: (token: string) => { + set({ accessToken: token, isLogin: token !== null }); + }, + resetToken: () => { + set({ accessToken: null, isLogin: false }); + }, +})); + +export default useAuthStore; diff --git a/FE/src/store/useLoginModalStore.ts b/FE/src/store/useLoginModalStore.ts new file mode 100644 index 00000000..ce0b5fcf --- /dev/null +++ b/FE/src/store/useLoginModalStore.ts @@ -0,0 +1,13 @@ +import { create } from 'zustand'; + +type ModalStore = { + isOpen: boolean; + toggleModal: () => void; +}; + +const useLoginModalStore = create((set) => ({ + isOpen: false, + toggleModal: () => set((state) => ({ isOpen: !state.isOpen })), +})); + +export default useLoginModalStore; diff --git a/FE/src/types.ts b/FE/src/types.ts new file mode 100644 index 00000000..06b1b10b --- /dev/null +++ b/FE/src/types.ts @@ -0,0 +1,9 @@ +export type LoginSuccessResponse = { + accessToken: string; +}; + +export type LoginFailResponse = { + error: string; + message: string[]; + statusCode: number; +}; diff --git a/FE/src/utils/chart.ts b/FE/src/utils/chart.ts new file mode 100644 index 00000000..15b515f9 --- /dev/null +++ b/FE/src/utils/chart.ts @@ -0,0 +1,50 @@ +const X_LENGTH = 79; // 9:00 ~ 15:30 까지 5분 단위의 총 개수 +const MIDDLE = 50; // 상한가, 하한가를 나누는 기준 + +export const drawChart = (ctx: CanvasRenderingContext2D, data: number[]) => { + const canvas = ctx.canvas; + const width = canvas.width; + const height = canvas.height; + + ctx.clearRect(0, 0, width, height); + + const padding = { + top: 10, + right: 10, + bottom: 10, + left: 10, + }; + + const chartWidth = width - padding.left - padding.right; + const chartHeight = height - padding.top - padding.bottom; + + const yMax = Math.max(...data.map((d) => d)) * 1.1; + const yMin = Math.min(...data.map((d) => d)) * 0.9; + + // 데이터 선 그리기 + if (data.length > 1) { + ctx.beginPath(); + data.forEach((point, i) => { + const x = padding.left + (chartWidth * i) / (X_LENGTH - 1); + const y = + padding.top + + chartHeight - + (chartHeight * (point - yMin)) / (yMax - yMin); + + if (i === 0) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + }); + + const currentValue = data[data.length - 1]; + if (currentValue >= MIDDLE) { + ctx.strokeStyle = '#FF3700'; + } else { + ctx.strokeStyle = '#2175F3'; + } + ctx.lineWidth = 2; + ctx.stroke(); + } +};