diff --git a/package.json b/package.json
index b4923e9..2bdac41 100644
--- a/package.json
+++ b/package.json
@@ -14,6 +14,7 @@
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
+ "@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
@@ -50,6 +51,7 @@
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.7.1",
"supabase": ">1.8.1",
+ "tailwind-scrollbar-hide": "^4.0.0",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e3c3d32..1a0c85c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -20,6 +20,9 @@ importers:
'@radix-ui/react-label':
specifier: ^2.1.8
version: 2.1.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-progress':
+ specifier: ^1.1.8
+ version: 1.1.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-select':
specifier: ^2.2.6
version: 2.2.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@@ -123,6 +126,9 @@ importers:
supabase:
specifier: '>1.8.1'
version: 2.58.5
+ tailwind-scrollbar-hide:
+ specifier: ^4.0.0
+ version: 4.0.0(tailwindcss@4.1.17)
tailwindcss:
specifier: ^4
version: 4.1.17
@@ -738,6 +744,19 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-progress@1.1.8':
+ resolution: {integrity: sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-select@2.2.6':
resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==}
peerDependencies:
@@ -2738,6 +2757,11 @@ packages:
tailwind-merge@3.3.1:
resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==}
+ tailwind-scrollbar-hide@4.0.0:
+ resolution: {integrity: sha512-gobtvVcThB2Dxhy0EeYSS1RKQJ5baDFkamkhwBvzvevwX6L4XQfpZ3me9s25Ss1ecFVT5jPYJ50n+7xTBJG9WQ==}
+ peerDependencies:
+ tailwindcss: '>=3.0.0 || >= 4.0.0 || >= 4.0.0-beta.8 || >= 4.0.0-alpha.20'
+
tailwindcss@4.1.17:
resolution: {integrity: sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==}
@@ -3493,6 +3517,16 @@ snapshots:
'@types/react': 19.2.2
'@types/react-dom': 19.2.2(@types/react@19.2.2)
+ '@radix-ui/react-progress@1.1.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ dependencies:
+ '@radix-ui/react-context': 1.1.3(@types/react@19.2.2)(react@19.2.0)
+ '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
+ optionalDependencies:
+ '@types/react': 19.2.2
+ '@types/react-dom': 19.2.2(@types/react@19.2.2)
+
'@radix-ui/react-select@2.2.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@radix-ui/number': 1.1.1
@@ -5602,6 +5636,10 @@ snapshots:
tailwind-merge@3.3.1: {}
+ tailwind-scrollbar-hide@4.0.0(tailwindcss@4.1.17):
+ dependencies:
+ tailwindcss: 4.1.17
+
tailwindcss@4.1.17: {}
tapable@2.3.0: {}
diff --git a/src/app/api/news/route.ts b/src/app/api/news/route.ts
new file mode 100644
index 0000000..aec77b9
--- /dev/null
+++ b/src/app/api/news/route.ts
@@ -0,0 +1,45 @@
+import { NextRequest, NextResponse } from "next/server";
+
+import { newsPaginationHandler } from "@/entities/news/handler/news-pagination.handler";
+
+export async function GET(request: NextRequest) {
+ try {
+ // URL에서 page 파라미터 추출
+ const searchParams = request.nextUrl.searchParams;
+ const pageParam = searchParams.get("page");
+ const page = pageParam ? parseInt(pageParam, 10) : 0;
+
+ // 페이지 번호 유효성 검사
+ if (isNaN(page) || page < 0) {
+ return NextResponse.json(
+ {
+ status: "error",
+ data: null,
+ error: "Invalid page parameter",
+ },
+ { status: 400 },
+ );
+ }
+
+ // 핸들러 호출
+ const data = await newsPaginationHandler(page);
+
+ return NextResponse.json(
+ {
+ status: "success",
+ data,
+ },
+ { status: 200 },
+ );
+ } catch (error) {
+ console.error("News API error:", error);
+ return NextResponse.json(
+ {
+ status: "error",
+ data: null,
+ error: error instanceof Error ? error.message : "Internal server error",
+ },
+ { status: 500 },
+ );
+ }
+}
diff --git a/src/app/globals.css b/src/app/globals.css
index 3adadee..f5a476d 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -1,4 +1,5 @@
@import "tailwindcss";
+@import "tailwind-scrollbar-hide/v4";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@@ -132,7 +133,7 @@
@layer base {
* {
- @apply border-border outline-ring/50;
+ @apply scrollbar-hide border-border outline-ring/50;
}
body {
@apply bg-background text-text-dark;
diff --git a/src/app/news/page.tsx b/src/app/news/page.tsx
index 6b7f388..ed29bb8 100644
--- a/src/app/news/page.tsx
+++ b/src/app/news/page.tsx
@@ -1,3 +1,36 @@
+"use client";
+
+import { useGetNewsList } from "@/entities";
+import { NewsListSection, SummaryNewsSection } from "@/features";
+
export default function NewsPage() {
- return
News Page
;
+ const { data: newsResponse, isLoading } = useGetNewsList(0);
+
+ const newsData = newsResponse?.data;
+ const { newsDate, totalNews, investmentIndex, summary, keywords, newsAnalysis } = newsData || {
+ newsDate: "",
+ totalNews: 0,
+ investmentIndex: 0,
+ summary: { positive: 0, negative: 0, neutral: 0 },
+ keywords: [],
+ newsAnalysis: [],
+ };
+
+ return (
+
+
뉴스 목록
+
AI가 분석한 코인 관련 뉴스입니다.
+
+
+
+
+
+ );
}
diff --git a/src/entities/index.ts b/src/entities/index.ts
index f0a06a4..f19112a 100644
--- a/src/entities/index.ts
+++ b/src/entities/index.ts
@@ -1,3 +1,4 @@
export * from "./auth";
export * from "./market";
export * from "./user";
+export * from "./news";
diff --git a/src/entities/news/handler/index.ts b/src/entities/news/handler/index.ts
new file mode 100644
index 0000000..e69de29
diff --git a/src/entities/news/handler/news-pagination.handler.ts b/src/entities/news/handler/news-pagination.handler.ts
new file mode 100644
index 0000000..d56391e
--- /dev/null
+++ b/src/entities/news/handler/news-pagination.handler.ts
@@ -0,0 +1,112 @@
+import { unstable_cache } from "next/cache";
+
+import {
+ AnalysisAPIResponseSchema,
+ NewsAPIResponseSchema,
+ NewsAnalysisData,
+ NewsAnalysisItem,
+ PaginatedNewsResponse,
+ analysisAPI,
+ newsAPI,
+} from "../model";
+
+const ITEMS_PER_PAGE = 5;
+const REVALIDATE_TIME = 300; // 5분 (초 단위)
+
+const getMergedNewsData = unstable_cache(
+ async () => {
+ // 두 API를 병렬로 호출
+ const [newsResponse, analysisResponse] = await Promise.all([newsAPI(), analysisAPI()]);
+
+ // Zod를 사용한 API 응답 검증
+ const validatedNewsResponse = NewsAPIResponseSchema.parse(newsResponse);
+ const validatedAnalysisResponse = AnalysisAPIResponseSchema.parse(analysisResponse);
+
+ if (!validatedNewsResponse.success || !validatedAnalysisResponse.success) {
+ throw new Error("Failed to fetch news data");
+ }
+
+ const newsData = validatedNewsResponse.data;
+ const analysisData = validatedAnalysisResponse.data;
+
+ // newsId를 기준으로 analysis 데이터를 Map으로 변환 (빠른 조회를 위해)
+ const analysisMap = new Map(analysisData.newsAnalysis.map((item) => [item.newsId, item]));
+
+ // newsData와 analysisData.newsAnalysis를 newsId 기준으로 매칭
+ const combinedNewsAnalysis: NewsAnalysisItem[] = newsData.map((news) => {
+ const analysis: NewsAnalysisData = analysisMap.get(news.id) || {
+ newsId: news.id,
+ reason: "",
+ keywords: [],
+ sentiment: "neutral" as const,
+ confidence: 0,
+ };
+
+ return {
+ newsId: news.id,
+ title: news.title,
+ content: news.content,
+ url: news.url,
+ source: news.source,
+ reason: analysis.reason,
+ keywords: analysis.keywords,
+ sentiment: analysis.sentiment,
+ confidence: analysis.confidence,
+ };
+ });
+
+ // 전체 데이터 반환 (페이지네이션 되지 않음)
+ return {
+ meta: {
+ newsDate: analysisData.date,
+ totalNews: analysisData.totalNews,
+ investmentIndex: analysisData.investmentIndex,
+ summary: analysisData.summary,
+ keywords: analysisData.keywords,
+ },
+ items: combinedNewsAnalysis,
+ };
+ },
+ ["combined-news-data"],
+ { revalidate: REVALIDATE_TIME, tags: ["news"] },
+);
+
+export const newsPaginationHandler = async (page: number = 0): Promise => {
+ try {
+ // 캐시된 전체 데이터를 가져옴 (API 호출 X, 메모리/파일시스템에서 로드 O)
+ const cachedData = await getMergedNewsData();
+
+ const { items, meta } = cachedData;
+
+ // 페이지네이션 계산
+ const totalItems = items.length;
+ const totalPages = Math.ceil(totalItems / ITEMS_PER_PAGE);
+ const startIndex = page * ITEMS_PER_PAGE;
+ const endIndex = startIndex + ITEMS_PER_PAGE;
+
+ // 현재 페이지의 데이터만 추출
+ const paginatedNewsAnalysis = items.slice(startIndex, endIndex);
+
+ return {
+ newsDate: meta.newsDate,
+ totalNews: meta.totalNews,
+ investmentIndex: meta.investmentIndex,
+ summary: meta.summary,
+ keywords: meta.keywords,
+ newsAnalysis: paginatedNewsAnalysis,
+ pagination: {
+ currentPage: page,
+ totalPages,
+ totalItems,
+ hasNext: page < totalPages - 1,
+ hasPrev: page > 0,
+ },
+ };
+ } catch (error) {
+ if (error instanceof Error) {
+ console.error("News pagination handler error:", error.message);
+ throw new Error(`Failed to process news data: ${error.message}`);
+ }
+ throw new Error("Failed to process news data: Unknown error");
+ }
+};
diff --git a/src/entities/news/index.ts b/src/entities/news/index.ts
new file mode 100644
index 0000000..b6837c3
--- /dev/null
+++ b/src/entities/news/index.ts
@@ -0,0 +1 @@
+export * from "./model";
diff --git a/src/entities/news/model/apis/index.ts b/src/entities/news/model/apis/index.ts
index 388887b..a6e5904 100644
--- a/src/entities/news/model/apis/index.ts
+++ b/src/entities/news/model/apis/index.ts
@@ -1,2 +1,3 @@
-export * from "./news.api";
export * from "./analysis.api";
+export * from "./news-list.api";
+export * from "./news.api";
diff --git a/src/entities/news/model/apis/news-list.api.ts b/src/entities/news/model/apis/news-list.api.ts
new file mode 100644
index 0000000..ba8e527
--- /dev/null
+++ b/src/entities/news/model/apis/news-list.api.ts
@@ -0,0 +1,19 @@
+import { APIResponse } from "@/shared";
+
+import { PaginatedNewsResponse } from "../types";
+
+export type NewsListParams = {
+ page?: number;
+};
+
+export type NewsListResponse = APIResponse;
+
+export const newsListAPI = async ({ page = 0 }: NewsListParams): Promise => {
+ const response = await fetch(`/api/news?page=${page}`);
+
+ if (!response.ok) {
+ throw new Error("Failed to fetch news list");
+ }
+
+ return response.json();
+};
diff --git a/src/entities/news/model/hooks/index.ts b/src/entities/news/model/hooks/index.ts
new file mode 100644
index 0000000..60c68b4
--- /dev/null
+++ b/src/entities/news/model/hooks/index.ts
@@ -0,0 +1 @@
+export * from "./useGetNewsList";
diff --git a/src/entities/news/model/hooks/useGetNewsList.ts b/src/entities/news/model/hooks/useGetNewsList.ts
new file mode 100644
index 0000000..0babc40
--- /dev/null
+++ b/src/entities/news/model/hooks/useGetNewsList.ts
@@ -0,0 +1,12 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+
+import { newsListAPI } from "../apis";
+
+export const useGetNewsList = (page: number) => {
+ return useQuery({
+ queryKey: ["news-list", page],
+ queryFn: () => newsListAPI({ page }),
+ });
+};
diff --git a/src/entities/news/model/index.ts b/src/entities/news/model/index.ts
index 1063c8d..f8e3786 100644
--- a/src/entities/news/model/index.ts
+++ b/src/entities/news/model/index.ts
@@ -1,2 +1,4 @@
export * from "./apis";
export * from "./types";
+export * from "./hooks";
+export * from "./schema";
diff --git a/src/entities/news/model/schema/index.ts b/src/entities/news/model/schema/index.ts
new file mode 100644
index 0000000..39f5df6
--- /dev/null
+++ b/src/entities/news/model/schema/index.ts
@@ -0,0 +1 @@
+export * from "./news.schema";
diff --git a/src/entities/news/model/schema/news.schema.ts b/src/entities/news/model/schema/news.schema.ts
new file mode 100644
index 0000000..2e7a186
--- /dev/null
+++ b/src/entities/news/model/schema/news.schema.ts
@@ -0,0 +1,58 @@
+import { z } from "zod";
+
+// Sentiment enum
+export const SentimentSchema = z.enum(["positive", "negative", "neutral"]);
+
+// Summary schema
+export const SummarySchema = z.object({
+ positive: z.number(),
+ negative: z.number(),
+ neutral: z.number(),
+});
+
+// NewsItem schema
+export const NewsItemSchema = z.object({
+ id: z.number(),
+ title: z.string(),
+ content: z.string(),
+ url: z.string(),
+ publishedAt: z.string(),
+ source: z.string(),
+ scrapedAt: z.string(),
+});
+
+// NewsAnalysisData schema
+export const NewsAnalysisDataSchema = z.object({
+ newsId: z.number(),
+ reason: z.string(),
+ keywords: z.array(z.string()),
+ sentiment: SentimentSchema,
+ confidence: z.number(),
+});
+
+// AnalysisItem schema
+export const AnalysisItemSchema = z.object({
+ date: z.string(),
+ totalNews: z.number(),
+ investmentIndex: z.number(),
+ summary: SummarySchema,
+ keywords: z.array(z.string()),
+ newsAnalysis: z.array(NewsAnalysisDataSchema),
+});
+
+// API Response schemas
+export const NewsAPIResponseSchema = z.object({
+ success: z.boolean(),
+ data: z.array(NewsItemSchema),
+});
+
+export const AnalysisAPIResponseSchema = z.object({
+ success: z.boolean(),
+ data: AnalysisItemSchema,
+});
+
+// Type inference from schemas
+export type NewsItemSchemaType = z.infer;
+export type AnalysisItemSchemaType = z.infer;
+export type NewsAPIResponseSchemaType = z.infer;
+export type AnalysisAPIResponseSchemaType = z.infer;
diff --git a/src/entities/news/model/types/news.type.ts b/src/entities/news/model/types/news.type.ts
index 93e0ced..9933c4b 100644
--- a/src/entities/news/model/types/news.type.ts
+++ b/src/entities/news/model/types/news.type.ts
@@ -28,17 +28,35 @@ export type NewsItem = {
scrapedAt: string;
};
+export type NewsAnalysisData = {
+ newsId: number;
+ reason: string;
+ keywords: string[];
+ sentiment: Sentiment;
+ confidence: number;
+};
+
export type AnalysisItem = {
date: string;
totalNews: number;
investmentIndex: number;
summary: Summary;
keywords: string[];
- newsAnalysis: {
- newsId: number;
- reason: string;
- keywords: string[];
- sentiment: Sentiment;
- confidence: number;
- }[];
+ newsAnalysis: NewsAnalysisData[];
};
+
+export interface PaginatedNewsResponse {
+ newsDate: string;
+ totalNews: number;
+ investmentIndex: number;
+ summary: Summary;
+ keywords: string[];
+ newsAnalysis: NewsAnalysisItem[];
+ pagination: {
+ currentPage: number;
+ totalPages: number;
+ totalItems: number;
+ hasNext: boolean;
+ hasPrev: boolean;
+ };
+}
diff --git a/src/features/index.ts b/src/features/index.ts
index c33a01f..7626f0e 100644
--- a/src/features/index.ts
+++ b/src/features/index.ts
@@ -1,5 +1,6 @@
export * from "./home";
export * from "./login";
+export * from "./news";
export * from "./setting";
export * from "./signup";
export * from "./wallet";
diff --git a/src/features/news/components/common/index.ts b/src/features/news/components/common/index.ts
new file mode 100644
index 0000000..caf1a9f
--- /dev/null
+++ b/src/features/news/components/common/index.ts
@@ -0,0 +1 @@
+export * from "./news-card";
diff --git a/src/features/news/components/common/news-card/ContentBox.tsx b/src/features/news/components/common/news-card/ContentBox.tsx
new file mode 100644
index 0000000..b1e5d83
--- /dev/null
+++ b/src/features/news/components/common/news-card/ContentBox.tsx
@@ -0,0 +1,27 @@
+import { Skeleton } from "@/shared";
+
+import { cleanContent } from "../../../utils";
+
+type Props = {
+ content: string;
+ isLoading?: boolean;
+};
+
+export const ContentBox = ({ content, isLoading }: Props) => {
+ if (isLoading) {
+ return (
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ {cleanContent(content)}
+
+ );
+};
diff --git a/src/features/news/components/common/news-card/MetaDataBox.tsx b/src/features/news/components/common/news-card/MetaDataBox.tsx
new file mode 100644
index 0000000..c1b89de
--- /dev/null
+++ b/src/features/news/components/common/news-card/MetaDataBox.tsx
@@ -0,0 +1,35 @@
+import { Skeleton } from "@/shared";
+
+type Props = {
+ keywords: string[];
+ source: string;
+ isLoading?: boolean;
+};
+
+export const MetaDataBox = ({ keywords, source, isLoading }: Props) => {
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ {keywords.map((k, index) => (
+
+ # {k}
+
+ ))}
+
+
{source}
+
+ );
+};
diff --git a/src/features/news/components/common/news-card/ReasonBox.tsx b/src/features/news/components/common/news-card/ReasonBox.tsx
new file mode 100644
index 0000000..24f3a36
--- /dev/null
+++ b/src/features/news/components/common/news-card/ReasonBox.tsx
@@ -0,0 +1,29 @@
+import { Skeleton, cn } from "@/shared";
+
+type Props = {
+ reason: string;
+ config: {
+ border: string;
+ bg: string;
+ };
+ isLoading?: boolean;
+};
+
+export const ReasonBox = ({ reason, config, isLoading }: Props) => {
+ if (isLoading) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
AI Insight
+
{reason}
+
+ );
+};
diff --git a/src/features/news/components/common/news-card/TitleBox.tsx b/src/features/news/components/common/news-card/TitleBox.tsx
new file mode 100644
index 0000000..8847b01
--- /dev/null
+++ b/src/features/news/components/common/news-card/TitleBox.tsx
@@ -0,0 +1,63 @@
+import { Badge, Button, Skeleton } from "@/shared";
+import { ExternalLink } from "lucide-react";
+
+type Props = {
+ title: string;
+ url: string;
+ confidence: number;
+ config: {
+ label: string;
+ icon: React.ReactNode;
+ color: string;
+ bg: string;
+ border: string;
+ };
+ isLoading?: boolean;
+};
+
+export const TitleBox = ({ title, url, confidence, config, isLoading }: Props) => {
+ const handleOpenLink = () => {
+ window.open(url, "_blank", "noopener,noreferrer");
+ };
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+ {config.icon}
+ {config.label}
+
+ AI 신뢰도 {confidence}%
+
+
+ {title}
+
+
+
+
+ );
+};
diff --git a/src/features/news/components/common/news-card/index.ts b/src/features/news/components/common/news-card/index.ts
new file mode 100644
index 0000000..735dbf3
--- /dev/null
+++ b/src/features/news/components/common/news-card/index.ts
@@ -0,0 +1,4 @@
+export * from "./ContentBox";
+export * from "./MetaDataBox";
+export * from "./ReasonBox";
+export * from "./TitleBox";
diff --git a/src/features/news/components/features/index.ts b/src/features/news/components/features/index.ts
new file mode 100644
index 0000000..097599a
--- /dev/null
+++ b/src/features/news/components/features/index.ts
@@ -0,0 +1,2 @@
+export * from "./news-list";
+export * from "./summary-news";
diff --git a/src/features/news/components/features/news-list/NewsCard.tsx b/src/features/news/components/features/news-list/NewsCard.tsx
new file mode 100644
index 0000000..37e6dcf
--- /dev/null
+++ b/src/features/news/components/features/news-list/NewsCard.tsx
@@ -0,0 +1,26 @@
+import { NewsAnalysisItem } from "@/entities";
+
+import { getSentimentConfig } from "../../../utils";
+import { ContentBox, MetaDataBox, ReasonBox, TitleBox } from "../../common";
+
+type NewsCardProps = {
+ news: NewsAnalysisItem;
+ isLoading?: boolean;
+};
+
+export const NewsCard = ({ news, isLoading }: NewsCardProps) => {
+ const { reason, content, title, keywords, source } = news;
+
+ const config = getSentimentConfig(news.sentiment);
+
+ return (
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/features/news/components/features/news-list/index.ts b/src/features/news/components/features/news-list/index.ts
new file mode 100644
index 0000000..1e94492
--- /dev/null
+++ b/src/features/news/components/features/news-list/index.ts
@@ -0,0 +1 @@
+export * from "./NewsCard";
diff --git a/src/features/news/components/features/summary-news/DistributionBox.tsx b/src/features/news/components/features/summary-news/DistributionBox.tsx
new file mode 100644
index 0000000..b04f197
--- /dev/null
+++ b/src/features/news/components/features/summary-news/DistributionBox.tsx
@@ -0,0 +1,61 @@
+import { Summary } from "@/entities";
+import { Skeleton, cn } from "@/shared";
+
+import { getWidthStyle } from "../../../utils";
+
+type Props = {
+ summary: Summary;
+ totalNews: number;
+ isLoading: boolean;
+};
+
+export const DistributionBox = ({ summary, totalNews, isLoading }: Props) => {
+ if (isLoading) {
+ return (
+ <>
+
+
+
+
+
+
+ >
+ );
+ }
+ const segments = [
+ {
+ key: "positive",
+ value: summary.positive,
+ color: "bg-positive",
+ },
+ {
+ key: "neutral",
+ value: summary.neutral,
+ color: "bg-text-dark/50",
+ },
+ {
+ key: "negative",
+ value: summary.negative,
+ color: "bg-negative",
+ },
+ ];
+
+ return (
+ <>
+
+ {segments.map((segment) => (
+
+ ))}
+
+
+ 긍정 {summary.positive}건
+ 중립 {summary.neutral}건
+ 부정 {summary.negative}건
+
+ >
+ );
+};
diff --git a/src/features/news/components/features/summary-news/KeywordBox.tsx b/src/features/news/components/features/summary-news/KeywordBox.tsx
new file mode 100644
index 0000000..c6607e7
--- /dev/null
+++ b/src/features/news/components/features/summary-news/KeywordBox.tsx
@@ -0,0 +1,27 @@
+import { Badge, Skeleton } from "@/shared";
+
+type Props = {
+ keywords: string[];
+ isLoading: boolean;
+};
+
+export const KeywordBox = ({ keywords, isLoading }: Props) => {
+ return (
+
+
Today's Keywords
+
+ {isLoading
+ ? Array.from({ length: 5 }).map((_, index) => )
+ : keywords.map((k, index) => (
+
+ # {k}
+
+ ))}
+
+
+ );
+};
diff --git a/src/features/news/components/features/summary-news/NewsListHeader.tsx b/src/features/news/components/features/summary-news/NewsListHeader.tsx
new file mode 100644
index 0000000..a968254
--- /dev/null
+++ b/src/features/news/components/features/summary-news/NewsListHeader.tsx
@@ -0,0 +1,38 @@
+import { Skeleton } from "@/shared";
+
+type Props = {
+ newsDate: string;
+ totalNews: number;
+ investmentIndex: number;
+ isLoading: boolean;
+};
+
+export const NewsListHeader = ({ newsDate, totalNews, investmentIndex, isLoading }: Props) => {
+ return (
+
+ );
+};
diff --git a/src/features/news/components/features/summary-news/index.ts b/src/features/news/components/features/summary-news/index.ts
new file mode 100644
index 0000000..37ec3a9
--- /dev/null
+++ b/src/features/news/components/features/summary-news/index.ts
@@ -0,0 +1,3 @@
+export * from "./DistributionBox";
+export * from "./KeywordBox";
+export * from "./NewsListHeader";
diff --git a/src/features/news/components/index.ts b/src/features/news/components/index.ts
new file mode 100644
index 0000000..0e84926
--- /dev/null
+++ b/src/features/news/components/index.ts
@@ -0,0 +1 @@
+export * from "./features";
diff --git a/src/features/news/index.ts b/src/features/news/index.ts
new file mode 100644
index 0000000..4aedf59
--- /dev/null
+++ b/src/features/news/index.ts
@@ -0,0 +1 @@
+export * from "./ui";
diff --git a/src/features/news/ui/NewsListSection.tsx b/src/features/news/ui/NewsListSection.tsx
new file mode 100644
index 0000000..3277eb1
--- /dev/null
+++ b/src/features/news/ui/NewsListSection.tsx
@@ -0,0 +1,42 @@
+import { NewsAnalysisItem } from "@/entities";
+
+import { NewsCard } from "../components";
+
+type Props = {
+ newsAnalysis: NewsAnalysisItem[];
+ isLoading?: boolean;
+};
+
+export const NewsListSection = ({ newsAnalysis, isLoading }: Props) => {
+ if (isLoading) {
+ return (
+
+ {Array.from({ length: 5 }).map((_, index) => (
+
+ ))}
+
+ );
+ }
+
+ return (
+
+ {newsAnalysis.map((news) => (
+
+ ))}
+
+ );
+};
diff --git a/src/features/news/ui/SummaryNewsSection.tsx b/src/features/news/ui/SummaryNewsSection.tsx
new file mode 100644
index 0000000..7eb498b
--- /dev/null
+++ b/src/features/news/ui/SummaryNewsSection.tsx
@@ -0,0 +1,31 @@
+import { Summary } from "@/entities";
+import { Separator } from "@/shared";
+
+import { DistributionBox, KeywordBox, NewsListHeader } from "../components";
+
+type Props = {
+ newsDate: string;
+ totalNews: number;
+ investmentIndex: number;
+ summary: Summary;
+ keywords: string[];
+ isLoading: boolean;
+};
+
+export const SummaryNewsSection = ({ newsDate, totalNews, investmentIndex, summary, keywords, isLoading }: Props) => {
+ return (
+
+ );
+};
diff --git a/src/features/news/ui/index.ts b/src/features/news/ui/index.ts
new file mode 100644
index 0000000..33d63a3
--- /dev/null
+++ b/src/features/news/ui/index.ts
@@ -0,0 +1,2 @@
+export * from "./NewsListSection";
+export * from "./SummaryNewsSection";
diff --git a/src/features/news/utils/clean-content.ts b/src/features/news/utils/clean-content.ts
new file mode 100644
index 0000000..0418ae6
--- /dev/null
+++ b/src/features/news/utils/clean-content.ts
@@ -0,0 +1,21 @@
+export const cleanContent = (content: string) => {
+ // 정규식 설명:
+ // \[ : 대괄호 시작
+ // 블록미디어 : 해당 문자열 찾기
+ // \s+ : 공백이 하나 이상 있음
+ // .*? : 기자 이름 (어떤 문자든 올 수 있음, 최소 매칭)
+ // 기자 : 해당 문자열 찾기
+ // \] : 대괄호 끝
+ const regex = /\[블록미디어\s+.*?\s*기자\]/;
+
+ const match = content.match(regex);
+
+ if (match) {
+ const matchIndex = content.indexOf(match[0]);
+ const contentAfterTag = content.slice(matchIndex + match[0].length);
+
+ return contentAfterTag.trim(); // 앞뒤 불필요한 공백 제거
+ }
+
+ return content;
+};
diff --git a/src/features/news/utils/index.ts b/src/features/news/utils/index.ts
new file mode 100644
index 0000000..5ec6697
--- /dev/null
+++ b/src/features/news/utils/index.ts
@@ -0,0 +1,3 @@
+export * from "./clean-content";
+export * from "./progress-width";
+export * from "./sentiment-config";
diff --git a/src/features/news/utils/progress-width.ts b/src/features/news/utils/progress-width.ts
new file mode 100644
index 0000000..86f7273
--- /dev/null
+++ b/src/features/news/utils/progress-width.ts
@@ -0,0 +1,5 @@
+export const getWidthStyle = (count: number, total: number) => {
+ const percentage = total > 0 ? (count / total) * 100 : 0;
+
+ return { width: `${percentage}%` };
+};
diff --git a/src/features/news/utils/sentiment-config.tsx b/src/features/news/utils/sentiment-config.tsx
new file mode 100644
index 0000000..59bca7d
--- /dev/null
+++ b/src/features/news/utils/sentiment-config.tsx
@@ -0,0 +1,30 @@
+import { Minus, TrendingDown, TrendingUp } from "lucide-react";
+
+export const getSentimentConfig = (sentiment: string) => {
+ switch (sentiment) {
+ case "positive":
+ return {
+ color: "text-positive",
+ bg: "bg-positive/10",
+ border: "border-positive/20",
+ label: "호재",
+ icon: ,
+ };
+ case "negative":
+ return {
+ color: "text-negative",
+ bg: "bg-negative/10",
+ border: "border-negative/20",
+ label: "악재",
+ icon: ,
+ };
+ default:
+ return {
+ color: "text-text-muted-dark",
+ bg: "bg-text-muted-dark/10",
+ border: "border-text-muted-dark/20",
+ label: "중립",
+ icon: ,
+ };
+ }
+};
diff --git a/src/shared/components/ui/badge.tsx b/src/shared/components/ui/badge.tsx
new file mode 100644
index 0000000..973b93f
--- /dev/null
+++ b/src/shared/components/ui/badge.tsx
@@ -0,0 +1,37 @@
+import * as React from "react";
+
+import { Slot } from "@radix-ui/react-slot";
+import { type VariantProps, cva } from "class-variance-authority";
+
+import { cn } from "../../utils";
+
+const badgeVariants = cva(
+ "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
+ {
+ variants: {
+ variant: {
+ default: "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
+ secondary: "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
+ destructive:
+ "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+function Badge({
+ className,
+ variant,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"span"> & VariantProps & { asChild?: boolean }) {
+ const Comp = asChild ? Slot : "span";
+
+ return ;
+}
+
+export { Badge, badgeVariants };
diff --git a/src/shared/components/ui/index.ts b/src/shared/components/ui/index.ts
index 4946e48..12d600c 100644
--- a/src/shared/components/ui/index.ts
+++ b/src/shared/components/ui/index.ts
@@ -1,4 +1,5 @@
export * from "./avatar";
+export * from "./badge";
export * from "./button";
export * from "./card";
export * from "./chart";
@@ -14,3 +15,4 @@ export * from "./sonner";
export * from "./spinner";
export * from "./table";
export * from "./textarea";
+export * from "./progress";
diff --git a/src/shared/components/ui/progress.tsx b/src/shared/components/ui/progress.tsx
new file mode 100644
index 0000000..a505142
--- /dev/null
+++ b/src/shared/components/ui/progress.tsx
@@ -0,0 +1,25 @@
+"use client";
+
+import * as React from "react";
+
+import * as ProgressPrimitive from "@radix-ui/react-progress";
+
+import { cn } from "../../utils";
+
+function Progress({ className, value, ...props }: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+export { Progress };