= ({ user }) => {
const [isFollowing, setIsFollowing] = useState(user.isFollowing);
const [isLoading, setIsLoading] = useState(false);
+ const [followUser] = useMutation(FOLLOW_USER);
+ const [unfollowUser] = useMutation(UNFOLLOW_USER);
const handleFollowToggle = useCallback(async () => {
if (isLoading) return;
-
setIsLoading(true);
-
- // Simulate API call delay
- setTimeout(() => {
- setIsFollowing(!isFollowing);
+ try {
+ if (isFollowing) {
+ await unfollowUser({ variables: { userId: user.id } });
+ setIsFollowing(false);
+ } else {
+ await followUser({ variables: { userId: user.id } });
+ setIsFollowing(true);
+ }
+ } catch {
+ // keep previous state on error
+ } finally {
setIsLoading(false);
- }, 500);
- }, [isFollowing, isLoading]);
+ }
+ }, [isFollowing, isLoading, followUser, unfollowUser, user.id]);
return (
({
@@ -78,6 +79,58 @@ const mocks = [
data: mockFollowingData,
},
},
+ // Search query: "Esthera"
+ {
+ request: {
+ query: GET_FOLLOWING,
+ variables: {
+ page: 1,
+ limit: 20,
+ search: "Esthera",
+ },
+ },
+ result: {
+ data: {
+ following: {
+ items: [mockFollowingData.following.items[0]],
+ pageInfo: {
+ page: 1,
+ limit: 20,
+ total: 1,
+ totalPages: 1,
+ hasNextPage: false,
+ hasPreviousPage: false,
+ },
+ },
+ },
+ },
+ },
+ // Search query: "__no_match__" returns empty list
+ {
+ request: {
+ query: GET_FOLLOWING,
+ variables: {
+ page: 1,
+ limit: 20,
+ search: "__no_match__",
+ },
+ },
+ result: {
+ data: {
+ following: {
+ items: [],
+ pageInfo: {
+ page: 1,
+ limit: 20,
+ total: 0,
+ totalPages: 1,
+ hasNextPage: false,
+ hasPreviousPage: false,
+ },
+ },
+ },
+ },
+ },
];
describe("FollowingPage", () => {
@@ -87,6 +140,7 @@ describe("FollowingPage", () => {
it("renders following page with modal", async () => {
render(
+ // @ts-expect-error addTypename is supported at runtime in our Apollo version
@@ -106,12 +160,13 @@ describe("FollowingPage", () => {
expect(screen.getByPlaceholderText("Search")).toBeInTheDocument();
// Check if following names are displayed
- expect(screen.getByText("Esthera Jackson")).toBeInTheDocument();
- expect(screen.getByText("Alexa Liras")).toBeInTheDocument();
+ await screen.findByText("Esthera Jackson");
+ await screen.findByText("Alexa Liras");
});
it("handles search functionality", async () => {
render(
+ // @ts-expect-error addTypename is supported at runtime in our Apollo version
@@ -134,6 +189,7 @@ describe("FollowingPage", () => {
it("handles close button click", async () => {
render(
+ // @ts-expect-error addTypename is supported at runtime in our Apollo version
@@ -156,6 +212,7 @@ describe("FollowingPage", () => {
it("navigates back to mypage when close button is clicked", async () => {
render(
+ // @ts-expect-error addTypename is supported at runtime in our Apollo version
diff --git a/src/pages/following/FollowingPage.tsx b/src/pages/following/FollowingPage.tsx
index f8cb5eb..badb980 100644
--- a/src/pages/following/FollowingPage.tsx
+++ b/src/pages/following/FollowingPage.tsx
@@ -3,18 +3,18 @@ import { useNavigate } from "react-router-dom";
import { Box } from "../../../styled-system/jsx";
import Header from "@/components/common/Header";
import FollowingModal from "./components/FollowingModal";
-import { mockFollowing } from "@/features/user/mock-data";
import multibg from "@/assets/images/multi_background.png";
+import { useFollowing } from "./hooks/useFollowing";
const FollowingPage: React.FC = () => {
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState("");
-
- // Filter following based on search query
- const filteredFollowing = mockFollowing.filter(
- (following) =>
- following.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
- following.email.toLowerCase().includes(searchQuery.toLowerCase())
+ const { data, loading, error, hasNextPage, loadMore, refetch } = useFollowing(
+ {
+ page: 1,
+ search: searchQuery,
+ limit: 20,
+ }
);
const handleSearch = useCallback((query: string) => {
@@ -77,15 +77,15 @@ const FollowingPage: React.FC = () => {
>
{/* Modal Container */}
{}}
- hasNextPage={false}
+ onLoadMore={loadMore}
+ hasNextPage={hasNextPage}
onClose={handleClose}
- onRefetch={() => {}}
+ onRefetch={refetch}
/>
diff --git a/src/pages/following/components/UserItem.tsx b/src/pages/following/components/UserItem.tsx
index fab7e0c..bfd965c 100644
--- a/src/pages/following/components/UserItem.tsx
+++ b/src/pages/following/components/UserItem.tsx
@@ -1,6 +1,8 @@
import React, { useState, useCallback } from "react";
import { css } from "../../../../styled-system/css";
import { FollowingUser } from "@/features/user/types/follow-types";
+import { useMutation } from "@apollo/client/react";
+import { UNFOLLOW_USER } from "@/features/user/graphql/follow-operations";
interface UserItemProps {
user: FollowingUser;
@@ -8,18 +10,23 @@ interface UserItemProps {
const UserItem: React.FC = ({ user }) => {
const [isLoading, setIsLoading] = useState(false);
+ const [removed, setRemoved] = useState(false);
+ const [unfollowUser] = useMutation(UNFOLLOW_USER);
const handleUnfollow = useCallback(async () => {
- if (isLoading) return;
-
+ if (isLoading || removed) return;
setIsLoading(true);
-
- // Simulate API call delay
- setTimeout(() => {
+ try {
+ await unfollowUser({ variables: { userId: user.id } });
+ setRemoved(true);
+ } catch {
+ // no-op UI; keep item visible
+ } finally {
setIsLoading(false);
- // Note: User will be removed from list by parent component
- }, 500);
- }, [isLoading]);
+ }
+ }, [isLoading, removed, unfollowUser, user.id]);
+
+ if (removed) return null;
return (
({
// Mock the modals
vi.mock("../../components/modal/RankingModal", () => ({
default: ({ onClose }: { onClose: () => void }) => (
-
Ranking Modal
+
+ Ranking Modal
+
),
}));
vi.mock("../../components/modal/SettingModal", () => ({
default: ({ onClose }: { onClose: () => void }) => (
-
Setting Modal
+
+ Setting Modal
+
),
}));
vi.mock("../../components/common/TutoModal", () => ({
default: ({ onClose }: { onClose: () => void }) => (
-
Tuto Modal
+
+ Tuto Modal
+
),
}));
diff --git a/src/pages/mypage/MyPage.tsx b/src/pages/mypage/MyPage.tsx
index 715b900..68bfd0a 100644
--- a/src/pages/mypage/MyPage.tsx
+++ b/src/pages/mypage/MyPage.tsx
@@ -1,4 +1,5 @@
-import React, { useState, useCallback, memo } from "react";
+import { useState, useCallback, memo } from "react";
+import type * as React from "react";
// import { useQuery, useMutation } from "@apollo/client/react";
import { useNavigate } from "react-router-dom";
import Header from "../../components/common/Header";
@@ -11,6 +12,7 @@ import PatchNoteModal from "../../components/PatchNoteModal";
import RankingModal from "../../components/modal/RankingModal";
import SettingModal from "../../components/modal/SettingModal";
import TutoModal from "../../components/common/TutoModal";
+import { upDateMyProfile } from "../../api/myPage";
// import {
// GET_USER_PROFILE,
// GET_MONTHLY_RANKINGS,
@@ -147,6 +149,7 @@ const MyPage: React.FC = () => {
useState
(false);
const [isSettingModalOpen, setIsSettingModalOpen] = useState(false);
const [isRankingModalOpen, setIsRankingModalOpen] = useState(false);
+ const [isEditModalOpen, setIsEditModalOpen] = useState(false);
// 검색 상태 관리
const [searchQuery, setSearchQuery] = useState("");
@@ -156,8 +159,8 @@ const MyPage: React.FC = () => {
setSearchQuery(query);
}, []);
- // User profile data
- const userProfile: UserProfile = {
+ // User profile data (stateful to reflect edits)
+ const [profile, setProfile] = useState({
id: mockUserData.me.id,
nickname: mockUserData.me.nickname,
email: mockUserData.me.email,
@@ -169,7 +172,7 @@ const MyPage: React.FC = () => {
monthlyRankings: mockRankingsData.monthlyRankings,
totalScore: mockUserData.me.totalScore,
wallet: mockUserData.me.wallet,
- };
+ });
// Performance data for graph - 검색어에 따라 변경
const performanceDataPoints: PerformanceData[] = searchQuery
@@ -360,6 +363,25 @@ const MyPage: React.FC = () => {
/>
)}
+ {/* Edit Profile Modal */}
+ {isEditModalOpen && (
+ setIsEditModalOpen(false)}
+ onSave={async (nextNickname: string) => {
+ try {
+ await upDateMyProfile({ nickname: nextNickname });
+ setProfile((prev) => ({ ...prev, nickname: nextNickname }));
+ alert("프로필이 업데이트되었습니다.");
+ } catch (e) {
+ alert("프로필 업데이트에 실패했습니다.");
+ } finally {
+ setIsEditModalOpen(false);
+ }
+ }}
+ />
+ )}
+
{/* Header Modals */}
{isTutorialModalOpen && (
setIsTutorialModalOpen(false)} />
@@ -449,9 +471,7 @@ const MyPage: React.FC = () => {
height: "120px",
borderRadius: "50%",
border: "4px solid #ffffff",
- backgroundImage: `url(${
- userProfile.profileImage || "/default-avatar.png"
- })`,
+ backgroundImage: `url(${profile.profileImage || "/default-avatar.png"})`,
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
@@ -488,9 +508,19 @@ const MyPage: React.FC = () => {
fontSize: "0.875rem",
fontWeight: "600",
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1)",
+ cursor: "pointer",
+ transition: "background-color 0.15s ease-in-out",
+ }}
+ role="button"
+ onClick={() => navigate("/follower")}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.backgroundColor = "#f3f4f6";
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.backgroundColor = "#ffffff";
}}
>
- {formatNumber(userProfile.followersCount)} Followers
+ {formatNumber(profile.followersCount)} Followers
{/* Following Box */}
@@ -505,9 +535,19 @@ const MyPage: React.FC = () => {
fontSize: "0.875rem",
fontWeight: "600",
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1)",
+ cursor: "pointer",
+ transition: "background-color 0.15s ease-in-out",
+ }}
+ role="button"
+ onClick={() => navigate("/following")}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.backgroundColor = "#f3f4f6";
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.backgroundColor = "#ffffff";
}}
>
- {formatNumber(userProfile.followingCount)} Following
+ {formatNumber(profile.followingCount)} Following
);
@@ -544,23 +584,22 @@ const MyPage: React.FC = () => {
color: "#ffffff",
}}
>
- ID/PW: {userProfile.email}
-
-
- {/* Connected Accounts */}
-
- {userProfile.connectedAccounts.google && (
-
- )}
- {userProfile.connectedAccounts.kakao && (
-
- )}
+ ID/PW: {profile.email}
+ {/* Connected Accounts */}
+
+ {profile.connectedAccounts.google && (
+
+ )}
+ {profile.connectedAccounts.kakao && (
+
+ )}
+
@@ -583,7 +622,7 @@ const MyPage: React.FC = () => {
Top Records
- {Object.entries(userProfile.topRecords).map(([language, score]) => (
+ {Object.entries(profile.topRecords).map(([language, score]) => (
{
))}
- {/* Withdraw Button */}
+ {/* Edit & Withdraw Buttons */}
+
setIsEditModalOpen(true)}
+ style={{
+ backgroundColor: "rgba(59, 130, 246, 0.9)",
+ color: "#ffffff",
+ borderRadius: "0.25rem",
+ padding: "0.75rem",
+ textAlign: "center",
+ fontSize: "0.875rem",
+ fontWeight: "600",
+ cursor: "pointer",
+ transition: "all 0.15s ease-in-out",
+ marginBottom: "0.5rem",
+ }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.backgroundColor = "rgba(37, 99, 235, 1)";
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.backgroundColor = "rgba(59, 130, 246, 0.9)";
+ }}
+ >
+ 정보 수정
+
setIsWithdrawModalOpen(true)}
@@ -790,43 +853,41 @@ const MyPage: React.FC = () => {
flexWrap: "wrap",
}}
>
- {Object.entries(userProfile.monthlyRankings).map(
- ([language, rank]) => (
+ {Object.entries(profile.monthlyRankings).map(([language, rank]) => (
+
+
+ {language}
+
-
- {language}
-
-
- {rank !== null ? formatRank(rank) : "-"}
-
+ {rank !== null ? formatRank(rank) : "-"}
- )
- )}
+
+ ))}
);
@@ -909,7 +970,7 @@ const MyPage: React.FC = () => {
{
+ onValueChange={(value: string[]) => {
if (value.length > 0) {
handleLanguageFilter(value[0]);
}
@@ -926,7 +987,7 @@ const MyPage: React.FC = () => {
{
+ onValueChange={(value: string[]) => {
if (value.length > 0) {
handleTimePeriodFilter(value[0] as "daily" | "weekly" | "annually");
}
@@ -1085,9 +1146,7 @@ const MyPage: React.FC = () => {
width: "24px",
height: "24px",
borderRadius: "50%",
- backgroundImage: `url(${
- userProfile.profileImage || "/default-avatar.png"
- })`,
+ backgroundImage: `url(${profile.profileImage || "/default-avatar.png"})`,
backgroundSize: "cover",
backgroundPosition: "center",
}}
@@ -1239,3 +1298,123 @@ const MyPage: React.FC = () => {
};
export default MyPage;
+
+// Edit Profile Modal Component
+function EditProfileModal({
+ initialNickname,
+ onClose,
+ onSave,
+}: {
+ initialNickname: string;
+ onClose: () => void;
+ onSave: (nickname: string) => void | Promise;
+}) {
+ const [nickname, setNickname] = useState(initialNickname);
+ const [saving, setSaving] = useState(false);
+
+ return (
+
+
+
+ 프로필 정보 수정
+
+
+
+
+ setNickname(e.target.value)}
+ style={{
+ padding: "0.5rem 0.75rem",
+ borderRadius: "0.25rem",
+ border: "1px solid rgba(255,255,255,0.2)",
+ background: "rgba(255,255,255,0.95)",
+ color: "#111827",
+ }}
+ placeholder="닉네임을 입력하세요"
+ />
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/ranking/Ranking.tsx b/src/pages/ranking/Ranking.tsx
index 6e28539..f8382e6 100644
--- a/src/pages/ranking/Ranking.tsx
+++ b/src/pages/ranking/Ranking.tsx
@@ -1,238 +1,262 @@
// @ts-nocheck
-import bgImg from "../../assets/images/multi_background.png"
-import Board2Container from "../../components/single/BoardContainer"
-import goldMedal from "../../assets/images/gold_medal.png"
-import silverMedal from "../../assets/images/silver_medal.png"
-import bronzeMedal from "../../assets/images/dong_medal.png"
-import leftBtn from "../../assets/images/left_btn.png"
-import rightBtn from "../../assets/images/right_btn.png"
-import leftBtn2 from "../../assets/images/less-than_black.png"
-import rightBtn2 from "../../assets/images/greater-than_black.png"
-import xBtn from "../../assets/images/x_btn.png"
-import { useEffect, useState } from "react"
-import { useNavigate } from 'react-router-dom'
-import { getRanking, getMemberRanking } from '../../api/rankingApi'
-import Header from '../../components/common/Header'
-import TutoModal from "../../components/common/TutoModal"
-import SettingModal from "../../components/modal/SettingModal"
-
+import bgImg from "../../assets/images/multi_background.png";
+import Board2Container from "../../components/single/BoardContainer";
+import goldMedal from "../../assets/images/gold_medal.png";
+import silverMedal from "../../assets/images/silver_medal.png";
+import bronzeMedal from "../../assets/images/dong_medal.png";
+import leftBtn from "../../assets/images/left_btn.png";
+import rightBtn from "../../assets/images/right_btn.png";
+import leftBtn2 from "../../assets/images/less-than_black.png";
+import rightBtn2 from "../../assets/images/greater-than_black.png";
+import xBtn from "../../assets/images/x_btn.png";
+import { useEffect, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { useQuery } from "@apollo/client/react";
+import { GET_RANKINGS } from "@/features/ranking/graphql/queries";
+import Header from "../../components/common/Header";
+import TutoModal from "../../components/common/TutoModal";
+import SettingModal from "../../components/modal/SettingModal";
const Ranking = () => {
+ const navigate = useNavigate();
- const navigate = useNavigate();
-
- const languages = ['JAVA', 'PYTHON' , 'SQL' ,'JS'];
- const [showTutoModal, setShowTutoModal] = useState(false)
- const [currentLangIndex, setCurrentLangIndex] = useState(0);
-
- const [ranking, setRanking] = useState([null,null,null,null]); //언어별 랭킹
- // const [myRanking, setMyRanking] = useState([])
-
- const btn_class = 'cursor-pointer scale-75 transition-all duration-150 hover:brightness-110 hover:translate-y-[2px] hover:scale-[0.98] active:scale-[0.95]'
-
- const [userType ,setUserType] = useState(null);
- const [showSettingModal, setShowSettingModal] = useState(false);
-
- useEffect(() => {
- const auth = JSON.parse(localStorage.getItem("auth-storage") || "{}");
- setUserType(auth?.state?.user?.userType);
- }, [])
-
- // /api/single/ranking/{language}
- const getRankingData = async () => {
- const lang = languages[currentLangIndex];
-
- if(ranking[currentLangIndex] !== null) { //null이면 이미 요청 받은 적 있는거
- return;
- }
-
- try {
- let response;
- if (userType === "guest") {
- response = await getRanking(lang);
- } else {
-
- response = await getMemberRanking(lang);
- }
-
- const { code, message } = response.data.status;
-
- if (code === 200){
- const newRanking = [...ranking]
- newRanking[currentLangIndex] = response.data.content
- setRanking(newRanking);
- } else{
- // console.log(message);
- }
-
- } catch (e) {
- console.error(e);
- }
- }
+ const languages = ["JAVA", "PYTHON", "SQL", "JS"];
+ const [showTutoModal, setShowTutoModal] = useState(false);
+ const [currentLangIndex, setCurrentLangIndex] = useState(0);
- useEffect(() => {
- getRankingData();
- },[currentLangIndex, userType])
+ const [ranking, setRanking] = useState([null, null, null, null]); //언어별 랭킹
+ const [period, setPeriod] = useState("all"); // daily|weekly|monthly|all
- // useEffect(() => {
- // console.log(ranking);
- // }, [ranking])
+ const btn_class =
+ "cursor-pointer scale-75 transition-all duration-150 hover:brightness-110 hover:translate-y-[2px] hover:scale-[0.98] active:scale-[0.95]";
- const handlePrev = () => {
- setCurrentLangIndex((prev) => (prev - 1 + languages.length) % languages.length);
- };
-
- const handleNext = () => {
- setCurrentLangIndex((prev) => (prev + 1) % languages.length);
+ const [userType, setUserType] = useState(null);
+ const [showSettingModal, setShowSettingModal] = useState(false);
+
+ useEffect(() => {
+ const auth = JSON.parse(localStorage.getItem("auth-storage") || "{}");
+ setUserType(auth?.state?.user?.userType);
+ }, []);
+
+ const lang = languages[currentLangIndex];
+ const { data, loading, error } = useQuery(GET_RANKINGS, {
+ variables: { game: lang, period, limit: 50 },
+ fetchPolicy: "cache-and-network",
+ });
+
+ useEffect(() => {
+ if (data?.rankings) {
+ const content = {
+ top10: data.rankings.items?.slice(0, 10) || [],
+ myRank: data.rankings.myRank || null,
};
+ const newRanking = [...ranking];
+ newRanking[currentLangIndex] = content;
+ setRanking(newRanking);
+ }
+ }, [data]);
+
+ // useEffect(() => {
+ // console.log(ranking);
+ // }, [ranking])
+
+ const handlePrev = () => {
+ setCurrentLangIndex(
+ (prev) => (prev - 1 + languages.length) % languages.length
+ );
+ };
+
+ const handleNext = () => {
+ setCurrentLangIndex((prev) => (prev + 1) % languages.length);
+ };
+
+ return (
+
+ {/* 튜토리얼 모달 조건부 렌더링 */}
+ {showTutoModal && (
+
+ setShowTutoModal(false)} />
+
+ )}
+ {showSettingModal && (
+
+ setShowSettingModal(false)} />
+
+ )}
+
+
setShowTutoModal(true)}
+ onShowSetting={() => setShowSettingModal(true)}
+ />
+
+
+
+
+ {/* 아이콘에 호버 애니메이션 추가 */}
+
+ ?
+
+
+ {/* 툴팁에 페이드 + 슬라이드 효과 */}
+
+ 언어 옆에 화살표를 클릭하시면
+
+ 다른 언어의 랭킹을 확인하실수 있습니다!
+
+
+
+
- return (
- {/* 튜토리얼 모달 조건부 렌더링 */}
- {showTutoModal && (
-
-
setShowTutoModal(false)} />
+
+
+
{languages[currentLangIndex]}
+
+ {[
+ { key: "daily", label: "Daily" },
+ { key: "weekly", label: "Weekly" },
+ { key: "monthly", label: "Monthly" },
+ { key: "all", label: "All" },
+ ].map((p) => (
+
+ ))}
- )}
- {showSettingModal && (
-
- setShowSettingModal(false)} />
+
+

+
+
+
navigate(-1)}
+ />
+
+
+
+
+

+
+
+
+ {ranking[currentLangIndex]?.top10?.[0]?.nickname || "없음"}
+
+
+ {ranking[currentLangIndex]?.top10?.[0]?.typingSpeed != null
+ ? Math.floor(
+ ranking[currentLangIndex]?.top10?.[0]?.typingSpeed
+ )
+ : 0}
+ 타
+
+
- )}
-
-
setShowTutoModal(true)}
- onShowSetting={() => setShowSettingModal(true)}
- />
-
-
-
-
- {/* 아이콘에 호버 애니메이션 추가 */}
-
- ?
-
-
- {/* 툴팁에 페이드 + 슬라이드 효과 */}
-
- 언어 옆에 화살표를 클릭하시면
- 다른 언어의 랭킹을 확인하실수 있습니다!
-
+
+
+

+
+
+
+ {ranking[currentLangIndex]?.top10?.[1]?.nickname || "없음"}
+
+
+ {ranking[currentLangIndex]?.top10?.[1]?.typingSpeed != null
+ ? Math.floor(
+ ranking[currentLangIndex]?.top10?.[1]?.typingSpeed
+ )
+ : 0}
+ 타
+
+
-
-
-
-

-
- {languages[currentLangIndex]}
-
-

-
-
-
-

navigate(-1)}
- />
-
-
-
-
-

-
-
-
- {ranking[currentLangIndex]?.top10?.[0]?.nickname || "없음"}
-
-
- {ranking[currentLangIndex]?.top10?.[0]?.typingSpeed != null ?
- Math.floor(ranking[currentLangIndex]?.top10?.[0]?.typingSpeed) : 0}타
-
-
-
-
-
-
-

-
-
-
- {ranking[currentLangIndex]?.top10?.[1]?.nickname || "없음"}
-
-
- {ranking[currentLangIndex]?.top10?.[1]?.typingSpeed != null ?
- Math.floor(ranking[currentLangIndex]?.top10?.[1]?.typingSpeed) : 0}타
-
-
-
-
-
-

-
-
-
- {ranking[currentLangIndex]?.top10?.[2]?.nickname || "없음"}
-
-
- {ranking[currentLangIndex]?.top10?.[2]?.typingSpeed != null ?
- Math.floor(ranking[currentLangIndex]?.top10?.[2]?.typingSpeed) : 0}타
-
-
-
-
-
-
-
-
-
- {Array(7).fill(0).map((_,idx) => {
- const nickname = ranking[currentLangIndex]?.top10?.[idx+3]?.nickname || "없음";
- const speed = ranking[currentLangIndex]?.top10?.[idx+3]?.typingSpeed || 0;
- return (
-
- {idx + 4}. {nickname} ({Math.floor(speed)})
-
- )
- })}
- {userType !== "guest" && (
-
- 내 등수: {ranking[currentLangIndex]?.myRank?.rank != null
- ? ` ${Math.floor(ranking[currentLangIndex].myRank.rank)}`
- : " - "
- }등
-
- {ranking[currentLangIndex]?.myRank?.typingSpeed != null
- ? ` ${Math.floor(ranking[currentLangIndex].myRank.typingSpeed)}`
- : " - "
- }타
-
- )}
-
-
-
+
+

+
+
+ {ranking[currentLangIndex]?.top10?.[2]?.nickname || "없음"}
+
+
+ {ranking[currentLangIndex]?.top10?.[2]?.typingSpeed != null
+ ? Math.floor(
+ ranking[currentLangIndex]?.top10?.[2]?.typingSpeed
+ )
+ : 0}
+ 타
+
+
+
+
+
+
+ {Array(7)
+ .fill(0)
+ .map((_, idx) => {
+ const nickname =
+ ranking[currentLangIndex]?.top10?.[idx + 3]?.nickname ||
+ "없음";
+ const speed =
+ ranking[currentLangIndex]?.top10?.[idx + 3]?.typingSpeed || 0;
+ return (
+
+ {idx + 4}. {nickname} ({Math.floor(speed)})
+
+ );
+ })}
+ {userType !== "guest" && (
+
+ 내 등수:{" "}
+ {ranking[currentLangIndex]?.myRank?.rank != null
+ ? ` ${Math.floor(ranking[currentLangIndex].myRank.rank)}`
+ : " - "}
+ 등
+ {ranking[currentLangIndex]?.myRank?.typingSpeed != null
+ ? ` ${Math.floor(ranking[currentLangIndex].myRank.typingSpeed)}`
+ : " - "}
+ 타
+
+ )}
+
- )
-
-}
+
+
+ );
+};
export default Ranking;
diff --git a/src/pages/store/StorePage.tsx b/src/pages/store/StorePage.tsx
index d3f4ce5..7892a88 100644
--- a/src/pages/store/StorePage.tsx
+++ b/src/pages/store/StorePage.tsx
@@ -1,9 +1,10 @@
-import React, { useState, useEffect } from "react";
+import { useState, useEffect, type FC } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@apollo/client/react";
import { Box } from "../../../styled-system/jsx";
import { usePayment } from "../../features/payment/hooks/usePayment";
import { GET_USER_PROFILE } from "../../features/user/graphql/queries";
+import { GET_PRODUCTS } from "@/features/products/graphql/queries";
import type { GetUserProfileData } from "../../features/user/types/follow-types";
import multibg from "@/assets/images/multi_background.png";
import Header from "../../components/common/Header";
@@ -19,7 +20,7 @@ interface PurchaseOption {
displayText: string;
}
-const StorePage: React.FC = () => {
+const StorePage: FC = () => {
const navigate = useNavigate();
const { requestPayment, error, isProcessing, clearError } = usePayment();
@@ -60,6 +61,20 @@ const StorePage: React.FC = () => {
},
];
+ // Load products
+ //const { data: productsData } = useQuery(GET_PRODUCTS, {
+ // variables: { page: 1, limit: 10 },
+ //});
+ //const purchaseOptions: PurchaseOption[] = (
+ // (productsData as any)?.products?.items || []
+ //).map((p: any) => ({
+ // id: p.id,
+ // amount: p.amount,
+ // bonus: p.bonus,
+ // price: p.price,
+ // displayText: `⭐ ${p.amount.toLocaleString()}${p.bonus ? ` + ${p.bonus.toLocaleString()}` : ""}`,
+ //}));
+
// Get user data (including wallet balance)
const { data: userData, loading } =
useQuery(GET_USER_PROFILE);
@@ -152,9 +167,7 @@ const StorePage: React.FC = () => {
};
return (
- messageMap[errorCode] ||
- originalMessage ||
- "Oops! Something went wrong"
+ messageMap[errorCode] || originalMessage || "Oops! Something went wrong"
);
};
@@ -178,7 +191,7 @@ const StorePage: React.FC = () => {
}, [navigate]);
// Purchase button component
- const PurchaseButton: React.FC<{
+ const PurchaseButton: FC<{
option: PurchaseOption;
onPurchase: (option: PurchaseOption) => void;
disabled: boolean;
@@ -296,27 +309,51 @@ const StorePage: React.FC = () => {
>
Store
- ) => {
- e.currentTarget.style.backgroundColor =
- "rgba(255, 255, 255, 0.1)";
- }}
- onMouseLeave={(e: React.MouseEvent) => {
- e.currentTarget.style.backgroundColor = "transparent";
- }}
- >
- ×
-
+
+
+ ) => {
+ e.currentTarget.style.backgroundColor =
+ "rgba(255, 255, 255, 0.1)";
+ }}
+ onMouseLeave={(e: React.MouseEvent) => {
+ e.currentTarget.style.backgroundColor = "transparent";
+ }}
+ >
+ ×
+
+
{/* Currency Icon */}
diff --git a/src/pages/wallet/WalletHistoryPage.tsx b/src/pages/wallet/WalletHistoryPage.tsx
new file mode 100644
index 0000000..3893413
--- /dev/null
+++ b/src/pages/wallet/WalletHistoryPage.tsx
@@ -0,0 +1,405 @@
+import { useState, useMemo, useEffect, useRef, type FC } from "react";
+import { useQuery } from "@apollo/client/react";
+import { Box } from "../../../styled-system/jsx";
+import multibg from "@/assets/images/multi_background.png";
+import Header from "@/components/common/Header";
+import { GET_WALLET_TRANSACTIONS } from "@/features/wallet/graphql/queries";
+
+type TxType = "ALL" | "PAYMENT" | "GRANT" | "REFUND" | "PURCHASE";
+
+const WalletHistoryPage: FC = () => {
+ const [type, setType] = useState("ALL");
+ const [from, setFrom] = useState("");
+ const [to, setTo] = useState("");
+ const [page, setPage] = useState(1);
+
+ const variables = useMemo(
+ () => ({
+ page,
+ limit: 20,
+ type: type === "ALL" ? undefined : type,
+ from: from || undefined,
+ to: to || undefined,
+ }),
+ [page, type, from, to]
+ );
+
+ const { data, loading, error, refetch } = useQuery(GET_WALLET_TRANSACTIONS, {
+ variables,
+ notifyOnNetworkStatusChange: true,
+ });
+
+ const [allItems, setAllItems] = useState([]);
+ const pageInfo = (data as any)?.walletTransactions?.pageInfo;
+ const hasNextPage: boolean = !!pageInfo?.hasNextPage;
+
+ // Reset accumulated list when filters change (page resets to 1 via handlers)
+ useEffect(() => {
+ setAllItems([]);
+ }, [type, from, to]);
+
+ // Append or replace items on data load
+ useEffect(() => {
+ const incoming = (data as any)?.walletTransactions?.items || [];
+ setAllItems((prev) => (page === 1 ? incoming : [...prev, ...incoming]));
+ }, [data, page]);
+
+ // Infinite scroll sentinel
+ const sentinelRef = useRef(null);
+ useEffect(() => {
+ const node = sentinelRef.current;
+ if (!node) return;
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries[0]?.isIntersecting && !loading && hasNextPage) {
+ setPage((p) => p + 1);
+ }
+ },
+ { root: null, rootMargin: "200px", threshold: 0 }
+ );
+ observer.observe(node);
+ return () => observer.disconnect();
+ }, [loading, hasNextPage]);
+
+ const isToday = (d: Date) => {
+ const now = new Date();
+ return (
+ d.getFullYear() === now.getFullYear() &&
+ d.getMonth() === now.getMonth() &&
+ d.getDate() === now.getDate()
+ );
+ };
+
+ const isYesterday = (d: Date) => {
+ const y = new Date();
+ y.setDate(y.getDate() - 1);
+ return (
+ d.getFullYear() === y.getFullYear() &&
+ d.getMonth() === y.getMonth() &&
+ d.getDate() === y.getDate()
+ );
+ };
+
+ const formatDateHeading = (iso: string) => {
+ const dt = new Date(iso);
+ if (isToday(dt)) return "NEWEST";
+ if (isYesterday(dt)) return "YESTERDAY";
+ return dt.toLocaleDateString(undefined, {
+ day: "2-digit",
+ month: "short",
+ year: "numeric",
+ });
+ };
+
+ const grouped: Record = {};
+ for (const tx of allItems) {
+ const k = formatDateHeading(tx.createdAt);
+ if (!grouped[k]) grouped[k] = [];
+ grouped[k].push(tx);
+ }
+
+ return (
+
+
+ {}}
+ onShowSetting={() => {}}
+ onShowRanking={() => {}}
+ />
+
+
+
+
+
+
+
+ History
+
+
+
+
+
+ {["ALL", "PAYMENT", "GRANT", "PURCHASE", "REFUND"].map((t) => (
+
+ ))}
+
+ {
+ setPage(1);
+ setFrom(e.target.value);
+ }}
+ style={{
+ padding: "8px 8px",
+ border: "1px solid #D1D5DB",
+ borderRadius: 6,
+ backgroundColor: "#FFFFFF",
+ color: "#111827",
+ }}
+ />
+ {
+ setPage(1);
+ setTo(e.target.value);
+ }}
+ style={{
+ padding: "8px 8px",
+ border: "1px solid #D1D5DB",
+ borderRadius: 6,
+ backgroundColor: "#FFFFFF",
+ color: "#111827",
+ }}
+ />
+
+
+
+
+
+
+ {error && (
+
+ Failed to load
+
+ )}
+
+ {Object.keys(grouped).map((section) => (
+
+
+ {section}
+
+ {grouped[section].map((tx: any) => {
+ const amt = tx.amount as number;
+ const amountColor =
+ amt > 0 ? "#22C55E" : amt < 0 ? "#EF4444" : "#94A3B8";
+ return (
+
+
+
+
+
+ {tx.description || "Unknown"}
+ {tx.type === "REFUND" && (
+
+ Refund
+
+ )}
+
+
+ {new Date(tx.createdAt).toLocaleString()}
+
+
+
+
+
+ {amt > 0 ? `+${amt}` : `${amt}`}
+
+
+
+ );
+ })}
+
+ ))}
+
+ {loading && page === 1 && (
+
Loading...
+ )}
+ {!loading && allItems.length === 0 && (
+
No transactions
+ )}
+
+ {loading && page > 1 && (
+
+ Loading more...
+
+ )}
+
+
+ {/* Pagination controls removed in infinite scroll mode */}
+
+
+
+
+ );
+};
+
+export default WalletHistoryPage;