diff --git a/README.md b/README.md index 616b1c0..4868ad7 100644 --- a/README.md +++ b/README.md @@ -120,17 +120,17 @@ #### 2.2 Payment Management (PF) -| No. | Requirement Name | Requirement Details | Priority | -| ---- | ------------------------------- | ---------------------------------------------- | -------- | -| PF01 | Prepare Payment | Prepare payment using PortOne API | Required | -| PF02 | Verify Payment & Grant Currency | Verify payment and grant in-game currency | Required | -| PF03 | Receive PortOne Webhook | Receive re-verification webhook from PortOne | Required | -| PF04 | View Purchase History | View user's own purchase history | Required | -| PF05 | View Purchase Details | View detailed purchase information | Required | -| PF06 | Request Refund | Request refund for purchased currency | Required | -| PF07 | View Refund History | View user's refund history | Required | -| PF08 | Check Refund Eligibility | Check if purchase is eligible for refund | Required | -| PF09 | Payment Failure Notification | Alert users of payment failures (errors, etc.) | Required | +| No. | Requirement Name | Requirement Details | Priority | +| ---- | ------------------------------ | ---------------------------------------------- | -------- | +| PF01 | Prepare Payment | Prepare payment using PortOne API | Required | +| PF02 | Verify Payment & lang Currency | Verify payment and lang in-game currency | Required | +| PF03 | Receive PortOne Webhook | Receive re-verification webhook from PortOne | Required | +| PF04 | View Purchase History | View user's own purchase history | Required | +| PF05 | View Purchase Details | View detailed purchase information | Required | +| PF06 | Request Refund | Request refund for purchased currency | Required | +| PF07 | View Refund History | View user's refund history | Required | +| PF08 | Check Refund Eligibility | Check if purchase is eligible for refund | Required | +| PF09 | Payment Failure Notification | Alert users of payment failures (errors, etc.) | Required | **Key Features**: diff --git a/src/App.tsx b/src/App.tsx index fbab72e..c28ec0c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -22,6 +22,7 @@ import StorePurchaseSuccessPage from "./pages/store/StorePurchaseSuccessPage"; import StorePurchaseFailurePage from "./pages/store/StorePurchaseFailurePage"; import FollowerPage from "./pages/follower/FollowerPage"; import FollowingPage from "./pages/following/FollowingPage"; +import WalletHistoryPage from "./pages/wallet/WalletHistoryPage"; function App() { const userType = useAuthStore((state: any) => state.user?.userType); @@ -71,6 +72,7 @@ function App() { } /> } /> } /> + } /> {/* 공개 라우트로 전환: 메인 페이지는 인증 없이 접근 가능 */} } /> diff --git a/src/features/products/graphql/queries.ts b/src/features/products/graphql/queries.ts new file mode 100644 index 0000000..00a9b24 --- /dev/null +++ b/src/features/products/graphql/queries.ts @@ -0,0 +1,37 @@ +import { gql } from "@apollo/client"; + +export const GET_PRODUCTS = gql` + query GetProducts($page: Int, $limit: Int) { + products(page: $page, limit: $limit) { + items { + id + name + description + price + bonus + amount + currency + } + pageInfo { + page + limit + total + totalPages + } + } + } +`; + +export const GET_PRODUCT = gql` + query GetProduct($id: ID!) { + product(id: $id) { + id + name + description + price + bonus + amount + currency + } + } +`; diff --git a/src/features/ranking/graphql/queries.ts b/src/features/ranking/graphql/queries.ts new file mode 100644 index 0000000..e11c548 --- /dev/null +++ b/src/features/ranking/graphql/queries.ts @@ -0,0 +1,20 @@ +import { gql } from "@apollo/client"; + +export const GET_RANKINGS = gql` + query GetRankings($game: String!, $period: String!, $limit: Int) { + rankings(game: $game, period: $period, limit: $limit) { + items { + userId + nickname + score + typingSpeed + rank + } + myRank { + rank + score + typingSpeed + } + } + } +`; diff --git a/src/features/wallet/graphql/queries.ts b/src/features/wallet/graphql/queries.ts new file mode 100644 index 0000000..5bcfc93 --- /dev/null +++ b/src/features/wallet/graphql/queries.ts @@ -0,0 +1,35 @@ +import { gql } from "@apollo/client"; + +export const GET_WALLET_TRANSACTIONS = gql` + query GetWalletTransactions( + $page: Int + $limit: Int + $type: String + $from: String + $to: String + ) { + walletTransactions( + page: $page + limit: $limit + type: $type + from: $from + to: $to + ) { + items { + id + type + amount + balanceAfter + createdAt + description + } + pageInfo { + page + limit + total + totalPages + hasNextPage + } + } + } +`; diff --git a/src/pages/follower/FollowerPage.test.tsx b/src/pages/follower/FollowerPage.test.tsx index 244b65e..95ccf6b 100644 --- a/src/pages/follower/FollowerPage.test.tsx +++ b/src/pages/follower/FollowerPage.test.tsx @@ -1,10 +1,11 @@ import React from "react"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import "@testing-library/jest-dom"; import { MockedProvider } from "@apollo/client/testing/react"; import { BrowserRouter } from "react-router-dom"; -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import FollowerPage from "./FollowerPage"; -import { GET_FOLLOWERS } from "@/features/user/graphql/follow-operations"; +import { GET_FOLLOWERS } from "../../features/user/graphql/follow-operations"; // Mock the Header component vi.mock("@/components/common/Header", () => ({ @@ -78,6 +79,32 @@ const mocks = [ data: mockFollowersData, }, }, + // Search query: "Esthera" + { + request: { + query: GET_FOLLOWERS, + variables: { + page: 1, + limit: 20, + search: "Esthera", + }, + }, + result: { + data: { + followers: { + items: [mockFollowersData.followers.items[0]], + pageInfo: { + page: 1, + limit: 20, + total: 1, + totalPages: 1, + hasNextPage: false, + hasPreviousPage: false, + }, + }, + }, + }, + }, ]; describe("FollowerPage", () => { @@ -87,6 +114,7 @@ describe("FollowerPage", () => { it("renders follower page with modal", async () => { render( + // @ts-expect-error addTypename is supported at runtime in our Apollo version @@ -106,12 +134,13 @@ describe("FollowerPage", () => { expect(screen.getByPlaceholderText("Search")).toBeInTheDocument(); // Check if follower 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 +163,7 @@ describe("FollowerPage", () => { it("handles close button click", async () => { render( + // @ts-expect-error addTypename is supported at runtime in our Apollo version @@ -156,6 +186,7 @@ describe("FollowerPage", () => { 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/follower/FollowerPage.tsx b/src/pages/follower/FollowerPage.tsx index 5ea5ce5..facef1d 100644 --- a/src/pages/follower/FollowerPage.tsx +++ b/src/pages/follower/FollowerPage.tsx @@ -1,21 +1,21 @@ import React, { useState, useCallback } from "react"; import { useNavigate } from "react-router-dom"; import { Box } from "../../../styled-system/jsx"; -import board1bg from "@/assets/images/board1.jpg"; import Header from "@/components/common/Header"; import FollowerModal from "./components/FollowerModal"; -import { mockFollowers } from "@/features/user/mock-data"; import multibg from "@/assets/images/multi_background.png"; +import { useFollowers } from "./hooks/useFollowers"; const FollowerPage: React.FC = () => { const navigate = useNavigate(); const [searchQuery, setSearchQuery] = useState(""); - // Filter followers based on search query - const filteredFollowers = mockFollowers.filter( - (follower) => - follower.name.toLowerCase().includes(searchQuery.toLowerCase()) || - follower.email.toLowerCase().includes(searchQuery.toLowerCase()) + const { data, loading, error, hasNextPage, loadMore, refetch } = useFollowers( + { + page: 1, + search: searchQuery, + limit: 20, + } ); const handleSearch = useCallback((query: string) => { @@ -80,15 +80,15 @@ const FollowerPage: React.FC = () => { > {/* Modal Container */} {}} - hasNextPage={false} + onLoadMore={loadMore} + hasNextPage={hasNextPage} onClose={handleClose} - onRefetch={() => {}} + onRefetch={refetch} /> diff --git a/src/pages/follower/components/UserItem.tsx b/src/pages/follower/components/UserItem.tsx index 2c64980..46c7074 100644 --- a/src/pages/follower/components/UserItem.tsx +++ b/src/pages/follower/components/UserItem.tsx @@ -1,6 +1,11 @@ import React, { useState, useCallback } from "react"; import { css } from "../../../../styled-system/css"; import { FollowerUser } from "@/features/user/types/follow-types"; +import { useMutation } from "@apollo/client/react"; +import { + FOLLOW_USER, + UNFOLLOW_USER, +} from "@/features/user/graphql/follow-operations"; interface UserItemProps { user: FollowerUser; @@ -9,18 +14,26 @@ interface UserItemProps { const UserItem: React.FC = ({ 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)} /> +
+ 오른쪽 +
+ + x 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]} -
- 오른쪽 - -
- - x 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;