From b36828327170959e4683a93d8920e68833a72855 Mon Sep 17 00:00:00 2001 From: Han-wo Date: Sun, 1 Dec 2024 20:38:23 +0900 Subject: [PATCH 1/7] =?UTF-8?q?merge:=2029=EB=B8=8C=EB=9E=9C=EC=B9=98?= =?UTF-8?q?=EC=99=80=20=EB=A8=B8=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/icons/Logo.svg | 15 ++ public/icons/down-graph.svg | 3 + public/icons/high-graph.svg | 3 + src/app/globals.css | 17 ++ src/app/main/_components/search-stock.tsx | 18 +- src/app/main/_components/stock-card.tsx | 7 +- .../_components/candle-chart-container.tsx | 135 +++++++++++ .../search/[id]/_components/candle-chart.tsx | 226 ++++++++++++++++++ .../[id]/_components/loading-spiner.tsx | 7 + .../search/[id]/_components/price-tooltip.tsx | 36 +++ .../search/[id]/_components/stock-header.tsx | 72 ++++++ src/app/search/[id]/layout.tsx | 2 +- src/app/search/[id]/page.tsx | 82 +++++++ src/app/search/[id]/types/index.ts | 32 +++ src/app/search/layout.tsx | 6 +- src/app/search/page.tsx | 4 +- .../nav-bar/_components/nav-menu.tsx | 16 +- 17 files changed, 666 insertions(+), 15 deletions(-) create mode 100644 public/icons/Logo.svg create mode 100644 public/icons/down-graph.svg create mode 100644 public/icons/high-graph.svg create mode 100644 src/app/search/[id]/_components/candle-chart-container.tsx create mode 100644 src/app/search/[id]/_components/candle-chart.tsx create mode 100644 src/app/search/[id]/_components/loading-spiner.tsx create mode 100644 src/app/search/[id]/_components/price-tooltip.tsx create mode 100644 src/app/search/[id]/_components/stock-header.tsx create mode 100644 src/app/search/[id]/types/index.ts diff --git a/public/icons/Logo.svg b/public/icons/Logo.svg new file mode 100644 index 0000000..6a3a931 --- /dev/null +++ b/public/icons/Logo.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/icons/down-graph.svg b/public/icons/down-graph.svg new file mode 100644 index 0000000..77af86b --- /dev/null +++ b/public/icons/down-graph.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/high-graph.svg b/public/icons/high-graph.svg new file mode 100644 index 0000000..489bff2 --- /dev/null +++ b/public/icons/high-graph.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/app/globals.css b/src/app/globals.css index 5e6cb22..68489eb 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2,6 +2,23 @@ @tailwind components; @tailwind utilities; +.loadingSpinner { + width: 40px; + height: 40px; + border: 3px solid #f3f3f3; + border-top: 3px solid #3498db; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} @font-face { font-family: "Pretendard-Regular"; src: url("https://fastly.jsdelivr.net/gh/Project-Noonnu/noonfonts_2107@1.1/Pretendard-Regular.woff") diff --git a/src/app/main/_components/search-stock.tsx b/src/app/main/_components/search-stock.tsx index e7f7228..79c1b8f 100644 --- a/src/app/main/_components/search-stock.tsx +++ b/src/app/main/_components/search-stock.tsx @@ -1,12 +1,28 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { KeyboardEvent } from "react"; + import Input from "@/components/common/input"; export default function SearchStock() { + const router = useRouter(); + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter" && e.currentTarget.value.trim()) { + router.push( + `/search/${encodeURIComponent(e.currentTarget.value.trim())}`, + ); + } + }; + return (
diff --git a/src/app/main/_components/stock-card.tsx b/src/app/main/_components/stock-card.tsx index b3d5636..36cd051 100644 --- a/src/app/main/_components/stock-card.tsx +++ b/src/app/main/_components/stock-card.tsx @@ -5,6 +5,8 @@ import { memo } from "react"; import ArrowDownIcon from "@/icons/arrow-down.svg"; import ArrowUpIcon from "@/icons/arrow-up.svg"; +import DownGraphIcon from "@/icons/down-graph.svg"; +import HighGraphIcon from "@/icons/high-graph.svg"; import type { StockIndexResponse } from "../types"; @@ -22,7 +24,7 @@ function MarketIndexCard({ endpoint, data }: MarketIndexCardProps) { return (
+
+ {isNegative ? : } +
); } diff --git a/src/app/search/[id]/_components/candle-chart-container.tsx b/src/app/search/[id]/_components/candle-chart-container.tsx new file mode 100644 index 0000000..35895ba --- /dev/null +++ b/src/app/search/[id]/_components/candle-chart-container.tsx @@ -0,0 +1,135 @@ +"use client"; + +import clsx from "clsx"; +import { useCallback, useEffect, useState } from "react"; + +import { + ChartDTO, + ChartResponse, + PeriodType, + VolumeDTO, + VolumeResponse, +} from "../types"; +import CandlestickChart from "./candle-chart"; + +interface Props { + stockName: string; + initialChartData: ChartResponse; + initialVolumeData: VolumeResponse; +} + +export default function CandlestickChartContainer({ + stockName, + initialChartData, + initialVolumeData, +}: Props) { + const [period, setPeriod] = useState("day"); + const [showMA, setShowMA] = useState(false); + const [chartData, setChartData] = useState( + initialChartData.chartDTOS, + ); + const [volumeData, setVolumeData] = useState( + initialVolumeData.dtoList, + ); + const [isLoading, setIsLoading] = useState(false); + + const resetToInitialData = useCallback(() => { + setChartData(initialChartData.chartDTOS); + setVolumeData(initialVolumeData.dtoList); + }, [initialChartData.chartDTOS, initialVolumeData.dtoList]); + + useEffect(() => { + if (period === "day") { + resetToInitialData(); + return; + } + + const fetchData = async () => { + setIsLoading(true); + try { + const [chartResponse, volumeResponse] = await Promise.all([ + fetch( + `${process.env.NEXT_PUBLIC_API_URL}/search/chart/${period}?stockName=${stockName}`, + ), + fetch( + `${process.env.NEXT_PUBLIC_API_URL}/search/chart/tradingVolume/${period}?stockName=${stockName}`, + ), + ]); + + if (!chartResponse.ok || !volumeResponse.ok) { + throw new Error("Failed to fetch data"); + } + + const [newChartData, newVolumeData] = await Promise.all([ + chartResponse.json() as Promise, + volumeResponse.json() as Promise, + ]); + + setChartData(newChartData.chartDTOS); + setVolumeData(newVolumeData.dtoList); + } catch (error) { + console.error("Error fetching data:", error); //eslint-disable-line + } + setIsLoading(false); + }; + + fetchData(); + }, [period, stockName, resetToInitialData]); + + const getButtonClasses = (isActive: boolean) => + clsx( + "rounded-md px-4 py-2 text-16-700 transition-colors", + isActive + ? "bg-blue-500 text-white hover:bg-blue-600" + : "bg-gray-100 hover:bg-gray-200", + ); + + return ( +
+
+
+ 차트 +
+
+ + + + +
+
+ +
+ +
+
+ ); +} diff --git a/src/app/search/[id]/_components/candle-chart.tsx b/src/app/search/[id]/_components/candle-chart.tsx new file mode 100644 index 0000000..7c15636 --- /dev/null +++ b/src/app/search/[id]/_components/candle-chart.tsx @@ -0,0 +1,226 @@ +"use client"; + +import { + ColorType, + createChart, + HistogramData, + Time, +} from "lightweight-charts"; +import { useEffect, useRef, useState } from "react"; + +import { ChartDTO, VolumeDTO } from "../types"; +import LoadingSpinner from "./loading-spiner"; +import PriceTooltip from "./price-tooltip"; + +interface Props { + data: ChartDTO[]; + volumeData: VolumeDTO[]; + isLoading?: boolean; + showMA: boolean; +} + +interface CandleData { + open: number; + high: number; + low: number; + close: number; +} + +interface TooltipData { + open: number; + high: number; + low: number; + close: number; + volume: number; +} + +interface MAData { + time: Time; + value: number; +} + +function calculateMA( + data: { close: number; time: Time }[], + period: number, +): MAData[] { + const result: MAData[] = []; + for (let i = period - 1; i < data.length; i += 1) { + let sum = 0; + for (let j = 0; j < period; j += 1) { + sum += data[i - j].close; + } + result.push({ + time: data[i].time, + value: sum / period, + }); + } + return result; +} + +function CandlestickChart({ data, volumeData, isLoading, showMA }: Props) { + const chartContainerRef = useRef(null); + const [tooltipVisible, setTooltipVisible] = useState(false); + const [tooltipData, setTooltipData] = useState({ + open: 0, + high: 0, + low: 0, + close: 0, + volume: 0, + }); + + useEffect(() => { + if (!data?.length || !volumeData?.length || isLoading) return undefined; + + const chartContainer = chartContainerRef.current; + if (!chartContainer) return undefined; + + const chart = createChart(chartContainer, { + layout: { + background: { type: ColorType.Solid, color: "#ffffff" }, + textColor: "#333", + }, + width: 700, + height: 426, + grid: { + vertLines: { color: "#E6E6E6" }, + horzLines: { color: "#E6E6E6" }, + }, + rightPriceScale: { + scaleMargins: { + top: 0.2, + bottom: 0.3, + }, + }, + }); + + const candlestickSeries = chart.addCandlestickSeries({ + upColor: "#FF3B30", + downColor: "#007AFF", + borderVisible: false, + wickUpColor: "#FF3B30", + wickDownColor: "#007AFF", + priceScaleId: "right", + }); + + const volumeSeries = chart.addHistogramSeries({ + color: "#26a69a", + priceFormat: { type: "volume" }, + priceScaleId: "volume", + }); + + volumeSeries.priceScale().applyOptions({ + scaleMargins: { + top: 0.8, + bottom: 0, + }, + visible: false, + }); + + const transformedCandleData = data + .map((item) => ({ + time: item.date.replace(/(\d{4})(\d{2})(\d{2})/, "$1-$2-$3") as Time, + open: Number(item.endPrice) - Number(item.prevPrice), + high: Number(item.highPrice), + low: Number(item.lowPrice), + close: Number(item.endPrice), + })) + .sort( + (a, b) => + new Date(a.time as string).getTime() - + new Date(b.time as string).getTime(), + ); + + const transformedVolumeData = volumeData + .map((item) => ({ + time: item.date.replace(/(\d{4})(\d{2})(\d{2})/, "$1-$2-$3") as Time, + value: Number(item.cumulativeVolume), + color: item.changeDirection === "increase" ? "#F05650" : "#00CFFA", + })) + .sort( + (a, b) => + new Date(a.time as string).getTime() - + new Date(b.time as string).getTime(), + ); + + candlestickSeries.setData(transformedCandleData); + volumeSeries.setData(transformedVolumeData); + + if (showMA) { + const ma5Series = chart.addLineSeries({ + color: "#FF9800", + lineWidth: 2, + priceScaleId: "right", + }); + + const ma20Series = chart.addLineSeries({ + color: "#7B1FA2", + lineWidth: 2, + priceScaleId: "right", + }); + + const ma60Series = chart.addLineSeries({ + color: "#2FF221", + lineWidth: 2, + priceScaleId: "right", + }); + + const ma5Data = calculateMA(transformedCandleData, 5); + const ma20Data = calculateMA(transformedCandleData, 20); + const ma60Data = calculateMA(transformedCandleData, 60); + + ma5Series.setData(ma5Data); + ma20Series.setData(ma20Data); + ma60Series.setData(ma60Data); + } + + chart.subscribeCrosshairMove((param) => { + if (param.time) { + const crosshairCandleData = param.seriesData.get( + candlestickSeries, + ) as CandleData; + const crosshairVolumeData = param.seriesData.get( + volumeSeries, + ) as HistogramData; + + if (crosshairCandleData) { + setTooltipData({ + ...crosshairCandleData, + volume: crosshairVolumeData ? crosshairVolumeData.value : 0, + }); + setTooltipVisible(true); + } + } else { + setTooltipVisible(false); + } + }); + + chart.timeScale().setVisibleLogicalRange({ + from: Math.max(0, transformedCandleData.length - 30), + to: transformedCandleData.length, + }); + + return () => { + chart.remove(); + return undefined; + }; + }, [data, volumeData, isLoading, showMA]); + + if (isLoading || !data?.length || !volumeData?.length) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+ +
+
+ ); +} + +export default CandlestickChart; diff --git a/src/app/search/[id]/_components/loading-spiner.tsx b/src/app/search/[id]/_components/loading-spiner.tsx new file mode 100644 index 0000000..1f0e14b --- /dev/null +++ b/src/app/search/[id]/_components/loading-spiner.tsx @@ -0,0 +1,7 @@ +export default function LoadingSpinner() { + return ( +
+
+
+ ); +} diff --git a/src/app/search/[id]/_components/price-tooltip.tsx b/src/app/search/[id]/_components/price-tooltip.tsx new file mode 100644 index 0000000..2c35482 --- /dev/null +++ b/src/app/search/[id]/_components/price-tooltip.tsx @@ -0,0 +1,36 @@ +import React from "react"; + +interface PriceTooltipProps { + close: number; + open: number; + low: number; + high: number; + visible: boolean; +} + +function PriceTooltip({ close, open, low, high, visible }: PriceTooltipProps) { + if (!visible) return null; + + return ( +
+
+
종가:
+
{close.toLocaleString()}
+
+
+
시가:
+
{open.toLocaleString()}
+
+
+
최저가:
+
{low.toLocaleString()}
+
+
+
최고가:
+
{high.toLocaleString()}
+
+
+ ); +} + +export default PriceTooltip; diff --git a/src/app/search/[id]/_components/stock-header.tsx b/src/app/search/[id]/_components/stock-header.tsx new file mode 100644 index 0000000..95d41d0 --- /dev/null +++ b/src/app/search/[id]/_components/stock-header.tsx @@ -0,0 +1,72 @@ +"use client"; + +import clsx from "clsx"; +import { useRouter } from "next/navigation"; + +import BackIcon from "@/icons/arrow-left.svg"; + +import { StockInfo } from "../types"; + +interface StockHeaderProps { + stockName: string; + initialStockInfo: StockInfo; +} + +function StockHeader({ stockName, initialStockInfo }: StockHeaderProps) { + const router = useRouter(); + const isPositive = parseFloat(initialStockInfo.contrastRatio) > 0; + const isNegative = parseFloat(initialStockInfo.contrastRatio) < 0; + + const formatPrice = (price: string) => + parseInt(price, 10).toLocaleString("ko-KR"); + + return ( +
+
+
+ +
{decodeURIComponent(stockName)}
+
+
+
+ 현재가 {formatPrice(initialStockInfo.stockPrice)}원 +
+
+ {isPositive ? "+" : ""} + {formatPrice(initialStockInfo.previousStockPrice)}원 ( + {initialStockInfo.contrastRatio} + %) +
+
+
+
+
+
1일 최저
+
+ {formatPrice(initialStockInfo.lowStockPrice)}원 +
+
+
+
1일 최고
+
+ {formatPrice(initialStockInfo.highStockPrice)}원 +
+
+
+
+ ); +} + +export default StockHeader; diff --git a/src/app/search/[id]/layout.tsx b/src/app/search/[id]/layout.tsx index 092ccce..a3df806 100644 --- a/src/app/search/[id]/layout.tsx +++ b/src/app/search/[id]/layout.tsx @@ -11,7 +11,7 @@ export default function Layout({ children: React.ReactNode; }>) { return ( -
+
{children}
); diff --git a/src/app/search/[id]/page.tsx b/src/app/search/[id]/page.tsx index e69de29..324da9c 100644 --- a/src/app/search/[id]/page.tsx +++ b/src/app/search/[id]/page.tsx @@ -0,0 +1,82 @@ +import CandlestickChartContainer from "./_components/candle-chart-container"; +import StockHeader from "./_components/stock-header"; +import { ChartResponse, StockInfo, VolumeResponse } from "./types"; + +async function getInitialData(id: string) { + try { + const [chartResponse, volumeResponse, stockResponse] = await Promise.all([ + fetch( + `${process.env.NEXT_PUBLIC_API_URL}/search/chart/day?stockName=${id}`, + { + cache: "no-store", + headers: { "Content-Type": "application/json" }, + }, + ), + fetch( + `${process.env.NEXT_PUBLIC_API_URL}/search/chart/tradingVolume/day?stockName=${id}`, + { + cache: "no-store", + headers: { "Content-Type": "application/json" }, + }, + ), + fetch(`${process.env.NEXT_PUBLIC_API_URL}/search/stock?stockName=${id}`, { + cache: "no-store", + headers: { "Content-Type": "application/json" }, + }), + ]); + + if (!chartResponse.ok || !volumeResponse.ok || !stockResponse.ok) { + throw new Error(`HTTP error! status: ${chartResponse.status}`); + } + + const [chartData, volumeData, stockData] = await Promise.all([ + chartResponse.json() as Promise, + volumeResponse.json() as Promise, + stockResponse.json() as Promise, + ]); + + return { + chartData, + volumeData, + stockData, + }; + } catch (error) { + console.error("Error fetching data:", error); //eslint-disable-line + throw error; + } +} + +export default async function StockPage({ + params, +}: { + params: { id: string }; +}) { + try { + const initialData = await getInitialData(params.id); + return ( +
+ + +
+ ); + } catch (error) { + const errorMessage = + error instanceof Error + ? error.message + : "알 수 없는 오류가 발생했습니다."; + return ( +
+

데이터 로딩 실패

+

{errorMessage}

+

잠시 후 다시 시도해주세요.

+
+ ); + } +} diff --git a/src/app/search/[id]/types/index.ts b/src/app/search/[id]/types/index.ts new file mode 100644 index 0000000..da98d03 --- /dev/null +++ b/src/app/search/[id]/types/index.ts @@ -0,0 +1,32 @@ +export interface ChartDTO { + date: string; + endPrice: string; + highPrice: string; + lowPrice: string; + prevPrice: string; +} + +export interface ChartResponse { + chartDTOS: ChartDTO[]; +} + +export interface VolumeDTO { + date: string; + cumulativeVolume: string; + changeDirection: "increase" | "decrease"; +} + +export interface VolumeResponse { + dtoList: VolumeDTO[]; +} + +export type PeriodType = "day" | "week" | "month"; + +export interface StockInfo { + stockName: string; + stockPrice: string; + previousStockPrice: string; + contrastRatio: string; + highStockPrice: string; + lowStockPrice: string; +} diff --git a/src/app/search/layout.tsx b/src/app/search/layout.tsx index 1fde58a..71daa12 100644 --- a/src/app/search/layout.tsx +++ b/src/app/search/layout.tsx @@ -10,9 +10,5 @@ export default function Layout({ }: Readonly<{ children: React.ReactNode; }>) { - return ( -
- {children} -
- ); + return
{children}
; } diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index dcbc6bb..4f2a946 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -13,8 +13,8 @@ export default async function SearchPage() { const data = await getStockData(); return ( -
-

투자 상품 검색하기

+
+

TOP10 주식 조회하기

검색된 투자 상품: {data.length}개
diff --git a/src/components/nav-bar/_components/nav-menu.tsx b/src/components/nav-bar/_components/nav-menu.tsx index ddb2fab..43d378e 100644 --- a/src/components/nav-bar/_components/nav-menu.tsx +++ b/src/components/nav-bar/_components/nav-menu.tsx @@ -10,6 +10,7 @@ import ContentIcon from "@/public/icons/contents.svg"; import ContentActiveIcon from "@/public/icons/contents-active.svg"; import HomeIcon from "@/public/icons/home.svg"; import HomeActiveIcon from "@/public/icons/home-active.svg"; +import LogoIcon from "@/public/icons/logo.svg"; import AccountIcon from "@/public/icons/mypage.svg"; import AccountActiveIcon from "@/public/icons/mypage-active.svg"; @@ -98,10 +99,15 @@ NavItem.displayName = "NavItem"; export default function NavMenu() { const pathname = usePathname(); + const isActiveRoute = (href: string) => { + if (href === "/") return pathname === href; + return pathname.startsWith(href); + }; + return ( - -
로고
-
    + + +
    {NAV_ITEMS.map((item) => ( ))} -
+
); } From fcea20eba8f200837a59f257aef0988a9d2bf23a Mon Sep 17 00:00:00 2001 From: Han-wo Date: Sun, 1 Dec 2024 23:04:56 +0900 Subject: [PATCH 2/7] =?UTF-8?q?refactor:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=EC=83=81=ED=83=9C=EC=97=90=EC=84=9C=20=EC=83=88=EB=A1=9C?= =?UTF-8?q?=EA=B3=A0=EC=B9=A8=ED=96=88=EC=9D=84=EB=95=8C=20=EB=B9=84?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EB=B3=B4=EC=9D=B4=EB=8A=94=20=EB=B6=80=EB=B6=84=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 스켈레톤 UI 이용 --- src/app/main/_components/asset-info.tsx | 9 +++- src/app/main/_components/skeleton.tsx | 56 +++++++++++++++++++++++++ src/app/main/_components/stock-info.tsx | 8 +++- src/hooks/use-auth.tsx | 23 ++++++---- 4 files changed, 85 insertions(+), 11 deletions(-) create mode 100644 src/app/main/_components/skeleton.tsx diff --git a/src/app/main/_components/asset-info.tsx b/src/app/main/_components/asset-info.tsx index 9af5bb5..8874e56 100644 --- a/src/app/main/_components/asset-info.tsx +++ b/src/app/main/_components/asset-info.tsx @@ -8,6 +8,8 @@ import { useAuth } from "@/hooks/use-auth"; import coinsIcon from "@/images/coin.png"; import shieldIcon from "@/images/shield.png"; +import { AssetInfoSkeleton } from "./skeleton"; + interface AssetResponse { asset: string; } @@ -33,7 +35,8 @@ const formatKoreanCurrency = (amount: number) => { const INITIAL_ASSET = 100_000_000; export default function AssetInfo() { - const { isAuthenticated, memberNickName, token, clearAuth } = useAuth(); + const { isAuthenticated, memberNickName, token, clearAuth, isInitialized } = + useAuth(); const [assetInfo, setAssetInfo] = useState(null); useEffect(() => { @@ -129,6 +132,10 @@ export default function AssetInfo() { ); } + if (!isInitialized) { + return ; + } + return (
diff --git a/src/app/main/_components/skeleton.tsx b/src/app/main/_components/skeleton.tsx new file mode 100644 index 0000000..48219b5 --- /dev/null +++ b/src/app/main/_components/skeleton.tsx @@ -0,0 +1,56 @@ +export function AssetInfoSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +} + +export function MyStockInfoSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+
+ {[1, 2, 3].map((idx) => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+
+
+ ); +} diff --git a/src/app/main/_components/stock-info.tsx b/src/app/main/_components/stock-info.tsx index 97eb872..72989ff 100644 --- a/src/app/main/_components/stock-info.tsx +++ b/src/app/main/_components/stock-info.tsx @@ -8,6 +8,8 @@ import { TableBody } from "@/components/common/table"; import { useAuth } from "@/hooks/use-auth"; import magnifierIcon from "@/images/stockInfo.png"; +import { MyStockInfoSkeleton } from "./skeleton"; + interface StockHolding { stockName: string; currentPrice: number; @@ -87,7 +89,7 @@ function StockTable({ data }: { data: StockHolding[] }) { } export default function MyStockInfo() { - const { isAuthenticated, token } = useAuth(); + const { isAuthenticated, token, isInitialized } = useAuth(); const [stockCount, setStockCount] = useState(null); const [stockHoldings, setStockHoldings] = useState([]); @@ -154,6 +156,10 @@ export default function MyStockInfo() { ); } + if (!isInitialized) { + return ; + } + return (
diff --git a/src/hooks/use-auth.tsx b/src/hooks/use-auth.tsx index 677cf23..93d1fd1 100644 --- a/src/hooks/use-auth.tsx +++ b/src/hooks/use-auth.tsx @@ -17,6 +17,7 @@ interface AuthStore { memberName: string | null; memberNickName: string | null; isAuthenticated: boolean; + isInitialized: boolean; // 추가 setAuth: (response: LoginResponse) => Promise; clearAuth: () => Promise; initAuth: () => Promise; @@ -30,12 +31,12 @@ interface AuthStore { * import { useAuth } from '@/hooks/useAuth'; * * function LoginComponent() { - * const { setToken } = useAuth(); + * const { setAuth } = useAuth(); * * const handleLogin = async () => { * const response = await fetch('/api/login'); - * const { token } = await response.json(); - * await setToken(token); // 로그인 후 토큰 저장 + * const data = await response.json(); + * await setAuth(data); // 로그인 후 인증 정보 저장 * }; * } * @@ -54,22 +55,22 @@ interface AuthStore { * } * * @example - * // 3. 컴포넌트 마운트시 토큰 초기화 + * // 3. 컴포넌트 마운트시 인증 상태 초기화 * function App() { - * const { initToken } = useAuth(); + * const { initAuth } = useAuth(); * * useEffect(() => { - * initToken(); // 페이지 로드시 쿠키에서 토큰 복원 + * initAuth(); // 페이지 로드시 쿠키에서 인증 정보 복원 * }, []); * } * * @example - * // 4. 로그아웃시 토큰 제거 + * // 4. 로그아웃시 인증 정보 제거 * function LogoutButton() { - * const { clearToken } = useAuth(); + * const { clearAuth } = useAuth(); * * const handleLogout = async () => { - * await clearToken(); // 로그아웃시 토큰 삭제 + * await clearAuth(); // 로그아웃시 인증 정보 삭제 * }; * } */ @@ -79,6 +80,7 @@ export const useAuth = create((set) => ({ memberName: null, memberNickName: null, isAuthenticated: false, + isInitialized: false, setAuth: async (response: LoginResponse) => { const { token, memberName, memberNickName } = response; @@ -91,6 +93,7 @@ export const useAuth = create((set) => ({ memberName, memberNickName, isAuthenticated: true, + isInitialized: true, }); }, @@ -104,6 +107,7 @@ export const useAuth = create((set) => ({ memberName: null, memberNickName: null, isAuthenticated: false, + isInitialized: true, }); }, @@ -117,6 +121,7 @@ export const useAuth = create((set) => ({ memberName, memberNickName, isAuthenticated: !!token, + isInitialized: true, }); }, })); From bf85f2d08db62c5841b86f134f8c38e9f136f767 Mon Sep 17 00:00:00 2001 From: Han-wo Date: Sun, 1 Dec 2024 23:37:49 +0900 Subject: [PATCH 3/7] =?UTF-8?q?fix:=20=EC=8A=A4=EC=BC=88=EB=A0=88=ED=86=A4?= =?UTF-8?q?=20=EC=88=9C=EC=84=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/main/_components/asset-info.tsx | 8 +++--- src/app/main/_components/stock-info.tsx | 8 +++--- src/hooks/use-auth.tsx | 37 +++++++++++++++---------- 3 files changed, 31 insertions(+), 22 deletions(-) diff --git a/src/app/main/_components/asset-info.tsx b/src/app/main/_components/asset-info.tsx index 8874e56..1aa1a5c 100644 --- a/src/app/main/_components/asset-info.tsx +++ b/src/app/main/_components/asset-info.tsx @@ -98,6 +98,10 @@ export default function AssetInfo() { await clearAuth(); }; + if (!isInitialized) { + return ; + } + if (!isAuthenticated) { return (
@@ -132,10 +136,6 @@ export default function AssetInfo() { ); } - if (!isInitialized) { - return ; - } - return (
diff --git a/src/app/main/_components/stock-info.tsx b/src/app/main/_components/stock-info.tsx index 72989ff..b022888 100644 --- a/src/app/main/_components/stock-info.tsx +++ b/src/app/main/_components/stock-info.tsx @@ -132,6 +132,10 @@ export default function MyStockInfo() { } }, [isAuthenticated, token]); + if (!isInitialized) { + return ; + } + if (!isAuthenticated || stockCount === "0") { return (
@@ -156,10 +160,6 @@ export default function MyStockInfo() { ); } - if (!isInitialized) { - return ; - } - return (
diff --git a/src/hooks/use-auth.tsx b/src/hooks/use-auth.tsx index 93d1fd1..5265cb0 100644 --- a/src/hooks/use-auth.tsx +++ b/src/hooks/use-auth.tsx @@ -17,7 +17,7 @@ interface AuthStore { memberName: string | null; memberNickName: string | null; isAuthenticated: boolean; - isInitialized: boolean; // 추가 + isInitialized: boolean; setAuth: (response: LoginResponse) => Promise; clearAuth: () => Promise; initAuth: () => Promise; @@ -80,7 +80,7 @@ export const useAuth = create((set) => ({ memberName: null, memberNickName: null, isAuthenticated: false, - isInitialized: false, + isInitialized: false, // 초기값은 초기화 중 setAuth: async (response: LoginResponse) => { const { token, memberName, memberNickName } = response; @@ -93,7 +93,7 @@ export const useAuth = create((set) => ({ memberName, memberNickName, isAuthenticated: true, - isInitialized: true, + isInitialized: true, // 로그인 시 초기화 완료 }); }, @@ -107,21 +107,30 @@ export const useAuth = create((set) => ({ memberName: null, memberNickName: null, isAuthenticated: false, - isInitialized: true, + isInitialized: true, // 로그아웃 시 초기화 완료 }); }, initAuth: async () => { - const token = await getCookie("token"); - const memberName = await getCookie("memberName"); - const memberNickName = await getCookie("memberNickName"); + try { + // 초기화 시작할 때는 false로 설정 + set({ isInitialized: false }); - set({ - token, - memberName, - memberNickName, - isAuthenticated: !!token, - isInitialized: true, - }); + const token = await getCookie("token"); + const memberName = await getCookie("memberName"); + const memberNickName = await getCookie("memberNickName"); + + set({ + token, + memberName, + memberNickName, + isAuthenticated: !!token, + isInitialized: true, // 초기화 완료 + }); + } catch (error) { + // 에러가 나도 초기화는 완료 처리 + set({ isInitialized: true }); + throw error; + } }, })); From 2dfff0ced926a83afe0420d74453c321a6619915 Mon Sep 17 00:00:00 2001 From: Han-wo Date: Mon, 2 Dec 2024 19:30:15 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20=EA=B3=B5=EC=9A=A9=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=EB=B0=8F=20=ED=86=A0=ED=81=B0=EA=B0=B1=EC=8B=A0=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/common/auth/refresh-modal.tsx | 87 +++++++++++++++++ src/components/common/modal/index.tsx | 99 ++++++++++++++++++++ src/store/modal-store.ts | 42 +++++++++ 3 files changed, 228 insertions(+) create mode 100644 src/components/common/auth/refresh-modal.tsx create mode 100644 src/components/common/modal/index.tsx create mode 100644 src/store/modal-store.ts diff --git a/src/components/common/auth/refresh-modal.tsx b/src/components/common/auth/refresh-modal.tsx new file mode 100644 index 0000000..b817b98 --- /dev/null +++ b/src/components/common/auth/refresh-modal.tsx @@ -0,0 +1,87 @@ +"use client"; + +import clsx from "clsx"; + +import BaseModal from "@/components/common/modal"; + +interface RefreshModalProps { + isOpen: boolean; + onClose: () => void; + remainingRefreshes: number; + onAccept: () => void; + onDecline: () => void; +} + +export default function RefreshModal({ + isOpen, + onClose, + remainingRefreshes, + onAccept, + onDecline, +}: RefreshModalProps) { + const handleRefresh = () => { + onAccept(); + onClose(); + }; + + const handleLogout = () => { + onDecline(); + onClose(); + }; + + return ( + +
+
+

세션 연장

+
+ +
+

+ 로그인 세션이 5분 후에 만료됩니다. +
+ 세션을 연장하시겠습니까? +

+
+ +
+

+ 남은 연장 횟수:{" "} + {remainingRefreshes}회 +

+
+ +
+ + +
+
+
+ ); +} diff --git a/src/components/common/modal/index.tsx b/src/components/common/modal/index.tsx new file mode 100644 index 0000000..04a3a87 --- /dev/null +++ b/src/components/common/modal/index.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { AnimatePresence, motion } from "framer-motion"; +import { useEffect } from "react"; + +interface BaseModalProps { + isOpen: boolean; + onClose: () => void; + children: React.ReactNode; +} + +const overlayVariants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { duration: 0.2 }, + }, +}; + +const modalVariants = { + hidden: { + opacity: 0, + scale: 0.75, + y: 20, + }, + visible: { + opacity: 1, + scale: 1, + y: 0, + transition: { + type: "spring", + stiffness: 300, + damping: 30, + }, + }, + exit: { + opacity: 0, + scale: 0.75, + y: 20, + transition: { duration: 0.2 }, + }, +}; + +export default function BaseModal({ + isOpen, + onClose, + children, +}: BaseModalProps) { + useEffect(() => { + if (!isOpen) { + return undefined; + } + + function handleEscape(e: KeyboardEvent) { + if (e.key === "Escape") { + onClose(); + } + } + + document.body.style.overflow = "hidden"; + window.addEventListener("keydown", handleEscape); + + return function cleanupModalEffect() { + window.removeEventListener("keydown", handleEscape); + document.body.style.overflow = "unset"; + }; + }, [isOpen, onClose]); + + return ( + + {isOpen && ( + + + + {children} + + + )} + + ); +} diff --git a/src/store/modal-store.ts b/src/store/modal-store.ts new file mode 100644 index 0000000..864584e --- /dev/null +++ b/src/store/modal-store.ts @@ -0,0 +1,42 @@ +import { create } from "zustand"; + +// 모달 props의 기본 타입 정의 +interface BaseModalProps { + onClose?: () => void; +} + +// 각 모달 컴포넌트의 props 타입 정의 +export interface RefreshModalProps extends BaseModalProps { + remainingRefreshes: number; + onAccept: () => void; + onDecline: () => void; +} + +// 지원되는 모달 타입들을 유니온 타입으로 정의 +export type ModalType = "refresh" | "alert" | "confirm"; + +// 각 모달 타입별 props 타입 매핑 +type ModalPropsMap = { + refresh: RefreshModalProps; + alert: BaseModalProps & { message: string }; + confirm: BaseModalProps & { message: string; onConfirm: () => void }; +}; + +interface ModalState { + isOpen: boolean; + modalType: ModalType | null; + modalProps: ModalPropsMap[ModalType] | null; + openModal: (type: T, props: ModalPropsMap[T]) => void; + closeModal: () => void; +} + +const useModalStore = create((set) => ({ + isOpen: false, + modalType: null, + modalProps: null, + openModal: (type, props) => + set({ isOpen: true, modalType: type, modalProps: props }), + closeModal: () => set({ isOpen: false, modalType: null, modalProps: null }), +})); + +export default useModalStore; From f0f9bca2478d8de99a1187ae785e1cf45fd5969d Mon Sep 17 00:00:00 2001 From: Han-wo Date: Mon, 2 Dec 2024 19:35:16 +0900 Subject: [PATCH 5/7] =?UTF-8?q?feat:=20=ED=86=A0=ED=81=B0=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0=20=EA=B4=80=EB=A0=A8=20=ED=9B=85=20=EC=A0=9C=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/layout.tsx | 2 + src/components/common/auth/refresh-modal.tsx | 9 +- src/hooks/use-token-refresh.ts | 106 +++++++++++++++++++ src/store/token-refresh.ts | 16 +++ src/utils/auth-handler.tsx | 19 ++++ src/utils/next-cookies.ts | 2 +- 6 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 src/hooks/use-token-refresh.ts create mode 100644 src/store/token-refresh.ts create mode 100644 src/utils/auth-handler.tsx diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 8338230..02fc529 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,6 +5,7 @@ import type { Metadata } from "next"; import NavBar from "@/components/nav-bar"; import AuthInitializer from "@/provider/AuthInitializer"; +import AuthRefreshHandler from "@/utils/auth-handler"; import Providers from "./provider"; @@ -26,6 +27,7 @@ export default async function RootLayout({ +
{children}
diff --git a/src/components/common/auth/refresh-modal.tsx b/src/components/common/auth/refresh-modal.tsx index b817b98..6152b31 100644 --- a/src/components/common/auth/refresh-modal.tsx +++ b/src/components/common/auth/refresh-modal.tsx @@ -1,6 +1,7 @@ "use client"; import clsx from "clsx"; +import { useEffect } from "react"; import BaseModal from "@/components/common/modal"; @@ -19,14 +20,20 @@ export default function RefreshModal({ onAccept, onDecline, }: RefreshModalProps) { + useEffect(() => { + if (!isOpen) { + onClose(); + } + }, [isOpen, onClose]); + const handleRefresh = () => { onAccept(); onClose(); }; const handleLogout = () => { - onDecline(); onClose(); + onDecline(); }; return ( diff --git a/src/hooks/use-token-refresh.ts b/src/hooks/use-token-refresh.ts new file mode 100644 index 0000000..1c641a0 --- /dev/null +++ b/src/hooks/use-token-refresh.ts @@ -0,0 +1,106 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { useAuth } from "@/hooks/use-auth"; +import { setCookie } from "@/utils/next-cookies"; + +const MAX_REFRESH_COUNT = 4; +const SESSION_DURATION = 30 * 60 * 1000; // 30분 +const MODAL_SHOW_BEFORE = 5 * 60 * 1000; // 5분 + +interface UseTokenRefreshReturn { + isModalOpen: boolean; + remainingRefreshes: number; + onClose: () => void; + onAccept: () => void; + onDecline: () => void; +} + +export default function useTokenRefresh(): UseTokenRefreshReturn { + const { token, memberName, memberNickName, clearAuth } = useAuth(); + const router = useRouter(); + const refreshCountRef = useRef(0); + const [isModalOpen, setIsModalOpen] = useState(false); + const [isWaitingForResponse, setIsWaitingForResponse] = useState(false); + const sessionTimerRef = useRef(); + const responseTimerRef = useRef(); + + const handleLogout = useCallback(async () => { + if (responseTimerRef.current) clearTimeout(responseTimerRef.current); + if (sessionTimerRef.current) clearTimeout(sessionTimerRef.current); + setIsModalOpen(false); + await clearAuth(); + router.push("/login"); + router.refresh(); + }, [clearAuth, router]); + + const startResponseTimer = useCallback(() => { + if (responseTimerRef.current) clearTimeout(responseTimerRef.current); + + setIsWaitingForResponse(true); + responseTimerRef.current = setTimeout(() => { + if (isModalOpen) { + handleLogout(); + } + }, MODAL_SHOW_BEFORE); + }, [handleLogout, isModalOpen]); + + const startSessionTimer = useCallback(() => { + if (sessionTimerRef.current) clearTimeout(sessionTimerRef.current); + + sessionTimerRef.current = setTimeout(() => { + if (refreshCountRef.current < MAX_REFRESH_COUNT) { + setIsModalOpen(true); + startResponseTimer(); + } else { + handleLogout(); + } + }, SESSION_DURATION - MODAL_SHOW_BEFORE); + }, [handleLogout, startResponseTimer]); + + const handleRefreshAccept = useCallback(async () => { + if (refreshCountRef.current >= MAX_REFRESH_COUNT) { + handleLogout(); + return; + } + + if (token) { + await setCookie("token", token); + if (memberName) await setCookie("memberName", memberName); + if (memberNickName) await setCookie("memberNickName", memberNickName); + } + + setIsWaitingForResponse(false); + if (responseTimerRef.current) clearTimeout(responseTimerRef.current); + + refreshCountRef.current += 1; + setIsModalOpen(false); + startSessionTimer(); + }, [handleLogout, startSessionTimer, token, memberName, memberNickName]); + + const handleRefreshDecline = useCallback(() => { + handleLogout(); + }, [handleLogout]); + + useEffect(() => { + if (token && !isWaitingForResponse) { + refreshCountRef.current = 0; + startSessionTimer(); + } + + return () => { + if (sessionTimerRef.current) clearTimeout(sessionTimerRef.current); + if (responseTimerRef.current) clearTimeout(responseTimerRef.current); + }; + }, [token, isWaitingForResponse, startSessionTimer]); + + return { + isModalOpen, + remainingRefreshes: MAX_REFRESH_COUNT - refreshCountRef.current, + onClose: () => setIsModalOpen(false), + onAccept: handleRefreshAccept, + onDecline: handleRefreshDecline, + }; +} diff --git a/src/store/token-refresh.ts b/src/store/token-refresh.ts new file mode 100644 index 0000000..a3cb525 --- /dev/null +++ b/src/store/token-refresh.ts @@ -0,0 +1,16 @@ +import { create } from "zustand"; + +interface RefreshState { + refreshCount: number; + incrementCount: () => void; + resetCount: () => void; +} + +const useRefreshStore = create((set) => ({ + refreshCount: 0, + incrementCount: () => + set((state) => ({ refreshCount: state.refreshCount + 1 })), + resetCount: () => set({ refreshCount: 0 }), +})); + +export default useRefreshStore; diff --git a/src/utils/auth-handler.tsx b/src/utils/auth-handler.tsx new file mode 100644 index 0000000..853874f --- /dev/null +++ b/src/utils/auth-handler.tsx @@ -0,0 +1,19 @@ +"use client"; + +import RefreshModal from "@/components/common/auth/refresh-modal"; +import useTokenRefresh from "@/hooks/use-token-refresh"; + +export default function AuthRefreshHandler() { + const { isModalOpen, remainingRefreshes, onClose, onAccept, onDecline } = + useTokenRefresh(); + + return ( + + ); +} diff --git a/src/utils/next-cookies.ts b/src/utils/next-cookies.ts index 81e8b41..cafdbb6 100644 --- a/src/utils/next-cookies.ts +++ b/src/utils/next-cookies.ts @@ -102,7 +102,7 @@ export async function setCookie( ): Promise { const defaultOptions: CookieOptions = { path: "/", - maxAge: 24 * 60 * 60, // 1일 + maxAge: 30 * 60, //30분 httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", From 00322395bf3ece067e905d8f1978742bb107e93b Mon Sep 17 00:00:00 2001 From: Han-wo Date: Mon, 2 Dec 2024 22:46:02 +0900 Subject: [PATCH 6/7] =?UTF-8?q?fix:=20=EC=BD=94=EC=8A=A4=ED=94=BC=20?= =?UTF-8?q?=EC=BA=90=EB=9F=AC=EC=85=80=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EB=B6=88=EC=9D=BC=EC=B9=98=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/kospi-kosdac/index.ts | 9 +++++++-- src/app/main/_components/stock-carousel.tsx | 1 + src/app/page.tsx | 10 ++++++++-- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/api/kospi-kosdac/index.ts b/src/api/kospi-kosdac/index.ts index 73cbf11..cd2f078 100644 --- a/src/api/kospi-kosdac/index.ts +++ b/src/api/kospi-kosdac/index.ts @@ -12,8 +12,12 @@ interface StockData { async function getInitialStockData(): Promise { try { const [kospiRes, kosdaqRes] = await Promise.all([ - fetch(`${process.env.NEXT_PUBLIC_API_URL}/home/index/kospi`), - fetch(`${process.env.NEXT_PUBLIC_API_URL}/home/index/kosdaq`), + fetch(`${process.env.NEXT_PUBLIC_API_URL}/home/index/kospi`, { + cache: "no-store", + }), + fetch(`${process.env.NEXT_PUBLIC_API_URL}/home/index/kosdaq`, { + cache: "no-store", + }), ]); if (!kospiRes.ok || !kosdaqRes.ok) { @@ -37,4 +41,5 @@ async function getInitialStockData(): Promise { }; } } + export default getInitialStockData; diff --git a/src/app/main/_components/stock-carousel.tsx b/src/app/main/_components/stock-carousel.tsx index 7ad182a..04bb05b 100644 --- a/src/app/main/_components/stock-carousel.tsx +++ b/src/app/main/_components/stock-carousel.tsx @@ -22,6 +22,7 @@ function StockIndexCarousel({ initialData }: StockIndexCarouselProps) { initialData, refetchInterval: 300000, refetchOnWindowFocus: false, + retry: 1, }); return ( diff --git a/src/app/page.tsx b/src/app/page.tsx index 6253f49..b44a5d6 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -12,7 +12,14 @@ import StockIndexCarousel from "@/app/main/_components/stock-carousel"; import MyStockInfo from "@/app/main/_components/stock-info"; export default async function Home() { - const queryClient = new QueryClient(); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 0, + retry: 1, + }, + }, + }); const initialStockData = await getInitialStockData(); await queryClient.prefetchQuery({ @@ -52,7 +59,6 @@ export default async function Home() {
- {/* 우측 사이드바 */}
From aa5bbd2bc7c546b174d20ad4e62fd4dd2f582dd9 Mon Sep 17 00:00:00 2001 From: Han-wo Date: Tue, 3 Dec 2024 14:33:46 +0900 Subject: [PATCH 7/7] =?UTF-8?q?refactor:=20=EB=A1=9C=EA=B3=A0=20=EB=B9=8C?= =?UTF-8?q?=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/nav-bar/_components/nav-menu.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/nav-bar/_components/nav-menu.tsx b/src/components/nav-bar/_components/nav-menu.tsx index 43d378e..8c71755 100644 --- a/src/components/nav-bar/_components/nav-menu.tsx +++ b/src/components/nav-bar/_components/nav-menu.tsx @@ -10,7 +10,7 @@ import ContentIcon from "@/public/icons/contents.svg"; import ContentActiveIcon from "@/public/icons/contents-active.svg"; import HomeIcon from "@/public/icons/home.svg"; import HomeActiveIcon from "@/public/icons/home-active.svg"; -import LogoIcon from "@/public/icons/logo.svg"; +import LogoIcon from "@/public/icons/Logo.svg"; import AccountIcon from "@/public/icons/mypage.svg"; import AccountActiveIcon from "@/public/icons/mypage-active.svg";