Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions src/_MainPage/apis/getIndex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { format, subDays } from "date-fns";
import { API_ENDPOINTS } from "@/constants/api";
import { instance } from "@/utils/instance";
import type { ApiResponse } from "@/lib/apis/types";
import type { MarketIndex } from "@/_MainPage/types/MarketIndexType";

/**
* 지수 데이터 조회 API
* @param marketType - 시장 타입 ("KOSPI" | "KOSDAQ")
* @returns Promise<MarketIndex> - 지수 데이터
*/
export const getIndexData = async (marketType: "KOSPI" | "KOSDAQ"): Promise<MarketIndex> => {
const today = new Date();
const endDate = format(today, "yyyy-MM-dd");
const startDate = format(subDays(today, 14), "yyyy-MM-dd");

const response = await instance.get<ApiResponse<MarketIndex>>(
API_ENDPOINTS.indexPeriod(marketType, startDate, endDate)
);

// ApiResponse의 result 필드 반환
return response.data.result;
};
141 changes: 104 additions & 37 deletions src/_MainPage/components/MarketIndexCard.tsx
Original file line number Diff line number Diff line change
@@ -1,79 +1,146 @@
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { YAxis, AreaChart, Area, ResponsiveContainer } from "recharts";
import { Spinner } from "@/components/ui/spinner";
import type { MarketType, MarketIndex } from "@/_MainPage/types/MarketIndexType";
import type { AxiosError } from "axios";
import type { ApiErrorResponse } from "@/lib/apis/types";

type MarketIndexCardProps = {
marketType: MarketType;
marketIndex: MarketIndex;
marketIndex: MarketIndex | null;
isLoading: boolean;
error: unknown;
};

const getChangeColor = (direction: MarketIndex["direction"]) => {
// 변동성 색상을 결정하는 함수
switch (direction) {
case "up":
return "text-red-500";
case "down":
return "text-blue-500";
default:
return "text-gray-500";
}
const getChangeColor = (changeRate: number) => {
if (changeRate < 0) {
return "text-blue-500";
} else return "text-red-500";
};
const getChartColor = (direction: MarketIndex["direction"]) => {
// 차트 색상을 결정하는 함수
switch (direction) {
case "up":
return "#EF4444";
case "down":
return "#3B82F6";
default:
return "#9CA3AF";

const getChartColor = (changeRate: number) => {
if (changeRate < 0) {
return "#3B82F6";
} else {
return "#EF4444";
}
};

const MarketIndexCard = ({ marketType, marketIndex }: MarketIndexCardProps) => {
const isUp = marketIndex.direction === "up";
const MarketIndexCard = ({ marketType, marketIndex, isLoading, error }: MarketIndexCardProps) => {
// 로딩 상태
if (isLoading) {
return (
<Card className="bg-[#0A194E] border-gray-700 text-white">
<CardHeader>
<CardTitle className="font-medium text-gray-300 text-lg">{marketType}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex justify-center items-center h-48">
<Spinner className="size-12" />
</div>
</CardContent>
</Card>
);
}

// 에러 상태
if (error) {
const axiosError = error as AxiosError<ApiErrorResponse>;
const errorMessage =
axiosError.response?.data?.detail || "데이터를 불러오는 중 오류가 발생했습니다.";

return (
<Card className="bg-[#0A194E] border-gray-700 text-white">
<CardHeader>
<CardTitle className="font-medium text-gray-300 text-lg">{marketType}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex justify-center items-center h-48">
<p className="text-red-500 text-center">{errorMessage}</p>
</div>
</CardContent>
</Card>
);
}

// 데이터가 없는 경우
if (!marketIndex) {
return (
<Card className="bg-[#0A194E] border-gray-700 text-white">
<CardHeader>
<CardTitle className="font-medium text-gray-300 text-lg">{marketType}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex justify-center items-center h-48">
<p className="text-gray-400">데이터가 없습니다.</p>
</div>
</CardContent>
</Card>
);
}

// 가장 최근 데이터 (배열의 마지막 항목)
const latestData = marketIndex.data[marketIndex.data.length - 1];

if (!latestData) {
return (
<Card className="bg-[#0A194E] border-gray-700 text-white">
<CardHeader>
<CardTitle className="font-medium text-gray-300 text-lg">{marketType}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex justify-center items-center h-48">
<p className="text-gray-400">데이터가 없습니다.</p>
</div>
</CardContent>
</Card>
);
}

const isUp = latestData.changeRate > 0;
const arrow = isUp ? "▲" : "▼";
const sign = isUp ? "+" : "-";
const sign = isUp ? "+" : "";

// 차트 데이터를 recharts 형식으로 변환
const chartData = marketIndex.data.map((item) => ({
date: item.baseDate,
value: item.closePrice,
}));

return (
<Card className="bg-[#0A194E] border-gray-700 text-white">
{/* 마켓 종류(KOSPI/KOSDAQ) */}
<CardHeader>
<CardTitle className="font-medium text-gray-300 text-lg">{marketType}</CardTitle>
</CardHeader>

<CardContent>
<p className="font-bold text-4xl">{marketIndex.value}</p>
{/* 상승여부에 따라 다른 색상으로 변동성 렌더링 */}
<p className={`mt-2 text-lg font-semibold ${getChangeColor(marketIndex.direction)}`}>
{arrow} {marketIndex.change.toFixed(2)} ({sign}
{marketIndex.changePercent.toFixed(2)}%)
<p className="font-bold text-4xl">{latestData.closePrice.toLocaleString()}</p>
<p className={`mt-2 text-lg font-semibold ${getChangeColor(latestData.changeRate)}`}>
{arrow} {Math.abs(latestData.changeAmount).toFixed(2)} ({sign}
{latestData.changeRate.toFixed(2)}%)
</p>
<div className="mt-4 h-24">
{/* 차트 영역 */}
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={marketIndex.chartData}>
{/* 그래프의 변동성을 더 크게 하기 위함 */}
<AreaChart data={chartData}>
<YAxis domain={["dataMin - 10", "dataMax + 10"]} hide />
<defs>
{/* 차트 아래 그래디언트 색상 정의 */}
<linearGradient id={`color-${marketType}`} x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor={getChartColor(marketIndex.direction)}
stopColor={getChartColor(latestData.changeRate)}
stopOpacity={0.4}
/>
<stop
offset="95%"
stopColor={getChartColor(marketIndex.direction)}
stopColor={getChartColor(latestData.changeRate)}
stopOpacity={0}
/>
</linearGradient>
</defs>
{/* 차트 선 영역 */}
<Area
type="monotone"
dataKey="value"
stroke={getChartColor(marketIndex.direction)}
stroke={getChartColor(latestData.changeRate)}
strokeWidth={2}
fillOpacity={1}
fill={`url(#color-${marketType})`}
Expand Down
60 changes: 55 additions & 5 deletions src/_MainPage/components/MarketIndexSection.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,64 @@
/* eslint-disable no-console */
import { getIndexData } from "@/_MainPage/apis/getIndex";
import MarketIndexCard from "@/_MainPage/components/MarketIndexCard";
import { marketData } from "@/_MainPage/mocks/marketData";
import { useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import type { ApiErrorResponse } from "@/lib/apis/types";
import { __DEV__ } from "@/utils/instance";

export default function MarketIndexSection() {
// KOSPI 데이터 조회 - 독립적으로 실행
const {
data: kospiData,
isLoading: isKospiLoading,
error: kospiError,
} = useQuery({
queryKey: ["indexData", "KOSPI"],
queryFn: () => getIndexData("KOSPI"),
});

// KOSDAQ 데이터 조회 - 독립적으로 실행
const {
data: kosdaqData,
isLoading: isKosdaqLoading,
error: kosdaqError,
} = useQuery({
queryKey: ["indexData", "KOSDAQ"],
queryFn: () => getIndexData("KOSDAQ"),
});

// 에러 처리 (개발 환경에서만 로깅)
if (__DEV__ && kospiError) {
const error = kospiError as AxiosError<ApiErrorResponse>;
if (error.response?.data) {
console.error("KOSPI 데이터 조회 에러:", error.response.data.detail);
}
}

if (__DEV__ && kosdaqError) {
const error = kosdaqError as AxiosError<ApiErrorResponse>;
if (error.response?.data) {
console.error("KOSDAQ 데이터 조회 에러:", error.response.data.detail);
}
}

return (
<div className="mx-auto px-6 lg:px-8 py-16 max-w-7xl">
<div className="gap-8 grid grid-cols-1 md:grid-cols-2">
{/* KOSPI 카드 */}
<MarketIndexCard marketType="KOSPI" marketIndex={marketData.KOSPI} />
{/* KOSDAQ 카드 */}
<MarketIndexCard marketType="KOSDAQ" marketIndex={marketData.KOSDAQ} />
{/* KOSPI 카드 - 독립적인 로딩/에러 상태 */}
<MarketIndexCard
marketType="KOSPI"
marketIndex={kospiData ?? null}
isLoading={isKospiLoading}
error={kospiError}
/>
{/* KOSDAQ 카드 - 독립적인 로딩/에러 상태 */}
<MarketIndexCard
marketType="KOSDAQ"
marketIndex={kosdaqData ?? null}
isLoading={isKosdaqLoading}
error={kosdaqError}
/>
</div>
</div>
);
Expand Down
30 changes: 0 additions & 30 deletions src/_MainPage/mocks/marketData.ts

This file was deleted.

21 changes: 13 additions & 8 deletions src/_MainPage/types/MarketIndexType.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
export type MarketType = "KOSPI" | "KOSDAQ";

export type ChartDataPoint = { date: string; value: number };

export type MarketIndex = {
value: number;
change: number;
changePercent: number;
direction: "up" | "down";
chartData: ChartDataPoint[];
marketType: MarketType;
startDate: string;
endDate: string;
data: {
marketType: MarketType;
baseDate: string;
openPrice: number;
closePrice: number;
highPrice: number;
lowPrice: number;
changeAmount: number;
changeRate: number;
}[];
};
export type MarketIndexData = Record<MarketType, MarketIndex>;
16 changes: 16 additions & 0 deletions src/components/ui/spinner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Loader2Icon } from "lucide-react";

import { cn } from "@/lib/utils";

function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2Icon
role="status"
aria-label="Loading"
className={cn("size-4 animate-spin", className)}
{...props}
/>
);
}

export { Spinner };