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 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": { diff --git a/package.json b/package.json index 5e110fa..8ee5473 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", @@ -32,11 +32,13 @@ "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", "react-router": "^7.5.3", "react-toastify": "^11.0.5", + "recharts": "^3.0.2", "ws": "^8.18.2", "xstate": "^5.19.3" }, @@ -68,6 +70,8 @@ }, "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..a8c0af0 100644 --- a/src/app/entry.client.tsx +++ b/src/app/entry.client.tsx @@ -1,36 +1,42 @@ -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(); + 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 1a6c146..f4c8e79 100644 --- a/src/app/entry.server.tsx +++ b/src/app/entry.server.tsx @@ -1,11 +1,8 @@ /* v8 ignore start */ import { PassThrough } from 'node:stream'; +import { server } from '~/mocks/server'; 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'; @@ -14,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, @@ -51,7 +57,9 @@ function handleRequest( }), ); - pipe(getMetaTagTransformer(body)); + /* Sentry 설정 제외 */ + // pipe(getMetaTagTransformer(body)); + pipe(body); }, onShellError(error: unknown) { reject(error); @@ -75,5 +83,7 @@ function handleRequest( }); } -export default wrapSentryHandleRequest(handleRequest); +/* Sentry 설정 제외 */ +// export default wrapSentryHandleRequest(handleRequest); +export default handleRequest; /* v8 ignore end */ diff --git a/src/app/provider/StompProvider.test.tsx b/src/app/provider/StompProvider.test.tsx index f7619a5..74460b5 100644 --- a/src/app/provider/StompProvider.test.tsx +++ b/src/app/provider/StompProvider.test.tsx @@ -1,36 +1,106 @@ -import { renderHook } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; - +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import StompProvider, { useStompClient } from './StompProvider'; -vi.mock('@stomp/stompjs', () => { +const brokerURL = 'ws://localhost:8080'; + +const { MockClient } = vi.hoisted(() => { return { - Client: vi.fn().mockImplementation(() => { - return { - activate: vi.fn(), - deactivate: vi.fn(), - onConnect: null, - onDisconnect: null, - onWebSocketError: null, - onStompError: null, - }; + 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(() => { + 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 = MockClient.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 = MockClient.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 = MockClient.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); }); }); diff --git a/src/app/provider/UserInfoProvider.test.tsx b/src/app/provider/UserInfoProvider.test.tsx new file mode 100644 index 0000000..3ffc34c --- /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'; + +vi.mock('window', () => { + const mockStore = new Map(); + + 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); + }); +}); diff --git a/src/app/root.tsx b/src/app/root.tsx index eec2f94..f1900f0 100644 --- a/src/app/root.tsx +++ b/src/app/root.tsx @@ -1,4 +1,4 @@ -import * as Sentry from '@sentry/react-router'; +/* v8 ignore start */ import { preload } from 'react-dom'; import { Links, @@ -12,6 +12,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 +113,22 @@ 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 ; } + +/* v8 ignore end */ diff --git a/src/app/routes.ts b/src/app/routes.ts index 8e87801..65095bd 100644 --- a/src/app/routes.ts +++ b/src/app/routes.ts @@ -1,6 +1,16 @@ /* 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', [ + route('history', './routes/history.tsx'), + ]), + ]), + ]), +] satisfies RouteConfig; /* v8 ignore end */ diff --git a/src/app/routes/callback.tsx b/src/app/routes/callback.tsx index 77e2aba..ae2261e 100644 --- a/src/app/routes/callback.tsx +++ b/src/app/routes/callback.tsx @@ -1,41 +1,76 @@ -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 { 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 cookies = cookie.parse(rawCookie); - const isAccessTokenExists = !!cookies.access_token; + const rawCookie = request.headers.get('Cookie'); + + const session = await getSession(rawCookie); + const referer = session.get('referer') || '/'; - if (!isAccessTokenExists) { - return redirect('/trade/BTC/login'); + const isLoggedIn = checkLogin(rawCookie); + + if (!isLoggedIn) { + return redirect(referer); } - const response = await ApiClient.get('api/userinfo', { - headers: { - Cookie: rawCookie, - }, - }); + try { + const response = await ApiClient.get('api/userinfo', { + headers: { + Cookie: rawCookie as string, + }, + }); + + const responseData = await response.json(); - const { data } = await response.json(); + return { userId: responseData.data.userId, referer: referer }; + } catch (error) { + throw new Error('로그인에 실패했습니다. 관리자에게 문의하세요.'); + } +} + +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 data.userId; + return ( + + ); } 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/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/history.tsx b/src/app/routes/history.tsx new file mode 100644 index 0000000..74ba1d8 --- /dev/null +++ b/src/app/routes/history.tsx @@ -0,0 +1,96 @@ +import { HTTPError } from 'ky'; +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, params }: Route.LoaderArgs) { + const rawCookie = request.headers.get('Cookie'); + const isLoggedIn = checkLogin(rawCookie); + const ticker = params.ticker; + + if (!isLoggedIn) { + return redirect(`/trade/${ticker}/login`); + } + + const { searchParams } = new URL(request.url); + const page = searchParams.get('p') ? Number(searchParams.get('p')) : 1; + const settled = searchParams.get('t') === 'settled'; + + if (page < 1) { + throw data('잘못된 요청입니다.', { status: 400 }); + } + + try { + const response = await profileApi.getHistory(page, FETCH_SIZE, settled, { + headers: { + Cookie: rawCookie as string, + }, + }); + 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 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}`; + 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) { + return ; +} diff --git a/src/app/routes/login.tsx b/src/app/routes/login.tsx new file mode 100644 index 0000000..f601252 --- /dev/null +++ b/src/app/routes/login.tsx @@ -0,0 +1,59 @@ +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 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( + { referer }, + { + headers: { + 'set-cookie': await commitSession(session), + }, + }, + ); +} + +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) { + const { referer } = loaderData; + + return ; +} diff --git a/src/app/routes/profile.tsx b/src/app/routes/profile.tsx new file mode 100644 index 0000000..1f1e8bb --- /dev/null +++ b/src/app/routes/profile.tsx @@ -0,0 +1,64 @@ +import { HTTPError } from 'ky'; +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({ request, context, params }: Route.LoaderArgs) { + const rawCookie = request.headers.get('Cookie'); + const isLoggedIn = checkLogin(rawCookie); + const ticker = params.ticker; + + if (!isLoggedIn) { + return redirect(`/trade/${ticker}/login`); + } + + try { + const response = await userApi.getUserInfo({ + headers: { + Cookie: rawCookie || '', + }, + }); + 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 ; + } + if (error instanceof Error) { + const errorTitle = error.name; + const errorDescription = error.message; + return ; + } + + return ( + + ); +} + +export default function ProfileRouteComponent({ + loaderData, +}: Route.ComponentProps) { + 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.login.tsx b/src/app/routes/trade.$ticker.login.tsx deleted file mode 100644 index 6b67006..0000000 --- a/src/app/routes/trade.$ticker.login.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { LoginModal } from '~/widgets/auth'; - -export default function LoginRouteComponent() { - return ; -} diff --git a/src/app/routes/trade.$ticker.tsx b/src/app/routes/trade.tsx similarity index 77% rename from src/app/routes/trade.$ticker.tsx rename to src/app/routes/trade.tsx index 701988b..889fbb7 100644 --- a/src/app/routes/trade.$ticker.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'; @@ -10,11 +10,13 @@ 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 ErrorComponent from '~/shared/ui/Error'; 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')); @@ -42,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) { @@ -80,18 +102,26 @@ export default function TradeRouteComponent({ />
{coinInfo && ( - + )}
실시간 차트 - + + +
-
+
주문 하기 {isLoggedIn && coinInfo ? ( @@ -105,7 +135,12 @@ export default function TradeRouteComponent({ 실시간 호가 - {coinInfo && } + {coinInfo && ( + + )}
diff --git a/src/app/sessions.server.ts b/src/app/sessions.server.ts new file mode 100644 index 0000000..a6efb1f --- /dev/null +++ b/src/app/sessions.server.ts @@ -0,0 +1,29 @@ +import { createCookieSessionStorage } from 'react-router'; + +type SessionData = { + userId: string; + referer: string; +}; + +type SessionFlashData = { + error: string; +}; + +const MINUTE = 60; + +const { getSession, commitSession, destroySession } = + createCookieSessionStorage({ + cookie: { + name: '__session', + + httpOnly: true, + maxAge: MINUTE * 60 * 24, + + path: '/', + sameSite: 'lax', + secrets: [String(import.meta.env.VITE_APP_SECRET)], + secure: true, + }, + }); + +export { getSession, commitSession, destroySession }; 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/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/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'; 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(); + }); +}); diff --git a/src/entities/coin/types/coin.type.ts b/src/entities/coin/types/coin.type.ts index 35b9b44..77e2fcc 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/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); + }); }); diff --git a/src/entities/coin/ui/CoinPriceWithName/index.tsx b/src/entities/coin/ui/CoinPriceWithName/index.tsx index 14afad4..cbb9539 100644 --- a/src/entities/coin/ui/CoinPriceWithName/index.tsx +++ b/src/entities/coin/ui/CoinPriceWithName/index.tsx @@ -1,20 +1,32 @@ import { formatCurrencyKR } from '~/shared/utils'; +import { convertBase64ToSvg } 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 +34,7 @@ export default function CoinPriceWithName({
- {price ? formatCurrencyKR(price.currentPrice) : '-'}원 + {formatCurrencyKR(displayPrice)}원
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); + }); }); diff --git a/src/entities/coin/ui/CoinWithIconAndName/index.tsx b/src/entities/coin/ui/CoinWithIconAndName/index.tsx index 5e33017..0b853cb 100644 --- a/src/entities/coin/ui/CoinWithIconAndName/index.tsx +++ b/src/entities/coin/ui/CoinWithIconAndName/index.tsx @@ -1,20 +1,29 @@ -import type { ReactElement } from 'react'; +import { convertBase64ToSvg } from '~/shared/utils'; 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/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/entities/user/index.ts b/src/entities/user/index.ts index 41fc40d..3829c50 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, 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 e722c01..fa96598 100644 --- a/src/entities/user/types/user.type.ts +++ b/src/entities/user/types/user.type.ts @@ -1,20 +1,22 @@ 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 = { +export type UserInfoResponseData = { userId: number; email: string; nickname: string; provider: string; cash: number; + totalAssetAmount: number; wallets: Wallet[]; }; diff --git a/src/features/chat/ui/AIChatBot/AIChatBot.test.tsx b/src/features/chat/ui/AIChatBot/AIChatBot.test.tsx index 7426543..9ead39f 100644 --- a/src/features/chat/ui/AIChatBot/AIChatBot.test.tsx +++ b/src/features/chat/ui/AIChatBot/AIChatBot.test.tsx @@ -1,17 +1,82 @@ -import { - render, - screen, - waitForElementToBeRemoved, -} from '@testing-library/react'; +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'; -vi.mock('~/shared/hooks/useScrollToBottom', () => ({ +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(); @@ -26,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'); @@ -41,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'); @@ -53,8 +114,52 @@ describe('AIChatBot 컴포넌트 테스트', () => { await user.click(closeButton); - await waitForElementToBeRemoved(() => screen.queryByTestId('chat-window')); + await waitFor(() => { + expect(screen.queryByTestId('chat-window')).not.toBeInTheDocument(); + }); + + 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: '↑' })); - expect(chatWindow).not.toBeInTheDocument(); + await waitFor( + () => { + expect(screen.getByText(AI_ANSWER)).toBeInTheDocument(); + }, + { timeout: 2000 }, + ); }); }); 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/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)}%
diff --git a/src/features/profile/api/history.endpoint.ts b/src/features/profile/api/history.endpoint.ts new file mode 100644 index 0000000..6992c9f --- /dev/null +++ b/src/features/profile/api/history.endpoint.ts @@ -0,0 +1,27 @@ +import ApiClient from '~/shared/api/httpClient'; +import type { HistoryResponse } from '../types/tradingHistory.type'; + +export default { + getHistory: ( + page?: number, + size?: number, + settled?: boolean, + init?: RequestInit, + ) => { + const params = new URLSearchParams(); + + if (page) params.set('page', page.toString()); + if (size) params.set('size', size.toString()); + + if (settled) params.set('settled', 'true'); + else params.set('settled', 'false'); + + return ApiClient.get( + `api/userinfo/trades?${params.toString()}`, + init, + ); + }, + deleteHistory: (orderId: string, init?: RequestInit) => { + return ApiClient.delete(`api/userinfo/trades?orderId=${orderId}`, init); + }, +}; 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, + ); + }); +}); diff --git a/src/features/profile/const/chart.const.ts b/src/features/profile/const/chart.const.ts new file mode 100644 index 0000000..627b410 --- /dev/null +++ b/src/features/profile/const/chart.const.ts @@ -0,0 +1,28 @@ +export const TABLE_HEAD_HEIGHT = 32; +export const TABLE_ROW_HEIGHT = 36; + +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..e47ba4f --- /dev/null +++ b/src/features/profile/index.ts @@ -0,0 +1,9 @@ +export { default as CoinPieChart } from './ui/CoinPieChart'; +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/chart.type.ts b/src/features/profile/types/chart.type.ts new file mode 100644 index 0000000..b1e72a2 --- /dev/null +++ b/src/features/profile/types/chart.type.ts @@ -0,0 +1,10 @@ +export type CoinPieChartData = { + ticker: string; + totalPrice: number; + averagePrice: number; + quantity: number; + roi: number; + accountId: number; + name: string; + currentPrice: number; +}; diff --git a/src/features/profile/types/tradingHistory.type.ts b/src/features/profile/types/tradingHistory.type.ts new file mode 100644 index 0000000..ad708c3 --- /dev/null +++ b/src/features/profile/types/tradingHistory.type.ts @@ -0,0 +1,61 @@ +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', +} + +type BaseOrder = { + orderId: string; + ticker: string; + name: string; + 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; + 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 = { + orderList: TradingHistory[]; + totalPages: number; + currentPage: number; + pageSize: number; + totalElements: number; +}; + +export type HistoryResponse = Response; diff --git a/src/features/profile/ui/AssetInfoGraphic/index.tsx b/src/features/profile/ui/AssetInfoGraphic/index.tsx new file mode 100644 index 0000000..0164f22 --- /dev/null +++ b/src/features/profile/ui/AssetInfoGraphic/index.tsx @@ -0,0 +1,70 @@ +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 assetData = generateCoinPieChartData(userInfo); + const coinData = assetData.filter((item) => item.ticker !== 'KRW'); + const roiAverage = + wallets.length > 0 + ? wallets.reduce((acc, item) => acc + item.roi, 0) / wallets.length + : 0; + + 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/AssetInfoGraphicText/index.tsx b/src/features/profile/ui/AssetInfoGraphicText/index.tsx new file mode 100644 index 0000000..df34031 --- /dev/null +++ b/src/features/profile/ui/AssetInfoGraphicText/index.tsx @@ -0,0 +1,36 @@ +import clsx from 'clsx'; +import IncrementingNumber from '~/shared/ui/IncrementingNumber'; + +type AssetInfoGraphicTextProps = { + label: string; + type: 'money' | 'percent'; + value: number; +}; + +export default function AssetInfoGraphicText({ + label, + type, + value, +}: AssetInfoGraphicTextProps) { + const valueUnit = type === 'money' ? '원' : '%'; + const color = + value > 0 ? 'text-red-500' : value < 0 ? 'text-blue-500' : 'text-gray-800'; + const valueTextClassName = type === 'percent' ? color : ''; + + return ( +
+

{label}

+

+ + {value} + + {valueUnit} +

+
+ ); +} diff --git a/src/features/profile/ui/CoinAssetTable/index.tsx b/src/features/profile/ui/CoinAssetTable/index.tsx new file mode 100644 index 0000000..45277f5 --- /dev/null +++ b/src/features/profile/ui/CoinAssetTable/index.tsx @@ -0,0 +1,86 @@ +import type { RefObject } from 'react'; +import NoContent from '~/shared/ui/NoContent'; +import { formatCurrencyKR } from '~/shared/utils'; +import { + COLORS, + TABLE_HEAD_HEIGHT, + TABLE_ROW_HEIGHT, +} from '../../const/chart.const'; +import type { CoinPieChartData } from '../../types/chart.type'; + +type CoinAssetTableProps = { + coinData: CoinPieChartData[]; + ref?: RefObject; +}; + +export default function CoinAssetTable({ coinData, ref }: CoinAssetTableProps) { + return ( +
+ + + + + + + + + + {coinData.length ? ( + + {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/features/profile/ui/CoinPieChart/index.tsx b/src/features/profile/ui/CoinPieChart/index.tsx new file mode 100644 index 0000000..161b0f5 --- /dev/null +++ b/src/features/profile/ui/CoinPieChart/index.tsx @@ -0,0 +1,40 @@ +import { Cell, Pie, PieChart, ResponsiveContainer } from 'recharts'; +import { COLORS } from '../../const/chart.const'; +import type { CoinPieChartData } from '../../types/chart.type'; +import CoinPieChartActiveShape from '../CoinPieChartActiveShape'; + +export type CoinPieChartProps = { + coinData: CoinPieChartData[]; + onClick?: (data: CoinPieChartData) => void; +}; + +export default function CoinPieChart({ coinData, onClick }: CoinPieChartProps) { + const handleClick = ({ payload }: { payload: CoinPieChartData }) => { + onClick?.(payload); + }; + + return ( + + + + {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..1f839fd --- /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(0)}%`} + + ); +} 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/ui/TradingHistoryCancelButton/index.tsx b/src/features/profile/ui/TradingHistoryCancelButton/index.tsx new file mode 100644 index 0000000..f0a1684 --- /dev/null +++ b/src/features/profile/ui/TradingHistoryCancelButton/index.tsx @@ -0,0 +1,56 @@ +import type { ButtonHTMLAttributes } from 'react'; +import { useFetcher, useLocation } from 'react-router'; +import Spinner from '~/shared/ui/Spinner'; +import { + OrderStatus, + type TradingHistory, +} from '../../types/tradingHistory.type'; + +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: + text = '취소'; + break; + case OrderStatus.SETTLED: + text = '체결완료'; + break; + } + + const isDisabled = status === OrderStatus.SETTLED || isDeleting; + + return ( + <> + {isDeleting ? ( + + ) : ( + + + + + )} + + ); +} diff --git a/src/features/profile/ui/TradingHistoryList/index.tsx b/src/features/profile/ui/TradingHistoryList/index.tsx new file mode 100644 index 0000000..64c365a --- /dev/null +++ b/src/features/profile/ui/TradingHistoryList/index.tsx @@ -0,0 +1,98 @@ +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 { HistoryResponseData } from '../../types/tradingHistory.type'; +import TradingHistoryListItem from '../TradingHistoryListItem'; + +type TradingHistoryListProps = { + historyData: HistoryResponseData; +}; + +export default function TradingHistoryList({ + historyData, +}: TradingHistoryListProps) { + const scrollContainerRef = useScrollTo([], { + top: 0, + behavior: 'instant', + }); + + const [searchParams, setSearchParams] = useSearchParams({ + p: '1', + t: 'unsettled', + }); + 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 }); + }; + + 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 ( +
+ +
+ 매매종류 + 종목 + 가격 + 수량 + 거래시간 + + {tab === 'unsettled' ? '주문 취소' : '상태'} + +
+
    + {historyData.orderList.length ? ( + historyData.orderList.map((item) => ( + + )) + ) : ( + + )} +
+ +
+ ); +} diff --git a/src/features/profile/ui/TradingHistoryListItem/index.tsx b/src/features/profile/ui/TradingHistoryListItem/index.tsx new file mode 100644 index 0000000..aac3ab6 --- /dev/null +++ b/src/features/profile/ui/TradingHistoryListItem/index.tsx @@ -0,0 +1,40 @@ +import { formatDateKr } from '~/features/tradeview/utils'; +import { formatCurrencyKR } from '~/shared/utils'; +import { + OrderType, + Side, + type TradingHistory, +} from '../../types/tradingHistory.type'; +import TradingHistoryCancelButton from '../TradingHistoryCancelButton'; + +type TradingHistoryListItemProps = TradingHistory; + +export default function TradingHistoryListItem({ + tradeTime, + orderId, + ...props +}: Readonly) { + const { side, ticker, orderStatus, orderType } = props; + const typeText = side === Side.ASK ? '매도' : '매수'; + const priceText = + side === Side.ASK && orderType === OrderType.MARKET + ? '시장가 매도' + : `${formatCurrencyKR(props.price)}원`; + const sizeText = + side === Side.BID && orderType === OrderType.MARKET + ? '시장가 매수' + : `${formatCurrencyKR(props.orderSize)}개`; + + return ( +
  • + {typeText} + {ticker} + {priceText} + {sizeText} + {formatDateKr(new Date(tradeTime))} +
    + +
    +
  • + ); +} diff --git a/src/features/profile/utils/index.ts b/src/features/profile/utils/index.ts new file mode 100644 index 0000000..0520172 --- /dev/null +++ b/src/features/profile/utils/index.ts @@ -0,0 +1,32 @@ +import type { UserInfoResponseData } from '~/entities/user'; +import type { CoinPieChartData } from '../types/chart.type'; + +export function generateCoinPieChartData( + data: UserInfoResponseData, +): CoinPieChartData[] { + const pieChartData = data.wallets + .map((item) => ({ + 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); + + pieChartData.push({ + name: '원화', + ticker: 'KRW', + accountId: 0, + totalPrice: data.cash, + averagePrice: data.cash, + quantity: 0, + roi: 0, + currentPrice: data.cash, + }); + + return pieChartData; +} 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..ae43521 100644 --- a/src/features/tradeview/ui/Orderbook/chart.tsx +++ b/src/features/tradeview/ui/Orderbook/chart.tsx @@ -1,167 +1,65 @@ -import * as am5 from '@amcharts/amcharts5'; -import * as am5xy from '@amcharts/amcharts5/xy'; -import { useEffect, useLayoutEffect, useRef } from 'react'; +import clsx from 'clsx'; +import { + Bar, + BarChart, + LabelList, + ResponsiveContainer, + XAxis, + YAxis, +} from 'recharts'; 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 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' ? '#FDD2D7' : '#CDE0FE'; + // 매도는 price 기준 내림차순 정렬 + const displayData = + type === 'bear' ? [...data].sort((a, b) => b.price - a.price) : data; return (
    + className={clsx( + 'flex h-full min-h-full w-full px-1', + type === 'bull' ? 'bg-red-50' : 'bg-blue-50', + )} + > +
    + {displayData.map((item) => ( +
    + {formatCurrencyKR(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 && }
    ); } 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/mocks/dummy.ts b/src/mocks/dummy.ts new file mode 100644 index 0000000..c4726d3 --- /dev/null +++ b/src/mocks/dummy.ts @@ -0,0 +1,442 @@ +import type { UserInfoResponseData } from '~/entities/user'; +import { + OrderStatus, + OrderType, + Side, + type TradingHistory, +} from '~/features/profile/types/tradingHistory.type'; + +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, + }, + ], +}; + +export const DUMMY_HISTORY_LIST: TradingHistory[] = [ + { + orderId: '1', + side: Side.ASK, + orderType: OrderType.LIMIT, + ticker: 'BTC', + name: 'BTC', + orderSize: 1, + price: 100000, + orderStatus: OrderStatus.UNSETTLED, + remainingSize: 1, + displaySize: 1, + tradeTime: '2025-07-23T21:02:59.000Z', + }, + { + orderId: '2', + side: Side.BID, + orderType: OrderType.MARKET, + ticker: 'ETH', + name: 'ETH', + orderStatus: OrderStatus.SETTLED, + tradeTime: '2025-07-23T21:02:59.000Z', + price: 200000, + }, + { + orderId: '3', + side: Side.ASK, + orderType: OrderType.MARKET, + ticker: 'XRP', + name: 'XRP', + orderStatus: OrderStatus.UNSETTLED, + tradeTime: '2025-07-23T21:02:59.000Z', + orderSize: 1, + remainingSize: 1, + displaySize: 1, + }, + { + orderId: '4', + side: Side.BID, + orderType: OrderType.LIMIT, + ticker: 'ADA', + name: 'Cardano', + price: 600, + orderSize: 300, + remainingSize: 150, + displaySize: 300, + orderStatus: OrderStatus.SETTLED, + tradeTime: '2025-07-23T20:58:30.000Z', + }, + { + orderId: '5', + side: Side.ASK, + orderType: OrderType.LIMIT, + ticker: 'SOL', + name: 'Solana', + price: 65000, + orderSize: 5, + remainingSize: 2, + displaySize: 5, + orderStatus: OrderStatus.UNSETTLED, + tradeTime: '2025-07-23T20:55:15.000Z', + }, + { + orderId: '6', + side: Side.BID, + orderType: OrderType.MARKET, + ticker: 'DOGE', + name: 'Dogecoin', + price: 100000, + orderStatus: OrderStatus.SETTLED, + tradeTime: '2025-07-23T20:52:45.000Z', + }, + { + orderId: '7', + side: Side.ASK, + orderType: OrderType.LIMIT, + ticker: 'MATIC', + name: 'Polygon', + price: 800, + orderSize: 200, + remainingSize: 80, + displaySize: 200, + orderStatus: OrderStatus.IN_PROGRESS, + tradeTime: '2025-07-23T20:50:20.000Z', + }, + { + orderId: '8', + side: Side.BID, + orderType: OrderType.LIMIT, + ticker: 'DOT', + name: 'Polkadot', + price: 8500, + orderSize: 15, + remainingSize: 5, + displaySize: 15, + orderStatus: OrderStatus.UNSETTLED, + tradeTime: '2025-07-23T20:48:10.000Z', + }, + { + orderId: '9', + side: Side.ASK, + orderType: OrderType.MARKET, + ticker: 'LINK', + name: 'Chainlink', + orderSize: 1, + remainingSize: 1, + displaySize: 1, + orderStatus: OrderStatus.SETTLED, + tradeTime: '2025-07-23T20:45:35.000Z', + }, + { + orderId: '10', + side: Side.BID, + orderType: OrderType.LIMIT, + ticker: 'AVAX', + name: 'Avalanche', + price: 18000, + orderSize: 25, + remainingSize: 10, + displaySize: 25, + orderStatus: OrderStatus.IN_PROGRESS, + tradeTime: '2025-07-23T20:42:50.000Z', + }, + { + orderId: '11', + side: Side.ASK, + orderType: OrderType.LIMIT, + ticker: 'LTC', + name: 'Litecoin', + price: 70000, + orderSize: 8, + remainingSize: 3, + displaySize: 8, + orderStatus: OrderStatus.UNSETTLED, + tradeTime: '2025-07-23T20:40:25.000Z', + }, + { + orderId: '12', + side: Side.BID, + orderType: OrderType.MARKET, + ticker: 'BNB', + name: 'Binance Coin', + price: 100000, + orderStatus: OrderStatus.SETTLED, + tradeTime: '2025-07-23T20:38:15.000Z', + }, + { + orderId: '13', + side: Side.ASK, + orderType: OrderType.LIMIT, + ticker: 'ATOM', + name: 'Cosmos', + price: 8400, + orderSize: 30, + remainingSize: 12, + displaySize: 30, + orderStatus: OrderStatus.UNSETTLED, + tradeTime: '2025-07-23T20:35:40.000Z', + }, + { + orderId: '14', + side: Side.BID, + orderType: OrderType.MARKET, + ticker: 'SHIB', + name: 'Shiba Inu', + price: 100000, + orderStatus: OrderStatus.SETTLED, + tradeTime: '2025-07-23T20:33:20.000Z', + }, + { + orderId: '15', + side: Side.ASK, + orderType: OrderType.LIMIT, + ticker: 'XLM', + name: 'Stellar', + price: 200, + orderSize: 500, + remainingSize: 200, + displaySize: 500, + orderStatus: OrderStatus.IN_PROGRESS, + tradeTime: '2025-07-23T20:30:55.000Z', + }, + { + orderId: '16', + side: Side.BID, + orderType: OrderType.LIMIT, + ticker: 'UNI', + name: 'Uniswap', + price: 6500, + orderSize: 40, + remainingSize: 15, + displaySize: 40, + orderStatus: OrderStatus.UNSETTLED, + tradeTime: '2025-07-23T20:28:30.000Z', + }, + { + orderId: '17', + side: Side.ASK, + orderType: OrderType.MARKET, + ticker: 'SAND', + name: 'The Sandbox', + orderSize: 1, + remainingSize: 1, + displaySize: 1, + orderStatus: OrderStatus.SETTLED, + tradeTime: '2025-07-23T20:26:15.000Z', + }, + { + orderId: '18', + side: Side.BID, + orderType: OrderType.LIMIT, + ticker: 'MANA', + name: 'Decentraland', + price: 380, + orderSize: 100, + remainingSize: 45, + displaySize: 100, + orderStatus: OrderStatus.IN_PROGRESS, + tradeTime: '2025-07-23T20:24:00.000Z', + }, + { + orderId: '19', + side: Side.ASK, + orderType: OrderType.LIMIT, + ticker: 'ALGO', + name: 'Algorand', + price: 150, + orderSize: 800, + remainingSize: 300, + displaySize: 800, + orderStatus: OrderStatus.UNSETTLED, + tradeTime: '2025-07-23T20:21:45.000Z', + }, + { + orderId: '20', + side: Side.BID, + orderType: OrderType.MARKET, + ticker: 'FTM', + name: 'Fantom', + price: 250, + orderStatus: OrderStatus.SETTLED, + tradeTime: '2025-07-23T20:19:30.000Z', + }, + { + orderId: '21', + side: Side.ASK, + orderType: OrderType.LIMIT, + ticker: 'NEAR', + name: 'NEAR Protocol', + price: 2800, + orderSize: 60, + remainingSize: 25, + displaySize: 60, + orderStatus: OrderStatus.IN_PROGRESS, + tradeTime: '2025-07-23T20:17:10.000Z', + }, + { + orderId: '22', + side: Side.BID, + orderType: OrderType.LIMIT, + ticker: 'HBAR', + name: 'Hedera', + price: 50, + orderSize: 2000, + remainingSize: 800, + displaySize: 2000, + orderStatus: OrderStatus.UNSETTLED, + tradeTime: '2025-07-23T20:14:55.000Z', + }, + { + orderId: '23', + side: Side.ASK, + orderType: OrderType.MARKET, + ticker: 'VET', + name: 'VeChain', + orderSize: 1, + remainingSize: 1, + displaySize: 1, + orderStatus: OrderStatus.SETTLED, + tradeTime: '2025-07-23T20:12:35.000Z', + }, + { + orderId: '24', + side: Side.BID, + orderType: OrderType.LIMIT, + ticker: 'ICP', + name: 'Internet Computer', + price: 4200, + orderSize: 12, + remainingSize: 4, + displaySize: 12, + orderStatus: OrderStatus.IN_PROGRESS, + tradeTime: '2025-07-23T20:10:20.000Z', + }, + { + orderId: '25', + side: Side.ASK, + orderType: OrderType.LIMIT, + ticker: 'FLOW', + name: 'Flow', + price: 650, + orderSize: 150, + remainingSize: 60, + displaySize: 150, + orderStatus: OrderStatus.UNSETTLED, + tradeTime: '2025-07-23T20:08:05.000Z', + }, + { + orderId: '26', + side: Side.BID, + orderType: OrderType.MARKET, + ticker: 'THETA', + name: 'Theta Network', + price: 100000, + orderStatus: OrderStatus.SETTLED, + tradeTime: '2025-07-23T20:05:50.000Z', + }, + { + orderId: '27', + side: Side.ASK, + orderType: OrderType.LIMIT, + ticker: 'EGLD', + name: 'MultiversX', + price: 15000, + orderSize: 6, + remainingSize: 2, + displaySize: 6, + orderStatus: OrderStatus.IN_PROGRESS, + tradeTime: '2025-07-23T20:03:30.000Z', + }, + { + orderId: '28', + side: Side.BID, + orderType: OrderType.LIMIT, + ticker: 'XTZ', + name: 'Tezos', + price: 900, + orderSize: 90, + remainingSize: 35, + displaySize: 90, + orderStatus: OrderStatus.UNSETTLED, + tradeTime: '2025-07-23T20:01:15.000Z', + }, + { + orderId: '29', + side: Side.ASK, + orderType: OrderType.MARKET, + ticker: 'AAVE', + name: 'Aave', + orderSize: 1, + remainingSize: 1, + displaySize: 1, + orderStatus: OrderStatus.SETTLED, + tradeTime: '2025-07-23T19:59:00.000Z', + }, + { + orderId: '30', + side: Side.BID, + orderType: OrderType.LIMIT, + ticker: 'CRV', + name: 'Curve DAO Token', + price: 320, + orderSize: 200, + remainingSize: 75, + displaySize: 200, + orderStatus: OrderStatus.IN_PROGRESS, + tradeTime: '2025-07-23T19:56:45.000Z', + }, +]; diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index a422d26..7d7734e 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -1,12 +1,88 @@ /* v8 ignore start */ import { http, HttpResponse } from 'msw'; +import { + type HistoryResponseData, + OrderStatus, +} from '~/features/profile/types/tradingHistory.type'; +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}`; +} + +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, + }); + }), + http.get(api('userinfo/trades'), 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'; + + const filteredOrderlist = historyList.filter((item) => + settled + ? item.orderStatus === OrderStatus.SETTLED + : item.orderStatus !== OrderStatus.SETTLED, + ); + + const firstItemIndex = (page - 1) * size; + const lastItemIndex = page * size - 1; + + const historyData: HistoryResponseData = { + 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, + }); + } + + return HttpResponse.json(successResponse(historyData), { + 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 */ 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/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/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; } 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; +} diff --git a/src/shared/ui/Button/index.tsx b/src/shared/ui/Button/index.tsx index f7874d7..549edeb 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' | 'warn'; } & ButtonHTMLAttributes; -export default function Button({ children, ...props }: ButtonProps) { +export default function Button({ + children, + buttonStyle = 'primary', + ...props +}: ButtonProps) { return ( +
    +
    + ); +} diff --git a/src/shared/ui/ErrorModal/index.tsx b/src/shared/ui/ErrorModal/index.tsx new file mode 100644 index 0000000..66fa04b --- /dev/null +++ b/src/shared/ui/ErrorModal/index.tsx @@ -0,0 +1,28 @@ +import { useRef } from 'react'; +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'; + +type ErrorModalProps = ErrorComponentProps; + +export default function ErrorModal({ title, description }: ErrorModalProps) { + const referer = useCustomReferer(); + const navigate = useNavigate(); + const modalRef = useRef(null); + + useClickOutside(modalRef, () => navigate(referer || '/trade/BTC')); + + return ( + + +
    + +
    +
    +
    + ); +} diff --git a/src/shared/ui/IncrementingNumber/index.tsx b/src/shared/ui/IncrementingNumber/index.tsx new file mode 100644 index 0000000..a8f7993 --- /dev/null +++ b/src/shared/ui/IncrementingNumber/index.tsx @@ -0,0 +1,39 @@ +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); + + if (typeof children !== 'number' || Number.isNaN(number)) { + throw new Error('children must be a number'); + } + + 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]); + + return {rounded}; +} 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/shared/ui/NoContent/index.tsx b/src/shared/ui/NoContent/index.tsx new file mode 100644 index 0000000..7b15950 --- /dev/null +++ b/src/shared/ui/NoContent/index.tsx @@ -0,0 +1,35 @@ +import Lottie from 'lottie-react'; +import type { CSSProperties } from 'react'; + +import NoContentAnimation from '~/assets/lotties/no-content.json'; +import ClientOnly from '../ClientOnly'; + +export type NoContentProps = { + title: string; + description?: string; + style?: CSSProperties; +}; + +export default function NoContent({ + title, + description, + style, +}: NoContentProps) { + return ( +
    + + + +

    {title}

    + {description &&

    {description}

    } +
    + ); +} diff --git a/src/shared/ui/Pagination/index.tsx b/src/shared/ui/Pagination/index.tsx new file mode 100644 index 0000000..ecfad34 --- /dev/null +++ b/src/shared/ui/Pagination/index.tsx @@ -0,0 +1,70 @@ +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) => ( + + ))} + +
    + ); +} 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); + } +} diff --git a/src/shared/ui/Tab/index.tsx b/src/shared/ui/Tab/index.tsx new file mode 100644 index 0000000..c72ad3d --- /dev/null +++ b/src/shared/ui/Tab/index.tsx @@ -0,0 +1,54 @@ +import { motion } from 'motion/react'; +import { Fragment, 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 && ( +
    • +
      +
    • + )} +
      + ))} +
    +
    + ); +} diff --git a/src/shared/utils/index.ts b/src/shared/utils/index.ts index d38b5c0..189514f 100644 --- a/src/shared/utils/index.ts +++ b/src/shared/utils/index.ts @@ -36,3 +36,13 @@ 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'); +} + +export function convertBase64ToSvg(base64: string) { + return `data:image/svg+xml;base64,${base64}`; +} 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; +} 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 9459ea7..fb18d1f 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(); @@ -32,12 +39,24 @@ export default function NavBar({ }; const LoginButton = () => ( - + ); - const LogoutButton = () => ; + const LogoutButton = () => ( + + ); + + const ProfileButton = () => ( + + + + ); return ( <> @@ -48,7 +67,10 @@ export default function NavBar({ - {isLoggedIn ? : } +
    + {isLoggedIn ? : null} + {isLoggedIn ? : } +
    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..741ad3f --- /dev/null +++ b/src/widgets/user/ui/ProfileModal/index.tsx @@ -0,0 +1,34 @@ +import { useRef } from 'react'; +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'; + +type ProfileModalProps = { + userInfo: UserInfoResponseData; +}; + +export default function ProfileModal({ userInfo }: ProfileModalProps) { + const referer = useCustomReferer(); + const navigate = useNavigate(); + const modalRef = useRef(null); + + useClickOutside(modalRef, () => navigate(referer || '/trade/BTC')); + + return ( + + +
    + +
    + +
    +
    +
    +
    + ); +} 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'], diff --git a/yarn.lock b/yarn.lock index cf2ffed..edbb75e 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" @@ -4979,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" @@ -5731,6 +5785,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 +5830,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 +5855,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 +5932,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 +6425,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 +6654,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 +6687,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"