From f8c3eecfa7805b3dc14515785331783190a052ee Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Sun, 29 Jun 2025 22:06:46 +0900 Subject: [PATCH 01/87] =?UTF-8?q?refactor:=20charts=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20shared=20=EB=94=94=EB=A0=89=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/tradeview/ui/StockChart/index.tsx | 10 +++++----- .../StockChart => shared/ui/Chart}/ChartContainer.tsx | 2 +- .../ui/StockChart => shared/ui/Chart}/ChartRoot.tsx | 0 .../ui/StockChart => shared/ui/Chart}/Series.tsx | 0 .../ui/StockChart => shared/ui/Chart}/ToolTip.tsx | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) rename src/{features/tradeview/ui/StockChart => shared/ui/Chart}/ChartContainer.tsx (96%) rename src/{features/tradeview/ui/StockChart => shared/ui/Chart}/ChartRoot.tsx (100%) rename src/{features/tradeview/ui/StockChart => shared/ui/Chart}/Series.tsx (100%) rename src/{features/tradeview/ui/StockChart => shared/ui/Chart}/ToolTip.tsx (97%) diff --git a/src/features/tradeview/ui/StockChart/index.tsx b/src/features/tradeview/ui/StockChart/index.tsx index 697fa2f..13e5e93 100644 --- a/src/features/tradeview/ui/StockChart/index.tsx +++ b/src/features/tradeview/ui/StockChart/index.tsx @@ -15,10 +15,11 @@ import { useState, } from 'react'; -import ChartContainer from './ChartContainer'; -import ChartRoot from './ChartRoot'; -import Series from './Series'; -import ToolTip from './ToolTip'; +import ChartContainer from '../../../../shared/ui/Chart/ChartContainer'; +import ChartRoot from '../../../../shared/ui/Chart/ChartRoot'; +import Series from '../../../../shared/ui/Chart/Series'; +import ToolTip from '../../../../shared/ui/Chart/ToolTip'; +import IntervalSelector from '../IntervalSelector'; import api from '../../api/tradeview.endpoints'; import { INTERVALS, MINUTE } from '../../const/chart.const'; @@ -29,7 +30,6 @@ import { priceFormatter, timestampToISOString, } from '../../utils'; -import IntervalSelector from '../IntervalSelector'; type ChartProps = { ticker?: string; diff --git a/src/features/tradeview/ui/StockChart/ChartContainer.tsx b/src/shared/ui/Chart/ChartContainer.tsx similarity index 96% rename from src/features/tradeview/ui/StockChart/ChartContainer.tsx rename to src/shared/ui/Chart/ChartContainer.tsx index 1671441..2eaf2ba 100644 --- a/src/features/tradeview/ui/StockChart/ChartContainer.tsx +++ b/src/shared/ui/Chart/ChartContainer.tsx @@ -17,7 +17,7 @@ import { useRef, } from 'react'; -import { INTERVAL_SELECTOR_HEIGHT } from '../../const/chart.const'; +import { INTERVAL_SELECTOR_HEIGHT } from '../../../features/tradeview/const/chart.const'; import { useChartRoot } from './ChartRoot'; type ChartContainerProps = PropsWithChildren<{ diff --git a/src/features/tradeview/ui/StockChart/ChartRoot.tsx b/src/shared/ui/Chart/ChartRoot.tsx similarity index 100% rename from src/features/tradeview/ui/StockChart/ChartRoot.tsx rename to src/shared/ui/Chart/ChartRoot.tsx diff --git a/src/features/tradeview/ui/StockChart/Series.tsx b/src/shared/ui/Chart/Series.tsx similarity index 100% rename from src/features/tradeview/ui/StockChart/Series.tsx rename to src/shared/ui/Chart/Series.tsx diff --git a/src/features/tradeview/ui/StockChart/ToolTip.tsx b/src/shared/ui/Chart/ToolTip.tsx similarity index 97% rename from src/features/tradeview/ui/StockChart/ToolTip.tsx rename to src/shared/ui/Chart/ToolTip.tsx index 7ea64dc..d63f9ab 100644 --- a/src/features/tradeview/ui/StockChart/ToolTip.tsx +++ b/src/shared/ui/Chart/ToolTip.tsx @@ -1,7 +1,7 @@ import type { CandlestickData } from 'lightweight-charts'; import { useLayoutEffect, useRef } from 'react'; import { formatCurrencyKR } from '~/shared/utils'; -import { formatDateKr } from '../../utils'; +import { formatDateKr } from '../../../features/tradeview/utils'; import { useChartContainer } from './ChartContainer'; import { useChartRoot } from './ChartRoot'; import { useSeries } from './Series'; From d2aa4ab879b1917e793cd0c9d1a604173642a85a Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Tue, 1 Jul 2025 15:24:46 +0900 Subject: [PATCH 02/87] =?UTF-8?q?config:=20recharts=20=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=B8=8C=EB=9F=AC=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 5 +- yarn.lock | 137 +++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 126 insertions(+), 16 deletions(-) diff --git a/package.json b/package.json index 5e110fa..4e58f27 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "react-dom": "^19.1.0", "react-router": "^7.5.3", "react-toastify": "^11.0.5", + "recharts": "^3.0.2", "ws": "^8.18.2", "xstate": "^5.19.3" }, @@ -68,6 +69,8 @@ }, "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e", "msw": { - "workerDirectory": ["public"] + "workerDirectory": [ + "public" + ] } } diff --git a/yarn.lock b/yarn.lock index cf2ffed..41c06a4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1828,6 +1828,18 @@ morgan "^1.10.0" source-map-support "^0.5.21" +"@reduxjs/toolkit@1.x.x || 2.x.x": + version "2.8.2" + resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-2.8.2.tgz#f4e9f973c6fc930c1e0f3bf462cc95210c28f5f9" + integrity sha512-MYlOhQ0sLdw4ud48FoC5w0dH9VfWQjtCjreKwYTT3l+r427qYC5Y8PihNutepr8XrNaBUDQo9khWUwQxZaqt5A== + dependencies: + "@standard-schema/spec" "^1.0.0" + "@standard-schema/utils" "^0.3.0" + immer "^10.0.3" + redux "^5.0.1" + redux-thunk "^3.1.0" + reselect "^5.1.0" + "@rollup/pluginutils@^5.0.2", "@rollup/pluginutils@^5.1.3": version "5.1.4" resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-5.1.4.tgz#bb94f1f9eaaac944da237767cdfee6c5b2262d4a" @@ -2214,6 +2226,16 @@ "@sentry/bundler-plugin-core" "3.5.0" unplugin "1.0.1" +"@standard-schema/spec@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.0.0.tgz#f193b73dc316c4170f2e82a881da0f550d551b9c" + integrity sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA== + +"@standard-schema/utils@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@standard-schema/utils/-/utils-0.3.0.tgz#3d5e608f16c2390c10528e98e59aef6bf73cae7b" + integrity sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g== + "@stomp/stompjs@^7.1.1": version "7.1.1" resolved "https://registry.yarnpkg.com/@stomp/stompjs/-/stompjs-7.1.1.tgz#9a836da33bed5b76c72a8f17f0594de98120f6d6" @@ -2498,7 +2520,7 @@ resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.6.0.tgz#eac397f28bf1d6ae0ae081363eca2f425bedf0d5" integrity sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA== -"@types/d3-array@*": +"@types/d3-array@*", "@types/d3-array@^3.0.3": version "3.2.1" resolved "https://registry.yarnpkg.com/@types/d3-array/-/d3-array-3.2.1.tgz#1f6658e3d2006c4fceac53fde464166859f8b8c5" integrity sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg== @@ -2557,7 +2579,7 @@ resolved "https://registry.yarnpkg.com/@types/d3-dsv/-/d3-dsv-3.0.7.tgz#0a351f996dc99b37f4fa58b492c2d1c04e3dac17" integrity sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g== -"@types/d3-ease@*": +"@types/d3-ease@*", "@types/d3-ease@^3.0.0": version "3.0.2" resolved "https://registry.yarnpkg.com/@types/d3-ease/-/d3-ease-3.0.2.tgz#e28db1bfbfa617076f7770dd1d9a48eaa3b6c51b" integrity sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA== @@ -2596,7 +2618,7 @@ resolved "https://registry.yarnpkg.com/@types/d3-hierarchy/-/d3-hierarchy-3.1.1.tgz#cd4656f5d17a98e26ed5d6f4be96dbda454af8b3" integrity sha512-QwjxA3+YCKH3N1Rs3uSiSy1bdxlLB1uUiENXeJudBoAFvtDuswUxLcanoOaR2JYn1melDTuIXR8VhnVyI3yG/A== -"@types/d3-interpolate@*": +"@types/d3-interpolate@*", "@types/d3-interpolate@^3.0.1": version "3.0.4" resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz#412b90e84870285f2ff8a846c6eb60344f12a41c" integrity sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA== @@ -2640,7 +2662,7 @@ resolved "https://registry.yarnpkg.com/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz#dc6d4f9a98376f18ea50bad6c39537f1b5463c39" integrity sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ== -"@types/d3-scale@*": +"@types/d3-scale@*", "@types/d3-scale@^4.0.2": version "4.0.9" resolved "https://registry.yarnpkg.com/@types/d3-scale/-/d3-scale-4.0.9.tgz#57a2f707242e6fe1de81ad7bfcccaaf606179afb" integrity sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw== @@ -2652,7 +2674,7 @@ resolved "https://registry.yarnpkg.com/@types/d3-selection/-/d3-selection-3.0.11.tgz#bd7a45fc0a8c3167a631675e61bc2ca2b058d4a3" integrity sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w== -"@types/d3-shape@*", "@types/d3-shape@^3.0.0": +"@types/d3-shape@*", "@types/d3-shape@^3.0.0", "@types/d3-shape@^3.1.0": version "3.1.7" resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-3.1.7.tgz#2b7b423dc2dfe69c8c93596e673e37443348c555" integrity sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg== @@ -2671,12 +2693,12 @@ resolved "https://registry.yarnpkg.com/@types/d3-time-format/-/d3-time-format-4.0.3.tgz#d6bc1e6b6a7db69cccfbbdd4c34b70632d9e9db2" integrity sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg== -"@types/d3-time@*": +"@types/d3-time@*", "@types/d3-time@^3.0.0": version "3.0.4" resolved "https://registry.yarnpkg.com/@types/d3-time/-/d3-time-3.0.4.tgz#8472feecd639691450dd8000eb33edd444e1323f" integrity sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g== -"@types/d3-timer@*": +"@types/d3-timer@*", "@types/d3-timer@^3.0.0": version "3.0.2" resolved "https://registry.yarnpkg.com/@types/d3-timer/-/d3-timer-3.0.2.tgz#70bbda77dc23aa727413e22e214afa3f0e852f70" integrity sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw== @@ -2832,6 +2854,11 @@ resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.5.tgz#cb6e2a691b70cb177c6e3ae9c1d2e8b2ea8cd304" integrity sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA== +"@types/use-sync-external-store@^0.0.6": + version "0.0.6" + resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz#60be8d21baab8c305132eb9cb912ed497852aadc" + integrity sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg== + "@vitest/coverage-v8@^3.1.4": version "3.1.4" resolved "https://registry.yarnpkg.com/@vitest/coverage-v8/-/coverage-v8-3.1.4.tgz#faffd0d22795938b69aa4fedc78622bce299ec26" @@ -3481,7 +3508,7 @@ csstype@^3.0.2: dependencies: internmap "^1.0.0" -"d3-array@2 - 3", "d3-array@2.10.0 - 3", "d3-array@2.5.0 - 3", d3-array@3, d3-array@^3.2.0: +"d3-array@2 - 3", "d3-array@2.10.0 - 3", "d3-array@2.5.0 - 3", d3-array@3, d3-array@^3.1.6, d3-array@^3.2.0: version "3.2.4" resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-3.2.4.tgz#15fec33b237f97ac5d7c986dc77da273a8ed0bb5" integrity sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg== @@ -3557,7 +3584,7 @@ d3-dispatch@2.*: iconv-lite "0.6" rw "1" -"d3-ease@1 - 3", d3-ease@3: +"d3-ease@1 - 3", d3-ease@3, d3-ease@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-3.0.1.tgz#9658ac38a2140d59d346160f1f6c30fda0bd12f4" integrity sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w== @@ -3595,7 +3622,7 @@ d3-hierarchy@3, d3-hierarchy@^3.0.0: resolved "https://registry.yarnpkg.com/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz#b01cd42c1eed3d46db77a5966cf726f8c09160c6" integrity sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA== -"d3-interpolate@1 - 3", "d3-interpolate@1.2.0 - 3", d3-interpolate@3: +"d3-interpolate@1 - 3", "d3-interpolate@1.2.0 - 3", d3-interpolate@3, d3-interpolate@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d" integrity sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g== @@ -3648,7 +3675,7 @@ d3-scale-chromatic@3: d3-color "1 - 3" d3-interpolate "1 - 3" -d3-scale@4: +d3-scale@4, d3-scale@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-4.0.2.tgz#82b38e8e8ff7080764f8dcec77bd4be393689396" integrity sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ== @@ -3664,7 +3691,7 @@ d3-scale@4: resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-3.0.0.tgz#c25338207efa72cc5b9bd1458a1a41901f1e1b31" integrity sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ== -d3-shape@3, d3-shape@^3.0.0: +d3-shape@3, d3-shape@^3.0.0, d3-shape@^3.1.0: version "3.2.0" resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-3.2.0.tgz#a1a839cbd9ba45f28674c69d7f855bcf91dfc6a5" integrity sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA== @@ -3685,14 +3712,14 @@ d3-shape@^1.2.0: dependencies: d3-time "1 - 3" -"d3-time@1 - 3", "d3-time@2.1.1 - 3", d3-time@3: +"d3-time@1 - 3", "d3-time@2.1.1 - 3", d3-time@3, d3-time@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-3.1.0.tgz#9310db56e992e3c0175e1ef385e545e48a9bb5c7" integrity sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q== dependencies: d3-array "2 - 3" -"d3-timer@1 - 3", d3-timer@3: +"d3-timer@1 - 3", d3-timer@3, d3-timer@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0" integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA== @@ -3824,6 +3851,11 @@ debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.4.0: dependencies: ms "^2.1.3" +decimal.js-light@^2.5.1: + version "2.5.1" + resolved "https://registry.yarnpkg.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz#134fd32508f19e208f4fb2f8dac0d2626a867934" + integrity sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg== + decimal.js@^10.5.0: version "10.5.0" resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.5.0.tgz#0f371c7cf6c4898ce0afb09836db73cd82010f22" @@ -4070,6 +4102,11 @@ es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: dependencies: es-errors "^1.3.0" +es-toolkit@^1.39.3: + version "1.39.5" + resolved "https://registry.yarnpkg.com/es-toolkit/-/es-toolkit-1.39.5.tgz#ee2a78a66aafb76c7345af0ea8c06722c78ef1fd" + integrity sha512-z9V0qU4lx1TBXDNFWfAASWk6RNU6c6+TJBKE+FLIg8u0XJ6Yw58Hi0yX8ftEouj6p1QARRlXLFfHbIli93BdQQ== + esbuild@^0.25.0: version "0.25.4" resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.4.tgz#bb9a16334d4ef2c33c7301a924b8b863351a0854" @@ -4545,6 +4582,11 @@ iconv-lite@0.6, iconv-lite@0.6.3, iconv-lite@^0.6.3: dependencies: safer-buffer ">= 2.1.2 < 3.0.0" +immer@^10.0.3, immer@^10.1.1: + version "10.1.1" + resolved "https://registry.yarnpkg.com/immer/-/immer-10.1.1.tgz#206f344ea372d8ea176891545ee53ccc062db7bc" + integrity sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw== + import-fresh@^3.3.0: version "3.3.1" resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.1.tgz#9cecb56503c0ada1f2741dbbd6546e4b13b57ccf" @@ -5731,6 +5773,14 @@ react-is@^17.0.1: resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== +"react-redux@8.x.x || 9.x.x": + version "9.2.0" + resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-9.2.0.tgz#96c3ab23fb9a3af2cb4654be4b51c989e32366f5" + integrity sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g== + dependencies: + "@types/use-sync-external-store" "^0.0.6" + use-sync-external-store "^1.4.0" + react-refresh@^0.14.0: version "0.14.2" resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.2.tgz#3833da01ce32da470f1f936b9d477da5c7028bf9" @@ -5768,6 +5818,23 @@ readdirp@~3.6.0: dependencies: picomatch "^2.2.1" +recharts@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/recharts/-/recharts-3.0.2.tgz#f81f411f57d5e41a9ab9fc5817be4a58a2181046" + integrity sha512-eDc3ile9qJU9Dp/EekSthQPhAVPG48/uM47jk+PF7VBQngxeW3cwQpPHb/GHC1uqwyCRWXcIrDzuHRVrnRryoQ== + dependencies: + "@reduxjs/toolkit" "1.x.x || 2.x.x" + clsx "^2.1.1" + decimal.js-light "^2.5.1" + es-toolkit "^1.39.3" + eventemitter3 "^5.0.1" + immer "^10.1.1" + react-redux "8.x.x || 9.x.x" + reselect "5.1.1" + tiny-invariant "^1.3.3" + use-sync-external-store "^1.2.2" + victory-vendor "^37.0.2" + redent@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/redent/-/redent-3.0.0.tgz#e557b7998316bb53c9f1f56fa626352c6963059f" @@ -5776,6 +5843,16 @@ redent@^3.0.0: indent-string "^4.0.0" strip-indent "^3.0.0" +redux-thunk@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-3.1.0.tgz#94aa6e04977c30e14e892eae84978c1af6058ff3" + integrity sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw== + +redux@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/redux/-/redux-5.0.1.tgz#97fa26881ce5746500125585d5642c77b6e9447b" + integrity sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w== + regenerate-unicode-properties@^10.2.0: version "10.2.0" resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.0.tgz#626e39df8c372338ea9b8028d1f99dc3fd9c3db0" @@ -5843,6 +5920,11 @@ requires-port@^1.0.0: resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ== +reselect@5.1.1, reselect@^5.1.0: + version "5.1.1" + resolved "https://registry.yarnpkg.com/reselect/-/reselect-5.1.1.tgz#c766b1eb5d558291e5e550298adb0becc24bb72e" + integrity sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w== + resolve-from@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" @@ -6331,6 +6413,11 @@ tiny-inflate@^1.0.0, tiny-inflate@^1.0.2: resolved "https://registry.yarnpkg.com/tiny-inflate/-/tiny-inflate-1.0.3.tgz#122715494913a1805166aaf7c93467933eea26c4" integrity sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw== +tiny-invariant@^1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz#46680b7a873a0d5d10005995eb90a70d74d60127" + integrity sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg== + tinybench@^2.9.0: version "2.9.0" resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.9.0.tgz#103c9f8ba6d7237a47ab6dd1dcff77251863426b" @@ -6555,7 +6642,7 @@ use-isomorphic-layout-effect@^1.1.2: resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.0.tgz#afb292eb284c39219e8cb8d3d62d71999361a21d" integrity sha512-q6ayo8DWoPZT0VdG4u3D3uxcgONP3Mevx2i2b0434cwWBoL+aelL1DzkXI6w3PhTZzUeR2kaVlZn70iCiseP6w== -use-sync-external-store@^1.2.0: +use-sync-external-store@^1.2.0, use-sync-external-store@^1.2.2, use-sync-external-store@^1.4.0: version "1.5.0" resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz#55122e2a3edd2a6c106174c27485e0fd59bcfca0" integrity sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A== @@ -6588,6 +6675,26 @@ vary@~1.1.2: resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== +victory-vendor@^37.0.2: + version "37.3.6" + resolved "https://registry.yarnpkg.com/victory-vendor/-/victory-vendor-37.3.6.tgz#401ac4b029a0b3d33e0cba8e8a1d765c487254da" + integrity sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ== + dependencies: + "@types/d3-array" "^3.0.3" + "@types/d3-ease" "^3.0.0" + "@types/d3-interpolate" "^3.0.1" + "@types/d3-scale" "^4.0.2" + "@types/d3-shape" "^3.1.0" + "@types/d3-time" "^3.0.0" + "@types/d3-timer" "^3.0.0" + d3-array "^3.1.6" + d3-ease "^3.0.1" + d3-interpolate "^3.0.1" + d3-scale "^4.0.2" + d3-shape "^3.1.0" + d3-time "^3.0.0" + d3-timer "^3.0.1" + vite-node@3.0.0-beta.2: version "3.0.0-beta.2" resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-3.0.0-beta.2.tgz#4208a6be384f9e7bba97570114d662ce9c957dc1" From 7fec092aed2a30816d1ab67b421ae34f97e800e1 Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Tue, 1 Jul 2025 15:26:12 +0900 Subject: [PATCH 03/87] =?UTF-8?q?refactor:=20=ED=98=B8=EA=B0=80=EC=B0=BD?= =?UTF-8?q?=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20amcharts=20=3D>=20rec?= =?UTF-8?q?harts=EB=A1=9C=20=EA=B5=90=EC=B2=B4;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tradeview/hooks/useOrderBookData.tsx | 10 +- .../tradeview/hooks/useScrollMiddle.tsx | 18 ++ .../tradeview/types/orderbook.type.ts | 14 +- src/features/tradeview/ui/Orderbook/chart.tsx | 189 ++++-------------- src/features/tradeview/ui/Orderbook/index.tsx | 16 +- 5 files changed, 79 insertions(+), 168 deletions(-) create mode 100644 src/features/tradeview/hooks/useScrollMiddle.tsx diff --git a/src/features/tradeview/hooks/useOrderBookData.tsx b/src/features/tradeview/hooks/useOrderBookData.tsx index 8dd231c..a1c611e 100644 --- a/src/features/tradeview/hooks/useOrderBookData.tsx +++ b/src/features/tradeview/hooks/useOrderBookData.tsx @@ -20,12 +20,14 @@ export default function useOrderBookData(ticker: CoinTicker) { const parsedData = JSON.parse(message.body) as RawOrderBookData; setData({ ticker: parsedData.ticker, - buyOrderBookUnits: parsedData.buyOrderBookUnits.map((unit) => ({ - price: String(unit.price), + buyOrderBookChartData: parsedData.buyOrderBookUnits.map((unit) => ({ + name: String(unit.price), + price: Number(unit.price), size: Number(unit.size), })), - sellOrderBookUnits: parsedData.sellOrderBookUnits.map((unit) => ({ - price: String(unit.price), + sellOrderBookChartData: parsedData.sellOrderBookUnits.map((unit) => ({ + name: String(unit.price), + price: Number(unit.price), size: Number(unit.size), })), }); diff --git a/src/features/tradeview/hooks/useScrollMiddle.tsx b/src/features/tradeview/hooks/useScrollMiddle.tsx new file mode 100644 index 0000000..1efcf89 --- /dev/null +++ b/src/features/tradeview/hooks/useScrollMiddle.tsx @@ -0,0 +1,18 @@ +import { type RefObject, useEffect, useRef } from 'react'; + +export default function useScrollMiddle( + scrollContainerRef: RefObject, + dependency: unknown, +) { + const isFirstRendered = useRef(true); + + useEffect(() => { + const scrollContainer = scrollContainerRef.current; + if (!scrollContainer || !isFirstRendered.current || !dependency) return; + + const middle = scrollContainer.clientHeight / 2; + scrollContainer.scrollTo({ top: middle }); + + isFirstRendered.current = false; + }, [dependency, scrollContainerRef]); +} diff --git a/src/features/tradeview/types/orderbook.type.ts b/src/features/tradeview/types/orderbook.type.ts index 4b3476b..4ef4728 100644 --- a/src/features/tradeview/types/orderbook.type.ts +++ b/src/features/tradeview/types/orderbook.type.ts @@ -1,9 +1,7 @@ -type Price = string; -type Size = number; - -export type OrderBookUnit = { - price: Price; - size: Size; +export type OrderBookChartData = { + name: string; + price: number; + size: number; }; export type OrderBookUnitRaw = { @@ -13,8 +11,8 @@ export type OrderBookUnitRaw = { export type OrderBookData = { ticker: string; - buyOrderBookUnits: OrderBookUnit[]; - sellOrderBookUnits: OrderBookUnit[]; + buyOrderBookChartData: OrderBookChartData[]; + sellOrderBookChartData: OrderBookChartData[]; }; export type RawOrderBookData = { diff --git a/src/features/tradeview/ui/Orderbook/chart.tsx b/src/features/tradeview/ui/Orderbook/chart.tsx index cb380ea..e2e580d 100644 --- a/src/features/tradeview/ui/Orderbook/chart.tsx +++ b/src/features/tradeview/ui/Orderbook/chart.tsx @@ -1,167 +1,50 @@ -import * as am5 from '@amcharts/amcharts5'; -import * as am5xy from '@amcharts/amcharts5/xy'; -import { useEffect, useLayoutEffect, useRef } from 'react'; -import { formatCurrencyKR } from '~/shared/utils'; -import type { OrderBookUnit } from '../../types/orderbook.type'; - -const THEME = { - bull: { - barColor: am5.color('#e12343'), - textColor: am5.color('#fff'), - }, - bear: { - barColor: am5.color('#1772f8'), - textColor: am5.color('#fff'), - }, -}; +import clsx from 'clsx'; +import { + Bar, + BarChart, + LabelList, + ResponsiveContainer, + XAxis, + YAxis, +} from 'recharts'; +import type { OrderBookChartData } from '../../types/orderbook.type'; export type OrderbookChartProps = { - data: OrderBookUnit[]; + data: OrderBookChartData[]; type?: 'bull' | 'bear'; + layout?: 'vertical' | 'horizontal'; }; export default function OrderbookChart({ data, type = 'bull', + layout = 'vertical', }: Readonly) { - const xAxisRef = useRef>(null); - const yAxisRef = useRef>(null); - const seriesRef = useRef(null); - const chartRef = useRef(null); - const rootRef = useRef(null); - - useEffect(() => { - if (!chartRef.current || !yAxisRef.current || !seriesRef.current) return; - - const formattedData = data.map((item) => ({ - price: formatCurrencyKR(+item.price), - size: item.size, - priceY: item.price, - sizeX: item.size, - })); - - yAxisRef.current.data.setAll(formattedData); - seriesRef.current.data.setAll(formattedData); - }, [data]); - - useLayoutEffect(() => { - rootRef.current = am5.Root.new(`orderbook-${type}`); - - chartRef.current = rootRef.current.container.children.push( - am5xy.XYChart.new(rootRef.current, { - panX: false, - panY: false, - wheelX: 'none', - wheelY: 'none', - layout: rootRef.current.verticalLayout, - paddingBottom: 0, - paddingLeft: 0, - paddingRight: 0, - paddingTop: 0, - }), - ); - - chartRef.current.set( - 'background', - am5.Rectangle.new(rootRef.current, { - stroke: am5.color('#fff'), - strokeOpacity: 0, - fillOpacity: 0.05, - }), - ); - const yRenderer = am5xy.AxisRendererY.new(rootRef.current, { - inversed: type === 'bull', - cellStartLocation: 0, - cellEndLocation: 1, - }); - - yRenderer.labels.template.setAll({ - textAlign: 'center', - centerY: am5.p50, - }); - - yAxisRef.current = chartRef.current.yAxes.push( - am5xy.CategoryAxis.new(rootRef.current, { - categoryField: 'price', - renderer: yRenderer, - }), - ); - - xAxisRef.current = chartRef.current.xAxes.push( - am5xy.ValueAxis.new(rootRef.current, { - renderer: am5xy.AxisRendererX.new(rootRef.current, { - strokeOpacity: 1, - stroke: am5.color('#cccccc'), - strokeWidth: 1, - }), - min: 0, - visible: false, - strictMinMax: true, - max: undefined, - autoZoom: true, - }), - ); - - seriesRef.current = chartRef.current.series.push( - am5xy.ColumnSeries.new(rootRef.current, { - name: '실시간 호가', - xAxis: xAxisRef.current, - yAxis: yAxisRef.current, - valueXField: 'size', - categoryYField: 'price', - sequencedInterpolation: true, - tooltip: am5.Tooltip.new(rootRef.current, { - pointerOrientation: 'horizontal', - labelText: "[bold]{priceY.formatNumber('#,###.##')}원 {sizeX}개", - }), - paddingBottom: 0, - paddingTop: 0, - visible: true, - }), - ); - - seriesRef.current.columns.template.setAll({ - height: am5.p100, - strokeOpacity: 1, - stroke: THEME[type].barColor, - strokeWidth: 0.5, - fillOpacity: 0.8, - fill: THEME[type].barColor, - width: am5.p100, - }); - - seriesRef.current?.bullets.push(() => { - if (!rootRef.current) return; - return am5.Bullet.new(rootRef.current, { - locationX: 0, - locationY: 0.5, - sprite: am5.Label.new(rootRef.current, { - centerY: am5.p50, - text: '{sizeX}', - populateText: true, - fill: THEME[type].textColor, - }), - }); - }); - - const cursor = chartRef.current?.set( - 'cursor', - am5xy.XYCursor.new(rootRef.current, {}), - ); - cursor?.lineY.set('forceHidden', true); - cursor?.lineX.set('forceHidden', true); - - return () => { - chartRef.current?.dispose(); - rootRef.current?.dispose(); - }; - }, [type]); + const color = type === 'bull' ? 'red' : 'blue'; return (
+ className={clsx( + 'flex h-full min-h-full w-full', + type === 'bull' ? 'bg-red-100' : 'bg-blue-100', + )} + > +
+ {data.map((item) => ( +
{item.price}
+ ))} +
+
+ + + + + + + + + +
+
); } diff --git a/src/features/tradeview/ui/Orderbook/index.tsx b/src/features/tradeview/ui/Orderbook/index.tsx index cd9d9ab..b41cab4 100644 --- a/src/features/tradeview/ui/Orderbook/index.tsx +++ b/src/features/tradeview/ui/Orderbook/index.tsx @@ -1,5 +1,8 @@ +import { useRef } from 'react'; + import type { CoinTicker } from '~/entities/coin'; import useOrderBookData from '../../hooks/useOrderBookData'; +import useScrollMiddle from '../../hooks/useScrollMiddle'; import OrderbookChart from './chart'; type OrderbookProps = { @@ -8,11 +11,18 @@ type OrderbookProps = { export default function Orderbook({ ticker }: OrderbookProps) { const data = useOrderBookData(ticker); + const scrollContainerRef = useRef(null); + useScrollMiddle(scrollContainerRef, data); return ( -
- {data && } - {data && } +
+ {data && ( + + )} + {data && }
); } From 41e20c7446e3db5b2d5303bc5eb363442a37b3bf Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Tue, 1 Jul 2025 16:11:44 +0900 Subject: [PATCH 04/87] =?UTF-8?q?fix:=20=EB=A0=8C=EB=8D=94=EB=A7=81?= =?UTF-8?q?=EB=A7=88=EB=8B=A4=20=EC=8A=A4=ED=81=AC=EB=A1=A4=EC=9D=B4=20?= =?UTF-8?q?=EC=9B=80=EC=A7=81=EC=9D=B4=EB=8A=94=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/tradeview/ui/Orderbook/chart.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/features/tradeview/ui/Orderbook/chart.tsx b/src/features/tradeview/ui/Orderbook/chart.tsx index e2e580d..15ec349 100644 --- a/src/features/tradeview/ui/Orderbook/chart.tsx +++ b/src/features/tradeview/ui/Orderbook/chart.tsx @@ -7,6 +7,7 @@ import { XAxis, YAxis, } from 'recharts'; +import { formatCurrencyKR } from '~/shared/utils'; import type { OrderBookChartData } from '../../types/orderbook.type'; export type OrderbookChartProps = { @@ -25,13 +26,15 @@ export default function OrderbookChart({ return (
{data.map((item) => ( -
{item.price}
+
+ {formatCurrencyKR(item.price)} +
))}
From 5733e2809d8e4c73a1773f8d459a0b93c49c116d Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Tue, 1 Jul 2025 16:24:05 +0900 Subject: [PATCH 05/87] =?UTF-8?q?style:=20=ED=98=B8=EA=B0=80=EC=B0=BD=20?= =?UTF-8?q?=EC=83=89=EC=83=81=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/tradeview/ui/Orderbook/chart.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/features/tradeview/ui/Orderbook/chart.tsx b/src/features/tradeview/ui/Orderbook/chart.tsx index 15ec349..99427f4 100644 --- a/src/features/tradeview/ui/Orderbook/chart.tsx +++ b/src/features/tradeview/ui/Orderbook/chart.tsx @@ -21,18 +21,22 @@ export default function OrderbookChart({ type = 'bull', layout = 'vertical', }: Readonly) { - const color = type === 'bull' ? 'red' : 'blue'; + const color = type === 'bull' ? '#FDD2D7' : '#CDE0FE'; return (
{data.map((item) => ( -
+
{formatCurrencyKR(item.price)}
))} From d20697d4ad68611092e04c3ced5186d9f1be0d2c Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Wed, 2 Jul 2025 03:32:36 +0900 Subject: [PATCH 06/87] =?UTF-8?q?refactor:=20fs=20routes=20=3D>=20code=20r?= =?UTF-8?q?outes=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit code routes가 전체적인 routes 파악과 유지보수가 쉽다는 생각이 들어서 code routes로 변경하였습니다. --- src/app/routes.ts | 14 +++++++++++--- src/app/routes/catchTrade.tsx | 9 +++++++++ .../{trade.$ticker.login.tsx => login.tsx} | 0 src/app/routes/profile.tsx | 5 +++++ src/app/routes/trade.$.tsx | 5 ----- .../routes/{trade.$ticker.tsx => trade.tsx} | 2 +- src/widgets/user/index.ts | 1 + src/widgets/user/ui/ProfileModal/index.tsx | 18 ++++++++++++++++++ 8 files changed, 45 insertions(+), 9 deletions(-) create mode 100644 src/app/routes/catchTrade.tsx rename src/app/routes/{trade.$ticker.login.tsx => login.tsx} (100%) create mode 100644 src/app/routes/profile.tsx delete mode 100644 src/app/routes/trade.$.tsx rename src/app/routes/{trade.$ticker.tsx => trade.tsx} (98%) create mode 100644 src/widgets/user/index.ts create mode 100644 src/widgets/user/ui/ProfileModal/index.tsx diff --git a/src/app/routes.ts b/src/app/routes.ts index 8e87801..69ef163 100644 --- a/src/app/routes.ts +++ b/src/app/routes.ts @@ -1,6 +1,14 @@ /* v8 ignore start */ -import type { RouteConfig } from '@react-router/dev/routes'; -import { flatRoutes } from '@react-router/fs-routes'; +import { type RouteConfig, prefix, route } from '@react-router/dev/routes'; -export default flatRoutes() satisfies RouteConfig; +export default [ + route('', './routes/_index.tsx', [route('trade', './routes/catchTrade.tsx')]), + route('callback', './routes/callback.tsx'), + ...prefix('trade', [ + route(':ticker', './routes/trade.tsx', [ + route('login', './routes/login.tsx'), + route('profile', './routes/profile.tsx'), + ]), + ]), +] satisfies RouteConfig; /* v8 ignore end */ diff --git a/src/app/routes/catchTrade.tsx b/src/app/routes/catchTrade.tsx new file mode 100644 index 0000000..ada4379 --- /dev/null +++ b/src/app/routes/catchTrade.tsx @@ -0,0 +1,9 @@ +import { Outlet, redirect } from 'react-router'; + +export async function loader() { + return redirect('/trade/BTC'); +} + +export default function CatchTradeRouteComponent() { + return ; +} diff --git a/src/app/routes/trade.$ticker.login.tsx b/src/app/routes/login.tsx similarity index 100% rename from src/app/routes/trade.$ticker.login.tsx rename to src/app/routes/login.tsx diff --git a/src/app/routes/profile.tsx b/src/app/routes/profile.tsx new file mode 100644 index 0000000..7e25912 --- /dev/null +++ b/src/app/routes/profile.tsx @@ -0,0 +1,5 @@ +import { ProfileModal } from '~/widgets/user'; + +export default function ProfileRouteComponent() { + return ; +} diff --git a/src/app/routes/trade.$.tsx b/src/app/routes/trade.$.tsx deleted file mode 100644 index b345b1f..0000000 --- a/src/app/routes/trade.$.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from 'react-router'; - -export async function loader() { - return redirect('/trade/BTC'); -} diff --git a/src/app/routes/trade.$ticker.tsx b/src/app/routes/trade.tsx similarity index 98% rename from src/app/routes/trade.$ticker.tsx rename to src/app/routes/trade.tsx index 701988b..ae6598f 100644 --- a/src/app/routes/trade.$ticker.tsx +++ b/src/app/routes/trade.tsx @@ -14,7 +14,7 @@ import Container from '~/shared/ui/Container'; import ContainerTitle from '~/shared/ui/ContainerTitle'; import { NavBar, SideBar } from '~/widgets/navbar'; import { useUserId } from '../provider/UserInfoProvider'; -import type { Route } from './+types/trade.$ticker'; +import type { Route } from './+types/trade'; const LazyStockChart = lazy(() => import('~/features/tradeview/ui/StockChart')); const LazyOrderBook = lazy(() => import('~/features/tradeview/ui/Orderbook')); diff --git a/src/widgets/user/index.ts b/src/widgets/user/index.ts new file mode 100644 index 0000000..1072635 --- /dev/null +++ b/src/widgets/user/index.ts @@ -0,0 +1 @@ +export { default as ProfileModal } from './ui/ProfileModal'; diff --git a/src/widgets/user/ui/ProfileModal/index.tsx b/src/widgets/user/ui/ProfileModal/index.tsx new file mode 100644 index 0000000..cc3c437 --- /dev/null +++ b/src/widgets/user/ui/ProfileModal/index.tsx @@ -0,0 +1,18 @@ +import { useRef } from 'react'; +import { useNavigate } from 'react-router'; +import useClickOutside from '~/shared/hooks/useClickOutside'; +import Backdrop from '~/shared/ui/Backdrop'; +import Modal from '~/shared/ui/Modal'; + +export default function ProfileModal() { + const navigate = useNavigate(); + const modalRef = useRef(null); + useClickOutside(modalRef, () => navigate(-1)); + return ( + + +
ProfileModal
+
+
+ ); +} From 4fe74427496c310c7f142703fc5cdbb46f1672bb Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Wed, 2 Jul 2025 23:15:55 +0900 Subject: [PATCH 07/87] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EB=84=A4=EB=B8=8C=EB=A7=81=ED=81=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/ui/Button/index.tsx | 15 +++++++++++++-- src/widgets/navbar/ui/NavBar/index.tsx | 17 +++++++++++++++-- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/shared/ui/Button/index.tsx b/src/shared/ui/Button/index.tsx index f7874d7..5fe50f8 100644 --- a/src/shared/ui/Button/index.tsx +++ b/src/shared/ui/Button/index.tsx @@ -1,13 +1,24 @@ +import clsx from 'clsx'; import type { ButtonHTMLAttributes, ReactNode } from 'react'; export type ButtonProps = { children: ReactNode; + buttonStyle?: 'primary' | 'secondary'; } & ButtonHTMLAttributes; -export default function Button({ children, ...props }: ButtonProps) { +export default function Button({ + children, + buttonStyle = 'primary', + ...props +}: ButtonProps) { return ( ; + const LogoutButton = () => ( + + ); + + const ProfileButton = () => ( + + + + ); return ( <> @@ -48,7 +58,10 @@ export default function NavBar({ - {isLoggedIn ? : } +
+ {isLoggedIn ? : null} + {isLoggedIn ? : } +
From 29276c59fe3df6aedb1b57de9c618851c49ba27c Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Thu, 3 Jul 2025 02:49:25 +0900 Subject: [PATCH 08/87] =?UTF-8?q?feat:=20=ED=8C=8C=EC=9D=B4=20=EC=B0=A8?= =?UTF-8?q?=ED=8A=B8=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 코인 자산 비율을 확인할 수 있는 파이 차트 컴포넌트를 추가했습니다. --- src/features/profile/const/chart.const.ts | 25 +++++++++++++ src/features/profile/index.ts | 1 + src/features/profile/types/chart.type.ts | 6 +++ .../profile/ui/CoinPieChart/index.tsx | 32 ++++++++++++++++ .../profile/ui/CoinPieChartLabel/index.tsx | 31 ++++++++++++++++ .../profile/ui/CoinPieChartTooltip/index.tsx | 37 +++++++++++++++++++ src/features/profile/utils/index.ts | 18 +++++++++ 7 files changed, 150 insertions(+) create mode 100644 src/features/profile/const/chart.const.ts create mode 100644 src/features/profile/index.ts create mode 100644 src/features/profile/types/chart.type.ts create mode 100644 src/features/profile/ui/CoinPieChart/index.tsx create mode 100644 src/features/profile/ui/CoinPieChartLabel/index.tsx create mode 100644 src/features/profile/ui/CoinPieChartTooltip/index.tsx create mode 100644 src/features/profile/utils/index.ts diff --git a/src/features/profile/const/chart.const.ts b/src/features/profile/const/chart.const.ts new file mode 100644 index 0000000..d7cb750 --- /dev/null +++ b/src/features/profile/const/chart.const.ts @@ -0,0 +1,25 @@ +export const COLORS = [ + { backgroundColor: '#FF3B30', textColor: '#FFFFFF' }, // 밝은 빨강 - 가장 눈에 띄는 색상 + { backgroundColor: '#007AFF', textColor: '#FFFFFF' }, // 밝은 파랑 + { backgroundColor: '#5856D6', textColor: '#FFFFFF' }, // 밝은 보라 + { backgroundColor: '#FF9500', textColor: '#FFFFFF' }, // 밝은 주황 + { backgroundColor: '#4CD964', textColor: '#000000' }, // 밝은 녹색 + { backgroundColor: '#FFCC00', textColor: '#000000' }, // 밝은 노랑 + { backgroundColor: '#34AADC', textColor: '#FFFFFF' }, // 밝은 하늘 + { backgroundColor: '#FF2D55', textColor: '#FFFFFF' }, // 밝은 핑크 + { backgroundColor: '#5AC8FA', textColor: '#FFFFFF' }, // 중간 하늘 + { backgroundColor: '#AF52DE', textColor: '#FFFFFF' }, // 중간 보라 + { backgroundColor: '#C86D00', textColor: '#FFFFFF' }, // 중간 갈색 + { backgroundColor: '#8E8E93', textColor: '#000000' }, // 중간 회색 + { backgroundColor: '#1C1C1E', textColor: '#FFFFFF' }, // 어두운 검정 + { backgroundColor: '#636366', textColor: '#FFFFFF' }, // 어두운 회색 + { backgroundColor: '#48484A', textColor: '#FFFFFF' }, // 더 어두운 회색 + { backgroundColor: '#3A3A3C', textColor: '#FFFFFF' }, // 매우 어두운 회색 + { backgroundColor: '#2C2C2E', textColor: '#FFFFFF' }, // 거의 검은 회색 + { backgroundColor: '#007356', textColor: '#FFFFFF' }, // 어두운 녹색 + { backgroundColor: '#004080', textColor: '#FFFFFF' }, // 어두운 파랑 + { backgroundColor: '#660033', textColor: '#FFFFFF' }, // 어두운 자주 - 가장 덜 눈에 띄는 색상 +] as const; + +export const RADIAN = Math.PI / 180; +export const LABEL_POSTION_WEIGHT = 0.6; diff --git a/src/features/profile/index.ts b/src/features/profile/index.ts new file mode 100644 index 0000000..4436046 --- /dev/null +++ b/src/features/profile/index.ts @@ -0,0 +1 @@ +export { default as CoinPieChart } from './ui/CoinPieChart'; diff --git a/src/features/profile/types/chart.type.ts b/src/features/profile/types/chart.type.ts new file mode 100644 index 0000000..aeae166 --- /dev/null +++ b/src/features/profile/types/chart.type.ts @@ -0,0 +1,6 @@ +export type CoinPieChartData = { + ticker: string; + totalPrice: number; + averagePrice: number; + quantity: number; +}; diff --git a/src/features/profile/ui/CoinPieChart/index.tsx b/src/features/profile/ui/CoinPieChart/index.tsx new file mode 100644 index 0000000..d7bb1f0 --- /dev/null +++ b/src/features/profile/ui/CoinPieChart/index.tsx @@ -0,0 +1,32 @@ +import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts'; +import { COLORS } from '../../const/chart.const'; +import type { CoinPieChartData } from '../../types/chart.type'; +import CoinPieChartLabel from '../CoinPieChartLabel'; +import CoinPieChartTooltip from '../CoinPieChartTooltip'; + +type CoinPieChartProps = { coinData: CoinPieChartData[] }; + +export default function CoinPieChart({ coinData }: CoinPieChartProps) { + return ( + + + + {coinData.map((item, index) => ( + + ))} + + + + + ); +} diff --git a/src/features/profile/ui/CoinPieChartLabel/index.tsx b/src/features/profile/ui/CoinPieChartLabel/index.tsx new file mode 100644 index 0000000..a47cced --- /dev/null +++ b/src/features/profile/ui/CoinPieChartLabel/index.tsx @@ -0,0 +1,31 @@ +import type { PieLabelProps } from 'recharts/types/polar/Pie'; +import { COLORS, LABEL_POSTION_WEIGHT, RADIAN } from '../../const/chart.const'; + +export default function CoinPieChartLabel({ + cx, + cy, + midAngle, + innerRadius, + outerRadius, + percent, + index, + name, +}: PieLabelProps) { + const radius = + innerRadius + (outerRadius - innerRadius) * LABEL_POSTION_WEIGHT; + const x = midAngle ? cx + radius * Math.cos(-midAngle * RADIAN) : cx; + const y = midAngle ? cy + radius * Math.sin(-midAngle * RADIAN) : cy; + const color = index ? COLORS[index % COLORS.length].textColor : '#fff'; + + return ( + + {percent ? `${name} ${(percent * 100).toFixed(0)}%` : ''} + + ); +} diff --git a/src/features/profile/ui/CoinPieChartTooltip/index.tsx b/src/features/profile/ui/CoinPieChartTooltip/index.tsx new file mode 100644 index 0000000..962869d --- /dev/null +++ b/src/features/profile/ui/CoinPieChartTooltip/index.tsx @@ -0,0 +1,37 @@ +import type { TooltipContentProps } from 'recharts/types/component/Tooltip'; +import { formatCurrencyKR } from '~/shared/utils'; +import type { CoinPieChartData } from '../../types/chart.type'; + +export default function CoinPieChartTooltip({ + active, + payload, +}: TooltipContentProps) { + if (!active) return null; + + const payloadData = payload[0].payload as CoinPieChartData; + return ( +
+ + {payloadData.ticker} + +

+ 평균매수가격:{' '} + + {formatCurrencyKR(payloadData.averagePrice)}원 + +

+

+ 매수수량:{' '} + + {payloadData.quantity}개 + +

+

+ 총매수금액:{' '} + + {formatCurrencyKR(payloadData.totalPrice)}원 + +

+
+ ); +} diff --git a/src/features/profile/utils/index.ts b/src/features/profile/utils/index.ts new file mode 100644 index 0000000..0cb5e16 --- /dev/null +++ b/src/features/profile/utils/index.ts @@ -0,0 +1,18 @@ +import type { CoinPieChartData } from '../types/chart.type'; + +export function generateCoinPieChartData( + data: { + ticker: string; + size: number; + price: number; + }[], +): CoinPieChartData[] { + return data + .map((item) => ({ + ticker: item.ticker, + totalPrice: item.size * item.price, + averagePrice: item.price, + quantity: item.size, + })) + .sort((a, b) => b.totalPrice - a.totalPrice); +} From 83bfcbcf0876057a284767262d5d6745e6e21ec1 Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Thu, 3 Jul 2025 18:04:41 +0900 Subject: [PATCH 09/87] =?UTF-8?q?feat:=20=EC=BD=94=EC=9D=B8=20=EC=9E=90?= =?UTF-8?q?=EC=82=B0=20=EB=AA=A9=EB=A1=9D=EC=9D=84=20=EB=B3=B4=EC=97=AC?= =?UTF-8?q?=EC=A3=BC=EB=8A=94=20=ED=85=8C=EC=9D=B4=EB=B8=94=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 --- src/entities/user/index.ts | 1 + src/entities/user/types/user.type.ts | 13 +++-- .../profile/ui/CoinAssetTable/index.tsx | 57 +++++++++++++++++++ src/features/profile/utils/index.ts | 6 +- 4 files changed, 68 insertions(+), 9 deletions(-) create mode 100644 src/features/profile/ui/CoinAssetTable/index.tsx diff --git a/src/entities/user/index.ts b/src/entities/user/index.ts index 41fc40d..e463737 100644 --- a/src/entities/user/index.ts +++ b/src/entities/user/index.ts @@ -1,3 +1,4 @@ /* v8 ignore start */ export { default as api } from './api/user.endpoint'; +export type { Wallet } from './types/user.type'; /* v8 ignore end */ diff --git a/src/entities/user/types/user.type.ts b/src/entities/user/types/user.type.ts index e722c01..7a1b23e 100644 --- a/src/entities/user/types/user.type.ts +++ b/src/entities/user/types/user.type.ts @@ -1,12 +1,13 @@ import type { Response } from '~/shared/types/api'; -type Wallet = { - accountId: number; - buyPrice: string; - id: number; - roi: string; - size: string; +export type Wallet = { + name: string; ticker: string; + accountId: number; + buyPrice: number; + currentPrice: number; + roi: number; + size: number; }; type UserInfoResponseData = { diff --git a/src/features/profile/ui/CoinAssetTable/index.tsx b/src/features/profile/ui/CoinAssetTable/index.tsx new file mode 100644 index 0000000..e50c842 --- /dev/null +++ b/src/features/profile/ui/CoinAssetTable/index.tsx @@ -0,0 +1,57 @@ +import clsx from 'clsx'; +import type { Wallet } from '~/entities/user'; +import { formatCurrencyKR } from '~/shared/utils'; + +type CoinAssetTableProps = { + wallets: Wallet[]; +}; + +const TABLE_HEAD_HEIGHT = 32; +const TABLE_ROW_HEIGHT = 36; + +export default function CoinAssetTable({ wallets }: CoinAssetTableProps) { + return ( +
+ + + + + + + + + + + {wallets.map((wallet) => ( + 0 ? 'bg-red-50' : 'bg-blue-50', + )} + style={{ height: `${TABLE_ROW_HEIGHT}px` }} + > + + + + + + ))} + +
티커평균단가평가금액수익률
{wallet.ticker}{formatCurrencyKR(wallet.buyPrice)}원{formatCurrencyKR(wallet.buyPrice * wallet.size)}원 0 ? 'text-red-500' : 'text-blue-500', + )} + > + {wallet.roi}% +
+
+ ); +} diff --git a/src/features/profile/utils/index.ts b/src/features/profile/utils/index.ts index 0cb5e16..14dd30e 100644 --- a/src/features/profile/utils/index.ts +++ b/src/features/profile/utils/index.ts @@ -4,14 +4,14 @@ export function generateCoinPieChartData( data: { ticker: string; size: number; - price: number; + buyPrice: number; }[], ): CoinPieChartData[] { return data .map((item) => ({ ticker: item.ticker, - totalPrice: item.size * item.price, - averagePrice: item.price, + totalPrice: item.size * item.buyPrice, + averagePrice: item.buyPrice, quantity: item.size, })) .sort((a, b) => b.totalPrice - a.totalPrice); From 0aa75c7051ca8dc0bc60309fe8cdb941d0cfa7a7 Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Sat, 5 Jul 2025 23:51:44 +0900 Subject: [PATCH 10/87] =?UTF-8?q?feat:=20AssetInfoGraphic=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 --- src/entities/user/index.ts | 2 +- src/entities/user/types/user.type.ts | 3 +- .../profile/ui/AssetInfoGraphic/index.tsx | 42 +++++++++++++++++++ .../profile/ui/AssetInfoGraphicText/index.tsx | 34 +++++++++++++++ 4 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 src/features/profile/ui/AssetInfoGraphic/index.tsx create mode 100644 src/features/profile/ui/AssetInfoGraphicText/index.tsx diff --git a/src/entities/user/index.ts b/src/entities/user/index.ts index e463737..3829c50 100644 --- a/src/entities/user/index.ts +++ b/src/entities/user/index.ts @@ -1,4 +1,4 @@ /* v8 ignore start */ export { default as api } from './api/user.endpoint'; -export type { Wallet } from './types/user.type'; +export type { Wallet, UserInfoResponseData } from './types/user.type'; /* v8 ignore end */ diff --git a/src/entities/user/types/user.type.ts b/src/entities/user/types/user.type.ts index 7a1b23e..fa96598 100644 --- a/src/entities/user/types/user.type.ts +++ b/src/entities/user/types/user.type.ts @@ -10,12 +10,13 @@ export type Wallet = { size: number; }; -type UserInfoResponseData = { +export type UserInfoResponseData = { userId: number; email: string; nickname: string; provider: string; cash: number; + totalAssetAmount: number; wallets: Wallet[]; }; diff --git a/src/features/profile/ui/AssetInfoGraphic/index.tsx b/src/features/profile/ui/AssetInfoGraphic/index.tsx new file mode 100644 index 0000000..bf5d47d --- /dev/null +++ b/src/features/profile/ui/AssetInfoGraphic/index.tsx @@ -0,0 +1,42 @@ +import type { UserInfoResponseData } from '~/entities/user'; +import { CoinPieChart } from '~/features/profile'; +import { generateCoinPieChartData } from '../../utils'; +import AssetInfoGraphicText from '../AssetInfoGraphicText'; + +type AssetInfoGraphicProps = { + userInfo: UserInfoResponseData; +}; + +export default function AssetInfoGraphic({ userInfo }: AssetInfoGraphicProps) { + const { wallets, totalAssetAmount } = userInfo; + const coinData = generateCoinPieChartData(wallets); + const roiAverage = + wallets.reduce((acc, item) => acc + item.roi, 0) / wallets.length; + + return ( +
+
+ +
+
+
+ + +
+ +
+
+ ); +} diff --git a/src/features/profile/ui/AssetInfoGraphicText/index.tsx b/src/features/profile/ui/AssetInfoGraphicText/index.tsx new file mode 100644 index 0000000..ef0eb55 --- /dev/null +++ b/src/features/profile/ui/AssetInfoGraphicText/index.tsx @@ -0,0 +1,34 @@ +import clsx from 'clsx'; +import { formatCurrencyKR } from '~/shared/utils'; + +type AssetInfoGraphicTextProps = { + label: string; + type: 'money' | 'percent'; + value: number; +}; + +export default function AssetInfoGraphicText({ + label, + type, + value, +}: AssetInfoGraphicTextProps) { + const valueText = + type === 'money' ? `${formatCurrencyKR(value)}원` : `${value.toFixed(4)}%`; + const color = + value > 0 ? 'text-red-500' : value < 0 ? 'text-blue-500' : 'text-gray-800'; + const valueTextClassName = type === 'percent' ? color : ''; + + return ( +
+

{label}

+

+ {valueText} +

+
+ ); +} From 0976cc2a74c46f177073399f2b6b5511f5d4d1cc Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Mon, 7 Jul 2025 17:35:22 +0900 Subject: [PATCH 11/87] =?UTF-8?q?config:=20Sentry=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sentry 무료 플랜이 끝나서 Sentry 설정을 주석처리하였습니다. 나중에 Sentry를 onpremise로 구축해 주석 해제 예정입니다. --- package.json | 8 +++----- react-router.config.ts | 8 ++++---- src/app/entry.client.tsx | 40 ++++++++++++++++++++-------------------- src/app/entry.server.tsx | 12 ++++++------ vite.config.ts | 21 +++++++++------------ 5 files changed, 42 insertions(+), 47 deletions(-) diff --git a/package.json b/package.json index 4e58f27..d293641 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,9 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "NODE_OPTIONS='--import ./instrument.server.mjs' react-router dev", + "dev": "react-router dev", "build": "react-router build", - "start": "NODE_ENV=production NODE_OPTIONS='--import ./instrument.server.mjs' react-router-serve ./build/server/index.js", + "start": "react-router-serve ./build/server/index.js", "typecheck": "react-router typegen && tsc", "test": "vitest", "coverage": "vitest run --coverage", @@ -69,8 +69,6 @@ }, "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e", "msw": { - "workerDirectory": [ - "public" - ] + "workerDirectory": ["public"] } } diff --git a/react-router.config.ts b/react-router.config.ts index 55fd0f7..e9c5e89 100644 --- a/react-router.config.ts +++ b/react-router.config.ts @@ -1,10 +1,10 @@ import type { Config } from '@react-router/dev/config'; -import { sentryOnBuildEnd } from '@sentry/react-router'; export default { appDirectory: './src/app', ssr: true, - buildEnd: async ({ viteConfig, reactRouterConfig, buildManifest }) => { - await sentryOnBuildEnd({viteConfig, reactRouterConfig, buildManifest}); - }, + /* Sentry 설정 제외 */ + // buildEnd: async ({ viteConfig, reactRouterConfig, buildManifest }) => { + // await sentryOnBuildEnd({viteConfig, reactRouterConfig, buildManifest}); + // }, } satisfies Config; diff --git a/src/app/entry.client.tsx b/src/app/entry.client.tsx index 2ed6ee5..78ea235 100644 --- a/src/app/entry.client.tsx +++ b/src/app/entry.client.tsx @@ -1,33 +1,33 @@ -import * as Sentry from '@sentry/react-router'; /* v8 ignore start */ import { startTransition } from 'react'; import { hydrateRoot } from 'react-dom/client'; import { HydratedRouter } from 'react-router/dom'; -Sentry.init({ - dsn: 'https://8343c6ee467e6f35f22c570a68cd2e6e@o4509544992407552.ingest.us.sentry.io/4509548888391680', +/* Sentry 설정 제외 */ +// Sentry.init({ +// dsn: 'https://8343c6ee467e6f35f22c570a68cd2e6e@o4509544992407552.ingest.us.sentry.io/4509548888391680', - sendDefaultPii: true, +// sendDefaultPii: true, - integrations: [ - Sentry.reactRouterTracingIntegration(), - Sentry.replayIntegration(), - Sentry.feedbackIntegration({ - colorScheme: 'system', - }), - ], +// integrations: [ +// Sentry.reactRouterTracingIntegration(), +// Sentry.replayIntegration(), +// Sentry.feedbackIntegration({ +// colorScheme: 'system', +// }), +// ], - _experiments: { enableLogs: true }, +// _experiments: { enableLogs: true }, - tracesSampleRate: 1.0, +// tracesSampleRate: 1.0, - // Set `tracePropagationTargets` to declare which URL(s) should have trace propagation enabled - tracePropagationTargets: [/^\//, /^https:\/\/investfuture\.my\/api/], - // Capture Replay for 10% of all sessions, - // plus 100% of sessions with an error - replaysSessionSampleRate: 0.1, - replaysOnErrorSampleRate: 1.0, -}); +// // Set `tracePropagationTargets` to declare which URL(s) should have trace propagation enabled +// tracePropagationTargets: [/^\//, /^https:\/\/investfuture\.my\/api/], +// // Capture Replay for 10% of all sessions, +// // plus 100% of sessions with an error +// replaysSessionSampleRate: 0.1, +// replaysOnErrorSampleRate: 1.0, +// }); async function prepareApp() { return Promise.resolve(); diff --git a/src/app/entry.server.tsx b/src/app/entry.server.tsx index 1a6c146..1c512a6 100644 --- a/src/app/entry.server.tsx +++ b/src/app/entry.server.tsx @@ -2,10 +2,6 @@ import { PassThrough } from 'node:stream'; import { createReadableStreamFromReadable } from '@react-router/node'; -import { - getMetaTagTransformer, - wrapSentryHandleRequest, -} from '@sentry/react-router'; import { isbot } from 'isbot'; import type { RenderToPipeableStreamOptions } from 'react-dom/server'; import { renderToPipeableStream } from 'react-dom/server'; @@ -51,7 +47,9 @@ function handleRequest( }), ); - pipe(getMetaTagTransformer(body)); + /* Sentry 설정 제외 */ + // pipe(getMetaTagTransformer(body)); + pipe(body); }, onShellError(error: unknown) { reject(error); @@ -75,5 +73,7 @@ function handleRequest( }); } -export default wrapSentryHandleRequest(handleRequest); +/* Sentry 설정 제외 */ +// export default wrapSentryHandleRequest(handleRequest); +export default handleRequest; /* v8 ignore end */ diff --git a/vite.config.ts b/vite.config.ts index 1de1612..c3b2740 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,21 +1,18 @@ import { reactRouter } from '@react-router/dev/vite'; -import { - type SentryReactRouterBuildOptions, - sentryReactRouter, -} from '@sentry/react-router'; import svgr from '@svgr/rollup'; import tailwindcss from '@tailwindcss/vite'; import { visualizer } from 'rollup-plugin-visualizer'; -import { type PluginOption, defineConfig, loadEnv } from 'vite'; +import { type PluginOption, defineConfig } from 'vite'; import tsconfigPaths from 'vite-tsconfig-paths'; +/* Sentry 설정 제외 */ export default defineConfig((config) => { - const env = loadEnv(config.mode, process.cwd()); - const sentryConfig: SentryReactRouterBuildOptions = { - org: env.VITE_SENTRY_ORG, - project: env.VITE_SENTRY_PROJECT, - authToken: env.VITE_SENTRY_AUTH_TOKEN, - }; + // const env = loadEnv(config.mode, process.cwd()); + // const sentryConfig: SentryReactRouterBuildOptions = { + // org: env.VITE_SENTRY_ORG, + // project: env.VITE_SENTRY_PROJECT, + // authToken: env.VITE_SENTRY_AUTH_TOKEN, + // }; return { plugins: [ @@ -24,7 +21,7 @@ export default defineConfig((config) => { reactRouter(), tsconfigPaths(), visualizer() as PluginOption, - sentryReactRouter(sentryConfig, config), + // sentryReactRouter(sentryConfig, config), ], optimizeDeps: { exclude: ['@amcharts/amcharts5'], From 98884cd04040d89e835e6150c1b59ce7f2d972da Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Tue, 8 Jul 2025 17:31:43 +0900 Subject: [PATCH 12/87] =?UTF-8?q?feat:=20=EC=88=AB=EC=9E=90=EA=B0=80=200?= =?UTF-8?q?=20=EB=B6=80=ED=84=B0=20=EC=A6=9D=EA=B0=80=ED=95=98=EB=8A=94=20?= =?UTF-8?q?IncrementingNumber=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=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 --- .../profile/ui/AssetInfoGraphic/index.tsx | 14 ++++--- .../profile/ui/AssetInfoGraphicText/index.tsx | 10 +++-- src/shared/ui/IncrementingNumber/index.tsx | 38 +++++++++++++++++++ 3 files changed, 52 insertions(+), 10 deletions(-) create mode 100644 src/shared/ui/IncrementingNumber/index.tsx diff --git a/src/features/profile/ui/AssetInfoGraphic/index.tsx b/src/features/profile/ui/AssetInfoGraphic/index.tsx index bf5d47d..42787ce 100644 --- a/src/features/profile/ui/AssetInfoGraphic/index.tsx +++ b/src/features/profile/ui/AssetInfoGraphic/index.tsx @@ -19,7 +19,7 @@ export default function AssetInfoGraphic({ userInfo }: AssetInfoGraphicProps) {
-
+
- +
+ +
); diff --git a/src/features/profile/ui/AssetInfoGraphicText/index.tsx b/src/features/profile/ui/AssetInfoGraphicText/index.tsx index ef0eb55..df34031 100644 --- a/src/features/profile/ui/AssetInfoGraphicText/index.tsx +++ b/src/features/profile/ui/AssetInfoGraphicText/index.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx'; -import { formatCurrencyKR } from '~/shared/utils'; +import IncrementingNumber from '~/shared/ui/IncrementingNumber'; type AssetInfoGraphicTextProps = { label: string; @@ -12,8 +12,7 @@ export default function AssetInfoGraphicText({ type, value, }: AssetInfoGraphicTextProps) { - const valueText = - type === 'money' ? `${formatCurrencyKR(value)}원` : `${value.toFixed(4)}%`; + const valueUnit = type === 'money' ? '원' : '%'; const color = value > 0 ? 'text-red-500' : value < 0 ? 'text-blue-500' : 'text-gray-800'; const valueTextClassName = type === 'percent' ? color : ''; @@ -27,7 +26,10 @@ export default function AssetInfoGraphicText({ valueTextClassName, )} > - {valueText} + + {value} + + {valueUnit}

); diff --git a/src/shared/ui/IncrementingNumber/index.tsx b/src/shared/ui/IncrementingNumber/index.tsx new file mode 100644 index 0000000..e64b518 --- /dev/null +++ b/src/shared/ui/IncrementingNumber/index.tsx @@ -0,0 +1,38 @@ +import { animate, motion, useMotionValue, useTransform } from 'motion/react'; +import { useEffect } from 'react'; +import { formatCurrencyKR } from '~/shared/utils'; + +type IncrementingNumberProps = { + children: number | string; + formatToCurrencyKr?: boolean; + duration?: number; +}; + +export default function IncrementingNumber({ + children, + formatToCurrencyKr = false, + duration = 1, +}: IncrementingNumberProps) { + const number = Number(children); + const value = useMotionValue(0); + const rounded = useTransform(() => + formatToCurrencyKr + ? formatCurrencyKR(Math.round(value.get())) + : String(Math.round(value.get())), + ); + + useEffect(() => { + const control = animate(value, number, { + duration, + ease: 'easeOut', + }); + + return () => control.stop(); + }, [number, value, duration]); + + if (typeof children !== 'number' || Number.isNaN(number)) { + throw new Error('children must be a number'); + } + + return {rounded}; +} From baf47ccbff7c373cb6c98df2ee7de6ef4647c2d9 Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Tue, 8 Jul 2025 23:13:48 +0900 Subject: [PATCH 13/87] =?UTF-8?q?feat:=20Tab=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=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 --- src/shared/ui/Tab/index.tsx | 54 +++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 src/shared/ui/Tab/index.tsx diff --git a/src/shared/ui/Tab/index.tsx b/src/shared/ui/Tab/index.tsx new file mode 100644 index 0000000..faeb597 --- /dev/null +++ b/src/shared/ui/Tab/index.tsx @@ -0,0 +1,54 @@ +import { motion } from 'motion/react'; +import type { MouseEvent } from 'react'; + +type TabItem = { + value: string; + label: string; +}; + +type TabProps = { + items: TabItem[]; + selected: TabItem['value']; + onClick?: (value: TabItem['value']) => void; +}; + +export default function Tab({ items, selected, onClick }: Readonly) { + const handleTabClick = (e: MouseEvent) => { + const value = e.currentTarget.value; + onClick?.(value); + }; + + return ( +
+
    + {items.map((item, index) => ( + <> +
  • + + {selected === item.value ? ( + + ) : ( + + )} +
  • + {index < items.length - 1 && ( +
  • +
    +
  • + )} + + ))} +
+
+ ); +} From b8594220b4f71649911c19966a030bff8f6c5c5d Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Fri, 11 Jul 2025 01:47:34 +0900 Subject: [PATCH 14/87] =?UTF-8?q?fix:=20=EB=B6=80=EB=AA=A8=EA=B0=80=20flex?= =?UTF-8?q?=20container=EC=9D=BC=EB=95=8C=20=EC=A4=84=EC=96=B4=EB=93=A4?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/profile/ui/CoinAssetTable/index.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/features/profile/ui/CoinAssetTable/index.tsx b/src/features/profile/ui/CoinAssetTable/index.tsx index e50c842..ffd9772 100644 --- a/src/features/profile/ui/CoinAssetTable/index.tsx +++ b/src/features/profile/ui/CoinAssetTable/index.tsx @@ -11,7 +11,7 @@ const TABLE_ROW_HEIGHT = 36; export default function CoinAssetTable({ wallets }: CoinAssetTableProps) { return ( -
+
수익률 - + {wallets.map((wallet) => ( Date: Fri, 11 Jul 2025 02:24:57 +0900 Subject: [PATCH 15/87] =?UTF-8?q?feat:=20Pagination=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=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 --- src/shared/ui/Pagination/index.tsx | 68 ++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 src/shared/ui/Pagination/index.tsx diff --git a/src/shared/ui/Pagination/index.tsx b/src/shared/ui/Pagination/index.tsx new file mode 100644 index 0000000..5291e51 --- /dev/null +++ b/src/shared/ui/Pagination/index.tsx @@ -0,0 +1,68 @@ +import clsx from 'clsx'; +import type { MouseEvent } from 'react'; +import { IconArrowLeft, IconArrowRight } from '~/assets/svgs'; + +type PaginationProps = { + currentPage: number; + totalPages: number; + showCount: number; + onClick: (page: number) => void; + onPrevClick: () => void; + onNextClick: () => void; +}; + +export default function Pagination({ + currentPage, + totalPages, + showCount, + onClick, + onPrevClick, + onNextClick, +}: Readonly) { + const pages: number[] = []; + + const currentSection = Math.ceil(currentPage / showCount); + const sectionStart = (currentSection - 1) * showCount + 1; + const sectionEnd = Math.min(currentSection * showCount, totalPages); + + for (let page = sectionStart; page <= sectionEnd; page++) { + pages.push(page); + } + + const handleClick = (e: MouseEvent) => { + onClick(Number(e.currentTarget.value)); + }; + + return ( +
+ + {pages.map((page) => ( + + ))} + +
+ ); +} From 98da5780a79004a2dc1e01e5db8fa3e373b758da Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Sat, 12 Jul 2025 00:05:01 +0900 Subject: [PATCH 16/87] =?UTF-8?q?feat:=20=ED=85=8C=EC=9D=B4=EB=B8=94=20?= =?UTF-8?q?=EB=A1=9C=EC=9A=B0=EC=97=90=20=EB=8F=84=EB=84=9B=EC=B0=A8?= =?UTF-8?q?=ED=8A=B8=EC=99=80=20=EB=98=91=EA=B0=99=EC=9D=80=20=EC=83=89?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../profile/ui/AssetInfoGraphic/index.tsx | 2 +- .../profile/ui/CoinAssetTable/index.tsx | 74 ++++++++++++------- 2 files changed, 48 insertions(+), 28 deletions(-) diff --git a/src/features/profile/ui/AssetInfoGraphic/index.tsx b/src/features/profile/ui/AssetInfoGraphic/index.tsx index 42787ce..9b0808b 100644 --- a/src/features/profile/ui/AssetInfoGraphic/index.tsx +++ b/src/features/profile/ui/AssetInfoGraphic/index.tsx @@ -14,7 +14,7 @@ export default function AssetInfoGraphic({ userInfo }: AssetInfoGraphicProps) { wallets.reduce((acc, item) => acc + item.roi, 0) / wallets.length; return ( -
+
diff --git a/src/features/profile/ui/CoinAssetTable/index.tsx b/src/features/profile/ui/CoinAssetTable/index.tsx index ffd9772..62f05e1 100644 --- a/src/features/profile/ui/CoinAssetTable/index.tsx +++ b/src/features/profile/ui/CoinAssetTable/index.tsx @@ -1,6 +1,6 @@ -import clsx from 'clsx'; import type { Wallet } from '~/entities/user'; import { formatCurrencyKR } from '~/shared/utils'; +import { COLORS } from '../../const/chart.const'; type CoinAssetTableProps = { wallets: Wallet[]; @@ -17,36 +17,56 @@ export default function CoinAssetTable({ wallets }: CoinAssetTableProps) { className="sticky top-0 bg-white" style={{ height: `${TABLE_HEAD_HEIGHT}px` }} > -
- - - - + + + + + - {wallets.map((wallet) => ( - 0 ? 'bg-red-50' : 'bg-blue-50', - )} - style={{ height: `${TABLE_ROW_HEIGHT}px` }} - > - - - - - {wallet.roi}% - - - ))} + + + + + + + ); + })}
티커평균단가평가금액수익률
+ 티커평균단가평가금액수익률
{wallet.ticker}{formatCurrencyKR(wallet.buyPrice)}원{formatCurrencyKR(wallet.buyPrice * wallet.size)}원 0 ? 'text-red-500' : 'text-blue-500', - )} + {wallets.map((wallet, index) => { + const color = COLORS[index % COLORS.length]; + const roiTextColor = + wallet.roi > 0 + ? '#fb2c36' + : wallet.roi < 0 + ? '#3b82f6' + : '#9ca3af'; + return ( +
+ + + {wallet.ticker} + + {formatCurrencyKR(wallet.buyPrice)}원 + + {formatCurrencyKR(wallet.buyPrice * wallet.size)}원 + + {wallet.roi}% +
From 264664801cd69d87864c8dd04b624088b135f305 Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Sat, 12 Jul 2025 01:27:01 +0900 Subject: [PATCH 17/87] =?UTF-8?q?feat:=20=ED=8C=8C=EC=9D=B4=EC=B0=A8?= =?UTF-8?q?=ED=8A=B8=20=EB=AA=A8=EC=96=91=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F?= =?UTF-8?q?=20CustomActiveShape=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=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 --- .../profile/ui/CoinPieChart/index.tsx | 10 +-- .../ui/CoinPieChartActiveShape/index.tsx | 88 +++++++++++++++++++ 2 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 src/features/profile/ui/CoinPieChartActiveShape/index.tsx diff --git a/src/features/profile/ui/CoinPieChart/index.tsx b/src/features/profile/ui/CoinPieChart/index.tsx index d7bb1f0..3b82ea4 100644 --- a/src/features/profile/ui/CoinPieChart/index.tsx +++ b/src/features/profile/ui/CoinPieChart/index.tsx @@ -1,8 +1,7 @@ -import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts'; +import { Cell, Pie, PieChart, ResponsiveContainer } from 'recharts'; import { COLORS } from '../../const/chart.const'; import type { CoinPieChartData } from '../../types/chart.type'; -import CoinPieChartLabel from '../CoinPieChartLabel'; -import CoinPieChartTooltip from '../CoinPieChartTooltip'; +import CoinPieChartActiveShape from '../CoinPieChartActiveShape'; type CoinPieChartProps = { coinData: CoinPieChartData[] }; @@ -15,8 +14,10 @@ export default function CoinPieChart({ coinData }: CoinPieChartProps) { cy="50%" nameKey="ticker" dataKey="totalPrice" - label={CoinPieChartLabel} labelLine={false} + innerRadius={60} + outerRadius={80} + activeShape={CoinPieChartActiveShape} > {coinData.map((item, index) => ( ))} - ); diff --git a/src/features/profile/ui/CoinPieChartActiveShape/index.tsx b/src/features/profile/ui/CoinPieChartActiveShape/index.tsx new file mode 100644 index 0000000..3f72d61 --- /dev/null +++ b/src/features/profile/ui/CoinPieChartActiveShape/index.tsx @@ -0,0 +1,88 @@ +import type { SVGProps } from 'react'; +import { Sector, type SectorProps } from 'recharts'; +import type { CoinPieChartData } from '../../types/chart.type'; + +type Coordinate = { + x: number; + y: number; +}; + +type PieSectorData = { + percent?: number; + name?: string | number; + midAngle?: number; + middleRadius?: number; + tooltipPosition?: Coordinate; + value?: number; + paddingAngle?: number; + dataKey?: string; + payload?: CoinPieChartData; +}; + +type CoinPieChartActiveShapeProps = SVGProps & + Partial & + PieSectorData; + +export default function CoinPieChartActiveShape({ + cx, + cy, + midAngle, + innerRadius, + outerRadius, + startAngle, + endAngle, + fill, + payload, + percent, + value, +}: CoinPieChartActiveShapeProps) { + const RADIAN = Math.PI / 180; + const sin = Math.sin(-RADIAN * (midAngle ?? 1)); + const cos = Math.cos(-RADIAN * (midAngle ?? 1)); + const sx = (cx ?? 0) + ((outerRadius ?? 0) + 10) * cos; + const sy = (cy ?? 0) + ((outerRadius ?? 0) + 10) * sin; + const mx = (cx ?? 0) + ((outerRadius ?? 0) + 30) * cos; + const my = (cy ?? 0) + ((outerRadius ?? 0) + 30) * sin; + const ex = mx + (cos >= 0 ? 1 : -1) * 11; + const ey = my; + const textAnchor = cos >= 0 ? 'start' : 'end'; + + return ( + + + {payload?.ticker} + + + + + + = 0 ? 1 : -1) * 12} + y={ey} + textAnchor={textAnchor} + fill="#333" + fontSize={14} + >{`${((percent ?? 1) * 100).toFixed(2)}%`} + + ); +} From f24e772a0868f6c1dadaba3fe53398b819b594d4 Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Sat, 12 Jul 2025 17:36:39 +0900 Subject: [PATCH 18/87] =?UTF-8?q?feat:=20=ED=8C=8C=EC=9D=B4=EC=B0=A8?= =?UTF-8?q?=ED=8A=B8=EB=A5=BC=20=EB=88=84=EB=A5=B4=EB=A9=B4=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=EC=9D=B4=20=ED=95=B4=EB=8B=B9=20=EC=BD=94?= =?UTF-8?q?=EC=9D=B8=EC=9C=BC=EB=A1=9C=20=EC=8A=A4=ED=81=AC=EB=A1=A4?= =?UTF-8?q?=EB=90=98=EA=B2=8C=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/profile/const/chart.const.ts | 3 + src/features/profile/types/chart.type.ts | 4 ++ .../profile/ui/AssetInfoGraphic/index.tsx | 69 ++++++++++++------- .../profile/ui/CoinAssetTable/index.tsx | 43 ++++++------ .../profile/ui/CoinPieChart/index.tsx | 12 +++- src/features/profile/utils/index.ts | 13 ++-- 6 files changed, 92 insertions(+), 52 deletions(-) diff --git a/src/features/profile/const/chart.const.ts b/src/features/profile/const/chart.const.ts index d7cb750..627b410 100644 --- a/src/features/profile/const/chart.const.ts +++ b/src/features/profile/const/chart.const.ts @@ -1,3 +1,6 @@ +export const TABLE_HEAD_HEIGHT = 32; +export const TABLE_ROW_HEIGHT = 36; + export const COLORS = [ { backgroundColor: '#FF3B30', textColor: '#FFFFFF' }, // 밝은 빨강 - 가장 눈에 띄는 색상 { backgroundColor: '#007AFF', textColor: '#FFFFFF' }, // 밝은 파랑 diff --git a/src/features/profile/types/chart.type.ts b/src/features/profile/types/chart.type.ts index aeae166..b1e72a2 100644 --- a/src/features/profile/types/chart.type.ts +++ b/src/features/profile/types/chart.type.ts @@ -3,4 +3,8 @@ export type CoinPieChartData = { totalPrice: number; averagePrice: number; quantity: number; + roi: number; + accountId: number; + name: string; + currentPrice: number; }; diff --git a/src/features/profile/ui/AssetInfoGraphic/index.tsx b/src/features/profile/ui/AssetInfoGraphic/index.tsx index 9b0808b..e5b6849 100644 --- a/src/features/profile/ui/AssetInfoGraphic/index.tsx +++ b/src/features/profile/ui/AssetInfoGraphic/index.tsx @@ -1,44 +1,67 @@ +import { useRef } from 'react'; import type { UserInfoResponseData } from '~/entities/user'; import { CoinPieChart } from '~/features/profile'; +import { TABLE_HEAD_HEIGHT } from '../../const/chart.const'; +import type { CoinPieChartData } from '../../types/chart.type'; import { generateCoinPieChartData } from '../../utils'; import AssetInfoGraphicText from '../AssetInfoGraphicText'; +import CoinAssetTable from '../CoinAssetTable'; type AssetInfoGraphicProps = { userInfo: UserInfoResponseData; }; export default function AssetInfoGraphic({ userInfo }: AssetInfoGraphicProps) { + const assetTableRef = useRef(null); const { wallets, totalAssetAmount } = userInfo; const coinData = generateCoinPieChartData(wallets); const roiAverage = wallets.reduce((acc, item) => acc + item.roi, 0) / wallets.length; + const handleClickChart = (data: CoinPieChartData) => { + if (!assetTableRef.current) return; + + const targetRow = assetTableRef.current.querySelector( + `[data-ticker="${data.ticker}"]`, + ) as HTMLElement; + + if (!targetRow) return; + + assetTableRef.current.scrollTo({ + top: targetRow.offsetTop - TABLE_HEAD_HEIGHT, + behavior: 'smooth', + }); + }; + return ( -
-
- -
-
-
- - + <> +
+
+
-
- +
+
+ + +
+
+ +
-
+ + ); } diff --git a/src/features/profile/ui/CoinAssetTable/index.tsx b/src/features/profile/ui/CoinAssetTable/index.tsx index 62f05e1..f7e9682 100644 --- a/src/features/profile/ui/CoinAssetTable/index.tsx +++ b/src/features/profile/ui/CoinAssetTable/index.tsx @@ -1,17 +1,23 @@ -import type { Wallet } from '~/entities/user'; +import type { RefObject } from 'react'; import { formatCurrencyKR } from '~/shared/utils'; -import { COLORS } from '../../const/chart.const'; +import { + COLORS, + TABLE_HEAD_HEIGHT, + TABLE_ROW_HEIGHT, +} from '../../const/chart.const'; +import type { CoinPieChartData } from '../../types/chart.type'; type CoinAssetTableProps = { - wallets: Wallet[]; + coinData: CoinPieChartData[]; + ref?: RefObject; }; -const TABLE_HEAD_HEIGHT = 32; -const TABLE_ROW_HEIGHT = 36; - -export default function CoinAssetTable({ wallets }: CoinAssetTableProps) { +export default function CoinAssetTable({ coinData, ref }: CoinAssetTableProps) { return ( -
+
- {wallets.map((wallet, index) => { + {coinData.map((coin, index) => { const color = COLORS[index % COLORS.length]; const roiTextColor = - wallet.roi > 0 - ? '#fb2c36' - : wallet.roi < 0 - ? '#3b82f6' - : '#9ca3af'; + coin.roi > 0 ? '#fb2c36' : coin.roi < 0 ? '#3b82f6' : '#9ca3af'; return ( ); diff --git a/src/features/profile/ui/CoinPieChart/index.tsx b/src/features/profile/ui/CoinPieChart/index.tsx index 3b82ea4..161b0f5 100644 --- a/src/features/profile/ui/CoinPieChart/index.tsx +++ b/src/features/profile/ui/CoinPieChart/index.tsx @@ -3,13 +3,21 @@ import { COLORS } from '../../const/chart.const'; import type { CoinPieChartData } from '../../types/chart.type'; import CoinPieChartActiveShape from '../CoinPieChartActiveShape'; -type CoinPieChartProps = { coinData: CoinPieChartData[] }; +export type CoinPieChartProps = { + coinData: CoinPieChartData[]; + onClick?: (data: CoinPieChartData) => void; +}; + +export default function CoinPieChart({ coinData, onClick }: CoinPieChartProps) { + const handleClick = ({ payload }: { payload: CoinPieChartData }) => { + onClick?.(payload); + }; -export default function CoinPieChart({ coinData }: CoinPieChartProps) { return ( ({ + name: item.name, ticker: item.ticker, + accountId: item.accountId, totalPrice: item.size * item.buyPrice, averagePrice: item.buyPrice, quantity: item.size, + roi: item.roi, + currentPrice: item.currentPrice, })) .sort((a, b) => b.totalPrice - a.totalPrice); } From 8ec86ec6d7e6532661ace4d0050ad524ea63f920 Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Sun, 13 Jul 2025 19:01:06 +0900 Subject: [PATCH 19/87] =?UTF-8?q?feat:=20arrow=20=EC=95=84=EC=9D=B4?= =?UTF-8?q?=EC=BD=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assets/svgs/chevron-left.svg | 1 + src/assets/svgs/chevron-right.svg | 1 + src/assets/svgs/index.ts | 2 ++ 3 files changed, 4 insertions(+) create mode 100644 src/assets/svgs/chevron-left.svg create mode 100644 src/assets/svgs/chevron-right.svg diff --git a/src/assets/svgs/chevron-left.svg b/src/assets/svgs/chevron-left.svg new file mode 100644 index 0000000..d0e5ca0 --- /dev/null +++ b/src/assets/svgs/chevron-left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/svgs/chevron-right.svg b/src/assets/svgs/chevron-right.svg new file mode 100644 index 0000000..6a1b85d --- /dev/null +++ b/src/assets/svgs/chevron-right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/svgs/index.ts b/src/assets/svgs/index.ts index 1cb6b38..24a97fa 100644 --- a/src/assets/svgs/index.ts +++ b/src/assets/svgs/index.ts @@ -6,3 +6,5 @@ export { ReactComponent as IconMinus } from './minus-solid.svg'; export { ReactComponent as IconHeadset } from './headset-solid.svg'; export { ReactComponent as IconBars } from './bars-solid.svg'; export { ReactComponent as IconXmark } from './xmark-solid.svg'; +export { ReactComponent as IconArrowLeft } from './chevron-left.svg'; +export { ReactComponent as IconArrowRight } from './chevron-right.svg'; From 04d57a0548ae0c9f0d2149e1694dd8f06039fa06 Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Sun, 13 Jul 2025 19:01:57 +0900 Subject: [PATCH 20/87] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=B2=B4=EA=B2=B0=20=EC=A1=B0=ED=9A=8C=20=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../profile/types/tradingHistory.type.ts | 35 +++ .../ui/TradingHistoryCancleButton/index.tsx | 37 +++ .../profile/ui/TradingHistoryList/index.tsx | 221 ++++++++++++++++++ .../ui/TradingHistoryListItem/index.tsx | 32 +++ 4 files changed, 325 insertions(+) create mode 100644 src/features/profile/types/tradingHistory.type.ts create mode 100644 src/features/profile/ui/TradingHistoryCancleButton/index.tsx create mode 100644 src/features/profile/ui/TradingHistoryList/index.tsx create mode 100644 src/features/profile/ui/TradingHistoryListItem/index.tsx diff --git a/src/features/profile/types/tradingHistory.type.ts b/src/features/profile/types/tradingHistory.type.ts new file mode 100644 index 0000000..3fe38a7 --- /dev/null +++ b/src/features/profile/types/tradingHistory.type.ts @@ -0,0 +1,35 @@ +export type TradingHistory = + | { + orderId: string; + side: 'ask'; + orderType: 'limit'; + ticker: string; + size: number; + price: number; + status: 'unsettled' | 'settled' | 'in_progress'; + } + | { + orderId: string; + side: 'ask'; + orderType: 'market'; + ticker: string; + size: number; + status: 'unsettled' | 'settled' | 'in_progress'; + } + | { + orderId: string; + side: 'bid'; + orderType: 'limit'; + ticker: string; + size: number; + price: number; + status: 'unsettled' | 'settled' | 'in_progress'; + } + | { + orderId: string; + side: 'bid'; + orderType: 'market'; + ticker: string; + price: number; + status: 'unsettled' | 'settled' | 'in_progress'; + }; diff --git a/src/features/profile/ui/TradingHistoryCancleButton/index.tsx b/src/features/profile/ui/TradingHistoryCancleButton/index.tsx new file mode 100644 index 0000000..d880954 --- /dev/null +++ b/src/features/profile/ui/TradingHistoryCancleButton/index.tsx @@ -0,0 +1,37 @@ +import clsx from 'clsx'; +import type { ButtonHTMLAttributes } from 'react'; +import type { TradingHistory } from '../../types/tradingHistory.type'; + +type TradingHistoryCancelButtonProps = { + status: TradingHistory['status']; +} & ButtonHTMLAttributes; + +export default function TradingHistoryCancelButton({ + status, + ...props +}: TradingHistoryCancelButtonProps) { + let text = ''; + switch (status) { + case 'unsettled': + text = '취소'; + break; + case 'settled': + text = '체결완료'; + break; + case 'in_progress': + text = '체결중'; + break; + } + + return ( + + ); +} diff --git a/src/features/profile/ui/TradingHistoryList/index.tsx b/src/features/profile/ui/TradingHistoryList/index.tsx new file mode 100644 index 0000000..d23a842 --- /dev/null +++ b/src/features/profile/ui/TradingHistoryList/index.tsx @@ -0,0 +1,221 @@ +import { useState } from 'react'; +import Pagination from '~/shared/ui/Pagination'; +import Tab from '~/shared/ui/Tab'; +import type { TradingHistory } from '../../types/tradingHistory.type'; +import TradingHistoryListItem from '../TradingHistoryListItem'; + +const HISTORY_LIST: TradingHistory[] = [ + { + orderId: '1', + side: 'ask', + orderType: 'limit', + ticker: 'BTC', + size: 1, + price: 100000, + status: 'unsettled', + }, + { + orderId: '2', + side: 'bid', + orderType: 'market', + ticker: 'ETH', + price: 2800000, + status: 'settled', + }, + { + orderId: '3', + side: 'ask', + orderType: 'market', + ticker: 'XRP', + size: 1000, + status: 'unsettled', + }, + { + orderId: '4', + side: 'bid', + orderType: 'limit', + ticker: 'ADA', + size: 300, + price: 600, + status: 'settled', + }, + { + orderId: '5', + side: 'ask', + orderType: 'limit', + ticker: 'DOGE', + size: 5000, + price: 90, + status: 'unsettled', + }, + { + orderId: '6', + side: 'bid', + orderType: 'market', + ticker: 'SOL', + price: 65000, + status: 'settled', + }, + { + orderId: '7', + side: 'ask', + orderType: 'limit', + ticker: 'BNB', + size: 2, + price: 480000, + status: 'settled', + }, + { + orderId: '8', + side: 'bid', + orderType: 'limit', + ticker: 'BTC', + size: 0.5, + price: 98000, + status: 'unsettled', + }, + { + orderId: '9', + side: 'ask', + orderType: 'market', + ticker: 'DOT', + size: 50, + status: 'settled', + }, + { + orderId: '10', + side: 'bid', + orderType: 'limit', + ticker: 'AVAX', + size: 15, + price: 18000, + status: 'unsettled', + }, + { + orderId: '11', + side: 'ask', + orderType: 'limit', + ticker: 'SHIB', + size: 50000000, + price: 0.001, + status: 'unsettled', + }, + { + orderId: '12', + side: 'bid', + orderType: 'market', + ticker: 'MATIC', + price: 600, + status: 'settled', + }, + { + orderId: '13', + side: 'ask', + orderType: 'limit', + ticker: 'LTC', + size: 8, + price: 70000, + status: 'settled', + }, + { + orderId: '14', + side: 'bid', + orderType: 'limit', + ticker: 'LINK', + size: 25, + price: 12000, + status: 'unsettled', + }, + { + orderId: '15', + side: 'ask', + orderType: 'market', + ticker: 'ATOM', + size: 20, + status: 'unsettled', + }, + { + orderId: '16', + side: 'bid', + orderType: 'limit', + ticker: 'ETH', + size: 2, + price: 2850000, + status: 'settled', + }, + { + orderId: '17', + side: 'ask', + orderType: 'limit', + ticker: 'XLM', + size: 3000, + price: 200, + status: 'unsettled', + }, + { + orderId: '18', + side: 'bid', + orderType: 'market', + ticker: 'SAND', + price: 450, + status: 'settled', + }, + { + orderId: '19', + side: 'ask', + orderType: 'market', + ticker: 'XTZ', + size: 100, + status: 'unsettled', + }, + { + orderId: '20', + side: 'bid', + orderType: 'limit', + ticker: 'BTC', + size: 0.2, + price: 101000, + status: 'settled', + }, +]; + +export default function TradingHistoryList() { + const [selectedTab, setSelectedTab] = useState('unsettled'); + + const handleTabClick = (value: string) => { + setSelectedTab(value); + }; + + return ( +
+ +
+ 매매종류 + 종목 + 가격 + 수량 + 취소 +
+
    + {HISTORY_LIST.map((item) => ( + + ))} +
+ {}} + onPrevClick={() => {}} + onNextClick={() => {}} + /> +
+ ); +} diff --git a/src/features/profile/ui/TradingHistoryListItem/index.tsx b/src/features/profile/ui/TradingHistoryListItem/index.tsx new file mode 100644 index 0000000..4c25075 --- /dev/null +++ b/src/features/profile/ui/TradingHistoryListItem/index.tsx @@ -0,0 +1,32 @@ +import { formatCurrencyKR } from '~/shared/utils'; +import type { TradingHistory } from '../../types/tradingHistory.type'; +import TradingHistoryCancelButton from '../TradingHistoryCancleButton'; + +type TradingHistoryListItemProps = TradingHistory; + +export default function TradingHistoryListItem( + props: Readonly, +) { + const { side, ticker, status, orderType } = props; + const typeText = side === 'ask' ? '매수' : '매도'; + const priceText = + side === 'ask' && orderType === 'market' + ? '시장가 매수' + : `${formatCurrencyKR(props.price)}원`; + const sizeText = + side === 'bid' && orderType === 'market' + ? '시장가 매도' + : `${formatCurrencyKR(props.size)}개`; + + return ( +
  • + {typeText} + {ticker} + {priceText} + {sizeText} +
    + +
    +
  • + ); +} From 4dccdcec390c678314e2c742bdea482556d8bca9 Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Sun, 13 Jul 2025 19:28:00 +0900 Subject: [PATCH 21/87] =?UTF-8?q?fix:=20Tab=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=EC=97=90=20key=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/ui/Tab/index.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/shared/ui/Tab/index.tsx b/src/shared/ui/Tab/index.tsx index faeb597..c72ad3d 100644 --- a/src/shared/ui/Tab/index.tsx +++ b/src/shared/ui/Tab/index.tsx @@ -1,5 +1,5 @@ import { motion } from 'motion/react'; -import type { MouseEvent } from 'react'; +import { Fragment, type MouseEvent } from 'react'; type TabItem = { value: string; @@ -22,7 +22,7 @@ export default function Tab({ items, selected, onClick }: Readonly) {
      {items.map((item, index) => ( - <> +
    • )} - +
      ))}
    From d0630c54f71c3b98057d08247631f8128341f153 Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Sun, 13 Jul 2025 20:05:52 +0900 Subject: [PATCH 22/87] =?UTF-8?q?config:=20msw=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=9E=AC=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/entry.client.tsx | 8 +++++++- src/app/entry.server.tsx | 12 +++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/app/entry.client.tsx b/src/app/entry.client.tsx index 78ea235..a8c0af0 100644 --- a/src/app/entry.client.tsx +++ b/src/app/entry.client.tsx @@ -30,7 +30,13 @@ import { HydratedRouter } from 'react-router/dom'; // }); async function prepareApp() { - return Promise.resolve(); + if (process.env.NODE_ENV !== 'development') { + return; + } + + const { worker } = await import('../mocks/browser'); + + return worker.start(); } prepareApp().then(() => { diff --git a/src/app/entry.server.tsx b/src/app/entry.server.tsx index 1c512a6..35f00c9 100644 --- a/src/app/entry.server.tsx +++ b/src/app/entry.server.tsx @@ -1,5 +1,6 @@ -/* v8 ignore start */ import { PassThrough } from 'node:stream'; +/* v8 ignore start */ +import { server } from '~/mocks/server'; import { createReadableStreamFromReadable } from '@react-router/node'; import { isbot } from 'isbot'; @@ -10,6 +11,15 @@ import { ServerRouter } from 'react-router'; export const streamTimeout = 5_000; +if (process.env.NODE_ENV === 'development') { + server.listen(); + + server.events.on('request:start', ({ request }) => { + // biome-ignore lint/suspicious/noConsole: + console.log('MSW intercepted:', request.method, request.url); + }); +} + function handleRequest( request: Request, responseStatusCode: number, From aa0f69b4afebcf7db6fb69e7bd624c5ff07ba49f Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Mon, 14 Jul 2025 22:51:17 +0900 Subject: [PATCH 23/87] =?UTF-8?q?feat:=20profile=20route=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/routes/profile.tsx | 14 ++++++++++++-- src/shared/ui/Modal/index.tsx | 2 +- src/widgets/user/ui/ProfileModal/index.tsx | 16 ++++++++++++++-- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/app/routes/profile.tsx b/src/app/routes/profile.tsx index 7e25912..a5cea7b 100644 --- a/src/app/routes/profile.tsx +++ b/src/app/routes/profile.tsx @@ -1,5 +1,15 @@ +import { api } from '~/entities/user'; import { ProfileModal } from '~/widgets/user'; +import type { Route } from './+types/profile'; -export default function ProfileRouteComponent() { - return ; +export async function loader() { + const response = await api.getUserInfo(); + const { data } = await response.json(); + return data; +} + +export default function ProfileRouteComponent({ + loaderData, +}: Route.ComponentProps) { + return ; } diff --git a/src/shared/ui/Modal/index.tsx b/src/shared/ui/Modal/index.tsx index ba47450..acccb07 100644 --- a/src/shared/ui/Modal/index.tsx +++ b/src/shared/ui/Modal/index.tsx @@ -10,7 +10,7 @@ export default function Modal({ children, ref }: Readonly) { {children} diff --git a/src/widgets/user/ui/ProfileModal/index.tsx b/src/widgets/user/ui/ProfileModal/index.tsx index cc3c437..e50e5e3 100644 --- a/src/widgets/user/ui/ProfileModal/index.tsx +++ b/src/widgets/user/ui/ProfileModal/index.tsx @@ -1,17 +1,29 @@ import { useRef } from 'react'; import { useNavigate } from 'react-router'; + +import type { UserInfoResponseData } from '~/entities/user'; +import AssetInfoGraphic from '~/features/profile/ui/AssetInfoGraphic'; +import TradingHistory from '~/features/profile/ui/TradingHistoryList'; import useClickOutside from '~/shared/hooks/useClickOutside'; import Backdrop from '~/shared/ui/Backdrop'; import Modal from '~/shared/ui/Modal'; -export default function ProfileModal() { +type ProfileModalProps = { + userInfo: UserInfoResponseData; +}; + +export default function ProfileModal({ userInfo }: ProfileModalProps) { const navigate = useNavigate(); const modalRef = useRef(null); useClickOutside(modalRef, () => navigate(-1)); + return ( -
    ProfileModal
    +
    + + +
    ); From 2cfe6f6a91119cb724e8f32f0dc8a06b784286cd Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Tue, 15 Jul 2025 20:52:23 +0900 Subject: [PATCH 24/87] =?UTF-8?q?test:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20msw=20api=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/mocks/dummy.ts | 66 +++++++++++++++++++++++++++++++++++++++++++ src/mocks/handlers.ts | 23 ++++++++++++++- 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 src/mocks/dummy.ts diff --git a/src/mocks/dummy.ts b/src/mocks/dummy.ts new file mode 100644 index 0000000..0201c07 --- /dev/null +++ b/src/mocks/dummy.ts @@ -0,0 +1,66 @@ +import type { UserInfoResponseData } from '~/entities/user'; + +export const DUMMY_USERINFO_DATA: UserInfoResponseData = { + userId: 1, + email: 'test@gmail.com', + nickname: 'test', + provider: 'test', + cash: 100000, + totalAssetAmount: 100000, + wallets: [ + { + name: 'BTC', + ticker: 'BTC', + accountId: 1, + buyPrice: 100000, + currentPrice: 100000, + roi: 0.001, + size: 1, + }, + { + name: 'ETH', + ticker: 'ETH', + accountId: 2, + buyPrice: 200000, + currentPrice: 200000, + roi: 0.002, + size: 1, + }, + { + name: 'SOL', + ticker: 'SOL', + accountId: 3, + buyPrice: 40000, + currentPrice: 40000, + roi: 0.003, + size: 1, + }, + { + name: 'ADA', + ticker: 'ADA', + accountId: 4, + buyPrice: 100000, + currentPrice: 100000, + roi: 0.004, + size: 1, + }, + { + name: 'ATOM', + ticker: 'ATOM', + accountId: 5, + buyPrice: 50000, + currentPrice: 50000, + roi: -0.005, + size: 1, + }, + { + name: 'XRP', + ticker: 'XRP', + accountId: 6, + buyPrice: 25000, + currentPrice: 25000, + roi: 0.006, + size: 1, + }, + ], +}; diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index a422d26..0aa6bda 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -1,12 +1,33 @@ /* v8 ignore start */ import { http, HttpResponse } from 'msw'; +import type { Response } from '~/shared/types/api'; +import { DUMMY_USERINFO_DATA } from './dummy'; + +function api(endpoint: string) { + return `http://localhost:8080/api/${endpoint}`; +} + +function successResponse(data: T) { + const response: Response = { + data: data, + isSuccess: true, + error: null, + }; + + return response; +} export const handlers = [ - http.get('/api/tokencheck', async ({ cookies }) => { + http.get(api('tokencheck'), async ({ cookies }) => { if (!cookies.access_token) { return new HttpResponse(null, { status: 401 }); } return new HttpResponse(null, { status: 200 }); }), + http.get(api('userinfo'), async () => { + return HttpResponse.json(successResponse(DUMMY_USERINFO_DATA), { + status: 200, + }); + }), ]; /* v8 ignore end */ From 8e4e560be2ac98e4c6b6c0aabbcad1d769bbf436 Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Wed, 16 Jul 2025 19:29:51 +0900 Subject: [PATCH 25/87] =?UTF-8?q?feat:=20streaming=EC=9D=84=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=ED=95=9C=20profile=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EA=B1=B0=EB=9E=98=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=ED=8C=A8?= =?UTF-8?q?=EC=B9=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/routes.ts | 2 +- src/app/routes/profile.tsx | 18 +- src/features/profile/api/history.endpoint.ts | 18 ++ src/features/profile/index.ts | 2 + .../profile/types/tradingHistory.type.ts | 10 + .../profile/ui/TradingHistoryList/index.tsx | 231 ++++-------------- 6 files changed, 89 insertions(+), 192 deletions(-) create mode 100644 src/features/profile/api/history.endpoint.ts diff --git a/src/app/routes.ts b/src/app/routes.ts index 69ef163..3f32d0c 100644 --- a/src/app/routes.ts +++ b/src/app/routes.ts @@ -7,7 +7,7 @@ export default [ ...prefix('trade', [ route(':ticker', './routes/trade.tsx', [ route('login', './routes/login.tsx'), - route('profile', './routes/profile.tsx'), + route('profile', './routes/profile.tsx', { id: 'profile' }), ]), ]), ] satisfies RouteConfig; diff --git a/src/app/routes/profile.tsx b/src/app/routes/profile.tsx index a5cea7b..b22267b 100644 --- a/src/app/routes/profile.tsx +++ b/src/app/routes/profile.tsx @@ -1,15 +1,25 @@ -import { api } from '~/entities/user'; +import { api as userApi } from '~/entities/user'; +import { type HistoryResponse, api as profileApi } from '~/features/profile'; import { ProfileModal } from '~/widgets/user'; import type { Route } from './+types/profile'; export async function loader() { - const response = await api.getUserInfo(); + const response = await userApi.getUserInfo(); const { data } = await response.json(); - return data; + + const history = new Promise((res) => { + profileApi.getHistory().then((response) => { + response.json().then((data) => { + res(data); + }); + }); + }); + + return { userInfo: data, historyDataPromise: history }; } export default function ProfileRouteComponent({ loaderData, }: Route.ComponentProps) { - return ; + return ; } diff --git a/src/features/profile/api/history.endpoint.ts b/src/features/profile/api/history.endpoint.ts new file mode 100644 index 0000000..9681514 --- /dev/null +++ b/src/features/profile/api/history.endpoint.ts @@ -0,0 +1,18 @@ +import ApiClient from '~/shared/api/httpClient'; +import type { HistoryResponse } from '../types/tradingHistory.type'; + +export default { + getHistory: ( + page?: number, + size?: number, + type?: 'unsettled' | 'settled', + ) => { + const params = new URLSearchParams(); + + if (page) params.set('page', page.toString()); + if (size) params.set('size', size.toString()); + if (type) params.set('type', type); + + return ApiClient.get(`api/history?${params.toString()}`); + }, +}; diff --git a/src/features/profile/index.ts b/src/features/profile/index.ts index 4436046..90291be 100644 --- a/src/features/profile/index.ts +++ b/src/features/profile/index.ts @@ -1 +1,3 @@ export { default as CoinPieChart } from './ui/CoinPieChart'; +export type { HistoryResponse } from './types/tradingHistory.type'; +export { default as api } from './api/history.endpoint'; diff --git a/src/features/profile/types/tradingHistory.type.ts b/src/features/profile/types/tradingHistory.type.ts index 3fe38a7..05ff1d7 100644 --- a/src/features/profile/types/tradingHistory.type.ts +++ b/src/features/profile/types/tradingHistory.type.ts @@ -1,3 +1,5 @@ +import type { Response } from '~/shared/types/api'; + export type TradingHistory = | { orderId: string; @@ -33,3 +35,11 @@ export type TradingHistory = price: number; status: 'unsettled' | 'settled' | 'in_progress'; }; + +export type History = { + data: TradingHistory[]; + next: number; + last: number; +}; + +export type HistoryResponse = Response; diff --git a/src/features/profile/ui/TradingHistoryList/index.tsx b/src/features/profile/ui/TradingHistoryList/index.tsx index d23a842..72f716b 100644 --- a/src/features/profile/ui/TradingHistoryList/index.tsx +++ b/src/features/profile/ui/TradingHistoryList/index.tsx @@ -1,189 +1,40 @@ -import { useState } from 'react'; +import { Suspense } from 'react'; +import { Await, useRouteLoaderData, useSearchParams } from 'react-router'; import Pagination from '~/shared/ui/Pagination'; import Tab from '~/shared/ui/Tab'; -import type { TradingHistory } from '../../types/tradingHistory.type'; +import type { HistoryResponse } from '../../types/tradingHistory.type'; import TradingHistoryListItem from '../TradingHistoryListItem'; -const HISTORY_LIST: TradingHistory[] = [ - { - orderId: '1', - side: 'ask', - orderType: 'limit', - ticker: 'BTC', - size: 1, - price: 100000, - status: 'unsettled', - }, - { - orderId: '2', - side: 'bid', - orderType: 'market', - ticker: 'ETH', - price: 2800000, - status: 'settled', - }, - { - orderId: '3', - side: 'ask', - orderType: 'market', - ticker: 'XRP', - size: 1000, - status: 'unsettled', - }, - { - orderId: '4', - side: 'bid', - orderType: 'limit', - ticker: 'ADA', - size: 300, - price: 600, - status: 'settled', - }, - { - orderId: '5', - side: 'ask', - orderType: 'limit', - ticker: 'DOGE', - size: 5000, - price: 90, - status: 'unsettled', - }, - { - orderId: '6', - side: 'bid', - orderType: 'market', - ticker: 'SOL', - price: 65000, - status: 'settled', - }, - { - orderId: '7', - side: 'ask', - orderType: 'limit', - ticker: 'BNB', - size: 2, - price: 480000, - status: 'settled', - }, - { - orderId: '8', - side: 'bid', - orderType: 'limit', - ticker: 'BTC', - size: 0.5, - price: 98000, - status: 'unsettled', - }, - { - orderId: '9', - side: 'ask', - orderType: 'market', - ticker: 'DOT', - size: 50, - status: 'settled', - }, - { - orderId: '10', - side: 'bid', - orderType: 'limit', - ticker: 'AVAX', - size: 15, - price: 18000, - status: 'unsettled', - }, - { - orderId: '11', - side: 'ask', - orderType: 'limit', - ticker: 'SHIB', - size: 50000000, - price: 0.001, - status: 'unsettled', - }, - { - orderId: '12', - side: 'bid', - orderType: 'market', - ticker: 'MATIC', - price: 600, - status: 'settled', - }, - { - orderId: '13', - side: 'ask', - orderType: 'limit', - ticker: 'LTC', - size: 8, - price: 70000, - status: 'settled', - }, - { - orderId: '14', - side: 'bid', - orderType: 'limit', - ticker: 'LINK', - size: 25, - price: 12000, - status: 'unsettled', - }, - { - orderId: '15', - side: 'ask', - orderType: 'market', - ticker: 'ATOM', - size: 20, - status: 'unsettled', - }, - { - orderId: '16', - side: 'bid', - orderType: 'limit', - ticker: 'ETH', - size: 2, - price: 2850000, - status: 'settled', - }, - { - orderId: '17', - side: 'ask', - orderType: 'limit', - ticker: 'XLM', - size: 3000, - price: 200, - status: 'unsettled', - }, - { - orderId: '18', - side: 'bid', - orderType: 'market', - ticker: 'SAND', - price: 450, - status: 'settled', - }, - { - orderId: '19', - side: 'ask', - orderType: 'market', - ticker: 'XTZ', - size: 100, - status: 'unsettled', - }, - { - orderId: '20', - side: 'bid', - orderType: 'limit', - ticker: 'BTC', - size: 0.2, - price: 101000, - status: 'settled', - }, -]; - export default function TradingHistoryList() { - const [selectedTab, setSelectedTab] = useState('unsettled'); + const { historyDataPromise } = useRouteLoaderData('profile'); + + const [searchParams, setSearchParams] = useSearchParams({ + p: '1', + t: 'unsettled', + }); + const currentPage = Number(searchParams.get('p')); + const tab = searchParams.get('t') || 'unsettled'; const handleTabClick = (value: string) => { - setSelectedTab(value); + setSearchParams({ p: '1', t: value }); + }; + + const handleClickPageNumber = (page: number) => { + setSearchParams({ p: page.toString(), t: tab }); + }; + + const handlePrevClick = () => { + setSearchParams({ + p: (currentPage - 1).toString(), + t: tab, + }); + }; + + const handleNextClick = () => { + setSearchParams({ + p: (currentPage + 1).toString(), + t: tab, + }); }; return ( @@ -193,7 +44,7 @@ export default function TradingHistoryList() { { label: '미체결 내역', value: 'unsettled' }, { label: '체결 내역', value: 'settled' }, ]} - selected={selectedTab} + selected={tab} onClick={handleTabClick} />
    @@ -204,17 +55,23 @@ export default function TradingHistoryList() { 취소
      - {HISTORY_LIST.map((item) => ( - - ))} + Loading...}> + + {(response: HistoryResponse) => + response.data.data.map((item) => { + return ; + }) + } + +
    {}} - onPrevClick={() => {}} - onNextClick={() => {}} + onClick={handleClickPageNumber} + onPrevClick={handlePrevClick} + onNextClick={handleNextClick} /> ); From 2432d08b71fb0f194d81d06255a7db9d551587ae Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Wed, 16 Jul 2025 22:18:42 +0900 Subject: [PATCH 26/87] =?UTF-8?q?fix:=20spec=20=EB=B3=80=EA=B2=BD=EC=97=90?= =?UTF-8?q?=20=EB=94=B0=EB=A5=B8=20/history=20api=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/profile/api/history.endpoint.ts | 10 ++++------ src/features/profile/types/tradingHistory.type.ts | 11 ++++++----- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/features/profile/api/history.endpoint.ts b/src/features/profile/api/history.endpoint.ts index 9681514..9463781 100644 --- a/src/features/profile/api/history.endpoint.ts +++ b/src/features/profile/api/history.endpoint.ts @@ -2,16 +2,14 @@ import ApiClient from '~/shared/api/httpClient'; import type { HistoryResponse } from '../types/tradingHistory.type'; export default { - getHistory: ( - page?: number, - size?: number, - type?: 'unsettled' | 'settled', - ) => { + getHistory: (page?: number, size?: number, settled?: boolean) => { const params = new URLSearchParams(); if (page) params.set('page', page.toString()); if (size) params.set('size', size.toString()); - if (type) params.set('type', type); + + if (settled) params.set('settled', settled.toString()); + else params.set('settled', 'unsettled'); return ApiClient.get(`api/history?${params.toString()}`); }, diff --git a/src/features/profile/types/tradingHistory.type.ts b/src/features/profile/types/tradingHistory.type.ts index 05ff1d7..2b53310 100644 --- a/src/features/profile/types/tradingHistory.type.ts +++ b/src/features/profile/types/tradingHistory.type.ts @@ -36,10 +36,11 @@ export type TradingHistory = status: 'unsettled' | 'settled' | 'in_progress'; }; -export type History = { - data: TradingHistory[]; - next: number; - last: number; +export type HistoryResonseData = { + orderList: TradingHistory[]; + totalPages: number; + currentPage: number; + pageSize: number; }; -export type HistoryResponse = Response; +export type HistoryResponse = Response; From 21786187ac2551d85bf4a878ba052df56cb1b91a Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Wed, 16 Jul 2025 22:20:48 +0900 Subject: [PATCH 27/87] =?UTF-8?q?fix:=20=EC=8A=A4=ED=8A=B8=EB=A6=AC?= =?UTF-8?q?=EB=B0=8D=EC=9D=84=20=EC=82=AC=EC=9A=A9=ED=95=9C=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=ED=8C=A8=EC=B9=AD=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit layout shifting이 일어나서 스트리밍을 제거하고 ssr로 변경했습니다. --- src/app/routes/profile.tsx | 29 +++++++++++-------- .../profile/ui/TradingHistoryList/index.tsx | 21 +++++--------- 2 files changed, 24 insertions(+), 26 deletions(-) diff --git a/src/app/routes/profile.tsx b/src/app/routes/profile.tsx index b22267b..abec52f 100644 --- a/src/app/routes/profile.tsx +++ b/src/app/routes/profile.tsx @@ -1,21 +1,26 @@ import { api as userApi } from '~/entities/user'; -import { type HistoryResponse, api as profileApi } from '~/features/profile'; +import { api as profileApi } from '~/features/profile'; import { ProfileModal } from '~/widgets/user'; import type { Route } from './+types/profile'; -export async function loader() { - const response = await userApi.getUserInfo(); - const { data } = await response.json(); +const FETCH_SIZE = 10; - const history = new Promise((res) => { - profileApi.getHistory().then((response) => { - response.json().then((data) => { - res(data); - }); - }); - }); +export async function loader({ request }: Route.LoaderArgs) { + const { searchParams } = new URL(request.url); + const page = Number(searchParams.get('p') || 1); + const settled = searchParams.get('t') === 'settled'; - return { userInfo: data, historyDataPromise: history }; + const [userInfoResponse, historyResponse] = await Promise.all([ + userApi.getUserInfo(), + profileApi.getHistory(page, FETCH_SIZE, settled), + ]); + + const [userInfo, history] = await Promise.all([ + userInfoResponse.json(), + historyResponse.json(), + ]); + + return { userInfo: userInfo.data, historyData: history.data }; } export default function ProfileRouteComponent({ diff --git a/src/features/profile/ui/TradingHistoryList/index.tsx b/src/features/profile/ui/TradingHistoryList/index.tsx index 72f716b..8717c31 100644 --- a/src/features/profile/ui/TradingHistoryList/index.tsx +++ b/src/features/profile/ui/TradingHistoryList/index.tsx @@ -1,12 +1,11 @@ -import { Suspense } from 'react'; -import { Await, useRouteLoaderData, useSearchParams } from 'react-router'; +import { useRouteLoaderData, useSearchParams } from 'react-router'; import Pagination from '~/shared/ui/Pagination'; import Tab from '~/shared/ui/Tab'; -import type { HistoryResponse } from '../../types/tradingHistory.type'; +import type { TradingHistory } from '../../types/tradingHistory.type'; import TradingHistoryListItem from '../TradingHistoryListItem'; export default function TradingHistoryList() { - const { historyDataPromise } = useRouteLoaderData('profile'); + const { historyData } = useRouteLoaderData('profile'); const [searchParams, setSearchParams] = useSearchParams({ p: '1', @@ -55,19 +54,13 @@ export default function TradingHistoryList() { 취소
      - Loading...}> - - {(response: HistoryResponse) => - response.data.data.map((item) => { - return ; - }) - } - - + {historyData.orderList.map((item: TradingHistory) => ( + + ))}
    Date: Wed, 16 Jul 2025 22:22:00 +0900 Subject: [PATCH 28/87] =?UTF-8?q?feat:=20=EB=AA=A8=EB=8B=AC=20=EC=99=B8?= =?UTF-8?q?=EB=B6=80=EB=A5=BC=20=ED=81=B4=EB=A6=AD=ED=95=98=EB=A9=B4=20?= =?UTF-8?q?=EC=9D=B4=EC=A0=84=20=ED=8E=98=EC=9D=B4=EC=A7=80=EB=A1=9C=20?= =?UTF-8?q?=EB=8F=8C=EC=95=84=EA=B0=88=20=EC=88=98=20=EC=9E=88=EA=B2=8C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TradingHistoryList에서 페이지네이션 상태를 URLSearchParam을 조작하여 상태를 저장하고 있는데 해당 기능은 navigation을 유발합니다. 따라서 페이지 이동후 모달 외부를 클릭했을 때 모달이 닫히는것이 아닌 이ㅣ전 페이지로 돌아가므로 이를 수정하였습니다. --- src/widgets/navbar/ui/NavBar/index.tsx | 11 +++++++++-- src/widgets/user/ui/ProfileModal/index.tsx | 7 +++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/widgets/navbar/ui/NavBar/index.tsx b/src/widgets/navbar/ui/NavBar/index.tsx index ac1b05b..cdc902c 100644 --- a/src/widgets/navbar/ui/NavBar/index.tsx +++ b/src/widgets/navbar/ui/NavBar/index.tsx @@ -1,4 +1,10 @@ -import { Link, type LinkProps, NavLink, useSubmit } from 'react-router'; +import { + Link, + type LinkProps, + NavLink, + useLocation, + useSubmit, +} from 'react-router'; import { useUserId } from '~/app/provider/UserInfoProvider'; import type { CoinTicker } from '~/entities/coin'; @@ -23,6 +29,7 @@ export default function NavBar({ ticker, onClickMenuButton, }: NavBarProps) { + const location = useLocation(); const submit = useSubmit(); const { setUserId } = useUserId(); @@ -44,7 +51,7 @@ export default function NavBar({ ); const ProfileButton = () => ( - + ); diff --git a/src/widgets/user/ui/ProfileModal/index.tsx b/src/widgets/user/ui/ProfileModal/index.tsx index e50e5e3..8788640 100644 --- a/src/widgets/user/ui/ProfileModal/index.tsx +++ b/src/widgets/user/ui/ProfileModal/index.tsx @@ -1,5 +1,5 @@ import { useRef } from 'react'; -import { useNavigate } from 'react-router'; +import { useNavigate, useSearchParams } from 'react-router'; import type { UserInfoResponseData } from '~/entities/user'; import AssetInfoGraphic from '~/features/profile/ui/AssetInfoGraphic'; @@ -13,9 +13,12 @@ type ProfileModalProps = { }; export default function ProfileModal({ userInfo }: ProfileModalProps) { + const [searchParams] = useSearchParams(); const navigate = useNavigate(); const modalRef = useRef(null); - useClickOutside(modalRef, () => navigate(-1)); + useClickOutside(modalRef, () => + navigate(searchParams.get('referer') || '/trade/BTC'), + ); return ( From 2b7ff1f0bd02fd5cfeef1e2f9311bbe94af3a21a Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Wed, 16 Jul 2025 22:24:40 +0900 Subject: [PATCH 29/87] =?UTF-8?q?test:=20history=20mock=20api=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/mocks/dummy.ts | 520 ++++++++++++++++++++++++++++++++++++++++++ src/mocks/handlers.ts | 28 ++- 2 files changed, 547 insertions(+), 1 deletion(-) diff --git a/src/mocks/dummy.ts b/src/mocks/dummy.ts index 0201c07..005b406 100644 --- a/src/mocks/dummy.ts +++ b/src/mocks/dummy.ts @@ -1,4 +1,5 @@ import type { UserInfoResponseData } from '~/entities/user'; +import type { TradingHistory } from '~/features/profile/types/tradingHistory.type'; export const DUMMY_USERINFO_DATA: UserInfoResponseData = { userId: 1, @@ -64,3 +65,522 @@ export const DUMMY_USERINFO_DATA: UserInfoResponseData = { }, ], }; + +export const DUMMY_HISTORY_LIST: TradingHistory[] = [ + { + orderId: '1', + side: 'ask', + orderType: 'limit', + ticker: 'BTC', + size: 1, + price: 100000, + status: 'unsettled', + }, + { + orderId: '2', + side: 'bid', + orderType: 'market', + ticker: 'ETH', + price: 2800000, + status: 'settled', + }, + { + orderId: '3', + side: 'ask', + orderType: 'market', + ticker: 'XRP', + size: 1000, + status: 'unsettled', + }, + { + orderId: '4', + side: 'bid', + orderType: 'limit', + ticker: 'ADA', + size: 300, + price: 600, + status: 'settled', + }, + { + orderId: '5', + side: 'ask', + orderType: 'limit', + ticker: 'DOGE', + size: 5000, + price: 90, + status: 'unsettled', + }, + { + orderId: '6', + side: 'bid', + orderType: 'market', + ticker: 'SOL', + price: 65000, + status: 'settled', + }, + { + orderId: '7', + side: 'ask', + orderType: 'limit', + ticker: 'BNB', + size: 2, + price: 480000, + status: 'settled', + }, + { + orderId: '8', + side: 'bid', + orderType: 'limit', + ticker: 'BTC', + size: 0.5, + price: 98000, + status: 'unsettled', + }, + { + orderId: '9', + side: 'ask', + orderType: 'market', + ticker: 'DOT', + size: 50, + status: 'settled', + }, + { + orderId: '10', + side: 'bid', + orderType: 'limit', + ticker: 'AVAX', + size: 15, + price: 18000, + status: 'unsettled', + }, + { + orderId: '11', + side: 'ask', + orderType: 'limit', + ticker: 'SHIB', + size: 50000000, + price: 0.001, + status: 'unsettled', + }, + { + orderId: '12', + side: 'bid', + orderType: 'market', + ticker: 'MATIC', + price: 600, + status: 'settled', + }, + { + orderId: '13', + side: 'ask', + orderType: 'limit', + ticker: 'LTC', + size: 8, + price: 70000, + status: 'settled', + }, + { + orderId: '14', + side: 'bid', + orderType: 'limit', + ticker: 'LINK', + size: 25, + price: 12000, + status: 'unsettled', + }, + { + orderId: '15', + side: 'ask', + orderType: 'market', + ticker: 'ATOM', + size: 20, + status: 'unsettled', + }, + { + orderId: '16', + side: 'bid', + orderType: 'limit', + ticker: 'ETH', + size: 2, + price: 2850000, + status: 'settled', + }, + { + orderId: '17', + side: 'ask', + orderType: 'limit', + ticker: 'XLM', + size: 3000, + price: 200, + status: 'unsettled', + }, + { + orderId: '18', + side: 'bid', + orderType: 'market', + ticker: 'SAND', + price: 450, + status: 'settled', + }, + { + orderId: '19', + side: 'ask', + orderType: 'market', + ticker: 'XTZ', + size: 100, + status: 'unsettled', + }, + { + orderId: '20', + side: 'bid', + orderType: 'limit', + ticker: 'BTC', + size: 0.2, + price: 101000, + status: 'settled', + }, + { + orderId: '21', + side: 'ask', + orderType: 'limit', + ticker: 'BTC', + size: 0.4, + price: 101500, + status: 'unsettled', + }, + { + orderId: '22', + side: 'bid', + orderType: 'market', + ticker: 'ETH', + price: 2880000, + status: 'settled', + }, + { + orderId: '23', + side: 'ask', + orderType: 'limit', + ticker: 'XRP', + size: 1200, + price: 600, + status: 'unsettled', + }, + { + orderId: '24', + side: 'bid', + orderType: 'limit', + ticker: 'SOL', + size: 10, + price: 65500, + status: 'settled', + }, + { + orderId: '25', + side: 'ask', + orderType: 'market', + ticker: 'DOT', + size: 55, + status: 'unsettled', + }, + { + orderId: '26', + side: 'bid', + orderType: 'limit', + ticker: 'ADA', + size: 350, + price: 630, + status: 'settled', + }, + { + orderId: '27', + side: 'ask', + orderType: 'market', + ticker: 'DOGE', + size: 6000, + status: 'unsettled', + }, + { + orderId: '28', + side: 'bid', + orderType: 'limit', + ticker: 'MATIC', + size: 800, + price: 620, + status: 'settled', + }, + { + orderId: '29', + side: 'ask', + orderType: 'limit', + ticker: 'LINK', + size: 30, + price: 12500, + status: 'unsettled', + }, + { + orderId: '30', + side: 'bid', + orderType: 'market', + ticker: 'BNB', + price: 482000, + status: 'settled', + }, + { + orderId: '31', + side: 'ask', + orderType: 'limit', + ticker: 'AVAX', + size: 18, + price: 18300, + status: 'unsettled', + }, + { + orderId: '32', + side: 'bid', + orderType: 'market', + ticker: 'LTC', + price: 71000, + status: 'settled', + }, + { + orderId: '33', + side: 'ask', + orderType: 'limit', + ticker: 'ATOM', + size: 25, + price: 8400, + status: 'unsettled', + }, + { + orderId: '34', + side: 'bid', + orderType: 'market', + ticker: 'XLM', + price: 210, + status: 'settled', + }, + { + orderId: '35', + side: 'ask', + orderType: 'limit', + ticker: 'SAND', + size: 700, + price: 460, + status: 'unsettled', + }, + { + orderId: '36', + side: 'bid', + orderType: 'limit', + ticker: 'XTZ', + size: 120, + price: 1500, + status: 'settled', + }, + { + orderId: '37', + side: 'ask', + orderType: 'market', + ticker: 'BTC', + size: 0.15, + status: 'unsettled', + }, + { + orderId: '38', + side: 'bid', + orderType: 'limit', + ticker: 'ETH', + size: 1.5, + price: 2830000, + status: 'settled', + }, + { + orderId: '39', + side: 'ask', + orderType: 'market', + ticker: 'DOT', + size: 60, + status: 'unsettled', + }, + { + orderId: '40', + side: 'bid', + orderType: 'limit', + ticker: 'ADA', + size: 400, + price: 620, + status: 'settled', + }, + { + orderId: '41', + side: 'ask', + orderType: 'limit', + ticker: 'SHIB', + size: 60000000, + price: 0.0012, + status: 'unsettled', + }, + { + orderId: '42', + side: 'bid', + orderType: 'market', + ticker: 'SOL', + price: 66000, + status: 'settled', + }, + { + orderId: '43', + side: 'ask', + orderType: 'limit', + ticker: 'BNB', + size: 2.5, + price: 485000, + status: 'unsettled', + }, + { + orderId: '44', + side: 'bid', + orderType: 'market', + ticker: 'MATIC', + price: 610, + status: 'settled', + }, + { + orderId: '45', + side: 'ask', + orderType: 'limit', + ticker: 'LINK', + size: 35, + price: 12200, + status: 'unsettled', + }, + { + orderId: '46', + side: 'bid', + orderType: 'limit', + ticker: 'AVAX', + size: 20, + price: 18100, + status: 'settled', + }, + { + orderId: '47', + side: 'ask', + orderType: 'market', + ticker: 'LTC', + size: 10, + status: 'unsettled', + }, + { + orderId: '48', + side: 'bid', + orderType: 'limit', + ticker: 'XRP', + size: 1500, + price: 580, + status: 'settled', + }, + { + orderId: '49', + side: 'ask', + orderType: 'market', + ticker: 'DOGE', + size: 7000, + status: 'unsettled', + }, + { + orderId: '50', + side: 'bid', + orderType: 'limit', + ticker: 'BTC', + size: 0.3, + price: 101200, + status: 'settled', + }, + { + orderId: '51', + side: 'ask', + orderType: 'limit', + ticker: 'ETH', + size: 1.8, + price: 2840000, + status: 'unsettled', + }, + { + orderId: '52', + side: 'bid', + orderType: 'market', + ticker: 'XLM', + price: 205, + status: 'settled', + }, + { + orderId: '53', + side: 'ask', + orderType: 'limit', + ticker: 'ATOM', + size: 22, + price: 8500, + status: 'unsettled', + }, + { + orderId: '54', + side: 'bid', + orderType: 'market', + ticker: 'SAND', + price: 455, + status: 'settled', + }, + { + orderId: '55', + side: 'ask', + orderType: 'limit', + ticker: 'XTZ', + size: 110, + price: 1520, + status: 'unsettled', + }, + { + orderId: '56', + side: 'bid', + orderType: 'limit', + ticker: 'DOT', + size: 65, + price: 8000, + status: 'settled', + }, + { + orderId: '57', + side: 'ask', + orderType: 'market', + ticker: 'ADA', + size: 450, + status: 'unsettled', + }, + { + orderId: '58', + side: 'bid', + orderType: 'limit', + ticker: 'SHIB', + size: 70000000, + price: 0.0011, + status: 'settled', + }, + { + orderId: '59', + side: 'ask', + orderType: 'market', + ticker: 'SOL', + size: 12, + status: 'unsettled', + }, + { + orderId: '60', + side: 'bid', + orderType: 'limit', + ticker: 'BNB', + size: 3, + price: 483000, + status: 'settled', + }, +]; diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index 0aa6bda..cae55e2 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -1,7 +1,8 @@ /* v8 ignore start */ import { http, HttpResponse } from 'msw'; +import type { HistoryResonseData } from '~/features/profile/types/tradingHistory.type'; import type { Response } from '~/shared/types/api'; -import { DUMMY_USERINFO_DATA } from './dummy'; +import { DUMMY_HISTORY_LIST, DUMMY_USERINFO_DATA } from './dummy'; function api(endpoint: string) { return `http://localhost:8080/api/${endpoint}`; @@ -29,5 +30,30 @@ export const handlers = [ status: 200, }); }), + http.get(api('history'), async ({ request }) => { + const { searchParams } = new URL(request.url); + + const page = Number(searchParams.get('page') || 1); + const size = Number(searchParams.get('size') || 10); + const settled = + searchParams.get('settled') === 'true' ? 'settled' : 'unsettled'; + + const filteredOrderlist = DUMMY_HISTORY_LIST.filter( + (item) => item.status === settled, + ); + const firstItemIndex = (page - 1) * size; + const lastItemIndex = page * size; + + const historyData: HistoryResonseData = { + orderList: filteredOrderlist.slice(firstItemIndex, lastItemIndex + 1), + totalPages: Math.ceil(filteredOrderlist.length / size), + currentPage: page, + pageSize: size, + }; + + return HttpResponse.json(successResponse(historyData), { + status: 200, + }); + }), ]; /* v8 ignore end */ From 9d14a50a79ee42e143b68d1a48688a0943a41f8b Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Thu, 17 Jul 2025 20:26:02 +0900 Subject: [PATCH 30/87] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=EC=8B=9C=20=EC=9D=B4=EC=A0=84=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EB=8F=99=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/routes/callback.tsx | 18 ++++++++++----- src/app/routes/login.tsx | 28 ++++++++++++++++++++++-- src/app/sessions.server.ts | 28 ++++++++++++++++++++++++ src/widgets/auth/ui/LoginModal/index.tsx | 8 +++++-- src/widgets/navbar/ui/NavBar/index.tsx | 2 +- 5 files changed, 73 insertions(+), 11 deletions(-) create mode 100644 src/app/sessions.server.ts diff --git a/src/app/routes/callback.tsx b/src/app/routes/callback.tsx index 77e2aba..595c034 100644 --- a/src/app/routes/callback.tsx +++ b/src/app/routes/callback.tsx @@ -6,14 +6,19 @@ import { useEffect } from 'react'; import type { UserInfoResponse } from '~/entities/user/types/user.type'; import ApiClient from '~/shared/api/httpClient'; import { useUserId } from '../provider/UserInfoProvider'; +import { getSession } from '../sessions.server'; export async function loader({ request }: LoaderFunctionArgs) { const rawCookie = request.headers.get('Cookie') ?? ''; + + const session = await getSession(rawCookie); + const referer = session.get('referer') || '/'; + const cookies = cookie.parse(rawCookie); const isAccessTokenExists = !!cookies.access_token; if (!isAccessTokenExists) { - return redirect('/trade/BTC/login'); + return redirect(referer); } const response = await ApiClient.get('api/userinfo', { @@ -24,18 +29,19 @@ export async function loader({ request }: LoaderFunctionArgs) { const { data } = await response.json(); - return data.userId; + return { userId: data.userId, referer: referer }; } export default function CallbackRoutes({ loaderData }: Route.ComponentProps) { + const { userId, referer } = loaderData; const navigate = useNavigate(); - const { userId, setUserId } = useUserId(); - setUserId(loaderData); + const { setUserId } = useUserId(); + setUserId(userId); useEffect(() => { if (!userId) return; - navigate('/trade/BTC'); - }, [userId, navigate]); + navigate(referer); + }, [userId, referer, navigate]); return null; } diff --git a/src/app/routes/login.tsx b/src/app/routes/login.tsx index 6b67006..f3595bf 100644 --- a/src/app/routes/login.tsx +++ b/src/app/routes/login.tsx @@ -1,5 +1,29 @@ +import { data } from 'react-router'; import { LoginModal } from '~/widgets/auth'; +import { commitSession, getSession } from '../sessions.server'; +import type { Route } from './+types/login'; -export default function LoginRouteComponent() { - return ; +export async function loader({ request }: Route.LoaderArgs) { + const { searchParams } = new URL(request.url); + const session = await getSession(request.headers.get('Cookie')); + + const referer = searchParams.get('referer') || '/'; + session.set('referer', referer); + + return data( + { referer }, + { + headers: { + 'set-cookie': await commitSession(session), + }, + }, + ); +} + +export default function LoginRouteComponent({ + loaderData, +}: Route.ComponentProps) { + const { referer } = loaderData; + + return ; } diff --git a/src/app/sessions.server.ts b/src/app/sessions.server.ts new file mode 100644 index 0000000..14c243b --- /dev/null +++ b/src/app/sessions.server.ts @@ -0,0 +1,28 @@ +import { createCookieSessionStorage } from 'react-router'; + +type SessionData = { + userId: string; + referer: string; +}; + +type SessionFlashData = { + error: string; +}; + +const MINITE = 60; + +const { getSession, commitSession, destroySession } = + createCookieSessionStorage({ + cookie: { + name: '__session', + + httpOnly: true, + maxAge: MINITE * 60 * 24, + path: '/', + sameSite: 'lax', + secrets: [String(import.meta.env.VITE_APP_SECRET)], + secure: true, + }, + }); + +export { getSession, commitSession, destroySession }; diff --git a/src/widgets/auth/ui/LoginModal/index.tsx b/src/widgets/auth/ui/LoginModal/index.tsx index 724e1d3..3cfa3da 100644 --- a/src/widgets/auth/ui/LoginModal/index.tsx +++ b/src/widgets/auth/ui/LoginModal/index.tsx @@ -8,10 +8,14 @@ import Modal from '~/shared/ui/Modal'; import CloudLogo from '~/assets/images/cloud.webp'; -export default function LoginModal() { +type LoginModalProps = { + referer: string; +}; + +export default function LoginModal({ referer }: LoginModalProps) { const navigate = useNavigate(); const modalRef = useRef(null); - useClickOutside(modalRef, () => navigate(-1)); + useClickOutside(modalRef, () => navigate(referer)); return ( diff --git a/src/widgets/navbar/ui/NavBar/index.tsx b/src/widgets/navbar/ui/NavBar/index.tsx index cdc902c..f8058a4 100644 --- a/src/widgets/navbar/ui/NavBar/index.tsx +++ b/src/widgets/navbar/ui/NavBar/index.tsx @@ -39,7 +39,7 @@ export default function NavBar({ }; const LoginButton = () => ( - + ); From de21c0cf2feca791a1eb08bfce296b40e7e202ef Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Thu, 17 Jul 2025 21:59:09 +0900 Subject: [PATCH 31/87] =?UTF-8?q?fix:=20react=20router=EA=B0=80=20ssr=20?= =?UTF-8?q?=3D>=20csr=EB=A1=9C=20=EC=A0=84=ED=99=98=EB=90=98=EB=8A=94=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/routes/trade.tsx | 5 ++++- src/shared/ui/ClientOnly/index.tsx | 11 +++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 src/shared/ui/ClientOnly/index.tsx diff --git a/src/app/routes/trade.tsx b/src/app/routes/trade.tsx index ae6598f..1f47f0c 100644 --- a/src/app/routes/trade.tsx +++ b/src/app/routes/trade.tsx @@ -10,6 +10,7 @@ import { CoinListWithSearchBar } from '~/features/coin-search-list'; import { OrderForm, OrderFormFallback } from '~/features/order'; import { ExecutionList } from '~/features/order-execution-list'; import useTradeNotification from '~/features/trade/hooks/useTradeNotification'; +import ClientOnly from '~/shared/ui/ClientOnly'; import Container from '~/shared/ui/Container'; import ContainerTitle from '~/shared/ui/ContainerTitle'; import { NavBar, SideBar } from '~/widgets/navbar'; @@ -87,7 +88,9 @@ export default function TradeRouteComponent({ 실시간 차트 - + + + diff --git a/src/shared/ui/ClientOnly/index.tsx b/src/shared/ui/ClientOnly/index.tsx new file mode 100644 index 0000000..edfdb78 --- /dev/null +++ b/src/shared/ui/ClientOnly/index.tsx @@ -0,0 +1,11 @@ +type ClientOnlyProps = { + children: React.ReactNode; + fallback?: React.ReactNode; +}; + +export default function ClientOnly({ + children, + fallback = null, +}: ClientOnlyProps) { + return typeof window !== 'undefined' ? <>{children} : fallback; +} From 60b296b84389ccb7f600a1a922f6d655277cb891 Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Sun, 20 Jul 2025 23:25:00 +0900 Subject: [PATCH 32/87] =?UTF-8?q?refactor:=20profile=20route=EC=97=90?= =?UTF-8?q?=EC=84=9C=20history=20route=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/routes.ts | 4 +++- src/app/routes/history.tsx | 23 +++++++++++++++++++ src/features/profile/index.ts | 1 + .../profile/ui/TradingHistoryList/index.tsx | 15 ++++++++---- src/widgets/navbar/ui/NavBar/index.tsx | 4 +++- src/widgets/user/ui/ProfileModal/index.tsx | 5 ++-- 6 files changed, 42 insertions(+), 10 deletions(-) create mode 100644 src/app/routes/history.tsx diff --git a/src/app/routes.ts b/src/app/routes.ts index 3f32d0c..65095bd 100644 --- a/src/app/routes.ts +++ b/src/app/routes.ts @@ -7,7 +7,9 @@ export default [ ...prefix('trade', [ route(':ticker', './routes/trade.tsx', [ route('login', './routes/login.tsx'), - route('profile', './routes/profile.tsx', { id: 'profile' }), + route('profile', './routes/profile.tsx', [ + route('history', './routes/history.tsx'), + ]), ]), ]), ] satisfies RouteConfig; diff --git a/src/app/routes/history.tsx b/src/app/routes/history.tsx new file mode 100644 index 0000000..7dc45a5 --- /dev/null +++ b/src/app/routes/history.tsx @@ -0,0 +1,23 @@ +import { TradingHistory } from '~/features/profile'; +import { api as profileApi } from '~/features/profile'; +import type { Route } from './+types/history'; + +const FETCH_SIZE = 10; + +export async function loader({ request }: Route.LoaderArgs) { + const { searchParams } = new URL(request.url); + const page = Number(searchParams.get('p') || 1); + const settled = searchParams.get('t') === 'settled'; + + const response = profileApi.getHistory(page, FETCH_SIZE, settled); + + const { data } = await response.json(); + + return data; +} + +export default function HistoryRouteComponent({ + loaderData, +}: Route.ComponentProps) { + return ; +} diff --git a/src/features/profile/index.ts b/src/features/profile/index.ts index 90291be..8a42cfc 100644 --- a/src/features/profile/index.ts +++ b/src/features/profile/index.ts @@ -1,3 +1,4 @@ export { default as CoinPieChart } from './ui/CoinPieChart'; export type { HistoryResponse } from './types/tradingHistory.type'; export { default as api } from './api/history.endpoint'; +export { default as TradingHistory } from './ui/TradingHistoryList'; diff --git a/src/features/profile/ui/TradingHistoryList/index.tsx b/src/features/profile/ui/TradingHistoryList/index.tsx index 8717c31..9750846 100644 --- a/src/features/profile/ui/TradingHistoryList/index.tsx +++ b/src/features/profile/ui/TradingHistoryList/index.tsx @@ -1,12 +1,17 @@ -import { useRouteLoaderData, useSearchParams } from 'react-router'; +import { useSearchParams } from 'react-router'; + import Pagination from '~/shared/ui/Pagination'; import Tab from '~/shared/ui/Tab'; -import type { TradingHistory } from '../../types/tradingHistory.type'; +import type { HistoryResonseData } from '../../types/tradingHistory.type'; import TradingHistoryListItem from '../TradingHistoryListItem'; -export default function TradingHistoryList() { - const { historyData } = useRouteLoaderData('profile'); +type TradingHistoryListProps = { + historyData: HistoryResonseData; +}; +export default function TradingHistoryList({ + historyData, +}: TradingHistoryListProps) { const [searchParams, setSearchParams] = useSearchParams({ p: '1', t: 'unsettled', @@ -54,7 +59,7 @@ export default function TradingHistoryList() { 취소
      - {historyData.orderList.map((item: TradingHistory) => ( + {historyData.orderList.map((item) => ( ))}
    diff --git a/src/widgets/navbar/ui/NavBar/index.tsx b/src/widgets/navbar/ui/NavBar/index.tsx index f8058a4..fb18d1f 100644 --- a/src/widgets/navbar/ui/NavBar/index.tsx +++ b/src/widgets/navbar/ui/NavBar/index.tsx @@ -51,7 +51,9 @@ export default function NavBar({ ); const ProfileButton = () => ( - + ); diff --git a/src/widgets/user/ui/ProfileModal/index.tsx b/src/widgets/user/ui/ProfileModal/index.tsx index 8788640..b6cfca6 100644 --- a/src/widgets/user/ui/ProfileModal/index.tsx +++ b/src/widgets/user/ui/ProfileModal/index.tsx @@ -1,9 +1,8 @@ import { useRef } from 'react'; -import { useNavigate, useSearchParams } from 'react-router'; +import { Outlet, useNavigate, useSearchParams } from 'react-router'; import type { UserInfoResponseData } from '~/entities/user'; import AssetInfoGraphic from '~/features/profile/ui/AssetInfoGraphic'; -import TradingHistory from '~/features/profile/ui/TradingHistoryList'; import useClickOutside from '~/shared/hooks/useClickOutside'; import Backdrop from '~/shared/ui/Backdrop'; import Modal from '~/shared/ui/Modal'; @@ -25,7 +24,7 @@ export default function ProfileModal({ userInfo }: ProfileModalProps) {
    - +
    From 9ed26f2276de4d307b87ed4cea0d280473e6a840 Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Mon, 21 Jul 2025 23:54:21 +0900 Subject: [PATCH 33/87] =?UTF-8?q?feat:=20Error=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=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 | 5 +- src/assets/lotties/error.json | 544 ++++++++++++++++++++++++++++++++++ src/shared/ui/Error/index.tsx | 20 ++ yarn.lock | 12 + 4 files changed, 580 insertions(+), 1 deletion(-) create mode 100644 src/assets/lotties/error.json create mode 100644 src/shared/ui/Error/index.tsx diff --git a/package.json b/package.json index d293641..8ee5473 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "isbot": "^5", "ky": "^1.8.1", "lightweight-charts": "^5.0.7", + "lottie-react": "^2.4.1", "motion": "^12.12.1", "react": "^19.1.0", "react-dom": "^19.1.0", @@ -69,6 +70,8 @@ }, "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e", "msw": { - "workerDirectory": ["public"] + "workerDirectory": [ + "public" + ] } } diff --git a/src/assets/lotties/error.json b/src/assets/lotties/error.json new file mode 100644 index 0000000..0ff4e02 --- /dev/null +++ b/src/assets/lotties/error.json @@ -0,0 +1,544 @@ +{ + "v": "5.5.7", + "meta": { "g": "LottieFiles AE 0.1.20", "a": "", "k": "", "d": "", "tc": "" }, + "fr": 30, + "ip": 0, + "op": 80, + "w": 500, + "h": 500, + "nm": "exclamação animation", + "ddd": 0, + "assets": [], + "layers": [ + { + "ddd": 0, + "ind": 1, + "ty": 4, + "nm": "exclamation", + "sr": 1, + "ks": { + "o": { "a": 0, "k": 100, "ix": 11 }, + "r": { "a": 0, "k": 0, "ix": 10 }, + "p": { + "a": 1, + "k": [ + { + "i": { "x": 0.667, "y": 1 }, + "o": { "x": 0.333, "y": 0 }, + "t": 7, + "s": [258.4, 231.36, 0], + "to": [0, -1.06, 0], + "ti": [0, 0, 0] + }, + { + "i": { "x": 0.667, "y": 1 }, + "o": { "x": 0.333, "y": 0 }, + "t": 10, + "s": [258.4, 225, 0], + "to": [0, 0, 0], + "ti": [0, -1.06, 0] + }, + { + "i": { "x": 0.833, "y": 1 }, + "o": { "x": 0.167, "y": 0 }, + "t": 13, + "s": [258.4, 231.36, 0], + "to": [0, 1.06, 0], + "ti": [0, 1.06, 0] + }, + { + "i": { "x": 0.667, "y": 1 }, + "o": { "x": 0.333, "y": 0 }, + "t": 41, + "s": [258.4, 231.36, 0], + "to": [0, -1.06, 0], + "ti": [0, 0, 0] + }, + { + "i": { "x": 0.667, "y": 1 }, + "o": { "x": 0.333, "y": 0 }, + "t": 44, + "s": [258.4, 225, 0], + "to": [0, 0, 0], + "ti": [0, -1.06, 0] + }, + { "t": 47, "s": [258.4, 231.36, 0] } + ], + "ix": 2 + }, + "a": { "a": 0, "k": [16.613, 83.692, 0], "ix": 1 }, + "s": { + "a": 1, + "k": [ + { + "i": { "x": [0.667, 0.667, 0.667], "y": [1, 1, 1] }, + "o": { "x": [0.333, 0.333, 0.333], "y": [0, 0, 0] }, + "t": 7, + "s": [90, 90, 100] + }, + { + "i": { "x": [0.667, 0.667, 0.667], "y": [1, 1, 1] }, + "o": { "x": [0.333, 0.333, 0.333], "y": [0, 0, 0] }, + "t": 10, + "s": [93, 93, 100] + }, + { + "i": { "x": [0.833, 0.833, 0.833], "y": [1, 1, 1] }, + "o": { "x": [0.167, 0.167, 0.167], "y": [0, 0, 0] }, + "t": 13, + "s": [90, 90, 100] + }, + { + "i": { "x": [0.667, 0.667, 0.667], "y": [1, 1, 1] }, + "o": { "x": [0.333, 0.333, 0.333], "y": [0, 0, 0] }, + "t": 41, + "s": [90, 90, 100] + }, + { + "i": { "x": [0.667, 0.667, 0.667], "y": [1, 1, 1] }, + "o": { "x": [0.333, 0.333, 0.333], "y": [0, 0, 0] }, + "t": 44, + "s": [93, 93, 100] + }, + { "t": 47, "s": [90, 90, 100] } + ], + "ix": 6 + } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 0, + "k": { + "i": [ + [9.037, 0], + [0, 0], + [0, 9.038], + [-9.037, 0], + [0, -9.037] + ], + "o": [ + [0, 0], + [-9.037, 0], + [0, -9.037], + [9.037, 0], + [0, 9.038] + ], + "v": [ + [0, 16.363], + [0, 16.363], + [-16.363, 0], + [0, -16.363], + [16.363, 0] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "fl", + "c": { "a": 0, "k": [1, 1, 1, 1], "ix": 4 }, + "o": { "a": 0, "k": 100, "ix": 5 }, + "r": 1, + "bm": 0, + "nm": "Fill 1", + "mn": "ADBE Vector Graphic - Fill", + "hd": false + }, + { + "ty": "tr", + "p": { "a": 0, "k": [16.613, 150.771], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 1 }, + "s": { "a": 0, "k": [100, 100], "ix": 3 }, + "r": { "a": 0, "k": 0, "ix": 6 }, + "o": { "a": 0, "k": 100, "ix": 7 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "nm": "Transform" + } + ], + "nm": "Group 1", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + }, + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 0, + "k": { + "i": [ + [8.134, 0], + [0, 0], + [0, 8.134], + [0, 0], + [-8.133, 0], + [0, -8.134], + [0, 0] + ], + "o": [ + [0, 0], + [-8.133, 0], + [0, 0], + [0, -8.134], + [8.134, 0], + [0, 0], + [0, 8.134] + ], + "v": [ + [0, 59.999], + [0, 59.999], + [-14.727, 45.271], + [-14.727, -45.271], + [0, -59.999], + [14.727, -45.271], + [14.727, 45.271] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "fl", + "c": { "a": 0, "k": [1, 1, 1, 1], "ix": 4 }, + "o": { "a": 0, "k": 100, "ix": 5 }, + "r": 1, + "bm": 0, + "nm": "Fill 1", + "mn": "ADBE Vector Graphic - Fill", + "hd": false + }, + { + "ty": "tr", + "p": { "a": 0, "k": [16.613, 60.249], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 1 }, + "s": { "a": 0, "k": [100, 100], "ix": 3 }, + "r": { "a": 0, "k": 0, "ix": 6 }, + "o": { "a": 0, "k": 100, "ix": 7 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "nm": "Transform" + } + ], + "nm": "Group 2", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 2, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 80, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 2, + "ty": 4, + "nm": "stroke circle", + "sr": 1, + "ks": { + "o": { "a": 0, "k": 100, "ix": 11 }, + "r": { "a": 0, "k": 0, "ix": 10 }, + "p": { "a": 0, "k": [258.4, 231.36, 0], "ix": 2 }, + "a": { "a": 0, "k": [146, 146, 0], "ix": 1 }, + "s": { "a": 0, "k": [100, 100, 100], "ix": 6 } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 0, + "k": { + "i": [[-77.872, 0], [0, -77.872], [77.872, 0], [0, 77.872]], + "o": [[77.872, 0], [0, 77.872], [-77.872, 0], [0, -77.872]], + "v": [[0, -141], [141, 0], [0, 141], [-141, 0]], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "st", + "c": { + "a": 0, + "k": [0.952999997606, 0.340999977261, 0.301999978458, 1], + "ix": 3 + }, + "o": { "a": 0, "k": 100, "ix": 4 }, + "w": { "a": 0, "k": 3, "ix": 5 }, + "lc": 1, + "lj": 1, + "ml": 10, + "bm": 0, + "nm": "Stroke 1", + "mn": "ADBE Vector Graphic - Stroke", + "hd": false + }, + { + "ty": "tr", + "p": { "a": 0, "k": [146, 146], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 1 }, + "s": { + "a": 1, + "k": [ + { + "i": { "x": [0.667, 0.667], "y": [1, 1] }, + "o": { "x": [0.333, 0.333], "y": [0, 0] }, + "t": 30, + "s": [90, 90] + }, + { "t": 78, "s": [120, 120] } + ], + "ix": 3 + }, + "r": { "a": 0, "k": 0, "ix": 6 }, + "o": { + "a": 1, + "k": [ + { + "i": { "x": [0.667], "y": [1] }, + "o": { "x": [0.333], "y": [0] }, + "t": 30, + "s": [90] + }, + { "t": 79, "s": [0] } + ], + "ix": 7 + }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "nm": "Transform" + } + ], + "nm": "Group 1", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 90, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 3, + "ty": 4, + "nm": "stroke circle 2", + "sr": 1, + "ks": { + "o": { "a": 0, "k": 100, "ix": 11 }, + "r": { "a": 0, "k": 0, "ix": 10 }, + "p": { "a": 0, "k": [258.4, 231.36, 0], "ix": 2 }, + "a": { "a": 0, "k": [146, 146, 0], "ix": 1 }, + "s": { "a": 0, "k": [100, 100, 100], "ix": 6 } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 0, + "k": { + "i": [[-77.872, 0], [0, -77.872], [77.872, 0], [0, 77.872]], + "o": [[77.872, 0], [0, 77.872], [-77.872, 0], [0, -77.872]], + "v": [[0, -141], [141, 0], [0, 141], [-141, 0]], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "st", + "c": { + "a": 0, + "k": [0.952999997606, 0.340999977261, 0.301999978458, 1], + "ix": 3 + }, + "o": { "a": 0, "k": 100, "ix": 4 }, + "w": { "a": 0, "k": 3, "ix": 5 }, + "lc": 1, + "lj": 1, + "ml": 10, + "bm": 0, + "nm": "Stroke 1", + "mn": "ADBE Vector Graphic - Stroke", + "hd": false + }, + { + "ty": "tr", + "p": { "a": 0, "k": [146, 146], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 1 }, + "s": { + "a": 1, + "k": [ + { + "i": { "x": [0.667, 0.667], "y": [1, 1] }, + "o": { "x": [0.333, 0.333], "y": [0, 0] }, + "t": 0, + "s": [90, 90] + }, + { "t": 48, "s": [120, 120] } + ], + "ix": 3 + }, + "r": { "a": 0, "k": 0, "ix": 6 }, + "o": { + "a": 1, + "k": [ + { + "i": { "x": [0.667], "y": [1] }, + "o": { "x": [0.333], "y": [0] }, + "t": 0, + "s": [90] + }, + { "t": 49, "s": [0] } + ], + "ix": 7 + }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "nm": "Transform" + } + ], + "nm": "Group 1", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 90, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 4, + "ty": 4, + "nm": "circle", + "sr": 1, + "ks": { + "o": { "a": 0, "k": 100, "ix": 11 }, + "r": { "a": 0, "k": 0, "ix": 10 }, + "p": { "a": 0, "k": [258.4, 231.36, 0], "ix": 2 }, + "a": { "a": 0, "k": [130.82, 130.82, 0], "ix": 1 }, + "s": { "a": 0, "k": [100, 100, 100], "ix": 6 } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 0, + "k": { + "i": [[-72.112, 0], [0, -72.112], [72.111, 0], [0, 72.111]], + "o": [[72.111, 0], [0, 72.111], [-72.112, 0], [0, -72.112]], + "v": [[0, -130.57], [130.57, 0], [0, 130.57], [-130.57, 0]], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "fl", + "c": { + "a": 0, + "k": [0.952999997606, 0.340999977261, 0.301999978458, 1], + "ix": 4 + }, + "o": { "a": 0, "k": 100, "ix": 5 }, + "r": 1, + "bm": 0, + "nm": "Fill 1", + "mn": "ADBE Vector Graphic - Fill", + "hd": false + }, + { + "ty": "tr", + "p": { "a": 0, "k": [130.82, 130.82], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 1 }, + "s": { "a": 0, "k": [100, 100], "ix": 3 }, + "r": { "a": 0, "k": 0, "ix": 6 }, + "o": { "a": 0, "k": 100, "ix": 7 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "nm": "Transform" + } + ], + "nm": "Group 1", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 90, + "st": 0, + "bm": 0 + } + ], + "markers": [] +} diff --git a/src/shared/ui/Error/index.tsx b/src/shared/ui/Error/index.tsx new file mode 100644 index 0000000..175c58c --- /dev/null +++ b/src/shared/ui/Error/index.tsx @@ -0,0 +1,20 @@ +import Lottie from 'lottie-react'; +import ErrorAnimation from '~/assets/lotties/error.json'; + +type ErrorComponentProps = { + title: string; + description: string; +}; + +export default function ErrorComponent({ + title, + description, +}: ErrorComponentProps) { + return ( +
    + +

    {title}

    +

    {description}

    +
    + ); +} diff --git a/yarn.lock b/yarn.lock index 41c06a4..edbb75e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5021,6 +5021,18 @@ log-update@^6.1.0: strip-ansi "^7.1.0" wrap-ansi "^9.0.0" +lottie-react@^2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/lottie-react/-/lottie-react-2.4.1.tgz#4bd3f2a8a5e48edbd43c05ca5080fdd50f049d31" + integrity sha512-LQrH7jlkigIIv++wIyrOYFLHSKQpEY4zehPicL9bQsrt1rnoKRYCYgpCUe5maqylNtacy58/sQDZTkwMcTRxZw== + dependencies: + lottie-web "^5.10.2" + +lottie-web@^5.10.2: + version "5.13.0" + resolved "https://registry.yarnpkg.com/lottie-web/-/lottie-web-5.13.0.tgz#441d3df217cc8ba302338c3f168e1a3af0f221d3" + integrity sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ== + loupe@^3.1.0, loupe@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/loupe/-/loupe-3.1.3.tgz#042a8f7986d77f3d0f98ef7990a2b2fef18b0fd2" From 8ab745779cfb7d00977eba431ff1bd8169a571bd Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Mon, 21 Jul 2025 23:55:11 +0900 Subject: [PATCH 34/87] =?UTF-8?q?feat:=20=EA=B0=81=EA=B0=81=20Route?= =?UTF-8?q?=EC=97=90=20=EB=8C=80=ED=95=9C=20error=20boundary=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/root.tsx | 37 ++++++++++++------------------------- src/app/routes/history.tsx | 22 ++++++++++++++++++++++ src/app/routes/login.tsx | 23 ++++++++++++++++++++++- src/app/routes/profile.tsx | 22 ++++++++++++++++++++++ src/app/routes/trade.tsx | 23 ++++++++++++++++++++++- 5 files changed, 100 insertions(+), 27 deletions(-) diff --git a/src/app/root.tsx b/src/app/root.tsx index eec2f94..2aea1bb 100644 --- a/src/app/root.tsx +++ b/src/app/root.tsx @@ -1,4 +1,3 @@ -import * as Sentry from '@sentry/react-router'; import { preload } from 'react-dom'; import { Links, @@ -12,6 +11,7 @@ import { Slide } from 'react-toastify'; import { ToastContainer } from 'react-toastify/unstyled'; import './app.css'; +import ErrorComponent from '~/shared/ui/Error'; import type { Route } from './+types/root'; import StompProvider from './provider/StompProvider'; import UserIdProvider from './provider/UserInfoProvider'; @@ -112,33 +112,20 @@ export default function App() { } export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { - let message = 'Oops!'; - let details = 'An unexpected error occurred.'; - let stack: string | undefined; - + let errorTitle = '에러발생'; + let errorDescription = '예상하지 못한 에러가 발생했습니다.'; if (isRouteErrorResponse(error)) { - message = error.status === 404 ? '404' : 'Error'; - details = - error.status === 404 - ? 'The requested page could not be found.' - : error.statusText || details; - } else if (error && error instanceof Error) { - Sentry.captureException(error); + errorTitle = `${error.status} ${error.statusText}`; + errorDescription = error.data; + } + if (error instanceof Error) { + errorTitle = error.name; + errorDescription = error.message; + if (import.meta.env.DEV) { - details = error.message; - stack = error.stack; + errorDescription += `\n\n${error.stack}`; } } - return ( -
    -

    {message}

    -

    {details}

    - {stack && ( -
    -					{stack}
    -				
    - )} -
    - ); + return ; } diff --git a/src/app/routes/history.tsx b/src/app/routes/history.tsx index 7dc45a5..814478a 100644 --- a/src/app/routes/history.tsx +++ b/src/app/routes/history.tsx @@ -1,5 +1,7 @@ +import { isRouteErrorResponse } from 'react-router'; import { TradingHistory } from '~/features/profile'; import { api as profileApi } from '~/features/profile'; +import ErrorComponent from '~/shared/ui/Error'; import type { Route } from './+types/history'; const FETCH_SIZE = 10; @@ -16,6 +18,26 @@ export async function loader({ request }: Route.LoaderArgs) { return data; } +export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { + if (isRouteErrorResponse(error)) { + const errorTitle = `${error.status} ${error.statusText}`; + const errorDescription = error.data; + return ; + } + if (error instanceof Error) { + const errorTitle = error.name; + const errorDescription = error.message; + return ; + } + + return ( + + ); +} + export default function HistoryRouteComponent({ loaderData, }: Route.ComponentProps) { diff --git a/src/app/routes/login.tsx b/src/app/routes/login.tsx index f3595bf..7266819 100644 --- a/src/app/routes/login.tsx +++ b/src/app/routes/login.tsx @@ -1,4 +1,5 @@ -import { data } from 'react-router'; +import { data, isRouteErrorResponse } from 'react-router'; +import ErrorComponent from '~/shared/ui/Error'; import { LoginModal } from '~/widgets/auth'; import { commitSession, getSession } from '../sessions.server'; import type { Route } from './+types/login'; @@ -20,6 +21,26 @@ export async function loader({ request }: Route.LoaderArgs) { ); } +export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { + if (isRouteErrorResponse(error)) { + const errorTitle = `${error.status} ${error.statusText}`; + const errorDescription = error.data; + return ; + } + if (error instanceof Error) { + const errorTitle = error.name; + const errorDescription = error.message; + return ; + } + + return ( + + ); +} + export default function LoginRouteComponent({ loaderData, }: Route.ComponentProps) { diff --git a/src/app/routes/profile.tsx b/src/app/routes/profile.tsx index abec52f..2680fe4 100644 --- a/src/app/routes/profile.tsx +++ b/src/app/routes/profile.tsx @@ -1,5 +1,7 @@ +import { isRouteErrorResponse } from 'react-router'; import { api as userApi } from '~/entities/user'; import { api as profileApi } from '~/features/profile'; +import ErrorComponent from '~/shared/ui/Error'; import { ProfileModal } from '~/widgets/user'; import type { Route } from './+types/profile'; @@ -23,6 +25,26 @@ export async function loader({ request }: Route.LoaderArgs) { return { userInfo: userInfo.data, historyData: history.data }; } +export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { + if (isRouteErrorResponse(error)) { + const errorTitle = `${error.status} ${error.statusText}`; + const errorDescription = error.data; + return ; + } + if (error instanceof Error) { + const errorTitle = error.name; + const errorDescription = error.message; + return ; + } + + return ( + + ); +} + export default function ProfileRouteComponent({ loaderData, }: Route.ComponentProps) { diff --git a/src/app/routes/trade.tsx b/src/app/routes/trade.tsx index 1f47f0c..966bac8 100644 --- a/src/app/routes/trade.tsx +++ b/src/app/routes/trade.tsx @@ -1,7 +1,7 @@ import * as cookie from 'cookie'; import { AnimatePresence } from 'motion/react'; import { Suspense, lazy, useMemo, useState } from 'react'; -import { Outlet, redirect } from 'react-router'; +import { Outlet, isRouteErrorResponse, redirect } from 'react-router'; import { CoinPriceWithName, api as coinApi } from '~/entities/coin'; import { api } from '~/entities/session'; @@ -13,6 +13,7 @@ import useTradeNotification from '~/features/trade/hooks/useTradeNotification'; import ClientOnly from '~/shared/ui/ClientOnly'; import Container from '~/shared/ui/Container'; import ContainerTitle from '~/shared/ui/ContainerTitle'; +import ErrorComponent from '~/shared/ui/Error'; import { NavBar, SideBar } from '~/widgets/navbar'; import { useUserId } from '../provider/UserInfoProvider'; import type { Route } from './+types/trade'; @@ -43,6 +44,26 @@ export async function clientAction() { return redirect('/trade/BTC'); } +export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { + if (isRouteErrorResponse(error)) { + const errorTitle = `${error.status} ${error.statusText}`; + const errorDescription = error.data; + return ; + } + if (error instanceof Error) { + const errorTitle = error.name; + const errorDescription = error.message; + return ; + } + + return ( + + ); +} + export default function TradeRouteComponent({ loaderData, }: Route.ComponentProps) { From 3d17745101d9b02c8399989aa39d5f9112e4986c Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Wed, 23 Jul 2025 21:40:48 +0900 Subject: [PATCH 35/87] =?UTF-8?q?fix:=20=EB=B0=B1=EC=97=94=EB=93=9C=20api?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=B0=8F=20=EC=9D=B8=ED=84=B0?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/profile/api/history.endpoint.ts | 8 +- src/features/profile/index.ts | 7 +- .../profile/types/tradingHistory.type.ts | 80 +- .../ui/TradingHistoryCancleButton/index.tsx | 18 +- .../ui/TradingHistoryListItem/index.tsx | 22 +- src/mocks/dummy.ts | 694 +++++++----------- src/mocks/handlers.ts | 41 +- 7 files changed, 407 insertions(+), 463 deletions(-) diff --git a/src/features/profile/api/history.endpoint.ts b/src/features/profile/api/history.endpoint.ts index 9463781..5eea904 100644 --- a/src/features/profile/api/history.endpoint.ts +++ b/src/features/profile/api/history.endpoint.ts @@ -8,9 +8,11 @@ export default { if (page) params.set('page', page.toString()); if (size) params.set('size', size.toString()); - if (settled) params.set('settled', settled.toString()); - else params.set('settled', 'unsettled'); + if (settled) params.set('settled', 'true'); + else params.set('settled', 'false'); - return ApiClient.get(`api/history?${params.toString()}`); + return ApiClient.get( + `api/userinfo/trades?${params.toString()}`, + ); }, }; diff --git a/src/features/profile/index.ts b/src/features/profile/index.ts index 8a42cfc..e47ba4f 100644 --- a/src/features/profile/index.ts +++ b/src/features/profile/index.ts @@ -1,4 +1,9 @@ export { default as CoinPieChart } from './ui/CoinPieChart'; -export type { HistoryResponse } from './types/tradingHistory.type'; +export type { + HistoryResponse, + OrderType, + Side, + OrderStatus, +} from './types/tradingHistory.type'; export { default as api } from './api/history.endpoint'; export { default as TradingHistory } from './ui/TradingHistoryList'; diff --git a/src/features/profile/types/tradingHistory.type.ts b/src/features/profile/types/tradingHistory.type.ts index 2b53310..057f9b5 100644 --- a/src/features/profile/types/tradingHistory.type.ts +++ b/src/features/profile/types/tradingHistory.type.ts @@ -1,39 +1,88 @@ import type { Response } from '~/shared/types/api'; +// 지정가/시장가 +export enum OrderType { + LIMIT = 'LIMIT', + MARKET = 'MARKET', +} + +// 매수/매도 +export enum Side { + ASK = 'ASK', + BID = 'BID', +} + +// 주문 상태 +export enum OrderStatus { + UNSETTLED = 'UNSETTLED', + SETTLED = 'SETTLED', + IN_PROGRESS = 'IN_PROGRESS', +} + export type TradingHistory = | { + // 지정가 매도 + side: Side.ASK; + orderStatus: + | OrderStatus.UNSETTLED + | OrderStatus.SETTLED + | OrderStatus.IN_PROGRESS; + orderType: OrderType.LIMIT; orderId: string; - side: 'ask'; - orderType: 'limit'; ticker: string; - size: number; + name: string; price: number; - status: 'unsettled' | 'settled' | 'in_progress'; + orderSize: number; + remainingSize: number; + displaySize: number; + tradeTime: string; } | { + // 시장가 매도 + side: Side.ASK; + orderStatus: + | OrderStatus.UNSETTLED + | OrderStatus.SETTLED + | OrderStatus.IN_PROGRESS; + orderType: OrderType.MARKET; orderId: string; - side: 'ask'; - orderType: 'market'; ticker: string; - size: number; - status: 'unsettled' | 'settled' | 'in_progress'; + name: string; + orderSize: number; + remainingSize: number; + displaySize: number; + tradeTime: string; } | { + // 지정가 매수 + side: Side.BID; + orderStatus: + | OrderStatus.UNSETTLED + | OrderStatus.SETTLED + | OrderStatus.IN_PROGRESS; + orderType: OrderType.LIMIT; orderId: string; - side: 'bid'; - orderType: 'limit'; ticker: string; - size: number; + name: string; price: number; - status: 'unsettled' | 'settled' | 'in_progress'; + orderSize: number; + remainingSize: number; + displaySize: number; + tradeTime: string; } | { + // 시장가 매수 + side: Side.BID; + orderStatus: + | OrderStatus.UNSETTLED + | OrderStatus.SETTLED + | OrderStatus.IN_PROGRESS; + orderType: OrderType.MARKET; orderId: string; - side: 'bid'; - orderType: 'market'; ticker: string; + name: string; price: number; - status: 'unsettled' | 'settled' | 'in_progress'; + tradeTime: string; }; export type HistoryResonseData = { @@ -41,6 +90,7 @@ export type HistoryResonseData = { totalPages: number; currentPage: number; pageSize: number; + totalElements: number; }; export type HistoryResponse = Response; diff --git a/src/features/profile/ui/TradingHistoryCancleButton/index.tsx b/src/features/profile/ui/TradingHistoryCancleButton/index.tsx index d880954..d6251f6 100644 --- a/src/features/profile/ui/TradingHistoryCancleButton/index.tsx +++ b/src/features/profile/ui/TradingHistoryCancleButton/index.tsx @@ -1,9 +1,12 @@ import clsx from 'clsx'; import type { ButtonHTMLAttributes } from 'react'; -import type { TradingHistory } from '../../types/tradingHistory.type'; +import { + OrderStatus, + type TradingHistory, +} from '../../types/tradingHistory.type'; type TradingHistoryCancelButtonProps = { - status: TradingHistory['status']; + status: TradingHistory['orderStatus']; } & ButtonHTMLAttributes; export default function TradingHistoryCancelButton({ @@ -12,22 +15,21 @@ export default function TradingHistoryCancelButton({ }: TradingHistoryCancelButtonProps) { let text = ''; switch (status) { - case 'unsettled': + case OrderStatus.UNSETTLED: + case OrderStatus.IN_PROGRESS: text = '취소'; break; - case 'settled': + case OrderStatus.SETTLED: text = '체결완료'; break; - case 'in_progress': - text = '체결중'; - break; } return ( @@ -59,7 +60,8 @@ export default function Pagination({ From 5d22be4845b911e75c22c228d76a1573fefccb9c Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Wed, 23 Jul 2025 22:49:09 +0900 Subject: [PATCH 38/87] =?UTF-8?q?fix:=20Error=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=EC=97=90=20ClientOnly=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit node환경에서 Lottie 컴포넌트를 렌더링 할 수 없어서 서버에러가 발생하는 것을 ClientOnly를 사용하여 수정하였습니다. --- src/shared/ui/Error/index.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/shared/ui/Error/index.tsx b/src/shared/ui/Error/index.tsx index 175c58c..1f9e941 100644 --- a/src/shared/ui/Error/index.tsx +++ b/src/shared/ui/Error/index.tsx @@ -1,5 +1,6 @@ import Lottie from 'lottie-react'; import ErrorAnimation from '~/assets/lotties/error.json'; +import ClientOnly from '../ClientOnly'; type ErrorComponentProps = { title: string; @@ -12,7 +13,9 @@ export default function ErrorComponent({ }: ErrorComponentProps) { return (
    - + + +

    {title}

    {description}

    From 2f52c2f7e71e30b04e4fa1dc689b9c64992c8f15 Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Wed, 23 Jul 2025 23:15:16 +0900 Subject: [PATCH 39/87] =?UTF-8?q?fix:=20=EC=B2=B4=EA=B2=B0=EB=82=B4?= =?UTF-8?q?=EC=97=AD=20=EB=AA=A9=EB=A1=9D=20=EC=97=90=EB=9F=AC=ED=95=B8?= =?UTF-8?q?=EB=93=A4=EB=A7=81=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/routes/history.tsx | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/src/app/routes/history.tsx b/src/app/routes/history.tsx index 814478a..78526b3 100644 --- a/src/app/routes/history.tsx +++ b/src/app/routes/history.tsx @@ -1,6 +1,7 @@ -import { isRouteErrorResponse } from 'react-router'; -import { TradingHistory } from '~/features/profile'; -import { api as profileApi } from '~/features/profile'; +import { HTTPError } from 'ky'; +import { data, isRouteErrorResponse } from 'react-router'; + +import { TradingHistory, api as profileApi } from '~/features/profile'; import ErrorComponent from '~/shared/ui/Error'; import type { Route } from './+types/history'; @@ -8,14 +9,27 @@ const FETCH_SIZE = 10; export async function loader({ request }: Route.LoaderArgs) { const { searchParams } = new URL(request.url); - const page = Number(searchParams.get('p') || 1); + const page = searchParams.get('p') ? Number(searchParams.get('p')) : 1; const settled = searchParams.get('t') === 'settled'; - const response = profileApi.getHistory(page, FETCH_SIZE, settled); - - const { data } = await response.json(); + if (page < 1) { + throw data('잘못된 요청입니다.', { status: 400 }); + } - return data; + try { + const response = await profileApi.getHistory(page, FETCH_SIZE, settled); + const { data } = await response.json(); + return data; + } catch (error) { + if (error instanceof HTTPError) { + const errorText = await error.response.text(); + throw data(errorText, { status: error.response.status }); + } + if (error instanceof Error) { + throw data(error.message, { status: 500 }); + } + throw data('예상하지 못한 에러가 발생했습니다.', { status: 500 }); + } } export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { From 9b6d181916a25de8b845130933cb0f1f8cfaab37 Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Thu, 24 Jul 2025 01:35:37 +0900 Subject: [PATCH 40/87] =?UTF-8?q?fix:=20profile=20route=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=ED=95=B8=EB=93=A4=EB=A7=81=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/routes/profile.tsx | 49 +++++++++++++++--------------- src/shared/ui/Error/index.tsx | 2 +- src/shared/ui/ErrorModal/index.tsx | 28 +++++++++++++++++ 3 files changed, 53 insertions(+), 26 deletions(-) create mode 100644 src/shared/ui/ErrorModal/index.tsx diff --git a/src/app/routes/profile.tsx b/src/app/routes/profile.tsx index 2680fe4..46274f9 100644 --- a/src/app/routes/profile.tsx +++ b/src/app/routes/profile.tsx @@ -1,44 +1,43 @@ -import { isRouteErrorResponse } from 'react-router'; +import { HTTPError } from 'ky'; +import { data, isRouteErrorResponse } from 'react-router'; + import { api as userApi } from '~/entities/user'; -import { api as profileApi } from '~/features/profile'; -import ErrorComponent from '~/shared/ui/Error'; +import ErrorModal from '~/shared/ui/ErrorModal'; import { ProfileModal } from '~/widgets/user'; import type { Route } from './+types/profile'; -const FETCH_SIZE = 10; - -export async function loader({ request }: Route.LoaderArgs) { - const { searchParams } = new URL(request.url); - const page = Number(searchParams.get('p') || 1); - const settled = searchParams.get('t') === 'settled'; - - const [userInfoResponse, historyResponse] = await Promise.all([ - userApi.getUserInfo(), - profileApi.getHistory(page, FETCH_SIZE, settled), - ]); - - const [userInfo, history] = await Promise.all([ - userInfoResponse.json(), - historyResponse.json(), - ]); - - return { userInfo: userInfo.data, historyData: history.data }; +export async function loader() { + try { + const response = await userApi.getUserInfo(); + const { data } = await response.json(); + + return data; + } catch (error) { + if (error instanceof HTTPError) { + const errorText = await error.response.text(); + throw data(errorText, { status: error.response.status }); + } + if (error instanceof Error) { + throw data(error.message, { status: 500 }); + } + throw data('예상하지 못한 에러가 발생했습니다.', { status: 500 }); + } } export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { if (isRouteErrorResponse(error)) { const errorTitle = `${error.status} ${error.statusText}`; const errorDescription = error.data; - return ; + return ; } if (error instanceof Error) { const errorTitle = error.name; const errorDescription = error.message; - return ; + return ; } return ( - @@ -48,5 +47,5 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { export default function ProfileRouteComponent({ loaderData, }: Route.ComponentProps) { - return ; + return ; } diff --git a/src/shared/ui/Error/index.tsx b/src/shared/ui/Error/index.tsx index 1f9e941..46d6274 100644 --- a/src/shared/ui/Error/index.tsx +++ b/src/shared/ui/Error/index.tsx @@ -2,7 +2,7 @@ import Lottie from 'lottie-react'; import ErrorAnimation from '~/assets/lotties/error.json'; import ClientOnly from '../ClientOnly'; -type ErrorComponentProps = { +export type ErrorComponentProps = { title: string; description: string; }; diff --git a/src/shared/ui/ErrorModal/index.tsx b/src/shared/ui/ErrorModal/index.tsx new file mode 100644 index 0000000..8073dce --- /dev/null +++ b/src/shared/ui/ErrorModal/index.tsx @@ -0,0 +1,28 @@ +import { useRef } from 'react'; +import { useNavigate, useSearchParams } from 'react-router'; + +import useClickOutside from '~/shared/hooks/useClickOutside'; +import Backdrop from '~/shared/ui/Backdrop'; +import ErrorComponent, { type ErrorComponentProps } from '~/shared/ui/Error'; +import Modal from '~/shared/ui/Modal'; + +type ErrorModalProps = ErrorComponentProps; + +export default function ErrorModal({ title, description }: ErrorModalProps) { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const modalRef = useRef(null); + useClickOutside(modalRef, () => + navigate(searchParams.get('referer') || '/trade/BTC'), + ); + + return ( + + +
    + +
    +
    +
    + ); +} From 181b6d6f157f186ecec6fbf2cda3826cc68c5b8b Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Thu, 24 Jul 2025 01:52:40 +0900 Subject: [PATCH 41/87] =?UTF-8?q?feat:=20history=20=EB=AA=A8=ED=82=B9=20ap?= =?UTF-8?q?i=20=EC=98=88=EC=99=B8=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/mocks/handlers.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index f4b27ba..fc54343 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -48,26 +48,26 @@ export const handlers = [ const firstItemIndex = (page - 1) * size; const lastItemIndex = page * size - 1; - if (firstItemIndex < 0) { + const historyData: HistoryResonseData = { + orderList: filteredOrderlist.slice(firstItemIndex, lastItemIndex + 1), + totalPages: Math.ceil(filteredOrderlist.length / size), + currentPage: page, + pageSize: size, + totalElements: filteredOrderlist.length, + }; + + if (page < 1 || size < 1 || firstItemIndex < 0) { return HttpResponse.json('잘못된 요청입니다.', { status: 400, }); } - if (lastItemIndex > filteredOrderlist.length + size) { + if (page > historyData.totalPages) { return HttpResponse.json('해당하는 리소스가 존재하지 않습니다.', { status: 404, }); } - const historyData: HistoryResonseData = { - orderList: filteredOrderlist.slice(firstItemIndex, lastItemIndex + 1), - totalPages: Math.ceil(filteredOrderlist.length / size), - currentPage: page, - pageSize: size, - totalElements: filteredOrderlist.length, - }; - if (filteredOrderlist.length === 0) { historyData.orderList = []; From 4b53b21898bd5dc705db8660200733bf4b9da46c Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Thu, 24 Jul 2025 02:10:56 +0900 Subject: [PATCH 42/87] =?UTF-8?q?style:=20Button=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20warn=20=EC=8A=A4=ED=83=80=EC=9D=BC=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/shared/ui/Button/index.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/shared/ui/Button/index.tsx b/src/shared/ui/Button/index.tsx index 5fe50f8..549edeb 100644 --- a/src/shared/ui/Button/index.tsx +++ b/src/shared/ui/Button/index.tsx @@ -3,7 +3,7 @@ import type { ButtonHTMLAttributes, ReactNode } from 'react'; export type ButtonProps = { children: ReactNode; - buttonStyle?: 'primary' | 'secondary'; + buttonStyle?: 'primary' | 'secondary' | 'warn'; } & ButtonHTMLAttributes; export default function Button({ @@ -14,10 +14,10 @@ export default function Button({ return ( + ); } From 239695e1fc3f26a4cdd67fa5a8f627b48885dc4f Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Thu, 24 Jul 2025 02:19:24 +0900 Subject: [PATCH 44/87] =?UTF-8?q?refactor:=20useScrollBottom=20=3D>=20useS?= =?UTF-8?q?crollIntoView=EB=A1=9C=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ScrollIntoViewOptions를 인자로 받을 수 있도록 수정하였습니다. --- src/features/chat/ui/AIChatBot/index.tsx | 7 +++++-- src/shared/hooks/hooks.test.tsx | 8 +++++--- .../{useScrollToBottom.tsx => useScrollIntoView.tsx} | 11 ++++------- 3 files changed, 14 insertions(+), 12 deletions(-) rename src/shared/hooks/{useScrollToBottom.tsx => useScrollIntoView.tsx} (56%) diff --git a/src/features/chat/ui/AIChatBot/index.tsx b/src/features/chat/ui/AIChatBot/index.tsx index 43a66d0..fed27f5 100644 --- a/src/features/chat/ui/AIChatBot/index.tsx +++ b/src/features/chat/ui/AIChatBot/index.tsx @@ -8,7 +8,7 @@ import { useState, } from 'react'; -import useScrollToBottom from '~/shared/hooks/useScrollToBottom'; +import useScrollIntoView from '~/shared/hooks/useScrollIntoView'; import { chatMachine } from '../../model/chat.machine'; import ChatButton from '../ChatButton'; import MessageBox from '../MessageBox'; @@ -18,7 +18,10 @@ const LazyChatWindow = lazy(() => import('~/features/chat/ui/ChatWindow')); export default function AIChatBot() { const [state, send] = useMachine(chatMachine); const [isOpen, setIsOpen] = useState(false); - const messagesEndRef = useScrollToBottom([state.context.messageList]); + const messagesEndRef = useScrollIntoView([...state.context.messageList], { + block: 'end', + behavior: 'smooth', + }); const handleOpenChatWindow = () => { setIsOpen(true); diff --git a/src/shared/hooks/hooks.test.tsx b/src/shared/hooks/hooks.test.tsx index 9ee6cce..acfc96c 100644 --- a/src/shared/hooks/hooks.test.tsx +++ b/src/shared/hooks/hooks.test.tsx @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest'; import useClickOutside from './useClickOutside'; import useDimensions from './useDimensions'; -import useScrollToBottom from './useScrollToBottom'; +import useScrollIntoView from './useScrollIntoView'; describe('useClickOutside 훅 테스트', () => { it('ref가 부착된 컴포넌트가 아닌 바깥 컴포넌트를 클릭하면 callback함수가 실행된다.', () => { @@ -50,8 +50,10 @@ describe('useScrollToBottom 훅 테스트', () => { const mockScrollIntoView = vi.fn(); const { result, rerender } = renderHook( - ({ deps }) => useScrollToBottom(deps), - { initialProps: { deps: [1] } }, + ({ deps }) => useScrollIntoView(deps), + { + initialProps: { deps: [1] }, + }, ); act(() => { diff --git a/src/shared/hooks/useScrollToBottom.tsx b/src/shared/hooks/useScrollIntoView.tsx similarity index 56% rename from src/shared/hooks/useScrollToBottom.tsx rename to src/shared/hooks/useScrollIntoView.tsx index 4c40eba..4ded5c6 100644 --- a/src/shared/hooks/useScrollToBottom.tsx +++ b/src/shared/hooks/useScrollIntoView.tsx @@ -1,19 +1,16 @@ import { useEffect, useRef } from 'react'; import type { DependencyList } from 'react'; -export default function useScrollToBottom< +export default function useScrollIntoView< T extends HTMLElement = HTMLDivElement, ->(dependencies: DependencyList = []) { +>(dependencies: DependencyList = [], options?: ScrollIntoViewOptions) { const bottomElementRef = useRef(null); useEffect(() => { if (!bottomElementRef.current) return; - bottomElementRef.current.scrollIntoView({ - behavior: 'smooth', - block: 'end', - }); - }, dependencies); + bottomElementRef.current.scrollIntoView(options); + }, [...dependencies, options]); return bottomElementRef; } From b9c68f2916026e1af26d461f4eebf15f66be4eb7 Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Fri, 25 Jul 2025 17:15:56 +0900 Subject: [PATCH 45/87] =?UTF-8?q?feat:=20=EB=8B=A4=EC=9D=8C=20history=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=EB=A5=BC=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=ED=95=A0=20=EB=95=8C=20=EC=B5=9C=EC=83=81=EB=8B=A8=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EB=8F=99=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../profile/ui/TradingHistoryList/index.tsx | 11 ++++++++++- src/shared/hooks/useScrollTo.tsx | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 src/shared/hooks/useScrollTo.tsx diff --git a/src/features/profile/ui/TradingHistoryList/index.tsx b/src/features/profile/ui/TradingHistoryList/index.tsx index b9b78aa..94845d8 100644 --- a/src/features/profile/ui/TradingHistoryList/index.tsx +++ b/src/features/profile/ui/TradingHistoryList/index.tsx @@ -1,5 +1,6 @@ import { useSearchParams } from 'react-router'; +import useScrollTo from '~/shared/hooks/useScrollTo'; import Pagination from '~/shared/ui/Pagination'; import Tab from '~/shared/ui/Tab'; import type { HistoryResonseData } from '../../types/tradingHistory.type'; @@ -12,6 +13,11 @@ type TradingHistoryListProps = { export default function TradingHistoryList({ historyData, }: TradingHistoryListProps) { + const scrollContainerRef = useScrollTo([], { + top: 0, + behavior: 'instant', + }); + const [searchParams, setSearchParams] = useSearchParams({ p: '1', t: 'unsettled', @@ -59,7 +65,10 @@ export default function TradingHistoryList({ 거래시간 주문 취소 -
      +
        {historyData.orderList.map((item) => ( ))} diff --git a/src/shared/hooks/useScrollTo.tsx b/src/shared/hooks/useScrollTo.tsx new file mode 100644 index 0000000..d040a40 --- /dev/null +++ b/src/shared/hooks/useScrollTo.tsx @@ -0,0 +1,18 @@ +import { type DependencyList, useEffect, useRef } from 'react'; + +export default function useScrollTo( + dependencies: DependencyList = [], + options?: ScrollToOptions, +) { + const scrollContainerRef = useRef(null); + + useEffect(() => { + const scrollContainer = scrollContainerRef.current; + + if (!scrollContainer) return; + + scrollContainer.scrollTo(options); + }, [...dependencies, options]); + + return scrollContainerRef; +} From 3b50d65bcc37640c1a58db29a14d29de6b0d638d Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Fri, 25 Jul 2025 19:33:00 +0900 Subject: [PATCH 46/87] =?UTF-8?q?feat:=20callback=20route=EC=97=90=20error?= =?UTF-8?q?=20handling=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/routes/callback.tsx | 48 ++++++++++++++++++++++++++++++------- 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/src/app/routes/callback.tsx b/src/app/routes/callback.tsx index 595c034..c6c4025 100644 --- a/src/app/routes/callback.tsx +++ b/src/app/routes/callback.tsx @@ -1,10 +1,16 @@ import * as cookie from 'cookie'; -import { type LoaderFunctionArgs, redirect, useNavigate } from 'react-router'; +import { useEffect } from 'react'; +import { + type LoaderFunctionArgs, + isRouteErrorResponse, + redirect, + useNavigate, +} from 'react-router'; import type { Route } from './+types/callback'; -import { useEffect } from 'react'; import type { UserInfoResponse } from '~/entities/user/types/user.type'; import ApiClient from '~/shared/api/httpClient'; +import ErrorComponent from '~/shared/ui/Error'; import { useUserId } from '../provider/UserInfoProvider'; import { getSession } from '../sessions.server'; @@ -21,15 +27,39 @@ export async function loader({ request }: LoaderFunctionArgs) { return redirect(referer); } - const response = await ApiClient.get('api/userinfo', { - headers: { - Cookie: rawCookie, - }, - }); + try { + const response = await ApiClient.get('api/userinfo', { + headers: { + Cookie: rawCookie, + }, + }); + + const responseData = await response.json(); + + return { userId: responseData.data.userId, referer: referer }; + } catch (error) { + throw new Error('로그인에 실패했습니다. 관리자에게 문의하세요.'); + } +} - const { data } = await response.json(); +export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { + if (isRouteErrorResponse(error)) { + const errorTitle = `${error.status} ${error.statusText}`; + const errorDescription = error.data; + return ; + } + if (error instanceof Error) { + const errorTitle = error.name; + const errorDescription = error.message; + return ; + } - return { userId: data.userId, referer: referer }; + return ( + + ); } export default function CallbackRoutes({ loaderData }: Route.ComponentProps) { From ca248ebd5c307953f40623de5f6e8e360b7776a4 Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Fri, 25 Jul 2025 20:17:16 +0900 Subject: [PATCH 47/87] =?UTF-8?q?feat:=20=EC=A3=BC=EB=AC=B8=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20api=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/profile/api/history.endpoint.ts | 3 +++ src/mocks/handlers.ts | 20 +++++++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/features/profile/api/history.endpoint.ts b/src/features/profile/api/history.endpoint.ts index 5eea904..a5654cc 100644 --- a/src/features/profile/api/history.endpoint.ts +++ b/src/features/profile/api/history.endpoint.ts @@ -15,4 +15,7 @@ export default { `api/userinfo/trades?${params.toString()}`, ); }, + deleteHistory: (orderId: string) => { + return ApiClient.delete(`api/userinfo/trades?orderId=${orderId}`); + }, }; diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index fc54343..0c98031 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -7,6 +7,8 @@ import { import type { Response } from '~/shared/types/api'; import { DUMMY_HISTORY_LIST, DUMMY_USERINFO_DATA } from './dummy'; +let historyList = [...DUMMY_HISTORY_LIST]; + function api(endpoint: string) { return `http://localhost:8080/api/${endpoint}`; } @@ -39,7 +41,7 @@ export const handlers = [ const size = Number(searchParams.get('size') || 10); const settled = searchParams.get('settled') === 'true'; - const filteredOrderlist = DUMMY_HISTORY_LIST.filter((item) => + const filteredOrderlist = historyList.filter((item) => settled ? item.orderStatus === OrderStatus.SETTLED : item.orderStatus !== OrderStatus.SETTLED, @@ -80,5 +82,21 @@ export const handlers = [ status: 200, }); }), + http.delete(api('userinfo/trades'), async ({ request }) => { + const { searchParams } = new URL(request.url); + const orderId = searchParams.get('orderId'); + + if (!orderId) { + return HttpResponse.json('잘못된 요청입니다.', { + status: 400, + }); + } + + historyList = historyList.filter((item) => item.orderId !== orderId); + + return HttpResponse.json(successResponse(null), { + status: 205, + }); + }), ]; /* v8 ignore end */ From 84ca1b8a58d8903aae3e807f65dcc682b6864819 Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Fri, 25 Jul 2025 21:52:33 +0900 Subject: [PATCH 48/87] =?UTF-8?q?feat:=20=EB=A1=9C=EB=94=A9=EC=8A=A4?= =?UTF-8?q?=ED=94=BC=EB=84=88=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/ui/Spinner/index.tsx | 10 ++++++++++ src/shared/ui/Spinner/spinner.module.css | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 src/shared/ui/Spinner/index.tsx create mode 100644 src/shared/ui/Spinner/spinner.module.css diff --git a/src/shared/ui/Spinner/index.tsx b/src/shared/ui/Spinner/index.tsx new file mode 100644 index 0000000..ec64c98 --- /dev/null +++ b/src/shared/ui/Spinner/index.tsx @@ -0,0 +1,10 @@ +import type { CSSProperties } from 'react'; +import classes from './spinner.module.css'; + +type SpinnerProps = { + style?: CSSProperties; +}; + +export default function Spinner({ style }: SpinnerProps) { + return
        ; +} diff --git a/src/shared/ui/Spinner/spinner.module.css b/src/shared/ui/Spinner/spinner.module.css new file mode 100644 index 0000000..6aaf8ad --- /dev/null +++ b/src/shared/ui/Spinner/spinner.module.css @@ -0,0 +1,19 @@ +.loader { + width: 16px; + padding: 4px; + margin: 0 auto; + aspect-ratio: 1; + border-radius: 50%; + background: #2b7fff; + --_m: conic-gradient(#0000 10%, #000), linear-gradient(#000 0 0) content-box; + -webkit-mask: var(--_m); + mask: var(--_m); + -webkit-mask-composite: source-out; + mask-composite: subtract; + animation: l3 1s infinite linear; +} +@keyframes l3 { + to { + transform: rotate(1turn); + } +} From 2d7773988a71a7775c91f791e1a9deb1c1888259 Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Fri, 25 Jul 2025 21:53:17 +0900 Subject: [PATCH 49/87] =?UTF-8?q?feat:=20fetcher=EB=A5=BC=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=ED=95=98=EC=97=AC=20=EC=A3=BC=EB=AC=B8=20=EC=B7=A8?= =?UTF-8?q?=EC=86=8C=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/routes/history.tsx | 24 ++++++++++++ .../ui/TradingHistoryCancleButton/index.tsx | 39 +++++++++++++------ .../ui/TradingHistoryListItem/index.tsx | 3 +- 3 files changed, 54 insertions(+), 12 deletions(-) diff --git a/src/app/routes/history.tsx b/src/app/routes/history.tsx index 78526b3..0e5b659 100644 --- a/src/app/routes/history.tsx +++ b/src/app/routes/history.tsx @@ -32,6 +32,30 @@ export async function loader({ request }: Route.LoaderArgs) { } } +export async function clientAction({ request }: Route.ClientActionArgs) { + const formData = await request.formData(); + const orderId = formData.get('orderId') as string; + + if (!orderId) { + throw data('주문번호가 존재하지 않습니다.', { status: 400 }); + } + + try { + await profileApi.deleteHistory(orderId); + + return data({}, { status: 205 }); + } catch (error) { + if (error instanceof HTTPError) { + const errorText = await error.response.text(); + throw data(errorText, { status: error.response.status }); + } + if (error instanceof Error) { + throw data(error.message, { status: 500 }); + } + throw data('예상하지 못한 에러가 발생했습니다.', { status: 500 }); + } +} + export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { if (isRouteErrorResponse(error)) { const errorTitle = `${error.status} ${error.statusText}`; diff --git a/src/features/profile/ui/TradingHistoryCancleButton/index.tsx b/src/features/profile/ui/TradingHistoryCancleButton/index.tsx index d6251f6..f0a1684 100644 --- a/src/features/profile/ui/TradingHistoryCancleButton/index.tsx +++ b/src/features/profile/ui/TradingHistoryCancleButton/index.tsx @@ -1,5 +1,6 @@ -import clsx from 'clsx'; import type { ButtonHTMLAttributes } from 'react'; +import { useFetcher, useLocation } from 'react-router'; +import Spinner from '~/shared/ui/Spinner'; import { OrderStatus, type TradingHistory, @@ -7,13 +8,20 @@ import { type TradingHistoryCancelButtonProps = { status: TradingHistory['orderStatus']; + orderId: string; } & ButtonHTMLAttributes; export default function TradingHistoryCancelButton({ status, + orderId, ...props }: TradingHistoryCancelButtonProps) { + const location = useLocation(); + const fetcher = useFetcher(); + const isDeleting = fetcher.state !== 'idle'; + let text = ''; + switch (status) { case OrderStatus.UNSETTLED: case OrderStatus.IN_PROGRESS: @@ -24,16 +32,25 @@ export default function TradingHistoryCancelButton({ break; } + const isDisabled = status === OrderStatus.SETTLED || isDeleting; + return ( - + <> + {isDeleting ? ( + + ) : ( + + + + + )} + ); } diff --git a/src/features/profile/ui/TradingHistoryListItem/index.tsx b/src/features/profile/ui/TradingHistoryListItem/index.tsx index a143993..23eca7e 100644 --- a/src/features/profile/ui/TradingHistoryListItem/index.tsx +++ b/src/features/profile/ui/TradingHistoryListItem/index.tsx @@ -11,6 +11,7 @@ type TradingHistoryListItemProps = TradingHistory; export default function TradingHistoryListItem({ tradeTime, + orderId, ...props }: Readonly) { const { side, ticker, orderStatus, orderType } = props; @@ -32,7 +33,7 @@ export default function TradingHistoryListItem({ {sizeText} {formatDateKr(new Date(tradeTime))}
        - +
        ); From 0629eff178e477461106b24929302150724e126c Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Fri, 25 Jul 2025 22:05:57 +0900 Subject: [PATCH 50/87] =?UTF-8?q?style:=20=ED=94=84=EB=A1=9C=ED=95=84=20ro?= =?UTF-8?q?ute=20=ED=8C=A8=EB=94=A9=20=EB=B0=8F=20=ED=85=8C=EB=91=90?= =?UTF-8?q?=EB=A6=AC=20=EC=83=89=EC=83=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/profile/ui/CoinAssetTable/index.tsx | 2 +- src/features/profile/ui/TradingHistoryList/index.tsx | 2 +- src/widgets/user/ui/ProfileModal/index.tsx | 6 ++++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/features/profile/ui/CoinAssetTable/index.tsx b/src/features/profile/ui/CoinAssetTable/index.tsx index f7e9682..7dd5521 100644 --- a/src/features/profile/ui/CoinAssetTable/index.tsx +++ b/src/features/profile/ui/CoinAssetTable/index.tsx @@ -16,7 +16,7 @@ export default function CoinAssetTable({ coinData, ref }: CoinAssetTableProps) { return (
    - {wallet.ticker} + {coin.ticker} - {formatCurrencyKR(wallet.buyPrice)}원 + {formatCurrencyKR(coin.averagePrice)}원 - {formatCurrencyKR(wallet.buyPrice * wallet.size)}원 + {formatCurrencyKR(coin.totalPrice)}원 - {wallet.roi}% + {coin.roi}%
    주문 취소
      {historyData.orderList.map((item) => ( diff --git a/src/widgets/user/ui/ProfileModal/index.tsx b/src/widgets/user/ui/ProfileModal/index.tsx index b6cfca6..f2128f8 100644 --- a/src/widgets/user/ui/ProfileModal/index.tsx +++ b/src/widgets/user/ui/ProfileModal/index.tsx @@ -22,9 +22,11 @@ export default function ProfileModal({ userInfo }: ProfileModalProps) { return ( -
      +
      - +
      + +
      From 9064d41cb6c0e62d5eb7bebc906b219d1fa5782a Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Fri, 25 Jul 2025 22:30:45 +0900 Subject: [PATCH 51/87] =?UTF-8?q?style:=20=EC=B2=B4=EA=B2=B0=EB=82=B4?= =?UTF-8?q?=EC=97=AD=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/profile/ui/TradingHistoryList/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/features/profile/ui/TradingHistoryList/index.tsx b/src/features/profile/ui/TradingHistoryList/index.tsx index 6412bb0..26b61a4 100644 --- a/src/features/profile/ui/TradingHistoryList/index.tsx +++ b/src/features/profile/ui/TradingHistoryList/index.tsx @@ -63,7 +63,9 @@ export default function TradingHistoryList({ 가격 수량 거래시간 - 주문 취소 + + {tab === 'unsettled' ? '주문 취소' : '상태'} +
        Date: Sat, 26 Jul 2025 20:51:35 +0900 Subject: [PATCH 52/87] =?UTF-8?q?fix:=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=EB=84=A4=EC=9D=B4=EC=85=98=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit currentPage === totalPages 일 때 page decrement 버튼이 활성화되는 버그수정 --- src/shared/ui/Pagination/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/shared/ui/Pagination/index.tsx b/src/shared/ui/Pagination/index.tsx index 3af139f..ecfad34 100644 --- a/src/shared/ui/Pagination/index.tsx +++ b/src/shared/ui/Pagination/index.tsx @@ -39,7 +39,7 @@ export default function Pagination({ onClick={onPrevClick} type="button" className="w-3 cursor-pointer fill-gray-400 hover:fill-gray-500 disabled:cursor-not-allowed disabled:fill-gray-200" - disabled={currentPage === 1} + disabled={currentPage <= 1} > @@ -61,7 +61,7 @@ export default function Pagination({ onClick={onNextClick} type="button" className="w-3 cursor-pointer fill-gray-400 hover:fill-gray-500 disabled:cursor-not-allowed disabled:fill-gray-200" - disabled={currentPage === totalPages} + disabled={currentPage >= totalPages} > From 3a9fd178bf51967cbd841216c7197bd9eec9ba01 Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Sat, 26 Jul 2025 20:53:38 +0900 Subject: [PATCH 53/87] =?UTF-8?q?feat:=20=EC=BB=A8=ED=85=90=EC=B8=A0?= =?UTF-8?q?=EA=B0=80=20=EC=97=86=EC=9D=84=EC=8B=9C=20=EB=B3=B4=EC=97=AC?= =?UTF-8?q?=EC=A3=BC=EB=8A=94=20fallback=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=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 --- src/assets/lotties/no-content.json | 1 + .../profile/ui/TradingHistoryList/index.tsx | 16 +++++++++--- src/shared/ui/NoContent/index.tsx | 26 +++++++++++++++++++ 3 files changed, 40 insertions(+), 3 deletions(-) create mode 100644 src/assets/lotties/no-content.json create mode 100644 src/shared/ui/NoContent/index.tsx diff --git a/src/assets/lotties/no-content.json b/src/assets/lotties/no-content.json new file mode 100644 index 0000000..e0fa9dc --- /dev/null +++ b/src/assets/lotties/no-content.json @@ -0,0 +1 @@ +{"v":"5.6.8","fr":30,"ip":0,"op":120,"w":3840,"h":2160,"nm":"10","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"eyes","parent":2,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[2996.232,1161.836,0],"ix":2},"a":{"a":0,"k":[3011.69,1478.552,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":58,"s":[93,93,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":63,"s":[93,18.6,100]},{"t":72,"s":[93,93,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-84.939],[84.94,0],[0,84.94],[-84.939,0]],"o":[[0,84.94],[-84.939,0],[0,-84.939],[84.94,0]],"v":[[153.797,0],[0,153.797],[-153.796,0],[0,-153.796]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.227450995352,0.192156877705,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":16,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.227450995352,0.192156877705,0.250980392157,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[2710.332,1478.552],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-84.939],[84.939,0],[0,84.94],[-84.94,0]],"o":[[0,84.94],[-84.94,0],[0,-84.939],[84.939,0]],"v":[[153.797,0],[0,153.797],[-153.796,0],[0,-153.796]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.227450995352,0.192156877705,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":16,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.227450995352,0.192156877705,0.250980392157,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[3313.048,1478.552],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":3,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":120,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"ghost","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":30,"s":[7]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":60,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":90,"s":[-7]},{"t":119,"s":[0]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.505,"y":1},"o":{"x":0.495,"y":0},"t":0,"s":[1097,1280,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.531,"y":1},"o":{"x":0.503,"y":0},"t":60,"s":[2768,1280,0],"to":[0,0,0],"ti":[0,0,0]},{"t":119,"s":[1097,1280,0]}],"ix":2},"a":{"a":0,"k":[3000,1750,0],"ix":1},"s":{"a":0,"k":[50,50,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[16.788,-27.874],[-16.788,27.874]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.227450995352,0.192156877705,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":16,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[2716.447,1392.434],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[16.788,-27.874],[-16.788,27.874]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.227450995352,0.192156877705,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":16,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[2658.799,1392.434],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[16.788,-27.874],[-16.788,27.874]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.227450995352,0.192156877705,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":16,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[2601.151,1392.434],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":2,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[16.788,-27.874],[-16.788,27.874]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.227450995352,0.192156877705,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":16,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[3397.203,1392.434],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 4","np":2,"cix":2,"bm":0,"ix":4,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[16.788,-27.874],[-16.788,27.874]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.227450995352,0.192156877705,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":16,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[3339.555,1392.434],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 5","np":2,"cix":2,"bm":0,"ix":5,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[16.788,-27.874],[-16.788,27.874]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.227450995352,0.192156877705,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":16,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[3281.906,1392.434],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 6","np":2,"cix":2,"bm":0,"ix":6,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[19.974,-30.439]],"o":[[-19.974,-30.439],[0,0],[0,0]],"v":[[93.512,25.278],[0,-25.278],[-93.512,25.278]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.227450995352,0.192156877705,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":16,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[3000,1417.712],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 7","np":2,"cix":2,"bm":0,"ix":7,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":0,"s":[{"i":[[-8.142,-14.102],[0,0],[-22.676,39.277],[0,0],[-8.142,-14.102],[0,0],[-23.423,37.931],[0,0],[-8.003,-13.862],[0,0],[-23.424,37.931],[0,0],[-8.004,-13.862],[0,0],[-23.424,37.931],[0,0],[-8.003,-13.862],[0,0],[1.264,22.249],[0,0],[314.296,0],[17.83,-313.79],[0,0],[-11.142,19.299],[0,0]],"o":[[0,0],[22.677,39.277],[0,0],[8.142,-14.102],[0,0],[22.291,38.608],[0,0],[8.41,-13.619],[0,0],[22.29,38.608],[0,0],[8.411,-13.619],[0,0],[22.29,38.608],[0,0],[8.41,-13.619],[0,0],[11.143,19.299],[0,0],[-17.829,-313.79],[-314.296,0],[0,0],[-1.264,22.249],[0,0],[8.142,-14.102]],"v":[[-521.679,825.992],[-467.204,920.347],[-365.16,920.347],[-310.684,825.992],[-274.046,825.992],[-207.626,941.033],[-106.478,942.531],[-33.525,824.396],[2.792,824.934],[69.824,941.033],[170.972,942.531],[243.925,824.396],[280.242,824.934],[347.272,941.033],[448.421,942.531],[521.375,824.396],[557.691,824.934],[629.555,949.406],[668.994,937.629],[591.78,-421.352],[0,-980.462],[-591.781,-421.352],[-668.995,937.604],[-629.557,949.38],[-558.318,825.992]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":30,"s":[{"i":[[-8.142,-14.102],[0,0],[-22.676,39.277],[0,0],[-8.142,-14.102],[0,0],[-23.423,37.931],[0,0],[-8.003,-13.862],[0,0],[-23.424,37.931],[0,0],[-8.004,-13.862],[0,0],[-23.424,37.931],[0,0],[-8.003,-13.862],[0,0],[1.264,22.249],[146.434,1003.659],[314.296,0],[23.956,-395.099],[0,0],[-11.142,19.299],[0,0]],"o":[[0,0],[22.677,39.277],[0,0],[8.142,-14.102],[0,0],[22.291,38.608],[0,0],[8.41,-13.619],[0,0],[22.29,38.608],[0,0],[8.411,-13.619],[0,0],[22.291,38.608],[0,0],[8.41,-13.619],[0,0],[11.143,19.299],[0,0],[-44.416,-435.812],[-314.296,0],[-2.554,586.948],[-1.264,22.249],[0,0],[8.142,-14.102]],"v":[[-649.192,837.148],[-594.716,931.503],[-492.673,931.503],[-438.196,837.148],[-401.559,837.148],[-335.139,952.189],[-233.99,953.687],[-161.037,835.552],[-124.72,836.09],[-57.689,952.189],[43.46,953.687],[116.412,835.552],[152.73,836.09],[219.759,952.189],[320.909,953.687],[393.862,835.552],[430.179,836.09],[502.043,960.562],[541.482,948.785],[591.78,-421.352],[-0.001,-980.462],[-591.781,-421.352],[-796.507,948.76],[-757.069,960.536],[-685.83,837.148]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":60,"s":[{"i":[[-8.142,-14.102],[0,0],[-22.676,39.277],[0,0],[-8.142,-14.102],[0,0],[-23.423,37.931],[0,0],[-8.003,-13.862],[0,0],[-23.424,37.931],[0,0],[-8.004,-13.862],[0,0],[-23.424,37.931],[0,0],[-8.003,-13.862],[0,0],[1.264,22.249],[0,0],[314.296,0],[17.83,-313.79],[0,0],[-11.142,19.299],[0,0]],"o":[[0,0],[22.677,39.277],[0,0],[8.142,-14.102],[0,0],[22.291,38.608],[0,0],[8.41,-13.619],[0,0],[22.29,38.608],[0,0],[8.411,-13.619],[0,0],[22.29,38.608],[0,0],[8.41,-13.619],[0,0],[11.143,19.299],[0,0],[-17.829,-313.79],[-314.296,0],[0,0],[-1.264,22.249],[0,0],[8.142,-14.102]],"v":[[-521.679,825.992],[-467.204,920.347],[-365.16,920.347],[-310.684,825.992],[-274.046,825.992],[-207.626,941.033],[-106.478,942.531],[-33.525,824.396],[2.792,824.934],[69.824,941.033],[170.972,942.531],[243.925,824.396],[280.242,824.934],[347.272,941.033],[448.421,942.531],[521.375,824.396],[557.691,824.934],[629.555,949.406],[668.994,937.629],[591.78,-421.352],[0,-980.462],[-591.781,-421.352],[-668.995,937.604],[-629.557,949.38],[-558.318,825.992]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":90,"s":[{"i":[[-8.142,-14.102],[0,0],[-22.675,39.277],[0,0],[-8.142,-14.102],[0,0],[-23.423,37.931],[0,0],[-8.004,-13.862],[0,0],[-23.423,37.931],[0,0],[-8.004,-13.862],[0,0],[-23.424,37.931],[0,0],[-8.003,-13.862],[0,0],[1.264,22.249],[-25.568,753.13],[314.296,0],[50.211,-380.695],[0,0],[-11.142,19.299],[0,0]],"o":[[0,0],[22.677,39.277],[0,0],[8.142,-14.102],[0,0],[22.291,38.608],[0,0],[8.41,-13.619],[0,0],[22.29,38.608],[0,0],[8.411,-13.619],[0,0],[22.29,38.608],[0,0],[8.41,-13.619],[0,0],[11.143,19.299],[0,0],[-9.741,-345.672],[-314.296,0],[-148.999,979.163],[-1.264,22.249],[0,0],[8.142,-14.102]],"v":[[-426.045,834.359],[-371.569,928.714],[-269.525,928.714],[-215.049,834.359],[-178.411,834.359],[-111.992,949.4],[-10.843,950.898],[62.11,832.763],[98.428,833.301],[165.458,949.4],[266.607,950.898],[339.559,832.763],[375.877,833.301],[442.907,949.4],[544.056,950.898],[617.009,832.763],[653.326,833.301],[725.19,957.773],[764.629,945.996],[591.78,-421.352],[-0.001,-980.462],[-591.781,-421.352],[-573.36,945.971],[-533.922,957.747],[-462.683,834.359]],"c":true}]},{"t":119,"s":[{"i":[[-8.142,-14.102],[0,0],[-22.676,39.277],[0,0],[-8.142,-14.102],[0,0],[-23.423,37.931],[0,0],[-8.003,-13.862],[0,0],[-23.424,37.931],[0,0],[-8.004,-13.862],[0,0],[-23.424,37.931],[0,0],[-8.003,-13.862],[0,0],[1.264,22.249],[0,0],[314.296,0],[17.83,-313.79],[0,0],[-11.142,19.299],[0,0]],"o":[[0,0],[22.677,39.277],[0,0],[8.142,-14.102],[0,0],[22.291,38.608],[0,0],[8.41,-13.619],[0,0],[22.29,38.608],[0,0],[8.411,-13.619],[0,0],[22.29,38.608],[0,0],[8.41,-13.619],[0,0],[11.143,19.299],[0,0],[-17.829,-313.79],[-314.296,0],[0,0],[-1.264,22.249],[0,0],[8.142,-14.102]],"v":[[-521.679,825.992],[-467.204,920.347],[-365.16,920.347],[-310.684,825.992],[-274.046,825.992],[-207.626,941.033],[-106.478,942.531],[-33.525,824.396],[2.792,824.934],[69.824,941.033],[170.972,942.531],[243.925,824.396],[280.242,824.934],[347.272,941.033],[448.421,942.531],[521.375,824.396],[557.691,824.934],[629.555,949.406],[668.994,937.629],[591.78,-421.352],[0,-980.462],[-591.781,-421.352],[-668.995,937.604],[-629.557,949.38],[-558.318,825.992]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.227450995352,0.192156877705,0.250980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":16,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.780392216701,0.901960844152,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[3000,1411.545],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 10","np":3,"cix":2,"bm":0,"ix":8,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":120,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/src/features/profile/ui/TradingHistoryList/index.tsx b/src/features/profile/ui/TradingHistoryList/index.tsx index 26b61a4..5881f61 100644 --- a/src/features/profile/ui/TradingHistoryList/index.tsx +++ b/src/features/profile/ui/TradingHistoryList/index.tsx @@ -1,6 +1,7 @@ import { useSearchParams } from 'react-router'; import useScrollTo from '~/shared/hooks/useScrollTo'; +import NoContent from '~/shared/ui/NoContent'; import Pagination from '~/shared/ui/Pagination'; import Tab from '~/shared/ui/Tab'; import type { HistoryResonseData } from '../../types/tradingHistory.type'; @@ -24,6 +25,8 @@ export default function TradingHistoryList({ }); const currentPage = Number(searchParams.get('p')); const tab = searchParams.get('t') || 'unsettled'; + const noContentTitle = + tab === 'unsettled' ? '미체결 내역이 없습니다.' : '체결 내역이 없습니다.'; const handleTabClick = (value: string) => { setSearchParams({ p: '1', t: value }); @@ -71,9 +74,16 @@ export default function TradingHistoryList({ className="scrollbar-custom flex h-60 flex-col gap-2 overflow-auto px-2 py-2" ref={scrollContainerRef} > - {historyData.orderList.map((item) => ( - - ))} + {historyData.orderList.length ? ( + historyData.orderList.map((item) => ( + + )) + ) : ( + + )}
      + + + +

      {title}

      + {description &&

      {description}

      } + + ); +} From c6ca1b8429b2cfe23d439c41853a88fc27681efd Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Sat, 26 Jul 2025 20:54:28 +0900 Subject: [PATCH 54/87] =?UTF-8?q?fix:=20/userinfo/trades=20=EB=AA=A8?= =?UTF-8?q?=ED=82=B9=20api=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit orderList가 빈배열일 때 204를 응답하면 클라이언트에서는 본문이 없을 것으로 예상하여 Unexpected end of JSON input에러 발생합니다. 따라서 빈배열일 때도 200응답 반환으로 수정하였습니다. --- src/mocks/handlers.ts | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index 0c98031..6cb11c7 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -64,20 +64,6 @@ export const handlers = [ }); } - if (page > historyData.totalPages) { - return HttpResponse.json('해당하는 리소스가 존재하지 않습니다.', { - status: 404, - }); - } - - if (filteredOrderlist.length === 0) { - historyData.orderList = []; - - return HttpResponse.json(successResponse(historyData), { - status: 204, - }); - } - return HttpResponse.json(successResponse(historyData), { status: 200, }); From b09bc6a4058d04931ca3df7ce6237b2a8a460bc6 Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Sat, 26 Jul 2025 21:11:50 +0900 Subject: [PATCH 55/87] =?UTF-8?q?style:=20Fallback=20component=20=ED=8C=A8?= =?UTF-8?q?=EB=94=A9=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../profile/ui/CoinAssetTable/index.tsx | 81 ++++++++++--------- src/shared/ui/NoContent/index.tsx | 13 ++- 2 files changed, 56 insertions(+), 38 deletions(-) diff --git a/src/features/profile/ui/CoinAssetTable/index.tsx b/src/features/profile/ui/CoinAssetTable/index.tsx index 7dd5521..45277f5 100644 --- a/src/features/profile/ui/CoinAssetTable/index.tsx +++ b/src/features/profile/ui/CoinAssetTable/index.tsx @@ -1,4 +1,5 @@ import type { RefObject } from 'react'; +import NoContent from '~/shared/ui/NoContent'; import { formatCurrencyKR } from '~/shared/utils'; import { COLORS, @@ -34,43 +35,51 @@ export default function CoinAssetTable({ coinData, ref }: CoinAssetTableProps) {
    - - {coinData.map((coin, index) => { - const color = COLORS[index % COLORS.length]; - const roiTextColor = - coin.roi > 0 ? '#fb2c36' : coin.roi < 0 ? '#3b82f6' : '#9ca3af'; - return ( - - - - - - - {coin.roi}% - - - ); - })} - + + + + + + + ); + })} + + ) : ( + + )}
    수익률
    - - - {coin.ticker} - - {formatCurrencyKR(coin.averagePrice)}원 - - {formatCurrencyKR(coin.totalPrice)}원 - + {coinData.map((coin, index) => { + const color = COLORS[index % COLORS.length]; + const roiTextColor = + coin.roi > 0 ? '#fb2c36' : coin.roi < 0 ? '#3b82f6' : '#9ca3af'; + return ( +
    + + + {coin.ticker} + + {formatCurrencyKR(coin.averagePrice)}원 + + {formatCurrencyKR(coin.totalPrice)}원 + + {coin.roi}% +
    ); diff --git a/src/shared/ui/NoContent/index.tsx b/src/shared/ui/NoContent/index.tsx index 46f24a3..7b15950 100644 --- a/src/shared/ui/NoContent/index.tsx +++ b/src/shared/ui/NoContent/index.tsx @@ -1,4 +1,5 @@ import Lottie from 'lottie-react'; +import type { CSSProperties } from 'react'; import NoContentAnimation from '~/assets/lotties/no-content.json'; import ClientOnly from '../ClientOnly'; @@ -6,11 +7,19 @@ import ClientOnly from '../ClientOnly'; export type NoContentProps = { title: string; description?: string; + style?: CSSProperties; }; -export default function NoContent({ title, description }: NoContentProps) { +export default function NoContent({ + title, + description, + style, +}: NoContentProps) { return ( -
    +
    Date: Sat, 26 Jul 2025 21:32:27 +0900 Subject: [PATCH 56/87] =?UTF-8?q?fix:=20=ED=8C=8C=EC=9D=B4=EC=B0=A8?= =?UTF-8?q?=ED=8A=B8=20=EC=86=8C=EC=88=98=20=EB=8B=A8=EC=9C=84=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 텍스트가 길어지면 가 잘리는 현상이 있어서 소수점 단위는 제거 했습니다. --- src/features/profile/ui/CoinPieChartActiveShape/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/profile/ui/CoinPieChartActiveShape/index.tsx b/src/features/profile/ui/CoinPieChartActiveShape/index.tsx index 3f72d61..1f839fd 100644 --- a/src/features/profile/ui/CoinPieChartActiveShape/index.tsx +++ b/src/features/profile/ui/CoinPieChartActiveShape/index.tsx @@ -82,7 +82,7 @@ export default function CoinPieChartActiveShape({ textAnchor={textAnchor} fill="#333" fontSize={14} - >{`${((percent ?? 1) * 100).toFixed(2)}%`} + >{`${((percent ?? 1) * 100).toFixed(0)}%`} ); } From 9a42a3aea5b983b6f88701ce5ae5b5040d06e52c Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Sat, 26 Jul 2025 21:33:44 +0900 Subject: [PATCH 57/87] =?UTF-8?q?feat:=20=ED=8C=8C=EC=9D=B4=EC=B0=A8?= =?UTF-8?q?=ED=8A=B8=EC=97=90=20=EC=98=88=EC=88=98=EA=B8=88(=EC=9B=90?= =?UTF-8?q?=ED=99=94)=EB=8F=84=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../profile/ui/AssetInfoGraphic/index.tsx | 9 +++++--- src/features/profile/utils/index.ts | 21 ++++++++++++++++--- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/features/profile/ui/AssetInfoGraphic/index.tsx b/src/features/profile/ui/AssetInfoGraphic/index.tsx index e5b6849..0164f22 100644 --- a/src/features/profile/ui/AssetInfoGraphic/index.tsx +++ b/src/features/profile/ui/AssetInfoGraphic/index.tsx @@ -14,9 +14,12 @@ type AssetInfoGraphicProps = { export default function AssetInfoGraphic({ userInfo }: AssetInfoGraphicProps) { const assetTableRef = useRef(null); const { wallets, totalAssetAmount } = userInfo; - const coinData = generateCoinPieChartData(wallets); + const assetData = generateCoinPieChartData(userInfo); + const coinData = assetData.filter((item) => item.ticker !== 'KRW'); const roiAverage = - wallets.reduce((acc, item) => acc + item.roi, 0) / wallets.length; + wallets.length > 0 + ? wallets.reduce((acc, item) => acc + item.roi, 0) / wallets.length + : 0; const handleClickChart = (data: CoinPieChartData) => { if (!assetTableRef.current) return; @@ -37,7 +40,7 @@ export default function AssetInfoGraphic({ userInfo }: AssetInfoGraphicProps) { <>
    - +
    diff --git a/src/features/profile/utils/index.ts b/src/features/profile/utils/index.ts index 50a62b9..0520172 100644 --- a/src/features/profile/utils/index.ts +++ b/src/features/profile/utils/index.ts @@ -1,8 +1,10 @@ -import type { Wallet } from '~/entities/user'; +import type { UserInfoResponseData } from '~/entities/user'; import type { CoinPieChartData } from '../types/chart.type'; -export function generateCoinPieChartData(data: Wallet[]): CoinPieChartData[] { - return data +export function generateCoinPieChartData( + data: UserInfoResponseData, +): CoinPieChartData[] { + const pieChartData = data.wallets .map((item) => ({ name: item.name, ticker: item.ticker, @@ -14,4 +16,17 @@ export function generateCoinPieChartData(data: Wallet[]): CoinPieChartData[] { currentPrice: item.currentPrice, })) .sort((a, b) => b.totalPrice - a.totalPrice); + + pieChartData.push({ + name: '원화', + ticker: 'KRW', + accountId: 0, + totalPrice: data.cash, + averagePrice: data.cash, + quantity: 0, + roi: 0, + currentPrice: data.cash, + }); + + return pieChartData; } From b4db40daa583d752f44bf14e7ead7c08959e16ec Mon Sep 17 00:00:00 2001 From: Caniro Date: Sat, 26 Jul 2025 22:36:30 +0900 Subject: [PATCH 58/87] =?UTF-8?q?feat:=20=EB=A7=A4=EB=8F=84=20=ED=98=B8?= =?UTF-8?q?=EA=B0=80=20=EA=B0=80=EA=B2=A9=20=EB=82=B4=EB=A6=BC=EC=B0=A8?= =?UTF-8?q?=EC=88=9C=20=EC=A0=95=EB=A0=AC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/tradeview/ui/Orderbook/chart.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/features/tradeview/ui/Orderbook/chart.tsx b/src/features/tradeview/ui/Orderbook/chart.tsx index 99427f4..ae43521 100644 --- a/src/features/tradeview/ui/Orderbook/chart.tsx +++ b/src/features/tradeview/ui/Orderbook/chart.tsx @@ -22,6 +22,9 @@ export default function OrderbookChart({ layout = 'vertical', }: Readonly) { const color = type === 'bull' ? '#FDD2D7' : '#CDE0FE'; + // 매도는 price 기준 내림차순 정렬 + const displayData = + type === 'bear' ? [...data].sort((a, b) => b.price - a.price) : data; return (
    - {data.map((item) => ( + {displayData.map((item) => (
    - + From 55e161512e03345b6b2973d5fdaa05f7e9d497af Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Sun, 27 Jul 2025 00:12:57 +0900 Subject: [PATCH 59/87] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=EC=9A=A9=20=EC=84=9C=EB=B2=84=20=EC=9C=A0?= =?UTF-8?q?=ED=8B=B8=20=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/routes/callback.tsx | 11 +++++------ src/app/routes/history.tsx | 10 +++++++++- src/app/routes/profile.tsx | 12 ++++++++++-- src/shared/utils/util.server.ts | 13 +++++++++++++ 4 files changed, 37 insertions(+), 9 deletions(-) create mode 100644 src/shared/utils/util.server.ts diff --git a/src/app/routes/callback.tsx b/src/app/routes/callback.tsx index c6c4025..ae2261e 100644 --- a/src/app/routes/callback.tsx +++ b/src/app/routes/callback.tsx @@ -1,4 +1,3 @@ -import * as cookie from 'cookie'; import { useEffect } from 'react'; import { type LoaderFunctionArgs, @@ -11,26 +10,26 @@ import type { Route } from './+types/callback'; import type { UserInfoResponse } from '~/entities/user/types/user.type'; import ApiClient from '~/shared/api/httpClient'; import ErrorComponent from '~/shared/ui/Error'; +import { checkLogin } from '~/shared/utils/util.server'; import { useUserId } from '../provider/UserInfoProvider'; import { getSession } from '../sessions.server'; export async function loader({ request }: LoaderFunctionArgs) { - const rawCookie = request.headers.get('Cookie') ?? ''; + const rawCookie = request.headers.get('Cookie'); const session = await getSession(rawCookie); const referer = session.get('referer') || '/'; - const cookies = cookie.parse(rawCookie); - const isAccessTokenExists = !!cookies.access_token; + const isLoggedIn = checkLogin(rawCookie); - if (!isAccessTokenExists) { + if (!isLoggedIn) { return redirect(referer); } try { const response = await ApiClient.get('api/userinfo', { headers: { - Cookie: rawCookie, + Cookie: rawCookie as string, }, }); diff --git a/src/app/routes/history.tsx b/src/app/routes/history.tsx index 0e5b659..5b38fea 100644 --- a/src/app/routes/history.tsx +++ b/src/app/routes/history.tsx @@ -1,13 +1,21 @@ import { HTTPError } from 'ky'; -import { data, isRouteErrorResponse } from 'react-router'; +import { data, isRouteErrorResponse, redirect } from 'react-router'; import { TradingHistory, api as profileApi } from '~/features/profile'; import ErrorComponent from '~/shared/ui/Error'; +import { checkLogin } from '~/shared/utils/util.server'; import type { Route } from './+types/history'; const FETCH_SIZE = 10; export async function loader({ request }: Route.LoaderArgs) { + const rawCookie = request.headers.get('Cookie'); + const isLoggedIn = checkLogin(rawCookie); + + if (!isLoggedIn) { + return redirect('/login'); + } + const { searchParams } = new URL(request.url); const page = searchParams.get('p') ? Number(searchParams.get('p')) : 1; const settled = searchParams.get('t') === 'settled'; diff --git a/src/app/routes/profile.tsx b/src/app/routes/profile.tsx index 46274f9..c1bd021 100644 --- a/src/app/routes/profile.tsx +++ b/src/app/routes/profile.tsx @@ -1,12 +1,20 @@ import { HTTPError } from 'ky'; -import { data, isRouteErrorResponse } from 'react-router'; +import { data, isRouteErrorResponse, redirect } from 'react-router'; import { api as userApi } from '~/entities/user'; import ErrorModal from '~/shared/ui/ErrorModal'; +import { checkLogin } from '~/shared/utils/util.server'; import { ProfileModal } from '~/widgets/user'; import type { Route } from './+types/profile'; -export async function loader() { +export async function loader({ request }: Route.LoaderArgs) { + const rawCookie = request.headers.get('Cookie'); + const isLoggedIn = checkLogin(rawCookie); + + if (!isLoggedIn) { + return redirect('/login'); + } + try { const response = await userApi.getUserInfo(); const { data } = await response.json(); diff --git a/src/shared/utils/util.server.ts b/src/shared/utils/util.server.ts new file mode 100644 index 0000000..f3f71f8 --- /dev/null +++ b/src/shared/utils/util.server.ts @@ -0,0 +1,13 @@ +import * as cookie from 'cookie'; + +export function extractAccessToken(rawCookie: string | null) { + if (!rawCookie) return; + + const parsedCookie = cookie.parse(rawCookie); + return parsedCookie.access_token; +} + +export function checkLogin(rawCookie: string | null) { + const accessToken = extractAccessToken(rawCookie); + return !!accessToken; +} From ba2e3675661a606a8087b16db2a0555217d0f972 Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Sun, 27 Jul 2025 00:19:20 +0900 Subject: [PATCH 60/87] =?UTF-8?q?fix:=20=EC=84=9C=EB=B2=84=ED=95=A8?= =?UTF-8?q?=EC=88=98=20=EC=8B=A4=ED=96=89=EC=8B=9C=20=EC=BF=A0=ED=82=A4?= =?UTF-8?q?=EB=A5=BC=20=EB=8B=B4=EC=95=84=EC=84=9C=20=EC=9A=94=EC=B2=AD?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/routes/history.tsx | 6 +++++- src/app/routes/profile.tsx | 6 +++++- src/entities/user/api/user.endpoint.ts | 4 ++-- src/features/profile/api/history.endpoint.ts | 12 +++++++++--- 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/app/routes/history.tsx b/src/app/routes/history.tsx index 5b38fea..3e2a704 100644 --- a/src/app/routes/history.tsx +++ b/src/app/routes/history.tsx @@ -25,7 +25,11 @@ export async function loader({ request }: Route.LoaderArgs) { } try { - const response = await profileApi.getHistory(page, FETCH_SIZE, settled); + const response = await profileApi.getHistory(page, FETCH_SIZE, settled, { + headers: { + Cookie: rawCookie as string, + }, + }); const { data } = await response.json(); return data; } catch (error) { diff --git a/src/app/routes/profile.tsx b/src/app/routes/profile.tsx index c1bd021..fec7dc4 100644 --- a/src/app/routes/profile.tsx +++ b/src/app/routes/profile.tsx @@ -16,7 +16,11 @@ export async function loader({ request }: Route.LoaderArgs) { } try { - const response = await userApi.getUserInfo(); + const response = await userApi.getUserInfo({ + headers: { + Cookie: rawCookie as string, + }, + }); const { data } = await response.json(); return data; diff --git a/src/entities/user/api/user.endpoint.ts b/src/entities/user/api/user.endpoint.ts index 4dbc374..37ef925 100644 --- a/src/entities/user/api/user.endpoint.ts +++ b/src/entities/user/api/user.endpoint.ts @@ -3,8 +3,8 @@ import ApiClient from '~/shared/api/httpClient'; import type { UserInfoResponse } from '../types/user.type'; export default { - getUserInfo: () => { - return ApiClient.get('api/userinfo'); + getUserInfo: (init?: RequestInit) => { + return ApiClient.get('api/userinfo', init); }, }; /* v8 ignore end */ diff --git a/src/features/profile/api/history.endpoint.ts b/src/features/profile/api/history.endpoint.ts index a5654cc..6992c9f 100644 --- a/src/features/profile/api/history.endpoint.ts +++ b/src/features/profile/api/history.endpoint.ts @@ -2,7 +2,12 @@ import ApiClient from '~/shared/api/httpClient'; import type { HistoryResponse } from '../types/tradingHistory.type'; export default { - getHistory: (page?: number, size?: number, settled?: boolean) => { + getHistory: ( + page?: number, + size?: number, + settled?: boolean, + init?: RequestInit, + ) => { const params = new URLSearchParams(); if (page) params.set('page', page.toString()); @@ -13,9 +18,10 @@ export default { return ApiClient.get( `api/userinfo/trades?${params.toString()}`, + init, ); }, - deleteHistory: (orderId: string) => { - return ApiClient.delete(`api/userinfo/trades?orderId=${orderId}`); + deleteHistory: (orderId: string, init?: RequestInit) => { + return ApiClient.delete(`api/userinfo/trades?orderId=${orderId}`, init); }, }; From c5b1c6c25a1339aa1fd77f2a154073c8de2ffb7f Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Sun, 27 Jul 2025 00:32:07 +0900 Subject: [PATCH 61/87] =?UTF-8?q?refactor:=20customReferer=EB=A5=BC=20?= =?UTF-8?q?=EA=B0=80=EC=A0=B8=EC=98=A4=EB=8A=94=20=EC=9C=A0=ED=8B=B8=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EB=B0=8F=20=ED=9B=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/routes/history.tsx | 2 ++ src/app/routes/login.tsx | 4 ++-- src/shared/hooks/useCustomReferer.tsx | 7 +++++++ src/shared/ui/ErrorModal/index.tsx | 10 +++++----- src/shared/utils/index.ts | 6 ++++++ src/widgets/user/ui/ProfileModal/index.tsx | 10 +++++----- 6 files changed, 27 insertions(+), 12 deletions(-) create mode 100644 src/shared/hooks/useCustomReferer.tsx diff --git a/src/app/routes/history.tsx b/src/app/routes/history.tsx index 3e2a704..5b07029 100644 --- a/src/app/routes/history.tsx +++ b/src/app/routes/history.tsx @@ -3,12 +3,14 @@ import { data, isRouteErrorResponse, redirect } from 'react-router'; import { TradingHistory, api as profileApi } from '~/features/profile'; import ErrorComponent from '~/shared/ui/Error'; +import { getCustomReferer } from '~/shared/utils'; import { checkLogin } from '~/shared/utils/util.server'; import type { Route } from './+types/history'; const FETCH_SIZE = 10; export async function loader({ request }: Route.LoaderArgs) { + const referer = getCustomReferer(request.url); const rawCookie = request.headers.get('Cookie'); const isLoggedIn = checkLogin(rawCookie); diff --git a/src/app/routes/login.tsx b/src/app/routes/login.tsx index 7266819..130743f 100644 --- a/src/app/routes/login.tsx +++ b/src/app/routes/login.tsx @@ -1,14 +1,14 @@ import { data, isRouteErrorResponse } from 'react-router'; import ErrorComponent from '~/shared/ui/Error'; +import { getCustomReferer } from '~/shared/utils'; import { LoginModal } from '~/widgets/auth'; import { commitSession, getSession } from '../sessions.server'; import type { Route } from './+types/login'; export async function loader({ request }: Route.LoaderArgs) { - const { searchParams } = new URL(request.url); const session = await getSession(request.headers.get('Cookie')); + const referer = getCustomReferer(request.url) || '/'; - const referer = searchParams.get('referer') || '/'; session.set('referer', referer); return data( diff --git a/src/shared/hooks/useCustomReferer.tsx b/src/shared/hooks/useCustomReferer.tsx new file mode 100644 index 0000000..69e2f81 --- /dev/null +++ b/src/shared/hooks/useCustomReferer.tsx @@ -0,0 +1,7 @@ +import { useSearchParams } from 'react-router'; + +export default function useCustomReferer() { + const [searchParams] = useSearchParams(); + + return searchParams.get('referer'); +} diff --git a/src/shared/ui/ErrorModal/index.tsx b/src/shared/ui/ErrorModal/index.tsx index 8073dce..66fa04b 100644 --- a/src/shared/ui/ErrorModal/index.tsx +++ b/src/shared/ui/ErrorModal/index.tsx @@ -1,7 +1,8 @@ import { useRef } from 'react'; -import { useNavigate, useSearchParams } from 'react-router'; +import { useNavigate } from 'react-router'; import useClickOutside from '~/shared/hooks/useClickOutside'; +import useCustomReferer from '~/shared/hooks/useCustomReferer'; import Backdrop from '~/shared/ui/Backdrop'; import ErrorComponent, { type ErrorComponentProps } from '~/shared/ui/Error'; import Modal from '~/shared/ui/Modal'; @@ -9,12 +10,11 @@ import Modal from '~/shared/ui/Modal'; type ErrorModalProps = ErrorComponentProps; export default function ErrorModal({ title, description }: ErrorModalProps) { - const [searchParams] = useSearchParams(); + const referer = useCustomReferer(); const navigate = useNavigate(); const modalRef = useRef(null); - useClickOutside(modalRef, () => - navigate(searchParams.get('referer') || '/trade/BTC'), - ); + + useClickOutside(modalRef, () => navigate(referer || '/trade/BTC')); return ( diff --git a/src/shared/utils/index.ts b/src/shared/utils/index.ts index d38b5c0..f494565 100644 --- a/src/shared/utils/index.ts +++ b/src/shared/utils/index.ts @@ -36,3 +36,9 @@ export function preventNonNumericInput(event: React.KeyboardEvent): void { export function isNullish(value: unknown): value is null | undefined { return value === null || value === undefined; } + +export function getCustomReferer(url: string | URL) { + const { searchParams } = new URL(url); + + return searchParams.get('referer'); +} diff --git a/src/widgets/user/ui/ProfileModal/index.tsx b/src/widgets/user/ui/ProfileModal/index.tsx index f2128f8..741ad3f 100644 --- a/src/widgets/user/ui/ProfileModal/index.tsx +++ b/src/widgets/user/ui/ProfileModal/index.tsx @@ -1,9 +1,10 @@ import { useRef } from 'react'; -import { Outlet, useNavigate, useSearchParams } from 'react-router'; +import { Outlet, useNavigate } from 'react-router'; import type { UserInfoResponseData } from '~/entities/user'; import AssetInfoGraphic from '~/features/profile/ui/AssetInfoGraphic'; import useClickOutside from '~/shared/hooks/useClickOutside'; +import useCustomReferer from '~/shared/hooks/useCustomReferer'; import Backdrop from '~/shared/ui/Backdrop'; import Modal from '~/shared/ui/Modal'; @@ -12,12 +13,11 @@ type ProfileModalProps = { }; export default function ProfileModal({ userInfo }: ProfileModalProps) { - const [searchParams] = useSearchParams(); + const referer = useCustomReferer(); const navigate = useNavigate(); const modalRef = useRef(null); - useClickOutside(modalRef, () => - navigate(searchParams.get('referer') || '/trade/BTC'), - ); + + useClickOutside(modalRef, () => navigate(referer || '/trade/BTC')); return ( From b059f651b21fc2286dcf0ffb44f24a0e24f6cbf5 Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Sun, 27 Jul 2025 00:42:15 +0900 Subject: [PATCH 62/87] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=EC=9D=B4=20=ED=92=80=EB=A0=B8=EC=9D=84=20=EA=B2=BD=EC=9A=B0=20?= =?UTF-8?q?login=20route=EB=A1=9C=20=EB=A6=AC=EB=8B=A4=EC=9D=B4=EB=A0=89?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/routes/history.tsx | 7 +++---- src/app/routes/profile.tsx | 5 +++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/app/routes/history.tsx b/src/app/routes/history.tsx index 5b07029..74ba1d8 100644 --- a/src/app/routes/history.tsx +++ b/src/app/routes/history.tsx @@ -3,19 +3,18 @@ import { data, isRouteErrorResponse, redirect } from 'react-router'; import { TradingHistory, api as profileApi } from '~/features/profile'; import ErrorComponent from '~/shared/ui/Error'; -import { getCustomReferer } from '~/shared/utils'; import { checkLogin } from '~/shared/utils/util.server'; import type { Route } from './+types/history'; const FETCH_SIZE = 10; -export async function loader({ request }: Route.LoaderArgs) { - const referer = getCustomReferer(request.url); +export async function loader({ request, params }: Route.LoaderArgs) { const rawCookie = request.headers.get('Cookie'); const isLoggedIn = checkLogin(rawCookie); + const ticker = params.ticker; if (!isLoggedIn) { - return redirect('/login'); + return redirect(`/trade/${ticker}/login`); } const { searchParams } = new URL(request.url); diff --git a/src/app/routes/profile.tsx b/src/app/routes/profile.tsx index fec7dc4..6c7c5e3 100644 --- a/src/app/routes/profile.tsx +++ b/src/app/routes/profile.tsx @@ -7,12 +7,13 @@ import { checkLogin } from '~/shared/utils/util.server'; import { ProfileModal } from '~/widgets/user'; import type { Route } from './+types/profile'; -export async function loader({ request }: Route.LoaderArgs) { +export async function loader({ request, context, params }: Route.LoaderArgs) { const rawCookie = request.headers.get('Cookie'); const isLoggedIn = checkLogin(rawCookie); + const ticker = params.ticker; if (!isLoggedIn) { - return redirect('/login'); + return redirect(`/trade/${ticker}/login`); } try { From 64288f50b491a0482d7225091f5565d61f7c8ea0 Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Sun, 27 Jul 2025 00:53:34 +0900 Subject: [PATCH 63/87] =?UTF-8?q?fix:=20=EC=9D=B4=EB=AF=B8=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=ED=95=9C=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=EC=9D=98=20login=20route=20=EC=A0=91=EA=B7=BC=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/routes/login.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/app/routes/login.tsx b/src/app/routes/login.tsx index 130743f..f601252 100644 --- a/src/app/routes/login.tsx +++ b/src/app/routes/login.tsx @@ -1,14 +1,23 @@ -import { data, isRouteErrorResponse } from 'react-router'; +import { data, isRouteErrorResponse, redirect } from 'react-router'; + import ErrorComponent from '~/shared/ui/Error'; import { getCustomReferer } from '~/shared/utils'; +import { checkLogin } from '~/shared/utils/util.server'; import { LoginModal } from '~/widgets/auth'; import { commitSession, getSession } from '../sessions.server'; import type { Route } from './+types/login'; export async function loader({ request }: Route.LoaderArgs) { - const session = await getSession(request.headers.get('Cookie')); + const cookie = request.headers.get('Cookie'); + const isLoggedIn = checkLogin(cookie); const referer = getCustomReferer(request.url) || '/'; + if (isLoggedIn) { + return redirect(referer); + } + + const session = await getSession(cookie); + session.set('referer', referer); return data( From 549b6c88a676706b8db26820b2b8c5982743aa4e Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Sun, 27 Jul 2025 17:01:15 +0900 Subject: [PATCH 64/87] =?UTF-8?q?fix:=20=EB=8B=A4=EB=A5=B8=20=EC=BD=94?= =?UTF-8?q?=EC=9D=B8=EC=9D=84=20=EC=84=A0=ED=83=9D=ED=95=B4=EB=8F=84=20?= =?UTF-8?q?=EB=98=91=EA=B0=99=EC=9D=80=20=EA=B0=80=EA=B2=A9=EC=9D=B4=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=EB=90=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/routes/trade.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/app/routes/trade.tsx b/src/app/routes/trade.tsx index 966bac8..844308a 100644 --- a/src/app/routes/trade.tsx +++ b/src/app/routes/trade.tsx @@ -102,7 +102,11 @@ export default function TradeRouteComponent({ />
    {coinInfo && ( - + )}
    From b532a98e6631f75d6b777174f67f74689ca639f8 Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Sun, 27 Jul 2025 18:14:15 +0900 Subject: [PATCH 65/87] =?UTF-8?q?style:=20=EC=98=A4=ED=83=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/sessions.server.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/app/sessions.server.ts b/src/app/sessions.server.ts index 14c243b..a6efb1f 100644 --- a/src/app/sessions.server.ts +++ b/src/app/sessions.server.ts @@ -9,7 +9,7 @@ type SessionFlashData = { error: string; }; -const MINITE = 60; +const MINUTE = 60; const { getSession, commitSession, destroySession } = createCookieSessionStorage({ @@ -17,7 +17,8 @@ const { getSession, commitSession, destroySession } = name: '__session', httpOnly: true, - maxAge: MINITE * 60 * 24, + maxAge: MINUTE * 60 * 24, + path: '/', sameSite: 'lax', secrets: [String(import.meta.env.VITE_APP_SECRET)], From f1bc0852918a8e36947af0a0117ffe6ff648602d Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Sun, 27 Jul 2025 18:38:35 +0900 Subject: [PATCH 66/87] =?UTF-8?q?refactor:=20=ED=95=9C=EA=B5=AD=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EA=B3=84=EC=82=B0=20=EB=A1=9C=EC=A7=81=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/ui/Chart/ToolTip.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/shared/ui/Chart/ToolTip.tsx b/src/shared/ui/Chart/ToolTip.tsx index d63f9ab..1c6436f 100644 --- a/src/shared/ui/Chart/ToolTip.tsx +++ b/src/shared/ui/Chart/ToolTip.tsx @@ -6,6 +6,9 @@ import { useChartContainer } from './ChartContainer'; import { useChartRoot } from './ChartRoot'; import { useSeries } from './Series'; +const HOUR = 60 * 60 * 1000; +const TIME_OFFSET = 9; + const TOOLTIP_WIDTH = 80; const TOOLTIP_HEIGHT = 80; const TOOLTIP_MARGIN = 15; @@ -38,7 +41,7 @@ export default function ToolTip() { chartSeries, ) as CandlestickData; const date = new Date((time as number) * 1000); - const koreanDate = new Date(date.setHours(date.getHours() - 9)); + const koreanDate = new Date(date.getTime() + HOUR * TIME_OFFSET); toolTipElementRef.current.style.display = 'block'; toolTipElementRef.current.innerHTML = `
    From 18bab19351e3bf535b8286a76bf559a9a54f51ba Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Sun, 27 Jul 2025 18:39:38 +0900 Subject: [PATCH 67/87] =?UTF-8?q?style:=20=ED=81=B4=EB=9E=98=EC=8A=A4=20?= =?UTF-8?q?=EC=98=A4=ED=83=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/ui/Chart/ToolTip.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/ui/Chart/ToolTip.tsx b/src/shared/ui/Chart/ToolTip.tsx index 1c6436f..9e29cf0 100644 --- a/src/shared/ui/Chart/ToolTip.tsx +++ b/src/shared/ui/Chart/ToolTip.tsx @@ -80,7 +80,7 @@ export default function ToolTip() {
    ); } From 428bdb450437b7b114c53db6336f5627d7788030 Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Sun, 27 Jul 2025 18:44:11 +0900 Subject: [PATCH 68/87] =?UTF-8?q?refactor:=20=EA=B3=B5=ED=86=B5=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../profile/types/tradingHistory.type.ts | 86 +++++-------------- 1 file changed, 21 insertions(+), 65 deletions(-) diff --git a/src/features/profile/types/tradingHistory.type.ts b/src/features/profile/types/tradingHistory.type.ts index 057f9b5..b940185 100644 --- a/src/features/profile/types/tradingHistory.type.ts +++ b/src/features/profile/types/tradingHistory.type.ts @@ -19,71 +19,27 @@ export enum OrderStatus { IN_PROGRESS = 'IN_PROGRESS', } -export type TradingHistory = - | { - // 지정가 매도 - side: Side.ASK; - orderStatus: - | OrderStatus.UNSETTLED - | OrderStatus.SETTLED - | OrderStatus.IN_PROGRESS; - orderType: OrderType.LIMIT; - orderId: string; - ticker: string; - name: string; - price: number; - orderSize: number; - remainingSize: number; - displaySize: number; - tradeTime: string; - } - | { - // 시장가 매도 - side: Side.ASK; - orderStatus: - | OrderStatus.UNSETTLED - | OrderStatus.SETTLED - | OrderStatus.IN_PROGRESS; - orderType: OrderType.MARKET; - orderId: string; - ticker: string; - name: string; - orderSize: number; - remainingSize: number; - displaySize: number; - tradeTime: string; - } - | { - // 지정가 매수 - side: Side.BID; - orderStatus: - | OrderStatus.UNSETTLED - | OrderStatus.SETTLED - | OrderStatus.IN_PROGRESS; - orderType: OrderType.LIMIT; - orderId: string; - ticker: string; - name: string; - price: number; - orderSize: number; - remainingSize: number; - displaySize: number; - tradeTime: string; - } - | { - // 시장가 매수 - side: Side.BID; - orderStatus: - | OrderStatus.UNSETTLED - | OrderStatus.SETTLED - | OrderStatus.IN_PROGRESS; - orderType: OrderType.MARKET; - orderId: string; - ticker: string; - name: string; - price: number; - tradeTime: string; - }; +type BaseOrder = { + orderId: string; + ticker: string; + name: string; + orderSize: number; + remainingSize: number; + displaySize: number; + tradeTime: string; + orderStatus: + | OrderStatus.UNSETTLED + | OrderStatus.SETTLED + | OrderStatus.IN_PROGRESS; +}; + +export type TradingHistory = BaseOrder & + ( + | { side: Side.ASK; orderType: OrderType.LIMIT; price: number } + | { side: Side.ASK; orderType: OrderType.MARKET } + | { side: Side.BID; orderType: OrderType.LIMIT; price: number } + | { side: Side.BID; orderType: OrderType.MARKET } + ); export type HistoryResonseData = { orderList: TradingHistory[]; From 11318276aa27b262f93ee29c2dbe4d760bbb0054 Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Sun, 27 Jul 2025 18:45:30 +0900 Subject: [PATCH 69/87] =?UTF-8?q?style:=20=EC=98=A4=ED=83=80=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/profile/types/tradingHistory.type.ts | 4 ++-- src/features/profile/ui/TradingHistoryList/index.tsx | 4 ++-- src/mocks/handlers.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/features/profile/types/tradingHistory.type.ts b/src/features/profile/types/tradingHistory.type.ts index b940185..3ef3b1e 100644 --- a/src/features/profile/types/tradingHistory.type.ts +++ b/src/features/profile/types/tradingHistory.type.ts @@ -41,7 +41,7 @@ export type TradingHistory = BaseOrder & | { side: Side.BID; orderType: OrderType.MARKET } ); -export type HistoryResonseData = { +export type HistoryResponseData = { orderList: TradingHistory[]; totalPages: number; currentPage: number; @@ -49,4 +49,4 @@ export type HistoryResonseData = { totalElements: number; }; -export type HistoryResponse = Response; +export type HistoryResponse = Response; diff --git a/src/features/profile/ui/TradingHistoryList/index.tsx b/src/features/profile/ui/TradingHistoryList/index.tsx index 5881f61..64c365a 100644 --- a/src/features/profile/ui/TradingHistoryList/index.tsx +++ b/src/features/profile/ui/TradingHistoryList/index.tsx @@ -4,11 +4,11 @@ import useScrollTo from '~/shared/hooks/useScrollTo'; import NoContent from '~/shared/ui/NoContent'; import Pagination from '~/shared/ui/Pagination'; import Tab from '~/shared/ui/Tab'; -import type { HistoryResonseData } from '../../types/tradingHistory.type'; +import type { HistoryResponseData } from '../../types/tradingHistory.type'; import TradingHistoryListItem from '../TradingHistoryListItem'; type TradingHistoryListProps = { - historyData: HistoryResonseData; + historyData: HistoryResponseData; }; export default function TradingHistoryList({ diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index 6cb11c7..7d7734e 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -1,7 +1,7 @@ /* v8 ignore start */ import { http, HttpResponse } from 'msw'; import { - type HistoryResonseData, + type HistoryResponseData, OrderStatus, } from '~/features/profile/types/tradingHistory.type'; import type { Response } from '~/shared/types/api'; @@ -50,7 +50,7 @@ export const handlers = [ const firstItemIndex = (page - 1) * size; const lastItemIndex = page * size - 1; - const historyData: HistoryResonseData = { + const historyData: HistoryResponseData = { orderList: filteredOrderlist.slice(firstItemIndex, lastItemIndex + 1), totalPages: Math.ceil(filteredOrderlist.length / size), currentPage: page, From 976af51a0219b4dba301ddaa142246a360a33824 Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Sun, 27 Jul 2025 19:01:47 +0900 Subject: [PATCH 70/87] =?UTF-8?q?fix:=20=EB=A1=9C=EC=A7=81=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/routes/profile.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/routes/profile.tsx b/src/app/routes/profile.tsx index 6c7c5e3..1f1e8bb 100644 --- a/src/app/routes/profile.tsx +++ b/src/app/routes/profile.tsx @@ -19,7 +19,7 @@ export async function loader({ request, context, params }: Route.LoaderArgs) { try { const response = await userApi.getUserInfo({ headers: { - Cookie: rawCookie as string, + Cookie: rawCookie || '', }, }); const { data } = await response.json(); From 533642ad3a83e675a286969a883fdf5c24c96379 Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Sun, 27 Jul 2025 19:03:16 +0900 Subject: [PATCH 71/87] =?UTF-8?q?style:=20=ED=8F=B4=EB=8D=94=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=EC=98=A4=ED=83=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../index.tsx | 0 src/features/profile/ui/TradingHistoryListItem/index.tsx | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename src/features/profile/ui/{TradingHistoryCancleButton => TradingHistoryCancelButton}/index.tsx (100%) diff --git a/src/features/profile/ui/TradingHistoryCancleButton/index.tsx b/src/features/profile/ui/TradingHistoryCancelButton/index.tsx similarity index 100% rename from src/features/profile/ui/TradingHistoryCancleButton/index.tsx rename to src/features/profile/ui/TradingHistoryCancelButton/index.tsx diff --git a/src/features/profile/ui/TradingHistoryListItem/index.tsx b/src/features/profile/ui/TradingHistoryListItem/index.tsx index 23eca7e..aac3ab6 100644 --- a/src/features/profile/ui/TradingHistoryListItem/index.tsx +++ b/src/features/profile/ui/TradingHistoryListItem/index.tsx @@ -5,7 +5,7 @@ import { Side, type TradingHistory, } from '../../types/tradingHistory.type'; -import TradingHistoryCancelButton from '../TradingHistoryCancleButton'; +import TradingHistoryCancelButton from '../TradingHistoryCancelButton'; type TradingHistoryListItemProps = TradingHistory; From f79e41e5b5dde6ce7d644583b4d82fe8cb1c85bc Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Sun, 27 Jul 2025 19:16:50 +0900 Subject: [PATCH 72/87] =?UTF-8?q?fix:=20=ED=83=80=EC=9E=85=20=EB=88=84?= =?UTF-8?q?=EB=9D=BD=20=ED=94=84=EB=A1=9C=ED=8D=BC=ED=8B=B0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../profile/types/tradingHistory.type.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/features/profile/types/tradingHistory.type.ts b/src/features/profile/types/tradingHistory.type.ts index 3ef3b1e..ad708c3 100644 --- a/src/features/profile/types/tradingHistory.type.ts +++ b/src/features/profile/types/tradingHistory.type.ts @@ -23,7 +23,6 @@ type BaseOrder = { orderId: string; ticker: string; name: string; - orderSize: number; remainingSize: number; displaySize: number; tradeTime: string; @@ -35,10 +34,20 @@ type BaseOrder = { export type TradingHistory = BaseOrder & ( - | { side: Side.ASK; orderType: OrderType.LIMIT; price: number } - | { side: Side.ASK; orderType: OrderType.MARKET } - | { side: Side.BID; orderType: OrderType.LIMIT; price: number } - | { side: Side.BID; orderType: OrderType.MARKET } + | { + side: Side.ASK; + orderType: OrderType.LIMIT; + price: number; + orderSize: number; + } + | { side: Side.ASK; orderType: OrderType.MARKET; orderSize: number } + | { + side: Side.BID; + orderType: OrderType.LIMIT; + price: number; + orderSize: number; + } + | { side: Side.BID; orderType: OrderType.MARKET; price: number } ); export type HistoryResponseData = { From ed4712dc8951404bed4a30f459f144a5783fb352 Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Sun, 27 Jul 2025 19:42:03 +0900 Subject: [PATCH 73/87] =?UTF-8?q?refactor:=20=EC=98=88=EC=99=B8=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=A1=9C=EC=A7=81=EC=9D=84=20react=20hook=20?= =?UTF-8?q?=EC=8B=A4=ED=96=89=EC=A0=84=EC=9C=BC=EB=A1=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/ui/IncrementingNumber/index.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/shared/ui/IncrementingNumber/index.tsx b/src/shared/ui/IncrementingNumber/index.tsx index e64b518..a8f7993 100644 --- a/src/shared/ui/IncrementingNumber/index.tsx +++ b/src/shared/ui/IncrementingNumber/index.tsx @@ -14,6 +14,11 @@ export default function IncrementingNumber({ duration = 1, }: IncrementingNumberProps) { const number = Number(children); + + if (typeof children !== 'number' || Number.isNaN(number)) { + throw new Error('children must be a number'); + } + const value = useMotionValue(0); const rounded = useTransform(() => formatToCurrencyKr @@ -30,9 +35,5 @@ export default function IncrementingNumber({ return () => control.stop(); }, [number, value, duration]); - if (typeof children !== 'number' || Number.isNaN(number)) { - throw new Error('children must be a number'); - } - return {rounded}; } From ac2f4411081d0bc2fce47fe938c7cd4f37d82a43 Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Sun, 27 Jul 2025 22:07:19 +0900 Subject: [PATCH 74/87] =?UTF-8?q?test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=B6=88=ED=95=84=EC=9A=94=20=ED=8C=8C=EC=9D=BC=20=EC=A0=9C?= =?UTF-8?q?=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/entry.server.tsx | 2 +- src/app/root.tsx | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/app/entry.server.tsx b/src/app/entry.server.tsx index 35f00c9..f4c8e79 100644 --- a/src/app/entry.server.tsx +++ b/src/app/entry.server.tsx @@ -1,5 +1,5 @@ -import { PassThrough } from 'node:stream'; /* v8 ignore start */ +import { PassThrough } from 'node:stream'; import { server } from '~/mocks/server'; import { createReadableStreamFromReadable } from '@react-router/node'; diff --git a/src/app/root.tsx b/src/app/root.tsx index 2aea1bb..f1900f0 100644 --- a/src/app/root.tsx +++ b/src/app/root.tsx @@ -1,3 +1,4 @@ +/* v8 ignore start */ import { preload } from 'react-dom'; import { Links, @@ -129,3 +130,5 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { return ; } + +/* v8 ignore end */ From 7a4cb587372818e6dd365424437b867cf5558133 Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Sun, 27 Jul 2025 23:58:42 +0900 Subject: [PATCH 75/87] =?UTF-8?q?feat:=20fallback=20=EA=B0=80=EA=B2=A9=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 렌더링 후에 웹소켓 커넥션이 연결되고 실시간 가격이 올때까지 rest api로 받은 과거 가격을 보여주도록 하였습니다. issues: closes #37 --- src/app/routes/trade.tsx | 17 +++++++---- src/entities/coin/types/coin.type.ts | 3 ++ .../coin/ui/CoinPriceWithName/index.tsx | 21 +++++++++----- .../coin/ui/CoinWithIconAndName/index.tsx | 16 ++++++---- .../ui/CoinListItem/index.tsx | 29 ++++++++++++------- 5 files changed, 58 insertions(+), 28 deletions(-) diff --git a/src/app/routes/trade.tsx b/src/app/routes/trade.tsx index 844308a..889fbb7 100644 --- a/src/app/routes/trade.tsx +++ b/src/app/routes/trade.tsx @@ -103,9 +103,11 @@ export default function TradeRouteComponent({
    {coinInfo && ( )}
    @@ -119,7 +121,7 @@ export default function TradeRouteComponent({
    -
    +
    주문 하기 {isLoggedIn && coinInfo ? ( @@ -133,7 +135,12 @@ export default function TradeRouteComponent({ 실시간 호가 - {coinInfo && } + {coinInfo && ( + + )}
    diff --git a/src/entities/coin/types/coin.type.ts b/src/entities/coin/types/coin.type.ts index 35b9b44..4107f65 100644 --- a/src/entities/coin/types/coin.type.ts +++ b/src/entities/coin/types/coin.type.ts @@ -6,6 +6,9 @@ export type CoinName = string; export type CoinInfo = { ticker: CoinTicker; name: CoinName; + svgIconBase64: string; + currentPrice: number | null; + changeRate: number | null; }; export type CoinListResponseData = { assets: CoinInfo[] }; diff --git a/src/entities/coin/ui/CoinPriceWithName/index.tsx b/src/entities/coin/ui/CoinPriceWithName/index.tsx index 14afad4..6726c11 100644 --- a/src/entities/coin/ui/CoinPriceWithName/index.tsx +++ b/src/entities/coin/ui/CoinPriceWithName/index.tsx @@ -2,19 +2,26 @@ import { formatCurrencyKR } from '~/shared/utils'; import useCurrentPrice from '../../hooks/useCurrentPrice'; import type { CoinInfo } from '../../types/coin.type'; -type CoinPriceWithNameProps = CoinInfo & { - img?: string; -}; +type CoinPriceWithNameProps = Omit; export default function CoinPriceWithName({ name, ticker, - img, + currentPrice: lastPrice, + svgIconBase64, }: CoinPriceWithNameProps) { - const price = useCurrentPrice(ticker); + const realtimePriceData = useCurrentPrice(ticker); + const displayPrice = realtimePriceData + ? realtimePriceData.currentPrice + : lastPrice || 0; + return (
    - {img ? {name} : 🪙} + {svgIconBase64 ? ( + {name} + ) : ( + 🪙 + )}
    {name} @@ -22,7 +29,7 @@ export default function CoinPriceWithName({
    - {price ? formatCurrencyKR(price.currentPrice) : '-'}원 + {formatCurrencyKR(displayPrice)}원
    diff --git a/src/entities/coin/ui/CoinWithIconAndName/index.tsx b/src/entities/coin/ui/CoinWithIconAndName/index.tsx index 5e33017..cba1a8f 100644 --- a/src/entities/coin/ui/CoinWithIconAndName/index.tsx +++ b/src/entities/coin/ui/CoinWithIconAndName/index.tsx @@ -1,20 +1,24 @@ -import type { ReactElement } from 'react'; import type { CoinInfo } from '../../types/coin.type'; -export type CoinWithIconAndNameProps = { - coinIcon: ReactElement; -} & CoinInfo; +export type CoinWithIconAndNameProps = Omit< + CoinInfo, + 'changeRate' | 'currentPrice' +>; export default function CoinWithIconAndName({ name, ticker, - coinIcon, + svgIconBase64, }: CoinWithIconAndNameProps) { return (
    - {coinIcon} + {svgIconBase64 ? ( + {name} + ) : ( + '🪙' + )} {ticker}
    diff --git a/src/features/coin-search-list/ui/CoinListItem/index.tsx b/src/features/coin-search-list/ui/CoinListItem/index.tsx index d29164e..aaa1df0 100644 --- a/src/features/coin-search-list/ui/CoinListItem/index.tsx +++ b/src/features/coin-search-list/ui/CoinListItem/index.tsx @@ -1,8 +1,8 @@ import { type LinkProps, useNavigate } from 'react-router'; import { + type CoinInfo, CoinWithIconAndName, - type CoinWithIconAndNameProps, useCurrentPrice, } from '~/entities/coin'; import { formatCurrencyKR } from '~/shared/utils'; @@ -10,21 +10,26 @@ import { formatCurrencyKR } from '~/shared/utils'; export type CoinListItemProps = { to: LinkProps['to']; onClick?: () => void; -} & CoinWithIconAndNameProps; +} & CoinInfo; export default function CoinListItem({ name, ticker, - coinIcon: CoinIcon, + svgIconBase64, + currentPrice: lastPrice, + changeRate, to, onClick, }: Readonly) { const navigate = useNavigate(); const currentPriceData = useCurrentPrice(ticker); - const isBull = currentPriceData && currentPriceData.changeRate > 0; - const formatedPrice = `${formatCurrencyKR( - +(currentPriceData?.currentPrice ?? 0).toFixed(2), - )}원`; + const displayPrice = currentPriceData + ? currentPriceData.currentPrice + : lastPrice || 0; + const displayChangeRate = currentPriceData + ? currentPriceData.changeRate + : changeRate || 0; + const isBull = displayChangeRate > 0; const handleClickCoinItem = async () => { onClick?.(); @@ -38,16 +43,20 @@ export default function CoinListItem({ onClick={handleClickCoinItem} >
    - +
    - {formatedPrice} + {formatCurrencyKR(Number(displayPrice.toFixed(2)))}원
    - {(currentPriceData?.changeRate ?? 0).toFixed(2)}% + {displayChangeRate.toFixed(2)}%
    From 3a3b50e2c3bafb96ba173b083ccb8a1906bb0bd9 Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Mon, 28 Jul 2025 00:11:22 +0900 Subject: [PATCH 76/87] =?UTF-8?q?feat:=20base64=20data=20to=20svg=20?= =?UTF-8?q?=EC=9C=A0=ED=8B=B8=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entities/coin/ui/CoinPriceWithName/index.tsx | 7 ++++++- src/entities/coin/ui/CoinWithIconAndName/index.tsx | 7 ++++++- src/shared/utils/index.ts | 4 ++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/entities/coin/ui/CoinPriceWithName/index.tsx b/src/entities/coin/ui/CoinPriceWithName/index.tsx index 6726c11..cbb9539 100644 --- a/src/entities/coin/ui/CoinPriceWithName/index.tsx +++ b/src/entities/coin/ui/CoinPriceWithName/index.tsx @@ -1,4 +1,5 @@ import { formatCurrencyKR } from '~/shared/utils'; +import { convertBase64ToSvg } from '~/shared/utils'; import useCurrentPrice from '../../hooks/useCurrentPrice'; import type { CoinInfo } from '../../types/coin.type'; @@ -18,7 +19,11 @@ export default function CoinPriceWithName({ return (
    {svgIconBase64 ? ( - {name} + {name} ) : ( 🪙 )} diff --git a/src/entities/coin/ui/CoinWithIconAndName/index.tsx b/src/entities/coin/ui/CoinWithIconAndName/index.tsx index cba1a8f..0b853cb 100644 --- a/src/entities/coin/ui/CoinWithIconAndName/index.tsx +++ b/src/entities/coin/ui/CoinWithIconAndName/index.tsx @@ -1,3 +1,4 @@ +import { convertBase64ToSvg } from '~/shared/utils'; import type { CoinInfo } from '../../types/coin.type'; export type CoinWithIconAndNameProps = Omit< @@ -15,7 +16,11 @@ export default function CoinWithIconAndName({
    {svgIconBase64 ? ( - {name} + {name} ) : ( '🪙' )} diff --git a/src/shared/utils/index.ts b/src/shared/utils/index.ts index f494565..189514f 100644 --- a/src/shared/utils/index.ts +++ b/src/shared/utils/index.ts @@ -42,3 +42,7 @@ export function getCustomReferer(url: string | URL) { return searchParams.get('referer'); } + +export function convertBase64ToSvg(base64: string) { + return `data:image/svg+xml;base64,${base64}`; +} From 7f235201211ab965e3869aae9147440483861258 Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Wed, 30 Jul 2025 15:24:45 +0900 Subject: [PATCH 77/87] =?UTF-8?q?config:=20test=ED=8C=8C=EC=9D=BC=EC=97=90?= =?UTF-8?q?=20=EB=8C=80=ED=95=9C=20rule=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- biome.json | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/biome.json b/biome.json index 2c24fc4..6a81ba3 100644 --- a/biome.json +++ b/biome.json @@ -71,6 +71,25 @@ } } } + }, + { + "include": [ + "**/*.test.ts", + "**/*.test.tsx", + "**/*.test.js", + "**/*.test.jsx", + "**/*.spec.ts", + "**/*.spec.tsx", + "**/*.spec.js", + "**/*.spec.jsx" + ], + "linter": { + "rules": { + "suspicious": { + "noExplicitAny": "off" + } + } + } } ], "javascript": { From c60391044954d0b84e0eaf38e5cc82a3e9712b3f Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Wed, 30 Jul 2025 15:37:46 +0900 Subject: [PATCH 78/87] =?UTF-8?q?test:=20useCurrentPrice=20=ED=9B=85=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=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 --- src/entities/coin/hooks/hooks.test.tsx | 131 +++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 src/entities/coin/hooks/hooks.test.tsx diff --git a/src/entities/coin/hooks/hooks.test.tsx b/src/entities/coin/hooks/hooks.test.tsx new file mode 100644 index 0000000..aa72041 --- /dev/null +++ b/src/entities/coin/hooks/hooks.test.tsx @@ -0,0 +1,131 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { beforeEach, describe, expect } from 'vitest'; +import { it, vi } from 'vitest'; +import { StompContext } from '~/app/provider/StompProvider'; +import useCurrentPrice, { type CurrentPriceData } from './useCurrentPrice'; + +const TICKER_FIRST = 'BTC'; +const TICKER_SECOND = 'ETH'; + +function generateDestinationEndPoint(ticker: string) { + return `/app/subscribe/prevRate/${ticker}`; +} + +function generateTopicEndPoint(ticker: string) { + return `/topic/prevRate/${ticker}`; +} + +const mockClient = { + publish: vi.fn(), + subscribe: vi.fn(() => ({ + unsubscribe: vi.fn(), + id: 'testId', + })), +} as any; + +const mockStompContextValue = { + client: mockClient, + connected: true, +}; + +const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + +); + +describe('useCurrentPrice 훅 테스트', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('클라이언트가 연결되지 않았을 때는 아무것도 하지 않는다', () => { + const disconnectedWrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + renderHook(() => useCurrentPrice(TICKER_FIRST), { + wrapper: disconnectedWrapper, + }); + + expect(mockClient.publish).not.toHaveBeenCalled(); + expect(mockClient.subscribe).not.toHaveBeenCalled(); + }); + + it('클라이언트가 연결되면 올바른 destination으로 publish한다', () => { + renderHook(() => useCurrentPrice(TICKER_FIRST), { wrapper }); + + expect(mockClient.publish).toHaveBeenCalledWith({ + destination: generateDestinationEndPoint(TICKER_FIRST), + body: JSON.stringify({ ticker: TICKER_FIRST }), + }); + }); + + it('올바른 topic으로 subscribe한다', () => { + renderHook(() => useCurrentPrice(TICKER_FIRST), { wrapper }); + + expect(mockClient.subscribe).toHaveBeenCalledWith( + generateTopicEndPoint(TICKER_FIRST), + expect.any(Function), + ); + }); + + it('메시지를 받으면 데이터를 파싱하여 상태를 업데이트한다', async () => { + const mockData: CurrentPriceData = { + changeRate: 0.05, + currentPrice: 50000, + prevClose: 47500, + ticker: 'BTC', + timestamp: '2024-01-01T00:00:00Z', + }; + + const mockMessage = { + body: JSON.stringify(mockData), + }; + + mockClient.subscribe.mockImplementation( + (destination: string, callback: (message: any) => void) => { + setTimeout(() => callback(mockMessage), 0); + return { unsubscribe: vi.fn() }; + }, + ); + + const { result } = renderHook(() => useCurrentPrice(TICKER_FIRST), { + wrapper, + }); + + await waitFor(() => { + expect(result.current).toEqual(mockData); + }); + }); + + it('ticker가 변경되면 새로운 구독을 생성한다', () => { + const { rerender } = renderHook(({ ticker }) => useCurrentPrice(ticker), { + wrapper, + initialProps: { ticker: TICKER_FIRST }, + }); + + rerender({ ticker: TICKER_SECOND }); + + expect(mockClient.publish).toHaveBeenCalledWith({ + destination: generateDestinationEndPoint(TICKER_SECOND), + body: JSON.stringify({ ticker: TICKER_SECOND }), + }); + }); + + it('컴포넌트가 언마운트되면 구독을 해제한다', () => { + const mockUnsubscribe = vi.fn(); + mockClient.subscribe.mockReturnValue({ unsubscribe: mockUnsubscribe }); + + const { unmount } = renderHook(() => useCurrentPrice(TICKER_FIRST), { + wrapper, + }); + + unmount(); + + expect(mockUnsubscribe).toHaveBeenCalled(); + }); +}); From 6a39d3baf56c7063c98ce45021370c84c0ef58a3 Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Wed, 30 Jul 2025 16:21:38 +0900 Subject: [PATCH 79/87] =?UTF-8?q?test:=20CoinPriceWithName=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CoinPriceWithName.test.tsx | 114 +++++++++++++----- 1 file changed, 85 insertions(+), 29 deletions(-) diff --git a/src/entities/coin/ui/CoinPriceWithName/CoinPriceWithName.test.tsx b/src/entities/coin/ui/CoinPriceWithName/CoinPriceWithName.test.tsx index ed88c23..df389e1 100644 --- a/src/entities/coin/ui/CoinPriceWithName/CoinPriceWithName.test.tsx +++ b/src/entities/coin/ui/CoinPriceWithName/CoinPriceWithName.test.tsx @@ -1,9 +1,18 @@ import { render, screen } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; -import CloudImage from '~/assets/images/cloud.webp'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import '../../hooks/useCurrentPrice'; +import { convertBase64ToSvg, formatCurrencyKR } from '~/shared/utils'; import CoinPriceWithName from '.'; +import useCurrentPrice from '../../hooks/useCurrentPrice'; + +const props = { + name: '비트코인', + ticker: 'BTC', + currentPrice: 9_000_000, +}; + +const FALLBACK_ICON = '🪙'; const mockPriceData = { changeRate: 4, @@ -14,50 +23,97 @@ const mockPriceData = { }; vi.mock('../../hooks/useCurrentPrice', () => ({ - default: vi.fn().mockImplementation((ticker) => { - return mockPriceData; - }), + default: vi.fn(), +})); + +vi.mock('~/shared/utils', () => ({ + formatCurrencyKR: vi.fn((value) => value.toLocaleString()), + convertBase64ToSvg: vi.fn((base64) => `data:image/svg+xml;base64,${base64}`), })); +const mockUseCurrentPrice = vi.mocked(useCurrentPrice); + describe('CoinPriceWithName 컴포넌트 테스트', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); it('name과 ticker과 img가 prop으로 전달되면 화면에 보인다', () => { - const props = { - name: '비트코인', - ticker: 'BTC', - img: CloudImage, - }; + mockUseCurrentPrice.mockReturnValue(null); + render(); - const name = screen.getByText('비트코인'); - const ticker = screen.getByText('BTC'); - const img = screen.getByRole('img'); + const name = screen.getByText(props.name); + const ticker = screen.getByText(props.ticker); expect(name).toBeInTheDocument(); expect(ticker).toBeInTheDocument(); - expect(img).toBeInTheDocument(); }); - it('img가 prop으로 전달되지 않으면 대체 아이콘이 보인다', () => { - const props = { - name: '비트코인', - ticker: 'BTC', - img: undefined, - }; + it('웹소켓이 연결되지 않으면 props로 전달된 가격이 보인다.', () => { + mockUseCurrentPrice.mockReturnValue(null); + render(); - const img = screen.getByText('🪙'); - expect(img).toBeInTheDocument(); + const price = screen.getByText(`${formatCurrencyKR(props.currentPrice)}원`); + expect(price).toBeInTheDocument(); }); - it('ticker가 주어지면 해당하는 코인의 가격이 한국의 원화 형식에 맞게 화면에 보인다.', () => { - const props = { - name: '비트코인', - ticker: 'BTC', - img: CloudImage, + it('웹소켓이 연결되지 않으면 props로 전달된 가격이 보이다가 웹소켓이 연결되면 실시간 가격이 보인다.', () => { + mockUseCurrentPrice.mockReturnValue(null); + + const { rerender } = render(); + + const price = screen.getByText(`${formatCurrencyKR(props.currentPrice)}원`); + expect(price).toBeInTheDocument(); + + mockUseCurrentPrice.mockReturnValue(mockPriceData); + + rerender(); + const realTimePrice = screen.getByText( + `${formatCurrencyKR(mockPriceData.currentPrice)}원`, + ); + expect(realTimePrice).toBeInTheDocument(); + }); + + it('props로 가격이 전달되지 않고 웹소켓이 연결되지 않으면 가격이 0으로 보인다', () => { + mockUseCurrentPrice.mockReturnValue(null); + const propsWithoutPrice = { + ...props, + currentPrice: null, }; - render(); - const price = screen.getByText('100,000,000원'); + render(); + + const price = screen.getByText('0원'); expect(price).toBeInTheDocument(); }); + + it('svgIconBase64가 제공되지 않으면 대체 아이콘이 보인다', () => { + mockUseCurrentPrice.mockReturnValue(null); + + render(); + + const fallbackIcon = screen.getByText(FALLBACK_ICON); + expect(fallbackIcon).toBeInTheDocument(); + }); + + it('svgIconBase64가 제공되면 이미지가 렌더링된다', () => { + mockUseCurrentPrice.mockReturnValue(null); + const mockBase64 = 'testBase64String'; + const propsWithIcon = { + ...props, + svgIconBase64: mockBase64, + }; + + render(); + + const img = screen.getByRole('img'); + expect(img).toBeInTheDocument(); + expect(img).toHaveAttribute('alt', props.name); + expect(img).toHaveAttribute( + 'src', + `data:image/svg+xml;base64,${mockBase64}`, + ); + expect(convertBase64ToSvg).toHaveBeenCalledWith(mockBase64); + }); }); From a3bc8a7ffee8305f0c51c4bfe2faceead54d0984 Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Wed, 30 Jul 2025 16:29:13 +0900 Subject: [PATCH 80/87] =?UTF-8?q?test:=20CoinWithIconAndName=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CoinWithIconAndName.test.tsx | 50 +++++++++++++++---- 1 file changed, 41 insertions(+), 9 deletions(-) diff --git a/src/entities/coin/ui/CoinWithIconAndName/CoinWithIconAndName.test.tsx b/src/entities/coin/ui/CoinWithIconAndName/CoinWithIconAndName.test.tsx index 9cf771c..913572b 100644 --- a/src/entities/coin/ui/CoinWithIconAndName/CoinWithIconAndName.test.tsx +++ b/src/entities/coin/ui/CoinWithIconAndName/CoinWithIconAndName.test.tsx @@ -1,26 +1,58 @@ import { render, screen } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import { convertBase64ToSvg } from '~/shared/utils'; import CoinWithIconAndName from '.'; +const props = { + name: '비트코인', + ticker: 'BTC', + svgIconBase64: 'testBase64String', +}; +const FALLBACK_ICON = '🪙'; + +vi.mock('~/shared/utils', () => ({ + formatCurrencyKR: vi.fn((value) => value.toLocaleString()), + convertBase64ToSvg: vi.fn((base64) => `data:image/svg+xml;base64,${base64}`), +})); + describe('CoinWithIconAndName 컴포넌트 테스트', () => { it('props로 전달된 name, ticker, coinIcon이 렌더링 된다 .', () => { - const props = { - name: '비트코인', - ticker: 'BTC', - coinIcon: 🪙, - }; render(); const component = screen.getByTestId('coin-with-icon-and-name'); expect(component).toBeInTheDocument(); - const coinIcon = screen.getByText('🪙'); + const coinIcon = screen.getByRole('img'); expect(coinIcon).toBeInTheDocument(); - const ticker = screen.getByText('BTC'); + const ticker = screen.getByText(props.ticker); expect(ticker).toBeInTheDocument(); - const name = screen.getByText('비트코인'); + const name = screen.getByText(props.name); expect(name).toBeInTheDocument(); }); + + it('svgIconBase64가 제공되지 않으면 대체 아이콘이 보인다', () => { + const propsWithoutIcon = { + ...props, + svgIconBase64: undefined, + }; + + render(); + + const fallbackIcon = screen.getByText(FALLBACK_ICON); + expect(fallbackIcon).toBeInTheDocument(); + }); + + it('svgIconBase64가 제공되면 이미지가 렌더링된다', () => { + render(); + + const image = convertBase64ToSvg(props.svgIconBase64); + const imgElement = screen.getByRole('img'); + + expect(imgElement).toBeInTheDocument(); + expect(imgElement).toHaveAttribute('alt', props.name); + expect(imgElement).toHaveAttribute('src', image); + expect(convertBase64ToSvg).toHaveBeenCalledWith(props.svgIconBase64); + }); }); From 21f16a3f016bafe2f8adf93b01a954d395935db2 Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Wed, 30 Jul 2025 17:19:15 +0900 Subject: [PATCH 81/87] =?UTF-8?q?test:=20history=20api=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=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 --- src/entities/coin/types/coin.type.ts | 2 +- src/features/profile/api/history.test.ts | 51 ++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 src/features/profile/api/history.test.ts diff --git a/src/entities/coin/types/coin.type.ts b/src/entities/coin/types/coin.type.ts index 4107f65..77e2fcc 100644 --- a/src/entities/coin/types/coin.type.ts +++ b/src/entities/coin/types/coin.type.ts @@ -6,7 +6,7 @@ export type CoinName = string; export type CoinInfo = { ticker: CoinTicker; name: CoinName; - svgIconBase64: string; + svgIconBase64?: string; currentPrice: number | null; changeRate: number | null; }; diff --git a/src/features/profile/api/history.test.ts b/src/features/profile/api/history.test.ts new file mode 100644 index 0000000..1a2a656 --- /dev/null +++ b/src/features/profile/api/history.test.ts @@ -0,0 +1,51 @@ +import { describe } from 'node:test'; +import { expect, it, vi } from 'vitest'; +import ApiClient from '~/shared/api/httpClient'; +import historyApi from './history.endpoint'; + +vi.mock('~/shared/api/httpClient', () => ({ + default: { + get: vi.fn(), + delete: vi.fn(), + }, +})); + +const getHistoryParams = { + page: 1, + size: 10, + settled: true, +}; + +const deleteHistoryParams = { + orderId: 'testOrderId', +}; + +describe('history api 테스트', () => { + it('getHistory가 호출되면 api 클라이언트가 호출된다', () => { + historyApi.getHistory( + getHistoryParams.page, + getHistoryParams.size, + getHistoryParams.settled, + ); + + const urlParams = new URLSearchParams(); + urlParams.set('page', getHistoryParams.page.toString()); + urlParams.set('size', getHistoryParams.size.toString()); + + if (getHistoryParams.settled) urlParams.set('settled', 'true'); + else urlParams.set('settled', 'false'); + + expect(ApiClient.get).toHaveBeenCalledWith( + `api/userinfo/trades?${urlParams.toString()}`, + undefined, + ); + }); + it('deleteHistory가 호출되면 api 클라이언트가 호출된다.', () => { + historyApi.deleteHistory(deleteHistoryParams.orderId); + + expect(ApiClient.delete).toHaveBeenCalledWith( + `api/userinfo/trades?orderId=${deleteHistoryParams.orderId}`, + undefined, + ); + }); +}); From 6c12bf61f8dbc74cf93d0f36f8cc7a3588134bb7 Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Thu, 31 Jul 2025 02:02:48 +0900 Subject: [PATCH 82/87] =?UTF-8?q?test:=20StompProvider=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=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 --- src/app/provider/StompProvider.test.tsx | 101 +++++++++++++++++++----- 1 file changed, 82 insertions(+), 19 deletions(-) diff --git a/src/app/provider/StompProvider.test.tsx b/src/app/provider/StompProvider.test.tsx index f7619a5..eda60d2 100644 --- a/src/app/provider/StompProvider.test.tsx +++ b/src/app/provider/StompProvider.test.tsx @@ -1,36 +1,99 @@ -import { renderHook } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; - +import { Client } from '@stomp/stompjs'; +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import StompProvider, { useStompClient } from './StompProvider'; -vi.mock('@stomp/stompjs', () => { - return { - Client: vi.fn().mockImplementation(() => { - return { - activate: vi.fn(), - deactivate: vi.fn(), - onConnect: null, - onDisconnect: null, - onWebSocketError: null, - onStompError: null, - }; - }), - }; -}); +const brokerURL = 'ws://localhost:8080'; + +vi.mock('@stomp/stompjs', () => ({ + Client: vi.fn(function (this: any, config: any) { + this.brokerURL = config.brokerURL; + this.activate = vi.fn(); + this.deactivate = vi.fn(); + this.onConnect = null; + this.onDisconnect = null; + this.onWebSocketError = null; + this.onStompError = null; + }), +})); describe('useStompClient 테스트', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('useStompClient hook은 StompProvider 외부에서 사용하면 에러를 던진다.', () => { expect(() => renderHook(() => useStompClient())).toThrowError(); }); - it('useStompClient hook은 StompProvider 내부에서 사용하면 정상 작동한다.', () => { + it('초기 상태에서는 connected가 false이다.', () => { const { result } = renderHook(() => useStompClient(), { wrapper: ({ children }) => ( - {children} + {children} ), }); expect(result.current).toHaveProperty('client'); + expect(result.current.client).toBeTruthy(); expect(result.current).toHaveProperty('connected'); + expect(result.current.connected).toBe(false); + }); + + it('onConnect 콜백이 호출되면 connected가 true가 된다.', () => { + const { result } = renderHook(() => useStompClient(), { + wrapper: ({ children }) => ( + {children} + ), + }); + + expect(result.current.connected).toBe(false); + + act(() => { + const clientInstance = vi.mocked(Client).mock.instances[0] as any; + clientInstance?.onConnect?.(); + }); + + expect(result.current.connected).toBe(true); + }); + + it('onDisconnect 콜백이 호출되면 connected가 false가 된다.', () => { + const { result } = renderHook(() => useStompClient(), { + wrapper: ({ children }) => ( + {children} + ), + }); + + const clientInstance = vi.mocked(Client).mock.instances[0] as any; + act(() => { + clientInstance?.onConnect?.(); + }); + expect(result.current.connected).toBe(true); + + act(() => { + clientInstance?.onDisconnect?.(); + }); + + expect(result.current.connected).toBe(false); + }); + + it('onWebSocketError 콜백이 호출되면 connected가 false가 된다.', () => { + const { result } = renderHook(() => useStompClient(), { + wrapper: ({ children }) => ( + {children} + ), + }); + + const clientInstance = vi.mocked(Client).mock.instances[0] as any; + act(() => { + clientInstance?.onConnect?.(); + }); + expect(result.current.connected).toBe(true); + + act(() => { + const mockError = new Error('WebSocket connection failed'); + clientInstance?.onWebSocketError?.(mockError); + }); + + expect(result.current.connected).toBe(false); }); }); From 4ad25680ff1a0786ed4bff79a6d44cebfba69c4b Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Thu, 31 Jul 2025 04:17:29 +0900 Subject: [PATCH 83/87] =?UTF-8?q?test:=20StompProvider=20test=20hoisted=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/provider/StompProvider.test.tsx | 37 +++++++++++++++---------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/src/app/provider/StompProvider.test.tsx b/src/app/provider/StompProvider.test.tsx index eda60d2..74460b5 100644 --- a/src/app/provider/StompProvider.test.tsx +++ b/src/app/provider/StompProvider.test.tsx @@ -1,21 +1,28 @@ -import { Client } from '@stomp/stompjs'; import { act, renderHook } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import StompProvider, { useStompClient } from './StompProvider'; const brokerURL = 'ws://localhost:8080'; -vi.mock('@stomp/stompjs', () => ({ - Client: vi.fn(function (this: any, config: any) { - this.brokerURL = config.brokerURL; - this.activate = vi.fn(); - this.deactivate = vi.fn(); - this.onConnect = null; - this.onDisconnect = null; - this.onWebSocketError = null; - this.onStompError = null; - }), -})); +const { MockClient } = vi.hoisted(() => { + return { + MockClient: vi.fn(function (this: any, config: any) { + this.brokerURL = config.brokerURL; + this.activate = vi.fn(); + this.deactivate = vi.fn(); + this.onConnect = null; + this.onDisconnect = null; + this.onWebSocketError = null; + this.onStompError = null; + }), + }; +}); + +vi.mock('@stomp/stompjs', () => { + return { + Client: MockClient, + }; +}); describe('useStompClient 테스트', () => { beforeEach(() => { @@ -49,7 +56,7 @@ describe('useStompClient 테스트', () => { expect(result.current.connected).toBe(false); act(() => { - const clientInstance = vi.mocked(Client).mock.instances[0] as any; + const clientInstance = MockClient.mock.instances[0] as any; clientInstance?.onConnect?.(); }); @@ -63,7 +70,7 @@ describe('useStompClient 테스트', () => { ), }); - const clientInstance = vi.mocked(Client).mock.instances[0] as any; + const clientInstance = MockClient.mock.instances[0] as any; act(() => { clientInstance?.onConnect?.(); }); @@ -83,7 +90,7 @@ describe('useStompClient 테스트', () => { ), }); - const clientInstance = vi.mocked(Client).mock.instances[0] as any; + const clientInstance = MockClient.mock.instances[0] as any; act(() => { clientInstance?.onConnect?.(); }); From ab65c31f2511a8f1cf4e4f068df86e20b2506d84 Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Thu, 31 Jul 2025 06:14:09 +0900 Subject: [PATCH 84/87] =?UTF-8?q?test:=20UserInfoProvider=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=ED=85=8C=EC=8A=A4=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 --- src/app/provider/UserInfoProvider.test.tsx | 61 ++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 src/app/provider/UserInfoProvider.test.tsx diff --git a/src/app/provider/UserInfoProvider.test.tsx b/src/app/provider/UserInfoProvider.test.tsx new file mode 100644 index 0000000..28dc058 --- /dev/null +++ b/src/app/provider/UserInfoProvider.test.tsx @@ -0,0 +1,61 @@ +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import UserIdProvider, { useUserId } from './UserInfoProvider'; + +const mockStore = new Map(); + +vi.mock('window', () => { + return { + localStorage: { + getItem: vi.fn((key) => mockStore.get(key)), + setItem: vi.fn((key, value) => mockStore.set(key, value)), + removeItem: vi.fn((key) => mockStore.delete(key)), + clear: vi.fn(() => mockStore.clear()), + }, + }; +}); + +const MOCK_USERID = 4; + +describe('UserInfoProvider 테스트', () => { + beforeEach(() => { + vi.clearAllMocks(); + window.localStorage.clear(); + }); + + it('useUserId hook은 UserInfoProvider 외부에서 사용하면 에러를 던진다.', () => { + expect(() => renderHook(() => useUserId())).toThrowError(); + }); + + it('초기 상태에서는 userId가 null이다.', () => { + const { result } = renderHook(useUserId, { + wrapper: ({ children }) => {children}, + }); + + expect(result.current.userId).toBe(null); + }); + + it('초기 마운트 시 로컬스토리지에서 userId를 불러온다.', () => { + window.localStorage.setItem('userId', MOCK_USERID.toString()); + + const { result } = renderHook(useUserId, { + wrapper: ({ children }) => {children}, + }); + + expect(result.current.userId).toBe(MOCK_USERID); + }); + + it('setUserId를 호출하면 userId 상태가 업데이트된다.', () => { + const { result } = renderHook(useUserId, { + wrapper: ({ children }) => {children}, + }); + + expect(result.current.userId).toBe(null); + + act(() => { + result.current.setUserId(MOCK_USERID); + }); + + expect(result.current.userId).toBe(MOCK_USERID); + }); +}); From 624cf0cc7e37286f0f94344996c25c120c9f8ac1 Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Fri, 1 Aug 2025 03:11:12 +0900 Subject: [PATCH 85/87] =?UTF-8?q?test:=20chatbot=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/provider/UserInfoProvider.test.tsx | 4 ++-- src/features/chat/ui/AIChatBot/AIChatBot.test.tsx | 15 +++++++-------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/app/provider/UserInfoProvider.test.tsx b/src/app/provider/UserInfoProvider.test.tsx index 28dc058..3ffc34c 100644 --- a/src/app/provider/UserInfoProvider.test.tsx +++ b/src/app/provider/UserInfoProvider.test.tsx @@ -2,9 +2,9 @@ import { act, renderHook } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import UserIdProvider, { useUserId } from './UserInfoProvider'; -const mockStore = new Map(); - vi.mock('window', () => { + const mockStore = new Map(); + return { localStorage: { getItem: vi.fn((key) => mockStore.get(key)), diff --git a/src/features/chat/ui/AIChatBot/AIChatBot.test.tsx b/src/features/chat/ui/AIChatBot/AIChatBot.test.tsx index 7426543..32af5a7 100644 --- a/src/features/chat/ui/AIChatBot/AIChatBot.test.tsx +++ b/src/features/chat/ui/AIChatBot/AIChatBot.test.tsx @@ -1,14 +1,10 @@ -import { - render, - screen, - waitForElementToBeRemoved, -} from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import userEvent from '@testing-library/user-event'; import AIChatBot from '.'; -vi.mock('~/shared/hooks/useScrollToBottom', () => ({ +vi.mock('~/shared/hooks/useScrollIntoView', () => ({ default: () => ({ current: { scrollIntoView: vi.fn() } }), })); @@ -53,8 +49,11 @@ describe('AIChatBot 컴포넌트 테스트', () => { await user.click(closeButton); - await waitForElementToBeRemoved(() => screen.queryByTestId('chat-window')); + await waitFor(() => { + expect(screen.queryByTestId('chat-window')).not.toBeInTheDocument(); + }); - expect(chatWindow).not.toBeInTheDocument(); + const newChatButton = screen.getByTestId('chat-button'); + expect(newChatButton).toBeInTheDocument(); }); }); From fd659d9f783bcbc3907be7b9b3bc9a0d73f94fdf Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Fri, 1 Aug 2025 04:01:18 +0900 Subject: [PATCH 86/87] =?UTF-8?q?test:=20chatbot=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/ui/AIChatBot/AIChatBot.test.tsx | 114 +++++++++++++++++- 1 file changed, 110 insertions(+), 4 deletions(-) diff --git a/src/features/chat/ui/AIChatBot/AIChatBot.test.tsx b/src/features/chat/ui/AIChatBot/AIChatBot.test.tsx index 32af5a7..9ead39f 100644 --- a/src/features/chat/ui/AIChatBot/AIChatBot.test.tsx +++ b/src/features/chat/ui/AIChatBot/AIChatBot.test.tsx @@ -1,13 +1,82 @@ import { render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; import { describe, expect, it, vi } from 'vitest'; import userEvent from '@testing-library/user-event'; import AIChatBot from '.'; +import type { MessageObj } from '../../types/chat.type'; + +const USER_TEXT = 'test'; +const AI_ANSWER = 'ai answer'; vi.mock('~/shared/hooks/useScrollIntoView', () => ({ default: () => ({ current: { scrollIntoView: vi.fn() } }), })); +vi.mock('@xstate/react', () => { + return { + useMachine: vi.fn().mockImplementation(() => { + const [state, setState] = React.useState({ + context: { + state: 'idle' as 'idle' | 'processing', + question: '', + messageList: [] as MessageObj[], + }, + }); + + const send = vi.fn().mockImplementation((event) => { + if (event.type === 'TYPING_QUESTION') { + setState((prev) => ({ + ...prev, + context: { + ...prev.context, + question: event.question, + }, + })); + } else if (event.type === 'SUBMIT_EVENT') { + setState((prev) => { + const newMessageList = [ + ...prev.context.messageList, + { + isMine: true, + message: prev.context.question, + }, + ]; + return { + ...prev, + context: { + ...prev.context, + state: 'processing', + question: '', + messageList: newMessageList, + }, + }; + }); + + setTimeout(() => { + setState((prev) => ({ + ...prev, + context: { + ...prev.context, + state: 'idle', + messageList: [ + ...prev.context.messageList, + { + isMine: false, + message: AI_ANSWER, + }, + ], + }, + })); + }, 1000); + } + }); + + return [state, send]; + }), + }; +}); + describe('AIChatBot 컴포넌트 테스트', () => { it('초기 상태에서 ChatButton이 보여진다.', () => { render(); @@ -22,9 +91,7 @@ describe('AIChatBot 컴포넌트 테스트', () => { render(); const chatButton = screen.getByTestId('chat-button'); - expect(chatButton).toBeInTheDocument(); - await user.click(chatButton); const chatWindow = await screen.findByTestId('chat-window'); @@ -37,9 +104,7 @@ describe('AIChatBot 컴포넌트 테스트', () => { render(); const chatButton = screen.getByTestId('chat-button'); - expect(chatButton).toBeInTheDocument(); - await user.click(chatButton); const chatWindow = await screen.findByTestId('chat-window'); @@ -56,4 +121,45 @@ describe('AIChatBot 컴포넌트 테스트', () => { const newChatButton = screen.getByTestId('chat-button'); expect(newChatButton).toBeInTheDocument(); }); + + it('사용자가 텍스트를 입력하면 input필드에 입력된 텍스트가 보이고 submit 버튼을 누르면 ChatWindow에 메시지가 추가된다.', async () => { + const user = userEvent.setup(); + render(); + + const chatButton = screen.getByTestId('chat-button'); + expect(chatButton).toBeInTheDocument(); + await user.click(chatButton); + + const input = await screen.findByRole('textbox'); + expect(input).toBeInTheDocument(); + + await user.type(input, USER_TEXT); + expect(input).toHaveValue(USER_TEXT); + + await user.click(screen.getByRole('button', { name: '↑' })); + }); + + it('사용자가 텍스트를 입력하면 input필드에 입력된 텍스트가 보이고 submit 버튼을 누르면 ChatWindow에 메시지가 추가되고 AI가 답변을하면 ChatWindow에 메시지가 추가된다.', async () => { + const user = userEvent.setup(); + render(); + + const chatButton = screen.getByTestId('chat-button'); + expect(chatButton).toBeInTheDocument(); + await user.click(chatButton); + + const input = await screen.findByRole('textbox'); + expect(input).toBeInTheDocument(); + + await user.type(input, USER_TEXT); + expect(input).toHaveValue(USER_TEXT); + + await user.click(screen.getByRole('button', { name: '↑' })); + + await waitFor( + () => { + expect(screen.getByText(AI_ANSWER)).toBeInTheDocument(); + }, + { timeout: 2000 }, + ); + }); }); From 1f64edaf061989d441fe9b6824eeecbe5ed67b36 Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Sat, 2 Aug 2025 10:36:58 +0900 Subject: [PATCH 87/87] =?UTF-8?q?docs:=20readme=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 339 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 295 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index e48cfdf..fee41d9 100644 --- a/README.md +++ b/README.md @@ -1,69 +1,320 @@ -# Invest Future -> 만약에 이때(IF) 투자했다면 지금 얼마를 벌었을까?
    -> -> 항상 저희는 상상을 하곤 합니다. ~~(이 때 돈 넣었으면 지금 4배는 벌었는데)~~
    -> 하지만 현실은 돈이 부족하기 떄문에 투자를 하기 쉽지 않습니다.
    ->
    -> IF에서는 모의투자를 통해 투자에 대한 경험을 쌓고, 투자에 대한 이해를 높일 수 있습니다.
    - -https://investfuture.my - -## 다른 모의투자 서비스와의 차이점 -> 많은 모의투자서비스는 3가지 유형이 있습니다. -> 1. 턴제 모의투자 -> 2. 자체 시장 모의투자 -> 3. 실시간 모의투자 (거래가 차트에 반영되지 않음) -> -> 기존 모의투자 서비스는 실제 시장의 추세를 따라가지 않거나, 따라가더라도 사용자의 투자 행동이 차트에 반영되지 않는 문제가 있었습니다.
    +# 🚀 Invest Future (IF) +## 실시간 시장 영향을 체험하는 혁신적인 암호화폐 모의투자 플랫폼 + +> **"만약에 이때 투자했다면 지금 얼마를 벌었을까?"** +> +> 누구나 한 번쯤 해본 생각입니다. 하지만 현실적인 제약으로 인해 투자 경험을 쌓기는 쉽지 않죠. > -> Invest Future(IF)는 실제 시장의 추세를 따라가고, 사용자의 투자 행동이 차트에 반영되는 모의투자 서비스입니다. +> **Invest Future(IF)**에서는 **실제 시장과 동일한 영향력을 체험**할 수 있는 모의투자를 통해 +> 안전하게 투자 경험을 쌓고 전문성을 기를 수 있습니다. + +**🌐 서비스 URL:** https://investfuture.my + +--- + +### 📊 프로젝트 개요 +- **개발 기간**: 2025.04 ~ 2025.08 (4개월) +- **총 커밋**: 331개 (14개 feature branch 병합) +- **테스트 커버리지**: 96% +- **핵심 기술**: React 19, TypeScript, React Router v7, XState, STOMP WebSocket +- **아키텍처**: Feature-Sliced Design (FSD) +- **배포**: Docker 컨테이너화 + +## 🎯 핵심 혁신: "멀티버스 문제" 해결 + +### 기존 모의투자 서비스의 한계 +기존 모의투자 서비스는 3가지 주요 한계가 있었습니다: + +| 유형 | 문제점 | 현실성 | +|------|--------|--------| +| **턴제 모의투자** | 실시간 거래 경험 불가 | ❌ 비현실적 | +| **자체 시장 모의투자** | 실제 시장과 완전 분리 | ❌ 비현실적 | +| **실시간 모의투자** | 거래가 차트에 반영되지 않음 | ⚠️ 부분적 현실성 | + +### 🚀 IF의 혁신적 해결책 + +> **핵심 아이디어**: 사용자의 거래가 실제로 차트에 영향을 미치면서도, 실제 시장 가격과의 동기화를 유지 + +**문제**: 사용자 거래가 차트에 반영되면 시간이 지날수록 실제 시장과의 괴리가 커집니다. (멀티버스 현상) + +**해결**: **자동화된 봇 시스템**이 실시간으로 시장 차이를 감지하고 **매수/매도 주문을 통해 가격을 보간**합니다. + +차트 시연 + +#### ✨ 결과 +- ✅ **사용자 거래의 실제 시장 영향** 체험 +- ✅ **실제 시장 가격과의 동기화** 유지 +- ✅ **현실적인 거래 경험** 제공 + + + +## 💻 주요 기능 + +### 🔥 실시간 거래 시스템 +- **실시간 호가창**: STOMP WebSocket을 통한 실시간 매수/매도 호가 표시 +- **즉시 체결**: 시장가/지정가 주문 지원 및 실시간 체결 처리 +- **실시간 차트**: 사용자 거래가 즉시 반영되는 캔들스틱 차트 +- **다중 시간프레임**: 1분, 5분, 15분, 30분 차트 지원 + +### 📊 고급 차트 기능 +- **무한 스크롤**: 과거 데이터 자동 로딩으로 히스토리 분석 가능 +- **기술적 지표**: 볼린저 밴드, 이동평균선 등 차트 분석 도구 +- **인터랙티브 툴팁**: 마우스 호버 시 상세 정보 표시 +- **실시간 업데이트**: WebSocket을 통한 지연 없는 가격 반영 + +### 👤 포트폴리오 관리 +- **자산 현황**: 실시간 포트폴리오 가치 계산 및 수익률 표시 +- **파이 차트**: 자산 배분 시각화 (클릭 시 해당 코인으로 스크롤) +- **거래 내역**: 체결/미체결 주문 관리 및 주문 취소 기능 +- **페이지네이션**: 대량 거래 내역의 효율적 탐색 + +### 🤖 AI 채팅 도우미 +- **채팅 상담**: 투자 관련 질문 및 시장 분석 지원 + +### 🔐 보안 & 인증 +- **카카오 OAuth**: 간편한 소셜 로그인 +- **JWT 세션**: HttpOnly + Secure 쿠키로 XSS 공격 방지 +- **보호된 라우팅**: 인증 기반 페이지 접근 제어 +- **토큰 보안**: Access Token을 안전한 쿠키에 저장하여 클라이언트 사이드 스크립트 접근 차단 + +## 🛠️ 기술 스택 & 아키텍처 -## How it works? -> 기존 실시간 모의투자 서비스에서 사용자의 투자 행동이 차트에 반영되지 않는 이유는 사용자의 투자 행동이 차트에 반영될경우 시간이 지날수록 실제 시장의 차트와 모의투자 서비스의 차트의 괴리가 커지기 때문입니다.
    -> 비유하자면 시간이 지날수록 멀티버스가 발생합니다. -> -> Invest Future(IF)는 사용자의 투자 행동으로 서비스의 차트와 실제 시장의 차트의 괴리가 커지면 자체 매수봇과 매도봇이 매수 매도를 하면서 실제 시장과의 차이를 보간합니다. -chart +### 💡 기술 스택 +``` +Frontend Framework → React 19 + TypeScript +Routing & SSR → React Router v7 (Framework Mode) +State Management → XState (이벤트 기반) + React Context +Real-time Communication → STOMP.js WebSocket +Charts & Visualization → AmCharts 5 → Recharts → Lightweight Charts +Styling → Tailwind CSS v4 + Pretendard Font +Build Tool → Vite 6 +Testing → Vitest + React Testing Library + MSW +Code Quality → Biome (Linting & Formatting) +CI/CD → GitHub Actions + Docker +``` +### 🏗️ 아키텍처 설계 원칙 +#### 1. **Feature-Sliced Design (FSD)** +엔터프라이즈급 확장성을 위한 계층형 아키텍처 +``` +src/ +├── app/ # 애플리케이션 셸 (프로바이더, 라우팅) +├── features/ # 비즈니스 기능 (거래, 인증, 채팅, 프로필) +├── entities/ # 핵심 비즈니스 엔티티 (코인, 사용자, 주문, 세션) +├── shared/ # 재사용 가능한 인프라 (UI, API, 유틸, 훅) +└── widgets/ # 복합 UI 블록 (네비바, 인증 모달) +``` +**전통적 방식 vs FSD** +- ❌ **기존**: components, pages, hooks (기능별 분리) → 파일 찾기 어려움, 의존성 파악 곤란 +- ✅ **FSD**: 목적별 분리로 높은 응집도와 낮은 결합도 달성 -## 프로젝트 특징 +#### 2. **이벤트 드리븐 상태 관리 (XState)** +복잡한 UI 플로우를 위한 유한 상태 머신 -### 이벤트 드리븐 모델링 아키텍처 -컴포넌트 상태관리에서 이벤트 기반 모델링을 하고 구현체인 xstate를 사용했습니다. +XState 상태 다이어그램 -기존 상태 기반 모델링으로 컴포넌트를 설계할 경우 관리할 상태가 늘어날 경우 코드가 복잡해져 유지보수가 어려워진다는 단점이 있고, 기능이 추가되거나 변경될 경우 구현이 어렵다는 문제가 생깁니다. +```typescript +// 주문 폼 상태 머신 예시 +const formMachine = setup({ + types: { + context: {} as FormContext, + events: {} as FormEvents, + }, +}).createMachine({ + initial: 'idle', + states: { + idle: { on: { SUBMIT: 'validating' } }, + validating: { + invoke: { src: 'validateOrder' }, + on: { + SUCCESS: 'submitting', + ERROR: 'error' + } + }, + submitting: { /* ... */ }, + error: { /* ... */ } + } +}); +``` -이를 이벤트 기반 모델링으로 복잡한 요구사항이 주어질때 구현과 기능 변경이 쉬워지기 때문에 이벤트 드리븐 모델링을 하였습니다. -스크린샷 2025-05-19 오후 2 01 45 +**XState 도입 이유:** +- ✅ **예측 가능한 상태 변화**: 버그 감소 및 디버깅 용이성 +- ✅ **복잡한 비즈니스 로직 관리**: 주문 프로세스, 채팅 플로우 +- ✅ **시각적 상태 다이어그램**: 팀 커뮤니케이션 개선 +#### 3. **React Router v7 Framework Mode** +**현재 렌더링 전략:** +- **SSR (Server-Side Rendering)**: 모든 페이지가 서버에서 초기 렌더링 +- **Client-Side Hydration**: 클라이언트에서 React 상호작용 활성화 +- **Client-Side Navigation**: 페이지 간 이동은 클라이언트에서 처리 +- **데이터 로더**: 서버사이드 데이터 페칭으로 초기 로딩 성능 최적화 -### FSD (Feature Sliced Design) -프로젝트의 유지보수성과 확장성을 위해 FSD 아키텍처를 사용했습니다. +**주요 이점:** +- ✅ **빠른 초기 로딩**: 서버에서 완성된 HTML 제공 +- ✅ **SEO 최적화**: 검색 엔진 크롤링 지원 +- ✅ **부드러운 네비게이션**: 페이지 간 새로고침 없이 전환 +- ✅ **실시간 기능**: WebSocket 연결로 동적 업데이트 -기존 프론트엔드 아키텍처에서는 components, pages, hooks등 **본질별**로 파일을 분리했습니다. -이렇게 했을 경우 단점은 파일들이 많아지면 파일을 찾기 어렵고 서로 참조 관계를 알수 없어 유지보수성이 떨어집니다. +#### 4. **실시간 데이터 아키텍처** -FSD 아키텍처를 사용하면 파일을 목적별로 분리하여 높은 응집도를 가질 수 있게 합니다. +```typescript +// WebSocket 연결 관리 +const StompProvider = ({ children }) => { + const [client, setClient] = useState(null); + + useEffect(() => { + const stompClient = new Client({ + brokerURL: WEBSOCKET_URL, + onConnect: () => setConnected(true), + onDisconnect: () => setConnected(false), + }); + + stompClient.activate(); + setClient(stompClient); + + return () => stompClient.deactivate(); + }, []); + + return ( + + {children} + + ); +}; +``` -### React Router V7 (Framework mode) -그래프와 호가창등 동적인 컴포넌트가 많은 `/trade` 페이지는 spa로 만드는 게 적절하지만, 후에 마이페이지나 랜딩페이지등을 ssr, ssg로 만들어 SEO와 빠른 로딩속도를 가져가기 위해 framework mode를 사용했습니다. +**실시간 데이터 플로우:** +1. **WebSocket 연결** → STOMP.js 클라이언트 초기화 +2. **구독 관리** → 코인별 가격/호가 채널 구독 +3. **데이터 변환** → 원시 데이터를 차트/UI 형식으로 변환 +4. **상태 업데이트** → React 상태 관리와 연동 +5. **UI 반영** → 지연 없는 실시간 업데이트 -### Amcharts + Stomp -실시간 그래프 차트나 호가창은 amcahrts5를 사용하였습니다.
    -highcharts나 recharts등 많은 차트라이브러리를 고려했는데 canvas api + 차트 툴팁 + 잘 정리된 공식문서에 부합하는 라이브러리가 amcharts여서 채택하였습니다. +#### 5. **차트 기술 진화** +``` +AmCharts 5 → Lightweight Charts +(무한스크롤 구현 실패) → (무한스크롤 구현 성공) +``` -api 서버와의 실시간 통신은 서버의 구현에 따라 Stomp.js를 사용했습니다. +**기술 변경 배경:** +- **AmCharts 5 한계**: 뛰어난 성능과 다양한 기능을 제공했으나, **과거 데이터 무한 스크롤 구현에서 기술적 제약** 발생 +- **핵심 요구사항**: 사용자가 차트를 좌측으로 스크롤할 때 과거 데이터를 동적으로 로딩하는 기능이 필수 +- **해결책 모색**: AmCharts 5의 기본동작 override 실패로 인해 무한 스크롤 구현 실패 +**최종 선택: Lightweight Charts** +- ✅ **무한 스크롤 구현 성공**: 과거 데이터 동적 로딩 완벽 지원 +- ✅ **Canvas 기반 고성능**: 대용량 실시간 데이터 처리 +- ✅ **유연한 API**: React와의 원활한 통합 및 커스터마이징 +--- -## 1차 구현 [5.2 ~ 5.15] +## 📈 개발 타임라인 -코인 한 개(트럼프 코인)을 매수 할 수 있습니다. -사용자의 주문에 따라 그래프차트와 호가창, 실시간 체결창에 반영됩니다. +### 🏗️ 1차 MVP 개발 (2025.05.02 - 2025.05.15) +**핵심 거래 시스템 구현** +- **프로젝트 기반**: React 19 + TypeScript + Vite, FSD 아키텍처 +- **실시간 시스템**: STOMP WebSocket 연결 및 AmCharts 5 차트 구현 +- **거래 엔진**: XState 기반 주문 폼 상태 관리 및 실시간 체결 시스템 +- **인증 시스템**: 카카오 OAuth 로그인 및 JWT 세션 관리 +- **거래 기능**: 시장가/지정가 주문, 실시간 호가창, 체결 목록 https://github.com/user-attachments/assets/779b77f1-e778-4a53-b37f-d79a2dc187e8 +### 🧪 2차 테스트 강화 (2025.05.16 - 2025.06.06) +**품질 보증 및 테스트 커버리지 확대** +- **단위 테스트**: 60% 커버리지 달성, React Testing Library + Vitest +- **CI/CD 파이프라인**: SonarQube 코드 품질 관리, GitHub Actions 워크플로우 +- **AI 채팅봇**: XState 기반 채팅 상태 머신 및 메시지 컴포넌트 +- **다중 코인**: BTC, ETH 등 주요 암호화폐 거래 지원 +- **실시간 알림**: 체결 완료 시 토스트 알림 시스템 + +### ⚡ 3차 성능 개선 (2025.06.07 - 2025.06.25) +**차트 기술 전환 및 성능 최적화** +- **차트 마이그레이션**: AmCharts 5 → Lightweight Charts (무한 스크롤 구현) +- **번들 최적화**: 청크 분리, 지연 로딩, 렌더링 블록 CSS 최적화 +- **무한 스크롤**: 과거 데이터 동적 로딩 및 중복 패칭 방지 +- **기술 지표**: 볼린저 밴드, 이동평균선, 차트 툴팁 +- **모니터링**: Sentry 오류 추적 시스템 도입 + +### 🔧 유지보수 (2025.06.26 이후) +**안정성 향상 및 사용자 경험 개선** +- **포트폴리오**: 파이차트 자산 시각화, 체결 내역 페이지네이션 +- **사용자 경험**: 에러 바운더리, 로딩 상태, 반응형 레이아웃 +- **데이터 품질**: 실시간 가격 포맷팅, 호가 정렬 개선 +- **인증 개선**: 로그인 상태 검증, 자동 리다이렉트 +- **테스트 확장**: 추가 컴포넌트 및 훅 단위 테스트 + +--- + +## 🎯 핵심 성과 & 혁신 + +### 📊 개발 지표 +- **총 커밋 수**: 331개 (체계적인 버전 관리) +- **테스트 커버리지(3차 기준)**: 60% (높은 코드 품질) + +### 🚀 기술적 혁신 +1. **실시간 시장 영향 시뮬레이션**: 봇 보간 시스템 +2. **XState 기반 상태 관리**: 예측 가능한 복잡한 UI 플로우 +3. **React Router v7 선도적 도입**: 하이브리드 렌더링 전략 +4. **60% 테스트 커버리지(3차 기준)**: 프로덕션 수준의 품질 보증 + +### 💡 비즈니스 가치 +- **차별화된 사용자 경험**: 실제 시장 영향을 체험하는 유일한 모의투자 플랫폼 +- **한국 시장 특화**: 카카오 로그인, 원화 표시, 한국 시간대 지원 +- **확장 가능한 아키텍처**: FSD 패턴으로 신규 기능 추가 용이 +- **모바일 퍼스트**: 반응형 디자인으로 모든 디바이스 지원 + +--- + +## 🚀 시작하기 + +### 📋 사전 요구사항 +- Node.js 18+ +- Yarn 1.22+ +- Docker (선택사항) + +### ⚡ 빠른 실행 +```bash +# 의존성 설치 +yarn install + +# 개발 서버 실행 +yarn dev + +# 테스트 실행 +yarn test + +# 빌드 +yarn build +``` + +### 🐳 Docker 실행 +```bash +# Docker 이미지 빌드 +docker build -t invest-future . + +# 컨테이너 실행 +docker run -p 3000:3000 invest-future +``` + +### 🔧 환경 변수 설정 +```env +VITE_API_URL=https://api.investfuture.my +VITE_STOMP_URL=wss://api.investfuture.my +VITE_OAUTH_URL=https://kauth.kakao.com/oauth/authorize +VITE_APP_SECRET=your-secret-key +``` + +--- + +
    + +**⭐ 이 프로젝트가 도움이 되었다면 별표를 눌러주세요! ⭐** + +*혁신적인 암호화폐 모의투자로 안전하게 투자 경험을 쌓아보세요.* + +
    \ No newline at end of file