From 3f0724e9efe6106e77dff59dbd377505850dbfc2 Mon Sep 17 00:00:00 2001 From: Dobbymin Date: Fri, 14 Nov 2025 17:05:57 +0900 Subject: [PATCH 01/22] =?UTF-8?q?chore:=20shadcn=20dialog=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + pnpm-lock.yaml | 61 ++++++++++++++ src/shared/components/ui/dialog.tsx | 124 ++++++++++++++++++++++++++++ src/shared/components/ui/index.ts | 1 + 4 files changed, 187 insertions(+) create mode 100644 src/shared/components/ui/dialog.tsx diff --git a/package.json b/package.json index 9bcc321..b4923e9 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "dependencies": { "@hookform/resolvers": "^5.2.2", "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 692fdbc..e3c3d32 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@radix-ui/react-avatar': specifier: ^1.1.11 version: 1.1.11(@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-dialog': + specifier: ^1.1.15 + version: 1.1.15(@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-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) @@ -591,6 +594,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-dialog@1.1.15': + resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} + 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-direction@1.1.1': resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} peerDependencies: @@ -683,6 +699,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + 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-primitive@2.1.3': resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} peerDependencies: @@ -3334,6 +3363,28 @@ snapshots: optionalDependencies: '@types/react': 19.2.2 + '@radix-ui/react-dialog@1.1.15(@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/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@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-focus-guards': 1.1.3(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-focus-scope': 1.1.7(@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-id': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-portal': 1.1.9(@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-presence': 1.1.5(@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-primitive': 2.1.3(@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-slot': 1.2.3(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0) + aria-hidden: 1.2.6 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + react-remove-scroll: 2.7.1(@types/react@19.2.2)(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@radix-ui/react-direction@1.1.1(@types/react@19.2.2)(react@19.2.0)': dependencies: react: 19.2.0 @@ -3414,6 +3465,16 @@ snapshots: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@radix-ui/react-presence@1.1.5(@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-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(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-primitive@2.1.3(@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-slot': 1.2.3(@types/react@19.2.2)(react@19.2.0) diff --git a/src/shared/components/ui/dialog.tsx b/src/shared/components/ui/dialog.tsx new file mode 100644 index 0000000..3548cab --- /dev/null +++ b/src/shared/components/ui/dialog.tsx @@ -0,0 +1,124 @@ +"use client"; + +import * as React from "react"; + +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { XIcon } from "lucide-react"; + +import { cn } from "../../utils"; + +function Dialog({ ...props }: React.ComponentProps) { + return ; +} + +function DialogTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function DialogPortal({ ...props }: React.ComponentProps) { + return ; +} + +function DialogClose({ ...props }: React.ComponentProps) { + return ; +} + +function DialogOverlay({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean; +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ); +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DialogTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function DialogDescription({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +}; diff --git a/src/shared/components/ui/index.ts b/src/shared/components/ui/index.ts index 5b283a4..43ba4b7 100644 --- a/src/shared/components/ui/index.ts +++ b/src/shared/components/ui/index.ts @@ -2,6 +2,7 @@ export * from "./avatar"; export * from "./button"; export * from "./card"; export * from "./chart"; +export * from "./dialog"; export * from "./form"; export * from "./input-group"; export * from "./input"; From fdbc52bb872181e8beebd90843854f86a3d8e077 Mon Sep 17 00:00:00 2001 From: Dobbymin Date: Fri, 14 Nov 2025 17:07:45 +0900 Subject: [PATCH 02/22] =?UTF-8?q?fix:=20table=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=ED=8F=AC=EB=A7=B7=ED=8C=85=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/components/ui/table.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/shared/components/ui/table.tsx b/src/shared/components/ui/table.tsx index 1b94527..bfeb696 100644 --- a/src/shared/components/ui/table.tsx +++ b/src/shared/components/ui/table.tsx @@ -48,7 +48,7 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) { [role=checkbox]]:translate-y-[2px]", + "h-10 p-2 text-left align-middle text-xs font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 *:[[role=checkbox]]:translate-y-0.5", className, )} {...props} @@ -61,7 +61,7 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) { [role=checkbox]]:translate-y-[2px]", + "px-2 py-3 align-middle font-mono whitespace-nowrap text-text-dark [&:has([role=checkbox])]:pr-0 *:[[role=checkbox]]:translate-y-0.5", className, )} {...props} From 4cc62b289525694061c713607840b2e33b2723d3 Mon Sep 17 00:00:00 2001 From: Dobbymin Date: Mon, 17 Nov 2025 13:46:43 +0900 Subject: [PATCH 03/22] =?UTF-8?q?fix:=20=EB=A7=88=EC=BC=93=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20api=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/market/route.ts | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/app/api/market/route.ts b/src/app/api/market/route.ts index 35b5f78..bc8d47f 100644 --- a/src/app/api/market/route.ts +++ b/src/app/api/market/route.ts @@ -1,9 +1,25 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; -import { marketInfoHandler } from "@/entities"; +import { marketAllAPI, marketInfoHandler } from "@/entities"; -export async function GET() { +/** + * 마켓 정보 조회 API + * GET /api/market - 마켓 정보 + 시세 (기본) + * GET /api/market?type=list - 마켓 목록만 조회 (KRW 마켓) + */ +export async function GET(request: NextRequest) { try { + const searchParams = request.nextUrl.searchParams; + const type = searchParams.get("type"); + + // type=list인 경우 마켓 목록만 반환 + if (type === "list") { + const markets = await marketAllAPI(); + const krwMarkets = markets.filter((market) => market.market.startsWith("KRW-")); + return NextResponse.json({ success: true, data: krwMarkets }, { status: 200 }); + } + + // 기본: 마켓 정보 + 시세 const data = await marketInfoHandler(); return NextResponse.json({ status: "success", data }); } catch (error) { From 0fd653800d243d052e45a7be8448162c5e5ac6e5 Mon Sep 17 00:00:00 2001 From: Dobbymin Date: Mon, 17 Nov 2025 13:47:10 +0900 Subject: [PATCH 04/22] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20api=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/model/apis/create-profile.ts | 40 +++++++++++++++++++ src/entities/user/model/apis/index.ts | 1 + src/entities/user/model/index.ts | 1 + 3 files changed, 42 insertions(+) diff --git a/src/entities/user/model/apis/create-profile.ts b/src/entities/user/model/apis/create-profile.ts index e69de29..777cbc5 100644 --- a/src/entities/user/model/apis/create-profile.ts +++ b/src/entities/user/model/apis/create-profile.ts @@ -0,0 +1,40 @@ +import { createClient } from "@/shared/utils/supabase/client"; + +type CreateProfileParams = { + id: string; // auth.users id (uuid) + email: string; + userName: string; // 로그인 아이디를 사용자명으로 기본 설정 + nickname?: string; // 기본값: userName + avatarUrl?: string | null; // 기본값: null +}; + +/** + * 회원 가입 직후 profiles 테이블에 사용자 프로필을 생성합니다. + * - id는 auth.users의 id와 동일하게 저장합니다. + * - nickname은 전달되지 않으면 userName과 동일하게 저장합니다. + */ +export const createProfileAPI = async ({ id, email, userName, nickname, avatarUrl }: CreateProfileParams) => { + const supabase = createClient(); + + const payload = { + id, + email, + user_name: userName, + nickname: nickname ?? userName, + avatar_url: avatarUrl ?? null, + } satisfies { + id: string; + email: string; + user_name: string; + nickname: string; + avatar_url: string | null; + }; + + const { data, error } = await supabase.from("profiles").upsert(payload, { onConflict: "id" }).select("*").single(); + + if (error) { + throw new Error(`Create profile failed: ${error.message}`); + } + + return data; +}; diff --git a/src/entities/user/model/apis/index.ts b/src/entities/user/model/apis/index.ts index db8399a..efb5e1a 100644 --- a/src/entities/user/model/apis/index.ts +++ b/src/entities/user/model/apis/index.ts @@ -1,2 +1,3 @@ export * from "./profile.api"; export * from "./user.api"; +export * from "./create-profile"; diff --git a/src/entities/user/model/index.ts b/src/entities/user/model/index.ts index a211ece..e4acacd 100644 --- a/src/entities/user/model/index.ts +++ b/src/entities/user/model/index.ts @@ -1,2 +1,3 @@ export * from "./types"; export * from "./hooks"; +export * from "./apis"; From 97a765dd7dbfc7dd488934e12a914bfbde3f8e2e Mon Sep 17 00:00:00 2001 From: Dobbymin Date: Mon, 17 Nov 2025 13:48:02 +0900 Subject: [PATCH 05/22] =?UTF-8?q?fix:=20=ED=8C=8C=EC=9D=BC=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../model/apis/{create-profile.ts => create-profile.api.ts} | 0 src/entities/user/model/apis/index.ts | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename src/entities/user/model/apis/{create-profile.ts => create-profile.api.ts} (100%) diff --git a/src/entities/user/model/apis/create-profile.ts b/src/entities/user/model/apis/create-profile.api.ts similarity index 100% rename from src/entities/user/model/apis/create-profile.ts rename to src/entities/user/model/apis/create-profile.api.ts diff --git a/src/entities/user/model/apis/index.ts b/src/entities/user/model/apis/index.ts index efb5e1a..f265e93 100644 --- a/src/entities/user/model/apis/index.ts +++ b/src/entities/user/model/apis/index.ts @@ -1,3 +1,3 @@ export * from "./profile.api"; export * from "./user.api"; -export * from "./create-profile"; +export * from "./create-profile.api"; From 35d80629dc8aef559d71c20622e5b463aded9013 Mon Sep 17 00:00:00 2001 From: Dobbymin Date: Mon, 17 Nov 2025 13:48:22 +0900 Subject: [PATCH 06/22] =?UTF-8?q?feat:=20real=20time=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20hook=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../wallet/hooks/useGetHoldDeposit.ts | 12 ++ .../wallet/hooks/useRealtimeWallet.ts | 111 ++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 src/features/wallet/hooks/useGetHoldDeposit.ts create mode 100644 src/features/wallet/hooks/useRealtimeWallet.ts diff --git a/src/features/wallet/hooks/useGetHoldDeposit.ts b/src/features/wallet/hooks/useGetHoldDeposit.ts new file mode 100644 index 0000000..85b7f02 --- /dev/null +++ b/src/features/wallet/hooks/useGetHoldDeposit.ts @@ -0,0 +1,12 @@ +import { useQuery } from "@tanstack/react-query"; + +import { holdDepositAPI } from "../apis"; + +export const useGetHoldDeposit = () => { + return useQuery({ + queryKey: ["hold-deposit"], + queryFn: holdDepositAPI, + + refetchInterval: 15000, + }); +}; diff --git a/src/features/wallet/hooks/useRealtimeWallet.ts b/src/features/wallet/hooks/useRealtimeWallet.ts new file mode 100644 index 0000000..b1def50 --- /dev/null +++ b/src/features/wallet/hooks/useRealtimeWallet.ts @@ -0,0 +1,111 @@ +"use client"; + +import { useEffect } from "react"; + +import { useQuery } from "@tanstack/react-query"; + +import { getTickerAPI } from "@/features/home/apis"; + +import { getWalletAPI } from "../apis"; +import { getTradesAPI } from "../apis/trades.api"; +import { useWalletStore } from "../store"; +import { computeCostBasis } from "../utils/aggregate-trades"; +import { createClient as createSupabaseBrowserClient } from "@/shared/utils/supabase"; + +/** + * 지갑과 시세를 실시간으로 동기화하는 hook + */ +export const useRealtimeWallet = () => { + const { setWallets, setTickerMap, setCostBasisMap } = useWalletStore(); + + // 지갑 정보 조회 + const { data: walletData, refetch: refetchWallet } = useQuery({ + queryKey: ["wallet"], + queryFn: () => getWalletAPI(), + }); + + // 지갑에 있는 모든 코인의 마켓 ID 추출 (KRW 제외) + const markets = walletData?.data + ?.filter((w) => w.coin_id !== "KRW") + .map((w) => w.coin_id) + .join(","); + + // 실시간 시세 조회 (1초마다) + const { data: tickerData } = useQuery({ + queryKey: ["ticker", markets], + queryFn: () => getTickerAPI(markets || ""), + refetchInterval: 1000, + enabled: !!markets, // markets가 있을 때만 실행 + }); + + // 거래 내역 조회 (1초마다 - 주문 시 즉시 반영) + const { data: tradesData } = useQuery({ + queryKey: ["trades"], + queryFn: () => getTradesAPI(), + refetchInterval: 1000, + }); + + // 지갑 데이터를 store에 동기화 + useEffect(() => { + if (walletData?.data) { + setWallets(walletData.data); + } + }, [walletData, setWallets]); + + // 시세 데이터를 store에 동기화 + useEffect(() => { + if (tickerData?.data) { + const map: Record = {}; + tickerData.data.forEach((ticker) => { + map[ticker.market] = ticker; + }); + setTickerMap(map); + } + }, [tickerData, setTickerMap]); + + // 거래내역을 평균매수가/원가 맵으로 변환하여 store 동기화 + useEffect(() => { + if (tradesData?.data) { + const map = computeCostBasis(tradesData.data); + setCostBasisMap(map); + } + }, [tradesData, setCostBasisMap]); + + // Supabase Realtime 구독: wallet 테이블의 사용자 레코드 변경 시 즉시 반영 + useEffect(() => { + const supabase = createSupabaseBrowserClient(); + + let subscribed = true; + let channel: ReturnType | null = null; + + const setup = async () => { + const { data: auth } = await supabase.auth.getUser(); + const userId = auth.user?.id; + if (!userId || !subscribed) return; + + channel = supabase + .channel(`wallet-changes-${userId}`) + .on( + "postgres_changes", + { event: "*", schema: "public", table: "wallet", filter: `user_id=eq.${userId}` }, + () => { + // 변경 감지 시 최신 지갑 데이터로 갱신 + refetchWallet(); + }, + ) + .subscribe(); + }; + + setup(); + + return () => { + subscribed = false; + channel?.unsubscribe(); + }; + }, [refetchWallet]); + + return { + wallets: walletData?.data || [], + refetchWallet, + }; +}; From 4f970c219a25e44512d7db73ba679601d97a18a7 Mon Sep 17 00:00:00 2001 From: Dobbymin Date: Mon, 17 Nov 2025 13:49:19 +0900 Subject: [PATCH 07/22] =?UTF-8?q?feat:=20order=20table=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/common/field/AmountField.tsx | 17 +++++++-- .../components/common/field/CoinSelect.tsx | 35 +++++++++++++++++++ .../components/common/field/PriceField.tsx | 17 +++++++-- .../components/common/field/TotalField.tsx | 17 +++++++-- .../components/features/order/OrderTable.tsx | 1 + 5 files changed, 81 insertions(+), 6 deletions(-) create mode 100644 src/features/home/components/common/field/CoinSelect.tsx diff --git a/src/features/home/components/common/field/AmountField.tsx b/src/features/home/components/common/field/AmountField.tsx index 8f90a08..847906a 100644 --- a/src/features/home/components/common/field/AmountField.tsx +++ b/src/features/home/components/common/field/AmountField.tsx @@ -4,7 +4,11 @@ import { OrderSchemaType } from "@/entities"; import { FormControl, FormField, FormItem, FormLabel, FormMessage, Input } from "@/shared"; import { useFormContext } from "react-hook-form"; -export const AmountField = () => { +type AmountFieldProps = { + onValueChange?: (value: number) => void; +}; + +export const AmountField = ({ onValueChange }: AmountFieldProps) => { const form = useFormContext(); return ( {

(WAXP)

- + { + const value = parseFloat(e.target.value) || 0; + field.onChange(value); + onValueChange?.(value); + }} + /> diff --git a/src/features/home/components/common/field/CoinSelect.tsx b/src/features/home/components/common/field/CoinSelect.tsx new file mode 100644 index 0000000..46705da --- /dev/null +++ b/src/features/home/components/common/field/CoinSelect.tsx @@ -0,0 +1,35 @@ +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/shared/components/ui/select"; + +type CoinSelectProps = { + value: string; + onChange: (value: string) => void; + markets: { market: string; korean_name: string; english_name: string }[]; +}; + +export const CoinSelect = ({ value, onChange, markets }: CoinSelectProps) => { + return ( + + ); +}; diff --git a/src/features/home/components/common/field/PriceField.tsx b/src/features/home/components/common/field/PriceField.tsx index 8b86e5c..969c378 100644 --- a/src/features/home/components/common/field/PriceField.tsx +++ b/src/features/home/components/common/field/PriceField.tsx @@ -4,7 +4,11 @@ import { OrderSchemaType } from "@/entities"; import { FormControl, FormField, FormItem, FormLabel, FormMessage, Input } from "@/shared"; import { useFormContext } from "react-hook-form"; -export const PriceField = () => { +type PriceFieldProps = { + onValueChange?: (value: number) => void; +}; + +export const PriceField = ({ onValueChange }: PriceFieldProps) => { const form = useFormContext(); return ( {

(KRW)

- + { + const value = parseFloat(e.target.value) || 0; + field.onChange(value); + onValueChange?.(value); + }} + /> diff --git a/src/features/home/components/common/field/TotalField.tsx b/src/features/home/components/common/field/TotalField.tsx index 93dd794..e5e7669 100644 --- a/src/features/home/components/common/field/TotalField.tsx +++ b/src/features/home/components/common/field/TotalField.tsx @@ -4,7 +4,11 @@ import { OrderSchemaType } from "@/entities"; import { FormControl, FormField, FormItem, FormLabel, FormMessage, Input } from "@/shared"; import { useFormContext } from "react-hook-form"; -export const TotalField = () => { +type TotalFieldProps = { + onValueChange?: (value: number) => void; +}; + +export const TotalField = ({ onValueChange }: TotalFieldProps) => { const form = useFormContext(); return ( {

(KRW)

- + { + const value = parseFloat(e.target.value) || 0; + field.onChange(value); + onValueChange?.(value); + }} + /> diff --git a/src/features/home/components/features/order/OrderTable.tsx b/src/features/home/components/features/order/OrderTable.tsx index f6f3366..5d14bd2 100644 --- a/src/features/home/components/features/order/OrderTable.tsx +++ b/src/features/home/components/features/order/OrderTable.tsx @@ -25,6 +25,7 @@ export const OrderTable = () => { // 즉시 실행 syncData(); + // TODO: 구현 후 다시 1000 으로 수정 // 1초마다 동기화 const interval = setInterval(() => { syncData(); From d5126dfbc5bd3900728c999cd775d016d0ba157e Mon Sep 17 00:00:00 2001 From: Dobbymin Date: Mon, 17 Nov 2025 13:49:43 +0900 Subject: [PATCH 08/22] =?UTF-8?q?feat:=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EC=8B=9C=EC=84=B8=20=EB=B0=98=EC=98=81=20route=20handler=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/ticker/route.ts | 27 ++++++ src/app/api/trade/route.ts | 137 +++++++++++++++++++++++++++- src/app/api/wallet/deposit/route.ts | 136 +++++++++++++++++++++++++++ 3 files changed, 296 insertions(+), 4 deletions(-) create mode 100644 src/app/api/ticker/route.ts create mode 100644 src/app/api/wallet/deposit/route.ts diff --git a/src/app/api/ticker/route.ts b/src/app/api/ticker/route.ts new file mode 100644 index 0000000..e814624 --- /dev/null +++ b/src/app/api/ticker/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from "next/server"; + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const markets = searchParams.get("markets") || "KRW-WAXP"; + + // Upbit API로 현재가 조회 + const response = await fetch(`https://api.upbit.com/v1/ticker?markets=${markets}`, { + headers: { + Accept: "application/json", + }, + cache: "no-store", + }); + + if (!response.ok) { + throw new Error("Failed to fetch ticker from Upbit"); + } + + const data = await response.json(); + + return NextResponse.json({ success: true, data }, { status: 200 }); + } catch (error) { + console.error("Ticker API error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/trade/route.ts b/src/app/api/trade/route.ts index 4a4e788..eeef0e3 100644 --- a/src/app/api/trade/route.ts +++ b/src/app/api/trade/route.ts @@ -1,10 +1,11 @@ +import { cookies } from "next/headers"; import { NextResponse } from "next/server"; -import { createClient } from "@/shared"; +import { createClient as createServerClient } from "@/shared/utils/supabase/server"; export async function POST(request: Request) { try { - const supabase = await createClient(); + const supabase = createServerClient(cookies()); const { data: userData, error: userError } = await supabase.auth.getUser(); if (userError || !userData.user) { @@ -26,12 +27,140 @@ export async function POST(request: Request) { // 총 거래 금액 계산 const total_krw = price * amount; + const userId = userData.user.id; + + // 매수/매도에 따른 지갑 잔고 확인 및 업데이트 + if (trade_type === "buy") { + // 매수: KRW 차감, 코인 증액 + // 1. KRW 잔고 확인 + const { data: krwWallet } = await supabase + .from("wallet") + .select("*") + .eq("user_id", userId) + .eq("coin_id", "KRW") + .single(); + + if (!krwWallet || krwWallet.amount < total_krw) { + return NextResponse.json({ error: "KRW 잔고가 부족합니다." }, { status: 400 }); + } + + // 2. KRW 차감 + const { error: krwUpdateError } = await supabase + .from("wallet") + .update({ amount: krwWallet.amount - total_krw }) + .eq("id", krwWallet.id); + + if (krwUpdateError) { + console.error("KRW update error:", krwUpdateError); + return NextResponse.json({ error: krwUpdateError.message }, { status: 500 }); + } + + // 3. 코인 증액 (없으면 생성) + const { data: coinWallet } = await supabase + .from("wallet") + .select("*") + .eq("user_id", userId) + .eq("coin_id", coin_id) + .single(); + + if (coinWallet) { + // 기존 코인 지갑 증액 + const { error: coinUpdateError } = await supabase + .from("wallet") + .update({ amount: coinWallet.amount + amount }) + .eq("id", coinWallet.id); + + if (coinUpdateError) { + console.error("Coin update error:", coinUpdateError); + return NextResponse.json({ error: coinUpdateError.message }, { status: 500 }); + } + } else { + // 새 코인 지갑 생성 + // coins 테이블에 코인이 없으면 생성 + const { error: coinSelectError } = await supabase + .from("coins") + .select("market_id") + .eq("market_id", coin_id) + .single(); + + if ((coinSelectError as { code?: string }).code === "PGRST116") { + const { error: coinInsertError } = await supabase + .from("coins") + .insert({ market_id: coin_id, korean_name: coin_id, english_name: coin_id }); + if (coinInsertError) { + console.error("Coin insert error:", coinInsertError); + return NextResponse.json({ error: coinInsertError.message }, { status: 500 }); + } + } + + const { error: walletInsertError } = await supabase.from("wallet").insert({ user_id: userId, coin_id, amount }); + + if (walletInsertError) { + console.error("Wallet insert error:", walletInsertError); + return NextResponse.json({ error: walletInsertError.message }, { status: 500 }); + } + } + } else if (trade_type === "sell") { + // 매도: 코인 차감, KRW 증액 + // 1. 코인 잔고 확인 + const { data: coinWallet } = await supabase + .from("wallet") + .select("*") + .eq("user_id", userId) + .eq("coin_id", coin_id) + .single(); + + if (!coinWallet || coinWallet.amount < amount) { + return NextResponse.json({ error: "코인 잔고가 부족합니다." }, { status: 400 }); + } + + // 2. 코인 차감 + const { error: coinUpdateError } = await supabase + .from("wallet") + .update({ amount: coinWallet.amount - amount }) + .eq("id", coinWallet.id); + + if (coinUpdateError) { + console.error("Coin update error:", coinUpdateError); + return NextResponse.json({ error: coinUpdateError.message }, { status: 500 }); + } + + // 3. KRW 증액 + const { data: krwWallet } = await supabase + .from("wallet") + .select("*") + .eq("user_id", userId) + .eq("coin_id", "KRW") + .single(); + + if (krwWallet) { + const { error: krwUpdateError } = await supabase + .from("wallet") + .update({ amount: krwWallet.amount + total_krw }) + .eq("id", krwWallet.id); + + if (krwUpdateError) { + console.error("KRW update error:", krwUpdateError); + return NextResponse.json({ error: krwUpdateError.message }, { status: 500 }); + } + } else { + // KRW 지갑이 없으면 생성 (일반적으로 있어야 하지만 예외 처리) + const { error: krwInsertError } = await supabase + .from("wallet") + .insert({ user_id: userId, coin_id: "KRW", amount: total_krw }); + + if (krwInsertError) { + console.error("KRW insert error:", krwInsertError); + return NextResponse.json({ error: krwInsertError.message }, { status: 500 }); + } + } + } // 거래 데이터 삽입 const { data: tradeData, error: tradeError } = await supabase .from("trade") .insert({ - user_id: userData.user.id, + user_id: userId, coin_id, price, amount, @@ -57,7 +186,7 @@ export async function POST(request: Request) { // 사용자의 거래 내역 조회 export async function GET(request: Request) { try { - const supabase = await createClient(); + const supabase = createServerClient(cookies()); const { data: userData, error: userError } = await supabase.auth.getUser(); if (userError || !userData.user) { diff --git a/src/app/api/wallet/deposit/route.ts b/src/app/api/wallet/deposit/route.ts new file mode 100644 index 0000000..3a5619e --- /dev/null +++ b/src/app/api/wallet/deposit/route.ts @@ -0,0 +1,136 @@ +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; + +import { createClient as createServerClient } from "@/shared/utils/supabase/server"; + +export async function POST(request: Request) { + try { + const supabase = createServerClient(cookies()); + const { data: userData, error: userError } = await supabase.auth.getUser(); + + if (userError || !userData.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const { amount } = body; + + // 필수 파라미터 검증 + if (!amount || amount <= 0) { + return NextResponse.json({ error: "Invalid amount" }, { status: 400 }); + } + + const coin_id = "KRW"; // KRW 입금 - coins.market_id를 참조 + + // FK 제약사항 대응: coins 테이블에 KRW가 없으면 생성 + const { error: coinSelectError } = await supabase + .from("coins") + .select("market_id") + .eq("market_id", coin_id) + .single(); + + if (coinSelectError) { + // 데이터 없음(PGRST116)이면 KRW를 생성 시도 + if ((coinSelectError as { code?: string }).code === "PGRST116") { + const { error: coinInsertError } = await supabase + .from("coins") + .insert({ market_id: coin_id, korean_name: "원화", english_name: "Korean Won" }); + if (coinInsertError) { + console.error("KRW coin insert error:", coinInsertError); + return NextResponse.json({ error: coinInsertError.message }, { status: 500 }); + } + } else { + console.error("KRW coin select error:", coinSelectError); + return NextResponse.json({ error: coinSelectError.message }, { status: 500 }); + } + } + + // 기존 KRW 지갑 조회 + const { data: existingWallet, error: walletError } = await supabase + .from("wallet") + .select("*") + .eq("user_id", userData.user.id) + .eq("coin_id", coin_id) + .single(); + + if (walletError && walletError.code !== "PGRST116") { + // PGRST116은 데이터 없음 오류 + console.error("Wallet query error:", walletError); + return NextResponse.json({ error: walletError.message }, { status: 500 }); + } + + let result; + + if (existingWallet) { + // 기존 지갑이 있으면 금액 증가 + const newAmount = existingWallet.amount + amount; + const { data, error } = await supabase + .from("wallet") + .update({ amount: newAmount }) + .eq("id", existingWallet.id) + .select() + .single(); + + if (error) { + console.error("Wallet update error:", error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } + result = data; + } else { + // 새 지갑 생성 + const { data, error } = await supabase + .from("wallet") + .insert({ + user_id: userData.user.id, + coin_id, + amount, + }) + .select() + .single(); + + if (error) { + console.error("Wallet insert error:", error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } + result = data; + } + + return NextResponse.json({ success: true, data: result }, { status: 200 }); + } catch (error) { + console.error("Deposit API error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} + +// 사용자의 지갑 조회 +export async function GET(request: Request) { + try { + const supabase = createServerClient(cookies()); + const { data: userData, error: userError } = await supabase.auth.getUser(); + + if (userError || !userData.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const coin_id = searchParams.get("coin_id"); + + let query = supabase.from("wallet").select("*").eq("user_id", userData.user.id); + + if (coin_id) { + query = query.eq("coin_id", coin_id); + } + + const { data, error } = await query; + + if (error) { + console.error("Wallet query error:", error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + return NextResponse.json({ success: true, data }, { status: 200 }); + } catch (error) { + console.error("Wallet GET API error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} From ca99a96233b2c4733c95dd050cc89f0128097fa7 Mon Sep 17 00:00:00 2001 From: Dobbymin Date: Mon, 17 Nov 2025 13:51:47 +0900 Subject: [PATCH 09/22] =?UTF-8?q?fix:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=ED=8F=BC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/signup/ui/SignupForm.tsx | 30 ++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/src/features/signup/ui/SignupForm.tsx b/src/features/signup/ui/SignupForm.tsx index 66fd441..b7e4115 100644 --- a/src/features/signup/ui/SignupForm.tsx +++ b/src/features/signup/ui/SignupForm.tsx @@ -3,7 +3,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; -import { SignupSchemaType, signupAPI, signupSchema } from "@/entities"; +import { SignupSchemaType, createProfileAPI, signupAPI, signupSchema } from "@/entities"; import { Button, Form, ROUTER_PATH, Spinner } from "@/shared"; import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation } from "@tanstack/react-query"; @@ -25,10 +25,30 @@ export const SignupForm = () => { mode: "onChange", }); - const onSuccess = () => { - toast.success("회원가입 성공!"); - router.push(ROUTER_PATH.LOGIN); - form.reset(); + const onSuccess = async (data: Awaited>) => { + try { + const user = data.user; + const userEmailId = form.getValues("userEmail"); + + if (!user) { + toast.error("회원가입은 되었지만 사용자 정보를 확인할 수 없습니다."); + return; + } + + await createProfileAPI({ + id: user.id, + email: user.email ?? `${userEmailId}@dobbit.com`, + userName: userEmailId, + nickname: userEmailId, + }); + + toast.success("회원가입 성공! 프로필이 생성되었습니다."); + router.push(ROUTER_PATH.LOGIN); + form.reset(); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "프로필 생성 중 오류가 발생했습니다."; + toast.error(message); + } }; const { mutate: signupMutate, isPending } = useMutation({ From b8810b271667d7872dce17cd15bc711ffe030064 Mon Sep 17 00:00:00 2001 From: Dobbymin Date: Mon, 17 Nov 2025 13:52:01 +0900 Subject: [PATCH 10/22] =?UTF-8?q?feat:=20=EC=A3=BC=EB=AC=B8=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20=ED=8F=BC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../home/components/common/field/index.ts | 1 + .../components/features/order/OrderForm.tsx | 117 +++++++++++++++--- 2 files changed, 103 insertions(+), 15 deletions(-) diff --git a/src/features/home/components/common/field/index.ts b/src/features/home/components/common/field/index.ts index c1720e8..0e2bdad 100644 --- a/src/features/home/components/common/field/index.ts +++ b/src/features/home/components/common/field/index.ts @@ -1,3 +1,4 @@ export * from "./PriceField"; export * from "./AmountField"; export * from "./TotalField"; +export * from "./CoinSelect"; diff --git a/src/features/home/components/features/order/OrderForm.tsx b/src/features/home/components/features/order/OrderForm.tsx index eb9c845..3fc9e7b 100644 --- a/src/features/home/components/features/order/OrderForm.tsx +++ b/src/features/home/components/features/order/OrderForm.tsx @@ -1,20 +1,50 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { OrderSchemaType, orderSchema, tradeAPI } from "@/entities"; import { Button, Form } from "@/shared"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { RotateCw } from "lucide-react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; +import { getWalletAPI } from "@/features/wallet/apis"; + +import { getMarketsAPI, getTickerAPI } from "../../../apis"; import { TabType } from "../../../types"; -import { AmountField, PriceField, ToggleButtonGroup, TotalField } from "../../common"; +import { AmountField, CoinSelect, PriceField, ToggleButtonGroup, TotalField } from "../../common"; export const OrderForm = () => { const [activeTab, setActiveTab] = useState("매수"); + const [selectedCoin, setSelectedCoin] = useState("KRW-WAXP"); + + // 마켓 목록 조회 + const { data: marketsData } = useQuery({ + queryKey: ["markets"], + queryFn: () => getMarketsAPI(), + }); + + // 실시간 시세 조회 (1초마다) + const { data: tickerData } = useQuery({ + queryKey: ["ticker", selectedCoin], + queryFn: () => getTickerAPI(selectedCoin), + refetchInterval: 1000, // 1초마다 갱신 + enabled: !!selectedCoin, + }); + + const currentPrice = tickerData?.data?.[0]?.trade_price || 0; + + // 지갑 정보 조회 + const { data: walletData, refetch: refetchWallet } = useQuery({ + queryKey: ["wallet"], + queryFn: () => getWalletAPI(), + }); + + // KRW 잔고와 선택된 코인 잔고 계산 + const krwBalance = walletData?.data?.find((w) => w.coin_id === "KRW")?.amount || 0; + const coinBalance = walletData?.data?.find((w) => w.coin_id === selectedCoin)?.amount || 0; const form = useForm({ resolver: zodResolver(orderSchema), @@ -25,16 +55,55 @@ export const OrderForm = () => { }, }); + // 실시간 시세가 변경되면 가격 필드 업데이트 + useEffect(() => { + if (currentPrice > 0) { + form.setValue("price", currentPrice); + } + }, [currentPrice, form]); + + // 가격 변경 핸들러: 가격 × 수량 = 총액 + const handlePriceChange = (value: number) => { + const amount = form.getValues("amount"); + const calculatedTotal = value * amount; + form.setValue("total", calculatedTotal); + }; + + // 수량 변경 핸들러: 가격 × 수량 = 총액 + const handleAmountChange = (value: number) => { + const price = form.getValues("price"); + const calculatedTotal = price * value; + form.setValue("total", calculatedTotal); + }; + + // 총액 변경 핸들러: 총액 ÷ 가격 = 수량 + const handleTotalChange = (value: number) => { + const price = form.getValues("price"); + if (price > 0) { + const calculatedAmount = value / price; + form.setValue("amount", calculatedAmount); + } + }; + + // 초기화 핸들러 + const handleReset = () => { + form.reset({ + price: 0, + amount: 0, + total: 0, + }); + }; + const onSuccess = () => { toast.success("주문이 완료되었습니다."); - - form.reset(); + handleReset(); + refetchWallet(); // 지갑 정보 재조회 }; - const { mutate: tradeMutate } = useMutation({ + const { mutate: tradeMutate, isPending } = useMutation({ mutationFn: async (data: OrderSchemaType) => { const response = await tradeAPI({ - coin_id: "KRW-WAXP", + coin_id: selectedCoin, price: data.price, amount: data.amount, trade_type: activeTab === "매수" ? "buy" : "sell", @@ -55,27 +124,45 @@ export const OrderForm = () => {
+ {/* 코인 선택 */} +
+ +
+

주문가능

-

0

-

KRW

+

{activeTab === "매수" ? krwBalance.toLocaleString() : coinBalance.toFixed(8)}

+

+ {activeTab === "매수" ? "KRW" : selectedCoin.replace("KRW-", "")} +

e.preventDefault()}>
- - - + + +
- -
From 801b2890dbf953a7eac90a04149360506a2340c0 Mon Sep 17 00:00:00 2001 From: Dobbymin Date: Mon, 17 Nov 2025 13:52:17 +0900 Subject: [PATCH 11/22] =?UTF-8?q?feat:=20=EC=A7=80=EA=B0=91=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=ED=83=80=EC=9E=85=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/wallet/types/index.ts | 1 + src/features/wallet/types/wallet.type.ts | 3 +++ 2 files changed, 4 insertions(+) create mode 100644 src/features/wallet/types/wallet.type.ts diff --git a/src/features/wallet/types/index.ts b/src/features/wallet/types/index.ts index bdf5d2d..e7412dd 100644 --- a/src/features/wallet/types/index.ts +++ b/src/features/wallet/types/index.ts @@ -1 +1,2 @@ export * from "./tab.type"; +export * from "./wallet.type"; diff --git a/src/features/wallet/types/wallet.type.ts b/src/features/wallet/types/wallet.type.ts new file mode 100644 index 0000000..d81758f --- /dev/null +++ b/src/features/wallet/types/wallet.type.ts @@ -0,0 +1,3 @@ +import { Database } from "@/shared"; + +export type WalletEntity = Database["public"]["Tables"]["wallet"]["Row"]; From a0bc9fb5c2325ec2187d9ae38d5494811dc07ca7 Mon Sep 17 00:00:00 2001 From: Dobbymin Date: Mon, 17 Nov 2025 13:52:38 +0900 Subject: [PATCH 12/22] =?UTF-8?q?feat:=20=EC=A7=80=EA=B0=91=20real=20time?= =?UTF-8?q?=20=EA=B4=80=EB=A0=A8=20hook=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/wallet/hooks/index.ts | 3 ++- src/features/wallet/hooks/useRealtimeWallet.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/features/wallet/hooks/index.ts b/src/features/wallet/hooks/index.ts index 8b13789..b6c63bb 100644 --- a/src/features/wallet/hooks/index.ts +++ b/src/features/wallet/hooks/index.ts @@ -1 +1,2 @@ - +export * from "./useGetHoldDeposit"; +export * from "./useRealtimeWallet"; diff --git a/src/features/wallet/hooks/useRealtimeWallet.ts b/src/features/wallet/hooks/useRealtimeWallet.ts index b1def50..75ab138 100644 --- a/src/features/wallet/hooks/useRealtimeWallet.ts +++ b/src/features/wallet/hooks/useRealtimeWallet.ts @@ -6,11 +6,12 @@ import { useQuery } from "@tanstack/react-query"; import { getTickerAPI } from "@/features/home/apis"; +import { createClient as createSupabaseBrowserClient } from "@/shared/utils/supabase"; + import { getWalletAPI } from "../apis"; import { getTradesAPI } from "../apis/trades.api"; import { useWalletStore } from "../store"; import { computeCostBasis } from "../utils/aggregate-trades"; -import { createClient as createSupabaseBrowserClient } from "@/shared/utils/supabase"; /** * 지갑과 시세를 실시간으로 동기화하는 hook From 32866b633c8a8103b3ae8d2d660354b0dbc0eee0 Mon Sep 17 00:00:00 2001 From: Dobbymin Date: Mon, 17 Nov 2025 13:52:58 +0900 Subject: [PATCH 13/22] =?UTF-8?q?feat:=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EC=8B=9C=EC=84=B8=20=EB=B0=98=EC=98=81=20ui=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/wallet/ui/CashSection.tsx | 77 ++++++++++ .../wallet/ui/HeldAssetsListSection.tsx | 137 +++++------------- 2 files changed, 112 insertions(+), 102 deletions(-) create mode 100644 src/features/wallet/ui/CashSection.tsx diff --git a/src/features/wallet/ui/CashSection.tsx b/src/features/wallet/ui/CashSection.tsx new file mode 100644 index 0000000..11e2296 --- /dev/null +++ b/src/features/wallet/ui/CashSection.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useState } from "react"; + +import { Button, Dialog, DialogContent, DialogDescription, DialogTitle, DialogTrigger, Input } from "@/shared"; +import { useMutation } from "@tanstack/react-query"; +import { toast } from "sonner"; + +import { depositAPI } from "../apis/deposit.api"; + +export const CashSection = () => { + const [amount, setAmount] = useState(""); + const [isOpen, setIsOpen] = useState(false); + + const { mutate: deposit, isPending } = useMutation({ + mutationFn: (amount: number) => depositAPI(amount), + onSuccess: () => { + toast.success(`${amount.toLocaleString()}원이 입금되었습니다!`); + setAmount(""); + setIsOpen(false); + // 페이지 새로고침으로 지갑 데이터 업데이트 + window.location.reload(); + }, + onError: (error: Error) => { + toast.error(`입금 실패: ${error.message}`); + }, + }); + + const onClickDeposit = () => { + const depositAmount = Number(amount); + + if (!amount || isNaN(depositAmount)) { + toast.error("올바른 금액을 입력해주세요."); + return; + } + + if (depositAmount <= 0) { + toast.error("0원보다 큰 금액을 입력해주세요."); + return; + } + + deposit(depositAmount); + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + onClickDeposit(); + } + }; + + return ( +
+

가상의 현금을 입금해보아요.

+ + + + + + + KRW입금 + 원하는 금액을 입력해주세요. + setAmount(e.target.value)} + onKeyPress={handleKeyPress} + disabled={isPending} + /> + + + +
+ ); +}; diff --git a/src/features/wallet/ui/HeldAssetsListSection.tsx b/src/features/wallet/ui/HeldAssetsListSection.tsx index 2115e6b..f9907c1 100644 --- a/src/features/wallet/ui/HeldAssetsListSection.tsx +++ b/src/features/wallet/ui/HeldAssetsListSection.tsx @@ -1,108 +1,41 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared"; +import { useRealtimeWallet } from "../hooks"; +import { useWalletStore } from "../store"; + export const HeldAssetsListSection = () => { - const assetList = [ - { - name: "이더리움", - symbol: "ETH", - quantity: "0.00001189", - buyPrice: "30,201,000", - currentPrice: "148,200", - profitRate: "+125", - profitAmount: "+148,200", - isProfit: true, - }, - { - name: "리플", - symbol: "XRP", - quantity: "1,250.50", - buyPrice: "1,500,000", - currentPrice: "1,680,000", - profitRate: "+12", - profitAmount: "+180,000", - isProfit: true, - }, - { - name: "비트코인", - symbol: "BTC", - quantity: "0.0025", - buyPrice: "100,000,000", - currentPrice: "95,000,000", - profitRate: "-5", - profitAmount: "-5,000,000", - isProfit: false, - }, - { - name: "카르다노", - symbol: "ADA", - quantity: "5,000", - buyPrice: "2,500,000", - currentPrice: "2,750,000", - profitRate: "+10", - profitAmount: "+250,000", - isProfit: true, - }, - { - name: "폴카닷", - symbol: "DOT", - quantity: "150", - buyPrice: "1,200,000", - currentPrice: "1,080,000", - profitRate: "-10", - profitAmount: "-120,000", - isProfit: false, - }, - { - name: "솔라나", - symbol: "SOL", - quantity: "10.5", - buyPrice: "2,100,000", - currentPrice: "2,520,000", - profitRate: "+20", - profitAmount: "+420,000", - isProfit: true, - }, - { - name: "체인링크", - symbol: "LINK", - quantity: "200", - buyPrice: "3,000,000", - currentPrice: "2,850,000", - profitRate: "-5", - profitAmount: "-150,000", - isProfit: false, - }, - { - name: "라이트코인", - symbol: "LTC", - quantity: "15", - buyPrice: "1,500,000", - currentPrice: "1,650,000", - profitRate: "+10", - profitAmount: "+150,000", - isProfit: true, - }, - { - name: "이오스", - symbol: "EOS", - quantity: "800", - buyPrice: "800,000", - currentPrice: "720,000", - profitRate: "-10", - profitAmount: "-80,000", - isProfit: false, - }, - { - name: "스텔라루멘", - symbol: "XLM", - quantity: "10,000", - buyPrice: "1,000,000", - currentPrice: "1,150,000", - profitRate: "+15", - profitAmount: "+150,000", - isProfit: true, - }, - ]; + // 실시간 지갑 데이터 구독 + useRealtimeWallet(); + + const { wallets, tickerMap, getCoinEvaluation, getCoinCostBasis } = useWalletStore(); + + // KRW를 제외한 코인만 표시 + const coinWallets = wallets.filter((w) => w.coin_id !== "KRW"); + + // 실제 데이터를 표시용 형식으로 변환 + const assetList = coinWallets.map((wallet) => { + const ticker = tickerMap[wallet.coin_id]; + const evaluation = getCoinEvaluation(wallet.coin_id, wallet.amount); + + // 거래 집계에서 코인별 원가 총액 + const buyTotalAmount = getCoinCostBasis(wallet.coin_id); + const profitAmount = evaluation - buyTotalAmount; + const profitRate = buyTotalAmount > 0 ? (profitAmount / buyTotalAmount) * 100 : 0; + + // 마켓 코드에서 코인 심볼 추출 (예: "KRW-BTC" -> "BTC") + const symbol = wallet.coin_id.replace("KRW-", ""); + + return { + name: ticker?.market || wallet.coin_id, + symbol, + quantity: wallet.amount.toFixed(8), + buyPrice: buyTotalAmount.toLocaleString("ko-KR"), + currentPrice: evaluation.toLocaleString("ko-KR"), + profitRate: `${profitRate >= 0 ? "+" : ""}${profitRate.toFixed(2)}`, + profitAmount: `${profitAmount >= 0 ? "+" : ""}${profitAmount.toLocaleString("ko-KR")}`, + isProfit: profitAmount >= 0, + }; + }); return (
From 930ffe0f85e0f85d788c543b0c87553f1aa684a2 Mon Sep 17 00:00:00 2001 From: Dobbymin Date: Mon, 17 Nov 2025 13:53:32 +0900 Subject: [PATCH 14/22] =?UTF-8?q?fix:=20=EC=BD=94=EC=9D=B8=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EA=B0=80=EC=A0=B8=EC=98=A4=EA=B8=B0=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=88=98=EC=A0=95=EC=82=AC=ED=95=AD=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/home/apis/index.ts | 2 ++ src/features/home/apis/markets.api.ts | 23 ++++++++++++++ src/features/home/apis/ticker.api.ts | 46 +++++++++++++++++++++++++++ 3 files changed, 71 insertions(+) create mode 100644 src/features/home/apis/markets.api.ts create mode 100644 src/features/home/apis/ticker.api.ts diff --git a/src/features/home/apis/index.ts b/src/features/home/apis/index.ts index ce385f9..4866baa 100644 --- a/src/features/home/apis/index.ts +++ b/src/features/home/apis/index.ts @@ -1 +1,3 @@ export * from "./market.api"; +export * from "./ticker.api"; +export * from "./markets.api"; diff --git a/src/features/home/apis/markets.api.ts b/src/features/home/apis/markets.api.ts new file mode 100644 index 0000000..0e937f8 --- /dev/null +++ b/src/features/home/apis/markets.api.ts @@ -0,0 +1,23 @@ +export interface MarketInfo { + market: string; + korean_name: string; + english_name: string; +} + +export interface MarketResponse { + success: boolean; + data: MarketInfo[]; +} + +export const getMarketsAPI = async (): Promise => { + const response = await fetch("/api/market?type=list", { + cache: "no-store", + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to fetch markets"); + } + + return response.json(); +}; diff --git a/src/features/home/apis/ticker.api.ts b/src/features/home/apis/ticker.api.ts new file mode 100644 index 0000000..3fbd29d --- /dev/null +++ b/src/features/home/apis/ticker.api.ts @@ -0,0 +1,46 @@ +export interface TickerData { + market: string; + trade_date: string; + trade_time: string; + trade_date_kst: string; + trade_time_kst: string; + trade_timestamp: number; + opening_price: number; + high_price: number; + low_price: number; + trade_price: number; // 현재가 + prev_closing_price: number; + change: "RISE" | "EVEN" | "FALL"; + change_price: number; + change_rate: number; + signed_change_price: number; + signed_change_rate: number; + trade_volume: number; + acc_trade_price: number; + acc_trade_price_24h: number; + acc_trade_volume: number; + acc_trade_volume_24h: number; + highest_52_week_price: number; + highest_52_week_date: string; + lowest_52_week_price: number; + lowest_52_week_date: string; + timestamp: number; +} + +export interface TickerResponse { + success: boolean; + data: TickerData[]; +} + +export const getTickerAPI = async (markets: string = "KRW-WAXP"): Promise => { + const response = await fetch(`/api/ticker?markets=${markets}`, { + cache: "no-store", + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to fetch ticker"); + } + + return response.json(); +}; From 5ee4e1f4733c0f1754d014ead8c8bef8aa0a60d8 Mon Sep 17 00:00:00 2001 From: Dobbymin Date: Mon, 17 Nov 2025 13:53:50 +0900 Subject: [PATCH 15/22] =?UTF-8?q?feat:=20=EC=A7=80=EA=B0=91=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B1=B0=EB=9E=98=20=EA=B4=80=EB=A0=A8=20api=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/wallet/apis/index.ts | 4 ++++ src/features/wallet/apis/trades.api.ts | 16 ++++++++++++++++ src/features/wallet/apis/wallet.api.ts | 22 ++++++++++++++++++++++ 3 files changed, 42 insertions(+) create mode 100644 src/features/wallet/apis/index.ts create mode 100644 src/features/wallet/apis/trades.api.ts create mode 100644 src/features/wallet/apis/wallet.api.ts diff --git a/src/features/wallet/apis/index.ts b/src/features/wallet/apis/index.ts new file mode 100644 index 0000000..ad65524 --- /dev/null +++ b/src/features/wallet/apis/index.ts @@ -0,0 +1,4 @@ +export * from "./deposit.api"; +export * from "./hold-deposit.api"; +export * from "./wallet.api"; +export * from "./trades.api"; diff --git a/src/features/wallet/apis/trades.api.ts b/src/features/wallet/apis/trades.api.ts new file mode 100644 index 0000000..b171c4c --- /dev/null +++ b/src/features/wallet/apis/trades.api.ts @@ -0,0 +1,16 @@ +import { TradeEntity } from "@/entities/market"; + +export interface TradesResponse { + success: boolean; + data: TradeEntity[]; + error?: string; +} + +export const getTradesAPI = async (): Promise => { + const res = await fetch("/api/trade", { cache: "no-store" }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || "Failed to fetch trades"); + } + return res.json(); +}; diff --git a/src/features/wallet/apis/wallet.api.ts b/src/features/wallet/apis/wallet.api.ts new file mode 100644 index 0000000..f1c0f95 --- /dev/null +++ b/src/features/wallet/apis/wallet.api.ts @@ -0,0 +1,22 @@ +import { WalletEntity } from "../types"; + +export interface WalletResponse { + success: boolean; + data: WalletEntity[]; +} + +export const getWalletAPI = async (coinId?: string): Promise => { + const url = coinId ? `/api/wallet/deposit?coin_id=${coinId}` : "/api/wallet/deposit"; + + const response = await fetch(url, { + method: "GET", + cache: "no-store", + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to fetch wallet"); + } + + return response.json(); +}; From 6ca68238965bf839d42262723b795d608caa18c6 Mon Sep 17 00:00:00 2001 From: Dobbymin Date: Mon, 17 Nov 2025 13:54:21 +0900 Subject: [PATCH 16/22] =?UTF-8?q?feat:=20=EB=82=B4=20=EC=A7=80=EA=B0=91=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EA=B0=80=EC=A0=B8=EC=98=A4=EA=B8=B0=20api?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/wallet/apis/deposit.api.ts | 28 ++++++++++++++++++++ src/features/wallet/apis/hold-deposit.api.ts | 8 ++++++ 2 files changed, 36 insertions(+) create mode 100644 src/features/wallet/apis/deposit.api.ts create mode 100644 src/features/wallet/apis/hold-deposit.api.ts diff --git a/src/features/wallet/apis/deposit.api.ts b/src/features/wallet/apis/deposit.api.ts new file mode 100644 index 0000000..3303e61 --- /dev/null +++ b/src/features/wallet/apis/deposit.api.ts @@ -0,0 +1,28 @@ +import { WalletEntity } from "../types"; + +export interface DepositParams { + amount: number; +} + +export interface DepositResponse { + success: boolean; + data?: WalletEntity; + error?: string; +} + +export const depositAPI = async (amount: number): Promise => { + const response = await fetch("/api/wallet/deposit", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ amount }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Deposit failed"); + } + + return response.json(); +}; diff --git a/src/features/wallet/apis/hold-deposit.api.ts b/src/features/wallet/apis/hold-deposit.api.ts new file mode 100644 index 0000000..b2ca181 --- /dev/null +++ b/src/features/wallet/apis/hold-deposit.api.ts @@ -0,0 +1,8 @@ +export const holdDepositAPI = async () => { + const response = await fetch("/api/wallet/deposit", { cache: "no-store" }); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Hold deposit fetch failed"); + } + return response.json(); +}; From c05196acf13d24e1a587dad259ddcb7186eadc96 Mon Sep 17 00:00:00 2001 From: Dobbymin Date: Mon, 17 Nov 2025 13:54:40 +0900 Subject: [PATCH 17/22] =?UTF-8?q?feat:=20=EC=A7=80=EA=B0=91=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20store=20=EC=BD=94=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/wallet/store/index.ts | 1 + src/features/wallet/store/useWalletStore.ts | 88 +++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 src/features/wallet/store/index.ts create mode 100644 src/features/wallet/store/useWalletStore.ts diff --git a/src/features/wallet/store/index.ts b/src/features/wallet/store/index.ts new file mode 100644 index 0000000..3328911 --- /dev/null +++ b/src/features/wallet/store/index.ts @@ -0,0 +1 @@ +export * from "./useWalletStore"; diff --git a/src/features/wallet/store/useWalletStore.ts b/src/features/wallet/store/useWalletStore.ts new file mode 100644 index 0000000..fb7d292 --- /dev/null +++ b/src/features/wallet/store/useWalletStore.ts @@ -0,0 +1,88 @@ +import { create } from "zustand"; + +import { TickerData } from "@/features/home/apis"; + +import { WalletEntity } from "../types"; +import { CostBasis } from "../utils/aggregate-trades"; + +interface WalletState { + wallets: WalletEntity[]; + tickerMap: Record; + costBasisMap: Record; // 코인별 평균매수가/원가 + setWallets: (wallets: WalletEntity[]) => void; + setTickerMap: (tickerMap: Record) => void; + setCostBasisMap: (map: Record) => void; + getTotalEvaluation: () => number; + getTotalPurchase: () => number; + getTotalProfitLoss: () => number; + getTotalProfitRate: () => number; + getCoinEvaluation: (coinId: string, amount: number) => number; + getTotalCoinEvaluation: () => number; // KRW 제외 코인 평가액 합계 + getCoinCostBasis: (coinId: string) => number; // 해당 코인의 총 원가 +} + +export const useWalletStore = create((set, get) => ({ + wallets: [], + tickerMap: {}, + costBasisMap: {}, + + setWallets: (wallets) => set({ wallets }), + + setTickerMap: (tickerMap) => set({ tickerMap }), + setCostBasisMap: (map) => set({ costBasisMap: map }), + + // 특정 코인의 평가액 계산 + getCoinEvaluation: (coinId, amount) => { + const { tickerMap } = get(); + if (coinId === "KRW") return amount; + + const ticker = tickerMap[coinId]; + if (!ticker) return 0; + + return ticker.trade_price * amount; + }, + + // 코인 평가액 총합 (KRW 제외) + getTotalCoinEvaluation: () => { + const { wallets, getCoinEvaluation } = get(); + return wallets + .filter((w) => w.coin_id !== "KRW") + .reduce((total, w) => total + getCoinEvaluation(w.coin_id, w.amount), 0); + }, + + // 특정 코인의 총 원가 + getCoinCostBasis: (coinId) => { + const { costBasisMap } = get(); + return costBasisMap[coinId]?.totalCost ?? 0; + }, + + // 총 평가액 (모든 자산의 현재 가치 합계) + getTotalEvaluation: () => { + const { wallets, getCoinEvaluation } = get(); + return wallets.reduce((total, wallet) => { + return total + getCoinEvaluation(wallet.coin_id, wallet.amount); + }, 0); + }, + + // 총 매수액 (현재 보유 코인의 원가 합계) + getTotalPurchase: () => { + const { wallets, getCoinCostBasis } = get(); + return wallets + .filter((w) => w.coin_id !== "KRW" && w.amount > 0) + .reduce((sum, w) => sum + getCoinCostBasis(w.coin_id), 0); + }, + + // 총 평가 손익 + getTotalProfitLoss: () => { + const { getTotalCoinEvaluation, getTotalPurchase } = get(); + return getTotalCoinEvaluation() - getTotalPurchase(); + }, + + // 총 평가 수익률 + getTotalProfitRate: () => { + const { getTotalProfitLoss, getTotalPurchase } = get(); + const purchase = getTotalPurchase(); + if (purchase === 0) return 0; + return (getTotalProfitLoss() / purchase) * 100; + }, +})); From 0f2233b25f3943473a521aec46e8c5f9cb65e969 Mon Sep 17 00:00:00 2001 From: Dobbymin Date: Mon, 17 Nov 2025 13:55:12 +0900 Subject: [PATCH 18/22] =?UTF-8?q?feat:=20=EB=82=B4=20=EB=B3=B4=EC=9C=A0=20?= =?UTF-8?q?=EC=BD=94=EC=9D=B8=20=EB=B3=B4=EA=B8=B0=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?util=20=ED=95=A8=EC=88=98=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/wallet/utils/aggregate-trades.ts | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 src/features/wallet/utils/aggregate-trades.ts diff --git a/src/features/wallet/utils/aggregate-trades.ts b/src/features/wallet/utils/aggregate-trades.ts new file mode 100644 index 0000000..e780bd8 --- /dev/null +++ b/src/features/wallet/utils/aggregate-trades.ts @@ -0,0 +1,52 @@ +import { TradeEntity } from "@/entities/market"; + +export type CostBasis = { + avgPrice: number; // 평균 매수가 (잔고 기준 이동평균) + quantity: number; // 현재 보유 수량 + totalCost: number; // 잔고 기준 총 매수 금액 (avgPrice * quantity) +}; + +/** + * 이동평균법으로 평균 매수가를 계산합니다. + * - buy: (기존총원가 + 매수금액) / (기존수량 + 매수수량) + * - sell: 수량만 감소, 평균단가는 유지. 총원가는 avgPrice * 남은수량 으로 조정 + */ +export function computeCostBasis(trades: TradeEntity[]): Record { + const map: Record = {}; + + // 시간 순으로 정렬 (오래된 거래 먼저) + const sorted = [...trades].sort((a, b) => new Date(a.created_at!).getTime() - new Date(b.created_at!).getTime()); + + for (const t of sorted) { + const key = t.coin_id; + const price = Number(t.price); + const qty = Number(t.amount); + const type = t.trade_type as "buy" | "sell"; + + if (!map[key]) { + map[key] = { avgPrice: 0, quantity: 0, totalCost: 0 }; + } + + const entry = map[key]; + + if (type === "buy") { + const newTotalCost = entry.totalCost + price * qty; + const newQty = entry.quantity + qty; + const newAvg = newQty > 0 ? newTotalCost / newQty : 0; + entry.avgPrice = newAvg; + entry.quantity = newQty; + entry.totalCost = newAvg * newQty; // 정규화 + } else if (type === "sell") { + const newQty = entry.quantity - qty; + const safeQty = Math.max(newQty, 0); + // 평균단가는 유지, 총원가는 avg * 남은수량 + entry.quantity = safeQty; + entry.totalCost = entry.avgPrice * safeQty; + if (safeQty === 0) { + entry.avgPrice = 0; + } + } + } + + return map; +} From c8b8950c3ea8584c803893ff4972f64d38041422 Mon Sep 17 00:00:00 2001 From: Dobbymin Date: Mon, 17 Nov 2025 13:55:36 +0900 Subject: [PATCH 19/22] =?UTF-8?q?feat:=20=EB=82=B4=20=EA=B1=B0=EB=9E=98=20?= =?UTF-8?q?=EB=82=B4=EC=97=AD=20=EB=B3=B4=EA=B8=B0=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/wallet/ui/StatsAreaSection.tsx | 47 ++++++++++++++++++--- src/features/wallet/ui/index.ts | 5 ++- 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/src/features/wallet/ui/StatsAreaSection.tsx b/src/features/wallet/ui/StatsAreaSection.tsx index f13fd54..f1997b2 100644 --- a/src/features/wallet/ui/StatsAreaSection.tsx +++ b/src/features/wallet/ui/StatsAreaSection.tsx @@ -1,15 +1,50 @@ import { Separator, Table, TableBody, TableCell, TableRow } from "@/shared"; import { TableItem } from "../components"; +import { useRealtimeWallet } from "../hooks"; +import { useWalletStore } from "../store"; export const StatsAreaSection = () => { + // 실시간 지갑 데이터 구독 + useRealtimeWallet(); + + // Zustand store에서 계산된 값 가져오기 + const { + wallets, + getTotalEvaluation, + getTotalCoinEvaluation, + getTotalPurchase, + getTotalProfitLoss, + getTotalProfitRate, + } = useWalletStore(); + + // KRW 잔고 + const krwWallet = wallets.find((w) => w.coin_id === "KRW"); + const heldKRW = krwWallet?.amount || 0; + + // 총 평가액 (보유 KRW + 코인 평가금액) + const totalEvaluation = getTotalEvaluation(); + + // 총 매수 (현재 보유 코인의 원가 합계) + const totalPurchase = getTotalPurchase(); + + // 총 평가 손익 + const totalProfitLoss = getTotalProfitLoss(); + + // 총 평가 수익률 + const totalProfitRate = getTotalProfitRate(); + + // 총 보유 자산 = 보유 KRW + 코인 평가금액 + const totalCoinEval = getTotalCoinEvaluation(); + const totalAssets = heldKRW + totalCoinEval; + const data = { - heldKRW: "100,000", - totalAssets: "1,000", - totalBuy: "1,000", - totalProfitLoss: "+10,000", - totalEvaluation: "1,000", - totalProfitRate: "+30.21", + heldKRW: heldKRW.toLocaleString("ko-KR"), + totalAssets: totalAssets.toLocaleString("ko-KR"), + totalBuy: totalPurchase.toLocaleString("ko-KR"), + totalProfitLoss: `${totalProfitLoss >= 0 ? "+" : ""}${totalProfitLoss.toLocaleString("ko-KR")}`, + totalEvaluation: totalEvaluation.toLocaleString("ko-KR"), + totalProfitRate: `${totalProfitRate >= 0 ? "+" : ""}${totalProfitRate.toFixed(2)}`, }; return ( diff --git a/src/features/wallet/ui/index.ts b/src/features/wallet/ui/index.ts index 5fb74b9..f8ccc7e 100644 --- a/src/features/wallet/ui/index.ts +++ b/src/features/wallet/ui/index.ts @@ -1,4 +1,5 @@ +export * from "./CashSection"; +export * from "./ChartAreaSection"; export * from "./HeldAssetsListSection"; -export * from "./TabNavigationSection"; export * from "./StatsAreaSection"; -export * from "./ChartAreaSection"; +export * from "./TabNavigationSection"; From f1e1027da1d9eb9545c7d169e077c3b4ef908d2c Mon Sep 17 00:00:00 2001 From: Dobbymin Date: Mon, 17 Nov 2025 13:55:58 +0900 Subject: [PATCH 20/22] =?UTF-8?q?fix:=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EC=BD=94=EC=9D=B8=20=EC=8B=9C=EC=84=B8=20=EA=B0=80=EC=A0=B8?= =?UTF-8?q?=EC=98=A4=EA=B8=B0=20hook=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/home/hooks/index.ts | 1 + src/features/home/hooks/useRealtimeTicker.ts | 35 ++++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 src/features/home/hooks/useRealtimeTicker.ts diff --git a/src/features/home/hooks/index.ts b/src/features/home/hooks/index.ts index beb905f..3f5c70b 100644 --- a/src/features/home/hooks/index.ts +++ b/src/features/home/hooks/index.ts @@ -1 +1,2 @@ export * from "./useGetMarketInfo"; +export * from "./useRealtimeTicker"; diff --git a/src/features/home/hooks/useRealtimeTicker.ts b/src/features/home/hooks/useRealtimeTicker.ts new file mode 100644 index 0000000..e0c134e --- /dev/null +++ b/src/features/home/hooks/useRealtimeTicker.ts @@ -0,0 +1,35 @@ +import { useMemo } from "react"; + +import { useQuery } from "@tanstack/react-query"; + +import { TickerData, getTickerAPI } from "../apis"; + +/** + * 코인의 실시간 시세를 구독하는 hook + * @param markets 조회할 마켓 (예: "KRW-BTC,KRW-ETH") + * @param refetchInterval 갱신 주기 (ms), 기본값 1000ms (1초) + */ +export const useRealtimeTicker = (markets: string, refetchInterval: number = 1000) => { + const { data } = useQuery({ + queryKey: ["ticker", markets], + queryFn: () => getTickerAPI(markets), + refetchInterval, + }); + + // useMemo를 사용하여 data가 변경될 때만 tickerMap 재계산 + const tickerMap = useMemo(() => { + if (!data?.data) return {}; + + const map: Record = {}; + data.data.forEach((ticker) => { + map[ticker.market] = ticker; + }); + return map; + }, [data]); + + return { + tickerMap, + getTicker: (market: string) => tickerMap[market], + getCurrentPrice: (market: string) => tickerMap[market]?.trade_price || 0, + }; +}; From a0a50f4dd1f609aa072213d3fe859bcf9cb756aa Mon Sep 17 00:00:00 2001 From: Dobbymin Date: Mon, 17 Nov 2025 13:56:10 +0900 Subject: [PATCH 21/22] =?UTF-8?q?feat:=20=EC=A7=80=EA=B0=91=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/wallet/page.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/app/wallet/page.tsx b/src/app/wallet/page.tsx index 54fbbe3..b25746d 100644 --- a/src/app/wallet/page.tsx +++ b/src/app/wallet/page.tsx @@ -2,7 +2,14 @@ import { useState } from "react"; -import { ChartAreaSection, HeldAssetsListSection, StatsAreaSection, TabNavigationSection, TabType } from "@/features"; +import { + CashSection, + ChartAreaSection, + HeldAssetsListSection, + StatsAreaSection, + TabNavigationSection, + TabType, +} from "@/features"; export default function WalletPage() { const [activeTab, setActiveTab] = useState("보유자산"); @@ -10,6 +17,7 @@ export default function WalletPage() { return (
+
From cb7306c4f4974fc64a4b6e5f7877910dfe3e4497 Mon Sep 17 00:00:00 2001 From: Dobbymin Date: Mon, 17 Nov 2025 14:10:59 +0900 Subject: [PATCH 22/22] =?UTF-8?q?deploy:=20ci=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EC=97=90=EC=84=9C=20build=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3e42ea..d379058 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,4 @@ jobs: - name: Lint Code run: pnpm lint - - - name: Build Test - run: pnpm run build + \ No newline at end of file