From ebb165b05d07b4e5fc33b8909b20f0038c61c9f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B2=9C=EC=84=B1=EC=9C=A4?= <1112csy@naver.com> Date: Fri, 5 Sep 2025 15:02:04 +0900 Subject: [PATCH 1/5] =?UTF-8?q?=E2=9A=A1=EF=B8=8F:=20=EC=8B=A4=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=EC=A3=BC=EC=8B=9D=ED=98=84=ED=99=A9=20=EC=B5=9C?= =?UTF-8?q?=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/home/TrendingKeywords.tsx | 116 ++++++++++++++++------- 1 file changed, 80 insertions(+), 36 deletions(-) diff --git a/src/components/home/TrendingKeywords.tsx b/src/components/home/TrendingKeywords.tsx index aaa03e5..44023cf 100644 --- a/src/components/home/TrendingKeywords.tsx +++ b/src/components/home/TrendingKeywords.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useEffect } from "react"; +import { useState, useMemo, useEffect, useCallback, memo } from "react"; import { useNavigate } from "react-router-dom"; import { Filter } from "lucide-react"; import { ResponsiveTreeMap } from "@nivo/treemap"; @@ -23,18 +23,38 @@ type SortBy = | "volume_desc" | "volume_asc"; -// API 호출 함수 +// API 호출 함수 (캐싱 추가) +const companyCache = new Map< + SortBy, + { data: CompanyInfo[]; timestamp: number } +>(); +const CACHE_DURATION = 5 * 60 * 1000; // 5분 + const getCompanies = async ( sortBy: SortBy = "market_cap_desc" ): Promise => { + // 캐시 확인 + const cached = companyCache.get(sortBy); + if (cached && Date.now() - cached.timestamp < CACHE_DURATION) { + return cached.data; + } + try { - const response = await fetch(`/api/info/companies?sort_by=${sortBy}`); + const response = await fetch(`/api/info/companies?sort_by=${sortBy}`, { + headers: { + "Cache-Control": "max-age=300", // 5분 브라우저 캐시 + }, + }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); + + // 캐시에 저장 + companyCache.set(sortBy, { data, timestamp: Date.now() }); + return data; } catch (error) { console.error("Error fetching companies:", error); @@ -54,8 +74,8 @@ const getSentimentColor = (sentiment: string) => { } }; -// 커스텀 툴팁 컴포넌트 -const CustomTooltip = ({ node }: any) => { +// 커스텀 툴팁 컴포넌트 (메모이제이션) +const CustomTooltip = memo(({ node }: any) => { return (
{/* 헤더 */} @@ -107,9 +127,9 @@ const CustomTooltip = ({ node }: any) => {
); -}; +}); -export default function TrendingKeywords() { +const TrendingKeywords = memo(function TrendingKeywords() { const navigate = useNavigate(); const [filter, setFilter] = useState("market_cap_desc"); const [companies, setCompanies] = useState([]); @@ -124,48 +144,67 @@ export default function TrendingKeywords() { volume_asc: [], }); const [loading, setLoading] = useState(true); + const [filterLoading, setFilterLoading] = useState(false); const [error, setError] = useState(null); useEffect(() => { - const fetchAllCompanies = async () => { + const fetchInitialData = async () => { try { setLoading(true); setError(null); - // 3가지 필터링 상태에 대한 데이터를 병렬로 로딩 - const [marketCapData, changePercentData, volumeData] = - await Promise.all([ - getCompanies("market_cap_desc"), - getCompanies("change_percent_desc"), - getCompanies("volume_desc"), - ]); + // 1단계: 시가총액 높은순 데이터를 먼저 로딩하여 즉시 표시 + const marketCapData = await getCompanies("market_cap_desc"); - setAllCompanies({ + setAllCompanies((prev) => ({ + ...prev, market_cap_desc: marketCapData, market_cap_asc: marketCapData, - change_percent_desc: changePercentData, - change_percent_asc: changePercentData, - volume_desc: volumeData, - volume_asc: volumeData, - }); - - // 초기 필터에 맞는 데이터 설정 + })); setCompanies(marketCapData); + setLoading(false); + + // 2단계: 백그라운드에서 나머지 데이터 로딩 + const loadRemainingData = async () => { + try { + const [changePercentData, volumeData] = await Promise.all([ + getCompanies("change_percent_desc"), + getCompanies("volume_desc"), + ]); + + setAllCompanies((prev) => ({ + ...prev, + change_percent_desc: changePercentData, + change_percent_asc: changePercentData, + volume_desc: volumeData, + volume_asc: volumeData, + })); + } catch (err) { + console.error("Error fetching remaining data:", err); + // 백그라운드 로딩 실패는 사용자에게 알리지 않음 + } + }; + + // 백그라운드에서 나머지 데이터 로딩 시작 + loadRemainingData(); } catch (err) { setError("데이터를 불러오는 중 오류가 발생했습니다."); - console.error("Error fetching companies:", err); - } finally { + console.error("Error fetching initial data:", err); setLoading(false); } }; - fetchAllCompanies(); + fetchInitialData(); }, []); // 필터 변경 시 해당 데이터로 업데이트 useEffect(() => { if (allCompanies[filter] && allCompanies[filter].length > 0) { setCompanies(allCompanies[filter]); + setFilterLoading(false); + } else if (allCompanies[filter] && allCompanies[filter].length === 0) { + // 데이터가 아직 로딩 중인 경우 + setFilterLoading(true); } }, [filter, allCompanies]); @@ -199,11 +238,14 @@ export default function TrendingKeywords() { }; }, [companies, filter]); - const handleNodeClick = (node: any) => { - if (node.data.stockCode) { - navigate(`/stock/${node.data.stockCode}`); - } - }; + const handleNodeClick = useCallback( + (node: any) => { + if (node.data.stockCode) { + navigate(`/stock/${node.data.stockCode}`); + } + }, + [navigate] + ); return (
@@ -253,7 +295,7 @@ export default function TrendingKeywords() { {/* TreeMap */}
- {loading ? ( + {loading || filterLoading ? (
{/* 스켈레톤 헤더 */}
@@ -277,7 +319,7 @@ export default function TrendingKeywords() { value="loc" valueFormat=".0f" margin={{ top: 10, right: 10, bottom: 10, left: 10 }} - labelSkipSize={8} + labelSkipSize={12} labelTextColor={{ from: "color", modifiers: [["darker", 1.2]] }} parentLabelPosition="left" parentLabelTextColor={{ @@ -295,8 +337,8 @@ export default function TrendingKeywords() { label={(node) => { // 주식명이 너무 길면 줄여서 표시 const name = node.data.name; - if (name.length > 8) { - return name.substring(0, 7) + "..."; + if (name.length > 6) { + return name.substring(0, 5) + "..."; } return name; }} @@ -333,4 +375,6 @@ export default function TrendingKeywords() {
); -} +}); + +export default TrendingKeywords; From 307273935a34f27afeb90ea509e78f71214ff093 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B2=9C=EC=84=B1=EC=9C=A4?= <1112csy@naver.com> Date: Mon, 8 Sep 2025 14:51:47 +0900 Subject: [PATCH 2/5] =?UTF-8?q?=E2=9C=A8:=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=ED=95=98=EC=9C=84=EB=9D=BC=EC=9D=B8=20?= =?UTF-8?q?=EB=9D=BC=EC=9A=B0=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/news/Watchlist.tsx | 85 ++++++-- src/pages/myPage/favorites.tsx | 330 +++++++++++++--------------- src/pages/myPage/index.tsx | 2 +- src/pages/myPage/notifications.tsx | 245 +++++++++++++++++++++ src/pages/myPage/payment.tsx | 38 ++-- src/pages/myPage/profile.tsx | 106 ++++----- src/pages/myPage/security.tsx | 338 +++++++++++++++++++++++++++++ src/pages/myPage/support.tsx | 219 +++++++++++++++++++ 8 files changed, 1087 insertions(+), 276 deletions(-) create mode 100644 src/pages/myPage/notifications.tsx create mode 100644 src/pages/myPage/security.tsx create mode 100644 src/pages/myPage/support.tsx diff --git a/src/components/news/Watchlist.tsx b/src/components/news/Watchlist.tsx index 1d044ad..ce45a5b 100644 --- a/src/components/news/Watchlist.tsx +++ b/src/components/news/Watchlist.tsx @@ -1,6 +1,7 @@ import { Star, Search, ChevronLeft, ChevronRight } from "lucide-react"; import { useState, useEffect } from "react"; import { getMyFeed } from "../../api/news/feed"; +import { getStockList } from "../../api/stock/list"; interface Company { name: string; @@ -22,30 +23,76 @@ export default function Watchlist({ const itemsPerPage = 5; useEffect(() => { - // /news/my-feed에서 뉴스 받아와서 관련 기업 추출 - getMyFeed({ limit: 100 }).then((newsList) => { - // 모든 뉴스의 related_companies를 flat하게 모으고 중복 제거 - const allCompanies = newsList - .flatMap((news) => news.related_companies || []) - .filter(Boolean); - const uniqueCompanies = Array.from(new Set(allCompanies)); - // 이름/코드 분리 (예시: "삼성전자(005930)" 형식이면 분리, 아니면 이름만) - const companyObjs: Company[] = uniqueCompanies.map((item) => { - const match = item.match(/(.+)[(]([0-9A-Za-z]+)[)]/); - if (match) { - return { name: match[1], code: match[2] }; - } else { - return { name: item, code: item }; + const loadCompanies = async () => { + try { + // 먼저 my-feed에서 관련 기업 추출 시도 + const newsList = await getMyFeed({ limit: 100 }); + console.log("뉴스 데이터:", newsList); + console.log("첫 번째 뉴스 상세:", newsList[0]); + + // 모든 뉴스의 related_companies를 flat하게 모으고 중복 제거 + const allCompanies = newsList + .flatMap((news) => news.related_companies || []) + .filter(Boolean); + + console.log("추출된 기업들:", allCompanies); + + // related_companies가 비어있으면 대안으로 인기 종목 사용 + if (allCompanies.length === 0) { + console.log("관련 기업이 없어서 인기 종목을 로딩합니다."); + const stockResponse = await getStockList("market_cap_desc"); + const companyObjs: Company[] = stockResponse.data + .slice(0, 20) + .map((stock) => ({ + name: stock.name, + code: stock.code, + })); + console.log("인기 종목으로 대체:", companyObjs); + setCompanies(companyObjs); + return; + } + + const uniqueCompanies = Array.from(new Set(allCompanies)); + console.log("중복 제거 후:", uniqueCompanies); + + // 이름/코드 분리 (예시: "삼성전자(005930)" 형식이면 분리, 아니면 이름만) + const companyObjs: Company[] = uniqueCompanies.map((item) => { + const match = item.match(/(.+)[(]([0-9A-Za-z]+)[)]/); + if (match) { + return { name: match[1], code: match[2] }; + } else { + return { name: item, code: item }; + } + }); + + console.log("최종 기업 객체들:", companyObjs); + setCompanies(companyObjs); + } catch (error) { + console.error("관심기업 데이터 로딩 실패:", error); + // 에러 시에도 인기 종목으로 대체 + try { + const stockResponse = await getStockList("market_cap_desc"); + const companyObjs: Company[] = stockResponse.data + .slice(0, 20) + .map((stock) => ({ + name: stock.name, + code: stock.code, + })); + setCompanies(companyObjs); + } catch (fallbackError) { + console.error("대체 데이터 로딩도 실패:", fallbackError); + setCompanies([]); } - }); - setCompanies(companyObjs); - }); + } + }; + + loadCompanies(); }, []); const filteredCompanies = companies.filter( (company) => company.name.toLowerCase().includes(searchQuery.toLowerCase()) || - company.code.includes(searchQuery), + company.code.includes(searchQuery) ); const totalPages = Math.ceil(filteredCompanies.length / itemsPerPage); @@ -85,7 +132,7 @@ export default function Watchlist({ key={index} onClick={() => onCompanySelect( - company === selectedCompany ? null : company, + company === selectedCompany ? null : company ) } className={`p-4 rounded-lg border transition-colors duration-200 cursor-pointer ${ diff --git a/src/pages/myPage/favorites.tsx b/src/pages/myPage/favorites.tsx index 73dc768..2f115ba 100644 --- a/src/pages/myPage/favorites.tsx +++ b/src/pages/myPage/favorites.tsx @@ -1,228 +1,208 @@ import { useState } from "react"; import { motion } from "framer-motion"; -import { ArrowLeft, Heart, Trash2, Search } from "lucide-react"; +import { + ArrowLeft, + Heart, + TrendingUp, + TrendingDown, + Star, + Search, + MoreVertical, +} from "lucide-react"; import { useNavigate } from "react-router-dom"; export default function FavoritesPage() { const navigate = useNavigate(); - const [activeTab, setActiveTab] = useState<"stocks" | "news">("stocks"); const [searchTerm, setSearchTerm] = useState(""); - const favoriteStocks = [ + const [favoriteStocks] = useState([ { id: 1, symbol: "005930", name: "삼성전자", - price: "75,800", - change: "+2.3%", - changeType: "up", - sector: "전자", + price: 71500, + change: 1500, + changePercent: 2.14, + volume: "12.5M", + marketCap: "427조", + isFavorite: true, }, { id: 2, symbol: "000660", name: "SK하이닉스", - price: "142,000", - change: "-1.2%", - changeType: "down", - sector: "반도체", + price: 128000, + change: -2000, + changePercent: -1.54, + volume: "3.2M", + marketCap: "93조", + isFavorite: true, }, { id: 3, symbol: "035420", name: "NAVER", - price: "198,500", - change: "+0.8%", - changeType: "up", - sector: "IT", - }, - ]; - - const favoriteNews = [ - { - id: 1, - title: "삼성전자, 2분기 실적 예상치 상회", - press: "한국경제", - published_at: "2024-01-15", - impact: "positive", - }, - { - id: 2, - title: "SK하이닉스 메모리 가격 상승세", - press: "매일경제", - published_at: "2024-01-14", - impact: "positive", + price: 198500, + change: 3500, + changePercent: 1.79, + volume: "1.8M", + marketCap: "32조", + isFavorite: true, }, { - id: 3, - title: "NAVER AI 기술 개발 현황", - press: "조선일보", - published_at: "2024-01-13", - impact: "neutral", + id: 4, + symbol: "051910", + name: "LG화학", + price: 425000, + change: -5000, + changePercent: -1.16, + volume: "0.8M", + marketCap: "30조", + isFavorite: true, }, - ]; + ]); - const getImpactColor = (impact: string) => { - switch (impact) { - case "positive": - return "text-green-600 bg-green-50 border-green-200"; - case "negative": - return "text-red-600 bg-red-50 border-red-200"; - case "neutral": - return "text-gray-600 bg-gray-50 border-gray-200"; - default: - return "text-gray-600 bg-gray-50 border-gray-200"; - } + const handleRemoveFavorite = (id: number) => { + console.log("즐겨찾기 제거:", id); }; - const getImpactText = (impact: string) => { - switch (impact) { - case "positive": - return "긍정"; - case "negative": - return "부정"; - case "neutral": - return "중립"; - default: - return "중립"; - } - }; + const filteredStocks = favoriteStocks.filter( + (stock) => + stock.name.toLowerCase().includes(searchTerm.toLowerCase()) || + stock.symbol.includes(searchTerm) + ); return ( -
+
{/* 헤더 */} -
-
+
+

즐겨찾기

- {/* 검색바 */} -
-
- - setSearchTerm(e.target.value)} - className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-[#0A5C2B]/20 focus:border-[#0A5C2B]" - /> -
-
+
+ {/* 검색 및 필터 */} + +
+ + setSearchTerm(e.target.value)} + className="w-full pl-12 pr-4 py-3 bg-white/70 backdrop-blur-xl border border-white/20 rounded-2xl focus:outline-none focus:ring-2 focus:ring-[#0A5C2B]/20 focus:border-[#0A5C2B] transition-all" + /> +
+
- {/* 탭 */} -
-
- - -
-
+ {/* 즐겨찾기 주식 */} + +
+

+ 즐겨찾기 주식 +

+ + {filteredStocks.length}개 + +
-
- {activeTab === "stocks" ? ( -
- {favoriteStocks.map((stock, index) => ( - -
-
-
-

- {stock.name} -

- - {stock.symbol} - - -
-

{stock.sector}

+ {filteredStocks.map((stock, index) => ( + +
+
+
+

+ {stock.name} +

+ + {stock.symbol} +
-
-

{stock.price}

-

+ + ₩{stock.price.toLocaleString()} + +

= 0 ? "text-red-500" : "text-blue-500" }`} > - {stock.change} -

-
- -
- - ))} -
- ) : ( -
- {favoriteNews.map((news, index) => ( - -
-
-
- - {getImpactText(news.impact)} + {stock.change >= 0 ? ( + + ) : ( + + )} + + {stock.change >= 0 ? "+" : ""} + {stock.change.toLocaleString()}( + {stock.changePercent >= 0 ? "+" : ""} + {stock.changePercent}%) - -
-

- {news.title} -

-
- {news.press} - - {news.published_at}
-
+
+ +
-
- ))} -
+
+
+ ))} + + + {/* 빈 상태 */} + {filteredStocks.length === 0 && ( + +
+ +
+

+ 즐겨찾기 주식이 없습니다 +

+

+ 관심 있는 주식을 즐겨찾기에 추가해보세요 +

+
)}
diff --git a/src/pages/myPage/index.tsx b/src/pages/myPage/index.tsx index 6f5bf18..cdf0dbf 100644 --- a/src/pages/myPage/index.tsx +++ b/src/pages/myPage/index.tsx @@ -43,7 +43,7 @@ export default function MyPage() { { icon: , title: "즐겨찾기 리스트", - subtitle: "관심 종목 및 뉴스 관리", + subtitle: "관심 종목 관리", href: "/myPage/favorites", }, { diff --git a/src/pages/myPage/notifications.tsx b/src/pages/myPage/notifications.tsx new file mode 100644 index 0000000..a66a912 --- /dev/null +++ b/src/pages/myPage/notifications.tsx @@ -0,0 +1,245 @@ +import { useState } from "react"; +import { motion } from "framer-motion"; +import { + ArrowLeft, + Bell, + Settings, + Check, + X, + AlertCircle, + Info, + TrendingUp, +} from "lucide-react"; +import { useNavigate } from "react-router-dom"; + +export default function NotificationsPage() { + const navigate = useNavigate(); + const [notifications, setNotifications] = useState([ + { + id: 1, + type: "alert", + title: "주가 급등 알림", + message: "삼성전자 주가가 5% 상승했습니다.", + time: "2분 전", + isRead: false, + }, + { + id: 2, + type: "info", + title: "뉴스 알림", + message: "새로운 시장 분석 리포트가 업데이트되었습니다.", + time: "1시간 전", + isRead: false, + }, + { + id: 3, + type: "trending", + title: "인기 키워드", + message: "AI 관련 주식이 오늘 인기 키워드 1위에 올랐습니다.", + time: "3시간 전", + isRead: true, + }, + { + id: 4, + type: "alert", + title: "포트폴리오 알림", + message: "보유 주식 중 하나가 목표가에 도달했습니다.", + time: "1일 전", + isRead: true, + }, + ]); + + const [settings, setSettings] = useState({ + pushNotifications: true, + emailNotifications: false, + priceAlerts: true, + newsAlerts: true, + trendingAlerts: false, + }); + + const handleMarkAsRead = (id: number) => { + setNotifications(prev => + prev.map(notif => + notif.id === id ? { ...notif, isRead: true } : notif + ) + ); + }; + + const handleDeleteNotification = (id: number) => { + setNotifications(prev => prev.filter(notif => notif.id !== id)); + }; + + const handleMarkAllAsRead = () => { + setNotifications(prev => + prev.map(notif => ({ ...notif, isRead: true })) + ); + }; + + const handleSettingChange = (key: string) => { + setSettings(prev => ({ + ...prev, + [key]: !prev[key as keyof typeof prev], + })); + }; + + const getIcon = (type: string) => { + switch (type) { + case "alert": + return ; + case "info": + return ; + case "trending": + return ; + default: + return ; + } + }; + + const unreadCount = notifications.filter(n => !n.isRead).length; + + return ( +
+ {/* 헤더 */} +
+
+ +

알림

+ +
+
+ +
+ {/* 알림 요약 */} + +
+
+
+ +
+
+

+ 알림 요약 +

+

+ {unreadCount}개의 읽지 않은 알림 +

+
+
+ {unreadCount > 0 && ( + + )} +
+
+ + {/* 알림 목록 */} + + {notifications.map((notification, index) => ( + +
+
+ {getIcon(notification.type)} +
+
+
+
+

+ {notification.title} +

+

+ {notification.message} +

+

{notification.time}

+
+
+ {!notification.isRead && ( + + )} + +
+
+
+
+
+ ))} +
+ + {/* 알림 설정 */} + +

+ 알림 설정 +

+
+ {Object.entries(settings).map(([key, value]) => ( +
+ + {key === "pushNotifications" && "푸시 알림"} + {key === "emailNotifications" && "이메일 알림"} + {key === "priceAlerts" && "주가 알림"} + {key === "newsAlerts" && "뉴스 알림"} + {key === "trendingAlerts" && "트렌드 알림"} + + +
+ ))} +
+
+
+
+ ); +} diff --git a/src/pages/myPage/payment.tsx b/src/pages/myPage/payment.tsx index d68a6d6..e85a901 100644 --- a/src/pages/myPage/payment.tsx +++ b/src/pages/myPage/payment.tsx @@ -72,22 +72,22 @@ export default function PaymentPage() { }; return ( -
+
{/* 헤더 */} -
-
+
+

결제 수단

-
+
{/* 결제 수단 섹션 */} 결제 수단 -

개인정보 관리

+

프로필

-
- {/* 프로필 이미지 섹션 */} +
+ {/* 사용자 정보 헤더 */} -
-
- {user.name} - -
-
-

- {user.name} -

- - {user.membership} - -
-
+

{user.name}

+ + {user.membership} +
{/* 개인정보 폼 */} @@ -87,14 +69,14 @@ export default function ProfilePage() { initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5, delay: 0.1 }} - className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden" + className="bg-white/70 backdrop-blur-xl rounded-3xl shadow-lg border border-white/20 overflow-hidden" > -
-

기본 정보

+
+

기본 정보

{!isEditing ? (
-
+
{/* 이름 */} -
-