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 (
+
+ );
}
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
+
+
+
+ >
+ );
+}
+
+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}
+
+
+
+ {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();
+ }
+};